pax_global_header 0000666 0000000 0000000 00000000064 15127074645 0014525 g ustar 00root root 0000000 0000000 52 comment=6439d352d29fb1b6a9543c2309a0cc25f57ac68d
v2-2.2.16/ 0000775 0000000 0000000 00000000000 15127074645 0012144 5 ustar 00root root 0000000 0000000 v2-2.2.16/.devcontainer/ 0000775 0000000 0000000 00000000000 15127074645 0014703 5 ustar 00root root 0000000 0000000 v2-2.2.16/.devcontainer/devcontainer.json 0000664 0000000 0000000 00000001377 15127074645 0020267 0 ustar 00root root 0000000 0000000 {
"name": "Miniflux",
"dockerComposeFile": "docker-compose.yml",
"service": "app",
"workspaceFolder": "/workspace",
"remoteUser": "vscode",
"forwardPorts": [
8080
],
"features": {
"ghcr.io/devcontainers/features/github-cli:1": {},
"ghcr.io/devcontainers/features/docker-outside-of-docker:1": {
"moby": false
}
},
"customizations": {
"vscode": {
"settings": {
"go.toolsManagement.checkForUpdates": "local",
"go.useLanguageServer": true,
"go.gopath": "/go"
},
"extensions": [
"ms-azuretools.vscode-docker",
"golang.go",
"rangav.vscode-thunder-client",
"GitHub.codespaces",
"GitHub.copilot",
"GitHub.copilot-chat"
]
}
}
}
v2-2.2.16/.devcontainer/docker-compose.yml 0000664 0000000 0000000 00000001375 15127074645 0020346 0 ustar 00root root 0000000 0000000 services:
app:
image: mcr.microsoft.com/devcontainers/go:1-trixie # https://www.debian.org/releases/trixie/index.en.html
volumes:
- ..:/workspace:cached
command: sleep infinity
network_mode: service:db
environment:
- CREATE_ADMIN=1
- ADMIN_USERNAME=admin
- ADMIN_PASSWORD=test123
db:
image: postgres:latest
restart: unless-stopped
volumes:
- postgres-data:/var/lib/postgresql
hostname: postgres
environment:
POSTGRES_DB: miniflux2
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_HOST_AUTH_METHOD: trust
ports:
- 5432:5432
apprise:
image: caronc/apprise:1.0
restart: unless-stopped
hostname: apprise
volumes:
postgres-data: null
v2-2.2.16/.github/ 0000775 0000000 0000000 00000000000 15127074645 0013504 5 ustar 00root root 0000000 0000000 v2-2.2.16/.github/ISSUE_TEMPLATE/ 0000775 0000000 0000000 00000000000 15127074645 0015667 5 ustar 00root root 0000000 0000000 v2-2.2.16/.github/ISSUE_TEMPLATE/bug_report.yml 0000664 0000000 0000000 00000005540 15127074645 0020566 0 ustar 00root root 0000000 0000000 name: "Bug Report"
description: "Report a bug or unexpected behavior"
title: "[Bug]: "
type: "Bug"
labels: ["triage needed"]
body:
- type: markdown
attributes:
value: |
Thanks for reporting a bug! Please provide detailed information to help us reproduce and fix the issue.
- type: input
id: summary
attributes:
label: "Bug Summary"
description: "Briefly describe the bug."
placeholder: "e.g., Error when saving a new entry"
validations:
required: true
- type: textarea
id: description
attributes:
label: "Description"
description: "A clear and concise description of the bug."
placeholder: "e.g., When I click 'Save', I get a 500 error."
validations:
required: true
- type: textarea
id: steps_to_reproduce
attributes:
label: "Steps to Reproduce"
description: "Steps to reproduce the behavior."
placeholder: |
1. Go to '...'
2. Click on '...'
3. Scroll down to '...'
4. See error
validations:
required: true
- type: textarea
id: expected_behavior
attributes:
label: "Expected Behavior"
description: "What should happen instead?"
placeholder: "e.g., The form should be saved successfully."
validations:
required: true
- type: textarea
id: actual_behavior
attributes:
label: "Actual Behavior"
description: "What actually happens?"
placeholder: "e.g., A 500 error is returned with no useful error message."
validations:
required: true
- type: input
id: version
attributes:
label: "Version"
description: "Which version of Miniflux are you using?"
placeholder: "e.g., 2.2.6"
validations:
required: true
- type: input
id: browser
attributes:
label: "Browser"
description: "If applicable, which browser are you using? Please provide the version."
placeholder: "e.g., Chrome, Firefox, Safari"
validations:
required: false
- type: textarea
id: logs
attributes:
label: "Relevant Logs or Error Output"
description: "Paste any relevant logs or error messages (if applicable)."
render: shell
placeholder: "e.g., Stack trace, log files, browser console logs, or console output"
validations:
required: false
- type: textarea
id: additional_context
attributes:
label: "Additional Context"
description: "Add any other context about the problem here."
placeholder: "e.g., Screenshots, video recordings, or related issues"
validations:
required: false
- type: checkboxes
id: agreement
attributes:
label: "Checklist"
description: "Please confirm the following:"
options:
- label: "I have searched existing issues to ensure this bug hasn't been reported before."
required: true
v2-2.2.16/.github/ISSUE_TEMPLATE/config.yml 0000664 0000000 0000000 00000000034 15127074645 0017654 0 ustar 00root root 0000000 0000000 blank_issues_enabled: false
v2-2.2.16/.github/ISSUE_TEMPLATE/documentation.yml 0000664 0000000 0000000 00000005046 15127074645 0021270 0 ustar 00root root 0000000 0000000 name: "Documentation Issue"
description: "Report issues or suggest improvements for the documentation"
title: "[Docs]: "
type: "Documentation"
labels: ["triage needed"]
body:
- type: markdown
attributes:
value: |
Thanks for helping improve the Miniflux documentation! Clear and accurate documentation helps everyone.
- type: dropdown
id: issue_type
attributes:
label: "Documentation Issue Type"
description: "What kind of documentation issue are you reporting?"
options:
- "Missing Information"
- "Incorrect Information"
- "Outdated Information"
- "Unclear Explanation"
- "Formatting/Structural Issue"
- "Typo/Grammar Error"
- "Documentation Request"
- "Other"
validations:
required: true
- type: input
id: summary
attributes:
label: "Summary"
description: "Briefly describe the documentation issue."
placeholder: "e.g., The API authentication section is outdated"
validations:
required: true
- type: input
id: location
attributes:
label: "Location"
description: "Where is the documentation you're referring to? Provide URLs, file paths, or section names."
placeholder: "e.g., README.md, docs/api.md, Installation section of the website"
validations:
required: true
- type: textarea
id: description
attributes:
label: "Detailed Description"
description: "Provide a detailed description of the issue or improvement."
placeholder: "e.g., The API authentication section doesn't mention the new token-based authentication method introduced in version 2.0.5."
validations:
required: true
- type: textarea
id: current_content
attributes:
label: "Current Content (if applicable)"
description: "What does the current documentation say?"
placeholder: "Paste the current documentation text here."
validations:
required: false
- type: textarea
id: suggested_content
attributes:
label: "Suggested Changes"
description: "If you have specific suggestions for how to improve the documentation, please provide them here."
placeholder: "e.g., Add a new section about token-based authentication with these details..."
validations:
required: false
- type: input
id: version
attributes:
label: "Version"
description: "Which version of Miniflux does this documentation issue relate to?"
placeholder: "e.g., 2.2.6, or 'all versions'"
validations:
required: false
v2-2.2.16/.github/ISSUE_TEMPLATE/feature_request.yml 0000664 0000000 0000000 00000004267 15127074645 0021626 0 ustar 00root root 0000000 0000000 name: "Feature Request"
description: "Suggest an idea or improvement for the project"
title: "[Feature]: "
type: "Feature"
body:
- type: markdown
attributes:
value: |
Thanks for taking the time to suggest a feature! Please provide detailed information to help us understand and evaluate your idea.
- type: input
id: summary
attributes:
label: "Feature Summary"
description: "Briefly describe the feature or enhancement."
placeholder: "e.g., Add dark mode support"
validations:
required: true
- type: textarea
id: problem
attributes:
label: "What problem does this feature solve?"
description: "Explain the problem or limitation this feature would address."
placeholder: "e.g., It's difficult to use the app in low-light environments."
validations:
required: true
- type: textarea
id: solution
attributes:
label: "Proposed Solution"
description: "Describe how you think this feature should work."
placeholder: "e.g., Add a toggle in settings to switch between light and dark mode."
validations:
required: true
- type: textarea
id: alternatives
attributes:
label: "Alternatives Considered"
description: "Have you considered other solutions or workarounds?"
placeholder: "e.g., Using browser extensions to force dark mode."
validations:
required: false
- type: textarea
id: additional_context
attributes:
label: "Additional Context"
description: "Add any other context, screenshots, or examples to explain your request."
placeholder: "e.g., A screenshot of a similar feature in another project."
validations:
required: false
- type: checkboxes
id: agreement
attributes:
label: "Checklist"
description: "Please confirm the following:"
options:
- label: "I have searched existing issues to ensure this feature hasn't been requested before."
required: true
- label: "I understand that feature requests are not guaranteed to be implemented."
required: true
- label: "I agree to follow the project's contribution guidelines."
required: true
v2-2.2.16/.github/ISSUE_TEMPLATE/feed_issue.yml 0000664 0000000 0000000 00000005245 15127074645 0020533 0 ustar 00root root 0000000 0000000 name: "Feed/Website Issue"
description: "Report problems with a specific feed or website"
title: "[Feed Issue]: "
type: "Feed Issue"
labels: ["triage needed"]
body:
- type: markdown
attributes:
value: |
Thanks for reporting an issue with a feed or website! Please provide detailed information to help us diagnose and resolve the problem.
- type: input
id: feed_url
attributes:
label: "Feed URL"
description: "Provide the URL of the feed that is not working correctly."
placeholder: "e.g., https://example.com/feed.xml"
validations:
required: true
- type: input
id: website_url
attributes:
label: "Website URL"
description: "Provide the URL of the website."
placeholder: "e.g., https://example.com"
validations:
required: true
- type: textarea
id: problem_description
attributes:
label: "Problem Description"
description: "Describe the issue you are experiencing with this feed."
placeholder: |
e.g.,
- The feed URL returns a 403 error.
- The content is malformed.
- Images are not loading in the web ui.
validations:
required: true
- type: textarea
id: expected_behavior
attributes:
label: "Expected Behavior"
description: "Describe what you expect to happen."
placeholder: "e.g., The feed should show the images correctly."
validations:
required: true
- type: textarea
id: error_logs
attributes:
label: "Relevant Logs or Error Output"
description: "Paste any relevant logs or error messages, if available."
render: shell
placeholder: "e.g., HTTP error codes, invalid XML warnings, etc."
validations:
required: false
- type: textarea
id: additional_context
attributes:
label: "Additional Context"
description: "Add any other context, screenshots, or related information to help us troubleshoot."
placeholder: "e.g., Is this a recurring problem? Did the feed work before?"
validations:
required: false
- type: checkboxes
id: troubleshooting
attributes:
label: "Troubleshooting Steps"
description: "Please confirm that you have tried the following:"
options:
- label: "I have checked if the feed URL is correct and accessible in a web browser."
required: true
- label: "I have checked if the feed URL is correct and accessible with `curl`."
required: true
- label: "I have verified that the feed is valid using an RSS/Atom validator."
required: false
- label: "I have searched for existing issues to avoid duplicates."
required: true
v2-2.2.16/.github/ISSUE_TEMPLATE/proposal.yml 0000664 0000000 0000000 00000006216 15127074645 0020256 0 ustar 00root root 0000000 0000000 name: "Proposal / RFC"
description: "Propose a significant change, or architectural decision"
title: "[Proposal]: "
type: "Proposal"
body:
- type: markdown
attributes:
value: |
Thanks for taking the time to submit a proposal! Please provide detailed information to ensure a productive discussion.
- type: input
id: summary
attributes:
label: "Proposal Summary"
description: "A brief summary of the proposed change or idea."
placeholder: "e.g., Refactor database schema for performance optimization"
validations:
required: true
- type: textarea
id: motivation
attributes:
label: "Motivation and Context"
description: "Explain the problem this proposal addresses. Why is it necessary? What are the current limitations or pain points?"
placeholder: |
e.g.,
- The current database schema causes performance bottlenecks when querying large datasets.
- Adding this feature will improve scalability and reliability for large-scale use cases.
validations:
required: true
- type: textarea
id: proposed_solution
attributes:
label: "Proposed Solution"
description: "Describe the proposed solution or approach. Include technical details, diagrams, and examples where possible."
placeholder: |
e.g.,
- Redesign the schema to normalize tables and introduce indexing.
validations:
required: true
- type: textarea
id: alternatives
attributes:
label: "Alternatives Considered"
description: "List any alternative approaches that were considered and explain why they were rejected."
placeholder: |
e.g.,
- Use Redis for caching, but it adds operational complexity.
- Stick with the current schema and optimize queries, but this has limited impact on performance.
validations:
required: false
- type: textarea
id: impact
attributes:
label: "Impact and Risks"
description: "Describe the potential impact of this change. Highlight possible risks and backward compatibility concerns."
placeholder: |
e.g.,
- May require data migration with downtime.
- Could introduce breaking changes in API responses.
- Affects core functionality, requiring extensive testing.
validations:
required: true
- type: textarea
id: additional_context
attributes:
label: "Additional Context or References"
description: "Add any relevant context, links to related discussions, RFCs, or design documents."
placeholder: "e.g., Links to research, GitHub issues, or similar projects"
validations:
required: false
- type: checkboxes
id: agreement
attributes:
label: "Checklist"
description: "Please confirm the following:"
options:
- label: "I have reviewed existing proposals to ensure this change hasn't been proposed before."
required: true
- label: "I agree to provide follow-up updates and maintain discussion on this proposal."
required: true
- label: "I agree to follow the project's contribution guidelines."
required: true
v2-2.2.16/.github/dependabot.yml 0000664 0000000 0000000 00000001210 15127074645 0016326 0 ustar 00root root 0000000 0000000 version: 2
updates:
- package-ecosystem: "gomod"
directory: "/"
schedule:
interval: "daily"
- package-ecosystem: "docker"
directory: "/packaging/docker/alpine"
schedule:
interval: "weekly"
- package-ecosystem: "docker"
directory: "/packaging/docker/distroless"
schedule:
interval: "weekly"
- package-ecosystem: "docker"
directory: "packaging/debian"
schedule:
interval: "weekly"
- package-ecosystem: "docker"
directory: "packaging/rpm"
schedule:
interval: "weekly"
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
v2-2.2.16/.github/pull_request_template.md 0000664 0000000 0000000 00000000640 15127074645 0020445 0 ustar 00root root 0000000 0000000 Have you followed these guidelines?
- [ ] I have tested my changes
- [ ] There are no breaking changes
- [ ] I have thoroughly tested my changes and verified there are no regressions
- [ ] My commit messages follow the [Conventional Commits specification](https://www.conventionalcommits.org/)
- [ ] I have read and understood the [contribution guidelines](https://github.com/miniflux/v2/blob/main/CONTRIBUTING.md)
v2-2.2.16/.github/workflows/ 0000775 0000000 0000000 00000000000 15127074645 0015541 5 ustar 00root root 0000000 0000000 v2-2.2.16/.github/workflows/build_binaries.yml 0000664 0000000 0000000 00000001246 15127074645 0021242 0 ustar 00root root 0000000 0000000 name: Build Binaries
permissions:
contents: read
on:
workflow_dispatch:
push:
tags:
- '[0-9]+.[0-9]+.[0-9]+'
jobs:
build:
name: Build
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Set up Golang
uses: actions/setup-go@v6
with:
go-version: stable
check-latest: true
- name: Compile binaries
env:
CGO_ENABLED: 0
run: make build
- name: Upload binaries
uses: actions/upload-artifact@v6
with:
name: binaries
path: miniflux-*
if-no-files-found: error
retention-days: 5
v2-2.2.16/.github/workflows/codeberg_mirror.yml 0000664 0000000 0000000 00000001252 15127074645 0021430 0 ustar 00root root 0000000 0000000 name: Mirror to Codeberg
on:
push:
branches: [ main ]
delete:
workflow_dispatch:
jobs:
mirror:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Mirror to Codeberg
env:
CODEBERG_USERNAME: ${{ secrets.CODEBERG_USERNAME }}
CODEBERG_TOKEN: ${{ secrets.CODEBERG_TOKEN }}
run: |
git remote add codeberg https://${{ secrets.CODEBERG_USERNAME }}:${{ secrets.CODEBERG_TOKEN }}@codeberg.org/miniflux/v2.git
git push --force --prune codeberg \
"refs/heads/*:refs/heads/*" \
"refs/tags/*:refs/tags/*"
v2-2.2.16/.github/workflows/codeql-analysis.yml 0000664 0000000 0000000 00000002345 15127074645 0021360 0 ustar 00root root 0000000 0000000 name: "CodeQL"
permissions: read-all
on:
push:
branches: [ main ]
paths:
- '**.js'
- '**.go'
- '!**_test.go'
- '.github/workflows/codeql-analysis.yml'
pull_request:
# The branches below must be a subset of the branches above
branches: [ main ]
paths:
- '**.js'
- '**.go'
- '!**_test.go'
- '.github/workflows/codeql-analysis.yml'
schedule:
- cron: '45 22 * * 3'
workflow_dispatch:
jobs:
analyze:
name: Analyze (${{ matrix.language }})
runs-on: ubuntu-latest
permissions:
actions: read
contents: read
security-events: write
strategy:
fail-fast: false
matrix:
language: [ 'go', 'javascript' ]
steps:
- name: Checkout repository
uses: actions/checkout@v6
- uses: actions/setup-go@v6
if: matrix.language == 'go'
with:
go-version: stable
- name: Initialize CodeQL
uses: github/codeql-action/init@v4
with:
languages: ${{ matrix.language }}
- name: Autobuild
uses: github/codeql-action/autobuild@v4
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v4
with:
category: "/language:${{ matrix.language }}"
v2-2.2.16/.github/workflows/debian_packages.yml 0000664 0000000 0000000 00000004724 15127074645 0021353 0 ustar 00root root 0000000 0000000 name: Debian Packages
permissions: read-all
on:
workflow_dispatch:
push:
tags:
- '[0-9]+.[0-9]+.[0-9]+'
schedule:
- cron: '0 0 * * 1,4' # Runs at 00:00 UTC on Monday and Thursday
pull_request:
branches: [ main ]
paths:
- 'packaging/debian/**' # Only run on changes to the debian packaging files
jobs:
test-packages:
if: github.event_name == 'schedule' || github.event_name == 'pull_request'
name: Test Packages
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
id: buildx
with:
install: true
- name: Available Docker Platforms
run: echo ${{ steps.buildx.outputs.platforms }}
- name: Build Debian Packages
run: make debian-packages
- name: List generated files
run: ls -l *.deb
build-packages-manually:
if: github.event_name == 'workflow_dispatch'
name: Build Packages Manually
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
id: buildx
with:
install: true
- name: Available Docker Platforms
run: echo ${{ steps.buildx.outputs.platforms }}
- name: Build Debian Packages
run: make debian-packages
- name: Upload package
uses: actions/upload-artifact@v6
with:
name: packages
path: "*.deb"
if-no-files-found: error
retention-days: 3
publish-packages:
if: github.event_name == 'push'
name: Publish Packages
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
id: buildx
with:
install: true
- name: Available Docker Platforms
run: echo ${{ steps.buildx.outputs.platforms }}
- name: Build Debian Packages
run: make debian-packages
- name: List generated files
run: ls -l *.deb
- name: Upload packages to repository
env:
FURY_TOKEN: ${{ secrets.FURY_TOKEN }}
run: for f in *.deb; do curl -F package=@$f https://$FURY_TOKEN@push.fury.io/miniflux/; done
v2-2.2.16/.github/workflows/docker.yml 0000664 0000000 0000000 00000006341 15127074645 0017537 0 ustar 00root root 0000000 0000000 name: Docker
on:
schedule:
- cron: '0 1 * * *'
push:
tags:
- '[0-9]+.[0-9]+.[0-9]+'
pull_request:
branches: [ main ]
paths:
- 'packaging/docker/**'
jobs:
docker-images:
name: Docker Images
permissions:
packages: write
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Generate Alpine Docker tags
id: docker_alpine_tags
uses: docker/metadata-action@v5
with:
images: |
docker.io/${{ github.repository_owner }}/miniflux
ghcr.io/${{ github.repository_owner }}/miniflux
quay.io/${{ github.repository_owner }}/miniflux
tags: |
type=ref,event=pr
type=schedule,pattern=nightly
type=semver,pattern={{raw}}
- name: Generate Distroless Docker tags
id: docker_distroless_tags
uses: docker/metadata-action@v5
with:
images: |
docker.io/${{ github.repository_owner }}/miniflux
ghcr.io/${{ github.repository_owner }}/miniflux
quay.io/${{ github.repository_owner }}/miniflux
tags: |
type=ref,event=pr
type=schedule,pattern=nightly
type=semver,pattern={{raw}}
flavor: |
suffix=-distroless,onlatest=true
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to DockerHub
if: ${{ github.event_name != 'pull_request' && vars.PUBLISH_DOCKER_IMAGES == 'true' }}
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GitHub Container Registry
if: ${{ github.event_name != 'pull_request' && vars.PUBLISH_DOCKER_IMAGES == 'true' }}
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to Quay Container Registry
if: ${{ github.event_name != 'pull_request' && vars.PUBLISH_DOCKER_IMAGES == 'true' }}
uses: docker/login-action@v3
with:
registry: quay.io
username: ${{ secrets.QUAY_USERNAME }}
password: ${{ secrets.QUAY_TOKEN }}
- name: Build and Push Alpine images
uses: docker/build-push-action@v6
if: ${{ vars.PUBLISH_DOCKER_IMAGES == 'true' }}
with:
context: .
file: ./packaging/docker/alpine/Dockerfile
platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.docker_alpine_tags.outputs.tags }}
- name: Build and Push Distroless images
uses: docker/build-push-action@v6
if: ${{ vars.PUBLISH_DOCKER_IMAGES == 'true' }}
with:
context: .
file: ./packaging/docker/distroless/Dockerfile
platforms: linux/amd64,linux/arm64
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.docker_distroless_tags.outputs.tags }}
v2-2.2.16/.github/workflows/linters.yml 0000664 0000000 0000000 00000002337 15127074645 0017751 0 ustar 00root root 0000000 0000000 name: Linters
permissions: read-all
on:
pull_request:
branches:
- main
workflow_dispatch:
jobs:
jshint:
name: Javascript Linter
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Install linters
run: |
sudo npm install -g jshint@2.13.6 eslint@8.57.0
- name: Run jshint
run: jshint internal/ui/static/js/*.js
- name: Run ESLint
run: eslint internal/ui/static/js/*.js
golangci:
name: Golang Linters
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-go@v6
with:
go-version: stable
- uses: golangci/golangci-lint-action@v9
- name: Run gofmt linter
run: gofmt -d -e .
commitlint:
if: github.event_name == 'pull_request'
name: Commit Linter
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: '3.13'
- name: Validate PR commits
run: python3 .github/workflows/scripts/commit-checker.py --base ${{ github.event.pull_request.base.sha }} --head ${{ github.event.pull_request.head.sha }}
v2-2.2.16/.github/workflows/rpm_packages.yml 0000664 0000000 0000000 00000003177 15127074645 0020730 0 ustar 00root root 0000000 0000000 name: RPM Packages
permissions: read-all
on:
workflow_dispatch:
push:
tags:
- '[0-9]+.[0-9]+.[0-9]+'
schedule:
- cron: '0 0 * * 1,4' # Runs at 00:00 UTC on Monday and Thursday
pull_request:
branches: [ main ]
paths:
- 'packaging/rpm/**' # Only run on changes to the rpm packaging files
- '.github/workflows/rpm_packages.yml'
jobs:
test-package:
if: github.event_name == 'schedule' || github.event_name == 'pull_request'
name: Test Packages
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Build RPM Package
run: make rpm VERSION=2.2.x_dev
- name: List generated files
run: ls -l *.rpm
build-package-manually:
if: github.event_name == 'workflow_dispatch'
name: Build Packages Manually
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Build RPM Package
run: make rpm
- name: Upload package
uses: actions/upload-artifact@v6
with:
name: packages
path: "*.rpm"
if-no-files-found: error
retention-days: 3
publish-package:
if: github.event_name == 'push'
name: Publish Packages
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Build RPM Package
run: make rpm
- name: List generated files
run: ls -l *.rpm
- name: Upload package to repository
env:
FURY_TOKEN: ${{ secrets.FURY_TOKEN }}
run: for f in *.rpm; do curl -F package=@$f https://$FURY_TOKEN@push.fury.io/miniflux/; done
v2-2.2.16/.github/workflows/scripts/ 0000775 0000000 0000000 00000000000 15127074645 0017230 5 ustar 00root root 0000000 0000000 v2-2.2.16/.github/workflows/scripts/commit-checker.py 0000664 0000000 0000000 00000006044 15127074645 0022500 0 ustar 00root root 0000000 0000000 import subprocess
import re
import sys
import argparse
from typing import Match
# Conventional commit pattern (including Git revert messages)
CONVENTIONAL_COMMIT_PATTERN: str = (
r"^((build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test)(\([a-z0-9-]+\))?!?: .{1,100}|Revert .+)"
)
def get_commit_message(commit_hash: str) -> str:
"""Get the commit message for a given commit hash."""
try:
result: subprocess.CompletedProcess = subprocess.run(
["git", "show", "-s", "--format=%B", commit_hash],
capture_output=True,
text=True,
check=True,
)
return result.stdout.strip()
except subprocess.CalledProcessError as e:
print(f"Error retrieving commit message: {e}")
sys.exit(1)
def check_commit_message(message: str, pattern: str = CONVENTIONAL_COMMIT_PATTERN) -> bool:
"""Check if commit message follows conventional commit format."""
first_line: str = message.split("\n")[0]
match: Match[str] | None = re.match(pattern, first_line)
return bool(match)
def check_commit_range(base_ref: str, head_ref: str) -> list[dict[str, str]]:
"""Check all commits in a range for compliance."""
try:
result: subprocess.CompletedProcess = subprocess.run(
["git", "log", "--format=%H", f"{base_ref}..{head_ref}"],
capture_output=True,
text=True,
check=True,
)
commit_hashes: list[str] = result.stdout.strip().split("\n")
# Filter out empty lines
commit_hashes = [hash for hash in commit_hashes if hash]
non_compliant: list[dict[str, str]] = []
for commit_hash in commit_hashes:
message: str = get_commit_message(commit_hash)
if not check_commit_message(message):
non_compliant.append({"hash": commit_hash, "message": message.split("\n")[0]})
return non_compliant
except subprocess.CalledProcessError as e:
print(f"Error checking commit range: {e}")
sys.exit(1)
def main() -> None:
parser: argparse.ArgumentParser = argparse.ArgumentParser(description="Check conventional commit compliance")
parser.add_argument("--base", required=True, help="Base ref (starting commit, exclusive)")
parser.add_argument("--head", required=True, help="Head ref (ending commit, inclusive)")
args: argparse.Namespace = parser.parse_args()
non_compliant: list[dict[str, str]] = check_commit_range(args.base, args.head)
if non_compliant:
print("The following commits do not follow the conventional commit format:")
for commit in non_compliant:
print(f"- {commit['hash'][:8]}: {commit['message']}")
print("\nPlease ensure your commit messages follow the format:")
print("type(scope): subject")
print("\nWhere type is one of: build, chore, ci, docs, feat, fix, perf, refactor, revert, style, test")
sys.exit(1)
else:
print("All commits follow the conventional commit format!")
sys.exit(0)
if __name__ == "__main__":
main()
v2-2.2.16/.github/workflows/tests.yml 0000664 0000000 0000000 00000002720 15127074645 0017427 0 ustar 00root root 0000000 0000000 name: Tests
permissions: read-all
on:
pull_request:
branches:
- main
workflow_dispatch:
jobs:
unit-tests:
name: Unit Tests
runs-on: ${{ matrix.os }}
strategy:
max-parallel: 4
matrix:
os: [ubuntu-latest, windows-latest, macOS-latest]
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Set up Go
uses: actions/setup-go@v6
with:
go-version: stable
- name: Run unit tests with coverage and race conditions checking
if: matrix.os == 'ubuntu-latest'
run: make test
- name: Run unit tests without coverage and race conditions checking
if: matrix.os != 'ubuntu-latest'
run: go test ./...
integration-tests:
name: Integration Tests
runs-on: ubuntu-latest
services:
postgres:
image: postgres:9.5
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: postgres
ports:
- 5432:5432
options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Set up Go
uses: actions/setup-go@v6
with:
go-version: stable
- name: Install Postgres client
run: sudo apt update && sudo apt install -y postgresql-client
- name: Run integration tests
run: make integration-test
env:
PGHOST: 127.0.0.1
PGPASSWORD: postgres
v2-2.2.16/.gitignore 0000664 0000000 0000000 00000000072 15127074645 0014133 0 ustar 00root root 0000000 0000000 ./*.sha256
/miniflux
.idea
.vscode
*.deb
*.rpm
miniflux-*
v2-2.2.16/.golangci.yml 0000664 0000000 0000000 00000000702 15127074645 0014527 0 ustar 00root root 0000000 0000000 version: "2"
linters:
default: standard
disable:
- errcheck
enable:
- errname
- gocritic
- goheader
- loggercheck
- misspell
- perfsprint
- prealloc
- sqlclosecheck
- staticcheck
- whitespace
settings:
loggercheck:
slog: true
goheader:
template: |-
SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
SPDX-License-Identifier: Apache-2.0
v2-2.2.16/CONTRIBUTING.md 0000664 0000000 0000000 00000012174 15127074645 0014402 0 ustar 00root root 0000000 0000000 # Contributing to Miniflux
This document outlines how to contribute effectively to Miniflux.
## Philosophy
Miniflux follows a **minimalist philosophy**. The feature set is intentionally kept limited to avoid bloatware. Before contributing, please understand that:
- **Improving existing features takes priority over adding new ones**
- **Quality over quantity** - well-implemented, focused features are preferred
- **Simplicity is key** - complex solutions are discouraged in favor of simple, maintainable code
## Before You Start
### Feature Requests
Before implementing a new feature:
- Check if it aligns with Miniflux's philosophy
- Consider if the feature could be implemented differently to maintain simplicity
- Remember that developing software takes significant time, and this is a volunteer-driven project
- If you need a specific feature, the best approach is to contribute it yourself
### Bug Reports
When reporting bugs:
- Search existing issues first to avoid duplicates
- Provide clear reproduction steps
- Include relevant system information (OS, browser, Miniflux version)
- Include error messages, screenshots, and logs when applicable
## Development Setup
### Requirements
- **Git**
- **Go >= 1.24**
- **PostgreSQL**
### Getting Started
1. **Fork the repository** on GitHub
2. **Clone your fork locally:**
```bash
git clone https://github.com/YOUR_USERNAME/miniflux.git
cd miniflux
```
3. **Build the application binary:**
```bash
make miniflux
```
4. **Run locally in debug mode:**
```bash
make run
```
### Database Setup
For development and testing, you can run a local PostgreSQL database with Docker:
```bash
# Start PostgreSQL container
docker run --rm --name miniflux2-db -p 5432:5432 \
-e POSTGRES_DB=miniflux2 \
-e POSTGRES_USER=postgres \
-e POSTGRES_PASSWORD=postgres \
postgres
```
You can also use an existing PostgreSQL instance. Make sure to set the `DATABASE_URL` environment variable accordingly.
## Development Workflow
### Code Quality
1. **Run the linter:**
```bash
make lint
```
Requires `staticcheck` and `golangci-lint` to be installed.
2. **Run unit tests:**
```bash
make test
```
3. **Run integration tests:**
```bash
make integration-test
make clean-integration-test
```
### Building
- **Current platform:** `make miniflux`
- **All platforms:** `make build`
- **Specific platforms:** `make linux-amd64`, `make darwin-arm64`, etc.
- **Docker image:** `make docker-image`
### Cross-Platform Support
Miniflux supports multiple architectures. When making changes, ensure compatibility across:
- Linux (amd64, arm64, armv7, armv6, armv5)
- macOS (amd64, arm64)
- FreeBSD, OpenBSD, Windows (amd64)
## Pull Request Guidelines
### What Is Preferred
✅ **Good Pull Requests:**
- Focus on a single issue or feature
- Include tests for new functionality
- Maintain or improve performance
- Follow existing code style and patterns
- The commit messages follow the [conventional commit format](https://www.conventionalcommits.org/) (e.g., `feat: add new feature`, `fix: resolve bug`)
- Update documentation when necessary
### What to Avoid
❌ **Pull Requests That Cannot Be Accepted:**
- **Too many changes** - makes review difficult
- **Breaking changes** - disrupts existing functionality
- **New bugs or regressions** - reduces software quality
- **Unnecessary dependencies** - conflicts with minimalist approach
- **Performance degradation** - slows down the software
- **Poor-quality code** - hard to maintain
- **Dependent PRs** - creates review complexity
- **Radical UI changes** - disrupts user experience
- **Conflicts with philosophy** - doesn't align with minimalist approach
### Pull Request Template
When creating a pull request, please include:
- **Description:** What does this PR do?
- **Motivation:** Why is this change needed?
- **Testing:** How was this tested?
- **Breaking Changes:** Are there any breaking changes?
- **Related Issues:** Link to any related issues
## Code Style
- Follow Go conventions and best practices
- Use `gofmt` to format your Go code, and `jshint` for JavaScript
- Write clear, descriptive variable and function names
- Include comments for complex logic
- Keep functions small and focused
## Testing
### Unit Tests
- Write unit tests for new functions and methods
- Ensure tests are fast and don't require external dependencies
- Aim for good test coverage
### Integration Tests
- Add integration tests for new API endpoints
- Tests run against a real PostgreSQL database
- Ensure tests clean up after themselves
## Communication
- **Discussions:** Use GitHub Discussions for general questions and community interaction
- **Issues:** Use GitHub issues for bug reports and feature requests
- **Pull Requests:** Use PR comments for code-specific discussions
- **Philosophy Questions:** Refer to the FAQ for common questions about project direction
## Questions?
- Check the [FAQ](https://miniflux.app/faq.html) for common questions
- Review the [development documentation](https://miniflux.app/docs/development.html) and [internationalization guide](https://miniflux.app/docs/i18n.html)
- Look at existing issues and pull requests for examples
v2-2.2.16/LICENSE 0000664 0000000 0000000 00000023676 15127074645 0013167 0 ustar 00root root 0000000 0000000
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
v2-2.2.16/Makefile 0000664 0000000 0000000 00000011126 15127074645 0013605 0 ustar 00root root 0000000 0000000 APP := miniflux
DOCKER_IMAGE := miniflux/miniflux
VERSION := $(shell git describe --tags --exact-match 2>/dev/null)
LD_FLAGS := "-s -w -X 'miniflux.app/v2/internal/version.Version=$(VERSION)'"
PKG_LIST := $(shell go list ./... | grep -v /vendor/)
DB_URL := postgres://postgres:postgres@localhost/miniflux_test?sslmode=disable
DOCKER_PLATFORM := amd64
export PGPASSWORD := postgres
.PHONY: \
miniflux \
miniflux-no-pie \
linux-amd64 \
linux-arm64 \
linux-armv7 \
linux-armv6 \
linux-armv5 \
linux-x86 \
darwin-amd64 \
darwin-arm64 \
freebsd-amd64 \
openbsd-amd64 \
netbsd-amd64 \
build \
run \
clean \
add-string \
test \
lint \
integration-test \
clean-integration-test \
docker-image \
docker-image-distroless \
docker-images \
rpm \
debian \
debian-packages
miniflux:
@ go build -buildmode=pie -ldflags=$(LD_FLAGS) -o $(APP)
miniflux-no-pie:
@ go build -ldflags=$(LD_FLAGS) -o $(APP)
linux-amd64:
@ CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@
@ sha256sum $(APP)-$@ > $(APP)-$@.sha256
linux-arm64:
@ CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@
@ sha256sum $(APP)-$@ > $(APP)-$@.sha256
linux-armv7:
@ CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=7 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@
@ sha256sum $(APP)-$@ > $(APP)-$@.sha256
linux-armv6:
@ CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=6 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@
@ sha256sum $(APP)-$@ > $(APP)-$@.sha256
linux-armv5:
@ CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=5 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@
@ sha256sum $(APP)-$@ > $(APP)-$@.sha256
darwin-amd64:
@ GOOS=darwin GOARCH=amd64 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@
@ sha256sum $(APP)-$@ > $(APP)-$@.sha256
darwin-arm64:
@ GOOS=darwin GOARCH=arm64 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@
@ sha256sum $(APP)-$@ > $(APP)-$@.sha256
freebsd-amd64:
@ CGO_ENABLED=0 GOOS=freebsd GOARCH=amd64 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@
@ sha256sum $(APP)-$@ > $(APP)-$@.sha256
openbsd-amd64:
@ GOOS=openbsd GOARCH=amd64 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@
@ sha256sum $(APP)-$@ > $(APP)-$@.sha256
build: linux-amd64 linux-arm64 linux-armv7 linux-armv6 linux-armv5 darwin-amd64 darwin-arm64 freebsd-amd64 openbsd-amd64
run:
@ LOG_DATE_TIME=1 LOG_LEVEL=debug RUN_MIGRATIONS=1 CREATE_ADMIN=1 ADMIN_USERNAME=admin ADMIN_PASSWORD=test123 go run main.go
clean:
@ rm -f $(APP)-* $(APP) $(APP)*.rpm $(APP)*.deb $(APP)*.exe $(APP)*.sha256
add-string:
cd internal/locale/translations && \
for file in *.json; do \
jq --indent 4 --arg key "$(KEY)" --arg val "$(VAL)" \
'. + {($$key): $$val} | to_entries | sort_by(.key) | from_entries' "$$file" > tmp && \
mv tmp "$$file"; \
done
test:
go test -cover -race -count=1 ./...
lint:
go vet ./...
gofmt -d -e .
golangci-lint run
integration-test:
psql -U postgres -c 'drop database if exists miniflux_test;'
psql -U postgres -c 'create database miniflux_test;'
DATABASE_URL=$(DB_URL) \
ADMIN_USERNAME=admin \
ADMIN_PASSWORD=test123 \
CREATE_ADMIN=1 \
RUN_MIGRATIONS=1 \
LOG_LEVEL=debug \
go run main.go >/tmp/miniflux.log 2>&1 & echo "$$!" > "/tmp/miniflux.pid"
while ! nc -z localhost 8080; do sleep 1; done
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:
@ kill -9 `cat /tmp/miniflux.pid`
@ rm -f /tmp/miniflux.pid /tmp/miniflux.log
@ psql -U postgres -c 'drop database if exists miniflux_test;'
docker-image:
docker build --pull -t $(DOCKER_IMAGE):$(VERSION) -f packaging/docker/alpine/Dockerfile .
docker-image-distroless:
docker build -t $(DOCKER_IMAGE):$(VERSION) -f packaging/docker/distroless/Dockerfile .
docker-images:
docker buildx build \
--platform linux/amd64,linux/arm64,linux/arm/v7,linux/arm/v6 \
--file packaging/docker/alpine/Dockerfile \
--tag $(DOCKER_IMAGE):$(VERSION) \
--push .
rpm: clean
@ docker build \
-t miniflux-rpm-builder \
-f packaging/rpm/Dockerfile \
.
@ docker run --rm \
-v ${PWD}:/root/rpmbuild/RPMS/x86_64 miniflux-rpm-builder \
rpmbuild -bb --define "_miniflux_version $(VERSION)" /root/rpmbuild/SPECS/miniflux.spec
debian:
@ docker buildx build --load \
--platform linux/$(DOCKER_PLATFORM) \
-t miniflux-deb-builder \
-f packaging/debian/Dockerfile \
.
@ docker run --rm --platform linux/$(DOCKER_PLATFORM) \
-v ${PWD}:/pkg miniflux-deb-builder
debian-packages: clean
$(MAKE) debian DOCKER_PLATFORM=amd64
$(MAKE) debian DOCKER_PLATFORM=arm64
$(MAKE) debian DOCKER_PLATFORM=arm/v7
v2-2.2.16/Procfile 0000664 0000000 0000000 00000000022 15127074645 0013624 0 ustar 00root root 0000000 0000000 web: miniflux.app
v2-2.2.16/README.md 0000664 0000000 0000000 00000020134 15127074645 0013423 0 ustar 00root root 0000000 0000000 Miniflux 2
==========
Miniflux is a minimalist and opinionated feed reader.
It's simple, fast, lightweight and super easy to install.
Official website:
Features
--------
### Feed Reader
- Supported feed formats: Atom 0.3/1.0, RSS 1.0/2.0, and JSON Feed 1.0/1.1.
- [OPML](https://en.wikipedia.org/wiki/OPML) file import/export and URL import.
- Supports multiple attachments (podcasts, videos, music, and images enclosures).
- Plays videos from YouTube directly inside Miniflux.
- Organizes articles using categories and bookmarks.
- Share individual articles publicly.
- Fetches website icons (favicons).
- Saves articles to third-party services.
- Provides full-text search (powered by Postgres).
- Available in 20 languages: Portuguese (Brazilian), Chinese (Simplified and Traditional), Dutch, English (US), Finnish, French, German, Greek, Hindi, Indonesian, Italian, Japanese, Polish, Romanian, Russian, Taiwanese POJ, Ukrainian, Spanish, and Turkish.
### Privacy and Security
- Removes pixel trackers.
- Strips tracking parameters from URLs (e.g., `utm_source`, `utm_medium`, `utm_campaign`, `fbclid`, etc.).
- Retrieves original links when feeds are sourced from FeedBurner.
- Opens external links with attributes `rel="noopener noreferrer" referrerpolicy="no-referrer"` for improved security.
- Implements the HTTP header `Referrer-Policy: no-referrer` to prevent referrer leakage.
- Provides a media proxy to avoid tracking and resolve mixed content warnings when using HTTPS.
- Plays YouTube videos via the privacy-focused domain `youtube-nocookie.com`.
- Supports alternative YouTube video players such as [Invidious](https://invidio.us).
- Blocks external JavaScript to prevent tracking and enhance security.
- Sanitizes external content before rendering it.
- Enforces a [Content Security](https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP) and a [Trusted Types Policy](https://developer.mozilla.org/en-US/docs/Web/API/Trusted_Types_API) to only application JavaScript and blocks inline scripts and styles.
### Bot Protection Bypass Mechanisms
- Optionally disable HTTP/2 to mitigate fingerprinting.
- Allows configuration of a custom user agent.
- Supports adding custom cookies for specific use cases.
- Enables the use of proxies for enhanced privacy or bypassing restrictions.
### Content Manipulation
- Fetches the original article and extracts only the relevant content using a local Readability parser.
- Allows custom scraper rules based on CSS selectors.
- Supports custom rewriting rules for content manipulation.
- Provides a regex filter to include or exclude articles based on specific patterns.
- Optionally permits self-signed or invalid certificates (disabled by default).
- Scrapes YouTube's website to retrieve video duration as read time or uses the YouTube API (disabled by default).
### User Interface
- Optimized stylesheet for readability.
- Responsive design that adapts seamlessly to desktop, tablet, and mobile devices.
- Minimalistic and distraction-free user interface.
- No requirement to download an app from Apple App Store or Google Play Store.
- Can be added directly to the home screen for quick access.
- Supports a wide range of keyboard shortcuts for efficient navigation.
- Optional touch gesture support for navigation on mobile devices.
- Custom stylesheets and JavaScript to personalize the user interface to your preferences.
- Themes:
- Light (Sans-Serif)
- Light (Serif)
- Dark (Sans-Serif)
- Dark (Serif)
- System (Sans-Serif) – Automatically switches between Dark and Light themes based on system preferences.
- System (Serif)
### Integrations
- 25+ integrations with third-party services: [Apprise](https://github.com/caronc/apprise), [Betula](https://sr.ht/~bouncepaw/betula/), [Cubox](https://cubox.cc/), [Discord](https://discord.com/), [Espial](https://github.com/jonschoning/espial), [Instapaper](https://www.instapaper.com/), [LinkAce](https://www.linkace.org/), [Linkding](https://github.com/sissbruecker/linkding), [LinkTaco](https://linktaco.com), [LinkWarden](https://linkwarden.app/), [Matrix](https://matrix.org), [Notion](https://www.notion.com/), [Ntfy](https://ntfy.sh/), [Nunux Keeper](https://keeper.nunux.org/), [Pinboard](https://pinboard.in/), [Pushover](https://pushover.net), [RainDrop](https://raindrop.io/), [Readeck](https://readeck.org/en/), [Readwise Reader](https://readwise.io/read), [RssBridge](https://rss-bridge.org/), [Shaarli](https://github.com/shaarli/Shaarli), [Shiori](https://github.com/go-shiori/shiori), [Slack](https://slack.com/), [Telegram](https://telegram.org), [Wallabag](https://www.wallabag.org/), etc.
- Bookmarklet for subscribing to websites directly from any web browser.
- Webhooks for real-time notifications or custom integrations.
- Compatibility with existing mobile applications using the Fever or Google Reader API.
- REST API with client libraries available in [Go](https://github.com/miniflux/v2/tree/main/client) and [Python](https://github.com/miniflux/python-client).
### Authentication
- Local username and password.
- Passkeys ([WebAuthn](https://en.wikipedia.org/wiki/WebAuthn)).
- Google (OAuth2).
- Generic OpenID Connect.
- Reverse-Proxy authentication.
### Technical Stuff
- Written in [Go (Golang)](https://golang.org/).
- Single binary compiled statically without dependency.
- Works only with [PostgreSQL](https://www.postgresql.org/).
- Does not use any ORM or any complicated frameworks.
- Uses modern vanilla JavaScript only when necessary.
- All static files are bundled into the application binary using the Go `embed` package.
- Supports the Systemd `sd_notify` protocol for process monitoring.
- Configures HTTPS automatically with Let's Encrypt.
- Allows the use of custom SSL certificates.
- Supports [HTTP/2](https://en.wikipedia.org/wiki/HTTP/2) when TLS is enabled.
- Updates feeds in the background using an internal scheduler or a traditional cron job.
- Uses native lazy loading for images and iframes.
- Compatible only with modern browsers.
- Adheres to the [Twelve-Factor App](https://12factor.net/) methodology.
- Provides official Debian/RPM packages and pre-built binaries.
- Publishes a Docker image to Docker Hub, GitHub Registry, and Quay.io Registry, with ARM architecture support.
- Uses a limited amount of third-party go dependencies
- Has a comprehensive testsuite, with both unit tests and integration tests.
- Only uses a couple of MB of memory and a negligible amount of CPU, even with several hundreds of feeds.
- Respects/sends Last-Modified, If-Modified-Since, If-None-Match, Cache-Control, Expires and ETags headers, and has a default polling interval of 1h.
Documentation
-------------
The Miniflux documentation is available here: ([Man page](https://miniflux.app/miniflux.1.html))
- [Opinionated?](https://miniflux.app/opinionated.html)
- [Features](https://miniflux.app/features.html)
- [Requirements](https://miniflux.app/docs/requirements.html)
- [Installation Instructions](https://miniflux.app/docs/installation.html)
- [Upgrading to a New Version](https://miniflux.app/docs/upgrade.html)
- [Configuration](https://miniflux.app/docs/configuration.html)
- [Command Line Usage](https://miniflux.app/docs/cli.html)
- [User Interface Usage](https://miniflux.app/docs/ui.html)
- [Keyboard Shortcuts](https://miniflux.app/docs/keyboard_shortcuts.html)
- [Integration with External Services](https://miniflux.app/docs/#integrations)
- [Rewrite and Scraper Rules](https://miniflux.app/docs/rules.html)
- [API Reference](https://miniflux.app/docs/api.html)
- [Development](https://miniflux.app/docs/development.html)
- [Internationalization](https://miniflux.app/docs/i18n.html)
- [Frequently Asked Questions](https://miniflux.app/faq.html)
Screenshots
-----------
Default theme:

Dark theme when using keyboard navigation:

Credits
-------
- Authors: Frédéric Guillot - [List of contributors](https://github.com/miniflux/v2/graphs/contributors)
- Distributed under Apache 2.0 License
v2-2.2.16/SECURITY.md 0000664 0000000 0000000 00000001014 15127074645 0013731 0 ustar 00root root 0000000 0000000 # Security Policy
## Supported Versions
Only the latest stable version is supported.
## Reporting a Vulnerability
Preferably, [report the vulnerability privately using GitHub](https://github.com/miniflux/v2/security/advisories/new) ([documentation](https://docs.github.com/en/code-security/security-advisories/guidance-on-reporting-and-writing/privately-reporting-a-security-vulnerability)).
If you do not want to use GitHub, send an email to `security AT miniflux DOT net` with all the steps to reproduce the problem.
v2-2.2.16/client/ 0000775 0000000 0000000 00000000000 15127074645 0013422 5 ustar 00root root 0000000 0000000 v2-2.2.16/client/README.md 0000664 0000000 0000000 00000002001 15127074645 0014672 0 ustar 00root root 0000000 0000000 Miniflux API Client
===================
[](https://pkg.go.dev/miniflux.app/v2/client)
Client library for Miniflux REST API.
Installation
------------
```bash
go get -u miniflux.app/v2/client
```
Example
-------
```go
package main
import (
"fmt"
"os"
miniflux "miniflux.app/v2/client"
)
func main() {
// Authentication with username/password:
client := miniflux.NewClient("https://api.example.org", "admin", "secret")
// Authentication with an API Key:
client := miniflux.NewClient("https://api.example.org", "my-secret-token")
// Fetch all feeds.
feeds, err := client.Feeds()
if err != nil {
fmt.Println(err)
return
}
fmt.Println(feeds)
// Backup your feeds to an OPML file.
opml, err := client.Export()
if err != nil {
fmt.Println(err)
return
}
err = os.WriteFile("opml.xml", opml, 0644)
if err != nil {
fmt.Println(err)
return
}
}
```
v2-2.2.16/client/client.go 0000664 0000000 0000000 00000105507 15127074645 0015237 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package client // import "miniflux.app/v2/client"
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
)
// Client holds API procedure calls.
type Client struct {
request *request
}
// New returns a new Miniflux client.
//
// Deprecated: use NewClient instead.
func New(endpoint string, credentials ...string) *Client {
return NewClient(endpoint, credentials...)
}
// NewClient returns a new Miniflux client.
func NewClient(endpoint string, credentials ...string) *Client {
switch len(credentials) {
case 2:
return NewClientWithOptions(endpoint, WithCredentials(credentials[0], credentials[1]))
case 1:
return NewClientWithOptions(endpoint, WithAPIKey(credentials[0]))
default:
return NewClientWithOptions(endpoint)
}
}
// NewClientWithOptions returns a new Miniflux client with options.
func NewClientWithOptions(endpoint string, options ...Option) *Client {
// Trim trailing slashes and /v1 from the endpoint.
endpoint = strings.TrimSuffix(endpoint, "/")
endpoint = strings.TrimSuffix(endpoint, "/v1")
request := &request{endpoint: endpoint, client: http.DefaultClient}
for _, option := range options {
option(request)
}
return &Client{request: request}
}
func withDefaultTimeout() (context.Context, func()) {
ctx, cancel := context.WithTimeout(context.Background(), defaultTimeout)
return ctx, cancel
}
// Healthcheck checks if the application is up and running.
func (c *Client) Healthcheck() error {
ctx, cancel := withDefaultTimeout()
defer cancel()
return c.HealthcheckContext(ctx)
}
// HealthcheckContext checks if the application is up and running.
func (c *Client) HealthcheckContext(ctx context.Context) error {
body, err := c.request.Get(ctx, "/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.
func (c *Client) Version() (*VersionResponse, error) {
ctx, cancel := withDefaultTimeout()
defer cancel()
return c.VersionContext(ctx)
}
// VersionContext returns the version of the Miniflux instance.
func (c *Client) VersionContext(ctx context.Context) (*VersionResponse, error) {
body, err := c.request.Get(ctx, "/v1/version")
if err != nil {
return nil, err
}
defer body.Close()
var versionResponse *VersionResponse
if err := json.NewDecoder(body).Decode(&versionResponse); err != nil {
return nil, fmt.Errorf("miniflux: json error (%v)", err)
}
return versionResponse, nil
}
// Me returns the logged user information.
func (c *Client) Me() (*User, error) {
ctx, cancel := withDefaultTimeout()
defer cancel()
return c.MeContext(ctx)
}
// MeContext returns the logged user information.
func (c *Client) MeContext(ctx context.Context) (*User, error) {
body, err := c.request.Get(ctx, "/v1/me")
if err != nil {
return nil, err
}
defer body.Close()
var user *User
if err := json.NewDecoder(body).Decode(&user); err != nil {
return nil, fmt.Errorf("miniflux: json error (%v)", err)
}
return user, nil
}
// Users returns all users.
func (c *Client) Users() (Users, error) {
ctx, cancel := withDefaultTimeout()
defer cancel()
return c.UsersContext(ctx)
}
// UsersContext returns all users.
func (c *Client) UsersContext(ctx context.Context) (Users, error) {
body, err := c.request.Get(ctx, "/v1/users")
if err != nil {
return nil, err
}
defer body.Close()
var users Users
if err := json.NewDecoder(body).Decode(&users); err != nil {
return nil, fmt.Errorf("miniflux: response error (%v)", err)
}
return users, nil
}
// UserByID returns a single user.
func (c *Client) UserByID(userID int64) (*User, error) {
ctx, cancel := withDefaultTimeout()
defer cancel()
return c.UserByIDContext(ctx, userID)
}
// UserByIDContext returns a single user.
func (c *Client) UserByIDContext(ctx context.Context, userID int64) (*User, error) {
body, err := c.request.Get(ctx, fmt.Sprintf("/v1/users/%d", userID))
if err != nil {
return nil, err
}
defer body.Close()
var user User
if err := json.NewDecoder(body).Decode(&user); err != nil {
return nil, fmt.Errorf("miniflux: response error (%v)", err)
}
return &user, nil
}
// UserByUsername returns a single user.
func (c *Client) UserByUsername(username string) (*User, error) {
ctx, cancel := withDefaultTimeout()
defer cancel()
return c.UserByUsernameContext(ctx, username)
}
// UserByUsernameContext returns a single user.
func (c *Client) UserByUsernameContext(ctx context.Context, username string) (*User, error) {
body, err := c.request.Get(ctx, "/v1/users/"+username)
if err != nil {
return nil, err
}
defer body.Close()
var user User
if err := json.NewDecoder(body).Decode(&user); err != nil {
return nil, fmt.Errorf("miniflux: response error (%v)", err)
}
return &user, nil
}
// CreateUser creates a new user in the system.
func (c *Client) CreateUser(username, password string, isAdmin bool) (*User, error) {
ctx, cancel := withDefaultTimeout()
defer cancel()
return c.CreateUserContext(ctx, username, password, isAdmin)
}
// CreateUserContext creates a new user in the system.
func (c *Client) CreateUserContext(ctx context.Context, username, password string, isAdmin bool) (*User, error) {
body, err := c.request.Post(ctx, "/v1/users", &UserCreationRequest{
Username: username,
Password: password,
IsAdmin: isAdmin,
})
if err != nil {
return nil, err
}
defer body.Close()
var user *User
if err := json.NewDecoder(body).Decode(&user); err != nil {
return nil, fmt.Errorf("miniflux: response error (%v)", err)
}
return user, nil
}
// UpdateUser updates a user in the system.
func (c *Client) UpdateUser(userID int64, userChanges *UserModificationRequest) (*User, error) {
ctx, cancel := withDefaultTimeout()
defer cancel()
return c.UpdateUserContext(ctx, userID, userChanges)
}
// UpdateUserContext updates a user in the system.
func (c *Client) UpdateUserContext(ctx context.Context, userID int64, userChanges *UserModificationRequest) (*User, error) {
body, err := c.request.Put(ctx, fmt.Sprintf("/v1/users/%d", userID), userChanges)
if err != nil {
return nil, err
}
defer body.Close()
var u *User
if err := json.NewDecoder(body).Decode(&u); err != nil {
return nil, fmt.Errorf("miniflux: response error (%v)", err)
}
return u, nil
}
// DeleteUser removes a user from the system.
func (c *Client) DeleteUser(userID int64) error {
ctx, cancel := withDefaultTimeout()
defer cancel()
return c.DeleteUserContext(ctx, userID)
}
// DeleteUserContext removes a user from the system.
func (c *Client) DeleteUserContext(ctx context.Context, userID int64) error {
return c.request.Delete(ctx, fmt.Sprintf("/v1/users/%d", userID))
}
// APIKeys returns all API keys for the authenticated user.
func (c *Client) APIKeys() (APIKeys, error) {
ctx, cancel := withDefaultTimeout()
defer cancel()
return c.APIKeysContext(ctx)
}
// APIKeysContext returns all API keys for the authenticated user.
func (c *Client) APIKeysContext(ctx context.Context) (APIKeys, error) {
body, err := c.request.Get(ctx, "/v1/api-keys")
if err != nil {
return nil, err
}
defer body.Close()
var apiKeys APIKeys
if err := json.NewDecoder(body).Decode(&apiKeys); err != nil {
return nil, fmt.Errorf("miniflux: response error (%v)", err)
}
return apiKeys, nil
}
// CreateAPIKey creates a new API key for the authenticated user.
func (c *Client) CreateAPIKey(description string) (*APIKey, error) {
ctx, cancel := withDefaultTimeout()
defer cancel()
return c.CreateAPIKeyContext(ctx, description)
}
// CreateAPIKeyContext creates a new API key for the authenticated user.
func (c *Client) CreateAPIKeyContext(ctx context.Context, description string) (*APIKey, error) {
body, err := c.request.Post(ctx, "/v1/api-keys", &APIKeyCreationRequest{
Description: description,
})
if err != nil {
return nil, err
}
defer body.Close()
var apiKey *APIKey
if err := json.NewDecoder(body).Decode(&apiKey); err != nil {
return nil, fmt.Errorf("miniflux: response error (%v)", err)
}
return apiKey, nil
}
// DeleteAPIKey removes an API key for the authenticated user.
func (c *Client) DeleteAPIKey(apiKeyID int64) error {
ctx, cancel := withDefaultTimeout()
defer cancel()
return c.DeleteAPIKeyContext(ctx, apiKeyID)
}
// DeleteAPIKeyContext removes an API key for the authenticated user.
func (c *Client) DeleteAPIKeyContext(ctx context.Context, apiKeyID int64) error {
return c.request.Delete(ctx, fmt.Sprintf("/v1/api-keys/%d", apiKeyID))
}
// MarkAllAsRead marks all unread entries as read for a given user.
func (c *Client) MarkAllAsRead(userID int64) error {
ctx, cancel := withDefaultTimeout()
defer cancel()
return c.MarkAllAsReadContext(ctx, userID)
}
// MarkAllAsReadContext marks all unread entries as read for a given user.
func (c *Client) MarkAllAsReadContext(ctx context.Context, userID int64) error {
_, err := c.request.Put(ctx, fmt.Sprintf("/v1/users/%d/mark-all-as-read", userID), nil)
return err
}
// IntegrationsStatus fetches the integrations status for the logged user.
func (c *Client) IntegrationsStatus() (bool, error) {
ctx, cancel := withDefaultTimeout()
defer cancel()
return c.IntegrationsStatusContext(ctx)
}
// IntegrationsStatusContext fetches the integrations status for the logged user.
func (c *Client) IntegrationsStatusContext(ctx context.Context) (bool, error) {
body, err := c.request.Get(ctx, "/v1/integrations/status")
if err != nil {
return false, err
}
defer body.Close()
var response struct {
HasIntegrations bool `json:"has_integrations"`
}
if err := json.NewDecoder(body).Decode(&response); err != nil {
return false, fmt.Errorf("miniflux: response error (%v)", err)
}
return response.HasIntegrations, nil
}
// Discover try to find subscriptions from a website.
func (c *Client) Discover(url string) (Subscriptions, error) {
ctx, cancel := withDefaultTimeout()
defer cancel()
return c.DiscoverContext(ctx, url)
}
// DiscoverContext tries to find subscriptions from a website.
func (c *Client) DiscoverContext(ctx context.Context, url string) (Subscriptions, error) {
body, err := c.request.Post(ctx, "/v1/discover", map[string]string{"url": url})
if err != nil {
return nil, err
}
defer body.Close()
var subscriptions Subscriptions
if err := json.NewDecoder(body).Decode(&subscriptions); err != nil {
return nil, fmt.Errorf("miniflux: response error (%v)", err)
}
return subscriptions, nil
}
// Categories gets the list of categories.
func (c *Client) Categories() (Categories, error) {
ctx, cancel := withDefaultTimeout()
defer cancel()
return c.CategoriesContext(ctx)
}
// CategoriesContext gets the list of categories.
func (c *Client) CategoriesContext(ctx context.Context) (Categories, error) {
body, err := c.request.Get(ctx, "/v1/categories")
if err != nil {
return nil, err
}
defer body.Close()
var categories Categories
if err := json.NewDecoder(body).Decode(&categories); err != nil {
return nil, fmt.Errorf("miniflux: response error (%v)", err)
}
return categories, nil
}
// CategoriesWithCounters fetches the categories with their respective feed and unread counts.
func (c *Client) CategoriesWithCounters() (Categories, error) {
ctx, cancel := withDefaultTimeout()
defer cancel()
return c.CategoriesWithCountersContext(ctx)
}
// CategoriesWithCountersContext fetches the categories with their respective feed and unread counts.
func (c *Client) CategoriesWithCountersContext(ctx context.Context) (Categories, error) {
body, err := c.request.Get(ctx, "/v1/categories?counts=true")
if err != nil {
return nil, err
}
defer body.Close()
var categories Categories
if err := json.NewDecoder(body).Decode(&categories); err != nil {
return nil, fmt.Errorf("miniflux: response error (%v)", err)
}
return categories, nil
}
// CreateCategory creates a new category.
func (c *Client) CreateCategory(title string) (*Category, error) {
ctx, cancel := withDefaultTimeout()
defer cancel()
return c.CreateCategoryContext(ctx, title)
}
// CreateCategoryContext creates a new category.
func (c *Client) CreateCategoryContext(ctx context.Context, title string) (*Category, error) {
body, err := c.request.Post(ctx, "/v1/categories", &CategoryCreationRequest{
Title: title,
})
if err != nil {
return nil, err
}
defer body.Close()
var category *Category
if err := json.NewDecoder(body).Decode(&category); err != nil {
return nil, fmt.Errorf("miniflux: response error (%v)", err)
}
return category, nil
}
// CreateCategoryWithOptions creates a new category with options.
func (c *Client) CreateCategoryWithOptions(createRequest *CategoryCreationRequest) (*Category, error) {
ctx, cancel := withDefaultTimeout()
defer cancel()
return c.CreateCategoryWithOptionsContext(ctx, createRequest)
}
// CreateCategoryWithOptionsContext creates a new category with options.
func (c *Client) CreateCategoryWithOptionsContext(ctx context.Context, createRequest *CategoryCreationRequest) (*Category, error) {
body, err := c.request.Post(ctx, "/v1/categories", createRequest)
if err != nil {
return nil, err
}
defer body.Close()
var category *Category
if err := json.NewDecoder(body).Decode(&category); err != nil {
return nil, fmt.Errorf("miniflux: response error (%v)", err)
}
return category, nil
}
// UpdateCategory updates a category.
func (c *Client) UpdateCategory(categoryID int64, title string) (*Category, error) {
ctx, cancel := withDefaultTimeout()
defer cancel()
return c.UpdateCategoryContext(ctx, categoryID, title)
}
// UpdateCategoryContext updates a category.
func (c *Client) UpdateCategoryContext(ctx context.Context, categoryID int64, title string) (*Category, error) {
body, err := c.request.Put(ctx, fmt.Sprintf("/v1/categories/%d", categoryID), &CategoryModificationRequest{
Title: SetOptionalField(title),
})
if err != nil {
return nil, err
}
defer body.Close()
var category *Category
if err := json.NewDecoder(body).Decode(&category); err != nil {
return nil, fmt.Errorf("miniflux: response error (%v)", err)
}
return category, nil
}
// UpdateCategoryWithOptions updates a category with options.
func (c *Client) UpdateCategoryWithOptions(categoryID int64, categoryChanges *CategoryModificationRequest) (*Category, error) {
ctx, cancel := withDefaultTimeout()
defer cancel()
return c.UpdateCategoryWithOptionsContext(ctx, categoryID, categoryChanges)
}
// UpdateCategoryWithOptionsContext updates a category with options.
func (c *Client) UpdateCategoryWithOptionsContext(ctx context.Context, categoryID int64, categoryChanges *CategoryModificationRequest) (*Category, error) {
body, err := c.request.Put(ctx, fmt.Sprintf("/v1/categories/%d", categoryID), categoryChanges)
if err != nil {
return nil, err
}
defer body.Close()
var category *Category
if err := json.NewDecoder(body).Decode(&category); err != nil {
return nil, fmt.Errorf("miniflux: response error (%v)", err)
}
return category, nil
}
// MarkCategoryAsRead marks all unread entries in a category as read.
func (c *Client) MarkCategoryAsRead(categoryID int64) error {
ctx, cancel := withDefaultTimeout()
defer cancel()
return c.MarkCategoryAsReadContext(ctx, categoryID)
}
// MarkCategoryAsReadContext marks all unread entries in a category as read.
func (c *Client) MarkCategoryAsReadContext(ctx context.Context, categoryID int64) error {
_, err := c.request.Put(ctx, fmt.Sprintf("/v1/categories/%d/mark-all-as-read", categoryID), nil)
return err
}
// CategoryFeeds gets feeds of a category.
func (c *Client) CategoryFeeds(categoryID int64) (Feeds, error) {
ctx, cancel := withDefaultTimeout()
defer cancel()
return c.CategoryFeedsContext(ctx, categoryID)
}
// CategoryFeedsContext gets feeds of a category.
func (c *Client) CategoryFeedsContext(ctx context.Context, categoryID int64) (Feeds, error) {
body, err := c.request.Get(ctx, fmt.Sprintf("/v1/categories/%d/feeds", categoryID))
if err != nil {
return nil, err
}
defer body.Close()
var feeds Feeds
if err := json.NewDecoder(body).Decode(&feeds); err != nil {
return nil, fmt.Errorf("miniflux: response error (%v)", err)
}
return feeds, nil
}
// DeleteCategory removes a category.
func (c *Client) DeleteCategory(categoryID int64) error {
ctx, cancel := withDefaultTimeout()
defer cancel()
return c.DeleteCategoryContext(ctx, categoryID)
}
// DeleteCategoryContext removes a category.
func (c *Client) DeleteCategoryContext(ctx context.Context, categoryID int64) error {
return c.request.Delete(ctx, fmt.Sprintf("/v1/categories/%d", categoryID))
}
// RefreshCategory refreshes a category.
func (c *Client) RefreshCategory(categoryID int64) error {
ctx, cancel := withDefaultTimeout()
defer cancel()
return c.RefreshCategoryContext(ctx, categoryID)
}
// RefreshCategoryContext refreshes a category.
func (c *Client) RefreshCategoryContext(ctx context.Context, categoryID int64) error {
_, err := c.request.Put(ctx, fmt.Sprintf("/v1/categories/%d/refresh", categoryID), nil)
return err
}
// Feeds gets all feeds.
func (c *Client) Feeds() (Feeds, error) {
ctx, cancel := withDefaultTimeout()
defer cancel()
return c.FeedsContext(ctx)
}
// FeedsContext gets all feeds.
func (c *Client) FeedsContext(ctx context.Context) (Feeds, error) {
body, err := c.request.Get(ctx, "/v1/feeds")
if err != nil {
return nil, err
}
defer body.Close()
var feeds Feeds
if err := json.NewDecoder(body).Decode(&feeds); err != nil {
return nil, fmt.Errorf("miniflux: response error (%v)", err)
}
return feeds, nil
}
// Export creates OPML file.
func (c *Client) Export() ([]byte, error) {
ctx, cancel := withDefaultTimeout()
defer cancel()
return c.ExportContext(ctx)
}
// ExportContext creates OPML file.
func (c *Client) ExportContext(ctx context.Context) ([]byte, error) {
body, err := c.request.Get(ctx, "/v1/export")
if err != nil {
return nil, err
}
defer body.Close()
opml, err := io.ReadAll(body)
if err != nil {
return nil, err
}
return opml, nil
}
// Import imports an OPML file.
func (c *Client) Import(f io.ReadCloser) error {
ctx, cancel := withDefaultTimeout()
defer cancel()
return c.ImportContext(ctx, f)
}
// ImportContext imports an OPML file.
func (c *Client) ImportContext(ctx context.Context, f io.ReadCloser) error {
_, err := c.request.PostFile(ctx, "/v1/import", f)
return err
}
// Feed gets a feed.
func (c *Client) Feed(feedID int64) (*Feed, error) {
ctx, cancel := withDefaultTimeout()
defer cancel()
return c.FeedContext(ctx, feedID)
}
// FeedContext gets a feed.
func (c *Client) FeedContext(ctx context.Context, feedID int64) (*Feed, error) {
body, err := c.request.Get(ctx, fmt.Sprintf("/v1/feeds/%d", feedID))
if err != nil {
return nil, err
}
defer body.Close()
var feed *Feed
if err := json.NewDecoder(body).Decode(&feed); err != nil {
return nil, fmt.Errorf("miniflux: response error (%v)", err)
}
return feed, nil
}
// CreateFeed creates a new feed.
func (c *Client) CreateFeed(feedCreationRequest *FeedCreationRequest) (int64, error) {
ctx, cancel := withDefaultTimeout()
defer cancel()
return c.CreateFeedContext(ctx, feedCreationRequest)
}
// CreateFeedContext creates a new feed.
func (c *Client) CreateFeedContext(ctx context.Context, feedCreationRequest *FeedCreationRequest) (int64, error) {
body, err := c.request.Post(ctx, "/v1/feeds", feedCreationRequest)
if err != nil {
return 0, err
}
defer body.Close()
type result struct {
FeedID int64 `json:"feed_id"`
}
var r result
if err := json.NewDecoder(body).Decode(&r); err != nil {
return 0, fmt.Errorf("miniflux: response error (%v)", err)
}
return r.FeedID, nil
}
// UpdateFeed updates a feed.
func (c *Client) UpdateFeed(feedID int64, feedChanges *FeedModificationRequest) (*Feed, error) {
ctx, cancel := withDefaultTimeout()
defer cancel()
return c.UpdateFeedContext(ctx, feedID, feedChanges)
}
// UpdateFeedContext updates a feed.
func (c *Client) UpdateFeedContext(ctx context.Context, feedID int64, feedChanges *FeedModificationRequest) (*Feed, error) {
body, err := c.request.Put(ctx, fmt.Sprintf("/v1/feeds/%d", feedID), feedChanges)
if err != nil {
return nil, err
}
defer body.Close()
var f *Feed
if err := json.NewDecoder(body).Decode(&f); err != nil {
return nil, fmt.Errorf("miniflux: response error (%v)", err)
}
return f, nil
}
// ImportFeedEntry imports a single entry into a feed.
func (c *Client) ImportFeedEntry(feedID int64, payload any) (int64, error) {
ctx, cancel := withDefaultTimeout()
defer cancel()
body, err := c.request.Post(
ctx,
fmt.Sprintf("/v1/feeds/%d/entries/import", feedID),
payload,
)
if err != nil {
return 0, err
}
defer body.Close()
var response struct {
ID int64 `json:"id"`
}
if err := json.NewDecoder(body).Decode(&response); err != nil {
return 0, fmt.Errorf("miniflux: json error (%v)", err)
}
return response.ID, nil
}
// MarkFeedAsRead marks all unread entries of the feed as read.
func (c *Client) MarkFeedAsRead(feedID int64) error {
ctx, cancel := withDefaultTimeout()
defer cancel()
return c.MarkFeedAsReadContext(ctx, feedID)
}
// MarkFeedAsReadContext marks all unread entries of the feed as read.
func (c *Client) MarkFeedAsReadContext(ctx context.Context, feedID int64) error {
_, err := c.request.Put(ctx, fmt.Sprintf("/v1/feeds/%d/mark-all-as-read", feedID), nil)
return err
}
// RefreshAllFeeds refreshes all feeds.
func (c *Client) RefreshAllFeeds() error {
ctx, cancel := withDefaultTimeout()
defer cancel()
return c.RefreshAllFeedsContext(ctx)
}
// RefreshAllFeedsContext refreshes all feeds.
func (c *Client) RefreshAllFeedsContext(ctx context.Context) error {
_, err := c.request.Put(ctx, "/v1/feeds/refresh", nil)
return err
}
// RefreshFeed refreshes a feed.
func (c *Client) RefreshFeed(feedID int64) error {
ctx, cancel := withDefaultTimeout()
defer cancel()
return c.RefreshFeedContext(ctx, feedID)
}
// RefreshFeedContext refreshes a feed.
func (c *Client) RefreshFeedContext(ctx context.Context, feedID int64) error {
_, err := c.request.Put(ctx, fmt.Sprintf("/v1/feeds/%d/refresh", feedID), nil)
return err
}
// DeleteFeed removes a feed.
func (c *Client) DeleteFeed(feedID int64) error {
ctx, cancel := withDefaultTimeout()
defer cancel()
return c.DeleteFeedContext(ctx, feedID)
}
// DeleteFeedContext removes a feed.
func (c *Client) DeleteFeedContext(ctx context.Context, feedID int64) error {
return c.request.Delete(ctx, fmt.Sprintf("/v1/feeds/%d", feedID))
}
// FeedIcon gets a feed icon.
func (c *Client) FeedIcon(feedID int64) (*FeedIcon, error) {
ctx, cancel := withDefaultTimeout()
defer cancel()
return c.FeedIconContext(ctx, feedID)
}
// FeedIconContext gets a feed icon.
func (c *Client) FeedIconContext(ctx context.Context, feedID int64) (*FeedIcon, error) {
body, err := c.request.Get(ctx, fmt.Sprintf("/v1/feeds/%d/icon", feedID))
if err != nil {
return nil, err
}
defer body.Close()
var feedIcon *FeedIcon
if err := json.NewDecoder(body).Decode(&feedIcon); err != nil {
return nil, fmt.Errorf("miniflux: response error (%v)", err)
}
return feedIcon, nil
}
// FeedEntry gets a single feed entry.
func (c *Client) FeedEntry(feedID, entryID int64) (*Entry, error) {
ctx, cancel := withDefaultTimeout()
defer cancel()
return c.FeedEntryContext(ctx, feedID, entryID)
}
// FeedEntryContext gets a single feed entry.
func (c *Client) FeedEntryContext(ctx context.Context, feedID, entryID int64) (*Entry, error) {
body, err := c.request.Get(ctx, fmt.Sprintf("/v1/feeds/%d/entries/%d", feedID, entryID))
if err != nil {
return nil, err
}
defer body.Close()
var entry *Entry
if err := json.NewDecoder(body).Decode(&entry); err != nil {
return nil, fmt.Errorf("miniflux: response error (%v)", err)
}
return entry, nil
}
// CategoryEntry gets a single category entry.
func (c *Client) CategoryEntry(categoryID, entryID int64) (*Entry, error) {
ctx, cancel := withDefaultTimeout()
defer cancel()
return c.CategoryEntryContext(ctx, categoryID, entryID)
}
// CategoryEntryContext gets a single category entry.
func (c *Client) CategoryEntryContext(ctx context.Context, categoryID, entryID int64) (*Entry, error) {
body, err := c.request.Get(ctx, fmt.Sprintf("/v1/categories/%d/entries/%d", categoryID, entryID))
if err != nil {
return nil, err
}
defer body.Close()
var entry *Entry
if err := json.NewDecoder(body).Decode(&entry); err != nil {
return nil, fmt.Errorf("miniflux: response error (%v)", err)
}
return entry, nil
}
// Entry gets a single entry.
func (c *Client) Entry(entryID int64) (*Entry, error) {
ctx, cancel := withDefaultTimeout()
defer cancel()
return c.EntryContext(ctx, entryID)
}
// EntryContext gets a single entry.
func (c *Client) EntryContext(ctx context.Context, entryID int64) (*Entry, error) {
body, err := c.request.Get(ctx, fmt.Sprintf("/v1/entries/%d", entryID))
if err != nil {
return nil, err
}
defer body.Close()
var entry *Entry
if err := json.NewDecoder(body).Decode(&entry); err != nil {
return nil, fmt.Errorf("miniflux: response error (%v)", err)
}
return entry, nil
}
// Entries fetch entries.
func (c *Client) Entries(filter *Filter) (*EntryResultSet, error) {
ctx, cancel := withDefaultTimeout()
defer cancel()
return c.EntriesContext(ctx, filter)
}
// EntriesContext fetches entries.
func (c *Client) EntriesContext(ctx context.Context, filter *Filter) (*EntryResultSet, error) {
path := buildFilterQueryString("/v1/entries", filter)
body, err := c.request.Get(ctx, path)
if err != nil {
return nil, err
}
defer body.Close()
var result EntryResultSet
if err := json.NewDecoder(body).Decode(&result); err != nil {
return nil, fmt.Errorf("miniflux: response error (%v)", err)
}
return &result, nil
}
// FeedEntries fetch feed entries.
func (c *Client) FeedEntries(feedID int64, filter *Filter) (*EntryResultSet, error) {
ctx, cancel := withDefaultTimeout()
defer cancel()
return c.FeedEntriesContext(ctx, feedID, filter)
}
// FeedEntriesContext fetches feed entries.
func (c *Client) FeedEntriesContext(ctx context.Context, feedID int64, filter *Filter) (*EntryResultSet, error) {
path := buildFilterQueryString(fmt.Sprintf("/v1/feeds/%d/entries", feedID), filter)
body, err := c.request.Get(ctx, path)
if err != nil {
return nil, err
}
defer body.Close()
var result EntryResultSet
if err := json.NewDecoder(body).Decode(&result); err != nil {
return nil, fmt.Errorf("miniflux: response error (%v)", err)
}
return &result, nil
}
// CategoryEntries fetch entries of a category.
func (c *Client) CategoryEntries(categoryID int64, filter *Filter) (*EntryResultSet, error) {
ctx, cancel := withDefaultTimeout()
defer cancel()
return c.CategoryEntriesContext(ctx, categoryID, filter)
}
// CategoryEntriesContext fetches category entries.
func (c *Client) CategoryEntriesContext(ctx context.Context, categoryID int64, filter *Filter) (*EntryResultSet, error) {
path := buildFilterQueryString(fmt.Sprintf("/v1/categories/%d/entries", categoryID), filter)
body, err := c.request.Get(ctx, path)
if err != nil {
return nil, err
}
defer body.Close()
var result EntryResultSet
if err := json.NewDecoder(body).Decode(&result); err != nil {
return nil, fmt.Errorf("miniflux: response error (%v)", err)
}
return &result, nil
}
// UpdateEntries updates the status of a list of entries.
func (c *Client) UpdateEntries(entryIDs []int64, status string) error {
ctx, cancel := withDefaultTimeout()
defer cancel()
return c.UpdateEntriesContext(ctx, entryIDs, status)
}
// UpdateEntriesContext updates the status of a list of entries.
func (c *Client) UpdateEntriesContext(ctx context.Context, entryIDs []int64, status string) error {
type payload struct {
EntryIDs []int64 `json:"entry_ids"`
Status string `json:"status"`
}
_, err := c.request.Put(ctx, "/v1/entries", &payload{EntryIDs: entryIDs, Status: status})
return err
}
// UpdateEntry updates an entry.
func (c *Client) UpdateEntry(entryID int64, entryChanges *EntryModificationRequest) (*Entry, error) {
ctx, cancel := withDefaultTimeout()
defer cancel()
return c.UpdateEntryContext(ctx, entryID, entryChanges)
}
// UpdateEntryContext updates an entry.
func (c *Client) UpdateEntryContext(ctx context.Context, entryID int64, entryChanges *EntryModificationRequest) (*Entry, error) {
body, err := c.request.Put(ctx, fmt.Sprintf("/v1/entries/%d", entryID), entryChanges)
if err != nil {
return nil, err
}
defer body.Close()
var entry *Entry
if err := json.NewDecoder(body).Decode(&entry); err != nil {
return nil, fmt.Errorf("miniflux: response error (%v)", err)
}
return entry, nil
}
// ToggleStarred toggles entry starred value.
func (c *Client) ToggleStarred(entryID int64) error {
ctx, cancel := withDefaultTimeout()
defer cancel()
return c.ToggleStarredContext(ctx, entryID)
}
// ToggleStarredContext toggles entry starred value.
func (c *Client) ToggleStarredContext(ctx context.Context, entryID int64) error {
_, err := c.request.Put(ctx, fmt.Sprintf("/v1/entries/%d/star", entryID), nil)
return err
}
// SaveEntry sends an entry to a third-party service.
func (c *Client) SaveEntry(entryID int64) error {
ctx, cancel := withDefaultTimeout()
defer cancel()
return c.SaveEntryContext(ctx, entryID)
}
// SaveEntryContext sends an entry to a third-party service.
func (c *Client) SaveEntryContext(ctx context.Context, entryID int64) error {
_, err := c.request.Post(ctx, fmt.Sprintf("/v1/entries/%d/save", entryID), nil)
return err
}
// FetchEntryOriginalContent fetches the original content of an entry using the scraper.
func (c *Client) FetchEntryOriginalContent(entryID int64) (string, error) {
ctx, cancel := withDefaultTimeout()
defer cancel()
return c.FetchEntryOriginalContentContext(ctx, entryID)
}
// FetchEntryOriginalContentContext fetches the original content of an entry using the scraper.
func (c *Client) FetchEntryOriginalContentContext(ctx context.Context, entryID int64) (string, error) {
body, err := c.request.Get(ctx, 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.
func (c *Client) FetchCounters() (*FeedCounters, error) {
ctx, cancel := withDefaultTimeout()
defer cancel()
return c.FetchCountersContext(ctx)
}
// FetchCountersContext fetches feed counters.
func (c *Client) FetchCountersContext(ctx context.Context) (*FeedCounters, error) {
body, err := c.request.Get(ctx, "/v1/feeds/counters")
if err != nil {
return nil, err
}
defer body.Close()
var result FeedCounters
if err := json.NewDecoder(body).Decode(&result); err != nil {
return nil, fmt.Errorf("miniflux: response error (%v)", err)
}
return &result, nil
}
// FlushHistory changes all entries with the status "read" to "removed".
func (c *Client) FlushHistory() error {
ctx, cancel := withDefaultTimeout()
defer cancel()
return c.FlushHistoryContext(ctx)
}
// FlushHistoryContext changes all entries with the status "read" to "removed".
func (c *Client) FlushHistoryContext(ctx context.Context) error {
_, err := c.request.Put(ctx, "/v1/flush-history", nil)
return err
}
// Icon fetches a feed icon.
func (c *Client) Icon(iconID int64) (*FeedIcon, error) {
ctx, cancel := withDefaultTimeout()
defer cancel()
return c.IconContext(ctx, iconID)
}
// IconContext fetches a feed icon.
func (c *Client) IconContext(ctx context.Context, iconID int64) (*FeedIcon, error) {
body, err := c.request.Get(ctx, fmt.Sprintf("/v1/icons/%d", iconID))
if err != nil {
return nil, err
}
defer body.Close()
var feedIcon *FeedIcon
if err := json.NewDecoder(body).Decode(&feedIcon); err != nil {
return nil, fmt.Errorf("miniflux: response error (%v)", err)
}
return feedIcon, nil
}
// Enclosure fetches a specific enclosure.
func (c *Client) Enclosure(enclosureID int64) (*Enclosure, error) {
ctx, cancel := withDefaultTimeout()
defer cancel()
return c.EnclosureContext(ctx, enclosureID)
}
// EnclosureContext fetches a specific enclosure.
func (c *Client) EnclosureContext(ctx context.Context, enclosureID int64) (*Enclosure, error) {
body, err := c.request.Get(ctx, fmt.Sprintf("/v1/enclosures/%d", enclosureID))
if err != nil {
return nil, err
}
defer body.Close()
var enclosure *Enclosure
if err := json.NewDecoder(body).Decode(&enclosure); err != nil {
return nil, fmt.Errorf("miniflux: response error(%v)", err)
}
return enclosure, nil
}
// UpdateEnclosure updates an enclosure.
func (c *Client) UpdateEnclosure(enclosureID int64, enclosureUpdate *EnclosureUpdateRequest) error {
ctx, cancel := withDefaultTimeout()
defer cancel()
return c.UpdateEnclosureContext(ctx, enclosureID, enclosureUpdate)
}
// UpdateEnclosureContext updates an enclosure.
func (c *Client) UpdateEnclosureContext(ctx context.Context, enclosureID int64, enclosureUpdate *EnclosureUpdateRequest) error {
_, err := c.request.Put(ctx, fmt.Sprintf("/v1/enclosures/%d", enclosureID), enclosureUpdate)
return err
}
func buildFilterQueryString(path string, filter *Filter) string {
if filter != nil {
values := url.Values{}
if filter.Status != "" {
values.Set("status", filter.Status)
}
if filter.Direction != "" {
values.Set("direction", filter.Direction)
}
if filter.Order != "" {
values.Set("order", filter.Order)
}
if filter.Limit >= 0 {
values.Set("limit", strconv.Itoa(filter.Limit))
}
if filter.Offset >= 0 {
values.Set("offset", strconv.Itoa(filter.Offset))
}
if filter.After > 0 {
values.Set("after", strconv.FormatInt(filter.After, 10))
}
if filter.Before > 0 {
values.Set("before", strconv.FormatInt(filter.Before, 10))
}
if filter.PublishedAfter > 0 {
values.Set("published_after", strconv.FormatInt(filter.PublishedAfter, 10))
}
if filter.PublishedBefore > 0 {
values.Set("published_before", strconv.FormatInt(filter.PublishedBefore, 10))
}
if filter.ChangedAfter > 0 {
values.Set("changed_after", strconv.FormatInt(filter.ChangedAfter, 10))
}
if filter.ChangedBefore > 0 {
values.Set("changed_before", strconv.FormatInt(filter.ChangedBefore, 10))
}
if filter.AfterEntryID > 0 {
values.Set("after_entry_id", strconv.FormatInt(filter.AfterEntryID, 10))
}
if filter.BeforeEntryID > 0 {
values.Set("before_entry_id", strconv.FormatInt(filter.BeforeEntryID, 10))
}
if filter.Starred != "" {
values.Set("starred", filter.Starred)
}
if filter.Search != "" {
values.Set("search", filter.Search)
}
if filter.CategoryID > 0 {
values.Set("category_id", strconv.FormatInt(filter.CategoryID, 10))
}
if filter.FeedID > 0 {
values.Set("feed_id", strconv.FormatInt(filter.FeedID, 10))
}
if filter.GloballyVisible {
values.Set("globally_visible", "true")
}
for _, status := range filter.Statuses {
values.Add("status", status)
}
path = fmt.Sprintf("%s?%s", path, values.Encode())
}
return path
}
v2-2.2.16/client/client_test.go 0000664 0000000 0000000 00000110263 15127074645 0016271 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package client
import (
"bytes"
"encoding/json"
"io"
"net/http"
"reflect"
"testing"
"time"
)
type roundTripperFunc func(req *http.Request) (*http.Response, error)
func (fn roundTripperFunc) RoundTrip(req *http.Request) (*http.Response, error) {
return fn(req)
}
func newFakeHTTPClient(
t *testing.T,
fn func(t *testing.T, req *http.Request) *http.Response,
) *http.Client {
return &http.Client{
Transport: roundTripperFunc(
func(req *http.Request) (*http.Response, error) {
return fn(t, req), nil
}),
}
}
func jsonResponseFrom(
t *testing.T,
status int,
headers http.Header,
body any,
) *http.Response {
data, err := json.Marshal(body)
if err != nil {
t.Fatalf("Unable to marshal body: %v", err)
}
return &http.Response{
StatusCode: status,
Body: io.NopCloser(bytes.NewBuffer(data)),
Header: headers,
}
}
func asJSON(data any) string {
json, err := json.MarshalIndent(data, "", " ")
if err != nil {
panic(err)
}
return string(json)
}
func expectRequest(
t *testing.T,
method string,
url string,
checkBody func(r io.Reader),
req *http.Request,
) {
if req.Method != method {
t.Fatalf("Expected method to be %s, got %s", method, req.Method)
}
if req.URL.String() != url {
t.Fatalf("Expected URL path to be %s, got %s", url, req.URL)
}
if checkBody != nil {
checkBody(req.Body)
}
}
func expectFromJSON[T any](
t *testing.T,
r io.Reader,
expected *T,
) {
var got T
if err := json.NewDecoder(r).Decode(&got); err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if !reflect.DeepEqual(&got, expected) {
t.Fatalf("Expected %s, got %s", asJSON(expected), asJSON(got))
}
}
func TestHealthcheck(t *testing.T) {
client := NewClientWithOptions(
"http://mf",
WithHTTPClient(
newFakeHTTPClient(t, func(t *testing.T, req *http.Request) *http.Response {
expectRequest(t, http.MethodGet, "http://mf/healthcheck", nil, req)
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(bytes.NewBufferString("OK")),
}
})))
if err := client.HealthcheckContext(t.Context()); err != nil {
t.Fatalf("Expected no error, got %v", err)
}
}
func TestVersion(t *testing.T) {
expected := &VersionResponse{
Version: "1.0.0",
Commit: "1234567890",
BuildDate: "2021-01-01T00:00:00Z",
GoVersion: "go1.20",
Compiler: "gc",
Arch: "amd64",
OS: "linux",
}
client := NewClientWithOptions(
"http://mf",
WithHTTPClient(
newFakeHTTPClient(t, func(t *testing.T, req *http.Request) *http.Response {
expectRequest(t, http.MethodGet, "http://mf/v1/version", nil, req)
return jsonResponseFrom(t, http.StatusOK, http.Header{}, expected)
})))
res, err := client.VersionContext(t.Context())
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if !reflect.DeepEqual(res, expected) {
t.Fatalf("Expected %+v, got %+v", expected, res)
}
}
func TestMe(t *testing.T) {
expected := &User{
ID: 1,
Username: "test",
Password: "password",
IsAdmin: false,
Theme: "light",
Language: "en",
Timezone: "UTC",
EntryDirection: "asc",
EntryOrder: "created_at",
Stylesheet: "default",
CustomJS: "custom.js",
GoogleID: "google-id",
OpenIDConnectID: "openid-connect-id",
EntriesPerPage: 10,
KeyboardShortcuts: true,
ShowReadingTime: true,
EntrySwipe: true,
GestureNav: "horizontal",
DisplayMode: "read",
DefaultReadingSpeed: 1,
CJKReadingSpeed: 1,
DefaultHomePage: "home",
CategoriesSortingOrder: "asc",
MarkReadOnView: true,
MediaPlaybackRate: 1.0,
BlockFilterEntryRules: "block",
KeepFilterEntryRules: "keep",
ExternalFontHosts: "https://fonts.googleapis.com",
AlwaysOpenExternalLinks: true,
OpenExternalLinksInNewTab: true,
}
client := NewClientWithOptions(
"http://mf",
WithHTTPClient(
newFakeHTTPClient(t, func(t *testing.T, req *http.Request) *http.Response {
expectRequest(t, http.MethodGet, "http://mf/v1/me", nil, req)
return jsonResponseFrom(t, http.StatusOK, http.Header{}, expected)
})))
res, err := client.MeContext(t.Context())
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if !reflect.DeepEqual(res, expected) {
t.Fatalf("Expected %+v, got %+v", expected, res)
}
}
func TestUsers(t *testing.T) {
expected := Users{
{
ID: 1,
Username: "test1",
},
{
ID: 2,
Username: "test2",
},
}
client := NewClientWithOptions(
"http://mf",
WithHTTPClient(
newFakeHTTPClient(t, func(t *testing.T, req *http.Request) *http.Response {
expectRequest(t, http.MethodGet, "http://mf/v1/users", nil, req)
return jsonResponseFrom(t, http.StatusOK, http.Header{}, expected)
})))
res, err := client.UsersContext(t.Context())
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if !reflect.DeepEqual(res, expected) {
t.Fatalf("Expected %+v, got %+v", expected, res)
}
}
func TestUserByID(t *testing.T) {
expected := &User{
ID: 1,
Username: "test",
}
client := NewClientWithOptions(
"http://mf",
WithHTTPClient(
newFakeHTTPClient(t, func(t *testing.T, req *http.Request) *http.Response {
expectRequest(t, http.MethodGet, "http://mf/v1/users/1", nil, req)
return jsonResponseFrom(t, http.StatusOK, http.Header{}, expected)
})))
res, err := client.UserByIDContext(t.Context(), 1)
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if !reflect.DeepEqual(res, expected) {
t.Fatalf("Expected %+v, got %+v", expected, res)
}
}
func TestUserByUsername(t *testing.T) {
expected := &User{
ID: 1,
Username: "test",
}
client := NewClientWithOptions(
"http://mf",
WithHTTPClient(
newFakeHTTPClient(t, func(t *testing.T, req *http.Request) *http.Response {
expectRequest(t, http.MethodGet, "http://mf/v1/users/test", nil, req)
return jsonResponseFrom(t, http.StatusOK, http.Header{}, expected)
})))
res, err := client.UserByUsernameContext(t.Context(), "test")
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if !reflect.DeepEqual(res, expected) {
t.Fatalf("Expected %+v, got %+v", expected, res)
}
}
func TestCreateUser(t *testing.T) {
expected := &User{
ID: 1,
Username: "test",
Password: "password",
IsAdmin: true,
}
client := NewClientWithOptions(
"http://mf",
WithHTTPClient(
newFakeHTTPClient(t, func(t *testing.T, req *http.Request) *http.Response {
exp := UserCreationRequest{
Username: "test",
Password: "password",
IsAdmin: true,
}
expectRequest(
t,
http.MethodPost,
"http://mf/v1/users",
func(r io.Reader) {
expectFromJSON(t, r, &exp)
},
req)
return jsonResponseFrom(t, http.StatusOK, http.Header{}, expected)
})))
res, err := client.CreateUserContext(t.Context(), "test", "password", true)
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if !reflect.DeepEqual(res, expected) {
t.Fatalf("Expected %+v, got %+v", expected, res)
}
}
func TestUpdateUser(t *testing.T) {
expected := &User{
ID: 1,
Username: "test",
Password: "password",
IsAdmin: true,
}
client := NewClientWithOptions(
"http://mf",
WithHTTPClient(
newFakeHTTPClient(t, func(t *testing.T, req *http.Request) *http.Response {
expectRequest(t, http.MethodPut, "http://mf/v1/users/1", func(r io.Reader) {
expectFromJSON(t, r, &UserModificationRequest{
Username: &expected.Username,
Password: &expected.Password,
})
}, req)
return jsonResponseFrom(t, http.StatusOK, http.Header{}, expected)
})))
res, err := client.UpdateUserContext(t.Context(), 1, &UserModificationRequest{
Username: &expected.Username,
Password: &expected.Password,
})
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if !reflect.DeepEqual(res, expected) {
t.Fatalf("Expected %+v, got %+v", expected, res)
}
}
func TestDeleteUser(t *testing.T) {
client := NewClientWithOptions(
"http://mf",
WithHTTPClient(
newFakeHTTPClient(t, func(t *testing.T, req *http.Request) *http.Response {
expectRequest(t, http.MethodDelete, "http://mf/v1/users/1", nil, req)
return jsonResponseFrom(t, http.StatusOK, http.Header{}, nil)
})))
if err := client.DeleteUserContext(t.Context(), 1); err != nil {
t.Fatalf("Expected no error, got %v", err)
}
}
func TestAPIKeys(t *testing.T) {
expected := APIKeys{
{
ID: 1,
Token: "token",
Description: "test",
},
{
ID: 2,
Token: "token2",
Description: "test2",
},
}
client := NewClientWithOptions(
"http://mf",
WithHTTPClient(
newFakeHTTPClient(t, func(t *testing.T, req *http.Request) *http.Response {
expectRequest(t, http.MethodGet, "http://mf/v1/api-keys", nil, req)
return jsonResponseFrom(t, http.StatusOK, http.Header{}, expected)
})))
res, err := client.APIKeysContext(t.Context())
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if !reflect.DeepEqual(res, expected) {
t.Fatalf("Expected %+v, got %+v", expected, res)
}
}
func TestCreateAPIKey(t *testing.T) {
expected := &APIKey{
ID: 42,
Token: "some-token",
Description: "desc",
}
client := NewClientWithOptions(
"http://mf",
WithHTTPClient(
newFakeHTTPClient(t, func(t *testing.T, req *http.Request) *http.Response {
expectRequest(t, http.MethodPost, "http://mf/v1/api-keys", func(r io.Reader) {
expectFromJSON(t, r, &APIKeyCreationRequest{
Description: "desc",
})
}, req)
return jsonResponseFrom(t, http.StatusOK, http.Header{}, expected)
})))
res, err := client.CreateAPIKeyContext(t.Context(), "desc")
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if !reflect.DeepEqual(res, expected) {
t.Fatalf("Expected %+v, got %+v", expected, res)
}
}
func TestDeleteAPIKey(t *testing.T) {
client := NewClientWithOptions(
"http://mf",
WithHTTPClient(
newFakeHTTPClient(t, func(t *testing.T, req *http.Request) *http.Response {
expectRequest(t, http.MethodDelete, "http://mf/v1/api-keys/1", nil, req)
return jsonResponseFrom(t, http.StatusOK, http.Header{}, nil)
})))
if err := client.DeleteAPIKeyContext(t.Context(), 1); err != nil {
t.Fatalf("Expected no error, got %v", err)
}
}
func TestMarkAllAsRead(t *testing.T) {
client := NewClientWithOptions(
"http://mf",
WithHTTPClient(
newFakeHTTPClient(t, func(t *testing.T, req *http.Request) *http.Response {
expectRequest(t, http.MethodPut, "http://mf/v1/users/1/mark-all-as-read", nil, req)
return jsonResponseFrom(t, http.StatusOK, http.Header{}, nil)
})))
if err := client.MarkAllAsReadContext(t.Context(), 1); err != nil {
t.Fatalf("Expected no error, got %v", err)
}
}
func TestIntegrationsStatus(t *testing.T) {
client := NewClientWithOptions(
"http://mf",
WithHTTPClient(
newFakeHTTPClient(t, func(t *testing.T, req *http.Request) *http.Response {
expectRequest(t, http.MethodGet, "http://mf/v1/integrations/status", nil, req)
return jsonResponseFrom(t, http.StatusOK, http.Header{}, struct {
HasIntegrations bool `json:"has_integrations"`
}{
HasIntegrations: true,
})
})))
status, err := client.IntegrationsStatusContext(t.Context())
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if !status {
t.Fatalf("Expected integrations status to be true, got false")
}
}
func TestDiscover(t *testing.T) {
expected := Subscriptions{
{
URL: "http://example.com",
Title: "Example",
Type: "rss",
},
}
client := NewClientWithOptions(
"http://mf",
WithHTTPClient(
newFakeHTTPClient(t, func(t *testing.T, req *http.Request) *http.Response {
expectRequest(t, http.MethodPost, "http://mf/v1/discover", func(r io.Reader) {
expectFromJSON(t, r, &map[string]string{"url": "http://example.com"})
}, req)
return jsonResponseFrom(t, http.StatusOK, http.Header{}, expected)
})))
res, err := client.DiscoverContext(t.Context(), "http://example.com")
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if !reflect.DeepEqual(res, expected) {
t.Fatalf("Expected %+v, got %+v", expected, res)
}
}
func TestCategories(t *testing.T) {
expected := Categories{
{
ID: 1,
Title: "Example",
},
}
client := NewClientWithOptions(
"http://mf",
WithHTTPClient(
newFakeHTTPClient(t, func(t *testing.T, req *http.Request) *http.Response {
expectRequest(t, http.MethodGet, "http://mf/v1/categories", nil, req)
return jsonResponseFrom(t, http.StatusOK, http.Header{}, expected)
})))
res, err := client.CategoriesContext(t.Context())
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if !reflect.DeepEqual(res, expected) {
t.Fatalf("Expected %+v, got %+v", expected, res)
}
}
func TestCategoriesWithCounters(t *testing.T) {
feedCount := 1
totalUnread := 2
expected := Categories{
{
ID: 1,
Title: "Example",
FeedCount: &feedCount,
TotalUnread: &totalUnread,
},
}
client := NewClientWithOptions(
"http://mf",
WithHTTPClient(
newFakeHTTPClient(t, func(t *testing.T, req *http.Request) *http.Response {
expectRequest(t, http.MethodGet, "http://mf/v1/categories?counts=true", nil, req)
return jsonResponseFrom(t, http.StatusOK, http.Header{}, expected)
})))
res, err := client.CategoriesWithCountersContext(t.Context())
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if !reflect.DeepEqual(res, expected) {
t.Fatalf("Expected %+v, got %+v", expected, res)
}
}
func TestCreateCategory(t *testing.T) {
expected := &Category{
ID: 1,
Title: "Example",
}
client := NewClientWithOptions(
"http://mf",
WithHTTPClient(
newFakeHTTPClient(t, func(t *testing.T, req *http.Request) *http.Response {
expectRequest(t, http.MethodPost, "http://mf/v1/categories", func(r io.Reader) {
expectFromJSON(t, r, &CategoryCreationRequest{
Title: "Example",
})
}, req)
return jsonResponseFrom(t, http.StatusOK, http.Header{}, expected)
})))
res, err := client.CreateCategoryContext(t.Context(), "Example")
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if !reflect.DeepEqual(res, expected) {
t.Fatalf("Expected %+v, got %+v", expected, res)
}
}
func TestCreateCategoryWithOptions(t *testing.T) {
expected := &Category{
ID: 1,
Title: "Example",
HideGlobally: true,
}
client := NewClientWithOptions(
"http://mf",
WithHTTPClient(
newFakeHTTPClient(t, func(t *testing.T, req *http.Request) *http.Response {
expectRequest(t, http.MethodPost, "http://mf/v1/categories", func(r io.Reader) {
expectFromJSON(t, r, &CategoryCreationRequest{
Title: "Example",
HideGlobally: true,
})
}, req)
return jsonResponseFrom(t, http.StatusOK, http.Header{}, expected)
})))
res, err := client.CreateCategoryWithOptionsContext(t.Context(), &CategoryCreationRequest{
Title: "Example",
HideGlobally: true,
})
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if !reflect.DeepEqual(res, expected) {
t.Fatalf("Expected %+v, got %+v", expected, res)
}
}
func TestUpdateCategory(t *testing.T) {
expected := &Category{
ID: 1,
Title: "Example",
}
client := NewClientWithOptions(
"http://mf",
WithHTTPClient(
newFakeHTTPClient(t, func(t *testing.T, req *http.Request) *http.Response {
expectRequest(t, http.MethodPut, "http://mf/v1/categories/1", func(r io.Reader) {
expectFromJSON(t, r, &CategoryModificationRequest{
Title: &expected.Title,
})
}, req)
return jsonResponseFrom(t, http.StatusOK, http.Header{}, expected)
})))
res, err := client.UpdateCategoryContext(t.Context(), 1, "Example")
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if !reflect.DeepEqual(res, expected) {
t.Fatalf("Expected %+v, got %+v", expected, res)
}
}
func TestUpdateCategoryWithOptions(t *testing.T) {
expected := &Category{
ID: 1,
Title: "Example",
HideGlobally: true,
}
client := NewClientWithOptions(
"http://mf",
WithHTTPClient(
newFakeHTTPClient(t, func(t *testing.T, req *http.Request) *http.Response {
expectRequest(t, http.MethodPut, "http://mf/v1/categories/1", func(r io.Reader) {
expectFromJSON(t, r, &CategoryModificationRequest{
Title: &expected.Title,
HideGlobally: &expected.HideGlobally,
})
}, req)
return jsonResponseFrom(t, http.StatusOK, http.Header{}, expected)
})))
res, err := client.UpdateCategoryWithOptionsContext(t.Context(), 1, &CategoryModificationRequest{
Title: &expected.Title,
HideGlobally: &expected.HideGlobally,
})
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if !reflect.DeepEqual(res, expected) {
t.Fatalf("Expected %+v, got %+v", expected, res)
}
}
func TestMarkCategoryAsRead(t *testing.T) {
client := NewClientWithOptions(
"http://mf",
WithHTTPClient(
newFakeHTTPClient(t, func(t *testing.T, req *http.Request) *http.Response {
expectRequest(t, http.MethodPut, "http://mf/v1/categories/1/mark-all-as-read", nil, req)
return jsonResponseFrom(t, http.StatusOK, http.Header{}, nil)
})))
if err := client.MarkCategoryAsReadContext(t.Context(), 1); err != nil {
t.Fatalf("Expected no error, got %v", err)
}
}
func TestCategoryFeeds(t *testing.T) {
expected := Feeds{
{
ID: 1,
Title: "Example",
},
}
client := NewClientWithOptions(
"http://mf",
WithHTTPClient(
newFakeHTTPClient(t, func(t *testing.T, req *http.Request) *http.Response {
expectRequest(t, http.MethodGet, "http://mf/v1/categories/1/feeds", nil, req)
return jsonResponseFrom(t, http.StatusOK, http.Header{}, expected)
})))
res, err := client.CategoryFeedsContext(t.Context(), 1)
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if !reflect.DeepEqual(res, expected) {
t.Fatalf("Expected %+v, got %+v", expected, res)
}
}
func TestDeleteCategory(t *testing.T) {
client := NewClientWithOptions(
"http://mf",
WithHTTPClient(
newFakeHTTPClient(t, func(t *testing.T, req *http.Request) *http.Response {
expectRequest(t, http.MethodDelete, "http://mf/v1/categories/1", nil, req)
return jsonResponseFrom(t, http.StatusOK, http.Header{}, nil)
})))
if err := client.DeleteCategoryContext(t.Context(), 1); err != nil {
t.Fatalf("Expected no error, got %v", err)
}
}
func TestRefreshCategory(t *testing.T) {
client := NewClientWithOptions(
"http://mf",
WithHTTPClient(
newFakeHTTPClient(t, func(t *testing.T, req *http.Request) *http.Response {
expectRequest(t, http.MethodPut, "http://mf/v1/categories/1/refresh", nil, req)
return jsonResponseFrom(t, http.StatusOK, http.Header{}, nil)
})))
if err := client.RefreshCategoryContext(t.Context(), 1); err != nil {
t.Fatalf("Expected no error, got %v", err)
}
}
func TestFeeds(t *testing.T) {
expected := Feeds{
{
ID: 1,
Title: "Example",
FeedURL: "http://example.com",
SiteURL: "http://example.com",
CheckedAt: time.Date(1970, 1, 1, 0, 7, 0, 0, time.UTC),
Disabled: false,
IgnoreHTTPCache: false,
AllowSelfSignedCertificates: false,
FetchViaProxy: false,
ScraperRules: "",
RewriteRules: "",
UrlRewriteRules: "",
BlocklistRules: "",
KeeplistRules: "",
BlockFilterEntryRules: "",
KeepFilterEntryRules: "",
Crawler: false,
UserAgent: "",
Cookie: "",
Username: "",
Password: "",
Category: &Category{
ID: 1,
Title: "Example",
},
HideGlobally: false,
DisableHTTP2: false,
ProxyURL: "",
},
{
ID: 2,
Title: "Example 2",
},
}
client := NewClientWithOptions(
"http://mf",
WithHTTPClient(
newFakeHTTPClient(t, func(t *testing.T, req *http.Request) *http.Response {
expectRequest(t, http.MethodGet, "http://mf/v1/feeds", nil, req)
return jsonResponseFrom(t, http.StatusOK, http.Header{}, expected)
})))
res, err := client.FeedsContext(t.Context())
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if !reflect.DeepEqual(res, expected) {
t.Fatalf("Expected %s, got %s", asJSON(expected), asJSON(res))
}
}
func TestExport(t *testing.T) {
expected := []byte("hello")
client := NewClientWithOptions(
"http://mf",
WithHTTPClient(
newFakeHTTPClient(t, func(t *testing.T, req *http.Request) *http.Response {
expectRequest(t, http.MethodGet, "http://mf/v1/export", nil, req)
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(bytes.NewBufferString(string(expected))),
Header: http.Header{},
}
})))
res, err := client.ExportContext(t.Context())
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if !reflect.DeepEqual(res, expected) {
t.Fatalf("Expected %+v, got %+v", expected, res)
}
}
func TestImport(t *testing.T) {
expected := []byte("hello")
client := NewClientWithOptions(
"http://mf",
WithHTTPClient(
newFakeHTTPClient(t, func(t *testing.T, req *http.Request) *http.Response {
expectRequest(
t,
http.MethodPost,
"http://mf/v1/import",
func(r io.Reader) {
b, err := io.ReadAll(r)
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if !bytes.Equal(b, expected) {
t.Fatalf("expected %+v, got %+v", expected, b)
}
},
req)
return &http.Response{
StatusCode: http.StatusOK,
Header: http.Header{},
}
})))
if err := client.ImportContext(t.Context(), io.NopCloser(bytes.NewBufferString(string(expected)))); err != nil {
t.Fatalf("Expected no error, got %v", err)
}
}
func TestFeed(t *testing.T) {
expected := &Feed{
ID: 1,
Title: "Example",
}
client := NewClientWithOptions(
"http://mf",
WithHTTPClient(
newFakeHTTPClient(t, func(t *testing.T, req *http.Request) *http.Response {
expectRequest(t, http.MethodGet, "http://mf/v1/feeds/1", nil, req)
return jsonResponseFrom(t, http.StatusOK, http.Header{}, expected)
})))
res, err := client.FeedContext(t.Context(), 1)
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if !reflect.DeepEqual(res, expected) {
t.Fatalf("Expected %s, got %s", asJSON(expected), asJSON(res))
}
}
func TestCreateFeed(t *testing.T) {
client := NewClientWithOptions(
"http://mf",
WithHTTPClient(
newFakeHTTPClient(t, func(t *testing.T, req *http.Request) *http.Response {
expectRequest(t, http.MethodPost, "http://mf/v1/feeds", nil, req)
return jsonResponseFrom(t, http.StatusOK, http.Header{}, struct {
FeedID int64 `json:"feed_id"`
}{
FeedID: 1,
})
})))
id, err := client.CreateFeedContext(t.Context(), &FeedCreationRequest{
FeedURL: "http://example.com",
})
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if id != 1 {
t.Fatalf("Expected feed ID to be 1, got %d", id)
}
}
func TestUpdateFeed(t *testing.T) {
expected := &Feed{
ID: 1,
FeedURL: "http://example.com/",
}
client := NewClientWithOptions(
"http://mf",
WithHTTPClient(
newFakeHTTPClient(t, func(t *testing.T, req *http.Request) *http.Response {
expectRequest(t, http.MethodPut, "http://mf/v1/feeds/1", nil, req)
return jsonResponseFrom(t, http.StatusOK, http.Header{}, expected)
})))
res, err := client.UpdateFeedContext(t.Context(), 1, &FeedModificationRequest{
FeedURL: &expected.FeedURL,
})
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if !reflect.DeepEqual(res, expected) {
t.Fatalf("Expected %s, got %s", asJSON(expected), asJSON(res))
}
}
func TestMarkFeedAsRead(t *testing.T) {
client := NewClientWithOptions(
"http://mf",
WithHTTPClient(
newFakeHTTPClient(t, func(t *testing.T, req *http.Request) *http.Response {
expectRequest(t, http.MethodPut, "http://mf/v1/feeds/1/mark-all-as-read", nil, req)
return jsonResponseFrom(t, http.StatusOK, http.Header{}, nil)
})))
if err := client.MarkFeedAsReadContext(t.Context(), 1); err != nil {
t.Fatalf("Expected no error, got %v", err)
}
}
func TestRefreshAllFeeds(t *testing.T) {
client := NewClientWithOptions(
"http://mf",
WithHTTPClient(
newFakeHTTPClient(t, func(t *testing.T, req *http.Request) *http.Response {
expectRequest(t, http.MethodPut, "http://mf/v1/feeds/refresh", nil, req)
return jsonResponseFrom(t, http.StatusOK, http.Header{}, nil)
})))
if err := client.RefreshAllFeedsContext(t.Context()); err != nil {
t.Fatalf("Expected no error, got %v", err)
}
}
func TestRefreshFeed(t *testing.T) {
client := NewClientWithOptions(
"http://mf",
WithHTTPClient(
newFakeHTTPClient(t, func(t *testing.T, req *http.Request) *http.Response {
expectRequest(t, http.MethodPut, "http://mf/v1/feeds/1/refresh", nil, req)
return jsonResponseFrom(t, http.StatusOK, http.Header{}, nil)
})))
if err := client.RefreshFeedContext(t.Context(), 1); err != nil {
t.Fatalf("Expected no error, got %v", err)
}
}
func TestDeleteFeed(t *testing.T) {
client := NewClientWithOptions(
"http://mf",
WithHTTPClient(
newFakeHTTPClient(t, func(t *testing.T, req *http.Request) *http.Response {
expectRequest(t, http.MethodDelete, "http://mf/v1/feeds/1", nil, req)
return jsonResponseFrom(t, http.StatusOK, http.Header{}, nil)
})))
if err := client.DeleteFeedContext(t.Context(), 1); err != nil {
t.Fatalf("Expected no error, got %v", err)
}
}
func TestFeedIcon(t *testing.T) {
expected := &FeedIcon{
ID: 1,
MimeType: "text/plain",
Data: "data",
}
client := NewClientWithOptions(
"http://mf",
WithHTTPClient(
newFakeHTTPClient(t, func(t *testing.T, req *http.Request) *http.Response {
expectRequest(t, http.MethodGet, "http://mf/v1/feeds/1/icon", nil, req)
return jsonResponseFrom(t, http.StatusOK, http.Header{}, expected)
})))
res, err := client.FeedIconContext(t.Context(), 1)
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if !reflect.DeepEqual(res, expected) {
t.Fatalf("Expected %s, got %s", asJSON(expected), asJSON(res))
}
}
func TestFeedEntry(t *testing.T) {
expected := &Entry{
ID: 1,
Title: "Example",
}
client := NewClientWithOptions(
"http://mf",
WithHTTPClient(
newFakeHTTPClient(t, func(t *testing.T, req *http.Request) *http.Response {
expectRequest(t, http.MethodGet, "http://mf/v1/feeds/1/entries/1", nil, req)
return jsonResponseFrom(t, http.StatusOK, http.Header{}, expected)
})))
res, err := client.FeedEntryContext(t.Context(), 1, 1)
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if !reflect.DeepEqual(res, expected) {
t.Fatalf("Expected %s, got %s", asJSON(expected), asJSON(res))
}
}
func TestCategoryEntry(t *testing.T) {
expected := &Entry{
ID: 1,
Title: "Example",
}
client := NewClientWithOptions(
"http://mf",
WithHTTPClient(
newFakeHTTPClient(t, func(t *testing.T, req *http.Request) *http.Response {
expectRequest(t, http.MethodGet, "http://mf/v1/categories/1/entries/1", nil, req)
return jsonResponseFrom(t, http.StatusOK, http.Header{}, expected)
})))
res, err := client.CategoryEntryContext(t.Context(), 1, 1)
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if !reflect.DeepEqual(res, expected) {
t.Fatalf("Expected %s, got %s", asJSON(expected), asJSON(res))
}
}
func TestEntry(t *testing.T) {
expected := &Entry{
ID: 1,
Title: "Example",
}
client := NewClientWithOptions(
"http://mf",
WithHTTPClient(
newFakeHTTPClient(t, func(t *testing.T, req *http.Request) *http.Response {
expectRequest(t, http.MethodGet, "http://mf/v1/entries/1", nil, req)
return jsonResponseFrom(t, http.StatusOK, http.Header{}, expected)
})))
res, err := client.EntryContext(t.Context(), 1)
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if !reflect.DeepEqual(res, expected) {
t.Fatalf("Expected %s, got %s", asJSON(expected), asJSON(res))
}
}
func TestEntries(t *testing.T) {
expected := &EntryResultSet{
Total: 1,
Entries: Entries{
{
ID: 1,
Title: "Example",
},
},
}
client := NewClientWithOptions(
"http://mf",
WithHTTPClient(
newFakeHTTPClient(t, func(t *testing.T, req *http.Request) *http.Response {
expectRequest(t, http.MethodGet, "http://mf/v1/entries", nil, req)
return jsonResponseFrom(t, http.StatusOK, http.Header{}, expected)
})))
res, err := client.EntriesContext(t.Context(), nil)
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if !reflect.DeepEqual(res, expected) {
t.Fatalf("Expected %s, got %s", asJSON(expected), asJSON(res))
}
}
func TestFeedEntries(t *testing.T) {
expected := &EntryResultSet{
Total: 1,
Entries: Entries{
{
ID: 1,
Title: "Example",
},
},
}
client := NewClientWithOptions(
"http://mf",
WithHTTPClient(
newFakeHTTPClient(t, func(t *testing.T, req *http.Request) *http.Response {
expectRequest(t, http.MethodGet, "http://mf/v1/feeds/1/entries?limit=10&offset=0", nil, req)
return jsonResponseFrom(t, http.StatusOK, http.Header{}, expected)
})))
res, err := client.FeedEntriesContext(t.Context(), 1, &Filter{
Limit: 10,
})
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if !reflect.DeepEqual(res, expected) {
t.Fatalf("Expected %s, got %s", asJSON(expected), asJSON(res))
}
}
func TestCategoryEntries(t *testing.T) {
expected := &EntryResultSet{
Total: 1,
Entries: Entries{
{
ID: 1,
Title: "Example",
},
},
}
client := NewClientWithOptions(
"http://mf",
WithHTTPClient(
newFakeHTTPClient(t, func(t *testing.T, req *http.Request) *http.Response {
expectRequest(t, http.MethodGet, "http://mf/v1/categories/1/entries?limit=10&offset=0", nil, req)
return jsonResponseFrom(t, http.StatusOK, http.Header{}, expected)
})))
res, err := client.CategoryEntriesContext(t.Context(), 1, &Filter{
Limit: 10,
})
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if !reflect.DeepEqual(res, expected) {
t.Fatalf("Expected %s, got %s", asJSON(expected), asJSON(res))
}
}
func TestUpdateEntries(t *testing.T) {
client := NewClientWithOptions(
"http://mf",
WithHTTPClient(
newFakeHTTPClient(t, func(t *testing.T, req *http.Request) *http.Response {
expectRequest(t, http.MethodPut, "http://mf/v1/entries", nil, req)
expectFromJSON(t, req.Body, &struct {
EntryIDs []int64 `json:"entry_ids"`
Status string `json:"status"`
}{
EntryIDs: []int64{1, 2},
Status: "read",
})
return jsonResponseFrom(t, http.StatusOK, http.Header{}, nil)
})))
if err := client.UpdateEntriesContext(t.Context(), []int64{1, 2}, "read"); err != nil {
t.Fatalf("Expected no error, got %v", err)
}
}
func TestUpdateEntry(t *testing.T) {
expected := &Entry{
ID: 1,
Title: "Example",
}
client := NewClientWithOptions(
"http://mf",
WithHTTPClient(
newFakeHTTPClient(t, func(t *testing.T, req *http.Request) *http.Response {
expectRequest(t, http.MethodPut, "http://mf/v1/entries/1", nil, req)
expectFromJSON(t, req.Body, &EntryModificationRequest{
Title: &expected.Title,
})
return jsonResponseFrom(t, http.StatusOK, http.Header{}, expected)
})))
res, err := client.UpdateEntryContext(t.Context(), 1, &EntryModificationRequest{
Title: &expected.Title,
})
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if !reflect.DeepEqual(res, expected) {
t.Fatalf("Expected %s, got %s", asJSON(expected), asJSON(res))
}
}
func TestToggleStarred(t *testing.T) {
client := NewClientWithOptions(
"http://mf",
WithHTTPClient(
newFakeHTTPClient(t, func(t *testing.T, req *http.Request) *http.Response {
expectRequest(t, http.MethodPut, "http://mf/v1/entries/1/star", nil, req)
return jsonResponseFrom(t, http.StatusOK, http.Header{}, nil)
})))
if err := client.ToggleStarredContext(t.Context(), 1); err != nil {
t.Fatalf("Expected no error, got %v", err)
}
}
func TestSaveEntry(t *testing.T) {
client := NewClientWithOptions(
"http://mf",
WithHTTPClient(
newFakeHTTPClient(t, func(t *testing.T, req *http.Request) *http.Response {
expectRequest(t, http.MethodPost, "http://mf/v1/entries/1/save", nil, req)
return jsonResponseFrom(t, http.StatusOK, http.Header{}, nil)
})))
if err := client.SaveEntryContext(t.Context(), 1); err != nil {
t.Fatalf("Expected no error, got %v", err)
}
}
func TestFetchEntryOriginalContent(t *testing.T) {
expected := "Example"
client := NewClientWithOptions(
"http://mf",
WithHTTPClient(
newFakeHTTPClient(t, func(t *testing.T, req *http.Request) *http.Response {
expectRequest(t, http.MethodGet, "http://mf/v1/entries/1/fetch-content", nil, req)
return jsonResponseFrom(t, http.StatusOK, http.Header{}, struct {
Content string `json:"content"`
}{
Content: expected,
})
})))
res, err := client.FetchEntryOriginalContentContext(t.Context(), 1)
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if res != expected {
t.Fatalf("Expected %s, got %s", expected, res)
}
}
func TestFetchCounters(t *testing.T) {
expected := &FeedCounters{
ReadCounters: map[int64]int{
2: 1,
},
UnreadCounters: map[int64]int{
3: 1,
},
}
client := NewClientWithOptions(
"http://mf",
WithHTTPClient(
newFakeHTTPClient(t, func(t *testing.T, req *http.Request) *http.Response {
expectRequest(t, http.MethodGet, "http://mf/v1/feeds/counters", nil, req)
return jsonResponseFrom(t, http.StatusOK, http.Header{}, expected)
})))
res, err := client.FetchCountersContext(t.Context())
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if !reflect.DeepEqual(res, expected) {
t.Fatalf("Expected %s, got %s", asJSON(expected), asJSON(res))
}
}
func TestFlushHistory(t *testing.T) {
client := NewClientWithOptions(
"http://mf",
WithHTTPClient(
newFakeHTTPClient(t, func(t *testing.T, req *http.Request) *http.Response {
expectRequest(t, http.MethodPut, "http://mf/v1/flush-history", nil, req)
return jsonResponseFrom(t, http.StatusOK, http.Header{}, nil)
})))
if err := client.FlushHistoryContext(t.Context()); err != nil {
t.Fatalf("Expected no error, got %v", err)
}
}
func TestIcon(t *testing.T) {
expected := &FeedIcon{
ID: 1,
MimeType: "text/plain",
Data: "data",
}
client := NewClientWithOptions(
"http://mf",
WithHTTPClient(
newFakeHTTPClient(t, func(t *testing.T, req *http.Request) *http.Response {
expectRequest(t, http.MethodGet, "http://mf/v1/icons/1", nil, req)
return jsonResponseFrom(t, http.StatusOK, http.Header{}, expected)
})))
res, err := client.IconContext(t.Context(), 1)
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if !reflect.DeepEqual(res, expected) {
t.Fatalf("Expected %s, got %s", asJSON(expected), asJSON(res))
}
}
func TestEnclosure(t *testing.T) {
expected := &Enclosure{
ID: 1,
URL: "http://example.com",
MimeType: "text/plain",
}
client := NewClientWithOptions(
"http://mf",
WithHTTPClient(
newFakeHTTPClient(t, func(t *testing.T, req *http.Request) *http.Response {
expectRequest(t, http.MethodGet, "http://mf/v1/enclosures/1", nil, req)
return jsonResponseFrom(t, http.StatusOK, http.Header{}, expected)
})))
res, err := client.EnclosureContext(t.Context(), 1)
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if !reflect.DeepEqual(res, expected) {
t.Fatalf("Expected %s, got %s", asJSON(expected), asJSON(res))
}
}
func TestUpdateEnclosure(t *testing.T) {
expected := &Enclosure{
ID: 1,
URL: "http://example.com",
MimeType: "text/plain",
}
client := NewClientWithOptions(
"http://mf",
WithHTTPClient(
newFakeHTTPClient(t, func(t *testing.T, req *http.Request) *http.Response {
expectRequest(t, http.MethodPut, "http://mf/v1/enclosures/1", nil, req)
expectFromJSON(t, req.Body, &EnclosureUpdateRequest{
MediaProgression: 10,
})
return jsonResponseFrom(t, http.StatusOK, http.Header{}, expected)
})))
if err := client.UpdateEnclosureContext(t.Context(), 1, &EnclosureUpdateRequest{
MediaProgression: 10,
}); err != nil {
t.Fatalf("Expected no error, got %v", err)
}
}
v2-2.2.16/client/doc.go 0000664 0000000 0000000 00000001317 15127074645 0014520 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
/*
Package client implements a client library for the Miniflux REST API.
# Examples
This code snippet fetch the list of users:
import (
miniflux "miniflux.app/v2/client"
)
client := miniflux.NewClient("https://api.example.org", "admin", "secret")
users, err := client.Users()
if err != nil {
fmt.Println(err)
return
}
fmt.Println(users, err)
This one discover subscriptions on a website:
subscriptions, err := client.Discover("https://example.org/")
if err != nil {
fmt.Println(err)
return
}
fmt.Println(subscriptions)
*/
package client // import "miniflux.app/v2/client"
v2-2.2.16/client/model.go 0000664 0000000 0000000 00000034225 15127074645 0015057 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package client // import "miniflux.app/v2/client"
import (
"fmt"
"time"
)
// Entry statuses.
const (
EntryStatusUnread = "unread"
EntryStatusRead = "read"
EntryStatusRemoved = "removed"
)
// User represents a user in the system.
type User struct {
ID int64 `json:"id"`
Username string `json:"username"`
Password string `json:"password,omitempty"`
IsAdmin bool `json:"is_admin"`
Theme string `json:"theme"`
Language string `json:"language"`
Timezone string `json:"timezone"`
EntryDirection string `json:"entry_sorting_direction"`
EntryOrder string `json:"entry_sorting_order"`
Stylesheet string `json:"stylesheet"`
CustomJS string `json:"custom_js"`
GoogleID string `json:"google_id"`
OpenIDConnectID string `json:"openid_connect_id"`
EntriesPerPage int `json:"entries_per_page"`
KeyboardShortcuts bool `json:"keyboard_shortcuts"`
ShowReadingTime bool `json:"show_reading_time"`
EntrySwipe bool `json:"entry_swipe"`
GestureNav string `json:"gesture_nav"`
LastLoginAt *time.Time `json:"last_login_at"`
DisplayMode string `json:"display_mode"`
DefaultReadingSpeed int `json:"default_reading_speed"`
CJKReadingSpeed int `json:"cjk_reading_speed"`
DefaultHomePage string `json:"default_home_page"`
CategoriesSortingOrder string `json:"categories_sorting_order"`
MarkReadOnView bool `json:"mark_read_on_view"`
MediaPlaybackRate float64 `json:"media_playback_rate"`
BlockFilterEntryRules string `json:"block_filter_entry_rules"`
KeepFilterEntryRules string `json:"keep_filter_entry_rules"`
ExternalFontHosts string `json:"external_font_hosts"`
AlwaysOpenExternalLinks bool `json:"always_open_external_links"`
OpenExternalLinksInNewTab bool `json:"open_external_links_in_new_tab"`
}
func (u User) String() string {
return fmt.Sprintf("#%d - %s (admin=%v)", u.ID, u.Username, u.IsAdmin)
}
// UserCreationRequest represents the request to create a user.
type UserCreationRequest struct {
Username string `json:"username"`
Password string `json:"password"`
IsAdmin bool `json:"is_admin"`
GoogleID string `json:"google_id"`
OpenIDConnectID string `json:"openid_connect_id"`
}
// UserModificationRequest represents the request to update a user.
type UserModificationRequest struct {
Username *string `json:"username"`
Password *string `json:"password"`
IsAdmin *bool `json:"is_admin"`
Theme *string `json:"theme"`
Language *string `json:"language"`
Timezone *string `json:"timezone"`
EntryDirection *string `json:"entry_sorting_direction"`
EntryOrder *string `json:"entry_sorting_order"`
Stylesheet *string `json:"stylesheet"`
CustomJS *string `json:"custom_js"`
GoogleID *string `json:"google_id"`
OpenIDConnectID *string `json:"openid_connect_id"`
EntriesPerPage *int `json:"entries_per_page"`
KeyboardShortcuts *bool `json:"keyboard_shortcuts"`
ShowReadingTime *bool `json:"show_reading_time"`
EntrySwipe *bool `json:"entry_swipe"`
GestureNav *string `json:"gesture_nav"`
DisplayMode *string `json:"display_mode"`
DefaultReadingSpeed *int `json:"default_reading_speed"`
CJKReadingSpeed *int `json:"cjk_reading_speed"`
DefaultHomePage *string `json:"default_home_page"`
CategoriesSortingOrder *string `json:"categories_sorting_order"`
MarkReadOnView *bool `json:"mark_read_on_view"`
MediaPlaybackRate *float64 `json:"media_playback_rate"`
BlockFilterEntryRules *string `json:"block_filter_entry_rules"`
KeepFilterEntryRules *string `json:"keep_filter_entry_rules"`
ExternalFontHosts *string `json:"external_font_hosts"`
AlwaysOpenExternalLinks *bool `json:"always_open_external_links"`
OpenExternalLinksInNewTab *bool `json:"open_external_links_in_new_tab"`
}
// Users represents a list of users.
type Users []User
// Category represents a feed category.
type Category struct {
ID int64 `json:"id"`
Title string `json:"title"`
UserID int64 `json:"user_id,omitempty"`
HideGlobally bool `json:"hide_globally,omitempty"`
FeedCount *int `json:"feed_count,omitempty"`
TotalUnread *int `json:"total_unread,omitempty"`
}
func (c Category) String() string {
return fmt.Sprintf("#%d %s", c.ID, c.Title)
}
// Categories represents a list of categories.
type Categories []*Category
// CategoryCreationRequest represents the request to create a category.
type CategoryCreationRequest struct {
Title string `json:"title"`
HideGlobally bool `json:"hide_globally"`
}
// CategoryModificationRequest represents the request to update a category.
type CategoryModificationRequest struct {
Title *string `json:"title"`
HideGlobally *bool `json:"hide_globally"`
}
// Subscription represents a feed subscription.
type Subscription struct {
Title string `json:"title"`
URL string `json:"url"`
Type string `json:"type"`
}
func (s Subscription) String() string {
return fmt.Sprintf(`Title=%q, URL=%q, Type=%q`, s.Title, s.URL, s.Type)
}
// Subscriptions represents a list of subscriptions.
type Subscriptions []*Subscription
// Feed represents a Miniflux feed.
type Feed struct {
ID int64 `json:"id"`
UserID int64 `json:"user_id"`
FeedURL string `json:"feed_url"`
SiteURL string `json:"site_url"`
Title string `json:"title"`
CheckedAt time.Time `json:"checked_at,omitempty"`
EtagHeader string `json:"etag_header,omitempty"`
LastModifiedHeader string `json:"last_modified_header,omitempty"`
ParsingErrorMsg string `json:"parsing_error_message,omitempty"`
ParsingErrorCount int `json:"parsing_error_count,omitempty"`
Disabled bool `json:"disabled"`
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"`
UrlRewriteRules string `json:"urlrewrite_rules"`
BlocklistRules string `json:"blocklist_rules"`
KeeplistRules string `json:"keeplist_rules"`
BlockFilterEntryRules string `json:"block_filter_entry_rules"`
KeepFilterEntryRules string `json:"keep_filter_entry_rules"`
Crawler bool `json:"crawler"`
UserAgent string `json:"user_agent"`
Cookie string `json:"cookie"`
Username string `json:"username"`
Password string `json:"password"`
Category *Category `json:"category,omitempty"`
HideGlobally bool `json:"hide_globally"`
DisableHTTP2 bool `json:"disable_http2"`
ProxyURL string `json:"proxy_url"`
}
// FeedCreationRequest represents the request to create a feed.
type FeedCreationRequest struct {
FeedURL string `json:"feed_url"`
CategoryID int64 `json:"category_id"`
UserAgent string `json:"user_agent"`
Cookie string `json:"cookie"`
Username string `json:"username"`
Password string `json:"password"`
Crawler bool `json:"crawler"`
Disabled bool `json:"disabled"`
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"`
UrlRewriteRules string `json:"urlrewrite_rules"`
BlocklistRules string `json:"blocklist_rules"`
KeeplistRules string `json:"keeplist_rules"`
BlockFilterEntryRules string `json:"block_filter_entry_rules"`
KeepFilterEntryRules string `json:"keep_filter_entry_rules"`
HideGlobally bool `json:"hide_globally"`
DisableHTTP2 bool `json:"disable_http2"`
ProxyURL string `json:"proxy_url"`
}
// FeedModificationRequest represents the request to update a feed.
type FeedModificationRequest struct {
FeedURL *string `json:"feed_url"`
SiteURL *string `json:"site_url"`
Title *string `json:"title"`
ScraperRules *string `json:"scraper_rules"`
RewriteRules *string `json:"rewrite_rules"`
UrlRewriteRules *string `json:"urlrewrite_rules"`
BlocklistRules *string `json:"blocklist_rules"`
KeeplistRules *string `json:"keeplist_rules"`
BlockFilterEntryRules *string `json:"block_filter_entry_rules"`
KeepFilterEntryRules *string `json:"keep_filter_entry_rules"`
Crawler *bool `json:"crawler"`
UserAgent *string `json:"user_agent"`
Cookie *string `json:"cookie"`
Username *string `json:"username"`
Password *string `json:"password"`
CategoryID *int64 `json:"category_id"`
Disabled *bool `json:"disabled"`
IgnoreHTTPCache *bool `json:"ignore_http_cache"`
AllowSelfSignedCertificates *bool `json:"allow_self_signed_certificates"`
FetchViaProxy *bool `json:"fetch_via_proxy"`
HideGlobally *bool `json:"hide_globally"`
DisableHTTP2 *bool `json:"disable_http2"`
ProxyURL *string `json:"proxy_url"`
}
// FeedIcon represents the feed icon.
type FeedIcon struct {
ID int64 `json:"id"`
MimeType string `json:"mime_type"`
Data string `json:"data"`
}
type FeedCounters struct {
ReadCounters map[int64]int `json:"reads"`
UnreadCounters map[int64]int `json:"unreads"`
}
// Feeds represents a list of feeds.
type Feeds []*Feed
// Entry represents a subscription item in the system.
type Entry struct {
ID int64 `json:"id"`
Date time.Time `json:"published_at"`
ChangedAt time.Time `json:"changed_at"`
CreatedAt time.Time `json:"created_at"`
Feed *Feed `json:"feed,omitempty"`
Hash string `json:"hash"`
URL string `json:"url"`
CommentsURL string `json:"comments_url"`
Title string `json:"title"`
Status string `json:"status"`
Content string `json:"content"`
Author string `json:"author"`
ShareCode string `json:"share_code"`
Enclosures Enclosures `json:"enclosures,omitempty"`
Tags []string `json:"tags"`
ReadingTime int `json:"reading_time"`
UserID int64 `json:"user_id"`
FeedID int64 `json:"feed_id"`
Starred bool `json:"starred"`
}
// EntryModificationRequest represents a request to modify an entry.
type EntryModificationRequest struct {
Title *string `json:"title"`
Content *string `json:"content"`
}
// Entries represents a list of entries.
type Entries []*Entry
// Enclosure represents an attachment.
type Enclosure struct {
ID int64 `json:"id"`
UserID int64 `json:"user_id"`
EntryID int64 `json:"entry_id"`
URL string `json:"url"`
MimeType string `json:"mime_type"`
Size int `json:"size"`
MediaProgression int64 `json:"media_progression"`
}
type EnclosureUpdateRequest struct {
MediaProgression int64 `json:"media_progression"`
}
// Enclosures represents a list of attachments.
type Enclosures []*Enclosure
const (
FilterNotStarred = "0"
FilterOnlyStarred = "1"
)
// Filter is used to filter entries.
type Filter struct {
Status string
Offset int
Limit int
Order string
Direction string
Starred string
Before int64
After int64
PublishedBefore int64
PublishedAfter int64
ChangedBefore int64
ChangedAfter int64
BeforeEntryID int64
AfterEntryID int64
Search string
CategoryID int64
FeedID int64
Statuses []string
GloballyVisible bool
}
// EntryResultSet represents the response when fetching entries.
type EntryResultSet struct {
Total int `json:"total"`
Entries Entries `json:"entries"`
}
// VersionResponse represents the version and the build information of the Miniflux instance.
type VersionResponse struct {
Version string `json:"version"`
Commit string `json:"commit"`
BuildDate string `json:"build_date"`
GoVersion string `json:"go_version"`
Compiler string `json:"compiler"`
Arch string `json:"arch"`
OS string `json:"os"`
}
// APIKey represents an application API key.
type APIKey struct {
ID int64 `json:"id"`
UserID int64 `json:"user_id"`
Token string `json:"token"`
Description string `json:"description"`
LastUsedAt *time.Time `json:"last_used_at"`
CreatedAt time.Time `json:"created_at"`
}
// APIKeys represents a collection of API keys.
type APIKeys []*APIKey
// APIKeyCreationRequest represents the request to create an API key.
type APIKeyCreationRequest struct {
Description string `json:"description"`
}
func SetOptionalField[T any](value T) *T {
return &value
}
v2-2.2.16/client/options.go 0000664 0000000 0000000 00000001317 15127074645 0015446 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package client // import "miniflux.app/v2/client"
import "net/http"
type Option func(*request)
// WithAPIKey sets the API key for the client.
func WithAPIKey(apiKey string) Option {
return func(r *request) {
r.apiKey = apiKey
}
}
// WithCredentials sets the username and password for the client.
func WithCredentials(username, password string) Option {
return func(r *request) {
r.username = username
r.password = password
}
}
// WithHTTPClient sets the HTTP client for the client.
func WithHTTPClient(client *http.Client) Option {
return func(r *request) {
r.client = client
}
}
v2-2.2.16/client/request.go 0000664 0000000 0000000 00000010202 15127074645 0015434 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package client // import "miniflux.app/v2/client"
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net/http"
"net/url"
"time"
)
const (
userAgent = "Miniflux Client Library"
defaultTimeout = 80 * time.Second
)
// List of exposed errors.
var (
ErrNotAuthorized = errors.New("miniflux: unauthorized (bad credentials)")
ErrForbidden = errors.New("miniflux: access forbidden")
ErrServerError = errors.New("miniflux: internal server error")
ErrNotFound = errors.New("miniflux: resource not found")
ErrBadRequest = errors.New("miniflux: bad request")
ErrEmptyEndpoint = errors.New("miniflux: empty endpoint provided")
)
type errorResponse struct {
ErrorMessage string `json:"error_message"`
}
type request struct {
endpoint string
username string
password string
apiKey string
client *http.Client
}
func (r *request) Get(ctx context.Context, path string) (io.ReadCloser, error) {
return r.execute(ctx, http.MethodGet, path, nil)
}
func (r *request) Post(ctx context.Context, path string, data any) (io.ReadCloser, error) {
return r.execute(ctx, http.MethodPost, path, data)
}
func (r *request) PostFile(ctx context.Context, path string, f io.ReadCloser) (io.ReadCloser, error) {
return r.execute(ctx, http.MethodPost, path, f)
}
func (r *request) Put(ctx context.Context, path string, data any) (io.ReadCloser, error) {
return r.execute(ctx, http.MethodPut, path, data)
}
func (r *request) Delete(ctx context.Context, path string) error {
_, err := r.execute(ctx, http.MethodDelete, path, nil)
return err
}
func (r *request) execute(
ctx context.Context,
method string,
path string,
data any,
) (io.ReadCloser, error) {
if r.endpoint == "" {
return nil, ErrEmptyEndpoint
}
if r.endpoint[len(r.endpoint)-1:] == "/" {
r.endpoint = r.endpoint[:len(r.endpoint)-1]
}
u, err := url.Parse(r.endpoint + path)
if err != nil {
return nil, err
}
request, err := http.NewRequestWithContext(ctx, method, u.String(), nil)
if err != nil {
return nil, err
}
request.Header = r.buildHeaders()
if r.username != "" && r.password != "" {
request.SetBasicAuth(r.username, r.password)
}
if data != nil {
switch data := data.(type) {
case io.ReadCloser:
request.Body = data
default:
request.Body = io.NopCloser(bytes.NewBuffer(r.toJSON(data)))
}
}
client := r.client
response, err := client.Do(request)
if err != nil {
return nil, err
}
switch response.StatusCode {
case http.StatusUnauthorized:
response.Body.Close()
return nil, ErrNotAuthorized
case http.StatusForbidden:
response.Body.Close()
return nil, ErrForbidden
case http.StatusInternalServerError:
defer response.Body.Close()
var resp errorResponse
decoder := json.NewDecoder(response.Body)
// If we failed to decode, just return a generic ErrServerError
if err := decoder.Decode(&resp); err != nil {
return nil, ErrServerError
}
return nil, errors.New("miniflux: internal server error: " + resp.ErrorMessage)
case http.StatusNotFound:
response.Body.Close()
return nil, ErrNotFound
case http.StatusNoContent:
response.Body.Close()
return nil, nil
case http.StatusBadRequest:
defer response.Body.Close()
var resp errorResponse
decoder := json.NewDecoder(response.Body)
if err := decoder.Decode(&resp); err != nil {
return nil, fmt.Errorf("%w (%v)", ErrBadRequest, err)
}
return nil, fmt.Errorf("%w (%s)", ErrBadRequest, resp.ErrorMessage)
}
if response.StatusCode > 400 {
response.Body.Close()
return nil, fmt.Errorf("miniflux: status code=%d", response.StatusCode)
}
return response.Body, nil
}
func (r *request) buildHeaders() http.Header {
headers := make(http.Header)
headers.Add("User-Agent", userAgent)
headers.Add("Content-Type", "application/json")
headers.Add("Accept", "application/json")
if r.apiKey != "" {
headers.Add("X-Auth-Token", r.apiKey)
}
return headers
}
func (r *request) toJSON(v any) []byte {
b, err := json.Marshal(v)
if err != nil {
log.Println("Unable to convert interface to JSON:", err)
return []byte("")
}
return b
}
v2-2.2.16/contrib/ 0000775 0000000 0000000 00000000000 15127074645 0013604 5 ustar 00root root 0000000 0000000 v2-2.2.16/contrib/README.md 0000664 0000000 0000000 00000000342 15127074645 0015062 0 ustar 00root root 0000000 0000000 The contrib directory contains various useful things contributed by the community.
Community contributions are not officially supported by the maintainers.
There is no guarantee whatsoever that anything in this folder works.
v2-2.2.16/contrib/ansible/ 0000775 0000000 0000000 00000000000 15127074645 0015221 5 ustar 00root root 0000000 0000000 v2-2.2.16/contrib/ansible/inventories/ 0000775 0000000 0000000 00000000000 15127074645 0017566 5 ustar 00root root 0000000 0000000 v2-2.2.16/contrib/ansible/inventories/group_vars/ 0000775 0000000 0000000 00000000000 15127074645 0021755 5 ustar 00root root 0000000 0000000 v2-2.2.16/contrib/ansible/inventories/group_vars/miniflux_vars.yml 0000664 0000000 0000000 00000000406 15127074645 0025366 0 ustar 00root root 0000000 0000000 ---
miniflux_linux_user: miniflux
miniflux_db_user_name: miniflux_db_user
miniflux_db_user_password: miniflux_db_user_password
miniflux_db: miniflux_db
miniflux_admin_name: admin
miniflux_admin_passwort: miniflux_admin_password
miniflux_port: 8080
v2-2.2.16/contrib/ansible/playbooks/ 0000775 0000000 0000000 00000000000 15127074645 0017224 5 ustar 00root root 0000000 0000000 v2-2.2.16/contrib/ansible/playbooks/playbook.yml 0000664 0000000 0000000 00000000120 15127074645 0021560 0 ustar 00root root 0000000 0000000 ---
- hosts: miniflux
roles:
- { role: mgrote.miniflux, tags: "miniflux" } v2-2.2.16/contrib/ansible/roles/ 0000775 0000000 0000000 00000000000 15127074645 0016345 5 ustar 00root root 0000000 0000000 v2-2.2.16/contrib/ansible/roles/mgrote.miniflux/ 0000775 0000000 0000000 00000000000 15127074645 0021474 5 ustar 00root root 0000000 0000000 v2-2.2.16/contrib/ansible/roles/mgrote.miniflux/README.md 0000664 0000000 0000000 00000001050 15127074645 0022747 0 ustar 00root root 0000000 0000000 ## mgrote.miniflux
### Details
Installs and configures Miniflux v2 with ansible
### Works on...
- [x] Ubuntu (>=18.04)
### Variables and Defaults
##### Linux User
miniflux_linux_user: miniflux
##### DB User
miniflux_db_user_name: miniflux_db_user
##### DB Password
miniflux_db_user_password: qqqqqqqqqqqqq
##### Database
miniflux_db: miniflux_db
##### Username Miniflux Admin
miniflux_admin_name: admin
##### Password Miniflux Admin
miniflux_admin_passwort: hallowelt
##### Port for Miniflux Frontend
miniflux_port: 8080
v2-2.2.16/contrib/ansible/roles/mgrote.miniflux/defaults/ 0000775 0000000 0000000 00000000000 15127074645 0023303 5 ustar 00root root 0000000 0000000 v2-2.2.16/contrib/ansible/roles/mgrote.miniflux/defaults/main.yml 0000664 0000000 0000000 00000000000 15127074645 0024740 0 ustar 00root root 0000000 0000000 v2-2.2.16/contrib/ansible/roles/mgrote.miniflux/handlers/ 0000775 0000000 0000000 00000000000 15127074645 0023274 5 ustar 00root root 0000000 0000000 v2-2.2.16/contrib/ansible/roles/mgrote.miniflux/handlers/main.yml 0000664 0000000 0000000 00000000330 15127074645 0024737 0 ustar 00root root 0000000 0000000 ---
- name: start_miniflux.service
become: yes
systemd:
name: miniflux
state: restarted
enabled: yes
# wait 15 seconds(for systemd)
- name: miniflux_wait
wait_for:
timeout: 15
v2-2.2.16/contrib/ansible/roles/mgrote.miniflux/tasks/ 0000775 0000000 0000000 00000000000 15127074645 0022621 5 ustar 00root root 0000000 0000000 v2-2.2.16/contrib/ansible/roles/mgrote.miniflux/tasks/main.yml 0000664 0000000 0000000 00000001630 15127074645 0024270 0 ustar 00root root 0000000 0000000 - name: add Apt-key for miniflux-repo
become: yes
apt_key:
url: https://apt.miniflux.app/KEY.gpg
state: present
- name: add miniflux-repo
become: yes
apt_repository:
repo: 'deb https://apt.miniflux.app/ /'
state: present
filename: miniflux_repo
update_cache: yes
- name: install miniflux
become: yes
apt:
name: miniflux
state: present
- name: add miniflux linux_user
become: yes
user:
name: "{{ miniflux_linux_user }}"
home: "/var/empty"
create_home: "no"
system: "yes"
shell: "/bin/false"
- name: create directory "/etc/miniflux.d"
become: yes
file:
path: /etc/miniflux.d
state: directory
- name: copy miniflux.conf
become: yes
template:
src: "miniflux.conf"
dest: "/etc/miniflux.conf"
notify:
- start_miniflux.service
- miniflux_wait
v2-2.2.16/contrib/ansible/roles/mgrote.miniflux/templates/ 0000775 0000000 0000000 00000000000 15127074645 0023472 5 ustar 00root root 0000000 0000000 v2-2.2.16/contrib/ansible/roles/mgrote.miniflux/templates/miniflux.conf 0000664 0000000 0000000 00000000732 15127074645 0026176 0 ustar 00root root 0000000 0000000 # See https://docs.miniflux.app/
LISTEN_ADDR=0.0.0.0:{{ miniflux_port }}
DATABASE_URL=user={{ miniflux_db_user_name }} password={{ miniflux_db_user_password }} dbname={{ miniflux_db }} sslmode=disable
POLLING_FREQUENCY=15
PROXY_IMAGES=http-only
# Run SQL migrations automatically:
RUN_MIGRATIONS=1
CREATE_ADMIN=1
ADMIN_USERNAME={{ miniflux_admin_name }}
ADMIN_PASSWORD={{ miniflux_admin_passwort }}
POLLING_FREQUENCY=10
# Options: https://miniflux.app/miniflux.1.html
v2-2.2.16/contrib/bruno/ 0000775 0000000 0000000 00000000000 15127074645 0014731 5 ustar 00root root 0000000 0000000 v2-2.2.16/contrib/bruno/README.md 0000664 0000000 0000000 00000000314 15127074645 0016206 0 ustar 00root root 0000000 0000000 This folder contains Miniflux API collection for [Bruno](https://www.usebruno.com).
Bruno is a lightweight alternative to Postman/Insomnia.
- https://www.usebruno.com
- https://github.com/usebruno/bruno v2-2.2.16/contrib/bruno/miniflux/ 0000775 0000000 0000000 00000000000 15127074645 0016564 5 ustar 00root root 0000000 0000000 v2-2.2.16/contrib/bruno/miniflux/Bookmark an entry.bru 0000664 0000000 0000000 00000000530 15127074645 0022542 0 ustar 00root root 0000000 0000000 meta {
name: Bookmark an entry
type: http
seq: 37
}
put {
url: {{minifluxBaseURL}}/v1/entries/{{entryID}}/bookmark
body: none
auth: basic
}
auth:basic {
username: {{minifluxUsername}}
password: {{minifluxPassword}}
}
body:json {
{
"feed_url": "https://miniflux.app/feed.xml"
}
}
vars:pre-request {
entryID: 1698
}
v2-2.2.16/contrib/bruno/miniflux/Create a feed.bru 0000664 0000000 0000000 00000000430 15127074645 0021563 0 ustar 00root root 0000000 0000000 meta {
name: Create a feed
type: http
seq: 19
}
post {
url: {{minifluxBaseURL}}/v1/feeds
body: json
auth: basic
}
auth:basic {
username: {{minifluxUsername}}
password: {{minifluxPassword}}
}
body:json {
{
"feed_url": "https://miniflux.app/feed.xml"
}
}
v2-2.2.16/contrib/bruno/miniflux/Create a new category.bru 0000664 0000000 0000000 00000000411 15127074645 0023246 0 ustar 00root root 0000000 0000000 meta {
name: Create a new category
type: http
seq: 10
}
post {
url: {{minifluxBaseURL}}/v1/categories
body: json
auth: basic
}
auth:basic {
username: {{minifluxUsername}}
password: {{minifluxPassword}}
}
body:json {
{
"title": "Test"
}
}
v2-2.2.16/contrib/bruno/miniflux/Create a new user.bru 0000664 0000000 0000000 00000000441 15127074645 0022412 0 ustar 00root root 0000000 0000000 meta {
name: Create a new user
type: http
seq: 5
}
post {
url: {{minifluxBaseURL}}/v1/users
body: json
auth: basic
}
auth:basic {
username: {{minifluxUsername}}
password: {{minifluxPassword}}
}
body:json {
{
"username": "foobar",
"password": "secret123"
}
}
v2-2.2.16/contrib/bruno/miniflux/Delete a category.bru 0000664 0000000 0000000 00000000503 15127074645 0022475 0 ustar 00root root 0000000 0000000 meta {
name: Delete a category
type: http
seq: 12
}
delete {
url: {{minifluxBaseURL}}/v1/categories/{{categoryID}}
body: none
auth: basic
}
auth:basic {
username: {{minifluxUsername}}
password: {{minifluxPassword}}
}
body:json {
{
"title": "Test Update"
}
}
vars:pre-request {
categoryID: 1
}
v2-2.2.16/contrib/bruno/miniflux/Delete a feed.bru 0000664 0000000 0000000 00000000472 15127074645 0021570 0 ustar 00root root 0000000 0000000 meta {
name: Delete a feed
type: http
seq: 26
}
delete {
url: {{minifluxBaseURL}}/v1/feeds/{{feedID}}
body: none
auth: basic
}
auth:basic {
username: {{minifluxUsername}}
password: {{minifluxPassword}}
}
body:json {
{
"user_agent": "My user agent"
}
}
vars:pre-request {
feedID: 18
}
v2-2.2.16/contrib/bruno/miniflux/Delete a user.bru 0000664 0000000 0000000 00000000456 15127074645 0021645 0 ustar 00root root 0000000 0000000 meta {
name: Delete a user
type: http
seq: 7
}
delete {
url: {{minifluxBaseURL}}/v1/users/{{userID}}
body: none
auth: basic
}
auth:basic {
username: {{minifluxUsername}}
password: {{minifluxPassword}}
}
body:json {
{
"language": "fr_FR"
}
}
vars:pre-request {
userID: 2
}
v2-2.2.16/contrib/bruno/miniflux/Discover feeds.bru 0000664 0000000 0000000 00000000416 15127074645 0022124 0 ustar 00root root 0000000 0000000 meta {
name: Discover feeds
type: http
seq: 18
}
post {
url: {{minifluxBaseURL}}/v1/discover
body: json
auth: basic
}
auth:basic {
username: {{minifluxUsername}}
password: {{minifluxPassword}}
}
body:json {
{
"url": "https://miniflux.app"
}
}
v2-2.2.16/contrib/bruno/miniflux/Fetch entry website content.bru 0000664 0000000 0000000 00000000547 15127074645 0024535 0 ustar 00root root 0000000 0000000 meta {
name: Fetch entry website content
type: http
seq: 39
}
get {
url: {{minifluxBaseURL}}/v1/entries/{{entryID}}/fetch-content
body: none
auth: basic
}
auth:basic {
username: {{minifluxUsername}}
password: {{minifluxPassword}}
}
body:json {
{
"feed_url": "https://miniflux.app/feed.xml"
}
}
vars:pre-request {
entryID: 1698
}
v2-2.2.16/contrib/bruno/miniflux/Flush history.bru 0000664 0000000 0000000 00000000421 15127074645 0022036 0 ustar 00root root 0000000 0000000 meta {
name: Flush history
type: http
seq: 40
}
put {
url: {{minifluxBaseURL}}/v1/flush-history
body: none
auth: basic
}
auth:basic {
username: {{minifluxUsername}}
password: {{minifluxPassword}}
}
body:json {
{
"url": "https://miniflux.app"
}
}
v2-2.2.16/contrib/bruno/miniflux/Get a single entry.bru 0000664 0000000 0000000 00000000520 15127074645 0022577 0 ustar 00root root 0000000 0000000 meta {
name: Get a single entry
type: http
seq: 36
}
get {
url: {{minifluxBaseURL}}/v1/entries/{{entryID}}
body: none
auth: basic
}
auth:basic {
username: {{minifluxUsername}}
password: {{minifluxPassword}}
}
body:json {
{
"feed_url": "https://miniflux.app/feed.xml"
}
}
vars:pre-request {
entryID: 1698
}
v2-2.2.16/contrib/bruno/miniflux/Get a single feed entry.bru 0000664 0000000 0000000 00000000563 15127074645 0023472 0 ustar 00root root 0000000 0000000 meta {
name: Get a single feed entry
type: http
seq: 33
}
get {
url: {{minifluxBaseURL}}/v1/feeds/{{feedID}}/entries/{{entryID}}
body: none
auth: basic
}
auth:basic {
username: {{minifluxUsername}}
password: {{minifluxPassword}}
}
body:json {
{
"feed_url": "https://miniflux.app/feed.xml"
}
}
vars:pre-request {
feedID: 19
entryID: 1698
}
v2-2.2.16/contrib/bruno/miniflux/Get a single feed.bru 0000664 0000000 0000000 00000000511 15127074645 0022341 0 ustar 00root root 0000000 0000000 meta {
name: Get a single feed
type: http
seq: 24
}
get {
url: {{minifluxBaseURL}}/v1/feeds/{{feedID}}
body: none
auth: basic
}
auth:basic {
username: {{minifluxUsername}}
password: {{minifluxPassword}}
}
body:json {
{
"feed_url": "https://miniflux.app/feed.xml"
}
}
vars:pre-request {
feedID: 18
}
v2-2.2.16/contrib/bruno/miniflux/Get a single user by ID.bru 0000664 0000000 0000000 00000000406 15127074645 0023267 0 ustar 00root root 0000000 0000000 meta {
name: Get a single user by ID
type: http
seq: 3
}
get {
url: {{minifluxBaseURL}}/v1/users/{{userID}}
body: none
auth: basic
}
auth:basic {
username: {{minifluxUsername}}
password: {{minifluxPassword}}
}
vars:pre-request {
userID: 1
}
v2-2.2.16/contrib/bruno/miniflux/Get a single user by username.bru 0000664 0000000 0000000 00000000424 15127074645 0024612 0 ustar 00root root 0000000 0000000 meta {
name: Get a single user by username
type: http
seq: 4
}
get {
url: {{minifluxBaseURL}}/v1/users/{{username}}
body: none
auth: basic
}
auth:basic {
username: {{minifluxUsername}}
password: {{minifluxPassword}}
}
vars:pre-request {
username: admin
}
v2-2.2.16/contrib/bruno/miniflux/Get all categories.bru 0000664 0000000 0000000 00000000331 15127074645 0022651 0 ustar 00root root 0000000 0000000 meta {
name: Get all categories
type: http
seq: 9
}
get {
url: {{minifluxBaseURL}}/v1/categories
body: none
auth: basic
}
auth:basic {
username: {{minifluxUsername}}
password: {{minifluxPassword}}
}
v2-2.2.16/contrib/bruno/miniflux/Get all entries.bru 0000664 0000000 0000000 00000000433 15127074645 0022200 0 ustar 00root root 0000000 0000000 meta {
name: Get all entries
type: http
seq: 34
}
get {
url: {{minifluxBaseURL}}/v1/entries
body: none
auth: basic
}
auth:basic {
username: {{minifluxUsername}}
password: {{minifluxPassword}}
}
body:json {
{
"feed_url": "https://miniflux.app/feed.xml"
}
}
v2-2.2.16/contrib/bruno/miniflux/Get all feeds.bru 0000664 0000000 0000000 00000000427 15127074645 0021620 0 ustar 00root root 0000000 0000000 meta {
name: Get all feeds
type: http
seq: 20
}
get {
url: {{minifluxBaseURL}}/v1/feeds
body: none
auth: basic
}
auth:basic {
username: {{minifluxUsername}}
password: {{minifluxPassword}}
}
body:json {
{
"feed_url": "https://miniflux.app/feed.xml"
}
}
v2-2.2.16/contrib/bruno/miniflux/Get all users.bru 0000664 0000000 0000000 00000000317 15127074645 0021671 0 ustar 00root root 0000000 0000000 meta {
name: Get all users
type: http
seq: 2
}
get {
url: {{minifluxBaseURL}}/v1/users
body: none
auth: basic
}
auth:basic {
username: {{minifluxUsername}}
password: {{minifluxPassword}}
}
v2-2.2.16/contrib/bruno/miniflux/Get category entries.bru 0000664 0000000 0000000 00000000513 15127074645 0023244 0 ustar 00root root 0000000 0000000 meta {
name: Get category entries
type: http
seq: 16
}
get {
url: {{minifluxBaseURL}}/v1/categories/{{categoryID}}/entries
body: none
auth: basic
}
auth:basic {
username: {{minifluxUsername}}
password: {{minifluxPassword}}
}
body:json {
{
"title": "Test Update"
}
}
vars:pre-request {
categoryID: 2
}
v2-2.2.16/contrib/bruno/miniflux/Get category entry.bru 0000664 0000000 0000000 00000000542 15127074645 0022736 0 ustar 00root root 0000000 0000000 meta {
name: Get category entry
type: http
seq: 17
}
get {
url: {{minifluxBaseURL}}/v1/categories/{{categoryID}}/entries/{{entryID}}
body: none
auth: basic
}
auth:basic {
username: {{minifluxUsername}}
password: {{minifluxPassword}}
}
body:json {
{
"title": "Test Update"
}
}
vars:pre-request {
categoryID: 2
entryID: 1
}
v2-2.2.16/contrib/bruno/miniflux/Get category feeds.bru 0000664 0000000 0000000 00000000507 15127074645 0022664 0 ustar 00root root 0000000 0000000 meta {
name: Get category feeds
type: http
seq: 14
}
get {
url: {{minifluxBaseURL}}/v1/categories/{{categoryID}}/feeds
body: none
auth: basic
}
auth:basic {
username: {{minifluxUsername}}
password: {{minifluxPassword}}
}
body:json {
{
"title": "Test Update"
}
}
vars:pre-request {
categoryID: 2
}
v2-2.2.16/contrib/bruno/miniflux/Get current user.bru 0000664 0000000 0000000 00000000317 15127074645 0022420 0 ustar 00root root 0000000 0000000 meta {
name: Get current user
type: http
seq: 1
}
get {
url: {{minifluxBaseURL}}/v1/me
body: none
auth: basic
}
auth:basic {
username: {{minifluxUsername}}
password: {{minifluxPassword}}
}
v2-2.2.16/contrib/bruno/miniflux/Get feed counters.bru 0000664 0000000 0000000 00000000444 15127074645 0022526 0 ustar 00root root 0000000 0000000 meta {
name: Get feed counters
type: http
seq: 21
}
get {
url: {{minifluxBaseURL}}/v1/feeds/counters
body: none
auth: basic
}
auth:basic {
username: {{minifluxUsername}}
password: {{minifluxPassword}}
}
body:json {
{
"feed_url": "https://miniflux.app/feed.xml"
}
}
v2-2.2.16/contrib/bruno/miniflux/Get feed entries.bru 0000664 0000000 0000000 00000000520 15127074645 0022330 0 ustar 00root root 0000000 0000000 meta {
name: Get feed entries
type: http
seq: 32
}
get {
url: {{minifluxBaseURL}}/v1/feeds/{{feedID}}/entries
body: none
auth: basic
}
auth:basic {
username: {{minifluxUsername}}
password: {{minifluxPassword}}
}
body:json {
{
"feed_url": "https://miniflux.app/feed.xml"
}
}
vars:pre-request {
feedID: 19
}
v2-2.2.16/contrib/bruno/miniflux/Get feed icon by feed ID.bru 0000664 0000000 0000000 00000000507 15127074645 0023350 0 ustar 00root root 0000000 0000000 meta {
name: Get feed icon by feed ID
type: http
seq: 27
}
get {
url: {{minifluxBaseURL}}/v1/feeds/{{feedID}}/icon
body: none
auth: basic
}
auth:basic {
username: {{minifluxUsername}}
password: {{minifluxPassword}}
}
body:json {
{
"user_agent": "My user agent"
}
}
vars:pre-request {
feedID: 19
}
v2-2.2.16/contrib/bruno/miniflux/Get feed icon by icon ID.bru 0000664 0000000 0000000 00000000502 15127074645 0023370 0 ustar 00root root 0000000 0000000 meta {
name: Get feed icon by icon ID
type: http
seq: 28
}
get {
url: {{minifluxBaseURL}}/v1/icons/{{iconID}}
body: none
auth: basic
}
auth:basic {
username: {{minifluxUsername}}
password: {{minifluxPassword}}
}
body:json {
{
"user_agent": "My user agent"
}
}
vars:pre-request {
iconID: 11
}
v2-2.2.16/contrib/bruno/miniflux/Get version and build information.bru 0000664 0000000 0000000 00000000346 15127074645 0025577 0 ustar 00root root 0000000 0000000 meta {
name: Get version and build information
type: http
seq: 42
}
get {
url: {{minifluxBaseURL}}/v1/version
body: none
auth: basic
}
auth:basic {
username: {{minifluxUsername}}
password: {{minifluxPassword}}
}
v2-2.2.16/contrib/bruno/miniflux/Mark all category entries as read.bru 0000664 0000000 0000000 00000000541 15127074645 0025431 0 ustar 00root root 0000000 0000000 meta {
name: Mark all category entries as read
type: http
seq: 13
}
put {
url: {{minifluxBaseURL}}/v1/categories/{{categoryID}}/mark-all-as-read
body: none
auth: basic
}
auth:basic {
username: {{minifluxUsername}}
password: {{minifluxPassword}}
}
body:json {
{
"title": "Test Update"
}
}
vars:pre-request {
categoryID: 2
}
v2-2.2.16/contrib/bruno/miniflux/Mark all user entries as read.bru 0000664 0000000 0000000 00000000517 15127074645 0024575 0 ustar 00root root 0000000 0000000 meta {
name: Mark all user entries as read
type: http
seq: 8
}
put {
url: {{minifluxBaseURL}}/v1/users/{{userID}}/mark-all-as-read
body: none
auth: basic
}
auth:basic {
username: {{minifluxUsername}}
password: {{minifluxPassword}}
}
body:json {
{
"title": "Test Update"
}
}
vars:pre-request {
userID: 1
}
v2-2.2.16/contrib/bruno/miniflux/Mark feed as read.bru 0000664 0000000 0000000 00000000514 15127074645 0022334 0 ustar 00root root 0000000 0000000 meta {
name: Mark feed as read
type: http
seq: 29
}
put {
url: {{minifluxBaseURL}}/v1/feeds/{{feedID}}/mark-all-as-read
body: none
auth: basic
}
auth:basic {
username: {{minifluxUsername}}
password: {{minifluxPassword}}
}
body:json {
{
"user_agent": "My user agent"
}
}
vars:pre-request {
feedID: 19
}
v2-2.2.16/contrib/bruno/miniflux/OPML Export.bru 0000664 0000000 0000000 00000000453 15127074645 0021311 0 ustar 00root root 0000000 0000000 meta {
name: OPML Export
type: http
seq: 30
}
get {
url: {{minifluxBaseURL}}/v1/export
body: none
auth: basic
}
auth:basic {
username: {{minifluxUsername}}
password: {{minifluxPassword}}
}
body:json {
{
"user_agent": "My user agent"
}
}
vars:pre-request {
feedID: 19
}
v2-2.2.16/contrib/bruno/miniflux/OPML Import.bru 0000664 0000000 0000000 00000001240 15127074645 0021275 0 ustar 00root root 0000000 0000000 meta {
name: OPML Import
type: http
seq: 31
}
post {
url: {{minifluxBaseURL}}/v1/import
body: xml
auth: basic
}
auth:basic {
username: {{minifluxUsername}}
password: {{minifluxPassword}}
}
body:json {
{
"user_agent": "My user agent"
}
}
body:xml {
Miniflux
}
vars:pre-request {
feedID: 19
}
v2-2.2.16/contrib/bruno/miniflux/Refresh a single feed.bru 0000664 0000000 0000000 00000000525 15127074645 0023225 0 ustar 00root root 0000000 0000000 meta {
name: Refresh a single feed
type: http
seq: 23
}
put {
url: {{minifluxBaseURL}}/v1/feeds/{{feedID}}/refresh
body: none
auth: basic
}
auth:basic {
username: {{minifluxUsername}}
password: {{minifluxPassword}}
}
body:json {
{
"feed_url": "https://miniflux.app/feed.xml"
}
}
vars:pre-request {
feedID: 18
}
v2-2.2.16/contrib/bruno/miniflux/Refresh all feeds.bru 0000664 0000000 0000000 00000000443 15127074645 0022475 0 ustar 00root root 0000000 0000000 meta {
name: Refresh all feeds
type: http
seq: 22
}
put {
url: {{minifluxBaseURL}}/v1/feeds/refresh
body: none
auth: basic
}
auth:basic {
username: {{minifluxUsername}}
password: {{minifluxPassword}}
}
body:json {
{
"feed_url": "https://miniflux.app/feed.xml"
}
}
v2-2.2.16/contrib/bruno/miniflux/Refresh category feeds.bru 0000664 0000000 0000000 00000000515 15127074645 0023542 0 ustar 00root root 0000000 0000000 meta {
name: Refresh category feeds
type: http
seq: 15
}
put {
url: {{minifluxBaseURL}}/v1/categories/{{categoryID}}/refresh
body: none
auth: basic
}
auth:basic {
username: {{minifluxUsername}}
password: {{minifluxPassword}}
}
body:json {
{
"title": "Test Update"
}
}
vars:pre-request {
categoryID: 2
}
v2-2.2.16/contrib/bruno/miniflux/Save an entry.bru 0000664 0000000 0000000 00000000521 15127074645 0021673 0 ustar 00root root 0000000 0000000 meta {
name: Save an entry
type: http
seq: 38
}
post {
url: {{minifluxBaseURL}}/v1/entries/{{entryID}}/save
body: none
auth: basic
}
auth:basic {
username: {{minifluxUsername}}
password: {{minifluxPassword}}
}
body:json {
{
"feed_url": "https://miniflux.app/feed.xml"
}
}
vars:pre-request {
entryID: 1698
}
v2-2.2.16/contrib/bruno/miniflux/Update a category.bru 0000664 0000000 0000000 00000000500 15127074645 0022512 0 ustar 00root root 0000000 0000000 meta {
name: Update a category
type: http
seq: 11
}
put {
url: {{minifluxBaseURL}}/v1/categories/{{categoryID}}
body: json
auth: basic
}
auth:basic {
username: {{minifluxUsername}}
password: {{minifluxPassword}}
}
body:json {
{
"title": "Test Update"
}
}
vars:pre-request {
categoryID: 1
}
v2-2.2.16/contrib/bruno/miniflux/Update a feed.bru 0000664 0000000 0000000 00000000467 15127074645 0021614 0 ustar 00root root 0000000 0000000 meta {
name: Update a feed
type: http
seq: 25
}
put {
url: {{minifluxBaseURL}}/v1/feeds/{{feedID}}
body: json
auth: basic
}
auth:basic {
username: {{minifluxUsername}}
password: {{minifluxPassword}}
}
body:json {
{
"user_agent": "My user agent"
}
}
vars:pre-request {
feedID: 18
}
v2-2.2.16/contrib/bruno/miniflux/Update a user.bru 0000664 0000000 0000000 00000000453 15127074645 0021662 0 ustar 00root root 0000000 0000000 meta {
name: Update a user
type: http
seq: 6
}
put {
url: {{minifluxBaseURL}}/v1/users/{{userID}}
body: json
auth: basic
}
auth:basic {
username: {{minifluxUsername}}
password: {{minifluxPassword}}
}
body:json {
{
"language": "fr_FR"
}
}
vars:pre-request {
userID: 1
}
v2-2.2.16/contrib/bruno/miniflux/Update entries status.bru 0000664 0000000 0000000 00000000445 15127074645 0023461 0 ustar 00root root 0000000 0000000 meta {
name: Update entries status
type: http
seq: 35
}
put {
url: {{minifluxBaseURL}}/v1/entries
body: json
auth: basic
}
auth:basic {
username: {{minifluxUsername}}
password: {{minifluxPassword}}
}
body:json {
{
"entry_ids": [1698, 1699],
"status": "read"
}
}
v2-2.2.16/contrib/bruno/miniflux/Update entry.bru 0000664 0000000 0000000 00000000517 15127074645 0021645 0 ustar 00root root 0000000 0000000 meta {
name: Update entry
type: http
seq: 41
}
put {
url: {{minifluxBaseURL}}/v1/entries/{{entryID}}
body: json
auth: basic
}
auth:basic {
username: {{minifluxUsername}}
password: {{minifluxPassword}}
}
body:json {
{
"title": "New title",
"content": "Some text"
}
}
vars:pre-request {
entryID: 1789
}
v2-2.2.16/contrib/bruno/miniflux/bruno.json 0000664 0000000 0000000 00000000102 15127074645 0020575 0 ustar 00root root 0000000 0000000 {
"version": "1",
"name": "Miniflux",
"type": "collection"
} v2-2.2.16/contrib/bruno/miniflux/environments/ 0000775 0000000 0000000 00000000000 15127074645 0021313 5 ustar 00root root 0000000 0000000 v2-2.2.16/contrib/bruno/miniflux/environments/Local.bru 0000664 0000000 0000000 00000000157 15127074645 0023062 0 ustar 00root root 0000000 0000000 vars {
minifluxBaseURL: http://127.0.0.1:8080
minifluxUsername: admin
}
vars:secret [
minifluxPassword
]
v2-2.2.16/contrib/docker-compose/ 0000775 0000000 0000000 00000000000 15127074645 0016516 5 ustar 00root root 0000000 0000000 v2-2.2.16/contrib/docker-compose/Caddyfile 0000664 0000000 0000000 00000000061 15127074645 0020322 0 ustar 00root root 0000000 0000000 miniflux.example.org
reverse_proxy miniflux:8080
v2-2.2.16/contrib/docker-compose/README.md 0000664 0000000 0000000 00000000446 15127074645 0020001 0 ustar 00root root 0000000 0000000 Docker-Compose Examples
=======================
Here are few Docker Compose examples:
- `basic.yml`: Basic example
- `caddy.yml`: Use Caddy as reverse-proxy with automatic HTTPS
- `traefik.yml`: Use Traefik as reverse-proxy with automatic HTTPS
```bash
docker compose -f basic.yml up -d
```
v2-2.2.16/contrib/docker-compose/basic.yml 0000664 0000000 0000000 00000001615 15127074645 0020325 0 ustar 00root root 0000000 0000000 services:
miniflux:
image: ${MINIFLUX_IMAGE:-miniflux/miniflux:latest}
container_name: miniflux
restart: always
ports:
- "80:8080"
depends_on:
db:
condition: service_healthy
environment:
- DATABASE_URL=postgres://miniflux:secret@db/miniflux?sslmode=disable
- RUN_MIGRATIONS=1
- CREATE_ADMIN=1
- ADMIN_USERNAME=admin
- ADMIN_PASSWORD=test123
- DEBUG=1
# Optional health check:
# healthcheck:
# test: ["CMD", "/usr/bin/miniflux", "-healthcheck", "auto"]
db:
image: postgres:latest
container_name: postgres
environment:
- POSTGRES_USER=miniflux
- POSTGRES_PASSWORD=secret
- POSTGRES_DB=miniflux
volumes:
- miniflux-db:/var/lib/postgresql
healthcheck:
test: ["CMD", "pg_isready", "-U", "miniflux"]
interval: 10s
start_period: 30s
volumes:
miniflux-db:
v2-2.2.16/contrib/docker-compose/caddy.yml 0000664 0000000 0000000 00000001774 15127074645 0020336 0 ustar 00root root 0000000 0000000 services:
caddy:
image: caddy:2
container_name: caddy
depends_on:
- miniflux
ports:
- "80:80"
- "443:443"
volumes:
- $PWD/Caddyfile:/etc/caddy/Caddyfile
- caddy_data:/data
- caddy_config:/config
miniflux:
image: ${MINIFLUX_IMAGE:-miniflux/miniflux:latest}
container_name: miniflux
depends_on:
db:
condition: service_healthy
environment:
- DATABASE_URL=postgres://miniflux:secret@db/miniflux?sslmode=disable
- RUN_MIGRATIONS=1
- CREATE_ADMIN=1
- ADMIN_USERNAME=admin
- ADMIN_PASSWORD=test123
- BASE_URL=https://miniflux.example.org
db:
image: postgres:latest
container_name: postgres
environment:
- POSTGRES_USER=miniflux
- POSTGRES_PASSWORD=secret
volumes:
- miniflux-db:/var/lib/postgresql
healthcheck:
test: ["CMD", "pg_isready", "-U", "miniflux"]
interval: 10s
start_period: 30s
volumes:
miniflux-db:
caddy_data:
caddy_config:
v2-2.2.16/contrib/docker-compose/traefik.yml 0000664 0000000 0000000 00000003132 15127074645 0020665 0 ustar 00root root 0000000 0000000 services:
traefik:
image: "traefik:v2.3"
container_name: traefik
command:
- "--providers.docker=true"
- "--providers.docker.exposedbydefault=false"
- "--entrypoints.websecure.address=:443"
- "--certificatesresolvers.myresolver.acme.tlschallenge=true"
- "--certificatesresolvers.myresolver.acme.email=postmaster@example.com"
- "--certificatesresolvers.myresolver.acme.storage=/letsencrypt/acme.json"
depends_on:
- miniflux
ports:
- "443:443"
volumes:
- "./letsencrypt:/letsencrypt"
- "/var/run/docker.sock:/var/run/docker.sock:ro"
miniflux:
image: ${MINIFLUX_IMAGE:-miniflux/miniflux:latest}
container_name: miniflux
depends_on:
db:
condition: service_healthy
expose:
- "8080"
environment:
- DATABASE_URL=postgres://miniflux:secret@db/miniflux?sslmode=disable
- RUN_MIGRATIONS=1
- CREATE_ADMIN=1
- ADMIN_USERNAME=admin
- ADMIN_PASSWORD=test123
- BASE_URL=https://miniflux.example.org
labels:
- "traefik.enable=true"
- "traefik.http.routers.miniflux.rule=Host(`miniflux.example.org`)"
- "traefik.http.routers.miniflux.entrypoints=websecure"
- "traefik.http.routers.miniflux.tls.certresolver=myresolver"
db:
image: postgres:latest
container_name: postgres
environment:
- POSTGRES_USER=miniflux
- POSTGRES_PASSWORD=secret
volumes:
- miniflux-db:/var/lib/postgresql
healthcheck:
test: ["CMD", "pg_isready", "-U", "miniflux"]
interval: 10s
start_period: 30s
volumes:
miniflux-db:
v2-2.2.16/contrib/grafana/ 0000775 0000000 0000000 00000000000 15127074645 0015203 5 ustar 00root root 0000000 0000000 v2-2.2.16/contrib/grafana/README.md 0000664 0000000 0000000 00000000037 15127074645 0016462 0 ustar 00root root 0000000 0000000 Grafana Dashboard for Miniflux
v2-2.2.16/contrib/grafana/dashboard.json 0000664 0000000 0000000 00000115501 15127074645 0020030 0 ustar 00root root 0000000 0000000 {
"__inputs": [
{
"name": "DS_PROMETHEUS",
"label": "prometheus",
"description": "",
"type": "datasource",
"pluginId": "prometheus",
"pluginName": "Prometheus"
}
],
"__elements": {},
"__requires": [
{
"type": "panel",
"id": "bargauge",
"name": "Bar gauge",
"version": ""
},
{
"type": "grafana",
"id": "grafana",
"name": "Grafana",
"version": "10.4.3"
},
{
"type": "datasource",
"id": "prometheus",
"name": "Prometheus",
"version": "1.0.0"
},
{
"type": "panel",
"id": "stat",
"name": "Stat",
"version": ""
},
{
"type": "panel",
"id": "timeseries",
"name": "Time series",
"version": ""
}
],
"annotations": {
"list": [
{
"builtIn": 1,
"datasource": {
"type": "datasource",
"uid": "grafana"
},
"enable": true,
"hide": true,
"iconColor": "rgba(0, 211, 255, 1)",
"name": "Annotations & Alerts",
"type": "dashboard"
}
]
},
"editable": true,
"fiscalYearStartMonth": 0,
"graphTooltip": 0,
"id": null,
"links": [],
"panels": [
{
"collapsed": false,
"datasource": {
"uid": "Prometheus"
},
"gridPos": {
"h": 1,
"w": 24,
"x": 0,
"y": 0
},
"id": 24,
"panels": [],
"targets": [
{
"datasource": {
"uid": "Prometheus"
},
"refId": "A"
}
],
"title": "Application",
"type": "row"
},
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"fieldConfig": {
"defaults": {
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
}
]
}
},
"overrides": []
},
"gridPos": {
"h": 7,
"w": 8,
"x": 0,
"y": 1
},
"id": 18,
"options": {
"displayMode": "basic",
"maxVizHeight": 300,
"minVizHeight": 16,
"minVizWidth": 8,
"namePlacement": "auto",
"orientation": "horizontal",
"reduceOptions": {
"calcs": [
"last"
],
"fields": "",
"values": false
},
"showUnfilled": true,
"sizing": "auto",
"valueMode": "color"
},
"pluginVersion": "10.4.3",
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"expr": "max(miniflux_feeds{status=\"total\"})",
"hide": false,
"interval": "",
"legendFormat": "Total",
"refId": "D"
},
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"expr": "max(miniflux_feeds{status=\"enabled\"})",
"hide": false,
"interval": "",
"legendFormat": "Enabled",
"refId": "C"
},
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"expr": "max(miniflux_broken_feeds)",
"interval": "",
"legendFormat": "Broken",
"refId": "A"
},
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"expr": "max(miniflux_feeds{status=\"disabled\"})",
"interval": "",
"legendFormat": "Disabled",
"refId": "B"
}
],
"title": "Feeds",
"type": "bargauge"
},
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"fieldConfig": {
"defaults": {
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "red",
"value": 80
}
]
}
},
"overrides": []
},
"gridPos": {
"h": 3,
"w": 4,
"x": 8,
"y": 1
},
"id": 2,
"options": {
"colorMode": "value",
"graphMode": "none",
"justifyMode": "auto",
"orientation": "horizontal",
"reduceOptions": {
"calcs": [
"last"
],
"fields": "",
"values": false
},
"showPercentChange": false,
"textMode": "auto",
"wideLayout": true
},
"pluginVersion": "10.4.3",
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"expr": "max(miniflux_users)",
"interval": "",
"legendFormat": "Users",
"refId": "A"
}
],
"title": "Users",
"type": "stat"
},
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"axisBorderShow": false,
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"drawStyle": "line",
"fillOpacity": 50,
"gradientMode": "opacity",
"hideFrom": {
"legend": false,
"tooltip": false,
"viz": false
},
"insertNulls": false,
"lineInterpolation": "linear",
"lineWidth": 1,
"pointSize": 5,
"scaleDistribution": {
"type": "linear"
},
"showPoints": "never",
"spanNulls": false,
"stacking": {
"group": "A",
"mode": "none"
},
"thresholdsStyle": {
"mode": "off"
}
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "red",
"value": 80
}
]
},
"unit": "short"
},
"overrides": []
},
"gridPos": {
"h": 7,
"w": 12,
"x": 12,
"y": 1
},
"id": 4,
"options": {
"legend": {
"calcs": [
"lastNotNull"
],
"displayMode": "table",
"placement": "right",
"showLegend": true
},
"tooltip": {
"mode": "multi",
"sort": "none"
}
},
"pluginVersion": "10.4.3",
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"expr": "max(miniflux_entries{status=\"total\"})",
"hide": false,
"interval": "",
"legendFormat": "Total",
"refId": "A"
},
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"expr": "max(miniflux_entries{status=\"unread\"})",
"hide": false,
"interval": "",
"legendFormat": "Unread",
"refId": "B"
},
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"expr": "max(miniflux_entries{status=\"read\"})",
"interval": "",
"legendFormat": "Read",
"refId": "C"
},
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"expr": "max(miniflux_entries{status=\"removed\"})",
"interval": "",
"legendFormat": "Removed",
"refId": "D"
}
],
"title": "Entries by Status",
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"description": "",
"fieldConfig": {
"defaults": {
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
}
]
},
"unit": "decbytes"
},
"overrides": []
},
"gridPos": {
"h": 4,
"w": 4,
"x": 8,
"y": 4
},
"id": 36,
"options": {
"colorMode": "value",
"graphMode": "none",
"justifyMode": "center",
"orientation": "vertical",
"reduceOptions": {
"calcs": [
"last"
],
"fields": "",
"values": false
},
"showPercentChange": false,
"textMode": "value",
"wideLayout": true
},
"pluginVersion": "10.4.3",
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"expr": "go_memstats_sys_bytes{job=\"miniflux\"}",
"interval": "",
"legendFormat": "{{ instance }} - Memory Used",
"refId": "A"
}
],
"type": "stat"
},
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"axisBorderShow": false,
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"drawStyle": "line",
"fillOpacity": 10,
"gradientMode": "none",
"hideFrom": {
"legend": false,
"tooltip": false,
"viz": false
},
"insertNulls": false,
"lineInterpolation": "linear",
"lineWidth": 1,
"pointSize": 5,
"scaleDistribution": {
"type": "linear"
},
"showPoints": "never",
"spanNulls": false,
"stacking": {
"group": "A",
"mode": "none"
},
"thresholdsStyle": {
"mode": "off"
}
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "red",
"value": 80
}
]
},
"unit": "s"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 0,
"y": 8
},
"id": 22,
"options": {
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "multi",
"sort": "none"
}
},
"pluginVersion": "10.4.3",
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"expr": "histogram_quantile(0.95, sum(rate(miniflux_scraper_request_duration_bucket[5m])) by (le))",
"interval": "",
"legendFormat": "Request Duration",
"refId": "A"
}
],
"title": "Scraper Request Duration",
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"axisBorderShow": false,
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"drawStyle": "line",
"fillOpacity": 10,
"gradientMode": "none",
"hideFrom": {
"legend": false,
"tooltip": false,
"viz": false
},
"insertNulls": false,
"lineInterpolation": "linear",
"lineWidth": 1,
"pointSize": 5,
"scaleDistribution": {
"type": "linear"
},
"showPoints": "never",
"spanNulls": false,
"stacking": {
"group": "A",
"mode": "none"
},
"thresholdsStyle": {
"mode": "off"
}
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "red",
"value": 80
}
]
},
"unit": "s"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 12,
"y": 8
},
"id": 20,
"options": {
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "multi",
"sort": "none"
}
},
"pluginVersion": "10.4.3",
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"expr": "histogram_quantile(0.95, sum(rate(miniflux_background_feed_refresh_duration_bucket[5m])) by (le))",
"interval": "",
"legendFormat": "Refresh Duration",
"refId": "A"
}
],
"title": "Background Feed Refresh Duration",
"type": "timeseries"
},
{
"collapsed": false,
"datasource": {
"uid": "Prometheus"
},
"gridPos": {
"h": 1,
"w": 24,
"x": 0,
"y": 16
},
"id": 28,
"panels": [],
"targets": [
{
"datasource": {
"uid": "Prometheus"
},
"refId": "A"
}
],
"title": "Process",
"type": "row"
},
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"axisBorderShow": false,
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"drawStyle": "line",
"fillOpacity": 10,
"gradientMode": "none",
"hideFrom": {
"legend": false,
"tooltip": false,
"viz": false
},
"insertNulls": false,
"lineInterpolation": "linear",
"lineWidth": 1,
"pointSize": 5,
"scaleDistribution": {
"type": "linear"
},
"showPoints": "never",
"spanNulls": false,
"stacking": {
"group": "A",
"mode": "none"
},
"thresholdsStyle": {
"mode": "off"
}
},
"links": [],
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "red",
"value": 80
}
]
},
"unit": "decbytes"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 0,
"y": 17
},
"id": 16,
"options": {
"legend": {
"calcs": [
"lastNotNull"
],
"displayMode": "table",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "multi",
"sort": "none"
}
},
"pluginVersion": "10.4.3",
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"expr": "go_memstats_sys_bytes{job=\"miniflux\"}",
"format": "time_series",
"interval": "",
"intervalFactor": 1,
"legendFormat": "{{ instance }}",
"refId": "A"
}
],
"title": "Total Used Memory",
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"axisBorderShow": false,
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"drawStyle": "line",
"fillOpacity": 10,
"gradientMode": "none",
"hideFrom": {
"legend": false,
"tooltip": false,
"viz": false
},
"insertNulls": false,
"lineInterpolation": "linear",
"lineWidth": 1,
"pointSize": 5,
"scaleDistribution": {
"type": "linear"
},
"showPoints": "never",
"spanNulls": false,
"stacking": {
"group": "A",
"mode": "none"
},
"thresholdsStyle": {
"mode": "off"
}
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "red",
"value": 80
}
]
},
"unit": "short"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 12,
"y": 17
},
"id": 6,
"options": {
"legend": {
"calcs": [
"mean",
"lastNotNull",
"max",
"min"
],
"displayMode": "table",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "multi",
"sort": "none"
}
},
"pluginVersion": "10.4.3",
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"expr": "process_open_fds{job=\"miniflux\"}",
"interval": "",
"legendFormat": "{{instance }} - Open File Descriptors",
"refId": "A"
}
],
"title": "File Descriptors",
"type": "timeseries"
},
{
"collapsed": false,
"datasource": {
"uid": "Prometheus"
},
"gridPos": {
"h": 1,
"w": 24,
"x": 0,
"y": 25
},
"id": 26,
"panels": [],
"targets": [
{
"datasource": {
"uid": "Prometheus"
},
"refId": "A"
}
],
"title": "Go Metrics",
"type": "row"
},
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"axisBorderShow": false,
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"drawStyle": "line",
"fillOpacity": 10,
"gradientMode": "none",
"hideFrom": {
"legend": false,
"tooltip": false,
"viz": false
},
"insertNulls": false,
"lineInterpolation": "linear",
"lineWidth": 2,
"pointSize": 5,
"scaleDistribution": {
"type": "linear"
},
"showPoints": "never",
"spanNulls": true,
"stacking": {
"group": "A",
"mode": "none"
},
"thresholdsStyle": {
"mode": "off"
}
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green"
},
{
"color": "red",
"value": 80
}
]
},
"unit": "bytes"
},
"overrides": [
{
"matcher": {
"id": "byName",
"options": "alloc rate"
},
"properties": [
{
"id": "unit",
"value": "Bps"
}
]
}
]
},
"gridPos": {
"h": 7,
"w": 12,
"x": 0,
"y": 26
},
"id": 12,
"options": {
"legend": {
"calcs": [
"mean",
"lastNotNull",
"max"
],
"displayMode": "table",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "multi",
"sort": "none"
}
},
"pluginVersion": "10.4.3",
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"expr": "go_memstats_alloc_bytes{job=\"miniflux\"}",
"format": "time_series",
"interval": "",
"intervalFactor": 2,
"legendFormat": "bytes allocated",
"metric": "go_memstats_alloc_bytes",
"refId": "A",
"step": 4
},
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"expr": "rate(go_memstats_alloc_bytes_total{job=\"miniflux\"}[30s])",
"format": "time_series",
"interval": "",
"intervalFactor": 2,
"legendFormat": "alloc rate",
"metric": "go_memstats_alloc_bytes_total",
"refId": "B",
"step": 4
},
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"expr": "go_memstats_stack_inuse_bytes{job=\"miniflux\"}",
"format": "time_series",
"interval": "",
"intervalFactor": 2,
"legendFormat": "stack inuse",
"metric": "go_memstats_stack_inuse_bytes",
"refId": "C",
"step": 4
},
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"expr": "go_memstats_heap_inuse_bytes{job=\"miniflux\"}",
"format": "time_series",
"hide": false,
"interval": "",
"intervalFactor": 2,
"legendFormat": "heap inuse",
"metric": "go_memstats_heap_inuse_bytes",
"refId": "D",
"step": 4
}
],
"title": "Golang Memory",
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"axisBorderShow": false,
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"drawStyle": "line",
"fillOpacity": 10,
"gradientMode": "none",
"hideFrom": {
"legend": false,
"tooltip": false,
"viz": false
},
"insertNulls": false,
"lineInterpolation": "linear",
"lineWidth": 1,
"pointSize": 5,
"scaleDistribution": {
"type": "linear"
},
"showPoints": "never",
"spanNulls": false,
"stacking": {
"group": "A",
"mode": "none"
},
"thresholdsStyle": {
"mode": "off"
}
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green"
},
{
"color": "red",
"value": 80
}
]
},
"unit": "short"
},
"overrides": []
},
"gridPos": {
"h": 7,
"w": 12,
"x": 12,
"y": 26
},
"id": 8,
"options": {
"legend": {
"calcs": [
"mean",
"lastNotNull",
"max",
"min"
],
"displayMode": "table",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "multi",
"sort": "none"
}
},
"pluginVersion": "10.4.3",
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"expr": "go_goroutines{job=\"miniflux\"}",
"interval": "",
"legendFormat": "{{ instance }} - Goroutines",
"refId": "A"
},
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"expr": "go_threads{job=\"miniflux\"}",
"interval": "",
"legendFormat": "{{ instance }} - OS threads",
"refId": "B"
}
],
"title": "Concurrency",
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"axisBorderShow": false,
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"drawStyle": "line",
"fillOpacity": 10,
"gradientMode": "none",
"hideFrom": {
"legend": false,
"tooltip": false,
"viz": false
},
"insertNulls": false,
"lineInterpolation": "linear",
"lineWidth": 1,
"pointSize": 5,
"scaleDistribution": {
"type": "linear"
},
"showPoints": "never",
"spanNulls": false,
"stacking": {
"group": "A",
"mode": "none"
},
"thresholdsStyle": {
"mode": "off"
}
},
"links": [],
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green"
},
{
"color": "red",
"value": 80
}
]
},
"unit": "decbytes"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 0,
"y": 33
},
"id": 34,
"options": {
"legend": {
"calcs": [
"lastNotNull"
],
"displayMode": "table",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "multi",
"sort": "none"
}
},
"pluginVersion": "10.4.3",
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"expr": "go_memstats_stack_inuse_bytes{job=\"miniflux\"}",
"format": "time_series",
"interval": "",
"intervalFactor": 1,
"legendFormat": "{{ instance }} - stack_inuse",
"refId": "A"
},
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"expr": "go_memstats_stack_sys_bytes{job=\"miniflux\"}",
"format": "time_series",
"interval": "",
"intervalFactor": 1,
"legendFormat": "{{ instance }} - stack_sys",
"refId": "B"
}
],
"title": "Memory in Stack",
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"axisBorderShow": false,
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"drawStyle": "line",
"fillOpacity": 10,
"gradientMode": "none",
"hideFrom": {
"legend": false,
"tooltip": false,
"viz": false
},
"insertNulls": false,
"lineInterpolation": "linear",
"lineWidth": 1,
"pointSize": 5,
"scaleDistribution": {
"type": "linear"
},
"showPoints": "never",
"spanNulls": false,
"stacking": {
"group": "A",
"mode": "none"
},
"thresholdsStyle": {
"mode": "off"
}
},
"links": [],
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green"
},
{
"color": "red",
"value": 80
}
]
},
"unit": "decbytes"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 12,
"y": 33
},
"id": 32,
"options": {
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "multi",
"sort": "none"
}
},
"pluginVersion": "10.4.3",
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"expr": "go_memstats_heap_alloc_bytes{job=\"miniflux\"}",
"format": "time_series",
"interval": "",
"intervalFactor": 1,
"legendFormat": "{{ instance }} - heap_alloc",
"refId": "B"
},
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"expr": "go_memstats_heap_sys_bytes{job=\"miniflux\"}",
"format": "time_series",
"interval": "",
"intervalFactor": 1,
"legendFormat": "{{ instance }} - heap_sys",
"refId": "A"
},
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"expr": "go_memstats_heap_idle_bytes{job=\"miniflux\"}",
"format": "time_series",
"interval": "",
"intervalFactor": 1,
"legendFormat": "{{ instance }} - heap_idle",
"refId": "C"
},
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"expr": "go_memstats_heap_inuse_bytes{job=\"miniflux\"}",
"format": "time_series",
"interval": "",
"intervalFactor": 1,
"legendFormat": "{{ instance }} - heap_inuse",
"refId": "D"
},
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"expr": "go_memstats_heap_released_bytes{job=\"miniflux\"}",
"format": "time_series",
"interval": "",
"intervalFactor": 1,
"legendFormat": "{{ instance }} - heap_released",
"refId": "E"
}
],
"title": "Memory in Heap",
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"axisBorderShow": false,
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"drawStyle": "line",
"fillOpacity": 10,
"gradientMode": "none",
"hideFrom": {
"legend": false,
"tooltip": false,
"viz": false
},
"insertNulls": false,
"lineInterpolation": "linear",
"lineWidth": 2,
"pointSize": 5,
"scaleDistribution": {
"type": "linear"
},
"showPoints": "never",
"spanNulls": true,
"stacking": {
"group": "A",
"mode": "none"
},
"thresholdsStyle": {
"mode": "off"
}
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green"
},
{
"color": "red",
"value": 80
}
]
},
"unit": "s"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 0,
"y": 41
},
"id": 14,
"options": {
"legend": {
"calcs": [
"mean",
"lastNotNull",
"max"
],
"displayMode": "table",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "multi",
"sort": "none"
}
},
"pluginVersion": "10.4.3",
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"expr": "go_gc_duration_seconds{job=\"miniflux\"}",
"format": "time_series",
"interval": "",
"intervalFactor": 2,
"legendFormat": "{{instance}}: {{quantile}}",
"metric": "go_gc_duration_seconds",
"refId": "A",
"step": 4
}
],
"title": "GC Duration Quantiles",
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"axisBorderShow": false,
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"drawStyle": "line",
"fillOpacity": 10,
"gradientMode": "none",
"hideFrom": {
"legend": false,
"tooltip": false,
"viz": false
},
"insertNulls": false,
"lineInterpolation": "linear",
"lineWidth": 1,
"pointSize": 5,
"scaleDistribution": {
"type": "linear"
},
"showPoints": "never",
"spanNulls": false,
"stacking": {
"group": "A",
"mode": "none"
},
"thresholdsStyle": {
"mode": "off"
}
},
"links": [],
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green"
},
{
"color": "red",
"value": 80
}
]
},
"unit": "short"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 12,
"y": 41
},
"id": 30,
"options": {
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "multi",
"sort": "none"
}
},
"pluginVersion": "10.4.3",
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"expr": "go_memstats_mallocs_total{job=\"miniflux\"} - go_memstats_frees_total{job=\"miniflux\"}",
"format": "time_series",
"interval": "",
"intervalFactor": 1,
"legendFormat": "{{ instance }}",
"refId": "A"
}
],
"title": "Number of Live Objects",
"type": "timeseries"
}
],
"refresh": "30s",
"schemaVersion": 39,
"tags": [],
"templating": {
"list": [
{
"current": {
"selected": false,
"text": "prometheus",
"value": "354cc25c-f240-4f6f-a2a9-2d68c22df64e"
},
"hide": 0,
"includeAll": false,
"label": "Datasource",
"multi": false,
"name": "DS_PROMETHEUS",
"options": [],
"query": "prometheus",
"queryValue": "",
"refresh": 1,
"regex": "",
"skipUrlSync": false,
"type": "datasource"
}
]
},
"time": {
"from": "now-24h",
"to": "now"
},
"timepicker": {
"refresh_intervals": [
"5s",
"10s",
"30s",
"1m",
"5m",
"15m",
"30m",
"1h",
"2h",
"1d"
]
},
"timezone": "",
"title": "Miniflux",
"uid": "vSaPgcFMk",
"version": 3,
"weekStart": ""
} v2-2.2.16/contrib/sysvinit/ 0000775 0000000 0000000 00000000000 15127074645 0015474 5 ustar 00root root 0000000 0000000 v2-2.2.16/contrib/sysvinit/README.md 0000664 0000000 0000000 00000000202 15127074645 0016745 0 ustar 00root root 0000000 0000000
System-V init for e.g. http://devuan.org
Assumes an executable `/usr/local/bin/miniflux`.
Configure in `etc/default/miniflux`
v2-2.2.16/contrib/sysvinit/etc/ 0000775 0000000 0000000 00000000000 15127074645 0016247 5 ustar 00root root 0000000 0000000 v2-2.2.16/contrib/sysvinit/etc/default/ 0000775 0000000 0000000 00000000000 15127074645 0017673 5 ustar 00root root 0000000 0000000 v2-2.2.16/contrib/sysvinit/etc/default/miniflux 0000664 0000000 0000000 00000000516 15127074645 0021453 0 ustar 00root root 0000000 0000000 # sourced by /etc/init.d/miniflux
# see cluster port in pg_lsclusters and ls -Al /var/run/postgresql/
export DATABASE_URL='host=/var/run/postgresql/ port=5433 user=miniflux password= dbname=miniflux sslmode=disable'
export LISTEN_ADDR='127.0.0.1:8081'
export BASE_URL='https:// and path/'
v2-2.2.16/contrib/sysvinit/etc/init.d/ 0000775 0000000 0000000 00000000000 15127074645 0017434 5 ustar 00root root 0000000 0000000 v2-2.2.16/contrib/sysvinit/etc/init.d/miniflux 0000775 0000000 0000000 00000006230 15127074645 0021216 0 ustar 00root root 0000000 0000000 #! /bin/sh
### BEGIN INIT INFO
# Provides: miniflux
# Required-Start: $syslog $network
# Required-Stop: $syslog
# Should-Start: postgresql
# Should-Stop: postgresql
# Default-Start: 2 3 4 5
# Default-Stop: 0 1 6
# Short-Description: A rss reader
# Description: A RSS reader
### END INIT INFO
# Author: Danny Boisvert
# Do NOT "set -e"
# PATH should only include /usr/* if it runs after the mountnfs.sh script
PATH=/sbin:/usr/sbin:/bin:/usr/bin
DESC="Miniflux"
NAME=miniflux
SERVICEVERBOSE=yes
PIDFILE=/var/run/$NAME.pid
SCRIPTNAME=/etc/init.d/$NAME
WORKINGDIR=/usr/local/bin
DAEMON=$WORKINGDIR/$NAME
DAEMON_ARGS=""
USER=nobody
# Read configuration variable file if it is present
[ -r /etc/default/$NAME ] && . /etc/default/$NAME
# Exit if the package is not installed
[ -x "$DAEMON" ] || exit 0
# Load the VERBOSE setting and other rcS variables
. /lib/init/vars.sh
# Define LSB log_* functions.
# Depend on lsb-base (>= 3.2-14) to ensure that this file is present
# and status_of_proc is working.
. /lib/lsb/init-functions
#
# Function that starts the daemon/service
#
do_start()
{
# Return
# 0 if daemon has been started
# 1 if daemon was already running
# 2 if daemon could not be started
sh -c "USER=$USER start-stop-daemon --start --quiet --pidfile $PIDFILE --make-pidfile \\
--test --chdir $WORKINGDIR --chuid $USER \\
--exec $DAEMON -- $DAEMON_ARGS > /dev/null \\
|| return 1"
sh -c "USER=$USER start-stop-daemon --start --quiet --pidfile $PIDFILE --make-pidfile \\
--background --chdir $WORKINGDIR --chuid $USER \\
--exec $DAEMON -- $DAEMON_ARGS \\
|| return 2"
}
#
# Function that stops the daemon/service
#
do_stop()
{
# Return
# 0 if daemon has been stopped
# 1 if daemon was already stopped
# 2 if daemon could not be stopped
# other if a failure occurred
start-stop-daemon --stop --quiet --retry=TERM/1/KILL/5 --pidfile $PIDFILE --name $NAME
RETVAL="$?"
[ "$RETVAL" = 2 ] && return 2
start-stop-daemon --stop --quiet --oknodo --retry=0/1/KILL/5 --exec $DAEMON
[ "$?" = 2 ] && return 2
# Many daemons don't delete their pidfiles when they exit.
rm -f $PIDFILE
return "$RETVAL"
}
case "$1" in
start)
[ "$SERVICEVERBOSE" != no ] && log_daemon_msg "Starting $DESC" "$NAME"
do_start
case "$?" in
0|1) [ "$SERVICEVERBOSE" != no ] && log_end_msg 0 ;;
2) [ "$SERVICEVERBOSE" != no ] && log_end_msg 1 ;;
esac
;;
stop)
[ "$SERVICEVERBOSE" != no ] && log_daemon_msg "Stopping $DESC" "$NAME"
do_stop
case "$?" in
0|1) [ "$SERVICEVERBOSE" != no ] && log_end_msg 0 ;;
2) [ "$SERVICEVERBOSE" != no ] && log_end_msg 1 ;;
esac
;;
status)
status_of_proc "$DAEMON" "$NAME" && exit 0 || exit $?
;;
restart|force-reload)
log_daemon_msg "Restarting $DESC" "$NAME"
do_stop
case "$?" in
0|1)
do_start
case "$?" in
0) log_end_msg 0 ;;
1) log_end_msg 1 ;; # Old process is still running
*) log_end_msg 1 ;; # Failed to start
esac
;;
*)
# Failed to stop
log_end_msg 1
;;
esac
;;
*)
echo "Usage: $SCRIPTNAME {start|stop|status|restart|force-reload}" >&2
exit 3
;;
esac
v2-2.2.16/contrib/thunder_client/ 0000775 0000000 0000000 00000000000 15127074645 0016613 5 ustar 00root root 0000000 0000000 v2-2.2.16/contrib/thunder_client/README.md 0000664 0000000 0000000 00000000451 15127074645 0020072 0 ustar 00root root 0000000 0000000 Miniflux API Collection for Thunder Client VS Code Extension
============================================================
Official website: https://www.thunderclient.com
This folder contains the API endpoints collection for Miniflux. You can import it locally to interact with the Miniflux API.
v2-2.2.16/contrib/thunder_client/collection.json 0000664 0000000 0000000 00000062715 15127074645 0021654 0 ustar 00root root 0000000 0000000 {
"client": "Thunder Client",
"collectionName": "Miniflux v2",
"dateExported": "2023-07-31T01:53:38.743Z",
"version": "1.1",
"folders": [],
"requests": [
{
"_id": "d23fb9ba-c0c1-46ff-93f4-c5ed24ecd56e",
"colId": "fc35618a-f39f-40a0-a443-d4ae568baa8e",
"containerId": "",
"name": "Discover Subscriptions",
"url": "/v1/discover",
"method": "POST",
"sortNum": 20000,
"created": "2023-07-31T01:20:12.275Z",
"modified": "2023-07-31T01:29:39.751Z",
"headers": [],
"params": [],
"body": {
"type": "json",
"raw": "\n{\n \"url\": \"https://miniflux.app/\"\n}",
"form": []
},
"tests": []
},
{
"_id": "29cfc679-31d4-4d8c-b843-ab92a74dfa85",
"colId": "fc35618a-f39f-40a0-a443-d4ae568baa8e",
"containerId": "",
"name": "Get Feeds",
"url": "/v1/feeds",
"method": "GET",
"sortNum": 50000,
"created": "2023-07-31T01:20:12.276Z",
"modified": "2023-07-31T01:20:12.276Z",
"headers": [],
"params": [],
"tests": []
},
{
"_id": "52a88df8-41c7-47c2-a635-8c93d7d29f40",
"colId": "fc35618a-f39f-40a0-a443-d4ae568baa8e",
"containerId": "",
"name": "Get Category Feeds",
"url": "/v1/categories/1/feeds",
"method": "GET",
"sortNum": 60000,
"created": "2023-07-31T01:20:12.277Z",
"modified": "2023-07-31T01:20:12.277Z",
"headers": [],
"params": [],
"tests": []
},
{
"_id": "a5c2cb48-a4cf-4edc-a0e0-927d9f711843",
"colId": "fc35618a-f39f-40a0-a443-d4ae568baa8e",
"containerId": "",
"name": "Get Feed",
"url": "/v1/feeds/{feedID}",
"method": "GET",
"sortNum": 70000,
"created": "2023-07-31T01:20:12.279Z",
"modified": "2023-07-31T01:31:11.478Z",
"headers": [],
"params": [
{
"name": "feedID",
"value": "1",
"isPath": true
}
],
"tests": []
},
{
"_id": "fb55b058-c2ba-4785-be92-a98f0596e86e",
"colId": "fc35618a-f39f-40a0-a443-d4ae568baa8e",
"containerId": "",
"name": "Get Feed Icon ",
"url": "/v1/feeds/{feedID}/icon",
"method": "GET",
"sortNum": 80000,
"created": "2023-07-31T01:20:12.280Z",
"modified": "2023-07-31T01:31:18.174Z",
"headers": [],
"params": [
{
"name": "feedID",
"value": "1",
"isPath": true
}
],
"tests": []
},
{
"_id": "c0ec9a45-263e-4627-a13b-b5df901a6456",
"colId": "fc35618a-f39f-40a0-a443-d4ae568baa8e",
"containerId": "",
"name": "Create Feed ",
"url": "/v1/feeds",
"method": "POST",
"sortNum": 90000,
"created": "2023-07-31T01:20:12.281Z",
"modified": "2023-07-31T01:31:31.415Z",
"headers": [],
"params": [],
"body": {
"type": "json",
"raw": "{\n \"feed_url\": \"https://miniflux.app/feed.xml\",\n \"category_id\": 1\n}",
"form": []
},
"tests": []
},
{
"_id": "f4c078a2-c031-4753-a7a4-4987439a61d0",
"colId": "fc35618a-f39f-40a0-a443-d4ae568baa8e",
"containerId": "",
"name": "Update Feed",
"url": "/v1/feeds/{feedID}",
"method": "PUT",
"sortNum": 100000,
"created": "2023-07-31T01:20:12.282Z",
"modified": "2023-07-31T01:31:48.115Z",
"headers": [],
"params": [
{
"name": "feedID",
"value": "1",
"isPath": true
}
],
"body": {
"type": "json",
"raw": "{\n \"title\": \"Updated - New Feed Title\",\n \"category_id\": 1\n}",
"form": []
},
"tests": []
},
{
"_id": "1e47aeab-09ce-439b-907f-f9347b98b160",
"colId": "fc35618a-f39f-40a0-a443-d4ae568baa8e",
"containerId": "",
"name": "Refresh Feed",
"url": "/v1/feeds/{feedID}/refresh",
"method": "PUT",
"sortNum": 110000,
"created": "2023-07-31T01:20:12.283Z",
"modified": "2023-07-31T01:31:58.778Z",
"headers": [],
"params": [
{
"name": "feedID",
"value": "1",
"isPath": true
}
],
"tests": []
},
{
"_id": "4f643fa6-042d-4e95-8194-4cb0af7102bf",
"colId": "fc35618a-f39f-40a0-a443-d4ae568baa8e",
"containerId": "",
"name": "Refresh All Feeds",
"url": "/v1/feeds/refresh",
"method": "PUT",
"sortNum": 115000,
"created": "2023-07-31T01:20:12.312Z",
"modified": "2023-07-31T01:20:12.312Z",
"headers": [],
"params": [],
"tests": []
},
{
"_id": "d829f651-e9b9-41f9-aa9e-bd830d5e6389",
"colId": "fc35618a-f39f-40a0-a443-d4ae568baa8e",
"containerId": "",
"name": "Remove Feed",
"url": "/v1/feeds/{feedID}",
"method": "DELETE",
"sortNum": 120000,
"created": "2023-07-31T01:20:12.284Z",
"modified": "2023-07-31T01:32:16.723Z",
"headers": [],
"params": [
{
"name": "feedID",
"value": "1",
"isPath": true
}
],
"tests": []
},
{
"_id": "deafbf1a-d9e0-420f-a749-1bdde56772cb",
"colId": "fc35618a-f39f-40a0-a443-d4ae568baa8e",
"containerId": "",
"name": "Get Feed Entries",
"url": "/v1/feeds/{feedID}/entries",
"method": "GET",
"sortNum": 130000,
"created": "2023-07-31T01:20:12.285Z",
"modified": "2023-07-31T01:32:52.812Z",
"headers": [],
"params": [
{
"name": "feedID",
"value": "2",
"isPath": true
}
],
"tests": []
},
{
"_id": "0052e903-75fc-48ec-8fd5-6e8784ed401a",
"colId": "fc35618a-f39f-40a0-a443-d4ae568baa8e",
"containerId": "",
"name": "Get Entry",
"url": "/v1/entries/{entryID}",
"method": "GET",
"sortNum": 140000,
"created": "2023-07-31T01:20:12.286Z",
"modified": "2023-07-31T01:33:30.417Z",
"headers": [],
"params": [
{
"name": "entryID",
"value": "19",
"isPath": true
}
],
"tests": []
},
{
"_id": "1a055ace-2629-4298-9ea0-1bd17d59a4d6",
"colId": "fc35618a-f39f-40a0-a443-d4ae568baa8e",
"containerId": "",
"name": "Fetch original article",
"url": "/v1/entries/{entryID}/fetch-content",
"method": "GET",
"sortNum": 150000,
"created": "2023-07-31T01:20:12.287Z",
"modified": "2023-07-31T01:33:41.014Z",
"headers": [],
"params": [
{
"name": "entryID",
"value": "19",
"isPath": true
}
],
"tests": []
},
{
"_id": "f272d1e6-ebbb-4c58-a159-4412ad657136",
"colId": "fc35618a-f39f-40a0-a443-d4ae568baa8e",
"containerId": "",
"name": "Get Category Entries",
"url": "/v1/categories/{categoryID}/entries",
"method": "GET",
"sortNum": 160000,
"created": "2023-07-31T01:20:12.288Z",
"modified": "2023-07-31T01:20:12.288Z",
"headers": [],
"params": [
{
"name": "categoryID",
"value": "1",
"isPath": true
}
],
"tests": []
},
{
"_id": "856ed091-318a-4a76-b7ce-6475106dd6b5",
"colId": "fc35618a-f39f-40a0-a443-d4ae568baa8e",
"containerId": "",
"name": "Mark All Feed Entries as Read",
"url": "/v1/feeds/{feedID}/mark-all-as-read",
"method": "PUT",
"sortNum": 180000,
"created": "2023-07-31T01:20:12.290Z",
"modified": "2023-07-31T01:46:57.443Z",
"headers": [],
"params": [
{
"name": "feedID",
"value": "2",
"isPath": true
}
],
"tests": []
},
{
"_id": "67749962-d646-45d5-8b78-a8eeaa7cb971",
"colId": "fc35618a-f39f-40a0-a443-d4ae568baa8e",
"containerId": "",
"name": "Get Entries",
"url": "/v1/entries",
"method": "GET",
"sortNum": 190000,
"created": "2023-07-31T01:20:12.291Z",
"modified": "2023-07-31T01:20:12.291Z",
"headers": [],
"params": [],
"tests": []
},
{
"_id": "b55ae165-2abe-41f0-8b8a-14d826238d20",
"colId": "fc35618a-f39f-40a0-a443-d4ae568baa8e",
"containerId": "",
"name": "Change Entries Status",
"url": "/v1/entries",
"method": "PUT",
"sortNum": 200000,
"created": "2023-07-31T01:20:12.292Z",
"modified": "2023-07-31T01:46:46.133Z",
"headers": [],
"params": [],
"body": {
"type": "json",
"raw": "{\n \"entry_ids\": [19, 20],\n \"status\": \"read\"\n}",
"form": []
},
"tests": []
},
{
"_id": "710dfc55-fc4e-48ab-989e-3ed78019d6c3",
"colId": "fc35618a-f39f-40a0-a443-d4ae568baa8e",
"containerId": "",
"name": "Toggle Entry Bookmark",
"url": "/v1/entries/{entryID}/bookmark",
"method": "PUT",
"sortNum": 210000,
"created": "2023-07-31T01:20:12.293Z",
"modified": "2023-07-31T01:45:51.933Z",
"headers": [],
"params": [
{
"name": "entryID",
"value": "19",
"isPath": true
}
],
"tests": []
},
{
"_id": "19edbe55-0a0a-4102-bde0-73ed6d8515f6",
"colId": "fc35618a-f39f-40a0-a443-d4ae568baa8e",
"containerId": "",
"name": "Save Entry to Third-Party Service",
"url": "/v1/entries/{entryID}/save",
"method": "POST",
"sortNum": 215000,
"created": "2023-07-31T01:20:12.313Z",
"modified": "2023-07-31T01:20:12.313Z",
"headers": [],
"params": [
{
"name": "entryID",
"value": "1",
"isPath": true
}
],
"tests": []
},
{
"_id": "13d2cf52-aa08-4f7f-a83d-ffcb1e1190cd",
"colId": "fc35618a-f39f-40a0-a443-d4ae568baa8e",
"containerId": "",
"name": "Get Categories",
"url": "/v1/categories",
"method": "GET",
"sortNum": 220000,
"created": "2023-07-31T01:20:12.294Z",
"modified": "2023-07-31T01:20:12.294Z",
"headers": [],
"params": [],
"tests": []
},
{
"_id": "1547dabe-2bcb-4e06-acaa-fb393d1027e2",
"colId": "fc35618a-f39f-40a0-a443-d4ae568baa8e",
"containerId": "",
"name": "Create Category ",
"url": "/v1/categories",
"method": "POST",
"sortNum": 230000,
"created": "2023-07-31T01:20:12.295Z",
"modified": "2023-07-31T01:20:12.295Z",
"headers": [],
"params": [],
"body": {
"type": "json",
"raw": "{\n \"title\": \"My category\"\n}",
"form": []
},
"tests": []
},
{
"_id": "e8dac503-19dc-434d-832f-eac4364785d8",
"colId": "fc35618a-f39f-40a0-a443-d4ae568baa8e",
"containerId": "",
"name": "Update Category",
"url": "/v1/categories/{categoryID}",
"method": "PUT",
"sortNum": 232500,
"created": "2023-07-31T01:20:12.296Z",
"modified": "2023-07-31T01:42:55.831Z",
"headers": [],
"params": [
{
"name": "categoryID",
"value": "3",
"isPath": true
}
],
"body": {
"type": "json",
"raw": "\n{\n \"title\": \"My new title\"\n}",
"form": []
},
"tests": []
},
{
"_id": "86d74247-7f12-4a6e-91b3-fad9e7b6b1fb",
"colId": "fc35618a-f39f-40a0-a443-d4ae568baa8e",
"containerId": "",
"name": "Delete Category",
"url": "/v1/categories/{categoryID}",
"method": "DELETE",
"sortNum": 235000,
"created": "2023-07-31T01:20:12.298Z",
"modified": "2023-07-31T01:44:21.486Z",
"headers": [],
"params": [
{
"name": "categoryID",
"value": "3",
"isPath": true
}
],
"tests": []
},
{
"_id": "668dde80-ed03-4fa6-ad2a-9cacd0ec31eb",
"colId": "fc35618a-f39f-40a0-a443-d4ae568baa8e",
"containerId": "",
"name": "Mark Category Entries as Read",
"url": "/v1/categories/{categoryID}/mark-all-as-read",
"method": "PUT",
"sortNum": 237500,
"created": "2023-07-31T01:20:12.299Z",
"modified": "2023-07-31T01:43:50.637Z",
"headers": [],
"params": [
{
"name": "categoryID",
"value": "1",
"isPath": true
}
],
"tests": []
},
{
"_id": "39ada469-765e-4584-ab00-9d263bd526a1",
"colId": "fc35618a-f39f-40a0-a443-d4ae568baa8e",
"containerId": "",
"name": "Get Category Feeds",
"url": "/v1/categories/{categoryID}/feeds",
"method": "GET",
"sortNum": 243750,
"created": "2023-07-31T01:50:23.959Z",
"modified": "2023-07-31T01:50:51.443Z",
"headers": [],
"params": [
{
"name": "categoryID",
"value": "1",
"isPath": true
}
],
"tests": []
},
{
"_id": "ec389c41-185f-4b57-a373-c6ff952b4282",
"colId": "fc35618a-f39f-40a0-a443-d4ae568baa8e",
"containerId": "",
"name": "Refresh Category Feeds",
"url": "/v1/categories/{categoryID}/refresh",
"method": "PUT",
"sortNum": 250000,
"created": "2023-07-31T01:20:12.297Z",
"modified": "2023-07-31T01:43:23.102Z",
"headers": [],
"params": [
{
"name": "categoryID",
"value": "1",
"isPath": true
}
],
"tests": []
},
{
"_id": "bc4a7578-c95e-4436-bbfa-61ccc4a8fc71",
"colId": "fc35618a-f39f-40a0-a443-d4ae568baa8e",
"containerId": "",
"name": "Get Category Entries",
"url": "/v1/categories/{categoryID}/entries",
"method": "GET",
"sortNum": 257500,
"created": "2023-07-31T01:51:15.403Z",
"modified": "2023-07-31T01:51:35.106Z",
"headers": [],
"params": [
{
"name": "categoryID",
"value": "1",
"isPath": true
}
],
"tests": []
},
{
"_id": "fa935fb3-3ed6-4ee3-b995-6c054766d109",
"colId": "fc35618a-f39f-40a0-a443-d4ae568baa8e",
"containerId": "",
"name": "Get Category Entry",
"url": "/v1/categories/{categoryID}/entries/{entryID}",
"method": "GET",
"sortNum": 258750,
"created": "2023-07-31T01:51:46.699Z",
"modified": "2023-07-31T01:52:12.155Z",
"headers": [],
"params": [
{
"name": "categoryID",
"value": "1",
"isPath": true
},
{
"name": "entryID",
"value": "19",
"isPath": true
}
],
"tests": []
},
{
"_id": "cb6968e9-8d13-4410-9ad5-85847b73d7eb",
"colId": "fc35618a-f39f-40a0-a443-d4ae568baa8e",
"containerId": "",
"name": "OPML Export",
"url": "/v1/export",
"method": "GET",
"sortNum": 280000,
"created": "2023-07-31T01:20:12.300Z",
"modified": "2023-07-31T01:20:12.300Z",
"headers": [],
"params": [],
"tests": []
},
{
"_id": "169a64e1-08dd-4760-b405-a748a5286b38",
"colId": "fc35618a-f39f-40a0-a443-d4ae568baa8e",
"containerId": "",
"name": "OPML Import",
"url": "/v1/import",
"method": "POST",
"sortNum": 290000,
"created": "2023-07-31T01:20:12.301Z",
"modified": "2023-07-31T01:41:31.218Z",
"headers": [],
"params": [],
"body": {
"type": "xml",
"raw": "\n\n \n Miniflux \n Sun, 30 Jul 2023 18:41:08 PDT \n \n \n \n \n \n \n ",
"form": []
},
"tests": []
},
{
"_id": "bfb7264a-7b46-49fe-b451-fb6d9b03f0b2",
"colId": "fc35618a-f39f-40a0-a443-d4ae568baa8e",
"containerId": "",
"name": "Create User",
"url": "/v1/users",
"method": "POST",
"sortNum": 300000,
"created": "2023-07-31T01:20:12.302Z",
"modified": "2023-07-31T01:20:12.302Z",
"headers": [],
"params": [],
"body": {
"type": "json",
"raw": "{\n \"username\": \"bob\",\n \"password\": \"test123\",\n \"is_admin\": false\n}",
"form": []
},
"tests": []
},
{
"_id": "93c1dcc2-bf09-4e8e-86ba-0c042147a48f",
"colId": "fc35618a-f39f-40a0-a443-d4ae568baa8e",
"containerId": "",
"name": "Update User",
"url": "/v1/users/{userID}",
"method": "PUT",
"sortNum": 310000,
"created": "2023-07-31T01:20:12.303Z",
"modified": "2023-07-31T01:40:09.576Z",
"headers": [],
"params": [
{
"name": "userID",
"value": "2",
"isPath": true
}
],
"body": {
"type": "json",
"raw": "{\n \"username\": \"joe\"\n}",
"form": []
},
"tests": []
},
{
"_id": "19cf34c1-eb0a-4442-a682-2e94c4f5e594",
"colId": "fc35618a-f39f-40a0-a443-d4ae568baa8e",
"containerId": "",
"name": "Get Current User",
"url": "/v1/me",
"method": "GET",
"sortNum": 320000,
"created": "2023-07-31T01:20:12.304Z",
"modified": "2023-07-31T01:20:12.304Z",
"headers": [],
"params": [],
"tests": []
},
{
"_id": "4a700f7c-8762-4cab-aab1-2d8066884d69",
"colId": "fc35618a-f39f-40a0-a443-d4ae568baa8e",
"containerId": "",
"name": "Get User by ID",
"url": "/v1/users/{userID}",
"method": "GET",
"sortNum": 330000,
"created": "2023-07-31T01:20:12.305Z",
"modified": "2023-07-31T01:39:38.472Z",
"headers": [],
"params": [
{
"name": "userID",
"value": "1",
"isPath": true
}
],
"tests": []
},
{
"_id": "66cb0985-5ed4-4b1e-9029-8605b7f5f74e",
"colId": "fc35618a-f39f-40a0-a443-d4ae568baa8e",
"containerId": "",
"name": "Get User by username",
"url": "/v1/users/{username}",
"method": "GET",
"sortNum": 335000,
"created": "2023-07-31T01:47:53.649Z",
"modified": "2023-07-31T01:48:10.655Z",
"headers": [],
"params": [
{
"name": "username",
"value": "admin",
"isPath": true
}
],
"tests": []
},
{
"_id": "3d4b227a-83a2-4d87-a0ed-ce9d5497aea6",
"colId": "fc35618a-f39f-40a0-a443-d4ae568baa8e",
"containerId": "",
"name": "Get Users",
"url": "/v1/users",
"method": "GET",
"sortNum": 340000,
"created": "2023-07-31T01:20:12.306Z",
"modified": "2023-07-31T01:20:12.306Z",
"headers": [],
"params": [],
"tests": []
},
{
"_id": "90138dea-799a-4b44-ad68-fce6ec5898a6",
"colId": "fc35618a-f39f-40a0-a443-d4ae568baa8e",
"containerId": "",
"name": "Delete User",
"url": "/v1/users/{userID}",
"method": "DELETE",
"sortNum": 350000,
"created": "2023-07-31T01:20:12.307Z",
"modified": "2023-07-31T01:40:38.124Z",
"headers": [],
"params": [
{
"name": "userID",
"value": "2",
"isPath": true
}
],
"tests": []
},
{
"_id": "4b3bf7ca-bc55-423b-a3ee-6279c10a0d85",
"colId": "fc35618a-f39f-40a0-a443-d4ae568baa8e",
"containerId": "",
"name": "Fetch Read/Unread Counters",
"url": "/v1/feeds/counters",
"method": "GET",
"sortNum": 370000,
"created": "2023-07-31T01:20:12.309Z",
"modified": "2023-07-31T01:20:12.309Z",
"headers": [],
"params": [],
"tests": []
},
{
"_id": "7721682f-31e3-4d71-8df9-02e30e4729d7",
"colId": "fc35618a-f39f-40a0-a443-d4ae568baa8e",
"containerId": "",
"name": "Healthcheck",
"url": "/healthcheck",
"method": "GET",
"sortNum": 380000,
"created": "2023-07-31T01:20:12.310Z",
"modified": "2023-07-31T01:20:12.310Z",
"headers": [],
"params": [],
"tests": []
},
{
"_id": "64410254-b17a-43e4-984d-10b9b13c5818",
"colId": "fc35618a-f39f-40a0-a443-d4ae568baa8e",
"containerId": "",
"name": "Version",
"url": "/version",
"method": "GET",
"sortNum": 390000,
"created": "2023-07-31T01:20:12.311Z",
"modified": "2023-07-31T01:20:12.311Z",
"headers": [],
"params": [],
"tests": []
}
],
"settings": {
"auth": {
"type": "basic",
"basic": {
"username": "admin",
"password": "test123"
}
},
"options": {
"baseUrl": "http://localhost:8080"
}
}
} v2-2.2.16/go.mod 0000664 0000000 0000000 00000003043 15127074645 0013252 0 ustar 00root root 0000000 0000000 module miniflux.app/v2
// +heroku goVersion go1.24
require (
github.com/PuerkitoBio/goquery v1.11.0
github.com/andybalholm/brotli v1.2.0
github.com/coreos/go-oidc/v3 v3.17.0
github.com/go-webauthn/webauthn v0.15.0
github.com/gorilla/mux v1.8.1
github.com/lib/pq v1.10.9
github.com/prometheus/client_golang v1.23.2
github.com/tdewolff/minify/v2 v2.24.8
golang.org/x/crypto v0.46.0
golang.org/x/image v0.34.0
golang.org/x/net v0.48.0
golang.org/x/oauth2 v0.34.0
golang.org/x/term v0.38.0
)
require (
github.com/go-webauthn/x v0.1.26 // indirect
github.com/golang-jwt/jwt/v5 v5.3.0 // indirect
github.com/google/go-tpm v0.9.6 // indirect
)
require (
github.com/andybalholm/cascadia v1.3.3 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
github.com/go-jose/go-jose/v4 v4.1.3 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.66.1 // indirect
github.com/prometheus/procfs v0.16.1 // indirect
github.com/tdewolff/parse/v2 v2.8.5 // indirect
github.com/x448/float16 v0.8.4 // indirect
go.yaml.in/yaml/v2 v2.4.2 // indirect
golang.org/x/sys v0.39.0 // indirect
golang.org/x/text v0.32.0 // indirect
google.golang.org/protobuf v1.36.8 // indirect
)
go 1.24.0
toolchain go1.24.1
v2-2.2.16/go.sum 0000664 0000000 0000000 00000033634 15127074645 0013310 0 ustar 00root root 0000000 0000000 github.com/PuerkitoBio/goquery v1.11.0 h1:jZ7pwMQXIITcUXNH83LLk+txlaEy6NVOfTuP43xxfqw=
github.com/PuerkitoBio/goquery v1.11.0/go.mod h1:wQHgxUOU3JGuj3oD/QFfxUdlzW6xPHfqyHre6VMY4DQ=
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/coreos/go-oidc/v3 v3.17.0 h1:hWBGaQfbi0iVviX4ibC7bk8OKT5qNr4klBaCHVNvehc=
github.com/coreos/go-oidc/v3 v3.17.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs=
github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/go-webauthn/webauthn v0.15.0 h1:LR1vPv62E0/6+sTenX35QrCmpMCzLeVAcnXeH4MrbJY=
github.com/go-webauthn/webauthn v0.15.0/go.mod h1:hcAOhVChPRG7oqG7Xj6XKN1mb+8eXTGP/B7zBLzkX5A=
github.com/go-webauthn/x v0.1.26 h1:eNzreFKnwNLDFoywGh9FA8YOMebBWTUNlNSdolQRebs=
github.com/go-webauthn/x v0.1.26/go.mod h1:jmf/phPV6oIsF6hmdVre+ovHkxjDOmNH0t6fekWUxvg=
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/go-tpm v0.9.6 h1:Ku42PT4LmjDu1H5C5ISWLlpI1mj+Zq7sPGKoRw2XROA=
github.com/google/go-tpm v0.9.6/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs=
github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA=
github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg=
github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tdewolff/minify/v2 v2.24.8 h1:58/VjsbevI4d5FGV0ZSuBrHMSSkH4MCH0sIz/eKIauE=
github.com/tdewolff/minify/v2 v2.24.8/go.mod h1:0Ukj0CRpo/sW/nd8uZ4ccXaV1rEVIWA3dj8U7+Shhfw=
github.com/tdewolff/parse/v2 v2.8.5 h1:ZmBiA/8Do5Rpk7bDye0jbbDUpXXbCdc3iah4VeUvwYU=
github.com/tdewolff/parse/v2 v2.8.5/go.mod h1:Hwlni2tiVNKyzR1o6nUs4FOF07URA+JLBLd6dlIXYqo=
github.com/tdewolff/test v1.0.11 h1:FdLbwQVHxqG16SlkGveC0JVyrJN62COWTRyUFzfbtBE=
github.com/tdewolff/test v1.0.11/go.mod h1:XPuWBzvdUzhCuxWO1ojpXsyzsA5bFoS3tO/Q3kFuTG8=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
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.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
golang.org/x/image v0.34.0 h1:33gCkyw9hmwbZJeZkct8XyR11yH889EQt/QH4VmXMn8=
golang.org/x/image v0.34.0/go.mod h1:2RNFBZRB+vnwwFil8GkMdRvrJOFd1AzdZI6vOY+eJVU=
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.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
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.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/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.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q=
golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.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.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc=
google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
v2-2.2.16/internal/ 0000775 0000000 0000000 00000000000 15127074645 0013760 5 ustar 00root root 0000000 0000000 v2-2.2.16/internal/api/ 0000775 0000000 0000000 00000000000 15127074645 0014531 5 ustar 00root root 0000000 0000000 v2-2.2.16/internal/api/api.go 0000664 0000000 0000000 00000012531 15127074645 0015633 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package api // import "miniflux.app/v2/internal/api"
import (
"net/http"
"runtime"
"miniflux.app/v2/internal/http/response/json"
"miniflux.app/v2/internal/storage"
"miniflux.app/v2/internal/version"
"miniflux.app/v2/internal/worker"
"github.com/gorilla/mux"
)
type handler struct {
store *storage.Storage
pool *worker.Pool
router *mux.Router
}
// Serve declares API routes for the application.
func Serve(router *mux.Router, store *storage.Storage, pool *worker.Pool) {
handler := &handler{store, pool, router}
sr := router.PathPrefix("/v1").Subrouter()
middleware := newMiddleware(store)
sr.Use(middleware.handleCORS)
sr.Use(middleware.apiKeyAuth)
sr.Use(middleware.basicAuth)
sr.Methods(http.MethodOptions)
sr.HandleFunc("/users", handler.createUser).Methods(http.MethodPost)
sr.HandleFunc("/users", handler.users).Methods(http.MethodGet)
sr.HandleFunc("/users/{userID:[0-9]+}", handler.userByID).Methods(http.MethodGet)
sr.HandleFunc("/users/{userID:[0-9]+}", handler.updateUser).Methods(http.MethodPut)
sr.HandleFunc("/users/{userID:[0-9]+}", handler.removeUser).Methods(http.MethodDelete)
sr.HandleFunc("/users/{userID:[0-9]+}/mark-all-as-read", handler.markUserAsRead).Methods(http.MethodPut)
sr.HandleFunc("/users/{username}", handler.userByUsername).Methods(http.MethodGet)
sr.HandleFunc("/me", handler.currentUser).Methods(http.MethodGet)
sr.HandleFunc("/categories", handler.createCategory).Methods(http.MethodPost)
sr.HandleFunc("/categories", handler.getCategories).Methods(http.MethodGet)
sr.HandleFunc("/categories/{categoryID}", handler.updateCategory).Methods(http.MethodPut)
sr.HandleFunc("/categories/{categoryID}", handler.removeCategory).Methods(http.MethodDelete)
sr.HandleFunc("/categories/{categoryID}/mark-all-as-read", handler.markCategoryAsRead).Methods(http.MethodPut)
sr.HandleFunc("/categories/{categoryID}/feeds", handler.getCategoryFeeds).Methods(http.MethodGet)
sr.HandleFunc("/categories/{categoryID}/refresh", handler.refreshCategory).Methods(http.MethodPut)
sr.HandleFunc("/categories/{categoryID}/entries", handler.getCategoryEntries).Methods(http.MethodGet)
sr.HandleFunc("/categories/{categoryID}/entries/{entryID}", handler.getCategoryEntry).Methods(http.MethodGet)
sr.HandleFunc("/discover", handler.discoverSubscriptions).Methods(http.MethodPost)
sr.HandleFunc("/feeds", handler.createFeed).Methods(http.MethodPost)
sr.HandleFunc("/feeds", handler.getFeeds).Methods(http.MethodGet)
sr.HandleFunc("/feeds/counters", handler.fetchCounters).Methods(http.MethodGet)
sr.HandleFunc("/feeds/refresh", handler.refreshAllFeeds).Methods(http.MethodPut)
sr.HandleFunc("/feeds/{feedID}/refresh", handler.refreshFeed).Methods(http.MethodPut)
sr.HandleFunc("/feeds/{feedID}", handler.getFeed).Methods(http.MethodGet)
sr.HandleFunc("/feeds/{feedID}", handler.updateFeed).Methods(http.MethodPut)
sr.HandleFunc("/feeds/{feedID}", handler.removeFeed).Methods(http.MethodDelete)
sr.HandleFunc("/feeds/{feedID}/icon", handler.getIconByFeedID).Methods(http.MethodGet)
sr.HandleFunc("/feeds/{feedID}/mark-all-as-read", handler.markFeedAsRead).Methods(http.MethodPut)
sr.HandleFunc("/export", handler.exportFeeds).Methods(http.MethodGet)
sr.HandleFunc("/import", handler.importFeeds).Methods(http.MethodPost)
sr.HandleFunc("/feeds/{feedID}/entries", handler.getFeedEntries).Methods(http.MethodGet)
sr.HandleFunc("/feeds/{feedID}/entries/import", handler.importFeedEntry).Methods(http.MethodPost)
sr.HandleFunc("/feeds/{feedID}/entries/{entryID}", handler.getFeedEntry).Methods(http.MethodGet)
sr.HandleFunc("/entries", handler.getEntries).Methods(http.MethodGet)
sr.HandleFunc("/entries", handler.setEntryStatus).Methods(http.MethodPut)
sr.HandleFunc("/entries/{entryID}", handler.getEntry).Methods(http.MethodGet)
sr.HandleFunc("/entries/{entryID}", handler.updateEntry).Methods(http.MethodPut)
sr.HandleFunc("/entries/{entryID}/bookmark", handler.toggleStarred).Methods(http.MethodPut)
sr.HandleFunc("/entries/{entryID}/star", handler.toggleStarred).Methods(http.MethodPut)
sr.HandleFunc("/entries/{entryID}/save", handler.saveEntry).Methods(http.MethodPost)
sr.HandleFunc("/entries/{entryID}/fetch-content", handler.fetchContent).Methods(http.MethodGet)
sr.HandleFunc("/flush-history", handler.flushHistory).Methods(http.MethodPut, http.MethodDelete)
sr.HandleFunc("/icons/{iconID}", handler.getIconByIconID).Methods(http.MethodGet)
sr.HandleFunc("/enclosures/{enclosureID}", handler.getEnclosureByID).Methods(http.MethodGet)
sr.HandleFunc("/enclosures/{enclosureID}", handler.updateEnclosureByID).Methods(http.MethodPut)
sr.HandleFunc("/integrations/status", handler.getIntegrationsStatus).Methods(http.MethodGet)
sr.HandleFunc("/version", handler.versionHandler).Methods(http.MethodGet)
sr.HandleFunc("/api-keys", handler.createAPIKey).Methods(http.MethodPost)
sr.HandleFunc("/api-keys", handler.getAPIKeys).Methods(http.MethodGet)
sr.HandleFunc("/api-keys/{apiKeyID}", handler.deleteAPIKey).Methods(http.MethodDelete)
}
func (h *handler) versionHandler(w http.ResponseWriter, r *http.Request) {
json.OK(w, r, &versionResponse{
Version: version.Version,
Commit: version.Commit,
BuildDate: version.BuildDate,
GoVersion: runtime.Version(),
Compiler: runtime.Compiler,
Arch: runtime.GOARCH,
OS: runtime.GOOS,
})
}
v2-2.2.16/internal/api/api_integration_test.go 0000664 0000000 0000000 00000261000 15127074645 0021272 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package api // import "miniflux.app/v2/internal/api"
import (
"bytes"
"errors"
"fmt"
"io"
"math/rand/v2"
"os"
"strings"
"testing"
miniflux "miniflux.app/v2/client"
"miniflux.app/v2/internal/model"
)
const skipIntegrationTestsMessage = `Set TEST_MINIFLUX_* environment variables to run the API integration tests`
type integrationTestConfig struct {
testBaseURL string
testAdminUsername string
testAdminPassword string
testRegularUsername string
testRegularPassword string
testFeedURL string
testFeedTitle string
testSubscriptionTitle string
testWebsiteURL string
}
func newIntegrationTestConfig() *integrationTestConfig {
getDefaultEnvValues := func(key, defaultValue string) string {
value := os.Getenv(key)
if value == "" {
return defaultValue
}
return value
}
return &integrationTestConfig{
testBaseURL: getDefaultEnvValues("TEST_MINIFLUX_BASE_URL", ""),
testAdminUsername: getDefaultEnvValues("TEST_MINIFLUX_ADMIN_USERNAME", ""),
testAdminPassword: getDefaultEnvValues("TEST_MINIFLUX_ADMIN_PASSWORD", ""),
testRegularUsername: getDefaultEnvValues("TEST_MINIFLUX_REGULAR_USERNAME_PREFIX", "regular_test_user"),
testRegularPassword: getDefaultEnvValues("TEST_MINIFLUX_REGULAR_PASSWORD", "regular_test_user_password"),
testFeedURL: getDefaultEnvValues("TEST_MINIFLUX_FEED_URL", "https://miniflux.app/feed.xml"),
testFeedTitle: getDefaultEnvValues("TEST_MINIFLUX_FEED_TITLE", "Miniflux"),
testSubscriptionTitle: getDefaultEnvValues("TEST_MINIFLUX_SUBSCRIPTION_TITLE", "Miniflux Releases"),
testWebsiteURL: getDefaultEnvValues("TEST_MINIFLUX_WEBSITE_URL", "https://miniflux.app/"),
}
}
func (c *integrationTestConfig) isConfigured() bool {
return c.testBaseURL != "" && c.testAdminUsername != "" && c.testAdminPassword != "" && c.testFeedURL != "" && c.testFeedTitle != "" && c.testSubscriptionTitle != "" && c.testWebsiteURL != ""
}
func (c *integrationTestConfig) genRandomUsername() string {
return fmt.Sprintf("%s_%10d", c.testRegularUsername, rand.Int())
}
func TestIncorrectEndpoint(t *testing.T) {
testConfig := newIntegrationTestConfig()
if !testConfig.isConfigured() {
t.Skip(skipIntegrationTestsMessage)
}
client := miniflux.NewClient("incorrect url")
if _, err := client.Users(); err == nil {
t.Fatal(`Using an incorrect URL should raise an error`)
}
client = miniflux.NewClient("")
if _, err := client.Users(); err == nil {
t.Fatal(`Using an empty URL should raise an error`)
}
}
func TestHealthcheckEndpoint(t *testing.T) {
testConfig := newIntegrationTestConfig()
if !testConfig.isConfigured() {
t.Skip(skipIntegrationTestsMessage)
}
client := miniflux.NewClient(testConfig.testBaseURL)
if err := client.Healthcheck(); err != nil {
t.Fatal(err)
}
}
func TestVersionEndpoint(t *testing.T) {
testConfig := newIntegrationTestConfig()
if !testConfig.isConfigured() {
t.Skip(skipIntegrationTestsMessage)
}
client := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)
version, err := client.Version()
if err != nil {
t.Fatal(err)
}
if version.Version == "" {
t.Fatal(`Version should not be empty`)
}
if version.Commit == "" {
t.Fatal(`Commit should not be empty`)
}
if version.BuildDate == "" {
t.Fatal(`Build date should not be empty`)
}
if version.GoVersion == "" {
t.Fatal(`Go version should not be empty`)
}
if version.Compiler == "" {
t.Fatal(`Compiler should not be empty`)
}
if version.Arch == "" {
t.Fatal(`Arch should not be empty`)
}
if version.OS == "" {
t.Fatal(`OS should not be empty`)
}
}
func TestInvalidCredentials(t *testing.T) {
testConfig := newIntegrationTestConfig()
if !testConfig.isConfigured() {
t.Skip(skipIntegrationTestsMessage)
}
client := miniflux.NewClient(testConfig.testBaseURL, "invalid", "invalid")
_, err := client.Users()
if err == nil {
t.Fatal(`Using bad credentials should raise an error`)
}
if err != miniflux.ErrNotAuthorized {
t.Fatal(`A "Not Authorized" error should be raised`)
}
}
func TestGetMeEndpoint(t *testing.T) {
testConfig := newIntegrationTestConfig()
if !testConfig.isConfigured() {
t.Skip(skipIntegrationTestsMessage)
}
client := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)
user, err := client.Me()
if err != nil {
t.Fatal(err)
}
if user.Username != testConfig.testAdminUsername {
t.Fatalf(`Invalid username, got %q instead of %q`, user.Username, testConfig.testAdminUsername)
}
}
func TestGetUsersEndpointAsAdmin(t *testing.T) {
testConfig := newIntegrationTestConfig()
if !testConfig.isConfigured() {
t.Skip(skipIntegrationTestsMessage)
}
client := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)
users, err := client.Users()
if err != nil {
t.Fatal(err)
}
if len(users) == 0 {
t.Fatal(`Users should not be empty`)
}
if users[0].ID == 0 {
t.Fatalf(`Invalid userID, got "%v"`, users[0].ID)
}
if users[0].Username != testConfig.testAdminUsername {
t.Fatalf(`Invalid username, got "%v" instead of "%v"`, users[0].Username, testConfig.testAdminUsername)
}
if users[0].Password != "" {
t.Fatalf(`Invalid password, got "%v"`, users[0].Password)
}
if users[0].Language != "en_US" {
t.Fatalf(`Invalid language, got "%v"`, users[0].Language)
}
if users[0].Theme != "light_serif" {
t.Fatalf(`Invalid theme, got "%v"`, users[0].Theme)
}
if users[0].Timezone != "UTC" {
t.Fatalf(`Invalid timezone, got "%v"`, users[0].Timezone)
}
if !users[0].IsAdmin {
t.Fatalf(`Invalid role, got "%v"`, users[0].IsAdmin)
}
if users[0].EntriesPerPage != 100 {
t.Fatalf(`Invalid entries per page, got "%v"`, users[0].EntriesPerPage)
}
if users[0].DisplayMode != "standalone" {
t.Fatalf(`Invalid web app display mode, got "%v"`, users[0].DisplayMode)
}
if users[0].GestureNav != "tap" {
t.Fatalf(`Invalid gesture navigation, got "%v"`, users[0].GestureNav)
}
if users[0].DefaultReadingSpeed != 265 {
t.Fatalf(`Invalid default reading speed, got "%v"`, users[0].DefaultReadingSpeed)
}
if users[0].CJKReadingSpeed != 500 {
t.Fatalf(`Invalid cjk reading speed, got "%v"`, users[0].CJKReadingSpeed)
}
}
func TestGetUsersEndpointAsRegularUser(t *testing.T) {
testConfig := newIntegrationTestConfig()
if !testConfig.isConfigured() {
t.Skip(skipIntegrationTestsMessage)
}
adminClient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)
regularTestUser, err := adminClient.CreateUser(testConfig.genRandomUsername(), testConfig.testRegularPassword, false)
if err != nil {
t.Fatal(err)
}
defer adminClient.DeleteUser(regularTestUser.ID)
regularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)
_, err = regularUserClient.Users()
if err == nil {
t.Fatal(`Regular users should not have access to the users endpoint`)
}
}
func TestCreateUserEndpointAsAdmin(t *testing.T) {
testConfig := newIntegrationTestConfig()
if !testConfig.isConfigured() {
t.Skip(skipIntegrationTestsMessage)
}
client := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)
username := testConfig.genRandomUsername()
regularTestUser, err := client.CreateUser(username, testConfig.testRegularPassword, false)
if err != nil {
t.Fatal(err)
}
defer client.DeleteUser(regularTestUser.ID)
if regularTestUser.Username != username {
t.Fatalf(`Invalid username, got "%v" instead of "%v"`, regularTestUser.Username, username)
}
if regularTestUser.Password != "" {
t.Fatalf(`Invalid password, got "%v"`, regularTestUser.Password)
}
if regularTestUser.Language != "en_US" {
t.Fatalf(`Invalid language, got "%v"`, regularTestUser.Language)
}
if regularTestUser.Theme != "light_serif" {
t.Fatalf(`Invalid theme, got "%v"`, regularTestUser.Theme)
}
if regularTestUser.Timezone != "UTC" {
t.Fatalf(`Invalid timezone, got "%v"`, regularTestUser.Timezone)
}
if regularTestUser.IsAdmin {
t.Fatalf(`Invalid role, got "%v"`, regularTestUser.IsAdmin)
}
if regularTestUser.EntriesPerPage != 100 {
t.Fatalf(`Invalid entries per page, got "%v"`, regularTestUser.EntriesPerPage)
}
if regularTestUser.DisplayMode != "standalone" {
t.Fatalf(`Invalid web app display mode, got "%v"`, regularTestUser.DisplayMode)
}
if regularTestUser.GestureNav != "tap" {
t.Fatalf(`Invalid gesture navigation, got "%v"`, regularTestUser.GestureNav)
}
if regularTestUser.DefaultReadingSpeed != 265 {
t.Fatalf(`Invalid default reading speed, got "%v"`, regularTestUser.DefaultReadingSpeed)
}
if regularTestUser.CJKReadingSpeed != 500 {
t.Fatalf(`Invalid cjk reading speed, got "%v"`, regularTestUser.CJKReadingSpeed)
}
}
func TestCreateUserEndpointAsRegularUser(t *testing.T) {
testConfig := newIntegrationTestConfig()
if !testConfig.isConfigured() {
t.Skip(skipIntegrationTestsMessage)
}
adminClient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)
regularTestUser, err := adminClient.CreateUser(testConfig.genRandomUsername(), testConfig.testRegularPassword, false)
if err != nil {
t.Fatal(err)
}
defer adminClient.DeleteUser(regularTestUser.ID)
regularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)
_, err = regularUserClient.CreateUser(regularTestUser.Username, testConfig.testRegularPassword, false)
if err == nil {
t.Fatal(`Regular users should not have access to the create user endpoint`)
}
}
func TestCannotCreateDuplicateUser(t *testing.T) {
testConfig := newIntegrationTestConfig()
if !testConfig.isConfigured() {
t.Skip(skipIntegrationTestsMessage)
}
client := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)
_, err := client.CreateUser(testConfig.testAdminUsername, testConfig.testAdminPassword, true)
if err == nil {
t.Fatal(`Duplicated users should not be allowed`)
}
}
func TestRemoveUserEndpointAsAdmin(t *testing.T) {
testConfig := newIntegrationTestConfig()
if !testConfig.isConfigured() {
t.Skip(skipIntegrationTestsMessage)
}
client := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)
user, err := client.CreateUser(testConfig.genRandomUsername(), testConfig.testRegularPassword, false)
if err != nil {
t.Fatal(err)
}
if err := client.DeleteUser(user.ID); err != nil {
t.Fatal(err)
}
}
func TestRemoveUserEndpointAsRegularUser(t *testing.T) {
testConfig := newIntegrationTestConfig()
if !testConfig.isConfigured() {
t.Skip(skipIntegrationTestsMessage)
}
adminClient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)
regularTestUser, err := adminClient.CreateUser(testConfig.genRandomUsername(), testConfig.testRegularPassword, false)
if err != nil {
t.Fatal(err)
}
defer adminClient.DeleteUser(regularTestUser.ID)
regularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)
err = regularUserClient.DeleteUser(regularTestUser.ID)
if err == nil {
t.Fatal(`Regular users should not have access to the remove user endpoint`)
}
}
func TestGetUserByIDEndpointAsAdmin(t *testing.T) {
testConfig := newIntegrationTestConfig()
if !testConfig.isConfigured() {
t.Skip(skipIntegrationTestsMessage)
}
client := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)
user, err := client.Me()
if err != nil {
t.Fatal(err)
}
userByID, err := client.UserByID(user.ID)
if err != nil {
t.Fatal(err)
}
if userByID.ID != user.ID {
t.Errorf(`Invalid userID, got "%v" instead of "%v"`, userByID.ID, user.ID)
}
if userByID.Username != user.Username {
t.Errorf(`Invalid username, got "%v" instead of "%v"`, userByID.Username, user.Username)
}
if userByID.Password != "" {
t.Errorf(`The password field must be empty, got "%v"`, userByID.Password)
}
if userByID.Language != user.Language {
t.Errorf(`Invalid language, got "%v"`, userByID.Language)
}
if userByID.Theme != user.Theme {
t.Errorf(`Invalid theme, got "%v"`, userByID.Theme)
}
if userByID.Timezone != user.Timezone {
t.Errorf(`Invalid timezone, got "%v"`, userByID.Timezone)
}
if userByID.IsAdmin != user.IsAdmin {
t.Errorf(`Invalid role, got "%v"`, userByID.IsAdmin)
}
if userByID.EntriesPerPage != user.EntriesPerPage {
t.Errorf(`Invalid entries per page, got "%v"`, userByID.EntriesPerPage)
}
if userByID.DisplayMode != user.DisplayMode {
t.Errorf(`Invalid web app display mode, got "%v"`, userByID.DisplayMode)
}
if userByID.GestureNav != user.GestureNav {
t.Errorf(`Invalid gesture navigation, got "%v"`, userByID.GestureNav)
}
if userByID.DefaultReadingSpeed != user.DefaultReadingSpeed {
t.Errorf(`Invalid default reading speed, got "%v"`, userByID.DefaultReadingSpeed)
}
if userByID.CJKReadingSpeed != user.CJKReadingSpeed {
t.Errorf(`Invalid cjk reading speed, got "%v"`, userByID.CJKReadingSpeed)
}
if userByID.EntryDirection != user.EntryDirection {
t.Errorf(`Invalid entry direction, got "%v"`, userByID.EntryDirection)
}
if userByID.EntryOrder != user.EntryOrder {
t.Errorf(`Invalid entry order, got "%v"`, userByID.EntryOrder)
}
}
func TestGetUserByIDEndpointAsRegularUser(t *testing.T) {
testConfig := newIntegrationTestConfig()
if !testConfig.isConfigured() {
t.Skip(skipIntegrationTestsMessage)
}
adminClient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)
regularTestUser, err := adminClient.CreateUser(testConfig.genRandomUsername(), testConfig.testRegularPassword, false)
if err != nil {
t.Fatal(err)
}
defer adminClient.DeleteUser(regularTestUser.ID)
regularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)
_, err = regularUserClient.UserByID(regularTestUser.ID)
if err == nil {
t.Fatal(`Regular users should not have access to the user by ID endpoint`)
}
}
func TestGetUserByUsernameEndpointAsAdmin(t *testing.T) {
testConfig := newIntegrationTestConfig()
if !testConfig.isConfigured() {
t.Skip(skipIntegrationTestsMessage)
}
client := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)
user, err := client.Me()
if err != nil {
t.Fatal(err)
}
userByUsername, err := client.UserByUsername(user.Username)
if err != nil {
t.Fatal(err)
}
if userByUsername.ID != user.ID {
t.Errorf(`Invalid userID, got "%v" instead of "%v"`, userByUsername.ID, user.ID)
}
if userByUsername.Username != user.Username {
t.Errorf(`Invalid username, got "%v" instead of "%v"`, userByUsername.Username, user.Username)
}
if userByUsername.Password != "" {
t.Errorf(`The password field must be empty, got "%v"`, userByUsername.Password)
}
if userByUsername.Language != user.Language {
t.Errorf(`Invalid language, got "%v"`, userByUsername.Language)
}
if userByUsername.Theme != user.Theme {
t.Errorf(`Invalid theme, got "%v"`, userByUsername.Theme)
}
if userByUsername.Timezone != user.Timezone {
t.Errorf(`Invalid timezone, got "%v"`, userByUsername.Timezone)
}
if userByUsername.IsAdmin != user.IsAdmin {
t.Errorf(`Invalid role, got "%v"`, userByUsername.IsAdmin)
}
if userByUsername.EntriesPerPage != user.EntriesPerPage {
t.Errorf(`Invalid entries per page, got "%v"`, userByUsername.EntriesPerPage)
}
if userByUsername.DisplayMode != user.DisplayMode {
t.Errorf(`Invalid web app display mode, got "%v"`, userByUsername.DisplayMode)
}
if userByUsername.GestureNav != user.GestureNav {
t.Errorf(`Invalid gesture navigation, got "%v"`, userByUsername.GestureNav)
}
if userByUsername.DefaultReadingSpeed != user.DefaultReadingSpeed {
t.Errorf(`Invalid default reading speed, got "%v"`, userByUsername.DefaultReadingSpeed)
}
if userByUsername.CJKReadingSpeed != user.CJKReadingSpeed {
t.Errorf(`Invalid cjk reading speed, got "%v"`, userByUsername.CJKReadingSpeed)
}
if userByUsername.EntryDirection != user.EntryDirection {
t.Errorf(`Invalid entry direction, got "%v"`, userByUsername.EntryDirection)
}
}
func TestGetUserByUsernameEndpointAsRegularUser(t *testing.T) {
testConfig := newIntegrationTestConfig()
if !testConfig.isConfigured() {
t.Skip(skipIntegrationTestsMessage)
}
adminClient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)
regularTestUser, err := adminClient.CreateUser(testConfig.genRandomUsername(), testConfig.testRegularPassword, false)
if err != nil {
t.Fatal(err)
}
defer adminClient.DeleteUser(regularTestUser.ID)
regularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)
_, err = regularUserClient.UserByUsername(regularTestUser.Username)
if err == nil {
t.Fatal(`Regular users should not have access to the user by username endpoint`)
}
}
func TestUpdateUserEndpointByChangingDefaultTheme(t *testing.T) {
testConfig := newIntegrationTestConfig()
if !testConfig.isConfigured() {
t.Skip(skipIntegrationTestsMessage)
}
adminClient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)
regularTestUser, err := adminClient.CreateUser(testConfig.genRandomUsername(), testConfig.testRegularPassword, false)
if err != nil {
t.Fatal(err)
}
defer adminClient.DeleteUser(regularTestUser.ID)
regularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)
userUpdateRequest := &miniflux.UserModificationRequest{
Theme: miniflux.SetOptionalField("dark_serif"),
}
updatedUser, err := regularUserClient.UpdateUser(regularTestUser.ID, userUpdateRequest)
if err != nil {
t.Fatal(err)
}
if updatedUser.Theme != "dark_serif" {
t.Fatalf(`Invalid theme, got "%v"`, updatedUser.Theme)
}
}
func TestUpdateUserEndpointByChangingExternalFonts(t *testing.T) {
testConfig := newIntegrationTestConfig()
if !testConfig.isConfigured() {
t.Skip(skipIntegrationTestsMessage)
}
adminClient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)
regularTestUser, err := adminClient.CreateUser(testConfig.genRandomUsername(), testConfig.testRegularPassword, false)
if err != nil {
t.Fatal(err)
}
defer adminClient.DeleteUser(regularTestUser.ID)
regularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)
userUpdateRequest := &miniflux.UserModificationRequest{
ExternalFontHosts: miniflux.SetOptionalField(" fonts.example.org "),
}
updatedUser, err := regularUserClient.UpdateUser(regularTestUser.ID, userUpdateRequest)
if err != nil {
t.Fatal(err)
}
if updatedUser.ExternalFontHosts != "fonts.example.org" {
t.Fatalf(`Invalid external font hosts, got "%v"`, updatedUser.ExternalFontHosts)
}
}
func TestUpdateUserEndpointByChangingExternalFontsWithInvalidValue(t *testing.T) {
testConfig := newIntegrationTestConfig()
if !testConfig.isConfigured() {
t.Skip(skipIntegrationTestsMessage)
}
adminClient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)
regularTestUser, err := adminClient.CreateUser(testConfig.genRandomUsername(), testConfig.testRegularPassword, false)
if err != nil {
t.Fatal(err)
}
defer adminClient.DeleteUser(regularTestUser.ID)
regularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)
userUpdateRequest := &miniflux.UserModificationRequest{
ExternalFontHosts: miniflux.SetOptionalField("'self' *"),
}
if _, err := regularUserClient.UpdateUser(regularTestUser.ID, userUpdateRequest); err == nil {
t.Fatal(`Updating the user with an invalid external font host should raise an error`)
}
}
func TestUpdateUserEndpointByChangingCustomJS(t *testing.T) {
testConfig := newIntegrationTestConfig()
if !testConfig.isConfigured() {
t.Skip(skipIntegrationTestsMessage)
}
adminClient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)
regularTestUser, err := adminClient.CreateUser(testConfig.genRandomUsername(), testConfig.testRegularPassword, false)
if err != nil {
t.Fatal(err)
}
defer adminClient.DeleteUser(regularTestUser.ID)
regularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)
userUpdateRequest := &miniflux.UserModificationRequest{
CustomJS: miniflux.SetOptionalField("alert('Hello, World!');"),
}
updatedUser, err := regularUserClient.UpdateUser(regularTestUser.ID, userUpdateRequest)
if err != nil {
t.Fatal(err)
}
if updatedUser.CustomJS != "alert('Hello, World!');" {
t.Fatalf(`Invalid custom JS, got %q`, updatedUser.CustomJS)
}
}
func TestUpdateUserEndpointByChangingDefaultThemeToInvalidValue(t *testing.T) {
testConfig := newIntegrationTestConfig()
if !testConfig.isConfigured() {
t.Skip(skipIntegrationTestsMessage)
}
adminClient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)
regularTestUser, err := adminClient.CreateUser(testConfig.genRandomUsername(), testConfig.testRegularPassword, false)
if err != nil {
t.Fatal(err)
}
defer adminClient.DeleteUser(regularTestUser.ID)
regularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)
userUpdateRequest := &miniflux.UserModificationRequest{
Theme: miniflux.SetOptionalField("invalid_theme"),
}
_, err = regularUserClient.UpdateUser(regularTestUser.ID, userUpdateRequest)
if err == nil {
t.Fatal(`Updating the user with an invalid theme should raise an error`)
}
}
func TestRegularUsersCannotUpdateOtherUsers(t *testing.T) {
testConfig := newIntegrationTestConfig()
if !testConfig.isConfigured() {
t.Skip(skipIntegrationTestsMessage)
}
adminClient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)
adminUser, err := adminClient.Me()
if err != nil {
t.Fatal(err)
}
regularTestUser, err := adminClient.CreateUser(testConfig.genRandomUsername(), testConfig.testRegularPassword, false)
if err != nil {
t.Fatal(err)
}
defer adminClient.DeleteUser(regularTestUser.ID)
regularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)
userUpdateRequest := &miniflux.UserModificationRequest{
Theme: miniflux.SetOptionalField("dark_serif"),
}
_, err = regularUserClient.UpdateUser(adminUser.ID, userUpdateRequest)
if err == nil {
t.Fatal(`Regular users should not be able to update other users`)
}
}
func TestAPIKeysEndpoint(t *testing.T) {
testConfig := newIntegrationTestConfig()
if !testConfig.isConfigured() {
t.Skip(skipIntegrationTestsMessage)
}
adminClient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)
regularTestUser, err := adminClient.CreateUser(testConfig.genRandomUsername(), testConfig.testRegularPassword, false)
if err != nil {
t.Fatal(err)
}
defer adminClient.DeleteUser(regularTestUser.ID)
regularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)
apiKeys, err := regularUserClient.APIKeys()
if err != nil {
t.Fatal(err)
}
if len(apiKeys) != 0 {
t.Fatalf(`Expected no API keys, got %d`, len(apiKeys))
}
// Create an API key for the user.
apiKey, err := regularUserClient.CreateAPIKey("Test API Key")
if err != nil {
t.Fatal(err)
}
if apiKey.ID == 0 {
t.Fatalf(`Invalid API key ID, got "%v"`, apiKey.ID)
}
if apiKey.UserID != regularTestUser.ID {
t.Fatalf(`Invalid user ID for API key, got "%v" instead of "%v"`, apiKey.UserID, regularTestUser.ID)
}
if apiKey.Token == "" {
t.Fatalf(`Invalid API key token, got "%v"`, apiKey.Token)
}
if apiKey.Description != "Test API Key" {
t.Fatalf(`Invalid API key description, got "%v" instead of "Test API Key"`, apiKey.Description)
}
// Create a duplicate API key with the same description.
if _, err := regularUserClient.CreateAPIKey("Test API Key"); err == nil {
t.Fatal(`Creating a duplicate API key with the same description should raise an error`)
}
// Fetch the API keys again.
apiKeys, err = regularUserClient.APIKeys()
if err != nil {
t.Fatal(err)
}
if len(apiKeys) != 1 {
t.Fatalf(`Expected 1 API key, got %d`, len(apiKeys))
}
if apiKeys[0].ID != apiKey.ID {
t.Fatalf(`Invalid API key ID, got "%v" instead of "%v"`, apiKeys[0].ID, apiKey.ID)
}
if apiKeys[0].UserID != regularTestUser.ID {
t.Fatalf(`Invalid user ID for API key, got "%v" instead of "%v"`, apiKeys[0].UserID, regularTestUser.ID)
}
if apiKeys[0].Token != apiKey.Token {
t.Fatalf(`Invalid API key token, got "%v" instead of "%v"`, apiKeys[0].Token, apiKey.Token)
}
if apiKeys[0].Description != "Test API Key" {
t.Fatalf(`Invalid API key description, got "%v" instead of "Test API Key"`, apiKeys[0].Description)
}
// Create a new client using the API key.
apiKeyClient := miniflux.NewClient(testConfig.testBaseURL, apiKey.Token)
// Fetch the user using the API key client.
user, err := apiKeyClient.Me()
if err != nil {
t.Fatal(err)
}
// Verify the user matches the regular test user.
if user.ID != regularTestUser.ID {
t.Fatalf(`Expected user ID %d, got %d`, regularTestUser.ID, user.ID)
}
// Delete the API key.
if err := regularUserClient.DeleteAPIKey(apiKey.ID); err != nil {
t.Fatal(err)
}
// Verify the API key is deleted.
apiKeys, err = regularUserClient.APIKeys()
if err != nil {
t.Fatal(err)
}
if len(apiKeys) != 0 {
t.Fatalf(`Expected no API keys after deletion, got %d`, len(apiKeys))
}
// Try to delete the API key again, it should return an error.
err = regularUserClient.DeleteAPIKey(apiKey.ID)
if err == nil {
t.Fatal(`Deleting a non-existent API key should raise an error`)
}
if !errors.Is(err, miniflux.ErrNotFound) {
t.Fatalf(`Expected "not found" error, got %v`, err)
}
// Try to create an API key with an empty description.
if _, err := regularUserClient.CreateAPIKey(""); err == nil {
t.Fatal(`Creating an API key with an empty description should raise an error`)
}
}
func TestMarkUserAsReadEndpoint(t *testing.T) {
testConfig := newIntegrationTestConfig()
if !testConfig.isConfigured() {
t.Skip(skipIntegrationTestsMessage)
}
adminClient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)
regularTestUser, err := adminClient.CreateUser(testConfig.genRandomUsername(), testConfig.testRegularPassword, false)
if err != nil {
t.Fatal(err)
}
defer adminClient.DeleteUser(regularTestUser.ID)
regularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)
feedID, err := regularUserClient.CreateFeed(&miniflux.FeedCreationRequest{
FeedURL: testConfig.testFeedURL,
})
if err != nil {
t.Fatal(err)
}
if err := regularUserClient.MarkAllAsRead(regularTestUser.ID); err != nil {
t.Fatal(err)
}
results, err := regularUserClient.FeedEntries(feedID, nil)
if err != nil {
t.Fatal(err)
}
for _, entry := range results.Entries {
if entry.Status != miniflux.EntryStatusRead {
t.Errorf(`Status for entry %d was %q instead of %q`, entry.ID, entry.Status, miniflux.EntryStatusRead)
}
}
}
func TestCannotMarkUserAsReadAsOtherUser(t *testing.T) {
testConfig := newIntegrationTestConfig()
if !testConfig.isConfigured() {
t.Skip(skipIntegrationTestsMessage)
}
adminClient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)
adminUser, err := adminClient.Me()
if err != nil {
t.Fatal(err)
}
regularTestUser, err := adminClient.CreateUser(testConfig.genRandomUsername(), testConfig.testRegularPassword, false)
if err != nil {
t.Fatal(err)
}
defer adminClient.DeleteUser(regularTestUser.ID)
regularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)
if err := regularUserClient.MarkAllAsRead(adminUser.ID); err == nil {
t.Fatalf(`Non-admin users should not be able to mark another user as read`)
}
}
func TestCreateCategoryEndpoint(t *testing.T) {
testConfig := newIntegrationTestConfig()
if !testConfig.isConfigured() {
t.Skip(skipIntegrationTestsMessage)
}
adminClient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)
regularTestUser, err := adminClient.CreateUser(testConfig.genRandomUsername(), testConfig.testRegularPassword, false)
if err != nil {
t.Fatal(err)
}
defer adminClient.DeleteUser(regularTestUser.ID)
regularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)
categoryName := "My category"
category, err := regularUserClient.CreateCategory(categoryName)
if err != nil {
t.Fatal(err)
}
if category.ID == 0 {
t.Errorf(`Invalid categoryID, got "%v"`, category.ID)
}
if category.UserID <= 0 {
t.Errorf(`Invalid userID, got "%v"`, category.UserID)
}
if category.Title != categoryName {
t.Errorf(`Invalid title, got "%v" instead of "%v"`, category.Title, categoryName)
}
if category.HideGlobally {
t.Errorf(`Invalid hide globally value, got "%v"`, category.HideGlobally)
}
}
func TestCreateCategoryWithEmptyTitle(t *testing.T) {
testConfig := newIntegrationTestConfig()
if !testConfig.isConfigured() {
t.Skip(skipIntegrationTestsMessage)
}
client := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)
_, err := client.CreateCategory("")
if err == nil {
t.Fatalf(`Creating a category with an empty title should raise an error`)
}
}
func TestCannotCreateDuplicatedCategory(t *testing.T) {
testConfig := newIntegrationTestConfig()
if !testConfig.isConfigured() {
t.Skip(skipIntegrationTestsMessage)
}
adminClient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)
regularTestUser, err := adminClient.CreateUser(testConfig.genRandomUsername(), testConfig.testRegularPassword, false)
if err != nil {
t.Fatal(err)
}
defer adminClient.DeleteUser(regularTestUser.ID)
regularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)
categoryName := "My category"
if _, err := regularUserClient.CreateCategory(categoryName); err != nil {
t.Fatal(err)
}
if _, err = regularUserClient.CreateCategory(categoryName); err == nil {
t.Fatalf(`Duplicated categories should not be allowed`)
}
}
func TestCreateCategoryWithOptions(t *testing.T) {
testConfig := newIntegrationTestConfig()
if !testConfig.isConfigured() {
t.Skip(skipIntegrationTestsMessage)
}
adminClient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)
regularTestUser, err := adminClient.CreateUser(testConfig.genRandomUsername(), testConfig.testRegularPassword, false)
if err != nil {
t.Fatal(err)
}
defer adminClient.DeleteUser(regularTestUser.ID)
regularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)
newCategory, err := regularUserClient.CreateCategoryWithOptions(&miniflux.CategoryCreationRequest{
Title: "My category",
HideGlobally: true,
})
if err != nil {
t.Fatalf(`Creating a category with options should not raise an error: %v`, err)
}
categories, err := regularUserClient.Categories()
if err != nil {
t.Fatal(err)
}
for _, category := range categories {
if category.ID == newCategory.ID {
if category.Title != newCategory.Title {
t.Errorf(`Invalid title, got %q instead of %q`, category.Title, newCategory.Title)
}
if category.HideGlobally != true {
t.Errorf(`Invalid hide globally value, got "%v"`, category.HideGlobally)
}
break
}
}
}
func TestUpdateCategoryEndpoint(t *testing.T) {
testConfig := newIntegrationTestConfig()
if !testConfig.isConfigured() {
t.Skip(skipIntegrationTestsMessage)
}
adminClient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)
regularTestUser, err := adminClient.CreateUser(testConfig.genRandomUsername(), testConfig.testRegularPassword, false)
if err != nil {
t.Fatal(err)
}
defer adminClient.DeleteUser(regularTestUser.ID)
regularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)
categoryName := "My category"
category, err := regularUserClient.CreateCategory(categoryName)
if err != nil {
t.Fatal(err)
}
updatedCategory, err := regularUserClient.UpdateCategory(category.ID, "new title")
if err != nil {
t.Fatal(err)
}
if updatedCategory.ID != category.ID {
t.Errorf(`Invalid categoryID, got "%v"`, updatedCategory.ID)
}
if updatedCategory.UserID != regularTestUser.ID {
t.Errorf(`Invalid userID, got "%v"`, updatedCategory.UserID)
}
if updatedCategory.Title != "new title" {
t.Errorf(`Invalid title, got "%v" instead of "%v"`, updatedCategory.Title, "new title")
}
if updatedCategory.HideGlobally {
t.Errorf(`Invalid hide globally value, got "%v"`, updatedCategory.HideGlobally)
}
}
func TestUpdateCategoryWithOptions(t *testing.T) {
testConfig := newIntegrationTestConfig()
if !testConfig.isConfigured() {
t.Skip(skipIntegrationTestsMessage)
}
adminClient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)
regularTestUser, err := adminClient.CreateUser(testConfig.genRandomUsername(), testConfig.testRegularPassword, false)
if err != nil {
t.Fatal(err)
}
defer adminClient.DeleteUser(regularTestUser.ID)
regularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)
newCategory, err := regularUserClient.CreateCategoryWithOptions(&miniflux.CategoryCreationRequest{
Title: "My category",
})
if err != nil {
t.Fatalf(`Creating a category with options should not raise an error: %v`, err)
}
updatedCategory, err := regularUserClient.UpdateCategoryWithOptions(newCategory.ID, &miniflux.CategoryModificationRequest{
Title: miniflux.SetOptionalField("new title"),
})
if err != nil {
t.Fatal(err)
}
if updatedCategory.ID != newCategory.ID {
t.Errorf(`Invalid categoryID, got "%v"`, updatedCategory.ID)
}
if updatedCategory.Title != "new title" {
t.Errorf(`Invalid title, got "%v" instead of "%v"`, updatedCategory.Title, "new title")
}
if updatedCategory.HideGlobally {
t.Errorf(`Invalid hide globally value, got "%v"`, updatedCategory.HideGlobally)
}
updatedCategory, err = regularUserClient.UpdateCategoryWithOptions(newCategory.ID, &miniflux.CategoryModificationRequest{
HideGlobally: miniflux.SetOptionalField(true),
})
if err != nil {
t.Fatal(err)
}
if updatedCategory.ID != newCategory.ID {
t.Errorf(`Invalid categoryID, got "%v"`, updatedCategory.ID)
}
if updatedCategory.Title != "new title" {
t.Errorf(`Invalid title, got "%v" instead of "%v"`, updatedCategory.Title, "new title")
}
if !updatedCategory.HideGlobally {
t.Errorf(`Invalid hide globally value, got "%v"`, updatedCategory.HideGlobally)
}
updatedCategory, err = regularUserClient.UpdateCategoryWithOptions(newCategory.ID, &miniflux.CategoryModificationRequest{
HideGlobally: miniflux.SetOptionalField(false),
})
if err != nil {
t.Fatal(err)
}
if updatedCategory.ID != newCategory.ID {
t.Errorf(`Invalid categoryID, got %q`, updatedCategory.ID)
}
if updatedCategory.Title != "new title" {
t.Errorf(`Invalid title, got %q instead of %q`, updatedCategory.Title, "new title")
}
if updatedCategory.HideGlobally {
t.Errorf(`Invalid hide globally value, got "%v"`, updatedCategory.HideGlobally)
}
}
func TestUpdateInexistingCategory(t *testing.T) {
testConfig := newIntegrationTestConfig()
if !testConfig.isConfigured() {
t.Skip(skipIntegrationTestsMessage)
}
client := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)
_, err := client.UpdateCategory(123456789, "new title")
if err == nil {
t.Fatalf(`Updating an inexisting category should raise an error`)
}
}
func TestDeleteCategoryEndpoint(t *testing.T) {
testConfig := newIntegrationTestConfig()
if !testConfig.isConfigured() {
t.Skip(skipIntegrationTestsMessage)
}
adminClient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)
regularTestUser, err := adminClient.CreateUser(testConfig.genRandomUsername(), testConfig.testRegularPassword, false)
if err != nil {
t.Fatal(err)
}
defer adminClient.DeleteUser(regularTestUser.ID)
regularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)
categoryName := "My category"
category, err := regularUserClient.CreateCategory(categoryName)
if err != nil {
t.Fatal(err)
}
if err := regularUserClient.DeleteCategory(category.ID); err != nil {
t.Fatal(err)
}
}
func TestCannotDeleteInexistingCategory(t *testing.T) {
testConfig := newIntegrationTestConfig()
if !testConfig.isConfigured() {
t.Skip(skipIntegrationTestsMessage)
}
client := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)
err := client.DeleteCategory(123456789)
if err == nil {
t.Fatalf(`Deleting an inexisting category should raise an error`)
}
}
func TestCannotDeleteCategoryOfAnotherUser(t *testing.T) {
testConfig := newIntegrationTestConfig()
if !testConfig.isConfigured() {
t.Skip(skipIntegrationTestsMessage)
}
adminClient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)
regularTestUser, err := adminClient.CreateUser(testConfig.genRandomUsername(), testConfig.testRegularPassword, false)
if err != nil {
t.Fatal(err)
}
defer adminClient.DeleteUser(regularTestUser.ID)
regularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)
category, err := regularUserClient.CreateCategory("My category")
if err != nil {
t.Fatal(err)
}
err = adminClient.DeleteCategory(category.ID)
if err == nil {
t.Fatalf(`Regular users should not be able to delete categories of other users`)
}
}
func TestGetCategoriesEndpoint(t *testing.T) {
testConfig := newIntegrationTestConfig()
if !testConfig.isConfigured() {
t.Skip(skipIntegrationTestsMessage)
}
adminClient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)
regularTestUser, err := adminClient.CreateUser(testConfig.genRandomUsername(), testConfig.testRegularPassword, false)
if err != nil {
t.Fatal(err)
}
defer adminClient.DeleteUser(regularTestUser.ID)
regularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)
category, err := regularUserClient.CreateCategory("My category")
if err != nil {
t.Fatal(err)
}
categories, err := regularUserClient.Categories()
if err != nil {
t.Fatal(err)
}
if len(categories) != 2 {
t.Fatalf(`Invalid number of categories, got %d instead of %d`, len(categories), 1)
}
if categories[0].UserID != regularTestUser.ID {
t.Fatalf(`Invalid userID, got %d`, categories[0].UserID)
}
if categories[0].Title != "All" {
t.Fatalf(`Invalid title, got %q instead of %q`, categories[0].Title, "All")
}
if categories[0].FeedCount != nil {
t.Errorf(`Expected FeedCount to be nil, got %d`, *categories[0].FeedCount)
}
if categories[0].TotalUnread != nil {
t.Errorf(`Expected TotalUnread to be nil, got %d`, *categories[0].TotalUnread)
}
if categories[1].ID != category.ID {
t.Fatalf(`Invalid categoryID, got %d`, categories[0].ID)
}
if categories[1].UserID != regularTestUser.ID {
t.Fatalf(`Invalid userID, got %d`, categories[0].UserID)
}
if categories[1].Title != "My category" {
t.Fatalf(`Invalid title, got %q instead of %q`, categories[0].Title, "My category")
}
if categories[1].FeedCount != nil {
t.Errorf(`Expected FeedCount to be nil, got %d`, *categories[1].FeedCount)
}
if categories[1].TotalUnread != nil {
t.Errorf(`Expected TotalUnread to be nil, got %d`, *categories[1].TotalUnread)
}
categories, err = regularUserClient.CategoriesWithCounters()
if err != nil {
t.Fatal(err)
}
if len(categories) != 2 {
t.Fatalf(`Invalid number of categories, got %d instead of %d`, len(categories), 1)
}
if categories[1].FeedCount == nil {
t.Fatalf(`Expected FeedCount to be not nil`)
}
if categories[1].TotalUnread == nil {
t.Fatalf(`Expected TotalUnread to be not nil`)
}
expectedCounterValue := 0
if *categories[1].FeedCount != expectedCounterValue {
t.Errorf(`Expected FeedCount to be %d, got %d`, expectedCounterValue, *categories[1].FeedCount)
}
if *categories[1].TotalUnread != expectedCounterValue {
t.Errorf(`Expected TotalUnread to be %d, got %d`, expectedCounterValue, *categories[1].TotalUnread)
}
}
func TestMarkCategoryAsReadEndpoint(t *testing.T) {
testConfig := newIntegrationTestConfig()
if !testConfig.isConfigured() {
t.Skip(skipIntegrationTestsMessage)
}
adminClient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)
regularTestUser, err := adminClient.CreateUser(testConfig.genRandomUsername(), testConfig.testRegularPassword, false)
if err != nil {
t.Fatal(err)
}
defer adminClient.DeleteUser(regularTestUser.ID)
regularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)
category, err := regularUserClient.CreateCategory("My category")
if err != nil {
t.Fatal(err)
}
feedID, err := regularUserClient.CreateFeed(&miniflux.FeedCreationRequest{
FeedURL: testConfig.testFeedURL,
CategoryID: category.ID,
})
if err != nil {
t.Fatal(err)
}
if err := regularUserClient.MarkCategoryAsRead(category.ID); err != nil {
t.Fatal(err)
}
results, err := regularUserClient.FeedEntries(feedID, nil)
if err != nil {
t.Fatal(err)
}
for _, entry := range results.Entries {
if entry.Status != miniflux.EntryStatusRead {
t.Errorf(`Status for entry %d was %q instead of %q`, entry.ID, entry.Status, miniflux.EntryStatusRead)
}
}
}
func TestCreateFeedEndpoint(t *testing.T) {
testConfig := newIntegrationTestConfig()
if !testConfig.isConfigured() {
t.Skip(skipIntegrationTestsMessage)
}
adminClient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)
regularTestUser, err := adminClient.CreateUser(testConfig.genRandomUsername(), testConfig.testRegularPassword, false)
if err != nil {
t.Fatal(err)
}
defer adminClient.DeleteUser(regularTestUser.ID)
regularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)
category, err := regularUserClient.CreateCategory("My category")
if err != nil {
t.Fatal(err)
}
feedID, err := regularUserClient.CreateFeed(&miniflux.FeedCreationRequest{
FeedURL: testConfig.testFeedURL,
CategoryID: category.ID,
})
if err != nil {
t.Fatal(err)
}
if feedID == 0 {
t.Errorf(`Invalid feedID, got "%v"`, feedID)
}
}
func TestCannotCreateDuplicatedFeed(t *testing.T) {
testConfig := newIntegrationTestConfig()
if !testConfig.isConfigured() {
t.Skip(skipIntegrationTestsMessage)
}
adminClient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)
regularTestUser, err := adminClient.CreateUser(testConfig.genRandomUsername(), testConfig.testRegularPassword, false)
if err != nil {
t.Fatal(err)
}
defer adminClient.DeleteUser(regularTestUser.ID)
regularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)
feedID, err := regularUserClient.CreateFeed(&miniflux.FeedCreationRequest{
FeedURL: testConfig.testFeedURL,
})
if err != nil {
t.Fatal(err)
}
if feedID == 0 {
t.Fatalf(`Invalid feedID, got "%v"`, feedID)
}
_, err = regularUserClient.CreateFeed(&miniflux.FeedCreationRequest{
FeedURL: testConfig.testFeedURL,
})
if err == nil {
t.Fatalf(`Duplicated feeds should not be allowed`)
}
}
func TestCreateFeedWithInexistingCategory(t *testing.T) {
testConfig := newIntegrationTestConfig()
if !testConfig.isConfigured() {
t.Skip(skipIntegrationTestsMessage)
}
adminClient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)
regularTestUser, err := adminClient.CreateUser(testConfig.genRandomUsername(), testConfig.testRegularPassword, false)
if err != nil {
t.Fatal(err)
}
defer adminClient.DeleteUser(regularTestUser.ID)
regularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)
_, err = regularUserClient.CreateFeed(&miniflux.FeedCreationRequest{
FeedURL: testConfig.testFeedURL,
CategoryID: 123456789,
})
if err == nil {
t.Fatalf(`Creating a feed with an inexisting category should raise an error`)
}
}
func TestCreateFeedWithEmptyFeedURL(t *testing.T) {
testConfig := newIntegrationTestConfig()
if !testConfig.isConfigured() {
t.Skip(skipIntegrationTestsMessage)
}
client := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)
_, err := client.CreateFeed(&miniflux.FeedCreationRequest{
FeedURL: "",
})
if err == nil {
t.Fatalf(`Creating a feed with an empty feed URL should raise an error`)
}
}
func TestCreateFeedWithInvalidFeedURL(t *testing.T) {
testConfig := newIntegrationTestConfig()
if !testConfig.isConfigured() {
t.Skip(skipIntegrationTestsMessage)
}
client := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)
_, err := client.CreateFeed(&miniflux.FeedCreationRequest{
FeedURL: "invalid_feed_url",
})
if err == nil {
t.Fatalf(`Creating a feed with an invalid feed URL should raise an error`)
}
}
func TestCreateDisabledFeed(t *testing.T) {
testConfig := newIntegrationTestConfig()
if !testConfig.isConfigured() {
t.Skip(skipIntegrationTestsMessage)
}
adminClient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)
regularTestUser, err := adminClient.CreateUser(testConfig.genRandomUsername(), testConfig.testRegularPassword, false)
if err != nil {
t.Fatal(err)
}
defer adminClient.DeleteUser(regularTestUser.ID)
regularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)
feedID, err := regularUserClient.CreateFeed(&miniflux.FeedCreationRequest{
FeedURL: testConfig.testFeedURL,
Disabled: true,
})
if err != nil {
t.Fatal(err)
}
feed, err := regularUserClient.Feed(feedID)
if err != nil {
t.Fatal(err)
}
if !feed.Disabled {
t.Fatalf(`The feed should be disabled`)
}
}
func TestCreateFeedWithDisabledHTTPCache(t *testing.T) {
testConfig := newIntegrationTestConfig()
if !testConfig.isConfigured() {
t.Skip(skipIntegrationTestsMessage)
}
adminClient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)
regularTestUser, err := adminClient.CreateUser(testConfig.genRandomUsername(), testConfig.testRegularPassword, false)
if err != nil {
t.Fatal(err)
}
defer adminClient.DeleteUser(regularTestUser.ID)
regularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)
feedID, err := regularUserClient.CreateFeed(&miniflux.FeedCreationRequest{
FeedURL: testConfig.testFeedURL,
IgnoreHTTPCache: true,
})
if err != nil {
t.Fatal(err)
}
feed, err := regularUserClient.Feed(feedID)
if err != nil {
t.Fatal(err)
}
if !feed.IgnoreHTTPCache {
t.Fatalf(`The feed should ignore the HTTP cache`)
}
}
func TestCreateFeedWithScraperRule(t *testing.T) {
testConfig := newIntegrationTestConfig()
if !testConfig.isConfigured() {
t.Skip(skipIntegrationTestsMessage)
}
adminClient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)
regularTestUser, err := adminClient.CreateUser(testConfig.genRandomUsername(), testConfig.testRegularPassword, false)
if err != nil {
t.Fatal(err)
}
defer adminClient.DeleteUser(regularTestUser.ID)
regularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)
feedID, err := regularUserClient.CreateFeed(&miniflux.FeedCreationRequest{
FeedURL: testConfig.testFeedURL,
ScraperRules: "article",
})
if err != nil {
t.Fatal(err)
}
feed, err := regularUserClient.Feed(feedID)
if err != nil {
t.Fatal(err)
}
if feed.ScraperRules != "article" {
t.Fatalf(`The feed should have the scraper rules set to "article"`)
}
}
func TestUpdateFeedEndpoint(t *testing.T) {
testConfig := newIntegrationTestConfig()
if !testConfig.isConfigured() {
t.Skip(skipIntegrationTestsMessage)
}
adminClient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)
regularTestUser, err := adminClient.CreateUser(testConfig.genRandomUsername(), testConfig.testRegularPassword, false)
if err != nil {
t.Fatal(err)
}
defer adminClient.DeleteUser(regularTestUser.ID)
regularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)
feedID, err := regularUserClient.CreateFeed(&miniflux.FeedCreationRequest{
FeedURL: testConfig.testFeedURL,
})
if err != nil {
t.Fatal(err)
}
feedUpdateRequest := &miniflux.FeedModificationRequest{
FeedURL: miniflux.SetOptionalField("https://example.org/feed.xml"),
}
updatedFeed, err := regularUserClient.UpdateFeed(feedID, feedUpdateRequest)
if err != nil {
t.Fatal(err)
}
if updatedFeed.FeedURL != "https://example.org/feed.xml" {
t.Fatalf(`Invalid feed URL, got "%v"`, updatedFeed.FeedURL)
}
}
func TestCannotHaveDuplicateFeedWhenUpdatingFeed(t *testing.T) {
testConfig := newIntegrationTestConfig()
if !testConfig.isConfigured() {
t.Skip(skipIntegrationTestsMessage)
}
adminClient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)
regularTestUser, err := adminClient.CreateUser(testConfig.genRandomUsername(), testConfig.testRegularPassword, false)
if err != nil {
t.Fatal(err)
}
defer adminClient.DeleteUser(regularTestUser.ID)
regularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)
if _, err := regularUserClient.CreateFeed(&miniflux.FeedCreationRequest{FeedURL: testConfig.testFeedURL}); err != nil {
t.Fatal(err)
}
feedID, err := regularUserClient.CreateFeed(&miniflux.FeedCreationRequest{
FeedURL: "https://github.com/miniflux/v2/commits.atom",
})
if err != nil {
t.Fatal(err)
}
feedUpdateRequest := &miniflux.FeedModificationRequest{
FeedURL: miniflux.SetOptionalField(testConfig.testFeedURL),
}
if _, err := regularUserClient.UpdateFeed(feedID, feedUpdateRequest); err == nil {
t.Fatalf(`Duplicated feeds should not be allowed`)
}
}
func TestUpdateFeedWithInvalidCategory(t *testing.T) {
testConfig := newIntegrationTestConfig()
if !testConfig.isConfigured() {
t.Skip(skipIntegrationTestsMessage)
}
adminClient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)
regularTestUser, err := adminClient.CreateUser(testConfig.genRandomUsername(), testConfig.testRegularPassword, false)
if err != nil {
t.Fatal(err)
}
defer adminClient.DeleteUser(regularTestUser.ID)
regularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)
feedID, err := regularUserClient.CreateFeed(&miniflux.FeedCreationRequest{
FeedURL: testConfig.testFeedURL,
})
if err != nil {
t.Fatal(err)
}
feedUpdateRequest := &miniflux.FeedModificationRequest{
CategoryID: miniflux.SetOptionalField(int64(123456789)),
}
if _, err := regularUserClient.UpdateFeed(feedID, feedUpdateRequest); err == nil {
t.Fatalf(`Updating a feed with an inexisting category should raise an error`)
}
}
func TestMarkFeedAsReadEndpoint(t *testing.T) {
testConfig := newIntegrationTestConfig()
if !testConfig.isConfigured() {
t.Skip(skipIntegrationTestsMessage)
}
adminClient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)
regularTestUser, err := adminClient.CreateUser(testConfig.genRandomUsername(), testConfig.testRegularPassword, false)
if err != nil {
t.Fatal(err)
}
defer adminClient.DeleteUser(regularTestUser.ID)
regularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)
feedID, err := regularUserClient.CreateFeed(&miniflux.FeedCreationRequest{
FeedURL: testConfig.testFeedURL,
})
if err != nil {
t.Fatal(err)
}
if err := regularUserClient.MarkFeedAsRead(feedID); err != nil {
t.Fatal(err)
}
results, err := regularUserClient.FeedEntries(feedID, nil)
if err != nil {
t.Fatalf(`Failed to get updated entries: %v`, err)
}
for _, entry := range results.Entries {
if entry.Status != miniflux.EntryStatusRead {
t.Errorf(`Status for entry %d was %q instead of %q`, entry.ID, entry.Status, miniflux.EntryStatusRead)
}
}
}
func TestFetchCountersEndpoint(t *testing.T) {
testConfig := newIntegrationTestConfig()
if !testConfig.isConfigured() {
t.Skip(skipIntegrationTestsMessage)
}
adminClient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)
regularTestUser, err := adminClient.CreateUser(testConfig.genRandomUsername(), testConfig.testRegularPassword, false)
if err != nil {
t.Fatal(err)
}
defer adminClient.DeleteUser(regularTestUser.ID)
regularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)
feedID, err := regularUserClient.CreateFeed(&miniflux.FeedCreationRequest{
FeedURL: testConfig.testFeedURL,
})
if err != nil {
t.Fatal(err)
}
counters, err := regularUserClient.FetchCounters()
if err != nil {
t.Fatal(err)
}
if value, ok := counters.ReadCounters[feedID]; ok && value != 0 {
t.Errorf(`Invalid read counter, got %d`, value)
}
if value, ok := counters.UnreadCounters[feedID]; !ok || value == 0 {
t.Errorf(`Invalid unread counter, got %d`, value)
}
}
func TestDeleteFeedEndpoint(t *testing.T) {
testConfig := newIntegrationTestConfig()
if !testConfig.isConfigured() {
t.Skip(skipIntegrationTestsMessage)
}
adminClient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)
regularTestUser, err := adminClient.CreateUser(testConfig.genRandomUsername(), testConfig.testRegularPassword, false)
if err != nil {
t.Fatal(err)
}
defer adminClient.DeleteUser(regularTestUser.ID)
regularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)
feedID, err := regularUserClient.CreateFeed(&miniflux.FeedCreationRequest{
FeedURL: testConfig.testFeedURL,
})
if err != nil {
t.Fatal(err)
}
if err := regularUserClient.DeleteFeed(feedID); err != nil {
t.Fatal(err)
}
}
func TestRefreshAllFeedsEndpoint(t *testing.T) {
testConfig := newIntegrationTestConfig()
if !testConfig.isConfigured() {
t.Skip(skipIntegrationTestsMessage)
}
adminClient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)
regularTestUser, err := adminClient.CreateUser(testConfig.genRandomUsername(), testConfig.testRegularPassword, false)
if err != nil {
t.Fatal(err)
}
defer adminClient.DeleteUser(regularTestUser.ID)
regularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)
if err := regularUserClient.RefreshAllFeeds(); err != nil {
t.Fatal(err)
}
}
func TestRefreshFeedEndpoint(t *testing.T) {
testConfig := newIntegrationTestConfig()
if !testConfig.isConfigured() {
t.Skip(skipIntegrationTestsMessage)
}
adminClient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)
regularTestUser, err := adminClient.CreateUser(testConfig.genRandomUsername(), testConfig.testRegularPassword, false)
if err != nil {
t.Fatal(err)
}
defer adminClient.DeleteUser(regularTestUser.ID)
regularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)
feedID, err := regularUserClient.CreateFeed(&miniflux.FeedCreationRequest{
FeedURL: testConfig.testFeedURL,
})
if err != nil {
t.Fatal(err)
}
if err := regularUserClient.RefreshFeed(feedID); err != nil {
t.Fatal(err)
}
}
func TestGetFeedEndpoint(t *testing.T) {
testConfig := newIntegrationTestConfig()
if !testConfig.isConfigured() {
t.Skip(skipIntegrationTestsMessage)
}
adminClient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)
regularTestUser, err := adminClient.CreateUser(testConfig.genRandomUsername(), testConfig.testRegularPassword, false)
if err != nil {
t.Fatal(err)
}
defer adminClient.DeleteUser(regularTestUser.ID)
regularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)
feedID, err := regularUserClient.CreateFeed(&miniflux.FeedCreationRequest{
FeedURL: testConfig.testFeedURL,
})
if err != nil {
t.Fatal(err)
}
feed, err := regularUserClient.Feed(feedID)
if err != nil {
t.Fatal(err)
}
if feed.ID != feedID {
t.Fatalf(`Invalid feedID, got %d`, feed.ID)
}
if feed.FeedURL != testConfig.testFeedURL {
t.Fatalf(`Invalid feed URL, got %q`, feed.FeedURL)
}
if feed.SiteURL != testConfig.testWebsiteURL {
t.Fatalf(`Invalid site URL, got %q`, feed.SiteURL)
}
if feed.Title != testConfig.testFeedTitle {
t.Fatalf(`Invalid title, got %q`, feed.Title)
}
}
func TestGetFeedIcon(t *testing.T) {
testConfig := newIntegrationTestConfig()
if !testConfig.isConfigured() {
t.Skip(skipIntegrationTestsMessage)
}
adminClient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)
regularTestUser, err := adminClient.CreateUser(testConfig.genRandomUsername(), testConfig.testRegularPassword, false)
if err != nil {
t.Fatal(err)
}
defer adminClient.DeleteUser(regularTestUser.ID)
regularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)
feedID, err := regularUserClient.CreateFeed(&miniflux.FeedCreationRequest{
FeedURL: testConfig.testFeedURL,
})
if err != nil {
t.Fatal(err)
}
icon, err := regularUserClient.FeedIcon(feedID)
if err != nil {
t.Fatal(err)
}
if icon == nil {
t.Fatalf(`Invalid icon, got nil`)
}
if icon.MimeType == "" {
t.Fatalf(`Invalid mime type, got %q`, icon.MimeType)
}
if len(icon.Data) == 0 {
t.Fatalf(`Invalid data, got empty`)
}
icon, err = regularUserClient.Icon(icon.ID)
if err != nil {
t.Fatal(err)
}
if icon == nil {
t.Fatalf(`Invalid icon, got nil`)
}
if icon.MimeType == "" {
t.Fatalf(`Invalid mime type, got %q`, icon.MimeType)
}
if len(icon.Data) == 0 {
t.Fatalf(`Invalid data, got empty`)
}
}
func TestGetFeedIconWithInexistingFeedID(t *testing.T) {
testConfig := newIntegrationTestConfig()
if !testConfig.isConfigured() {
t.Skip(skipIntegrationTestsMessage)
}
client := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)
_, err := client.FeedIcon(123456789)
if err == nil {
t.Fatalf(`Fetching the icon of an inexisting feed should raise an error`)
}
}
func TestGetFeedsEndpoint(t *testing.T) {
testConfig := newIntegrationTestConfig()
if !testConfig.isConfigured() {
t.Skip(skipIntegrationTestsMessage)
}
adminClient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)
regularTestUser, err := adminClient.CreateUser(testConfig.genRandomUsername(), testConfig.testRegularPassword, false)
if err != nil {
t.Fatal(err)
}
defer adminClient.DeleteUser(regularTestUser.ID)
regularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)
feedID, err := regularUserClient.CreateFeed(&miniflux.FeedCreationRequest{
FeedURL: testConfig.testFeedURL,
})
if err != nil {
t.Fatal(err)
}
feeds, err := regularUserClient.Feeds()
if err != nil {
t.Fatal(err)
}
if len(feeds) != 1 {
t.Fatalf(`Invalid number of feeds, got %d`, len(feeds))
}
if feeds[0].ID != feedID {
t.Fatalf(`Invalid feedID, got %d`, feeds[0].ID)
}
if feeds[0].FeedURL != testConfig.testFeedURL {
t.Fatalf(`Invalid feed URL, got %q`, feeds[0].FeedURL)
}
}
func TestGetCategoryFeedsEndpoint(t *testing.T) {
testConfig := newIntegrationTestConfig()
if !testConfig.isConfigured() {
t.Skip(skipIntegrationTestsMessage)
}
adminClient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)
regularTestUser, err := adminClient.CreateUser(testConfig.genRandomUsername(), testConfig.testRegularPassword, false)
if err != nil {
t.Fatal(err)
}
defer adminClient.DeleteUser(regularTestUser.ID)
regularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)
category, err := regularUserClient.CreateCategory("My category")
if err != nil {
t.Fatal(err)
}
feedID, err := regularUserClient.CreateFeed(&miniflux.FeedCreationRequest{
FeedURL: testConfig.testFeedURL,
CategoryID: category.ID,
})
if err != nil {
t.Fatal(err)
}
feeds, err := regularUserClient.CategoryFeeds(category.ID)
if err != nil {
t.Fatal(err)
}
if len(feeds) != 1 {
t.Fatalf(`Invalid number of feeds, got %d`, len(feeds))
}
if feeds[0].ID != feedID {
t.Fatalf(`Invalid feedID, got %d`, feeds[0].ID)
}
if feeds[0].FeedURL != testConfig.testFeedURL {
t.Fatalf(`Invalid feed URL, got %q`, feeds[0].FeedURL)
}
}
func TestExportEndpoint(t *testing.T) {
testConfig := newIntegrationTestConfig()
if !testConfig.isConfigured() {
t.Skip(skipIntegrationTestsMessage)
}
adminClient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)
regularTestUser, err := adminClient.CreateUser(testConfig.genRandomUsername(), testConfig.testRegularPassword, false)
if err != nil {
t.Fatal(err)
}
defer adminClient.DeleteUser(regularTestUser.ID)
regularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)
if _, err := regularUserClient.CreateFeed(&miniflux.FeedCreationRequest{FeedURL: testConfig.testFeedURL}); err != nil {
t.Fatal(err)
}
exportedData, err := regularUserClient.Export()
if err != nil {
t.Fatal(err)
}
if len(exportedData) == 0 {
t.Fatalf(`Invalid exported data, got empty`)
}
if !strings.HasPrefix(string(exportedData), "
`
regularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)
bytesReader := bytes.NewReader([]byte(data))
if err := regularUserClient.Import(io.NopCloser(bytesReader)); err != nil {
t.Fatal(err)
}
}
func TestDiscoverSubscriptionsEndpoint(t *testing.T) {
testConfig := newIntegrationTestConfig()
if !testConfig.isConfigured() {
t.Skip(skipIntegrationTestsMessage)
}
client := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)
subscriptions, err := client.Discover(testConfig.testWebsiteURL)
if err != nil {
t.Fatal(err)
}
if len(subscriptions) == 0 {
t.Fatalf(`Invalid number of subscriptions, got %d`, len(subscriptions))
}
if subscriptions[0].Title != testConfig.testSubscriptionTitle {
t.Fatalf(`Invalid title, got %q`, subscriptions[0].Title)
}
if subscriptions[0].URL != testConfig.testFeedURL {
t.Fatalf(`Invalid URL, got %q`, subscriptions[0].URL)
}
}
func TestDiscoverSubscriptionsWithInvalidURL(t *testing.T) {
testConfig := newIntegrationTestConfig()
if !testConfig.isConfigured() {
t.Skip(skipIntegrationTestsMessage)
}
client := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)
_, err := client.Discover("invalid_url")
if err == nil {
t.Fatalf(`Discovering subscriptions with an invalid URL should raise an error`)
}
}
func TestDiscoverSubscriptionsWithNoSubscription(t *testing.T) {
testConfig := newIntegrationTestConfig()
if !testConfig.isConfigured() {
t.Skip(skipIntegrationTestsMessage)
}
client := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)
if _, err := client.Discover(testConfig.testBaseURL); err != miniflux.ErrNotFound {
t.Fatalf(`Discovering subscriptions with no subscription should raise a 404 error`)
}
}
func TestGetAllFeedEntriesEndpoint(t *testing.T) {
testConfig := newIntegrationTestConfig()
if !testConfig.isConfigured() {
t.Skip(skipIntegrationTestsMessage)
}
adminClient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)
regularTestUser, err := adminClient.CreateUser(testConfig.genRandomUsername(), testConfig.testRegularPassword, false)
if err != nil {
t.Fatal(err)
}
defer adminClient.DeleteUser(regularTestUser.ID)
regularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)
feedID, err := regularUserClient.CreateFeed(&miniflux.FeedCreationRequest{
FeedURL: testConfig.testFeedURL,
})
if err != nil {
t.Fatal(err)
}
results, err := regularUserClient.FeedEntries(feedID, nil)
if err != nil {
t.Fatal(err)
}
if len(results.Entries) == 0 {
t.Fatalf(`Invalid number of entries, got %d`, len(results.Entries))
}
if results.Total == 0 {
t.Fatalf(`Invalid total, got %d`, results.Total)
}
if results.Entries[0].FeedID != feedID {
t.Fatalf(`Invalid feedID, got %d`, results.Entries[0].FeedID)
}
if results.Entries[0].Feed.FeedURL != testConfig.testFeedURL {
t.Fatalf(`Invalid feed URL, got %q`, results.Entries[0].Feed.FeedURL)
}
if results.Entries[0].Title == "" {
t.Fatalf(`Invalid title, got empty`)
}
}
func TestGetAllCategoryEntriesEndpoint(t *testing.T) {
testConfig := newIntegrationTestConfig()
if !testConfig.isConfigured() {
t.Skip(skipIntegrationTestsMessage)
}
adminClient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)
regularTestUser, err := adminClient.CreateUser(testConfig.genRandomUsername(), testConfig.testRegularPassword, false)
if err != nil {
t.Fatal(err)
}
defer adminClient.DeleteUser(regularTestUser.ID)
regularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)
category, err := regularUserClient.CreateCategory("My category")
if err != nil {
t.Fatal(err)
}
feedID, err := regularUserClient.CreateFeed(&miniflux.FeedCreationRequest{
FeedURL: testConfig.testFeedURL,
CategoryID: category.ID,
})
if err != nil {
t.Fatal(err)
}
results, err := regularUserClient.CategoryEntries(category.ID, nil)
if err != nil {
t.Fatal(err)
}
if len(results.Entries) == 0 {
t.Fatalf(`Invalid number of entries, got %d`, len(results.Entries))
}
if results.Total == 0 {
t.Fatalf(`Invalid total, got %d`, results.Total)
}
if results.Entries[0].FeedID != feedID {
t.Fatalf(`Invalid feedID, got %d`, results.Entries[0].FeedID)
}
if results.Entries[0].Feed.FeedURL != testConfig.testFeedURL {
t.Fatalf(`Invalid feed URL, got %q`, results.Entries[0].Feed.FeedURL)
}
if results.Entries[0].Title == "" {
t.Fatalf(`Invalid title, got empty`)
}
}
func TestGetAllEntriesEndpointWithFilter(t *testing.T) {
testConfig := newIntegrationTestConfig()
if !testConfig.isConfigured() {
t.Skip(skipIntegrationTestsMessage)
}
adminClient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)
regularTestUser, err := adminClient.CreateUser(testConfig.genRandomUsername(), testConfig.testRegularPassword, false)
if err != nil {
t.Fatal(err)
}
defer adminClient.DeleteUser(regularTestUser.ID)
regularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)
feedID, err := regularUserClient.CreateFeed(&miniflux.FeedCreationRequest{
FeedURL: testConfig.testFeedURL,
})
if err != nil {
t.Fatal(err)
}
feedEntries, err := regularUserClient.Entries(&miniflux.Filter{FeedID: feedID})
if err != nil {
t.Fatal(err)
}
if len(feedEntries.Entries) == 0 {
t.Fatalf(`Invalid number of entries, got %d`, len(feedEntries.Entries))
}
if feedEntries.Total == 0 {
t.Fatalf(`Invalid total, got %d`, feedEntries.Total)
}
if feedEntries.Entries[0].FeedID != feedID {
t.Fatalf(`Invalid feedID, got %d`, feedEntries.Entries[0].FeedID)
}
if feedEntries.Entries[0].Feed.FeedURL != testConfig.testFeedURL {
t.Fatalf(`Invalid feed URL, got %q`, feedEntries.Entries[0].Feed.FeedURL)
}
if feedEntries.Entries[0].Title == "" {
t.Fatalf(`Invalid title, got empty`)
}
recentEntries, err := regularUserClient.Entries(&miniflux.Filter{Order: "published_at", Direction: "desc"})
if err != nil {
t.Fatal(err)
}
if len(recentEntries.Entries) == 0 {
t.Fatalf(`Invalid number of entries, got %d`, len(recentEntries.Entries))
}
if recentEntries.Total == 0 {
t.Fatalf(`Invalid total, got %d`, recentEntries.Total)
}
if feedEntries.Entries[0].Title == recentEntries.Entries[0].Title {
t.Fatalf(`Invalid order, got the same title`)
}
searchedEntries, err := regularUserClient.Entries(&miniflux.Filter{Search: "2.0.8"})
if err != nil {
t.Fatal(err)
}
if searchedEntries.Total != 1 {
t.Fatalf(`Invalid total, got %d`, searchedEntries.Total)
}
if _, err := regularUserClient.Entries(&miniflux.Filter{Status: "invalid"}); err == nil {
t.Fatal(`Using invalid status should raise an error`)
}
if _, err = regularUserClient.Entries(&miniflux.Filter{Direction: "invalid"}); err == nil {
t.Fatal(`Using invalid direction should raise an error`)
}
if _, err = regularUserClient.Entries(&miniflux.Filter{Order: "invalid"}); err == nil {
t.Fatal(`Using invalid order should raise an error`)
}
}
func TestGetGlobalEntriesEndpoint(t *testing.T) {
testConfig := newIntegrationTestConfig()
if !testConfig.isConfigured() {
t.Skip(skipIntegrationTestsMessage)
}
adminClient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)
regularTestUser, err := adminClient.CreateUser(testConfig.genRandomUsername(), testConfig.testRegularPassword, false)
if err != nil {
t.Fatal(err)
}
defer adminClient.DeleteUser(regularTestUser.ID)
regularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)
feedID, err := regularUserClient.CreateFeed(&miniflux.FeedCreationRequest{
FeedURL: testConfig.testFeedURL,
HideGlobally: true,
})
if err != nil {
t.Fatal(err)
}
feedIDEntry, err := regularUserClient.Feed(feedID)
if err != nil {
t.Fatal(err)
}
if feedIDEntry.HideGlobally != true {
t.Fatalf(`Expected feed to have globally_hidden set to true, was false.`)
}
/* Not filtering on GloballyVisible should return all entries */
feedEntries, err := regularUserClient.Entries(&miniflux.Filter{FeedID: feedID})
if err != nil {
t.Fatal(err)
}
if len(feedEntries.Entries) == 0 {
t.Fatalf(`Expected entries but response contained none.`)
}
/* Feed is hidden globally, so this should be empty */
globallyVisibleEntries, err := regularUserClient.Entries(&miniflux.Filter{GloballyVisible: true})
if err != nil {
t.Fatal(err)
}
if len(globallyVisibleEntries.Entries) != 0 {
t.Fatalf(`Expected no entries, got %d`, len(globallyVisibleEntries.Entries))
}
}
func TestCannotGetRemovedEntries(t *testing.T) {
testConfig := newIntegrationTestConfig()
if !testConfig.isConfigured() {
t.Skip(skipIntegrationTestsMessage)
}
adminClient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)
regularTestUser, err := adminClient.CreateUser(testConfig.genRandomUsername(), testConfig.testRegularPassword, false)
if err != nil {
t.Fatal(err)
}
defer adminClient.DeleteUser(regularTestUser.ID)
regularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)
feedID, err := regularUserClient.CreateFeed(&miniflux.FeedCreationRequest{
FeedURL: testConfig.testFeedURL,
})
if err != nil {
t.Fatal(err)
}
feedEntries, err := regularUserClient.Entries(&miniflux.Filter{FeedID: feedID})
if err != nil {
t.Fatal(err)
}
if feedEntries.Total == 0 {
t.Fatalf(`Expected at least one entry, got none`)
}
if err := regularUserClient.UpdateEntries([]int64{feedEntries.Entries[0].ID}, miniflux.EntryStatusRemoved); err != nil {
t.Fatal(err)
}
if _, err := regularUserClient.Entry(feedEntries.Entries[0].ID); err != miniflux.ErrNotFound {
t.Fatalf(`Expected entry to be not found, got %v`, err)
}
if _, err := regularUserClient.FeedEntry(feedID, feedEntries.Entries[0].ID); err != miniflux.ErrNotFound {
t.Fatalf(`Expected entry to be not found, got %v`, err)
}
if _, err := regularUserClient.CategoryEntry(feedEntries.Entries[0].Feed.Category.ID, feedEntries.Entries[0].ID); err != miniflux.ErrNotFound {
t.Fatalf(`Expected entry to be not found, got %v`, err)
}
updatedFeedEntries, err := regularUserClient.Entries(&miniflux.Filter{FeedID: feedID})
if err != nil {
t.Fatal(err)
}
if updatedFeedEntries.Total != feedEntries.Total-1 {
t.Fatalf(`Expected %d entries, got %d`, feedEntries.Total-1, updatedFeedEntries.Total)
}
}
func TestUpdateEnclosureEndpoint(t *testing.T) {
testConfig := newIntegrationTestConfig()
if !testConfig.isConfigured() {
t.Skip(skipIntegrationTestsMessage)
}
adminClient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)
regularTestUser, err := adminClient.CreateUser(testConfig.genRandomUsername(), testConfig.testRegularPassword, false)
if err != nil {
t.Fatal(err)
}
defer adminClient.DeleteUser(regularTestUser.ID)
regularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)
feedID, err := regularUserClient.CreateFeed(&miniflux.FeedCreationRequest{
FeedURL: testConfig.testFeedURL,
})
if err != nil {
t.Fatal(err)
}
result, err := regularUserClient.FeedEntries(feedID, nil)
if err != nil {
t.Fatalf(`Failed to get entries: %v`, err)
}
var enclosure *miniflux.Enclosure
for _, entry := range result.Entries {
if len(entry.Enclosures) > 0 {
enclosure = entry.Enclosures[0]
break
}
}
if enclosure == nil {
t.Skip(`Skipping test, missing enclosure in feed.`)
}
err = regularUserClient.UpdateEnclosure(enclosure.ID, &miniflux.EnclosureUpdateRequest{
MediaProgression: 20,
})
if err != nil {
t.Fatal(err)
}
updatedEnclosure, err := regularUserClient.Enclosure(enclosure.ID)
if err != nil {
t.Fatal(err)
}
if updatedEnclosure.MediaProgression != 20 {
t.Fatalf(`Failed to update media_progression, expected %d but got %d`, 20, updatedEnclosure.MediaProgression)
}
}
func TestGetEnclosureEndpoint(t *testing.T) {
testConfig := newIntegrationTestConfig()
if !testConfig.isConfigured() {
t.Skip(skipIntegrationTestsMessage)
}
adminClient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)
regularTestUser, err := adminClient.CreateUser(testConfig.genRandomUsername(), testConfig.testRegularPassword, false)
if err != nil {
t.Fatal(err)
}
defer adminClient.DeleteUser(regularTestUser.ID)
regularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)
feedID, err := regularUserClient.CreateFeed(&miniflux.FeedCreationRequest{
FeedURL: testConfig.testFeedURL,
})
if err != nil {
t.Fatal(err)
}
result, err := regularUserClient.FeedEntries(feedID, nil)
if err != nil {
t.Fatalf(`Failed to get entries: %v`, err)
}
var expectedEnclosure *miniflux.Enclosure
for _, entry := range result.Entries {
if len(entry.Enclosures) > 0 {
expectedEnclosure = entry.Enclosures[0]
break
}
}
if expectedEnclosure == nil {
t.Skip(`Skipping test, missing enclosure in feed.`)
}
enclosure, err := regularUserClient.Enclosure(expectedEnclosure.ID)
if err != nil {
t.Fatal(err)
}
if enclosure.ID != expectedEnclosure.ID {
t.Fatalf(`Invalid enclosureID, got %d while expecting %d`, enclosure.ID, expectedEnclosure.ID)
}
if _, err = regularUserClient.Enclosure(99999); err == nil {
t.Fatalf(`Fetching an inexisting enclosure should raise an error`)
}
}
func TestGetEntryEndpoints(t *testing.T) {
testConfig := newIntegrationTestConfig()
if !testConfig.isConfigured() {
t.Skip(skipIntegrationTestsMessage)
}
adminClient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)
regularTestUser, err := adminClient.CreateUser(testConfig.genRandomUsername(), testConfig.testRegularPassword, false)
if err != nil {
t.Fatal(err)
}
defer adminClient.DeleteUser(regularTestUser.ID)
regularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)
feedID, err := regularUserClient.CreateFeed(&miniflux.FeedCreationRequest{
FeedURL: testConfig.testFeedURL,
})
if err != nil {
t.Fatal(err)
}
result, err := regularUserClient.FeedEntries(feedID, nil)
if err != nil {
t.Fatalf(`Failed to get entries: %v`, err)
}
entry, err := regularUserClient.FeedEntry(feedID, result.Entries[0].ID)
if err != nil {
t.Fatal(err)
}
if entry.ID != result.Entries[0].ID {
t.Fatalf(`Invalid entryID, got %d`, entry.ID)
}
if entry.FeedID != feedID {
t.Fatalf(`Invalid feedID, got %d`, entry.FeedID)
}
if entry.Feed.FeedURL != testConfig.testFeedURL {
t.Fatalf(`Invalid feed URL, got %q`, entry.Feed.FeedURL)
}
entry, err = regularUserClient.Entry(result.Entries[0].ID)
if err != nil {
t.Fatal(err)
}
if entry.ID != result.Entries[0].ID {
t.Fatalf(`Invalid entryID, got %d`, entry.ID)
}
entry, err = regularUserClient.CategoryEntry(result.Entries[0].Feed.Category.ID, result.Entries[0].ID)
if err != nil {
t.Fatal(err)
}
if entry.ID != result.Entries[0].ID {
t.Fatalf(`Invalid entryID, got %d`, entry.ID)
}
}
func TestUpdateEntryStatusEndpoint(t *testing.T) {
testConfig := newIntegrationTestConfig()
if !testConfig.isConfigured() {
t.Skip(skipIntegrationTestsMessage)
}
adminClient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)
regularTestUser, err := adminClient.CreateUser(testConfig.genRandomUsername(), testConfig.testRegularPassword, false)
if err != nil {
t.Fatal(err)
}
defer adminClient.DeleteUser(regularTestUser.ID)
regularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)
feedID, err := regularUserClient.CreateFeed(&miniflux.FeedCreationRequest{
FeedURL: testConfig.testFeedURL,
})
if err != nil {
t.Fatal(err)
}
result, err := regularUserClient.FeedEntries(feedID, nil)
if err != nil {
t.Fatalf(`Failed to get entries: %v`, err)
}
if err := regularUserClient.UpdateEntries([]int64{result.Entries[0].ID}, miniflux.EntryStatusRead); err != nil {
t.Fatal(err)
}
entry, err := regularUserClient.Entry(result.Entries[0].ID)
if err != nil {
t.Fatal(err)
}
if entry.Status != miniflux.EntryStatusRead {
t.Fatalf(`Invalid status, got %q`, entry.Status)
}
}
func TestUpdateEntryEndpoint(t *testing.T) {
testConfig := newIntegrationTestConfig()
if !testConfig.isConfigured() {
t.Skip(skipIntegrationTestsMessage)
}
adminClient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)
regularTestUser, err := adminClient.CreateUser(testConfig.genRandomUsername(), testConfig.testRegularPassword, false)
if err != nil {
t.Fatal(err)
}
defer adminClient.DeleteUser(regularTestUser.ID)
regularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)
feedID, err := regularUserClient.CreateFeed(&miniflux.FeedCreationRequest{
FeedURL: testConfig.testFeedURL,
})
if err != nil {
t.Fatal(err)
}
result, err := regularUserClient.FeedEntries(feedID, nil)
if err != nil {
t.Fatalf(`Failed to get entries: %v`, err)
}
entryUpdateRequest := &miniflux.EntryModificationRequest{
Title: miniflux.SetOptionalField("New title"),
Content: miniflux.SetOptionalField("New content"),
}
updatedEntry, err := regularUserClient.UpdateEntry(result.Entries[0].ID, entryUpdateRequest)
if err != nil {
t.Fatal(err)
}
if updatedEntry.Title != "New title" {
t.Errorf(`Invalid title, got %q`, updatedEntry.Title)
}
if updatedEntry.Content != "New content" {
t.Errorf(`Invalid content, got %q`, updatedEntry.Content)
}
entry, err := regularUserClient.Entry(result.Entries[0].ID)
if err != nil {
t.Fatal(err)
}
if entry.Title != "New title" {
t.Errorf(`Invalid title, got %q`, entry.Title)
}
if entry.Content != "New content" {
t.Errorf(`Invalid content, got %q`, entry.Content)
}
}
func TestToggleStarredEndpoint(t *testing.T) {
testConfig := newIntegrationTestConfig()
if !testConfig.isConfigured() {
t.Skip(skipIntegrationTestsMessage)
}
adminClient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)
regularTestUser, err := adminClient.CreateUser(testConfig.genRandomUsername(), testConfig.testRegularPassword, false)
if err != nil {
t.Fatal(err)
}
defer adminClient.DeleteUser(regularTestUser.ID)
regularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)
feedID, err := regularUserClient.CreateFeed(&miniflux.FeedCreationRequest{
FeedURL: testConfig.testFeedURL,
})
if err != nil {
t.Fatal(err)
}
result, err := regularUserClient.FeedEntries(feedID, &miniflux.Filter{Limit: 1})
if err != nil {
t.Fatalf(`Failed to get entries: %v`, err)
}
if err := regularUserClient.ToggleStarred(result.Entries[0].ID); err != nil {
t.Fatal(err)
}
entry, err := regularUserClient.Entry(result.Entries[0].ID)
if err != nil {
t.Fatal(err)
}
if !entry.Starred {
t.Fatalf(`The entry should be starred`)
}
}
func TestSaveEntryEndpoint(t *testing.T) {
testConfig := newIntegrationTestConfig()
if !testConfig.isConfigured() {
t.Skip(skipIntegrationTestsMessage)
}
adminClient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)
regularTestUser, err := adminClient.CreateUser(testConfig.genRandomUsername(), testConfig.testRegularPassword, false)
if err != nil {
t.Fatal(err)
}
defer adminClient.DeleteUser(regularTestUser.ID)
regularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)
feedID, err := regularUserClient.CreateFeed(&miniflux.FeedCreationRequest{
FeedURL: testConfig.testFeedURL,
})
if err != nil {
t.Fatal(err)
}
result, err := regularUserClient.FeedEntries(feedID, &miniflux.Filter{Limit: 1})
if err != nil {
t.Fatalf(`Failed to get entries: %v`, err)
}
if err := regularUserClient.SaveEntry(result.Entries[0].ID); !errors.Is(err, miniflux.ErrBadRequest) {
t.Fatalf(`Saving an entry should raise a bad request error because no integration is configured`)
}
}
func TestFetchIntegrationsStatusEndpoint(t *testing.T) {
testConfig := newIntegrationTestConfig()
if !testConfig.isConfigured() {
t.Skip(skipIntegrationTestsMessage)
}
adminClient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)
regularTestUser, err := adminClient.CreateUser(testConfig.genRandomUsername(), testConfig.testRegularPassword, false)
if err != nil {
t.Fatal(err)
}
defer adminClient.DeleteUser(regularTestUser.ID)
regularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)
hasIntegrations, err := regularUserClient.IntegrationsStatus()
if err != nil {
t.Fatalf("Failed to fetch integrations status: %v", err)
}
if hasIntegrations {
t.Fatalf("New user should not have integrations configured")
}
}
func TestFetchContentEndpoint(t *testing.T) {
testConfig := newIntegrationTestConfig()
if !testConfig.isConfigured() {
t.Skip(skipIntegrationTestsMessage)
}
adminClient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)
regularTestUser, err := adminClient.CreateUser(testConfig.genRandomUsername(), testConfig.testRegularPassword, false)
if err != nil {
t.Fatal(err)
}
defer adminClient.DeleteUser(regularTestUser.ID)
regularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)
feedID, err := regularUserClient.CreateFeed(&miniflux.FeedCreationRequest{
FeedURL: testConfig.testFeedURL,
})
if err != nil {
t.Fatal(err)
}
result, err := regularUserClient.FeedEntries(feedID, &miniflux.Filter{Limit: 1})
if err != nil {
t.Fatalf(`Failed to get entries: %v`, err)
}
content, err := regularUserClient.FetchEntryOriginalContent(result.Entries[0].ID)
if err != nil {
t.Fatal(err)
}
if content == "" {
t.Fatalf(`Invalid content, got empty`)
}
}
func TestFlushHistoryEndpoint(t *testing.T) {
testConfig := newIntegrationTestConfig()
if !testConfig.isConfigured() {
t.Skip(skipIntegrationTestsMessage)
}
adminClient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)
regularTestUser, err := adminClient.CreateUser(testConfig.genRandomUsername(), testConfig.testRegularPassword, false)
if err != nil {
t.Fatal(err)
}
defer adminClient.DeleteUser(regularTestUser.ID)
regularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)
feedID, err := regularUserClient.CreateFeed(&miniflux.FeedCreationRequest{
FeedURL: testConfig.testFeedURL,
})
if err != nil {
t.Fatal(err)
}
result, err := regularUserClient.FeedEntries(feedID, &miniflux.Filter{Limit: 3})
if err != nil {
t.Fatalf(`Failed to get entries: %v`, err)
}
if err := regularUserClient.UpdateEntries([]int64{result.Entries[0].ID, result.Entries[1].ID}, miniflux.EntryStatusRead); err != nil {
t.Fatal(err)
}
if err := regularUserClient.FlushHistory(); err != nil {
t.Fatal(err)
}
readEntries, err := regularUserClient.Entries(&miniflux.Filter{Status: miniflux.EntryStatusRead})
if err != nil {
t.Fatal(err)
}
if readEntries.Total != 0 {
t.Fatalf(`Invalid total, got %d`, readEntries.Total)
}
}
func TestImportFeedEntryEndpoint(t *testing.T) {
testConfig := newIntegrationTestConfig()
if !testConfig.isConfigured() {
t.Skip(skipIntegrationTestsMessage)
}
client := miniflux.NewClient(
testConfig.testBaseURL,
testConfig.testAdminUsername,
testConfig.testAdminPassword,
)
// Create a feed
feedID, err := client.CreateFeed(&miniflux.FeedCreationRequest{
FeedURL: testConfig.testFeedURL,
})
if err != nil {
t.Fatal(err)
}
defer client.DeleteFeed(feedID)
payload := map[string]any{
"title": "Imported Entry",
"url": "https://example.org/imported-entry",
"content": "Hello world",
"external_id": "integration-test-entry-1",
"status": model.EntryStatusUnread,
"starred": false,
"published_at": 0,
}
// First import
firstID, err := client.ImportFeedEntry(feedID, payload)
if err != nil {
t.Fatal(err)
}
if firstID == 0 {
t.Fatal("expected non-zero entry ID on first import")
}
// Second import (same payload)
secondID, err := client.ImportFeedEntry(feedID, payload)
if err != nil {
t.Fatal(err)
}
if secondID != firstID {
t.Fatalf("expected same entry ID on re-import, got %d and %d", firstID, secondID)
}
}
v2-2.2.16/internal/api/api_key.go 0000664 0000000 0000000 00000003205 15127074645 0016501 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package api // import "miniflux.app/v2/internal/api"
import (
json_parser "encoding/json"
"errors"
"net/http"
"miniflux.app/v2/internal/http/request"
"miniflux.app/v2/internal/http/response/json"
"miniflux.app/v2/internal/model"
"miniflux.app/v2/internal/storage"
"miniflux.app/v2/internal/validator"
)
func (h *handler) createAPIKey(w http.ResponseWriter, r *http.Request) {
userID := request.UserID(r)
var apiKeyCreationRequest model.APIKeyCreationRequest
if err := json_parser.NewDecoder(r.Body).Decode(&apiKeyCreationRequest); err != nil {
json.BadRequest(w, r, err)
return
}
if validationErr := validator.ValidateAPIKeyCreation(h.store, userID, &apiKeyCreationRequest); validationErr != nil {
json.BadRequest(w, r, validationErr.Error())
return
}
apiKey, err := h.store.CreateAPIKey(userID, apiKeyCreationRequest.Description)
if err != nil {
json.ServerError(w, r, err)
return
}
json.Created(w, r, apiKey)
}
func (h *handler) getAPIKeys(w http.ResponseWriter, r *http.Request) {
userID := request.UserID(r)
apiKeys, err := h.store.APIKeys(userID)
if err != nil {
json.ServerError(w, r, err)
return
}
json.OK(w, r, apiKeys)
}
func (h *handler) deleteAPIKey(w http.ResponseWriter, r *http.Request) {
userID := request.UserID(r)
apiKeyID := request.RouteInt64Param(r, "apiKeyID")
if err := h.store.DeleteAPIKey(userID, apiKeyID); err != nil {
if errors.Is(err, storage.ErrAPIKeyNotFound) {
json.NotFound(w, r)
return
}
json.ServerError(w, r, err)
return
}
json.NoContent(w, r)
}
v2-2.2.16/internal/api/category.go 0000664 0000000 0000000 00000010110 15127074645 0016666 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package api // import "miniflux.app/v2/internal/api"
import (
json_parser "encoding/json"
"log/slog"
"net/http"
"time"
"miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/http/request"
"miniflux.app/v2/internal/http/response/json"
"miniflux.app/v2/internal/model"
"miniflux.app/v2/internal/validator"
)
func (h *handler) createCategory(w http.ResponseWriter, r *http.Request) {
userID := request.UserID(r)
var categoryCreationRequest model.CategoryCreationRequest
if err := json_parser.NewDecoder(r.Body).Decode(&categoryCreationRequest); err != nil {
json.BadRequest(w, r, err)
return
}
if validationErr := validator.ValidateCategoryCreation(h.store, userID, &categoryCreationRequest); validationErr != nil {
json.BadRequest(w, r, validationErr.Error())
return
}
category, err := h.store.CreateCategory(userID, &categoryCreationRequest)
if err != nil {
json.ServerError(w, r, err)
return
}
json.Created(w, r, category)
}
func (h *handler) updateCategory(w http.ResponseWriter, r *http.Request) {
userID := request.UserID(r)
categoryID := request.RouteInt64Param(r, "categoryID")
category, err := h.store.Category(userID, categoryID)
if err != nil {
json.ServerError(w, r, err)
return
}
if category == nil {
json.NotFound(w, r)
return
}
var categoryModificationRequest model.CategoryModificationRequest
if err := json_parser.NewDecoder(r.Body).Decode(&categoryModificationRequest); err != nil {
json.BadRequest(w, r, err)
return
}
if validationErr := validator.ValidateCategoryModification(h.store, userID, category.ID, &categoryModificationRequest); validationErr != nil {
json.BadRequest(w, r, validationErr.Error())
return
}
categoryModificationRequest.Patch(category)
if err := h.store.UpdateCategory(category); err != nil {
json.ServerError(w, r, err)
return
}
json.Created(w, r, category)
}
func (h *handler) markCategoryAsRead(w http.ResponseWriter, r *http.Request) {
userID := request.UserID(r)
categoryID := request.RouteInt64Param(r, "categoryID")
category, err := h.store.Category(userID, categoryID)
if err != nil {
json.ServerError(w, r, err)
return
}
if category == nil {
json.NotFound(w, r)
return
}
if err = h.store.MarkCategoryAsRead(userID, categoryID, time.Now()); err != nil {
json.ServerError(w, r, err)
return
}
json.NoContent(w, r)
}
func (h *handler) getCategories(w http.ResponseWriter, r *http.Request) {
var categories model.Categories
var err error
includeCounts := request.QueryStringParam(r, "counts", "false")
if includeCounts == "true" {
categories, err = h.store.CategoriesWithFeedCount(request.UserID(r))
} else {
categories, err = h.store.Categories(request.UserID(r))
}
if err != nil {
json.ServerError(w, r, err)
return
}
json.OK(w, r, categories)
}
func (h *handler) removeCategory(w http.ResponseWriter, r *http.Request) {
userID := request.UserID(r)
categoryID := request.RouteInt64Param(r, "categoryID")
if !h.store.CategoryIDExists(userID, categoryID) {
json.NotFound(w, r)
return
}
if err := h.store.RemoveCategory(userID, categoryID); err != nil {
json.ServerError(w, r, err)
return
}
json.NoContent(w, r)
}
func (h *handler) refreshCategory(w http.ResponseWriter, r *http.Request) {
userID := request.UserID(r)
categoryID := request.RouteInt64Param(r, "categoryID")
batchBuilder := h.store.NewBatchBuilder()
batchBuilder.WithErrorLimit(config.Opts.PollingParsingErrorLimit())
batchBuilder.WithoutDisabledFeeds()
batchBuilder.WithUserID(userID)
batchBuilder.WithCategoryID(categoryID)
batchBuilder.WithNextCheckExpired()
batchBuilder.WithLimitPerHost(config.Opts.PollingLimitPerHost())
jobs, err := batchBuilder.FetchJobs()
if err != nil {
json.ServerError(w, r, err)
return
}
slog.Info(
"Triggered a manual refresh of all feeds for a given category from the API",
slog.Int64("user_id", userID),
slog.Int64("category_id", categoryID),
slog.Int("nb_jobs", len(jobs)),
)
go h.pool.Push(jobs)
json.NoContent(w, r)
}
v2-2.2.16/internal/api/enclosure.go 0000664 0000000 0000000 00000003543 15127074645 0017064 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package api // import "miniflux.app/v2/internal/api"
import (
json_parser "encoding/json"
"net/http"
"miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/http/request"
"miniflux.app/v2/internal/http/response/json"
"miniflux.app/v2/internal/model"
"miniflux.app/v2/internal/validator"
)
func (h *handler) getEnclosureByID(w http.ResponseWriter, r *http.Request) {
enclosureID := request.RouteInt64Param(r, "enclosureID")
enclosure, err := h.store.GetEnclosure(enclosureID)
if err != nil {
json.ServerError(w, r, err)
return
}
if enclosure == nil {
json.NotFound(w, r)
return
}
userID := request.UserID(r)
if enclosure.UserID != userID {
json.NotFound(w, r)
return
}
enclosure.ProxifyEnclosureURL(h.router, config.Opts.MediaProxyMode(), config.Opts.MediaProxyResourceTypes())
json.OK(w, r, enclosure)
}
func (h *handler) updateEnclosureByID(w http.ResponseWriter, r *http.Request) {
enclosureID := request.RouteInt64Param(r, "enclosureID")
var enclosureUpdateRequest model.EnclosureUpdateRequest
if err := json_parser.NewDecoder(r.Body).Decode(&enclosureUpdateRequest); err != nil {
json.BadRequest(w, r, err)
return
}
if err := validator.ValidateEnclosureUpdateRequest(&enclosureUpdateRequest); err != nil {
json.BadRequest(w, r, err)
return
}
enclosure, err := h.store.GetEnclosure(enclosureID)
if err != nil {
json.ServerError(w, r, err)
return
}
if enclosure == nil {
json.NotFound(w, r)
return
}
userID := request.UserID(r)
if enclosure.UserID != userID {
json.NotFound(w, r)
return
}
enclosure.MediaProgression = enclosureUpdateRequest.MediaProgression
if err := h.store.UpdateEnclosure(enclosure); err != nil {
json.ServerError(w, r, err)
return
}
json.NoContent(w, r)
}
v2-2.2.16/internal/api/entry.go 0000664 0000000 0000000 00000032773 15127074645 0016235 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package api // import "miniflux.app/v2/internal/api"
import (
json_parser "encoding/json"
"errors"
"net/http"
"strconv"
"time"
"miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/crypto"
"miniflux.app/v2/internal/http/request"
"miniflux.app/v2/internal/http/response/json"
"miniflux.app/v2/internal/integration"
"miniflux.app/v2/internal/mediaproxy"
"miniflux.app/v2/internal/model"
"miniflux.app/v2/internal/reader/processor"
"miniflux.app/v2/internal/reader/readingtime"
"miniflux.app/v2/internal/reader/sanitizer"
"miniflux.app/v2/internal/storage"
"miniflux.app/v2/internal/validator"
)
func (h *handler) getEntryFromBuilder(w http.ResponseWriter, r *http.Request, b *storage.EntryQueryBuilder) {
entry, err := b.GetEntry()
if err != nil {
json.ServerError(w, r, err)
return
}
if entry == nil {
json.NotFound(w, r)
return
}
entry.Content = mediaproxy.RewriteDocumentWithAbsoluteProxyURL(h.router, entry.Content)
entry.Enclosures.ProxifyEnclosureURL(h.router, config.Opts.MediaProxyMode(), config.Opts.MediaProxyResourceTypes())
json.OK(w, r, entry)
}
func (h *handler) getFeedEntry(w http.ResponseWriter, r *http.Request) {
feedID := request.RouteInt64Param(r, "feedID")
entryID := request.RouteInt64Param(r, "entryID")
builder := h.store.NewEntryQueryBuilder(request.UserID(r))
builder.WithFeedID(feedID)
builder.WithEntryID(entryID)
builder.WithoutStatus(model.EntryStatusRemoved)
h.getEntryFromBuilder(w, r, builder)
}
func (h *handler) getCategoryEntry(w http.ResponseWriter, r *http.Request) {
categoryID := request.RouteInt64Param(r, "categoryID")
entryID := request.RouteInt64Param(r, "entryID")
builder := h.store.NewEntryQueryBuilder(request.UserID(r))
builder.WithCategoryID(categoryID)
builder.WithEntryID(entryID)
builder.WithoutStatus(model.EntryStatusRemoved)
h.getEntryFromBuilder(w, r, builder)
}
func (h *handler) getEntry(w http.ResponseWriter, r *http.Request) {
entryID := request.RouteInt64Param(r, "entryID")
builder := h.store.NewEntryQueryBuilder(request.UserID(r))
builder.WithEntryID(entryID)
builder.WithoutStatus(model.EntryStatusRemoved)
h.getEntryFromBuilder(w, r, builder)
}
func (h *handler) getFeedEntries(w http.ResponseWriter, r *http.Request) {
feedID := request.RouteInt64Param(r, "feedID")
h.findEntries(w, r, feedID, 0)
}
func (h *handler) getCategoryEntries(w http.ResponseWriter, r *http.Request) {
categoryID := request.RouteInt64Param(r, "categoryID")
h.findEntries(w, r, 0, categoryID)
}
func (h *handler) getEntries(w http.ResponseWriter, r *http.Request) {
h.findEntries(w, r, 0, 0)
}
func (h *handler) findEntries(w http.ResponseWriter, r *http.Request, feedID int64, categoryID int64) {
statuses := request.QueryStringParamList(r, "status")
for _, status := range statuses {
if err := validator.ValidateEntryStatus(status); err != nil {
json.BadRequest(w, r, err)
return
}
}
order := request.QueryStringParam(r, "order", model.DefaultSortingOrder)
if err := validator.ValidateEntryOrder(order); err != nil {
json.BadRequest(w, r, err)
return
}
direction := request.QueryStringParam(r, "direction", model.DefaultSortingDirection)
if err := validator.ValidateDirection(direction); err != nil {
json.BadRequest(w, r, err)
return
}
limit := request.QueryIntParam(r, "limit", 100)
offset := request.QueryIntParam(r, "offset", 0)
if err := validator.ValidateRange(offset, limit); err != nil {
json.BadRequest(w, r, err)
return
}
userID := request.UserID(r)
categoryID = request.QueryInt64Param(r, "category_id", categoryID)
if categoryID > 0 && !h.store.CategoryIDExists(userID, categoryID) {
json.BadRequest(w, r, errors.New("invalid category ID"))
return
}
feedID = request.QueryInt64Param(r, "feed_id", feedID)
if feedID > 0 && !h.store.FeedExists(userID, feedID) {
json.BadRequest(w, r, errors.New("invalid feed ID"))
return
}
tags := request.QueryStringParamList(r, "tags")
builder := h.store.NewEntryQueryBuilder(userID)
builder.WithFeedID(feedID)
builder.WithCategoryID(categoryID)
builder.WithStatuses(statuses)
builder.WithSorting(order, direction)
builder.WithOffset(offset)
builder.WithLimit(limit)
builder.WithTags(tags)
builder.WithEnclosures()
builder.WithoutStatus(model.EntryStatusRemoved)
if request.HasQueryParam(r, "globally_visible") {
globallyVisible := request.QueryBoolParam(r, "globally_visible", true)
if globallyVisible {
builder.WithGloballyVisible()
}
}
configureFilters(builder, r)
entries, err := builder.GetEntries()
if err != nil {
json.ServerError(w, r, err)
return
}
count, err := builder.CountEntries()
if err != nil {
json.ServerError(w, r, err)
return
}
for i := range entries {
entries[i].Content = mediaproxy.RewriteDocumentWithAbsoluteProxyURL(h.router, entries[i].Content)
}
json.OK(w, r, &entriesResponse{Total: count, Entries: entries})
}
func (h *handler) setEntryStatus(w http.ResponseWriter, r *http.Request) {
var entriesStatusUpdateRequest model.EntriesStatusUpdateRequest
if err := json_parser.NewDecoder(r.Body).Decode(&entriesStatusUpdateRequest); err != nil {
json.BadRequest(w, r, err)
return
}
if err := validator.ValidateEntriesStatusUpdateRequest(&entriesStatusUpdateRequest); err != nil {
json.BadRequest(w, r, err)
return
}
if err := h.store.SetEntriesStatus(request.UserID(r), entriesStatusUpdateRequest.EntryIDs, entriesStatusUpdateRequest.Status); err != nil {
json.ServerError(w, r, err)
return
}
json.NoContent(w, r)
}
func (h *handler) toggleStarred(w http.ResponseWriter, r *http.Request) {
entryID := request.RouteInt64Param(r, "entryID")
if err := h.store.ToggleStarred(request.UserID(r), entryID); err != nil {
json.ServerError(w, r, err)
return
}
json.NoContent(w, r)
}
func (h *handler) saveEntry(w http.ResponseWriter, r *http.Request) {
entryID := request.RouteInt64Param(r, "entryID")
builder := h.store.NewEntryQueryBuilder(request.UserID(r))
builder.WithEntryID(entryID)
builder.WithoutStatus(model.EntryStatusRemoved)
if !h.store.HasSaveEntry(request.UserID(r)) {
json.BadRequest(w, r, errors.New("no third-party integration enabled"))
return
}
entry, err := builder.GetEntry()
if err != nil {
json.ServerError(w, r, err)
return
}
if entry == nil {
json.NotFound(w, r)
return
}
settings, err := h.store.Integration(request.UserID(r))
if err != nil {
json.ServerError(w, r, err)
return
}
go integration.SendEntry(entry, settings)
json.Accepted(w, r)
}
func (h *handler) updateEntry(w http.ResponseWriter, r *http.Request) {
var entryUpdateRequest model.EntryUpdateRequest
if err := json_parser.NewDecoder(r.Body).Decode(&entryUpdateRequest); err != nil {
json.BadRequest(w, r, err)
return
}
if err := validator.ValidateEntryModification(&entryUpdateRequest); err != nil {
json.BadRequest(w, r, err)
return
}
loggedUserID := request.UserID(r)
entryID := request.RouteInt64Param(r, "entryID")
entryBuilder := h.store.NewEntryQueryBuilder(loggedUserID)
entryBuilder.WithEntryID(entryID)
entryBuilder.WithoutStatus(model.EntryStatusRemoved)
entry, err := entryBuilder.GetEntry()
if err != nil {
json.ServerError(w, r, err)
return
}
if entry == nil {
json.NotFound(w, r)
return
}
user, err := h.store.UserByID(loggedUserID)
if err != nil {
json.ServerError(w, r, err)
return
}
if user == nil {
json.NotFound(w, r)
return
}
if entryUpdateRequest.Content != nil {
sanitizedContent := sanitizer.SanitizeHTML(entry.URL, *entryUpdateRequest.Content, &sanitizer.SanitizerOptions{OpenLinksInNewTab: user.OpenExternalLinksInNewTab})
entryUpdateRequest.Content = &sanitizedContent
}
entryUpdateRequest.Patch(entry)
if user.ShowReadingTime {
entry.ReadingTime = readingtime.EstimateReadingTime(entry.Content, user.DefaultReadingSpeed, user.CJKReadingSpeed)
}
if err := h.store.UpdateEntryTitleAndContent(entry); err != nil {
json.ServerError(w, r, err)
return
}
json.Created(w, r, entry)
}
func (h *handler) importFeedEntry(w http.ResponseWriter, r *http.Request) {
userID := request.UserID(r)
feedID := request.RouteInt64Param(r, "feedID")
if feedID <= 0 {
json.BadRequest(w, r, errors.New("invalid feed ID"))
return
}
if !h.store.FeedExists(userID, feedID) {
json.BadRequest(w, r, errors.New("feed does not exist"))
return
}
var req EntryImportRequest
if err := json_parser.NewDecoder(r.Body).Decode(&req); err != nil {
json.BadRequest(w, r, err)
return
}
if req.URL == "" {
json.BadRequest(w, r, errors.New("url is required"))
return
}
if req.Status == "" {
req.Status = model.EntryStatusRead
}
if err := validator.ValidateEntryStatus(req.Status); err != nil {
json.BadRequest(w, r, err)
return
}
entry := model.NewEntry()
entry.URL = req.URL
entry.CommentsURL = req.CommentsURL
entry.Author = req.Author
entry.Tags = req.Tags
if req.PublishedAt > 0 {
entry.Date = time.Unix(req.PublishedAt, 0).UTC()
} else {
entry.Date = time.Now().UTC()
}
if req.Title == "" {
entry.Title = entry.URL
} else {
entry.Title = req.Title
}
hashInput := req.ExternalID
if hashInput == "" {
hashInput = req.URL
}
entry.Hash = crypto.HashFromBytes([]byte(hashInput))
user, err := h.store.UserByID(userID)
if err != nil {
json.ServerError(w, r, err)
return
}
if user == nil {
json.NotFound(w, r)
return
}
if req.Content != "" {
entry.Content = sanitizer.SanitizeHTML(entry.URL, req.Content, &sanitizer.SanitizerOptions{OpenLinksInNewTab: user.OpenExternalLinksInNewTab})
}
if user.ShowReadingTime {
entry.ReadingTime = readingtime.EstimateReadingTime(entry.Content, user.DefaultReadingSpeed, user.CJKReadingSpeed)
}
created, err := h.store.InsertEntryForFeed(userID, feedID, entry)
if err != nil {
json.ServerError(w, r, err)
return
}
if err := h.store.SetEntriesStatus(userID, []int64{entry.ID}, req.Status); err != nil {
json.ServerError(w, r, err)
return
}
entry.Status = req.Status
if req.Starred {
if err := h.store.SetEntriesStarredState(userID, []int64{entry.ID}, true); err != nil {
json.ServerError(w, r, err)
return
}
entry.Starred = true
}
if created {
json.Created(w, r, map[string]int64{"id": entry.ID})
} else {
json.OK(w, r, map[string]int64{"id": entry.ID})
}
}
func (h *handler) fetchContent(w http.ResponseWriter, r *http.Request) {
loggedUserID := request.UserID(r)
entryID := request.RouteInt64Param(r, "entryID")
entryBuilder := h.store.NewEntryQueryBuilder(loggedUserID)
entryBuilder.WithEntryID(entryID)
entryBuilder.WithoutStatus(model.EntryStatusRemoved)
entry, err := entryBuilder.GetEntry()
if err != nil {
json.ServerError(w, r, err)
return
}
if entry == nil {
json.NotFound(w, r)
return
}
user, err := h.store.UserByID(loggedUserID)
if err != nil {
json.ServerError(w, r, err)
return
}
if user == nil {
json.NotFound(w, r)
return
}
feedBuilder := storage.NewFeedQueryBuilder(h.store, loggedUserID)
feedBuilder.WithFeedID(entry.FeedID)
feed, err := feedBuilder.GetFeed()
if err != nil {
json.ServerError(w, r, err)
return
}
if feed == nil {
json.NotFound(w, r)
return
}
if err := processor.ProcessEntryWebPage(feed, entry, user); err != nil {
json.ServerError(w, r, err)
return
}
shouldUpdateContent := request.QueryBoolParam(r, "update_content", false)
if shouldUpdateContent {
if err := h.store.UpdateEntryTitleAndContent(entry); err != nil {
json.ServerError(w, r, err)
return
}
}
json.OK(w, r, map[string]any{"content": mediaproxy.RewriteDocumentWithAbsoluteProxyURL(h.router, entry.Content), "reading_time": entry.ReadingTime})
}
func (h *handler) flushHistory(w http.ResponseWriter, r *http.Request) {
loggedUserID := request.UserID(r)
go h.store.FlushHistory(loggedUserID)
json.Accepted(w, r)
}
func configureFilters(builder *storage.EntryQueryBuilder, r *http.Request) {
if beforeEntryID := request.QueryInt64Param(r, "before_entry_id", 0); beforeEntryID > 0 {
builder.BeforeEntryID(beforeEntryID)
}
if afterEntryID := request.QueryInt64Param(r, "after_entry_id", 0); afterEntryID > 0 {
builder.AfterEntryID(afterEntryID)
}
if beforePublishedTimestamp := request.QueryInt64Param(r, "before", 0); beforePublishedTimestamp > 0 {
builder.BeforePublishedDate(time.Unix(beforePublishedTimestamp, 0))
}
if afterPublishedTimestamp := request.QueryInt64Param(r, "after", 0); afterPublishedTimestamp > 0 {
builder.AfterPublishedDate(time.Unix(afterPublishedTimestamp, 0))
}
if beforePublishedTimestamp := request.QueryInt64Param(r, "published_before", 0); beforePublishedTimestamp > 0 {
builder.BeforePublishedDate(time.Unix(beforePublishedTimestamp, 0))
}
if afterPublishedTimestamp := request.QueryInt64Param(r, "published_after", 0); afterPublishedTimestamp > 0 {
builder.AfterPublishedDate(time.Unix(afterPublishedTimestamp, 0))
}
if beforeChangedTimestamp := request.QueryInt64Param(r, "changed_before", 0); beforeChangedTimestamp > 0 {
builder.BeforeChangedDate(time.Unix(beforeChangedTimestamp, 0))
}
if afterChangedTimestamp := request.QueryInt64Param(r, "changed_after", 0); afterChangedTimestamp > 0 {
builder.AfterChangedDate(time.Unix(afterChangedTimestamp, 0))
}
if categoryID := request.QueryInt64Param(r, "category_id", 0); categoryID > 0 {
builder.WithCategoryID(categoryID)
}
if request.HasQueryParam(r, "starred") {
starred, err := strconv.ParseBool(r.URL.Query().Get("starred"))
if err == nil {
builder.WithStarred(starred)
}
}
if searchQuery := request.QueryStringParam(r, "search", ""); searchQuery != "" {
builder.WithSearchQuery(searchQuery)
}
}
v2-2.2.16/internal/api/feed.go 0000664 0000000 0000000 00000012672 15127074645 0015773 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package api // import "miniflux.app/v2/internal/api"
import (
json_parser "encoding/json"
"log/slog"
"net/http"
"time"
"miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/http/request"
"miniflux.app/v2/internal/http/response/json"
"miniflux.app/v2/internal/model"
feedHandler "miniflux.app/v2/internal/reader/handler"
"miniflux.app/v2/internal/validator"
)
func (h *handler) createFeed(w http.ResponseWriter, r *http.Request) {
userID := request.UserID(r)
var feedCreationRequest model.FeedCreationRequest
if err := json_parser.NewDecoder(r.Body).Decode(&feedCreationRequest); err != nil {
json.BadRequest(w, r, err)
return
}
// Make the feed category optional for clients who don't support categories.
if feedCreationRequest.CategoryID == 0 {
category, err := h.store.FirstCategory(userID)
if err != nil {
json.ServerError(w, r, err)
return
}
feedCreationRequest.CategoryID = category.ID
}
if validationErr := validator.ValidateFeedCreation(h.store, userID, &feedCreationRequest); validationErr != nil {
json.BadRequest(w, r, validationErr.Error())
return
}
feed, localizedError := feedHandler.CreateFeed(h.store, userID, &feedCreationRequest)
if localizedError != nil {
json.ServerError(w, r, localizedError.Error())
return
}
json.Created(w, r, &feedCreationResponse{FeedID: feed.ID})
}
func (h *handler) refreshFeed(w http.ResponseWriter, r *http.Request) {
feedID := request.RouteInt64Param(r, "feedID")
userID := request.UserID(r)
if !h.store.FeedExists(userID, feedID) {
json.NotFound(w, r)
return
}
localizedError := feedHandler.RefreshFeed(h.store, userID, feedID, false)
if localizedError != nil {
json.ServerError(w, r, localizedError.Error())
return
}
json.NoContent(w, r)
}
func (h *handler) refreshAllFeeds(w http.ResponseWriter, r *http.Request) {
userID := request.UserID(r)
batchBuilder := h.store.NewBatchBuilder()
batchBuilder.WithErrorLimit(config.Opts.PollingParsingErrorLimit())
batchBuilder.WithoutDisabledFeeds()
batchBuilder.WithNextCheckExpired()
batchBuilder.WithUserID(userID)
batchBuilder.WithLimitPerHost(config.Opts.PollingLimitPerHost())
jobs, err := batchBuilder.FetchJobs()
if err != nil {
json.ServerError(w, r, err)
return
}
slog.Info(
"Triggered a manual refresh of all feeds from the API",
slog.Int64("user_id", userID),
slog.Int("nb_jobs", len(jobs)),
)
go h.pool.Push(jobs)
json.NoContent(w, r)
}
func (h *handler) updateFeed(w http.ResponseWriter, r *http.Request) {
var feedModificationRequest model.FeedModificationRequest
if err := json_parser.NewDecoder(r.Body).Decode(&feedModificationRequest); err != nil {
json.BadRequest(w, r, err)
return
}
userID := request.UserID(r)
feedID := request.RouteInt64Param(r, "feedID")
originalFeed, err := h.store.FeedByID(userID, feedID)
if err != nil {
json.NotFound(w, r)
return
}
if originalFeed == nil {
json.NotFound(w, r)
return
}
if validationErr := validator.ValidateFeedModification(h.store, userID, originalFeed.ID, &feedModificationRequest); validationErr != nil {
json.BadRequest(w, r, validationErr.Error())
return
}
feedModificationRequest.Patch(originalFeed)
originalFeed.ResetErrorCounter()
if err := h.store.UpdateFeed(originalFeed); err != nil {
json.ServerError(w, r, err)
return
}
originalFeed, err = h.store.FeedByID(userID, feedID)
if err != nil {
json.ServerError(w, r, err)
return
}
json.Created(w, r, originalFeed)
}
func (h *handler) markFeedAsRead(w http.ResponseWriter, r *http.Request) {
feedID := request.RouteInt64Param(r, "feedID")
userID := request.UserID(r)
if !h.store.FeedExists(userID, feedID) {
json.NotFound(w, r)
return
}
if err := h.store.MarkFeedAsRead(userID, feedID, time.Now()); err != nil {
json.ServerError(w, r, err)
return
}
json.NoContent(w, r)
}
func (h *handler) getCategoryFeeds(w http.ResponseWriter, r *http.Request) {
userID := request.UserID(r)
categoryID := request.RouteInt64Param(r, "categoryID")
category, err := h.store.Category(userID, categoryID)
if err != nil {
json.ServerError(w, r, err)
return
}
if category == nil {
json.NotFound(w, r)
return
}
feeds, err := h.store.FeedsByCategoryWithCounters(userID, categoryID)
if err != nil {
json.ServerError(w, r, err)
return
}
json.OK(w, r, feeds)
}
func (h *handler) getFeeds(w http.ResponseWriter, r *http.Request) {
feeds, err := h.store.Feeds(request.UserID(r))
if err != nil {
json.ServerError(w, r, err)
return
}
json.OK(w, r, feeds)
}
func (h *handler) fetchCounters(w http.ResponseWriter, r *http.Request) {
counters, err := h.store.FetchCounters(request.UserID(r))
if err != nil {
json.ServerError(w, r, err)
return
}
json.OK(w, r, counters)
}
func (h *handler) getFeed(w http.ResponseWriter, r *http.Request) {
feedID := request.RouteInt64Param(r, "feedID")
feed, err := h.store.FeedByID(request.UserID(r), feedID)
if err != nil {
json.ServerError(w, r, err)
return
}
if feed == nil {
json.NotFound(w, r)
return
}
json.OK(w, r, feed)
}
func (h *handler) removeFeed(w http.ResponseWriter, r *http.Request) {
feedID := request.RouteInt64Param(r, "feedID")
userID := request.UserID(r)
if !h.store.FeedExists(userID, feedID) {
json.NotFound(w, r)
return
}
if err := h.store.RemoveFeed(userID, feedID); err != nil {
json.ServerError(w, r, err)
return
}
json.NoContent(w, r)
}
v2-2.2.16/internal/api/icon.go 0000664 0000000 0000000 00000002217 15127074645 0016012 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package api // import "miniflux.app/v2/internal/api"
import (
"net/http"
"miniflux.app/v2/internal/http/request"
"miniflux.app/v2/internal/http/response/json"
)
func (h *handler) getIconByFeedID(w http.ResponseWriter, r *http.Request) {
feedID := request.RouteInt64Param(r, "feedID")
if !h.store.HasFeedIcon(feedID) {
json.NotFound(w, r)
return
}
icon, err := h.store.IconByFeedID(request.UserID(r), feedID)
if err != nil {
json.ServerError(w, r, err)
return
}
if icon == nil {
json.NotFound(w, r)
return
}
json.OK(w, r, &feedIconResponse{
ID: icon.ID,
MimeType: icon.MimeType,
Data: icon.DataURL(),
})
}
func (h *handler) getIconByIconID(w http.ResponseWriter, r *http.Request) {
iconID := request.RouteInt64Param(r, "iconID")
icon, err := h.store.IconByID(iconID)
if err != nil {
json.ServerError(w, r, err)
return
}
if icon == nil {
json.NotFound(w, r)
return
}
json.OK(w, r, &feedIconResponse{
ID: icon.ID,
MimeType: icon.MimeType,
Data: icon.DataURL(),
})
}
v2-2.2.16/internal/api/middleware.go 0000664 0000000 0000000 00000012306 15127074645 0017177 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package api // import "miniflux.app/v2/internal/api"
import (
"context"
"log/slog"
"net/http"
"miniflux.app/v2/internal/http/request"
"miniflux.app/v2/internal/http/response/json"
"miniflux.app/v2/internal/storage"
)
type middleware struct {
store *storage.Storage
}
func newMiddleware(s *storage.Storage) *middleware {
return &middleware{s}
}
func (m *middleware) handleCORS(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "X-Auth-Token, Authorization, Content-Type, Accept")
if r.Method == http.MethodOptions {
w.Header().Set("Access-Control-Max-Age", "3600")
w.WriteHeader(http.StatusOK)
return
}
next.ServeHTTP(w, r)
})
}
func (m *middleware) apiKeyAuth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
clientIP := request.ClientIP(r)
token := r.Header.Get("X-Auth-Token")
if token == "" {
slog.Debug("[API] Skipped API token authentication because no API Key has been provided",
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
slog.String("request_uri", r.RequestURI),
)
next.ServeHTTP(w, r)
return
}
user, err := m.store.UserByAPIKey(token)
if err != nil {
json.ServerError(w, r, err)
return
}
if user == nil {
slog.Warn("[API] No user found with the provided API key",
slog.Bool("authentication_failed", true),
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
slog.String("request_uri", r.RequestURI),
)
json.Unauthorized(w, r)
return
}
slog.Info("[API] User authenticated successfully with the API Token Authentication",
slog.Bool("authentication_successful", true),
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
slog.String("username", user.Username),
slog.String("request_uri", r.RequestURI),
)
m.store.SetLastLogin(user.ID)
m.store.SetAPIKeyUsedTimestamp(user.ID, token)
ctx := r.Context()
ctx = context.WithValue(ctx, request.UserIDContextKey, user.ID)
ctx = context.WithValue(ctx, request.UserTimezoneContextKey, user.Timezone)
ctx = context.WithValue(ctx, request.IsAdminUserContextKey, user.IsAdmin)
ctx = context.WithValue(ctx, request.IsAuthenticatedContextKey, true)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
func (m *middleware) basicAuth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if request.IsAuthenticated(r) {
next.ServeHTTP(w, r)
return
}
w.Header().Set("WWW-Authenticate", `Basic realm="Restricted"`)
clientIP := request.ClientIP(r)
username, password, authOK := r.BasicAuth()
if !authOK {
slog.Warn("[API] No Basic HTTP Authentication header sent with the request",
slog.Bool("authentication_failed", true),
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
slog.String("request_uri", r.RequestURI),
)
json.Unauthorized(w, r)
return
}
if username == "" || password == "" {
slog.Warn("[API] Empty username or password provided during Basic HTTP Authentication",
slog.Bool("authentication_failed", true),
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
slog.String("request_uri", r.RequestURI),
)
json.Unauthorized(w, r)
return
}
if err := m.store.CheckPassword(username, password); err != nil {
slog.Warn("[API] Invalid username or password provided during Basic HTTP Authentication",
slog.Bool("authentication_failed", true),
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
slog.String("username", username),
slog.String("request_uri", r.RequestURI),
)
json.Unauthorized(w, r)
return
}
user, err := m.store.UserByUsername(username)
if err != nil {
json.ServerError(w, r, err)
return
}
if user == nil {
slog.Warn("[API] User not found while using Basic HTTP Authentication",
slog.Bool("authentication_failed", true),
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
slog.String("username", username),
slog.String("request_uri", r.RequestURI),
)
json.Unauthorized(w, r)
return
}
slog.Info("[API] User authenticated successfully with the Basic HTTP Authentication",
slog.Bool("authentication_successful", true),
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
slog.String("username", username),
slog.String("request_uri", r.RequestURI),
)
m.store.SetLastLogin(user.ID)
ctx := r.Context()
ctx = context.WithValue(ctx, request.UserIDContextKey, user.ID)
ctx = context.WithValue(ctx, request.UserTimezoneContextKey, user.Timezone)
ctx = context.WithValue(ctx, request.IsAdminUserContextKey, user.IsAdmin)
ctx = context.WithValue(ctx, request.IsAuthenticatedContextKey, true)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
v2-2.2.16/internal/api/opml.go 0000664 0000000 0000000 00000001705 15127074645 0016032 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package api // import "miniflux.app/v2/internal/api"
import (
"net/http"
"miniflux.app/v2/internal/http/request"
"miniflux.app/v2/internal/http/response/json"
"miniflux.app/v2/internal/http/response/xml"
"miniflux.app/v2/internal/reader/opml"
)
func (h *handler) exportFeeds(w http.ResponseWriter, r *http.Request) {
opmlHandler := opml.NewHandler(h.store)
opmlExport, err := opmlHandler.Export(request.UserID(r))
if err != nil {
json.ServerError(w, r, err)
return
}
xml.OK(w, r, opmlExport)
}
func (h *handler) importFeeds(w http.ResponseWriter, r *http.Request) {
opmlHandler := opml.NewHandler(h.store)
err := opmlHandler.Import(request.UserID(r), r.Body)
defer r.Body.Close()
if err != nil {
json.ServerError(w, r, err)
return
}
json.Created(w, r, map[string]string{"message": "Feeds imported successfully"})
}
v2-2.2.16/internal/api/payload.go 0000664 0000000 0000000 00000002472 15127074645 0016516 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package api // import "miniflux.app/v2/internal/api"
import (
"miniflux.app/v2/internal/model"
)
type feedIconResponse struct {
ID int64 `json:"id"`
MimeType string `json:"mime_type"`
Data string `json:"data"`
}
type entriesResponse struct {
Total int `json:"total"`
Entries model.Entries `json:"entries"`
}
// EntryImportRequest represents a manually imported entry for a feed.
type EntryImportRequest struct {
URL string `json:"url"`
Title string `json:"title"`
Content string `json:"content"`
Author string `json:"author"`
CommentsURL string `json:"comments_url"`
PublishedAt int64 `json:"published_at"`
Status string `json:"status"`
Starred bool `json:"starred"`
Tags []string `json:"tags"`
ExternalID string `json:"external_id"`
}
type feedCreationResponse struct {
FeedID int64 `json:"feed_id"`
}
type versionResponse struct {
Version string `json:"version"`
Commit string `json:"commit"`
BuildDate string `json:"build_date"`
GoVersion string `json:"go_version"`
Compiler string `json:"compiler"`
Arch string `json:"arch"`
OS string `json:"os"`
}
v2-2.2.16/internal/api/subscription.go 0000664 0000000 0000000 00000004700 15127074645 0017605 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package api // import "miniflux.app/v2/internal/api"
import (
json_parser "encoding/json"
"net/http"
"miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/http/request"
"miniflux.app/v2/internal/http/response/json"
"miniflux.app/v2/internal/model"
"miniflux.app/v2/internal/proxyrotator"
"miniflux.app/v2/internal/reader/fetcher"
"miniflux.app/v2/internal/reader/subscription"
"miniflux.app/v2/internal/validator"
)
func (h *handler) discoverSubscriptions(w http.ResponseWriter, r *http.Request) {
var subscriptionDiscoveryRequest model.SubscriptionDiscoveryRequest
if err := json_parser.NewDecoder(r.Body).Decode(&subscriptionDiscoveryRequest); err != nil {
json.BadRequest(w, r, err)
return
}
if validationErr := validator.ValidateSubscriptionDiscovery(&subscriptionDiscoveryRequest); validationErr != nil {
json.BadRequest(w, r, validationErr.Error())
return
}
var rssbridgeURL string
var rssbridgeToken string
intg, err := h.store.Integration(request.UserID(r))
if err == nil && intg != nil && intg.RSSBridgeEnabled {
rssbridgeURL = intg.RSSBridgeURL
rssbridgeToken = intg.RSSBridgeToken
}
requestBuilder := fetcher.NewRequestBuilder()
requestBuilder.WithTimeout(config.Opts.HTTPClientTimeout())
requestBuilder.WithProxyRotator(proxyrotator.ProxyRotatorInstance)
requestBuilder.WithCustomFeedProxyURL(subscriptionDiscoveryRequest.ProxyURL)
requestBuilder.WithCustomApplicationProxyURL(config.Opts.HTTPClientProxyURL())
requestBuilder.UseCustomApplicationProxyURL(subscriptionDiscoveryRequest.FetchViaProxy)
requestBuilder.WithUserAgent(subscriptionDiscoveryRequest.UserAgent, config.Opts.HTTPClientUserAgent())
requestBuilder.WithCookie(subscriptionDiscoveryRequest.Cookie)
requestBuilder.WithUsernameAndPassword(subscriptionDiscoveryRequest.Username, subscriptionDiscoveryRequest.Password)
requestBuilder.IgnoreTLSErrors(subscriptionDiscoveryRequest.AllowSelfSignedCertificates)
requestBuilder.DisableHTTP2(subscriptionDiscoveryRequest.DisableHTTP2)
subscriptions, localizedError := subscription.NewSubscriptionFinder(requestBuilder).FindSubscriptions(
subscriptionDiscoveryRequest.URL,
rssbridgeURL,
rssbridgeToken,
)
if localizedError != nil {
json.ServerError(w, r, localizedError.Error())
return
}
if len(subscriptions) == 0 {
json.NotFound(w, r)
return
}
json.OK(w, r, subscriptions)
}
v2-2.2.16/internal/api/user.go 0000664 0000000 0000000 00000011434 15127074645 0016041 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package api // import "miniflux.app/v2/internal/api"
import (
json_parser "encoding/json"
"errors"
"net/http"
"miniflux.app/v2/internal/http/request"
"miniflux.app/v2/internal/http/response/json"
"miniflux.app/v2/internal/model"
"miniflux.app/v2/internal/validator"
)
func (h *handler) currentUser(w http.ResponseWriter, r *http.Request) {
user, err := h.store.UserByID(request.UserID(r))
if err != nil {
json.ServerError(w, r, err)
return
}
json.OK(w, r, user)
}
func (h *handler) createUser(w http.ResponseWriter, r *http.Request) {
if !request.IsAdminUser(r) {
json.Forbidden(w, r)
return
}
var userCreationRequest model.UserCreationRequest
if err := json_parser.NewDecoder(r.Body).Decode(&userCreationRequest); err != nil {
json.BadRequest(w, r, err)
return
}
if validationErr := validator.ValidateUserCreationWithPassword(h.store, &userCreationRequest); validationErr != nil {
json.BadRequest(w, r, validationErr.Error())
return
}
user, err := h.store.CreateUser(&userCreationRequest)
if err != nil {
json.ServerError(w, r, err)
return
}
json.Created(w, r, user)
}
func (h *handler) updateUser(w http.ResponseWriter, r *http.Request) {
userID := request.RouteInt64Param(r, "userID")
var userModificationRequest model.UserModificationRequest
if err := json_parser.NewDecoder(r.Body).Decode(&userModificationRequest); err != nil {
json.BadRequest(w, r, err)
return
}
originalUser, err := h.store.UserByID(userID)
if err != nil {
json.ServerError(w, r, err)
return
}
if originalUser == nil {
json.NotFound(w, r)
return
}
if !request.IsAdminUser(r) {
if originalUser.ID != request.UserID(r) {
json.Forbidden(w, r)
return
}
if userModificationRequest.IsAdmin != nil && *userModificationRequest.IsAdmin {
json.BadRequest(w, r, errors.New("only administrators can change permissions of standard users"))
return
}
}
if validationErr := validator.ValidateUserModification(h.store, originalUser.ID, &userModificationRequest); validationErr != nil {
json.BadRequest(w, r, validationErr.Error())
return
}
userModificationRequest.Patch(originalUser)
if err = h.store.UpdateUser(originalUser); err != nil {
json.ServerError(w, r, err)
return
}
json.Created(w, r, originalUser)
}
func (h *handler) markUserAsRead(w http.ResponseWriter, r *http.Request) {
userID := request.RouteInt64Param(r, "userID")
if userID != request.UserID(r) {
json.Forbidden(w, r)
return
}
if _, err := h.store.UserByID(userID); err != nil {
json.NotFound(w, r)
return
}
if err := h.store.MarkAllAsRead(userID); err != nil {
json.ServerError(w, r, err)
return
}
json.NoContent(w, r)
}
func (h *handler) getIntegrationsStatus(w http.ResponseWriter, r *http.Request) {
userID := request.UserID(r)
if _, err := h.store.UserByID(userID); err != nil {
json.NotFound(w, r)
return
}
hasIntegrations := h.store.HasSaveEntry(userID)
response := struct {
HasIntegrations bool `json:"has_integrations"`
}{
HasIntegrations: hasIntegrations,
}
json.OK(w, r, response)
}
func (h *handler) users(w http.ResponseWriter, r *http.Request) {
if !request.IsAdminUser(r) {
json.Forbidden(w, r)
return
}
users, err := h.store.Users()
if err != nil {
json.ServerError(w, r, err)
return
}
users.UseTimezone(request.UserTimezone(r))
json.OK(w, r, users)
}
func (h *handler) userByID(w http.ResponseWriter, r *http.Request) {
if !request.IsAdminUser(r) {
json.Forbidden(w, r)
return
}
userID := request.RouteInt64Param(r, "userID")
user, err := h.store.UserByID(userID)
if err != nil {
json.BadRequest(w, r, errors.New("unable to fetch this user from the database"))
return
}
if user == nil {
json.NotFound(w, r)
return
}
user.UseTimezone(request.UserTimezone(r))
json.OK(w, r, user)
}
func (h *handler) userByUsername(w http.ResponseWriter, r *http.Request) {
if !request.IsAdminUser(r) {
json.Forbidden(w, r)
return
}
username := request.RouteStringParam(r, "username")
user, err := h.store.UserByUsername(username)
if err != nil {
json.BadRequest(w, r, errors.New("unable to fetch this user from the database"))
return
}
if user == nil {
json.NotFound(w, r)
return
}
json.OK(w, r, user)
}
func (h *handler) removeUser(w http.ResponseWriter, r *http.Request) {
if !request.IsAdminUser(r) {
json.Forbidden(w, r)
return
}
userID := request.RouteInt64Param(r, "userID")
user, err := h.store.UserByID(userID)
if err != nil {
json.ServerError(w, r, err)
return
}
if user == nil {
json.NotFound(w, r)
return
}
if user.ID == request.UserID(r) {
json.BadRequest(w, r, errors.New("you cannot remove yourself"))
return
}
h.store.RemoveUserAsync(user.ID)
json.NoContent(w, r)
}
v2-2.2.16/internal/cli/ 0000775 0000000 0000000 00000000000 15127074645 0014527 5 ustar 00root root 0000000 0000000 v2-2.2.16/internal/cli/ask_credentials.go 0000664 0000000 0000000 00000001405 15127074645 0020211 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package cli // import "miniflux.app/v2/internal/cli"
import (
"bufio"
"errors"
"fmt"
"os"
"strings"
"golang.org/x/term"
)
func askCredentials() (string, string) {
fd := int(os.Stdin.Fd())
if !term.IsTerminal(fd) {
printErrorAndExit(errors.New("this is not an interactive terminal, exiting"))
}
fmt.Print("Enter Username: ")
reader := bufio.NewReader(os.Stdin)
username, _ := reader.ReadString('\n')
fmt.Print("Enter Password: ")
state, _ := term.GetState(fd)
defer term.Restore(fd, state)
bytePassword, _ := term.ReadPassword(fd)
fmt.Print("\n")
return strings.TrimSpace(username), strings.TrimSpace(string(bytePassword))
}
v2-2.2.16/internal/cli/cleanup_tasks.go 0000664 0000000 0000000 00000004665 15127074645 0017725 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package cli // import "miniflux.app/v2/internal/cli"
import (
"log/slog"
"time"
"miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/metric"
"miniflux.app/v2/internal/model"
"miniflux.app/v2/internal/storage"
)
func runCleanupTasks(store *storage.Storage) {
nbSessions := store.CleanOldSessions(config.Opts.CleanupRemoveSessionsInterval())
nbUserSessions := store.CleanOldUserSessions(config.Opts.CleanupRemoveSessionsInterval())
slog.Info("Sessions cleanup completed",
slog.Int64("application_sessions_removed", nbSessions),
slog.Int64("user_sessions_removed", nbUserSessions),
)
startTime := time.Now()
if rowsAffected, err := store.ArchiveEntries(model.EntryStatusRead, config.Opts.CleanupArchiveReadInterval(), config.Opts.CleanupArchiveBatchSize()); err != nil {
slog.Error("Unable to archive read entries", slog.Any("error", err))
} else {
slog.Info("Archiving read entries completed",
slog.Int64("read_entries_archived", rowsAffected),
)
if config.Opts.HasMetricsCollector() {
metric.ArchiveEntriesDuration.WithLabelValues(model.EntryStatusRead).Observe(time.Since(startTime).Seconds())
}
}
startTime = time.Now()
if rowsAffected, err := store.ArchiveEntries(model.EntryStatusUnread, config.Opts.CleanupArchiveUnreadInterval(), config.Opts.CleanupArchiveBatchSize()); err != nil {
slog.Error("Unable to archive unread entries", slog.Any("error", err))
} else {
slog.Info("Archiving unread entries completed",
slog.Int64("unread_entries_archived", rowsAffected),
)
if config.Opts.HasMetricsCollector() {
metric.ArchiveEntriesDuration.WithLabelValues(model.EntryStatusUnread).Observe(time.Since(startTime).Seconds())
}
}
if enclosuresAffected, err := store.DeleteRemovedEntriesEnclosures(); err != nil {
slog.Error("Unable to delete enclosures from removed entries", slog.Any("error", err))
} else {
slog.Info("Deleting enclosures from removed entries completed",
slog.Int64("removed_entries_enclosures_deleted", enclosuresAffected))
}
if contentAffected, err := store.ClearRemovedEntriesContent(config.Opts.CleanupArchiveBatchSize()); err != nil {
slog.Error("Unable to clear content from removed entries", slog.Any("error", err))
} else {
slog.Info("Clearing content from removed entries completed",
slog.Int64("removed_entries_content_cleared", contentAffected))
}
}
v2-2.2.16/internal/cli/cli.go 0000664 0000000 0000000 00000020254 15127074645 0015630 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package cli // import "miniflux.app/v2/internal/cli"
import (
"errors"
"flag"
"fmt"
"io"
"log/slog"
"os"
"miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/database"
"miniflux.app/v2/internal/proxyrotator"
"miniflux.app/v2/internal/storage"
"miniflux.app/v2/internal/ui/static"
"miniflux.app/v2/internal/version"
)
const (
flagInfoHelp = "Show build information"
flagVersionHelp = "Show application version"
flagMigrateHelp = "Run SQL migrations"
flagFlushSessionsHelp = "Flush all sessions (disconnect users)"
flagCreateAdminHelp = "Create an admin user from an interactive terminal"
flagResetPasswordHelp = "Reset user password"
flagResetFeedErrorsHelp = "Clear all feed errors for all users"
flagDebugModeHelp = "Show debug logs"
flagConfigFileHelp = "Load configuration file"
flagConfigDumpHelp = "Print parsed configuration values"
flagHealthCheckHelp = `Perform a health check on the given endpoint (the value "auto" tries to guess the health check endpoint).`
flagRefreshFeedsHelp = "Refresh a batch of feeds and exit"
flagRunCleanupTasksHelp = "Run cleanup tasks (delete old sessions and archive old entries)"
flagExportUserFeedsHelp = "Export user feeds (provide the username as argument)"
flagResetNextCheckAtHelp = "Reset the next check time for all feeds"
)
// Parse parses command line arguments.
func Parse() {
var (
err error
flagInfo bool
flagVersion bool
flagMigrate bool
flagFlushSessions bool
flagCreateAdmin bool
flagResetPassword bool
flagResetFeedErrors bool
flagResetFeedNextCheckAt bool
flagDebugMode bool
flagConfigFile string
flagConfigDump bool
flagHealthCheck string
flagRefreshFeeds bool
flagRunCleanupTasks bool
flagExportUserFeeds string
)
flag.BoolVar(&flagInfo, "info", false, flagInfoHelp)
flag.BoolVar(&flagInfo, "i", false, flagInfoHelp)
flag.BoolVar(&flagVersion, "version", false, flagVersionHelp)
flag.BoolVar(&flagVersion, "v", false, flagVersionHelp)
flag.BoolVar(&flagMigrate, "migrate", false, flagMigrateHelp)
flag.BoolVar(&flagFlushSessions, "flush-sessions", false, flagFlushSessionsHelp)
flag.BoolVar(&flagCreateAdmin, "create-admin", false, flagCreateAdminHelp)
flag.BoolVar(&flagResetPassword, "reset-password", false, flagResetPasswordHelp)
flag.BoolVar(&flagResetFeedErrors, "reset-feed-errors", false, flagResetFeedErrorsHelp)
flag.BoolVar(&flagResetFeedNextCheckAt, "reset-feed-next-check-at", false, flagResetNextCheckAtHelp)
flag.BoolVar(&flagDebugMode, "debug", false, flagDebugModeHelp)
flag.StringVar(&flagConfigFile, "config-file", "", flagConfigFileHelp)
flag.StringVar(&flagConfigFile, "c", "", flagConfigFileHelp)
flag.BoolVar(&flagConfigDump, "config-dump", false, flagConfigDumpHelp)
flag.StringVar(&flagHealthCheck, "healthcheck", "", flagHealthCheckHelp)
flag.BoolVar(&flagRefreshFeeds, "refresh-feeds", false, flagRefreshFeedsHelp)
flag.BoolVar(&flagRunCleanupTasks, "run-cleanup-tasks", false, flagRunCleanupTasksHelp)
flag.StringVar(&flagExportUserFeeds, "export-user-feeds", "", flagExportUserFeedsHelp)
flag.Parse()
cfg := config.NewConfigParser()
if flagConfigFile != "" {
config.Opts, err = cfg.ParseFile(flagConfigFile)
if err != nil {
printErrorAndExit(err)
}
}
config.Opts, err = cfg.ParseEnvironmentVariables()
if err != nil {
printErrorAndExit(err)
}
if oauth2Provider := config.Opts.OAuth2Provider(); oauth2Provider != "" {
if oauth2Provider != "oidc" && oauth2Provider != "google" {
printErrorAndExit(fmt.Errorf(`unsupported OAuth2 provider: %q (Possible values are "google" or "oidc")`, oauth2Provider))
}
}
if config.Opts.DisableLocalAuth() {
switch {
case config.Opts.OAuth2Provider() == "" && config.Opts.AuthProxyHeader() == "":
printErrorAndExit(errors.New("DISABLE_LOCAL_AUTH is enabled but neither OAUTH2_PROVIDER nor AUTH_PROXY_HEADER is not set. Please enable at least one authentication source"))
case config.Opts.OAuth2Provider() != "" && !config.Opts.IsOAuth2UserCreationAllowed():
printErrorAndExit(errors.New("DISABLE_LOCAL_AUTH is enabled and an OAUTH2_PROVIDER is configured, but OAUTH2_USER_CREATION is not enabled"))
case config.Opts.AuthProxyHeader() != "" && !config.Opts.IsAuthProxyUserCreationAllowed():
printErrorAndExit(errors.New("DISABLE_LOCAL_AUTH is enabled and an AUTH_PROXY_HEADER is configured, but AUTH_PROXY_USER_CREATION is not enabled"))
}
}
if config.Opts.AuthProxyHeader() != "" {
if len(config.Opts.TrustedReverseProxyNetworks()) == 0 {
printErrorAndExit(errors.New("TRUSTED_REVERSE_PROXY_NETWORKS must be configured when AUTH_PROXY_HEADER is used"))
}
}
if flagConfigDump {
fmt.Print(config.Opts)
return
}
if flagDebugMode {
config.Opts.SetLogLevel("debug")
}
logFile := config.Opts.LogFile()
var logFileHandler io.Writer
switch logFile {
case "stdout":
logFileHandler = os.Stdout
case "stderr":
logFileHandler = os.Stderr
default:
logFileHandler, err = os.OpenFile(logFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0600)
if err != nil {
printErrorAndExit(fmt.Errorf("unable to open log file: %v", err))
}
defer logFileHandler.(*os.File).Close()
}
if err := InitializeDefaultLogger(config.Opts.LogLevel(), logFileHandler, config.Opts.LogFormat(), config.Opts.LogDateTime()); err != nil {
printErrorAndExit(err)
}
if flagHealthCheck != "" {
doHealthCheck(flagHealthCheck)
return
}
if flagInfo {
info()
return
}
if flagVersion {
fmt.Println(version.Version)
return
}
if config.Opts.IsDefaultDatabaseURL() {
slog.Info("The default value for DATABASE_URL is used")
}
if err := static.GenerateBinaryBundles(); err != nil {
printErrorAndExit(fmt.Errorf("unable to generate binary files bundle: %v", err))
}
if err := static.GenerateStylesheetsBundles(); err != nil {
printErrorAndExit(fmt.Errorf("unable to generate stylesheets bundle: %v", err))
}
if err := static.GenerateJavascriptBundles(config.Opts.WebAuthn()); err != nil {
printErrorAndExit(fmt.Errorf("unable to generate javascript bundle: %v", err))
}
db, err := database.NewConnectionPool(
config.Opts.DatabaseURL(),
config.Opts.DatabaseMinConns(),
config.Opts.DatabaseMaxConns(),
config.Opts.DatabaseConnectionLifetime(),
)
if err != nil {
printErrorAndExit(fmt.Errorf("unable to connect to database: %v", err))
}
defer db.Close()
store := storage.NewStorage(db)
if err := store.Ping(); err != nil {
printErrorAndExit(err)
}
if flagMigrate {
if err := database.Migrate(db); err != nil {
printErrorAndExit(err)
}
return
}
if flagResetFeedErrors {
if err := store.ResetFeedErrors(); err != nil {
printErrorAndExit(err)
}
return
}
if flagResetFeedNextCheckAt {
if err := store.ResetNextCheckAt(); err != nil {
printErrorAndExit(err)
}
return
}
if flagExportUserFeeds != "" {
exportUserFeeds(store, flagExportUserFeeds)
return
}
if flagFlushSessions {
flushSessions(store)
return
}
if flagCreateAdmin {
createAdminUserFromInteractiveTerminal(store)
return
}
if flagResetPassword {
resetPassword(store)
return
}
// Run migrations and start the daemon.
if config.Opts.RunMigrations() {
if err := database.Migrate(db); err != nil {
printErrorAndExit(err)
}
}
if err := database.IsSchemaUpToDate(db); err != nil {
printErrorAndExit(err)
}
if config.Opts.CreateAdmin() {
createAdminUserFromEnvironmentVariables(store)
}
if config.Opts.HasHTTPClientProxiesConfigured() {
slog.Info("Initializing proxy rotation", slog.Int("proxies_count", len(config.Opts.HTTPClientProxies())))
proxyrotator.ProxyRotatorInstance, err = proxyrotator.NewProxyRotator(config.Opts.HTTPClientProxies())
if err != nil {
printErrorAndExit(fmt.Errorf("unable to initialize proxy rotator: %v", err))
}
}
if flagRefreshFeeds {
refreshFeeds(store)
return
}
if flagRunCleanupTasks {
runCleanupTasks(store)
return
}
startDaemon(store)
}
func printErrorAndExit(err error) {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
v2-2.2.16/internal/cli/create_admin.go 0000664 0000000 0000000 00000002635 15127074645 0017477 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package cli // import "miniflux.app/v2/internal/cli"
import (
"log/slog"
"miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/model"
"miniflux.app/v2/internal/storage"
"miniflux.app/v2/internal/validator"
)
func createAdminUserFromEnvironmentVariables(store *storage.Storage) {
createAdminUser(store, config.Opts.AdminUsername(), config.Opts.AdminPassword())
}
func createAdminUserFromInteractiveTerminal(store *storage.Storage) {
username, password := askCredentials()
createAdminUser(store, username, password)
}
func createAdminUser(store *storage.Storage, username, password string) {
userCreationRequest := &model.UserCreationRequest{
Username: username,
Password: password,
IsAdmin: true,
}
if store.UserExists(userCreationRequest.Username) {
slog.Info("Skipping admin user creation because it already exists",
slog.String("username", userCreationRequest.Username),
)
return
}
if validationErr := validator.ValidateUserCreationWithPassword(store, userCreationRequest); validationErr != nil {
printErrorAndExit(validationErr.Error())
}
if user, err := store.CreateUser(userCreationRequest); err != nil {
printErrorAndExit(err)
} else {
slog.Info("Created new admin user",
slog.String("username", user.Username),
slog.Int64("user_id", user.ID),
)
}
}
v2-2.2.16/internal/cli/daemon.go 0000664 0000000 0000000 00000004705 15127074645 0016327 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package cli // import "miniflux.app/v2/internal/cli"
import (
"context"
"log/slog"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/http/server"
"miniflux.app/v2/internal/metric"
"miniflux.app/v2/internal/storage"
"miniflux.app/v2/internal/systemd"
"miniflux.app/v2/internal/worker"
)
func startDaemon(store *storage.Storage) {
slog.Debug("Starting daemon...")
stop := make(chan os.Signal, 1)
signal.Notify(stop, os.Interrupt)
signal.Notify(stop, syscall.SIGTERM)
pool := worker.NewPool(store, config.Opts.WorkerPoolSize())
if config.Opts.HasSchedulerService() && !config.Opts.HasMaintenanceMode() {
runScheduler(store, pool)
}
var httpServers []*http.Server
if config.Opts.HasHTTPService() {
httpServers = server.StartWebServer(store, pool)
}
if config.Opts.HasMetricsCollector() {
collector := metric.NewCollector(store, config.Opts.MetricsRefreshInterval())
go collector.GatherStorageMetrics()
}
if systemd.HasNotifySocket() {
slog.Debug("Sending readiness notification to Systemd")
if err := systemd.SdNotify(systemd.SdNotifyReady); err != nil {
slog.Error("Unable to send readiness notification to systemd", slog.Any("error", err))
}
if config.Opts.HasWatchdog() && systemd.HasSystemdWatchdog() {
slog.Debug("Activating Systemd watchdog")
go func() {
interval, err := systemd.WatchdogInterval()
if err != nil {
slog.Error("Unable to get watchdog interval from systemd", slog.Any("error", err))
return
}
for {
if err := store.Ping(); err != nil {
slog.Error("Unable to ping database", slog.Any("error", err))
} else {
systemd.SdNotify(systemd.SdNotifyWatchdog)
}
time.Sleep(interval / 3)
}
}()
}
}
<-stop
slog.Debug("Shutting down the process")
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if len(httpServers) > 0 {
slog.Debug("Shutting down HTTP servers...")
for _, server := range httpServers {
if server != nil {
if err := server.Shutdown(ctx); err != nil {
slog.Error("HTTP server shutdown error", slog.Any("error", err), slog.String("addr", server.Addr))
}
}
}
slog.Debug("All HTTP servers shut down.")
} else {
slog.Debug("No HTTP servers to shut down.")
}
slog.Debug("Process gracefully stopped")
}
v2-2.2.16/internal/cli/export_feeds.go 0000664 0000000 0000000 00000001357 15127074645 0017553 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package cli // import "miniflux.app/v2/internal/cli"
import (
"fmt"
"miniflux.app/v2/internal/reader/opml"
"miniflux.app/v2/internal/storage"
)
func exportUserFeeds(store *storage.Storage, username string) {
user, err := store.UserByUsername(username)
if err != nil {
printErrorAndExit(fmt.Errorf("unable to find user: %w", err))
}
if user == nil {
printErrorAndExit(fmt.Errorf("user %q not found", username))
}
opmlHandler := opml.NewHandler(store)
opmlExport, err := opmlHandler.Export(user.ID)
if err != nil {
printErrorAndExit(fmt.Errorf("unable to export feeds: %w", err))
}
fmt.Println(opmlExport)
}
v2-2.2.16/internal/cli/flush_sessions.go 0000664 0000000 0000000 00000000634 15127074645 0020130 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package cli // import "miniflux.app/v2/internal/cli"
import (
"fmt"
"miniflux.app/v2/internal/storage"
)
func flushSessions(store *storage.Storage) {
fmt.Println("Flushing all sessions (disconnect users)")
if err := store.FlushAllSessions(); err != nil {
printErrorAndExit(err)
}
}
v2-2.2.16/internal/cli/health_check.go 0000664 0000000 0000000 00000001632 15127074645 0017462 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package cli // import "miniflux.app/v2/internal/cli"
import (
"fmt"
"log/slog"
"net/http"
"time"
"miniflux.app/v2/internal/config"
)
func doHealthCheck(healthCheckEndpoint string) {
if healthCheckEndpoint == "auto" {
healthCheckEndpoint = "http://" + config.Opts.ListenAddr()[0] + config.Opts.BasePath() + "/healthcheck"
}
slog.Debug("Executing health check request", slog.String("endpoint", healthCheckEndpoint))
client := &http.Client{Timeout: 3 * time.Second}
resp, err := client.Get(healthCheckEndpoint)
if err != nil {
printErrorAndExit(fmt.Errorf(`health check failure: %v`, err))
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
printErrorAndExit(fmt.Errorf(`health check failed with status code %d`, resp.StatusCode))
}
slog.Debug(`Health check is passing`)
}
v2-2.2.16/internal/cli/info.go 0000664 0000000 0000000 00000001045 15127074645 0016011 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package cli // import "miniflux.app/v2/internal/cli"
import (
"fmt"
"runtime"
"miniflux.app/v2/internal/version"
)
func info() {
fmt.Println("Version:", version.Version)
fmt.Println("Commit:", version.Commit)
fmt.Println("Build Date:", version.BuildDate)
fmt.Println("Go Version:", runtime.Version())
fmt.Println("Compiler:", runtime.Compiler)
fmt.Println("Arch:", runtime.GOARCH)
fmt.Println("OS:", runtime.GOOS)
}
v2-2.2.16/internal/cli/logger.go 0000664 0000000 0000000 00000002072 15127074645 0016336 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package cli // import "miniflux.app/v2/internal/cli"
import (
"io"
"log/slog"
)
func InitializeDefaultLogger(logLevel string, logFile io.Writer, logFormat string, logTime bool) error {
var programLogLevel = new(slog.LevelVar)
switch logLevel {
case "debug":
programLogLevel.Set(slog.LevelDebug)
case "info":
programLogLevel.Set(slog.LevelInfo)
case "warning":
programLogLevel.Set(slog.LevelWarn)
case "error":
programLogLevel.Set(slog.LevelError)
}
logHandlerOptions := &slog.HandlerOptions{Level: programLogLevel}
if !logTime {
logHandlerOptions.ReplaceAttr = func(groups []string, a slog.Attr) slog.Attr {
if a.Key == slog.TimeKey {
return slog.Attr{}
}
return a
}
}
var logger *slog.Logger
switch logFormat {
case "json":
logger = slog.New(slog.NewJSONHandler(logFile, logHandlerOptions))
default:
logger = slog.New(slog.NewTextHandler(logFile, logHandlerOptions))
}
slog.SetDefault(logger)
return nil
}
v2-2.2.16/internal/cli/refresh_feeds.go 0000664 0000000 0000000 00000004011 15127074645 0017656 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package cli // import "miniflux.app/v2/internal/cli"
import (
"log/slog"
"sync"
"time"
"miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/model"
feedHandler "miniflux.app/v2/internal/reader/handler"
"miniflux.app/v2/internal/storage"
)
func refreshFeeds(store *storage.Storage) {
var wg sync.WaitGroup
startTime := time.Now()
// Generate a batch of feeds for any user that has feeds to refresh.
batchBuilder := store.NewBatchBuilder()
batchBuilder.WithBatchSize(config.Opts.BatchSize())
batchBuilder.WithErrorLimit(config.Opts.PollingParsingErrorLimit())
batchBuilder.WithoutDisabledFeeds()
batchBuilder.WithNextCheckExpired()
batchBuilder.WithLimitPerHost(config.Opts.PollingLimitPerHost())
jobs, err := batchBuilder.FetchJobs()
if err != nil {
slog.Error("Unable to fetch jobs from database", slog.Any("error", err))
return
}
slog.Debug("Feed URLs in this batch", slog.Any("feed_urls", jobs.FeedURLs()))
nbJobs := len(jobs)
var jobQueue = make(chan model.Job, nbJobs)
slog.Info("Starting a pool of workers",
slog.Int("nb_workers", config.Opts.WorkerPoolSize()),
)
for i := range config.Opts.WorkerPoolSize() {
wg.Add(1)
go func(workerID int) {
defer wg.Done()
for job := range jobQueue {
slog.Info("Refreshing feed",
slog.Int64("feed_id", job.FeedID),
slog.Int64("user_id", job.UserID),
slog.Int("worker_id", workerID),
)
if localizedError := feedHandler.RefreshFeed(store, job.UserID, job.FeedID, false); localizedError != nil {
slog.Warn("Unable to refresh feed",
slog.Int64("feed_id", job.FeedID),
slog.Int64("user_id", job.UserID),
slog.Any("error", localizedError.Error()),
)
}
}
}(i)
}
for _, job := range jobs {
jobQueue <- job
}
close(jobQueue)
wg.Wait()
slog.Info("Refreshed a batch of feeds",
slog.Int("nb_feeds", nbJobs),
slog.String("duration", time.Since(startTime).String()),
)
}
v2-2.2.16/internal/cli/reset_password.go 0000664 0000000 0000000 00000001671 15127074645 0020127 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package cli // import "miniflux.app/v2/internal/cli"
import (
"errors"
"fmt"
"miniflux.app/v2/internal/model"
"miniflux.app/v2/internal/storage"
"miniflux.app/v2/internal/validator"
)
func resetPassword(store *storage.Storage) {
username, password := askCredentials()
user, err := store.UserByUsername(username)
if err != nil {
printErrorAndExit(err)
}
if user == nil {
printErrorAndExit(errors.New("user not found"))
}
userModificationRequest := &model.UserModificationRequest{
Password: &password,
}
if validationErr := validator.ValidateUserModification(store, user.ID, userModificationRequest); validationErr != nil {
printErrorAndExit(validationErr.Error())
}
user.Password = password
if err := store.UpdateUser(user); err != nil {
printErrorAndExit(err)
}
fmt.Println("Password changed!")
}
v2-2.2.16/internal/cli/scheduler.go 0000664 0000000 0000000 00000003036 15127074645 0017036 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package cli // import "miniflux.app/v2/internal/cli"
import (
"log/slog"
"time"
"miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/storage"
"miniflux.app/v2/internal/worker"
)
func runScheduler(store *storage.Storage, pool *worker.Pool) {
slog.Debug(`Starting background scheduler...`)
go feedScheduler(
store,
pool,
config.Opts.PollingFrequency(),
config.Opts.BatchSize(),
config.Opts.PollingParsingErrorLimit(),
config.Opts.PollingLimitPerHost(),
)
go cleanupScheduler(
store,
config.Opts.CleanupFrequency(),
)
}
func feedScheduler(store *storage.Storage, pool *worker.Pool, frequency time.Duration, batchSize, errorLimit, limitPerHost int) {
for range time.Tick(frequency) {
// Generate a batch of feeds for any user that has feeds to refresh.
batchBuilder := store.NewBatchBuilder()
batchBuilder.WithBatchSize(batchSize)
batchBuilder.WithErrorLimit(errorLimit)
batchBuilder.WithoutDisabledFeeds()
batchBuilder.WithNextCheckExpired()
batchBuilder.WithLimitPerHost(limitPerHost)
if jobs, err := batchBuilder.FetchJobs(); err != nil {
slog.Error("Unable to fetch jobs from database", slog.Any("error", err))
} else if len(jobs) > 0 {
slog.Debug("Feed URLs in this batch", slog.Any("feed_urls", jobs.FeedURLs()))
pool.Push(jobs)
}
}
}
func cleanupScheduler(store *storage.Storage, frequency time.Duration) {
for range time.Tick(frequency) {
runCleanupTasks(store)
}
}
v2-2.2.16/internal/config/ 0000775 0000000 0000000 00000000000 15127074645 0015225 5 ustar 00root root 0000000 0000000 v2-2.2.16/internal/config/config.go 0000664 0000000 0000000 00000000631 15127074645 0017021 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package config // import "miniflux.app/v2/internal/config"
import "miniflux.app/v2/internal/version"
// Opts holds parsed configuration options.
var Opts *configOptions
var defaultHTTPClientUserAgent = "Mozilla/5.0 (compatible; Miniflux/" + version.Version + "; +https://miniflux.app)"
v2-2.2.16/internal/config/options.go 0000664 0000000 0000000 00000067172 15127074645 0017264 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package config // import "miniflux.app/v2/internal/config"
import (
"maps"
"net/url"
"slices"
"strings"
"time"
)
type optionPair struct {
Key string
Value string
}
type configValueType int
const (
stringType configValueType = iota
stringListType
boolType
intType
int64Type
urlType
secondType
minuteType
hourType
dayType
secretFileType
bytesType
)
type configValue struct {
parsedStringValue string
parsedBoolValue bool
parsedIntValue int
parsedInt64Value int64
parsedDuration time.Duration
parsedStringList []string
parsedURLValue *url.URL
parsedBytesValue []byte
rawValue string
valueType configValueType
secret bool
targetKey string
validator func(string) error
}
type configOptions struct {
rootURL string
basePath string
youTubeEmbedDomain string
options map[string]*configValue
}
// NewConfigOptions creates a new instance of ConfigOptions with default values.
func NewConfigOptions() *configOptions {
return &configOptions{
rootURL: "http://localhost",
basePath: "",
youTubeEmbedDomain: "www.youtube-nocookie.com",
options: map[string]*configValue{
"ADMIN_PASSWORD": {
parsedStringValue: "",
rawValue: "",
valueType: stringType,
secret: true,
},
"ADMIN_PASSWORD_FILE": {
parsedStringValue: "",
rawValue: "",
valueType: secretFileType,
targetKey: "ADMIN_PASSWORD",
},
"ADMIN_USERNAME": {
parsedStringValue: "",
rawValue: "",
valueType: stringType,
},
"ADMIN_USERNAME_FILE": {
parsedStringValue: "",
rawValue: "",
valueType: secretFileType,
targetKey: "ADMIN_USERNAME",
},
"AUTH_PROXY_HEADER": {
parsedStringValue: "",
rawValue: "",
valueType: stringType,
},
"AUTH_PROXY_USER_CREATION": {
parsedBoolValue: false,
rawValue: "0",
valueType: boolType,
},
"BASE_URL": {
parsedStringValue: "http://localhost",
rawValue: "http://localhost",
valueType: stringType,
},
"BATCH_SIZE": {
parsedIntValue: 100,
rawValue: "100",
valueType: intType,
validator: func(rawValue string) error {
return validateGreaterOrEqualThan(rawValue, 1)
},
},
"CERT_DOMAIN": {
parsedStringValue: "",
rawValue: "",
valueType: stringType,
},
"CERT_FILE": {
parsedStringValue: "",
rawValue: "",
valueType: stringType,
},
"CLEANUP_ARCHIVE_BATCH_SIZE": {
parsedIntValue: 10000,
rawValue: "10000",
valueType: intType,
validator: func(rawValue string) error {
return validateGreaterOrEqualThan(rawValue, 1)
},
},
"CLEANUP_ARCHIVE_READ_DAYS": {
parsedDuration: time.Hour * 24 * 60,
rawValue: "60",
valueType: dayType,
},
"CLEANUP_ARCHIVE_UNREAD_DAYS": {
parsedDuration: time.Hour * 24 * 180,
rawValue: "180",
valueType: dayType,
},
"CLEANUP_FREQUENCY_HOURS": {
parsedDuration: time.Hour * 24,
rawValue: "24",
valueType: hourType,
validator: func(rawValue string) error {
return validateGreaterOrEqualThan(rawValue, 1)
},
},
"CLEANUP_REMOVE_SESSIONS_DAYS": {
parsedDuration: time.Hour * 24 * 30,
rawValue: "30",
valueType: dayType,
},
"CREATE_ADMIN": {
parsedBoolValue: false,
rawValue: "0",
valueType: boolType,
},
"DATABASE_CONNECTION_LIFETIME": {
parsedDuration: time.Minute * 5,
rawValue: "5",
valueType: minuteType,
validator: func(rawValue string) error {
return validateGreaterThan(rawValue, 0)
},
},
"DATABASE_MAX_CONNS": {
parsedIntValue: 20,
rawValue: "20",
valueType: intType,
validator: func(rawValue string) error {
return validateGreaterOrEqualThan(rawValue, 1)
},
},
"DATABASE_MIN_CONNS": {
parsedIntValue: 1,
rawValue: "1",
valueType: intType,
validator: func(rawValue string) error {
return validateGreaterOrEqualThan(rawValue, 0)
},
},
"DATABASE_URL": {
parsedStringValue: "user=postgres password=postgres dbname=miniflux2 sslmode=disable",
rawValue: "user=postgres password=postgres dbname=miniflux2 sslmode=disable",
valueType: stringType,
secret: true,
},
"DATABASE_URL_FILE": {
parsedStringValue: "",
rawValue: "",
valueType: secretFileType,
targetKey: "DATABASE_URL",
},
"DISABLE_API": {
parsedBoolValue: false,
rawValue: "0",
valueType: boolType,
},
"DISABLE_HSTS": {
parsedBoolValue: false,
rawValue: "0",
valueType: boolType,
},
"DISABLE_HTTP_SERVICE": {
parsedBoolValue: false,
rawValue: "0",
valueType: boolType,
},
"DISABLE_LOCAL_AUTH": {
parsedBoolValue: false,
rawValue: "0",
valueType: boolType,
},
"DISABLE_SCHEDULER_SERVICE": {
parsedBoolValue: false,
rawValue: "0",
valueType: boolType,
},
"FETCH_BILIBILI_WATCH_TIME": {
parsedBoolValue: false,
rawValue: "0",
valueType: boolType,
},
"FETCH_NEBULA_WATCH_TIME": {
parsedBoolValue: false,
rawValue: "0",
valueType: boolType,
},
"FETCH_ODYSEE_WATCH_TIME": {
parsedBoolValue: false,
rawValue: "0",
valueType: boolType,
},
"FETCH_YOUTUBE_WATCH_TIME": {
parsedBoolValue: false,
rawValue: "0",
valueType: boolType,
},
"FILTER_ENTRY_MAX_AGE_DAYS": {
parsedIntValue: 0,
rawValue: "0",
valueType: intType,
validator: func(rawValue string) error {
return validateGreaterOrEqualThan(rawValue, 0)
},
},
"FORCE_REFRESH_INTERVAL": {
parsedDuration: 30 * time.Minute,
rawValue: "30",
valueType: minuteType,
validator: func(rawValue string) error {
return validateGreaterThan(rawValue, 0)
},
},
"HTTP_CLIENT_MAX_BODY_SIZE": {
parsedInt64Value: 15,
rawValue: "15",
valueType: int64Type,
validator: func(rawValue string) error {
return validateGreaterOrEqualThan(rawValue, 1)
},
},
"HTTP_CLIENT_PROXIES": {
parsedStringList: []string{},
rawValue: "",
valueType: stringListType,
secret: true,
},
"HTTP_CLIENT_PROXY": {
parsedURLValue: nil,
rawValue: "",
valueType: urlType,
secret: true,
},
"HTTP_CLIENT_TIMEOUT": {
parsedDuration: 20 * time.Second,
rawValue: "20",
valueType: secondType,
validator: func(rawValue string) error {
return validateGreaterOrEqualThan(rawValue, 1)
},
},
"HTTP_CLIENT_USER_AGENT": {
parsedStringValue: "",
rawValue: "",
valueType: stringType,
},
"HTTP_SERVER_TIMEOUT": {
parsedDuration: 300 * time.Second,
rawValue: "300",
valueType: secondType,
validator: func(rawValue string) error {
return validateGreaterOrEqualThan(rawValue, 1)
},
},
"HTTPS": {
parsedBoolValue: false,
rawValue: "0",
valueType: boolType,
},
"ICON_FETCH_ALLOW_PRIVATE_NETWORKS": {
parsedBoolValue: false,
rawValue: "0",
valueType: boolType,
},
"INVIDIOUS_INSTANCE": {
parsedStringValue: "yewtu.be",
rawValue: "yewtu.be",
valueType: stringType,
},
"KEY_FILE": {
parsedStringValue: "",
rawValue: "",
valueType: stringType,
},
"LISTEN_ADDR": {
parsedStringList: []string{"127.0.0.1:8080"},
rawValue: "127.0.0.1:8080",
valueType: stringListType,
},
"LOG_DATE_TIME": {
parsedBoolValue: false,
rawValue: "0",
valueType: boolType,
},
"LOG_FILE": {
parsedStringValue: "stderr",
rawValue: "stderr",
valueType: stringType,
},
"LOG_FORMAT": {
parsedStringValue: "text",
rawValue: "text",
valueType: stringType,
validator: func(rawValue string) error {
return validateChoices(rawValue, []string{"text", "json"})
},
},
"LOG_LEVEL": {
parsedStringValue: "info",
rawValue: "info",
valueType: stringType,
validator: func(rawValue string) error {
return validateChoices(rawValue, []string{"debug", "info", "warning", "error"})
},
},
"MAINTENANCE_MESSAGE": {
parsedStringValue: "Miniflux is currently under maintenance",
rawValue: "Miniflux is currently under maintenance",
valueType: stringType,
},
"MAINTENANCE_MODE": {
parsedBoolValue: false,
rawValue: "0",
valueType: boolType,
},
"MEDIA_PROXY_CUSTOM_URL": {
rawValue: "",
valueType: urlType,
},
"MEDIA_PROXY_ALLOW_PRIVATE_NETWORKS": {
parsedBoolValue: false,
rawValue: "0",
valueType: boolType,
},
"MEDIA_PROXY_HTTP_CLIENT_TIMEOUT": {
parsedDuration: 120 * time.Second,
rawValue: "120",
valueType: secondType,
validator: func(rawValue string) error {
return validateGreaterOrEqualThan(rawValue, 1)
},
},
"MEDIA_PROXY_MODE": {
parsedStringValue: "http-only",
rawValue: "http-only",
valueType: stringType,
validator: func(rawValue string) error {
return validateChoices(rawValue, []string{"none", "http-only", "all"})
},
},
"MEDIA_PROXY_PRIVATE_KEY": {
valueType: bytesType,
secret: true,
},
"MEDIA_PROXY_RESOURCE_TYPES": {
parsedStringList: []string{"image"},
rawValue: "image",
valueType: stringListType,
validator: func(rawValue string) error {
return validateListChoices(strings.Split(rawValue, ","), []string{"image", "video", "audio"})
},
},
"METRICS_ALLOWED_NETWORKS": {
parsedStringList: []string{"127.0.0.1/8"},
rawValue: "127.0.0.1/8",
valueType: stringListType,
},
"METRICS_COLLECTOR": {
parsedBoolValue: false,
rawValue: "0",
valueType: boolType,
},
"METRICS_PASSWORD": {
parsedStringValue: "",
rawValue: "",
valueType: stringType,
secret: true,
},
"METRICS_PASSWORD_FILE": {
parsedStringValue: "",
rawValue: "",
valueType: secretFileType,
targetKey: "METRICS_PASSWORD",
},
"METRICS_REFRESH_INTERVAL": {
parsedDuration: 60 * time.Second,
rawValue: "60",
valueType: secondType,
validator: func(rawValue string) error {
return validateGreaterOrEqualThan(rawValue, 1)
},
},
"METRICS_USERNAME": {
parsedStringValue: "",
rawValue: "",
valueType: stringType,
},
"METRICS_USERNAME_FILE": {
parsedStringValue: "",
rawValue: "",
valueType: secretFileType,
targetKey: "METRICS_USERNAME",
},
"OAUTH2_CLIENT_ID": {
parsedStringValue: "",
rawValue: "",
valueType: stringType,
secret: true,
},
"OAUTH2_CLIENT_ID_FILE": {
parsedStringValue: "",
rawValue: "",
valueType: secretFileType,
targetKey: "OAUTH2_CLIENT_ID",
},
"OAUTH2_CLIENT_SECRET": {
parsedStringValue: "",
rawValue: "",
valueType: stringType,
secret: true,
},
"OAUTH2_CLIENT_SECRET_FILE": {
parsedStringValue: "",
rawValue: "",
valueType: secretFileType,
targetKey: "OAUTH2_CLIENT_SECRET",
},
"OAUTH2_OIDC_DISCOVERY_ENDPOINT": {
parsedStringValue: "",
rawValue: "",
valueType: stringType,
},
"OAUTH2_OIDC_PROVIDER_NAME": {
parsedStringValue: "OpenID Connect",
rawValue: "OpenID Connect",
valueType: stringType,
},
"OAUTH2_PROVIDER": {
parsedStringValue: "",
rawValue: "",
valueType: stringType,
validator: func(rawValue string) error {
return validateChoices(rawValue, []string{"oidc", "google"})
},
},
"OAUTH2_REDIRECT_URL": {
parsedStringValue: "",
rawValue: "",
valueType: stringType,
},
"OAUTH2_USER_CREATION": {
parsedBoolValue: false,
rawValue: "0",
valueType: boolType,
},
"POLLING_FREQUENCY": {
parsedDuration: 60 * time.Minute,
rawValue: "60",
valueType: minuteType,
validator: func(rawValue string) error {
return validateGreaterOrEqualThan(rawValue, 1)
},
},
"POLLING_LIMIT_PER_HOST": {
parsedIntValue: 0,
rawValue: "0",
valueType: intType,
validator: func(rawValue string) error {
return validateGreaterOrEqualThan(rawValue, 0)
},
},
"POLLING_PARSING_ERROR_LIMIT": {
parsedIntValue: 3,
rawValue: "3",
valueType: intType,
validator: func(rawValue string) error {
return validateGreaterOrEqualThan(rawValue, 0)
},
},
"POLLING_SCHEDULER": {
parsedStringValue: "round_robin",
rawValue: "round_robin",
valueType: stringType,
validator: func(rawValue string) error {
return validateChoices(rawValue, []string{"round_robin", "entry_frequency"})
},
},
"PORT": {
parsedStringValue: "",
rawValue: "",
valueType: stringType,
validator: func(rawValue string) error {
return validateRange(rawValue, 1, 65535)
},
},
"RUN_MIGRATIONS": {
parsedBoolValue: false,
rawValue: "0",
valueType: boolType,
},
"SCHEDULER_ENTRY_FREQUENCY_FACTOR": {
parsedIntValue: 1,
rawValue: "1",
valueType: intType,
},
"SCHEDULER_ENTRY_FREQUENCY_MAX_INTERVAL": {
parsedDuration: 24 * time.Hour,
rawValue: "1440",
valueType: minuteType,
validator: func(rawValue string) error {
return validateGreaterOrEqualThan(rawValue, 1)
},
},
"SCHEDULER_ENTRY_FREQUENCY_MIN_INTERVAL": {
parsedDuration: 5 * time.Minute,
rawValue: "5",
valueType: minuteType,
validator: func(rawValue string) error {
return validateGreaterOrEqualThan(rawValue, 1)
},
},
"SCHEDULER_ROUND_ROBIN_MAX_INTERVAL": {
parsedDuration: 1440 * time.Minute,
rawValue: "1440",
valueType: minuteType,
validator: func(rawValue string) error {
return validateGreaterOrEqualThan(rawValue, 1)
},
},
"SCHEDULER_ROUND_ROBIN_MIN_INTERVAL": {
parsedDuration: 60 * time.Minute,
rawValue: "60",
valueType: minuteType,
validator: func(rawValue string) error {
return validateGreaterOrEqualThan(rawValue, 1)
},
},
"TRUSTED_REVERSE_PROXY_NETWORKS": {
parsedStringList: []string{},
rawValue: "",
valueType: stringListType,
},
"WATCHDOG": {
parsedBoolValue: true,
rawValue: "1",
valueType: boolType,
},
"WEBAUTHN": {
parsedBoolValue: false,
rawValue: "0",
valueType: boolType,
},
"WORKER_POOL_SIZE": {
parsedIntValue: 16,
rawValue: "16",
valueType: intType,
validator: func(rawValue string) error {
return validateGreaterOrEqualThan(rawValue, 1)
},
},
"YOUTUBE_API_KEY": {
parsedStringValue: "",
rawValue: "",
valueType: stringType,
secret: true,
},
"YOUTUBE_EMBED_URL_OVERRIDE": {
parsedStringValue: "https://www.youtube-nocookie.com/embed/",
rawValue: "https://www.youtube-nocookie.com/embed/",
valueType: stringType,
},
},
}
}
func (c *configOptions) AdminPassword() string {
return c.options["ADMIN_PASSWORD"].parsedStringValue
}
func (c *configOptions) AdminUsername() string {
return c.options["ADMIN_USERNAME"].parsedStringValue
}
func (c *configOptions) AuthProxyHeader() string {
return c.options["AUTH_PROXY_HEADER"].parsedStringValue
}
func (c *configOptions) AuthProxyUserCreation() bool {
return c.options["AUTH_PROXY_USER_CREATION"].parsedBoolValue
}
func (c *configOptions) BasePath() string {
return c.basePath
}
func (c *configOptions) BaseURL() string {
return c.options["BASE_URL"].parsedStringValue
}
func (c *configOptions) RootURL() string {
return c.rootURL
}
func (c *configOptions) BatchSize() int {
return c.options["BATCH_SIZE"].parsedIntValue
}
func (c *configOptions) CertDomain() string {
return c.options["CERT_DOMAIN"].parsedStringValue
}
func (c *configOptions) CertFile() string {
return c.options["CERT_FILE"].parsedStringValue
}
func (c *configOptions) CleanupArchiveBatchSize() int {
return c.options["CLEANUP_ARCHIVE_BATCH_SIZE"].parsedIntValue
}
func (c *configOptions) CleanupArchiveReadInterval() time.Duration {
return c.options["CLEANUP_ARCHIVE_READ_DAYS"].parsedDuration
}
func (c *configOptions) CleanupArchiveUnreadInterval() time.Duration {
return c.options["CLEANUP_ARCHIVE_UNREAD_DAYS"].parsedDuration
}
func (c *configOptions) CleanupFrequency() time.Duration {
return c.options["CLEANUP_FREQUENCY_HOURS"].parsedDuration
}
func (c *configOptions) CleanupRemoveSessionsInterval() time.Duration {
return c.options["CLEANUP_REMOVE_SESSIONS_DAYS"].parsedDuration
}
func (c *configOptions) CreateAdmin() bool {
return c.options["CREATE_ADMIN"].parsedBoolValue
}
func (c *configOptions) DatabaseConnectionLifetime() time.Duration {
return c.options["DATABASE_CONNECTION_LIFETIME"].parsedDuration
}
func (c *configOptions) DatabaseMaxConns() int {
return c.options["DATABASE_MAX_CONNS"].parsedIntValue
}
func (c *configOptions) DatabaseMinConns() int {
return c.options["DATABASE_MIN_CONNS"].parsedIntValue
}
func (c *configOptions) DatabaseURL() string {
return c.options["DATABASE_URL"].parsedStringValue
}
func (c *configOptions) DisableHSTS() bool {
return c.options["DISABLE_HSTS"].parsedBoolValue
}
func (c *configOptions) DisableHTTPService() bool {
return c.options["DISABLE_HTTP_SERVICE"].parsedBoolValue
}
func (c *configOptions) DisableLocalAuth() bool {
return c.options["DISABLE_LOCAL_AUTH"].parsedBoolValue
}
func (c *configOptions) DisableSchedulerService() bool {
return c.options["DISABLE_SCHEDULER_SERVICE"].parsedBoolValue
}
func (c *configOptions) FetchBilibiliWatchTime() bool {
return c.options["FETCH_BILIBILI_WATCH_TIME"].parsedBoolValue
}
func (c *configOptions) FetchNebulaWatchTime() bool {
return c.options["FETCH_NEBULA_WATCH_TIME"].parsedBoolValue
}
func (c *configOptions) FetchOdyseeWatchTime() bool {
return c.options["FETCH_ODYSEE_WATCH_TIME"].parsedBoolValue
}
func (c *configOptions) FetchYouTubeWatchTime() bool {
return c.options["FETCH_YOUTUBE_WATCH_TIME"].parsedBoolValue
}
func (c *configOptions) FilterEntryMaxAgeDays() int {
return c.options["FILTER_ENTRY_MAX_AGE_DAYS"].parsedIntValue
}
func (c *configOptions) ForceRefreshInterval() time.Duration {
return c.options["FORCE_REFRESH_INTERVAL"].parsedDuration
}
func (c *configOptions) HasHTTPClientProxiesConfigured() bool {
return len(c.options["HTTP_CLIENT_PROXIES"].parsedStringList) > 0
}
func (c *configOptions) HasAPI() bool {
return !c.options["DISABLE_API"].parsedBoolValue
}
func (c *configOptions) HasHTTPService() bool {
return !c.options["DISABLE_HTTP_SERVICE"].parsedBoolValue
}
func (c *configOptions) HasHSTS() bool {
return !c.options["DISABLE_HSTS"].parsedBoolValue
}
func (c *configOptions) HasHTTPClientProxyURLConfigured() bool {
return c.options["HTTP_CLIENT_PROXY"].parsedURLValue != nil
}
func (c *configOptions) HasMaintenanceMode() bool {
return c.options["MAINTENANCE_MODE"].parsedBoolValue
}
func (c *configOptions) HasMetricsCollector() bool {
return c.options["METRICS_COLLECTOR"].parsedBoolValue
}
func (c *configOptions) HasSchedulerService() bool {
return !c.options["DISABLE_SCHEDULER_SERVICE"].parsedBoolValue
}
func (c *configOptions) HasWatchdog() bool {
return c.options["WATCHDOG"].parsedBoolValue
}
func (c *configOptions) HTTPClientMaxBodySize() int64 {
return c.options["HTTP_CLIENT_MAX_BODY_SIZE"].parsedInt64Value * 1024 * 1024
}
func (c *configOptions) HTTPClientProxies() []string {
return c.options["HTTP_CLIENT_PROXIES"].parsedStringList
}
func (c *configOptions) HTTPClientProxyURL() *url.URL {
return c.options["HTTP_CLIENT_PROXY"].parsedURLValue
}
func (c *configOptions) HTTPClientTimeout() time.Duration {
return c.options["HTTP_CLIENT_TIMEOUT"].parsedDuration
}
func (c *configOptions) HTTPClientUserAgent() string {
if c.options["HTTP_CLIENT_USER_AGENT"].parsedStringValue != "" {
return c.options["HTTP_CLIENT_USER_AGENT"].parsedStringValue
}
return defaultHTTPClientUserAgent
}
func (c *configOptions) HTTPServerTimeout() time.Duration {
return c.options["HTTP_SERVER_TIMEOUT"].parsedDuration
}
func (c *configOptions) HTTPS() bool {
return c.options["HTTPS"].parsedBoolValue
}
func (c *configOptions) IconFetchAllowPrivateNetworks() bool {
return c.options["ICON_FETCH_ALLOW_PRIVATE_NETWORKS"].parsedBoolValue
}
func (c *configOptions) InvidiousInstance() string {
return c.options["INVIDIOUS_INSTANCE"].parsedStringValue
}
func (c *configOptions) IsAuthProxyUserCreationAllowed() bool {
return c.options["AUTH_PROXY_USER_CREATION"].parsedBoolValue
}
func (c *configOptions) IsDefaultDatabaseURL() bool {
return c.options["DATABASE_URL"].rawValue == "user=postgres password=postgres dbname=miniflux2 sslmode=disable"
}
func (c *configOptions) IsOAuth2UserCreationAllowed() bool {
return c.options["OAUTH2_USER_CREATION"].parsedBoolValue
}
func (c *configOptions) CertKeyFile() string {
return c.options["KEY_FILE"].parsedStringValue
}
func (c *configOptions) ListenAddr() []string {
return c.options["LISTEN_ADDR"].parsedStringList
}
func (c *configOptions) LogFile() string {
return c.options["LOG_FILE"].parsedStringValue
}
func (c *configOptions) LogDateTime() bool {
return c.options["LOG_DATE_TIME"].parsedBoolValue
}
func (c *configOptions) LogFormat() string {
return c.options["LOG_FORMAT"].parsedStringValue
}
func (c *configOptions) LogLevel() string {
return c.options["LOG_LEVEL"].parsedStringValue
}
func (c *configOptions) MaintenanceMessage() string {
return c.options["MAINTENANCE_MESSAGE"].parsedStringValue
}
func (c *configOptions) MaintenanceMode() bool {
return c.options["MAINTENANCE_MODE"].parsedBoolValue
}
func (c *configOptions) MediaCustomProxyURL() *url.URL {
return c.options["MEDIA_PROXY_CUSTOM_URL"].parsedURLValue
}
func (c *configOptions) MediaProxyAllowPrivateNetworks() bool {
return c.options["MEDIA_PROXY_ALLOW_PRIVATE_NETWORKS"].parsedBoolValue
}
func (c *configOptions) MediaProxyHTTPClientTimeout() time.Duration {
return c.options["MEDIA_PROXY_HTTP_CLIENT_TIMEOUT"].parsedDuration
}
func (c *configOptions) MediaProxyMode() string {
return c.options["MEDIA_PROXY_MODE"].parsedStringValue
}
func (c *configOptions) MediaProxyPrivateKey() []byte {
return c.options["MEDIA_PROXY_PRIVATE_KEY"].parsedBytesValue
}
func (c *configOptions) MediaProxyResourceTypes() []string {
return c.options["MEDIA_PROXY_RESOURCE_TYPES"].parsedStringList
}
func (c *configOptions) MetricsAllowedNetworks() []string {
return c.options["METRICS_ALLOWED_NETWORKS"].parsedStringList
}
func (c *configOptions) MetricsCollector() bool {
return c.options["METRICS_COLLECTOR"].parsedBoolValue
}
func (c *configOptions) MetricsPassword() string {
return c.options["METRICS_PASSWORD"].parsedStringValue
}
func (c *configOptions) MetricsRefreshInterval() time.Duration {
return c.options["METRICS_REFRESH_INTERVAL"].parsedDuration
}
func (c *configOptions) MetricsUsername() string {
return c.options["METRICS_USERNAME"].parsedStringValue
}
func (c *configOptions) OAuth2ClientID() string {
return c.options["OAUTH2_CLIENT_ID"].parsedStringValue
}
func (c *configOptions) OAuth2ClientSecret() string {
return c.options["OAUTH2_CLIENT_SECRET"].parsedStringValue
}
func (c *configOptions) OAuth2OIDCDiscoveryEndpoint() string {
return c.options["OAUTH2_OIDC_DISCOVERY_ENDPOINT"].parsedStringValue
}
func (c *configOptions) OAuth2OIDCProviderName() string {
return c.options["OAUTH2_OIDC_PROVIDER_NAME"].parsedStringValue
}
func (c *configOptions) OAuth2Provider() string {
return c.options["OAUTH2_PROVIDER"].parsedStringValue
}
func (c *configOptions) OAuth2RedirectURL() string {
return c.options["OAUTH2_REDIRECT_URL"].parsedStringValue
}
func (c *configOptions) OAuth2UserCreation() bool {
return c.options["OAUTH2_USER_CREATION"].parsedBoolValue
}
func (c *configOptions) PollingFrequency() time.Duration {
return c.options["POLLING_FREQUENCY"].parsedDuration
}
func (c *configOptions) PollingLimitPerHost() int {
return c.options["POLLING_LIMIT_PER_HOST"].parsedIntValue
}
func (c *configOptions) PollingParsingErrorLimit() int {
return c.options["POLLING_PARSING_ERROR_LIMIT"].parsedIntValue
}
func (c *configOptions) PollingScheduler() string {
return c.options["POLLING_SCHEDULER"].parsedStringValue
}
func (c *configOptions) Port() string {
return c.options["PORT"].parsedStringValue
}
func (c *configOptions) RunMigrations() bool {
return c.options["RUN_MIGRATIONS"].parsedBoolValue
}
func (c *configOptions) SetLogLevel(level string) {
c.options["LOG_LEVEL"].parsedStringValue = level
c.options["LOG_LEVEL"].rawValue = level
}
func (c *configOptions) SetHTTPSValue(value bool) {
c.options["HTTPS"].parsedBoolValue = value
if value {
c.options["HTTPS"].rawValue = "1"
} else {
c.options["HTTPS"].rawValue = "0"
}
}
func (c *configOptions) SchedulerEntryFrequencyFactor() int {
return c.options["SCHEDULER_ENTRY_FREQUENCY_FACTOR"].parsedIntValue
}
func (c *configOptions) SchedulerEntryFrequencyMaxInterval() time.Duration {
return c.options["SCHEDULER_ENTRY_FREQUENCY_MAX_INTERVAL"].parsedDuration
}
func (c *configOptions) SchedulerEntryFrequencyMinInterval() time.Duration {
return c.options["SCHEDULER_ENTRY_FREQUENCY_MIN_INTERVAL"].parsedDuration
}
func (c *configOptions) SchedulerRoundRobinMaxInterval() time.Duration {
return c.options["SCHEDULER_ROUND_ROBIN_MAX_INTERVAL"].parsedDuration
}
func (c *configOptions) SchedulerRoundRobinMinInterval() time.Duration {
return c.options["SCHEDULER_ROUND_ROBIN_MIN_INTERVAL"].parsedDuration
}
func (c *configOptions) TrustedReverseProxyNetworks() []string {
return c.options["TRUSTED_REVERSE_PROXY_NETWORKS"].parsedStringList
}
func (c *configOptions) Watchdog() bool {
return c.options["WATCHDOG"].parsedBoolValue
}
func (c *configOptions) WebAuthn() bool {
return c.options["WEBAUTHN"].parsedBoolValue
}
func (c *configOptions) WorkerPoolSize() int {
return c.options["WORKER_POOL_SIZE"].parsedIntValue
}
func (c *configOptions) YouTubeAPIKey() string {
return c.options["YOUTUBE_API_KEY"].parsedStringValue
}
func (c *configOptions) YouTubeEmbedUrlOverride() string {
return c.options["YOUTUBE_EMBED_URL_OVERRIDE"].parsedStringValue
}
func (c *configOptions) YouTubeEmbedDomain() string {
return c.youTubeEmbedDomain
}
func (c *configOptions) ConfigMap(redactSecret bool) []*optionPair {
sortedKeys := slices.Sorted(maps.Keys(c.options))
sortedOptions := make([]*optionPair, 0, len(sortedKeys))
for _, key := range sortedKeys {
value := c.options[key]
displayValue := value.rawValue
if displayValue != "" && redactSecret && value.secret {
displayValue = ""
}
sortedOptions = append(sortedOptions, &optionPair{Key: key, Value: displayValue})
}
return sortedOptions
}
func (c *configOptions) String() string {
var builder strings.Builder
for _, option := range c.ConfigMap(false) {
builder.WriteString(option.Key)
builder.WriteByte('=')
builder.WriteString(option.Value)
builder.WriteByte('\n')
}
return builder.String()
}
v2-2.2.16/internal/config/options_parsing_test.go 0000664 0000000 0000000 00000153251 15127074645 0022040 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package config // import "miniflux.app/v2/internal/config"
import (
"slices"
"testing"
)
func TestBaseURLOptionParsing(t *testing.T) {
configParser := NewConfigParser()
if configParser.options.BaseURL() != "http://localhost" {
t.Fatalf("Expected BASE_URL to be 'http://localhost' by default")
}
if configParser.options.RootURL() != "http://localhost" {
t.Fatalf("Expected ROOT_URL to be 'http://localhost' by default")
}
if configParser.options.BasePath() != "" {
t.Fatalf("Expected BASE_PATH to be empty by default")
}
if err := configParser.parseLines([]string{"BASE_URL=https://example.com/app"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if configParser.options.BaseURL() != "https://example.com/app" {
t.Fatalf("Expected BASE_URL to be 'https://example.com/app', got '%s'", configParser.options.BaseURL())
}
if configParser.options.RootURL() != "https://example.com" {
t.Fatalf("Expected ROOT_URL to be 'https://example.com', got '%s'", configParser.options.RootURL())
}
if configParser.options.BasePath() != "/app" {
t.Fatalf("Expected BASE_PATH to be '/app', got '%s'", configParser.options.BasePath())
}
if err := configParser.parseLines([]string{"BASE_URL=https://example.com/app/"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if configParser.options.BaseURL() != "https://example.com/app" {
t.Fatalf("Expected BASE_URL to be 'https://example.com/app', got '%s'", configParser.options.BaseURL())
}
if configParser.options.RootURL() != "https://example.com" {
t.Fatalf("Expected ROOT_URL to be 'https://example.com', got '%s'", configParser.options.RootURL())
}
if configParser.options.BasePath() != "/app" {
t.Fatalf("Expected BASE_PATH to be '/app', got '%s'", configParser.options.BasePath())
}
if err := configParser.parseLines([]string{"BASE_URL=example.com/app/"}); err == nil {
t.Fatal("Expected an error due to missing scheme in BASE_URL")
}
}
func TestWatchdogOptionParsing(t *testing.T) {
configParser := NewConfigParser()
if !configParser.options.Watchdog() {
t.Fatal("Expected WATCHDOG to be enabled by default")
}
if !configParser.options.HasSchedulerService() {
t.Fatal("Expected HAS_SCHEDULER_SERVICE to be enabled by default")
}
if err := configParser.parseLines([]string{"WATCHDOG=1"}); err != nil {
t.Fatal("Unexpected error:", err)
}
if !configParser.options.Watchdog() {
t.Fatal("Expected WATCHDOG to be enabled")
}
if !configParser.options.HasSchedulerService() {
t.Fatal("Expected HAS_SCHEDULER_SERVICE to be enabled")
}
if err := configParser.parseLines([]string{"WATCHDOG=0"}); err != nil {
t.Fatal("Unexpected error:", err)
}
if configParser.options.Watchdog() {
t.Fatal("Expected WATCHDOG to be disabled")
}
if configParser.options.HasWatchdog() {
t.Fatal("Expected HAS_WATCHDOG to be disabled")
}
}
func TestWebAuthnOptionParsing(t *testing.T) {
configParser := NewConfigParser()
if configParser.options.WebAuthn() {
t.Fatalf("Expected WEBAUTHN to be disabled by default")
}
if err := configParser.parseLines([]string{"WEBAUTHN=1"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if !configParser.options.WebAuthn() {
t.Fatalf("Expected WEBAUTHN to be enabled")
}
}
func TestWorkerPoolSizeOptionParsing(t *testing.T) {
configParser := NewConfigParser()
if configParser.options.WorkerPoolSize() != 16 {
t.Fatalf("Expected WORKER_POOL_SIZE to be 16 by default")
}
if err := configParser.parseLines([]string{"WORKER_POOL_SIZE=8"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if configParser.options.WorkerPoolSize() != 8 {
t.Fatalf("Expected WORKER_POOL_SIZE to be 8")
}
}
func TestYouTubeAPIKeyOptionParsing(t *testing.T) {
configParser := NewConfigParser()
if configParser.options.YouTubeAPIKey() != "" {
t.Fatalf("Expected YOUTUBE_API_KEY to be empty by default")
}
if err := configParser.parseLines([]string{"YOUTUBE_API_KEY=somekey"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if configParser.options.YouTubeAPIKey() != "somekey" {
t.Fatalf("Expected YOUTUBE_API_KEY to be 'somekey'")
}
}
func TestAdminPasswordOptionParsing(t *testing.T) {
configParser := NewConfigParser()
if configParser.options.AdminPassword() != "" {
t.Fatalf("Expected ADMIN_PASSWORD to be empty by default")
}
if err := configParser.parseLines([]string{"ADMIN_PASSWORD=secret123"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if configParser.options.AdminPassword() != "secret123" {
t.Fatalf("Expected ADMIN_PASSWORD to be 'secret123'")
}
}
func TestAdminUsernameOptionParsing(t *testing.T) {
configParser := NewConfigParser()
if configParser.options.AdminUsername() != "" {
t.Fatalf("Expected ADMIN_USERNAME to be empty by default")
}
if err := configParser.parseLines([]string{"ADMIN_USERNAME=admin"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if configParser.options.AdminUsername() != "admin" {
t.Fatalf("Expected ADMIN_USERNAME to be 'admin'")
}
}
func TestAuthProxyHeaderOptionParsing(t *testing.T) {
configParser := NewConfigParser()
if configParser.options.AuthProxyHeader() != "" {
t.Fatalf("Expected AUTH_PROXY_HEADER to be empty by default")
}
if err := configParser.parseLines([]string{"AUTH_PROXY_HEADER=X-Forwarded-User"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if configParser.options.AuthProxyHeader() != "X-Forwarded-User" {
t.Fatalf("Expected AUTH_PROXY_HEADER to be 'X-Forwarded-User'")
}
}
func TestAuthProxyUserCreationOptionParsing(t *testing.T) {
configParser := NewConfigParser()
if configParser.options.AuthProxyUserCreation() {
t.Fatal("Expected AUTH_PROXY_USER_CREATION to be disabled by default")
}
if configParser.options.IsAuthProxyUserCreationAllowed() {
t.Fatal("Expected HAS_AUTH_PROXY_USER_CREATION to be disabled by default")
}
if err := configParser.parseLines([]string{"AUTH_PROXY_USER_CREATION=1"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if !configParser.options.AuthProxyUserCreation() {
t.Fatal("Expected AUTH_PROXY_USER_CREATION to be enabled")
}
if !configParser.options.IsAuthProxyUserCreationAllowed() {
t.Fatal("Expected HAS_AUTH_PROXY_USER_CREATION to be enabled")
}
if err := configParser.parseLines([]string{"AUTH_PROXY_USER_CREATION=0"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if configParser.options.AuthProxyUserCreation() {
t.Fatal("Expected AUTH_PROXY_USER_CREATION to be disabled")
}
if configParser.options.IsAuthProxyUserCreationAllowed() {
t.Fatal("Expected HAS_AUTH_PROXY_USER_CREATION to be disabled")
}
}
func TestBatchSizeOptionParsing(t *testing.T) {
configParser := NewConfigParser()
if configParser.options.BatchSize() != 100 {
t.Fatalf("Expected BATCH_SIZE to be 100 by default")
}
if err := configParser.parseLines([]string{"BATCH_SIZE=50"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if configParser.options.BatchSize() != 50 {
t.Fatalf("Expected BATCH_SIZE to be 50")
}
}
func TestCertDomainOptionParsing(t *testing.T) {
configParser := NewConfigParser()
if configParser.options.CertDomain() != "" {
t.Fatalf("Expected CERT_DOMAIN to be empty by default")
}
if err := configParser.parseLines([]string{"CERT_DOMAIN=example.com"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if configParser.options.CertDomain() != "example.com" {
t.Fatalf("Expected CERT_DOMAIN to be 'example.com'")
}
}
func TestCertFileOptionParsing(t *testing.T) {
configParser := NewConfigParser()
if configParser.options.CertFile() != "" {
t.Fatalf("Expected CERT_FILE to be empty by default")
}
if err := configParser.parseLines([]string{"CERT_FILE=/path/to/cert.pem"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if configParser.options.CertFile() != "/path/to/cert.pem" {
t.Fatalf("Expected CERT_FILE to be '/path/to/cert.pem'")
}
}
func TestCleanupArchiveBatchSizeOptionParsing(t *testing.T) {
configParser := NewConfigParser()
if configParser.options.CleanupArchiveBatchSize() != 10000 {
t.Fatalf("Expected CLEANUP_ARCHIVE_BATCH_SIZE to be 10000 by default")
}
if err := configParser.parseLines([]string{"CLEANUP_ARCHIVE_BATCH_SIZE=5000"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if configParser.options.CleanupArchiveBatchSize() != 5000 {
t.Fatalf("Expected CLEANUP_ARCHIVE_BATCH_SIZE to be 5000")
}
}
func TestCreateAdminOptionParsing(t *testing.T) {
configParser := NewConfigParser()
if configParser.options.CreateAdmin() {
t.Fatalf("Expected CREATE_ADMIN to be disabled by default")
}
if err := configParser.parseLines([]string{"CREATE_ADMIN=1"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if !configParser.options.CreateAdmin() {
t.Fatalf("Expected CREATE_ADMIN to be enabled")
}
if err := configParser.parseLines([]string{"CREATE_ADMIN=0"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if configParser.options.CreateAdmin() {
t.Fatalf("Expected CREATE_ADMIN to be disabled")
}
}
func TestDatabaseMaxConnsOptionParsing(t *testing.T) {
configParser := NewConfigParser()
if configParser.options.DatabaseMaxConns() != 20 {
t.Fatalf("Expected DATABASE_MAX_CONNS to be 20 by default")
}
if err := configParser.parseLines([]string{"DATABASE_MAX_CONNS=10"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if configParser.options.DatabaseMaxConns() != 10 {
t.Fatalf("Expected DATABASE_MAX_CONNS to be 10")
}
}
func TestDatabaseMinConnsOptionParsing(t *testing.T) {
configParser := NewConfigParser()
if configParser.options.DatabaseMinConns() != 1 {
t.Fatalf("Expected DATABASE_MIN_CONNS to be 1 by default")
}
if err := configParser.parseLines([]string{"DATABASE_MIN_CONNS=2"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if configParser.options.DatabaseMinConns() != 2 {
t.Fatalf("Expected DATABASE_MIN_CONNS to be 2")
}
}
func TestDatabaseURLOptionParsing(t *testing.T) {
configParser := NewConfigParser()
if configParser.options.DatabaseURL() != "user=postgres password=postgres dbname=miniflux2 sslmode=disable" {
t.Fatal("Expected DATABASE_URL to have default value")
}
if !configParser.options.IsDefaultDatabaseURL() {
t.Fatal("Expected DATABASE_URL to be the default value")
}
if err := configParser.parseLines([]string{"DATABASE_URL=postgres://user:pass@localhost/db"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if configParser.options.DatabaseURL() != "postgres://user:pass@localhost/db" {
t.Fatal("Expected DATABASE_URL to be 'postgres://user:pass@localhost/db'")
}
if configParser.options.IsDefaultDatabaseURL() {
t.Fatal("Expected DATABASE_URL to not be the default value")
}
}
func TestDisableHSTSOptionParsing(t *testing.T) {
configParser := NewConfigParser()
if configParser.options.DisableHSTS() {
t.Fatal("Expected DISABLE_HSTS to be disabled by default")
}
if !configParser.options.HasHSTS() {
t.Fatal("Expected HAS_HSTS to be enabled by default")
}
if err := configParser.parseLines([]string{"DISABLE_HSTS=1"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if !configParser.options.DisableHSTS() {
t.Fatal("Expected DISABLE_HSTS to be enabled")
}
if configParser.options.HasHSTS() {
t.Fatal("Expected HAS_HSTS to be disabled")
}
if err := configParser.parseLines([]string{"DISABLE_HSTS=0"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if configParser.options.DisableHSTS() {
t.Fatal("Expected DISABLE_HSTS to be disabled")
}
if !configParser.options.HasHSTS() {
t.Fatal("Expected HAS_HSTS to be enabled")
}
}
func TestDisableHTTPServiceOptionParsing(t *testing.T) {
configParser := NewConfigParser()
if configParser.options.DisableHTTPService() {
t.Fatal("Expected DISABLE_HTTP_SERVICE to be disabled by default")
}
if !configParser.options.HasHTTPService() {
t.Fatal("Expected HAS_HTTP_SERVICE to be enabled by default")
}
if err := configParser.parseLines([]string{"DISABLE_HTTP_SERVICE=1"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if !configParser.options.DisableHTTPService() {
t.Fatal("Expected DISABLE_HTTP_SERVICE to be enabled")
}
if configParser.options.HasHTTPService() {
t.Fatal("Expected HAS_HTTP_SERVICE to be disabled")
}
if err := configParser.parseLines([]string{"DISABLE_HTTP_SERVICE=0"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if configParser.options.DisableHTTPService() {
t.Fatal("Expected DISABLE_HTTP_SERVICE to be disabled")
}
if !configParser.options.HasHTTPService() {
t.Fatal("Expected HAS_HTTP_SERVICE to be disabled")
}
}
func TestDisableLocalAuthOptionParsing(t *testing.T) {
configParser := NewConfigParser()
if configParser.options.DisableLocalAuth() {
t.Fatalf("Expected DISABLE_LOCAL_AUTH to be disabled by default")
}
if err := configParser.parseLines([]string{"DISABLE_LOCAL_AUTH=1"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if !configParser.options.DisableLocalAuth() {
t.Fatalf("Expected DISABLE_LOCAL_AUTH to be enabled")
}
if err := configParser.parseLines([]string{"DISABLE_LOCAL_AUTH=0"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if configParser.options.DisableLocalAuth() {
t.Fatalf("Expected DISABLE_LOCAL_AUTH to be disabled")
}
}
func TestDisableSchedulerServiceOptionParsing(t *testing.T) {
configParser := NewConfigParser()
if configParser.options.DisableSchedulerService() {
t.Fatal("Expected DISABLE_SCHEDULER_SERVICE to be disabled by default")
}
if !configParser.options.HasSchedulerService() {
t.Fatal("Expected HAS_SCHEDULER_SERVICE to be enabled by default")
}
if err := configParser.parseLines([]string{"DISABLE_SCHEDULER_SERVICE=1"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if !configParser.options.DisableSchedulerService() {
t.Fatal("Expected DISABLE_SCHEDULER_SERVICE to be enabled")
}
if configParser.options.HasSchedulerService() {
t.Fatal("Expected HAS_SCHEDULER_SERVICE to be disabled")
}
if err := configParser.parseLines([]string{"DISABLE_SCHEDULER_SERVICE=0"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if configParser.options.DisableSchedulerService() {
t.Fatal("Expected DISABLE_SCHEDULER_SERVICE to be disabled")
}
if !configParser.options.HasSchedulerService() {
t.Fatal("Expected HAS_SCHEDULER_SERVICE to be enabled")
}
}
func TestFetchBilibiliWatchTimeOptionParsing(t *testing.T) {
configParser := NewConfigParser()
if configParser.options.FetchBilibiliWatchTime() {
t.Fatalf("Expected FETCH_BILIBILI_WATCH_TIME to be disabled by default")
}
if err := configParser.parseLines([]string{"FETCH_BILIBILI_WATCH_TIME=1"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if !configParser.options.FetchBilibiliWatchTime() {
t.Fatalf("Expected FETCH_BILIBILI_WATCH_TIME to be enabled")
}
if err := configParser.parseLines([]string{"FETCH_BILIBILI_WATCH_TIME=0"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if configParser.options.FetchBilibiliWatchTime() {
t.Fatalf("Expected FETCH_BILIBILI_WATCH_TIME to be disabled")
}
}
func TestFetchNebulaWatchTimeOptionParsing(t *testing.T) {
configParser := NewConfigParser()
if configParser.options.FetchNebulaWatchTime() {
t.Fatalf("Expected FETCH_NEBULA_WATCH_TIME to be disabled by default")
}
if err := configParser.parseLines([]string{"FETCH_NEBULA_WATCH_TIME=1"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if !configParser.options.FetchNebulaWatchTime() {
t.Fatalf("Expected FETCH_NEBULA_WATCH_TIME to be enabled")
}
if err := configParser.parseLines([]string{"FETCH_NEBULA_WATCH_TIME=0"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if configParser.options.FetchNebulaWatchTime() {
t.Fatalf("Expected FETCH_NEBULA_WATCH_TIME to be disabled")
}
}
func TestFetchOdyseeWatchTimeOptionParsing(t *testing.T) {
configParser := NewConfigParser()
if configParser.options.FetchOdyseeWatchTime() {
t.Fatalf("Expected FETCH_ODYSEE_WATCH_TIME to be disabled by default")
}
if err := configParser.parseLines([]string{"FETCH_ODYSEE_WATCH_TIME=1"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if !configParser.options.FetchOdyseeWatchTime() {
t.Fatalf("Expected FETCH_ODYSEE_WATCH_TIME to be enabled")
}
if err := configParser.parseLines([]string{"FETCH_ODYSEE_WATCH_TIME=0"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if configParser.options.FetchOdyseeWatchTime() {
t.Fatalf("Expected FETCH_ODYSEE_WATCH_TIME to be disabled")
}
}
func TestFetchYouTubeWatchTimeOptionParsing(t *testing.T) {
configParser := NewConfigParser()
if configParser.options.FetchYouTubeWatchTime() {
t.Fatalf("Expected FETCH_YOUTUBE_WATCH_TIME to be disabled by default")
}
if err := configParser.parseLines([]string{"FETCH_YOUTUBE_WATCH_TIME=1"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if !configParser.options.FetchYouTubeWatchTime() {
t.Fatalf("Expected FETCH_YOUTUBE_WATCH_TIME to be enabled")
}
if err := configParser.parseLines([]string{"FETCH_YOUTUBE_WATCH_TIME=0"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if configParser.options.FetchYouTubeWatchTime() {
t.Fatalf("Expected FETCH_YOUTUBE_WATCH_TIME to be disabled")
}
}
func TestHTTPClientMaxBodySizeOptionParsing(t *testing.T) {
configParser := NewConfigParser()
if configParser.options.HTTPClientMaxBodySize() != 15*1024*1024 {
t.Fatalf("Expected HTTP_CLIENT_MAX_BODY_SIZE to be 15 by default, got %d", configParser.options.HTTPClientMaxBodySize())
}
if err := configParser.parseLines([]string{"HTTP_CLIENT_MAX_BODY_SIZE=25"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
expectedValue := 25 * 1024 * 1024
currentValue := configParser.options.HTTPClientMaxBodySize()
if currentValue != int64(expectedValue) {
t.Fatalf("Expected HTTP_CLIENT_MAX_BODY_SIZE to be %d, got %d", expectedValue, currentValue)
}
}
func TestHTTPClientUserAgentOptionParsing(t *testing.T) {
configParser := NewConfigParser()
if configParser.options.HTTPClientUserAgent() != defaultHTTPClientUserAgent {
t.Fatalf("Expected HTTP_CLIENT_USER_AGENT to have default value")
}
if err := configParser.parseLines([]string{"HTTP_CLIENT_USER_AGENT=Custom User Agent"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if configParser.options.HTTPClientUserAgent() != "Custom User Agent" {
t.Fatalf("Expected HTTP_CLIENT_USER_AGENT to be 'Custom User Agent'")
}
}
func TestHTTPSOptionParsing(t *testing.T) {
configParser := NewConfigParser()
if configParser.options.HTTPS() {
t.Fatalf("Expected HTTPS to be disabled by default")
}
if err := configParser.parseLines([]string{"HTTPS=1"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if !configParser.options.HTTPS() {
t.Fatalf("Expected HTTPS to be enabled")
}
if err := configParser.parseLines([]string{"HTTPS=0"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if configParser.options.HTTPS() {
t.Fatalf("Expected HTTPS to be disabled")
}
}
func TestInvidiousInstanceOptionParsing(t *testing.T) {
configParser := NewConfigParser()
if configParser.options.InvidiousInstance() != "yewtu.be" {
t.Fatalf("Expected INVIDIOUS_INSTANCE to be 'yewtu.be' by default")
}
if err := configParser.parseLines([]string{"INVIDIOUS_INSTANCE=invidious.example.com"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if configParser.options.InvidiousInstance() != "invidious.example.com" {
t.Fatalf("Expected INVIDIOUS_INSTANCE to be 'invidious.example.com'")
}
}
func TestCertKeyFileOptionParsing(t *testing.T) {
configParser := NewConfigParser()
if configParser.options.CertKeyFile() != "" {
t.Fatalf("Expected KEY_FILE to be empty by default")
}
if err := configParser.parseLines([]string{"KEY_FILE=/path/to/key.pem"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if configParser.options.CertKeyFile() != "/path/to/key.pem" {
t.Fatalf("Expected KEY_FILE to be '/path/to/key.pem'")
}
}
func TestLogDateTimeOptionParsing(t *testing.T) {
configParser := NewConfigParser()
if configParser.options.LogDateTime() {
t.Fatalf("Expected LOG_DATE_TIME to be disabled by default")
}
if err := configParser.parseLines([]string{"LOG_DATE_TIME=1"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if !configParser.options.LogDateTime() {
t.Fatalf("Expected LOG_DATE_TIME to be enabled")
}
if err := configParser.parseLines([]string{"LOG_DATE_TIME=0"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if configParser.options.LogDateTime() {
t.Fatalf("Expected LOG_DATE_TIME to be disabled")
}
}
func TestLogFileOptionParsing(t *testing.T) {
configParser := NewConfigParser()
if configParser.options.LogFile() != "stderr" {
t.Fatalf("Expected LOG_FILE to be 'stderr' by default")
}
if err := configParser.parseLines([]string{"LOG_FILE=/var/log/miniflux.log"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if configParser.options.LogFile() != "/var/log/miniflux.log" {
t.Fatalf("Expected LOG_FILE to be '/var/log/miniflux.log'")
}
}
func TestLogFormatOptionParsing(t *testing.T) {
configParser := NewConfigParser()
if configParser.options.LogFormat() != "text" {
t.Fatalf("Expected LOG_FORMAT to be 'text' by default")
}
if err := configParser.parseLines([]string{"LOG_FORMAT=json"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if configParser.options.LogFormat() != "json" {
t.Fatalf("Expected LOG_FORMAT to be 'json'")
}
}
func TestLogLevelOptionParsing(t *testing.T) {
configParser := NewConfigParser()
if configParser.options.LogLevel() != "info" {
t.Fatalf("Expected LOG_LEVEL to be 'info' by default")
}
if err := configParser.parseLines([]string{"LOG_LEVEL=debug"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if configParser.options.LogLevel() != "debug" {
t.Fatalf("Expected LOG_LEVEL to be 'debug'")
}
}
func TestMaintenanceMessageOptionParsing(t *testing.T) {
configParser := NewConfigParser()
if configParser.options.MaintenanceMessage() != "Miniflux is currently under maintenance" {
t.Fatalf("Expected MAINTENANCE_MESSAGE to have default value")
}
if err := configParser.parseLines([]string{"MAINTENANCE_MESSAGE=System upgrade in progress"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if configParser.options.MaintenanceMessage() != "System upgrade in progress" {
t.Fatalf("Expected MAINTENANCE_MESSAGE to be 'System upgrade in progress'")
}
}
func TestMaintenanceModeOptionParsing(t *testing.T) {
configParser := NewConfigParser()
if configParser.options.MaintenanceMode() {
t.Fatal("Expected MAINTENANCE_MODE to be disabled by default")
}
if configParser.options.HasMaintenanceMode() {
t.Fatal("Expected HAS_MAINTENANCE_MODE to be disabled by default")
}
if err := configParser.parseLines([]string{"MAINTENANCE_MODE=1"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if !configParser.options.MaintenanceMode() {
t.Fatal("Expected MAINTENANCE_MODE to be enabled")
}
if !configParser.options.HasMaintenanceMode() {
t.Fatal("Expected HAS_MAINTENANCE_MODE to be enabled")
}
if err := configParser.parseLines([]string{"MAINTENANCE_MODE=0"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if configParser.options.MaintenanceMode() {
t.Fatal("Expected MAINTENANCE_MODE to be disabled")
}
if configParser.options.HasMaintenanceMode() {
t.Fatal("Expected HAS_MAINTENANCE_MODE to be disabled")
}
}
func TestMediaProxyModeOptionParsing(t *testing.T) {
configParser := NewConfigParser()
if configParser.options.MediaProxyMode() != "http-only" {
t.Fatalf("Expected MEDIA_PROXY_MODE to be 'http-only' by default")
}
if err := configParser.parseLines([]string{"MEDIA_PROXY_MODE=all"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if configParser.options.MediaProxyMode() != "all" {
t.Fatalf("Expected MEDIA_PROXY_MODE to be 'all'")
}
}
func TestMetricsCollectorOptionParsing(t *testing.T) {
configParser := NewConfigParser()
if configParser.options.MetricsCollector() {
t.Fatal("Expected METRICS_COLLECTOR to be disabled by default")
}
if configParser.options.HasMetricsCollector() {
t.Fatal("Expected HAS_METRICS_COLLECTOR to be disabled by default")
}
if err := configParser.parseLines([]string{"METRICS_COLLECTOR=1"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if !configParser.options.MetricsCollector() {
t.Fatal("Expected METRICS_COLLECTOR to be enabled")
}
if !configParser.options.HasMetricsCollector() {
t.Fatal("Expected HAS_METRICS_COLLECTOR to be enabled")
}
if err := configParser.parseLines([]string{"METRICS_COLLECTOR=0"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if configParser.options.MetricsCollector() {
t.Fatal("Expected METRICS_COLLECTOR to be disabled")
}
if configParser.options.HasMetricsCollector() {
t.Fatal("Expected HAS_METRICS_COLLECTOR to be disabled")
}
}
func TestMetricsPasswordOptionParsing(t *testing.T) {
configParser := NewConfigParser()
if configParser.options.MetricsPassword() != "" {
t.Fatalf("Expected METRICS_PASSWORD to be empty by default")
}
if err := configParser.parseLines([]string{"METRICS_PASSWORD=secret123"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if configParser.options.MetricsPassword() != "secret123" {
t.Fatalf("Expected METRICS_PASSWORD to be 'secret123'")
}
}
func TestMetricsUsernameOptionParsing(t *testing.T) {
configParser := NewConfigParser()
if configParser.options.MetricsUsername() != "" {
t.Fatalf("Expected METRICS_USERNAME to be empty by default")
}
if err := configParser.parseLines([]string{"METRICS_USERNAME=metrics_user"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if configParser.options.MetricsUsername() != "metrics_user" {
t.Fatalf("Expected METRICS_USERNAME to be 'metrics_user'")
}
}
func TestOAuth2ClientIDOptionParsing(t *testing.T) {
configParser := NewConfigParser()
if configParser.options.OAuth2ClientID() != "" {
t.Fatalf("Expected OAUTH2_CLIENT_ID to be empty by default")
}
if err := configParser.parseLines([]string{"OAUTH2_CLIENT_ID=client123"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if configParser.options.OAuth2ClientID() != "client123" {
t.Fatalf("Expected OAUTH2_CLIENT_ID to be 'client123'")
}
}
func TestOAuth2ClientSecretOptionParsing(t *testing.T) {
configParser := NewConfigParser()
if configParser.options.OAuth2ClientSecret() != "" {
t.Fatalf("Expected OAUTH2_CLIENT_SECRET to be empty by default")
}
if err := configParser.parseLines([]string{"OAUTH2_CLIENT_SECRET=secret456"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if configParser.options.OAuth2ClientSecret() != "secret456" {
t.Fatalf("Expected OAUTH2_CLIENT_SECRET to be 'secret456'")
}
}
func TestOAuth2OIDCDiscoveryEndpointOptionParsing(t *testing.T) {
configParser := NewConfigParser()
if configParser.options.OAuth2OIDCDiscoveryEndpoint() != "" {
t.Fatalf("Expected OAUTH2_OIDC_DISCOVERY_ENDPOINT to be empty by default")
}
if err := configParser.parseLines([]string{"OAUTH2_OIDC_DISCOVERY_ENDPOINT=https://example.com/.well-known/openid_configuration"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if configParser.options.OAuth2OIDCDiscoveryEndpoint() != "https://example.com/.well-known/openid_configuration" {
t.Fatalf("Expected OAUTH2_OIDC_DISCOVERY_ENDPOINT to be 'https://example.com/.well-known/openid_configuration'")
}
}
func TestOAuth2OIDCProviderNameOptionParsing(t *testing.T) {
configParser := NewConfigParser()
if configParser.options.OAuth2OIDCProviderName() != "OpenID Connect" {
t.Fatalf("Expected OAUTH2_OIDC_PROVIDER_NAME to be 'OpenID Connect' by default")
}
if err := configParser.parseLines([]string{"OAUTH2_OIDC_PROVIDER_NAME=My Provider"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if configParser.options.OAuth2OIDCProviderName() != "My Provider" {
t.Fatalf("Expected OAUTH2_OIDC_PROVIDER_NAME to be 'My Provider'")
}
}
func TestOAuth2ProviderOptionParsing(t *testing.T) {
configParser := NewConfigParser()
if configParser.options.OAuth2Provider() != "" {
t.Fatal("Expected OAUTH2_PROVIDER to be empty by default")
}
if err := configParser.parseLines([]string{"OAUTH2_PROVIDER=google"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if configParser.options.OAuth2Provider() != "google" {
t.Fatal("Expected OAUTH2_PROVIDER to be 'google'")
}
if err := configParser.parseLines([]string{"OAUTH2_PROVIDER=oidc"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if configParser.options.OAuth2Provider() != "oidc" {
t.Fatal("Expected OAUTH2_PROVIDER to be 'oidc'")
}
if err := configParser.parseLines([]string{"OAUTH2_PROVIDER=invalid"}); err == nil {
t.Fatal("Expected error for invalid OAUTH2_PROVIDER value")
}
}
func TestOAuth2RedirectURLOptionParsing(t *testing.T) {
configParser := NewConfigParser()
if configParser.options.OAuth2RedirectURL() != "" {
t.Fatalf("Expected OAUTH2_REDIRECT_URL to be empty by default")
}
if err := configParser.parseLines([]string{"OAUTH2_REDIRECT_URL=https://example.com/callback"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if configParser.options.OAuth2RedirectURL() != "https://example.com/callback" {
t.Fatalf("Expected OAUTH2_REDIRECT_URL to be 'https://example.com/callback'")
}
}
func TestOAuth2UserCreationOptionParsing(t *testing.T) {
configParser := NewConfigParser()
if configParser.options.OAuth2UserCreation() {
t.Fatal("Expected OAUTH2_USER_CREATION to be disabled by default")
}
if configParser.options.IsOAuth2UserCreationAllowed() {
t.Fatal("Expected OAUTH2_USER_CREATION to be disabled by default")
}
if err := configParser.parseLines([]string{"OAUTH2_USER_CREATION=1"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if !configParser.options.OAuth2UserCreation() {
t.Fatal("Expected OAUTH2_USER_CREATION to be enabled")
}
if !configParser.options.IsOAuth2UserCreationAllowed() {
t.Fatal("Expected OAUTH2_USER_CREATION to be enabled")
}
if err := configParser.parseLines([]string{"OAUTH2_USER_CREATION=0"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if configParser.options.OAuth2UserCreation() {
t.Fatal("Expected OAUTH2_USER_CREATION to be disabled")
}
if configParser.options.IsOAuth2UserCreationAllowed() {
t.Fatal("Expected OAUTH2_USER_CREATION to be disabled")
}
}
func TestPollingLimitPerHostOptionParsing(t *testing.T) {
configParser := NewConfigParser()
if configParser.options.PollingLimitPerHost() != 0 {
t.Fatalf("Expected POLLING_LIMIT_PER_HOST to be 0 by default")
}
if err := configParser.parseLines([]string{"POLLING_LIMIT_PER_HOST=5"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if configParser.options.PollingLimitPerHost() != 5 {
t.Fatalf("Expected POLLING_LIMIT_PER_HOST to be 5")
}
}
func TestPollingParsingErrorLimitOptionParsing(t *testing.T) {
configParser := NewConfigParser()
if configParser.options.PollingParsingErrorLimit() != 3 {
t.Fatalf("Expected POLLING_PARSING_ERROR_LIMIT to be 3 by default")
}
if err := configParser.parseLines([]string{"POLLING_PARSING_ERROR_LIMIT=5"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if configParser.options.PollingParsingErrorLimit() != 5 {
t.Fatalf("Expected POLLING_PARSING_ERROR_LIMIT to be 5")
}
}
func TestPollingSchedulerOptionParsing(t *testing.T) {
configParser := NewConfigParser()
if configParser.options.PollingScheduler() != "round_robin" {
t.Fatalf("Expected POLLING_SCHEDULER to be 'round_robin' by default")
}
if err := configParser.parseLines([]string{"POLLING_SCHEDULER=entry_frequency"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if configParser.options.PollingScheduler() != "entry_frequency" {
t.Fatalf("Expected POLLING_SCHEDULER to be 'entry_frequency'")
}
}
func TestPortOptionParsing(t *testing.T) {
configParser := NewConfigParser()
if configParser.options.Port() != "" {
t.Fatalf("Expected PORT to be empty by default")
}
if err := configParser.parseLines([]string{"PORT=1234"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if configParser.options.Port() != "1234" {
t.Fatalf("Expected PORT to be '1234'")
}
addresses := configParser.options.ListenAddr()
if len(addresses) != 1 || addresses[0] != ":1234" {
t.Fatalf("Expected LISTEN_ADDR to be ':1234'")
}
}
func TestRunMigrationsOptionParsing(t *testing.T) {
configParser := NewConfigParser()
if configParser.options.RunMigrations() {
t.Fatalf("Expected RUN_MIGRATIONS to be disabled by default")
}
if err := configParser.parseLines([]string{"RUN_MIGRATIONS=1"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if !configParser.options.RunMigrations() {
t.Fatalf("Expected RUN_MIGRATIONS to be enabled")
}
if err := configParser.parseLines([]string{"RUN_MIGRATIONS=0"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if configParser.options.RunMigrations() {
t.Fatalf("Expected RUN_MIGRATIONS to be disabled")
}
}
func TestSchedulerEntryFrequencyFactorOptionParsing(t *testing.T) {
configParser := NewConfigParser()
if configParser.options.SchedulerEntryFrequencyFactor() != 1 {
t.Fatalf("Expected SCHEDULER_ENTRY_FREQUENCY_FACTOR to be 1 by default")
}
if err := configParser.parseLines([]string{"SCHEDULER_ENTRY_FREQUENCY_FACTOR=2"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if configParser.options.SchedulerEntryFrequencyFactor() != 2 {
t.Fatalf("Expected SCHEDULER_ENTRY_FREQUENCY_FACTOR to be 2")
}
}
func TestYouTubeEmbedUrlOverrideOptionParsing(t *testing.T) {
configParser := NewConfigParser()
// Test default value
if configParser.options.YouTubeEmbedUrlOverride() != "https://www.youtube-nocookie.com/embed/" {
t.Fatal("Expected YOUTUBE_EMBED_URL_OVERRIDE to have default value")
}
if configParser.options.YouTubeEmbedDomain() != "www.youtube-nocookie.com" {
t.Fatal("Expected YOUTUBE_EMBED_DOMAIN to be 'www.youtube-nocookie.com' by default")
}
// Test custom value
if err := configParser.parseLines([]string{"YOUTUBE_EMBED_URL_OVERRIDE=https://custom.youtube.com/embed/"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if configParser.options.YouTubeEmbedUrlOverride() != "https://custom.youtube.com/embed/" {
t.Fatal("Expected YOUTUBE_EMBED_URL_OVERRIDE to be 'https://custom.youtube.com/embed/'")
}
if configParser.options.YouTubeEmbedDomain() != "custom.youtube.com" {
t.Fatal("Expected YOUTUBE_EMBED_DOMAIN to be 'custom.youtube.com'")
}
// Test empty value resets to default
configParser = NewConfigParser()
if err := configParser.parseLines([]string{"YOUTUBE_EMBED_URL_OVERRIDE="}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if configParser.options.YouTubeEmbedUrlOverride() != "https://www.youtube-nocookie.com/embed/" {
t.Fatal("Expected YOUTUBE_EMBED_URL_OVERRIDE to have default value")
}
// Test invalid value
configParser = NewConfigParser()
if err := configParser.parseLines([]string{"YOUTUBE_EMBED_URL_OVERRIDE=http://example.com/%"}); err == nil {
t.Fatal("Expected error for invalid YOUTUBE_EMBED_URL_OVERRIDE")
}
}
func TestCleanupArchiveReadIntervalOptionParsing(t *testing.T) {
configParser := NewConfigParser()
if configParser.options.CleanupArchiveReadInterval().Hours() != 24*60 {
t.Fatalf("Expected CLEANUP_ARCHIVE_READ_DAYS to be 60 days by default")
}
if err := configParser.parseLines([]string{"CLEANUP_ARCHIVE_READ_DAYS=30"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if configParser.options.CleanupArchiveReadInterval().Hours() != 24*30 {
t.Fatalf("Expected CLEANUP_ARCHIVE_READ_DAYS to be 30 days")
}
}
func TestCleanupArchiveUnreadIntervalOptionParsing(t *testing.T) {
configParser := NewConfigParser()
if configParser.options.CleanupArchiveUnreadInterval().Hours() != 24*180 {
t.Fatalf("Expected CLEANUP_ARCHIVE_UNREAD_DAYS to be 180 days by default")
}
if err := configParser.parseLines([]string{"CLEANUP_ARCHIVE_UNREAD_DAYS=90"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if configParser.options.CleanupArchiveUnreadInterval().Hours() != 24*90 {
t.Fatalf("Expected CLEANUP_ARCHIVE_UNREAD_DAYS to be 90 days")
}
}
func TestCleanupFrequencyOptionParsing(t *testing.T) {
configParser := NewConfigParser()
if configParser.options.CleanupFrequency().Hours() != 24 {
t.Fatalf("Expected CLEANUP_FREQUENCY_HOURS to be 24 hours by default")
}
if err := configParser.parseLines([]string{"CLEANUP_FREQUENCY_HOURS=12"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if configParser.options.CleanupFrequency().Hours() != 12 {
t.Fatalf("Expected CLEANUP_FREQUENCY_HOURS to be 12 hours")
}
}
func TestCleanupRemoveSessionsIntervalOptionParsing(t *testing.T) {
configParser := NewConfigParser()
if configParser.options.CleanupRemoveSessionsInterval().Hours() != 24*30 {
t.Fatalf("Expected CLEANUP_REMOVE_SESSIONS_DAYS to be 30 days by default")
}
if err := configParser.parseLines([]string{"CLEANUP_REMOVE_SESSIONS_DAYS=14"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if configParser.options.CleanupRemoveSessionsInterval().Hours() != 24*14 {
t.Fatalf("Expected CLEANUP_REMOVE_SESSIONS_DAYS to be 14 days")
}
}
func TestDatabaseConnectionLifetimeOptionParsing(t *testing.T) {
configParser := NewConfigParser()
if configParser.options.DatabaseConnectionLifetime().Minutes() != 5 {
t.Fatalf("Expected DATABASE_CONNECTION_LIFETIME to be 5 minutes by default")
}
if err := configParser.parseLines([]string{"DATABASE_CONNECTION_LIFETIME=10"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if configParser.options.DatabaseConnectionLifetime().Minutes() != 10 {
t.Fatalf("Expected DATABASE_CONNECTION_LIFETIME to be 10 minutes")
}
}
func TestFilterEntryMaxAgeDaysOptionParsing(t *testing.T) {
configParser := NewConfigParser()
if configParser.options.FilterEntryMaxAgeDays() != 0 {
t.Fatalf("Expected FILTER_ENTRY_MAX_AGE_DAYS to be 0 by default")
}
if err := configParser.parseLines([]string{"FILTER_ENTRY_MAX_AGE_DAYS=7"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if configParser.options.FilterEntryMaxAgeDays() != 7 {
t.Fatalf("Expected FILTER_ENTRY_MAX_AGE_DAYS to be 7 days")
}
}
func TestForceRefreshIntervalOptionParsing(t *testing.T) {
configParser := NewConfigParser()
if configParser.options.ForceRefreshInterval().Minutes() != 30 {
t.Fatalf("Expected FORCE_REFRESH_INTERVAL to be 30 minutes by default")
}
if err := configParser.parseLines([]string{"FORCE_REFRESH_INTERVAL=15"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if configParser.options.ForceRefreshInterval().Minutes() != 15 {
t.Fatalf("Expected FORCE_REFRESH_INTERVAL to be 15 minutes")
}
}
func TestHTTPClientProxiesOptionParsing(t *testing.T) {
configParser := NewConfigParser()
if configParser.options.HasHTTPClientProxiesConfigured() {
t.Fatalf("Expected HTTP_CLIENT_PROXIES to be empty by default")
}
if err := configParser.parseLines([]string{"HTTP_CLIENT_PROXIES=proxy1.example.com:8080,proxy2.example.com:8080"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if !configParser.options.HasHTTPClientProxiesConfigured() {
t.Fatalf("Expected HTTP_CLIENT_PROXIES to be configured")
}
proxies := configParser.options.HTTPClientProxies()
if len(proxies) != 2 || proxies[0] != "proxy1.example.com:8080" || proxies[1] != "proxy2.example.com:8080" {
t.Fatalf("Expected HTTP_CLIENT_PROXIES to contain two proxies")
}
}
func TestHTTPClientProxyOptionParsing(t *testing.T) {
configParser := NewConfigParser()
if configParser.options.HTTPClientProxyURL() != nil {
t.Fatal("Expected HTTP_CLIENT_PROXY to be nil by default")
}
if configParser.options.HasHTTPClientProxyURLConfigured() {
t.Fatal("Expected HAS_HTTP_CLIENT_PROXY to be disabled by default")
}
if err := configParser.parseLines([]string{"HTTP_CLIENT_PROXY=http://proxy.example.com:8080"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
proxyURL := configParser.options.HTTPClientProxyURL()
if proxyURL == nil || proxyURL.String() != "http://proxy.example.com:8080" {
t.Fatal("Expected HTTP_CLIENT_PROXY to be 'http://proxy.example.com:8080'")
}
if !configParser.options.HasHTTPClientProxyURLConfigured() {
t.Fatal("Expected HAS_HTTP_CLIENT_PROXY to be enabled")
}
}
func TestHTTPClientTimeoutOptionParsing(t *testing.T) {
configParser := NewConfigParser()
if configParser.options.HTTPClientTimeout().Seconds() != 20 {
t.Fatalf("Expected HTTP_CLIENT_TIMEOUT to be 20 seconds by default")
}
if err := configParser.parseLines([]string{"HTTP_CLIENT_TIMEOUT=30"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if configParser.options.HTTPClientTimeout().Seconds() != 30 {
t.Fatalf("Expected HTTP_CLIENT_TIMEOUT to be 30 seconds")
}
}
func TestIconFetchAllowPrivateNetworksOptionParsing(t *testing.T) {
configParser := NewConfigParser()
if configParser.options.IconFetchAllowPrivateNetworks() {
t.Fatalf("Expected ICON_FETCH_ALLOW_PRIVATE_NETWORKS to be disabled by default")
}
if err := configParser.parseLines([]string{"ICON_FETCH_ALLOW_PRIVATE_NETWORKS=1"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if !configParser.options.IconFetchAllowPrivateNetworks() {
t.Fatalf("Expected ICON_FETCH_ALLOW_PRIVATE_NETWORKS to be enabled")
}
if err := configParser.parseLines([]string{"ICON_FETCH_ALLOW_PRIVATE_NETWORKS=0"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if configParser.options.IconFetchAllowPrivateNetworks() {
t.Fatalf("Expected ICON_FETCH_ALLOW_PRIVATE_NETWORKS to be disabled")
}
}
func TestHTTPServerTimeoutOptionParsing(t *testing.T) {
configParser := NewConfigParser()
if configParser.options.HTTPServerTimeout().Seconds() != 300 {
t.Fatal("Expected HTTP_SERVER_TIMEOUT to be 300 seconds by default")
}
if err := configParser.parseLines([]string{"HTTP_SERVER_TIMEOUT=60"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if configParser.options.HTTPServerTimeout().Seconds() != 60 {
t.Fatal("Expected HTTP_SERVER_TIMEOUT to be 60 seconds")
}
}
func TestListenAddrOptionParsing(t *testing.T) {
configParser := NewConfigParser()
addrs := configParser.options.ListenAddr()
if len(addrs) != 1 || addrs[0] != "127.0.0.1:8080" {
t.Fatalf("Expected LISTEN_ADDR to be '127.0.0.1:8080' by default")
}
if err := configParser.parseLines([]string{"LISTEN_ADDR=0.0.0.0:8080,127.0.0.1:8081,/unix.socket"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
addrs = configParser.options.ListenAddr()
if len(addrs) != 3 || addrs[0] != "0.0.0.0:8080" || addrs[1] != "127.0.0.1:8081" || addrs[2] != "/unix.socket" {
t.Fatalf("Expected LISTEN_ADDR to contain two addresses")
}
}
func TestMediaCustomProxyURLOptionParsing(t *testing.T) {
configParser := NewConfigParser()
if configParser.options.MediaCustomProxyURL() != nil {
t.Fatalf("Expected MEDIA_PROXY_CUSTOM_URL to be nil by default")
}
if err := configParser.parseLines([]string{"MEDIA_PROXY_CUSTOM_URL=https://proxy.example.com"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
proxyURL := configParser.options.MediaCustomProxyURL()
if proxyURL == nil || proxyURL.String() != "https://proxy.example.com" {
t.Fatalf("Expected MEDIA_PROXY_CUSTOM_URL to be 'https://proxy.example.com'")
}
}
func TestMediaProxyHTTPClientTimeoutOptionParsing(t *testing.T) {
configParser := NewConfigParser()
if configParser.options.MediaProxyHTTPClientTimeout().Seconds() != 120 {
t.Fatalf("Expected MEDIA_PROXY_HTTP_CLIENT_TIMEOUT to be 120 seconds by default")
}
if err := configParser.parseLines([]string{"MEDIA_PROXY_HTTP_CLIENT_TIMEOUT=60"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if configParser.options.MediaProxyHTTPClientTimeout().Seconds() != 60 {
t.Fatalf("Expected MEDIA_PROXY_HTTP_CLIENT_TIMEOUT to be 60 seconds")
}
}
func TestMediaProxyAllowPrivateNetworksOptionParsing(t *testing.T) {
configParser := NewConfigParser()
if configParser.options.MediaProxyAllowPrivateNetworks() {
t.Fatalf("Expected MEDIA_PROXY_ALLOW_PRIVATE_NETWORKS to be disabled by default")
}
if err := configParser.parseLines([]string{"MEDIA_PROXY_ALLOW_PRIVATE_NETWORKS=1"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if !configParser.options.MediaProxyAllowPrivateNetworks() {
t.Fatalf("Expected MEDIA_PROXY_ALLOW_PRIVATE_NETWORKS to be enabled")
}
if err := configParser.parseLines([]string{"MEDIA_PROXY_ALLOW_PRIVATE_NETWORKS=0"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if configParser.options.MediaProxyAllowPrivateNetworks() {
t.Fatalf("Expected MEDIA_PROXY_ALLOW_PRIVATE_NETWORKS to be disabled")
}
}
func TestMediaProxyPrivateKeyOptionParsing(t *testing.T) {
configParser := NewConfigParser()
if len(configParser.options.MediaProxyPrivateKey()) != 0 {
t.Fatalf("Expected MEDIA_PROXY_PRIVATE_KEY to be empty by default")
}
if err := configParser.parseLines([]string{"MEDIA_PROXY_PRIVATE_KEY=secret123"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
privateKey := configParser.options.MediaProxyPrivateKey()
if string(privateKey) != "secret123" {
t.Fatalf("Expected MEDIA_PROXY_PRIVATE_KEY to be 'secret123'")
}
}
func TestMediaProxyResourceTypesOptionParsing(t *testing.T) {
configParser := NewConfigParser()
resourceTypes := configParser.options.MediaProxyResourceTypes()
if len(resourceTypes) != 1 || resourceTypes[0] != "image" {
t.Fatalf("Expected MEDIA_PROXY_RESOURCE_TYPES to have default values")
}
if err := configParser.parseLines([]string{"MEDIA_PROXY_RESOURCE_TYPES=image,video"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
resourceTypes = configParser.options.MediaProxyResourceTypes()
if len(resourceTypes) != 2 || resourceTypes[0] != "image" || resourceTypes[1] != "video" {
t.Fatalf("Expected MEDIA_PROXY_RESOURCE_TYPES to contain image and video")
}
if err := configParser.parseLines([]string{"MEDIA_PROXY_RESOURCE_TYPES=image,invalid,video"}); err == nil {
t.Fatal("Expected error due to invalid resource type")
}
}
func TestMetricsAllowedNetworksOptionParsing(t *testing.T) {
configParser := NewConfigParser()
networks := configParser.options.MetricsAllowedNetworks()
if len(networks) != 1 || networks[0] != "127.0.0.1/8" {
t.Fatalf("Expected METRICS_ALLOWED_NETWORKS to have default values")
}
if err := configParser.parseLines([]string{"METRICS_ALLOWED_NETWORKS=10.0.0.0/8,192.168.0.0/16"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
networks = configParser.options.MetricsAllowedNetworks()
if len(networks) != 2 || networks[0] != "10.0.0.0/8" || networks[1] != "192.168.0.0/16" {
t.Fatalf("Expected METRICS_ALLOWED_NETWORKS to contain specified networks")
}
}
func TestMetricsRefreshIntervalOptionParsing(t *testing.T) {
configParser := NewConfigParser()
if configParser.options.MetricsRefreshInterval().Seconds() != 60 {
t.Fatalf("Expected METRICS_REFRESH_INTERVAL to be 60 seconds by default")
}
if err := configParser.parseLines([]string{"METRICS_REFRESH_INTERVAL=120"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if configParser.options.MetricsRefreshInterval().Seconds() != 120 {
t.Fatalf("Expected METRICS_REFRESH_INTERVAL to be 120 seconds")
}
}
func TestPollingFrequencyOptionParsing(t *testing.T) {
configParser := NewConfigParser()
if configParser.options.PollingFrequency().Minutes() != 60 {
t.Fatalf("Expected POLLING_FREQUENCY to be 60 minutes by default")
}
if err := configParser.parseLines([]string{"POLLING_FREQUENCY=30"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if configParser.options.PollingFrequency().Minutes() != 30 {
t.Fatalf("Expected POLLING_FREQUENCY to be 30 minutes")
}
}
func TestSchedulerEntryFrequencyMaxIntervalOptionParsing(t *testing.T) {
configParser := NewConfigParser()
if configParser.options.SchedulerEntryFrequencyMaxInterval().Hours() != 24 {
t.Fatalf("Expected SCHEDULER_ENTRY_FREQUENCY_MAX_INTERVAL to be 24 hours by default")
}
if err := configParser.parseLines([]string{"SCHEDULER_ENTRY_FREQUENCY_MAX_INTERVAL=720"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if configParser.options.SchedulerEntryFrequencyMaxInterval().Hours() != 12 {
t.Fatalf("Expected SCHEDULER_ENTRY_FREQUENCY_MAX_INTERVAL to be 12 hours")
}
}
func TestSchedulerEntryFrequencyMinIntervalOptionParsing(t *testing.T) {
configParser := NewConfigParser()
if configParser.options.SchedulerEntryFrequencyMinInterval().Minutes() != 5 {
t.Fatalf("Expected SCHEDULER_ENTRY_FREQUENCY_MIN_INTERVAL to be 5 minutes by default")
}
if err := configParser.parseLines([]string{"SCHEDULER_ENTRY_FREQUENCY_MIN_INTERVAL=10"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if configParser.options.SchedulerEntryFrequencyMinInterval().Minutes() != 10 {
t.Fatalf("Expected SCHEDULER_ENTRY_FREQUENCY_MIN_INTERVAL to be 10 minutes")
}
}
func TestSchedulerRoundRobinMaxIntervalOptionParsing(t *testing.T) {
configParser := NewConfigParser()
if configParser.options.SchedulerRoundRobinMaxInterval().Hours() != 24 {
t.Fatalf("Expected SCHEDULER_ROUND_ROBIN_MAX_INTERVAL to be 24 hours by default")
}
if err := configParser.parseLines([]string{"SCHEDULER_ROUND_ROBIN_MAX_INTERVAL=60"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if configParser.options.SchedulerRoundRobinMaxInterval().Hours() != 1 {
t.Fatalf("Expected SCHEDULER_ROUND_ROBIN_MAX_INTERVAL to be 1 hour")
}
}
func TestSchedulerRoundRobinMinIntervalOptionParsing(t *testing.T) {
configParser := NewConfigParser()
if configParser.options.SchedulerRoundRobinMinInterval().Minutes() != 60 {
t.Fatalf("Expected SCHEDULER_ROUND_ROBIN_MIN_INTERVAL to be 60 minutes by default")
}
if err := configParser.parseLines([]string{"SCHEDULER_ROUND_ROBIN_MIN_INTERVAL=30"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if configParser.options.SchedulerRoundRobinMinInterval().Minutes() != 30 {
t.Fatalf("Expected SCHEDULER_ROUND_ROBIN_MIN_INTERVAL to be 30 minutes")
}
}
func TestTrustedReverseProxyNetworksOptionParsing(t *testing.T) {
configParser := NewConfigParser()
// Test default value
defaultNetworks := configParser.options.TrustedReverseProxyNetworks()
if len(defaultNetworks) != 0 {
t.Fatalf("Expected 0 allowed networks by default, got %d", len(defaultNetworks))
}
// Test valid value
if err := configParser.parseLines([]string{"TRUSTED_REVERSE_PROXY_NETWORKS=10.0.0.0/8,192.168.1.0/24"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
allowedNetworks := configParser.options.TrustedReverseProxyNetworks()
if len(allowedNetworks) != 2 {
t.Fatalf("Expected 2 allowed networks, got %d", len(allowedNetworks))
}
if !slices.Contains(allowedNetworks, "10.0.0.0/8") {
t.Errorf("Expected 10.0.0.0/8 in allowed networks")
}
if !slices.Contains(allowedNetworks, "192.168.1.0/24") {
t.Errorf("Expected 192.168.1.0/24 in allowed networks")
}
}
func TestYouTubeEmbedDomainOptionParsing(t *testing.T) {
configParser := NewConfigParser()
if configParser.options.YouTubeEmbedDomain() != "www.youtube-nocookie.com" {
t.Fatalf("Expected YouTubeEmbedDomain to be 'www.youtube-nocookie.com' by default")
}
// YouTube embed domain is derived from YOUTUBE_EMBED_URL_OVERRIDE
if err := configParser.parseLines([]string{"YOUTUBE_EMBED_URL_OVERRIDE=https://custom.youtube.com/embed/"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if configParser.options.YouTubeEmbedDomain() != "custom.youtube.com" {
t.Fatalf("Expected YouTubeEmbedDomain to be 'custom.youtube.com'")
}
}
func TestSetLogLevelFunction(t *testing.T) {
configParser := NewConfigParser()
// Test default log level
if configParser.options.LogLevel() != "info" {
t.Fatalf("Expected LOG_LEVEL to be 'info' by default, got '%s'", configParser.options.LogLevel())
}
// Test setting log level to debug
configParser.options.SetLogLevel("debug")
if configParser.options.LogLevel() != "debug" {
t.Fatalf("Expected LOG_LEVEL to be 'debug' after SetLogLevel('debug'), got '%s'", configParser.options.LogLevel())
}
if configParser.options.options["LOG_LEVEL"].rawValue != "debug" {
t.Fatalf("Expected LOG_LEVEL RawValue to be 'debug', got '%s'", configParser.options.options["LOG_LEVEL"].rawValue)
}
// Test setting log level to warning
configParser.options.SetLogLevel("warning")
if configParser.options.LogLevel() != "warning" {
t.Fatalf("Expected LOG_LEVEL to be 'warning' after SetLogLevel('warning'), got '%s'", configParser.options.LogLevel())
}
if configParser.options.options["LOG_LEVEL"].rawValue != "warning" {
t.Fatalf("Expected LOG_LEVEL RawValue to be 'warning', got '%s'", configParser.options.options["LOG_LEVEL"].rawValue)
}
}
func TestSetHTTPSValueFunction(t *testing.T) {
configParser := NewConfigParser()
// Test setting HTTPS to true
configParser.options.SetHTTPSValue(true)
if !configParser.options.HTTPS() {
t.Fatalf("Expected HTTPS to be true after SetHTTPSValue(true)")
}
// Test setting HTTPS to false
configParser.options.SetHTTPSValue(false)
if configParser.options.HTTPS() {
t.Fatalf("Expected HTTPS to be false after SetHTTPSValue(false)")
}
// Test setting HTTPS to true again
configParser.options.SetHTTPSValue(true)
if !configParser.options.HTTPS() {
t.Fatalf("Expected HTTPS to be true after second SetHTTPSValue(true)")
}
}
func TestConfigMap(t *testing.T) {
configMap := NewConfigOptions().ConfigMap(false)
if len(configMap) == 0 {
t.Fatal("Expected ConfigMap to contain configuration options")
}
// The first option should be "ADMIN_PASSWORD"
if configMap[0].Key != "ADMIN_PASSWORD" {
t.Fatalf("Expected first config option to be 'ADMIN_PASSWORD', got '%s'", configMap[0].Key)
}
}
func TestConfigMapWithRedactedSecrets(t *testing.T) {
configParser := NewConfigParser()
if err := configParser.parseLines([]string{"ADMIN_PASSWORD=secret123"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
configMap := configParser.options.ConfigMap(true)
if len(configMap) == 0 {
t.Fatal("Expected ConfigMap to contain configuration options")
}
// The first option should be "ADMIN_PASSWORD"
if configMap[0].Key != "ADMIN_PASSWORD" {
t.Fatalf("Expected first config option to be 'ADMIN_PASSWORD', got '%s'", configMap[0].Key)
}
// The value should be redacted
if configMap[0].Value != "" {
t.Fatalf("Expected ADMIN_PASSWORD value to be redacted, got '%s'", configMap[0].Value)
}
}
v2-2.2.16/internal/config/parser.go 0000664 0000000 0000000 00000017027 15127074645 0017057 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package config // import "miniflux.app/v2/internal/config"
import (
"bufio"
"bytes"
"crypto/rand"
"errors"
"fmt"
"io"
"net/url"
"os"
"strconv"
"strings"
"time"
)
type configParser struct {
options *configOptions
}
func NewConfigParser() *configParser {
return &configParser{
options: NewConfigOptions(),
}
}
func (cp *configParser) ParseEnvironmentVariables() (*configOptions, error) {
if err := cp.parseLines(os.Environ()); err != nil {
return nil, err
}
return cp.options, nil
}
func (cp *configParser) ParseFile(filename string) (*configOptions, error) {
fp, err := os.Open(filename)
if err != nil {
return nil, err
}
defer fp.Close()
if err := cp.parseLines(parseFileContent(fp)); err != nil {
return nil, err
}
return cp.options, nil
}
func (cp *configParser) postParsing() error {
// Parse basePath and rootURL based on BASE_URL
baseURL := cp.options.options["BASE_URL"].parsedStringValue
baseURL = strings.TrimSuffix(baseURL, "/")
parsedURL, err := url.Parse(baseURL)
if err != nil {
return fmt.Errorf("invalid BASE_URL: %v", err)
}
scheme := strings.ToLower(parsedURL.Scheme)
if scheme != "https" && scheme != "http" {
return errors.New("BASE_URL scheme must be http or https")
}
cp.options.options["BASE_URL"].parsedStringValue = baseURL
cp.options.basePath = parsedURL.Path
parsedURL.Path = ""
cp.options.rootURL = parsedURL.String()
// Parse YouTube embed domain based on YOUTUBE_EMBED_URL_OVERRIDE
youTubeEmbedURLOverride := cp.options.options["YOUTUBE_EMBED_URL_OVERRIDE"].parsedStringValue
if youTubeEmbedURLOverride != "" {
parsedYouTubeEmbedURL, err := url.Parse(youTubeEmbedURLOverride)
if err != nil {
return fmt.Errorf("invalid YOUTUBE_EMBED_URL_OVERRIDE: %v", err)
}
cp.options.youTubeEmbedDomain = parsedYouTubeEmbedURL.Hostname()
}
// Generate a media proxy private key if not set
if len(cp.options.options["MEDIA_PROXY_PRIVATE_KEY"].parsedBytesValue) == 0 {
randomKey := make([]byte, 16)
rand.Read(randomKey)
cp.options.options["MEDIA_PROXY_PRIVATE_KEY"].parsedBytesValue = randomKey
}
// Override LISTEN_ADDR with PORT if set (for compatibility reasons)
if cp.options.Port() != "" {
cp.options.options["LISTEN_ADDR"].parsedStringList = []string{":" + cp.options.Port()}
cp.options.options["LISTEN_ADDR"].rawValue = ":" + cp.options.Port()
}
return nil
}
func (cp *configParser) parseLines(lines []string) error {
for lineNum, line := range lines {
key, value, ok := strings.Cut(line, "=")
if !ok {
return fmt.Errorf("unable to parse configuration, invalid format on line %d", lineNum)
}
key, value = strings.TrimSpace(key), strings.TrimSpace(value)
if err := cp.parseLine(key, value); err != nil {
return err
}
}
if err := cp.postParsing(); err != nil {
return err
}
return nil
}
func (cp *configParser) parseLine(key, value string) error {
field, exists := cp.options.options[key]
if !exists {
// Ignore unknown configuration keys to avoid parsing unrelated environment variables.
return nil
}
// Validate the option if a validator is provided
if field.validator != nil {
if err := field.validator(value); err != nil {
return fmt.Errorf("invalid value for key %s: %v", key, err)
}
}
// Convert the raw value based on its type
switch field.valueType {
case stringType:
field.parsedStringValue = parseStringValue(value, field.parsedStringValue)
field.rawValue = value
case stringListType:
field.parsedStringList = parseStringListValue(value, field.parsedStringList)
field.rawValue = value
case boolType:
parsedValue, err := parseBoolValue(value, field.parsedBoolValue)
if err != nil {
return fmt.Errorf("invalid boolean value for key %s: %v", key, err)
}
field.parsedBoolValue = parsedValue
field.rawValue = value
case intType:
field.parsedIntValue = parseIntValue(value, field.parsedIntValue)
field.rawValue = value
case int64Type:
field.parsedInt64Value = ParsedInt64Value(value, field.parsedInt64Value)
field.rawValue = value
case secondType:
field.parsedDuration = parseDurationValue(value, time.Second, field.parsedDuration)
field.rawValue = value
case minuteType:
field.parsedDuration = parseDurationValue(value, time.Minute, field.parsedDuration)
field.rawValue = value
case hourType:
field.parsedDuration = parseDurationValue(value, time.Hour, field.parsedDuration)
field.rawValue = value
case dayType:
field.parsedDuration = parseDurationValue(value, time.Hour*24, field.parsedDuration)
field.rawValue = value
case urlType:
parsedURL, err := parseURLValue(value, field.parsedURLValue)
if err != nil {
return fmt.Errorf("invalid URL for key %s: %v", key, err)
}
field.parsedURLValue = parsedURL
field.rawValue = value
case secretFileType:
secretValue, err := readSecretFileValue(value)
if err != nil {
return fmt.Errorf("error reading secret file for key %s: %v", key, err)
}
if field.targetKey != "" {
if targetField, ok := cp.options.options[field.targetKey]; ok {
targetField.parsedStringValue = secretValue
targetField.rawValue = secretValue
}
}
field.rawValue = value
case bytesType:
if value != "" {
field.parsedBytesValue = []byte(value)
field.rawValue = value
}
}
return nil
}
func parseStringValue(value string, fallback string) string {
if value == "" {
return fallback
}
return value
}
func parseBoolValue(value string, fallback bool) (bool, error) {
if value == "" {
return fallback, nil
}
value = strings.ToLower(value)
if value == "1" || value == "yes" || value == "true" || value == "on" {
return true, nil
}
if value == "0" || value == "no" || value == "false" || value == "off" {
return false, nil
}
return false, fmt.Errorf("invalid boolean value: %q", value)
}
func parseIntValue(value string, fallback int) int {
if value == "" {
return fallback
}
v, err := strconv.Atoi(value)
if err != nil {
return fallback
}
return v
}
func ParsedInt64Value(value string, fallback int64) int64 {
if value == "" {
return fallback
}
v, err := strconv.ParseInt(value, 10, 64)
if err != nil {
return fallback
}
return v
}
func parseStringListValue(value string, fallback []string) []string {
if value == "" {
return fallback
}
var strList []string
present := make(map[string]bool)
for item := range strings.SplitSeq(value, ",") {
if itemValue := strings.TrimSpace(item); itemValue != "" {
if !present[itemValue] {
present[itemValue] = true
strList = append(strList, itemValue)
}
}
}
return strList
}
func parseDurationValue(value string, unit time.Duration, fallback time.Duration) time.Duration {
if value == "" {
return fallback
}
v, err := strconv.Atoi(value)
if err != nil {
return fallback
}
return time.Duration(v) * unit
}
func parseURLValue(value string, fallback *url.URL) (*url.URL, error) {
if value == "" {
return fallback, nil
}
parsedURL, err := url.Parse(value)
if err != nil {
return fallback, err
}
return parsedURL, nil
}
func readSecretFileValue(filename string) (string, error) {
data, err := os.ReadFile(filename)
if err != nil {
return "", err
}
value := string(bytes.TrimSpace(data))
if value == "" {
return "", errors.New("secret file is empty")
}
return value, nil
}
func parseFileContent(r io.Reader) (lines []string) {
scanner := bufio.NewScanner(r)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if !strings.HasPrefix(line, "#") && strings.Index(line, "=") > 0 {
lines = append(lines, line)
}
}
return lines
}
v2-2.2.16/internal/config/parser_test.go 0000664 0000000 0000000 00000027116 15127074645 0020116 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package config // import "miniflux.app/v2/internal/config"
import (
"net/url"
"os"
"reflect"
"testing"
"time"
)
func TestParseStringValue(t *testing.T) {
// Test with non-empty value
result := parseStringValue("test", "fallback")
if result != "test" {
t.Errorf("Expected 'test', got '%s'", result)
}
// Test with empty value
result = parseStringValue("", "fallback")
if result != "fallback" {
t.Errorf("Expected 'fallback', got '%s'", result)
}
// Test with empty value and empty fallback
result = parseStringValue("", "")
if result != "" {
t.Errorf("Expected empty string, got '%s'", result)
}
}
func TestParseBoolValue(t *testing.T) {
// Test with empty value - should return fallback
result, err := parseBoolValue("", true)
if err != nil {
t.Errorf("Unexpected error: %v", err)
}
if result != true {
t.Errorf("Expected true, got %v", result)
}
// Test true values
trueValues := []string{"1", "yes", "true", "on", "YES", "TRUE", "ON"}
for _, value := range trueValues {
result, err := parseBoolValue(value, false)
if err != nil {
t.Errorf("Unexpected error for value '%s': %v", value, err)
}
if result != true {
t.Errorf("Expected true for '%s', got %v", value, result)
}
}
// Test false values
falseValues := []string{"0", "no", "false", "off", "NO", "FALSE", "OFF"}
for _, value := range falseValues {
result, err := parseBoolValue(value, true)
if err != nil {
t.Errorf("Unexpected error for value '%s': %v", value, err)
}
if result != false {
t.Errorf("Expected false for '%s', got %v", value, result)
}
}
// Test invalid value - should return error
_, err = parseBoolValue("invalid", false)
if err == nil {
t.Error("Expected error for invalid boolean value")
}
}
func TestParseIntValue(t *testing.T) {
// Test with empty value - should return fallback
result := parseIntValue("", 42)
if result != 42 {
t.Errorf("Expected 42, got %d", result)
}
// Test with valid integer
result = parseIntValue("123", 42)
if result != 123 {
t.Errorf("Expected 123, got %d", result)
}
// Test with invalid integer - should return fallback
result = parseIntValue("invalid", 42)
if result != 42 {
t.Errorf("Expected 42, got %d", result)
}
// Test with zero
result = parseIntValue("0", 42)
if result != 0 {
t.Errorf("Expected 0, got %d", result)
}
}
func TestParsedInt64Value(t *testing.T) {
// Test with empty value - should return fallback
result := ParsedInt64Value("", 42)
if result != 42 {
t.Errorf("Expected 42, got %d", result)
}
// Test with valid int64
result = ParsedInt64Value("9223372036854775807", 42)
if result != 9223372036854775807 {
t.Errorf("Expected 9223372036854775807, got %d", result)
}
// Test with invalid int64 - should return fallback
result = ParsedInt64Value("invalid", 42)
if result != 42 {
t.Errorf("Expected 42, got %d", result)
}
}
func TestParseStringListValue(t *testing.T) {
// Test with empty value - should return fallback
fallback := []string{"a", "b"}
result := parseStringListValue("", fallback)
if !reflect.DeepEqual(result, fallback) {
t.Errorf("Expected %v, got %v", fallback, result)
}
// Test with single value
result = parseStringListValue("item1", nil)
expected := []string{"item1"}
if !reflect.DeepEqual(result, expected) {
t.Errorf("Expected %v, got %v", expected, result)
}
// Test with multiple values
result = parseStringListValue("item1,item2,item3", nil)
expected = []string{"item1", "item2", "item3"}
if !reflect.DeepEqual(result, expected) {
t.Errorf("Expected %v, got %v", expected, result)
}
// Test with duplicates - should remove duplicates
result = parseStringListValue("item1,item2,item1", nil)
expected = []string{"item1", "item2"}
if !reflect.DeepEqual(result, expected) {
t.Errorf("Expected %v, got %v", expected, result)
}
// Test with spaces
result = parseStringListValue(" item1 , item2 , item3 ", nil)
expected = []string{"item1", "item2", "item3"}
if !reflect.DeepEqual(result, expected) {
t.Errorf("Expected %v, got %v", expected, result)
}
}
func TestParseDurationValue(t *testing.T) {
// Test with empty value - should return fallback
fallback := 5 * time.Second
result := parseDurationValue("", time.Second, fallback)
if result != fallback {
t.Errorf("Expected %v, got %v", fallback, result)
}
// Test with valid duration
result = parseDurationValue("30", time.Second, fallback)
expected := 30 * time.Second
if result != expected {
t.Errorf("Expected %v, got %v", expected, result)
}
// Test with minutes
result = parseDurationValue("5", time.Minute, fallback)
expected = 5 * time.Minute
if result != expected {
t.Errorf("Expected %v, got %v", expected, result)
}
// Test with invalid value - should return fallback
result = parseDurationValue("invalid", time.Second, fallback)
if result != fallback {
t.Errorf("Expected %v, got %v", fallback, result)
}
}
func TestParseURLValue(t *testing.T) {
// Test with empty value - should return fallback
fallbackURL, _ := url.Parse("https://fallback.com")
result, err := parseURLValue("", fallbackURL)
if err != nil {
t.Errorf("Unexpected error: %v", err)
}
if result != fallbackURL {
t.Errorf("Expected %v, got %v", fallbackURL, result)
}
// Test with valid URL
result, err = parseURLValue("https://example.com", nil)
if err != nil {
t.Errorf("Unexpected error: %v", err)
}
if result.String() != "https://example.com" {
t.Errorf("Expected https://example.com, got %s", result.String())
}
// Test with invalid URL - should return fallback and error
result, err = parseURLValue("://invalid", fallbackURL)
if err == nil {
t.Error("Expected error for invalid URL")
}
if result != fallbackURL {
t.Errorf("Expected fallback URL, got %v", result)
}
}
func TestConfigFileParsing(t *testing.T) {
fileContent := `
# This is a comment
LOG_FILE=miniflux.log
LOG_DATE_TIME=1
LOG_FORMAT=json
LISTEN_ADDR=:8080,:8443
`
// Write a temporary config file and parse it
tmpFile, err := os.CreateTemp("", "miniflux-*.txt")
if err != nil {
t.Fatalf("Failed to create temporary file: %v", err)
}
defer os.Remove(tmpFile.Name())
defer tmpFile.Close()
filename := tmpFile.Name()
if _, err := tmpFile.WriteString(fileContent); err != nil {
t.Fatalf("Failed to write to temporary file: %v", err)
}
configParser := NewConfigParser()
configOptions, err := configParser.ParseFile(filename)
if err != nil {
t.Fatalf("Unexpected parsing error: %v", err)
}
if configOptions.LogFile() != "miniflux.log" {
t.Fatalf("Unexpected log file, got %q", configOptions.LogFile())
}
if configOptions.LogDateTime() != true {
t.Fatalf("Unexpected log datetime, got %v", configOptions.LogDateTime())
}
if configOptions.LogFormat() != "json" {
t.Fatalf("Unexpected log format, got %q", configOptions.LogFormat())
}
if configOptions.LogLevel() != "info" {
t.Fatalf("Unexpected log level, got %q", configOptions.LogLevel())
}
if len(configOptions.ListenAddr()) != 2 || configOptions.ListenAddr()[0] != ":8080" || configOptions.ListenAddr()[1] != ":8443" {
t.Fatalf("Unexpected listen addresses, got %v", configOptions.ListenAddr())
}
}
func TestConfigFileParsingWithIncorrectKeyValuePair(t *testing.T) {
fileContent := `
LOG_FILE=miniflux.log
INVALID_LINE
`
// Write a temporary config file and parse it
tmpFile, err := os.CreateTemp("", "miniflux-*.txt")
if err != nil {
t.Fatalf("Failed to create temporary file: %v", err)
}
defer os.Remove(tmpFile.Name())
defer tmpFile.Close()
filename := tmpFile.Name()
if _, err := tmpFile.WriteString(fileContent); err != nil {
t.Fatalf("Failed to write to temporary file: %v", err)
}
configParser := NewConfigParser()
_, err = configParser.ParseFile(filename)
if err != nil {
t.Fatal("Invalid lines should be ignored, but got error:", err)
}
}
func TestParseAdminPasswordFileOption(t *testing.T) {
tmpFile, err := os.CreateTemp("", "password-*.txt")
if err != nil {
t.Fatalf("Failed to create temporary file: %v", err)
}
defer os.Remove(tmpFile.Name())
defer tmpFile.Close()
password := "supersecret"
if _, err := tmpFile.WriteString(password); err != nil {
t.Fatalf("Failed to write to temporary file: %v", err)
}
os.Clearenv()
os.Setenv("ADMIN_PASSWORD_FILE", tmpFile.Name())
configParser := NewConfigParser()
configOptions, err := configParser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf("Unexpected parsing error: %v", err)
}
if configOptions.AdminPassword() != password {
t.Fatalf("Unexpected admin password, got %q", configOptions.AdminPassword())
}
}
func TestParseAdminPasswordFileOptionWithEmptyFile(t *testing.T) {
tmpFile, err := os.CreateTemp("", "empty-password-*.txt")
if err != nil {
t.Fatalf("Failed to create temporary file: %v", err)
}
defer os.Remove(tmpFile.Name())
defer tmpFile.Close()
os.Clearenv()
os.Setenv("ADMIN_PASSWORD_FILE", tmpFile.Name())
configParser := NewConfigParser()
_, err = configParser.ParseEnvironmentVariables()
if err == nil {
t.Fatal("Expected error due to empty password file, but got none")
}
}
func TestParseLogFileOptionDefaultValue(t *testing.T) {
os.Clearenv()
configParser := NewConfigParser()
configOptions, err := configParser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf("Unexpected parsing error: %v", err)
}
if configOptions.LogFile() != "stderr" {
t.Fatalf("Unexpected default log file, got %q", configOptions.LogFile())
}
}
func TestParseLogFileOptionWithCustomFilename(t *testing.T) {
os.Clearenv()
os.Setenv("LOG_FILE", "miniflux.log")
configParser := NewConfigParser()
configOptions, err := configParser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf("Unexpected parsing error: %v", err)
}
if configOptions.LogFile() != "miniflux.log" {
t.Fatalf("Unexpected log file, got %q", configOptions.LogFile())
}
}
func TestParseLogFileOptionWithEmptyValue(t *testing.T) {
os.Clearenv()
os.Setenv("LOG_FILE", "")
configParser := NewConfigParser()
configOptions, err := configParser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf("Unexpected parsing error: %v", err)
}
if configOptions.LogFile() != "stderr" {
t.Fatalf("Unexpected log file, got %q", configOptions.LogFile())
}
}
func TestParseLogDateTimeOptionDefaultValue(t *testing.T) {
os.Clearenv()
configParser := NewConfigParser()
configOptions, err := configParser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf("Unexpected parsing error: %v", err)
}
if configOptions.LogDateTime() != false {
t.Fatalf("Unexpected default log datetime, got %v", configOptions.LogDateTime())
}
}
func TestParseLogDateTimeOptionWithCustomValue(t *testing.T) {
os.Clearenv()
os.Setenv("LOG_DATE_TIME", "true")
configParser := NewConfigParser()
configOptions, err := configParser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf("Unexpected parsing error: %v", err)
}
if configOptions.LogDateTime() != true {
t.Fatalf("Unexpected log datetime, got %v", configOptions.LogDateTime())
}
}
func TestParseLogDateTimeOptionWithEmptyValue(t *testing.T) {
os.Clearenv()
os.Setenv("LOG_DATE_TIME", "")
configParser := NewConfigParser()
configOptions, err := configParser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf("Unexpected parsing error: %v", err)
}
if configOptions.LogDateTime() != false {
t.Fatalf("Unexpected log datetime, got %v", configOptions.LogDateTime())
}
}
func TestParseLogDateTimeOptionWithIncorrectValue(t *testing.T) {
os.Clearenv()
os.Setenv("LOG_DATE_TIME", "invalid")
configParser := NewConfigParser()
if _, err := configParser.ParseEnvironmentVariables(); err == nil {
t.Fatal("Expected parsing error, got nil")
}
}
v2-2.2.16/internal/config/validators.go 0000664 0000000 0000000 00000002674 15127074645 0017735 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package config // import "miniflux.app/v2/internal/config"
import (
"errors"
"fmt"
"slices"
"strconv"
"strings"
)
func validateChoices(rawValue string, choices []string) error {
if !slices.Contains(choices, rawValue) {
return fmt.Errorf("value must be one of: %v", strings.Join(choices, ", "))
}
return nil
}
func validateListChoices(inputValues, choices []string) error {
for _, value := range inputValues {
if err := validateChoices(value, choices); err != nil {
return err
}
}
return nil
}
func validateGreaterThan(rawValue string, min int) error {
intValue, err := strconv.Atoi(rawValue)
if err != nil {
return errors.New("value must be an integer")
}
if intValue > min {
return nil
}
return fmt.Errorf("value must be at least %d", min)
}
func validateGreaterOrEqualThan(rawValue string, min int) error {
intValue, err := strconv.Atoi(rawValue)
if err != nil {
return errors.New("value must be an integer")
}
if intValue >= min {
return nil
}
return fmt.Errorf("value must be greater or equal than %d", min)
}
func validateRange(rawValue string, min, max int) error {
intValue, err := strconv.Atoi(rawValue)
if err != nil {
return errors.New("value must be an integer")
}
if intValue < min || intValue > max {
return fmt.Errorf("value must be between %d and %d", min, max)
}
return nil
}
v2-2.2.16/internal/config/validators_test.go 0000664 0000000 0000000 00000021546 15127074645 0020773 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package config // import "miniflux.app/v2/internal/config"
import (
"strings"
"testing"
)
func TestValidateChoices(t *testing.T) {
tests := []struct {
name string
rawValue string
choices []string
expectError bool
}{
{
name: "valid choice",
rawValue: "option1",
choices: []string{"option1", "option2", "option3"},
expectError: false,
},
{
name: "valid choice from middle",
rawValue: "option2",
choices: []string{"option1", "option2", "option3"},
expectError: false,
},
{
name: "valid choice from end",
rawValue: "option3",
choices: []string{"option1", "option2", "option3"},
expectError: false,
},
{
name: "invalid choice",
rawValue: "invalid",
choices: []string{"option1", "option2", "option3"},
expectError: true,
},
{
name: "empty value with non-empty choices",
rawValue: "",
choices: []string{"option1", "option2"},
expectError: true,
},
{
name: "case sensitive - different case",
rawValue: "OPTION1",
choices: []string{"option1", "option2"},
expectError: true,
},
{
name: "single choice valid",
rawValue: "only",
choices: []string{"only"},
expectError: false,
},
{
name: "empty choices list",
rawValue: "anything",
choices: []string{},
expectError: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validateChoices(tt.rawValue, tt.choices)
if tt.expectError {
if err == nil {
t.Errorf("expected error but got none")
} else {
// Verify error message format
expectedPrefix := "value must be one of:"
if !strings.Contains(err.Error(), expectedPrefix) {
t.Errorf("error message should contain '%s', got: %s", expectedPrefix, err.Error())
}
}
} else {
if err != nil {
t.Errorf("expected no error but got: %v", err)
}
}
})
}
}
func TestValidateListChoices(t *testing.T) {
tests := []struct {
name string
inputValues []string
choices []string
expectError bool
}{
{
name: "all valid choices",
inputValues: []string{"option1", "option2"},
choices: []string{"option1", "option2", "option3"},
expectError: false,
},
{
name: "single valid choice",
inputValues: []string{"option1"},
choices: []string{"option1", "option2", "option3"},
expectError: false,
},
{
name: "empty input list",
inputValues: []string{},
choices: []string{"option1", "option2", "option3"},
expectError: false,
},
{
name: "all choices from available list",
inputValues: []string{"option1", "option2", "option3"},
choices: []string{"option1", "option2", "option3"},
expectError: false,
},
{
name: "duplicate valid choices",
inputValues: []string{"option1", "option1", "option2"},
choices: []string{"option1", "option2", "option3"},
expectError: false,
},
{
name: "one invalid choice",
inputValues: []string{"option1", "invalid"},
choices: []string{"option1", "option2", "option3"},
expectError: true,
},
{
name: "all invalid choices",
inputValues: []string{"invalid1", "invalid2"},
choices: []string{"option1", "option2", "option3"},
expectError: true,
},
{
name: "case sensitive - different case",
inputValues: []string{"OPTION1"},
choices: []string{"option1", "option2"},
expectError: true,
},
{
name: "empty string in input",
inputValues: []string{""},
choices: []string{"option1", "option2"},
expectError: true,
},
{
name: "empty choices list with non-empty input",
inputValues: []string{"anything"},
choices: []string{},
expectError: true,
},
{
name: "mixed valid and invalid choices",
inputValues: []string{"option1", "invalid", "option2"},
choices: []string{"option1", "option2", "option3"},
expectError: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validateListChoices(tt.inputValues, tt.choices)
if tt.expectError {
if err == nil {
t.Errorf("expected error but got none")
} else {
// Verify error message format
expectedPrefix := "value must be one of:"
if !strings.Contains(err.Error(), expectedPrefix) {
t.Errorf("error message should contain '%s', got: %s", expectedPrefix, err.Error())
}
}
} else {
if err != nil {
t.Errorf("expected no error but got: %v", err)
}
}
})
}
}
func TestValidateGreaterThan(t *testing.T) {
if err := validateGreaterThan("10", 5); err != nil {
t.Errorf("expected no error, got: %v", err)
}
if err := validateGreaterThan("5", 5); err == nil {
t.Errorf("expected error, got none")
}
if err := validateGreaterThan("abc", 5); err == nil {
t.Errorf("expected error for non-integer input, got none")
}
if err := validateGreaterThan("-1", 0); err == nil {
t.Errorf("expected error for value below minimum, got none")
}
}
func TestValidateGreaterOrEqualThan(t *testing.T) {
if err := validateGreaterOrEqualThan("10", 5); err != nil {
t.Errorf("expected no error, got: %v", err)
}
if err := validateGreaterOrEqualThan("5", 5); err != nil {
t.Errorf("expected no error for equal value, got: %v", err)
}
if err := validateGreaterOrEqualThan("abc", 5); err == nil {
t.Errorf("expected error for non-integer input, got none")
}
if err := validateGreaterOrEqualThan("-1", 0); err == nil {
t.Errorf("expected error for value below minimum, got none")
}
}
func TestValidateRange(t *testing.T) {
tests := []struct {
name string
rawValue string
min int
max int
expectError bool
errorMsg string
}{
{
name: "valid integer within range",
rawValue: "5",
min: 1,
max: 10,
expectError: false,
},
{
name: "valid integer at minimum",
rawValue: "1",
min: 1,
max: 10,
expectError: false,
},
{
name: "valid integer at maximum",
rawValue: "10",
min: 1,
max: 10,
expectError: false,
},
{
name: "valid zero in range",
rawValue: "0",
min: -5,
max: 5,
expectError: false,
},
{
name: "valid negative in range",
rawValue: "-3",
min: -5,
max: 5,
expectError: false,
},
{
name: "integer below minimum",
rawValue: "0",
min: 1,
max: 10,
expectError: true,
errorMsg: "value must be between 1 and 10",
},
{
name: "integer above maximum",
rawValue: "11",
min: 1,
max: 10,
expectError: true,
errorMsg: "value must be between 1 and 10",
},
{
name: "integer far below minimum",
rawValue: "-100",
min: 1,
max: 10,
expectError: true,
errorMsg: "value must be between 1 and 10",
},
{
name: "integer far above maximum",
rawValue: "100",
min: 1,
max: 10,
expectError: true,
errorMsg: "value must be between 1 and 10",
},
{
name: "non-integer string",
rawValue: "abc",
min: 1,
max: 10,
expectError: true,
errorMsg: "value must be an integer",
},
{
name: "empty string",
rawValue: "",
min: 1,
max: 10,
expectError: true,
errorMsg: "value must be an integer",
},
{
name: "float string",
rawValue: "5.5",
min: 1,
max: 10,
expectError: true,
errorMsg: "value must be an integer",
},
{
name: "string with spaces",
rawValue: " 5 ",
min: 1,
max: 10,
expectError: true,
errorMsg: "value must be an integer",
},
{
name: "single value range",
rawValue: "5",
min: 5,
max: 5,
expectError: false,
},
{
name: "single value range - below",
rawValue: "4",
min: 5,
max: 5,
expectError: true,
errorMsg: "value must be between 5 and 5",
},
{
name: "single value range - above",
rawValue: "6",
min: 5,
max: 5,
expectError: true,
errorMsg: "value must be between 5 and 5",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validateRange(tt.rawValue, tt.min, tt.max)
if tt.expectError {
if err == nil {
t.Errorf("expected error but got none")
} else if tt.errorMsg != "" && err.Error() != tt.errorMsg {
t.Errorf("expected error message '%s', got '%s'", tt.errorMsg, err.Error())
}
} else {
if err != nil {
t.Errorf("expected no error but got: %v", err)
}
}
})
}
}
v2-2.2.16/internal/crypto/ 0000775 0000000 0000000 00000000000 15127074645 0015300 5 ustar 00root root 0000000 0000000 v2-2.2.16/internal/crypto/crypto.go 0000664 0000000 0000000 00000003013 15127074645 0017144 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package crypto // import "miniflux.app/v2/internal/crypto"
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"fmt"
"hash/fnv"
"golang.org/x/crypto/bcrypt"
)
// HashFromBytes returns a non-cryptographic checksum of the input.
func HashFromBytes(value []byte) string {
h := fnv.New128a()
h.Write(value)
return hex.EncodeToString(h.Sum(nil))
}
// SHA256 returns a SHA-256 checksum of a string.
func SHA256(value string) string {
h := sha256.Sum256([]byte(value))
return hex.EncodeToString(h[:])
}
// GenerateRandomBytes returns random bytes.
func GenerateRandomBytes(size int) []byte {
b := make([]byte, size)
rand.Read(b)
return b
}
// GenerateRandomStringHex returns a random hexadecimal string.
func GenerateRandomStringHex(size int) string {
return hex.EncodeToString(GenerateRandomBytes(size))
}
func HashPassword(password string) (string, error) {
bytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
return string(bytes), err
}
func GenerateSHA256Hmac(secret string, data []byte) string {
h := hmac.New(sha256.New, []byte(secret))
h.Write(data)
return hex.EncodeToString(h.Sum(nil))
}
func GenerateUUID() string {
b := GenerateRandomBytes(16)
return fmt.Sprintf("%X-%X-%X-%X-%X", b[0:4], b[4:6], b[6:8], b[8:10], b[10:])
}
func ConstantTimeCmp(a, b string) bool {
return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1
}
v2-2.2.16/internal/database/ 0000775 0000000 0000000 00000000000 15127074645 0015524 5 ustar 00root root 0000000 0000000 v2-2.2.16/internal/database/database.go 0000664 0000000 0000000 00000003250 15127074645 0017617 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package database // import "miniflux.app/v2/internal/database"
import (
"database/sql"
"fmt"
"log/slog"
)
// Migrate executes database migrations.
func Migrate(db *sql.DB) error {
var currentVersion int
db.QueryRow(`SELECT version FROM schema_version`).Scan(¤tVersion)
slog.Info("Running database migrations",
slog.Int("current_version", currentVersion),
slog.Int("latest_version", schemaVersion),
)
for version := currentVersion; version < schemaVersion; version++ {
newVersion := version + 1
tx, err := db.Begin()
if err != nil {
return fmt.Errorf("[Migration v%d] %v", newVersion, err)
}
if err := migrations[version](tx); err != nil {
tx.Rollback()
return fmt.Errorf("[Migration v%d] %v", newVersion, err)
}
if _, err := tx.Exec(`TRUNCATE schema_version`); err != nil {
tx.Rollback()
return fmt.Errorf("[Migration v%d] %v", newVersion, err)
}
if _, err := tx.Exec(`INSERT INTO schema_version (version) VALUES ($1)`, newVersion); err != nil {
tx.Rollback()
return fmt.Errorf("[Migration v%d] %v", newVersion, err)
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("[Migration v%d] %v", newVersion, err)
}
}
return nil
}
// IsSchemaUpToDate checks if the database schema is up to date.
func IsSchemaUpToDate(db *sql.DB) error {
var currentVersion int
db.QueryRow(`SELECT version FROM schema_version`).Scan(¤tVersion)
if currentVersion < schemaVersion {
return fmt.Errorf(`the database schema is not up to date: current=v%d expected=v%d`, currentVersion, schemaVersion)
}
return nil
}
v2-2.2.16/internal/database/migrations.go 0000664 0000000 0000000 00000114143 15127074645 0020233 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package database // import "miniflux.app/v2/internal/database"
import (
"database/sql"
"miniflux.app/v2/internal/crypto"
)
var schemaVersion = len(migrations)
// Order is important. Add new migrations at the end of the list.
var migrations = [...]func(tx *sql.Tx) error{
func(tx *sql.Tx) (err error) {
sql := `
CREATE TABLE schema_version (
version text not null
);
CREATE TABLE users (
id SERIAL,
username text not null unique,
password text,
is_admin bool default 'f',
language text default 'en_US',
timezone text default 'UTC',
theme text default 'default',
last_login_at timestamp with time zone,
primary key (id)
);
CREATE TABLE sessions (
id SERIAL,
user_id int not null,
token text not null unique,
created_at timestamp with time zone default now(),
user_agent text,
ip text,
primary key (id),
unique (user_id, token),
foreign key (user_id) references users(id) on delete cascade
);
CREATE TABLE categories (
id SERIAL,
user_id int not null,
title text not null,
primary key (id),
unique (user_id, title),
foreign key (user_id) references users(id) on delete cascade
);
CREATE TABLE feeds (
id BIGSERIAL,
user_id int not null,
category_id int not null,
title text not null,
feed_url text not null,
site_url text not null,
checked_at timestamp with time zone default now(),
etag_header text default '',
last_modified_header text default '',
parsing_error_msg text default '',
parsing_error_count int default 0,
primary key (id),
unique (user_id, feed_url),
foreign key (user_id) references users(id) on delete cascade,
foreign key (category_id) references categories(id) on delete cascade
);
CREATE TYPE entry_status as enum('unread', 'read', 'removed');
CREATE TABLE entries (
id BIGSERIAL,
user_id int not null,
feed_id bigint not null,
hash text not null,
published_at timestamp with time zone not null,
title text not null,
url text not null,
author text,
content text,
status entry_status default 'unread',
primary key (id),
unique (feed_id, hash),
foreign key (user_id) references users(id) on delete cascade,
foreign key (feed_id) references feeds(id) on delete cascade
);
CREATE INDEX entries_feed_idx on entries using btree(feed_id);
CREATE TABLE enclosures (
id BIGSERIAL,
user_id int not null,
entry_id bigint not null,
url text not null,
size int default 0,
mime_type text default '',
primary key (id),
foreign key (user_id) references users(id) on delete cascade,
foreign key (entry_id) references entries(id) on delete cascade
);
CREATE TABLE icons (
id BIGSERIAL,
hash text not null unique,
mime_type text not null,
content bytea not null,
primary key (id)
);
CREATE TABLE feed_icons (
feed_id bigint not null,
icon_id bigint not null,
primary key(feed_id, icon_id),
foreign key (feed_id) references feeds(id) on delete cascade,
foreign key (icon_id) references icons(id) on delete cascade
);
`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
// This used to create a HSTORE `extra` column in the table `users`,
// which hasn't been used since Miniflux 2.0.27.
return nil
},
func(tx *sql.Tx) (err error) {
sql := `
CREATE TABLE tokens (
id text not null,
value text not null,
created_at timestamp with time zone not null default now(),
primary key(id, value)
);
`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
sql := `
CREATE TYPE entry_sorting_direction AS enum('asc', 'desc');
ALTER TABLE users ADD COLUMN entry_direction entry_sorting_direction default 'asc';
`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
sql := `
CREATE TABLE integrations (
user_id int not null,
pinboard_enabled bool default 'f',
pinboard_token text default '',
pinboard_tags text default 'miniflux',
pinboard_mark_as_unread bool default 'f',
instapaper_enabled bool default 'f',
instapaper_username text default '',
instapaper_password text default '',
fever_enabled bool default 'f',
fever_username text default '',
fever_password text default '',
fever_token text default '',
primary key(user_id)
);
`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
sql := `ALTER TABLE feeds ADD COLUMN scraper_rules text default ''`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
sql := `ALTER TABLE feeds ADD COLUMN rewrite_rules text default ''`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
sql := `ALTER TABLE feeds ADD COLUMN crawler boolean default 'f'`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
sql := `ALTER TABLE sessions rename to user_sessions`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
sql := `
DROP TABLE tokens;
CREATE TABLE sessions (
id text not null,
data jsonb not null,
created_at timestamp with time zone not null default now(),
primary key(id)
);
`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
sql := `
ALTER TABLE integrations
ADD COLUMN wallabag_enabled bool default 'f',
ADD COLUMN wallabag_url text default '',
ADD COLUMN wallabag_client_id text default '',
ADD COLUMN wallabag_client_secret text default '',
ADD COLUMN wallabag_username text default '',
ADD COLUMN wallabag_password text default '';
`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
sql := `ALTER TABLE entries ADD COLUMN starred bool default 'f'`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
sql := `
CREATE INDEX entries_user_status_idx ON entries(user_id, status);
CREATE INDEX feeds_user_category_idx ON feeds(user_id, category_id);
`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
sql := `
ALTER TABLE integrations
ADD COLUMN nunux_keeper_enabled bool default 'f',
ADD COLUMN nunux_keeper_url text default '',
ADD COLUMN nunux_keeper_api_key text default '';
`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
sql := `ALTER TABLE enclosures ALTER COLUMN size SET DATA TYPE bigint`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
sql := `ALTER TABLE entries ADD COLUMN comments_url text default ''`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
sql := `
ALTER TABLE integrations
ADD COLUMN pocket_enabled bool default 'f',
ADD COLUMN pocket_access_token text default '',
ADD COLUMN pocket_consumer_key text default '';
`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
sql := `
ALTER TABLE user_sessions ALTER COLUMN ip SET DATA TYPE inet using ip::inet;
`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
sql := `
ALTER TABLE feeds
ADD COLUMN username text default '',
ADD COLUMN password text default '';
`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
sql := `
ALTER TABLE entries ADD COLUMN document_vectors tsvector;
UPDATE entries SET document_vectors = to_tsvector(substring(title || ' ' || coalesce(content, '') for 1000000));
CREATE INDEX document_vectors_idx ON entries USING gin(document_vectors);
`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
sql := `ALTER TABLE feeds ADD COLUMN user_agent text default ''`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
sql := `
UPDATE
entries
SET
document_vectors = setweight(to_tsvector(substring(coalesce(title, '') for 1000000)), 'A') || setweight(to_tsvector(substring(coalesce(content, '') for 1000000)), 'B')
`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
sql := `ALTER TABLE users ADD COLUMN keyboard_shortcuts boolean default 't'`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
sql := `ALTER TABLE feeds ADD COLUMN disabled boolean default 'f';`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
sql := `
ALTER TABLE users ALTER COLUMN theme SET DEFAULT 'light_serif';
UPDATE users SET theme='light_serif' WHERE theme='default';
UPDATE users SET theme='light_sans_serif' WHERE theme='sansserif';
UPDATE users SET theme='dark_serif' WHERE theme='black';
`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
sql := `
ALTER TABLE entries ADD COLUMN changed_at timestamp with time zone;
UPDATE entries SET changed_at = published_at;
ALTER TABLE entries ALTER COLUMN changed_at SET not null;
`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
sql := `
CREATE TABLE api_keys (
id SERIAL,
user_id int not null references users(id) on delete cascade,
token text not null unique,
description text not null,
last_used_at timestamp with time zone,
created_at timestamp with time zone default now(),
primary key(id),
unique (user_id, description)
);
`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
sql := `
ALTER TABLE entries ADD COLUMN share_code text not null default '';
CREATE UNIQUE INDEX entries_share_code_idx ON entries USING btree(share_code) WHERE share_code <> '';
`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
sql := `CREATE INDEX enclosures_user_entry_url_idx ON enclosures(user_id, entry_id, md5(url))`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
sql := `
ALTER TABLE feeds ADD COLUMN next_check_at timestamp with time zone default now();
CREATE INDEX entries_user_feed_idx ON entries (user_id, feed_id);
`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
sql := `ALTER TABLE feeds ADD COLUMN ignore_http_cache bool default false`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
sql := `ALTER TABLE users ADD COLUMN entries_per_page int default 100`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
sql := `ALTER TABLE users ADD COLUMN show_reading_time boolean default 't'`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
sql := `CREATE INDEX entries_id_user_status_idx ON entries USING btree (id, user_id, status)`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
sql := `ALTER TABLE feeds ADD COLUMN fetch_via_proxy bool default false`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
sql := `CREATE INDEX entries_feed_id_status_hash_idx ON entries USING btree (feed_id, status, hash)`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
sql := `CREATE INDEX entries_user_id_status_starred_idx ON entries (user_id, status, starred)`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
sql := `ALTER TABLE users ADD COLUMN entry_swipe boolean default 't'`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
sql := `ALTER TABLE integrations DROP COLUMN fever_password`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
sql := `
ALTER TABLE feeds
ADD COLUMN blocklist_rules text not null default '',
ADD COLUMN keeplist_rules text not null default ''
`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
sql := `ALTER TABLE entries ADD COLUMN reading_time int not null default 0`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
sql := `
ALTER TABLE entries ADD COLUMN created_at timestamp with time zone not null default now();
UPDATE entries SET created_at = published_at;
`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
hasExtra := false
if err := tx.QueryRow(`
SELECT true
FROM information_schema.columns
WHERE
table_name='users' AND
column_name='extra';
`).Scan(&hasExtra); err != nil && err != sql.ErrNoRows {
return err
}
_, err = tx.Exec(`
ALTER TABLE users
ADD column stylesheet text not null default '',
ADD column google_id text not null default '',
ADD column openid_connect_id text not null default ''
`)
if err != nil {
return err
}
if !hasExtra {
// No need to migrate things from the `extra` column if it's not present
return nil
}
_, err = tx.Exec(`
DECLARE my_cursor CURSOR FOR
SELECT
id,
COALESCE(extra->'custom_css', '') as custom_css,
COALESCE(extra->'google_id', '') as google_id,
COALESCE(extra->'oidc_id', '') as oidc_id
FROM users
FOR UPDATE
`)
if err != nil {
return err
}
defer tx.Exec("CLOSE my_cursor")
for {
var (
userID int64
customStylesheet string
googleID string
oidcID string
)
if err := tx.QueryRow(`FETCH NEXT FROM my_cursor`).Scan(&userID, &customStylesheet, &googleID, &oidcID); err != nil {
if err == sql.ErrNoRows {
break
}
return err
}
_, err := tx.Exec(
`UPDATE
users
SET
stylesheet=$2,
google_id=$3,
openid_connect_id=$4
WHERE
id=$1
`,
userID, customStylesheet, googleID, oidcID)
if err != nil {
return err
}
}
return err
},
func(tx *sql.Tx) (err error) {
if _, err = tx.Exec(`ALTER TABLE users DROP COLUMN IF EXISTS extra;`); err != nil {
return err
}
_, err = tx.Exec(`
CREATE UNIQUE INDEX users_google_id_idx ON users(google_id) WHERE google_id <> '';
CREATE UNIQUE INDEX users_openid_connect_id_idx ON users(openid_connect_id) WHERE openid_connect_id <> '';
`)
return err
},
func(tx *sql.Tx) (err error) {
_, err = tx.Exec(`
CREATE INDEX entries_feed_url_idx ON entries(feed_id, url) WHERE length(url) < 2000;
CREATE INDEX entries_user_status_feed_idx ON entries(user_id, status, feed_id);
CREATE INDEX entries_user_status_changed_idx ON entries(user_id, status, changed_at);
`)
return err
},
func(tx *sql.Tx) (err error) {
_, err = tx.Exec(`
CREATE TABLE acme_cache (
key varchar(400) not null primary key,
data bytea not null,
updated_at timestamptz not null
);
`)
return err
},
func(tx *sql.Tx) (err error) {
_, err = tx.Exec(`
ALTER TABLE feeds ADD COLUMN allow_self_signed_certificates boolean not null default false
`)
return err
},
func(tx *sql.Tx) (err error) {
sql := `
CREATE TYPE webapp_display_mode AS enum('fullscreen', 'standalone', 'minimal-ui', 'browser');
ALTER TABLE users ADD COLUMN display_mode webapp_display_mode default 'standalone';
`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
sql := `ALTER TABLE feeds ADD COLUMN cookie text default ''`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
_, err = tx.Exec(`
ALTER TABLE categories ADD COLUMN hide_globally boolean not null default false
`)
return err
},
func(tx *sql.Tx) (err error) {
_, err = tx.Exec(`
ALTER TABLE feeds ADD COLUMN hide_globally boolean not null default false
`)
return err
},
func(tx *sql.Tx) (err error) {
sql := `
ALTER TABLE integrations
ADD COLUMN telegram_bot_enabled bool default 'f',
ADD COLUMN telegram_bot_token text default '',
ADD COLUMN telegram_bot_chat_id text default '';
`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
sql := `
CREATE TYPE entry_sorting_order AS enum('published_at', 'created_at');
ALTER TABLE users ADD COLUMN entry_order entry_sorting_order default 'published_at';
`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
sql := `
ALTER TABLE integrations
ADD COLUMN googlereader_enabled bool default 'f',
ADD COLUMN googlereader_username text default '',
ADD COLUMN googlereader_password text default '';
`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
sql := `
ALTER TABLE integrations
ADD COLUMN espial_enabled bool default 'f',
ADD COLUMN espial_url text default '',
ADD COLUMN espial_api_key text default '',
ADD COLUMN espial_tags text default 'miniflux';
`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
sql := `
ALTER TABLE integrations
ADD COLUMN linkding_enabled bool default 'f',
ADD COLUMN linkding_url text default '',
ADD COLUMN linkding_api_key text default '';
`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
_, err = tx.Exec(`
ALTER TABLE feeds ADD COLUMN url_rewrite_rules text not null default ''
`)
return err
},
func(tx *sql.Tx) (err error) {
_, err = tx.Exec(`
ALTER TABLE users
ADD COLUMN default_reading_speed int default 265,
ADD COLUMN cjk_reading_speed int default 500;
`)
return
},
func(tx *sql.Tx) (err error) {
_, err = tx.Exec(`
ALTER TABLE users ADD COLUMN default_home_page text default 'unread';
`)
return
},
func(tx *sql.Tx) (err error) {
_, err = tx.Exec(`
ALTER TABLE integrations ADD COLUMN wallabag_only_url bool default 'f';
`)
return
},
func(tx *sql.Tx) (err error) {
_, err = tx.Exec(`
ALTER TABLE users ADD COLUMN categories_sorting_order text not null default 'unread_count';
`)
return
},
func(tx *sql.Tx) (err error) {
sql := `
ALTER TABLE integrations
ADD COLUMN matrix_bot_enabled bool default 'f',
ADD COLUMN matrix_bot_user text default '',
ADD COLUMN matrix_bot_password text default '',
ADD COLUMN matrix_bot_url text default '',
ADD COLUMN matrix_bot_chat_id text default '';
`
_, err = tx.Exec(sql)
return
},
func(tx *sql.Tx) (err error) {
sql := `ALTER TABLE users ADD COLUMN double_tap boolean default 't'`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
_, err = tx.Exec(`
ALTER TABLE entries ADD COLUMN tags text[] default '{}';
`)
return
},
func(tx *sql.Tx) (err error) {
sql := `
ALTER TABLE users RENAME double_tap TO gesture_nav;
ALTER TABLE users
ALTER COLUMN gesture_nav SET DATA TYPE text using case when gesture_nav = true then 'tap' when gesture_nav = false then 'none' end,
ALTER COLUMN gesture_nav SET default 'tap';
`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
sql := `
ALTER TABLE integrations ADD COLUMN linkding_tags text default '';
`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
sql := `
ALTER TABLE feeds ADD COLUMN no_media_player boolean default 'f';
ALTER TABLE enclosures ADD COLUMN media_progression int default 0;
`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
sql := `
ALTER TABLE integrations ADD COLUMN linkding_mark_as_unread bool default 'f';
`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
// Delete duplicated rows
sql := `
DELETE FROM enclosures a USING enclosures b
WHERE a.id < b.id
AND a.user_id = b.user_id
AND a.entry_id = b.entry_id
AND a.url = b.url;
`
_, err = tx.Exec(sql)
if err != nil {
return err
}
// Remove previous index
_, err = tx.Exec(`DROP INDEX enclosures_user_entry_url_idx`)
if err != nil {
return err
}
// Create unique index
_, err = tx.Exec(`CREATE UNIQUE INDEX enclosures_user_entry_url_unique_idx ON enclosures(user_id, entry_id, md5(url))`)
if err != nil {
return err
}
return nil
},
func(tx *sql.Tx) (err error) {
sql := `ALTER TABLE users ADD COLUMN mark_read_on_view boolean default 't'`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
sql := `
ALTER TABLE integrations
ADD COLUMN notion_enabled bool default 'f',
ADD COLUMN notion_token text default '',
ADD COLUMN notion_page_id text default '';
`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
sql := `
ALTER TABLE integrations
ADD COLUMN readwise_enabled bool default 'f',
ADD COLUMN readwise_api_key text default '';
`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
sql := `
ALTER TABLE integrations
ADD COLUMN apprise_enabled bool default 'f',
ADD COLUMN apprise_url text default '',
ADD COLUMN apprise_services_url text default '';
`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
sql := `
ALTER TABLE integrations
ADD COLUMN shiori_enabled bool default 'f',
ADD COLUMN shiori_url text default '',
ADD COLUMN shiori_username text default '',
ADD COLUMN shiori_password text default '';
`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
sql := `
ALTER TABLE integrations
ADD COLUMN shaarli_enabled bool default 'f',
ADD COLUMN shaarli_url text default '',
ADD COLUMN shaarli_api_secret text default '';
`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
_, err = tx.Exec(`
ALTER TABLE feeds ADD COLUMN apprise_service_urls text default '';
`)
return err
},
func(tx *sql.Tx) (err error) {
sql := `
ALTER TABLE integrations
ADD COLUMN webhook_enabled bool default 'f',
ADD COLUMN webhook_url text default '',
ADD COLUMN webhook_secret text default '';
`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
sql := `
ALTER TABLE integrations
ADD COLUMN telegram_bot_topic_id int,
ADD COLUMN telegram_bot_disable_web_page_preview bool default 'f',
ADD COLUMN telegram_bot_disable_notification bool default 'f';
`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
sql := `
ALTER TABLE integrations ADD COLUMN telegram_bot_disable_buttons bool default 'f';
`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
sql := `
-- Speed up has_enclosure
CREATE INDEX enclosures_entry_id_idx ON enclosures(entry_id);
-- Speed up unread page
CREATE INDEX entries_user_status_published_idx ON entries(user_id, status, published_at);
CREATE INDEX entries_user_status_created_idx ON entries(user_id, status, created_at);
CREATE INDEX feeds_feed_id_hide_globally_idx ON feeds(id, hide_globally);
-- Speed up history page
CREATE INDEX entries_user_status_changed_published_idx ON entries(user_id, status, changed_at, published_at);
`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
sql := `
ALTER TABLE integrations
ADD COLUMN rssbridge_enabled bool default 'f',
ADD COLUMN rssbridge_url text default '';
`
_, err = tx.Exec(sql)
return
},
func(tx *sql.Tx) (err error) {
_, err = tx.Exec(`
CREATE TABLE webauthn_credentials (
handle bytea primary key,
cred_id bytea unique not null,
user_id int references users(id) on delete cascade not null,
public_key bytea not null,
attestation_type varchar(255) not null,
aaguid bytea,
sign_count bigint,
clone_warning bool,
name text,
added_on timestamp with time zone default now(),
last_seen_on timestamp with time zone default now()
);
`)
return
},
func(tx *sql.Tx) (err error) {
sql := `
ALTER TABLE integrations
ADD COLUMN omnivore_enabled bool default 'f',
ADD COLUMN omnivore_api_key text default '',
ADD COLUMN omnivore_url text default '';
`
_, err = tx.Exec(sql)
return
},
func(tx *sql.Tx) (err error) {
sql := `
ALTER TABLE integrations
ADD COLUMN linkace_enabled bool default 'f',
ADD COLUMN linkace_url text default '',
ADD COLUMN linkace_api_key text default '',
ADD COLUMN linkace_tags text default '',
ADD COLUMN linkace_is_private bool default 't',
ADD COLUMN linkace_check_disabled bool default 't';
`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
sql := `
ALTER TABLE integrations
ADD COLUMN linkwarden_enabled bool default 'f',
ADD COLUMN linkwarden_url text default '',
ADD COLUMN linkwarden_api_key text default '';
`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
sql := `
ALTER TABLE integrations
ADD COLUMN readeck_enabled bool default 'f',
ADD COLUMN readeck_only_url bool default 'f',
ADD COLUMN readeck_url text default '',
ADD COLUMN readeck_api_key text default '',
ADD COLUMN readeck_labels text default '';
`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
sql := `ALTER TABLE feeds ADD COLUMN disable_http2 bool default 'f'`
_, err = tx.Exec(sql)
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',
ADD COLUMN raindrop_token text default '',
ADD COLUMN raindrop_collection_id text default '',
ADD COLUMN raindrop_tags text default '';
`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
sql := `ALTER TABLE feeds ADD COLUMN description text default ''`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
sql := `
ALTER TABLE users
ADD COLUMN block_filter_entry_rules text not null default '',
ADD COLUMN keep_filter_entry_rules text not null default ''
`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
sql := `
ALTER TABLE integrations
ADD COLUMN betula_url text default '',
ADD COLUMN betula_token text default '',
ADD COLUMN betula_enabled bool default 'f';
`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
sql := `
ALTER TABLE integrations
ADD COLUMN ntfy_enabled bool default 'f',
ADD COLUMN ntfy_url text default '',
ADD COLUMN ntfy_topic text default '',
ADD COLUMN ntfy_api_token text default '',
ADD COLUMN ntfy_username text default '',
ADD COLUMN ntfy_password text default '',
ADD COLUMN ntfy_icon_url text default '';
ALTER TABLE feeds
ADD COLUMN ntfy_enabled bool default 'f',
ADD COLUMN ntfy_priority int default '3';
`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
sql := `ALTER TABLE users ADD COLUMN mark_read_on_media_player_completion bool default 'f';`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
sql := `ALTER TABLE users ADD COLUMN custom_js text not null default '';`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
sql := `ALTER TABLE users ADD COLUMN external_font_hosts text not null default '';`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
sql := `
ALTER TABLE integrations
ADD COLUMN cubox_enabled bool default 'f',
ADD COLUMN cubox_api_link text default '';
`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
sql := `
ALTER TABLE integrations
ADD COLUMN discord_enabled bool default 'f',
ADD COLUMN discord_webhook_link text default '';
`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
sql := `ALTER TABLE integrations ADD COLUMN ntfy_internal_links bool default 'f';`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
sql := `
ALTER TABLE integrations
ADD COLUMN slack_enabled bool default 'f',
ADD COLUMN slack_webhook_link text default '';
`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
_, err = tx.Exec(`ALTER TABLE feeds ADD COLUMN webhook_url text default '';`)
return err
},
func(tx *sql.Tx) (err error) {
sql := `
ALTER TABLE integrations
ADD COLUMN pushover_enabled bool default 'f',
ADD COLUMN pushover_user text default '',
ADD COLUMN pushover_token text default '',
ADD COLUMN pushover_device text default '',
ADD COLUMN pushover_prefix text default '';
ALTER TABLE feeds
ADD COLUMN pushover_enabled bool default 'f',
ADD COLUMN pushover_priority int default '0';
`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
sql := `
ALTER TABLE feeds ADD COLUMN ntfy_topic text default '';
`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
sql := `
ALTER TABLE icons ADD COLUMN external_id text default '';
CREATE UNIQUE INDEX icons_external_id_idx ON icons USING btree(external_id) WHERE external_id <> '';
`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
_, err = tx.Exec(`
DECLARE id_cursor CURSOR FOR
SELECT
id
FROM icons
WHERE external_id = ''
FOR UPDATE`)
if err != nil {
return err
}
defer tx.Exec("CLOSE id_cursor")
for {
var id int64
if err := tx.QueryRow(`FETCH NEXT FROM id_cursor`).Scan(&id); err != nil {
if err == sql.ErrNoRows {
break
}
return err
}
_, err = tx.Exec(
`
UPDATE icons SET external_id = $1 WHERE id = $2
`,
crypto.GenerateRandomStringHex(20), id)
if err != nil {
return err
}
}
return nil
},
func(tx *sql.Tx) (err error) {
_, err = tx.Exec(`ALTER TABLE feeds ADD COLUMN proxy_url text default ''`)
return err
},
func(tx *sql.Tx) (err error) {
sql := `
ALTER TABLE integrations ADD COLUMN rssbridge_token text default '';
`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
_, err = tx.Exec(`ALTER TABLE users ADD COLUMN always_open_external_links bool default 'f'`)
return err
},
func(tx *sql.Tx) (err error) {
sql := `
ALTER TABLE integrations
ADD COLUMN karakeep_enabled bool default 'f',
ADD COLUMN karakeep_api_key text default '',
ADD COLUMN karakeep_url text default '';
`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
_, err = tx.Exec(`ALTER TABLE users ADD COLUMN open_external_links_in_new_tab bool default 't'`)
return err
},
func(tx *sql.Tx) (err error) {
sql := `
ALTER TABLE integrations
DROP COLUMN pocket_enabled,
DROP COLUMN pocket_access_token,
DROP COLUMN pocket_consumer_key;
`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
sql := `
ALTER TABLE feeds
ADD COLUMN block_filter_entry_rules text not null default '',
ADD COLUMN keep_filter_entry_rules text not null default ''
`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
sql := `
CREATE TYPE linktaco_link_visibility AS ENUM (
'PUBLIC',
'PRIVATE'
);
ALTER TABLE integrations
ADD COLUMN linktaco_enabled bool default 'f',
ADD COLUMN linktaco_api_token text default '',
ADD COLUMN linktaco_org_slug text default '',
ADD COLUMN linktaco_tags text default '',
ADD COLUMN linktaco_visibility linktaco_link_visibility default 'PUBLIC';
`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
sql := `
ALTER TABLE integrations ADD COLUMN wallabag_tags text default '';
`
_, err = tx.Exec(sql)
return err
},
// This migration replaces deprecated timezones by their equivalent on Debian Trixie.
func(tx *sql.Tx) (err error) {
var deprecatedTimeZoneMap = map[string]string{
// Africa
"Africa/Asmera": "Africa/Asmara",
// America - Argentina
"America/Argentina/ComodRivadavia": "America/Argentina/Catamarca",
"America/Buenos_Aires": "America/Argentina/Buenos_Aires",
"America/Catamarca": "America/Argentina/Catamarca",
"America/Cordoba": "America/Argentina/Cordoba",
"America/Jujuy": "America/Argentina/Jujuy",
"America/Mendoza": "America/Argentina/Mendoza",
"America/Rosario": "America/Argentina/Cordoba",
// America - US
"America/Fort_Wayne": "America/Indiana/Indianapolis",
"America/Indianapolis": "America/Indiana/Indianapolis",
"America/Knox_IN": "America/Indiana/Knox",
"America/Louisville": "America/Kentucky/Louisville",
// America - Greenland
"America/Godthab": "America/Nuuk",
// Antarctica
"Antarctica/South_Pole": "Pacific/Auckland",
// Asia
"Asia/Ashkhabad": "Asia/Ashgabat",
"Asia/Calcutta": "Asia/Kolkata",
"Asia/Choibalsan": "Asia/Ulaanbaatar",
"Asia/Chungking": "Asia/Chongqing",
"Asia/Dacca": "Asia/Dhaka",
"Asia/Katmandu": "Asia/Kathmandu",
"Asia/Macao": "Asia/Macau",
"Asia/Rangoon": "Asia/Yangon",
"Asia/Saigon": "Asia/Ho_Chi_Minh",
"Asia/Thimbu": "Asia/Thimphu",
"Asia/Ujung_Pandang": "Asia/Makassar",
"Asia/Ulan_Bator": "Asia/Ulaanbaatar",
// Atlantic
"Atlantic/Faeroe": "Atlantic/Faroe",
// Australia
"Australia/ACT": "Australia/Sydney",
"Australia/LHI": "Australia/Lord_Howe",
"Australia/North": "Australia/Darwin",
"Australia/NSW": "Australia/Sydney",
"Australia/Queensland": "Australia/Brisbane",
"Australia/South": "Australia/Adelaide",
"Australia/Tasmania": "Australia/Hobart",
"Australia/Victoria": "Australia/Melbourne",
"Australia/West": "Australia/Perth",
// Brazil
"Brazil/Acre": "America/Rio_Branco",
"Brazil/DeNoronha": "America/Noronha",
"Brazil/East": "America/Sao_Paulo",
"Brazil/West": "America/Manaus",
// Canada
"Canada/Atlantic": "America/Halifax",
"Canada/Central": "America/Winnipeg",
"Canada/Eastern": "America/Toronto",
"Canada/Mountain": "America/Edmonton",
"Canada/Newfoundland": "America/St_Johns",
"Canada/Pacific": "America/Vancouver",
"Canada/Saskatchewan": "America/Regina",
"Canada/Yukon": "America/Whitehorse",
// Europe
"CET": "Europe/Paris",
"EET": "Europe/Sofia",
"Europe/Kiev": "Europe/Kyiv",
"Europe/Uzhgorod": "Europe/Kyiv",
"Europe/Zaporozhye": "Europe/Kyiv",
"MET": "Europe/Paris",
"WET": "Europe/Lisbon",
// Chile
"Chile/Continental": "America/Santiago",
"Chile/EasterIsland": "Pacific/Easter",
// Fixed offset and generic zones
"CST6CDT": "America/Chicago",
"EST": "America/New_York",
"EST5EDT": "America/New_York",
"HST": "Pacific/Honolulu",
"MST": "America/Denver",
"MST7MDT": "America/Denver",
"PST8PDT": "America/Los_Angeles",
// Countries/Regions
"Cuba": "America/Havana",
"Egypt": "Africa/Cairo",
"Eire": "Europe/Dublin",
"GB": "Europe/London",
"GB-Eire": "Europe/London",
"Hongkong": "Asia/Hong_Kong",
"Iceland": "Atlantic/Reykjavik",
"Iran": "Asia/Tehran",
"Israel": "Asia/Jerusalem",
"Jamaica": "America/Jamaica",
"Japan": "Asia/Tokyo",
"Libya": "Africa/Tripoli",
"Poland": "Europe/Warsaw",
"Portugal": "Europe/Lisbon",
"PRC": "Asia/Shanghai",
"ROC": "Asia/Taipei",
"ROK": "Asia/Seoul",
"Singapore": "Asia/Singapore",
"Turkey": "Europe/Istanbul",
// GMT variations
"GMT+0": "GMT",
"GMT-0": "GMT",
"GMT0": "GMT",
"Greenwich": "GMT",
"UCT": "UTC",
"Universal": "UTC",
"Zulu": "UTC",
// Mexico
"Mexico/BajaNorte": "America/Tijuana",
"Mexico/BajaSur": "America/Mazatlan",
"Mexico/General": "America/Mexico_City",
// US zones
"Navajo": "America/Denver",
"US/Alaska": "America/Anchorage",
"US/Aleutian": "America/Adak",
"US/Arizona": "America/Phoenix",
"US/Central": "America/Chicago",
"US/Eastern": "America/New_York",
"US/East-Indiana": "America/Indiana/Indianapolis",
"US/Hawaii": "Pacific/Honolulu",
"US/Indiana-Starke": "America/Indiana/Knox",
"US/Michigan": "America/Detroit",
"US/Mountain": "America/Denver",
"US/Pacific": "America/Los_Angeles",
"US/Samoa": "Pacific/Pago_Pago",
// Pacific
"Kwajalein": "Pacific/Kwajalein",
"NZ": "Pacific/Auckland",
"NZ-CHAT": "Pacific/Chatham",
"Pacific/Enderbury": "Pacific/Kanton",
"Pacific/Ponape": "Pacific/Pohnpei",
"Pacific/Truk": "Pacific/Chuuk",
// Special cases
"Factory": "UTC", // Factory is used for unconfigured systems
"W-SU": "Europe/Moscow",
}
// Loop through each user and correct the timezone
rows, err := tx.Query(`SELECT id, timezone FROM users`)
if err != nil {
return err
}
userTimezoneMap := make(map[int64]string)
for rows.Next() {
var userID int64
var userTimezone string
if err := rows.Scan(&userID, &userTimezone); err != nil {
return err
}
userTimezoneMap[userID] = userTimezone
}
rows.Close()
for userID, userTimezone := range userTimezoneMap {
if newTimezone, found := deprecatedTimeZoneMap[userTimezone]; found {
if _, err := tx.Exec(`UPDATE users SET timezone = $1 WHERE id = $2`, newTimezone, userID); err != nil {
return err
}
}
}
return nil
},
func(tx *sql.Tx) (err error) {
sql := `
ALTER TABLE integrations ADD COLUMN archiveorg_enabled bool default 'f'
`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
sql := `DROP EXTENSION IF EXISTS hstore;`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
sql := `
ALTER TABLE integrations ADD COLUMN karakeep_tags text default '';
`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
sql := `
ALTER TABLE integrations ADD COLUMN linkwarden_collection_id int;
`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
sql := `
ALTER TABLE integrations ADD COLUMN readeck_push_enabled bool default 'f';
`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
// There is no need to keep an index on the content of deleted entries.
_, err = tx.Exec(`DROP INDEX document_vectors_idx;`)
if err != nil {
return err
}
sql := `
CREATE INDEX document_vectors_idx
ON entries
USING gin(document_vectors)
WHERE status != 'removed';
`
_, err = tx.Exec(sql)
return err
},
}
v2-2.2.16/internal/database/postgresql.go 0000664 0000000 0000000 00000001201 15127074645 0020250 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package database // import "miniflux.app/v2/internal/database"
import (
"database/sql"
"time"
_ "github.com/lib/pq"
)
// NewConnectionPool configures the database connection pool.
func NewConnectionPool(dsn string, minConnections, maxConnections int, connectionLifetime time.Duration) (*sql.DB, error) {
db, err := sql.Open("postgres", dsn)
if err != nil {
return nil, err
}
db.SetMaxOpenConns(maxConnections)
db.SetMaxIdleConns(minConnections)
db.SetConnMaxLifetime(connectionLifetime)
return db, nil
}
v2-2.2.16/internal/fever/ 0000775 0000000 0000000 00000000000 15127074645 0015067 5 ustar 00root root 0000000 0000000 v2-2.2.16/internal/fever/handler.go 0000664 0000000 0000000 00000036307 15127074645 0017044 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package fever // import "miniflux.app/v2/internal/fever"
import (
"log/slog"
"net/http"
"strconv"
"strings"
"time"
"miniflux.app/v2/internal/http/request"
"miniflux.app/v2/internal/http/response/json"
"miniflux.app/v2/internal/integration"
"miniflux.app/v2/internal/mediaproxy"
"miniflux.app/v2/internal/model"
"miniflux.app/v2/internal/storage"
"github.com/gorilla/mux"
)
// Serve handles Fever API calls.
func Serve(router *mux.Router, store *storage.Storage) {
handler := &handler{store, router}
sr := router.PathPrefix("/fever").Subrouter()
sr.Use(newMiddleware(store).serve)
sr.HandleFunc("/", handler.serve).Name("feverEndpoint")
}
type handler struct {
store *storage.Storage
router *mux.Router
}
func (h *handler) serve(w http.ResponseWriter, r *http.Request) {
switch {
case request.HasQueryParam(r, "groups"):
h.handleGroups(w, r)
case request.HasQueryParam(r, "feeds"):
h.handleFeeds(w, r)
case request.HasQueryParam(r, "favicons"):
h.handleFavicons(w, r)
case request.HasQueryParam(r, "unread_item_ids"):
h.handleUnreadItems(w, r)
case request.HasQueryParam(r, "saved_item_ids"):
h.handleSavedItems(w, r)
case request.HasQueryParam(r, "items"):
h.handleItems(w, r)
case r.FormValue("mark") == "item":
h.handleWriteItems(w, r)
case r.FormValue("mark") == "feed":
h.handleWriteFeeds(w, r)
case r.FormValue("mark") == "group":
h.handleWriteGroups(w, r)
default:
json.OK(w, r, newBaseResponse())
}
}
/*
A request with the groups argument will return two additional members:
groups contains an array of group objects
feeds_groups contains an array of feeds_group objects
A group object has the following members:
id (positive integer)
title (utf-8 string)
The feeds_group object is documented under “Feeds/Groups Relationships.”
The “Kindling” super group is not included in this response and is composed of all feeds with
an is_spark equal to 0.
The “Sparks” super group is not included in this response and is composed of all feeds with an
is_spark equal to 1.
*/
func (h *handler) handleGroups(w http.ResponseWriter, r *http.Request) {
userID := request.UserID(r)
slog.Debug("[Fever] Fetching groups",
slog.Int64("user_id", userID),
)
categories, err := h.store.Categories(userID)
if err != nil {
json.ServerError(w, r, err)
return
}
feeds, err := h.store.Feeds(userID)
if err != nil {
json.ServerError(w, r, err)
return
}
var result groupsResponse
for _, category := range categories {
result.Groups = append(result.Groups, group{ID: category.ID, Title: category.Title})
}
result.FeedsGroups = h.buildFeedGroups(feeds)
result.SetCommonValues()
json.OK(w, r, result)
}
/*
A request with the feeds argument will return two additional members:
feeds contains an array of group objects
feeds_groups contains an array of feeds_group objects
A feed object has the following members:
id (positive integer)
favicon_id (positive integer)
title (utf-8 string)
url (utf-8 string)
site_url (utf-8 string)
is_spark (boolean integer)
last_updated_on_time (Unix timestamp/integer)
The feeds_group object is documented under “Feeds/Groups Relationships.”
The “All Items” super feed is not included in this response and is composed of all items from all feeds
that belong to a given group. For the “Kindling” super group and all user created groups the items
should be limited to feeds with an is_spark equal to 0.
For the “Sparks” super group the items should be limited to feeds with an is_spark equal to 1.
*/
func (h *handler) handleFeeds(w http.ResponseWriter, r *http.Request) {
userID := request.UserID(r)
slog.Debug("[Fever] Fetching feeds",
slog.Int64("user_id", userID),
)
feeds, err := h.store.Feeds(userID)
if err != nil {
json.ServerError(w, r, err)
return
}
var result feedsResponse
result.Feeds = make([]feed, 0, len(feeds))
for _, f := range feeds {
subscription := feed{
ID: f.ID,
Title: f.Title,
URL: f.FeedURL,
SiteURL: f.SiteURL,
IsSpark: 0,
LastUpdated: f.CheckedAt.Unix(),
}
if f.Icon != nil {
subscription.FaviconID = f.Icon.IconID
}
result.Feeds = append(result.Feeds, subscription)
}
result.FeedsGroups = h.buildFeedGroups(feeds)
result.SetCommonValues()
json.OK(w, r, result)
}
/*
A request with the favicons argument will return one additional member:
favicons contains an array of favicon objects
A favicon object has the following members:
id (positive integer)
data (base64 encoded image data; prefixed by image type)
An example data value:
image/gif;base64,R0lGODlhAQABAIAAAObm5gAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw==
The data member of a favicon object can be used with the data: protocol to embed an image in CSS or HTML.
A PHP/HTML example:
echo ' ';
*/
func (h *handler) handleFavicons(w http.ResponseWriter, r *http.Request) {
userID := request.UserID(r)
slog.Debug("[Fever] Fetching favicons",
slog.Int64("user_id", userID),
)
icons, err := h.store.Icons(userID)
if err != nil {
json.ServerError(w, r, err)
return
}
var result faviconsResponse
for _, i := range icons {
result.Favicons = append(result.Favicons, favicon{
ID: i.ID,
Data: i.DataURL(),
})
}
result.SetCommonValues()
json.OK(w, r, result)
}
/*
A request with the items argument will return two additional members:
items contains an array of item objects
total_items contains the total number of items stored in the database (added in API version 2)
An item object has the following members:
id (positive integer)
feed_id (positive integer)
title (utf-8 string)
author (utf-8 string)
html (utf-8 string)
url (utf-8 string)
is_saved (boolean integer)
is_read (boolean integer)
created_on_time (Unix timestamp/integer)
Most servers won’t have enough memory allocated to PHP to dump all items at once.
Three optional arguments control determine the items included in the response.
Use the since_id argument with the highest id of locally cached items to request 50 additional items.
Repeat until the items array in the response is empty.
Use the max_id argument with the lowest id of locally cached items (or 0 initially) to request 50 previous items.
Repeat until the items array in the response is empty. (added in API version 2)
Use the with_ids argument with a comma-separated list of item ids to request (a maximum of 50) specific items.
(added in API version 2)
*/
func (h *handler) handleItems(w http.ResponseWriter, r *http.Request) {
var result itemsResponse
userID := request.UserID(r)
builder := h.store.NewEntryQueryBuilder(userID)
builder.WithoutStatus(model.EntryStatusRemoved)
builder.WithLimit(50)
switch {
case request.HasQueryParam(r, "since_id"):
sinceID := request.QueryInt64Param(r, "since_id", 0)
if sinceID > 0 {
slog.Debug("[Fever] Fetching items since a given date",
slog.Int64("user_id", userID),
slog.Int64("since_id", sinceID),
)
builder.AfterEntryID(sinceID)
builder.WithSorting("id", "ASC")
}
case request.HasQueryParam(r, "max_id"):
maxID := request.QueryInt64Param(r, "max_id", 0)
if maxID == 0 {
slog.Debug("[Fever] Fetching most recent items",
slog.Int64("user_id", userID),
)
builder.WithSorting("id", "DESC")
} else if maxID > 0 {
slog.Debug("[Fever] Fetching items before a given item ID",
slog.Int64("user_id", userID),
slog.Int64("max_id", maxID),
)
builder.BeforeEntryID(maxID)
builder.WithSorting("id", "DESC")
}
case request.HasQueryParam(r, "with_ids"):
csvItemIDs := request.QueryStringParam(r, "with_ids", "")
if csvItemIDs != "" {
var itemIDs []int64
for _, strItemID := range strings.Split(csvItemIDs, ",") {
strItemID = strings.TrimSpace(strItemID)
itemID, _ := strconv.ParseInt(strItemID, 10, 64)
itemIDs = append(itemIDs, itemID)
}
builder.WithEntryIDs(itemIDs)
}
default:
slog.Debug("[Fever] Fetching oldest items",
slog.Int64("user_id", userID),
)
}
entries, err := builder.GetEntries()
if err != nil {
json.ServerError(w, r, err)
return
}
builder = h.store.NewEntryQueryBuilder(userID)
builder.WithoutStatus(model.EntryStatusRemoved)
result.Total, err = builder.CountEntries()
if err != nil {
json.ServerError(w, r, err)
return
}
result.Items = make([]item, 0, len(entries))
for _, entry := range entries {
isRead := 0
if entry.Status == model.EntryStatusRead {
isRead = 1
}
isSaved := 0
if entry.Starred {
isSaved = 1
}
result.Items = append(result.Items, item{
ID: entry.ID,
FeedID: entry.FeedID,
Title: entry.Title,
Author: entry.Author,
HTML: mediaproxy.RewriteDocumentWithAbsoluteProxyURL(h.router, entry.Content),
URL: entry.URL,
IsSaved: isSaved,
IsRead: isRead,
CreatedAt: entry.Date.Unix(),
})
}
result.SetCommonValues()
json.OK(w, r, result)
}
/*
The unread_item_ids and saved_item_ids arguments can be used to keep your local cache synced
with the remote Fever installation.
A request with the unread_item_ids argument will return one additional member:
unread_item_ids (string/comma-separated list of positive integers)
*/
func (h *handler) handleUnreadItems(w http.ResponseWriter, r *http.Request) {
userID := request.UserID(r)
slog.Debug("[Fever] Fetching unread items",
slog.Int64("user_id", userID),
)
builder := h.store.NewEntryQueryBuilder(userID)
builder.WithStatus(model.EntryStatusUnread)
rawEntryIDs, err := builder.GetEntryIDs()
if err != nil {
json.ServerError(w, r, err)
return
}
itemIDs := make([]string, 0, len(rawEntryIDs))
for _, entryID := range rawEntryIDs {
itemIDs = append(itemIDs, strconv.FormatInt(entryID, 10))
}
var result unreadResponse
result.ItemIDs = strings.Join(itemIDs, ",")
result.SetCommonValues()
json.OK(w, r, result)
}
/*
The unread_item_ids and saved_item_ids arguments can be used to keep your local cache synced
with the remote Fever installation.
A request with the saved_item_ids argument will return one additional member:
saved_item_ids (string/comma-separated list of positive integers)
*/
func (h *handler) handleSavedItems(w http.ResponseWriter, r *http.Request) {
userID := request.UserID(r)
slog.Debug("[Fever] Fetching saved items",
slog.Int64("user_id", userID),
)
builder := h.store.NewEntryQueryBuilder(userID)
builder.WithStarred(true)
entryIDs, err := builder.GetEntryIDs()
if err != nil {
json.ServerError(w, r, err)
return
}
itemsIDs := make([]string, 0, len(entryIDs))
for _, entryID := range entryIDs {
itemsIDs = append(itemsIDs, strconv.FormatInt(entryID, 10))
}
result := &savedResponse{ItemIDs: strings.Join(itemsIDs, ",")}
result.SetCommonValues()
json.OK(w, r, result)
}
/*
mark=item
as=? where ? is replaced with read, saved or unsaved
id=? where ? is replaced with the id of the item to modify
*/
func (h *handler) handleWriteItems(w http.ResponseWriter, r *http.Request) {
userID := request.UserID(r)
slog.Debug("[Fever] Receiving mark=item call",
slog.Int64("user_id", userID),
)
entryID := request.FormInt64Value(r, "id")
if entryID <= 0 {
return
}
builder := h.store.NewEntryQueryBuilder(userID)
builder.WithEntryID(entryID)
builder.WithoutStatus(model.EntryStatusRemoved)
entry, err := builder.GetEntry()
if err != nil {
json.ServerError(w, r, err)
return
}
if entry == nil {
slog.Debug("[Fever] Entry not found",
slog.Int64("user_id", userID),
slog.Int64("entry_id", entryID),
)
json.OK(w, r, newBaseResponse())
return
}
switch r.FormValue("as") {
case "read":
slog.Debug("[Fever] Mark entry as read",
slog.Int64("user_id", userID),
slog.Int64("entry_id", entryID),
)
h.store.SetEntriesStatus(userID, []int64{entryID}, model.EntryStatusRead)
case "unread":
slog.Debug("[Fever] Mark entry as unread",
slog.Int64("user_id", userID),
slog.Int64("entry_id", entryID),
)
h.store.SetEntriesStatus(userID, []int64{entryID}, model.EntryStatusUnread)
case "saved":
slog.Debug("[Fever] Mark entry as saved",
slog.Int64("user_id", userID),
slog.Int64("entry_id", entryID),
)
if err := h.store.ToggleStarred(userID, entryID); err != nil {
json.ServerError(w, r, err)
return
}
settings, err := h.store.Integration(userID)
if err != nil {
json.ServerError(w, r, err)
return
}
go func() {
integration.SendEntry(entry, settings)
}()
case "unsaved":
slog.Debug("[Fever] Mark entry as unsaved",
slog.Int64("user_id", userID),
slog.Int64("entry_id", entryID),
)
if err := h.store.ToggleStarred(userID, entryID); err != nil {
json.ServerError(w, r, err)
return
}
}
json.OK(w, r, newBaseResponse())
}
/*
mark=feed
as=read
id=? where ? is replaced with the id of the feed or group to modify
before=? where ? is replaced with the Unix timestamp of the the local client’s most recent items API request
*/
func (h *handler) handleWriteFeeds(w http.ResponseWriter, r *http.Request) {
userID := request.UserID(r)
feedID := request.FormInt64Value(r, "id")
before := time.Unix(request.FormInt64Value(r, "before"), 0)
slog.Debug("[Fever] Mark feed as read before a given date",
slog.Int64("user_id", userID),
slog.Int64("feed_id", feedID),
slog.Time("before_ts", before),
)
if feedID <= 0 {
return
}
go func() {
if err := h.store.MarkFeedAsRead(userID, feedID, before); err != nil {
slog.Error("[Fever] Unable to mark feed as read",
slog.Int64("user_id", userID),
slog.Int64("feed_id", feedID),
slog.Time("before_ts", before),
slog.Any("error", err),
)
}
}()
json.OK(w, r, newBaseResponse())
}
/*
mark=group
as=read
id=? where ? is replaced with the id of the feed or group to modify
before=? where ? is replaced with the Unix timestamp of the the local client’s most recent items API request
*/
func (h *handler) handleWriteGroups(w http.ResponseWriter, r *http.Request) {
userID := request.UserID(r)
groupID := request.FormInt64Value(r, "id")
before := time.Unix(request.FormInt64Value(r, "before"), 0)
slog.Debug("[Fever] Mark group as read before a given date",
slog.Int64("user_id", userID),
slog.Int64("group_id", groupID),
slog.Time("before_ts", before),
)
if groupID < 0 {
return
}
go func() {
var err error
if groupID == 0 {
err = h.store.MarkAllAsRead(userID)
} else {
err = h.store.MarkCategoryAsRead(userID, groupID, before)
}
if err != nil {
slog.Error("[Fever] Unable to mark group as read",
slog.Int64("user_id", userID),
slog.Int64("group_id", groupID),
slog.Time("before_ts", before),
slog.Any("error", err),
)
}
}()
json.OK(w, r, newBaseResponse())
}
/*
A feeds_group object has the following members:
group_id (positive integer)
feed_ids (string/comma-separated list of positive integers)
*/
func (h *handler) buildFeedGroups(feeds model.Feeds) []feedsGroups {
feedsGroupedByCategory := make(map[int64][]string, len(feeds))
for _, feed := range feeds {
feedsGroupedByCategory[feed.Category.ID] = append(feedsGroupedByCategory[feed.Category.ID], strconv.FormatInt(feed.ID, 10))
}
result := make([]feedsGroups, 0, len(feedsGroupedByCategory))
for categoryID, feedIDs := range feedsGroupedByCategory {
result = append(result, feedsGroups{
GroupID: categoryID,
FeedIDs: strings.Join(feedIDs, ","),
})
}
return result
}
v2-2.2.16/internal/fever/middleware.go 0000664 0000000 0000000 00000004266 15127074645 0017543 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package fever // import "miniflux.app/v2/internal/fever"
import (
"context"
"log/slog"
"net/http"
"miniflux.app/v2/internal/http/request"
"miniflux.app/v2/internal/http/response/json"
"miniflux.app/v2/internal/storage"
)
type middleware struct {
store *storage.Storage
}
func newMiddleware(s *storage.Storage) *middleware {
return &middleware{s}
}
func (m *middleware) serve(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
clientIP := request.ClientIP(r)
apiKey := r.FormValue("api_key")
if apiKey == "" {
slog.Warn("[Fever] No API key provided",
slog.Bool("authentication_failed", true),
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
)
json.OK(w, r, newAuthFailureResponse())
return
}
user, err := m.store.UserByFeverToken(apiKey)
if err != nil {
slog.Error("[Fever] Unable to fetch user by API key",
slog.Bool("authentication_failed", true),
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
slog.Any("error", err),
)
json.OK(w, r, newAuthFailureResponse())
return
}
if user == nil {
slog.Warn("[Fever] No user found with the API key provided",
slog.Bool("authentication_failed", true),
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
)
json.OK(w, r, newAuthFailureResponse())
return
}
slog.Info("[Fever] User authenticated successfully",
slog.Bool("authentication_successful", true),
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
slog.Int64("user_id", user.ID),
slog.String("username", user.Username),
)
m.store.SetLastLogin(user.ID)
ctx := r.Context()
ctx = context.WithValue(ctx, request.UserIDContextKey, user.ID)
ctx = context.WithValue(ctx, request.UserTimezoneContextKey, user.Timezone)
ctx = context.WithValue(ctx, request.IsAdminUserContextKey, user.IsAdmin)
ctx = context.WithValue(ctx, request.IsAuthenticatedContextKey, true)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
v2-2.2.16/internal/fever/response.go 0000664 0000000 0000000 00000005404 15127074645 0017257 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package fever // import "miniflux.app/v2/internal/fever"
import (
"time"
)
type baseResponse struct {
Version int `json:"api_version"`
Authenticated int `json:"auth"`
LastRefresh int64 `json:"last_refreshed_on_time"`
}
func (b *baseResponse) SetCommonValues() {
b.Version = 3
b.Authenticated = 1
b.LastRefresh = time.Now().Unix()
}
/*
The default response is a JSON object containing two members:
api_version contains the version of the API responding (positive integer)
auth whether the request was successfully authenticated (boolean integer)
The API can also return XML by passing xml as the optional value of the api argument like so:
http://yourdomain.com/fever/?api=xml
The top level XML element is named response.
The response to each successfully authenticated request will have auth set to 1 and include
at least one additional member:
last_refreshed_on_time contains the time of the most recently refreshed (not updated)
feed (Unix timestamp/integer)
*/
func newBaseResponse() baseResponse {
r := baseResponse{}
r.SetCommonValues()
return r
}
func newAuthFailureResponse() baseResponse {
return baseResponse{Version: 3, Authenticated: 0}
}
type groupsResponse struct {
baseResponse
Groups []group `json:"groups"`
FeedsGroups []feedsGroups `json:"feeds_groups"`
}
type feedsResponse struct {
baseResponse
Feeds []feed `json:"feeds"`
FeedsGroups []feedsGroups `json:"feeds_groups"`
}
type faviconsResponse struct {
baseResponse
Favicons []favicon `json:"favicons"`
}
type itemsResponse struct {
baseResponse
Items []item `json:"items"`
Total int `json:"total_items"`
}
type unreadResponse struct {
baseResponse
ItemIDs string `json:"unread_item_ids"`
}
type savedResponse struct {
baseResponse
ItemIDs string `json:"saved_item_ids"`
}
type group struct {
ID int64 `json:"id"`
Title string `json:"title"`
}
type feedsGroups struct {
GroupID int64 `json:"group_id"`
FeedIDs string `json:"feed_ids"`
}
type feed struct {
ID int64 `json:"id"`
FaviconID int64 `json:"favicon_id"`
Title string `json:"title"`
URL string `json:"url"`
SiteURL string `json:"site_url"`
IsSpark int `json:"is_spark"`
LastUpdated int64 `json:"last_updated_on_time"`
}
type item struct {
ID int64 `json:"id"`
FeedID int64 `json:"feed_id"`
Title string `json:"title"`
Author string `json:"author"`
HTML string `json:"html"`
URL string `json:"url"`
IsSaved int `json:"is_saved"`
IsRead int `json:"is_read"`
CreatedAt int64 `json:"created_on_time"`
}
type favicon struct {
ID int64 `json:"id"`
Data string `json:"data"`
}
v2-2.2.16/internal/googlereader/ 0000775 0000000 0000000 00000000000 15127074645 0016417 5 ustar 00root root 0000000 0000000 v2-2.2.16/internal/googlereader/handler.go 0000664 0000000 0000000 00000110643 15127074645 0020370 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package googlereader // import "miniflux.app/v2/internal/googlereader"
import (
"errors"
"fmt"
"log/slog"
"net/http"
"strconv"
"time"
"miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/http/request"
"miniflux.app/v2/internal/http/response"
"miniflux.app/v2/internal/http/response/json"
"miniflux.app/v2/internal/http/route"
"miniflux.app/v2/internal/integration"
"miniflux.app/v2/internal/mediaproxy"
"miniflux.app/v2/internal/model"
"miniflux.app/v2/internal/proxyrotator"
"miniflux.app/v2/internal/reader/fetcher"
mff "miniflux.app/v2/internal/reader/handler"
mfs "miniflux.app/v2/internal/reader/subscription"
"miniflux.app/v2/internal/storage"
"miniflux.app/v2/internal/validator"
"github.com/gorilla/mux"
)
type handler struct {
store *storage.Storage
router *mux.Router
}
var (
errEmptyFeedTitle = errors.New("googlereader: empty feed title")
errFeedNotFound = errors.New("googlereader: feed not found")
errCategoryNotFound = errors.New("googlereader: category not found")
)
// Serve handles Google Reader API calls.
func Serve(router *mux.Router, store *storage.Storage) {
handler := &handler{store, router}
router.HandleFunc("/accounts/ClientLogin", handler.clientLoginHandler).Methods(http.MethodPost).Name("ClientLogin")
middleware := newMiddleware(store)
sr := router.PathPrefix("/reader/api/0").Subrouter()
sr.Use(middleware.apiKeyAuth)
sr.HandleFunc("/token", handler.tokenHandler).Methods(http.MethodGet).Name("Token")
sr.HandleFunc("/edit-tag", handler.editTagHandler).Methods(http.MethodPost).Name("EditTag")
sr.HandleFunc("/rename-tag", handler.renameTagHandler).Methods(http.MethodPost).Name("Rename Tag")
sr.HandleFunc("/disable-tag", handler.disableTagHandler).Methods(http.MethodPost).Name("Disable Tag")
sr.HandleFunc("/tag/list", handler.tagListHandler).Methods(http.MethodGet).Name("TagList")
sr.HandleFunc("/user-info", handler.userInfoHandler).Methods(http.MethodGet).Name("UserInfo")
sr.HandleFunc("/subscription/list", handler.subscriptionListHandler).Methods(http.MethodGet).Name("SubscriptonList")
sr.HandleFunc("/subscription/edit", handler.editSubscriptionHandler).Methods(http.MethodPost).Name("SubscriptionEdit")
sr.HandleFunc("/subscription/quickadd", handler.quickAddHandler).Methods(http.MethodPost).Name("QuickAdd")
sr.HandleFunc("/stream/items/ids", handler.streamItemIDsHandler).Methods(http.MethodGet).Name("StreamItemIDs")
sr.HandleFunc("/stream/items/contents", handler.streamItemContentsHandler).Methods(http.MethodPost).Name("StreamItemsContents")
sr.HandleFunc("/mark-all-as-read", handler.markAllAsReadHandler).Methods(http.MethodPost).Name("MarkAllAsRead")
sr.PathPrefix("/").HandlerFunc(handler.serveHandler).Methods(http.MethodPost, http.MethodGet).Name("GoogleReaderApiEndpoint")
}
func checkAndSimplifyTags(addTags []Stream, removeTags []Stream) (map[StreamType]bool, error) {
tags := make(map[StreamType]bool)
for _, s := range addTags {
switch s.Type {
case ReadStream:
if _, ok := tags[KeptUnreadStream]; ok {
return nil, fmt.Errorf("googlereader: %s and %s should not be supplied simultaneously", keptUnreadStreamSuffix, readStreamSuffix)
}
tags[ReadStream] = true
case KeptUnreadStream:
if _, ok := tags[ReadStream]; ok {
return nil, fmt.Errorf("googlereader: %s and %s should not be supplied simultaneously", keptUnreadStreamSuffix, readStreamSuffix)
}
tags[ReadStream] = false
case StarredStream:
tags[StarredStream] = true
case BroadcastStream, LikeStream:
slog.Debug("Broadcast & Like tags are not implemented!")
default:
return nil, fmt.Errorf("googlereader: unsupported tag type: %s", s.Type)
}
}
for _, s := range removeTags {
switch s.Type {
case ReadStream:
if _, ok := tags[ReadStream]; ok {
return nil, fmt.Errorf("googlereader: %s and %s should not be supplied simultaneously", keptUnreadStreamSuffix, readStreamSuffix)
}
tags[ReadStream] = false
case KeptUnreadStream:
if _, ok := tags[ReadStream]; ok {
return nil, fmt.Errorf("googlereader: %s and %s should not be supplied simultaneously", keptUnreadStreamSuffix, readStreamSuffix)
}
tags[ReadStream] = true
case StarredStream:
if _, ok := tags[StarredStream]; ok {
return nil, fmt.Errorf("googlereader: %s should not be supplied for add and remove simultaneously", starredStreamSuffix)
}
tags[StarredStream] = false
case BroadcastStream, LikeStream:
slog.Debug("Broadcast & Like tags are not implemented!")
default:
return nil, fmt.Errorf("googlereader: unsupported tag type: %s", s.Type)
}
}
return tags, nil
}
func checkOutputFormat(r *http.Request) error {
var output string
if r.Method == http.MethodPost {
err := r.ParseForm()
if err != nil {
return err
}
output = r.Form.Get("output")
} else {
output = request.QueryStringParam(r, "output", "")
}
if output != "json" {
return errors.New("googlereader: only json output is supported")
}
return nil
}
func (h *handler) clientLoginHandler(w http.ResponseWriter, r *http.Request) {
clientIP := request.ClientIP(r)
slog.Debug("[GoogleReader] Handle /accounts/ClientLogin",
slog.String("handler", "clientLoginHandler"),
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
)
if err := r.ParseForm(); err != nil {
slog.Warn("[GoogleReader] Could not parse request form data",
slog.Bool("authentication_failed", true),
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
slog.Any("error", err),
)
json.Unauthorized(w, r)
return
}
username := r.Form.Get("Email")
password := r.Form.Get("Passwd")
output := r.Form.Get("output")
if username == "" || password == "" {
slog.Warn("[GoogleReader] Empty username or password",
slog.Bool("authentication_failed", true),
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
)
json.Unauthorized(w, r)
return
}
if err := h.store.GoogleReaderUserCheckPassword(username, password); err != nil {
slog.Warn("[GoogleReader] Invalid username or password",
slog.Bool("authentication_failed", true),
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
slog.String("username", username),
slog.Any("error", err),
)
json.Unauthorized(w, r)
return
}
slog.Info("[GoogleReader] User authenticated successfully",
slog.Bool("authentication_successful", true),
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
slog.String("username", username),
)
integration, err := h.store.GoogleReaderUserGetIntegration(username)
if err != nil {
json.ServerError(w, r, err)
return
}
h.store.SetLastLogin(integration.UserID)
token := getAuthToken(integration.GoogleReaderUsername, integration.GoogleReaderPassword)
slog.Debug("[GoogleReader] Created token",
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
slog.String("username", username),
)
result := loginResponse{SID: token, LSID: token, Auth: token}
if output == "json" {
json.OK(w, r, result)
return
}
builder := response.New(w, r)
builder.WithHeader("Content-Type", "text/plain; charset=UTF-8")
builder.WithBody(result.String())
builder.Write()
}
func (h *handler) tokenHandler(w http.ResponseWriter, r *http.Request) {
clientIP := request.ClientIP(r)
slog.Debug("[GoogleReader] Handle /token",
slog.String("handler", "tokenHandler"),
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
)
if !request.IsAuthenticated(r) {
slog.Warn("[GoogleReader] User is not authenticated",
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
)
json.Unauthorized(w, r)
return
}
token := request.GoogleReaderToken(r)
if token == "" {
slog.Warn("[GoogleReader] User does not have token",
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
slog.Int64("user_id", request.UserID(r)),
)
json.Unauthorized(w, r)
return
}
slog.Debug("[GoogleReader] Token handler",
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
slog.Int64("user_id", request.UserID(r)),
)
w.Header().Add("Content-Type", "text/plain; charset=UTF-8")
w.WriteHeader(http.StatusOK)
w.Write([]byte(token))
}
func (h *handler) editTagHandler(w http.ResponseWriter, r *http.Request) {
userID := request.UserID(r)
clientIP := request.ClientIP(r)
slog.Debug("[GoogleReader] Handle /edit-tag",
slog.String("handler", "editTagHandler"),
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
slog.Int64("user_id", userID),
)
if err := r.ParseForm(); err != nil {
json.ServerError(w, r, err)
return
}
addTags, err := getStreams(r.PostForm[paramTagsAdd], userID)
if err != nil {
json.ServerError(w, r, err)
return
}
removeTags, err := getStreams(r.PostForm[paramTagsRemove], userID)
if err != nil {
json.ServerError(w, r, err)
return
}
if len(addTags) == 0 && len(removeTags) == 0 {
err = errors.New("googlreader: add or/and remove tags should be supplied")
json.ServerError(w, r, err)
return
}
tags, err := checkAndSimplifyTags(addTags, removeTags)
if err != nil {
json.ServerError(w, r, err)
return
}
itemIDs, err := parseItemIDsFromRequest(r)
if err != nil {
json.BadRequest(w, r, err)
return
}
slog.Debug("[GoogleReader] Edited tags",
slog.String("handler", "editTagHandler"),
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
slog.Int64("user_id", userID),
slog.Any("item_ids", itemIDs),
slog.Any("tags", tags),
)
builder := h.store.NewEntryQueryBuilder(userID)
builder.WithEntryIDs(itemIDs)
builder.WithoutStatus(model.EntryStatusRemoved)
entries, err := builder.GetEntries()
if err != nil {
json.ServerError(w, r, err)
return
}
n := 0
readEntryIDs := make([]int64, 0)
unreadEntryIDs := make([]int64, 0)
starredEntryIDs := make([]int64, 0)
unstarredEntryIDs := make([]int64, 0)
for _, entry := range entries {
if read, exists := tags[ReadStream]; exists {
if read && entry.Status == model.EntryStatusUnread {
readEntryIDs = append(readEntryIDs, entry.ID)
} else if entry.Status == model.EntryStatusRead {
unreadEntryIDs = append(unreadEntryIDs, entry.ID)
}
}
if starred, exists := tags[StarredStream]; exists {
if starred && !entry.Starred {
starredEntryIDs = append(starredEntryIDs, entry.ID)
// filter the original array
entries[n] = entry
n++
} else if entry.Starred {
unstarredEntryIDs = append(unstarredEntryIDs, entry.ID)
}
}
}
entries = entries[:n]
if len(readEntryIDs) > 0 {
err = h.store.SetEntriesStatus(userID, readEntryIDs, model.EntryStatusRead)
if err != nil {
json.ServerError(w, r, err)
return
}
}
if len(unreadEntryIDs) > 0 {
err = h.store.SetEntriesStatus(userID, unreadEntryIDs, model.EntryStatusUnread)
if err != nil {
json.ServerError(w, r, err)
return
}
}
if len(unstarredEntryIDs) > 0 {
err = h.store.SetEntriesStarredState(userID, unstarredEntryIDs, false)
if err != nil {
json.ServerError(w, r, err)
return
}
}
if len(starredEntryIDs) > 0 {
err = h.store.SetEntriesStarredState(userID, starredEntryIDs, true)
if err != nil {
json.ServerError(w, r, err)
return
}
}
if len(entries) > 0 {
settings, err := h.store.Integration(userID)
if err != nil {
json.ServerError(w, r, err)
return
}
for _, entry := range entries {
e := entry
go func() {
integration.SendEntry(e, settings)
}()
}
}
sendOkayResponse(w)
}
func (h *handler) quickAddHandler(w http.ResponseWriter, r *http.Request) {
userID := request.UserID(r)
clientIP := request.ClientIP(r)
slog.Debug("[GoogleReader] Handle /subscription/quickadd",
slog.String("handler", "quickAddHandler"),
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
slog.Int64("user_id", userID),
)
err := r.ParseForm()
if err != nil {
json.BadRequest(w, r, err)
return
}
feedURL := r.Form.Get(paramQuickAdd)
if !validator.IsValidURL(feedURL) {
json.BadRequest(w, r, fmt.Errorf("googlereader: invalid URL: %s", feedURL))
return
}
requestBuilder := fetcher.NewRequestBuilder()
requestBuilder.WithTimeout(config.Opts.HTTPClientTimeout())
requestBuilder.WithProxyRotator(proxyrotator.ProxyRotatorInstance)
var rssBridgeURL string
var rssBridgeToken string
if intg, err := h.store.Integration(userID); err == nil && intg != nil && intg.RSSBridgeEnabled {
rssBridgeURL = intg.RSSBridgeURL
rssBridgeToken = intg.RSSBridgeToken
}
subscriptions, localizedError := mfs.NewSubscriptionFinder(requestBuilder).FindSubscriptions(feedURL, rssBridgeURL, rssBridgeToken)
if localizedError != nil {
json.ServerError(w, r, localizedError.Error())
return
}
if len(subscriptions) == 0 {
json.OK(w, r, quickAddResponse{
NumResults: 0,
})
return
}
toSubscribe := Stream{FeedStream, subscriptions[0].URL}
category := Stream{NoStream, ""}
newFeed, err := subscribe(toSubscribe, category, "", h.store, userID)
if err != nil {
json.ServerError(w, r, err)
return
}
slog.Debug("[GoogleReader] Added a new feed",
slog.String("handler", "quickAddHandler"),
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
slog.Int64("user_id", userID),
slog.String("feed_url", newFeed.FeedURL),
)
json.OK(w, r, quickAddResponse{
NumResults: 1,
Query: newFeed.FeedURL,
StreamID: fmt.Sprintf(feedPrefix+"%d", newFeed.ID),
StreamName: newFeed.Title,
})
}
func getFeed(stream Stream, store *storage.Storage, userID int64) (*model.Feed, error) {
feedID, err := strconv.ParseInt(stream.ID, 10, 64)
if err != nil {
return nil, err
}
return store.FeedByID(userID, feedID)
}
func getOrCreateCategory(streamCategory Stream, store *storage.Storage, userID int64) (*model.Category, error) {
switch {
case streamCategory.ID == "":
return store.FirstCategory(userID)
case store.CategoryTitleExists(userID, streamCategory.ID):
return store.CategoryByTitle(userID, streamCategory.ID)
default:
return store.CreateCategory(userID, &model.CategoryCreationRequest{
Title: streamCategory.ID,
})
}
}
func subscribe(newFeed Stream, category Stream, title string, store *storage.Storage, userID int64) (*model.Feed, error) {
destCategory, err := getOrCreateCategory(category, store, userID)
if err != nil {
return nil, err
}
feedRequest := model.FeedCreationRequest{
FeedURL: newFeed.ID,
CategoryID: destCategory.ID,
}
verr := validator.ValidateFeedCreation(store, userID, &feedRequest)
if verr != nil {
return nil, verr.Error()
}
created, localizedError := mff.CreateFeed(store, userID, &feedRequest)
if localizedError != nil {
return nil, localizedError.Error()
}
if title != "" {
feedModification := model.FeedModificationRequest{
Title: &title,
}
feedModification.Patch(created)
if err := store.UpdateFeed(created); err != nil {
return nil, err
}
}
return created, nil
}
func unsubscribe(streams []Stream, store *storage.Storage, userID int64) error {
for _, stream := range streams {
feedID, err := strconv.ParseInt(stream.ID, 10, 64)
if err != nil {
return err
}
err = store.RemoveFeed(userID, feedID)
if err != nil {
return err
}
}
return nil
}
func rename(feedStream Stream, title string, store *storage.Storage, userID int64) error {
slog.Debug("[GoogleReader] Renaming feed",
slog.Int64("user_id", userID),
slog.Any("feed_stream", feedStream),
slog.String("new_title", title),
)
if title == "" {
return errEmptyFeedTitle
}
feed, err := getFeed(feedStream, store, userID)
if err != nil {
return err
}
if feed == nil {
return errFeedNotFound
}
feedModification := model.FeedModificationRequest{
Title: &title,
}
feedModification.Patch(feed)
return store.UpdateFeed(feed)
}
func move(feedStream Stream, labelStream Stream, store *storage.Storage, userID int64) error {
slog.Debug("[GoogleReader] Moving feed",
slog.Int64("user_id", userID),
slog.Any("feed_stream", feedStream),
slog.Any("label_stream", labelStream),
)
feed, err := getFeed(feedStream, store, userID)
if err != nil {
return err
}
if feed == nil {
return errFeedNotFound
}
category, err := getOrCreateCategory(labelStream, store, userID)
if err != nil {
return err
}
if category == nil {
return errCategoryNotFound
}
feedModification := model.FeedModificationRequest{
CategoryID: &category.ID,
}
feedModification.Patch(feed)
return store.UpdateFeed(feed)
}
func (h *handler) feedIconURL(f *model.Feed) string {
if f.Icon != nil && f.Icon.ExternalIconID != "" {
return config.Opts.RootURL() + route.Path(h.router, "feedIcon", "externalIconID", f.Icon.ExternalIconID)
} else {
return ""
}
}
func (h *handler) editSubscriptionHandler(w http.ResponseWriter, r *http.Request) {
userID := request.UserID(r)
clientIP := request.ClientIP(r)
slog.Debug("[GoogleReader] Handle /subscription/edit",
slog.String("handler", "editSubscriptionHandler"),
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
slog.Int64("user_id", userID),
)
if err := r.ParseForm(); err != nil {
json.BadRequest(w, r, err)
return
}
streamIds, err := getStreams(r.Form[paramStreamID], userID)
if err != nil || len(streamIds) == 0 {
json.BadRequest(w, r, errors.New("googlereader: no valid stream IDs provided"))
return
}
newLabel, err := getStream(r.Form.Get(paramTagsAdd), userID)
if err != nil {
json.BadRequest(w, r, fmt.Errorf("googlereader: invalid data in %s", paramTagsAdd))
return
}
title := r.Form.Get(paramTitle)
action := r.Form.Get(paramSubscribeAction)
switch action {
case "subscribe":
_, err := subscribe(streamIds[0], newLabel, title, h.store, userID)
if err != nil {
json.ServerError(w, r, err)
return
}
case "unsubscribe":
err := unsubscribe(streamIds, h.store, userID)
if err != nil {
json.ServerError(w, r, err)
return
}
case "edit":
if title != "" {
if err := rename(streamIds[0], title, h.store, userID); err != nil {
if errors.Is(err, errFeedNotFound) || errors.Is(err, errEmptyFeedTitle) {
json.BadRequest(w, r, err)
} else {
json.ServerError(w, r, err)
}
return
}
}
if r.Form.Has(paramTagsAdd) {
if newLabel.Type != LabelStream {
json.BadRequest(w, r, errors.New("destination must be a label"))
return
}
if err := move(streamIds[0], newLabel, h.store, userID); err != nil {
if errors.Is(err, errFeedNotFound) || errors.Is(err, errCategoryNotFound) {
json.BadRequest(w, r, err)
} else {
json.ServerError(w, r, err)
}
return
}
}
default:
json.BadRequest(w, r, fmt.Errorf("googlereader: unrecognized action %s", action))
return
}
sendOkayResponse(w)
}
func (h *handler) streamItemContentsHandler(w http.ResponseWriter, r *http.Request) {
userID := request.UserID(r)
userName := request.UserName(r)
clientIP := request.ClientIP(r)
slog.Debug("[GoogleReader] Handle /stream/items/contents",
slog.String("handler", "streamItemContentsHandler"),
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
slog.Int64("user_id", userID),
)
if err := checkOutputFormat(r); err != nil {
json.BadRequest(w, r, err)
return
}
err := r.ParseForm()
if err != nil {
json.ServerError(w, r, err)
return
}
requestModifiers, err := parseStreamFilterFromRequest(r)
if err != nil {
json.ServerError(w, r, err)
return
}
userReadingList := fmt.Sprintf(userStreamPrefix, userID) + readingListStreamSuffix
userRead := fmt.Sprintf(userStreamPrefix, userID) + readStreamSuffix
userStarred := fmt.Sprintf(userStreamPrefix, userID) + starredStreamSuffix
itemIDs, err := parseItemIDsFromRequest(r)
if err != nil {
json.BadRequest(w, r, err)
return
}
slog.Debug("[GoogleReader] Fetching item contents",
slog.String("handler", "streamItemContentsHandler"),
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
slog.Int64("user_id", userID),
slog.Any("item_ids", itemIDs),
)
builder := h.store.NewEntryQueryBuilder(userID)
builder.WithEnclosures()
builder.WithoutStatus(model.EntryStatusRemoved)
builder.WithEntryIDs(itemIDs)
builder.WithSorting(model.DefaultSortingOrder, requestModifiers.SortDirection)
entries, err := builder.GetEntries()
if err != nil {
json.ServerError(w, r, err)
return
}
result := streamContentItemsResponse{
Direction: "ltr",
ID: "user/-/state/com.google/reading-list",
Title: "Reading List",
Updated: time.Now().Unix(),
Self: []contentHREF{{
HREF: config.Opts.RootURL() + route.Path(h.router, "StreamItemsContents"),
}},
Author: userName,
Items: make([]contentItem, len(entries)),
}
for i, entry := range entries {
enclosures := make([]contentItemEnclosure, 0, len(entry.Enclosures))
for _, enclosure := range entry.Enclosures {
enclosures = append(enclosures, contentItemEnclosure{URL: enclosure.URL, Type: enclosure.MimeType})
}
categories := make([]string, 0)
categories = append(categories, userReadingList)
if entry.Feed.Category.Title != "" {
categories = append(categories, fmt.Sprintf(userLabelPrefix, userID)+entry.Feed.Category.Title)
}
if entry.Status == model.EntryStatusRead {
categories = append(categories, userRead)
}
if entry.Starred {
categories = append(categories, userStarred)
}
entry.Content = mediaproxy.RewriteDocumentWithAbsoluteProxyURL(h.router, entry.Content)
entry.Enclosures.ProxifyEnclosureURL(h.router, config.Opts.MediaProxyMode(), config.Opts.MediaProxyResourceTypes())
result.Items[i] = contentItem{
ID: convertEntryIDToLongFormItemID(entry.ID),
Title: entry.Title,
Author: entry.Author,
TimestampUsec: strconv.FormatInt(entry.Date.UnixMicro(), 10),
CrawlTimeMsec: strconv.FormatInt(entry.CreatedAt.UnixMilli(), 10),
Published: entry.Date.Unix(),
Updated: entry.ChangedAt.Unix(),
Categories: categories,
Canonical: []contentHREF{
{
HREF: entry.URL,
},
},
Alternate: []contentHREFType{
{
HREF: entry.URL,
Type: "text/html",
},
},
Content: contentItemContent{
Direction: "ltr",
Content: entry.Content,
},
Summary: contentItemContent{
Direction: "ltr",
Content: entry.Content,
},
Origin: contentItemOrigin{
StreamID: fmt.Sprintf(feedPrefix+"%d", entry.FeedID),
Title: entry.Feed.Title,
HTMLUrl: entry.Feed.SiteURL,
},
Enclosure: enclosures,
}
}
json.OK(w, r, result)
}
func (h *handler) disableTagHandler(w http.ResponseWriter, r *http.Request) {
userID := request.UserID(r)
clientIP := request.ClientIP(r)
slog.Debug("[GoogleReader] Handle /disable-tags",
slog.String("handler", "disableTagHandler"),
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
slog.Int64("user_id", userID),
)
err := r.ParseForm()
if err != nil {
json.BadRequest(w, r, err)
return
}
streams, err := getStreams(r.Form[paramStreamID], userID)
if err != nil {
json.BadRequest(w, r, fmt.Errorf("googlereader: invalid data in %s", paramStreamID))
return
}
titles := make([]string, len(streams))
for i, stream := range streams {
if stream.Type != LabelStream {
json.BadRequest(w, r, errors.New("googlereader: only labels are supported"))
return
}
titles[i] = stream.ID
}
err = h.store.RemoveAndReplaceCategoriesByName(userID, titles)
if err != nil {
json.ServerError(w, r, err)
return
}
sendOkayResponse(w)
}
func (h *handler) renameTagHandler(w http.ResponseWriter, r *http.Request) {
userID := request.UserID(r)
clientIP := request.ClientIP(r)
slog.Debug("[GoogleReader] Handle /rename-tag",
slog.String("handler", "renameTagHandler"),
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
)
err := r.ParseForm()
if err != nil {
json.BadRequest(w, r, err)
return
}
source, err := getStream(r.Form.Get(paramStreamID), userID)
if err != nil {
json.BadRequest(w, r, fmt.Errorf("googlereader: invalid data in %s", paramStreamID))
return
}
destination, err := getStream(r.Form.Get(paramDestination), userID)
if err != nil {
json.BadRequest(w, r, fmt.Errorf("googlereader: invalid data in %s", paramDestination))
return
}
if source.Type != LabelStream || destination.Type != LabelStream {
json.BadRequest(w, r, errors.New("googlereader: only labels supported"))
return
}
if destination.ID == "" {
json.BadRequest(w, r, errors.New("googlereader: empty destination name"))
return
}
category, err := h.store.CategoryByTitle(userID, source.ID)
if err != nil {
json.ServerError(w, r, err)
return
}
if category == nil {
json.NotFound(w, r)
return
}
categoryModificationRequest := model.CategoryModificationRequest{
Title: model.SetOptionalField(destination.ID),
}
if validationError := validator.ValidateCategoryModification(h.store, userID, category.ID, &categoryModificationRequest); validationError != nil {
json.BadRequest(w, r, validationError.Error())
return
}
categoryModificationRequest.Patch(category)
if err := h.store.UpdateCategory(category); err != nil {
json.ServerError(w, r, err)
return
}
sendOkayResponse(w)
}
func (h *handler) tagListHandler(w http.ResponseWriter, r *http.Request) {
userID := request.UserID(r)
clientIP := request.ClientIP(r)
slog.Debug("[GoogleReader] Handle /tags/list",
slog.String("handler", "tagListHandler"),
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
)
if err := checkOutputFormat(r); err != nil {
json.BadRequest(w, r, err)
return
}
var result tagsResponse
categories, err := h.store.Categories(userID)
if err != nil {
json.ServerError(w, r, err)
return
}
result.Tags = make([]subscriptionCategoryResponse, 0)
result.Tags = append(result.Tags, subscriptionCategoryResponse{
ID: fmt.Sprintf(userStreamPrefix, userID) + starredStreamSuffix,
})
for _, category := range categories {
result.Tags = append(result.Tags, subscriptionCategoryResponse{
ID: fmt.Sprintf(userLabelPrefix, userID) + category.Title,
Label: category.Title,
Type: "folder",
})
}
json.OK(w, r, result)
}
func (h *handler) subscriptionListHandler(w http.ResponseWriter, r *http.Request) {
userID := request.UserID(r)
clientIP := request.ClientIP(r)
slog.Debug("[GoogleReader] Handle /subscription/list",
slog.String("handler", "subscriptionListHandler"),
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
)
if err := checkOutputFormat(r); err != nil {
json.BadRequest(w, r, err)
return
}
var result subscriptionsResponse
feeds, err := h.store.Feeds(userID)
if err != nil {
json.ServerError(w, r, err)
return
}
result.Subscriptions = make([]subscriptionResponse, 0)
for _, feed := range feeds {
result.Subscriptions = append(result.Subscriptions, subscriptionResponse{
ID: fmt.Sprintf(feedPrefix+"%d", feed.ID),
Title: feed.Title,
URL: feed.FeedURL,
Categories: []subscriptionCategoryResponse{{fmt.Sprintf(userLabelPrefix, userID) + feed.Category.Title, feed.Category.Title, "folder"}},
HTMLURL: feed.SiteURL,
IconURL: h.feedIconURL(feed),
})
}
json.OK(w, r, result)
}
func (h *handler) serveHandler(w http.ResponseWriter, r *http.Request) {
clientIP := request.ClientIP(r)
slog.Debug("[GoogleReader] API endpoint not implemented yet",
slog.Any("url", r.RequestURI),
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
)
json.OK(w, r, []string{})
}
func (h *handler) userInfoHandler(w http.ResponseWriter, r *http.Request) {
clientIP := request.ClientIP(r)
slog.Debug("[GoogleReader] Handle /user-info",
slog.String("handler", "userInfoHandler"),
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
)
user, err := h.store.UserByID(request.UserID(r))
if err != nil {
json.ServerError(w, r, err)
return
}
if user == nil {
json.NotFound(w, r)
return
}
userInfo := userInfoResponse{UserID: strconv.FormatInt(user.ID, 10), UserName: user.Username, UserProfileID: strconv.FormatInt(user.ID, 10), UserEmail: user.Username}
json.OK(w, r, userInfo)
}
func (h *handler) streamItemIDsHandler(w http.ResponseWriter, r *http.Request) {
userID := request.UserID(r)
clientIP := request.ClientIP(r)
slog.Debug("[GoogleReader] Handle /stream/items/ids",
slog.String("handler", "streamItemIDsHandler"),
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
slog.Int64("user_id", userID),
)
if err := checkOutputFormat(r); err != nil {
json.BadRequest(w, r, err)
return
}
rm, err := parseStreamFilterFromRequest(r)
if err != nil {
json.ServerError(w, r, err)
return
}
slog.Debug("[GoogleReader] Request Modifiers",
slog.String("handler", "streamItemIDsHandler"),
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
slog.Any("modifiers", rm),
)
if len(rm.Streams) != 1 {
json.ServerError(w, r, errors.New("googlereader: only one stream type expected"))
return
}
switch rm.Streams[0].Type {
case ReadingListStream:
h.handleReadingListStreamHandler(w, r, rm)
case StarredStream:
h.handleStarredStreamHandler(w, r, rm)
case ReadStream:
h.handleReadStreamHandler(w, r, rm)
case FeedStream:
h.handleFeedStreamHandler(w, r, rm)
default:
slog.Warn("[GoogleReader] Unknown Stream",
slog.String("handler", "streamItemIDsHandler"),
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
slog.Any("stream_type", rm.Streams[0].Type),
)
json.ServerError(w, r, fmt.Errorf("googlereader: unknown stream type %s", rm.Streams[0].Type))
}
}
func (h *handler) handleReadingListStreamHandler(w http.ResponseWriter, r *http.Request, rm RequestModifiers) {
clientIP := request.ClientIP(r)
slog.Debug("[GoogleReader] Handle ReadingListStream",
slog.String("handler", "handleReadingListStreamHandler"),
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
)
builder := h.store.NewEntryQueryBuilder(rm.UserID)
for _, s := range rm.ExcludeTargets {
switch s.Type {
case ReadStream:
builder.WithStatus(model.EntryStatusUnread)
default:
slog.Warn("[GoogleReader] Unknown ExcludeTargets filter type",
slog.String("handler", "handleReadingListStreamHandler"),
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
slog.Int("filter_type", int(s.Type)),
)
}
}
builder.WithoutStatus(model.EntryStatusRemoved)
builder.WithLimit(rm.Count)
builder.WithOffset(rm.Offset)
builder.WithSorting(model.DefaultSortingOrder, rm.SortDirection)
if rm.StartTime > 0 {
builder.AfterPublishedDate(time.Unix(rm.StartTime, 0))
}
if rm.StopTime > 0 {
builder.BeforePublishedDate(time.Unix(rm.StopTime, 0))
}
rawEntryIDs, err := builder.GetEntryIDs()
if err != nil {
json.ServerError(w, r, err)
return
}
var itemRefs = make([]itemRef, 0)
for _, entryID := range rawEntryIDs {
formattedID := strconv.FormatInt(entryID, 10)
itemRefs = append(itemRefs, itemRef{ID: formattedID})
}
totalEntries, err := builder.CountEntries()
if err != nil {
json.ServerError(w, r, err)
return
}
continuation := 0
if len(itemRefs)+rm.Offset < totalEntries {
continuation = len(itemRefs) + rm.Offset
}
json.OK(w, r, streamIDResponse{itemRefs, continuation})
}
func (h *handler) handleStarredStreamHandler(w http.ResponseWriter, r *http.Request, rm RequestModifiers) {
builder := h.store.NewEntryQueryBuilder(rm.UserID)
builder.WithoutStatus(model.EntryStatusRemoved)
builder.WithStarred(true)
builder.WithLimit(rm.Count)
builder.WithOffset(rm.Offset)
builder.WithSorting(model.DefaultSortingOrder, rm.SortDirection)
if rm.StartTime > 0 {
builder.AfterPublishedDate(time.Unix(rm.StartTime, 0))
}
if rm.StopTime > 0 {
builder.BeforePublishedDate(time.Unix(rm.StopTime, 0))
}
rawEntryIDs, err := builder.GetEntryIDs()
if err != nil {
json.ServerError(w, r, err)
return
}
var itemRefs = make([]itemRef, 0)
for _, entryID := range rawEntryIDs {
formattedID := strconv.FormatInt(entryID, 10)
itemRefs = append(itemRefs, itemRef{ID: formattedID})
}
totalEntries, err := builder.CountEntries()
if err != nil {
json.ServerError(w, r, err)
return
}
continuation := 0
if len(itemRefs)+rm.Offset < totalEntries {
continuation = len(itemRefs) + rm.Offset
}
json.OK(w, r, streamIDResponse{itemRefs, continuation})
}
func (h *handler) handleReadStreamHandler(w http.ResponseWriter, r *http.Request, rm RequestModifiers) {
builder := h.store.NewEntryQueryBuilder(rm.UserID)
builder.WithoutStatus(model.EntryStatusRemoved)
builder.WithStatus(model.EntryStatusRead)
builder.WithLimit(rm.Count)
builder.WithOffset(rm.Offset)
builder.WithSorting(model.DefaultSortingOrder, rm.SortDirection)
if rm.StartTime > 0 {
builder.AfterPublishedDate(time.Unix(rm.StartTime, 0))
}
if rm.StopTime > 0 {
builder.BeforePublishedDate(time.Unix(rm.StopTime, 0))
}
rawEntryIDs, err := builder.GetEntryIDs()
if err != nil {
json.ServerError(w, r, err)
return
}
var itemRefs = make([]itemRef, 0)
for _, entryID := range rawEntryIDs {
formattedID := strconv.FormatInt(entryID, 10)
itemRefs = append(itemRefs, itemRef{ID: formattedID})
}
totalEntries, err := builder.CountEntries()
if err != nil {
json.ServerError(w, r, err)
return
}
continuation := 0
if len(itemRefs)+rm.Offset < totalEntries {
continuation = len(itemRefs) + rm.Offset
}
json.OK(w, r, streamIDResponse{itemRefs, continuation})
}
func (h *handler) handleFeedStreamHandler(w http.ResponseWriter, r *http.Request, rm RequestModifiers) {
feedID, err := strconv.ParseInt(rm.Streams[0].ID, 10, 64)
if err != nil {
json.ServerError(w, r, err)
return
}
builder := h.store.NewEntryQueryBuilder(rm.UserID)
builder.WithoutStatus(model.EntryStatusRemoved)
builder.WithFeedID(feedID)
builder.WithLimit(rm.Count)
builder.WithOffset(rm.Offset)
builder.WithSorting(model.DefaultSortingOrder, rm.SortDirection)
if rm.StartTime > 0 {
builder.AfterPublishedDate(time.Unix(rm.StartTime, 0))
}
if rm.StopTime > 0 {
builder.BeforePublishedDate(time.Unix(rm.StopTime, 0))
}
if len(rm.ExcludeTargets) > 0 {
for _, s := range rm.ExcludeTargets {
if s.Type == ReadStream {
builder.WithoutStatus(model.EntryStatusRead)
}
}
}
rawEntryIDs, err := builder.GetEntryIDs()
if err != nil {
json.ServerError(w, r, err)
return
}
var itemRefs = make([]itemRef, 0)
for _, entryID := range rawEntryIDs {
formattedID := strconv.FormatInt(entryID, 10)
itemRefs = append(itemRefs, itemRef{ID: formattedID})
}
totalEntries, err := builder.CountEntries()
if err != nil {
json.ServerError(w, r, err)
return
}
continuation := 0
if len(itemRefs)+rm.Offset < totalEntries {
continuation = len(itemRefs) + rm.Offset
}
json.OK(w, r, streamIDResponse{itemRefs, continuation})
}
func (h *handler) markAllAsReadHandler(w http.ResponseWriter, r *http.Request) {
userID := request.UserID(r)
clientIP := request.ClientIP(r)
slog.Debug("[GoogleReader] Handle /mark-all-as-read",
slog.String("handler", "markAllAsReadHandler"),
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
)
if err := r.ParseForm(); err != nil {
json.BadRequest(w, r, err)
return
}
stream, err := getStream(r.Form.Get(paramStreamID), userID)
if err != nil {
json.BadRequest(w, r, err)
return
}
var before time.Time
if timestampParamValue := r.Form.Get(paramTimestamp); timestampParamValue != "" {
timestampParsedValue, err := strconv.ParseInt(timestampParamValue, 10, 64)
if err != nil {
json.BadRequest(w, r, err)
return
}
if timestampParsedValue > 0 {
// It's unclear if the timestamp is in seconds or microseconds, so we try both using a naive approach.
if len(timestampParamValue) >= 16 {
before = time.UnixMicro(timestampParsedValue)
} else {
before = time.Unix(timestampParsedValue, 0)
}
}
}
if before.IsZero() {
before = time.Now()
}
switch stream.Type {
case FeedStream:
feedID, err := strconv.ParseInt(stream.ID, 10, 64)
if err != nil {
json.BadRequest(w, r, err)
return
}
err = h.store.MarkFeedAsRead(userID, feedID, before)
if err != nil {
json.ServerError(w, r, err)
return
}
case LabelStream:
category, err := h.store.CategoryByTitle(userID, stream.ID)
if err != nil {
json.ServerError(w, r, err)
return
}
if category == nil {
json.NotFound(w, r)
return
}
if err := h.store.MarkCategoryAsRead(userID, category.ID, before); err != nil {
json.ServerError(w, r, err)
return
}
case ReadingListStream:
if err = h.store.MarkAllAsReadBeforeDate(userID, before); err != nil {
json.ServerError(w, r, err)
return
}
}
sendOkayResponse(w)
}
v2-2.2.16/internal/googlereader/item.go 0000664 0000000 0000000 00000004466 15127074645 0017716 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package googlereader // import "miniflux.app/v2/internal/googlereader"
import (
"errors"
"fmt"
"net/http"
"strconv"
"strings"
)
const (
ItemIDPrefix = "tag:google.com,2005:reader/item/"
ItemIDFormat = "tag:google.com,2005:reader/item/%016x"
)
func convertEntryIDToLongFormItemID(entryID int64) string {
// The entry ID is a 64-bit integer, so we need to format it as a 16-character hexadecimal string.
return fmt.Sprintf(ItemIDFormat, entryID)
}
// Expected format: "tag:google.com,2005:reader/item/00000000148b9369" (hexadecimal string with prefix and padding)
// NetNewsWire uses this format: "tag:google.com,2005:reader/item/2f2" (hexadecimal string with prefix and no padding)
// Reeder uses this format: "000000000000048c" (hexadecimal string without prefix and padding)
// Liferea uses this format: "12345" (decimal string)
// It returns the parsed ID as a int64 and an error if parsing fails.
func parseItemID(itemIDValue string) (int64, error) {
var itemID int64
if strings.HasPrefix(itemIDValue, ItemIDPrefix) {
n, err := fmt.Sscanf(itemIDValue, ItemIDFormat, &itemID)
if err != nil {
return 0, fmt.Errorf("failed to parse hexadecimal item ID %s: %w", itemIDValue, err)
}
if n != 1 {
return 0, fmt.Errorf("failed to parse hexadecimal item ID %s: expected 1 value, got %d", itemIDValue, n)
}
if itemID == 0 {
return 0, fmt.Errorf("failed to parse hexadecimal item ID %s: item ID is zero", itemIDValue)
}
return itemID, nil
}
if len(itemIDValue) == 16 {
if n, err := fmt.Sscanf(itemIDValue, "%016x", &itemID); err == nil && n == 1 {
return itemID, nil
}
}
itemID, err := strconv.ParseInt(itemIDValue, 10, 64)
if err != nil {
return 0, fmt.Errorf("failed to parse decimal item ID %s: %w", itemIDValue, err)
}
return itemID, nil
}
func parseItemIDsFromRequest(r *http.Request) ([]int64, error) {
items := r.Form[paramItemIDs]
if len(items) == 0 {
return nil, errors.New("googlereader: no items requested")
}
itemIDs := make([]int64, len(items))
for i, item := range items {
itemID, err := parseItemID(item)
if err != nil {
return nil, fmt.Errorf("googlereader: failed to parse item ID %s: %w", item, err)
}
itemIDs[i] = itemID
}
return itemIDs, nil
}
v2-2.2.16/internal/googlereader/item_test.go 0000664 0000000 0000000 00000005056 15127074645 0020751 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package googlereader // import "miniflux.app/v2/internal/googlereader"
import (
"net/http"
"net/url"
"reflect"
"testing"
)
func TestConvertEntryIDToLongFormItemID(t *testing.T) {
entryID := int64(344691561)
expected := "tag:google.com,2005:reader/item/00000000148b9369"
result := convertEntryIDToLongFormItemID(entryID)
if result != expected {
t.Errorf("expected %s, got %s", expected, result)
}
}
func TestParseItemIDsFromRequest(t *testing.T) {
formValues := url.Values{}
formValues.Add("i", "12345")
formValues.Add("i", "tag:google.com,2005:reader/item/00000000148b9369")
formValues.Add("i", "tag:google.com,2005:reader/item/2f2")
formValues.Add("i", "000000000000046f")
formValues.Add("i", "tag:google.com,2005:reader/item/272")
request := &http.Request{
Form: formValues,
}
result, err := parseItemIDsFromRequest(request)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var expected = []int64{12345, 344691561, 754, 1135, 626}
if !reflect.DeepEqual(result, expected) {
t.Errorf("expected %v, got %v", expected, result)
}
// Test with no item IDs
formValues = url.Values{}
request = &http.Request{
Form: formValues,
}
_, err = parseItemIDsFromRequest(request)
if err == nil {
t.Fatalf("expected error, got nil")
}
}
func TestParseItemID(t *testing.T) {
// Test with long form ID and hex ID
result, err := parseItemID("tag:google.com,2005:reader/item/0000000000000001")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
expected := int64(1)
if result != expected {
t.Errorf("expected %d, got %d", expected, result)
}
// Test with hexadecimal long form ID
result, err = parseItemID("0000000000000468")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
expected = int64(1128)
if result != expected {
t.Errorf("expected %d, got %d", expected, result)
}
// Test with short form ID
result, err = parseItemID("12345")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
expected = int64(12345)
if result != expected {
t.Errorf("expected %d, got %d", expected, result)
}
// Test with invalid long form ID
_, err = parseItemID("tag:google.com,2005:reader/item/000000000000000g")
if err == nil {
t.Fatalf("expected error, got nil")
}
// Test with invalid short form ID
_, err = parseItemID("invalid_id")
if err == nil {
t.Fatalf("expected error, got nil")
}
// Test with empty ID
_, err = parseItemID("")
if err == nil {
t.Fatalf("expected error, got nil")
}
}
v2-2.2.16/internal/googlereader/middleware.go 0000664 0000000 0000000 00000013043 15127074645 0021064 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package googlereader // import "miniflux.app/v2/internal/googlereader"
import (
"context"
"crypto/hmac"
"crypto/sha1"
"encoding/hex"
"log/slog"
"net/http"
"strings"
"miniflux.app/v2/internal/http/request"
"miniflux.app/v2/internal/model"
"miniflux.app/v2/internal/storage"
)
type middleware struct {
store *storage.Storage
}
func newMiddleware(s *storage.Storage) *middleware {
return &middleware{s}
}
func (m *middleware) apiKeyAuth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
clientIP := request.ClientIP(r)
var token string
if r.Method == http.MethodPost {
if err := r.ParseForm(); err != nil {
slog.Warn("[GoogleReader] Could not parse request form data",
slog.Bool("authentication_failed", true),
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
slog.Any("error", err),
)
sendUnauthorizedResponse(w)
return
}
token = r.Form.Get("T")
if token == "" {
slog.Warn("[GoogleReader] Post-Form T field is empty",
slog.Bool("authentication_failed", true),
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
)
sendUnauthorizedResponse(w)
return
}
} else {
authorization := r.Header.Get("Authorization")
if authorization == "" {
slog.Warn("[GoogleReader] No token provided",
slog.Bool("authentication_failed", true),
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
)
sendUnauthorizedResponse(w)
return
}
fields := strings.Fields(authorization)
if len(fields) != 2 {
slog.Warn("[GoogleReader] Authorization header does not have the expected GoogleLogin format auth=xxxxxx",
slog.Bool("authentication_failed", true),
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
)
sendUnauthorizedResponse(w)
return
}
if fields[0] != "GoogleLogin" {
slog.Warn("[GoogleReader] Authorization header does not begin with GoogleLogin",
slog.Bool("authentication_failed", true),
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
)
sendUnauthorizedResponse(w)
return
}
auths := strings.Split(fields[1], "=")
if len(auths) != 2 {
slog.Warn("[GoogleReader] Authorization header does not have the expected GoogleLogin format auth=xxxxxx",
slog.Bool("authentication_failed", true),
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
)
sendUnauthorizedResponse(w)
return
}
if auths[0] != "auth" {
slog.Warn("[GoogleReader] Authorization header does not have the expected GoogleLogin format auth=xxxxxx",
slog.Bool("authentication_failed", true),
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
)
sendUnauthorizedResponse(w)
return
}
token = auths[1]
}
parts := strings.Split(token, "/")
if len(parts) != 2 {
slog.Warn("[GoogleReader] Auth token does not have the expected structure username/hash",
slog.Bool("authentication_failed", true),
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
slog.String("token", token),
)
sendUnauthorizedResponse(w)
return
}
var integration *model.Integration
var user *model.User
var err error
if integration, err = m.store.GoogleReaderUserGetIntegration(parts[0]); err != nil {
slog.Warn("[GoogleReader] No user found with the given Google Reader username",
slog.Bool("authentication_failed", true),
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
slog.Any("error", err),
)
sendUnauthorizedResponse(w)
return
}
expectedToken := getAuthToken(integration.GoogleReaderUsername, integration.GoogleReaderPassword)
if expectedToken != token {
slog.Warn("[GoogleReader] Token does not match",
slog.Bool("authentication_failed", true),
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
)
sendUnauthorizedResponse(w)
return
}
if user, err = m.store.UserByID(integration.UserID); err != nil {
slog.Error("[GoogleReader] Unable to fetch user from database",
slog.Bool("authentication_failed", true),
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
slog.Any("error", err),
)
sendUnauthorizedResponse(w)
return
}
if user == nil {
slog.Warn("[GoogleReader] No user found with the given Google Reader credentials",
slog.Bool("authentication_failed", true),
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
)
sendUnauthorizedResponse(w)
return
}
m.store.SetLastLogin(integration.UserID)
ctx := r.Context()
ctx = context.WithValue(ctx, request.UserIDContextKey, user.ID)
ctx = context.WithValue(ctx, request.UserNameContextKey, user.Username)
ctx = context.WithValue(ctx, request.UserTimezoneContextKey, user.Timezone)
ctx = context.WithValue(ctx, request.IsAdminUserContextKey, user.IsAdmin)
ctx = context.WithValue(ctx, request.IsAuthenticatedContextKey, true)
ctx = context.WithValue(ctx, request.GoogleReaderTokenKey, token)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
func getAuthToken(username, password string) string {
token := hex.EncodeToString(hmac.New(sha1.New, []byte(username+password)).Sum(nil))
token = username + "/" + token
return token
}
v2-2.2.16/internal/googlereader/parameters.go 0000664 0000000 0000000 00000003617 15127074645 0021120 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package googlereader // import "miniflux.app/v2/internal/googlereader"
const (
// paramItemIDs - name of the parameter with the item ids
paramItemIDs = "i"
// paramStreamID - name of the parameter containing the stream to be included
paramStreamID = "s"
// paramStreamExcludes - name of the parameter containing streams to be excluded
paramStreamExcludes = "xt"
// paramStreamFilters - name of the parameter containing streams to be included
paramStreamFilters = "it"
// paramStreamMaxItems - name of the parameter containing number of items per page/max items returned
paramStreamMaxItems = "n"
// paramStreamOrder - name of the parameter containing the sort criteria
paramStreamOrder = "r"
// paramStreamStartTime - name of the parameter containing epoch timestamp, filtering items older than
paramStreamStartTime = "ot"
// paramStreamStopTime - name of the parameter containing epoch timestamp, filtering items newer than
paramStreamStopTime = "nt"
// paramTagsRemove - name of the parameter containing tags (streams) to be removed
paramTagsRemove = "r"
// paramTagsAdd - name of the parameter containing tags (streams) to be added
paramTagsAdd = "a"
// paramSubscribeAction - name of the parameter indicating the action to take for subscription/edit
paramSubscribeAction = "ac"
// paramTitle - name of the parameter for the title of the subscription
paramTitle = "t"
// paramQuickAdd - name of the parameter for a URL being quick subscribed to
paramQuickAdd = "quickadd"
// paramDestination - name of the parameter for the new name of a tag
paramDestination = "dest"
// paramContinuation - name of the parameter for callers to pass to receive the next page of results
paramContinuation = "c"
// paramTimestamp - name of the parameter for unix timestamp
paramTimestamp = "ts"
)
v2-2.2.16/internal/googlereader/prefix_suffix.go 0000664 0000000 0000000 00000002610 15127074645 0021626 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package googlereader // import "miniflux.app/v2/internal/googlereader"
const (
// streamPrefix is the prefix for streams (read/starred/reading list and so on)
streamPrefix = "user/-/state/com.google/"
// userStreamPrefix is the user specific prefix for streams (read/starred/reading list and so on)
userStreamPrefix = "user/%d/state/com.google/"
// labelPrefix is the prefix for a label stream
labelPrefix = "user/-/label/"
// userLabelPrefix is the user specific prefix prefix for a label stream
userLabelPrefix = "user/%d/label/"
// feedPrefix is the prefix for a feed stream
feedPrefix = "feed/"
// readStreamSuffix is the suffix for read stream
readStreamSuffix = "read"
// starredStreamSuffix is the suffix for starred stream
starredStreamSuffix = "starred"
// readingListStreamSuffix is the suffix for reading list stream
readingListStreamSuffix = "reading-list"
// keptUnreadStreamSuffix is the suffix for kept unread stream
keptUnreadStreamSuffix = "kept-unread"
// broadcastStreamSuffix is the suffix for broadcast stream
broadcastStreamSuffix = "broadcast"
// broadcastFriendsStreamSuffix is the suffix for broadcast friends stream
broadcastFriendsStreamSuffix = "broadcast-friends"
// likeStreamSuffix is the suffix for like stream
likeStreamSuffix = "like"
)
v2-2.2.16/internal/googlereader/request_modifier.go 0000664 0000000 0000000 00000005527 15127074645 0022325 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package googlereader // import "miniflux.app/v2/internal/googlereader"
import (
"fmt"
"net/http"
"strings"
"miniflux.app/v2/internal/http/request"
)
type RequestModifiers struct {
ExcludeTargets []Stream
FilterTargets []Stream
Streams []Stream
Count int
Offset int
SortDirection string
StartTime int64
StopTime int64
ContinuationToken string
UserID int64
}
func (r RequestModifiers) String() string {
var results []string
results = append(results, fmt.Sprintf("UserID: %d", r.UserID))
streamStr := make([]string, 0, len(r.Streams))
for _, s := range r.Streams {
streamStr = append(streamStr, s.String())
}
results = append(results, fmt.Sprintf("Streams: [%s]", strings.Join(streamStr, ", ")))
exclusions := make([]string, 0, len(r.ExcludeTargets))
for _, s := range r.ExcludeTargets {
exclusions = append(exclusions, s.String())
}
results = append(results, fmt.Sprintf("Exclusions: [%s]", strings.Join(exclusions, ", ")))
filters := make([]string, 0, len(r.FilterTargets))
for _, s := range r.FilterTargets {
filters = append(filters, s.String())
}
results = append(results, fmt.Sprintf("Filters: [%s]", strings.Join(filters, ", ")))
results = append(results, fmt.Sprintf("Count: %d", r.Count))
results = append(results, fmt.Sprintf("Offset: %d", r.Offset))
results = append(results, "Sort Direction: "+r.SortDirection)
results = append(results, "Continuation Token: "+r.ContinuationToken)
results = append(results, fmt.Sprintf("Start Time: %d", r.StartTime))
results = append(results, fmt.Sprintf("Stop Time: %d", r.StopTime))
return strings.Join(results, "; ")
}
func parseStreamFilterFromRequest(r *http.Request) (RequestModifiers, error) {
userID := request.UserID(r)
result := RequestModifiers{
SortDirection: "desc",
UserID: userID,
}
streamOrder := request.QueryStringParam(r, paramStreamOrder, "d")
if streamOrder == "o" {
result.SortDirection = "asc"
}
var err error
result.Streams, err = getStreams(request.QueryStringParamList(r, paramStreamID), userID)
if err != nil {
return RequestModifiers{}, err
}
result.ExcludeTargets, err = getStreams(request.QueryStringParamList(r, paramStreamExcludes), userID)
if err != nil {
return RequestModifiers{}, err
}
result.FilterTargets, err = getStreams(request.QueryStringParamList(r, paramStreamFilters), userID)
if err != nil {
return RequestModifiers{}, err
}
result.Count = request.QueryIntParam(r, paramStreamMaxItems, 0)
result.Offset = request.QueryIntParam(r, paramContinuation, 0)
result.StartTime = request.QueryInt64Param(r, paramStreamStartTime, int64(0))
result.StopTime = request.QueryInt64Param(r, paramStreamStopTime, int64(0))
return result, nil
}
v2-2.2.16/internal/googlereader/response.go 0000664 0000000 0000000 00000007523 15127074645 0020613 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package googlereader // import "miniflux.app/v2/internal/googlereader"
import (
"fmt"
"net/http"
)
type loginResponse struct {
SID string `json:"SID,omitempty"`
LSID string `json:"LSID,omitempty"`
Auth string `json:"Auth,omitempty"`
}
func (l loginResponse) String() string {
return fmt.Sprintf("SID=%s\nLSID=%s\nAuth=%s\n", l.SID, l.LSID, l.Auth)
}
type userInfoResponse struct {
UserID string `json:"userId"`
UserName string `json:"userName"`
UserProfileID string `json:"userProfileId"`
UserEmail string `json:"userEmail"`
}
type subscriptionResponse struct {
ID string `json:"id"`
Title string `json:"title"`
Categories []subscriptionCategoryResponse `json:"categories"`
URL string `json:"url"`
HTMLURL string `json:"htmlUrl"`
IconURL string `json:"iconUrl"`
}
type subscriptionsResponse struct {
Subscriptions []subscriptionResponse `json:"subscriptions"`
}
type quickAddResponse struct {
NumResults int64 `json:"numResults"`
Query string `json:"query,omitempty"`
StreamID string `json:"streamId,omitempty"`
StreamName string `json:"streamName,omitempty"`
}
type subscriptionCategoryResponse struct {
ID string `json:"id"`
Label string `json:"label,omitempty"`
Type string `json:"type,omitempty"`
}
type itemRef struct {
ID string `json:"id"`
DirectStreamIDs string `json:"directStreamIds,omitempty"`
TimestampUsec string `json:"timestampUsec,omitempty"`
}
type streamIDResponse struct {
ItemRefs []itemRef `json:"itemRefs"`
Continuation int `json:"continuation,omitempty,string"`
}
type tagsResponse struct {
Tags []subscriptionCategoryResponse `json:"tags"`
}
type streamContentItemsResponse struct {
Direction string `json:"direction"`
ID string `json:"id"`
Title string `json:"title"`
Self []contentHREF `json:"self"`
Updated int64 `json:"updated"`
Items []contentItem `json:"items"`
Author string `json:"author"`
}
type contentItem struct {
ID string `json:"id"`
Categories []string `json:"categories"`
Title string `json:"title"`
CrawlTimeMsec string `json:"crawlTimeMsec"`
TimestampUsec string `json:"timestampUsec"`
Published int64 `json:"published"`
Updated int64 `json:"updated"`
Author string `json:"author"`
Alternate []contentHREFType `json:"alternate"`
Summary contentItemContent `json:"summary"`
Content contentItemContent `json:"content"`
Origin contentItemOrigin `json:"origin"`
Enclosure []contentItemEnclosure `json:"enclosure"`
Canonical []contentHREF `json:"canonical"`
}
type contentHREFType struct {
HREF string `json:"href"`
Type string `json:"type"`
}
type contentHREF struct {
HREF string `json:"href"`
}
type contentItemEnclosure struct {
URL string `json:"url"`
Type string `json:"type"`
}
type contentItemContent struct {
Direction string `json:"direction"`
Content string `json:"content"`
}
type contentItemOrigin struct {
StreamID string `json:"streamId"`
Title string `json:"title"`
HTMLUrl string `json:"htmlUrl"`
}
func sendUnauthorizedResponse(w http.ResponseWriter) {
w.Header().Set("Content-Type", "text/plain")
w.Header().Set("X-Reader-Google-Bad-Token", "true")
w.WriteHeader(http.StatusUnauthorized)
w.Write([]byte("Unauthorized"))
}
func sendOkayResponse(w http.ResponseWriter) {
w.Header().Set("Content-Type", "text/plain")
w.WriteHeader(http.StatusOK)
w.Write([]byte("OK"))
}
v2-2.2.16/internal/googlereader/stream.go 0000664 0000000 0000000 00000006466 15127074645 0020255 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package googlereader // import "miniflux.app/v2/internal/googlereader"
import (
"fmt"
"strings"
)
type StreamType int
const (
// NoStream - no stream type
NoStream StreamType = iota
// ReadStream - read stream type
ReadStream
// StarredStream - starred stream type
StarredStream
// ReadingListStream - reading list stream type
ReadingListStream
// KeptUnreadStream - kept unread stream type
KeptUnreadStream
// BroadcastStream - broadcast stream type
BroadcastStream
// BroadcastFriendsStream - broadcast friends stream type
BroadcastFriendsStream
// LabelStream - label stream type
LabelStream
// FeedStream - feed stream type
FeedStream
// LikeStream - like stream type
LikeStream
)
// Stream defines a stream type and its ID.
type Stream struct {
Type StreamType
ID string
}
func (s Stream) String() string {
return fmt.Sprintf("%v - '%s'", s.Type, s.ID)
}
func (st StreamType) String() string {
switch st {
case NoStream:
return "NoStream"
case ReadStream:
return "ReadStream"
case StarredStream:
return "StarredStream"
case ReadingListStream:
return "ReadingListStream"
case KeptUnreadStream:
return "KeptUnreadStream"
case BroadcastStream:
return "BroadcastStream"
case BroadcastFriendsStream:
return "BroadcastFriendsStream"
case LabelStream:
return "LabelStream"
case FeedStream:
return "FeedStream"
case LikeStream:
return "LikeStream"
default:
return st.String()
}
}
func getStream(streamID string, userID int64) (Stream, error) {
switch {
case strings.HasPrefix(streamID, feedPrefix):
return Stream{Type: FeedStream, ID: strings.TrimPrefix(streamID, feedPrefix)}, nil
case strings.HasPrefix(streamID, fmt.Sprintf(userStreamPrefix, userID)), strings.HasPrefix(streamID, streamPrefix):
id := strings.TrimPrefix(streamID, fmt.Sprintf(userStreamPrefix, userID))
id = strings.TrimPrefix(id, streamPrefix)
switch id {
case readStreamSuffix:
return Stream{ReadStream, ""}, nil
case starredStreamSuffix:
return Stream{StarredStream, ""}, nil
case readingListStreamSuffix:
return Stream{ReadingListStream, ""}, nil
case keptUnreadStreamSuffix:
return Stream{KeptUnreadStream, ""}, nil
case broadcastStreamSuffix:
return Stream{BroadcastStream, ""}, nil
case broadcastFriendsStreamSuffix:
return Stream{BroadcastFriendsStream, ""}, nil
case likeStreamSuffix:
return Stream{LikeStream, ""}, nil
default:
return Stream{NoStream, ""}, fmt.Errorf("googlereader: unknown stream with id: %s", id)
}
case strings.HasPrefix(streamID, fmt.Sprintf(userLabelPrefix, userID)), strings.HasPrefix(streamID, labelPrefix):
id := strings.TrimPrefix(streamID, fmt.Sprintf(userLabelPrefix, userID))
id = strings.TrimPrefix(id, labelPrefix)
return Stream{LabelStream, id}, nil
case streamID == "":
return Stream{NoStream, ""}, nil
default:
return Stream{NoStream, ""}, fmt.Errorf("googlereader: unknown stream type: %s", streamID)
}
}
func getStreams(streamIDs []string, userID int64) ([]Stream, error) {
streams := make([]Stream, 0, len(streamIDs))
for _, streamID := range streamIDs {
stream, err := getStream(streamID, userID)
if err != nil {
return []Stream{}, err
}
streams = append(streams, stream)
}
return streams, nil
}
v2-2.2.16/internal/http/ 0000775 0000000 0000000 00000000000 15127074645 0014737 5 ustar 00root root 0000000 0000000 v2-2.2.16/internal/http/cookie/ 0000775 0000000 0000000 00000000000 15127074645 0016210 5 ustar 00root root 0000000 0000000 v2-2.2.16/internal/http/cookie/cookie.go 0000664 0000000 0000000 00000002163 15127074645 0020012 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package cookie // import "miniflux.app/v2/internal/http/cookie"
import (
"net/http"
"time"
"miniflux.app/v2/internal/config"
)
// Cookie names.
const (
CookieAppSessionID = "MinifluxAppSessionID"
CookieUserSessionID = "MinifluxUserSessionID"
)
// New creates a new cookie.
func New(name, value string, isHTTPS bool, path string) *http.Cookie {
return &http.Cookie{
Name: name,
Value: value,
Path: basePath(path),
Secure: isHTTPS,
HttpOnly: true,
Expires: time.Now().Add(config.Opts.CleanupRemoveSessionsInterval()),
SameSite: http.SameSiteLaxMode,
}
}
// Expired returns an expired cookie.
func Expired(name string, isHTTPS bool, path string) *http.Cookie {
return &http.Cookie{
Name: name,
Value: "",
Path: basePath(path),
Secure: isHTTPS,
HttpOnly: true,
MaxAge: -1,
Expires: time.Date(1970, 1, 1, 0, 0, 0, 0, time.UTC),
SameSite: http.SameSiteLaxMode,
}
}
func basePath(path string) string {
if path == "" {
return "/"
}
return path
}
v2-2.2.16/internal/http/request/ 0000775 0000000 0000000 00000000000 15127074645 0016427 5 ustar 00root root 0000000 0000000 v2-2.2.16/internal/http/request/client_ip.go 0000664 0000000 0000000 00000003265 15127074645 0020732 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package request // import "miniflux.app/v2/internal/http/request"
import (
"net"
"net/http"
"strings"
)
// IsTrustedIP checks if the given remote IP belongs to one of the trusted networks.
func IsTrustedIP(remoteIP string, trustedNetworks []string) bool {
if remoteIP == "@" || strings.HasPrefix(remoteIP, "/") {
return true
}
ip := net.ParseIP(remoteIP)
if ip == nil {
return false
}
for _, cidr := range trustedNetworks {
_, network, err := net.ParseCIDR(cidr)
if err != nil {
continue
}
if network.Contains(ip) {
return true
}
}
return false
}
// FindClientIP returns the client real IP address based on trusted Reverse-Proxy HTTP headers.
func FindClientIP(r *http.Request, isTrustedProxyClient bool) string {
if isTrustedProxyClient {
headers := [...]string{"X-Forwarded-For", "X-Real-Ip"}
for _, header := range headers {
value := r.Header.Get(header)
if value != "" {
addresses := strings.Split(value, ",")
address := strings.TrimSpace(addresses[0])
address = dropIPv6zone(address)
if net.ParseIP(address) != nil {
return address
}
}
}
}
// Fallback to TCP/IP source IP address.
return FindRemoteIP(r)
}
// FindRemoteIP returns remote client IP address without considering HTTP headers.
func FindRemoteIP(r *http.Request) string {
remoteIP, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
remoteIP = r.RemoteAddr
}
return dropIPv6zone(remoteIP)
}
func dropIPv6zone(address string) string {
i := strings.IndexByte(address, '%')
if i != -1 {
address = address[:i]
}
return address
}
v2-2.2.16/internal/http/request/client_ip_test.go 0000664 0000000 0000000 00000011212 15127074645 0021760 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package request // import "miniflux.app/v2/internal/http/request"
import (
"net/http"
"testing"
)
func TestFindClientIPWithoutHeaders(t *testing.T) {
r := &http.Request{RemoteAddr: "192.168.0.1:4242"}
if ip := FindClientIP(r, false); ip != "192.168.0.1" {
t.Fatalf(`Unexpected result, got: %q`, ip)
}
r = &http.Request{RemoteAddr: "192.168.0.1"}
if ip := FindClientIP(r, false); ip != "192.168.0.1" {
t.Fatalf(`Unexpected result, got: %q`, ip)
}
r = &http.Request{RemoteAddr: "fe80::14c2:f039:edc7:edc7"}
if ip := FindClientIP(r, false); ip != "fe80::14c2:f039:edc7:edc7" {
t.Fatalf(`Unexpected result, got: %q`, ip)
}
r = &http.Request{RemoteAddr: "fe80::14c2:f039:edc7:edc7%eth0"}
if ip := FindClientIP(r, false); ip != "fe80::14c2:f039:edc7:edc7" {
t.Fatalf(`Unexpected result, got: %q`, ip)
}
r = &http.Request{RemoteAddr: "[fe80::14c2:f039:edc7:edc7%eth0]:4242"}
if ip := FindClientIP(r, false); ip != "fe80::14c2:f039:edc7:edc7" {
t.Fatalf(`Unexpected result, got: %q`, ip)
}
}
func TestFindClientIPWithXFFHeader(t *testing.T) {
// Test with multiple IPv4 addresses.
headers := http.Header{}
headers.Set("X-Forwarded-For", "203.0.113.195, 70.41.3.18, 150.172.238.178")
r := &http.Request{RemoteAddr: "192.168.0.1:4242", Header: headers}
if ip := FindClientIP(r, true); ip != "203.0.113.195" {
t.Fatalf(`Unexpected result, got: %q`, ip)
}
// Test with single IPv6 address.
headers = http.Header{}
headers.Set("X-Forwarded-For", "2001:db8:85a3:8d3:1319:8a2e:370:7348")
r = &http.Request{RemoteAddr: "192.168.0.1:4242", Header: headers}
if ip := FindClientIP(r, true); ip != "2001:db8:85a3:8d3:1319:8a2e:370:7348" {
t.Fatalf(`Unexpected result, got: %q`, ip)
}
// Test with single IPv6 address with zone
headers = http.Header{}
headers.Set("X-Forwarded-For", "fe80::14c2:f039:edc7:edc7%eth0")
r = &http.Request{RemoteAddr: "192.168.0.1:4242", Header: headers}
if ip := FindClientIP(r, true); ip != "fe80::14c2:f039:edc7:edc7" {
t.Fatalf(`Unexpected result, got: %q`, ip)
}
// Test with single IPv4 address.
headers = http.Header{}
headers.Set("X-Forwarded-For", "70.41.3.18")
r = &http.Request{RemoteAddr: "192.168.0.1:4242", Header: headers}
if ip := FindClientIP(r, true); ip != "70.41.3.18" {
t.Fatalf(`Unexpected result, got: %q`, ip)
}
// Test with invalid IP address.
headers = http.Header{}
headers.Set("X-Forwarded-For", "fake IP")
r = &http.Request{RemoteAddr: "192.168.0.1:4242", Header: headers}
if ip := FindClientIP(r, true); ip != "192.168.0.1" {
t.Fatalf(`Unexpected result, got: %q`, ip)
}
}
func TestClientIPWithXRealIPHeader(t *testing.T) {
headers := http.Header{}
headers.Set("X-Real-Ip", "192.168.122.1")
r := &http.Request{RemoteAddr: "192.168.0.1:4242", Header: headers}
if ip := FindClientIP(r, true); ip != "192.168.122.1" {
t.Fatalf(`Unexpected result, got: %q`, ip)
}
}
func TestClientIPWithBothHeaders(t *testing.T) {
headers := http.Header{}
headers.Set("X-Forwarded-For", "203.0.113.195, 70.41.3.18, 150.172.238.178")
headers.Set("X-Real-Ip", "192.168.122.1")
r := &http.Request{RemoteAddr: "192.168.0.1:4242", Header: headers}
if ip := FindClientIP(r, true); ip != "203.0.113.195" {
t.Fatalf(`Unexpected result, got: %q`, ip)
}
}
func TestClientIPWithUnixSocketRemoteAddress(t *testing.T) {
r := &http.Request{RemoteAddr: "@"}
if ip := FindClientIP(r, false); ip != "@" {
t.Fatalf(`Unexpected result, got: %q`, ip)
}
}
func TestClientIPWithUnixSocketRemoteAddrAndBothHeaders(t *testing.T) {
headers := http.Header{}
headers.Set("X-Forwarded-For", "203.0.113.195, 70.41.3.18, 150.172.238.178")
headers.Set("X-Real-Ip", "192.168.122.1")
r := &http.Request{RemoteAddr: "@", Header: headers}
if ip := FindClientIP(r, true); ip != "203.0.113.195" {
t.Fatalf(`Unexpected result, got: %q`, ip)
}
}
func TestIsTrustedIP(t *testing.T) {
trustedNetworks := []string{"127.0.0.1/8", "10.0.0.0/8", "::1/128", "invalid"}
scenarios := []struct {
ip string
expected bool
}{
{"127.0.0.1", true},
{"10.0.0.1", true},
{"::1", true},
{"192.168.1.1", false},
{"invalid", false},
{"@", true},
{"/tmp/miniflux.sock", true},
}
for _, scenario := range scenarios {
result := IsTrustedIP(scenario.ip, trustedNetworks)
if result != scenario.expected {
t.Errorf("Expected %v for IP %s, got %v", scenario.expected, scenario.ip, result)
}
}
if IsTrustedIP("127.0.0.1", nil) {
t.Error("Expected false when no trusted networks are defined")
}
if IsTrustedIP("127.0.0.1", []string{}) {
t.Error("Expected false when trusted networks list is empty")
}
}
v2-2.2.16/internal/http/request/context.go 0000664 0000000 0000000 00000011042 15127074645 0020440 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package request // import "miniflux.app/v2/internal/http/request"
import (
"net/http"
"strconv"
"time"
"miniflux.app/v2/internal/model"
)
// ContextKey represents a context key.
type ContextKey int
// List of context keys.
const (
UserIDContextKey ContextKey = iota
UserNameContextKey
UserTimezoneContextKey
IsAdminUserContextKey
IsAuthenticatedContextKey
UserSessionTokenContextKey
UserLanguageContextKey
UserThemeContextKey
SessionIDContextKey
CSRFContextKey
OAuth2StateContextKey
OAuth2CodeVerifierContextKey
FlashMessageContextKey
FlashErrorMessageContextKey
LastForceRefreshContextKey
ClientIPContextKey
GoogleReaderTokenKey
WebAuthnDataContextKey
)
func WebAuthnSessionData(r *http.Request) *model.WebAuthnSession {
if v := r.Context().Value(WebAuthnDataContextKey); v != nil {
if value, valid := v.(model.WebAuthnSession); valid {
return &value
}
}
return nil
}
// GoogleReaderToken returns the google reader token if it exists.
func GoogleReaderToken(r *http.Request) string {
return getContextStringValue(r, GoogleReaderTokenKey)
}
// IsAdminUser checks if the logged user is administrator.
func IsAdminUser(r *http.Request) bool {
return getContextBoolValue(r, IsAdminUserContextKey)
}
// IsAuthenticated returns a boolean if the user is authenticated.
func IsAuthenticated(r *http.Request) bool {
return getContextBoolValue(r, IsAuthenticatedContextKey)
}
// UserID returns the UserID of the logged user.
func UserID(r *http.Request) int64 {
return getContextInt64Value(r, UserIDContextKey)
}
// UserName returns the username of the logged user.
func UserName(r *http.Request) string {
value := getContextStringValue(r, UserNameContextKey)
if value == "" {
value = "unknown"
}
return value
}
// UserTimezone returns the timezone used by the logged user.
func UserTimezone(r *http.Request) string {
value := getContextStringValue(r, UserTimezoneContextKey)
if value == "" {
value = "UTC"
}
return value
}
// UserLanguage get the locale used by the current logged user.
func UserLanguage(r *http.Request) string {
language := getContextStringValue(r, UserLanguageContextKey)
if language == "" {
language = "en_US"
}
return language
}
// UserTheme get the theme used by the current logged user.
func UserTheme(r *http.Request) string {
theme := getContextStringValue(r, UserThemeContextKey)
if theme == "" {
theme = "system_serif"
}
return theme
}
// CSRF returns the current CSRF token.
func CSRF(r *http.Request) string {
return getContextStringValue(r, CSRFContextKey)
}
// SessionID returns the current session ID.
func SessionID(r *http.Request) string {
return getContextStringValue(r, SessionIDContextKey)
}
// UserSessionToken returns the current user session token.
func UserSessionToken(r *http.Request) string {
return getContextStringValue(r, UserSessionTokenContextKey)
}
// OAuth2State returns the current OAuth2 state.
func OAuth2State(r *http.Request) string {
return getContextStringValue(r, OAuth2StateContextKey)
}
func OAuth2CodeVerifier(r *http.Request) string {
return getContextStringValue(r, OAuth2CodeVerifierContextKey)
}
// FlashMessage returns the message message if any.
func FlashMessage(r *http.Request) string {
return getContextStringValue(r, FlashMessageContextKey)
}
// FlashErrorMessage returns the message error message if any.
func FlashErrorMessage(r *http.Request) string {
return getContextStringValue(r, FlashErrorMessageContextKey)
}
// LastForceRefresh returns the last force refresh timestamp.
func LastForceRefresh(r *http.Request) time.Time {
jsonStringValue := getContextStringValue(r, LastForceRefreshContextKey)
timestamp, err := strconv.ParseInt(jsonStringValue, 10, 64)
if err != nil {
return time.Time{}
}
return time.Unix(timestamp, 0)
}
// ClientIP returns the client IP address stored in the context.
func ClientIP(r *http.Request) string {
return getContextStringValue(r, ClientIPContextKey)
}
func getContextStringValue(r *http.Request, key ContextKey) string {
if v := r.Context().Value(key); v != nil {
if value, valid := v.(string); valid {
return value
}
}
return ""
}
func getContextBoolValue(r *http.Request, key ContextKey) bool {
if v := r.Context().Value(key); v != nil {
if value, valid := v.(bool); valid {
return value
}
}
return false
}
func getContextInt64Value(r *http.Request, key ContextKey) int64 {
if v := r.Context().Value(key); v != nil {
if value, valid := v.(int64); valid {
return value
}
}
return 0
}
v2-2.2.16/internal/http/request/context_test.go 0000664 0000000 0000000 00000023764 15127074645 0021515 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package request // import "miniflux.app/v2/internal/http/request"
import (
"context"
"net/http"
"testing"
)
func TestContextStringValue(t *testing.T) {
r, _ := http.NewRequest("GET", "http://example.org", nil)
ctx := r.Context()
ctx = context.WithValue(ctx, ClientIPContextKey, "IP")
r = r.WithContext(ctx)
result := getContextStringValue(r, ClientIPContextKey)
expected := "IP"
if result != expected {
t.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)
}
}
func TestContextStringValueWithInvalidType(t *testing.T) {
r, _ := http.NewRequest("GET", "http://example.org", nil)
ctx := r.Context()
ctx = context.WithValue(ctx, ClientIPContextKey, 0)
r = r.WithContext(ctx)
result := getContextStringValue(r, ClientIPContextKey)
expected := ""
if result != expected {
t.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)
}
}
func TestContextStringValueWhenUnset(t *testing.T) {
r, _ := http.NewRequest("GET", "http://example.org", nil)
result := getContextStringValue(r, ClientIPContextKey)
expected := ""
if result != expected {
t.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)
}
}
func TestContextBoolValue(t *testing.T) {
r, _ := http.NewRequest("GET", "http://example.org", nil)
ctx := r.Context()
ctx = context.WithValue(ctx, IsAdminUserContextKey, true)
r = r.WithContext(ctx)
result := getContextBoolValue(r, IsAdminUserContextKey)
expected := true
if result != expected {
t.Errorf(`Unexpected context value, got %v instead of %v`, result, expected)
}
}
func TestContextBoolValueWithInvalidType(t *testing.T) {
r, _ := http.NewRequest("GET", "http://example.org", nil)
ctx := r.Context()
ctx = context.WithValue(ctx, IsAdminUserContextKey, "invalid")
r = r.WithContext(ctx)
result := getContextBoolValue(r, IsAdminUserContextKey)
expected := false
if result != expected {
t.Errorf(`Unexpected context value, got %v instead of %v`, result, expected)
}
}
func TestContextBoolValueWhenUnset(t *testing.T) {
r, _ := http.NewRequest("GET", "http://example.org", nil)
result := getContextBoolValue(r, IsAdminUserContextKey)
expected := false
if result != expected {
t.Errorf(`Unexpected context value, got %v instead of %v`, result, expected)
}
}
func TestContextInt64Value(t *testing.T) {
r, _ := http.NewRequest("GET", "http://example.org", nil)
ctx := r.Context()
ctx = context.WithValue(ctx, UserIDContextKey, int64(1234))
r = r.WithContext(ctx)
result := getContextInt64Value(r, UserIDContextKey)
expected := int64(1234)
if result != expected {
t.Errorf(`Unexpected context value, got %d instead of %d`, result, expected)
}
}
func TestContextInt64ValueWithInvalidType(t *testing.T) {
r, _ := http.NewRequest("GET", "http://example.org", nil)
ctx := r.Context()
ctx = context.WithValue(ctx, UserIDContextKey, "invalid")
r = r.WithContext(ctx)
result := getContextInt64Value(r, UserIDContextKey)
expected := int64(0)
if result != expected {
t.Errorf(`Unexpected context value, got %d instead of %d`, result, expected)
}
}
func TestContextInt64ValueWhenUnset(t *testing.T) {
r, _ := http.NewRequest("GET", "http://example.org", nil)
result := getContextInt64Value(r, UserIDContextKey)
expected := int64(0)
if result != expected {
t.Errorf(`Unexpected context value, got %d instead of %d`, result, expected)
}
}
func TestIsAdmin(t *testing.T) {
r, _ := http.NewRequest("GET", "http://example.org", nil)
result := IsAdminUser(r)
expected := false
if result != expected {
t.Errorf(`Unexpected context value, got %v instead of %v`, result, expected)
}
ctx := r.Context()
ctx = context.WithValue(ctx, IsAdminUserContextKey, true)
r = r.WithContext(ctx)
result = IsAdminUser(r)
expected = true
if result != expected {
t.Errorf(`Unexpected context value, got %v instead of %v`, result, expected)
}
}
func TestIsAuthenticated(t *testing.T) {
r, _ := http.NewRequest("GET", "http://example.org", nil)
result := IsAuthenticated(r)
expected := false
if result != expected {
t.Errorf(`Unexpected context value, got %v instead of %v`, result, expected)
}
ctx := r.Context()
ctx = context.WithValue(ctx, IsAuthenticatedContextKey, true)
r = r.WithContext(ctx)
result = IsAuthenticated(r)
expected = true
if result != expected {
t.Errorf(`Unexpected context value, got %v instead of %v`, result, expected)
}
}
func TestUserID(t *testing.T) {
r, _ := http.NewRequest("GET", "http://example.org", nil)
result := UserID(r)
expected := int64(0)
if result != expected {
t.Errorf(`Unexpected context value, got %v instead of %v`, result, expected)
}
ctx := r.Context()
ctx = context.WithValue(ctx, UserIDContextKey, int64(123))
r = r.WithContext(ctx)
result = UserID(r)
expected = int64(123)
if result != expected {
t.Errorf(`Unexpected context value, got %v instead of %v`, result, expected)
}
}
func TestUserTimezone(t *testing.T) {
r, _ := http.NewRequest("GET", "http://example.org", nil)
result := UserTimezone(r)
expected := "UTC"
if result != expected {
t.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)
}
ctx := r.Context()
ctx = context.WithValue(ctx, UserTimezoneContextKey, "Europe/Paris")
r = r.WithContext(ctx)
result = UserTimezone(r)
expected = "Europe/Paris"
if result != expected {
t.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)
}
}
func TestUserLanguage(t *testing.T) {
r, _ := http.NewRequest("GET", "http://example.org", nil)
result := UserLanguage(r)
expected := "en_US"
if result != expected {
t.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)
}
ctx := r.Context()
ctx = context.WithValue(ctx, UserLanguageContextKey, "fr_FR")
r = r.WithContext(ctx)
result = UserLanguage(r)
expected = "fr_FR"
if result != expected {
t.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)
}
}
func TestUserTheme(t *testing.T) {
r, _ := http.NewRequest("GET", "http://example.org", nil)
result := UserTheme(r)
expected := "system_serif"
if result != expected {
t.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)
}
ctx := r.Context()
ctx = context.WithValue(ctx, UserThemeContextKey, "dark_serif")
r = r.WithContext(ctx)
result = UserTheme(r)
expected = "dark_serif"
if result != expected {
t.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)
}
}
func TestCSRF(t *testing.T) {
r, _ := http.NewRequest("GET", "http://example.org", nil)
result := CSRF(r)
expected := ""
if result != expected {
t.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)
}
ctx := r.Context()
ctx = context.WithValue(ctx, CSRFContextKey, "secret")
r = r.WithContext(ctx)
result = CSRF(r)
expected = "secret"
if result != expected {
t.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)
}
}
func TestSessionID(t *testing.T) {
r, _ := http.NewRequest("GET", "http://example.org", nil)
result := SessionID(r)
expected := ""
if result != expected {
t.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)
}
ctx := r.Context()
ctx = context.WithValue(ctx, SessionIDContextKey, "id")
r = r.WithContext(ctx)
result = SessionID(r)
expected = "id"
if result != expected {
t.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)
}
}
func TestUserSessionToken(t *testing.T) {
r, _ := http.NewRequest("GET", "http://example.org", nil)
result := UserSessionToken(r)
expected := ""
if result != expected {
t.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)
}
ctx := r.Context()
ctx = context.WithValue(ctx, UserSessionTokenContextKey, "token")
r = r.WithContext(ctx)
result = UserSessionToken(r)
expected = "token"
if result != expected {
t.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)
}
}
func TestOAuth2State(t *testing.T) {
r, _ := http.NewRequest("GET", "http://example.org", nil)
result := OAuth2State(r)
expected := ""
if result != expected {
t.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)
}
ctx := r.Context()
ctx = context.WithValue(ctx, OAuth2StateContextKey, "state")
r = r.WithContext(ctx)
result = OAuth2State(r)
expected = "state"
if result != expected {
t.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)
}
}
func TestFlashMessage(t *testing.T) {
r, _ := http.NewRequest("GET", "http://example.org", nil)
result := FlashMessage(r)
expected := ""
if result != expected {
t.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)
}
ctx := r.Context()
ctx = context.WithValue(ctx, FlashMessageContextKey, "message")
r = r.WithContext(ctx)
result = FlashMessage(r)
expected = "message"
if result != expected {
t.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)
}
}
func TestFlashErrorMessage(t *testing.T) {
r, _ := http.NewRequest("GET", "http://example.org", nil)
result := FlashErrorMessage(r)
expected := ""
if result != expected {
t.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)
}
ctx := r.Context()
ctx = context.WithValue(ctx, FlashErrorMessageContextKey, "error message")
r = r.WithContext(ctx)
result = FlashErrorMessage(r)
expected = "error message"
if result != expected {
t.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)
}
}
func TestClientIP(t *testing.T) {
r, _ := http.NewRequest("GET", "http://example.org", nil)
result := ClientIP(r)
expected := ""
if result != expected {
t.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)
}
ctx := r.Context()
ctx = context.WithValue(ctx, ClientIPContextKey, "127.0.0.1")
r = r.WithContext(ctx)
result = ClientIP(r)
expected = "127.0.0.1"
if result != expected {
t.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)
}
}
v2-2.2.16/internal/http/request/cookie.go 0000664 0000000 0000000 00000000606 15127074645 0020231 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package request // import "miniflux.app/v2/internal/http/request"
import "net/http"
// CookieValue returns the cookie value.
func CookieValue(r *http.Request, name string) string {
cookie, err := r.Cookie(name)
if err != nil {
return ""
}
return cookie.Value
}
v2-2.2.16/internal/http/request/cookie_test.go 0000664 0000000 0000000 00000001511 15127074645 0021264 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package request // import "miniflux.app/v2/internal/http/request"
import (
"net/http"
"testing"
)
func TestGetCookieValue(t *testing.T) {
r, _ := http.NewRequest("GET", "http://example.org", nil)
r.AddCookie(&http.Cookie{Value: "cookie_value", Name: "my_cookie"})
result := CookieValue(r, "my_cookie")
expected := "cookie_value"
if result != expected {
t.Errorf(`Unexpected cookie value, got %q instead of %q`, result, expected)
}
}
func TestGetCookieValueWhenUnset(t *testing.T) {
r, _ := http.NewRequest("GET", "http://example.org", nil)
result := CookieValue(r, "my_cookie")
expected := ""
if result != expected {
t.Errorf(`Unexpected cookie value, got %q instead of %q`, result, expected)
}
}
v2-2.2.16/internal/http/request/params.go 0000664 0000000 0000000 00000005360 15127074645 0020245 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package request // import "miniflux.app/v2/internal/http/request"
import (
"net/http"
"strconv"
"strings"
"github.com/gorilla/mux"
)
// FormInt64Value returns a form value as integer.
func FormInt64Value(r *http.Request, param string) int64 {
value := r.FormValue(param)
integer, err := strconv.ParseInt(value, 10, 64)
if err != nil {
return 0
}
return integer
}
// RouteInt64Param returns an URL route parameter as int64.
func RouteInt64Param(r *http.Request, param string) int64 {
vars := mux.Vars(r)
value, err := strconv.ParseInt(vars[param], 10, 64)
if err != nil {
return 0
}
if value < 0 {
return 0
}
return value
}
// RouteStringParam returns a URL route parameter as string.
func RouteStringParam(r *http.Request, param string) string {
vars := mux.Vars(r)
return vars[param]
}
// QueryStringParam returns a query string parameter as string.
func QueryStringParam(r *http.Request, param, defaultValue string) string {
value := r.URL.Query().Get(param)
if value == "" {
value = defaultValue
}
return value
}
// QueryStringParamList returns all values associated to the parameter.
func QueryStringParamList(r *http.Request, param string) []string {
var results []string
values := r.URL.Query()
if _, found := values[param]; found {
for _, value := range values[param] {
value = strings.TrimSpace(value)
if value != "" {
results = append(results, value)
}
}
}
return results
}
// QueryIntParam returns a query string parameter as integer.
func QueryIntParam(r *http.Request, param string, defaultValue int) int {
value := r.URL.Query().Get(param)
if value == "" {
return defaultValue
}
val, err := strconv.ParseInt(value, 10, 0)
if err != nil {
return defaultValue
}
if val < 0 {
return defaultValue
}
return int(val)
}
// QueryInt64Param returns a query string parameter as int64.
func QueryInt64Param(r *http.Request, param string, defaultValue int64) int64 {
value := r.URL.Query().Get(param)
if value == "" {
return defaultValue
}
val, err := strconv.ParseInt(value, 10, 64)
if err != nil {
return defaultValue
}
if val < 0 {
return defaultValue
}
return val
}
// QueryBoolParam returns a query string parameter as bool.
func QueryBoolParam(r *http.Request, param string, defaultValue bool) bool {
value := r.URL.Query().Get(param)
if value == "" {
return defaultValue
}
val, err := strconv.ParseBool(value)
if err != nil {
return defaultValue
}
return val
}
// HasQueryParam checks if the query string contains the given parameter.
func HasQueryParam(r *http.Request, param string) bool {
values := r.URL.Query()
_, ok := values[param]
return ok
}
v2-2.2.16/internal/http/request/params_test.go 0000664 0000000 0000000 00000012064 15127074645 0021303 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package request // import "miniflux.app/v2/internal/http/request"
import (
"net/http"
"net/http/httptest"
"net/url"
"testing"
"github.com/gorilla/mux"
)
func TestFormInt64Value(t *testing.T) {
f := url.Values{}
f.Set("integer value", "42")
f.Set("invalid value", "invalid integer")
r := &http.Request{Form: f}
result := FormInt64Value(r, "integer value")
expected := int64(42)
if result != expected {
t.Errorf(`Unexpected result, got %d instead of %d`, result, expected)
}
result = FormInt64Value(r, "invalid value")
expected = int64(0)
if result != expected {
t.Errorf(`Unexpected result, got %d instead of %d`, result, expected)
}
result = FormInt64Value(r, "missing value")
expected = int64(0)
if result != expected {
t.Errorf(`Unexpected result, got %d instead of %d`, result, expected)
}
}
func TestRouteStringParam(t *testing.T) {
router := mux.NewRouter()
router.HandleFunc("/route/{variable}/index", func(w http.ResponseWriter, r *http.Request) {
result := RouteStringParam(r, "variable")
expected := "value"
if result != expected {
t.Errorf(`Unexpected result, got %q instead of %q`, result, expected)
}
result = RouteStringParam(r, "missing variable")
expected = ""
if result != expected {
t.Errorf(`Unexpected result, got %q instead of %q`, result, expected)
}
})
r, err := http.NewRequest("GET", "/route/value/index", nil)
if err != nil {
t.Fatal(err)
}
w := httptest.NewRecorder()
router.ServeHTTP(w, r)
}
func TestRouteInt64Param(t *testing.T) {
router := mux.NewRouter()
router.HandleFunc("/a/{variable1}/b/{variable2}/c/{variable3}", func(w http.ResponseWriter, r *http.Request) {
result := RouteInt64Param(r, "variable1")
expected := int64(42)
if result != expected {
t.Errorf(`Unexpected result, got %d instead of %d`, result, expected)
}
result = RouteInt64Param(r, "missing variable")
expected = 0
if result != expected {
t.Errorf(`Unexpected result, got %d instead of %d`, result, expected)
}
result = RouteInt64Param(r, "variable2")
expected = 0
if result != expected {
t.Errorf(`Unexpected result, got %d instead of %d`, result, expected)
}
result = RouteInt64Param(r, "variable3")
expected = 0
if result != expected {
t.Errorf(`Unexpected result, got %d instead of %d`, result, expected)
}
})
r, err := http.NewRequest("GET", "/a/42/b/not-int/c/-10", nil)
if err != nil {
t.Fatal(err)
}
w := httptest.NewRecorder()
router.ServeHTTP(w, r)
}
func TestQueryStringParam(t *testing.T) {
u, _ := url.Parse("http://example.org/?key=value")
r := &http.Request{URL: u}
result := QueryStringParam(r, "key", "fallback")
expected := "value"
if result != expected {
t.Errorf(`Unexpected result, got %q instead of %q`, result, expected)
}
result = QueryStringParam(r, "missing key", "fallback")
expected = "fallback"
if result != expected {
t.Errorf(`Unexpected result, got %q instead of %q`, result, expected)
}
}
func TestQueryIntParam(t *testing.T) {
u, _ := url.Parse("http://example.org/?key=42&invalid=value&negative=-5")
r := &http.Request{URL: u}
result := QueryIntParam(r, "key", 84)
expected := 42
if result != expected {
t.Errorf(`Unexpected result, got %d instead of %d`, result, expected)
}
result = QueryIntParam(r, "missing key", 84)
expected = 84
if result != expected {
t.Errorf(`Unexpected result, got %d instead of %d`, result, expected)
}
result = QueryIntParam(r, "negative", 69)
expected = 69
if result != expected {
t.Errorf(`Unexpected result, got %d instead of %d`, result, expected)
}
result = QueryIntParam(r, "invalid", 99)
expected = 99
if result != expected {
t.Errorf(`Unexpected result, got %d instead of %d`, result, expected)
}
}
func TestQueryInt64Param(t *testing.T) {
u, _ := url.Parse("http://example.org/?key=42&invalid=value&negative=-5")
r := &http.Request{URL: u}
result := QueryInt64Param(r, "key", int64(84))
expected := int64(42)
if result != expected {
t.Errorf(`Unexpected result, got %d instead of %d`, result, expected)
}
result = QueryInt64Param(r, "missing key", int64(84))
expected = int64(84)
if result != expected {
t.Errorf(`Unexpected result, got %d instead of %d`, result, expected)
}
result = QueryInt64Param(r, "invalid", int64(69))
expected = int64(69)
if result != expected {
t.Errorf(`Unexpected result, got %d instead of %d`, result, expected)
}
result = QueryInt64Param(r, "invalid", int64(99))
expected = int64(99)
if result != expected {
t.Errorf(`Unexpected result, got %d instead of %d`, result, expected)
}
}
func TestHasQueryParam(t *testing.T) {
u, _ := url.Parse("http://example.org/?key=42")
r := &http.Request{URL: u}
result := HasQueryParam(r, "key")
expected := true
if result != expected {
t.Errorf(`Unexpected result, got %v instead of %v`, result, expected)
}
result = HasQueryParam(r, "missing key")
expected = false
if result != expected {
t.Errorf(`Unexpected result, got %v instead of %v`, result, expected)
}
}
v2-2.2.16/internal/http/response/ 0000775 0000000 0000000 00000000000 15127074645 0016575 5 ustar 00root root 0000000 0000000 v2-2.2.16/internal/http/response/builder.go 0000664 0000000 0000000 00000007066 15127074645 0020563 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package response // import "miniflux.app/v2/internal/http/response"
import (
"compress/flate"
"compress/gzip"
"io"
"log/slog"
"net/http"
"strings"
"time"
"github.com/andybalholm/brotli"
)
const compressionThreshold = 1024
// Builder generates HTTP responses.
type Builder struct {
w http.ResponseWriter
r *http.Request
statusCode int
headers map[string]string
enableCompression bool
body any
}
// WithStatus uses the given status code to build the response.
func (b *Builder) WithStatus(statusCode int) *Builder {
b.statusCode = statusCode
return b
}
// WithHeader adds the given HTTP header to the response.
func (b *Builder) WithHeader(key, value string) *Builder {
b.headers[key] = value
return b
}
// WithBody uses the given body to build the response.
func (b *Builder) WithBody(body any) *Builder {
b.body = body
return b
}
// WithAttachment forces the document to be downloaded by the web browser.
func (b *Builder) WithAttachment(filename string) *Builder {
b.headers["Content-Disposition"] = "attachment; filename=" + filename
return b
}
// WithoutCompression disables HTTP compression.
func (b *Builder) WithoutCompression() *Builder {
b.enableCompression = false
return b
}
// WithCaching adds caching headers to the response.
func (b *Builder) WithCaching(etag string, duration time.Duration, callback func(*Builder)) {
b.headers["ETag"] = etag
b.headers["Cache-Control"] = "public"
b.headers["Expires"] = time.Now().Add(duration).UTC().Format(http.TimeFormat)
if etag == b.r.Header.Get("If-None-Match") {
b.statusCode = http.StatusNotModified
b.body = nil
b.Write()
} else {
callback(b)
}
}
// Write generates the HTTP response.
func (b *Builder) Write() {
if b.body == nil {
b.writeHeaders()
return
}
switch v := b.body.(type) {
case []byte:
b.compress(v)
case string:
b.compress([]byte(v))
case io.Reader:
// Compression not implemented in this case
b.writeHeaders()
_, err := io.Copy(b.w, v)
if err != nil {
slog.Error("Unable to write response body", slog.Any("error", err))
}
}
}
func (b *Builder) writeHeaders() {
b.headers["X-Content-Type-Options"] = "nosniff"
b.headers["X-Frame-Options"] = "DENY"
b.headers["Referrer-Policy"] = "no-referrer"
for key, value := range b.headers {
b.w.Header().Set(key, value)
}
b.w.WriteHeader(b.statusCode)
}
func (b *Builder) compress(data []byte) {
if b.enableCompression && len(data) > compressionThreshold {
acceptEncoding := b.r.Header.Get("Accept-Encoding")
switch {
case strings.Contains(acceptEncoding, "br"):
b.headers["Content-Encoding"] = "br"
b.writeHeaders()
brotliWriter := brotli.NewWriterV2(b.w, brotli.DefaultCompression)
brotliWriter.Write(data)
brotliWriter.Close()
return
case strings.Contains(acceptEncoding, "gzip"):
b.headers["Content-Encoding"] = "gzip"
b.writeHeaders()
gzipWriter := gzip.NewWriter(b.w)
gzipWriter.Write(data)
gzipWriter.Close()
return
case strings.Contains(acceptEncoding, "deflate"):
b.headers["Content-Encoding"] = "deflate"
b.writeHeaders()
flateWriter, _ := flate.NewWriter(b.w, -1)
flateWriter.Write(data)
flateWriter.Close()
return
}
}
b.writeHeaders()
b.w.Write(data)
}
// New creates a new response builder.
func New(w http.ResponseWriter, r *http.Request) *Builder {
return &Builder{w: w, r: r, statusCode: http.StatusOK, headers: make(map[string]string), enableCompression: true}
}
v2-2.2.16/internal/http/response/builder_test.go 0000664 0000000 0000000 00000021125 15127074645 0021612 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package response // import "miniflux.app/v2/internal/http/response"
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
func TestResponseHasCommonHeaders(t *testing.T) {
r, err := http.NewRequest("GET", "/", nil)
if err != nil {
t.Fatal(err)
}
w := httptest.NewRecorder()
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
New(w, r).Write()
})
handler.ServeHTTP(w, r)
resp := w.Result()
headers := map[string]string{
"X-Content-Type-Options": "nosniff",
"X-Frame-Options": "DENY",
}
for header, expected := range headers {
actual := resp.Header.Get(header)
if actual != expected {
t.Fatalf(`Unexpected header value, got %q instead of %q`, actual, expected)
}
}
}
func TestBuildResponseWithCustomStatusCode(t *testing.T) {
r, err := http.NewRequest("GET", "/", nil)
if err != nil {
t.Fatal(err)
}
w := httptest.NewRecorder()
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
New(w, r).WithStatus(http.StatusNotAcceptable).Write()
})
handler.ServeHTTP(w, r)
resp := w.Result()
expectedStatusCode := http.StatusNotAcceptable
if resp.StatusCode != expectedStatusCode {
t.Fatalf(`Unexpected status code, got %d instead of %d`, resp.StatusCode, expectedStatusCode)
}
}
func TestBuildResponseWithCustomHeader(t *testing.T) {
r, err := http.NewRequest("GET", "/", nil)
if err != nil {
t.Fatal(err)
}
w := httptest.NewRecorder()
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
New(w, r).WithHeader("X-My-Header", "Value").Write()
})
handler.ServeHTTP(w, r)
resp := w.Result()
expected := "Value"
actual := resp.Header.Get("X-My-Header")
if actual != expected {
t.Fatalf(`Unexpected header value, got %q instead of %q`, actual, expected)
}
}
func TestBuildResponseWithAttachment(t *testing.T) {
r, err := http.NewRequest("GET", "/", nil)
if err != nil {
t.Fatal(err)
}
w := httptest.NewRecorder()
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
New(w, r).WithAttachment("my_file.pdf").Write()
})
handler.ServeHTTP(w, r)
resp := w.Result()
expected := "attachment; filename=my_file.pdf"
actual := resp.Header.Get("Content-Disposition")
if actual != expected {
t.Fatalf(`Unexpected header value, got %q instead of %q`, actual, expected)
}
}
func TestBuildResponseWithByteBody(t *testing.T) {
r, err := http.NewRequest("GET", "/", nil)
if err != nil {
t.Fatal(err)
}
w := httptest.NewRecorder()
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
New(w, r).WithBody([]byte("body")).Write()
})
handler.ServeHTTP(w, r)
expectedBody := `body`
actualBody := w.Body.String()
if actualBody != expectedBody {
t.Fatalf(`Unexpected body, got %s instead of %s`, actualBody, expectedBody)
}
}
func TestBuildResponseWithCachingEnabled(t *testing.T) {
r, err := http.NewRequest("GET", "/", nil)
if err != nil {
t.Fatal(err)
}
w := httptest.NewRecorder()
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
New(w, r).WithCaching("etag", 1*time.Minute, func(b *Builder) {
b.WithBody("cached body")
b.Write()
})
})
handler.ServeHTTP(w, r)
resp := w.Result()
expectedStatusCode := http.StatusOK
if resp.StatusCode != expectedStatusCode {
t.Fatalf(`Unexpected status code, got %d instead of %d`, resp.StatusCode, expectedStatusCode)
}
expectedBody := `cached body`
actualBody := w.Body.String()
if actualBody != expectedBody {
t.Fatalf(`Unexpected body, got %s instead of %s`, actualBody, expectedBody)
}
expectedHeader := "public"
actualHeader := resp.Header.Get("Cache-Control")
if actualHeader != expectedHeader {
t.Fatalf(`Unexpected cache control header, got %q instead of %q`, actualHeader, expectedHeader)
}
if resp.Header.Get("Expires") == "" {
t.Fatalf(`Expires header should not be empty`)
}
}
func TestBuildResponseWithCachingAndEtag(t *testing.T) {
r, err := http.NewRequest("GET", "/", nil)
r.Header.Set("If-None-Match", "etag")
if err != nil {
t.Fatal(err)
}
w := httptest.NewRecorder()
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
New(w, r).WithCaching("etag", 1*time.Minute, func(b *Builder) {
b.WithBody("cached body")
b.Write()
})
})
handler.ServeHTTP(w, r)
resp := w.Result()
expectedStatusCode := http.StatusNotModified
if resp.StatusCode != expectedStatusCode {
t.Fatalf(`Unexpected status code, got %d instead of %d`, resp.StatusCode, expectedStatusCode)
}
expectedBody := ``
actualBody := w.Body.String()
if actualBody != expectedBody {
t.Fatalf(`Unexpected body, got %s instead of %s`, actualBody, expectedBody)
}
expectedHeader := "public"
actualHeader := resp.Header.Get("Cache-Control")
if actualHeader != expectedHeader {
t.Fatalf(`Unexpected cache control header, got %q instead of %q`, actualHeader, expectedHeader)
}
if resp.Header.Get("Expires") == "" {
t.Fatalf(`Expires header should not be empty`)
}
}
func TestBuildResponseWithBrotliCompression(t *testing.T) {
body := strings.Repeat("a", compressionThreshold+1)
r, err := http.NewRequest("GET", "/", nil)
r.Header.Set("Accept-Encoding", "gzip, deflate, br")
if err != nil {
t.Fatal(err)
}
w := httptest.NewRecorder()
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
New(w, r).WithBody(body).Write()
})
handler.ServeHTTP(w, r)
resp := w.Result()
expected := "br"
actual := resp.Header.Get("Content-Encoding")
if actual != expected {
t.Fatalf(`Unexpected header value, got %q instead of %q`, actual, expected)
}
}
func TestBuildResponseWithGzipCompression(t *testing.T) {
body := strings.Repeat("a", compressionThreshold+1)
r, err := http.NewRequest("GET", "/", nil)
r.Header.Set("Accept-Encoding", "gzip, deflate")
if err != nil {
t.Fatal(err)
}
w := httptest.NewRecorder()
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
New(w, r).WithBody(body).Write()
})
handler.ServeHTTP(w, r)
resp := w.Result()
expected := "gzip"
actual := resp.Header.Get("Content-Encoding")
if actual != expected {
t.Fatalf(`Unexpected header value, got %q instead of %q`, actual, expected)
}
}
func TestBuildResponseWithDeflateCompression(t *testing.T) {
body := strings.Repeat("a", compressionThreshold+1)
r, err := http.NewRequest("GET", "/", nil)
r.Header.Set("Accept-Encoding", "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 := "deflate"
actual := resp.Header.Get("Content-Encoding")
if actual != expected {
t.Fatalf(`Unexpected header value, got %q instead of %q`, actual, expected)
}
}
func TestBuildResponseWithCompressionDisabled(t *testing.T) {
body := strings.Repeat("a", compressionThreshold+1)
r, err := http.NewRequest("GET", "/", nil)
r.Header.Set("Accept-Encoding", "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).WithoutCompression().Write()
})
handler.ServeHTTP(w, r)
resp := w.Result()
expected := ""
actual := resp.Header.Get("Content-Encoding")
if actual != expected {
t.Fatalf(`Unexpected header value, got %q instead of %q`, actual, expected)
}
}
func TestBuildResponseWithDeflateCompressionAndSmallPayload(t *testing.T) {
body := strings.Repeat("a", compressionThreshold)
r, err := http.NewRequest("GET", "/", nil)
r.Header.Set("Accept-Encoding", "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 := ""
actual := resp.Header.Get("Content-Encoding")
if actual != expected {
t.Fatalf(`Unexpected header value, got %q instead of %q`, actual, expected)
}
}
func TestBuildResponseWithoutCompressionHeader(t *testing.T) {
body := strings.Repeat("a", compressionThreshold+1)
r, err := http.NewRequest("GET", "/", nil)
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 := ""
actual := resp.Header.Get("Content-Encoding")
if actual != expected {
t.Fatalf(`Unexpected header value, got %q instead of %q`, actual, expected)
}
}
v2-2.2.16/internal/http/response/html/ 0000775 0000000 0000000 00000000000 15127074645 0017541 5 ustar 00root root 0000000 0000000 v2-2.2.16/internal/http/response/html/html.go 0000664 0000000 0000000 00000011552 15127074645 0021040 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package html // import "miniflux.app/v2/internal/http/response/html"
import (
"html"
"log/slog"
"net/http"
"miniflux.app/v2/internal/http/request"
"miniflux.app/v2/internal/http/response"
)
// OK creates a new HTML response with a 200 status code.
func OK[T []byte | string](w http.ResponseWriter, r *http.Request, body T) {
builder := response.New(w, r)
builder.WithHeader("Content-Type", "text/html; charset=utf-8")
builder.WithHeader("Cache-Control", "no-cache, max-age=0, must-revalidate, no-store")
builder.WithBody(body)
builder.Write()
}
// ServerError sends an internal error to the client.
func ServerError(w http.ResponseWriter, r *http.Request, err error) {
slog.Error(http.StatusText(http.StatusInternalServerError),
slog.Any("error", err),
slog.String("client_ip", request.ClientIP(r)),
slog.Group("request",
slog.String("method", r.Method),
slog.String("uri", r.RequestURI),
slog.String("user_agent", r.UserAgent()),
),
slog.Group("response",
slog.Int("status_code", http.StatusInternalServerError),
),
)
builder := response.New(w, r)
builder.WithStatus(http.StatusInternalServerError)
builder.WithHeader("Content-Security-Policy", response.ContentSecurityPolicyForUntrustedContent)
builder.WithHeader("Content-Type", "text/plain; charset=utf-8")
builder.WithHeader("Cache-Control", "no-cache, max-age=0, must-revalidate, no-store")
builder.WithBody(html.EscapeString(err.Error()))
builder.Write()
}
// BadRequest sends a bad request error to the client.
func BadRequest(w http.ResponseWriter, r *http.Request, err error) {
slog.Warn(http.StatusText(http.StatusBadRequest),
slog.Any("error", err),
slog.String("client_ip", request.ClientIP(r)),
slog.Group("request",
slog.String("method", r.Method),
slog.String("uri", r.RequestURI),
slog.String("user_agent", r.UserAgent()),
),
slog.Group("response",
slog.Int("status_code", http.StatusBadRequest),
),
)
builder := response.New(w, r)
builder.WithStatus(http.StatusBadRequest)
builder.WithHeader("Content-Security-Policy", response.ContentSecurityPolicyForUntrustedContent)
builder.WithHeader("Content-Type", "text/plain; charset=utf-8")
builder.WithHeader("Cache-Control", "no-cache, max-age=0, must-revalidate, no-store")
builder.WithBody(html.EscapeString(err.Error()))
builder.Write()
}
// Forbidden sends a forbidden error to the client.
func Forbidden(w http.ResponseWriter, r *http.Request) {
slog.Warn(http.StatusText(http.StatusForbidden),
slog.String("client_ip", request.ClientIP(r)),
slog.Group("request",
slog.String("method", r.Method),
slog.String("uri", r.RequestURI),
slog.String("user_agent", r.UserAgent()),
),
slog.Group("response",
slog.Int("status_code", http.StatusForbidden),
),
)
builder := response.New(w, r)
builder.WithStatus(http.StatusForbidden)
builder.WithHeader("Content-Type", "text/html; charset=utf-8")
builder.WithHeader("Cache-Control", "no-cache, max-age=0, must-revalidate, no-store")
builder.WithBody("Access Forbidden")
builder.Write()
}
// NotFound sends a page not found error to the client.
func NotFound(w http.ResponseWriter, r *http.Request) {
slog.Warn(http.StatusText(http.StatusNotFound),
slog.String("client_ip", request.ClientIP(r)),
slog.Group("request",
slog.String("method", r.Method),
slog.String("uri", r.RequestURI),
slog.String("user_agent", r.UserAgent()),
),
slog.Group("response",
slog.Int("status_code", http.StatusNotFound),
),
)
builder := response.New(w, r)
builder.WithStatus(http.StatusNotFound)
builder.WithHeader("Content-Type", "text/html; charset=utf-8")
builder.WithHeader("Cache-Control", "no-cache, max-age=0, must-revalidate, no-store")
builder.WithBody("Page Not Found")
builder.Write()
}
// Redirect redirects the user to another location.
func Redirect(w http.ResponseWriter, r *http.Request, uri string) {
http.Redirect(w, r, uri, http.StatusFound)
}
// RequestedRangeNotSatisfiable sends a range not satisfiable error to the client.
func RequestedRangeNotSatisfiable(w http.ResponseWriter, r *http.Request, contentRange string) {
slog.Warn(http.StatusText(http.StatusRequestedRangeNotSatisfiable),
slog.String("client_ip", request.ClientIP(r)),
slog.Group("request",
slog.String("method", r.Method),
slog.String("uri", r.RequestURI),
slog.String("user_agent", r.UserAgent()),
),
slog.Group("response",
slog.Int("status_code", http.StatusRequestedRangeNotSatisfiable),
),
)
builder := response.New(w, r)
builder.WithStatus(http.StatusRequestedRangeNotSatisfiable)
builder.WithHeader("Content-Type", "text/html; charset=utf-8")
builder.WithHeader("Cache-Control", "no-cache, max-age=0, must-revalidate, no-store")
builder.WithHeader("Content-Range", contentRange)
builder.WithBody("Range Not Satisfiable")
builder.Write()
}
v2-2.2.16/internal/http/response/html/html_test.go 0000664 0000000 0000000 00000015276 15127074645 0022106 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package html // import "miniflux.app/v2/internal/http/response/html"
import (
"errors"
"net/http"
"net/http/httptest"
"testing"
)
func TestOKResponse(t *testing.T) {
r, err := http.NewRequest("GET", "/", nil)
if err != nil {
t.Fatal(err)
}
w := httptest.NewRecorder()
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
OK(w, r, "Some HTML")
})
handler.ServeHTTP(w, r)
resp := w.Result()
expectedStatusCode := http.StatusOK
if resp.StatusCode != expectedStatusCode {
t.Fatalf(`Unexpected status code, got %d instead of %d`, resp.StatusCode, expectedStatusCode)
}
expectedBody := `Some HTML`
actualBody := w.Body.String()
if actualBody != expectedBody {
t.Fatalf(`Unexpected body, got %s instead of %s`, actualBody, expectedBody)
}
headers := map[string]string{
"Content-Type": "text/html; charset=utf-8",
"Cache-Control": "no-cache, max-age=0, must-revalidate, no-store",
}
for header, expected := range headers {
actual := resp.Header.Get(header)
if actual != expected {
t.Fatalf(`Unexpected header value, got %q instead of %q`, actual, expected)
}
}
}
func TestServerErrorResponse(t *testing.T) {
r, err := http.NewRequest("GET", "/", nil)
if err != nil {
t.Fatal(err)
}
w := httptest.NewRecorder()
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ServerError(w, r, errors.New("Some error with injected HTML "))
})
handler.ServeHTTP(w, r)
resp := w.Result()
expectedStatusCode := http.StatusInternalServerError
if resp.StatusCode != expectedStatusCode {
t.Fatalf(`Unexpected status code, got %d instead of %d`, resp.StatusCode, expectedStatusCode)
}
expectedBody := `Some error with injected HTML <script>alert('XSS')</script>`
actualBody := w.Body.String()
if actualBody != expectedBody {
t.Fatalf(`Unexpected body, got %s instead of %s`, actualBody, expectedBody)
}
expectedContentType := "text/plain; charset=utf-8"
actualContentType := resp.Header.Get("Content-Type")
if actualContentType != expectedContentType {
t.Fatalf(`Unexpected content type, got %q instead of %q`, actualContentType, expectedContentType)
}
}
func TestBadRequestResponse(t *testing.T) {
r, err := http.NewRequest("GET", "/", nil)
if err != nil {
t.Fatal(err)
}
w := httptest.NewRecorder()
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
BadRequest(w, r, errors.New("Some error with injected HTML "))
})
handler.ServeHTTP(w, r)
resp := w.Result()
expectedStatusCode := http.StatusBadRequest
if resp.StatusCode != expectedStatusCode {
t.Fatalf(`Unexpected status code, got %d instead of %d`, resp.StatusCode, expectedStatusCode)
}
expectedBody := `Some error with injected HTML <script>alert('XSS')</script>`
actualBody := w.Body.String()
if actualBody != expectedBody {
t.Fatalf(`Unexpected body, got %s instead of %s`, actualBody, expectedBody)
}
expectedContentType := "text/plain; charset=utf-8"
actualContentType := resp.Header.Get("Content-Type")
if actualContentType != expectedContentType {
t.Fatalf(`Unexpected content type, got %q instead of %q`, actualContentType, expectedContentType)
}
}
func TestForbiddenResponse(t *testing.T) {
r, err := http.NewRequest("GET", "/", nil)
if err != nil {
t.Fatal(err)
}
w := httptest.NewRecorder()
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
Forbidden(w, r)
})
handler.ServeHTTP(w, r)
resp := w.Result()
expectedStatusCode := http.StatusForbidden
if resp.StatusCode != expectedStatusCode {
t.Fatalf(`Unexpected status code, got %d instead of %d`, resp.StatusCode, expectedStatusCode)
}
expectedBody := `Access Forbidden`
actualBody := w.Body.String()
if actualBody != expectedBody {
t.Fatalf(`Unexpected body, got %s instead of %s`, actualBody, expectedBody)
}
expectedContentType := "text/html; charset=utf-8"
actualContentType := resp.Header.Get("Content-Type")
if actualContentType != expectedContentType {
t.Fatalf(`Unexpected content type, got %q instead of %q`, actualContentType, expectedContentType)
}
}
func TestNotFoundResponse(t *testing.T) {
r, err := http.NewRequest("GET", "/", nil)
if err != nil {
t.Fatal(err)
}
w := httptest.NewRecorder()
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
NotFound(w, r)
})
handler.ServeHTTP(w, r)
resp := w.Result()
expectedStatusCode := http.StatusNotFound
if resp.StatusCode != expectedStatusCode {
t.Fatalf(`Unexpected status code, got %d instead of %d`, resp.StatusCode, expectedStatusCode)
}
expectedBody := `Page Not Found`
actualBody := w.Body.String()
if actualBody != expectedBody {
t.Fatalf(`Unexpected body, got %s instead of %s`, actualBody, expectedBody)
}
expectedContentType := "text/html; charset=utf-8"
actualContentType := resp.Header.Get("Content-Type")
if actualContentType != expectedContentType {
t.Fatalf(`Unexpected content type, got %q instead of %q`, actualContentType, expectedContentType)
}
}
func TestRedirectResponse(t *testing.T) {
r, err := http.NewRequest("GET", "/", nil)
if err != nil {
t.Fatal(err)
}
w := httptest.NewRecorder()
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
Redirect(w, r, "/path")
})
handler.ServeHTTP(w, r)
resp := w.Result()
defer resp.Body.Close()
expectedStatusCode := http.StatusFound
if resp.StatusCode != expectedStatusCode {
t.Fatalf(`Unexpected status code, got %d instead of %d`, resp.StatusCode, expectedStatusCode)
}
expectedResult := "/path"
actualResult := resp.Header.Get("Location")
if actualResult != expectedResult {
t.Fatalf(`Unexpected redirect location, got %q instead of %q`, actualResult, expectedResult)
}
}
func TestRequestedRangeNotSatisfiable(t *testing.T) {
r, err := http.NewRequest("GET", "/", nil)
if err != nil {
t.Fatal(err)
}
w := httptest.NewRecorder()
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
RequestedRangeNotSatisfiable(w, r, "bytes */12777")
})
handler.ServeHTTP(w, r)
resp := w.Result()
defer resp.Body.Close()
expectedStatusCode := http.StatusRequestedRangeNotSatisfiable
if resp.StatusCode != expectedStatusCode {
t.Fatalf(`Unexpected status code, got %d instead of %d`, resp.StatusCode, expectedStatusCode)
}
expectedContentRangeHeader := "bytes */12777"
actualContentRangeHeader := resp.Header.Get("Content-Range")
if actualContentRangeHeader != expectedContentRangeHeader {
t.Fatalf(`Unexpected content range header, got %q instead of %q`, actualContentRangeHeader, expectedContentRangeHeader)
}
}
v2-2.2.16/internal/http/response/json/ 0000775 0000000 0000000 00000000000 15127074645 0017546 5 ustar 00root root 0000000 0000000 v2-2.2.16/internal/http/response/json/json.go 0000664 0000000 0000000 00000014446 15127074645 0021057 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package json // import "miniflux.app/v2/internal/http/response/json"
import (
"encoding/json"
"errors"
"log/slog"
"net/http"
"miniflux.app/v2/internal/http/request"
"miniflux.app/v2/internal/http/response"
)
const contentTypeHeader = `application/json`
// OK creates a new JSON response with a 200 status code.
func OK(w http.ResponseWriter, r *http.Request, body any) {
responseBody, err := json.Marshal(body)
if err != nil {
ServerError(w, r, err)
return
}
builder := response.New(w, r)
builder.WithHeader("Content-Type", contentTypeHeader)
builder.WithBody(responseBody)
builder.Write()
}
// Created sends a created response to the client.
func Created(w http.ResponseWriter, r *http.Request, body any) {
responseBody, err := json.Marshal(body)
if err != nil {
ServerError(w, r, err)
return
}
builder := response.New(w, r)
builder.WithStatus(http.StatusCreated)
builder.WithHeader("Content-Type", contentTypeHeader)
builder.WithBody(responseBody)
builder.Write()
}
// NoContent sends a no content response to the client.
func NoContent(w http.ResponseWriter, r *http.Request) {
builder := response.New(w, r)
builder.WithStatus(http.StatusNoContent)
builder.WithHeader("Content-Type", contentTypeHeader)
builder.Write()
}
func Accepted(w http.ResponseWriter, r *http.Request) {
builder := response.New(w, r)
builder.WithStatus(http.StatusAccepted)
builder.WithHeader("Content-Type", contentTypeHeader)
builder.Write()
}
// ServerError sends an internal error to the client.
func ServerError(w http.ResponseWriter, r *http.Request, err error) {
slog.Error(http.StatusText(http.StatusInternalServerError),
slog.Any("error", err),
slog.String("client_ip", request.ClientIP(r)),
slog.Group("request",
slog.String("method", r.Method),
slog.String("uri", r.RequestURI),
slog.String("user_agent", r.UserAgent()),
),
slog.Group("response",
slog.Int("status_code", http.StatusInternalServerError),
),
)
responseBody, jsonErr := generateJSONError(err)
if jsonErr != nil {
slog.Error("Unable to generate JSON error", slog.Any("error", jsonErr))
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
builder := response.New(w, r)
builder.WithStatus(http.StatusInternalServerError)
builder.WithHeader("Content-Type", contentTypeHeader)
builder.WithBody(responseBody)
builder.Write()
}
// BadRequest sends a bad request error to the client.
func BadRequest(w http.ResponseWriter, r *http.Request, err error) {
slog.Warn(http.StatusText(http.StatusBadRequest),
slog.Any("error", err),
slog.String("client_ip", request.ClientIP(r)),
slog.Group("request",
slog.String("method", r.Method),
slog.String("uri", r.RequestURI),
slog.String("user_agent", r.UserAgent()),
),
slog.Group("response",
slog.Int("status_code", http.StatusBadRequest),
),
)
responseBody, jsonErr := generateJSONError(err)
if jsonErr != nil {
slog.Error("Unable to generate JSON error", slog.Any("error", jsonErr))
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
builder := response.New(w, r)
builder.WithStatus(http.StatusBadRequest)
builder.WithHeader("Content-Type", contentTypeHeader)
builder.WithBody(responseBody)
builder.Write()
}
// Unauthorized sends a not authorized error to the client.
func Unauthorized(w http.ResponseWriter, r *http.Request) {
slog.Warn(http.StatusText(http.StatusUnauthorized),
slog.String("client_ip", request.ClientIP(r)),
slog.Group("request",
slog.String("method", r.Method),
slog.String("uri", r.RequestURI),
slog.String("user_agent", r.UserAgent()),
),
slog.Group("response",
slog.Int("status_code", http.StatusUnauthorized),
),
)
responseBody, jsonErr := generateJSONError(errors.New("access unauthorized"))
if jsonErr != nil {
slog.Error("Unable to generate JSON error", slog.Any("error", jsonErr))
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
builder := response.New(w, r)
builder.WithStatus(http.StatusUnauthorized)
builder.WithHeader("Content-Type", contentTypeHeader)
builder.WithBody(responseBody)
builder.Write()
}
// Forbidden sends a forbidden error to the client.
func Forbidden(w http.ResponseWriter, r *http.Request) {
slog.Warn(http.StatusText(http.StatusForbidden),
slog.String("client_ip", request.ClientIP(r)),
slog.Group("request",
slog.String("method", r.Method),
slog.String("uri", r.RequestURI),
slog.String("user_agent", r.UserAgent()),
),
slog.Group("response",
slog.Int("status_code", http.StatusForbidden),
),
)
responseBody, jsonErr := generateJSONError(errors.New("access forbidden"))
if jsonErr != nil {
slog.Error("Unable to generate JSON error", slog.Any("error", jsonErr))
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
builder := response.New(w, r)
builder.WithStatus(http.StatusForbidden)
builder.WithHeader("Content-Type", contentTypeHeader)
builder.WithBody(responseBody)
builder.Write()
}
// NotFound sends a page not found error to the client.
func NotFound(w http.ResponseWriter, r *http.Request) {
slog.Warn(http.StatusText(http.StatusNotFound),
slog.String("client_ip", request.ClientIP(r)),
slog.Group("request",
slog.String("method", r.Method),
slog.String("uri", r.RequestURI),
slog.String("user_agent", r.UserAgent()),
),
slog.Group("response",
slog.Int("status_code", http.StatusNotFound),
),
)
responseBody, jsonErr := generateJSONError(errors.New("resource not found"))
if jsonErr != nil {
slog.Error("Unable to generate JSON error", slog.Any("error", jsonErr))
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
builder := response.New(w, r)
builder.WithStatus(http.StatusNotFound)
builder.WithHeader("Content-Type", contentTypeHeader)
builder.WithBody(responseBody)
builder.Write()
}
func generateJSONError(err error) ([]byte, error) {
type errorMsg struct {
ErrorMessage string `json:"error_message"`
}
encodedBody, err := json.Marshal(errorMsg{ErrorMessage: err.Error()})
if err != nil {
return nil, err
}
return encodedBody, nil
}
v2-2.2.16/internal/http/response/json/json_test.go 0000664 0000000 0000000 00000021157 15127074645 0022113 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package json // import "miniflux.app/v2/internal/http/response/json"
import (
"errors"
"net/http"
"net/http/httptest"
"testing"
)
func TestOKResponse(t *testing.T) {
r, err := http.NewRequest("GET", "/", nil)
if err != nil {
t.Fatal(err)
}
w := httptest.NewRecorder()
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
OK(w, r, map[string]string{"key": "value"})
})
handler.ServeHTTP(w, r)
resp := w.Result()
defer resp.Body.Close()
expectedStatusCode := http.StatusOK
if resp.StatusCode != expectedStatusCode {
t.Fatalf(`Unexpected status code, got %d instead of %d`, resp.StatusCode, expectedStatusCode)
}
expectedBody := `{"key":"value"}`
actualBody := w.Body.String()
if actualBody != expectedBody {
t.Fatalf(`Unexpected body, got %q instead of %q`, actualBody, expectedBody)
}
expectedContentType := contentTypeHeader
actualContentType := resp.Header.Get("Content-Type")
if actualContentType != expectedContentType {
t.Fatalf(`Unexpected content type, got %q instead of %q`, actualContentType, expectedContentType)
}
}
func TestCreatedResponse(t *testing.T) {
r, err := http.NewRequest("GET", "/", nil)
if err != nil {
t.Fatal(err)
}
w := httptest.NewRecorder()
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
Created(w, r, map[string]string{"key": "value"})
})
handler.ServeHTTP(w, r)
resp := w.Result()
expectedStatusCode := http.StatusCreated
if resp.StatusCode != expectedStatusCode {
t.Fatalf(`Unexpected status code, got %d instead of %d`, resp.StatusCode, expectedStatusCode)
}
expectedBody := `{"key":"value"}`
actualBody := w.Body.String()
if actualBody != expectedBody {
t.Fatalf(`Unexpected body, got %s instead of %s`, actualBody, expectedBody)
}
expectedContentType := contentTypeHeader
actualContentType := resp.Header.Get("Content-Type")
if actualContentType != expectedContentType {
t.Fatalf(`Unexpected content type, got %q instead of %q`, actualContentType, expectedContentType)
}
}
func TestNoContentResponse(t *testing.T) {
r, err := http.NewRequest("GET", "/", nil)
if err != nil {
t.Fatal(err)
}
w := httptest.NewRecorder()
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
NoContent(w, r)
})
handler.ServeHTTP(w, r)
resp := w.Result()
expectedStatusCode := http.StatusNoContent
if resp.StatusCode != expectedStatusCode {
t.Fatalf(`Unexpected status code, got %d instead of %d`, resp.StatusCode, expectedStatusCode)
}
expectedBody := ``
actualBody := w.Body.String()
if actualBody != expectedBody {
t.Fatalf(`Unexpected body, got %s instead of %s`, actualBody, expectedBody)
}
expectedContentType := contentTypeHeader
actualContentType := resp.Header.Get("Content-Type")
if actualContentType != expectedContentType {
t.Fatalf(`Unexpected content type, got %q instead of %q`, actualContentType, expectedContentType)
}
}
func TestServerErrorResponse(t *testing.T) {
r, err := http.NewRequest("GET", "/", nil)
if err != nil {
t.Fatal(err)
}
w := httptest.NewRecorder()
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ServerError(w, r, errors.New("some error"))
})
handler.ServeHTTP(w, r)
resp := w.Result()
defer resp.Body.Close()
expectedStatusCode := http.StatusInternalServerError
if resp.StatusCode != expectedStatusCode {
t.Fatalf(`Unexpected status code, got %d instead of %d`, resp.StatusCode, expectedStatusCode)
}
expectedBody := `{"error_message":"some error"}`
actualBody := w.Body.String()
if actualBody != expectedBody {
t.Fatalf(`Unexpected body, got %q instead of %q`, actualBody, expectedBody)
}
expectedContentType := contentTypeHeader
actualContentType := resp.Header.Get("Content-Type")
if actualContentType != expectedContentType {
t.Fatalf(`Unexpected content type, got %q instead of %q`, actualContentType, expectedContentType)
}
}
func TestBadRequestResponse(t *testing.T) {
r, err := http.NewRequest("GET", "/", nil)
if err != nil {
t.Fatal(err)
}
w := httptest.NewRecorder()
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
BadRequest(w, r, errors.New("Some Error"))
})
handler.ServeHTTP(w, r)
resp := w.Result()
expectedStatusCode := http.StatusBadRequest
if resp.StatusCode != expectedStatusCode {
t.Fatalf(`Unexpected status code, got %d instead of %d`, resp.StatusCode, expectedStatusCode)
}
expectedBody := `{"error_message":"Some Error"}`
actualBody := w.Body.String()
if actualBody != expectedBody {
t.Fatalf(`Unexpected body, got %s instead of %s`, actualBody, expectedBody)
}
expectedContentType := contentTypeHeader
actualContentType := resp.Header.Get("Content-Type")
if actualContentType != expectedContentType {
t.Fatalf(`Unexpected content type, got %q instead of %q`, actualContentType, expectedContentType)
}
}
func TestUnauthorizedResponse(t *testing.T) {
r, err := http.NewRequest("GET", "/", nil)
if err != nil {
t.Fatal(err)
}
w := httptest.NewRecorder()
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
Unauthorized(w, r)
})
handler.ServeHTTP(w, r)
resp := w.Result()
expectedStatusCode := http.StatusUnauthorized
if resp.StatusCode != expectedStatusCode {
t.Fatalf(`Unexpected status code, got %d instead of %d`, resp.StatusCode, expectedStatusCode)
}
expectedBody := `{"error_message":"access unauthorized"}`
actualBody := w.Body.String()
if actualBody != expectedBody {
t.Fatalf(`Unexpected body, got %s instead of %s`, actualBody, expectedBody)
}
expectedContentType := contentTypeHeader
actualContentType := resp.Header.Get("Content-Type")
if actualContentType != expectedContentType {
t.Fatalf(`Unexpected content type, got %q instead of %q`, actualContentType, expectedContentType)
}
}
func TestForbiddenResponse(t *testing.T) {
r, err := http.NewRequest("GET", "/", nil)
if err != nil {
t.Fatal(err)
}
w := httptest.NewRecorder()
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
Forbidden(w, r)
})
handler.ServeHTTP(w, r)
resp := w.Result()
expectedStatusCode := http.StatusForbidden
if resp.StatusCode != expectedStatusCode {
t.Fatalf(`Unexpected status code, got %d instead of %d`, resp.StatusCode, expectedStatusCode)
}
expectedBody := `{"error_message":"access forbidden"}`
actualBody := w.Body.String()
if actualBody != expectedBody {
t.Fatalf(`Unexpected body, got %s instead of %s`, actualBody, expectedBody)
}
expectedContentType := contentTypeHeader
actualContentType := resp.Header.Get("Content-Type")
if actualContentType != expectedContentType {
t.Fatalf(`Unexpected content type, got %q instead of %q`, actualContentType, expectedContentType)
}
}
func TestNotFoundResponse(t *testing.T) {
r, err := http.NewRequest("GET", "/", nil)
if err != nil {
t.Fatal(err)
}
w := httptest.NewRecorder()
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
NotFound(w, r)
})
handler.ServeHTTP(w, r)
resp := w.Result()
expectedStatusCode := http.StatusNotFound
if resp.StatusCode != expectedStatusCode {
t.Fatalf(`Unexpected status code, got %d instead of %d`, resp.StatusCode, expectedStatusCode)
}
expectedBody := `{"error_message":"resource not found"}`
actualBody := w.Body.String()
if actualBody != expectedBody {
t.Fatalf(`Unexpected body, got %s instead of %s`, actualBody, expectedBody)
}
expectedContentType := contentTypeHeader
actualContentType := resp.Header.Get("Content-Type")
if actualContentType != expectedContentType {
t.Fatalf(`Unexpected content type, got %q instead of %q`, actualContentType, expectedContentType)
}
}
func TestBuildInvalidJSONResponse(t *testing.T) {
r, err := http.NewRequest("GET", "/", nil)
if err != nil {
t.Fatal(err)
}
w := httptest.NewRecorder()
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
OK(w, r, make(chan int))
})
handler.ServeHTTP(w, r)
resp := w.Result()
expectedStatusCode := http.StatusInternalServerError
if resp.StatusCode != expectedStatusCode {
t.Fatalf(`Unexpected status code, got %d instead of %d`, resp.StatusCode, expectedStatusCode)
}
expectedBody := `{"error_message":"json: unsupported type: chan int"}`
actualBody := w.Body.String()
if actualBody != expectedBody {
t.Fatalf(`Unexpected body, got %s instead of %s`, actualBody, expectedBody)
}
expectedContentType := contentTypeHeader
actualContentType := resp.Header.Get("Content-Type")
if actualContentType != expectedContentType {
t.Fatalf(`Unexpected content type, got %q instead of %q`, actualContentType, expectedContentType)
}
}
v2-2.2.16/internal/http/response/response.go 0000664 0000000 0000000 00000001612 15127074645 0020762 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package response // import "miniflux.app/v2/internal/http/response"
// ContentSecurityPolicyForUntrustedContent is the default CSP for untrusted content.
// default-src 'none' disables all content sources
// form-action 'none' disables all form submissions
// sandbox enables a sandbox for the requested resource
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/form-action
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/sandbox
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/default-src
const ContentSecurityPolicyForUntrustedContent = `default-src 'none'; form-action 'none'; sandbox;`
v2-2.2.16/internal/http/response/xml/ 0000775 0000000 0000000 00000000000 15127074645 0017375 5 ustar 00root root 0000000 0000000 v2-2.2.16/internal/http/response/xml/xml.go 0000664 0000000 0000000 00000001555 15127074645 0020532 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package xml // import "miniflux.app/v2/internal/http/response/xml"
import (
"net/http"
"miniflux.app/v2/internal/http/response"
)
// OK writes a standard XML response with a status 200 OK.
func OK[T []byte | string](w http.ResponseWriter, r *http.Request, body T) {
builder := response.New(w, r)
builder.WithHeader("Content-Type", "text/xml; charset=utf-8")
builder.WithBody(body)
builder.Write()
}
// Attachment forces the XML document to be downloaded by the web browser.
func Attachment[T []byte | string](w http.ResponseWriter, r *http.Request, filename string, body T) {
builder := response.New(w, r)
builder.WithHeader("Content-Type", "text/xml; charset=utf-8")
builder.WithAttachment(filename)
builder.WithBody(body)
builder.Write()
}
v2-2.2.16/internal/http/response/xml/xml_test.go 0000664 0000000 0000000 00000004167 15127074645 0021573 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package xml // import "miniflux.app/v2/internal/http/response/xml"
import (
"net/http"
"net/http/httptest"
"testing"
)
func TestOKResponse(t *testing.T) {
r, err := http.NewRequest("GET", "/", nil)
if err != nil {
t.Fatal(err)
}
w := httptest.NewRecorder()
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
OK(w, r, "Some XML")
})
handler.ServeHTTP(w, r)
resp := w.Result()
expectedStatusCode := http.StatusOK
if resp.StatusCode != expectedStatusCode {
t.Fatalf(`Unexpected status code, got %d instead of %d`, resp.StatusCode, expectedStatusCode)
}
expectedBody := `Some XML`
actualBody := w.Body.String()
if actualBody != expectedBody {
t.Fatalf(`Unexpected body, got %s instead of %s`, actualBody, expectedBody)
}
expectedContentType := "text/xml; charset=utf-8"
actualContentType := resp.Header.Get("Content-Type")
if actualContentType != expectedContentType {
t.Fatalf(`Unexpected content type, got %q instead of %q`, actualContentType, expectedContentType)
}
}
func TestAttachmentResponse(t *testing.T) {
r, err := http.NewRequest("GET", "/", nil)
if err != nil {
t.Fatal(err)
}
w := httptest.NewRecorder()
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
Attachment(w, r, "file.xml", "Some XML")
})
handler.ServeHTTP(w, r)
resp := w.Result()
expectedStatusCode := http.StatusOK
if resp.StatusCode != expectedStatusCode {
t.Fatalf(`Unexpected status code, got %d instead of %d`, resp.StatusCode, expectedStatusCode)
}
expectedBody := `Some XML`
actualBody := w.Body.String()
if actualBody != expectedBody {
t.Fatalf(`Unexpected body, got %s instead of %s`, actualBody, expectedBody)
}
headers := map[string]string{
"Content-Type": "text/xml; charset=utf-8",
"Content-Disposition": "attachment; filename=file.xml",
}
for header, expected := range headers {
actual := resp.Header.Get(header)
if actual != expected {
t.Fatalf(`Unexpected header value, got %q instead of %q`, actual, expected)
}
}
}
v2-2.2.16/internal/http/route/ 0000775 0000000 0000000 00000000000 15127074645 0016075 5 ustar 00root root 0000000 0000000 v2-2.2.16/internal/http/route/route.go 0000664 0000000 0000000 00000001353 15127074645 0017564 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package route // import "miniflux.app/v2/internal/http/route"
import (
"strconv"
"github.com/gorilla/mux"
)
// Path returns the defined route based on given arguments.
func Path(router *mux.Router, name string, args ...any) string {
route := router.Get(name)
if route == nil {
panic("route not found: " + name)
}
var pairs []string
for _, arg := range args {
switch param := arg.(type) {
case string:
pairs = append(pairs, param)
case int64:
pairs = append(pairs, strconv.FormatInt(param, 10))
}
}
result, err := route.URLPath(pairs...)
if err != nil {
panic(err)
}
return result.String()
}
v2-2.2.16/internal/http/server/ 0000775 0000000 0000000 00000000000 15127074645 0016245 5 ustar 00root root 0000000 0000000 v2-2.2.16/internal/http/server/httpd.go 0000664 0000000 0000000 00000024726 15127074645 0017732 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package server // import "miniflux.app/v2/internal/http/server"
import (
"crypto/tls"
"fmt"
"log/slog"
"net"
"net/http"
"os"
"strconv"
"strings"
"miniflux.app/v2/internal/api"
"miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/fever"
"miniflux.app/v2/internal/googlereader"
"miniflux.app/v2/internal/http/request"
"miniflux.app/v2/internal/storage"
"miniflux.app/v2/internal/ui"
"miniflux.app/v2/internal/version"
"miniflux.app/v2/internal/worker"
"github.com/gorilla/mux"
"github.com/prometheus/client_golang/prometheus/promhttp"
"golang.org/x/crypto/acme"
"golang.org/x/crypto/acme/autocert"
)
func StartWebServer(store *storage.Storage, pool *worker.Pool) []*http.Server {
listenAddresses := config.Opts.ListenAddr()
var httpServers []*http.Server
certFile := config.Opts.CertFile()
keyFile := config.Opts.CertKeyFile()
certDomain := config.Opts.CertDomain()
var sharedAutocertTLSConfig *tls.Config
if certDomain != "" {
slog.Debug("Configuring autocert manager and shared TLS config", slog.String("domain", certDomain))
certManager := autocert.Manager{
Cache: storage.NewCertificateCache(store),
Prompt: autocert.AcceptTOS,
HostPolicy: autocert.HostWhitelist(certDomain),
}
sharedAutocertTLSConfig = &tls.Config{}
sharedAutocertTLSConfig.GetCertificate = certManager.GetCertificate
sharedAutocertTLSConfig.NextProtos = []string{"h2", "http/1.1", acme.ALPNProto}
challengeServer := &http.Server{
Handler: certManager.HTTPHandler(nil),
Addr: ":http",
}
slog.Info("Starting ACME HTTP challenge server for autocert", slog.String("address", challengeServer.Addr))
go func() {
if err := challengeServer.ListenAndServe(); err != http.ErrServerClosed {
slog.Error("ACME HTTP challenge server failed", slog.Any("error", err))
}
}()
config.Opts.SetHTTPSValue(true)
httpServers = append(httpServers, challengeServer)
}
for i, listenAddr := range listenAddresses {
server := &http.Server{
ReadTimeout: config.Opts.HTTPServerTimeout(),
WriteTimeout: config.Opts.HTTPServerTimeout(),
IdleTimeout: config.Opts.HTTPServerTimeout(),
Handler: setupHandler(store, pool),
}
if !strings.HasPrefix(listenAddr, "/") && os.Getenv("LISTEN_PID") != strconv.Itoa(os.Getpid()) {
server.Addr = listenAddr
}
shouldAddServer := true
switch {
case os.Getenv("LISTEN_PID") == strconv.Itoa(os.Getpid()):
if i == 0 {
slog.Info("Starting server using systemd socket for the first listen address", slog.String("address_info", listenAddr))
startSystemdSocketServer(server)
} else {
slog.Warn("Systemd socket activation: Only the first listen address is used by systemd. Other addresses ignored.", slog.String("skipped_address", listenAddr))
shouldAddServer = false
}
case strings.HasPrefix(listenAddr, "/"): // Unix socket
startUnixSocketServer(server, listenAddr)
case certDomain != "" && (listenAddr == ":https" || (i == 0 && strings.Contains(listenAddr, ":"))):
server.Addr = listenAddr
startAutoCertTLSServer(server, sharedAutocertTLSConfig)
case certFile != "" && keyFile != "":
server.Addr = listenAddr
startTLSServer(server, certFile, keyFile)
config.Opts.SetHTTPSValue(true)
default:
server.Addr = listenAddr
startHTTPServer(server)
}
if shouldAddServer {
httpServers = append(httpServers, server)
}
}
return httpServers
}
func startSystemdSocketServer(server *http.Server) {
go func() {
f := os.NewFile(3, "systemd socket")
listener, err := net.FileListener(f)
if err != nil {
printErrorAndExit(`Unable to create listener from systemd socket: %v`, err)
}
slog.Info(`Starting server using systemd socket`)
if err := server.Serve(listener); err != http.ErrServerClosed {
printErrorAndExit(`Systemd socket server failed to start: %v`, err)
}
}()
}
func startUnixSocketServer(server *http.Server, socketFile string) {
if err := os.Remove(socketFile); err != nil && !os.IsNotExist(err) {
printErrorAndExit("Unable to remove existing Unix socket %s: %v", socketFile, err)
}
listener, err := net.Listen("unix", socketFile)
if err != nil {
printErrorAndExit(`Server failed to listen on Unix socket %s: %v`, socketFile, err)
}
if err := os.Chmod(socketFile, 0666); err != nil {
printErrorAndExit(`Unable to change socket permission for %s: %v`, socketFile, err)
}
go func() {
certFile := config.Opts.CertFile()
keyFile := config.Opts.CertKeyFile()
if certFile != "" && keyFile != "" {
slog.Info("Starting TLS server using a Unix socket",
slog.String("socket", socketFile),
slog.String("cert_file", certFile),
slog.String("key_file", keyFile),
)
// Ensure HTTPS is marked as true if any listener uses TLS
config.Opts.SetHTTPSValue(true)
if err := server.ServeTLS(listener, certFile, keyFile); err != http.ErrServerClosed {
printErrorAndExit("TLS Unix socket server failed to start on %s: %v", socketFile, err)
}
} else {
slog.Info("Starting server using a Unix socket", slog.String("socket", socketFile))
if err := server.Serve(listener); err != http.ErrServerClosed {
printErrorAndExit("Unix socket server failed to start on %s: %v", socketFile, err)
}
}
}()
}
func startAutoCertTLSServer(server *http.Server, autoTLSConfig *tls.Config) {
if server.TLSConfig == nil {
server.TLSConfig = &tls.Config{}
}
server.TLSConfig.GetCertificate = autoTLSConfig.GetCertificate
server.TLSConfig.NextProtos = autoTLSConfig.NextProtos
go func() {
slog.Info("Starting TLS server using automatic certificate management",
slog.String("listen_address", server.Addr),
)
if err := server.ListenAndServeTLS("", ""); err != http.ErrServerClosed {
printErrorAndExit("Autocert server failed to start on %s: %v", server.Addr, err)
}
}()
}
func startTLSServer(server *http.Server, certFile, keyFile string) {
go func() {
slog.Info("Starting TLS server using a certificate",
slog.String("listen_address", server.Addr),
slog.String("cert_file", certFile),
slog.String("key_file", keyFile),
)
if err := server.ListenAndServeTLS(certFile, keyFile); err != http.ErrServerClosed {
printErrorAndExit("TLS server failed to start on %s: %v", server.Addr, err)
}
}()
}
func startHTTPServer(server *http.Server) {
go func() {
slog.Info("Starting HTTP server",
slog.String("listen_address", server.Addr),
)
if err := server.ListenAndServe(); err != http.ErrServerClosed {
printErrorAndExit("HTTP server failed to start on %s: %v", server.Addr, err)
}
}()
}
func setupHandler(store *storage.Storage, pool *worker.Pool) *mux.Router {
livenessProbe := func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("OK"))
}
readinessProbe := func(w http.ResponseWriter, r *http.Request) {
if err := store.Ping(); err != nil {
http.Error(w, fmt.Sprintf("Database Connection Error: %q", err), http.StatusServiceUnavailable)
return
}
w.WriteHeader(http.StatusOK)
w.Write([]byte("OK"))
}
router := mux.NewRouter()
// These routes do not take the base path into consideration and are always available at the root of the server.
router.HandleFunc("/liveness", livenessProbe).Name("liveness")
router.HandleFunc("/healthz", livenessProbe).Name("healthz")
router.HandleFunc("/readiness", readinessProbe).Name("readiness")
router.HandleFunc("/readyz", readinessProbe).Name("readyz")
var subrouter *mux.Router
if config.Opts.BasePath() != "" {
subrouter = router.PathPrefix(config.Opts.BasePath()).Subrouter()
} else {
subrouter = router.NewRoute().Subrouter()
}
if config.Opts.HasMaintenanceMode() {
subrouter.Use(func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(config.Opts.MaintenanceMessage()))
})
})
}
subrouter.Use(middleware)
fever.Serve(subrouter, store)
googlereader.Serve(subrouter, store)
if config.Opts.HasAPI() {
api.Serve(subrouter, store, pool)
}
ui.Serve(subrouter, store, pool)
subrouter.HandleFunc("/healthcheck", readinessProbe).Name("healthcheck")
subrouter.HandleFunc("/version", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(version.Version))
}).Name("version")
if config.Opts.HasMetricsCollector() {
subrouter.Handle("/metrics", promhttp.Handler()).Name("metrics")
subrouter.Use(func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
route := mux.CurrentRoute(r)
// Returns a 404 if the client is not authorized to access the metrics endpoint.
if route.GetName() == "metrics" && !isAllowedToAccessMetricsEndpoint(r) {
slog.Warn("Authentication failed while accessing the metrics endpoint",
slog.String("client_ip", request.ClientIP(r)),
slog.String("client_user_agent", r.UserAgent()),
slog.String("client_remote_addr", r.RemoteAddr),
)
http.NotFound(w, r)
return
}
next.ServeHTTP(w, r)
})
})
}
return router
}
func isAllowedToAccessMetricsEndpoint(r *http.Request) bool {
clientIP := request.ClientIP(r)
if config.Opts.MetricsUsername() != "" && config.Opts.MetricsPassword() != "" {
username, password, authOK := r.BasicAuth()
if !authOK {
slog.Warn("Metrics endpoint accessed without authentication header",
slog.Bool("authentication_failed", true),
slog.String("client_ip", clientIP),
slog.String("client_user_agent", r.UserAgent()),
slog.String("client_remote_addr", r.RemoteAddr),
)
return false
}
if username == "" || password == "" {
slog.Warn("Metrics endpoint accessed with empty username or password",
slog.Bool("authentication_failed", true),
slog.String("client_ip", clientIP),
slog.String("client_user_agent", r.UserAgent()),
slog.String("client_remote_addr", r.RemoteAddr),
)
return false
}
if username != config.Opts.MetricsUsername() || password != config.Opts.MetricsPassword() {
slog.Warn("Metrics endpoint accessed with invalid username or password",
slog.Bool("authentication_failed", true),
slog.String("client_ip", clientIP),
slog.String("client_user_agent", r.UserAgent()),
slog.String("client_remote_addr", r.RemoteAddr),
)
return false
}
}
remoteIP := request.FindRemoteIP(r)
return request.IsTrustedIP(remoteIP, config.Opts.MetricsAllowedNetworks())
}
func printErrorAndExit(format string, a ...any) {
message := fmt.Sprintf(format, a...)
slog.Error(message)
fmt.Fprintf(os.Stderr, "%v\n", message)
os.Exit(1)
}
v2-2.2.16/internal/http/server/middleware.go 0000664 0000000 0000000 00000002500 15127074645 0020706 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package server // import "miniflux.app/v2/internal/http/server"
import (
"context"
"log/slog"
"net/http"
"time"
"miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/http/request"
)
func middleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
remoteIP := request.FindRemoteIP(r)
isTrustedProxyClientIP := request.IsTrustedIP(remoteIP, config.Opts.TrustedReverseProxyNetworks())
clientIP := request.FindClientIP(r, isTrustedProxyClientIP)
ctx := r.Context()
ctx = context.WithValue(ctx, request.ClientIPContextKey, clientIP)
if isTrustedProxyClientIP && r.Header.Get("X-Forwarded-Proto") == "https" {
config.Opts.SetHTTPSValue(true)
}
t1 := time.Now()
defer func() {
slog.Debug("Incoming request",
slog.String("client_ip", clientIP),
slog.Group("request",
slog.String("method", r.Method),
slog.String("uri", r.RequestURI),
slog.String("protocol", r.Proto),
slog.Duration("execution_time", time.Since(t1)),
),
)
}()
if config.Opts.HTTPS() && config.Opts.HasHSTS() {
w.Header().Set("Strict-Transport-Security", "max-age=31536000")
}
next.ServeHTTP(w, r.WithContext(ctx))
})
}
v2-2.2.16/internal/integration/ 0000775 0000000 0000000 00000000000 15127074645 0016303 5 ustar 00root root 0000000 0000000 v2-2.2.16/internal/integration/apprise/ 0000775 0000000 0000000 00000000000 15127074645 0017746 5 ustar 00root root 0000000 0000000 v2-2.2.16/internal/integration/apprise/apprise.go 0000664 0000000 0000000 00000004151 15127074645 0021741 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package apprise
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/http"
"time"
"miniflux.app/v2/internal/model"
"miniflux.app/v2/internal/urllib"
"miniflux.app/v2/internal/version"
)
const defaultClientTimeout = 10 * time.Second
type Client struct {
servicesURL string
baseURL string
}
func NewClient(serviceURL, baseURL string) *Client {
return &Client{serviceURL, baseURL}
}
func (c *Client) SendNotification(feed *model.Feed, entries model.Entries) error {
if c.baseURL == "" || c.servicesURL == "" {
return errors.New("apprise: missing base URL or services URL")
}
for _, entry := range entries {
message := "[" + entry.Title + "]" + "(" + entry.URL + ")" + "\n\n"
apiEndpoint, err := urllib.JoinBaseURLAndPath(c.baseURL, "/notify")
if err != nil {
return fmt.Errorf(`apprise: invalid API endpoint: %v`, err)
}
requestBody, err := json.Marshal(map[string]any{
"urls": c.servicesURL,
"body": message,
"title": feed.Title,
})
if err != nil {
return fmt.Errorf("apprise: unable to encode request body: %v", err)
}
request, err := http.NewRequest(http.MethodPost, apiEndpoint, bytes.NewReader(requestBody))
if err != nil {
return fmt.Errorf("apprise: unable to create request: %v", err)
}
request.Header.Set("Content-Type", "application/json")
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
slog.Debug("Sending Apprise notification",
slog.String("apprise_url", c.baseURL),
slog.String("services_url", c.servicesURL),
slog.String("title", feed.Title),
slog.String("body", message),
slog.String("entry_url", entry.URL),
)
httpClient := &http.Client{Timeout: defaultClientTimeout}
response, err := httpClient.Do(request)
if err != nil {
return fmt.Errorf("apprise: unable to send request: %v", err)
}
response.Body.Close()
if response.StatusCode >= 400 {
return fmt.Errorf("apprise: unable to send a notification: url=%s status=%d", apiEndpoint, response.StatusCode)
}
}
return nil
}
v2-2.2.16/internal/integration/archiveorg/ 0000775 0000000 0000000 00000000000 15127074645 0020434 5 ustar 00root root 0000000 0000000 v2-2.2.16/internal/integration/archiveorg/archiveorg.go 0000664 0000000 0000000 00000002200 15127074645 0023106 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package archiveorg
import (
"log/slog"
"net/http"
"net/url"
)
// See https://docs.google.com/document/d/1Nsv52MvSjbLb2PCpHlat0gkzw0EvtSgpKHu4mk0MnrA/edit?tab=t.0
const options = "delay_wb_availability=1&if_not_archived_within=15d"
type Client struct{}
func NewClient() *Client {
return &Client{}
}
func (c *Client) SendURL(entryURL, title string) {
// We're using a goroutine here as submissions to archive.org might take a long time
// and trigger a timeout on miniflux' side.
go func(entryURL string) {
res, err := http.Get("https://web.archive.org/save/" + url.QueryEscape(entryURL) + "?" + options)
if err != nil {
slog.Error("archiveorg: unable to send request: %v",
slog.Any("err", err),
slog.String("title", title),
slog.String("url", entryURL),
)
return
}
if res.StatusCode > 299 {
slog.Error("archiveorg: failed with status code",
slog.String("title", title),
slog.String("url", entryURL),
slog.Int("code", res.StatusCode),
)
}
res.Body.Close()
}(entryURL)
}
v2-2.2.16/internal/integration/betula/ 0000775 0000000 0000000 00000000000 15127074645 0017557 5 ustar 00root root 0000000 0000000 v2-2.2.16/internal/integration/betula/betula.go 0000664 0000000 0000000 00000003161 15127074645 0021363 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package betula // import "miniflux.app/v2/internal/integration/betula"
import (
"fmt"
"net/http"
"net/url"
"strings"
"time"
"miniflux.app/v2/internal/urllib"
"miniflux.app/v2/internal/version"
)
const defaultClientTimeout = 10 * time.Second
type Client struct {
url string
token string
}
func NewClient(url, token string) *Client {
return &Client{url: url, token: token}
}
func (c *Client) CreateBookmark(entryURL, entryTitle string, tags []string) error {
apiEndpoint, err := urllib.JoinBaseURLAndPath(c.url, "/save-link")
if err != nil {
return fmt.Errorf("betula: unable to generate save-link endpoint: %v", err)
}
values := url.Values{}
values.Add("url", entryURL)
values.Add("title", entryTitle)
values.Add("tags", strings.Join(tags, ","))
request, err := http.NewRequest(http.MethodPost, apiEndpoint+"?"+values.Encode(), nil)
if err != nil {
return fmt.Errorf("betula: unable to create request: %v", err)
}
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
request.AddCookie(&http.Cookie{Name: "betula-token", Value: c.token})
httpClient := &http.Client{Timeout: defaultClientTimeout}
response, err := httpClient.Do(request)
if err != nil {
return fmt.Errorf("betula: unable to send request: %v", err)
}
defer response.Body.Close()
if response.StatusCode >= 400 {
return fmt.Errorf("betula: unable to create bookmark: url=%s status=%d", apiEndpoint, response.StatusCode)
}
return nil
}
v2-2.2.16/internal/integration/cubox/ 0000775 0000000 0000000 00000000000 15127074645 0017423 5 ustar 00root root 0000000 0000000 v2-2.2.16/internal/integration/cubox/cubox.go 0000664 0000000 0000000 00000003131 15127074645 0021070 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
// Cubox API documentation: https://help.cubox.cc/save/api/
package cubox // import "miniflux.app/v2/internal/integration/cubox"
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"time"
"miniflux.app/v2/internal/version"
)
const defaultClientTimeout = 10 * time.Second
type Client struct {
apiLink string
}
func NewClient(apiLink string) *Client {
return &Client{apiLink: apiLink}
}
func (c *Client) SaveLink(entryURL string) error {
if c.apiLink == "" {
return errors.New("cubox: missing API link")
}
requestBody, err := json.Marshal(&card{
Type: "url",
Content: entryURL,
})
if err != nil {
return fmt.Errorf("cubox: unable to encode request body: %w", err)
}
ctx, cancel := context.WithTimeout(context.Background(), defaultClientTimeout)
defer cancel()
request, err := http.NewRequestWithContext(ctx, http.MethodPost, c.apiLink, bytes.NewReader(requestBody))
if err != nil {
return fmt.Errorf("cubox: unable to create request: %w", err)
}
request.Header.Set("Content-Type", "application/json")
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
response, err := http.DefaultClient.Do(request)
if err != nil {
return fmt.Errorf("cubox: unable to send request: %w", err)
}
defer response.Body.Close()
if response.StatusCode != 200 {
return fmt.Errorf("cubox: unable to save link: status=%d", response.StatusCode)
}
return nil
}
type card struct {
Type string `json:"type"`
Content string `json:"content"`
}
v2-2.2.16/internal/integration/discord/ 0000775 0000000 0000000 00000000000 15127074645 0017732 5 ustar 00root root 0000000 0000000 v2-2.2.16/internal/integration/discord/discord.go 0000664 0000000 0000000 00000005221 15127074645 0021710 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
// Discord Webhooks documentation: https://discord.com/developers/docs/resources/webhook
package discord // import "miniflux.app/v2/internal/integration/discord"
import (
"bytes"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"time"
"miniflux.app/v2/internal/model"
"miniflux.app/v2/internal/urllib"
"miniflux.app/v2/internal/version"
)
const defaultClientTimeout = 10 * time.Second
const discordMsgColor = 5793266
type Client struct {
webhookURL string
}
func NewClient(webhookURL string) *Client {
return &Client{webhookURL: webhookURL}
}
func (c *Client) SendDiscordMsg(feed *model.Feed, entries model.Entries) error {
for _, entry := range entries {
requestBody, err := json.Marshal(&discordMessage{
Embeds: []discordEmbed{
{
Title: "RSS feed update from Miniflux",
Color: discordMsgColor,
Fields: []discordFields{
{
Name: "Updated feed",
Value: feed.Title,
},
{
Name: "Article link",
Value: "[" + entry.Title + "]" + "(" + entry.URL + ")",
},
{
Name: "Author",
Value: entry.Author,
Inline: true,
},
{
Name: "Source website",
Value: urllib.RootURL(feed.SiteURL),
Inline: true,
},
},
},
},
})
if err != nil {
return fmt.Errorf("discord: unable to encode request body: %v", err)
}
request, err := http.NewRequest(http.MethodPost, c.webhookURL, bytes.NewReader(requestBody))
if err != nil {
return fmt.Errorf("discord: unable to create request: %v", err)
}
request.Header.Set("Content-Type", "application/json")
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
slog.Debug("Sending Discord notification",
slog.String("webhookURL", c.webhookURL),
slog.String("title", feed.Title),
slog.String("entry_url", entry.URL),
)
httpClient := &http.Client{Timeout: defaultClientTimeout}
response, err := httpClient.Do(request)
if err != nil {
return fmt.Errorf("discord: unable to send request: %v", err)
}
response.Body.Close()
if response.StatusCode >= 400 {
return fmt.Errorf("discord: unable to send a notification: url=%s status=%d", c.webhookURL, response.StatusCode)
}
}
return nil
}
type discordFields struct {
Name string `json:"name"`
Value string `json:"value"`
Inline bool `json:"inline,omitempty"`
}
type discordEmbed struct {
Title string `json:"title"`
Color int `json:"color"`
Fields []discordFields `json:"fields"`
}
type discordMessage struct {
Embeds []discordEmbed `json:"embeds"`
}
v2-2.2.16/internal/integration/espial/ 0000775 0000000 0000000 00000000000 15127074645 0017560 5 ustar 00root root 0000000 0000000 v2-2.2.16/internal/integration/espial/espial.go 0000664 0000000 0000000 00000004134 15127074645 0021366 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package espial // import "miniflux.app/v2/internal/integration/espial"
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"net/http"
"time"
"miniflux.app/v2/internal/urllib"
"miniflux.app/v2/internal/version"
)
const defaultClientTimeout = 10 * time.Second
type Client struct {
baseURL string
apiKey string
}
func NewClient(baseURL, apiKey string) *Client {
return &Client{baseURL: baseURL, apiKey: apiKey}
}
func (c *Client) CreateLink(entryURL, entryTitle, espialTags string) error {
if c.baseURL == "" || c.apiKey == "" {
return errors.New("espial: missing base URL or API key")
}
apiEndpoint, err := urllib.JoinBaseURLAndPath(c.baseURL, "/api/add")
if err != nil {
return fmt.Errorf("espial: invalid API endpoint: %v", err)
}
requestBody, err := json.Marshal(&espialDocument{
Title: entryTitle,
Url: entryURL,
ToRead: true,
Tags: espialTags,
})
if err != nil {
return fmt.Errorf("espial: unable to encode request body: %v", err)
}
request, err := http.NewRequest(http.MethodPost, apiEndpoint, bytes.NewReader(requestBody))
if err != nil {
return fmt.Errorf("espial: 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", "ApiKey "+c.apiKey)
httpClient := &http.Client{Timeout: defaultClientTimeout}
response, err := httpClient.Do(request)
if err != nil {
return fmt.Errorf("espial: unable to send request: %v", err)
}
defer response.Body.Close()
if response.StatusCode != http.StatusCreated {
responseBody := new(bytes.Buffer)
responseBody.ReadFrom(response.Body)
return fmt.Errorf("espial: unable to create link: url=%s status=%d body=%s", apiEndpoint, response.StatusCode, responseBody.String())
}
return nil
}
type espialDocument struct {
Title string `json:"title,omitempty"`
Url string `json:"url,omitempty"`
ToRead bool `json:"toread,omitempty"`
Tags string `json:"tags,omitempty"`
}
v2-2.2.16/internal/integration/instapaper/ 0000775 0000000 0000000 00000000000 15127074645 0020451 5 ustar 00root root 0000000 0000000 v2-2.2.16/internal/integration/instapaper/instapaper.go 0000664 0000000 0000000 00000003044 15127074645 0023147 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package instapaper // import "miniflux.app/v2/internal/integration/instapaper"
import (
"errors"
"fmt"
"net/http"
"net/url"
"time"
"miniflux.app/v2/internal/version"
)
const defaultClientTimeout = 10 * time.Second
type Client struct {
username string
password string
}
func NewClient(username, password string) *Client {
return &Client{username: username, password: password}
}
func (c *Client) AddURL(entryURL, entryTitle string) error {
if c.username == "" || c.password == "" {
return errors.New("instapaper: missing username or password")
}
values := url.Values{}
values.Add("url", entryURL)
values.Add("title", entryTitle)
apiEndpoint := "https://www.instapaper.com/api/add?" + values.Encode()
request, err := http.NewRequest(http.MethodGet, apiEndpoint, nil)
if err != nil {
return fmt.Errorf("instapaper: unable to create request: %v", err)
}
request.SetBasicAuth(c.username, c.password)
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
httpClient := &http.Client{Timeout: defaultClientTimeout}
response, err := httpClient.Do(request)
if err != nil {
return fmt.Errorf("instapaper: unable to send request: %v", err)
}
defer response.Body.Close()
if response.StatusCode != http.StatusCreated {
return fmt.Errorf("instapaper: unable to add URL: url=%s status=%d", apiEndpoint, response.StatusCode)
}
return nil
}
v2-2.2.16/internal/integration/integration.go 0000664 0000000 0000000 00000052466 15127074645 0021172 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package integration // import "miniflux.app/v2/internal/integration"
import (
"log/slog"
"miniflux.app/v2/internal/integration/apprise"
"miniflux.app/v2/internal/integration/archiveorg"
"miniflux.app/v2/internal/integration/betula"
"miniflux.app/v2/internal/integration/cubox"
"miniflux.app/v2/internal/integration/discord"
"miniflux.app/v2/internal/integration/espial"
"miniflux.app/v2/internal/integration/instapaper"
"miniflux.app/v2/internal/integration/karakeep"
"miniflux.app/v2/internal/integration/linkace"
"miniflux.app/v2/internal/integration/linkding"
"miniflux.app/v2/internal/integration/linktaco"
"miniflux.app/v2/internal/integration/linkwarden"
"miniflux.app/v2/internal/integration/matrixbot"
"miniflux.app/v2/internal/integration/notion"
"miniflux.app/v2/internal/integration/ntfy"
"miniflux.app/v2/internal/integration/nunuxkeeper"
"miniflux.app/v2/internal/integration/omnivore"
"miniflux.app/v2/internal/integration/pinboard"
"miniflux.app/v2/internal/integration/pushover"
"miniflux.app/v2/internal/integration/raindrop"
"miniflux.app/v2/internal/integration/readeck"
"miniflux.app/v2/internal/integration/readwise"
"miniflux.app/v2/internal/integration/shaarli"
"miniflux.app/v2/internal/integration/shiori"
"miniflux.app/v2/internal/integration/slack"
"miniflux.app/v2/internal/integration/telegrambot"
"miniflux.app/v2/internal/integration/wallabag"
"miniflux.app/v2/internal/integration/webhook"
"miniflux.app/v2/internal/model"
)
// SendEntry sends the entry to third-party providers when the user click on "Save".
func SendEntry(entry *model.Entry, userIntegrations *model.Integration) {
if userIntegrations.BetulaEnabled {
slog.Debug("Sending entry to Betula",
slog.Int64("user_id", userIntegrations.UserID),
slog.Int64("entry_id", entry.ID),
slog.String("entry_url", entry.URL),
)
client := betula.NewClient(userIntegrations.BetulaURL, userIntegrations.BetulaToken)
err := client.CreateBookmark(
entry.URL,
entry.Title,
entry.Tags,
)
if err != nil {
slog.Error("Unable to send entry to Betula",
slog.Int64("user_id", userIntegrations.UserID),
slog.Int64("entry_id", entry.ID),
slog.String("entry_url", entry.URL),
slog.Any("error", err),
)
}
}
if userIntegrations.PinboardEnabled {
slog.Debug("Sending entry to Pinboard",
slog.Int64("user_id", userIntegrations.UserID),
slog.Int64("entry_id", entry.ID),
slog.String("entry_url", entry.URL),
)
client := pinboard.NewClient(userIntegrations.PinboardToken)
err := client.CreateBookmark(
entry.URL,
entry.Title,
userIntegrations.PinboardTags,
userIntegrations.PinboardMarkAsUnread,
)
if err != nil {
slog.Error("Unable to send entry to Pinboard",
slog.Int64("user_id", userIntegrations.UserID),
slog.Int64("entry_id", entry.ID),
slog.String("entry_url", entry.URL),
slog.Any("error", err),
)
}
}
if userIntegrations.InstapaperEnabled {
slog.Debug("Sending entry to Instapaper",
slog.Int64("user_id", userIntegrations.UserID),
slog.Int64("entry_id", entry.ID),
slog.String("entry_url", entry.URL),
)
client := instapaper.NewClient(userIntegrations.InstapaperUsername, userIntegrations.InstapaperPassword)
if err := client.AddURL(entry.URL, entry.Title); err != nil {
slog.Error("Unable to send entry to Instapaper",
slog.Int64("user_id", userIntegrations.UserID),
slog.Int64("entry_id", entry.ID),
slog.String("entry_url", entry.URL),
slog.Any("error", err),
)
}
}
if userIntegrations.WallabagEnabled {
slog.Debug("Sending entry to Wallabag",
slog.Int64("user_id", userIntegrations.UserID),
slog.String("user_tags", userIntegrations.WallabagTags),
slog.Int64("entry_id", entry.ID),
slog.String("entry_url", entry.URL),
)
client := wallabag.NewClient(
userIntegrations.WallabagURL,
userIntegrations.WallabagClientID,
userIntegrations.WallabagClientSecret,
userIntegrations.WallabagUsername,
userIntegrations.WallabagPassword,
userIntegrations.WallabagTags,
userIntegrations.WallabagOnlyURL,
)
if err := client.CreateEntry(entry.URL, entry.Title, entry.Content); err != nil {
slog.Error("Unable to send entry to Wallabag",
slog.Int64("user_id", userIntegrations.UserID),
slog.String("user_tags", userIntegrations.WallabagTags),
slog.Int64("entry_id", entry.ID),
slog.String("entry_url", entry.URL),
slog.Any("error", err),
)
}
}
if userIntegrations.NotionEnabled {
slog.Debug("Sending entry to Notion",
slog.Int64("user_id", userIntegrations.UserID),
slog.Int64("entry_id", entry.ID),
slog.String("entry_url", entry.URL),
)
client := notion.NewClient(
userIntegrations.NotionToken,
userIntegrations.NotionPageID,
)
if err := client.UpdateDocument(entry.URL, entry.Title); err != nil {
slog.Error("Unable to send entry to Notion",
slog.Int64("user_id", userIntegrations.UserID),
slog.Int64("entry_id", entry.ID),
slog.String("entry_url", entry.URL),
slog.Any("error", err),
)
}
}
if userIntegrations.NunuxKeeperEnabled {
slog.Debug("Sending entry to NunuxKeeper",
slog.Int64("user_id", userIntegrations.UserID),
slog.Int64("entry_id", entry.ID),
slog.String("entry_url", entry.URL),
)
client := nunuxkeeper.NewClient(
userIntegrations.NunuxKeeperURL,
userIntegrations.NunuxKeeperAPIKey,
)
if err := client.AddEntry(entry.URL, entry.Title, entry.Content); err != nil {
slog.Error("Unable to send entry to NunuxKeeper",
slog.Int64("user_id", userIntegrations.UserID),
slog.Int64("entry_id", entry.ID),
slog.String("entry_url", entry.URL),
slog.Any("error", err),
)
}
}
if userIntegrations.EspialEnabled {
slog.Debug("Sending entry to Espial",
slog.Int64("user_id", userIntegrations.UserID),
slog.Int64("entry_id", entry.ID),
slog.String("entry_url", entry.URL),
)
client := espial.NewClient(
userIntegrations.EspialURL,
userIntegrations.EspialAPIKey,
)
if err := client.CreateLink(entry.URL, entry.Title, userIntegrations.EspialTags); err != nil {
slog.Error("Unable to send entry to Espial",
slog.Int64("user_id", userIntegrations.UserID),
slog.Int64("entry_id", entry.ID),
slog.String("entry_url", entry.URL),
slog.Any("error", err),
)
}
}
if userIntegrations.LinkAceEnabled {
slog.Debug("Sending entry to LinkAce",
slog.Int64("user_id", userIntegrations.UserID),
slog.Int64("entry_id", entry.ID),
slog.String("entry_url", entry.URL),
)
client := linkace.NewClient(
userIntegrations.LinkAceURL,
userIntegrations.LinkAceAPIKey,
userIntegrations.LinkAceTags,
userIntegrations.LinkAcePrivate,
userIntegrations.LinkAceCheckDisabled,
)
if err := client.AddURL(entry.URL, entry.Title); err != nil {
slog.Error("Unable to send entry to LinkAce",
slog.Int64("user_id", userIntegrations.UserID),
slog.Int64("entry_id", entry.ID),
slog.String("entry_url", entry.URL),
slog.Any("error", err),
)
}
}
if userIntegrations.LinkdingEnabled {
slog.Debug("Sending entry to Linkding",
slog.Int64("user_id", userIntegrations.UserID),
slog.Int64("entry_id", entry.ID),
slog.String("entry_url", entry.URL),
)
client := linkding.NewClient(
userIntegrations.LinkdingURL,
userIntegrations.LinkdingAPIKey,
userIntegrations.LinkdingTags,
userIntegrations.LinkdingMarkAsUnread,
)
if err := client.CreateBookmark(entry.URL, entry.Title); err != nil {
slog.Error("Unable to send entry to Linkding",
slog.Int64("user_id", userIntegrations.UserID),
slog.Int64("entry_id", entry.ID),
slog.String("entry_url", entry.URL),
slog.Any("error", err),
)
}
}
if userIntegrations.LinktacoEnabled {
slog.Debug("Sending entry to LinkTaco",
slog.Int64("user_id", userIntegrations.UserID),
slog.Int64("entry_id", entry.ID),
slog.String("entry_url", entry.URL),
)
client := linktaco.NewClient(
userIntegrations.LinktacoAPIToken,
userIntegrations.LinktacoOrgSlug,
userIntegrations.LinktacoTags,
userIntegrations.LinktacoVisibility,
)
if err := client.CreateBookmark(entry.URL, entry.Title, entry.Content); err != nil {
slog.Error("Unable to send entry to LinkTaco",
slog.Int64("user_id", userIntegrations.UserID),
slog.Int64("entry_id", entry.ID),
slog.String("entry_url", entry.URL),
slog.Any("error", err),
)
}
}
if userIntegrations.LinkwardenEnabled {
attrs := []any{
slog.Int64("user_id", userIntegrations.UserID),
slog.Int64("entry_id", entry.ID),
slog.String("entry_url", entry.URL),
}
if userIntegrations.LinkwardenCollectionID != nil {
attrs = append(attrs, slog.Int64("collection_id", *userIntegrations.LinkwardenCollectionID))
}
slog.Debug("Sending entry to linkwarden", attrs...)
client := linkwarden.NewClient(
userIntegrations.LinkwardenURL,
userIntegrations.LinkwardenAPIKey,
userIntegrations.LinkwardenCollectionID,
)
if err := client.CreateBookmark(entry.URL, entry.Title); err != nil {
attrs = append(attrs, slog.Any("error", err))
slog.Error("Unable to send entry to Linkwarden", attrs...)
}
}
if userIntegrations.ReadeckEnabled {
slog.Debug("Sending entry to Readeck",
slog.Int64("user_id", userIntegrations.UserID),
slog.Int64("entry_id", entry.ID),
slog.String("entry_url", entry.URL),
)
client := readeck.NewClient(
userIntegrations.ReadeckURL,
userIntegrations.ReadeckAPIKey,
userIntegrations.ReadeckLabels,
userIntegrations.ReadeckOnlyURL,
)
if err := client.CreateBookmark(entry.URL, entry.Title, entry.Content); err != nil {
slog.Error("Unable to send entry to Readeck",
slog.Int64("user_id", userIntegrations.UserID),
slog.Int64("entry_id", entry.ID),
slog.String("entry_url", entry.URL),
slog.Any("error", err),
)
}
}
if userIntegrations.ReadwiseEnabled {
slog.Debug("Sending entry to Readwise",
slog.Int64("user_id", userIntegrations.UserID),
slog.Int64("entry_id", entry.ID),
slog.String("entry_url", entry.URL),
)
client := readwise.NewClient(
userIntegrations.ReadwiseAPIKey,
)
if err := client.CreateDocument(entry.URL); err != nil {
slog.Error("Unable to send entry to Readwise",
slog.Int64("user_id", userIntegrations.UserID),
slog.Int64("entry_id", entry.ID),
slog.String("entry_url", entry.URL),
slog.Any("error", err),
)
}
}
if userIntegrations.CuboxEnabled {
slog.Debug("Sending entry to Cubox",
slog.Int64("user_id", userIntegrations.UserID),
slog.Int64("entry_id", entry.ID),
slog.String("entry_url", entry.URL),
)
client := cubox.NewClient(userIntegrations.CuboxAPILink)
if err := client.SaveLink(entry.URL); err != nil {
slog.Error("Unable to send entry to Cubox",
slog.Int64("user_id", userIntegrations.UserID),
slog.Int64("entry_id", entry.ID),
slog.String("entry_url", entry.URL),
slog.Any("error", err),
)
}
}
if userIntegrations.ShioriEnabled {
slog.Debug("Sending entry to Shiori",
slog.Int64("user_id", userIntegrations.UserID),
slog.Int64("entry_id", entry.ID),
slog.String("entry_url", entry.URL),
)
client := shiori.NewClient(
userIntegrations.ShioriURL,
userIntegrations.ShioriUsername,
userIntegrations.ShioriPassword,
)
if err := client.CreateBookmark(entry.URL, entry.Title); err != nil {
slog.Error("Unable to send entry to Shiori",
slog.Int64("user_id", userIntegrations.UserID),
slog.Int64("entry_id", entry.ID),
slog.String("entry_url", entry.URL),
slog.Any("error", err),
)
}
}
if userIntegrations.ShaarliEnabled {
slog.Debug("Sending entry to Shaarli",
slog.Int64("user_id", userIntegrations.UserID),
slog.Int64("entry_id", entry.ID),
slog.String("entry_url", entry.URL),
)
client := shaarli.NewClient(
userIntegrations.ShaarliURL,
userIntegrations.ShaarliAPISecret,
)
if err := client.CreateLink(entry.URL, entry.Title); err != nil {
slog.Error("Unable to send entry to Shaarli",
slog.Int64("user_id", userIntegrations.UserID),
slog.Int64("entry_id", entry.ID),
slog.String("entry_url", entry.URL),
slog.Any("error", err),
)
}
}
if userIntegrations.ArchiveorgEnabled {
slog.Debug("Sending entry to archive.org",
slog.Int64("user_id", userIntegrations.UserID),
slog.Int64("entry_id", entry.ID),
slog.String("entry_url", entry.URL),
)
archiveorg.NewClient().SendURL(entry.URL, entry.Title)
}
if userIntegrations.WebhookEnabled {
var webhookURL string
if entry.Feed != nil && entry.Feed.WebhookURL != "" {
webhookURL = entry.Feed.WebhookURL
} else {
webhookURL = userIntegrations.WebhookURL
}
slog.Debug("Sending entry to Webhook",
slog.Int64("user_id", userIntegrations.UserID),
slog.Int64("entry_id", entry.ID),
slog.String("entry_url", entry.URL),
slog.String("webhook_url", webhookURL),
)
webhookClient := webhook.NewClient(webhookURL, userIntegrations.WebhookSecret)
if err := webhookClient.SendSaveEntryWebhookEvent(entry); err != nil {
slog.Error("Unable to send entry to Webhook",
slog.Int64("user_id", userIntegrations.UserID),
slog.Int64("entry_id", entry.ID),
slog.String("entry_url", entry.URL),
slog.String("webhook_url", webhookURL),
slog.Any("error", err),
)
}
}
if userIntegrations.OmnivoreEnabled {
slog.Debug("Sending entry to Omnivore",
slog.Int64("user_id", userIntegrations.UserID),
slog.Int64("entry_id", entry.ID),
slog.String("entry_url", entry.URL),
)
client := omnivore.NewClient(userIntegrations.OmnivoreAPIKey, userIntegrations.OmnivoreURL)
if err := client.SaveUrl(entry.URL); err != nil {
slog.Error("Unable to send entry to Omnivore",
slog.Int64("user_id", userIntegrations.UserID),
slog.Int64("entry_id", entry.ID),
slog.String("entry_url", entry.URL),
slog.Any("error", err),
)
}
}
if userIntegrations.KarakeepEnabled {
slog.Debug("Sending entry to Karakeep",
slog.Int64("user_id", userIntegrations.UserID),
slog.String("user_tags", userIntegrations.KarakeepTags),
slog.Int64("entry_id", entry.ID),
slog.String("entry_url", entry.URL),
)
client := karakeep.NewClient(
userIntegrations.KarakeepAPIKey,
userIntegrations.KarakeepURL,
userIntegrations.KarakeepTags,
)
if err := client.SaveURL(entry.URL); err != nil {
slog.Error("Unable to send entry to Karakeep",
slog.Int64("user_id", userIntegrations.UserID),
slog.String("user_tags", userIntegrations.KarakeepTags),
slog.Int64("entry_id", entry.ID),
slog.String("entry_url", entry.URL),
slog.Any("error", err),
)
}
}
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.
func PushEntries(feed *model.Feed, entries model.Entries, userIntegrations *model.Integration) {
if userIntegrations.MatrixBotEnabled {
slog.Debug("Sending new entries to Matrix",
slog.Int64("user_id", userIntegrations.UserID),
slog.Int("nb_entries", len(entries)),
slog.Int64("feed_id", feed.ID),
)
err := matrixbot.PushEntries(
feed,
entries,
userIntegrations.MatrixBotURL,
userIntegrations.MatrixBotUser,
userIntegrations.MatrixBotPassword,
userIntegrations.MatrixBotChatID,
)
if err != nil {
slog.Error("Unable to send new entries to Matrix",
slog.Int64("user_id", userIntegrations.UserID),
slog.Int("nb_entries", len(entries)),
slog.Int64("feed_id", feed.ID),
slog.Any("error", err),
)
}
}
if userIntegrations.WebhookEnabled {
var webhookURL string
if feed.WebhookURL != "" {
webhookURL = feed.WebhookURL
} else {
webhookURL = userIntegrations.WebhookURL
}
slog.Debug("Sending new entries to Webhook",
slog.Int64("user_id", userIntegrations.UserID),
slog.Int("nb_entries", len(entries)),
slog.Int64("feed_id", feed.ID),
slog.String("webhook_url", webhookURL),
)
webhookClient := webhook.NewClient(webhookURL, userIntegrations.WebhookSecret)
if err := webhookClient.SendNewEntriesWebhookEvent(feed, entries); err != nil {
slog.Debug("Unable to send new entries to Webhook",
slog.Int64("user_id", userIntegrations.UserID),
slog.Int("nb_entries", len(entries)),
slog.Int64("feed_id", feed.ID),
slog.String("webhook_url", webhookURL),
slog.Any("error", err),
)
}
}
if userIntegrations.NtfyEnabled && feed.NtfyEnabled {
ntfyTopic := feed.NtfyTopic
if ntfyTopic == "" {
ntfyTopic = userIntegrations.NtfyTopic
}
slog.Debug("Sending new entries to Ntfy",
slog.Int64("user_id", userIntegrations.UserID),
slog.Int("nb_entries", len(entries)),
slog.Int64("feed_id", feed.ID),
slog.String("topic", ntfyTopic),
)
client := ntfy.NewClient(
userIntegrations.NtfyURL,
ntfyTopic,
userIntegrations.NtfyAPIToken,
userIntegrations.NtfyUsername,
userIntegrations.NtfyPassword,
userIntegrations.NtfyIconURL,
userIntegrations.NtfyInternalLinks,
feed.NtfyPriority,
)
if err := client.SendMessages(feed, entries); err != nil {
slog.Warn("Unable to send new entries to Ntfy", slog.Any("error", err))
}
}
if userIntegrations.AppriseEnabled {
slog.Debug("Sending new entries to Apprise",
slog.Int64("user_id", userIntegrations.UserID),
slog.Int("nb_entries", len(entries)),
slog.Int64("feed_id", feed.ID),
)
appriseServiceURLs := userIntegrations.AppriseServicesURL
if feed.AppriseServiceURLs != "" {
appriseServiceURLs = feed.AppriseServiceURLs
}
client := apprise.NewClient(
appriseServiceURLs,
userIntegrations.AppriseURL,
)
if err := client.SendNotification(feed, entries); err != nil {
slog.Warn("Unable to send new entries to Apprise", slog.Any("error", err))
}
}
if userIntegrations.DiscordEnabled {
slog.Debug("Sending new entries to Discord",
slog.Int64("user_id", userIntegrations.UserID),
slog.Int("nb_entries", len(entries)),
slog.Int64("feed_id", feed.ID),
)
client := discord.NewClient(
userIntegrations.DiscordWebhookLink,
)
if err := client.SendDiscordMsg(feed, entries); err != nil {
slog.Warn("Unable to send new entries to Discord", slog.Any("error", err))
}
}
if userIntegrations.SlackEnabled {
slog.Debug("Sending new entries to Slack",
slog.Int64("user_id", userIntegrations.UserID),
slog.Int("nb_entries", len(entries)),
slog.Int64("feed_id", feed.ID),
)
client := slack.NewClient(
userIntegrations.SlackWebhookLink,
)
if err := client.SendSlackMsg(feed, entries); err != nil {
slog.Warn("Unable to send new entries to Slack", slog.Any("error", err))
}
}
if userIntegrations.PushoverEnabled && feed.PushoverEnabled {
slog.Debug("Sending new entries to Pushover",
slog.Int64("user_id", userIntegrations.UserID),
slog.Int("nb_entries", len(entries)),
slog.Int64("feed_id", feed.ID),
)
client := pushover.New(
userIntegrations.PushoverUser,
userIntegrations.PushoverToken,
feed.PushoverPriority,
userIntegrations.PushoverDevice,
userIntegrations.PushoverPrefix,
)
if err := client.SendMessages(feed, entries); err != nil {
slog.Warn("Unable to send new entries to Pushover", slog.Any("error", err))
}
}
// Integrations that only support sending individual entries
if userIntegrations.TelegramBotEnabled {
for _, entry := range entries {
if userIntegrations.TelegramBotEnabled {
slog.Debug("Sending a new entry to Telegram",
slog.Int64("user_id", userIntegrations.UserID),
slog.Int64("entry_id", entry.ID),
slog.String("entry_url", entry.URL),
)
if err := telegrambot.PushEntry(
feed,
entry,
userIntegrations.TelegramBotToken,
userIntegrations.TelegramBotChatID,
userIntegrations.TelegramBotTopicID,
userIntegrations.TelegramBotDisableWebPagePreview,
userIntegrations.TelegramBotDisableNotification,
userIntegrations.TelegramBotDisableButtons,
); err != nil {
slog.Error("Unable to send entry to Telegram",
slog.Int64("user_id", userIntegrations.UserID),
slog.Int64("entry_id", entry.ID),
slog.String("entry_url", entry.URL),
slog.Any("error", err),
)
}
}
}
}
// Push each new entry to Readeck when push is enabled
if userIntegrations.ReadeckPushEnabled {
client := readeck.NewClient(
userIntegrations.ReadeckURL,
userIntegrations.ReadeckAPIKey,
userIntegrations.ReadeckLabels,
userIntegrations.ReadeckOnlyURL,
)
for _, entry := range entries {
slog.Debug("Sending a new entry to Readeck",
slog.Int64("user_id", userIntegrations.UserID),
slog.Int64("entry_id", entry.ID),
slog.String("entry_url", entry.URL),
)
if err := client.CreateBookmark(entry.URL, entry.Title, entry.Content); err != nil {
slog.Error("Unable to send entry to Readeck",
slog.Int64("user_id", userIntegrations.UserID),
slog.Int64("entry_id", entry.ID),
slog.String("entry_url", entry.URL),
slog.Any("error", err),
)
}
}
}
}
v2-2.2.16/internal/integration/integration_test.go 0000664 0000000 0000000 00000003067 15127074645 0022222 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package integration
import (
"bytes"
"log/slog"
"strings"
"testing"
"miniflux.app/v2/internal/model"
)
func TestSendEntryLogsLinkwardenCollectionID(t *testing.T) {
var buf bytes.Buffer
handler := slog.NewJSONHandler(&buf, nil)
logger := slog.New(handler)
prev := slog.Default()
slog.SetDefault(logger)
defer slog.SetDefault(prev)
entry := &model.Entry{ID: 52, URL: "https://example.org/test.html", Title: "Test"}
coll := int64(12345)
userIntegrations := &model.Integration{
UserID: 1,
LinkwardenEnabled: true,
LinkwardenCollectionID: &coll,
LinkwardenURL: "",
LinkwardenAPIKey: "",
}
SendEntry(entry, userIntegrations)
out := buf.String()
if !strings.Contains(out, `"collection_id":12345`) {
t.Fatalf("expected collection_id in logs; got: %s", out)
}
}
func TestSendEntryLogsLinkwardenWithoutCollectionID(t *testing.T) {
var buf bytes.Buffer
handler := slog.NewJSONHandler(&buf, nil)
logger := slog.New(handler)
prev := slog.Default()
slog.SetDefault(logger)
defer slog.SetDefault(prev)
entry := &model.Entry{ID: 52, URL: "https://example.org/test.html", Title: "Test"}
userIntegrations := &model.Integration{
UserID: 1,
LinkwardenEnabled: true,
LinkwardenURL: "",
LinkwardenAPIKey: "",
}
SendEntry(entry, userIntegrations)
out := buf.String()
if strings.Contains(out, "collection_id") {
t.Fatalf("did not expect collection_id in logs; got: %s", out)
}
}
v2-2.2.16/internal/integration/karakeep/ 0000775 0000000 0000000 00000000000 15127074645 0020066 5 ustar 00root root 0000000 0000000 v2-2.2.16/internal/integration/karakeep/karakeep.go 0000664 0000000 0000000 00000011007 15127074645 0022177 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package karakeep // import "miniflux.app/v2/internal/integration/karakeep"
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strings"
"time"
"miniflux.app/v2/internal/version"
)
const defaultClientTimeout = 10 * time.Second
type Client struct {
wrapped *http.Client
apiEndpoint string
apiToken string
tags string
}
type tagItem struct {
TagName string `json:"tagName"`
}
type saveURLPayload struct {
Type string `json:"type"`
URL string `json:"url"`
}
type saveURLResponse struct {
ID string `json:"id"`
}
type attachTagsPayload struct {
Tags []tagItem `json:"tags"`
}
type errorResponse struct {
Code string `json:"code"`
Error string `json:"error"`
}
func NewClient(apiToken string, apiEndpoint string, tags string) *Client {
return &Client{wrapped: &http.Client{Timeout: defaultClientTimeout}, apiEndpoint: apiEndpoint, apiToken: apiToken, tags: tags}
}
func (c *Client) attachTags(entryID string) error {
if c.tags == "" {
return nil
}
tagItems := make([]tagItem, 0)
for tag := range strings.SplitSeq(c.tags, ",") {
if trimmedTag := strings.TrimSpace(tag); trimmedTag != "" {
tagItems = append(tagItems, tagItem{TagName: trimmedTag})
}
}
if len(tagItems) == 0 {
return nil
}
tagRequestBody, err := json.Marshal(&attachTagsPayload{
Tags: tagItems,
})
if err != nil {
return fmt.Errorf("karakeep: unable to encode tag request body: %v", err)
}
tagRequest, err := http.NewRequest(http.MethodPost, fmt.Sprintf("%s/%s/tags", c.apiEndpoint, entryID), bytes.NewReader(tagRequestBody))
if err != nil {
return fmt.Errorf("karakeep: unable to create tag request: %v", err)
}
tagRequest.Header.Set("Authorization", "Bearer "+c.apiToken)
tagRequest.Header.Set("Content-Type", "application/json")
tagRequest.Header.Set("User-Agent", "Miniflux/"+version.Version)
tagResponse, err := c.wrapped.Do(tagRequest)
if err != nil {
return fmt.Errorf("karakeep: unable to send tag request: %v", err)
}
defer tagResponse.Body.Close()
if tagResponse.StatusCode != http.StatusOK && tagResponse.StatusCode != http.StatusCreated {
tagResponseBody, err := io.ReadAll(tagResponse.Body)
if err != nil {
return fmt.Errorf("karakeep: failed to parse tag response: %s", err)
}
var errResponse errorResponse
if err := json.Unmarshal(tagResponseBody, &errResponse); err != nil {
return fmt.Errorf("karakeep: unable to parse tag error response: status=%d body=%s", tagResponse.StatusCode, string(tagResponseBody))
}
return fmt.Errorf("karakeep: failed to attach tags: status=%d errorcode=%s %s", tagResponse.StatusCode, errResponse.Code, errResponse.Error)
}
return nil
}
func (c *Client) SaveURL(entryURL string) error {
requestBody, err := json.Marshal(&saveURLPayload{
Type: "link",
URL: entryURL,
})
if err != nil {
return fmt.Errorf("karakeep: unable to encode request body: %v", err)
}
req, err := http.NewRequest(http.MethodPost, c.apiEndpoint, bytes.NewReader(requestBody))
if err != nil {
return fmt.Errorf("karakeep: unable to create request: %v", err)
}
req.Header.Set("Authorization", "Bearer "+c.apiToken)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", "Miniflux/"+version.Version)
resp, err := c.wrapped.Do(req)
if err != nil {
return fmt.Errorf("karakeep: unable to send request: %v", err)
}
defer resp.Body.Close()
responseBody, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("karakeep: failed to parse response: %s", err)
}
if resp.Header.Get("Content-Type") != "application/json" {
return fmt.Errorf("karakeep: unexpected content type response: %s", resp.Header.Get("Content-Type"))
}
if resp.StatusCode != http.StatusCreated {
var errResponse errorResponse
if err := json.Unmarshal(responseBody, &errResponse); err != nil {
return fmt.Errorf("karakeep: unable to parse error response: status=%d body=%s", resp.StatusCode, string(responseBody))
}
return fmt.Errorf("karakeep: failed to save URL: status=%d errorcode=%s %s", resp.StatusCode, errResponse.Code, errResponse.Error)
}
var response saveURLResponse
if err := json.Unmarshal(responseBody, &response); err != nil {
return fmt.Errorf("karakeep: unable to parse response: %v", err)
}
if response.ID == "" {
return errors.New("karakeep: unable to get ID from response")
}
if err := c.attachTags(response.ID); err != nil {
return fmt.Errorf("karakeep: unable to attach tags: %v", err)
}
return nil
}
v2-2.2.16/internal/integration/linkace/ 0000775 0000000 0000000 00000000000 15127074645 0017711 5 ustar 00root root 0000000 0000000 v2-2.2.16/internal/integration/linkace/linkace.go 0000664 0000000 0000000 00000004714 15127074645 0021654 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package linkace // import "miniflux.app/v2/internal/integration/linkace"
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
"time"
"miniflux.app/v2/internal/urllib"
"miniflux.app/v2/internal/version"
)
const defaultClientTimeout = 10 * time.Second
type Client struct {
baseURL string
apiKey string
tags string
private bool
checkDisabled bool
}
func NewClient(baseURL, apiKey, tags string, private bool, checkDisabled bool) *Client {
return &Client{baseURL: baseURL, apiKey: apiKey, tags: tags, private: private, checkDisabled: checkDisabled}
}
func (c *Client) AddURL(entryURL, entryTitle string) error {
if c.baseURL == "" || c.apiKey == "" {
return errors.New("linkace: missing base URL or API key")
}
tagsSplitFn := func(c rune) bool {
return c == ',' || c == ' '
}
apiEndpoint, err := urllib.JoinBaseURLAndPath(c.baseURL, "/api/v2/links")
if err != nil {
return fmt.Errorf("linkace: invalid API endpoint: %v", err)
}
requestBody, err := json.Marshal(&createItemRequest{
Url: entryURL,
Title: entryTitle,
Tags: strings.FieldsFunc(c.tags, tagsSplitFn),
Private: c.private,
CheckDisabled: c.checkDisabled,
})
if err != nil {
return fmt.Errorf("linkace: unable to encode request body: %v", err)
}
request, err := http.NewRequest(http.MethodPost, apiEndpoint, bytes.NewReader(requestBody))
if err != nil {
return fmt.Errorf("linkace: unable to create request: %v", err)
}
request.Header.Set("Content-Type", "application/json")
request.Header.Set("Accept", "application/json")
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
request.Header.Set("Authorization", "Bearer "+c.apiKey)
httpClient := &http.Client{Timeout: defaultClientTimeout}
response, err := httpClient.Do(request)
if err != nil {
return fmt.Errorf("linkace: unable to send request: %v", err)
}
defer response.Body.Close()
if response.StatusCode >= 400 {
return fmt.Errorf("linkace: unable to create item: url=%s status=%d", apiEndpoint, response.StatusCode)
}
return nil
}
type createItemRequest struct {
Title string `json:"title,omitempty"`
Url string `json:"url"`
Tags []string `json:"tags,omitempty"`
Private bool `json:"is_private,omitempty"`
CheckDisabled bool `json:"check_disabled,omitempty"`
}
v2-2.2.16/internal/integration/linkding/ 0000775 0000000 0000000 00000000000 15127074645 0020102 5 ustar 00root root 0000000 0000000 v2-2.2.16/internal/integration/linkding/linkding.go 0000664 0000000 0000000 00000004324 15127074645 0022233 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package linkding // import "miniflux.app/v2/internal/integration/linkding"
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
"time"
"miniflux.app/v2/internal/urllib"
"miniflux.app/v2/internal/version"
)
const defaultClientTimeout = 10 * time.Second
type Client struct {
baseURL string
apiKey string
tags string
unread bool
}
func NewClient(baseURL, apiKey, tags string, unread bool) *Client {
return &Client{baseURL: baseURL, apiKey: apiKey, tags: tags, unread: unread}
}
func (c *Client) CreateBookmark(entryURL, entryTitle string) error {
if c.baseURL == "" || c.apiKey == "" {
return errors.New("linkding: missing base URL or API key")
}
tagsSplitFn := func(c rune) bool {
return c == ',' || c == ' '
}
apiEndpoint, err := urllib.JoinBaseURLAndPath(c.baseURL, "/api/bookmarks/")
if err != nil {
return fmt.Errorf(`linkding: invalid API endpoint: %v`, err)
}
requestBody, err := json.Marshal(&linkdingBookmark{
Url: entryURL,
Title: entryTitle,
TagNames: strings.FieldsFunc(c.tags, tagsSplitFn),
Unread: c.unread,
})
if err != nil {
return fmt.Errorf("linkding: unable to encode request body: %v", err)
}
request, err := http.NewRequest(http.MethodPost, apiEndpoint, bytes.NewReader(requestBody))
if err != nil {
return fmt.Errorf("linkding: 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", "Token "+c.apiKey)
httpClient := &http.Client{Timeout: defaultClientTimeout}
response, err := httpClient.Do(request)
if err != nil {
return fmt.Errorf("linkding: unable to send request: %v", err)
}
defer response.Body.Close()
if response.StatusCode >= 400 {
return fmt.Errorf("linkding: unable to create bookmark: url=%s status=%d", apiEndpoint, response.StatusCode)
}
return nil
}
type linkdingBookmark struct {
Url string `json:"url,omitempty"`
Title string `json:"title,omitempty"`
TagNames []string `json:"tag_names,omitempty"`
Unread bool `json:"unread,omitempty"`
}
v2-2.2.16/internal/integration/linktaco/ 0000775 0000000 0000000 00000000000 15127074645 0020107 5 ustar 00root root 0000000 0000000 v2-2.2.16/internal/integration/linktaco/linktaco.go 0000664 0000000 0000000 00000007021 15127074645 0022242 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package linktaco // import "miniflux.app/v2/internal/integration/linktaco"
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
"time"
"miniflux.app/v2/internal/version"
)
const (
defaultClientTimeout = 10 * time.Second
defaultGraphQLURL = "https://api.linktaco.com/query"
maxTags = 10
maxDescriptionLength = 500
)
type Client struct {
graphqlURL string
apiToken string
orgSlug string
tags string
visibility string
}
func NewClient(apiToken, orgSlug, tags, visibility string) *Client {
if visibility == "" {
visibility = "PUBLIC"
}
return &Client{
graphqlURL: defaultGraphQLURL,
apiToken: apiToken,
orgSlug: orgSlug,
tags: tags,
visibility: visibility,
}
}
func (c *Client) CreateBookmark(entryURL, entryTitle, entryContent string) error {
if c.apiToken == "" || c.orgSlug == "" {
return errors.New("linktaco: missing API token or organization slug")
}
description := entryContent
if len(description) > maxDescriptionLength {
description = description[:maxDescriptionLength]
}
// tags (limit to 10)
tags := strings.FieldsFunc(c.tags, func(c rune) bool {
return c == ',' || c == ' '
})
if len(tags) > maxTags {
tags = tags[:maxTags]
}
// tagsStr is used in GraphQL query to pass comma separated tags
tagsStr := strings.Join(tags, ",")
mutation := `
mutation AddLink($input: LinkInput!) {
addLink(input: $input) {
id
url
title
}
}
`
variables := map[string]any{
"input": map[string]any{
"url": entryURL,
"title": entryTitle,
"description": description,
"orgSlug": c.orgSlug,
"visibility": c.visibility,
"unread": true,
"starred": false,
"archive": false,
"tags": tagsStr,
},
}
requestBody, err := json.Marshal(map[string]any{
"query": mutation,
"variables": variables,
})
if err != nil {
return fmt.Errorf("linktaco: unable to encode request body: %v", err)
}
request, err := http.NewRequest(http.MethodPost, c.graphqlURL, bytes.NewReader(requestBody))
if err != nil {
return fmt.Errorf("linktaco: 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.apiToken)
httpClient := &http.Client{Timeout: defaultClientTimeout}
response, err := httpClient.Do(request)
if err != nil {
return fmt.Errorf("linktaco: unable to send request: %v", err)
}
defer response.Body.Close()
if response.StatusCode >= 400 {
return fmt.Errorf("linktaco: unable to create bookmark: status=%d", response.StatusCode)
}
var graphqlResponse struct {
Data json.RawMessage `json:"data"`
Errors []json.RawMessage `json:"errors"`
}
if err := json.NewDecoder(response.Body).Decode(&graphqlResponse); err != nil {
return fmt.Errorf("linktaco: unable to decode response: %v", err)
}
if len(graphqlResponse.Errors) > 0 {
// Try to extract error message
var errorMsg string
for _, errJSON := range graphqlResponse.Errors {
var errObj struct {
Message string `json:"message"`
}
if json.Unmarshal(errJSON, &errObj) == nil && errObj.Message != "" {
errorMsg = errObj.Message
break
}
}
if errorMsg == "" {
// Fallback. Should never be reached.
errorMsg = "GraphQL error occurred (fallback message)"
}
return fmt.Errorf("linktaco: %s", errorMsg)
}
return nil
}
v2-2.2.16/internal/integration/linktaco/linktaco_test.go 0000664 0000000 0000000 00000030306 15127074645 0023303 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package linktaco
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestCreateBookmark(t *testing.T) {
tests := []struct {
name string
apiToken string
orgSlug string
tags string
visibility string
entryURL string
entryTitle string
entryContent string
serverResponse func(w http.ResponseWriter, r *http.Request)
wantErr bool
errContains string
}{
{
name: "successful bookmark creation",
apiToken: "test-token",
orgSlug: "test-org",
tags: "tag1, tag2",
visibility: "PUBLIC",
entryURL: "https://example.com",
entryTitle: "Test Article",
entryContent: "Test content",
serverResponse: func(w http.ResponseWriter, r *http.Request) {
// Verify authorization header
auth := r.Header.Get("Authorization")
if auth != "Bearer test-token" {
t.Errorf("Expected Authorization header 'Bearer test-token', got %s", auth)
}
// Verify content type
contentType := r.Header.Get("Content-Type")
if contentType != "application/json" {
t.Errorf("Expected Content-Type 'application/json', got %s", contentType)
}
// Parse and verify request
body, _ := io.ReadAll(r.Body)
var req map[string]interface{}
if err := json.Unmarshal(body, &req); err != nil {
t.Errorf("Failed to parse request body: %v", err)
}
// Verify mutation exists
if _, ok := req["query"]; !ok {
t.Error("Missing 'query' field in request")
}
// Return success response
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]interface{}{
"data": map[string]interface{}{
"addLink": map[string]interface{}{
"id": "123",
"url": "https://example.com",
"title": "Test Article",
},
},
})
},
wantErr: false,
},
{
name: "missing API token",
apiToken: "",
orgSlug: "test-org",
entryURL: "https://example.com",
entryTitle: "Test",
entryContent: "Content",
serverResponse: func(w http.ResponseWriter, r *http.Request) {
// Should not be called
t.Error("Server should not be called when API token is missing")
},
wantErr: true,
errContains: "missing API token or organization slug",
},
{
name: "missing organization slug",
apiToken: "test-token",
orgSlug: "",
entryURL: "https://example.com",
entryTitle: "Test",
entryContent: "Content",
serverResponse: func(w http.ResponseWriter, r *http.Request) {
// Should not be called
t.Error("Server should not be called when org slug is missing")
},
wantErr: true,
errContains: "missing API token or organization slug",
},
{
name: "GraphQL error response",
apiToken: "test-token",
orgSlug: "test-org",
entryURL: "https://example.com",
entryTitle: "Test",
entryContent: "Content",
serverResponse: func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]interface{}{
"errors": []interface{}{
map[string]interface{}{
"message": "Invalid input",
},
},
})
},
wantErr: true,
errContains: "Invalid input",
},
{
name: "HTTP error status",
apiToken: "test-token",
orgSlug: "test-org",
entryURL: "https://example.com",
entryTitle: "Test",
entryContent: "Content",
serverResponse: func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusUnauthorized)
},
wantErr: true,
errContains: "status=401",
},
{
name: "private visibility permission error",
apiToken: "test-token",
orgSlug: "test-org",
visibility: "PRIVATE",
entryURL: "https://example.com",
entryTitle: "Test",
entryContent: "Content",
serverResponse: func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]interface{}{
"errors": []interface{}{
map[string]interface{}{
"message": "PRIVATE visibility requires a paid LinkTaco account",
},
},
})
},
wantErr: true,
errContains: "PRIVATE visibility requires a paid LinkTaco account",
},
{
name: "content truncation",
apiToken: "test-token",
orgSlug: "test-org",
entryURL: "https://example.com",
entryTitle: "Test",
entryContent: strings.Repeat("a", 600), // Content longer than 500 chars
serverResponse: func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
var req map[string]interface{}
json.Unmarshal(body, &req)
// Check that description was truncated
variables := req["variables"].(map[string]interface{})
input := variables["input"].(map[string]interface{})
description := input["description"].(string)
if len(description) != maxDescriptionLength {
t.Errorf("Expected description length %d, got %d", maxDescriptionLength, len(description))
}
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]interface{}{
"data": map[string]interface{}{
"addLink": map[string]interface{}{"id": "123"},
},
})
},
wantErr: false,
},
{
name: "tag limiting",
apiToken: "test-token",
orgSlug: "test-org",
tags: "tag1,tag2,tag3,tag4,tag5,tag6,tag7,tag8,tag9,tag10,tag11,tag12",
entryURL: "https://example.com",
entryTitle: "Test",
entryContent: "Content",
serverResponse: func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
var req map[string]interface{}
json.Unmarshal(body, &req)
// Check that only 10 tags were sent
variables := req["variables"].(map[string]interface{})
input := variables["input"].(map[string]interface{})
tags := input["tags"].(string)
tagCount := len(strings.Split(tags, ","))
if tagCount != maxTags {
t.Errorf("Expected %d tags, got %d", maxTags, tagCount)
}
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]interface{}{
"data": map[string]interface{}{
"addLink": map[string]interface{}{"id": "123"},
},
})
},
wantErr: false,
},
{
name: "invalid JSON response",
apiToken: "test-token",
orgSlug: "test-org",
entryURL: "https://example.com",
entryTitle: "Test",
entryContent: "Content",
serverResponse: func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("invalid json"))
},
wantErr: true,
errContains: "unable to decode response",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Create test server if we have a server response function
var serverURL string
if tt.serverResponse != nil {
server := httptest.NewServer(http.HandlerFunc(tt.serverResponse))
defer server.Close()
serverURL = server.URL
}
// Create client with test server URL
client := &Client{
graphqlURL: serverURL,
apiToken: tt.apiToken,
orgSlug: tt.orgSlug,
tags: tt.tags,
visibility: tt.visibility,
}
// Call CreateBookmark
err := client.CreateBookmark(tt.entryURL, tt.entryTitle, tt.entryContent)
// Check error expectations
if tt.wantErr {
if err == nil {
t.Errorf("Expected error but got none")
} else if tt.errContains != "" && !strings.Contains(err.Error(), tt.errContains) {
t.Errorf("Expected error containing '%s', got '%s'", tt.errContains, err.Error())
}
} else {
if err != nil {
t.Errorf("Unexpected error: %v", err)
}
}
})
}
}
func TestNewClient(t *testing.T) {
tests := []struct {
name string
apiToken string
orgSlug string
tags string
visibility string
expectedVisibility string
}{
{
name: "with all parameters",
apiToken: "token",
orgSlug: "org",
tags: "tag1,tag2",
visibility: "PRIVATE",
expectedVisibility: "PRIVATE",
},
{
name: "empty visibility defaults to PUBLIC",
apiToken: "token",
orgSlug: "org",
tags: "tag1",
visibility: "",
expectedVisibility: "PUBLIC",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
client := NewClient(tt.apiToken, tt.orgSlug, tt.tags, tt.visibility)
if client.apiToken != tt.apiToken {
t.Errorf("Expected apiToken %s, got %s", tt.apiToken, client.apiToken)
}
if client.orgSlug != tt.orgSlug {
t.Errorf("Expected orgSlug %s, got %s", tt.orgSlug, client.orgSlug)
}
if client.tags != tt.tags {
t.Errorf("Expected tags %s, got %s", tt.tags, client.tags)
}
if client.visibility != tt.expectedVisibility {
t.Errorf("Expected visibility %s, got %s", tt.expectedVisibility, client.visibility)
}
if client.graphqlURL != defaultGraphQLURL {
t.Errorf("Expected graphqlURL %s, got %s", defaultGraphQLURL, client.graphqlURL)
}
})
}
}
func TestGraphQLMutation(t *testing.T) {
// Test that the GraphQL mutation is properly formatted
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
var req map[string]interface{}
if err := json.Unmarshal(body, &req); err != nil {
t.Fatalf("Failed to parse request: %v", err)
}
// Verify mutation structure
query, ok := req["query"].(string)
if !ok {
t.Fatal("Missing query field")
}
// Check that mutation contains expected parts
if !strings.Contains(query, "mutation AddLink") {
t.Error("Mutation should contain 'mutation AddLink'")
}
if !strings.Contains(query, "$input: LinkInput!") {
t.Error("Mutation should contain input parameter")
}
if !strings.Contains(query, "addLink(input: $input)") {
t.Error("Mutation should contain addLink call")
}
// Verify variables structure
variables, ok := req["variables"].(map[string]interface{})
if !ok {
t.Fatal("Missing variables field")
}
input, ok := variables["input"].(map[string]interface{})
if !ok {
t.Fatal("Missing input in variables")
}
// Check all required fields
requiredFields := []string{"url", "title", "description", "orgSlug", "visibility", "unread", "starred", "archive", "tags"}
for _, field := range requiredFields {
if _, ok := input[field]; !ok {
t.Errorf("Missing required field: %s", field)
}
}
// Return success
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]interface{}{
"data": map[string]interface{}{
"addLink": map[string]interface{}{
"id": "123",
},
},
})
}))
defer server.Close()
client := &Client{
graphqlURL: server.URL,
apiToken: "test-token",
orgSlug: "test-org",
tags: "test",
visibility: "PUBLIC",
}
err := client.CreateBookmark("https://example.com", "Test Title", "Test Content")
if err != nil {
t.Errorf("Unexpected error: %v", err)
}
}
func BenchmarkCreateBookmark(b *testing.B) {
// Create a mock server that always returns success
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]interface{}{
"data": map[string]interface{}{
"addLink": map[string]interface{}{
"id": "123",
},
},
})
}))
defer server.Close()
client := &Client{
graphqlURL: server.URL,
apiToken: "test-token",
orgSlug: "test-org",
tags: "tag1,tag2,tag3",
visibility: "PUBLIC",
}
// Run benchmark
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = client.CreateBookmark("https://example.com", "Test Title", "Test Content")
}
}
func BenchmarkTagProcessing(b *testing.B) {
// Benchmark tag splitting and limiting
tags := "tag1,tag2,tag3,tag4,tag5,tag6,tag7,tag8,tag9,tag10,tag11,tag12,tag13,tag14,tag15"
b.ResetTimer()
for i := 0; i < b.N; i++ {
tagsSplitFn := func(c rune) bool {
return c == ',' || c == ' '
}
splitTags := strings.FieldsFunc(tags, tagsSplitFn)
if len(splitTags) > maxTags {
splitTags = splitTags[:maxTags]
}
_ = strings.Join(splitTags, ",")
}
}
v2-2.2.16/internal/integration/linkwarden/ 0000775 0000000 0000000 00000000000 15127074645 0020441 5 ustar 00root root 0000000 0000000 v2-2.2.16/internal/integration/linkwarden/linkwarden.go 0000664 0000000 0000000 00000004574 15127074645 0023140 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package linkwarden // import "miniflux.app/v2/internal/integration/linkwarden"
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"time"
"miniflux.app/v2/internal/urllib"
"miniflux.app/v2/internal/version"
)
const defaultClientTimeout = 10 * time.Second
type Client struct {
baseURL string
apiKey string
collectionID *int64
}
type linkwardenCollection struct {
ID *int64 `json:"id"`
}
type linkwardenRequest struct {
URL string `json:"url"`
Name string `json:"name"`
Collection *linkwardenCollection `json:"collection,omitempty"`
}
func NewClient(baseURL, apiKey string, collectionID *int64) *Client {
return &Client{baseURL: baseURL, apiKey: apiKey, collectionID: collectionID}
}
func (c *Client) CreateBookmark(entryURL, entryTitle string) error {
if c.baseURL == "" || c.apiKey == "" {
return errors.New("linkwarden: missing base URL or API key")
}
apiEndpoint, err := urllib.JoinBaseURLAndPath(c.baseURL, "/api/v1/links")
if err != nil {
return fmt.Errorf(`linkwarden: invalid API endpoint: %v`, err)
}
payload := linkwardenRequest{
URL: entryURL,
Name: entryTitle,
}
if c.collectionID != nil {
payload.Collection = &linkwardenCollection{ID: c.collectionID}
}
requestBody, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("linkwarden: unable to encode request body: %v", err)
}
request, err := http.NewRequest(http.MethodPost, apiEndpoint, bytes.NewReader(requestBody))
if err != nil {
return fmt.Errorf("linkwarden: 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.apiKey)
httpClient := &http.Client{Timeout: defaultClientTimeout}
response, err := httpClient.Do(request)
if err != nil {
return fmt.Errorf("linkwarden: unable to send request: %v", err)
}
defer response.Body.Close()
responseBody, err := io.ReadAll(response.Body)
if err != nil {
return fmt.Errorf("linkwarden: unable to read response body: %v", err)
}
if response.StatusCode >= 400 {
return fmt.Errorf("linkwarden: unable to create link: status=%d body=%s", response.StatusCode, string(responseBody))
}
return nil
}
v2-2.2.16/internal/integration/linkwarden/linkwarden_test.go 0000664 0000000 0000000 00000023637 15127074645 0024200 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package linkwarden
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"miniflux.app/v2/internal/model"
)
func TestCreateBookmark(t *testing.T) {
tests := []struct {
name string
baseURL string
apiKey string
collectionID *int64
entryURL string
entryTitle string
serverResponse func(w http.ResponseWriter, r *http.Request, t *testing.T, collectionID *int64)
wantErr bool
errContains string
}{
{
name: "successful bookmark creation without collection",
baseURL: "",
apiKey: "test-api-key",
collectionID: nil,
entryURL: "https://example.com",
entryTitle: "Test Article",
serverResponse: func(w http.ResponseWriter, r *http.Request, t *testing.T, collectionId *int64) {
// Verify authorization header
auth := r.Header.Get("Authorization")
if auth != "Bearer test-api-key" {
t.Errorf("Expected Authorization header 'Bearer test-api-key', got %s", auth)
}
// Verify content type
contentType := r.Header.Get("Content-Type")
if contentType != "application/json" {
t.Errorf("Expected Content-Type 'application/json', got %s", contentType)
}
// Parse and verify request
body, _ := io.ReadAll(r.Body)
var req map[string]any
if err := json.Unmarshal(body, &req); err != nil {
t.Errorf("Failed to parse request body: %v", err)
}
// Verify URL
if reqURL := req["url"]; reqURL != "https://example.com" {
t.Errorf("Expected URL 'https://example.com', got %v", reqURL)
}
// Verify title/name
if reqName := req["name"]; reqName != "Test Article" {
t.Errorf("Expected name 'Test Article', got %v", reqName)
}
// Verify collection is not present when nil
if _, ok := req["collection"]; ok {
t.Error("Expected collection field to be omitted when collectionId is nil")
}
// Return success response
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]any{
"id": "123",
"url": "https://example.com",
"name": "Test Article",
})
},
wantErr: false,
},
{
name: "successful bookmark creation with collection",
baseURL: "",
apiKey: "test-api-key",
collectionID: model.OptionalNumber(int64(42)),
entryURL: "https://example.com/article",
entryTitle: "Test Article With Collection",
serverResponse: func(w http.ResponseWriter, r *http.Request, t *testing.T, collectionID *int64) {
// Verify authorization header
auth := r.Header.Get("Authorization")
if auth != "Bearer test-api-key" {
t.Errorf("Expected Authorization header 'Bearer test-api-key', got %s", auth)
}
// Parse and verify request
body, _ := io.ReadAll(r.Body)
var req map[string]any
if err := json.Unmarshal(body, &req); err != nil {
t.Errorf("Failed to parse request body: %v", err)
}
// Verify URL
if reqURL := req["url"]; reqURL != "https://example.com/article" {
t.Errorf("Expected URL 'https://example.com/article', got %v", reqURL)
}
// Verify title/name
if reqName := req["name"]; reqName != "Test Article With Collection" {
t.Errorf("Expected name 'Test Article With Collection', got %v", reqName)
}
// Verify collection is present and correct
if collection, ok := req["collection"]; ok {
collectionMap, ok := collection.(map[string]any)
if !ok {
t.Error("Expected collection to be a map")
}
if collectionID, ok := collectionMap["id"]; ok {
// JSON numbers are float64
if collectionIDFloat, ok := collectionID.(float64); !ok || int64(collectionIDFloat) != 42 {
t.Errorf("Expected collection id 42, got %v", collectionID)
}
} else {
t.Error("Expected collection to have 'id' field")
}
} else {
t.Error("Expected collection field to be present when collectionId is set")
}
// Return success response
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]any{
"id": "124",
"url": "https://example.com/article",
"name": "Test Article With Collection",
})
},
wantErr: false,
},
{
name: "missing API key",
baseURL: "",
apiKey: "",
collectionID: nil,
entryURL: "https://example.com",
entryTitle: "Test",
serverResponse: func(w http.ResponseWriter, r *http.Request, t *testing.T, collectionId *int64) {
// Should not be called
t.Error("Server should not be called when API key is missing")
},
wantErr: true,
errContains: "missing base URL or API key",
},
{
name: "server error",
baseURL: "",
apiKey: "test-api-key",
collectionID: nil,
entryURL: "https://example.com",
entryTitle: "Test",
serverResponse: func(w http.ResponseWriter, r *http.Request, t *testing.T, collectionId *int64) {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(`{"error": "Internal server error"}`))
},
wantErr: true,
errContains: "unable to create link: status=500",
},
{
name: "bad request with null collection id error",
baseURL: "",
apiKey: "test-api-key",
collectionID: nil,
entryURL: "https://example.com",
entryTitle: "Test",
serverResponse: func(w http.ResponseWriter, r *http.Request, t *testing.T, collectionId *int64) {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte(`{"response":"Error: Expected number, received null [collection, id]"}`))
},
wantErr: true,
errContains: "unable to create link: status=400",
},
{
name: "unauthorized",
baseURL: "",
apiKey: "invalid-key",
collectionID: nil,
entryURL: "https://example.com",
entryTitle: "Test",
serverResponse: func(w http.ResponseWriter, r *http.Request, t *testing.T, collectionId *int64) {
w.WriteHeader(http.StatusUnauthorized)
w.Write([]byte(`{"error": "Unauthorized"}`))
},
wantErr: true,
errContains: "unable to create link: status=401",
},
{
name: "invalid base URL",
baseURL: ":",
apiKey: "test-api-key",
collectionID: nil,
entryURL: "https://example.com",
entryTitle: "Test",
serverResponse: func(w http.ResponseWriter, r *http.Request, t *testing.T, collectionId *int64) {
// Should not be called
t.Error("Server should not be called when base URL is invalid")
},
wantErr: true,
errContains: "invalid API endpoint",
},
{
name: "missing base URL",
baseURL: "",
apiKey: "",
collectionID: nil,
entryURL: "https://example.com",
entryTitle: "Test",
serverResponse: func(w http.ResponseWriter, r *http.Request, t *testing.T, collectionId *int64) {
// Should not be called
t.Error("Server should not be called when base URL is missing")
},
wantErr: true,
errContains: "missing base URL or API key",
},
{
name: "network connection error",
baseURL: "http://localhost:1", // Invalid port that should fail to connect
apiKey: "test-api-key",
collectionID: nil,
entryURL: "https://example.com",
entryTitle: "Test",
serverResponse: func(w http.ResponseWriter, r *http.Request, t *testing.T, collectionId *int64) {
// Should not be called due to connection failure
t.Error("Server should not be called when connection fails")
},
wantErr: true,
errContains: "unable to send request",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Create test server only if we have a valid apiKey and don't have a custom baseURL for error testing
var server *httptest.Server
if tt.apiKey != "" && tt.baseURL != ":" && tt.baseURL != "http://localhost:1" {
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
tt.serverResponse(w, r, t, tt.collectionID)
}))
defer server.Close()
}
// Use test server URL if baseURL is empty and we have a server
baseURL := tt.baseURL
if baseURL == "" && server != nil {
baseURL = server.URL
}
// Create client
client := NewClient(baseURL, tt.apiKey, tt.collectionID)
// Call CreateBookmark
err := client.CreateBookmark(tt.entryURL, tt.entryTitle)
// Check error
if tt.wantErr {
if err == nil {
t.Error("Expected error, got nil")
} else if tt.errContains != "" && !strings.Contains(err.Error(), tt.errContains) {
t.Errorf("Expected error to contain '%s', got '%s'", tt.errContains, err.Error())
}
} else {
if err != nil {
t.Errorf("Expected no error, got %v", err)
}
}
})
}
}
func TestNewClient(t *testing.T) {
tests := []struct {
name string
baseURL string
apiKey string
collectionID *int64
}{
{
name: "client without collection",
baseURL: "https://linkwarden.example.com",
apiKey: "test-key",
collectionID: nil,
},
{
name: "client with collection",
baseURL: "https://linkwarden.example.com",
apiKey: "test-key",
collectionID: model.OptionalNumber(int64(123)),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
client := NewClient(tt.baseURL, tt.apiKey, tt.collectionID)
if client.baseURL != tt.baseURL {
t.Errorf("Expected baseURL %s, got %s", tt.baseURL, client.baseURL)
}
if client.apiKey != tt.apiKey {
t.Errorf("Expected apiKey %s, got %s", tt.apiKey, client.apiKey)
}
if tt.collectionID == nil {
if client.collectionID != nil {
t.Errorf("Expected collectionId to be nil, got %v", *client.collectionID)
}
} else {
if client.collectionID == nil {
t.Error("Expected collectionId to be set, got nil")
} else if *client.collectionID != *tt.collectionID {
t.Errorf("Expected collectionId %d, got %d", *tt.collectionID, *client.collectionID)
}
}
})
}
}
v2-2.2.16/internal/integration/matrixbot/ 0000775 0000000 0000000 00000000000 15127074645 0020314 5 ustar 00root root 0000000 0000000 v2-2.2.16/internal/integration/matrixbot/client.go 0000664 0000000 0000000 00000014265 15127074645 0022131 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package matrixbot // import "miniflux.app/v2/internal/integration/matrixbot"
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"net/url"
"time"
"miniflux.app/v2/internal/crypto"
"miniflux.app/v2/internal/version"
)
const defaultClientTimeout = 10 * time.Second
type Client struct {
matrixBaseURL string
}
func NewClient(matrixBaseURL string) *Client {
return &Client{matrixBaseURL: matrixBaseURL}
}
// Specs: https://spec.matrix.org/v1.8/client-server-api/#getwell-knownmatrixclient
func (c *Client) DiscoverEndpoints() (*DiscoveryEndpointResponse, error) {
endpointURL, err := url.JoinPath(c.matrixBaseURL, "/.well-known/matrix/client")
if err != nil {
return nil, fmt.Errorf("matrix: unable to join base URL and path: %w", err)
}
request, err := http.NewRequest(http.MethodGet, endpointURL, nil)
if err != nil {
return nil, fmt.Errorf("matrix: unable to create request: %v", err)
}
request.Header.Set("Accept", "application/json")
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
httpClient := &http.Client{Timeout: defaultClientTimeout}
response, err := httpClient.Do(request)
if err != nil {
return nil, fmt.Errorf("matrix: unable to send request: %v", err)
}
defer response.Body.Close()
if response.StatusCode >= 400 {
return nil, fmt.Errorf("matrix: unexpected response from %s status code is %d", endpointURL, response.StatusCode)
}
var discoveryEndpointResponse DiscoveryEndpointResponse
if err := json.NewDecoder(response.Body).Decode(&discoveryEndpointResponse); err != nil {
return nil, fmt.Errorf("matrix: unable to decode discovery response: %w", err)
}
return &discoveryEndpointResponse, nil
}
// Specs https://spec.matrix.org/v1.8/client-server-api/#post_matrixclientv3login
func (c *Client) Login(homeServerURL, matrixUsername, matrixPassword string) (*LoginResponse, error) {
endpointURL, err := url.JoinPath(homeServerURL, "/_matrix/client/v3/login")
if err != nil {
return nil, fmt.Errorf("matrix: unable to join base URL and path: %w", err)
}
loginRequest := LoginRequest{
Type: "m.login.password",
Identifier: UserIdentifier{
Type: "m.id.user",
User: matrixUsername,
},
Password: matrixPassword,
}
requestBody, err := json.Marshal(loginRequest)
if err != nil {
return nil, fmt.Errorf("matrix: unable to encode request body: %v", err)
}
request, err := http.NewRequest(http.MethodPost, endpointURL, bytes.NewReader(requestBody))
if err != nil {
return nil, fmt.Errorf("matrix: unable to create request: %v", err)
}
request.Header.Set("Content-Type", "application/json")
request.Header.Set("Accept", "application/json")
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
httpClient := &http.Client{Timeout: defaultClientTimeout}
response, err := httpClient.Do(request)
if err != nil {
return nil, fmt.Errorf("matrix: unable to send request: %v", err)
}
defer response.Body.Close()
if response.StatusCode >= 400 {
return nil, fmt.Errorf("matrix: unexpected response from %s status code is %d", endpointURL, response.StatusCode)
}
var loginResponse LoginResponse
if err := json.NewDecoder(response.Body).Decode(&loginResponse); err != nil {
return nil, fmt.Errorf("matrix: unable to decode login response: %w", err)
}
return &loginResponse, nil
}
// Specs https://spec.matrix.org/v1.8/client-server-api/#put_matrixclientv3roomsroomidsendeventtypetxnid
func (c *Client) SendFormattedTextMessage(homeServerURL, accessToken, roomID, textMessage, formattedMessage string) (*RoomEventResponse, error) {
txnID := crypto.GenerateRandomStringHex(10)
endpointURL, err := url.JoinPath(homeServerURL, "/_matrix/client/v3/rooms/", roomID, "/send/m.room.message/", txnID)
if err != nil {
return nil, fmt.Errorf("matrix: unable to join base URL and path: %w", err)
}
messageEvent := TextMessageEventRequest{
MsgType: "m.text",
Body: textMessage,
Format: "org.matrix.custom.html",
FormattedBody: formattedMessage,
}
requestBody, err := json.Marshal(messageEvent)
if err != nil {
return nil, fmt.Errorf("matrix: unable to encode request body: %v", err)
}
request, err := http.NewRequest(http.MethodPut, endpointURL, bytes.NewReader(requestBody))
if err != nil {
return nil, fmt.Errorf("matrix: unable to create request: %v", err)
}
request.Header.Set("Content-Type", "application/json")
request.Header.Set("Accept", "application/json")
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
request.Header.Set("Authorization", "Bearer "+accessToken)
httpClient := &http.Client{Timeout: defaultClientTimeout}
response, err := httpClient.Do(request)
if err != nil {
return nil, fmt.Errorf("matrix: unable to send request: %v", err)
}
defer response.Body.Close()
if response.StatusCode >= 400 {
return nil, fmt.Errorf("matrix: unexpected response from %s status code is %d", endpointURL, response.StatusCode)
}
var eventResponse RoomEventResponse
if err := json.NewDecoder(response.Body).Decode(&eventResponse); err != nil {
return nil, fmt.Errorf("matrix: unable to decode event response: %w", err)
}
return &eventResponse, nil
}
type HomeServerInformation struct {
BaseURL string `json:"base_url"`
}
type IdentityServerInformation struct {
BaseURL string `json:"base_url"`
}
type DiscoveryEndpointResponse struct {
HomeServerInformation HomeServerInformation `json:"m.homeserver"`
IdentityServerInformation IdentityServerInformation `json:"m.identity_server"`
}
type UserIdentifier struct {
Type string `json:"type"`
User string `json:"user"`
}
type LoginRequest struct {
Type string `json:"type"`
Identifier UserIdentifier `json:"identifier"`
Password string `json:"password"`
}
type LoginResponse struct {
UserID string `json:"user_id"`
AccessToken string `json:"access_token"`
DeviceID string `json:"device_id"`
HomeServer string `json:"home_server"`
}
type TextMessageEventRequest struct {
MsgType string `json:"msgtype"`
Body string `json:"body"`
Format string `json:"format"`
FormattedBody string `json:"formatted_body"`
}
type RoomEventResponse struct {
EventID string `json:"event_id"`
}
v2-2.2.16/internal/integration/matrixbot/matrixbot.go 0000664 0000000 0000000 00000002552 15127074645 0022660 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package matrixbot // import "miniflux.app/v2/internal/integration/matrixbot"
import (
"fmt"
"strings"
"miniflux.app/v2/internal/model"
)
// PushEntries pushes entries to matrix chat using integration settings provided
func PushEntries(feed *model.Feed, entries model.Entries, matrixBaseURL, matrixUsername, matrixPassword, matrixRoomID string) error {
client := NewClient(matrixBaseURL)
discovery, err := client.DiscoverEndpoints()
if err != nil {
return err
}
loginResponse, err := client.Login(discovery.HomeServerInformation.BaseURL, matrixUsername, matrixPassword)
if err != nil {
return err
}
textMessages := make([]string, 0, len(entries))
formattedTextMessages := make([]string, 0, len(entries))
for _, entry := range entries {
textMessages = append(textMessages, fmt.Sprintf(`[%s] %s - %s`, feed.Title, entry.Title, entry.URL))
formattedTextMessages = append(formattedTextMessages, fmt.Sprintf(`%s : %s `, feed.Title, entry.URL, entry.Title))
}
_, err = client.SendFormattedTextMessage(
discovery.HomeServerInformation.BaseURL,
loginResponse.AccessToken,
matrixRoomID,
strings.Join(textMessages, "\n"),
""+strings.Join(formattedTextMessages, "\n")+" ",
)
return err
}
v2-2.2.16/internal/integration/notion/ 0000775 0000000 0000000 00000000000 15127074645 0017611 5 ustar 00root root 0000000 0000000 v2-2.2.16/internal/integration/notion/notion.go 0000664 0000000 0000000 00000004100 15127074645 0021441 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package notion
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"net/http"
"time"
"miniflux.app/v2/internal/version"
)
const defaultClientTimeout = 10 * time.Second
type Client struct {
apiToken string
pageID string
}
func NewClient(apiToken, pageID string) *Client {
return &Client{apiToken, pageID}
}
func (c *Client) UpdateDocument(entryURL string, entryTitle string) error {
if c.apiToken == "" || c.pageID == "" {
return errors.New("notion: missing API token or page ID")
}
apiEndpoint := "https://api.notion.com/v1/blocks/" + c.pageID + "/children"
requestBody, err := json.Marshal(¬ionDocument{
Children: []block{
{
Object: "block",
Type: "bookmark",
Bookmark: bookmarkObject{
Caption: []any{},
URL: entryURL,
},
},
},
})
if err != nil {
return fmt.Errorf("notion: unable to encode request body: %v", err)
}
request, err := http.NewRequest(http.MethodPatch, apiEndpoint, bytes.NewReader(requestBody))
if err != nil {
return fmt.Errorf("notion: unable to create request: %v", err)
}
request.Header.Set("Content-Type", "application/json")
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
request.Header.Set("Notion-Version", "2022-06-28")
request.Header.Set("Authorization", "Bearer "+c.apiToken)
httpClient := &http.Client{Timeout: defaultClientTimeout}
response, err := httpClient.Do(request)
if err != nil {
return fmt.Errorf("notion: unable to send request: %v", err)
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
return fmt.Errorf("notion: unable to update document: url=%s status=%d", apiEndpoint, response.StatusCode)
}
return nil
}
type notionDocument struct {
Children []block `json:"children"`
}
type block struct {
Object string `json:"object"`
Type string `json:"type"`
Bookmark bookmarkObject `json:"bookmark"`
}
type bookmarkObject struct {
Caption []any `json:"caption"`
URL string `json:"url"`
}
v2-2.2.16/internal/integration/ntfy/ 0000775 0000000 0000000 00000000000 15127074645 0017263 5 ustar 00root root 0000000 0000000 v2-2.2.16/internal/integration/ntfy/ntfy.go 0000664 0000000 0000000 00000007365 15127074645 0020605 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package ntfy // import "miniflux.app/v2/internal/integration/ntfy"
import (
"bytes"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"net/url"
"time"
"miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/model"
"miniflux.app/v2/internal/version"
)
const (
defaultClientTimeout = 10 * time.Second
defaultNtfyURL = "https://ntfy.sh"
)
type Client struct {
ntfyURL, ntfyTopic, ntfyApiToken, ntfyUsername, ntfyPassword, ntfyIconURL string
ntfyInternalLinks bool
ntfyPriority int
}
func NewClient(ntfyURL, ntfyTopic, ntfyApiToken, ntfyUsername, ntfyPassword, ntfyIconURL string, ntfyInternalLinks bool, ntfyPriority int) *Client {
if ntfyURL == "" {
ntfyURL = defaultNtfyURL
}
return &Client{ntfyURL, ntfyTopic, ntfyApiToken, ntfyUsername, ntfyPassword, ntfyIconURL, ntfyInternalLinks, ntfyPriority}
}
func (c *Client) SendMessages(feed *model.Feed, entries model.Entries) error {
for _, entry := range entries {
ntfyMessage := &ntfyMessage{
Topic: c.ntfyTopic,
Message: entry.Title,
Title: feed.Title,
Priority: c.ntfyPriority,
Click: entry.URL,
}
if c.ntfyIconURL != "" {
ntfyMessage.Icon = c.ntfyIconURL
}
if c.ntfyInternalLinks {
url, err := url.Parse(config.Opts.BaseURL())
if err != nil {
slog.Error("Unable to parse base URL", slog.Any("error", err))
} else {
ntfyMessage.Click = fmt.Sprintf("%s%s%d", url, "/unread/entry/", entry.ID)
}
}
slog.Debug("Sending Ntfy message",
slog.String("url", c.ntfyURL),
slog.String("topic", c.ntfyTopic),
slog.Int("priority", ntfyMessage.Priority),
slog.String("message", ntfyMessage.Message),
slog.String("entry_url", ntfyMessage.Click),
)
if err := c.makeRequest(ntfyMessage); err != nil {
return err
}
}
return nil
}
func (c *Client) makeRequest(payload any) error {
requestBody, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("ntfy: unable to encode request body: %v", err)
}
request, err := http.NewRequest(http.MethodPost, c.ntfyURL, bytes.NewReader(requestBody))
if err != nil {
return fmt.Errorf("ntfy: unable to create request: %v", err)
}
request.Header.Set("Content-Type", "application/json")
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
// See https://docs.ntfy.sh/publish/#access-tokens
if c.ntfyApiToken != "" {
request.Header.Set("Authorization", "Bearer "+c.ntfyApiToken)
}
// See https://docs.ntfy.sh/publish/#username-password
if c.ntfyUsername != "" && c.ntfyPassword != "" {
request.SetBasicAuth(c.ntfyUsername, c.ntfyPassword)
}
httpClient := &http.Client{Timeout: defaultClientTimeout}
response, err := httpClient.Do(request)
if err != nil {
return fmt.Errorf("ntfy: unable to send request: %v", err)
}
defer response.Body.Close()
if response.StatusCode >= 400 {
return fmt.Errorf("ntfy: incorrect response status code %d for url %s", response.StatusCode, c.ntfyURL)
}
return nil
}
// See https://docs.ntfy.sh/publish/#publish-as-json
type ntfyMessage struct {
Topic string `json:"topic"`
Message string `json:"message"`
Title string `json:"title"`
Tags []string `json:"tags,omitempty"`
Priority int `json:"priority,omitempty"`
Icon string `json:"icon,omitempty"` // https://docs.ntfy.sh/publish/#icons
Click string `json:"click,omitempty"`
Actions []ntfyAction `json:"actions,omitempty"`
}
// See https://docs.ntfy.sh/publish/#action-buttons
type ntfyAction struct {
Action string `json:"action"`
Label string `json:"label"`
URL string `json:"url"`
}
v2-2.2.16/internal/integration/nunuxkeeper/ 0000775 0000000 0000000 00000000000 15127074645 0020654 5 ustar 00root root 0000000 0000000 v2-2.2.16/internal/integration/nunuxkeeper/nunuxkeeper.go 0000664 0000000 0000000 00000004120 15127074645 0023551 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package nunuxkeeper // import "miniflux.app/v2/internal/integration/nunuxkeeper"
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"net/http"
"time"
"miniflux.app/v2/internal/urllib"
"miniflux.app/v2/internal/version"
)
const defaultClientTimeout = 10 * time.Second
type Client struct {
baseURL string
apiKey string
}
func NewClient(baseURL, apiKey string) *Client {
return &Client{baseURL: baseURL, apiKey: apiKey}
}
func (c *Client) AddEntry(entryURL, entryTitle, entryContent string) error {
if c.baseURL == "" || c.apiKey == "" {
return errors.New("nunux-keeper: missing base URL or API key")
}
apiEndpoint, err := urllib.JoinBaseURLAndPath(c.baseURL, "/v2/documents")
if err != nil {
return fmt.Errorf(`nunux-keeper: invalid API endpoint: %v`, err)
}
requestBody, err := json.Marshal(&nunuxKeeperDocument{
Title: entryTitle,
Origin: entryURL,
Content: entryContent,
ContentType: "text/html",
})
if err != nil {
return fmt.Errorf("notion: unable to encode request body: %v", err)
}
request, err := http.NewRequest(http.MethodPost, apiEndpoint, bytes.NewReader(requestBody))
if err != nil {
return fmt.Errorf("nunux-keeper: unable to create request: %v", err)
}
request.SetBasicAuth("api", c.apiKey)
request.Header.Set("Content-Type", "application/json")
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
httpClient := &http.Client{Timeout: defaultClientTimeout}
response, err := httpClient.Do(request)
if err != nil {
return fmt.Errorf("nunux-keeper: unable to send request: %v", err)
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
return fmt.Errorf("nunux-keeper: unable to create document: url=%s status=%d", apiEndpoint, response.StatusCode)
}
return nil
}
type nunuxKeeperDocument struct {
Title string `json:"title,omitempty"`
Origin string `json:"origin,omitempty"`
Content string `json:"content,omitempty"`
ContentType string `json:"contentType,omitempty"`
}
v2-2.2.16/internal/integration/omnivore/ 0000775 0000000 0000000 00000000000 15127074645 0020141 5 ustar 00root root 0000000 0000000 v2-2.2.16/internal/integration/omnivore/omnivore.go 0000664 0000000 0000000 00000005605 15127074645 0022334 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package omnivore // import "miniflux.app/v2/internal/integration/omnivore"
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
"miniflux.app/v2/internal/crypto"
"miniflux.app/v2/internal/version"
)
const defaultClientTimeout = 10 * time.Second
const defaultApiEndpoint = "https://api-prod.omnivore.app/api/graphql"
var mutation = `
mutation SaveUrl($input: SaveUrlInput!) {
saveUrl(input: $input) {
... on SaveSuccess {
url
clientRequestId
}
... on SaveError {
errorCodes
message
}
}
}
`
type SaveUrlInput struct {
ClientRequestId string `json:"clientRequestId"`
Source string `json:"source"`
Url string `json:"url"`
}
type errorResponse struct {
Errors []struct {
Message string `json:"message"`
} `json:"errors"`
}
type successResponse struct {
Data struct {
SaveUrl struct {
Url string `json:"url"`
ClientRequestId string `json:"clientRequestId"`
} `json:"saveUrl"`
} `json:"data"`
}
type Client interface {
SaveUrl(url string) error
}
type client struct {
wrapped *http.Client
apiEndpoint string
apiToken string
}
func NewClient(apiToken string, apiEndpoint string) Client {
if apiEndpoint == "" {
apiEndpoint = defaultApiEndpoint
}
return &client{wrapped: &http.Client{Timeout: defaultClientTimeout}, apiEndpoint: apiEndpoint, apiToken: apiToken}
}
func (c *client) SaveUrl(url string) error {
var payload = map[string]any{
"query": mutation,
"variables": map[string]any{
"input": map[string]any{
"clientRequestId": crypto.GenerateUUID(),
"source": "api",
"url": url,
},
},
}
b, err := json.Marshal(payload)
if err != nil {
return err
}
req, err := http.NewRequest(http.MethodPost, c.apiEndpoint, bytes.NewReader(b))
if err != nil {
return err
}
req.Header.Set("Authorization", c.apiToken)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", "Miniflux/"+version.Version)
resp, err := c.wrapped.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
b, err = io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("omnivore: failed to parse response: %s", err)
}
if resp.StatusCode >= 400 {
var errResponse errorResponse
if err = json.Unmarshal(b, &errResponse); err != nil {
return fmt.Errorf("omnivore: failed to save URL: status=%d %s", resp.StatusCode, string(b))
}
return fmt.Errorf("omnivore: failed to save URL: status=%d %s", resp.StatusCode, errResponse.Errors[0].Message)
}
var successReponse successResponse
if err = json.Unmarshal(b, &successReponse); err != nil {
return fmt.Errorf("omnivore: failed to parse response, however the request appears successful, is the url correct?: status=%d %s", resp.StatusCode, string(b))
}
return nil
}
v2-2.2.16/internal/integration/pinboard/ 0000775 0000000 0000000 00000000000 15127074645 0020101 5 ustar 00root root 0000000 0000000 v2-2.2.16/internal/integration/pinboard/pinboard.go 0000664 0000000 0000000 00000006356 15127074645 0022240 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package pinboard // import "miniflux.app/v2/internal/integration/pinboard"
import (
"encoding/xml"
"errors"
"fmt"
"net/http"
"net/url"
"time"
"miniflux.app/v2/internal/version"
)
var errPostNotFound = errors.New("pinboard: post not found")
var errMissingCredentials = errors.New("pinboard: missing auth token")
const defaultClientTimeout = 10 * time.Second
type Client struct {
authToken string
}
func NewClient(authToken string) *Client {
return &Client{authToken: authToken}
}
func (c *Client) CreateBookmark(entryURL, entryTitle, pinboardTags string, markAsUnread bool) error {
if c.authToken == "" {
return errMissingCredentials
}
// We check if the url is already bookmarked to avoid overriding existing data.
post, err := c.getBookmark(entryURL)
if err != nil && errors.Is(err, errPostNotFound) {
post = NewPost(entryURL, entryTitle)
} else if err != nil {
// In case of any other error, we return immediately to avoid overriding existing data.
return err
}
post.addTag(pinboardTags)
if markAsUnread {
post.SetToread()
}
values := url.Values{}
values.Add("auth_token", c.authToken)
post.AddValues(values)
apiEndpoint := "https://api.pinboard.in/v1/posts/add?" + values.Encode()
request, err := http.NewRequest(http.MethodGet, apiEndpoint, nil)
if err != nil {
return fmt.Errorf("pinboard: unable to create request: %v", err)
}
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
httpClient := &http.Client{Timeout: defaultClientTimeout}
response, err := httpClient.Do(request)
if err != nil {
return fmt.Errorf("pinboard: unable to send request: %v", err)
}
defer response.Body.Close()
if response.StatusCode >= 400 {
return fmt.Errorf("pinboard: unable to create a bookmark: url=%s status=%d", apiEndpoint, response.StatusCode)
}
return nil
}
// getBookmark fetches a bookmark from Pinboard. https://www.pinboard.in/api/#posts_get
func (c *Client) getBookmark(entryURL string) (*Post, error) {
if c.authToken == "" {
return nil, errMissingCredentials
}
values := url.Values{}
values.Add("auth_token", c.authToken)
values.Add("url", entryURL)
apiEndpoint := "https://api.pinboard.in/v1/posts/get?" + values.Encode()
request, err := http.NewRequest(http.MethodGet, apiEndpoint, nil)
if err != nil {
return nil, fmt.Errorf("pinboard: unable to create request: %v", err)
}
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
httpClient := &http.Client{Timeout: defaultClientTimeout}
response, err := httpClient.Do(request)
if err != nil {
return nil, fmt.Errorf("pinboard: unable fetch bookmark: %v", err)
}
defer response.Body.Close()
if response.StatusCode >= 400 {
return nil, fmt.Errorf("pinboard: unable to fetch bookmark, status=%d", response.StatusCode)
}
var results posts
err = xml.NewDecoder(response.Body).Decode(&results)
if err != nil {
return nil, fmt.Errorf("pinboard: unable to decode XML: %v", err)
}
if len(results.Posts) == 0 {
return nil, errPostNotFound
}
return &results.Posts[0], nil
}
v2-2.2.16/internal/integration/pinboard/post.go 0000664 0000000 0000000 00000002752 15127074645 0021423 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package pinboard // import "miniflux.app/v2/internal/integration/pinboard"
import (
"encoding/xml"
"net/url"
"strings"
"time"
)
// Post a Pinboard bookmark. "inspiration" from https://github.com/drags/pinboard/blob/master/posts.go#L32-L42
type Post struct {
XMLName xml.Name `xml:"post"`
Url string `xml:"href,attr"`
Description string `xml:"description,attr"`
Tags string `xml:"tag,attr"`
Extended string `xml:"extended,attr"`
Date time.Time `xml:"time,attr"`
Shared string `xml:"shared,attr"`
Toread string `xml:"toread,attr"`
}
// Posts A result of a Pinboard API call
type posts struct {
XMLName xml.Name `xml:"posts"`
Posts []Post `xml:"post"`
}
func NewPost(url string, description string) *Post {
return &Post{
Url: url,
Description: description,
Date: time.Now(),
Toread: "no",
}
}
func (p *Post) addTag(tag string) {
if !strings.Contains(p.Tags, tag) {
p.Tags += " " + tag
}
}
func (p *Post) SetToread() {
p.Toread = "yes"
}
func (p *Post) AddValues(values url.Values) {
values.Add("url", p.Url)
values.Add("description", p.Description)
values.Add("tags", p.Tags)
if p.Toread != "" {
values.Add("toread", p.Toread)
}
if p.Shared != "" {
values.Add("shared", p.Shared)
}
values.Add("dt", p.Date.Format(time.RFC3339))
values.Add("extended", p.Extended)
}
v2-2.2.16/internal/integration/pushover/ 0000775 0000000 0000000 00000000000 15127074645 0020156 5 ustar 00root root 0000000 0000000 v2-2.2.16/internal/integration/pushover/pushover.go 0000664 0000000 0000000 00000005754 15127074645 0022373 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package pushover // import "miniflux.app/v2/internal/integration/pushover"
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/http"
"strings"
"time"
"miniflux.app/v2/internal/model"
"miniflux.app/v2/internal/version"
)
const (
defaultClientTimeout = 10 * time.Second
defaultPushoverURL = "https://api.pushover.net"
)
type Client struct {
prefix string
token string
user string
device string
priority int
}
type Message struct {
Token string `json:"token"`
User string `json:"user"`
Title string `json:"title"`
Message string `json:"message"`
Priority int `json:"priority"`
URL string `json:"url"`
URLTitle string `json:"url_title"`
Device string `json:"device,omitempty"`
}
type ErrorResponse struct {
User string `json:"user"`
Errors []string `json:"errors"`
Status int `json:"status"`
Request string `json:"request"`
}
func New(user, token string, priority int, device, urlPrefix string) *Client {
if urlPrefix == "" {
urlPrefix = defaultPushoverURL
}
if priority < -2 {
priority = -2
}
if priority > 2 {
priority = 2
}
return &Client{
user: user,
token: token,
device: device,
prefix: urlPrefix,
priority: priority,
}
}
func (c *Client) SendMessages(feed *model.Feed, entries model.Entries) error {
if c.token == "" || c.user == "" {
return errors.New("pushover token and user are required")
}
for _, entry := range entries {
msg := &Message{
User: c.user,
Token: c.token,
Device: c.device,
Message: entry.Title,
Title: feed.Title,
Priority: c.priority,
URL: entry.URL,
}
slog.Debug("Sending Pushover message",
slog.Int("priority", msg.Priority),
slog.String("message", msg.Message),
slog.String("entry_url", msg.URL),
)
if err := c.makeRequest(msg); err != nil {
return fmt.Errorf("c.makeRequest: %w", err)
}
}
return nil
}
func (c *Client) makeRequest(payload *Message) error {
jsonData, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("json.Marshal: %w", err)
}
url := c.prefix + "/1/messages.json"
req, err := http.NewRequest(http.MethodPost, url, bytes.NewBuffer(jsonData))
if err != nil {
return fmt.Errorf("http.NewRequest: %w", err)
}
req.Header.Add("Content-Type", "application/json")
req.Header.Set("User-Agent", "Miniflux/"+version.Version)
httpClient := &http.Client{Timeout: defaultClientTimeout}
resp, err := httpClient.Do(req)
if err != nil {
return fmt.Errorf("httpClient.Do: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode >= http.StatusBadRequest {
errorMessage := resp.Status
var errResp ErrorResponse
if err := json.NewDecoder(resp.Body).Decode(&errResp); err != nil {
if len(errResp.Errors) > 0 {
errorMessage = strings.Join(errResp.Errors, ",")
}
}
return fmt.Errorf("pushover API error (%d): %s", resp.StatusCode, errorMessage)
}
return nil
}
v2-2.2.16/internal/integration/raindrop/ 0000775 0000000 0000000 00000000000 15127074645 0020121 5 ustar 00root root 0000000 0000000 v2-2.2.16/internal/integration/raindrop/raindrop.go 0000664 0000000 0000000 00000004062 15127074645 0022270 0 ustar 00root root 0000000 0000000 // 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"
"errors"
"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 errors.New("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"`
}
v2-2.2.16/internal/integration/readeck/ 0000775 0000000 0000000 00000000000 15127074645 0017701 5 ustar 00root root 0000000 0000000 v2-2.2.16/internal/integration/readeck/readeck.go 0000664 0000000 0000000 00000010561 15127074645 0021631 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package readeck // import "miniflux.app/v2/internal/integration/readeck"
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"mime/multipart"
"net/http"
"strings"
"time"
"miniflux.app/v2/internal/urllib"
"miniflux.app/v2/internal/version"
)
const defaultClientTimeout = 10 * time.Second
type Client struct {
baseURL string
apiKey string
labels string
onlyURL bool
}
func NewClient(baseURL, apiKey, labels string, onlyURL bool) *Client {
return &Client{baseURL: baseURL, apiKey: apiKey, labels: labels, onlyURL: onlyURL}
}
func (c *Client) CreateBookmark(entryURL, entryTitle string, entryContent string) error {
if c.baseURL == "" || c.apiKey == "" {
return errors.New("readeck: missing base URL or API key")
}
apiEndpoint, err := urllib.JoinBaseURLAndPath(c.baseURL, "/api/bookmarks/")
if err != nil {
return fmt.Errorf(`readeck: invalid API endpoint: %v`, err)
}
labelsSplitFn := func(c rune) bool {
return c == ',' || c == ' '
}
labelsSplit := strings.FieldsFunc(c.labels, labelsSplitFn)
var request *http.Request
if c.onlyURL {
requestBodyJson, err := json.Marshal(&readeckBookmark{
Url: entryURL,
Title: entryTitle,
Labels: labelsSplit,
})
if err != nil {
return fmt.Errorf("readeck: unable to encode request body: %v", err)
}
request, err = http.NewRequest(http.MethodPost, apiEndpoint, bytes.NewReader(requestBodyJson))
if err != nil {
return fmt.Errorf("readeck: unable to create request: %v", err)
}
request.Header.Set("Content-Type", "application/json")
} else {
requestBody := new(bytes.Buffer)
multipartWriter := multipart.NewWriter(requestBody)
urlPart, err := multipartWriter.CreateFormField("url")
if err != nil {
return fmt.Errorf("readeck: unable to encode request body (entry url): %v", err)
}
urlPart.Write([]byte(entryURL))
titlePart, err := multipartWriter.CreateFormField("title")
if err != nil {
return fmt.Errorf("readeck: unable to encode request body (entry title): %v", err)
}
titlePart.Write([]byte(entryTitle))
featurePart, err := multipartWriter.CreateFormField("feature_find_main")
if err != nil {
return fmt.Errorf("readeck: unable to encode request body (feature_find_main flag): %v", err)
}
featurePart.Write([]byte("false")) // false to disable readability
for _, label := range labelsSplit {
labelPart, err := multipartWriter.CreateFormField("labels")
if err != nil {
return fmt.Errorf("readeck: unable to encode request body (entry labels): %v", err)
}
labelPart.Write([]byte(label))
}
contentBodyHeader, err := json.Marshal(&partContentHeader{
Url: entryURL,
ContentHeader: contentHeader{ContentType: "text/html; charset=utf-8"},
})
if err != nil {
return fmt.Errorf("readeck: unable to encode request body (entry content header): %v", err)
}
contentPart, err := multipartWriter.CreateFormFile("resource", "blob")
if err != nil {
return fmt.Errorf("readeck: unable to encode request body (entry content): %v", err)
}
contentPart.Write(contentBodyHeader)
contentPart.Write([]byte("\n"))
contentPart.Write([]byte(entryContent))
err = multipartWriter.Close()
if err != nil {
return fmt.Errorf("readeck: unable to encode request body: %v", err)
}
request, err = http.NewRequest(http.MethodPost, apiEndpoint, requestBody)
if err != nil {
return fmt.Errorf("readeck: unable to create request: %v", err)
}
request.Header.Set("Content-Type", multipartWriter.FormDataContentType())
}
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
request.Header.Set("Authorization", "Bearer "+c.apiKey)
httpClient := &http.Client{Timeout: defaultClientTimeout}
response, err := httpClient.Do(request)
if err != nil {
return fmt.Errorf("readeck: unable to send request: %v", err)
}
defer response.Body.Close()
if response.StatusCode >= 400 {
return fmt.Errorf("readeck: unable to create bookmark: url=%s status=%d", apiEndpoint, response.StatusCode)
}
return nil
}
type readeckBookmark struct {
Url string `json:"url"`
Title string `json:"title"`
Labels []string `json:"labels,omitempty"`
}
type contentHeader struct {
ContentType string `json:"content-type"`
}
type partContentHeader struct {
Url string `json:"url"`
ContentHeader contentHeader `json:"headers"`
}
v2-2.2.16/internal/integration/readeck/readeck_test.go 0000664 0000000 0000000 00000024044 15127074645 0022671 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package readeck
import (
"encoding/json"
"io"
"mime/multipart"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestCreateBookmark(t *testing.T) {
entryURL := "https://example.com/article"
entryTitle := "Example Title"
entryContent := "Some HTML content
"
labels := "tag1,tag2"
tests := []struct {
name string
onlyURL bool
baseURL string
apiKey string
labels string
entryURL string
entryTitle string
entryContent string
serverResponse func(w http.ResponseWriter, r *http.Request)
wantErr bool
errContains string
}{
{
name: "successful bookmark creation with only URL",
onlyURL: true,
labels: labels,
entryURL: entryURL,
entryTitle: entryTitle,
entryContent: entryContent,
serverResponse: func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
t.Errorf("expected POST, got %s", r.Method)
}
if r.URL.Path != "/api/bookmarks/" {
t.Errorf("expected path /api/bookmarks/, got %s", r.URL.Path)
}
if got := r.Header.Get("Authorization"); !strings.HasPrefix(got, "Bearer ") {
t.Errorf("expected Authorization Bearer header, got %q", got)
}
if ct := r.Header.Get("Content-Type"); ct != "application/json" {
t.Errorf("expected Content-Type application/json, got %s", ct)
}
body, _ := io.ReadAll(r.Body)
var payload map[string]any
if err := json.Unmarshal(body, &payload); err != nil {
t.Fatalf("failed to parse JSON body: %v", err)
}
if u := payload["url"]; u != entryURL {
t.Errorf("expected url %s, got %v", entryURL, u)
}
if title := payload["title"]; title != entryTitle {
t.Errorf("expected title %s, got %v", entryTitle, title)
}
// Labels should be split into an array
if raw := payload["labels"]; raw == nil {
t.Errorf("expected labels to be set")
} else if arr, ok := raw.([]any); ok {
if len(arr) != 2 || arr[0] != "tag1" || arr[1] != "tag2" {
t.Errorf("unexpected labels: %#v", arr)
}
} else {
t.Errorf("labels should be an array, got %T", raw)
}
w.WriteHeader(http.StatusOK)
},
},
{
name: "successful bookmark creation with content (multipart)",
onlyURL: false,
labels: labels,
entryURL: entryURL,
entryTitle: entryTitle,
entryContent: entryContent,
serverResponse: func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
t.Errorf("expected POST, got %s", r.Method)
}
if r.URL.Path != "/api/bookmarks/" {
t.Errorf("expected path /api/bookmarks/, got %s", r.URL.Path)
}
if got := r.Header.Get("Authorization"); !strings.HasPrefix(got, "Bearer ") {
t.Errorf("expected Authorization Bearer header, got %q", got)
}
ct := r.Header.Get("Content-Type")
if !strings.HasPrefix(ct, "multipart/form-data;") {
t.Errorf("expected multipart/form-data, got %s", ct)
}
boundaryIdx := strings.Index(ct, "boundary=")
if boundaryIdx == -1 {
t.Fatalf("missing multipart boundary in Content-Type: %s", ct)
}
boundary := ct[boundaryIdx+len("boundary="):]
mr := multipart.NewReader(r.Body, boundary)
seenLabels := []string{}
var seenURL, seenTitle, seenFeature string
var resourceHeader map[string]any
var resourceBody string
for {
part, err := mr.NextPart()
if err == io.EOF {
break
}
if err != nil {
t.Fatalf("reading multipart: %v", err)
}
name := part.FormName()
data, _ := io.ReadAll(part)
switch name {
case "url":
seenURL = string(data)
case "title":
seenTitle = string(data)
case "feature_find_main":
seenFeature = string(data)
case "labels":
seenLabels = append(seenLabels, string(data))
case "resource":
// First line is JSON header, then newline, then content
all := string(data)
idx := strings.IndexByte(all, '\n')
if idx == -1 {
t.Fatalf("resource content missing header separator")
}
headerJSON := all[:idx]
resourceBody = all[idx+1:]
if err := json.Unmarshal([]byte(headerJSON), &resourceHeader); err != nil {
t.Fatalf("invalid resource header JSON: %v", err)
}
}
}
if seenURL != entryURL {
t.Errorf("expected url %s, got %s", entryURL, seenURL)
}
if seenTitle != entryTitle {
t.Errorf("expected title %s, got %s", entryTitle, seenTitle)
}
if seenFeature != "false" {
t.Errorf("expected feature_find_main to be 'false', got %s", seenFeature)
}
if len(seenLabels) != 2 || seenLabels[0] != "tag1" || seenLabels[1] != "tag2" {
t.Errorf("unexpected labels: %#v", seenLabels)
}
if resourceHeader == nil {
t.Fatalf("missing resource header")
}
if hURL, _ := resourceHeader["url"].(string); hURL != entryURL {
t.Errorf("expected resource header url %s, got %v", entryURL, hURL)
}
if headers, ok := resourceHeader["headers"].(map[string]any); ok {
if ct, _ := headers["content-type"].(string); ct != "text/html; charset=utf-8" {
t.Errorf("expected resource header content-type text/html; charset=utf-8, got %v", ct)
}
} else {
t.Errorf("missing resource header 'headers' field")
}
if resourceBody != entryContent {
t.Errorf("expected resource body %q, got %q", entryContent, resourceBody)
}
w.WriteHeader(http.StatusOK)
},
},
{
name: "error when server returns 400",
onlyURL: true,
labels: labels,
entryURL: entryURL,
entryTitle: entryTitle,
entryContent: entryContent,
serverResponse: func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusBadRequest)
},
wantErr: true,
errContains: "unable to create bookmark",
},
{
name: "error when missing baseURL or apiKey",
onlyURL: true,
baseURL: "",
apiKey: "",
labels: labels,
entryURL: entryURL,
entryTitle: entryTitle,
entryContent: entryContent,
serverResponse: nil,
wantErr: true,
errContains: "missing base URL or API key",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var serverURL string
if tt.serverResponse != nil {
srv := httptest.NewServer(http.HandlerFunc(tt.serverResponse))
defer srv.Close()
serverURL = srv.URL
}
baseURL := tt.baseURL
if baseURL == "" {
baseURL = serverURL
}
apiKey := tt.apiKey
if apiKey == "" {
apiKey = "test-api-key"
}
client := NewClient(baseURL, apiKey, tt.labels, tt.onlyURL)
err := client.CreateBookmark(tt.entryURL, tt.entryTitle, tt.entryContent)
if tt.wantErr {
if err == nil {
t.Fatalf("expected error, got none")
}
if tt.errContains != "" && !strings.Contains(err.Error(), tt.errContains) {
t.Fatalf("expected error containing %q, got %q", tt.errContains, err.Error())
}
} else if err != nil {
t.Fatalf("unexpected error: %v", err)
}
})
}
}
func TestNewClient(t *testing.T) {
baseURL := "https://readeck.example.com"
apiKey := "key"
labels := "tag1,tag2"
onlyURL := true
c := NewClient(baseURL, apiKey, labels, onlyURL)
if c.baseURL != baseURL {
t.Errorf("expected baseURL %s, got %s", baseURL, c.baseURL)
}
if c.apiKey != apiKey {
t.Errorf("expected apiKey %s, got %s", apiKey, c.apiKey)
}
if c.labels != labels {
t.Errorf("expected labels %s, got %s", labels, c.labels)
}
if c.onlyURL != onlyURL {
t.Errorf("expected onlyURL %v, got %v", onlyURL, c.onlyURL)
}
}
v2-2.2.16/internal/integration/readwise/ 0000775 0000000 0000000 00000000000 15127074645 0020106 5 ustar 00root root 0000000 0000000 v2-2.2.16/internal/integration/readwise/readwise.go 0000664 0000000 0000000 00000003257 15127074645 0022247 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
// Readwise Reader API documentation: https://readwise.io/reader_api
package readwise // import "miniflux.app/v2/internal/integration/readwise"
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"net/http"
"time"
"miniflux.app/v2/internal/version"
)
const (
readwiseApiEndpoint = "https://readwise.io/api/v3/save/"
defaultClientTimeout = 10 * time.Second
)
type Client struct {
apiKey string
}
func NewClient(apiKey string) *Client {
return &Client{apiKey: apiKey}
}
func (c *Client) CreateDocument(entryURL string) error {
if c.apiKey == "" {
return errors.New("readwise: missing API key")
}
requestBody, err := json.Marshal(&readwiseDocument{
URL: entryURL,
})
if err != nil {
return fmt.Errorf("readwise: unable to encode request body: %v", err)
}
request, err := http.NewRequest(http.MethodPost, readwiseApiEndpoint, bytes.NewReader(requestBody))
if err != nil {
return fmt.Errorf("readwise: 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", "Token "+c.apiKey)
httpClient := &http.Client{Timeout: defaultClientTimeout}
response, err := httpClient.Do(request)
if err != nil {
return fmt.Errorf("readwise: unable to send request: %v", err)
}
defer response.Body.Close()
if response.StatusCode >= 400 {
return fmt.Errorf("readwise: unable to create document: url=%s status=%d", readwiseApiEndpoint, response.StatusCode)
}
return nil
}
type readwiseDocument struct {
URL string `json:"url"`
}
v2-2.2.16/internal/integration/rssbridge/ 0000775 0000000 0000000 00000000000 15127074645 0020267 5 ustar 00root root 0000000 0000000 v2-2.2.16/internal/integration/rssbridge/rssbridge.go 0000664 0000000 0000000 00000004666 15127074645 0022616 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package rssbridge // import "miniflux.app/v2/internal/integration/rssbridge"
import (
"encoding/json"
"fmt"
"log/slog"
"net/http"
"net/url"
"strings"
"time"
)
const defaultClientTimeout = 30 * time.Second
type Bridge struct {
URL string `json:"url"`
BridgeMeta BridgeMeta `json:"bridgeMeta"`
}
type BridgeMeta struct {
Name string `json:"name"`
}
func DetectBridges(rssBridgeURL, rssBridgeToken, websiteURL string) ([]*Bridge, error) {
endpointURL, err := url.Parse(rssBridgeURL)
if err != nil {
return nil, fmt.Errorf("RSS-Bridge: unable to parse bridge URL: %w", err)
}
values := endpointURL.Query()
if rssBridgeToken != "" {
values.Add("token", rssBridgeToken)
}
values.Add("action", "findfeed")
values.Add("format", "atom")
values.Add("url", websiteURL)
endpointURL.RawQuery = values.Encode()
slog.Debug("Detecting RSS bridges", slog.String("url", endpointURL.String()))
request, err := http.NewRequest(http.MethodGet, endpointURL.String(), nil)
if err != nil {
return nil, fmt.Errorf("RSS-Bridge: unable to create request: %w", err)
}
httpClient := &http.Client{Timeout: defaultClientTimeout}
response, err := httpClient.Do(request)
if err != nil {
return nil, fmt.Errorf("RSS-Bridge: unable to execute request: %w", err)
}
defer response.Body.Close()
if response.StatusCode == http.StatusNotFound {
return nil, nil
}
if response.StatusCode > 400 {
return nil, fmt.Errorf("RSS-Bridge: unexpected status code %d", response.StatusCode)
}
var bridgeResponse []*Bridge
if err := json.NewDecoder(response.Body).Decode(&bridgeResponse); err != nil {
return nil, fmt.Errorf("RSS-Bridge: unable to decode bridge response: %w", err)
}
for _, bridge := range bridgeResponse {
slog.Debug("Found RSS bridge",
slog.String("name", bridge.BridgeMeta.Name),
slog.String("url", bridge.URL),
)
if strings.HasPrefix(bridge.URL, "./") {
bridge.URL = rssBridgeURL + bridge.URL[2:]
slog.Debug("Rewrited relative RSS bridge URL",
slog.String("name", bridge.BridgeMeta.Name),
slog.String("url", bridge.URL),
)
}
if rssBridgeToken != "" {
bridge.URL = bridge.URL + "&token=" + rssBridgeToken
slog.Debug("Appended token to RSS bridge URL",
slog.String("name", bridge.BridgeMeta.Name),
slog.String("url", bridge.URL),
)
}
}
return bridgeResponse, nil
}
v2-2.2.16/internal/integration/shaarli/ 0000775 0000000 0000000 00000000000 15127074645 0017726 5 ustar 00root root 0000000 0000000 v2-2.2.16/internal/integration/shaarli/shaarli.go 0000664 0000000 0000000 00000004740 15127074645 0021705 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package shaarli // import "miniflux.app/v2/internal/integration/shaarli"
import (
"bytes"
"crypto/hmac"
"crypto/sha512"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"net/http"
"time"
"miniflux.app/v2/internal/urllib"
"miniflux.app/v2/internal/version"
)
const defaultClientTimeout = 10 * time.Second
type Client struct {
baseURL string
apiSecret string
}
func NewClient(baseURL, apiSecret string) *Client {
return &Client{baseURL: baseURL, apiSecret: apiSecret}
}
func (c *Client) CreateLink(entryURL, entryTitle string) error {
if c.baseURL == "" || c.apiSecret == "" {
return errors.New("shaarli: missing base URL or API secret")
}
apiEndpoint, err := urllib.JoinBaseURLAndPath(c.baseURL, "/api/v1/links")
if err != nil {
return fmt.Errorf("shaarli: invalid API endpoint: %v", err)
}
requestBody, err := json.Marshal(&addLinkRequest{
URL: entryURL,
Title: entryTitle,
Private: true,
})
if err != nil {
return fmt.Errorf("shaarli: unable to encode request body: %v", err)
}
request, err := http.NewRequest(http.MethodPost, apiEndpoint, bytes.NewReader(requestBody))
if err != nil {
return fmt.Errorf("shaarli: unable to create request: %v", err)
}
request.Header.Set("Content-Type", "application/json")
request.Header.Set("Accept", "application/json")
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
request.Header.Set("Authorization", "Bearer "+c.generateBearerToken())
httpClient := &http.Client{Timeout: defaultClientTimeout}
response, err := httpClient.Do(request)
if err != nil {
return fmt.Errorf("shaarli: unable to send request: %v", err)
}
defer response.Body.Close()
if response.StatusCode != http.StatusCreated {
return fmt.Errorf("shaarli: unable to add link: url=%s status=%d", apiEndpoint, response.StatusCode)
}
return nil
}
func (c *Client) generateBearerToken() string {
header := base64.RawURLEncoding.EncodeToString([]byte(`{"typ":"JWT","alg":"HS512"}`))
payload := base64.RawURLEncoding.EncodeToString([]byte(fmt.Sprintf(`{"iat":%d}`, time.Now().Unix())))
data := header + "." + payload
mac := hmac.New(sha512.New, []byte(c.apiSecret))
mac.Write([]byte(data))
signature := base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
return data + "." + signature
}
type addLinkRequest struct {
URL string `json:"url"`
Title string `json:"title"`
Private bool `json:"private"`
}
v2-2.2.16/internal/integration/shiori/ 0000775 0000000 0000000 00000000000 15127074645 0017600 5 ustar 00root root 0000000 0000000 v2-2.2.16/internal/integration/shiori/shiori.go 0000664 0000000 0000000 00000010175 15127074645 0021430 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package shiori // import "miniflux.app/v2/internal/integration/shiori"
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"net/http"
"time"
"miniflux.app/v2/internal/urllib"
"miniflux.app/v2/internal/version"
)
const defaultClientTimeout = 10 * time.Second
type Client struct {
baseURL string
username string
password string
}
func NewClient(baseURL, username, password string) *Client {
return &Client{baseURL: baseURL, username: username, password: password}
}
func (c *Client) CreateBookmark(entryURL, entryTitle string) error {
if c.baseURL == "" || c.username == "" || c.password == "" {
return errors.New("shiori: missing base URL, username or password")
}
token, err := c.authenticate()
if err != nil {
return fmt.Errorf("shiori: unable to authenticate: %v", err)
}
apiEndpoint, err := urllib.JoinBaseURLAndPath(c.baseURL, "/api/bookmarks")
if err != nil {
return fmt.Errorf("shiori: invalid API endpoint: %v", err)
}
requestBody, err := json.Marshal(&addBookmarkRequest{
URL: entryURL,
Title: entryTitle,
Excerpt: "",
CreateArchive: true,
CreateEbook: false,
Public: 0,
Tags: make([]string, 0),
})
if err != nil {
return fmt.Errorf("shiori: unable to encode request body: %v", err)
}
request, err := http.NewRequest(http.MethodPost, apiEndpoint, bytes.NewReader(requestBody))
if err != nil {
return fmt.Errorf("shiori: 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 "+token)
httpClient := &http.Client{Timeout: defaultClientTimeout}
response, err := httpClient.Do(request)
if err != nil {
return fmt.Errorf("shiori: unable to send request: %v", err)
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
return fmt.Errorf("shiori: unable to create bookmark: url=%s status=%d", apiEndpoint, response.StatusCode)
}
return nil
}
func (c *Client) authenticate() (token string, err error) {
apiEndpoint, err := urllib.JoinBaseURLAndPath(c.baseURL, "/api/v1/auth/login")
if err != nil {
return "", fmt.Errorf("shiori: invalid API endpoint: %v", err)
}
requestBody, err := json.Marshal(&authRequest{Username: c.username, Password: c.password, RememberMe: false})
if err != nil {
return "", fmt.Errorf("shiori: unable to encode request body: %v", err)
}
request, err := http.NewRequest(http.MethodPost, apiEndpoint, bytes.NewReader(requestBody))
if err != nil {
return "", fmt.Errorf("shiori: unable to create request: %v", err)
}
request.Header.Set("Content-Type", "application/json")
request.Header.Set("Accept", "application/json")
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
httpClient := &http.Client{Timeout: defaultClientTimeout}
response, err := httpClient.Do(request)
if err != nil {
return "", fmt.Errorf("shiori: unable to send request: %v", err)
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
return "", fmt.Errorf("shiori: unable to authenticate: url=%s status=%d", apiEndpoint, response.StatusCode)
}
var authResponse authResponse
if err := json.NewDecoder(response.Body).Decode(&authResponse); err != nil {
return "", fmt.Errorf("shiori: unable to decode response: %v", err)
}
return authResponse.Message.Token, nil
}
type authRequest struct {
Username string `json:"username"`
Password string `json:"password"`
RememberMe bool `json:"remember_me"`
}
type authResponse struct {
OK bool `json:"ok"`
Message authResponseMessage `json:"message"`
}
type authResponseMessage struct {
SessionID string `json:"session"`
Token string `json:"token"`
}
type addBookmarkRequest struct {
URL string `json:"url"`
Title string `json:"title"`
CreateArchive bool `json:"create_archive"`
CreateEbook bool `json:"create_ebook"`
Public int `json:"public"`
Excerpt string `json:"excerpt"`
Tags []string `json:"tags"`
}
v2-2.2.16/internal/integration/slack/ 0000775 0000000 0000000 00000000000 15127074645 0017400 5 ustar 00root root 0000000 0000000 v2-2.2.16/internal/integration/slack/slack.go 0000664 0000000 0000000 00000005231 15127074645 0021025 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
// Slack Webhooks documentation: https://api.slack.com/messaging/webhooks
package slack // import "miniflux.app/v2/internal/integration/slack"
import (
"bytes"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"time"
"miniflux.app/v2/internal/model"
"miniflux.app/v2/internal/urllib"
"miniflux.app/v2/internal/version"
)
const defaultClientTimeout = 10 * time.Second
const slackMsgColor = "#5865F2"
type Client struct {
webhookURL string
}
func NewClient(webhookURL string) *Client {
return &Client{webhookURL: webhookURL}
}
func (c *Client) SendSlackMsg(feed *model.Feed, entries model.Entries) error {
for _, entry := range entries {
requestBody, err := json.Marshal(&slackMessage{
Attachments: []slackAttachments{
{
Title: "RSS feed update from Miniflux",
Color: slackMsgColor,
Fields: []slackFields{
{
Title: "Updated feed",
Value: feed.Title,
},
{
Title: "Article title",
Value: entry.Title,
},
{
Title: "Article link",
Value: entry.URL,
},
{
Title: "Author",
Value: entry.Author,
Short: true,
},
{
Title: "Source website",
Value: urllib.RootURL(feed.SiteURL),
Short: true,
},
},
},
},
})
if err != nil {
return fmt.Errorf("slack: unable to encode request body: %v", err)
}
request, err := http.NewRequest(http.MethodPost, c.webhookURL, bytes.NewReader(requestBody))
if err != nil {
return fmt.Errorf("slack: unable to create request: %v", err)
}
request.Header.Set("Content-Type", "application/json")
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
slog.Debug("Sending Slack notification",
slog.String("webhookURL", c.webhookURL),
slog.String("title", feed.Title),
slog.String("entry_url", entry.URL),
)
httpClient := &http.Client{Timeout: defaultClientTimeout}
response, err := httpClient.Do(request)
if err != nil {
return fmt.Errorf("slack: unable to send request: %v", err)
}
response.Body.Close()
if response.StatusCode >= 400 {
return fmt.Errorf("slack: unable to send a notification: url=%s status=%d", c.webhookURL, response.StatusCode)
}
}
return nil
}
type slackFields struct {
Title string `json:"title"`
Value string `json:"value"`
Short bool `json:"short,omitempty"`
}
type slackAttachments struct {
Title string `json:"title"`
Color string `json:"color"`
Fields []slackFields `json:"fields"`
}
type slackMessage struct {
Attachments []slackAttachments `json:"attachments"`
}
v2-2.2.16/internal/integration/telegrambot/ 0000775 0000000 0000000 00000000000 15127074645 0020610 5 ustar 00root root 0000000 0000000 v2-2.2.16/internal/integration/telegrambot/client.go 0000664 0000000 0000000 00000012174 15127074645 0022422 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package telegrambot // import "miniflux.app/v2/internal/integration/telegrambot"
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"net/url"
"time"
"miniflux.app/v2/internal/version"
)
const (
defaultClientTimeout = 10 * time.Second
telegramAPIEndpoint = "https://api.telegram.org"
MarkdownFormatting = "Markdown"
MarkdownV2Formatting = "MarkdownV2"
HTMLFormatting = "HTML"
)
type Client struct {
botToken string
chatID string
}
func NewClient(botToken, chatID string) *Client {
return &Client{
botToken: botToken,
chatID: chatID,
}
}
// Specs: https://core.telegram.org/bots/api#getme
func (c *Client) GetMe() (*User, error) {
endpointURL, err := url.JoinPath(telegramAPIEndpoint, "/bot"+c.botToken, "/getMe")
if err != nil {
return nil, fmt.Errorf("telegram: unable to join base URL and path: %w", err)
}
request, err := http.NewRequest(http.MethodGet, endpointURL, nil)
if err != nil {
return nil, fmt.Errorf("telegram: unable to create request: %v", err)
}
request.Header.Set("Accept", "application/json")
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
httpClient := &http.Client{Timeout: defaultClientTimeout}
response, err := httpClient.Do(request)
if err != nil {
return nil, fmt.Errorf("telegram: unable to send request: %v", err)
}
defer response.Body.Close()
var userResponse UserResponse
if err := json.NewDecoder(response.Body).Decode(&userResponse); err != nil {
return nil, fmt.Errorf("telegram: unable to decode user response: %w", err)
}
if !userResponse.Ok {
return nil, fmt.Errorf("telegram: unable to send message: %s (error code is %d)", userResponse.Description, userResponse.ErrorCode)
}
return &userResponse.Result, nil
}
// Specs: https://core.telegram.org/bots/api#sendmessage
func (c *Client) SendMessage(message *MessageRequest) (*Message, error) {
endpointURL, err := url.JoinPath(telegramAPIEndpoint, "/bot"+c.botToken, "/sendMessage")
if err != nil {
return nil, fmt.Errorf("telegram: unable to join base URL and path: %w", err)
}
requestBody, err := json.Marshal(message)
if err != nil {
return nil, fmt.Errorf("telegram: unable to encode request body: %v", err)
}
request, err := http.NewRequest(http.MethodPost, endpointURL, bytes.NewReader(requestBody))
if err != nil {
return nil, fmt.Errorf("telegram: unable to create request: %v", err)
}
request.Header.Set("Content-Type", "application/json")
request.Header.Set("Accept", "application/json")
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
httpClient := &http.Client{Timeout: defaultClientTimeout}
response, err := httpClient.Do(request)
if err != nil {
return nil, fmt.Errorf("telegram: unable to send request: %v", err)
}
defer response.Body.Close()
var messageResponse MessageResponse
if err := json.NewDecoder(response.Body).Decode(&messageResponse); err != nil {
return nil, fmt.Errorf("telegram: unable to decode discovery response: %w", err)
}
if !messageResponse.Ok {
return nil, fmt.Errorf("telegram: unable to send message: %s (error code is %d)", messageResponse.Description, messageResponse.ErrorCode)
}
return &messageResponse.Result, nil
}
type InlineKeyboard struct {
InlineKeyboard []InlineKeyboardRow `json:"inline_keyboard"`
}
type InlineKeyboardRow []*InlineKeyboardButton
type InlineKeyboardButton struct {
Text string `json:"text"`
URL string `json:"url,omitempty"`
}
type User struct {
ID int64 `json:"id"`
IsBot bool `json:"is_bot"`
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
Username string `json:"username"`
LanguageCode string `json:"language_code"`
IsPremium bool `json:"is_premium"`
CanJoinGroups bool `json:"can_join_groups"`
CanReadAllGroupMessages bool `json:"can_read_all_group_messages"`
SupportsInlineQueries bool `json:"supports_inline_queries"`
}
type Chat struct {
ID int64 `json:"id"`
Type string `json:"type"`
Title string `json:"title"`
}
type Message struct {
MessageID int64 `json:"message_id"`
From User `json:"from"`
Chat Chat `json:"chat"`
MessageThreadID int64 `json:"message_thread_id"`
Date int64 `json:"date"`
}
type BaseResponse struct {
Ok bool `json:"ok"`
ErrorCode int `json:"error_code"`
Description string `json:"description"`
}
type UserResponse struct {
BaseResponse
Result User `json:"result"`
}
type MessageRequest struct {
ChatID string `json:"chat_id"`
MessageThreadID int64 `json:"message_thread_id,omitempty"`
Text string `json:"text"`
ParseMode string `json:"parse_mode,omitempty"`
DisableWebPagePreview bool `json:"disable_web_page_preview"`
DisableNotification bool `json:"disable_notification"`
ReplyMarkup *InlineKeyboard `json:"reply_markup,omitempty"`
}
type MessageResponse struct {
BaseResponse
Result Message `json:"result"`
}
v2-2.2.16/internal/integration/telegrambot/telegrambot.go 0000664 0000000 0000000 00000003673 15127074645 0023455 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package telegrambot // import "miniflux.app/v2/internal/integration/telegrambot"
import (
"fmt"
"log/slog"
"strconv"
"miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/model"
"miniflux.app/v2/internal/urllib"
)
func PushEntry(feed *model.Feed, entry *model.Entry, botToken, chatID string, topicID *int64, disableWebPagePreview, disableNotification bool, disableButtons bool) error {
formattedText := fmt.Sprintf(
`%s - %s `,
feed.Title,
entry.URL,
entry.Title,
)
message := &MessageRequest{
ChatID: chatID,
Text: formattedText,
ParseMode: HTMLFormatting,
DisableWebPagePreview: disableWebPagePreview,
DisableNotification: disableNotification,
}
if topicID != nil {
message.MessageThreadID = *topicID
}
if !disableButtons {
var markupRow []*InlineKeyboardButton
baseURL := config.Opts.BaseURL()
entryPath := "/unread/entry/" + strconv.FormatInt(entry.ID, 10)
minifluxEntryURL, err := urllib.JoinBaseURLAndPath(baseURL, entryPath)
if err != nil {
slog.Error("Unable to create Miniflux entry URL", slog.Any("error", err))
} else {
minifluxEntryURLButton := InlineKeyboardButton{Text: "Go to Miniflux", URL: minifluxEntryURL}
markupRow = append(markupRow, &minifluxEntryURLButton)
}
articleURLButton := InlineKeyboardButton{Text: "Go to article", URL: entry.URL}
markupRow = append(markupRow, &articleURLButton)
if entry.CommentsURL != "" {
commentURLButton := InlineKeyboardButton{Text: "Comments", URL: entry.CommentsURL}
markupRow = append(markupRow, &commentURLButton)
}
message.ReplyMarkup = &InlineKeyboard{}
message.ReplyMarkup.InlineKeyboard = append(message.ReplyMarkup.InlineKeyboard, markupRow)
}
client := NewClient(botToken, chatID)
_, err := client.SendMessage(message)
return err
}
v2-2.2.16/internal/integration/wallabag/ 0000775 0000000 0000000 00000000000 15127074645 0020055 5 ustar 00root root 0000000 0000000 v2-2.2.16/internal/integration/wallabag/wallabag.go 0000664 0000000 0000000 00000010457 15127074645 0022165 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package wallabag // import "miniflux.app/v2/internal/integration/wallabag"
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"strings"
"time"
"miniflux.app/v2/internal/urllib"
"miniflux.app/v2/internal/version"
)
const defaultClientTimeout = 10 * time.Second
type Client struct {
baseURL string
clientID string
clientSecret string
username string
password string
tags string
onlyURL bool
}
func NewClient(baseURL, clientID, clientSecret, username, password, tags string, onlyURL bool) *Client {
return &Client{baseURL, clientID, clientSecret, username, password, tags, onlyURL}
}
func (c *Client) CreateEntry(entryURL, entryTitle, entryContent string) error {
if c.baseURL == "" || c.clientID == "" || c.clientSecret == "" || c.username == "" || c.password == "" {
return errors.New("wallabag: missing base URL, client ID, client secret, username or password")
}
accessToken, err := c.getAccessToken()
if err != nil {
return err
}
return c.createEntry(accessToken, entryURL, entryTitle, entryContent, c.tags)
}
func (c *Client) createEntry(accessToken, entryURL, entryTitle, entryContent, tags string) error {
apiEndpoint, err := urllib.JoinBaseURLAndPath(c.baseURL, "/api/entries.json")
if err != nil {
return fmt.Errorf("wallbag: unable to generate entries endpoint: %v", err)
}
if c.onlyURL {
entryContent = ""
}
requestBody, err := json.Marshal(&createEntryRequest{
URL: entryURL,
Title: entryTitle,
Content: entryContent,
Tags: tags,
})
if err != nil {
return fmt.Errorf("wallbag: unable to encode request body: %v", err)
}
request, err := http.NewRequest(http.MethodPost, apiEndpoint, bytes.NewReader(requestBody))
if err != nil {
return fmt.Errorf("wallbag: unable to create request: %v", err)
}
request.Header.Set("Content-Type", "application/json")
request.Header.Set("Accept", "application/json")
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
request.Header.Set("Authorization", "Bearer "+accessToken)
httpClient := &http.Client{Timeout: defaultClientTimeout}
response, err := httpClient.Do(request)
if err != nil {
return fmt.Errorf("wallabag: unable to send request: %v", err)
}
defer response.Body.Close()
if response.StatusCode >= 400 {
return fmt.Errorf("wallabag: unable to get save entry: url=%s status=%d", apiEndpoint, response.StatusCode)
}
return nil
}
func (c *Client) getAccessToken() (string, error) {
values := url.Values{}
values.Add("grant_type", "password")
values.Add("client_id", c.clientID)
values.Add("client_secret", c.clientSecret)
values.Add("username", c.username)
values.Add("password", c.password)
apiEndpoint, err := urllib.JoinBaseURLAndPath(c.baseURL, "/oauth/v2/token")
if err != nil {
return "", fmt.Errorf("wallbag: unable to generate token endpoint: %v", err)
}
request, err := http.NewRequest(http.MethodPost, apiEndpoint, strings.NewReader(values.Encode()))
if err != nil {
return "", fmt.Errorf("wallbag: unable to create request: %v", err)
}
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
request.Header.Set("Accept", "application/json")
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
httpClient := &http.Client{Timeout: defaultClientTimeout}
response, err := httpClient.Do(request)
if err != nil {
return "", fmt.Errorf("wallabag: unable to send request: %v", err)
}
defer response.Body.Close()
if response.StatusCode >= 400 {
return "", fmt.Errorf("wallabag: unable to get access token: url=%s status=%d", apiEndpoint, response.StatusCode)
}
var responseBody tokenResponse
if err := json.NewDecoder(response.Body).Decode(&responseBody); err != nil {
return "", fmt.Errorf("wallabag: unable to decode token response: %v", err)
}
return responseBody.AccessToken, nil
}
type tokenResponse struct {
AccessToken string `json:"access_token"`
Expires int `json:"expires_in"`
RefreshToken string `json:"refresh_token"`
Scope string `json:"scope"`
TokenType string `json:"token_type"`
}
type createEntryRequest struct {
URL string `json:"url"`
Title string `json:"title"`
Content string `json:"content,omitempty"`
Tags string `json:"tags,omitempty"`
}
v2-2.2.16/internal/integration/wallabag/wallabag_test.go 0000664 0000000 0000000 00000023464 15127074645 0023226 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package wallabag
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestCreateEntry(t *testing.T) {
entryURL := "https://example.com"
entryTitle := "title"
entryContent := "content"
tags := "tag1,tag2,tag3"
tests := []struct {
name string
username string
password string
clientID string
clientSecret string
tags string
onlyURL bool
entryURL string
entryTitle string
entryContent string
serverResponse func(w http.ResponseWriter, r *http.Request)
wantErr bool
errContains string
}{
{
name: "successful entry creation with url only",
wantErr: false,
onlyURL: true,
username: "username",
password: "password",
clientID: "clientId",
clientSecret: "clientSecret",
tags: tags,
entryURL: entryURL,
entryTitle: entryTitle,
entryContent: entryContent,
serverResponse: func(w http.ResponseWriter, r *http.Request) {
if strings.Contains(r.URL.Path, "/oauth/v2/token") {
// Return success response
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]any{
"access_token": "test-token",
"expires_in": 3600,
"refresh_token": "token",
"scope": "scope",
"token_type": "token_type",
})
return
}
// Verify authorization header
auth := r.Header.Get("Authorization")
if auth != "Bearer test-token" {
t.Errorf("Expected Authorization header 'Bearer test-token', got %s", auth)
}
// Verify content type
contentType := r.Header.Get("Content-Type")
if contentType != "application/json" {
t.Errorf("Expected Content-Type 'application/json', got %s", contentType)
}
// Parse and verify request
body, _ := io.ReadAll(r.Body)
var req map[string]any
if err := json.Unmarshal(body, &req); err != nil {
t.Errorf("Failed to parse request body: %v", err)
}
if requstEntryURL := req["url"]; requstEntryURL != entryURL {
t.Errorf("Expected entryURL %s, got %s", entryURL, requstEntryURL)
}
if requestEntryTitle := req["title"]; requestEntryTitle != entryTitle {
t.Errorf("Expected entryTitle %s, got %s", entryTitle, requestEntryTitle)
}
if _, ok := req["content"]; ok {
t.Errorf("Expected entryContent to be empty, got value")
}
if requestTags := req["tags"]; requestTags != tags {
t.Errorf("Expected tags %s, got %s", tags, requestTags)
} // Return success response
w.WriteHeader(http.StatusOK)
},
errContains: "",
},
{
name: "successful entry creation with content",
wantErr: false,
onlyURL: false,
username: "username",
password: "password",
clientID: "clientId",
clientSecret: "clientSecret",
tags: tags,
entryURL: entryURL,
entryTitle: entryTitle,
entryContent: entryContent,
serverResponse: func(w http.ResponseWriter, r *http.Request) {
if strings.Contains(r.URL.Path, "/oauth/v2/token") {
// Return success response
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]any{
"access_token": "test-token",
"expires_in": 3600,
"refresh_token": "token",
"scope": "scope",
"token_type": "token_type",
})
return
}
// Verify authorization header
auth := r.Header.Get("Authorization")
if auth != "Bearer test-token" {
t.Errorf("Expected Authorization header 'Bearer test-token', got %s", auth)
}
// Verify content type
contentType := r.Header.Get("Content-Type")
if contentType != "application/json" {
t.Errorf("Expected Content-Type 'application/json', got %s", contentType)
}
// Parse and verify request
body, _ := io.ReadAll(r.Body)
var req map[string]any
if err := json.Unmarshal(body, &req); err != nil {
t.Errorf("Failed to parse request body: %v", err)
}
if requstEntryURL := req["url"]; requstEntryURL != entryURL {
t.Errorf("Expected entryURL %s, got %s", entryURL, requstEntryURL)
}
if requestEntryTitle := req["title"]; requestEntryTitle != entryTitle {
t.Errorf("Expected entryTitle %s, got %s", entryTitle, requestEntryTitle)
}
if requestEntryContent := req["content"]; requestEntryContent != entryContent {
t.Errorf("Expected entryContent %s, got %s", entryContent, requestEntryContent)
}
if requestTags := req["tags"]; requestTags != tags {
t.Errorf("Expected tags %s, got %s", tags, requestTags)
} // Return success response
w.WriteHeader(http.StatusOK)
},
errContains: "",
},
{
name: "failed when unable to decode accessToken response",
wantErr: true,
onlyURL: true,
username: "username",
password: "password",
clientID: "clientId",
clientSecret: "clientSecret",
tags: tags,
entryURL: entryURL,
entryTitle: entryTitle,
entryContent: entryContent,
serverResponse: func(w http.ResponseWriter, r *http.Request) {
if strings.Contains(r.URL.Path, "/oauth/v2/token") {
// Return success response
w.WriteHeader(http.StatusOK)
w.Write([]byte("invalid json"))
return
}
t.Error("Server should not be called when failed to get accessToken")
},
errContains: "unable to decode token response",
},
{
name: "failed when saving entry",
wantErr: true,
onlyURL: true,
username: "username",
password: "password",
clientID: "clientId",
clientSecret: "clientSecret",
tags: tags,
entryURL: entryURL,
entryTitle: entryTitle,
entryContent: entryContent,
serverResponse: func(w http.ResponseWriter, r *http.Request) {
if strings.Contains(r.URL.Path, "/oauth/v2/token") {
// Return success response
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]any{
"access_token": "test-token",
"expires_in": 3600,
"refresh_token": "token",
"scope": "scope",
"token_type": "token_type",
})
return
}
w.WriteHeader(http.StatusUnauthorized)
},
errContains: "unable to get save entry",
},
{
name: "failure due to no accessToken",
wantErr: true,
onlyURL: false,
username: "username",
password: "password",
clientID: "clientId",
clientSecret: "clientSecret",
tags: tags,
entryURL: entryURL,
entryTitle: entryTitle,
entryContent: entryContent,
serverResponse: func(w http.ResponseWriter, r *http.Request) {
if strings.Contains(r.URL.Path, "/oauth/v2/token") {
// Return error response
w.WriteHeader(http.StatusUnauthorized)
return
}
t.Error("Server should not be called when failed to get accessToken")
},
errContains: "unable to get access token",
},
{
name: "failure due to missing client parameters",
wantErr: true,
onlyURL: false,
tags: tags,
entryURL: entryURL,
entryTitle: entryTitle,
entryContent: entryContent,
serverResponse: func(w http.ResponseWriter, r *http.Request) {
t.Error("Server should not be called when failed to get accessToken")
},
errContains: "wallabag: missing base URL, client ID, client secret, username or password",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Create test server if we have a server response function
var serverURL string
if tt.serverResponse != nil {
server := httptest.NewServer(http.HandlerFunc(tt.serverResponse))
defer server.Close()
serverURL = server.URL
}
// Create client with test server URL
client := NewClient(serverURL, tt.clientID, tt.clientSecret, tt.username, tt.password, tt.tags, tt.onlyURL)
// Call CreateBookmark
err := client.CreateEntry(tt.entryURL, tt.entryTitle, tt.entryContent)
// Check error expectations
if tt.wantErr {
if err == nil {
t.Errorf("Expected error but got none")
} else if tt.errContains != "" && !strings.Contains(err.Error(), tt.errContains) {
t.Errorf("Expected error containing '%s', got '%s'", tt.errContains, err.Error())
}
} else {
if err != nil {
t.Errorf("Unexpected error: %v", err)
}
}
})
}
}
func TestNewClient(t *testing.T) {
tests := []struct {
name string
baseURL string
clientID string
clientSecret string
username string
password string
tags string
onlyURL bool
}{
{
name: "with all parameters",
baseURL: "https://wallabag.example.com",
clientID: "clientID",
clientSecret: "clientSecret",
username: "wallabag",
password: "wallabag",
tags: "",
onlyURL: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
client := NewClient(tt.baseURL, tt.clientID, tt.clientSecret, tt.username, tt.password, tt.tags, tt.onlyURL)
if client.baseURL != tt.baseURL {
t.Errorf("Expected.baseURL %s, got %s", tt.baseURL, client.baseURL)
}
if client.username != tt.username {
t.Errorf("Expected username %s, got %s", tt.username, client.username)
}
if client.password != tt.password {
t.Errorf("Expected password %s, got %s", tt.password, client.password)
}
if client.clientID != tt.clientID {
t.Errorf("Expected clientID %s, got %s", tt.clientID, client.clientID)
}
if client.clientSecret != tt.clientSecret {
t.Errorf("Expected clientSecret %s, got %s", tt.clientSecret, client.clientSecret)
}
if client.tags != tt.tags {
t.Errorf("Expected tags %s, got %s", tt.tags, client.tags)
}
if client.onlyURL != tt.onlyURL {
t.Errorf("Expected onlyURL %v, got %v", tt.onlyURL, client.onlyURL)
}
})
}
}
v2-2.2.16/internal/integration/webhook/ 0000775 0000000 0000000 00000000000 15127074645 0017741 5 ustar 00root root 0000000 0000000 v2-2.2.16/internal/integration/webhook/webhook.go 0000664 0000000 0000000 00000013562 15127074645 0021735 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package webhook // import "miniflux.app/v2/internal/integration/webhook"
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"net/http"
"time"
"miniflux.app/v2/internal/crypto"
"miniflux.app/v2/internal/model"
"miniflux.app/v2/internal/version"
)
const (
defaultClientTimeout = 10 * time.Second
NewEntriesEventType = "new_entries"
SaveEntryEventType = "save_entry"
)
type Client struct {
webhookURL string
webhookSecret string
}
func NewClient(webhookURL, webhookSecret string) *Client {
return &Client{webhookURL, webhookSecret}
}
func (c *Client) SendSaveEntryWebhookEvent(entry *model.Entry) error {
return c.makeRequest(SaveEntryEventType, &WebhookSaveEntryEvent{
EventType: SaveEntryEventType,
Entry: &WebhookEntry{
ID: entry.ID,
UserID: entry.UserID,
FeedID: entry.FeedID,
Status: entry.Status,
Hash: entry.Hash,
Title: entry.Title,
URL: entry.URL,
CommentsURL: entry.CommentsURL,
Date: entry.Date,
CreatedAt: entry.CreatedAt,
ChangedAt: entry.ChangedAt,
Content: entry.Content,
Author: entry.Author,
ShareCode: entry.ShareCode,
Starred: entry.Starred,
ReadingTime: entry.ReadingTime,
Enclosures: entry.Enclosures,
Tags: entry.Tags,
Feed: &WebhookFeed{
ID: entry.Feed.ID,
UserID: entry.Feed.UserID,
CategoryID: entry.Feed.Category.ID,
Category: &WebhookCategory{ID: entry.Feed.Category.ID, Title: entry.Feed.Category.Title},
FeedURL: entry.Feed.FeedURL,
SiteURL: entry.Feed.SiteURL,
Title: entry.Feed.Title,
CheckedAt: entry.Feed.CheckedAt,
},
},
})
}
func (c *Client) SendNewEntriesWebhookEvent(feed *model.Feed, entries model.Entries) error {
if len(entries) == 0 {
return nil
}
webhookEntries := make([]*WebhookEntry, 0, len(entries))
for _, entry := range entries {
webhookEntries = append(webhookEntries, &WebhookEntry{
ID: entry.ID,
UserID: entry.UserID,
FeedID: entry.FeedID,
Status: entry.Status,
Hash: entry.Hash,
Title: entry.Title,
URL: entry.URL,
CommentsURL: entry.CommentsURL,
Date: entry.Date,
CreatedAt: entry.CreatedAt,
ChangedAt: entry.ChangedAt,
Content: entry.Content,
Author: entry.Author,
ShareCode: entry.ShareCode,
Starred: entry.Starred,
ReadingTime: entry.ReadingTime,
Enclosures: entry.Enclosures,
Tags: entry.Tags,
})
}
return c.makeRequest(NewEntriesEventType, &WebhookNewEntriesEvent{
EventType: NewEntriesEventType,
Feed: &WebhookFeed{
ID: feed.ID,
UserID: feed.UserID,
CategoryID: feed.Category.ID,
Category: &WebhookCategory{ID: feed.Category.ID, Title: feed.Category.Title},
FeedURL: feed.FeedURL,
SiteURL: feed.SiteURL,
Title: feed.Title,
CheckedAt: feed.CheckedAt,
},
Entries: webhookEntries,
})
}
func (c *Client) makeRequest(eventType string, payload any) error {
if c.webhookURL == "" {
return errors.New(`webhook: missing webhook URL`)
}
requestBody, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("webhook: unable to encode request body: %v", err)
}
request, err := http.NewRequest(http.MethodPost, c.webhookURL, bytes.NewReader(requestBody))
if err != nil {
return fmt.Errorf("webhook: unable to create request: %v", err)
}
request.Header.Set("Content-Type", "application/json")
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
request.Header.Set("X-Miniflux-Signature", crypto.GenerateSHA256Hmac(c.webhookSecret, requestBody))
request.Header.Set("X-Miniflux-Event-Type", eventType)
httpClient := &http.Client{Timeout: defaultClientTimeout}
response, err := httpClient.Do(request)
if err != nil {
return fmt.Errorf("webhook: unable to send request: %v", err)
}
defer response.Body.Close()
if response.StatusCode >= 400 {
return fmt.Errorf("webhook: incorrect response status code %d for url %s", response.StatusCode, c.webhookURL)
}
return nil
}
type WebhookFeed struct {
ID int64 `json:"id"`
UserID int64 `json:"user_id"`
CategoryID int64 `json:"category_id"`
Category *WebhookCategory `json:"category,omitempty"`
FeedURL string `json:"feed_url"`
SiteURL string `json:"site_url"`
Title string `json:"title"`
CheckedAt time.Time `json:"checked_at"`
}
type WebhookCategory struct {
ID int64 `json:"id"`
Title string `json:"title"`
}
type WebhookEntry struct {
ID int64 `json:"id"`
UserID int64 `json:"user_id"`
FeedID int64 `json:"feed_id"`
Status string `json:"status"`
Hash string `json:"hash"`
Title string `json:"title"`
URL string `json:"url"`
CommentsURL string `json:"comments_url"`
Date time.Time `json:"published_at"`
CreatedAt time.Time `json:"created_at"`
ChangedAt time.Time `json:"changed_at"`
Content string `json:"content"`
Author string `json:"author"`
ShareCode string `json:"share_code"`
Starred bool `json:"starred"`
ReadingTime int `json:"reading_time"`
Enclosures model.EnclosureList `json:"enclosures"`
Tags []string `json:"tags"`
Feed *WebhookFeed `json:"feed,omitempty"`
}
type WebhookNewEntriesEvent struct {
EventType string `json:"event_type"`
Feed *WebhookFeed `json:"feed"`
Entries []*WebhookEntry `json:"entries"`
}
type WebhookSaveEntryEvent struct {
EventType string `json:"event_type"`
Entry *WebhookEntry `json:"entry"`
}
v2-2.2.16/internal/locale/ 0000775 0000000 0000000 00000000000 15127074645 0015217 5 ustar 00root root 0000000 0000000 v2-2.2.16/internal/locale/catalog.go 0000664 0000000 0000000 00000004250 15127074645 0017161 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package locale // import "miniflux.app/v2/internal/locale"
import (
"embed"
"encoding/json"
"fmt"
)
type translationDict struct {
singulars map[string]string
plurals map[string][]string
}
type catalog map[string]translationDict
var defaultCatalog = make(catalog, len(AvailableLanguages))
//go:embed translations/*.json
var translationFiles embed.FS
func getTranslationDict(language string) (translationDict, error) {
if _, ok := defaultCatalog[language]; !ok {
var err error
if defaultCatalog[language], err = loadTranslationFile(language); err != nil {
return translationDict{}, err
}
}
return defaultCatalog[language], nil
}
func loadTranslationFile(language string) (translationDict, error) {
translationFileData, err := translationFiles.ReadFile("translations/" + language + ".json")
if err != nil {
return translationDict{}, err
}
translationMessages, err := parseTranslationMessages(translationFileData)
if err != nil {
return translationDict{}, err
}
return translationMessages, nil
}
func (t *translationDict) UnmarshalJSON(data []byte) error {
var tmpMap map[string]any
err := json.Unmarshal(data, &tmpMap)
if err != nil {
return err
}
m := translationDict{
singulars: make(map[string]string),
plurals: make(map[string][]string),
}
for key, value := range tmpMap {
switch vtype := value.(type) {
case string:
m.singulars[key] = vtype
case []any:
for _, translation := range vtype {
if translationStr, ok := translation.(string); ok {
m.plurals[key] = append(m.plurals[key], translationStr)
} else {
return fmt.Errorf("invalid type for translation in an array: %v", translation)
}
}
default:
return fmt.Errorf("invalid type (%T) for translation: %v", vtype, value)
}
}
*t = m
return nil
}
func parseTranslationMessages(data []byte) (translationDict, error) {
var translationMessages translationDict
if err := json.Unmarshal(data, &translationMessages); err != nil {
return translationDict{}, fmt.Errorf(`invalid translation file: %w`, err)
}
return translationMessages, nil
}
v2-2.2.16/internal/locale/catalog_test.go 0000664 0000000 0000000 00000007105 15127074645 0020222 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package locale // import "miniflux.app/v2/internal/locale"
import (
"testing"
)
func TestParserWithInvalidData(t *testing.T) {
_, err := parseTranslationMessages([]byte(`{`))
if err == nil {
t.Fatal(`An error should be returned when parsing invalid data`)
}
}
func TestParser(t *testing.T) {
translations, err := parseTranslationMessages([]byte(`{"k": "v"}`))
if err != nil {
t.Fatalf(`Unexpected parsing error: %v`, err)
}
value, found := translations.singulars["k"]
if !found {
t.Fatalf(`The translation %v should contains the defined key`, translations.singulars)
}
if value != "v" {
t.Fatal(`The translation key should contains the defined value`)
}
}
func TestLoadCatalog(t *testing.T) {
for language := range AvailableLanguages {
_, err := loadTranslationFile(language)
if err != nil {
t.Fatal(err)
}
}
}
func TestAllKeysHaveValue(t *testing.T) {
for language := range AvailableLanguages {
messages, err := loadTranslationFile(language)
if err != nil {
t.Fatalf(`Unable to load translation messages for language %q`, language)
}
if len(messages.singulars) == 0 {
t.Fatalf(`The language %q doesn't have any messages for singulars`, language)
}
if len(messages.plurals) == 0 {
t.Fatalf(`The language %q doesn't have any messages for plurals`, language)
}
for k, v := range messages.singulars {
if len(v) == 0 {
t.Errorf(`The key %q for singulars for the language %q has an empty list as value`, k, language)
}
}
for k, v := range messages.plurals {
if len(v) == 0 {
t.Errorf(`The key %q for plurals for the language %q has an empty list as value`, k, language)
}
}
}
}
func TestMissingTranslations(t *testing.T) {
refLang := "en_US"
references, err := loadTranslationFile(refLang)
if err != nil {
t.Fatal(`Unable to parse reference language`)
}
for language := range AvailableLanguages {
if language == refLang {
continue
}
messages, err := loadTranslationFile(language)
if err != nil {
t.Fatalf(`Parsing error for language %q`, language)
}
for key := range references.singulars {
if _, found := messages.singulars[key]; !found {
t.Errorf(`Translation key %q not found in language %q singulars`, key, language)
}
}
for key := range references.plurals {
if _, found := messages.plurals[key]; !found {
t.Errorf(`Translation key %q not found in language %q plurals`, key, language)
}
}
}
}
func TestTranslationFilePluralForms(t *testing.T) {
var numberOfPluralFormsPerLanguage = map[string]int{
"de_DE": 2,
"el_EL": 2,
"en_US": 2,
"es_ES": 2,
"fi_FI": 2,
"fr_FR": 2,
"hi_IN": 2,
"id_ID": 1,
"it_IT": 2,
"ja_JP": 1,
"nan_Latn_pehoeji": 1,
"nl_NL": 2,
"pl_PL": 3,
"pt_BR": 2,
"ro_RO": 3,
"ru_RU": 3,
"tr_TR": 2,
"uk_UA": 3,
"zh_CN": 1,
"zh_TW": 1,
}
for language := range AvailableLanguages {
messages, err := loadTranslationFile(language)
if err != nil {
t.Fatalf(`Unable to load translation messages for language %q`, language)
}
for k, v := range messages.plurals {
if len(v) != numberOfPluralFormsPerLanguage[language] {
t.Errorf(`The key %q for the language %q does not have the expected number of plurals, got %d instead of %d`, k, language, len(v), numberOfPluralFormsPerLanguage[language])
}
}
}
}
v2-2.2.16/internal/locale/error.go 0000664 0000000 0000000 00000002646 15127074645 0016707 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package locale // import "miniflux.app/v2/internal/locale"
import "errors"
type LocalizedErrorWrapper struct {
originalErr error
translationKey string
translationArgs []any
}
func NewLocalizedErrorWrapper(originalErr error, translationKey string, translationArgs ...any) *LocalizedErrorWrapper {
return &LocalizedErrorWrapper{
originalErr: originalErr,
translationKey: translationKey,
translationArgs: translationArgs,
}
}
func (l *LocalizedErrorWrapper) Error() error {
return l.originalErr
}
func (l *LocalizedErrorWrapper) Translate(language string) string {
if l.translationKey == "" {
return l.originalErr.Error()
}
return NewPrinter(language).Printf(l.translationKey, l.translationArgs...)
}
type LocalizedError struct {
translationKey string
translationArgs []any
}
func NewLocalizedError(translationKey string, translationArgs ...any) *LocalizedError {
return &LocalizedError{translationKey: translationKey, translationArgs: translationArgs}
}
func (v *LocalizedError) String() string {
return NewPrinter("en_US").Printf(v.translationKey, v.translationArgs...)
}
func (v *LocalizedError) Error() error {
return errors.New(v.String())
}
func (v *LocalizedError) Translate(language string) string {
return NewPrinter(language).Printf(v.translationKey, v.translationArgs...)
}
v2-2.2.16/internal/locale/error_test.go 0000664 0000000 0000000 00000020666 15127074645 0017750 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package locale // import "miniflux.app/v2/internal/locale"
import (
"errors"
"testing"
)
func TestNewLocalizedErrorWrapper(t *testing.T) {
originalErr := errors.New("original error message")
translationKey := "error.test_key"
args := []any{"arg1", 42}
wrapper := NewLocalizedErrorWrapper(originalErr, translationKey, args...)
if wrapper.originalErr != originalErr {
t.Errorf("Expected original error to be %v, got %v", originalErr, wrapper.originalErr)
}
if wrapper.translationKey != translationKey {
t.Errorf("Expected translation key to be %q, got %q", translationKey, wrapper.translationKey)
}
if len(wrapper.translationArgs) != 2 {
t.Errorf("Expected 2 translation args, got %d", len(wrapper.translationArgs))
}
if wrapper.translationArgs[0] != "arg1" || wrapper.translationArgs[1] != 42 {
t.Errorf("Expected translation args [arg1, 42], got %v", wrapper.translationArgs)
}
}
func TestLocalizedErrorWrapper_Error(t *testing.T) {
originalErr := errors.New("original error message")
wrapper := NewLocalizedErrorWrapper(originalErr, "error.test_key")
result := wrapper.Error()
if result != originalErr {
t.Errorf("Expected Error() to return original error %v, got %v", originalErr, result)
}
}
func TestLocalizedErrorWrapper_Translate(t *testing.T) {
// Set up test catalog
defaultCatalog = catalog{
"en_US": translationDict{
singulars: map[string]string{
"error.test_key": "Error: %s (code: %d)",
},
},
"fr_FR": translationDict{
singulars: map[string]string{
"error.test_key": "Erreur : %s (code : %d)",
},
},
}
originalErr := errors.New("original error")
wrapper := NewLocalizedErrorWrapper(originalErr, "error.test_key", "test message", 404)
// Test English translation
result := wrapper.Translate("en_US")
expected := "Error: test message (code: 404)"
if result != expected {
t.Errorf("Expected English translation %q, got %q", expected, result)
}
// Test French translation
result = wrapper.Translate("fr_FR")
expected = "Erreur : test message (code : 404)"
if result != expected {
t.Errorf("Expected French translation %q, got %q", expected, result)
}
// Test with missing language (should use key as fallback with args applied)
result = wrapper.Translate("invalid_lang")
expected = "error.test_key%!(EXTRA string=test message, int=404)"
if result != expected {
t.Errorf("Expected fallback translation %q, got %q", expected, result)
}
}
func TestLocalizedErrorWrapper_TranslateWithEmptyKey(t *testing.T) {
originalErr := errors.New("original error message")
wrapper := NewLocalizedErrorWrapper(originalErr, "")
result := wrapper.Translate("en_US")
expected := "original error message"
if result != expected {
t.Errorf("Expected original error message %q, got %q", expected, result)
}
}
func TestLocalizedErrorWrapper_TranslateWithNoArgs(t *testing.T) {
defaultCatalog = catalog{
"en_US": translationDict{
singulars: map[string]string{
"error.simple": "Simple error message",
},
},
}
originalErr := errors.New("original error")
wrapper := NewLocalizedErrorWrapper(originalErr, "error.simple")
result := wrapper.Translate("en_US")
expected := "Simple error message"
if result != expected {
t.Errorf("Expected translation %q, got %q", expected, result)
}
}
func TestNewLocalizedError(t *testing.T) {
translationKey := "error.validation"
args := []any{"field1", "invalid"}
localizedErr := NewLocalizedError(translationKey, args...)
if localizedErr.translationKey != translationKey {
t.Errorf("Expected translation key to be %q, got %q", translationKey, localizedErr.translationKey)
}
if len(localizedErr.translationArgs) != 2 {
t.Errorf("Expected 2 translation args, got %d", len(localizedErr.translationArgs))
}
if localizedErr.translationArgs[0] != "field1" || localizedErr.translationArgs[1] != "invalid" {
t.Errorf("Expected translation args [field1, invalid], got %v", localizedErr.translationArgs)
}
}
func TestLocalizedError_String(t *testing.T) {
defaultCatalog = catalog{
"en_US": translationDict{
singulars: map[string]string{
"error.validation": "Validation failed for %s: %s",
},
},
}
localizedErr := NewLocalizedError("error.validation", "username", "too short")
result := localizedErr.String()
expected := "Validation failed for username: too short"
if result != expected {
t.Errorf("Expected String() result %q, got %q", expected, result)
}
}
func TestLocalizedError_StringWithMissingTranslation(t *testing.T) {
defaultCatalog = catalog{
"en_US": translationDict{},
}
localizedErr := NewLocalizedError("error.missing", "arg1")
result := localizedErr.String()
expected := "error.missing%!(EXTRA string=arg1)"
if result != expected {
t.Errorf("Expected String() result %q, got %q", expected, result)
}
}
func TestLocalizedError_Error(t *testing.T) {
defaultCatalog = catalog{
"en_US": translationDict{
singulars: map[string]string{
"error.database": "Database connection failed: %s",
},
},
}
localizedErr := NewLocalizedError("error.database", "timeout")
result := localizedErr.Error()
if result == nil {
t.Error("Expected Error() to return a non-nil error")
}
expected := "Database connection failed: timeout"
if result.Error() != expected {
t.Errorf("Expected Error() message %q, got %q", expected, result.Error())
}
}
func TestLocalizedError_Translate(t *testing.T) {
defaultCatalog = catalog{
"en_US": translationDict{
singulars: map[string]string{
"error.permission": "Permission denied for %s",
},
},
"es_ES": translationDict{
singulars: map[string]string{
"error.permission": "Permiso denegado para %s",
},
},
}
localizedErr := NewLocalizedError("error.permission", "admin panel")
// Test English translation
result := localizedErr.Translate("en_US")
expected := "Permission denied for admin panel"
if result != expected {
t.Errorf("Expected English translation %q, got %q", expected, result)
}
// Test Spanish translation
result = localizedErr.Translate("es_ES")
expected = "Permiso denegado para admin panel"
if result != expected {
t.Errorf("Expected Spanish translation %q, got %q", expected, result)
}
// Test with missing language
result = localizedErr.Translate("invalid_lang")
expected = "error.permission%!(EXTRA string=admin panel)"
if result != expected {
t.Errorf("Expected fallback translation %q, got %q", expected, result)
}
}
func TestLocalizedError_TranslateWithNoArgs(t *testing.T) {
defaultCatalog = catalog{
"en_US": translationDict{
singulars: map[string]string{
"error.generic": "An error occurred",
},
},
"de_DE": translationDict{
singulars: map[string]string{
"error.generic": "Ein Fehler ist aufgetreten",
},
},
}
localizedErr := NewLocalizedError("error.generic")
// Test English
result := localizedErr.Translate("en_US")
expected := "An error occurred"
if result != expected {
t.Errorf("Expected English translation %q, got %q", expected, result)
}
// Test German
result = localizedErr.Translate("de_DE")
expected = "Ein Fehler ist aufgetreten"
if result != expected {
t.Errorf("Expected German translation %q, got %q", expected, result)
}
}
func TestLocalizedError_TranslateWithComplexArgs(t *testing.T) {
defaultCatalog = catalog{
"en_US": translationDict{
singulars: map[string]string{
"error.complex": "Error %d: %s occurred at %s with severity %s",
},
},
}
localizedErr := NewLocalizedError("error.complex", 500, "Internal Server Error", "2024-01-01", "high")
result := localizedErr.Translate("en_US")
expected := "Error 500: Internal Server Error occurred at 2024-01-01 with severity high"
if result != expected {
t.Errorf("Expected complex translation %q, got %q", expected, result)
}
}
func TestLocalizedErrorWrapper_WithNilError(t *testing.T) {
// This tests edge case behavior - what happens with nil error
wrapper := NewLocalizedErrorWrapper(nil, "error.test")
// Error() should return nil
result := wrapper.Error()
if result != nil {
t.Errorf("Expected Error() to return nil, got %v", result)
}
}
func TestLocalizedError_EmptyKey(t *testing.T) {
localizedErr := NewLocalizedError("")
result := localizedErr.String()
expected := ""
if result != expected {
t.Errorf("Expected empty string for empty key, got %q", result)
}
result = localizedErr.Translate("en_US")
if result != expected {
t.Errorf("Expected empty string for empty key translation, got %q", result)
}
}
v2-2.2.16/internal/locale/locale.go 0000664 0000000 0000000 00000001763 15127074645 0017014 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package locale // import "miniflux.app/v2/internal/locale"
// AvailableLanguages is the list of available languages.
var AvailableLanguages = map[string]string{
"de_DE": "Deutsch",
"el_EL": "Ελληνικά",
"en_US": "English",
"es_ES": "Español",
"fi_FI": "Suomi",
"fr_FR": "Français",
"hi_IN": "हिन्दी",
"id_ID": "Bahasa Indonesia",
"it_IT": "Italiano",
"ja_JP": "日本語",
"nan_Latn_pehoeji": "Pe̍h-ōe-jī",
"nl_NL": "Nederlands",
"pl_PL": "Polski",
"pt_BR": "Português Brasileiro",
"ro_RO": "Română",
"ru_RU": "Русский",
"tr_TR": "Türkçe",
"uk_UA": "Українська",
"zh_CN": "简体中文",
"zh_TW": "繁體中文",
}
v2-2.2.16/internal/locale/locale_test.go 0000664 0000000 0000000 00000001042 15127074645 0020041 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package locale // import "miniflux.app/v2/internal/locale"
import "testing"
func TestAvailableLanguages(t *testing.T) {
results := AvailableLanguages
for k, v := range results {
if k == "" {
t.Errorf(`Empty language key detected`)
}
if v == "" {
t.Errorf(`Empty language value detected`)
}
}
if _, found := results["en_US"]; !found {
t.Errorf(`We must have at least the default language (en_US)`)
}
}
v2-2.2.16/internal/locale/plural.go 0000664 0000000 0000000 00000002560 15127074645 0017050 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package locale // import "miniflux.app/v2/internal/locale"
// See https://localization-guide.readthedocs.io/en/latest/l10n/pluralforms.html
// And http://www.unicode.org/cldr/charts/29/supplemental/language_plural_rules.html
func getPluralForm(lang string, n int) int {
switch lang {
case "ar_AR":
switch {
case n == 0:
return 0
case n == 1:
return 1
case n == 2:
return 2
case n%100 >= 3 && n%100 <= 10:
return 3
case n%100 >= 11:
return 4
default:
return 5
}
case "cs_CZ":
switch {
case n == 1:
return 0
case n >= 2 && n <= 4:
return 1
default:
return 2
}
case "id_ID", "ja_JP":
return 0
case "pl_PL":
switch {
case n == 1:
return 0
case n%10 >= 2 && n%10 <= 4 && (n%100 < 10 || n%100 >= 20):
return 1
default:
return 2
}
case "ro_RO":
switch {
case n == 1:
return 0
case n == 0 || (n%100 > 0 && n%100 < 20):
return 1
default:
return 2
}
case "ru_RU", "uk_UA", "sr_RS":
switch {
case n%10 == 1 && n%100 != 11:
return 0
case n%10 >= 2 && n%10 <= 4 && (n%100 < 10 || n%100 >= 20):
return 1
default:
return 2
}
case "zh_CN", "zh_TW", "nan_Latn_pehoeji":
return 0
default: // includes fr_FR, pr_BR, tr_TR
if n > 1 {
return 1
}
return 0
}
}
v2-2.2.16/internal/locale/plural_test.go 0000664 0000000 0000000 00000012104 15127074645 0020102 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package locale // import "miniflux.app/v2/internal/locale"
import "testing"
func TestPluralRules(t *testing.T) {
scenarios := map[string]map[int]int{
// Default rule (covers fr_FR, pt_BR, tr_TR, and other unlisted languages)
"default": {
0: 0, // n <= 1
1: 0, // n <= 1
2: 1, // n > 1
5: 1, // n > 1
},
// Arabic (ar_AR) - 6 forms
"ar_AR": {
0: 0, // n == 0
1: 1, // n == 1
2: 2, // n == 2
3: 3, // n%100 >= 3 && n%100 <= 10
5: 3, // n%100 >= 3 && n%100 <= 10
10: 3, // n%100 >= 3 && n%100 <= 10
11: 4, // n%100 >= 11
15: 4, // n%100 >= 11
99: 4, // n%100 >= 11
100: 5, // default case (n%100 == 0, doesn't match any condition)
101: 5, // default case (n%100 == 1, but n != 1)
200: 5, // default case
},
// Czech (cs_CZ) - 3 forms
"cs_CZ": {
1: 0, // n == 1
2: 1, // n >= 2 && n <= 4
3: 1, // n >= 2 && n <= 4
4: 1, // n >= 2 && n <= 4
5: 2, // default case
},
// French (fr_FR) - uses default rule
"fr_FR": {
0: 0, // n <= 1
1: 0, // n <= 1
2: 1, // n > 1
5: 1, // n > 1
},
// Indonesian (id_ID) - always form 0
"id_ID": {
0: 0,
1: 0,
5: 0,
100: 0,
},
// Japanese (ja_JP) - always form 0
"ja_JP": {
0: 0,
1: 0,
2: 0,
5: 0,
100: 0,
},
// Polish (pl_PL) - 3 forms
"pl_PL": {
1: 0, // n == 1
2: 1, // n%10 >= 2 && n%10 <= 4 && (n%100 < 10 || n%100 >= 20)
3: 1, // n%10 >= 2 && n%10 <= 4 && (n%100 < 10 || n%100 >= 20)
4: 1, // n%10 >= 2 && n%10 <= 4 && (n%100 < 10 || n%100 >= 20)
5: 2, // default case
10: 2, // default case (n%100 < 10, but n%10 not in 2-4)
11: 2, // default case (n%100 >= 10 and < 20)
12: 2, // default case (n%100 >= 10 and < 20)
22: 1, // n%10 >= 2 && n%10 <= 4 && (n%100 >= 20)
24: 1, // n%10 >= 2 && n%10 <= 4 && (n%100 >= 20)
},
// Portuguese Brazilian (pt_BR) - uses default rule
"pt_BR": {
0: 0, // n <= 1
1: 0, // n <= 1
2: 1, // n > 1
5: 1, // n > 1
},
// Romanian (ro_RO) - 3 forms
"ro_RO": {
0: 1, // n == 0 || (n%100 > 0 && n%100 < 20)
1: 0, // n == 1
2: 1, // n == 0 || (n%100 > 0 && n%100 < 20)
5: 1, // n == 0 || (n%100 > 0 && n%100 < 20)
19: 1, // n == 0 || (n%100 > 0 && n%100 < 20)
20: 2, // default case
21: 2, // default case
100: 2, // default case (n%100 == 0, so condition fails)
101: 1, // n%100 == 1, so n%100 > 0 && n%100 < 20
},
// Russian (ru_RU) - 3 forms
"ru_RU": {
1: 0, // n%10 == 1 && n%100 != 11
2: 1, // n%10 >= 2 && n%10 <= 4 && (n%100 < 10 || n%100 >= 20)
3: 1, // n%10 >= 2 && n%10 <= 4 && (n%100 < 10 || n%100 >= 20)
4: 1, // n%10 >= 2 && n%10 <= 4 && (n%100 < 10 || n%100 >= 20)
5: 2, // default case
11: 2, // n%10 == 1 but n%100 == 11, so default case
12: 2, // default case
21: 0, // n%10 == 1 && n%100 != 11
22: 1, // n%10 >= 2 && n%10 <= 4 && (n%100 >= 20)
},
// Serbian (sr_RS) - same as Russian
"sr_RS": {
1: 0, // n%10 == 1 && n%100 != 11
2: 1, // n%10 >= 2 && n%10 <= 4 && (n%100 < 10 || n%100 >= 20)
5: 2, // default case
11: 2, // n%10 == 1 but n%100 == 11, so default case
21: 0, // n%10 == 1 && n%100 != 11
},
// Turkish (tr_TR) - uses default rule
"tr_TR": {
0: 0, // n <= 1
1: 0, // n <= 1
2: 1, // n > 1
5: 1, // n > 1
},
// Ukrainian (uk_UA) - same as Russian
"uk_UA": {
1: 0, // n%10 == 1 && n%100 != 11
2: 1, // n%10 >= 2 && n%10 <= 4 && (n%100 < 10 || n%100 >= 20)
5: 2, // default case
11: 2, // n%10 == 1 but n%100 == 11, so default case
21: 0, // n%10 == 1 && n%100 != 11
},
// Chinese Simplified (zh_CN) - always form 0
"zh_CN": {
0: 0,
1: 0,
5: 0,
100: 0,
},
// Chinese Traditional (zh_TW) - always form 0
"zh_TW": {
0: 0,
1: 0,
5: 0,
100: 0,
},
// Min Nan (nan_Latn_pehoeji) - always form 0
"nan_Latn_pehoeji": {
0: 0,
1: 0,
5: 0,
100: 0,
},
// Additional languages from AvailableLanguages that use default rule
"de_DE": {
0: 0, // n <= 1
1: 0, // n <= 1
2: 1, // n > 1
},
"el_EL": {
0: 0, // n <= 1
1: 0, // n <= 1
2: 1, // n > 1
},
"en_US": {
0: 0, // n <= 1
1: 0, // n <= 1
2: 1, // n > 1
},
"es_ES": {
0: 0, // n <= 1
1: 0, // n <= 1
2: 1, // n > 1
},
"fi_FI": {
0: 0, // n <= 1
1: 0, // n <= 1
2: 1, // n > 1
},
"hi_IN": {
0: 0, // n <= 1
1: 0, // n <= 1
2: 1, // n > 1
},
"it_IT": {
0: 0, // n <= 1
1: 0, // n <= 1
2: 1, // n > 1
},
"nl_NL": {
0: 0, // n <= 1
1: 0, // n <= 1
2: 1, // n > 1
},
// Test a language not in the switch (should use default rule)
"unknown_language": {
0: 0, // n <= 1
1: 0, // n <= 1
2: 1, // n > 1
},
}
for rule, values := range scenarios {
for input, expected := range values {
result := getPluralForm(rule, input)
if result != expected {
t.Errorf(`Unexpected result for %q rule, got %d instead of %d for %d as input`, rule, result, expected, input)
}
}
}
}
v2-2.2.16/internal/locale/printer.go 0000664 0000000 0000000 00000002307 15127074645 0017233 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package locale // import "miniflux.app/v2/internal/locale"
import "fmt"
// Printer converts translation keys to language-specific strings.
type Printer struct {
language string
}
// NewPrinter creates a new Printer instance for the given language.
func NewPrinter(language string) *Printer {
return &Printer{language}
}
func (p *Printer) Print(key string) string {
if dict, err := getTranslationDict(p.language); err == nil {
if str, ok := dict.singulars[key]; ok {
return str
}
}
return key
}
// Printf is like fmt.Printf, but using language-specific formatting.
func (p *Printer) Printf(key string, args ...any) string {
return fmt.Sprintf(p.Print(key), args...)
}
// Plural returns the translation of the given key by using the language plural form.
func (p *Printer) Plural(key string, n int, args ...any) string {
dict, err := getTranslationDict(p.language)
if err != nil {
return key
}
if choices, found := dict.plurals[key]; found {
index := getPluralForm(p.language, n)
if len(choices) > index {
return fmt.Sprintf(choices[index], args...)
}
}
return key
}
v2-2.2.16/internal/locale/printer_test.go 0000664 0000000 0000000 00000021610 15127074645 0020270 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package locale // import "miniflux.app/v2/internal/locale"
import "testing"
func TestPrintfWithMissingLanguage(t *testing.T) {
defaultCatalog = catalog{}
translation := NewPrinter("invalid").Printf("missing.key")
if translation != "missing.key" {
t.Errorf(`Wrong translation, got %q`, translation)
}
}
func TestPrintfWithMissingKey(t *testing.T) {
defaultCatalog = catalog{
"en_US": translationDict{
singulars: map[string]string{
"k": "v",
},
},
}
translation := NewPrinter("en_US").Printf("missing.key")
if translation != "missing.key" {
t.Errorf(`Wrong translation, got %q`, translation)
}
}
func TestPrintfWithExistingKey(t *testing.T) {
defaultCatalog = catalog{
"en_US": translationDict{
singulars: map[string]string{
"auth.username": "Login",
},
},
}
translation := NewPrinter("en_US").Printf("auth.username")
if translation != "Login" {
t.Errorf(`Wrong translation, got %q`, translation)
}
}
func TestPrintfWithExistingKeyAndPlaceholder(t *testing.T) {
defaultCatalog = catalog{
"en_US": translationDict{
singulars: map[string]string{
"key": "Test: %s",
},
},
"fr_FR": translationDict{
singulars: map[string]string{
"key": "Test : %s",
},
},
}
translation := NewPrinter("fr_FR").Printf("key", "ok")
if translation != "Test : ok" {
t.Errorf(`Wrong translation, got %q`, translation)
}
}
func TestPrintfWithMissingKeyAndPlaceholder(t *testing.T) {
defaultCatalog = catalog{
"en_US": translationDict{
singulars: map[string]string{
"auth.username": "Login",
},
},
"fr_FR": translationDict{
singulars: map[string]string{
"auth.username": "Identifiant",
},
},
}
translation := NewPrinter("fr_FR").Printf("Status: %s", "ok")
if translation != "Status: ok" {
t.Errorf(`Wrong translation, got %q`, translation)
}
}
func TestPrintWithMissingLanguage(t *testing.T) {
defaultCatalog = catalog{}
translation := NewPrinter("invalid").Print("missing.key")
if translation != "missing.key" {
t.Errorf(`Wrong translation, got %q`, translation)
}
}
func TestPrintWithMissingKey(t *testing.T) {
defaultCatalog = catalog{
"en_US": translationDict{
singulars: map[string]string{
"existing.key": "value",
},
},
}
translation := NewPrinter("en_US").Print("missing.key")
if translation != "missing.key" {
t.Errorf(`Wrong translation, got %q`, translation)
}
}
func TestPrintWithExistingKey(t *testing.T) {
defaultCatalog = catalog{
"en_US": translationDict{
singulars: map[string]string{
"auth.username": "Login",
},
},
}
translation := NewPrinter("en_US").Print("auth.username")
if translation != "Login" {
t.Errorf(`Wrong translation, got %q`, translation)
}
}
func TestPrintWithDifferentLanguages(t *testing.T) {
defaultCatalog = catalog{
"en_US": translationDict{
singulars: map[string]string{
"greeting": "Hello",
},
},
"fr_FR": translationDict{
singulars: map[string]string{
"greeting": "Bonjour",
},
},
"es_ES": translationDict{
singulars: map[string]string{
"greeting": "Hola",
},
},
}
tests := []struct {
language string
expected string
}{
{"en_US", "Hello"},
{"fr_FR", "Bonjour"},
{"es_ES", "Hola"},
}
for _, test := range tests {
translation := NewPrinter(test.language).Print("greeting")
if translation != test.expected {
t.Errorf(`Wrong translation for %s, got %q instead of %q`, test.language, translation, test.expected)
}
}
}
func TestPrintWithEmptyKey(t *testing.T) {
defaultCatalog = catalog{
"en_US": translationDict{
singulars: map[string]string{
"": "empty key translation",
},
},
}
translation := NewPrinter("en_US").Print("")
if translation != "empty key translation" {
t.Errorf(`Wrong translation for empty key, got %q`, translation)
}
}
func TestPrintWithEmptyTranslation(t *testing.T) {
defaultCatalog = catalog{
"en_US": translationDict{
singulars: map[string]string{
"empty.value": "",
},
},
}
translation := NewPrinter("en_US").Print("empty.value")
if translation != "" {
t.Errorf(`Wrong translation for empty value, got %q`, translation)
}
}
func TestPluralWithDefaultRule(t *testing.T) {
defaultCatalog = catalog{
"en_US": translationDict{
plurals: map[string][]string{
"number_of_users": {"%d user (%s)", "%d users (%s)"},
},
},
"fr_FR": translationDict{
plurals: map[string][]string{
"number_of_users": {"%d utilisateur (%s)", "%d utilisateurs (%s)"},
},
},
}
printer := NewPrinter("fr_FR")
translation := printer.Plural("number_of_users", 1, 1, "some text")
expected := "1 utilisateur (some text)"
if translation != expected {
t.Errorf(`Wrong translation, got %q instead of %q`, translation, expected)
}
translation = printer.Plural("number_of_users", 2, 2, "some text")
expected = "2 utilisateurs (some text)"
if translation != expected {
t.Errorf(`Wrong translation, got %q instead of %q`, translation, expected)
}
}
func TestPluralWithRussianRule(t *testing.T) {
defaultCatalog = catalog{
"en_US": translationDict{
plurals: map[string][]string{
"time_elapsed.years": {"%d year", "%d years"},
},
},
"ru_RU": translationDict{
plurals: map[string][]string{
"time_elapsed.years": {"%d год назад", "%d года назад", "%d лет назад"},
},
},
}
printer := NewPrinter("ru_RU")
translation := printer.Plural("time_elapsed.years", 1, 1)
expected := "1 год назад"
if translation != expected {
t.Errorf(`Wrong translation, got %q instead of %q`, translation, expected)
}
translation = printer.Plural("time_elapsed.years", 2, 2)
expected = "2 года назад"
if translation != expected {
t.Errorf(`Wrong translation, got %q instead of %q`, translation, expected)
}
translation = printer.Plural("time_elapsed.years", 5, 5)
expected = "5 лет назад"
if translation != expected {
t.Errorf(`Wrong translation, got %q instead of %q`, translation, expected)
}
}
func TestPluralWithMissingTranslation(t *testing.T) {
defaultCatalog = catalog{
"en_US": translationDict{
plurals: map[string][]string{
"number_of_users": {"%d user (%s)", "%d users (%s)"},
},
},
"fr_FR": translationDict{},
}
translation := NewPrinter("fr_FR").Plural("number_of_users", 2)
expected := "number_of_users"
if translation != expected {
t.Errorf(`Wrong translation, got %q instead of %q`, translation, expected)
}
}
func TestPluralWithMissingLanguage(t *testing.T) {
defaultCatalog = catalog{}
translation := NewPrinter("invalid_language").Plural("test.key", 2)
expected := "test.key"
if translation != expected {
t.Errorf(`Wrong translation, got %q instead of %q`, translation, expected)
}
}
func TestPluralWithIndexOutOfBounds(t *testing.T) {
defaultCatalog = catalog{
"test_lang": translationDict{
plurals: map[string][]string{
"limited.key": {"only one form"},
},
},
}
// Force a scenario where getPluralForm might return an index >= len(plurals)
// We'll create a scenario with Czech language rules
defaultCatalog["cs_CZ"] = translationDict{
plurals: map[string][]string{
"limited.key": {"one form only"}, // Only one form, but Czech has 3 plural forms
},
}
printer := NewPrinter("cs_CZ")
// n=5 should return index 2 for Czech, but we only have 1 form (index 0)
translation := printer.Plural("limited.key", 5)
expected := "limited.key"
if translation != expected {
t.Errorf(`Wrong translation for out of bounds index, got %q instead of %q`, translation, expected)
}
}
func TestPluralWithVariousLanguageRules(t *testing.T) {
defaultCatalog = catalog{
"ar_AR": translationDict{
plurals: map[string][]string{
"items": {"no items", "one item", "two items", "few items", "many items", "other items"},
},
},
"pl_PL": translationDict{
plurals: map[string][]string{
"files": {"one file", "few files", "many files"},
},
},
"ja_JP": translationDict{
plurals: map[string][]string{
"photos": {"photos"},
},
},
}
tests := []struct {
language string
key string
n int
expected string
}{
// Arabic tests
{"ar_AR", "items", 0, "no items"},
{"ar_AR", "items", 1, "one item"},
{"ar_AR", "items", 2, "two items"},
{"ar_AR", "items", 5, "few items"}, // n%100 >= 3 && n%100 <= 10
{"ar_AR", "items", 15, "many items"}, // n%100 >= 11
// Polish tests
{"pl_PL", "files", 1, "one file"},
{"pl_PL", "files", 3, "few files"}, // n%10 >= 2 && n%10 <= 4
{"pl_PL", "files", 5, "many files"}, // default case
// Japanese tests (always uses same form)
{"ja_JP", "photos", 1, "photos"},
{"ja_JP", "photos", 10, "photos"},
}
for _, test := range tests {
printer := NewPrinter(test.language)
translation := printer.Plural(test.key, test.n)
if translation != test.expected {
t.Errorf(`Wrong translation for %s with n=%d, got %q instead of %q`,
test.language, test.n, translation, test.expected)
}
}
}
v2-2.2.16/internal/locale/translations/ 0000775 0000000 0000000 00000000000 15127074645 0017740 5 ustar 00root root 0000000 0000000 v2-2.2.16/internal/locale/translations/de_DE.json 0000664 0000000 0000000 00000117022 15127074645 0021576 0 ustar 00root root 0000000 0000000 {
"action.cancel": "abbrechen",
"action.download": "Herunterladen",
"action.edit": "Bearbeiten",
"action.home_screen": "Zum Startbildschirm hinzufügen",
"action.import": "Importieren",
"action.login": "Anmelden",
"action.or": "oder",
"action.remove": "Entfernen",
"action.remove_feed": "Dieses Abonnement entfernen",
"action.save": "Speichern",
"action.subscribe": "Abonnieren",
"action.update": "Aktualisieren",
"alert.account_linked": "Ihr externes Konto wurde verknüpft!",
"alert.account_unlinked": "Ihr externer Account ist jetzt getrennt!",
"alert.background_feed_refresh": "Alle Abonnements werden derzeit im Hintergrund aktualisiert. Sie können Miniflux weiterhin benutzen, während dieser Prozess ausgeführt wird.",
"alert.feed_error": "Es gibt ein Problem mit diesem Abonnement",
"alert.no_starred": "Es existieren derzeit keine markierten Artikel.",
"alert.no_category": "Es ist keine Kategorie vorhanden.",
"alert.no_category_entry": "Es befindet sich kein Artikel in dieser Kategorie.",
"alert.no_feed": "Es sind keine Abonnements vorhanden.",
"alert.no_feed_entry": "Es existiert kein Artikel für dieses Abonnement.",
"alert.no_feed_in_category": "Für diese Kategorie gibt es kein Abonnement.",
"alert.no_history": "Es existiert zur Zeit kein Verlauf.",
"alert.no_search_result": "Es gibt kein Ergebnis für diese Suche.",
"alert.no_shared_entry": "Es existieren derzeit keine geteilten Artikel.",
"alert.no_tag_entry": "Es gibt keine Artikel, die diesem Tag entsprechen.",
"alert.no_unread_entry": "Es existiert kein ungelesener Artikel.",
"alert.no_user": "Sie sind der einzige Benutzer.",
"alert.prefs_saved": "Einstellungen gespeichert!",
"alert.too_many_feeds_refresh": [
"Sie haben zu viele Aktualisierungen ausgelöst. Bitte warten Sie %d Minute, bevor Sie es erneut versuchen.",
"Sie haben zu viele Aktualisierungen ausgelöst. Bitte warten Sie %d Minuten, bevor Sie es erneut versuchen."
],
"confirm.loading": "In Arbeit...",
"confirm.no": "nein",
"confirm.question": "Sind Sie sicher?",
"confirm.question.refresh": "Möchten Sie eine erzwungene Aktualisierung durchführen?",
"confirm.yes": "ja",
"enclosure_media_controls.seek": "Vorspulen:",
"enclosure_media_controls.seek.title": "%s Sekunden vorspulen",
"enclosure_media_controls.speed": "Geschwindigkeit:",
"enclosure_media_controls.speed.faster": "Schneller",
"enclosure_media_controls.speed.faster.title": "%sx schneller",
"enclosure_media_controls.speed.reset": "Zurücksetzen",
"enclosure_media_controls.speed.reset.title": "Wiedergabegeschwindigkeit auf 1x zurücksetzen",
"enclosure_media_controls.speed.slower": "Langsamer",
"enclosure_media_controls.speed.slower.title": "%sx langsamer",
"entry.starred.toast.off": "Nicht markiert",
"entry.starred.toast.on": "Markiert",
"entry.starred.toggle.off": "Markierung entfernen",
"entry.starred.toggle.on": "Markierung hinzufügen",
"entry.comments.label": "Kommentare",
"entry.comments.title": "Kommentare anzeigen",
"entry.estimated_reading_time": [
"%d Minute zu lesen",
"%d Minuten zu lesen"
],
"entry.external_link.label": "Externer Link",
"entry.save.completed": "Erledigt!",
"entry.save.label": "Speichern",
"entry.save.title": "Diesen Artikel speichern",
"entry.save.toast.completed": "Artikel gespeichert",
"entry.scraper.completed": "Erledigt!",
"entry.scraper.label": "Herunterladen",
"entry.scraper.title": "Inhalt herunterladen",
"entry.share.label": "Teilen",
"entry.share.title": "Diesen Artikel teilen",
"entry.shared_entry.label": "Teilen",
"entry.shared_entry.title": "Öffnen Sie den öffentlichen Link",
"entry.state.loading": "Lade...",
"entry.state.saving": "Speichern...",
"entry.status.mark_as_read": "Als gelesen markieren",
"entry.status.mark_as_unread": "Als ungelesen markieren",
"entry.status.title": "Status des Artikels ändern",
"entry.status.toast.read": "Als gelesen markiert",
"entry.status.toast.unread": "Als ungelesen markiert",
"entry.tags.label": "Stichworte:",
"entry.tags.more_tags_label": [
"Zeige %d weiteres Schlagwort",
"Zeige %d weitere Schlagwörter"
],
"entry.unshare.label": "Nicht teilen",
"error.api_key_already_exists": "Dieser API-Schlüssel ist bereits vorhanden.",
"error.bad_credentials": "Benutzername oder Passwort ungültig.",
"error.category_already_exists": "Diese Kategorie existiert bereits.",
"error.category_not_found": "Diese Kategorie existiert nicht oder gehört nicht zu diesem Benutzer.",
"error.database_error": "Datenbank-Fehler: %v.",
"error.different_passwords": "Passwörter stimmen nicht überein.",
"error.duplicate_fever_username": "Es existiert bereits jemand mit diesem Fever-Benutzernamen!",
"error.duplicate_googlereader_username": "Es existiert bereits jemand mit diesem Google-Reader-Benutzernamen!",
"error.duplicate_linked_account": "Es ist bereits jemand mit diesem Anbieter assoziiert!",
"error.duplicated_feed": "Dieses Abonnement existiert bereits.",
"error.empty_file": "Diese Datei ist leer.",
"error.entries_per_page_invalid": "Die Anzahl der Artikel pro Seite ist ungültig.",
"error.feed_already_exists": "Dieser Feed existiert bereits.",
"error.feed_category_not_found": "Diese Kategorie existiert nicht oder gehört nicht zu diesem Benutzer.",
"error.feed_format_not_detected": "Das Format des Abonnements kann nicht erkannt werden: %v.",
"error.feed_invalid_blocklist_rule": "Die Blockierregel ist ungültig.",
"error.feed_invalid_keeplist_rule": "Die Erlaubnisregel ist ungültig.",
"error.feed_mandatory_fields": "Die URL und die Kategorie sind obligatorisch.",
"error.feed_not_found": "Dieses Abonnement existiert nicht oder gehört nicht zu diesem Benutzer.",
"error.feed_title_not_empty": "Der Feed-Titel darf nicht leer sein.",
"error.feed_url_not_empty": "Der Feed-URL darf nicht leer sein.",
"error.fields_mandatory": "Alle Felder sind obligatorisch.",
"error.http_bad_gateway": "Die Webseite ist aufgrund eines Bad-Gateway-Fehlers derzeit nicht verfügbar. Das Problem liegt nicht bei Miniflux. Bitte versuchen Sie es später erneut.",
"error.http_body_read": "Der HTTP-Inhalt kann nicht gelesen werden: %v",
"error.http_client_error": "HTTP-Client-Fehler: %v.",
"error.http_empty_response": "Die HTTP-Antwort ist leer. Vielleicht versucht die Webseite, sich vor Bots zu schützen?",
"error.http_empty_response_body": "Der Inhalt der HTTP-Antwort ist leer.",
"error.http_forbidden": "Der Zugriff auf diese Webseite ist verboten. Vielleicht versucht die Webseite, sich vor Bots zu schützen?",
"error.http_gateway_timeout": "Die Webseite ist aufgrund eines Gateway-Timeout-Fehlers derzeit nicht verfügbar. Das Problem liegt nicht bei Miniflux. Bitte versuchen Sie es später erneut.",
"error.http_internal_server_error": "Die Webseite steht durch einen Server-Fehler derzeit nicht zur Verfügung. Versuchen Sie es bitte später erneut.",
"error.http_not_authorized": "Der Zugriff auf diese Website ist nicht erlaubt. Möglicherweise ist der Benutzername oder das Passwort falsch.",
"error.http_resource_not_found": "Die gewünschte Quelle wurde nicht gefunden. Bitte stellen Sie sicher, dass die URL korrekt ist.",
"error.http_response_too_large": "Die HTTP-Antwort ist zu groß. Sie könnten die Grenze für die Größe der HTTP-Antwort in den globalen Einstellungen erhöhen (benötigt einen Neustart des Servers)",
"error.http_service_unavailable": "Die Webseite ist aufgrund eines Internal-Server-Fehlers derzeit nicht verfügbar. Das Problem liegt nicht bei Miniflux. Bitte versuchen Sie es später erneut.",
"error.http_too_many_requests": "Miniflux hat zu viele Anfragen an diese Webseite gestellt. Bitte versuchen Sie es später erneut oder ändern Sie die Konfiguration der Anwendung.",
"error.http_unexpected_status_code": "Die Webseite ist aufgrund eines eines unerwarteten HTTP-Fehlers derzeit nicht verfügbar: %d. Das Problem liegt nicht bei Miniflux. Bitte versuchen Sie es später erneut.",
"error.invalid_categories_sorting_order": "Ungültige Kategorie-Sortierreihenfolge.",
"error.invalid_default_home_page": "Ungültige Standard-Startseite!",
"error.invalid_display_mode": "Progressive-Web-App- (PWA-)Anzeigemodus",
"error.invalid_entry_direction": "Ungültige Sortierreihenfolge.",
"error.invalid_entry_order": "Ungültige Sortierreihenfolge.",
"error.invalid_feed_proxy_url": "Ungültige Proxy-URL.",
"error.invalid_feed_url": "Ungültiger Feed-URL.",
"error.invalid_gesture_nav": "Ungültige Gestennavigation.",
"error.invalid_language": "Ungültige Sprache.",
"error.invalid_site_url": "Ungültiger Site-URL.",
"error.invalid_theme": "Ungültiges Thema.",
"error.invalid_timezone": "Ungültige Zeitzone.",
"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.password_min_length": "Wenigstens 6 Zeichen müssen genutzt werden.",
"error.proxy_url_not_empty": "Die Proxy-URL darf nicht leer sein.",
"error.settings_block_rule_fieldname_invalid": "Ungültige Blockierregel: Regel #%d hat keinen gültigen Feldnamen (Optionen: %s)",
"error.settings_block_rule_invalid_regex": "Ungültige Blockierregel: Das Muster für Regel #%d ist kein zulässiger regulärer Ausdruck",
"error.settings_block_rule_regex_required": "Ungültige Blockierregel: Regel #%d hat kein Muster",
"error.settings_block_rule_separator_required": "Ungültige Blockierregel: Das Muster für Regel #%d muss per '=' getrennt werden",
"error.settings_invalid_domain_list": "Ungültige Domainliste. Bitte geben Sie eine per Leerzeichen getrennte Liste von Domains an.",
"error.settings_keep_rule_fieldname_invalid": "Ungültige Erlaubnisregel: Regel #%d hat keinen gültigen Feldnamen (Optionen: %s)",
"error.settings_keep_rule_invalid_regex": "Ungültige Erlaubnisregel: Das Muster für Regel #%d ist kein zulässiger regulärer Ausdruck",
"error.settings_keep_rule_regex_required": "Ungültige Erlaubnisregel: Regel #%d hat kein Muster",
"error.settings_keep_rule_separator_required": "Ungültige Erlaubnisregel: Das Muster für Regel #%d muss per '=' getrennt werden",
"error.settings_mandatory_fields": "Die Felder für Benutzername, Thema, Sprache und Zeitzone sind obligatorisch.",
"error.settings_media_playback_rate_range": "Die Wiedergabegeschwindigkeit liegt außerhalb des Bereichs",
"error.settings_reading_speed_is_positive": "Die Lesegeschwindigkeiten müssen positive ganze Zahlen sein.",
"error.site_url_not_empty": "Der Site-URL darf nicht leer sein.",
"error.subscription_not_found": "Es wurden keine Abonnements gefunden.",
"error.title_required": "Der Titel ist obligatorisch.",
"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.unable_to_create_api_key": "Dieser API-Schlüssel kann nicht erstellt werden.",
"error.unable_to_create_category": "Diese Kategorie konnte nicht angelegt werden.",
"error.unable_to_create_user": "Dieser Benutzer kann nicht erstellt werden.",
"error.unable_to_detect_rssbridge": "Abonnement kann nicht durch RSS-Bridge erkannt werden: %v.",
"error.unable_to_parse_feed": "Dieses Abonnement kann nicht gelesen werden: %v.",
"error.unable_to_update_category": "Diese Kategorie konnte nicht aktualisiert werden.",
"error.unable_to_update_feed": "Dieses Abonnement konnte nicht aktualisiert werden.",
"error.unable_to_update_user": "Dieser Benutzer konnte nicht aktualisiert werden.",
"error.unlink_account_without_password": "Sie müssen ein Passwort festlegen, sonst können Sie sich nicht erneut anmelden.",
"error.user_already_exists": "Dieser Benutzer existiert bereits.",
"error.user_mandatory_fields": "Der Benutzername ist obligatorisch.",
"error.linktaco_missing_required_fields": "LinkTaco API Token und Organization Slug sind erforderlich.",
"form.api_key.label.description": "API-Schlüsselbezeichnung",
"form.category.hide_globally": "Artikel in der globalen Ungelesen-Liste ausblenden",
"form.category.label.title": "Titel",
"form.feed.fieldset.general": "Allgemein",
"form.feed.fieldset.integration": "Drittanbieter-Dienste",
"form.feed.fieldset.network_settings": "Netzwerkeinstellungen",
"form.feed.fieldset.rules": "Regeln",
"form.feed.label.allow_self_signed_certificates": "Erlaube selbstsignierte oder ungültige Zertifikate",
"form.feed.label.apprise_service_urls": "Kommaseparierte Liste der Apprise-Service-URLs",
"form.feed.label.block_filter_entry_rules": "Eintrags-Sperrregeln",
"form.feed.label.blocklist_rules": "Regex-basierte Sperrfilter",
"form.feed.label.category": "Kategorie",
"form.feed.label.cookie": "Cookies setzen",
"form.feed.label.crawler": "Originalinhalt herunterladen",
"form.feed.label.description": "Beschreibung",
"form.feed.label.disable_http2": "HTTP/2 deaktivieren, um Fingerprinting zu verhindern",
"form.feed.label.disabled": "Dieses Abonnement nicht aktualisieren",
"form.feed.label.feed_password": "Passwort des Abonnements",
"form.feed.label.feed_url": "URL des Abonnements",
"form.feed.label.feed_username": "Benutzername des Abonnements",
"form.feed.label.fetch_via_proxy": "Den auf Anwendungsebene konfigurierten Proxy verwenden",
"form.feed.label.hide_globally": "Artikel in der globalen Ungelesen-Liste ausblenden",
"form.feed.label.ignore_http_cache": "Ignoriere HTTP-Cache",
"form.feed.label.keep_filter_entry_rules": "Eintrags-Erlaubnisregeln",
"form.feed.label.keeplist_rules": "Regex-basierte Behalte-Filter",
"form.feed.label.no_media_player": "Kein Media-Player (Audio/Video)",
"form.feed.label.ntfy_activate": "Artikel zu ntfy pushen",
"form.feed.label.ntfy_default_priority": "Normale Ntfy-Priorität",
"form.feed.label.ntfy_high_priority": "Hohe Ntfy-Priorität",
"form.feed.label.ntfy_low_priority": "Niedrige Ntfy-Priorität",
"form.feed.label.ntfy_max_priority": "Höchste Ntfy-Priorität",
"form.feed.label.ntfy_min_priority": "Niedrigste Ntfy-Priorität",
"form.feed.label.ntfy_priority": "Ntfy-Priorität",
"form.feed.label.ntfy_topic": "Ntfy-Thema (optional)",
"form.feed.label.proxy_url": "Proxy-URL",
"form.feed.label.pushover_activate": "Artikel an pushover.net senden",
"form.feed.label.pushover_default_priority": "Pushover-Standardpriorität",
"form.feed.label.pushover_high_priority": "Hohe Pushoverpriorität",
"form.feed.label.pushover_low_priority": "Niedrige Pushoverpriorität",
"form.feed.label.pushover_max_priority": "Höchste Pushoverpriorität",
"form.feed.label.pushover_min_priority": "Niedrigste Pushoverpriorität",
"form.feed.label.pushover_priority": "Pushover-Nachrichtenpriorität",
"form.feed.label.rewrite_rules": "Inhalts-Umschreibregeln",
"form.feed.label.scraper_rules": "Extraktionsregeln",
"form.feed.label.site_url": "URL der Webseite",
"form.feed.label.title": "Titel",
"form.feed.label.urlrewrite_rules": "Umschreibregeln für URL",
"form.feed.label.user_agent": "Standardbenutzeragenten überschreiben",
"form.feed.label.webhook_url": "Webhook-URL überschreiben",
"form.import.label.file": "OPML-Datei",
"form.import.label.url": "URL",
"form.integration.archiveorg_activate": "Artikel zu archive.org pushen",
"form.integration.apprise_activate": "Artikel zu Apprise pushen",
"form.integration.apprise_services_url": "Kommaseparierte Liste von Apprise-Dienst-URLs",
"form.integration.apprise_url": "Apprise-API-URL",
"form.integration.betula_activate": "Artikel in Betula speichern",
"form.integration.betula_token": "Betula-Token",
"form.integration.betula_url": "Betula-Server-URL",
"form.integration.cubox_activate": "Artikel in Cubox speichern",
"form.integration.cubox_api_link": "Cubox-API-Link",
"form.integration.discord_activate": "Artikel zu Discord pushen",
"form.integration.discord_webhook_link": "Discord-Webhook-URL",
"form.integration.espial_activate": "Artikel in Espial speichern",
"form.integration.espial_api_key": "Espial-API-Schlüssel",
"form.integration.espial_endpoint": "Espial-API-Endpunkt",
"form.integration.espial_tags": "Espial-Tags",
"form.integration.fever_activate": "Fever-API aktivieren",
"form.integration.fever_endpoint": "Fever-API-Endpunkt:",
"form.integration.fever_password": "Fever-Passwort",
"form.integration.fever_username": "Fever-Benutzername",
"form.integration.googlereader_activate": "Google-Reader-API aktivieren",
"form.integration.googlereader_endpoint": "Google-Reader-API-Endpunkt:",
"form.integration.googlereader_password": "Google-Reader-Passwort",
"form.integration.googlereader_username": "Google-Reader-Benutzername",
"form.integration.instapaper_activate": "Artikel in Instapaper speichern",
"form.integration.instapaper_password": "Instapaper-Passwort",
"form.integration.instapaper_username": "Instapaper-Benutzername",
"form.integration.karakeep_activate": "Artikel in Karakeep speichern",
"form.integration.karakeep_api_key": "Karakeep-API-Schlüssel",
"form.integration.karakeep_url": "Karakeep-API-Endpunkt",
"form.integration.karakeep_tags": "Karakeep-Tags",
"form.integration.linkace_activate": "Artikel in LinkAce speichern",
"form.integration.linkace_api_key": "LinkAce-API-Schlüssel",
"form.integration.linkace_check_disabled": "Linkprüfung deaktivieren",
"form.integration.linkace_endpoint": "LinkAce-API-Endpunkt",
"form.integration.linkace_is_private": "Link als privat markieren",
"form.integration.linkace_tags": "LinkAce-Tags",
"form.integration.linkding_activate": "Artikel in Linkding speichern",
"form.integration.linkding_api_key": "Linkding-API-Schlüssel",
"form.integration.linkding_bookmark": "Lesezeichen als ungelesen markieren",
"form.integration.linkding_endpoint": "Linkding-API-Endpunkt",
"form.integration.linkding_tags": "Linkding-Tags",
"form.integration.linktaco_activate": "Artikel in LinkTaco speichern",
"form.integration.linktaco_api_token": "LinkTaco-API-Token",
"form.integration.linktaco_api_token_hint": "Holen Sie sich Ihr persönliches Zugriffstoken unter",
"form.integration.linktaco_org_slug": "Organisationstitel",
"form.integration.linktaco_tags": "Tags (max. 10, kommagetrennt)",
"form.integration.linktaco_tags_hint": "Maximal 10 Tags, kommagetrennt",
"form.integration.linktaco_visibility": "Sichtbarkeit",
"form.integration.linktaco_visibility_public": "Öffentlich",
"form.integration.linktaco_visibility_private": "Privat",
"form.integration.linktaco_visibility_hint": "PRIVATE Sichtbarkeit erfordert ein kostenpflichtiges LinkTaco-Konto",
"form.integration.linkwarden_activate": "Artikel in Linkwarden speichern",
"form.integration.linkwarden_api_key": "Linkwarden-API-Schlüssel",
"form.integration.linkwarden_endpoint": "Linkwarden-Base-URL",
"form.integration.linkwarden_collection_id": "Linkwarden-Sammlungs-ID",
"form.integration.matrix_bot_activate": "Neue Artikel in Matrix übertragen",
"form.integration.matrix_bot_chat_id": "ID des Matrix-Raums",
"form.integration.matrix_bot_password": "Passwort für Matrix-Benutzer",
"form.integration.matrix_bot_url": "URL des Matrix-Servers",
"form.integration.matrix_bot_user": "Benutzername für Matrix",
"form.integration.notion_activate": "Artikel in Notion speichern",
"form.integration.notion_page_id": "Notion-Page-ID",
"form.integration.notion_token": "Notion-Geheimnis-Token",
"form.integration.ntfy_activate": "Artikel zu ntfy pushen",
"form.integration.ntfy_api_token": "Ntfy-API-Token (optional)",
"form.integration.ntfy_icon_url": "Ntfy-Symbol-URL (optional)",
"form.integration.ntfy_internal_links": "Interne Links beim Klicken verwenden (optional)",
"form.integration.ntfy_password": "Ntfy-Passwort (optional)",
"form.integration.ntfy_topic": "Ntfy-Thema (Standard, wenn nicht im Feed eingestellt)",
"form.integration.ntfy_url": "Ntfy-URL (optional, Standard ist ntfy.sh)",
"form.integration.ntfy_username": "Ntfy-Benutzername (optional)",
"form.integration.nunux_keeper_activate": "Artikel in Nunux Keeper speichern",
"form.integration.nunux_keeper_api_key": "Nunux-Keeper-API-Schlüssel",
"form.integration.nunux_keeper_endpoint": "Nunux-Keeper-API-Endpunkt",
"form.integration.omnivore_activate": "Artikel in Omnivore speichern",
"form.integration.omnivore_api_key": "Omnivore-API-Schlüssel",
"form.integration.omnivore_url": "Omnivore-API-Endpunkt",
"form.integration.pinboard_activate": "Artikel in Pinboard speichern",
"form.integration.pinboard_bookmark": "Lesezeichen als ungelesen markieren",
"form.integration.pinboard_tags": "Pinboard-Tags",
"form.integration.pinboard_token": "Pinboard-API-Token",
"form.integration.pushover_activate": "Artikel an Pushover senden",
"form.integration.pushover_device": "Pushovergerät (optional)",
"form.integration.pushover_prefix": "Pushover-URL-Präfix (optional)",
"form.integration.pushover_token": "Pushover-Anwendungs-API-Token",
"form.integration.pushover_user": "Pushover-Benutzerschlüssel",
"form.integration.raindrop_activate": "Artikel in Raindrop speichern",
"form.integration.raindrop_collection_id": "Sammlungs-ID",
"form.integration.raindrop_tags": "Tags (kommagetrennt)",
"form.integration.raindrop_token": "(Test-)Token",
"form.integration.readeck_activate": "Artikel in Readeck speichern",
"form.integration.readeck_api_key": "Readeck-API-Schlüssel",
"form.integration.readeck_endpoint": "Readeck-URL",
"form.integration.readeck_labels": "Readeck-Labels",
"form.integration.readeck_only_url": "Nur URL senden (anstelle des vollständigen Inhalts)",
"form.integration.readeck_push_activate": "Neue Artikel automatisch in Readeck speichern",
"form.integration.readwise_activate": "Artikel in Readwise Reader speichern",
"form.integration.readwise_api_key": "Readwise-Reader-Zugangstoken",
"form.integration.readwise_api_key_link": "Erhalten Sie Ihren Readwise-Zugangstoken",
"form.integration.rssbridge_activate": "Beim Hinzufügen von Abonnements RSS-Bridge prüfen.",
"form.integration.rssbridge_token": "RSS-Bridge-Authentifizierungs-Token",
"form.integration.rssbridge_url": "RSS-Bridge-Server-URL",
"form.integration.shaarli_activate": "Artikel in Shaarli speichern",
"form.integration.shaarli_api_secret": "Shaarli-API-Geheimnis",
"form.integration.shaarli_endpoint": "Shaarli-URL",
"form.integration.shiori_activate": "Artikel in Shiori speichern",
"form.integration.shiori_endpoint": "Shiori-API-Endpunkt",
"form.integration.shiori_password": "Shiori-Passwort",
"form.integration.shiori_username": "Shiori-Benutzername",
"form.integration.slack_activate": "Artikel zu Slack pushen",
"form.integration.slack_webhook_link": "Slack-Webhook-URL",
"form.integration.telegram_bot_activate": "Schicken Sie neue Artikel in den Telegram-Chat",
"form.integration.telegram_bot_disable_buttons": "Schaltfächen deaktivieren",
"form.integration.telegram_bot_disable_notification": "Benachrichtigungen deaktivieren",
"form.integration.telegram_bot_disable_web_page_preview": "Webseiten-Vorschau deaktivieren",
"form.integration.telegram_bot_token": "Bot-Token",
"form.integration.telegram_chat_id": "Chat-ID",
"form.integration.telegram_topic_id": "Thema-ID",
"form.integration.wallabag_activate": "Artikel in Wallabag speichern",
"form.integration.wallabag_client_id": "Wallabag-Client-ID",
"form.integration.wallabag_client_secret": "Wallabag-Client-Geheimnis",
"form.integration.wallabag_endpoint": "Wallabag-Basis-URL",
"form.integration.wallabag_only_url": "Nur URL senden (anstelle des vollständigen Inhalts)",
"form.integration.wallabag_password": "Wallabag-Passwort",
"form.integration.wallabag_username": "Wallabag-Benutzername",
"form.integration.wallabag_tags": "Wallabag-Tags",
"form.integration.webhook_activate": "Webhooks aktivieren",
"form.integration.webhook_secret": "Webhook-Geheimnis",
"form.integration.webhook_url": "Standard-Webhook-URL",
"form.prefs.fieldset.application_settings": "Anwendungseinstellungen",
"form.prefs.fieldset.authentication_settings": "Authentifizierungseinstellungen",
"form.prefs.fieldset.global_feed_settings": "Globale Feedeinstellungen",
"form.prefs.fieldset.reader_settings": "Reader-Einstellungen",
"form.prefs.help.external_font_hosts": "Per Leerzeichen getrennte Liste externer Schriftarten-Hosts, die erlaubt werden sollen. Beispiel: \"fonts.gstatic.com fonts.googleapis.com\".",
"form.prefs.label.always_open_external_links": "Artikel immer mit Öffnen der Links lesen",
"form.prefs.label.categories_sorting_order": "Kategorie-Sortierung",
"form.prefs.label.cjk_reading_speed": "Lesegeschwindigkeit für Chinesisch, Koreanisch und Japanisch (Zeichen pro Minute)",
"form.prefs.label.custom_css": "Benutzerdefiniertes CSS",
"form.prefs.label.custom_js": "Benutzerdefiniertes JavaScript",
"form.prefs.label.default_home_page": "Standard-Startseite",
"form.prefs.label.default_reading_speed": "Lesegeschwindigkeit für andere Sprachen (Wörter pro Minute)",
"form.prefs.label.display_mode": "Anzeigemodus der progressiven Web-Anwendung (PWA)",
"form.prefs.label.entries_per_page": "Artikel pro Seite",
"form.prefs.label.entry_order": "Artikel-Sortierspalte",
"form.prefs.label.entry_sorting": "Sortierung der Artikel",
"form.prefs.label.entry_swipe": "Aktivieren Sie das Wischen von Artikeln auf Touchscreens",
"form.prefs.label.external_font_hosts": "Externe Schriftarten-Hosts",
"form.prefs.label.gesture_nav": "Geste zum Navigieren zwischen Artikeln",
"form.prefs.label.keyboard_shortcuts": "Tastaturkürzel aktivieren",
"form.prefs.label.language": "Sprache",
"form.prefs.label.mark_read_manually": "Artikel manuell als gelesen markieren",
"form.prefs.label.mark_read_on_media_completion": "Nur als gelesen markieren, wenn Audio/Video zu 90%% wiedergegeben wurden",
"form.prefs.label.mark_read_on_view": "Artikel automatisch als gelesen markieren, wenn sie angezeigt werden",
"form.prefs.label.mark_read_on_view_or_media_completion": "Artikel automatisch als gelesen markieren, wenn sie angezeigt werden. Audio/Video bei 90%% Wiedergabe als gelesen markieren",
"form.prefs.label.media_playback_rate": "Wiedergabegeschwindigkeit von Audio/Video",
"form.prefs.label.open_external_links_in_new_tab": "Externe Links in einem neuen Tab öffnen (fügt target=\"_blank\" zu Links hinzu)",
"form.prefs.label.show_reading_time": "Geschätzte Lesezeit für Artikel anzeigen",
"form.prefs.label.theme": "Thema",
"form.prefs.label.timezone": "Zeitzone",
"form.prefs.select.alphabetical": "Alphabetisch",
"form.prefs.select.browser": "Systembrowser",
"form.prefs.select.created_time": "Artikel erstellt am",
"form.prefs.select.fullscreen": "Vollbildschirm",
"form.prefs.select.minimal_ui": "Minimale Oberfläche",
"form.prefs.select.none": "Keine",
"form.prefs.select.older_first": "Ältere Artikel zuerst",
"form.prefs.select.publish_time": "Artikel veröffentlicht am",
"form.prefs.select.recent_first": "Neue Artikel zuerst",
"form.prefs.select.standalone": "Eigenständige",
"form.prefs.select.swipe": "Wischen",
"form.prefs.select.tap": "Doppeltippen",
"form.prefs.select.unread_count": "Ungelesen",
"form.submit.loading": "Lade...",
"form.submit.saving": "Speichern...",
"form.user.label.admin": "Administrator",
"form.user.label.confirmation": "Passwortbestätigung",
"form.user.label.password": "Passwort",
"form.user.label.username": "Benutzername",
"menu.about": "Über",
"menu.add_feed": "Abonnement hinzufügen",
"menu.add_user": "Benutzer anlegen",
"menu.api_keys": "API-Schlüssel",
"menu.categories": "Kategorien",
"menu.create_api_key": "Erstellen Sie einen neuen API-Schlüssel",
"menu.create_category": "Kategorie anlegen",
"menu.edit_category": "Bearbeiten",
"menu.edit_feed": "Bearbeiten",
"menu.export": "Exportieren",
"menu.feed_entries": "Artikel",
"menu.feeds": "Abonnements",
"menu.flush_history": "Verlauf leeren",
"menu.history": "Verlauf",
"menu.home_page": "Startseite",
"menu.import": "Importieren",
"menu.integrations": "Dienste",
"menu.logout": "Abmelden",
"menu.mark_all_as_read": "Alle als gelesen markieren",
"menu.mark_page_as_read": "Diese Seite als gelesen markieren",
"menu.preferences": "Einstellungen",
"menu.refresh_all_feeds": "Alle Abonnements im Hintergrund aktualisieren",
"menu.refresh_feed": "Aktualisieren",
"menu.search": "Suche",
"menu.sessions": "Sitzungen",
"menu.settings": "Einstellungen",
"menu.shared_entries": "Geteilte Artikel",
"menu.show_all_entries": "Zeige alle Artikel",
"menu.show_only_starred_entries": "Nur markierte Artikel anzeigen",
"menu.show_only_unread_entries": "Nur ungelesene Artikel anzeigen",
"menu.starred": "Markiert",
"menu.title": "Menü",
"menu.unread": "Ungelesen",
"menu.users": "Benutzer",
"page.about.author": "Autor:",
"page.about.build_date": "Datum der Kompilierung:",
"page.about.credits": "Urheberrechte",
"page.about.db_usage": "Datenbankgröße:",
"page.about.git_commit": "Git-Commit:",
"page.about.global_config_options": "Globale Konfigurationsoptionen",
"page.about.go_version": "Go-Version:",
"page.about.license": "Lizenz:",
"page.about.postgres_version": "Postgres-Version:",
"page.about.title": "Über",
"page.about.version": "Version:",
"page.add_feed.choose_feed": "Abonnement auswählen",
"page.add_feed.label.url": "URL",
"page.add_feed.legend.advanced_options": "Erweiterte Optionen",
"page.add_feed.no_category": "Es ist keine Kategorie vorhanden. Wenigstens eine Kategorie muss angelegt sein.",
"page.add_feed.submit": "Abonnement finden",
"page.add_feed.title": "Neues Abonnement",
"page.api_keys.never_used": "Nie benutzt",
"page.api_keys.table.actions": "Aktionen",
"page.api_keys.table.created_at": "Erstellungsdatum",
"page.api_keys.table.description": "Beschreibung",
"page.api_keys.table.last_used_at": "Zuletzt verwendeten",
"page.api_keys.table.token": "Zeichen",
"page.api_keys.title": "API-Schlüssel",
"page.categories.entries": "Artikel",
"page.categories.feed_count": [
"Es gibt %d Abonnement.",
"Es gibt %d Abonnements."
],
"page.categories.feeds": "Abonnements",
"page.categories.no_feed": "Kein Abonnement.",
"page.categories.title": "Kategorien",
"page.categories_count": [
"%d Kategorie",
"%d Kategorien"
],
"page.category_label": "Kategorie: %s",
"page.edit_category.title": "Kategorie bearbeiten: %s",
"page.edit_feed.etag_header": "ETag-Kopfzeile:",
"page.edit_feed.last_check": "Letzte Aktualisierung:",
"page.edit_feed.last_modified_header": "Zuletzt geändert:",
"page.edit_feed.last_parsing_error": "Letzter Analysefehler",
"page.edit_feed.no_header": "Nicht verfügbar",
"page.edit_feed.title": "Abonnement bearbeiten: %s",
"page.edit_user.title": "Benutzer bearbeiten: %s",
"page.entry.attachments": "Anhänge",
"page.feeds.error_count": [
"%d Fehler",
"%d Fehler"
],
"page.feeds.last_check": "Letzte Aktualisierung:",
"page.feeds.next_check": "Nächste Aktualisierung:",
"page.feeds.read_counter": "Anzahl der gelesenen Artikel",
"page.feeds.title": "Abonnements",
"page.footer.elevator": "Zurück nach oben",
"page.history.title": "Verlauf",
"page.import.title": "Importieren",
"page.integration.bookmarklet": "Bookmarklet",
"page.integration.bookmarklet.help": "Dieser spezielle Link ermöglicht es, eine Webseite direkt über ein Lesezeichen im Browser zu abonnieren.",
"page.integration.bookmarklet.instructions": "Ziehen Sie diesen Link in Ihre Lesezeichen.",
"page.integration.bookmarklet.name": "Mit Miniflux abonnieren",
"page.integration.miniflux_api": "Miniflux-API",
"page.integration.miniflux_api_endpoint": "API-Endpunkt",
"page.integration.miniflux_api_password": "Passwort",
"page.integration.miniflux_api_password_value": "Ihr Konto-Passwort",
"page.integration.miniflux_api_username": "Benutzername",
"page.integrations.title": "Dienste",
"page.keyboard_shortcuts.close_modal": "Liste der Tastenkürzel schließen",
"page.keyboard_shortcuts.download_content": "Vollständigen Inhalt herunterladen",
"page.keyboard_shortcuts.go_to_bottom_item": "Gehen Sie zum untersten Element",
"page.keyboard_shortcuts.go_to_categories": "Zu den Kategorien gehen",
"page.keyboard_shortcuts.go_to_feed": "Zum Abonnement gehen",
"page.keyboard_shortcuts.go_to_feeds": "Zu den Abonnements gehen",
"page.keyboard_shortcuts.go_to_history": "Zum Verlauf gehen",
"page.keyboard_shortcuts.go_to_next_item": "Zum nächsten Artikel gehen",
"page.keyboard_shortcuts.go_to_next_page": "Zur nächsten Seite gehen",
"page.keyboard_shortcuts.go_to_previous_item": "Zum vorherigen Artikel gehen",
"page.keyboard_shortcuts.go_to_previous_page": "Zur vorherigen Seite gehen",
"page.keyboard_shortcuts.go_to_search": "Fokus auf das Suchformular setzen",
"page.keyboard_shortcuts.go_to_settings": "Zu den Einstellungen gehen",
"page.keyboard_shortcuts.go_to_starred": "Zu den markierten Artikeln gehen",
"page.keyboard_shortcuts.go_to_top_item": "Zum obersten Artikel gehen",
"page.keyboard_shortcuts.go_to_unread": "Zu den ungelesenen Artikeln gehen",
"page.keyboard_shortcuts.mark_page_as_read": "Aktuelle Seite als gelesen markieren",
"page.keyboard_shortcuts.open_comments": "Kommentare öffnen",
"page.keyboard_shortcuts.open_comments_same_window": "Öffne den Kommentare-Link in der aktuellen Registerkarte",
"page.keyboard_shortcuts.open_item": "Gewählten 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.refresh_all_feeds": "Alle Abonnements im Hintergrund aktualisieren",
"page.keyboard_shortcuts.remove_feed": "Dieses Abonnement entfernen",
"page.keyboard_shortcuts.save_article": "Artikel speichern",
"page.keyboard_shortcuts.scroll_item_to_top": "Artikel an den Anfang blättern",
"page.keyboard_shortcuts.show_keyboard_shortcuts": "Liste der Tastenkürzel anzeigen",
"page.keyboard_shortcuts.subtitle.actions": "Aktionen",
"page.keyboard_shortcuts.subtitle.items": "Navigation zwischen den Artikeln",
"page.keyboard_shortcuts.subtitle.pages": "Navigation zwischen den Seiten",
"page.keyboard_shortcuts.subtitle.sections": "Navigation zwischen den Menüpunkten",
"page.keyboard_shortcuts.title": "Tastenkürzel",
"page.keyboard_shortcuts.toggle_star_status": "Markierung hinzufügen/entfernen",
"page.keyboard_shortcuts.toggle_entry_attachments": "Artikelanhänge öffnen/schließen",
"page.keyboard_shortcuts.toggle_read_status_next": "Gewählten Artikel als gelesen/ungelesen markieren, nächsten auswählen",
"page.keyboard_shortcuts.toggle_read_status_prev": "Gewählten Artikel als gelesen/ungelesen markieren, vorherigen auswählen",
"page.login.google_signin": "Anmeldung mit Google",
"page.login.oidc_signin": "Anmeldung mit %s",
"page.login.title": "Anmeldung",
"page.login.webauthn_login": "Melden Sie sich mit dem Passkey an",
"page.login.webauthn_login.error": "Anmeldung mit Passkey nicht möglich",
"page.login.webauthn_login.help": "Bitte geben Sie Ihren Benutzernamen ein, sofern Sie einen Sicherheitsschlüssel verwenden. Dies ist nicht nötig, wenn Sie einen Passkey verwenden (auffindbare Anmeldeinformationen).",
"page.new_api_key.title": "Neuer API-Schlüssel",
"page.new_category.title": "Neue Kategorie",
"page.new_user.title": "Neuer Benutzer",
"page.offline.message": "Sie sind offline",
"page.offline.refresh_page": "Versuchen Sie, die Seite zu aktualisieren",
"page.offline.title": "Offline-Modus",
"page.read_entry_count": [
"%d gelesener Artikel",
"%d gelesene Artikel"
],
"page.search.title": "Suchergebnisse",
"page.sessions.table.actions": "Aktionen",
"page.sessions.table.current_session": "Aktuelle Sitzung",
"page.sessions.table.date": "Datum",
"page.sessions.table.ip": "IP-Adresse",
"page.sessions.table.user_agent": "Benutzeragent",
"page.sessions.title": "Sitzungen",
"page.settings.link_google_account": "Google-Konto verknüpfen",
"page.settings.link_oidc_account": "%s-Konto verknüpfen",
"page.settings.title": "Einstellungen",
"page.settings.unlink_google_account": "Verknüpfung mit Google-Konto entfernen",
"page.settings.unlink_oidc_account": "Verknüpfung mit %s-Konto entfernen",
"page.settings.webauthn.actions": "Aktionen",
"page.settings.webauthn.added_on": "Hinzugefügt am",
"page.settings.webauthn.delete": [
"Entfernen Sie %d Hauptschlüssel",
"%d Hauptschlüssel entfernen"
],
"page.settings.webauthn.last_seen_on": "Zuletzt genutzt",
"page.settings.webauthn.passkey_name": "Name des Passkeys",
"page.settings.webauthn.passkeys": "Passkeys",
"page.settings.webauthn.register": "Hauptschlüssel registrieren",
"page.settings.webauthn.register.error": "Hauptschlüssel kann nicht registriert werden",
"page.shared_entries.title": "Geteilte Artikel",
"page.shared_entries_count": [
"%d geteilter Artikel",
"%d geteilte Artikel"
],
"page.starred.title": "Markiert",
"page.starred_entry_count": [
"%d markierter Artikel",
"%d markierte Artikel"
],
"page.total_entry_count": [
"%d Artikel insgesamt",
"%d Artikel insgesamt"
],
"page.unread.title": "Ungelesen",
"page.unread_entry_count": [
"%d ungelesener Artikel",
"%d ungelesene Artikel"
],
"page.users.actions": "Aktionen",
"page.users.admin.no": "Nein",
"page.users.admin.yes": "Ja",
"page.users.is_admin": "Administrator",
"page.users.last_login": "Letzte Anmeldung",
"page.users.never_logged": "Niemals",
"page.users.title": "Benutzer",
"page.users.username": "Benutzername",
"page.webauthn_rename.title": "Passkey umbenennen",
"pagination.first": "Erste",
"pagination.last": "Letzte",
"pagination.next": "Nächste",
"pagination.previous": "Vorherige",
"search.label": "Suche",
"search.placeholder": "Suche...",
"search.submit": "Suchen",
"skip_to_content": "Zum Inhalt springen",
"time_elapsed.days": [
"vor %d Tag",
"vor %d Tagen"
],
"time_elapsed.hours": [
"vor %d Stunde",
"vor %d Stunden"
],
"time_elapsed.minutes": [
"vor %d Minute",
"vor %d Minuten"
],
"time_elapsed.months": [
"vor %d Monat",
"vor %d Monaten"
],
"time_elapsed.not_yet": "noch nicht",
"time_elapsed.now": "gerade",
"time_elapsed.weeks": [
"vor %d Woche",
"vor %d Wochen"
],
"time_elapsed.years": [
"vor %d Jahr",
"vor %d Jahren"
],
"time_elapsed.yesterday": "gestern",
"tooltip.keyboard_shortcuts": "Tastenkürzel: %s",
"tooltip.logged_user": "Angemeldet als %s"
}
v2-2.2.16/internal/locale/translations/el_EL.json 0000664 0000000 0000000 00000155077 15127074645 0021632 0 ustar 00root root 0000000 0000000 {
"action.cancel": "ακύρωση",
"action.download": "Λήψη",
"action.edit": "Επεξεργασία",
"action.home_screen": "Προσθήκη στην αρχική οθόνη",
"action.import": "Εισαγωγή",
"action.login": "Σύνδεση",
"action.or": "ή",
"action.remove": "Κατάργηση",
"action.remove_feed": "Κατάργηση αυτής της ροής",
"action.save": "Αποθηκεύσετε",
"action.subscribe": "Εγγραφείτε",
"action.update": "Ενημέρωση",
"alert.account_linked": "Ο εξωτερικός σας λογαριασμός είναι πλέον συνδεδεμένος!",
"alert.account_unlinked": "Ο εξωτερικός σας λογαριασμός είναι πλέον αποσυνδεδεμένος!",
"alert.background_feed_refresh": "Όλες οι ροές ανανεώνονται στο παρασκήνιο. Μπορείτε να συνεχίσετε να χρησιμοποιείτε το Miniflux όσο εκτελείται αυτή η διαδικασία.",
"alert.feed_error": "Υπάρχει πρόβλημα με αυτήν τη ροή",
"alert.no_starred": "Δεν υπάρχει σελιδοδείκτης αυτή τη στιγμή.",
"alert.no_category": "Δεν υπάρχει κατηγορία.",
"alert.no_category_entry": "Δεν υπάρχουν άρθρα σε αυτήν την κατηγορία.",
"alert.no_feed": "Δεν έχετε συνδρομές.",
"alert.no_feed_entry": "Δεν υπάρχουν άρθρα για αυτήν τη ροή.",
"alert.no_feed_in_category": "Δεν υπάρχει συνδρομή για αυτήν την κατηγορία.",
"alert.no_history": "Δεν υπάρχει ιστορικό αυτή τη στιγμή.",
"alert.no_search_result": "Δεν υπάρχουν αποτελέσματα για αυτήν την αναζήτηση.",
"alert.no_shared_entry": "Δεν υπάρχει κοινόχρηστη καταχώρηση.",
"alert.no_tag_entry": "Δεν υπάρχουν αντικείμενα που να ταιριάζουν με αυτή την ετικέτα.",
"alert.no_unread_entry": "Δεν υπάρχουν μη αναγνωσμένα άρθρα.",
"alert.no_user": "Είστε ο μόνος χρήστης.",
"alert.prefs_saved": "Οι προτιμήσεις αποθηκεύτηκαν!",
"alert.too_many_feeds_refresh": [
"Έχετε ενεργοποιήσει πάρα πολλές ανανεώσεις ροών. Παρακαλώ περιμένετε %d λεπτό πριν προσπαθήσετε ξανά.",
"Έχετε ενεργοποιήσει πάρα πολλές ανανεώσεις ροών. Παρακαλώ περιμένετε %d λεπτά πριν προσπαθήσετε ξανά."
],
"confirm.loading": "Σε εξέλιξη...",
"confirm.no": "όχι",
"confirm.question": "Είστε σίγουροι;",
"confirm.question.refresh": "Θέλετε να επιτελέσετε μια υποχρεωτική ανανέωση;",
"confirm.yes": "ναι",
"enclosure_media_controls.seek": "Αναζήτηση:",
"enclosure_media_controls.seek.title": "Αναζήτηση %s δευτερόλεπτα",
"enclosure_media_controls.speed": "Ταχύτητα:",
"enclosure_media_controls.speed.faster": "Γρηγορότερα",
"enclosure_media_controls.speed.faster.title": "Γρηγορότερα κατά %sx",
"enclosure_media_controls.speed.reset": "Επαναφορά",
"enclosure_media_controls.speed.reset.title": "Επαναφορά ταχύτητας σε 1x",
"enclosure_media_controls.speed.slower": "Πιο αργά",
"enclosure_media_controls.speed.slower.title": "Πιο αργά κατά %sx",
"entry.starred.toast.off": "Μη αγαπημένα",
"entry.starred.toast.on": "Αγαπημένα",
"entry.starred.toggle.off": "Αναίρεση αγαπημένου",
"entry.starred.toggle.on": "Αγαπημένο",
"entry.comments.label": "Σχόλια",
"entry.comments.title": "Δείτε Σχόλια",
"entry.estimated_reading_time": [
"%d λεπτό ανάγνωση",
"%d λεπτά ανάγνωση"
],
"entry.external_link.label": "Εξωτερικός σύνδεσμος",
"entry.save.completed": "Έγινε!",
"entry.save.label": "Αποθηκεύσετε",
"entry.save.title": "Αποθηκεύστε αυτό το άρθρο",
"entry.save.toast.completed": "Το άρθρο αποθηκεύτηκε",
"entry.scraper.completed": "Έγινε!",
"entry.scraper.label": "Λήψη",
"entry.scraper.title": "Λήψη αρχικού περιεχομένου",
"entry.share.label": "Διαμοιρασμός",
"entry.share.title": "Μοιραστείτε αυτό το άρθρο",
"entry.shared_entry.label": "Διαμοιρασμός",
"entry.shared_entry.title": "Ανοίξτε τον δημόσιο σύνδεσμο",
"entry.state.loading": "Φόρτωση...",
"entry.state.saving": "Aποθήκευση...",
"entry.status.mark_as_read": "Επισήμανση ως αναγνωσμένο",
"entry.status.mark_as_unread": "Επισήμανση ως μη αναγνωσμένο",
"entry.status.title": "Αλλαγή κατάστασης καταχώρησης",
"entry.status.toast.read": "Επισήμανση ως αναγνωσμένο",
"entry.status.toast.unread": "Επισήμανση ως μη αναγνωσμένο",
"entry.tags.label": "Ετικέτες:",
"entry.tags.more_tags_label": [
"Εμφάνιση %d ακόμη ετικέτας",
"Εμφάνιση %d ακόμη ετικετών"
],
"entry.unshare.label": "Aναίρεση Διαμοιρασμού",
"error.api_key_already_exists": "Αυτό το κλειδί API υπάρχει ήδη.",
"error.bad_credentials": "Μη έγκυρο όνομα χρήστη ή κωδικό πρόσβασης.",
"error.category_already_exists": "Αυτή η κατηγορία υπάρχει ήδη.",
"error.category_not_found": "Αυτή η κατηγορία δεν υπάρχει ή δεν ανήκει σε αυτόν τον χρήστη.",
"error.database_error": "Σφάλμα βάσης δεδομένων: %v.",
"error.different_passwords": "Οι κωδικοί πρόσβασης δεν είναι οι ίδιοι.",
"error.duplicate_fever_username": "Υπάρχει ήδη κάποιος άλλος με το ίδιο όνομα χρήστη Fever!",
"error.duplicate_googlereader_username": "Υπάρχει ήδη κάποιος άλλος με το ίδιο όνομα χρήστη Google Reader!",
"error.duplicate_linked_account": "Υπάρχει ήδη κάποιος που σχετίζεται με αυτόν τον πάροχο!",
"error.duplicated_feed": "Αυτή η ροή υπάρχει ήδη.",
"error.empty_file": "Αυτό το αρχείο είναι κενό.",
"error.entries_per_page_invalid": "Ο αριθμός των καταχωρήσεων ανά σελίδα δεν είναι έγκυρος.",
"error.feed_already_exists": "Αυτή η ροή υπάρχει ήδη.",
"error.feed_category_not_found": "Αυτή η κατηγορία δεν υπάρχει ή δεν ανήκει σε αυτόν τον χρήστη.",
"error.feed_format_not_detected": "Δεν είναι δυνατή η ανίχνευση της μορφής ροής: %v.",
"error.feed_invalid_blocklist_rule": "Ο κανόνας λίστας μπλοκ δεν είναι έγκυρος.",
"error.feed_invalid_keeplist_rule": "Ο κανόνας keep list δεν είναι έγκυρος.",
"error.feed_mandatory_fields": "Η διεύθυνση URL και η κατηγορία είναι υποχρεωτικά.",
"error.feed_not_found": "Αυτή η ροή δεν υπάρχει ή δεν ανήκει σε αυτόν τον χρήστη.",
"error.feed_title_not_empty": "Ο τίτλος ροής δεν μπορεί να είναι κενός.",
"error.feed_url_not_empty": "Η διεύθυνση URL ροής δεν μπορεί να είναι κενή.",
"error.fields_mandatory": "Όλα τα πεδία είναι υποχρεωτικά.",
"error.http_bad_gateway": "Ο ιστότοπος δεν είναι διαθέσιμος αυτήν τη στιγμή λόγω σφάλματος κακής πύλης. Το πρόβλημα δεν είναι στην πλευρά του Miniflux. Παρακαλώ δοκιμάστε ξανά αργότερα.",
"error.http_body_read": "Δεν είναι δυνατή η ανάγνωση του σώματος HTTP: %v.",
"error.http_client_error": "Σφάλμα πελάτη HTTP: %v.",
"error.http_empty_response": "Η απάντηση HTTP είναι κενή. Ίσως αυτός ο ιστότοπος χρησιμοποιεί μηχανισμό προστασίας από bot;",
"error.http_empty_response_body": "Το σώμα απάντησης HTTP είναι κενό.",
"error.http_forbidden": "Η πρόσβαση σε αυτόν τον ιστότοπο απαγορεύεται. Ίσως αυτός ο ιστότοπος διαθέτει μηχανισμό προστασίας από bot;",
"error.http_gateway_timeout": "Ο ιστότοπος δεν είναι διαθέσιμος αυτήν τη στιγμή λόγω σφάλματος χρονικού ορίου πύλης. Το πρόβλημα δεν είναι στην πλευρά του Miniflux. Παρακαλώ δοκιμάστε ξανά αργότερα.",
"error.http_internal_server_error": "Ο ιστότοπος δεν είναι διαθέσιμος αυτήν τη στιγμή λόγω σφάλματος διακομιστή. Το πρόβλημα δεν είναι στην πλευρά του Miniflux. Παρακαλώ δοκιμάστε ξανά αργότερα.",
"error.http_not_authorized": "Η πρόσβαση σε αυτόν τον ιστότοπο δεν είναι εξουσιοδοτημένη. Μπορεί να είναι λανθασμένο όνομα χρήστη ή κωδικός πρόσβασης.",
"error.http_resource_not_found": "Ο ζητούμενος πόρος δεν βρέθηκε. Επαληθεύστε τη διεύθυνση URL.",
"error.http_response_too_large": "Η απάντηση HTTP είναι πολύ μεγάλη. Μπορείτε να αυξήσετε το όριο μεγέθους απάντησης HTTP στις καθολικές ρυθμίσεις (απαιτεί επανεκκίνηση του διακομιστή).",
"error.http_service_unavailable": "Ο ιστότοπος δεν είναι διαθέσιμος αυτήν τη στιγμή λόγω εσωτερικού σφάλματος διακομιστή. Το πρόβλημα δεν είναι στην πλευρά του Miniflux. Παρακαλώ δοκιμάστε ξανά αργότερα.",
"error.http_too_many_requests": "Το Miniflux δημιούργησε πάρα πολλά αιτήματα σε αυτόν τον ιστότοπο. Παρακαλώ δοκιμάστε ξανά αργότερα ή αλλάξτε τη διαμόρφωση της εφαρμογής.",
"error.http_unexpected_status_code": "Ο ιστότοπος δεν είναι διαθέσιμος αυτήν τη στιγμή λόγω μη αναμενόμενου κωδικού κατάστασης HTTP: %d. Το πρόβλημα δεν είναι στην πλευρά του Miniflux. Παρακαλώ δοκιμάστε ξανά αργότερα.",
"error.invalid_categories_sorting_order": "Η κατηγορία δεν μπορεί να είναι κενή.",
"error.invalid_default_home_page": "Μη έγκυρη προεπιλεγμένη αρχική σελίδα!",
"error.invalid_display_mode": "Μη έγκυρη λειτουργία εμφάνισης εφαρμογών ιστού.",
"error.invalid_entry_direction": "Μη έγκυρη κατεύθυνση ταξινόμησης άρθρων.",
"error.invalid_entry_order": "Η σειρά των καταχωρήσεων είναι μη έγκυρη.",
"error.invalid_feed_proxy_url": "Μη έγκυρη διεύθυνση URL διακομιστή μεσολάβησης.",
"error.invalid_feed_url": "Μη έγκυρη διεύθυνση URL ροής.",
"error.invalid_gesture_nav": "Μη έγκυρη πλοήγηση με χειρονομίες.",
"error.invalid_language": "Μη έγκυρη γλώσσα.",
"error.invalid_site_url": "Μη έγκυρη διεύθυνση URL ιστότοπου.",
"error.invalid_theme": "Μη έγκυρο θέμα.",
"error.invalid_timezone": "Μη έγκυρη ζώνη ώρας.",
"error.network_operation": "Το Miniflux δεν μπορεί να φτάσει σε αυτόν τον ιστότοπο λόγω σφάλματος δικτύου: %v.",
"error.network_timeout": "Αυτός ο ιστότοπος είναι πολύ αργός και το αίτημα έληξε: %v",
"error.password_min_length": "Ο κωδικός πρόσβασης πρέπει να έχει τουλάχιστον 6 χαρακτήρες.",
"error.proxy_url_not_empty": "Η διεύθυνση URL του διακομιστή μεσολάβησης δεν μπορεί να είναι κενή.",
"error.settings_block_rule_fieldname_invalid": "Μη έγκυρος κανόνας αποκλεισμού: ο κανόνας #%d λείπει ένα έγκυρο όνομα πεδίου (Επιλογές: %s)",
"error.settings_block_rule_invalid_regex": "Μη έγκυρος κανόνας αποκλεισμού: το μοτίβο του κανόνα #%d δεν είναι έγκυρη κανονική έκφραση",
"error.settings_block_rule_regex_required": "Μη έγκυρος κανόνας αποκλεισμού: το μοτίβο του κανόνα #%d δεν παρέχεται",
"error.settings_block_rule_separator_required": "Μη έγκυρος κανόνας αποκλεισμού: το μοτίβο του κανόνα #%d απαιτείται να διαχωρίζεται με ένα '='",
"error.settings_invalid_domain_list": "Μη έγκυρη λίστα τομέων. Παρακαλώ δώστε μια λίστα τομέων διαχωρισμένων με κενό.",
"error.settings_keep_rule_fieldname_invalid": "Μη έγκυρος κανόνας διατήρησης: ο κανόνας #%d λείπει ένα έγκυρο όνομα πεδίου (Επιλογές: %s)",
"error.settings_keep_rule_invalid_regex": "Μη έγκυρος κανόνας διατήρησης: το μοτίβο του κανόνα #%d δεν είναι έγκυρη κανονική έκφραση",
"error.settings_keep_rule_regex_required": "Μη έγκυρος κανόνας διατήρησης: το μοτίβο του κανόνα #%d δεν παρέχεται",
"error.settings_keep_rule_separator_required": "Μη έγκυρος κανόνας διατήρησης: το μοτίβο του κανόνα #%d απαιτείται να διαχωρίζεται με ένα '='",
"error.settings_mandatory_fields": "Τα πεδία όνομα χρήστη, θέμα, Γλώσσα και ζώνη ώρας είναι υποχρεωτικά.",
"error.settings_media_playback_rate_range": "Η ταχύτητα αναπαραγωγής είναι εκτός εύρους",
"error.settings_reading_speed_is_positive": "Οι ταχύτητες ανάγνωσης πρέπει να είναι θετικοί ακέραιοι αριθμοί.",
"error.site_url_not_empty": "Η διεύθυνση URL του ιστότοπου δεν μπορεί να είναι κενή.",
"error.subscription_not_found": "Δεν είναι δυνατή η εύρεση συνδρομής.",
"error.title_required": "Ο τίτλος είναι υποχρεωτικός.",
"error.tls_error": "Σφάλμα TLS: %q. Μπορείτε να απενεργοποιήσετε την επαλήθευση TLS στις ρυθμίσεις ροής εάν το επιθυμείτε.",
"error.unable_to_create_api_key": "Δεν είναι δυνατή η δημιουργία αυτού του κλειδιού API.",
"error.unable_to_create_category": "Δεν είναι δυνατή η δημιουργία αυτής της κατηγορίας.",
"error.unable_to_create_user": "Δεν είναι δυνατή η δημιουργία αυτού του χρήστη.",
"error.unable_to_detect_rssbridge": "Δεν είναι δυνατή η ανίχνευση ροής με χρήση RSS-Bridge: %v.",
"error.unable_to_parse_feed": "Δεν είναι δυνατή η ανάλυση αυτής της ροής: %v.",
"error.unable_to_update_category": "Δεν είναι δυνατή η ενημέρωση αυτής της κατηγορίας.",
"error.unable_to_update_feed": "Δεν είναι δυνατή η ενημέρωση αυτής της ροής.",
"error.unable_to_update_user": "Δεν είναι δυνατή η ενημέρωση αυτού του χρήστη.",
"error.unlink_account_without_password": "Πρέπει να ορίσετε έναν κωδικό πρόσβασης διαφορετικά δεν θα μπορείτε να συνδεθείτε ξανά.",
"error.user_already_exists": "Αυτός ο χρήστης υπάρχει ήδη.",
"error.user_mandatory_fields": "Το όνομα χρήστη είναι υποχρεωτικό.",
"error.linktaco_missing_required_fields": "Το LinkTaco API Token και το Organization Slug είναι απαραίτητα",
"form.api_key.label.description": "Ετικέτα κλειδιού API",
"form.category.hide_globally": "Απόκρυψη καταχωρήσεων σε γενική λίστα μη αναγνωσμένων",
"form.category.label.title": "Τίτλος",
"form.feed.fieldset.general": "Γενικά",
"form.feed.fieldset.integration": "Υπηρεσίες τρίτων",
"form.feed.fieldset.network_settings": "Ρυθμίσεις δικτύου",
"form.feed.fieldset.rules": "Κανόνες",
"form.feed.label.allow_self_signed_certificates": "Να επιτρέπονται αυτο-υπογεγραμμένα ή μη έγκυρα πιστοποιητικά",
"form.feed.label.apprise_service_urls": "Λίστα διευθύνσεων URL υπηρεσιών Apprise διαχωρισμένων με κόμμα",
"form.feed.label.block_filter_entry_rules": "Κανόνες Αποκλεισμού Καταχωρήσεων",
"form.feed.label.blocklist_rules": "Φίλτρα Αποκλεισμού Βασισμένα σε Regex",
"form.feed.label.category": "Κατηγορία",
"form.feed.label.cookie": "Ορισμός Cookies",
"form.feed.label.crawler": "Λήψη αρχικού περιεχομένου",
"form.feed.label.description": "Περιγραφή",
"form.feed.label.disable_http2": "Απενεργοποίηση HTTP/2 για αποφυγή δακτυλικών αποτυπωμάτων",
"form.feed.label.disabled": "Μη ανανέωση αυτής της ροής",
"form.feed.label.feed_password": "Κωδικός Πρόσβασης ροής",
"form.feed.label.feed_url": "Διεύθυνση URL ροής",
"form.feed.label.feed_username": "Όνομα Χρήστη ροής",
"form.feed.label.fetch_via_proxy": "Χρησιμοποιήστε τον διακομιστή μεσολάβησης που έχει ρυθμιστεί σε επίπεδο εφαρμογής",
"form.feed.label.hide_globally": "Απόκρυψη καταχωρήσεων σε γενική λίστα μη αναγνωσμένων",
"form.feed.label.ignore_http_cache": "Αγνοήστε την προσωρινή μνήμη HTTP",
"form.feed.label.keep_filter_entry_rules": "Κανόνες Επιτρεπόμενων Καταχωρήσεων",
"form.feed.label.keeplist_rules": "Φίλτρα Διατήρησης Βασισμένα σε Regex",
"form.feed.label.no_media_player": "Χωρίς πρόγραμμα αναπαραγωγής πολυμέσων (ήχος/βίντεο)",
"form.feed.label.ntfy_activate": "Προώθηση καταχωρήσεων στο ntfy",
"form.feed.label.ntfy_default_priority": "Προεπιλεγμένη προτεραιότητα Ntfy",
"form.feed.label.ntfy_high_priority": "Υψηλή προτεραιότητα Ntfy",
"form.feed.label.ntfy_low_priority": "Χαμηλή προτεραιότητα Ntfy",
"form.feed.label.ntfy_max_priority": "Μέγιστη προτεραιότητα Ntfy",
"form.feed.label.ntfy_min_priority": "Ελάχιστη προτεραιότητα Ntfy",
"form.feed.label.ntfy_priority": "Προτεραιότητα Ntfy",
"form.feed.label.ntfy_topic": "Θέμα Ntfy (προαιρετικό)",
"form.feed.label.proxy_url": "Διεύθυνση URL διακομιστή μεσολάβησης",
"form.feed.label.pushover_activate": "Προώθηση καταχωρήσεων στο pushover.net",
"form.feed.label.pushover_default_priority": "Προεπιλεγμένη προτεραιότητα Pushover",
"form.feed.label.pushover_high_priority": "Υψηλή προτεραιότητα Pushover",
"form.feed.label.pushover_low_priority": "Χαμηλή προτεραιότητα Pushover",
"form.feed.label.pushover_max_priority": "Μέγιστη προτεραιότητα Pushover",
"form.feed.label.pushover_min_priority": "Ελάχιστη προτεραιότητα Pushover",
"form.feed.label.pushover_priority": "Προτεραιότητα μηνύματος Pushover",
"form.feed.label.rewrite_rules": "Κανόνες Επανασύνταξης Περιεχομένου",
"form.feed.label.scraper_rules": "Κανόνες Scraper",
"form.feed.label.site_url": "Διεύθυνση URL ιστότοπου",
"form.feed.label.title": "Τίτλος",
"form.feed.label.urlrewrite_rules": "κανόνες επανεγγραφής για τη διεύθυνση URL.",
"form.feed.label.user_agent": "Παράκαμψη Προεπιλεγμένου User Agent Χρήστη",
"form.feed.label.webhook_url": "Παράκαμψη διεύθυνσης URL webhook",
"form.import.label.file": "Αρχείο OPML",
"form.import.label.url": "Διεύθυνση URL",
"form.integration.archiveorg_activate": "Προώθηση καταχωρήσεων στο archive.org",
"form.integration.apprise_activate": "Προώθηση καταχωρήσεων στο Apprise",
"form.integration.apprise_services_url": "Λίστα διευθύνσεων URL υπηρεσιών Apprise διαχωρισμένων με κόμμα",
"form.integration.apprise_url": "Διεύθυνση URL API Apprise",
"form.integration.betula_activate": "Αποθήκευση καταχωρήσεων στο Betula",
"form.integration.betula_token": "Διακριτικό Betula",
"form.integration.betula_url": "Διεύθυνση URL διακομιστή Betula",
"form.integration.cubox_activate": "Αποθήκευση καταχωρήσεων στο Cubox",
"form.integration.cubox_api_link": "Σύνδεσμος API Cubox",
"form.integration.discord_activate": "Προώθηση καταχωρήσεων στο Discord",
"form.integration.discord_webhook_link": "Σύνδεσμος Webhook Discord",
"form.integration.espial_activate": "Αποθήκευση άρθρων στο Espial",
"form.integration.espial_api_key": "Κλειδί API Espial",
"form.integration.espial_endpoint": "Τελικό σημείο Espial API",
"form.integration.espial_tags": "Ετικέτες Espial",
"form.integration.fever_activate": "Ενεργοποιήστε το Fever API",
"form.integration.fever_endpoint": "Τελικό σημείο Fever API:",
"form.integration.fever_password": "Κωδικός Πρόσβασης Fever",
"form.integration.fever_username": "Όνομα Χρήστη Fever",
"form.integration.googlereader_activate": "Ενεργοποιήστε το Google Reader API",
"form.integration.googlereader_endpoint": "Τελικό σημείο Google Reader API:",
"form.integration.googlereader_password": "Κωδικός Πρόσβασης Google Reader",
"form.integration.googlereader_username": "Όνομα Χρήστη Google Reader",
"form.integration.instapaper_activate": "Αποθήκευση άρθρων στο Instapaper",
"form.integration.instapaper_password": "Κωδικός Πρόσβασης Instapaper",
"form.integration.instapaper_username": "Όνομα Χρήστη Instapaper",
"form.integration.karakeep_activate": "Αποθήκευση άρθρων στο Karakeep",
"form.integration.karakeep_api_key": "Κλειδί API Karakeep",
"form.integration.karakeep_url": "Τελικό σημείο Karakeep API",
"form.integration.karakeep_tags": "Ετικέτες Karakeep",
"form.integration.linkace_activate": "Αποθήκευση καταχωρήσεων στο LinkAce",
"form.integration.linkace_api_key": "Κλειδί API LinkAce",
"form.integration.linkace_check_disabled": "Απενεργοποίηση ελέγχου συνδέσμου",
"form.integration.linkace_endpoint": "Τελικό σημείο API LinkAce",
"form.integration.linkace_is_private": "Σήμανση συνδέσμου ως ιδιωτικού",
"form.integration.linkace_tags": "Ετικέτες LinkAce",
"form.integration.linkding_activate": "Αποθήκευση άρθρων στο Linkding",
"form.integration.linkding_api_key": "Κλειδί API Linkding",
"form.integration.linkding_bookmark": "Σημείωση του σελιδοδείκτη ως μη αναγνωσμένου",
"form.integration.linkding_endpoint": "Τελικό σημείο Linkding API",
"form.integration.linkding_tags": "Ετικέτες Linkding",
"form.integration.linktaco_activate": "Αποθήκευση καταχωρήσεων στο LinkTaco",
"form.integration.linktaco_api_token": "Διακριτικό API LinkTaco",
"form.integration.linktaco_api_token_hint": "Λάβετε το προσωπικό σας διακριτικό πρόσβασης στο",
"form.integration.linktaco_org_slug": "Σύντομο όνομα οργανισμού",
"form.integration.linktaco_tags": "Ετικέτες (μέγιστο 10, διαχωρισμένες με κόμμα)",
"form.integration.linktaco_tags_hint": "Μέγιστο 10 ετικέτες, διαχωρισμένες με κόμμα",
"form.integration.linktaco_visibility": "Ορατότητα",
"form.integration.linktaco_visibility_public": "Δημόσια",
"form.integration.linktaco_visibility_private": "Ιδιωτική",
"form.integration.linktaco_visibility_hint": "Η ΙΔΙΩΤΙΚΗ ορατότητα απαιτεί επί πληρωμή λογαριασμό LinkTaco",
"form.integration.linkwarden_activate": "Αποθήκευση άρθρων στο Linkwarden",
"form.integration.linkwarden_api_key": "Κλειδί API Linkwarden",
"form.integration.linkwarden_endpoint": "URL βάσης Linkwarden",
"form.integration.linkwarden_collection_id": "ID συλλογής Linkwarden",
"form.integration.matrix_bot_activate": "Μεταφορά νέων άρθρων στο Matrix",
"form.integration.matrix_bot_chat_id": "Αναγνωριστικό της αίθουσας Matrix",
"form.integration.matrix_bot_password": "Κωδικός πρόσβασης για τον χρήστη Matrix",
"form.integration.matrix_bot_url": "URL διακομιστή Matrix",
"form.integration.matrix_bot_user": "Όνομα χρήστη για το Matrix",
"form.integration.notion_activate": "Αποθήκευση καταχωρήσεων στο Notion",
"form.integration.notion_page_id": "Αναγνωριστικό σελίδας Notion",
"form.integration.notion_token": "Μυστικό διακριτικό Notion",
"form.integration.ntfy_activate": "Προώθηση καταχωρήσεων στο ntfy",
"form.integration.ntfy_api_token": "Διακριτικό API Ntfy (προαιρετικό)",
"form.integration.ntfy_icon_url": "Διεύθυνση URL εικονιδίου Ntfy (προαιρετικό)",
"form.integration.ntfy_internal_links": "Χρήση εσωτερικών συνδέσμων με κλικ (προαιρετικό)",
"form.integration.ntfy_password": "Κωδικός πρόσβασης Ntfy (προαιρετικό)",
"form.integration.ntfy_topic": "Θέμα Ntfy (προεπιλογή χρησιμοποιείται εάν δεν οριστεί στη ροή)",
"form.integration.ntfy_url": "Διεύθυνση URL Ntfy (προαιρετικό, προεπιλογή είναι ntfy.sh)",
"form.integration.ntfy_username": "Όνομα χρήστη Ntfy (προαιρετικό)",
"form.integration.nunux_keeper_activate": "Αποθήκευση άρθρων στο Nunux Keeper",
"form.integration.nunux_keeper_api_key": "Κλειδί API Nunux Keeper",
"form.integration.nunux_keeper_endpoint": "Τελικό σημείο Nunux Keeper API",
"form.integration.omnivore_activate": "Αποθήκευση άρθρων στο Omnivore",
"form.integration.omnivore_api_key": "Κλειδί API Omnivore",
"form.integration.omnivore_url": "Τελικό σημείο Omnivore API",
"form.integration.pinboard_activate": "Αποθήκευση άρθρων στο Pinboard",
"form.integration.pinboard_bookmark": "Σημείωση του σελιδοδείκτη ως μη αναγνωσμένου",
"form.integration.pinboard_tags": "Ετικέτες Pinboard",
"form.integration.pinboard_token": "Διακριτικό API Pinboard",
"form.integration.pushover_activate": "Προώθηση καταχωρήσεων στο Pushover",
"form.integration.pushover_device": "Συσκευή Pushover (προαιρετικό)",
"form.integration.pushover_prefix": "Πρόθεμα διεύθυνσης URL Pushover (προαιρετικό)",
"form.integration.pushover_token": "Διακριτικό API εφαρμογής Pushover",
"form.integration.pushover_user": "Κλειδί χρήστη Pushover",
"form.integration.raindrop_activate": "Αποθήκευση καταχωρήσεων στο Raindrop",
"form.integration.raindrop_collection_id": "Αναγνωριστικό συλλογής",
"form.integration.raindrop_tags": "Ετικέτες (διαχωρισμένες με κόμμα)",
"form.integration.raindrop_token": "Διακριτικό (Δοκιμή)",
"form.integration.readeck_activate": "Αποθήκευση άρθρων στο Readeck",
"form.integration.readeck_api_key": "Κλειδί API Readeck",
"form.integration.readeck_endpoint": "Τελικό σημείο Readeck API",
"form.integration.readeck_labels": "Ετικέτες Readeck",
"form.integration.readeck_only_url": "Αποστολή μόνο URL (αντί για πλήρες περιεχόμενο)",
"form.integration.readeck_push_activate": "Αυτόματη αποστολή νέων καταχωρήσεων στο Readeck",
"form.integration.readwise_activate": "Αποθήκευση καταχωρήσεων στο Readwise Reader",
"form.integration.readwise_api_key": "Διακριτικό πρόσβασης Readwise Reader",
"form.integration.readwise_api_key_link": "Λήψη του διακριτικού πρόσβασης Readwise",
"form.integration.rssbridge_activate": "Έλεγχος RSS-Bridge κατά την προσθήκη συνδρομών",
"form.integration.rssbridge_token": "Διακριτικό ελέγχου ταυτότητας RSS-Bridge",
"form.integration.rssbridge_url": "Διεύθυνση URL διακομιστή RSS-Bridge",
"form.integration.shaarli_activate": "Αποθήκευση άρθρων στο Shaarli",
"form.integration.shaarli_api_secret": "Μυστικό API Shaarli",
"form.integration.shaarli_endpoint": "Διεύθυνση URL Shaarli",
"form.integration.shiori_activate": "Αποθήκευση άρθρων στο Shiori",
"form.integration.shiori_endpoint": "Τελικό σημείο Shiori",
"form.integration.shiori_password": "Κωδικός Πρόσβασης Shiori",
"form.integration.shiori_username": "Όνομα Χρήστη Shiori",
"form.integration.slack_activate": "Προώθηση καταχωρήσεων στο Slack",
"form.integration.slack_webhook_link": "Σύνδεσμος Webhook Slack",
"form.integration.telegram_bot_activate": "Προωθήστε νέα άρθρα στη συνομιλία Telegram",
"form.integration.telegram_bot_disable_buttons": "Απενεργοποίηση κουμπιών",
"form.integration.telegram_bot_disable_notification": "Απενεργοποίηση ειδοποίησης",
"form.integration.telegram_bot_disable_web_page_preview": "Απενεργοποίηση προεπισκόπησης ιστοσελίδας",
"form.integration.telegram_bot_token": "Διακριτικό bot",
"form.integration.telegram_chat_id": "Αναγνωριστικό συνομιλίας",
"form.integration.telegram_topic_id": "Αναγνωριστικό θέματος",
"form.integration.wallabag_activate": "Αποθήκευση άρθρων στο Wallabag",
"form.integration.wallabag_client_id": "Ταυτότητα πελάτη Wallabag",
"form.integration.wallabag_client_secret": "Wallabag Μυστικό Πελάτη",
"form.integration.wallabag_endpoint": "Βασική διεύθυνση URL Wallabag",
"form.integration.wallabag_only_url": "Αποστολή μόνο URL (αντί για πλήρες περιεχόμενο)",
"form.integration.wallabag_password": "Wallabag Κωδικός Πρόσβασης",
"form.integration.wallabag_username": "Όνομα Χρήστη Wallabag",
"form.integration.wallabag_tags": "Ετικέτες Wallabag",
"form.integration.webhook_activate": "Ενεργοποίηση Webhooks",
"form.integration.webhook_secret": "Μυστικό Webhooks",
"form.integration.webhook_url": "Προεπιλεγμένη διεύθυνση URL Webhook",
"form.prefs.fieldset.application_settings": "Ρυθμίσεις εφαρμογής",
"form.prefs.fieldset.authentication_settings": "Ρυθμίσεις ελέγχου ταυτότητας",
"form.prefs.fieldset.global_feed_settings": "Καθολικές ρυθμίσεις ροής",
"form.prefs.fieldset.reader_settings": "Ρυθμίσεις αναγνώστη",
"form.prefs.help.external_font_hosts": "Λίστα εξωτερικών κεντρικών υπολογιστών γραμματοσειρών διαχωρισμένων με κενό για να επιτρέπονται. Για παράδειγμα: \"fonts.gstatic.com fonts.googleapis.com\".",
"form.prefs.label.always_open_external_links": "Ανάγνωση άρθρων ανοίγοντας εξωτερικούς συνδέσμους",
"form.prefs.label.categories_sorting_order": "Ταξινόμηση κατηγοριών",
"form.prefs.label.cjk_reading_speed": "Ταχύτητα ανάγνωσης για κινέζικα, κορεάτικα και ιαπωνικά (χαρακτήρες ανά λεπτό)",
"form.prefs.label.custom_css": "Προσαρμοσμένο CSS",
"form.prefs.label.custom_js": "Προσαρμοσμένο JavaScript",
"form.prefs.label.default_home_page": "Προεπιλεγμένη αρχική σελίδα",
"form.prefs.label.default_reading_speed": "Ταχύτητα ανάγνωσης άλλων γλωσσών (λέξεις ανά λεπτό)",
"form.prefs.label.display_mode": "Λειτουργία προβολής προοδευτικής εφαρμογής Ιστού (PWA)",
"form.prefs.label.entries_per_page": "Καταχωρήσεις ανά σελίδα",
"form.prefs.label.entry_order": "Στήλη ταξινόμησης εισόδου",
"form.prefs.label.entry_sorting": "Ταξινόμηση",
"form.prefs.label.entry_swipe": "Ενεργοποιήστε το σάρωση καταχώρισης στις οθόνες αφής",
"form.prefs.label.external_font_hosts": "Εξωτερικοί κεντρικοί υπολογιστές γραμματοσειρών",
"form.prefs.label.gesture_nav": "Χειρονομία για πλοήγηση μεταξύ των καταχωρήσεων",
"form.prefs.label.keyboard_shortcuts": "Ενεργοποίηση συντομεύσεων πληκτρολογίου",
"form.prefs.label.language": "Γλώσσα",
"form.prefs.label.mark_read_manually": "Σήμανση καταχωρήσεων ως αναγνωσμένων με μη αυτόματο τρόπο",
"form.prefs.label.mark_read_on_media_completion": "Σήμανση ως αναγνωσμένου μόνο όταν η αναπαραγωγή ήχου/βίντεο φτάσει το 90%% ολοκλήρωσης",
"form.prefs.label.mark_read_on_view": "Αυτόματη επισήμανση καταχωρήσεων ως αναγνωσμένων κατά την προβολή",
"form.prefs.label.mark_read_on_view_or_media_completion": "Σήμανση καταχωρήσεων ως αναγνωσμένων κατά την προβολή. Για ήχο/βίντεο, σήμανση ως αναγνωσμένου στο 90%% ολοκλήρωσης",
"form.prefs.label.media_playback_rate": "Ταχύτητα αναπαραγωγής του ήχου/βίντεο",
"form.prefs.label.open_external_links_in_new_tab": "Άνοιγμα εξωτερικών συνδέσμων σε νέα καρτέλα (προσθέτει target=\"_blank\" στους συνδέσμους)",
"form.prefs.label.show_reading_time": "Εμφάνιση εκτιμώμενου χρόνου ανάγνωσης για άρθρα",
"form.prefs.label.theme": "Θέμα",
"form.prefs.label.timezone": "Ζώνη Ώρας",
"form.prefs.select.alphabetical": "Αλφαβητική σειρά",
"form.prefs.select.browser": "Περιηγητής",
"form.prefs.select.created_time": "Χρόνος δημιουργίας καταχώρησης",
"form.prefs.select.fullscreen": "Πλήρης οθόνη",
"form.prefs.select.minimal_ui": "Ελάχιστη",
"form.prefs.select.none": "Κανένας",
"form.prefs.select.older_first": "Παλαιότερες καταχωρήσεις πρώτα",
"form.prefs.select.publish_time": "Δημοσιευμένος χρόνος εισόδου",
"form.prefs.select.recent_first": "Πρόσφατες καταχωρήσεις πρώτα",
"form.prefs.select.standalone": "Μεμονωμένο",
"form.prefs.select.swipe": "Σουφρώνω",
"form.prefs.select.tap": "Διπλό χτύπημα",
"form.prefs.select.unread_count": "Αριθμός μη αναγνωσμένων",
"form.submit.loading": "Φόρτωση...",
"form.submit.saving": "Αποθήκευση...",
"form.user.label.admin": "Διαχειριστής",
"form.user.label.confirmation": "Επιβεβαίωση Κωδικού Πρόσβασης",
"form.user.label.password": "Κωδικός",
"form.user.label.username": "Χρήστης",
"menu.about": "Περί",
"menu.add_feed": "Προσθήκη συνδρομής",
"menu.add_user": "Προσθήκη χρήστη",
"menu.api_keys": "Κλειδιά API",
"menu.categories": "Κατηγορίες",
"menu.create_api_key": "Δημιουργήστε ένα νέο κλειδί API",
"menu.create_category": "Δημιουργήστε μια κατηγορία",
"menu.edit_category": "Επεξεργασία",
"menu.edit_feed": "Επεξεργασία",
"menu.export": "Εξαγωγή",
"menu.feed_entries": "Καταχωρήσεις",
"menu.feeds": "Ροές",
"menu.flush_history": "Εκκαθάριση ιστορικού",
"menu.history": "Ιστορικό",
"menu.home_page": "Αρχική σελίδα",
"menu.import": "Εισαγωγή",
"menu.integrations": "Ενσωμάτωσεις",
"menu.logout": "Αποσύνδεση",
"menu.mark_all_as_read": "Σημείωση όλων ως αναγνωσμένα",
"menu.mark_page_as_read": "Σημείωση αυτής της σελίδας ως αναγνωσμένη",
"menu.preferences": "Προτιμήσεις",
"menu.refresh_all_feeds": "Ανανέωση όλων των ροών στο παρασκήνιο",
"menu.refresh_feed": "Ανανέωση",
"menu.search": "Αναζήτηση",
"menu.sessions": "Συνδέσεις",
"menu.settings": "Ρυθμίσεις",
"menu.shared_entries": "Κοινόχρηστες καταχωρήσεις",
"menu.show_all_entries": "Εμφάνιση όλων των καταχωρήσεων",
"menu.show_only_starred_entries": "Εμφάνιση μόνο αγαπημένων καταχωρήσεων",
"menu.show_only_unread_entries": "Εμφάνιση μόνο μη αναγνωσμένων καταχωρήσεων",
"menu.starred": "Αγαπημένα",
"menu.title": "Μενού",
"menu.unread": "Μη αναγνωσμένα",
"menu.users": "Χρήστες",
"page.about.author": "Συγγραφέας:",
"page.about.build_date": "Ημερομηνία Κατασκευής:",
"page.about.credits": "Συνεισφέροντες",
"page.about.db_usage": "Μέγεθος βάσης δεδομένων:",
"page.about.git_commit": "Υποβολή Git:",
"page.about.global_config_options": "Γενικές ρυθμίσεις",
"page.about.go_version": "Έκδοση Go:",
"page.about.license": "Άδεια:",
"page.about.postgres_version": "Έκδοση Postgres:",
"page.about.title": "Περί",
"page.about.version": "Έκδοση:",
"page.add_feed.choose_feed": "Επιλέξτε μια συνδρομή",
"page.add_feed.label.url": "Διεύθυνση URL",
"page.add_feed.legend.advanced_options": "Προχωρημένες Επιλογές",
"page.add_feed.no_category": "Δεν υπάρχει κατηγορία. Πρέπει να έχετε τουλάχιστον μία κατηγορία.",
"page.add_feed.submit": "Βρείτε μια συνδρομή",
"page.add_feed.title": "Νέα Συνδρομή",
"page.api_keys.never_used": "Δεν έχει χρησιμοποιηθεί ποτέ",
"page.api_keys.table.actions": "Eνέργειες",
"page.api_keys.table.created_at": "Ημερομηνία Δημιουργίας",
"page.api_keys.table.description": "Περιγραφή",
"page.api_keys.table.last_used_at": "Τελευταία Χρήση",
"page.api_keys.table.token": "Διακριτικό",
"page.api_keys.title": "Κλειδιά API",
"page.categories.entries": "Άρθρα",
"page.categories.feed_count": [
"Υπάρχει μία %d ροή.",
"Υπάρχουν %d ροές."
],
"page.categories.feeds": "Συνδρομές",
"page.categories.no_feed": "Καμία ροή.",
"page.categories.title": "Κατηγορίες",
"page.categories_count": [
"%d κατηγορία",
"%d κατηγορίες"
],
"page.category_label": "Κατηγορία: %s",
"page.edit_category.title": "Επεξεργασία κατηγορίας: % s",
"page.edit_feed.etag_header": "Κεφαλίδα ETag:",
"page.edit_feed.last_check": "Τελευταίος έλεγχος:",
"page.edit_feed.last_modified_header": "LastModified κεφαλίδα:",
"page.edit_feed.last_parsing_error": "Τελευταίο Σφάλμα Ανάλυσης",
"page.edit_feed.no_header": "Καμία",
"page.edit_feed.title": "Επεξεργασία ροής: % s",
"page.edit_user.title": "Επεξεργασία χρήστη: % s",
"page.entry.attachments": "Συνημμένα",
"page.feeds.error_count": [
"%d σφάλμα",
"%d σφάλματα"
],
"page.feeds.last_check": "Τελευταίος έλεγχος:",
"page.feeds.next_check": "Επόμενος έλεγχος:",
"page.feeds.read_counter": "Αριθμός αναγνωσμένων καταχωρήσεων",
"page.feeds.title": "Ροές",
"page.footer.elevator": "Επιστροφή στην κορυφή",
"page.history.title": "Ιστορικό",
"page.import.title": "Εισαγωγή",
"page.integration.bookmarklet": "Σελιδοδείκτης (bookmarklet)",
"page.integration.bookmarklet.help": "Αυτός ο ειδικός σύνδεσμος σάς επιτρέπει να εγγραφείτε απευθείας σε έναν ιστότοπο χρησιμοποιώντας ένα σελιδοδείκτη στο πρόγραμμα περιήγησης ιστού σας.",
"page.integration.bookmarklet.instructions": "Σύρετε και αποθέστε αυτόν τον σύνδεσμο στους σελιδοδείκτες σας.",
"page.integration.bookmarklet.name": "Προσθήκη στο Miniflux",
"page.integration.miniflux_api": "API του Miniflux",
"page.integration.miniflux_api_endpoint": "Τελικό σημείο API",
"page.integration.miniflux_api_password": "Κωδικός",
"page.integration.miniflux_api_password_value": "Ο κωδικός πρόσβασης του λογαριασμού σας",
"page.integration.miniflux_api_username": "Χρήστης",
"page.integrations.title": "Ενσωμάτωση",
"page.keyboard_shortcuts.close_modal": "Κλείσιμο παραθύρου διαλόγου",
"page.keyboard_shortcuts.download_content": "Κατεβάστε το αρχικό περιεχόμενο",
"page.keyboard_shortcuts.go_to_bottom_item": "Μετάβαση στο κάτω στοιχείο",
"page.keyboard_shortcuts.go_to_categories": "Μεταβείτε στις κατηγορίες",
"page.keyboard_shortcuts.go_to_feed": "Πηγαίνετε στη ροή",
"page.keyboard_shortcuts.go_to_feeds": "Μεταβείτε στις ροές",
"page.keyboard_shortcuts.go_to_history": "Μεταβείτε στο ιστορικό",
"page.keyboard_shortcuts.go_to_next_item": "Μετάβαση στο επόμενο στοιχείο",
"page.keyboard_shortcuts.go_to_next_page": "Μετάβαση στην επόμενη σελίδα",
"page.keyboard_shortcuts.go_to_previous_item": "Μεταβείτε στο προηγούμενο στοιχείο",
"page.keyboard_shortcuts.go_to_previous_page": "Μετάβαση στην προηγούμενη σελίδα",
"page.keyboard_shortcuts.go_to_search": "Ορίστε εστίαση στη φόρμα αναζήτησης",
"page.keyboard_shortcuts.go_to_settings": "Μεταβείτε στις ρυθμίσεις",
"page.keyboard_shortcuts.go_to_starred": "Μεταβείτε στους σελιδοδείκτες",
"page.keyboard_shortcuts.go_to_top_item": "Μετάβαση στο επάνω στοιχείο",
"page.keyboard_shortcuts.go_to_unread": "Μεταβείτε στα μη αναγνωσμένα",
"page.keyboard_shortcuts.mark_page_as_read": "Σημείωση της τρέχουσας σελίδας ως αναγνωσμένη",
"page.keyboard_shortcuts.open_comments": "Άνοιγμα συνδέσμου σχολίων",
"page.keyboard_shortcuts.open_comments_same_window": "Άνοιγμα συνδέσμου σχολίων στην τρέχουσα καρτέλα",
"page.keyboard_shortcuts.open_item": "Άνοιγμα επιλεγμένου στοιχείου",
"page.keyboard_shortcuts.open_original": "Άνοιγμα αρχικού συνδέσμου",
"page.keyboard_shortcuts.open_original_same_window": "Άνοιγμα αρχικού συνδέσμου στην τρέχουσα καρτέλα",
"page.keyboard_shortcuts.refresh_all_feeds": "Ανανέωση όλων των ροών στο παρασκήνιο",
"page.keyboard_shortcuts.remove_feed": "Κατάργηση αυτής της ροής",
"page.keyboard_shortcuts.save_article": "Αποθήκευση άρθρου",
"page.keyboard_shortcuts.scroll_item_to_top": "Μετακινηση στοιχείου στην κορυφή",
"page.keyboard_shortcuts.show_keyboard_shortcuts": "Εμφάνιση συντομεύσεων πληκτρολογίου",
"page.keyboard_shortcuts.subtitle.actions": "Ενέργειες",
"page.keyboard_shortcuts.subtitle.items": "Πλοήγηση Στοιχείων",
"page.keyboard_shortcuts.subtitle.pages": "Πλοήγηση Σελίδων",
"page.keyboard_shortcuts.subtitle.sections": "Πλοήγηση Τμημάτων",
"page.keyboard_shortcuts.title": "Συντομεύσεις Πληκτρολογίου",
"page.keyboard_shortcuts.toggle_star_status": "Εναλλαγή σελιδοδείκτη",
"page.keyboard_shortcuts.toggle_entry_attachments": "Εναλλαγή άνοιγμα/κλείσιμο συνημμένων καταχώρησης",
"page.keyboard_shortcuts.toggle_read_status_next": "Εναλλαγή ανάγνωσης / μη αναγνωσμένης, εστίαση στη συνέχεια",
"page.keyboard_shortcuts.toggle_read_status_prev": "Εναλλαγή ανάγνωσης / μη αναγνωσμένης, εστίαση στο προηγούμενο",
"page.login.google_signin": "Συνδεθείτε με τo Google",
"page.login.oidc_signin": "Συνδεθείτε με το %s",
"page.login.title": "Είσοδος",
"page.login.webauthn_login": "Είσοδος με κωδικό πρόσβασης",
"page.login.webauthn_login.error": "Δεν είναι δυνατή η σύνδεση με κωδικό πρόσβασης",
"page.login.webauthn_login.help": "Παρακαλώ εισαγάγετε το όνομα χρήστη σας εάν χρησιμοποιείτε κλειδί ασφαλείας. Αυτό δεν απαιτείται εάν χρησιμοποιείτε Passkey (ανακαλύψιμα διαπιστευτήρια).",
"page.new_api_key.title": "Νέο κλειδί API",
"page.new_category.title": "Νέα Κατηγορία",
"page.new_user.title": "Νέος Χρήστης",
"page.offline.message": "Είστε εκτός σύνδεσης",
"page.offline.refresh_page": "Προσπαθήστε να ανανεώσετε τη σελίδα",
"page.offline.title": "Λειτουργία Εκτός Σύνδεσης",
"page.read_entry_count": [
"%d αναγνωσμένη καταχώρηση",
"%d αναγνωσμένες καταχωρήσεις"
],
"page.search.title": "Αποτελέσματα Αναζήτησης",
"page.sessions.table.actions": "Eνέργειες",
"page.sessions.table.current_session": "Τρέχουσα Συνεδρία",
"page.sessions.table.date": "Ημερομηνία",
"page.sessions.table.ip": "Διεύθυνση IP",
"page.sessions.table.user_agent": "Πρόγραμμα περιήγησης (User Agent)",
"page.sessions.title": "Συνεδρίες",
"page.settings.link_google_account": "Σύνδεση του λογαριασμό μου Google",
"page.settings.link_oidc_account": "Σύνδεση του λογαριασμού μου %s",
"page.settings.title": "Ρυθμίσεις",
"page.settings.unlink_google_account": "Αποσύνδεση του λογαριασμού μου Google",
"page.settings.unlink_oidc_account": "Αποσύνδεση του λογαριασμού μου %s",
"page.settings.webauthn.actions": "Ενέργειες",
"page.settings.webauthn.added_on": "Προστέθηκε στις",
"page.settings.webauthn.delete": [
"Κατάργηση %d κωδικού πρόσβασης",
"Κατάργηση %d κωδικών πρόσβασης"
],
"page.settings.webauthn.last_seen_on": "Τελευταία χρήση",
"page.settings.webauthn.passkey_name": "Όνομα κωδικού πρόσβασης",
"page.settings.webauthn.passkeys": "Κωδικοί πρόσβασης",
"page.settings.webauthn.register": "Εγγραφή κωδικού πρόσβασης",
"page.settings.webauthn.register.error": "Δεν είναι δυνατή η εγγραφή του κωδικού πρόσβασης",
"page.shared_entries.title": "Κοινόχρηστες Καταχωρήσεις",
"page.shared_entries_count": [
"%d κοινόχρηστη καταχώρηση",
"%d κοινόχρηστες καταχωρήσεις"
],
"page.starred.title": "Αγαπημένo",
"page.starred_entry_count": [
"%d καταχώρηση με αστέρι",
"%d καταχωρήσεις με αστέρι"
],
"page.total_entry_count": [
"%d καταχώρηση συνολικά",
"%d καταχωρήσεις συνολικά"
],
"page.unread.title": "Μη αναγνωσμένα",
"page.unread_entry_count": [
"%d μη αναγνωσμένη καταχώρηση",
"%d μη αναγνωσμένες καταχωρήσεις"
],
"page.users.actions": "Eνέργειες",
"page.users.admin.no": "Όχι",
"page.users.admin.yes": "Ναι.",
"page.users.is_admin": "Διαχειριστής",
"page.users.last_login": "Τελευταία Σύνδεση",
"page.users.never_logged": "Ποτέ",
"page.users.title": "Χρήστες",
"page.users.username": "Χρήστης",
"page.webauthn_rename.title": "Μετονομασία κωδικού πρόσβασης",
"pagination.first": "Πρώτο",
"pagination.last": "Τελευταίο",
"pagination.next": "Επόμενη",
"pagination.previous": "Προηγούμενη",
"search.label": "Αναζήτηση",
"search.placeholder": "Αναζήτηση...",
"search.submit": "Αναζήτηση",
"skip_to_content": "Μετάβαση στο περιεχόμενο",
"time_elapsed.days": [
"πριν %d ημέρα",
"πριν %d ημέρες"
],
"time_elapsed.hours": [
"πριν %d ώρα",
"πριν %d ώρες"
],
"time_elapsed.minutes": [
"πριν %d λεπτό",
"πριν %d λεπτά"
],
"time_elapsed.months": [
"πριν %d μήνα",
"πριν %d μήνες"
],
"time_elapsed.not_yet": "όχι ακόμα.",
"time_elapsed.now": "μόλις τώρα",
"time_elapsed.weeks": [
"πριν %d εβδομάδα",
"πριν %d εβδομάδες"
],
"time_elapsed.years": [
"πριν %d έτος",
"πριν %d έτη"
],
"time_elapsed.yesterday": "χθες",
"tooltip.keyboard_shortcuts": "Συντόμευση πληκτρολογίου: % s",
"tooltip.logged_user": "Συνδεδεμένος/η ως %s"
}
v2-2.2.16/internal/locale/translations/en_US.json 0000664 0000000 0000000 00000110013 15127074645 0021640 0 ustar 00root root 0000000 0000000 {
"action.cancel": "cancel",
"action.download": "Download",
"action.edit": "Edit",
"action.home_screen": "Add to home screen",
"action.import": "Import",
"action.login": "Login",
"action.or": "or",
"action.remove": "Remove",
"action.remove_feed": "Remove this feed",
"action.save": "Save",
"action.subscribe": "Subscribe",
"action.update": "Update",
"alert.account_linked": "Your external account is now linked!",
"alert.account_unlinked": "Your external account is now dissociated!",
"alert.background_feed_refresh": "All feeds are being refreshed in the background. You can continue to use Miniflux while this process is running.",
"alert.feed_error": "There is a problem with this feed",
"alert.no_starred": "There are no starred entries.",
"alert.no_category": "There is no category.",
"alert.no_category_entry": "There are no entries in this category.",
"alert.no_feed": "You don’t have any feeds.",
"alert.no_feed_entry": "There are no entries for this feed.",
"alert.no_feed_in_category": "There is no feed for this category.",
"alert.no_history": "There is no history at the moment.",
"alert.no_search_result": "There are no results for this search.",
"alert.no_shared_entry": "There is no shared entry.",
"alert.no_tag_entry": "There are no entries matching this tag.",
"alert.no_unread_entry": "There are no unread entries.",
"alert.no_user": "You are the only user.",
"alert.prefs_saved": "Preferences saved!",
"alert.too_many_feeds_refresh": [
"You have triggered too many feed refreshes. Please wait %d minute before trying again.",
"You have triggered too many feed refreshes. Please wait %d minutes before trying again."
],
"confirm.loading": "In progress…",
"confirm.no": "no",
"confirm.question": "Are you sure?",
"confirm.question.refresh": "Are you sure you want to force refresh?",
"confirm.yes": "yes",
"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.reset": "Reset",
"enclosure_media_controls.speed.reset.title": "Reset speed to 1x",
"enclosure_media_controls.speed.slower": "Slower",
"enclosure_media_controls.speed.slower.title": "Slower by %sx",
"entry.starred.toast.off": "Unstarred",
"entry.starred.toast.on": "Starred",
"entry.starred.toggle.off": "Unstar",
"entry.starred.toggle.on": "Star",
"entry.comments.label": "Comments",
"entry.comments.title": "View Comments",
"entry.estimated_reading_time": [
"%d minute read",
"%d minutes read"
],
"entry.external_link.label": "External link",
"entry.save.completed": "Done!",
"entry.save.label": "Save",
"entry.save.title": "Save this entry",
"entry.save.toast.completed": "Entry saved",
"entry.scraper.completed": "Done!",
"entry.scraper.label": "Download",
"entry.scraper.title": "Fetch original content",
"entry.share.label": "Share",
"entry.share.title": "Share this entry",
"entry.shared_entry.label": "Share",
"entry.shared_entry.title": "Open the public link",
"entry.state.loading": "Loading…",
"entry.state.saving": "Saving…",
"entry.status.mark_as_read": "Mark as read",
"entry.status.mark_as_unread": "Mark as unread",
"entry.status.title": "Change entry status",
"entry.status.toast.read": "Marked as read",
"entry.status.toast.unread": "Marked as unread",
"entry.tags.label": "Tags:",
"entry.tags.more_tags_label": [
"Show %d more tag",
"Show %d more tags"
],
"entry.unshare.label": "Unshare",
"error.api_key_already_exists": "This API Key already exists.",
"error.bad_credentials": "Invalid username or password.",
"error.category_already_exists": "This category already exists.",
"error.category_not_found": "This category does not exist or does not belong to this user.",
"error.database_error": "Database error: %v.",
"error.different_passwords": "Passwords are not the same.",
"error.duplicate_fever_username": "There is already someone else with the same Fever username!",
"error.duplicate_googlereader_username": "There is already someone else with the same Google Reader username!",
"error.linktaco_missing_required_fields": "LinkTaco API Token and Organization Slug are required",
"error.duplicate_linked_account": "There is already someone associated with this provider!",
"error.duplicated_feed": "This feed already exists.",
"error.empty_file": "This file is empty.",
"error.entries_per_page_invalid": "The number of entries per page is not valid.",
"error.feed_already_exists": "This feed already exists.",
"error.feed_category_not_found": "This category does not exist or does not belong to this user.",
"error.feed_format_not_detected": "Unable to detect feed format: %v.",
"error.feed_invalid_blocklist_rule": "The block list rule is invalid.",
"error.feed_invalid_keeplist_rule": "The keep list rule is invalid.",
"error.feed_mandatory_fields": "The URL and the category are mandatory.",
"error.feed_not_found": "This feed does not exist or does not belong to this user.",
"error.feed_title_not_empty": "The feed title cannot be empty.",
"error.feed_url_not_empty": "The feed URL cannot be empty.",
"error.fields_mandatory": "All fields are mandatory.",
"error.http_bad_gateway": "The website is not available at the moment due to a bad gateway error. The problem is not on Miniflux side. Please, try again later.",
"error.http_body_read": "Unable to read the HTTP body: %v.",
"error.http_client_error": "HTTP client error: %v.",
"error.http_empty_response": "The HTTP response is empty. Perhaps, this website is using a bot protection mechanism?",
"error.http_empty_response_body": "The HTTP response body is empty.",
"error.http_forbidden": "Access to this website is forbidden. Perhaps, this website has a bot protection mechanism?",
"error.http_gateway_timeout": "The website is not available at the moment due to a gateway timeout error. The problem is not on Miniflux side. Please, try again later.",
"error.http_internal_server_error": "The website is not available at the moment due to a server error. The problem is not on Miniflux side. Please, try again later.",
"error.http_not_authorized": "Access to this website is not authorized. It could be a bad username or password.",
"error.http_resource_not_found": "The requested resource is not found. Please, verify the URL.",
"error.http_response_too_large": "The HTTP response is too large. You could increase the HTTP response size limit in the global settings (requires a server restart).",
"error.http_service_unavailable": "The website is not available at the moment due to an internal server error. The problem is not on Miniflux side. Please, try again later.",
"error.http_too_many_requests": "Miniflux generated too many requests to this website. Please, try again later or change the application configuration.",
"error.http_unexpected_status_code": "The website is not available at the moment due to an unexpected HTTP status code: %d. The problem is not on Miniflux side. Please, try again later.",
"error.invalid_categories_sorting_order": "Invalid categories sorting order.",
"error.invalid_default_home_page": "Invalid default homepage!",
"error.invalid_display_mode": "Invalid web app display mode.",
"error.invalid_entry_direction": "Invalid entry direction.",
"error.invalid_entry_order": "Invalid entry order.",
"error.invalid_feed_proxy_url": "Invalid proxy URL.",
"error.invalid_feed_url": "Invalid feed URL.",
"error.invalid_gesture_nav": "Invalid gesture navigation.",
"error.invalid_language": "Invalid language.",
"error.invalid_site_url": "Invalid site URL.",
"error.invalid_theme": "Invalid theme.",
"error.invalid_timezone": "Invalid timezone.",
"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.password_min_length": "The password must have at least 6 characters.",
"error.proxy_url_not_empty": "The proxy URL cannot be empty.",
"error.settings_block_rule_fieldname_invalid": "Invalid Block rule: rule #%d is missing a valid field name (Options: %s)",
"error.settings_block_rule_invalid_regex": "Invalid Block rule: rule #%d's pattern is not a valid regex",
"error.settings_block_rule_regex_required": "Invalid Block rule: rule #%d's pattern is not provided",
"error.settings_block_rule_separator_required": "Invalid Block rule: rule #%d's pattern is required to be seperated by a '='",
"error.settings_invalid_domain_list": "Invalid domain list. Please provide a space separated list of domains.",
"error.settings_keep_rule_fieldname_invalid": "Invalid Keep rule: rule #%d is missing a valid field name (Options: %s)",
"error.settings_keep_rule_invalid_regex": "Invalid Keep rule: rule #%d's pattern is not a valid regex",
"error.settings_keep_rule_regex_required": "Invalid Keep rule: rule #%d pattern is not provided",
"error.settings_keep_rule_separator_required": "Invalid Keep rule: rule #%d's pattern is required to be seperated by a '='",
"error.settings_mandatory_fields": "The username, theme, language and timezone fields are mandatory.",
"error.settings_media_playback_rate_range": "Playback speed is out of range",
"error.settings_reading_speed_is_positive": "The reading speeds must be positive integers.",
"error.site_url_not_empty": "The site URL cannot be empty.",
"error.subscription_not_found": "Unable to find any feed.",
"error.title_required": "The title is mandatory.",
"error.tls_error": "TLS error: %q. You could disable TLS verification in the feed settings if you would like.",
"error.unable_to_create_api_key": "Unable to create this API Key.",
"error.unable_to_create_category": "Unable to create this category.",
"error.unable_to_create_user": "Unable to create this user.",
"error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.",
"error.unable_to_parse_feed": "Unable to parse this feed: %v.",
"error.unable_to_update_category": "Unable to update this category.",
"error.unable_to_update_feed": "Unable to update this feed.",
"error.unable_to_update_user": "Unable to update this user.",
"error.unlink_account_without_password": "You must define a password otherwise you won’t be able to login again.",
"error.user_already_exists": "This user already exists.",
"error.user_mandatory_fields": "The username is mandatory.",
"form.api_key.label.description": "API Key Label",
"form.category.hide_globally": "Hide entries in global unread list",
"form.category.label.title": "Title",
"form.feed.fieldset.general": "General",
"form.feed.fieldset.integration": "Third-Party Services",
"form.feed.fieldset.network_settings": "Network Settings",
"form.feed.fieldset.rules": "Rules",
"form.feed.label.allow_self_signed_certificates": "Allow self-signed or invalid certificates",
"form.feed.label.apprise_service_urls": "Comma separated list of Apprise service URLs",
"form.feed.label.block_filter_entry_rules": "Entry Blocking Rules",
"form.feed.label.blocklist_rules": "Regex-Based Blocking Filters",
"form.feed.label.category": "Category",
"form.feed.label.cookie": "Set Cookies",
"form.feed.label.crawler": "Fetch original content",
"form.feed.label.description": "Description",
"form.feed.label.disable_http2": "Disable HTTP/2 to avoid fingerprinting",
"form.feed.label.disabled": "Do not refresh this feed",
"form.feed.label.feed_password": "Feed Password",
"form.feed.label.feed_url": "Feed URL",
"form.feed.label.feed_username": "Feed Username",
"form.feed.label.fetch_via_proxy": "Use the proxy configured at the application level",
"form.feed.label.hide_globally": "Hide entries in global unread list",
"form.feed.label.ignore_http_cache": "Ignore HTTP cache",
"form.feed.label.keep_filter_entry_rules": "Entry Allow Rules",
"form.feed.label.keeplist_rules": "Regex-Based Keep Filters",
"form.feed.label.no_media_player": "No media player (audio/video)",
"form.feed.label.ntfy_activate": "Push entries to ntfy",
"form.feed.label.ntfy_default_priority": "Ntfy default priority",
"form.feed.label.ntfy_high_priority": "Ntfy high priority",
"form.feed.label.ntfy_low_priority": "Ntfy low priority",
"form.feed.label.ntfy_max_priority": "Ntfy max priority",
"form.feed.label.ntfy_min_priority": "Ntfy min priority",
"form.feed.label.ntfy_priority": "Ntfy priority",
"form.feed.label.ntfy_topic": "Ntfy topic (optional)",
"form.feed.label.proxy_url": "Proxy URL",
"form.feed.label.pushover_activate": "Push entries to Pushover",
"form.feed.label.pushover_default_priority": "Default priority",
"form.feed.label.pushover_high_priority": "High priority",
"form.feed.label.pushover_low_priority": "Low priority",
"form.feed.label.pushover_max_priority": "Max priority",
"form.feed.label.pushover_min_priority": "Minimal priority",
"form.feed.label.pushover_priority": "Pushover message priority",
"form.feed.label.rewrite_rules": "Content Rewrite Rules",
"form.feed.label.scraper_rules": "Scraper Rules",
"form.feed.label.site_url": "Site URL",
"form.feed.label.title": "Title",
"form.feed.label.urlrewrite_rules": "URL Rewrite Rules",
"form.feed.label.user_agent": "Override Default User Agent",
"form.feed.label.webhook_url": "Override webhook url",
"form.import.label.file": "OPML file",
"form.import.label.url": "URL",
"form.integration.archiveorg_activate": "Push entries to archive.org",
"form.integration.apprise_activate": "Push entries to Apprise",
"form.integration.apprise_services_url": "Comma separated list of Apprise service URLs",
"form.integration.apprise_url": "Apprise API URL",
"form.integration.betula_activate": "Save entries to Betula",
"form.integration.betula_token": "Betula Token",
"form.integration.betula_url": "Betula server URL",
"form.integration.cubox_activate": "Save entries to Cubox",
"form.integration.cubox_api_link": "Cubox API link",
"form.integration.discord_activate": "Push entries to Discord",
"form.integration.discord_webhook_link": "Discord Webhook link",
"form.integration.espial_activate": "Save entries to Espial",
"form.integration.espial_api_key": "Espial API key",
"form.integration.espial_endpoint": "Espial API Endpoint",
"form.integration.espial_tags": "Espial Tags",
"form.integration.fever_activate": "Activate Fever API",
"form.integration.fever_endpoint": "Fever API endpoint:",
"form.integration.fever_password": "Fever Password",
"form.integration.fever_username": "Fever Username",
"form.integration.googlereader_activate": "Activate Google Reader API",
"form.integration.googlereader_endpoint": "Google Reader API endpoint:",
"form.integration.googlereader_password": "Google Reader Password",
"form.integration.googlereader_username": "Google Reader Username",
"form.integration.instapaper_activate": "Save entries to Instapaper",
"form.integration.instapaper_password": "Instapaper Password",
"form.integration.instapaper_username": "Instapaper Username",
"form.integration.karakeep_activate": "Save entries to Karakeep",
"form.integration.karakeep_api_key": "Karakeep API key",
"form.integration.karakeep_url": "Karakeep API Endpoint",
"form.integration.karakeep_tags": "Karakeep Tags",
"form.integration.linkace_activate": "Save entries to LinkAce",
"form.integration.linkace_api_key": "LinkAce API key",
"form.integration.linkace_check_disabled": "Disable link check",
"form.integration.linkace_endpoint": "LinkAce API Endpoint",
"form.integration.linkace_is_private": "Mark link as private",
"form.integration.linkace_tags": "LinkAce Tags",
"form.integration.linkding_activate": "Save entries to Linkding",
"form.integration.linkding_api_key": "Linkding API key",
"form.integration.linkding_bookmark": "Mark bookmark as unread",
"form.integration.linkding_endpoint": "Linkding API Endpoint",
"form.integration.linkding_tags": "Linkding Tags",
"form.integration.linktaco_activate": "Save entries to LinkTaco",
"form.integration.linktaco_api_token": "LinkTaco API Token",
"form.integration.linktaco_api_token_hint": "Get your personal access token at",
"form.integration.linktaco_org_slug": "Organization Slug",
"form.integration.linktaco_tags": "Tags (max 10, comma-separated)",
"form.integration.linktaco_tags_hint": "Maximum 10 tags, comma-separated",
"form.integration.linktaco_visibility": "Visibility",
"form.integration.linktaco_visibility_public": "Public",
"form.integration.linktaco_visibility_private": "Private",
"form.integration.linktaco_visibility_hint": "PRIVATE visibility requires a paid LinkTaco account",
"form.integration.linkwarden_activate": "Save entries to Linkwarden",
"form.integration.linkwarden_api_key": "Linkwarden API key",
"form.integration.linkwarden_endpoint": "Linkwarden Base URL",
"form.integration.linkwarden_collection_id": "Linkwarden Collection ID",
"form.integration.matrix_bot_activate": "Push new entries to Matrix",
"form.integration.matrix_bot_chat_id": "ID of Matrix Room",
"form.integration.matrix_bot_password": "Password for Matrix user",
"form.integration.matrix_bot_url": "Matrix server URL",
"form.integration.matrix_bot_user": "Username for Matrix",
"form.integration.notion_activate": "Save entries to Notion",
"form.integration.notion_page_id": "Notion Page ID",
"form.integration.notion_token": "Notion Secret Token",
"form.integration.ntfy_activate": "Push entries to ntfy",
"form.integration.ntfy_api_token": "Ntfy API Token (optional)",
"form.integration.ntfy_icon_url": "Ntfy Icon URL (optional)",
"form.integration.ntfy_internal_links": "Use internal links on click (optional)",
"form.integration.ntfy_password": "Ntfy Password (optional)",
"form.integration.ntfy_topic": "Ntfy topic (default used if not set in feed)",
"form.integration.ntfy_url": "Ntfy URL (optional, default is ntfy.sh)",
"form.integration.ntfy_username": "Ntfy Username (optional)",
"form.integration.nunux_keeper_activate": "Save entries to Nunux Keeper",
"form.integration.nunux_keeper_api_key": "Nunux Keeper API key",
"form.integration.nunux_keeper_endpoint": "Nunux Keeper API Endpoint",
"form.integration.omnivore_activate": "Save entries to Omnivore",
"form.integration.omnivore_api_key": "Omnivore API key",
"form.integration.omnivore_url": "Omnivore API Endpoint",
"form.integration.pinboard_activate": "Save entries to Pinboard",
"form.integration.pinboard_bookmark": "Mark bookmark as unread",
"form.integration.pinboard_tags": "Pinboard Tags",
"form.integration.pinboard_token": "Pinboard API Token",
"form.integration.pushover_activate": "Push entries to Pushover",
"form.integration.pushover_device": "Pushover device (optional)",
"form.integration.pushover_prefix": "Pushover URL prefix (optional)",
"form.integration.pushover_token": "Pushover application API token",
"form.integration.pushover_user": "Pushover user key",
"form.integration.raindrop_activate": "Save entries to Raindrop",
"form.integration.raindrop_collection_id": "Collection ID",
"form.integration.raindrop_tags": "Tags (comma-separated)",
"form.integration.raindrop_token": "(Test) Token",
"form.integration.readeck_activate": "Save entries to readeck",
"form.integration.readeck_api_key": "Readeck API key",
"form.integration.readeck_endpoint": "Readeck URL",
"form.integration.readeck_labels": "Readeck Labels",
"form.integration.readeck_only_url": "Send only URL (instead of full content)",
"form.integration.readeck_push_activate": "Automatically push new entries to Readeck",
"form.integration.readwise_activate": "Save entries to Readwise Reader",
"form.integration.readwise_api_key": "Readwise Reader Access Token",
"form.integration.readwise_api_key_link": "Get your Readwise Access Token",
"form.integration.rssbridge_activate": "Check RSS-Bridge when adding subscriptions",
"form.integration.rssbridge_token": "RSS-Bridge authentication token",
"form.integration.rssbridge_url": "RSS-Bridge server URL",
"form.integration.shaarli_activate": "Save articles to Shaarli",
"form.integration.shaarli_api_secret": "Shaarli API Secret",
"form.integration.shaarli_endpoint": "Shaarli URL",
"form.integration.shiori_activate": "Save articles to Shiori",
"form.integration.shiori_endpoint": "Shiori API Endpoint",
"form.integration.shiori_password": "Shiori Password",
"form.integration.shiori_username": "Shiori Username",
"form.integration.slack_activate": "Push entries to Slack",
"form.integration.slack_webhook_link": "Slack Webhook link",
"form.integration.telegram_bot_activate": "Push new entries to Telegram chat",
"form.integration.telegram_bot_disable_buttons": "Disable buttons",
"form.integration.telegram_bot_disable_notification": "Disable notification",
"form.integration.telegram_bot_disable_web_page_preview": "Disable web page preview",
"form.integration.telegram_bot_token": "Bot token",
"form.integration.telegram_chat_id": "Chat ID",
"form.integration.telegram_topic_id": "Topic ID",
"form.integration.wallabag_activate": "Save entries to Wallabag",
"form.integration.wallabag_client_id": "Wallabag Client ID",
"form.integration.wallabag_client_secret": "Wallabag Client Secret",
"form.integration.wallabag_endpoint": "Wallabag Base URL",
"form.integration.wallabag_only_url": "Send only URL (instead of full content)",
"form.integration.wallabag_password": "Wallabag Password",
"form.integration.wallabag_username": "Wallabag Username",
"form.integration.wallabag_tags": "Wallabag Tags",
"form.integration.webhook_activate": "Enable Webhooks",
"form.integration.webhook_secret": "Webhooks Secret",
"form.integration.webhook_url": "Default Webhook URL",
"form.prefs.fieldset.application_settings": "Application Settings",
"form.prefs.fieldset.authentication_settings": "Authentication Settings",
"form.prefs.fieldset.global_feed_settings": "Global Feed Settings",
"form.prefs.fieldset.reader_settings": "Reader Settings",
"form.prefs.help.external_font_hosts": "Space separated list of external font hosts to allow. For example: \"fonts.gstatic.com fonts.googleapis.com\".",
"form.prefs.label.always_open_external_links": "Read articles by opening external links",
"form.prefs.label.categories_sorting_order": "Categories sorting",
"form.prefs.label.cjk_reading_speed": "Reading speed for Chinese, Korean and Japanese (characters per minute)",
"form.prefs.label.custom_css": "Custom CSS",
"form.prefs.label.custom_js": "Custom JavaScript",
"form.prefs.label.default_home_page": "Default home page",
"form.prefs.label.default_reading_speed": "Reading speed for other languages (words per minute)",
"form.prefs.label.display_mode": "Progressive Web App (PWA) display mode",
"form.prefs.label.entries_per_page": "Entries per page",
"form.prefs.label.entry_order": "Entry sorting column",
"form.prefs.label.entry_sorting": "Entry sorting",
"form.prefs.label.entry_swipe": "Enable entry swipe on touch screens",
"form.prefs.label.external_font_hosts": "External font hosts",
"form.prefs.label.gesture_nav": "Gesture to navigate between entries",
"form.prefs.label.keyboard_shortcuts": "Enable keyboard shortcuts",
"form.prefs.label.language": "Language",
"form.prefs.label.mark_read_manually": "Mark entries as read manually",
"form.prefs.label.mark_read_on_media_completion": "Only mark as read when audio/video playback reaches 90%% completion",
"form.prefs.label.mark_read_on_view": "Automatically mark entries as read when viewed",
"form.prefs.label.mark_read_on_view_or_media_completion": "Mark entries as read when viewed. For audio/video, mark as read at 90%% completion",
"form.prefs.label.media_playback_rate": "Playback speed of the audio/video",
"form.prefs.label.open_external_links_in_new_tab": "Open external links in a new tab (adds target=\"_blank\" to links)",
"form.prefs.label.show_reading_time": "Show estimated reading time for entries",
"form.prefs.label.theme": "Theme",
"form.prefs.label.timezone": "Timezone",
"form.prefs.select.alphabetical": "Alphabetical",
"form.prefs.select.browser": "Browser",
"form.prefs.select.created_time": "Entry created time",
"form.prefs.select.fullscreen": "Fullscreen",
"form.prefs.select.minimal_ui": "Minimal",
"form.prefs.select.none": "None",
"form.prefs.select.older_first": "Older entries first",
"form.prefs.select.publish_time": "Entry published time",
"form.prefs.select.recent_first": "Recent entries first",
"form.prefs.select.standalone": "Standalone",
"form.prefs.select.swipe": "Swipe",
"form.prefs.select.tap": "Double tap",
"form.prefs.select.unread_count": "Unread count",
"form.submit.loading": "Loading…",
"form.submit.saving": "Saving…",
"form.user.label.admin": "Administrator",
"form.user.label.confirmation": "Password Confirmation",
"form.user.label.password": "Password",
"form.user.label.username": "Username",
"menu.about": "About",
"menu.add_feed": "Add feed",
"menu.add_user": "Add user",
"menu.api_keys": "API Keys",
"menu.categories": "Categories",
"menu.create_api_key": "Create a new API key",
"menu.create_category": "Create a category",
"menu.edit_category": "Edit",
"menu.edit_feed": "Edit",
"menu.export": "Export",
"menu.feed_entries": "Entries",
"menu.feeds": "Feeds",
"menu.flush_history": "Flush history",
"menu.history": "History",
"menu.home_page": "Home page",
"menu.import": "Import",
"menu.integrations": "Integrations",
"menu.logout": "Logout",
"menu.mark_all_as_read": "Mark all as read",
"menu.mark_page_as_read": "Mark this page as read",
"menu.preferences": "Preferences",
"menu.refresh_all_feeds": "Refresh all feeds in the background",
"menu.refresh_feed": "Refresh",
"menu.search": "Search",
"menu.sessions": "Sessions",
"menu.settings": "Settings",
"menu.shared_entries": "Shared entries",
"menu.show_all_entries": "Show all entries",
"menu.show_only_starred_entries": "Show only starred entries",
"menu.show_only_unread_entries": "Show only unread entries",
"menu.starred": "Starred",
"menu.title": "Menu",
"menu.unread": "Unread",
"menu.users": "Users",
"page.about.author": "Author:",
"page.about.build_date": "Build Date:",
"page.about.credits": "Credits",
"page.about.db_usage": "Database size:",
"page.about.git_commit": "Git Commit:",
"page.about.global_config_options": "Global configuration options",
"page.about.go_version": "Go version:",
"page.about.license": "License:",
"page.about.postgres_version": "Postgres version:",
"page.about.title": "About",
"page.about.version": "Version:",
"page.add_feed.choose_feed": "Choose a feed",
"page.add_feed.label.url": "URL",
"page.add_feed.legend.advanced_options": "Advanced Options",
"page.add_feed.no_category": "There is no category. You must have at least one category.",
"page.add_feed.submit": "Find a feed",
"page.add_feed.title": "New feed",
"page.api_keys.never_used": "Never Used",
"page.api_keys.table.actions": "Actions",
"page.api_keys.table.created_at": "Creation Date",
"page.api_keys.table.description": "Description",
"page.api_keys.table.last_used_at": "Last Used",
"page.api_keys.table.token": "Token",
"page.api_keys.title": "API Keys",
"page.categories.entries": "Entries",
"page.categories.feed_count": [
"There is %d feed.",
"There are %d feeds."
],
"page.categories.feeds": "Feeds",
"page.categories.no_feed": "No feed.",
"page.categories.title": "Categories",
"page.categories_count": [
"%d category",
"%d categories"
],
"page.category_label": "Category: %s",
"page.edit_category.title": "Edit Category: %s",
"page.edit_feed.etag_header": "ETag header:",
"page.edit_feed.last_check": "Last check:",
"page.edit_feed.last_modified_header": "LastModified header:",
"page.edit_feed.last_parsing_error": "Last Parsing Error",
"page.edit_feed.no_header": "None",
"page.edit_feed.title": "Edit Feed: %s",
"page.edit_user.title": "Edit User: %s",
"page.entry.attachments": "Attachments",
"page.feeds.error_count": [
"%d error",
"%d errors"
],
"page.feeds.last_check": "Last check:",
"page.feeds.next_check": "Next check:",
"page.feeds.read_counter": "Number of read entries",
"page.feeds.title": "Feeds",
"page.footer.elevator": "Back to top",
"page.history.title": "History",
"page.import.title": "Import",
"page.integration.bookmarklet": "Bookmarklet",
"page.integration.bookmarklet.help": "This special link allows you to subscribe to a website directly by using a bookmark in your web browser.",
"page.integration.bookmarklet.instructions": "Drag and drop this link to your bookmarks.",
"page.integration.bookmarklet.name": "Add to Miniflux",
"page.integration.miniflux_api": "Miniflux API",
"page.integration.miniflux_api_endpoint": "API Endpoint",
"page.integration.miniflux_api_password": "Password",
"page.integration.miniflux_api_password_value": "Your account password",
"page.integration.miniflux_api_username": "Username",
"page.integrations.title": "Integrations",
"page.keyboard_shortcuts.close_modal": "Close modal dialog",
"page.keyboard_shortcuts.download_content": "Download original content",
"page.keyboard_shortcuts.go_to_bottom_item": "Go to bottom item",
"page.keyboard_shortcuts.go_to_categories": "Go to categories",
"page.keyboard_shortcuts.go_to_feed": "Go to feed",
"page.keyboard_shortcuts.go_to_feeds": "Go to feeds",
"page.keyboard_shortcuts.go_to_history": "Go to history",
"page.keyboard_shortcuts.go_to_next_item": "Go to next item",
"page.keyboard_shortcuts.go_to_next_page": "Go to next page",
"page.keyboard_shortcuts.go_to_previous_item": "Go to previous item",
"page.keyboard_shortcuts.go_to_previous_page": "Go to previous page",
"page.keyboard_shortcuts.go_to_search": "Set focus on search form",
"page.keyboard_shortcuts.go_to_settings": "Go to settings",
"page.keyboard_shortcuts.go_to_starred": "Go to starred",
"page.keyboard_shortcuts.go_to_top_item": "Go to top item",
"page.keyboard_shortcuts.go_to_unread": "Go to unread",
"page.keyboard_shortcuts.mark_page_as_read": "Mark current page as read",
"page.keyboard_shortcuts.open_comments": "Open comments link",
"page.keyboard_shortcuts.open_comments_same_window": "Open comments link in current tab",
"page.keyboard_shortcuts.open_item": "Open selected item",
"page.keyboard_shortcuts.open_original": "Open original link",
"page.keyboard_shortcuts.open_original_same_window": "Open original link in current tab",
"page.keyboard_shortcuts.refresh_all_feeds": "Refresh all feeds in the background",
"page.keyboard_shortcuts.remove_feed": "Remove this feed",
"page.keyboard_shortcuts.save_article": "Save entry",
"page.keyboard_shortcuts.scroll_item_to_top": "Scroll item to top",
"page.keyboard_shortcuts.show_keyboard_shortcuts": "Show keyboard shortcuts",
"page.keyboard_shortcuts.subtitle.actions": "Actions",
"page.keyboard_shortcuts.subtitle.items": "Items Navigation",
"page.keyboard_shortcuts.subtitle.pages": "Pages Navigation",
"page.keyboard_shortcuts.subtitle.sections": "Sections Navigation",
"page.keyboard_shortcuts.title": "Keyboard Shortcuts",
"page.keyboard_shortcuts.toggle_star_status": "Toggle starred",
"page.keyboard_shortcuts.toggle_entry_attachments": "Toggle open/close entry attachments",
"page.keyboard_shortcuts.toggle_read_status_next": "Toggle read/unread, focus next",
"page.keyboard_shortcuts.toggle_read_status_prev": "Toggle read/unread, focus previous",
"page.login.google_signin": "Sign in with Google",
"page.login.oidc_signin": "Sign in with %s",
"page.login.title": "Sign In",
"page.login.webauthn_login": "Login with passkey",
"page.login.webauthn_login.error": "Unable to login with passkey",
"page.login.webauthn_login.help": "Please enter your username if you're using a security key. This is not required if you are using a Passkey (discoverable credentials).",
"page.new_api_key.title": "New API Key",
"page.new_category.title": "New Category",
"page.new_user.title": "New User",
"page.offline.message": "You are offline",
"page.offline.refresh_page": "Try to refresh the page",
"page.offline.title": "Offline Mode",
"page.read_entry_count": [
"%d read entry",
"%d read entries"
],
"page.search.title": "Search Results",
"page.sessions.table.actions": "Actions",
"page.sessions.table.current_session": "Current Session",
"page.sessions.table.date": "Date",
"page.sessions.table.ip": "IP Address",
"page.sessions.table.user_agent": "User Agent",
"page.sessions.title": "Sessions",
"page.settings.link_google_account": "Link my Google account",
"page.settings.link_oidc_account": "Link my %s account",
"page.settings.title": "Settings",
"page.settings.unlink_google_account": "Unlink my Google account",
"page.settings.unlink_oidc_account": "Unlink my %s account",
"page.settings.webauthn.actions": "Actions",
"page.settings.webauthn.added_on": "Added On",
"page.settings.webauthn.delete": [
"Remove %d passkey",
"Remove %d passkeys"
],
"page.settings.webauthn.last_seen_on": "Last Used",
"page.settings.webauthn.passkey_name": "Passkey Name",
"page.settings.webauthn.passkeys": "Passkeys",
"page.settings.webauthn.register": "Register passkey",
"page.settings.webauthn.register.error": "Unable to register passkey",
"page.shared_entries.title": "Shared entries",
"page.shared_entries_count": [
"%d shared entry",
"%d shared entries"
],
"page.starred.title": "Starred",
"page.starred_entry_count": [
"%d starred entry",
"%d starred entries"
],
"page.total_entry_count": [
"%d entry in total",
"%d entries in total"
],
"page.unread.title": "Unread",
"page.unread_entry_count": [
"%d unread entry",
"%d unread entries"
],
"page.users.actions": "Actions",
"page.users.admin.no": "No",
"page.users.admin.yes": "Yes",
"page.users.is_admin": "Administrator",
"page.users.last_login": "Last Login",
"page.users.never_logged": "Never",
"page.users.title": "Users",
"page.users.username": "Username",
"page.webauthn_rename.title": "Rename Passkey",
"pagination.first": "First",
"pagination.last": "Last",
"pagination.next": "Next",
"pagination.previous": "Previous",
"search.label": "Search",
"search.placeholder": "Search…",
"search.submit": "Search",
"skip_to_content": "Skip to content",
"time_elapsed.days": [
"%d day ago",
"%d days ago"
],
"time_elapsed.hours": [
"%d hour ago",
"%d hours ago"
],
"time_elapsed.minutes": [
"%d minute ago",
"%d minutes ago"
],
"time_elapsed.months": [
"%d month ago",
"%d months ago"
],
"time_elapsed.not_yet": "not yet",
"time_elapsed.now": "just now",
"time_elapsed.weeks": [
"%d week ago",
"%d weeks ago"
],
"time_elapsed.years": [
"%d year ago",
"%d years ago"
],
"time_elapsed.yesterday": "yesterday",
"tooltip.keyboard_shortcuts": "Keyboard Shortcut: %s",
"tooltip.logged_user": "Logged in as %s"
} v2-2.2.16/internal/locale/translations/es_ES.json 0000664 0000000 0000000 00000117570 15127074645 0021644 0 ustar 00root root 0000000 0000000 {
"action.cancel": "Cancelar",
"action.download": "Descargar",
"action.edit": "Editar",
"action.home_screen": "Añadir a la pantalla principal",
"action.import": "Importar",
"action.login": "Iniciar sesión",
"action.or": "o",
"action.remove": "Eliminar",
"action.remove_feed": "Eliminar esta fuente",
"action.save": "Guardar",
"action.subscribe": "Suscribir",
"action.update": "Actualizar",
"alert.account_linked": "¡Tu cuenta externa ya está vinculada!",
"alert.account_unlinked": "¡Tu cuenta externa ya está desvinculada!",
"alert.background_feed_refresh": "Todos los feeds se actualizan en segundo plano. Puede continuar usando Miniflux mientras se ejecuta este proceso.",
"alert.feed_error": "Hay un problema con esta fuente.",
"alert.no_starred": "No hay marcador en este momento.",
"alert.no_category": "No hay categoría.",
"alert.no_category_entry": "No hay artículos en esta categoría.",
"alert.no_feed": "No tienes fuentes.",
"alert.no_feed_entry": "No hay artículos para esta fuente.",
"alert.no_feed_in_category": "No hay fuentes para esta categoría.",
"alert.no_history": "No hay historial en este momento.",
"alert.no_search_result": "No hay resultados para esta búsqueda.",
"alert.no_shared_entry": "No hay artículos compartidos.",
"alert.no_tag_entry": "No hay artículos con esta etiqueta.",
"alert.no_unread_entry": "No hay artículos sin leer.",
"alert.no_user": "Eres el único usuario.",
"alert.prefs_saved": "¡Las preferencias se han guardado!",
"alert.too_many_feeds_refresh": [
"Has activado demasiadas actualizaciones del feed. Espere %d minuto antes de volver a intentarlo.",
"Has activado demasiadas actualizaciones del feed. Espere %d minutos antes de volver a intentarlo."
],
"confirm.loading": "En progreso...",
"confirm.no": "no",
"confirm.question": "¿Estás seguro?",
"confirm.question.refresh": "¿Quieres forzar la actualización?",
"confirm.yes": "sí",
"enclosure_media_controls.seek": "Buscar:",
"enclosure_media_controls.seek.title": "Buscar %s segundos",
"enclosure_media_controls.speed": "Velocidad:",
"enclosure_media_controls.speed.faster": "Más rápido",
"enclosure_media_controls.speed.faster.title": "Más rápido a %sx",
"enclosure_media_controls.speed.reset": "Restablecer",
"enclosure_media_controls.speed.reset.title": "Restablecer la velocidad a 1x",
"enclosure_media_controls.speed.slower": "Despacio",
"enclosure_media_controls.speed.slower.title": "Más despacio a %sx",
"entry.starred.toast.off": "Sin estrellas",
"entry.starred.toast.on": "Sembrado de estrellas",
"entry.starred.toggle.off": "Desmarcar",
"entry.starred.toggle.on": "Marcar",
"entry.comments.label": "Comentarios",
"entry.comments.title": "Ver comentarios",
"entry.estimated_reading_time": [
"%d minuto de lectura",
"%d minutos de lectura"
],
"entry.external_link.label": "Enlace externo",
"entry.save.completed": "¡Hecho!",
"entry.save.label": "Guardar",
"entry.save.title": "Guardar este artículo",
"entry.save.toast.completed": "Artículos guardados",
"entry.scraper.completed": "¡Hecho!",
"entry.scraper.label": "Descargar",
"entry.scraper.title": "Obtener contenido original",
"entry.share.label": "Compartir",
"entry.share.title": "Compartir este artículo",
"entry.shared_entry.label": "Compartir",
"entry.shared_entry.title": "Abrir el enlace público",
"entry.state.loading": "Cargando...",
"entry.state.saving": "Guardando...",
"entry.status.mark_as_read": "Marcar como leído",
"entry.status.mark_as_unread": "Marcar como no leído",
"entry.status.title": "Cambiar estado del artículo",
"entry.status.toast.read": "Marcado como leído",
"entry.status.toast.unread": "Marcado como no leído",
"entry.tags.label": "Etiquetas:",
"entry.tags.more_tags_label": [
"Mostrar %d etiqueta más",
"Mostrar %d etiquetas más"
],
"entry.unshare.label": "No compartir",
"error.api_key_already_exists": "Esta clave API ya existe.",
"error.bad_credentials": "Usuario o contraseña no válido.",
"error.category_already_exists": "Esta categoría ya existe.",
"error.category_not_found": "Esta categoría no existe o no pertenece a este usuario.",
"error.database_error": "Error en la base de datos: %v.",
"error.different_passwords": "Las contraseñas no son las mismas.",
"error.duplicate_fever_username": "¡Ya hay alguien con el mismo nombre de usuario de Fever!",
"error.duplicate_googlereader_username": "¡Ya hay alguien con el mismo nombre de usuario de Google Reader!",
"error.duplicate_linked_account": "¡Ya hay alguien asociado a este servicio!",
"error.duplicated_feed": "Este feed ya existe.",
"error.empty_file": "Este archivo está vacío.",
"error.entries_per_page_invalid": "El número de artículos por página no es válido.",
"error.feed_already_exists": "Este feed ya existe.",
"error.feed_category_not_found": "Esta categoría no existe o no pertenece a este usuario.",
"error.feed_format_not_detected": "No se puede detectar el formato del feed: %v.",
"error.feed_invalid_blocklist_rule": "La regla de la lista de bloqueo no es válida.",
"error.feed_invalid_keeplist_rule": "La regla de mantener la lista no es válida.",
"error.feed_mandatory_fields": "Los campos de URL y categoría son obligatorios.",
"error.feed_not_found": "Este feed no existe o no pertenece a este usuario.",
"error.feed_title_not_empty": "El título del feed no puede estar vacío.",
"error.feed_url_not_empty": "La URL del feed no puede estar vacía.",
"error.fields_mandatory": "Todos los campos son obligatorios.",
"error.http_bad_gateway": "El sitio web no está disponible en este momento debido a un error en la puerta de enlace. El problema no está en el lado de Miniflux. Por favor, inténtalo de nuevo más tarde.",
"error.http_body_read": "Imposible leer el cuerpo HTTP: %v.",
"error.http_client_error": "Error cliente HTTP: %v.",
"error.http_empty_response": "La respuesta HTTP está vacía. ¿Quizás este sitio web tiene un mecanismo de protección contra bots?",
"error.http_empty_response_body": "El cuerpo de la respuesta HTTP está vacío.",
"error.http_forbidden": "El acceso a este sitio web está prohibido. ¿Quizás este sitio web tiene un mecanismo de protección contra bots?",
"error.http_gateway_timeout": "El sitio web no está disponible en este momento debido a un error de tiempo de espera de la puerta de enlace. El problema no está en el lado de Miniflux. Por favor, inténtalo de nuevo más tarde.",
"error.http_internal_server_error": "El sitio web no está disponible en estos momentos debido a un error del servidor. El problema no está en el lado de Miniflux. Por favor, inténtalo de nuevo más tarde.",
"error.http_not_authorized": "El acceso a este sitio web no está autorizado. Podría ser un nombre de usuario o contraseña incorrectos.",
"error.http_resource_not_found": "No se encuentra el recurso solicitado. Por favor, verifique la URL.",
"error.http_response_too_large": "La respuesta HTTP es demasiado grande. Puede aumentar el límite de tamaño de respuesta HTTP en la configuración global (requiere reiniciar el servidor).",
"error.http_service_unavailable": "El sitio web no está disponible en estos momentos debido a un error interno del servidor. El problema no está en el lado de Miniflux. Por favor, inténtalo de nuevo más tarde.",
"error.http_too_many_requests": "Miniflux generó demasiadas solicitudes a este sitio web. Por favor, inténtalo de nuevo más tarde o cambia la configuración de la aplicación.",
"error.http_unexpected_status_code": "El sitio web no está disponible en este momento debido a un código de estado HTTP inesperado: %d. El problema no está en el lado de Miniflux. Por favor, inténtalo de nuevo más tarde.",
"error.invalid_categories_sorting_order": "Orden de clasificación de categorías no válido.",
"error.invalid_default_home_page": "¡Página de inicio por defecto no válida!",
"error.invalid_display_mode": "Modo de visualización de la aplicación web no válido.",
"error.invalid_entry_direction": "Dirección de artículo no válida.",
"error.invalid_entry_order": "Orden de artículo no válido.",
"error.invalid_feed_proxy_url": "URL de proxy inválida.",
"error.invalid_feed_url": "URL de feed no válida.",
"error.invalid_gesture_nav": "Navegación por gestos no válida.",
"error.invalid_language": "Idioma no válido.",
"error.invalid_site_url": "URL del sitio no válida.",
"error.invalid_theme": "Tema no válido.",
"error.invalid_timezone": "Zona horaria no válida.",
"error.network_operation": "Miniflux no puede acceder a este sitio web debido a un error de red: %v.",
"error.network_timeout": "Este sitio web es demasiado lento y se agotó el tiempo de espera de la solicitud: %v",
"error.password_min_length": "La contraseña debería tener al menos 6 caracteres.",
"error.proxy_url_not_empty": "La URL del proxy no puede estar vacía.",
"error.settings_block_rule_fieldname_invalid": "Regla de bloqueo no válida: a la regla #%d le falta un nombre de campo válido (Opciones: %s)",
"error.settings_block_rule_invalid_regex": "Regla de bloqueo no válida: el patrón de la regla #%d no es una expresión regular válida",
"error.settings_block_rule_regex_required": "Regla de bloqueo no válida: no se ha proporcionado el patrón de la regla #%d",
"error.settings_block_rule_separator_required": "Regla de bloqueo no válida: el patrón de la regla #%d debe estar separado por un '='",
"error.settings_invalid_domain_list": "Lista de dominios inválida. Por favor proporcione una lista de dominios separados por espacios.",
"error.settings_keep_rule_fieldname_invalid": "Regla de mantenimiento no válida: a la regla #%d le falta un nombre de campo válido (Opciones: %s)",
"error.settings_keep_rule_invalid_regex": "Regla de mantenimiento no válida: el patrón de la regla #%d no es una expresión regular válida",
"error.settings_keep_rule_regex_required": "Regla de conservación no válida: no se ha proporcionado la regla #%d patrón",
"error.settings_keep_rule_separator_required": "Regla de mantenimiento no válida: el patrón de la regla #%d debe estar separado por un '='",
"error.settings_mandatory_fields": "Los campos de nombre de usuario, tema, idioma y zona horaria son obligatorios.",
"error.settings_media_playback_rate_range": "La velocidad de reproducción está fuera de rango",
"error.settings_reading_speed_is_positive": "Las velocidades de lectura deben ser números enteros positivos.",
"error.site_url_not_empty": "La URL del sitio no puede estar vacía.",
"error.subscription_not_found": "Incapaz de encontrar alguna fuente.",
"error.title_required": "El título es obligatorio.",
"error.tls_error": "Error de TLS: %q. Puede desactivar la verificación TLS en la configuración del feed si lo desea.",
"error.unable_to_create_api_key": "No se puede crear esta clave API.",
"error.unable_to_create_category": "Incapaz de crear esta categoría.",
"error.unable_to_create_user": "Incapaz de crear este usuario.",
"error.unable_to_detect_rssbridge": "No se puede detectar la fuente usando RSS-Bridge: %v.",
"error.unable_to_parse_feed": "No se puede analizar este feed: %v.",
"error.unable_to_update_category": "Incapaz de actualizar esta categoría.",
"error.unable_to_update_feed": "Incapaz de actualizar esta fuente.",
"error.unable_to_update_user": "Incapaz de actualizar este usuario.",
"error.unlink_account_without_password": "Debe definir una contraseña, de lo contrario no podrá volver a iniciar sesión.",
"error.user_already_exists": "Este usuario ya existe.",
"error.user_mandatory_fields": "El nombre de usuario es obligatorio.",
"error.linktaco_missing_required_fields": "LinkTaco API Token y Organization Slug son obligatorios.",
"form.api_key.label.description": "Etiqueta de clave API",
"form.category.hide_globally": "Ocultar artículos en la lista global de no leídos",
"form.category.label.title": "Título",
"form.feed.fieldset.general": "Generalidades",
"form.feed.fieldset.integration": "Servicios de terceros",
"form.feed.fieldset.network_settings": "Ajustes de red",
"form.feed.fieldset.rules": "Reglas",
"form.feed.label.allow_self_signed_certificates": "Permitir certificados autofirmados o no válidos",
"form.feed.label.apprise_service_urls": "Lista separada por comas de las URL del servicio Apprise",
"form.feed.label.block_filter_entry_rules": "Reglas de Bloqueo de Entradas",
"form.feed.label.blocklist_rules": "Filtros de Bloqueo Basados en Regex",
"form.feed.label.category": "Categoría",
"form.feed.label.cookie": "Configurar las cookies",
"form.feed.label.crawler": "Obtener rastreador original",
"form.feed.label.description": "Descripción",
"form.feed.label.disable_http2": "Deshabilite HTTP/2 para evitar huellas digitales",
"form.feed.label.disabled": "No actualice este feed",
"form.feed.label.feed_password": "Contraseña de la fuente",
"form.feed.label.feed_url": "URL de la fuente",
"form.feed.label.feed_username": "Nombre de usuario de la fuente",
"form.feed.label.fetch_via_proxy": "Usar el proxy configurado a nivel de la aplicación",
"form.feed.label.hide_globally": "Ocultar artículos en la lista global de no leídos",
"form.feed.label.ignore_http_cache": "Ignorar caché HTTP",
"form.feed.label.keep_filter_entry_rules": "Reglas de Permitir Entradas",
"form.feed.label.keeplist_rules": "Filtros de Mantener Basados en Regex",
"form.feed.label.no_media_player": "Sin reproductor multimedia (audio/video)",
"form.feed.label.ntfy_activate": "Enviar entradas a ntfy",
"form.feed.label.ntfy_default_priority": "Prioridad predeterminada a Ntfy",
"form.feed.label.ntfy_high_priority": "Prioridad alta a Ntfy",
"form.feed.label.ntfy_low_priority": "Prioridad baja a Ntfy",
"form.feed.label.ntfy_max_priority": "Prioridad máxima a Ntfy",
"form.feed.label.ntfy_min_priority": "Prioridad mínima a Ntfy",
"form.feed.label.ntfy_priority": "Prioridad Ntfy",
"form.feed.label.ntfy_topic": "Tema Ntfy (opcional)",
"form.feed.label.proxy_url": "URL del Proxy",
"form.feed.label.pushover_activate": "Enviar artículos a pushover.net",
"form.feed.label.pushover_default_priority": "Prioridad predeterminada de Pushover",
"form.feed.label.pushover_high_priority": "Prioridad alta de Pushover",
"form.feed.label.pushover_low_priority": "Prioridad baja de Pushover",
"form.feed.label.pushover_max_priority": "Prioridad máxima de Pushover",
"form.feed.label.pushover_min_priority": "Prioridad mínima de Pushover",
"form.feed.label.pushover_priority": "Prioridad del mensaje de Pushover",
"form.feed.label.rewrite_rules": "Reglas de Reescritura de Contenido",
"form.feed.label.scraper_rules": "Reglas de extracción de información",
"form.feed.label.site_url": "URL del sitio",
"form.feed.label.title": "Título",
"form.feed.label.urlrewrite_rules": "Reglas de Filtrado (Reescritura)",
"form.feed.label.user_agent": "Invalidar el agente de usuario predeterminado",
"form.feed.label.webhook_url": "Invalidar la URL del webhook",
"form.import.label.file": "Archivo OPML",
"form.import.label.url": "URL",
"form.integration.archiveorg_activate": "Enviar entradas a archive.org",
"form.integration.apprise_activate": "Enviar artículos a Apprise",
"form.integration.apprise_services_url": "Lista separada por comas de las URL del servicio Apprise",
"form.integration.apprise_url": "URL de la API de Apprise",
"form.integration.betula_activate": "Guardar artículos en Betula",
"form.integration.betula_token": "Token de Betula",
"form.integration.betula_url": "URL del servidor Betula",
"form.integration.cubox_activate": "Guardar artículos en Cubox",
"form.integration.cubox_api_link": "Enlace de la API de Cubox",
"form.integration.discord_activate": "Enviar artículos a Discord",
"form.integration.discord_webhook_link": "URL de la Webhook de Discord",
"form.integration.espial_activate": "Enviar artículos a Espial",
"form.integration.espial_api_key": "Clave de API de Espial",
"form.integration.espial_endpoint": "Acceso API de Espial",
"form.integration.espial_tags": "Etiquetas de Espial",
"form.integration.fever_activate": "Activar API de Fever",
"form.integration.fever_endpoint": "Acceso API de Fever:",
"form.integration.fever_password": "Contraseña de Fever",
"form.integration.fever_username": "Nombre de usuario de Fever",
"form.integration.googlereader_activate": "Activar API de Google Reader",
"form.integration.googlereader_endpoint": "Acceso API de Google Reader:",
"form.integration.googlereader_password": "Contraseña de Google Reader",
"form.integration.googlereader_username": "Nombre de usuario de Google Reader",
"form.integration.instapaper_activate": "Enviar artículos a Instapaper",
"form.integration.instapaper_password": "Contraseña de Instapaper",
"form.integration.instapaper_username": "Nombre de usuario de Instapaper",
"form.integration.karakeep_activate": "Enviar artículos a Karakeep",
"form.integration.karakeep_api_key": "Clave de API de Karakeep",
"form.integration.karakeep_url": "Acceso API de Karakeep",
"form.integration.karakeep_tags": "Etiquetas de Karakeep",
"form.integration.linkace_activate": "Guardar artículos en LinkAce",
"form.integration.linkace_api_key": "Clave API de LinkAce",
"form.integration.linkace_check_disabled": "Deshabilitar la comprobación de enlace",
"form.integration.linkace_endpoint": "Extremo de la API de LinkAce",
"form.integration.linkace_is_private": "Marcar enlace como privado",
"form.integration.linkace_tags": "Etiquetas de LinkAce",
"form.integration.linkding_activate": "Enviar artículos a Linkding",
"form.integration.linkding_api_key": "Clave de API de Linkding",
"form.integration.linkding_bookmark": "Marcar marcador como no leído",
"form.integration.linkding_endpoint": "Acceso API de Linkding",
"form.integration.linkding_tags": "Etiquetas de Linkding",
"form.integration.linktaco_activate": "Guardar entradas en LinkTaco",
"form.integration.linktaco_api_token": "Token de la API de LinkTaco",
"form.integration.linktaco_api_token_hint": "Obtenga su token de acceso personal en",
"form.integration.linktaco_org_slug": "Slug de la organización",
"form.integration.linktaco_tags": "Etiquetas (máx. 10, separadas por comas)",
"form.integration.linktaco_tags_hint": "Máximo 10 etiquetas, separadas por comas",
"form.integration.linktaco_visibility": "Visibilidad",
"form.integration.linktaco_visibility_public": "Público",
"form.integration.linktaco_visibility_private": "Privado",
"form.integration.linktaco_visibility_hint": "La visibilidad PRIVADA requiere una cuenta de pago de LinkTaco",
"form.integration.linkwarden_activate": "Enviar artículos a Linkwarden",
"form.integration.linkwarden_api_key": "Clave de API de Linkwarden",
"form.integration.linkwarden_endpoint": "URL base de Linkwarden",
"form.integration.linkwarden_collection_id": "ID de colección de Linkwarden",
"form.integration.matrix_bot_activate": "Transferir nuevos artículos a Matrix",
"form.integration.matrix_bot_chat_id": "ID de la sala 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_user": "Nombre de usuario para Matrix",
"form.integration.notion_activate": "Guardar entradas en Notion",
"form.integration.notion_page_id": "ID de página de Notion",
"form.integration.notion_token": "Token secreto de Notion",
"form.integration.ntfy_activate": "Enviar artículos a ntfy",
"form.integration.ntfy_api_token": "Token de API de Ntfy (opcional)",
"form.integration.ntfy_icon_url": "URL del icono de Ntfy (opcional)",
"form.integration.ntfy_internal_links": "Usar enlaces internos al hacer clic (opcional)",
"form.integration.ntfy_password": "Contraseña de Ntfy (opcional)",
"form.integration.ntfy_topic": "Tema Ntfy (por defecto, si no se establece en el feed)",
"form.integration.ntfy_url": "URL de Ntfy (opcional, la predeterminada es ntfy.sh)",
"form.integration.ntfy_username": "Nombre de usuario de Ntfy (opcional)",
"form.integration.nunux_keeper_activate": "Enviar artículos a Nunux Keeper",
"form.integration.nunux_keeper_api_key": "Clave de API de Nunux Keeper",
"form.integration.nunux_keeper_endpoint": "Acceso API de Nunux Keeper",
"form.integration.omnivore_activate": "Enviar artículos a Omnivore",
"form.integration.omnivore_api_key": "Clave de API de Omnivore",
"form.integration.omnivore_url": "Acceso API de Omnivore",
"form.integration.pinboard_activate": "Enviar artículos a Pinboard",
"form.integration.pinboard_bookmark": "Marcar marcador como no leído",
"form.integration.pinboard_tags": "Etiquetas de Pinboard",
"form.integration.pinboard_token": "Token de API de Pinboard",
"form.integration.pushover_activate": "Enviar artículos a Pushover",
"form.integration.pushover_device": "Dispositivo Pushover (opcional)",
"form.integration.pushover_prefix": "Prefijo de URL de Pushover (opcional)",
"form.integration.pushover_token": "Token de API de la aplicación Pushover",
"form.integration.pushover_user": "Clave de usuario de Pushover",
"form.integration.raindrop_activate": "Guardar artículos en Raindrop",
"form.integration.raindrop_collection_id": "Colección ID",
"form.integration.raindrop_tags": "Etiquetas (separadas por comas)",
"form.integration.raindrop_token": "Token (prueba)",
"form.integration.readeck_activate": "Enviar artículos a Readeck",
"form.integration.readeck_api_key": "Clave de API de Readeck",
"form.integration.readeck_endpoint": "Acceso API de Readeck",
"form.integration.readeck_labels": "Etiquetas de Readeck",
"form.integration.readeck_only_url": "Enviar solo URL (en lugar de contenido completo)",
"form.integration.readeck_push_activate": "Enviar automáticamente nuevas entradas a Readeck",
"form.integration.readwise_activate": "Guardar artículos en Readwise Reader",
"form.integration.readwise_api_key": "Token de acceso a Readwise Reader",
"form.integration.readwise_api_key_link": "Obtener tu token de acceso a Readwise",
"form.integration.rssbridge_activate": "Vericar RSS-Bridge al agregar suscripciones",
"form.integration.rssbridge_token": "Token de autenticación de RSS-Bridge",
"form.integration.rssbridge_url": "URL del servidro RSS-Bridge",
"form.integration.shaarli_activate": "Guardar artículos en Shaarli",
"form.integration.shaarli_api_secret": "Secreto API de Shaarli",
"form.integration.shaarli_endpoint": "URL de Shaarli",
"form.integration.shiori_activate": "Guardar artículos a Shiori",
"form.integration.shiori_endpoint": "Extremo de API de Shiori",
"form.integration.shiori_password": "Contraseña de Shiori",
"form.integration.shiori_username": "Nombre de usuario de Shiori",
"form.integration.slack_activate": "Enviar artículos a Slack",
"form.integration.slack_webhook_link": "URL de la Webhook de Slack",
"form.integration.telegram_bot_activate": "Envíe nuevos artículos al chat de Telegram",
"form.integration.telegram_bot_disable_buttons": "Deshabilitar botones",
"form.integration.telegram_bot_disable_notification": "Deshabilitar notificación",
"form.integration.telegram_bot_disable_web_page_preview": "Deshabilitar la vista previa de la página web",
"form.integration.telegram_bot_token": "Token de bot",
"form.integration.telegram_chat_id": "ID de chat",
"form.integration.telegram_topic_id": "ID de tema",
"form.integration.wallabag_activate": "Enviar artículos a Wallabag",
"form.integration.wallabag_client_id": "ID de cliente de Wallabag",
"form.integration.wallabag_client_secret": "Secreto de cliente de Wallabag",
"form.integration.wallabag_endpoint": "URL base de Wallabag",
"form.integration.wallabag_only_url": "Enviar solo URL (en lugar de contenido completo)",
"form.integration.wallabag_password": "Contraseña de Wallabag",
"form.integration.wallabag_username": "Nombre de usuario de Wallabag",
"form.integration.wallabag_tags": "Etiquetas de Wallabag",
"form.integration.webhook_activate": "Habilitar Webhooks",
"form.integration.webhook_secret": "Secreto de Webhooks",
"form.integration.webhook_url": "Defecto URL de Webhook",
"form.prefs.fieldset.application_settings": "Ajustes de la aplicación",
"form.prefs.fieldset.authentication_settings": "Ajustes de la autentificación",
"form.prefs.fieldset.global_feed_settings": "Ajustes globales del feed",
"form.prefs.fieldset.reader_settings": "Ajustes del lector",
"form.prefs.help.external_font_hosts": "Lista separada por espacios de hosts de fuentes externas permitidos. Por ejemplo: \"fonts.gstatic.com fonts.googleapis.com\".",
"form.prefs.label.always_open_external_links": "Leer artículos abriendo enlaces externos",
"form.prefs.label.categories_sorting_order": "Clasificación por categorías",
"form.prefs.label.cjk_reading_speed": "Velocidad de lectura en chino, coreano y japonés (caracteres por minuto)",
"form.prefs.label.custom_css": "CSS personalizado",
"form.prefs.label.custom_js": "JavaScript personalizado",
"form.prefs.label.default_home_page": "Página de inicio por defecto",
"form.prefs.label.default_reading_speed": "Velocidad de lectura de otras lenguas (palabras por minuto)",
"form.prefs.label.display_mode": "Modo de visualización de aplicación web progresiva (PWA)",
"form.prefs.label.entries_per_page": "Artículos por página",
"form.prefs.label.entry_order": "Columna de clasificación de artículos",
"form.prefs.label.entry_sorting": "Clasificación de artículos",
"form.prefs.label.entry_swipe": "Habilitar deslizamiento de entrada en pantallas táctiles",
"form.prefs.label.external_font_hosts": "Hosts de fuentes externas",
"form.prefs.label.gesture_nav": "Gesto para navegar entre entradas",
"form.prefs.label.keyboard_shortcuts": "Habilitar atajos de teclado",
"form.prefs.label.language": "Idioma",
"form.prefs.label.mark_read_manually": "Marcar entradas como leídas manualmente",
"form.prefs.label.mark_read_on_media_completion": "Marcar como leído solo cuando la reproducción de audio/video alcance el 90%% de finalización",
"form.prefs.label.mark_read_on_view": "Marcar automáticamente las entradas como leídas cuando se vean",
"form.prefs.label.mark_read_on_view_or_media_completion": "Marcar las entradas como leídas cuando se vean. Para audio/video, marcar como leído al 90%% de finalización",
"form.prefs.label.media_playback_rate": "Velocidad de reproducción del audio/vídeo",
"form.prefs.label.open_external_links_in_new_tab": "Abrir enlaces externos en una nueva pestaña (agrega target=\"_blank\" a los enlaces)",
"form.prefs.label.show_reading_time": "Mostrar el tiempo estimado de lectura de los artículos",
"form.prefs.label.theme": "Tema",
"form.prefs.label.timezone": "Zona horaria",
"form.prefs.select.alphabetical": "Alfabético",
"form.prefs.select.browser": "Navegador",
"form.prefs.select.created_time": "Hora de creación del artículo",
"form.prefs.select.fullscreen": "Pantalla completa",
"form.prefs.select.minimal_ui": "Mínimo",
"form.prefs.select.none": "Ninguno",
"form.prefs.select.older_first": "Artículos antiguos primero",
"form.prefs.select.publish_time": "Hora de publicación del artículo",
"form.prefs.select.recent_first": "Artículos recientes primero",
"form.prefs.select.standalone": "Autónomo",
"form.prefs.select.swipe": "Golpe fuerte",
"form.prefs.select.tap": "Doble toque",
"form.prefs.select.unread_count": "Recuento de no leídos",
"form.submit.loading": "Cargando...",
"form.submit.saving": "Guardando...",
"form.user.label.admin": "Administrador",
"form.user.label.confirmation": "Confirmación de contraseña",
"form.user.label.password": "Contraseña",
"form.user.label.username": "Nombre de usuario",
"menu.about": "Acerca de",
"menu.add_feed": "Agregar fuente",
"menu.add_user": "Agregar usuario",
"menu.api_keys": "Claves API",
"menu.categories": "Categorías",
"menu.create_api_key": "Crear una nueva clave API",
"menu.create_category": "Crear una categoría",
"menu.edit_category": "Editar",
"menu.edit_feed": "Editar",
"menu.export": "Exportar",
"menu.feed_entries": "Artículos",
"menu.feeds": "Fuentes",
"menu.flush_history": "Borrar historial",
"menu.history": "Historial",
"menu.home_page": "Página de inicio",
"menu.import": "Importar",
"menu.integrations": "Integraciones",
"menu.logout": "Cerrar sesión",
"menu.mark_all_as_read": "Marcar todos como leídos",
"menu.mark_page_as_read": "Marcar esta página como leída",
"menu.preferences": "Preferencias",
"menu.refresh_all_feeds": "Refrescar todas las fuentes en segundo plano",
"menu.refresh_feed": "Refrescar",
"menu.search": "Buscar",
"menu.sessions": "Sesiones",
"menu.settings": "Configuración",
"menu.shared_entries": "Artículos compartidos",
"menu.show_all_entries": "Mostrar todos los artículos",
"menu.show_only_starred_entries": "Mostrar solo los artículos marcados con una estrella",
"menu.show_only_unread_entries": "Mostrar solo los artículos no leídos",
"menu.starred": "Marcadores",
"menu.title": "Menú",
"menu.unread": "No leídos",
"menu.users": "Usuarios",
"page.about.author": "Autor:",
"page.about.build_date": "Fecha de compilación:",
"page.about.credits": "Créditos",
"page.about.db_usage": "Tamaño de la base de datos:",
"page.about.git_commit": "Commit de Git:",
"page.about.global_config_options": "Opciones de configuración global",
"page.about.go_version": "Go versión:",
"page.about.license": "Licencia:",
"page.about.postgres_version": "Postgres versión:",
"page.about.title": "Acerca de",
"page.about.version": "Versión:",
"page.add_feed.choose_feed": "Elegir una fuente",
"page.add_feed.label.url": "URL",
"page.add_feed.legend.advanced_options": "Opciones avanzadas",
"page.add_feed.no_category": "No hay categoría. Debe tener al menos una categoría.",
"page.add_feed.submit": "Encontrar una fuente",
"page.add_feed.title": "Nueva fuente",
"page.api_keys.never_used": "Nunca usado",
"page.api_keys.table.actions": "Acciones",
"page.api_keys.table.created_at": "Fecha de creación",
"page.api_keys.table.description": "Descripción",
"page.api_keys.table.last_used_at": "Último utilizado",
"page.api_keys.table.token": "simbólico",
"page.api_keys.title": "Claves API",
"page.categories.entries": "Artículos",
"page.categories.feed_count": [
"Hay %d fuente.",
"Hay %d fuentes."
],
"page.categories.feeds": "Fuentes",
"page.categories.no_feed": "Sin fuente.",
"page.categories.title": "Categorías",
"page.categories_count": [
"%d categoría",
"%d categorías"
],
"page.category_label": "Categoría: %s",
"page.edit_category.title": "Editar categoría: %s",
"page.edit_feed.etag_header": "Cabecera de ETag:",
"page.edit_feed.last_check": "Última verificación:",
"page.edit_feed.last_modified_header": "Cabecera de LastModified:",
"page.edit_feed.last_parsing_error": "Último error de análisis",
"page.edit_feed.no_header": "Sin cabecera",
"page.edit_feed.title": "Editar fuente: %s",
"page.edit_user.title": "Editar usuario: %s",
"page.entry.attachments": "Archivos adjuntos",
"page.feeds.error_count": [
"%d error",
"%d errores"
],
"page.feeds.last_check": "Última verificación:",
"page.feeds.next_check": "Próxima verificación:",
"page.feeds.read_counter": "Número de artículos leídos",
"page.feeds.title": "Fuentes",
"page.footer.elevator": "Volver arriba",
"page.history.title": "Historial",
"page.import.title": "Importar",
"page.integration.bookmarklet": "Marcapáginas",
"page.integration.bookmarklet.help": "Este enlace especial te permite suscribirte a un sitio de web directamente usando un marcador del navegador.",
"page.integration.bookmarklet.instructions": "Arrastrar y soltar este enlace a tus marcadores del navegador.",
"page.integration.bookmarklet.name": "Agregar a Miniflux",
"page.integration.miniflux_api": "API de Miniflux",
"page.integration.miniflux_api_endpoint": "Extremo de API",
"page.integration.miniflux_api_password": "Contraseña",
"page.integration.miniflux_api_password_value": "Contraseña de tu cuenta",
"page.integration.miniflux_api_username": "Nombre de usuario",
"page.integrations.title": "Integraciones",
"page.keyboard_shortcuts.close_modal": "Cerrar el cuadro de diálogo modal",
"page.keyboard_shortcuts.download_content": "Descargar el contenido original",
"page.keyboard_shortcuts.go_to_bottom_item": "Ir al elemento inferior",
"page.keyboard_shortcuts.go_to_categories": "Ir a las categorías",
"page.keyboard_shortcuts.go_to_feed": "Ir a la fuente",
"page.keyboard_shortcuts.go_to_feeds": "Ir a las fuentes",
"page.keyboard_shortcuts.go_to_history": "Ir al historial",
"page.keyboard_shortcuts.go_to_next_item": "Ir al elemento siguiente",
"page.keyboard_shortcuts.go_to_next_page": "Ir al página siguiente",
"page.keyboard_shortcuts.go_to_previous_item": "Ir al elemento anterior",
"page.keyboard_shortcuts.go_to_previous_page": "Ir al página anterior",
"page.keyboard_shortcuts.go_to_search": "Centrarse en el cuadro de búsqueda",
"page.keyboard_shortcuts.go_to_settings": "Ir a la configuración",
"page.keyboard_shortcuts.go_to_starred": "Ir a los marcadores",
"page.keyboard_shortcuts.go_to_top_item": "Ir al elemento superior",
"page.keyboard_shortcuts.go_to_unread": "Ir a los no leídos",
"page.keyboard_shortcuts.mark_page_as_read": "Marcar página actual como leída",
"page.keyboard_shortcuts.open_comments": "Abrir el enlace de comentarios",
"page.keyboard_shortcuts.open_comments_same_window": "Abrir enlace de comentarios en la pestaña actual",
"page.keyboard_shortcuts.open_item": "Abrir el elemento seleccionado",
"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.refresh_all_feeds": "Refrescar todas las fuentes en segundo plano",
"page.keyboard_shortcuts.remove_feed": "Quitar esta fuente",
"page.keyboard_shortcuts.save_article": "Guardar artículo",
"page.keyboard_shortcuts.scroll_item_to_top": "Desplazar elemento hacia arriba",
"page.keyboard_shortcuts.show_keyboard_shortcuts": "Mostrar atajos de teclado",
"page.keyboard_shortcuts.subtitle.actions": "Acciones",
"page.keyboard_shortcuts.subtitle.items": "Navegación de artículos",
"page.keyboard_shortcuts.subtitle.pages": "Navegación de páginas",
"page.keyboard_shortcuts.subtitle.sections": "Navegación de secciones",
"page.keyboard_shortcuts.title": "Atajos de teclado",
"page.keyboard_shortcuts.toggle_star_status": "Agregar o quitar marcador",
"page.keyboard_shortcuts.toggle_entry_attachments": "Alternar abrir/cerrar adjuntos de la entrada",
"page.keyboard_shortcuts.toggle_read_status_next": "Marcar como leído o no leído, enfoque siguiente",
"page.keyboard_shortcuts.toggle_read_status_prev": "Marcar como leído o no leído, foco anterior",
"page.login.google_signin": "Iniciar sesión con tu cuenta de Google",
"page.login.oidc_signin": "Iniciar sesión con tu cuenta de %s",
"page.login.title": "Iniciar sesión",
"page.login.webauthn_login": "Iniciar sesión con clave de acceso",
"page.login.webauthn_login.error": "No se puede iniciar sesión con la clave de acceso",
"page.login.webauthn_login.help": "Por favor, introduce tu nombre de usuario si usas una clave de seguridad. Esto no es necesario si usas una Passkey (credenciales detectables).",
"page.new_api_key.title": "Nueva clave API",
"page.new_category.title": "Nueva categoría",
"page.new_user.title": "Nuevo usuario",
"page.offline.message": "Estas desconectado",
"page.offline.refresh_page": "Intenta actualizar la página",
"page.offline.title": "Modo offline",
"page.read_entry_count": [
"%d artículo leído",
"%d artículos leídos"
],
"page.search.title": "Resultados de la búsqueda",
"page.sessions.table.actions": "Acciones",
"page.sessions.table.current_session": "Sesión actual",
"page.sessions.table.date": "Fecha",
"page.sessions.table.ip": "Dirección de IP",
"page.sessions.table.user_agent": "Agente de usuario",
"page.sessions.title": "Sesiones",
"page.settings.link_google_account": "Vincular mi cuenta de Google",
"page.settings.link_oidc_account": "Vincular mi cuenta de %s",
"page.settings.title": "Ajustes",
"page.settings.unlink_google_account": "Desvincular mi cuenta de Google",
"page.settings.unlink_oidc_account": "Desvincular mi cuenta de %s",
"page.settings.webauthn.actions": "Acciones",
"page.settings.webauthn.added_on": "Añadido",
"page.settings.webauthn.delete": [
"Eliminar %d clave de acceso",
"Eliminar %d claves de acceso"
],
"page.settings.webauthn.last_seen_on": "Usado por última vez",
"page.settings.webauthn.passkey_name": "Nombre de clave de acceso",
"page.settings.webauthn.passkeys": "Claves de acceso",
"page.settings.webauthn.register": "Registrar clave de acceso",
"page.settings.webauthn.register.error": "No se puede registrar la clave de acceso",
"page.shared_entries.title": "Artículos compartidos",
"page.shared_entries_count": [
"%d artículo compartido",
"%d artículos compartidos"
],
"page.starred.title": "Marcadores",
"page.starred_entry_count": [
"%d artículo marcado",
"%d artículos marcados"
],
"page.total_entry_count": [
"%d artículo en total",
"%d artículos en total"
],
"page.unread.title": "No leídos",
"page.unread_entry_count": [
"%d artículo no leído",
"%d artículos no leídos"
],
"page.users.actions": "Acciones",
"page.users.admin.no": "No",
"page.users.admin.yes": "Sí",
"page.users.is_admin": "Administrador",
"page.users.last_login": "Último ingreso",
"page.users.never_logged": "Nunca",
"page.users.title": "Usuarios",
"page.users.username": "Nombre de usuario",
"page.webauthn_rename.title": "Renombrar clave de acceso",
"pagination.first": "Primero",
"pagination.last": "Último",
"pagination.next": "Siguiente",
"pagination.previous": "Anterior",
"search.label": "Buscar",
"search.placeholder": "Búsqueda...",
"search.submit": "Buscar",
"skip_to_content": "Saltar al contenido",
"time_elapsed.days": [
"hace %d día",
"hace %d días"
],
"time_elapsed.hours": [
"hace %d hora",
"hace %d horas"
],
"time_elapsed.minutes": [
"hace %d minuto",
"hace %d minutos"
],
"time_elapsed.months": [
"hace %d mes",
"hace %d meses"
],
"time_elapsed.not_yet": "todavía no",
"time_elapsed.now": "ahora mismo",
"time_elapsed.weeks": [
"hace %d semana",
"hace %d semanas"
],
"time_elapsed.years": [
"hace %d año",
"hace %d años"
],
"time_elapsed.yesterday": "ayer",
"tooltip.keyboard_shortcuts": "Atajo de teclado: %s",
"tooltip.logged_user": "Registrado como %s"
}
v2-2.2.16/internal/locale/translations/fi_FI.json 0000664 0000000 0000000 00000115011 15127074645 0021606 0 ustar 00root root 0000000 0000000 {
"action.cancel": "peru",
"action.download": "Lataa",
"action.edit": "Muokkaa",
"action.home_screen": "Lisää aloitusnäytölle",
"action.import": "Tuo",
"action.login": "Kirjaudu sisään",
"action.or": "tai",
"action.remove": "Poista",
"action.remove_feed": "Poista tämä syöte",
"action.save": "Tallenna",
"action.subscribe": "Tilaa",
"action.update": "Päivitä",
"alert.account_linked": "Ulkoinen tilisi on nyt linkitetty!",
"alert.account_unlinked": "Ulkoinen tilisi on nyt irrotettu!",
"alert.background_feed_refresh": "Kaikki syötteet päivitetään taustalla. Voit jatkaa Minifluxin käyttöä tämän prosessin aikana.",
"alert.feed_error": "Tässä syötteessä on ongelma",
"alert.no_starred": "Tällä hetkellä ei ole kirjanmerkkiä.",
"alert.no_category": "Ei ole kategoriaa.",
"alert.no_category_entry": "Tässä kategoriassa ei ole artikkeleita.",
"alert.no_feed": "Sinulla ei ole tilauksia.",
"alert.no_feed_entry": "Tässä syötteessä ei ole artikkeleita.",
"alert.no_feed_in_category": "Tälle kategorialle ei ole tilausta.",
"alert.no_history": "Tällä hetkellä ei ole historiaa.",
"alert.no_search_result": "Ei hakua vastaavia tuloksia.",
"alert.no_shared_entry": "Jaettua artikkelia ei ole.",
"alert.no_tag_entry": "Tätä tunnistetta vastaavia merkintöjä ei ole.",
"alert.no_unread_entry": "Ei ole lukemattomia artikkeleita.",
"alert.no_user": "Olet ainoa käyttäjä.",
"alert.prefs_saved": "Asetukset tallennettu!",
"alert.too_many_feeds_refresh": [
"Olet käynnistänyt liian monta syötteen päivitystä. Odota %d minuutti ennen kuin yrität uudelleen.",
"Olet käynnistänyt liian monta syötteen päivitystä. Odota %d minuuttia ennen kuin yrität uudelleen."
],
"confirm.loading": "Käynnissä...",
"confirm.no": "ei",
"confirm.question": "Oletko varma?",
"confirm.question.refresh": "Haluatko pakottaa päivityksen?",
"confirm.yes": "kyllä",
"enclosure_media_controls.seek": "Siirry:",
"enclosure_media_controls.seek.title": "Siirry %s sekuntia",
"enclosure_media_controls.speed": "Nopeus:",
"enclosure_media_controls.speed.faster": "Nopeammin",
"enclosure_media_controls.speed.faster.title": "Nopeampi %sx",
"enclosure_media_controls.speed.reset": "Palauta",
"enclosure_media_controls.speed.reset.title": "Palauta nopeus 1x",
"enclosure_media_controls.speed.slower": "Hitaammin",
"enclosure_media_controls.speed.slower.title": "Hitaampi %sx",
"entry.starred.toast.off": "Tähdettömät",
"entry.starred.toast.on": "Tähdellä merkityt",
"entry.starred.toggle.off": "Poista suosikeista",
"entry.starred.toggle.on": "Lisää suosikkeihin",
"entry.comments.label": "Kommentit",
"entry.comments.title": "Näytä kommentit",
"entry.estimated_reading_time": [
"%d minuutin lukuaika",
"%d minuutin lukuaika"
],
"entry.external_link.label": "Ulkoinen linkki",
"entry.save.completed": "Valmis!",
"entry.save.label": "Tallenna",
"entry.save.title": "Tallenna tämä artikkeli",
"entry.save.toast.completed": "Artikkeli tallennettu",
"entry.scraper.completed": "Valmis!",
"entry.scraper.label": "Lataa",
"entry.scraper.title": "Nouda alkuperäinen sisältö",
"entry.share.label": "Jaa",
"entry.share.title": "Jaa tämä artikkeli",
"entry.shared_entry.label": "Jaa",
"entry.shared_entry.title": "Avaa julkinen linkki",
"entry.state.loading": "Ladataan...",
"entry.state.saving": "Tallennetaan...",
"entry.status.mark_as_read": "Merkitse luetuksi",
"entry.status.mark_as_unread": "Merkitse lukemattomaksi",
"entry.status.title": "Vaihda artikkelin tilaa",
"entry.status.toast.read": "Merkitty luetuksi",
"entry.status.toast.unread": "Merkitty lukemattomaksi",
"entry.tags.label": "Tunnisteet:",
"entry.tags.more_tags_label": [
"Näytä %d lisää tunnistetta",
"Näytä %d lisää tunnisteita"
],
"entry.unshare.label": "Poista jako",
"error.api_key_already_exists": "API-avain on jo olemassa.",
"error.bad_credentials": "Virheellinen käyttäjänimi tai salasana.",
"error.category_already_exists": "Kategoria on jo olemassa. ",
"error.category_not_found": "Tämä kategoria ei ole olemassa tai se ei kuulu tälle käyttäjälle.",
"error.database_error": "Tietokantavirhe: %v.",
"error.different_passwords": "Salasanat eivät ole samat.",
"error.duplicate_fever_username": "Joku muu käyttää jo samaa Fever-käyttäjänimeä!",
"error.duplicate_googlereader_username": "On jo joku muu, jolla on sama Google-syötteenlukijan käyttäjätunnus!",
"error.duplicate_linked_account": "Joku on jo yhdistetty tähän palveluntarjoajaan!",
"error.duplicated_feed": "Tämä syöte on jo olemassa.",
"error.empty_file": "Tiedosto on tyhjä.",
"error.entries_per_page_invalid": "Artikkelien määrä sivulla ei kelpaa.",
"error.feed_already_exists": "Tämä syöte on jo olemassa.",
"error.feed_category_not_found": "Tätä kategoriaa ei ole olemassa tai se ei kuulu tälle käyttäjälle.",
"error.feed_format_not_detected": "Syötteen muotoa ei voitu tunnistaa: %v.",
"error.feed_invalid_blocklist_rule": "Estolistan sääntö on virheellinen.",
"error.feed_invalid_keeplist_rule": "Säilytettävien listan sääntö on virheellinen.",
"error.feed_mandatory_fields": "URL-osoite ja kategoria ovat pakollisia.",
"error.feed_not_found": "Tämä syöte ei ole olemassa tai se ei kuulu tälle käyttäjälle.",
"error.feed_title_not_empty": "Syötteen otsikko ei voi olla tyhjä.",
"error.feed_url_not_empty": "Syötteen URL-osoite ei voi olla tyhjä.",
"error.fields_mandatory": "Kaikki kentät ovat pakollisia.",
"error.http_bad_gateway": "Verkkosivusto ei ole tällä hetkellä saatavilla huonon yhdyskäytävän virheen vuoksi. Ongelma ei ole Miniflux-puolella. Yritä uudelleen myöhemmin.",
"error.http_body_read": "HTTP-rungon lukeminen epäonnistui: %v.",
"error.http_client_error": "HTTP-asiakasvirhe: %v.",
"error.http_empty_response": "HTTP-vastaus on tyhjä. Sivusto saattaa käyttää bottisuojausta?",
"error.http_empty_response_body": "HTTP-vastauksen runko on tyhjä.",
"error.http_forbidden": "Pääsy tälle sivustolle on kielletty. Sivustolla saattaa olla bottisuojaus?",
"error.http_gateway_timeout": "Sivusto ei ole nyt käytettävissä yhdyskäytävän aikakatkaisun vuoksi. Ongelma ei ole Minifluxin puolella. Yritä myöhemmin uudelleen.",
"error.http_internal_server_error": "Sivusto ei ole nyt käytettävissä palvelinvirheen vuoksi. Ongelma ei ole Minifluxin puolella. Yritä myöhemmin uudelleen.",
"error.http_not_authorized": "Pääsy tälle sivustolle ei ole sallittu. Käyttäjänimi tai salasana voi olla väärä.",
"error.http_resource_not_found": "Pyydettyä resurssia ei löytynyt. Tarkista URL-osoite.",
"error.http_response_too_large": "HTTP-vastaus on liian suuri. Voit kasvattaa rajan yleisasetuksissa (vaatii palvelimen uudelleenkäynnistyksen).",
"error.http_service_unavailable": "Sivusto ei ole nyt käytettävissä sisäisen palvelinvirheen vuoksi. Ongelma ei ole Minifluxin puolella. Yritä myöhemmin uudelleen.",
"error.http_too_many_requests": "Miniflux lähetti liikaa pyyntöjä tälle sivustolle. Yritä myöhemmin uudelleen tai muuta sovelluksen asetuksia.",
"error.http_unexpected_status_code": "Sivusto ei ole nyt käytettävissä odottamattoman HTTP-tilakoodin %d vuoksi. Ongelma ei ole Minifluxin puolella. Yritä myöhemmin uudelleen.",
"error.invalid_categories_sorting_order": "Virheellinen kategorioiden lajittelujärjestys.",
"error.invalid_default_home_page": "Väärä oletusarvoinen kotisivu!",
"error.invalid_display_mode": "Virheellinen verkkosovelluksen näyttötila.",
"error.invalid_entry_direction": "Virheellinen merkintäsuunta.",
"error.invalid_entry_order": "Virheellinen artikkelin lajittelu.",
"error.invalid_feed_proxy_url": "Virheellinen välityspalvelimen URL.",
"error.invalid_feed_url": "Virheellinen syötteen URL-osoite.",
"error.invalid_gesture_nav": "Virheellinen ele-navigointi.",
"error.invalid_language": "Virheellinen kieli.",
"error.invalid_site_url": "Virheellinen sivuston URL-osoite.",
"error.invalid_theme": "Virheellinen teema.",
"error.invalid_timezone": "Virheellinen aikavyöhyke.",
"error.network_operation": "Miniflux ei tavoita tätä sivustoa verkkovirheen vuoksi: %v.",
"error.network_timeout": "Tämä sivusto on liian hidas ja pyyntö aikakatkaistiin: %v",
"error.password_min_length": "Salasanassa on oltava vähintään 6 merkkiä.",
"error.proxy_url_not_empty": "Välityspalvelimen URL ei voi olla tyhjä.",
"error.settings_block_rule_fieldname_invalid": "Virheellinen estosääntö: säännöltä #%d puuttuu kelvollinen kentän nimi (vaihtoehdot: %s)",
"error.settings_block_rule_invalid_regex": "Virheellinen estosääntö: säännön #%d kuvio ei ole kelvollinen regex",
"error.settings_block_rule_regex_required": "Virheellinen estosääntö: säännöltä #%d puuttuu kuvio",
"error.settings_block_rule_separator_required": "Virheellinen estosääntö: säännön #%d kuvio tulee erottaa merkillä '='",
"error.settings_invalid_domain_list": "Virheellinen verkkotunnuslista. Anna välilyönnein eroteltu luettelo.",
"error.settings_keep_rule_fieldname_invalid": "Virheellinen säilytyssääntö: säännöltä #%d puuttuu kelvollinen kentän nimi (vaihtoehdot: %s)",
"error.settings_keep_rule_invalid_regex": "Virheellinen säilytyssääntö: säännön #%d kuvio ei ole kelvollinen regex",
"error.settings_keep_rule_regex_required": "Virheellinen säilytyssääntö: säännöltä #%d puuttuu kuvio",
"error.settings_keep_rule_separator_required": "Virheellinen säilytyssääntö: säännön #%d kuvio tulee erottaa merkillä '='",
"error.settings_mandatory_fields": "Käyttäjätunnus, teema, kieli ja aikavyöhyke ovat pakollisia.",
"error.settings_media_playback_rate_range": "Toistonopeus on alueen ulkopuolella",
"error.settings_reading_speed_is_positive": "Lukunopeuksien on oltava positiivisia kokonaislukuja.",
"error.site_url_not_empty": "Sivuston URL-osoite ei voi olla tyhjä.",
"error.subscription_not_found": "Tilausta ei löydy.",
"error.title_required": "Otsikko on pakollinen.",
"error.tls_error": "TLS-virhe: %q. Voit halutessasi poistaa TLS-tarkistuksen syöteasetuksista.",
"error.unable_to_create_api_key": "API-avainta ei voi luoda.",
"error.unable_to_create_category": "Kategoriaa ei voi luoda.",
"error.unable_to_create_user": "Käyttäjää ei voi luoda.",
"error.unable_to_detect_rssbridge": "Syötettä ei voitu havaita RSS-Bridgea käyttäen: %v.",
"error.unable_to_parse_feed": "Tätä syötettä ei voitu jäsentää: %v.",
"error.unable_to_update_category": "Kategoriaa ei voi päivittää.",
"error.unable_to_update_feed": "Syötettä ei voi päivittää.",
"error.unable_to_update_user": "Käyttäjää ei voi päivittää.",
"error.unlink_account_without_password": "Sinun on määritettävä salasana, muuten et voi kirjautua uudelleen.",
"error.user_already_exists": "Käyttäjä on jo olemassa.",
"error.user_mandatory_fields": "Käyttäjätunnus on pakollinen.",
"error.linktaco_missing_required_fields": "LinkTaco API Token ja Organization Slug vaaditaan",
"form.api_key.label.description": "API-avaimen nimi",
"form.category.hide_globally": "Piilota artikkelit lukemattomien listassa",
"form.category.label.title": "Otsikko",
"form.feed.fieldset.general": "Yleiset",
"form.feed.fieldset.integration": "Kolmannen osapuolen palvelut",
"form.feed.fieldset.network_settings": "Verkkoasetukset",
"form.feed.fieldset.rules": "Säännöt",
"form.feed.label.allow_self_signed_certificates": "Salli itseallekirjoitetut tai virheelliset varmenteet",
"form.feed.label.apprise_service_urls": "Apprise-palvelujen URL-osoitteet pilkuilla eroteltuna",
"form.feed.label.block_filter_entry_rules": "Merkinnän estosäännöt",
"form.feed.label.blocklist_rules": "Regex-pohjaiset estosuodattimet",
"form.feed.label.category": "Kategoria",
"form.feed.label.cookie": "Aseta evästeet",
"form.feed.label.crawler": "Nouda alkuperäinen sisältö",
"form.feed.label.description": "Kuvaus",
"form.feed.label.disable_http2": "Poista HTTP/2 käytöstä sormenjälkien välttämiseksi",
"form.feed.label.disabled": "Älä päivitä tätä syötettä",
"form.feed.label.feed_password": "Syötteen salasana",
"form.feed.label.feed_url": "Syötteen URL-osoite",
"form.feed.label.feed_username": "Syötteen käyttäjätunnus",
"form.feed.label.fetch_via_proxy": "Käytä sovellustasolla määritettyä välityspalvelinta",
"form.feed.label.hide_globally": "Piilota artikkelit lukemattomien listassa",
"form.feed.label.ignore_http_cache": "Ohita HTTP-välimuisti",
"form.feed.label.keep_filter_entry_rules": "Merkinnän sallimissäännöt",
"form.feed.label.keeplist_rules": "Regex-pohjaiset säilytyssuodattimet",
"form.feed.label.no_media_player": "Ei mediasoitinta (ääni/video)",
"form.feed.label.ntfy_activate": "Lähetä merkinnät ntfy-palveluun",
"form.feed.label.ntfy_default_priority": "Ntfy-oletusprioriteetti",
"form.feed.label.ntfy_high_priority": "Ntfy-korkea prioriteetti",
"form.feed.label.ntfy_low_priority": "Ntfy-matala prioriteetti",
"form.feed.label.ntfy_max_priority": "Ntfy-enimmäisprioriteetti",
"form.feed.label.ntfy_min_priority": "Ntfy-vähimmäisprioriteetti",
"form.feed.label.ntfy_priority": "Ntfy-prioriteetti",
"form.feed.label.ntfy_topic": "Ntfy-aihe (valinnainen)",
"form.feed.label.proxy_url": "Välityspalvelimen URL",
"form.feed.label.pushover_activate": "Lähetä merkinnät pushover.net-palveluun",
"form.feed.label.pushover_default_priority": "Pushover-oletusprioriteetti",
"form.feed.label.pushover_high_priority": "Pushover-korkea prioriteetti",
"form.feed.label.pushover_low_priority": "Pushover-matala prioriteetti",
"form.feed.label.pushover_max_priority": "Pushover-enimmäisprioriteetti",
"form.feed.label.pushover_min_priority": "Pushover-vähimmäisprioriteetti",
"form.feed.label.pushover_priority": "Pushover-viestin prioriteetti",
"form.feed.label.rewrite_rules": "Sisällön uudelleenkirjoitussäännöt",
"form.feed.label.scraper_rules": "Scraper-säännöt",
"form.feed.label.site_url": "Sivuston URL-osoite",
"form.feed.label.title": "Otsikko",
"form.feed.label.urlrewrite_rules": "URL-osoitteen uudelleenkirjoitussäännöt",
"form.feed.label.user_agent": "Ohita oletuskäyttäjäagentti",
"form.feed.label.webhook_url": "Ohita oletus-webhook-osoite",
"form.import.label.file": "OPML-tiedosto",
"form.import.label.url": "URL-osoite",
"form.integration.archiveorg_activate": "Työnnä merkinnät osoitteeseen archive.org",
"form.integration.apprise_activate": "Lähetä merkinnät Appriseen",
"form.integration.apprise_services_url": "Pilkuilla eroteltu Apprise-palvelujen URL-lista",
"form.integration.apprise_url": "Apprise API -osoite",
"form.integration.betula_activate": "Tallenna merkinnät Betulaan",
"form.integration.betula_token": "Betula-tunnus",
"form.integration.betula_url": "Betula-palvelimen URL",
"form.integration.cubox_activate": "Tallenna merkinnät Cuboxiin",
"form.integration.cubox_api_link": "Cubox API -linkki",
"form.integration.discord_activate": "Lähetä merkinnät Discordiin",
"form.integration.discord_webhook_link": "Discord-webhook-linkki",
"form.integration.espial_activate": "Tallenna artikkelit Espialiin",
"form.integration.espial_api_key": "Espial API-avain",
"form.integration.espial_endpoint": "Espial API-päätepiste",
"form.integration.espial_tags": "Espial-tagit",
"form.integration.fever_activate": "Ota Fever API käyttöön",
"form.integration.fever_endpoint": "Fever API -päätepiste:",
"form.integration.fever_password": "Fever-salasana",
"form.integration.fever_username": "Fever-käyttäjätunnus",
"form.integration.googlereader_activate": "Aktivoi Google Reader API",
"form.integration.googlereader_endpoint": "Google Reader API -päätepiste:",
"form.integration.googlereader_password": "Google-lukijan salasana",
"form.integration.googlereader_username": "Google-lukijan käyttäjätunnus",
"form.integration.instapaper_activate": "Tallenna artikkelit Instapaperiin",
"form.integration.instapaper_password": "Instapaper-salasana",
"form.integration.instapaper_username": "Instapaper-käyttäjätunnus",
"form.integration.karakeep_activate": "Tallenna artikkelit Karakeepiin",
"form.integration.karakeep_api_key": "Karakeep API-avain",
"form.integration.karakeep_url": "Karakeep API-päätepiste",
"form.integration.karakeep_tags": "Karakeep-tunnisteet",
"form.integration.linkace_activate": "Tallenna merkinnät LinkAceen",
"form.integration.linkace_api_key": "LinkAce API -avain",
"form.integration.linkace_check_disabled": "Poista linkkitarkistus käytöstä",
"form.integration.linkace_endpoint": "LinkAce API -päätepiste",
"form.integration.linkace_is_private": "Merkitse linkki yksityiseksi",
"form.integration.linkace_tags": "LinkAce-tunnisteet",
"form.integration.linkding_activate": "Tallenna artikkelit Linkkiin",
"form.integration.linkding_api_key": "Linkding API-avain",
"form.integration.linkding_bookmark": "Merkitse kirjanmerkki lukemattomaksi",
"form.integration.linkding_endpoint": "Linkding API-päätepiste",
"form.integration.linkding_tags": "Linkding-tunnisteet",
"form.integration.linktaco_activate": "Tallenna kirjoituksia LinkTacoon",
"form.integration.linktaco_api_token": "LinkTaco API -tunnus",
"form.integration.linktaco_api_token_hint": "Hanki henkilökohtainen pääsytunniste osoitteesta",
"form.integration.linktaco_org_slug": "Organisaation slug",
"form.integration.linktaco_tags": "Tunnisteet (enintään 10, pilkuilla eroteltu)",
"form.integration.linktaco_tags_hint": "Enintään 10 tunnistetta, pilkuilla eroteltuna",
"form.integration.linktaco_visibility": "Näkyvyys",
"form.integration.linktaco_visibility_public": "Julkinen",
"form.integration.linktaco_visibility_private": "Yksityinen",
"form.integration.linktaco_visibility_hint": "Yksityinen näkyvyys vaatii maksullisen LinkTaco-tilin",
"form.integration.linkwarden_activate": "Tallenna artikkelit Linkwardeniin",
"form.integration.linkwarden_api_key": "Linkwarden API -avain",
"form.integration.linkwarden_endpoint": "Linkwardenin perus-URL",
"form.integration.linkwarden_collection_id": "Linkwarden-kokoelman tunnus",
"form.integration.matrix_bot_activate": "Siirrä uudet artikkelit Matrixiin",
"form.integration.matrix_bot_chat_id": "Matrix-huoneen tunnus",
"form.integration.matrix_bot_password": "Matrix-käyttäjän salasana",
"form.integration.matrix_bot_url": "Matrix-palvelimen URL-osoite",
"form.integration.matrix_bot_user": "Matrixin käyttäjätunnus",
"form.integration.notion_activate": "Tallenna merkinnät Notioniin",
"form.integration.notion_page_id": "Notion-sivun tunnus",
"form.integration.notion_token": "Notion-salaisuustunnus",
"form.integration.ntfy_activate": "Lähetä merkinnät ntfy-palveluun",
"form.integration.ntfy_api_token": "Ntfy API -tunnus (valinnainen)",
"form.integration.ntfy_icon_url": "Ntfy-kuvakkeen URL (valinnainen)",
"form.integration.ntfy_internal_links": "Käytä sisäisiä linkkejä napsautettaessa (valinnainen)",
"form.integration.ntfy_password": "Ntfy-salasana (valinnainen)",
"form.integration.ntfy_topic": "Ntfy-aihe (oletus jos ei määritetty syötteessä)",
"form.integration.ntfy_url": "Ntfy-URL (valinnainen, oletus ntfy.sh)",
"form.integration.ntfy_username": "Ntfy-käyttäjätunnus (valinnainen)",
"form.integration.nunux_keeper_activate": "Tallenna artikkelit Nunux Keeperiin",
"form.integration.nunux_keeper_api_key": "Nunux Keeper API-avain",
"form.integration.nunux_keeper_endpoint": "Nunux Keeper API-päätepiste",
"form.integration.omnivore_activate": "Tallenna artikkelit Omnivoreiin",
"form.integration.omnivore_api_key": "Omnivore API-avain",
"form.integration.omnivore_url": "Omnivore API-päätepiste",
"form.integration.pinboard_activate": "Tallenna artikkelit Pinboardiin",
"form.integration.pinboard_bookmark": "Merkitse kirjanmerkki lukemattomaksi",
"form.integration.pinboard_tags": "Pinboard-tagit",
"form.integration.pinboard_token": "Pinboard API-tunnus",
"form.integration.pushover_activate": "Lähetä merkinnät Pushoveriin",
"form.integration.pushover_device": "Pushover-laite (valinnainen)",
"form.integration.pushover_prefix": "Pushover-URL:n etuliite (valinnainen)",
"form.integration.pushover_token": "Pushover-sovelluksen API-tunnus",
"form.integration.pushover_user": "Pushover-käyttäjän avain",
"form.integration.raindrop_activate": "Tallenna merkinnät Raindropiin",
"form.integration.raindrop_collection_id": "Kokoelman tunnus",
"form.integration.raindrop_tags": "Tunnisteet (pilkuilla eroteltu)",
"form.integration.raindrop_token": "(Testi) tunnus",
"form.integration.readeck_activate": "Tallenna artikkelit Readeckiin",
"form.integration.readeck_api_key": "Readeck API-avain",
"form.integration.readeck_endpoint": "Readeck API-päätepiste",
"form.integration.readeck_labels": "Readeck-tunnisteet",
"form.integration.readeck_only_url": "Lähetä vain URL-osoite (koko sisällön sijaan)",
"form.integration.readeck_push_activate": "Lähetä uudet merkinnät automaattisesti Readeckiin",
"form.integration.readwise_activate": "Tallenna merkinnät Readwise Readeriin",
"form.integration.readwise_api_key": "Readwise Reader -pääsytunnus",
"form.integration.readwise_api_key_link": "Hanki Readwise-pääsytunnus",
"form.integration.rssbridge_activate": "Tarkista RSS-Bridge tilauksia lisättäessä",
"form.integration.rssbridge_token": "RSS-Bridge-todennustunnus",
"form.integration.rssbridge_url": "RSS-Bridge-palvelimen URL",
"form.integration.shaarli_activate": "Tallenna artikkelit Shaarliin",
"form.integration.shaarli_api_secret": "Shaarli API -salaisuus",
"form.integration.shaarli_endpoint": "Shaarli-osoite",
"form.integration.shiori_activate": "Tallenna artikkelit Shioriin",
"form.integration.shiori_endpoint": "Shiori API -päätepiste",
"form.integration.shiori_password": "Shiori-salasana",
"form.integration.shiori_username": "Shiori-käyttäjätunnus",
"form.integration.slack_activate": "Lähetä merkinnät Slackiin",
"form.integration.slack_webhook_link": "Slack-webhook-linkki",
"form.integration.telegram_bot_activate": "Lähetä uusia artikkeleita Telegram-chatiin",
"form.integration.telegram_bot_disable_buttons": "Poista painikkeet käytöstä",
"form.integration.telegram_bot_disable_notification": "Poista ilmoitukset käytöstä",
"form.integration.telegram_bot_disable_web_page_preview": "Poista sivun esikatselu käytöstä",
"form.integration.telegram_bot_token": "Bot-tunnus",
"form.integration.telegram_chat_id": "Keskustelun tunnus",
"form.integration.telegram_topic_id": "Aiheen tunnus",
"form.integration.wallabag_activate": "Tallenna artikkelit Wallabagiin",
"form.integration.wallabag_client_id": "Wallabag-asiakastunnus",
"form.integration.wallabag_client_secret": "Wallabag-asiakassalaisuus",
"form.integration.wallabag_endpoint": "Wallabagin perus-URL-osoite",
"form.integration.wallabag_only_url": "Lähetä vain URL-osoite (koko sisällön sijaan)",
"form.integration.wallabag_password": "Wallabag-salasana",
"form.integration.wallabag_username": "Wallabag-käyttäjätunnus",
"form.integration.wallabag_tags": "Wallabag-tunnisteet",
"form.integration.webhook_activate": "Ota webhookit käyttöön",
"form.integration.webhook_secret": "Webhookien salaisuus",
"form.integration.webhook_url": "Oletus-webhook-URL",
"form.prefs.fieldset.application_settings": "Sovellusasetukset",
"form.prefs.fieldset.authentication_settings": "Todennusasetukset",
"form.prefs.fieldset.global_feed_settings": "Syötteiden yleisasetukset",
"form.prefs.fieldset.reader_settings": "Lukija-asetukset",
"form.prefs.help.external_font_hosts": "Sallittujen ulkoisten fonttipalvelinten lista välilyönnein eroteltuna. Esimerkiksi: \"fonts.gstatic.com fonts.googleapis.com\".",
"form.prefs.label.always_open_external_links": "Lue artikkelit avaamalla ulkoiset linkit",
"form.prefs.label.categories_sorting_order": "Kategorioiden lajittelu",
"form.prefs.label.cjk_reading_speed": "Kiinan, Korean ja Japanin lukunopeus (merkkejä minuutissa)",
"form.prefs.label.custom_css": "Mukautettu CSS",
"form.prefs.label.custom_js": "Mukautettu JavaScript",
"form.prefs.label.default_home_page": "Oletusarvoinen etusivu",
"form.prefs.label.default_reading_speed": "Muiden kielten lukunopeus (sanaa minuutissa)",
"form.prefs.label.display_mode": "Progressive Web App (PWA) -näyttötila",
"form.prefs.label.entries_per_page": "Artikkelia sivulla",
"form.prefs.label.entry_order": "Lajittele sarakkeen mukaan",
"form.prefs.label.entry_sorting": "Lajittelu",
"form.prefs.label.entry_swipe": "Ota syöttöpyyhkäisy käyttöön kosketusnäytöissä",
"form.prefs.label.external_font_hosts": "Ulkoiset fonttipalvelimet",
"form.prefs.label.gesture_nav": "Ele siirtyäksesi merkintöjen välillä",
"form.prefs.label.keyboard_shortcuts": "Ota pikanäppäimet käyttöön",
"form.prefs.label.language": "Kieli",
"form.prefs.label.mark_read_manually": "Merkitse merkinnät luetuiksi manuaalisesti",
"form.prefs.label.mark_read_on_media_completion": "Merkitse luetuksi vasta, kun ääni/video on 90%% toistettu",
"form.prefs.label.mark_read_on_view": "Merkitse kohdat automaattisesti luetuiksi, kun niitä tarkastellaan",
"form.prefs.label.mark_read_on_view_or_media_completion": "Merkitse merkinnät luetuiksi katsottaessa. Ääni/videolle merkitse 90%% toistettuna",
"form.prefs.label.media_playback_rate": "Äänen/videon toistonopeus",
"form.prefs.label.open_external_links_in_new_tab": "Avaa ulkoiset linkit uuteen välilehteen (lisää target=\"_blank\" linkkeihin)",
"form.prefs.label.show_reading_time": "Näytä artikkeleiden arvioitu lukuaika",
"form.prefs.label.theme": "Teema",
"form.prefs.label.timezone": "Aikavyöhyke",
"form.prefs.select.alphabetical": "Aakkosjärjestys",
"form.prefs.select.browser": "Selain",
"form.prefs.select.created_time": "Luomisaika",
"form.prefs.select.fullscreen": "Kokoruututila",
"form.prefs.select.minimal_ui": "Minimaalinen",
"form.prefs.select.none": "Ei mitään",
"form.prefs.select.older_first": "Vanhin ensin",
"form.prefs.select.publish_time": "Julkaisuaika",
"form.prefs.select.recent_first": "Uusin ensin",
"form.prefs.select.standalone": "Itsenäinen tila",
"form.prefs.select.swipe": "Pyyhkäise",
"form.prefs.select.tap": "Kaksoisnapauta",
"form.prefs.select.unread_count": "Lukemattomien määrä",
"form.submit.loading": "Ladataan...",
"form.submit.saving": "Tallennetaan...",
"form.user.label.admin": "Ylläpitäjä",
"form.user.label.confirmation": "Salasanan vahvistus",
"form.user.label.password": "Salasana",
"form.user.label.username": "Käyttäjätunnus",
"menu.about": "Tietoja",
"menu.add_feed": "Lisää tilaus",
"menu.add_user": "Lisää käyttäjä",
"menu.api_keys": "API-avaimet",
"menu.categories": "Kategoriat",
"menu.create_api_key": "Luo uusi API-avain",
"menu.create_category": "Luo kategoria",
"menu.edit_category": "Muokkaa",
"menu.edit_feed": "Muokkaa",
"menu.export": "Vie",
"menu.feed_entries": "Artikkelit",
"menu.feeds": "Syötteet",
"menu.flush_history": "Tyhjennä historia",
"menu.history": "Historia",
"menu.home_page": "Etusivu",
"menu.import": "Tuo",
"menu.integrations": "Integraatiot",
"menu.logout": "Kirjaudu ulos",
"menu.mark_all_as_read": "Merkitse kaikki luetuksi",
"menu.mark_page_as_read": "Merkitse tämä sivu luetuksi",
"menu.preferences": "Asetukset",
"menu.refresh_all_feeds": "Päivitä kaikki syötteet taustalla",
"menu.refresh_feed": "Päivitä",
"menu.search": "Haku",
"menu.sessions": "Istunnot",
"menu.settings": "Asetukset",
"menu.shared_entries": "Jaetut artikkelit",
"menu.show_all_entries": "Näytä kaikki artikkelit",
"menu.show_only_starred_entries": "Näytä vain suosikit",
"menu.show_only_unread_entries": "Näytä vain lukemattomat artikkelit",
"menu.starred": "Suosikit",
"menu.title": "Valikko",
"menu.unread": "Lukemattomat",
"menu.users": "Käyttäjät",
"page.about.author": "Tekijä:",
"page.about.build_date": "Valmistuspäivä:",
"page.about.credits": "Kiitokset",
"page.about.db_usage": "Tietokannan koko:",
"page.about.git_commit": "Git-toimite:",
"page.about.global_config_options": "Yleiset asetukset",
"page.about.go_version": "Go-versio:",
"page.about.license": "Lisenssi:",
"page.about.postgres_version": "Postgres-versio:",
"page.about.title": "Tietoja",
"page.about.version": "Versio:",
"page.add_feed.choose_feed": "Valitse tilaus",
"page.add_feed.label.url": "URL-osoite",
"page.add_feed.legend.advanced_options": "Edistyneet asetukset",
"page.add_feed.no_category": "Ei ole ketegoriaa. Sinulla on oltava vähintään yksi ketegoria.",
"page.add_feed.submit": "Etsi tilaus",
"page.add_feed.title": "Uusi tilaus",
"page.api_keys.never_used": "Käyttämätön",
"page.api_keys.table.actions": "Toiminnot",
"page.api_keys.table.created_at": "Luomispäivä",
"page.api_keys.table.description": "Kuvaus",
"page.api_keys.table.last_used_at": "Viimeksi käytetty",
"page.api_keys.table.token": "Tunnus",
"page.api_keys.title": "API-avaimet",
"page.categories.entries": "Artikkelit",
"page.categories.feed_count": [
"On %d syöte.",
"On %d syötettä."
],
"page.categories.feeds": "Tilaukset",
"page.categories.no_feed": "Ei syötettä.",
"page.categories.title": "Kategoriat",
"page.categories_count": [
"%d kategoria",
"%d kategoriaa"
],
"page.category_label": "Kategoria: %s",
"page.edit_category.title": "Muokkaa kategoria: %s",
"page.edit_feed.etag_header": "ETag-otsikko:",
"page.edit_feed.last_check": "Viimeisin tarkistus:",
"page.edit_feed.last_modified_header": "LastModified-otsikko:",
"page.edit_feed.last_parsing_error": "Viimeisin jäsennysvirhe",
"page.edit_feed.no_header": "Ei mitään",
"page.edit_feed.title": "Muokkaa syöte: %s",
"page.edit_user.title": "Muokkaa käyttäjä: %s",
"page.entry.attachments": "Liitteet",
"page.feeds.error_count": [
"%d virhe",
"%d virhettä"
],
"page.feeds.last_check": "Viimeisin tarkistus:",
"page.feeds.next_check": "Seuraava tarkistus:",
"page.feeds.read_counter": "Luettujen artikkeleiden määrä",
"page.feeds.title": "Syötteet",
"page.footer.elevator": "Takaisin ylös",
"page.history.title": "Historia",
"page.import.title": "Tuo",
"page.integration.bookmarklet": "Sovelluskirjanmerkki",
"page.integration.bookmarklet.help": "Tämä erityinen linkki antaa sinun tilata verkkosivuston suoraan selaimen kirjanmerkillä.",
"page.integration.bookmarklet.instructions": "Vedä ja pudota tämä linkki kirjanmerkkeihisi.",
"page.integration.bookmarklet.name": "Lisää Minifluxiin",
"page.integration.miniflux_api": "Minifluxin API",
"page.integration.miniflux_api_endpoint": "API-päätepiste",
"page.integration.miniflux_api_password": "Salasana",
"page.integration.miniflux_api_password_value": "Tilisi salasana",
"page.integration.miniflux_api_username": "Käyttäjätunnus",
"page.integrations.title": "Integraatiot",
"page.keyboard_shortcuts.close_modal": "Sulje modaalinen valintaikkuna",
"page.keyboard_shortcuts.download_content": "Lataa alkuperäinen sisältö",
"page.keyboard_shortcuts.go_to_bottom_item": "Siirry alimpaan kohtaan",
"page.keyboard_shortcuts.go_to_categories": "Siirry kategorioihin",
"page.keyboard_shortcuts.go_to_feed": "Siirry syötteeseen",
"page.keyboard_shortcuts.go_to_feeds": "Siirry syötteisiin",
"page.keyboard_shortcuts.go_to_history": "Siirry historiaan",
"page.keyboard_shortcuts.go_to_next_item": "Siirry seuraavaan kohteeseen",
"page.keyboard_shortcuts.go_to_next_page": "Siirry seuraavalle sivulle",
"page.keyboard_shortcuts.go_to_previous_item": "Siirry edelliseen kohteeseen",
"page.keyboard_shortcuts.go_to_previous_page": "Siirry edelliselle sivulle",
"page.keyboard_shortcuts.go_to_search": "Aseta painopiste hakukenttään",
"page.keyboard_shortcuts.go_to_settings": "Siirry asetuksiin",
"page.keyboard_shortcuts.go_to_starred": "Siirry kirjanmerkkeihin",
"page.keyboard_shortcuts.go_to_top_item": "Siirry alkuun",
"page.keyboard_shortcuts.go_to_unread": "Siirry lukemattomiin",
"page.keyboard_shortcuts.mark_page_as_read": "Merkitse nykyinen sivu luetuksi",
"page.keyboard_shortcuts.open_comments": "Avaa kommenttilinkki",
"page.keyboard_shortcuts.open_comments_same_window": "Avaa kommenttilinkki nykyisessä välilehdessä",
"page.keyboard_shortcuts.open_item": "Avaa valittu kohde",
"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.refresh_all_feeds": "Päivitä kaikki syötteet taustalla",
"page.keyboard_shortcuts.remove_feed": "Poista tämä syöte",
"page.keyboard_shortcuts.save_article": "Tallenna artikkeli",
"page.keyboard_shortcuts.scroll_item_to_top": "Vieritä ylös",
"page.keyboard_shortcuts.show_keyboard_shortcuts": "Näytä pikanäppäimet",
"page.keyboard_shortcuts.subtitle.actions": "Toiminnot",
"page.keyboard_shortcuts.subtitle.items": "Kohteiden navigointi",
"page.keyboard_shortcuts.subtitle.pages": "Sivujen navigointi",
"page.keyboard_shortcuts.subtitle.sections": "Osion navigointi",
"page.keyboard_shortcuts.title": "Pikanäppäimet",
"page.keyboard_shortcuts.toggle_star_status": "Vaihda kirjanmerkki",
"page.keyboard_shortcuts.toggle_entry_attachments": "Avaa tai sulje merkinnän liitteet",
"page.keyboard_shortcuts.toggle_read_status_next": "Vaihda luettu/lukematon, keskity seuraavaksi",
"page.keyboard_shortcuts.toggle_read_status_prev": "Vaihda luettu/lukematon, keskity edelliseen",
"page.login.google_signin": "Kirjaudu sisään Googlella",
"page.login.oidc_signin": "Kirjaudu sisään %silla",
"page.login.title": "Kirjaudu sisään",
"page.login.webauthn_login": "Kirjaudu sisään salasanalla",
"page.login.webauthn_login.error": "Ei voida kirjautua sisään salasanalla",
"page.login.webauthn_login.help": "Jos käytät turva-avainta, kirjoita käyttäjätunnus. Passkeytä käyttäessä tämä ei ole tarpeen.",
"page.new_api_key.title": "Uusi API-avain",
"page.new_category.title": "Uusi kategoria",
"page.new_user.title": "Uusi käyttäjä",
"page.offline.message": "Olet offline-tilassa",
"page.offline.refresh_page": "Yritä päivittää sivu",
"page.offline.title": "Offline-tila",
"page.read_entry_count": [
"%d luettu merkintä",
"%d luettua merkintää"
],
"page.search.title": "Hakutulokset",
"page.sessions.table.actions": "Toiminnot",
"page.sessions.table.current_session": "Nykyinen istunto",
"page.sessions.table.date": "Päivämäärä",
"page.sessions.table.ip": "IP-osoite",
"page.sessions.table.user_agent": "Käyttäjäagentti",
"page.sessions.title": "Istunnot",
"page.settings.link_google_account": "Linkitä Google-tilini",
"page.settings.link_oidc_account": "Linkitä %s -tilini",
"page.settings.title": "Asetukset",
"page.settings.unlink_google_account": "Poista Google-tilini linkitys",
"page.settings.unlink_oidc_account": "Poista %s -tilini linkitys",
"page.settings.webauthn.actions": "Toiminnot",
"page.settings.webauthn.added_on": "Lisätty",
"page.settings.webauthn.delete": [
"Poista %d salasana",
"Poista %d salasanaa"
],
"page.settings.webauthn.last_seen_on": "Viimeksi käytetty",
"page.settings.webauthn.passkey_name": "Passkey-nimi",
"page.settings.webauthn.passkeys": "Passkeyt",
"page.settings.webauthn.register": "Rekisteröi salasana",
"page.settings.webauthn.register.error": "Salasanaa ei voi rekisteröidä",
"page.shared_entries.title": "Jaetut artikkelit",
"page.shared_entries_count": [
"%d jaettu merkintä",
"%d jaettua merkintää"
],
"page.starred.title": "Suosikit",
"page.starred_entry_count": [
"%d suosikkimerkintä",
"%d suosikkimerkintää"
],
"page.total_entry_count": [
"Yhteensä %d merkintä",
"Yhteensä %d merkintää"
],
"page.unread.title": "Lukemattomat",
"page.unread_entry_count": [
"%d lukematon merkintä",
"%d lukematonta merkintää"
],
"page.users.actions": "Toiminnot",
"page.users.admin.no": "Ei",
"page.users.admin.yes": "Kyllä",
"page.users.is_admin": "Ylläpitäjä",
"page.users.last_login": "Viimeisin kirjautuminen",
"page.users.never_logged": "Ei koskaan",
"page.users.title": "Käyttäjät",
"page.users.username": "Käyttäjätunnus",
"page.webauthn_rename.title": "Nimeä passkey uudelleen",
"pagination.first": "Ensimmäinen",
"pagination.last": "Viimeinen",
"pagination.next": "Seuraava",
"pagination.previous": "Edellinen",
"search.label": "Haku",
"search.placeholder": "Hae...",
"search.submit": "Hae",
"skip_to_content": "Siirry sisältöön",
"time_elapsed.days": [
"%d päivä sitten",
"%d päivää sitten"
],
"time_elapsed.hours": [
"%d tunti sitten",
"%d tuntia sitten"
],
"time_elapsed.minutes": [
"%d minuutti sitten",
"%d minuuttia sitten"
],
"time_elapsed.months": [
"%d kuukausi sitten",
"%d kuukautta sitten"
],
"time_elapsed.not_yet": "ei vielä",
"time_elapsed.now": "juuri nyt",
"time_elapsed.weeks": [
"%d viikko sitten",
"%d viikkoa sitten"
],
"time_elapsed.years": [
"%d vuosi sitten",
"%d vuotta sitten"
],
"time_elapsed.yesterday": "eilen",
"tooltip.keyboard_shortcuts": "Pikanäppäin: %s",
"tooltip.logged_user": "Kirjautunut %s-käyttäjänä"
}
v2-2.2.16/internal/locale/translations/fr_FR.json 0000664 0000000 0000000 00000121460 15127074645 0021635 0 ustar 00root root 0000000 0000000 {
"action.cancel": "annuler",
"action.download": "Télécharger",
"action.edit": "Modifier",
"action.home_screen": "Ajouter à l'écran d'accueil",
"action.import": "Importer",
"action.login": "Se connecter",
"action.or": "ou",
"action.remove": "Supprimer",
"action.remove_feed": "Supprimer ce flux",
"action.save": "Sauvegarder",
"action.subscribe": "S'abonner",
"action.update": "Mettre à jour",
"alert.account_linked": "Votre compte externe est maintenant associé !",
"alert.account_unlinked": "Votre compte externe est maintenant dissocié !",
"alert.background_feed_refresh": "Les abonnements sont en cours d'actualisation en arrière-plan. Vous pouvez continuer à naviguer dans l'application.",
"alert.feed_error": "Il y a un problème avec cet abonnement",
"alert.no_starred": "Il n'y a aucun favoris pour le moment.",
"alert.no_category": "Il n'y a aucune catégorie.",
"alert.no_category_entry": "Il n'y a aucun article dans cette catégorie.",
"alert.no_feed": "Vous n'avez aucun abonnement.",
"alert.no_feed_entry": "Il n'y a aucun article pour cet abonnement.",
"alert.no_feed_in_category": "Il n'y a pas d'abonnement pour cette catégorie.",
"alert.no_history": "Il n'y a aucun historique pour le moment.",
"alert.no_search_result": "Il n'y a aucun résultat pour cette recherche.",
"alert.no_shared_entry": "Il n'y a pas d'article partagé.",
"alert.no_tag_entry": "Il n'y a aucun article correspondant à ce tag.",
"alert.no_unread_entry": "Il n'y a rien de nouveau à lire.",
"alert.no_user": "Vous êtes le seul utilisateur.",
"alert.prefs_saved": "Préférences sauvegardées !",
"alert.too_many_feeds_refresh": [
"Vous avez déclenché trop d'actualisations de flux. Veuillez attendre %d minute avant de réessayer.",
"Vous avez déclenché trop d'actualisations de flux. Veuillez attendre %d minutes avant de réessayer."
],
"confirm.loading": "En cours...",
"confirm.no": "non",
"confirm.question": "Êtes-vous sûr ?",
"confirm.question.refresh": "Voulez-vous forcer le rafraîchissement ?",
"confirm.yes": "oui",
"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.reset": "Réinitialiser",
"enclosure_media_controls.speed.reset.title": "Réinitialiser la vitesse de lecture à 1x",
"enclosure_media_controls.speed.slower": "Ralentir",
"enclosure_media_controls.speed.slower.title": "Ralentir de %sx",
"entry.starred.toast.off": "Enlevé des favoris",
"entry.starred.toast.on": "Ajouté aux favoris",
"entry.starred.toggle.off": "Enlever favoris",
"entry.starred.toggle.on": "Favoris",
"entry.comments.label": "Commentaires",
"entry.comments.title": "Voir les commentaires",
"entry.estimated_reading_time": [
"%d minute de lecture",
"%d minutes de lecture"
],
"entry.external_link.label": "Lien externe",
"entry.save.completed": "Terminé !",
"entry.save.label": "Sauvegarder",
"entry.save.title": "Sauvegarder cet article",
"entry.save.toast.completed": "Article sauvegardé",
"entry.scraper.completed": "Terminé !",
"entry.scraper.label": "Télécharger",
"entry.scraper.title": "Récupérer le contenu original",
"entry.share.label": "Partager",
"entry.share.title": "Partager cet article",
"entry.shared_entry.label": "Partage",
"entry.shared_entry.title": "Ouvrir le lien public",
"entry.state.loading": "Chargement...",
"entry.state.saving": "Sauvegarde en cours...",
"entry.status.mark_as_read": "Marquer comme lu",
"entry.status.mark_as_unread": "Marquer comme non lu",
"entry.status.title": "Changer le statut de l'entrée",
"entry.status.toast.read": "Marqué comme lu",
"entry.status.toast.unread": "Marqué comme non lu",
"entry.tags.label": "Libellés :",
"entry.tags.more_tags_label": [
"Afficher %d libellé supplémentaire",
"Afficher %d libellés supplémentaires"
],
"entry.unshare.label": "Enlever le partage",
"error.api_key_already_exists": "Cette clé d'API existe déjà.",
"error.bad_credentials": "Mauvais identifiant ou mot de passe.",
"error.category_already_exists": "Cette catégorie existe déjà.",
"error.category_not_found": "Cette catégorie n'existe pas ou n'appartient pas à cet utilisateur.",
"error.database_error": "Erreur de la base de données : %v.",
"error.different_passwords": "Les mots de passe ne sont pas les mêmes.",
"error.duplicate_fever_username": "Il y a déjà quelqu'un d'autre avec le même nom d'utilisateur Fever !",
"error.duplicate_googlereader_username": "Il y a déjà quelqu'un d'autre avec le même nom d'utilisateur Google Reader !",
"error.duplicate_linked_account": "Il y a déjà quelqu'un d'associé avec ce provider !",
"error.duplicated_feed": "Ce flux existe déjà.",
"error.empty_file": "Ce fichier est vide.",
"error.entries_per_page_invalid": "Le nombre d'entrées par page n'est pas valide.",
"error.feed_already_exists": "Ce flux existe déjà.",
"error.feed_category_not_found": "Cette catégorie n'existe pas ou n'appartient pas à cet utilisateur.",
"error.feed_format_not_detected": "Impossible de détecter le format du flux : %v.",
"error.feed_invalid_blocklist_rule": "La règle de blocage n'est pas valide.",
"error.feed_invalid_keeplist_rule": "La règle d'autorisation n'est pas valide.",
"error.feed_mandatory_fields": "L'URL et la catégorie sont obligatoire.",
"error.feed_not_found": "Impossible de trouver ce flux.",
"error.feed_title_not_empty": "Le titre du flux ne peut pas être vide.",
"error.feed_url_not_empty": "L'URL du flux ne peut pas être vide.",
"error.fields_mandatory": "Tous les champs sont obligatoire.",
"error.http_bad_gateway": "Le site web n'est pas disponible pour le moment à cause d'une erreur de passerelle réseau. Le problème ne vient pas de Miniflux. Veuillez réessayer plus tard.",
"error.http_body_read": "Impossible de lire le corps de la réponse HTTP : %v.",
"error.http_client_error": "Erreur du client HTTP : %v.",
"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_body": "Le corps de la réponse HTTP est vide.",
"error.http_forbidden": "Accès interdit à ce site web. Il se peut que ce site web bloque Miniflux avec une protection anti-bot.",
"error.http_gateway_timeout": "Le site web n'est pas disponible pour le moment à cause d'un délai d'attente dépassé. Le problème ne vient pas de Miniflux. Veuillez réessayer plus tard.",
"error.http_internal_server_error": "Le site web n'est pas disponible pour le moment à cause d'une erreur interne au serveur. Le problème ne vient pas de Miniflux. Veuillez réessayer plus tard.",
"error.http_not_authorized": "Accès non autorisé à ce site web. Veuillez vérifier les identifiants de cet abonnement.",
"error.http_resource_not_found": "La resource demandée n'existe pas sur ce site web. Veuillez vérifier l'URL.",
"error.http_response_too_large": "La réponse HTTP est trop volumineuse. Vous pouvez augmenter la limite de taille de réponse HTTP dans les paramètres de l'application (redémarrage de l'application nécessaire).",
"error.http_service_unavailable": "Le site web n'est pas disponible pour le moment. Le problème ne vient pas de Miniflux. Veuillez réessayer plus tard.",
"error.http_too_many_requests": "Miniflux a généré trop de requêtes vers ce site web. Veuillez réessayer plus tard ou changez la configuration de l'application.",
"error.http_unexpected_status_code": "Le site web a répondu avec un code HTTP inattendu : %d. Le problème ne vient pas de Miniflux. Veuillez réessayer plus tard.",
"error.invalid_categories_sorting_order": "L'ordre de tri des catégories n'est pas valide.",
"error.invalid_default_home_page": "Page d'accueil par défaut invalide !",
"error.invalid_display_mode": "Mode d'affichage de l'application web non valide.",
"error.invalid_entry_direction": "Ordre de trie non valide.",
"error.invalid_entry_order": "Ordre de tri non valide.",
"error.invalid_feed_proxy_url": "L'URL du proxy n'est pas valide.",
"error.invalid_feed_url": "URL de flux non valide.",
"error.invalid_gesture_nav": "Navigation gestuelle non valide.",
"error.invalid_language": "Langue non valide.",
"error.invalid_site_url": "URL de site non valide.",
"error.invalid_theme": "Thème non valide.",
"error.invalid_timezone": "Fuseau horaire non valide.",
"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.password_min_length": "Vous devez utiliser au moins 6 caractères pour le mot de passe.",
"error.proxy_url_not_empty": "L'URL du proxy ne peut pas être vide.",
"error.settings_block_rule_fieldname_invalid": "Règle de blocage invalide : la règle n°%d ne contient pas un nom de champ valide (Options : %s)",
"error.settings_block_rule_invalid_regex": "Règle de blocage invalide : le motif de la règle n°%d n'est pas une expression régulière valide",
"error.settings_block_rule_regex_required": "Règle de blocage invalide : le motif de la règle n°%d n'est pas fourni",
"error.settings_block_rule_separator_required": "Règle de blocage invalide : le motif de la règle n°%d doit être séparé par un '='",
"error.settings_invalid_domain_list": "Liste de domaines invalide. Veuillez fournir une liste de domaines séparés par des espaces.",
"error.settings_keep_rule_fieldname_invalid": "Règle de conservation invalide : la règle n°%d ne contient pas un nom de champ valide (Options : %s)",
"error.settings_keep_rule_invalid_regex": "Règle de conservation invalide : le motif de la règle n°%d n'est pas une expression régulière valide",
"error.settings_keep_rule_regex_required": "Règle de conservation invalide : le motif de la règle n°%d n'est pas fourni",
"error.settings_keep_rule_separator_required": "Règle de conservation invalide : le motif de la règle n°%d doit être séparé par un '='",
"error.settings_mandatory_fields": "Le nom d'utilisateur, le thème, la langue et le fuseau horaire sont obligatoire.",
"error.settings_media_playback_rate_range": "La vitesse de lecture est hors limites",
"error.settings_reading_speed_is_positive": "Les vitesses de lecture doivent être des entiers positifs.",
"error.site_url_not_empty": "L'URL du site ne peut pas être vide.",
"error.subscription_not_found": "Impossible de trouver un abonnement.",
"error.title_required": "Le titre est obligatoire.",
"error.tls_error": "Erreur TLS : %q. Vous pouvez désactiver la vérification TLS dans les paramètres de l'abonnement.",
"error.unable_to_create_api_key": "Impossible de créer cette clé d'API.",
"error.unable_to_create_category": "Impossible de créer cette catégorie.",
"error.unable_to_create_user": "Impossible de créer cet utilisateur.",
"error.unable_to_detect_rssbridge": "Impossible de détecter un flux RSS en utilisant RSS-Bridge: %v.",
"error.unable_to_parse_feed": "Impossible d'analyser ce flux : %v.",
"error.unable_to_update_category": "Impossible de mettre à jour cette catégorie.",
"error.unable_to_update_feed": "Impossible de mettre à jour cet abonnement.",
"error.unable_to_update_user": "Impossible de mettre à jour cet utilisateur.",
"error.unlink_account_without_password": "Vous devez définir un mot de passe sinon vous ne pourrez plus vous connecter par la suite.",
"error.user_already_exists": "Cet utilisateur existe déjà.",
"error.user_mandatory_fields": "Le nom d'utilisateur est obligatoire.",
"error.linktaco_missing_required_fields": "Le token API LinkTaco et le slug de l'organisation sont requis.",
"form.api_key.label.description": "Libellé de la clé d'API",
"form.category.hide_globally": "Masquer les entrées dans la liste globale non lue",
"form.category.label.title": "Titre",
"form.feed.fieldset.general": "Général",
"form.feed.fieldset.integration": "Services tiers",
"form.feed.fieldset.network_settings": "Paramètres réseau",
"form.feed.fieldset.rules": "Règles",
"form.feed.label.allow_self_signed_certificates": "Autoriser les certificats auto-signés ou non valides",
"form.feed.label.apprise_service_urls": "Liste séparée par des virgules des URL du service Apprise",
"form.feed.label.block_filter_entry_rules": "Règles de blocage des entrées",
"form.feed.label.blocklist_rules": "Filtres de blocage basés sur des expressions régulières",
"form.feed.label.category": "Catégorie",
"form.feed.label.cookie": "Définir les cookies",
"form.feed.label.crawler": "Récupérer le contenu original",
"form.feed.label.description": "Description",
"form.feed.label.disable_http2": "Désactiver HTTP/2",
"form.feed.label.disabled": "Ne pas actualiser ce flux",
"form.feed.label.feed_password": "Mot de passe du flux",
"form.feed.label.feed_url": "URL du flux",
"form.feed.label.feed_username": "Nom d'utilisateur du flux",
"form.feed.label.fetch_via_proxy": "Utiliser le proxy configuré au niveau de l'application",
"form.feed.label.hide_globally": "Masquer les entrées dans la liste globale non lue",
"form.feed.label.ignore_http_cache": "Ignorer le cache HTTP",
"form.feed.label.keep_filter_entry_rules": "Règles d'autorisation des entrées",
"form.feed.label.keeplist_rules": "Filtres de conservation basés sur des expressions régulières",
"form.feed.label.no_media_player": "Pas de lecteur multimedia (audio/vidéo)",
"form.feed.label.ntfy_activate": "Activer les notifications",
"form.feed.label.ntfy_default_priority": "Priorité par défaut de notification",
"form.feed.label.ntfy_high_priority": "Priorité élevée de notification",
"form.feed.label.ntfy_low_priority": "Priorité basse de notification",
"form.feed.label.ntfy_max_priority": "Priorité maximale de notification",
"form.feed.label.ntfy_min_priority": "Priorité minimale de notification",
"form.feed.label.ntfy_priority": "Priorité de notification",
"form.feed.label.ntfy_topic": "Sujet Ntfy (facultatif)",
"form.feed.label.proxy_url": "URL du proxy",
"form.feed.label.pushover_activate": "Activer les notifications vers Pushover",
"form.feed.label.pushover_default_priority": "Priorité par défaut",
"form.feed.label.pushover_high_priority": "Priorité élevée",
"form.feed.label.pushover_low_priority": "Priorité basse",
"form.feed.label.pushover_max_priority": "Priorité maximale",
"form.feed.label.pushover_min_priority": "Priorité minimale",
"form.feed.label.pushover_priority": "Priorité des notifications Pushover",
"form.feed.label.rewrite_rules": "Règles de réécriture du contenu",
"form.feed.label.scraper_rules": "Règles pour récupérer le contenu original",
"form.feed.label.site_url": "URL du site web",
"form.feed.label.title": "Titre",
"form.feed.label.urlrewrite_rules": "Règles de réécriture d'URL",
"form.feed.label.user_agent": "Remplacer l'agent utilisateur par défaut",
"form.feed.label.webhook_url": "Remplacer l'URL du webhook",
"form.import.label.file": "Fichier OPML",
"form.import.label.url": "URL",
"form.integration.archiveorg_activate": "Envoyer les articles vers archive.org",
"form.integration.apprise_activate": "Envoyer les articles vers Apprise",
"form.integration.apprise_services_url": "Liste des services Apprise séparés par des virgules",
"form.integration.apprise_url": "URL de l'API Apprise",
"form.integration.betula_activate": "Sauvegarder les entrées vers Betula",
"form.integration.betula_token": "Jeton de sécurité de l'API de Betula",
"form.integration.betula_url": "URL du serveur Betula",
"form.integration.cubox_activate": "Sauvegarder les entrées vers Cubox",
"form.integration.cubox_api_link": "Lien API Cubox",
"form.integration.discord_activate": "Envoyer les articles vers Discord",
"form.integration.discord_webhook_link": "URL du Webhook Discord",
"form.integration.espial_activate": "Sauvegarder les articles vers Espial",
"form.integration.espial_api_key": "Clé d'API de Espial",
"form.integration.espial_endpoint": "URL de l'API de Espial",
"form.integration.espial_tags": "Libellés de Espial",
"form.integration.fever_activate": "Activer l'API de Fever",
"form.integration.fever_endpoint": "Point de terminaison de l'API Fever :",
"form.integration.fever_password": "Mot de passe pour l'API de Fever",
"form.integration.fever_username": "Nom d'utilisateur pour l'API de Fever",
"form.integration.googlereader_activate": "Activer l'API de Google Reader",
"form.integration.googlereader_endpoint": "Point de terminaison de l'API Google Reader :",
"form.integration.googlereader_password": "Mot de passe pour l'API de Google Reader",
"form.integration.googlereader_username": "Nom d'utilisateur pour l'API de Google Reader",
"form.integration.instapaper_activate": "Sauvegarder les articles vers Instapaper",
"form.integration.instapaper_password": "Mot de passe Instapaper",
"form.integration.instapaper_username": "Nom d'utilisateur Instapaper",
"form.integration.karakeep_activate": "Sauvegarder les articles vers Karakeep",
"form.integration.karakeep_api_key": "Clé d'API de Karakeep",
"form.integration.karakeep_url": "URL de l'API de Karakeep",
"form.integration.karakeep_tags": "Libellés Karakeep",
"form.integration.linkace_activate": "Enregistrer les entrées vers LinkAce",
"form.integration.linkace_api_key": "Clé d'API LinkAce",
"form.integration.linkace_check_disabled": "Désactiver la vérification des liens",
"form.integration.linkace_endpoint": "Point de terminaison de l'API LinkAce",
"form.integration.linkace_is_private": "Marquer le lien comme privé",
"form.integration.linkace_tags": "Étiquettes LinkAce",
"form.integration.linkding_activate": "Sauvegarder les articles vers Linkding",
"form.integration.linkding_api_key": "Clé d'API de Linkding",
"form.integration.linkding_bookmark": "Marquer le lien comme non lu",
"form.integration.linkding_endpoint": "URL de l'API de Linkding",
"form.integration.linkding_tags": "Libellés",
"form.integration.linktaco_activate": "Sauvegarder les entrées vers LinkTaco",
"form.integration.linktaco_api_token": "Token API LinkTaco",
"form.integration.linktaco_api_token_hint": "Obtenez votre token d'accès personnel sur",
"form.integration.linktaco_org_slug": "Slug de l'organisation",
"form.integration.linktaco_tags": "Libellés (max. 10, séparés par des virgules)",
"form.integration.linktaco_tags_hint": "Maximum 10 libellés, séparés par des virgules",
"form.integration.linktaco_visibility": "Visibilité",
"form.integration.linktaco_visibility_public": "Publique",
"form.integration.linktaco_visibility_private": "Privé",
"form.integration.linktaco_visibility_hint": "La visibilité PRIVÉE nécessite un compte LinkTaco payant",
"form.integration.linkwarden_activate": "Sauvegarder les articles vers Linkwarden",
"form.integration.linkwarden_api_key": "Clé d'API de Linkwarden",
"form.integration.linkwarden_endpoint": "URL de base de Linkwarden",
"form.integration.linkwarden_collection_id": "ID de collection Linkwarden",
"form.integration.matrix_bot_activate": "Envoyer les nouveaux articles vers Matrix",
"form.integration.matrix_bot_chat_id": "Identifiant de la salle 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_user": "Nom de l'utilisateur Matrix",
"form.integration.notion_activate": "Sauvegarder les articles vers Notion",
"form.integration.notion_page_id": "Identifiant de la page Notion",
"form.integration.notion_token": "Jeton d'accès de l'API de Notion",
"form.integration.ntfy_activate": "Envoyer les entrées vers ntfy",
"form.integration.ntfy_api_token": "Jeton d'API Ntfy (optionnel)",
"form.integration.ntfy_icon_url": "URL de l'icône Ntfy (facultatif)",
"form.integration.ntfy_internal_links": "Utiliser les liens internes vers Miniflux (facultatif)",
"form.integration.ntfy_password": "Mot de passe Ntfy (facultatif)",
"form.integration.ntfy_topic": "Sujet Ntfy (défaut s'il n'est pas défini dans le flux)",
"form.integration.ntfy_url": "URL de Ntfy (optionnel, ntfy.sh par défaut)",
"form.integration.ntfy_username": "Nom d'utilisateur Ntfy (optionnel)",
"form.integration.nunux_keeper_activate": "Sauvegarder les articles vers Nunux Keeper",
"form.integration.nunux_keeper_api_key": "Clé d'API de Nunux Keeper",
"form.integration.nunux_keeper_endpoint": "URL de l'API de Nunux Keeper",
"form.integration.omnivore_activate": "Sauvegarder les articles vers Omnivore",
"form.integration.omnivore_api_key": "Clé d'API de Omnivore",
"form.integration.omnivore_url": "URL de l'API de Omnivore",
"form.integration.pinboard_activate": "Sauvegarder les articles vers Pinboard",
"form.integration.pinboard_bookmark": "Marquer le lien comme non lu",
"form.integration.pinboard_tags": "Libellés de Pinboard",
"form.integration.pinboard_token": "Jeton de sécurité de l'API de Pinboard",
"form.integration.pushover_activate": "Envoyer les articles vers Pushover",
"form.integration.pushover_device": "Nom de l'appareil Pushover (facultatif)",
"form.integration.pushover_prefix": "URL de préfixe Pushover (facultatif)",
"form.integration.pushover_token": "Jeton d'API de l'application Pushover",
"form.integration.pushover_user": "Identifiant de l'utilisateur Pushover (user key)",
"form.integration.raindrop_activate": "Enregistrer les entrées vers Raindrop",
"form.integration.raindrop_collection_id": "Identifiant de la collection",
"form.integration.raindrop_tags": "Libellés (séparées par des virgules)",
"form.integration.raindrop_token": "Jeton d'accès de Raindrop",
"form.integration.readeck_activate": "Sauvegarder les articles vers Readeck",
"form.integration.readeck_api_key": "Clé d'API de Readeck",
"form.integration.readeck_endpoint": "URL de l'API de Readeck",
"form.integration.readeck_labels": "Libellés Readeck",
"form.integration.readeck_only_url": "Envoyer uniquement l'URL (au lieu du contenu complet)",
"form.integration.readeck_push_activate": "Envoyer automatiquement les nouvelles entrées vers Readeck",
"form.integration.readwise_activate": "Enregistrer les entrées vers Readwise Reader",
"form.integration.readwise_api_key": "Jeton d'accès au lecteur Readwise",
"form.integration.readwise_api_key_link": "Obtenez votre jeton d'accès Readwise",
"form.integration.rssbridge_activate": "Vérifier RSS-Bridge lors de l'ajout d'abonnements",
"form.integration.rssbridge_token": "Jeton d'authentification RSS-Bridge",
"form.integration.rssbridge_url": "URL du serveur RSS-Bridge",
"form.integration.shaarli_activate": "Sauvegarder les articles vers Shaarli",
"form.integration.shaarli_api_secret": "Clé d'API de Shaarli API",
"form.integration.shaarli_endpoint": "URL de l'API de Shaarli",
"form.integration.shiori_activate": "Sauvegarder les articles vers Shiori",
"form.integration.shiori_endpoint": "URL de l'API de Shiori",
"form.integration.shiori_password": "Mot de passe de Shiori",
"form.integration.shiori_username": "Nom d'utilisateur de Shiori",
"form.integration.slack_activate": "Envoyer les articles vers Slack",
"form.integration.slack_webhook_link": "URL du Webhook Slack",
"form.integration.telegram_bot_activate": "Envoyer les nouveaux articles vers Telegram",
"form.integration.telegram_bot_disable_buttons": "Désactiver les boutons",
"form.integration.telegram_bot_disable_notification": "Désactiver les notifications",
"form.integration.telegram_bot_disable_web_page_preview": "Désactiver l'aperçu de la page Web",
"form.integration.telegram_bot_token": "Jeton de sécurité de l'API du Bot Telegram",
"form.integration.telegram_chat_id": "Identifiant de discussion (Chat ID)",
"form.integration.telegram_topic_id": "Identifiant du sujet (Topic ID)",
"form.integration.wallabag_activate": "Sauvegarder les articles vers Wallabag",
"form.integration.wallabag_client_id": "Identifiant unique du client Wallabag",
"form.integration.wallabag_client_secret": "Clé secrète du client Wallabag",
"form.integration.wallabag_endpoint": "URL de base de Wallabag",
"form.integration.wallabag_only_url": "Envoyer uniquement l'URL (au lieu du contenu complet)",
"form.integration.wallabag_password": "Mot de passe de Wallabag",
"form.integration.wallabag_username": "Nom d'utilisateur de Wallabag",
"form.integration.wallabag_tags": "Libellés Wallabag",
"form.integration.webhook_activate": "Activer le webhook",
"form.integration.webhook_secret": "Secret du webhook",
"form.integration.webhook_url": "URL du webhook",
"form.prefs.fieldset.application_settings": "Paramètres de l'application",
"form.prefs.fieldset.authentication_settings": "Paramètres d'authentification",
"form.prefs.fieldset.global_feed_settings": "Paramètres globaux des abonnements",
"form.prefs.fieldset.reader_settings": "Paramètres du lecteur",
"form.prefs.help.external_font_hosts": "Liste de domaine externes autorisés, séparés par des espaces. Par exemple : « fonts.gstatic.com fonts.googleapis.com ».",
"form.prefs.label.always_open_external_links": "Lire les articles en ouvrant les liens externes",
"form.prefs.label.categories_sorting_order": "Colonne de tri des catégories",
"form.prefs.label.cjk_reading_speed": "Vitesse de lecture pour le chinois, le coréen et le japonais (caractères par minute)",
"form.prefs.label.custom_css": "Feuille de style personnalisée",
"form.prefs.label.custom_js": "Code JavaScript personnalisé",
"form.prefs.label.default_home_page": "Page d'accueil par défaut",
"form.prefs.label.default_reading_speed": "Vitesse de lecture pour les autres langues (mots par minute)",
"form.prefs.label.display_mode": "Mode d'affichage de l'Application Web Progressive (PWA)",
"form.prefs.label.entries_per_page": "Entrées par page",
"form.prefs.label.entry_order": "Colonne de tri des entrées",
"form.prefs.label.entry_sorting": "Ordre des éléments",
"form.prefs.label.entry_swipe": "Activer le balayage des entrées sur les écrans tactiles",
"form.prefs.label.external_font_hosts": "Polices externes autorisées",
"form.prefs.label.gesture_nav": "Geste pour naviguer entre les entrées",
"form.prefs.label.keyboard_shortcuts": "Activer les raccourcis clavier",
"form.prefs.label.language": "Langue",
"form.prefs.label.mark_read_manually": "Marquer les entrées comme lues manuellement",
"form.prefs.label.mark_read_on_media_completion": "Marquer les entrées comme lues uniquement après 90%% de lecture de l'audio/vidéo",
"form.prefs.label.mark_read_on_view": "Marquer automatiquement les entrées comme lues lorsqu'elles sont consultées",
"form.prefs.label.mark_read_on_view_or_media_completion": "Marquer automatiquement les entrées comme lues lorsqu'elles sont consultées. Pour l'audio/vidéo, marquer comme lues après 90%%",
"form.prefs.label.media_playback_rate": "Vitesse de lecture de l'audio/vidéo",
"form.prefs.label.open_external_links_in_new_tab": "Ouvrir les liens externes dans un nouvel onglet (ajoute target=\"_blank\" aux liens)",
"form.prefs.label.show_reading_time": "Afficher le temps de lecture estimé des articles",
"form.prefs.label.theme": "Thème",
"form.prefs.label.timezone": "Fuseau horaire",
"form.prefs.select.alphabetical": "Alphabétique",
"form.prefs.select.browser": "Navigateur",
"form.prefs.select.created_time": "Heure de création de l'entrée",
"form.prefs.select.fullscreen": "Plein écran",
"form.prefs.select.minimal_ui": "Minimaliste",
"form.prefs.select.none": "Aucun",
"form.prefs.select.older_first": "Anciens éléments en premier",
"form.prefs.select.publish_time": "Heure de publication de l'entrée",
"form.prefs.select.recent_first": "Éléments récents en premier",
"form.prefs.select.standalone": "Autonome",
"form.prefs.select.swipe": "Glisser",
"form.prefs.select.tap": "Tapez deux fois",
"form.prefs.select.unread_count": "Nombre d'articles non lus",
"form.submit.loading": "Chargement...",
"form.submit.saving": "Sauvegarde en cours...",
"form.user.label.admin": "Administrateur",
"form.user.label.confirmation": "Confirmation du mot de passe",
"form.user.label.password": "Mot de passe",
"form.user.label.username": "Nom d'utilisateur",
"menu.about": "À propos",
"menu.add_feed": "Ajouter un abonnement",
"menu.add_user": "Ajouter un utilisateur",
"menu.api_keys": "Clés d'API",
"menu.categories": "Catégories",
"menu.create_api_key": "Créer une nouvelle clé d'API",
"menu.create_category": "Créer une catégorie",
"menu.edit_category": "Modifier",
"menu.edit_feed": "Modifier",
"menu.export": "Export",
"menu.feed_entries": "Articles",
"menu.feeds": "Abonnements",
"menu.flush_history": "Supprimer l'historique",
"menu.history": "Historique",
"menu.home_page": "Page d'accueil",
"menu.import": "Import",
"menu.integrations": "Intégrations",
"menu.logout": "Se déconnecter",
"menu.mark_all_as_read": "Tout marquer comme lu",
"menu.mark_page_as_read": "Marquer cette page comme lue",
"menu.preferences": "Préférences",
"menu.refresh_all_feeds": "Actualiser les abonnements en arrière-plan",
"menu.refresh_feed": "Actualiser",
"menu.search": "Recherche",
"menu.sessions": "Sessions",
"menu.settings": "Réglages",
"menu.shared_entries": "Articles partagés",
"menu.show_all_entries": "Afficher tous les articles",
"menu.show_only_starred_entries": "Afficher uniquement les favoris",
"menu.show_only_unread_entries": "Afficher uniquement les articles non lus",
"menu.starred": "Favoris",
"menu.title": "Menu",
"menu.unread": "Non lus",
"menu.users": "Utilisateurs",
"page.about.author": "Auteur :",
"page.about.build_date": "Date de la compilation :",
"page.about.credits": "Crédits",
"page.about.db_usage": "Taille de la base de données :",
"page.about.git_commit": "Commit Git :",
"page.about.global_config_options": "Options de configuration globales",
"page.about.go_version": "Version de Go :",
"page.about.license": "Licence :",
"page.about.postgres_version": "Version de Postgresql :",
"page.about.title": "À propos",
"page.about.version": "Version :",
"page.add_feed.choose_feed": "Choisissez un abonnement",
"page.add_feed.label.url": "Lien",
"page.add_feed.legend.advanced_options": "Options avancées",
"page.add_feed.no_category": "Il n'y a aucune catégorie. Vous devez avoir au moins une catégorie.",
"page.add_feed.submit": "Trouver un abonnement",
"page.add_feed.title": "Nouvel Abonnement",
"page.api_keys.never_used": "Jamais utilisé",
"page.api_keys.table.actions": "Actions",
"page.api_keys.table.created_at": "Date de création",
"page.api_keys.table.description": "Description",
"page.api_keys.table.last_used_at": "Dernière utilisation",
"page.api_keys.table.token": "Jeton",
"page.api_keys.title": "Clés d'API",
"page.categories.entries": "Articles",
"page.categories.feed_count": [
"Il y a %d abonnement.",
"Il y a %d abonnements."
],
"page.categories.feeds": "Abonnements",
"page.categories.no_feed": "Aucun abonnement.",
"page.categories.title": "Catégories",
"page.categories_count": [
"%d catégorie",
"%d catégories"
],
"page.category_label": "Catégorie : %s",
"page.edit_category.title": "Modification de la catégorie : %s",
"page.edit_feed.etag_header": "En-tête ETag :",
"page.edit_feed.last_check": "Dernière vérification :",
"page.edit_feed.last_modified_header": "En-tête LastModified :",
"page.edit_feed.last_parsing_error": "Dernière erreur d'analyse",
"page.edit_feed.no_header": "Aucune",
"page.edit_feed.title": "Modification de l'abonnement : %s",
"page.edit_user.title": "Modification de l'utilisateur : %s",
"page.entry.attachments": "Pièces Jointes",
"page.feeds.error_count": [
"%d erreur",
"%d erreurs"
],
"page.feeds.last_check": "Dernière vérification :",
"page.feeds.next_check": "Prochaine vérification :",
"page.feeds.read_counter": "Nombre d'entrées lues",
"page.feeds.title": "Abonnements",
"page.footer.elevator": "Retour en haut",
"page.history.title": "Historique",
"page.import.title": "Importation",
"page.integration.bookmarklet": "Signet (bookmarklet)",
"page.integration.bookmarklet.help": "Ce lien spécial vous permet de vous abonner à un site web directement en utilisant un marque page dans votre navigateur web.",
"page.integration.bookmarklet.instructions": "Glisser-déposer ce lien dans vos favoris.",
"page.integration.bookmarklet.name": "Ajouter à Miniflux",
"page.integration.miniflux_api": "API de Miniflux",
"page.integration.miniflux_api_endpoint": "Point de terminaison de l'API",
"page.integration.miniflux_api_password": "Mot de passe",
"page.integration.miniflux_api_password_value": "Le mot de passe de votre compte",
"page.integration.miniflux_api_username": "Nom d'utilisateur",
"page.integrations.title": "Intégrations",
"page.keyboard_shortcuts.close_modal": "Fermer la boite de dialogue",
"page.keyboard_shortcuts.download_content": "Télécharger le contenu original",
"page.keyboard_shortcuts.go_to_bottom_item": "Aller à l'élément du bas",
"page.keyboard_shortcuts.go_to_categories": "Voir les catégories",
"page.keyboard_shortcuts.go_to_feed": "Voir abonnement",
"page.keyboard_shortcuts.go_to_feeds": "Voir les abonnements",
"page.keyboard_shortcuts.go_to_history": "Voir l'historique",
"page.keyboard_shortcuts.go_to_next_item": "Élément suivant",
"page.keyboard_shortcuts.go_to_next_page": "Page suivante",
"page.keyboard_shortcuts.go_to_previous_item": "Élément précédent",
"page.keyboard_shortcuts.go_to_previous_page": "Page précédente",
"page.keyboard_shortcuts.go_to_search": "Mettre le focus sur le champ de recherche",
"page.keyboard_shortcuts.go_to_settings": "Voir les réglages",
"page.keyboard_shortcuts.go_to_starred": "Voir les favoris",
"page.keyboard_shortcuts.go_to_top_item": "Aller à l'élément supérieur",
"page.keyboard_shortcuts.go_to_unread": "Aller aux éléments non lus",
"page.keyboard_shortcuts.mark_page_as_read": "Marquer la page actuelle comme lu",
"page.keyboard_shortcuts.open_comments": "Ouvrir le lien des commentaires",
"page.keyboard_shortcuts.open_comments_same_window": "Ouvrir le lien des commentaires dans l'onglet en cours",
"page.keyboard_shortcuts.open_item": "Ouvrir élément sélectionné",
"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.refresh_all_feeds": "Actualiser les abonnements en arrière-plan",
"page.keyboard_shortcuts.remove_feed": "Supprimer ce flux",
"page.keyboard_shortcuts.save_article": "Sauvegarder l'article",
"page.keyboard_shortcuts.scroll_item_to_top": "Faire défiler l'élément vers le haut",
"page.keyboard_shortcuts.show_keyboard_shortcuts": "Voir les raccourcis clavier",
"page.keyboard_shortcuts.subtitle.actions": "Actions",
"page.keyboard_shortcuts.subtitle.items": "Navigation entre les éléments",
"page.keyboard_shortcuts.subtitle.pages": "Navigation entre les pages",
"page.keyboard_shortcuts.subtitle.sections": "Navigation entre les sections",
"page.keyboard_shortcuts.title": "Raccourcis clavier",
"page.keyboard_shortcuts.toggle_star_status": "Ajouter/Enlever favoris",
"page.keyboard_shortcuts.toggle_entry_attachments": "Ouvrir/Fermer les pièces jointes de l'entrée",
"page.keyboard_shortcuts.toggle_read_status_next": "Basculer entre lu/non lu, et changer le focus sur l'élément suivant",
"page.keyboard_shortcuts.toggle_read_status_prev": "Basculer entre lu/non lu, et changer le focus sur l'élément précédent",
"page.login.google_signin": "Se connecter avec Google",
"page.login.oidc_signin": "Se connecter avec %s",
"page.login.title": "Connexion",
"page.login.webauthn_login": "Se connecter avec une clé d’accès",
"page.login.webauthn_login.error": "Impossible de se connecter avec la clé d’accès",
"page.login.webauthn_login.help": "Veuillez saisir votre nom d'utilisateur si vous utilisez une clé de sécurité. Cela n'est pas nécessaire si vous utilisez une clé d'accès (Passkey).",
"page.new_api_key.title": "Nouvelle clé d'API",
"page.new_category.title": "Nouvelle catégorie",
"page.new_user.title": "Nouvel Utilisateur",
"page.offline.message": "Vous n'êtes pas connecté",
"page.offline.refresh_page": "Essayez de rafraîchir la page",
"page.offline.title": "Mode Hors-Ligne",
"page.read_entry_count": [
"%d entrée lue",
"%d entrées lues"
],
"page.search.title": "Résultats de la recherche",
"page.sessions.table.actions": "Actions",
"page.sessions.table.current_session": "Session actuelle",
"page.sessions.table.date": "Date",
"page.sessions.table.ip": "Adresse IP",
"page.sessions.table.user_agent": "Navigateur Web",
"page.sessions.title": "Sessions",
"page.settings.link_google_account": "Associer mon compte Google",
"page.settings.link_oidc_account": "Associer mon compte %s",
"page.settings.title": "Réglages",
"page.settings.unlink_google_account": "Dissocier mon compte Google",
"page.settings.unlink_oidc_account": "Dissocier mon compte %s",
"page.settings.webauthn.actions": "Actions",
"page.settings.webauthn.added_on": "Date de création",
"page.settings.webauthn.delete": [
"Supprimer %d clé d’accès",
"Supprimer %d clés d’accès"
],
"page.settings.webauthn.last_seen_on": "Dernière utilisation",
"page.settings.webauthn.passkey_name": "Nom de la clé d’accès",
"page.settings.webauthn.passkeys": "Clés d’accès",
"page.settings.webauthn.register": "Enregistrer une nouvelle clé d’accès",
"page.settings.webauthn.register.error": "Impossible d'enregistrer la clé d’accès",
"page.shared_entries.title": "Articles partagés",
"page.shared_entries_count": [
"%d article partagé",
"%d articles partagés"
],
"page.starred.title": "Favoris",
"page.starred_entry_count": [
"%d favori",
"%d favoris"
],
"page.total_entry_count": [
"%d article au total",
"%d articles au total"
],
"page.unread.title": "Non lus",
"page.unread_entry_count": [
"%d article non lu",
"%d articles non lus"
],
"page.users.actions": "Actions",
"page.users.admin.no": "Non",
"page.users.admin.yes": "Oui",
"page.users.is_admin": "Administrateur",
"page.users.last_login": "Dernière connexion",
"page.users.never_logged": "Jamais",
"page.users.title": "Utilisateurs",
"page.users.username": "Nom d'utilisateur",
"page.webauthn_rename.title": "Renommer la clé d'accès",
"pagination.first": "Première page",
"pagination.last": "Dernière page",
"pagination.next": "Suivant",
"pagination.previous": "Précédent",
"search.label": "Recherche",
"search.placeholder": "Recherche...",
"search.submit": "Rechercher",
"skip_to_content": "Aller au contenu",
"time_elapsed.days": [
"il y a %d jour",
"il y a %d jours"
],
"time_elapsed.hours": [
"il y a %d heure",
"il y a %d heures"
],
"time_elapsed.minutes": [
"il y a %d minute",
"il y a %d minutes"
],
"time_elapsed.months": [
"il y a %d mois",
"il y a %d mois"
],
"time_elapsed.not_yet": "pas encore",
"time_elapsed.now": "à l'instant",
"time_elapsed.weeks": [
"il y a %d semaine",
"il y a %d semaines"
],
"time_elapsed.years": [
"il y a %d an",
"il y a %d ans"
],
"time_elapsed.yesterday": "hier",
"tooltip.keyboard_shortcuts": "Raccourci clavier : %s",
"tooltip.logged_user": "Connecté en tant que %s"
}
v2-2.2.16/internal/locale/translations/hi_IN.json 0000664 0000000 0000000 00000164544 15127074645 0021637 0 ustar 00root root 0000000 0000000 {
"action.cancel": "रद्द करें",
"action.download": "डाउनलोड",
"action.edit": "संपाद करे",
"action.home_screen": "होम स्क्रीन में शामिल करें",
"action.import": "आयात करे",
"action.login": "लॉग इन करें",
"action.or": "या",
"action.remove": "हटाएँ",
"action.remove_feed": "इस फ़ीड को हटाएँ",
"action.save": "सहेजें",
"action.subscribe": "सदस्यता लें",
"action.update": "नवीनीकरण करे",
"alert.account_linked": "आपका बाहरी खाता अब लिंक हो गया है!",
"alert.account_unlinked": "आपका बाहरी खाता अब अलग कर दिया गया है!",
"alert.background_feed_refresh": "सभी फ़ीड्स पृष्ठभूमि में ताज़ा की जा रही हैं। जब यह प्रक्रिया चल रही हो, तो आप मिनीफ्लक्स का उपयोग जारी रख सकते हैं।",
"alert.feed_error": "इस फ़ीड में एक समस्या है",
"alert.no_starred": "इस समय कोई बुकमार्क नहीं है",
"alert.no_category": "कोई श्रेणी नहीं है।",
"alert.no_category_entry": "इस श्रेणी में कोई विषय-वस्तु नहीं है।",
"alert.no_feed": "आपके पास कोई सदस्यता नहीं है।",
"alert.no_feed_entry": "इस फ़ीड के लिए कोई विषय-वस्तु नहीं है।",
"alert.no_feed_in_category": "इस श्रेणी के लिए कोई सदस्यता नहीं है।",
"alert.no_history": "इस समय कोई इतिहास नहीं है",
"alert.no_search_result": "इस खोज के लिए कोई परिणाम नहीं हैं।",
"alert.no_shared_entry": "कोई साझा प्रविष्टि नहीं है",
"alert.no_tag_entry": "इस टैग से मेल खाती कोई प्रविष्टियाँ नहीं हैं।",
"alert.no_unread_entry": "कोई अपठित वस्तुत नहीं है।",
"alert.no_user": "आप एकमात्र उपयोगकर्ता हैं।",
"alert.prefs_saved": "प्राथमिकताएं सहेजी गईं!",
"alert.too_many_feeds_refresh": [
"आपने बहुत अधिक फ़ीड ताज़ा करने की प्रक्रिया शुरू कर दी है। कृपया पुनः प्रयास करने से पहले %d मिनट प्रतीक्षा करें।",
"आपने बहुत अधिक फ़ीड ताज़ा करने की प्रक्रिया शुरू कर दी है। कृपया पुनः प्रयास करने से पहले %d मिनट प्रतीक्षा करें।"
],
"confirm.loading": " प्रगति में है ...",
"confirm.no": " नहीं",
"confirm.question": "मंजूर है?",
"confirm.question.refresh": "क्या आप बल द्वारा ताज़ा करना चाहते हैं?",
"confirm.yes": "हाँ",
"enclosure_media_controls.seek": "खोजें:",
"enclosure_media_controls.seek.title": "%s सेकंड खोजें",
"enclosure_media_controls.speed": "गति:",
"enclosure_media_controls.speed.faster": "तेज",
"enclosure_media_controls.speed.faster.title": "%sx गुना तेज",
"enclosure_media_controls.speed.reset": "रीसेट करें",
"enclosure_media_controls.speed.reset.title": "गति 1x पर रीसेट करें",
"enclosure_media_controls.speed.slower": "धीमा",
"enclosure_media_controls.speed.slower.title": "%sx गुना धीमा",
"entry.starred.toast.off": "तारांकित न करे",
"entry.starred.toast.on": "तारांकित",
"entry.starred.toggle.off": "सितारा हटा दो",
"entry.starred.toggle.on": "सितारा दे",
"entry.comments.label": "टिप्पणियाँ",
"entry.comments.title": "टिप्पणियाँ देखे",
"entry.estimated_reading_time": [
"पढ़ने मे %d मिनट मागेगा",
"पढ़ने मे %d मिनट मागेगा"
],
"entry.external_link.label": "बाहरी संपर्क",
"entry.save.completed": "कार्य समाप्त हुआ!",
"entry.save.label": "सहेजे",
"entry.save.title": "एस लेख को सहेजे",
"entry.save.toast.completed": "लेख को सहेज लिया",
"entry.scraper.completed": "कार्य समाप्त हुआ!",
"entry.scraper.label": "डाउनलोड",
"entry.scraper.title": "मूल विषयवस्तु लाए",
"entry.share.label": "साझा करें",
"entry.share.title": "विषयवस्तु साझा करें",
"entry.shared_entry.label": "साझा करें",
"entry.shared_entry.title": "सार्वजनिक लिंक खोले",
"entry.state.loading": "लोड हो रहा है...",
"entry.state.saving": "सहेजा जा रहा है...",
"entry.status.mark_as_read": "पढ़े हुए का चिह्न",
"entry.status.mark_as_unread": "अपठित के रूप में चिह्नित करें",
"entry.status.title": "प्रविष्टि स्थिति बदलें",
"entry.status.toast.read": "पढ़ा हुआ चिह्नित करे",
"entry.status.toast.unread": "अपठित के रूप में चिह्नित",
"entry.tags.label": "टैग:",
"entry.tags.more_tags_label": [
"%d और टैग दिखाएँ",
"%d और टैग दिखाएँ"
],
"entry.unshare.label": "न साझा कारें",
"error.api_key_already_exists": "यह एपीआई कुंजी पहले से मौजूद है।",
"error.bad_credentials": "अमान्य उपयोगकर्ता नाम या पासवर्ड।",
"error.category_already_exists": "यह श्रेणी पहले से मौजूद है।",
"error.category_not_found": "यह श्रेणी मौजूद नहीं है या इस उपयोगकर्ता से संबंधित नहीं है।",
"error.database_error": "डेटाबेस त्रुटि: %v।",
"error.different_passwords": "पासवर्ड एक जैसे नहीं हैं।",
"error.duplicate_fever_username": "पहले से ही समान फीवर उपयोगकर्ता नाम वाला कोई और है!",
"error.duplicate_googlereader_username": "समान गूगल रीडर उपयोगकर्ता नाम वाला कोई और पहले से मौजूद है!",
"error.duplicate_linked_account": "इस प्रदाता के साथ पहले से ही कोई व्यक्ति जुड़ा हुआ है!",
"error.duplicated_feed": "यह फ़ीड पहले से मौजूद है।",
"error.empty_file": "यह फ़ाइल खाली है।",
"error.entries_per_page_invalid": "प्रति पृष्ठ प्रविष्टियों की संख्या मान्य नहीं है।",
"error.feed_already_exists": "यह फ़ीड पहले से मौजूद है.",
"error.feed_category_not_found": "यह श्रेणी मौजूद नहीं है या इस उपयोगकर्ता से संबंधित नहीं है।",
"error.feed_format_not_detected": "फ़ीड प्रारूप का पता नहीं लगा सकते: %v।",
"error.feed_invalid_blocklist_rule": "ब्लॉक सूची नियम अमान्य है।",
"error.feed_invalid_keeplist_rule": "सूची रखें नियम अमान्य है।",
"error.feed_mandatory_fields": "URL और श्रेणी अनिवार्य हैं।",
"error.feed_not_found": "यह फ़ीड मौजूद नहीं है या इस उपयोगकर्ता से संबंधित नहीं है।",
"error.feed_title_not_empty": "फ़ीड शीर्षक खाली नहीं हो सकता.",
"error.feed_url_not_empty": "फ़ीड यूआरएल खाली नहीं हो सकता.",
"error.fields_mandatory": "सभी फील्ड अनिवार्य।",
"error.http_bad_gateway": "खराब गेटवे त्रुटि के कारण वेबसाइट फिलहाल उपलब्ध नहीं है। समस्या Miniflux की तरफ नहीं है। कृपया बाद में फिर से कोशिश करें।",
"error.http_body_read": "HTTP बॉडी पढ़ने में असमर्थ: %v।",
"error.http_client_error": "HTTP क्लाइंट त्रुटि: %v।",
"error.http_empty_response": "HTTP प्रतिक्रिया खाली है। शायद यह वेबसाइट बॉट सुरक्षा तंत्र का उपयोग कर रही है?",
"error.http_empty_response_body": "HTTP प्रतिक्रिया बॉडी खाली है।",
"error.http_forbidden": "इस वेबसाइट तक पहुंच वर्जित है। शायद इस वेबसाइट में बॉट सुरक्षा तंत्र है?",
"error.http_gateway_timeout": "गेटवे टाइमआउट त्रुटि के कारण वेबसाइट फिलहाल उपलब्ध नहीं है। समस्या मिनीफ्लक्स की तरफ नहीं है। कृपया बाद में पुनः प्रयास करें।",
"error.http_internal_server_error": "सर्वर त्रुटि के कारण वेबसाइट फिलहाल उपलब्ध नहीं है। समस्या मिनीफ्लक्स की तरफ नहीं है। कृपया बाद में पुनः प्रयास करें।",
"error.http_not_authorized": "इस वेबसाइट तक पहुंच अधिकृत नहीं है। यह गलत उपयोगकर्ता नाम या पासवर्ड हो सकता है।",
"error.http_resource_not_found": "अनुरोधित संसाधन नहीं मिला। कृपया यूआरएल सत्यापित करें।",
"error.http_response_too_large": "HTTP प्रतिक्रिया बहुत बड़ी है। आप वैश्विक सेटिंग्स में HTTP प्रतिक्रिया आकार सीमा बढ़ा सकते हैं (सर्वर पुनःआरंभ आवश्यक)।",
"error.http_service_unavailable": "आंतरिक सर्वर त्रुटि के कारण वेबसाइट फिलहाल उपलब्ध नहीं है। समस्या मिनीफ्लक्स की तरफ नहीं है। कृपया बाद में पुनः प्रयास करें।",
"error.http_too_many_requests": "मिनीफ्लक्स ने इस वेबसाइट पर बहुत अधिक अनुरोध भेजे हैं। कृपया बाद में पुनः प्रयास करें या एप्लिकेशन कॉन्फ़िगरेशन बदलें।",
"error.http_unexpected_status_code": "अप्रत्याशित HTTP स्थिति कोड %d के कारण वेबसाइट उपलब्ध नहीं है। समस्या मिनीफ्लक्स की तरफ नहीं है। कृपया बाद में पुनः प्रयास करें।",
"error.invalid_categories_sorting_order": "अमान्य श्रेणी क्रम।",
"error.invalid_default_home_page": "अमान्य डिफ़ॉल्ट मुखपृष्ठ!",
"error.invalid_display_mode": "अमान्य वेब ऐप्लिकेशन प्रदर्शन मोड.",
"error.invalid_entry_direction": "अमान्य प्रवेश दिशा।",
"error.invalid_entry_order": "अमान्य प्रविष्टि क्रम।",
"error.invalid_feed_proxy_url": "अमान्य प्रॉक्सी यूआरएल।",
"error.invalid_feed_url": "दृष्टिकोण यूआरएल.",
"error.invalid_gesture_nav": "अमान्य इशारा नेविगेशन।",
"error.invalid_language": "अमान्य भाषा.",
"error.invalid_site_url": "अमान्य साइट यूआरएल",
"error.invalid_theme": "अमान्य थीम.",
"error.invalid_timezone": "अमान्य समयक्षेत्र.",
"error.network_operation": "नेटवर्क त्रुटि के कारण मिनीफ्लक्स इस वेबसाइट तक नहीं पहुँच पा रहा: %v.",
"error.network_timeout": "यह वेबसाइट बहुत धीमी है और अनुरोध का समय समाप्त हो गया: %v",
"error.password_min_length": "पासवर्ड में कम से कम 6 अक्षर होने चाहिए।",
"error.proxy_url_not_empty": "प्रॉक्सी यूआरएल खाली नहीं हो सकता।",
"error.settings_block_rule_fieldname_invalid": "अमान्य ब्लॉक नियम: नियम #%d में मान्य फील्ड नाम नहीं है (विकल्प: %s)",
"error.settings_block_rule_invalid_regex": "अमान्य ब्लॉक नियम: नियम #%d का पैटर्न मान्य रेगेक्स नहीं है",
"error.settings_block_rule_regex_required": "अमान्य ब्लॉक नियम: नियम #%d का पैटर्न प्रदान नहीं किया गया",
"error.settings_block_rule_separator_required": "अमान्य ब्लॉक नियम: नियम #%d के पैटर्न को '=' द्वारा अलग होना आवश्यक है",
"error.settings_invalid_domain_list": "अमान्य डोमेन सूची। कृपया स्पेस से अलग किए गए डोमेन दें।",
"error.settings_keep_rule_fieldname_invalid": "अमान्य रखने का नियम: नियम #%d में मान्य फील्ड नाम नहीं है (विकल्प: %s)",
"error.settings_keep_rule_invalid_regex": "अमान्य रखने का नियम: नियम #%d का पैटर्न मान्य रेगेक्स नहीं है",
"error.settings_keep_rule_regex_required": "अमान्य रखने का नियम: नियम #%d का पैटर्न नहीं दिया गया",
"error.settings_keep_rule_separator_required": "अमान्य रखने का नियम: नियम #%d के पैटर्न को '=' से अलग किया जाना आवश्यक है",
"error.settings_mandatory_fields": "उपयोगकर्ता नाम, विषयवस्तु, भाषा और समयक्षेत्र फ़ील्ड अनिवार्य हैं।",
"error.settings_media_playback_rate_range": "प्लेबैक गति सीमा से बाहर है",
"error.settings_reading_speed_is_positive": "पढ़ने की गति सकारात्मक पूर्णांक होनी चाहिए।",
"error.site_url_not_empty": "साइट का यूआरएल खाली नहीं हो सकता.",
"error.subscription_not_found": "कोई सदस्यता ढूँढने में असमर्थ.",
"error.title_required": "शीर्षक अनिवार्य है।",
"error.tls_error": "TLS त्रुटि: %q. यदि आप चाहें तो फ़ीड सेटिंग्स में TLS सत्यापन अक्षम कर सकते हैं।",
"error.unable_to_create_api_key": "यह एपीआई कुंजी बनाने में असमर्थ।",
"error.unable_to_create_category": "यह श्रेणी बनाने में असमर्थ.",
"error.unable_to_create_user": "इस उपयोगकर्ता को बनाने में असमर्थ।",
"error.unable_to_detect_rssbridge": "RSS-Bridge का उपयोग करके फ़ीड का पता लगाने में असमर्थ: %v.",
"error.unable_to_parse_feed": "इस फ़ीड को पार्स करने में असमर्थ: %v.",
"error.unable_to_update_category": "इस श्रेणी को अपडेट करने में असमर्थ।",
"error.unable_to_update_feed": "इस फ़ीड को अपडेट करने में असमर्थ.",
"error.unable_to_update_user": "इस उपयोगकर्ता को अपडेट करने में असमर्थ.",
"error.unlink_account_without_password": "आपको एक पासवर्ड परिभाषित करना होगा अन्यथा आप फिर से लॉगिन नहीं कर पाएंगे।",
"error.user_already_exists": "यह उपयोगकर्ता पहले से ही मौजूद है।",
"error.user_mandatory_fields": "उपयोगकर्ता नाम अनिवार्य है।",
"error.linktaco_missing_required_fields": "LinkTaco API Token और Organization Slug आवश्यक हैं",
"form.api_key.label.description": "एपीआई कुंजी लेबल",
"form.category.hide_globally": "वैश्विक अपठित सूची में प्रविष्टियां छिपाएं",
"form.category.label.title": "शीर्षक",
"form.feed.fieldset.general": "सामान्य",
"form.feed.fieldset.integration": "तृतीय-पक्ष सेवाएँ",
"form.feed.fieldset.network_settings": "नेटवर्क सेटिंग्स",
"form.feed.fieldset.rules": "नियम",
"form.feed.label.allow_self_signed_certificates": "स्व-हस्ताक्षरित या अमान्य प्रमाणपत्रों की अनुमति दें",
"form.feed.label.apprise_service_urls": "Apprise सेवा URL की कॉमा से अलग सूची",
"form.feed.label.block_filter_entry_rules": "प्रविष्टि अवरोधन नियम",
"form.feed.label.blocklist_rules": "रेगेक्स-आधारित अवरोधन फिल्टर",
"form.feed.label.category": "श्रेणी",
"form.feed.label.cookie": "कुकीज़ सेट करें",
"form.feed.label.crawler": "मूल सामग्री प्राप्त करें",
"form.feed.label.description": "विवरण",
"form.feed.label.disable_http2": "फिंगरप्रिंटिंग से बचने के लिए HTTP/2 अक्षम करें",
"form.feed.label.disabled": "इस फ़ीड को रीफ़्रेश न करें",
"form.feed.label.feed_password": "फ़ीड पासवर्ड",
"form.feed.label.feed_url": "फ़ीड यूआरएल",
"form.feed.label.feed_username": "फ़ीड उपयोगकर्ता नाम",
"form.feed.label.fetch_via_proxy": "एप्लिकेशन स्तर पर कॉन्फ़िगर किए गए प्रॉक्सी का उपयोग करें",
"form.feed.label.hide_globally": "वैश्विक अपठित सूची में प्रविष्टियां छिपाएं",
"form.feed.label.ignore_http_cache": "एचटीटीपी कैश पर ध्यान न दें",
"form.feed.label.keep_filter_entry_rules": "प्रविष्टि अनुमति नियम",
"form.feed.label.keeplist_rules": "रेगेक्स-आधारित रखने वाले फिल्टर",
"form.feed.label.no_media_player": "कोई मीडिया प्लेयर नहीं (ऑडियो/वीडियो)",
"form.feed.label.ntfy_activate": "प्रविष्टियाँ ntfy पर भेजें",
"form.feed.label.ntfy_default_priority": "Ntfy डिफ़ॉल्ट प्राथमिकता",
"form.feed.label.ntfy_high_priority": "Ntfy उच्च प्राथमिकता",
"form.feed.label.ntfy_low_priority": "Ntfy निम्न प्राथमिकता",
"form.feed.label.ntfy_max_priority": "Ntfy अधिकतम प्राथमिकता",
"form.feed.label.ntfy_min_priority": "Ntfy न्यूनतम प्राथमिकता",
"form.feed.label.ntfy_priority": "Ntfy प्राथमिकता",
"form.feed.label.ntfy_topic": "Ntfy विषय (वैकल्पिक)",
"form.feed.label.proxy_url": "प्रॉक्सी URL",
"form.feed.label.pushover_activate": "प्रविष्टियाँ pushover.net पर भेजें",
"form.feed.label.pushover_default_priority": "Pushover डिफ़ॉल्ट प्राथमिकता",
"form.feed.label.pushover_high_priority": "Pushover उच्च प्राथमिकता",
"form.feed.label.pushover_low_priority": "Pushover निम्न प्राथमिकता",
"form.feed.label.pushover_max_priority": "Pushover अधिकतम प्राथमिकता",
"form.feed.label.pushover_min_priority": "Pushover न्यूनतम प्राथमिकता",
"form.feed.label.pushover_priority": "Pushover संदेश प्राथमिकता",
"form.feed.label.rewrite_rules": "सामग्री पुनर्लेखन नियम",
"form.feed.label.scraper_rules": "खुरचनी नियम",
"form.feed.label.site_url": "साइट यूआरएल",
"form.feed.label.title": "शीर्षक",
"form.feed.label.urlrewrite_rules": " यूआरएल पुनर्लेखन नियम",
"form.feed.label.user_agent": "डिफ़ॉल्ट उपयोगकर्ता एजेंट को ओवरराइड करें",
"form.feed.label.webhook_url": "वेबहुक URL को अधिलेखित करें",
"form.import.label.file": "ओपीएमएल फ़ाइल",
"form.import.label.url": "यूआरएल",
"form.integration.archiveorg_activate": "प्रविष्टियों को archive.org पर भेजें",
"form.integration.apprise_activate": "प्रविष्टियाँ Apprise पर भेजें",
"form.integration.apprise_services_url": "Apprise सेवा URLs की कॉमा से पृथक सूची",
"form.integration.apprise_url": "Apprise API यूआरएल",
"form.integration.betula_activate": "प्रविष्टियाँ Betula में सहेजें",
"form.integration.betula_token": "Betula टोकन",
"form.integration.betula_url": "Betula सर्वर URL",
"form.integration.cubox_activate": "प्रविष्टियाँ Cubox में सहेजें",
"form.integration.cubox_api_link": "Cubox API लिंक",
"form.integration.discord_activate": "प्रविष्टियाँ Discord पर भेजें",
"form.integration.discord_webhook_link": "Discord वेबहुक लिंक",
"form.integration.espial_activate": "विषय-वस्तु को जासूसी में सहेजें",
"form.integration.espial_api_key": "जासूसी एपीआई कुंजी",
"form.integration.espial_endpoint": "जासूसी एपीआई समापन बिंदु",
"form.integration.espial_tags": "जासूसी टैग",
"form.integration.fever_activate": "फीवर एपीआई सक्रिय करें",
"form.integration.fever_endpoint": "फीवर एपीआई समापन बिंदु:",
"form.integration.fever_password": "फीवर पासवर्ड",
"form.integration.fever_username": "फीवर उपयोगकर्ता नाम",
"form.integration.googlereader_activate": "गूगल रीडर एपीआई सक्रिय करें",
"form.integration.googlereader_endpoint": "गूगल रीडर एपीआई समापन बिंदु:",
"form.integration.googlereader_password": "गूगल रीडर पासवर्ड",
"form.integration.googlereader_username": "गूगल रीडर उपयोगकर्ता नाम",
"form.integration.instapaper_activate": "विषय-वस्तु को इंस्टापेपर में सहेजें",
"form.integration.instapaper_password": "इंस्टापेपर पासवर्ड",
"form.integration.instapaper_username": "इंस्टापेपर यूजरनेम",
"form.integration.karakeep_activate": "प्रविष्टियाँ Karakeep में सहेजें",
"form.integration.karakeep_api_key": "Karakeep API कुंजी",
"form.integration.karakeep_url": "Karakeep API समापन बिंदु",
"form.integration.karakeep_tags": "Karakeep लेबल",
"form.integration.linkace_activate": "प्रविष्टियाँ LinkAce में सहेजें",
"form.integration.linkace_api_key": "LinkAce API कुंजी",
"form.integration.linkace_check_disabled": "लिंक जांच अक्षम करें",
"form.integration.linkace_endpoint": "LinkAce API समापन बिंदु",
"form.integration.linkace_is_private": "लिंक को निजी चिह्नित करें",
"form.integration.linkace_tags": "LinkAce टैग",
"form.integration.linkding_activate": "लिंक्डिन में विषयवस्तु सहेजें",
"form.integration.linkding_api_key": "लिंकिंग एपीआई कुंजी",
"form.integration.linkding_bookmark": "बुकमार्क को अपठित के रूप में चिह्नित करें",
"form.integration.linkding_endpoint": "लिंकिंग एपीआई समापन बिंदु",
"form.integration.linkding_tags": "Linkding टैग",
"form.integration.linktaco_activate": "LinkTaco में प्रविष्टियाँ सहेजें",
"form.integration.linktaco_api_token": "LinkTaco API टोकन",
"form.integration.linktaco_api_token_hint": "अपना व्यक्तिगत पहुँच टोकन प्राप्त करें",
"form.integration.linktaco_org_slug": "संगठन स्लग",
"form.integration.linktaco_tags": "टैग (अधिकतम 10, कॉमा से अलग किए गए)",
"form.integration.linktaco_tags_hint": "अधिकतम 10 टैग, कॉमा से अलग किए गए",
"form.integration.linktaco_visibility": "दृश्यता",
"form.integration.linktaco_visibility_public": "सार्वजनिक",
"form.integration.linktaco_visibility_private": "निजी",
"form.integration.linktaco_visibility_hint": "निजी दृश्यता के लिए भुगतान LinkTaco खाता आवश्यक है",
"form.integration.linkwarden_activate": "प्रविष्टियाँ Linkwarden में सहेजें",
"form.integration.linkwarden_api_key": "Linkwarden API कुंजी",
"form.integration.linkwarden_endpoint": "Linkwarden बेस URL",
"form.integration.linkwarden_collection_id": "Linkwarden संग्रह ID",
"form.integration.matrix_bot_activate": "नए लेखों को मैट्रिक्स में स्थानांतरित करें",
"form.integration.matrix_bot_chat_id": "मैट्रिक्स रूम की आईडी",
"form.integration.matrix_bot_password": "मैट्रिक्स उपयोगकर्ता के लिए पासवर्ड",
"form.integration.matrix_bot_url": "मैट्रिक्स सर्वर URL",
"form.integration.matrix_bot_user": "मैट्रिक्स के लिए उपयोगकर्ता नाम",
"form.integration.notion_activate": "प्रविष्टियाँ Notion में सहेजें",
"form.integration.notion_page_id": "Notion पेज ID",
"form.integration.notion_token": "Notion गुप्त टोकन",
"form.integration.ntfy_activate": "प्रविष्टियाँ ntfy पर भेजें",
"form.integration.ntfy_api_token": "Ntfy API टोकन (वैकल्पिक)",
"form.integration.ntfy_icon_url": "Ntfy आइकन URL (वैकल्पिक)",
"form.integration.ntfy_internal_links": "क्लिक पर आंतरिक लिंक इस्तेमाल करें (वैकल्पिक)",
"form.integration.ntfy_password": "Ntfy पासवर्ड (वैकल्पिक)",
"form.integration.ntfy_topic": "Ntfy विषय (यदि फ़ीड में नहीं सेट है तो डिफ़ॉल्ट)",
"form.integration.ntfy_url": "Ntfy URL (वैकल्पिक, डिफ़ॉल्ट ntfy.sh)",
"form.integration.ntfy_username": "Ntfy उपयोगकर्ता नाम (वैकल्पिक)",
"form.integration.nunux_keeper_activate": "विषय-वस्तु को ननक्स कीपर में सहेजें",
"form.integration.nunux_keeper_api_key": "ननक्स कीपर एपीआई कुंजी",
"form.integration.nunux_keeper_endpoint": "ननक्स कीपर एपीआई समापन बिंदु",
"form.integration.omnivore_activate": "प्रविष्टियाँ Omnivore में सहेजें",
"form.integration.omnivore_api_key": "Omnivore API कुंजी",
"form.integration.omnivore_url": "Omnivore API समापन बिंदु",
"form.integration.pinboard_activate": "सहेजें विषयवस्तु प्रति का बोर्ड ",
"form.integration.pinboard_bookmark": "बुकमार्क को अपठित के रूप में चिह्नित करें",
"form.integration.pinboard_tags": "पिनबोर्ड टैग",
"form.integration.pinboard_token": "पिनबोर्ड एपीआई टोकन",
"form.integration.pushover_activate": "प्रविष्टियाँ Pushover पर भेजें",
"form.integration.pushover_device": "Pushover डिवाइस (वैकल्पिक)",
"form.integration.pushover_prefix": "Pushover URL उपसर्ग (वैकल्पिक)",
"form.integration.pushover_token": "Pushover ऐप्लिकेशन API टोकन",
"form.integration.pushover_user": "Pushover उपयोगकर्ता कुंजी",
"form.integration.raindrop_activate": "प्रविष्टियाँ Raindrop में सहेजें",
"form.integration.raindrop_collection_id": "संग्रह ID",
"form.integration.raindrop_tags": "टैग (कॉमा से अलग)",
"form.integration.raindrop_token": "(टेस्ट) टोकन",
"form.integration.readeck_activate": "Readeck में विषयवस्तु सहेजें",
"form.integration.readeck_api_key": "Readeck एपीआई कुंजी",
"form.integration.readeck_endpoint": "Readeck यूआरएल",
"form.integration.readeck_labels": "Readeck लेबल",
"form.integration.readeck_only_url": "केवल URL भेजें (पूर्ण सामग्री के बजाय)",
"form.integration.readeck_push_activate": "नई प्रविष्टियाँ स्वतः Readeck पर भेजें",
"form.integration.readwise_activate": "प्रविष्टियाँ Readwise Reader में सहेजें",
"form.integration.readwise_api_key": "Readwise Reader एक्सेस टोकन",
"form.integration.readwise_api_key_link": "अपना Readwise एक्सेस टोकन प्राप्त करें",
"form.integration.rssbridge_activate": "सदस्यता जोड़ते समय RSS-Bridge जांचें",
"form.integration.rssbridge_token": "RSS-Bridge प्रमाणीकरण टोकन",
"form.integration.rssbridge_url": "RSS-Bridge सर्वर URL",
"form.integration.shaarli_activate": "लेखों को Shaarli में सहेजें",
"form.integration.shaarli_api_secret": "Shaarli API रहस्य",
"form.integration.shaarli_endpoint": "Shaarli यूआरएल",
"form.integration.shiori_activate": "लेखों को Shiori में सहेजें",
"form.integration.shiori_endpoint": "Shiori API समापन बिंदु",
"form.integration.shiori_password": "Shiori पासवर्ड",
"form.integration.shiori_username": "Shiori उपयोगकर्ता नाम",
"form.integration.slack_activate": "प्रविष्टियाँ Slack पर भेजें",
"form.integration.slack_webhook_link": "Slack वेबहुक लिंक",
"form.integration.telegram_bot_activate": "टेलीग्राम चैट के लिए नई विषय-कविता पुश करें",
"form.integration.telegram_bot_disable_buttons": "बटन अक्षम करें",
"form.integration.telegram_bot_disable_notification": "सूचनाएँ अक्षम करें",
"form.integration.telegram_bot_disable_web_page_preview": "वेब पृष्ठ पूर्वावलोकन अक्षम करें",
"form.integration.telegram_bot_token": "बॉट टोकन",
"form.integration.telegram_chat_id": "चैट आईडी",
"form.integration.telegram_topic_id": "टॉपिक ID",
"form.integration.wallabag_activate": "विषय सहेजें वालाबाग में ",
"form.integration.wallabag_client_id": "वालाबैग क्लाइंट आईडी",
"form.integration.wallabag_client_secret": "वालाबैग क्लाइंट सीक्रेट",
"form.integration.wallabag_endpoint": "वल्लाबैग बेस यूआरएल",
"form.integration.wallabag_only_url": "केवल URL भेजें (पूर्ण सामग्री के बजाय)",
"form.integration.wallabag_password": "वालाबैग पासवर्ड",
"form.integration.wallabag_username": "वालाबैग उपयोगकर्ता नाम",
"form.integration.wallabag_tags": "Wallabag टैग",
"form.integration.webhook_activate": "वेबहुक सक्षम करें",
"form.integration.webhook_secret": "वेबहुक रहस्य",
"form.integration.webhook_url": "डिफ़ॉल्ट वेबहुक URL",
"form.prefs.fieldset.application_settings": "एप्लिकेशन सेटिंग्स",
"form.prefs.fieldset.authentication_settings": "प्रमाणीकरण सेटिंग्स",
"form.prefs.fieldset.global_feed_settings": "वैश्विक फ़ीड सेटिंग्स",
"form.prefs.fieldset.reader_settings": "रीडर सेटिंग्स",
"form.prefs.help.external_font_hosts": "अनुमति प्राप्त बाहरी फ़ॉन्ट होस्ट की सूची (स्पेस से पृथक). उदाहरण: \"fonts.gstatic.com fonts.googleapis.com\".",
"form.prefs.label.always_open_external_links": "बाहरी लिंक खोलकर लेख पढ़ें",
"form.prefs.label.categories_sorting_order": "श्रेणियाँ छँटाई",
"form.prefs.label.cjk_reading_speed": "चीनी, कोरियाई और जापानी के लिए पढ़ने की गति (प्रति मिनट वर्ण)",
"form.prefs.label.custom_css": "कस्टम सीएसएस",
"form.prefs.label.custom_js": "कस्टम जेएस",
"form.prefs.label.default_home_page": "डिफ़ॉल्ट होमपेज़",
"form.prefs.label.default_reading_speed": "अन्य भाषाओं के लिए पढ़ने की गति (प्रति मिनट शब्द)",
"form.prefs.label.display_mode": "प्रोग्रेसिव वेब ऐप (PWA) डिस्प्ले मोड",
"form.prefs.label.entries_per_page": "प्रति पृष्ठ प्रविष्टियाँ",
"form.prefs.label.entry_order": "प्रवेश छँटाई कॉलम",
"form.prefs.label.entry_sorting": "प्रवेश छँटाई",
"form.prefs.label.entry_swipe": "टच स्क्रीन पर एंट्री स्वाइप सक्षम करें",
"form.prefs.label.external_font_hosts": "बाहरी फ़ॉन्ट होस्ट",
"form.prefs.label.gesture_nav": "प्रविष्टियों के बीच नेविगेट करने के लिए इशारा",
"form.prefs.label.keyboard_shortcuts": "कीबोर्ड शॉर्टकट सक्षम करें",
"form.prefs.label.language": "भाषाओं",
"form.prefs.label.mark_read_manually": "प्रविष्टियों को मैन्युअल रूप से पढ़ा हुआ चिह्नित करें",
"form.prefs.label.mark_read_on_media_completion": "केवल तब पढ़ा हुआ चिह्नित करें जब ऑडियो/वीडियो का 90%% चल चुका हो",
"form.prefs.label.mark_read_on_view": "देखे जाने पर स्वचालित रूप से प्रविष्टियों को पढ़ने के रूप में चिह्नित करें",
"form.prefs.label.mark_read_on_view_or_media_completion": "देखने पर पढ़ा हुआ चिह्नित करें; ऑडियो/वीडियो 90%% पर पढ़ा हुआ करें",
"form.prefs.label.media_playback_rate": "ऑडियो/वीडियो की प्लेबैक गति",
"form.prefs.label.open_external_links_in_new_tab": "बाहरी लिंक को एक नए टैब में खोलें (लिंक में target=\"_blank\" जोड़ता है)",
"form.prefs.label.show_reading_time": "विषय के लिए अनुमानित पढ़ने का समय दिखाएं",
"form.prefs.label.theme": "थीम",
"form.prefs.label.timezone": "समय क्षेत्र",
"form.prefs.select.alphabetical": "वर्णक्रम",
"form.prefs.select.browser": "ब्राउज़र",
"form.prefs.select.created_time": "प्रवेश बनाया समय",
"form.prefs.select.fullscreen": "पूर्ण स्क्रीन",
"form.prefs.select.minimal_ui": "कम से कम",
"form.prefs.select.none": "कोई नहीं",
"form.prefs.select.older_first": "पहले पुरानी प्रविष्टियाँ",
"form.prefs.select.publish_time": "प्रवेश प्रकाशित समय",
"form.prefs.select.recent_first": "हाल की प्रविष्टियाँ पहले",
"form.prefs.select.standalone": "स्टैंडअलोन",
"form.prefs.select.swipe": "कड़ी चोट",
"form.prefs.select.tap": "दो बार टैप",
"form.prefs.select.unread_count": "अपठित गणना",
"form.submit.loading": "लोड हो रहा है...",
"form.submit.saving": "सहेजा जा रहा है...",
"form.user.label.admin": "प्रशासक",
"form.user.label.confirmation": "पासवर्ड पुष्टि",
"form.user.label.password": "पासवर्ड",
"form.user.label.username": "उपयोगकर्ता नाम",
"menu.about": "के बारे में",
"menu.add_feed": "सदस्यता जोरीय",
"menu.add_user": "उपयोगकर्ता जोड़ें",
"menu.api_keys": "एपीआई कुंजी",
"menu.categories": "श्रेणियाँ",
"menu.create_api_key": "नई एपीआई कुंजी बनाएं",
"menu.create_category": "श्रेणी बनाए",
"menu.edit_category": "श्रेणी संपाद करे",
"menu.edit_feed": "फ़ीड संपाद करे",
"menu.export": "निर्यात करे",
"menu.feed_entries": "प्रविष्टियाँ",
"menu.feeds": "फ़ीड",
"menu.flush_history": "इतिहास मिटाएँ",
"menu.history": "इतिहास",
"menu.home_page": "मुखपृष्ठ",
"menu.import": "आयात करे",
"menu.integrations": "एकीकरण",
"menu.logout": "लॉग आउट",
"menu.mark_all_as_read": "सभी को पढ़ा हुआ मार्क करें",
"menu.mark_page_as_read": "इस पृष्ठ को पढ़ा हुआ चिह्नित करें",
"menu.preferences": "पसंद",
"menu.refresh_all_feeds": "पृष्ठभूमि में सभी फ़ीड को ताज़ा करें",
"menu.refresh_feed": "ताज़ा करें",
"menu.search": "खोज",
"menu.sessions": "सत्र",
"menu.settings": "समायोजन",
"menu.shared_entries": "साझा प्रविष्टियां",
"menu.show_all_entries": "सभी प्रविष्टियाँ दिखाए",
"menu.show_only_starred_entries": "केवल पसंदीदा प्रविष्टियाँ दिखाएं",
"menu.show_only_unread_entries": "सभी अपठित प्रविष्टियाँ दिखाए",
"menu.starred": "तारांकित",
"menu.title": "मेनू",
"menu.unread": "अपठित",
"menu.users": "उपयोगकर्ताओं",
"page.about.author": "रचयिता:",
"page.about.build_date": "बनाने की तिथि:",
"page.about.credits": "आभार सूची",
"page.about.db_usage": "डेटाबेस आकार:",
"page.about.git_commit": "Git कमिट:",
"page.about.global_config_options": "वैश्विक विन्यास विकल्प",
"page.about.go_version": "गो संस्करण:",
"page.about.license": "अनुज्ञा:",
"page.about.postgres_version": "पोस्तग्राइस संस्करण:",
"page.about.title": "पृष्ठ के बारे में",
"page.about.version": "संस्करण:",
"page.add_feed.choose_feed": "एक सदस्यता का चयन करे",
"page.add_feed.label.url": "यूआरएल",
"page.add_feed.legend.advanced_options": "उन्नत विकल्प",
"page.add_feed.no_category": "कोई श्रेणी नहीं है। एक श्रेणी अव्यशाक है।",
"page.add_feed.submit": "सदस्यता खोजे",
"page.add_feed.title": "नया सदस्यता",
"page.api_keys.never_used": "कभी प्रयोग नहीं हुआ",
"page.api_keys.table.actions": "कार्रवाई",
"page.api_keys.table.created_at": "निर्माण तिथि",
"page.api_keys.table.description": "विवरण",
"page.api_keys.table.last_used_at": "आखरी इस्त्तमाल किया गया",
"page.api_keys.table.token": "टोकन",
"page.api_keys.title": "एपीआई कुंजी",
"page.categories.entries": "विषयवस्तुया",
"page.categories.feed_count": [
"%d फ़ीड बाकी है।",
"%d फ़ीड बाकी है।"
],
"page.categories.feeds": "सदस्यता ले",
"page.categories.no_feed": "कोई फ़ीड नहीं है।",
"page.categories.title": "श्रेणियाँ",
"page.categories_count": [
"%d श्रेणी",
"%d श्रेणियाँ"
],
"page.category_label": "श्रेणी: %s",
"page.edit_category.title": "%s श्रेणी संपाद करे",
"page.edit_feed.etag_header": "ईटाग हैडर:",
"page.edit_feed.last_check": "अंतिम जांच:",
"page.edit_feed.last_modified_header": "अंतिम बार संशोधित हैडर:",
"page.edit_feed.last_parsing_error": "अंतिम पार्सिंग त्रुटि",
"page.edit_feed.no_header": "कोई भी नहीं",
"page.edit_feed.title": "%s फ़ीड संपाद करे",
"page.edit_user.title": "%s उपभोक्ता संपाद करे",
"page.entry.attachments": "संलग्नक",
"page.feeds.error_count": [
"%d समस्या",
"%d समस्याए"
],
"page.feeds.last_check": "आखरी जाँच",
"page.feeds.next_check": "अगली जाँच:",
"page.feeds.read_counter": "पड़े हुए विषयवस्तुया",
"page.feeds.title": "फ़ीड",
"page.footer.elevator": "ऊपर जाएँ",
"page.history.title": "इतिहास",
"page.import.title": "आयात",
"page.integration.bookmarklet": "बुकमार्कलेट",
"page.integration.bookmarklet.help": "यह विशेष लिंक आपको अपने वेब ब्राउज़र में बुकमार्क का उपयोग करके सीधे वेबसाइट की सदस्यता लेने की अनुमति देता है।",
"page.integration.bookmarklet.instructions": "इस लिंक को खींचकर अपने बुकमार्क पर छोड़ दें।",
"page.integration.bookmarklet.name": "मिनीफ्लक्स में जोड़ें",
"page.integration.miniflux_api": "मिनिफलक्ष एपीआई",
"page.integration.miniflux_api_endpoint": "एपीआई समापन बिंदु",
"page.integration.miniflux_api_password": "पासवर्ड",
"page.integration.miniflux_api_password_value": "आपका खाता पासवर्ड",
"page.integration.miniflux_api_username": "यूसर्नेम",
"page.integrations.title": "एकीकरण",
"page.keyboard_shortcuts.close_modal": "मोडल डायलॉग बंद करें",
"page.keyboard_shortcuts.download_content": "मूल सामग्री डाउनलोड करें",
"page.keyboard_shortcuts.go_to_bottom_item": "निचले आइटम पर जाएँ",
"page.keyboard_shortcuts.go_to_categories": "श्रेणि पर जाएं",
"page.keyboard_shortcuts.go_to_feed": "फ़ीड पर जाएं",
"page.keyboard_shortcuts.go_to_feeds": "फ़ीड पर जाएं",
"page.keyboard_shortcuts.go_to_history": "इतिहास पर जाएं",
"page.keyboard_shortcuts.go_to_next_item": "अगले आइटम पर जाएं",
"page.keyboard_shortcuts.go_to_next_page": "अगले पेज पर जाएं",
"page.keyboard_shortcuts.go_to_previous_item": "पिछले आइटम पर जाएं",
"page.keyboard_shortcuts.go_to_previous_page": "पिछले पृष्ठ पर जाएं",
"page.keyboard_shortcuts.go_to_search": "सर्च फॉर्म पर फोकस सेट करें",
"page.keyboard_shortcuts.go_to_settings": "सेटिंग्स में जाओ",
"page.keyboard_shortcuts.go_to_starred": "बुकमार्क पर जाएं",
"page.keyboard_shortcuts.go_to_top_item": "शीर्ष आइटम पर जाएँ",
"page.keyboard_shortcuts.go_to_unread": "अपठित पर जाएं",
"page.keyboard_shortcuts.mark_page_as_read": "मौजूदा पेज को पढ़ा हुआ चिह्नित करें",
"page.keyboard_shortcuts.open_comments": "टिप्पणी लिंक खोलें",
"page.keyboard_shortcuts.open_comments_same_window": "मौजूदा टैब में टिप्पणी लिंक खोलें",
"page.keyboard_shortcuts.open_item": "चयनित आइटम खोलें",
"page.keyboard_shortcuts.open_original": "मूल लिंक खोलें",
"page.keyboard_shortcuts.open_original_same_window": "वर्तमान टैब में मूल लिंक खोलें",
"page.keyboard_shortcuts.refresh_all_feeds": "बैकग्राउंड में सभी फ़ीड्स रीफ़्रेश करें",
"page.keyboard_shortcuts.remove_feed": "यह फ़ीड हटाएं",
"page.keyboard_shortcuts.save_article": "विषयवस्तु सहेजें",
"page.keyboard_shortcuts.scroll_item_to_top": "आइटम को ऊपर तक स्क्रॉल करें",
"page.keyboard_shortcuts.show_keyboard_shortcuts": "कीबोर्ड शॉर्टकट दिखाएं",
"page.keyboard_shortcuts.subtitle.actions": "कार्रवाई",
"page.keyboard_shortcuts.subtitle.items": "आइटम नेविगेशन",
"page.keyboard_shortcuts.subtitle.pages": "पेज नेविगेशन",
"page.keyboard_shortcuts.subtitle.sections": "अनुभाग नेविगेशन",
"page.keyboard_shortcuts.title": "कुंजीपटल अल्प मार्ग",
"page.keyboard_shortcuts.toggle_star_status": "बुकमार्क टॉगल करें",
"page.keyboard_shortcuts.toggle_entry_attachments": "प्रविष्टि संलग्नक खोलें/बंद करें",
"page.keyboard_shortcuts.toggle_read_status_next": "पढ़ें/अपठित टॉगल करें, अगला फ़ोकस करें",
"page.keyboard_shortcuts.toggle_read_status_prev": "पढ़ें/अपठित टॉगल करें, पिछला फ़ोकस करें",
"page.login.google_signin": "गूगल के साथ साइन इन करें",
"page.login.oidc_signin": "ओपन-ईद के साथ साइन इन करें (%s)",
"page.login.title": "साइन इन करें",
"page.login.webauthn_login": "पासकी से लॉगिन करें",
"page.login.webauthn_login.error": "पासकी से लॉगिन करने में असमर्थ",
"page.login.webauthn_login.help": "यदि आप सुरक्षा कुंजी का उपयोग कर रहे हैं तो कृपया अपना उपयोगकर्ता नाम दर्ज करें। पासकी (discoverable credentials) के लिए यह आवश्यक नहीं है।",
"page.new_api_key.title": "नई एपीआई कुंजी",
"page.new_category.title": "नया श्रेणी",
"page.new_user.title": "नया उपभोक्ता",
"page.offline.message": "आप संपर्क में नहीं हैं",
"page.offline.refresh_page": "पृष्ठ को ताज़ा करने का प्रयास करें",
"page.offline.title": "ऑफ़लाइन मोड",
"page.read_entry_count": [
"%d पढ़ी गई प्रविष्टि",
"%d पढ़ी गई प्रविष्टियाँ"
],
"page.search.title": "खोज का परिणाम",
"page.sessions.table.actions": "कार्रवाई",
"page.sessions.table.current_session": "वर्तमान सत्र",
"page.sessions.table.date": "दिनांक",
"page.sessions.table.ip": "आईपी पता",
"page.sessions.table.user_agent": "उपभोक्ता अभिकर्ता",
"page.sessions.title": "सत्र",
"page.settings.link_google_account": "मेरा गूगल खाता जोरीय",
"page.settings.link_oidc_account": "मेरा ओपन-ईद खाता जोरीय (%s)",
"page.settings.title": "समायोजन",
"page.settings.unlink_google_account": "मेरा गूगल खाता हटाय",
"page.settings.unlink_oidc_account": "मेरा ओपन-ईद खाता हटाय (%s)",
"page.settings.webauthn.actions": "कार्रवाई",
"page.settings.webauthn.added_on": "जोड़ा गया",
"page.settings.webauthn.delete": [
"%d पासकुंजी निकालें",
"%d पासकी हटाएं"
],
"page.settings.webauthn.last_seen_on": "अंतिम उपयोग",
"page.settings.webauthn.passkey_name": "पासकी का नाम",
"page.settings.webauthn.passkeys": "पासकी",
"page.settings.webauthn.register": "रजिस्टर पासकी",
"page.settings.webauthn.register.error": "पासकी पंजीकृत करने में असमर्थ",
"page.shared_entries.title": "साझा किया हुआ प्रविष्टि",
"page.shared_entries_count": [
"%d साझा प्रविष्टि",
"%d साझा प्रविष्टियाँ"
],
"page.starred.title": "तारांकित",
"page.starred_entry_count": [
"%d तारांकित प्रविष्टि",
"%d तारांकित प्रविष्टियाँ"
],
"page.total_entry_count": [
"कुल %d प्रविष्टि",
"कुल %d प्रविष्टियाँ"
],
"page.unread.title": "अपठित",
"page.unread_entry_count": [
"%d अपठित प्रविष्टि",
"%d अपठित प्रविष्टियाँ"
],
"page.users.actions": "कार्रवाई",
"page.users.admin.no": "नहीं",
"page.users.admin.yes": "हां",
"page.users.is_admin": "प्रशासक",
"page.users.last_login": "आखरी लॉगइन",
"page.users.never_logged": "कभी नहीं",
"page.users.title": "उपभोक्ता",
"page.users.username": "यूसर्नेम",
"page.webauthn_rename.title": "पासकी का नाम बदलें",
"pagination.first": "पहला",
"pagination.last": "अंतिम",
"pagination.next": "अगला",
"pagination.previous": "पिछला",
"search.label": "खोजे",
"search.placeholder": "खोजे...",
"search.submit": "खोजें",
"skip_to_content": "सामग्री पर जाएं",
"time_elapsed.days": [
"%d दिन पहले",
"%d दिन पहले"
],
"time_elapsed.hours": [
"%d घंटेभर पहले",
"%d घंटो पहले"
],
"time_elapsed.minutes": [
"%d मिनट पहले",
"%d मिनट पहले"
],
"time_elapsed.months": [
"%d महीने पहले",
"%d महिनो पहले"
],
"time_elapsed.not_yet": "अभी तक नहीं",
"time_elapsed.now": "अभी",
"time_elapsed.weeks": [
"%d सप्ताह पहले",
"%d हफ्तों पहले"
],
"time_elapsed.years": [
"%d साल पहले",
"%d वर्षों पहले"
],
"time_elapsed.yesterday": "कल",
"tooltip.keyboard_shortcuts": "कुंजीपटल शॉर्टकट: %s",
"tooltip.logged_user": "%s के रूप में लॉग इन किया"
}
v2-2.2.16/internal/locale/translations/id_ID.json 0000664 0000000 0000000 00000111556 15127074645 0021614 0 ustar 00root root 0000000 0000000 {
"action.cancel": "batal",
"action.download": "Unduh",
"action.edit": "Sunting",
"action.home_screen": "Tambahkan ke beranda",
"action.import": "Impor",
"action.login": "Masuk",
"action.or": "atau",
"action.remove": "Hapus",
"action.remove_feed": "Hapus umpan ini",
"action.save": "Simpan",
"action.subscribe": "Langgan",
"action.update": "Perbarui",
"alert.account_linked": "Akun eksternal Anda sudah terhubung!",
"alert.account_unlinked": "Akun eksternal Anda sudah terputus!",
"alert.background_feed_refresh": "Semua umpan sedang disegarkan di latar belakang. Anda bisa lanjut menggunakan Miniflux sembari proses ini berlanjut.",
"alert.feed_error": "Ada masalah dengan umpan ini",
"alert.no_starred": "Tidak ada markah.",
"alert.no_category": "Tidak ada kategori.",
"alert.no_category_entry": "Tidak ada artikel di kategori ini.",
"alert.no_feed": "Anda tidak memiliki langganan.",
"alert.no_feed_entry": "Tidak ada artikel di umpan ini.",
"alert.no_feed_in_category": "Tidak ada langganan untuk kategori ini.",
"alert.no_history": "Tidak ada riwayat untuk saat ini.",
"alert.no_search_result": "Tidak ada hasil untuk pencarian ini.",
"alert.no_shared_entry": "Tidak ada entri yang dibagikan.",
"alert.no_tag_entry": "Tidak ada entri yang cocok dengan tag ini.",
"alert.no_unread_entry": "Belum ada artikel yang dibaca.",
"alert.no_user": "Anda adalah satu-satunya pengguna.",
"alert.prefs_saved": "Preferensi disimpan!",
"alert.too_many_feeds_refresh": [
"Anda terlalu banyak menyegarkan umpan. Mohon tunggu %d menit sebelum mencoba lagi."
],
"confirm.loading": "Sedang progres...",
"confirm.no": "tidak",
"confirm.question": "Apakah Anda yakin?",
"confirm.question.refresh": "Apakah Anda ingin memaksa penyegaran?",
"confirm.yes": "ya",
"enclosure_media_controls.seek": "Putar:",
"enclosure_media_controls.seek.title": "Putar %s detik",
"enclosure_media_controls.speed": "Kecepatan:",
"enclosure_media_controls.speed.faster": "Lebih cepat",
"enclosure_media_controls.speed.faster.title": "Lebih cepat %sx",
"enclosure_media_controls.speed.reset": "Atur ulang",
"enclosure_media_controls.speed.reset.title": "Atur ulang ke 1x",
"enclosure_media_controls.speed.slower": "Lebih lambat",
"enclosure_media_controls.speed.slower.title": "Lebih lambat %sx",
"entry.starred.toast.off": "Batal Markahi",
"entry.starred.toast.on": "Markahi",
"entry.starred.toggle.off": "Batal Markahi",
"entry.starred.toggle.on": "Markahi",
"entry.comments.label": "Komentar",
"entry.comments.title": "Lihat Komentar",
"entry.estimated_reading_time": [
"%d menit untuk dibaca"
],
"entry.external_link.label": "Tautan eksternal",
"entry.save.completed": "Selesai!",
"entry.save.label": "Simpan",
"entry.save.title": "Simpan artikel ini",
"entry.save.toast.completed": "Artikel tersimpan",
"entry.scraper.completed": "Selesai!",
"entry.scraper.label": "Unduh",
"entry.scraper.title": "Ambil konten asli",
"entry.share.label": "Bagikan",
"entry.share.title": "Bagikan artikel ini",
"entry.shared_entry.label": "Bagikan",
"entry.shared_entry.title": "Buka tautan publik",
"entry.state.loading": "Memuat...",
"entry.state.saving": "Menyimpan...",
"entry.status.mark_as_read": "Telah dibaca",
"entry.status.mark_as_unread": "Belum dibaca",
"entry.status.title": "Ubah status entri",
"entry.status.toast.read": "Ditandai sebagai telah dibaca",
"entry.status.toast.unread": "Ditandai sebagai belum dibaca",
"entry.tags.label": "Tanda:",
"entry.tags.more_tags_label": [
"Tampilkan %d tag lainnya"
],
"entry.unshare.label": "Batal bagikan",
"error.api_key_already_exists": "Kunci API ini sudah ada.",
"error.bad_credentials": "Nama pengguna atau kata sandi tidak valid.",
"error.category_already_exists": "Kategori ini telah ada.",
"error.category_not_found": "Kategori ini tidak ada atau tidak dipunyai oleh pengguna ini.",
"error.database_error": "Galat basis data: %v.",
"error.different_passwords": "Kata sandi tidak sama.",
"error.duplicate_fever_username": "Sudah ada pengguna lain dengan nama pengguna Fever yang sama!",
"error.duplicate_googlereader_username": "Sudah ada pengguna lain dengan nama pengguna Google Reader yang sama!",
"error.duplicate_linked_account": "Sudah ada pengguna lain yang terhubung dengan penyedia ini!",
"error.duplicated_feed": "Umpan ini sudah ada.",
"error.empty_file": "Berkas ini kosong.",
"error.entries_per_page_invalid": "Jumlah entri per halaman tidak valid.",
"error.feed_already_exists": "Umpan ini sudah ada.",
"error.feed_category_not_found": "Kategori ini tidak ada atau tidak dipunyai oleh pengguna ini.",
"error.feed_format_not_detected": "Tidak dapat mendeteksi format umpan: %v.",
"error.feed_invalid_blocklist_rule": "Aturan blokir tidak valid.",
"error.feed_invalid_keeplist_rule": "Aturan simpan tidak valid.",
"error.feed_mandatory_fields": "Harus ada URL dan kategorinya.",
"error.feed_not_found": "Umpan ini tidak ada atau tidak dipunyai oleh pengguna ini",
"error.feed_title_not_empty": "Judul umpan tidak boleh kosong.",
"error.feed_url_not_empty": "URL umpan tidak boleh kosong.",
"error.fields_mandatory": "Semua bidang diharuskan.",
"error.http_bad_gateway": "Situs ini tidak tersedia saat ini karena kesalahan akses peladen situs. Masalah ini bukan pada sisi Miniflux. Coba lagi nanti.",
"error.http_body_read": "Tidak dapat membaca badan HTTP: %v.",
"error.http_client_error": "Galat klien HTTP: %v.",
"error.http_empty_response": "Balasan HTTP kosong. Mungkin, situs ini menggunakan mekanisme perlindungan dari bot?",
"error.http_empty_response_body": "Badan balasan HTTP kosong.",
"error.http_forbidden": "Akses ke situs ini terlarang. Mungkin, situs ini menggunakan mekanisme perlindungan dari bot?",
"error.http_gateway_timeout": "Situs ini tidak tersedia saat ini karena kesalahan akses jaringan peladen situs. Masalah ini bukan pada sisi Miniflux. Coba lagi nanti.",
"error.http_internal_server_error": "Situs ini tidak tersedia saat ini karena galat peladen situs. Masalah ini bukan pada sisi Miniflux. Coba lagi nanti.",
"error.http_not_authorized": "Akses ke situs ini tidak diizinkan. Mungkin nama pengguna atau kata sandinya salah.",
"error.http_resource_not_found": "Sumber daya yang diminta tidak ditemukan. Periksa kembali URL-nya.",
"error.http_response_too_large": "Balasan HTTP terlalu besar. Anda bisa menaikkan batas ukuran balasan HTTP di pengaturan global (membutuhkan pemulaian ulang peladen).",
"error.http_service_unavailable": "Situs ini tidak tersedia saat ini dikarenakan galat internal peladen situs. Masalah ini bukan pada sisi Miniflux. Coba lagi nanti.",
"error.http_too_many_requests": "Terlalu banyak koneksi dari Miniflux yang dibuat ke situs ini. Coba lagi nanti atau ubah konfigurasi aplikasi.",
"error.http_unexpected_status_code": "Situs ini tidak dapat dijangkau saat ini dikarenakan kode status HTTP tak diduga: %d Masalah ini bukan pada sisi Miniflux. Coba lagi nanti.",
"error.invalid_categories_sorting_order": "Urutan penyortiran kategori tidak valid.",
"error.invalid_default_home_page": "Beranda baku tidak valid!",
"error.invalid_display_mode": "Mode tampilan aplikasi web tidak valid.",
"error.invalid_entry_direction": "Urutan entri tidak valid.",
"error.invalid_entry_order": "Urutan entri tidak valid.",
"error.invalid_feed_proxy_url": "URL proksi tidak valid.",
"error.invalid_feed_url": "URL umpan tidak valid.",
"error.invalid_gesture_nav": "Navigasi gestur tidak valid.",
"error.invalid_language": "Bahasa tidak valid.",
"error.invalid_site_url": "URL situs tidak valid.",
"error.invalid_theme": "Tema tidak valid.",
"error.invalid_timezone": "Zona waktu tidak valid.",
"error.network_operation": "Miniflux tidak dapat menjangkau situs ini dikarenakan galat jaringan: %v.",
"error.network_timeout": "Situs ini terlalu lambat dan permintaan ke situs terlalu lama: %v",
"error.password_min_length": "Kata sandi harus memiliki setidaknya 6 karakter.",
"error.proxy_url_not_empty": "URL proksi tidak boleh kosong.",
"error.settings_block_rule_fieldname_invalid": "Aturan blokir tidak valid: aturan #%d tidak mempunyai nama bidang yang valid (Opsi: %s)",
"error.settings_block_rule_invalid_regex": "Aturan blokir tidak valid: aturan pola #%d bukan ekspresi regular (regex) yang valid",
"error.settings_block_rule_regex_required": "Aturan blokir tidak valid: aturan pola #%d tidak disediakan",
"error.settings_block_rule_separator_required": "Aturan blokir tidak valid: aturan pola #%d diharuskan dipisah menggunakan '='",
"error.settings_invalid_domain_list": "Daftar domain tidak valid. Mohon sediakan daftar domain yang dipisah spasi.",
"error.settings_keep_rule_fieldname_invalid": "Aturan simpan tidak valid: aturan #%d tidak mempunyai nama bidang yang valid (Opsi: %s)",
"error.settings_keep_rule_invalid_regex": "Aturan simpan tidak valid: aturan pola #%d bukan ekspresi regular (regex) yang valid",
"error.settings_keep_rule_regex_required": "Aturan simpan tidak valid: aturan pola #%d tidak disediakan",
"error.settings_keep_rule_separator_required": "Aturan simpan tidak valid: aturan pola #%d diharuskan dipisah menggunakan '='",
"error.settings_mandatory_fields": "Harus ada nama pengguna, tema, bahasa, dan zona waktu.",
"error.settings_media_playback_rate_range": "Kecepatan pemutaran di luar jangkauan",
"error.settings_reading_speed_is_positive": "Kecepatan membaca harus integer positif.",
"error.site_url_not_empty": "URL situs tidak boleh kosong.",
"error.subscription_not_found": "Tidak bisa mencari langganan apa pun.",
"error.title_required": "Judul harus ada.",
"error.tls_error": "Galat TLS: %q. Anda bisa mematikan verifikasi TLS di pengaturan umpan jika Anda mau.",
"error.unable_to_create_api_key": "Tidak bisa membuat kunci API ini.",
"error.unable_to_create_category": "Tidak bisa membuat kategori ini.",
"error.unable_to_create_user": "Tidak bisa membuat pengguna tersebut.",
"error.unable_to_detect_rssbridge": "Tidak dapat mendeteksi umpan menggunakan RSS-Bridge: %v.",
"error.unable_to_parse_feed": "Tidak dapat membaca umpan: %v.",
"error.unable_to_update_category": "Tidak bisa memperbarui kategori ini.",
"error.unable_to_update_feed": "Tidak bisa memperbarui umpan ini.",
"error.unable_to_update_user": "Tidak bisa memperbarui pengguna tersebut.",
"error.unlink_account_without_password": "Anda harus mengatur kata sandi atau Anda tidak bisa masuk kembali.",
"error.user_already_exists": "Pengguna ini sudah ada.",
"error.user_mandatory_fields": "Harus ada nama pengguna.",
"error.linktaco_missing_required_fields": "LinkTaco API Token dan Organization Slug diperlukan",
"form.api_key.label.description": "Label Kunci API",
"form.category.hide_globally": "Sembunyikan entri di daftar belum dibaca global",
"form.category.label.title": "Judul",
"form.feed.fieldset.general": "Umum",
"form.feed.fieldset.integration": "Pengaturan Pihak Ketiga",
"form.feed.fieldset.network_settings": "Pengaturan Jaringan",
"form.feed.fieldset.rules": "Aturan",
"form.feed.label.allow_self_signed_certificates": "Perbolehkan sertifikat web tidak valid atau sertifikasi sendiri",
"form.feed.label.apprise_service_urls": "Daftar yang dipisahkan koma untuk URL layanan Apprise",
"form.feed.label.block_filter_entry_rules": "Aturan Pemblokiran Entri",
"form.feed.label.blocklist_rules": "Filter Pemblokiran Berbasis Regex",
"form.feed.label.category": "Kategori",
"form.feed.label.cookie": "Atur Kuki",
"form.feed.label.crawler": "Ambil konten asli",
"form.feed.label.description": "Deskripsi",
"form.feed.label.disable_http2": "Matikan HTTP/2 untuk menghindari pelacakan",
"form.feed.label.disabled": "Jangan perbarui umpan ini",
"form.feed.label.feed_password": "Kata Sandi Umpan",
"form.feed.label.feed_url": "URL Umpan",
"form.feed.label.feed_username": "Nama Pengguna Umpan",
"form.feed.label.fetch_via_proxy": "Gunakan proksi yang dikonfigurasi di tingkat aplikasi",
"form.feed.label.hide_globally": "Sembunyikan entri di daftar belum dibaca global",
"form.feed.label.ignore_http_cache": "Abaikan Tembolok HTTP",
"form.feed.label.keep_filter_entry_rules": "Aturan Izin Entri",
"form.feed.label.keeplist_rules": "Filter Simpan Berbasis Regex",
"form.feed.label.no_media_player": "Tidak ada pemutar media (audio/video)",
"form.feed.label.ntfy_activate": "Kirim artikel ke ntfy",
"form.feed.label.ntfy_default_priority": "Prioritas baku Ntfy",
"form.feed.label.ntfy_high_priority": "Priroritas tinggi Ntfy",
"form.feed.label.ntfy_low_priority": "Prioritas rendah Ntfy",
"form.feed.label.ntfy_max_priority": "Prioritas maksimal Ntfy",
"form.feed.label.ntfy_min_priority": "Prioritas minimal Ntfy",
"form.feed.label.ntfy_priority": "Prioritas Ntfy",
"form.feed.label.ntfy_topic": "Topik Ntfy (opsional)",
"form.feed.label.proxy_url": "URL Proksi",
"form.feed.label.pushover_activate": "Kirim artikel ke pushover.net",
"form.feed.label.pushover_default_priority": "Prioritas baku Pushover",
"form.feed.label.pushover_high_priority": "Prioritas tinggi Pushover",
"form.feed.label.pushover_low_priority": "Prioritas rendah Pushover",
"form.feed.label.pushover_max_priority": "Prioritas maksimal Pushover",
"form.feed.label.pushover_min_priority": "Prioritas minimal Pushover",
"form.feed.label.pushover_priority": "Prioritas pesan Pushover",
"form.feed.label.rewrite_rules": "Aturan Penulisan Ulang Konten",
"form.feed.label.scraper_rules": "Aturan Pengambil Data",
"form.feed.label.site_url": "URL Situs",
"form.feed.label.title": "Judul",
"form.feed.label.urlrewrite_rules": "Aturan Tulis Ulang URL",
"form.feed.label.user_agent": "Timpa User Agent Baku",
"form.feed.label.webhook_url": "Timpa URL Webhook",
"form.import.label.file": "Berkas OPML",
"form.import.label.url": "URL",
"form.integration.archiveorg_activate": "Kirim entri ke archive.org",
"form.integration.apprise_activate": "Kirim artikel ke Apprise",
"form.integration.apprise_services_url": "Daftar yang dipisahkan koma untuk URL layanan Apprise",
"form.integration.apprise_url": "URL API Apprise",
"form.integration.betula_activate": "Simpan artikel ke Betula",
"form.integration.betula_token": "Token Betula",
"form.integration.betula_url": "URL Peladen Betula",
"form.integration.cubox_activate": "Simpan artikel ke Cubox",
"form.integration.cubox_api_link": "Tautan API Cubox",
"form.integration.discord_activate": "Kirim artikel ke Discord",
"form.integration.discord_webhook_link": "Tautan Webhook Discord",
"form.integration.espial_activate": "Simpan artikel ke Espial",
"form.integration.espial_api_key": "Kunci API Espial",
"form.integration.espial_endpoint": "Titik URL API Espial",
"form.integration.espial_tags": "Tanda di Espial",
"form.integration.fever_activate": "Aktifkan API Fever",
"form.integration.fever_endpoint": "Titik URL API Fever:",
"form.integration.fever_password": "Kata Sandi Fever",
"form.integration.fever_username": "Nama Pengguna Fever",
"form.integration.googlereader_activate": "Aktifkan API Google Reader",
"form.integration.googlereader_endpoint": "Titik URL API Google Reader:",
"form.integration.googlereader_password": "Kata Sandi Google Reader",
"form.integration.googlereader_username": "Nama Pengguna Google Reader",
"form.integration.instapaper_activate": "Simpan artikel ke Instapaper",
"form.integration.instapaper_password": "Kata Sandi Instapaper",
"form.integration.instapaper_username": "Nama Pengguna Instapaper",
"form.integration.karakeep_activate": "Simpan artikel ke Karakeep",
"form.integration.karakeep_api_key": "Kunci API Karakeep",
"form.integration.karakeep_url": "Titik URL API Karakeep",
"form.integration.karakeep_tags": "Tanda di Karakeep",
"form.integration.linkace_activate": "Simpan artikel ke LinkAce",
"form.integration.linkace_api_key": "Kunci API LinkAce",
"form.integration.linkace_check_disabled": "Matikan pemeriksaan tautan",
"form.integration.linkace_endpoint": "Titik URL API LinkAce",
"form.integration.linkace_is_private": "Tandai tautan sebagai pribadi",
"form.integration.linkace_tags": "Tanda LinkAce",
"form.integration.linkding_activate": "Simpan artikel ke Linkding",
"form.integration.linkding_api_key": "Kunci API Linkding",
"form.integration.linkding_bookmark": "Tandai markah sebagai belum dibaca",
"form.integration.linkding_endpoint": "Titik URL API Linkding",
"form.integration.linkding_tags": "Tanda Linkding",
"form.integration.linktaco_activate": "Simpan entri ke LinkTaco",
"form.integration.linktaco_api_token": "Token API LinkTaco",
"form.integration.linktaco_api_token_hint": "Dapatkan token akses pribadi Anda di",
"form.integration.linktaco_org_slug": "Slug organisasi",
"form.integration.linktaco_tags": "Tag (maksimal 10, dipisahkan koma)",
"form.integration.linktaco_tags_hint": "Maksimal 10 tag, dipisahkan koma",
"form.integration.linktaco_visibility": "Visibilitas",
"form.integration.linktaco_visibility_public": "Publik",
"form.integration.linktaco_visibility_private": "Pribadi",
"form.integration.linktaco_visibility_hint": "Visibilitas PRIBADI membutuhkan akun LinkTaco berbayar",
"form.integration.linkwarden_activate": "Simpan artikel ke Linkwarden",
"form.integration.linkwarden_api_key": "Kunci API Linkwarden",
"form.integration.linkwarden_endpoint": "URL Dasar Linkwarden",
"form.integration.linkwarden_collection_id": "ID koleksi Linkwarden",
"form.integration.matrix_bot_activate": "Kirim entri baru ke Matrix",
"form.integration.matrix_bot_chat_id": "ID Ruang Matrix",
"form.integration.matrix_bot_password": "Kata Sandi Matrix",
"form.integration.matrix_bot_url": "URL Peladen Matrix",
"form.integration.matrix_bot_user": "Nama Pengguna Matrix",
"form.integration.notion_activate": "Simpan artikel ke Notion",
"form.integration.notion_page_id": "ID Halaman Notion",
"form.integration.notion_token": "Token Rahasia Notion",
"form.integration.ntfy_activate": "Kirim artikel ke ntfy",
"form.integration.ntfy_api_token": "Token API Ntfy (opsional)",
"form.integration.ntfy_icon_url": "URL ikon Ntfy (opsional)",
"form.integration.ntfy_internal_links": "Gunakan tautan internal ketika mengklik (opsional)",
"form.integration.ntfy_password": "Kata sandi Ntfy (opsional)",
"form.integration.ntfy_topic": "Topik Ntfy (yang akan digunakan jika tidak diatur di umpan)",
"form.integration.ntfy_url": "URL Ntfy (opsional, bawaan ke ntfy.sh)",
"form.integration.ntfy_username": "Nama pengguna Ntfy (opsional)",
"form.integration.nunux_keeper_activate": "Simpan artikel ke Nunux Keeper",
"form.integration.nunux_keeper_api_key": "Kunci API Nunux Keeper",
"form.integration.nunux_keeper_endpoint": "Titik URL API Nunux Keeper",
"form.integration.omnivore_activate": "Simpan artikel ke Omnivore",
"form.integration.omnivore_api_key": "Kunci API Omnivore",
"form.integration.omnivore_url": "Titik URL API Omnivore",
"form.integration.pinboard_activate": "Simpan artikel ke Pinboard",
"form.integration.pinboard_bookmark": "Tandai markah sebagai belum dibaca",
"form.integration.pinboard_tags": "Tanda di Pinboard",
"form.integration.pinboard_token": "Token API Pinboard",
"form.integration.pushover_activate": "Kirim artikel ke Pushover",
"form.integration.pushover_device": "Perangkat Pushover (opsional)",
"form.integration.pushover_prefix": "Prefiks URL Pushover (opsional)",
"form.integration.pushover_token": "Token API aplikasi Pushover",
"form.integration.pushover_user": "Kunci pengguna Pushover",
"form.integration.raindrop_activate": "Simpan artikel ke Raindrop",
"form.integration.raindrop_collection_id": "ID Koleksi",
"form.integration.raindrop_tags": "Tanda (dipisahkan koma)",
"form.integration.raindrop_token": "Token (Tes)",
"form.integration.readeck_activate": "Simpan artikel ke Readeck",
"form.integration.readeck_api_key": "Kunci API Readeck",
"form.integration.readeck_endpoint": "Titik URL API Readeck",
"form.integration.readeck_labels": "Tagar Readeck",
"form.integration.readeck_only_url": "Kirim hanya URL (alih-alih konten penuh)",
"form.integration.readeck_push_activate": "Kirim otomatis entri baru ke Readeck",
"form.integration.readwise_activate": "Simpan artikel ke Readwise",
"form.integration.readwise_api_key": "Token Akses Readwise",
"form.integration.readwise_api_key_link": "Dapatkan Token Akses Readwise Anda",
"form.integration.rssbridge_activate": "Periksa RSS-Bridge ketika menambahkan langganan",
"form.integration.rssbridge_token": "Token autentikasi RSS-Bridge",
"form.integration.rssbridge_url": "URL peladen RSS-Bridge",
"form.integration.shaarli_activate": "Simpan artikel ke Shaarli",
"form.integration.shaarli_api_secret": "Rahasia API Shaarli",
"form.integration.shaarli_endpoint": "URL Shaarli",
"form.integration.shiori_activate": "Simpan artikel ke Shiori",
"form.integration.shiori_endpoint": "Titik URL API Shiori",
"form.integration.shiori_password": "Kata Sandi Shiori",
"form.integration.shiori_username": "Nama Pengguna Shiori",
"form.integration.slack_activate": "Kirim artikel ke Slack",
"form.integration.slack_webhook_link": "Tautan Webhook Slack",
"form.integration.telegram_bot_activate": "Kirim artikel baru ke percakapan Telegram",
"form.integration.telegram_bot_disable_buttons": "Matikan tombol",
"form.integration.telegram_bot_disable_notification": "Matikan notifikasi",
"form.integration.telegram_bot_disable_web_page_preview": "Matikan tinjauan halaman web",
"form.integration.telegram_bot_token": "Token Bot",
"form.integration.telegram_chat_id": "ID Obrolan",
"form.integration.telegram_topic_id": "ID Topik",
"form.integration.wallabag_activate": "Simpan artikel ke Wallabag",
"form.integration.wallabag_client_id": "ID Klien Wallabag",
"form.integration.wallabag_client_secret": "Rahasia Klien Wallabag",
"form.integration.wallabag_endpoint": "URL Dasar Wallabag",
"form.integration.wallabag_only_url": "Kirim hanya URL (alih-alih konten penuh)",
"form.integration.wallabag_password": "Kata Sandi Wallabag",
"form.integration.wallabag_username": "Nama Pengguna Wallabag",
"form.integration.wallabag_tags": "Tag Wallabag",
"form.integration.webhook_activate": "Aktifkan Webhook",
"form.integration.webhook_secret": "Rahasia Webhook",
"form.integration.webhook_url": "URL Webhook baku",
"form.prefs.fieldset.application_settings": "Pengaturan Aplikasi",
"form.prefs.fieldset.authentication_settings": "Pengaturan Autentikasi",
"form.prefs.fieldset.global_feed_settings": "Pengaturan Umpan Global",
"form.prefs.fieldset.reader_settings": "Pengaturan Pembaca",
"form.prefs.help.external_font_hosts": "Daftar yang dipisah spasi untuk peladen penyedia fonta eksternal yang diperbolehkan. Seperti: \"fonts.gstatic.com fonts.googleapis.com\".",
"form.prefs.label.always_open_external_links": "Baca artikel dengan membuka tautan eksternal",
"form.prefs.label.categories_sorting_order": "Pengurutan Kategori",
"form.prefs.label.cjk_reading_speed": "Kecepatan membaca untuk bahasa Tiongkok, Korea, dan Jepang (karakter per menit)",
"form.prefs.label.custom_css": "Modifikasi CSS",
"form.prefs.label.custom_js": "Modifikasi JavaScript",
"form.prefs.label.default_home_page": "Beranda Baku",
"form.prefs.label.default_reading_speed": "Kecepatan membaca untuk bahasa lain (kata per menit)",
"form.prefs.label.display_mode": "Mode Tampilan Aplikasi Web (perlu pemasangan ulang)",
"form.prefs.label.entries_per_page": "Entri per Halaman",
"form.prefs.label.entry_order": "Pengurutan Kolom Entri",
"form.prefs.label.entry_sorting": "Pengurutan Entri",
"form.prefs.label.entry_swipe": "Aktifkan tindakan geser pada entri di ponsel",
"form.prefs.label.external_font_hosts": "Peladen penyedia fonta eksternal",
"form.prefs.label.gesture_nav": "Isyarat untuk menavigasi antar entri",
"form.prefs.label.keyboard_shortcuts": "Aktifkan pintasan papan tik",
"form.prefs.label.language": "Bahasa",
"form.prefs.label.mark_read_manually": "Tandai entri sebagai telah dibaca secara manual",
"form.prefs.label.mark_read_on_media_completion": "Tandai entri sebagai telah dibaca ketika audio/video sudah 90% didengar/ditonton",
"form.prefs.label.mark_read_on_view": "Secara otomatis menandai entri sebagai telah dibaca saat dilihat",
"form.prefs.label.mark_read_on_view_or_media_completion": "Tandai entri sebagai telah dibaca ketika dilihat. Untuk audio/video, tandai sebagai telah dibaca ketika sudah 90% didengar/ditonton.",
"form.prefs.label.media_playback_rate": "Kecepatan pemutaran audio/video",
"form.prefs.label.open_external_links_in_new_tab": "Buka tautan eksternal di tab baru (menambahkan target=\"_blank\" ke tautan)",
"form.prefs.label.show_reading_time": "Tampilkan perkiraan waktu baca untuk artikel",
"form.prefs.label.theme": "Tema",
"form.prefs.label.timezone": "Zona Waktu",
"form.prefs.select.alphabetical": "Secara alfabet",
"form.prefs.select.browser": "Peramban",
"form.prefs.select.created_time": "Waktu entri dibuat",
"form.prefs.select.fullscreen": "Layar Penuh",
"form.prefs.select.minimal_ui": "Antarmuka minimal",
"form.prefs.select.none": "Tidak ada",
"form.prefs.select.older_first": "Entri tertua dulu",
"form.prefs.select.publish_time": "Waktu entri dipublikasikan",
"form.prefs.select.recent_first": "Entri terbaru dulu",
"form.prefs.select.standalone": "Tersendiri",
"form.prefs.select.swipe": "Geser",
"form.prefs.select.tap": "Ketuk dua kali",
"form.prefs.select.unread_count": "Jumlah yang belum dibaca",
"form.submit.loading": "Memuat...",
"form.submit.saving": "Menyimpan...",
"form.user.label.admin": "Admin",
"form.user.label.confirmation": "Konfirmasi Kata Sandi",
"form.user.label.password": "Kata Sandi",
"form.user.label.username": "Nama Pengguna",
"menu.about": "Tentang",
"menu.add_feed": "Tambah langganan",
"menu.add_user": "Tambah pengguna",
"menu.api_keys": "Kunci API",
"menu.categories": "Kategori",
"menu.create_api_key": "Buat kunci API baru",
"menu.create_category": "Buat kategori",
"menu.edit_category": "Sunting",
"menu.edit_feed": "Sunting",
"menu.export": "Ekspor",
"menu.feed_entries": "Entri",
"menu.feeds": "Umpan",
"menu.flush_history": "Hapus riwayat",
"menu.history": "Riwayat",
"menu.home_page": "Beranda",
"menu.import": "Impor",
"menu.integrations": "Integrasi",
"menu.logout": "Keluar",
"menu.mark_all_as_read": "Tandai semua sebagai telah dibaca",
"menu.mark_page_as_read": "Tandai halaman ini sebagai telah dibaca",
"menu.preferences": "Preferensi",
"menu.refresh_all_feeds": "Muat ulang semua umpan di latar belakang",
"menu.refresh_feed": "Muat ulang",
"menu.search": "Cari",
"menu.sessions": "Sesi",
"menu.settings": "Pengaturan",
"menu.shared_entries": "Entri yang Dibagikan",
"menu.show_all_entries": "Tampilkan semua entri",
"menu.show_only_starred_entries": "Tampilkan hanya entri yang dimarkahkan",
"menu.show_only_unread_entries": "Tampilkan hanya entri yang belum dibaca",
"menu.starred": "Markah",
"menu.title": "Menu",
"menu.unread": "Belum Dibaca",
"menu.users": "Pengguna",
"page.about.author": "Pengembang:",
"page.about.build_date": "Tanggal Penyusunan:",
"page.about.credits": "Pengembang",
"page.about.db_usage": "Ukuran basis data:",
"page.about.git_commit": "Komit Git:",
"page.about.global_config_options": "Pengaturan Konfigurasi Global",
"page.about.go_version": "Versi Go:",
"page.about.license": "Lisensi:",
"page.about.postgres_version": "Versi Postgres:",
"page.about.title": "Tentang",
"page.about.version": "Versi:",
"page.add_feed.choose_feed": "Pilih Umpan",
"page.add_feed.label.url": "URL",
"page.add_feed.legend.advanced_options": "Pilihan Tingkat Lanjut",
"page.add_feed.no_category": "Tidak ada kategori. Anda harus paling tidak memiliki satu kategori.",
"page.add_feed.submit": "Cari langganan",
"page.add_feed.title": "Langganan Baru",
"page.api_keys.never_used": "Tidak Pernah Digunakan",
"page.api_keys.table.actions": "Tindakan",
"page.api_keys.table.created_at": "Tanggal Pembuatan",
"page.api_keys.table.description": "Deskripsi",
"page.api_keys.table.last_used_at": "Terakhir Digunakan",
"page.api_keys.table.token": "Token",
"page.api_keys.title": "Kunci API",
"page.categories.entries": "Artikel",
"page.categories.feed_count": [
"Ada %d umpan."
],
"page.categories.feeds": "Langganan",
"page.categories.no_feed": "Tidak ada umpan.",
"page.categories.title": "Kategori",
"page.categories_count": [
"%d kategori"
],
"page.category_label": "Kategori: %s",
"page.edit_category.title": "Sunting Kategori: %s",
"page.edit_feed.etag_header": "Tajuk ETag:",
"page.edit_feed.last_check": "Terakhir diperiksa:",
"page.edit_feed.last_modified_header": "Tajuk LastModified:",
"page.edit_feed.last_parsing_error": "Galat Penguraian Terakhir",
"page.edit_feed.no_header": "Tidak Ada",
"page.edit_feed.title": "Sunting Umpan: %s",
"page.edit_user.title": "Sunting Pengguna: %s",
"page.entry.attachments": "Lampiran",
"page.feeds.error_count": [
"%d galat"
],
"page.feeds.last_check": "Terakhir diperiksa:",
"page.feeds.next_check": "Akan diperiksa kembali:",
"page.feeds.read_counter": "Jumlah entri yang telah dibaca",
"page.feeds.title": "Umpan",
"page.footer.elevator": "Kembali ke atas",
"page.history.title": "Riwayat",
"page.import.title": "Impor",
"page.integration.bookmarklet": "Penanda (bookmarklet)",
"page.integration.bookmarklet.help": "Tautan spesial ini memperbolehkan Anda untuk berlangganan ke situs langsung dengan menggunakan markah di peramban web Anda.",
"page.integration.bookmarklet.instructions": "Seret dan tempatkan tautan ini ke markah Anda.",
"page.integration.bookmarklet.name": "Tambahkan ke Miniflux",
"page.integration.miniflux_api": "API Miniflux",
"page.integration.miniflux_api_endpoint": "Titik URL API",
"page.integration.miniflux_api_password": "Kata Sandi",
"page.integration.miniflux_api_password_value": "Kata sandi akun Anda",
"page.integration.miniflux_api_username": "Nama Pengguna",
"page.integrations.title": "Integrasi",
"page.keyboard_shortcuts.close_modal": "Tutup bilah modal",
"page.keyboard_shortcuts.download_content": "Unduh konten asli",
"page.keyboard_shortcuts.go_to_bottom_item": "Pergi ke item paling bawah",
"page.keyboard_shortcuts.go_to_categories": "Ke kategori",
"page.keyboard_shortcuts.go_to_feed": "Ke umpan",
"page.keyboard_shortcuts.go_to_feeds": "Ke umpan",
"page.keyboard_shortcuts.go_to_history": "Ke riwayat",
"page.keyboard_shortcuts.go_to_next_item": "Ke entri berikutnya",
"page.keyboard_shortcuts.go_to_next_page": "Ke halaman berikutnya",
"page.keyboard_shortcuts.go_to_previous_item": "Ke entri sebelumnya",
"page.keyboard_shortcuts.go_to_previous_page": "Ke halaman sebelumnya",
"page.keyboard_shortcuts.go_to_search": "Atur fokus ke pencaarian",
"page.keyboard_shortcuts.go_to_settings": "Ke pengaturan",
"page.keyboard_shortcuts.go_to_starred": "Ke markah",
"page.keyboard_shortcuts.go_to_top_item": "Pergi ke item teratas",
"page.keyboard_shortcuts.go_to_unread": "Ke bagian yang belum dibaca",
"page.keyboard_shortcuts.mark_page_as_read": "Tandai halaman saat ini sebagai telah dibaca",
"page.keyboard_shortcuts.open_comments": "Buka tautan komentar",
"page.keyboard_shortcuts.open_comments_same_window": "Buka tautan komentar di bilah saat ini",
"page.keyboard_shortcuts.open_item": "Buka entri yang dipilih",
"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.refresh_all_feeds": "Muat ulang semua umpan di latar belakang",
"page.keyboard_shortcuts.remove_feed": "Hapus umpan ini",
"page.keyboard_shortcuts.save_article": "Simpan Artikel",
"page.keyboard_shortcuts.scroll_item_to_top": "Gulir ke atas",
"page.keyboard_shortcuts.show_keyboard_shortcuts": "Tampilkan pintasan papan tik",
"page.keyboard_shortcuts.subtitle.actions": "Tindakan",
"page.keyboard_shortcuts.subtitle.items": "Navigasi Entri",
"page.keyboard_shortcuts.subtitle.pages": "Navigasi Halaman",
"page.keyboard_shortcuts.subtitle.sections": "Navigasi Bagian",
"page.keyboard_shortcuts.title": "Pintasan Papan Tik",
"page.keyboard_shortcuts.toggle_star_status": "Ubah status markah",
"page.keyboard_shortcuts.toggle_entry_attachments": "Buka/tutup lampiran entri",
"page.keyboard_shortcuts.toggle_read_status_next": "Ubah status baca, fokus ke selanjutnya",
"page.keyboard_shortcuts.toggle_read_status_prev": "Ubah status baca, fokus ke sebelumnya",
"page.login.google_signin": "Masuk menggunakan Google",
"page.login.oidc_signin": "Masuk menggunakan %s",
"page.login.title": "Masuk",
"page.login.webauthn_login": "Masuk menggunakan passkey",
"page.login.webauthn_login.error": "Tidak dapat masuk menggunakan passkey",
"page.login.webauthn_login.help": "Mohon untuk memasukkan nama pengguna Anda jika Anda menggunakan kunci keamanan. Tidak diperlukan jika anda menggunakan Passkey (kredensial dapat ditemukan).",
"page.new_api_key.title": "Kunci API Baru",
"page.new_category.title": "Kategori Baru",
"page.new_user.title": "Pengguna Baru",
"page.offline.message": "Anda sedang luring",
"page.offline.refresh_page": "Coba untuk memuat ulang halaman ini",
"page.offline.title": "Mode Luring",
"page.read_entry_count": [
"%d entri dibaca"
],
"page.search.title": "Hasil Pencarian",
"page.sessions.table.actions": "Tindakan",
"page.sessions.table.current_session": "Sesi Saat Ini",
"page.sessions.table.date": "Tanggal",
"page.sessions.table.ip": "Alamat IP",
"page.sessions.table.user_agent": "User Agent",
"page.sessions.title": "Sesi",
"page.settings.link_google_account": "Tautkan akun Google saya",
"page.settings.link_oidc_account": "Tautkan akun %s saya",
"page.settings.title": "Pengaturan",
"page.settings.unlink_google_account": "Putuskan akun Google saya",
"page.settings.unlink_oidc_account": "Putuskan akun %s saya",
"page.settings.webauthn.actions": "Tindakan",
"page.settings.webauthn.added_on": "Ditambahkan Pada",
"page.settings.webauthn.delete": [
"Hapus %d passkey"
],
"page.settings.webauthn.last_seen_on": "Terakhir Digunakan",
"page.settings.webauthn.passkey_name": "Nama Passkey",
"page.settings.webauthn.passkeys": "Passkey",
"page.settings.webauthn.register": "Daftar passkey",
"page.settings.webauthn.register.error": "Tidak dapat mendaftarkan passkey",
"page.shared_entries.title": "Entri yang Dibagikan",
"page.shared_entries_count": [
"%d entri yang dibagikan"
],
"page.starred.title": "Markah",
"page.starred_entry_count": [
"%d entri dimarkahi"
],
"page.total_entry_count": [
"%d entri secara total"
],
"page.unread.title": "Belum Dibaca",
"page.unread_entry_count": [
"%d entri belum dibaca"
],
"page.users.actions": "Tindakan",
"page.users.admin.no": "Tidak",
"page.users.admin.yes": "Ya",
"page.users.is_admin": "Admin",
"page.users.last_login": "Terakhir Masuk",
"page.users.never_logged": "Tidak Pernah",
"page.users.title": "Pengguna",
"page.users.username": "Nama Pengguna",
"page.webauthn_rename.title": "Ubah Nama Passkey",
"pagination.first": "Pertama",
"pagination.last": "Terakhir",
"pagination.next": "Berikutnya",
"pagination.previous": "Sebelumnya",
"search.label": "Cari",
"search.placeholder": "Cari...",
"search.submit": "Cari",
"skip_to_content": "Langsung ke konten",
"time_elapsed.days": [
"%d hari yang lalu"
],
"time_elapsed.hours": [
"%d jam yang lalu"
],
"time_elapsed.minutes": [
"%d menit yang lalu"
],
"time_elapsed.months": [
"%d bulan yang lalu"
],
"time_elapsed.not_yet": "belum",
"time_elapsed.now": "baru saja",
"time_elapsed.weeks": [
"%d pekan yang lalu"
],
"time_elapsed.years": [
"%d tahun yang lalu"
],
"time_elapsed.yesterday": "kemarin",
"tooltip.keyboard_shortcuts": "Pintasan Papan Tik: %s",
"tooltip.logged_user": "Masuk sebagai %s"
}
v2-2.2.16/internal/locale/translations/it_IT.json 0000664 0000000 0000000 00000115521 15127074645 0021650 0 ustar 00root root 0000000 0000000 {
"action.cancel": "cancella",
"action.download": "Scarica",
"action.edit": "Modifica",
"action.home_screen": "Aggiungere alla schermata Home",
"action.import": "Importa",
"action.login": "Accedi",
"action.or": "o",
"action.remove": "Elimina",
"action.remove_feed": "Elimina questo feed",
"action.save": "Salva",
"action.subscribe": "Abbonati",
"action.update": "Aggiorna",
"alert.account_linked": "Il tuo account esterno ora è collegato!",
"alert.account_unlinked": "Il tuo account esterno ora è scollegato!",
"alert.background_feed_refresh": "Tutti i feed vengono aggiornati in background. Puoi continuare a usare Miniflux mentre questo processo è in esecuzione.",
"alert.feed_error": "Sembra ci sia un problema con questo feed",
"alert.no_starred": "Nessun preferito disponibile.",
"alert.no_category": "Nessuna categoria disponibile.",
"alert.no_category_entry": "Questa categoria non contiene alcun articolo.",
"alert.no_feed": "Nessun feed disponibile.",
"alert.no_feed_entry": "Questo feed non contiene alcun articolo.",
"alert.no_feed_in_category": "Non esiste un abbonamento per questa categoria.",
"alert.no_history": "La tua cronologia al momento è vuota.",
"alert.no_search_result": "La ricerca non ha prodotto risultati.",
"alert.no_shared_entry": "Non ci sono voci condivise.",
"alert.no_tag_entry": "Non ci sono voci corrispondenti a questo tag.",
"alert.no_unread_entry": "Nessun articolo da leggere.",
"alert.no_user": "Tu sei l'unico utente.",
"alert.prefs_saved": "Preferenze salvate!",
"alert.too_many_feeds_refresh": [
"Hai richiesto troppi aggiornamenti dei feed. Attendi %d minuto prima di riprovare.",
"Hai richiesto troppi aggiornamenti dei feed. Attendi %d minuti prima di riprovare."
],
"confirm.loading": "In corso...",
"confirm.no": "no",
"confirm.question": "Sei sicuro?",
"confirm.question.refresh": "Vuoi forzare l'aggiornamento?",
"confirm.yes": "sì",
"enclosure_media_controls.seek": "Sposta:",
"enclosure_media_controls.seek.title": "Sposta di %s secondi",
"enclosure_media_controls.speed": "Velocità:",
"enclosure_media_controls.speed.faster": "Più veloce",
"enclosure_media_controls.speed.faster.title": "Più veloce di %sx",
"enclosure_media_controls.speed.reset": "Reimposta",
"enclosure_media_controls.speed.reset.title": "Reimposta velocità a 1x",
"enclosure_media_controls.speed.slower": "Più lento",
"enclosure_media_controls.speed.slower.title": "Più lento di %sx",
"entry.starred.toast.off": "Non preferito",
"entry.starred.toast.on": "Preferito",
"entry.starred.toggle.off": "Rimuovi dai preferiti",
"entry.starred.toggle.on": "Aggiungi ai preferiti",
"entry.comments.label": "Commenti",
"entry.comments.title": "Mostra i commenti",
"entry.estimated_reading_time": [
"%d minuto di lettura",
"%d minuti di lettura"
],
"entry.external_link.label": "Link esterno",
"entry.save.completed": "Fatto!",
"entry.save.label": "Salva",
"entry.save.title": "Salva questo articolo",
"entry.save.toast.completed": "Articolo salvato",
"entry.scraper.completed": "Fatto!",
"entry.scraper.label": "Scarica",
"entry.scraper.title": "Scarica il contenuto integrale",
"entry.share.label": "Condividi",
"entry.share.title": "Condividi questo articolo",
"entry.shared_entry.label": "Condivisione",
"entry.shared_entry.title": "Apri il link pubblico",
"entry.state.loading": "Caricamento in corso...",
"entry.state.saving": "Salvataggio in corso...",
"entry.status.mark_as_read": "Segna come letto",
"entry.status.mark_as_unread": "Segna come non letto",
"entry.status.title": "Cambia lo stato dell'articolo",
"entry.status.toast.read": "Contrassegnato come letto",
"entry.status.toast.unread": "Contrassegnato come non letto",
"entry.tags.label": "Tag:",
"entry.tags.more_tags_label": [
"Mostra %d altro tag",
"Mostra %d altri tag"
],
"entry.unshare.label": "Rimuovi condivisione",
"error.api_key_already_exists": "Questa chiave API esiste già.",
"error.bad_credentials": "Nome utente o password non validi.",
"error.category_already_exists": "Questa categoria esiste già.",
"error.category_not_found": "Questa categoria non esiste o non appartiene a questo utente.",
"error.database_error": "Errore del database: %v.",
"error.different_passwords": "Le password non coincidono.",
"error.duplicate_fever_username": "Esiste già un account Fever con lo stesso nome utente!",
"error.duplicate_googlereader_username": "Esiste già un account Google Reader con lo stesso nome utente!",
"error.duplicate_linked_account": "Esiste già un account configurato per questo servizio!",
"error.duplicated_feed": "Questo feed esiste già.",
"error.empty_file": "Questo file è vuoto.",
"error.entries_per_page_invalid": "Il numero di articoli per pagina non è valido.",
"error.feed_already_exists": "Questo feed esiste già.",
"error.feed_category_not_found": "Questa categoria non esiste o non appartiene a questo utente.",
"error.feed_format_not_detected": "Impossibile rilevare il formato del feed: %v.",
"error.feed_invalid_blocklist_rule": "La regola dell'elenco di blocco non è valida.",
"error.feed_invalid_keeplist_rule": "La regola dell'elenco di conservazione non è valida.",
"error.feed_mandatory_fields": "L'URL e la categoria sono obbligatori.",
"error.feed_not_found": "Questo feed non esiste o non appartiene a questo utente.",
"error.feed_title_not_empty": "Il titolo del feed non può essere vuoto.",
"error.feed_url_not_empty": "L'URL del feed non può essere vuoto.",
"error.fields_mandatory": "Tutti i campi sono obbligatori.",
"error.http_bad_gateway": "Il sito web non è disponibile al momento a causa di un errore di gateway. Il problema non è dal lato di Miniflux. Per favore, riprova più tardi.",
"error.http_body_read": "Impossibile leggere il corpo HTTP: %v.",
"error.http_client_error": "Errore del client HTTP: %v.",
"error.http_empty_response": "La risposta HTTP è vuota. Forse questo sito web utilizza un meccanismo di protezione dai bot?",
"error.http_empty_response_body": "Il corpo della risposta HTTP è vuoto.",
"error.http_forbidden": "L'accesso a questo sito web è vietato. Forse questo sito web ha un meccanismo di protezione dai bot?",
"error.http_gateway_timeout": "Il sito web non è disponibile a causa di un timeout del gateway. Il problema non è lato Miniflux. Riprova più tardi.",
"error.http_internal_server_error": "Il sito web non è disponibile a causa di un errore del server. Il problema non è lato Miniflux. Riprova più tardi.",
"error.http_not_authorized": "L'accesso a questo sito web non è autorizzato. Potrebbero essere errati nome utente o password.",
"error.http_resource_not_found": "La risorsa richiesta non è stata trovata. Verifica l'URL.",
"error.http_response_too_large": "La risposta HTTP è troppo grande. Puoi aumentare il limite di dimensione nelle impostazioni globali (richiede il riavvio del server).",
"error.http_service_unavailable": "Il sito web non è disponibile a causa di un errore interno del server. Il problema non è lato Miniflux. Riprova più tardi.",
"error.http_too_many_requests": "Miniflux ha generato troppe richieste verso questo sito. Riprova più tardi o modifica la configurazione dell'applicazione.",
"error.http_unexpected_status_code": "Il sito web non è disponibile a causa di un codice di stato HTTP inatteso: %d. Il problema non è lato Miniflux. Riprova più tardi.",
"error.invalid_categories_sorting_order": "L'ordinamento delle categorie non è valido.",
"error.invalid_default_home_page": "Pagina iniziale predefinita non valida!",
"error.invalid_display_mode": "Modalità di visualizzazione web app non valida.",
"error.invalid_entry_direction": "Ordinamento non valido.",
"error.invalid_entry_order": "L'ordinamento delle voci non è valido.",
"error.invalid_feed_proxy_url": "URL del proxy non valido.",
"error.invalid_feed_url": "URL del feed non valido.",
"error.invalid_gesture_nav": "Navigazione gestuale non valida.",
"error.invalid_language": "Lingua non valida.",
"error.invalid_site_url": "URL del sito non valido.",
"error.invalid_theme": "Tema non valido.",
"error.invalid_timezone": "Fuso orario non valido.",
"error.network_operation": "Miniflux non riesce a raggiungere questo sito web a causa di un errore di rete: %v.",
"error.network_timeout": "Questo sito web è troppo lento e la richiesta è scaduta: %v",
"error.password_min_length": "La password deve contenere almeno 6 caratteri.",
"error.proxy_url_not_empty": "L'URL del proxy non può essere vuoto.",
"error.settings_block_rule_fieldname_invalid": "Regola di blocco non valida: la regola #%d non ha un nome di campo valido (opzioni: %s)",
"error.settings_block_rule_invalid_regex": "Regola di blocco non valida: il pattern della regola #%d non è una regex valida",
"error.settings_block_rule_regex_required": "Regola di blocco non valida: il pattern della regola #%d non è stato fornito",
"error.settings_block_rule_separator_required": "Regola di blocco non valida: il pattern della regola #%d deve essere separato da '='",
"error.settings_invalid_domain_list": "Elenco di domini non valido. Fornisci domini separati da spazi.",
"error.settings_keep_rule_fieldname_invalid": "Regola di mantenimento non valida: la regola #%d non ha un nome di campo valido (opzioni: %s)",
"error.settings_keep_rule_invalid_regex": "Regola di mantenimento non valida: il pattern della regola #%d non è una regex valida",
"error.settings_keep_rule_regex_required": "Regola di mantenimento non valida: il pattern della regola #%d non è stato fornito",
"error.settings_keep_rule_separator_required": "Regola di mantenimento non valida: il pattern della regola #%d deve essere separato da '='",
"error.settings_mandatory_fields": "Il nome utente, il tema, la lingua ed il fuso orario sono campi obbligatori.",
"error.settings_media_playback_rate_range": "La velocità di riproduzione non rientra nell'intervallo",
"error.settings_reading_speed_is_positive": "Le velocità di lettura devono essere numeri interi positivi.",
"error.site_url_not_empty": "L'URL del sito non può essere vuoto.",
"error.subscription_not_found": "Non ho trovato nessun feed.",
"error.title_required": "Il titolo è obbligatorio.",
"error.tls_error": "Errore TLS: %q. Puoi disabilitare la verifica TLS nelle impostazioni del feed se preferisci.",
"error.unable_to_create_api_key": "Impossibile creare questa chiave API.",
"error.unable_to_create_category": "Non sono riuscito ad aggiungere questa categoria.",
"error.unable_to_create_user": "Non sono riuscito ad aggiungere questo user.",
"error.unable_to_detect_rssbridge": "Impossibile rilevare il feed usando RSS-Bridge: %v.",
"error.unable_to_parse_feed": "Impossibile analizzare questo feed: %v.",
"error.unable_to_update_category": "Non sono riuscito ad aggiornare questa categoria.",
"error.unable_to_update_feed": "Non sono riuscito ad aggiornare questo feed.",
"error.unable_to_update_user": "Non sono riuscito ad aggiornare questo utente.",
"error.unlink_account_without_password": "Devi scegliere una password altrimenti la prossima volta non riuscirai ad accedere.",
"error.user_already_exists": "Questo utente esiste già.",
"error.user_mandatory_fields": "Il nome utente è obbligatorio.",
"error.linktaco_missing_required_fields": "LinkTaco API Token e Organization Slug sono richiesti",
"form.api_key.label.description": "Etichetta chiave API",
"form.category.hide_globally": "Nascondere le voci nella lista globale dei non letti",
"form.category.label.title": "Titolo",
"form.feed.fieldset.general": "Generale",
"form.feed.fieldset.integration": "Servizi di terze parti",
"form.feed.fieldset.network_settings": "Impostazioni di rete",
"form.feed.fieldset.rules": "Regole",
"form.feed.label.allow_self_signed_certificates": "Consenti certificati autofirmati o non validi",
"form.feed.label.apprise_service_urls": "Elenco di URL di servizi Apprise separati da virgola",
"form.feed.label.block_filter_entry_rules": "Regole di Blocco delle Voci",
"form.feed.label.blocklist_rules": "Filtri di Blocco Basati su Regex",
"form.feed.label.category": "Categoria",
"form.feed.label.cookie": "Installare i cookies",
"form.feed.label.crawler": "Scarica il contenuto integrale",
"form.feed.label.description": "Descrizione",
"form.feed.label.disable_http2": "Disabilita HTTP/2 per evitare il fingerprinting",
"form.feed.label.disabled": "Non aggiornare questo feed",
"form.feed.label.feed_password": "Password del feed",
"form.feed.label.feed_url": "URL del feed",
"form.feed.label.feed_username": "Nome utente del feed",
"form.feed.label.fetch_via_proxy": "Usa il proxy configurato a livello di applicazione",
"form.feed.label.hide_globally": "Nascondere le voci nella lista globale dei non letti",
"form.feed.label.ignore_http_cache": "Ignora cache HTTP",
"form.feed.label.keep_filter_entry_rules": "Regole di Permesso delle Voci",
"form.feed.label.keeplist_rules": "Filtri di Mantenimento Basati su Regex",
"form.feed.label.no_media_player": "Nessun lettore multimediale (audio/video)",
"form.feed.label.ntfy_activate": "Invia le voci a ntfy",
"form.feed.label.ntfy_default_priority": "Priorità predefinita ntfy",
"form.feed.label.ntfy_high_priority": "Priorità alta ntfy",
"form.feed.label.ntfy_low_priority": "Priorità bassa ntfy",
"form.feed.label.ntfy_max_priority": "Priorità massima ntfy",
"form.feed.label.ntfy_min_priority": "Priorità minima ntfy",
"form.feed.label.ntfy_priority": "Priorità ntfy",
"form.feed.label.ntfy_topic": "Topic ntfy (opzionale)",
"form.feed.label.proxy_url": "URL del proxy",
"form.feed.label.pushover_activate": "Invia le voci a pushover.net",
"form.feed.label.pushover_default_priority": "Priorità predefinita Pushover",
"form.feed.label.pushover_high_priority": "Priorità alta Pushover",
"form.feed.label.pushover_low_priority": "Priorità bassa Pushover",
"form.feed.label.pushover_max_priority": "Priorità massima Pushover",
"form.feed.label.pushover_min_priority": "Priorità minima Pushover",
"form.feed.label.pushover_priority": "Priorità del messaggio Pushover",
"form.feed.label.rewrite_rules": "Regole di Riscrittura del Contenuto",
"form.feed.label.scraper_rules": "Regole di estrazione del contenuto",
"form.feed.label.site_url": "URL del sito",
"form.feed.label.title": "Titolo",
"form.feed.label.urlrewrite_rules": "Regole di riscrittura URL",
"form.feed.label.user_agent": "Usa user agent personalizzato",
"form.feed.label.webhook_url": "Sovrascrivi l'URL del webhook",
"form.import.label.file": "File OPML",
"form.import.label.url": "URL",
"form.integration.archiveorg_activate": "Invia le voci ad archive.org",
"form.integration.apprise_activate": "Invia le voci ad Apprise",
"form.integration.apprise_services_url": "Elenco di URL di servizi Apprise separati da virgole",
"form.integration.apprise_url": "URL API di Apprise",
"form.integration.betula_activate": "Salva le voci in Betula",
"form.integration.betula_token": "Token Betula",
"form.integration.betula_url": "URL del server Betula",
"form.integration.cubox_activate": "Salva le voci in Cubox",
"form.integration.cubox_api_link": "Link API di Cubox",
"form.integration.discord_activate": "Invia le voci a Discord",
"form.integration.discord_webhook_link": "Link webhook di Discord",
"form.integration.espial_activate": "Salva gli articoli su Espial",
"form.integration.espial_api_key": "API key dell'account Espial",
"form.integration.espial_endpoint": "Endpoint dell'API di Espial",
"form.integration.espial_tags": "Tag di Espial",
"form.integration.fever_activate": "Abilita l'API di Fever",
"form.integration.fever_endpoint": "Endpoint dell'API di Fever:",
"form.integration.fever_password": "Password dell'account Fever",
"form.integration.fever_username": "Nome utente dell'account Fever",
"form.integration.googlereader_activate": "Abilita l'API di Google Reader",
"form.integration.googlereader_endpoint": "Endpoint dell'API di Google Reader:",
"form.integration.googlereader_password": "Password dell'account Google Reader",
"form.integration.googlereader_username": "Nome utente dell'account Google Reader",
"form.integration.instapaper_activate": "Salva gli articoli su Instapaper",
"form.integration.instapaper_password": "Password dell'account Instapaper",
"form.integration.instapaper_username": "Nome utente dell'account Instapaper",
"form.integration.karakeep_activate": "Salva gli articoli su Karakeep",
"form.integration.karakeep_api_key": "API key dell'account Karakeep",
"form.integration.karakeep_url": "Endpoint dell'API di Karakeep",
"form.integration.karakeep_tags": "Etichette Karakeep",
"form.integration.linkace_activate": "Salva le voci su LinkAce",
"form.integration.linkace_api_key": "Chiave API di LinkAce",
"form.integration.linkace_check_disabled": "Disabilita il controllo dei link",
"form.integration.linkace_endpoint": "Endpoint API di LinkAce",
"form.integration.linkace_is_private": "Segna il link come privato",
"form.integration.linkace_tags": "Tag di LinkAce",
"form.integration.linkding_activate": "Salva gli articoli su Linkding",
"form.integration.linkding_api_key": "API key dell'account Linkding",
"form.integration.linkding_bookmark": "Segna i preferiti come non letti",
"form.integration.linkding_endpoint": "Endpoint dell'API di Linkding",
"form.integration.linkding_tags": "Tag di Linkding",
"form.integration.linktaco_activate": "Salva le voci in LinkTaco",
"form.integration.linktaco_api_token": "Token API di LinkTaco",
"form.integration.linktaco_api_token_hint": "Ottieni il tuo token di accesso personale su",
"form.integration.linktaco_org_slug": "Slug dell'organizzazione",
"form.integration.linktaco_tags": "Tag (massimo 10, separati da virgola)",
"form.integration.linktaco_tags_hint": "Massimo 10 tag, separati da virgola",
"form.integration.linktaco_visibility": "Visibilità",
"form.integration.linktaco_visibility_public": "Pubblico",
"form.integration.linktaco_visibility_private": "Privato",
"form.integration.linktaco_visibility_hint": "La visibilità PRIVATA richiede un account LinkTaco a pagamento",
"form.integration.linkwarden_activate": "Salva le voci su Linkwarden",
"form.integration.linkwarden_api_key": "Chiave API di Linkwarden",
"form.integration.linkwarden_endpoint": "URL di base di Linkwarden",
"form.integration.linkwarden_collection_id": "ID collezione Linkwarden",
"form.integration.matrix_bot_activate": "Trasferimento di nuovi articoli a Matrix",
"form.integration.matrix_bot_chat_id": "ID della stanza Matrix",
"form.integration.matrix_bot_password": "Password per l'utente Matrix",
"form.integration.matrix_bot_url": "URL del server Matrix",
"form.integration.matrix_bot_user": "Nome utente per Matrix",
"form.integration.notion_activate": "Salva le voci in Notion",
"form.integration.notion_page_id": "ID pagina Notion",
"form.integration.notion_token": "Token segreto Notion",
"form.integration.ntfy_activate": "Invia le voci a ntfy",
"form.integration.ntfy_api_token": "Token API ntfy (opzionale)",
"form.integration.ntfy_icon_url": "URL icona ntfy (opzionale)",
"form.integration.ntfy_internal_links": "Usa link interni al clic (opzionale)",
"form.integration.ntfy_password": "Password ntfy (opzionale)",
"form.integration.ntfy_topic": "Topic ntfy (predefinito se non impostato nel feed)",
"form.integration.ntfy_url": "URL ntfy (opzionale, predefinito ntfy.sh)",
"form.integration.ntfy_username": "Username ntfy (opzionale)",
"form.integration.nunux_keeper_activate": "Salva gli articoli su Nunux Keeper",
"form.integration.nunux_keeper_api_key": "API key dell'account Nunux Keeper",
"form.integration.nunux_keeper_endpoint": "Endpoint dell'API di Nunux Keeper",
"form.integration.omnivore_activate": "Salva gli articoli su Omnivore",
"form.integration.omnivore_api_key": "API key dell'account Omnivore",
"form.integration.omnivore_url": "Endpoint dell'API di Omnivore",
"form.integration.pinboard_activate": "Salva gli articoli su Pinboard",
"form.integration.pinboard_bookmark": "Segna i preferiti come non letti",
"form.integration.pinboard_tags": "Tag di Pinboard",
"form.integration.pinboard_token": "Token dell'API di Pinboard",
"form.integration.pushover_activate": "Invia le voci a Pushover",
"form.integration.pushover_device": "Dispositivo Pushover (opzionale)",
"form.integration.pushover_prefix": "Prefisso URL Pushover (opzionale)",
"form.integration.pushover_token": "Token API dell'app Pushover",
"form.integration.pushover_user": "Chiave utente Pushover",
"form.integration.raindrop_activate": "Salva le voci su Raindrop",
"form.integration.raindrop_collection_id": "ID collezione",
"form.integration.raindrop_tags": "Tag (separati da virgola)",
"form.integration.raindrop_token": "(Test) token",
"form.integration.readeck_activate": "Salva gli articoli su Readeck",
"form.integration.readeck_api_key": "API key dell'account Readeck",
"form.integration.readeck_endpoint": "Endpoint dell'API di Readeck",
"form.integration.readeck_labels": "Etichette Readeck",
"form.integration.readeck_only_url": "Invia solo URL (invece del contenuto completo)",
"form.integration.readeck_push_activate": "Invia automaticamente le nuove voci a Readeck",
"form.integration.readwise_activate": "Salva le voci in Readwise Reader",
"form.integration.readwise_api_key": "Token di accesso a Readwise Reader",
"form.integration.readwise_api_key_link": "Ottieni il tuo token di accesso Readwise",
"form.integration.rssbridge_activate": "Controlla RSS-Bridge quando aggiungi sottoscrizioni",
"form.integration.rssbridge_token": "Token di autenticazione RSS-Bridge",
"form.integration.rssbridge_url": "URL del server RSS-Bridge",
"form.integration.shaarli_activate": "Salva gli articoli su Shaarli",
"form.integration.shaarli_api_secret": "Segreto API di Shaarli",
"form.integration.shaarli_endpoint": "URL di Shaarli",
"form.integration.shiori_activate": "Salva gli articoli su Shiori",
"form.integration.shiori_endpoint": "Endpoint dell'API di Shiori",
"form.integration.shiori_password": "Password dell'account Shiori",
"form.integration.shiori_username": "Nome utente dell'account Shiori",
"form.integration.slack_activate": "Invia le voci a Slack",
"form.integration.slack_webhook_link": "Link webhook di Slack",
"form.integration.telegram_bot_activate": "Invia nuovi articoli alla chat di Telegram",
"form.integration.telegram_bot_disable_buttons": "Disabilita i pulsanti",
"form.integration.telegram_bot_disable_notification": "Disabilita le notifiche",
"form.integration.telegram_bot_disable_web_page_preview": "Disabilita l'anteprima delle pagine",
"form.integration.telegram_bot_token": "Token bot",
"form.integration.telegram_chat_id": "ID chat",
"form.integration.telegram_topic_id": "ID argomento",
"form.integration.wallabag_activate": "Salva gli articoli su Wallabag",
"form.integration.wallabag_client_id": "Client ID dell'account Wallabag",
"form.integration.wallabag_client_secret": "Client secret dell'account Wallabag",
"form.integration.wallabag_endpoint": "URL di base di Wallabagg",
"form.integration.wallabag_only_url": "Invia solo URL (invece del contenuto completo)",
"form.integration.wallabag_password": "Password dell'account Wallabag",
"form.integration.wallabag_username": "Nome utente dell'account Wallabag",
"form.integration.wallabag_tags": "Tag di Wallabag",
"form.integration.webhook_activate": "Abilita i webhook",
"form.integration.webhook_secret": "Segreto dei webhook",
"form.integration.webhook_url": "URL webhook predefinito",
"form.prefs.fieldset.application_settings": "Impostazioni applicazione",
"form.prefs.fieldset.authentication_settings": "Impostazioni di autenticazione",
"form.prefs.fieldset.global_feed_settings": "Impostazioni globali dei feed",
"form.prefs.fieldset.reader_settings": "Impostazioni del lettore",
"form.prefs.help.external_font_hosts": "Elenco, separato da spazi, degli host di font esterni consentiti. Ad esempio: \"fonts.gstatic.com fonts.googleapis.com\".",
"form.prefs.label.always_open_external_links": "Leggi gli articoli aprendo i link esterni",
"form.prefs.label.categories_sorting_order": "Ordinamento delle categorie",
"form.prefs.label.cjk_reading_speed": "Velocità di lettura per cinese, coreano e giapponese (caratteri al minuto)",
"form.prefs.label.custom_css": "CSS personalizzati",
"form.prefs.label.custom_js": "JavaScript personalizzati",
"form.prefs.label.default_home_page": "Pagina iniziale predefinita",
"form.prefs.label.default_reading_speed": "Velocità di lettura di altre lingue (parole al minuto)",
"form.prefs.label.display_mode": "Modalità di visualizzazione dell'app Web progressiva (PWA).",
"form.prefs.label.entries_per_page": "Articoli per pagina",
"form.prefs.label.entry_order": "Colonna di ordinamento delle voci",
"form.prefs.label.entry_sorting": "Ordinamento articoli",
"form.prefs.label.entry_swipe": "Abilita lo scorrimento della voce sui touch screen",
"form.prefs.label.external_font_hosts": "Host di font esterni",
"form.prefs.label.gesture_nav": "Gesto per navigare tra le voci",
"form.prefs.label.keyboard_shortcuts": "Abilita le scorciatoie da tastiera",
"form.prefs.label.language": "Lingua",
"form.prefs.label.mark_read_manually": "Contrassegna manualmente le voci come lette",
"form.prefs.label.mark_read_on_media_completion": "Segna come letto solo quando audio/video raggiunge il 90%%",
"form.prefs.label.mark_read_on_view": "Contrassegna automaticamente le voci come lette quando visualizzate",
"form.prefs.label.mark_read_on_view_or_media_completion": "Segna le voci lette alla visualizzazione; per audio/video al 90%%",
"form.prefs.label.media_playback_rate": "Velocità di riproduzione dell'audio/video",
"form.prefs.label.open_external_links_in_new_tab": "Apri i link esterni in una nuova scheda (aggiunge target=\"_blank\" ai link)",
"form.prefs.label.show_reading_time": "Mostra il tempo di lettura stimato per gli articoli",
"form.prefs.label.theme": "Tema",
"form.prefs.label.timezone": "Fuso orario",
"form.prefs.select.alphabetical": "In ordine alfabetico",
"form.prefs.select.browser": "Browser",
"form.prefs.select.created_time": "Tempo di creazione dell'entrata",
"form.prefs.select.fullscreen": "Schermo intero",
"form.prefs.select.minimal_ui": "Minimale",
"form.prefs.select.none": "Nessuno",
"form.prefs.select.older_first": "Prima i più vecchi",
"form.prefs.select.publish_time": "Ora di pubblicazione dell'entrata",
"form.prefs.select.recent_first": "Prima i più recenti",
"form.prefs.select.standalone": "Autonoma",
"form.prefs.select.swipe": "Scorri",
"form.prefs.select.tap": "Tocca due volte",
"form.prefs.select.unread_count": "Conteggio dei non letti",
"form.submit.loading": "Caricamento in corso...",
"form.submit.saving": "Salvataggio in corso...",
"form.user.label.admin": "Amministratore",
"form.user.label.confirmation": "Conferma password",
"form.user.label.password": "Parola d'accesso",
"form.user.label.username": "Nome utente",
"menu.about": "Informazioni",
"menu.add_feed": "Aggiungi feed",
"menu.add_user": "Aggiungi utente",
"menu.api_keys": "Chiavi API",
"menu.categories": "Categorie",
"menu.create_api_key": "Crea una nuova chiave API",
"menu.create_category": "Aggiungi una categoria",
"menu.edit_category": "Modifica",
"menu.edit_feed": "Modifica",
"menu.export": "Esporta",
"menu.feed_entries": "Articoli",
"menu.feeds": "Feed",
"menu.flush_history": "Svuota la cronologia",
"menu.history": "Cronologia",
"menu.home_page": "Pagina iniziale",
"menu.import": "Importa",
"menu.integrations": "Integrazioni",
"menu.logout": "Esci",
"menu.mark_all_as_read": "Segna tutti gli articoli come letti",
"menu.mark_page_as_read": "Segna questa pagina come letta",
"menu.preferences": "Preferenze",
"menu.refresh_all_feeds": "Aggiorna tutti i feed in background",
"menu.refresh_feed": "Aggiorna",
"menu.search": "Cerca",
"menu.sessions": "Sessioni",
"menu.settings": "Impostazioni",
"menu.shared_entries": "Voci condivise",
"menu.show_all_entries": "Mostra tutte le voci",
"menu.show_only_starred_entries": "Mostra solo voci preferiti",
"menu.show_only_unread_entries": "Mostra solo voci non lette",
"menu.starred": "Preferiti",
"menu.title": "Menù",
"menu.unread": "Da leggere",
"menu.users": "Utenti",
"page.about.author": "Autore:",
"page.about.build_date": "Data della build:",
"page.about.credits": "Crediti",
"page.about.db_usage": "Dimensione del database:",
"page.about.git_commit": "Commit Git:",
"page.about.global_config_options": "Opzioni di configurazione globali",
"page.about.go_version": "Go versione:",
"page.about.license": "Licenza:",
"page.about.postgres_version": "Postgres versione:",
"page.about.title": "Informazioni",
"page.about.version": "Versione:",
"page.add_feed.choose_feed": "Scegli un feed",
"page.add_feed.label.url": "URL",
"page.add_feed.legend.advanced_options": "Opzioni avanzate",
"page.add_feed.no_category": "Nessuna categoria selezionata. Devi scegliere almeno una categoria.",
"page.add_feed.submit": "Abbonati al feed",
"page.add_feed.title": "Nuovo feed",
"page.api_keys.never_used": "Mai usato",
"page.api_keys.table.actions": "Azioni",
"page.api_keys.table.created_at": "Data di creazione",
"page.api_keys.table.description": "Descrizione",
"page.api_keys.table.last_used_at": "Ultimo uso",
"page.api_keys.table.token": "Gettone",
"page.api_keys.title": "Chiavi API",
"page.categories.entries": "Articoli",
"page.categories.feed_count": [
"C'è %d feed.",
"Ci sono %d feed."
],
"page.categories.feeds": "Abbonamenti",
"page.categories.no_feed": "Nessun feed.",
"page.categories.title": "Categorie",
"page.categories_count": [
"%d categoria",
"%d categorie"
],
"page.category_label": "Categoria: %s",
"page.edit_category.title": "Modifica categoria: %s",
"page.edit_feed.etag_header": "Header ETag:",
"page.edit_feed.last_check": "Ultimo controllo:",
"page.edit_feed.last_modified_header": "Header LastModified:",
"page.edit_feed.last_parsing_error": "Ultimo errore di parsing",
"page.edit_feed.no_header": "Nessun header",
"page.edit_feed.title": "Modifica feed: %s",
"page.edit_user.title": "Modifica utente: %s",
"page.entry.attachments": "Allegati",
"page.feeds.error_count": [
"%d errore",
"%d errori"
],
"page.feeds.last_check": "Ultimo controllo:",
"page.feeds.next_check": "Prossimo controllo:",
"page.feeds.read_counter": "Numero di voci lette",
"page.feeds.title": "Feed",
"page.footer.elevator": "Torna su",
"page.history.title": "Cronologia",
"page.import.title": "Importa",
"page.integration.bookmarklet": "Segnalibro",
"page.integration.bookmarklet.help": "Questo collegamento speciale ti consente di abbonarti ad un sito web semplicemente usando un segnalibro del tuo browser.",
"page.integration.bookmarklet.instructions": "Trascina questo collegamento sui tuoi segnalibri.",
"page.integration.bookmarklet.name": "Aggiungi a Miniflux",
"page.integration.miniflux_api": "API di Miniflux",
"page.integration.miniflux_api_endpoint": "Endpoint dell'API di Miniflux",
"page.integration.miniflux_api_password": "Password dell'API",
"page.integration.miniflux_api_password_value": "La password del tuo account",
"page.integration.miniflux_api_username": "Nome utente",
"page.integrations.title": "Integrazioni",
"page.keyboard_shortcuts.close_modal": "Chiudi la finestra di dialogo",
"page.keyboard_shortcuts.download_content": "Scarica il contenuto integrale",
"page.keyboard_shortcuts.go_to_bottom_item": "Vai all'elemento in fondo",
"page.keyboard_shortcuts.go_to_categories": "Mostra le categorie",
"page.keyboard_shortcuts.go_to_feed": "Mostra il feed",
"page.keyboard_shortcuts.go_to_feeds": "Mostra i feed",
"page.keyboard_shortcuts.go_to_history": "Mostra la cronologia",
"page.keyboard_shortcuts.go_to_next_item": "Mostra l'articolo successivo",
"page.keyboard_shortcuts.go_to_next_page": "Mostra la pagina successiva",
"page.keyboard_shortcuts.go_to_previous_item": "Mostra l'articolo precedente",
"page.keyboard_shortcuts.go_to_previous_page": "Mostra la pagina precedente",
"page.keyboard_shortcuts.go_to_search": "Apri la casella di ricerca",
"page.keyboard_shortcuts.go_to_settings": "Mostra le impostazioni",
"page.keyboard_shortcuts.go_to_starred": "Mostra i preferiti",
"page.keyboard_shortcuts.go_to_top_item": "Vai all'elemento principale",
"page.keyboard_shortcuts.go_to_unread": "Mostra gli articoli da leggere",
"page.keyboard_shortcuts.mark_page_as_read": "Segna la pagina attuale come letta",
"page.keyboard_shortcuts.open_comments": "Apri la pagina web dei commenti",
"page.keyboard_shortcuts.open_comments_same_window": "Apri il link dei commenti nella scheda corrente",
"page.keyboard_shortcuts.open_item": "Apri l'articolo selezionato",
"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.refresh_all_feeds": "Aggiorna tutti i feed in background",
"page.keyboard_shortcuts.remove_feed": "Rimuovi questo feed",
"page.keyboard_shortcuts.save_article": "Salva l'articolo",
"page.keyboard_shortcuts.scroll_item_to_top": "Scorri l'articolo in alto",
"page.keyboard_shortcuts.show_keyboard_shortcuts": "Mostra le scorciatoie da tastiera",
"page.keyboard_shortcuts.subtitle.actions": "Azioni",
"page.keyboard_shortcuts.subtitle.items": "Navigazione articoli",
"page.keyboard_shortcuts.subtitle.pages": "Navigazione pagine",
"page.keyboard_shortcuts.subtitle.sections": "Navigazione sezioni",
"page.keyboard_shortcuts.title": "Scorciatoie da tastiera",
"page.keyboard_shortcuts.toggle_star_status": "Aggiungi/rimuovi dai preferiti",
"page.keyboard_shortcuts.toggle_entry_attachments": "Apri/chiudi gli allegati dell'articolo",
"page.keyboard_shortcuts.toggle_read_status_next": "Cambia lo stato di lettura (letto/da leggere), concentrati dopo",
"page.keyboard_shortcuts.toggle_read_status_prev": "Cambia lo stato di lettura (letto/da leggere), focus precedente",
"page.login.google_signin": "Accedi tramite Google",
"page.login.oidc_signin": "Accedi tramite %s",
"page.login.title": "Accedi",
"page.login.webauthn_login": "Accedi con passkey",
"page.login.webauthn_login.error": "Impossibile accedere con passkey",
"page.login.webauthn_login.help": "Inserisci il tuo nome utente se stai usando una chiave di sicurezza. Non è necessario con una Passkey (credenziali rilevabili).",
"page.new_api_key.title": "Nuova chiave API",
"page.new_category.title": "Nuova categoria",
"page.new_user.title": "Nuovo utente",
"page.offline.message": "Sei offline",
"page.offline.refresh_page": "Prova ad aggiornare la pagina",
"page.offline.title": "Modalità offline",
"page.read_entry_count": [
"%d voce letta",
"%d voci lette"
],
"page.search.title": "Risultati della ricerca",
"page.sessions.table.actions": "Azioni",
"page.sessions.table.current_session": "Sessione corrente",
"page.sessions.table.date": "Data",
"page.sessions.table.ip": "Indirizzo IP",
"page.sessions.table.user_agent": "User agent",
"page.sessions.title": "Sessioni",
"page.settings.link_google_account": "Collega il mio account Google",
"page.settings.link_oidc_account": "Collega il mio account %s",
"page.settings.title": "Impostazioni",
"page.settings.unlink_google_account": "Scollega il mio account Google",
"page.settings.unlink_oidc_account": "Scollega il mio account %s",
"page.settings.webauthn.actions": "Azioni",
"page.settings.webauthn.added_on": "Aggiunta il",
"page.settings.webauthn.delete": [
"Rimuovi %d passkey",
"Rimuovi %d passkey"
],
"page.settings.webauthn.last_seen_on": "Ultimo uso",
"page.settings.webauthn.passkey_name": "Nome passkey",
"page.settings.webauthn.passkeys": "Passkey",
"page.settings.webauthn.register": "Registra la chiave di accesso",
"page.settings.webauthn.register.error": "Impossibile registrare la passkey",
"page.shared_entries.title": "Voci condivise",
"page.shared_entries_count": [
"%d voce condivisa",
"%d voci condivise"
],
"page.starred.title": "Preferiti",
"page.starred_entry_count": [
"%d voce preferita",
"%d voci preferite"
],
"page.total_entry_count": [
"%d voce in totale",
"%d voci in totale"
],
"page.unread.title": "Da leggere",
"page.unread_entry_count": [
"%d voce non letta",
"%d voci non lette"
],
"page.users.actions": "Azioni",
"page.users.admin.no": "Niente",
"page.users.admin.yes": "Sì",
"page.users.is_admin": "Amministratore",
"page.users.last_login": "Ultimo accesso",
"page.users.never_logged": "Mai",
"page.users.title": "Utenti",
"page.users.username": "Nome utente",
"page.webauthn_rename.title": "Rinomina passkey",
"pagination.first": "Primo",
"pagination.last": "Ultimo",
"pagination.next": "Successivo",
"pagination.previous": "Precedente",
"search.label": "Cerca",
"search.placeholder": "Cerca...",
"search.submit": "Cerca",
"skip_to_content": "Salta al contenuto",
"time_elapsed.days": [
"%d giorno fa",
"%d giorni fa"
],
"time_elapsed.hours": [
"%d ora fa",
"%d ore fa"
],
"time_elapsed.minutes": [
"%d minuto fa",
"%d minuti fa"
],
"time_elapsed.months": [
"%d mese fa",
"%d mesi fa"
],
"time_elapsed.not_yet": "non ancora",
"time_elapsed.now": "adesso",
"time_elapsed.weeks": [
"%d settimana fa",
"%d settimane fa"
],
"time_elapsed.years": [
"%d anno fa",
"%d anni fa"
],
"time_elapsed.yesterday": "ieri",
"tooltip.keyboard_shortcuts": "Scorciatoia da tastiera: %s",
"tooltip.logged_user": "Autenticato come %s"
}
v2-2.2.16/internal/locale/translations/ja_JP.json 0000664 0000000 0000000 00000122625 15127074645 0021626 0 ustar 00root root 0000000 0000000 {
"action.cancel": "取り消し",
"action.download": "ダウンロード",
"action.edit": "編集",
"action.home_screen": "ホームスクリーンに追加",
"action.import": "インポート",
"action.login": "ログイン",
"action.or": "または",
"action.remove": "削除",
"action.remove_feed": "このフィードを削除",
"action.save": "保存",
"action.subscribe": "フィードを購読",
"action.update": "更新",
"alert.account_linked": "外部アカウントとリンクされました!",
"alert.account_unlinked": "外部アカウントとのリンクが解除されました!",
"alert.background_feed_refresh": "すべてのフィードがバックグラウンドで更新されています。この処理中も Miniflux を使い続けることができます。",
"alert.feed_error": "このフィードには問題があります。",
"alert.no_starred": "現在星付きはありません。",
"alert.no_category": "カテゴリが存在しません。",
"alert.no_category_entry": "このカテゴリには記事がありません。",
"alert.no_feed": "何も購読していません。",
"alert.no_feed_entry": "このフィードには記事がありません。",
"alert.no_feed_in_category": "このカテゴリには購読中のフィードがありません。",
"alert.no_history": "現在履歴はありません。",
"alert.no_search_result": "検索で何も見つかりませんでした。",
"alert.no_shared_entry": "共有エントリはありません。",
"alert.no_tag_entry": "このタグに一致するエントリーはありません。",
"alert.no_unread_entry": "未読の記事はありません。",
"alert.no_user": "あなたが唯一のユーザーです。",
"alert.prefs_saved": "設定情報は保存されました!",
"alert.too_many_feeds_refresh": [
"フィードの更新を要求しすぎました。%d 分後に再度お試しください。"
],
"confirm.loading": "実行中…",
"confirm.no": "いいえ",
"confirm.question": "よろしいですか?",
"confirm.question.refresh": "強制的に更新しますか?",
"confirm.yes": "はい",
"enclosure_media_controls.seek": "シーク:",
"enclosure_media_controls.seek.title": "%s 秒シーク",
"enclosure_media_controls.speed": "速度:",
"enclosure_media_controls.speed.faster": "速く",
"enclosure_media_controls.speed.faster.title": "%sx 速く",
"enclosure_media_controls.speed.reset": "リセット",
"enclosure_media_controls.speed.reset.title": "速度を1xにリセット",
"enclosure_media_controls.speed.slower": "遅く",
"enclosure_media_controls.speed.slower.title": "%sx 遅く",
"entry.starred.toast.off": "星を外しました",
"entry.starred.toast.on": "星を付けました",
"entry.starred.toggle.off": "星を外す",
"entry.starred.toggle.on": "星を付ける",
"entry.comments.label": "コメント",
"entry.comments.title": "コメントを見る",
"entry.estimated_reading_time": [
"%d 分で読めます"
],
"entry.external_link.label": "外部リンク",
"entry.save.completed": "完了!",
"entry.save.label": "保存",
"entry.save.title": "この記事を保存",
"entry.save.toast.completed": "記事は保存されました",
"entry.scraper.completed": "完了!",
"entry.scraper.label": "ダウンロード",
"entry.scraper.title": "オリジナルの内容を取得",
"entry.share.label": "共有",
"entry.share.title": "この記事を共有する",
"entry.shared_entry.label": "共有する",
"entry.shared_entry.title": "公開リンクを開く",
"entry.state.loading": "読み込み中…",
"entry.state.saving": "保存中…",
"entry.status.mark_as_read": "既読にする",
"entry.status.mark_as_unread": "未読に戻す",
"entry.status.title": "記事の状態を変更",
"entry.status.toast.read": "既読にしました",
"entry.status.toast.unread": "未読にしました",
"entry.tags.label": "タグ:",
"entry.tags.more_tags_label": [
"%d 個のタグ"
],
"entry.unshare.label": "共有を解除",
"error.api_key_already_exists": "この API キーは既に存在します。",
"error.bad_credentials": "ユーザー名かパスワードが間違っています。",
"error.category_already_exists": "このカテゴリは既に存在します。",
"error.category_not_found": "このカテゴリは存在しないか、このユーザーに属していません。",
"error.database_error": "データベースエラー: %v。",
"error.different_passwords": "パスワードが一致しません。",
"error.duplicate_fever_username": "既に同じ名前の Fever ユーザー名が使われています!",
"error.duplicate_googlereader_username": "既に同じ名前の Google Reader ユーザー名が使われています!",
"error.duplicate_linked_account": "別なユーザーが既にこのサービスの同じユーザーとリンクしています。",
"error.duplicated_feed": "このフィードは既に存在します。",
"error.empty_file": "このファイルは空です。",
"error.entries_per_page_invalid": "ページあたりの記事数が無効です。",
"error.feed_already_exists": "このフィードは既に存在します。",
"error.feed_category_not_found": "このカテゴリは存在しないか、このユーザーに属していません。",
"error.feed_format_not_detected": "フィードの形式を検出できません: %v.",
"error.feed_invalid_blocklist_rule": "ブロックリストルールが無効です。",
"error.feed_invalid_keeplist_rule": "リストの保持ルールが無効です。",
"error.feed_mandatory_fields": "URL と カテゴリが必要です。",
"error.feed_not_found": "このフィードは存在しないか、このユーザーに属していません。",
"error.feed_title_not_empty": "フィードのタイトルを空にすることはできません。",
"error.feed_url_not_empty": "フィード URL を空にすることはできません。",
"error.fields_mandatory": "すべての項目が必要です。",
"error.http_bad_gateway": "ウェブサイトは、不正なゲートウェイエラーのため現在利用できません。問題はMiniflux側にはありません。後でもう一度お試しください。",
"error.http_body_read": "HTTP本文を読み取れません: %v。",
"error.http_client_error": "HTTPクライアントエラー: %v。",
"error.http_empty_response": "HTTP応答が空です。おそらく、このウェブサイトはボット保護メカニズムを使用していますか?",
"error.http_empty_response_body": "HTTP応答本文が空です。",
"error.http_forbidden": "このウェブサイトへのアクセスは禁止されています。おそらく、このウェブサイトはボット保護メカニズムを持っていますか?",
"error.http_gateway_timeout": "ゲートウェイタイムアウトのため現在このウェブサイトは利用できません。問題は Miniflux 側にはありません。しばらくしてから再度お試しください。",
"error.http_internal_server_error": "サーバーエラーのため現在このウェブサイトは利用できません。問題は Miniflux 側にはありません。しばらくしてから再度お試しください。",
"error.http_not_authorized": "このウェブサイトへのアクセスが許可されていません。ユーザー名またはパスワードが正しくない可能性があります。",
"error.http_resource_not_found": "要求されたリソースが見つかりません。URL を確認してください。",
"error.http_response_too_large": "HTTP 応答が大きすぎます。グローバル設定で HTTP 応答サイズの上限を引き上げることができます(サーバー再起動が必要)。",
"error.http_service_unavailable": "内部サーバーエラーのため現在このウェブサイトは利用できません。問題は Miniflux 側にはありません。しばらくしてから再度お試しください。",
"error.http_too_many_requests": "Miniflux がこのウェブサイトに対してリクエストを送りすぎました。しばらく待つか、アプリケーション設定を変更してください。",
"error.http_unexpected_status_code": "予期しない HTTP ステータスコード (%d) により現在このウェブサイトは利用できません。問題は Miniflux 側にはありません。しばらくしてから再度お試しください。",
"error.invalid_categories_sorting_order": "カテゴリの表示順が無効です。",
"error.invalid_default_home_page": "デフォルトのトップページが無効です",
"error.invalid_display_mode": "Web アプリの表示モードが無効です。",
"error.invalid_entry_direction": "記事の表示順が無効です。",
"error.invalid_entry_order": "記事の表示順が無効です。",
"error.invalid_feed_proxy_url": "プロキシURLが無効です。",
"error.invalid_feed_url": "フィード URL が無効です。",
"error.invalid_gesture_nav": "ジェスチャー ナビゲーションが無効です。",
"error.invalid_language": "言語が無効です。",
"error.invalid_site_url": "サイト URL が無効です。",
"error.invalid_theme": "テーマが無効です。",
"error.invalid_timezone": "タイムゾーンが無効です。",
"error.network_operation": "Miniflux はネットワークエラーのためこのウェブサイトに到達できません: %v.",
"error.network_timeout": "このウェブサイトは応答が遅すぎるためタイムアウトしました: %v",
"error.password_min_length": "パスワードは6文字以上である必要があります。",
"error.proxy_url_not_empty": "プロキシURLを空にすることはできません。",
"error.settings_block_rule_fieldname_invalid": "ブロックルールが無効です: ルール #%d に有効なフィールド名がありません (オプション: %s)",
"error.settings_block_rule_invalid_regex": "ブロックルールが無効です: ルール #%d のパターンが正規表現として無効です",
"error.settings_block_rule_regex_required": "ブロックルールが無効です: ルール #%d にパターンが指定されていません",
"error.settings_block_rule_separator_required": "ブロックルールが無効です: ルール #%d のパターンは '=' で区切る必要があります",
"error.settings_invalid_domain_list": "ドメインリストが無効です。ドメインをスペース区切りで指定してください。",
"error.settings_keep_rule_fieldname_invalid": "キープルールが無効です: ルール #%d に有効なフィールド名がありません (オプション: %s)",
"error.settings_keep_rule_invalid_regex": "キープルールが無効です: ルール #%d のパターンが正規表現として無効です",
"error.settings_keep_rule_regex_required": "キープルールが無効です: ルール #%d にパターンが指定されていません",
"error.settings_keep_rule_separator_required": "キープルールが無効です: ルール #%d のパターンは '=' で区切る必要があります",
"error.settings_mandatory_fields": "ユーザー名、テーマ、言語、タイムゾーンのすべてが必要です。",
"error.settings_media_playback_rate_range": "再生速度が範囲外",
"error.settings_reading_speed_is_positive": "読書速度は正の整数である必要があります。",
"error.site_url_not_empty": "サイトの URL を空にすることはできません。",
"error.subscription_not_found": "フィードが見つかりません。",
"error.title_required": "タイトルが必要です。",
"error.tls_error": "TLS エラー: %q。必要であればフィード設定で TLS 検証を無効にできます。",
"error.unable_to_create_api_key": "この API キーを作成できません。",
"error.unable_to_create_category": "このカテゴリは作成できません。",
"error.unable_to_create_user": "このユーザーは作成できません。",
"error.unable_to_detect_rssbridge": "RSS-Bridge を使ってフィードを検出できません: %v.",
"error.unable_to_parse_feed": "このフィードを解析できません: %v.",
"error.unable_to_update_category": "このカテゴリは更新できません。",
"error.unable_to_update_feed": "このフィードは更新できません。",
"error.unable_to_update_user": "このユーザーは更新できません。",
"error.unlink_account_without_password": "パスワードを設定しなければ再びログインすることはできません。",
"error.user_already_exists": "このユーザーは既に存在します。",
"error.user_mandatory_fields": "ユーザー名が必要です。",
"error.linktaco_missing_required_fields": "LinkTaco API TokenとOrganization Slugが必要です",
"form.api_key.label.description": "API キーラベル",
"form.category.hide_globally": "未読一覧に記事を表示しない",
"form.category.label.title": "タイトル",
"form.feed.fieldset.general": "一般",
"form.feed.fieldset.integration": "サードパーティサービス",
"form.feed.fieldset.network_settings": "ネットワーク設定",
"form.feed.fieldset.rules": "ルール",
"form.feed.label.allow_self_signed_certificates": "自己署名証明書または無効な証明書を許可する",
"form.feed.label.apprise_service_urls": "Apprise サービス URL のカンマ区切りリスト",
"form.feed.label.block_filter_entry_rules": "エントリブロッキングルール",
"form.feed.label.blocklist_rules": "正規表現ベースのブロッキングフィルター",
"form.feed.label.category": "カテゴリ",
"form.feed.label.cookie": "Cookie の設定",
"form.feed.label.crawler": "オリジナルの内容を取得",
"form.feed.label.description": "説明",
"form.feed.label.disable_http2": "フィンガープリンティング回避のため HTTP/2 を無効化",
"form.feed.label.disabled": "このフィードを更新しない",
"form.feed.label.feed_password": "フィードのパスワード",
"form.feed.label.feed_url": "フィード URL",
"form.feed.label.feed_username": "フィードのユーザー名",
"form.feed.label.fetch_via_proxy": "アプリケーションレベルで設定されたプロキシを使用する",
"form.feed.label.hide_globally": "未読一覧に記事を表示しない",
"form.feed.label.ignore_http_cache": "HTTPキャッシュを無視",
"form.feed.label.keep_filter_entry_rules": "エントリ許可ルール",
"form.feed.label.keeplist_rules": "正規表現ベースのキープフィルター",
"form.feed.label.no_media_player": "メディアプレーヤーなし(音声/動画)",
"form.feed.label.ntfy_activate": "エントリを ntfy に送信",
"form.feed.label.ntfy_default_priority": "ntfy デフォルト優先度",
"form.feed.label.ntfy_high_priority": "ntfy 高優先度",
"form.feed.label.ntfy_low_priority": "ntfy 低優先度",
"form.feed.label.ntfy_max_priority": "ntfy 最大優先度",
"form.feed.label.ntfy_min_priority": "ntfy 最小優先度",
"form.feed.label.ntfy_priority": "ntfy 優先度",
"form.feed.label.ntfy_topic": "ntfy トピック(任意)",
"form.feed.label.proxy_url": "プロキシ URL",
"form.feed.label.pushover_activate": "エントリを pushover.net に送信",
"form.feed.label.pushover_default_priority": "Pushover 既定の優先度",
"form.feed.label.pushover_high_priority": "Pushover 高優先度",
"form.feed.label.pushover_low_priority": "Pushover 低優先度",
"form.feed.label.pushover_max_priority": "Pushover 最大優先度",
"form.feed.label.pushover_min_priority": "Pushover 最小優先度",
"form.feed.label.pushover_priority": "Pushover メッセージ優先度",
"form.feed.label.rewrite_rules": "コンテンツ書き換えルール",
"form.feed.label.scraper_rules": "Scraper ルール",
"form.feed.label.site_url": "サイト URL",
"form.feed.label.title": "タイトル",
"form.feed.label.urlrewrite_rules": "Rewrite URL ルール",
"form.feed.label.user_agent": "デフォルトの User Agent を上書きする",
"form.feed.label.webhook_url": "Webhook の URL を上書き",
"form.import.label.file": "OPML ファイル",
"form.import.label.url": "URL",
"form.integration.archiveorg_activate": "エントリーをarchive.orgにプッシュする",
"form.integration.apprise_activate": "エントリを Apprise に送信",
"form.integration.apprise_services_url": "Apprise サービス URL のカンマ区切りリスト",
"form.integration.apprise_url": "Apprise APIのURL",
"form.integration.betula_activate": "エントリを Betula に保存",
"form.integration.betula_token": "Betula トークン",
"form.integration.betula_url": "Betula サーバー URL",
"form.integration.cubox_activate": "エントリを Cubox に保存",
"form.integration.cubox_api_link": "Cubox API リンク",
"form.integration.discord_activate": "エントリを Discord に送信",
"form.integration.discord_webhook_link": "Discord Webhook リンク",
"form.integration.espial_activate": "Espial に記事を保存する",
"form.integration.espial_api_key": "Espial の API key",
"form.integration.espial_endpoint": "Espial の API Endpoint",
"form.integration.espial_tags": "Espial の Tag",
"form.integration.fever_activate": "Fever API を有効にする",
"form.integration.fever_endpoint": "Fever APIエンドポイント:",
"form.integration.fever_password": "Fever のパスワード",
"form.integration.fever_username": "Fever のユーザー名",
"form.integration.googlereader_activate": "Google Reader API を有効にする",
"form.integration.googlereader_endpoint": "Google Reader APIエンドポイント:",
"form.integration.googlereader_password": "Google Reader のパスワード",
"form.integration.googlereader_username": "Google Reader のユーザー名",
"form.integration.instapaper_activate": "Instapaper に記事を保存する",
"form.integration.instapaper_password": "Instapaper のパスワード",
"form.integration.instapaper_username": "Instapaper のユーザー名",
"form.integration.karakeep_activate": "Karakeep に記事を保存する",
"form.integration.karakeep_api_key": "Karakeep の API key",
"form.integration.karakeep_url": "Karakeep の API Endpoint",
"form.integration.karakeep_tags": "Karakeep の Tags",
"form.integration.linkace_activate": "エントリを LinkAce に保存",
"form.integration.linkace_api_key": "LinkAce API キー",
"form.integration.linkace_check_disabled": "リンクチェックを無効化",
"form.integration.linkace_endpoint": "LinkAce API エンドポイント",
"form.integration.linkace_is_private": "リンクを非公開にする",
"form.integration.linkace_tags": "LinkAce タグ",
"form.integration.linkding_activate": "Linkding に記事を保存する",
"form.integration.linkding_api_key": "Linkding の API key",
"form.integration.linkding_bookmark": "ブックマークを未読にする",
"form.integration.linkding_endpoint": "Linkding の API Endpoint",
"form.integration.linkding_tags": "Linkding タグ",
"form.integration.linktaco_activate": "LinkTacoでエントリを保存する",
"form.integration.linktaco_api_token": "LinkTaco API トークン",
"form.integration.linktaco_api_token_hint": "個人用アクセス トークンを取得",
"form.integration.linktaco_org_slug": "組織スラッグ",
"form.integration.linktaco_tags": "タグ (最大10件、カンマ区切り)",
"form.integration.linktaco_tags_hint": "最大10件のタグ、カンマ区切り",
"form.integration.linktaco_visibility": "公開設定",
"form.integration.linktaco_visibility_public": "公開",
"form.integration.linktaco_visibility_private": "非公開",
"form.integration.linktaco_visibility_hint": "非公開設定には有料のLinkTacoアカウントが必要です",
"form.integration.linkwarden_activate": "Linkwarden に記事を保存",
"form.integration.linkwarden_api_key": "Linkwarden の API キー",
"form.integration.linkwarden_endpoint": "Linkwarden ベース URL",
"form.integration.linkwarden_collection_id": "Linkwarden コレクション ID",
"form.integration.matrix_bot_activate": "新しい記事をMatrixに転送する",
"form.integration.matrix_bot_chat_id": "MatrixルームのID",
"form.integration.matrix_bot_password": "Matrixユーザ用パスワード",
"form.integration.matrix_bot_url": "MatrixサーバーのURL",
"form.integration.matrix_bot_user": "Matrixのユーザー名",
"form.integration.notion_activate": "エントリを Notion に保存",
"form.integration.notion_page_id": "Notion ページ ID",
"form.integration.notion_token": "Notion シークレット トークン",
"form.integration.ntfy_activate": "エントリを ntfy に送信",
"form.integration.ntfy_api_token": "ntfy API トークン(任意)",
"form.integration.ntfy_icon_url": "ntfy アイコン URL(任意)",
"form.integration.ntfy_internal_links": "クリック時に内部リンクを使用(任意)",
"form.integration.ntfy_password": "ntfy パスワード(任意)",
"form.integration.ntfy_topic": "ntfy トピック(フィードで未設定なら既定値)",
"form.integration.ntfy_url": "ntfy URL(任意、既定 ntfy.sh)",
"form.integration.ntfy_username": "ntfy ユーザー名(任意)",
"form.integration.nunux_keeper_activate": "Nunux Keeper に記事を保存する",
"form.integration.nunux_keeper_api_key": "Nunux Keeper の API key",
"form.integration.nunux_keeper_endpoint": "Nunux Keeper の API Endpoint",
"form.integration.omnivore_activate": "Omnivore に記事を保存する",
"form.integration.omnivore_api_key": "Omnivore の API key",
"form.integration.omnivore_url": "Omnivore の API Endpoint",
"form.integration.pinboard_activate": "Pinboard に記事を保存する",
"form.integration.pinboard_bookmark": "ブックマークを未読にする",
"form.integration.pinboard_tags": "Pinboard の Tag",
"form.integration.pinboard_token": "Pinboard の API Token",
"form.integration.pushover_activate": "エントリを Pushover に送信",
"form.integration.pushover_device": "Pushover デバイス(任意)",
"form.integration.pushover_prefix": "Pushover URL プレフィックス(任意)",
"form.integration.pushover_token": "Pushover アプリ API トークン",
"form.integration.pushover_user": "Pushover ユーザーキー",
"form.integration.raindrop_activate": "エントリを Raindrop に保存",
"form.integration.raindrop_collection_id": "コレクション ID",
"form.integration.raindrop_tags": "タグ(カンマ区切り)",
"form.integration.raindrop_token": "(テスト) トークン",
"form.integration.readeck_activate": "Readeck に記事を保存する",
"form.integration.readeck_api_key": "Readeck の API key",
"form.integration.readeck_endpoint": "Readeck の API Endpoint",
"form.integration.readeck_labels": "Readeck ラベル",
"form.integration.readeck_only_url": "URL のみを送信 (完全なコンテンツではなく)",
"form.integration.readeck_push_activate": "新しいエントリを自動的に Readeck へ送信",
"form.integration.readwise_activate": "エントリを Readwise Reader に保存",
"form.integration.readwise_api_key": "Readwise Reader アクセストークン",
"form.integration.readwise_api_key_link": "Readwise アクセストークンを取得",
"form.integration.rssbridge_activate": "購読を追加する際に RSS-Bridge を確認",
"form.integration.rssbridge_token": "RSS-Bridge 認証トークン",
"form.integration.rssbridge_url": "RSS-Bridge サーバー URL",
"form.integration.shaarli_activate": "記事を Shaarli に保存",
"form.integration.shaarli_api_secret": "Shaarli API シークレット",
"form.integration.shaarli_endpoint": "ShaarliのURL",
"form.integration.shiori_activate": "記事を Shiori に保存",
"form.integration.shiori_endpoint": "Shiori API エンドポイント",
"form.integration.shiori_password": "Shiori パスワード",
"form.integration.shiori_username": "Shiori ユーザー名",
"form.integration.slack_activate": "エントリを Slack に送信",
"form.integration.slack_webhook_link": "Slack Webhook リンク",
"form.integration.telegram_bot_activate": "新しい記事を Telegram チャットにプッシュする",
"form.integration.telegram_bot_disable_buttons": "ボタンを無効化",
"form.integration.telegram_bot_disable_notification": "通知を無効化",
"form.integration.telegram_bot_disable_web_page_preview": "Web ページのプレビューを無効化",
"form.integration.telegram_bot_token": "ボットトークン",
"form.integration.telegram_chat_id": "チャット ID",
"form.integration.telegram_topic_id": "トピック ID",
"form.integration.wallabag_activate": "Wallabag に記事を保存する",
"form.integration.wallabag_client_id": "Wallabag の Client ID",
"form.integration.wallabag_client_secret": "Wallabag の Client Secret",
"form.integration.wallabag_endpoint": "ワラバッグベースURL",
"form.integration.wallabag_only_url": "URL のみを送信 (完全なコンテンツではなく)",
"form.integration.wallabag_password": "Wallabag のパスワード",
"form.integration.wallabag_username": "Wallabag のユーザー名",
"form.integration.wallabag_tags": "Wallabag タグ",
"form.integration.webhook_activate": "Webhook を有効化",
"form.integration.webhook_secret": "Webhook シークレット",
"form.integration.webhook_url": "デフォルトの Webhook URL",
"form.prefs.fieldset.application_settings": "アプリケーション設定",
"form.prefs.fieldset.authentication_settings": "認証設定",
"form.prefs.fieldset.global_feed_settings": "グローバルフィード設定",
"form.prefs.fieldset.reader_settings": "リーダー設定",
"form.prefs.help.external_font_hosts": "許可する外部フォントホストをスペース区切りで指定します。例: \"fonts.gstatic.com fonts.googleapis.com\"",
"form.prefs.label.always_open_external_links": "外部リンクを開いて記事を読む",
"form.prefs.label.categories_sorting_order": "カテゴリの表示順",
"form.prefs.label.cjk_reading_speed": "中国語、韓国語、日本語の読書速度(文字数/分)",
"form.prefs.label.custom_css": "カスタム CSS",
"form.prefs.label.custom_js": "カスタム JavaScript",
"form.prefs.label.default_home_page": "デフォルトのトップページ",
"form.prefs.label.default_reading_speed": "他言語の読書速度(単語/分)",
"form.prefs.label.display_mode": "プログレッシブ Web アプリ (PWA) 表示モード",
"form.prefs.label.entries_per_page": "ページあたりの記事数",
"form.prefs.label.entry_order": "記事の表示順の基準",
"form.prefs.label.entry_sorting": "記事の表示順",
"form.prefs.label.entry_swipe": "タッチスクリーンでスワイプ入力を有効にする",
"form.prefs.label.external_font_hosts": "外部フォントホスト",
"form.prefs.label.gesture_nav": "エントリ間を移動するジェスチャー",
"form.prefs.label.keyboard_shortcuts": "キーボードショートカットを有効にする",
"form.prefs.label.language": "言語",
"form.prefs.label.mark_read_manually": "手動で既読にする",
"form.prefs.label.mark_read_on_media_completion": "音声/動画の再生が90%%に達したら既読にする",
"form.prefs.label.mark_read_on_view": "表示時にエントリを自動的に既読としてマークします",
"form.prefs.label.mark_read_on_view_or_media_completion": "表示時に既読にする。音声/動画は再生90%%で既読にする",
"form.prefs.label.media_playback_rate": "オーディオ/ビデオの再生速度",
"form.prefs.label.open_external_links_in_new_tab": "外部リンクを新しいタブで開く(リンクに target=\"_blank\" を追加)",
"form.prefs.label.show_reading_time": "記事の推定読書時間を表示する",
"form.prefs.label.theme": "テーマ",
"form.prefs.label.timezone": "タイムゾーン",
"form.prefs.select.alphabetical": "アルファベット順",
"form.prefs.select.browser": "ブラウザ",
"form.prefs.select.created_time": "記事の取得時刻",
"form.prefs.select.fullscreen": "フルスクリーン",
"form.prefs.select.minimal_ui": "ミニマル",
"form.prefs.select.none": "なし",
"form.prefs.select.older_first": "古い記事を最初に",
"form.prefs.select.publish_time": "記事の公開時刻",
"form.prefs.select.recent_first": "新しい記事を最初に",
"form.prefs.select.standalone": "スタンドアロン",
"form.prefs.select.swipe": "スワイプ",
"form.prefs.select.tap": "ダブルタップ",
"form.prefs.select.unread_count": "未読数",
"form.submit.loading": "読み込み中…",
"form.submit.saving": "保存中…",
"form.user.label.admin": "管理者",
"form.user.label.confirmation": "パスワード確認",
"form.user.label.password": "パスワード",
"form.user.label.username": "ユーザー名",
"menu.about": "ソフトウェア情報",
"menu.add_feed": "フィードを購読",
"menu.add_user": "ユーザーを追加",
"menu.api_keys": "API キー",
"menu.categories": "カテゴリ",
"menu.create_api_key": "新しい API キーを作成する",
"menu.create_category": "カテゴリを作成",
"menu.edit_category": "編集",
"menu.edit_feed": "編集",
"menu.export": "エクスポート",
"menu.feed_entries": "記事一覧",
"menu.feeds": "フィード一覧",
"menu.flush_history": "履歴をクリア",
"menu.history": "履歴",
"menu.home_page": "ホームページ",
"menu.import": "インポート",
"menu.integrations": "連携",
"menu.logout": "ログアウト",
"menu.mark_all_as_read": "すべて既読にする",
"menu.mark_page_as_read": "このページを既読にする",
"menu.preferences": "設定情報",
"menu.refresh_all_feeds": "すべてのフィードをバックグラウンドで更新",
"menu.refresh_feed": "更新",
"menu.search": "検索",
"menu.sessions": "セッション",
"menu.settings": "設定",
"menu.shared_entries": "共有エントリ",
"menu.show_all_entries": "すべての記事を表示",
"menu.show_only_starred_entries": "星付きのみを表示",
"menu.show_only_unread_entries": "未読の記事だけを表示",
"menu.starred": "星付き",
"menu.title": "メニュー",
"menu.unread": "未読",
"menu.users": "ユーザー一覧",
"page.about.author": "作者:",
"page.about.build_date": "ビルド日時:",
"page.about.credits": "著作権表示",
"page.about.db_usage": "データベースサイズ:",
"page.about.git_commit": "Git コミット:",
"page.about.global_config_options": "グローバル構成オプション",
"page.about.go_version": "Go バージョン:",
"page.about.license": "ライセンス:",
"page.about.postgres_version": "Postgres バージョン:",
"page.about.title": "ソフトウェア情報",
"page.about.version": "バージョン:",
"page.add_feed.choose_feed": "フィードを選択",
"page.add_feed.label.url": "フィードURL",
"page.add_feed.legend.advanced_options": "高度な設定",
"page.add_feed.no_category": "カテゴリが存在しません。カテゴリが少なくとも1つ必要です。",
"page.add_feed.submit": "フィードを探索して追加",
"page.add_feed.title": "新規フィード",
"page.api_keys.never_used": "未使用",
"page.api_keys.table.actions": "アクション",
"page.api_keys.table.created_at": "作成日",
"page.api_keys.table.description": "説明",
"page.api_keys.table.last_used_at": "最終使用",
"page.api_keys.table.token": "トークン",
"page.api_keys.title": "API キー",
"page.categories.entries": "記事一覧",
"page.categories.feed_count": [
"%d 件のフィードがあります。"
],
"page.categories.feeds": "フィード一覧",
"page.categories.no_feed": "フィードはありません。",
"page.categories.title": "カテゴリ",
"page.categories_count": [
"%d 件のカテゴリ"
],
"page.category_label": "カテゴリ: %s",
"page.edit_category.title": "カテゴリを編集: %s",
"page.edit_feed.etag_header": "ETag ヘッダー:",
"page.edit_feed.last_check": "最終チェック:",
"page.edit_feed.last_modified_header": "Last-Modified ヘッダー:",
"page.edit_feed.last_parsing_error": "直近の解析エラー",
"page.edit_feed.no_header": "なし",
"page.edit_feed.title": "フィードを編集: %s",
"page.edit_user.title": "ユーザーを編集: %s",
"page.entry.attachments": "添付ファイル",
"page.feeds.error_count": [
"%d 個のエラー"
],
"page.feeds.last_check": "最終チェック:",
"page.feeds.next_check": "次回チェック:",
"page.feeds.read_counter": "既読記事の数",
"page.feeds.title": "フィード一覧",
"page.footer.elevator": "トップに戻る",
"page.history.title": "履歴",
"page.import.title": "インポート",
"page.integration.bookmarklet": "ブックマークレット",
"page.integration.bookmarklet.help": "この特別なリンクを使ってブラウザから直接ウェブサイトのフィードを購読できます。",
"page.integration.bookmarklet.instructions": "このリンクをブラウザのブックマークへドラッグしてください。",
"page.integration.bookmarklet.name": "Miniflux に追加",
"page.integration.miniflux_api": "MinifluxのAPI",
"page.integration.miniflux_api_endpoint": "APIエンドポイント",
"page.integration.miniflux_api_password": "パスワード",
"page.integration.miniflux_api_password_value": "アカウントのパスワード",
"page.integration.miniflux_api_username": "ユーザー名",
"page.integrations.title": "連携",
"page.keyboard_shortcuts.close_modal": "モーダルダイアログを閉じる",
"page.keyboard_shortcuts.download_content": "オリジナルの内容をダウンロード",
"page.keyboard_shortcuts.go_to_bottom_item": "一番下の項目に移動",
"page.keyboard_shortcuts.go_to_categories": "カテゴリ",
"page.keyboard_shortcuts.go_to_feed": "フィード",
"page.keyboard_shortcuts.go_to_feeds": "フィード一覧",
"page.keyboard_shortcuts.go_to_history": "履歴",
"page.keyboard_shortcuts.go_to_next_item": "次のアイテム",
"page.keyboard_shortcuts.go_to_next_page": "次のページ",
"page.keyboard_shortcuts.go_to_previous_item": "前のアイテム",
"page.keyboard_shortcuts.go_to_previous_page": "前のページ",
"page.keyboard_shortcuts.go_to_search": "検索フォームに移動",
"page.keyboard_shortcuts.go_to_settings": "設定",
"page.keyboard_shortcuts.go_to_starred": "星付き",
"page.keyboard_shortcuts.go_to_top_item": "先頭の項目に移動",
"page.keyboard_shortcuts.go_to_unread": "未読",
"page.keyboard_shortcuts.mark_page_as_read": "現在のページの記事をすべて既読にする",
"page.keyboard_shortcuts.open_comments": "コメントリンクを開く",
"page.keyboard_shortcuts.open_comments_same_window": "現在のタブでコメントリンクを開く",
"page.keyboard_shortcuts.open_item": "選択されたアイテムを開く",
"page.keyboard_shortcuts.open_original": "オリジナルのリンクを開く",
"page.keyboard_shortcuts.open_original_same_window": "現在のタブでオリジナルのリンクを開く",
"page.keyboard_shortcuts.refresh_all_feeds": "すべてのフィードをバックグラウンドで更新",
"page.keyboard_shortcuts.remove_feed": "このフィードを削除",
"page.keyboard_shortcuts.save_article": "記事を保存",
"page.keyboard_shortcuts.scroll_item_to_top": "アイテムが上端になるようにスクロール",
"page.keyboard_shortcuts.show_keyboard_shortcuts": "キーボードショートカットを表示",
"page.keyboard_shortcuts.subtitle.actions": "アクション",
"page.keyboard_shortcuts.subtitle.items": "アイテム間を移動する",
"page.keyboard_shortcuts.subtitle.pages": "ページ間を移動する",
"page.keyboard_shortcuts.subtitle.sections": "セクションを移動する",
"page.keyboard_shortcuts.title": "キーボードショートカット",
"page.keyboard_shortcuts.toggle_star_status": "星を付ける/外す",
"page.keyboard_shortcuts.toggle_entry_attachments": "添付ファイルを開く/閉じる",
"page.keyboard_shortcuts.toggle_read_status_next": "既読/未読を切り替えて次のアイテムに移動",
"page.keyboard_shortcuts.toggle_read_status_prev": "既読/未読を切り替えて前のアイテムに移動",
"page.login.google_signin": "Google アカウントでログイン",
"page.login.oidc_signin": "%s アカウントでログイン",
"page.login.title": "ログイン",
"page.login.webauthn_login": "パスキーでログイン",
"page.login.webauthn_login.error": "パスキーでログインできない",
"page.login.webauthn_login.help": "セキュリティキーを使用する場合はユーザー名を入力してください。パスキー(検出可能な認証情報)の場合は不要です。",
"page.new_api_key.title": "新しい API キー",
"page.new_category.title": "新規カテゴリ",
"page.new_user.title": "新規ユーザー",
"page.offline.message": "オフラインです",
"page.offline.refresh_page": "ページを更新してみてください",
"page.offline.title": "オフラインモード",
"page.read_entry_count": [
"%d 件の既読エントリ"
],
"page.search.title": "検索結果",
"page.sessions.table.actions": "アクション",
"page.sessions.table.current_session": "現在のセッション",
"page.sessions.table.date": "日付",
"page.sessions.table.ip": "IP アドレス",
"page.sessions.table.user_agent": "ユーザーエージェント",
"page.sessions.title": "セッション",
"page.settings.link_google_account": "Google アカウントと接続する",
"page.settings.link_oidc_account": "%s アカウントと接続する",
"page.settings.title": "設定",
"page.settings.unlink_google_account": "Google アカウントと接続を解除する",
"page.settings.unlink_oidc_account": "%s アカウントと接続を解除する",
"page.settings.webauthn.actions": "操作",
"page.settings.webauthn.added_on": "追加日",
"page.settings.webauthn.delete": [
"%d 個のパスキーを削除"
],
"page.settings.webauthn.last_seen_on": "最終使用日",
"page.settings.webauthn.passkey_name": "パスキー名",
"page.settings.webauthn.passkeys": "パスキー",
"page.settings.webauthn.register": "パスキーを登録する",
"page.settings.webauthn.register.error": "パスキーを登録できません",
"page.shared_entries.title": "共有エントリ",
"page.shared_entries_count": [
"%d 件の共有エントリ"
],
"page.starred.title": "星付き",
"page.starred_entry_count": [
"%d 件の星付きエントリ"
],
"page.total_entry_count": [
"合計 %d 件のエントリ"
],
"page.unread.title": "未読",
"page.unread_entry_count": [
"%d 件の未読エントリ"
],
"page.users.actions": "アクション",
"page.users.admin.no": "非管理者",
"page.users.admin.yes": "管理者",
"page.users.is_admin": "管理者",
"page.users.last_login": "最終ログイン",
"page.users.never_logged": "未ログイン",
"page.users.title": "ユーザー一覧",
"page.users.username": "ユーザー名",
"page.webauthn_rename.title": "パスキー名の変更",
"pagination.first": "最初",
"pagination.last": "最後",
"pagination.next": "次",
"pagination.previous": "前",
"search.label": "検索",
"search.placeholder": "…を検索",
"search.submit": "検索",
"skip_to_content": "コンテンツへスキップ",
"time_elapsed.days": [
"%d 日前"
],
"time_elapsed.hours": [
"%d 時間前"
],
"time_elapsed.minutes": [
"%d 分前"
],
"time_elapsed.months": [
"%d か月前"
],
"time_elapsed.not_yet": "未来",
"time_elapsed.now": "今",
"time_elapsed.weeks": [
"%d 週間前"
],
"time_elapsed.years": [
"%d 年前"
],
"time_elapsed.yesterday": "昨日",
"tooltip.keyboard_shortcuts": "キーボードショートカット: %s",
"tooltip.logged_user": "%s としてログイン中"
}
v2-2.2.16/internal/locale/translations/nan_Latn_pehoeji.json 0000664 0000000 0000000 00000123061 15127074645 0024073 0 ustar 00root root 0000000 0000000 {
"action.cancel": "Chhú-siau",
"action.download": "Lia̍h----loh-lâi",
"action.edit": "Pian-chi̍p",
"action.home_screen": "Chng tī chú ōe-bīn",
"action.import": "Hōe--li̍p",
"action.login": "Teng-lo̍k",
"action.or": "ah-sī",
"action.remove": "Thâi tiāu",
"action.remove_feed": "Thâi tiāu chit ê siau-sit lâi-goân",
"action.save": "Pó-chûn",
"action.subscribe": "Tēng",
"action.update": "Ōaⁿ-sin",
"alert.account_linked": "Í-keng kah lí ê gōa-pō͘ kháu-chō kiat chòe-hé--ah!",
"alert.account_unlinked": "Kah lí ê gōa-pō͘ kháu-chō ê kiat í-keng phah khui--ah!",
"alert.background_feed_refresh": "Tng leh pōe-āu ōaⁿ-sin só͘-ū siau-sit lâi-goân, lí ē-sái kè-sio̍k sú-iōng Miniflux。",
"alert.feed_error": "Chit ê siau-sit lâi-goân ū būn-tôe",
"alert.no_starred": "Chit-má ah bô siu-chông",
"alert.no_category": "Chit-má ah bô lūi-pia̍t",
"alert.no_category_entry": "Chit ê lūi-pah ah bô siau-sit",
"alert.no_feed": "Chit-má ah bô siau-sit lâi-goân",
"alert.no_feed_entry": "Chit ê siau-sit lâi-goân lāi bô siau-sit",
"alert.no_feed_in_category": "Bô chit ê lūi-pia̍t ê siau-sit lâi-goân",
"alert.no_history": "Chit-má ah bô kì-lo̍k",
"alert.no_search_result": "Bô hû-ha̍p ê chhiau-chhē kiat-kó",
"alert.no_shared_entry": "Chit-má ah bô hun-hióng ê siau-sit",
"alert.no_tag_entry": "Bô kah chit ê khan-á ū hû-ha̍p ê siau-sit",
"alert.no_unread_entry": "Chit-má ah-bô tha̍k kè ê siau-sit",
"alert.no_user": "Lí sī ûi-it ê sú-iōng-lâng",
"alert.prefs_saved": "Siat-tēng í-keng pó-chûn--ah!",
"alert.too_many_feeds_refresh": [
"Lí í-keng ín-khí siuⁿ chōe pái siau-sit lâi-goân ōaⁿ-sin, chhiáⁿ tán-hāu %d hun-cheng āu koh chhì-khòaⁿ-māi."
],
"confirm.loading": "Tng leh chip-hêng…",
"confirm.no": "Hóⁿ",
"confirm.question": "Kám ū khak-tēng?",
"confirm.question.refresh": "Kám beh kiông-chè têng lia̍h?",
"confirm.yes": "Sī",
"enclosure_media_controls.seek": "Sóa-ūi:",
"enclosure_media_controls.seek.title": "Sóa %s bió",
"enclosure_media_controls.speed": "Sok-tō͘",
"enclosure_media_controls.speed.faster": "Cheng-ka sok-tō͘",
"enclosure_media_controls.speed.faster.title": "Cheng-ka sok-tō͘ %sx",
"enclosure_media_controls.speed.reset": "Têng siat-tēng",
"enclosure_media_controls.speed.reset.title": "Têng siat-tēng pàng ê sok-tō͘ chòe 1x",
"enclosure_media_controls.speed.slower": "Pàng bān",
"enclosure_media_controls.speed.slower.title": "Pàng bān %sx",
"entry.starred.toast.off": "Chhú-siau siu-chông chòe soah",
"entry.starred.toast.on": "Sin cheng-ka siu-chông chòe soah",
"entry.starred.toggle.off": "Chhú-siau siu-chông",
"entry.starred.toggle.on": "Siu-chông khí-lâi",
"entry.comments.label": "Hôe-èng",
"entry.comments.title": "Khòaⁿ hôe-èng",
"entry.estimated_reading_time": [
"Ài %d hun-cheng lâi tha̍k"
],
"entry.external_link.label": "Gōa-pō͘ liân-kiat",
"entry.save.completed": "Pó-chûn chò soah",
"entry.save.label": "Pó-chûn",
"entry.save.title": "Pó-chûn chit ê siau-sit",
"entry.save.toast.completed": "Pó-chûn chò soah",
"entry.scraper.completed": "Lia̍h soah--ah",
"entry.scraper.label": "Lia̍h--lo̍h-lâi",
"entry.scraper.title": "Lia̍h goân-tóe lōe-iông",
"entry.share.label": "Hun-hióng",
"entry.share.title": "Hun-hióng chit ê siau-sit",
"entry.shared_entry.label": "Hun-hióng",
"entry.shared_entry.title": "Phah khui kong-khai ê liân-kiat",
"entry.state.loading": "Tng leh chip-hêng…",
"entry.state.saving": "Tng leh pó-chûn…",
"entry.status.mark_as_read": "Chù chòe tha̍k kè",
"entry.status.mark_as_unread": "Chù chòe ah-bōe tha̍k",
"entry.status.title": "Kái chōng-thài",
"entry.status.toast.read": "Chù chòe tha̍k kè chòe soah",
"entry.status.toast.unread": "Chù chòe ah-bōe tha̍k chòe soah",
"entry.tags.label": "Khan-á:",
"entry.tags.more_tags_label": [
"Kah %d khan-á"
],
"entry.unshare.label": "Chhú-siau hun-hióng",
"error.api_key_already_exists": "Chit ê API só-sî í-keng chûn-chāi",
"error.bad_credentials": "M̄-tio̍h ê kháu-chō miâ ah-sī bi̍t-bé.",
"error.category_already_exists": "Lūi-pia̍t í-keng chûn-chāi.",
"error.category_not_found": "Chit ê lūi-pia̍t bô chûn-chāi ah-sī bô sio̍k-tī lí.",
"error.database_error": "Chu-liāu khò͘ ū m̄-tiō: %v.",
"error.different_passwords": "Su-li̍p ê bi̍t-bé chit nn̄g pái bô kâng.",
"error.duplicate_fever_username": "Fever ê kháu-chō miâ í-keng hō͘ lâng iōng khì--ah!",
"error.duplicate_googlereader_username": "Google Reader ê kháu-chō miâ í-keng hō͘ lâng iōng khì--ah!",
"error.duplicate_linked_account": "Chit ê beh kiat chòe-hé--ê í-keng seng hō͘ lâng kiat khì--ah!",
"error.duplicated_feed": "Chit ê siau-sit lâi-goân í-keng chûn-chāi.",
"error.empty_file": "Chit ê tóng-àn sī khang--ê.",
"error.entries_per_page_invalid": "Ta̍k ia̍h ê siau-sit sò͘ ū būn-tôe.",
"error.feed_already_exists": "Chit ê siau-sit lâi-goân í-keng chûn-chāi.",
"error.feed_category_not_found": "Bô chit ê lūi-pia̍t ah-sī kóng bô sio̍k-tī chit ê sú-iōng-lâng.",
"error.feed_format_not_detected": "Bōe līn chit ê siau-sit lâi-goân ê keh-sek: %v.",
"error.feed_invalid_blocklist_rule": "Hong-só kui-chek bô-hāu.",
"error.feed_invalid_keeplist_rule": "Pó-liû kui-chek bô-hāu.",
"error.feed_mandatory_fields": "Tio̍h-ài su-lip bāng-chí kah lūi-pia̍t.",
"error.feed_not_found": "Chhē bô chit ê siau-sit lâi-goân ah-sī bô sio̍k-tī lí",
"error.feed_title_not_empty": "Beh tēng ê siau-sit lâi-goân ê piau-tôe bōe-sái sī khang--ê.",
"error.feed_url_not_empty": "Beh tēng ê siau-sit lâi-goân bāng-chí bōe-sái sī khang--ê.",
"error.fields_mandatory": "Tio̍h-ài kā chu-liāu lóng siá chê.",
"error.http_bad_gateway": "Chit ê bāng-chām chit-má in-ūi gateway ū būn-tôe bô-hoat-tō͘ iōng, m̄ sī Miniflux chia ê būn-tôe, chhiáⁿ tán--chi̍t-ē chiah koh chhì-khòaⁿ-māi.",
"error.http_body_read": "Bô-hoat-tō͘ tha̍k HTTP body lōe-iông: %v。",
"error.http_client_error": "HTTP kheh-hō͘ thâu ū m̄-tio̍h: %v.",
"error.http_empty_response": "HTTP hôe-èng lōe-iông sī khang--ê, ū khó-lêng sī hit ê bāng-chām ū pó-hō͘ ki-chè.",
"error.http_empty_response_body": "HTTP hôe-èng body sī khang--ê.",
"error.http_forbidden": "Hō͘ kū-choa̍t chûn-chhú chit ê bāng-chām, ū khó-lêng chit ê bāng-chām ū pó-hō͘ ki-chè.",
"error.http_gateway_timeout": "Tán chit ê bāng-chām ê hôe-èng í-keng chhiau-kè sî-kan, m̄ sī Miniflux chia ê būn-tôe, chhiáⁿ tán--chi̍t-ē chiah koh chhì-khòaⁿ-māi.",
"error.http_internal_server_error": "Chit ê bāng-chām ê su-hāu-khì in ka-kī ū būn-tôe, m̄ sī Miniflux chia ê būn-tôe, chhiáⁿ tán--chi̍t-ē chiah koh chhì-khòaⁿ-māi.",
"error.http_not_authorized": "Bô khoân chûn-chhú chit ê bāng-chām, chhiáⁿ kiám-cha kháu-chō miâ kah bi̍t-bé。",
"error.http_resource_not_found": "Chhē bô chit ê liân-kiat, chhiáⁿ khak-līn bāng-chí kám ū chèng-khak.",
"error.http_response_too_large": "HTTP hôe-èng siuⁿ tōa. Lí ē-sái tī choân-he̍k siat-tēng lāi kā siōng koân hān-tō͘ kái khah koân (ài têng khui su-hāu-khì)。",
"error.http_service_unavailable": "Chit ê bāng-chām in-ūi in ka-kī lāi-pō͘ ū būn-tôe,m̄ sī Miniflux chia ê būn-tôe, chhiáⁿ tán--chi̍t-ē chiah koh chhì-khòaⁿ-māi.",
"error.http_too_many_requests": "Miniflux tùi chit ê bāng-chām ê chhéng-kiû siuⁿ kè chōe, chhiáⁿ têng chhì-khòaⁿ-māi ah-sī tiâu-chéng thêng-sek siat-tēng.",
"error.http_unexpected_status_code": "Chit ê bāng-chām chòe liáu chi̍t ê liāu-bōe-tio̍h ê HTTP chōng-thài bé: %d, chhiáⁿ tán--chi̍t-ē chiah koh chhì-khòaⁿ-māi.",
"error.invalid_categories_sorting_order": "Lūi-pia̍t ê chōe pái bô-hāu, chhiáⁿ tán-hāu %d hun-cheng āu koh chhì-khòaⁿ-māi.",
"error.invalid_default_home_page": "Ū-siat chú-ia̍h ū būn-tôe!",
"error.invalid_display_mode": "Ū būn-tôe ê su-li̍p bô͘-sek.",
"error.invalid_entry_direction": "Ū būn-tôe ê su-li̍p hong-hiòng.",
"error.invalid_entry_order": "Siau-sit ê chōe pái bô-hāu, chhiáⁿ tán-hāu %d hun-cheng āu koh chhì-khòaⁿ-māi.",
"error.invalid_feed_proxy_url": "Proxy URL ū būn-tôe.",
"error.invalid_feed_url": "Beh tēng ê siau-sit lâi-goân ê bāng-chí ū būn-tôe.",
"error.invalid_gesture_nav": "Chhiú-sè tō-lám ū būn-tôe.",
"error.invalid_language": "Ū būn-tôe ê gú-giân.",
"error.invalid_site_url": "Siau-sit lâi-goân ê bāng-chām ê bāng-chí ū būn-tôe.",
"error.invalid_theme": "Ū būn-tôe ê chú-tôe.",
"error.invalid_timezone": "Ū būn-tôe ê sî-khu.",
"error.network_operation": "Miniflux bô-hoat-tō͘ liân kàu chit ê bāng-chām, ū khó-lêng sī bāng-lō͘ būn-tôe: %v.",
"error.network_timeout": "Chit ê bāng-chām ê hôe-èng siuⁿ bān, chhéng-kiû chhiau-kè sî-kan: %v.",
"error.password_min_length": "Chhiáⁿ chì-chió ài su-li̍p la̍k ê lī goân.",
"error.proxy_url_not_empty": "Proxy URL bōe-sái sī khang--ê.",
"error.settings_block_rule_fieldname_invalid": "Bô-hāu ê hong-só kui-chek: kui-chek #%d khiàm ū-hāu ê lân-ūi miâ (e-sai ê soán-hāng: %s)",
"error.settings_block_rule_invalid_regex": "Bô-hāu ê hong-só kui-chek: kui-chek #%d ê bô͘-sek m̄ sī ha̍p-hoat ê chiàⁿ-kui piáu-ta̍t sek",
"error.settings_block_rule_regex_required": "Bô-hāu ê hong-só kui-chek: kui-chek #%d bô thê-kiong chiàⁿ-kui piáu-ta̍t sek",
"error.settings_block_rule_separator_required": "Bô-hāu ê hong-só kui-chek: kui-chek #%d ê bô͘-sek tio̍h-ài iōng '=' keh khui.",
"error.settings_invalid_domain_list": "Bāng-he̍k chheng-toaⁿ ū būn-tôe, chhiáⁿ iōng khang-keh keh khui bô kâng ê bāng-he̍k.",
"error.settings_keep_rule_fieldname_invalid": "Bô-hāu ê pó-liû kui-chek: kui-chek #%d khiàm ū-hāu ê lân-ūi miâ (e-sai ê soán-hāng: %s)",
"error.settings_keep_rule_invalid_regex": "Bô-hāu ê pó-liû kui-chek: kui-chek #%d d ê bô͘-sek m̄ sī ha̍p-hoat ê chiàⁿ-kui piáu-ta̍t sek",
"error.settings_keep_rule_regex_required": "Bô-hāu ê pó-liû kui-chek: kui-chek #%d bô thê-kiong chiàⁿ-kui piáu-ta̍t sek",
"error.settings_keep_rule_separator_required": "Bô-hāu ê pó-liû kui-chek: kui-chek #%d ê bô͘-sek tio̍h-ài iōng '=' keh khui.",
"error.settings_mandatory_fields": "Tio̍h-ài su-li̍p kháu-chō miâ, chú-tôe, gú-giân, sî-khu.",
"error.settings_media_playback_rate_range": "Pàng ê sok-tō͘ chhiau-kè hoān-ûi",
"error.settings_reading_speed_is_positive": "Tha̍k ê sok-tō͘ tio̍h-ài sī chiaⁿ chéng-sò͘",
"error.site_url_not_empty": "Siau-sit lâi-goân ê bāng-chām ê bāng-chí bōe-sái sī khang--ê.",
"error.subscription_not_found": "Chhē bōe tio̍h līm-hô tēng ê siau-sit lâi-goân",
"error.title_required": "Tio̍h-ài su-li̍p piau-tôe.",
"error.tls_error": "TLS m̄-tio̍h: %q。Nā-sī beh pàng-ba̍k TSL chèng-bêng, ē-sái tī siau-sit lâi-goân siat-tēng lāi thêng-tiong.",
"error.unable_to_create_api_key": "Bô-hoat-tō͘ sin cheng-ka chit ê API só-sî.",
"error.unable_to_create_category": "Bô-hoat-tō͘ sin cheng-ka chit ê lūi-pia̍t",
"error.unable_to_create_user": "Bô-hoat-tō͘ sin cheng-ka chit ê sú-iōng-lâng",
"error.unable_to_detect_rssbridge": "Sú-iōng RSS-Bridge sî chhē bô līm-hô siau-sit lâi-goân: %v.",
"error.unable_to_parse_feed": "Bô-hoat-tō͘ kái-sek chit ê siau-sit lâi-goân: %v.",
"error.unable_to_update_category": "Bô-hoat-tō͘ ōaⁿ-sin chit ê lūi-pia̍t",
"error.unable_to_update_feed": "Bô-hoat-tō͘ ōaⁿ-sin chit ê siau-sit lâi-goân",
"error.unable_to_update_user": "Bô-hoat-tō͘ ōaⁿ-sin chit ê sú-iōng-lâng",
"error.unlink_account_without_password": "Lí it-tēng ài siat-tēng bi̍t-bé, bô lí ē bô-hoat-tō͘ koh teng-lo̍k.",
"error.user_already_exists": "Chit ê sú-iōng-lâng í-keng chûn-chāi.",
"error.user_mandatory_fields": "Tio̍h-ài su-li̍p kháu-chō miâ",
"error.linktaco_missing_required_fields": "LinkTaco API Token kâh Organization Slug sio̍kêi",
"form.api_key.label.description": "API só-sîkhan-á",
"form.category.hide_globally": "Mài hián-sī siau-sit tī choân-he̍k ah-bōe tha̍k lia̍t-pió lāi",
"form.category.label.title": "Piau-tôe",
"form.feed.fieldset.general": "Thong-iōng",
"form.feed.fieldset.integration": "Tē-saⁿ hong ho̍k-bū",
"form.feed.fieldset.network_settings": "Bāng-lō͘ siat-tēng",
"form.feed.fieldset.rules": "Kui-chek",
"form.feed.label.allow_self_signed_certificates": "ún-chún chū chhiam ah-sī bô-hāu ê pîn-chèng",
"form.feed.label.apprise_service_urls": "Sú-iōng tō͘-tiám keh khui ê Apprise ho̍k-bū bāng-chí lia̍t-pió",
"form.feed.label.block_filter_entry_rules": "Chhōa siau-sit ê kè-kng",
"form.feed.label.blocklist_rules": "Regex chhōa sè-khuán",
"form.feed.label.category": "lūi-pia̍t",
"form.feed.label.cookie": "Siat-tēng Cookies",
"form.feed.label.crawler": "Lia̍h goân-tóe lōe-iông",
"form.feed.label.description": "Biâu-su̍t",
"form.feed.label.disable_http2": "Thêng iōng HTTP/2 pī-bián chéng-thâu-á-hûn tui-chong",
"form.feed.label.disabled": "Mài tha̍k chit ê siau-sit lâi-goân ê sin siau-sit",
"form.feed.label.feed_password": "Siau-sit lâi-goân bi̍t-bé",
"form.feed.label.feed_url": "Siau-sit lâi-goân bāng-chí",
"form.feed.label.feed_username": "Siau-sit lâi-goân kháu-chō miâ",
"form.feed.label.fetch_via_proxy": "Iōng tī su-hāu-khì siat-tēng ê proxy",
"form.feed.label.hide_globally": "Tī choân-he̍k ah-bōe tha̍k--ê lia̍t-pió am-khàm siau-sit",
"form.feed.label.ignore_http_cache": "Pàng-ba̍k HTTP cache",
"form.feed.label.keep_filter_entry_rules": "Bêng ê siau-sit hō͘-chiâⁿ kui-chek",
"form.feed.label.keeplist_rules": "Regex pó͘-tē ê pò͘-chûn kui-chek",
"form.feed.label.no_media_player": "Bô mûi-thé hòng-sàng khì (im-sìn, sī-sìn)",
"form.feed.label.ntfy_activate": "Thui-sàng siau-sit khì ntfy",
"form.feed.label.ntfy_default_priority": "Ntfy ū-siat iu-sian sūn-sū",
"form.feed.label.ntfy_high_priority": "Ntfy koân iu-sian sūn-sū",
"form.feed.label.ntfy_low_priority": "Ntfy kē iu-sian sūn-sū",
"form.feed.label.ntfy_max_priority": "Ntfy siōng koân iu-sian sūn-sū",
"form.feed.label.ntfy_min_priority": "Ntfy siōng kē iu-sian sūn-sū",
"form.feed.label.ntfy_priority": "Ntfy iu-sian sūn-sū",
"form.feed.label.ntfy_topic": "Ntfy topic (soán thiⁿ)",
"form.feed.label.proxy_url": "Proxy ê URL",
"form.feed.label.pushover_activate": "Pó-chûn siau-sit kàu pushover.net",
"form.feed.label.pushover_default_priority": "Pushover ū-siat iu-sian sūn-sū",
"form.feed.label.pushover_high_priority": "Pushover koân iu-sian sūn-sū",
"form.feed.label.pushover_low_priority": "Pushover kē iu-sian sūn-sū",
"form.feed.label.pushover_max_priority": "Pushover siōng koân iu-sian sūn-sū",
"form.feed.label.pushover_min_priority": "Pushover siōng kē iu-sian sūn-sū",
"form.feed.label.pushover_priority": "Pushover siau-sit iu-sian sūn-sū",
"form.feed.label.rewrite_rules": "Lōe-iông têng-siá kui-chek",
"form.feed.label.scraper_rules": "Lia̍h ê kui-chek",
"form.feed.label.site_url": "Bāng-chām bāng-chí",
"form.feed.label.title": "Piau-tôe",
"form.feed.label.urlrewrite_rules": "Bāng-chí têng siá kui-chek",
"form.feed.label.user_agent": "Ngī kái sú-iōng-lâng tāi-lí",
"form.feed.label.webhook_url": "Ngī kái webhook bāng-chí",
"form.import.label.file": "OPML tóng-àn",
"form.import.label.url": "URL tiàm-chhī",
"form.integration.archiveorg_activate": "Pó͘-chûn siau-sit kàu archive.org",
"form.integration.apprise_activate": "Thui sàng siau-sit khì Apprise",
"form.integration.apprise_services_url": "Iōng tō͘-tiám keh khui ê Apprise ho̍k-bū bāng-chí lia̍t-pió",
"form.integration.apprise_url": "Apprise API bāng-chí",
"form.integration.betula_activate": "Pó-chûn siau-sit kàu Betula",
"form.integration.betula_token": "Betula tō͘-khíng",
"form.integration.betula_url": "Betula su-hāu-khì bāng-chí",
"form.integration.cubox_activate": "Pó-chûn siau-sit khì Cubox",
"form.integration.cubox_api_link": "Cubox API liân-kiat",
"form.integration.discord_activate": "Thui-sàng siau-sit kàu Discord",
"form.integration.discord_webhook_link": "Discord Webhook liân-kiat",
"form.integration.espial_activate": "Pó-chûn siau-sit kàu Espial",
"form.integration.espial_api_key": "Espial API só-sî",
"form.integration.espial_endpoint": "Espial API thâu",
"form.integration.espial_tags": "Espial khan-á",
"form.integration.fever_activate": "Khai-sí iōng Fever API",
"form.integration.fever_endpoint": "Fever API thâu",
"form.integration.fever_password": "Fever bi̍t-bé",
"form.integration.fever_username": "Fever kháu-chō miâ",
"form.integration.googlereader_activate": "Khai-sí iōng Google Reader API",
"form.integration.googlereader_endpoint": "Google Reader API thâu:",
"form.integration.googlereader_password": "Google Reader bi̍t-bé",
"form.integration.googlereader_username": "Google Reader Kháu-chō miâ",
"form.integration.instapaper_activate": "Pó-chûn siau-sit kàu Instapaper",
"form.integration.instapaper_password": "Instapaper bi̍t-bé",
"form.integration.instapaper_username": "Instapaper Kháu-chō miâ",
"form.integration.karakeep_activate": "Pó-chûn siau-sit kàu Karakeep",
"form.integration.karakeep_api_key": "Karakeep API só-sî",
"form.integration.karakeep_url": "Karakeep API thâu",
"form.integration.karakeep_tags": "Karakeep khan-á",
"form.integration.linkace_activate": "Pó-chûn siau-sit kàu LinkAce",
"form.integration.linkace_api_key": "LinkAce API só-sî",
"form.integration.linkace_check_disabled": "Thêng iōng liân-kiat kiám-cha",
"form.integration.linkace_endpoint": "LinkAce API thâu",
"form.integration.linkace_is_private": "Chù chòe su-lîn ê liân-kiat",
"form.integration.linkace_tags": "LinkAce khan-á",
"form.integration.linkding_activate": "Pó-chûn siau-sit kàu Linkding",
"form.integration.linkding_api_key": "Linkding API só-sî",
"form.integration.linkding_bookmark": "Chù chòe ah-bōe tha̍k",
"form.integration.linkding_endpoint": "Linkding API thâu",
"form.integration.linkding_tags": "Linkding khan-á",
"form.integration.linktaco_activate": "Pó-chûn siau-sit kàu LinkTaco",
"form.integration.linktaco_api_token": "LinkTaco API tō͘-khíng",
"form.integration.linktaco_api_token_hint": "Chhú-tek lí ê kò-jîn chún-chhú token tī",
"form.integration.linktaco_org_slug": "Chō͘-hêng hiân-jī",
"form.integration.linktaco_tags": "khan-á (siōn-koân 10, iōng tō͘-tiám keh khui)",
"form.integration.linktaco_tags_hint": "Siōn-koân 10 khan-á, iōng tō͘-tiám keh khui",
"form.integration.linktaco_visibility": "Kò-chhiah-kì sìa?",
"form.integration.linktaco_visibility_public": "Kò-chhiah-kì",
"form.integration.linktaco_visibility_private": "Su-lîn",
"form.integration.linktaco_visibility_hint": "Su-lîn sìa tík tio̍h-ài chù-hêng LinkTaco kháu-chō",
"form.integration.linkwarden_activate": "Pó-chûn siau-sit kàu Linkwarden",
"form.integration.linkwarden_api_key": "Linkwarden API só-sî",
"form.integration.linkwarden_endpoint": "Linkwarden ki-kiân bāng-chí",
"form.integration.linkwarden_collection_id": "Linkwarden sò͘-tē ID",
"form.integration.matrix_bot_activate": "Thui-sàng siau-sit kàu Matrix",
"form.integration.matrix_bot_chat_id": "Matrix pâng-keng ID",
"form.integration.matrix_bot_password": "Matrix bi̍t-bé",
"form.integration.matrix_bot_url": "Matrix su-hāu-khìbāng-chí",
"form.integration.matrix_bot_user": "Matrix kháu-chō miâ",
"form.integration.notion_activate": "Pó-chûn siau-sit kàu Notion",
"form.integration.notion_page_id": "Notion iah-piⁿ ID",
"form.integration.notion_token": "Notion bí-koān tō͘-khíng",
"form.integration.ntfy_activate": "Thui-sàng siau-sit kàu Ntfy",
"form.integration.ntfy_api_token": "Ntfy API só-sî (soán thiⁿ)",
"form.integration.ntfy_icon_url": "Ntfy Icon bāng-chí (soán thiⁿ)",
"form.integration.ntfy_internal_links": "Tiám ê sî-chūn iōng lāi-pō͘ liân-kiat (soán thiⁿ)",
"form.integration.ntfy_password": "Ntfy bi̍t-bé (soán thiⁿ)",
"form.integration.ntfy_topic": "Ntfy topic (chhī-liāu nā bô siat-tēng, tiō iōng ī-siat-ti̍t)",
"form.integration.ntfy_url": "Ntfy bāng-chí (soán thiⁿ, ū-siat sī ntfy.sh)",
"form.integration.ntfy_username": "Ntfy kháu-chō miâ (soán thiⁿ)",
"form.integration.nunux_keeper_activate": "Pó-chûn siau-sit kàu Nunux Keeper",
"form.integration.nunux_keeper_api_key": "Nunux Keeper API só-sî",
"form.integration.nunux_keeper_endpoint": "Nunux Keeper API thâu",
"form.integration.omnivore_activate": "Pó-chûn siau-sit kàu Omnivore",
"form.integration.omnivore_api_key": "Omnivore API só-sî",
"form.integration.omnivore_url": "Omnivore API thâu",
"form.integration.pinboard_activate": "Pó-chûn siau-sit kàu Pinboard",
"form.integration.pinboard_bookmark": "Chù chòe ah-bōe tha̍k",
"form.integration.pinboard_tags": "Pinboard khan-á",
"form.integration.pinboard_token": "Pinboard API tō͘-khíng",
"form.integration.pushover_activate": "Pó-chûn siau-sit kàu Pushover",
"form.integration.pushover_device": "Pushover ki-hì (soán thiⁿ)",
"form.integration.pushover_prefix": "Pushover URL tó͘-bí (soán thiⁿ)",
"form.integration.pushover_token": "Pushover application API só-sî",
"form.integration.pushover_user": "Pushover sú-iōng-lâng só-sî",
"form.integration.raindrop_activate": "Pó-chûn siau-sit kàu Raindrop",
"form.integration.raindrop_collection_id": "Sò͘-tē ID",
"form.integration.raindrop_tags": "Khan-á (iōng tō͘-tiám keh khui)",
"form.integration.raindrop_token": "Raindrop thè-khui só-sî",
"form.integration.readeck_activate": "Pó-chûn siau-sit kàu Readeck",
"form.integration.readeck_api_key": "Readeck API só-sî",
"form.integration.readeck_endpoint": "Readeck API thâu",
"form.integration.readeck_labels": "Readeck khan-á",
"form.integration.readeck_only_url": "Kan-na thoân bāng-chí (m̄ sī oân-chéng ê lōe-iông)",
"form.integration.readeck_push_activate": "Sin siau-sit thàn-lâi chūi-tō͘ thui kàu Readeck",
"form.integration.readwise_activate": "Pó-chûn siau-sit kàu Readwise Reader",
"form.integration.readwise_api_key": "Readwise Reader thè-khui só-sî",
"form.integration.readwise_api_key_link": "Chhú-tek lí ê Readwise thè-khui só-sî",
"form.integration.rssbridge_activate": "Sin cheng-ka siau-sit lâi-goân ê sî tio̍h RSS-Bridge",
"form.integration.rssbridge_token": "RSS-Bridge chheng-bêng só-sî",
"form.integration.rssbridge_url": "RSS-Bridge su-hāu-khì ê bāng-chí",
"form.integration.shaarli_activate": "Pó-chûn siau-sit kàu Shaarli",
"form.integration.shaarli_api_secret": "Shaarli API só-sî",
"form.integration.shaarli_endpoint": "Shaarli bāng-chí",
"form.integration.shiori_activate": "Pó-chûn siau-sit kàu Shiori",
"form.integration.shiori_endpoint": "Shiori API thâu",
"form.integration.shiori_password": "Shiori bi̍t-bé",
"form.integration.shiori_username": "Shiori kháu-chō miâ",
"form.integration.slack_activate": "Thui-sàng siau-sit kàu Slack",
"form.integration.slack_webhook_link": "Slack Webhook liân-kiat",
"form.integration.telegram_bot_activate": "Thui-sàng siau-sit kàu Telegram",
"form.integration.telegram_bot_disable_buttons": "Mài hián-sī khai-koan",
"form.integration.telegram_bot_disable_notification": "Têng iōng thong-ti",
"form.integration.telegram_bot_disable_web_page_preview": "Thêng iōng bāng-ia̍h ū-lám",
"form.integration.telegram_bot_token": "Bot Token",
"form.integration.telegram_chat_id": "Lîn-lūn ID",
"form.integration.telegram_topic_id": "Siōng-tê ID",
"form.integration.wallabag_activate": "Pó-chûn siau-sit kàu Wallabag",
"form.integration.wallabag_client_id": "Wallabag kheh-hō͘ thâu ID",
"form.integration.wallabag_client_secret": "Wallabag kheh-hō͘ thâu só-sî",
"form.integration.wallabag_endpoint": "Wallabag ki-kiân bāng-chí",
"form.integration.wallabag_only_url": "Kan-na thoân bāng-chí (m̄ sī oân-chéng ê lōe-iông)",
"form.integration.wallabag_password": "Wallabag bi̍t-bé",
"form.integration.wallabag_username": "Wallabag kháu-chō miâ",
"form.integration.wallabag_tags": "Wallabag khan-á",
"form.integration.webhook_activate": "Khai-sí Webhooks",
"form.integration.webhook_secret": "Webhooks bí-miâ",
"form.integration.webhook_url": "Koán-tē Webhook bāng-chí",
"form.prefs.fieldset.application_settings": "Èng-iōng thêng-sek siat-tēng",
"form.prefs.fieldset.authentication_settings": "Sú-iōng-lâng giām-chèng siat-tēng",
"form.prefs.fieldset.global_feed_settings": "Choân-he̍k siau-sit lâi-goân siat-tēng",
"form.prefs.fieldset.reader_settings": "Ia̍t-tha̍k khì siat-tēng",
"form.prefs.help.external_font_hosts": "Iōng khang-keh keh khui ún-chún ê gōa-pō͘ lī-hêng lâi-goân. Phì-lû \"fonts.gstatic.com fonts.googleapis.com\"",
"form.prefs.label.always_open_external_links": "Chhiau-chhē bûn-chiong sī iōng gōa-pō͘ liân-kiat phah khui",
"form.prefs.label.categories_sorting_order": "Lūi-pia̍t hián-sī sūn-sū",
"form.prefs.label.cjk_reading_speed": "Tiong-bûn, Hân-bûn, Li̍t-bûn tha̍k ê sok-tō͘ (múi hun-cheng ē-sái tha̍k kúi ê lī-goân)",
"form.prefs.label.custom_css": "Chū tēng ê CSS",
"form.prefs.label.custom_js": "Chū tēng ê JavaScript",
"form.prefs.label.default_home_page": "Ū-siat chú-ia̍h",
"form.prefs.label.default_reading_speed": "Kî-thaⁿ gú-giân tha̍k ê sok-tō͘ (múi hun-cheng ē-sái tha̍k kúi ê lī)",
"form.prefs.label.display_mode": "Chiām-chìn sek bāng-lō͘ èng-iōng theng-sek (PWA) ê hián-sī bô͘-sek",
"form.prefs.label.entries_per_page": "Ta̍k ia̍h siau-sit sò͘",
"form.prefs.label.entry_order": "Siau-sit hián-sī sūn-sū ê i-kù",
"form.prefs.label.entry_sorting": "Siau-sit sūn-sū",
"form.prefs.label.entry_swipe": "Ē-sái tī chhiok-khòng sek êng-bō͘ ùi siau-sit iōng thoa tāng chhau-chok",
"form.prefs.label.external_font_hosts": "Gōa-pō͘ lī-hêng lâi-goân",
"form.prefs.label.gesture_nav": "Tī siau-sit kan sóa-ūi ê chhiú-sè",
"form.prefs.label.keyboard_shortcuts": "Ē-sái iōng khí-pôaⁿ khoài-sok khí",
"form.prefs.label.language": "Gú-giân",
"form.prefs.label.mark_read_manually": "Ka-kī chhau-chok kám beh chù chòe tha̍k kè",
"form.prefs.label.mark_read_on_media_completion": "Kan-na tī im-sìn, sī-sìn hòng-sàng kàu 90%% ê si-chun chù chòe tha̍k kè",
"form.prefs.label.mark_read_on_view": "Phah khui ê sî-chūn sūn-sòa kā siau-sit chù chòe tha̍k kè",
"form.prefs.label.mark_read_on_view_or_media_completion": "Phah khui ê sî-chūn sūn-sòa kā siau-sit chù chòe tha̍k kè, m̄-koh nā-sī im-sìn, sī-sìn tio̍h tī hòng-sàng kàu 90%% ê si-chun chiah lâi chù",
"form.prefs.label.media_playback_rate": "Im-sìn, sī-sìn pàng ê sok-tō͘",
"form.prefs.label.open_external_links_in_new_tab": "Chhiau-chhē gōa-pō͘ liân-kiat sī tī sin ê ia̍h phah khui (kā liân-kiat chhē target=\"_blank\")",
"form.prefs.label.show_reading_time": "Hián-sī siau-sit àn-sǹg ài gōa-kú lâi tha̍k",
"form.prefs.label.theme": "Chú-tôe",
"form.prefs.label.timezone": "Sî-khu",
"form.prefs.select.alphabetical": "Chiàu lī-bú pâi",
"form.prefs.select.browser": "Iû-lâm-khì",
"form.prefs.select.created_time": "Siau-sit kiàn-li̍p sî-kan",
"form.prefs.select.fullscreen": "Choân êng-bō͘",
"form.prefs.select.minimal_ui": "Siōng sió UI",
"form.prefs.select.none": "Bô",
"form.prefs.select.older_first": "Ùi kū--ê khai-sí pâi",
"form.prefs.select.publish_time": "Siau-sit hoat-pò͘ sî-kan",
"form.prefs.select.recent_first": "Ùi sin--ê khai-sí pâi",
"form.prefs.select.standalone": "To̍k-li̍p--ê",
"form.prefs.select.swipe": "Iōng thoa--ê",
"form.prefs.select.tap": "Tiám nn̄g pái",
"form.prefs.select.unread_count": "Ah-bōe tha̍k ê sò͘-liōng",
"form.submit.loading": "Tng leh chip-hêng…",
"form.submit.saving": "Tng leh pó-chûn…",
"form.user.label.admin": "Koán-lí-lâng",
"form.user.label.confirmation": "Koh su-li̍p chi̍t pái bi̍t-bé",
"form.user.label.password": "Bi̍t-bé",
"form.user.label.username": "Kháu-chō miâ",
"menu.about": "Iú-koan",
"menu.add_feed": "Sin cheng-ka siau-sit lâi-goân",
"menu.add_user": "Sin cheng-ka sú-iōng-lâng",
"menu.api_keys": "API só-sî",
"menu.categories": "Lūi-pia̍t",
"menu.create_api_key": "Sin cheng-ka chi̍t ê API só-sî",
"menu.create_category": "Sin cheng-ka lūi-pia̍t",
"menu.edit_category": "Pian-chi̍p",
"menu.edit_feed": "Pian-chi̍p",
"menu.export": "Hōe--chhut",
"menu.feed_entries": "Bûn-chiong",
"menu.feeds": "Siau-sit lâi-goân",
"menu.flush_history": "Hìⁿ-sak kì-lo̍k",
"menu.history": "Kì-lo̍k",
"menu.home_page": "Siú ia̍h",
"menu.import": "Hōe--li̍p",
"menu.integrations": "Chéng-ha̍p",
"menu.logout": "Teng-chhut",
"menu.mark_all_as_read": "Choân-pō͘ chù chòe tha̍k kè",
"menu.mark_page_as_read": "Kā chit ia̍h--ê lóng chù chòe tha̍k kè",
"menu.preferences": "Siat-tēng",
"menu.refresh_all_feeds": "Tī pōe-āu têng lia̍h só͘-ū ê siau-sit lâi-goân",
"menu.refresh_feed": "Têng lia̍h",
"menu.search": "Chhiau-chhē",
"menu.sessions": "Ū teng-lo̍k--ê",
"menu.settings": "Siat-tēng",
"menu.shared_entries": "Hun-hióng kè ê siau-sit",
"menu.show_all_entries": "Hián-sī só͘-ū ê siau-sit",
"menu.show_only_starred_entries": "Kan-na hián-sī siu-chông ê siau-sit",
"menu.show_only_unread_entries": "Kan-na hián-sī ah-bōe tha̍k kè ê siau-sit",
"menu.starred": "Siu-chông",
"menu.title": "Tō-lám",
"menu.unread": "Ah-bōe tha̍k",
"menu.users": "Sú-iōng-lâng",
"page.about.author": "Chok-chiá: ",
"page.about.build_date": "Kiàn-tì li̍t-kî:",
"page.about.credits": "Pán-koân",
"page.about.db_usage": "Database chhài-chhiú:",
"page.about.git_commit": "Git commit:",
"page.about.global_config_options": "Choân-he̍k siat-tēng soán-hāng",
"page.about.go_version": "Go pán-pún:",
"page.about.license": "Pàng-koân:",
"page.about.postgres_version": "Postgres pán-pún:",
"page.about.title": "Iú-koan",
"page.about.version": "Pán-pún:",
"page.add_feed.choose_feed": "Soán-te̍k chi̍t ê Siau-sit lâi-goân",
"page.add_feed.label.url": "Bāng-chí",
"page.add_feed.legend.advanced_options": "Chìn-kai soán-hāng",
"page.add_feed.no_category": "Ah bô lūi-pia̍t, chì-chió ài ū chi̍t ê",
"page.add_feed.submit": "Chhē Siau-sit lâi-goân",
"page.add_feed.title": "Sin cheng-ka Siau-sit lâi-goân",
"page.api_keys.never_used": "Bô iōng kè",
"page.api_keys.table.actions": "Chhau-chok",
"page.api_keys.table.created_at": "Kiàn-tì li̍t-kî",
"page.api_keys.table.description": "Biâu-su̍t",
"page.api_keys.table.last_used_at": "Siōng-bóe pái sú-iōng",
"page.api_keys.table.token": "Só-sî",
"page.api_keys.title": "API só-sî",
"page.categories.entries": "Siau-sit",
"page.categories.feed_count": [
"Ū %d ê Siau-sit lâi-goân"
],
"page.categories.feeds": "Siau-sit lâi-goân",
"page.categories.no_feed": "Ah-bô siau-sit lâi-goân",
"page.categories.title": "Lūi-pia̍t",
"page.categories_count": [
"%d ê lūi-pia̍t"
],
"page.category_label": "Lūi-pia̍t: %s",
"page.edit_category.title": "Pian-chi̍p lūi-pia̍t: %s",
"page.edit_feed.etag_header": "ETag piau-thâu:",
"page.edit_feed.last_check": "Siōng-bóe pái kiám-cha sî-kan",
"page.edit_feed.last_modified_header": "Siōng-bóe pái siu-kái piau-thâu:",
"page.edit_feed.last_parsing_error": "Siōng-bóe pái kái-sek m̄-tio̍h",
"page.edit_feed.no_header": "Bô",
"page.edit_feed.title": "Pian-chi̍p Siau-sit lâi-goân: %s",
"page.edit_user.title": "pian-chi̍p sú-iōng-lâng: %s",
"page.entry.attachments": "Hù-kiāⁿ",
"page.feeds.error_count": [
"%d ê m̄-tio̍h"
],
"page.feeds.last_check": "Siōng-bóe kiám-cha sî-kan:",
"page.feeds.next_check": "Āu-pái kiám-cha sî-kan:",
"page.feeds.read_counter": "Tha̍k kè--ê siau-sit sò͘",
"page.feeds.title": "Siau-sit lâi-goân",
"page.footer.elevator": "Thâu-tiō siōng-ló͘",
"page.history.title": "Kì-lo̍k",
"page.import.title": "Hōe-li̍p",
"page.integration.bookmarklet": "Chheh-chhiam ke-si",
"page.integration.bookmarklet.help": "Lí ē-sái iōng chit ê te̍k-pia̍t ê chheh-chhiam ti̍t-chiap tēng bāng-ia̍h ê siau-sit",
"page.integration.bookmarklet.instructions": "Kā chit ê liân-kiat thoa khì iû-lám khì ê chheh-chhiam lân",
"page.integration.bookmarklet.name": "Siu-chông Miniflux",
"page.integration.miniflux_api": "Miniflux ê API",
"page.integration.miniflux_api_endpoint": "API thâu",
"page.integration.miniflux_api_password": "Bi̍t-bé",
"page.integration.miniflux_api_password_value": "Lí ê kháu-chō ê bi̍t-bé",
"page.integration.miniflux_api_username": "Kháu-chō miâ",
"page.integrations.title": "Chéng-ha̍p",
"page.keyboard_shortcuts.close_modal": "Kìm tiāu tùi-ōe thang",
"page.keyboard_shortcuts.download_content": "Liah goân-tóe ê siau-sit lōe-iông",
"page.keyboard_shortcuts.go_to_bottom_item": "Sóa khì thōng ē-kha ê siau-sit",
"page.keyboard_shortcuts.go_to_categories": "Phah khui lūi-pia̍t ia̍h",
"page.keyboard_shortcuts.go_to_feed": "Khì siau-sit lâi-goân",
"page.keyboard_shortcuts.go_to_feeds": "Phah khui siau-sit lâi-goân ia̍h",
"page.keyboard_shortcuts.go_to_history": "Phah khui kì-lo̍k ia̍h",
"page.keyboard_shortcuts.go_to_next_item": "Āu-chi̍t ê siau-sit",
"page.keyboard_shortcuts.go_to_next_page": "Āu-chi̍t ia̍h",
"page.keyboard_shortcuts.go_to_previous_item": "Téng-chi̍t ê siau-sit",
"page.keyboard_shortcuts.go_to_previous_page": "Téng-chi̍t ia̍h",
"page.keyboard_shortcuts.go_to_search": "Phah khui chhiau-chhē ia̍h",
"page.keyboard_shortcuts.go_to_settings": "Phah khui siat-tēng ia̍h",
"page.keyboard_shortcuts.go_to_starred": "Phah khui siu-chông--ê ia̍h",
"page.keyboard_shortcuts.go_to_top_item": "Sóa khì thōng téng-koân ê siau-sit",
"page.keyboard_shortcuts.go_to_unread": "Phah khui ah-bōe tha̍k--ê ia̍h",
"page.keyboard_shortcuts.mark_page_as_read": "Kā chit ia̍h--ê lóng chù chòe tha̍k--kè",
"page.keyboard_shortcuts.open_comments": "Phah khui hôe-èng liân-kiat",
"page.keyboard_shortcuts.open_comments_same_window": "Tī chit-má ê hun-ia̍h phah khui hôe-èng liân-kiat",
"page.keyboard_shortcuts.open_item": "Phah khui soán-te̍k ê siau-sit",
"page.keyboard_shortcuts.open_original": "Phah khui siau-sit goân-tóe ê liân-kiat",
"page.keyboard_shortcuts.open_original_same_window": "Tī chit-má ê hun-ia̍h phah khui siau-sit goân-tóe ê liân-kiat",
"page.keyboard_shortcuts.refresh_all_feeds": "Tī pōe-āu ōaⁿ-sin siau-sit lâi-goân",
"page.keyboard_shortcuts.remove_feed": "Thâi tiāu siau-sit lâi-goân",
"page.keyboard_shortcuts.save_article": "Pó-chûn siau-sit",
"page.keyboard_shortcuts.scroll_item_to_top": "Sóa khì bāng-ia̍h siōng téng-koân",
"page.keyboard_shortcuts.show_keyboard_shortcuts": "Hián-sī khoài-sok khí",
"page.keyboard_shortcuts.subtitle.actions": "Chhau-chok",
"page.keyboard_shortcuts.subtitle.items": "Bûn-chiong tō-lám",
"page.keyboard_shortcuts.subtitle.pages": "Ia̍h bīn tō-lám",
"page.keyboard_shortcuts.subtitle.sections": "Hun lân tō-lám",
"page.keyboard_shortcuts.title": "Khoài-sok khí",
"page.keyboard_shortcuts.toggle_star_status": "Chhet-li̍p siu-chông chōng-thài",
"page.keyboard_shortcuts.toggle_entry_attachments": "Chhet-li̍p thián khui kah siu-ha̍p siau-sit hù-kiāⁿ ê chōng-thài",
"page.keyboard_shortcuts.toggle_read_status_next": "Chhet-li̍p tha̍k--kè, ah-bōe tha̍k ê chōng-thài, koh chiau-tiám tī āu-chi̍t--ê",
"page.keyboard_shortcuts.toggle_read_status_prev": "Chhet-li̍p tha̍k--kè, ah-bōe tha̍k ê chōng-thài, koh chiau-tiám tī téng-chi̍t--ê",
"page.login.google_signin": "Sú-iōng Google teng-lo̍k",
"page.login.oidc_signin": "Sú-iōng %s teng-lo̍k",
"page.login.title": "teng-lo̍k",
"page.login.webauthn_login": "Sú-iōng bi̍t-bé teng-lo̍k",
"page.login.webauthn_login.error": "Bô-hoat-tō͘ iōng bi̍t-bé teng-lo̍k",
"page.login.webauthn_login.help": "Sú-iōng an-choân só-sî teng-lo̍k ê sî-chūn, chhiáⁿ su-li̍p kháu-chō miâ. Nā-sī iōng thang chhiau-chhē ê Passkey (discoverable credentials) tio̍h bián.",
"page.new_api_key.title": "Sin ê API só-sî",
"page.new_category.title": "Sin lūi-pia̍t",
"page.new_user.title": "Sin sú-iōng-lâng",
"page.offline.message": "Lí í-keng lî-sòaⁿ",
"page.offline.refresh_page": "Chhì-khòaⁿ-māi têng tha̍k bāng-ia̍h",
"page.offline.title": "Lî-sòaⁿ bô͘-sek",
"page.read_entry_count": [
"%d ê tha̍k kè ê siau-sit"
],
"page.search.title": "Chhiau-chhē kiat-kó",
"page.sessions.table.actions": "Chhau-chok",
"page.sessions.table.current_session": "Chit-má teng-lo̍k--ê",
"page.sessions.table.date": "Li̍t-kî",
"page.sessions.table.ip": "IP tōe-chí",
"page.sessions.table.user_agent": "Sú-iōng-lâng tāi-lí",
"page.sessions.title": "Ū teng-lo̍k--ê",
"page.settings.link_google_account": "Kah góa ê Google kháu-chō kiat chòe-hé",
"page.settings.link_oidc_account": "Kah góa ê %s kháu-chō kiat chòe-hé",
"page.settings.title": "Siat-tēng",
"page.settings.unlink_google_account": "Phah khui kah góa ê Google kháu-chō ê kiat",
"page.settings.unlink_oidc_account": "Phah khui kah góa ê %s kháu-chō ê kiat",
"page.settings.webauthn.actions": "Chhau-chok",
"page.settings.webauthn.added_on": "Sin cheng-ka ê sî-kan",
"page.settings.webauthn.delete": [
"Thâi tiāu %d ê Passkey"
],
"page.settings.webauthn.last_seen_on": "Siōng-bóe pái sú-iōng sî-kan",
"page.settings.webauthn.passkey_name": "Passkey miâ",
"page.settings.webauthn.passkeys": "Passkeys",
"page.settings.webauthn.register": "Chù-chheh Passkey",
"page.settings.webauthn.register.error": "Bô-hoat-tō͘ chù-chheh Passkey",
"page.shared_entries.title": "Hun-hióng kè ê siau-sit",
"page.shared_entries_count": [
"Í-keng hun-hióng %d ê siau-sit"
],
"page.starred.title": "Siu-chông",
"page.starred_entry_count": [
"%d ê siu-chông ê siau-sit"
],
"page.total_entry_count": [
"Lóng-chóng %d ê siau-sit"
],
"page.unread.title": "Ah-bōe tha̍k",
"page.unread_entry_count": [
"%d ê siau-sit ah-bōe tha̍k"
],
"page.users.actions": "chhau-chok",
"page.users.admin.no": "Hóⁿ",
"page.users.admin.yes": "Sī",
"page.users.is_admin": "Koán-lí-lâng",
"page.users.last_login": "Siōng-bóe pái teng-lo̍k",
"page.users.never_logged": "Chū-lâi bô teng-lo̍k kè",
"page.users.title": "Sú-iōng-lâng",
"page.users.username": "Sú-iōng-lâng miâ",
"page.webauthn_rename.title": "Tiông-sin hō͘ miâ Passkey",
"pagination.first": "Thâu-chi̍t ia̍h",
"pagination.last": "Siōng-bóe ia̍h",
"pagination.next": "Āu-chi̍t ia̍h",
"pagination.previous": "Téng-chi̍t ia̍h",
"search.label": "Chhiau-chhē",
"search.placeholder": "Chhiau-chhē...",
"search.submit": "Chhiau-chhē",
"skip_to_content": "Thiaⁿ--khì chhòng-bûn",
"time_elapsed.days": [
"%d kang chêng"
],
"time_elapsed.hours": [
"%d tiám-cheng chêng"
],
"time_elapsed.minutes": [
"%d hun-cheng chêng"
],
"time_elapsed.months": [
"%d kò ge̍h chêng"
],
"time_elapsed.not_yet": "ah-bōe",
"time_elapsed.now": "tú-chiah",
"time_elapsed.weeks": [
"%d lé-pài chêng"
],
"time_elapsed.years": [
"%d nî chêng"
],
"time_elapsed.yesterday": "cha-hng",
"tooltip.keyboard_shortcuts": "Khoài-sok khí:%s",
"tooltip.logged_user": "Chit-má teng-lo̍k--ê: %s"
}
v2-2.2.16/internal/locale/translations/nl_NL.json 0000664 0000000 0000000 00000113472 15127074645 0021645 0 ustar 00root root 0000000 0000000 {
"action.cancel": "annuleren",
"action.download": "Downloaden",
"action.edit": "Bewerken",
"action.home_screen": "Toevoegen aan startscherm",
"action.import": "Importeren",
"action.login": "Inloggen",
"action.or": "of",
"action.remove": "Verwijderen",
"action.remove_feed": "Verwijder deze feed",
"action.save": "Opslaan",
"action.subscribe": "Abonneren",
"action.update": "Bijwerken",
"alert.account_linked": "Jouw externe account is nu gekoppeld!",
"alert.account_unlinked": "Jouw externe account is nu ontkoppeld!",
"alert.background_feed_refresh": "Alle feeds worden op de achtergrond vernieuwd. Je kunt Miniflux blijven gebruiker terwijl dit proces draait.",
"alert.feed_error": "Er is een probleem met deze feed",
"alert.no_starred": "Er zijn geen favorieten.",
"alert.no_category": "Er zijn geen categorieën.",
"alert.no_category_entry": "Er zijn geen artikelen in deze categorie.",
"alert.no_feed": "Je hebt nog geen feed geabonneerd.",
"alert.no_feed_entry": "Er zijn geen artikelen in deze feed.",
"alert.no_feed_in_category": "Er is geen feed voor deze categorie.",
"alert.no_history": "Geschiedenis is op dit moment leeg.",
"alert.no_search_result": "Er is geen resultaat voor deze zoekopdracht.",
"alert.no_shared_entry": "Er is geen gedeeld artikel.",
"alert.no_tag_entry": "Er zijn geen artikelen die overeenkomen met deze tag.",
"alert.no_unread_entry": "Er zijn geen ongelezen artikelen.",
"alert.no_user": "Je bent de enige gebruiker.",
"alert.prefs_saved": "Instellingen opgeslagen!",
"alert.too_many_feeds_refresh": [
"Je hebt te veel feed-vernieuwingen getriggered. Wacht aub %d minuut voor opnieuw proberen.",
"Je hebt te veel feed-vernieuwingen getriggered. Wacht aub %d minuten voor opnieuw proberen."
],
"confirm.loading": "Bezig...",
"confirm.no": "nee",
"confirm.question": "Weet je het zeker?",
"confirm.question.refresh": "Wil je vernieuwen forceren?",
"confirm.yes": "ja",
"enclosure_media_controls.seek": "Vooruit/terug:",
"enclosure_media_controls.seek.title": " Vooruit/terug met %s seconden",
"enclosure_media_controls.speed": "Snelheid:",
"enclosure_media_controls.speed.faster": "Versnel",
"enclosure_media_controls.speed.faster.title": "Versnel met %sx",
"enclosure_media_controls.speed.reset": "Resetten",
"enclosure_media_controls.speed.reset.title": "Reset snelheid naar 1x",
"enclosure_media_controls.speed.slower": "Vertraag",
"enclosure_media_controls.speed.slower.title": "Vertraag met %sx",
"entry.starred.toast.off": "Favoriet verwijderd",
"entry.starred.toast.on": "Favoriet toegevoegd",
"entry.starred.toggle.off": "Favoriet verwijderen",
"entry.starred.toggle.on": "Favoriet",
"entry.comments.label": "Reacties",
"entry.comments.title": "Bekijk reacties",
"entry.estimated_reading_time": [
"%d minuut leestijd",
"%d minuten leestijd"
],
"entry.external_link.label": "Externe link",
"entry.save.completed": "Klaar!",
"entry.save.label": "Opslaan",
"entry.save.title": "Artikel opslaan",
"entry.save.toast.completed": "Artikel opgeslagen",
"entry.scraper.completed": "Klaar!",
"entry.scraper.label": "Downloaden",
"entry.scraper.title": "Originele inhoud ophalen",
"entry.share.label": "Delen",
"entry.share.title": "Deel dit artikel",
"entry.shared_entry.label": "Delen",
"entry.shared_entry.title": "Open de openbare link",
"entry.state.loading": "Laden...",
"entry.state.saving": "Opslaan...",
"entry.status.mark_as_read": "Markeren als gelezen",
"entry.status.mark_as_unread": "Markeren als ongelezen",
"entry.status.title": "Verander artikelstatus",
"entry.status.toast.read": "Gemarkeerd als gelezen",
"entry.status.toast.unread": "Gemarkeerd als ongelezen",
"entry.tags.label": "Labels:",
"entry.tags.more_tags_label": [
"Toon %d extra tag",
"Toon %d extra tags"
],
"entry.unshare.label": "Delen ongedaan maken",
"error.api_key_already_exists": "Deze API-sleutel bestaat al.",
"error.bad_credentials": "Onjuiste gebruikersnaam of wachtwoord.",
"error.category_already_exists": "Deze categorie bestaat al.",
"error.category_not_found": "Deze categorie bestaat niet of hoort niet bij deze gebruiker.",
"error.database_error": "Database fout: %v.",
"error.different_passwords": "Wachtwoorden zijn niet hetzelfde.",
"error.duplicate_fever_username": "Er is al iemand met dezelfde Fever gebruikersnaam!",
"error.duplicate_googlereader_username": "Er is al iemand met dezelfde Google Reader gebruikersnaam!",
"error.duplicate_linked_account": "Er is al iemand geregistreerd met deze provider!",
"error.duplicated_feed": "Deze feed bestaat al.",
"error.empty_file": "Dit bestand is leeg.",
"error.entries_per_page_invalid": "Het aantal artikelen per pagina is niet geldig.",
"error.feed_already_exists": "Deze feed bestaat al.",
"error.feed_category_not_found": "Deze categorie bestaat niet of behoort niet tot deze gebruiker.",
"error.feed_format_not_detected": "Feed-formaat kan niet worden gedetecteerd: %v.",
"error.feed_invalid_blocklist_rule": "De blokkeerregel is ongeldig.",
"error.feed_invalid_keeplist_rule": "De bewaarregel is ongeldig.",
"error.feed_mandatory_fields": "De velden URL en categorie zijn verplicht.",
"error.feed_not_found": "Deze feed bestaat niet of is niet van deze gebruiker.",
"error.feed_title_not_empty": "De feed titel mag niet leeg zijn.",
"error.feed_url_not_empty": "De feed URL mag niet leeg zijn.",
"error.fields_mandatory": "Alle velden moeten ingevuld zijn.",
"error.http_bad_gateway": "De website is momenteel niet beschikbaar vanwege een slechte-gateway-fout. De oorzaak hiervan ligt niet bij Miniflux. Probeer het later nogmaals aub.",
"error.http_body_read": "Kan de HTTP-body niet lezen: %v.",
"error.http_client_error": "HTTP-client-fout: %v.",
"error.http_empty_response": "De HTTP-respons is leeg. Misschien gebruikt deze website een botbeveiligingsmechanisme?",
"error.http_empty_response_body": "De HTTP-respons body is leeg.",
"error.http_forbidden": "Toegang tot deze website is verboden. Misschien heeft deze website een botbeveiligingsmechanisme?",
"error.http_gateway_timeout": "De website is momenteel niet beschikbaar vanwege een timeout bij de gateway. De oorzaak hiervan ligt niet bij Miniflux. Probeer het later nogmaals aub.",
"error.http_internal_server_error": "De website is momenteel niet beschikbaar vanwege een interne-server-fout. De oorzaak hiervan ligt niet bij Miniflux. Probeer het later nogmaals aub.",
"error.http_not_authorized": "Toegang tot deze website is niet geautoriseerd. Het kan een foute gebruikersnaam of wachtwoord zijn.",
"error.http_resource_not_found": "De gevraagde bron is niet gevonden. Controleer de URL.",
"error.http_response_too_large": "De HTTP-respons is te groot. Je kunt de limiet voor de HTTP-responsgrootte verhogen in de globale instellingen (server herstart noodzakelijk)",
"error.http_service_unavailable": "De website is momenteel niet beschikbaar vanwege een interne-server-fout. De oorzaak hiervan ligt niet bij Miniflux. Probeer het later nogmaals aub.",
"error.http_too_many_requests": "Miniflux heeft te veel aanvragen gegenereerd voor deze website. Probeer het later nog eens of wijzig de applicatieconfiguratie.",
"error.http_unexpected_status_code": "De website is momenteel niet beschikbaar vanwege een onverwachte HTTP-statuscode: %d. De oorzaak hiervan ligt niet bij Miniflux. Probeer het later nogmaals aub.",
"error.invalid_categories_sorting_order": "Ongeldige volgorde van categorieën.",
"error.invalid_default_home_page": "Ongeldige startpagina!",
"error.invalid_display_mode": "Ongeldige weergavemodus voor de webapp.",
"error.invalid_entry_direction": "Ongeldige sorteervolgorde.",
"error.invalid_entry_order": "Ongeldige volgorde van artikelen.",
"error.invalid_feed_proxy_url": "Ongeldige proxy-URL.",
"error.invalid_feed_url": "Ongeldige feed URL.",
"error.invalid_gesture_nav": "Ongeldige gebarennavigatie.",
"error.invalid_language": "Ongeldige taal.",
"error.invalid_site_url": "Ongeldige site URL.",
"error.invalid_theme": "Ongeldig thema.",
"error.invalid_timezone": "Ongeldige tijdzone.",
"error.network_operation": "Miniflux kan deze website niet bereiken vanwege een netwerkfout: %v.",
"error.network_timeout": "Deze website is te traag en de aanvraag gaf timeout: %v",
"error.password_min_length": "Minimaal 6 tekens gebruiken.",
"error.proxy_url_not_empty": "De proxy-URL mag niet leeg zijn.",
"error.settings_block_rule_fieldname_invalid": "Ongeldige blokkeerregel: regel #%d mist een geldige veldnaam (Opties: %s)",
"error.settings_block_rule_invalid_regex": "Ongeldige blokkeerregel: het patroon van regel #%d is geen geldige regex",
"error.settings_block_rule_regex_required": "Ongeldige blokkeerregel: het patroon van regel #%d is niet opgegeven",
"error.settings_block_rule_separator_required": "Ongeldige blokkeerregel: het patroon van regel #%d moet worden gescheiden door een '='",
"error.settings_invalid_domain_list": "Ongeldige domeinlijst. Geef een spatiegescheiden lijst van domeinen op.",
"error.settings_keep_rule_fieldname_invalid": "Ongeldige bewaarregel: regel #%d mist een geldige veldnaam (Options: %s)",
"error.settings_keep_rule_invalid_regex": "Ongeldige bewaarregel: het patroon van regel #%d is geen geldige regex",
"error.settings_keep_rule_regex_required": "Ongeldige bewaarregel: het patroon van regel #%d is niet opgegeven",
"error.settings_keep_rule_separator_required": "Ongeldige bewaarregel: het patroon van regel #%d moet worden gescheiden door een '='",
"error.settings_mandatory_fields": "Gebruikersnaam, thema, taal en tijdzone zijn verplichte velden.",
"error.settings_media_playback_rate_range": "Afspeelsnelheid is buiten bereik",
"error.settings_reading_speed_is_positive": "De leessnelheden moeten positieve gehele getallen zijn.",
"error.site_url_not_empty": "De site URL mag niet leeg zijn.",
"error.subscription_not_found": "Kan geen feeds vinden.",
"error.title_required": "De titel is verplicht.",
"error.tls_error": "TLS fout: %q. Als je wilt, kun je TLS-verificatie uitschakelen in de feed-instellingen.",
"error.unable_to_create_api_key": "Kan deze API-sleutel niet aanmaken.",
"error.unable_to_create_category": "Kan deze categorie niet aanmaken.",
"error.unable_to_create_user": "Kan deze gebruiker niet aanmaken.",
"error.unable_to_detect_rssbridge": "Kan feed niet detecteren met RSS-Bridge: %v.",
"error.unable_to_parse_feed": "Kan deze feed niet verwerken: %v.",
"error.unable_to_update_category": "Kan categorie niet bijwerken.",
"error.unable_to_update_feed": "Kan deze feed niet bijwerken.",
"error.unable_to_update_user": "Kan deze gebruiker niet bijwerken.",
"error.unlink_account_without_password": "Je moet een wachtwoord opgeven anders kun je niet meer inloggen.",
"error.user_already_exists": "Deze gebruiker bestaat al.",
"error.user_mandatory_fields": "Gebruikersnaam is verplicht",
"error.linktaco_missing_required_fields": "LinkTaco API Token en Organization Slug zijn verplicht",
"form.api_key.label.description": "API-sleutel omschrijving",
"form.category.hide_globally": "Verberg artikelen in de globale ongelezen lijst",
"form.category.label.title": "Titel",
"form.feed.fieldset.general": "Algemeen",
"form.feed.fieldset.integration": "Diensten van derden",
"form.feed.fieldset.network_settings": "Netwerk Instellingen",
"form.feed.fieldset.rules": "Regels",
"form.feed.label.allow_self_signed_certificates": "Zelfondertekende of ongeldige certificaten toestaan",
"form.feed.label.apprise_service_urls": "Door komma's gescheiden lijst van Apprise service URL's",
"form.feed.label.block_filter_entry_rules": "Blokkeerregels voor Items",
"form.feed.label.blocklist_rules": "Regex-gebaseerde Blokkeerfilters",
"form.feed.label.category": "Categorie",
"form.feed.label.cookie": "Cookies instellen",
"form.feed.label.crawler": "Download originele inhoud",
"form.feed.label.description": "Omschrijving",
"form.feed.label.disable_http2": "HTTP/2 uitschakelen om fingerprinting te voorkomen",
"form.feed.label.disabled": "Deze feed niet vernieuwen",
"form.feed.label.feed_password": "Feed wachtwoord",
"form.feed.label.feed_url": "Feed-URL",
"form.feed.label.feed_username": "Feed gebruikersnaam",
"form.feed.label.fetch_via_proxy": "Gebruik de proxy die op applicatieniveau is geconfigureerd",
"form.feed.label.hide_globally": "Verberg artikelen in de globale ongelezen lijst",
"form.feed.label.ignore_http_cache": "Negeer HTTP-cache",
"form.feed.label.keep_filter_entry_rules": "Toestaan Regels voor Items",
"form.feed.label.keeplist_rules": "Regex-gebaseerde Bewaarfilters",
"form.feed.label.no_media_player": "Geen mediaspeler (audio/video)",
"form.feed.label.ntfy_activate": "Artikelen naar ntfy sturen",
"form.feed.label.ntfy_default_priority": "Ntfy standaard prioriteit",
"form.feed.label.ntfy_high_priority": "Ntfy hoge prioriteit",
"form.feed.label.ntfy_low_priority": "Ntfy lage prioriteit",
"form.feed.label.ntfy_max_priority": "Ntfy maximale prioriteit",
"form.feed.label.ntfy_min_priority": "Ntfy minimale prioriteit",
"form.feed.label.ntfy_priority": "Ntfy prioriteit",
"form.feed.label.ntfy_topic": "Ntfy onderwerp (optioneel)",
"form.feed.label.proxy_url": "Proxy-URL",
"form.feed.label.pushover_activate": "Stuur artikelen naar pushover.net",
"form.feed.label.pushover_default_priority": "Pushover standaard prioriteit",
"form.feed.label.pushover_high_priority": "Pushover hoge prioriteit",
"form.feed.label.pushover_low_priority": "Pushover lage prioriteit",
"form.feed.label.pushover_max_priority": "Pushover maximale prioriteit",
"form.feed.label.pushover_min_priority": "Pushover minimale prioriteit",
"form.feed.label.pushover_priority": "Pushover berichtprioriteit",
"form.feed.label.rewrite_rules": "Inhoud Herschrijfregels",
"form.feed.label.scraper_rules": "Extractieregels",
"form.feed.label.site_url": "Website URL",
"form.feed.label.title": "Titel",
"form.feed.label.urlrewrite_rules": "Herschrijfregels voor URL's",
"form.feed.label.user_agent": "Standaard User-agent overschrijven",
"form.feed.label.webhook_url": "Overschrijf webhook URL",
"form.import.label.file": "OPML-bestand",
"form.import.label.url": "URL",
"form.integration.archiveorg_activate": "Artikelen sturen naar archive.org",
"form.integration.apprise_activate": "Artikelen opslaan in Apprise",
"form.integration.apprise_services_url": "Door komma's gescheiden lijst van Apprise service URL's",
"form.integration.apprise_url": "Apprise API-URL",
"form.integration.betula_activate": "Artikelen opslaan in Betula",
"form.integration.betula_token": "Betula-token",
"form.integration.betula_url": "Betula-server-URL",
"form.integration.cubox_activate": "Artikelen opslaan in Cubox",
"form.integration.cubox_api_link": "Cubox API-link",
"form.integration.discord_activate": "Artikelen opslaan in Discord",
"form.integration.discord_webhook_link": "Discord-webhooklink",
"form.integration.espial_activate": "Artikelen opslaan in Espial",
"form.integration.espial_api_key": "Espial API-sleutel",
"form.integration.espial_endpoint": "Espial URL",
"form.integration.espial_tags": "Espial tags",
"form.integration.fever_activate": "Activeer Fever API",
"form.integration.fever_endpoint": "Fever URL:",
"form.integration.fever_password": "Fever wachtwoord",
"form.integration.fever_username": "Fever gebruikersnaam",
"form.integration.googlereader_activate": "Activeer Google Reader API",
"form.integration.googlereader_endpoint": "Google Reader API-endpoint:",
"form.integration.googlereader_password": "Google Reader wachtwoord",
"form.integration.googlereader_username": "Google Reader gebruikersnaam",
"form.integration.instapaper_activate": "Artikelen opslaan in Instapaper",
"form.integration.instapaper_password": "Instapaper wachtwoord",
"form.integration.instapaper_username": "Instapaper gebruikersnaam",
"form.integration.karakeep_activate": "Artikelen opslaan in Karakeep",
"form.integration.karakeep_api_key": "Karakeep API-sleutel",
"form.integration.karakeep_url": "Karakeep URL",
"form.integration.karakeep_tags": "Karakeep tags",
"form.integration.linkace_activate": "Artikelen opslaan in LinkAce",
"form.integration.linkace_api_key": "LinkAce API-sleutel",
"form.integration.linkace_check_disabled": "Koppelingcontrole uitschakelen",
"form.integration.linkace_endpoint": "LinkAce API-eindpunt",
"form.integration.linkace_is_private": "Koppeling als privé markeren",
"form.integration.linkace_tags": "LinkAce tags",
"form.integration.linkding_activate": "Artikelen opslaan in Linkding",
"form.integration.linkding_api_key": "Linkding API-sleutel",
"form.integration.linkding_bookmark": "Markeer favoriet als ongelezen",
"form.integration.linkding_endpoint": "Linkding URL",
"form.integration.linkding_tags": "Linkding tags",
"form.integration.linktaco_activate": "Artikelen opslaan in LinkTaco",
"form.integration.linktaco_api_token": "LinkTaco API-token",
"form.integration.linktaco_api_token_hint": "Verkrijg uw persoonlijke toegangstoken op",
"form.integration.linktaco_org_slug": "Organisatie-slug",
"form.integration.linktaco_tags": "Tags (max 10, kommagescheiden)",
"form.integration.linktaco_tags_hint": "Maximaal 10 tags, kommagescheiden",
"form.integration.linktaco_visibility": "Zichtbaarheid",
"form.integration.linktaco_visibility_public": "Openbaar",
"form.integration.linktaco_visibility_private": "Privé",
"form.integration.linktaco_visibility_hint": "PRIVÉ zichtbaarheid vereist een betaald LinkTaco account",
"form.integration.linkwarden_activate": "Artikelen opslaan in Linkwarden",
"form.integration.linkwarden_api_key": "Linkwarden API-sleutel",
"form.integration.linkwarden_endpoint": "Linkwarden Basis URL",
"form.integration.linkwarden_collection_id": "Linkwarden collectie-ID",
"form.integration.matrix_bot_activate": "Nieuwe artikelen opslaan in Matrix",
"form.integration.matrix_bot_chat_id": "ID van Matrix-kamer",
"form.integration.matrix_bot_password": "Wachtwoord voor Matrix-gebruiker",
"form.integration.matrix_bot_url": "URL van de Matrix-server",
"form.integration.matrix_bot_user": "Matrix gebruikersnaam",
"form.integration.notion_activate": "Artikelen opslaan in Notion",
"form.integration.notion_page_id": "Notion-pagina-ID",
"form.integration.notion_token": "Notion geheim token",
"form.integration.ntfy_activate": "Stuur artikelen naar ntfy",
"form.integration.ntfy_api_token": "Ntfy API Token (optioneel)",
"form.integration.ntfy_icon_url": "Ntfy Icon URL (optioneel)",
"form.integration.ntfy_internal_links": "Gebruik interne links bij klikken (optioneel)",
"form.integration.ntfy_password": "Ntfy wachtwoord (optioneel)",
"form.integration.ntfy_topic": "Ntfy topic (standaard gebruikt als deze niet is ingesteld in feed)",
"form.integration.ntfy_url": "Ntfy URL (optioneel, standaard is ntfy.sh)",
"form.integration.ntfy_username": "Ntfy gebruikersnaam (optioneel)",
"form.integration.nunux_keeper_activate": "Artikelen opslaan in Nunux Keeper",
"form.integration.nunux_keeper_api_key": "Nunux Keeper API-sleutel",
"form.integration.nunux_keeper_endpoint": "Nunux Keeper URL",
"form.integration.omnivore_activate": "Artikelen opslaan in Omnivore",
"form.integration.omnivore_api_key": "Omnivore API-sleutel",
"form.integration.omnivore_url": "Omnivore URL",
"form.integration.pinboard_activate": "Artikelen opslaan in Pinboard",
"form.integration.pinboard_bookmark": "Markeer favoriet als ongelezen",
"form.integration.pinboard_tags": "Pinboard tags",
"form.integration.pinboard_token": "Pinboard API token",
"form.integration.pushover_activate": "Artikelen sturen naar Pushover",
"form.integration.pushover_device": "Pushover-apparaat (optioneel)",
"form.integration.pushover_prefix": "Pushover URL-prefix (optioneel)",
"form.integration.pushover_token": "Pushover API-token van de applicatie",
"form.integration.pushover_user": "Pushover gebruikerssleutel",
"form.integration.raindrop_activate": "Artikelen opslaan in Raindrop",
"form.integration.raindrop_collection_id": "Collectie ID",
"form.integration.raindrop_tags": "Tags (commagescheiden)",
"form.integration.raindrop_token": "Raindrop Token",
"form.integration.readeck_activate": "Artikelen opslaan in Readeck",
"form.integration.readeck_api_key": "Readeck API-sleutel",
"form.integration.readeck_endpoint": "Readeck-URL",
"form.integration.readeck_labels": "Readeck-labels",
"form.integration.readeck_only_url": "Alleen URL verzenden (in plaats van volledige inhoud)",
"form.integration.readeck_push_activate": "Nieuwe artikelen automatisch naar Readeck sturen",
"form.integration.readwise_activate": "Artikelen opslaan in Readwise Reader",
"form.integration.readwise_api_key": "Readwise Reader-toegangstoken",
"form.integration.readwise_api_key_link": "Readwise Access Token ophalen",
"form.integration.rssbridge_activate": "Controleer RSS-Bridge bij het toevoegen van abonnementen",
"form.integration.rssbridge_token": "Authenticatietoken voor RSS-Bridge",
"form.integration.rssbridge_url": "RSS-Bridge-server-URL",
"form.integration.shaarli_activate": "Artikelen opslaan in Shaarli",
"form.integration.shaarli_api_secret": "Shaarli API-geheim",
"form.integration.shaarli_endpoint": "Shaarli-URL",
"form.integration.shiori_activate": "Artikelen opslaan in Shiori",
"form.integration.shiori_endpoint": "Shiori URL",
"form.integration.shiori_password": "Shiori wachtwoord",
"form.integration.shiori_username": "Shiori gebruikersnaam",
"form.integration.slack_activate": "Artikelen opslaan in Slack",
"form.integration.slack_webhook_link": "Slack-webhooklink",
"form.integration.telegram_bot_activate": "Stuur nieuwe artikelen naar Telegram",
"form.integration.telegram_bot_disable_buttons": "Knoppen uitschakelen",
"form.integration.telegram_bot_disable_notification": "Notificatie uitschakelen",
"form.integration.telegram_bot_disable_web_page_preview": "Webpaginavoorbeeld uitschakelen",
"form.integration.telegram_bot_token": "Bot-token",
"form.integration.telegram_chat_id": "Chat-ID",
"form.integration.telegram_topic_id": "Topic-ID",
"form.integration.wallabag_activate": "Artikelen opslaan in Wallabag",
"form.integration.wallabag_client_id": "Wallabag Client-ID",
"form.integration.wallabag_client_secret": "Wallabag Client-Secret",
"form.integration.wallabag_endpoint": "Wallabag basis-URL",
"form.integration.wallabag_only_url": "Alleen URL verzenden (in plaats van volledige inhoud)",
"form.integration.wallabag_password": "Wallabag wachtwoord",
"form.integration.wallabag_username": "Wallabag gebruikersnaam",
"form.integration.wallabag_tags": "Wallabag-tags",
"form.integration.webhook_activate": "Webhooks activeren",
"form.integration.webhook_secret": "Webhooks geheim",
"form.integration.webhook_url": "Standaard Webhook-URL",
"form.prefs.fieldset.application_settings": "Applicatie Instellingen",
"form.prefs.fieldset.authentication_settings": "Authenticatie Instellingen",
"form.prefs.fieldset.global_feed_settings": "Globale Feed Instellingen",
"form.prefs.fieldset.reader_settings": "Lees Instellingen",
"form.prefs.help.external_font_hosts": "Spatiegescheiden lijst van externe font-hosts die zijn toegestaan. Bijvoorbeeld: 'fonts.gstatic.com fonts.googleapis.com'.",
"form.prefs.label.always_open_external_links": "Lees artikelen door externe links te openen",
"form.prefs.label.categories_sorting_order": "Volgorde categorieën",
"form.prefs.label.cjk_reading_speed": "Leessnelheid voor Chinees, Koreaans en Japans (tekens per minuut)",
"form.prefs.label.custom_css": "Aangepaste CSS",
"form.prefs.label.custom_js": "Aangepaste JavaScript",
"form.prefs.label.default_home_page": "Startpagina",
"form.prefs.label.default_reading_speed": "Leessnelheid voor andere talen (woorden per minuut)",
"form.prefs.label.display_mode": "Weergavemodus Progressive Web App (PWA).",
"form.prefs.label.entries_per_page": "Artikelen per pagina",
"form.prefs.label.entry_order": "Artikelen sorteren",
"form.prefs.label.entry_sorting": "Volgorde van artikelen",
"form.prefs.label.entry_swipe": "Vegen tussen artikelen inschakelen op aanraakschermen",
"form.prefs.label.external_font_hosts": "Externe font-hosts",
"form.prefs.label.gesture_nav": "Gebaar om tussen artikelen te navigeren",
"form.prefs.label.keyboard_shortcuts": "Sneltoetsen inschakelen",
"form.prefs.label.language": "Taal",
"form.prefs.label.mark_read_manually": "Markeer artikelen handmatig als gelezen",
"form.prefs.label.mark_read_on_media_completion": "Markeer artikelen alleen als gelezen wanneer het afspelen van audio/video 90%% heeft bereikt",
"form.prefs.label.mark_read_on_view": "Markeer artikelen automatisch als gelezen wanneer ze worden bekeken",
"form.prefs.label.mark_read_on_view_or_media_completion": "Markeer artikelen als gelezen wanneer ze worden bekeken. Voor audio/video, markeer als gelezen bij 90%% voltooiing",
"form.prefs.label.media_playback_rate": "Afspeelsnelheid van de audio/video",
"form.prefs.label.open_external_links_in_new_tab": "Open externe links in een nieuw tabblad (voegt target=\"_blank\" toe aan links)",
"form.prefs.label.show_reading_time": "Toon geschatte leestijd van artikelen",
"form.prefs.label.theme": "Thema",
"form.prefs.label.timezone": "Tijdzone",
"form.prefs.select.alphabetical": "Alfabetisch",
"form.prefs.select.browser": "Systeembrowser",
"form.prefs.select.created_time": "Tijdstip van aanmaken artikel",
"form.prefs.select.fullscreen": "Volledig scherm",
"form.prefs.select.minimal_ui": "Minimaal",
"form.prefs.select.none": "Geen",
"form.prefs.select.older_first": "Oudere artikelen eerst",
"form.prefs.select.publish_time": "Tijdstip van publiceren artikel",
"form.prefs.select.recent_first": "Recente artikelen eerst",
"form.prefs.select.standalone": "Standalone-modus",
"form.prefs.select.swipe": "Vegen",
"form.prefs.select.tap": "Dubbeltik",
"form.prefs.select.unread_count": "Aantal ongelezen artikelen",
"form.submit.loading": "Laden...",
"form.submit.saving": "Opslaan...",
"form.user.label.admin": "Beheerder",
"form.user.label.confirmation": "Bevestig wachtwoord",
"form.user.label.password": "Wachtwoord",
"form.user.label.username": "Gebruikersnaam",
"menu.about": "Over",
"menu.add_feed": "Feed toevoegen",
"menu.add_user": "Gebruiker toevoegen",
"menu.api_keys": "API-sleutels",
"menu.categories": "Categorieën",
"menu.create_api_key": "Maak een nieuwe API-sleutel",
"menu.create_category": "Categorie toevoegen",
"menu.edit_category": "Bewerken",
"menu.edit_feed": "Bewerken",
"menu.export": "Exporteren",
"menu.feed_entries": "Artikelen",
"menu.feeds": "Abonnementen",
"menu.flush_history": "Verwijder geschiedenis",
"menu.history": "Geschiedenis",
"menu.home_page": "Startpagina",
"menu.import": "Importeren",
"menu.integrations": "Integraties",
"menu.logout": "Uitloggen",
"menu.mark_all_as_read": "Markeer alles als gelezen",
"menu.mark_page_as_read": "Markeer deze pagina als gelezen",
"menu.preferences": "Voorkeuren",
"menu.refresh_all_feeds": "Vernieuw alle feeds in de achtergrond",
"menu.refresh_feed": "Vernieuwen",
"menu.search": "Zoeken",
"menu.sessions": "Sessies",
"menu.settings": "Instellingen",
"menu.shared_entries": "Gedeelde artikelen",
"menu.show_all_entries": "Toon alle artikelen",
"menu.show_only_starred_entries": "Toon alleen favorieten",
"menu.show_only_unread_entries": "Toon alleen ongelezen artikelen",
"menu.starred": "Favorieten",
"menu.title": "Menu",
"menu.unread": "Ongelezen",
"menu.users": "Gebruikers",
"page.about.author": "Auteur:",
"page.about.build_date": "Compilatiedatum:",
"page.about.credits": "Credits",
"page.about.db_usage": "Databasegrootte:",
"page.about.git_commit": "Git-commit:",
"page.about.global_config_options": "Globale Configuratie Opties",
"page.about.go_version": "Go versie:",
"page.about.license": "Licentie:",
"page.about.postgres_version": "Postgres versie:",
"page.about.title": "Over",
"page.about.version": "Versie:",
"page.add_feed.choose_feed": "Feed kiezen",
"page.add_feed.label.url": "URL-adres",
"page.add_feed.legend.advanced_options": "Geavanceerde opties",
"page.add_feed.no_category": "Er is geen categorie. Je moet minstens één categorie hebben.",
"page.add_feed.submit": "Feed zoeken",
"page.add_feed.title": "Nieuwe feed",
"page.api_keys.never_used": "Nooit gebruikt",
"page.api_keys.table.actions": "Acties",
"page.api_keys.table.created_at": "Aanmaakdatum",
"page.api_keys.table.description": "Omschrijving",
"page.api_keys.table.last_used_at": "Laatst gebruikt",
"page.api_keys.table.token": "API-token",
"page.api_keys.title": "API-sleutels",
"page.categories.entries": "Artikelen",
"page.categories.feed_count": [
"Er is %d feed.",
"Er zijn %d feeds."
],
"page.categories.feeds": "Feeds",
"page.categories.no_feed": "Geen feed.",
"page.categories.title": "Categorieën",
"page.categories_count": [
"%d categorie",
"%d categorieën"
],
"page.category_label": "Categorie: %s",
"page.edit_category.title": "Bewerk categorie: %s",
"page.edit_feed.etag_header": "ETAG header:",
"page.edit_feed.last_check": "Laatste controle:",
"page.edit_feed.last_modified_header": "LastModified-header:",
"page.edit_feed.last_parsing_error": "Laatste analysefout",
"page.edit_feed.no_header": "Geen",
"page.edit_feed.title": "Bewerk feed: %s",
"page.edit_user.title": "Bewerk gebruiker: %s",
"page.entry.attachments": "Bijlagen",
"page.feeds.error_count": [
"%d fout",
"%d fouten"
],
"page.feeds.last_check": "Laatste controle:",
"page.feeds.next_check": "Volgende controle:",
"page.feeds.read_counter": "Aantal gelezen artikelen",
"page.feeds.title": "Feeds",
"page.footer.elevator": "Terug naar boven",
"page.history.title": "Geschiedenis",
"page.import.title": "Importeren",
"page.integration.bookmarklet": "Bookmarklet",
"page.integration.bookmarklet.help": "Gebruik deze link als bookmark in je browser om je direct te abonneren op een website.",
"page.integration.bookmarklet.instructions": "Sleep deze link naar je bookmarks.",
"page.integration.bookmarklet.name": "Toevoegen aan Miniflux",
"page.integration.miniflux_api": "Miniflux-API",
"page.integration.miniflux_api_endpoint": "API-URL",
"page.integration.miniflux_api_password": "Wachtwoord",
"page.integration.miniflux_api_password_value": "Wachtwoord van jouw account",
"page.integration.miniflux_api_username": "Gebruikersnaam",
"page.integrations.title": "Integraties",
"page.keyboard_shortcuts.close_modal": "Dialoogvenster sluiten",
"page.keyboard_shortcuts.download_content": "Download originele inhoud",
"page.keyboard_shortcuts.go_to_bottom_item": "Ga naar het onderste artikel",
"page.keyboard_shortcuts.go_to_categories": "Ga naar categorieën",
"page.keyboard_shortcuts.go_to_feed": "Ga naar feed",
"page.keyboard_shortcuts.go_to_feeds": "Ga naar feeds",
"page.keyboard_shortcuts.go_to_history": "Ga naar geschiedenis",
"page.keyboard_shortcuts.go_to_next_item": "Volgend artikel",
"page.keyboard_shortcuts.go_to_next_page": "Volgende pagina",
"page.keyboard_shortcuts.go_to_previous_item": "Vorig artikel",
"page.keyboard_shortcuts.go_to_previous_page": "Vorige pagina",
"page.keyboard_shortcuts.go_to_search": "Focus instellen op zoekformulier",
"page.keyboard_shortcuts.go_to_settings": "Ga naar instellingen",
"page.keyboard_shortcuts.go_to_starred": "Ga naar favorieten",
"page.keyboard_shortcuts.go_to_top_item": "Ga naar het bovenste artikel",
"page.keyboard_shortcuts.go_to_unread": "Ga naar ongelezen",
"page.keyboard_shortcuts.mark_page_as_read": "Markeer huidige pagina als gelezen",
"page.keyboard_shortcuts.open_comments": "Open reacties",
"page.keyboard_shortcuts.open_comments_same_window": "Open reacties in huidig tabblad",
"page.keyboard_shortcuts.open_item": "Open geselecteerd artikel",
"page.keyboard_shortcuts.open_original": "Open originele link",
"page.keyboard_shortcuts.open_original_same_window": "Open originele link in huidig tabblad",
"page.keyboard_shortcuts.refresh_all_feeds": "Vernieuw alle feeds in de achtergrond",
"page.keyboard_shortcuts.remove_feed": "Verwijder deze feed",
"page.keyboard_shortcuts.save_article": "Artikel opslaan",
"page.keyboard_shortcuts.scroll_item_to_top": "Scroll artikel naar boven",
"page.keyboard_shortcuts.show_keyboard_shortcuts": "Sneltoetsen tonen",
"page.keyboard_shortcuts.subtitle.actions": "Acties",
"page.keyboard_shortcuts.subtitle.items": "Navigeren door artikelen",
"page.keyboard_shortcuts.subtitle.pages": "Navigeren door pagina's",
"page.keyboard_shortcuts.subtitle.sections": "Navigeren door menu's",
"page.keyboard_shortcuts.title": "Sneltoetsen",
"page.keyboard_shortcuts.toggle_star_status": "Favoriet toevoegen/verwijderen",
"page.keyboard_shortcuts.toggle_entry_attachments": "Bijlagen van artikel openen/sluiten",
"page.keyboard_shortcuts.toggle_read_status_next": "Markeer gelezen/ongelezen, focus volgende",
"page.keyboard_shortcuts.toggle_read_status_prev": "Markeer gelezen/ongelezen, focus vorige",
"page.login.google_signin": "Inloggen met Google",
"page.login.oidc_signin": "Inloggen met %s",
"page.login.title": "Inloggen",
"page.login.webauthn_login": "Inloggen met passkey",
"page.login.webauthn_login.error": "Kan niet inloggen met passkey",
"page.login.webauthn_login.help": "Voer je gebruikersnaam in als je een beveiligingssleutel gebruikt. Dit is niet nodig als je een Passkey (ontdekkingsbare referenties) gebruikt.",
"page.new_api_key.title": "Nieuwe API-sleutel",
"page.new_category.title": "Nieuwe categorie",
"page.new_user.title": "Nieuwe gebruiker",
"page.offline.message": "Je bent offline",
"page.offline.refresh_page": "Probeer de pagina te vernieuwen",
"page.offline.title": "Offline modus",
"page.read_entry_count": [
"%d gelezen artikel",
"%d gelezen artikelen"
],
"page.search.title": "Zoekresultaten",
"page.sessions.table.actions": "Acties",
"page.sessions.table.current_session": "Huidige sessie",
"page.sessions.table.date": "Datum",
"page.sessions.table.ip": "IP-adres",
"page.sessions.table.user_agent": "User-agent",
"page.sessions.title": "Sessies",
"page.settings.link_google_account": "Koppel mijn Google-account",
"page.settings.link_oidc_account": "Koppel mijn %s account",
"page.settings.title": "Instellingen",
"page.settings.unlink_google_account": "Ontkoppel mijn Google-account",
"page.settings.unlink_oidc_account": "Ontkoppel mijn %s account",
"page.settings.webauthn.actions": "Acties",
"page.settings.webauthn.added_on": "Toegevoegd op",
"page.settings.webauthn.delete": [
"Verwijder %d passkey",
"Verwijder %d passkeys"
],
"page.settings.webauthn.last_seen_on": "Laatst gebruikt",
"page.settings.webauthn.passkey_name": "Passkey Naam",
"page.settings.webauthn.passkeys": "Passkeys",
"page.settings.webauthn.register": "Passkey registreren",
"page.settings.webauthn.register.error": "Kan passkey niet registreren",
"page.shared_entries.title": "Gedeelde artikelen",
"page.shared_entries_count": [
"%d gedeeld artikel",
"%d gedeelde artikelen"
],
"page.starred.title": "Favorieten",
"page.starred_entry_count": [
"%d favoriet artikel",
"%d favoriete artikelen"
],
"page.total_entry_count": [
"%d artikel totaal",
"%d artikelen totaal"
],
"page.unread.title": "Ongelezen",
"page.unread_entry_count": [
"%d ongelezen artikel",
"%d ongelezen artikelen"
],
"page.users.actions": "Acties",
"page.users.admin.no": "Nee",
"page.users.admin.yes": "Ja",
"page.users.is_admin": "Beheerder",
"page.users.last_login": "Laatste login",
"page.users.never_logged": "Nooit",
"page.users.title": "Gebruikers",
"page.users.username": "Gebruikersnaam",
"page.webauthn_rename.title": "Hernoem Passkey",
"pagination.first": "Eerste",
"pagination.last": "Laatste",
"pagination.next": "Volgende",
"pagination.previous": "Vorige",
"search.label": "Zoeken",
"search.placeholder": "Zoeken...",
"search.submit": "Zoeken",
"skip_to_content": "Ga naar inhoud",
"time_elapsed.days": [
"%d dag geleden",
"%d dagen geleden"
],
"time_elapsed.hours": [
"%d uur geleden",
"%d uur geleden"
],
"time_elapsed.minutes": [
"%d minuut geleden",
"%d minuten geleden"
],
"time_elapsed.months": [
"%d maand geleden",
"%d maanden geleden"
],
"time_elapsed.not_yet": "nog niet",
"time_elapsed.now": "minder dan een minuut geleden",
"time_elapsed.weeks": [
"%d week geleden",
"%d weken geleden"
],
"time_elapsed.years": [
"%d jaar geleden",
"%d jaar geleden"
],
"time_elapsed.yesterday": "gisteren",
"tooltip.keyboard_shortcuts": "Sneltoets: %s",
"tooltip.logged_user": "Ingelogd als %s"
}
v2-2.2.16/internal/locale/translations/pl_PL.json 0000664 0000000 0000000 00000120052 15127074645 0021641 0 ustar 00root root 0000000 0000000 {
"action.cancel": "anuluj",
"action.download": "Pobierz",
"action.edit": "Edytuj",
"action.home_screen": "Dodaj do ekranu głównego",
"action.import": "Importuj",
"action.login": "Zaloguj się",
"action.or": "lub",
"action.remove": "Usuń",
"action.remove_feed": "Usuń ten kanał",
"action.save": "Zapisz",
"action.subscribe": "Subskrypcja",
"action.update": "Zaktualizuj",
"alert.account_linked": "Twoje konto zewnętrzne jest teraz połączone!",
"alert.account_unlinked": "Twoje konto zewnętrzne jest teraz zdysocjowane!",
"alert.background_feed_refresh": "Wszystkie kanały są odświeżane w tle. Możesz kontynuować korzystanie z Miniflux podczas trwania tego procesu.",
"alert.feed_error": "Z tym kanałem jest problem",
"alert.no_starred": "Brak ulubionych w tej chwili.",
"alert.no_category": "Brak kategorii!",
"alert.no_category_entry": "Brak wpisów w tej kategorii",
"alert.no_feed": "Nie masz żadnej subskrypcji.",
"alert.no_feed_entry": "Brak wpisów tego kanału.",
"alert.no_feed_in_category": "Nie ma subskrypcji tej kategorii.",
"alert.no_history": "Obecnie nie ma żadnej historii.",
"alert.no_search_result": "Brak wyników tego wyszukiwania.",
"alert.no_shared_entry": "Brak udostępnionego wpisu.",
"alert.no_tag_entry": "Brak wpisów pasujących do tego znacznika.",
"alert.no_unread_entry": "Nie ma żadnych nieprzeczytanych wpisów.",
"alert.no_user": "Jesteś jedynym użytkownikiem.",
"alert.prefs_saved": "Ustawienia zapisane!",
"alert.too_many_feeds_refresh": [
"Wykonano zbyt wiele odświeżeń kanału. Poczekaj %d minutę przed ponowną próbą.",
"Wykonano zbyt wiele odświeżeń kanału. Poczekaj %d minuty przed ponowną próbą.",
"Wykonano zbyt wiele odświeżeń kanału. Poczekaj %d minut przed ponowną próbą."
],
"confirm.loading": "W toku…",
"confirm.no": "nie",
"confirm.question": "Czy na pewno?",
"confirm.question.refresh": "Czy na pewno chcesz wymusić odświeżenie?",
"confirm.yes": "tak",
"enclosure_media_controls.seek": "Przewiń:",
"enclosure_media_controls.seek.title": "Przewiń o %s sek.",
"enclosure_media_controls.speed": "Szybkość:",
"enclosure_media_controls.speed.faster": "Szybciej",
"enclosure_media_controls.speed.faster.title": "Szybciej o %sx",
"enclosure_media_controls.speed.reset": "Przywróć",
"enclosure_media_controls.speed.reset.title": "Przywróć szybkość do 1x",
"enclosure_media_controls.speed.slower": "Wolniej",
"enclosure_media_controls.speed.slower.title": "Wolniej o %sx",
"entry.starred.toast.off": "Usunięto z ulubionych",
"entry.starred.toast.on": "Dodano do ulubionych",
"entry.starred.toggle.off": "Usuń z ulubionych",
"entry.starred.toggle.on": "Dodaj do ulubionych",
"entry.comments.label": "Komentarze",
"entry.comments.title": "Zobacz komentarze",
"entry.estimated_reading_time": [
"%d minuta czytania",
"%d minuty czytania",
"%d minut czytania"
],
"entry.external_link.label": "Łącze zewnętrzne",
"entry.save.completed": "Gotowe!",
"entry.save.label": "Zapisz",
"entry.save.title": "Zapisz ten wpis",
"entry.save.toast.completed": "Zapisano wpis",
"entry.scraper.completed": "Gotowe!",
"entry.scraper.label": "Pobierz treść",
"entry.scraper.title": "Pobierz oryginalną treść",
"entry.share.label": "Udostępnij",
"entry.share.title": "Udostępnij ten wpis",
"entry.shared_entry.label": "Udostępnij",
"entry.shared_entry.title": "Otwórz publiczne łącze",
"entry.state.loading": "Ładowanie…",
"entry.state.saving": "Zapisywanie…",
"entry.status.mark_as_read": "Oznacz jako przeczytany",
"entry.status.mark_as_unread": "Oznacz jako nieprzeczytany",
"entry.status.title": "Zmień status wpisu",
"entry.status.toast.read": "Oznaczono jako przeczytany",
"entry.status.toast.unread": "Oznaczono jako nieprzeczytany",
"entry.tags.label": "Znaczniki:",
"entry.tags.more_tags_label": [
"Dodaj znacznik",
"Dodaj %d znaczniki",
"Dodaj %d znaczników"
],
"entry.unshare.label": "Cofnij udostępnianie",
"error.api_key_already_exists": "Ten klucz API już istnieje.",
"error.bad_credentials": "Nieprawidłowa nazwa użytkownika lub hasło.",
"error.category_already_exists": "Ta kategoria już istnieje.",
"error.category_not_found": "Ta kategoria nie istnieje lub nie należy do tego użytkownika.",
"error.database_error": "Błąd bazy danych: %v.",
"error.different_passwords": "Hasła nie są identyczne.",
"error.duplicate_fever_username": "Już ktoś inny używa tej nazwy użytkownika Fever!",
"error.duplicate_googlereader_username": "Istnieje już ktoś inny z tą samą nazwą użytkownika Google Reader!",
"error.duplicate_linked_account": "Już ktoś jest powiązany z tym dostawcą!",
"error.duplicated_feed": "Ten kanał już istnieje.",
"error.empty_file": "Ten plik jest pusty.",
"error.entries_per_page_invalid": "Liczba wpisów na stronę jest nieprawidłowa.",
"error.feed_already_exists": "Ten kanał już istnieje.",
"error.feed_category_not_found": "Ta kategoria nie istnieje lub nie należy do tego użytkownika.",
"error.feed_format_not_detected": "Nie można wykryć formatu kanału: %v.",
"error.feed_invalid_blocklist_rule": "Reguła listy zablokowanych jest nieprawidłowa.",
"error.feed_invalid_keeplist_rule": "Reguła listy zachowywania jest nieprawidłowa.",
"error.feed_mandatory_fields": "Adres URL i kategoria są obowiązkowe.",
"error.feed_not_found": "Ten kanał nie istnieje lub nie należy do tego użytkownika.",
"error.feed_title_not_empty": "Tytuł kanału nie może być pusty.",
"error.feed_url_not_empty": "Adres URL kanału nie może być pusty.",
"error.fields_mandatory": "Wszystkie pola są obowiązkowe.",
"error.http_bad_gateway": "Strona jest w tej chwili niedostępna z powodu błędu nieprawidłowej bramy. Problem nie leży po stronie Miniflux. Spróbuj ponownie później.",
"error.http_body_read": "Nie można odczytać treści HTTP: %v.",
"error.http_client_error": "Błąd klienta HTTP: %v.",
"error.http_empty_response": "Odpowiedź HTTP jest pusta. Być może ta witryna korzysta z mechanizmu ochrony przed botami?",
"error.http_empty_response_body": "Treść odpowiedzi HTTP jest pusta.",
"error.http_forbidden": "Dostęp do tej strony jest zabroniony. Być może ta strona ma mechanizm zabezpieczający przed botami?",
"error.http_gateway_timeout": "Strona internetowa jest w tej chwili niedostępna z powodu błędu przekroczenia limitu czasu bramy. Problem nie leży po stronie Miniflux. Spróbuj ponownie później.",
"error.http_internal_server_error": "Strona jest w tej chwili niedostępna z powodu błędu serwera. Problem nie leży po stronie Miniflux. Spróbuj ponownie później.",
"error.http_not_authorized": "Dostęp do tej witryny nie jest autoryzowany. Może to być błędna nazwa użytkownika lub hasło.",
"error.http_resource_not_found": "Nie znaleziono żądanego zasobu. Sprawdź adres URL.",
"error.http_response_too_large": "Odpowiedź HTTP jest za duża. Możesz zwiększyć limit rozmiaru odpowiedzi HTTP w ustawieniach globalnych (wymaga ponownego uruchomienia serwera).",
"error.http_service_unavailable": "Strona jest w tej chwili niedostępna z powodu wewnętrznego błędu serwera. Problem nie leży po stronie Miniflux. Spróbuj ponownie później.",
"error.http_too_many_requests": "Miniflux wygenerował zbyt wiele żądań do tej witryny. Spróbuj ponownie później lub zmień konfigurację aplikacji.",
"error.http_unexpected_status_code": "Strona jest w tej chwili niedostępna z powodu nieoczekiwanego kodu stanu HTTP: %d. Problem nie leży po stronie Miniflux. Spróbuj ponownie później.",
"error.invalid_categories_sorting_order": "Nieprawidłowa kolejność sortowania kategorii.",
"error.invalid_default_home_page": "Nieprawidłowa domyślna strona główna!",
"error.invalid_display_mode": "Nieprawidłowy tryb wyświetlania aplikacji sieciowej.",
"error.invalid_entry_direction": "Nieprawidłowa kolejność sortowania.",
"error.invalid_entry_order": "Nieprawidłowa kolejność sortowania wpisów.",
"error.invalid_feed_proxy_url": "Nieprawidłowy adres URL serwera proxy.",
"error.invalid_feed_url": "Nieprawidłowy adres URL kanału.",
"error.invalid_gesture_nav": "Nieprawidłowa nawigacja gestami.",
"error.invalid_language": "Nieprawidłowy język.",
"error.invalid_site_url": "Nieprawidłowy adres URL witryny.",
"error.invalid_theme": "Nieprawidłowy motyw.",
"error.invalid_timezone": "Nieprawidłowa strefa czasowa.",
"error.network_operation": "Miniflux nie może połączyć się z tą witryną z powodu błędu sieci: %v.",
"error.network_timeout": "Ta witryna internetowa jest zbyt wolna i upłynął limit czasu żądania: %v",
"error.password_min_length": "Musisz użyć co najmniej 6 znaków.",
"error.proxy_url_not_empty": "Adres URL serwera proxy nie może być pusty.",
"error.settings_block_rule_fieldname_invalid": "Nieprawidłowa reguła blokowania: w regule #%d brakuje prawidłowej nazwy pola (opcje: %s)",
"error.settings_block_rule_invalid_regex": "Nieprawidłowa reguła blokowania: wzór reguły #%d nie jest prawidłowym wyrażeniem regularnym",
"error.settings_block_rule_regex_required": "Nieprawidłowa reguła blokowania: nie podano wzorca reguły #%d",
"error.settings_block_rule_separator_required": "Nieprawidłowa reguła blokowania: wzór reguły #%d musi być oddzielony znakiem '='",
"error.settings_invalid_domain_list": "Nieprawidłowa lista domen. Podaj listę domen rozdzielonych spacjami.",
"error.settings_keep_rule_fieldname_invalid": "Nieprawidłowa reguła utrzymywania: w regule #%d brakuje prawidłowej nazwy pola (opcje: %s)",
"error.settings_keep_rule_invalid_regex": "Nieprawidłowa reguła utrzymywania: wzór reguły #%d nie jest prawidłowym wyrażeniem regularnym",
"error.settings_keep_rule_regex_required": "Nieprawidłowa reguła utrzymywania nie podano wzorca reguły #%d",
"error.settings_keep_rule_separator_required": "Nieprawidłowa reguła utrzymywania: wzór reguły #%d musi być oddzielony znakiem '='",
"error.settings_mandatory_fields": "Pola nazwy użytkownika, tematu, języka i strefy czasowej są obowiązkowe.",
"error.settings_media_playback_rate_range": "Szybkość odtwarzania jest poza zakresem",
"error.settings_reading_speed_is_positive": "Szybkości czytania muszą być dodatnimi liczbami całkowitymi.",
"error.site_url_not_empty": "Adres URL witryny nie może być pusty.",
"error.subscription_not_found": "Nie znaleziono żadnych kanałów.",
"error.title_required": "Tytuł jest obowiązkowy.",
"error.tls_error": "Błąd TLS: %q. Jeśli chcesz, możesz wyłączyć weryfikację TLS w ustawieniach kanału.",
"error.unable_to_create_api_key": "Nie można utworzyć tego klucza API.",
"error.unable_to_create_category": "Ta kategoria nie mogła zostać utworzona.",
"error.unable_to_create_user": "Nie można utworzyć tego użytkownika.",
"error.unable_to_detect_rssbridge": "Nie można wykryć kanału za pomocą RSS-Bridge: %v.",
"error.unable_to_parse_feed": "Nie można przeanalizować tego kanału: %v.",
"error.unable_to_update_category": "Ta kategoria nie mogła zostać zaktualizowana.",
"error.unable_to_update_feed": "Nie można zaktualizować tego kanału.",
"error.unable_to_update_user": "Nie można zaktualizować tego użytkownika.",
"error.unlink_account_without_password": "Musisz zdefiniować hasło, inaczej nie będziesz mógł się ponownie zalogować.",
"error.user_already_exists": "Ten użytkownik już istnieje.",
"error.user_mandatory_fields": "Nazwa użytkownika jest obowiązkowa.",
"error.linktaco_missing_required_fields": "Token API LinkTaco i ślimak organizacji są wymagane",
"form.api_key.label.description": "Etykieta klucza API",
"form.category.hide_globally": "Ukryj wpisy na globalnej liście nieprzeczytanych",
"form.category.label.title": "Tytuł",
"form.feed.fieldset.general": "Ogólne",
"form.feed.fieldset.integration": "Usługi dostawców zewnętrznych",
"form.feed.fieldset.network_settings": "Ustawienia sieci",
"form.feed.fieldset.rules": "Reguły",
"form.feed.label.allow_self_signed_certificates": "Zezwalaj na samopodpisane lub nieprawidłowe certyfikaty",
"form.feed.label.apprise_service_urls": "Rozdzielana przecinkami lista adresów URL usług Appprise",
"form.feed.label.block_filter_entry_rules": "Reguły blokowania wpisów",
"form.feed.label.blocklist_rules": "Filtry blokowania oparte na wyrażeniach regularnych",
"form.feed.label.category": "Kategoria",
"form.feed.label.cookie": "Ustaw ciasteczka",
"form.feed.label.crawler": "Pobierz oryginalną treść",
"form.feed.label.description": "Opis",
"form.feed.label.disable_http2": "Wyłącz protokół HTTP/2, aby uniknąć identyfikowania",
"form.feed.label.disabled": "Nie aktualizuj tego kanału",
"form.feed.label.feed_password": "Hasło do subskrypcji",
"form.feed.label.feed_url": "Adres URL kanału",
"form.feed.label.feed_username": "Nazwa użytkownika subskrypcji",
"form.feed.label.fetch_via_proxy": "Użyj serwera proxy skonfigurowanego na poziomie aplikacji",
"form.feed.label.hide_globally": "Ukryj wpisy na globalnej liście nieprzeczytanych",
"form.feed.label.ignore_http_cache": "Zignoruj pamięć podręczną HTTP",
"form.feed.label.keep_filter_entry_rules": "Reguły zachowywania wpisów",
"form.feed.label.keeplist_rules": "Filtry zachowywania oparte na wyrażeniach regularnych",
"form.feed.label.no_media_player": "Brak odtwarzacza multimedialnego (audio i wideo)",
"form.feed.label.ntfy_activate": "Prześlij wpisy do ntfy",
"form.feed.label.ntfy_default_priority": "Domyślny priorytet ntfy",
"form.feed.label.ntfy_high_priority": "Wysoki priorytet ntfy",
"form.feed.label.ntfy_low_priority": "Niski priorytet ntfy",
"form.feed.label.ntfy_max_priority": "Maksymalny priorytet ntfy",
"form.feed.label.ntfy_min_priority": "Minimalny priorytet ntfy",
"form.feed.label.ntfy_priority": "Priorytet ntfy",
"form.feed.label.ntfy_topic": "Temat ntfy (opcjonalny)",
"form.feed.label.proxy_url": "Adres URL serwera proxy",
"form.feed.label.pushover_activate": "Prześlij wpisy do pushover.net",
"form.feed.label.pushover_default_priority": "Domyślny priorytet Pushover",
"form.feed.label.pushover_high_priority": "Wysoki priorytet Pushover",
"form.feed.label.pushover_low_priority": "Niski priorytet Pushover",
"form.feed.label.pushover_max_priority": "Maksymalny priorytet Pushover",
"form.feed.label.pushover_min_priority": "Minimalny priorytet Pushover",
"form.feed.label.pushover_priority": "Priorytet wiadomości Pushover",
"form.feed.label.rewrite_rules": "Reguły przepisywania treści",
"form.feed.label.scraper_rules": "Reguły ekstrakcji",
"form.feed.label.site_url": "Adres URL strony",
"form.feed.label.title": "Tytuł",
"form.feed.label.urlrewrite_rules": "Reguły przepisywania adresów URL",
"form.feed.label.user_agent": "Zastąp domyślny agent użytkownika",
"form.feed.label.webhook_url": "Zastąp adres URL webhooka",
"form.import.label.file": "Plik OPML",
"form.import.label.url": "Adres URL",
"form.integration.archiveorg_activate": "Prześlij wpisy do archive.org",
"form.integration.apprise_activate": "Przesyłaj wpisy do Apprise",
"form.integration.apprise_services_url": "Oddzielona przecinkami lista adresów URL usługi Apprise",
"form.integration.apprise_url": "Adres URL API Apprise",
"form.integration.betula_activate": "Zapisuj wpisy w Betula",
"form.integration.betula_token": "Token do Betula",
"form.integration.betula_url": "Adres URL serwera Betula",
"form.integration.cubox_activate": "Zapisuj wpisy w Cubox",
"form.integration.cubox_api_link": "Łącze API Cubox",
"form.integration.discord_activate": "Przesyłaj wpisy do Discord",
"form.integration.discord_webhook_link": "Adres URL webhooka Discord",
"form.integration.espial_activate": "Zapisuj wpisy w Espial",
"form.integration.espial_api_key": "Klucz API do Espial",
"form.integration.espial_endpoint": "Punkt końcowy API Espial",
"form.integration.espial_tags": "Znaczniki Espial",
"form.integration.fever_activate": "Aktywuj API Fever",
"form.integration.fever_endpoint": "Punkt końcowy API Fever:",
"form.integration.fever_password": "Hasło do Fever",
"form.integration.fever_username": "Login do Fever",
"form.integration.googlereader_activate": "Aktywuj API Google Reader",
"form.integration.googlereader_endpoint": "Punkt końcowy API Google Reader:",
"form.integration.googlereader_password": "Hasło do Google Reader",
"form.integration.googlereader_username": "Login do Google Reader",
"form.integration.instapaper_activate": "Zapisuj wpisy w Instapaper",
"form.integration.instapaper_password": "Hasło do Instapaper",
"form.integration.instapaper_username": "Login do Instapaper",
"form.integration.karakeep_activate": "Zapisuj wpisy w Karakeep",
"form.integration.karakeep_api_key": "Klucz API do Karakeep",
"form.integration.karakeep_url": "Punkt końcowy API Karakeep",
"form.integration.karakeep_tags": "Znaczniki Karakeep",
"form.integration.linkace_activate": "Zapisuj wpisy w LinkAce",
"form.integration.linkace_api_key": "Klucz API do LinkAce",
"form.integration.linkace_check_disabled": "Wyłącz sprawdzanie łączy",
"form.integration.linkace_endpoint": "Punkt końcowy API LinkAce",
"form.integration.linkace_is_private": "Oznacz łącze jako prywatne",
"form.integration.linkace_tags": "Znaczniki LinkAce",
"form.integration.linkding_activate": "Zapisuj wpisy w Linkding",
"form.integration.linkding_api_key": "Klucz API do Linkding",
"form.integration.linkding_bookmark": "Oznacz zakładkę jako nieprzeczytaną",
"form.integration.linkding_endpoint": "Punkt końcowy API Linkding",
"form.integration.linkding_tags": "Znaczniki Linkding",
"form.integration.linktaco_activate": "Zapisuj wpisy w LinkTaco",
"form.integration.linktaco_api_token": "Token API LinkTaco",
"form.integration.linktaco_api_token_hint": "Uzyskaj osobisty token dostępu na",
"form.integration.linktaco_org_slug": "Ślimak organizacji",
"form.integration.linktaco_tags": "Znaczniki (maks. 10, oddzielone przecinkami)",
"form.integration.linktaco_tags_hint": "Maksymalnie 10 znaczników, oddzielone przecinkami",
"form.integration.linktaco_visibility": "Widoczność",
"form.integration.linktaco_visibility_public": "Publiczne",
"form.integration.linktaco_visibility_private": "Prywatne",
"form.integration.linktaco_visibility_hint": "Widoczność PRYWATNE wymaga płatnego konta LinkTaco",
"form.integration.linkwarden_activate": "Zapisuj wpisy w Linkwarden",
"form.integration.linkwarden_api_key": "Klucz API do Linkwarden",
"form.integration.linkwarden_endpoint": "Podstawowy adres URL Linkwarden",
"form.integration.linkwarden_collection_id": "Identyfikator kolekcji Linkwarden",
"form.integration.matrix_bot_activate": "Przesyłaj nowe wpisy do Matrix",
"form.integration.matrix_bot_chat_id": "Identyfikator pokoju Matrix",
"form.integration.matrix_bot_password": "Hasło do Matrix",
"form.integration.matrix_bot_url": "Adres URL serwera Matrix",
"form.integration.matrix_bot_user": "Login do Matrix",
"form.integration.notion_activate": "Zapisuj wpisy w Notion",
"form.integration.notion_page_id": "Identyfikator strony Notion",
"form.integration.notion_token": "Tajny token do Notion",
"form.integration.ntfy_activate": "Przesyłaj wpisy do ntfy",
"form.integration.ntfy_api_token": "Token API ntfy (opcjonalny)",
"form.integration.ntfy_icon_url": "Adres URL ikony ntfy (opcjonalny)",
"form.integration.ntfy_internal_links": "Używaj łączy wewnętrznych po kliknięciu (opcjonalnie)",
"form.integration.ntfy_password": "Hasło do ntfy (opcjonalne)",
"form.integration.ntfy_topic": "Temat ntfy (domyślny, jeśli nie został ustawiony w kanale)",
"form.integration.ntfy_url": "Adres URL ntfy (opcjonalny, domyślny to ntfy.sh)",
"form.integration.ntfy_username": "Login do ntfy (opcjonalny)",
"form.integration.nunux_keeper_activate": "Zapisuj wpisy w Nunux Keeper",
"form.integration.nunux_keeper_api_key": "Klucz API do Nunux Keeper",
"form.integration.nunux_keeper_endpoint": "Punkt końcowy API Nunux Keeper",
"form.integration.omnivore_activate": "Zapisuj wpisy w Omnivore",
"form.integration.omnivore_api_key": "Klucz API do Omnivore",
"form.integration.omnivore_url": "Punkt końcowy API Omnivore",
"form.integration.pinboard_activate": "Zapisuj wpisy w Pinboard",
"form.integration.pinboard_bookmark": "Zaznacz zakładkę jako nieprzeczytaną",
"form.integration.pinboard_tags": "Znaczniki Pinboard",
"form.integration.pinboard_token": "Token API do Pinboard",
"form.integration.pushover_activate": "Prześlij wpisy do Pushover",
"form.integration.pushover_device": "Urządzenie Pushover (opcjonalne)",
"form.integration.pushover_prefix": "Prefiks adresu URL Pushover (opcjonalny)",
"form.integration.pushover_token": "Token API aplikacji Pushover",
"form.integration.pushover_user": "Klucz użytkownika Pushover",
"form.integration.raindrop_activate": "Zapisuj wpisy do Raindrop",
"form.integration.raindrop_collection_id": "Identyfikator kolekcji",
"form.integration.raindrop_tags": "Znaczniki (oddzielone przecinkami)",
"form.integration.raindrop_token": "Token (testowy)",
"form.integration.readeck_activate": "Zapisuj wpisy do Readeck",
"form.integration.readeck_api_key": "Tajny klucz API Readeck",
"form.integration.readeck_endpoint": "Adres URL Readeck",
"form.integration.readeck_labels": "Etykiety Readeck",
"form.integration.readeck_only_url": "Wysyłaj tylko adres URL (zamiast pełnej treści)",
"form.integration.readeck_push_activate": "Automatycznie przesyłaj nowe wpisy do Readeck",
"form.integration.readwise_activate": "Zapisuj wpisy w czytniku Readwise",
"form.integration.readwise_api_key": "Token dostępu do czytnika Readwise",
"form.integration.readwise_api_key_link": "Zdobądź token dostępu Readwise",
"form.integration.rssbridge_activate": "Sprawdź RSS-Bridge podczas dodawania subskrypcji",
"form.integration.rssbridge_token": "Token uwierzytelniający RSS-Bridge",
"form.integration.rssbridge_url": "Adres URL serwera RSS-Bridge",
"form.integration.shaarli_activate": "Zapisuj artykuły w Shaarli",
"form.integration.shaarli_api_secret": "Tajny klucz API do Shaarli",
"form.integration.shaarli_endpoint": "Adres URL Shaarli",
"form.integration.shiori_activate": "Zapisuj artykuły w Shiori",
"form.integration.shiori_endpoint": "Punkt końcowy API Shiori",
"form.integration.shiori_password": "Hasło do Shiori",
"form.integration.shiori_username": "Login do Shiori",
"form.integration.slack_activate": "Przesyłaj wpisy do Slack",
"form.integration.slack_webhook_link": "Łącze webhooka Slack",
"form.integration.telegram_bot_activate": "Przesyłaj nowe wpisy do czatu Telegram",
"form.integration.telegram_bot_disable_buttons": "Wyłącz przyciski",
"form.integration.telegram_bot_disable_notification": "Wyłącz powiadomienie",
"form.integration.telegram_bot_disable_web_page_preview": "Wyłącz podgląd strony internetowej",
"form.integration.telegram_bot_token": "Token do bota",
"form.integration.telegram_chat_id": "Identyfikator czatu",
"form.integration.telegram_topic_id": "Identyfikator tematu",
"form.integration.wallabag_activate": "Zapisuj wpisy w Wallabag",
"form.integration.wallabag_client_id": "Identyfikator klienta Wallabag",
"form.integration.wallabag_client_secret": "Tajny klucz klienta Wallabag",
"form.integration.wallabag_endpoint": "Podstawowy adres URL Wallabag",
"form.integration.wallabag_tags": "Znaczniki Wallabag",
"form.integration.wallabag_only_url": "Przesyłaj tylko adres URL (zamiast pełnej treści)",
"form.integration.wallabag_password": "Hasło do Wallabag",
"form.integration.wallabag_username": "Login do Wallabag",
"form.integration.webhook_activate": "Włącz webhooki",
"form.integration.webhook_secret": "Tajny klucz do webhooków",
"form.integration.webhook_url": "Domyślny adres URL webhooka",
"form.prefs.fieldset.application_settings": "Ustawienia aplikacji",
"form.prefs.fieldset.authentication_settings": "Ustawienia uwierzytelniania",
"form.prefs.fieldset.global_feed_settings": "Globalne ustawienia kanałów",
"form.prefs.fieldset.reader_settings": "Ustawienia czytnika",
"form.prefs.help.external_font_hosts": "Lista hostów zewnętrznych czcionek, na które należy zezwolić, rozdzielona spacjami. Na przykład: „fonts.gstatic.com fonts.googleapis.com”.",
"form.prefs.label.always_open_external_links": "Czytaj artykuły, otwierając łącza zewnętrzne",
"form.prefs.label.categories_sorting_order": "Sortowanie kategorii",
"form.prefs.label.cjk_reading_speed": "Szybkość czytania w języku chińskim, koreańskim i japońskim (znaki na minutę)",
"form.prefs.label.custom_css": "Niestandardowy CSS",
"form.prefs.label.custom_js": "Niestandardowy JavaScript",
"form.prefs.label.default_home_page": "Domyślna strona główna",
"form.prefs.label.default_reading_speed": "Szybkość czytania w innych językach (słowa na minutę)",
"form.prefs.label.display_mode": "Tryb wyświetlania progresywnej aplikacji sieciowej (PWA)",
"form.prefs.label.entries_per_page": "Wpisy na stronę",
"form.prefs.label.entry_order": "Kolumna sortowania wpisów",
"form.prefs.label.entry_sorting": "Sortowanie wpisów",
"form.prefs.label.entry_swipe": "Włącz przesuwanie wpisów na ekranach dotykowych",
"form.prefs.label.external_font_hosts": "Hosty zewnętrznych czcionek",
"form.prefs.label.gesture_nav": "Gest do poruszania się między wpisami",
"form.prefs.label.keyboard_shortcuts": "Włącz skróty klawiszowe",
"form.prefs.label.language": "Język",
"form.prefs.label.mark_read_manually": "Oznacz wpisy jako przeczytane ręcznie",
"form.prefs.label.mark_read_on_media_completion": "Oznacz jako przeczytane dopiero wtedy, gdy odtwarzanie audio i wideo osiągnie 90%% ukończenia",
"form.prefs.label.mark_read_on_view": "Automatycznie oznacz wpisy jako przeczytane podczas przeglądania",
"form.prefs.label.mark_read_on_view_or_media_completion": "Oznacz wpisy jako przeczytane po wyświetleniu. W przypadku audio i wideo oznacz jako przeczytane po ukończeniu 90%%",
"form.prefs.label.media_playback_rate": "Szybkość odtwarzania audio i wideo",
"form.prefs.label.open_external_links_in_new_tab": "Otwieraj łącza zewnętrzne w nowej karcie (dodaje target=\"_blank\" do łączy)",
"form.prefs.label.show_reading_time": "Pokaż szacowany czas czytania wpisów",
"form.prefs.label.theme": "Wygląd",
"form.prefs.label.timezone": "Strefa czasowa",
"form.prefs.select.alphabetical": "Alfabetycznie",
"form.prefs.select.browser": "Przeglądarkowy",
"form.prefs.select.created_time": "Czas utworzenia wpisu",
"form.prefs.select.fullscreen": "Pełnoekranowy",
"form.prefs.select.minimal_ui": "Minimalny",
"form.prefs.select.none": "Brak",
"form.prefs.select.older_first": "Najstarsze wpisy jako pierwsze",
"form.prefs.select.publish_time": "Czas publikacji wpisu",
"form.prefs.select.recent_first": "Najnowsze wpisy jako pierwsze",
"form.prefs.select.standalone": "Samodzielny",
"form.prefs.select.swipe": "Przesuwanie",
"form.prefs.select.tap": "Podwójne stuknięcie",
"form.prefs.select.unread_count": "Liczba nieprzeczytanych",
"form.submit.loading": "Ładowanie…",
"form.submit.saving": "Zapisywanie…",
"form.user.label.admin": "Administrator",
"form.user.label.confirmation": "Potwierdzenie hasła",
"form.user.label.password": "Hasło",
"form.user.label.username": "Nazwa użytkownika",
"menu.about": "O czytniku",
"menu.add_feed": "Dodaj kanał",
"menu.add_user": "Dodaj użytkownika",
"menu.api_keys": "Klucze API",
"menu.categories": "Kategorie",
"menu.create_api_key": "Utwórz nowy klucz API",
"menu.create_category": "Utwórz kategorię",
"menu.edit_category": "Edytuj",
"menu.edit_feed": "Edytuj",
"menu.export": "Eksportuj",
"menu.feed_entries": "Wpisy",
"menu.feeds": "Kanały",
"menu.flush_history": "Usuń historię",
"menu.history": "Historia",
"menu.home_page": "Strona główna",
"menu.import": "Importuj",
"menu.integrations": "Usługi",
"menu.logout": "Wyloguj się",
"menu.mark_all_as_read": "Oznacz wszystkie jako przeczytane",
"menu.mark_page_as_read": "Oznacz jako przeczytane",
"menu.preferences": "Preferencje",
"menu.refresh_all_feeds": "Odśwież w tle wszystkie subskrypcje",
"menu.refresh_feed": "Odśwież",
"menu.search": "Szukaj",
"menu.sessions": "Sesje",
"menu.settings": "Ustawienia",
"menu.shared_entries": "Udostępnione wpisy",
"menu.show_all_entries": "Pokaż wszystkie wpisy",
"menu.show_only_starred_entries": "Pokaż tylko ulubione wpisy",
"menu.show_only_unread_entries": "Pokaż tylko nieprzeczytane wpisy",
"menu.starred": "Ulubione",
"menu.title": "Menu",
"menu.unread": "Nieprzeczytane",
"menu.users": "Użytkownicy",
"page.about.author": "Autor:",
"page.about.build_date": "Data opracowania:",
"page.about.credits": "Prawa autorskie",
"page.about.db_usage": "Rozmiar bazy danych:",
"page.about.git_commit": "Zatwierdzenie Git:",
"page.about.global_config_options": "Globalne opcje konfiguracji",
"page.about.go_version": "Wersja Go:",
"page.about.license": "Licencja:",
"page.about.postgres_version": "Wersja PostgreSQL:",
"page.about.title": "O stronie",
"page.about.version": "Wersja:",
"page.add_feed.choose_feed": "Wybierz subskrypcję",
"page.add_feed.label.url": "Adres URL",
"page.add_feed.legend.advanced_options": "Opcje zaawansowane",
"page.add_feed.no_category": "Nie ma żadnej kategorii. Musisz mieć co najmniej jedną kategorię.",
"page.add_feed.submit": "Znajdź subskrypcję",
"page.add_feed.title": "Nowa subskrypcja",
"page.api_keys.never_used": "Nigdy nie używany",
"page.api_keys.table.actions": "Działania",
"page.api_keys.table.created_at": "Data utworzenia",
"page.api_keys.table.description": "Opis",
"page.api_keys.table.last_used_at": "Ostatnio używane",
"page.api_keys.table.token": "Token",
"page.api_keys.title": "Klucze API",
"page.categories.entries": "Wpisy",
"page.categories.feed_count": [
"Jest %d kanał.",
"Są %d kanały.",
"Jest %d kanałów."
],
"page.categories.feeds": "Kanały",
"page.categories.no_feed": "Brak kanałów.",
"page.categories.title": "Kategorie",
"page.categories_count": [
"%d kategoria",
"%d kategorie",
"%d kategorii"
],
"page.category_label": "Kategoria: %s",
"page.edit_category.title": "Edytuj kategorię: %s",
"page.edit_feed.etag_header": "Nagłówek ETag:",
"page.edit_feed.last_check": "Ostatnia aktualizacja:",
"page.edit_feed.last_modified_header": "Ostatnio zmienione:",
"page.edit_feed.last_parsing_error": "Ostatni błąd analizy",
"page.edit_feed.no_header": "Brak",
"page.edit_feed.title": "Edytuj kanał: %s",
"page.edit_user.title": "Edytuj użytkownika: %s",
"page.entry.attachments": "Załączniki",
"page.feeds.error_count": [
"%d błąd",
"%d błędy",
"%d błędów"
],
"page.feeds.last_check": "Ostatnia aktualizacja:",
"page.feeds.next_check": "Następna aktualizacja:",
"page.feeds.read_counter": "Liczba przeczytanych wpisów",
"page.feeds.title": "Kanały",
"page.footer.elevator": "Wróć do góry",
"page.history.title": "Historia",
"page.import.title": "Importuj",
"page.integration.bookmarklet": "Skryptozakładka",
"page.integration.bookmarklet.help": "To łącze umożliwia subskrypcję strony internetowej bezpośrednio za pomocą zakładki w przeglądarce internetowej.",
"page.integration.bookmarklet.instructions": "Przeciągnij i upuść to łącze do zakładek.",
"page.integration.bookmarklet.name": "Dodaj do Miniflux",
"page.integration.miniflux_api": "API Miniflux",
"page.integration.miniflux_api_endpoint": "Punkt końcowy API",
"page.integration.miniflux_api_password": "Hasło",
"page.integration.miniflux_api_password_value": "Hasło do konta",
"page.integration.miniflux_api_username": "Nazwa użytkownika",
"page.integrations.title": "Usługi",
"page.keyboard_shortcuts.close_modal": "Zamknij listę skrótów klawiszowych",
"page.keyboard_shortcuts.download_content": "Pobierz oryginalną treść",
"page.keyboard_shortcuts.go_to_bottom_item": "Przejdź do dolnego elementu",
"page.keyboard_shortcuts.go_to_categories": "Przejdź do kategorii",
"page.keyboard_shortcuts.go_to_feed": "Przejdź do subskrypcji",
"page.keyboard_shortcuts.go_to_feeds": "Przejdź do kanałów",
"page.keyboard_shortcuts.go_to_history": "Przejdź do historii",
"page.keyboard_shortcuts.go_to_next_item": "Przejdź do następnego elementu",
"page.keyboard_shortcuts.go_to_next_page": "Przejdź do następnej strony",
"page.keyboard_shortcuts.go_to_previous_item": "Przejdź do poprzedniego elementu",
"page.keyboard_shortcuts.go_to_previous_page": "Przejdź do poprzedniej strony",
"page.keyboard_shortcuts.go_to_search": "Ustaw fokus na formularzu wyszukiwania",
"page.keyboard_shortcuts.go_to_settings": "Przejdź do ustawień",
"page.keyboard_shortcuts.go_to_starred": "Przejdź do ulubionych",
"page.keyboard_shortcuts.go_to_top_item": "Przejdź do górnego elementu",
"page.keyboard_shortcuts.go_to_unread": "Przejdź do nieprzeczytanych",
"page.keyboard_shortcuts.mark_page_as_read": "Zaznacz aktualną stronę jako przeczytaną",
"page.keyboard_shortcuts.open_comments": "Otwórz łącze do komentarzy",
"page.keyboard_shortcuts.open_comments_same_window": "Otwórz łącze do komentarzy w bieżącej karcie",
"page.keyboard_shortcuts.open_item": "Otwórz zaznaczony element",
"page.keyboard_shortcuts.open_original": "Otwórz oryginalne łącze",
"page.keyboard_shortcuts.open_original_same_window": "Otwórz oryginalne łącze w bieżącej karcie",
"page.keyboard_shortcuts.refresh_all_feeds": "Odśwież w tle wszystkie kanały",
"page.keyboard_shortcuts.remove_feed": "Usuń ten kanał",
"page.keyboard_shortcuts.save_article": "Zapisz wpis",
"page.keyboard_shortcuts.scroll_item_to_top": "Przewiń element do góry",
"page.keyboard_shortcuts.show_keyboard_shortcuts": "Pokaż listę skrótów klawiszowych",
"page.keyboard_shortcuts.subtitle.actions": "Działania",
"page.keyboard_shortcuts.subtitle.items": "Nawigacja między elementami",
"page.keyboard_shortcuts.subtitle.pages": "Nawigacja między stronami",
"page.keyboard_shortcuts.subtitle.sections": "Nawigacja między punktami menu",
"page.keyboard_shortcuts.title": "Skróty klawiszowe",
"page.keyboard_shortcuts.toggle_star_status": "Przełącz dodanie do ulubionych",
"page.keyboard_shortcuts.toggle_entry_attachments": "Przełącz otwieranie/zamykanie załączników wpisów",
"page.keyboard_shortcuts.toggle_read_status_next": "Przełącz przeczytane/nieprzeczytane, przejdź dalej",
"page.keyboard_shortcuts.toggle_read_status_prev": "Przełącz przeczytane/nieprzeczytane, przejdź wstecz",
"page.login.google_signin": "Zaloguj się przez Google",
"page.login.oidc_signin": "Zaloguj się przez %s",
"page.login.title": "Zaloguj się",
"page.login.webauthn_login": "Zaloguj się przez klucz dostępu",
"page.login.webauthn_login.error": "Nie można zalogować się za pomocą klucza dostępu",
"page.login.webauthn_login.help": "Wpisz swoją nazwę użytkownika, jeśli używasz klucza bezpieczeństwa. Nie jest to wymagane, jeśli używasz klucza dostępu (wykrywalnych danych uwierzytelniających).",
"page.new_api_key.title": "Nowy klucz API",
"page.new_category.title": "Nowa kategoria",
"page.new_user.title": "Nowy użytkownik",
"page.offline.message": "Jesteś odłączony od sieci",
"page.offline.refresh_page": "Spróbuj odświeżyć stronę",
"page.offline.title": "Tryb offline",
"page.read_entry_count": [
"%d przeczytany wpis",
"%d przeczytane wpisy",
"%d przeczytanych wpisów"
],
"page.search.title": "Wyniki wyszukiwania",
"page.sessions.table.actions": "Działania",
"page.sessions.table.current_session": "Bieżąca sesja",
"page.sessions.table.date": "Data",
"page.sessions.table.ip": "Adres IP",
"page.sessions.table.user_agent": "Agent użytkownika",
"page.sessions.title": "Sesje",
"page.settings.link_google_account": "Połącz z moim kontem Google",
"page.settings.link_oidc_account": "Połącz z moim kontem %s",
"page.settings.title": "Ustawienia",
"page.settings.unlink_google_account": "Odłącz moje konto Google",
"page.settings.unlink_oidc_account": "Odłącz moje konto %s",
"page.settings.webauthn.actions": "Działania",
"page.settings.webauthn.added_on": "Dodano",
"page.settings.webauthn.delete": [
"Usuń %d klucz dostępu",
"Usuń %d klucze dostępu",
"Usuń %d kluczy dostępu"
],
"page.settings.webauthn.last_seen_on": "Ostatnio użyte",
"page.settings.webauthn.passkey_name": "Nazwa klucza dostępu",
"page.settings.webauthn.passkeys": "Klucze dostępu",
"page.settings.webauthn.register": "Zarejestruj klucz dostępu",
"page.settings.webauthn.register.error": "Nie można zarejestrować klucza dostępu",
"page.shared_entries.title": "Udostępnione wpisy",
"page.shared_entries_count": [
"%d udostępniony wpis",
"%d udostępnione wpisy",
"%d udostępnionych wpisów"
],
"page.starred.title": "Ulubione",
"page.starred_entry_count": [
"%d ulubiony wpis",
"%d ulubione wpisy",
"%d ulubionych wpisów"
],
"page.total_entry_count": [
"%d wpis łącznie",
"%d wpisy łącznie",
"%d wpisów łącznie"
],
"page.unread.title": "Nieprzeczytane",
"page.unread_entry_count": [
"%d nieprzeczytany wpis",
"%d nieprzeczytane wpisy",
"%d nieprzeczytanych wpisów"
],
"page.users.actions": "Działania",
"page.users.admin.no": "Nie",
"page.users.admin.yes": "Tak",
"page.users.is_admin": "Administrator",
"page.users.last_login": "Ostatnie logowanie",
"page.users.never_logged": "Nigdy",
"page.users.title": "Użytkownicy",
"page.users.username": "Nazwa użytkownika",
"page.webauthn_rename.title": "Zmień nazwę klucza dostępu",
"pagination.first": "Pierwsza",
"pagination.last": "Ostatnia",
"pagination.next": "Następna",
"pagination.previous": "Poprzednia",
"search.label": "Szukaj",
"search.placeholder": "Szukaj…",
"search.submit": "Szukaj",
"skip_to_content": "Przejdź do treści",
"time_elapsed.days": [
"%d dzień temu",
"%d dni temu",
"%d dni temu"
],
"time_elapsed.hours": [
"%d godzinę temu",
"%d godziny temu",
"%d godzin temu"
],
"time_elapsed.minutes": [
"%d minuta temu",
"%d minuty temu",
"%d minut temu"
],
"time_elapsed.months": [
"%d miesiąc temu",
"%d miesiące temu",
"%d miesięcy temu"
],
"time_elapsed.not_yet": "jeszcze nie",
"time_elapsed.now": "przed chwilą",
"time_elapsed.weeks": [
"%d tydzień temu",
"%d tygodnie temu",
"%d tygodni temu"
],
"time_elapsed.years": [
"%d rok temu",
"%d lat temu",
"%d lat temu"
],
"time_elapsed.yesterday": "wczoraj",
"tooltip.keyboard_shortcuts": "Skróty klawiszowe: %s",
"tooltip.logged_user": "Zalogowany jako %s"
}
v2-2.2.16/internal/locale/translations/pt_BR.json 0000664 0000000 0000000 00000115703 15127074645 0021650 0 ustar 00root root 0000000 0000000 {
"action.cancel": "Cancelar",
"action.download": "Baixar",
"action.edit": "Editar",
"action.home_screen": "Voltar para a tela inicial",
"action.import": "Importar",
"action.login": "Iniciar sessão",
"action.or": "Ou",
"action.remove": "Remover",
"action.remove_feed": "Remover fonte",
"action.save": "Salvar",
"action.subscribe": "Inscrever",
"action.update": "Atualizar",
"alert.account_linked": "Sua conta externa está vinculada!",
"alert.account_unlinked": "Sua conta externa está desvinculada!",
"alert.background_feed_refresh": "Todas as fontes estão sendo atualizadas em segundo plano. Você pode continuar usando o Miniflux enquanto este processo está em execução.",
"alert.feed_error": "Ocorreu um problema com esta fonte.",
"alert.no_starred": "Não há favorito neste momento.",
"alert.no_category": "Não há categoria.",
"alert.no_category_entry": "Não há itens nesta categoria.",
"alert.no_feed": "Não há inscrições.",
"alert.no_feed_entry": "Não há itens nessa fonte.",
"alert.no_feed_in_category": "Não há inscrições nessa categoria.",
"alert.no_history": "Não há histórico nesse momento.",
"alert.no_search_result": "Não há resultados para essa busca.",
"alert.no_shared_entry": "Não há itens compartilhados.",
"alert.no_tag_entry": "Não há itens que correspondam a esta etiqueta.",
"alert.no_unread_entry": "Não há itens não lidos.",
"alert.no_user": "Você é o único usuário.",
"alert.prefs_saved": "Suas preferências foram salvas!",
"alert.too_many_feeds_refresh": [
"Você acionou muitas atualizações de fontes. Por favor, aguarde %d minuto antes de tentar novamente.",
"Você acionou muitas atualizações de fontes. Por favor, aguarde %d minutos antes de tentar novamente."
],
"confirm.loading": "Carregando...",
"confirm.no": "Não",
"confirm.question": "Tem certeza?",
"confirm.question.refresh": "Você deseja forçar a atualização?",
"confirm.yes": "Sim",
"enclosure_media_controls.seek": "Procurar:",
"enclosure_media_controls.seek.title": "Procurar %s segundos",
"enclosure_media_controls.speed": "Velocidade:",
"enclosure_media_controls.speed.faster": "Mais Rápido",
"enclosure_media_controls.speed.faster.title": "Mais rápido em %sx",
"enclosure_media_controls.speed.reset": "Resetar",
"enclosure_media_controls.speed.reset.title": "Resetar velocidade para 1x",
"enclosure_media_controls.speed.slower": "Mais Lento",
"enclosure_media_controls.speed.slower.title": "Mais lento em %sx",
"entry.starred.toast.off": "Desfavoritado",
"entry.starred.toast.on": "Favoritado",
"entry.starred.toggle.off": "Remover dos Favoritos",
"entry.starred.toggle.on": "Favoritar",
"entry.comments.label": "Comentários",
"entry.comments.title": "Ver comentários",
"entry.estimated_reading_time": [
"Leitura de %d minuto",
"Leitura de %d minutos"
],
"entry.external_link.label": "Link externo",
"entry.save.completed": "Feito!",
"entry.save.label": "Salvar",
"entry.save.title": "Salvar esse item",
"entry.save.toast.completed": "Item guardado",
"entry.scraper.completed": "Feito!",
"entry.scraper.label": "Baixar",
"entry.scraper.title": "Obter conteúdo completo",
"entry.share.label": "Compartilhar",
"entry.share.title": "Compartilhar esse item",
"entry.shared_entry.label": "Compartilhar",
"entry.shared_entry.title": "Abrir link público",
"entry.state.loading": "Carregando...",
"entry.state.saving": "Salvando...",
"entry.status.mark_as_read": "Marcar como lido",
"entry.status.mark_as_unread": "Marcar como não lido",
"entry.status.title": "Modificar estado deste item",
"entry.status.toast.read": "Marcado como lido",
"entry.status.toast.unread": "Marcado como não lido",
"entry.tags.label": "Etiquetas:",
"entry.tags.more_tags_label": [
"Mostrar mais %d etiqueta",
"Mostrar mais %d etiquetas"
],
"entry.unshare.label": "Descompartilhar",
"error.api_key_already_exists": "Essa chave de API já existe.",
"error.bad_credentials": "Usuário ou senha são inválidos.",
"error.category_already_exists": "Esta categoria já existe.",
"error.category_not_found": "Esta categoria não existe ou não pertence a este usuário.",
"error.database_error": "Erro no banco de dados: %v.",
"error.different_passwords": "As senhas não são iguais.",
"error.duplicate_fever_username": "Alguém já está utilizando esse nome de usuário do Fever!",
"error.duplicate_googlereader_username": "Alguém já está utilizando esse nome de usuário do Google Reader!",
"error.duplicate_linked_account": "Alguém já está vinculado a esse serviço!",
"error.duplicated_feed": "Esta fonte já existe.",
"error.empty_file": "Esse arquivo está vazio.",
"error.entries_per_page_invalid": "O número de itens por página é inválido.",
"error.feed_already_exists": "Este feed já existe.",
"error.feed_category_not_found": "Esta categoria não existe ou não pertence a este usuário.",
"error.feed_format_not_detected": "Não foi possível detectar o formato da fonte: %v.",
"error.feed_invalid_blocklist_rule": "A regra da lista de bloqueio é inválida.",
"error.feed_invalid_keeplist_rule": "A regra de manutenção da lista é inválida.",
"error.feed_mandatory_fields": "O campo de URL e categoria são obrigatórios.",
"error.feed_not_found": "Esta fonte não existe ou não pertence a este usuário.",
"error.feed_title_not_empty": "O título do feed não pode estar vazio.",
"error.feed_url_not_empty": "O URL do feed não pode estar vazio.",
"error.fields_mandatory": "Todos os campos são obrigatórios.",
"error.http_bad_gateway": "O site não está disponível no momento devido a um erro de gateway. O problema não está no Miniflux. Por favor, tente novamente mais tarde.",
"error.http_body_read": "Não foi possível ler o corpo HTTP: %v.",
"error.http_client_error": "Erro do cliente HTTP: %v.",
"error.http_empty_response": "A resposta HTTP está vazia. Talvez este site esteja usando um mecanismo de proteção contra bots?",
"error.http_empty_response_body": "O corpo da resposta HTTP está vazio.",
"error.http_forbidden": "O acesso a este site está proibido. Talvez este site tenha um mecanismo de proteção contra bots?",
"error.http_gateway_timeout": "O site não está disponível no momento devido a um erro de tempo limite do gateway. O problema não está no Miniflux. Por favor, tente novamente mais tarde.",
"error.http_internal_server_error": "O site não está disponível no momento devido a um erro interno do servidor. O problema não está no Miniflux. Por favor, tente novamente mais tarde.",
"error.http_not_authorized": "O acesso a este site não está autorizado. Pode ser um nome de usuário ou senha incorretos.",
"error.http_resource_not_found": "O recurso solicitado não foi encontrado. Por favor, verifique a URL.",
"error.http_response_too_large": "A resposta HTTP é muito grande. Você pode aumentar o limite de tamanho da resposta HTTP nas configurações globais (requer reinício do servidor).",
"error.http_service_unavailable": "O site não está disponível no momento devido a um erro interno do servidor. O problema não está no Miniflux. Por favor, tente novamente mais tarde.",
"error.http_too_many_requests": "O Miniflux gerou muitas solicitações para este site. Por favor, tente novamente mais tarde ou altere a configuração do aplicativo.",
"error.http_unexpected_status_code": "O site não está disponível no momento devido a um código de status HTTP inesperado: %d. O problema não está no Miniflux. Por favor, tente novamente mais tarde.",
"error.invalid_categories_sorting_order": "A ordem de classificação das categorias não é válida.",
"error.invalid_default_home_page": "Página inicial por defeito inválida!",
"error.invalid_display_mode": "Modo de exibição de aplicativo inválido da web.",
"error.invalid_entry_direction": "Direção de entrada inválida.",
"error.invalid_entry_order": "A ordem de entrada é inválida.",
"error.invalid_feed_proxy_url": "URL de proxy inválido.",
"error.invalid_feed_url": "URL de feed inválido.",
"error.invalid_gesture_nav": "Navegação por gestos inválida.",
"error.invalid_language": "Idioma inválido.",
"error.invalid_site_url": "URL de site inválido.",
"error.invalid_theme": "Tema inválido.",
"error.invalid_timezone": "Fuso horário inválido.",
"error.network_operation": "O Miniflux não conseguiu acessar este site devido a um erro de rede: %v.",
"error.network_timeout": "Este site está muito lento e a solicitação expirou: %v",
"error.password_min_length": "A senha deve ter no mínimo 6 caracteres.",
"error.proxy_url_not_empty": "A URL do proxy não pode estar vazia.",
"error.settings_block_rule_fieldname_invalid": "Regra de bloqueio inválida: a regra #%d está sem um nome de campo válido (Opções: %s)",
"error.settings_block_rule_invalid_regex": "Regra de bloqueio inválida: o padrão da regra #%d não é uma expressão regular válida",
"error.settings_block_rule_regex_required": "Regra de bloqueio inválida: o padrão da regra #%d não foi fornecido",
"error.settings_block_rule_separator_required": "Regra de bloqueio inválida: o padrão da regra #%d deve ser separado por um '='",
"error.settings_invalid_domain_list": "Lista de domínios inválida. Por favor, forneça uma lista de domínios separados por espaço.",
"error.settings_keep_rule_fieldname_invalid": "Regra de permissão inválida: a regra #%d está sem um nome de campo válido (Opções: %s)",
"error.settings_keep_rule_invalid_regex": "Regra de permissão inválida: o padrão da regra #%d não é uma expressão regular válida",
"error.settings_keep_rule_regex_required": "Regra de permissão inválida: o padrão da regra #%d não foi fornecido",
"error.settings_keep_rule_separator_required": "Regra de permissão inválida: o padrão da regra #%d deve ser separado por um '='",
"error.settings_mandatory_fields": "Os campos de nome de usuário, tema, idioma e fuso horário são obrigatórios.",
"error.settings_media_playback_rate_range": "A velocidade de reprodução está fora do intervalo",
"error.settings_reading_speed_is_positive": "As velocidades de leitura devem ser inteiros positivos.",
"error.site_url_not_empty": "O URL do site não pode estar vazio.",
"error.subscription_not_found": "Não foi possível encontrar uma inscrição.",
"error.title_required": "O título é obrigatório.",
"error.tls_error": "Erro TLS: %q. Você pode desabilitar a verificação TLS nas configurações do feed se desejar.",
"error.unable_to_create_api_key": "Não foi possível criar uma chave de API.",
"error.unable_to_create_category": "Não foi possível criar essa categoria.",
"error.unable_to_create_user": "Não foi possível criar esse usuário.",
"error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.",
"error.unable_to_parse_feed": "Unable to parse this feed: %v.",
"error.unable_to_update_category": "Não foi possível atualizar essa categoria.",
"error.unable_to_update_feed": "Não foi possível atualizar essa fonte.",
"error.unable_to_update_user": "Não foi possível atualizar esse usuário.",
"error.unlink_account_without_password": "Você deve definir uma senha, senão não será possível efetuar a sessão novamente.",
"error.user_already_exists": "Esse usuário já existe.",
"error.user_mandatory_fields": "O nome de usuário é obrigatório.",
"error.linktaco_missing_required_fields": "LinkTaco API Token e Organization Slug são obrigatórios",
"form.api_key.label.description": "Etiqueta da chave de API",
"form.category.hide_globally": "Ocultar entradas na lista global não lida",
"form.category.label.title": "Título",
"form.feed.fieldset.general": "Geral",
"form.feed.fieldset.integration": "Serviços de Terceiros",
"form.feed.fieldset.network_settings": "Configurações de Rede",
"form.feed.fieldset.rules": "Regras",
"form.feed.label.allow_self_signed_certificates": "Permitir certificados autoassinados ou inválidos",
"form.feed.label.apprise_service_urls": "Lista de URLs de serviços Apprise separadas por vírgula",
"form.feed.label.block_filter_entry_rules": "Regras de Bloqueio de Entradas",
"form.feed.label.blocklist_rules": "Filtros de Bloqueio Baseados em Regex",
"form.feed.label.category": "Categoria",
"form.feed.label.cookie": "Definir Cookies",
"form.feed.label.crawler": "Obter conteúdo original",
"form.feed.label.description": "Descrição",
"form.feed.label.disable_http2": "Desativar HTTP/2 para evitar fingerprinting",
"form.feed.label.disabled": "Não atualizar esta fonte",
"form.feed.label.feed_password": "Senha da fonte",
"form.feed.label.feed_url": "URL da fonte",
"form.feed.label.feed_username": "Nome de usuário da fonte",
"form.feed.label.fetch_via_proxy": "Usar o proxy configurado no nível da aplicação",
"form.feed.label.hide_globally": "Ocultar entradas na lista global não lida",
"form.feed.label.ignore_http_cache": "Ignorar cache HTTP",
"form.feed.label.keep_filter_entry_rules": "Regras de Permissão de Entradas",
"form.feed.label.keeplist_rules": "Filtros de Manutenção Baseados em Regex",
"form.feed.label.no_media_player": "Sem reprodutor de mídia (áudio/vídeo)",
"form.feed.label.ntfy_activate": "Enviar itens para o ntfy",
"form.feed.label.ntfy_default_priority": "Prioridade padrão do ntfy",
"form.feed.label.ntfy_high_priority": "Alta prioridade do ntfy",
"form.feed.label.ntfy_low_priority": "Baixa prioridade do ntfy",
"form.feed.label.ntfy_max_priority": "Prioridade máxima do ntfy",
"form.feed.label.ntfy_min_priority": "Prioridade mínima do ntfy",
"form.feed.label.ntfy_priority": "Prioridade do ntfy",
"form.feed.label.ntfy_topic": "Tópico do ntfy (opcional)",
"form.feed.label.proxy_url": "Proxy URL",
"form.feed.label.pushover_activate": "Enviar itens para o pushover.net",
"form.feed.label.pushover_default_priority": "Prioridade padrão do Pushover",
"form.feed.label.pushover_high_priority": "Alta prioridade do Pushover",
"form.feed.label.pushover_low_priority": "Baixa prioridade do Pushover",
"form.feed.label.pushover_max_priority": "Prioridade máxima do Pushover",
"form.feed.label.pushover_min_priority": "Prioridade mínima do Pushover",
"form.feed.label.pushover_priority": "Prioridade da mensagem do Pushover",
"form.feed.label.rewrite_rules": "Regras de Reescrita de Conteúdo",
"form.feed.label.scraper_rules": "Regras do scraper",
"form.feed.label.site_url": "URL do site",
"form.feed.label.title": "Título",
"form.feed.label.urlrewrite_rules": "Regras de reescrita de URL",
"form.feed.label.user_agent": "Sobrescrever o agente de usuário (user-agent) padrão",
"form.feed.label.webhook_url": "Sobrescrever URL do webhook",
"form.import.label.file": "Arquivo OPML",
"form.import.label.url": "URL",
"form.integration.archiveorg_activate": "Enviar itens para o archive.org",
"form.integration.apprise_activate": "Enviar itens para o Apprise",
"form.integration.apprise_services_url": "Lista de URLs de serviços Apprise separadas por vírgula",
"form.integration.apprise_url": "Apprise API URL",
"form.integration.betula_activate": "Salvar itens no Betula",
"form.integration.betula_token": "Betula Token",
"form.integration.betula_url": "Betula server URL",
"form.integration.cubox_activate": "Salvar itens no Cubox",
"form.integration.cubox_api_link": "Link da API do Cubox",
"form.integration.discord_activate": "Enviar itens para o Discord",
"form.integration.discord_webhook_link": "Discord Webhook link",
"form.integration.espial_activate": "Salvar itens no Espial",
"form.integration.espial_api_key": "Chave de API do Espial",
"form.integration.espial_endpoint": "Endpoint de API do Espial",
"form.integration.espial_tags": "Etiquetas (tags) do Espial",
"form.integration.fever_activate": "Ativar API do Fever",
"form.integration.fever_endpoint": "Endpoint da API do Fever:",
"form.integration.fever_password": "Senha do Fever",
"form.integration.fever_username": "Nome de usuário do Fever",
"form.integration.googlereader_activate": "Ativar API do Google Reader",
"form.integration.googlereader_endpoint": "Endpoint da API do Google Reader:",
"form.integration.googlereader_password": "Senha do Google Reader",
"form.integration.googlereader_username": "Nome de usuário do Google Reader",
"form.integration.instapaper_activate": "Salvar itens no Instapaper",
"form.integration.instapaper_password": "Senha do Instapaper",
"form.integration.instapaper_username": "Nome do usuário do Instapaper",
"form.integration.karakeep_activate": "Salvar itens no Karakeep",
"form.integration.karakeep_api_key": "Chave de API do Karakeep",
"form.integration.karakeep_url": "Endpoint de API do Karakeep",
"form.integration.karakeep_tags": "Etiquetas do Karakeep",
"form.integration.linkace_activate": "Salvar itens no LinkAce",
"form.integration.linkace_api_key": "Chave de API do LinkAce",
"form.integration.linkace_check_disabled": "Desativar verificação de link",
"form.integration.linkace_endpoint": "Endpoint de API do LinkAce",
"form.integration.linkace_is_private": "Marcar link como privado",
"form.integration.linkace_tags": "Etiquetas do LinkAce",
"form.integration.linkding_activate": "Salvar itens no Linkding",
"form.integration.linkding_api_key": "Chave de API do Linkding",
"form.integration.linkding_bookmark": "Salvar marcador como não lido",
"form.integration.linkding_endpoint": "Endpoint de API do Linkding",
"form.integration.linkding_tags": "Etiquetas do Linkding",
"form.integration.linktaco_activate": "Salvar itens no LinkTaco",
"form.integration.linktaco_api_token": "LinkTaco API Token",
"form.integration.linktaco_api_token_hint": "Obtenha seu token de acesso pessoal em",
"form.integration.linktaco_org_slug": "Slug da organização",
"form.integration.linktaco_tags": "Tags (máx 10, separadas por vírgula)",
"form.integration.linktaco_tags_hint": "Máximo 10 tags, separadas por vírgula",
"form.integration.linktaco_visibility": "Visibilidade",
"form.integration.linktaco_visibility_public": "Público",
"form.integration.linktaco_visibility_private": "Privado",
"form.integration.linktaco_visibility_hint": "Visibilidade PRIVADA requer uma conta LinkTaco paga",
"form.integration.linkwarden_activate": "Salvar itens no Linkwarden",
"form.integration.linkwarden_api_key": "Chave de API do Linkwarden",
"form.integration.linkwarden_endpoint": "URL base do Linkwarden",
"form.integration.linkwarden_collection_id": "ID da coleção do Linkwarden",
"form.integration.matrix_bot_activate": "Transferir novos artigos para o Matrix",
"form.integration.matrix_bot_chat_id": "Identificação da sala 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_user": "Nome de utilizador para Matrix",
"form.integration.notion_activate": "Salvar itens no Notion",
"form.integration.notion_page_id": "ID da página do Notion",
"form.integration.notion_token": "Token secreto do Notion",
"form.integration.ntfy_activate": "Enviar itens para o ntfy",
"form.integration.ntfy_api_token": "Ntfy API Token (opcional)",
"form.integration.ntfy_icon_url": "Ntfy Icon URL (opcional)",
"form.integration.ntfy_internal_links": "Usar links internos ao clicar (opcional)",
"form.integration.ntfy_password": "Ntfy Password (opcional)",
"form.integration.ntfy_topic": "Tópico do Ntfy (padrão se não definido na fonte)",
"form.integration.ntfy_url": "URL do Ntfy (opcional, padrão ntfy.sh)",
"form.integration.ntfy_username": "Usuário do Ntfy (opcional)",
"form.integration.nunux_keeper_activate": "Salvar itens no Nunux Keeper",
"form.integration.nunux_keeper_api_key": "Chave de API do Nunux Keeper",
"form.integration.nunux_keeper_endpoint": "Endpoint de API do Nunux Keeper",
"form.integration.omnivore_activate": "Salvar itens no Omnivore",
"form.integration.omnivore_api_key": "Chave de API do Omnivore",
"form.integration.omnivore_url": "Endpoint de API do Omnivore",
"form.integration.pinboard_activate": "Salvar itens no Pinboard",
"form.integration.pinboard_bookmark": "Salvar marcador como não lido",
"form.integration.pinboard_tags": "Etiquetas (tags) do Pinboard",
"form.integration.pinboard_token": "Token de API do Pinboard",
"form.integration.pushover_activate": "Enviar itens para o Pushover",
"form.integration.pushover_device": "Dispositivo Pushover (opcional)",
"form.integration.pushover_prefix": "Prefixo da URL do Pushover (opcional)",
"form.integration.pushover_token": "Token de API do aplicativo Pushover",
"form.integration.pushover_user": "Chave do usuário Pushover",
"form.integration.raindrop_activate": "Salvar itens no Raindrop",
"form.integration.raindrop_collection_id": "ID da coleção",
"form.integration.raindrop_tags": "Etiquetas (separadas por vírgula)",
"form.integration.raindrop_token": "Token (teste)",
"form.integration.readeck_activate": "Salvar itens no Readeck",
"form.integration.readeck_api_key": "Chave de API do Readeck",
"form.integration.readeck_endpoint": "Endpoint de API do Readeck",
"form.integration.readeck_labels": "Etiquetas do Readeck",
"form.integration.readeck_only_url": "Enviar apenas URL (em vez de conteúdo completo)",
"form.integration.readeck_push_activate": "Enviar automaticamente novos itens para o Readeck",
"form.integration.readwise_activate": "Salvar itens no Readwise Reader",
"form.integration.readwise_api_key": "Token de acesso do Readwise Reader",
"form.integration.readwise_api_key_link": "Obtenha seu token de acesso do Readwise",
"form.integration.rssbridge_activate": "Verificar RSS-Bridge ao adicionar inscrições",
"form.integration.rssbridge_token": "Token de autenticação do RSS-Bridge",
"form.integration.rssbridge_url": "URL do servidor RSS-Bridge",
"form.integration.shaarli_activate": "Salvar artigos no Shaarli",
"form.integration.shaarli_api_secret": "Segredo da API do Shaarli",
"form.integration.shaarli_endpoint": "URL do Shaarli",
"form.integration.shiori_activate": "Salvar itens no Shiori",
"form.integration.shiori_endpoint": "Endpoint da API do Shiori",
"form.integration.shiori_password": "Senha do Shiori",
"form.integration.shiori_username": "Nome de usuário do Shiori",
"form.integration.slack_activate": "Enviar itens para o Slack",
"form.integration.slack_webhook_link": "Link do Webhook do Slack",
"form.integration.telegram_bot_activate": "Envie novos artigos para o chat do Telegram",
"form.integration.telegram_bot_disable_buttons": "Desativar botões",
"form.integration.telegram_bot_disable_notification": "Desativar notificação",
"form.integration.telegram_bot_disable_web_page_preview": "Desativar pré-visualização de página",
"form.integration.telegram_bot_token": "Token de bot",
"form.integration.telegram_chat_id": "ID de bate-papo",
"form.integration.telegram_topic_id": "Topic ID",
"form.integration.wallabag_activate": "Salvar itens no Wallabag",
"form.integration.wallabag_client_id": "ID de cliente (Client ID) do Wallabag",
"form.integration.wallabag_client_secret": "Segredo do cliente (Client Secret) do Wallabag",
"form.integration.wallabag_endpoint": "URL base do Wallabag",
"form.integration.wallabag_only_url": "Enviar apenas URL (em vez de conteúdo completo)",
"form.integration.wallabag_password": "Senha do Wallabag",
"form.integration.wallabag_username": "Nome de usuário do Wallabag",
"form.integration.wallabag_tags": "Etiquetas do Wallabag",
"form.integration.webhook_activate": "Ativar Webhooks",
"form.integration.webhook_secret": "Segredo dos Webhooks",
"form.integration.webhook_url": "URL padrão do Webhook",
"form.prefs.fieldset.application_settings": "Configurações do aplicativo",
"form.prefs.fieldset.authentication_settings": "Configurações de autenticação",
"form.prefs.fieldset.global_feed_settings": "Configurações globais de fontes",
"form.prefs.fieldset.reader_settings": "Configurações do leitor",
"form.prefs.help.external_font_hosts": "Lista separada por espaço de hosts de fontes externas permitidos. Por exemplo: 'fonts.gstatic.com fonts.googleapis.com'.",
"form.prefs.label.always_open_external_links": "Ler artigos abrindo links externos",
"form.prefs.label.categories_sorting_order": "Classificação das categorias",
"form.prefs.label.cjk_reading_speed": "Velocidade de leitura para chinês, coreano e japonês (caracteres por minuto)",
"form.prefs.label.custom_css": "CSS customizado",
"form.prefs.label.custom_js": "JavaScript customizado",
"form.prefs.label.default_home_page": "Página inicial predefinida",
"form.prefs.label.default_reading_speed": "Velocidade de leitura para outros idiomas (palavras por minuto)",
"form.prefs.label.display_mode": "Modo de exibição Progressive Web App (PWA)",
"form.prefs.label.entries_per_page": "Itens por página",
"form.prefs.label.entry_order": "Coluna de Ordenação de Entrada",
"form.prefs.label.entry_sorting": "Ordenação dos itens",
"form.prefs.label.entry_swipe": "Ativar entrada de furto em telas sensíveis ao toque",
"form.prefs.label.external_font_hosts": "Hosts de fontes externas",
"form.prefs.label.gesture_nav": "Gesto para navegar entre as entradas",
"form.prefs.label.keyboard_shortcuts": "Habilitar atalhos do teclado",
"form.prefs.label.language": "Idioma",
"form.prefs.label.mark_read_manually": "Marcar itens como lidos manualmente",
"form.prefs.label.mark_read_on_media_completion": "Marcar como lido apenas quando a reprodução de áudio/vídeo atingir 90%% de conclusão",
"form.prefs.label.mark_read_on_view": "Marcar automaticamente as entradas como lidas quando visualizadas",
"form.prefs.label.mark_read_on_view_or_media_completion": "Marcar itens como lidos quando visualizados. Para áudio/vídeo, marcar como lido em 90%% de conclusão",
"form.prefs.label.media_playback_rate": "Velocidade de reprodução do áudio/vídeo",
"form.prefs.label.open_external_links_in_new_tab": "Abrir links externos em uma nova aba (adiciona target=\"_blank\" aos links)",
"form.prefs.label.show_reading_time": "Mostrar tempo estimado de leitura de artigos",
"form.prefs.label.theme": "Tema",
"form.prefs.label.timezone": "Fuso horário",
"form.prefs.select.alphabetical": "Por ordem alfabética",
"form.prefs.select.browser": "Navegador",
"form.prefs.select.created_time": "Entrada tempo criado",
"form.prefs.select.fullscreen": "Tela completa",
"form.prefs.select.minimal_ui": "Mínimo",
"form.prefs.select.none": "Nenhum",
"form.prefs.select.older_first": "Itens mais velhos primeiro",
"form.prefs.select.publish_time": "Entrada hora de publicação",
"form.prefs.select.recent_first": "Itens mais recentes",
"form.prefs.select.standalone": "Autônomo",
"form.prefs.select.swipe": "Deslize",
"form.prefs.select.tap": "Toque duplo",
"form.prefs.select.unread_count": "Contagem não lida",
"form.submit.loading": "Carregando...",
"form.submit.saving": "Salvando...",
"form.user.label.admin": "Administrador",
"form.user.label.confirmation": "Confirmação de senha",
"form.user.label.password": "Senha",
"form.user.label.username": "Nome de usuário",
"menu.about": "Sobre",
"menu.add_feed": "Adicionar inscrição",
"menu.add_user": "Adicionar usuário",
"menu.api_keys": "Chaves de API",
"menu.categories": "Categorias",
"menu.create_api_key": "Criar uma nova chave de API",
"menu.create_category": "Criar uma categoria",
"menu.edit_category": "Editar",
"menu.edit_feed": "Editar",
"menu.export": "Exportar",
"menu.feed_entries": "Itens",
"menu.feeds": "Fontes",
"menu.flush_history": "Limpar histórico",
"menu.history": "Histórico",
"menu.home_page": "Home page",
"menu.import": "Importar",
"menu.integrations": "Integrações",
"menu.logout": "Encerrar sessão",
"menu.mark_all_as_read": "Marcar todos como lido",
"menu.mark_page_as_read": "Marcar essa página como lida",
"menu.preferences": "Preferências",
"menu.refresh_all_feeds": "Atualizar todas as fontes",
"menu.refresh_feed": "Atualizar",
"menu.search": "Buscar",
"menu.sessions": "Sessões",
"menu.settings": "Configurações",
"menu.shared_entries": "Itens compartilhados",
"menu.show_all_entries": "Mostrar todas os itens",
"menu.show_only_starred_entries": "Mostrar apenas os favoritos",
"menu.show_only_unread_entries": "Mostrar apenas itens não lidos",
"menu.starred": "Favoritos",
"menu.title": "Menu",
"menu.unread": "Não lido",
"menu.users": "Usuários",
"page.about.author": "Autor:",
"page.about.build_date": "Compilado em:",
"page.about.credits": "Créditos",
"page.about.db_usage": "Tamanho do banco de dados:",
"page.about.git_commit": "Commit do Git:",
"page.about.global_config_options": "opções de configuração global",
"page.about.go_version": "Go versão:",
"page.about.license": "Licença:",
"page.about.postgres_version": "Postgres versão:",
"page.about.title": "Sobre",
"page.about.version": "Versão:",
"page.add_feed.choose_feed": "Escolher uma fonte",
"page.add_feed.label.url": "URL",
"page.add_feed.legend.advanced_options": "Opções avançadas",
"page.add_feed.no_category": "Não existe uma categoria. Deve existir pelo menos uma categoria.",
"page.add_feed.submit": "Buscar uma fonte",
"page.add_feed.title": "Nova inscrição",
"page.api_keys.never_used": "Nunca usado",
"page.api_keys.table.actions": "Ações",
"page.api_keys.table.created_at": "Data de criação",
"page.api_keys.table.description": "Descrição",
"page.api_keys.table.last_used_at": "Ultima utilização",
"page.api_keys.table.token": "Token",
"page.api_keys.title": "Chaves de API",
"page.categories.entries": "Itens",
"page.categories.feed_count": [
"Existe %d fonte.",
"Existem %d fontes."
],
"page.categories.feeds": "Inscrições",
"page.categories.no_feed": "Sem fonte.",
"page.categories.title": "Categorias",
"page.categories_count": [
"%d categoria",
"%d categorias"
],
"page.category_label": "Categoria: %s",
"page.edit_category.title": "Editar categoria: %s",
"page.edit_feed.etag_header": "Cabeçalho 'ETag':",
"page.edit_feed.last_check": "Última verificação:",
"page.edit_feed.last_modified_header": "Cabeçalho 'LastModified':",
"page.edit_feed.last_parsing_error": "Último erro durante processamento",
"page.edit_feed.no_header": "Sem cabeçalhos",
"page.edit_feed.title": "Editar fonte: %s",
"page.edit_user.title": "Editar usuário: %s",
"page.entry.attachments": "Anexos",
"page.feeds.error_count": [
"%d erro",
"%d erros"
],
"page.feeds.last_check": "Última verificação:",
"page.feeds.next_check": "Próxima verificação:",
"page.feeds.read_counter": "Número de itens lidos",
"page.feeds.title": "Fontes",
"page.footer.elevator": "Voltar ao topo",
"page.history.title": "Histórico",
"page.import.title": "Importar",
"page.integration.bookmarklet": "Bookmarklet",
"page.integration.bookmarklet.help": "Esse link especial permite você se inscrever a um site diretamente usando favorito do navegador.",
"page.integration.bookmarklet.instructions": "Arrasta e solta esse link para os favoritos do teu navegador.",
"page.integration.bookmarklet.name": "Adicionar ao Miniflux",
"page.integration.miniflux_api": "API do Miniflux",
"page.integration.miniflux_api_endpoint": "Endpoint da API",
"page.integration.miniflux_api_password": "Senha",
"page.integration.miniflux_api_password_value": "Senha da sua Conta",
"page.integration.miniflux_api_username": "Nome de usuário",
"page.integrations.title": "Integrações",
"page.keyboard_shortcuts.close_modal": "Fechar janela",
"page.keyboard_shortcuts.download_content": "Buscar o conteúdo original",
"page.keyboard_shortcuts.go_to_bottom_item": "Ir para o item inferior",
"page.keyboard_shortcuts.go_to_categories": "Ir as categorias",
"page.keyboard_shortcuts.go_to_feed": "Ir a fonte",
"page.keyboard_shortcuts.go_to_feeds": "Ir as inscrições",
"page.keyboard_shortcuts.go_to_history": "Ir ao histórico",
"page.keyboard_shortcuts.go_to_next_item": "Ir ao tem seguinte",
"page.keyboard_shortcuts.go_to_next_page": "Ir a página seguinte",
"page.keyboard_shortcuts.go_to_previous_item": "Ir ao item anterior",
"page.keyboard_shortcuts.go_to_previous_page": "Ir a página anterior",
"page.keyboard_shortcuts.go_to_search": "Ir para o campo de busca",
"page.keyboard_shortcuts.go_to_settings": "Ir as configurações",
"page.keyboard_shortcuts.go_to_starred": "Ir aos favoritos",
"page.keyboard_shortcuts.go_to_top_item": "Ir para o item superior",
"page.keyboard_shortcuts.go_to_unread": "Ir aos não lidos",
"page.keyboard_shortcuts.mark_page_as_read": "Marcar página atual como lida",
"page.keyboard_shortcuts.open_comments": "Abrir os comentários",
"page.keyboard_shortcuts.open_comments_same_window": "Abrir os comentários na janela atual",
"page.keyboard_shortcuts.open_item": "Abrir o item selecionado",
"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.refresh_all_feeds": "Atualizar todas as fontes",
"page.keyboard_shortcuts.remove_feed": "Remover essa fonte",
"page.keyboard_shortcuts.save_article": "Salvar item",
"page.keyboard_shortcuts.scroll_item_to_top": "Role o item para cima",
"page.keyboard_shortcuts.show_keyboard_shortcuts": "Mostrar atalhos de teclado",
"page.keyboard_shortcuts.subtitle.actions": "Ações",
"page.keyboard_shortcuts.subtitle.items": "Navegação de itens",
"page.keyboard_shortcuts.subtitle.pages": "Navegação de páginas",
"page.keyboard_shortcuts.subtitle.sections": "Navegação de seções",
"page.keyboard_shortcuts.title": "Atalhos de teclado",
"page.keyboard_shortcuts.toggle_star_status": "Marcar ou desmarcar como favorito",
"page.keyboard_shortcuts.toggle_entry_attachments": "Alternar abrir/fechar anexos do item",
"page.keyboard_shortcuts.toggle_read_status_next": "Inverter estado de leitura do item, focar próximo item",
"page.keyboard_shortcuts.toggle_read_status_prev": "Inverter estado de leitura do item, focar item anterior",
"page.login.google_signin": "Iniciar Sessão com sua conta do Google",
"page.login.oidc_signin": "Iniciar Sessão com sua conta do %s",
"page.login.title": "Iniciar Sessão",
"page.login.webauthn_login": "Entrar com senha",
"page.login.webauthn_login.error": "Não é possível fazer login com senha",
"page.login.webauthn_login.help": "Please enter your username if you're using a security key. This is not required if you are using a Passkey (discoverable credentials).",
"page.new_api_key.title": "Nova chave de API",
"page.new_category.title": "Nova categoria",
"page.new_user.title": "Novo usuário",
"page.offline.message": "Você está offline",
"page.offline.refresh_page": "Tente atualizar a página",
"page.offline.title": "Modo offline",
"page.read_entry_count": [
"%d item lido",
"%d itens lidos"
],
"page.search.title": "Resultados da busca",
"page.sessions.table.actions": "Ações",
"page.sessions.table.current_session": "Sessão Atual",
"page.sessions.table.date": "Data",
"page.sessions.table.ip": "Endereço IP",
"page.sessions.table.user_agent": "Agente de usuário",
"page.sessions.title": "Sessões",
"page.settings.link_google_account": "Vincular minha conta do Google",
"page.settings.link_oidc_account": "Vincular minha conta do %s",
"page.settings.title": "Ajustes",
"page.settings.unlink_google_account": "Desvincular minha conta do Google",
"page.settings.unlink_oidc_account": "Desvincular minha conta do %s",
"page.settings.webauthn.actions": "Ações",
"page.settings.webauthn.added_on": "Adicionado em",
"page.settings.webauthn.delete": [
"Remover %d senha",
"Remover %d senhas"
],
"page.settings.webauthn.last_seen_on": "Último uso",
"page.settings.webauthn.passkey_name": "Nome da senha",
"page.settings.webauthn.passkeys": "Senhas",
"page.settings.webauthn.register": "Registrar senha",
"page.settings.webauthn.register.error": "Não foi possível registrar a senha",
"page.shared_entries.title": "Itens compartilhados",
"page.shared_entries_count": [
"%d item compartilhado",
"%d itens compartilhados"
],
"page.starred.title": "Favoritos",
"page.starred_entry_count": [
"%d item favorito",
"%d itens favoritos"
],
"page.total_entry_count": [
"%d item no total",
"%d itens no total"
],
"page.unread.title": "Não lidos",
"page.unread_entry_count": [
"%d item não lido",
"%d itens não lidos"
],
"page.users.actions": "Ações",
"page.users.admin.no": "Não",
"page.users.admin.yes": "Sim",
"page.users.is_admin": "Administrador",
"page.users.last_login": "Último acesso",
"page.users.never_logged": "Nunca",
"page.users.title": "Usuários",
"page.users.username": "Nome de usuário",
"page.webauthn_rename.title": "Renomear senha",
"pagination.first": "Primeira",
"pagination.last": "Última",
"pagination.next": "Próximo",
"pagination.previous": "Anterior",
"search.label": "Buscar",
"search.placeholder": "Buscar por...",
"search.submit": "Buscar",
"skip_to_content": "Pular para o conteúdo",
"time_elapsed.days": [
"há %d dia",
"há %d dias"
],
"time_elapsed.hours": [
"há %d hora",
"há %d horas"
],
"time_elapsed.minutes": [
"há %d minuto",
"há %d minutos"
],
"time_elapsed.months": [
"há %d mês",
"há %d meses"
],
"time_elapsed.not_yet": "ainda não",
"time_elapsed.now": "agora mesmo",
"time_elapsed.weeks": [
"há %d semana",
"há %d semanas"
],
"time_elapsed.years": [
"há %d ano",
"há %d anos"
],
"time_elapsed.yesterday": "ontem",
"tooltip.keyboard_shortcuts": "Atalho do teclado: %s",
"tooltip.logged_user": "Autenticado como %s"
}
v2-2.2.16/internal/locale/translations/ro_RO.json 0000664 0000000 0000000 00000116660 15127074645 0021665 0 ustar 00root root 0000000 0000000 {
"action.cancel": "abandon",
"action.download": "Descărcare",
"action.edit": "Editare",
"action.home_screen": "Adaugă pe ecranul principal",
"action.import": "Importă",
"action.login": "Autentificare",
"action.or": "sau",
"action.remove": "Elimină",
"action.remove_feed": "Elimină acest flux",
"action.save": "Salvează",
"action.subscribe": "Abonează-te",
"action.update": "Actualizare",
"alert.account_linked": "Contul dvs. extern este atașat!",
"alert.account_unlinked": "Am decuplat contul dvs. extern!",
"alert.background_feed_refresh": "Toate fluxurile sunt actualizate în fundal. Puteți să continuați utilizarea Miniflux în timp ce procesul rulează.",
"alert.feed_error": "Este o problemă cu acest flux",
"alert.no_starred": "Nu sunt înregistrări marcate.",
"alert.no_category": "Nu sunt categorii.",
"alert.no_category_entry": "Nu sunt înregistrări în această categorie.",
"alert.no_feed": "Nu aveți fluxuri.",
"alert.no_feed_entry": "Nu sunt înregistrări pentru acest flux.",
"alert.no_feed_in_category": "Nu sunt fluxuri pentru această categorie.",
"alert.no_history": "Nu există istoric în acest moment.",
"alert.no_search_result": "Nu există înregistrări pentru această căutare.",
"alert.no_shared_entry": "Nu sunt înregistrări partajate.",
"alert.no_tag_entry": "Nu sunt înregistrări pentru această etichetă.",
"alert.no_unread_entry": "Nu sunt intrări necitite.",
"alert.no_user": "Sunteți singurul utilizator.",
"alert.prefs_saved": "Preferințe salvate!",
"alert.too_many_feeds_refresh": [
"Ați activat actualizarea a prea multe fluxuri de informații. Vă rog să așteptați %d minut înainte de a reîncerca.",
"Ați activat actualizarea a prea multe fluxuri de informații. Vă rog să așteptați %d minute înainte de a reîncerca.",
"Ați activat actualizarea a prea multe fluxuri de informații. Vă rog să așteptați %d minute înainte de a reîncerca."
],
"confirm.loading": "În progres…",
"confirm.no": "nu",
"confirm.question": "Suneți sigur?",
"confirm.question.refresh": "Sunteți sigur că vreți să forțați reîmprospătarea?",
"confirm.yes": "da",
"enclosure_media_controls.seek": "Caută:",
"enclosure_media_controls.seek.title": "Caută %s secunde",
"enclosure_media_controls.speed": "Viteză:",
"enclosure_media_controls.speed.faster": "Mai rapid",
"enclosure_media_controls.speed.faster.title": "Mai rapid cu %sx",
"enclosure_media_controls.speed.reset": "Resetare",
"enclosure_media_controls.speed.reset.title": "Resetare viteză la 1x",
"enclosure_media_controls.speed.slower": "Mai încet",
"enclosure_media_controls.speed.slower.title": "Mai încet cu %sx",
"entry.starred.toast.off": "Fără stea",
"entry.starred.toast.on": "Cu stea",
"entry.starred.toggle.off": "Fără stea",
"entry.starred.toggle.on": "Stea",
"entry.comments.label": "Comentarii",
"entry.comments.title": "Vizualizare Comentarii",
"entry.estimated_reading_time": [
"%d minut de lectură",
"%d minute de lectură",
"%d minut de lectură"
],
"entry.external_link.label": "Legătură externă",
"entry.save.completed": "Gata!",
"entry.save.label": "Salvare",
"entry.save.title": "Salvez această înregistrare",
"entry.save.toast.completed": "Înregistrare salvată",
"entry.scraper.completed": "Gata!",
"entry.scraper.label": "Descărcare",
"entry.scraper.title": "Descarcă conținutul original",
"entry.share.label": "Partajare",
"entry.share.title": "Partajează această înregistrare",
"entry.shared_entry.label": "Partajare",
"entry.shared_entry.title": "Deschide legătura publică",
"entry.state.loading": "Încarc…",
"entry.state.saving": "Salvez…",
"entry.status.mark_as_read": "Marcați ca citit",
"entry.status.mark_as_unread": "Marcați ca necitit",
"entry.status.title": "Modifică starea intrării",
"entry.status.toast.read": "Marcat ca citit",
"entry.status.toast.unread": "Marcat ca necitit",
"entry.tags.label": "Etichete:",
"entry.tags.more_tags_label": [
"Afișează încă o etichetă",
"Afișează încă %d etichete",
"Afișează încă %d de etichete"
],
"entry.unshare.label": "Elimină partajarea",
"error.api_key_already_exists": "Această cheie API există deja.",
"error.bad_credentials": "Utilizator sau parolă invalide.",
"error.category_already_exists": "Această categorie există deja.",
"error.category_not_found": "Această categorie nu există sau nu aparține acestui utilizator.",
"error.database_error": "Eroare bază de date: %v.",
"error.different_passwords": "Parolele nu sunt identice.",
"error.duplicate_fever_username": "Este deja cineva cu același cont de Fever!",
"error.duplicate_googlereader_username": "Este deja cineva cu același nume de utilizator Google Reader!",
"error.duplicate_linked_account": "Este deja cineva asociat cu acest furnizor!",
"error.duplicated_feed": "Acest flux există deja.",
"error.empty_file": "Acest fișier este gol.",
"error.entries_per_page_invalid": "Numărul de înregistrări de pe pagină nu este valid.",
"error.feed_already_exists": "Acest flux există deja.",
"error.feed_category_not_found": "Această categorie nu există sau nu aparține utilizatorului.",
"error.feed_format_not_detected": "Nu pot detecta formatul fluxului: %v.",
"error.feed_invalid_blocklist_rule": "Blocul listei de reguli este invalid.",
"error.feed_invalid_keeplist_rule": "Lista de reguli keep este invalidă.",
"error.feed_mandatory_fields": "Adresa URL și categoria sunt obligatorii.",
"error.feed_not_found": "Acest flux nu există sau un aparține acestui utilizator.",
"error.feed_title_not_empty": "Titlul fluxului nu poate fi gol.",
"error.feed_url_not_empty": "Adresa URL a fluxului nu poate fi goală.",
"error.fields_mandatory": "Toate câmpurile sunt obligatorii.",
"error.http_bad_gateway": "Acest site web nu este disponibil momentan din cauza unei erori generată de gateway. Problema nu este de la Miniflux. Vă rugăm să reîncercați mai târziu.",
"error.http_body_read": "Nu pot citi corpul HTTP: %v.",
"error.http_client_error": "Eroare client HTTP: %v.",
"error.http_empty_response": "Răspunsul HTTP este gol. Poate acest site web utilizează un mecanism împotriva boților?",
"error.http_empty_response_body": "Corpul răspunsului HTTP este gol.",
"error.http_forbidden": "Accesul la acest site web este interzis. Poate acesta utilizează un mecanism împotriva boților?",
"error.http_gateway_timeout": "Acest site web nu este disponibil momentan din cauza unei erori generată de gateway. Problema nu este de la Miniflux. Vă rugăm să reîncercați mai târziu.",
"error.http_internal_server_error": "Acest site web nu este disponibil momentan din cauza unei erori generată de server. Problema nu este de la Miniflux. Vă rugăm să reîncercați mai târziu.",
"error.http_not_authorized": "Accesul la acest site nu este autorizat. Poate fi din cauza parolei sau a userului greșite.",
"error.http_resource_not_found": "Resursa solicitată nu este găsită. Vă rog să verificați URL-ul.",
"error.http_response_too_large": "Răspunsul HTTP este prea mare. Puteți crește dimensiunea acestuia în setările globale (necesită repornirea server-ului).",
"error.http_service_unavailable": "Acest site web nu este disponibil momentan din cauza unei erori generată de server. Problema nu este de la Miniflux. Vă rugăm să reîncercați mai târziu.",
"error.http_too_many_requests": "Miniflux a generat prea multe solicitări pe acest site web. Vă rog, încercați mai tîrziu sau modificați configurațiile aplicației.",
"error.http_unexpected_status_code": "Acest site web nu este disponibil momentan din cauza unei erori HTTP: %d. Problema nu este de la Miniflux. Vă rugăm să reîncercați mai târziu.",
"error.invalid_categories_sorting_order": "Ordinea de sortare a categoriilor nu este validă.",
"error.invalid_default_home_page": "Pagină de start invalidă!",
"error.invalid_display_mode": "Mod invalid de afișare în aplicația web.",
"error.invalid_entry_direction": "Direcție invalidă ăn intrare.",
"error.invalid_entry_order": "Direcție de sortare invalidă.",
"error.invalid_feed_proxy_url": "URL proxy invalid.",
"error.invalid_feed_url": "Adresa URL a fluxului este invalidă.",
"error.invalid_gesture_nav": "Gest de navigare invalid.",
"error.invalid_language": "Limbă invalidă.",
"error.invalid_site_url": "Adresa URL a site-ului este invalidă.",
"error.invalid_theme": "Temă invalidă.",
"error.invalid_timezone": "Dată/oră invalide.",
"error.network_operation": "Miniflux nu poate ajunge la acest site din cauza unei erori de rețea: %v.",
"error.network_timeout": "Acest site web este prea lent și conexiunea nu s-a realizat: %v",
"error.password_min_length": "Parola trebuie să aibă cel puțin 6 caractere.",
"error.proxy_url_not_empty": "URL-ul proxy nu poate fi gol.",
"error.settings_block_rule_fieldname_invalid": "Regulă de bloc invalidă: regulii #%d îi lipsește un nume valid de câmp (Opțiuni: %s)",
"error.settings_block_rule_invalid_regex": "Regulă de bloc invalidă: modelul regulii #%d's nu este regex valid",
"error.settings_block_rule_regex_required": "Regulă de bloc invalidă: modelul regulii #%d's nu este furnizat",
"error.settings_block_rule_separator_required": "Regulă de bloc invalidă: modelul regulii #%d's trebuie separat de '='",
"error.settings_invalid_domain_list": "Lista domeniilor este invalidă. Vă rugăm să furnizați o listă de domenii separate prin spațiu.",
"error.settings_keep_rule_fieldname_invalid": "Regulă Keep invalidă: regulii #%d îi lipsește un nume valid (Opțiuni: %s)",
"error.settings_keep_rule_invalid_regex": "Regulă Keep invalidă: modelul regulii #%d's nu este regex valid",
"error.settings_keep_rule_regex_required": "Regulă Keep invalidă: modelul regulii #%d nu este furnizat",
"error.settings_keep_rule_separator_required": "Regulă Keep invalidă: modelul regulii #%d's trebuie separat de'='",
"error.settings_mandatory_fields": "Numele utilizatorului, tema, limba și fusul orar sunt obligatorii.",
"error.settings_media_playback_rate_range": "Viteza de rulare nu este validă",
"error.settings_reading_speed_is_positive": "Vitezele de citire trebuie să fie numere întregi pozitive.",
"error.site_url_not_empty": "Adresa URL a site-ului nu poate fi goală.",
"error.subscription_not_found": "Nu se poate găsi nici un flux.",
"error.title_required": "Titlul este obligatoriu.",
"error.tls_error": "Eroare TLS: %q. Puteți dezactiva verificarea TLS în setările fluxurilor dacă doriți.",
"error.unable_to_create_api_key": "Nu pot crea această cheie API.",
"error.unable_to_create_category": "Nu se poate crea această categorie.",
"error.unable_to_create_user": "Nu se poate crea utilizatorul.",
"error.unable_to_detect_rssbridge": "Nu pot detecta fluxul când utilizez RSS-Bridge: %v.",
"error.unable_to_parse_feed": "Nu pot procesa acest flux: %v.",
"error.unable_to_update_category": "Nu se poate actualiza această categorie.",
"error.unable_to_update_feed": "Nu se poate actualiza acest flux.",
"error.unable_to_update_user": "Nu se poate actualiza utilizatorul.",
"error.unlink_account_without_password": "Trebuie să definiți o parolă, altfel nu vă veți mai putea conecta.",
"error.user_already_exists": "Acest utilizator există deja.",
"error.user_mandatory_fields": "Numele utilizatorului este obligatoriu.",
"error.linktaco_missing_required_fields": "LinkTaco API Token și Organization Slug sunt necesare",
"form.api_key.label.description": "Etichetă Cheie API",
"form.category.hide_globally": "Ascunde intrările în lista globală de articole necitite",
"form.category.label.title": "Titlu",
"form.feed.fieldset.general": "General",
"form.feed.fieldset.integration": "Servicii Terțe",
"form.feed.fieldset.network_settings": "Setări Rețea",
"form.feed.fieldset.rules": "Reguli",
"form.feed.label.allow_self_signed_certificates": "Permite certificatele auto-semnate sau invalide",
"form.feed.label.apprise_service_urls": "Lista de URL-uri ale serviciilor Apprise separate prin virgule",
"form.feed.label.block_filter_entry_rules": "Reguli de Blocare a Intrărilor",
"form.feed.label.blocklist_rules": "Filtre de Blocare Bazate pe Regex",
"form.feed.label.category": "Categorie",
"form.feed.label.cookie": "Setare Cookie-uri",
"form.feed.label.crawler": "Aduce conținutul original",
"form.feed.label.description": "Descriere",
"form.feed.label.disable_http2": "Dezactivează HTTP/2 pentru a preveni amprentarea",
"form.feed.label.disabled": "Nu actualiza acest flux",
"form.feed.label.feed_password": "Parolă Flux",
"form.feed.label.feed_url": "Flux URL",
"form.feed.label.feed_username": "Nume user Flux",
"form.feed.label.fetch_via_proxy": "Utilizați proxy-ul configurat la nivelul aplicației",
"form.feed.label.hide_globally": "Ascunde intrările în lista globală de articole necitite",
"form.feed.label.ignore_http_cache": "Ignoră cache HTTP",
"form.feed.label.keep_filter_entry_rules": "Reguli de Permitere a Intrărilor",
"form.feed.label.keeplist_rules": "Filtre de Păstrare Bazate pe Regex",
"form.feed.label.no_media_player": "Nu există player media (audio/video)",
"form.feed.label.ntfy_activate": "Împinge intrările la ntfy",
"form.feed.label.ntfy_default_priority": "Prioritate predefinită Ntfy",
"form.feed.label.ntfy_high_priority": "Prioritate ridicată Ntfy",
"form.feed.label.ntfy_low_priority": "Prioritate redusă Ntfy",
"form.feed.label.ntfy_max_priority": "Prioritate maximă Ntfy",
"form.feed.label.ntfy_min_priority": "Prioritate minimă Ntfy",
"form.feed.label.ntfy_priority": "Prioritate Ntfy",
"form.feed.label.ntfy_topic": "Subiect Ntfy (opțional)",
"form.feed.label.proxy_url": "URL Proxy",
"form.feed.label.pushover_activate": "Activează Pushover",
"form.feed.label.pushover_default_priority": "Prioritate implicită Pushover",
"form.feed.label.pushover_high_priority": "Prioritate ridicată Pushover",
"form.feed.label.pushover_low_priority": "Prioritate redusă Pushover",
"form.feed.label.pushover_max_priority": "Prioritate maximă Pushover",
"form.feed.label.pushover_min_priority": "Prioritate minimă Pushover",
"form.feed.label.pushover_priority": "Prioritate Pushover",
"form.feed.label.rewrite_rules": "Reguli de Rescriere a Conținutului",
"form.feed.label.scraper_rules": "Reguli de Eliminare",
"form.feed.label.site_url": "Adresă URL",
"form.feed.label.title": "Titlu",
"form.feed.label.urlrewrite_rules": "URL Reguli de Rescriere",
"form.feed.label.user_agent": "Suprascrie User Agent Predefinit",
"form.feed.label.webhook_url": "URL Webhook (pentru a primi notificări despre evenimentele de intrare)",
"form.import.label.file": "Fișier OPML",
"form.import.label.url": "URL",
"form.integration.archiveorg_activate": "Trimite înregistrările pe archive.org",
"form.integration.apprise_activate": "Trimite înregistrările pe Apprise",
"form.integration.apprise_services_url": "URL-uri separate de virgulă cu servicii Apprise",
"form.integration.apprise_url": "URL API Apprise",
"form.integration.betula_activate": "Salvează înregistrările în Betula",
"form.integration.betula_token": "Token Betula",
"form.integration.betula_url": "Adresă server Betula",
"form.integration.cubox_activate": "Salvează intrările în Cubox",
"form.integration.cubox_api_link": "Link APi Cubox",
"form.integration.discord_activate": "Împinge intrările pe Discord",
"form.integration.discord_webhook_link": "Link Webhook Discord",
"form.integration.espial_activate": "Salvează intrările în Espial",
"form.integration.espial_api_key": "Cheie API Espial",
"form.integration.espial_endpoint": "Punct acces API Espial",
"form.integration.espial_tags": "Etichete Espial",
"form.integration.fever_activate": "Activează API Fever",
"form.integration.fever_endpoint": "Punct access API Fever:",
"form.integration.fever_password": "Parolă Fever",
"form.integration.fever_username": "Utilizator Fever",
"form.integration.googlereader_activate": "Activează API Google Reader",
"form.integration.googlereader_endpoint": "Punct acces API Google Reader:",
"form.integration.googlereader_password": "Parolă Google Reader",
"form.integration.googlereader_username": "Utilizator Google Reader",
"form.integration.instapaper_activate": "Salvează înregistrările pe Instapaper",
"form.integration.instapaper_password": "Parolă Instapaper",
"form.integration.instapaper_username": "Utilizator Instapaper",
"form.integration.karakeep_activate": "Salvare înregistrări în Karakeep",
"form.integration.karakeep_api_key": "Cheie API Karakeep",
"form.integration.karakeep_url": "Punct acces API Karakeep",
"form.integration.karakeep_tags": "Karakeep Tags",
"form.integration.linkace_activate": "Salvează intrările în LinkAce",
"form.integration.linkace_api_key": "Cheie API LinkAce",
"form.integration.linkace_check_disabled": "Dezactivează verificarea link-urilor",
"form.integration.linkace_endpoint": "Endpoint API LinkAce",
"form.integration.linkace_is_private": "Marchează link-urile ca private",
"form.integration.linkace_tags": "Tag-uri LinkAce",
"form.integration.linkding_activate": "Salvează intrările în Linkding",
"form.integration.linkding_api_key": "Cheie API Linkding",
"form.integration.linkding_bookmark": "Marchează semnele de carte ca necitite",
"form.integration.linkding_endpoint": "Endpoint API Linkding",
"form.integration.linkding_tags": "TAG-uri Linkding",
"form.integration.linktaco_activate": "Salvează înregistrările în LinkTaco",
"form.integration.linktaco_api_token": "LinkTaco API Token",
"form.integration.linktaco_api_token_hint": "Obțineți jetonul de acces personal la",
"form.integration.linktaco_org_slug": "Slug organizației",
"form.integration.linktaco_tags": "Tag-uri (maxim 10, separate prin virgule)",
"form.integration.linktaco_tags_hint": "Maxim 10 tag-uri, separate prin virgule",
"form.integration.linktaco_visibility": "Vizibilitate",
"form.integration.linktaco_visibility_public": "Public",
"form.integration.linktaco_visibility_private": "Privat",
"form.integration.linktaco_visibility_hint": "Vizibilitatea PRIVATĂ necesită un cont LinkTaco plătit",
"form.integration.linkwarden_activate": "Salvează intrările în Linkwarden",
"form.integration.linkwarden_api_key": "Cheie API Linkwarden",
"form.integration.linkwarden_endpoint": "URL-ul de bază Linkwarden",
"form.integration.linkwarden_collection_id": "Linkwarden Collection ID",
"form.integration.matrix_bot_activate": "Împinge intrările noi pe Matrix",
"form.integration.matrix_bot_chat_id": "ID-ul Camerei Matrix",
"form.integration.matrix_bot_password": "Parola utilizatorului Matrix",
"form.integration.matrix_bot_url": "Server URL Matrix",
"form.integration.matrix_bot_user": "Utilizator Matrix",
"form.integration.notion_activate": "Salvează înregistrările în Notion",
"form.integration.notion_page_id": "ID Pagină Notion",
"form.integration.notion_token": "Token Secret Notion",
"form.integration.ntfy_activate": "Împinge intrările pe ntfy",
"form.integration.ntfy_api_token": "Token API Ntfy (opțional)",
"form.integration.ntfy_icon_url": "Icon URL Ntfy (opțional)",
"form.integration.ntfy_internal_links": "Utilizează legături interne la clic (opțional)",
"form.integration.ntfy_password": "Parolă Ntfy (opțional)",
"form.integration.ntfy_topic": "Topic Ntfy",
"form.integration.ntfy_url": "URL Ntfy (opțional, predefinit este ntfy.sh)",
"form.integration.ntfy_username": "Utilizator Ntfy (opțional)",
"form.integration.nunux_keeper_activate": "Salvează înregistrările în Nunux Keeper",
"form.integration.nunux_keeper_api_key": "Cheie API Nunux Keeper",
"form.integration.nunux_keeper_endpoint": "Punct de acces API Keeper",
"form.integration.omnivore_activate": "Salvare înregistrări în Omnivore",
"form.integration.omnivore_api_key": "Cheie API Omnivore",
"form.integration.omnivore_url": "Punct acces API Omnivore",
"form.integration.pinboard_activate": "Salvează intrările în Pinboard",
"form.integration.pinboard_bookmark": "Marchează bookmark ca necitit",
"form.integration.pinboard_tags": "Etichete Pinboard",
"form.integration.pinboard_token": "Token API Pinboard",
"form.integration.pushover_activate": "Activează Pushover",
"form.integration.pushover_device": "Dispozitiv Pushover (opțional)",
"form.integration.pushover_prefix": "Prefix Pushover (opțional)",
"form.integration.pushover_token": "Token Pushover",
"form.integration.pushover_user": "Utilizator Pushover",
"form.integration.raindrop_activate": "Salvează intrările în Raindrop",
"form.integration.raindrop_collection_id": "ID Colecție",
"form.integration.raindrop_tags": "Tag-uri (separate de virgulă)",
"form.integration.raindrop_token": "(Test) Token",
"form.integration.readeck_activate": "Salvează intrările în readeck",
"form.integration.readeck_api_key": "Cheie API Readeck",
"form.integration.readeck_endpoint": "URL Readeck",
"form.integration.readeck_labels": "Etichete Readeck",
"form.integration.readeck_only_url": "Trimite numai URL (în loc de tot conținutul)",
"form.integration.readeck_push_activate": "Trimite automat noile înregistrări în Readeck",
"form.integration.readwise_activate": "Salvare înregistrări în Readwise Reader",
"form.integration.readwise_api_key": "Token Acces Readwise Reader",
"form.integration.readwise_api_key_link": "Obțineți Token-ul de Acess pe Readwise",
"form.integration.rssbridge_activate": "Verifică RSS-Bridge la adăugarea de abonamente",
"form.integration.rssbridge_token": "Token de autentificare RSS-Bridge",
"form.integration.rssbridge_url": "URL server RSS-Bridge",
"form.integration.shaarli_activate": "Salvează articolele în Shaarli",
"form.integration.shaarli_api_secret": "Secret API Shaarli",
"form.integration.shaarli_endpoint": "URL Shaarli",
"form.integration.shiori_activate": "Salvează articolele în Shiori",
"form.integration.shiori_endpoint": "Endpoint API Shiori",
"form.integration.shiori_password": "Parolă Shiori",
"form.integration.shiori_username": "Utilizator Shiori",
"form.integration.slack_activate": "Împinge intrările pe Slack",
"form.integration.slack_webhook_link": "Link Webhook Slack",
"form.integration.telegram_bot_activate": "Împingeți înregistrările noi pe chat-ul Telegram",
"form.integration.telegram_bot_disable_buttons": "Dezactivează butoanele",
"form.integration.telegram_bot_disable_notification": "Dezactivează notificările",
"form.integration.telegram_bot_disable_web_page_preview": "Dezactivează previzualizarea paginii web",
"form.integration.telegram_bot_token": "Token Bot",
"form.integration.telegram_chat_id": "ID Chat",
"form.integration.telegram_topic_id": "ID Topic",
"form.integration.wallabag_activate": "Salvează înregistrările în Wallabag",
"form.integration.wallabag_client_id": "ID Client Wallabag",
"form.integration.wallabag_client_secret": "Secret Client Wallabag",
"form.integration.wallabag_endpoint": "URL Wallabag",
"form.integration.wallabag_tags": "Etichete Wallabag",
"form.integration.wallabag_only_url": "Trimite numai URL-ul (fără conținut complet)",
"form.integration.wallabag_password": "Parolă Wallabag",
"form.integration.wallabag_username": "Utilizator Wallabag",
"form.integration.webhook_activate": "Activează Webhook",
"form.integration.webhook_secret": "Secret Webhook",
"form.integration.webhook_url": "URL Webhook",
"form.prefs.fieldset.application_settings": "Setări Aplicație",
"form.prefs.fieldset.authentication_settings": "Setări Autentificare",
"form.prefs.fieldset.global_feed_settings": "Setări Globale pt. Flux",
"form.prefs.fieldset.reader_settings": "Setări Citire",
"form.prefs.help.external_font_hosts": "Lista fonturilor de pe gazdă separate de virgulă care poate fi utilizate. De exemplu: \"fonts.gstatic.com fonts.googleapis.com\".",
"form.prefs.label.always_open_external_links": "Citește articolele deschizând linkurile externe",
"form.prefs.label.categories_sorting_order": "Sortare categorii",
"form.prefs.label.cjk_reading_speed": "Viteză de citire pentru Chineză, Coreană și Japoneză (caractere pe minut)",
"form.prefs.label.custom_css": "CSS personalizat",
"form.prefs.label.custom_js": "JavaScript personalizat",
"form.prefs.label.default_home_page": "Pagina pornire predefinită",
"form.prefs.label.default_reading_speed": "Viteză de citire pentru alte limbi (cuvinte pe minut)",
"form.prefs.label.display_mode": "Mod afișare Aplicație Web Progresivă (PWA)",
"form.prefs.label.entries_per_page": "Intrări pe pagină",
"form.prefs.label.entry_order": "Coloană de sortare",
"form.prefs.label.entry_sorting": "Sortare intrări",
"form.prefs.label.entry_swipe": "Activare glisare pentru ecranele tactile",
"form.prefs.label.external_font_hosts": "Fonturi externe gazdă",
"form.prefs.label.gesture_nav": "Gesturi pentru navigare între înregistrări",
"form.prefs.label.keyboard_shortcuts": "Activare scurtături tastatură",
"form.prefs.label.language": "Limbă",
"form.prefs.label.mark_read_manually": "Marchează manual intrările ca citite",
"form.prefs.label.mark_read_on_media_completion": "Marchează ca citit numai când redarea de conținut audio/video atinge 90%%",
"form.prefs.label.mark_read_on_view": "Marchează intrările ca citite la vizualizare",
"form.prefs.label.mark_read_on_view_or_media_completion": "Marchează intrările ca citite la vizualizare. Pentru audio/video, marchează ca citit la redarea a 90%% de conținut",
"form.prefs.label.media_playback_rate": "Viteza de rulare audio/video",
"form.prefs.label.open_external_links_in_new_tab": "Deschide linkurile externe într-o filă nouă (adaugă target=\"_blank\" la linkuri)",
"form.prefs.label.show_reading_time": "Afișare timp estimat de citire pentru înregistrări",
"form.prefs.label.theme": "Temă",
"form.prefs.label.timezone": "Fus orar",
"form.prefs.select.alphabetical": "Alfabetic",
"form.prefs.select.browser": "Browser",
"form.prefs.select.created_time": "Dată creare înregistrare",
"form.prefs.select.fullscreen": "Ecran complet",
"form.prefs.select.minimal_ui": "Minim",
"form.prefs.select.none": "Nimic",
"form.prefs.select.older_first": "Intrările mai vechi la început",
"form.prefs.select.publish_time": "Data publicare înregistrare",
"form.prefs.select.recent_first": "Intrările mai noi la început",
"form.prefs.select.standalone": "Independent",
"form.prefs.select.swipe": "Glisare",
"form.prefs.select.tap": "Apăsare dublă",
"form.prefs.select.unread_count": "Contor necitite",
"form.submit.loading": "Încarc…",
"form.submit.saving": "Salvez…",
"form.user.label.admin": "Administrator",
"form.user.label.confirmation": "Confirmare Parolă",
"form.user.label.password": "Parolă",
"form.user.label.username": "Nume utilizator",
"menu.about": "Despre",
"menu.add_feed": "Adaugă flux",
"menu.add_user": "Adaugă utilizator",
"menu.api_keys": "Chei API",
"menu.categories": "Categorii",
"menu.create_api_key": "Crează o nouă cheie API",
"menu.create_category": "Crează o categorie",
"menu.edit_category": "Editare",
"menu.edit_feed": "Editare",
"menu.export": "Exportă",
"menu.feed_entries": "Intrări",
"menu.feeds": "Fluxuri",
"menu.flush_history": "Elimină istoricul",
"menu.history": "Istoric",
"menu.home_page": "Pagina principală",
"menu.import": "Importă",
"menu.integrations": "Integrări",
"menu.logout": "Deconectare",
"menu.mark_all_as_read": "Marchează tot ca citit",
"menu.mark_page_as_read": "Marchează această pagină ca citită",
"menu.preferences": "Preferințe",
"menu.refresh_all_feeds": "Reînnoiește toate fluxurile în fundal",
"menu.refresh_feed": "Reînnoire",
"menu.search": "Caută",
"menu.sessions": "Sesiuni",
"menu.settings": "Setări",
"menu.shared_entries": "Intrări partajate",
"menu.show_all_entries": "Afișează toate intrările",
"menu.show_only_starred_entries": "Afișează numai intrările marcate",
"menu.show_only_unread_entries": "Afișează numai intrările necitite",
"menu.starred": "Marcat",
"menu.title": "Meniu",
"menu.unread": "Necitit",
"menu.users": "Utilizatori",
"page.about.author": "Autor:",
"page.about.build_date": "Dată Build:",
"page.about.credits": "Credit",
"page.about.db_usage": "Utilizare Bază de Date",
"page.about.git_commit": "Git Commit:",
"page.about.global_config_options": "Opțiuni globale de configurare",
"page.about.go_version": "Versiune Go:",
"page.about.license": "Licență:",
"page.about.postgres_version": "Versiune Postgres:",
"page.about.title": "Despre",
"page.about.version": "Versiune:",
"page.add_feed.choose_feed": "Alegeți un flux",
"page.add_feed.label.url": "URL",
"page.add_feed.legend.advanced_options": "Opțiuni Avansate",
"page.add_feed.no_category": "Nu există categorii. Trebuie să aveți măcar o categorie.",
"page.add_feed.submit": "Găsește un flux",
"page.add_feed.title": "Flux nou",
"page.api_keys.never_used": "Niciodată Utilizată",
"page.api_keys.table.actions": "Acțiuni",
"page.api_keys.table.created_at": "Dată Creare",
"page.api_keys.table.description": "Descriere",
"page.api_keys.table.last_used_at": "Utilizat ultima dată",
"page.api_keys.table.token": "Token",
"page.api_keys.title": "Chei API",
"page.categories.entries": "Intrări",
"page.categories.feed_count": [
"Este %d flux.",
"Sunt %d fluxuri.",
"Sunt %d fluxuri găsite."
],
"page.categories.feeds": "Fluxuri",
"page.categories.no_feed": "Nici un flux.",
"page.categories.title": "Categorii",
"page.categories_count": [
"%d categorie",
"%d categorii",
"%d categorie găsită"
],
"page.category_label": "Categorie: %s",
"page.edit_category.title": "Editare Categorie: %s",
"page.edit_feed.etag_header": "Antet ETag:",
"page.edit_feed.last_check": "Ultima verificare:",
"page.edit_feed.last_modified_header": "UltimaModificare antet:",
"page.edit_feed.last_parsing_error": "Ultima Eroare la Analiză",
"page.edit_feed.no_header": "Nimic",
"page.edit_feed.title": "Editare Flux: %s",
"page.edit_user.title": "Editare Utilizator: %s",
"page.entry.attachments": "Atașamente",
"page.feeds.error_count": [
"%d eroare",
"%d erori",
"%d erori găsite"
],
"page.feeds.last_check": "Ultima verificare:",
"page.feeds.next_check": "Următoarea verificare:",
"page.feeds.read_counter": "Numărul de intrări citite",
"page.feeds.title": "Fluxuri",
"page.footer.elevator": "Înapoi sus",
"page.history.title": "Istoric",
"page.import.title": "Import",
"page.integration.bookmarklet": "Marcaje",
"page.integration.bookmarklet.help": "Această legătură specială permite să vă abonați direct pe un site prin utilizarea unui marcaj în browser-ul web.",
"page.integration.bookmarklet.instructions": "Trageți legătura în favorite.",
"page.integration.bookmarklet.name": "Adaugă în Miniflux",
"page.integration.miniflux_api": "API Miniflux",
"page.integration.miniflux_api_endpoint": "Punct de acces API",
"page.integration.miniflux_api_password": "Parolă",
"page.integration.miniflux_api_password_value": "Parola contului",
"page.integration.miniflux_api_username": "Utilizator",
"page.integrations.title": "Integrări",
"page.keyboard_shortcuts.close_modal": "Închide fereastra de dialog",
"page.keyboard_shortcuts.download_content": "Descarcă conținutul original",
"page.keyboard_shortcuts.go_to_bottom_item": "Du-te la ultimul obiect",
"page.keyboard_shortcuts.go_to_categories": "Du-te la categorii",
"page.keyboard_shortcuts.go_to_feed": "Du-te la flux",
"page.keyboard_shortcuts.go_to_feeds": "Du-te la fluxuri",
"page.keyboard_shortcuts.go_to_history": "Du-te la istoric",
"page.keyboard_shortcuts.go_to_next_item": "Du-te la obiectul următor",
"page.keyboard_shortcuts.go_to_next_page": "Du-te la pagina următoare",
"page.keyboard_shortcuts.go_to_previous_item": "Du-te la obiectul anterior",
"page.keyboard_shortcuts.go_to_previous_page": "Du-te la pagina anterioară",
"page.keyboard_shortcuts.go_to_search": "Focusul pe formularul de căutare",
"page.keyboard_shortcuts.go_to_settings": "Du-te la setări",
"page.keyboard_shortcuts.go_to_starred": "Du-te la marcat",
"page.keyboard_shortcuts.go_to_top_item": "Du-te la primul obiect",
"page.keyboard_shortcuts.go_to_unread": "Du-te la necitit",
"page.keyboard_shortcuts.mark_page_as_read": "Marchează pagina curentă ca citită",
"page.keyboard_shortcuts.open_comments": "Deschide comentariile link-ului",
"page.keyboard_shortcuts.open_comments_same_window": "Deschide comentariile link-ului în tab-ul curent",
"page.keyboard_shortcuts.open_item": "Deschide obiectul selectat",
"page.keyboard_shortcuts.open_original": "Deschide link-ul original",
"page.keyboard_shortcuts.open_original_same_window": "Deschide link-ul original în tab-ul curent",
"page.keyboard_shortcuts.refresh_all_feeds": "Reîncarcă toate fluxurile în fundal",
"page.keyboard_shortcuts.remove_feed": "Elimină acest flux",
"page.keyboard_shortcuts.save_article": "Salvare înregistrare",
"page.keyboard_shortcuts.scroll_item_to_top": "Derulează obiectul la început",
"page.keyboard_shortcuts.show_keyboard_shortcuts": "Afișează scurtăturile tastaturii",
"page.keyboard_shortcuts.subtitle.actions": "Acțiuni",
"page.keyboard_shortcuts.subtitle.items": "Navigare Obiecte",
"page.keyboard_shortcuts.subtitle.pages": "Navigare Pagini",
"page.keyboard_shortcuts.subtitle.sections": "Navigare Secțiuni",
"page.keyboard_shortcuts.title": "Scurtături Tastatură",
"page.keyboard_shortcuts.toggle_star_status": "Comută marcate",
"page.keyboard_shortcuts.toggle_entry_attachments": "Comută deschis/închis pe atașamentele înregistrării",
"page.keyboard_shortcuts.toggle_read_status_next": "Comută citit/necitit focus următor",
"page.keyboard_shortcuts.toggle_read_status_prev": "Comută citit/necitit, focus anterior",
"page.login.google_signin": "Conectare cu Google",
"page.login.oidc_signin": "Conectare cu %s",
"page.login.title": "Conectare",
"page.login.webauthn_login": "Conectare cu cheia de acces",
"page.login.webauthn_login.error": "Eroare la conectarea cu cheia de acces",
"page.login.webauthn_login.help": "Vă rog să introduceți numele utilizatorului dacă utilizați o cheie. Nu este necesară dacă utilizați o cheie de acces (credențiale descoperibile).",
"page.new_api_key.title": "Cheie API Nouă",
"page.new_category.title": "Categorie Nouă",
"page.new_user.title": "Utilizator Nou",
"page.offline.message": "Sunteți offline",
"page.offline.refresh_page": "Încercați să reîmprospătați pagina",
"page.offline.title": "Mod Offline",
"page.read_entry_count": [
"%d înregistrare citită",
"%d înregistrări citite",
"%d înregistrări citite"
],
"page.search.title": "Rezultate Căutare",
"page.sessions.table.actions": "Acțiuni",
"page.sessions.table.current_session": "Sesiunea Curentă",
"page.sessions.table.date": "Dată",
"page.sessions.table.ip": "Adresă IP",
"page.sessions.table.user_agent": "Agent Utilizator",
"page.sessions.title": "Sesiuni",
"page.settings.link_google_account": "Atașează contul personal Google",
"page.settings.link_oidc_account": "Atașează contul meu %s",
"page.settings.title": "Setări",
"page.settings.unlink_google_account": "Decuplează contul personal Google",
"page.settings.unlink_oidc_account": "Decuplează contul meu %s",
"page.settings.webauthn.actions": "Acțiuni",
"page.settings.webauthn.added_on": "Adăugată în",
"page.settings.webauthn.delete": [
"Elimină %d cheie de acces",
"Elimină %d chei de acces",
"Elimină %d chei de acces"
],
"page.settings.webauthn.last_seen_on": "Utilizat ultima dată",
"page.settings.webauthn.passkey_name": "Nume cheie acces",
"page.settings.webauthn.passkeys": "Chei Acces",
"page.settings.webauthn.register": "Înregistrare cheie acces",
"page.settings.webauthn.register.error": "Eroare la înregistrarea cheii de acces",
"page.shared_entries.title": "Înregistrări partajate",
"page.shared_entries_count": [
"%d înregistrare partajată",
"%d înregistrări partajate",
"%d înregistrări partajate"
],
"page.starred.title": "Marcate",
"page.starred_entry_count": [
"%d înregistrare marcată",
"%d Înregistrări marcate",
"%d Înregistrări marcate"
],
"page.total_entry_count": [
"%d intrare în total",
"%d intrări în total",
"%d intrări în total"
],
"page.unread.title": "Necitite",
"page.unread_entry_count": [
"%d înregistrare necitită",
"%d înregistrări necitite",
"%d înregistrări necitite"
],
"page.users.actions": "Acțiuni",
"page.users.admin.no": "Nu",
"page.users.admin.yes": "Da",
"page.users.is_admin": "Administrator",
"page.users.last_login": "Ultima Conectare",
"page.users.never_logged": "Niciodată",
"page.users.title": "Utilizatori",
"page.users.username": "Nume",
"page.webauthn_rename.title": "Redenumire Cheie Acces",
"pagination.first": "Prima",
"pagination.last": "Ultima",
"pagination.next": "Următor",
"pagination.previous": "Anterior",
"search.label": "Caută",
"search.placeholder": "Caută…",
"search.submit": "Caută",
"skip_to_content": "Sari la conținut",
"time_elapsed.days": [
"%d zi în urmă",
"%d zile în urmă",
"%d zile în urmă"
],
"time_elapsed.hours": [
"%d oră în urmă",
"%d ore în urmă",
"%d ore în urmă"
],
"time_elapsed.minutes": [
"%d minut în urmă",
"%d minute în urmă",
"%d minute în urmă"
],
"time_elapsed.months": [
"%d lună în urmă",
"%d luni în urmă",
"%d luni în urmă"
],
"time_elapsed.not_yet": "încă nu",
"time_elapsed.now": "chiar acum",
"time_elapsed.weeks": [
"%d săptămână în urmă",
"%d săptămâni în urmă",
"%d săptămâni în urmă"
],
"time_elapsed.years": [
"%d an în urmă",
"%d ani în urmă",
"%d ani în urmă"
],
"time_elapsed.yesterday": "ieri",
"tooltip.keyboard_shortcuts": "Scurtături Tastatură: %s",
"tooltip.logged_user": "Atentificat ca %s"
}
v2-2.2.16/internal/locale/translations/ru_RU.json 0000664 0000000 0000000 00000145471 15127074645 0021703 0 ustar 00root root 0000000 0000000 {
"action.cancel": "закрыть",
"action.download": "Загрузить",
"action.edit": "Изменить",
"action.home_screen": "Добавить на домашний экран",
"action.import": "Импорт",
"action.login": "Войти",
"action.or": "или",
"action.remove": "Удалить",
"action.remove_feed": "Удалить эту подписку",
"action.save": "Сохранить",
"action.subscribe": "Подписаться",
"action.update": "Обновить",
"alert.account_linked": "Ваш внешний аккаунт теперь привязан!",
"alert.account_unlinked": "Ваш внешний аккаунт теперь отвязан!",
"alert.background_feed_refresh": "Все подписки обновляются в фоновом режиме. Вы можете продолжать использовать Miniflux пока идёт этот процесс.",
"alert.feed_error": "С этой подпиской есть проблема",
"alert.no_starred": "Избранное отсутствует.",
"alert.no_category": "Категории отсутствуют.",
"alert.no_category_entry": "В этой категории нет статей.",
"alert.no_feed": "У вас нет ни одной подписки.",
"alert.no_feed_entry": "В этой подписке отсутствуют статьи.",
"alert.no_feed_in_category": "Для этой категории нет подписки.",
"alert.no_history": "Истории пока что нет.",
"alert.no_search_result": "Нет результатов для данного поискового запроса.",
"alert.no_shared_entry": "Общедоступные статьи отсутствуют.",
"alert.no_tag_entry": "Нет записей, соответствующих этому тегу.",
"alert.no_unread_entry": "Нет непрочитанных статей.",
"alert.no_user": "Вы единственный пользователь.",
"alert.prefs_saved": "Предпочтения сохранены!",
"alert.too_many_feeds_refresh": [
"Вы запустили слишком много обновлений подписок. Подождите %d минуту для нового запуска",
"Вы запустили слишком много обновлений подписок. Подождите %d минут для нового запуска",
"Вы запустили слишком много обновлений подписок. Подождите %d минут для нового запуска"
],
"confirm.loading": "В процессе…",
"confirm.no": "нет",
"confirm.question": "Вы уверены?",
"confirm.question.refresh": "Вы хотите выполнить принудительное обновление?",
"confirm.yes": "да",
"enclosure_media_controls.seek": "Перемотка:",
"enclosure_media_controls.seek.title": "Перемотать на %s секунд",
"enclosure_media_controls.speed": "Скорость:",
"enclosure_media_controls.speed.faster": "Быстрее",
"enclosure_media_controls.speed.faster.title": "Ускорить в %s раз",
"enclosure_media_controls.speed.reset": "Сбросить",
"enclosure_media_controls.speed.reset.title": "Сбросить скорость до 1x",
"enclosure_media_controls.speed.slower": "Медленнее",
"enclosure_media_controls.speed.slower.title": "Замедлить в %s раз",
"entry.starred.toast.off": "Без пометок",
"entry.starred.toast.on": "Помеченные",
"entry.starred.toggle.off": "Удалить из Избранного",
"entry.starred.toggle.on": "Добавить в Избранное",
"entry.comments.label": "Комментарии",
"entry.comments.title": "Показать комментарии",
"entry.estimated_reading_time": [
"%d минута чтения",
"%d минуты чтения",
"%d минут чтения"
],
"entry.external_link.label": "Внешняя ссылка",
"entry.save.completed": "Готово!",
"entry.save.label": "Сохранить",
"entry.save.title": "Сохранить эту статью",
"entry.save.toast.completed": "Статья сохранена",
"entry.scraper.completed": "Готово!",
"entry.scraper.label": "Скачать",
"entry.scraper.title": "Извлечь оригинальное содержимое",
"entry.share.label": "Поделиться",
"entry.share.title": "Поделиться этой статьёй",
"entry.shared_entry.label": "Поделиться",
"entry.shared_entry.title": "Открыть публичную ссылку",
"entry.state.loading": "Загрузка…",
"entry.state.saving": "Сохранение…",
"entry.status.mark_as_read": "Отметить как прочитанное",
"entry.status.mark_as_unread": "Пометить как непрочитанное",
"entry.status.title": "Изменить статус записи",
"entry.status.toast.read": "Помечено как прочитанное",
"entry.status.toast.unread": "Помечено как непрочитанное",
"entry.tags.label": "Теги:",
"entry.tags.more_tags_label": [
"Ещё %d тег",
"Ещё %d тега",
"Ещё %d тегов"
],
"entry.unshare.label": "Удалить из общедоступных",
"error.api_key_already_exists": "Этот API-ключ уже существует.",
"error.bad_credentials": "Неверное имя пользователя или пароль.",
"error.category_already_exists": "Эта категория уже существует.",
"error.category_not_found": "Эта категория не существует или не принадлежит этому пользователю.",
"error.database_error": "Ошибка базы данных: %v.",
"error.different_passwords": "Пароли не совпадают.",
"error.duplicate_fever_username": "Уже есть кто-то с таким же именем пользователя Fever!",
"error.duplicate_googlereader_username": "Уже есть кто-то с таким же именем пользователя Google Reader!",
"error.duplicate_linked_account": "Уже есть кто-то, кто ассоциирован с этим аккаунтом!",
"error.duplicated_feed": "Эта подписка уже существует.",
"error.empty_file": "Этот файл пуст.",
"error.entries_per_page_invalid": "Недопустимое значение количества записей на странице.",
"error.feed_already_exists": "Эта подписка уже существует.",
"error.feed_category_not_found": "Эта категория не существует или не принадлежит этому пользователю.",
"error.feed_format_not_detected": "Не удалось определить формат подписки: %v.",
"error.feed_invalid_blocklist_rule": "Правило черного списка некорректно.",
"error.feed_invalid_keeplist_rule": "Правило белого списка некорректно.",
"error.feed_mandatory_fields": "Ссылка и категория обязательны.",
"error.feed_not_found": "Эта подписка не существует или не принадлежит этому пользователю.",
"error.feed_title_not_empty": "Заголовок подписки не может быть пустым.",
"error.feed_url_not_empty": "URL-адрес подписки не может быть пустым.",
"error.fields_mandatory": "Все поля обязательны.",
"error.http_bad_gateway": "В данный момент сайт недоступен из-за ошибки шлюза. Проблема не связана с Miniflux. Пожалуйста, попробуйте позже.",
"error.http_body_read": "Невозможно прочитать тело HTTP-сообщения: %v.",
"error.http_client_error": "Ошибка HTTP-клиента: %v.",
"error.http_empty_response": "Пустой ответ HTTP. Возможно этот сайт использует защиту от ботов?",
"error.http_empty_response_body": "Пустое тело HTTP-ответа.",
"error.http_forbidden": "Доступ к сайту запрещён. Возможно этот сайт использует защиту от ботов?",
"error.http_gateway_timeout": "В данный момент сайт недоступен из-за превышения времени ожидания ответа от шлюза. Проблема не связана с Miniflux. Пожалуйста, попробуйте позже.",
"error.http_internal_server_error": "В данный момент сайт недоступен из-за ошибки сервера. Проблема не связана с Miniflux. Пожалуйста, попробуйте позже.",
"error.http_not_authorized": "Доступ к сайту запрещён. Возможно используется неправильное имя пользователя или пароль.",
"error.http_resource_not_found": "Запрашиваемый ресурс не найден. Пожалуйста, проверьте URL.",
"error.http_response_too_large": "Превышен размер HTTP-ответа. Вы можете увеличить лимит размера HTTP-ответа в настройках (для применения новых настроек потребуется перезагрузка приложения).",
"error.http_service_unavailable": "В данный момент сайт недоступен из-за ошибки сервера. Проблема не связана с Miniflux. Пожалуйста, попробуйте позже.",
"error.http_too_many_requests": "Miniflux отправил слишком много запросов к этому сайту. Пожалуйста, попробуйте позже или измените настройки приложения.",
"error.http_unexpected_status_code": "В данный момент сайт недоступен из-за непредвиденного кода HTTP-ответа: %d. Проблема не связана с Miniflux. Пожалуйста, попробуйте позже.",
"error.invalid_categories_sorting_order": "Недопустимый порядок сортировки категорий.",
"error.invalid_default_home_page": "Недопустимая домашняя страница по умолчанию!",
"error.invalid_display_mode": "Недопустимый режим отображения веб-приложения.",
"error.invalid_entry_direction": "Недопустимая сортировка записей.",
"error.invalid_entry_order": "Недопустимый порядок статей.",
"error.invalid_feed_proxy_url": "Недействительный URL прокси.",
"error.invalid_feed_url": "Недействительная ссылка подписки.",
"error.invalid_gesture_nav": "Недопустимая навигация жестами.",
"error.invalid_language": "Недопустимый язык.",
"error.invalid_site_url": "Недействительный ссылка сайта.",
"error.invalid_theme": "Недопустимая тема.",
"error.invalid_timezone": "Недопустимый часовой пояс.",
"error.network_operation": "Miniflux не может открыть сайт из-за ошибки сети: %v.",
"error.network_timeout": "Этот сайт слишком медленный и время ожидания запроса истекло: %v",
"error.password_min_length": "Вы должны использовать минимум 6 символов.",
"error.proxy_url_not_empty": "URL прокси не может быть пустым.",
"error.settings_block_rule_fieldname_invalid": "Недопустимое правило блокировки: у правила #%d отсутствует корректное имя поля (Возможные варианты: %s)",
"error.settings_block_rule_invalid_regex": "Недопустимое правило блокировки: шаблон правила #%d не является корректным регулярным выражением",
"error.settings_block_rule_regex_required": "Недопустимое правило блокировки: не указан шаблон для правила #%d",
"error.settings_block_rule_separator_required": "Недопустимое правило блокировки: шаблон правила #%d должен быть отделен символом '='",
"error.settings_invalid_domain_list": "Недопустимый список доменов. Пожалуйста, укажите список доменов, разделенных пробелами.",
"error.settings_keep_rule_fieldname_invalid": "Недопустимое правило сохранения: у правила #%d отсутствует корректное имя поля (Возможные варианты: %s)",
"error.settings_keep_rule_invalid_regex": "Недопустимое правило сохранения: шаблон правила #%d не является корректным регулярным выражением",
"error.settings_keep_rule_regex_required": "Недопустимое правило сохранения: не указан шаблон для правила #%d",
"error.settings_keep_rule_separator_required": "Недопустимое правило сохранения: шаблон правила #%d должен быть отделен символом '='",
"error.settings_mandatory_fields": "Имя пользователя, тема, язык и часовой пояс обязательны.",
"error.settings_media_playback_rate_range": "Скорость воспроизведения выходит за пределы диапазона",
"error.settings_reading_speed_is_positive": "Скорость чтения должна быть целым положительным числом.",
"error.site_url_not_empty": "Ссылка на сайт не может быть пустой.",
"error.subscription_not_found": "Не удалось найти подписки.",
"error.title_required": "Название обязательно.",
"error.tls_error": "Ошибка TLS: %q. Вы можете отключить проверку TLS в настройках подписки.",
"error.unable_to_create_api_key": "Невозможно создать этот API-ключ.",
"error.unable_to_create_category": "Не удалось создать эту категорию.",
"error.unable_to_create_user": "Не удалось создать этого пользователя.",
"error.unable_to_detect_rssbridge": "Не удалось обнаружить подписку с помощью RSS-Bridge: %v.",
"error.unable_to_parse_feed": "Не удалось обработать эту подписку: %v.",
"error.unable_to_update_category": "Не удалось обновить эту категорию.",
"error.unable_to_update_feed": "Не удалось обновить эту подписку.",
"error.unable_to_update_user": "Не удалось обновить этого пользователя.",
"error.unlink_account_without_password": "Вы должны установить пароль, иначе вы не сможете войти снова.",
"error.user_already_exists": "Этот пользователь уже существует.",
"error.user_mandatory_fields": "Имя пользователя обязательно.",
"error.linktaco_missing_required_fields": "LinkTaco API Token и Organization Slug обязательны",
"form.api_key.label.description": "Описание API-ключа",
"form.category.hide_globally": "Скрыть записи в глобальном списке непрочитанных",
"form.category.label.title": "Название",
"form.feed.fieldset.general": "Общие",
"form.feed.fieldset.integration": "Сторонние сервисы",
"form.feed.fieldset.network_settings": "Настройки сети",
"form.feed.fieldset.rules": "Правила",
"form.feed.label.allow_self_signed_certificates": "Разрешить самоподписанные или недействительные сертификаты",
"form.feed.label.apprise_service_urls": "Список ссылок сервисов Apprise, разделенный запятой",
"form.feed.label.block_filter_entry_rules": "Правила блокировки записей",
"form.feed.label.blocklist_rules": "Фильтры блокировки на основе регулярных выражений",
"form.feed.label.category": "Категория",
"form.feed.label.cookie": "Установить куки",
"form.feed.label.crawler": "Извлечь оригинальное содержимое",
"form.feed.label.description": "Описание",
"form.feed.label.disable_http2": "Отключить HTTP/2 для предотвращения фингерпринтинга",
"form.feed.label.disabled": "Не обновлять эту подписку",
"form.feed.label.feed_password": "Пароль подписки",
"form.feed.label.feed_url": "Адрес подписки",
"form.feed.label.feed_username": "Имя пользователя подписки",
"form.feed.label.fetch_via_proxy": "Использовать прокси, настроенный на уровне приложения",
"form.feed.label.hide_globally": "Скрыть записи в глобальном списке непрочитанных",
"form.feed.label.ignore_http_cache": "Игнорировать HTTP кеш",
"form.feed.label.keep_filter_entry_rules": "Правила разрешения записей",
"form.feed.label.keeplist_rules": "Фильтры сохранения на основе регулярных выражений",
"form.feed.label.no_media_player": "Отключить медиаплеер (аудио и видео)",
"form.feed.label.ntfy_activate": "Отправлять статьи в ntfy",
"form.feed.label.ntfy_default_priority": "По умолчанию",
"form.feed.label.ntfy_high_priority": "Высший",
"form.feed.label.ntfy_low_priority": "Низкий",
"form.feed.label.ntfy_max_priority": "Высокий",
"form.feed.label.ntfy_min_priority": "Минимальный",
"form.feed.label.ntfy_priority": "Приоритет ntfy",
"form.feed.label.ntfy_topic": "Топик ntfy (опционально)",
"form.feed.label.proxy_url": "URL прокси",
"form.feed.label.pushover_activate": "Отправлять статьи в pushover.net",
"form.feed.label.pushover_default_priority": "По умолчанию",
"form.feed.label.pushover_high_priority": "Высокий",
"form.feed.label.pushover_low_priority": "Низкий",
"form.feed.label.pushover_max_priority": "Высший",
"form.feed.label.pushover_min_priority": "Минимальный",
"form.feed.label.pushover_priority": "Приоритет сообщений Pushover",
"form.feed.label.rewrite_rules": "Правила переписывания содержимого",
"form.feed.label.scraper_rules": "Правила сборщика",
"form.feed.label.site_url": "Адрес сайта",
"form.feed.label.title": "Название",
"form.feed.label.urlrewrite_rules": "Правила перезаписи URL",
"form.feed.label.user_agent": "Переопределить User-Agent по умолчанию",
"form.feed.label.webhook_url": "Переопределить URL вебхука",
"form.import.label.file": "OPML файл",
"form.import.label.url": "Ссылка",
"form.integration.archiveorg_activate": "Отправить статьи в archive.org",
"form.integration.apprise_activate": "Отправить статьи в Apprise",
"form.integration.apprise_services_url": "Список ссылок сервисов Apprise, разделенный запятой",
"form.integration.apprise_url": "Ссылка на Apprise API",
"form.integration.betula_activate": "Сохранять статьи в Betula",
"form.integration.betula_token": "Токен Betula",
"form.integration.betula_url": "Адрес сервера Betula",
"form.integration.cubox_activate": "Сохранять статьи в Cubox",
"form.integration.cubox_api_link": "Ссылка на Cubox API",
"form.integration.discord_activate": "Отправить статьи в Discord",
"form.integration.discord_webhook_link": "Ссылка на Discord Webhook",
"form.integration.espial_activate": "Сохранять статьи в Espial",
"form.integration.espial_api_key": "API-ключ Espial",
"form.integration.espial_endpoint": "Конечная точка Espial API",
"form.integration.espial_tags": "Теги Espial",
"form.integration.fever_activate": "Активировать Fever API",
"form.integration.fever_endpoint": "Конечная точка Fever API:",
"form.integration.fever_password": "Пароль Fever",
"form.integration.fever_username": "Имя пользователя Fever",
"form.integration.googlereader_activate": "Активировать Google Reader API",
"form.integration.googlereader_endpoint": "Конечная точка Google Reader API:",
"form.integration.googlereader_password": "Пароль Google Reader",
"form.integration.googlereader_username": "Имя пользователя Google Reader",
"form.integration.instapaper_activate": "Сохранять статьи в Instapaper",
"form.integration.instapaper_password": "Пароль Instapaper",
"form.integration.instapaper_username": "Имя пользователя Instapaper",
"form.integration.karakeep_activate": "Сохранять статьи в Karakeep",
"form.integration.karakeep_api_key": "API-ключ Karakeep",
"form.integration.karakeep_url": "Конечная точка Karakeep API",
"form.integration.karakeep_tags": "Karakeep Tags",
"form.integration.linkace_activate": "Сохранять статьи в LinkAce",
"form.integration.linkace_api_key": "API-ключ LinkAce",
"form.integration.linkace_check_disabled": "Отключить проверку ссылок",
"form.integration.linkace_endpoint": "Конечная точка LinkAce API",
"form.integration.linkace_is_private": "Отмечать ссылки как приватные",
"form.integration.linkace_tags": "Теги LinkAce",
"form.integration.linkding_activate": "Сохранять статьи в Linkding",
"form.integration.linkding_api_key": "API-ключ Linkding",
"form.integration.linkding_bookmark": "Помечать закладки как непрочитанное",
"form.integration.linkding_endpoint": "Конечная точка Linkding API",
"form.integration.linkding_tags": "Теги Linkding",
"form.integration.linktaco_activate": "Сохранять статьи в LinkTaco",
"form.integration.linktaco_api_token": "LinkTaco API Token",
"form.integration.linktaco_api_token_hint": "Получить ваш персональный токен доступа на",
"form.integration.linktaco_org_slug": "Слаг организации",
"form.integration.linktaco_tags": "Теги (макс. 10, через запятую)",
"form.integration.linktaco_tags_hint": "Максимум 10 тегов, через запятую",
"form.integration.linktaco_visibility": "Видимость",
"form.integration.linktaco_visibility_public": "Публично",
"form.integration.linktaco_visibility_private": "Приватно",
"form.integration.linktaco_visibility_hint": "ПРИВАТНАЯ видимость требует платного аккаунта LinkTaco",
"form.integration.linkwarden_activate": "Сохранять статьи в Linkwarden",
"form.integration.linkwarden_api_key": "API-ключ Linkwarden",
"form.integration.linkwarden_endpoint": "Базовый URL-адрес Linkwarden",
"form.integration.linkwarden_collection_id": "Linkwarden Collection ID",
"form.integration.matrix_bot_activate": "Отправлять статьи в Matrix",
"form.integration.matrix_bot_chat_id": "ID комнаты Matrix",
"form.integration.matrix_bot_password": "Пароль пользователя Matrix",
"form.integration.matrix_bot_url": "Ссылка на сервер Matrix",
"form.integration.matrix_bot_user": "Имя пользователя Matrix",
"form.integration.notion_activate": "Сохранить статьи в Notion",
"form.integration.notion_page_id": "Идентификатор страницы Notion",
"form.integration.notion_token": "Секретный токен Notion",
"form.integration.ntfy_activate": "Отправлять статьи в ntfy",
"form.integration.ntfy_api_token": "API-токен ntfy (опционально)",
"form.integration.ntfy_icon_url": "URL иконки ntfy (опционально)",
"form.integration.ntfy_internal_links": "Использовать внутренние ссылки по клику (опционально)",
"form.integration.ntfy_password": "Пароль ntfy (опционально)",
"form.integration.ntfy_topic": "Тема ntfy (по умолчанию, если не задана в подписке)",
"form.integration.ntfy_url": "URL ntfy (опционально, по умолчанию ntfy.sh)",
"form.integration.ntfy_username": "Имя пользователя ntfy (опционально)",
"form.integration.nunux_keeper_activate": "Сохранять статьи в Nunux Keeper",
"form.integration.nunux_keeper_api_key": "API-ключ Nunux Keeper",
"form.integration.nunux_keeper_endpoint": "Конечная точка Nunux Keeper API",
"form.integration.omnivore_activate": "Сохранять статьи в Omnivore",
"form.integration.omnivore_api_key": "API-ключ Omnivore",
"form.integration.omnivore_url": "Конечная точка Omnivore API",
"form.integration.pinboard_activate": "Сохранять статьи в Pinboard",
"form.integration.pinboard_bookmark": "Помечать закладки как непрочитанное",
"form.integration.pinboard_tags": "Теги Pinboard",
"form.integration.pinboard_token": "Токен Pinboard API",
"form.integration.pushover_activate": "Отправлять статьи Pushover",
"form.integration.pushover_device": "Устройство Pushover (опционально)",
"form.integration.pushover_prefix": "URL-префикс Pushover (опционально)",
"form.integration.pushover_token": "API-токен приложения Pushover",
"form.integration.pushover_user": "Пользовательский ключ Pushover",
"form.integration.raindrop_activate": "Сохранять статьи в Raindrop",
"form.integration.raindrop_collection_id": "ID коллекции",
"form.integration.raindrop_tags": "Теги (через запятую)",
"form.integration.raindrop_token": "Токен (тестовый)",
"form.integration.readeck_activate": "Сохранять статьи в Readeck",
"form.integration.readeck_api_key": "API-ключ Readeck",
"form.integration.readeck_endpoint": "Конечная точка Readeck API",
"form.integration.readeck_labels": "Теги Readeck",
"form.integration.readeck_only_url": "Отправлять только ссылку (без содержимого)",
"form.integration.readeck_push_activate": "Автоматически отправлять новые статьи в Readeck",
"form.integration.readwise_activate": "Сохранить статьи в Readwise",
"form.integration.readwise_api_key": "Токен доступа в Readwise",
"form.integration.readwise_api_key_link": "Получить токен доступа Readwise",
"form.integration.rssbridge_activate": "Проверять RSS-Bridge при добавлении подписок",
"form.integration.rssbridge_token": "Токен аутентификации RSS-Bridge",
"form.integration.rssbridge_url": "URL сервера RSS-Bridge",
"form.integration.shaarli_activate": "Сохранить статьи в Shaarli",
"form.integration.shaarli_api_secret": "Секретный ключ Shaarli API",
"form.integration.shaarli_endpoint": "Ссылка Shaarli",
"form.integration.shiori_activate": "Сохранять статьи в Shiori",
"form.integration.shiori_endpoint": "Конечная точка Shiori API",
"form.integration.shiori_password": "Пароль Shiori",
"form.integration.shiori_username": "Имя пользователя Shiori",
"form.integration.slack_activate": "Отправить статьи в Slack",
"form.integration.slack_webhook_link": "Ссылка на Slack Webhook",
"form.integration.telegram_bot_activate": "Отправлять статьи в Telegram-чат",
"form.integration.telegram_bot_disable_buttons": "Отключить кнопки",
"form.integration.telegram_bot_disable_notification": "Отключить уведомления",
"form.integration.telegram_bot_disable_web_page_preview": "Отключить предпросмотр веб-страниц",
"form.integration.telegram_bot_token": "Токен бота",
"form.integration.telegram_chat_id": "ID чата",
"form.integration.telegram_topic_id": "ID топика",
"form.integration.wallabag_activate": "Сохранять статьи в Wallabag",
"form.integration.wallabag_client_id": "Номер клиента Wallabag",
"form.integration.wallabag_client_secret": "Секретный код клиента Wallabag",
"form.integration.wallabag_endpoint": "URL-адрес базы Валлабаг",
"form.integration.wallabag_tags": "Теги Wallabag",
"form.integration.wallabag_only_url": "Отправлять только ссылку (без содержимого)",
"form.integration.wallabag_password": "Пароль Wallabag",
"form.integration.wallabag_username": "Имя пользователя Wallabag",
"form.integration.webhook_activate": "Включить вебхуки",
"form.integration.webhook_secret": "Секретный ключ для вебхуков",
"form.integration.webhook_url": "Адрес вебхуков",
"form.prefs.fieldset.application_settings": "Настройки приложения",
"form.prefs.fieldset.authentication_settings": "Настройки аутентификации",
"form.prefs.fieldset.global_feed_settings": "Глобальные настройки подписок",
"form.prefs.fieldset.reader_settings": "Настройки чтения",
"form.prefs.help.external_font_hosts": "Список разрешённых внешних хостов для шрифтов, разделенных пробелами. Например: \"fonts.gstatic.com fonts.googleapis.com\".",
"form.prefs.label.always_open_external_links": "Читать статьи, открывая внешние ссылки",
"form.prefs.label.categories_sorting_order": "Сортировка категорий",
"form.prefs.label.cjk_reading_speed": "Скорость чтения на китайском, корейском и японском языках (знаков в минуту)",
"form.prefs.label.custom_css": "Пользовательский CSS",
"form.prefs.label.custom_js": "Пользовательский JavaScript",
"form.prefs.label.default_home_page": "Домашняя страница по умолчанию",
"form.prefs.label.default_reading_speed": "Скорость чтения на других языках (слов в минуту)",
"form.prefs.label.display_mode": "Режим отображения Progressive Web App (PWA)",
"form.prefs.label.entries_per_page": "Количество статей на страницу",
"form.prefs.label.entry_order": "Столбец сортировки статей",
"form.prefs.label.entry_sorting": "Сортировка статей",
"form.prefs.label.entry_swipe": "Включить пролистывание свайпом на сенсорных экранах",
"form.prefs.label.external_font_hosts": "Внешние хосты шрифтов",
"form.prefs.label.gesture_nav": "Жест для перехода между статьями",
"form.prefs.label.keyboard_shortcuts": "Включить горячие клавиши",
"form.prefs.label.language": "Язык",
"form.prefs.label.mark_read_manually": "Отмечать статьи как прочитанные вручную",
"form.prefs.label.mark_read_on_media_completion": "Отмечать как прочитанное только когда воспроизведение аудио/видео достигает 90%% завершения",
"form.prefs.label.mark_read_on_view": "Автоматически отмечать записи как прочитанные при просмотре",
"form.prefs.label.mark_read_on_view_or_media_completion": "Отмечать статьи как прочитанные при просмотре. Для аудио/видео - при 90%% завершения воспроизведения",
"form.prefs.label.media_playback_rate": "Скорость воспроизведения аудио/видео",
"form.prefs.label.open_external_links_in_new_tab": "Открывать внешние ссылки в новой вкладке (добавляет target=\"_blank\" к ссылкам)",
"form.prefs.label.show_reading_time": "Показать примерное время чтения статей",
"form.prefs.label.theme": "Тема",
"form.prefs.label.timezone": "Часовой пояс",
"form.prefs.select.alphabetical": "В алфавитном порядке",
"form.prefs.select.browser": "Браузер",
"form.prefs.select.created_time": "Время создания статьи",
"form.prefs.select.fullscreen": "Полноэкранный",
"form.prefs.select.minimal_ui": "Минимальный",
"form.prefs.select.none": "Отключить",
"form.prefs.select.older_first": "Сначала старые записи",
"form.prefs.select.publish_time": "Время публикации статьи",
"form.prefs.select.recent_first": "Сначала новые записи",
"form.prefs.select.standalone": "Автономный",
"form.prefs.select.swipe": "Свайп",
"form.prefs.select.tap": "Двойное нажатие",
"form.prefs.select.unread_count": "Количество непрочитанных",
"form.submit.loading": "Загрузка…",
"form.submit.saving": "Сохранение…",
"form.user.label.admin": "Администратор",
"form.user.label.confirmation": "Подтверждение пароля",
"form.user.label.password": "Пароль",
"form.user.label.username": "Имя пользователя",
"menu.about": "О приложении",
"menu.add_feed": "Добавить подписку",
"menu.add_user": "Добавить пользователя",
"menu.api_keys": "API-ключи",
"menu.categories": "Категории",
"menu.create_api_key": "Создать новый API-ключ",
"menu.create_category": "Создать категорию",
"menu.edit_category": "Изменить",
"menu.edit_feed": "Изменить",
"menu.export": "Экспорт",
"menu.feed_entries": "Статьи",
"menu.feeds": "Подписки",
"menu.flush_history": "Очистить историю",
"menu.history": "История",
"menu.home_page": "Главная",
"menu.import": "Импорт",
"menu.integrations": "Интеграции",
"menu.logout": "Выйти",
"menu.mark_all_as_read": "Отметить всё как прочитанное",
"menu.mark_page_as_read": "Отметить эту страницу прочитанной",
"menu.preferences": "Предпочтения",
"menu.refresh_all_feeds": "Обновить все подписки в фоне",
"menu.refresh_feed": "Обновить",
"menu.search": "Поиск",
"menu.sessions": "Сессии",
"menu.settings": "Настройки",
"menu.shared_entries": "Общие записи",
"menu.show_all_entries": "Показать все статьи",
"menu.show_only_starred_entries": "Показывать только избранные статьи",
"menu.show_only_unread_entries": "Показывать только непрочитанные статьи",
"menu.starred": "Избранное",
"menu.title": "Меню",
"menu.unread": "Непрочитанное",
"menu.users": "Пользователи",
"page.about.author": "Автор:",
"page.about.build_date": "Дата сборки:",
"page.about.credits": "Авторы",
"page.about.db_usage": "Размер базы данных:",
"page.about.git_commit": "Git-коммит:",
"page.about.global_config_options": "Глобальные параметры конфигурации",
"page.about.go_version": "Версия Go:",
"page.about.license": "Лицензия:",
"page.about.postgres_version": "Версия PostgreSQL:",
"page.about.title": "О приложении",
"page.about.version": "Версия:",
"page.add_feed.choose_feed": "Выберите подписку",
"page.add_feed.label.url": "Ссылка",
"page.add_feed.legend.advanced_options": "Расширенные настройки",
"page.add_feed.no_category": "Категории отсутствуют. У вас должна быть хотя бы одна категория.",
"page.add_feed.submit": "Найти подписку",
"page.add_feed.title": "Новая подписка",
"page.api_keys.never_used": "Никогда не использовался",
"page.api_keys.table.actions": "Действия",
"page.api_keys.table.created_at": "Дата создания",
"page.api_keys.table.description": "Описание",
"page.api_keys.table.last_used_at": "Последнее использование",
"page.api_keys.table.token": "Токен",
"page.api_keys.title": "API-ключи",
"page.categories.entries": "Статьи",
"page.categories.feed_count": [
"Есть %d подписка.",
"Есть %d подписки.",
"Есть %d подписок."
],
"page.categories.feeds": "Подписки",
"page.categories.no_feed": "Нет подписок.",
"page.categories.title": "Категории",
"page.categories_count": [
"%d категория",
"%d категории",
"%d категорий"
],
"page.category_label": "Категории: %s",
"page.edit_category.title": "Изменить категорию: %s",
"page.edit_feed.etag_header": "Заголовок ETag:",
"page.edit_feed.last_check": "Последняя проверка:",
"page.edit_feed.last_modified_header": "Заголовок LastModified:",
"page.edit_feed.last_parsing_error": "Последняя ошибка парсинга",
"page.edit_feed.no_header": "Отсутствует",
"page.edit_feed.title": "Изменить подписку: %s",
"page.edit_user.title": "Изменить пользователя: %s",
"page.entry.attachments": "Вложения",
"page.feeds.error_count": [
"%d ошибка",
"%d ошибки",
"%d ошибок"
],
"page.feeds.last_check": "Последнее обновление:",
"page.feeds.next_check": "Следующее обновление:",
"page.feeds.read_counter": "Количество прочитанных статей",
"page.feeds.title": "Подписки",
"page.footer.elevator": "Вернуться наверх",
"page.history.title": "История",
"page.import.title": "Импорт",
"page.integration.bookmarklet": "Букмарклет",
"page.integration.bookmarklet.help": "Эта специальная ссылка позволит вам подписаться на сайт, используя обыкновенную закладку в вашем браузере.",
"page.integration.bookmarklet.instructions": "Перетащите эту ссылку в ваши закладки.",
"page.integration.bookmarklet.name": "Добавить в Miniflux",
"page.integration.miniflux_api": "API Miniflux",
"page.integration.miniflux_api_endpoint": "Конечная точка API",
"page.integration.miniflux_api_password": "Пароль",
"page.integration.miniflux_api_password_value": "Пароль вашего аккаунта",
"page.integration.miniflux_api_username": "Имя пользователя",
"page.integrations.title": "Интеграции",
"page.keyboard_shortcuts.close_modal": "Закрыть модальный диалог",
"page.keyboard_shortcuts.download_content": "Загрузить оригинальное содержимое",
"page.keyboard_shortcuts.go_to_bottom_item": "Перейти к нижнему элементу",
"page.keyboard_shortcuts.go_to_categories": "Перейти к Категориям",
"page.keyboard_shortcuts.go_to_feed": "Перейти к подписке",
"page.keyboard_shortcuts.go_to_feeds": "Перейти к Подпискам",
"page.keyboard_shortcuts.go_to_history": "Перейти к Истории",
"page.keyboard_shortcuts.go_to_next_item": "Перейти к следующему элементу",
"page.keyboard_shortcuts.go_to_next_page": "Перейти к следующей странице",
"page.keyboard_shortcuts.go_to_previous_item": "Перейти к предыдущему элементу",
"page.keyboard_shortcuts.go_to_previous_page": "Перейти к предыдущей странице",
"page.keyboard_shortcuts.go_to_search": "Установить фокус в поисковой форме",
"page.keyboard_shortcuts.go_to_settings": "Перейти к Настройкам",
"page.keyboard_shortcuts.go_to_starred": "Перейти к Избранному",
"page.keyboard_shortcuts.go_to_top_item": "Перейти к верхнему элементу",
"page.keyboard_shortcuts.go_to_unread": "Перейти к Непрочитанным",
"page.keyboard_shortcuts.mark_page_as_read": "Отметить текущую страницу прочитанной",
"page.keyboard_shortcuts.open_comments": "Открыть ссылку для комментариев",
"page.keyboard_shortcuts.open_comments_same_window": "Открыть ссылку на комментарии в текущей вкладке",
"page.keyboard_shortcuts.open_item": "Открыть выбранный элемент",
"page.keyboard_shortcuts.open_original": "Открыть оригинальную ссылку",
"page.keyboard_shortcuts.open_original_same_window": "Открыть оригинальную ссылку в текущей вкладке",
"page.keyboard_shortcuts.refresh_all_feeds": "Обновить все подписки в фоне",
"page.keyboard_shortcuts.remove_feed": "Удалить эту подписку",
"page.keyboard_shortcuts.save_article": "Сохранить статью",
"page.keyboard_shortcuts.scroll_item_to_top": "Прокрутите элемент вверх",
"page.keyboard_shortcuts.show_keyboard_shortcuts": "Показать сочетания клавиш",
"page.keyboard_shortcuts.subtitle.actions": "Действия",
"page.keyboard_shortcuts.subtitle.items": "Навигация по элементам",
"page.keyboard_shortcuts.subtitle.pages": "Навигация по страницам",
"page.keyboard_shortcuts.subtitle.sections": "Навигация по секциям",
"page.keyboard_shortcuts.title": "Горячие клавиши",
"page.keyboard_shortcuts.toggle_star_status": "Переключатель избранного",
"page.keyboard_shortcuts.toggle_entry_attachments": "Переключатель показать/скрыть вложения",
"page.keyboard_shortcuts.toggle_read_status_next": "Переключатель прочитанного, сосредоточиться на следующем",
"page.keyboard_shortcuts.toggle_read_status_prev": "Переключатель прочитанного, фокус предыдущий",
"page.login.google_signin": "Войти с помощью Google",
"page.login.oidc_signin": "Войти с помощью %s",
"page.login.title": "Войти",
"page.login.webauthn_login": "Войти с паролем",
"page.login.webauthn_login.error": "Невозможно войти с паролем",
"page.login.webauthn_login.help": "Пожалуйста, введите имя пользователя, если вы используете ключ безопасности. Это не требуется при использовании Passkey (обнаруживаемые учетные данные).",
"page.new_api_key.title": "Новый API-ключ",
"page.new_category.title": "Новая категория",
"page.new_user.title": "Новый пользователь",
"page.offline.message": "Нет соединения",
"page.offline.refresh_page": "Попробуйте обновить страницу",
"page.offline.title": "Автономный режим",
"page.read_entry_count": [
"%d прочитанная статья",
"%d прочитанных статьи",
"%d прочитанных статей"
],
"page.search.title": "Результаты поиска",
"page.sessions.table.actions": "Действия",
"page.sessions.table.current_session": "Текущая сессия",
"page.sessions.table.date": "Время",
"page.sessions.table.ip": "IP адрес",
"page.sessions.table.user_agent": "User-Agent",
"page.sessions.title": "Сессии",
"page.settings.link_google_account": "Привязать мой Google аккаунт",
"page.settings.link_oidc_account": "Привязать мой %s аккаунт",
"page.settings.title": "Настройки",
"page.settings.unlink_google_account": "Отвязать мой Google аккаунт",
"page.settings.unlink_oidc_account": "Отвязать мой %s аккаунт",
"page.settings.webauthn.actions": "Действия",
"page.settings.webauthn.added_on": "Добавлен",
"page.settings.webauthn.delete": [
"Удалить %d пароль",
"Удалить %d пароля",
"Удалить %d пароля"
],
"page.settings.webauthn.last_seen_on": "Последнее использование",
"page.settings.webauthn.passkey_name": "Название ключа доступа",
"page.settings.webauthn.passkeys": "Ключи доступа",
"page.settings.webauthn.register": "Зарегистрировать пароль",
"page.settings.webauthn.register.error": "Не удается зарегистрировать пароль",
"page.shared_entries.title": "Общедоступные статьи",
"page.shared_entries_count": [
"%d общедоступная статья",
"%d общедоступных статьи",
"%d общедоступных статей"
],
"page.starred.title": "Избранное",
"page.starred_entry_count": [
"%d избранная статья",
"%d избранные статьи",
"%d избранных статей"
],
"page.total_entry_count": [
"%d статья всего",
"%d статьи всего",
"%d статей всего"
],
"page.unread.title": "Непрочитанное",
"page.unread_entry_count": [
"%d непрочитанная статья",
"%d непрочитанных статьи",
"%d непрочитанных статей"
],
"page.users.actions": "Действия",
"page.users.admin.no": "Нет",
"page.users.admin.yes": "Да",
"page.users.is_admin": "Администратор",
"page.users.last_login": "Последний вход",
"page.users.never_logged": "Никогда",
"page.users.title": "Пользователи",
"page.users.username": "Имя пользователя",
"page.webauthn_rename.title": "Переименовать ключ доступа",
"pagination.first": "Первая",
"pagination.last": "Последняя",
"pagination.next": "Следующая",
"pagination.previous": "Предыдущая",
"search.label": "Поиск",
"search.placeholder": "Поиск…",
"search.submit": "Искать",
"skip_to_content": "Перейти к содержимому",
"time_elapsed.days": [
"%d день назад",
"%d дня назад",
"%d дней назад"
],
"time_elapsed.hours": [
"%d час назад",
"%d часа назад",
"%d часов назад"
],
"time_elapsed.minutes": [
"%d минуту назад",
"%d минуты назад",
"%d минут назад"
],
"time_elapsed.months": [
"%d месяц назад",
"%d месяца назад",
"%d месяцев назад"
],
"time_elapsed.not_yet": "ещё нет",
"time_elapsed.now": "только что",
"time_elapsed.weeks": [
"%d неделю назад",
"%d недели назад",
"%d недель назад"
],
"time_elapsed.years": [
"%d год назад",
"%d года назад",
"%d лет назад"
],
"time_elapsed.yesterday": "вчера",
"tooltip.keyboard_shortcuts": "Сочетания клавиш: %s",
"tooltip.logged_user": "Авторизован как %s"
}
v2-2.2.16/internal/locale/translations/tr_TR.json 0000664 0000000 0000000 00000115014 15127074645 0021667 0 ustar 00root root 0000000 0000000 {
"action.cancel": "iptal",
"action.download": "İndir",
"action.edit": "Düzenle",
"action.home_screen": "Ana ekrana ekle",
"action.import": "İçeri Aktar",
"action.login": "Giriş",
"action.or": "veya",
"action.remove": "Kaldır",
"action.remove_feed": "Bu beslemeyi kaldır",
"action.save": "Kaydet",
"action.subscribe": "Abone Ol",
"action.update": "Güncelle",
"alert.account_linked": "Harici hesabınız bağlandı!",
"alert.account_unlinked": "Harici hesabınızın bağlantısı kaldırıldı!",
"alert.background_feed_refresh": "Tüm beslemeler arkaplanda yenileniyor. Bu süreç devam ederken Miniflux'ı kullanmaya devam edebilirsiniz.",
"alert.feed_error": "Bu beslemeyle ilgili bir problem var",
"alert.no_starred": "Yıldızlanmış makale yok.",
"alert.no_category": "Hiç kategori yok.",
"alert.no_category_entry": "Bu kategoride hiç makele yok.",
"alert.no_feed": "Hiç beslemeniz yok.",
"alert.no_feed_entry": "Bu besleme için makele yok.",
"alert.no_feed_in_category": "Bu kategori için besleme yok.",
"alert.no_history": "Şu anda hiç geçmiş yok.",
"alert.no_search_result": "Bu arama için sonuç yok",
"alert.no_shared_entry": "Paylaşılan bir makele yok.",
"alert.no_tag_entry": "Bu etiketle eşleşen hiçbir giriş yok.",
"alert.no_unread_entry": "Okunmamış makele yok",
"alert.no_user": "Tek kullanıcı sizsiniz",
"alert.prefs_saved": "Tercihler kaydedildi!",
"alert.too_many_feeds_refresh": [
"Çok fazla besleme yenilemesi başlattınız. Tekrar denemeden önce lütfen %d dakika bekleyin.",
"Çok fazla besleme yenilemesi başlattınız. Tekrar denemeden önce lütfen %d dakika bekleyin."
],
"confirm.loading": "Devam ediyor...",
"confirm.no": "hayır",
"confirm.question": "Emin misiniz?",
"confirm.question.refresh": "Zorla yenilemek istiyor musunuz?",
"confirm.yes": "evet",
"enclosure_media_controls.seek": "Sar:",
"enclosure_media_controls.seek.title": "%s saniye sar",
"enclosure_media_controls.speed": "Hız:",
"enclosure_media_controls.speed.faster": "Daha hızlı",
"enclosure_media_controls.speed.faster.title": "%sx kat daha hızlı",
"enclosure_media_controls.speed.reset": "Sıfırla",
"enclosure_media_controls.speed.reset.title": "Hızı 1x'e sıfırla",
"enclosure_media_controls.speed.slower": "Daha yavaş",
"enclosure_media_controls.speed.slower.title": "%sx kat daha yavaş",
"entry.starred.toast.off": "Yıldızsız",
"entry.starred.toast.on": "Yıldızlı",
"entry.starred.toggle.off": "Yıldızı kaldır",
"entry.starred.toggle.on": "Yıldız ekle",
"entry.comments.label": "Yorumlar",
"entry.comments.title": "Yorumları Göster",
"entry.estimated_reading_time": [
"%d dakika okuma süresi",
"%d dakika okuma süresi"
],
"entry.external_link.label": "Dış bağlantı",
"entry.save.completed": "Tamamlandı!",
"entry.save.label": "Kaydet",
"entry.save.title": "Bu makeleyi kaydet",
"entry.save.toast.completed": "Makele kaydedildi",
"entry.scraper.completed": "Tamamlandı!",
"entry.scraper.label": "İndir",
"entry.scraper.title": "Orijinal içeriği çek",
"entry.share.label": "Paylaş",
"entry.share.title": "Bu makeleyi paylaş",
"entry.shared_entry.label": "Paylaş",
"entry.shared_entry.title": "Herkese açık bağlantıyı aç",
"entry.state.loading": "Yükleniyor...",
"entry.state.saving": "Kaydediliyor...",
"entry.status.mark_as_read": "Okundu olarak işaretle",
"entry.status.mark_as_unread": "Okunmadı olarak işaretle",
"entry.status.title": "Makele okundu durumunu değiştir",
"entry.status.toast.read": "Okundu olarak işaretlendi",
"entry.status.toast.unread": "Okunmamış olarak işaretlendi",
"entry.tags.label": "Etiketler:",
"entry.tags.more_tags_label": [
"%d tane daha etiket göster",
"%d tane daha etiket göster"
],
"entry.unshare.label": "Paylaşma",
"error.api_key_already_exists": "Bu API anahtarı zaten mevcut.",
"error.bad_credentials": "Geçersiz kullanıcı veya parola.",
"error.category_already_exists": "Bu kategori zaten mevcut.",
"error.category_not_found": "Bu kategori mevcut değil ya da bu kullanıcıya ait değil.",
"error.database_error": "Veritabanı hatası: %v.",
"error.different_passwords": "Parolalar eşleşmiyor.",
"error.duplicate_fever_username": "Aynı Fever kullanıcı adına sahip başka biri zaten var!",
"error.duplicate_googlereader_username": "Aynı Google Reader kullanıcı adına sahip başka biri zaten var!",
"error.duplicate_linked_account": "Bu sağlayıcıyla ilişkilendirilmiş biri zaten var!",
"error.duplicated_feed": "Bu makele zaten var.",
"error.empty_file": "Bu dosya boş.",
"error.entries_per_page_invalid": "Sayfa başına makele sayısı geçersiz.",
"error.feed_already_exists": "Bu besleme zaten mevcut.",
"error.feed_category_not_found": "Bu kategori mevcut değil ya da bu kullanıcıya ait değil.",
"error.feed_format_not_detected": "Besleme formatı algılanamadı: %v.",
"error.feed_invalid_blocklist_rule": "Engelleme listesi kuralı geçersiz.",
"error.feed_invalid_keeplist_rule": "Saklama listesi kuralı geçersiz.",
"error.feed_mandatory_fields": "URL ve kategori zorunlu.",
"error.feed_not_found": "Bu makele mevcut değil ya da bu kullanıcıya ait değil.",
"error.feed_title_not_empty": "Besleme başlığı boş olamaz.",
"error.feed_url_not_empty": "Besleme URL'si boş olamaz.",
"error.fields_mandatory": "Tüm alanlar zorunlu.",
"error.http_bad_gateway": "Kötü ağ geçidi hatası nedeniyle bu website şu anda kullanılamıyor. Sorun Miniflux tarafında değil. Lütfen daha sonra tekrar deneyiniz.",
"error.http_body_read": "HTTP gövdesi okunamıyor: %v.",
"error.http_client_error": "HTTP istemci hatası: %v.",
"error.http_empty_response": "HTTP yanıtı boş. Belki bu web sitesi bir bot koruma mekanizması kullanıyordur?",
"error.http_empty_response_body": "HTTP yanıt gövdesi boş.",
"error.http_forbidden": "Bu siteye erişim yasak. Belki bu web sitesinin bir bot koruma mekanizması vardır?",
"error.http_gateway_timeout": "Ağ geçidi zaman aşımı hatası nedeniyle bu websitesi şu anda kullanılamıyor. Sorun Miniflux tarafında değil. Lütfen daha sonra tekrar deneyiniz.",
"error.http_internal_server_error": "Sunucu hatası nedeniyle bu websitesi şu anda kullanılamıyor. Sorun Miniflux tarafında değil. Lütfen daha sonra tekrar deneyiniz.",
"error.http_not_authorized": "Bu web sitesine erişim izni verilmemektedir. Kötü bir kullanıcı adı veya şifreden kaynaklanıyor olabilir.",
"error.http_resource_not_found": "İstenilen kaynak bulunamadı. Lütfen URL'yi doğrulayın.",
"error.http_response_too_large": "HTTP yanıtı çok büyük. Genel ayarlardan HTTP yanıt boyutu sınırını artırabilirsiniz (sunucunun yeniden başlatılmasını gerektirir).",
"error.http_service_unavailable": "Dahili sunucu hatası nedeniyle web sitesi şu anda kullanılamıyor. Sorun Miniflux tarafında değil. Lütfen daha sonra tekrar deneyiniz.",
"error.http_too_many_requests": "Miniflux bu web sitesine çok fazla istek oluşturdu. Lütfen daha sonra tekrar deneyin veya uygulama yapılandırmasını değiştirin.",
"error.http_unexpected_status_code": "Beklenmeyen bir HTTP durum kodu nedeniyle bu websitesi şu anda kullanılamıyor: %d. Sorun Miniflux tarafında değil. Lütfen daha sonra tekrar deneyiniz.",
"error.invalid_categories_sorting_order": "Geçersiz kategori sıralama düzeni.",
"error.invalid_default_home_page": "Geçersiz varsayılan ana sayfa!",
"error.invalid_display_mode": "Geçersiz web uygulaması görüntüleme modu.",
"error.invalid_entry_direction": "Geçersiz makele sıralaması.",
"error.invalid_entry_order": "Geçersiz makele sıralaması.",
"error.invalid_feed_proxy_url": "Geçersiz proxy URL'si.",
"error.invalid_feed_url": "Geçersiz besleme URL'si.",
"error.invalid_gesture_nav": "Hareketle gezinme geçersiz.",
"error.invalid_language": "Geçersiz dil.",
"error.invalid_site_url": "Geçersiz site URL'si.",
"error.invalid_theme": "Geçersiz tema.",
"error.invalid_timezone": "Geçersiz saat dilimi.",
"error.network_operation": "Miniflux bir ağ hatası nedeniyle bu websitesine erişemiyor: %v.",
"error.network_timeout": "Bu websitesi çok yavaş ve istek zaman aşımına uğradı: %v",
"error.password_min_length": "Parola en az 6 karakter içermeli.",
"error.proxy_url_not_empty": "Proxy URL'si boş olamaz.",
"error.settings_block_rule_fieldname_invalid": "Geçersiz Engelleme kuralı: #%d kuralında geçerli bir alan adı eksik (Seçenekler: %s)",
"error.settings_block_rule_invalid_regex": "Geçersiz Engelleme kuralı: #%d kuralı modeli geçerli bir düzenli ifade değil",
"error.settings_block_rule_regex_required": "Geçersiz Engelleme kuralı: #%d kuralı modeli sağlanmadı",
"error.settings_block_rule_separator_required": "Geçersiz Engelleme kuralı: #%d kuralı modelinin '=' ile ayrılması gerekiyor",
"error.settings_invalid_domain_list": "Geçersiz alan adı listesi. Lütfen boşlukla ayrılmış bir alan adı listesi girin.",
"error.settings_keep_rule_fieldname_invalid": "Geçersiz Koruma kuralı: #%d kuralında geçerli bir alan adı eksik (Seçenekler: %s)",
"error.settings_keep_rule_invalid_regex": "Geçersiz Koruma kuralı: #%d kuralı modeli geçerli bir düzenli ifade değil",
"error.settings_keep_rule_regex_required": "Geçersiz Koruma kuralı: #%d kuralı modeli sağlanmadı",
"error.settings_keep_rule_separator_required": "Geçersiz Koruma kuralı: #%d kuralı modelinin '=' ile ayrılması gerekiyor",
"error.settings_mandatory_fields": "Kullanıcı ad, tema, dil ve saat dilimi zorunlu.",
"error.settings_media_playback_rate_range": "Oynatma hızı aralık dışında",
"error.settings_reading_speed_is_positive": "Okuma hızları pozitif tam sayılar olmalıdır.",
"error.site_url_not_empty": "Site URL'si boş olamaz.",
"error.subscription_not_found": "Herhangi bir abonelik bulunamadı.",
"error.title_required": "Başlık zorunlu.",
"error.tls_error": "TLS hatası: %q. İsterseniz feed ayarlarından TLS doğrulamasını devre dışı bırakabilirsiniz.",
"error.unable_to_create_api_key": "Bu API anahtarı oluşturulamıyor.",
"error.unable_to_create_category": "Bu kategori oluşturulamıyor.",
"error.unable_to_create_user": "Bu kullanıcı oluşturulamıyor.",
"error.unable_to_detect_rssbridge": "RSS-Bridge kullanılarak besleme algılanamıyor: %v.",
"error.unable_to_parse_feed": "Bu besleme ayrıştırılamıyor: %v.",
"error.unable_to_update_category": "Bu kategori güncellenemiyor.",
"error.unable_to_update_feed": "Bu besleme güncellenemiyor.",
"error.unable_to_update_user": "Bu kullanıcı güncellenemiyor.",
"error.unlink_account_without_password": "Bir şifre belirlemelisiniz, aksi takdirde tekrar oturum açamazsınız.",
"error.user_already_exists": "Bu kullanıcı zaten mevcut.",
"error.user_mandatory_fields": "Kullanıcı adı zorunlu.",
"error.linktaco_missing_required_fields": "LinkTaco API Token ve Organization Slug gereklidir",
"form.api_key.label.description": "API Anahtar Etiketi",
"form.category.hide_globally": "Genel okunmamış listesindeki girişleri gizle",
"form.category.label.title": "Başlık",
"form.feed.fieldset.general": "Genel",
"form.feed.fieldset.integration": "Üçüncü Taraf Hizmetleri",
"form.feed.fieldset.network_settings": "Ağ Ayarları",
"form.feed.fieldset.rules": "Kurallar",
"form.feed.label.allow_self_signed_certificates": "Kendinden imzalı veya geçersiz sertifikalara izin ver",
"form.feed.label.apprise_service_urls": "Apprise hizmet URL'lerinin virgülle ayrılmış listesi",
"form.feed.label.block_filter_entry_rules": "Giriş Engelleme Kuralları",
"form.feed.label.blocklist_rules": "Regex Tabanlı Engelleme Filtreleri",
"form.feed.label.category": "Kategori",
"form.feed.label.cookie": "Çerezleri Ayarla",
"form.feed.label.crawler": "Orijinal içeriği çek",
"form.feed.label.description": "Açıklama",
"form.feed.label.disable_http2": "Parmak izini önlemek için HTTP/2'yi devre dışı bırakın",
"form.feed.label.disabled": "Bu beslemeyi yenileme",
"form.feed.label.feed_password": "Besleme Parolası",
"form.feed.label.feed_url": "Besleme URL'si",
"form.feed.label.feed_username": "Besleme Kullanıcı Adı",
"form.feed.label.fetch_via_proxy": "Uygulama düzeyinde yapılandırılmış proxy'yi kullan",
"form.feed.label.hide_globally": "Genel okunmamış listesindeki girişleri gizle",
"form.feed.label.ignore_http_cache": "HTTP önbelleğini yoksay",
"form.feed.label.keep_filter_entry_rules": "Giriş İzin Kuralları",
"form.feed.label.keeplist_rules": "Regex Tabanlı Tutma Filtreleri",
"form.feed.label.no_media_player": "Medya oynatıcı yok (ses/video)",
"form.feed.label.ntfy_activate": "Makaleleri ntfy'ye gönder",
"form.feed.label.ntfy_default_priority": "Ntfy varsayılan öncelik",
"form.feed.label.ntfy_high_priority": "Ntfy yüksek öncelik",
"form.feed.label.ntfy_low_priority": "Ntfy düşük öncelik",
"form.feed.label.ntfy_max_priority": "Ntfy maksimum öncelik",
"form.feed.label.ntfy_min_priority": "Ntfy minimum öncelik",
"form.feed.label.ntfy_priority": "Ntfy öncelik",
"form.feed.label.ntfy_topic": "Ntfy konusu (isteğe bağlı)",
"form.feed.label.proxy_url": "Proxy URL",
"form.feed.label.pushover_activate": "Makaleleri pushover.net'e gönder",
"form.feed.label.pushover_default_priority": "Pushover varsayılan öncelik",
"form.feed.label.pushover_high_priority": "Pushover yüksek öncelik",
"form.feed.label.pushover_low_priority": "Pushover düşük öncelik",
"form.feed.label.pushover_max_priority": "Pushover maksimum öncelik",
"form.feed.label.pushover_min_priority": "Pushover minimum öncelik",
"form.feed.label.pushover_priority": "Pushover mesaj önceliği",
"form.feed.label.rewrite_rules": "İçerik Yeniden Yazma Kuralları",
"form.feed.label.scraper_rules": "Scrapper Kuralları",
"form.feed.label.site_url": "Site URL'si",
"form.feed.label.title": "Başlık",
"form.feed.label.urlrewrite_rules": "URL Yeniden Yazma Kuralları",
"form.feed.label.user_agent": "Varsayılan User Agent'i Geçersiz Kıl",
"form.feed.label.webhook_url": "Webhook URL'sini geçersiz kıl",
"form.import.label.file": "OPML dosyası",
"form.import.label.url": "URL",
"form.integration.archiveorg_activate": "Makaleleri archive.org'a gönder",
"form.integration.apprise_activate": "Makaleleri Apprise'a gönder",
"form.integration.apprise_services_url": "Apprise hizmet URL'lerinin virgülle ayrılmış listesi",
"form.integration.apprise_url": "Apprise API URL",
"form.integration.betula_activate": "Makaleleri Betula'ya kaydet",
"form.integration.betula_token": "Betula Token",
"form.integration.betula_url": "Betula sunucu URLsi",
"form.integration.cubox_activate": "Makaleleri Cubox'a kaydet",
"form.integration.cubox_api_link": "Cubox API bağlantısı",
"form.integration.discord_activate": "Makaleleri Discord'a gönder",
"form.integration.discord_webhook_link": "Discord hizmet Webhook'lerinin virgülle ayrılmış listesi",
"form.integration.espial_activate": "Makaleleri Espial'e kaydet",
"form.integration.espial_api_key": "Espial API Anahtarı",
"form.integration.espial_endpoint": "Espial API Uç Noktası",
"form.integration.espial_tags": "Espial Etiketleri",
"form.integration.fever_activate": "Fever API'yi Etkinleştir",
"form.integration.fever_endpoint": "Fever API uç noktası:",
"form.integration.fever_password": "Fever Parolası",
"form.integration.fever_username": "Fever Kullanıcı Adı",
"form.integration.googlereader_activate": "Google Reader API'yi Etkinleştir",
"form.integration.googlereader_endpoint": "Google Reader API uç noktası:",
"form.integration.googlereader_password": "Google Reader Parolası",
"form.integration.googlereader_username": "Google Reader Kullanıcı Adı",
"form.integration.instapaper_activate": "Makaleleri Instapaper'a kaydet",
"form.integration.instapaper_password": "Instapaper Parolası",
"form.integration.instapaper_username": "Instapaper Kullanıcı Adı",
"form.integration.karakeep_activate": "Makaleleri Karakeep'a kaydet",
"form.integration.karakeep_api_key": "Karakeep API anahtarı",
"form.integration.karakeep_url": "Karakeep API Uç Noktası",
"form.integration.karakeep_tags": "Karakeep Tags",
"form.integration.linkace_activate": "Makaleleri LinkAce'e kaydet",
"form.integration.linkace_api_key": "LinkAce API anahtarı",
"form.integration.linkace_check_disabled": "Link kontrolünü devre dışı bırak",
"form.integration.linkace_endpoint": "LinkAce API Uç Noktası",
"form.integration.linkace_is_private": "Linki özel olarak işaretle",
"form.integration.linkace_tags": "LinkAce Etiketleri",
"form.integration.linkding_activate": "Makaleleri Linkding'e kaydet",
"form.integration.linkding_api_key": "Linkding API Anahtarı",
"form.integration.linkding_bookmark": "Yer imini okunmadı olarak işaretle",
"form.integration.linkding_endpoint": "Linkding API Uç Noktası",
"form.integration.linkding_tags": "Linkding Etiketleri",
"form.integration.linktaco_activate": "Makaleleri LinkTaco'ya kaydet",
"form.integration.linktaco_api_token": "LinkTaco API Token",
"form.integration.linktaco_api_token_hint": "Kişisel erişim tokenınızı edinin",
"form.integration.linktaco_org_slug": "Organizasyon kısaltması",
"form.integration.linktaco_tags": "Etiketler (maks 10, virgülle ayrılmış)",
"form.integration.linktaco_tags_hint": "Maksimum 10 etiket, virgülle ayrılmış",
"form.integration.linktaco_visibility": "Görünürlük",
"form.integration.linktaco_visibility_public": "Genel",
"form.integration.linktaco_visibility_private": "Özel",
"form.integration.linktaco_visibility_hint": "ÖZEL görünürlük ücretli bir LinkTaco hesabı gerektirir",
"form.integration.linkwarden_activate": "Makaleleri Linkwarden'e kaydet",
"form.integration.linkwarden_api_key": "Linkwarden API Anahtarı",
"form.integration.linkwarden_endpoint": "Linkwarden Temel URL'si",
"form.integration.linkwarden_collection_id": "Linkwarden Collection ID",
"form.integration.matrix_bot_activate": "Yeni makaleleri Matrix'e aktarın",
"form.integration.matrix_bot_chat_id": "Matrix odasının kimliği",
"form.integration.matrix_bot_password": "Matrix kullanıcısı için parola",
"form.integration.matrix_bot_url": "Matrix sunucu URL'si",
"form.integration.matrix_bot_user": "Matrix için Kullanıcı Adı",
"form.integration.notion_activate": "Makaleleri Notion'a kaydet",
"form.integration.notion_page_id": "Notion Sayfa ID'si",
"form.integration.notion_token": "Notion Secret Token",
"form.integration.ntfy_activate": "Makaleleri ntfy'ye gönder",
"form.integration.ntfy_api_token": "Ntfy API Token (isteğe bağlı)",
"form.integration.ntfy_icon_url": "Ntfy ikon URL'si (isteğe bağlı)",
"form.integration.ntfy_internal_links": "Tıklamada dahili bağlantıları kullan (isteğe bağlı)",
"form.integration.ntfy_password": "Ntfy parolası (isteğe bağlı)",
"form.integration.ntfy_topic": "Ntfy konusu (beslemede yoksa varsayılan kullanılır)",
"form.integration.ntfy_url": "Ntfy URL'si (isteğe bağlı, varsayılan ntfy.sh)",
"form.integration.ntfy_username": "Ntfy kullanıcı adı (isteğe bağlı)",
"form.integration.nunux_keeper_activate": "Makaleleri Nunux Keeper'a kaydet",
"form.integration.nunux_keeper_api_key": "Nunux Keeper API anahtarı",
"form.integration.nunux_keeper_endpoint": "Nunux Keeper API Uç Noktası",
"form.integration.omnivore_activate": "Makaleleri Omnivore'a kaydet",
"form.integration.omnivore_api_key": "Omnivore API anahtarı",
"form.integration.omnivore_url": "Omnivore API Uç Noktası",
"form.integration.pinboard_activate": "Makaleleri Pinboard'a kaydet",
"form.integration.pinboard_bookmark": "Yer imini okunmadı olarak işaretle",
"form.integration.pinboard_tags": "Pinboard Etiketleri",
"form.integration.pinboard_token": "Pinboard API Token",
"form.integration.pushover_activate": "Makaleleri Pushover'a gönder",
"form.integration.pushover_device": "Pushover cihazı (isteğe bağlı)",
"form.integration.pushover_prefix": "Pushover URL öneki (isteğe bağlı)",
"form.integration.pushover_token": "Pushover uygulama API anahtarı",
"form.integration.pushover_user": "Pushover kullanıcı anahtarı",
"form.integration.raindrop_activate": "Makaleleri Raindrop'a kaydet",
"form.integration.raindrop_collection_id": "Koleksiyon ID",
"form.integration.raindrop_tags": "Etiketler (virgülle ayrılmış)",
"form.integration.raindrop_token": "(Test) Token",
"form.integration.readeck_activate": "Makaleleri Readeck'e kaydet",
"form.integration.readeck_api_key": "Readeck API Anahtarı",
"form.integration.readeck_endpoint": "Readeck API Uç Noktası",
"form.integration.readeck_labels": "Readeck Etiketleri",
"form.integration.readeck_only_url": "Yalnızca URL gönder (tam makale yerine)",
"form.integration.readeck_push_activate": "Yeni makaleleri otomatik olarak Readeck'e gönder",
"form.integration.readwise_activate": "Makaleleri Readwise Reader'a kaydet",
"form.integration.readwise_api_key": "Readwise Reader Access Token",
"form.integration.readwise_api_key_link": "Readwise Access Token'ınızı alın",
"form.integration.rssbridge_activate": "Abonelik eklerken RSS-Bridge'i kontrol edin",
"form.integration.rssbridge_token": "RSS-Bridge kimlik doğrulama jetonu",
"form.integration.rssbridge_url": "RSS-Bridge server URL",
"form.integration.shaarli_activate": "Makaleleri Shaarli'ye kaydet",
"form.integration.shaarli_api_secret": "Shaarli API Secret",
"form.integration.shaarli_endpoint": "Shaarli URL",
"form.integration.shiori_activate": "Makaleleri Shiori'ye kaydet",
"form.integration.shiori_endpoint": "Shiori API Uç Noktası",
"form.integration.shiori_password": "Shiori Parolası",
"form.integration.shiori_username": "Shiori Kullanıcı Adı",
"form.integration.slack_activate": "Makaleleri Slack'a gönder",
"form.integration.slack_webhook_link": "Slack hizmet Webhook'lerinin virgülle ayrılmış listesi",
"form.integration.telegram_bot_activate": "Yeni makaleleri Telegram sohbetine gönderin",
"form.integration.telegram_bot_disable_buttons": "Butonları devre dışı bırak",
"form.integration.telegram_bot_disable_notification": "Bildirimleri devre dışı bırak",
"form.integration.telegram_bot_disable_web_page_preview": "Web sayfası önizlemesini devre dışı bırak",
"form.integration.telegram_bot_token": "Bot token",
"form.integration.telegram_chat_id": "Sohbet ID",
"form.integration.telegram_topic_id": "Konu ID",
"form.integration.wallabag_activate": "Makaleleri Wallabag'e kaydet",
"form.integration.wallabag_client_id": "Wallabag Client ID",
"form.integration.wallabag_client_secret": "Wallabag Client Secret",
"form.integration.wallabag_endpoint": "Wallabag Üssü URL",
"form.integration.wallabag_only_url": "Yalnızca URL gönder (tam makale yerine)",
"form.integration.wallabag_password": "Wallabag Parolası",
"form.integration.wallabag_username": "Wallabag Kullanıcı Adı",
"form.integration.wallabag_tags": "Wallabag etiketleri",
"form.integration.webhook_activate": "Webhook'u etkinleştir",
"form.integration.webhook_secret": "Webhooks Secret",
"form.integration.webhook_url": "Default Webhook URL",
"form.prefs.fieldset.application_settings": "Uygulama Ayarları",
"form.prefs.fieldset.authentication_settings": "Kimlik Doğrulama Ayarları",
"form.prefs.fieldset.global_feed_settings": "Genel Besleme Ayarları",
"form.prefs.fieldset.reader_settings": "Okuyucu Ayarları",
"form.prefs.help.external_font_hosts": "İzin verilecek harici font sunucularının boşlukla ayrılmış listesi. Örneğin: 'fonts.gstatic.com fonts.googleapis.com'.",
"form.prefs.label.always_open_external_links": "Makaleleri harici bağlantıları açarak oku",
"form.prefs.label.categories_sorting_order": "Kategori sıralaması",
"form.prefs.label.cjk_reading_speed": "Çince, Korece ve Japonca için okuma hızı (dakika başına karakter)",
"form.prefs.label.custom_css": "Özel CSS",
"form.prefs.label.custom_js": "Özel JavaScript",
"form.prefs.label.default_home_page": "Varsayılan ana sayfa",
"form.prefs.label.default_reading_speed": "Diğer diller için okuma hızı (dakika başına kelime)",
"form.prefs.label.display_mode": "Progressive Web App (PWA) görüntüleme modu",
"form.prefs.label.entries_per_page": "Sayfa başına makale",
"form.prefs.label.entry_order": "Makale Sıralama Sütunu",
"form.prefs.label.entry_sorting": "Makale Sıralaması",
"form.prefs.label.entry_swipe": "Dokunmatik ekranlarda makale kaydırmayı etkinleştir",
"form.prefs.label.external_font_hosts": "Harici font sunucuları",
"form.prefs.label.gesture_nav": "Makaleler arasında gezinmek için dokunma hareketi",
"form.prefs.label.keyboard_shortcuts": "Klavye kısayollarını etkinleştir",
"form.prefs.label.language": "Dil",
"form.prefs.label.mark_read_manually": "Mark entries as read manually",
"form.prefs.label.mark_read_on_media_completion": "Only mark as read when audio/video playback reaches 90%% completion",
"form.prefs.label.mark_read_on_view": "Makaleler görüntülendiğinde otomatik olarak okundu olarak işaretle",
"form.prefs.label.mark_read_on_view_or_media_completion": "Mark entries as read when viewed. For audio/video, mark as read at 90%% completion",
"form.prefs.label.media_playback_rate": "Ses/video oynatma hızı",
"form.prefs.label.open_external_links_in_new_tab": "Harici bağlantıları yeni bir sekmede aç (bağlantılara target=\"_blank\" ekler)",
"form.prefs.label.show_reading_time": "Makaleler için tahmini okuma süresini göster",
"form.prefs.label.theme": "Tema",
"form.prefs.label.timezone": "Saat Dilimi",
"form.prefs.select.alphabetical": "Alfabetik",
"form.prefs.select.browser": "Tarayıcı",
"form.prefs.select.created_time": "İçeriğin oluşturulma zamanı",
"form.prefs.select.fullscreen": "Tam Ekran",
"form.prefs.select.minimal_ui": "Minimal",
"form.prefs.select.none": "Hiçbiri",
"form.prefs.select.older_first": "Önce eski makaleler",
"form.prefs.select.publish_time": "Makale yayınlanma zamanı",
"form.prefs.select.recent_first": "Önce yeni makaleler",
"form.prefs.select.standalone": "Bağımsız",
"form.prefs.select.swipe": "Kaydırma",
"form.prefs.select.tap": "Çift dokunma",
"form.prefs.select.unread_count": "Okunmamış sayısı",
"form.submit.loading": "Yükleniyor...",
"form.submit.saving": "Kaydediliyor...",
"form.user.label.admin": "Yönetici",
"form.user.label.confirmation": "Parola Doğrulama",
"form.user.label.password": "Parola",
"form.user.label.username": "Kullanıcı Adı",
"menu.about": "Hakkında",
"menu.add_feed": "Besleme ekle",
"menu.add_user": "Kullanıcı ekle",
"menu.api_keys": "API Anahtarları",
"menu.categories": "Kategoriler",
"menu.create_api_key": "Yeni bir API anahtarı oluştur",
"menu.create_category": "Kategori oluştur",
"menu.edit_category": "Düzenle",
"menu.edit_feed": "Düzenle",
"menu.export": "Dışarı Aktar",
"menu.feed_entries": "Makaleler",
"menu.feeds": "Beslemeler",
"menu.flush_history": "Geçmişi temizle",
"menu.history": "Geçmiş",
"menu.home_page": "Anasayfa",
"menu.import": "İçeri Aktar",
"menu.integrations": "Entegrasyonlar",
"menu.logout": "Çıkış",
"menu.mark_all_as_read": "Tümünü okundu olarak işaretle",
"menu.mark_page_as_read": "Bu sayfayı okundu olarak işaretle",
"menu.preferences": "Tercihler",
"menu.refresh_all_feeds": "Tüm beslemeleri arka planda yenile",
"menu.refresh_feed": "Yenile",
"menu.search": "Ara",
"menu.sessions": "Oturumlar",
"menu.settings": "Ayarlar",
"menu.shared_entries": "Paylaşılan makaleler",
"menu.show_all_entries": "Tüm makaleleri göster",
"menu.show_only_starred_entries": "Sadece yıldızlanmış makaleleri göster",
"menu.show_only_unread_entries": "Sadece okunmamış makaleleri göster",
"menu.starred": "Yıldız",
"menu.title": "Menü",
"menu.unread": "Okunmadı",
"menu.users": "Kullanıcılar",
"page.about.author": "Yazar:",
"page.about.build_date": "Oluşturulma Tarihi:",
"page.about.credits": "Katkıda Bulunanlar",
"page.about.db_usage": "Veritabanı boyutu:",
"page.about.git_commit": "Git Commit:",
"page.about.global_config_options": "Global yapılandırma seçenekleri",
"page.about.go_version": "Go sürümü:",
"page.about.license": "Lisans:",
"page.about.postgres_version": "Postgres sürümü:",
"page.about.title": "Hakkında",
"page.about.version": "Sürüm:",
"page.add_feed.choose_feed": "Bir Besleme Seçin",
"page.add_feed.label.url": "URL",
"page.add_feed.legend.advanced_options": "Gelişmiş Seçenekler",
"page.add_feed.no_category": "Kategori yok. En az bir kategoriye sahip olmalısınız.",
"page.add_feed.submit": "Besleme bul",
"page.add_feed.title": "Yeni Besleme",
"page.api_keys.never_used": "Hiç Kullanılmadı",
"page.api_keys.table.actions": "Hareketler",
"page.api_keys.table.created_at": "Oluşturulma Tarihi",
"page.api_keys.table.description": "Açıklama",
"page.api_keys.table.last_used_at": "Son Kullanılma",
"page.api_keys.table.token": "Token",
"page.api_keys.title": "API Anahtarları",
"page.categories.entries": "Makaleler",
"page.categories.feed_count": [
"%d besleme var.",
"%d besleme var."
],
"page.categories.feeds": "Beslemeler",
"page.categories.no_feed": "Besleme yok.",
"page.categories.title": "Kategoriler",
"page.categories_count": [
"%d kategori",
"%d kategori"
],
"page.category_label": "Kategori: %s",
"page.edit_category.title": "Kategoriyi Düzenle: %s",
"page.edit_feed.etag_header": "ETag başlığı:",
"page.edit_feed.last_check": "Son kontrol:",
"page.edit_feed.last_modified_header": "LastModified başlığı:",
"page.edit_feed.last_parsing_error": "Son Ayrıştırma Hatası",
"page.edit_feed.no_header": "Hiçbiri",
"page.edit_feed.title": "Beslemeyi düzenle: %s",
"page.edit_user.title": "Kullanıcıyı Düzenle: %s",
"page.entry.attachments": "Ekler",
"page.feeds.error_count": [
"%d hatası",
"%d hatası"
],
"page.feeds.last_check": "Son kontrol:",
"page.feeds.next_check": "Sonraki kontrol:",
"page.feeds.read_counter": "Okunmuş makalelerin sayısı",
"page.feeds.title": "Beslemeler",
"page.footer.elevator": "Başa dön",
"page.history.title": "Geçmiş",
"page.import.title": "İçeri Aktar",
"page.integration.bookmarklet": "Bookmarklet",
"page.integration.bookmarklet.help": "Bu özel bağlantı, web tarayıcınızdaki yer imini kullanarak bir websitesine doğrudan abone olmanızı sağlar.",
"page.integration.bookmarklet.instructions": "Bu bağlantıyı yer imlerinize sürükleyip bırakın",
"page.integration.bookmarklet.name": "Miniflux'a Ekle",
"page.integration.miniflux_api": "Miniflux API",
"page.integration.miniflux_api_endpoint": "API Uç Noktası",
"page.integration.miniflux_api_password": "Parola",
"page.integration.miniflux_api_password_value": "Hesap parolan",
"page.integration.miniflux_api_username": "Kullanıcı adı",
"page.integrations.title": "Entegrasyonlar",
"page.keyboard_shortcuts.close_modal": "İletişim kutusunu kapat",
"page.keyboard_shortcuts.download_content": "Orijinal içeriği indir",
"page.keyboard_shortcuts.go_to_bottom_item": "Alt makeleye git",
"page.keyboard_shortcuts.go_to_categories": "Kategorilere git",
"page.keyboard_shortcuts.go_to_feed": "Beslemeye git",
"page.keyboard_shortcuts.go_to_feeds": "Beslemelere git",
"page.keyboard_shortcuts.go_to_history": "Geçmişe git",
"page.keyboard_shortcuts.go_to_next_item": "Sonraki makeleye git",
"page.keyboard_shortcuts.go_to_next_page": "Sonraki sayfaya git",
"page.keyboard_shortcuts.go_to_previous_item": "Önceki makeleye git",
"page.keyboard_shortcuts.go_to_previous_page": "Önceki sayfaya git",
"page.keyboard_shortcuts.go_to_search": "Arama formuna odakla",
"page.keyboard_shortcuts.go_to_settings": "Ayarlara git",
"page.keyboard_shortcuts.go_to_starred": "Yer imlerine git",
"page.keyboard_shortcuts.go_to_top_item": "En üstteki makeleye git",
"page.keyboard_shortcuts.go_to_unread": "Okunmamışa git",
"page.keyboard_shortcuts.mark_page_as_read": "Mevcut sayfayı okundu olarak işaretle",
"page.keyboard_shortcuts.open_comments": "Yorumlar bağlantısını aç",
"page.keyboard_shortcuts.open_comments_same_window": "Yorumlar bağlantısını mevcut sekmede aç",
"page.keyboard_shortcuts.open_item": "Seçili makeleyi aç",
"page.keyboard_shortcuts.open_original": "Orijinal bağlantıyı aç",
"page.keyboard_shortcuts.open_original_same_window": "Orijinal bağlantıyı mevcut sekmede aç",
"page.keyboard_shortcuts.refresh_all_feeds": "Tüm beslemeleri arka planda yenile",
"page.keyboard_shortcuts.remove_feed": "Bu beslemeyi kaldır",
"page.keyboard_shortcuts.save_article": "İçeriği kaydet",
"page.keyboard_shortcuts.scroll_item_to_top": "Makaleyi en üste kaydır",
"page.keyboard_shortcuts.show_keyboard_shortcuts": "Klavye kısayollarını göster",
"page.keyboard_shortcuts.subtitle.actions": "Eylemler",
"page.keyboard_shortcuts.subtitle.items": "Makalelerde Gezinme",
"page.keyboard_shortcuts.subtitle.pages": "Sayfalarda Gezinme",
"page.keyboard_shortcuts.subtitle.sections": "Bölümlerde Gezinme",
"page.keyboard_shortcuts.title": "Klavye Kısayolları",
"page.keyboard_shortcuts.toggle_star_status": "Yıldız ekle/kaldır",
"page.keyboard_shortcuts.toggle_entry_attachments": "Makele eklerini açma/kapama arasında geçiş yap",
"page.keyboard_shortcuts.toggle_read_status_next": "Okundu/okunmadı arasında geçiş yap, sonrakine odaklan",
"page.keyboard_shortcuts.toggle_read_status_prev": "Okundu/okunmadı arasında geçiş yap, öncekine odaklan",
"page.login.google_signin": "Google ile oturum aç",
"page.login.oidc_signin": "%s ile oturum aç",
"page.login.title": "Oturum aç",
"page.login.webauthn_login": "Passkey ile giriş yap",
"page.login.webauthn_login.error": "Passkey ile giriş yapılamıyor",
"page.login.webauthn_login.help": "Please enter your username if you're using a security key. This is not required if you are using a Passkey (discoverable credentials).",
"page.new_api_key.title": "Yeni API Anahtarı",
"page.new_category.title": "Yeni Kategori",
"page.new_user.title": "Yeni Kullanıcı",
"page.offline.message": "Çevrimdışısınız",
"page.offline.refresh_page": "Sayfayı yenilemeyi dene",
"page.offline.title": "Çevrimdışı Modu",
"page.read_entry_count": [
"%d okunmuş makale",
"%d okunmuş makale"
],
"page.search.title": "Arama Sonuçları",
"page.sessions.table.actions": "Eylemler",
"page.sessions.table.current_session": "Mevcut Oturum",
"page.sessions.table.date": "Tarih",
"page.sessions.table.ip": "IP Adresi",
"page.sessions.table.user_agent": "User Agent",
"page.sessions.title": "Oturumlar",
"page.settings.link_google_account": "Google hesabımı bağla",
"page.settings.link_oidc_account": "%s hesabımı bağla",
"page.settings.title": "Ayarlar",
"page.settings.unlink_google_account": "Google hesabımın bağlantısını kaldır",
"page.settings.unlink_oidc_account": "%s hesabımın bağlantısını kaldır",
"page.settings.webauthn.actions": "Eylemler",
"page.settings.webauthn.added_on": "Eklendi",
"page.settings.webauthn.delete": [
"%d passkey'i kaldır",
"%d passkey'i kaldır"
],
"page.settings.webauthn.last_seen_on": "Son Kullanım",
"page.settings.webauthn.passkey_name": "Passkey Adı",
"page.settings.webauthn.passkeys": "Passkeyler",
"page.settings.webauthn.register": "Passkey'i kaydet",
"page.settings.webauthn.register.error": "Passkey kaydedilemiyor",
"page.shared_entries.title": "Paylaşılan makaleler",
"page.shared_entries_count": [
"%d paylaşılan makaleler",
"%d paylaşılan makaleler"
],
"page.starred.title": "Yıldızlı",
"page.starred_entry_count": [
"%d yıldızlanmış makale",
"%d yıldızlanmış makale"
],
"page.total_entry_count": [
"Toplamda %d makale",
"Toplamda %d makale"
],
"page.unread.title": "Okunmadı",
"page.unread_entry_count": [
"Toplamda %d okunmamış makale",
"Toplamda %d okunmamış makale"
],
"page.users.actions": "Eylemler",
"page.users.admin.no": "Hayır",
"page.users.admin.yes": "Evet",
"page.users.is_admin": "Yönetici",
"page.users.last_login": "Son Giriş",
"page.users.never_logged": "Asla",
"page.users.title": "Kullanıcılar",
"page.users.username": "Kullanıcı adı",
"page.webauthn_rename.title": "Passkey'i Yeniden Adlandır",
"pagination.first": "İlk",
"pagination.last": "Son",
"pagination.next": "Sonraki",
"pagination.previous": "Önceki",
"search.label": "Ara",
"search.placeholder": "Ara...",
"search.submit": "Ara",
"skip_to_content": "İçeriğe atla",
"time_elapsed.days": [
"%d gün önce",
"%d gün önce"
],
"time_elapsed.hours": [
"%d saat önce",
"%d saat önce"
],
"time_elapsed.minutes": [
"%d dakika önce",
"%d dakika önce"
],
"time_elapsed.months": [
"%d ay önce",
"%d ay önce"
],
"time_elapsed.not_yet": "henüz değil",
"time_elapsed.now": "şimdi",
"time_elapsed.weeks": [
"%d hafta önce",
"%d hafta önce"
],
"time_elapsed.years": [
"%d yıl önce",
"%d yıl önce"
],
"time_elapsed.yesterday": "dün",
"tooltip.keyboard_shortcuts": "Klavye Kısayolu: %s",
"tooltip.logged_user": "%s olarak giriş yapıldı"
}
v2-2.2.16/internal/locale/translations/uk_UA.json 0000664 0000000 0000000 00000143360 15127074645 0021646 0 ustar 00root root 0000000 0000000 {
"action.cancel": "скасувати",
"action.download": "Завантажити",
"action.edit": "Редагувати",
"action.home_screen": "Додати до головного екрану",
"action.import": "Імпортувати",
"action.login": "Увійти",
"action.or": "або",
"action.remove": "Видалити",
"action.remove_feed": "Видалити стрічку",
"action.save": "Зберегти",
"action.subscribe": "Підписатись",
"action.update": "Зберегти",
"alert.account_linked": "Тепер ваш зовнішній обліковий запис від’єднано!",
"alert.account_unlinked": "Тепер ваш зовнішній обліковий запис підключено!",
"alert.background_feed_refresh": "Всі стрічки оновлюються у фоновому режимі. Ви можете продовжувати користуватися Miniflux, поки триває цей процес.",
"alert.feed_error": "З цією стрічкою трапилась помилка",
"alert.no_starred": "Наразі закладки відсутні.",
"alert.no_category": "Немає категорії.",
"alert.no_category_entry": "У цій категорії немає записів.",
"alert.no_feed": "У вас немає підписок.",
"alert.no_feed_entry": "У цій стрічці немає записів.",
"alert.no_feed_in_category": "У цій категорії немає підписок.",
"alert.no_history": "Наразі історія порожня.",
"alert.no_search_result": "Немає результатів для цього пошуку.",
"alert.no_shared_entry": "Немає спільного запису.",
"alert.no_tag_entry": "Немає записів, що відповідають цьому тегу.",
"alert.no_unread_entry": "Немає непрочитаних статей.",
"alert.no_user": "Ви єдиний користувач.",
"alert.prefs_saved": "Уподобання збережено!",
"alert.too_many_feeds_refresh": [
"Ви запустили надто багато оновлень стрічок. Будь ласка, зачекайте %d хвилину перед повторною спробою.",
"Ви запустили надто багато оновлень стрічок. Будь ласка, зачекайте %d хвилини перед повторною спробою.",
"Ви запустили надто багато оновлень стрічок. Будь ласка, зачекайте %d хвилин перед повторною спробою."
],
"confirm.loading": "В процесі...",
"confirm.no": "ні",
"confirm.question": "Ви впевнені?",
"confirm.question.refresh": "Ви хочете змусити оновити?",
"confirm.yes": "так",
"enclosure_media_controls.seek": "Пошук:",
"enclosure_media_controls.seek.title": "Пошук %s секунд",
"enclosure_media_controls.speed": "Швидкість:",
"enclosure_media_controls.speed.faster": "Швидше",
"enclosure_media_controls.speed.faster.title": "Швидше на %sx",
"enclosure_media_controls.speed.reset": "Скинути",
"enclosure_media_controls.speed.reset.title": "Скинути швидкість до 1x",
"enclosure_media_controls.speed.slower": "Повільніше",
"enclosure_media_controls.speed.slower.title": "Повільніше на %sx",
"entry.starred.toast.off": "Без зірочки",
"entry.starred.toast.on": "З зірочкою",
"entry.starred.toggle.off": "Прибрати зірочку",
"entry.starred.toggle.on": "Поставити зірочку",
"entry.comments.label": "Коментарі",
"entry.comments.title": "Дивитися коментарі",
"entry.estimated_reading_time": [
"читати %d хвилину",
"читати %d хвилини",
"читати %d хвилин"
],
"entry.external_link.label": "Зовнішнє посилання",
"entry.save.completed": "Готово!",
"entry.save.label": "Зберегти",
"entry.save.title": "Зберегти цю статтю",
"entry.save.toast.completed": "Стаття збережена",
"entry.scraper.completed": "Готово!",
"entry.scraper.label": "Завантажити",
"entry.scraper.title": "Отримати оригінальний зміст",
"entry.share.label": "Поділитись",
"entry.share.title": "Поділитись статтєю",
"entry.shared_entry.label": "Поділитись",
"entry.shared_entry.title": "Відкрити публічне посилання",
"entry.state.loading": "Завантаження...",
"entry.state.saving": "Зберігаю...",
"entry.status.mark_as_read": "Позначити як прочитане",
"entry.status.mark_as_unread": "Позначити як непрочитане",
"entry.status.title": "Змінити стан запису",
"entry.status.toast.read": "Відмічено прочитаним",
"entry.status.toast.unread": "Відмічено непрочитаним",
"entry.tags.label": "Теги:",
"entry.tags.more_tags_label": [
"Ще %d тег",
"Ще %d теги",
"Ще %d тегів"
],
"entry.unshare.label": "Не ділитися",
"error.api_key_already_exists": "Такий ключ API вже існує.",
"error.bad_credentials": "Невірне ім’я користувача або пароль.",
"error.category_already_exists": "Така категорія вже існує.",
"error.category_not_found": "Ця категорія не існує або не належить цьому користувачу.",
"error.database_error": "Помилка бази даних: %v.",
"error.different_passwords": "Паролі не співпадають.",
"error.duplicate_fever_username": "Вже є обліковий запис з таким самим користувачем Fever!",
"error.duplicate_googlereader_username": "Вже є обліковий запис з таким самим користувачем Google Reader!",
"error.duplicate_linked_account": "Вже є обліковий запис, під’єднаний до цього провайдера!",
"error.duplicated_feed": "Ця стрічка вже існує.",
"error.empty_file": "Цей файл порожній.",
"error.entries_per_page_invalid": "Число записів на сторінку недійсне.",
"error.feed_already_exists": "Така стрічка вже існує.",
"error.feed_category_not_found": "Категорія не існує або належить до іншого користувача.",
"error.feed_format_not_detected": "Не вдалося визначити формат стрічки: %v.",
"error.feed_invalid_blocklist_rule": "Правило списку блокувань недійсне.",
"error.feed_invalid_keeplist_rule": "Правило списку дозволень недійсне.",
"error.feed_mandatory_fields": "URL та категорія є обов’язковими.",
"error.feed_not_found": "Ця стрічка не існує або не належить цьому користувачу.",
"error.feed_title_not_empty": "Назва стрічки не може бути порожньою.",
"error.feed_url_not_empty": "URL-адреса стрічки не може бути порожньою.",
"error.fields_mandatory": "Всі поля є обов’язковими.",
"error.http_bad_gateway": "Сайт наразі недоступний через помилку шлюзу. Проблема не на стороні Miniflux. Будь ласка, спробуйте пізніше.",
"error.http_body_read": "Не вдалося прочитати HTTP-вміст: %v.",
"error.http_client_error": "Помилка HTTP-клієнта: %v.",
"error.http_empty_response": "Відповідь HTTP порожня. Можливо, цей сайт використовує захист від ботів?",
"error.http_empty_response_body": "Тіло відповіді HTTP порожнє.",
"error.http_forbidden": "Доступ до цього сайту заборонено. Можливо, сайт має захист від ботів?",
"error.http_gateway_timeout": "Сайт наразі недоступний через помилку тайм-ауту шлюзу. Проблема не на стороні Miniflux. Будь ласка, спробуйте пізніше.",
"error.http_internal_server_error": "Сайт наразі недоступний через внутрішню помилку сервера. Проблема не на стороні Miniflux. Будь ласка, спробуйте пізніше.",
"error.http_not_authorized": "Доступ до цього сайту не дозволено. Можливо, неправильне ім’я користувача або пароль.",
"error.http_resource_not_found": "Запитаний ресурс не знайдено. Будь ласка, перевірте URL.",
"error.http_response_too_large": "Відповідь HTTP занадто велика. Ви можете збільшити ліміт розміру HTTP-відповіді у глобальних налаштуваннях (потрібен перезапуск сервера).",
"error.http_service_unavailable": "Сайт наразі недоступний через внутрішню помилку сервера. Проблема не на стороні Miniflux. Будь ласка, спробуйте пізніше.",
"error.http_too_many_requests": "Miniflux згенерував надто багато запитів до цього сайту. Будь ласка, спробуйте пізніше або змініть налаштування програми.",
"error.http_unexpected_status_code": "Сайт наразі недоступний через неочікуваний HTTP-код: %d. Проблема не на стороні Miniflux. Будь ласка, спробуйте пізніше.",
"error.invalid_categories_sorting_order": "Недійсний порядок сортування категорій.",
"error.invalid_default_home_page": "Недійсна домашня сторінка за замовчуванням!",
"error.invalid_display_mode": "Недійсний режим відображення.",
"error.invalid_entry_direction": "Недійсний напрямок запису.",
"error.invalid_entry_order": "Недійсний порядок запису.",
"error.invalid_feed_proxy_url": "Недійсний proxy URL.",
"error.invalid_feed_url": "Недійсна URL-адреса стрічки.",
"error.invalid_gesture_nav": "Недійсна навігація жестами.",
"error.invalid_language": "Недійсна мова.",
"error.invalid_site_url": "Недійсна URL-адреса сайту.",
"error.invalid_theme": "Недійсна тема.",
"error.invalid_timezone": "Недійсний часовий пояс.",
"error.network_operation": "Miniflux не може отримати доступ до цього сайту через помилку мережі: %v.",
"error.network_timeout": "Цей сайт занадто повільний і запит перевищив час очікування: %v",
"error.password_min_length": "Пароль має складати щонайменше 6 символів.",
"error.proxy_url_not_empty": "Proxy URL не може бути порожнім.",
"error.settings_block_rule_fieldname_invalid": "Недійсне правило блокування: у правилі #%d відсутнє коректне ім’я поля (Опції: %s)",
"error.settings_block_rule_invalid_regex": "Недійсне правило блокування: шаблон правила #%d не є коректним регулярним виразом",
"error.settings_block_rule_regex_required": "Недійсне правило блокування: не вказано шаблон для правила #%d",
"error.settings_block_rule_separator_required": "Недійсне правило блокування: шаблон правила #%d має бути розділений знаком '='",
"error.settings_invalid_domain_list": "Недійсний список доменів. Будь ласка, вкажіть список доменів, розділених пробілами.",
"error.settings_keep_rule_fieldname_invalid": "Недійсне правило дозволення: у правилі #%d відсутнє коректне ім’я поля (Опції: %s)",
"error.settings_keep_rule_invalid_regex": "Недійсне правило дозволення: шаблон правила #%d не є коректним регулярним виразом",
"error.settings_keep_rule_regex_required": "Недійсне правило дозволення: не вказано шаблон для правила #%d",
"error.settings_keep_rule_separator_required": "Недійсне правило дозволення: шаблон правила #%d має бути розділений знаком '='",
"error.settings_mandatory_fields": "Поля імені, теми, мови та часового поясу є обов’язковими.",
"error.settings_media_playback_rate_range": "Швидкість відтворення виходить за межі діапазону",
"error.settings_reading_speed_is_positive": "Швидкість читання має бути додатнім цілим числом.",
"error.site_url_not_empty": "URL-адреса сайту не може бути порожньою.",
"error.subscription_not_found": "Не знайшлося жодної підписки.",
"error.title_required": "Назва є обов’язковою.",
"error.tls_error": "Помилка TLS: %q. Ви можете відключити перевірку TLS в налаштуваннях фіду, якщо хочете.",
"error.unable_to_create_api_key": "Не вдається створити такий ключ API",
"error.unable_to_create_category": "Не вдається сворити категорію.",
"error.unable_to_create_user": "Не вдається створити користувача.",
"error.unable_to_detect_rssbridge": "Не вдалося виявити стрічку за допомогою RSS-Bridge: %v.",
"error.unable_to_parse_feed": "Не вдалося розібрати цю стрічку: %v.",
"error.unable_to_update_category": "Не вдається відредагувати категорію.",
"error.unable_to_update_feed": "Не вдається оновити стрічку.",
"error.unable_to_update_user": "Не вдається оновити користувача.",
"error.unlink_account_without_password": "Ви маєте встановити пароль, щоб мати можливість увійти наступного разу",
"error.user_already_exists": "Такий користувач вже існує.",
"error.user_mandatory_fields": "Ім'я користувача є обов'язковим.",
"error.linktaco_missing_required_fields": "LinkTaco API Token і Organization Slug є обов'язковими",
"form.api_key.label.description": "Назва ключа API",
"form.category.hide_globally": "Приховати записи в глобальному списку непрочитаного",
"form.category.label.title": "Назва",
"form.feed.fieldset.general": "Загальні",
"form.feed.fieldset.integration": "Сторонні сервіси",
"form.feed.fieldset.network_settings": "Налаштування мережі",
"form.feed.fieldset.rules": "Правила",
"form.feed.label.allow_self_signed_certificates": "Дозволити сертифікати з власним підписом або недійсні",
"form.feed.label.apprise_service_urls": "Список URL сервісів Apprise, розділених комами",
"form.feed.label.block_filter_entry_rules": "Правила блокування записів",
"form.feed.label.blocklist_rules": "Фільтри блокування на основі регулярних виразів",
"form.feed.label.category": "Категорія",
"form.feed.label.cookie": "Встановити кукі",
"form.feed.label.crawler": "Завантажувати оригінальний вміст",
"form.feed.label.description": "Опис",
"form.feed.label.disable_http2": "Вимкнути HTTP/2 для уникнення відбитків",
"form.feed.label.disabled": "Не оновлювати цю стрічку",
"form.feed.label.feed_password": "Пароль для завантаження",
"form.feed.label.feed_url": "URL-адреса стрічки",
"form.feed.label.feed_username": "Ім’я користувача для завантаження",
"form.feed.label.fetch_via_proxy": "Використовувати проксі, налаштований на рівні програми",
"form.feed.label.hide_globally": "Приховати записи в глобальному списку непрочитаного",
"form.feed.label.ignore_http_cache": "Ігнорувати кеш HTTP",
"form.feed.label.keep_filter_entry_rules": "Правила дозволу записів",
"form.feed.label.keeplist_rules": "Фільтри збереження на основі регулярних виразів",
"form.feed.label.no_media_player": "Немає медіаплеєра (аудіо/відео)",
"form.feed.label.ntfy_activate": "Надсилати записи у ntfy",
"form.feed.label.ntfy_default_priority": "Стандартний пріоритет ntfy",
"form.feed.label.ntfy_high_priority": "Високий пріоритет ntfy",
"form.feed.label.ntfy_low_priority": "Низький пріоритет ntfy",
"form.feed.label.ntfy_max_priority": "Максимальний пріоритет ntfy",
"form.feed.label.ntfy_min_priority": "Мінімальний пріоритет ntfy",
"form.feed.label.ntfy_priority": "Пріоритет ntfy",
"form.feed.label.ntfy_topic": "Тема ntfy (необов’язково)",
"form.feed.label.proxy_url": "URL-адреса проксі",
"form.feed.label.pushover_activate": "Надсилати записи у pushover.net",
"form.feed.label.pushover_default_priority": "Стандартний пріоритет Pushover",
"form.feed.label.pushover_high_priority": "Високий пріоритет Pushover",
"form.feed.label.pushover_low_priority": "Низький пріоритет Pushover",
"form.feed.label.pushover_max_priority": "Максимальний пріоритет Pushover",
"form.feed.label.pushover_min_priority": "Мінімальний пріоритет Pushover",
"form.feed.label.pushover_priority": "Пріоритет повідомлення Pushover",
"form.feed.label.rewrite_rules": "Правила перезапису вмісту",
"form.feed.label.scraper_rules": "Правила Scraper",
"form.feed.label.site_url": "URL-адреса сайту",
"form.feed.label.title": "Назва",
"form.feed.label.urlrewrite_rules": "Правила перезапису URL-адрес",
"form.feed.label.user_agent": "Назначити User Agent",
"form.feed.label.webhook_url": "Перевизначити URL вебхука",
"form.import.label.file": "Файл OPML",
"form.import.label.url": "URL-адреса",
"form.integration.archiveorg_activate": "Надсилати записи у archive.org",
"form.integration.apprise_activate": "Надсилати записи у Apprise",
"form.integration.apprise_services_url": "Список URL сервісів Apprise, розділених комами",
"form.integration.apprise_url": "URL API Apprise",
"form.integration.betula_activate": "Зберігати записи до Betula",
"form.integration.betula_token": "Токен Betula",
"form.integration.betula_url": "URL сервера Betula",
"form.integration.cubox_activate": "Зберігати статті до Cubox",
"form.integration.cubox_api_link": "Посилання на Cubox API",
"form.integration.discord_activate": "Надсилати записи до Discord",
"form.integration.discord_webhook_link": "Посилання на вебхук Discord",
"form.integration.espial_activate": "Зберігати статті до Espial",
"form.integration.espial_api_key": "Ключ API Espial",
"form.integration.espial_endpoint": "Кінцева точка API Espial",
"form.integration.espial_tags": "Теги для Espial",
"form.integration.fever_activate": "Увімкнути API Fever",
"form.integration.fever_endpoint": "Адреса доступу API Fever:",
"form.integration.fever_password": "Пароль Fever",
"form.integration.fever_username": "Ім’я користувача Fever",
"form.integration.googlereader_activate": "Увімкнути API Google Reader",
"form.integration.googlereader_endpoint": "Адреса доступу API Google Reader:",
"form.integration.googlereader_password": "Пароль Google Reader",
"form.integration.googlereader_username": "Ім’я користувача Google Reader",
"form.integration.instapaper_activate": "Зберігати статті до Instapaper",
"form.integration.instapaper_password": "Пароль Instapaper",
"form.integration.instapaper_username": "Ім’я користувача Instapaper",
"form.integration.karakeep_activate": "Зберігати статті до Karakeep",
"form.integration.karakeep_api_key": "Ключ API Karakeep",
"form.integration.karakeep_url": "Кінцева точка API Karakeep",
"form.integration.karakeep_tags": "Теги Karakeep",
"form.integration.linkace_activate": "Зберігати статті до LinkAce",
"form.integration.linkace_api_key": "Ключ API LinkAce",
"form.integration.linkace_check_disabled": "Вимкнути перевірку посилань",
"form.integration.linkace_endpoint": "Кінцева точка API LinkAce",
"form.integration.linkace_is_private": "Відмічати посилання як приватне",
"form.integration.linkace_tags": "Теги LinkAce",
"form.integration.linkding_activate": "Зберігати статті до Linkding",
"form.integration.linkding_api_key": "Ключ API Linkding",
"form.integration.linkding_bookmark": "Відмічати закладку як непрочитану",
"form.integration.linkding_endpoint": "Linkding API Endpoint",
"form.integration.linkding_tags": "Теги Linkding",
"form.integration.linktaco_activate": "Зберігати статті в LinkTaco",
"form.integration.linktaco_api_token": "LinkTaco API Token",
"form.integration.linktaco_api_token_hint": "Отримайте ваш персональний токен доступу на",
"form.integration.linktaco_org_slug": "Organization Slug",
"form.integration.linktaco_tags": "Теги (макс. 10, через кому)",
"form.integration.linktaco_tags_hint": "Максимум 10 тегів, через кому",
"form.integration.linktaco_visibility": "Видимість",
"form.integration.linktaco_visibility_public": "Публічно",
"form.integration.linktaco_visibility_private": "Приватно",
"form.integration.linktaco_visibility_hint": "ПРИВАТНА видимість потребує платного акаунта LinkTaco",
"form.integration.linkwarden_activate": "Зберігати статті до Linkwarden",
"form.integration.linkwarden_api_key": "Ключ API Linkwarden",
"form.integration.linkwarden_endpoint": "Базова URL-адреса Linkwarden",
"form.integration.linkwarden_collection_id": "Linkwarden Collection ID",
"form.integration.matrix_bot_activate": "Перенесення нових статей в Матрицю",
"form.integration.matrix_bot_chat_id": "Ідентифікатор кімнати Матриці",
"form.integration.matrix_bot_password": "Пароль для користувача Matrix",
"form.integration.matrix_bot_url": "URL-адреса сервера Матриці",
"form.integration.matrix_bot_user": "Ім'я користувача для Matrix",
"form.integration.notion_activate": "Save entries to Notion",
"form.integration.notion_page_id": "Notion Page ID",
"form.integration.notion_token": "Notion Secret Token",
"form.integration.ntfy_activate": "Надсилати записи у ntfy",
"form.integration.ntfy_api_token": "Ntfy API Token (optional)",
"form.integration.ntfy_icon_url": "Ntfy Icon URL (optional)",
"form.integration.ntfy_internal_links": "Використовувати внутрішні посилання при натисканні (необов’язково)",
"form.integration.ntfy_password": "Ntfy Password (optional)",
"form.integration.ntfy_topic": "Ntfy topic (default if not set in feed)",
"form.integration.ntfy_url": "Ntfy URL (optional, default is ntfy.sh)",
"form.integration.ntfy_username": "Ntfy Username (optional)",
"form.integration.nunux_keeper_activate": "Зберігати статті до Nunux Keeper",
"form.integration.nunux_keeper_api_key": "Ключ API Nunux Keeper",
"form.integration.nunux_keeper_endpoint": "Nunux Keeper API Endpoint",
"form.integration.omnivore_activate": "Зберігати статті до Omnivore",
"form.integration.omnivore_api_key": "Ключ API Omnivore",
"form.integration.omnivore_url": "Omnivore API Endpoint",
"form.integration.pinboard_activate": "Зберігати статті до Pinboard",
"form.integration.pinboard_bookmark": "Відмічати закладку як непрочитану",
"form.integration.pinboard_tags": "Теги для Pinboard",
"form.integration.pinboard_token": "API ключ від Pinboard",
"form.integration.pushover_activate": "Push entries to Pushover",
"form.integration.pushover_device": "Pushover device (optional)",
"form.integration.pushover_prefix": "Pushover URL prefix (optional)",
"form.integration.pushover_token": "Pushover application API token",
"form.integration.pushover_user": "Pushover user key",
"form.integration.raindrop_activate": "Save entries to Raindrop",
"form.integration.raindrop_collection_id": "Collection ID",
"form.integration.raindrop_tags": "Tags (comma-separated)",
"form.integration.raindrop_token": "(Test) Token",
"form.integration.readeck_activate": "Зберігати статті до Readeck",
"form.integration.readeck_api_key": "Ключ API Readeck",
"form.integration.readeck_endpoint": "Readeck URL",
"form.integration.readeck_labels": "Readeck Labels",
"form.integration.readeck_only_url": "Надіслати лише URL (замість повного вмісту)",
"form.integration.readeck_push_activate": "Automatically push new entries to Readeck",
"form.integration.readwise_activate": "Save entries to Readwise Reader",
"form.integration.readwise_api_key": "Readwise Reader Access Token",
"form.integration.readwise_api_key_link": "Get your Readwise Access Token",
"form.integration.rssbridge_activate": "Check RSS-Bridge when adding subscriptions",
"form.integration.rssbridge_token": "RSS-Bridge authentication token",
"form.integration.rssbridge_url": "RSS-Bridge server URL",
"form.integration.shaarli_activate": "Save articles to Shaarli",
"form.integration.shaarli_api_secret": "Shaarli API Secret",
"form.integration.shaarli_endpoint": "Shaarli URL",
"form.integration.shiori_activate": "Save articles to Shiori",
"form.integration.shiori_endpoint": "Shiori API Endpoint",
"form.integration.shiori_password": "Shiori Password",
"form.integration.shiori_username": "Shiori Username",
"form.integration.slack_activate": "Slack entries to Discord",
"form.integration.slack_webhook_link": "Slack Webhook link",
"form.integration.telegram_bot_activate": "Відправляти нові статті до чату Telegram",
"form.integration.telegram_bot_disable_buttons": "Вимкнути кнопки",
"form.integration.telegram_bot_disable_notification": "Вимкнути сповіщення",
"form.integration.telegram_bot_disable_web_page_preview": "Вимкнути попередній перегляд вебсторінок",
"form.integration.telegram_bot_token": "Токен боту",
"form.integration.telegram_chat_id": "ID чату",
"form.integration.telegram_topic_id": "ID теми",
"form.integration.wallabag_activate": "Зберігати статті до Wallabag",
"form.integration.wallabag_client_id": "ID клієнта Wallabag",
"form.integration.wallabag_client_secret": "Секрет клієнта Wallabag",
"form.integration.wallabag_endpoint": "Базова URL-адреса Wallabag",
"form.integration.wallabag_tags": "Теги Wallabag",
"form.integration.wallabag_only_url": "Надіслати лише URL (замість повного вмісту)",
"form.integration.wallabag_password": "Пароль Wallabag",
"form.integration.wallabag_username": "Ім’я користувача Wallabag",
"form.integration.webhook_activate": "Увімкнути вебхуки",
"form.integration.webhook_secret": "Секрет вебхуків",
"form.integration.webhook_url": "URL вебхука за замовчуванням",
"form.prefs.fieldset.application_settings": "Налаштування застосунку",
"form.prefs.fieldset.authentication_settings": "Налаштування автентифікації",
"form.prefs.fieldset.global_feed_settings": "Глобальні налаштування стрічок",
"form.prefs.fieldset.reader_settings": "Налаштування читача",
"form.prefs.help.external_font_hosts": "Список дозволених зовнішніх хостів шрифтів, розділених пробілами. Наприклад: 'fonts.gstatic.com fonts.googleapis.com'.",
"form.prefs.label.always_open_external_links": "Читати статті, відкриваючи зовнішні посилання",
"form.prefs.label.categories_sorting_order": "Сортування за категоріями",
"form.prefs.label.cjk_reading_speed": "Швидкість читання для китайської, корейської та японської мови (символів на хвилину)",
"form.prefs.label.custom_css": "Спеціальний CSS",
"form.prefs.label.custom_js": "Спеціальний JavaScript",
"form.prefs.label.default_home_page": "Домашня сторінка за умовчанням",
"form.prefs.label.default_reading_speed": "Швидкість читання для інших мов (слів на хвилину)",
"form.prefs.label.display_mode": "Режим відображення Progressive Web App (PWA).",
"form.prefs.label.entries_per_page": "Кількість записів на сторінку",
"form.prefs.label.entry_order": "Стовпець сортування записів",
"form.prefs.label.entry_sorting": "Сортування записів",
"form.prefs.label.entry_swipe": "Увімкніть введення пальцем на сенсорних екранах",
"form.prefs.label.external_font_hosts": "Зовнішні хости шрифтів",
"form.prefs.label.gesture_nav": "Жест для переходу між записами",
"form.prefs.label.keyboard_shortcuts": "Увімкнути комбінації клавиш",
"form.prefs.label.language": "Мова",
"form.prefs.label.mark_read_manually": "Позначати записи як прочитані вручну",
"form.prefs.label.mark_read_on_media_completion": "Позначати прочитаним лише після відтворення аудіо/відео на 90%%",
"form.prefs.label.mark_read_on_view": "Автоматично позначати записи як прочитані під час перегляду",
"form.prefs.label.mark_read_on_view_or_media_completion": "Позначати прочитаним під час перегляду. Для аудіо/відео — на 90%% відтворення",
"form.prefs.label.media_playback_rate": "Швидкість відтворення аудіо/відео",
"form.prefs.label.open_external_links_in_new_tab": "Відкривати зовнішні посилання у новій вкладці (додає target=\"_blank\" до посилань)",
"form.prefs.label.show_reading_time": "Показувати приблизний час читання для записів",
"form.prefs.label.theme": "Тема",
"form.prefs.label.timezone": "Часовий пояс",
"form.prefs.select.alphabetical": "За алфавітом",
"form.prefs.select.browser": "Браузер",
"form.prefs.select.created_time": "Дата створення запису",
"form.prefs.select.fullscreen": "Повний екран",
"form.prefs.select.minimal_ui": "Мінімальний",
"form.prefs.select.none": "Жодного",
"form.prefs.select.older_first": "Старіші записи спочатку",
"form.prefs.select.publish_time": "Дата публікації запису",
"form.prefs.select.recent_first": "Останні записи спочатку",
"form.prefs.select.standalone": "Автономний",
"form.prefs.select.swipe": "Проведіть пальцем",
"form.prefs.select.tap": "Двічі натисніть",
"form.prefs.select.unread_count": "Кількість непрочитаних",
"form.submit.loading": "Завантаження...",
"form.submit.saving": "Зберігаю...",
"form.user.label.admin": "Адміністратор",
"form.user.label.confirmation": "Підтверждення паролю",
"form.user.label.password": "Пароль",
"form.user.label.username": "Ім’я користувача",
"menu.about": "Про додаток",
"menu.add_feed": "Додати підписку",
"menu.add_user": "Додати користувачв",
"menu.api_keys": "Ключі API",
"menu.categories": "Категорії",
"menu.create_api_key": "Створити новий ключ API",
"menu.create_category": "Створити категорію",
"menu.edit_category": "Редагувати",
"menu.edit_feed": "Редагувати",
"menu.export": "Експорт",
"menu.feed_entries": "Записи",
"menu.feeds": "Стрічки",
"menu.flush_history": "Очистити історію",
"menu.history": "Історія",
"menu.home_page": "Головна сторінка",
"menu.import": "Імпорт",
"menu.integrations": "Інтеграції",
"menu.logout": "Вийти",
"menu.mark_all_as_read": "Відмітити все як прочитане",
"menu.mark_page_as_read": "Відмітити цю сторінку як прочитане",
"menu.preferences": "Уподобання",
"menu.refresh_all_feeds": "Оновити всі стрічки у фоновому режимі",
"menu.refresh_feed": "Оновити",
"menu.search": "Пошук",
"menu.sessions": "Сеанси",
"menu.settings": "Налаштування",
"menu.shared_entries": "Спільні записи",
"menu.show_all_entries": "Показати всі записи",
"menu.show_only_starred_entries": "Показати тільки записи з зірочкою",
"menu.show_only_unread_entries": "Показати тільки непрочитані записи",
"menu.starred": "З зірочкою",
"menu.title": "Меню",
"menu.unread": "Непрочитане",
"menu.users": "Користувачі",
"page.about.author": "Автор:",
"page.about.build_date": "Дата побудови:",
"page.about.credits": "Титри",
"page.about.db_usage": "Розмір бази даних:",
"page.about.git_commit": "Git-коміт:",
"page.about.global_config_options": "Параметри глобальної конфігурації",
"page.about.go_version": "Версія Go:",
"page.about.license": "Ліцензія:",
"page.about.postgres_version": "Версія Postgres:",
"page.about.title": "Про додадок",
"page.about.version": "Версія:",
"page.add_feed.choose_feed": "Обрати підписку",
"page.add_feed.label.url": "URL-адреса",
"page.add_feed.legend.advanced_options": "Розширені опції",
"page.add_feed.no_category": "Немає категорії. Ви маєте додати принаймні одну категорію.",
"page.add_feed.submit": "Знайти підписку",
"page.add_feed.title": "Нова підписка",
"page.api_keys.never_used": "Ніколи не використався",
"page.api_keys.table.actions": "Дії",
"page.api_keys.table.created_at": "Дата створення",
"page.api_keys.table.description": "Опис",
"page.api_keys.table.last_used_at": "Дата останнього використання",
"page.api_keys.table.token": "Токен",
"page.api_keys.title": "Ключі API",
"page.categories.entries": "Статті",
"page.categories.feed_count": [
"Містить %d стрічку.",
"Містить %d стрічки.",
"Містить %d стрічок."
],
"page.categories.feeds": "Підписки",
"page.categories.no_feed": "Немає стрічки.",
"page.categories.title": "Категорії",
"page.categories_count": [
"%d категорія",
"%d категорії",
"%d категорій"
],
"page.category_label": "Категорія: %s",
"page.edit_category.title": "Редагування категорії: %s",
"page.edit_feed.etag_header": "Заголовок ETag:",
"page.edit_feed.last_check": "Остання перевірка:",
"page.edit_feed.last_modified_header": "Заголовок LastModified:",
"page.edit_feed.last_parsing_error": "Остання помилка аналізу",
"page.edit_feed.no_header": "Немає",
"page.edit_feed.title": "Редагування стрічки: %s",
"page.edit_user.title": "Редагування користувача: %s",
"page.entry.attachments": "Додатки",
"page.feeds.error_count": [
"%d помилка",
"%d помилки",
"%d помилок"
],
"page.feeds.last_check": "Остання перевірка:",
"page.feeds.next_check": "Наступна перевірка:",
"page.feeds.read_counter": "Кількість прочитаних записів",
"page.feeds.title": "Стрічки",
"page.footer.elevator": "Повернутися нагору",
"page.history.title": "Історія",
"page.import.title": "Імпорт",
"page.integration.bookmarklet": "Букмарклет",
"page.integration.bookmarklet.help": "Це спеціальне посилання дозволяє підписатися на веб-сайт безпосередньо за допомогою закладки у вашому веб-браузері.",
"page.integration.bookmarklet.instructions": "Перетягніть це посилання до своїх закладок.",
"page.integration.bookmarklet.name": "Додати до Miniflux",
"page.integration.miniflux_api": "API Miniflux",
"page.integration.miniflux_api_endpoint": "Адреса доступу API",
"page.integration.miniflux_api_password": "Пароль",
"page.integration.miniflux_api_password_value": "Пароль до вашого облікового запису",
"page.integration.miniflux_api_username": "Ім’я користувача",
"page.integrations.title": "Інтеграції",
"page.keyboard_shortcuts.close_modal": "Закрити модальне діалогове вікно",
"page.keyboard_shortcuts.download_content": "Завантажити оригінальний зміст",
"page.keyboard_shortcuts.go_to_bottom_item": "Перейти до нижнього пункту",
"page.keyboard_shortcuts.go_to_categories": "Перейти до категорій",
"page.keyboard_shortcuts.go_to_feed": "Перейти до стрічки",
"page.keyboard_shortcuts.go_to_feeds": "Перейти до стрічок",
"page.keyboard_shortcuts.go_to_history": "Перейти до історії",
"page.keyboard_shortcuts.go_to_next_item": "Перейти до наступного запису",
"page.keyboard_shortcuts.go_to_next_page": "Перейти до наступної сторінки",
"page.keyboard_shortcuts.go_to_previous_item": "Перейти до попереднього запису",
"page.keyboard_shortcuts.go_to_previous_page": "Перейти до попередньої сторінки",
"page.keyboard_shortcuts.go_to_search": "Поставити фокус на поле пошуку",
"page.keyboard_shortcuts.go_to_settings": "Перейти до налаштувань",
"page.keyboard_shortcuts.go_to_starred": "Перейти до закладок",
"page.keyboard_shortcuts.go_to_top_item": "Перейти до верхнього пункту",
"page.keyboard_shortcuts.go_to_unread": "Перейти до непрочитаних",
"page.keyboard_shortcuts.mark_page_as_read": "Відмітити поточну сторінку як прочитане",
"page.keyboard_shortcuts.open_comments": "Відкрити посилання на коментарі",
"page.keyboard_shortcuts.open_comments_same_window": "Відкрити посилання на коментарі в поточній вкладці",
"page.keyboard_shortcuts.open_item": "Відкрити виділений запис",
"page.keyboard_shortcuts.open_original": "Відкрити оригінальне посилання",
"page.keyboard_shortcuts.open_original_same_window": "Відкрити оригінальне посилання в поточній вкладці",
"page.keyboard_shortcuts.refresh_all_feeds": "Оновити всі стрічки в фоновому режимі",
"page.keyboard_shortcuts.remove_feed": "Видалити цю стрічку",
"page.keyboard_shortcuts.save_article": "Зберегти статтю",
"page.keyboard_shortcuts.scroll_item_to_top": "Прокрутити запис догори",
"page.keyboard_shortcuts.show_keyboard_shortcuts": "Показати комбінації клавиш",
"page.keyboard_shortcuts.subtitle.actions": "Дії",
"page.keyboard_shortcuts.subtitle.items": "Навігація по записах",
"page.keyboard_shortcuts.subtitle.pages": "Навігація по сторінках",
"page.keyboard_shortcuts.subtitle.sections": "Навігація по розділах",
"page.keyboard_shortcuts.title": "Комбінації клавиш",
"page.keyboard_shortcuts.toggle_star_status": "Переключити статус закладки",
"page.keyboard_shortcuts.toggle_entry_attachments": "Перемкнути відкриття/закриття вкладень запису",
"page.keyboard_shortcuts.toggle_read_status_next": "Переключити статус читання, перейти до наступного",
"page.keyboard_shortcuts.toggle_read_status_prev": "Переключити статус читання, перейти до попереднього",
"page.login.google_signin": "Увійти через Google",
"page.login.oidc_signin": "Увійти через %s",
"page.login.title": "Вхід",
"page.login.webauthn_login": "Увійти за допомогою пароля",
"page.login.webauthn_login.error": "Неможливо ввійти за допомогою ключа доступу",
"page.login.webauthn_login.help": "Якщо використовуєте ключ безпеки, введіть ім'я користувача. Для паролю-паскі це не потрібно.",
"page.new_api_key.title": "Створити ключ API",
"page.new_category.title": "Нова категорія",
"page.new_user.title": "Новий користувач",
"page.offline.message": "Ви офлайн",
"page.offline.refresh_page": "Спробуйте оновити сторінку",
"page.offline.title": "Автономний режим",
"page.read_entry_count": [
"%d прочитаний запис",
"%d прочитаних записів",
"%d прочитаних записів"
],
"page.search.title": "Результати пошуку",
"page.sessions.table.actions": "Дії",
"page.sessions.table.current_session": "Поточний сеанс",
"page.sessions.table.date": "Дата",
"page.sessions.table.ip": "IP адреса",
"page.sessions.table.user_agent": "Агент користувача (User Agent)",
"page.sessions.title": "Сеанси",
"page.settings.link_google_account": "Підключити мій обліковий запис Google",
"page.settings.link_oidc_account": "Підключити мій обліковий запис %s",
"page.settings.title": "Налаштування ",
"page.settings.unlink_google_account": "Відключити мій обліковий запис Google",
"page.settings.unlink_oidc_account": "Відключити мій обліковий запис %s",
"page.settings.webauthn.actions": "Дії",
"page.settings.webauthn.added_on": "Додано",
"page.settings.webauthn.delete": [
"Видалити %d ключ доступу",
"Видаліть %d ключа доступу",
"Видаліть %d ключа доступу"
],
"page.settings.webauthn.last_seen_on": "Востаннє використано",
"page.settings.webauthn.passkey_name": "Назва паскі",
"page.settings.webauthn.passkeys": "Паскі",
"page.settings.webauthn.register": "Зареєструвати пароль",
"page.settings.webauthn.register.error": "Не вдалося зареєструвати ключ доступу",
"page.shared_entries.title": "Спільні записи",
"page.shared_entries_count": [
"%d спільний запис",
"%d спільні записи",
"%d спільних записів"
],
"page.starred.title": "З зірочкою",
"page.starred_entry_count": [
"%d запис із зіркою",
"%d записи із зіркою",
"%d записів із зіркою"
],
"page.total_entry_count": [
"Усього %d запис",
"Усього %d записи",
"Усього %d записів"
],
"page.unread.title": "Непрочитане",
"page.unread_entry_count": [
"%d непрочитаний запис",
"%d непрочитані записи",
"%d непрочитаних записів"
],
"page.users.actions": "Дії",
"page.users.admin.no": "Ні",
"page.users.admin.yes": "Так",
"page.users.is_admin": "Адміністратор",
"page.users.last_login": "Дата останнього входу",
"page.users.never_logged": "Ніколи",
"page.users.title": "Користувачі",
"page.users.username": "Ім’я користувача",
"page.webauthn_rename.title": "Перейменувати паскі",
"pagination.first": "Перша",
"pagination.last": "Остання",
"pagination.next": "Наступна",
"pagination.previous": "Попередня",
"search.label": "Пошук",
"search.placeholder": "Шукати...",
"search.submit": "Знайти",
"skip_to_content": "Перейти до вмісту",
"time_elapsed.days": [
"%d день тому",
"%d дні тому",
"%d днів тому"
],
"time_elapsed.hours": [
"%d годину тому",
"%d години тому",
"%d годин тому"
],
"time_elapsed.minutes": [
"%d хвилину тому",
"%d хвилини тому",
"%d хвилин тому"
],
"time_elapsed.months": [
"%d місяць тому",
"%d місяця тому",
"%d місяців тому"
],
"time_elapsed.not_yet": "ще ні",
"time_elapsed.now": "прямо зараз",
"time_elapsed.weeks": [
"%d тиждень тому",
"%d тижня тому",
"%d тижнів тому"
],
"time_elapsed.years": [
"%d рік тому",
"%d роки тому",
"%d років тому"
],
"time_elapsed.yesterday": "вчора",
"tooltip.keyboard_shortcuts": "Комбінація клавіш: %s",
"tooltip.logged_user": "Здійснено вхід як %s"
}
v2-2.2.16/internal/locale/translations/zh_CN.json 0000664 0000000 0000000 00000106104 15127074645 0021636 0 ustar 00root root 0000000 0000000 {
"action.cancel": "取消",
"action.download": "下载",
"action.edit": "编辑",
"action.home_screen": "添加到主屏幕",
"action.import": "导入",
"action.login": "登录",
"action.or": "或",
"action.remove": "移除",
"action.remove_feed": "移除此订阅源",
"action.save": "保存",
"action.subscribe": "订阅",
"action.update": "更新",
"alert.account_linked": "您的外部账号已关联!",
"alert.account_unlinked": "您的外部帐户已解除关联!",
"alert.background_feed_refresh": "所有订阅源正在后台刷新。您可以在刷新过程中继续使用 Miniflux。",
"alert.feed_error": "此订阅源存在问题",
"alert.no_starred": "没有收藏的条目。",
"alert.no_category": "没有分类。",
"alert.no_category_entry": "此分类下没有条目。",
"alert.no_feed": "你没有任何订阅源。",
"alert.no_feed_entry": "此订阅源中没有条目。",
"alert.no_feed_in_category": "此分类中没有订阅源。",
"alert.no_history": "当前没有历史记录。",
"alert.no_search_result": "此搜索没有结果。",
"alert.no_shared_entry": "没有已分享条目。",
"alert.no_tag_entry": "没有匹配此标签的条目。",
"alert.no_unread_entry": "没有未读条目。",
"alert.no_user": "您是唯一的用户。",
"alert.prefs_saved": "偏好设置已保存!",
"alert.too_many_feeds_refresh": [
"您触发了太多次订阅源刷新。请在 %d 分钟后重试。"
],
"confirm.loading": "进行中…",
"confirm.no": "否",
"confirm.question": "您确定吗?",
"confirm.question.refresh": "您确定要强制刷新吗?",
"confirm.yes": "是",
"enclosure_media_controls.seek": "查找:",
"enclosure_media_controls.seek.title": "查找 %s 秒",
"enclosure_media_controls.speed": "速度:",
"enclosure_media_controls.speed.faster": "快进",
"enclosure_media_controls.speed.faster.title": "速度快进到 %sx",
"enclosure_media_controls.speed.reset": "重置",
"enclosure_media_controls.speed.reset.title": "重置速度到 1x",
"enclosure_media_controls.speed.slower": "减慢",
"enclosure_media_controls.speed.slower.title": "速度减慢到 %sx",
"entry.starred.toast.off": "已取消收藏",
"entry.starred.toast.on": "已添加收藏",
"entry.starred.toggle.off": "取消收藏",
"entry.starred.toggle.on": "添加收藏",
"entry.comments.label": "评论",
"entry.comments.title": "查看评论",
"entry.estimated_reading_time": [
"需要 %d 分钟阅读"
],
"entry.external_link.label": "外部链接",
"entry.save.completed": "完成!",
"entry.save.label": "保存",
"entry.save.title": "保存此条目",
"entry.save.toast.completed": "条目已保存",
"entry.scraper.completed": "完成!",
"entry.scraper.label": "下载",
"entry.scraper.title": "获取原始内容",
"entry.share.label": "分享",
"entry.share.title": "分享此条目",
"entry.shared_entry.label": "分享",
"entry.shared_entry.title": "打开公开链接",
"entry.state.loading": "加载中…",
"entry.state.saving": "保存中…",
"entry.status.mark_as_read": "标为已读",
"entry.status.mark_as_unread": "标为未读",
"entry.status.title": "更改条目状态",
"entry.status.toast.read": "已标为已读",
"entry.status.toast.unread": "已标为未读",
"entry.tags.label": "标签:",
"entry.tags.more_tags_label": [
"显示 %d 个更多标签"
],
"entry.unshare.label": "取消分享",
"error.api_key_already_exists": "此 API 密钥已存在。",
"error.bad_credentials": "用户名或密码无效。",
"error.category_already_exists": "此分类已存在。",
"error.category_not_found": "此分类不存在或不属于此用户。",
"error.database_error": "数据库错误: %v。",
"error.different_passwords": "密码不一致。",
"error.duplicate_fever_username": "已存在其他用户使用相同的 Fever 用户名!",
"error.duplicate_googlereader_username": "已存在其他用户使用相同的 Google Reader 用户名!",
"error.duplicate_linked_account": "已有人与该提供商关联!",
"error.duplicated_feed": "此订阅源已经存在。",
"error.empty_file": "此文件为空。",
"error.entries_per_page_invalid": "每页的条目数无效。",
"error.feed_already_exists": "此订阅源已存在。",
"error.feed_category_not_found": "此分类不存在或不属于此用户。",
"error.feed_format_not_detected": "无法解析订阅源格式:%v。",
"error.feed_invalid_blocklist_rule": "阻止列表规则无效。",
"error.feed_invalid_keeplist_rule": "保留列表规则无效。",
"error.feed_mandatory_fields": "必须填写 URL 和分类。",
"error.feed_not_found": "此订阅源不存在或不属于此用户。",
"error.feed_title_not_empty": "订阅源的标题不能为空。",
"error.feed_url_not_empty": "订阅源的 URL 不能为空。",
"error.fields_mandatory": "必须填写全部信息。",
"error.http_bad_gateway": "由于网关错误,网站暂不可用。这不是 Miniflux 的问题,请稍后重试。",
"error.http_body_read": "无法读取 HTTP 正文:%v。",
"error.http_client_error": "HTTP 客户端错误:%v。",
"error.http_empty_response": "HTTP 响应为空,该网站可能使用了反爬虫机制。",
"error.http_empty_response_body": "HTTP 响应正文为空。",
"error.http_forbidden": "禁止访问该网站。可能该网站使用了反爬虫机制?",
"error.http_gateway_timeout": "由于网关超时,网站暂不可用。这不是 Miniflux 的问题,请稍后重试。",
"error.http_internal_server_error": "由于服务器错误,网站暂不可用。这不是 Miniflux 的问题,请稍后重试。",
"error.http_not_authorized": "未经授权访问此网站。可能是用户名或密码错误。",
"error.http_resource_not_found": "未找到请求的资源。请检查 URL。",
"error.http_response_too_large": "HTTP 响应过大。您可以在全局设置中增加 HTTP 响应大小限制(需重启服务器)。",
"error.http_service_unavailable": "由于内部服务器错误,网站暂不可用。这不是 Miniflux 的问题,请稍后重试。",
"error.http_too_many_requests": "Miniflux 向此网站生成了过多请求。请稍后重试或更改应用程序配置。",
"error.http_unexpected_status_code": "由于意外的 HTTP 状态码 %d,网站暂不可用。这不是 Miniflux 的问题,请稍后重试。",
"error.invalid_categories_sorting_order": "无效的分类排序顺序。",
"error.invalid_default_home_page": "无效的默认主页!",
"error.invalid_display_mode": "无效的网页应用显示模式。",
"error.invalid_entry_direction": "无效的条目方向。",
"error.invalid_entry_order": "无效的条目排序。",
"error.invalid_feed_proxy_url": "无效的代理 URL。",
"error.invalid_feed_url": "无效的订阅源 URL。",
"error.invalid_gesture_nav": "无效的手势导航。",
"error.invalid_language": "无效的语言。",
"error.invalid_site_url": "无效的网站 URL。",
"error.invalid_theme": "无效的主题。",
"error.invalid_timezone": "无效的时区。",
"error.network_operation": "由于网络错误,Miniflux 无法访问此网站:%v。",
"error.network_timeout": "该网站响应过慢,请求已超时:%v",
"error.password_min_length": "密码长度至少为 6 个字符。",
"error.proxy_url_not_empty": "代理 URL 不能为空。",
"error.settings_block_rule_fieldname_invalid": "无效的阻止规则:规则 #%d 缺少合法的字段名(可选:%s)",
"error.settings_block_rule_invalid_regex": "无效的阻止规则:规则 #%d 的模式字符不是合法的正则表达式",
"error.settings_block_rule_regex_required": "无效的阻止规则:规则 #%d 的模式字符没有提供",
"error.settings_block_rule_separator_required": "无效的阻止规则:规则 #%d 的模式字符必须用‘=’分开",
"error.settings_invalid_domain_list": "无效的域名列表。请提供以空格分隔的域名列表。",
"error.settings_keep_rule_fieldname_invalid": "无效的保留规则:规则 #%d 缺少合法的字段名(可选:%s)",
"error.settings_keep_rule_invalid_regex": "无效的保留规则:规则 #%d 的模式字符不是合法的正则表达式",
"error.settings_keep_rule_regex_required": "无效的保留规则:规则 #%d 的模式字符没有提供",
"error.settings_keep_rule_separator_required": "无效的保留规则:规则 #%d 的模式字符必须用‘=’分开",
"error.settings_mandatory_fields": "必须填写用户名、主题、语言以及时区。",
"error.settings_media_playback_rate_range": "播放速度超出范围",
"error.settings_reading_speed_is_positive": "阅读速度必须是正整数。",
"error.site_url_not_empty": "站点 URL 不能为空。",
"error.subscription_not_found": "无法找到任何订阅源。",
"error.title_required": "必须填写标题。",
"error.tls_error": "TLS 错误: %q。如果您愿意的话可以在订阅源设置里关闭 TLS 验证。",
"error.unable_to_create_api_key": "无法创建此 API 密钥。",
"error.unable_to_create_category": "无法创建此分类。",
"error.unable_to_create_user": "无法创建此用户。",
"error.unable_to_detect_rssbridge": "无法使用 RSS-Bridge 检测订阅源:%v。",
"error.unable_to_parse_feed": "无法解析此订阅源:%v。",
"error.unable_to_update_category": "无法更新此分类。",
"error.unable_to_update_feed": "无法更新此订阅源。",
"error.unable_to_update_user": "无法更新此用户。",
"error.unlink_account_without_password": "您必须设置密码,否则您将无法再次登录。",
"error.user_already_exists": "此用户已存在。",
"error.user_mandatory_fields": "必须填写用户名。",
"error.linktaco_missing_required_fields": "LinkTaco API Token 和 Organization Slug 是必需的",
"form.api_key.label.description": "API 密钥标签",
"form.category.hide_globally": "在全局未读列表中隐藏条目",
"form.category.label.title": "标题",
"form.feed.fieldset.general": "常规",
"form.feed.fieldset.integration": "第三方服务",
"form.feed.fieldset.network_settings": "网络设置",
"form.feed.fieldset.rules": "规则",
"form.feed.label.allow_self_signed_certificates": "允许自签名证书或无效证书",
"form.feed.label.apprise_service_urls": "使用逗号分隔的 Apprise 服务 URL 列表",
"form.feed.label.block_filter_entry_rules": "条目屏蔽规则",
"form.feed.label.blocklist_rules": "基于正则表达式的屏蔽过滤器",
"form.feed.label.category": "分类",
"form.feed.label.cookie": "设置 Cookie",
"form.feed.label.crawler": "获取原始内容",
"form.feed.label.description": "描述",
"form.feed.label.disable_http2": "禁用 HTTP/2 以避免指纹识别",
"form.feed.label.disabled": "不刷新此订阅",
"form.feed.label.feed_password": "订阅源密码",
"form.feed.label.feed_url": "订阅源 URL",
"form.feed.label.feed_username": "订阅源用户名",
"form.feed.label.fetch_via_proxy": "使用在应用程序级别配置的代理",
"form.feed.label.hide_globally": "在全局未读列表中隐藏条目",
"form.feed.label.ignore_http_cache": "忽略 HTTP 缓存",
"form.feed.label.keep_filter_entry_rules": "条目允许规则",
"form.feed.label.keeplist_rules": "基于正则表达式的保留过滤器",
"form.feed.label.no_media_player": "无媒体播放器(音频/视频)",
"form.feed.label.ntfy_activate": "推送条目到 Ntfy",
"form.feed.label.ntfy_default_priority": "Ntfy 默认优先级",
"form.feed.label.ntfy_high_priority": "Ntfy 高优先级",
"form.feed.label.ntfy_low_priority": "Ntfy 低优先级",
"form.feed.label.ntfy_max_priority": "Ntfy 最高优先级",
"form.feed.label.ntfy_min_priority": "Ntfy 最低优先级",
"form.feed.label.ntfy_priority": "Ntfy 优先级",
"form.feed.label.ntfy_topic": "Ntfy 主题(可选)",
"form.feed.label.proxy_url": "代理 URL",
"form.feed.label.pushover_activate": "推送条目到 Pushover",
"form.feed.label.pushover_default_priority": "Pushover 默认优先级",
"form.feed.label.pushover_high_priority": "Pushover 高优先级",
"form.feed.label.pushover_low_priority": "Pushover 低优先级",
"form.feed.label.pushover_max_priority": "Pushover 最高优先级",
"form.feed.label.pushover_min_priority": "Pushover 最低优先级",
"form.feed.label.pushover_priority": "Pushover 消息优先级",
"form.feed.label.rewrite_rules": "内容重写规则",
"form.feed.label.scraper_rules": "抓取规则",
"form.feed.label.site_url": "站点 URL",
"form.feed.label.title": "标题",
"form.feed.label.urlrewrite_rules": "URL 重写规则",
"form.feed.label.user_agent": "覆盖默认的用户代理",
"form.feed.label.webhook_url": "覆盖 Webhook URL",
"form.import.label.file": "OPML 文件",
"form.import.label.url": "URL",
"form.integration.archiveorg_activate": "将新条目推送到 archive.org",
"form.integration.apprise_activate": "将新条目推送到 Apprise",
"form.integration.apprise_services_url": "使用逗号分隔的 Apprise 服务 URL 列表",
"form.integration.apprise_url": "Apprise API URL",
"form.integration.betula_activate": "保存条目到 Betula",
"form.integration.betula_token": "Betula 令牌",
"form.integration.betula_url": "Betula 服务端 URL",
"form.integration.cubox_activate": "保存条目到 Cubox",
"form.integration.cubox_api_link": "Cubox API 链接",
"form.integration.discord_activate": "推送条目到 Discord",
"form.integration.discord_webhook_link": "Discord Webhook 链接",
"form.integration.espial_activate": "保存条目到 Espial",
"form.integration.espial_api_key": "Espial API 密钥",
"form.integration.espial_endpoint": "Espial API 端点",
"form.integration.espial_tags": "Espial 标签",
"form.integration.fever_activate": "启用 Fever API",
"form.integration.fever_endpoint": "Fever API 端点",
"form.integration.fever_password": "Fever 密码",
"form.integration.fever_username": "Fever 用户名",
"form.integration.googlereader_activate": "启用 Google Reader API",
"form.integration.googlereader_endpoint": "Google Reader API 端点:",
"form.integration.googlereader_password": "Google Reader 密码",
"form.integration.googlereader_username": "Google Reader 用户名",
"form.integration.instapaper_activate": "保存条目到 Instapaper",
"form.integration.instapaper_password": "Instapaper 密码",
"form.integration.instapaper_username": "Instapaper 用户名",
"form.integration.karakeep_activate": "保存条目到 Karakeep",
"form.integration.karakeep_api_key": "Karakeep API 密钥",
"form.integration.karakeep_url": "Karakeep API 端点",
"form.integration.karakeep_tags": "Karakeep 标签",
"form.integration.linkace_activate": "保存条目到 LinkAce",
"form.integration.linkace_api_key": "LinkAce API 密钥",
"form.integration.linkace_check_disabled": "禁用链接检查",
"form.integration.linkace_endpoint": "LinkAce API 端点",
"form.integration.linkace_is_private": "将链接标记为私有",
"form.integration.linkace_tags": "LinkAce 标签",
"form.integration.linkding_activate": "保存条目到 Linkding",
"form.integration.linkding_api_key": "Linkding API 密钥",
"form.integration.linkding_bookmark": "将书签标记为未读",
"form.integration.linkding_endpoint": "Linkding API 端点",
"form.integration.linkding_tags": "Linkding 标签",
"form.integration.linktaco_activate": "保存条目到 LinkTaco",
"form.integration.linktaco_api_token": "LinkTaco API Token",
"form.integration.linktaco_api_token_hint": "在此获取您的个人访问令牌",
"form.integration.linktaco_org_slug": "组织代称",
"form.integration.linktaco_tags": "标签(最多10个,逗号分隔)",
"form.integration.linktaco_tags_hint": "最多10个标签,逗号分隔",
"form.integration.linktaco_visibility": "可见性",
"form.integration.linktaco_visibility_public": "公开",
"form.integration.linktaco_visibility_private": "私人",
"form.integration.linktaco_visibility_hint": "私人可见性需要付费的 LinkTaco 帐户",
"form.integration.linkwarden_activate": "保存条目到 Linkwarden",
"form.integration.linkwarden_api_key": "Linkwarden API 密钥",
"form.integration.linkwarden_endpoint": "Linkwarden 基本 URL",
"form.integration.linkwarden_collection_id": "Linkwarden 集合 ID",
"form.integration.matrix_bot_activate": "推送新条目到 Matrix",
"form.integration.matrix_bot_chat_id": "Matrix 房间 ID",
"form.integration.matrix_bot_password": "Matrix 用户密码",
"form.integration.matrix_bot_url": "Matrix 服务器 URL",
"form.integration.matrix_bot_user": "Matrix 用户名",
"form.integration.notion_activate": "保存条目到 Notion",
"form.integration.notion_page_id": "Notion 页面 ID",
"form.integration.notion_token": "Notion 密钥令牌",
"form.integration.ntfy_activate": "推送条目到 Ntfy",
"form.integration.ntfy_api_token": "Ntfy API 令牌(可选)",
"form.integration.ntfy_icon_url": "Ntfy 图标 URL(可选)",
"form.integration.ntfy_internal_links": "点击时使用内部链接(可选)",
"form.integration.ntfy_password": "Ntfy 密码(可选)",
"form.integration.ntfy_topic": "Ntfy 主题(如果订阅源中未设置则使用默认值)",
"form.integration.ntfy_url": "Ntfy URL(可选,默认为 ntfy.sh)",
"form.integration.ntfy_username": "Ntfy 用户名(可选)",
"form.integration.nunux_keeper_activate": "保存条目到 Nunux Keeper",
"form.integration.nunux_keeper_api_key": "Nunux Keeper API 密钥",
"form.integration.nunux_keeper_endpoint": "Nunux Keeper API 端点",
"form.integration.omnivore_activate": "保存条目到 Omnivore",
"form.integration.omnivore_api_key": "Omnivore API 密钥",
"form.integration.omnivore_url": "Omnivore API 端点",
"form.integration.pinboard_activate": "保存条目到 Pinboard",
"form.integration.pinboard_bookmark": "将书签标记为未读",
"form.integration.pinboard_tags": "Pinboard 标签",
"form.integration.pinboard_token": "Pinboard API 令牌",
"form.integration.pushover_activate": "推送条目到 Pushover",
"form.integration.pushover_device": "Pushover 设备(可选)",
"form.integration.pushover_prefix": "Pushover URL 前缀(可选)",
"form.integration.pushover_token": "Pushover 应用 API 令牌",
"form.integration.pushover_user": "Pushover 用户密钥",
"form.integration.raindrop_activate": "保存条目到 Raindrop",
"form.integration.raindrop_collection_id": "集合 ID",
"form.integration.raindrop_tags": "标签(逗号分隔)",
"form.integration.raindrop_token": "(测试)令牌",
"form.integration.readeck_activate": "保存条目到 Readeck",
"form.integration.readeck_api_key": "Readeck API 密钥",
"form.integration.readeck_endpoint": "Readeck API 端点",
"form.integration.readeck_labels": "Readeck 标签",
"form.integration.readeck_only_url": "仅发送 URL(而非完整内容)",
"form.integration.readeck_push_activate": "自动将新条目推送到 Readeck",
"form.integration.readwise_activate": "保存条目到 Readwise Reader",
"form.integration.readwise_api_key": "Readwise Reader 访问令牌",
"form.integration.readwise_api_key_link": "获取你的 Readwise 访问令牌",
"form.integration.rssbridge_activate": "添加订阅时检查 RSS-Bridge",
"form.integration.rssbridge_token": "RSS-Bridge 认证令牌",
"form.integration.rssbridge_url": "RSS-Bridge 服务器 URL",
"form.integration.shaarli_activate": "保存条目到 Shaarli",
"form.integration.shaarli_api_secret": "Shaarli API 密钥",
"form.integration.shaarli_endpoint": "Shaarli URL",
"form.integration.shiori_activate": "保存条目到 Shiori",
"form.integration.shiori_endpoint": "Shiori API 端点",
"form.integration.shiori_password": "Shiori 密码",
"form.integration.shiori_username": "Shiori 用户名",
"form.integration.slack_activate": "推送条目到 Slack",
"form.integration.slack_webhook_link": "Slack Webhook 链接",
"form.integration.telegram_bot_activate": "推送新条目到 Telegram 聊天",
"form.integration.telegram_bot_disable_buttons": "禁用按钮",
"form.integration.telegram_bot_disable_notification": "禁用通知",
"form.integration.telegram_bot_disable_web_page_preview": "禁用网页预览",
"form.integration.telegram_bot_token": "机器人令牌",
"form.integration.telegram_chat_id": "聊天 ID",
"form.integration.telegram_topic_id": "主题 ID",
"form.integration.wallabag_activate": "保存条目到 Wallabag",
"form.integration.wallabag_client_id": "Wallabag 客户端 ID",
"form.integration.wallabag_client_secret": "Wallabag 客户端密钥",
"form.integration.wallabag_endpoint": "Wallabag 基础 URL",
"form.integration.wallabag_only_url": "仅发送 URL(而非完整内容)",
"form.integration.wallabag_password": "Wallabag 密码",
"form.integration.wallabag_username": "Wallabag 用户名",
"form.integration.wallabag_tags": "Wallabag 标签",
"form.integration.webhook_activate": "启用 Webhooks",
"form.integration.webhook_secret": "Webhooks 密钥",
"form.integration.webhook_url": "默认 Webhook URL",
"form.prefs.fieldset.application_settings": "应用设置",
"form.prefs.fieldset.authentication_settings": "认证设置",
"form.prefs.fieldset.global_feed_settings": "全局订阅源设置",
"form.prefs.fieldset.reader_settings": "阅读器设置",
"form.prefs.help.external_font_hosts": "允许外部字体托管的空格分隔列表。例如:\"fonts.gstatic.com fonts.googleapis.com\"。",
"form.prefs.label.always_open_external_links": "打开外部链接阅读条目",
"form.prefs.label.categories_sorting_order": "分类排序",
"form.prefs.label.cjk_reading_speed": "中文、韩文和日文的阅读速度(每分钟字符数)",
"form.prefs.label.custom_css": "自定义 CSS",
"form.prefs.label.custom_js": "自定义 JavaScript",
"form.prefs.label.default_home_page": "默认主页",
"form.prefs.label.default_reading_speed": "其他语言的阅读速度(每分钟字数)",
"form.prefs.label.display_mode": "渐进式网络应用程序(PWA)显示模式",
"form.prefs.label.entries_per_page": "每页条目数",
"form.prefs.label.entry_order": "条目排序字段",
"form.prefs.label.entry_sorting": "条目排序",
"form.prefs.label.entry_swipe": "在触摸屏上启用条目滑动",
"form.prefs.label.external_font_hosts": "外部字体主机",
"form.prefs.label.gesture_nav": "在条目间导航的手势",
"form.prefs.label.keyboard_shortcuts": "启用键盘快捷键",
"form.prefs.label.language": "语言",
"form.prefs.label.mark_read_manually": "手动标记条目为已读",
"form.prefs.label.mark_read_on_media_completion": "仅当音频/视频播放完成 90%% 时标记为已读",
"form.prefs.label.mark_read_on_view": "查看时自动将条目标记为已读",
"form.prefs.label.mark_read_on_view_or_media_completion": "当浏览时标记条目为已读。对于音频/视频,当播放完成 90%% 时标记为已读",
"form.prefs.label.media_playback_rate": "音频/视频的播放速度",
"form.prefs.label.open_external_links_in_new_tab": "在新标签页中打开外部链接(为链接添加 target=\"_blank\")",
"form.prefs.label.show_reading_time": "显示条目的预计阅读时间",
"form.prefs.label.theme": "主题",
"form.prefs.label.timezone": "时区",
"form.prefs.select.alphabetical": "字母顺序",
"form.prefs.select.browser": "浏览器",
"form.prefs.select.created_time": "条目创建时间",
"form.prefs.select.fullscreen": "全屏",
"form.prefs.select.minimal_ui": "最小",
"form.prefs.select.none": "没有任何",
"form.prefs.select.older_first": "旧->新",
"form.prefs.select.publish_time": "条目发布时间",
"form.prefs.select.recent_first": "新->旧",
"form.prefs.select.standalone": "独立",
"form.prefs.select.swipe": "滑动",
"form.prefs.select.tap": "双击",
"form.prefs.select.unread_count": "未读计数",
"form.submit.loading": "加载中…",
"form.submit.saving": "保存中…",
"form.user.label.admin": "管理员",
"form.user.label.confirmation": "确认密码",
"form.user.label.password": "密码",
"form.user.label.username": "用户名",
"menu.about": "关于",
"menu.add_feed": "添加订阅源",
"menu.add_user": "添加用户",
"menu.api_keys": "API 密钥",
"menu.categories": "分类",
"menu.create_api_key": "创建新 API 密钥",
"menu.create_category": "创建分类",
"menu.edit_category": "编辑",
"menu.edit_feed": "编辑",
"menu.export": "导出",
"menu.feed_entries": "条目",
"menu.feeds": "订阅源",
"menu.flush_history": "清除历史记录",
"menu.history": "历史记录",
"menu.home_page": "主页",
"menu.import": "导入",
"menu.integrations": "集成",
"menu.logout": "登出",
"menu.mark_all_as_read": "全部标为已读",
"menu.mark_page_as_read": "将此页标为已读",
"menu.preferences": "偏好设置",
"menu.refresh_all_feeds": "后台刷新所有订阅源",
"menu.refresh_feed": "刷新",
"menu.search": "搜索",
"menu.sessions": "会话",
"menu.settings": "设置",
"menu.shared_entries": "已共享的条目",
"menu.show_all_entries": "显示所有条目",
"menu.show_only_starred_entries": "仅显示已收藏条目",
"menu.show_only_unread_entries": "仅显示未读条目",
"menu.starred": "收藏",
"menu.title": "菜单",
"menu.unread": "未读",
"menu.users": "用户",
"page.about.author": "作者:",
"page.about.build_date": "构建日期:",
"page.about.credits": "鸣谢",
"page.about.db_usage": "数据库大小:",
"page.about.git_commit": "Git 提交:",
"page.about.global_config_options": "全局配置选项",
"page.about.go_version": "Go 版本:",
"page.about.license": "许可证:",
"page.about.postgres_version": "Postgres 版本:",
"page.about.title": "关于",
"page.about.version": "版本:",
"page.add_feed.choose_feed": "选择订阅源",
"page.add_feed.label.url": "URL",
"page.add_feed.legend.advanced_options": "高级选项",
"page.add_feed.no_category": "没有分类。您必须至少有一个分类。",
"page.add_feed.submit": "查找订阅源",
"page.add_feed.title": "新建订阅源",
"page.api_keys.never_used": "从未使用",
"page.api_keys.table.actions": "操作",
"page.api_keys.table.created_at": "创建日期",
"page.api_keys.table.description": "描述",
"page.api_keys.table.last_used_at": "最后使用",
"page.api_keys.table.token": "令牌",
"page.api_keys.title": "API 密钥",
"page.categories.entries": "条目",
"page.categories.feed_count": [
"有 %d 个订阅源"
],
"page.categories.feeds": "订阅源",
"page.categories.no_feed": "无订阅源。",
"page.categories.title": "分类",
"page.categories_count": [
"%d 个分类"
],
"page.category_label": "分类: %s",
"page.edit_category.title": "编辑分类:%s",
"page.edit_feed.etag_header": "ETag 标题:",
"page.edit_feed.last_check": "最后检查时间:",
"page.edit_feed.last_modified_header": "最后修改的 Header:",
"page.edit_feed.last_parsing_error": "最后一次解析错误",
"page.edit_feed.no_header": "无 Header",
"page.edit_feed.title": "编辑订阅源: %s",
"page.edit_user.title": "编辑用户: %s",
"page.entry.attachments": "附件",
"page.feeds.error_count": [
"%d 错误"
],
"page.feeds.last_check": "最后检查:",
"page.feeds.next_check": "下次检查:",
"page.feeds.read_counter": "已读条目数",
"page.feeds.title": "订阅源",
"page.footer.elevator": "返回顶部",
"page.history.title": "历史记录",
"page.import.title": "导入",
"page.integration.bookmarklet": "书签小应用",
"page.integration.bookmarklet.help": "此链接允许您通过浏览器书签直接订阅网站。",
"page.integration.bookmarklet.instructions": "将此链接拖动到您的书签栏。",
"page.integration.bookmarklet.name": "添加到 Miniflux",
"page.integration.miniflux_api": "Miniflux API",
"page.integration.miniflux_api_endpoint": "API 端点",
"page.integration.miniflux_api_password": "密码",
"page.integration.miniflux_api_password_value": "您账号的密码",
"page.integration.miniflux_api_username": "用户名",
"page.integrations.title": "集成",
"page.keyboard_shortcuts.close_modal": "关闭对话窗口",
"page.keyboard_shortcuts.download_content": "下载原始内容",
"page.keyboard_shortcuts.go_to_bottom_item": "跳转到最后一条",
"page.keyboard_shortcuts.go_to_categories": "转到分类",
"page.keyboard_shortcuts.go_to_feed": "转到订阅源",
"page.keyboard_shortcuts.go_to_feeds": "转到订阅源列表",
"page.keyboard_shortcuts.go_to_history": "转到历史记录",
"page.keyboard_shortcuts.go_to_next_item": "转到下一条目",
"page.keyboard_shortcuts.go_to_next_page": "转到下一页",
"page.keyboard_shortcuts.go_to_previous_item": "转到上一条目",
"page.keyboard_shortcuts.go_to_previous_page": "转到上一页",
"page.keyboard_shortcuts.go_to_search": "聚焦到搜索框",
"page.keyboard_shortcuts.go_to_settings": "转到设置",
"page.keyboard_shortcuts.go_to_starred": "转到收藏",
"page.keyboard_shortcuts.go_to_top_item": "转到第一条",
"page.keyboard_shortcuts.go_to_unread": "转到未读",
"page.keyboard_shortcuts.mark_page_as_read": "标记当前页为已读",
"page.keyboard_shortcuts.open_comments": "打开评论链接",
"page.keyboard_shortcuts.open_comments_same_window": "在当前标签页中打开评论链接",
"page.keyboard_shortcuts.open_item": "打开选定的条目",
"page.keyboard_shortcuts.open_original": "打开原始链接",
"page.keyboard_shortcuts.open_original_same_window": "在当前标签页中打开原始链接",
"page.keyboard_shortcuts.refresh_all_feeds": "在后台刷新全部订阅源",
"page.keyboard_shortcuts.remove_feed": "移除此订阅源",
"page.keyboard_shortcuts.save_article": "保存条目",
"page.keyboard_shortcuts.scroll_item_to_top": "滚动到顶部",
"page.keyboard_shortcuts.show_keyboard_shortcuts": "显示快捷键帮助",
"page.keyboard_shortcuts.subtitle.actions": "操作",
"page.keyboard_shortcuts.subtitle.items": "条目导航",
"page.keyboard_shortcuts.subtitle.pages": "页面导航",
"page.keyboard_shortcuts.subtitle.sections": "区域导航",
"page.keyboard_shortcuts.title": "键盘快捷键",
"page.keyboard_shortcuts.toggle_star_status": "切换收藏状态",
"page.keyboard_shortcuts.toggle_entry_attachments": "切换展开/折叠条目附件",
"page.keyboard_shortcuts.toggle_read_status_next": "切换已读/未读状态,并切换到下一项",
"page.keyboard_shortcuts.toggle_read_status_prev": "切换已读/未读状态,并切换到上一项",
"page.login.google_signin": "使用 Google 登录",
"page.login.oidc_signin": "使用 %s 登录",
"page.login.title": "登录",
"page.login.webauthn_login": "使用通行密钥登录",
"page.login.webauthn_login.error": "无法使用通行密钥登录",
"page.login.webauthn_login.help": "如果您正在使用安全密钥,请输入您的用户名。如果您正在使用通行密钥(可发现凭证),则无需输入。",
"page.new_api_key.title": "新的 API 密钥",
"page.new_category.title": "新建分类",
"page.new_user.title": "新建用户",
"page.offline.message": "您已离线",
"page.offline.refresh_page": "尝试刷新页面",
"page.offline.title": "离线模式",
"page.read_entry_count": [
"%d 个已读条目"
],
"page.search.title": "搜索结果",
"page.sessions.table.actions": "操作",
"page.sessions.table.current_session": "当前会话",
"page.sessions.table.date": "日期",
"page.sessions.table.ip": "IP 地址",
"page.sessions.table.user_agent": "用户代理",
"page.sessions.title": "会话",
"page.settings.link_google_account": "关联我的 Google 账号",
"page.settings.link_oidc_account": "关联我的 %s 账号",
"page.settings.title": "设置",
"page.settings.unlink_google_account": "解除 Google 账号关联",
"page.settings.unlink_oidc_account": "解除 %s 账号关联",
"page.settings.webauthn.actions": "操作",
"page.settings.webauthn.added_on": "添加于",
"page.settings.webauthn.delete": [
"删除 %d 个通行密钥"
],
"page.settings.webauthn.last_seen_on": "最后使用",
"page.settings.webauthn.passkey_name": "通行密钥名称",
"page.settings.webauthn.passkeys": "通行密钥",
"page.settings.webauthn.register": "注册通行密钥",
"page.settings.webauthn.register.error": "无法注册通行密钥",
"page.shared_entries.title": "已共享的条目",
"page.shared_entries_count": [
"%d 个共享条目"
],
"page.starred.title": "收藏",
"page.starred_entry_count": [
"%d 个收藏条目"
],
"page.total_entry_count": [
"%d 个条目"
],
"page.unread.title": "未读",
"page.unread_entry_count": [
"%d 个未读条目"
],
"page.users.actions": "操作",
"page.users.admin.no": "否",
"page.users.admin.yes": "是",
"page.users.is_admin": "管理员",
"page.users.last_login": "最后登录",
"page.users.never_logged": "从未",
"page.users.title": "用户",
"page.users.username": "用户名",
"page.webauthn_rename.title": "重命名通行密钥",
"pagination.first": "第一页",
"pagination.last": "最后一页",
"pagination.next": "下一页",
"pagination.previous": "上一页",
"search.label": "搜索",
"search.placeholder": "搜索…",
"search.submit": "搜索",
"skip_to_content": "跳转至内容",
"time_elapsed.days": [
"%d 天前"
],
"time_elapsed.hours": [
"%d 小时前"
],
"time_elapsed.minutes": [
"%d 分钟前"
],
"time_elapsed.months": [
"%d 月前"
],
"time_elapsed.not_yet": "未来",
"time_elapsed.now": "刚刚",
"time_elapsed.weeks": [
"%d 周前"
],
"time_elapsed.years": [
"%d 年前"
],
"time_elapsed.yesterday": "昨天",
"tooltip.keyboard_shortcuts": "键盘快捷键:%s",
"tooltip.logged_user": "登录用户:%s"
}
v2-2.2.16/internal/locale/translations/zh_TW.json 0000664 0000000 0000000 00000105443 15127074645 0021675 0 ustar 00root root 0000000 0000000 {
"action.cancel": "取消",
"action.download": "下載",
"action.edit": "編輯",
"action.home_screen": "新增到主螢幕",
"action.import": "匯入",
"action.login": "登入",
"action.or": "或",
"action.remove": "刪除",
"action.remove_feed": "刪除此 Feed",
"action.save": "儲存",
"action.subscribe": "訂閱",
"action.update": "更新",
"alert.account_linked": "您的外部帳號已成功關聯!",
"alert.account_unlinked": "您的外部帳戶已解除關聯!",
"alert.background_feed_refresh": "所有 Feed 正在背景中更新,您可以繼續使用 Miniflux。",
"alert.feed_error": "該 Feed 存在問題",
"alert.no_starred": "目前沒有收藏",
"alert.no_category": "目前沒有分類",
"alert.no_category_entry": "該分類下沒有文章",
"alert.no_feed": "目前沒有 Feed",
"alert.no_feed_entry": "該 Feed 中沒有文章",
"alert.no_feed_in_category": "沒有該類別的 Feed。",
"alert.no_history": "目前沒有歷史",
"alert.no_search_result": "沒有符合搜尋的結果",
"alert.no_shared_entry": "沒有分享文章。",
"alert.no_tag_entry": "沒有與此標籤相符的文章。",
"alert.no_unread_entry": "目前沒有未讀文章",
"alert.no_user": "您是唯一的使用者",
"alert.prefs_saved": "設定已儲存!",
"alert.too_many_feeds_refresh": [
"您已觸發過太多次 Feed 更新,請等待 %d 分鐘後再嘗試。"
],
"confirm.loading": "執行中…",
"confirm.no": "否",
"confirm.question": "您確定嗎?",
"confirm.question.refresh": "您想要強制重新整理嗎?",
"confirm.yes": "是",
"enclosure_media_controls.seek": "移動:",
"enclosure_media_controls.seek.title": "移動 %s 秒",
"enclosure_media_controls.speed": "速度:",
"enclosure_media_controls.speed.faster": "加快",
"enclosure_media_controls.speed.faster.title": "加快 %sx",
"enclosure_media_controls.speed.reset": "重設",
"enclosure_media_controls.speed.reset.title": "重設播放速度為 1x",
"enclosure_media_controls.speed.slower": "放慢",
"enclosure_media_controls.speed.slower.title": "放慢 %sx",
"entry.starred.toast.off": "已取消收藏",
"entry.starred.toast.on": "已新增收藏",
"entry.starred.toggle.off": "取消收藏",
"entry.starred.toggle.on": "新增收藏",
"entry.comments.label": "評論",
"entry.comments.title": "檢視評論",
"entry.estimated_reading_time": [
"需要 %d 分鐘閱讀"
],
"entry.external_link.label": "外部連結",
"entry.save.completed": "完成",
"entry.save.label": "儲存",
"entry.save.title": "儲存這篇文章",
"entry.save.toast.completed": "已儲存文章",
"entry.scraper.completed": "下載完成",
"entry.scraper.label": "下載原文",
"entry.scraper.title": "下載原文內容",
"entry.share.label": "分享",
"entry.share.title": "分享這篇文章",
"entry.shared_entry.label": "分享",
"entry.shared_entry.title": "開啟公共連結",
"entry.state.loading": "載入中…",
"entry.state.saving": "儲存中…",
"entry.status.mark_as_read": "標記為已讀",
"entry.status.mark_as_unread": "標記為未讀",
"entry.status.title": "更改狀態",
"entry.status.toast.read": "已標記為已讀",
"entry.status.toast.unread": "已標記為未讀",
"entry.tags.label": "標籤:",
"entry.tags.more_tags_label": [
"還有 %d 個標籤"
],
"entry.unshare.label": "取消分享",
"error.api_key_already_exists": "此 API 金鑰已存在。",
"error.bad_credentials": "使用者名稱或密碼無效",
"error.category_already_exists": "分類已存在",
"error.category_not_found": "此分類不存在或不屬於您。",
"error.database_error": "資料庫錯誤:%v。",
"error.different_passwords": "兩次輸入的密碼不同",
"error.duplicate_fever_username": "Fever 使用者名稱已被佔用!",
"error.duplicate_googlereader_username": "Google Reader 使用者名稱已被佔用!",
"error.duplicate_linked_account": "該提供者已被其他人綁定!",
"error.duplicated_feed": "該 Feed 已存在。",
"error.empty_file": "該檔案為空",
"error.entries_per_page_invalid": "每頁的文章數無效。",
"error.feed_already_exists": "此 Feed 已存在。",
"error.feed_category_not_found": "此類別不存在或不屬於該使用者。",
"error.feed_format_not_detected": "無法辨識 Feed 格式:%v。",
"error.feed_invalid_blocklist_rule": "阻擋規則無效。",
"error.feed_invalid_keeplist_rule": "保留規則無效。",
"error.feed_mandatory_fields": "必須填寫網址和分類",
"error.feed_not_found": "無法找到此 Feed 或不屬於您。",
"error.feed_title_not_empty": "訂閱的標題不能為空。",
"error.feed_url_not_empty": "訂閱網址不能為空。",
"error.fields_mandatory": "必須填寫全部資訊",
"error.http_bad_gateway": "此網站目前因閘道錯誤無法使用,問題不在 Miniflux,請稍後重試。",
"error.http_body_read": "無法讀取 HTTP 本體內容:%v。",
"error.http_client_error": "HTTP 客戶端錯誤:%v。",
"error.http_empty_response": "HTTP 回應內容為空,可能該網站有防護機制。",
"error.http_empty_response_body": "HTTP 回應本體為空。",
"error.http_forbidden": "拒絕存取此網站,可能該網站有防護機制。",
"error.http_gateway_timeout": "此網站回應逾時,問題不在 Miniflux,請稍後重試。",
"error.http_internal_server_error": "此網站目前因伺服器錯誤無法使用,問題不在 Miniflux,請稍後重試。",
"error.http_not_authorized": "未授權存取此網站,請檢查使用者名稱與密碼。",
"error.http_resource_not_found": "找不到該連結,請確認網址是否正確。",
"error.http_response_too_large": "HTTP 回應過大。您可以在全域設定中提高上限 (需重啟伺服器)。",
"error.http_service_unavailable": "此網站目前因內部問題無法使用,問題不在 Miniflux,請稍後重試。",
"error.http_too_many_requests": "Miniflux 對此網站的請求過多,請稍後重試或調整程式設定。",
"error.http_unexpected_status_code": "此網站回應了意外的 HTTP 狀態碼:%d,請稍後重試。",
"error.invalid_categories_sorting_order": "無效的分類排序",
"error.invalid_default_home_page": "預設主頁無效!",
"error.invalid_display_mode": "無效的顯示模式。",
"error.invalid_entry_direction": "無效的輸入方向。",
"error.invalid_entry_order": "無效的文章排序依據。",
"error.invalid_feed_proxy_url": "代理伺服器網址無效。",
"error.invalid_feed_url": "訂閱網址無效。",
"error.invalid_gesture_nav": "手勢導覽無效。",
"error.invalid_language": "無效的語言。",
"error.invalid_site_url": "Feed 網站的網址無效。",
"error.invalid_theme": "無效的主題。",
"error.invalid_timezone": "無效的時區。",
"error.network_operation": "Miniflux 無法連線到該網站,可能是網路問題:%v。",
"error.network_timeout": "該網站回應過慢,請求逾時:%v。",
"error.password_min_length": "請至少輸入 6 個字元",
"error.proxy_url_not_empty": "代理伺服器網址不能為空。",
"error.settings_block_rule_fieldname_invalid": "無效的封鎖規則:規則 #%d 缺少有效的欄位名稱 (可用選項:%s)",
"error.settings_block_rule_invalid_regex": "無效的封鎖規則:規則 #%d 的模式不是合法的正規表示式",
"error.settings_block_rule_regex_required": "無效的封鎖規則:規則 #%d 沒有提供正規表示式",
"error.settings_block_rule_separator_required": "無效的封鎖規則:規則 #%d 的模式必須用 '=' 分隔",
"error.settings_invalid_domain_list": "網域清單無效。請以空白分隔多個網域。",
"error.settings_keep_rule_fieldname_invalid": "無效的保留規則:規則 #%d 缺少有效的欄位名稱 (可用選項:%s)",
"error.settings_keep_rule_invalid_regex": "無效的保留規則:規則 #%d 的模式不是合法的正規表示式",
"error.settings_keep_rule_regex_required": "無效的保留規則:規則 #%d 沒有提供正規表示式",
"error.settings_keep_rule_separator_required": "無效的保留規則:規則 #%d 的模式必須用 '=' 分隔",
"error.settings_mandatory_fields": "必須填寫使用者名稱、主題、語言以及時區",
"error.settings_media_playback_rate_range": "播放速度超出範圍",
"error.settings_reading_speed_is_positive": "閱讀速度必須是正整數。",
"error.site_url_not_empty": "Feed 網站的網址不能為空。",
"error.subscription_not_found": "找不到任何訂閱",
"error.title_required": "必須填寫標題",
"error.tls_error": "TLS 錯誤:%q。若需忽略 TLS 驗證,可在 Feed 設定中停用。",
"error.unable_to_create_api_key": "無法建立此 API 金鑰。",
"error.unable_to_create_category": "無法建立這個分類",
"error.unable_to_create_user": "無法建立此使用者",
"error.unable_to_detect_rssbridge": "使用 RSS-Bridge 無法找到任何訂閱:%v。",
"error.unable_to_parse_feed": "無法解析此 Feed:%v。",
"error.unable_to_update_category": "無法更新該分類",
"error.unable_to_update_feed": "無法更新此 Feed",
"error.unable_to_update_user": "無法更新此使用者",
"error.unlink_account_without_password": "您必須設定密碼,否則您將無法再次登入。",
"error.user_already_exists": "使用者已存在",
"error.user_mandatory_fields": "必須填寫使用者名稱",
"error.linktaco_missing_required_fields": "LinkTaco API Token 和 Organization Slug 是必需的",
"form.api_key.label.description": "API 金鑰標籤",
"form.category.hide_globally": "在全域未讀列表中隱藏文章",
"form.category.label.title": "標題",
"form.feed.fieldset.general": "通用",
"form.feed.fieldset.integration": "第三方服務",
"form.feed.fieldset.network_settings": "網路設定",
"form.feed.fieldset.rules": "規則",
"form.feed.label.allow_self_signed_certificates": "允許自簽或無效的憑證",
"form.feed.label.apprise_service_urls": "使用逗號分隔的 Apprise 服務網址列表",
"form.feed.label.block_filter_entry_rules": "條目封鎖規則",
"form.feed.label.blocklist_rules": "基於正則表達式的封鎖過濾器",
"form.feed.label.category": "類別",
"form.feed.label.cookie": "設定 Cookies",
"form.feed.label.crawler": "下載原文內容",
"form.feed.label.description": "描述",
"form.feed.label.disable_http2": "停用 HTTP/2 以避免指紋追蹤",
"form.feed.label.disabled": "不要更新此 Feed",
"form.feed.label.feed_password": "Feed 密碼",
"form.feed.label.feed_url": "Feed 網址",
"form.feed.label.feed_username": "Feed 使用者名稱",
"form.feed.label.fetch_via_proxy": "使用應用程式層級設定的代理",
"form.feed.label.hide_globally": "在全域未讀列表中隱藏文章",
"form.feed.label.ignore_http_cache": "忽略 HTTP 快取",
"form.feed.label.keep_filter_entry_rules": "條目允許規則",
"form.feed.label.keeplist_rules": "基於正則表達式的保留過濾器",
"form.feed.label.no_media_player": "無媒體播放器 (音訊/視訊)",
"form.feed.label.ntfy_activate": "推送文章到 ntfy",
"form.feed.label.ntfy_default_priority": "Ntfy 預設優先順序",
"form.feed.label.ntfy_high_priority": "Ntfy 高優先順序",
"form.feed.label.ntfy_low_priority": "Ntfy 低優先順序",
"form.feed.label.ntfy_max_priority": "Ntfy 最高優先順序",
"form.feed.label.ntfy_min_priority": "Ntfy 最低優先順序",
"form.feed.label.ntfy_priority": "Ntfy 優先順序",
"form.feed.label.ntfy_topic": "Ntfy topic (選填)",
"form.feed.label.proxy_url": "代理URL",
"form.feed.label.pushover_activate": "推送文章到 Pushover",
"form.feed.label.pushover_default_priority": "Pushover 預設優先順序",
"form.feed.label.pushover_high_priority": "Pushover 高優先順序",
"form.feed.label.pushover_low_priority": "Pushover 低優先順序",
"form.feed.label.pushover_max_priority": "Pushover 最高優先順序",
"form.feed.label.pushover_min_priority": "Pushover 最低優先順序",
"form.feed.label.pushover_priority": "Pushover 訊息優先順序",
"form.feed.label.rewrite_rules": "內容重寫規則",
"form.feed.label.scraper_rules": "抓取規則",
"form.feed.label.site_url": "網站網址",
"form.feed.label.title": "標題",
"form.feed.label.urlrewrite_rules": "網址重寫規則",
"form.feed.label.user_agent": "覆蓋預設的使用者代理",
"form.feed.label.webhook_url": "覆蓋webhook URL",
"form.import.label.file": "OPML 檔案",
"form.import.label.url": "URL",
"form.integration.archiveorg_activate": "推送文章到 archive.org",
"form.integration.apprise_activate": "推送文章到 Apprise",
"form.integration.apprise_services_url": "使用逗號分隔的 Apprise 服務網址列表",
"form.integration.apprise_url": "Apprise API 網址",
"form.integration.betula_activate": "儲存文章到 Betula",
"form.integration.betula_token": "Betula令牌",
"form.integration.betula_url": "Betula 伺服器網址",
"form.integration.cubox_activate": "儲存文章到 Cubox",
"form.integration.cubox_api_link": "Cubox API 連結",
"form.integration.discord_activate": "推送文章到 Discord",
"form.integration.discord_webhook_link": "Discord Webhook 連結",
"form.integration.espial_activate": "儲存文章到 Espial",
"form.integration.espial_api_key": "Espial API 金鑰",
"form.integration.espial_endpoint": "Espial API 端點",
"form.integration.espial_tags": "Espial 標籤",
"form.integration.fever_activate": "啟用 Fever API",
"form.integration.fever_endpoint": "Fever API 端點",
"form.integration.fever_password": "Fever 密碼",
"form.integration.fever_username": "Fever 使用者名稱",
"form.integration.googlereader_activate": "啟用 Google Reader API",
"form.integration.googlereader_endpoint": "Google Reader API 端點:",
"form.integration.googlereader_password": "Google Reader 密碼",
"form.integration.googlereader_username": "Google Reader 使用者名稱",
"form.integration.instapaper_activate": "儲存文章到 Instapaper",
"form.integration.instapaper_password": "Instapaper 密碼",
"form.integration.instapaper_username": "Instapaper 使用者名稱",
"form.integration.karakeep_activate": "儲存文章到 Karakeep",
"form.integration.karakeep_api_key": "Karakeep API 金鑰",
"form.integration.karakeep_url": "Karakeep API 端點",
"form.integration.karakeep_tags": "Karakeep 標籤",
"form.integration.linkace_activate": "儲存文章到 LinkAce",
"form.integration.linkace_api_key": "LinkAce API 金鑰",
"form.integration.linkace_check_disabled": "停用連結檢查",
"form.integration.linkace_endpoint": "LinkAce API 端點",
"form.integration.linkace_is_private": "標記為私人連結",
"form.integration.linkace_tags": "LinkAce 標籤",
"form.integration.linkding_activate": "儲存文章到 Linkding",
"form.integration.linkding_api_key": "Linkding API 金鑰",
"form.integration.linkding_bookmark": "標記為未讀",
"form.integration.linkding_endpoint": "Linkding API 端點",
"form.integration.linkding_tags": "Linkding 標籤",
"form.integration.linktaco_activate": "儲存文章到 LinkTaco",
"form.integration.linktaco_api_token": "LinkTaco API Token",
"form.integration.linktaco_api_token_hint": "在此取得您的個人存取權杖",
"form.integration.linktaco_org_slug": "組織代稱",
"form.integration.linktaco_tags": "標籤(最多10個,逗號分隔)",
"form.integration.linktaco_tags_hint": "最多10個標籤,逗號分隔",
"form.integration.linktaco_visibility": "可見性",
"form.integration.linktaco_visibility_public": "公開",
"form.integration.linktaco_visibility_private": "私人",
"form.integration.linktaco_visibility_hint": "私人可見性需要付費的 LinkTaco 帳戶",
"form.integration.linkwarden_activate": "儲存文章到 Linkwarden",
"form.integration.linkwarden_api_key": "Linkwarden API 金鑰",
"form.integration.linkwarden_endpoint": "Linkwarden 基本 URL",
"form.integration.linkwarden_collection_id": "Linkwarden Collection ID",
"form.integration.matrix_bot_activate": "推送文章到 Matrix",
"form.integration.matrix_bot_chat_id": "Matrix 房間 ID",
"form.integration.matrix_bot_password": "Matrix 密碼",
"form.integration.matrix_bot_url": "Matrix 伺服器網址",
"form.integration.matrix_bot_user": "Matrix 使用者名稱",
"form.integration.notion_activate": "儲存文章到 Notion",
"form.integration.notion_page_id": "Notion Page ID",
"form.integration.notion_token": "Notion Secret Token",
"form.integration.ntfy_activate": "推送文章到 Ntfy",
"form.integration.ntfy_api_token": "Ntfy API 金鑰 (選填)",
"form.integration.ntfy_icon_url": "Ntfy Icon 網址 (選填)",
"form.integration.ntfy_internal_links": "點選時使用內部連結 (選填)",
"form.integration.ntfy_password": "Ntfy 密碼 (選填)",
"form.integration.ntfy_topic": "Ntfy topic (飼料若無設定,則使用預設值)",
"form.integration.ntfy_url": "Ntfy 網址 (選填,預設為 ntfy.sh)",
"form.integration.ntfy_username": "Ntfy 使用者名稱 (選填)",
"form.integration.nunux_keeper_activate": "儲存文章到 Nunux Keeper",
"form.integration.nunux_keeper_api_key": "Nunux Keeper API 金鑰",
"form.integration.nunux_keeper_endpoint": "Nunux Keeper API 端點",
"form.integration.omnivore_activate": "儲存文章到 Omnivore",
"form.integration.omnivore_api_key": "Omnivore API 金鑰",
"form.integration.omnivore_url": "Omnivore API 端點",
"form.integration.pinboard_activate": "儲存文章到 Pinboard",
"form.integration.pinboard_bookmark": "標記為未讀",
"form.integration.pinboard_tags": "Pinboard 標籤",
"form.integration.pinboard_token": "Pinboard API Token",
"form.integration.pushover_activate": "推送文章到 Pushover",
"form.integration.pushover_device": "Pushover 裝置(選填)",
"form.integration.pushover_prefix": "Pushover URL 前綴(選填)",
"form.integration.pushover_token": "Pushover 應用程式 API 金鑰",
"form.integration.pushover_user": "Pushover 使用者金鑰",
"form.integration.raindrop_activate": "儲存文章到 Raindrop",
"form.integration.raindrop_collection_id": "Collection ID",
"form.integration.raindrop_tags": "標籤 (以逗號分隔)",
"form.integration.raindrop_token": "Raindrop 存取金鑰",
"form.integration.readeck_activate": "儲存文章到 Readeck",
"form.integration.readeck_api_key": "Readeck API 金鑰",
"form.integration.readeck_endpoint": "Readeck API 端點",
"form.integration.readeck_labels": "Readeck 標籤",
"form.integration.readeck_only_url": "僅傳送網址(而不是完整內容)",
"form.integration.readeck_push_activate": "自動將新文章推送到 Readeck",
"form.integration.readwise_activate": "儲存文章到 Readwise Reader",
"form.integration.readwise_api_key": "Readwise Reader 存取金鑰",
"form.integration.readwise_api_key_link": "取得您的 Readwise 存取金鑰",
"form.integration.rssbridge_activate": "新增訂閱時檢查 RSS-Bridge",
"form.integration.rssbridge_token": "RSS-Bridge 驗證權杖",
"form.integration.rssbridge_url": "RSS-Bridge 伺服器的網址",
"form.integration.shaarli_activate": "儲存文章到 Shaarli",
"form.integration.shaarli_api_secret": "Shaarli API 金鑰",
"form.integration.shaarli_endpoint": "Shaarli 網址",
"form.integration.shiori_activate": "儲存文章到 Shiori",
"form.integration.shiori_endpoint": "Shiori API 端點",
"form.integration.shiori_password": "Shiori 密碼",
"form.integration.shiori_username": "Shiori 使用者名稱",
"form.integration.slack_activate": "推送文章到 Slack",
"form.integration.slack_webhook_link": "Slack Webhook 連結",
"form.integration.telegram_bot_activate": "推送文章到 Telegram",
"form.integration.telegram_bot_disable_buttons": "不顯示按鈕",
"form.integration.telegram_bot_disable_notification": "停用通知",
"form.integration.telegram_bot_disable_web_page_preview": "停用網頁預覽",
"form.integration.telegram_bot_token": "Bot Token",
"form.integration.telegram_chat_id": "Chat ID",
"form.integration.telegram_topic_id": "Topic ID",
"form.integration.wallabag_activate": "儲存文章到 Wallabag",
"form.integration.wallabag_client_id": "Wallabag 客戶端 ID",
"form.integration.wallabag_client_secret": "Wallabag 客戶端金鑰",
"form.integration.wallabag_endpoint": "Wallabag 基本網址",
"form.integration.wallabag_only_url": "僅傳送網址(而不是完整內容)",
"form.integration.wallabag_password": "Wallabag 密碼",
"form.integration.wallabag_username": "Wallabag 使用者名稱",
"form.integration.wallabag_tags": "Wallabag Tags",
"form.integration.webhook_activate": "啟用 Webhooks",
"form.integration.webhook_secret": "Webhooks Secret",
"form.integration.webhook_url": "Default Webhook 網址",
"form.prefs.fieldset.application_settings": "應用程式設定",
"form.prefs.fieldset.authentication_settings": "使用者認證設定",
"form.prefs.fieldset.global_feed_settings": "全域 Feed 設定",
"form.prefs.fieldset.reader_settings": "閱讀器設定",
"form.prefs.help.external_font_hosts": "以空白分隔允許的外部字型來源。例如:「fonts.gstatic.com fonts.googleapis.com」。",
"form.prefs.label.always_open_external_links": "開啟外部連結閱讀文章",
"form.prefs.label.categories_sorting_order": "分類排序",
"form.prefs.label.cjk_reading_speed": "中文、韓文和日文的閱讀速度(每分鐘字元數)",
"form.prefs.label.custom_css": "自訂 CSS",
"form.prefs.label.custom_js": "自訂 JavaScript",
"form.prefs.label.default_home_page": "預設主頁",
"form.prefs.label.default_reading_speed": "其他語言的閱讀速度(每分鐘字)",
"form.prefs.label.display_mode": "漸進式網路應用程式(PWA)顯示模式",
"form.prefs.label.entries_per_page": "每頁文章數",
"form.prefs.label.entry_order": "文章排序依據",
"form.prefs.label.entry_sorting": "文章排序",
"form.prefs.label.entry_swipe": "在觸控式螢幕上啟用文章滑動",
"form.prefs.label.external_font_hosts": "外部字型來源",
"form.prefs.label.gesture_nav": "在文章之間導覽的手勢",
"form.prefs.label.keyboard_shortcuts": "啟用鍵盤快捷鍵",
"form.prefs.label.language": "語言",
"form.prefs.label.mark_read_manually": "僅手動標記為已讀",
"form.prefs.label.mark_read_on_media_completion": "僅在音訊/視訊播放達 90% 時標記為已讀",
"form.prefs.label.mark_read_on_view": "檢視時自動將文章標記為已讀",
"form.prefs.label.mark_read_on_view_or_media_completion": "檢視文章即標記為已讀;若是音訊/視訊則在 90% 播放完成時標記",
"form.prefs.label.media_playback_rate": "音訊/視訊播放速度",
"form.prefs.label.open_external_links_in_new_tab": "在新分頁中開啟外部連結(為連結加上 target=\"_blank\")",
"form.prefs.label.show_reading_time": "顯示文章的預計閱讀時間",
"form.prefs.label.theme": "主題",
"form.prefs.label.timezone": "時區",
"form.prefs.select.alphabetical": "按字母順序",
"form.prefs.select.browser": "瀏覽器",
"form.prefs.select.created_time": "文章建立時間",
"form.prefs.select.fullscreen": "全螢幕",
"form.prefs.select.minimal_ui": "最小",
"form.prefs.select.none": "無",
"form.prefs.select.older_first": "舊→新",
"form.prefs.select.publish_time": "文章發布時間",
"form.prefs.select.recent_first": "新→舊",
"form.prefs.select.standalone": "獨立",
"form.prefs.select.swipe": "滑動",
"form.prefs.select.tap": "雙擊",
"form.prefs.select.unread_count": "未讀計數",
"form.submit.loading": "載入中…",
"form.submit.saving": "儲存中…",
"form.user.label.admin": "管理員",
"form.user.label.confirmation": "再次輸入密碼",
"form.user.label.password": "密碼",
"form.user.label.username": "使用者名稱",
"menu.about": "關於",
"menu.add_feed": "新增 Feed",
"menu.add_user": "新建使用者",
"menu.api_keys": "API 金鑰",
"menu.categories": "分類",
"menu.create_api_key": "建立一個新的 API 金鑰",
"menu.create_category": "新建分類",
"menu.edit_category": "編輯",
"menu.edit_feed": "編輯",
"menu.export": "匯出",
"menu.feed_entries": "文章",
"menu.feeds": "Feeds",
"menu.flush_history": "清理歷史",
"menu.history": "歷史",
"menu.home_page": "主頁",
"menu.import": "匯入",
"menu.integrations": "整合",
"menu.logout": "登出",
"menu.mark_all_as_read": "全部標為已讀",
"menu.mark_page_as_read": "將此頁面標記為已讀",
"menu.preferences": "設定",
"menu.refresh_all_feeds": "在背景更新所有 Feed",
"menu.refresh_feed": "更新",
"menu.search": "搜尋",
"menu.sessions": "工作階段",
"menu.settings": "設定",
"menu.shared_entries": "已分享的文章",
"menu.show_all_entries": "顯示所有文章",
"menu.show_only_starred_entries": "僅顯示收藏文章",
"menu.show_only_unread_entries": "僅顯示未讀文章",
"menu.starred": "收藏",
"menu.title": "導覽",
"menu.unread": "未讀",
"menu.users": "使用者",
"page.about.author": "作者:",
"page.about.build_date": "建構日期:",
"page.about.credits": "版權",
"page.about.db_usage": "資料庫大小:",
"page.about.git_commit": "Git 提交:",
"page.about.global_config_options": "全域設定選項",
"page.about.go_version": "Go 版本:",
"page.about.license": "授權:",
"page.about.postgres_version": "Postgres 版本:",
"page.about.title": "關於",
"page.about.version": "版本:",
"page.add_feed.choose_feed": "選擇一個 Feed",
"page.add_feed.label.url": "網址",
"page.add_feed.legend.advanced_options": "進階選項",
"page.add_feed.no_category": "沒有類別,至少需要有一個類別",
"page.add_feed.submit": "查詢 Feed",
"page.add_feed.title": "新增 Feed",
"page.api_keys.never_used": "沒用過",
"page.api_keys.table.actions": "操作",
"page.api_keys.table.created_at": "建立日期",
"page.api_keys.table.description": "描述",
"page.api_keys.table.last_used_at": "最後使用",
"page.api_keys.table.token": "金鑰",
"page.api_keys.title": "API 金鑰",
"page.categories.entries": "檢視內容",
"page.categories.feed_count": [
"有 %d 個 Feed"
],
"page.categories.feeds": "檢視 Feeds",
"page.categories.no_feed": "沒有 Feed",
"page.categories.title": "分類",
"page.categories_count": [
"%d 個分類"
],
"page.category_label": "分類:%s",
"page.edit_category.title": "編輯分類 : %s",
"page.edit_feed.etag_header": "ETag 標頭:",
"page.edit_feed.last_check": "最後檢查時間:",
"page.edit_feed.last_modified_header": "最後修改的 Header:",
"page.edit_feed.last_parsing_error": "最後一次解析錯誤",
"page.edit_feed.no_header": "無",
"page.edit_feed.title": "編輯 Feed : %s",
"page.edit_user.title": "編輯使用者 : %s",
"page.entry.attachments": "附件",
"page.feeds.error_count": [
"%d 錯誤"
],
"page.feeds.last_check": "最後檢查時間:",
"page.feeds.next_check": "下次檢查時間:",
"page.feeds.read_counter": "已讀文章數",
"page.feeds.title": "Feeds",
"page.footer.elevator": "返回頂部",
"page.history.title": "歷史",
"page.import.title": "匯入",
"page.integration.bookmarklet": "書籤小工具",
"page.integration.bookmarklet.help": "您可以透過這個特殊的書籤直接訂閱網站",
"page.integration.bookmarklet.instructions": "拖動這個連結到瀏覽器書籤欄",
"page.integration.bookmarklet.name": "收藏 Miniflux",
"page.integration.miniflux_api": "Miniflux API",
"page.integration.miniflux_api_endpoint": "API 端點",
"page.integration.miniflux_api_password": "密碼",
"page.integration.miniflux_api_password_value": "您帳號的密碼",
"page.integration.miniflux_api_username": "使用者名稱",
"page.integrations.title": "整合",
"page.keyboard_shortcuts.close_modal": "關閉對話視窗",
"page.keyboard_shortcuts.download_content": "下載原文內容",
"page.keyboard_shortcuts.go_to_bottom_item": "轉到底端項目",
"page.keyboard_shortcuts.go_to_categories": "開啟分類頁面",
"page.keyboard_shortcuts.go_to_feed": "轉到 Feed 頁面",
"page.keyboard_shortcuts.go_to_feeds": "開啟 Feeds 頁面",
"page.keyboard_shortcuts.go_to_history": "開啟歷史頁面",
"page.keyboard_shortcuts.go_to_next_item": "下一文章",
"page.keyboard_shortcuts.go_to_next_page": "下一頁",
"page.keyboard_shortcuts.go_to_previous_item": "上一文章",
"page.keyboard_shortcuts.go_to_previous_page": "上一頁",
"page.keyboard_shortcuts.go_to_search": "將焦點放在搜尋表單上",
"page.keyboard_shortcuts.go_to_settings": "開啟設定頁面",
"page.keyboard_shortcuts.go_to_starred": "開啟收藏頁面",
"page.keyboard_shortcuts.go_to_top_item": "轉到頂端項目",
"page.keyboard_shortcuts.go_to_unread": "開啟未讀頁面",
"page.keyboard_shortcuts.mark_page_as_read": "將此頁面標記為已讀",
"page.keyboard_shortcuts.open_comments": "開啟評論連結",
"page.keyboard_shortcuts.open_comments_same_window": "在目前標籤頁中開啟評論連結",
"page.keyboard_shortcuts.open_item": "開啟選定的文章",
"page.keyboard_shortcuts.open_original": "開啟原始連結",
"page.keyboard_shortcuts.open_original_same_window": "在目前標籤頁中開啟原始連結",
"page.keyboard_shortcuts.refresh_all_feeds": "在背景更新所有 Feed",
"page.keyboard_shortcuts.remove_feed": "刪除此 Feed",
"page.keyboard_shortcuts.save_article": "儲存文章",
"page.keyboard_shortcuts.scroll_item_to_top": "捲動到頂端",
"page.keyboard_shortcuts.show_keyboard_shortcuts": "顯示快捷鍵幫助",
"page.keyboard_shortcuts.subtitle.actions": "操作",
"page.keyboard_shortcuts.subtitle.items": "文章導覽",
"page.keyboard_shortcuts.subtitle.pages": "頁面導覽",
"page.keyboard_shortcuts.subtitle.sections": "分欄導覽",
"page.keyboard_shortcuts.title": "快捷鍵",
"page.keyboard_shortcuts.toggle_star_status": "切換收藏狀態",
"page.keyboard_shortcuts.toggle_entry_attachments": "展開/折疊文章附件",
"page.keyboard_shortcuts.toggle_read_status_next": "切換已讀/未讀狀態,並聚焦到下一個",
"page.keyboard_shortcuts.toggle_read_status_prev": "切換已讀/未讀狀態,並聚焦到上一個",
"page.login.google_signin": "使用 Google 登入",
"page.login.oidc_signin": "使用 %s 登入",
"page.login.title": "登入",
"page.login.webauthn_login": "使用密碼登入",
"page.login.webauthn_login.error": "無法使用密碼登入",
"page.login.webauthn_login.help": "使用安全金鑰登入時,請輸入使用者名稱。若使用可探索式 Passkey 則無需輸入。",
"page.new_api_key.title": "新的 API 金鑰",
"page.new_category.title": "新分類",
"page.new_user.title": "新使用者",
"page.offline.message": "您已離線",
"page.offline.refresh_page": "嘗試重新整理頁面",
"page.offline.title": "離線模式",
"page.read_entry_count": [
"%d 篇已讀文章"
],
"page.search.title": "搜尋結果",
"page.sessions.table.actions": "操作",
"page.sessions.table.current_session": "目前工作階段",
"page.sessions.table.date": "日期",
"page.sessions.table.ip": "IP 位址",
"page.sessions.table.user_agent": "使用者代理",
"page.sessions.title": "工作階段",
"page.settings.link_google_account": "關聯我的 Google 帳號",
"page.settings.link_oidc_account": "關聯我的 %s 帳號",
"page.settings.title": "設定",
"page.settings.unlink_google_account": "解除 Google 帳號關聯",
"page.settings.unlink_oidc_account": "解除 %s 帳號關聯",
"page.settings.webauthn.actions": "操作",
"page.settings.webauthn.added_on": "新增時間",
"page.settings.webauthn.delete": [
"刪除 %d 個 Passkey"
],
"page.settings.webauthn.last_seen_on": "最後使用時間",
"page.settings.webauthn.passkey_name": "Passkey 名稱",
"page.settings.webauthn.passkeys": "Passkeys",
"page.settings.webauthn.register": "註冊 Passkey",
"page.settings.webauthn.register.error": "無法註冊 Passkey",
"page.shared_entries.title": "已分享的文章",
"page.shared_entries_count": [
"已分享 %d 篇文章"
],
"page.starred.title": "收藏",
"page.starred_entry_count": [
"%d 篇收藏文章"
],
"page.total_entry_count": [
"總共 %d 篇文章"
],
"page.unread.title": "未讀",
"page.unread_entry_count": [
"%d 篇未讀文章"
],
"page.users.actions": "操作",
"page.users.admin.no": "否",
"page.users.admin.yes": "是",
"page.users.is_admin": "管理員",
"page.users.last_login": "最後登入時間",
"page.users.never_logged": "從未登入",
"page.users.title": "使用者",
"page.users.username": "使用者名稱",
"page.webauthn_rename.title": "重新命名 Passkey",
"pagination.first": "第一頁",
"pagination.last": "最後一頁",
"pagination.next": "下一頁",
"pagination.previous": "上一頁",
"search.label": "搜尋",
"search.placeholder": "搜尋…",
"search.submit": "送出",
"skip_to_content": "跳到主要內容",
"time_elapsed.days": [
"%d 天前"
],
"time_elapsed.hours": [
"%d 小時前"
],
"time_elapsed.minutes": [
"%d 分鐘前"
],
"time_elapsed.months": [
"%d 個月前"
],
"time_elapsed.not_yet": "未來",
"time_elapsed.now": "剛剛",
"time_elapsed.weeks": [
"%d 週前"
],
"time_elapsed.years": [
"%d 年前"
],
"time_elapsed.yesterday": "昨天",
"tooltip.keyboard_shortcuts": "快捷鍵:%s",
"tooltip.logged_user": "目前登入 %s"
}
v2-2.2.16/internal/mediaproxy/ 0000775 0000000 0000000 00000000000 15127074645 0016141 5 ustar 00root root 0000000 0000000 v2-2.2.16/internal/mediaproxy/media_proxy_test.go 0000664 0000000 0000000 00000060666 15127074645 0022065 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package mediaproxy // import "miniflux.app/v2/internal/mediaproxy"
import (
"net/http"
"os"
"testing"
"github.com/gorilla/mux"
"miniflux.app/v2/internal/config"
)
func TestProxyFilterWithHttpDefault(t *testing.T) {
os.Clearenv()
os.Setenv("MEDIA_PROXY_MODE", "http-only")
os.Setenv("MEDIA_PROXY_RESOURCE_TYPES", "image")
os.Setenv("MEDIA_PROXY_PRIVATE_KEY", "test")
var err error
parser := config.NewConfigParser()
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 := `
`
output := RewriteDocumentWithRelativeProxyURL(r, input)
expected := `
`
if expected != output {
t.Errorf(`Not expected output: got %q instead of %q`, output, expected)
}
}
func TestProxyFilterWithHttpsDefault(t *testing.T) {
os.Clearenv()
os.Setenv("MEDIA_PROXY_MODE", "http-only")
os.Setenv("MEDIA_PROXY_RESOURCE_TYPES", "image")
var err error
parser := config.NewConfigParser()
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 := `
`
output := RewriteDocumentWithRelativeProxyURL(r, input)
expected := `
`
if expected != output {
t.Errorf(`Not expected output: got %q instead of %q`, output, expected)
}
}
func TestProxyFilterWithHttpNever(t *testing.T) {
os.Clearenv()
os.Setenv("MEDIA_PROXY_MODE", "none")
var err error
parser := config.NewConfigParser()
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 := `
`
output := RewriteDocumentWithRelativeProxyURL(r, input)
expected := input
if expected != output {
t.Errorf(`Not expected output: got %q instead of %q`, output, expected)
}
}
func TestProxyFilterWithHttpsNever(t *testing.T) {
os.Clearenv()
os.Setenv("MEDIA_PROXY_MODE", "none")
var err error
parser := config.NewConfigParser()
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 := `
`
output := RewriteDocumentWithRelativeProxyURL(r, input)
expected := input
if expected != output {
t.Errorf(`Not expected output: got %q instead of %q`, output, expected)
}
}
func TestProxyFilterWithHttpAlways(t *testing.T) {
os.Clearenv()
os.Setenv("MEDIA_PROXY_MODE", "all")
os.Setenv("MEDIA_PROXY_RESOURCE_TYPES", "image")
os.Setenv("MEDIA_PROXY_PRIVATE_KEY", "test")
var err error
parser := config.NewConfigParser()
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 := `
`
output := RewriteDocumentWithRelativeProxyURL(r, input)
expected := `
`
if expected != output {
t.Errorf(`Not expected output: got %q instead of %q`, output, expected)
}
}
func TestProxyFilterWithHttpsAlways(t *testing.T) {
os.Clearenv()
os.Setenv("MEDIA_PROXY_MODE", "all")
os.Setenv("MEDIA_PROXY_RESOURCE_TYPES", "image")
os.Setenv("MEDIA_PROXY_PRIVATE_KEY", "test")
var err error
parser := config.NewConfigParser()
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 := `
`
output := RewriteDocumentWithRelativeProxyURL(r, input)
expected := `
`
if expected != output {
t.Errorf(`Not expected output: got %q instead of %q`, output, expected)
}
}
func TestAbsoluteProxyFilterWithHttpsAlways(t *testing.T) {
os.Clearenv()
os.Setenv("MEDIA_PROXY_MODE", "all")
os.Setenv("MEDIA_PROXY_RESOURCE_TYPES", "image")
os.Setenv("MEDIA_PROXY_PRIVATE_KEY", "test")
var err error
parser := config.NewConfigParser()
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 := `
`
output := RewriteDocumentWithAbsoluteProxyURL(r, input)
expected := `
`
if expected != output {
t.Errorf(`Not expected output: got %q instead of %q`, output, expected)
}
}
func TestAbsoluteProxyFilterWithCustomPortAndSubfolderInBaseURL(t *testing.T) {
os.Clearenv()
os.Setenv("BASE_URL", "http://example.org:88/folder/")
os.Setenv("MEDIA_PROXY_PRIVATE_KEY", "test")
var err error
parser := config.NewConfigParser()
config.Opts, err = parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
if config.Opts.BaseURL() != "http://example.org:88/folder" {
t.Fatalf(`Unexpected base URL, got "%s"`, config.Opts.BaseURL())
}
if config.Opts.RootURL() != "http://example.org:88" {
t.Fatalf(`Unexpected root URL, got "%s"`, config.Opts.RootURL())
}
router := mux.NewRouter()
if config.Opts.BasePath() != "" {
router = router.PathPrefix(config.Opts.BasePath()).Subrouter()
}
router.HandleFunc("/proxy/{encodedDigest}/{encodedURL}", func(w http.ResponseWriter, r *http.Request) {}).Name("proxy")
input := `
`
output := RewriteDocumentWithAbsoluteProxyURL(router, input)
expected := `
`
if expected != output {
t.Errorf(`Not expected output: got %q instead of %q`, output, expected)
}
}
func TestAbsoluteProxyFilterWithHttpsAlwaysAndAudioTag(t *testing.T) {
os.Clearenv()
os.Setenv("MEDIA_PROXY_MODE", "all")
os.Setenv("MEDIA_PROXY_RESOURCE_TYPES", "audio")
os.Setenv("MEDIA_PROXY_PRIVATE_KEY", "test")
var err error
parser := config.NewConfigParser()
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 := ` `
output := RewriteDocumentWithAbsoluteProxyURL(r, input)
expected := ` `
if expected != output {
t.Errorf(`Not expected output: got %q instead of %q`, output, expected)
}
}
func TestProxyFilterWithHttpsAlwaysAndCustomProxyServer(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.NewConfigParser()
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 := `
`
output := RewriteDocumentWithRelativeProxyURL(r, input)
expected := `
`
if expected != output {
t.Errorf(`Not expected output: got %q instead of %q`, output, expected)
}
}
func TestProxyFilterWithHttpsAlwaysAndIncorrectCustomProxyServer(t *testing.T) {
os.Clearenv()
os.Setenv("MEDIA_PROXY_MODE", "all")
os.Setenv("MEDIA_PROXY_RESOURCE_TYPES", "image")
os.Setenv("MEDIA_PROXY_CUSTOM_URL", "http://:8080example.com")
var err error
parser := config.NewConfigParser()
config.Opts, err = parser.ParseEnvironmentVariables()
if err == nil {
t.Fatalf(`Incorrect proxy URL silently accepted (MEDIA_PROXY_CUSTOM_URL=%q): %q`, os.Getenv("MEDIA_PROXY_CUSTOM_URL"), config.Opts.MediaCustomProxyURL())
}
}
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.NewConfigParser()
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 := `
`
output := RewriteDocumentWithAbsoluteProxyURL(r, input)
expected := `
`
if expected != output {
t.Errorf(`Not expected output: got %q instead of %q`, output, expected)
}
}
func TestProxyFilterWithHttpInvalid(t *testing.T) {
os.Clearenv()
os.Setenv("MEDIA_PROXY_MODE", "invalid")
os.Setenv("MEDIA_PROXY_PRIVATE_KEY", "test")
if _, err := config.NewConfigParser().ParseEnvironmentVariables(); err == nil {
t.Fatalf(`Parsing should have failed (MEDIA_PROXY_MODE=%q): %q`, os.Getenv("MEDIA_PROXY_MODE"), config.Opts.MediaProxyMode())
}
}
func TestProxyFilterWithSrcset(t *testing.T) {
os.Clearenv()
os.Setenv("MEDIA_PROXY_MODE", "all")
os.Setenv("MEDIA_PROXY_RESOURCE_TYPES", "image")
os.Setenv("MEDIA_PROXY_PRIVATE_KEY", "test")
var err error
parser := config.NewConfigParser()
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 := `
`
expected := `
`
output := RewriteDocumentWithRelativeProxyURL(r, input)
if expected != output {
t.Errorf(`Not expected output: got %s`, output)
}
}
func TestProxyFilterWithEmptySrcset(t *testing.T) {
os.Clearenv()
os.Setenv("MEDIA_PROXY_MODE", "all")
os.Setenv("MEDIA_PROXY_RESOURCE_TYPES", "image")
os.Setenv("MEDIA_PROXY_PRIVATE_KEY", "test")
var err error
parser := config.NewConfigParser()
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 := `
`
expected := `
`
output := RewriteDocumentWithRelativeProxyURL(r, input)
if expected != output {
t.Errorf(`Not expected output: got %s`, output)
}
}
func TestProxyFilterWithPictureSource(t *testing.T) {
os.Clearenv()
os.Setenv("MEDIA_PROXY_MODE", "all")
os.Setenv("MEDIA_PROXY_RESOURCE_TYPES", "image")
os.Setenv("MEDIA_PROXY_PRIVATE_KEY", "test")
var err error
parser := config.NewConfigParser()
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 := ` `
expected := ` `
output := RewriteDocumentWithRelativeProxyURL(r, input)
if expected != output {
t.Errorf(`Not expected output: got %s`, output)
}
}
func TestProxyFilterOnlyNonHTTPWithPictureSource(t *testing.T) {
os.Clearenv()
os.Setenv("MEDIA_PROXY_MODE", "http-only")
os.Setenv("MEDIA_PROXY_RESOURCE_TYPES", "image")
os.Setenv("MEDIA_PROXY_PRIVATE_KEY", "test")
var err error
parser := config.NewConfigParser()
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 := ` `
expected := ` `
output := RewriteDocumentWithRelativeProxyURL(r, input)
if expected != output {
t.Errorf(`Not expected output: got %s`, output)
}
}
func TestProxyWithImageDataURL(t *testing.T) {
os.Clearenv()
os.Setenv("MEDIA_PROXY_MODE", "all")
os.Setenv("MEDIA_PROXY_RESOURCE_TYPES", "image")
var err error
parser := config.NewConfigParser()
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 := ` `
expected := ` `
output := RewriteDocumentWithRelativeProxyURL(r, input)
if expected != output {
t.Errorf(`Not expected output: got %s`, output)
}
}
func TestProxyWithImageSourceDataURL(t *testing.T) {
os.Clearenv()
os.Setenv("MEDIA_PROXY_MODE", "all")
os.Setenv("MEDIA_PROXY_RESOURCE_TYPES", "image")
var err error
parser := config.NewConfigParser()
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 := ` `
expected := ` `
output := RewriteDocumentWithRelativeProxyURL(r, input)
if expected != output {
t.Errorf(`Not expected output: got %s`, output)
}
}
func TestProxyFilterWithVideo(t *testing.T) {
os.Clearenv()
os.Setenv("MEDIA_PROXY_MODE", "all")
os.Setenv("MEDIA_PROXY_RESOURCE_TYPES", "video")
os.Setenv("MEDIA_PROXY_PRIVATE_KEY", "test")
var err error
parser := config.NewConfigParser()
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 := ` `
expected := ` `
output := RewriteDocumentWithRelativeProxyURL(r, input)
if expected != output {
t.Errorf(`Not expected output: got %s`, output)
}
}
func TestProxyFilterVideoPoster(t *testing.T) {
os.Clearenv()
os.Setenv("MEDIA_PROXY_MODE", "all")
os.Setenv("MEDIA_PROXY_RESOURCE_TYPES", "image")
os.Setenv("MEDIA_PROXY_PRIVATE_KEY", "test")
var err error
parser := config.NewConfigParser()
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 := ` `
expected := ` `
output := RewriteDocumentWithRelativeProxyURL(r, input)
if expected != output {
t.Errorf(`Not expected output: got %s`, output)
}
}
func TestProxyFilterVideoPosterOnce(t *testing.T) {
os.Clearenv()
os.Setenv("MEDIA_PROXY_MODE", "all")
os.Setenv("MEDIA_PROXY_RESOURCE_TYPES", "image,video")
os.Setenv("MEDIA_PROXY_PRIVATE_KEY", "test")
var err error
parser := config.NewConfigParser()
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 := ` `
expected := ` `
output := RewriteDocumentWithRelativeProxyURL(r, input)
if expected != output {
t.Errorf(`Not expected output: got %s`, output)
}
}
func TestShouldProxifyURLWithMimeType(t *testing.T) {
testCases := []struct {
name string
mediaURL string
mediaMimeType string
mediaProxyOption string
mediaProxyResourceTypes []string
expected bool
}{
{
name: "Empty URL should not be proxified",
mediaURL: "",
mediaMimeType: "image/jpeg",
mediaProxyOption: "all",
mediaProxyResourceTypes: []string{"image"},
expected: false,
},
{
name: "Data URL should not be proxified",
mediaURL: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==",
mediaMimeType: "image/png",
mediaProxyOption: "all",
mediaProxyResourceTypes: []string{"image"},
expected: false,
},
{
name: "HTTP URL with all mode and matching MIME type should be proxified",
mediaURL: "http://example.com/image.jpg",
mediaMimeType: "image/jpeg",
mediaProxyOption: "all",
mediaProxyResourceTypes: []string{"image"},
expected: true,
},
{
name: "HTTPS URL with all mode and matching MIME type should be proxified",
mediaURL: "https://example.com/image.jpg",
mediaMimeType: "image/jpeg",
mediaProxyOption: "all",
mediaProxyResourceTypes: []string{"image"},
expected: true,
},
{
name: "HTTP URL with http-only mode and matching MIME type should be proxified",
mediaURL: "http://example.com/image.jpg",
mediaMimeType: "image/jpeg",
mediaProxyOption: "http-only",
mediaProxyResourceTypes: []string{"image"},
expected: true,
},
{
name: "HTTPS URL with http-only mode should not be proxified",
mediaURL: "https://example.com/image.jpg",
mediaMimeType: "image/jpeg",
mediaProxyOption: "http-only",
mediaProxyResourceTypes: []string{"image"},
expected: false,
},
{
name: "URL with none mode should not be proxified",
mediaURL: "http://example.com/image.jpg",
mediaMimeType: "image/jpeg",
mediaProxyOption: "none",
mediaProxyResourceTypes: []string{"image"},
expected: false,
},
{
name: "URL with matching MIME type should be proxified",
mediaURL: "http://example.com/video.mp4",
mediaMimeType: "video/mp4",
mediaProxyOption: "all",
mediaProxyResourceTypes: []string{"video"},
expected: true,
},
{
name: "URL with non-matching MIME type should not be proxified",
mediaURL: "http://example.com/video.mp4",
mediaMimeType: "video/mp4",
mediaProxyOption: "all",
mediaProxyResourceTypes: []string{"image"},
expected: false,
},
{
name: "URL with multiple resource types and matching MIME type should be proxified",
mediaURL: "http://example.com/audio.mp3",
mediaMimeType: "audio/mp3",
mediaProxyOption: "all",
mediaProxyResourceTypes: []string{"image", "audio", "video"},
expected: true,
},
{
name: "URL with multiple resource types but non-matching MIME type should not be proxified",
mediaURL: "http://example.com/document.pdf",
mediaMimeType: "application/pdf",
mediaProxyOption: "all",
mediaProxyResourceTypes: []string{"image", "audio", "video"},
expected: false,
},
{
name: "URL with empty resource types should not be proxified",
mediaURL: "http://example.com/image.jpg",
mediaMimeType: "image/jpeg",
mediaProxyOption: "all",
mediaProxyResourceTypes: []string{},
expected: false,
},
{
name: "URL with partial MIME type match should be proxified",
mediaURL: "http://example.com/image.jpg",
mediaMimeType: "image/jpeg",
mediaProxyOption: "all",
mediaProxyResourceTypes: []string{"image"},
expected: true,
},
{
name: "URL with audio MIME type and audio resource type should be proxified",
mediaURL: "http://example.com/song.ogg",
mediaMimeType: "audio/ogg",
mediaProxyOption: "all",
mediaProxyResourceTypes: []string{"audio"},
expected: true,
},
{
name: "URL with video MIME type and video resource type should be proxified",
mediaURL: "http://example.com/movie.webm",
mediaMimeType: "video/webm",
mediaProxyOption: "all",
mediaProxyResourceTypes: []string{"video"},
expected: true,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
result := ShouldProxifyURLWithMimeType(tc.mediaURL, tc.mediaMimeType, tc.mediaProxyOption, tc.mediaProxyResourceTypes)
if result != tc.expected {
t.Errorf("Expected %v, got %v for URL: %s, MIME type: %s, proxy option: %s, resource types: %v",
tc.expected, result, tc.mediaURL, tc.mediaMimeType, tc.mediaProxyOption, tc.mediaProxyResourceTypes)
}
})
}
}
v2-2.2.16/internal/mediaproxy/rewriter.go 0000664 0000000 0000000 00000010270 15127074645 0020333 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package mediaproxy // import "miniflux.app/v2/internal/mediaproxy"
import (
"slices"
"strings"
"miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/reader/sanitizer"
"miniflux.app/v2/internal/urllib"
"github.com/PuerkitoBio/goquery"
"github.com/gorilla/mux"
)
type urlProxyRewriter func(router *mux.Router, url string) string
func RewriteDocumentWithRelativeProxyURL(router *mux.Router, htmlDocument string) string {
return genericProxyRewriter(router, ProxifyRelativeURL, htmlDocument)
}
func RewriteDocumentWithAbsoluteProxyURL(router *mux.Router, htmlDocument string) string {
return genericProxyRewriter(router, ProxifyAbsoluteURL, htmlDocument)
}
func genericProxyRewriter(router *mux.Router, proxifyFunction urlProxyRewriter, htmlDocument string) string {
proxyOption := config.Opts.MediaProxyMode()
if proxyOption == "none" {
return htmlDocument
}
doc, err := goquery.NewDocumentFromReader(strings.NewReader(htmlDocument))
if err != nil {
return htmlDocument
}
for _, mediaType := range config.Opts.MediaProxyResourceTypes() {
switch mediaType {
case "image":
doc.Find("img, picture source").Each(func(i int, img *goquery.Selection) {
if srcAttrValue, ok := img.Attr("src"); ok {
if shouldProxifyURL(srcAttrValue, proxyOption) {
img.SetAttr("src", proxifyFunction(router, srcAttrValue))
}
}
if srcsetAttrValue, ok := img.Attr("srcset"); ok {
proxifySourceSet(img, router, proxifyFunction, proxyOption, srcsetAttrValue)
}
})
if !slices.Contains(config.Opts.MediaProxyResourceTypes(), "video") {
doc.Find("video").Each(func(i int, video *goquery.Selection) {
if posterAttrValue, ok := video.Attr("poster"); ok {
if shouldProxifyURL(posterAttrValue, proxyOption) {
video.SetAttr("poster", proxifyFunction(router, posterAttrValue))
}
}
})
}
case "audio":
doc.Find("audio, audio source").Each(func(i int, audio *goquery.Selection) {
if srcAttrValue, ok := audio.Attr("src"); ok {
if shouldProxifyURL(srcAttrValue, proxyOption) {
audio.SetAttr("src", proxifyFunction(router, srcAttrValue))
}
}
})
case "video":
doc.Find("video, video source").Each(func(i int, video *goquery.Selection) {
if srcAttrValue, ok := video.Attr("src"); ok {
if shouldProxifyURL(srcAttrValue, proxyOption) {
video.SetAttr("src", proxifyFunction(router, srcAttrValue))
}
}
if posterAttrValue, ok := video.Attr("poster"); ok {
if shouldProxifyURL(posterAttrValue, proxyOption) {
video.SetAttr("poster", proxifyFunction(router, posterAttrValue))
}
}
})
}
}
output, err := doc.FindMatcher(goquery.Single("body")).Html()
if err != nil {
return htmlDocument
}
return output
}
func proxifySourceSet(element *goquery.Selection, router *mux.Router, proxifyFunction urlProxyRewriter, proxyOption, srcsetAttrValue string) {
imageCandidates := sanitizer.ParseSrcSetAttribute(srcsetAttrValue)
for _, imageCandidate := range imageCandidates {
if shouldProxifyURL(imageCandidate.ImageURL, proxyOption) {
imageCandidate.ImageURL = proxifyFunction(router, imageCandidate.ImageURL)
}
}
element.SetAttr("srcset", imageCandidates.String())
}
// shouldProxifyURL checks if the media URL should be proxified based on the media proxy option and URL scheme.
func shouldProxifyURL(mediaURL, mediaProxyOption string) bool {
switch {
case mediaURL == "":
return false
case strings.HasPrefix(mediaURL, "data:"):
return false
case mediaProxyOption == "all":
return true
case mediaProxyOption != "none" && !urllib.IsHTTPS(mediaURL):
return true
default:
return false
}
}
// ShouldProxifyURLWithMimeType checks if the media URL should be proxified based on the media proxy option, URL scheme, and MIME type.
func ShouldProxifyURLWithMimeType(mediaURL, mediaMimeType, mediaProxyOption string, mediaProxyResourceTypes []string) bool {
if !shouldProxifyURL(mediaURL, mediaProxyOption) {
return false
}
for _, mediaType := range mediaProxyResourceTypes {
if strings.HasPrefix(mediaMimeType, mediaType+"/") {
return true
}
}
return false
}
v2-2.2.16/internal/mediaproxy/url.go 0000664 0000000 0000000 00000003173 15127074645 0017276 0 ustar 00root root 0000000 0000000 // 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"
"net/url"
"github.com/gorilla/mux"
"miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/http/route"
)
func ProxifyRelativeURL(router *mux.Router, mediaURL string) string {
if mediaURL == "" {
return ""
}
if customProxyURL := config.Opts.MediaCustomProxyURL(); customProxyURL != nil {
return proxifyURLWithCustomProxy(mediaURL, customProxyURL)
}
mac := hmac.New(sha256.New, config.Opts.MediaProxyPrivateKey())
mac.Write([]byte(mediaURL))
digest := mac.Sum(nil)
return route.Path(router, "proxy", "encodedDigest", base64.URLEncoding.EncodeToString(digest), "encodedURL", base64.URLEncoding.EncodeToString([]byte(mediaURL)))
}
func ProxifyAbsoluteURL(router *mux.Router, mediaURL string) string {
if mediaURL == "" {
return ""
}
if customProxyURL := config.Opts.MediaCustomProxyURL(); customProxyURL != nil {
return proxifyURLWithCustomProxy(mediaURL, customProxyURL)
}
// Note that the proxyified URL is relative to the root URL.
proxifiedUrl := ProxifyRelativeURL(router, mediaURL)
absoluteURL, err := url.JoinPath(config.Opts.RootURL(), proxifiedUrl)
if err != nil {
return mediaURL
}
return absoluteURL
}
func proxifyURLWithCustomProxy(mediaURL string, customProxyURL *url.URL) string {
if customProxyURL == nil {
return mediaURL
}
absoluteURL := customProxyURL.JoinPath(base64.URLEncoding.EncodeToString([]byte(mediaURL)))
return absoluteURL.String()
}
v2-2.2.16/internal/metric/ 0000775 0000000 0000000 00000000000 15127074645 0015243 5 ustar 00root root 0000000 0000000 v2-2.2.16/internal/metric/metric.go 0000664 0000000 0000000 00000012744 15127074645 0017065 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package metric // import "miniflux.app/v2/internal/metric"
import (
"log/slog"
"time"
"miniflux.app/v2/internal/storage"
"github.com/prometheus/client_golang/prometheus"
)
// Prometheus Metrics.
var (
BackgroundFeedRefreshDuration = prometheus.NewHistogramVec(
prometheus.HistogramOpts{
Namespace: "miniflux",
Name: "background_feed_refresh_duration",
Help: "Processing time to refresh feeds from the background workers",
Buckets: prometheus.LinearBuckets(1, 2, 15),
},
[]string{"status"},
)
ScraperRequestDuration = prometheus.NewHistogramVec(
prometheus.HistogramOpts{
Namespace: "miniflux",
Name: "scraper_request_duration",
Help: "Web scraper request duration",
Buckets: prometheus.LinearBuckets(1, 2, 25),
},
[]string{"status"},
)
ArchiveEntriesDuration = prometheus.NewHistogramVec(
prometheus.HistogramOpts{
Namespace: "miniflux",
Name: "archive_entries_duration",
Help: "Archive entries duration",
Buckets: prometheus.LinearBuckets(1, 2, 30),
},
[]string{"status"},
)
usersGauge = prometheus.NewGauge(
prometheus.GaugeOpts{
Namespace: "miniflux",
Name: "users",
Help: "Number of users",
},
)
feedsGauge = prometheus.NewGaugeVec(
prometheus.GaugeOpts{
Namespace: "miniflux",
Name: "feeds",
Help: "Number of feeds by status",
},
[]string{"status"},
)
brokenFeedsGauge = prometheus.NewGauge(
prometheus.GaugeOpts{
Namespace: "miniflux",
Name: "broken_feeds",
Help: "Number of broken feeds",
},
)
entriesGauge = prometheus.NewGaugeVec(
prometheus.GaugeOpts{
Namespace: "miniflux",
Name: "entries",
Help: "Number of entries by status",
},
[]string{"status"},
)
dbOpenConnectionsGauge = prometheus.NewGauge(
prometheus.GaugeOpts{
Namespace: "miniflux",
Name: "db_open_connections",
Help: "The number of established connections both in use and idle",
},
)
dbConnectionsInUseGauge = prometheus.NewGauge(
prometheus.GaugeOpts{
Namespace: "miniflux",
Name: "db_connections_in_use",
Help: "The number of connections currently in use",
},
)
dbConnectionsIdleGauge = prometheus.NewGauge(
prometheus.GaugeOpts{
Namespace: "miniflux",
Name: "db_connections_idle",
Help: "The number of idle connections",
},
)
dbConnectionsWaitCountGauge = prometheus.NewGauge(
prometheus.GaugeOpts{
Namespace: "miniflux",
Name: "db_connections_wait_count",
Help: "The total number of connections waited for",
},
)
dbConnectionsMaxIdleClosedGauge = prometheus.NewGauge(
prometheus.GaugeOpts{
Namespace: "miniflux",
Name: "db_connections_max_idle_closed",
Help: "The total number of connections closed due to SetMaxIdleConns",
},
)
dbConnectionsMaxIdleTimeClosedGauge = prometheus.NewGauge(
prometheus.GaugeOpts{
Namespace: "miniflux",
Name: "db_connections_max_idle_time_closed",
Help: "The total number of connections closed due to SetConnMaxIdleTime",
},
)
dbConnectionsMaxLifetimeClosedGauge = prometheus.NewGauge(
prometheus.GaugeOpts{
Namespace: "miniflux",
Name: "db_connections_max_lifetime_closed",
Help: "The total number of connections closed due to SetConnMaxLifetime",
},
)
)
// collector represents a metric collector.
type collector struct {
store *storage.Storage
refreshInterval time.Duration
}
// NewCollector initializes a new metric collector.
func NewCollector(store *storage.Storage, refreshInterval time.Duration) *collector {
prometheus.MustRegister(BackgroundFeedRefreshDuration)
prometheus.MustRegister(ScraperRequestDuration)
prometheus.MustRegister(ArchiveEntriesDuration)
prometheus.MustRegister(usersGauge)
prometheus.MustRegister(feedsGauge)
prometheus.MustRegister(brokenFeedsGauge)
prometheus.MustRegister(entriesGauge)
prometheus.MustRegister(dbOpenConnectionsGauge)
prometheus.MustRegister(dbConnectionsInUseGauge)
prometheus.MustRegister(dbConnectionsIdleGauge)
prometheus.MustRegister(dbConnectionsWaitCountGauge)
prometheus.MustRegister(dbConnectionsMaxIdleClosedGauge)
prometheus.MustRegister(dbConnectionsMaxIdleTimeClosedGauge)
prometheus.MustRegister(dbConnectionsMaxLifetimeClosedGauge)
return &collector{store, refreshInterval}
}
// GatherStorageMetrics polls the database to fetch metrics.
func (c *collector) GatherStorageMetrics() {
for range time.Tick(c.refreshInterval) {
slog.Debug("Collecting metrics from the database")
usersGauge.Set(float64(c.store.CountUsers()))
brokenFeedsGauge.Set(float64(c.store.CountAllFeedsWithErrors()))
feedsCount := c.store.CountAllFeeds()
for status, count := range feedsCount {
feedsGauge.WithLabelValues(status).Set(float64(count))
}
entriesCount := c.store.CountAllEntries()
for status, count := range entriesCount {
entriesGauge.WithLabelValues(status).Set(float64(count))
}
dbStats := c.store.DBStats()
dbOpenConnectionsGauge.Set(float64(dbStats.OpenConnections))
dbConnectionsInUseGauge.Set(float64(dbStats.InUse))
dbConnectionsIdleGauge.Set(float64(dbStats.Idle))
dbConnectionsWaitCountGauge.Set(float64(dbStats.WaitCount))
dbConnectionsMaxIdleClosedGauge.Set(float64(dbStats.MaxIdleClosed))
dbConnectionsMaxIdleTimeClosedGauge.Set(float64(dbStats.MaxIdleTimeClosed))
dbConnectionsMaxLifetimeClosedGauge.Set(float64(dbStats.MaxLifetimeClosed))
}
}
v2-2.2.16/internal/model/ 0000775 0000000 0000000 00000000000 15127074645 0015060 5 ustar 00root root 0000000 0000000 v2-2.2.16/internal/model/api_key.go 0000664 0000000 0000000 00000001523 15127074645 0017031 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package model // import "miniflux.app/v2/internal/model"
import (
"time"
)
// APIKey represents an application API key.
// We need to use a pointer for LastUsedAt,
// as the value obtained from the database might sometimes be nil.
type APIKey struct {
ID int64 `json:"id"`
UserID int64 `json:"user_id"`
Token string `json:"token"`
Description string `json:"description"`
LastUsedAt *time.Time `json:"last_used_at"`
CreatedAt time.Time `json:"created_at"`
}
// APIKeys represents a collection of API Key.
type APIKeys []APIKey
// APIKeyCreationRequest represents the request to create a new API Key.
type APIKeyCreationRequest struct {
Description string `json:"description"`
}
v2-2.2.16/internal/model/app_session.go 0000664 0000000 0000000 00000003524 15127074645 0017736 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package model // import "miniflux.app/v2/internal/model"
import (
"database/sql/driver"
"encoding/json"
"errors"
"fmt"
)
// SessionData represents the data attached to the session.
type SessionData struct {
CSRF string `json:"csrf"`
OAuth2State string `json:"oauth2_state"`
OAuth2CodeVerifier string `json:"oauth2_code_verifier"`
FlashMessage string `json:"flash_message"`
FlashErrorMessage string `json:"flash_error_message"`
Language string `json:"language"`
Theme string `json:"theme"`
LastForceRefresh string `json:"last_force_refresh"`
WebAuthnSessionData WebAuthnSession `json:"webauthn_session_data"`
}
func (s *SessionData) String() string {
return fmt.Sprintf(`CSRF=%q, OAuth2State=%q, OAuth2CodeVerifier=%q, FlashMsg=%q, FlashErrMsg=%q, Lang=%q, Theme=%q, LastForceRefresh=%s, WebAuthnSession=%q`,
s.CSRF,
s.OAuth2State,
s.OAuth2CodeVerifier,
s.FlashMessage,
s.FlashErrorMessage,
s.Language,
s.Theme,
s.LastForceRefresh,
s.WebAuthnSessionData,
)
}
// Value converts the session data to JSON.
func (s *SessionData) Value() (driver.Value, error) {
j, err := json.Marshal(s)
return j, err
}
// Scan converts raw JSON data.
func (s *SessionData) Scan(src any) error {
source, ok := src.([]byte)
if !ok {
return errors.New("session: unable to assert type of src")
}
err := json.Unmarshal(source, s)
if err != nil {
return fmt.Errorf("session: %v", err)
}
return err
}
// Session represents a session in the system.
type Session struct {
ID string
Data *SessionData
}
func (s *Session) String() string {
return fmt.Sprintf(`ID=%q, Data={%v}`, s.ID, s.Data)
}
v2-2.2.16/internal/model/categories_sort_options.go 0000664 0000000 0000000 00000000556 15127074645 0022364 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package model // import "miniflux.app/v2/internal/model"
func CategoriesSortingOptions() map[string]string {
return map[string]string{
"unread_count": "form.prefs.select.unread_count",
"alphabetical": "form.prefs.select.alphabetical",
}
}
v2-2.2.16/internal/model/category.go 0000664 0000000 0000000 00000002270 15127074645 0017225 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package model // import "miniflux.app/v2/internal/model"
import "fmt"
// Category represents a feed category.
type Category struct {
ID int64 `json:"id"`
Title string `json:"title"`
UserID int64 `json:"user_id"`
HideGlobally bool `json:"hide_globally"`
// Pointers are needed to avoid breaking /v1/categories?counts=true
FeedCount *int `json:"feed_count,omitempty"`
TotalUnread *int `json:"total_unread,omitempty"`
}
func (c *Category) String() string {
return fmt.Sprintf("ID=%d, UserID=%d, Title=%s", c.ID, c.UserID, c.Title)
}
type CategoryCreationRequest struct {
Title string `json:"title"`
HideGlobally bool `json:"hide_globally"`
}
type CategoryModificationRequest struct {
Title *string `json:"title"`
HideGlobally *bool `json:"hide_globally"`
}
func (c *CategoryModificationRequest) Patch(category *Category) {
if c.Title != nil {
category.Title = *c.Title
}
if c.HideGlobally != nil {
category.HideGlobally = *c.HideGlobally
}
}
// Categories represents a list of categories.
type Categories []Category
v2-2.2.16/internal/model/enclosure.go 0000664 0000000 0000000 00000005227 15127074645 0017414 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package model // import "miniflux.app/v2/internal/model"
import (
"strings"
"github.com/gorilla/mux"
"miniflux.app/v2/internal/mediaproxy"
)
// Enclosure represents an attachment.
type Enclosure struct {
ID int64 `json:"id"`
UserID int64 `json:"user_id"`
EntryID int64 `json:"entry_id"`
URL string `json:"url"`
MimeType string `json:"mime_type"`
Size int64 `json:"size"`
MediaProgression int64 `json:"media_progression"`
}
type EnclosureUpdateRequest struct {
MediaProgression int64 `json:"media_progression"`
}
// Html5MimeType will modify the actual MimeType to allow direct playback from HTML5 player for some kind of MimeType
func (e *Enclosure) Html5MimeType() string {
if e.MimeType == "video/m4v" {
return "video/x-m4v"
}
return e.MimeType
}
func (e *Enclosure) IsAudio() bool {
return strings.HasPrefix(strings.ToLower(e.MimeType), "audio/")
}
func (e *Enclosure) IsVideo() bool {
return strings.HasPrefix(strings.ToLower(e.MimeType), "video/")
}
func (e *Enclosure) IsImage() bool {
mimeType := strings.ToLower(e.MimeType)
if strings.HasPrefix(mimeType, "image/") {
return true
}
mediaURL := strings.ToLower(e.URL)
return strings.HasSuffix(mediaURL, ".jpg") || strings.HasSuffix(mediaURL, ".jpeg") || strings.HasSuffix(mediaURL, ".png") || strings.HasSuffix(mediaURL, ".gif")
}
// ProxifyEnclosureURL modifies the enclosure URL to use the media proxy if necessary.
func (e *Enclosure) ProxifyEnclosureURL(router *mux.Router, mediaProxyOption string, mediaProxyResourceTypes []string) {
if mediaproxy.ShouldProxifyURLWithMimeType(e.URL, e.MimeType, mediaProxyOption, mediaProxyResourceTypes) {
e.URL = mediaproxy.ProxifyAbsoluteURL(router, e.URL)
}
}
// EnclosureList represents a list of attachments.
type EnclosureList []*Enclosure
// FindMediaPlayerEnclosure returns the first enclosure that can be played by a media player.
func (el EnclosureList) FindMediaPlayerEnclosure() *Enclosure {
for _, enclosure := range el {
if enclosure.URL != "" {
if enclosure.IsAudio() || enclosure.IsVideo() {
return enclosure
}
}
}
return nil
}
func (el EnclosureList) ContainsAudioOrVideo() bool {
for _, enclosure := range el {
if enclosure.IsAudio() || enclosure.IsVideo() {
return true
}
}
return false
}
func (el EnclosureList) ProxifyEnclosureURL(router *mux.Router, mediaProxyOption string, mediaProxyResourceTypes []string) {
for _, enclosure := range el {
enclosure.ProxifyEnclosureURL(router, mediaProxyOption, mediaProxyResourceTypes)
}
}
v2-2.2.16/internal/model/enclosure_test.go 0000664 0000000 0000000 00000045212 15127074645 0020451 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package model
import (
"net/http"
"os"
"testing"
"github.com/gorilla/mux"
"miniflux.app/v2/internal/config"
)
func TestEnclosure_Html5MimeTypeGivesOriginalMimeType(t *testing.T) {
enclosure := Enclosure{MimeType: "thing/thisMimeTypeIsNotExpectedToBeReplaced"}
if enclosure.Html5MimeType() != enclosure.MimeType {
t.Fatalf(
"HTML5 MimeType must provide original MimeType if not explicitly Replaced. Got %s ,expected '%s' ",
enclosure.Html5MimeType(),
enclosure.MimeType,
)
}
}
func TestEnclosure_Html5MimeTypeReplaceStandardM4vByAppleSpecificMimeType(t *testing.T) {
enclosure := Enclosure{MimeType: "video/m4v"}
if enclosure.Html5MimeType() != "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
t.Fatalf(
"HTML5 MimeType must be replaced by 'video/x-m4v' when originally video/m4v to ensure playbacks in browsers. Got '%s'",
enclosure.Html5MimeType(),
)
}
}
func TestEnclosure_IsAudio(t *testing.T) {
testCases := []struct {
name string
mimeType string
expected bool
}{
{"MP3 audio", "audio/mpeg", true},
{"WAV audio", "audio/wav", true},
{"OGG audio", "audio/ogg", true},
{"Mixed case audio", "Audio/MP3", true},
{"Video file", "video/mp4", false},
{"Image file", "image/jpeg", false},
{"Text file", "text/plain", false},
{"Empty mime type", "", false},
{"Audio with extra info", "audio/mpeg; charset=utf-8", true},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
enclosure := &Enclosure{MimeType: tc.mimeType}
if got := enclosure.IsAudio(); got != tc.expected {
t.Errorf("IsAudio() = %v, want %v for mime type %s", got, tc.expected, tc.mimeType)
}
})
}
}
func TestEnclosure_IsVideo(t *testing.T) {
testCases := []struct {
name string
mimeType string
expected bool
}{
{"MP4 video", "video/mp4", true},
{"AVI video", "video/avi", true},
{"WebM video", "video/webm", true},
{"M4V video", "video/m4v", true},
{"Mixed case video", "Video/MP4", true},
{"Audio file", "audio/mpeg", false},
{"Image file", "image/jpeg", false},
{"Text file", "text/plain", false},
{"Empty mime type", "", false},
{"Video with extra info", "video/mp4; codecs=\"avc1.42E01E\"", true},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
enclosure := &Enclosure{MimeType: tc.mimeType}
if got := enclosure.IsVideo(); got != tc.expected {
t.Errorf("IsVideo() = %v, want %v for mime type %s", got, tc.expected, tc.mimeType)
}
})
}
}
func TestEnclosure_IsImage(t *testing.T) {
testCases := []struct {
name string
mimeType string
url string
expected bool
}{
{"JPEG image by mime", "image/jpeg", "http://example.com/file", true},
{"PNG image by mime", "image/png", "http://example.com/file", true},
{"GIF image by mime", "image/gif", "http://example.com/file", true},
{"Mixed case image mime", "Image/JPEG", "http://example.com/file", true},
{"JPG file extension", "application/octet-stream", "http://example.com/photo.jpg", true},
{"JPEG file extension", "text/plain", "http://example.com/photo.jpeg", true},
{"PNG file extension", "unknown/type", "http://example.com/photo.png", true},
{"GIF file extension", "binary/data", "http://example.com/photo.gif", true},
{"Mixed case extension", "text/plain", "http://example.com/photo.JPG", true},
{"Image mime and extension", "image/jpeg", "http://example.com/photo.jpg", true},
{"Video file", "video/mp4", "http://example.com/video.mp4", false},
{"Audio file", "audio/mpeg", "http://example.com/audio.mp3", false},
{"Text file", "text/plain", "http://example.com/file.txt", false},
{"No extension", "text/plain", "http://example.com/file", false},
{"Other extension", "text/plain", "http://example.com/file.pdf", false},
{"Empty values", "", "", false},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
enclosure := &Enclosure{MimeType: tc.mimeType, URL: tc.url}
if got := enclosure.IsImage(); got != tc.expected {
t.Errorf("IsImage() = %v, want %v for mime type %s and URL %s", got, tc.expected, tc.mimeType, tc.url)
}
})
}
}
func TestEnclosureList_FindMediaPlayerEnclosure(t *testing.T) {
testCases := []struct {
name string
enclosures EnclosureList
expectedNil bool
}{
{
name: "Returns first audio enclosure",
enclosures: EnclosureList{
&Enclosure{URL: "http://example.com/audio.mp3", MimeType: "audio/mpeg"},
&Enclosure{URL: "http://example.com/video.mp4", MimeType: "video/mp4"},
},
expectedNil: false,
},
{
name: "Returns first video enclosure",
enclosures: EnclosureList{
&Enclosure{URL: "http://example.com/video.mp4", MimeType: "video/mp4"},
&Enclosure{URL: "http://example.com/audio.mp3", MimeType: "audio/mpeg"},
},
expectedNil: false,
},
{
name: "Skips image enclosure and returns audio",
enclosures: EnclosureList{
&Enclosure{URL: "http://example.com/image.jpg", MimeType: "image/jpeg"},
&Enclosure{URL: "http://example.com/audio.mp3", MimeType: "audio/mpeg"},
},
expectedNil: false,
},
{
name: "Skips enclosure with empty URL",
enclosures: EnclosureList{
&Enclosure{URL: "", MimeType: "audio/mpeg"},
&Enclosure{URL: "http://example.com/audio.mp3", MimeType: "audio/mpeg"},
},
expectedNil: false,
},
{
name: "Returns nil for no media enclosures",
enclosures: EnclosureList{
&Enclosure{URL: "http://example.com/image.jpg", MimeType: "image/jpeg"},
&Enclosure{URL: "http://example.com/doc.pdf", MimeType: "application/pdf"},
},
expectedNil: true,
},
{
name: "Returns nil for empty list",
enclosures: EnclosureList{},
expectedNil: true,
},
{
name: "Returns nil for all empty URLs",
enclosures: EnclosureList{
&Enclosure{URL: "", MimeType: "audio/mpeg"},
&Enclosure{URL: "", MimeType: "video/mp4"},
},
expectedNil: true,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
result := tc.enclosures.FindMediaPlayerEnclosure()
if tc.expectedNil {
if result != nil {
t.Errorf("FindMediaPlayerEnclosure() = %v, want nil", result)
}
} else {
if result == nil {
t.Errorf("FindMediaPlayerEnclosure() = nil, want non-nil")
} else if !result.IsAudio() && !result.IsVideo() {
t.Errorf("FindMediaPlayerEnclosure() returned non-media enclosure: %s", result.MimeType)
}
}
})
}
}
func TestEnclosureList_ContainsAudioOrVideo(t *testing.T) {
testCases := []struct {
name string
enclosures EnclosureList
expected bool
}{
{
name: "Contains audio",
enclosures: EnclosureList{
&Enclosure{MimeType: "audio/mpeg"},
&Enclosure{MimeType: "image/jpeg"},
},
expected: true,
},
{
name: "Contains video",
enclosures: EnclosureList{
&Enclosure{MimeType: "image/jpeg"},
&Enclosure{MimeType: "video/mp4"},
},
expected: true,
},
{
name: "Contains both audio and video",
enclosures: EnclosureList{
&Enclosure{MimeType: "audio/mpeg"},
&Enclosure{MimeType: "video/mp4"},
},
expected: true,
},
{
name: "Contains only images",
enclosures: EnclosureList{
&Enclosure{MimeType: "image/jpeg"},
&Enclosure{MimeType: "image/png"},
},
expected: false,
},
{
name: "Contains only documents",
enclosures: EnclosureList{
&Enclosure{MimeType: "application/pdf"},
&Enclosure{MimeType: "text/plain"},
},
expected: false,
},
{
name: "Empty list",
enclosures: EnclosureList{},
expected: false,
},
{
name: "Single audio enclosure",
enclosures: EnclosureList{
&Enclosure{MimeType: "audio/wav"},
},
expected: true,
},
{
name: "Single video enclosure",
enclosures: EnclosureList{
&Enclosure{MimeType: "video/webm"},
},
expected: true,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
result := tc.enclosures.ContainsAudioOrVideo()
if result != tc.expected {
t.Errorf("ContainsAudioOrVideo() = %v, want %v", result, tc.expected)
}
})
}
}
func TestEnclosure_ProxifyEnclosureURL(t *testing.T) {
// Initialize config for testing
os.Clearenv()
os.Setenv("BASE_URL", "http://localhost")
os.Setenv("MEDIA_PROXY_PRIVATE_KEY", "test-private-key")
var err error
parser := config.NewConfigParser()
config.Opts, err = parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Config parsing failure: %v`, err)
}
router := mux.NewRouter()
router.HandleFunc("/proxy/{encodedDigest}/{encodedURL}", func(w http.ResponseWriter, r *http.Request) {}).Name("proxy")
testCases := []struct {
name string
url string
mimeType string
mediaProxyOption string
mediaProxyResourceTypes []string
expectedURLChanged bool
}{
{
name: "HTTP URL with audio type - proxy mode all",
url: "http://example.com/audio.mp3",
mimeType: "audio/mpeg",
mediaProxyOption: "all",
mediaProxyResourceTypes: []string{"audio", "video"},
expectedURLChanged: true,
},
{
name: "HTTPS URL with video type - proxy mode all",
url: "https://example.com/video.mp4",
mimeType: "video/mp4",
mediaProxyOption: "all",
mediaProxyResourceTypes: []string{"audio", "video"},
expectedURLChanged: true,
},
{
name: "HTTP URL with video type - proxy mode http-only",
url: "http://example.com/video.mp4",
mimeType: "video/mp4",
mediaProxyOption: "http-only",
mediaProxyResourceTypes: []string{"audio", "video"},
expectedURLChanged: true,
},
{
name: "HTTPS URL with video type - proxy mode http-only",
url: "https://example.com/video.mp4",
mimeType: "video/mp4",
mediaProxyOption: "http-only",
mediaProxyResourceTypes: []string{"audio", "video"},
expectedURLChanged: false,
},
{
name: "HTTP URL with image type - not in resource types",
url: "http://example.com/image.jpg",
mimeType: "image/jpeg",
mediaProxyOption: "all",
mediaProxyResourceTypes: []string{"audio", "video"},
expectedURLChanged: false,
},
{
name: "HTTP URL with image type - in resource types",
url: "http://example.com/image.jpg",
mimeType: "image/jpeg",
mediaProxyOption: "all",
mediaProxyResourceTypes: []string{"audio", "video", "image"},
expectedURLChanged: true,
},
{
name: "HTTP URL - proxy mode none",
url: "http://example.com/audio.mp3",
mimeType: "audio/mpeg",
mediaProxyOption: "none",
mediaProxyResourceTypes: []string{"audio", "video"},
expectedURLChanged: false,
},
{
name: "Empty URL",
url: "",
mimeType: "audio/mpeg",
mediaProxyOption: "all",
mediaProxyResourceTypes: []string{"audio", "video"},
expectedURLChanged: false,
},
{
name: "Non-media MIME type",
url: "http://example.com/doc.pdf",
mimeType: "application/pdf",
mediaProxyOption: "all",
mediaProxyResourceTypes: []string{"audio", "video"},
expectedURLChanged: false,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
enclosure := &Enclosure{
URL: tc.url,
MimeType: tc.mimeType,
}
originalURL := enclosure.URL
// Call the method
enclosure.ProxifyEnclosureURL(router, tc.mediaProxyOption, tc.mediaProxyResourceTypes)
// Check if URL changed as expected
urlChanged := enclosure.URL != originalURL
if urlChanged != tc.expectedURLChanged {
t.Errorf("ProxifyEnclosureURL() URL changed = %v, want %v. Original: %s, New: %s",
urlChanged, tc.expectedURLChanged, originalURL, enclosure.URL)
}
// If URL should have changed, verify it's not empty
if tc.expectedURLChanged && enclosure.URL == "" {
t.Error("ProxifyEnclosureURL() resulted in empty URL when proxification was expected")
}
// If URL shouldn't have changed, verify it's identical
if !tc.expectedURLChanged && enclosure.URL != originalURL {
t.Errorf("ProxifyEnclosureURL() URL changed unexpectedly from %s to %s", originalURL, enclosure.URL)
}
})
}
}
func TestEnclosureList_ProxifyEnclosureURL(t *testing.T) {
// Initialize config for testing
os.Clearenv()
os.Setenv("BASE_URL", "http://localhost")
os.Setenv("MEDIA_PROXY_PRIVATE_KEY", "test-private-key")
var err error
parser := config.NewConfigParser()
config.Opts, err = parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Config parsing failure: %v`, err)
}
router := mux.NewRouter()
router.HandleFunc("/proxy/{encodedDigest}/{encodedURL}", func(w http.ResponseWriter, r *http.Request) {}).Name("proxy")
testCases := []struct {
name string
enclosures EnclosureList
mediaProxyOption string
mediaProxyResourceTypes []string
expectedChangedCount int
}{
{
name: "Mixed enclosures with all proxy mode",
enclosures: EnclosureList{
&Enclosure{URL: "http://example.com/audio.mp3", MimeType: "audio/mpeg"},
&Enclosure{URL: "https://example.com/video.mp4", MimeType: "video/mp4"},
&Enclosure{URL: "http://example.com/image.jpg", MimeType: "image/jpeg"},
&Enclosure{URL: "http://example.com/doc.pdf", MimeType: "application/pdf"},
},
mediaProxyOption: "all",
mediaProxyResourceTypes: []string{"audio", "video"},
expectedChangedCount: 2, // audio and video should be proxified
},
{
name: "Mixed enclosures with http-only proxy mode",
enclosures: EnclosureList{
&Enclosure{URL: "http://example.com/audio.mp3", MimeType: "audio/mpeg"},
&Enclosure{URL: "https://example.com/video.mp4", MimeType: "video/mp4"},
&Enclosure{URL: "http://example.com/video2.mp4", MimeType: "video/mp4"},
},
mediaProxyOption: "http-only",
mediaProxyResourceTypes: []string{"audio", "video"},
expectedChangedCount: 2, // only HTTP URLs should be proxified
},
{
name: "No media types in resource list",
enclosures: EnclosureList{
&Enclosure{URL: "http://example.com/audio.mp3", MimeType: "audio/mpeg"},
&Enclosure{URL: "http://example.com/video.mp4", MimeType: "video/mp4"},
},
mediaProxyOption: "all",
mediaProxyResourceTypes: []string{"image"},
expectedChangedCount: 0, // no matching resource types
},
{
name: "Proxy mode none",
enclosures: EnclosureList{
&Enclosure{URL: "http://example.com/audio.mp3", MimeType: "audio/mpeg"},
&Enclosure{URL: "http://example.com/video.mp4", MimeType: "video/mp4"},
},
mediaProxyOption: "none",
mediaProxyResourceTypes: []string{"audio", "video"},
expectedChangedCount: 0,
},
{
name: "Empty enclosure list",
enclosures: EnclosureList{},
mediaProxyOption: "all",
mediaProxyResourceTypes: []string{"audio", "video"},
expectedChangedCount: 0,
},
{
name: "Enclosures with empty URLs",
enclosures: EnclosureList{
&Enclosure{URL: "", MimeType: "audio/mpeg"},
&Enclosure{URL: "http://example.com/video.mp4", MimeType: "video/mp4"},
},
mediaProxyOption: "all",
mediaProxyResourceTypes: []string{"audio", "video"},
expectedChangedCount: 1, // only the non-empty URL should be processed
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
// Store original URLs
originalURLs := make([]string, len(tc.enclosures))
for i, enclosure := range tc.enclosures {
originalURLs[i] = enclosure.URL
}
// Call the method
tc.enclosures.ProxifyEnclosureURL(router, tc.mediaProxyOption, tc.mediaProxyResourceTypes)
// Count how many URLs actually changed
changedCount := 0
for i, enclosure := range tc.enclosures {
if enclosure.URL != originalURLs[i] {
changedCount++
// Verify that changed URLs are not empty (unless they were empty originally)
if originalURLs[i] != "" && enclosure.URL == "" {
t.Errorf("Enclosure %d: ProxifyEnclosureURL resulted in empty URL", i)
}
}
}
if changedCount != tc.expectedChangedCount {
t.Errorf("ProxifyEnclosureURL() changed %d URLs, want %d", changedCount, tc.expectedChangedCount)
}
})
}
}
func TestEnclosure_ProxifyEnclosureURL_EdgeCases(t *testing.T) {
// Initialize config for testing
os.Clearenv()
os.Setenv("BASE_URL", "http://localhost")
os.Setenv("MEDIA_PROXY_PRIVATE_KEY", "test-private-key")
var err error
parser := config.NewConfigParser()
config.Opts, err = parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Config parsing failure: %v`, err)
}
router := mux.NewRouter()
router.HandleFunc("/proxy/{encodedDigest}/{encodedURL}", func(w http.ResponseWriter, r *http.Request) {}).Name("proxy")
t.Run("Empty resource types slice", func(t *testing.T) {
enclosure := &Enclosure{
URL: "http://example.com/audio.mp3",
MimeType: "audio/mpeg",
}
originalURL := enclosure.URL
enclosure.ProxifyEnclosureURL(router, "all", []string{})
// With empty resource types, URL should not change
if enclosure.URL != originalURL {
t.Errorf("URL should not change with empty resource types. Original: %s, New: %s", originalURL, enclosure.URL)
}
})
t.Run("Nil resource types slice", func(t *testing.T) {
enclosure := &Enclosure{
URL: "http://example.com/audio.mp3",
MimeType: "audio/mpeg",
}
originalURL := enclosure.URL
enclosure.ProxifyEnclosureURL(router, "all", nil)
// With nil resource types, URL should not change
if enclosure.URL != originalURL {
t.Errorf("URL should not change with nil resource types. Original: %s, New: %s", originalURL, enclosure.URL)
}
})
t.Run("Invalid proxy mode", func(t *testing.T) {
enclosure := &Enclosure{
URL: "http://example.com/audio.mp3",
MimeType: "audio/mpeg",
}
originalURL := enclosure.URL
enclosure.ProxifyEnclosureURL(router, "invalid-mode", []string{"audio"})
// With invalid proxy mode, the function still proxifies non-HTTPS URLs
// because shouldProxifyURL defaults to checking URL scheme
if enclosure.URL == originalURL {
t.Errorf("URL should change for HTTP URL even with invalid proxy mode. Original: %s, New: %s", originalURL, enclosure.URL)
}
})
}
v2-2.2.16/internal/model/entry.go 0000664 0000000 0000000 00000005331 15127074645 0016552 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package model // import "miniflux.app/v2/internal/model"
import (
"time"
)
// Entry statuses and default sorting order.
const (
EntryStatusUnread = "unread"
EntryStatusRead = "read"
EntryStatusRemoved = "removed"
DefaultSortingOrder = "published_at"
DefaultSortingDirection = "asc"
)
// Entry represents a feed item in the system.
type Entry struct {
ID int64 `json:"id"`
UserID int64 `json:"user_id"`
FeedID int64 `json:"feed_id"`
Status string `json:"status"`
Hash string `json:"hash"`
Title string `json:"title"`
URL string `json:"url"`
CommentsURL string `json:"comments_url"`
Date time.Time `json:"published_at"`
CreatedAt time.Time `json:"created_at"`
ChangedAt time.Time `json:"changed_at"`
Content string `json:"content"`
Author string `json:"author"`
ShareCode string `json:"share_code"`
Starred bool `json:"starred"`
ReadingTime int `json:"reading_time"`
Enclosures EnclosureList `json:"enclosures"`
Feed *Feed `json:"feed,omitempty"`
Tags []string `json:"tags"`
}
func NewEntry() *Entry {
return &Entry{
Enclosures: make(EnclosureList, 0),
Tags: make([]string, 0),
Feed: &Feed{
Category: &Category{},
Icon: &FeedIcon{},
},
}
}
// ShouldMarkAsReadOnView Return whether the entry should be marked as viewed considering all user settings and entry state.
func (e *Entry) ShouldMarkAsReadOnView(user *User) bool {
// Already read, no need to mark as read again. Removed entries are not marked as read
if e.Status != EntryStatusUnread {
return false
}
// There is an enclosure, markAsRead will happen at enclosure completion time, no need to mark as read on view
if user.MarkReadOnMediaPlayerCompletion && e.Enclosures.ContainsAudioOrVideo() {
return false
}
// The user wants to mark as read on view
return user.MarkReadOnView
}
// Entries represents a list of entries.
type Entries []*Entry
// EntriesStatusUpdateRequest represents a request to change entries status.
type EntriesStatusUpdateRequest struct {
EntryIDs []int64 `json:"entry_ids"`
Status string `json:"status"`
}
// EntryUpdateRequest represents a request to update an entry.
type EntryUpdateRequest struct {
Title *string `json:"title"`
Content *string `json:"content"`
}
func (e *EntryUpdateRequest) Patch(entry *Entry) {
if e.Title != nil && *e.Title != "" {
entry.Title = *e.Title
}
if e.Content != nil && *e.Content != "" {
entry.Content = *e.Content
}
}
v2-2.2.16/internal/model/feed.go 0000664 0000000 0000000 00000025206 15127074645 0016317 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package model // import "miniflux.app/v2/internal/model"
import (
"fmt"
"io"
"time"
"miniflux.app/v2/internal/config"
)
// List of supported schedulers.
const (
SchedulerRoundRobin = "round_robin"
SchedulerEntryFrequency = "entry_frequency"
// Default settings for the feed query builder
DefaultFeedSorting = "parsing_error_count"
DefaultFeedSortingDirection = "desc"
)
// Feed represents a feed in the application.
type Feed struct {
ID int64 `json:"id"`
UserID int64 `json:"user_id"`
FeedURL string `json:"feed_url"`
SiteURL string `json:"site_url"`
Title string `json:"title"`
Description string `json:"description"`
CheckedAt time.Time `json:"checked_at"`
NextCheckAt time.Time `json:"next_check_at"`
EtagHeader string `json:"etag_header"`
LastModifiedHeader string `json:"last_modified_header"`
ParsingErrorMsg string `json:"parsing_error_message"`
ParsingErrorCount int `json:"parsing_error_count"`
ScraperRules string `json:"scraper_rules"`
RewriteRules string `json:"rewrite_rules"`
BlocklistRules string `json:"blocklist_rules"`
KeeplistRules string `json:"keeplist_rules"`
BlockFilterEntryRules string `json:"block_filter_entry_rules"`
KeepFilterEntryRules string `json:"keep_filter_entry_rules"`
UrlRewriteRules string `json:"urlrewrite_rules"`
UserAgent string `json:"user_agent"`
Cookie string `json:"cookie"`
Username string `json:"username"`
Password string `json:"password"`
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"`
HideGlobally bool `json:"hide_globally"`
DisableHTTP2 bool `json:"disable_http2"`
PushoverEnabled bool `json:"pushover_enabled"`
NtfyEnabled bool `json:"ntfy_enabled"`
Crawler bool `json:"crawler"`
AppriseServiceURLs string `json:"apprise_service_urls"`
WebhookURL string `json:"webhook_url"`
NtfyPriority int `json:"ntfy_priority"`
NtfyTopic string `json:"ntfy_topic"`
PushoverPriority int `json:"pushover_priority"`
ProxyURL string `json:"proxy_url"`
// Non-persisted attributes
Category *Category `json:"category,omitempty"`
Icon *FeedIcon `json:"icon"`
Entries Entries `json:"entries,omitempty"`
// Internal attributes (not exposed in the API and not persisted in the database)
TTL time.Duration `json:"-"`
IconURL string `json:"-"`
UnreadCount int `json:"-"`
ReadCount int `json:"-"`
NumberOfVisibleEntries int `json:"-"`
}
type FeedCounters struct {
ReadCounters map[int64]int `json:"reads"`
UnreadCounters map[int64]int `json:"unreads"`
}
func (f *Feed) String() string {
return fmt.Sprintf("ID=%d, UserID=%d, FeedURL=%s, SiteURL=%s, Title=%s, Category={%s}",
f.ID,
f.UserID,
f.FeedURL,
f.SiteURL,
f.Title,
f.Category,
)
}
// WithCategoryID initializes the category attribute of the feed.
func (f *Feed) WithCategoryID(categoryID int64) {
f.Category = &Category{ID: categoryID}
}
// WithTranslatedErrorMessage adds a new error message and increment the error counter.
func (f *Feed) WithTranslatedErrorMessage(message string) {
f.ParsingErrorCount++
f.ParsingErrorMsg = message
}
// ResetErrorCounter removes all previous errors.
func (f *Feed) ResetErrorCounter() {
f.ParsingErrorCount = 0
f.ParsingErrorMsg = ""
}
// CheckedNow set attribute values when the feed is refreshed.
func (f *Feed) CheckedNow() {
f.CheckedAt = time.Now()
if f.SiteURL == "" {
f.SiteURL = f.FeedURL
}
}
// ScheduleNextCheck set "next_check_at" of a feed based on the scheduler selected from the configuration.
func (f *Feed) ScheduleNextCheck(weeklyCount int, refreshDelay time.Duration) time.Duration {
// Default to the global config Polling Frequency.
interval := config.Opts.SchedulerRoundRobinMinInterval()
if config.Opts.PollingScheduler() == SchedulerEntryFrequency {
if weeklyCount <= 0 {
interval = config.Opts.SchedulerEntryFrequencyMaxInterval()
} else {
interval = (7 * 24 * time.Hour) / time.Duration(weeklyCount*config.Opts.SchedulerEntryFrequencyFactor())
interval = min(interval, config.Opts.SchedulerEntryFrequencyMaxInterval())
interval = max(interval, config.Opts.SchedulerEntryFrequencyMinInterval())
}
}
// Use the RSS TTL field, Retry-After, Cache-Control or Expires HTTP headers if defined.
interval = max(interval, refreshDelay)
// Limit the max interval value for misconfigured feeds.
switch config.Opts.PollingScheduler() {
case SchedulerRoundRobin:
interval = min(interval, config.Opts.SchedulerRoundRobinMaxInterval())
case SchedulerEntryFrequency:
interval = min(interval, config.Opts.SchedulerEntryFrequencyMaxInterval())
}
f.NextCheckAt = time.Now().Add(interval)
return interval
}
// FeedCreationRequest represents the request to create a feed.
type FeedCreationRequest struct {
FeedURL string `json:"feed_url"`
CategoryID int64 `json:"category_id"`
UserAgent string `json:"user_agent"`
Cookie string `json:"cookie"`
Username string `json:"username"`
Password string `json:"password"`
Crawler bool `json:"crawler"`
Disabled bool `json:"disabled"`
NoMediaPlayer bool `json:"no_media_player"`
IgnoreHTTPCache bool `json:"ignore_http_cache"`
AllowSelfSignedCertificates bool `json:"allow_self_signed_certificates"`
FetchViaProxy bool `json:"fetch_via_proxy"`
HideGlobally bool `json:"hide_globally"`
DisableHTTP2 bool `json:"disable_http2"`
ScraperRules string `json:"scraper_rules"`
RewriteRules string `json:"rewrite_rules"`
BlocklistRules string `json:"blocklist_rules"`
KeeplistRules string `json:"keeplist_rules"`
BlockFilterEntryRules string `json:"block_filter_entry_rules"`
KeepFilterEntryRules string `json:"keep_filter_entry_rules"`
UrlRewriteRules string `json:"urlrewrite_rules"`
ProxyURL string `json:"proxy_url"`
}
type FeedCreationRequestFromSubscriptionDiscovery struct {
Content io.ReadSeeker
ETag string
LastModified string
FeedCreationRequest
}
// FeedModificationRequest represents the request to update a feed.
type FeedModificationRequest struct {
FeedURL *string `json:"feed_url"`
SiteURL *string `json:"site_url"`
Title *string `json:"title"`
Description *string `json:"description"`
ScraperRules *string `json:"scraper_rules"`
RewriteRules *string `json:"rewrite_rules"`
BlocklistRules *string `json:"blocklist_rules"`
UrlRewriteRules *string `json:"urlrewrite_rules"`
KeeplistRules *string `json:"keeplist_rules"`
BlockFilterEntryRules *string `json:"block_filter_entry_rules"`
KeepFilterEntryRules *string `json:"keep_filter_entry_rules"`
Crawler *bool `json:"crawler"`
UserAgent *string `json:"user_agent"`
Cookie *string `json:"cookie"`
Username *string `json:"username"`
Password *string `json:"password"`
CategoryID *int64 `json:"category_id"`
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"`
HideGlobally *bool `json:"hide_globally"`
DisableHTTP2 *bool `json:"disable_http2"`
ProxyURL *string `json:"proxy_url"`
}
// Patch updates a feed with modified values.
func (f *FeedModificationRequest) Patch(feed *Feed) {
if f.FeedURL != nil && *f.FeedURL != "" {
feed.FeedURL = *f.FeedURL
}
if f.SiteURL != nil && *f.SiteURL != "" {
feed.SiteURL = *f.SiteURL
}
if f.Title != nil && *f.Title != "" {
feed.Title = *f.Title
}
if f.Description != nil && *f.Description != "" {
feed.Description = *f.Description
}
if f.ScraperRules != nil {
feed.ScraperRules = *f.ScraperRules
}
if f.RewriteRules != nil {
feed.RewriteRules = *f.RewriteRules
}
if f.UrlRewriteRules != nil {
feed.UrlRewriteRules = *f.UrlRewriteRules
}
if f.KeeplistRules != nil {
feed.KeeplistRules = *f.KeeplistRules
}
if f.BlocklistRules != nil {
feed.BlocklistRules = *f.BlocklistRules
}
if f.BlockFilterEntryRules != nil {
feed.BlockFilterEntryRules = *f.BlockFilterEntryRules
}
if f.KeepFilterEntryRules != nil {
feed.KeepFilterEntryRules = *f.KeepFilterEntryRules
}
if f.Crawler != nil {
feed.Crawler = *f.Crawler
}
if f.UserAgent != nil {
feed.UserAgent = *f.UserAgent
}
if f.Cookie != nil {
feed.Cookie = *f.Cookie
}
if f.Username != nil {
feed.Username = *f.Username
}
if f.Password != nil {
feed.Password = *f.Password
}
if f.CategoryID != nil && *f.CategoryID > 0 {
feed.Category.ID = *f.CategoryID
}
if f.Disabled != nil {
feed.Disabled = *f.Disabled
}
if f.NoMediaPlayer != nil {
feed.NoMediaPlayer = *f.NoMediaPlayer
}
if f.IgnoreHTTPCache != nil {
feed.IgnoreHTTPCache = *f.IgnoreHTTPCache
}
if f.AllowSelfSignedCertificates != nil {
feed.AllowSelfSignedCertificates = *f.AllowSelfSignedCertificates
}
if f.FetchViaProxy != nil {
feed.FetchViaProxy = *f.FetchViaProxy
}
if f.HideGlobally != nil {
feed.HideGlobally = *f.HideGlobally
}
if f.DisableHTTP2 != nil {
feed.DisableHTTP2 = *f.DisableHTTP2
}
if f.ProxyURL != nil {
feed.ProxyURL = *f.ProxyURL
}
}
// Feeds is a list of feed
type Feeds []*Feed
v2-2.2.16/internal/model/feed_test.go 0000664 0000000 0000000 00000025422 15127074645 0017356 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package model // import "miniflux.app/v2/internal/model"
import (
"os"
"strconv"
"testing"
"time"
"miniflux.app/v2/internal/config"
)
const (
largeWeeklyCount = 10080
noRefreshDelay = 0
)
func TestFeedCategorySetter(t *testing.T) {
feed := &Feed{}
feed.WithCategoryID(int64(123))
if feed.Category == nil {
t.Fatal(`The category field should not be null`)
}
if feed.Category.ID != int64(123) {
t.Error(`The category ID must be set`)
}
}
func TestFeedErrorCounter(t *testing.T) {
feed := &Feed{}
feed.WithTranslatedErrorMessage("Some Error")
if feed.ParsingErrorMsg != "Some Error" {
t.Error(`The error message must be set`)
}
if feed.ParsingErrorCount != 1 {
t.Error(`The error counter must be set to 1`)
}
feed.ResetErrorCounter()
if feed.ParsingErrorMsg != "" {
t.Error(`The error message must be removed`)
}
if feed.ParsingErrorCount != 0 {
t.Error(`The error counter must be set to 0`)
}
}
func TestFeedCheckedNow(t *testing.T) {
feed := &Feed{}
feed.FeedURL = "https://example.org/feed"
feed.CheckedNow()
if feed.SiteURL != feed.FeedURL {
t.Error(`The site URL must not be empty`)
}
if feed.CheckedAt.IsZero() {
t.Error(`The checked date must be set`)
}
}
func checkTargetInterval(t *testing.T, feed *Feed, targetInterval time.Duration, timeBefore time.Time, message string) {
if feed.NextCheckAt.Before(timeBefore.Add(targetInterval)) {
t.Errorf(`The next_check_at should be after timeBefore + %s`, message)
}
if feed.NextCheckAt.After(time.Now().Add(targetInterval)) {
t.Errorf(`The next_check_at should be before now + %s`, message)
}
}
func TestFeedScheduleNextCheckRoundRobinDefault(t *testing.T) {
os.Clearenv()
var err error
parser := config.NewConfigParser()
config.Opts, err = parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
timeBefore := time.Now()
feed := &Feed{}
feed.ScheduleNextCheck(0, noRefreshDelay)
if feed.NextCheckAt.IsZero() {
t.Error(`The next_check_at must be set`)
}
targetInterval := config.Opts.SchedulerRoundRobinMinInterval()
checkTargetInterval(t, feed, targetInterval, timeBefore, "TestFeedScheduleNextCheckRoundRobinDefault")
}
func TestFeedScheduleNextCheckRoundRobinWithRefreshDelayAboveMinInterval(t *testing.T) {
os.Clearenv()
var err error
parser := config.NewConfigParser()
config.Opts, err = parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
timeBefore := time.Now()
feed := &Feed{}
feed.ScheduleNextCheck(0, config.Opts.SchedulerRoundRobinMinInterval()+30)
if feed.NextCheckAt.IsZero() {
t.Error(`The next_check_at must be set`)
}
expectedInterval := config.Opts.SchedulerRoundRobinMinInterval() + 30
checkTargetInterval(t, feed, expectedInterval, timeBefore, "TestFeedScheduleNextCheckRoundRobinWithRefreshDelayAboveMinInterval")
}
func TestFeedScheduleNextCheckRoundRobinWithRefreshDelayBelowMinInterval(t *testing.T) {
os.Clearenv()
var err error
parser := config.NewConfigParser()
config.Opts, err = parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
timeBefore := time.Now()
feed := &Feed{}
feed.ScheduleNextCheck(0, config.Opts.SchedulerRoundRobinMinInterval()-30)
if feed.NextCheckAt.IsZero() {
t.Error(`The next_check_at must be set`)
}
expectedInterval := config.Opts.SchedulerRoundRobinMinInterval()
checkTargetInterval(t, feed, expectedInterval, timeBefore, "TestFeedScheduleNextCheckRoundRobinWithRefreshDelayBelowMinInterval")
}
func TestFeedScheduleNextCheckRoundRobinWithRefreshDelayAboveMaxInterval(t *testing.T) {
os.Clearenv()
var err error
parser := config.NewConfigParser()
config.Opts, err = parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
timeBefore := time.Now()
feed := &Feed{}
feed.ScheduleNextCheck(0, config.Opts.SchedulerRoundRobinMaxInterval()+30)
if feed.NextCheckAt.IsZero() {
t.Error(`The next_check_at must be set`)
}
expectedInterval := config.Opts.SchedulerRoundRobinMaxInterval()
checkTargetInterval(t, feed, expectedInterval, timeBefore, "TestFeedScheduleNextCheckRoundRobinWithRefreshDelayAboveMaxInterval")
}
func TestFeedScheduleNextCheckRoundRobinMinInterval(t *testing.T) {
minInterval := 1
os.Clearenv()
os.Setenv("POLLING_SCHEDULER", "round_robin")
os.Setenv("SCHEDULER_ROUND_ROBIN_MIN_INTERVAL", strconv.Itoa(minInterval))
var err error
parser := config.NewConfigParser()
config.Opts, err = parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
timeBefore := time.Now()
feed := &Feed{}
feed.ScheduleNextCheck(0, noRefreshDelay)
if feed.NextCheckAt.IsZero() {
t.Error(`The next_check_at must be set`)
}
expectedInterval := time.Duration(minInterval) * time.Minute
checkTargetInterval(t, feed, expectedInterval, timeBefore, "TestFeedScheduleNextCheckRoundRobinMinInterval")
}
func TestFeedScheduleNextCheckEntryFrequencyMaxInterval(t *testing.T) {
maxInterval := 5
minInterval := 1
os.Clearenv()
os.Setenv("POLLING_SCHEDULER", "entry_frequency")
os.Setenv("SCHEDULER_ENTRY_FREQUENCY_MAX_INTERVAL", strconv.Itoa(maxInterval))
os.Setenv("SCHEDULER_ENTRY_FREQUENCY_MIN_INTERVAL", strconv.Itoa(minInterval))
var err error
parser := config.NewConfigParser()
config.Opts, err = parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
timeBefore := time.Now()
feed := &Feed{}
// Use a very small weekly count to trigger the max interval
weeklyCount := 1
feed.ScheduleNextCheck(weeklyCount, noRefreshDelay)
if feed.NextCheckAt.IsZero() {
t.Error(`The next_check_at must be set`)
}
targetInterval := time.Duration(maxInterval) * time.Minute
checkTargetInterval(t, feed, targetInterval, timeBefore, "entry frequency max interval")
}
func TestFeedScheduleNextCheckEntryFrequencyMaxIntervalZeroWeeklyCount(t *testing.T) {
maxInterval := 5
minInterval := 1
os.Clearenv()
os.Setenv("POLLING_SCHEDULER", "entry_frequency")
os.Setenv("SCHEDULER_ENTRY_FREQUENCY_MAX_INTERVAL", strconv.Itoa(maxInterval))
os.Setenv("SCHEDULER_ENTRY_FREQUENCY_MIN_INTERVAL", strconv.Itoa(minInterval))
var err error
parser := config.NewConfigParser()
config.Opts, err = parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
timeBefore := time.Now()
feed := &Feed{}
// Use a very small weekly count to trigger the max interval
weeklyCount := 0
feed.ScheduleNextCheck(weeklyCount, noRefreshDelay)
if feed.NextCheckAt.IsZero() {
t.Error(`The next_check_at must be set`)
}
targetInterval := time.Duration(maxInterval) * time.Minute
checkTargetInterval(t, feed, targetInterval, timeBefore, "entry frequency max interval")
}
func TestFeedScheduleNextCheckEntryFrequencyMinInterval(t *testing.T) {
maxInterval := 500
minInterval := 100
os.Clearenv()
os.Setenv("POLLING_SCHEDULER", "entry_frequency")
os.Setenv("SCHEDULER_ENTRY_FREQUENCY_MAX_INTERVAL", strconv.Itoa(maxInterval))
os.Setenv("SCHEDULER_ENTRY_FREQUENCY_MIN_INTERVAL", strconv.Itoa(minInterval))
var err error
parser := config.NewConfigParser()
config.Opts, err = parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
timeBefore := time.Now()
feed := &Feed{}
// Use a very large weekly count to trigger the min interval
weeklyCount := largeWeeklyCount
feed.ScheduleNextCheck(weeklyCount, noRefreshDelay)
if feed.NextCheckAt.IsZero() {
t.Error(`The next_check_at must be set`)
}
targetInterval := time.Duration(minInterval) * time.Minute
checkTargetInterval(t, feed, targetInterval, timeBefore, "entry frequency min interval")
}
func TestFeedScheduleNextCheckEntryFrequencyFactor(t *testing.T) {
factor := 2
os.Clearenv()
os.Setenv("POLLING_SCHEDULER", "entry_frequency")
os.Setenv("SCHEDULER_ENTRY_FREQUENCY_FACTOR", strconv.Itoa(factor))
var err error
parser := config.NewConfigParser()
config.Opts, err = parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
timeBefore := time.Now()
feed := &Feed{}
weeklyCount := 7
feed.ScheduleNextCheck(weeklyCount, noRefreshDelay)
if feed.NextCheckAt.IsZero() {
t.Error(`The next_check_at must be set`)
}
targetInterval := config.Opts.SchedulerEntryFrequencyMaxInterval() / time.Duration(factor)
checkTargetInterval(t, feed, targetInterval, timeBefore, "factor * count")
}
func TestFeedScheduleNextCheckEntryFrequencySmallNewTTL(t *testing.T) {
// If the feed has a TTL defined, we use it to make sure we don't check it too often.
maxInterval := 500
minInterval := 100
os.Clearenv()
os.Setenv("POLLING_SCHEDULER", "entry_frequency")
os.Setenv("SCHEDULER_ENTRY_FREQUENCY_MAX_INTERVAL", strconv.Itoa(maxInterval))
os.Setenv("SCHEDULER_ENTRY_FREQUENCY_MIN_INTERVAL", strconv.Itoa(minInterval))
var err error
parser := config.NewConfigParser()
config.Opts, err = parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
timeBefore := time.Now()
feed := &Feed{}
// Use a very large weekly count to trigger the min interval
weeklyCount := largeWeeklyCount
// TTL is smaller than minInterval.
newTTL := time.Duration(minInterval) * time.Minute / 2
feed.ScheduleNextCheck(weeklyCount, newTTL)
if feed.NextCheckAt.IsZero() {
t.Error(`The next_check_at must be set`)
}
targetInterval := time.Duration(minInterval) * time.Minute
checkTargetInterval(t, feed, targetInterval, timeBefore, "entry frequency min interval")
if feed.NextCheckAt.Before(timeBefore.Add(newTTL)) {
t.Error(`The next_check_at should be after timeBefore + TTL`)
}
}
func TestFeedScheduleNextCheckEntryFrequencyLargeNewTTL(t *testing.T) {
// If the feed has a TTL defined, we use it to make sure we don't check it too often.
maxInterval := 500
minInterval := 100
os.Clearenv()
os.Setenv("POLLING_SCHEDULER", "entry_frequency")
os.Setenv("SCHEDULER_ENTRY_FREQUENCY_MAX_INTERVAL", strconv.Itoa(maxInterval))
os.Setenv("SCHEDULER_ENTRY_FREQUENCY_MIN_INTERVAL", strconv.Itoa(minInterval))
var err error
parser := config.NewConfigParser()
config.Opts, err = parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
timeBefore := time.Now()
feed := &Feed{}
// Use a very large weekly count to trigger the min interval
weeklyCount := largeWeeklyCount
// TTL is larger than minInterval.
newTTL := time.Duration(minInterval) * time.Minute * 2
feed.ScheduleNextCheck(weeklyCount, newTTL)
if feed.NextCheckAt.IsZero() {
t.Error(`The next_check_at must be set`)
}
targetInterval := newTTL
checkTargetInterval(t, feed, targetInterval, timeBefore, "TTL")
if feed.NextCheckAt.Before(timeBefore.Add(time.Minute * time.Duration(minInterval))) {
t.Error(`The next_check_at should be after timeBefore + entry frequency min interval`)
}
}
v2-2.2.16/internal/model/home_page.go 0000664 0000000 0000000 00000000716 15127074645 0017337 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package model // import "miniflux.app/v2/internal/model"
// HomePages returns the list of available home pages.
func HomePages() map[string]string {
return map[string]string{
"unread": "menu.unread",
"starred": "menu.starred",
"history": "menu.history",
"feeds": "menu.feeds",
"categories": "menu.categories",
}
}
v2-2.2.16/internal/model/icon.go 0000664 0000000 0000000 00000001554 15127074645 0016344 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package model // import "miniflux.app/v2/internal/model"
import (
"encoding/base64"
)
// Icon represents a website icon (favicon)
type Icon struct {
ID int64 `json:"id"`
Hash string `json:"hash"`
MimeType string `json:"mime_type"`
Content []byte `json:"-"`
ExternalID string `json:"external_id"`
}
// DataURL returns the data URL of the icon.
func (i *Icon) DataURL() string {
return i.MimeType + ";base64," + base64.StdEncoding.EncodeToString(i.Content)
}
// Icons represents a list of icons.
type Icons []*Icon
// FeedIcon is a junction table between feeds and icons.
type FeedIcon struct {
FeedID int64 `json:"feed_id"`
IconID int64 `json:"icon_id"`
ExternalIconID string `json:"external_icon_id"`
}
v2-2.2.16/internal/model/integration.go 0000664 0000000 0000000 00000012070 15127074645 0017732 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package model // import "miniflux.app/v2/internal/model"
// Integration represents user integration settings.
type Integration struct {
UserID int64
BetulaEnabled bool
BetulaURL string
BetulaToken string
PinboardEnabled bool
PinboardToken string
PinboardTags string
PinboardMarkAsUnread bool
InstapaperEnabled bool
InstapaperUsername string
InstapaperPassword string
FeverEnabled bool
FeverUsername string
FeverToken string
GoogleReaderEnabled bool
GoogleReaderUsername string
GoogleReaderPassword string
WallabagEnabled bool
WallabagOnlyURL bool
WallabagURL string
WallabagClientID string
WallabagClientSecret string
WallabagUsername string
WallabagPassword string
WallabagTags string
NunuxKeeperEnabled bool
NunuxKeeperURL string
NunuxKeeperAPIKey string
NotionEnabled bool
NotionToken string
NotionPageID string
EspialEnabled bool
EspialURL string
EspialAPIKey string
EspialTags string
ReadwiseEnabled bool
ReadwiseAPIKey string
TelegramBotEnabled bool
TelegramBotToken string
TelegramBotChatID string
TelegramBotTopicID *int64
TelegramBotDisableWebPagePreview bool
TelegramBotDisableNotification bool
TelegramBotDisableButtons bool
LinkAceEnabled bool
LinkAceURL string
LinkAceAPIKey string
LinkAceTags string
LinkAcePrivate bool
LinkAceCheckDisabled bool
LinkdingEnabled bool
LinkdingURL string
LinkdingAPIKey string
LinkdingTags string
LinkdingMarkAsUnread bool
LinktacoEnabled bool
LinktacoAPIToken string
LinktacoOrgSlug string
LinktacoTags string
LinktacoVisibility string
LinkwardenEnabled bool
LinkwardenURL string
LinkwardenAPIKey string
LinkwardenCollectionID *int64
MatrixBotEnabled bool
MatrixBotUser string
MatrixBotPassword string
MatrixBotURL string
MatrixBotChatID string
AppriseEnabled bool
AppriseURL string
AppriseServicesURL string
ReadeckEnabled bool
ReadeckPushEnabled bool
ReadeckURL string
ReadeckAPIKey string
ReadeckLabels string
ReadeckOnlyURL bool
ShioriEnabled bool
ShioriURL string
ShioriUsername string
ShioriPassword string
ShaarliEnabled bool
ShaarliURL string
ShaarliAPISecret string
WebhookEnabled bool
WebhookURL string
WebhookSecret string
RSSBridgeEnabled bool
RSSBridgeURL string
RSSBridgeToken string
OmnivoreEnabled bool
OmnivoreAPIKey string
OmnivoreURL string
KarakeepEnabled bool
KarakeepAPIKey string
KarakeepURL string
KarakeepTags string
RaindropEnabled bool
RaindropToken string
RaindropCollectionID string
RaindropTags string
NtfyEnabled bool
NtfyTopic string
NtfyURL string
NtfyAPIToken string
NtfyUsername string
NtfyPassword string
NtfyIconURL string
NtfyInternalLinks bool
CuboxEnabled bool
CuboxAPILink string
DiscordEnabled bool
DiscordWebhookLink string
SlackEnabled bool
SlackWebhookLink string
PushoverEnabled bool
PushoverUser string
PushoverToken string
PushoverDevice string
PushoverPrefix string
ArchiveorgEnabled bool
}
v2-2.2.16/internal/model/job.go 0000664 0000000 0000000 00000001234 15127074645 0016161 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package model // import "miniflux.app/v2/internal/model"
// Job represents a payload sent to the processing queue.
type Job struct {
UserID int64
FeedID int64
FeedURL string
}
// JobList represents a list of jobs.
type JobList []Job
// FeedURLs returns a list of feed URLs from the job list.
// This is useful for logging or debugging purposes to see which feeds are being processed.
func (jl *JobList) FeedURLs() []string {
feedURLs := make([]string, len(*jl))
for i, job := range *jl {
feedURLs[i] = job.FeedURL
}
return feedURLs
}
v2-2.2.16/internal/model/model.go 0000664 0000000 0000000 00000000736 15127074645 0016515 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package model // import "miniflux.app/v2/internal/model"
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 {
if value != "" {
return &value
}
return nil
}
func SetOptionalField[T any](value T) *T {
return &value
}
v2-2.2.16/internal/model/subscription.go 0000664 0000000 0000000 00000001463 15127074645 0020137 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package model // import "miniflux.app/v2/internal/model"
// SubscriptionDiscoveryRequest represents a request to discover subscriptions.
type SubscriptionDiscoveryRequest struct {
URL string `json:"url"`
UserAgent string `json:"user_agent"`
Cookie string `json:"cookie"`
Username string `json:"username"`
Password string `json:"password"`
ProxyURL string `json:"proxy_url"`
FetchViaProxy bool `json:"fetch_via_proxy"`
AllowSelfSignedCertificates bool `json:"allow_self_signed_certificates"`
DisableHTTP2 bool `json:"disable_http2"`
}
v2-2.2.16/internal/model/theme.go 0000664 0000000 0000000 00000002133 15127074645 0016510 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package model // import "miniflux.app/v2/internal/model"
// Themes returns the list of available themes.
func Themes() map[string]string {
return map[string]string{
"light_serif": "Light - Serif",
"light_sans_serif": "Light - Sans Serif",
"dark_serif": "Dark - Serif",
"dark_sans_serif": "Dark - Sans Serif",
"system_serif": "System - Serif",
"system_sans_serif": "System - Sans Serif",
}
}
// ThemeColor returns the color for the address bar or/and the browser color.
// https://developer.mozilla.org/en-US/docs/Web/Manifest#theme_color
// https://developers.google.com/web/tools/lighthouse/audits/address-bar
// https://developer.mozilla.org/en-US/docs/Web/HTML/Element/meta/name/theme-color
func ThemeColor(theme, colorScheme string) string {
switch theme {
case "dark_serif", "dark_sans_serif":
return "#222"
case "system_serif", "system_sans_serif":
if colorScheme == "dark" {
return "#222"
}
return "#fff"
default:
return "#fff"
}
}
v2-2.2.16/internal/model/user.go 0000664 0000000 0000000 00000017244 15127074645 0016375 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package model // import "miniflux.app/v2/internal/model"
import (
"time"
"miniflux.app/v2/internal/timezone"
)
// User represents a user in the system.
type User struct {
ID int64 `json:"id"`
Username string `json:"username"`
Password string `json:"-"`
IsAdmin bool `json:"is_admin"`
Theme string `json:"theme"`
Language string `json:"language"`
Timezone string `json:"timezone"`
EntryDirection string `json:"entry_sorting_direction"`
EntryOrder string `json:"entry_sorting_order"`
Stylesheet string `json:"stylesheet"`
CustomJS string `json:"custom_js"`
ExternalFontHosts string `json:"external_font_hosts"`
GoogleID string `json:"google_id"`
OpenIDConnectID string `json:"openid_connect_id"`
EntriesPerPage int `json:"entries_per_page"`
KeyboardShortcuts bool `json:"keyboard_shortcuts"`
ShowReadingTime bool `json:"show_reading_time"`
EntrySwipe bool `json:"entry_swipe"`
GestureNav string `json:"gesture_nav"`
LastLoginAt *time.Time `json:"last_login_at"`
DisplayMode string `json:"display_mode"`
DefaultReadingSpeed int `json:"default_reading_speed"`
CJKReadingSpeed int `json:"cjk_reading_speed"`
DefaultHomePage string `json:"default_home_page"`
CategoriesSortingOrder string `json:"categories_sorting_order"`
MarkReadOnView bool `json:"mark_read_on_view"`
MarkReadOnMediaPlayerCompletion bool `json:"mark_read_on_media_player_completion"`
MediaPlaybackRate float64 `json:"media_playback_rate"`
BlockFilterEntryRules string `json:"block_filter_entry_rules"`
KeepFilterEntryRules string `json:"keep_filter_entry_rules"`
AlwaysOpenExternalLinks bool `json:"always_open_external_links"`
OpenExternalLinksInNewTab bool `json:"open_external_links_in_new_tab"`
}
// UserCreationRequest represents the request to create a user.
type UserCreationRequest struct {
Username string `json:"username"`
Password string `json:"password"`
IsAdmin bool `json:"is_admin"`
GoogleID string `json:"google_id"`
OpenIDConnectID string `json:"openid_connect_id"`
}
// UserModificationRequest represents the request to update a user.
type UserModificationRequest struct {
Username *string `json:"username"`
Password *string `json:"password"`
Theme *string `json:"theme"`
Language *string `json:"language"`
Timezone *string `json:"timezone"`
EntryDirection *string `json:"entry_sorting_direction"`
EntryOrder *string `json:"entry_sorting_order"`
Stylesheet *string `json:"stylesheet"`
CustomJS *string `json:"custom_js"`
ExternalFontHosts *string `json:"external_font_hosts"`
GoogleID *string `json:"google_id"`
OpenIDConnectID *string `json:"openid_connect_id"`
EntriesPerPage *int `json:"entries_per_page"`
IsAdmin *bool `json:"is_admin"`
KeyboardShortcuts *bool `json:"keyboard_shortcuts"`
ShowReadingTime *bool `json:"show_reading_time"`
EntrySwipe *bool `json:"entry_swipe"`
GestureNav *string `json:"gesture_nav"`
DisplayMode *string `json:"display_mode"`
DefaultReadingSpeed *int `json:"default_reading_speed"`
CJKReadingSpeed *int `json:"cjk_reading_speed"`
DefaultHomePage *string `json:"default_home_page"`
CategoriesSortingOrder *string `json:"categories_sorting_order"`
MarkReadOnView *bool `json:"mark_read_on_view"`
MarkReadOnMediaPlayerCompletion *bool `json:"mark_read_on_media_player_completion"`
MediaPlaybackRate *float64 `json:"media_playback_rate"`
BlockFilterEntryRules *string `json:"block_filter_entry_rules"`
KeepFilterEntryRules *string `json:"keep_filter_entry_rules"`
AlwaysOpenExternalLinks *bool `json:"always_open_external_links"`
OpenExternalLinksInNewTab *bool `json:"open_external_links_in_new_tab"`
}
// Patch updates the User object with the modification request.
func (u *UserModificationRequest) Patch(user *User) {
if u.Username != nil {
user.Username = *u.Username
}
if u.Password != nil {
user.Password = *u.Password
}
if u.IsAdmin != nil {
user.IsAdmin = *u.IsAdmin
}
if u.Theme != nil {
user.Theme = *u.Theme
}
if u.Language != nil {
user.Language = *u.Language
}
if u.Timezone != nil {
user.Timezone = *u.Timezone
}
if u.EntryDirection != nil {
user.EntryDirection = *u.EntryDirection
}
if u.EntryOrder != nil {
user.EntryOrder = *u.EntryOrder
}
if u.Stylesheet != nil {
user.Stylesheet = *u.Stylesheet
}
if u.CustomJS != nil {
user.CustomJS = *u.CustomJS
}
if u.ExternalFontHosts != nil {
user.ExternalFontHosts = *u.ExternalFontHosts
}
if u.GoogleID != nil {
user.GoogleID = *u.GoogleID
}
if u.OpenIDConnectID != nil {
user.OpenIDConnectID = *u.OpenIDConnectID
}
if u.EntriesPerPage != nil {
user.EntriesPerPage = *u.EntriesPerPage
}
if u.KeyboardShortcuts != nil {
user.KeyboardShortcuts = *u.KeyboardShortcuts
}
if u.ShowReadingTime != nil {
user.ShowReadingTime = *u.ShowReadingTime
}
if u.EntrySwipe != nil {
user.EntrySwipe = *u.EntrySwipe
}
if u.GestureNav != nil {
user.GestureNav = *u.GestureNav
}
if u.DisplayMode != nil {
user.DisplayMode = *u.DisplayMode
}
if u.DefaultReadingSpeed != nil {
user.DefaultReadingSpeed = *u.DefaultReadingSpeed
}
if u.CJKReadingSpeed != nil {
user.CJKReadingSpeed = *u.CJKReadingSpeed
}
if u.DefaultHomePage != nil {
user.DefaultHomePage = *u.DefaultHomePage
}
if u.CategoriesSortingOrder != nil {
user.CategoriesSortingOrder = *u.CategoriesSortingOrder
}
if u.MarkReadOnView != nil {
user.MarkReadOnView = *u.MarkReadOnView
}
if u.MarkReadOnMediaPlayerCompletion != nil {
user.MarkReadOnMediaPlayerCompletion = *u.MarkReadOnMediaPlayerCompletion
}
if u.MediaPlaybackRate != nil {
user.MediaPlaybackRate = *u.MediaPlaybackRate
}
if u.BlockFilterEntryRules != nil {
user.BlockFilterEntryRules = *u.BlockFilterEntryRules
}
if u.KeepFilterEntryRules != nil {
user.KeepFilterEntryRules = *u.KeepFilterEntryRules
}
if u.AlwaysOpenExternalLinks != nil {
user.AlwaysOpenExternalLinks = *u.AlwaysOpenExternalLinks
}
if u.OpenExternalLinksInNewTab != nil {
user.OpenExternalLinksInNewTab = *u.OpenExternalLinksInNewTab
}
}
// UseTimezone converts last login date to the given timezone.
func (u *User) UseTimezone(tz string) {
if u.LastLoginAt != nil {
*u.LastLoginAt = timezone.Convert(tz, *u.LastLoginAt)
}
}
// Users represents a list of users.
type Users []*User
// UseTimezone converts last login timestamp of all users to the given timezone.
func (u Users) UseTimezone(tz string) {
for _, user := range u {
user.UseTimezone(tz)
}
}
v2-2.2.16/internal/model/user_session.go 0000664 0000000 0000000 00000001327 15127074645 0020133 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package model // import "miniflux.app/v2/internal/model"
import (
"fmt"
"time"
"miniflux.app/v2/internal/timezone"
)
// UserSession represents a user session in the system.
type UserSession struct {
ID int64
UserID int64
Token string
CreatedAt time.Time
UserAgent string
IP string
}
func (u *UserSession) String() string {
return fmt.Sprintf(`ID=%q, UserID=%q, IP=%q, Token=%q`, u.ID, u.UserID, u.IP, u.Token)
}
// UseTimezone converts creation date to the given timezone.
func (u *UserSession) UseTimezone(tz string) {
u.CreatedAt = timezone.Convert(tz, u.CreatedAt)
}
v2-2.2.16/internal/model/webauthn.go 0000664 0000000 0000000 00000002125 15127074645 0017224 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package model // import "miniflux.app/v2/internal/model"
import (
"database/sql/driver"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"time"
"github.com/go-webauthn/webauthn/webauthn"
)
// WebAuthnSession handles marshalling / unmarshalling session data
type WebAuthnSession struct {
*webauthn.SessionData
}
func (s WebAuthnSession) Value() (driver.Value, error) {
return json.Marshal(s)
}
func (s *WebAuthnSession) Scan(value any) error {
b, ok := value.([]byte)
if !ok {
return errors.New("type assertion to []byte failed")
}
return json.Unmarshal(b, &s)
}
func (s WebAuthnSession) String() string {
if s.SessionData == nil {
return "{}"
}
return fmt.Sprintf("{Challenge: %s, UserID: %x}", s.Challenge, s.UserID)
}
type WebAuthnCredential struct {
Credential webauthn.Credential
Name string
AddedOn *time.Time
LastSeenOn *time.Time
Handle []byte
}
func (s WebAuthnCredential) HandleEncoded() string {
return hex.EncodeToString(s.Handle)
}
v2-2.2.16/internal/oauth2/ 0000775 0000000 0000000 00000000000 15127074645 0015162 5 ustar 00root root 0000000 0000000 v2-2.2.16/internal/oauth2/authorization.go 0000664 0000000 0000000 00000002103 15127074645 0020405 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package oauth2 // import "miniflux.app/v2/internal/oauth2"
import (
"crypto/sha256"
"encoding/base64"
"golang.org/x/oauth2"
"miniflux.app/v2/internal/crypto"
)
type Authorization struct {
url string
state string
codeVerifier string
}
func (u *Authorization) RedirectURL() string {
return u.url
}
func (u *Authorization) State() string {
return u.state
}
func (u *Authorization) CodeVerifier() string {
return u.codeVerifier
}
func GenerateAuthorization(config *oauth2.Config) *Authorization {
codeVerifier := crypto.GenerateRandomStringHex(32)
sum := sha256.Sum256([]byte(codeVerifier))
state := crypto.GenerateRandomStringHex(24)
authUrl := config.AuthCodeURL(
state,
oauth2.SetAuthURLParam("code_challenge_method", "S256"),
oauth2.SetAuthURLParam("code_challenge", base64.RawURLEncoding.EncodeToString(sum[:])),
)
return &Authorization{
url: authUrl,
state: state,
codeVerifier: codeVerifier,
}
}
v2-2.2.16/internal/oauth2/google.go 0000664 0000000 0000000 00000004347 15127074645 0016775 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package oauth2 // import "miniflux.app/v2/internal/oauth2"
import (
"context"
"encoding/json"
"fmt"
"miniflux.app/v2/internal/model"
"golang.org/x/oauth2"
)
type googleProfile struct {
Sub string `json:"sub"`
Email string `json:"email"`
}
type googleProvider struct {
clientID string
clientSecret string
redirectURL string
}
func NewGoogleProvider(clientID, clientSecret, redirectURL string) *googleProvider {
return &googleProvider{clientID: clientID, clientSecret: clientSecret, redirectURL: redirectURL}
}
func (g *googleProvider) GetConfig() *oauth2.Config {
return &oauth2.Config{
RedirectURL: g.redirectURL,
ClientID: g.clientID,
ClientSecret: g.clientSecret,
Scopes: []string{"email"},
Endpoint: oauth2.Endpoint{
AuthURL: "https://accounts.google.com/o/oauth2/auth",
TokenURL: "https://accounts.google.com/o/oauth2/token",
},
}
}
func (g *googleProvider) GetUserExtraKey() string {
return "google_id"
}
func (g *googleProvider) GetProfile(ctx context.Context, code, codeVerifier string) (*Profile, error) {
conf := g.GetConfig()
token, err := conf.Exchange(ctx, code, oauth2.SetAuthURLParam("code_verifier", codeVerifier))
if err != nil {
return nil, fmt.Errorf("google: failed to exchange token: %w", err)
}
client := conf.Client(ctx, token)
resp, err := client.Get("https://www.googleapis.com/oauth2/v3/userinfo")
if err != nil {
return nil, fmt.Errorf("google: failed to get user info: %w", err)
}
defer resp.Body.Close()
var user googleProfile
decoder := json.NewDecoder(resp.Body)
if err := decoder.Decode(&user); err != nil {
return nil, fmt.Errorf("google: unable to unserialize Google profile: %w", err)
}
profile := &Profile{Key: g.GetUserExtraKey(), ID: user.Sub, Username: user.Email}
return profile, nil
}
func (g *googleProvider) PopulateUserCreationWithProfileID(user *model.UserCreationRequest, profile *Profile) {
user.GoogleID = profile.ID
}
func (g *googleProvider) PopulateUserWithProfileID(user *model.User, profile *Profile) {
user.GoogleID = profile.ID
}
func (g *googleProvider) UnsetUserProfileID(user *model.User) {
user.GoogleID = ""
}
v2-2.2.16/internal/oauth2/manager.go 0000664 0000000 0000000 00000002321 15127074645 0017121 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package oauth2 // import "miniflux.app/v2/internal/oauth2"
import (
"context"
"errors"
"log/slog"
)
type Manager struct {
providers map[string]Provider
}
func (m *Manager) FindProvider(name string) (Provider, error) {
if provider, found := m.providers[name]; found {
return provider, nil
}
return nil, errors.New("oauth2 provider not found")
}
func (m *Manager) AddProvider(name string, provider Provider) {
m.providers[name] = provider
}
func NewManager(ctx context.Context, clientID, clientSecret, redirectURL, oidcDiscoveryEndpoint string) *Manager {
m := &Manager{providers: make(map[string]Provider)}
m.AddProvider("google", NewGoogleProvider(clientID, clientSecret, redirectURL))
if oidcDiscoveryEndpoint != "" {
if genericOidcProvider, err := NewOidcProvider(ctx, clientID, clientSecret, redirectURL, oidcDiscoveryEndpoint); err != nil {
slog.Error("Failed to initialize OIDC provider",
slog.Any("error", err),
)
} else {
m.AddProvider("oidc", genericOidcProvider)
}
}
if clientSecret == "" {
slog.Warn("OIDC client secret is empty or missing.")
}
return m
}
v2-2.2.16/internal/oauth2/oidc.go 0000664 0000000 0000000 00000005676 15127074645 0016445 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package oauth2 // import "miniflux.app/v2/internal/oauth2"
import (
"context"
"errors"
"fmt"
"miniflux.app/v2/internal/model"
"github.com/coreos/go-oidc/v3/oidc"
"golang.org/x/oauth2"
)
var (
ErrEmptyUsername = errors.New("oidc: username is empty")
)
type oidcProvider struct {
clientID string
clientSecret string
redirectURL string
provider *oidc.Provider
}
func NewOidcProvider(ctx context.Context, clientID, clientSecret, redirectURL, discoveryEndpoint string) (*oidcProvider, error) {
provider, err := oidc.NewProvider(ctx, discoveryEndpoint)
if err != nil {
return nil, fmt.Errorf(`oidc: failed to initialize provider %q: %w`, discoveryEndpoint, err)
}
return &oidcProvider{
clientID: clientID,
clientSecret: clientSecret,
redirectURL: redirectURL,
provider: provider,
}, nil
}
func (o *oidcProvider) GetUserExtraKey() string {
return "openid_connect_id"
}
func (o *oidcProvider) GetConfig() *oauth2.Config {
return &oauth2.Config{
RedirectURL: o.redirectURL,
ClientID: o.clientID,
ClientSecret: o.clientSecret,
Scopes: []string{oidc.ScopeOpenID, "profile", "email"},
Endpoint: o.provider.Endpoint(),
}
}
func (o *oidcProvider) GetProfile(ctx context.Context, code, codeVerifier string) (*Profile, error) {
conf := o.GetConfig()
token, err := conf.Exchange(ctx, code, oauth2.SetAuthURLParam("code_verifier", codeVerifier))
if err != nil {
return nil, fmt.Errorf(`oidc: failed to exchange token: %w`, err)
}
userInfo, err := o.provider.UserInfo(ctx, oauth2.StaticTokenSource(token))
if err != nil {
return nil, fmt.Errorf(`oidc: failed to get user info: %w`, err)
}
profile := &Profile{
Key: o.GetUserExtraKey(),
ID: userInfo.Subject,
}
var userClaims userClaims
if err := userInfo.Claims(&userClaims); err != nil {
return nil, fmt.Errorf(`oidc: failed to parse user claims: %w`, err)
}
// Use the first non-empty value from the claims to set the username.
// The order of preference is: preferred_username, email, name, profile.
for _, value := range []string{userClaims.PreferredUsername, userClaims.Email, userClaims.Name, userClaims.Profile} {
if value != "" {
profile.Username = value
break
}
}
if profile.Username == "" {
return nil, ErrEmptyUsername
}
return profile, nil
}
func (o *oidcProvider) PopulateUserCreationWithProfileID(user *model.UserCreationRequest, profile *Profile) {
user.OpenIDConnectID = profile.ID
}
func (o *oidcProvider) PopulateUserWithProfileID(user *model.User, profile *Profile) {
user.OpenIDConnectID = profile.ID
}
func (o *oidcProvider) UnsetUserProfileID(user *model.User) {
user.OpenIDConnectID = ""
}
type userClaims struct {
Email string `json:"email"`
Profile string `json:"profile"`
Name string `json:"name"`
PreferredUsername string `json:"preferred_username"`
}
v2-2.2.16/internal/oauth2/profile.go 0000664 0000000 0000000 00000000654 15127074645 0017156 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package oauth2 // import "miniflux.app/v2/internal/oauth2"
import (
"fmt"
)
// Profile is the OAuth2 user profile.
type Profile struct {
Key string
ID string
Username string
}
func (p Profile) String() string {
return fmt.Sprintf(`Key=%s ; ID=%s ; Username=%s`, p.Key, p.ID, p.Username)
}
v2-2.2.16/internal/oauth2/provider.go 0000664 0000000 0000000 00000001223 15127074645 0017341 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package oauth2 // import "miniflux.app/v2/internal/oauth2"
import (
"context"
"golang.org/x/oauth2"
"miniflux.app/v2/internal/model"
)
// Provider is an interface for OAuth2 providers.
type Provider interface {
GetConfig() *oauth2.Config
GetUserExtraKey() string
GetProfile(ctx context.Context, code, codeVerifier string) (*Profile, error)
PopulateUserCreationWithProfileID(user *model.UserCreationRequest, profile *Profile)
PopulateUserWithProfileID(user *model.User, profile *Profile)
UnsetUserProfileID(user *model.User)
}
v2-2.2.16/internal/proxyrotator/ 0000775 0000000 0000000 00000000000 15127074645 0016554 5 ustar 00root root 0000000 0000000 v2-2.2.16/internal/proxyrotator/proxyrotator.go 0000664 0000000 0000000 00000002476 15127074645 0021710 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package proxyrotator // import "miniflux.app/v2/internal/proxyrotator"
import (
"net/url"
"sync"
)
var ProxyRotatorInstance *ProxyRotator
// ProxyRotator manages a list of proxies and rotates through them.
type ProxyRotator struct {
proxies []*url.URL
currentIndex int
mutex sync.Mutex
}
// NewProxyRotator creates a new ProxyRotator with the given proxy URLs.
func NewProxyRotator(proxyURLs []string) (*ProxyRotator, error) {
parsedProxies := make([]*url.URL, 0, len(proxyURLs))
for _, p := range proxyURLs {
proxyURL, err := url.Parse(p)
if err != nil {
return nil, err
}
parsedProxies = append(parsedProxies, proxyURL)
}
return &ProxyRotator{
proxies: parsedProxies,
currentIndex: 0,
mutex: sync.Mutex{},
}, nil
}
// GetNextProxy returns the next proxy in the rotation.
func (pr *ProxyRotator) GetNextProxy() *url.URL {
if len(pr.proxies) == 0 {
return nil
}
pr.mutex.Lock()
proxy := pr.proxies[pr.currentIndex]
pr.currentIndex = (pr.currentIndex + 1) % len(pr.proxies)
pr.mutex.Unlock()
return proxy
}
// HasProxies checks if there are any proxies available in the rotator.
func (pr *ProxyRotator) HasProxies() bool {
return len(pr.proxies) > 0
}
v2-2.2.16/internal/proxyrotator/proxyrotator_test.go 0000664 0000000 0000000 00000003224 15127074645 0022737 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package proxyrotator // import "miniflux.app/v2/internal/proxyrotator"
import (
"testing"
)
func TestProxyRotator(t *testing.T) {
proxyURLs := []string{
"http://proxy1.example.com",
"http://proxy2.example.com",
"http://proxy3.example.com",
}
rotator, err := NewProxyRotator(proxyURLs)
if err != nil {
t.Fatalf("Failed to create ProxyRotator: %v", err)
}
if !rotator.HasProxies() {
t.Fatalf("Expected rotator to have proxies")
}
seenProxies := make(map[string]bool)
for range len(proxyURLs) * 2 {
proxy := rotator.GetNextProxy()
if proxy == nil {
t.Fatalf("Expected a proxy, got nil")
}
seenProxies[proxy.String()] = true
}
if len(seenProxies) != len(proxyURLs) {
t.Fatalf("Expected to see all proxies, but saw: %v", seenProxies)
}
}
func TestProxyRotatorEmpty(t *testing.T) {
rotator, err := NewProxyRotator([]string{})
if err != nil {
t.Fatalf("Failed to create ProxyRotator: %v", err)
}
if rotator.HasProxies() {
t.Fatalf("Expected rotator to have no proxies")
}
proxy := rotator.GetNextProxy()
if proxy != nil {
t.Fatalf("Expected no proxy, got: %v", proxy)
}
}
func TestProxyRotatorInvalidURL(t *testing.T) {
invalidProxyURLs := []string{
"http://validproxy.example.com",
"test|test://invalidproxy.example.com",
}
rotator, err := NewProxyRotator(invalidProxyURLs)
if err == nil {
t.Fatalf("Expected an error when creating ProxyRotator with invalid URLs, but got none")
}
if rotator != nil {
t.Fatalf("Expected rotator to be nil when initialization fails, but got: %v", rotator)
}
}
v2-2.2.16/internal/reader/ 0000775 0000000 0000000 00000000000 15127074645 0015222 5 ustar 00root root 0000000 0000000 v2-2.2.16/internal/reader/atom/ 0000775 0000000 0000000 00000000000 15127074645 0016162 5 ustar 00root root 0000000 0000000 v2-2.2.16/internal/reader/atom/atom_03.go 0000664 0000000 0000000 00000016154 15127074645 0017762 0 ustar 00root root 0000000 0000000 // 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 (
"encoding/base64"
"html"
"strings"
)
// Specs: http://web.archive.org/web/20060811235523/http://www.mnot.net/drafts/draft-nottingham-atom-format-02.html
type atom03Feed struct {
Version string `xml:"version,attr"`
// The "atom:id" element's content conveys a permanent, globally unique identifier for the feed.
// It MUST NOT change over time, even if the feed is relocated. atom:feed elements MAY contain an atom:id element,
// 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"`
}
type atom03Entry struct {
// 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"`
// 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"`
// The "atom:modified" element is a Date construct that indicates the time that the entry was last modified.
// atom:entry elements MUST contain an atom:modified element, but MUST NOT contain more than one.
// The content of an atom:modified element MUST have a time zone whose value SHOULD be "UTC".
Modified string `xml:"modified"`
// The "atom:issued" element is a Date construct that indicates the time that the entry was issued.
// atom:entry elements MUST contain an atom:issued element, but MUST NOT contain more than one.
// The content of an atom:issued element MAY omit a time zone.
Issued string `xml:"issued"`
// The "atom:created" element is a Date construct that indicates the time that the entry was created.
// atom:entry elements MAY contain an atom:created element, but MUST NOT contain more than one.
// 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"`
// The "atom:link" element is a Link construct that conveys a URI associated with the entry.
// The nature of the relationship as well as the link itself is determined by the element's content.
// atom:entry elements MUST contain at least one atom:link element with a rel attribute value of "alternate".
// 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.
// atom:entry elements MAY contain additional atom:link elements beyond those described above.
Links atomLinks `xml:"link"`
// The "atom:summary" element is a Content construct that conveys a short summary, abstract or excerpt of the entry.
// atom:entry elements MAY contain an atom:created element, but MUST NOT contain more than one.
Summary atom03Content `xml:"summary"`
// The "atom:content" element is a Content construct that conveys the content of the entry.
// atom:entry elements MAY contain one or more atom:content elements.
Content atom03Content `xml:"content"`
// The "atom:author" element is a Person construct that indicates the default author of the entry.
// 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.
Author AtomPerson `xml:"author"`
}
type atom03Content struct {
// Content constructs MAY have a "type" attribute, whose value indicates the media type of the content.
// When present, this attribute's value MUST be a registered media type [RFC2045].
// If not present, its value MUST be considered to be "text/plain".
Type string `xml:"type,attr"`
// Content constructs MAY have a "mode" attribute, whose value indicates the method used to encode the content.
// When present, this attribute's value MUST be listed below.
// If not present, its value MUST be considered to be "xml".
//
// "xml": A mode attribute with the value "xml" indicates that the element's content is inline xml (for example, namespace-qualified XHTML).
//
// "escaped": A mode attribute with the value "escaped" indicates that the element's content is an escaped string.
// Processors MUST unescape the element's content before considering it as content of the indicated media type.
//
// "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"`
CharData string `xml:",chardata"`
InnerXML string `xml:",innerxml"`
}
func (a *atom03Content) content() string {
content := ""
switch a.Mode {
case "xml":
content = a.InnerXML
case "escaped":
content = a.CharData
case "base64":
b, err := base64.StdEncoding.DecodeString(a.CharData)
if err == nil {
content = string(b)
}
default:
content = a.CharData
}
if a.Type != "text/html" {
content = html.EscapeString(content)
}
return strings.TrimSpace(content)
}
v2-2.2.16/internal/reader/atom/atom_03_adapter.go 0000664 0000000 0000000 00000005264 15127074645 0021462 0 ustar 00root root 0000000 0000000 // 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 (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.SHA256(value)
break
}
}
feed.Entries = append(feed.Entries, entry)
}
return feed
}
v2-2.2.16/internal/reader/atom/atom_03_test.go 0000664 0000000 0000000 00000023312 15127074645 0021013 0 ustar 00root root 0000000 0000000 // 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"
"time"
)
func TestParseAtom03(t *testing.T) {
data := `
dive into mark
2003-12-13T18:30:02Z
Mark Pilgrim
Atom 0.3 snapshot
tag:diveintomark.org,2003:3.2397
2003-12-13T08:29:29-04:00
2003-12-13T18:30:02Z
It's a test
HTML content
]]>
`
feed, err := Parse("http://diveintomark.org/atom.xml", bytes.NewReader([]byte(data)), "0.3")
if err != nil {
t.Fatal(err)
}
if feed.Title != "dive into mark" {
t.Errorf("Incorrect title, got: %s", feed.Title)
}
if feed.FeedURL != "http://diveintomark.org/atom.xml" {
t.Errorf("Incorrect feed URL, got: %s", feed.FeedURL)
}
if feed.SiteURL != "http://diveintomark.org/" {
t.Errorf("Incorrect site URL, got: %s", feed.SiteURL)
}
if len(feed.Entries) != 1 {
t.Errorf("Incorrect number of entries, got: %d", len(feed.Entries))
}
tz := time.FixedZone("Test Case Time", -int((4 * time.Hour).Seconds()))
if !feed.Entries[0].Date.Equal(time.Date(2003, time.December, 13, 8, 29, 29, 0, tz)) {
t.Errorf("Incorrect entry date, got: %v", feed.Entries[0].Date)
}
if feed.Entries[0].Hash != "b70d30334b808f32e66eb19fabb263525cecd18f205720b583e84f7f295cf728" {
t.Errorf("Incorrect entry hash, got: %s", feed.Entries[0].Hash)
}
if feed.Entries[0].URL != "http://diveintomark.org/2003/12/13/atom03" {
t.Errorf("Incorrect entry URL, got: %s", feed.Entries[0].URL)
}
if feed.Entries[0].Title != "Atom 0.3 snapshot" {
t.Errorf("Incorrect entry title, got: %s", feed.Entries[0].Title)
}
if feed.Entries[0].Content != "HTML content
" {
t.Errorf("Incorrect entry content, got: %s", feed.Entries[0].Content)
}
if feed.Entries[0].Author != "Mark Pilgrim" {
t.Errorf("Incorrect entry author, got: %s", feed.Entries[0].Author)
}
}
func TestParseAtom03WithoutSiteURL(t *testing.T) {
data := `
2003-12-13T18:30:02Z
Mark Pilgrim
Atom 0.3 snapshot
tag:diveintomark.org,2003:3.2397
`
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) {
data := `
2003-12-13T18:30:02Z
Mark Pilgrim
Atom 0.3 snapshot
tag:diveintomark.org,2003:3.2397
`
feed, err := Parse("http://diveintomark.org/", bytes.NewReader([]byte(data)), "0.3")
if err != nil {
t.Fatal(err)
}
if feed.Title != "http://diveintomark.org/" {
t.Errorf("Incorrect title, got: %s", feed.Title)
}
}
func TestParseAtom03WithoutEntryTitleButWithLink(t *testing.T) {
data := `
dive into mark
2003-12-13T18:30:02Z
Mark Pilgrim
tag:diveintomark.org,2003:3.2397
`
feed, err := Parse("http://diveintomark.org/", bytes.NewReader([]byte(data)), "0.3")
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].Title != "http://diveintomark.org/2003/12/13/atom03" {
t.Errorf("Incorrect entry title, got: %s", feed.Entries[0].Title)
}
}
func TestParseAtom03WithoutEntryTitleButWithSummary(t *testing.T) {
data := `
dive into mark
2003-12-13T18:30:02Z
Mark Pilgrim
tag:diveintomark.org,2003:3.2397
It's a test
`
feed, err := Parse("http://diveintomark.org/", bytes.NewReader([]byte(data)), "0.3")
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].Title != "It's a test" {
t.Errorf("Incorrect entry title, got: %s", feed.Entries[0].Title)
}
}
func TestParseAtom03WithoutEntryTitleButWithXMLContent(t *testing.T) {
data := `
dive into mark
2003-12-13T18:30:02Z
Mark Pilgrim
tag:diveintomark.org,2003:3.2397
Some text.
`
feed, err := Parse("http://diveintomark.org/", bytes.NewReader([]byte(data)), "0.3")
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].Title != "Some text." {
t.Errorf("Incorrect entry title, got: %s", feed.Entries[0].Title)
}
}
func TestParseAtom03WithSummaryOnly(t *testing.T) {
data := `
dive into mark
2003-12-13T18:30:02Z
Mark Pilgrim
Atom 0.3 snapshot
tag:diveintomark.org,2003:3.2397
2003-12-13T08:29:29-04:00
2003-12-13T18:30:02Z
It's a test
`
feed, err := Parse("http://diveintomark.org/", bytes.NewReader([]byte(data)), "0.3")
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].Content != "It's a test" {
t.Errorf("Incorrect entry content, got: %s", feed.Entries[0].Content)
}
}
func TestParseAtom03WithXMLContent(t *testing.T) {
data := `
dive into mark
2003-12-13T18:30:02Z
Mark Pilgrim
Atom 0.3 snapshot
tag:diveintomark.org,2003:3.2397
2003-12-13T08:29:29-04:00
2003-12-13T18:30:02Z
Some text.
`
feed, err := Parse("http://diveintomark.org/", bytes.NewReader([]byte(data)), "0.3")
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].Content != "Some text.
" {
t.Errorf("Incorrect entry content, got: %s", feed.Entries[0].Content)
}
}
func TestParseAtom03WithBase64Content(t *testing.T) {
data := `
dive into mark
2003-12-13T18:30:02Z
Mark Pilgrim
Atom 0.3 snapshot
tag:diveintomark.org,2003:3.2397
2003-12-13T08:29:29-04:00
2003-12-13T18:30:02Z
PHA+U29tZSB0ZXh0LjwvcD4=
`
feed, err := Parse("http://diveintomark.org/", bytes.NewReader([]byte(data)), "0.3")
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].Content != "Some text.
" {
t.Errorf("Incorrect entry content, got: %s", feed.Entries[0].Content)
}
}
v2-2.2.16/internal/reader/atom/atom_10.go 0000664 0000000 0000000 00000017326 15127074645 0017762 0 ustar 00root root 0000000 0000000 // 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 (
"encoding/xml"
"html"
"strings"
"miniflux.app/v2/internal/reader/media"
)
// 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:
// https://tools.ietf.org/html/rfc4287
// https://validator.w3.org/feed/docs/atom.html
type atom10Feed struct {
XMLName xml.Name `xml:"http://www.w3.org/2005/Atom feed"`
// 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: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:subtitle" element is a Text construct that
// contains a human-readable description or subtitle for the feed.
Subtitle atom10Text `xml:"http://www.w3.org/2005/Atom subtitle"`
// The "atom:author" element is a Person construct that indicates the
// author of the entry or feed.
//
// atom:feed elements MUST contain one or more atom:author elements,
// unless all of the atom:feed element's child atom:entry elements
// contain at least one atom:author element.
Authors atomPersons `xml:"http://www.w3.org/2005/Atom author"`
// The "atom:icon" element's content is an IRI reference [RFC3987] that
// identifies an image that provides iconic visual identification for a
// feed.
//
// atom:feed elements MUST NOT contain more than one atom:icon element.
Icon string `xml:"http://www.w3.org/2005/Atom icon"`
// The "atom:logo" element's content is an IRI reference [RFC3987] that
// identifies an image that provides visual identification for a feed.
//
// atom:feed elements MUST NOT contain more than one atom:logo element.
Logo string `xml:"http://www.w3.org/2005/Atom logo"`
// atom:feed elements SHOULD contain one atom:link element with a rel
// attribute value of "self". This is the preferred URI for
// retrieving Atom Feed Documents representing this Atom feed.
//
// atom:feed elements MUST NOT contain more than one atom:link
// element with a rel attribute value of "alternate" that has the
// same combination of type and hreflang attribute values.
Links atomLinks `xml:"http://www.w3.org/2005/Atom link"`
// The "atom:category" element conveys information about a category
// associated with an entry or feed. This specification assigns no
// meaning to the content (if any) of this element.
//
// atom:feed elements MAY contain any number of atom:category
// elements.
Categories atomCategories `xml:"http://www.w3.org/2005/Atom category"`
Entries []atom10Entry `xml:"http://www.w3.org/2005/Atom entry"`
}
type atom10Entry struct {
// 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"`
// 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"`
// The "atom:published" element is a Date construct indicating an
// instant in time associated with an event early in the life cycle of
// the entry.
Published string `xml:"http://www.w3.org/2005/Atom published"`
// The "atom:updated" element is a Date construct indicating the most
// recent instant in time when an entry or feed was modified in a way
// the publisher considers significant. Therefore, not all
// 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"`
// atom:entry 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"`
// 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"`
// atom:entry elements MUST NOT contain more than one atom:content
// element.
Content atom10Text `xml:"http://www.w3.org/2005/Atom content"`
// The "atom:author" element is a Person construct that indicates the
// 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"`
// 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:entry elements MAY contain any number of atom:category
// elements.
Categories atomCategories `xml:"http://www.w3.org/2005/Atom category"`
media.MediaItemElement
}
// 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
// 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
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
if strings.EqualFold(a.Type, "xhtml") {
content = a.xhtmlContent()
} else {
content = a.CharData
}
return strings.TrimSpace(content)
}
func (a *atom10Text) title() string {
var content string
switch {
case strings.EqualFold(a.Type, "xhtml"):
content = a.xhtmlContent()
case strings.Contains(a.InnerXML, "