pax_global_header 0000666 0000000 0000000 00000000064 14546226260 0014521 g ustar 00root root 0000000 0000000 52 comment=cd0b71f1371b79ab2f3a4fffcc333a2c9a26b3b6
miniflux-2.0.51/ 0000775 0000000 0000000 00000000000 14546226260 0013441 5 ustar 00root root 0000000 0000000 miniflux-2.0.51/.devcontainer/ 0000775 0000000 0000000 00000000000 14546226260 0016200 5 ustar 00root root 0000000 0000000 miniflux-2.0.51/.devcontainer/devcontainer.json 0000664 0000000 0000000 00000001345 14546226260 0021557 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": {}
},
"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"
]
}
}
} miniflux-2.0.51/.devcontainer/docker-compose.yml 0000664 0000000 0000000 00000001320 14546226260 0021631 0 ustar 00root root 0000000 0000000 version: '3.8'
services:
app:
image: mcr.microsoft.com/devcontainers/go
volumes:
- ..:/workspace:cached
command: sleep infinity
network_mode: service:db
environment:
- CREATE_ADMIN=1
- ADMIN_USERNAME=admin
- ADMIN_PASSWORD=test123
db:
image: postgres:15
restart: unless-stopped
volumes:
- postgres-data:/var/lib/postgresql/data
hostname: postgres
environment:
POSTGRES_DB: miniflux2
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_HOST_AUTH_METHOD: trust
ports:
- 5432:5432
apprise:
image: caronc/apprise:latest
restart: unless-stopped
hostname: apprise
volumes:
postgres-data: null
miniflux-2.0.51/.github/ 0000775 0000000 0000000 00000000000 14546226260 0015001 5 ustar 00root root 0000000 0000000 miniflux-2.0.51/.github/ISSUE_TEMPLATE/ 0000775 0000000 0000000 00000000000 14546226260 0017164 5 ustar 00root root 0000000 0000000 miniflux-2.0.51/.github/ISSUE_TEMPLATE/bug_report.md 0000664 0000000 0000000 00000000152 14546226260 0021654 0 ustar 00root root 0000000 0000000 ---
name: Bug report
about: Create a bug report
title: ''
labels: bug, triage needed
assignees: ''
---
miniflux-2.0.51/.github/ISSUE_TEMPLATE/config.yml 0000664 0000000 0000000 00000000034 14546226260 0021151 0 ustar 00root root 0000000 0000000 blank_issues_enabled: false
miniflux-2.0.51/.github/ISSUE_TEMPLATE/feature_request.md 0000664 0000000 0000000 00000000317 14546226260 0022712 0 ustar 00root root 0000000 0000000 ---
name: Feature request
about: Suggest an idea for this project
title: ''
labels: feature request
assignees: ''
---
- [ ] I have read this document: https://miniflux.app/opinionated.html#feature-request
miniflux-2.0.51/.github/ISSUE_TEMPLATE/feed-problems.md 0000664 0000000 0000000 00000000205 14546226260 0022227 0 ustar 00root root 0000000 0000000 ---
name: Feed Problems
about: Problems with a feed or a website
title: ''
labels: feed problems, triage needed
assignees: ''
---
miniflux-2.0.51/.github/ISSUE_TEMPLATE/improvement.md 0000664 0000000 0000000 00000000205 14546226260 0022050 0 ustar 00root root 0000000 0000000 ---
name: Improvement
about: Do you have an idea to improve an existing feature?
title: ''
labels: improvements
assignees: ''
---
miniflux-2.0.51/.github/dependabot.yml 0000664 0000000 0000000 00000002040 14546226260 0017625 0 ustar 00root root 0000000 0000000 version: 2
updates:
- package-ecosystem: "gomod"
directory: "/"
schedule:
interval: "daily"
reviewers:
- "fguillot"
assignees:
- "fguillot"
- package-ecosystem: "docker"
directory: "/packaging/docker/alpine"
schedule:
interval: "weekly"
reviewers:
- "fguillot"
assignees:
- "fguillot"
- package-ecosystem: "docker"
directory: "/packaging/docker/distroless"
schedule:
interval: "weekly"
reviewers:
- "fguillot"
assignees:
- "fguillot"
- package-ecosystem: "docker"
directory: "packaging/debian"
schedule:
interval: "weekly"
reviewers:
- "fguillot"
assignees:
- "fguillot"
- package-ecosystem: "docker"
directory: "packaging/rpm"
schedule:
interval: "weekly"
reviewers:
- "fguillot"
assignees:
- "fguillot"
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
reviewers:
- "fguillot"
assignees:
- "fguillot"
miniflux-2.0.51/.github/pull_request_template.md 0000664 0000000 0000000 00000000205 14546226260 0021737 0 ustar 00root root 0000000 0000000 Do you follow the guidelines?
- [ ] I have tested my changes
- [ ] I read this document: https://miniflux.app/faq.html#pull-request
miniflux-2.0.51/.github/workflows/ 0000775 0000000 0000000 00000000000 14546226260 0017036 5 ustar 00root root 0000000 0000000 miniflux-2.0.51/.github/workflows/build_binaries.yml 0000664 0000000 0000000 00000001105 14546226260 0022531 0 ustar 00root root 0000000 0000000 name: Build Binaries
on:
workflow_dispatch:
push:
tags:
- '[0-9]+.[0-9]+.[0-9]+'
jobs:
build:
name: Build
runs-on: ubuntu-latest
steps:
- name: Set up Golang
uses: actions/setup-go@v5
with:
go-version: "1.21"
- name: Checkout
uses: actions/checkout@v4
- name: Compile binaries
run: make build
- name: Upload binaries
uses: actions/upload-artifact@v3
with:
name: binaries
path: miniflux-*
if-no-files-found: error
retention-days: 5
miniflux-2.0.51/.github/workflows/codeql-analysis.yml 0000664 0000000 0000000 00000001364 14546226260 0022655 0 ustar 00root root 0000000 0000000 name: "CodeQL"
permissions: read-all
on:
push:
branches: [ main ]
pull_request:
# The branches below must be a subset of the branches above
branches: [ main ]
schedule:
- cron: '45 22 * * 3'
jobs:
analyze:
name: Analyze
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@v4
- name: Initialize CodeQL
uses: github/codeql-action/init@v2
- name: Autobuild
uses: github/codeql-action/autobuild@v2
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v2
miniflux-2.0.51/.github/workflows/debian_packages.yml 0000664 0000000 0000000 00000002775 14546226260 0022654 0 ustar 00root root 0000000 0000000 name: Debian Packages
permissions: read-all
on:
push:
tags:
- '[0-9]+.[0-9]+.[0-9]+'
pull_request:
branches: [ main ]
jobs:
test-packages:
if: github.event.pull_request
name: Test Packages
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
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
publish-packages:
if: ${{ ! github.event.pull_request }}
name: Publish Packages
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
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
miniflux-2.0.51/.github/workflows/docker.yml 0000664 0000000 0000000 00000010652 14546226260 0021034 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 ]
jobs:
test-docker-images:
if: github.event.pull_request
name: Test Images
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Build Alpine image
uses: docker/build-push-action@v5
with:
context: .
file: ./packaging/docker/alpine/Dockerfile
push: false
tags: ${{ github.repository_owner }}/miniflux:alpine-dev
- name: Test Alpine Docker image
run: docker run --rm ${{ github.repository_owner }}/miniflux:alpine-dev miniflux -i
- name: Build Distroless image
uses: docker/build-push-action@v5
with:
context: .
file: ./packaging/docker/distroless/Dockerfile
push: false
tags: ${{ github.repository_owner }}/miniflux:distroless-dev
- name: Test Distroless Docker image
run: docker run --rm ${{ github.repository_owner }}/miniflux:distroless-dev miniflux -i
publish-docker-images:
if: ${{ ! github.event.pull_request }}
name: Publish Images
permissions:
packages: write
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Generate Alpine Docker tag
id: docker_alpine_tag
run: |
DOCKER_IMAGE=${{ github.repository_owner }}/miniflux
DOCKER_VERSION=dev
if [ "${{ github.event_name }}" = "schedule" ]; then
DOCKER_VERSION=nightly
TAGS="docker.io/${DOCKER_IMAGE}:${DOCKER_VERSION},ghcr.io/${DOCKER_IMAGE}:${DOCKER_VERSION},quay.io/${DOCKER_IMAGE}:${DOCKER_VERSION}"
elif [[ $GITHUB_REF == refs/tags/* ]]; then
DOCKER_VERSION=${GITHUB_REF#refs/tags/}
TAGS="docker.io/${DOCKER_IMAGE}:${DOCKER_VERSION},ghcr.io/${DOCKER_IMAGE}:${DOCKER_VERSION},quay.io/${DOCKER_IMAGE}:${DOCKER_VERSION},docker.io/${DOCKER_IMAGE}:latest,ghcr.io/${DOCKER_IMAGE}:latest,quay.io/${DOCKER_IMAGE}:latest"
fi
echo ::set-output name=tags::${TAGS}
- name: Generate Distroless Docker tag
id: docker_distroless_tag
run: |
DOCKER_IMAGE=${{ github.repository_owner }}/miniflux
DOCKER_VERSION=dev-distroless
if [ "${{ github.event_name }}" = "schedule" ]; then
DOCKER_VERSION=nightly-distroless
TAGS="docker.io/${DOCKER_IMAGE}:${DOCKER_VERSION},ghcr.io/${DOCKER_IMAGE}:${DOCKER_VERSION},quay.io/${DOCKER_IMAGE}:${DOCKER_VERSION}"
elif [[ $GITHUB_REF == refs/tags/* ]]; then
DOCKER_VERSION=${GITHUB_REF#refs/tags/}-distroless
TAGS="docker.io/${DOCKER_IMAGE}:${DOCKER_VERSION},ghcr.io/${DOCKER_IMAGE}:${DOCKER_VERSION},quay.io/${DOCKER_IMAGE}:${DOCKER_VERSION},docker.io/${DOCKER_IMAGE}:latest-distroless,ghcr.io/${DOCKER_IMAGE}:latest-distroless,quay.io/${DOCKER_IMAGE}:latest-distroless"
fi
echo ::set-output name=tags::${TAGS}
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to DockerHub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to Quay Container Registry
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@v5
with:
context: .
file: ./packaging/docker/alpine/Dockerfile
platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64
push: true
tags: ${{ steps.docker_alpine_tag.outputs.tags }}
- name: Build and Push Distroless images
uses: docker/build-push-action@v5
with:
context: .
file: ./packaging/docker/distroless/Dockerfile
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.docker_distroless_tag.outputs.tags }}
miniflux-2.0.51/.github/workflows/linters.yml 0000664 0000000 0000000 00000001400 14546226260 0021234 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@v4
- name: Install jshint
run: |
sudo npm install -g jshint@2.13.3
- name: Run jshint
run: jshint ui/static/js/*.js
golangci:
name: Golang Linter
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: "1.21"
- uses: golangci/golangci-lint-action@v3
with:
args: --timeout 10m --skip-dirs tests --disable errcheck --enable sqlclosecheck --enable misspell --enable gofmt --enable goimports --enable whitespace
miniflux-2.0.51/.github/workflows/rpm_packages.yml 0000664 0000000 0000000 00000001635 14546226260 0022222 0 ustar 00root root 0000000 0000000 name: RPM Packages
permissions: read-all
on:
push:
tags:
- '[0-9]+.[0-9]+.[0-9]+'
pull_request:
branches: [ main ]
jobs:
test-package:
if: github.event.pull_request
name: Test Packages
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Build RPM Package
run: make rpm
- name: List generated files
run: ls -l *.rpm
publish-package:
if: ${{ ! github.event.pull_request }}
name: Publish Packages
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
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
miniflux-2.0.51/.github/workflows/tests.yml 0000664 0000000 0000000 00000002526 14546226260 0020730 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]
go-version: ["1.21"]
steps:
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: ${{ matrix.go-version }}
- name: Checkout
uses: actions/checkout@v4
- name: Run unit tests
run: make 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: Set up Go
uses: actions/setup-go@v5
with:
go-version: "1.21"
- name: Checkout
uses: actions/checkout@v4
- name: Install Postgres client
run: sudo apt update && sudo apt install -y postgresql-client
- name: Run integration tests
run: make integration-test
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PGHOST: 127.0.0.1
PGPASSWORD: postgres
miniflux-2.0.51/.gitignore 0000664 0000000 0000000 00000000057 14546226260 0015433 0 ustar 00root root 0000000 0000000 miniflux-*
./miniflux
*.rpm
*.deb
.idea
.vscode miniflux-2.0.51/ChangeLog 0000664 0000000 0000000 00000163370 14546226260 0015225 0 ustar 00root root 0000000 0000000 Version 2.0.51 (December 13, 2023)
----------------------------------
* Add Omnivore integration
* Fixes for the regressions introduced in version 2.0.50:
* Ensure all HTML documents are encoded in UTF-8
* Send default User-Agent and HTTP caching headers when making HTTP requests
* Allow Youtube links to be opened outside the `iframe` (avoid `ERR_BLOCKED_BY_RESPONSE` error)
* Fix inaccessible metrics endpoint when listening on Unix socket
* Allow renaming and moving feed at the same time in the Google Reader API
* Log `nb_jobs` only when number of jobs is larger than 0 in background scheduler
* Deduplicate feed URLs when parsing HTML document during discovery process
* Calculate a virtual weekly count based on the average updating frequency (`POLLING_SCHEDULER=entry_frequency`)
* Update GitHub Actions workflow to be able to run the linter and tests on-demand
* Add `SCHEDULER_ROUND_ROBIN_MIN_INTERVAL` config option
* Add links to GitHub for the commit hash and the version in the about page
* Use "starred" rather than "bookmarked" in English translation
* Update Chinese (CN & TW) translation
* Bump `github.com/google/uuid` from `1.4.0` to `1.5.0`
* Bump `github.com/coreos/go-oidc/v3` from `3.7.0` to `3.9.0`
* Bump `github.com/tdewolff/minify/v2` from `2.20.6` to `2.20.9`
* Bump `github.com/go-webauthn/webauthn` from `0.8.6` to `0.9.4`
* Bump `golang.org/x/oauth2` from `0.14.0` to `0.15.0`
Version 2.0.50 (November 12, 2023)
----------------------------------
* Add WebAuthn / Passkey integration
* Add RSS-Bridge integration
* Take RSS TTL field into consideration to schedule next check date
* Show number of visible entries instead of number of read entries in feed list
* OpenID Connect: Redirect to configured user home page after successful authentication
* Google Reader API fixes:
* `user/{userID}/state/com.google/read` is missing in categories section for read entries
* Take `ExcludeTargets` into consideration in feed stream handler
* Allow iframes pointing to Twitch videos
* Filter feed entries based on URL or title
* Take into consideration `hide_globally` property defined for categories in `/v1/entries` API endpoint
* Add category ID to webhooks request body
* Update date parser to parse more invalid date formats
* Refactor feed discovery handler, and avoid an extra HTTP request if the URL provided is the feed
* Refactor HTTP Client and `LocalizedError` packages
* Refactor Batch Builder, and prevent accidental and excessive refreshes from the web UI
* Refactor icon finder:
- Continue the discovery process when the feed icon is invalid
- Search all icons from the HTML document and do not stop on the first one
* Add support for SVG icons with data URL without encoding
* Expose `next_check_at` in the web ui and API
* Add database indexes to improve performance
* Change log level to warning for failed feeds refresh in cronjob
* Do not log website without icon as warning
* Add GitHub workflow to build binaries
* Add GitHub extensions to devcontainer
* Make sure to pull the latest base image when building the Docker image
* Strip version prefix when building Debian package
* Add `github-cli` and `docker-outside-of-docker` features to devcontainer
* Bump `golang.org/x/*` dependencies
* Bump `github.com/gorilla/mux` from `1.8.0` to `1.8.1`
* Bump `github.com/tdewolff/minify/v2` from `2.19.9` to `2.20.6`
* Bump `github.com/yuin/goldmark` from `1.5.6` to `1.6.0`
* Bump `github.com/coreos/go-oidc/v3` from `3.6.0` to `3.7.0`
Version 2.0.49 (October 15, 2023)
---------------------------------
* Implement structured logging using `log/slog` package. New config options available:
* `LOG_FORMAT`: `json` or `text`
* `LOG_LEVEL`: `debug`, `info`, `warning`, or `error`
* `LOG_FILE`: `sdterr`, `stdout`, or a file path
* The `DEBUG` option is now deprecated in favor of `LOG_LEVEL`
* API Improvements:
* Add endpoint `/v1/version`
* Add endpoint `PUT /v1/entries` to update entry title and content
* Add endpoint `/v1/icons/{iconID}`
* Add endpoint `/v1/flush-history` to flush history
* Make the category optional when creating feeds for API clients who don't support categories
* Add enclosures to `GET /v1/entries` endpoint
* Add `published_after`, `published_before`, `changed_after` and `changed_before` options to `/v1/entries` endpoint
* Telegram integration improvements:
* Replace feed HTML link with a button to avoid page preview issues
* Add the possibility to disable buttons
* Add Bruno Miniflux API collection in `contrib` folder (Bruno is an open source alternative to Postman/Insomnia)
* Add command line argument to export user feeds as OPML
* Add new rewrite rules `add_hn_links_using_hack` and `add_hn_links_using_opener` to open HN comments with iOS apps
* Fix timestamp format for `Expires` response header
* Fix Javascript error when reading time option is disabled
* Fix Apprise logic to handle feed service URLs
* Fix missing word in force refresh message
* Remove deprecated `PreferServerCipherSuites` TLS option
* Replace `github.com/rylans/getlang` with `github.com/abadojack/whatlanggo` because `getlang` doesn't seems to be updated anymore
* Bump `github.com/mccutchen/go-httpbin/v2` from `2.11.0` to `2.11.1`
* Bump `golang.org/x/*` dependencies
Version 2.0.48 (September 15, 2023)
-----------------------------------
* Add generic webhook integration
* Send webhook events when new entries are detected
* Send wehbook events when saving an entry
* Sign the outgoing requests with HMAC-SHA256
* Improve Telegram integration
* Add built-in Telegram client
* Remove dependency on `go-telegram-bot-api` library
* Add new options:
* Optional topic ID
* Disable page preview
* Disable notifications
* Add new button to go to article
* Improve Matrix integration
* Add built-in Matrix client
* Remove dependency on `gomatrix` library
* Send HTML formatted messages to Matrix
* OpenID Connect authentication improvements:
* Add OAuth2 PKCE support
* Add `profile` scope to OIDC integration to support accounts without email address
* Prevent empty username when using the OIDC integration
* Add `factor` for `entry_frequency` scheduler:
* Allow the user to increase the frequency of the `entry_frequency`
scheduler by a configurable factor in order to shorten the time between
updates.
* Fix: status bar is unreadable when using PWA in dark mode on Firefox Android
* Group form fields into fieldsets to improve page layout
* Update Russian translation
* Make sure icon URLs are always absolute
* Add Apprise service URLs per feed
* Trim `username` and `password` form fields
* Strip HTML tags from DublinCore Creator tags
* Fix scroll up behavior on Firefox Android
* Add missing `return` statement in `fetchContent` UI handler
* Add `replace_title` rewrite rule to adjust entry titles
* Fix Pocket integration redirect URL and Google Reader API HREF
* Fix feed `hide_globally` property to use it with third-party clients.
Version 2.0.47 (August 20, 2023)
--------------------------------
* Update rules for `webtoons.com`
* Use HTTP client from the standard library for third-party integrations
* Rename internal `url` package to `urllib` to avoid overlap with `net/url`
* Add Shaarli integration
* Add Shiori integration
* Add Apprise integration
* Add Readwise Reader integration
* Consider base path when generating third-party services API endpoint
* Use podcast duration tag as reading time
* Move internal packages to an `internal` folder
* For reference:
* Rename Miniflux package name to follow Go module naming convention
* For reference:
* Update RockyLinux image from 8 to 9 (used to build RPM package)
* Add force refresh in feed edit and feed entries page
* Use Odysee video duration as read time
* Upgrade to Go 1.21
* Use details disclosure element to show the list of third-party services
* Use Web Share API for sharing entry
* Add a workaround for parsing some invalid date format
* Add Thunder Client API collection into contrib folder
* Add new API endpoint: `/entries/{entryID}/save`
* Trigger Docker and packages workflows only for semantic tags
* Go module versioning expect Git tags to start with the letter v.
* The goal is to keep the existing naming convention for generated artifacts and
have proper versioning for the Go module.
* Bump `golang.org/x/*` dependencies
* Bump `github.com/yuin/goldmark`
* Bump `github.com/tdewolff/minify/v2`
* Bump `github.com/mccutchen/go-httpbin/v2`
Version 2.0.46 (July 21, 2023)
------------------------------
* Add scraper and rewrite rules for Webtoons
* Fix regression in integration page and simplify SQL query
* Wallabag integration: add more information in log messages
* Add support for custom Youtube embed URL
* Fix accessibility issues in modal component
* Fix modal aria role
* Trap focusing with tab / shift+tab inside the modal
* Restore keyboard focus when closing modal
* Automatically move keyboard focus to first focusable element unless specified otherwise
* Keyboard shortcut help modal: move keyboard focus to modal title
* Keyboard shortcut help modal: change close control from link to button
* Add Notion integration
* Update `golang.org/x/*` dependencies and `go-oidc` to v3.6.0
* Improve responsive design
* Add user setting for marking entry as read on view
* Improve Russian translation
* Add the possibility to run cleanup tasks from the command line
* Add the possibility to run Miniflux as a cronjob
* Use `go-httpbin` to run tests locally and avoid remote calls to `httpbin.org`
* Display tags when viewing entries
* Update categories API endpoint to return `total_unread` and `feed_count`
* Improve date parser to handle various broken date formats
* Avoid `pq: time zone displacement out of range` errors
* Improve entry existance check to make better use of index
* Add unique index `enclosures_user_entry_url_idx`
* Add mark as unread for Linkding integration
* Add sub-folder support for Wallabag integration
* Use RockyLinux to build RPM package
* Disable CGO when building RPM package
* Disable CGO when building Docker images
Version 2.0.45 (June 21, 2023)
------------------------------
* Add media player to listen to audio and video podcasts with the possiblity to resume to last playback position
* Add default tag names for Linkding integration
* Mark only globally visible entries when marking all entries from UI
* Use image included in feed as feed icon when available
* Order history by `changed_at` and `published_at`
* Remove title attribute from entry title links
* Fix reading time that is not aligned correctly with the latest version of Safari
* Use glyphs of the same size on keyboard shortcuts page
* Add maskable versions of the PWA icon
* Replace copyright header with SPDX identifier
* Remove the "í" letter from the Portuguese "lido" word
* Increase golangci-lint timeout value
* Bump `github.com/tdewolff/minify/v2`, `github.com/prometheus/client_golang`, `golang.org/x/*` dependencies
Version 2.0.44 (May 6, 2023)
----------------------------
* Add link to the URL rewrite rules documentation
* Update scraping rules for `ilpost.it`
* Update rewrite rules for `theverge.com`
* Add a rewrite rule to remove clickbait titles
* Make sure `PROXY_IMAGES` option is backward compatible with `PROXY_OPTION` and `PROXY_MEDIA_TYPES`
* Add new rule to remove tables
* Add support for searching well-known URLs in subdirectory
* Add CSS `word-wrap` rule to break very long entry title into multiple lines
* Add swipe as option for gesture navigation between entries. There are now 3 possible choices: `none`, `double-tap`, and `swipe`.
* Prefer typographic punctuation in English translation
* Process older entries first:
- Feed entries are usually ordered from most to least recent.
- Processing older entries first ensures that their creation timestamp
is lower than that of newer entries.
- This is useful when we order by creation, because then we get a
consistent timeline.
* Fix Grafana dashboard
* Push Docker images to `Quay.io` (RedHat)
* Bump `golang.org/x/*`, `github.com/lib/pq`, `mvdan.cc/xurls/v2` and `github.com/prometheus/client_golang` dependencies
Version 2.0.43 (March 16, 2023)
-------------------------------
* Avoid XSS when opening a broken image due to unescaped ServerError in proxy handler (CVE-2023-27592)
Creating an RSS feed item with the inline description containing an `` tag
with a `srcset` attribute pointing to an invalid URL like
`http:a`, we can coerce the proxy handler into an error
condition where the invalid URL is returned unescaped and in full.
This results in JavaScript execution on the Miniflux instance as soon as the
user is convinced to open the broken image.
* Use `r.RemoteAddr` to check `/metrics` endpoint network access (CVE-2023-27591)
HTTP headers like `X-Forwarded-For` or `X-Real-Ip` can be easily spoofed. As
such, it cannot be used to test if the client IP is allowed.
The recommendation is to use HTTP Basic authentication to protect the
metrics endpoint, or run Miniflux behind a trusted reverse-proxy.
* Add HTTP Basic authentication for `/metrics` endpoint
* Add proxy support for several media types
* Parse feed categories from RSS, Atom and JSON feeds
* Ignore empty link when discovering feeds
* Disable CGO explicitly to make sure the binary is statically linked
* Add CSS classes to differentiate between category/feed/entry view and icons
* Add rewrite and scraper rules for `blog.cloudflare.com`
* Add `color-scheme` to themes
* Add new keyboard shortcut to toggle open/close entry attachments section
* Sanitizer: allow `id` attribute in `` element
* Add Indonesian Language
* Update translations
* Update Docker Compose examples:
- Run the application in one command
- Bring back the health check condition to `depends_on`
- Remove deprecated `version` element
* Update scraping rules for `ilpost.it`
* Bump `github.com/PuerkitoBio/goquery` from `1.8.0` to `1.8.1`
* Bump `github.com/tdewolff/minify/v2` from `2.12.4` to `2.12.5`
* Bump `github.com/yuin/goldmark` from `1.5.3` to `1.5.4`
* Bump `golang.org/x/*` dependencies
Version 2.0.42 (January 29, 2023)
---------------------------------
* Fix header items wrapping
* Add option to enable or disable double tap
* Improve PWA display mode label in settings page
* Bump `golang.org/x/*` dependencies
* Update translations
* Add scraping rule for `ilpost.it`
* Update reading time HTML element after fetching the original web page
* Add category feeds refresh feature
Version 2.0.41 (December 10, 2022)
----------------------------------
* Reverted PR #1290 (follow the only link) because it leads to several panics/segfaults that prevent feed updates
* Disable double-tap mobile gesture if swipe gesture is disabled
* Skip integrations if there are no entries to push
* Enable TLS-ALPN-01 challenge for ACME
- This type of challenge works purely at the TLS layer and is compatible
with SNI proxies. The existing HTTP-01 challenge support has been left
as-is.
* Preconfigure Miniflux for GitHub Codespaces
* Updated `golang.org/x/net/*` dependencies
Version 2.0.40 (November 13, 2022)
----------------------------------
* Update dependencies
* Pin Postgres image version in Docker Compose examples to avoid unexpected upgrades
* Make English and Spanish translation more consistent:
- Use "Feed" everywhere instead of "Subscription"
- Use "Entry" instead of "Article"
* Allow Content-Type and Accept headers in CORS policy
* Use dirs file for Debian package
* Use custom home page in PWA manifest
* Fix scraper rule that could be incorrect when there is a redirect
* Improve web scraper to fetch the only link present as workaround to some landing pages
* Add Matrix bot integration
* Proxify images in API responses
* Add new options in user preferences to configure sorting of entries in the category page
* Remove dependency on `github.com/mitchellh/go-server-timing`
* Add support for the `continuation` parameter and result for Google Reader API ID calls
* Use automatic variable for build target file names
* Add rewrite rule for `recalbox.com`
* Improve Dutch translation
Version 2.0.39 (October 16, 2022)
---------------------------------
* Add support for date filtering in Google Reader API item ID calls
* Handle RSS entries with only a GUID permalink
* Go API Client: Accept endpoint URLs ending with `/v1/`
* CORS API headers: Allow `Basic` authorization header
* Log feed URL when submitting a subscription that returns an error
* Update `make run` command to execute migrations automatically
* Add option to send only the URL to Wallabag
* Do not convert anchors to absolute links
* Add config option to use a custom image proxy URL
* Allow zoom on mobile devices
* Add scraping rules for `theverge.com`, `royalroad.com`, `swordscomic.com`, and `smbc-comics.com`
* Add Ukrainian translation
* Update `golang.org/x/*` dependencies
* Bump `github.com/tdewolff/minify/v2` from `2.12.0` to `2.12.4`
* Bump `github.com/yuin/goldmark` from `1.4.13` to `1.5.2`
* Bump `github.com/lib/pq` from `1.10.6` to `1.10.7`
Version 2.0.38 (August 13, 2022)
--------------------------------
* Rename default branch from master to main
* Update GitHub Actions
* Bump `github.com/prometheus/client_golang` from `1.12.2` to `1.13.0`
* Fix some linter issues
* Handle Atom links with a text/html type defined
* Add `parse_markdown` rewrite function
* Build RPM and Debian packages automatically using GitHub Actions
* Add `explosm.net` scraper rule
* Make default home page configurable
* Add title attribute to entry links because text could be truncated
* Highlight categories with unread entries
* Allow option to order by title and author in API entry endpoint
* Update Russian translation
* Make reading speed user-configurable
* Added translation for Hindi language used in India
* Add rewrite rules for article URL before fetching content
* Bump `github.com/tdewolff/minify/v2` from `2.11.7` to `2.12.0`
* Support other repo owners in GitHub Docker Action
* Proxify empty URL should not crash
* Avoid stretched image if specified width is larger than Miniflux's layout
* Add support for OPML files with several nested outlines
* sanitizer: handle image URLs in `srcset` attribute with comma
* Allow `width` and `height` attributes for `img` tags
* Document that `-config-dump` command line argument shows sensitive info
* Add System-V init service in contrib folder
* Fix syntax error in `RequestBuilder.getCsrfToken()` method
Version 2.0.37 (May 27, 2022)
-----------------------------
* Add rewrite rule to decode base64 content
* Add Linkding integration
* Add comment button to Telegram message
* Add API endpoint to fetch unread and read counters
* Fixes logic bug in Google Reader API sanity check
* Reduce number of CORS preflight check to save network brandwidth
* Add Espial integration
* Allow API search for entries which are not starred
* Try to use outermost element text when title is empty
* Make swipe gestures feel more natural
- Removes opacity transition when swiping an article read/unread
- Adds "resistance" to the swiped entry when the 75px threshold is
reached
- Fixes an issue in which a swiped article couldn't be moved <15px
* Add support for feed streams to Google Reader API IDs API
* Fix invalid parsing of icon data URL
* Add Traditional Chinese translation
* Add distroless Docker image variant
* Add Go 1.18 to GitHub Action
* Bump `github.com/tdewolff/minify/v2` from `2.10.0` to `2.11`
* Bump `github.com/prometheus/client_golang` from `1.12.1` to `1.12.2`
* Bump `github.com/lib/pq` from `1.10.4` to `1.10.6`
Version 2.0.36 (March 8, 2022)
------------------------------
* Gray out pagination buttons when they are not applicable
* Use truncated entry description as title if unavailable
* Do not fallback to InnerXML if XHTML title is empty
* Add `+` keyboard shortcut for new subscription page
* Add `(+)` action next to Feeds to quickly add new feeds
* Fix unstar not working via Google Reader API
* Remove circles in front of page header list items
* Fix CSS hover style for links styled as buttons
* Avoid showing `undefined` when clicking on read/unread
* Add new keyboard shortcut `M` to toggle read/unread, and go to previous item
* Add several icons to menus according to their roles
* Add missing event argument to `onClick()` function call
* Add links to scraper/rewrite/filtering docs when editing feeds
* Add a rewrite rule for Castopod episodes
* Fix regression: reset touch-item if not in `/unread` page
* Add API endpoint to fetch original article
* Show the category first in feed settings
* Add pagination on top of all entries
* Display Go version in "About" page
* Bump `mvdan.cc/xurls/v2` from 2.3.0 to 2.4.0
* Bump `github.com/prometheus/client_golang` from 1.11.0 to 1.12.1
* Bump `github.com/tdewolff/minify/v2` from 2.9.28 to 2.10.0
Version 2.0.35 (January 21, 2022)
---------------------------------
* Set `read-all` permission to `GITHUB_TOKEN` for GitHub Actions
* Pin `jshint` version in linter job
* Fix incorrect conversion between integer types
* Add new GitHub Actions workflows: CodeQL and Scorecards analysis
* Handle Atom feeds with space around CDATA
* Bump `github.com/tdewolff/minify/v2` from 2.9.22 to 2.9.28
* Add Documentation directive to Systemd service
* Do not reset `touch-item` if successfully swiped
* Add support for multiple authors in Atom feeds
* Omit `User-Agent` header in image proxy to avoid being blocked
* Use custom feed user agent to fetch website icon
* Make default Invidious instance configurable
* Add new rewrite rule `add_youtube_video_from_id` to add Youtube videos in Quanta articles
* Add scrape and rewrite rules for `quantamagazine.org`
* Expose entry unshare link in the entry and list views
* Add Google Reader API implementation (experimental)
* Add `Content-Security-Policy` header to feed icon and image proxy endpoints
- SVG images could contain Javascript. This CSP blocks inline script.
- Feed icons are served using `` tag and Javascript is not interpreted.
* Add Finnish translation
* Add scraper rule for `ikiwiki.iki.fi`
* Remove `SystemCallFilter` from `miniflux.service`
* Fix minor typo in French translation
Version 2.0.34 (December 16, 2021)
----------------------------------
* Add rewrite rule for comics website http://monkeyuser.com
* Add `` tag to OPML export
* Tighten Systemd sandboxing and update comments in `miniflux.service`
* Add `RuntimeDirectory` to Systemd service
* Order disabled feeds at the end of the list
* Add support for theme color based on preferred color scheme of OS
* Bump `github.com/lib/pq` from 1.10.3 to 1.10.4
* Bump `github.com/PuerkitoBio/goquery` from 1.7.1 to 1.8.0
* Fix typos in `model/icon.go`
* Add `data-srcset` support to `add_dynamic_image rewrite` rewrite rule
* Fix Docker Compose example files compatibility to v3
* Added the `role="article"` to `` elements for better accessibility with screen readers
* Redact secrets shown on the about page
* Handle `srcset` images with no space after comma
* Hide the logout link when using auth proxy
* Fix wrong CSS variable
* Change `-config-dump` command to use `KEY=VALUE` format
Version 2.0.33 (September 25, 2021)
-----------------------------------
* Build RPM and Debian package with PIE mode enabled
* Add CSS rule to hide `` tag in old browsers
* Bump `github.com/tdewolff/minify/v2 from 2.9.21 to 2.9.22`
* Bump `github.com/lib/pq from 1.10.2 to 1.10.3`
* Remove `RequestURI()` hack
* Improve `zh_CN` translation
* Add ability to change entry sort order in the UI
* Add minor improvements in integration package
* Add Telegram integration
* Add rewrite rule to remove DOM elements
* Add proxy argument to `scraper.Fetch()`
* Add mime type `application/feed+json` to discover JSON Feed v1.1
* Update scraper rule for `theregister.com`
* Add Go 1.17 to GitHub Actions
* Display option to hide feed only when category is not already hidden
* Add option to hide feeds from the global Unread list
Version 2.0.32 (August 14, 2021)
--------------------------------
* Bump `github.com/tdewolff/minify/v2` from 2.9.17 to 2.9.21
* Bump `mvdan.cc/xurls/v2` from 2.2.0 to 2.3.0
* Bump `github.com/PuerkitoBio/goquery` from 1.6.1 to 1.7.1
* Bump `github.com/prometheus/client_golang` from 1.10.0 to 1.11.0
* Add `/rss/` to the list of well known URLs during feed discovery
* Use `authors` entry for JSON 1.1 feeds
* Added Greek translation
* Added the ability to mark an entire category as read in the web ui
* Added "in" in "logged in" for en_US `tooltip.logged_user`
* Added option to hide categories from the global unread list
* Show "saving" labels for entry status button
* Golang client: Try to parse response body on `InternalServerError` errors
* contrib: Add support for a $MINIFLUX_IMAGE env var in docker-compose
* contrib: Bump docker-compose version to 3.4
Version 2.0.31 (June 6, 2021)
-----------------------------
* Expose comments_url entry field in Golang API client
* Use unique file names for cache busting instead of query string
* Highlight and sort feeds with unread entries in feeds list
* Mark items as read on click/middle click of external links
* Fix: Firefox on Windows does not show the active link as bold
* Avoid extra HTTP request for fetching custom stylesheet
* Remove invalid CSRF HTML meta tag
* Add lang attribute to root HTML tag
* Use runes instead of bytes to truncate JSON feed titles (avoid breaking Unicode strings)
* Expose changed_at time through the API
* Add new config option CLEANUP_ARCHIVE_BATCH_SIZE
* Add new option DATABASE_CONNECTION_LIFETIME
* Add database stats to Prometheus exporter
* Add Systemd watchdog
* Avoid custom stylesheet to be cached by third-party CDN
* Update a shared entry label translation in zh_CN
* Bump github.com/tdewolff/minify/v2 from 2.9.16 to 2.9.17
* Bump github.com/lib/pq from 1.10.1 to 1.10.2
Version 2.0.30 (May 7, 2021)
----------------------------
* Security fix: any user can delete any feed (Regression introduced in commit 51fb949)
* Fix password reset via CLI
* Increase default batch size value
* Handle RSS feed title with encoded Unicode entities
* Show number of unread per category in category list instead of number of feeds
* Bump github.com/lib/pq from 1.10.0 to 1.10.1
* Filtering doesn't work when selecting from multiple found feeds
* Bump github.com/tdewolff/minify/v2 from 2.9.15 to 2.9.16
* Use an appropriate color for visited links on dark theme
* Fix typo in reader/json/doc.go
* Create SECURITY.md
* Setup golangci-lint Github Action
* Add per feed cookies option
* Bump github.com/prometheus/client_golang from 1.9.0 to 1.10.0
* Bump github.com/tdewolff/minify/v2 from 2.9.13 to 2.9.15
Version 2.0.29 (Mar 21, 2021)
-----------------------------
* Miniflux requires at least Go 1.16 now
* Improved support of Atom text constructs
- Improve handling of CDATA in text elements
- Omit XHTML root element because it should not be part of the content
- Fix incorrect parsing of HTML elements
* Handle RDF feed with HTML encoded entry title
* Add Turkish language
* Improve deletion of feeds with lots of entries
* Add support of Systemd readiness notification using the sd_notify protocol
* Remove feed_icons service worker cache because it's causing more problems than it solves (and HTTP cache seems faster)
* Add basic PWA offline page
- Add basic offline mode when using the service worker
- Starting in Chrome 93, offline mode is going to be a requirement to install the PWA
* Replace icon for "Add to home screen" button
* Use SVG icons for "toast" notifications
* Use SVG sprite for icons instead of inline elements
* Reset scroll position on mark page as read
* Add link to mark all feed entries as read
* Make web app display mode configurable (The change is visible after reinstalling the web app)
* Handle RSS feeds with CDATA in author item element
* Add read time on the article page
* Avoid showing a broken image when there is no feed icon
* Add option to allow self-signed or invalid certificates
* Add new config option to scrape YouTube's website to get video duration as read time (disabled by default)
* Send full article content to Wallabag
* Add more extensive health check support
- Improve endpoint to test database connection
- Add new cli argument: -healthcheck
- Update Docker Compose examples
* Update iframe "allow list" to support Bilibili videos
* Remove completely generated files and use embed package to bundle JS/CSS/Images/Translations files into the final binary
* Remove deprecated io/ioutil package
* Show Postgres version in "About" page
Version 2.0.28 (Feb 15, 2021)
-----------------------------
* Add HTTP header "Referrer-Policy: no-referrer"
* Handle entry title with double encoded entities
* Add Open Containers annotations to Docker image
* Remove iframe inner HTML contents (iframe element never has fallback content)
* Update date parser to fix another time zone issue
* Update German translation for blocklist and keeplist
* Validate Keep list and Block list rules syntax
* Add support for IPv6 with zone index
* Allow images with data URLs
* Limit full-text search indexation to first 500K characters (tsvector has a size limit of 1MB)
* Change PWA display mode to standalone
* ETag value is not set correctly in HTTP client (regression)
* Add database backed Let's Encrypt certificate cache
* Add global option POLLING_PARSING_ERROR_LIMIT
* Update systemd service file comments to use `systemctl edit` for editing
* Update Go version to 1.15 in go.mod
* Don't discard the "Fetch via Proxy" option
* Update man page to show the default values
* Add PostgreSQL indices
* Add API endpoints to get feeds and entries of a category
* Create feed query builder
* Bump github.com/PuerkitoBio/goquery from 1.6.0 to 1.6.1
* Show global options in the about page
* Update man page to mention -1 can be used for CLEANUP_ARCHIVE_* options
Version 2.0.27 (Jan 9, 2021)
----------------------------
* Add spellcheck="false" to input fields
* Refactoring of entry, feed, category, and user validation
* Avoid stripping tags for entry title
* Add the possibility to subscribe to feeds with the Android Share menu
* API improvements:
- Change feed creation request to allow setting most fields via API
- Allow regular users to change settings via API
- Make user fields editable via API
- Renaming non-existent category via API should return a 404
* Update Systemd service file:
- Add capability CAP_NET_BIND_SERVICE (allow the process to listen on privileged ports)
- Enable a private /tmp for $CERT_CACHE (required when using Let's Encrypt)
* Update read/star icons to SVGs
* Add autocomplete="username" to HTML forms
* Improve user mass delete to use fewer Goroutines
* Use SQL transaction when creating user sessions and users
* Remove extra column (HSTORE field) from users table and migrate key/value pairs to specific columns
* Bump github.com/prometheus/client_golang from 1.8.0 to 1.9.0
* Bump github.com/lib/pq from 1.8.0 to 1.9.0
* Add styles for HTML tag
* Refactor SQL migrations:
- Avoid embedding SQL files into binary
- Allow more flexible changes by using Go functions
* Add Server-Timing header to unread page
* Show correct User Agent in input placeholders
* Add autocomplete attribute to login form
* Add Grafana dashboard in contrib folder
Version 2.0.26 (Dec 5, 2020)
----------------------------
* Use created_at instead of published_at for archiving entries
* Add created_at field for entries
* Handle invalid feeds with relative URLs
* Add API routes for "mark all as read"
* Add support for setting a global default User-Agent
* Add rewrite rule "replace" for custom search and replace
* Calculate reading time during feed processing
* Handle various invalid dates
* systemd: keep /run writeable
* debian package: add missing post-install script
* Do not follow redirects when trying known feed URLs
* Trim spaces around icon URLs
* Reinstate EXPOSE instruction in Dockerfile
* Update German and Portuguese translations
Version 2.0.25 (Nov 3, 2020)
----------------------------
* Rename "original" link to be more explicit
* Do not escape HTML for Atom 1.0 text content during parsing (Avoid HTML entities issues)
* Do not use charset.NewReader if the body is a valid UTF-8 document
* Restore the ability to use a proxy for all HTTP requests (see https://golang.org/pkg/net/http/#ProxyFromEnvironment)
* Show Git commit in about page
* Publish Docker images to GitHub Container Registry
* Added few Docker Compose examples in contrib folder
* Added Ansible Role + Playbook for Miniflux in contrib folder
* Add rewrite rule to use noscript content for images rendered with Javascript
* Bump github.com/prometheus/client_golang from 1.7.1 to 1.8.0
* Update contributor link and Godoc badge for API client
* Move Debian package builder to main repository
* Move RPM build files to main repository
* Add GitHub Action to generate Docker images
* Build multi-platform images with Docker Buildx
* Add keyboard shortcut to scroll current item to the top
* Add feed filters (Keeplist and Blocklist)
* Do not proxy image with a data URL
* Bump github.com/PuerkitoBio/goquery from 1.5.1 to 1.6.0
* Proxify articles crawled manually
* Proxify images defined in srcset attribute
* Remove plaintext Fever password from database
* Add keyboard shortcut to jump to an item's feed page
* Add option for swipe gesture on entries on mobile
Version 2.0.24 (Oct 3, 2020)
----------------------------
* Add rewrite rule to fix Medium.com images
* Update sanitizer to support responsive images:
- Add support for picture HTML tag
- Add support for srcset, media, and sizes attributes to img and source tags
* Enhance man page formatting
* Add Prometheus exporter
* Remove dependency on global config options in HTTP client
* API:
- Avoid database lookup if empty credentials are provided
- Add the possibility to filter entries by category ID
- Add the possibility to filter entries by a list of statuses
* Add Feed ID in worker error logs
* Tweak default HTTP client transport timeout values to reduce the number of file descriptors
* CSS tweaks and optimizations:
- Prevent sub and sup from affecting line-height
- Set touch-action on .items to prevent browser navigation
- Move font-family specific CSS to the appropriate file
- Update primary font-family for UI to be even more compatible with various operating systems
- Make .entry-content font-weight variable depending on font-family used
* Avoid Javascript minifier to break keyboard shortcuts
* Rename service worker script to avoid being blocked by uBlock
* Update date parser to handle Pacific Daylight Time in addition to Pacific Standard Time
* Create index to speed up bookmark page
* Do not try to update a duplicated feed after a refresh
* Use a transaction to refresh and create entries
* Speed up entries clean up with an index and a goroutine
* Avoid the accumulation of enclosures by keeping only what is referenced in the feed
* Add workarounds for parsing an invalid date
* Archive older entries first
* Update API client to support more filters
* Avoid duplication between get feed entries and get entries API endpoints
* Enable strict slash to avoid a page not found (404) when using a trailing slash in the URLs
* Add a submit button to each section of the integration page
* Reload page after making page as read when showing unread entries
* Add option to archive unread entries
* Add option to enable maintenance mode
* Add HTTP proxy option for subscriptions
* Make add_invidious_video rule applicable for different invidious instances
* Fix reading time for jp, ko and zh languages
* Update POLLING_SCHEDULER description in man page
* Bump gorilla/mux from 1.7.4 to 1.8.0
* Add link to mark a feed as read
Version 2.0.23 (Aug 15, 2020)
-----------------------------
* Try known URLs when discovering subscriptions
* Add workarounds to find YouTube channel feeds (YouTube doesn't expose RSS links anymore for new-style URLs)
* Increase HTTP server timeout values
* Use stdlib constants for HTTP methods instead of strings
* Add support for RTL feed content
* Ignore to avoid overriding the default title if they are different
* Add support for secret keys exposed as a file (useful for containerized environments)
* Display recent entries first in search results
* Do not archive shared items
* Add option to change the number of entries per page
* Add Brazilian Portuguese (pt_BR) translation
* Add reading time for entries
* Redirect to login page if CSRF token is expired
* Fever API:
- Use getEntryIDs instead of getEntries to reduce memory consumption
- Fix max_id argument logic to follow the specs
- Improve logging
- Do not send articles to external services when unsaving an item
- Create index to speed up API calls
- Log client IP in middleware
* API client: Do not return body for response with no content
* REST API:
- Delete users asynchronously (Deleting large users might lock the tables)
- Add CORS support
* Align entry actions to the left
- Attempt to avoid awkward alignment on smartphone screens
- Keep the read/star actions aligned to the left
- Remove CSS flex to allow easier override with custom CSS
* Upgrade Postgres client library
* Upgrade CI checks to Go 1.15
Version 2.0.22 (Jun 19, 2020)
-----------------------------
* Remove child-src CSP policy (deprecated)
* Add /version endpoint
* Add the ability to use custom css
* Handle more invalid dates
* Add CSS styles for textarea
* Add index to speed up slow query
* Speed up feed list page rendering
* Add alternative scheduler based on the number of entries
* Setup Dependabot on GitHub
* Update Docker image to Alpine 3.12
* Add feed option to ignore HTTP cache
* Fix some Italian and German translations
* Added scraper rule for RayWenderlich.com, TheOatmeal.com, financialsamurai.com, dilbert.com and turnoff.us
* Replace link to categories by a link to the list of entries in "Category > Feeds" page
* Change feed title to a link to the original website
* Add icons to feeds and categories list
* Update dependencies and remove vendor folder
Version 2.0.21 (Mar 28, 2020)
-----------------------------
* Add SVG icons to entry actions
* Add support for Invidious
- Embed Invidious player for invidio.us feeds
- Add new rewrite rule to use Invidious player for Youtube feeds
* Check during application startup if the database schema is up to date
* Change default theme for public pages to "System Serif"
* Add feature to share an article (create a public link of a feed entry)
* Fix SQL injection in full-text search rank ordering
* Add generic OpenID Connect provider (OAuth2)
* Use more secure TLS configuration for autocert server (increase SSL Labs score from B to A+)
* Add feature to create per-application API Keys
* Add Go 1.14 to GitHub Actions
* Add scraper rule for wdwnt.com
* Add API client function to refresh all feeds
* Add API endpoint to refresh all feeds
* Add Auth Proxy authentication
* Use rel=prev/next on pagination links
Version 2.0.20 (Feb 15, 2020)
-----------------------------
* Add Japanese translation
* History: show entries in the order in which they were read
* Add button to add to Home screen
* Ignore enclosures without URL
* Correct spelling of "toggle"
* List view: align information to the left side, and the actionable buttons to the right
* Redirect to /unread when getting a 404 for an unread expired entry
* Do not advance to the next item when using the 'v' shortcut on the list of starred items
* Wrap around when navigating with keyboard shortcuts on a list view
* Remove unused Feed.Entries and Entry.Category from API client
* Add comments link keyboard shortcut
* Allow application/xhtml+xml links as comments URL in Atom replies
* Allow only absolute URLs in comments URL
* Use internal XML workarounds to detect feed format
* Make menu consistent across feed pages
* Make sure external URLs are not encoded incorrectly by Go template engine
* Make sure whitelisted URI schemes are handled properly by the sanitizer
* Use white background for favicon (Improve legibility when using a dark theme)
* Remove dependency on Sed to build Docker images
* Normalize URL query string before executing HTTP requests
* Improve Dublin Core support for RDF feeds
* Improve Podcast support (iTunes and Google Play feeds)
* Add support for Atom 0.3
* Add support for Atom "replies" link relation
* Return outer HTML when scraping elements
* Update scraper rule for "Le Monde"
* Filter valid XML characters for UTF-8 XML documents before decoding
* Trim spaces for RDF entry links
Version 2.0.19 (Dec 1, 2019)
----------------------------
* Add shortcut "V" to open original link in current tab
* Add the possibility to add rules during feed creation
* Wrap attachments into disclosure element
* Show attachment size on entry page
* Add support of RSS Media elements (group, description, peer link, and thumbnails)
* Add rewrite functions: convert_text_link and nl2br
* Add scraper rule for openingsource.org
* Add Makefile target to build only amd64 Docker image
* Make sure to remove integration settings when removing a user
* Add API parameter to filter entries by category
* Display list of feeds per category
* Show the number of read and unread entries for each feed
* Make sure settings menu is consistent
* Remove fixed table-layout for entry content
* Update autocert lib because ACME v1 is EOL
* Do not lighten blockquote font color
* Update de_DE translation
* Send a response when changing status of removed entries in Fever API
* Add meta tag to disable Google Translate
* Improve storage module
* Improve XML decoder to remove illegal characters
* Compare Fever token case-insensitively
* Make sure integration tests are marked as failed in Github Actions
* Add new formats to date parser
* Add notification message when using keyboard shortcuts: f, s, and m.
* Avoid keyboard shortcuts to conflict with Firefox’s "Find as you type" feature
Version 2.0.18 (Sep 25, 2019)
-----------------------------
* Add Docker image variant for arm32v7
* Add theme variants
- Use CSS variables instead of inherence
- Rename default theme to "Light - Serif"
- Rename Black theme to "Dark - Serif"
- Rename "Sans-Serif" theme to "Light - Sans Serif"
- Add "System" theme that use system preferences: Dark or Light
- Add Serif and Sans-Serif variants for each color theme
* Avoid constraint error when having duplicate entries during feed creation
* Disable strict XML parsing
* Ignore invalid content type
* Update man page
* Replace Travis by GitHub Actions
* Rename cleanup config variables and deprecate old ones
- CLEANUP_FREQUENCY_HOURS instead of CLEANUP_FREQUENCY
- CLEANUP_ARCHIVE_READ_DAYS instead of ARCHIVE_READ_DAYS
* Make configurable the number of days to remove old sessions
* Add native lazy loading for images and iframes
* Do not buffer responses in the image proxy
* Update dependencies
* Add Go 1.13 to test matrix
* Replace link border by outline to avoid slight content shift
* New rewrite function: add_mailto_subject
* Import OPML from URL
* Fix HTML injection in addImageTitle
* Accept HTML entities when parsing XML
Version 2.0.17 (Aug 3, 2019)
----------------------------
* Update Docker image to Alpine Linux 3.10.1
* Pass auth header to manifest request (crossorigin attribute)
* Sort feed categories before serialization
* Fix syntax errors in man page
* Add .search margin-right
* Ask for confirmation before flushing history, marking page as read, and mark all as read
* Add option to disable feeds
Version 2.0.16 (Jun 8, 2019)
----------------------------
* Add option to toggle date/time in log messages
* Add optional config file parser in addition to environment variables
* Make HTTP Client timeout and max body size configurable
* Refactor config package:
- Parse configuration only once during startup time
- Store configuration values in a global variable
* Flip behavior of j and k keyboard shortcuts
* Bump Postgresql client library to v1.1.1 to bring in SCRAM-SHA-256 authentication
* Add option to enable/disable keyboard shortcuts
* Add missing translation
* Improve page reload when showing unread/all entries:
- Show only unread entries = refresh current page
- Show all entries = go to next page
* Always display feed entries even when there is a feed error
* Use loading label instead of saving when submitting login form
* Add OPML v1 support during importation
* Add 'allow-popups' to iframe sandbox permissions
Version 2.0.15 (Mar 16, 2019)
-----------------------------
* Move Dockerfile to main repo
* Change location of the binary from /usr/local/bin to /usr/bin in Docker image
* Add double tap detection for next/previous page navigation
* Allow users to disable auto-remove
* Make parser compatible with Go 1.12
* Add Golang 1.12 to CI
* Use feed ID instead of user ID to check entry URLs presence
* Fix typo in stylesheet
* Sort search results by relevance
* Use preferably the published date for Atom feeds
* Add Spanish translation
* Rename session cookies
* Handle the case when application session is expired and not user session
Version 2.0.14 (Jan 13, 2019)
-----------------------------
* Only attempt to change password if the confirmation field is filled in (Firefox)
* Remove URL from client user agent
* Make the feed list order case-insensitive
* Handle XHTML Summary elements for Atom feeds
* Make UTF-8 the default encoding for XML feeds
* Add more targets to Makefile
* Add -mod=vendor in Makefile
* Move health check endpoint from ui package to httpd service
* Add workaround for non GMT dates (RFC822, RFC850, and RFC1123)
* Make sure `` elements are bold
* Show scrollbars only when necessary for
elements
* Add Italian translation
* Allow to switch between unread only and all entries on category/feed views
* Add function storage.UpdateFeedError()
* Add BBC News scraping rule
* Ignore JSON feeds from EnsureUnicode()
* Preserve category selection when no feed is found
* Update XML encoding regex to take single quotes into consideration
* Send cli errors to stderr
* Update dependencies
* Make password prompt compatible with Windows
* Make configurable the number of days to archive read items
* Change log level to debug when starting workers
* Do not show $DATABASE_URL warning when showing application info
* Move image proxy filter to template functions
* Update scraper rule for lemonde.fr
* Refactor manual entry scraper
* Apply rewriter rules on manual "Fetch Original Content"
* Add Makefile target for current OS and architecture
* Improve Makefile
Version 2.0.13 (Nov 25, 2018)
-----------------------------
* Add man page
* Add support for Systemd Socket Activation (experimental)
* Add the possibility to listen on Unix sockets
* Add config options to disable HTTP and scheduler services
* Archive more read entries in cleanup job
* Change default database connection string (backward compatible)
* Improve logging messages in ui package
* Improve overall Simplified Chinese translations
* Improve time since post date displays:
- "15 days" now is "15 days" rather than "3 weeks" ago
- "32 days" is now "1 month" rather than "2 months" ago
- "366 days" is now "1 year" rather than "2 years" ago
* Allow the scraper to parse XHTML documents
* Remove charset=utf-8 from JSON responses
* Ignore hotkeys containing Control, Alt or Meta keys
* Handle more encoding conversion edge cases
* Disable go test caching
* Avoid duplication of ldflags in Makefile
* Fix wrong translation key for category pages
* Code refactoring:
- Simplify application HTTP middlewares
- Replace daemon and scheduler package with service package
- Move UI middlewares and routes to ui package
- Move API middleware and routes to api package
- Move Fever middleware and routes to fever package
Version 2.0.12 (Oct 26, 2018)
-----------------------------
* Add OpenBSD build
* Improve logging for OAuth2 callback
* Make "g f" go to feed, or list of feeds
* Add more details in feed storage errors to facilitate debugging
* Add entries storage error to feed errors count
* Set arbitrary maximum size for tsvector column
* Unsubscribe from feed through link or "#"
* Simplify feed entries filtering
* Simplify feed fetcher
* Simplify feed parser and format detection
* Improve unit tests in url package
* Add short cli flags -i and -v
* Convert text links and line feeds to HTML in YouTube channels
* Change link state when marking all entries as read
* Add missing package descriptions for GoDoc
* Fix typo in license header
* Refactor HTTP response builder
* Improve Fever API performances when marking a feed or group as read
* Set focus on article link when pressing prev/next hotkeys
* Improve request package and add more unit tests
* Add more unit tests for config package
* Simplify locale package usage (refactoring)
* Translate application in Russian
* Use disclosure widget for advanced feed options
* Use unique translation IDs instead of English text as key
* Add more unit tests for template functions
* Fix invalid output when truncating Unicode text in templates
* Add the possibility to override default user agent for each feed
* Split Makefile linux targets by architecture
* Add compiler, Arch, and OS to info command
* Avoid line break between emoji and (un)read/(un)star links
* Build Docker image for multiple architectures (amd64, arm32v6, arm64v8)
Version 2.0.11 (Sep 11, 2018)
-----------------------------
* Set cookie flag `SameSite` to Lax mode
* Use predefined ciphers when TLS is configured
* Avoid displaying an error when shutting down the daemon
* Add "Mark this page as read" to the bottom
* Store client IP address in request context
* Refactor HTTP context handling
* Make user creation via environment variables idempotent
* Use regular text version of ✔︎ instead of emoji version on iOS
* Add toggle status button to entry page
* Migrate to Go Modules and Go 1.11
* Show count of feeds with permanent errors in header menu
* Display remote client IP in logs when having a login failure (Fail2Ban)
* Add remove button in feed edit page
* Split integration tests into multiple files
* Update scraper rule for heise.de
* Expose real error messages for internal server API errors
* Move Golang API client in project source tree (the separate project is deprecated)
* Use canonical imports
* Add Procfile
* Create database package (refactoring)
* Update user agent with new website URL
* Update German translation
Version 2.0.10 (July 22, 2018)
------------------------------
* Avoid browser caching issues when assets changes
* Add Gzip/Deflate compression for HTML, JSON, CSS and Javascript responses
* Improve themes handling
* Store user theme in session
* Logged out users will keep their theme
* Add theme background color to web manifest and meta tag
* Update application icon with different sizes
* Add support for published tag in Atom feeds
* Add tooltip to feed domain in feeds list (title attribute)
* Prevent vertical scrolling on swipe
* Show feed title instead of domain in items list
* Add service worker to cache feed icons
* Make image proxy configurable via IMAGE_PROXY environment variable:
* none = No proxy
* http-only = Proxy only non-HTTPS images (default)
* all = Proxy everything
* Add alt attribute for feed icons
* Update CI jshint check
* Add embedly.com to iframe whitelist
* Use passive event listeners for touch events
* Add `add_dynamic_image` rewriter for JavaScript-loaded images
* Change feed password field type to text to avoid auto-completion with Firefox
* Using autocomplete="off" or autocomplete="new-password" doesn't change anything
* Changing the input ID doesn't change anything
* Using a different input name doesn't change anything
* Only Chrome supports autocomplete="new-password"
* Add base URL validation
* Update default stylesheet name in HTML layout
* Pre-generate themes stylesheets at build time
* Update vendor dependencies
* Refactor assets bundler and split Javascript files
* Run sanitizer after all processing and entry content rewrite
* Remove timestamp from generated files
* Add support for protocol relative YouTube URLs
* Add Postgres full-text search for entries
* Add search form in user interface
* Add search parameter to the API
* Improve Dutch locales
* Sandbox iframes when sanitizing
* Keep consistent text size on mobile orientation change
* Change permission of /etc/miniflux.conf to 600 instead of 644 in RPM package
* Add tzdata package to Docker image
* Update Docker image to Alpine Linux 3.8
Version 2.0.9 (July 1, 2018)
----------------------------
* Avoid Chrome to autocomplete no-login password fields
* Add cli flag to reset all feed errors
* Do not ignore errored feeds when a user refresh feeds manually
* Add specific 404 and 401 error messages
* Strip binaries to reduce size
* Make sure we always get the pagination in unread mode
* Fix incorrect data value when toggling bookmark flag on entry page
* Set opener to null when opening original URL with JavaScript
* Remove unnecessary style
* Refactor AddImageTitle rewriter
* Only processes images with `src` **and** `title` attributes (others are ignored)
* Processes **all** images in the document (not just the first one)
* Wraps the image and its title attribute in a `figure` tag with the title attribute's contents in a `figcaption` tag
* Improve sanitizer to remove `style`, `noscript` and `script` tag contents
* Improve feed and user API updates with optional values
* Add new fields for feed username/password
* Improve memory usage debug log
* Disable keep-alive for HTTP client
* Close HTTP response body even for failed requests
* Add Sans-Serif theme
* Rewrite iframe Youtube URLs to https://www.youtube-nocookie.com
* Add more filters for API call `/entries`:
* before (unix timestamp)
* before_entry_id (int64)
* after (unix timestamp)
* after_entry_id (int64)
* starred (boolean)
* Rewrite individual entry pagination SQL queries
* Simplify entry query builder
* Prevent items from sticking on touchend
* Extended horizontal overflow to feed and category views
* Translate missing strings
* Update German translation
Version 2.0.8 (June 4, 2018)
----------------------------
* Add Pocket integration
* Rewrite RealIP() to avoid returning an empty string
* Convert IP field from text to inet type
* Improve error handling in integration clients
* Make unread counter clickable
* Archive read entries automatically after 60 days
* Hide horizontal overflow when swiping articles on touch devices
* Add API endpoint to get logged user
* Fever API: Return response with an empty list if there is no item
* Handle feeds with dates formatted as Unix timestamp
Version 2.0.7 (May 7, 2018)
---------------------------
* Add API endpoint to import OPML file
* Make sure to close request body in HTTP client
* Do not show save link if no integration is configured
* Make sure integrations are configured before to make any HTTP requests
* Avoid people to unlink their OAuth2 account without having a local password
* Do not use shared variable to translate templates (avoid concurrency issue)
* Use vanilla HTTP handlers (refactoring)
* Move HTTP client to its own package (refactoring)
* Add middleware to read X-Forwarded-Proto header (refactoring)
* Use Gorilla middleware (refactoring)
* Scrape parent element for iframe
* Add SoundCloud and Bandcamp iframe sources
Version 2.0.6 (Apr 20, 2018)
----------------------------
* Improve graceful shutdown
* Simplify Heroku deployment
* Display memory usage and some metrics in logs
* Increase read/write timeout for HTTP server
* Add support for Dublin Core date in RDF feeds
* Do not return an error if the user session is not found
* Handle some non-english date formats
* Add missing French translation
* Rename RSS parser getters
* Get the right comments URL when having multiple namespaces
* Ignore caching headers for feeds that send "Expires: 0"
* Update translations
Version 2.0.5 (Apr 7, 2018)
---------------------------
* Avoid unread counter to be off by one when reading an entry
* Add Comments URL to entries
* Add FreeBSD build target
* Handle RSS author elements with inner HTML
* Fix typo in translations
* Add Dutch translation
* Convert enclosure size field to bigint
* Switch CI to Go v1.10
* Fix broken OPML import when compiling with Go 1.10
Version 2.0.4 (Mar 5, 2018)
---------------------------
* Add Simplified Chinese translation
* Add Nunux Keeper integration
* Filter the list of timezones
* Add timezone to entries dates for REST and Fever API
* Show last login and session creation date in current timezone
* Fix typo in edit user template
* Improve parser error messages
* Remove parentheses around feed error messages
* Support localized feed errors generated by background workers
* Print info message if DATABASE_URL is not set
Version 2.0.3 (Feb 19, 2018)
----------------------------
* Add Polish translation
* Change color of tags for black theme
* Add database indexes (don't forget to run database migrations)
* Handle Atom feeds with HTML title
* Strip invalid XML characters to avoid parsing errors
* Improve error handling for HTTP client
Version 2.0.2 (Feb 5, 2018)
---------------------------
* Add support for Let's Encrypt http-01 challenge
* Move template functions outside engine (refactoring)
* Take timezone into consideration when calculating relative time
* Add support for HTTP Strict Transport Security header
* Add support for base URLs with subfolders
* Add missing about menu in settings
* Show API URL endpoints in user interface
* Do not update entry date while refreshing a feed
* Add flag to toggle debug logging
* Improve unread counter updates
Version 2.0.1 (Jan 22, 2018)
----------------------------
* Change user agent (People are blocking the crawler with mod_security)
* Move environment variables to config package (refactoring)
* Add build targets for all ARM architectures
* Do not crawl existing entry URLs
* Show translated login page in user language when logged out
* Handle more encoding edge cases:
- Feeds with charset specified only in Content-Type header and not in XML document
- Feeds with charset specified in both places
- Feeds with charset specified only in XML document and not in HTTP header
* Add German translation
* Add mark as read/unread link on list items
* Add API endpoint for OPML export
Version 2.0.0 (Jan 11, 2018)
----------------------------
* Initial release of Miniflux 2.
miniflux-2.0.51/LICENSE 0000664 0000000 0000000 00000023676 14546226260 0014464 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
miniflux-2.0.51/Makefile 0000664 0000000 0000000 00000011524 14546226260 0015104 0 ustar 00root root 0000000 0000000 APP := miniflux
DOCKER_IMAGE := miniflux/miniflux
VERSION := $(shell git describe --tags --abbrev=0)
COMMIT := $(shell git rev-parse --short HEAD)
BUILD_DATE := `date +%FT%T%z`
LD_FLAGS := "-s -w -X 'miniflux.app/v2/internal/version.Version=$(VERSION)' -X 'miniflux.app/v2/internal/version.Commit=$(COMMIT)' -X 'miniflux.app/v2/internal/version.BuildDate=$(BUILD_DATE)'"
PKG_LIST := $(shell go list ./... | grep -v /vendor/)
DB_URL := postgres://postgres:postgres@localhost/miniflux_test?sslmode=disable
DEB_IMG_ARCH := amd64
export PGPASSWORD := postgres
.PHONY: \
miniflux \
linux-amd64 \
linux-arm64 \
linux-armv7 \
linux-armv6 \
linux-armv5 \
linux-x86 \
darwin-amd64 \
darwin-arm64 \
freebsd-amd64 \
freebsd-x86 \
openbsd-amd64 \
openbsd-x86 \
netbsd-x86 \
netbsd-amd64 \
windows-amd64 \
windows-x86 \
build \
run \
clean \
test \
lint \
integration-test \
clean-integration-test \
docker-image \
docker-image-distroless \
docker-images \
rpm \
debian \
debian-packages
miniflux:
@ CGO_ENABLED=0 go build -buildmode=pie -ldflags=$(LD_FLAGS) -o $(APP) main.go
linux-amd64:
@ CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@ main.go
linux-arm64:
@ CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@ main.go
linux-armv7:
@ CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=7 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@ main.go
linux-armv6:
@ CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=6 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@ main.go
linux-armv5:
@ CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=5 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@ main.go
darwin-amd64:
@ GOOS=darwin GOARCH=amd64 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@ main.go
darwin-arm64:
@ GOOS=darwin GOARCH=arm64 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@ main.go
freebsd-amd64:
@ CGO_ENABLED=0 GOOS=freebsd GOARCH=amd64 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@ main.go
openbsd-amd64:
@ GOOS=openbsd GOARCH=amd64 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@ main.go
windows-amd64:
@ GOOS=windows GOARCH=amd64 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@ main.go
build: linux-amd64 linux-arm64 linux-armv7 linux-armv6 linux-armv5 darwin-amd64 darwin-arm64 freebsd-amd64 openbsd-amd64 windows-amd64
# NOTE: unsupported targets
netbsd-amd64:
@ CGO_ENABLED=0 GOOS=netbsd GOARCH=amd64 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@ main.go
linux-x86:
@ CGO_ENABLED=0 GOOS=linux GOARCH=386 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@ main.go
freebsd-x86:
@ CGO_ENABLED=0 GOOS=freebsd GOARCH=386 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@ main.go
netbsd-x86:
@ CGO_ENABLED=0 GOOS=netbsd GOARCH=386 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@ main.go
openbsd-x86:
@ GOOS=openbsd GOARCH=386 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@ main.go
windows-x86:
@ GOOS=windows GOARCH=386 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@ main.go
run:
@ LOG_DATE_TIME=1 DEBUG=1 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
test:
go test -cover -race -count=1 ./...
lint:
golint -set_exit_status ${PKG_LIST}
integration-test:
psql -U postgres -c 'drop database if exists miniflux_test;'
psql -U postgres -c 'create database miniflux_test;'
go build -o miniflux-test main.go
DATABASE_URL=$(DB_URL) \
ADMIN_USERNAME=admin \
ADMIN_PASSWORD=test123 \
CREATE_ADMIN=1 \
RUN_MIGRATIONS=1 \
DEBUG=1 \
./miniflux-test >/tmp/miniflux.log 2>&1 & echo "$$!" > "/tmp/miniflux.pid"
while ! nc -z localhost 8080; do sleep 1; done
go test -v -tags=integration -count=1 miniflux.app/v2/internal/tests
clean-integration-test:
@ kill -9 `cat /tmp/miniflux.pid`
@ rm -f /tmp/miniflux.pid /tmp/miniflux.log
@ rm miniflux-test
@ 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 build --load \
--build-arg BASE_IMAGE_ARCH=$(DEB_IMG_ARCH) \
-t $(DEB_IMG_ARCH)/miniflux-deb-builder \
-f packaging/debian/Dockerfile \
.
@ docker run --rm \
-v ${PWD}:/pkg $(DEB_IMG_ARCH)/miniflux-deb-builder
debian-packages: clean
$(MAKE) debian DEB_IMG_ARCH=amd64
$(MAKE) debian DEB_IMG_ARCH=arm64v8
$(MAKE) debian DEB_IMG_ARCH=arm32v7
miniflux-2.0.51/Procfile 0000664 0000000 0000000 00000000022 14546226260 0015121 0 ustar 00root root 0000000 0000000 web: miniflux.app
miniflux-2.0.51/README.md 0000664 0000000 0000000 00000003640 14546226260 0014723 0 ustar 00root root 0000000 0000000 Miniflux 2
==========
Miniflux is a minimalist and opinionated feed reader:
- Written in Go (Golang)
- Works only with Postgresql
- Doesn't use any ORM
- Doesn't use any complicated framework
- Use only modern vanilla Javascript (ES6 and Fetch API)
- Single binary compiled statically without dependency
- The number of features is voluntarily limited
It's simple, fast, lightweight and super easy to install.
Official website:
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/services.html)
- [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
miniflux-2.0.51/SECURITY.md 0000664 0000000 0000000 00000001014 14546226260 0015226 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.
miniflux-2.0.51/client/ 0000775 0000000 0000000 00000000000 14546226260 0014717 5 ustar 00root root 0000000 0000000 miniflux-2.0.51/client/README.md 0000664 0000000 0000000 00000001765 14546226260 0016207 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.New("https://api.example.org", "admin", "secret")
// Authentication with an API Key:
client := miniflux.New("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
}
}
```
miniflux-2.0.51/client/client.go 0000664 0000000 0000000 00000040245 14546226260 0016531 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 (
"encoding/json"
"fmt"
"io"
"net/url"
"strconv"
"strings"
)
// Client holds API procedure calls.
type Client struct {
request *request
}
// New returns a new Miniflux client.
func New(endpoint string, credentials ...string) *Client {
// Web gives "API Endpoint = https://miniflux.app/v1/", it doesn't work (/v1/v1/me)
endpoint = strings.TrimSuffix(endpoint, "/")
endpoint = strings.TrimSuffix(endpoint, "/v1")
// trim to https://miniflux.app
if len(credentials) == 2 {
return &Client{request: &request{endpoint: endpoint, username: credentials[0], password: credentials[1]}}
}
return &Client{request: &request{endpoint: endpoint, apiKey: credentials[0]}}
}
// Version returns the version of the Miniflux instance.
func (c *Client) Version() (*VersionResponse, error) {
body, err := c.request.Get("/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) {
body, err := c.request.Get("/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) {
body, err := c.request.Get("/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) {
body, err := c.request.Get(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) {
body, err := c.request.Get(fmt.Sprintf("/v1/users/%s", 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) {
body, err := c.request.Post("/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) {
body, err := c.request.Put(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 {
return c.request.Delete(fmt.Sprintf("/v1/users/%d", userID))
}
// MarkAllAsRead marks all unread entries as read for a given user.
func (c *Client) MarkAllAsRead(userID int64) error {
_, err := c.request.Put(fmt.Sprintf("/v1/users/%d/mark-all-as-read", userID), nil)
return err
}
// Discover try to find subscriptions from a website.
func (c *Client) Discover(url string) (Subscriptions, error) {
body, err := c.request.Post("/v1/discover", map[string]string{"url": url})
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) {
body, err := c.request.Get("/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
}
// CreateCategory creates a new category.
func (c *Client) CreateCategory(title string) (*Category, error) {
body, err := c.request.Post("/v1/categories", map[string]interface{}{
"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
}
// UpdateCategory updates a category.
func (c *Client) UpdateCategory(categoryID int64, title string) (*Category, error) {
body, err := c.request.Put(fmt.Sprintf("/v1/categories/%d", categoryID), map[string]interface{}{
"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
}
// MarkCategoryAsRead marks all unread entries in a category as read.
func (c *Client) MarkCategoryAsRead(categoryID int64) error {
_, err := c.request.Put(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) {
body, err := c.request.Get(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 {
return c.request.Delete(fmt.Sprintf("/v1/categories/%d", categoryID))
}
// RefreshCategory refreshes a category.
func (c *Client) RefreshCategory(categoryID int64) error {
_, err := c.request.Put(fmt.Sprintf("/v1/categories/%d/refresh", categoryID), nil)
return err
}
// Feeds gets all feeds.
func (c *Client) Feeds() (Feeds, error) {
body, err := c.request.Get("/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) {
body, err := c.request.Get("/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 {
_, err := c.request.PostFile("/v1/import", f)
return err
}
// Feed gets a feed.
func (c *Client) Feed(feedID int64) (*Feed, error) {
body, err := c.request.Get(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) {
body, err := c.request.Post("/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) {
body, err := c.request.Put(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
}
// MarkFeedAsRead marks all unread entries of the feed as read.
func (c *Client) MarkFeedAsRead(feedID int64) error {
_, err := c.request.Put(fmt.Sprintf("/v1/feeds/%d/mark-all-as-read", feedID), nil)
return err
}
// RefreshAllFeeds refreshes all feeds.
func (c *Client) RefreshAllFeeds() error {
_, err := c.request.Put("/v1/feeds/refresh", nil)
return err
}
// RefreshFeed refreshes a feed.
func (c *Client) RefreshFeed(feedID int64) error {
_, err := c.request.Put(fmt.Sprintf("/v1/feeds/%d/refresh", feedID), nil)
return err
}
// DeleteFeed removes a feed.
func (c *Client) DeleteFeed(feedID int64) error {
return c.request.Delete(fmt.Sprintf("/v1/feeds/%d", feedID))
}
// FeedIcon gets a feed icon.
func (c *Client) FeedIcon(feedID int64) (*FeedIcon, error) {
body, err := c.request.Get(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) {
body, err := c.request.Get(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) {
body, err := c.request.Get(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) {
body, err := c.request.Get(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) {
path := buildFilterQueryString("/v1/entries", filter)
body, err := c.request.Get(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) {
path := buildFilterQueryString(fmt.Sprintf("/v1/feeds/%d/entries", feedID), filter)
body, err := c.request.Get(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) {
path := buildFilterQueryString(fmt.Sprintf("/v1/categories/%d/entries", categoryID), filter)
body, err := c.request.Get(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 {
type payload struct {
EntryIDs []int64 `json:"entry_ids"`
Status string `json:"status"`
}
_, err := c.request.Put("/v1/entries", &payload{EntryIDs: entryIDs, Status: status})
return err
}
// UpdateEntry updates an entry.
func (c *Client) UpdateEntry(entryID int64, entryChanges *EntryModificationRequest) (*Entry, error) {
body, err := c.request.Put(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
}
// ToggleBookmark toggles entry bookmark value.
func (c *Client) ToggleBookmark(entryID int64) error {
_, err := c.request.Put(fmt.Sprintf("/v1/entries/%d/bookmark", entryID), nil)
return err
}
// SaveEntry sends an entry to a third-party service.
func (c *Client) SaveEntry(entryID int64) error {
_, err := c.request.Post(fmt.Sprintf("/v1/entries/%d/save", entryID), nil)
return err
}
// FetchCounters fetches feed counters.
func (c *Client) FetchCounters() (*FeedCounters, error) {
body, err := c.request.Get("/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 {
_, err := c.request.Put("/v1/flush-history", nil)
return err
}
// Icon fetches a feed icon.
func (c *Client) Icon(iconID int64) (*FeedIcon, error) {
body, err := c.request.Get(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
}
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))
}
for _, status := range filter.Statuses {
values.Add("status", status)
}
path = fmt.Sprintf("%s?%s", path, values.Encode())
}
return path
}
miniflux-2.0.51/client/doc.go 0000664 0000000 0000000 00000001311 14546226260 0016007 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.New("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"
miniflux-2.0.51/client/model.go 0000664 0000000 0000000 00000025440 14546226260 0016353 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"`
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"`
}
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"`
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"`
}
// Users represents a list of users.
type Users []User
// Category represents a feed category.
type Category struct {
ID int64 `json:"id,omitempty"`
Title string `json:"title,omitempty"`
UserID int64 `json:"user_id,omitempty"`
}
func (c Category) String() string {
return fmt.Sprintf("#%d %s", c.ID, c.Title)
}
// Categories represents a list of categories.
type Categories []*Category
// 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="%s", URL="%s", Type="%s"`, 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"`
BlocklistRules string `json:"blocklist_rules"`
KeeplistRules string `json:"keeplist_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"`
}
// 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"`
BlocklistRules string `json:"blocklist_rules"`
KeeplistRules string `json:"keeplist_rules"`
HideGlobally bool `json:"hide_globally"`
}
// 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"`
BlocklistRules *string `json:"blocklist_rules"`
KeeplistRules *string `json:"keeplist_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"`
}
// 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"`
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 Enclosures `json:"enclosures,omitempty"`
Feed *Feed `json:"feed,omitempty"`
Tags []string `json:"tags"`
}
// 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"`
}
// 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
}
// 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"`
}
miniflux-2.0.51/client/request.go 0000664 0000000 0000000 00000007563 14546226260 0016751 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"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net/http"
"net/url"
"time"
)
const (
userAgent = "Miniflux Client Library"
defaultTimeout = 80
)
// 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")
)
type errorResponse struct {
ErrorMessage string `json:"error_message"`
}
type request struct {
endpoint string
username string
password string
apiKey string
}
func (r *request) Get(path string) (io.ReadCloser, error) {
return r.execute(http.MethodGet, path, nil)
}
func (r *request) Post(path string, data interface{}) (io.ReadCloser, error) {
return r.execute(http.MethodPost, path, data)
}
func (r *request) PostFile(path string, f io.ReadCloser) (io.ReadCloser, error) {
return r.execute(http.MethodPost, path, f)
}
func (r *request) Put(path string, data interface{}) (io.ReadCloser, error) {
return r.execute(http.MethodPut, path, data)
}
func (r *request) Delete(path string) error {
_, err := r.execute(http.MethodDelete, path, nil)
return err
}
func (r *request) execute(method, path string, data interface{}) (io.ReadCloser, error) {
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 := &http.Request{
URL: u,
Method: method,
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.buildClient()
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("miniflux: bad request error (%v)", err)
}
return nil, fmt.Errorf("miniflux: bad request (%s)", 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) buildClient() http.Client {
return http.Client{
Timeout: time.Duration(defaultTimeout * time.Second),
}
}
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 interface{}) []byte {
b, err := json.Marshal(v)
if err != nil {
log.Println("Unable to convert interface to JSON:", err)
return []byte("")
}
return b
}
miniflux-2.0.51/contrib/ 0000775 0000000 0000000 00000000000 14546226260 0015101 5 ustar 00root root 0000000 0000000 miniflux-2.0.51/contrib/README.md 0000664 0000000 0000000 00000000342 14546226260 0016357 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.
miniflux-2.0.51/contrib/ansible/ 0000775 0000000 0000000 00000000000 14546226260 0016516 5 ustar 00root root 0000000 0000000 miniflux-2.0.51/contrib/ansible/inventories/ 0000775 0000000 0000000 00000000000 14546226260 0021063 5 ustar 00root root 0000000 0000000 miniflux-2.0.51/contrib/ansible/inventories/group_vars/ 0000775 0000000 0000000 00000000000 14546226260 0023252 5 ustar 00root root 0000000 0000000 miniflux-2.0.51/contrib/ansible/inventories/group_vars/miniflux_vars.yml 0000664 0000000 0000000 00000000406 14546226260 0026663 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
miniflux-2.0.51/contrib/ansible/playbooks/ 0000775 0000000 0000000 00000000000 14546226260 0020521 5 ustar 00root root 0000000 0000000 miniflux-2.0.51/contrib/ansible/playbooks/playbook.yml 0000664 0000000 0000000 00000000120 14546226260 0023055 0 ustar 00root root 0000000 0000000 ---
- hosts: miniflux
roles:
- { role: mgrote.miniflux, tags: "miniflux" } miniflux-2.0.51/contrib/ansible/roles/ 0000775 0000000 0000000 00000000000 14546226260 0017642 5 ustar 00root root 0000000 0000000 miniflux-2.0.51/contrib/ansible/roles/mgrote.miniflux/ 0000775 0000000 0000000 00000000000 14546226260 0022771 5 ustar 00root root 0000000 0000000 miniflux-2.0.51/contrib/ansible/roles/mgrote.miniflux/README.md 0000664 0000000 0000000 00000001050 14546226260 0024244 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
miniflux-2.0.51/contrib/ansible/roles/mgrote.miniflux/defaults/ 0000775 0000000 0000000 00000000000 14546226260 0024600 5 ustar 00root root 0000000 0000000 miniflux-2.0.51/contrib/ansible/roles/mgrote.miniflux/defaults/main.yml 0000664 0000000 0000000 00000000000 14546226260 0026235 0 ustar 00root root 0000000 0000000 miniflux-2.0.51/contrib/ansible/roles/mgrote.miniflux/handlers/ 0000775 0000000 0000000 00000000000 14546226260 0024571 5 ustar 00root root 0000000 0000000 miniflux-2.0.51/contrib/ansible/roles/mgrote.miniflux/handlers/main.yml 0000664 0000000 0000000 00000000330 14546226260 0026234 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
miniflux-2.0.51/contrib/ansible/roles/mgrote.miniflux/tasks/ 0000775 0000000 0000000 00000000000 14546226260 0024116 5 ustar 00root root 0000000 0000000 miniflux-2.0.51/contrib/ansible/roles/mgrote.miniflux/tasks/main.yml 0000664 0000000 0000000 00000001630 14546226260 0025565 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
miniflux-2.0.51/contrib/ansible/roles/mgrote.miniflux/templates/ 0000775 0000000 0000000 00000000000 14546226260 0024767 5 ustar 00root root 0000000 0000000 miniflux-2.0.51/contrib/ansible/roles/mgrote.miniflux/templates/miniflux.conf 0000664 0000000 0000000 00000000732 14546226260 0027473 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
miniflux-2.0.51/contrib/bruno/ 0000775 0000000 0000000 00000000000 14546226260 0016226 5 ustar 00root root 0000000 0000000 miniflux-2.0.51/contrib/bruno/README.md 0000664 0000000 0000000 00000000314 14546226260 0017503 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 miniflux-2.0.51/contrib/bruno/miniflux/ 0000775 0000000 0000000 00000000000 14546226260 0020061 5 ustar 00root root 0000000 0000000 miniflux-2.0.51/contrib/bruno/miniflux/Bookmark an entry.bru 0000664 0000000 0000000 00000000530 14546226260 0024037 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
}
miniflux-2.0.51/contrib/bruno/miniflux/Create a feed.bru 0000664 0000000 0000000 00000000430 14546226260 0023060 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"
}
}
miniflux-2.0.51/contrib/bruno/miniflux/Create a new category.bru 0000664 0000000 0000000 00000000411 14546226260 0024543 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"
}
}
miniflux-2.0.51/contrib/bruno/miniflux/Create a new user.bru 0000664 0000000 0000000 00000000441 14546226260 0023707 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"
}
}
miniflux-2.0.51/contrib/bruno/miniflux/Delete a category.bru 0000664 0000000 0000000 00000000503 14546226260 0023772 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
}
miniflux-2.0.51/contrib/bruno/miniflux/Delete a feed.bru 0000664 0000000 0000000 00000000472 14546226260 0023065 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
}
miniflux-2.0.51/contrib/bruno/miniflux/Delete a user.bru 0000664 0000000 0000000 00000000456 14546226260 0023142 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
}
miniflux-2.0.51/contrib/bruno/miniflux/Discover feeds.bru 0000664 0000000 0000000 00000000416 14546226260 0023421 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"
}
}
miniflux-2.0.51/contrib/bruno/miniflux/Fetch entry website content.bru 0000664 0000000 0000000 00000000547 14546226260 0026032 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
}
miniflux-2.0.51/contrib/bruno/miniflux/Flush history.bru 0000664 0000000 0000000 00000000421 14546226260 0023333 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"
}
}
miniflux-2.0.51/contrib/bruno/miniflux/Get a single entry.bru 0000664 0000000 0000000 00000000520 14546226260 0024074 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
}
miniflux-2.0.51/contrib/bruno/miniflux/Get a single feed entry.bru 0000664 0000000 0000000 00000000563 14546226260 0024767 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
}
miniflux-2.0.51/contrib/bruno/miniflux/Get a single feed.bru 0000664 0000000 0000000 00000000511 14546226260 0023636 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
}
miniflux-2.0.51/contrib/bruno/miniflux/Get a single user by ID.bru 0000664 0000000 0000000 00000000406 14546226260 0024564 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
}
miniflux-2.0.51/contrib/bruno/miniflux/Get a single user by username.bru 0000664 0000000 0000000 00000000424 14546226260 0026107 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
}
miniflux-2.0.51/contrib/bruno/miniflux/Get all categories.bru 0000664 0000000 0000000 00000000331 14546226260 0024146 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}}
}
miniflux-2.0.51/contrib/bruno/miniflux/Get all entries.bru 0000664 0000000 0000000 00000000433 14546226260 0023475 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"
}
}
miniflux-2.0.51/contrib/bruno/miniflux/Get all feeds.bru 0000664 0000000 0000000 00000000427 14546226260 0023115 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"
}
}
miniflux-2.0.51/contrib/bruno/miniflux/Get all users.bru 0000664 0000000 0000000 00000000317 14546226260 0023166 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}}
}
miniflux-2.0.51/contrib/bruno/miniflux/Get category entries.bru 0000664 0000000 0000000 00000000513 14546226260 0024541 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
}
miniflux-2.0.51/contrib/bruno/miniflux/Get category entry.bru 0000664 0000000 0000000 00000000542 14546226260 0024233 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
}
miniflux-2.0.51/contrib/bruno/miniflux/Get category feeds.bru 0000664 0000000 0000000 00000000507 14546226260 0024161 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
}
miniflux-2.0.51/contrib/bruno/miniflux/Get current user.bru 0000664 0000000 0000000 00000000317 14546226260 0023715 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}}
}
miniflux-2.0.51/contrib/bruno/miniflux/Get feed counters.bru 0000664 0000000 0000000 00000000444 14546226260 0024023 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"
}
}
miniflux-2.0.51/contrib/bruno/miniflux/Get feed entries.bru 0000664 0000000 0000000 00000000520 14546226260 0023625 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
}
miniflux-2.0.51/contrib/bruno/miniflux/Get feed icon by feed ID.bru 0000664 0000000 0000000 00000000507 14546226260 0024645 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
}
miniflux-2.0.51/contrib/bruno/miniflux/Get feed icon by icon ID.bru 0000664 0000000 0000000 00000000502 14546226260 0024665 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
}
miniflux-2.0.51/contrib/bruno/miniflux/Get version and build information.bru 0000664 0000000 0000000 00000000346 14546226260 0027074 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}}
}
miniflux-2.0.51/contrib/bruno/miniflux/Mark all category entries as read.bru 0000664 0000000 0000000 00000000541 14546226260 0026726 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
}
miniflux-2.0.51/contrib/bruno/miniflux/Mark all user entries as read.bru 0000664 0000000 0000000 00000000517 14546226260 0026072 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
}
miniflux-2.0.51/contrib/bruno/miniflux/Mark feed as read.bru 0000664 0000000 0000000 00000000514 14546226260 0023631 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
}
miniflux-2.0.51/contrib/bruno/miniflux/OPML Export.bru 0000664 0000000 0000000 00000000453 14546226260 0022606 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
}
miniflux-2.0.51/contrib/bruno/miniflux/OPML Import.bru 0000664 0000000 0000000 00000001240 14546226260 0022572 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
}
miniflux-2.0.51/contrib/bruno/miniflux/Refresh a single feed.bru 0000664 0000000 0000000 00000000525 14546226260 0024522 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
}
miniflux-2.0.51/contrib/bruno/miniflux/Refresh all feeds.bru 0000664 0000000 0000000 00000000443 14546226260 0023772 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"
}
}
miniflux-2.0.51/contrib/bruno/miniflux/Refresh category feeds.bru 0000664 0000000 0000000 00000000515 14546226260 0025037 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
}
miniflux-2.0.51/contrib/bruno/miniflux/Save an entry.bru 0000664 0000000 0000000 00000000521 14546226260 0023170 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
}
miniflux-2.0.51/contrib/bruno/miniflux/Update a category.bru 0000664 0000000 0000000 00000000500 14546226260 0024007 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
}
miniflux-2.0.51/contrib/bruno/miniflux/Update a feed.bru 0000664 0000000 0000000 00000000467 14546226260 0023111 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
}
miniflux-2.0.51/contrib/bruno/miniflux/Update a user.bru 0000664 0000000 0000000 00000000453 14546226260 0023157 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
}
miniflux-2.0.51/contrib/bruno/miniflux/Update entries status.bru 0000664 0000000 0000000 00000000445 14546226260 0024756 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"
}
}
miniflux-2.0.51/contrib/bruno/miniflux/Update entry.bru 0000664 0000000 0000000 00000000517 14546226260 0023142 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
}
miniflux-2.0.51/contrib/bruno/miniflux/bruno.json 0000664 0000000 0000000 00000000102 14546226260 0022072 0 ustar 00root root 0000000 0000000 {
"version": "1",
"name": "Miniflux",
"type": "collection"
} miniflux-2.0.51/contrib/bruno/miniflux/environments/ 0000775 0000000 0000000 00000000000 14546226260 0022610 5 ustar 00root root 0000000 0000000 miniflux-2.0.51/contrib/bruno/miniflux/environments/Local.bru 0000664 0000000 0000000 00000000157 14546226260 0024357 0 ustar 00root root 0000000 0000000 vars {
minifluxBaseURL: http://127.0.0.1:8080
minifluxUsername: admin
}
vars:secret [
minifluxPassword
]
miniflux-2.0.51/contrib/docker-compose/ 0000775 0000000 0000000 00000000000 14546226260 0020013 5 ustar 00root root 0000000 0000000 miniflux-2.0.51/contrib/docker-compose/Caddyfile 0000664 0000000 0000000 00000000061 14546226260 0021617 0 ustar 00root root 0000000 0000000 miniflux.example.org
reverse_proxy miniflux:8080
miniflux-2.0.51/contrib/docker-compose/README.md 0000664 0000000 0000000 00000000446 14546226260 0021276 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
```
miniflux-2.0.51/contrib/docker-compose/basic.yml 0000664 0000000 0000000 00000001561 14546226260 0021622 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:15
container_name: postgres
environment:
- POSTGRES_USER=miniflux
- POSTGRES_PASSWORD=secret
volumes:
- miniflux-db:/var/lib/postgresql/data
healthcheck:
test: ["CMD", "pg_isready", "-U", "miniflux"]
interval: 10s
start_period: 30s
volumes:
miniflux-db:
miniflux-2.0.51/contrib/docker-compose/caddy.yml 0000664 0000000 0000000 00000001775 14546226260 0021634 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:15
container_name: postgres
environment:
- POSTGRES_USER=miniflux
- POSTGRES_PASSWORD=secret
volumes:
- miniflux-db:/var/lib/postgresql/data
healthcheck:
test: ["CMD", "pg_isready", "-U", "miniflux"]
interval: 10s
start_period: 30s
volumes:
miniflux-db:
caddy_data:
caddy_config:
miniflux-2.0.51/contrib/docker-compose/traefik.yml 0000664 0000000 0000000 00000003133 14546226260 0022163 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:15
container_name: postgres
environment:
- POSTGRES_USER=miniflux
- POSTGRES_PASSWORD=secret
volumes:
- miniflux-db:/var/lib/postgresql/data
healthcheck:
test: ["CMD", "pg_isready", "-U", "miniflux"]
interval: 10s
start_period: 30s
volumes:
miniflux-db:
miniflux-2.0.51/contrib/grafana/ 0000775 0000000 0000000 00000000000 14546226260 0016500 5 ustar 00root root 0000000 0000000 miniflux-2.0.51/contrib/grafana/README.md 0000664 0000000 0000000 00000000037 14546226260 0017757 0 ustar 00root root 0000000 0000000 Grafana Dashboard for Miniflux
miniflux-2.0.51/contrib/grafana/dashboard.json 0000664 0000000 0000000 00000105301 14546226260 0021322 0 ustar 00root root 0000000 0000000 {
"annotations": {
"list": [
{
"builtIn": 1,
"datasource": "-- Grafana --",
"enable": true,
"hide": true,
"iconColor": "rgba(0, 211, 255, 1)",
"name": "Annotations & Alerts",
"type": "dashboard"
}
]
},
"editable": true,
"gnetId": null,
"graphTooltip": 0,
"id": 10,
"links": [],
"panels": [
{
"collapsed": false,
"datasource": "Prometheus",
"gridPos": {
"h": 1,
"w": 24,
"x": 0,
"y": 0
},
"id": 24,
"panels": [],
"title": "Application",
"type": "row"
},
{
"datasource": "Prometheus",
"fieldConfig": {
"defaults": {
"custom": {},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
}
]
}
},
"overrides": []
},
"gridPos": {
"h": 7,
"w": 8,
"x": 0,
"y": 1
},
"id": 18,
"options": {
"displayMode": "basic",
"orientation": "horizontal",
"reduceOptions": {
"calcs": [
"last"
],
"fields": "",
"values": false
},
"showUnfilled": true
},
"pluginVersion": "7.1.5",
"targets": [
{
"expr": "max(miniflux_feeds{status=\"total\"})",
"hide": false,
"interval": "",
"legendFormat": "Total",
"refId": "D"
},
{
"expr": "max(miniflux_feeds{status=\"enabled\"})",
"hide": false,
"interval": "",
"legendFormat": "Enabled",
"refId": "C"
},
{
"expr": "max(miniflux_broken_feeds)",
"interval": "",
"legendFormat": "Broken",
"refId": "A"
},
{
"expr": "max(miniflux_feeds{status=\"disabled\"})",
"interval": "",
"legendFormat": "Disabled",
"refId": "B"
}
],
"timeFrom": null,
"timeShift": null,
"title": "Feeds",
"type": "bargauge"
},
{
"datasource": "Prometheus",
"fieldConfig": {
"defaults": {
"custom": {},
"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
},
"textMode": "auto"
},
"pluginVersion": "7.1.5",
"targets": [
{
"expr": "max(miniflux_users)",
"interval": "",
"legendFormat": "Users",
"refId": "A"
}
],
"timeFrom": null,
"timeShift": null,
"title": "Users",
"type": "stat"
},
{
"aliasColors": {},
"bars": false,
"dashLength": 10,
"dashes": false,
"datasource": "Prometheus",
"fieldConfig": {
"defaults": {
"custom": {},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "red",
"value": 80
}
]
}
},
"overrides": []
},
"fill": 5,
"fillGradient": 5,
"gridPos": {
"h": 7,
"w": 12,
"x": 12,
"y": 1
},
"hiddenSeries": false,
"id": 4,
"legend": {
"alignAsTable": true,
"avg": false,
"current": true,
"max": false,
"min": false,
"rightSide": true,
"show": true,
"total": false,
"values": true
},
"lines": true,
"linewidth": 1,
"nullPointMode": "null",
"percentage": false,
"pluginVersion": "7.1.5",
"pointradius": 2,
"points": false,
"renderer": "flot",
"seriesOverrides": [],
"spaceLength": 10,
"stack": false,
"steppedLine": false,
"targets": [
{
"expr": "max(miniflux_entries{status=\"total\"})",
"hide": false,
"interval": "",
"legendFormat": "Total",
"refId": "A"
},
{
"expr": "max(miniflux_entries{status=\"unread\"})",
"hide": false,
"interval": "",
"legendFormat": "Unread",
"refId": "B"
},
{
"expr": "max(miniflux_entries{status=\"read\"})",
"interval": "",
"legendFormat": "Read",
"refId": "C"
},
{
"expr": "max(miniflux_entries{status=\"removed\"})",
"interval": "",
"legendFormat": "Removed",
"refId": "D"
}
],
"thresholds": [],
"timeFrom": null,
"timeRegions": [],
"timeShift": null,
"title": "Entries by Status",
"tooltip": {
"shared": true,
"sort": 0,
"value_type": "individual"
},
"type": "graph",
"xaxis": {
"buckets": null,
"mode": "time",
"name": null,
"show": true,
"values": []
},
"yaxes": [
{
"format": "short",
"label": null,
"logBase": 1,
"max": null,
"min": null,
"show": true
},
{
"format": "short",
"label": null,
"logBase": 1,
"max": null,
"min": null,
"show": false
}
],
"yaxis": {
"align": false,
"alignLevel": null
}
},
{
"datasource": "Prometheus",
"description": "",
"fieldConfig": {
"defaults": {
"custom": {},
"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
},
"textMode": "value"
},
"pluginVersion": "7.1.5",
"targets": [
{
"expr": "go_memstats_sys_bytes{job=\"miniflux\"}",
"interval": "",
"legendFormat": "{{ instance }} - Memory Used",
"refId": "A"
}
],
"timeFrom": null,
"timeShift": null,
"title": "",
"type": "stat"
},
{
"aliasColors": {},
"bars": false,
"dashLength": 10,
"dashes": false,
"datasource": "Prometheus",
"fieldConfig": {
"defaults": {
"custom": {}
},
"overrides": []
},
"fill": 1,
"fillGradient": 0,
"gridPos": {
"h": 8,
"w": 12,
"x": 0,
"y": 8
},
"hiddenSeries": false,
"id": 22,
"legend": {
"avg": false,
"current": false,
"max": false,
"min": false,
"show": true,
"total": false,
"values": false
},
"lines": true,
"linewidth": 1,
"nullPointMode": "null",
"percentage": false,
"pluginVersion": "7.1.5",
"pointradius": 2,
"points": false,
"renderer": "flot",
"seriesOverrides": [],
"spaceLength": 10,
"stack": false,
"steppedLine": false,
"targets": [
{
"expr": "histogram_quantile(0.95, sum(rate(miniflux_scraper_request_duration_bucket[5m])) by (le))",
"interval": "",
"legendFormat": "Request Duration",
"refId": "A"
}
],
"thresholds": [],
"timeFrom": null,
"timeRegions": [],
"timeShift": null,
"title": "Scraper Request Duration",
"tooltip": {
"shared": true,
"sort": 0,
"value_type": "individual"
},
"type": "graph",
"xaxis": {
"buckets": null,
"mode": "time",
"name": null,
"show": true,
"values": []
},
"yaxes": [
{
"format": "s",
"label": null,
"logBase": 1,
"max": null,
"min": null,
"show": true
},
{
"format": "short",
"label": null,
"logBase": 1,
"max": null,
"min": null,
"show": true
}
],
"yaxis": {
"align": false,
"alignLevel": null
}
},
{
"aliasColors": {},
"bars": false,
"dashLength": 10,
"dashes": false,
"datasource": "Prometheus",
"fieldConfig": {
"defaults": {
"custom": {}
},
"overrides": []
},
"fill": 1,
"fillGradient": 0,
"gridPos": {
"h": 8,
"w": 12,
"x": 12,
"y": 8
},
"hiddenSeries": false,
"id": 20,
"legend": {
"avg": false,
"current": false,
"max": false,
"min": false,
"show": true,
"total": false,
"values": false
},
"lines": true,
"linewidth": 1,
"nullPointMode": "null",
"percentage": false,
"pluginVersion": "7.1.5",
"pointradius": 2,
"points": false,
"renderer": "flot",
"seriesOverrides": [],
"spaceLength": 10,
"stack": false,
"steppedLine": false,
"targets": [
{
"expr": "histogram_quantile(0.95, sum(rate(miniflux_background_feed_refresh_duration_bucket[5m])) by (le))",
"interval": "",
"legendFormat": "Refresh Duration",
"refId": "A"
}
],
"thresholds": [],
"timeFrom": null,
"timeRegions": [],
"timeShift": null,
"title": "Background Feed Refresh Duration",
"tooltip": {
"shared": true,
"sort": 0,
"value_type": "individual"
},
"type": "graph",
"xaxis": {
"buckets": null,
"mode": "time",
"name": null,
"show": true,
"values": []
},
"yaxes": [
{
"format": "s",
"label": null,
"logBase": 1,
"max": null,
"min": null,
"show": true
},
{
"format": "short",
"label": null,
"logBase": 1,
"max": null,
"min": null,
"show": true
}
],
"yaxis": {
"align": false,
"alignLevel": null
}
},
{
"collapsed": false,
"datasource": "Prometheus",
"gridPos": {
"h": 1,
"w": 24,
"x": 0,
"y": 16
},
"id": 28,
"panels": [],
"title": "Process",
"type": "row"
},
{
"aliasColors": {},
"bars": false,
"dashLength": 10,
"dashes": false,
"datasource": "Prometheus",
"fieldConfig": {
"defaults": {
"custom": {},
"links": []
},
"overrides": []
},
"fill": 1,
"fillGradient": 0,
"gridPos": {
"h": 8,
"w": 12,
"x": 0,
"y": 17
},
"hiddenSeries": false,
"id": 16,
"legend": {
"alignAsTable": true,
"avg": false,
"current": true,
"max": false,
"min": false,
"rightSide": false,
"show": true,
"total": false,
"values": true
},
"lines": true,
"linewidth": 1,
"links": [],
"nullPointMode": "null",
"percentage": false,
"pluginVersion": "7.1.5",
"pointradius": 2,
"points": false,
"renderer": "flot",
"seriesOverrides": [],
"spaceLength": 10,
"stack": false,
"steppedLine": false,
"targets": [
{
"expr": "go_memstats_sys_bytes{job=\"miniflux\"}",
"format": "time_series",
"interval": "",
"intervalFactor": 1,
"legendFormat": "{{ instance }}",
"refId": "A"
}
],
"thresholds": [],
"timeFrom": null,
"timeRegions": [],
"timeShift": null,
"title": "Total Used Memory",
"tooltip": {
"shared": true,
"sort": 0,
"value_type": "individual"
},
"type": "graph",
"xaxis": {
"buckets": null,
"mode": "time",
"name": null,
"show": true,
"values": []
},
"yaxes": [
{
"format": "decbytes",
"label": null,
"logBase": 1,
"max": null,
"min": null,
"show": true
},
{
"format": "short",
"label": null,
"logBase": 1,
"max": null,
"min": null,
"show": true
}
],
"yaxis": {
"align": false,
"alignLevel": null
}
},
{
"aliasColors": {},
"bars": false,
"dashLength": 10,
"dashes": false,
"datasource": "Prometheus",
"fieldConfig": {
"defaults": {
"custom": {},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "red",
"value": 80
}
]
}
},
"overrides": []
},
"fill": 1,
"fillGradient": 0,
"gridPos": {
"h": 8,
"w": 12,
"x": 12,
"y": 17
},
"hiddenSeries": false,
"id": 6,
"legend": {
"alignAsTable": true,
"avg": true,
"current": true,
"max": true,
"min": true,
"show": true,
"total": false,
"values": true
},
"lines": true,
"linewidth": 1,
"nullPointMode": "null",
"percentage": false,
"pluginVersion": "7.1.5",
"pointradius": 2,
"points": false,
"renderer": "flot",
"seriesOverrides": [],
"spaceLength": 10,
"stack": false,
"steppedLine": false,
"targets": [
{
"expr": "process_open_fds{job=\"miniflux\"}",
"interval": "",
"legendFormat": "{{instance }} - Open File Descriptors",
"refId": "A"
}
],
"thresholds": [],
"timeFrom": null,
"timeRegions": [],
"timeShift": null,
"title": "File Descriptors",
"tooltip": {
"shared": true,
"sort": 0,
"value_type": "individual"
},
"type": "graph",
"xaxis": {
"buckets": null,
"mode": "time",
"name": null,
"show": true,
"values": []
},
"yaxes": [
{
"format": "short",
"label": null,
"logBase": 1,
"max": null,
"min": null,
"show": true
},
{
"format": "short",
"label": null,
"logBase": 1,
"max": null,
"min": null,
"show": true
}
],
"yaxis": {
"align": false,
"alignLevel": null
}
},
{
"collapsed": false,
"datasource": "Prometheus",
"gridPos": {
"h": 1,
"w": 24,
"x": 0,
"y": 25
},
"id": 26,
"panels": [],
"title": "Go Metrics",
"type": "row"
},
{
"aliasColors": {},
"bars": false,
"dashLength": 10,
"dashes": false,
"datasource": "Prometheus",
"editable": true,
"error": false,
"fieldConfig": {
"defaults": {
"custom": {}
},
"overrides": []
},
"fill": 1,
"fillGradient": 0,
"grid": {},
"gridPos": {
"h": 7,
"w": 12,
"x": 0,
"y": 26
},
"hiddenSeries": false,
"id": 12,
"isNew": true,
"legend": {
"alignAsTable": true,
"avg": true,
"current": true,
"max": true,
"min": false,
"show": true,
"total": false,
"values": true
},
"lines": true,
"linewidth": 2,
"links": [],
"nullPointMode": "connected",
"percentage": false,
"pluginVersion": "7.1.5",
"pointradius": 5,
"points": false,
"renderer": "flot",
"seriesOverrides": [
{
"alias": "alloc rate",
"yaxis": 2
}
],
"spaceLength": 10,
"stack": false,
"steppedLine": false,
"targets": [
{
"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
},
{
"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
},
{
"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
},
{
"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
}
],
"thresholds": [],
"timeFrom": null,
"timeRegions": [],
"timeShift": null,
"title": "Golang Memory",
"tooltip": {
"msResolution": false,
"shared": true,
"sort": 0,
"value_type": "cumulative"
},
"type": "graph",
"xaxis": {
"buckets": null,
"mode": "time",
"name": null,
"show": true,
"values": []
},
"yaxes": [
{
"format": "bytes",
"label": null,
"logBase": 1,
"max": null,
"min": null,
"show": true
},
{
"format": "Bps",
"label": null,
"logBase": 1,
"max": null,
"min": null,
"show": true
}
],
"yaxis": {
"align": false,
"alignLevel": null
}
},
{
"aliasColors": {},
"bars": false,
"dashLength": 10,
"dashes": false,
"datasource": "Prometheus",
"fieldConfig": {
"defaults": {
"custom": {}
},
"overrides": []
},
"fill": 1,
"fillGradient": 0,
"gridPos": {
"h": 10,
"w": 12,
"x": 12,
"y": 26
},
"hiddenSeries": false,
"id": 8,
"legend": {
"alignAsTable": true,
"avg": true,
"current": true,
"max": true,
"min": true,
"show": true,
"total": false,
"values": true
},
"lines": true,
"linewidth": 1,
"nullPointMode": "null",
"percentage": false,
"pluginVersion": "7.1.5",
"pointradius": 2,
"points": false,
"renderer": "flot",
"seriesOverrides": [],
"spaceLength": 10,
"stack": false,
"steppedLine": false,
"targets": [
{
"expr": "go_goroutines{job=\"miniflux\"}",
"interval": "",
"legendFormat": "{{ instance }} - Goroutines",
"refId": "A"
},
{
"expr": "go_threads{job=\"miniflux\"}",
"interval": "",
"legendFormat": "{{ instance }} - OS threads",
"refId": "B"
}
],
"thresholds": [],
"timeFrom": null,
"timeRegions": [],
"timeShift": null,
"title": "Concurrency",
"tooltip": {
"shared": true,
"sort": 0,
"value_type": "individual"
},
"type": "graph",
"xaxis": {
"buckets": null,
"mode": "time",
"name": null,
"show": true,
"values": []
},
"yaxes": [
{
"format": "short",
"label": null,
"logBase": 1,
"max": null,
"min": null,
"show": true
},
{
"format": "short",
"label": null,
"logBase": 1,
"max": null,
"min": null,
"show": true
}
],
"yaxis": {
"align": false,
"alignLevel": null
}
},
{
"aliasColors": {},
"bars": false,
"dashLength": 10,
"dashes": false,
"datasource": "Prometheus",
"fieldConfig": {
"defaults": {
"custom": {},
"links": []
},
"overrides": []
},
"fill": 1,
"fillGradient": 0,
"gridPos": {
"h": 11,
"w": 12,
"x": 0,
"y": 33
},
"hiddenSeries": false,
"id": 34,
"legend": {
"alignAsTable": true,
"avg": false,
"current": true,
"max": false,
"min": false,
"show": true,
"total": false,
"values": true
},
"lines": true,
"linewidth": 1,
"links": [],
"nullPointMode": "null",
"percentage": false,
"pluginVersion": "7.1.5",
"pointradius": 2,
"points": false,
"renderer": "flot",
"seriesOverrides": [],
"spaceLength": 10,
"stack": false,
"steppedLine": false,
"targets": [
{
"expr": "go_memstats_stack_inuse_bytes{job=\"miniflux\"}",
"format": "time_series",
"interval": "",
"intervalFactor": 1,
"legendFormat": "{{ instance }} - stack_inuse",
"refId": "A"
},
{
"expr": "go_memstats_stack_sys_bytes{job=\"miniflux\"}",
"format": "time_series",
"interval": "",
"intervalFactor": 1,
"legendFormat": "{{ instance }} - stack_sys",
"refId": "B"
}
],
"thresholds": [],
"timeFrom": null,
"timeRegions": [],
"timeShift": null,
"title": "Memory in Stack",
"tooltip": {
"shared": true,
"sort": 0,
"value_type": "individual"
},
"type": "graph",
"xaxis": {
"buckets": null,
"mode": "time",
"name": null,
"show": true,
"values": []
},
"yaxes": [
{
"format": "decbytes",
"label": null,
"logBase": 1,
"max": null,
"min": null,
"show": true
},
{
"format": "short",
"label": null,
"logBase": 1,
"max": null,
"min": null,
"show": true
}
],
"yaxis": {
"align": false,
"alignLevel": null
}
},
{
"aliasColors": {},
"bars": false,
"dashLength": 10,
"dashes": false,
"datasource": "Prometheus",
"fieldConfig": {
"defaults": {
"custom": {},
"links": []
},
"overrides": []
},
"fill": 1,
"fillGradient": 0,
"gridPos": {
"h": 8,
"w": 12,
"x": 12,
"y": 36
},
"hiddenSeries": false,
"id": 32,
"legend": {
"avg": false,
"current": false,
"max": false,
"min": false,
"show": true,
"total": false,
"values": false
},
"lines": true,
"linewidth": 1,
"links": [],
"nullPointMode": "null",
"percentage": false,
"pluginVersion": "7.1.5",
"pointradius": 2,
"points": false,
"renderer": "flot",
"seriesOverrides": [],
"spaceLength": 10,
"stack": false,
"steppedLine": false,
"targets": [
{
"expr": "go_memstats_heap_alloc_bytes{job=\"miniflux\"}",
"format": "time_series",
"interval": "",
"intervalFactor": 1,
"legendFormat": "{{ instance }} - heap_alloc",
"refId": "B"
},
{
"expr": "go_memstats_heap_sys_bytes{job=\"miniflux\"}",
"format": "time_series",
"interval": "",
"intervalFactor": 1,
"legendFormat": "{{ instance }} - heap_sys",
"refId": "A"
},
{
"expr": "go_memstats_heap_idle_bytes{job=\"miniflux\"}",
"format": "time_series",
"interval": "",
"intervalFactor": 1,
"legendFormat": "{{ instance }} - heap_idle",
"refId": "C"
},
{
"expr": "go_memstats_heap_inuse_bytes{job=\"miniflux\"}",
"format": "time_series",
"interval": "",
"intervalFactor": 1,
"legendFormat": "{{ instance }} - heap_inuse",
"refId": "D"
},
{
"expr": "go_memstats_heap_released_bytes{job=\"miniflux\"}",
"format": "time_series",
"interval": "",
"intervalFactor": 1,
"legendFormat": "{{ instance }} - heap_released",
"refId": "E"
}
],
"thresholds": [],
"timeFrom": null,
"timeRegions": [],
"timeShift": null,
"title": "Memory in Heap",
"tooltip": {
"shared": true,
"sort": 0,
"value_type": "individual"
},
"type": "graph",
"xaxis": {
"buckets": null,
"mode": "time",
"name": null,
"show": true,
"values": []
},
"yaxes": [
{
"format": "decbytes",
"label": null,
"logBase": 1,
"max": null,
"min": null,
"show": true
},
{
"format": "short",
"label": null,
"logBase": 1,
"max": null,
"min": null,
"show": true
}
],
"yaxis": {
"align": false,
"alignLevel": null
}
},
{
"aliasColors": {},
"bars": false,
"dashLength": 10,
"dashes": false,
"datasource": "Prometheus",
"editable": true,
"error": false,
"fieldConfig": {
"defaults": {
"custom": {}
},
"overrides": []
},
"fill": 1,
"fillGradient": 0,
"grid": {},
"gridPos": {
"h": 8,
"w": 12,
"x": 0,
"y": 44
},
"hiddenSeries": false,
"id": 14,
"isNew": true,
"legend": {
"alignAsTable": true,
"avg": true,
"current": true,
"max": true,
"min": false,
"show": true,
"total": false,
"values": true
},
"lines": true,
"linewidth": 2,
"links": [],
"nullPointMode": "connected",
"percentage": false,
"pluginVersion": "7.1.5",
"pointradius": 5,
"points": false,
"renderer": "flot",
"seriesOverrides": [],
"spaceLength": 10,
"stack": false,
"steppedLine": false,
"targets": [
{
"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
}
],
"thresholds": [],
"timeFrom": null,
"timeRegions": [],
"timeShift": null,
"title": "GC Duration Quantiles",
"tooltip": {
"msResolution": false,
"shared": true,
"sort": 0,
"value_type": "cumulative"
},
"type": "graph",
"xaxis": {
"buckets": null,
"mode": "time",
"name": null,
"show": true,
"values": []
},
"yaxes": [
{
"format": "s",
"label": null,
"logBase": 1,
"max": null,
"min": null,
"show": true
},
{
"format": "short",
"label": null,
"logBase": 1,
"max": null,
"min": null,
"show": true
}
],
"yaxis": {
"align": false,
"alignLevel": null
}
},
{
"aliasColors": {},
"bars": false,
"dashLength": 10,
"dashes": false,
"datasource": "Prometheus",
"fieldConfig": {
"defaults": {
"custom": {},
"links": []
},
"overrides": []
},
"fill": 1,
"fillGradient": 0,
"gridPos": {
"h": 7,
"w": 12,
"x": 12,
"y": 44
},
"hiddenSeries": false,
"id": 30,
"legend": {
"alignAsTable": false,
"avg": false,
"current": false,
"max": false,
"min": false,
"rightSide": false,
"show": true,
"total": false,
"values": false
},
"lines": true,
"linewidth": 1,
"links": [],
"nullPointMode": "null",
"percentage": false,
"pluginVersion": "7.1.5",
"pointradius": 2,
"points": false,
"renderer": "flot",
"seriesOverrides": [],
"spaceLength": 10,
"stack": false,
"steppedLine": false,
"targets": [
{
"expr": "go_memstats_mallocs_total{job=\"miniflux\"} - go_memstats_frees_total{job=\"miniflux\"}",
"format": "time_series",
"interval": "",
"intervalFactor": 1,
"legendFormat": "{{ instance }}",
"refId": "A"
}
],
"thresholds": [],
"timeFrom": null,
"timeRegions": [],
"timeShift": null,
"title": "Number of Live Objects",
"tooltip": {
"shared": true,
"sort": 0,
"value_type": "individual"
},
"type": "graph",
"xaxis": {
"buckets": null,
"mode": "time",
"name": null,
"show": true,
"values": []
},
"yaxes": [
{
"format": "short",
"label": null,
"logBase": 1,
"max": null,
"min": null,
"show": true
},
{
"format": "short",
"label": null,
"logBase": 1,
"max": null,
"min": null,
"show": false
}
],
"yaxis": {
"align": false,
"alignLevel": null
}
}
],
"refresh": "30s",
"schemaVersion": 26,
"style": "dark",
"tags": [],
"templating": {
"list": []
},
"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": 23
}
miniflux-2.0.51/contrib/sysvinit/ 0000775 0000000 0000000 00000000000 14546226260 0016771 5 ustar 00root root 0000000 0000000 miniflux-2.0.51/contrib/sysvinit/README.md 0000664 0000000 0000000 00000000202 14546226260 0020242 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`
miniflux-2.0.51/contrib/sysvinit/etc/ 0000775 0000000 0000000 00000000000 14546226260 0017544 5 ustar 00root root 0000000 0000000 miniflux-2.0.51/contrib/sysvinit/etc/default/ 0000775 0000000 0000000 00000000000 14546226260 0021170 5 ustar 00root root 0000000 0000000 miniflux-2.0.51/contrib/sysvinit/etc/default/miniflux 0000664 0000000 0000000 00000000516 14546226260 0022750 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/'
miniflux-2.0.51/contrib/sysvinit/etc/init.d/ 0000775 0000000 0000000 00000000000 14546226260 0020731 5 ustar 00root root 0000000 0000000 miniflux-2.0.51/contrib/sysvinit/etc/init.d/miniflux 0000775 0000000 0000000 00000006230 14546226260 0022513 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
miniflux-2.0.51/contrib/thunder_client/ 0000775 0000000 0000000 00000000000 14546226260 0020110 5 ustar 00root root 0000000 0000000 miniflux-2.0.51/contrib/thunder_client/README.md 0000664 0000000 0000000 00000000451 14546226260 0021367 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.
miniflux-2.0.51/contrib/thunder_client/collection.json 0000664 0000000 0000000 00000062715 14546226260 0023151 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"
}
}
} miniflux-2.0.51/go.mod 0000664 0000000 0000000 00000003103 14546226260 0014544 0 ustar 00root root 0000000 0000000 module miniflux.app/v2
// +heroku goVersion go1.21
require (
github.com/PuerkitoBio/goquery v1.8.1
github.com/abadojack/whatlanggo v1.0.1
github.com/coreos/go-oidc/v3 v3.9.0
github.com/go-webauthn/webauthn v0.9.4
github.com/google/uuid v1.5.0
github.com/gorilla/mux v1.8.1
github.com/lib/pq v1.10.9
github.com/prometheus/client_golang v1.17.0
github.com/tdewolff/minify/v2 v2.20.9
github.com/yuin/goldmark v1.6.0
golang.org/x/crypto v0.16.0
golang.org/x/net v0.19.0
golang.org/x/oauth2 v0.15.0
golang.org/x/term v0.15.0
mvdan.cc/xurls/v2 v2.5.0
)
require (
github.com/go-webauthn/x v0.1.5 // indirect
github.com/golang-jwt/jwt/v5 v5.2.0 // indirect
github.com/google/go-tpm v0.9.0 // indirect
)
require (
github.com/andybalholm/cascadia v1.3.2 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.2.0 // indirect
github.com/fxamacker/cbor/v2 v2.5.0 // indirect
github.com/go-jose/go-jose/v3 v3.0.1 // indirect
github.com/golang/protobuf v1.5.3 // indirect
github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/prometheus/client_model v0.4.1-0.20230718164431-9a2bf3000d16 // indirect
github.com/prometheus/common v0.44.0 // indirect
github.com/prometheus/procfs v0.11.1 // indirect
github.com/tdewolff/parse/v2 v2.7.6 // indirect
github.com/x448/float16 v0.8.4 // indirect
golang.org/x/sys v0.15.0 // indirect
golang.org/x/text v0.14.0 // indirect
google.golang.org/appengine v1.6.8 // indirect
google.golang.org/protobuf v1.31.0 // indirect
)
go 1.21
miniflux-2.0.51/go.sum 0000664 0000000 0000000 00000030422 14546226260 0014575 0 ustar 00root root 0000000 0000000 github.com/PuerkitoBio/goquery v1.8.1 h1:uQxhNlArOIdbrH1tr0UXwdVFgDcZDrZVdcpygAcwmWM=
github.com/PuerkitoBio/goquery v1.8.1/go.mod h1:Q8ICL1kNUJ2sXGoAhPGUdYDJvgQgHzJsnnd3H7Ho5jQ=
github.com/abadojack/whatlanggo v1.0.1 h1:19N6YogDnf71CTHm3Mp2qhYfkRdyvbgwWdd2EPxJRG4=
github.com/abadojack/whatlanggo v1.0.1/go.mod h1:66WiQbSbJBIlOZMsvbKe5m6pzQovxCH9B/K8tQB2uoc=
github.com/andybalholm/cascadia v1.3.1/go.mod h1:R4bJ1UQfqADjvDa4P6HZHLh/3OxWWEqc0Sk8XGwHqvA=
github.com/andybalholm/cascadia v1.3.2 h1:3Xi6Dw5lHF15JtdcmAHD3i1+T8plmv7BQ/nsViSLyss=
github.com/andybalholm/cascadia v1.3.2/go.mod h1:7gtRlve5FxPPgIgX36uWBX58OdBsSS6lUvCFb+h7KvU=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/coreos/go-oidc/v3 v3.9.0 h1:0J/ogVOd4y8P0f0xUh8l9t07xRP/d8tccvjHl2dcsSo=
github.com/coreos/go-oidc/v3 v3.9.0/go.mod h1:rTKz2PYwftcrtoCzV5g5kvfJoWcm0Mk8AF8y1iAQro4=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/fxamacker/cbor/v2 v2.5.0 h1:oHsG0V/Q6E/wqTS2O1Cozzsy69nqCiguo5Q1a1ADivE=
github.com/fxamacker/cbor/v2 v2.5.0/go.mod h1:TA1xS00nchWmaBnEIxPSE5oHLuJBAVvqrtAnWBwBCVo=
github.com/go-jose/go-jose/v3 v3.0.1 h1:pWmKFVtt+Jl0vBZTIpz/eAKwsm6LkIxDVVbFHKkchhA=
github.com/go-jose/go-jose/v3 v3.0.1/go.mod h1:RNkWWRld676jZEYoV3+XK8L2ZnNSvIsxFMht0mSX+u8=
github.com/go-webauthn/webauthn v0.9.4 h1:YxvHSqgUyc5AK2pZbqkWWR55qKeDPhP8zLDr6lpIc2g=
github.com/go-webauthn/webauthn v0.9.4/go.mod h1:LqupCtzSef38FcxzaklmOn7AykGKhAhr9xlRbdbgnTw=
github.com/go-webauthn/x v0.1.5 h1:V2TCzDU2TGLd0kSZOXdrqDVV5JB9ILnKxA9S53CSBw0=
github.com/go-webauthn/x v0.1.5/go.mod h1:qbzWwcFcv4rTwtCLOZd+icnr6B7oSsAGZJqlt8cukqY=
github.com/golang-jwt/jwt/v5 v5.2.0 h1:d/ix8ftRUorsN+5eMIlF4T6J8CAt9rch3My2winC1Jw=
github.com/golang-jwt/jwt/v5 v5.2.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg=
github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-tpm v0.9.0 h1:sQF6YqWMi+SCXpsmS3fd21oPy/vSddwZry4JnmltHVk=
github.com/google/go-tpm v0.9.0/go.mod h1:FkNVkc6C+IsvDI9Jw1OveJmxGZUUaKxtrpOS47QWKfU=
github.com/google/uuid v1.5.0 h1:1p67kYwdtXjb0gL0BPiP1Av9wiZPo5A8z2cWkTZ+eyU=
github.com/google/uuid v1.5.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/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/matttproud/golang_protobuf_extensions v1.0.4 h1:mmDVorXM7PCGKw94cs5zkfA9PSy5pEvNWRP0ET0TIVo=
github.com/matttproud/golang_protobuf_extensions v1.0.4/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/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.17.0 h1:rl2sfwZMtSthVU752MqfjQozy7blglC+1SOtjMAMh+Q=
github.com/prometheus/client_golang v1.17.0/go.mod h1:VeL+gMmOAxkS2IqfCq0ZmHSL+LjWfWDUmp1mBz9JgUY=
github.com/prometheus/client_model v0.4.1-0.20230718164431-9a2bf3000d16 h1:v7DLqVdK4VrYkVD5diGdl4sxJurKJEMnODWRJlxV9oM=
github.com/prometheus/client_model v0.4.1-0.20230718164431-9a2bf3000d16/go.mod h1:oMQmHW1/JoDwqLtg57MGgP/Fb1CJEYF2imWWhWtMkYU=
github.com/prometheus/common v0.44.0 h1:+5BrQJwiBB9xsMygAB3TNvpQKOwlkc25LbISbrdOOfY=
github.com/prometheus/common v0.44.0/go.mod h1:ofAIvZbQ1e/nugmZGz4/qCb9Ap1VoSTIO7x0VV9VvuY=
github.com/prometheus/procfs v0.11.1 h1:xRC8Iq1yyca5ypa9n1EZnWZkt7dwcoRPQwX/5gwaUuI=
github.com/prometheus/procfs v0.11.1/go.mod h1:eesXgaPo1q7lBpVMoMy0ZOFTth9hBn4W/y0/p/ScXhY=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/tdewolff/minify/v2 v2.20.9 h1:0RGsL+jBpm77obkuNCjNZ2eiN81CZzTnjeVmTqxCmYk=
github.com/tdewolff/minify/v2 v2.20.9/go.mod h1:hZnNtFqXVQ5QIAR05tdgvS7h6E80jyRwHSGVmM4jbzQ=
github.com/tdewolff/parse/v2 v2.7.6 h1:PGZH2b/itDSye9RatReRn4GBhsT+KFEMtAMjHRuY1h8=
github.com/tdewolff/parse/v2 v2.7.6/go.mod h1:3FbJWZp3XT9OWVN3Hmfp0p/a08v4h8J9W1aghka0soA=
github.com/tdewolff/test v1.0.11-0.20231101010635-f1265d231d52 h1:gAQliwn+zJrkjAHVcBEYW/RFvd2St4yYimisvozAYlA=
github.com/tdewolff/test v1.0.11-0.20231101010635-f1265d231d52/go.mod h1:6DAvZliBAAnD7rhVgwaM7DE5/d9NMOAJ09SqYqeK4QE=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yuin/goldmark v1.6.0 h1:boZcn2GTjpsynOsC0iJHnBWa4Bi0qzfJjthwauItG68=
github.com/yuin/goldmark v1.6.0/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190911031432-227b76d455e7/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.16.0 h1:mMMrFzRSCF0GvB7Ne27XVtVAaXLrPmgPC7/v0tkwHaY=
golang.org/x/crypto v0.16.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
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-20210916014120-12bc252f5db8/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
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.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns=
golang.org/x/net v0.19.0 h1:zTwKpTd2XuCqf8huc7Fo2iSy+4RHPd10s4KzeTnVr1c=
golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U=
golang.org/x/oauth2 v0.15.0 h1:s8pnnxNVzjWyrvYdFUQq5llS1PX2zhPXmccZv99h7uQ=
golang.org/x/oauth2 v0.15.0/go.mod h1:q48ptWNTY5XWf+JNten23lcvHpLJ0ZSxF5ttTHKVCAM=
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/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.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc=
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY=
golang.org/x/term v0.15.0 h1:y/Oo/a/q3IXu26lQgl04j/gjuBDOBlx7X6Om1j2CPW4=
golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0=
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.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM=
google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8=
google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
mvdan.cc/xurls/v2 v2.5.0 h1:lyBNOm8Wo71UknhUs4QTFUNNMyxy2JEIaKKo0RWOh+8=
mvdan.cc/xurls/v2 v2.5.0/go.mod h1:yQgaGQ1rFtJUzkmKiHYSSfuQxqfYmd//X6PxvholpeE=
miniflux-2.0.51/internal/ 0000775 0000000 0000000 00000000000 14546226260 0015255 5 ustar 00root root 0000000 0000000 miniflux-2.0.51/internal/api/ 0000775 0000000 0000000 00000000000 14546226260 0016026 5 ustar 00root root 0000000 0000000 miniflux-2.0.51/internal/api/api.go 0000664 0000000 0000000 00000011226 14546226260 0017130 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/{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.toggleBookmark).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("/version", handler.versionHandler).Methods(http.MethodGet)
}
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,
})
}
miniflux-2.0.51/internal/api/category.go 0000664 0000000 0000000 00000007640 14546226260 0020201 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 categoryRequest model.CategoryRequest
if err := json_parser.NewDecoder(r.Body).Decode(&categoryRequest); err != nil {
json.BadRequest(w, r, err)
return
}
if validationErr := validator.ValidateCategoryCreation(h.store, userID, &categoryRequest); validationErr != nil {
json.BadRequest(w, r, validationErr.Error())
return
}
category, err := h.store.CreateCategory(userID, &categoryRequest)
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 categoryRequest model.CategoryRequest
if err := json_parser.NewDecoder(r.Body).Decode(&categoryRequest); err != nil {
json.BadRequest(w, r, err)
return
}
if validationErr := validator.ValidateCategoryModification(h.store, userID, category.ID, &categoryRequest); validationErr != nil {
json.BadRequest(w, r, validationErr.Error())
return
}
categoryRequest.Patch(category)
err = h.store.UpdateCategory(category)
if 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()
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)
}
miniflux-2.0.51/internal/api/entry.go 0000664 0000000 0000000 00000025046 14546226260 0017525 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"
"strings"
"time"
"miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/http/request"
"miniflux.app/v2/internal/http/response/json"
"miniflux.app/v2/internal/integration"
"miniflux.app/v2/internal/model"
"miniflux.app/v2/internal/proxy"
"miniflux.app/v2/internal/reader/processor"
"miniflux.app/v2/internal/reader/readingtime"
"miniflux.app/v2/internal/storage"
"miniflux.app/v2/internal/urllib"
"miniflux.app/v2/internal/validator"
)
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 = proxy.AbsoluteProxyRewriter(h.router, r.Host, entry.Content)
proxyOption := config.Opts.ProxyOption()
for i := range entry.Enclosures {
if proxyOption == "all" || proxyOption != "none" && !urllib.IsHTTPS(entry.Enclosures[i].URL) {
for _, mediaType := range config.Opts.ProxyMediaTypes() {
if strings.HasPrefix(entry.Enclosures[i].MimeType, mediaType+"/") {
entry.Enclosures[i].URL = proxy.AbsoluteProxifyURL(h.router, r.Host, entry.Enclosures[i].URL)
break
}
}
}
}
json.OK(w, r, entry)
}
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)
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)
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)
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()
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 = proxy.AbsoluteProxyRewriter(h.router, r.Host, 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) toggleBookmark(w http.ResponseWriter, r *http.Request) {
entryID := request.RouteInt64Param(r, "entryID")
if err := h.store.ToggleBookmark(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
}
entryUpdateRequest.Patch(entry)
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) 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
}
json.OK(w, r, map[string]string{"content": entry.Content})
}
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)
}
}
miniflux-2.0.51/internal/api/feed.go 0000664 0000000 0000000 00000012620 14546226260 0017261 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)
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, &feedModificationRequest); validationErr != nil {
json.BadRequest(w, r, validationErr.Error())
return
}
feedModificationRequest.Patch(originalFeed)
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)
feed, err := h.store.FeedByID(userID, feedID)
if err != nil {
json.NotFound(w, r)
return
}
if feed == nil {
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)
}
miniflux-2.0.51/internal/api/icon.go 0000664 0000000 0000000 00000002213 14546226260 0017303 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.HasIcon(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(),
})
}
miniflux-2.0.51/internal/api/middleware.go 0000664 0000000 0000000 00000011530 14546226260 0020472 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()),
)
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()),
)
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),
)
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()),
)
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()),
)
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),
)
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),
)
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),
)
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))
})
}
miniflux-2.0.51/internal/api/opml.go 0000664 0000000 0000000 00000001705 14546226260 0017327 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"})
}
miniflux-2.0.51/internal/api/payload.go 0000664 0000000 0000000 00000001445 14546226260 0020012 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"`
}
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"`
}
miniflux-2.0.51/internal/api/subscription.go 0000664 0000000 0000000 00000004076 14546226260 0021110 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/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
intg, err := h.store.Integration(request.UserID(r))
if err == nil && intg != nil && intg.RSSBridgeEnabled {
rssbridgeURL = intg.RSSBridgeURL
}
requestBuilder := fetcher.NewRequestBuilder()
requestBuilder.WithTimeout(config.Opts.HTTPClientTimeout())
requestBuilder.WithProxy(config.Opts.HTTPClientProxy())
requestBuilder.WithUserAgent(subscriptionDiscoveryRequest.UserAgent, config.Opts.HTTPClientUserAgent())
requestBuilder.WithCookie(subscriptionDiscoveryRequest.Cookie)
requestBuilder.WithUsernameAndPassword(subscriptionDiscoveryRequest.Username, subscriptionDiscoveryRequest.Password)
requestBuilder.UseProxy(subscriptionDiscoveryRequest.FetchViaProxy)
requestBuilder.IgnoreTLSErrors(subscriptionDiscoveryRequest.AllowSelfSignedCertificates)
subscriptions, localizedError := subscription.NewSubscriptionFinder(requestBuilder).FindSubscriptions(
subscriptionDiscoveryRequest.URL,
rssbridgeURL,
)
if localizedError != nil {
json.ServerError(w, r, localizedError.Error())
return
}
if len(subscriptions) == 0 {
json.NotFound(w, r)
return
}
json.OK(w, r, subscriptions)
}
miniflux-2.0.51/internal/api/user.go 0000664 0000000 0000000 00000010623 14546226260 0017335 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) 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)
}
miniflux-2.0.51/internal/cli/ 0000775 0000000 0000000 00000000000 14546226260 0016024 5 ustar 00root root 0000000 0000000 miniflux-2.0.51/internal/cli/ask_credentials.go 0000664 0000000 0000000 00000001357 14546226260 0021514 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"
"fmt"
"os"
"strings"
"golang.org/x/term"
)
func askCredentials() (string, string) {
fd := int(os.Stdin.Fd())
if !term.IsTerminal(fd) {
printErrorAndExit(fmt.Errorf("this is not a 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.Printf("\n")
return strings.TrimSpace(username), strings.TrimSpace(string(bytePassword))
}
miniflux-2.0.51/internal/cli/cleanup_tasks.go 0000664 0000000 0000000 00000003410 14546226260 0021205 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.CleanupRemoveSessionsDays())
nbUserSessions := store.CleanOldUserSessions(config.Opts.CleanupRemoveSessionsDays())
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.CleanupArchiveReadDays(), 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.CleanupArchiveUnreadDays(), 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())
}
}
}
miniflux-2.0.51/internal/cli/cli.go 0000664 0000000 0000000 00000014230 14546226260 0017122 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 (
"flag"
"fmt"
"io"
"log/slog"
"os"
"miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/database"
"miniflux.app/v2/internal/locale"
"miniflux.app/v2/internal/storage"
"miniflux.app/v2/internal/ui/static"
"miniflux.app/v2/internal/version"
)
const (
flagInfoHelp = "Show build information"
flagVersionHelp = "Show application version"
flagMigrateHelp = "Run SQL migrations"
flagFlushSessionsHelp = "Flush all sessions (disconnect users)"
flagCreateAdminHelp = "Create admin user"
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" try to guess the health check endpoint).`
flagRefreshFeedsHelp = "Refresh a batch of feeds and exit"
flagRunCleanupTasksHelp = "Run cleanup tasks (delete old sessions and archives old entries)"
flagExportUserFeedsHelp = "Export user feeds (provide the username as argument)"
)
// Parse parses command line arguments.
func Parse() {
var (
err error
flagInfo bool
flagVersion bool
flagMigrate bool
flagFlushSessions bool
flagCreateAdmin bool
flagResetPassword bool
flagResetFeedErrors 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(&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.NewParser()
if flagConfigFile != "" {
config.Opts, err = cfg.ParseFile(flagConfigFile)
if err != nil {
printErrorAndExit(err)
}
}
config.Opts, err = cfg.ParseEnvironmentVariables()
if err != nil {
printErrorAndExit(err)
}
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 := locale.LoadCatalogMessages(); err != nil {
printErrorAndExit(fmt.Errorf("unable to load translations: %v", err))
}
if err := static.CalculateBinaryFileChecksums(); err != nil {
printErrorAndExit(fmt.Errorf("unable to calculate binary file checksums: %v", err))
}
if err := static.GenerateStylesheetsBundles(); err != nil {
printErrorAndExit(fmt.Errorf("unable to generate stylesheets bundles: %v", err))
}
if err := static.GenerateJavascriptBundles(); err != nil {
printErrorAndExit(fmt.Errorf("unable to generate javascript bundles: %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 {
store.ResetFeedErrors()
return
}
if flagExportUserFeeds != "" {
exportUserFeeds(store, flagExportUserFeeds)
return
}
if flagFlushSessions {
flushSessions(store)
return
}
if flagCreateAdmin {
createAdmin(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)
}
// Create admin user and start the daemon.
if config.Opts.CreateAdmin() {
createAdmin(store)
}
if flagRefreshFeeds {
refreshFeeds(store)
return
}
if flagRunCleanupTasks {
runCleanupTasks(store)
return
}
startDaemon(store)
}
func printErrorAndExit(err error) {
fmt.Fprintf(os.Stderr, "%v\n", err)
os.Exit(1)
}
miniflux-2.0.51/internal/cli/create_admin.go 0000664 0000000 0000000 00000002211 14546226260 0020762 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 createAdmin(store *storage.Storage) {
userCreationRequest := &model.UserCreationRequest{
Username: config.Opts.AdminUsername(),
Password: config.Opts.AdminPassword(),
IsAdmin: true,
}
if userCreationRequest.Username == "" || userCreationRequest.Password == "" {
userCreationRequest.Username, userCreationRequest.Password = askCredentials()
}
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 _, err := store.CreateUser(userCreationRequest); err != nil {
printErrorAndExit(err)
}
}
miniflux-2.0.51/internal/cli/daemon.go 0000664 0000000 0000000 00000004147 14546226260 0017624 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"
httpd "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 httpServer *http.Server
if config.Opts.HasHTTPService() {
httpServer = httpd.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 httpServer != nil {
httpServer.Shutdown(ctx)
}
slog.Debug("Process gracefully stopped")
}
miniflux-2.0.51/internal/cli/export_feeds.go 0000664 0000000 0000000 00000001357 14546226260 0021050 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)
}
miniflux-2.0.51/internal/cli/flush_sessions.go 0000664 0000000 0000000 00000000634 14546226260 0021425 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)
}
}
miniflux-2.0.51/internal/cli/health_check.go 0000664 0000000 0000000 00000001627 14546226260 0020763 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() + 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`)
}
miniflux-2.0.51/internal/cli/info.go 0000664 0000000 0000000 00000001045 14546226260 0017306 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)
}
miniflux-2.0.51/internal/cli/logger.go 0000664 0000000 0000000 00000002072 14546226260 0017633 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
}
miniflux-2.0.51/internal/cli/refresh_feeds.go 0000664 0000000 0000000 00000003760 14546226260 0021165 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()
jobs, err := batchBuilder.FetchJobs()
if err != nil {
slog.Error("Unable to fetch jobs from database", slog.Any("error", err))
return
}
nbJobs := len(jobs)
slog.Info("Created a batch of feeds",
slog.Int("nb_jobs", nbJobs),
slog.Int("batch_size", config.Opts.BatchSize()),
)
var jobQueue = make(chan model.Job, nbJobs)
slog.Info("Starting a pool of workers",
slog.Int("nb_workers", config.Opts.WorkerPoolSize()),
)
for i := 0; i < config.Opts.WorkerPoolSize(); i++ {
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); err != 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()),
)
}
miniflux-2.0.51/internal/cli/reset_password.go 0000664 0000000 0000000 00000001657 14546226260 0021430 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/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(fmt.Errorf("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!")
}
miniflux-2.0.51/internal/cli/scheduler.go 0000664 0000000 0000000 00000002743 14546226260 0020337 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(),
)
go cleanupScheduler(
store,
config.Opts.CleanupFrequencyHours(),
)
}
func feedScheduler(store *storage.Storage, pool *worker.Pool, frequency, batchSize, errorLimit int) {
for range time.Tick(time.Duration(frequency) * time.Minute) {
// 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()
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.Info("Created a batch of feeds",
slog.Int("nb_jobs", len(jobs)),
)
pool.Push(jobs)
}
}
}
func cleanupScheduler(store *storage.Storage, frequency int) {
for range time.Tick(time.Duration(frequency) * time.Hour) {
runCleanupTasks(store)
}
}
miniflux-2.0.51/internal/config/ 0000775 0000000 0000000 00000000000 14546226260 0016522 5 ustar 00root root 0000000 0000000 miniflux-2.0.51/internal/config/config.go 0000664 0000000 0000000 00000000362 14546226260 0020317 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"
// Opts holds parsed configuration options.
var Opts *Options
miniflux-2.0.51/internal/config/config_test.go 0000664 0000000 0000000 00000127776 14546226260 0021401 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 (
"os"
"testing"
)
func TestLogFileDefaultValue(t *testing.T) {
os.Clearenv()
parser := NewParser()
opts, err := parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
if opts.LogFile() != defaultLogFile {
t.Fatalf(`Unexpected log file value, got %q`, opts.LogFile())
}
}
func TestLogFileWithCustomFilename(t *testing.T) {
os.Clearenv()
os.Setenv("LOG_FILE", "foobar.log")
parser := NewParser()
opts, err := parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
if opts.LogFile() != "foobar.log" {
t.Fatalf(`Unexpected log file value, got %q`, opts.LogFile())
}
}
func TestLogFileWithEmptyValue(t *testing.T) {
os.Clearenv()
os.Setenv("LOG_FILE", "")
parser := NewParser()
opts, err := parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
if opts.LogFile() != defaultLogFile {
t.Fatalf(`Unexpected log file value, got %q`, opts.LogFile())
}
}
func TestLogLevelDefaultValue(t *testing.T) {
os.Clearenv()
parser := NewParser()
opts, err := parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
if opts.LogLevel() != defaultLogLevel {
t.Fatalf(`Unexpected log level value, got %q`, opts.LogLevel())
}
}
func TestLogLevelWithCustomValue(t *testing.T) {
os.Clearenv()
os.Setenv("LOG_LEVEL", "warning")
parser := NewParser()
opts, err := parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
if opts.LogLevel() != "warning" {
t.Fatalf(`Unexpected log level value, got %q`, opts.LogLevel())
}
}
func TestLogLevelWithInvalidValue(t *testing.T) {
os.Clearenv()
os.Setenv("LOG_LEVEL", "invalid")
parser := NewParser()
opts, err := parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
if opts.LogLevel() != defaultLogLevel {
t.Fatalf(`Unexpected log level value, got %q`, opts.LogLevel())
}
}
func TestLogDateTimeDefaultValue(t *testing.T) {
os.Clearenv()
parser := NewParser()
opts, err := parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
if opts.LogDateTime() != defaultLogDateTime {
t.Fatalf(`Unexpected log date time value, got %v`, opts.LogDateTime())
}
}
func TestLogDateTimeWithCustomValue(t *testing.T) {
os.Clearenv()
os.Setenv("LOG_DATETIME", "false")
parser := NewParser()
opts, err := parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
if opts.LogDateTime() != false {
t.Fatalf(`Unexpected log date time value, got %v`, opts.LogDateTime())
}
}
func TestLogDateTimeWithInvalidValue(t *testing.T) {
os.Clearenv()
os.Setenv("LOG_DATETIME", "invalid")
parser := NewParser()
opts, err := parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
if opts.LogDateTime() != defaultLogDateTime {
t.Fatalf(`Unexpected log date time value, got %v`, opts.LogDateTime())
}
}
func TestLogFormatDefaultValue(t *testing.T) {
os.Clearenv()
parser := NewParser()
opts, err := parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
if opts.LogFormat() != defaultLogFormat {
t.Fatalf(`Unexpected log format value, got %q`, opts.LogFormat())
}
}
func TestLogFormatWithCustomValue(t *testing.T) {
os.Clearenv()
os.Setenv("LOG_FORMAT", "json")
parser := NewParser()
opts, err := parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
if opts.LogFormat() != "json" {
t.Fatalf(`Unexpected log format value, got %q`, opts.LogFormat())
}
}
func TestLogFormatWithInvalidValue(t *testing.T) {
os.Clearenv()
os.Setenv("LOG_FORMAT", "invalid")
parser := NewParser()
opts, err := parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
if opts.LogFormat() != defaultLogFormat {
t.Fatalf(`Unexpected log format value, got %q`, opts.LogFormat())
}
}
func TestDebugModeOn(t *testing.T) {
os.Clearenv()
os.Setenv("DEBUG", "1")
parser := NewParser()
opts, err := parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
if opts.LogLevel() != "debug" {
t.Fatalf(`Unexpected debug mode value, got %q`, opts.LogLevel())
}
}
func TestDebugModeOff(t *testing.T) {
os.Clearenv()
parser := NewParser()
opts, err := parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
if opts.LogLevel() != "info" {
t.Fatalf(`Unexpected debug mode value, got %q`, opts.LogLevel())
}
}
func TestCustomBaseURL(t *testing.T) {
os.Clearenv()
os.Setenv("BASE_URL", "http://example.org")
parser := NewParser()
opts, err := parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
if opts.BaseURL() != "http://example.org" {
t.Fatalf(`Unexpected base URL, got "%s"`, opts.BaseURL())
}
if opts.RootURL() != "http://example.org" {
t.Fatalf(`Unexpected root URL, got "%s"`, opts.RootURL())
}
if opts.BasePath() != "" {
t.Fatalf(`Unexpected base path, got "%s"`, opts.BasePath())
}
}
func TestCustomBaseURLWithTrailingSlash(t *testing.T) {
os.Clearenv()
os.Setenv("BASE_URL", "http://example.org/folder/")
parser := NewParser()
opts, err := parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
if opts.BaseURL() != "http://example.org/folder" {
t.Fatalf(`Unexpected base URL, got "%s"`, opts.BaseURL())
}
if opts.RootURL() != "http://example.org" {
t.Fatalf(`Unexpected root URL, got "%s"`, opts.RootURL())
}
if opts.BasePath() != "/folder" {
t.Fatalf(`Unexpected base path, got "%s"`, opts.BasePath())
}
}
func TestBaseURLWithoutScheme(t *testing.T) {
os.Clearenv()
os.Setenv("BASE_URL", "example.org/folder/")
_, err := NewParser().ParseEnvironmentVariables()
if err == nil {
t.Fatalf(`Parsing must fail`)
}
}
func TestBaseURLWithInvalidScheme(t *testing.T) {
os.Clearenv()
os.Setenv("BASE_URL", "ftp://example.org/folder/")
_, err := NewParser().ParseEnvironmentVariables()
if err == nil {
t.Fatalf(`Parsing must fail`)
}
}
func TestInvalidBaseURL(t *testing.T) {
os.Clearenv()
os.Setenv("BASE_URL", "http://example|org")
_, err := NewParser().ParseEnvironmentVariables()
if err == nil {
t.Fatalf(`Parsing must fail`)
}
}
func TestDefaultBaseURL(t *testing.T) {
os.Clearenv()
parser := NewParser()
opts, err := parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
if opts.BaseURL() != defaultBaseURL {
t.Fatalf(`Unexpected base URL, got "%s"`, opts.BaseURL())
}
if opts.RootURL() != defaultBaseURL {
t.Fatalf(`Unexpected root URL, got "%s"`, opts.RootURL())
}
if opts.BasePath() != "" {
t.Fatalf(`Unexpected base path, got "%s"`, opts.BasePath())
}
}
func TestDatabaseURL(t *testing.T) {
os.Clearenv()
os.Setenv("DATABASE_URL", "foobar")
parser := NewParser()
opts, err := parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
expected := "foobar"
result := opts.DatabaseURL()
if result != expected {
t.Errorf(`Unexpected DATABASE_URL value, got %q instead of %q`, result, expected)
}
if opts.IsDefaultDatabaseURL() {
t.Errorf(`This is not the default database URL and it should returns false`)
}
}
func TestDefaultDatabaseURLValue(t *testing.T) {
os.Clearenv()
parser := NewParser()
opts, err := parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
expected := defaultDatabaseURL
result := opts.DatabaseURL()
if result != expected {
t.Errorf(`Unexpected DATABASE_URL value, got %q instead of %q`, result, expected)
}
if !opts.IsDefaultDatabaseURL() {
t.Errorf(`This is the default database URL and it should returns true`)
}
}
func TestDefaultDatabaseMaxConnsValue(t *testing.T) {
os.Clearenv()
parser := NewParser()
opts, err := parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
expected := defaultDatabaseMaxConns
result := opts.DatabaseMaxConns()
if result != expected {
t.Fatalf(`Unexpected DATABASE_MAX_CONNS value, got %v instead of %v`, result, expected)
}
}
func TestDatabaseMaxConns(t *testing.T) {
os.Clearenv()
os.Setenv("DATABASE_MAX_CONNS", "42")
parser := NewParser()
opts, err := parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
expected := 42
result := opts.DatabaseMaxConns()
if result != expected {
t.Fatalf(`Unexpected DATABASE_MAX_CONNS value, got %v instead of %v`, result, expected)
}
}
func TestDefaultDatabaseMinConnsValue(t *testing.T) {
os.Clearenv()
parser := NewParser()
opts, err := parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
expected := defaultDatabaseMinConns
result := opts.DatabaseMinConns()
if result != expected {
t.Fatalf(`Unexpected DATABASE_MIN_CONNS value, got %v instead of %v`, result, expected)
}
}
func TestDatabaseMinConns(t *testing.T) {
os.Clearenv()
os.Setenv("DATABASE_MIN_CONNS", "42")
parser := NewParser()
opts, err := parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
expected := 42
result := opts.DatabaseMinConns()
if result != expected {
t.Fatalf(`Unexpected DATABASE_MIN_CONNS value, got %v instead of %v`, result, expected)
}
}
func TestListenAddr(t *testing.T) {
os.Clearenv()
os.Setenv("LISTEN_ADDR", "foobar")
parser := NewParser()
opts, err := parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
expected := "foobar"
result := opts.ListenAddr()
if result != expected {
t.Fatalf(`Unexpected LISTEN_ADDR value, got %q instead of %q`, result, expected)
}
}
func TestListenAddrWithPortDefined(t *testing.T) {
os.Clearenv()
os.Setenv("PORT", "3000")
os.Setenv("LISTEN_ADDR", "foobar")
parser := NewParser()
opts, err := parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
expected := ":3000"
result := opts.ListenAddr()
if result != expected {
t.Fatalf(`Unexpected LISTEN_ADDR value, got %q instead of %q`, result, expected)
}
}
func TestDefaultListenAddrValue(t *testing.T) {
os.Clearenv()
parser := NewParser()
opts, err := parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
expected := defaultListenAddr
result := opts.ListenAddr()
if result != expected {
t.Fatalf(`Unexpected LISTEN_ADDR value, got %q instead of %q`, result, expected)
}
}
func TestCertFile(t *testing.T) {
os.Clearenv()
os.Setenv("CERT_FILE", "foobar")
parser := NewParser()
opts, err := parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
expected := "foobar"
result := opts.CertFile()
if result != expected {
t.Fatalf(`Unexpected CERT_FILE value, got %q instead of %q`, result, expected)
}
}
func TestDefaultCertFileValue(t *testing.T) {
os.Clearenv()
parser := NewParser()
opts, err := parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
expected := defaultCertFile
result := opts.CertFile()
if result != expected {
t.Fatalf(`Unexpected CERT_FILE value, got %q instead of %q`, result, expected)
}
}
func TestKeyFile(t *testing.T) {
os.Clearenv()
os.Setenv("KEY_FILE", "foobar")
parser := NewParser()
opts, err := parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
expected := "foobar"
result := opts.CertKeyFile()
if result != expected {
t.Fatalf(`Unexpected KEY_FILE value, got %q instead of %q`, result, expected)
}
}
func TestDefaultKeyFileValue(t *testing.T) {
os.Clearenv()
parser := NewParser()
opts, err := parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
expected := defaultKeyFile
result := opts.CertKeyFile()
if result != expected {
t.Fatalf(`Unexpected KEY_FILE value, got %q instead of %q`, result, expected)
}
}
func TestCertDomain(t *testing.T) {
os.Clearenv()
os.Setenv("CERT_DOMAIN", "example.org")
parser := NewParser()
opts, err := parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
expected := "example.org"
result := opts.CertDomain()
if result != expected {
t.Fatalf(`Unexpected CERT_DOMAIN value, got %q instead of %q`, result, expected)
}
}
func TestDefaultCertDomainValue(t *testing.T) {
os.Clearenv()
parser := NewParser()
opts, err := parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
expected := defaultCertDomain
result := opts.CertDomain()
if result != expected {
t.Fatalf(`Unexpected CERT_DOMAIN value, got %q instead of %q`, result, expected)
}
}
func TestDefaultCleanupFrequencyHoursValue(t *testing.T) {
os.Clearenv()
parser := NewParser()
opts, err := parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
expected := defaultCleanupFrequencyHours
result := opts.CleanupFrequencyHours()
if result != expected {
t.Fatalf(`Unexpected CLEANUP_FREQUENCY_HOURS value, got %v instead of %v`, result, expected)
}
}
func TestCleanupFrequencyHours(t *testing.T) {
os.Clearenv()
os.Setenv("CLEANUP_FREQUENCY_HOURS", "42")
os.Setenv("CLEANUP_FREQUENCY", "19")
parser := NewParser()
opts, err := parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
expected := 42
result := opts.CleanupFrequencyHours()
if result != expected {
t.Fatalf(`Unexpected CLEANUP_FREQUENCY_HOURS value, got %v instead of %v`, result, expected)
}
}
func TestDefaultCleanupArchiveReadDaysValue(t *testing.T) {
os.Clearenv()
parser := NewParser()
opts, err := parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
expected := 60
result := opts.CleanupArchiveReadDays()
if result != expected {
t.Fatalf(`Unexpected CLEANUP_ARCHIVE_READ_DAYS value, got %v instead of %v`, result, expected)
}
}
func TestCleanupArchiveReadDays(t *testing.T) {
os.Clearenv()
os.Setenv("CLEANUP_ARCHIVE_READ_DAYS", "7")
os.Setenv("ARCHIVE_READ_DAYS", "19")
parser := NewParser()
opts, err := parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
expected := 7
result := opts.CleanupArchiveReadDays()
if result != expected {
t.Fatalf(`Unexpected CLEANUP_ARCHIVE_READ_DAYS value, got %v instead of %v`, result, expected)
}
}
func TestDefaultCleanupRemoveSessionsDaysValue(t *testing.T) {
os.Clearenv()
parser := NewParser()
opts, err := parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
expected := 30
result := opts.CleanupRemoveSessionsDays()
if result != expected {
t.Fatalf(`Unexpected CLEANUP_REMOVE_SESSIONS_DAYS value, got %v instead of %v`, result, expected)
}
}
func TestCleanupRemoveSessionsDays(t *testing.T) {
os.Clearenv()
os.Setenv("CLEANUP_REMOVE_SESSIONS_DAYS", "7")
parser := NewParser()
opts, err := parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
expected := 7
result := opts.CleanupRemoveSessionsDays()
if result != expected {
t.Fatalf(`Unexpected CLEANUP_REMOVE_SESSIONS_DAYS value, got %v instead of %v`, result, expected)
}
}
func TestDefaultWorkerPoolSizeValue(t *testing.T) {
os.Clearenv()
parser := NewParser()
opts, err := parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
expected := defaultWorkerPoolSize
result := opts.WorkerPoolSize()
if result != expected {
t.Fatalf(`Unexpected WORKER_POOL_SIZE value, got %v instead of %v`, result, expected)
}
}
func TestWorkerPoolSize(t *testing.T) {
os.Clearenv()
os.Setenv("WORKER_POOL_SIZE", "42")
parser := NewParser()
opts, err := parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
expected := 42
result := opts.WorkerPoolSize()
if result != expected {
t.Fatalf(`Unexpected WORKER_POOL_SIZE value, got %v instead of %v`, result, expected)
}
}
func TestDefautPollingFrequencyValue(t *testing.T) {
os.Clearenv()
parser := NewParser()
opts, err := parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
expected := defaultPollingFrequency
result := opts.PollingFrequency()
if result != expected {
t.Fatalf(`Unexpected POLLING_FREQUENCY value, got %v instead of %v`, result, expected)
}
}
func TestPollingFrequency(t *testing.T) {
os.Clearenv()
os.Setenv("POLLING_FREQUENCY", "42")
parser := NewParser()
opts, err := parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
expected := 42
result := opts.PollingFrequency()
if result != expected {
t.Fatalf(`Unexpected POLLING_FREQUENCY value, got %v instead of %v`, result, expected)
}
}
func TestDefaultBatchSizeValue(t *testing.T) {
os.Clearenv()
parser := NewParser()
opts, err := parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
expected := defaultBatchSize
result := opts.BatchSize()
if result != expected {
t.Fatalf(`Unexpected BATCH_SIZE value, got %v instead of %v`, result, expected)
}
}
func TestBatchSize(t *testing.T) {
os.Clearenv()
os.Setenv("BATCH_SIZE", "42")
parser := NewParser()
opts, err := parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
expected := 42
result := opts.BatchSize()
if result != expected {
t.Fatalf(`Unexpected BATCH_SIZE value, got %v instead of %v`, result, expected)
}
}
func TestDefautPollingSchedulerValue(t *testing.T) {
os.Clearenv()
parser := NewParser()
opts, err := parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
expected := defaultPollingScheduler
result := opts.PollingScheduler()
if result != expected {
t.Fatalf(`Unexpected POLLING_SCHEDULER value, got %v instead of %v`, result, expected)
}
}
func TestPollingScheduler(t *testing.T) {
os.Clearenv()
os.Setenv("POLLING_SCHEDULER", "entry_count_based")
parser := NewParser()
opts, err := parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
expected := "entry_count_based"
result := opts.PollingScheduler()
if result != expected {
t.Fatalf(`Unexpected POLLING_SCHEDULER value, got %v instead of %v`, result, expected)
}
}
func TestDefautSchedulerEntryFrequencyMaxIntervalValue(t *testing.T) {
os.Clearenv()
parser := NewParser()
opts, err := parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
expected := defaultSchedulerEntryFrequencyMaxInterval
result := opts.SchedulerEntryFrequencyMaxInterval()
if result != expected {
t.Fatalf(`Unexpected SCHEDULER_ENTRY_FREQUENCY_MAX_INTERVAL value, got %v instead of %v`, result, expected)
}
}
func TestSchedulerEntryFrequencyMaxInterval(t *testing.T) {
os.Clearenv()
os.Setenv("SCHEDULER_ENTRY_FREQUENCY_MAX_INTERVAL", "30")
parser := NewParser()
opts, err := parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
expected := 30
result := opts.SchedulerEntryFrequencyMaxInterval()
if result != expected {
t.Fatalf(`Unexpected SCHEDULER_ENTRY_FREQUENCY_MAX_INTERVAL value, got %v instead of %v`, result, expected)
}
}
func TestDefautSchedulerEntryFrequencyMinIntervalValue(t *testing.T) {
os.Clearenv()
parser := NewParser()
opts, err := parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
expected := defaultSchedulerEntryFrequencyMinInterval
result := opts.SchedulerEntryFrequencyMinInterval()
if result != expected {
t.Fatalf(`Unexpected SCHEDULER_ENTRY_FREQUENCY_MIN_INTERVAL value, got %v instead of %v`, result, expected)
}
}
func TestSchedulerEntryFrequencyMinInterval(t *testing.T) {
os.Clearenv()
os.Setenv("SCHEDULER_ENTRY_FREQUENCY_MIN_INTERVAL", "30")
parser := NewParser()
opts, err := parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
expected := 30
result := opts.SchedulerEntryFrequencyMinInterval()
if result != expected {
t.Fatalf(`Unexpected SCHEDULER_ENTRY_FREQUENCY_MIN_INTERVAL value, got %v instead of %v`, result, expected)
}
}
func TestDefautSchedulerEntryFrequencyFactorValue(t *testing.T) {
os.Clearenv()
parser := NewParser()
opts, err := parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
expected := defaultSchedulerEntryFrequencyFactor
result := opts.SchedulerEntryFrequencyFactor()
if result != expected {
t.Fatalf(`Unexpected SCHEDULER_ENTRY_FREQUENCY_FACTOR value, got %v instead of %v`, result, expected)
}
}
func TestSchedulerEntryFrequencyFactor(t *testing.T) {
os.Clearenv()
os.Setenv("SCHEDULER_ENTRY_FREQUENCY_FACTOR", "2")
parser := NewParser()
opts, err := parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
expected := 2
result := opts.SchedulerEntryFrequencyFactor()
if result != expected {
t.Fatalf(`Unexpected SCHEDULER_ENTRY_FREQUENCY_FACTOR value, got %v instead of %v`, result, expected)
}
}
func TestDefaultSchedulerRoundRobinValue(t *testing.T) {
os.Clearenv()
parser := NewParser()
opts, err := parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
expected := defaultSchedulerRoundRobinMinInterval
result := opts.SchedulerRoundRobinMinInterval()
if result != expected {
t.Fatalf(`Unexpected SCHEDULER_ROUND_ROBIN_MIN_INTERVAL value, got %v instead of %v`, result, expected)
}
}
func TestSchedulerRoundRobin(t *testing.T) {
os.Clearenv()
os.Setenv("SCHEDULER_ROUND_ROBIN_MIN_INTERVAL", "15")
parser := NewParser()
opts, err := parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
expected := 15
result := opts.SchedulerRoundRobinMinInterval()
if result != expected {
t.Fatalf(`Unexpected SCHEDULER_ROUND_ROBIN_MIN_INTERVAL value, got %v instead of %v`, result, expected)
}
}
func TestPollingParsingErrorLimit(t *testing.T) {
os.Clearenv()
os.Setenv("POLLING_PARSING_ERROR_LIMIT", "100")
parser := NewParser()
opts, err := parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
expected := 100
result := opts.PollingParsingErrorLimit()
if result != expected {
t.Fatalf(`Unexpected POLLING_SCHEDULER value, got %v instead of %v`, result, expected)
}
}
func TestOAuth2UserCreationWhenUnset(t *testing.T) {
os.Clearenv()
parser := NewParser()
opts, err := parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
expected := false
result := opts.IsOAuth2UserCreationAllowed()
if result != expected {
t.Fatalf(`Unexpected OAUTH2_USER_CREATION value, got %v instead of %v`, result, expected)
}
}
func TestOAuth2UserCreationAdmin(t *testing.T) {
os.Clearenv()
os.Setenv("OAUTH2_USER_CREATION", "1")
parser := NewParser()
opts, err := parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
expected := true
result := opts.IsOAuth2UserCreationAllowed()
if result != expected {
t.Fatalf(`Unexpected OAUTH2_USER_CREATION value, got %v instead of %v`, result, expected)
}
}
func TestOAuth2ClientID(t *testing.T) {
os.Clearenv()
os.Setenv("OAUTH2_CLIENT_ID", "foobar")
parser := NewParser()
opts, err := parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
expected := "foobar"
result := opts.OAuth2ClientID()
if result != expected {
t.Fatalf(`Unexpected OAUTH2_CLIENT_ID value, got %q instead of %q`, result, expected)
}
}
func TestDefaultOAuth2ClientIDValue(t *testing.T) {
os.Clearenv()
parser := NewParser()
opts, err := parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
expected := defaultOAuth2ClientID
result := opts.OAuth2ClientID()
if result != expected {
t.Fatalf(`Unexpected OAUTH2_CLIENT_ID value, got %q instead of %q`, result, expected)
}
}
func TestOAuth2ClientSecret(t *testing.T) {
os.Clearenv()
os.Setenv("OAUTH2_CLIENT_SECRET", "secret")
parser := NewParser()
opts, err := parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
expected := "secret"
result := opts.OAuth2ClientSecret()
if result != expected {
t.Fatalf(`Unexpected OAUTH2_CLIENT_SECRET value, got %q instead of %q`, result, expected)
}
}
func TestDefaultOAuth2ClientSecretValue(t *testing.T) {
os.Clearenv()
parser := NewParser()
opts, err := parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
expected := defaultOAuth2ClientSecret
result := opts.OAuth2ClientSecret()
if result != expected {
t.Fatalf(`Unexpected OAUTH2_CLIENT_SECRET value, got %q instead of %q`, result, expected)
}
}
func TestOAuth2RedirectURL(t *testing.T) {
os.Clearenv()
os.Setenv("OAUTH2_REDIRECT_URL", "http://example.org")
parser := NewParser()
opts, err := parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
expected := "http://example.org"
result := opts.OAuth2RedirectURL()
if result != expected {
t.Fatalf(`Unexpected OAUTH2_REDIRECT_URL value, got %q instead of %q`, result, expected)
}
}
func TestDefaultOAuth2RedirectURLValue(t *testing.T) {
os.Clearenv()
parser := NewParser()
opts, err := parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
expected := defaultOAuth2RedirectURL
result := opts.OAuth2RedirectURL()
if result != expected {
t.Fatalf(`Unexpected OAUTH2_REDIRECT_URL value, got %q instead of %q`, result, expected)
}
}
func TestOAuth2OIDCDiscoveryEndpoint(t *testing.T) {
os.Clearenv()
os.Setenv("OAUTH2_OIDC_DISCOVERY_ENDPOINT", "http://example.org")
parser := NewParser()
opts, err := parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
expected := "http://example.org"
result := opts.OIDCDiscoveryEndpoint()
if result != expected {
t.Fatalf(`Unexpected OAUTH2_OIDC_DISCOVERY_ENDPOINT value, got %q instead of %q`, result, expected)
}
}
func TestDefaultOIDCDiscoveryEndpointValue(t *testing.T) {
os.Clearenv()
parser := NewParser()
opts, err := parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
expected := defaultOAuth2OidcDiscoveryEndpoint
result := opts.OIDCDiscoveryEndpoint()
if result != expected {
t.Fatalf(`Unexpected OAUTH2_OIDC_DISCOVERY_ENDPOINT value, got %q instead of %q`, result, expected)
}
}
func TestOAuth2Provider(t *testing.T) {
os.Clearenv()
os.Setenv("OAUTH2_PROVIDER", "google")
parser := NewParser()
opts, err := parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
expected := "google"
result := opts.OAuth2Provider()
if result != expected {
t.Fatalf(`Unexpected OAUTH2_PROVIDER value, got %q instead of %q`, result, expected)
}
}
func TestDefaultOAuth2ProviderValue(t *testing.T) {
os.Clearenv()
parser := NewParser()
opts, err := parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
expected := defaultOAuth2Provider
result := opts.OAuth2Provider()
if result != expected {
t.Fatalf(`Unexpected OAUTH2_PROVIDER value, got %q instead of %q`, result, expected)
}
}
func TestHSTSWhenUnset(t *testing.T) {
os.Clearenv()
parser := NewParser()
opts, err := parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
expected := true
result := opts.HasHSTS()
if result != expected {
t.Fatalf(`Unexpected DISABLE_HSTS value, got %v instead of %v`, result, expected)
}
}
func TestHSTS(t *testing.T) {
os.Clearenv()
os.Setenv("DISABLE_HSTS", "1")
parser := NewParser()
opts, err := parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
expected := false
result := opts.HasHSTS()
if result != expected {
t.Fatalf(`Unexpected DISABLE_HSTS value, got %v instead of %v`, result, expected)
}
}
func TestDisableHTTPServiceWhenUnset(t *testing.T) {
os.Clearenv()
parser := NewParser()
opts, err := parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
expected := true
result := opts.HasHTTPService()
if result != expected {
t.Fatalf(`Unexpected DISABLE_HTTP_SERVICE value, got %v instead of %v`, result, expected)
}
}
func TestDisableHTTPService(t *testing.T) {
os.Clearenv()
os.Setenv("DISABLE_HTTP_SERVICE", "1")
parser := NewParser()
opts, err := parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
expected := false
result := opts.HasHTTPService()
if result != expected {
t.Fatalf(`Unexpected DISABLE_HTTP_SERVICE value, got %v instead of %v`, result, expected)
}
}
func TestDisableSchedulerServiceWhenUnset(t *testing.T) {
os.Clearenv()
parser := NewParser()
opts, err := parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
expected := true
result := opts.HasSchedulerService()
if result != expected {
t.Fatalf(`Unexpected DISABLE_SCHEDULER_SERVICE value, got %v instead of %v`, result, expected)
}
}
func TestDisableSchedulerService(t *testing.T) {
os.Clearenv()
os.Setenv("DISABLE_SCHEDULER_SERVICE", "1")
parser := NewParser()
opts, err := parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
expected := false
result := opts.HasSchedulerService()
if result != expected {
t.Fatalf(`Unexpected DISABLE_SCHEDULER_SERVICE value, got %v instead of %v`, result, expected)
}
}
func TestRunMigrationsWhenUnset(t *testing.T) {
os.Clearenv()
parser := NewParser()
opts, err := parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
expected := false
result := opts.RunMigrations()
if result != expected {
t.Fatalf(`Unexpected RUN_MIGRATIONS value, got %v instead of %v`, result, expected)
}
}
func TestRunMigrations(t *testing.T) {
os.Clearenv()
os.Setenv("RUN_MIGRATIONS", "yes")
parser := NewParser()
opts, err := parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
expected := true
result := opts.RunMigrations()
if result != expected {
t.Fatalf(`Unexpected RUN_MIGRATIONS value, got %v instead of %v`, result, expected)
}
}
func TestCreateAdminWhenUnset(t *testing.T) {
os.Clearenv()
parser := NewParser()
opts, err := parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
expected := false
result := opts.CreateAdmin()
if result != expected {
t.Fatalf(`Unexpected CREATE_ADMIN value, got %v instead of %v`, result, expected)
}
}
func TestCreateAdmin(t *testing.T) {
os.Clearenv()
os.Setenv("CREATE_ADMIN", "true")
parser := NewParser()
opts, err := parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
expected := true
result := opts.CreateAdmin()
if result != expected {
t.Fatalf(`Unexpected CREATE_ADMIN value, got %v instead of %v`, result, expected)
}
}
func TestPocketConsumerKeyFromEnvVariable(t *testing.T) {
os.Clearenv()
os.Setenv("POCKET_CONSUMER_KEY", "something")
parser := NewParser()
opts, err := parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
expected := "something"
result := opts.PocketConsumerKey("default")
if result != expected {
t.Fatalf(`Unexpected POCKET_CONSUMER_KEY value, got %q instead of %q`, result, expected)
}
}
func TestPocketConsumerKeyFromUserPrefs(t *testing.T) {
os.Clearenv()
parser := NewParser()
opts, err := parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
expected := "default"
result := opts.PocketConsumerKey("default")
if result != expected {
t.Fatalf(`Unexpected POCKET_CONSUMER_KEY value, got %q instead of %q`, result, expected)
}
}
func TestProxyOption(t *testing.T) {
os.Clearenv()
os.Setenv("PROXY_OPTION", "all")
parser := NewParser()
opts, err := parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
expected := "all"
result := opts.ProxyOption()
if result != expected {
t.Fatalf(`Unexpected PROXY_OPTION value, got %q instead of %q`, result, expected)
}
}
func TestDefaultProxyOptionValue(t *testing.T) {
os.Clearenv()
parser := NewParser()
opts, err := parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
expected := defaultProxyOption
result := opts.ProxyOption()
if result != expected {
t.Fatalf(`Unexpected PROXY_OPTION value, got %q instead of %q`, result, expected)
}
}
func TestProxyMediaTypes(t *testing.T) {
os.Clearenv()
os.Setenv("PROXY_MEDIA_TYPES", "image,audio")
parser := NewParser()
opts, err := parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
expected := []string{"audio", "image"}
if len(expected) != len(opts.ProxyMediaTypes()) {
t.Fatalf(`Unexpected PROXY_MEDIA_TYPES value, got %v instead of %v`, opts.ProxyMediaTypes(), expected)
}
resultMap := make(map[string]bool)
for _, mediaType := range opts.ProxyMediaTypes() {
resultMap[mediaType] = true
}
for _, mediaType := range expected {
if !resultMap[mediaType] {
t.Fatalf(`Unexpected PROXY_MEDIA_TYPES value, got %v instead of %v`, opts.ProxyMediaTypes(), expected)
}
}
}
func TestProxyMediaTypesWithDuplicatedValues(t *testing.T) {
os.Clearenv()
os.Setenv("PROXY_MEDIA_TYPES", "image,audio, image")
parser := NewParser()
opts, err := parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
expected := []string{"audio", "image"}
if len(expected) != len(opts.ProxyMediaTypes()) {
t.Fatalf(`Unexpected PROXY_MEDIA_TYPES value, got %v instead of %v`, opts.ProxyMediaTypes(), expected)
}
resultMap := make(map[string]bool)
for _, mediaType := range opts.ProxyMediaTypes() {
resultMap[mediaType] = true
}
for _, mediaType := range expected {
if !resultMap[mediaType] {
t.Fatalf(`Unexpected PROXY_MEDIA_TYPES value, got %v instead of %v`, opts.ProxyMediaTypes(), expected)
}
}
}
func TestProxyImagesOptionBackwardCompatibility(t *testing.T) {
os.Clearenv()
os.Setenv("PROXY_IMAGES", "all")
parser := NewParser()
opts, err := parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
expected := []string{"image"}
if len(expected) != len(opts.ProxyMediaTypes()) {
t.Fatalf(`Unexpected PROXY_MEDIA_TYPES value, got %v instead of %v`, opts.ProxyMediaTypes(), expected)
}
resultMap := make(map[string]bool)
for _, mediaType := range opts.ProxyMediaTypes() {
resultMap[mediaType] = true
}
for _, mediaType := range expected {
if !resultMap[mediaType] {
t.Fatalf(`Unexpected PROXY_MEDIA_TYPES value, got %v instead of %v`, opts.ProxyMediaTypes(), expected)
}
}
expectedProxyOption := "all"
result := opts.ProxyOption()
if result != expectedProxyOption {
t.Fatalf(`Unexpected PROXY_OPTION value, got %q instead of %q`, result, expectedProxyOption)
}
}
func TestDefaultProxyMediaTypes(t *testing.T) {
os.Clearenv()
parser := NewParser()
opts, err := parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
expected := []string{"image"}
if len(expected) != len(opts.ProxyMediaTypes()) {
t.Fatalf(`Unexpected PROXY_MEDIA_TYPES value, got %v instead of %v`, opts.ProxyMediaTypes(), expected)
}
resultMap := make(map[string]bool)
for _, mediaType := range opts.ProxyMediaTypes() {
resultMap[mediaType] = true
}
for _, mediaType := range expected {
if !resultMap[mediaType] {
t.Fatalf(`Unexpected PROXY_MEDIA_TYPES value, got %v instead of %v`, opts.ProxyMediaTypes(), expected)
}
}
}
func TestProxyHTTPClientTimeout(t *testing.T) {
os.Clearenv()
os.Setenv("PROXY_HTTP_CLIENT_TIMEOUT", "24")
parser := NewParser()
opts, err := parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
expected := 24
result := opts.ProxyHTTPClientTimeout()
if result != expected {
t.Fatalf(`Unexpected PROXY_HTTP_CLIENT_TIMEOUT value, got %d instead of %d`, result, expected)
}
}
func TestDefaultProxyHTTPClientTimeoutValue(t *testing.T) {
os.Clearenv()
parser := NewParser()
opts, err := parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
expected := defaultProxyHTTPClientTimeout
result := opts.ProxyHTTPClientTimeout()
if result != expected {
t.Fatalf(`Unexpected PROXY_HTTP_CLIENT_TIMEOUT value, got %d instead of %d`, result, expected)
}
}
func TestHTTPSOff(t *testing.T) {
os.Clearenv()
parser := NewParser()
opts, err := parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
if opts.HTTPS {
t.Fatalf(`Unexpected HTTPS value, got "%v"`, opts.HTTPS)
}
}
func TestHTTPSOn(t *testing.T) {
os.Clearenv()
os.Setenv("HTTPS", "on")
parser := NewParser()
opts, err := parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
if !opts.HTTPS {
t.Fatalf(`Unexpected HTTPS value, got "%v"`, opts.HTTPS)
}
}
func TestHTTPClientTimeout(t *testing.T) {
os.Clearenv()
os.Setenv("HTTP_CLIENT_TIMEOUT", "42")
parser := NewParser()
opts, err := parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
expected := 42
result := opts.HTTPClientTimeout()
if result != expected {
t.Fatalf(`Unexpected HTTP_CLIENT_TIMEOUT value, got %d instead of %d`, result, expected)
}
}
func TestDefaultHTTPClientTimeoutValue(t *testing.T) {
os.Clearenv()
parser := NewParser()
opts, err := parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
expected := defaultHTTPClientTimeout
result := opts.HTTPClientTimeout()
if result != expected {
t.Fatalf(`Unexpected HTTP_CLIENT_TIMEOUT value, got %d instead of %d`, result, expected)
}
}
func TestHTTPClientMaxBodySize(t *testing.T) {
os.Clearenv()
os.Setenv("HTTP_CLIENT_MAX_BODY_SIZE", "42")
parser := NewParser()
opts, err := parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
expected := int64(42 * 1024 * 1024)
result := opts.HTTPClientMaxBodySize()
if result != expected {
t.Fatalf(`Unexpected HTTP_CLIENT_MAX_BODY_SIZE value, got %d instead of %d`, result, expected)
}
}
func TestDefaultHTTPClientMaxBodySizeValue(t *testing.T) {
os.Clearenv()
parser := NewParser()
opts, err := parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
expected := int64(defaultHTTPClientMaxBodySize * 1024 * 1024)
result := opts.HTTPClientMaxBodySize()
if result != expected {
t.Fatalf(`Unexpected HTTP_CLIENT_MAX_BODY_SIZE value, got %d instead of %d`, result, expected)
}
}
func TestHTTPServerTimeout(t *testing.T) {
os.Clearenv()
os.Setenv("HTTP_SERVER_TIMEOUT", "342")
parser := NewParser()
opts, err := parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
expected := 342
result := opts.HTTPServerTimeout()
if result != expected {
t.Fatalf(`Unexpected HTTP_SERVER_TIMEOUT value, got %d instead of %d`, result, expected)
}
}
func TestDefaultHTTPServerTimeoutValue(t *testing.T) {
os.Clearenv()
parser := NewParser()
opts, err := parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
expected := defaultHTTPServerTimeout
result := opts.HTTPServerTimeout()
if result != expected {
t.Fatalf(`Unexpected HTTP_SERVER_TIMEOUT value, got %d instead of %d`, result, expected)
}
}
func TestParseConfigFile(t *testing.T) {
content := []byte(`
# This is a comment
DEBUG = yes
POCKET_CONSUMER_KEY= >#1234
Invalid text
`)
tmpfile, err := os.CreateTemp(".", "miniflux.*.unit_test.conf")
if err != nil {
t.Fatal(err)
}
if _, err := tmpfile.Write(content); err != nil {
t.Fatal(err)
}
os.Clearenv()
parser := NewParser()
opts, err := parser.ParseFile(tmpfile.Name())
if err != nil {
t.Errorf(`Parsing failure: %v`, err)
}
if opts.LogLevel() != "debug" {
t.Errorf(`Unexpected debug mode value, got %q`, opts.LogLevel())
}
expected := ">#1234"
result := opts.PocketConsumerKey("default")
if result != expected {
t.Errorf(`Unexpected POCKET_CONSUMER_KEY value, got %q instead of %q`, result, expected)
}
if err := tmpfile.Close(); err != nil {
t.Fatal(err)
}
if err := os.Remove(tmpfile.Name()); err != nil {
t.Fatal(err)
}
}
func TestAuthProxyHeader(t *testing.T) {
os.Clearenv()
os.Setenv("AUTH_PROXY_HEADER", "X-Forwarded-User")
parser := NewParser()
opts, err := parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
expected := "X-Forwarded-User"
result := opts.AuthProxyHeader()
if result != expected {
t.Fatalf(`Unexpected AUTH_PROXY_HEADER value, got %q instead of %q`, result, expected)
}
}
func TestDefaultAuthProxyHeaderValue(t *testing.T) {
os.Clearenv()
parser := NewParser()
opts, err := parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
expected := defaultAuthProxyHeader
result := opts.AuthProxyHeader()
if result != expected {
t.Fatalf(`Unexpected AUTH_PROXY_HEADER value, got %q instead of %q`, result, expected)
}
}
func TestAuthProxyUserCreationWhenUnset(t *testing.T) {
os.Clearenv()
parser := NewParser()
opts, err := parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
expected := false
result := opts.IsAuthProxyUserCreationAllowed()
if result != expected {
t.Fatalf(`Unexpected AUTH_PROXY_USER_CREATION value, got %v instead of %v`, result, expected)
}
}
func TestAuthProxyUserCreationAdmin(t *testing.T) {
os.Clearenv()
os.Setenv("AUTH_PROXY_USER_CREATION", "1")
parser := NewParser()
opts, err := parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
expected := true
result := opts.IsAuthProxyUserCreationAllowed()
if result != expected {
t.Fatalf(`Unexpected AUTH_PROXY_USER_CREATION value, got %v instead of %v`, result, expected)
}
}
func TestFetchOdyseeWatchTime(t *testing.T) {
os.Clearenv()
os.Setenv("FETCH_ODYSEE_WATCH_TIME", "1")
parser := NewParser()
opts, err := parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
expected := true
result := opts.FetchOdyseeWatchTime()
if result != expected {
t.Fatalf(`Unexpected FETCH_ODYSEE_WATCH_TIME value, got %v instead of %v`, result, expected)
}
}
func TestFetchYouTubeWatchTime(t *testing.T) {
os.Clearenv()
os.Setenv("FETCH_YOUTUBE_WATCH_TIME", "1")
parser := NewParser()
opts, err := parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
expected := true
result := opts.FetchYouTubeWatchTime()
if result != expected {
t.Fatalf(`Unexpected FETCH_YOUTUBE_WATCH_TIME value, got %v instead of %v`, result, expected)
}
}
func TestYouTubeEmbedUrlOverride(t *testing.T) {
os.Clearenv()
os.Setenv("YOUTUBE_EMBED_URL_OVERRIDE", "https://invidious.custom/embed/")
parser := NewParser()
opts, err := parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
expected := "https://invidious.custom/embed/"
result := opts.YouTubeEmbedUrlOverride()
if result != expected {
t.Fatalf(`Unexpected YOUTUBE_EMBED_URL_OVERRIDE value, got %v instead of %v`, result, expected)
}
}
func TestParseConfigDumpOutput(t *testing.T) {
os.Clearenv()
wantOpts := NewOptions()
wantOpts.adminUsername = "my-username"
serialized := wantOpts.String()
tmpfile, err := os.CreateTemp(".", "miniflux.*.unit_test.conf")
if err != nil {
t.Fatal(err)
}
if _, err := tmpfile.Write([]byte(serialized)); err != nil {
t.Fatal(err)
}
parser := NewParser()
parsedOpts, err := parser.ParseFile(tmpfile.Name())
if err != nil {
t.Errorf(`Parsing failure: %v`, err)
}
if parsedOpts.AdminUsername() != wantOpts.AdminUsername() {
t.Fatalf(`Unexpected ADMIN_USERNAME value, got %q instead of %q`, parsedOpts.AdminUsername(), wantOpts.AdminUsername())
}
if err := tmpfile.Close(); err != nil {
t.Fatal(err)
}
if err := os.Remove(tmpfile.Name()); err != nil {
t.Fatal(err)
}
}
miniflux-2.0.51/internal/config/options.go 0000664 0000000 0000000 00000066535 14546226260 0020563 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 (
"crypto/rand"
"fmt"
"sort"
"strings"
"time"
"miniflux.app/v2/internal/version"
)
const (
defaultHTTPS = false
defaultLogFile = "stderr"
defaultLogDateTime = false
defaultLogFormat = "text"
defaultLogLevel = "info"
defaultHSTS = true
defaultHTTPService = true
defaultSchedulerService = true
defaultDebug = false
defaultTiming = false
defaultBaseURL = "http://localhost"
defaultRootURL = "http://localhost"
defaultBasePath = ""
defaultWorkerPoolSize = 5
defaultPollingFrequency = 60
defaultBatchSize = 100
defaultPollingScheduler = "round_robin"
defaultSchedulerEntryFrequencyMinInterval = 5
defaultSchedulerEntryFrequencyMaxInterval = 24 * 60
defaultSchedulerEntryFrequencyFactor = 1
defaultSchedulerRoundRobinMinInterval = 60
defaultPollingParsingErrorLimit = 3
defaultRunMigrations = false
defaultDatabaseURL = "user=postgres password=postgres dbname=miniflux2 sslmode=disable"
defaultDatabaseMaxConns = 20
defaultDatabaseMinConns = 1
defaultDatabaseConnectionLifetime = 5
defaultListenAddr = "127.0.0.1:8080"
defaultCertFile = ""
defaultKeyFile = ""
defaultCertDomain = ""
defaultCleanupFrequencyHours = 24
defaultCleanupArchiveReadDays = 60
defaultCleanupArchiveUnreadDays = 180
defaultCleanupArchiveBatchSize = 10000
defaultCleanupRemoveSessionsDays = 30
defaultProxyHTTPClientTimeout = 120
defaultProxyOption = "http-only"
defaultProxyMediaTypes = "image"
defaultProxyUrl = ""
defaultFetchOdyseeWatchTime = false
defaultFetchYouTubeWatchTime = false
defaultYouTubeEmbedUrlOverride = "https://www.youtube-nocookie.com/embed/"
defaultCreateAdmin = false
defaultAdminUsername = ""
defaultAdminPassword = ""
defaultOAuth2UserCreation = false
defaultOAuth2ClientID = ""
defaultOAuth2ClientSecret = ""
defaultOAuth2RedirectURL = ""
defaultOAuth2OidcDiscoveryEndpoint = ""
defaultOAuth2Provider = ""
defaultPocketConsumerKey = ""
defaultHTTPClientTimeout = 20
defaultHTTPClientMaxBodySize = 15
defaultHTTPClientProxy = ""
defaultHTTPServerTimeout = 300
defaultAuthProxyHeader = ""
defaultAuthProxyUserCreation = false
defaultMaintenanceMode = false
defaultMaintenanceMessage = "Miniflux is currently under maintenance"
defaultMetricsCollector = false
defaultMetricsRefreshInterval = 60
defaultMetricsAllowedNetworks = "127.0.0.1/8"
defaultMetricsUsername = ""
defaultMetricsPassword = ""
defaultWatchdog = true
defaultInvidiousInstance = "yewtu.be"
defaultWebAuthn = false
)
var defaultHTTPClientUserAgent = "Mozilla/5.0 (compatible; Miniflux/" + version.Version + "; +https://miniflux.app)"
// Option contains a key to value map of a single option. It may be used to output debug strings.
type Option struct {
Key string
Value interface{}
}
// Options contains configuration options.
type Options struct {
HTTPS bool
logFile string
logDateTime bool
logFormat string
logLevel string
hsts bool
httpService bool
schedulerService bool
serverTimingHeader bool
baseURL string
rootURL string
basePath string
databaseURL string
databaseMaxConns int
databaseMinConns int
databaseConnectionLifetime int
runMigrations bool
listenAddr string
certFile string
certDomain string
certKeyFile string
cleanupFrequencyHours int
cleanupArchiveReadDays int
cleanupArchiveUnreadDays int
cleanupArchiveBatchSize int
cleanupRemoveSessionsDays int
pollingFrequency int
batchSize int
pollingScheduler string
schedulerEntryFrequencyMinInterval int
schedulerEntryFrequencyMaxInterval int
schedulerEntryFrequencyFactor int
schedulerRoundRobinMinInterval int
pollingParsingErrorLimit int
workerPoolSize int
createAdmin bool
adminUsername string
adminPassword string
proxyHTTPClientTimeout int
proxyOption string
proxyMediaTypes []string
proxyUrl string
fetchOdyseeWatchTime bool
fetchYouTubeWatchTime bool
youTubeEmbedUrlOverride string
oauth2UserCreationAllowed bool
oauth2ClientID string
oauth2ClientSecret string
oauth2RedirectURL string
oidcDiscoveryEndpoint string
oauth2Provider string
pocketConsumerKey string
httpClientTimeout int
httpClientMaxBodySize int64
httpClientProxy string
httpClientUserAgent string
httpServerTimeout int
authProxyHeader string
authProxyUserCreation bool
maintenanceMode bool
maintenanceMessage string
metricsCollector bool
metricsRefreshInterval int
metricsAllowedNetworks []string
metricsUsername string
metricsPassword string
watchdog bool
invidiousInstance string
proxyPrivateKey []byte
webAuthn bool
}
// NewOptions returns Options with default values.
func NewOptions() *Options {
randomKey := make([]byte, 16)
rand.Read(randomKey)
return &Options{
HTTPS: defaultHTTPS,
logFile: defaultLogFile,
logDateTime: defaultLogDateTime,
logFormat: defaultLogFormat,
logLevel: defaultLogLevel,
hsts: defaultHSTS,
httpService: defaultHTTPService,
schedulerService: defaultSchedulerService,
serverTimingHeader: defaultTiming,
baseURL: defaultBaseURL,
rootURL: defaultRootURL,
basePath: defaultBasePath,
databaseURL: defaultDatabaseURL,
databaseMaxConns: defaultDatabaseMaxConns,
databaseMinConns: defaultDatabaseMinConns,
databaseConnectionLifetime: defaultDatabaseConnectionLifetime,
runMigrations: defaultRunMigrations,
listenAddr: defaultListenAddr,
certFile: defaultCertFile,
certDomain: defaultCertDomain,
certKeyFile: defaultKeyFile,
cleanupFrequencyHours: defaultCleanupFrequencyHours,
cleanupArchiveReadDays: defaultCleanupArchiveReadDays,
cleanupArchiveUnreadDays: defaultCleanupArchiveUnreadDays,
cleanupArchiveBatchSize: defaultCleanupArchiveBatchSize,
cleanupRemoveSessionsDays: defaultCleanupRemoveSessionsDays,
pollingFrequency: defaultPollingFrequency,
batchSize: defaultBatchSize,
pollingScheduler: defaultPollingScheduler,
schedulerEntryFrequencyMinInterval: defaultSchedulerEntryFrequencyMinInterval,
schedulerEntryFrequencyMaxInterval: defaultSchedulerEntryFrequencyMaxInterval,
schedulerEntryFrequencyFactor: defaultSchedulerEntryFrequencyFactor,
schedulerRoundRobinMinInterval: defaultSchedulerRoundRobinMinInterval,
pollingParsingErrorLimit: defaultPollingParsingErrorLimit,
workerPoolSize: defaultWorkerPoolSize,
createAdmin: defaultCreateAdmin,
proxyHTTPClientTimeout: defaultProxyHTTPClientTimeout,
proxyOption: defaultProxyOption,
proxyMediaTypes: []string{defaultProxyMediaTypes},
proxyUrl: defaultProxyUrl,
fetchOdyseeWatchTime: defaultFetchOdyseeWatchTime,
fetchYouTubeWatchTime: defaultFetchYouTubeWatchTime,
youTubeEmbedUrlOverride: defaultYouTubeEmbedUrlOverride,
oauth2UserCreationAllowed: defaultOAuth2UserCreation,
oauth2ClientID: defaultOAuth2ClientID,
oauth2ClientSecret: defaultOAuth2ClientSecret,
oauth2RedirectURL: defaultOAuth2RedirectURL,
oidcDiscoveryEndpoint: defaultOAuth2OidcDiscoveryEndpoint,
oauth2Provider: defaultOAuth2Provider,
pocketConsumerKey: defaultPocketConsumerKey,
httpClientTimeout: defaultHTTPClientTimeout,
httpClientMaxBodySize: defaultHTTPClientMaxBodySize * 1024 * 1024,
httpClientProxy: defaultHTTPClientProxy,
httpClientUserAgent: defaultHTTPClientUserAgent,
httpServerTimeout: defaultHTTPServerTimeout,
authProxyHeader: defaultAuthProxyHeader,
authProxyUserCreation: defaultAuthProxyUserCreation,
maintenanceMode: defaultMaintenanceMode,
maintenanceMessage: defaultMaintenanceMessage,
metricsCollector: defaultMetricsCollector,
metricsRefreshInterval: defaultMetricsRefreshInterval,
metricsAllowedNetworks: []string{defaultMetricsAllowedNetworks},
metricsUsername: defaultMetricsUsername,
metricsPassword: defaultMetricsPassword,
watchdog: defaultWatchdog,
invidiousInstance: defaultInvidiousInstance,
proxyPrivateKey: randomKey,
webAuthn: defaultWebAuthn,
}
}
func (o *Options) LogFile() string {
return o.logFile
}
// LogDateTime returns true if the date/time should be displayed in log messages.
func (o *Options) LogDateTime() bool {
return o.logDateTime
}
// LogFormat returns the log format.
func (o *Options) LogFormat() string {
return o.logFormat
}
// LogLevel returns the log level.
func (o *Options) LogLevel() string {
return o.logLevel
}
// SetLogLevel sets the log level.
func (o *Options) SetLogLevel(level string) {
o.logLevel = level
}
// HasMaintenanceMode returns true if maintenance mode is enabled.
func (o *Options) HasMaintenanceMode() bool {
return o.maintenanceMode
}
// MaintenanceMessage returns maintenance message.
func (o *Options) MaintenanceMessage() string {
return o.maintenanceMessage
}
// HasServerTimingHeader returns true if server-timing headers enabled.
func (o *Options) HasServerTimingHeader() bool {
return o.serverTimingHeader
}
// BaseURL returns the application base URL with path.
func (o *Options) BaseURL() string {
return o.baseURL
}
// RootURL returns the base URL without path.
func (o *Options) RootURL() string {
return o.rootURL
}
// BasePath returns the application base path according to the base URL.
func (o *Options) BasePath() string {
return o.basePath
}
// IsDefaultDatabaseURL returns true if the default database URL is used.
func (o *Options) IsDefaultDatabaseURL() bool {
return o.databaseURL == defaultDatabaseURL
}
// DatabaseURL returns the database URL.
func (o *Options) DatabaseURL() string {
return o.databaseURL
}
// DatabaseMaxConns returns the maximum number of database connections.
func (o *Options) DatabaseMaxConns() int {
return o.databaseMaxConns
}
// DatabaseMinConns returns the minimum number of database connections.
func (o *Options) DatabaseMinConns() int {
return o.databaseMinConns
}
// DatabaseConnectionLifetime returns the maximum amount of time a connection may be reused.
func (o *Options) DatabaseConnectionLifetime() time.Duration {
return time.Duration(o.databaseConnectionLifetime) * time.Minute
}
// ListenAddr returns the listen address for the HTTP server.
func (o *Options) ListenAddr() string {
return o.listenAddr
}
// CertFile returns the SSL certificate filename if any.
func (o *Options) CertFile() string {
return o.certFile
}
// CertKeyFile returns the private key filename for custom SSL certificate.
func (o *Options) CertKeyFile() string {
return o.certKeyFile
}
// CertDomain returns the domain to use for Let's Encrypt certificate.
func (o *Options) CertDomain() string {
return o.certDomain
}
// CleanupFrequencyHours returns the interval in hours for cleanup jobs.
func (o *Options) CleanupFrequencyHours() int {
return o.cleanupFrequencyHours
}
// CleanupArchiveReadDays returns the number of days after which marking read items as removed.
func (o *Options) CleanupArchiveReadDays() int {
return o.cleanupArchiveReadDays
}
// CleanupArchiveUnreadDays returns the number of days after which marking unread items as removed.
func (o *Options) CleanupArchiveUnreadDays() int {
return o.cleanupArchiveUnreadDays
}
// CleanupArchiveBatchSize returns the number of entries to archive for each interval.
func (o *Options) CleanupArchiveBatchSize() int {
return o.cleanupArchiveBatchSize
}
// CleanupRemoveSessionsDays returns the number of days after which to remove sessions.
func (o *Options) CleanupRemoveSessionsDays() int {
return o.cleanupRemoveSessionsDays
}
// WorkerPoolSize returns the number of background worker.
func (o *Options) WorkerPoolSize() int {
return o.workerPoolSize
}
// PollingFrequency returns the interval to refresh feeds in the background.
func (o *Options) PollingFrequency() int {
return o.pollingFrequency
}
// BatchSize returns the number of feeds to send for background processing.
func (o *Options) BatchSize() int {
return o.batchSize
}
// PollingScheduler returns the scheduler used for polling feeds.
func (o *Options) PollingScheduler() string {
return o.pollingScheduler
}
// SchedulerEntryFrequencyMaxInterval returns the maximum interval in minutes for the entry frequency scheduler.
func (o *Options) SchedulerEntryFrequencyMaxInterval() int {
return o.schedulerEntryFrequencyMaxInterval
}
// SchedulerEntryFrequencyMinInterval returns the minimum interval in minutes for the entry frequency scheduler.
func (o *Options) SchedulerEntryFrequencyMinInterval() int {
return o.schedulerEntryFrequencyMinInterval
}
// SchedulerEntryFrequencyFactor returns the factor for the entry frequency scheduler.
func (o *Options) SchedulerEntryFrequencyFactor() int {
return o.schedulerEntryFrequencyFactor
}
func (o *Options) SchedulerRoundRobinMinInterval() int {
return o.schedulerRoundRobinMinInterval
}
// PollingParsingErrorLimit returns the limit of errors when to stop polling.
func (o *Options) PollingParsingErrorLimit() int {
return o.pollingParsingErrorLimit
}
// IsOAuth2UserCreationAllowed returns true if user creation is allowed for OAuth2 users.
func (o *Options) IsOAuth2UserCreationAllowed() bool {
return o.oauth2UserCreationAllowed
}
// OAuth2ClientID returns the OAuth2 Client ID.
func (o *Options) OAuth2ClientID() string {
return o.oauth2ClientID
}
// OAuth2ClientSecret returns the OAuth2 client secret.
func (o *Options) OAuth2ClientSecret() string {
return o.oauth2ClientSecret
}
// OAuth2RedirectURL returns the OAuth2 redirect URL.
func (o *Options) OAuth2RedirectURL() string {
return o.oauth2RedirectURL
}
// OIDCDiscoveryEndpoint returns the OAuth2 OIDC discovery endpoint.
func (o *Options) OIDCDiscoveryEndpoint() string {
return o.oidcDiscoveryEndpoint
}
// OAuth2Provider returns the name of the OAuth2 provider configured.
func (o *Options) OAuth2Provider() string {
return o.oauth2Provider
}
// HasHSTS returns true if HTTP Strict Transport Security is enabled.
func (o *Options) HasHSTS() bool {
return o.hsts
}
// RunMigrations returns true if the environment variable RUN_MIGRATIONS is not empty.
func (o *Options) RunMigrations() bool {
return o.runMigrations
}
// CreateAdmin returns true if the environment variable CREATE_ADMIN is not empty.
func (o *Options) CreateAdmin() bool {
return o.createAdmin
}
// AdminUsername returns the admin username if defined.
func (o *Options) AdminUsername() string {
return o.adminUsername
}
// AdminPassword returns the admin password if defined.
func (o *Options) AdminPassword() string {
return o.adminPassword
}
// FetchYouTubeWatchTime returns true if the YouTube video duration
// should be fetched and used as a reading time.
func (o *Options) FetchYouTubeWatchTime() bool {
return o.fetchYouTubeWatchTime
}
// YouTubeEmbedUrlOverride returns YouTube URL which will be used for embeds
func (o *Options) YouTubeEmbedUrlOverride() string {
return o.youTubeEmbedUrlOverride
}
// FetchOdyseeWatchTime returns true if the Odysee video duration
// should be fetched and used as a reading time.
func (o *Options) FetchOdyseeWatchTime() bool {
return o.fetchOdyseeWatchTime
}
// ProxyOption returns "none" to never proxy, "http-only" to proxy non-HTTPS, "all" to always proxy.
func (o *Options) ProxyOption() string {
return o.proxyOption
}
// ProxyMediaTypes returns a slice of media types to proxy.
func (o *Options) ProxyMediaTypes() []string {
return o.proxyMediaTypes
}
// ProxyUrl returns a string of a URL to use to proxy image requests
func (o *Options) ProxyUrl() string {
return o.proxyUrl
}
// ProxyHTTPClientTimeout returns the time limit in seconds before the proxy HTTP client cancel the request.
func (o *Options) ProxyHTTPClientTimeout() int {
return o.proxyHTTPClientTimeout
}
// HasHTTPService returns true if the HTTP service is enabled.
func (o *Options) HasHTTPService() bool {
return o.httpService
}
// HasSchedulerService returns true if the scheduler service is enabled.
func (o *Options) HasSchedulerService() bool {
return o.schedulerService
}
// PocketConsumerKey returns the Pocket Consumer Key if configured.
func (o *Options) PocketConsumerKey(defaultValue string) string {
if o.pocketConsumerKey != "" {
return o.pocketConsumerKey
}
return defaultValue
}
// HTTPClientTimeout returns the time limit in seconds before the HTTP client cancel the request.
func (o *Options) HTTPClientTimeout() int {
return o.httpClientTimeout
}
// HTTPClientMaxBodySize returns the number of bytes allowed for the HTTP client to transfer.
func (o *Options) HTTPClientMaxBodySize() int64 {
return o.httpClientMaxBodySize
}
// HTTPClientProxy returns the proxy URL for HTTP client.
func (o *Options) HTTPClientProxy() string {
return o.httpClientProxy
}
// HTTPServerTimeout returns the time limit in seconds before the HTTP server cancel the request.
func (o *Options) HTTPServerTimeout() int {
return o.httpServerTimeout
}
// HasHTTPClientProxyConfigured returns true if the HTTP proxy is configured.
func (o *Options) HasHTTPClientProxyConfigured() bool {
return o.httpClientProxy != ""
}
// AuthProxyHeader returns an HTTP header name that contains username for
// authentication using auth proxy.
func (o *Options) AuthProxyHeader() string {
return o.authProxyHeader
}
// IsAuthProxyUserCreationAllowed returns true if user creation is allowed for
// users authenticated using auth proxy.
func (o *Options) IsAuthProxyUserCreationAllowed() bool {
return o.authProxyUserCreation
}
// HasMetricsCollector returns true if metrics collection is enabled.
func (o *Options) HasMetricsCollector() bool {
return o.metricsCollector
}
// MetricsRefreshInterval returns the refresh interval in seconds.
func (o *Options) MetricsRefreshInterval() int {
return o.metricsRefreshInterval
}
// MetricsAllowedNetworks returns the list of networks allowed to connect to the metrics endpoint.
func (o *Options) MetricsAllowedNetworks() []string {
return o.metricsAllowedNetworks
}
func (o *Options) MetricsUsername() string {
return o.metricsUsername
}
func (o *Options) MetricsPassword() string {
return o.metricsPassword
}
// HTTPClientUserAgent returns the global User-Agent header for miniflux.
func (o *Options) HTTPClientUserAgent() string {
return o.httpClientUserAgent
}
// HasWatchdog returns true if the systemd watchdog is enabled.
func (o *Options) HasWatchdog() bool {
return o.watchdog
}
// InvidiousInstance returns the invidious instance used by miniflux
func (o *Options) InvidiousInstance() string {
return o.invidiousInstance
}
// ProxyPrivateKey returns the private key used by the media proxy
func (o *Options) ProxyPrivateKey() []byte {
return o.proxyPrivateKey
}
// WebAuthn returns true if WebAuthn logins are supported
func (o *Options) WebAuthn() bool {
return o.webAuthn
}
// SortedOptions returns options as a list of key value pairs, sorted by keys.
func (o *Options) SortedOptions(redactSecret bool) []*Option {
var keyValues = map[string]interface{}{
"ADMIN_PASSWORD": redactSecretValue(o.adminPassword, redactSecret),
"ADMIN_USERNAME": o.adminUsername,
"AUTH_PROXY_HEADER": o.authProxyHeader,
"AUTH_PROXY_USER_CREATION": o.authProxyUserCreation,
"BASE_PATH": o.basePath,
"BASE_URL": o.baseURL,
"BATCH_SIZE": o.batchSize,
"CERT_DOMAIN": o.certDomain,
"CERT_FILE": o.certFile,
"CLEANUP_ARCHIVE_BATCH_SIZE": o.cleanupArchiveBatchSize,
"CLEANUP_ARCHIVE_READ_DAYS": o.cleanupArchiveReadDays,
"CLEANUP_ARCHIVE_UNREAD_DAYS": o.cleanupArchiveUnreadDays,
"CLEANUP_FREQUENCY_HOURS": o.cleanupFrequencyHours,
"CLEANUP_REMOVE_SESSIONS_DAYS": o.cleanupRemoveSessionsDays,
"CREATE_ADMIN": o.createAdmin,
"DATABASE_CONNECTION_LIFETIME": o.databaseConnectionLifetime,
"DATABASE_MAX_CONNS": o.databaseMaxConns,
"DATABASE_MIN_CONNS": o.databaseMinConns,
"DATABASE_URL": redactSecretValue(o.databaseURL, redactSecret),
"DISABLE_HSTS": !o.hsts,
"DISABLE_HTTP_SERVICE": !o.httpService,
"DISABLE_SCHEDULER_SERVICE": !o.schedulerService,
"FETCH_YOUTUBE_WATCH_TIME": o.fetchYouTubeWatchTime,
"FETCH_ODYSEE_WATCH_TIME": o.fetchOdyseeWatchTime,
"HTTPS": o.HTTPS,
"HTTP_CLIENT_MAX_BODY_SIZE": o.httpClientMaxBodySize,
"HTTP_CLIENT_PROXY": o.httpClientProxy,
"HTTP_CLIENT_TIMEOUT": o.httpClientTimeout,
"HTTP_CLIENT_USER_AGENT": o.httpClientUserAgent,
"HTTP_SERVER_TIMEOUT": o.httpServerTimeout,
"HTTP_SERVICE": o.httpService,
"INVIDIOUS_INSTANCE": o.invidiousInstance,
"KEY_FILE": o.certKeyFile,
"LISTEN_ADDR": o.listenAddr,
"LOG_FILE": o.logFile,
"LOG_DATE_TIME": o.logDateTime,
"LOG_FORMAT": o.logFormat,
"LOG_LEVEL": o.logLevel,
"MAINTENANCE_MESSAGE": o.maintenanceMessage,
"MAINTENANCE_MODE": o.maintenanceMode,
"METRICS_ALLOWED_NETWORKS": strings.Join(o.metricsAllowedNetworks, ","),
"METRICS_COLLECTOR": o.metricsCollector,
"METRICS_PASSWORD": redactSecretValue(o.metricsPassword, redactSecret),
"METRICS_REFRESH_INTERVAL": o.metricsRefreshInterval,
"METRICS_USERNAME": o.metricsUsername,
"OAUTH2_CLIENT_ID": o.oauth2ClientID,
"OAUTH2_CLIENT_SECRET": redactSecretValue(o.oauth2ClientSecret, redactSecret),
"OAUTH2_OIDC_DISCOVERY_ENDPOINT": o.oidcDiscoveryEndpoint,
"OAUTH2_PROVIDER": o.oauth2Provider,
"OAUTH2_REDIRECT_URL": o.oauth2RedirectURL,
"OAUTH2_USER_CREATION": o.oauth2UserCreationAllowed,
"POCKET_CONSUMER_KEY": redactSecretValue(o.pocketConsumerKey, redactSecret),
"POLLING_FREQUENCY": o.pollingFrequency,
"POLLING_PARSING_ERROR_LIMIT": o.pollingParsingErrorLimit,
"POLLING_SCHEDULER": o.pollingScheduler,
"PROXY_HTTP_CLIENT_TIMEOUT": o.proxyHTTPClientTimeout,
"PROXY_MEDIA_TYPES": o.proxyMediaTypes,
"PROXY_OPTION": o.proxyOption,
"PROXY_PRIVATE_KEY": redactSecretValue(string(o.proxyPrivateKey), redactSecret),
"PROXY_URL": o.proxyUrl,
"ROOT_URL": o.rootURL,
"RUN_MIGRATIONS": o.runMigrations,
"SCHEDULER_ENTRY_FREQUENCY_MAX_INTERVAL": o.schedulerEntryFrequencyMaxInterval,
"SCHEDULER_ENTRY_FREQUENCY_MIN_INTERVAL": o.schedulerEntryFrequencyMinInterval,
"SCHEDULER_ENTRY_FREQUENCY_FACTOR": o.schedulerEntryFrequencyFactor,
"SCHEDULER_ROUND_ROBIN_MIN_INTERVAL": o.schedulerRoundRobinMinInterval,
"SCHEDULER_SERVICE": o.schedulerService,
"SERVER_TIMING_HEADER": o.serverTimingHeader,
"WATCHDOG": o.watchdog,
"WORKER_POOL_SIZE": o.workerPoolSize,
"YOUTUBE_EMBED_URL_OVERRIDE": o.youTubeEmbedUrlOverride,
"WEBAUTHN": o.webAuthn,
}
keys := make([]string, 0, len(keyValues))
for key := range keyValues {
keys = append(keys, key)
}
sort.Strings(keys)
var sortedOptions []*Option
for _, key := range keys {
sortedOptions = append(sortedOptions, &Option{Key: key, Value: keyValues[key]})
}
return sortedOptions
}
func (o *Options) String() string {
var builder strings.Builder
for _, option := range o.SortedOptions(false) {
fmt.Fprintf(&builder, "%s=%v\n", option.Key, option.Value)
}
return builder.String()
}
func redactSecretValue(value string, redactSecret bool) string {
if redactSecret && value != "" {
return ""
}
return value
}
miniflux-2.0.51/internal/config/parser.go 0000664 0000000 0000000 00000027504 14546226260 0020355 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"
)
// Parser handles configuration parsing.
type Parser struct {
opts *Options
}
// NewParser returns a new Parser.
func NewParser() *Parser {
return &Parser{
opts: NewOptions(),
}
}
// ParseEnvironmentVariables loads configuration values from environment variables.
func (p *Parser) ParseEnvironmentVariables() (*Options, error) {
err := p.parseLines(os.Environ())
if err != nil {
return nil, err
}
return p.opts, nil
}
// ParseFile loads configuration values from a local file.
func (p *Parser) ParseFile(filename string) (*Options, error) {
fp, err := os.Open(filename)
if err != nil {
return nil, err
}
defer fp.Close()
err = p.parseLines(p.parseFileContent(fp))
if err != nil {
return nil, err
}
return p.opts, nil
}
func (p *Parser) parseFileContent(r io.Reader) (lines []string) {
scanner := bufio.NewScanner(r)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if len(line) > 0 && !strings.HasPrefix(line, "#") && strings.Index(line, "=") > 0 {
lines = append(lines, line)
}
}
return lines
}
func (p *Parser) parseLines(lines []string) (err error) {
var port string
for _, line := range lines {
fields := strings.SplitN(line, "=", 2)
key := strings.TrimSpace(fields[0])
value := strings.TrimSpace(fields[1])
switch key {
case "LOG_FILE":
p.opts.logFile = parseString(value, defaultLogFile)
case "LOG_DATE_TIME":
p.opts.logDateTime = parseBool(value, defaultLogDateTime)
case "LOG_LEVEL":
parsedValue := parseString(value, defaultLogLevel)
if parsedValue == "debug" || parsedValue == "info" || parsedValue == "warning" || parsedValue == "error" {
p.opts.logLevel = parsedValue
}
case "LOG_FORMAT":
parsedValue := parseString(value, defaultLogFormat)
if parsedValue == "json" || parsedValue == "text" {
p.opts.logFormat = parsedValue
}
case "DEBUG":
parsedValue := parseBool(value, defaultDebug)
if parsedValue {
p.opts.logLevel = "debug"
}
case "SERVER_TIMING_HEADER":
p.opts.serverTimingHeader = parseBool(value, defaultTiming)
case "BASE_URL":
p.opts.baseURL, p.opts.rootURL, p.opts.basePath, err = parseBaseURL(value)
if err != nil {
return err
}
case "PORT":
port = value
case "LISTEN_ADDR":
p.opts.listenAddr = parseString(value, defaultListenAddr)
case "DATABASE_URL":
p.opts.databaseURL = parseString(value, defaultDatabaseURL)
case "DATABASE_URL_FILE":
p.opts.databaseURL = readSecretFile(value, defaultDatabaseURL)
case "DATABASE_MAX_CONNS":
p.opts.databaseMaxConns = parseInt(value, defaultDatabaseMaxConns)
case "DATABASE_MIN_CONNS":
p.opts.databaseMinConns = parseInt(value, defaultDatabaseMinConns)
case "DATABASE_CONNECTION_LIFETIME":
p.opts.databaseConnectionLifetime = parseInt(value, defaultDatabaseConnectionLifetime)
case "RUN_MIGRATIONS":
p.opts.runMigrations = parseBool(value, defaultRunMigrations)
case "DISABLE_HSTS":
p.opts.hsts = !parseBool(value, defaultHSTS)
case "HTTPS":
p.opts.HTTPS = parseBool(value, defaultHTTPS)
case "DISABLE_SCHEDULER_SERVICE":
p.opts.schedulerService = !parseBool(value, defaultSchedulerService)
case "DISABLE_HTTP_SERVICE":
p.opts.httpService = !parseBool(value, defaultHTTPService)
case "CERT_FILE":
p.opts.certFile = parseString(value, defaultCertFile)
case "KEY_FILE":
p.opts.certKeyFile = parseString(value, defaultKeyFile)
case "CERT_DOMAIN":
p.opts.certDomain = parseString(value, defaultCertDomain)
case "CLEANUP_FREQUENCY_HOURS":
p.opts.cleanupFrequencyHours = parseInt(value, defaultCleanupFrequencyHours)
case "CLEANUP_ARCHIVE_READ_DAYS":
p.opts.cleanupArchiveReadDays = parseInt(value, defaultCleanupArchiveReadDays)
case "CLEANUP_ARCHIVE_UNREAD_DAYS":
p.opts.cleanupArchiveUnreadDays = parseInt(value, defaultCleanupArchiveUnreadDays)
case "CLEANUP_ARCHIVE_BATCH_SIZE":
p.opts.cleanupArchiveBatchSize = parseInt(value, defaultCleanupArchiveBatchSize)
case "CLEANUP_REMOVE_SESSIONS_DAYS":
p.opts.cleanupRemoveSessionsDays = parseInt(value, defaultCleanupRemoveSessionsDays)
case "WORKER_POOL_SIZE":
p.opts.workerPoolSize = parseInt(value, defaultWorkerPoolSize)
case "POLLING_FREQUENCY":
p.opts.pollingFrequency = parseInt(value, defaultPollingFrequency)
case "BATCH_SIZE":
p.opts.batchSize = parseInt(value, defaultBatchSize)
case "POLLING_SCHEDULER":
p.opts.pollingScheduler = strings.ToLower(parseString(value, defaultPollingScheduler))
case "SCHEDULER_ENTRY_FREQUENCY_MAX_INTERVAL":
p.opts.schedulerEntryFrequencyMaxInterval = parseInt(value, defaultSchedulerEntryFrequencyMaxInterval)
case "SCHEDULER_ENTRY_FREQUENCY_MIN_INTERVAL":
p.opts.schedulerEntryFrequencyMinInterval = parseInt(value, defaultSchedulerEntryFrequencyMinInterval)
case "SCHEDULER_ENTRY_FREQUENCY_FACTOR":
p.opts.schedulerEntryFrequencyFactor = parseInt(value, defaultSchedulerEntryFrequencyFactor)
case "SCHEDULER_ROUND_ROBIN_MIN_INTERVAL":
p.opts.schedulerRoundRobinMinInterval = parseInt(value, defaultSchedulerRoundRobinMinInterval)
case "POLLING_PARSING_ERROR_LIMIT":
p.opts.pollingParsingErrorLimit = parseInt(value, defaultPollingParsingErrorLimit)
// kept for compatibility purpose
case "PROXY_IMAGES":
p.opts.proxyOption = parseString(value, defaultProxyOption)
case "PROXY_HTTP_CLIENT_TIMEOUT":
p.opts.proxyHTTPClientTimeout = parseInt(value, defaultProxyHTTPClientTimeout)
case "PROXY_OPTION":
p.opts.proxyOption = parseString(value, defaultProxyOption)
case "PROXY_MEDIA_TYPES":
p.opts.proxyMediaTypes = parseStringList(value, []string{defaultProxyMediaTypes})
// kept for compatibility purpose
case "PROXY_IMAGE_URL":
p.opts.proxyUrl = parseString(value, defaultProxyUrl)
case "PROXY_URL":
p.opts.proxyUrl = parseString(value, defaultProxyUrl)
case "CREATE_ADMIN":
p.opts.createAdmin = parseBool(value, defaultCreateAdmin)
case "ADMIN_USERNAME":
p.opts.adminUsername = parseString(value, defaultAdminUsername)
case "ADMIN_USERNAME_FILE":
p.opts.adminUsername = readSecretFile(value, defaultAdminUsername)
case "ADMIN_PASSWORD":
p.opts.adminPassword = parseString(value, defaultAdminPassword)
case "ADMIN_PASSWORD_FILE":
p.opts.adminPassword = readSecretFile(value, defaultAdminPassword)
case "POCKET_CONSUMER_KEY":
p.opts.pocketConsumerKey = parseString(value, defaultPocketConsumerKey)
case "POCKET_CONSUMER_KEY_FILE":
p.opts.pocketConsumerKey = readSecretFile(value, defaultPocketConsumerKey)
case "OAUTH2_USER_CREATION":
p.opts.oauth2UserCreationAllowed = parseBool(value, defaultOAuth2UserCreation)
case "OAUTH2_CLIENT_ID":
p.opts.oauth2ClientID = parseString(value, defaultOAuth2ClientID)
case "OAUTH2_CLIENT_ID_FILE":
p.opts.oauth2ClientID = readSecretFile(value, defaultOAuth2ClientID)
case "OAUTH2_CLIENT_SECRET":
p.opts.oauth2ClientSecret = parseString(value, defaultOAuth2ClientSecret)
case "OAUTH2_CLIENT_SECRET_FILE":
p.opts.oauth2ClientSecret = readSecretFile(value, defaultOAuth2ClientSecret)
case "OAUTH2_REDIRECT_URL":
p.opts.oauth2RedirectURL = parseString(value, defaultOAuth2RedirectURL)
case "OAUTH2_OIDC_DISCOVERY_ENDPOINT":
p.opts.oidcDiscoveryEndpoint = parseString(value, defaultOAuth2OidcDiscoveryEndpoint)
case "OAUTH2_PROVIDER":
p.opts.oauth2Provider = parseString(value, defaultOAuth2Provider)
case "HTTP_CLIENT_TIMEOUT":
p.opts.httpClientTimeout = parseInt(value, defaultHTTPClientTimeout)
case "HTTP_CLIENT_MAX_BODY_SIZE":
p.opts.httpClientMaxBodySize = int64(parseInt(value, defaultHTTPClientMaxBodySize) * 1024 * 1024)
case "HTTP_CLIENT_PROXY":
p.opts.httpClientProxy = parseString(value, defaultHTTPClientProxy)
case "HTTP_CLIENT_USER_AGENT":
p.opts.httpClientUserAgent = parseString(value, defaultHTTPClientUserAgent)
case "HTTP_SERVER_TIMEOUT":
p.opts.httpServerTimeout = parseInt(value, defaultHTTPServerTimeout)
case "AUTH_PROXY_HEADER":
p.opts.authProxyHeader = parseString(value, defaultAuthProxyHeader)
case "AUTH_PROXY_USER_CREATION":
p.opts.authProxyUserCreation = parseBool(value, defaultAuthProxyUserCreation)
case "MAINTENANCE_MODE":
p.opts.maintenanceMode = parseBool(value, defaultMaintenanceMode)
case "MAINTENANCE_MESSAGE":
p.opts.maintenanceMessage = parseString(value, defaultMaintenanceMessage)
case "METRICS_COLLECTOR":
p.opts.metricsCollector = parseBool(value, defaultMetricsCollector)
case "METRICS_REFRESH_INTERVAL":
p.opts.metricsRefreshInterval = parseInt(value, defaultMetricsRefreshInterval)
case "METRICS_ALLOWED_NETWORKS":
p.opts.metricsAllowedNetworks = parseStringList(value, []string{defaultMetricsAllowedNetworks})
case "METRICS_USERNAME":
p.opts.metricsUsername = parseString(value, defaultMetricsUsername)
case "METRICS_USERNAME_FILE":
p.opts.metricsUsername = readSecretFile(value, defaultMetricsUsername)
case "METRICS_PASSWORD":
p.opts.metricsPassword = parseString(value, defaultMetricsPassword)
case "METRICS_PASSWORD_FILE":
p.opts.metricsPassword = readSecretFile(value, defaultMetricsPassword)
case "FETCH_ODYSEE_WATCH_TIME":
p.opts.fetchOdyseeWatchTime = parseBool(value, defaultFetchOdyseeWatchTime)
case "FETCH_YOUTUBE_WATCH_TIME":
p.opts.fetchYouTubeWatchTime = parseBool(value, defaultFetchYouTubeWatchTime)
case "YOUTUBE_EMBED_URL_OVERRIDE":
p.opts.youTubeEmbedUrlOverride = parseString(value, defaultYouTubeEmbedUrlOverride)
case "WATCHDOG":
p.opts.watchdog = parseBool(value, defaultWatchdog)
case "INVIDIOUS_INSTANCE":
p.opts.invidiousInstance = parseString(value, defaultInvidiousInstance)
case "PROXY_PRIVATE_KEY":
randomKey := make([]byte, 16)
rand.Read(randomKey)
p.opts.proxyPrivateKey = parseBytes(value, randomKey)
case "WEBAUTHN":
p.opts.webAuthn = parseBool(value, defaultWebAuthn)
}
}
if port != "" {
p.opts.listenAddr = ":" + port
}
return nil
}
func parseBaseURL(value string) (string, string, string, error) {
if value == "" {
return defaultBaseURL, defaultRootURL, "", nil
}
if value[len(value)-1:] == "/" {
value = value[:len(value)-1]
}
parsedURL, err := url.Parse(value)
if err != nil {
return "", "", "", fmt.Errorf("config: invalid BASE_URL: %w", err)
}
scheme := strings.ToLower(parsedURL.Scheme)
if scheme != "https" && scheme != "http" {
return "", "", "", errors.New("config: invalid BASE_URL: scheme must be http or https")
}
basePath := parsedURL.Path
parsedURL.Path = ""
return value, parsedURL.String(), basePath, nil
}
func parseBool(value string, fallback bool) bool {
if value == "" {
return fallback
}
value = strings.ToLower(value)
if value == "1" || value == "yes" || value == "true" || value == "on" {
return true
}
return false
}
func parseInt(value string, fallback int) int {
if value == "" {
return fallback
}
v, err := strconv.Atoi(value)
if err != nil {
return fallback
}
return v
}
func parseString(value string, fallback string) string {
if value == "" {
return fallback
}
return value
}
func parseStringList(value string, fallback []string) []string {
if value == "" {
return fallback
}
var strList []string
strMap := make(map[string]bool)
items := strings.Split(value, ",")
for _, item := range items {
itemValue := strings.TrimSpace(item)
if _, found := strMap[itemValue]; !found {
strMap[itemValue] = true
strList = append(strList, itemValue)
}
}
return strList
}
func parseBytes(value string, fallback []byte) []byte {
if value == "" {
return fallback
}
return []byte(value)
}
func readSecretFile(filename, fallback string) string {
data, err := os.ReadFile(filename)
if err != nil {
return fallback
}
value := string(bytes.TrimSpace(data))
if value == "" {
return fallback
}
return value
}
miniflux-2.0.51/internal/config/parser_test.go 0000664 0000000 0000000 00000002722 14546226260 0021407 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 (
"testing"
)
func TestParseBoolValue(t *testing.T) {
scenarios := map[string]bool{
"": true,
"1": true,
"Yes": true,
"yes": true,
"True": true,
"true": true,
"on": true,
"false": false,
"off": false,
"invalid": false,
}
for input, expected := range scenarios {
result := parseBool(input, true)
if result != expected {
t.Errorf(`Unexpected result for %q, got %v instead of %v`, input, result, expected)
}
}
}
func TestParseStringValueWithUnsetVariable(t *testing.T) {
if parseString("", "defaultValue") != "defaultValue" {
t.Errorf(`Unset variables should returns the default value`)
}
}
func TestParseStringValue(t *testing.T) {
if parseString("test", "defaultValue") != "test" {
t.Errorf(`Defined variables should returns the specified value`)
}
}
func TestParseIntValueWithUnsetVariable(t *testing.T) {
if parseInt("", 42) != 42 {
t.Errorf(`Unset variables should returns the default value`)
}
}
func TestParseIntValueWithInvalidInput(t *testing.T) {
if parseInt("invalid integer", 42) != 42 {
t.Errorf(`Invalid integer should returns the default value`)
}
}
func TestParseIntValue(t *testing.T) {
if parseInt("2018", 42) != 2018 {
t.Errorf(`Defined variables should returns the specified value`)
}
}
miniflux-2.0.51/internal/crypto/ 0000775 0000000 0000000 00000000000 14546226260 0016575 5 ustar 00root root 0000000 0000000 miniflux-2.0.51/internal/crypto/crypto.go 0000664 0000000 0000000 00000002640 14546226260 0020446 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"
"encoding/base64"
"encoding/hex"
"fmt"
"golang.org/x/crypto/bcrypt"
)
// HashFromBytes returns a SHA-256 checksum of the input.
func HashFromBytes(value []byte) string {
sum := sha256.Sum256(value)
return fmt.Sprintf("%x", sum)
}
// Hash returns a SHA-256 checksum of a string.
func Hash(value string) string {
return HashFromBytes([]byte(value))
}
// GenerateRandomBytes returns random bytes.
func GenerateRandomBytes(size int) []byte {
b := make([]byte, size)
if _, err := rand.Read(b); err != nil {
panic(err)
}
return b
}
// GenerateRandomString returns a random string.
func GenerateRandomString(size int) string {
return base64.URLEncoding.EncodeToString(GenerateRandomBytes(size))
}
// 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))
}
miniflux-2.0.51/internal/database/ 0000775 0000000 0000000 00000000000 14546226260 0017021 5 ustar 00root root 0000000 0000000 miniflux-2.0.51/internal/database/database.go 0000664 0000000 0000000 00000004167 14546226260 0021124 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"
"time"
// Postgresql driver import
_ "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
}
// Migrate executes database migrations.
func Migrate(db *sql.DB) error {
var currentVersion int
db.QueryRow(`SELECT version FROM schema_version`).Scan(¤tVersion)
slog.Debug("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(`DELETE FROM 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
}
miniflux-2.0.51/internal/database/migrations.go 0000664 0000000 0000000 00000057421 14546226260 0021535 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"
)
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 not null,
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 not null,
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 not null,
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 not null,
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 not null,
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 not null,
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 not null,
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) {
sql := `
CREATE EXTENSION IF NOT EXISTS hstore;
ALTER TABLE users ADD COLUMN extra hstore;
CREATE INDEX users_extra_idx ON users using gin(extra);
`
_, err = tx.Exec(sql)
return err
},
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';
ALTER TABLE integrations ADD COLUMN wallabag_url text default '';
ALTER TABLE integrations ADD COLUMN wallabag_client_id text default '';
ALTER TABLE integrations ADD COLUMN wallabag_client_secret text default '';
ALTER TABLE integrations ADD COLUMN wallabag_username text default '';
ALTER TABLE integrations 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';
ALTER TABLE integrations ADD COLUMN nunux_keeper_url text default '';
ALTER TABLE integrations 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';
ALTER TABLE integrations ADD COLUMN pocket_access_token text default '';
ALTER TABLE integrations 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 '';
ALTER TABLE feeds 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 not null,
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) {
_, 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
}
_, 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) {
_, err = tx.Exec(`
ALTER TABLE users DROP COLUMN extra;
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);
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';
ALTER TABLE integrations ADD COLUMN telegram_bot_token text default '';
ALTER TABLE integrations 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';
ALTER TABLE integrations ADD COLUMN googlereader_username text default '';
ALTER TABLE integrations 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';
ALTER TABLE integrations ADD COLUMN espial_url text default '';
ALTER TABLE integrations ADD COLUMN espial_api_key text default '';
ALTER TABLE integrations 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';
ALTER TABLE integrations ADD COLUMN linkding_url text default '';
ALTER TABLE integrations 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;
ALTER TABLE users 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';
ALTER TABLE integrations ADD COLUMN matrix_bot_user text default '';
ALTER TABLE integrations ADD COLUMN matrix_bot_password text default '';
ALTER TABLE integrations ADD COLUMN matrix_bot_url text default '';
ALTER TABLE integrations 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 TABLE users 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';
ALTER TABLE integrations ADD COLUMN notion_token text default '';
ALTER TABLE integrations 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';
ALTER TABLE integrations 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';
ALTER TABLE integrations ADD COLUMN apprise_url text default '';
ALTER TABLE integrations 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';
ALTER TABLE integrations ADD COLUMN shiori_url text default '';
ALTER TABLE integrations ADD COLUMN shiori_username text default '';
ALTER TABLE integrations 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';
ALTER TABLE integrations ADD COLUMN shaarli_url text default '';
ALTER TABLE integrations 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';
ALTER TABLE integrations ADD COLUMN webhook_url text default '';
ALTER TABLE integrations 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;
ALTER TABLE integrations ADD COLUMN telegram_bot_disable_web_page_preview bool default 'f';
ALTER TABLE integrations 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';
ALTER TABLE integrations 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';
ALTER TABLE integrations ADD COLUMN omnivore_api_key text default '';
ALTER TABLE integrations ADD COLUMN omnivore_url text default '';
`
_, err = tx.Exec(sql)
return
},
}
miniflux-2.0.51/internal/fever/ 0000775 0000000 0000000 00000000000 14546226260 0016364 5 ustar 00root root 0000000 0000000 miniflux-2.0.51/internal/fever/handler.go 0000664 0000000 0000000 00000036130 14546226260 0020333 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/model"
"miniflux.app/v2/internal/proxy"
"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)
for _, f := range feeds {
subscripion := feed{
ID: f.ID,
Title: f.Title,
URL: f.FeedURL,
SiteURL: f.SiteURL,
IsSpark: 0,
LastUpdated: f.CheckedAt.Unix(),
}
if f.Icon != nil {
subscripion.FaviconID = f.Icon.IconID
}
result.Feeds = append(result.Feeds, subscripion)
}
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)
builder.WithSorting("id", model.DefaultSortingDirection)
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)
}
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)
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: proxy.AbsoluteProxyRewriter(h.router, r.Host, 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
}
var itemIDs []string
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
}
var itemsIDs []string
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.ToggleBookmark(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.ToggleBookmark(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)
for _, feed := range feeds {
feedsGroupedByCategory[feed.Category.ID] = append(feedsGroupedByCategory[feed.Category.ID], strconv.FormatInt(feed.ID, 10))
}
result := make([]feedsGroups, 0)
for categoryID, feedIDs := range feedsGroupedByCategory {
result = append(result, feedsGroups{
GroupID: categoryID,
FeedIDs: strings.Join(feedIDs, ","),
})
}
return result
}
miniflux-2.0.51/internal/fever/middleware.go 0000664 0000000 0000000 00000004266 14546226260 0021040 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))
})
}
miniflux-2.0.51/internal/fever/response.go 0000664 0000000 0000000 00000005404 14546226260 0020554 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"`
}
miniflux-2.0.51/internal/googlereader/ 0000775 0000000 0000000 00000000000 14546226260 0017714 5 ustar 00root root 0000000 0000000 miniflux-2.0.51/internal/googlereader/handler.go 0000664 0000000 0000000 00000125140 14546226260 0021663 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"
"strings"
"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/model"
"miniflux.app/v2/internal/proxy"
"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/urllib"
"miniflux.app/v2/internal/validator"
"github.com/gorilla/mux"
)
type handler struct {
store *storage.Storage
router *mux.Router
}
const (
// StreamPrefix is the prefix for astreams (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/"
// Read is the suffix for read stream
Read = "read"
// Starred is the suffix for starred stream
Starred = "starred"
// ReadingList is the suffix for reading list stream
ReadingList = "reading-list"
// KeptUnread is the suffix for kept unread stream
KeptUnread = "kept-unread"
// Broadcast is the suffix for broadcast stream
Broadcast = "broadcast"
// BroadcastFriends is the suffix for broadcast friends stream
BroadcastFriends = "broadcast-friends"
// Like is the suffix for like stream
Like = "like"
// EntryIDLong is the long entry id representation
EntryIDLong = "tag:google.com,2005:reader/item/%016x"
)
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"
)
// StreamType represents the possible stream types
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()
}
}
// RequestModifiers are the parsed request parameters.
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))
var streamStr []string
for _, s := range r.Streams {
streamStr = append(streamStr, s.String())
}
results = append(results, fmt.Sprintf("Streams: [%s]", strings.Join(streamStr, ", ")))
var exclusions []string
for _, s := range r.ExcludeTargets {
exclusions = append(exclusions, s.String())
}
results = append(results, fmt.Sprintf("Exclusions: [%s]", strings.Join(exclusions, ", ")))
var filters []string
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, fmt.Sprintf("Sort Direction: %s", r.SortDirection))
results = append(results, fmt.Sprintf("Continuation Token: %s", 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, "; ")
}
// 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.handleCORS)
sr.Use(middleware.apiKeyAuth)
sr.Methods(http.MethodOptions)
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.PathPrefix("/").HandlerFunc(handler.serveHandler).Methods(http.MethodPost, http.MethodGet).Name("GoogleReaderApiEndpoint")
}
func getStreamFilterModifiers(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
}
func getStream(streamID string, userID int64) (Stream, error) {
if strings.HasPrefix(streamID, FeedPrefix) {
return Stream{Type: FeedStream, ID: strings.TrimPrefix(streamID, FeedPrefix)}, nil
} else if 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 Read:
return Stream{ReadStream, ""}, nil
case Starred:
return Stream{StarredStream, ""}, nil
case ReadingList:
return Stream{ReadingListStream, ""}, nil
case KeptUnread:
return Stream{KeptUnreadStream, ""}, nil
case Broadcast:
return Stream{BroadcastStream, ""}, nil
case BroadcastFriends:
return Stream{BroadcastFriendsStream, ""}, nil
case Like:
return Stream{LikeStream, ""}, nil
default:
return Stream{NoStream, ""}, fmt.Errorf("googlereader: unknown stream with id: %s", id)
}
} else if strings.HasPrefix(streamID, fmt.Sprintf(UserLabelPrefix, userID)) || strings.HasPrefix(streamID, LabelPrefix) {
id := strings.TrimPrefix(streamID, fmt.Sprintf(UserLabelPrefix, userID))
id = strings.TrimPrefix(id, LabelPrefix)
return Stream{LabelStream, id}, nil
} else if streamID == "" {
return Stream{NoStream, ""}, nil
}
return Stream{NoStream, ""}, fmt.Errorf("googlereader: unknown stream type: %s", streamID)
}
func getStreams(streamIDs []string, userID int64) ([]Stream, error) {
streams := make([]Stream, 0)
for _, streamID := range streamIDs {
stream, err := getStream(streamID, userID)
if err != nil {
return []Stream{}, err
}
streams = append(streams, stream)
}
return streams, nil
}
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 ad %s should not be supplied simultaneously", KeptUnread, Read)
}
tags[ReadStream] = true
case KeptUnreadStream:
if _, ok := tags[ReadStream]; ok {
return nil, fmt.Errorf("googlereader: %s ad %s should not be supplied simultaneously", KeptUnread, Read)
}
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 ad %s should not be supplied simultaneously", KeptUnread, Read)
}
tags[ReadStream] = false
case KeptUnreadStream:
if _, ok := tags[ReadStream]; ok {
return nil, fmt.Errorf("googlereader: %s ad %s should not be supplied simultaneously", KeptUnread, Read)
}
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", Starred)
}
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 getItemIDs(r *http.Request) ([]int64, error) {
items := r.Form[ParamItemIDs]
if len(items) == 0 {
return nil, fmt.Errorf("googlereader: no items requested")
}
itemIDs := make([]int64, len(items))
for i, item := range items {
var itemID int64
_, err := fmt.Sscanf(item, EntryIDLong, &itemID)
if err != nil {
itemID, err = strconv.ParseInt(item, 16, 64)
if err != nil {
return nil, fmt.Errorf("googlereader: could not parse item: %v", item)
}
}
itemIDs[i] = itemID
}
return itemIDs, nil
}
func checkOutputFormat(w http.ResponseWriter, 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" {
err := fmt.Errorf("googlereader: only json output is supported")
return err
}
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 := login{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.GoolgeReaderToken(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)),
slog.String("token", token),
)
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 = fmt.Errorf("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 := getItemIDs(r)
if err != nil {
json.ServerError(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.SetEntriesBookmarkedState(userID, unstarredEntryIDs, false)
if err != nil {
json.ServerError(w, r, err)
return
}
}
if len(starredEntryIDs) > 0 {
err = h.store.SetEntriesBookmarkedState(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)
}()
}
}
OK(w, r)
}
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.WithProxy(config.Opts.HTTPClientProxy())
var rssBridgeURL string
if intg, err := h.store.Integration(userID); err == nil && intg != nil && intg.RSSBridgeEnabled {
rssBridgeURL = intg.RSSBridgeURL
}
subscriptions, localizedError := mfs.NewSubscriptionFinder(requestBuilder).FindSubscriptions(feedURL, rssBridgeURL)
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(category Stream, store *storage.Storage, userID int64) (*model.Category, error) {
if category.ID == "" {
return store.FirstCategory(userID)
} else if store.CategoryTitleExists(userID, category.ID) {
return store.CategoryByTitle(userID, category.ID)
} else {
catRequest := model.CategoryRequest{
Title: category.ID,
}
return store.CreateCategory(userID, &catRequest)
}
}
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 err != 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(stream Stream, title string, store *storage.Storage, userID int64) error {
if title == "" {
return errors.New("empty title")
}
feed, err := getFeed(stream, store, userID)
if err != nil {
return err
}
feedModification := model.FeedModificationRequest{
Title: &title,
}
feedModification.Patch(feed)
return store.UpdateFeed(feed)
}
func move(stream Stream, destination Stream, store *storage.Storage, userID int64) error {
feed, err := getFeed(stream, store, userID)
if err != nil {
return err
}
category, err := getOrCreateCategory(destination, store, userID)
if err != nil {
return err
}
feedModification := model.FeedModificationRequest{
CategoryID: &category.ID,
}
feedModification.Patch(feed)
return store.UpdateFeed(feed)
}
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 {
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 {
json.ServerError(w, r, err)
return
}
}
default:
json.ServerError(w, r, fmt.Errorf("googlereader: unrecognized action %s", action))
return
}
OK(w, r)
}
func (h *handler) streamItemContentsHandler(w http.ResponseWriter, r *http.Request) {
userID := request.UserID(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(w, r); err != nil {
json.ServerError(w, r, err)
return
}
err := r.ParseForm()
if err != nil {
json.ServerError(w, r, err)
return
}
var user *model.User
if user, err = h.store.UserByID(userID); err != nil {
json.ServerError(w, r, err)
return
}
requestModifiers, err := getStreamFilterModifiers(r)
if err != nil {
json.ServerError(w, r, err)
return
}
userReadingList := fmt.Sprintf(UserStreamPrefix, userID) + ReadingList
userRead := fmt.Sprintf(UserStreamPrefix, userID) + Read
userStarred := fmt.Sprintf(UserStreamPrefix, userID) + Starred
itemIDs, err := getItemIDs(r)
if err != nil {
json.ServerError(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.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
}
if len(entries) == 0 {
json.ServerError(w, r, fmt.Errorf("googlereader: no items returned from the database"))
return
}
result := streamContentItems{
Direction: "ltr",
ID: fmt.Sprintf("feed/%d", entries[0].FeedID),
Title: entries[0].Feed.Title,
Alternate: []contentHREFType{
{
HREF: entries[0].Feed.SiteURL,
Type: "text/html",
},
},
Updated: time.Now().Unix(),
Self: []contentHREF{
{
HREF: config.Opts.RootURL() + route.Path(h.router, "StreamItemsContents"),
},
},
Author: user.Username,
}
contentItems := make([]contentItem, len(entries))
for i, entry := range entries {
enclosures := make([]contentItemEnclosure, 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 = proxy.AbsoluteProxyRewriter(h.router, r.Host, entry.Content)
proxyOption := config.Opts.ProxyOption()
for i := range entry.Enclosures {
if proxyOption == "all" || proxyOption != "none" && !urllib.IsHTTPS(entry.Enclosures[i].URL) {
for _, mediaType := range config.Opts.ProxyMediaTypes() {
if strings.HasPrefix(entry.Enclosures[i].MimeType, mediaType+"/") {
entry.Enclosures[i].URL = proxy.AbsoluteProxifyURL(h.router, r.Host, entry.Enclosures[i].URL)
break
}
}
}
}
contentItems[i] = contentItem{
ID: fmt.Sprintf(EntryIDLong, entry.ID),
Title: entry.Title,
Author: entry.Author,
TimestampUsec: fmt.Sprintf("%d", entry.Date.UnixNano()/(int64(time.Microsecond)/int64(time.Nanosecond))),
CrawlTimeMsec: fmt.Sprintf("%d", entry.Date.UnixNano()/(int64(time.Microsecond)/int64(time.Nanosecond))),
Published: entry.Date.Unix(),
Updated: entry.Date.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("feed/%d", entry.FeedID),
Title: entry.Feed.Title,
HTMLUrl: entry.Feed.SiteURL,
},
Enclosure: enclosures,
}
}
result.Items = contentItems
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
}
OK(w, r)
}
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
}
categoryRequest := model.CategoryRequest{
Title: destination.ID,
}
verr := validator.ValidateCategoryModification(h.store, userID, category.ID, &categoryRequest)
if verr != nil {
json.BadRequest(w, r, verr.Error())
return
}
categoryRequest.Patch(category)
err = h.store.UpdateCategory(category)
if err != nil {
json.ServerError(w, r, err)
return
}
OK(w, r)
}
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(w, 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([]subscriptionCategory, 0)
result.Tags = append(result.Tags, subscriptionCategory{
ID: fmt.Sprintf(UserStreamPrefix, userID) + Starred,
})
for _, category := range categories {
result.Tags = append(result.Tags, subscriptionCategory{
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(w, r); err != nil {
json.ServerError(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([]subscription, 0)
for _, feed := range feeds {
result.Subscriptions = append(result.Subscriptions, subscription{
ID: fmt.Sprintf(FeedPrefix+"%d", feed.ID),
Title: feed.Title,
URL: feed.FeedURL,
Categories: []subscriptionCategory{{fmt.Sprintf(UserLabelPrefix, userID) + feed.Category.Title, feed.Category.Title, "folder"}},
HTMLURL: feed.SiteURL,
IconURL: "", //TODO Icons are only base64 encode in DB yet
})
}
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()),
)
if err := checkOutputFormat(w, r); err != nil {
json.ServerError(w, r, err)
return
}
user, err := h.store.UserByID(request.UserID(r))
if err != nil {
json.ServerError(w, r, err)
return
}
userInfo := userInfo{UserID: fmt.Sprint(user.ID), UserName: user.Username, UserProfileID: fmt.Sprint(user.ID), 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(w, r); err != nil {
json.ServerError(w, r, fmt.Errorf("googlereader: output only as json supported"))
return
}
rm, err := getStreamFilterModifiers(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, fmt.Errorf("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.Any("filter_type", 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 {
switch s.Type {
case 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})
}
miniflux-2.0.51/internal/googlereader/middleware.go 0000664 0000000 0000000 00000014107 14546226260 0022363 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) 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", "Authorization")
if r.Method == http.MethodOptions {
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)
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),
)
Unauthorized(w, r)
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()),
)
Unauthorized(w, r)
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()),
)
Unauthorized(w, r)
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()),
)
Unauthorized(w, r)
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()),
)
Unauthorized(w, r)
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()),
)
Unauthorized(w, r)
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()),
)
Unauthorized(w, r)
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),
)
Unauthorized(w, r)
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),
)
Unauthorized(w, r)
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()),
)
Unauthorized(w, r)
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),
)
Unauthorized(w, r)
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()),
)
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.Int64("user_id", user.ID),
slog.String("username", user.Username),
)
m.store.SetLastLogin(integration.UserID)
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)
ctx = context.WithValue(ctx, request.GoogleReaderToken, 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
}
miniflux-2.0.51/internal/googlereader/response.go 0000664 0000000 0000000 00000010076 14546226260 0022105 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"
"miniflux.app/v2/internal/http/response"
)
type login struct {
SID string `json:"SID,omitempty"`
LSID string `json:"LSID,omitempty"`
Auth string `json:"Auth,omitempty"`
}
func (l login) String() string {
return fmt.Sprintf("SID=%s\nLSID=%s\nAuth=%s\n", l.SID, l.LSID, l.Auth)
}
type userInfo struct {
UserID string `json:"userId"`
UserName string `json:"userName"`
UserProfileID string `json:"userProfileId"`
UserEmail string `json:"userEmail"`
}
type subscription struct {
ID string `json:"id"`
Title string `json:"title"`
Categories []subscriptionCategory `json:"categories"`
URL string `json:"url"`
HTMLURL string `json:"htmlUrl"`
IconURL string `json:"iconUrl"`
}
type quickAddResponse struct {
NumResults int64 `json:"numResults"`
Query string `json:"query,omitempty"`
StreamID string `json:"streamId,omitempty"`
StreamName string `json:"streamName,omitempty"`
}
type subscriptionCategory struct {
ID string `json:"id"`
Label string `json:"label,omitempty"`
Type string `json:"type,omitempty"`
}
type subscriptionsResponse struct {
Subscriptions []subscription `json:"subscriptions"`
}
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 []subscriptionCategory `json:"tags"`
}
type streamContentItems struct {
Direction string `json:"direction"`
ID string `json:"id"`
Title string `json:"title"`
Self []contentHREF `json:"self"`
Alternate []contentHREFType `json:"alternate"`
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"`
}
// Unauthorized sends a not authorized error to the client.
func Unauthorized(w http.ResponseWriter, r *http.Request) {
builder := response.New(w, r)
builder.WithStatus(http.StatusUnauthorized)
builder.WithHeader("Content-Type", "text/plain")
builder.WithHeader("X-Reader-Google-Bad-Token", "true")
builder.WithBody("Unauthorized")
builder.Write()
}
// OK sends a ok response to the client.
func OK(w http.ResponseWriter, r *http.Request) {
builder := response.New(w, r)
builder.WithStatus(http.StatusOK)
builder.WithHeader("Content-Type", "text/plain")
builder.WithBody("OK")
builder.Write()
}
miniflux-2.0.51/internal/http/ 0000775 0000000 0000000 00000000000 14546226260 0016234 5 ustar 00root root 0000000 0000000 miniflux-2.0.51/internal/http/cookie/ 0000775 0000000 0000000 00000000000 14546226260 0017505 5 ustar 00root root 0000000 0000000 miniflux-2.0.51/internal/http/cookie/cookie.go 0000664 0000000 0000000 00000002166 14546226260 0021312 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"
)
// Cookie names.
const (
CookieAppSessionID = "MinifluxAppSessionID"
CookieUserSessionID = "MinifluxUserSessionID"
// Cookie duration in days.
cookieDuration = 30
)
// 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(cookieDuration * 24 * time.Hour),
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
}
miniflux-2.0.51/internal/http/request/ 0000775 0000000 0000000 00000000000 14546226260 0017724 5 ustar 00root root 0000000 0000000 miniflux-2.0.51/internal/http/request/client_ip.go 0000664 0000000 0000000 00000002221 14546226260 0022216 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"
)
// FindClientIP returns the client real IP address based on trusted Reverse-Proxy HTTP headers.
func FindClientIP(r *http.Request) string {
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
}
miniflux-2.0.51/internal/http/request/client_ip_test.go 0000664 0000000 0000000 00000007444 14546226260 0023271 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); ip != "192.168.0.1" {
t.Fatalf(`Unexpected result, got: %q`, ip)
}
r = &http.Request{RemoteAddr: "192.168.0.1"}
if ip := FindClientIP(r); 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); 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); 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); 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); 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); 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); 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); 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); 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); 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); 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); 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); ip != "203.0.113.195" {
t.Fatalf(`Unexpected result, got: %q`, ip)
}
}
miniflux-2.0.51/internal/http/request/context.go 0000664 0000000 0000000 00000011052 14546226260 0021736 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"
"miniflux.app/v2/internal/model"
)
// ContextKey represents a context key.
type ContextKey int
// List of context keys.
const (
UserIDContextKey ContextKey = iota
UserTimezoneContextKey
IsAdminUserContextKey
IsAuthenticatedContextKey
UserSessionTokenContextKey
UserLanguageContextKey
UserThemeContextKey
SessionIDContextKey
CSRFContextKey
OAuth2StateContextKey
OAuth2CodeVerifierContextKey
FlashMessageContextKey
FlashErrorMessageContextKey
PocketRequestTokenContextKey
LastForceRefreshContextKey
ClientIPContextKey
GoogleReaderToken
WebAuthnDataContextKey
)
func WebAuthnSessionData(r *http.Request) *model.WebAuthnSession {
if v := r.Context().Value(WebAuthnDataContextKey); v != nil {
value, valid := v.(model.WebAuthnSession)
if !valid {
return nil
}
return &value
}
return nil
}
// GoolgeReaderToken returns the google reader token if it exists.
func GoolgeReaderToken(r *http.Request) string {
return getContextStringValue(r, GoogleReaderToken)
}
// 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)
}
// 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)
}
// PocketRequestToken returns the Pocket Request Token if any.
func PocketRequestToken(r *http.Request) string {
return getContextStringValue(r, PocketRequestTokenContextKey)
}
// LastForceRefresh returns the last force refresh timestamp.
func LastForceRefresh(r *http.Request) int64 {
jsonStringValue := getContextStringValue(r, LastForceRefreshContextKey)
timestamp, err := strconv.ParseInt(jsonStringValue, 10, 64)
if err != nil {
return 0
}
return timestamp
}
// 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 {
value, valid := v.(string)
if !valid {
return ""
}
return value
}
return ""
}
func getContextBoolValue(r *http.Request, key ContextKey) bool {
if v := r.Context().Value(key); v != nil {
value, valid := v.(bool)
if !valid {
return false
}
return value
}
return false
}
func getContextInt64Value(r *http.Request, key ContextKey) int64 {
if v := r.Context().Value(key); v != nil {
value, valid := v.(int64)
if !valid {
return 0
}
return value
}
return 0
}
miniflux-2.0.51/internal/http/request/context_test.go 0000664 0000000 0000000 00000025037 14546226260 0023005 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 TestPocketRequestToken(t *testing.T) {
r, _ := http.NewRequest("GET", "http://example.org", nil)
result := PocketRequestToken(r)
expected := ""
if result != expected {
t.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)
}
ctx := r.Context()
ctx = context.WithValue(ctx, PocketRequestTokenContextKey, "request token")
r = r.WithContext(ctx)
result = PocketRequestToken(r)
expected = "request token"
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)
}
}
miniflux-2.0.51/internal/http/request/cookie.go 0000664 0000000 0000000 00000000623 14546226260 0021525 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 == http.ErrNoCookie {
return ""
}
return cookie.Value
}
miniflux-2.0.51/internal/http/request/cookie_test.go 0000664 0000000 0000000 00000001511 14546226260 0022561 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)
}
}
miniflux-2.0.51/internal/http/request/params.go 0000664 0000000 0000000 00000005360 14546226260 0021542 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
}
miniflux-2.0.51/internal/http/request/params_test.go 0000664 0000000 0000000 00000012064 14546226260 0022600 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)
}
}
miniflux-2.0.51/internal/http/response/ 0000775 0000000 0000000 00000000000 14546226260 0020072 5 ustar 00root root 0000000 0000000 miniflux-2.0.51/internal/http/response/builder.go 0000664 0000000 0000000 00000006664 14546226260 0022063 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"
"fmt"
"io"
"log/slog"
"net/http"
"strings"
"time"
)
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 interface{}
}
// 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 interface{}) *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"] = fmt.Sprintf("attachment; filename=%s", 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 error:
b.compress([]byte(v.Error()))
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-XSS-Protection"] = "1; mode=block"
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, "gzip"):
b.headers["Content-Encoding"] = "gzip"
b.writeHeaders()
gzipWriter := gzip.NewWriter(b.w)
defer gzipWriter.Close()
gzipWriter.Write(data)
return
case strings.Contains(acceptEncoding, "deflate"):
b.headers["Content-Encoding"] = "deflate"
b.writeHeaders()
flateWriter, _ := flate.NewWriter(b.w, -1)
defer flateWriter.Close()
flateWriter.Write(data)
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}
}
miniflux-2.0.51/internal/http/response/builder_test.go 0000664 0000000 0000000 00000021035 14546226260 0023107 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 (
"errors"
"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-XSS-Protection": "1; mode=block",
"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 TestBuildResponseWithError(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(errors.New("Some error")).Write()
})
handler.ServeHTTP(w, r)
expectedBody := `Some error`
actualBody := w.Body.String()
if actualBody != expectedBody {
t.Fatalf(`Unexpected body, got %s instead of %s`, actualBody, expectedBody)
}
}
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 TestBuildResponseWithGzipCompression(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 := "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)
}
}
miniflux-2.0.51/internal/http/response/html/ 0000775 0000000 0000000 00000000000 14546226260 0021036 5 ustar 00root root 0000000 0000000 miniflux-2.0.51/internal/http/response/html/html.go 0000664 0000000 0000000 00000011347 14546226260 0022337 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 (
"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(w http.ResponseWriter, r *http.Request, body interface{}) {
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", `default-src 'self'`)
builder.WithHeader("Content-Type", "text/html; charset=utf-8")
builder.WithHeader("Cache-Control", "no-cache, max-age=0, must-revalidate, no-store")
builder.WithBody(err)
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", `default-src 'self'`)
builder.WithHeader("Content-Type", "text/html; charset=utf-8")
builder.WithHeader("Cache-Control", "no-cache, max-age=0, must-revalidate, no-store")
builder.WithBody(err)
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()
}
miniflux-2.0.51/internal/http/response/html/html_test.go 0000664 0000000 0000000 00000014720 14546226260 0023374 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"))
})
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`
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 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 := `Some error`
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 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)
}
}
miniflux-2.0.51/internal/http/response/json/ 0000775 0000000 0000000 00000000000 14546226260 0021043 5 ustar 00root root 0000000 0000000 miniflux-2.0.51/internal/http/response/json/json.go 0000664 0000000 0000000 00000011757 14546226260 0022356 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 interface{}) {
builder := response.New(w, r)
builder.WithHeader("Content-Type", contentTypeHeader)
builder.WithBody(toJSON(body))
builder.Write()
}
// Created sends a created response to the client.
func Created(w http.ResponseWriter, r *http.Request, body interface{}) {
builder := response.New(w, r)
builder.WithStatus(http.StatusCreated)
builder.WithHeader("Content-Type", contentTypeHeader)
builder.WithBody(toJSON(body))
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),
),
)
builder := response.New(w, r)
builder.WithStatus(http.StatusInternalServerError)
builder.WithHeader("Content-Type", contentTypeHeader)
builder.WithBody(toJSONError(err))
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-Type", contentTypeHeader)
builder.WithBody(toJSONError(err))
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),
),
)
builder := response.New(w, r)
builder.WithStatus(http.StatusUnauthorized)
builder.WithHeader("Content-Type", contentTypeHeader)
builder.WithBody(toJSONError(errors.New("access unauthorized")))
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", contentTypeHeader)
builder.WithBody(toJSONError(errors.New("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", contentTypeHeader)
builder.WithBody(toJSONError(errors.New("resource not found")))
builder.Write()
}
func toJSONError(err error) []byte {
type errorMsg struct {
ErrorMessage string `json:"error_message"`
}
return toJSON(errorMsg{ErrorMessage: err.Error()})
}
func toJSON(v interface{}) []byte {
b, err := json.Marshal(v)
if err != nil {
slog.Error("Unable to marshal JSON response", slog.Any("error", err))
return []byte("")
}
return b
}
miniflux-2.0.51/internal/http/response/json/json_test.go 0000664 0000000 0000000 00000021052 14546226260 0023402 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.StatusOK
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)
}
}
miniflux-2.0.51/internal/http/response/xml/ 0000775 0000000 0000000 00000000000 14546226260 0020672 5 ustar 00root root 0000000 0000000 miniflux-2.0.51/internal/http/response/xml/xml.go 0000664 0000000 0000000 00000001533 14546226260 0022023 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(w http.ResponseWriter, r *http.Request, body interface{}) {
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(w http.ResponseWriter, r *http.Request, filename string, body interface{}) {
builder := response.New(w, r)
builder.WithHeader("Content-Type", "text/xml; charset=utf-8")
builder.WithAttachment(filename)
builder.WithBody(body)
builder.Write()
}
miniflux-2.0.51/internal/http/response/xml/xml_test.go 0000664 0000000 0000000 00000004167 14546226260 0023070 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)
}
}
}
miniflux-2.0.51/internal/http/route/ 0000775 0000000 0000000 00000000000 14546226260 0017372 5 ustar 00root root 0000000 0000000 miniflux-2.0.51/internal/http/route/route.go 0000664 0000000 0000000 00000001353 14546226260 0021061 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()
}
miniflux-2.0.51/internal/http/server/ 0000775 0000000 0000000 00000000000 14546226260 0017542 5 ustar 00root root 0000000 0000000 miniflux-2.0.51/internal/http/server/httpd.go 0000664 0000000 0000000 00000021637 14546226260 0021225 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package httpd // import "miniflux.app/v2/internal/http/server"
import (
"crypto/tls"
"fmt"
"log/slog"
"net"
"net/http"
"os"
"strconv"
"strings"
"time"
"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 {
certFile := config.Opts.CertFile()
keyFile := config.Opts.CertKeyFile()
certDomain := config.Opts.CertDomain()
listenAddr := config.Opts.ListenAddr()
server := &http.Server{
ReadTimeout: time.Duration(config.Opts.HTTPServerTimeout()) * time.Second,
WriteTimeout: time.Duration(config.Opts.HTTPServerTimeout()) * time.Second,
IdleTimeout: time.Duration(config.Opts.HTTPServerTimeout()) * time.Second,
Handler: setupHandler(store, pool),
}
switch {
case os.Getenv("LISTEN_PID") == strconv.Itoa(os.Getpid()):
startSystemdSocketServer(server)
case strings.HasPrefix(listenAddr, "/"):
startUnixSocketServer(server, listenAddr)
case certDomain != "":
config.Opts.HTTPS = true
startAutoCertTLSServer(server, certDomain, store)
case certFile != "" && keyFile != "":
config.Opts.HTTPS = true
server.Addr = listenAddr
startTLSServer(server, certFile, keyFile)
default:
server.Addr = listenAddr
startHTTPServer(server)
}
return server
}
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(`Server failed to start: %v`, err)
}
}()
}
func startUnixSocketServer(server *http.Server, socketFile string) {
os.Remove(socketFile)
go func(sock string) {
listener, err := net.Listen("unix", sock)
if err != nil {
printErrorAndExit(`Server failed to start: %v`, err)
}
defer listener.Close()
if err := os.Chmod(sock, 0666); err != nil {
printErrorAndExit(`Unable to change socket permission: %v`, err)
}
slog.Info("Starting server using a Unix socket", slog.String("socket", sock))
if err := server.Serve(listener); err != http.ErrServerClosed {
printErrorAndExit(`Server failed to start: %v`, err)
}
}(socketFile)
}
func tlsConfig() *tls.Config {
// See https://blog.cloudflare.com/exposing-go-on-the-internet/
// And https://wiki.mozilla.org/Security/Server_Side_TLS
return &tls.Config{
MinVersion: tls.VersionTLS12,
CurvePreferences: []tls.CurveID{
tls.CurveP256,
tls.X25519,
},
CipherSuites: []uint16{
tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384,
tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384,
tls.TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305,
tls.TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305,
tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,
tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,
},
}
}
func startAutoCertTLSServer(server *http.Server, certDomain string, store *storage.Storage) {
server.Addr = ":https"
certManager := autocert.Manager{
Cache: storage.NewCertificateCache(store),
Prompt: autocert.AcceptTOS,
HostPolicy: autocert.HostWhitelist(certDomain),
}
server.TLSConfig = tlsConfig()
server.TLSConfig.GetCertificate = certManager.GetCertificate
server.TLSConfig.NextProtos = []string{"h2", "http/1.1", acme.ALPNProto}
// Handle http-01 challenge.
s := &http.Server{
Handler: certManager.HTTPHandler(nil),
Addr: ":http",
}
go s.ListenAndServe()
go func() {
slog.Info("Starting TLS server using automatic certificate management",
slog.String("listen_address", server.Addr),
slog.String("domain", certDomain),
)
if err := server.ListenAndServeTLS("", ""); err != http.ErrServerClosed {
printErrorAndExit(`Server failed to start: %v`, err)
}
}()
}
func startTLSServer(server *http.Server, certFile, keyFile string) {
server.TLSConfig = tlsConfig()
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(`Server failed to start: %v`, 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(`Server failed to start: %v`, err)
}
}()
}
func setupHandler(store *storage.Storage, pool *worker.Pool) *mux.Router {
router := mux.NewRouter()
if config.Opts.BasePath() != "" {
router = router.PathPrefix(config.Opts.BasePath()).Subrouter()
}
if config.Opts.HasMaintenanceMode() {
router.Use(func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(config.Opts.MaintenanceMessage()))
})
})
}
router.Use(middleware)
fever.Serve(router, store)
googlereader.Serve(router, store)
api.Serve(router, store, pool)
ui.Serve(router, store, pool)
router.HandleFunc("/healthcheck", func(w http.ResponseWriter, r *http.Request) {
if err := store.Ping(); err != nil {
http.Error(w, "Database Connection Error", http.StatusInternalServerError)
return
}
w.Write([]byte("OK"))
}).Name("healthcheck")
router.HandleFunc("/version", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(version.Version))
}).Name("version")
if config.Opts.HasMetricsCollector() {
router.Handle("/metrics", promhttp.Handler()).Name("metrics")
router.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)
if remoteIP == "@" {
// This indicates a request sent via a Unix socket, always consider these trusted.
return true
}
for _, cidr := range config.Opts.MetricsAllowedNetworks() {
_, network, err := net.ParseCIDR(cidr)
if err != nil {
slog.Error("Metrics endpoint accessed with invalid CIDR",
slog.Bool("authentication_failed", true),
slog.String("client_ip", clientIP),
slog.String("client_user_agent", r.UserAgent()),
slog.String("client_remote_addr", r.RemoteAddr),
slog.String("cidr", cidr),
)
return false
}
// We use r.RemoteAddr in this case because HTTP headers like X-Forwarded-For can be easily spoofed.
// The recommendation is to use HTTP Basic authentication.
if network.Contains(net.ParseIP(remoteIP)) {
return true
}
}
return false
}
func printErrorAndExit(format string, a ...any) {
message := fmt.Sprintf(format, a...)
slog.Error(message)
fmt.Fprintf(os.Stderr, "%v\n", message)
os.Exit(1)
}
miniflux-2.0.51/internal/http/server/middleware.go 0000664 0000000 0000000 00000002171 14546226260 0022207 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package httpd // 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) {
clientIP := request.FindClientIP(r)
ctx := r.Context()
ctx = context.WithValue(ctx, request.ClientIPContextKey, clientIP)
if r.Header.Get("X-Forwarded-Proto") == "https" {
config.Opts.HTTPS = 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))
})
}
miniflux-2.0.51/internal/integration/ 0000775 0000000 0000000 00000000000 14546226260 0017600 5 ustar 00root root 0000000 0000000 miniflux-2.0.51/internal/integration/apprise/ 0000775 0000000 0000000 00000000000 14546226260 0021243 5 ustar 00root root 0000000 0000000 miniflux-2.0.51/internal/integration/apprise/apprise.go 0000664 0000000 0000000 00000003362 14546226260 0023241 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"
"fmt"
"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(entry *model.Entry) error {
if c.baseURL == "" || c.servicesURL == "" {
return fmt.Errorf("apprise: missing base URL or services URL")
}
message := "[" + entry.Title + "]" + "(" + entry.URL + ")" + "\n\n"
apiEndpoint, err := urllib.JoinBaseURLAndPath(c.baseURL, "/notify")
if err != nil {
return fmt.Errorf(`apprise: invalid API endpoint: %v`, err)
}
requestBody, err := json.Marshal(map[string]any{
"urls": c.servicesURL,
"body": message,
})
if err != nil {
return fmt.Errorf("apprise: unable to encode request body: %v", err)
}
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)
httpClient := &http.Client{Timeout: defaultClientTimeout}
response, err := httpClient.Do(request)
if err != nil {
return fmt.Errorf("apprise: unable to send request: %v", err)
}
defer response.Body.Close()
if response.StatusCode >= 400 {
return fmt.Errorf("apprise: unable to send a notification: url=%s status=%d", apiEndpoint, response.StatusCode)
}
return nil
}
miniflux-2.0.51/internal/integration/espial/ 0000775 0000000 0000000 00000000000 14546226260 0021055 5 ustar 00root root 0000000 0000000 miniflux-2.0.51/internal/integration/espial/espial.go 0000664 0000000 0000000 00000004122 14546226260 0022660 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"
"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 fmt.Errorf("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"`
}
miniflux-2.0.51/internal/integration/instapaper/ 0000775 0000000 0000000 00000000000 14546226260 0021746 5 ustar 00root root 0000000 0000000 miniflux-2.0.51/internal/integration/instapaper/instapaper.go 0000664 0000000 0000000 00000003032 14546226260 0024441 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 (
"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 fmt.Errorf("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
}
miniflux-2.0.51/internal/integration/integration.go 0000664 0000000 0000000 00000031474 14546226260 0022463 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/config"
"miniflux.app/v2/internal/integration/apprise"
"miniflux.app/v2/internal/integration/espial"
"miniflux.app/v2/internal/integration/instapaper"
"miniflux.app/v2/internal/integration/linkding"
"miniflux.app/v2/internal/integration/matrixbot"
"miniflux.app/v2/internal/integration/notion"
"miniflux.app/v2/internal/integration/nunuxkeeper"
"miniflux.app/v2/internal/integration/omnivore"
"miniflux.app/v2/internal/integration/pinboard"
"miniflux.app/v2/internal/integration/pocket"
"miniflux.app/v2/internal/integration/readwise"
"miniflux.app/v2/internal/integration/shaarli"
"miniflux.app/v2/internal/integration/shiori"
"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.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.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.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.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.PocketEnabled {
slog.Debug("Sending entry to Pocket",
slog.Int64("user_id", userIntegrations.UserID),
slog.Int64("entry_id", entry.ID),
slog.String("entry_url", entry.URL),
)
client := pocket.NewClient(config.Opts.PocketConsumerKey(userIntegrations.PocketConsumerKey), userIntegrations.PocketAccessToken)
if err := client.AddURL(entry.URL, entry.Title); err != nil {
slog.Error("Unable to send entry to Pocket",
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.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.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.WebhookEnabled {
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", userIntegrations.WebhookURL),
)
webhookClient := webhook.NewClient(userIntegrations.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", userIntegrations.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),
)
}
}
}
// 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 {
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", userIntegrations.WebhookURL),
)
webhookClient := webhook.NewClient(userIntegrations.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", userIntegrations.WebhookURL),
slog.Any("error", err),
)
}
}
// Integrations that only support sending individual entries
if userIntegrations.TelegramBotEnabled || userIntegrations.AppriseEnabled {
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),
)
}
}
if userIntegrations.AppriseEnabled {
slog.Debug("Sending a new entry to Apprise",
slog.Int64("user_id", userIntegrations.UserID),
slog.Int64("entry_id", entry.ID),
slog.String("entry_url", entry.URL),
slog.String("apprise_url", userIntegrations.AppriseURL),
)
appriseServiceURLs := userIntegrations.AppriseServicesURL
if feed.AppriseServiceURLs != "" {
appriseServiceURLs = feed.AppriseServiceURLs
}
client := apprise.NewClient(
appriseServiceURLs,
userIntegrations.AppriseURL,
)
if err := client.SendNotification(entry); err != nil {
slog.Error("Unable to send entry to Apprise",
slog.Int64("user_id", userIntegrations.UserID),
slog.Int64("entry_id", entry.ID),
slog.String("entry_url", entry.URL),
slog.String("apprise_url", userIntegrations.AppriseURL),
slog.Any("error", err),
)
}
}
}
}
}
miniflux-2.0.51/internal/integration/linkding/ 0000775 0000000 0000000 00000000000 14546226260 0021377 5 ustar 00root root 0000000 0000000 miniflux-2.0.51/internal/integration/linkding/linkding.go 0000664 0000000 0000000 00000004312 14546226260 0023525 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"
"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 fmt.Errorf("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"`
}
miniflux-2.0.51/internal/integration/matrixbot/ 0000775 0000000 0000000 00000000000 14546226260 0021611 5 ustar 00root root 0000000 0000000 miniflux-2.0.51/internal/integration/matrixbot/client.go 0000664 0000000 0000000 00000014265 14546226260 0023426 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"`
}
miniflux-2.0.51/internal/integration/matrixbot/matrixbot.go 0000664 0000000 0000000 00000002476 14546226260 0024162 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"
)
// PushEntry 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
}
var textMessages []string
var formattedTextMessages []string
for _, entry := range entries {
textMessages = append(textMessages, fmt.Sprintf(`[%s] %s - %s`, feed.Title, entry.Title, entry.URL))
formattedTextMessages = append(formattedTextMessages, fmt.Sprintf(`
",
)
return err
}
miniflux-2.0.51/internal/integration/notion/ 0000775 0000000 0000000 00000000000 14546226260 0021106 5 ustar 00root root 0000000 0000000 miniflux-2.0.51/internal/integration/notion/notion.go 0000664 0000000 0000000 00000004066 14546226260 0022751 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"
"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 fmt.Errorf("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"`
}
miniflux-2.0.51/internal/integration/nunuxkeeper/ 0000775 0000000 0000000 00000000000 14546226260 0022151 5 ustar 00root root 0000000 0000000 miniflux-2.0.51/internal/integration/nunuxkeeper/nunuxkeeper.go 0000664 0000000 0000000 00000004106 14546226260 0025052 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"
"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 fmt.Errorf("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"`
}
miniflux-2.0.51/internal/integration/omnivore/ 0000775 0000000 0000000 00000000000 14546226260 0021436 5 ustar 00root root 0000000 0000000 miniflux-2.0.51/internal/integration/omnivore/omnivore.go 0000664 0000000 0000000 00000005623 14546226260 0023631 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"
"github.com/google/uuid"
"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]interface{}{
"query": mutation,
"variables": map[string]interface{}{
"input": map[string]interface{}{
"clientRequestId": uuid.New().String(),
"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
}
miniflux-2.0.51/internal/integration/pinboard/ 0000775 0000000 0000000 00000000000 14546226260 0021376 5 ustar 00root root 0000000 0000000 miniflux-2.0.51/internal/integration/pinboard/pinboard.go 0000664 0000000 0000000 00000003143 14546226260 0023524 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 (
"fmt"
"net/http"
"net/url"
"time"
"miniflux.app/v2/internal/version"
)
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 fmt.Errorf("pinboard: missing auth token")
}
toRead := "no"
if markAsUnread {
toRead = "yes"
}
values := url.Values{}
values.Add("auth_token", c.authToken)
values.Add("url", entryURL)
values.Add("description", entryTitle)
values.Add("tags", pinboardTags)
values.Add("toread", toRead)
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
}
miniflux-2.0.51/internal/integration/pocket/ 0000775 0000000 0000000 00000000000 14546226260 0021065 5 ustar 00root root 0000000 0000000 miniflux-2.0.51/internal/integration/pocket/connector.go 0000664 0000000 0000000 00000007777 14546226260 0023430 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package pocket // import "miniflux.app/v2/internal/integration/pocket"
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"net/http"
"miniflux.app/v2/internal/version"
)
// Connector manages the authorization flow with Pocket to get a personal access token.
type Connector struct {
consumerKey string
}
// NewConnector returns a new Pocket Connector.
func NewConnector(consumerKey string) *Connector {
return &Connector{consumerKey}
}
// RequestToken fetches a new request token from Pocket API.
func (c *Connector) RequestToken(redirectURL string) (string, error) {
apiEndpoint := "https://getpocket.com/v3/oauth/request"
requestBody, err := json.Marshal(&createTokenRequest{ConsumerKey: c.consumerKey, RedirectURI: redirectURL})
if err != nil {
return "", fmt.Errorf("pocket: unable to encode request body: %v", err)
}
request, err := http.NewRequest(http.MethodPost, apiEndpoint, bytes.NewReader(requestBody))
if err != nil {
return "", fmt.Errorf("pocket: unable to create request: %v", err)
}
request.Header.Set("Content-Type", "application/json")
request.Header.Set("X-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("pocket: unable to send request: %v", err)
}
defer response.Body.Close()
if response.StatusCode >= 400 {
return "", fmt.Errorf("pocket: unable get request token: url=%s status=%d", apiEndpoint, response.StatusCode)
}
var result createTokenResponse
if err := json.NewDecoder(response.Body).Decode(&result); err != nil {
return "", fmt.Errorf("pocket: unable to decode response: %v", err)
}
if result.Code == "" {
return "", errors.New("pocket: request token is empty")
}
return result.Code, nil
}
// AccessToken fetches a new access token once the end-user authorized the application.
func (c *Connector) AccessToken(requestToken string) (string, error) {
apiEndpoint := "https://getpocket.com/v3/oauth/authorize"
requestBody, err := json.Marshal(&authorizeRequest{ConsumerKey: c.consumerKey, Code: requestToken})
if err != nil {
return "", fmt.Errorf("pocket: unable to encode request body: %v", err)
}
request, err := http.NewRequest(http.MethodPost, apiEndpoint, bytes.NewReader(requestBody))
if err != nil {
return "", fmt.Errorf("pocket: unable to create request: %v", err)
}
request.Header.Set("Content-Type", "application/json")
request.Header.Set("X-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("pocket: unable to send request: %v", err)
}
defer response.Body.Close()
if response.StatusCode >= 400 {
return "", fmt.Errorf("pocket: unable get access token: url=%s status=%d", apiEndpoint, response.StatusCode)
}
var result authorizeReponse
if err := json.NewDecoder(response.Body).Decode(&result); err != nil {
return "", fmt.Errorf("pocket: unable to decode response: %v", err)
}
if result.AccessToken == "" {
return "", errors.New("pocket: access token is empty")
}
return result.AccessToken, nil
}
// AuthorizationURL returns the authorization URL for the end-user.
func (c *Connector) AuthorizationURL(requestToken, redirectURL string) string {
return fmt.Sprintf(
"https://getpocket.com/auth/authorize?request_token=%s&redirect_uri=%s",
requestToken,
redirectURL,
)
}
type createTokenRequest struct {
ConsumerKey string `json:"consumer_key"`
RedirectURI string `json:"redirect_uri"`
}
type createTokenResponse struct {
Code string `json:"code"`
}
type authorizeRequest struct {
ConsumerKey string `json:"consumer_key"`
Code string `json:"code"`
}
type authorizeReponse struct {
AccessToken string `json:"access_token"`
Username string `json:"username"`
}
miniflux-2.0.51/internal/integration/pocket/pocket.go 0000664 0000000 0000000 00000003507 14546226260 0022706 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package pocket // import "miniflux.app/v2/internal/integration/pocket"
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"time"
"miniflux.app/v2/internal/version"
)
const defaultClientTimeout = 10 * time.Second
type Client struct {
consumerKey string
accessToken string
}
func NewClient(consumerKey, accessToken string) *Client {
return &Client{consumerKey, accessToken}
}
func (c *Client) AddURL(entryURL, entryTitle string) error {
if c.consumerKey == "" || c.accessToken == "" {
return fmt.Errorf("pocket: missing consumer key or access token")
}
apiEndpoint := "https://getpocket.com/v3/add"
requestBody, err := json.Marshal(&createItemRequest{
AccessToken: c.accessToken,
ConsumerKey: c.consumerKey,
Title: entryTitle,
URL: entryURL,
})
if err != nil {
return fmt.Errorf("pocket: unable to encode request body: %v", err)
}
request, err := http.NewRequest(http.MethodPost, apiEndpoint, bytes.NewReader(requestBody))
if err != nil {
return fmt.Errorf("pocket: unable to create request: %v", err)
}
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("pocket: unable to send request: %v", err)
}
defer response.Body.Close()
if response.StatusCode >= 400 {
return fmt.Errorf("pocket: unable to create item: url=%s status=%d", apiEndpoint, response.StatusCode)
}
return nil
}
type createItemRequest struct {
AccessToken string `json:"access_token"`
ConsumerKey string `json:"consumer_key"`
Title string `json:"title,omitempty"`
URL string `json:"url"`
}
miniflux-2.0.51/internal/integration/readwise/ 0000775 0000000 0000000 00000000000 14546226260 0021403 5 ustar 00root root 0000000 0000000 miniflux-2.0.51/internal/integration/readwise/readwise.go 0000664 0000000 0000000 00000003245 14546226260 0023541 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"
"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 fmt.Errorf("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"`
}
miniflux-2.0.51/internal/integration/rssbridge/ 0000775 0000000 0000000 00000000000 14546226260 0021564 5 ustar 00root root 0000000 0000000 miniflux-2.0.51/internal/integration/rssbridge/rssbridge.go 0000664 0000000 0000000 00000002322 14546226260 0024076 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/integration/rssbridge"
import (
"encoding/json"
"fmt"
"net/http"
"net/url"
)
type Bridge struct {
URL string `json:"url"`
BridgeMeta BridgeMeta `json:"bridgeMeta"`
}
type BridgeMeta struct {
Name string `json:"name"`
}
func DetectBridges(rssbridgeURL, websiteURL string) (bridgeResponse []Bridge, err error) {
u, err := url.Parse(rssbridgeURL)
if err != nil {
return nil, err
}
values := u.Query()
values.Add("action", "findfeed")
values.Add("format", "atom")
values.Add("url", websiteURL)
u.RawQuery = values.Encode()
response, err := http.Get(u.String())
if err != nil {
return nil, fmt.Errorf("RSS-Bridge: unable to excute request: %w", err)
}
defer response.Body.Close()
if response.StatusCode == http.StatusNotFound {
return
}
if response.StatusCode > 400 {
return nil, fmt.Errorf("RSS-Bridge: unexpected status code %d", response.StatusCode)
}
if err := json.NewDecoder(response.Body).Decode(&bridgeResponse); err != nil {
return nil, fmt.Errorf("RSS-Bridge: unable to decode bridge response: %w", err)
}
return
}
miniflux-2.0.51/internal/integration/shaarli/ 0000775 0000000 0000000 00000000000 14546226260 0021223 5 ustar 00root root 0000000 0000000 miniflux-2.0.51/internal/integration/shaarli/shaarli.go 0000664 0000000 0000000 00000005046 14546226260 0023202 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"
"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
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 fmt.Errorf("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 := strings.TrimRight(base64.URLEncoding.EncodeToString([]byte(`{"typ":"JWT", "alg":"HS256"}`)), "=")
payload := strings.TrimRight(base64.URLEncoding.EncodeToString([]byte(fmt.Sprintf(`{"iat": %d}`, time.Now().Unix()))), "=")
mac := hmac.New(sha512.New, []byte(c.apiSecret))
mac.Write([]byte(header + "." + payload))
signature := strings.TrimRight(base64.URLEncoding.EncodeToString(mac.Sum(nil)), "=")
return header + "." + payload + "." + signature
}
type addLinkRequest struct {
URL string `json:"url"`
Title string `json:"title"`
Private bool `json:"private"`
}
miniflux-2.0.51/internal/integration/shiori/ 0000775 0000000 0000000 00000000000 14546226260 0021075 5 ustar 00root root 0000000 0000000 miniflux-2.0.51/internal/integration/shiori/shiori.go 0000664 0000000 0000000 00000007174 14546226260 0022732 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"
"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 fmt.Errorf("shiori: missing base URL, username or password")
}
sessionID, 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,
CreateArchive: true,
})
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("X-Session-Id", sessionID)
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() (sessionID string, err error) {
apiEndpoint, err := urllib.JoinBaseURLAndPath(c.baseURL, "/api/login")
if err != nil {
return "", fmt.Errorf("shiori: invalid API endpoint: %v", err)
}
requestBody, err := json.Marshal(&authRequest{Username: c.username, Password: c.password})
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.SessionID, nil
}
type authRequest struct {
Username string `json:"username"`
Password string `json:"password"`
}
type authResponse struct {
SessionID string `json:"session"`
}
type addBookmarkRequest struct {
URL string `json:"url"`
Title string `json:"title"`
CreateArchive bool `json:"createArchive"`
}
miniflux-2.0.51/internal/integration/telegrambot/ 0000775 0000000 0000000 00000000000 14546226260 0022105 5 ustar 00root root 0000000 0000000 miniflux-2.0.51/internal/integration/telegrambot/client.go 0000664 0000000 0000000 00000012174 14546226260 0023717 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"`
}
miniflux-2.0.51/internal/integration/telegrambot/telegrambot.go 0000664 0000000 0000000 00000003024 14546226260 0024740 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"
"miniflux.app/v2/internal/model"
)
func PushEntry(feed *model.Feed, entry *model.Entry, botToken, chatID string, topicID *int64, disableWebPagePreview, disableNotification bool, disableButtons bool) error {
formattedText := fmt.Sprintf(
`%s`,
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
websiteURLButton := InlineKeyboardButton{Text: "Go to website", URL: feed.SiteURL}
markupRow = append(markupRow, &websiteURLButton)
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
}
miniflux-2.0.51/internal/integration/wallabag/ 0000775 0000000 0000000 00000000000 14546226260 0021352 5 ustar 00root root 0000000 0000000 miniflux-2.0.51/internal/integration/wallabag/wallabag.go 0000664 0000000 0000000 00000010277 14546226260 0023462 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"
"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
onlyURL bool
}
func NewClient(baseURL, clientID, clientSecret, username, password string, onlyURL bool) *Client {
return &Client{baseURL, clientID, clientSecret, username, password, onlyURL}
}
func (c *Client) CreateEntry(entryURL, entryTitle, entryContent string) error {
if c.baseURL == "" || c.clientID == "" || c.clientSecret == "" || c.username == "" || c.password == "" {
return fmt.Errorf("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)
}
func (c *Client) createEntry(accessToken, entryURL, entryTitle, entryContent 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,
})
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 access token: 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"`
}
miniflux-2.0.51/internal/integration/webhook/ 0000775 0000000 0000000 00000000000 14546226260 0021236 5 ustar 00root root 0000000 0000000 miniflux-2.0.51/internal/integration/webhook/webhook.go 0000664 0000000 0000000 00000012736 14546226260 0023234 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"
"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,
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
}
var webhookEntries []*WebhookEntry
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,
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 fmt.Errorf(`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"`
FeedURL string `json:"feed_url"`
SiteURL string `json:"site_url"`
Title string `json:"title"`
CheckedAt time.Time `json:"checked_at"`
}
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"`
}
miniflux-2.0.51/internal/locale/ 0000775 0000000 0000000 00000000000 14546226260 0016514 5 ustar 00root root 0000000 0000000 miniflux-2.0.51/internal/locale/catalog.go 0000664 0000000 0000000 00000002520 14546226260 0020454 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 map[string]interface{}
type catalog map[string]translationDict
var defaultCatalog catalog
//go:embed translations/*.json
var translationFiles embed.FS
// LoadCatalogMessages loads and parses all translations encoded in JSON.
func LoadCatalogMessages() error {
var err error
defaultCatalog = make(catalog)
for language := range AvailableLanguages() {
defaultCatalog[language], err = loadTranslationFile(language)
if err != nil {
return err
}
}
return nil
}
func loadTranslationFile(language string) (translationDict, error) {
translationFileData, err := translationFiles.ReadFile(fmt.Sprintf("translations/%s.json", language))
if err != nil {
return nil, err
}
translationMessages, err := parseTranslationMessages(translationFileData)
if err != nil {
return nil, err
}
return translationMessages, nil
}
func parseTranslationMessages(data []byte) (translationDict, error) {
var translationMessages translationDict
if err := json.Unmarshal(data, &translationMessages); err != nil {
return nil, fmt.Errorf(`invalid translation file: %w`, err)
}
return translationMessages, nil
}
miniflux-2.0.51/internal/locale/catalog_test.go 0000664 0000000 0000000 00000004221 14546226260 0021513 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)
}
if translations == nil {
t.Fatal(`Translations should not be nil`)
}
value, found := translations["k"]
if !found {
t.Fatal(`The translation should contains the defined key`)
}
if value.(string) != "v" {
t.Fatal(`The translation key should contains the defined value`)
}
}
func TestLoadCatalog(t *testing.T) {
if err := LoadCatalogMessages(); 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) == 0 {
t.Fatalf(`The language %q doesn't have any messages`, language)
}
for k, v := range messages {
switch value := v.(type) {
case string:
if value == "" {
t.Errorf(`The key %q for the language %q have an empty string as value`, k, language)
}
case []string:
if len(value) == 0 {
t.Errorf(`The key %q for the language %q have 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 {
if _, found := messages[key]; !found {
t.Fatalf(`Translation key %q not found in language %q`, key, language)
}
}
}
}
miniflux-2.0.51/internal/locale/error.go 0000664 0000000 0000000 00000002646 14546226260 0020204 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...)
}
miniflux-2.0.51/internal/locale/locale.go 0000664 0000000 0000000 00000001435 14546226260 0020305 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 returns the list of available languages.
func AvailableLanguages() map[string]string {
return map[string]string{
"en_US": "English",
"es_ES": "Español",
"fr_FR": "Français",
"de_DE": "Deutsch",
"pl_PL": "Polski",
"pt_BR": "Português Brasileiro",
"zh_CN": "简体中文",
"zh_TW": "繁體中文",
"nl_NL": "Nederlands",
"ru_RU": "Русский",
"it_IT": "Italiano",
"ja_JP": "日本語",
"tr_TR": "Türkçe",
"el_EL": "Ελληνικά",
"fi_FI": "Suomi",
"hi_IN": "हिन्दी",
"uk_UA": "Українська",
"id_ID": "Bahasa Indonesia",
}
}
miniflux-2.0.51/internal/locale/locale_test.go 0000664 0000000 0000000 00000001044 14546226260 0021340 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)`)
}
}
miniflux-2.0.51/internal/locale/plural.go 0000664 0000000 0000000 00000003535 14546226260 0020350 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"
type pluralFormFunc func(n int) int
// See https://localization-guide.readthedocs.io/en/latest/l10n/pluralforms.html
// And http://www.unicode.org/cldr/charts/29/supplemental/language_plural_rules.html
var pluralForms = map[string]pluralFormFunc{
// nplurals=2; plural=(n != 1);
"default": func(n int) int {
if n != 1 {
return 1
}
return 0
},
// nplurals=6; plural=(n==0 ? 0 : n==1 ? 1 : n==2 ? 2 : n%100>=3 && n%100<=10 ? 3 : n%100>=11 ? 4 : 5);
"ar_AR": func(n int) int {
if n == 0 {
return 0
}
if n == 1 {
return 1
}
if n == 2 {
return 2
}
if n%100 >= 3 && n%100 <= 10 {
return 3
}
if n%100 >= 11 {
return 4
}
return 5
},
// nplurals=3; plural=(n==1) ? 0 : (n>=2 && n<=4) ? 1 : 2;
"cs_CZ": func(n int) int {
if n == 1 {
return 0
}
if n >= 2 && n <= 4 {
return 1
}
return 2
},
// nplurals=1; plural=0;
"id_ID": func(n int) int {
return 0
},
// nplurals=3; plural=(n==1 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);
"pl_PL": func(n int) int {
if n == 1 {
return 0
}
if n%10 >= 2 && n%10 <= 4 && (n%100 < 10 || n%100 >= 20) {
return 1
}
return 2
},
// nplurals=2; plural=(n > 1);
"pt_BR": func(n int) int {
if n > 1 {
return 1
}
return 0
},
"ru_RU": pluralFormRuSrUa,
"uk_UA": pluralFormRuSrUa,
"sr_RS": pluralFormRuSrUa,
// nplurals=1; plural=0;
"zh_CN": func(n int) int {
return 0
},
}
// nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);
func pluralFormRuSrUa(n int) int {
if n%10 == 1 && n%100 != 11 {
return 0
}
if n%10 >= 2 && n%10 <= 4 && (n%100 < 10 || n%100 >= 20) {
return 1
}
return 2
}
miniflux-2.0.51/internal/locale/plural_test.go 0000664 0000000 0000000 00000001656 14546226260 0021411 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": {
1: 0,
2: 1,
5: 1,
},
"ar_AR": {
0: 0,
1: 1,
2: 2,
5: 3,
11: 4,
200: 5,
},
"cs_CZ": {
1: 0,
2: 1,
5: 2,
},
"pl_PL": {
1: 0,
2: 1,
5: 2,
},
"pt_BR": {
1: 0,
2: 1,
5: 1,
},
"ru_RU": {
1: 0,
2: 1,
5: 2,
},
"sr_RS": {
1: 0,
2: 1,
5: 2,
},
"zh_CN": {
1: 0,
5: 0,
},
}
for rule, values := range scenarios {
for input, expected := range values {
result := pluralForms[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)
}
}
}
}
miniflux-2.0.51/internal/locale/printer.go 0000664 0000000 0000000 00000002700 14546226260 0020525 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
}
// Printf is like fmt.Printf, but using language-specific formatting.
func (p *Printer) Printf(key string, args ...interface{}) string {
var translation string
str, found := defaultCatalog[p.language][key]
if !found {
translation = key
} else {
var valid bool
translation, valid = str.(string)
if !valid {
translation = key
}
}
return fmt.Sprintf(translation, args...)
}
// Plural returns the translation of the given key by using the language plural form.
func (p *Printer) Plural(key string, n int, args ...interface{}) string {
choices, found := defaultCatalog[p.language][key]
if found {
var plurals []string
switch v := choices.(type) {
case []interface{}:
for _, v := range v {
plurals = append(plurals, fmt.Sprint(v))
}
case []string:
plurals = v
default:
return key
}
pluralForm, found := pluralForms[p.language]
if !found {
pluralForm = pluralForms["default"]
}
index := pluralForm(n)
if len(plurals) > index {
return fmt.Sprintf(plurals[index], args...)
}
}
return key
}
// NewPrinter creates a new Printer.
func NewPrinter(language string) *Printer {
return &Printer{language}
}
miniflux-2.0.51/internal/locale/printer_test.go 0000664 0000000 0000000 00000011177 14546226260 0021574 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 TestTranslateWithMissingLanguage(t *testing.T) {
defaultCatalog = catalog{}
translation := NewPrinter("invalid").Printf("missing.key")
if translation != "missing.key" {
t.Errorf(`Wrong translation, got %q`, translation)
}
}
func TestTranslateWithMissingKey(t *testing.T) {
defaultCatalog = catalog{
"en_US": translationDict{
"k": "v",
},
}
translation := NewPrinter("en_US").Printf("missing.key")
if translation != "missing.key" {
t.Errorf(`Wrong translation, got %q`, translation)
}
}
func TestTranslateWithExistingKey(t *testing.T) {
defaultCatalog = catalog{
"en_US": translationDict{
"auth.username": "Login",
},
}
translation := NewPrinter("en_US").Printf("auth.username")
if translation != "Login" {
t.Errorf(`Wrong translation, got %q`, translation)
}
}
func TestTranslateWithExistingKeyAndPlaceholder(t *testing.T) {
defaultCatalog = catalog{
"en_US": translationDict{
"key": "Test: %s",
},
"fr_FR": translationDict{
"key": "Test : %s",
},
}
translation := NewPrinter("fr_FR").Printf("key", "ok")
if translation != "Test : ok" {
t.Errorf(`Wrong translation, got %q`, translation)
}
}
func TestTranslateWithMissingKeyAndPlaceholder(t *testing.T) {
defaultCatalog = catalog{
"en_US": translationDict{
"auth.username": "Login",
},
"fr_FR": translationDict{
"auth.username": "Identifiant",
},
}
translation := NewPrinter("fr_FR").Printf("Status: %s", "ok")
if translation != "Status: ok" {
t.Errorf(`Wrong translation, got %q`, translation)
}
}
func TestTranslateWithInvalidValue(t *testing.T) {
defaultCatalog = catalog{
"en_US": translationDict{
"auth.username": "Login",
},
"fr_FR": translationDict{
"auth.username": true,
},
}
translation := NewPrinter("fr_FR").Printf("auth.username")
if translation != "auth.username" {
t.Errorf(`Wrong translation, got %q`, translation)
}
}
func TestTranslatePluralWithDefaultRule(t *testing.T) {
defaultCatalog = catalog{
"en_US": translationDict{
"number_of_users": []string{"%d user (%s)", "%d users (%s)"},
},
"fr_FR": translationDict{
"number_of_users": []string{"%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 TestTranslatePluralWithRussianRule(t *testing.T) {
defaultCatalog = catalog{
"en_US": translationDict{
"time_elapsed.years": []string{"%d year", "%d years"},
},
"ru_RU": translationDict{
"time_elapsed.years": []string{"%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 TestTranslatePluralWithMissingTranslation(t *testing.T) {
defaultCatalog = catalog{
"en_US": translationDict{
"number_of_users": []string{"%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 TestTranslatePluralWithInvalidValues(t *testing.T) {
defaultCatalog = catalog{
"en_US": translationDict{
"number_of_users": []string{"%d user (%s)", "%d users (%s)"},
},
"fr_FR": translationDict{
"number_of_users": "must be a slice",
},
}
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)
}
}
miniflux-2.0.51/internal/locale/translations/ 0000775 0000000 0000000 00000000000 14546226260 0021235 5 ustar 00root root 0000000 0000000 miniflux-2.0.51/internal/locale/translations/de_DE.json 0000664 0000000 0000000 00000071642 14546226260 0023102 0 ustar 00root root 0000000 0000000 {
"confirm.question": "Sind Sie sicher?",
"confirm.question.refresh": "Möchten Sie eine erzwungene Aktualisierung durchführen?",
"confirm.yes": "ja",
"confirm.no": "nein",
"confirm.loading": "In Arbeit...",
"action.subscribe": "Abonnieren",
"action.save": "Speichern",
"action.or": "oder",
"action.cancel": "abbrechen",
"action.remove": "Entfernen",
"action.remove_feed": "Dieses Abonnement entfernen",
"action.update": "Aktualisieren",
"action.edit": "Bearbeiten",
"action.download": "Herunterladen",
"action.import": "Importieren",
"action.login": "Anmelden",
"action.home_screen": "Zum Startbildschirm hinzufügen",
"tooltip.keyboard_shortcuts": "Tastenkürzel: %s",
"tooltip.logged_user": "Angemeldet als %s",
"menu.unread": "Ungelesen",
"menu.starred": "Lesezeichen",
"menu.history": "Verlauf",
"menu.feeds": "Abonnements",
"menu.categories": "Kategorien",
"menu.settings": "Einstellungen",
"menu.logout": "Abmelden",
"menu.preferences": "Einstellungen",
"menu.integrations": "Dienste",
"menu.sessions": "Sitzungen",
"menu.users": "Benutzer",
"menu.about": "Über",
"menu.export": "Exportieren",
"menu.import": "Importieren",
"menu.create_category": "Kategorie anlegen",
"menu.mark_page_as_read": "Diese Seite als gelesen markieren",
"menu.mark_all_as_read": "Alle als gelesen markieren",
"menu.show_all_entries": "Zeige alle Artikel",
"menu.show_only_unread_entries": "Nur ungelesene Artikel anzeigen",
"menu.refresh_feed": "Aktualisieren",
"menu.refresh_all_feeds": "Alle Abonnements im Hintergrund aktualisieren",
"menu.edit_feed": "Bearbeiten",
"menu.edit_category": "Bearbeiten",
"menu.add_feed": "Abonnement hinzufügen",
"menu.add_user": "Benutzer anlegen",
"menu.flush_history": "Verlauf leeren",
"menu.feed_entries": "Artikel",
"menu.api_keys": "API-Schlüssel",
"menu.create_api_key": "Erstellen Sie einen neuen API-Schlüssel",
"menu.shared_entries": "Geteilte Artikel",
"search.label": "Suche",
"search.placeholder": "Suche...",
"pagination.next": "Nächste",
"pagination.previous": "Vorherige",
"entry.status.unread": "Ungelesen",
"entry.status.read": "Gelesen",
"entry.status.toast.unread": "Als ungelesen markiert",
"entry.status.toast.read": "Als gelesen markiert",
"entry.status.title": "Status des Artikels ändern",
"entry.bookmark.toggle.on": "Lesezeichen hinzufügen",
"entry.bookmark.toggle.off": "Lesezeichen entfernen",
"entry.bookmark.toast.on": "Markiert",
"entry.bookmark.toast.off": "Nicht markiert",
"entry.state.saving": "Speichern...",
"entry.state.loading": "Lade...",
"entry.save.label": "Speichern",
"entry.save.title": "Diesen Artikel speichern",
"entry.save.completed": "Erledigt!",
"entry.save.toast.completed": "Artikel gespeichert",
"entry.scraper.label": "Herunterladen",
"entry.scraper.title": "Inhalt herunterladen",
"entry.scraper.completed": "Erledigt!",
"entry.external_link.label": "Externer Link",
"entry.comments.label": "Kommentare",
"entry.comments.title": "Kommentare anzeigen",
"entry.share.label": "Teilen",
"entry.share.title": "Diesen Artikel teilen",
"entry.unshare.label": "Nicht teilen",
"entry.shared_entry.title": "Öffnen Sie den öffentlichen Link",
"entry.shared_entry.label": "Teilen",
"entry.estimated_reading_time": [
"%d Minute zu lesen",
"%d Minuten zu lesen"
],
"entry.tags.label": "Stichworte:",
"page.shared_entries.title": "Geteilte Artikel",
"page.unread.title": "Ungelesen",
"page.starred.title": "Lesezeichen",
"page.categories.title": "Kategorien",
"page.categories.no_feed": "Kein Abonnement.",
"page.categories.entries": "Artikel",
"page.categories.feeds": "Abonnements",
"page.categories.feed_count": [
"Es gibt %d Abonnement.",
"Es gibt %d Abonnements."
],
"page.categories.unread_counter": "Anzahl der ungelesenen Artikel",
"page.new_category.title": "Neue Kategorie",
"page.new_user.title": "Neuer Benutzer",
"page.edit_category.title": "Kategorie bearbeiten: %s",
"page.edit_user.title": "Benutzer bearbeiten: %s",
"page.feeds.title": "Abonnements",
"page.feeds.last_check": "Letzte Aktualisierung:",
"page.feeds.next_check": "Next check:",
"page.feeds.unread_counter": "Anzahl der ungelesenen Artikel",
"page.feeds.read_counter": "Anzahl der gelesenen Artikel",
"page.feeds.error_count": [
"%d Fehler",
"%d Fehler"
],
"page.history.title": "Verlauf",
"page.import.title": "Importieren",
"page.search.title": "Suchergebnisse",
"page.about.title": "Über",
"page.about.credits": "Urheberrechte",
"page.about.version": "Version:",
"page.about.build_date": "Datum der Kompilierung:",
"page.about.author": "Autor:",
"page.about.license": "Lizenz:",
"page.about.global_config_options": "Globale Konfigurationsoptionen",
"page.about.postgres_version": "Postgres Version:",
"page.about.go_version": "Go Version:",
"page.add_feed.title": "Neues Abonnement",
"page.add_feed.no_category": "Es ist keine Kategorie vorhanden. Wenigstens eine Kategorie muss angelegt sein.",
"page.add_feed.label.url": "URL",
"page.add_feed.submit": "Abonnement suchen",
"page.add_feed.legend.advanced_options": "Erweiterte Optionen",
"page.add_feed.choose_feed": "Abonnement auswählen",
"page.edit_feed.title": "Abonnement bearbeiten: %s",
"page.edit_feed.last_check": "Letzte Aktualisierung:",
"page.edit_feed.last_modified_header": "Zuletzt geändert:",
"page.edit_feed.etag_header": "ETag-Kopfzeile:",
"page.edit_feed.no_header": "Nicht verfügbar",
"page.edit_feed.last_parsing_error": "Letzter Analysefehler",
"page.entry.attachments": "Anlagen",
"page.keyboard_shortcuts.title": "Tastenkürzel",
"page.keyboard_shortcuts.subtitle.sections": "Navigation zwischen den Menüpunkten",
"page.keyboard_shortcuts.subtitle.items": "Navigation zwischen den Artikeln",
"page.keyboard_shortcuts.subtitle.pages": "Navigation zwischen den Seiten",
"page.keyboard_shortcuts.subtitle.actions": "Aktionen",
"page.keyboard_shortcuts.go_to_unread": "Zu den ungelesenen Artikeln gehen",
"page.keyboard_shortcuts.go_to_starred": "Zu den Lesezeichen gehen",
"page.keyboard_shortcuts.go_to_history": "Zum Verlauf gehen",
"page.keyboard_shortcuts.go_to_feeds": "Zu den Abonnements gehen",
"page.keyboard_shortcuts.go_to_categories": "Zu den Kategorien gehen",
"page.keyboard_shortcuts.go_to_settings": "Zu den Einstellungen gehen",
"page.keyboard_shortcuts.show_keyboard_shortcuts": "Liste der Tastenkürzel anzeigen",
"page.keyboard_shortcuts.go_to_previous_item": "Zum vorherigen Artikel gehen",
"page.keyboard_shortcuts.go_to_next_item": "Zum nächsten Artikel gehen",
"page.keyboard_shortcuts.go_to_feed": "Zum Abonnement gehen",
"page.keyboard_shortcuts.go_to_previous_page": "Zur vorherigen Seite gehen",
"page.keyboard_shortcuts.go_to_next_page": "Zur nächsten Seite gehen",
"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.open_comments": "Kommentare öffnen",
"page.keyboard_shortcuts.open_comments_same_window": "Öffne den Kommentare-Link in der aktuellen Registerkarte",
"page.keyboard_shortcuts.toggle_read_status_next": "Gewählten Artikel als gelesen/ungelesen markieren, fokus als nächstes",
"page.keyboard_shortcuts.toggle_read_status_prev": "Gewählten Artikel als gelesen/ungelesen markieren, fokus vorherige",
"page.keyboard_shortcuts.refresh_all_feeds": "Alle Abonnements im Hintergrund aktualisieren",
"page.keyboard_shortcuts.mark_page_as_read": "Aktuelle Seite als gelesen markieren",
"page.keyboard_shortcuts.download_content": "Vollständigen Inhalt herunterladen",
"page.keyboard_shortcuts.toggle_bookmark_status": "Lesezeichen hinzufügen/entfernen",
"page.keyboard_shortcuts.save_article": "Artikel speichern",
"page.keyboard_shortcuts.scroll_item_to_top": "Artikel nach oben blättern",
"page.keyboard_shortcuts.remove_feed": "Dieses Abonnement entfernen",
"page.keyboard_shortcuts.go_to_search": "Fokus auf das Suchformular setzen",
"page.keyboard_shortcuts.toggle_entry_attachments": "Artikel Anhänge öffnen/schließen",
"page.keyboard_shortcuts.close_modal": "Liste der Tastenkürzel schließen",
"page.users.title": "Benutzer",
"page.users.username": "Benutzername",
"page.users.never_logged": "Niemals",
"page.users.admin.yes": "Ja",
"page.users.admin.no": "Nein",
"page.users.actions": "Aktionen",
"page.users.last_login": "Letzte Anmeldung",
"page.users.is_admin": "Administrator",
"page.settings.title": "Einstellungen",
"page.settings.link_google_account": "Google Konto verknüpfen",
"page.settings.unlink_google_account": "Google Konto Verknüpfung entfernen",
"page.settings.link_oidc_account": "OpenID Connect Konto verknüpfen",
"page.settings.unlink_oidc_account": "OpenID Connect Konto Verknüpfung entfernen",
"page.settings.webauthn.passkeys": "Passkeys",
"page.settings.webauthn.actions": "Actions",
"page.settings.webauthn.passkey_name": "Passkey Name",
"page.settings.webauthn.added_on": "Added On",
"page.settings.webauthn.last_seen_on": "Last Used",
"page.settings.webauthn.register": "Hauptschlüssel registrieren",
"page.settings.webauthn.register.error": "Hauptschlüssel kann nicht registriert werden",
"page.settings.webauthn.delete": [
"Entfernen Sie %d Hauptschlüssel",
"%d Hauptschlüssel entfernen"
],
"page.login.title": "Anmeldung",
"page.login.google_signin": "Anmeldung mit Google",
"page.login.oidc_signin": "Anmeldung mit OpenID Connect",
"page.login.webauthn_login": "Melden Sie sich mit dem Hauptschlüssel an",
"page.login.webauthn_login.error": "Anmeldung mit Passkey nicht möglich",
"page.integrations.title": "Dienste",
"page.integration.miniflux_api": "Miniflux API",
"page.integration.miniflux_api_endpoint": "API Endpunkt",
"page.integration.miniflux_api_username": "Benutzername",
"page.integration.miniflux_api_password": "Passwort",
"page.integration.miniflux_api_password_value": "Ihr Konto Passwort",
"page.integration.bookmarklet": "Bookmarklet",
"page.integration.bookmarklet.name": "Mit Miniflux abonnieren",
"page.integration.bookmarklet.instructions": "Ziehen Sie diesen Link in Ihre Lesezeichen.",
"page.integration.bookmarklet.help": "Dieser spezielle Link ermöglicht es, eine Webseite direkt über ein Lesezeichen im Browser zu abonnieren.",
"page.sessions.title": "Sitzungen",
"page.sessions.table.date": "Datum",
"page.sessions.table.ip": "IP Addresse",
"page.sessions.table.user_agent": "Benutzeragent",
"page.sessions.table.actions": "Aktionen",
"page.sessions.table.current_session": "Aktuelle Sitzung",
"page.api_keys.title": "API-Schlüssel",
"page.api_keys.table.description": "Beschreibung",
"page.api_keys.table.token": "Zeichen",
"page.api_keys.table.last_used_at": "Zuletzt verwendeten",
"page.api_keys.table.created_at": "Erstellungsdatum",
"page.api_keys.table.actions": "Aktionen",
"page.api_keys.never_used": "Nie benutzt",
"page.new_api_key.title": "Neuer API-Schlüssel",
"page.offline.title": "Offline-Modus",
"page.offline.message": "Du bist offline",
"page.offline.refresh_page": "Versuchen Sie, die Seite zu aktualisieren",
"page.webauthn_rename.title": "Rename Passkey",
"alert.no_shared_entry": "Es existieren derzeit keine geteilten Artikel.",
"alert.no_bookmark": "Es existiert derzeit kein Lesezeichen.",
"alert.no_category": "Es ist keine Kategorie vorhanden.",
"alert.no_category_entry": "Es befindet sich kein Artikel in dieser Kategorie.",
"alert.no_feed_entry": "Es existiert kein Artikel für dieses Abonnement.",
"alert.no_feed": "Es sind keine Abonnements vorhanden.",
"alert.no_feed_in_category": "Für diese Kategorie gibt es kein Abonnement.",
"alert.no_history": "Es existiert zur Zeit kein Verlauf.",
"alert.feed_error": "Es gibt ein Problem mit diesem Abonnement",
"alert.no_search_result": "Es gibt kein Ergebnis für diese Suche.",
"alert.no_unread_entry": "Es existiert kein ungelesener Artikel.",
"alert.no_user": "Sie sind der einzige Benutzer.",
"alert.account_unlinked": "Ihr externer Account ist jetzt getrennt!",
"alert.account_linked": "Ihr externes Konto wurde verknüpft!",
"alert.pocket_linked": "Ihr Pocket Konto ist jetzt verknüpft!",
"alert.prefs_saved": "Einstellungen gespeichert!",
"error.unlink_account_without_password": "Sie müssen ein Passwort festlegen, sonst können Sie sich nicht erneut anmelden.",
"error.duplicate_linked_account": "Es ist bereits jemand mit diesem Anbieter assoziiert!",
"error.duplicate_fever_username": "Es existiert bereits jemand mit diesem Fever Benutzernamen!",
"error.duplicate_googlereader_username": "Es existiert bereits jemand mit diesem Google Reader Benutzernamen!",
"error.pocket_request_token": "Anfrage-Token konnte nicht von Pocket abgerufen werden!",
"error.pocket_access_token": "Zugriffstoken konnte nicht von Pocket abgerufen werden!",
"error.category_already_exists": "Diese Kategorie existiert bereits.",
"error.unable_to_create_category": "Diese Kategorie konnte nicht angelegt werden.",
"error.unable_to_update_category": "Diese Kategorie konnte nicht aktualisiert werden.",
"error.user_already_exists": "Dieser Benutzer existiert bereits.",
"error.unable_to_create_user": "Dieser Benutzer kann nicht erstellt werden.",
"error.unable_to_update_user": "Dieser Benutzer konnte nicht aktualisiert werden.",
"error.unable_to_update_feed": "Dieses Abonnement konnte nicht aktualisiert werden.",
"error.subscription_not_found": "Es wurden keine Abonnements gefunden.",
"error.empty_file": "Diese Datei ist leer.",
"error.bad_credentials": "Benutzername oder Passwort ungültig.",
"error.fields_mandatory": "Alle Felder sind obligatorisch.",
"error.title_required": "Der Titel ist obligatorisch.",
"error.different_passwords": "Passwörter stimmen nicht überein.",
"error.password_min_length": "Wenigstens 6 Zeichen müssen genutzt werden.",
"error.settings_mandatory_fields": "Die Felder für Benutzername, Thema, Sprache und Zeitzone sind obligatorisch.",
"error.settings_reading_speed_is_positive": "Die Lesegeschwindigkeiten müssen positive ganze Zahlen sein.",
"error.entries_per_page_invalid": "Die Anzahl der Einträge pro Seite ist ungültig.",
"error.feed_mandatory_fields": "Die URL und die Kategorie sind obligatorisch.",
"error.feed_already_exists": "Dieser Feed existiert bereits.",
"error.invalid_feed_url": "Ungültige Feed-URL.",
"error.invalid_site_url": "Ungültige Site-URL.",
"error.feed_url_not_empty": "Die Feed-URL darf nicht leer sein.",
"error.site_url_not_empty": "Die Site-URL darf nicht leer sein.",
"error.feed_title_not_empty": "Der Feed-Titel darf nicht leer sein.",
"error.feed_category_not_found": "Diese Kategorie existiert nicht oder gehört nicht zu diesem Benutzer.",
"error.feed_invalid_blocklist_rule": "Die Blockierregel ist ungültig.",
"error.feed_invalid_keeplist_rule": "Die Erlaubnisregel ist ungültig.",
"error.user_mandatory_fields": "Der Benutzername ist obligatorisch.",
"error.api_key_already_exists": "Dieser API-Schlüssel ist bereits vorhanden.",
"error.unable_to_create_api_key": "Dieser API-Schlüssel kann nicht erstellt werden.",
"error.invalid_theme": "Ungültiges Thema.",
"error.invalid_language": "Ungültige Sprache.",
"error.invalid_timezone": "Ungültige Zeitzone.",
"error.invalid_entry_direction": "Ungültige Sortierreihenfolge.",
"error.invalid_display_mode": "Progressive Web App (PWA) Anzeigemodus",
"error.invalid_gesture_nav": "Ungültige Gestennavigation.",
"error.invalid_default_home_page": "Ungültige Standard-Startseite!",
"form.feed.label.title": "Titel",
"form.feed.label.site_url": "Webseite-URL",
"form.feed.label.feed_url": "Abonnement-URL",
"form.feed.label.category": "Kategorie",
"form.feed.label.crawler": "Inhalt herunterladen",
"form.feed.label.feed_username": "Benutzername des Abonnements",
"form.feed.label.feed_password": "Passwort des Abonnements",
"form.feed.label.user_agent": "Standardbenutzeragenten überschreiben",
"form.feed.label.cookie": "Cookies setzen",
"form.feed.label.scraper_rules": "Extraktionsregeln",
"form.feed.label.rewrite_rules": "Umschreiberegeln",
"form.feed.label.blocklist_rules": "Blockierregeln",
"form.feed.label.keeplist_rules": "Erlaubnisregeln",
"form.feed.label.urlrewrite_rules": "Umschreibregeln für URL",
"form.feed.label.apprise_service_urls": "Kommaseparierte Liste der Apprise service URLs",
"form.feed.label.ignore_http_cache": "Ignoriere HTTP-cache",
"form.feed.label.allow_self_signed_certificates": "Erlaube selbstsignierte oder ungültige Zertifikate",
"form.feed.label.fetch_via_proxy": "Über Proxy abrufen",
"form.feed.label.disabled": "Dieses Abonnement nicht aktualisieren",
"form.feed.label.no_media_player": "No media player (audio/video)",
"form.feed.label.hide_globally": "Einträge in der globalen Ungelesen-Liste ausblenden",
"form.feed.fieldset.general": "General",
"form.feed.fieldset.rules": "Rules",
"form.feed.fieldset.network_settings": "Network Settings",
"form.feed.fieldset.integration": "Third-Party Services",
"form.category.label.title": "Titel",
"form.category.hide_globally": "Einträge in der globalen Ungelesen-Liste ausblenden",
"form.user.label.username": "Benutzername",
"form.user.label.password": "Passwort",
"form.user.label.confirmation": "Passwort Bestätigung",
"form.user.label.admin": "Administrator",
"form.prefs.label.language": "Sprache",
"form.prefs.label.timezone": "Zeitzone",
"form.prefs.label.theme": "Thema",
"form.prefs.label.entry_sorting": "Sortierung der Artikel",
"form.prefs.label.entries_per_page": "Einträge pro Seite",
"form.prefs.label.default_reading_speed": "Lesegeschwindigkeit für andere Sprachen (Wörter pro Minute)",
"form.prefs.label.cjk_reading_speed": "Lesegeschwindigkeit für Chinesisch, Koreanisch und Japanisch (Zeichen pro Minute)",
"form.prefs.label.display_mode": "Anzeigemodus der Web-App (muss neu installiert werden)",
"form.prefs.select.older_first": "Älteste Artikel zuerst",
"form.prefs.select.recent_first": "Neueste Artikel zuerst",
"form.prefs.select.fullscreen": "Vollbildschirm",
"form.prefs.select.standalone": "Eigenständige",
"form.prefs.select.minimal_ui": "Minimal",
"form.prefs.select.browser": "Browser",
"form.prefs.select.publish_time": "Eintrag veröffentlichte Zeit",
"form.prefs.select.created_time": "Eintrag erstellt Zeit",
"form.prefs.select.alphabetical": "Alphabetisch",
"form.prefs.select.unread_count": "Ungelesen zählen",
"form.prefs.select.none": "Keiner",
"form.prefs.select.tap": "Doppeltippen",
"form.prefs.select.swipe": "Wischen",
"form.prefs.label.keyboard_shortcuts": "Tastaturkürzel aktivieren",
"form.prefs.label.entry_swipe": "Aktivieren Sie das Streichen von Einträgen auf Touchscreens",
"form.prefs.label.gesture_nav": "Geste zum Navigieren zwischen Einträgen",
"form.prefs.label.show_reading_time": "Geschätzte Lesezeit für Artikel anzeigen",
"form.prefs.label.custom_css": "Benutzerdefiniertes CSS",
"form.prefs.label.entry_order": "Eintrag Sortierspalte",
"form.prefs.label.default_home_page": "Standard Startseite",
"form.prefs.label.categories_sorting_order": "Kategorien sortieren",
"form.prefs.label.mark_read_on_view": "Einträge automatisch als gelesen markieren, wenn sie angezeigt werden",
"form.prefs.fieldset.application_settings": "Application Settings",
"form.prefs.fieldset.authentication_settings": "Authentication Settings",
"form.prefs.fieldset.reader_settings": "Reader Settings",
"form.import.label.file": "OPML Datei",
"form.import.label.url": "URL",
"form.integration.fever_activate": "Fever API aktivieren",
"form.integration.fever_username": "Fever Benutzername",
"form.integration.fever_password": "Fever Passwort",
"form.integration.fever_endpoint": "Fever API Endpunkt:",
"form.integration.googlereader_activate": "Google Reader API aktivieren",
"form.integration.googlereader_username": "Google Reader Benutzername",
"form.integration.googlereader_password": "Google Reader Passwort",
"form.integration.googlereader_endpoint": "Google Reader API Endpunkt:",
"form.integration.pinboard_activate": "Artikel in Pinboard speichern",
"form.integration.pinboard_token": "Pinboard API Token",
"form.integration.pinboard_tags": "Pinboard Tags",
"form.integration.pinboard_bookmark": "Lesezeichen als ungelesen markieren",
"form.integration.instapaper_activate": "Artikel in Instapaper speichern",
"form.integration.instapaper_username": "Instapaper Benutzername",
"form.integration.instapaper_password": "Instapaper Passwort",
"form.integration.pocket_activate": "Artikel in Pocket speichern",
"form.integration.pocket_consumer_key": "Pocket Consumer Key",
"form.integration.pocket_access_token": "Pocket Access Token",
"form.integration.pocket_connect_link": "Verbinden Sie Ihr Pocket Konto",
"form.integration.wallabag_activate": "Artikel in Wallabag speichern",
"form.integration.wallabag_only_url": "Nur URL senden (anstelle des vollständigen Inhalts)",
"form.integration.wallabag_endpoint": "Wallabag URL",
"form.integration.wallabag_client_id": "Wallabag Client-ID",
"form.integration.wallabag_client_secret": "Wallabag Client-Secret",
"form.integration.wallabag_username": "Wallabag Benutzername",
"form.integration.wallabag_password": "Wallabag Passwort",
"form.integration.notion_activate": "Save entries to Notion",
"form.integration.notion_page_id": "Notion Page ID",
"form.integration.notion_token": "Notion Secret Token",
"form.integration.apprise_activate": "Push entries to Apprise",
"form.integration.apprise_url": "Apprise API URL",
"form.integration.apprise_services_url": "Kommaseparierte Liste der Apprise service URLs",
"form.integration.nunux_keeper_activate": "Artikel in Nunux Keeper speichern",
"form.integration.nunux_keeper_endpoint": "Nunux Keeper API-Endpunkt",
"form.integration.nunux_keeper_api_key": "Nunux Keeper API-Schlüssel",
"form.integration.omnivore_activate": "Artikel in Omnivore speichern",
"form.integration.omnivore_url": "Omnivore API-Endpunkt",
"form.integration.omnivore_api_key": "Omnivore API-Schlüssel",
"form.integration.espial_activate": "Artikel in Espial speichern",
"form.integration.espial_endpoint": "Espial API-Endpunkt",
"form.integration.espial_api_key": "Espial API-Schlüssel",
"form.integration.espial_tags": "Espial tags",
"form.integration.readwise_activate": "Save entries to Readwise Reader",
"form.integration.readwise_api_key": "Readwise Reader Access Token",
"form.integration.readwise_api_key_link": "Get your Readwise Access Token",
"form.integration.telegram_bot_activate": "Pushen Sie neue Artikel in den Telegram-Chat",
"form.integration.telegram_bot_token": "Bot token",
"form.integration.telegram_chat_id": "Chat ID",
"form.integration.telegram_topic_id": "Topic ID",
"form.integration.telegram_bot_disable_web_page_preview": "Disable web page preview",
"form.integration.telegram_bot_disable_notification": "Disable notification",
"form.integration.telegram_bot_disable_buttons": "Disable buttons",
"form.integration.linkding_activate": "Artikel in Linkding speichern",
"form.integration.linkding_endpoint": "Linkding API-Endpunkt",
"form.integration.linkding_api_key": "Linkding API-Schlüssel",
"form.integration.linkding_tags": "Linkding Tags",
"form.integration.linkding_bookmark": "Lesezeichen als ungelesen markieren",
"form.integration.matrix_bot_activate": "Neue Artikel in die Matrix übertragen",
"form.integration.matrix_bot_user": "Benutzername für Matrix",
"form.integration.matrix_bot_password": "Passwort für Matrix-Benutzer",
"form.integration.matrix_bot_url": "URL des Matrix-Servers",
"form.integration.matrix_bot_chat_id": "ID des Matrix-Raums",
"form.integration.shiori_activate": "Artikel in Shiori",
"form.integration.shiori_endpoint": "Shiori API-Endpunkt",
"form.integration.shiori_username": "Shiori Benutzername",
"form.integration.shiori_password": "Shiori Passwort",
"form.integration.shaarli_activate": "Save articles to Shaarli",
"form.integration.shaarli_endpoint": "Shaarli URL",
"form.integration.shaarli_api_secret": "Shaarli API Secret",
"form.integration.webhook_activate": "Enable Webhook",
"form.integration.webhook_url": "Webhook URL",
"form.integration.webhook_secret": "Webhook Secret",
"form.integration.rssbridge_activate": "Check RSS-Bridge when adding subscriptions",
"form.integration.rssbridge_url": "RSS-Bridge server URL",
"form.api_key.label.description": "API-Schlüsselbezeichnung",
"form.submit.loading": "Lade...",
"form.submit.saving": "Speichern...",
"time_elapsed.not_yet": "noch nicht",
"time_elapsed.yesterday": "gestern",
"time_elapsed.now": "gerade",
"time_elapsed.minutes": [
"vor %d Minute",
"vor %d Minuten"
],
"time_elapsed.hours": [
"vor %d Stunde",
"vor %d Stunden"
],
"time_elapsed.days": [
"vor %d Tag",
"vor %d Tagen"
],
"time_elapsed.weeks": [
"vor %d Woche",
"vor %d Wochen"
],
"time_elapsed.months": [
"vor %d Monat",
"vor %d Monaten"
],
"time_elapsed.years": [
"vor %d Jahr",
"vor %d Jahren"
],
"alert.too_many_feeds_refresh": "You have triggered too many feed refreshes. Please wait 30 minutes before trying again.",
"alert.background_feed_refresh": "All feeds are being refreshed in the background. You can continue to use Miniflux while this process is running.",
"error.http_response_too_large": "The HTTP response is too large. You could increase the HTTP response size limit in the global settings (requires a server restart).",
"error.http_body_read": "Unable to read the HTTP body: %v.",
"error.http_empty_response_body": "The HTTP response body is empty.",
"error.http_empty_response": "The HTTP response is empty. Perhaps, this website is using a bot protection mechanism?",
"error.tls_error": "TLS error: %v. You could disable TLS verification in the feed settings if you would like.",
"error.network_operation": "Miniflux is not able to reach this website due to a network error: %v.",
"error.network_timeout": "This website is too slow and the request timed out: %v",
"error.http_client_error": "HTTP client error: %v.",
"error.http_not_authorized": "Access to this website is not authorized. It could be a bad username or password.",
"error.http_too_many_requests": "Miniflux generated too many requests to this website. Please, try again later or change the application configuration.",
"error.http_forbidden": "Access to this website is forbidden. Perhaps, this website has a bot protection mechanism?",
"error.http_resource_not_found": "The requested resource is not found. Please, verify the URL.",
"error.http_internal_server_error": "The website is not available at the moment due to a server error. The problem is not on Miniflux side. Please, try again later.",
"error.http_bad_gateway": "The website is not available at the moment due to a bad gateway error. The problem is not on Miniflux side. Please, try again later.",
"error.http_service_unavailable": "The website is not available at the moment due to an internal server error. The problem is not on Miniflux side. Please, try again later.",
"error.http_gateway_timeout": "The website is not available at the moment due to a gateway timeout error. The problem is not on Miniflux side. Please, try again later.",
"error.http_unexpected_status_code": "The website is not available at the moment due to an unexpected HTTP status code: %d. The problem is not on Miniflux side. Please, try again later.",
"error.database_error": "Database error: %v.",
"error.category_not_found": "This category does not exist or does not belong to this user.",
"error.duplicated_feed": "This feed already exists.",
"error.unable_to_parse_feed": "Unable to parse this feed: %v.",
"error.feed_not_found": "This feed does not exist or does not belong to this user.",
"error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.",
"error.feed_format_not_detected": "Unable to detect feed format: %v."
}
miniflux-2.0.51/internal/locale/translations/el_EL.json 0000664 0000000 0000000 00000111453 14546226260 0023115 0 ustar 00root root 0000000 0000000 {
"confirm.question": "Είστε σίγουροι;",
"confirm.question.refresh": "Θέλετε να επιτελέσετε μια υποχρεωτική ανανέωση;",
"confirm.yes": "ναι",
"confirm.no": "όχι",
"confirm.loading": "Σε εξέλιξη...",
"action.subscribe": "Εγγραφείτε",
"action.save": "Αποθηκεύσετε",
"action.or": "ή",
"action.cancel": "ακύρωση",
"action.remove": "Κατάργηση",
"action.remove_feed": "Κατάργηση αυτής της ροής",
"action.update": "Ενημέρωση",
"action.edit": "Επεξεργασία",
"action.download": "Λήψη",
"action.import": "Εισαγωγή",
"action.login": "Σύνδεση",
"action.home_screen": "Προσθήκη στην αρχική οθόνη",
"tooltip.keyboard_shortcuts": "Συντόμευση πληκτρολογίου: % s",
"tooltip.logged_user": "Συνδεδεμένος/η ως %s",
"menu.unread": "Μη αναγνωσμένα",
"menu.starred": "Αγαπημένα",
"menu.history": "Ιστορικό",
"menu.feeds": "Ροές",
"menu.categories": "Κατηγορίες",
"menu.settings": "Ρυθμίσεις",
"menu.logout": "Αποσύνδεση",
"menu.preferences": "Προτιμήσεις",
"menu.integrations": "Ενσωμάτωσεις",
"menu.sessions": "Συνδέσεις",
"menu.users": "Χρήστες",
"menu.about": "Περί",
"menu.export": "Εξαγωγή",
"menu.import": "Εισαγωγή",
"menu.create_category": "Δημιουργήστε μια κατηγορία",
"menu.mark_page_as_read": "Σημείωση αυτής της σελίδας ως αναγνωσμένη",
"menu.mark_all_as_read": "Σημείωση όλων ως αναγνωσμένα",
"menu.show_all_entries": "Εμφάνιση όλων των καταχωρήσεων",
"menu.show_only_unread_entries": "Εμφάνιση μόνο μη αναγνωσμένων καταχωρήσεων",
"menu.refresh_feed": "Ανανέωση",
"menu.refresh_all_feeds": "Ανανέωση όλων των ροών στο παρασκήνιο",
"menu.edit_feed": "Επεξεργασία",
"menu.edit_category": "Επεξεργασία",
"menu.add_feed": "Προσθήκη συνδρομής",
"menu.add_user": "Προσθήκη χρήστη",
"menu.flush_history": "Εκκαθάριση ιστορικού",
"menu.feed_entries": "Καταχωρήσεις",
"menu.api_keys": "Κλειδιά API",
"menu.create_api_key": "Δημιουργήστε ένα νέο κλειδί API",
"menu.shared_entries": "Κοινόχρηστες καταχωρήσεις",
"search.label": "Αναζήτηση",
"search.placeholder": "Αναζήτηση...",
"pagination.next": "Επόμενη",
"pagination.previous": "Προηγούμενη",
"entry.status.unread": "Μη αναγνωσμένο",
"entry.status.read": "Αναγνωσμένο",
"entry.status.toast.unread": "Επισήμανση ως μη αναγνωσμένο",
"entry.status.toast.read": "Επισήμανση ως αναγνωσμένο",
"entry.status.title": "Αλλαγή κατάστασης καταχώρησης",
"entry.bookmark.toggle.on": "Αγαπημένο",
"entry.bookmark.toggle.off": "Αναίρεση αγαπημένου",
"entry.bookmark.toast.on": "Αγαπημένα",
"entry.bookmark.toast.off": "Μη αγαπημένα",
"entry.state.saving": "Aποθήκευση...",
"entry.state.loading": "Φόρτωση...",
"entry.save.label": "Αποθηκεύσετε",
"entry.save.title": "Αποθηκεύστε αυτό το άρθρο",
"entry.save.completed": "Έγινε!",
"entry.save.toast.completed": "Το άρθρο αποθηκεύτηκε",
"entry.scraper.label": "Λήψη",
"entry.scraper.title": "Λήψη αρχικού περιεχομένου",
"entry.scraper.completed": "Έγινε!",
"entry.external_link.label": "Εξωτερικός σύνδεσμος",
"entry.comments.label": "Σχόλια",
"entry.comments.title": "Δείτε Σχόλια",
"entry.share.label": "Διαμοιρασμός",
"entry.share.title": "Μοιραστείτε αυτό το άρθρο",
"entry.unshare.label": "Aναίρεση Διαμοιρασμού",
"entry.shared_entry.title": "Ανοίξτε τον δημόσιο σύνδεσμο",
"entry.shared_entry.label": "Διαμοιρασμός",
"entry.estimated_reading_time": [
"%d λεπτό ανάγνωση",
"%d λεπτά ανάγνωση"
],
"entry.tags.label": "Ετικέτες:",
"page.shared_entries.title": "Κοινόχρηστες Καταχωρήσεις",
"page.unread.title": "Μη αναγνωσμένα",
"page.starred.title": "Αγαπημένo",
"page.categories.title": "Κατηγορίες",
"page.categories.no_feed": "Καμία ροή.",
"page.categories.entries": "Άρθρα",
"page.categories.feeds": "Συνδρομές",
"page.categories.feed_count": [
"Υπάρχει μία %d ροή.",
"Υπάρχουν %d ροές."
],
"page.categories.unread_counter": "Αριθμός μη αναγνωσμένων καταχωρήσεων",
"page.new_category.title": "Νέα Κατηγορία",
"page.new_user.title": "Νέος Χρήστης",
"page.edit_category.title": "Επεξεργασία κατηγορίας: % s",
"page.edit_user.title": "Επεξεργασία χρήστη: % s",
"page.feeds.title": "Ροές",
"page.feeds.last_check": "Τελευταίος έλεγχος:",
"page.feeds.next_check": "Next check:",
"page.feeds.unread_counter": "Αριθμός μη αναγνωσμένων καταχωρήσεων",
"page.feeds.read_counter": "Αριθμός αναγνωσμένων καταχωρήσεων",
"page.feeds.error_count": [
"%d σφάλμα",
"%d σφάλματα"
],
"page.history.title": "Ιστορικό",
"page.import.title": "Εισαγωγή",
"page.search.title": "Αποτελέσματα Αναζήτησης",
"page.about.title": "Περί",
"page.about.credits": "Συνεισφέροντες",
"page.about.version": "Έκδοση:",
"page.about.build_date": "Ημερομηνία Κατασκευής:",
"page.about.author": "Συγγραφέας:",
"page.about.license": "Άδεια:",
"page.about.global_config_options": "Γενικές ρυθμίσεις",
"page.about.postgres_version": "Έκδοση Postgres:",
"page.about.go_version": "Έκδοση Go:",
"page.add_feed.title": "Νέα Συνδρομή",
"page.add_feed.no_category": "Δεν υπάρχει κατηγορία. Πρέπει να έχετε τουλάχιστον μία κατηγορία.",
"page.add_feed.label.url": "URL",
"page.add_feed.submit": "Βρείτε μια συνδρομή",
"page.add_feed.legend.advanced_options": "Προχωρημένες Επιλογές",
"page.add_feed.choose_feed": "Επιλέξτε μια συνδρομή",
"page.edit_feed.title": "Επεξεργασία ροής: % s",
"page.edit_feed.last_check": "Τελευταίος έλεγχος:",
"page.edit_feed.last_modified_header": "LastModified κεφαλίδα:",
"page.edit_feed.etag_header": "Κεφαλίδα ETag:",
"page.edit_feed.no_header": "Καμία",
"page.edit_feed.last_parsing_error": "Τελευταίο Σφάλμα Ανάλυσης",
"page.entry.attachments": "Συνημμένα",
"page.keyboard_shortcuts.title": "Συντομεύσεις Πληκτρολογίου",
"page.keyboard_shortcuts.subtitle.sections": "Πλοήγηση Τμημάτων",
"page.keyboard_shortcuts.subtitle.items": "Πλοήγηση Στοιχείων",
"page.keyboard_shortcuts.subtitle.pages": "Πλοήγηση Σελίδων",
"page.keyboard_shortcuts.subtitle.actions": "Ενέργειες",
"page.keyboard_shortcuts.go_to_unread": "Μεταβείτε στα μη αναγνωσμένα",
"page.keyboard_shortcuts.go_to_starred": "Μεταβείτε στους σελιδοδείκτες",
"page.keyboard_shortcuts.go_to_history": "Μεταβείτε στο ιστορικό",
"page.keyboard_shortcuts.go_to_feeds": "Μεταβείτε στις ροές",
"page.keyboard_shortcuts.go_to_categories": "Μεταβείτε στις κατηγορίες",
"page.keyboard_shortcuts.go_to_settings": "Μεταβείτε στις ρυθμίσεις",
"page.keyboard_shortcuts.show_keyboard_shortcuts": "Εμφάνιση συντομεύσεων πληκτρολογίου",
"page.keyboard_shortcuts.go_to_previous_item": "Μεταβείτε στο προηγούμενο στοιχείο",
"page.keyboard_shortcuts.go_to_next_item": "Μετάβαση στο επόμενο στοιχείο",
"page.keyboard_shortcuts.go_to_feed": "Πηγαίνετε στη ροή",
"page.keyboard_shortcuts.go_to_previous_page": "Μετάβαση στην προηγούμενη σελίδα",
"page.keyboard_shortcuts.go_to_next_page": "Μετάβαση στην επόμενη σελίδα",
"page.keyboard_shortcuts.open_item": "Άνοιγμα επιλεγμένου στοιχείου",
"page.keyboard_shortcuts.open_original": "Άνοιγμα αρχικού συνδέσμου",
"page.keyboard_shortcuts.open_original_same_window": "Άνοιγμα αρχικού συνδέσμου στην τρέχουσα καρτέλα",
"page.keyboard_shortcuts.open_comments": "Άνοιγμα συνδέσμου σχολίων",
"page.keyboard_shortcuts.open_comments_same_window": "Άνοιγμα συνδέσμου σχολίων στην τρέχουσα καρτέλα",
"page.keyboard_shortcuts.toggle_read_status_next": "Εναλλαγή ανάγνωσης / μη αναγνωσμένης, εστίαση στη συνέχεια",
"page.keyboard_shortcuts.toggle_read_status_prev": "Εναλλαγή ανάγνωσης / μη αναγνωσμένης, εστίαση στο προηγούμενο",
"page.keyboard_shortcuts.refresh_all_feeds": "Ανανέωση όλων των ροών στο παρασκήνιο",
"page.keyboard_shortcuts.mark_page_as_read": "Σημείωση της τρέχουσας σελίδας ως αναγνωσμένη",
"page.keyboard_shortcuts.download_content": "Κατεβάστε το αρχικό περιεχόμενο",
"page.keyboard_shortcuts.toggle_bookmark_status": "Εναλλαγή σελιδοδείκτη",
"page.keyboard_shortcuts.save_article": "Αποθήκευση άρθρου",
"page.keyboard_shortcuts.scroll_item_to_top": "Μετακινηση στοιχείου στην κορυφή",
"page.keyboard_shortcuts.remove_feed": "Κατάργηση αυτής της ροής",
"page.keyboard_shortcuts.go_to_search": "Ορίστε εστίαση στη φόρμα αναζήτησης",
"page.keyboard_shortcuts.toggle_entry_attachments": "Toggle open/close entry attachments",
"page.keyboard_shortcuts.close_modal": "Κλείσιμο παραθύρου διαλόγου",
"page.users.title": "Χρήστες",
"page.users.username": "Χρήστης",
"page.users.never_logged": "Ποτέ",
"page.users.admin.yes": "Ναι.",
"page.users.admin.no": "Όχι",
"page.users.actions": "Eνέργειες",
"page.users.last_login": "Τελευταία Σύνδεση",
"page.users.is_admin": "Διαχειριστής",
"page.settings.title": "Ρυθμίσεις",
"page.settings.link_google_account": "Σύνδεση του λογαριασμό μου Google",
"page.settings.unlink_google_account": "Αποσύνδεση του λογαριασμού μου Google",
"page.settings.link_oidc_account": "Σύνδεση του λογαριασμού μου OpenID Connect",
"page.settings.unlink_oidc_account": "Αποσύνδεση του λογαριασμού μου OpenID Connect",
"page.settings.webauthn.passkeys": "Passkeys",
"page.settings.webauthn.actions": "Actions",
"page.settings.webauthn.passkey_name": "Passkey Name",
"page.settings.webauthn.added_on": "Added On",
"page.settings.webauthn.last_seen_on": "Last Used",
"page.settings.webauthn.register": "Εγγραφή κωδικού πρόσβασης",
"page.settings.webauthn.register.error": "Δεν είναι δυνατή η εγγραφή του κωδικού πρόσβασης",
"page.settings.webauthn.delete": [
"Αφαιρέστε %d κωδικό πρόσβασης",
"Καταργήστε %d κωδικούς πρόσβασης"
],
"page.login.title": "Είσοδος",
"page.login.google_signin": "Συνδεθείτε με τo Google",
"page.login.oidc_signin": "Συνδεθείτε με το OpenID Connect",
"page.login.webauthn_login": "Είσοδος με κωδικό πρόσβασης",
"page.login.webauthn_login.error": "Δεν είναι δυνατή η σύνδεση με κωδικό πρόσβασης",
"page.integrations.title": "Ενσωμάτωση",
"page.integration.miniflux_api": "Miniflux API",
"page.integration.miniflux_api_endpoint": "Τελικό σημείο API",
"page.integration.miniflux_api_username": "Χρήστης",
"page.integration.miniflux_api_password": "Κωδικός",
"page.integration.miniflux_api_password_value": "Ο κωδικός πρόσβασης του λογαριασμού σας",
"page.integration.bookmarklet": "Bookmarklet",
"page.integration.bookmarklet.name": "Προσθήκη στο Miniflux",
"page.integration.bookmarklet.instructions": "Σύρετε και αποθέστε αυτόν τον σύνδεσμο στους σελιδοδείκτες σας.",
"page.integration.bookmarklet.help": "Αυτός ο ειδικός σύνδεσμος σάς επιτρέπει να εγγραφείτε απευθείας σε έναν ιστότοπο χρησιμοποιώντας ένα σελιδοδείκτη στο πρόγραμμα περιήγησης ιστού σας.",
"page.sessions.title": "Συνεδρίες",
"page.sessions.table.date": "Ημερομηνία",
"page.sessions.table.ip": "Διεύθυνση IP",
"page.sessions.table.user_agent": "User Agent",
"page.sessions.table.actions": "Eνέργειες",
"page.sessions.table.current_session": "Τρέχουσα Συνεδρία",
"page.api_keys.title": "Κλειδιά API",
"page.api_keys.table.description": "Περιγραφή",
"page.api_keys.table.token": "Token",
"page.api_keys.table.last_used_at": "Τελευταία Χρήση",
"page.api_keys.table.created_at": "Ημερομηνία Δημιουργίας",
"page.api_keys.table.actions": "Eνέργειες",
"page.api_keys.never_used": "Δεν έχει χρησιμοποιηθεί ποτέ",
"page.new_api_key.title": "Νέο κλειδί API",
"page.offline.title": "Λειτουργία Εκτός Σύνδεσης",
"page.offline.message": "Είστε εκτός σύνδεσης",
"page.offline.refresh_page": "Προσπαθήστε να ανανεώσετε τη σελίδα",
"page.webauthn_rename.title": "Rename Passkey",
"alert.no_shared_entry": "Δεν υπάρχει κοινόχρηστη καταχώρηση.",
"alert.no_bookmark": "Δεν υπάρχει σελιδοδείκτης αυτή τη στιγμή.",
"alert.no_category": "Δεν υπάρχει κατηγορία.",
"alert.no_category_entry": "Δεν υπάρχουν άρθρα σε αυτήν την κατηγορία.",
"alert.no_feed_entry": "Δεν υπάρχουν άρθρα για αυτήν τη ροή.",
"alert.no_feed": "Δεν έχετε συνδρομές.",
"alert.no_feed_in_category": "Δεν υπάρχει συνδρομή για αυτήν την κατηγορία.",
"alert.no_history": "Δεν υπάρχει ιστορικό αυτή τη στιγμή.",
"alert.feed_error": "Υπάρχει πρόβλημα με αυτήν τη ροή",
"alert.no_search_result": "Δεν υπάρχουν αποτελέσματα για αυτήν την αναζήτηση.",
"alert.no_unread_entry": "Δεν υπάρχουν μη αναγνωσμένα άρθρα.",
"alert.no_user": "Είστε ο μόνος χρήστης.",
"alert.account_unlinked": "Ο εξωτερικός σας λογαριασμός είναι πλέον αποσυνδεδεμένος!",
"alert.account_linked": "Ο εξωτερικός σας λογαριασμός είναι πλέον συνδεδεμένος!",
"alert.pocket_linked": "Ο λογαριασμός Pocket είναι τώρα συνδεδεμένος!",
"alert.prefs_saved": "Οι προτιμήσεις αποθηκεύτηκαν!",
"error.unlink_account_without_password": "Πρέπει να ορίσετε έναν κωδικό πρόσβασης διαφορετικά δεν θα μπορείτε να συνδεθείτε ξανά.",
"error.duplicate_linked_account": "Υπάρχει ήδη κάποιος που σχετίζεται με αυτόν τον πάροχο!",
"error.duplicate_fever_username": "Υπάρχει ήδη κάποιος άλλος με το ίδιο όνομα χρήστη Fever!",
"error.duplicate_googlereader_username": "Υπάρχει ήδη κάποιος άλλος με το ίδιο όνομα χρήστη Google Reader!",
"error.pocket_request_token": "Δεν είναι δυνατή η λήψη του request token από το Pocket!",
"error.pocket_access_token": "Δεν είναι δυνατή η λήψη του access token από το Pocket!",
"error.category_already_exists": "Αυτή η κατηγορία υπάρχει ήδη.",
"error.unable_to_create_category": "Δεν είναι δυνατή η δημιουργία αυτής της κατηγορίας.",
"error.unable_to_update_category": "Δεν είναι δυνατή η ενημέρωση αυτής της κατηγορίας.",
"error.user_already_exists": "Αυτός ο χρήστης υπάρχει ήδη.",
"error.unable_to_create_user": "Δεν είναι δυνατή η δημιουργία αυτού του χρήστη.",
"error.unable_to_update_user": "Δεν είναι δυνατή η ενημέρωση αυτού του χρήστη.",
"error.unable_to_update_feed": "Δεν είναι δυνατή η ενημέρωση αυτής της ροής.",
"error.subscription_not_found": "Δεν είναι δυνατή η εύρεση συνδρομής.",
"error.invalid_theme": "Μη έγκυρο θέμα.",
"error.invalid_language": "Μη έγκυρη γλώσσα.",
"error.invalid_timezone": "Μη έγκυρη ζώνη ώρας.",
"error.invalid_entry_direction": "Μη έγκυρη κατεύθυνση ταξινόμησης άρθρων.",
"error.invalid_display_mode": "Μη έγκυρη λειτουργία εμφάνισης εφαρμογών ιστού.",
"error.invalid_gesture_nav": "Μη έγκυρη πλοήγηση με χειρονομίες.",
"error.invalid_default_home_page": "Μη έγκυρη προεπιλεγμένη αρχική σελίδα!",
"error.empty_file": "Αυτό το αρχείο είναι κενό.",
"error.bad_credentials": "Μη έγκυρο όνομα χρήστη ή κωδικό πρόσβασης.",
"error.fields_mandatory": "Όλα τα πεδία είναι υποχρεωτικά.",
"error.title_required": "Ο τίτλος είναι υποχρεωτικός.",
"error.different_passwords": "Οι κωδικοί πρόσβασης δεν είναι οι ίδιοι.",
"error.password_min_length": "Ο κωδικός πρόσβασης πρέπει να έχει τουλάχιστον 6 χαρακτήρες.",
"error.settings_mandatory_fields": "Τα πεδία όνομα χρήστη, θέμα, Γλώσσα και ζώνη ώρας είναι υποχρεωτικά.",
"error.settings_reading_speed_is_positive": "Οι ταχύτητες ανάγνωσης πρέπει να είναι θετικοί ακέραιοι αριθμοί.",
"error.entries_per_page_invalid": "Ο αριθμός των καταχωρήσεων ανά σελίδα δεν είναι έγκυρος.",
"error.feed_mandatory_fields": "Η διεύθυνση URL και η κατηγορία είναι υποχρεωτικά.",
"error.feed_already_exists": "Αυτή η ροή υπάρχει ήδη.",
"error.invalid_feed_url": "Μη έγκυρη διεύθυνση URL ροής.",
"error.invalid_site_url": "Μη έγκυρη διεύθυνση URL ιστότοπου.",
"error.feed_url_not_empty": "Η διεύθυνση URL ροής δεν μπορεί να είναι κενή.",
"error.site_url_not_empty": "Η διεύθυνση URL του ιστότοπου δεν μπορεί να είναι κενή.",
"error.feed_title_not_empty": "Ο τίτλος ροής δεν μπορεί να είναι κενός.",
"error.feed_category_not_found": "Αυτή η κατηγορία δεν υπάρχει ή δεν ανήκει σε αυτόν τον χρήστη.",
"error.feed_invalid_blocklist_rule": "Ο κανόνας λίστας μπλοκ δεν είναι έγκυρος.",
"error.feed_invalid_keeplist_rule": "Ο κανόνας keep list δεν είναι έγκυρος.",
"form.feed.label.urlrewrite_rules": "επανεγγραφή κανόνων για τη διεύθυνση URL.",
"form.feed.label.apprise_service_urls": "Comma separated list of Apprise service URLs",
"error.user_mandatory_fields": "Το όνομα χρήστη είναι υποχρεωτικό.",
"error.api_key_already_exists": "Αυτό το κλειδί API υπάρχει ήδη.",
"error.unable_to_create_api_key": "Δεν είναι δυνατή η δημιουργία αυτού του κλειδιού API.",
"form.feed.label.title": "Τίτλος",
"form.feed.label.site_url": "Διεύθυνση URL ιστότοπου",
"form.feed.label.feed_url": "Διεύθυνση URL ροής",
"form.feed.label.category": "Κατηγορία",
"form.feed.label.crawler": "Λήψη αρχικού περιεχομένου",
"form.feed.label.feed_username": "Όνομα Χρήστη ροής",
"form.feed.label.feed_password": "Κωδικός Πρόσβασης ροής",
"form.feed.label.user_agent": "Παράκαμψη Προεπιλεγμένου User Agent Χρήστη",
"form.feed.label.cookie": "Ορισμός Cookies",
"form.feed.label.scraper_rules": "Κανόνες Scraper",
"form.feed.label.rewrite_rules": "Κανόνες Μετατροπής",
"form.feed.label.blocklist_rules": "Κανόνες Αποκλεισμού",
"form.feed.label.keeplist_rules": "Κρατήστε Κανόνες",
"form.feed.label.ignore_http_cache": "Αγνοήστε την προσωρινή μνήμη HTTP",
"form.feed.label.allow_self_signed_certificates": "Να επιτρέπονται αυτο-υπογεγραμμένα ή μη έγκυρα πιστοποιητικά",
"form.feed.label.fetch_via_proxy": "Λήψη μέσω διακομιστή μεσολάβησης",
"form.feed.label.disabled": "Μη ανανέωση αυτής της ροής",
"form.feed.label.no_media_player": "No media player (audio/video)",
"form.feed.label.hide_globally": "Απόκρυψη καταχωρήσεων σε γενική λίστα μη αναγνωσμένων",
"form.feed.fieldset.general": "General",
"form.feed.fieldset.rules": "Rules",
"form.feed.fieldset.network_settings": "Network Settings",
"form.feed.fieldset.integration": "Third-Party Services",
"form.category.label.title": "Τίτλος",
"form.category.hide_globally": "Απόκρυψη καταχωρήσεων σε γενική λίστα μη αναγνωσμένων",
"form.user.label.username": "Χρήστης",
"form.user.label.password": "Κωδικός",
"form.user.label.confirmation": "Επιβεβαίωση Κωδικού Πρόσβασης",
"form.user.label.admin": "Διαχειριστής",
"form.prefs.label.language": "Γλώσσα",
"form.prefs.label.timezone": "Ζώνη Ώρας",
"form.prefs.label.theme": "Θέμα",
"form.prefs.label.entry_sorting": "Ταξινόμηση",
"form.prefs.label.entries_per_page": "Καταχωρήσεις ανά σελίδα",
"form.prefs.label.default_reading_speed": "Ταχύτητα ανάγνωσης άλλων γλωσσών (λέξεις ανά λεπτό)",
"form.prefs.label.cjk_reading_speed": "Ταχύτητα ανάγνωσης για κινέζικα, κορεάτικα και ιαπωνικά (χαρακτήρες ανά λεπτό)",
"form.prefs.label.display_mode": "Λειτουργία προβολής προοδευτικής εφαρμογής Ιστού (PWA)",
"form.prefs.select.older_first": "Παλαιότερες καταχωρήσεις πρώτα",
"form.prefs.select.recent_first": "Πρόσφατες καταχωρήσεις πρώτα",
"form.prefs.select.fullscreen": "Πλήρης οθόνη",
"form.prefs.select.standalone": "Μεμονωμένο",
"form.prefs.select.minimal_ui": "Ελάχιστη",
"form.prefs.select.browser": "Περιηγητής",
"form.prefs.select.publish_time": "Δημοσιευμένος χρόνος εισόδου",
"form.prefs.select.created_time": "Χρόνος δημιουργίας καταχώρησης",
"form.prefs.select.alphabetical": "Αλφαβητική σειρά",
"form.prefs.select.unread_count": "Αριθμός μη αναγνωσμένων",
"form.prefs.select.none": "Κανένας",
"form.prefs.select.tap": "Διπλό χτύπημα",
"form.prefs.select.swipe": "Σουφρώνω",
"form.prefs.label.keyboard_shortcuts": "Ενεργοποίηση συντομεύσεων πληκτρολογίου",
"form.prefs.label.entry_swipe": "Ενεργοποιήστε το σάρωση καταχώρισης στις οθόνες αφής",
"form.prefs.label.gesture_nav": "Χειρονομία για πλοήγηση μεταξύ των καταχωρήσεων",
"form.prefs.label.show_reading_time": "Εμφάνιση εκτιμώμενου χρόνου ανάγνωσης για άρθρα",
"form.prefs.label.custom_css": "Προσαρμοσμένο CSS",
"form.prefs.label.entry_order": "Στήλη ταξινόμησης εισόδου",
"form.prefs.label.default_home_page": "Προεπιλεγμένη αρχική σελίδα",
"form.prefs.label.categories_sorting_order": "Ταξινόμηση κατηγοριών",
"form.prefs.label.mark_read_on_view": "Αυτόματη επισήμανση καταχωρήσεων ως αναγνωσμένων κατά την προβολή",
"form.prefs.fieldset.application_settings": "Application Settings",
"form.prefs.fieldset.authentication_settings": "Authentication Settings",
"form.prefs.fieldset.reader_settings": "Reader Settings",
"form.import.label.file": "Αρχείο OPML",
"form.import.label.url": "URL",
"form.integration.fever_activate": "Ενεργοποιήστε το Fever API",
"form.integration.fever_username": "Όνομα Χρήστη Fever",
"form.integration.fever_password": "Κωδικός Πρόσβασης Fever",
"form.integration.fever_endpoint": "Τελικό σημείο Fever API:",
"form.integration.googlereader_activate": "Ενεργοποιήστε το Google Reader API",
"form.integration.googlereader_username": "Όνομα Χρήστη Google Reader",
"form.integration.googlereader_password": "Κωδικός Πρόσβασης Google Reader",
"form.integration.googlereader_endpoint": "Τελικό σημείο Google Reader API:",
"form.integration.pinboard_activate": "Αποθήκευση άρθρων στο Pinboard",
"form.integration.pinboard_token": "Pinboard API Token",
"form.integration.pinboard_tags": "Ετικέτες Pinboard",
"form.integration.pinboard_bookmark": "Σημείωση του σελιδοδείκτη ως μη αναγνωσμένου",
"form.integration.instapaper_activate": "Αποθήκευση άρθρων στο Instapaper",
"form.integration.instapaper_username": "Όνομα Χρήστη Instapaper",
"form.integration.instapaper_password": "Κωδικός Πρόσβασης Instapaper",
"form.integration.pocket_activate": "Αποθήκευση άρθρων στο Pocket",
"form.integration.pocket_consumer_key": "Pocket Consumer Key",
"form.integration.pocket_access_token": "Pocket Access Token",
"form.integration.pocket_connect_link": "Συνδέστε τον λογαριασμό Pocket σας",
"form.integration.wallabag_activate": "Αποθήκευση άρθρων στο Wallabag",
"form.integration.wallabag_only_url": "Αποστολή μόνο URL (αντί για πλήρες περιεχόμενο)",
"form.integration.wallabag_endpoint": "Τελικό σημείο Wallabag API ",
"form.integration.wallabag_client_id": "Ταυτότητα πελάτη Wallabag",
"form.integration.wallabag_client_secret": "Wallabag Μυστικό Πελάτη",
"form.integration.wallabag_username": "Όνομα Χρήστη Wallabag",
"form.integration.wallabag_password": "Wallabag Κωδικός Πρόσβασης",
"form.integration.notion_activate": "Save entries to Notion",
"form.integration.notion_page_id": "Notion Page ID",
"form.integration.notion_token": "Notion Secret Token",
"form.integration.apprise_activate": "Push entries to Apprise",
"form.integration.apprise_url": "Apprise API URL",
"form.integration.apprise_services_url": "Comma separated list of Apprise service URLs",
"form.integration.nunux_keeper_activate": "Αποθήκευση άρθρων στο Nunux Keeper",
"form.integration.nunux_keeper_endpoint": "Τελικό σημείο Nunux Keeper API",
"form.integration.nunux_keeper_api_key": "Κλειδί API Nunux Keeper",
"form.integration.omnivore_activate": "Αποθήκευση άρθρων στο Omnivore",
"form.integration.omnivore_url": "Τελικό σημείο Omnivore API",
"form.integration.omnivore_api_key": "Κλειδί API Omnivore",
"form.integration.espial_activate": "Αποθήκευση άρθρων στο Espial",
"form.integration.espial_endpoint": "Τελικό σημείο Espial API",
"form.integration.espial_api_key": "Κλειδί API Espial",
"form.integration.espial_tags": "Ετικέτες Espial",
"form.integration.readwise_activate": "Save entries to Readwise Reader",
"form.integration.readwise_api_key": "Readwise Reader Access Token",
"form.integration.readwise_api_key_link": "Get your Readwise Access Token",
"form.integration.telegram_bot_activate": "Προωθήστε νέα άρθρα στη συνομιλία Telegram",
"form.integration.telegram_bot_token": "Διακριτικό bot",
"form.integration.telegram_chat_id": "Αναγνωριστικό συνομιλίας",
"form.integration.telegram_topic_id": "Topic ID",
"form.integration.telegram_bot_disable_web_page_preview": "Disable web page preview",
"form.integration.telegram_bot_disable_notification": "Disable notification",
"form.integration.telegram_bot_disable_buttons": "Disable buttons",
"form.integration.linkding_activate": "Αποθήκευση άρθρων στο Linkding",
"form.integration.linkding_endpoint": "Τελικό σημείο Linkding API",
"form.integration.linkding_api_key": "Κλειδί API Linkding",
"form.integration.linkding_tags": "Linkding Tags",
"form.integration.linkding_bookmark": "Σημείωση του σελιδοδείκτη ως μη αναγνωσμένου",
"form.integration.matrix_bot_activate": "Μεταφορά νέων άρθρων στο Matrix",
"form.integration.matrix_bot_user": "Όνομα χρήστη για το Matrix",
"form.integration.matrix_bot_password": "Κωδικός πρόσβασης για τον χρήστη Matrix",
"form.integration.matrix_bot_url": "URL διακομιστή Matrix",
"form.integration.matrix_bot_chat_id": "Αναγνωριστικό της αίθουσας Matrix",
"form.integration.shiori_activate": "Αποθήκευση άρθρων στο Shiori",
"form.integration.shiori_endpoint": "Τελικό σημείο Shiori",
"form.integration.shiori_username": "Όνομα Χρήστη Shiori",
"form.integration.shiori_password": "Κωδικός Πρόσβασης Shiori",
"form.integration.shaarli_activate": "Save articles to Shaarli",
"form.integration.shaarli_endpoint": "Shaarli URL",
"form.integration.shaarli_api_secret": "Shaarli API Secret",
"form.integration.webhook_activate": "Enable Webhook",
"form.integration.webhook_url": "Webhook URL",
"form.integration.webhook_secret": "Webhook Secret",
"form.integration.rssbridge_activate": "Check RSS-Bridge when adding subscriptions",
"form.integration.rssbridge_url": "RSS-Bridge server URL",
"form.api_key.label.description": "Ετικέτα κλειδιού API",
"form.submit.loading": "Φόρτωση...",
"form.submit.saving": "Αποθήκευση...",
"time_elapsed.not_yet": "όχι ακόμα.",
"time_elapsed.yesterday": "χθες",
"time_elapsed.now": "μόλις τώρα",
"time_elapsed.minutes": [
"πριν %d λεπτό",
"πριν %d λεπτά"
],
"time_elapsed.hours": [
"πριν %d ώρα",
"πριν %d ώρες"
],
"time_elapsed.days": [
"πριν %d ημέρα",
"πριν %d ημέρες"
],
"time_elapsed.weeks": [
"πριν %d εβδομάδα",
"πριν %d εβδομάδες"
],
"time_elapsed.months": [
"πριν %d μήνα",
"πριν %d μήνες"
],
"time_elapsed.years": [
"πριν %d έτος",
"πριν %d έτη"
],
"alert.too_many_feeds_refresh": "You have triggered too many feed refreshes. Please wait 30 minutes before trying again.",
"alert.background_feed_refresh": "All feeds are being refreshed in the background. You can continue to use Miniflux while this process is running.",
"error.http_response_too_large": "The HTTP response is too large. You could increase the HTTP response size limit in the global settings (requires a server restart).",
"error.http_body_read": "Unable to read the HTTP body: %v.",
"error.http_empty_response_body": "The HTTP response body is empty.",
"error.http_empty_response": "The HTTP response is empty. Perhaps, this website is using a bot protection mechanism?",
"error.tls_error": "TLS error: %v. You could disable TLS verification in the feed settings if you would like.",
"error.network_operation": "Miniflux is not able to reach this website due to a network error: %v.",
"error.network_timeout": "This website is too slow and the request timed out: %v",
"error.http_client_error": "HTTP client error: %v.",
"error.http_not_authorized": "Access to this website is not authorized. It could be a bad username or password.",
"error.http_too_many_requests": "Miniflux generated too many requests to this website. Please, try again later or change the application configuration.",
"error.http_forbidden": "Access to this website is forbidden. Perhaps, this website has a bot protection mechanism?",
"error.http_resource_not_found": "The requested resource is not found. Please, verify the URL.",
"error.http_internal_server_error": "The website is not available at the moment due to a server error. The problem is not on Miniflux side. Please, try again later.",
"error.http_bad_gateway": "The website is not available at the moment due to a bad gateway error. The problem is not on Miniflux side. Please, try again later.",
"error.http_service_unavailable": "The website is not available at the moment due to an internal server error. The problem is not on Miniflux side. Please, try again later.",
"error.http_gateway_timeout": "The website is not available at the moment due to a gateway timeout error. The problem is not on Miniflux side. Please, try again later.",
"error.http_unexpected_status_code": "The website is not available at the moment due to an unexpected HTTP status code: %d. The problem is not on Miniflux side. Please, try again later.",
"error.database_error": "Database error: %v.",
"error.category_not_found": "This category does not exist or does not belong to this user.",
"error.duplicated_feed": "This feed already exists.",
"error.unable_to_parse_feed": "Unable to parse this feed: %v.",
"error.feed_not_found": "This feed does not exist or does not belong to this user.",
"error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.",
"error.feed_format_not_detected": "Unable to detect feed format: %v."
}
miniflux-2.0.51/internal/locale/translations/en_US.json 0000664 0000000 0000000 00000065517 14546226260 0023157 0 ustar 00root root 0000000 0000000 {
"confirm.question": "Are you sure?",
"confirm.question.refresh": "Are you sure you want to force refresh?",
"confirm.yes": "yes",
"confirm.no": "no",
"confirm.loading": "In progress…",
"action.subscribe": "Subscribe",
"action.save": "Save",
"action.or": "or",
"action.cancel": "cancel",
"action.remove": "Remove",
"action.remove_feed": "Remove this feed",
"action.update": "Update",
"action.edit": "Edit",
"action.download": "Download",
"action.import": "Import",
"action.login": "Login",
"action.home_screen": "Add to home screen",
"tooltip.keyboard_shortcuts": "Keyboard Shortcut: %s",
"tooltip.logged_user": "Logged in as %s",
"menu.unread": "Unread",
"menu.starred": "Starred",
"menu.history": "History",
"menu.feeds": "Feeds",
"menu.categories": "Categories",
"menu.settings": "Settings",
"menu.logout": "Logout",
"menu.preferences": "Preferences",
"menu.integrations": "Integrations",
"menu.sessions": "Sessions",
"menu.users": "Users",
"menu.about": "About",
"menu.export": "Export",
"menu.import": "Import",
"menu.create_category": "Create a category",
"menu.mark_page_as_read": "Mark this page as read",
"menu.mark_all_as_read": "Mark all as read",
"menu.show_all_entries": "Show all entries",
"menu.show_only_unread_entries": "Show only unread entries",
"menu.refresh_feed": "Refresh",
"menu.refresh_all_feeds": "Refresh all feeds in the background",
"menu.edit_feed": "Edit",
"menu.edit_category": "Edit",
"menu.add_feed": "Add feed",
"menu.add_user": "Add user",
"menu.flush_history": "Flush history",
"menu.feed_entries": "Entries",
"menu.api_keys": "API Keys",
"menu.create_api_key": "Create a new API key",
"menu.shared_entries": "Shared entries",
"search.label": "Search",
"search.placeholder": "Search…",
"pagination.next": "Next",
"pagination.previous": "Previous",
"entry.status.unread": "Unread",
"entry.status.read": "Read",
"entry.status.toast.unread": "Marked as unread",
"entry.status.toast.read": "Marked as read",
"entry.status.title": "Change entry status",
"entry.bookmark.toggle.on": "Star",
"entry.bookmark.toggle.off": "Unstar",
"entry.bookmark.toast.on": "Starred",
"entry.bookmark.toast.off": "Unstarred",
"entry.state.saving": "Saving…",
"entry.state.loading": "Loading…",
"entry.save.label": "Save",
"entry.save.title": "Save this entry",
"entry.save.completed": "Done!",
"entry.save.toast.completed": "Entry saved",
"entry.scraper.label": "Download",
"entry.scraper.title": "Fetch original content",
"entry.scraper.completed": "Done!",
"entry.external_link.label": "External link",
"entry.comments.label": "Comments",
"entry.comments.title": "View Comments",
"entry.share.label": "Share",
"entry.share.title": "Share this entry",
"entry.unshare.label": "Unshare",
"entry.shared_entry.title": "Open the public link",
"entry.shared_entry.label": "Share",
"entry.estimated_reading_time": [
"%d minute read",
"%d minutes read"
],
"entry.tags.label": "Tags:",
"page.shared_entries.title": "Shared entries",
"page.unread.title": "Unread",
"page.starred.title": "Starred",
"page.categories.title": "Categories",
"page.categories.no_feed": "No feed.",
"page.categories.entries": "Entries",
"page.categories.feeds": "Feeds",
"page.categories.feed_count": [
"There is %d feed.",
"There are %d feeds."
],
"page.categories.unread_counter": "Number of unread entries",
"page.new_category.title": "New Category",
"page.new_user.title": "New User",
"page.edit_category.title": "Edit Category: %s",
"page.edit_user.title": "Edit User: %s",
"page.feeds.title": "Feeds",
"page.feeds.last_check": "Last check:",
"page.feeds.next_check": "Next check:",
"page.feeds.unread_counter": "Number of unread entries",
"page.feeds.read_counter": "Number of read entries",
"page.feeds.error_count": [
"%d error",
"%d errors"
],
"page.history.title": "History",
"page.import.title": "Import",
"page.search.title": "Search Results",
"page.about.title": "About",
"page.about.credits": "Credits",
"page.about.version": "Version:",
"page.about.build_date": "Build Date:",
"page.about.author": "Author:",
"page.about.license": "License:",
"page.about.global_config_options": "Global configuration options",
"page.about.postgres_version": "Postgres version:",
"page.about.go_version": "Go version:",
"page.add_feed.title": "New feed",
"page.add_feed.no_category": "There is no category. You must have at least one category.",
"page.add_feed.label.url": "URL",
"page.add_feed.submit": "Find a feed",
"page.add_feed.legend.advanced_options": "Advanced Options",
"page.add_feed.choose_feed": "Choose a feed",
"page.edit_feed.title": "Edit Feed: %s",
"page.edit_feed.last_check": "Last check:",
"page.edit_feed.last_modified_header": "LastModified header:",
"page.edit_feed.etag_header": "ETag header:",
"page.edit_feed.no_header": "None",
"page.edit_feed.last_parsing_error": "Last Parsing Error",
"page.entry.attachments": "Attachments",
"page.keyboard_shortcuts.title": "Keyboard Shortcuts",
"page.keyboard_shortcuts.subtitle.sections": "Sections Navigation",
"page.keyboard_shortcuts.subtitle.items": "Items Navigation",
"page.keyboard_shortcuts.subtitle.pages": "Pages Navigation",
"page.keyboard_shortcuts.subtitle.actions": "Actions",
"page.keyboard_shortcuts.go_to_unread": "Go to unread",
"page.keyboard_shortcuts.go_to_starred": "Go to starred",
"page.keyboard_shortcuts.go_to_history": "Go to history",
"page.keyboard_shortcuts.go_to_feeds": "Go to feeds",
"page.keyboard_shortcuts.go_to_categories": "Go to categories",
"page.keyboard_shortcuts.go_to_settings": "Go to settings",
"page.keyboard_shortcuts.show_keyboard_shortcuts": "Show keyboard shortcuts",
"page.keyboard_shortcuts.go_to_previous_item": "Go to previous item",
"page.keyboard_shortcuts.go_to_next_item": "Go to next item",
"page.keyboard_shortcuts.go_to_feed": "Go to feed",
"page.keyboard_shortcuts.go_to_previous_page": "Go to previous page",
"page.keyboard_shortcuts.go_to_next_page": "Go to next page",
"page.keyboard_shortcuts.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.open_comments": "Open comments link",
"page.keyboard_shortcuts.open_comments_same_window": "Open comments link in current tab",
"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.keyboard_shortcuts.refresh_all_feeds": "Refresh all feeds in the background",
"page.keyboard_shortcuts.mark_page_as_read": "Mark current page as read",
"page.keyboard_shortcuts.download_content": "Download original content",
"page.keyboard_shortcuts.toggle_bookmark_status": "Toggle starred",
"page.keyboard_shortcuts.save_article": "Save entry",
"page.keyboard_shortcuts.scroll_item_to_top": "Scroll item to top",
"page.keyboard_shortcuts.remove_feed": "Remove this feed",
"page.keyboard_shortcuts.go_to_search": "Set focus on search form",
"page.keyboard_shortcuts.toggle_entry_attachments": "Toggle open/close entry attachments",
"page.keyboard_shortcuts.close_modal": "Close modal dialog",
"page.users.title": "Users",
"page.users.username": "Username",
"page.users.never_logged": "Never",
"page.users.admin.yes": "Yes",
"page.users.admin.no": "No",
"page.users.actions": "Actions",
"page.users.last_login": "Last Login",
"page.users.is_admin": "Administrator",
"page.settings.title": "Settings",
"page.settings.link_google_account": "Link my Google account",
"page.settings.unlink_google_account": "Unlink my Google account",
"page.settings.link_oidc_account": "Link my OpenID Connect account",
"page.settings.unlink_oidc_account": "Unlink my OpenID Connect account",
"page.settings.webauthn.passkeys": "Passkeys",
"page.settings.webauthn.actions": "Actions",
"page.settings.webauthn.passkey_name": "Passkey Name",
"page.settings.webauthn.added_on": "Added On",
"page.settings.webauthn.last_seen_on": "Last Used",
"page.settings.webauthn.register": "Register passkey",
"page.settings.webauthn.register.error": "Unable to register passkey",
"page.settings.webauthn.delete" : [
"Remove %d passkey",
"Remove %d passkeys"
],
"page.login.title": "Sign In",
"page.login.google_signin": "Sign in with Google",
"page.login.oidc_signin": "Sign in with OpenID Connect",
"page.login.webauthn_login": "Login with passkey",
"page.login.webauthn_login.error": "Unable to login with passkey",
"page.integrations.title": "Integrations",
"page.integration.miniflux_api": "Miniflux API",
"page.integration.miniflux_api_endpoint": "API Endpoint",
"page.integration.miniflux_api_username": "Username",
"page.integration.miniflux_api_password": "Password",
"page.integration.miniflux_api_password_value": "Your account password",
"page.integration.bookmarklet": "Bookmarklet",
"page.integration.bookmarklet.name": "Add to Miniflux",
"page.integration.bookmarklet.instructions": "Drag and drop this link to your bookmarks.",
"page.integration.bookmarklet.help": "This special link allows you to subscribe to a website directly by using a bookmark in your web browser.",
"page.sessions.title": "Sessions",
"page.sessions.table.date": "Date",
"page.sessions.table.ip": "IP Address",
"page.sessions.table.user_agent": "User Agent",
"page.sessions.table.actions": "Actions",
"page.sessions.table.current_session": "Current Session",
"page.api_keys.title": "API Keys",
"page.api_keys.table.description": "Description",
"page.api_keys.table.token": "Token",
"page.api_keys.table.last_used_at": "Last Used",
"page.api_keys.table.created_at": "Creation Date",
"page.api_keys.table.actions": "Actions",
"page.api_keys.never_used": "Never Used",
"page.new_api_key.title": "New API Key",
"page.offline.title": "Offline Mode",
"page.offline.message": "You are offline",
"page.offline.refresh_page": "Try to refresh the page",
"page.webauthn_rename.title": "Rename Passkey",
"alert.no_shared_entry": "There is no shared entry.",
"alert.no_bookmark": "There are no starred entries.",
"alert.no_category": "There is no category.",
"alert.no_category_entry": "There are no entries in this category.",
"alert.no_feed_entry": "There are no entries for this feed.",
"alert.no_feed": "You don’t have any feeds.",
"alert.no_feed_in_category": "There is no feed for this category.",
"alert.no_history": "There is no history at the moment.",
"alert.feed_error": "There is a problem with this feed",
"alert.no_search_result": "There are no results for this search.",
"alert.no_unread_entry": "There are no unread entries.",
"alert.no_user": "You are the only user.",
"alert.account_unlinked": "Your external account is now dissociated!",
"alert.account_linked": "Your external account is now linked!",
"alert.pocket_linked": "Your Pocket account is now linked!",
"alert.prefs_saved": "Preferences saved!",
"error.unlink_account_without_password": "You must define a password otherwise you won’t be able to login again.",
"error.duplicate_linked_account": "There is already someone associated with this provider!",
"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.pocket_request_token": "Unable to fetch request token from Pocket!",
"error.pocket_access_token": "Unable to fetch access token from Pocket!",
"error.category_already_exists": "This category already exists.",
"error.unable_to_create_category": "Unable to create this category.",
"error.unable_to_update_category": "Unable to update this category.",
"error.user_already_exists": "This user already exists.",
"error.unable_to_create_user": "Unable to create this user.",
"error.unable_to_update_user": "Unable to update this user.",
"error.unable_to_update_feed": "Unable to update this feed.",
"error.subscription_not_found": "Unable to find any feed.",
"error.invalid_theme": "Invalid theme.",
"error.invalid_language": "Invalid language.",
"error.invalid_timezone": "Invalid timezone.",
"error.invalid_entry_direction": "Invalid entry direction.",
"error.invalid_display_mode": "Invalid web app display mode.",
"error.invalid_gesture_nav": "Invalid gesture navigation.",
"error.invalid_default_home_page": "Invalid default homepage!",
"error.empty_file": "This file is empty.",
"error.bad_credentials": "Invalid username or password.",
"error.fields_mandatory": "All fields are mandatory.",
"error.title_required": "The title is mandatory.",
"error.different_passwords": "Passwords are not the same.",
"error.password_min_length": "The password must have at least 6 characters.",
"error.settings_mandatory_fields": "The username, theme, language and timezone fields are mandatory.",
"error.settings_reading_speed_is_positive": "The reading speeds must be positive integers.",
"error.entries_per_page_invalid": "The number of entries per page is not valid.",
"error.feed_mandatory_fields": "The URL and the category are mandatory.",
"error.feed_already_exists": "This feed already exists.",
"error.invalid_feed_url": "Invalid feed URL.",
"error.invalid_site_url": "Invalid site URL.",
"error.feed_url_not_empty": "The feed URL cannot be empty.",
"error.site_url_not_empty": "The site URL cannot be empty.",
"error.feed_title_not_empty": "The feed title cannot be empty.",
"error.feed_category_not_found": "This category does not exist or does not belong to this user.",
"error.feed_invalid_blocklist_rule": "The block list rule is invalid.",
"error.feed_invalid_keeplist_rule": "The keep list rule is invalid.",
"error.user_mandatory_fields": "The username is mandatory.",
"error.api_key_already_exists": "This API Key already exists.",
"error.unable_to_create_api_key": "Unable to create this API Key.",
"form.feed.label.title": "Title",
"form.feed.label.site_url": "Site URL",
"form.feed.label.feed_url": "Feed URL",
"form.feed.label.category": "Category",
"form.feed.label.crawler": "Fetch original content",
"form.feed.label.feed_username": "Feed Username",
"form.feed.label.feed_password": "Feed Password",
"form.feed.label.user_agent": "Override Default User Agent",
"form.feed.label.cookie": "Set Cookies",
"form.feed.label.scraper_rules": "Scraper Rules",
"form.feed.label.rewrite_rules": "Rewrite Rules",
"form.feed.label.apprise_service_urls": "Comma separated list of Apprise service URLs",
"form.feed.label.blocklist_rules": "Block Rules",
"form.feed.label.keeplist_rules": "Keep Rules",
"form.feed.label.urlrewrite_rules": "URL Rewrite Rules",
"form.feed.label.ignore_http_cache": "Ignore HTTP cache",
"form.feed.label.allow_self_signed_certificates": "Allow self-signed or invalid certificates",
"form.feed.label.fetch_via_proxy": "Fetch via proxy",
"form.feed.label.disabled": "Do not refresh this feed",
"form.feed.label.no_media_player": "No media player (audio/video)",
"form.feed.label.hide_globally": "Hide entries in global unread list",
"form.feed.fieldset.general": "General",
"form.feed.fieldset.rules": "Rules",
"form.feed.fieldset.network_settings": "Network Settings",
"form.feed.fieldset.integration": "Third-Party Services",
"form.category.label.title": "Title",
"form.category.hide_globally": "Hide entries in global unread list",
"form.user.label.username": "Username",
"form.user.label.password": "Password",
"form.user.label.confirmation": "Password Confirmation",
"form.user.label.admin": "Administrator",
"form.prefs.label.language": "Language",
"form.prefs.label.timezone": "Timezone",
"form.prefs.label.theme": "Theme",
"form.prefs.label.entry_sorting": "Entry sorting",
"form.prefs.label.entries_per_page": "Entries per page",
"form.prefs.label.default_reading_speed": "Reading speed for other languages (words per minute)",
"form.prefs.label.cjk_reading_speed": "Reading speed for Chinese, Korean and Japanese (characters per minute)",
"form.prefs.label.display_mode": "Progressive Web App (PWA) display mode",
"form.prefs.select.older_first": "Older entries first",
"form.prefs.select.recent_first": "Recent entries first",
"form.prefs.select.fullscreen": "Fullscreen",
"form.prefs.select.standalone": "Standalone",
"form.prefs.select.minimal_ui": "Minimal",
"form.prefs.select.browser": "Browser",
"form.prefs.select.publish_time": "Entry published time",
"form.prefs.select.created_time": "Entry created time",
"form.prefs.select.alphabetical": "Alphabetical",
"form.prefs.select.unread_count": "Unread count",
"form.prefs.select.none": "None",
"form.prefs.select.tap": "Double tap",
"form.prefs.select.swipe": "Swipe",
"form.prefs.label.keyboard_shortcuts": "Enable keyboard shortcuts",
"form.prefs.label.entry_swipe": "Enable entry swipe on touch screens",
"form.prefs.label.gesture_nav": "Gesture to navigate between entries",
"form.prefs.label.show_reading_time": "Show estimated reading time for entries",
"form.prefs.label.custom_css": "Custom CSS",
"form.prefs.label.entry_order": "Entry sorting column",
"form.prefs.label.default_home_page": "Default home page",
"form.prefs.label.categories_sorting_order": "Categories sorting",
"form.prefs.label.mark_read_on_view": "Automatically mark entries as read when viewed",
"form.prefs.fieldset.application_settings": "Application Settings",
"form.prefs.fieldset.authentication_settings": "Authentication Settings",
"form.prefs.fieldset.reader_settings": "Reader Settings",
"form.import.label.file": "OPML file",
"form.import.label.url": "URL",
"form.integration.fever_activate": "Activate Fever API",
"form.integration.fever_username": "Fever Username",
"form.integration.fever_password": "Fever Password",
"form.integration.fever_endpoint": "Fever API endpoint:",
"form.integration.googlereader_activate": "Activate Google Reader API",
"form.integration.googlereader_username": "Google Reader Username",
"form.integration.googlereader_password": "Google Reader Password",
"form.integration.googlereader_endpoint": "Google Reader API endpoint:",
"form.integration.pinboard_activate": "Save entries to Pinboard",
"form.integration.pinboard_token": "Pinboard API Token",
"form.integration.pinboard_tags": "Pinboard Tags",
"form.integration.pinboard_bookmark": "Mark bookmark as unread",
"form.integration.instapaper_activate": "Save entries to Instapaper",
"form.integration.instapaper_username": "Instapaper Username",
"form.integration.instapaper_password": "Instapaper Password",
"form.integration.pocket_activate": "Save entries to Pocket",
"form.integration.pocket_consumer_key": "Pocket Consumer Key",
"form.integration.pocket_access_token": "Pocket Access Token",
"form.integration.pocket_connect_link": "Connect your Pocket account",
"form.integration.wallabag_activate": "Save entries to Wallabag",
"form.integration.wallabag_only_url": "Send only URL (instead of full content)",
"form.integration.wallabag_endpoint": "Wallabag API Endpoint",
"form.integration.wallabag_client_id": "Wallabag Client ID",
"form.integration.wallabag_client_secret": "Wallabag Client Secret",
"form.integration.wallabag_username": "Wallabag Username",
"form.integration.wallabag_password": "Wallabag Password",
"form.integration.notion_activate": "Save entries to Notion",
"form.integration.notion_page_id": "Notion Page ID",
"form.integration.notion_token": "Notion Secret Token",
"form.integration.apprise_activate": "Push entries to Apprise",
"form.integration.apprise_url": "Apprise API URL",
"form.integration.apprise_services_url": "Comma separated list of Apprise service URLs",
"form.integration.nunux_keeper_activate": "Save entries to Nunux Keeper",
"form.integration.nunux_keeper_endpoint": "Nunux Keeper API Endpoint",
"form.integration.nunux_keeper_api_key": "Nunux Keeper API key",
"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.espial_activate": "Save entries to Espial",
"form.integration.espial_endpoint": "Espial API Endpoint",
"form.integration.espial_api_key": "Espial API key",
"form.integration.espial_tags": "Espial Tags",
"form.integration.readwise_activate": "Save entries to Readwise Reader",
"form.integration.readwise_api_key": "Readwise Reader Access Token",
"form.integration.readwise_api_key_link": "Get your Readwise Access Token",
"form.integration.telegram_bot_activate": "Push new entries to Telegram chat",
"form.integration.telegram_bot_token": "Bot token",
"form.integration.telegram_chat_id": "Chat ID",
"form.integration.telegram_topic_id": "Topic ID",
"form.integration.telegram_bot_disable_web_page_preview": "Disable web page preview",
"form.integration.telegram_bot_disable_notification": "Disable notification",
"form.integration.telegram_bot_disable_buttons": "Disable buttons",
"form.integration.linkding_activate": "Save entries to Linkding",
"form.integration.linkding_endpoint": "Linkding API Endpoint",
"form.integration.linkding_api_key": "Linkding API key",
"form.integration.linkding_tags": "Linkding Tags",
"form.integration.linkding_bookmark": "Mark bookmark as unread",
"form.integration.matrix_bot_activate": "Push new entries to Matrix",
"form.integration.matrix_bot_user": "Username for Matrix",
"form.integration.matrix_bot_password": "Password for Matrix user",
"form.integration.matrix_bot_url": "Matrix server URL",
"form.integration.matrix_bot_chat_id": "ID of Matrix Room",
"form.integration.shiori_activate": "Save articles to Shiori",
"form.integration.shiori_endpoint": "Shiori API Endpoint",
"form.integration.shiori_username": "Shiori Username",
"form.integration.shiori_password": "Shiori Password",
"form.integration.shaarli_activate": "Save articles to Shaarli",
"form.integration.shaarli_endpoint": "Shaarli URL",
"form.integration.shaarli_api_secret": "Shaarli API Secret",
"form.integration.webhook_activate": "Enable Webhook",
"form.integration.webhook_url": "Webhook URL",
"form.integration.webhook_secret": "Webhook Secret",
"form.integration.rssbridge_activate": "Check RSS-Bridge when adding subscriptions",
"form.integration.rssbridge_url": "RSS-Bridge server URL",
"form.api_key.label.description": "API Key Label",
"form.submit.loading": "Loading…",
"form.submit.saving": "Saving…",
"time_elapsed.not_yet": "not yet",
"time_elapsed.yesterday": "yesterday",
"time_elapsed.now": "just now",
"time_elapsed.minutes": [
"%d minute ago",
"%d minutes ago"
],
"time_elapsed.hours": [
"%d hour ago",
"%d hours ago"
],
"time_elapsed.days": [
"%d day ago",
"%d days ago"
],
"time_elapsed.weeks": [
"%d week ago",
"%d weeks ago"
],
"time_elapsed.months": [
"%d month ago",
"%d months ago"
],
"time_elapsed.years": [
"%d year ago",
"%d years ago"
],
"alert.too_many_feeds_refresh": "You have triggered too many feed refreshes. Please wait 30 minutes before trying again.",
"alert.background_feed_refresh": "All feeds are being refreshed in the background. You can continue to use Miniflux while this process is running.",
"error.http_response_too_large": "The HTTP response is too large. You could increase the HTTP response size limit in the global settings (requires a server restart).",
"error.http_body_read": "Unable to read the HTTP body: %v.",
"error.http_empty_response_body": "The HTTP response body is empty.",
"error.http_empty_response": "The HTTP response is empty. Perhaps, this website is using a bot protection mechanism?",
"error.tls_error": "TLS error: %v. You could disable TLS verification in the feed settings if you would like.",
"error.network_operation": "Miniflux is not able to reach this website due to a network error: %v.",
"error.network_timeout": "This website is too slow and the request timed out: %v",
"error.http_client_error": "HTTP client error: %v.",
"error.http_not_authorized": "Access to this website is not authorized. It could be a bad username or password.",
"error.http_too_many_requests": "Miniflux generated too many requests to this website. Please, try again later or change the application configuration.",
"error.http_forbidden": "Access to this website is forbidden. Perhaps, this website has a bot protection mechanism?",
"error.http_resource_not_found": "The requested resource is not found. Please, verify the URL.",
"error.http_internal_server_error": "The website is not available at the moment due to a server error. The problem is not on Miniflux side. Please, try again later.",
"error.http_bad_gateway": "The website is not available at the moment due to a bad gateway error. The problem is not on Miniflux side. Please, try again later.",
"error.http_service_unavailable": "The website is not available at the moment due to an internal server error. The problem is not on Miniflux side. Please, try again later.",
"error.http_gateway_timeout": "The website is not available at the moment due to a gateway timeout error. The problem is not on Miniflux side. Please, try again later.",
"error.http_unexpected_status_code": "The website is not available at the moment due to an unexpected HTTP status code: %d. The problem is not on Miniflux side. Please, try again later.",
"error.database_error": "Database error: %v.",
"error.category_not_found": "This category does not exist or does not belong to this user.",
"error.duplicated_feed": "This feed already exists.",
"error.unable_to_parse_feed": "Unable to parse this feed: %v.",
"error.feed_not_found": "This feed does not exist or does not belong to this user.",
"error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.",
"error.feed_format_not_detected": "Unable to detect feed format: %v."
}
miniflux-2.0.51/internal/locale/translations/es_ES.json 0000664 0000000 0000000 00000071625 14546226260 0023141 0 ustar 00root root 0000000 0000000 {
"confirm.question": "¿Estás seguro?",
"confirm.question.refresh": "¿Quieres forzar la actualización?",
"confirm.yes": "sí",
"confirm.no": "no",
"confirm.loading": "En progreso...",
"action.subscribe": "Suscribir",
"action.save": "Guardar",
"action.or": "o",
"action.cancel": "Cancelar",
"action.remove": "Quitar",
"action.remove_feed": "Quitar esta fuente",
"action.update": "Actualizar",
"action.edit": "Editar",
"action.download": "Descargar",
"action.import": "Importar",
"action.login": "Iniciar sesión",
"action.home_screen": "Añadir a la pantalla principal",
"tooltip.keyboard_shortcuts": "Atajo de teclado: %s",
"tooltip.logged_user": "Registrado como %s",
"menu.unread": "No leídos",
"menu.starred": "Marcadores",
"menu.history": "Historial",
"menu.feeds": "Fuentes",
"menu.categories": "Categorías",
"menu.settings": "Configuración",
"menu.logout": "Cerrar sesión",
"menu.preferences": "Preferencias",
"menu.integrations": "Integraciones",
"menu.sessions": "Sesiones",
"menu.users": "Usuarios",
"menu.about": "Acerca de",
"menu.export": "Exportar",
"menu.import": "Importar",
"menu.create_category": "Crear una categoría",
"menu.mark_page_as_read": "Marcar esta página como leída",
"menu.mark_all_as_read": "Marcar todos como leídos",
"menu.show_all_entries": "Mostrar todos los artículos",
"menu.show_only_unread_entries": "Mostrar solo los artículos no leídos",
"menu.refresh_feed": "Refrescar",
"menu.refresh_all_feeds": "Refrescar todas las fuentes en el fondo",
"menu.edit_feed": "Editar",
"menu.edit_category": "Editar",
"menu.add_feed": "Agregar fuente",
"menu.add_user": "Agregar usuario",
"menu.flush_history": "Borrar historial",
"menu.feed_entries": "Artículos",
"menu.api_keys": "Claves API",
"menu.create_api_key": "Crear una nueva clave API",
"menu.shared_entries": "Artículos compartidos",
"search.label": "Buscar",
"search.placeholder": "Búsqueda...",
"pagination.next": "Siguiente",
"pagination.previous": "Anterior",
"entry.status.unread": "No leído",
"entry.status.read": "Leído",
"entry.status.toast.unread": "Marcado como no leído",
"entry.status.toast.read": "Marcado como leído",
"entry.status.title": "Cambiar estado del artículo",
"entry.bookmark.toggle.on": "Marcar",
"entry.bookmark.toggle.off": "Desmarcar",
"entry.bookmark.toast.on": "Sembrado de estrellas",
"entry.bookmark.toast.off": "Sin estrellas",
"entry.state.saving": "Guardando...",
"entry.state.loading": "Cargando...",
"entry.save.label": "Guardar",
"entry.save.title": "Guardar este artículo",
"entry.save.completed": "¡Hecho!",
"entry.save.toast.completed": "Artículos guardados",
"entry.scraper.label": "Descargar",
"entry.scraper.title": "Obtener contenido original",
"entry.scraper.completed": "¡Hecho!",
"entry.external_link.label": "Enlace externo",
"entry.comments.label": "Comentarios",
"entry.comments.title": "Ver comentarios",
"entry.share.label": "Compartir",
"entry.share.title": "Compartir este artículo",
"entry.unshare.label": "No compartir",
"entry.shared_entry.title": "Abrir el enlace público",
"entry.shared_entry.label": "Compartir",
"entry.estimated_reading_time": [
"%d minuto de lectura",
"%d minutos de lectura"
],
"entry.tags.label": "Etiquetas:",
"page.shared_entries.title": "Artículos compartidos",
"page.unread.title": "No leídos",
"page.starred.title": "Marcadores",
"page.categories.title": "Categorías",
"page.categories.no_feed": "Sin fuente.",
"page.categories.entries": "Artículos",
"page.categories.feeds": "Fuentes",
"page.categories.feed_count": [
"Hay %d fuente.",
"Hay %d fuentes."
],
"page.categories.unread_counter": "Número de artículos no leídos",
"page.new_category.title": "Nueva categoría",
"page.new_user.title": "Nuevo usuario",
"page.edit_category.title": "Editar categoría: %s",
"page.edit_user.title": "Editar usuario: %s",
"page.feeds.title": "Fuentes",
"page.feeds.last_check": "Última verificación:",
"page.feeds.next_check": "Next check:",
"page.feeds.unread_counter": "Número de artículos no leídos",
"page.feeds.read_counter": "Número de artículos leídos",
"page.feeds.error_count": [
"%d error",
"%d errores"
],
"page.history.title": "Historial",
"page.import.title": "Importar",
"page.search.title": "Resultados de la búsqueda",
"page.about.title": "Acerca de",
"page.about.credits": "Créditos",
"page.about.version": "Versión:",
"page.about.build_date": "Fecha de construcción:",
"page.about.author": "Autor:",
"page.about.license": "Licencia:",
"page.about.global_config_options": "Opciones de configuración global",
"page.about.postgres_version": "Postgres versión:",
"page.about.go_version": "Go versión:",
"page.add_feed.title": "Nueva fuente",
"page.add_feed.no_category": "No hay categoría. Debe tener al menos una categoría.",
"page.add_feed.label.url": "URL",
"page.add_feed.submit": "Encontrar una fuente",
"page.add_feed.legend.advanced_options": "Opciones avanzadas",
"page.add_feed.choose_feed": "Elegir una fuente",
"page.edit_feed.title": "Editar fuente: %s",
"page.edit_feed.last_check": "Última verificación:",
"page.edit_feed.last_modified_header": "Cabecera de LastModified:",
"page.edit_feed.etag_header": "Cabecera de ETag:",
"page.edit_feed.no_header": "Sin cabecera",
"page.edit_feed.last_parsing_error": "Último error de análisis",
"page.entry.attachments": "Archivos adjuntos",
"page.keyboard_shortcuts.title": "Atajos de teclado",
"page.keyboard_shortcuts.subtitle.sections": "Navegación de secciones",
"page.keyboard_shortcuts.subtitle.items": "Navegación de artículos",
"page.keyboard_shortcuts.subtitle.pages": "Navegación de páginas",
"page.keyboard_shortcuts.subtitle.actions": "Acciones",
"page.keyboard_shortcuts.go_to_unread": "Ir a los no leídos",
"page.keyboard_shortcuts.go_to_starred": "Ir a los marcadores",
"page.keyboard_shortcuts.go_to_history": "Ir al historial",
"page.keyboard_shortcuts.go_to_feeds": "Ir a las fuentes",
"page.keyboard_shortcuts.go_to_categories": "Ir a las categorías",
"page.keyboard_shortcuts.go_to_settings": "Ir a la configuración",
"page.keyboard_shortcuts.show_keyboard_shortcuts": "Mostrar atajos de teclado",
"page.keyboard_shortcuts.go_to_previous_item": "Ir al elemento anterior",
"page.keyboard_shortcuts.go_to_next_item": "Ir al elemento siguiente",
"page.keyboard_shortcuts.go_to_feed": "Ir a la fuente",
"page.keyboard_shortcuts.go_to_previous_page": "Ir al página anterior",
"page.keyboard_shortcuts.go_to_next_page": "Ir al página siguiente",
"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.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.toggle_read_status_next": "Marcar como leído o no leído, enfoque siguiente",
"page.keyboard_shortcuts.toggle_read_status_prev": "Marcar como leído o no leído, foco anterior",
"page.keyboard_shortcuts.refresh_all_feeds": "Refrescar todas las fuentes en el fondo",
"page.keyboard_shortcuts.mark_page_as_read": "Marcar página actual como leída",
"page.keyboard_shortcuts.download_content": "Descargar el contento original",
"page.keyboard_shortcuts.toggle_bookmark_status": "Agregar o quitar marcador",
"page.keyboard_shortcuts.save_article": "Guardar artículo",
"page.keyboard_shortcuts.scroll_item_to_top": "Desplazar elemento hacia arriba",
"page.keyboard_shortcuts.remove_feed": "Quitar esta fuente",
"page.keyboard_shortcuts.go_to_search": "Centrarse en el cuadro de búsqueda",
"page.keyboard_shortcuts.toggle_entry_attachments": "Alternar abrir/cerrar adjuntos de la entrada",
"page.keyboard_shortcuts.close_modal": "Cerrar el cuadro de diálogo modal",
"page.users.title": "Usuarios",
"page.users.username": "Nombre de usuario",
"page.users.never_logged": "Nunca",
"page.users.admin.yes": "Sí",
"page.users.admin.no": "No",
"page.users.actions": "Acciones",
"page.users.last_login": "Último ingreso",
"page.users.is_admin": "Administrador",
"page.settings.title": "Ajustes",
"page.settings.link_google_account": "Vincular mi cuenta de Google",
"page.settings.unlink_google_account": "Desvincular mi cuenta de Google",
"page.settings.link_oidc_account": "Vincular mi cuenta de OpenID Connect",
"page.settings.unlink_oidc_account": "Desvincular mi cuenta de OpenID Connect",
"page.settings.webauthn.passkeys": "Passkeys",
"page.settings.webauthn.actions": "Actions",
"page.settings.webauthn.passkey_name": "Passkey Name",
"page.settings.webauthn.added_on": "Added On",
"page.settings.webauthn.last_seen_on": "Last Used",
"page.settings.webauthn.register": "Registrar clave de acceso",
"page.settings.webauthn.register.error": "No se puede registrar la clave de paso",
"page.settings.webauthn.delete": [
"Eliminar %d clave de paso",
"Eliminar %d claves de paso"
],
"page.login.title": "Iniciar sesión",
"page.login.google_signin": "Iniciar sesión con tu cuenta de Google",
"page.login.oidc_signin": "Iniciar sesión con tu cuenta de OpenID Connect",
"page.login.webauthn_login": "Iniciar sesión con clave de acceso",
"page.login.webauthn_login.error": "No se puede iniciar sesión con la clave de paso",
"page.integrations.title": "Integraciones",
"page.integration.miniflux_api": "API de Miniflux",
"page.integration.miniflux_api_endpoint": "Extremo de API",
"page.integration.miniflux_api_username": "Nombre de usuario",
"page.integration.miniflux_api_password": "Contraseña",
"page.integration.miniflux_api_password_value": "Contraseña de tu cuenta",
"page.integration.bookmarklet": "Marcapáginas",
"page.integration.bookmarklet.name": "Agregar a Miniflux",
"page.integration.bookmarklet.instructions": "Arrastrar y soltar este enlace a tus marcadores del navegador.",
"page.integration.bookmarklet.help": "Este enlace especial te permite suscribirte a un sitio de web directamente usando un marcador del navegador.",
"page.sessions.title": "Sesiones",
"page.sessions.table.date": "Fecha",
"page.sessions.table.ip": "Dirección de IP",
"page.sessions.table.user_agent": "Agente de usuario",
"page.sessions.table.actions": "Acciones",
"page.sessions.table.current_session": "Sesión actual",
"page.api_keys.title": "Claves API",
"page.api_keys.table.description": "Descripción",
"page.api_keys.table.token": "simbólico",
"page.api_keys.table.last_used_at": "Último utilizado",
"page.api_keys.table.created_at": "Fecha de creación",
"page.api_keys.table.actions": "Acciones",
"page.api_keys.never_used": "Nunca usado",
"page.new_api_key.title": "Nueva clave API",
"page.offline.title": "Modo offline",
"page.offline.message": "Estas desconectado",
"page.offline.refresh_page": "Intenta actualizar la página",
"page.webauthn_rename.title": "Rename Passkey",
"alert.no_shared_entry": "No hay artículos compartidos.",
"alert.no_bookmark": "No hay marcador en este momento.",
"alert.no_category": "No hay categoría.",
"alert.no_category_entry": "No hay artículos en esta categoría.",
"alert.no_feed_entry": "No hay artículos para esta fuente.",
"alert.no_feed": "No tienes fuentes.",
"alert.no_feed_in_category": "No hay fuentes para esta categoría.",
"alert.no_history": "No hay historial en este momento.",
"alert.feed_error": "Hay un problema con esta fuente.",
"alert.no_search_result": "No hay resultados para esta búsqueda.",
"alert.no_unread_entry": "No hay artículos sin leer.",
"alert.no_user": "Eres el único usuario.",
"alert.account_unlinked": "¡Tu cuenta externa ya está desvinculada!",
"alert.account_linked": "¡Tu cuenta externa ya está vinculada!",
"alert.pocket_linked": "¡Tu cuenta de Pocket ya está vinculada!",
"alert.prefs_saved": "¡Las preferencias se han guardado!",
"error.unlink_account_without_password": "Debe definir una contraseña, de lo contrario no podrá volver a iniciar sesión.",
"error.duplicate_linked_account": "¡Ya hay alguien asociado a este servicio!",
"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.pocket_request_token": "Incapaz de obtener un token de solicitud de Pocket!",
"error.pocket_access_token": "Incapaz de obtener un token de acceso de Pocket!",
"error.category_already_exists": "Esta categoría ya existe.",
"error.unable_to_create_category": "Incapaz de crear esta categoría.",
"error.unable_to_update_category": "Incapaz de actualizar esta categoría.",
"error.user_already_exists": "Este usuario ya existe.",
"error.unable_to_create_user": "Incapaz de crear este usuario.",
"error.unable_to_update_user": "Incapaz de actualizar este usuario.",
"error.unable_to_update_feed": "Incapaz de actualizar esta fuente.",
"error.subscription_not_found": "Incapaz de encontrar alguna fuente.",
"error.empty_file": "Este archivo está vacío.",
"error.bad_credentials": "Usuario o contraseña no válido.",
"error.fields_mandatory": "Todos los campos son obligatorios.",
"error.title_required": "El título es obligatorio.",
"error.different_passwords": "Las contraseñas no son las mismas.",
"error.password_min_length": "La contraseña debería tener al menos 6 caracteres.",
"error.settings_mandatory_fields": "Los campos de nombre de usuario, tema, idioma y zona horaria son obligatorios.",
"error.settings_reading_speed_is_positive": "Las velocidades de lectura deben ser números enteros positivos.",
"error.entries_per_page_invalid": "El número de artículos por página no es válido.",
"error.feed_mandatory_fields": "Los campos de URL y categoría son obligatorios.",
"error.feed_already_exists": "Este feed ya existe.",
"error.invalid_feed_url": "URL de feed no válida.",
"error.invalid_site_url": "URL del sitio no válida.",
"error.feed_url_not_empty": "La URL del feed no puede estar vacía.",
"error.site_url_not_empty": "La URL del sitio no puede estar vacía.",
"error.feed_title_not_empty": "El título del feed no puede estar vacío.",
"error.feed_category_not_found": "Esta categoría no existe o no pertenece a este usuario.",
"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.user_mandatory_fields": "El nombre de usuario es obligatorio.",
"error.api_key_already_exists": "Esta clave API ya existe.",
"error.unable_to_create_api_key": "No se puede crear esta clave API.",
"error.invalid_theme": "Tema no válido.",
"error.invalid_language": "Idioma no válido.",
"error.invalid_timezone": "Zona horaria no válida.",
"error.invalid_entry_direction": "Dirección de artículo no válida.",
"error.invalid_display_mode": "Modo de visualización de la aplicación web no válido.",
"error.invalid_gesture_nav": "Navegación por gestos no válida.",
"error.invalid_default_home_page": "¡Página de inicio por defecto no válida!",
"form.feed.label.title": "Título",
"form.feed.label.site_url": "URL del sitio",
"form.feed.label.feed_url": "URL de la fuente",
"form.feed.label.category": "Categoría",
"form.feed.label.crawler": "Obtener rastreador original",
"form.feed.label.feed_username": "Nombre de usuario de la fuente",
"form.feed.label.feed_password": "Contraseña de la fuente",
"form.feed.label.user_agent": "Invalidar el agente de usuario predeterminado",
"form.feed.label.cookie": "Configurar las cookies",
"form.feed.label.scraper_rules": "Reglas de extracción de información",
"form.feed.label.rewrite_rules": "Reglas de reescribir",
"form.feed.label.apprise_service_urls": "Comma separated list of Apprise service URLs",
"form.feed.label.blocklist_rules": "Reglas de Filtrado (Bloquear)",
"form.feed.label.keeplist_rules": "Reglas de Filtrado (Permitir)",
"form.feed.label.urlrewrite_rules": "Reglas de Filtrado (Reescritura)",
"form.feed.label.ignore_http_cache": "Ignorar caché HTTP",
"form.feed.label.allow_self_signed_certificates": "Permitir certificados autofirmados o no válidos",
"form.feed.label.fetch_via_proxy": "Buscar a través de proxy",
"form.feed.label.disabled": "No actualice este feed",
"form.feed.label.no_media_player": "No media player (audio/video)",
"form.feed.label.hide_globally": "Ocultar artículos en la lista global de no leídos",
"form.feed.fieldset.general": "General",
"form.feed.fieldset.rules": "Rules",
"form.feed.fieldset.network_settings": "Network Settings",
"form.feed.fieldset.integration": "Third-Party Services",
"form.category.label.title": "Título",
"form.category.hide_globally": "Ocultar artículos en la lista global de no leídos",
"form.user.label.username": "Nombre de usuario",
"form.user.label.password": "Contraseña",
"form.user.label.confirmation": "Confirmación de contraseña",
"form.user.label.admin": "Administrador",
"form.prefs.label.language": "Idioma",
"form.prefs.label.timezone": "Zona horaria",
"form.prefs.label.theme": "Tema",
"form.prefs.label.entry_sorting": "Clasificación de artículos",
"form.prefs.label.entries_per_page": "Artículos por página",
"form.prefs.label.default_reading_speed": "Velocidad de lectura de otras lenguas (palabras por minuto)",
"form.prefs.label.cjk_reading_speed": "Velocidad de lectura en chino, coreano y japonés (caracteres por minuto)",
"form.prefs.label.display_mode": "Modo de visualización de aplicación web progresiva (PWA)",
"form.prefs.select.older_first": "Artículos antiguos primero",
"form.prefs.select.recent_first": "Artículos recientes primero",
"form.prefs.select.fullscreen": "Pantalla completa",
"form.prefs.select.standalone": "Autónomo",
"form.prefs.select.minimal_ui": "Mínimo",
"form.prefs.select.browser": "Navegador",
"form.prefs.select.publish_time": "Hora de publicación del artículo",
"form.prefs.select.created_time": "Hora de creación del artículo",
"form.prefs.select.alphabetical": "Alfabético",
"form.prefs.select.unread_count": "Recuento de no leídos",
"form.prefs.select.none": "Ninguno",
"form.prefs.select.tap": "Doble toque",
"form.prefs.select.swipe": "Golpe fuerte",
"form.prefs.label.keyboard_shortcuts": "Habilitar atajos de teclado",
"form.prefs.label.entry_swipe": "Habilitar deslizamiento de entrada en pantallas táctiles",
"form.prefs.label.gesture_nav": "Gesto para navegar entre entradas",
"form.prefs.label.show_reading_time": "Mostrar el tiempo estimado de lectura de los artículos",
"form.prefs.label.custom_css": "CSS personalizado",
"form.prefs.label.entry_order": "Columna de clasificación de artículos",
"form.prefs.label.default_home_page": "Página de inicio por defecto",
"form.prefs.label.categories_sorting_order": "Clasificación por categorías",
"form.prefs.label.mark_read_on_view": "Marcar automáticamente las entradas como leídas cuando se vean",
"form.prefs.fieldset.application_settings": "Application Settings",
"form.prefs.fieldset.authentication_settings": "Authentication Settings",
"form.prefs.fieldset.reader_settings": "Reader Settings",
"form.import.label.file": "Archivo OPML",
"form.import.label.url": "URL",
"form.integration.fever_activate": "Activar API de Fever",
"form.integration.fever_username": "Nombre de usuario de Fever",
"form.integration.fever_password": "Contraseña de Fever",
"form.integration.fever_endpoint": "Acceso API de Fever:",
"form.integration.googlereader_activate": "Activar API de Google Reader",
"form.integration.googlereader_username": "Nombre de usuario de Google Reader",
"form.integration.googlereader_password": "Contraseña de Google Reader",
"form.integration.googlereader_endpoint": "Acceso API de Google Reader:",
"form.integration.pinboard_activate": "Enviar artículos a Pinboard",
"form.integration.pinboard_token": "Token de API de Pinboard",
"form.integration.pinboard_tags": "Etiquetas de Pinboard",
"form.integration.pinboard_bookmark": "Marcar marcador como no leído",
"form.integration.instapaper_activate": "Enviar artículos a Instapaper",
"form.integration.instapaper_username": "Nombre de usuario de Instapaper",
"form.integration.instapaper_password": "Contraseña de Instapaper",
"form.integration.pocket_activate": "Enviar artículos a Pocket",
"form.integration.pocket_consumer_key": "Clave del consumidor de Pocket",
"form.integration.pocket_access_token": "Token de acceso de Pocket",
"form.integration.pocket_connect_link": "Conectar a la cuenta de Pocket",
"form.integration.wallabag_activate": "Enviar artículos a Wallabag",
"form.integration.wallabag_only_url": "Enviar solo URL (en lugar de contenido completo)",
"form.integration.wallabag_endpoint": "Acceso API de Wallabag",
"form.integration.wallabag_client_id": "ID de cliente de Wallabag",
"form.integration.wallabag_client_secret": "Secreto de cliente de Wallabag",
"form.integration.wallabag_username": "Nombre de usuario de Wallabag",
"form.integration.wallabag_password": "Contraseña de Wallabag",
"form.integration.notion_activate": "Save entries to Notion",
"form.integration.notion_page_id": "Notion Page ID",
"form.integration.notion_token": "Notion Secret Token",
"form.integration.apprise_activate": "Push entries to Apprise",
"form.integration.apprise_url": "Apprise API URL",
"form.integration.apprise_services_url": "Comma separated list of Apprise service URLs",
"form.integration.nunux_keeper_activate": "Enviar artículos a Nunux Keeper",
"form.integration.nunux_keeper_endpoint": "Acceso API de Nunux Keeper",
"form.integration.nunux_keeper_api_key": "Clave de API de Nunux Keeper",
"form.integration.omnivore_activate": "Enviar artículos a Omnivore",
"form.integration.omnivore_url": "Acceso API de Omnivore",
"form.integration.omnivore_api_key": "Clave de API de Omnivore",
"form.integration.espial_activate": "Enviar artículos a Espial",
"form.integration.espial_endpoint": "Acceso API de Espial",
"form.integration.espial_api_key": "Clave de API de Espial",
"form.integration.espial_tags": "Etiquetas de Espial",
"form.integration.readwise_activate": "Save entries to Readwise Reader",
"form.integration.readwise_api_key": "Readwise Reader Access Token",
"form.integration.readwise_api_key_link": "Get your Readwise Access Token",
"form.integration.telegram_bot_activate": "Envíe nuevos artículos al chat de Telegram",
"form.integration.telegram_bot_token": "Token de bot",
"form.integration.telegram_chat_id": "ID de chat",
"form.integration.telegram_topic_id": "Topic ID",
"form.integration.telegram_bot_disable_web_page_preview": "Disable web page preview",
"form.integration.telegram_bot_disable_notification": "Disable notification",
"form.integration.telegram_bot_disable_buttons": "Disable buttons",
"form.integration.linkding_activate": "Enviar artículos a Linkding",
"form.integration.linkding_endpoint": "Acceso API de Linkding",
"form.integration.linkding_api_key": "Clave de API de Linkding",
"form.integration.linkding_tags": "Linkding Tags",
"form.integration.linkding_bookmark": "Marcar marcador como no leído",
"form.integration.matrix_bot_activate": "Transferir nuevos artículos a Matrix",
"form.integration.matrix_bot_user": "Nombre de usuario para 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_chat_id": "ID de la sala de Matrix",
"form.integration.shiori_activate": "Guardar artículos a Shiori",
"form.integration.shiori_endpoint": "Extremo de API de Shiori",
"form.integration.shiori_username": "Nombre de usuario de Shiori",
"form.integration.shiori_password": "Contraseña de Shiori",
"form.integration.shaarli_activate": "Save articles to Shaarli",
"form.integration.shaarli_endpoint": "Shaarli URL",
"form.integration.shaarli_api_secret": "Shaarli API Secret",
"form.integration.webhook_activate": "Enable Webhook",
"form.integration.webhook_url": "Webhook URL",
"form.integration.webhook_secret": "Webhook Secret",
"form.integration.rssbridge_activate": "Check RSS-Bridge when adding subscriptions",
"form.integration.rssbridge_url": "RSS-Bridge server URL",
"form.api_key.label.description": "Etiqueta de clave API",
"form.submit.loading": "Cargando...",
"form.submit.saving": "Guardando...",
"time_elapsed.not_yet": "todavía no",
"time_elapsed.yesterday": "ayer",
"time_elapsed.now": "ahora mismo",
"time_elapsed.minutes": [
"hace %d minuto",
"hace %d minutos"
],
"time_elapsed.hours": [
"hace %d hora",
"hace %d horas"
],
"time_elapsed.days": [
"hace %d día",
"hace %d días"
],
"time_elapsed.weeks": [
"hace %d semana",
"hace %d semanas"
],
"time_elapsed.months": [
"hace %d mes",
"hace %d meses"
],
"time_elapsed.years": [
"hace %d año",
"hace %d años"
],
"alert.too_many_feeds_refresh": "You have triggered too many feed refreshes. Please wait 30 minutes before trying again.",
"alert.background_feed_refresh": "All feeds are being refreshed in the background. You can continue to use Miniflux while this process is running.",
"error.http_response_too_large": "The HTTP response is too large. You could increase the HTTP response size limit in the global settings (requires a server restart).",
"error.http_body_read": "Unable to read the HTTP body: %v.",
"error.http_empty_response_body": "The HTTP response body is empty.",
"error.http_empty_response": "The HTTP response is empty. Perhaps, this website is using a bot protection mechanism?",
"error.tls_error": "TLS error: %v. You could disable TLS verification in the feed settings if you would like.",
"error.network_operation": "Miniflux is not able to reach this website due to a network error: %v.",
"error.network_timeout": "This website is too slow and the request timed out: %v",
"error.http_client_error": "HTTP client error: %v.",
"error.http_not_authorized": "Access to this website is not authorized. It could be a bad username or password.",
"error.http_too_many_requests": "Miniflux generated too many requests to this website. Please, try again later or change the application configuration.",
"error.http_forbidden": "Access to this website is forbidden. Perhaps, this website has a bot protection mechanism?",
"error.http_resource_not_found": "The requested resource is not found. Please, verify the URL.",
"error.http_internal_server_error": "The website is not available at the moment due to a server error. The problem is not on Miniflux side. Please, try again later.",
"error.http_bad_gateway": "The website is not available at the moment due to a bad gateway error. The problem is not on Miniflux side. Please, try again later.",
"error.http_service_unavailable": "The website is not available at the moment due to an internal server error. The problem is not on Miniflux side. Please, try again later.",
"error.http_gateway_timeout": "The website is not available at the moment due to a gateway timeout error. The problem is not on Miniflux side. Please, try again later.",
"error.http_unexpected_status_code": "The website is not available at the moment due to an unexpected HTTP status code: %d. The problem is not on Miniflux side. Please, try again later.",
"error.database_error": "Database error: %v.",
"error.category_not_found": "This category does not exist or does not belong to this user.",
"error.duplicated_feed": "This feed already exists.",
"error.unable_to_parse_feed": "Unable to parse this feed: %v.",
"error.feed_not_found": "This feed does not exist or does not belong to this user.",
"error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.",
"error.feed_format_not_detected": "Unable to detect feed format: %v."
}
miniflux-2.0.51/internal/locale/translations/fi_FI.json 0000664 0000000 0000000 00000070342 14546226260 0023112 0 ustar 00root root 0000000 0000000 {
"confirm.question": "Oletko varma?",
"confirm.question.refresh": "Haluatko pakottaa päivityksen?",
"confirm.yes": "kyllä",
"confirm.no": "ei",
"confirm.loading": "Käynnissä...",
"action.subscribe": "Tilaa",
"action.save": "Tallenna",
"action.or": "tai",
"action.cancel": "peru",
"action.remove": "Poista",
"action.remove_feed": "Poista tämä syöte",
"action.update": "Päivitä",
"action.edit": "Muokkaa",
"action.download": "Lataa",
"action.import": "Tuo",
"action.login": "Kirjaudu sisään",
"action.home_screen": "Lisää aloitusnäytölle",
"tooltip.keyboard_shortcuts": "Pikanäppäin: %s",
"tooltip.logged_user": "Kirjautunut %s-käyttäjänä",
"menu.unread": "Lukemattomat",
"menu.starred": "Suosikit",
"menu.history": "Historia",
"menu.feeds": "Syötteet",
"menu.categories": "Kategoriat",
"menu.settings": "Asetukset",
"menu.logout": "Kirjaudu ulos",
"menu.preferences": "Asetukset",
"menu.integrations": "Integraatiot",
"menu.sessions": "Istunnot",
"menu.users": "Käyttäjät",
"menu.about": "Tietoja",
"menu.export": "Vie",
"menu.import": "Tuo",
"menu.create_category": "Luo kategoria",
"menu.mark_page_as_read": "Merkitse tämä sivu luetuksi",
"menu.mark_all_as_read": "Merkitse kaikki luetuksi",
"menu.show_all_entries": "Näytä kaikki artikkelit",
"menu.show_only_unread_entries": "Näytä vain lukemattomat artikkelit",
"menu.refresh_feed": "Päivitä",
"menu.refresh_all_feeds": "Päivitä kaikki syötteet taustalla",
"menu.edit_feed": "Muokkaa",
"menu.edit_category": "Muokkaa",
"menu.add_feed": "Lisää tilaus",
"menu.add_user": "Lisää käyttäjä",
"menu.flush_history": "Tyhjennä historia",
"menu.feed_entries": "Artikkelit",
"menu.api_keys": "API-avaimet",
"menu.create_api_key": "Luo uusi API-avain",
"menu.shared_entries": "Jaetut artikkelit",
"search.label": "Haku",
"search.placeholder": "Hae...",
"pagination.next": "Seuraava",
"pagination.previous": "Edellinen",
"entry.status.unread": "Lukematon",
"entry.status.read": "Luettu",
"entry.status.toast.unread": "Merkitty lukemattomaksi",
"entry.status.toast.read": "Merkitty luetuksi",
"entry.status.title": "Vaihda artikkelin tilaa",
"entry.bookmark.toggle.on": "Lisää suosikkeihin",
"entry.bookmark.toggle.off": "Poista suosikeista",
"entry.bookmark.toast.on": "Tähdellä merkityt",
"entry.bookmark.toast.off": "Tähdettömät",
"entry.state.saving": "Tallennetaan...",
"entry.state.loading": "Ladataan...",
"entry.save.label": "Tallenna",
"entry.save.title": "Tallenna tämä artikkeli",
"entry.save.completed": "Valmis!",
"entry.save.toast.completed": "Artikkeli tallennettu",
"entry.scraper.label": "Lataa",
"entry.scraper.title": "Nouda alkuperäinen sisältö",
"entry.scraper.completed": "Valmis!",
"entry.external_link.label": "Ulkoinen linkki",
"entry.comments.label": "Kommentit",
"entry.comments.title": "Näytä kommentit",
"entry.share.label": "Jaa",
"entry.share.title": "Jaa tämä artikkeli",
"entry.unshare.label": "Poista jako",
"entry.shared_entry.title": "Avaa julkinen linkki",
"entry.shared_entry.label": "Jaa",
"entry.estimated_reading_time": [
"%d minuutin lukuaika",
"%d minuutin lukuaika"
],
"entry.tags.label": "Tags:",
"page.shared_entries.title": "Jaetut artikkelit",
"page.unread.title": "Lukemattomat",
"page.starred.title": "Suosikit",
"page.categories.title": "Kategoriat",
"page.categories.no_feed": "Ei syötettä.",
"page.categories.entries": "Artikkelit",
"page.categories.feeds": "Tilaukset",
"page.categories.feed_count": [
"On %d syöte.",
"On %d syötettä."
],
"page.categories.unread_counter": "Lukemattomien artikkeleiden määrä",
"page.new_category.title": "Uusi kategoria",
"page.new_user.title": "Uusi käyttäjä",
"page.edit_category.title": "Muokkaa kategoria: %s",
"page.edit_user.title": "Muokkaa käyttäjä: %s",
"page.feeds.title": "Syötteet",
"page.feeds.last_check": "Viimeisin tarkistus:",
"page.feeds.next_check": "Next check:",
"page.feeds.unread_counter": "Lukemattomien artikkeleiden määrä",
"page.feeds.read_counter": "Luettujen artikkeleiden määrä",
"page.feeds.error_count": [
"%d virhe",
"%d virhettä"
],
"page.history.title": "Historia",
"page.import.title": "Tuo",
"page.search.title": "Hakutulokset",
"page.about.title": "Tietoja",
"page.about.credits": "Kiitokset",
"page.about.version": "Versio:",
"page.about.build_date": "Valmistuspäivä:",
"page.about.author": "Tekijä:",
"page.about.license": "Lisenssi:",
"page.about.global_config_options": "Yleiset asetukset",
"page.about.postgres_version": "Postgres-versio:",
"page.about.go_version": "Go-versio:",
"page.add_feed.title": "Uusi tilaus",
"page.add_feed.no_category": "Ei ole ketegoriaa. Sinulla on oltava vähintään yksi ketegoria.",
"page.add_feed.label.url": "URL-osoite",
"page.add_feed.submit": "Etsi tilaus",
"page.add_feed.legend.advanced_options": "Edistyneet asetukset",
"page.add_feed.choose_feed": "Valitse tilaus",
"page.edit_feed.title": "Muokkaa syöte: %s",
"page.edit_feed.last_check": "Viimeisin tarkistus:",
"page.edit_feed.last_modified_header": "LastModified-otsikko:",
"page.edit_feed.etag_header": "ETag-otsikko:",
"page.edit_feed.no_header": "Ei mitään",
"page.edit_feed.last_parsing_error": "Viimeisin jäsennysvirhe",
"page.entry.attachments": "Liitteet",
"page.keyboard_shortcuts.title": "Pikanäppäimet",
"page.keyboard_shortcuts.subtitle.sections": "Osion navigointi",
"page.keyboard_shortcuts.subtitle.items": "Kohteiden navigointi",
"page.keyboard_shortcuts.subtitle.pages": "Sivujen navigointi",
"page.keyboard_shortcuts.subtitle.actions": "Toiminnot",
"page.keyboard_shortcuts.go_to_unread": "Siirry lukemattomiin",
"page.keyboard_shortcuts.go_to_starred": "Siirry kirjanmerkkeihin",
"page.keyboard_shortcuts.go_to_history": "Siirry historiaan",
"page.keyboard_shortcuts.go_to_feeds": "Siirry syötteisiin",
"page.keyboard_shortcuts.go_to_categories": "Siirry kategorioihin",
"page.keyboard_shortcuts.go_to_settings": "Siirry asetuksiin",
"page.keyboard_shortcuts.show_keyboard_shortcuts": "Näytä pikanäppäimet",
"page.keyboard_shortcuts.go_to_previous_item": "Siirry edelliseen kohteeseen",
"page.keyboard_shortcuts.go_to_next_item": "Siirry seuraavaan kohteeseen",
"page.keyboard_shortcuts.go_to_feed": "Siirry syötteeseen",
"page.keyboard_shortcuts.go_to_previous_page": "Siirry edelliselle sivulle",
"page.keyboard_shortcuts.go_to_next_page": "Siirry seuraavalle sivulle",
"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.open_comments": "Avaa kommenttilinkki",
"page.keyboard_shortcuts.open_comments_same_window": "Avaa kommenttilinkki nykyisessä välilehdessä",
"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.keyboard_shortcuts.refresh_all_feeds": "Päivitä kaikki syötteet taustalla",
"page.keyboard_shortcuts.mark_page_as_read": "Merkitse nykyinen sivu luetuksi",
"page.keyboard_shortcuts.download_content": "Lataa alkuperäinen sisältö",
"page.keyboard_shortcuts.toggle_bookmark_status": "Vaihda kirjanmerkki",
"page.keyboard_shortcuts.save_article": "Tallenna artikkeli",
"page.keyboard_shortcuts.scroll_item_to_top": "Vieritä ylös",
"page.keyboard_shortcuts.remove_feed": "Poista tämä syöte",
"page.keyboard_shortcuts.go_to_search": "Aseta painopiste hakukenttään",
"page.keyboard_shortcuts.toggle_entry_attachments": "Toggle open/close entry attachments",
"page.keyboard_shortcuts.close_modal": "Sulje modaalinen valintaikkuna",
"page.users.title": "Käyttäjät",
"page.users.username": "Käyttäjätunnus",
"page.users.never_logged": "Ei koskaan",
"page.users.admin.yes": "Kyllä",
"page.users.admin.no": "Ei",
"page.users.actions": "Toiminnot",
"page.users.last_login": "Viimeisin kirjautuminen",
"page.users.is_admin": "Ylläpitäjä",
"page.settings.title": "Asetukset",
"page.settings.link_google_account": "Linkitä Google-tilini",
"page.settings.unlink_google_account": "Poista Google-tilini linkitys",
"page.settings.link_oidc_account": "Linkitä OpenID Connect -tilini",
"page.settings.unlink_oidc_account": "Poista OpenID Connect -tilini linkitys",
"page.settings.webauthn.passkeys": "Passkeys",
"page.settings.webauthn.actions": "Actions",
"page.settings.webauthn.passkey_name": "Passkey Name",
"page.settings.webauthn.added_on": "Added On",
"page.settings.webauthn.last_seen_on": "Last Used",
"page.settings.webauthn.register": "Rekisteröi salasana",
"page.settings.webauthn.register.error": "Salasanaa ei voi rekisteröidä",
"page.settings.webauthn.delete": [
"Poista %d salasana",
"Poista %d salasanaa"
],
"page.login.title": "Kirjaudu sisään",
"page.login.google_signin": "Kirjaudu sisään Googlella",
"page.login.oidc_signin": "Kirjaudu sisään OpenID Connectilla",
"page.login.webauthn_login": "Kirjaudu sisään salasanalla",
"page.login.webauthn_login.error": "Ei voida kirjautua sisään salasanalla",
"page.integrations.title": "Integraatiot",
"page.integration.miniflux_api": "Miniflux API",
"page.integration.miniflux_api_endpoint": "API-päätepiste",
"page.integration.miniflux_api_username": "Käyttäjätunnus",
"page.integration.miniflux_api_password": "Salasana",
"page.integration.miniflux_api_password_value": "Tilisi salasana",
"page.integration.bookmarklet": "Sovelluskirjanmerkki",
"page.integration.bookmarklet.name": "Lisää Minifluxiin",
"page.integration.bookmarklet.instructions": "Vedä ja pudota tämä linkki kirjanmerkkeihisi.",
"page.integration.bookmarklet.help": "This special link allows you to subscribe to a website directly by using a bookmark in your web browser.",
"page.sessions.title": "Istunnot",
"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.table.actions": "Toiminnot",
"page.sessions.table.current_session": "Nykyinen istunto",
"page.api_keys.title": "API-avaimet",
"page.api_keys.table.description": "Kuvaus",
"page.api_keys.table.token": "Tunnus",
"page.api_keys.table.last_used_at": "Viimeksi käytetty",
"page.api_keys.table.created_at": "Luomispäivä",
"page.api_keys.table.actions": "Toiminnot",
"page.api_keys.never_used": "Käyttämätön",
"page.new_api_key.title": "Uusi API-avain",
"page.offline.title": "Offline-tila",
"page.offline.message": "Olet offline-tilassa",
"page.offline.refresh_page": "Yritä päivittää sivu",
"page.webauthn_rename.title": "Rename Passkey",
"alert.no_shared_entry": "Jaettua artikkelia ei ole.",
"alert.no_bookmark": "Tällä hetkellä ei ole kirjanmerkkiä.",
"alert.no_category": "Ei ole kategoriaa.",
"alert.no_category_entry": "Tässä kategoriassa ei ole artikkeleita.",
"alert.no_feed_entry": "Tässä syötteessä ei ole artikkeleita.",
"alert.no_feed": "Sinulla ei ole tilauksia.",
"alert.no_feed_in_category": "Tälle kategorialle ei ole tilausta.",
"alert.no_history": "Tällä hetkellä ei ole historiaa.",
"alert.feed_error": "Tässä syötteessä on ongelma",
"alert.no_search_result": "Ei hakua vastaavia tuloksia.",
"alert.no_unread_entry": "Ei ole lukemattomia artikkeleita.",
"alert.no_user": "Olet ainoa käyttäjä.",
"alert.account_unlinked": "Ulkoinen tilisi on nyt irrotettu!",
"alert.account_linked": "Ulkoinen tilisi on nyt linkitetty!",
"alert.pocket_linked": "Pocket-tilisi on nyt linkitetty!",
"alert.prefs_saved": "Asetukset tallennettu!",
"error.unlink_account_without_password": "Sinun on määritettävä salasana, muuten et voi kirjautua uudelleen.",
"error.duplicate_linked_account": "There is already someone associated with this provider!",
"error.duplicate_fever_username": "There is already someone else with the same Fever username!",
"error.duplicate_googlereader_username": "On jo joku muu, jolla on sama Google-syötteenlukijan käyttäjätunnus!",
"error.pocket_request_token": "Unable to fetch request token from Pocket!",
"error.pocket_access_token": "Unable to fetch access token from Pocket!",
"error.category_already_exists": "Kategoria on jo olemassa. ",
"error.unable_to_create_category": "Kategoriaa ei voi luoda.",
"error.unable_to_update_category": "Kategoriaa ei voi päivittää.",
"error.user_already_exists": "Käyttäjä on jo olemassa.",
"error.unable_to_create_user": "Käyttäjää ei voi luoda.",
"error.unable_to_update_user": "Käyttäjää ei voi päivittää.",
"error.unable_to_update_feed": "Syötettä ei voi päivittää.",
"error.subscription_not_found": "Tilausta ei löydy.",
"error.invalid_theme": "Virheellinen teema.",
"error.invalid_language": "Virheellinen kieli.",
"error.invalid_timezone": "Virheellinen aikavyöhyke.",
"error.invalid_entry_direction": "Invalid entry direction.",
"error.invalid_display_mode": "Virheellinen verkkosovelluksen näyttötila.",
"error.invalid_gesture_nav": "Virheellinen ele-navigointi.",
"error.invalid_default_home_page": "Väärä oletusarvoinen kotisivu!",
"error.empty_file": "Tiedosto on tyhjä.",
"error.bad_credentials": "Virheellinen käyttäjänimi tai salasana.",
"error.fields_mandatory": "Kaikki kentät ovat pakollisia.",
"error.title_required": "Otsikko on pakollinen.",
"error.different_passwords": "Salasanat eivät ole samat.",
"error.password_min_length": "Salasanassa on oltava vähintään 6 merkkiä.",
"error.settings_mandatory_fields": "Käyttäjätunnus, teema, kieli ja aikavyöhyke ovat pakollisia.",
"error.settings_reading_speed_is_positive": "Lukunopeuksien on oltava positiivisia kokonaislukuja.",
"error.entries_per_page_invalid": "Artikkelien määrä sivulla ei kelpaa.",
"error.feed_mandatory_fields": "URL-osoite ja kategoria ovat pakollisia.",
"error.feed_already_exists": "Tämä syöte on jo olemassa.",
"error.invalid_feed_url": "Virheellinen syötteen URL-osoite.",
"error.invalid_site_url": "Virheellinen sivuston URL-osoite.",
"error.feed_url_not_empty": "Syötteen URL-osoite ei voi olla tyhjä.",
"error.site_url_not_empty": "Sivuston URL-osoite ei voi olla tyhjä.",
"error.feed_title_not_empty": "Syötteen otsikko ei voi olla tyhjä.",
"error.feed_category_not_found": "Tätä kategoriaa ei ole olemassa tai se ei kuulu tälle käyttäjälle.",
"error.feed_invalid_blocklist_rule": "The block list rule is invalid.",
"error.feed_invalid_keeplist_rule": "The keep list rule is invalid.",
"form.feed.label.urlrewrite_rules": "URL-osoitteen uudelleenkirjoitussäännöt",
"form.feed.label.apprise_service_urls": "Comma separated list of Apprise service URLs",
"error.user_mandatory_fields": "Käyttäjätunnus on pakollinen.",
"error.api_key_already_exists": "API-avain on jo olemassa.",
"error.unable_to_create_api_key": "API-avainta ei voi luoda.",
"form.feed.label.title": "Otsikko",
"form.feed.label.site_url": "Sivuston URL-osoite",
"form.feed.label.feed_url": "Syötteen URL-osoite",
"form.feed.label.category": "Kategoria",
"form.feed.label.crawler": "Nouda alkuperäinen sisältö",
"form.feed.label.feed_username": "Syötteen käyttäjätunnus",
"form.feed.label.feed_password": "Syötteen salasana",
"form.feed.label.user_agent": "Ohita oletuskäyttäjäagentti",
"form.feed.label.cookie": "Aseta evästeet",
"form.feed.label.scraper_rules": "Scraper-säännöt",
"form.feed.label.rewrite_rules": "Rewrite-säännöt",
"form.feed.label.blocklist_rules": "Block-säännöt",
"form.feed.label.keeplist_rules": "Keep-säännöt",
"form.feed.label.ignore_http_cache": "Ohita HTTP-välimuisti",
"form.feed.label.allow_self_signed_certificates": "Salli itseallekirjoitetut tai virheelliset varmenteet",
"form.feed.label.fetch_via_proxy": "Nouda välityspalvelimen kautta",
"form.feed.label.disabled": "Älä päivitä tätä syötettä",
"form.feed.label.no_media_player": "No media player (audio/video)",
"form.feed.label.hide_globally": "Piilota artikkelit lukemattomien listassa",
"form.feed.fieldset.general": "General",
"form.feed.fieldset.rules": "Rules",
"form.feed.fieldset.network_settings": "Network Settings",
"form.feed.fieldset.integration": "Third-Party Services",
"form.category.label.title": "Otsikko",
"form.category.hide_globally": "Piilota artikkelit lukemattomien listassa",
"form.user.label.username": "Käyttäjätunnus",
"form.user.label.password": "Salasana",
"form.user.label.confirmation": "Salasanan vahvistus",
"form.user.label.admin": "Ylläpitäjä",
"form.prefs.label.language": "Kieli",
"form.prefs.label.timezone": "Aikavyöhyke",
"form.prefs.label.theme": "Teema",
"form.prefs.label.entry_sorting": "Lajittelu",
"form.prefs.label.entries_per_page": "Artikkelia sivulla",
"form.prefs.label.default_reading_speed": "Muiden kielten lukunopeus (sanaa minuutissa)",
"form.prefs.label.cjk_reading_speed": "Kiinan, Korean ja Japanin lukunopeus (merkkejä minuutissa)",
"form.prefs.label.display_mode": "Progressive Web App (PWA) -näyttötila",
"form.prefs.select.older_first": "Vanhin ensin",
"form.prefs.select.recent_first": "Uusin ensin",
"form.prefs.select.fullscreen": "Kokoruututila",
"form.prefs.select.standalone": "Itsenäinen tila",
"form.prefs.select.minimal_ui": "Minimaalinen",
"form.prefs.select.browser": "Selain",
"form.prefs.select.publish_time": "Julkaisuaika",
"form.prefs.select.created_time": "Luomisaika",
"form.prefs.select.alphabetical": "Aakkosjärjestys",
"form.prefs.select.unread_count": "Lukemattomien määrä",
"form.prefs.select.none": "Ei mitään",
"form.prefs.select.tap": "Kaksoisnapauta",
"form.prefs.select.swipe": "Pyyhkäise",
"form.prefs.label.keyboard_shortcuts": "Ota pikanäppäimet käyttöön",
"form.prefs.label.entry_swipe": "Ota syöttöpyyhkäisy käyttöön kosketusnäytöissä",
"form.prefs.label.gesture_nav": "Ele siirtyäksesi merkintöjen välillä",
"form.prefs.label.show_reading_time": "Näytä artikkeleiden arvioitu lukuaika",
"form.prefs.label.custom_css": "Mukautettu CSS",
"form.prefs.label.entry_order": "Lajittele sarakkeen mukaan",
"form.prefs.label.default_home_page": "Oletusarvoinen etusivu",
"form.prefs.label.categories_sorting_order": "Kategorioiden lajittelu",
"form.prefs.label.mark_read_on_view": "Merkitse kohdat automaattisesti luetuiksi, kun niitä tarkastellaan",
"form.prefs.fieldset.application_settings": "Application Settings",
"form.prefs.fieldset.authentication_settings": "Authentication Settings",
"form.prefs.fieldset.reader_settings": "Reader Settings",
"form.import.label.file": "OPML-tiedosto",
"form.import.label.url": "URL",
"form.integration.fever_activate": "Ota Fever API käyttöön",
"form.integration.fever_username": "Fever-käyttäjätunnus",
"form.integration.fever_password": "Fever-salasana",
"form.integration.fever_endpoint": "Fever API -päätepiste:",
"form.integration.googlereader_activate": "Aktivoi Google Reader API",
"form.integration.googlereader_username": "Google-lukijan käyttäjätunnus",
"form.integration.googlereader_password": "Google-lukijan salasana",
"form.integration.googlereader_endpoint": "Google Reader API -päätepiste:",
"form.integration.pinboard_activate": "Tallenna artikkelit Pinboardiin",
"form.integration.pinboard_token": "Pinboard API-tunnus",
"form.integration.pinboard_tags": "Pinboard-tagit",
"form.integration.pinboard_bookmark": "Merkitse kirjanmerkki lukemattomaksi",
"form.integration.instapaper_activate": "Tallenna artikkelit Instapaperiin",
"form.integration.instapaper_username": "Instapaper-käyttäjätunnus",
"form.integration.instapaper_password": "Instapaper-salasana",
"form.integration.pocket_activate": "Tallenna artikkelit Pocketiin",
"form.integration.pocket_consumer_key": "Pocket Consumer Key",
"form.integration.pocket_access_token": "Pocket-käyttöoikeustunnus",
"form.integration.pocket_connect_link": "Yhdistä Pocket-tilisi",
"form.integration.wallabag_activate": "Tallenna artikkelit Wallabagiin",
"form.integration.wallabag_only_url": "Lähetä vain URL-osoite (koko sisällön sijaan)",
"form.integration.wallabag_endpoint": "Wallabag API -päätepiste",
"form.integration.wallabag_client_id": "Wallabag Client ID",
"form.integration.wallabag_client_secret": "Wallabag Client Secret",
"form.integration.wallabag_username": "Wallabag-käyttäjätunnus",
"form.integration.wallabag_password": "Wallabag-salasana",
"form.integration.notion_activate": "Save entries to Notion",
"form.integration.notion_page_id": "Notion Page ID",
"form.integration.notion_token": "Notion Secret Token",
"form.integration.apprise_activate": "Push entries to Apprise",
"form.integration.apprise_url": "Apprise API URL",
"form.integration.apprise_services_url": "Comma separated list of Apprise service URLs",
"form.integration.nunux_keeper_activate": "Tallenna artikkelit Nunux Keeperiin",
"form.integration.nunux_keeper_endpoint": "Nunux Keeper API-päätepiste",
"form.integration.nunux_keeper_api_key": "Nunux Keeper API-avain",
"form.integration.omnivore_activate": "Tallenna artikkelit Omnivoreiin",
"form.integration.omnivore_url": "Omnivore API-päätepiste",
"form.integration.omnivore_api_key": "Omnivore API-avain",
"form.integration.espial_activate": "Tallenna artikkelit Espialiin",
"form.integration.espial_endpoint": "Espial API-päätepiste",
"form.integration.espial_api_key": "Espial API-avain",
"form.integration.espial_tags": "Espial-tagit",
"form.integration.readwise_activate": "Save entries to Readwise Reader",
"form.integration.readwise_api_key": "Readwise Reader Access Token",
"form.integration.readwise_api_key_link": "Get your Readwise Access Token",
"form.integration.telegram_bot_activate": "Lähetä uusia artikkeleita Telegram-chatiin",
"form.integration.telegram_bot_token": "Bot-tunnus",
"form.integration.telegram_chat_id": "Chat ID",
"form.integration.telegram_topic_id": "Topic ID",
"form.integration.telegram_bot_disable_web_page_preview": "Disable web page preview",
"form.integration.telegram_bot_disable_notification": "Disable notification",
"form.integration.telegram_bot_disable_buttons": "Disable buttons",
"form.integration.linkding_activate": "Tallenna artikkelit Linkkiin",
"form.integration.linkding_endpoint": "Linkding API-päätepiste",
"form.integration.linkding_api_key": "Linkding API-avain",
"form.integration.linkding_tags": "Linkding Tags",
"form.integration.linkding_bookmark": "Merkitse kirjanmerkki lukemattomaksi",
"form.integration.matrix_bot_activate": "Siirrä uudet artikkelit Matrixiin",
"form.integration.matrix_bot_user": "Matrixin käyttäjä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_chat_id": "Matrix-huoneen tunnus",
"form.integration.shiori_activate": "Save articles to Shiori",
"form.integration.shiori_endpoint": "Shiori API Endpoint",
"form.integration.shiori_username": "Shiori Username",
"form.integration.shiori_password": "Shiori Password",
"form.integration.shaarli_activate": "Save articles to Shaarli",
"form.integration.shaarli_endpoint": "Shaarli URL",
"form.integration.shaarli_api_secret": "Shaarli API Secret",
"form.integration.webhook_activate": "Enable Webhook",
"form.integration.webhook_url": "Webhook URL",
"form.integration.webhook_secret": "Webhook Secret",
"form.integration.rssbridge_activate": "Check RSS-Bridge when adding subscriptions",
"form.integration.rssbridge_url": "RSS-Bridge server URL",
"form.api_key.label.description": "API Key Label",
"form.submit.loading": "Ladataan...",
"form.submit.saving": "Tallennetaan...",
"time_elapsed.not_yet": "ei vielä",
"time_elapsed.yesterday": "eilen",
"time_elapsed.now": "juuri nyt",
"time_elapsed.minutes": [
"%d minuutti sitten",
"%d minuuttia sitten"
],
"time_elapsed.hours": [
"%d tunti sitten",
"%d tuntia sitten"
],
"time_elapsed.days": [
"%d päivä sitten",
"%d päivää sitten"
],
"time_elapsed.weeks": [
"%d viikko sitten",
"%d viikkoa sitten"
],
"time_elapsed.months": [
"%d kuukausi sitten",
"%d kuukautta sitten"
],
"time_elapsed.years": [
"%d vuosi sitten",
"%d vuotta sitten"
],
"alert.too_many_feeds_refresh": "You have triggered too many feed refreshes. Please wait 30 minutes before trying again.",
"alert.background_feed_refresh": "All feeds are being refreshed in the background. You can continue to use Miniflux while this process is running.",
"error.http_response_too_large": "The HTTP response is too large. You could increase the HTTP response size limit in the global settings (requires a server restart).",
"error.http_body_read": "Unable to read the HTTP body: %v.",
"error.http_empty_response_body": "The HTTP response body is empty.",
"error.http_empty_response": "The HTTP response is empty. Perhaps, this website is using a bot protection mechanism?",
"error.tls_error": "TLS error: %v. You could disable TLS verification in the feed settings if you would like.",
"error.network_operation": "Miniflux is not able to reach this website due to a network error: %v.",
"error.network_timeout": "This website is too slow and the request timed out: %v",
"error.http_client_error": "HTTP client error: %v.",
"error.http_not_authorized": "Access to this website is not authorized. It could be a bad username or password.",
"error.http_too_many_requests": "Miniflux generated too many requests to this website. Please, try again later or change the application configuration.",
"error.http_forbidden": "Access to this website is forbidden. Perhaps, this website has a bot protection mechanism?",
"error.http_resource_not_found": "The requested resource is not found. Please, verify the URL.",
"error.http_internal_server_error": "The website is not available at the moment due to a server error. The problem is not on Miniflux side. Please, try again later.",
"error.http_bad_gateway": "The website is not available at the moment due to a bad gateway error. The problem is not on Miniflux side. Please, try again later.",
"error.http_service_unavailable": "The website is not available at the moment due to an internal server error. The problem is not on Miniflux side. Please, try again later.",
"error.http_gateway_timeout": "The website is not available at the moment due to a gateway timeout error. The problem is not on Miniflux side. Please, try again later.",
"error.http_unexpected_status_code": "The website is not available at the moment due to an unexpected HTTP status code: %d. The problem is not on Miniflux side. Please, try again later.",
"error.database_error": "Database error: %v.",
"error.category_not_found": "This category does not exist or does not belong to this user.",
"error.duplicated_feed": "This feed already exists.",
"error.unable_to_parse_feed": "Unable to parse this feed: %v.",
"error.feed_not_found": "This feed does not exist or does not belong to this user.",
"error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.",
"error.feed_format_not_detected": "Unable to detect feed format: %v."
}
miniflux-2.0.51/internal/locale/translations/fr_FR.json 0000664 0000000 0000000 00000074273 14546226260 0023143 0 ustar 00root root 0000000 0000000 {
"confirm.question": "Êtes-vous sûr ?",
"confirm.question.refresh": "Voulez-vous forcer le rafraîchissement ?",
"confirm.yes": "oui",
"confirm.no": "non",
"confirm.loading": "En cours...",
"action.subscribe": "S'abonner",
"action.save": "Sauvegarder",
"action.or": "ou",
"action.cancel": "annuler",
"action.remove": "Supprimer",
"action.remove_feed": "Supprimer ce flux",
"action.update": "Mettre à jour",
"action.edit": "Modifier",
"action.download": "Télécharger",
"action.import": "Importer",
"action.login": "Se connecter",
"action.home_screen": "Ajouter à l'écran d'accueil",
"tooltip.keyboard_shortcuts": "Raccourci clavier : %s",
"tooltip.logged_user": "Connecté en tant que %s",
"menu.unread": "Non lus",
"menu.starred": "Favoris",
"menu.history": "Historique",
"menu.feeds": "Abonnements",
"menu.categories": "Catégories",
"menu.settings": "Réglages",
"menu.logout": "Se déconnecter",
"menu.preferences": "Préférences",
"menu.integrations": "Intégrations",
"menu.sessions": "Sessions",
"menu.users": "Utilisateurs",
"menu.about": "À propos",
"menu.export": "Export",
"menu.import": "Import",
"menu.create_category": "Créer une catégorie",
"menu.mark_page_as_read": "Marquer cette page comme lu",
"menu.mark_all_as_read": "Tout marquer comme lu",
"menu.show_all_entries": "Afficher tous les articles",
"menu.show_only_unread_entries": "Afficher uniquement les articles non lus",
"menu.refresh_feed": "Actualiser",
"menu.refresh_all_feeds": "Actualiser les abonnements en arrière-plan",
"menu.edit_feed": "Modifier",
"menu.edit_category": "Modifier",
"menu.add_feed": "Ajouter un abonnement",
"menu.add_user": "Ajouter un utilisateur",
"menu.flush_history": "Supprimer l'historique",
"menu.feed_entries": "Articles",
"menu.api_keys": "Clés d'API",
"menu.create_api_key": "Créer une nouvelle clé d'API",
"menu.shared_entries": "Articles partagés",
"search.label": "Recherche",
"search.placeholder": "Recherche...",
"pagination.next": "Suivant",
"pagination.previous": "Précédent",
"entry.status.unread": "Non lu",
"entry.status.read": "Lu",
"entry.status.title": "Changer le statut de l'entrée",
"entry.status.toast.unread": "Marqué comme non lu",
"entry.status.toast.read": "Marqué comme lu",
"entry.bookmark.toggle.on": "Favoris",
"entry.bookmark.toggle.off": "Enlever favoris",
"entry.bookmark.toast.on": "Ajouté aux favoris",
"entry.bookmark.toast.off": "Enlevé des favoris",
"entry.state.saving": "Sauvegarde en cours...",
"entry.state.loading": "Chargement...",
"entry.save.label": "Sauvegarder",
"entry.save.title": "Sauvegarder cet article",
"entry.save.completed": "Terminé !",
"entry.save.toast.completed": "Article sauvegardé",
"entry.scraper.label": "Télécharger",
"entry.scraper.title": "Récupérer le contenu original",
"entry.scraper.completed": "Terminé !",
"entry.external_link.label": "Lien externe",
"entry.comments.label": "Commentaires",
"entry.comments.title": "Voir les commentaires",
"entry.share.label": "Partager",
"entry.share.title": "Partager cet article",
"entry.unshare.label": "Enlever le partage",
"entry.shared_entry.title": "Ouvrir le lien public",
"entry.shared_entry.label": "Partage",
"entry.estimated_reading_time": [
"%d minute de lecture",
"%d minutes de lecture"
],
"entry.tags.label": "Libellés :",
"page.shared_entries.title": "Articles partagés",
"page.unread.title": "Non lus",
"page.starred.title": "Favoris",
"page.categories.title": "Catégories",
"page.categories.no_feed": "Aucun abonnement.",
"page.categories.entries": "Articles",
"page.categories.feeds": "Abonnements",
"page.categories.feed_count": [
"Il y a %d abonnement.",
"Il y a %d abonnements."
],
"page.categories.unread_counter": "Nombre d'entrées non lues",
"page.new_category.title": "Nouvelle catégorie",
"page.new_user.title": "Nouvel Utilisateur",
"page.edit_category.title": "Modification de la catégorie : %s",
"page.edit_user.title": "Modification de l'utilisateur : %s",
"page.feeds.title": "Abonnements",
"page.feeds.last_check": "Dernière vérification :",
"page.feeds.next_check": "Prochaine vérification :",
"page.feeds.unread_counter": "Nombre d'entrées non lues",
"page.feeds.read_counter": "Nombre d'entrées lues",
"page.feeds.error_count": [
"%d erreur",
"%d erreurs"
],
"page.history.title": "Historique",
"page.import.title": "Importation",
"page.search.title": "Résultats de la recherche",
"page.about.title": "À propos",
"page.about.credits": "Crédits",
"page.about.version": "Version :",
"page.about.build_date": "Date de la compilation :",
"page.about.author": "Auteur :",
"page.about.license": "Licence :",
"page.about.global_config_options": "Options de configuration globales",
"page.about.postgres_version": "Version de Postgresql :",
"page.about.go_version": "Version de Go :",
"page.add_feed.title": "Nouvel Abonnement",
"page.add_feed.no_category": "Il n'y a aucune catégorie. Vous devez avoir au moins une catégorie.",
"page.add_feed.label.url": "Lien",
"page.add_feed.submit": "Trouver un abonnement",
"page.add_feed.legend.advanced_options": "Options avancées",
"page.add_feed.choose_feed": "Choisissez un abonnement",
"page.edit_feed.title": "Modification de l'abonnement : %s",
"page.edit_feed.last_check": "Dernière vérification :",
"page.edit_feed.last_modified_header": "En-tête LastModified :",
"page.edit_feed.etag_header": "En-tête ETag :",
"page.edit_feed.no_header": "Aucune",
"page.edit_feed.last_parsing_error": "Dernière erreur d'analyse",
"page.entry.attachments": "Pièces Jointes",
"page.keyboard_shortcuts.title": "Raccourcis clavier",
"page.keyboard_shortcuts.subtitle.sections": "Naviguation entre les sections",
"page.keyboard_shortcuts.subtitle.items": "Naviguation entre les éléments",
"page.keyboard_shortcuts.subtitle.pages": "Naviguation entre les pages",
"page.keyboard_shortcuts.subtitle.actions": "Actions",
"page.keyboard_shortcuts.go_to_unread": "Aller aux éléments non lus",
"page.keyboard_shortcuts.go_to_starred": "Voir les favoris",
"page.keyboard_shortcuts.go_to_history": "Voir l'historique",
"page.keyboard_shortcuts.go_to_feeds": "Voir les abonnements",
"page.keyboard_shortcuts.go_to_categories": "Voir les catégories",
"page.keyboard_shortcuts.go_to_settings": "Voir les réglages",
"page.keyboard_shortcuts.show_keyboard_shortcuts": "Voir les raccourcis clavier",
"page.keyboard_shortcuts.go_to_previous_item": "Élément précédent",
"page.keyboard_shortcuts.go_to_next_item": "Élément suivant",
"page.keyboard_shortcuts.go_to_feed": "Voir abonnement",
"page.keyboard_shortcuts.go_to_previous_page": "Page précédente",
"page.keyboard_shortcuts.go_to_next_page": "Page suivante",
"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.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.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.keyboard_shortcuts.refresh_all_feeds": "Actualiser les abonnements en arrière-plan",
"page.keyboard_shortcuts.mark_page_as_read": "Marquer la page actuelle comme lu",
"page.keyboard_shortcuts.download_content": "Télécharger le contenu original",
"page.keyboard_shortcuts.toggle_bookmark_status": "Ajouter/Enlever favoris",
"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.remove_feed": "Supprimer ce flux",
"page.keyboard_shortcuts.go_to_search": "Mettre le focus sur le champ de recherche",
"page.keyboard_shortcuts.toggle_entry_attachments": "Toggle open/close entry attachments",
"page.keyboard_shortcuts.close_modal": "Fermer la boite de dialogue",
"page.users.title": "Utilisateurs",
"page.users.username": "Nom d'utilisateur",
"page.users.never_logged": "Jamais",
"page.users.admin.yes": "Oui",
"page.users.admin.no": "Non",
"page.users.actions": "Actions",
"page.users.last_login": "Dernière connexion",
"page.users.is_admin": "Administrateur",
"page.settings.title": "Réglages",
"page.settings.link_google_account": "Associer mon compte Google",
"page.settings.unlink_google_account": "Dissocier mon compte Google",
"page.settings.link_oidc_account": "Associer mon compte OpenID Connect",
"page.settings.unlink_oidc_account": "Dissocier mon compte OpenID Connect",
"page.settings.webauthn.passkeys": "Clés d’accès",
"page.settings.webauthn.actions": "Actions",
"page.settings.webauthn.passkey_name": "Nom de la clé d’accès",
"page.settings.webauthn.added_on": "Date de création",
"page.settings.webauthn.last_seen_on": "Dernière utilisation",
"page.settings.webauthn.register": "Enregister une nouvelle clé d’accès",
"page.settings.webauthn.register.error": "Impossible d'enregistrer la clé d’accès",
"page.settings.webauthn.delete" : [
"Supprimer %d clé d’accès",
"Supprimer %d clés d’accès"
],
"page.login.title": "Connexion",
"page.login.google_signin": "Se connecter avec Google",
"page.login.oidc_signin": "Se connecter avec OpenID Connect",
"page.login.webauthn_login": "Se connecter avec une clé d’accès",
"page.login.webauthn_login.error": "Impossible de se connecter avec la clé d’accès",
"page.integrations.title": "Intégrations",
"page.integration.miniflux_api": "API de Miniflux",
"page.integration.miniflux_api_endpoint": "Point de terminaison de l'API",
"page.integration.miniflux_api_username": "Nom d'utilisateur",
"page.integration.miniflux_api_password": "Mot de passe",
"page.integration.miniflux_api_password_value": "Le mot de passe de votre compte",
"page.integration.bookmarklet": "Bookmarklet",
"page.integration.bookmarklet.name": "Ajouter à Miniflux",
"page.integration.bookmarklet.instructions": "Glisser-déposer ce lien dans vos favoris.",
"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.sessions.title": "Sessions",
"page.sessions.table.date": "Date",
"page.sessions.table.ip": "Adresse IP",
"page.sessions.table.user_agent": "Navigateur Web",
"page.sessions.table.actions": "Actions",
"page.sessions.table.current_session": "Session actuelle",
"page.api_keys.title": "Clés d'API",
"page.api_keys.table.description": "Description",
"page.api_keys.table.token": "Jeton",
"page.api_keys.table.last_used_at": "Dernière utilisation",
"page.api_keys.table.created_at": "Date de création",
"page.api_keys.table.actions": "Actions",
"page.api_keys.never_used": "Jamais utilisé",
"page.new_api_key.title": "Nouvelle clé d'API",
"page.offline.title": "Mode Hors-Ligne",
"page.offline.message": "Vous n'êtes pas connecté",
"page.offline.refresh_page": "Essayez de rafraîchir la page",
"page.webauthn_rename.title": "Rename Passkey",
"alert.no_shared_entry": "Il n'y a pas d'article partagé.",
"alert.no_bookmark": "Il n'y a aucun favoris pour le moment.",
"alert.no_category": "Il n'y a aucune catégorie.",
"alert.no_category_entry": "Il n'y a aucun article dans cette catégorie.",
"alert.no_feed_entry": "Il n'y a aucun article pour cet abonnement.",
"alert.no_feed": "Vous n'avez aucun abonnement.",
"alert.no_feed_in_category": "Il n'y a pas d'abonnement pour cette catégorie.",
"alert.no_history": "Il n'y a aucun historique pour le moment.",
"alert.feed_error": "Il y a un problème avec cet abonnement",
"alert.no_search_result": "Il n'y a aucun résultat pour cette recherche.",
"alert.no_unread_entry": "Il n'y a rien de nouveau à lire.",
"alert.no_user": "Vous êtes le seul utilisateur.",
"alert.account_unlinked": "Votre compte externe est maintenant dissocié !",
"alert.account_linked": "Votre compte externe est maintenant associé !",
"alert.pocket_linked": "Votre compte Pocket est maintenant connecté !",
"alert.prefs_saved": "Préférences sauvegardées !",
"error.unlink_account_without_password": "Vous devez définir un mot de passe sinon vous ne pourrez plus vous connecter par la suite.",
"error.duplicate_linked_account": "Il y a déjà quelqu'un d'associé avec ce provider !",
"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.pocket_request_token": "Impossible de récupérer le jeton d'accès depuis Pocket !",
"error.pocket_access_token": "Impossible de récupérer le jeton d'accès depuis Pocket !",
"error.category_already_exists": "Cette catégorie existe déjà.",
"error.unable_to_create_category": "Impossible de créer cette catégorie.",
"error.unable_to_update_category": "Impossible de mettre à jour cette catégorie.",
"error.user_already_exists": "Cet utilisateur existe déjà.",
"error.unable_to_create_user": "Impossible de créer cet utilisateur.",
"error.unable_to_update_user": "Impossible de mettre à jour cet utilisateur.",
"error.unable_to_update_feed": "Impossible de mettre à jour cet abonnement.",
"error.subscription_not_found": "Impossible de trouver un abonnement.",
"error.empty_file": "Ce fichier est vide.",
"error.bad_credentials": "Mauvais identifiant ou mot de passe.",
"error.fields_mandatory": "Tous les champs sont obligatoire.",
"error.title_required": "Le titre est obligatoire.",
"error.different_passwords": "Les mots de passe ne sont pas les mêmes.",
"error.password_min_length": "Vous devez utiliser au moins 6 caractères pour le mot de passe.",
"error.settings_mandatory_fields": "Le nom d'utilisateur, le thème, la langue et le fuseau horaire sont obligatoire.",
"error.settings_reading_speed_is_positive": "Les vitesses de lecture doivent être des entiers positifs.",
"error.entries_per_page_invalid": "Le nombre d'entrées par page n'est pas valide.",
"error.feed_mandatory_fields": "L'URL et la catégorie sont obligatoire.",
"error.feed_already_exists": "Ce flux existe déjà.",
"error.invalid_feed_url": "URL de flux non valide.",
"error.invalid_site_url": "URL de site non valide.",
"error.feed_url_not_empty": "L'URL du flux ne peut pas être vide.",
"error.site_url_not_empty": "L'URL du site ne peut pas être vide.",
"error.feed_title_not_empty": "Le titre du flux ne peut pas être vide.",
"error.feed_category_not_found": "Cette catégorie n'existe pas ou n'appartient pas à cet utilisateur.",
"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.user_mandatory_fields": "Le nom d'utilisateur est obligatoire.",
"error.api_key_already_exists": "Cette clé d'API existe déjà.",
"error.unable_to_create_api_key": "Impossible de créer cette clé d'API.",
"error.invalid_theme": "Thème non valide.",
"error.invalid_language": "Langue non valide.",
"error.invalid_timezone": "Fuseau horaire non valide.",
"error.invalid_entry_direction": "Ordre de trie non valide.",
"error.invalid_display_mode": "Mode d'affichage de l'application web non valide.",
"error.invalid_gesture_nav": "Navigation gestuelle non valide.",
"error.invalid_default_home_page": "Page d'accueil par défaut invalide !",
"form.feed.label.title": "Titre",
"form.feed.label.site_url": "URL du site web",
"form.feed.label.feed_url": "URL du flux",
"form.feed.label.category": "Catégorie",
"form.feed.label.crawler": "Récupérer le contenu original",
"form.feed.label.feed_username": "Nom d'utilisateur du flux",
"form.feed.label.feed_password": "Mot de passe du flux",
"form.feed.label.user_agent": "Remplacer l'agent utilisateur par défaut",
"form.feed.label.cookie": "Définir les cookies",
"form.feed.label.scraper_rules": "Règles pour récupérer le contenu original",
"form.feed.label.rewrite_rules": "Règles de réécriture",
"form.feed.label.apprise_service_urls": "Liste séparée par des virgules des URL du service Apprise",
"form.feed.label.blocklist_rules": "Règles de blocage",
"form.feed.label.keeplist_rules": "Règles d'autorisation",
"form.feed.label.urlrewrite_rules": "Règles de réécriture d'URL",
"form.feed.label.ignore_http_cache": "Ignorer le cache HTTP",
"form.feed.label.allow_self_signed_certificates": "Autoriser les certificats auto-signés ou non valides",
"form.feed.label.fetch_via_proxy": "Récupérer via proxy",
"form.feed.label.disabled": "Ne pas actualiser ce flux",
"form.feed.label.no_media_player": "Pas de lecteur multimedia (audio/vidéo)",
"form.feed.label.hide_globally": "Masquer les entrées dans la liste globale non lue",
"form.feed.fieldset.general": "Général",
"form.feed.fieldset.rules": "Règles",
"form.feed.fieldset.network_settings": "Paramètres réseau",
"form.feed.fieldset.integration": "Services tiers",
"form.category.label.title": "Titre",
"form.category.hide_globally": "Masquer les entrées dans la liste globale non lue",
"form.user.label.username": "Nom d'utilisateur",
"form.user.label.password": "Mot de passe",
"form.user.label.confirmation": "Confirmation du mot de passe",
"form.user.label.admin": "Administrateur",
"form.prefs.label.language": "Langue",
"form.prefs.label.timezone": "Fuseau horaire",
"form.prefs.label.theme": "Thème",
"form.prefs.label.entry_sorting": "Ordre des éléments",
"form.prefs.label.entries_per_page": "Entrées par page",
"form.prefs.label.default_reading_speed": "Vitesse de lecture pour les autres langues (mots par minute)",
"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.display_mode": "Mode d'affichage de l'Application Web Progressive (PWA)",
"form.prefs.select.older_first": "Ancien éléments en premier",
"form.prefs.select.recent_first": "Éléments récents en premier",
"form.prefs.select.fullscreen": "Plein écran",
"form.prefs.select.standalone": "Autonome",
"form.prefs.select.minimal_ui": "Minimal",
"form.prefs.select.browser": "Navigateur",
"form.prefs.select.publish_time": "Heure de publication de l'entrée",
"form.prefs.select.created_time": "Heure de création de l'entrée",
"form.prefs.select.alphabetical": "Alphabétique",
"form.prefs.select.unread_count": "Nombre d'articles non lus",
"form.prefs.select.none": "Aucun",
"form.prefs.select.tap": "Tapez deux fois",
"form.prefs.select.swipe": "Glisser",
"form.prefs.label.keyboard_shortcuts": "Activer les raccourcis clavier",
"form.prefs.label.entry_swipe": "Activer le balayage des entrées sur les écrans tactiles",
"form.prefs.label.gesture_nav": "Geste pour naviguer entre les entrées",
"form.prefs.label.show_reading_time": "Afficher le temps de lecture estimé des articles",
"form.prefs.label.custom_css": "Feuille de style personnalisée",
"form.prefs.label.entry_order": "Colonne de tri des entrées",
"form.prefs.label.default_home_page": "Page d'accueil par défaut",
"form.prefs.label.categories_sorting_order": "Colonne de tri des catégories",
"form.prefs.label.mark_read_on_view": "Marquer automatiquement les entrées comme lues lorsqu'elles sont consultées",
"form.prefs.fieldset.application_settings": "Paramètres de l'application",
"form.prefs.fieldset.authentication_settings": "Paramètres d'authentification",
"form.prefs.fieldset.reader_settings": "Paramètres du lecteur",
"form.import.label.file": "Fichier OPML",
"form.import.label.url": "URL",
"form.integration.fever_activate": "Activer l'API de Fever",
"form.integration.fever_username": "Nom d'utilisateur pour l'API de Fever",
"form.integration.fever_password": "Mot de passe pour l'API de Fever",
"form.integration.fever_endpoint": "Point de terminaison de l'API Fever :",
"form.integration.googlereader_activate": "Activer l'API de Google Reader",
"form.integration.googlereader_username": "Nom d'utilisateur pour l'API de Google Reader",
"form.integration.googlereader_password": "Mot de passe pour l'API de Google Reader",
"form.integration.googlereader_endpoint": "Point de terminaison de l'API Google Reader :",
"form.integration.pinboard_activate": "Sauvegarder les articles vers Pinboard",
"form.integration.pinboard_token": "Jeton de sécurité de l'API de Pinboard",
"form.integration.pinboard_tags": "Libellés de Pinboard",
"form.integration.pinboard_bookmark": "Marquer le lien comme non lu",
"form.integration.instapaper_activate": "Sauvegarder les articles vers Instapaper",
"form.integration.instapaper_username": "Nom d'utilisateur Instapaper",
"form.integration.instapaper_password": "Mot de passe Instapaper",
"form.integration.pocket_activate": "Sauvegarder les articles vers Pocket",
"form.integration.pocket_consumer_key": "Clé de l'API de Pocket",
"form.integration.pocket_access_token": "Jeton d'accès de l'API de Pocket",
"form.integration.pocket_connect_link": "Connectez votre compte Pocket",
"form.integration.wallabag_activate": "Sauvegarder les articles vers Wallabag",
"form.integration.wallabag_only_url": "Envoyer uniquement l'URL (au lieu du contenu complet)",
"form.integration.wallabag_endpoint": "URL de l'API de 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_username": "Nom d'utilisateur de Wallabag",
"form.integration.wallabag_password": "Mot de passe de Wallabag",
"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.apprise_activate": "Envoyer les articles vers Apprise",
"form.integration.apprise_url": "URL de l'API Apprise",
"form.integration.apprise_services_url": "Liste des services Apprise séparés par des virgules",
"form.integration.nunux_keeper_activate": "Sauvegarder les articles vers Nunux Keeper",
"form.integration.nunux_keeper_endpoint": "URL de l'API de Nunux Keeper",
"form.integration.nunux_keeper_api_key": "Clé d'API de Nunux Keeper",
"form.integration.omnivore_activate": "Sauvegarder les articles vers Omnivore",
"form.integration.omnivore_url": "URL de l'API de Omnivore",
"form.integration.omnivore_api_key": "Clé d'API de Omnivore",
"form.integration.espial_activate": "Sauvegarder les articles vers Espial",
"form.integration.espial_endpoint": "URL de l'API de Espial",
"form.integration.espial_api_key": "Clé d'API de Espial",
"form.integration.espial_tags": "Libellés de Espial",
"form.integration.readwise_activate": "Enregistrer les entrées dans Readwise Reader",
"form.integration.readwise_api_key": "Jeton d'accès au lecteur Readwise",
"form.integration.readwise_api_key_link": "Obtenez votre jeton d'accès Readwise",
"form.integration.telegram_bot_activate": "Envoyer les nouveaux articles vers Telegram",
"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.telegram_bot_disable_web_page_preview": "Désactiver l'aperçu de la page Web",
"form.integration.telegram_bot_disable_notification": "Désactiver les notifications",
"form.integration.telegram_bot_disable_buttons": "Disable buttons",
"form.integration.linkding_activate": "Sauvegarder les articles vers Linkding",
"form.integration.linkding_endpoint": "URL de l'API de Linkding",
"form.integration.linkding_api_key": "Clé d'API de Linkding",
"form.integration.linkding_tags": "Linkding Tags",
"form.integration.linkding_bookmark": "Marquer le lien comme non lu",
"form.integration.matrix_bot_activate": "Envoyer les nouveaux articles vers Matrix",
"form.integration.matrix_bot_user": "Nom de l'utilisateur Matrix",
"form.integration.matrix_bot_password": "Mot de passe de l'utilisateur Matrix",
"form.integration.matrix_bot_url": "URL du serveur Matrix",
"form.integration.matrix_bot_chat_id": "Identifiant de la salle Matrix",
"form.integration.shiori_activate": "Sauvegarder les articles vers Shiori",
"form.integration.shiori_endpoint": "URL de l'API de Shiori",
"form.integration.shiori_username": "Nom d'utilisateur de Shiori",
"form.integration.shiori_password": "Mot de passe de Shiori",
"form.integration.shaarli_activate": "Sauvegarder les articles vers Shaarli",
"form.integration.shaarli_endpoint": "URL de l'API de Shaarli",
"form.integration.shaarli_api_secret": "Clé d'API de Shaarli API",
"form.integration.webhook_activate": "Activer le webhook",
"form.integration.webhook_url": "URL du webhook",
"form.integration.webhook_secret": "Secret du webhook",
"form.integration.rssbridge_activate": "Check RSS-Bridge when adding subscriptions",
"form.integration.rssbridge_url": "RSS-Bridge server URL",
"form.api_key.label.description": "Libellé de la clé d'API",
"form.submit.loading": "Chargement...",
"form.submit.saving": "Sauvegarde en cours...",
"time_elapsed.not_yet": "pas encore",
"time_elapsed.yesterday": "hier",
"time_elapsed.now": "à l'instant",
"time_elapsed.minutes": [
"il y a %d minute",
"il y a %d minutes"
],
"time_elapsed.hours": [
"il y a %d heure",
"il y a %d heures"
],
"time_elapsed.days": [
"il y a %d jour",
"il y a %d jours"
],
"time_elapsed.weeks": [
"il y a %d semaine",
"il y a %d semaines"
],
"time_elapsed.months": [
"il y a %d mois",
"il y a %d mois"
],
"time_elapsed.years": [
"il y a %d an",
"il y a %d ans"
],
"alert.too_many_feeds_refresh": "Vous avez déclenché trop d'actualisations de flux. Veuillez attendre 30 minutes avant de réessayer.",
"alert.background_feed_refresh": "Les abonnements sont en cours d'actualisation en arrière-plan. Vous pouvez continuer à naviguer dans l'application.",
"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_body_read": "Impossible de lire le corps de la réponse HTTP : %v.",
"error.http_empty_response_body": "Le corps de la réponse HTTP est vide.",
"error.http_empty_response": "La réponse HTTP est vide. Peut-être que ce site web bloque Miniflux avec une protection anti-bot ?",
"error.tls_error": "Erreur TLS : %v. Vous pouvez désactiver la vérification TLS dans les paramètres de l'abonnement.",
"error.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.http_client_error": "Erreur du client HTTP : %v.",
"error.http_not_authorized": "Accès non autorisé à ce site web. Veuillez vérifier les identifiants de cet abonnement.",
"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_forbidden": "Accès interdit à ce site web. Il se peut que ce site web bloque Miniflux avec une protection anti-bot.",
"error.http_resource_not_found": "La resource demandée n'existe pas sur ce site web. Veuillez vérifier l'URL.",
"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_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_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_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_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.database_error": "Erreur de la base de données : %v.",
"error.category_not_found": "Cette catégorie n'existe pas ou n'appartient pas à cet utilisateur.",
"error.duplicated_feed": "Ce flux existe déjà.",
"error.unable_to_parse_feed": "Impossible d'analyser ce flux : %v.",
"error.feed_not_found": "Impossible de trouver ce flux.",
"error.unable_to_detect_rssbridge": "Impossible de détecter un flux RSS en utilisant RSS-Bridge: %v.",
"error.feed_format_not_detected": "Impossible de détecter le format du flux : %v."
}
miniflux-2.0.51/internal/locale/translations/hi_IN.json 0000664 0000000 0000000 00000117543 14546226260 0023131 0 ustar 00root root 0000000 0000000 {
"confirm.question": "मंजूर है?",
"confirm.question.refresh": "क्या आप बल द्वारा ताज़ा करना चाहते हैं?",
"confirm.yes": "हाँ",
"confirm.no": " नहीं",
"confirm.loading": " प्रगति में है ...",
"action.subscribe": "सदस्यता लें",
"action.save": "सहेजें",
"action.or": "या",
"action.cancel": "रद्द करें",
"action.remove": "हटाएँ",
"action.remove_feed": "इस फ़ीड को हटाएँ",
"action.update": "नवीनीकरण करे",
"action.edit": "संपाद करे",
"action.download": "डाउनलोड",
"action.import": "आयात करे",
"action.login": "लॉग इन करें",
"action.home_screen": "होम स्क्रीन में शामिल करें",
"tooltip.keyboard_shortcuts": "कुंजीपटल संक्षिप्त रीति: %s",
"tooltip.logged_user": "%s के रूप में लॉग इन किया",
"menu.unread": "अपठित",
"menu.starred": "तारांकित",
"menu.history": "इतिहास",
"menu.feeds": "फ़ीड",
"menu.categories": "श्रेणियाँ",
"menu.settings": "समायोजन",
"menu.logout": "लॉग आउट",
"menu.preferences": "पसंद",
"menu.integrations": "एकीकरण",
"menu.sessions": "सत्र",
"menu.users": "उपयोगकर्ताओं",
"menu.about": "के बारे में",
"menu.export": "निर्यात करे",
"menu.import": "आयात करे",
"menu.create_category": "श्रेणी बनाए",
"menu.mark_page_as_read": "इस पृष्ठ को पढ़ा हुआ चिह्नित करें",
"menu.mark_all_as_read": "सभी को पढ़ा हुआ मार्क करें",
"menu.show_all_entries": "सभी प्रविष्टियाँ दिखाए",
"menu.show_only_unread_entries": "सभी अपठित प्रविष्टियाँ दिखाए",
"menu.refresh_feed": "ताज़ा करें",
"menu.refresh_all_feeds": "पृष्ठभूमि में सभी फ़ीड को ताज़ा करें",
"menu.edit_feed": "फ़ीड संपाद करे",
"menu.edit_category": "श्रेणी संपाद करे",
"menu.add_feed": "सदस्यता जोरीय",
"menu.add_user": "उपयोगकर्ता जोड़ें",
"menu.flush_history": "इतिहास मिटाएँ",
"menu.feed_entries": "प्रविष्टियाँ",
"menu.api_keys": "एपीआई कुंजी",
"menu.create_api_key": "नई एपीआई कुंजी बनाएं",
"menu.shared_entries": "साझा प्रविष्टियां",
"search.label": "खोजे",
"search.placeholder": "खोजे...",
"pagination.next": "अगला",
"pagination.previous": "पिछला",
"entry.status.unread": "अपठित",
"entry.status.read": "पढ़े",
"entry.status.toast.unread": "अपठित के रूप में चिह्नित",
"entry.status.toast.read": "पढ़ा हुआ चिह्नित करे",
"entry.status.title": "प्रविष्टि स्थिति बदलें",
"entry.bookmark.toggle.on": "सितारा दे",
"entry.bookmark.toggle.off": "सितारा हटा दो",
"entry.bookmark.toast.on": "तारांकित",
"entry.bookmark.toast.off": "तारांकित न करे",
"entry.state.saving": "सहेजा जा रहा है...",
"entry.state.loading": "लोड हो रहा है...",
"entry.save.label": "सहेजे",
"entry.save.title": "एस लेख को सहेजे",
"entry.save.completed": "कार्य समाप्त हुआ!",
"entry.save.toast.completed": "लेख को सहेज लिया",
"entry.scraper.label": "डाउनलोड",
"entry.scraper.title": "मूल विषयवस्तु लाए",
"entry.scraper.completed": "कार्य समाप्त हुआ!",
"entry.external_link.label": "बाहरी संपर्क",
"entry.comments.label": "टिप्पणियाँ",
"entry.comments.title": "टिप्पणियाँ देखे",
"entry.share.label": "साझा करें",
"entry.share.title": "विषयवस्तु साझा करें",
"entry.unshare.label": "न साझा कारें",
"entry.shared_entry.title": "सार्वजनिक लिंक खोले",
"entry.shared_entry.label": "साझा करें",
"entry.estimated_reading_time": [
"पढ़ने मे %d मिनट मागेगा",
"पढ़ने मे %d मिनट मागेगा"
],
"entry.tags.label": "टैग:",
"page.shared_entries.title": "साझा किया हुआ प्रविष्टि",
"page.unread.title": "अपठित",
"page.starred.title": "तारांकित",
"page.categories.title": "श्रेणियाँ",
"page.categories.no_feed": "कोई फ़ीड नहीं है।",
"page.categories.entries": "विषयवस्तुया",
"page.categories.feeds": "सदस्यता ले",
"page.categories.feed_count": [
"%d फ़ीड बाकी है।",
"%d फ़ीड बाकी है।"
],
"page.categories.unread_counter": "अपठित प्रविष्टिया",
"page.new_category.title": "नया श्रेणी",
"page.new_user.title": "नया उपभोक्ता",
"page.edit_category.title": "%s श्रेणी संपाद करे",
"page.edit_user.title": "%s उपभोक्ता संपाद करे",
"page.feeds.title": "फ़ीड",
"page.feeds.last_check": "आखरी जाँच",
"page.feeds.next_check": "Next check:",
"page.feeds.unread_counter": "अपठित विषयवस्तुया",
"page.feeds.read_counter": "पड़े हुए विषयवस्तुया",
"page.feeds.error_count": [
"%d समस्या",
"%d समस्याए"
],
"page.history.title": "इतिहास",
"page.import.title": "आयात",
"page.search.title": "खोज का परिणाम",
"page.about.title": "पृष्ठ के बारे में",
"page.about.credits": "आभार सूची",
"page.about.version": "संस्करण:",
"page.about.build_date": "बनाने की तिथि:",
"page.about.author": "रचयिता:",
"page.about.license": "अनुज्ञा:",
"page.about.global_config_options": "वैश्विक विन्यास विकल्प",
"page.about.postgres_version": "पोस्तग्राइस संस्करण:",
"page.about.go_version": "गो संस्करण:",
"page.add_feed.title": "नया सदस्यता",
"page.add_feed.no_category": "कोई श्रेणी नहीं है। एक श्रेणी अव्यशाक है।",
"page.add_feed.label.url": "यूआरएल",
"page.add_feed.submit": "सदस्यता खोजे",
"page.add_feed.legend.advanced_options": "उन्नत विकल्प",
"page.add_feed.choose_feed": "एक सदस्यता का चयन करे",
"page.edit_feed.title": "%s फ़ीड संपाद करे",
"page.edit_feed.last_check": "अंतिम जांच:",
"page.edit_feed.last_modified_header": "अंतिम बार संशोधित हैडर:",
"page.edit_feed.etag_header": "ईटाग हैडर:",
"page.edit_feed.no_header": "कोई भी नहीं",
"page.edit_feed.last_parsing_error": "अंतिम पार्सिंग त्रुटि",
"page.entry.attachments": "संलग्नक",
"page.keyboard_shortcuts.title": "कुंजीपटल अल्प मार्ग",
"page.keyboard_shortcuts.subtitle.sections": "अनुभाग नेविगेशन",
"page.keyboard_shortcuts.subtitle.items": "आइटम नेविगेशन",
"page.keyboard_shortcuts.subtitle.pages": "पेज नेविगेशन",
"page.keyboard_shortcuts.subtitle.actions": "कार्रवाई",
"page.keyboard_shortcuts.go_to_unread": "अपठित पर जाएं",
"page.keyboard_shortcuts.go_to_starred": "बुकमार्क पर जाएं",
"page.keyboard_shortcuts.go_to_history": "इतिहास पर जाएं",
"page.keyboard_shortcuts.go_to_feeds": "फ़ीड पर जाएं",
"page.keyboard_shortcuts.go_to_categories": "श्रेणि पर जाएं",
"page.keyboard_shortcuts.go_to_settings": "सेटिंग्स में जाओ",
"page.keyboard_shortcuts.show_keyboard_shortcuts": "कीबोर्ड शॉर्टकट दिखाएं",
"page.keyboard_shortcuts.go_to_previous_item": "पिछले आइटम पर जाएं",
"page.keyboard_shortcuts.go_to_next_item": "अगले आइटम पर जाएं",
"page.keyboard_shortcuts.go_to_feed": "फ़ीड पर जाएं",
"page.keyboard_shortcuts.go_to_previous_page": "पिछले पृष्ठ पर जाएं",
"page.keyboard_shortcuts.go_to_next_page": "अगले पेज पर जाएं",
"page.keyboard_shortcuts.open_item": "चयनित आइटम खोलें",
"page.keyboard_shortcuts.open_original": "मूल लिंक खोलें",
"page.keyboard_shortcuts.open_original_same_window": "वर्तमान टैब में मूल लिंक खोलें",
"page.keyboard_shortcuts.open_comments": "टिप्पणी लिंक खोलें",
"page.keyboard_shortcuts.open_comments_same_window": "मौजूदा टैब में टिप्पणी लिंक खोलें",
"page.keyboard_shortcuts.toggle_read_status_next": "पढ़ें/अपठित टॉगल करें, अगला फ़ोकस करें",
"page.keyboard_shortcuts.toggle_read_status_prev": "पढ़ें/अपठित टॉगल करें, पिछला फ़ोकस करें",
"page.keyboard_shortcuts.refresh_all_feeds": "बैकग्राउंड में सभी फ़ीड्स रीफ़्रेश करें",
"page.keyboard_shortcuts.mark_page_as_read": "मौजूदा पेज को पढ़ा हुआ चिह्नित करें",
"page.keyboard_shortcuts.download_content": "मूल सामग्री डाउनलोड करें",
"page.keyboard_shortcuts.toggle_bookmark_status": "बुकमार्क टॉगल करें",
"page.keyboard_shortcuts.save_article": "विषयवस्तु सहेजें",
"page.keyboard_shortcuts.scroll_item_to_top": "आइटम को ऊपर तक स्क्रॉल करें",
"page.keyboard_shortcuts.remove_feed": "यह फ़ीड हटाएं",
"page.keyboard_shortcuts.go_to_search": "सर्च फॉर्म पर फोकस सेट करें",
"page.keyboard_shortcuts.toggle_entry_attachments": "Toggle open/close entry attachments",
"page.keyboard_shortcuts.close_modal": "मोडल डायलॉग बंद करें",
"page.users.title": "उपभोक्ता",
"page.users.username": "यूसर्नेम",
"page.users.never_logged": "कभी नहीं",
"page.users.admin.yes": "हां",
"page.users.admin.no": "नहीं",
"page.users.actions": "कार्रवाई",
"page.users.last_login": "आखरी लॉगइन",
"page.users.is_admin": "प्रशासक",
"page.settings.title": "समायोजन",
"page.settings.link_google_account": "मेरा गूगल खाता जोरीय",
"page.settings.unlink_google_account": "मेरा गूगल खाता हटाय",
"page.settings.link_oidc_account": "मेरा ओपन-ईद खाता जोरीय",
"page.settings.unlink_oidc_account": "मेरा ओपन-ईद खाता हटाय",
"page.settings.webauthn.passkeys": "Passkeys",
"page.settings.webauthn.actions": "Actions",
"page.settings.webauthn.passkey_name": "Passkey Name",
"page.settings.webauthn.added_on": "Added On",
"page.settings.webauthn.last_seen_on": "Last Used",
"page.settings.webauthn.register": "रजिस्टर पासकी",
"page.settings.webauthn.register.error": "पासकी पंजीकृत करने में असमर्थ",
"page.settings.webauthn.delete": [
"%d पासकुंजी निकालें",
"%d पासकी हटाएं"
],
"page.login.title": "साइन इन करें",
"page.login.google_signin": "गूगल के साथ साइन इन करें",
"page.login.oidc_signin": "ओपन-ईद के साथ साइन इन करें",
"page.login.webauthn_login": "पासकी से लॉगिन करें",
"page.login.webauthn_login.error": "पासकी से लॉगिन करने में असमर्थ",
"page.integrations.title": "एकीकरण",
"page.integration.miniflux_api": "मिनिफलक्ष एपीआई",
"page.integration.miniflux_api_endpoint": "एपीआई समापन बिंदु",
"page.integration.miniflux_api_username": "यूसर्नेम",
"page.integration.miniflux_api_password": "पासवर्ड",
"page.integration.miniflux_api_password_value": "आपका खाता पासवर्ड",
"page.integration.bookmarklet": "बुकमार्कलेट",
"page.integration.bookmarklet.name": "मिनीफ्लक्स में जोड़ें",
"page.integration.bookmarklet.instructions": "इस लिंक को खींचकर अपने बुकमार्क पर छोड़ दें।",
"page.integration.bookmarklet.help": "यह विशेष लिंक आपको अपने वेब ब्राउज़र में बुकमार्क का उपयोग करके सीधे वेबसाइट की सदस्यता लेने की अनुमति देता है।",
"page.sessions.title": "सत्र",
"page.sessions.table.date": "दिनांक",
"page.sessions.table.ip": "आईपी पता",
"page.sessions.table.user_agent": "उपभोक्ता अभिकर्ता",
"page.sessions.table.actions": "कार्रवाई",
"page.sessions.table.current_session": "वर्तमान सत्र",
"page.api_keys.title": "एपीआई कुंजी",
"page.api_keys.table.description": "विवरण",
"page.api_keys.table.token": "टोकन",
"page.api_keys.table.last_used_at": "आखरी इस्त्तमाल किया गया",
"page.api_keys.table.created_at": "निर्माण तिथि",
"page.api_keys.table.actions": "कार्रवाई",
"page.api_keys.never_used": "कभी प्रयोग नहीं हुआ",
"page.new_api_key.title": "नई एपीआई कुंजी",
"page.offline.title": "ऑफ़लाइन मोड",
"page.offline.message": "आप संपर्क में नहीं हैं",
"page.offline.refresh_page": "पृष्ठ को ताज़ा करने का प्रयास करें",
"page.webauthn_rename.title": "Rename Passkey",
"alert.no_shared_entry": "कोई साझा प्रविष्टि नहीं है",
"alert.no_bookmark": "इस समय कोई बुकमार्क नहीं है",
"alert.no_category": "कोई श्रेणी नहीं है।",
"alert.no_category_entry": "इस श्रेणी में कोई विषय-वस्तु नहीं है।",
"alert.no_feed_entry": "इस फ़ीड के लिए कोई विषय-वस्तु नहीं है।",
"alert.no_feed": "आपके पास कोई सदस्यता नहीं है।",
"alert.no_feed_in_category": "इस श्रेणी के लिए कोई सदस्यता नहीं है।",
"alert.no_history": "इस समय कोई इतिहास नहीं है",
"alert.feed_error": "इस फ़ीड में एक समस्या है",
"alert.no_search_result": "इस खोज के लिए कोई परिणाम नहीं हैं।",
"alert.no_unread_entry": "कोई अपठित वस्तुत नहीं है।",
"alert.no_user": "आप एकमात्र उपयोगकर्ता हैं।",
"alert.account_unlinked": "आपका बाहरी खाता अब अलग कर दिया गया है!",
"alert.account_linked": "आपका बाहरी खाता अब लिंक हो गया है!",
"alert.pocket_linked": "आपका पॉकेट खाता अब लिंक हो गया है!",
"alert.prefs_saved": "प्राथमिकताएं सहेजी गईं!",
"error.unlink_account_without_password": "आपको एक पासवर्ड परिभाषित करना होगा अन्यथा आप फिर से लॉगिन नहीं कर पाएंगे।",
"error.duplicate_linked_account": "इस प्रदाता के साथ पहले से ही कोई व्यक्ति जुड़ा हुआ है!",
"error.duplicate_fever_username": "पहले से ही समान फीवर उपयोगकर्ता नाम वाला कोई और है!",
"error.duplicate_googlereader_username": "समान गूगल रीडर उपयोगकर्ता नाम वाला कोई और पहले से मौजूद है!",
"error.pocket_request_token": "पॉकेट से अनुरोध टोकन लाने में असमर्थ!",
"error.pocket_access_token": "पॉकेट से एक्सेस टोकन प्राप्त करने में असमर्थ!",
"error.category_already_exists": "यह श्रेणी पहले से मौजूद है।",
"error.unable_to_create_category": "यह श्रेणी बनाने में असमर्थ.",
"error.unable_to_update_category": "इस श्रेणी को अपडेट करने में असमर्थ।",
"error.user_already_exists": "यह उपयोगकर्ता पहले से ही मौजूद है।",
"error.unable_to_create_user": "इस उपयोगकर्ता को बनाने में असमर्थ।",
"error.unable_to_update_user": "इस उपयोगकर्ता को अपडेट करने में असमर्थ.",
"error.unable_to_update_feed": "इस फ़ीड को अपडेट करने में असमर्थ.",
"error.subscription_not_found": "कोई सदस्यता ढूँढने में असमर्थ.",
"error.invalid_theme": "अमान्य थीम.",
"error.invalid_language": "अमान्य भाषा.",
"error.invalid_timezone": "अमान्य समयक्षेत्र.",
"error.invalid_entry_direction": "अमान्य प्रवेश दिशा।",
"error.invalid_display_mode": "अमान्य वेब ऐप्लिकेशन प्रदर्शन मोड.",
"error.invalid_gesture_nav": "अमान्य इशारा नेविगेशन।",
"error.invalid_default_home_page": "अमान्य डिफ़ॉल्ट मुखपृष्ठ!",
"error.empty_file": "यह फ़ाइल खाली है।",
"error.bad_credentials": "अमान्य उपयोगकर्ता नाम या पासवर्ड।",
"error.fields_mandatory": "सभी फील्ड अनिवार्य।",
"error.title_required": "शीर्षक अनिवार्य है।",
"error.different_passwords": "पासवर्ड एक जैसे नहीं हैं।",
"error.password_min_length": "पासवर्ड में कम से कम 6 अक्षर होने चाहिए।",
"error.settings_mandatory_fields": "उपयोगकर्ता नाम, विषयवस्तु, भाषा और समयक्षेत्र फ़ील्ड अनिवार्य हैं।",
"error.settings_reading_speed_is_positive": "पढ़ने की गति सकारात्मक पूर्णांक होनी चाहिए।",
"error.entries_per_page_invalid": "प्रति पृष्ठ प्रविष्टियों की संख्या मान्य नहीं है।",
"error.feed_mandatory_fields": "URL और श्रेणी अनिवार्य हैं।",
"error.feed_already_exists": "यह फ़ीड पहले से मौजूद है.",
"error.invalid_feed_url": "दृष्टिकोण यूआरएल.",
"error.invalid_site_url": "अमान्य साइट यूआरएल",
"error.feed_url_not_empty": "फ़ीड यूआरएल खाली नहीं हो सकता.",
"error.site_url_not_empty": "साइट का यूआरएल खाली नहीं हो सकता.",
"error.feed_title_not_empty": "फ़ीड शीर्षक खाली नहीं हो सकता.",
"error.feed_category_not_found": "यह श्रेणी मौजूद नहीं है या इस उपयोगकर्ता से संबंधित नहीं है।",
"error.feed_invalid_blocklist_rule": "ब्लॉक सूची नियम अमान्य है।",
"error.feed_invalid_keeplist_rule": "सूची रखें नियम अमान्य है।",
"error.user_mandatory_fields": "उपयोगकर्ता नाम अनिवार्य है।",
"error.api_key_already_exists": "यह एपीआई कुंजी पहले से मौजूद है।",
"error.unable_to_create_api_key": "यह एपीआई कुंजी बनाने में असमर्थ।",
"form.feed.label.title": "शीर्षक",
"form.feed.label.site_url": "साइट यूआरएल",
"form.feed.label.feed_url": "फ़ीड यूआरएल",
"form.feed.label.category": "श्रेणी",
"form.feed.label.crawler": "मूल सामग्री प्राप्त करें",
"form.feed.label.feed_username": "फ़ीड उपयोगकर्ता नाम",
"form.feed.label.feed_password": "फ़ीड पासवर्ड",
"form.feed.label.user_agent": "डिफ़ॉल्ट उपयोगकर्ता एजेंट को ओवरराइड करें",
"form.feed.label.cookie": "कुकीज़ सेट करें",
"form.feed.label.scraper_rules": "खुरचनी नियम",
"form.feed.label.rewrite_rules": "नियम फिर से लिखें",
"form.feed.label.apprise_service_urls": "Comma separated list of Apprise service URLs",
"form.feed.label.blocklist_rules": "ब्लॉक नियम",
"form.feed.label.keeplist_rules": "नियम बनाए रखें",
"form.feed.label.urlrewrite_rules": " यूआरएल पुनर्लेखन नियम",
"form.feed.label.ignore_http_cache": "एचटीटीपी कैश पर ध्यान न दें",
"form.feed.label.allow_self_signed_certificates": "स्व-हस्ताक्षरित या अमान्य प्रमाणपत्रों की अनुमति दें",
"form.feed.label.fetch_via_proxy": "प्रॉक्सी के माध्यम से प्राप्त करें",
"form.feed.label.disabled": "इस फ़ीड को रीफ़्रेश न करें",
"form.feed.label.no_media_player": "No media player (audio/video)",
"form.feed.label.hide_globally": "वैश्विक अपठित सूची में प्रविष्टियां छिपाएं",
"form.feed.fieldset.general": "General",
"form.feed.fieldset.rules": "Rules",
"form.feed.fieldset.network_settings": "Network Settings",
"form.feed.fieldset.integration": "Third-Party Services",
"form.category.label.title": "शीर्षक",
"form.category.hide_globally": "वैश्विक अपठित सूची में प्रविष्टियां छिपाएं",
"form.user.label.username": "उपयोगकर्ता नाम",
"form.user.label.password": "पासवर्ड",
"form.user.label.confirmation": "पासवर्ड पुष्टि",
"form.user.label.admin": "प्रशासक",
"form.prefs.label.language": "भाषाओं",
"form.prefs.label.timezone": "समय क्षेत्र",
"form.prefs.label.theme": "थीम",
"form.prefs.label.entry_sorting": "प्रवेश छँटाई",
"form.prefs.label.entries_per_page": "प्रति पृष्ठ प्रविष्टियाँ",
"form.prefs.label.default_reading_speed": "अन्य भाषाओं के लिए पढ़ने की गति (प्रति मिनट शब्द)",
"form.prefs.label.cjk_reading_speed": "चीनी, कोरियाई और जापानी के लिए पढ़ने की गति (प्रति मिनट वर्ण)",
"form.prefs.label.display_mode": "प्रोग्रेसिव वेब ऐप (PWA) डिस्प्ले मोड",
"form.prefs.select.older_first": "पहले पुरानी प्रविष्टियाँ",
"form.prefs.select.recent_first": "हाल की प्रविष्टियाँ पहले",
"form.prefs.select.fullscreen": "पूर्ण स्क्रीन",
"form.prefs.select.standalone": "स्टैंडअलोन",
"form.prefs.select.minimal_ui": "कम से कम",
"form.prefs.select.browser": "ब्राउज़र",
"form.prefs.select.publish_time": "प्रवेश प्रकाशित समय",
"form.prefs.select.created_time": "प्रवेश बनाया समय",
"form.prefs.select.alphabetical": "वर्णक्रम",
"form.prefs.select.unread_count": "अपठित गणना",
"form.prefs.select.none": "कोई नहीं",
"form.prefs.select.tap": "दो बार टैप",
"form.prefs.select.swipe": "कड़ी चोट",
"form.prefs.label.keyboard_shortcuts": "कीबोर्ड शॉर्टकट सक्षम करें",
"form.prefs.label.entry_swipe": "टच स्क्रीन पर एंट्री स्वाइप सक्षम करें",
"form.prefs.label.gesture_nav": "प्रविष्टियों के बीच नेविगेट करने के लिए इशारा",
"form.prefs.label.show_reading_time": "विषय के लिए अनुमानित पढ़ने का समय दिखाएं",
"form.prefs.label.custom_css": "कस्टम सीएसएस",
"form.prefs.label.entry_order": "प्रवेश छँटाई कॉलम",
"form.prefs.label.default_home_page": "डिफ़ॉल्ट होमपेज़",
"form.prefs.label.categories_sorting_order": "श्रेणियाँ छँटाई",
"form.prefs.label.mark_read_on_view": "देखे जाने पर स्वचालित रूप से प्रविष्टियों को पढ़ने के रूप में चिह्नित करें",
"form.prefs.fieldset.application_settings": "Application Settings",
"form.prefs.fieldset.authentication_settings": "Authentication Settings",
"form.prefs.fieldset.reader_settings": "Reader Settings",
"form.import.label.file": "ओपीएमएल फ़ाइल",
"form.import.label.url": "यूआरएल",
"form.integration.fever_activate": "फीवर एपीआई सक्रिय करें",
"form.integration.fever_username": "फीवर उपयोगकर्ता नाम",
"form.integration.fever_password": "फीवर पासवर्ड",
"form.integration.fever_endpoint": "फीवर एपीआई समापन बिंदु:",
"form.integration.googlereader_activate": "गूगल रीडर एपीआई सक्रिय करें",
"form.integration.googlereader_username": "गूगल रीडर उपयोगकर्ता नाम",
"form.integration.googlereader_password": "गूगल रीडर पासवर्ड",
"form.integration.googlereader_endpoint": "गूगल रीडर एपीआई समापन बिंदु:",
"form.integration.pinboard_activate": "सहेजें विषयवस्तु प्रति का बोर्ड ",
"form.integration.pinboard_token": "पिनबोर्ड एपीआई टोकन",
"form.integration.pinboard_tags": "पिनबोर्ड टैग",
"form.integration.pinboard_bookmark": "बुकमार्क को अपठित के रूप में चिह्नित करें",
"form.integration.instapaper_activate": "विषय-वस्तु को इंस्टापेपर में सहेजें",
"form.integration.instapaper_username": "इंस्टापेपर यूजरनेम",
"form.integration.instapaper_password": "इंस्टापेपर पासवर्ड",
"form.integration.pocket_activate": "विषय-कविता को पॉकेट में सहेजें",
"form.integration.pocket_consumer_key": "पॉकेट उपभोक्ता कुंजी",
"form.integration.pocket_access_token": "पॉकेट एक्सेस टोकन",
"form.integration.pocket_connect_link": "अपना पॉकेट खाता कनेक्ट करें",
"form.integration.wallabag_activate": "विषय सहेजें वालाबाग में ",
"form.integration.wallabag_only_url": "केवल URL भेजें (पूर्ण सामग्री के बजाय)",
"form.integration.wallabag_endpoint": "वालबैग एपीआई एंडपॉइंट",
"form.integration.wallabag_client_id": "वालाबैग क्लाइंट आईडी",
"form.integration.wallabag_client_secret": "वालाबैग क्लाइंट सीक्रेट",
"form.integration.wallabag_username": "वालाबैग उपयोगकर्ता नाम",
"form.integration.wallabag_password": "वालाबैग पासवर्ड",
"form.integration.notion_activate": "Save entries to Notion",
"form.integration.notion_page_id": "Notion Page ID",
"form.integration.notion_token": "Notion Secret Token",
"form.integration.apprise_activate": "Push entries to Apprise",
"form.integration.apprise_url": "Apprise API URL",
"form.integration.apprise_services_url": "Comma separated list of Apprise service URLs",
"form.integration.nunux_keeper_activate": "विषय-वस्तु को ननक्स कीपर में सहेजें",
"form.integration.nunux_keeper_endpoint": "ननक्स कीपर एपीआई समापन बिंदु",
"form.integration.nunux_keeper_api_key": "ननक्स कीपर एपीआई कुंजी",
"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.espial_activate": "विषय-वस्तु को जासूसी में सहेजें",
"form.integration.espial_endpoint": "जासूसी एपीआई समापन बिंदु",
"form.integration.espial_api_key": "जासूसी एपीआई कुंजी",
"form.integration.espial_tags": "जासूसी टैग",
"form.integration.readwise_activate": "Save entries to Readwise Reader",
"form.integration.readwise_api_key": "Readwise Reader Access Token",
"form.integration.readwise_api_key_link": "Get your Readwise Access Token",
"form.integration.telegram_bot_activate": "टेलीग्राम चैट के लिए नई विषय-कविता पुश करें",
"form.integration.telegram_bot_token": "बॉट टोकन",
"form.integration.telegram_chat_id": "चैट आईडी",
"form.integration.telegram_topic_id": "Topic ID",
"form.integration.telegram_bot_disable_web_page_preview": "Disable web page preview",
"form.integration.telegram_bot_disable_notification": "Disable notification",
"form.integration.telegram_bot_disable_buttons": "Disable buttons",
"form.integration.linkding_activate": "लिंक्डिन में विषयवस्तु सहेजें",
"form.integration.linkding_endpoint": "लिंकिंग एपीआई समापन बिंदु",
"form.integration.linkding_api_key": "लिंकिंग एपीआई कुंजी",
"form.integration.linkding_tags": "Linkding Tags",
"form.integration.linkding_bookmark": "बुकमार्क को अपठित के रूप में चिह्नित करें",
"form.integration.matrix_bot_activate": "नए लेखों को मैट्रिक्स में स्थानांतरित करें",
"form.integration.matrix_bot_user": "मैट्रिक्स के लिए उपयोगकर्ता नाम",
"form.integration.matrix_bot_password": "मैट्रिक्स उपयोगकर्ता के लिए पासवर्ड",
"form.integration.matrix_bot_url": "मैट्रिक्स सर्वर URL",
"form.integration.matrix_bot_chat_id": "मैट्रिक्स रूम की आईडी",
"form.integration.shiori_activate": "Save articles to Shiori",
"form.integration.shiori_endpoint": "Shiori API Endpoint",
"form.integration.shiori_username": "Shiori Username",
"form.integration.shiori_password": "Shiori Password",
"form.integration.shaarli_activate": "Save articles to Shaarli",
"form.integration.shaarli_endpoint": "Shaarli URL",
"form.integration.shaarli_api_secret": "Shaarli API Secret",
"form.integration.webhook_activate": "Enable Webhook",
"form.integration.webhook_url": "Webhook URL",
"form.integration.webhook_secret": "Webhook Secret",
"form.integration.rssbridge_activate": "Check RSS-Bridge when adding subscriptions",
"form.integration.rssbridge_url": "RSS-Bridge server URL",
"form.api_key.label.description": "एपीआई कुंजी लेबल",
"form.submit.loading": "लोड हो रहा है...",
"form.submit.saving": "सहेजा जा रहा है...",
"time_elapsed.not_yet": "अभी तक नहीं",
"time_elapsed.yesterday": "कल",
"time_elapsed.now": "बिल्कुल अभी",
"time_elapsed.minutes": [
"%d मिनट पहले",
"%d मिनट पहले"
],
"time_elapsed.hours": [
"%d घंटेभर पहले",
"%d घंटो पहले"
],
"time_elapsed.days": [
"%d दिन पहले",
"%d दिन पहले"
],
"time_elapsed.weeks": [
"%d सप्ताह पहले",
"%d हफ्तों पहले"
],
"time_elapsed.months": [
"%d महीने पहले",
"%d महिनो पहले"
],
"time_elapsed.years": [
"%d साल पहले",
"%d वर्षों पहले"
],
"alert.too_many_feeds_refresh": "You have triggered too many feed refreshes. Please wait 30 minutes before trying again.",
"alert.background_feed_refresh": "All feeds are being refreshed in the background. You can continue to use Miniflux while this process is running.",
"error.http_response_too_large": "The HTTP response is too large. You could increase the HTTP response size limit in the global settings (requires a server restart).",
"error.http_body_read": "Unable to read the HTTP body: %v.",
"error.http_empty_response_body": "The HTTP response body is empty.",
"error.http_empty_response": "The HTTP response is empty. Perhaps, this website is using a bot protection mechanism?",
"error.tls_error": "TLS error: %v. You could disable TLS verification in the feed settings if you would like.",
"error.network_operation": "Miniflux is not able to reach this website due to a network error: %v.",
"error.network_timeout": "This website is too slow and the request timed out: %v",
"error.http_client_error": "HTTP client error: %v.",
"error.http_not_authorized": "Access to this website is not authorized. It could be a bad username or password.",
"error.http_too_many_requests": "Miniflux generated too many requests to this website. Please, try again later or change the application configuration.",
"error.http_forbidden": "Access to this website is forbidden. Perhaps, this website has a bot protection mechanism?",
"error.http_resource_not_found": "The requested resource is not found. Please, verify the URL.",
"error.http_internal_server_error": "The website is not available at the moment due to a server error. The problem is not on Miniflux side. Please, try again later.",
"error.http_bad_gateway": "The website is not available at the moment due to a bad gateway error. The problem is not on Miniflux side. Please, try again later.",
"error.http_service_unavailable": "The website is not available at the moment due to an internal server error. The problem is not on Miniflux side. Please, try again later.",
"error.http_gateway_timeout": "The website is not available at the moment due to a gateway timeout error. The problem is not on Miniflux side. Please, try again later.",
"error.http_unexpected_status_code": "The website is not available at the moment due to an unexpected HTTP status code: %d. The problem is not on Miniflux side. Please, try again later.",
"error.database_error": "Database error: %v.",
"error.category_not_found": "This category does not exist or does not belong to this user.",
"error.duplicated_feed": "This feed already exists.",
"error.unable_to_parse_feed": "Unable to parse this feed: %v.",
"error.feed_not_found": "This feed does not exist or does not belong to this user.",
"error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.",
"error.feed_format_not_detected": "Unable to detect feed format: %v."
}
miniflux-2.0.51/internal/locale/translations/id_ID.json 0000664 0000000 0000000 00000066525 14546226260 0023116 0 ustar 00root root 0000000 0000000 {
"confirm.question": "Apakah Anda yakin?",
"confirm.question.refresh": "Apakah Anda ingin memaksa penyegaran?",
"confirm.yes": "ya",
"confirm.no": "tidak",
"confirm.loading": "Sedang progres...",
"action.subscribe": "Langgan",
"action.save": "Simpan",
"action.or": "atau",
"action.cancel": "batal",
"action.remove": "Hapus",
"action.remove_feed": "Hapus umpan ini",
"action.update": "Perbarui",
"action.edit": "Sunting",
"action.download": "Unduh",
"action.import": "Impor",
"action.login": "Masuk",
"action.home_screen": "Tambahkan ke beranda",
"tooltip.keyboard_shortcuts": "Pintasan Papan Tik: %s",
"tooltip.logged_user": "Masuk sebagai %s",
"menu.unread": "Belum Dibaca",
"menu.starred": "Markah",
"menu.history": "Riwayat",
"menu.feeds": "Umpan",
"menu.categories": "Kategori",
"menu.settings": "Pengaturan",
"menu.logout": "Keluar",
"menu.preferences": "Preferensi",
"menu.integrations": "Integrasi",
"menu.sessions": "Sesi",
"menu.users": "Pengguna",
"menu.about": "Tentang",
"menu.export": "Ekspor",
"menu.import": "Impor",
"menu.create_category": "Buat kategori",
"menu.mark_page_as_read": "Tandai halaman ini sebagai telah dibaca",
"menu.mark_all_as_read": "Tandai semua sebagai telah dibaca",
"menu.show_all_entries": "Tampilkan semua entri",
"menu.show_only_unread_entries": "Tampilkan hanya entri yang belum dibaca",
"menu.refresh_feed": "Muat ulang",
"menu.refresh_all_feeds": "Muat ulang semua umpan di latar belakang",
"menu.edit_feed": "Sunting",
"menu.edit_category": "Sunting",
"menu.add_feed": "Tambah langganan",
"menu.add_user": "Tambah pengguna",
"menu.flush_history": "Hapus riwayat",
"menu.feed_entries": "Entri",
"menu.api_keys": "Kunci API",
"menu.create_api_key": "Buat kunci API baru",
"menu.shared_entries": "Entri yang Dibagikan",
"search.label": "Cari",
"search.placeholder": "Cari...",
"pagination.next": "Berikutnya",
"pagination.previous": "Sebelumnya",
"entry.status.unread": "Belum dibaca",
"entry.status.read": "Telah dibaca",
"entry.status.toast.unread": "Ditandai sebagai belum dibaca",
"entry.status.toast.read": "Ditandai sebagai telah dibaca",
"entry.status.title": "Ubah status entri",
"entry.bookmark.toggle.on": "Markahi",
"entry.bookmark.toggle.off": "Batal Markahi",
"entry.bookmark.toast.on": "Markahi",
"entry.bookmark.toast.off": "Batal Markahi",
"entry.state.saving": "Menyimpan...",
"entry.state.loading": "Memuat...",
"entry.save.label": "Simpan",
"entry.save.title": "Simpan artikel ini",
"entry.save.completed": "Selesai!",
"entry.save.toast.completed": "Artikel tersimpan",
"entry.scraper.label": "Unduh",
"entry.scraper.title": "Ambil konten asli",
"entry.scraper.completed": "Selesai!",
"entry.external_link.label": "Tautan eksternal",
"entry.comments.label": "Komentar",
"entry.comments.title": "Lihat Komentar",
"entry.share.label": "Bagikan",
"entry.share.title": "Bagikan artikel ini",
"entry.unshare.label": "Batal bagikan",
"entry.shared_entry.title": "Buka tautan publik",
"entry.shared_entry.label": "Bagikan",
"entry.estimated_reading_time": [
"%d menit untuk dibaca"
],
"entry.tags.label": "Tanda:",
"page.shared_entries.title": "Entri yang Dibagikan",
"page.unread.title": "Belum Dibaca",
"page.starred.title": "Markah",
"page.categories.title": "Kategori",
"page.categories.no_feed": "Tidak ada umpan.",
"page.categories.entries": "Artikel",
"page.categories.feeds": "Langganan",
"page.categories.feed_count": [
"Ada %d umpan."
],
"page.categories.unread_counter": "Jumlah entri yang belum dibaca",
"page.new_category.title": "Kategori Baru",
"page.new_user.title": "Pengguna Baru",
"page.edit_category.title": "Sunting Kategori: %s",
"page.edit_user.title": "Sunting Pengguna: %s",
"page.feeds.title": "Umpan",
"page.feeds.last_check": "Terakhir diperiksa:",
"page.feeds.next_check": "Next check:",
"page.feeds.unread_counter": "Jumlah entri yang belum dibaca",
"page.feeds.read_counter": "Jumlah entri yang telah dibaca",
"page.feeds.error_count": [
"%d galat"
],
"page.history.title": "Riwayat",
"page.import.title": "Impor",
"page.search.title": "Hasil Pencarian",
"page.about.title": "Tentang",
"page.about.credits": "Pengembang",
"page.about.version": "Versi:",
"page.about.build_date": "Tanggal Penyusunan:",
"page.about.author": "Pengembang:",
"page.about.license": "Lisensi:",
"page.about.global_config_options": "Pengaturan Konfigurasi Global",
"page.about.postgres_version": "Versi Postgres:",
"page.about.go_version": "Versi Go:",
"page.add_feed.title": "Langganan Baru",
"page.add_feed.no_category": "Tidak ada kategori. Anda harus paling tidak memiliki satu kategori.",
"page.add_feed.label.url": "URL",
"page.add_feed.submit": "Cari langganan",
"page.add_feed.legend.advanced_options": "Pilihan Tingkat Lanjut",
"page.add_feed.choose_feed": "Pilih Umpan",
"page.edit_feed.title": "Sunting Umpan: %s",
"page.edit_feed.last_check": "Terakhir diperiksa:",
"page.edit_feed.last_modified_header": "Tajuk LastModified:",
"page.edit_feed.etag_header": "Tajuk ETag:",
"page.edit_feed.no_header": "Tidak Ada",
"page.edit_feed.last_parsing_error": "Galat Penguraian Terakhir",
"page.entry.attachments": "Lampiran",
"page.keyboard_shortcuts.title": "Pintasan Papan Tik",
"page.keyboard_shortcuts.subtitle.sections": "Navigasi Bagian",
"page.keyboard_shortcuts.subtitle.items": "Navigasi Entri",
"page.keyboard_shortcuts.subtitle.pages": "Navigasi Halaman",
"page.keyboard_shortcuts.subtitle.actions": "Tindakan",
"page.keyboard_shortcuts.go_to_unread": "Ke bagian yang belum dibaca",
"page.keyboard_shortcuts.go_to_starred": "Ke markah",
"page.keyboard_shortcuts.go_to_history": "Ke riwayat",
"page.keyboard_shortcuts.go_to_feeds": "Ke umpan",
"page.keyboard_shortcuts.go_to_categories": "Ke kategori",
"page.keyboard_shortcuts.go_to_settings": "Ke pengaturan",
"page.keyboard_shortcuts.show_keyboard_shortcuts": "Tampilkan pintasan papan tik",
"page.keyboard_shortcuts.go_to_previous_item": "Ke entri sebelumnya",
"page.keyboard_shortcuts.go_to_next_item": "Ke entri berikutnya",
"page.keyboard_shortcuts.go_to_feed": "Ke umpan",
"page.keyboard_shortcuts.go_to_previous_page": "Ke halaman sebelumnya",
"page.keyboard_shortcuts.go_to_next_page": "Ke halaman berikutnya",
"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.open_comments": "Buka tautan komentar",
"page.keyboard_shortcuts.open_comments_same_window": "Buka tautan komentar di bilah saat ini",
"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.keyboard_shortcuts.refresh_all_feeds": "Muat ulang semua umpan di latar belakang",
"page.keyboard_shortcuts.mark_page_as_read": "Tandai halaman saat ini sebagai telah dibaca",
"page.keyboard_shortcuts.download_content": "Unduh konten asli",
"page.keyboard_shortcuts.toggle_bookmark_status": "Ubah status markah",
"page.keyboard_shortcuts.save_article": "Simpan Artikel",
"page.keyboard_shortcuts.scroll_item_to_top": "Gulir ke atas",
"page.keyboard_shortcuts.remove_feed": "Hapus umpan ini",
"page.keyboard_shortcuts.go_to_search": "Atur fokus ke pencaarian",
"page.keyboard_shortcuts.toggle_entry_attachments": "Buka/tutup lampiran entri",
"page.keyboard_shortcuts.close_modal": "Tutup bilah modal",
"page.users.title": "Pengguna",
"page.users.username": "Nama Pengguna",
"page.users.never_logged": "Tidak Pernah",
"page.users.admin.yes": "Ya",
"page.users.admin.no": "Tidak",
"page.users.actions": "Tindakan",
"page.users.last_login": "Terakhir Masuk",
"page.users.is_admin": "Administrator",
"page.settings.title": "Pengaturan",
"page.settings.link_google_account": "Tautkan akun Google saya",
"page.settings.unlink_google_account": "Putuskan akun Google saya",
"page.settings.link_oidc_account": "Tautkan akun OpenID Connect saya",
"page.settings.unlink_oidc_account": "Putuskan akun OpenID Connect saya",
"page.settings.webauthn.passkeys": "Passkeys",
"page.settings.webauthn.actions": "Actions",
"page.settings.webauthn.passkey_name": "Passkey Name",
"page.settings.webauthn.added_on": "Added On",
"page.settings.webauthn.last_seen_on": "Last Used",
"page.settings.webauthn.register": "Register passkey",
"page.settings.webauthn.register.error": "Unable to register passkey",
"page.settings.webauthn.delete": [
"Remove %d passkey",
"Remove %d passkeys"
],
"page.login.title": "Masuk",
"page.login.google_signin": "Masuk dengan Google",
"page.login.oidc_signin": "Masuk dengan OpenID Connect",
"page.login.webauthn_login": "Login with passkey",
"page.login.webauthn_login.error": "Unable to login with passkey",
"page.integrations.title": "Integrasi",
"page.integration.miniflux_api": "API Miniflux",
"page.integration.miniflux_api_endpoint": "Titik URL API",
"page.integration.miniflux_api_username": "Nama Pengguna",
"page.integration.miniflux_api_password": "Kata Sandi",
"page.integration.miniflux_api_password_value": "Kata sandi akun Anda",
"page.integration.bookmarklet": "Bookmarklet",
"page.integration.bookmarklet.name": "Tambahkan ke Miniflux",
"page.integration.bookmarklet.instructions": "Seret dan tempatkan tautan ini ke markah Anda.",
"page.integration.bookmarklet.help": "Tautan spesial ini memperbolehkan Anda untuk berlangganan ke situs langsung dengan menggunakan markah di peramban web Anda.",
"page.sessions.title": "Sesi",
"page.sessions.table.date": "Tanggal",
"page.sessions.table.ip": "Alamat IP",
"page.sessions.table.user_agent": "User Agent",
"page.sessions.table.actions": "Tindakan",
"page.sessions.table.current_session": "Sesi Saat Ini",
"page.api_keys.title": "Kunci API",
"page.api_keys.table.description": "Deskripsi",
"page.api_keys.table.token": "Token",
"page.api_keys.table.last_used_at": "Terakhir Digunakan",
"page.api_keys.table.created_at": "Tanggal Pembuatan",
"page.api_keys.table.actions": "Tindakan",
"page.api_keys.never_used": "Tidak Pernah Digunakan",
"page.new_api_key.title": "Kunci API Baru",
"page.offline.title": "Mode Luring",
"page.offline.message": "Anda sedang luring",
"page.offline.refresh_page": "Coba untuk memuat ulang halaman ini",
"page.webauthn_rename.title": "Rename Passkey",
"alert.no_shared_entry": "Tidak ada entri yang dibagikan.",
"alert.no_bookmark": "Tidak ada markah.",
"alert.no_category": "Tidak ada kategori.",
"alert.no_category_entry": "Tidak ada artikel di kategori ini.",
"alert.no_feed_entry": "Tidak ada artikel di umpan ini.",
"alert.no_feed": "Anda tidak memiliki langganan.",
"alert.no_feed_in_category": "Tidak ada langganan untuk kategori ini.",
"alert.no_history": "Tidak ada riwayat untuk saat ini.",
"alert.feed_error": "Ada masalah dengan umpan ini",
"alert.no_search_result": "Tidak ada hasil untuk pencarian ini.",
"alert.no_unread_entry": "Belum ada artikel yang dibaca.",
"alert.no_user": "Anda adalah satu-satunya pengguna.",
"alert.account_unlinked": "Akun eksternal Anda sudah terputus!",
"alert.account_linked": "Akun eksternal Anda sudah terhubung!",
"alert.pocket_linked": "Akun Pocket Anda sudah terhubung!",
"alert.prefs_saved": "Preferensi disimpan!",
"error.unlink_account_without_password": "Anda harus mengatur kata sandi atau Anda tidak bisa masuk kembali.",
"error.duplicate_linked_account": "Sudah ada orang lain yang terhubung dengan penyedia ini!",
"error.duplicate_fever_username": "Sudah ada orang lain dengan nama pengguna Fever yang sama!",
"error.duplicate_googlereader_username": "Sudah ada orang lain dengan nama pengguna Google Reader yang sama!",
"error.pocket_request_token": "Tidak bisa mendapatkan token permintaan dari Pocket!",
"error.pocket_access_token": "Tidak bisa mendapatkan token akses dari Pocket!",
"error.category_already_exists": "Kategori ini telah ada.",
"error.unable_to_create_category": "Tidak bisa membuat kategori ini.",
"error.unable_to_update_category": "Tidak bisa memperbarui kategori ini.",
"error.user_already_exists": "Pengguna ini sudah ada.",
"error.unable_to_create_user": "Tidak bisa membuat pengguna tersebut.",
"error.unable_to_update_user": "Tidak bisa memperbarui pengguna tersebut.",
"error.unable_to_update_feed": "Tidak bisa memperbarui umpan ini.",
"error.subscription_not_found": "Tidak bisa mencari langganan apa pun.",
"error.invalid_theme": "Tema tidak valid.",
"error.invalid_language": "Bahasa tidak valid.",
"error.invalid_timezone": "Zona waktu tidak valid.",
"error.invalid_entry_direction": "Urutan entri tidak valid.",
"error.invalid_display_mode": "Mode tampilan aplikasi web tidak valid.",
"error.invalid_gesture_nav": "Navigasi gestur tidak valid.",
"error.invalid_default_home_page": "Beranda baku tidak valid!",
"error.empty_file": "Berkas ini kosong.",
"error.bad_credentials": "Nama pengguna atau kata sandi tidak valid.",
"error.fields_mandatory": "Semua bidang diharuskan.",
"error.title_required": "Judul diharuskan.",
"error.different_passwords": "Kata sandi tidak sama.",
"error.password_min_length": "Kata sandi harus memiliki setidaknya 6 karakter.",
"error.settings_mandatory_fields": "Harus ada nama pengguna, tema, bahasa, dan zona waktu.",
"error.settings_reading_speed_is_positive": "Kecepatan membaca harus integer positif.",
"error.entries_per_page_invalid": "Jumlah entri per halaman tidak valid.",
"error.feed_mandatory_fields": "Harus ada URL dan kategorinya.",
"error.feed_already_exists": "Umpan ini sudah ada.",
"error.invalid_feed_url": "URL umpan tidak valid.",
"error.invalid_site_url": "URL situs tidak valid.",
"error.feed_url_not_empty": "URL umpan tidak boleh kosong.",
"error.site_url_not_empty": "URL situs tidak boleh kosong.",
"error.feed_title_not_empty": "Judul umpan tidak boleh kosong.",
"error.feed_category_not_found": "Kategori ini tidak ada atau tidak dipunyai oleh pengguna ini.",
"error.feed_invalid_blocklist_rule": "Aturan blokir tidak valid.",
"error.feed_invalid_keeplist_rule": "Aturan simpan tidak valid.",
"error.user_mandatory_fields": "Harus ada nama pengguna.",
"error.api_key_already_exists": "Kunci API ini sudah ada.",
"error.unable_to_create_api_key": "Tidak bisa membuat kunci API ini.",
"form.feed.label.title": "Judul",
"form.feed.label.site_url": "URL Situs",
"form.feed.label.feed_url": "URL Umpan",
"form.feed.label.category": "Kategori",
"form.feed.label.crawler": "Ambil konten asli",
"form.feed.label.feed_username": "Nama Pengguna Umpan",
"form.feed.label.feed_password": "Kata Sandi Umpan",
"form.feed.label.user_agent": "Timpa User Agent Baku",
"form.feed.label.cookie": "Atur Kuki",
"form.feed.label.scraper_rules": "Aturan Pengambil Data",
"form.feed.label.rewrite_rules": "Aturan Tulis Ulang",
"form.feed.label.apprise_service_urls": "Comma separated list of Apprise service URLs",
"form.feed.label.blocklist_rules": "Aturan Blokir",
"form.feed.label.keeplist_rules": "Aturan Simpan",
"form.feed.label.urlrewrite_rules": "Aturan Tulis Ulang URL",
"form.feed.label.ignore_http_cache": "Abaikan Tembolok HTTP",
"form.feed.label.allow_self_signed_certificates": "Perbolehkan sertifikat web tidak valid atau sertifikasi sendiri",
"form.feed.label.fetch_via_proxy": "Ambil via Proksi",
"form.feed.label.disabled": "Jangan perbarui umpan ini",
"form.feed.label.no_media_player": "No media player (audio/video)",
"form.feed.label.hide_globally": "Sembunyikan entri di daftar belum dibaca global",
"form.feed.fieldset.general": "General",
"form.feed.fieldset.rules": "Rules",
"form.feed.fieldset.network_settings": "Network Settings",
"form.feed.fieldset.integration": "Third-Party Services",
"form.category.label.title": "Judul",
"form.category.hide_globally": "Sembunyikan entri di daftar belum dibaca global",
"form.user.label.username": "Nama Pengguna",
"form.user.label.password": "Kata Sandi",
"form.user.label.confirmation": "Konfirmasi Kata Sandi",
"form.user.label.admin": "Administrator",
"form.prefs.label.language": "Bahasa",
"form.prefs.label.timezone": "Zona Waktu",
"form.prefs.label.theme": "Tema",
"form.prefs.label.entry_sorting": "Pengurutan Entri",
"form.prefs.label.entries_per_page": "Entri per Halaman",
"form.prefs.label.default_reading_speed": "Kecepatan membaca untuk bahasa lain (kata per menit)",
"form.prefs.label.cjk_reading_speed": "Kecepatan membaca untuk bahasa Tiongkok, Korea, dan Jepang (karakter per menit)",
"form.prefs.label.display_mode": "Mode Tampilan Aplikasi Web (perlu pemasangan ulang)",
"form.prefs.select.older_first": "Entri tertua dulu",
"form.prefs.select.recent_first": "Entri terbaru dulu",
"form.prefs.select.fullscreen": "Layar Penuh",
"form.prefs.select.standalone": "Tersendiri",
"form.prefs.select.minimal_ui": "Minimal",
"form.prefs.select.browser": "Peramban",
"form.prefs.select.publish_time": "Waktu entri dipublikasikan",
"form.prefs.select.created_time": "Waktu entri dibuat",
"form.prefs.select.alphabetical": "Secara alfabet",
"form.prefs.select.unread_count": "Jumlah yang belum dibaca",
"form.prefs.select.none": "Tidak ada",
"form.prefs.select.tap": "Ketuk dua kali",
"form.prefs.select.swipe": "Geser",
"form.prefs.label.keyboard_shortcuts": "Aktifkan pintasan papan tik",
"form.prefs.label.entry_swipe": "Aktifkan tindakan geser pada entri di ponsel",
"form.prefs.label.gesture_nav": "Isyarat untuk menavigasi antar entri",
"form.prefs.label.show_reading_time": "Tampilkan perkiraan waktu baca untuk artikel",
"form.prefs.label.custom_css": "Modifikasi CSS",
"form.prefs.label.entry_order": "Pengurutan Kolom Entri",
"form.prefs.label.default_home_page": "Beranda Baku",
"form.prefs.label.categories_sorting_order": "Pengurutan Kategori",
"form.prefs.label.mark_read_on_view": "Secara otomatis menandai entri sebagai telah dibaca saat dilihat",
"form.prefs.fieldset.application_settings": "Application Settings",
"form.prefs.fieldset.authentication_settings": "Authentication Settings",
"form.prefs.fieldset.reader_settings": "Reader Settings",
"form.import.label.file": "Berkas OPML",
"form.import.label.url": "URL",
"form.integration.fever_activate": "Aktifkan API Fever",
"form.integration.fever_username": "Nama Pengguna Fever",
"form.integration.fever_password": "Kata Sandi Fever",
"form.integration.fever_endpoint": "Titik URL API Fever:",
"form.integration.googlereader_activate": "Aktifkan API Google Reader",
"form.integration.googlereader_username": "Nama Pengguna Google Reader",
"form.integration.googlereader_password": "Kata Sandi Google Reader",
"form.integration.googlereader_endpoint": "Titik URL API Google Reader:",
"form.integration.pinboard_activate": "Simpan artikel ke Pinboard",
"form.integration.pinboard_token": "Token API Pinboard",
"form.integration.pinboard_tags": "Tanda di Pinboard",
"form.integration.pinboard_bookmark": "Tandai markah sebagai belum dibaca",
"form.integration.instapaper_activate": "Simpan artikel ke Instapaper",
"form.integration.instapaper_username": "Nama Pengguna Instapaper",
"form.integration.instapaper_password": "Kata Sandi Instapaper",
"form.integration.pocket_activate": "Simpan artikel ke Pocket",
"form.integration.pocket_consumer_key": "Kunci Pelanggan Pocket",
"form.integration.pocket_access_token": "Token Akses Pocket",
"form.integration.pocket_connect_link": "Hubungkan akun Pocket Anda",
"form.integration.wallabag_activate": "Simpan artikel ke Wallabag",
"form.integration.wallabag_only_url": "Kirim hanya URL (alih-alih konten penuh)",
"form.integration.wallabag_endpoint": "Titik URL API Wallabag",
"form.integration.wallabag_client_id": "ID Klien Wallabag",
"form.integration.wallabag_client_secret": "Rahasia Klien Wallabag",
"form.integration.wallabag_username": "Nama Pengguna Wallabag",
"form.integration.wallabag_password": "Kata Sandi Wallabag",
"form.integration.notion_activate": "Save entries to Notion",
"form.integration.notion_page_id": "Notion Page ID",
"form.integration.notion_token": "Notion Secret Token",
"form.integration.apprise_activate": "Push entries to Apprise",
"form.integration.apprise_url": "Apprise API URL",
"form.integration.apprise_services_url": "Comma separated list of Apprise service URLs",
"form.integration.nunux_keeper_activate": "Simpan artikel ke Nunux Keeper",
"form.integration.nunux_keeper_endpoint": "Titik URL API Nunux Keeper",
"form.integration.nunux_keeper_api_key": "Kunci API Nunux Keeper",
"form.integration.omnivore_activate": "Simpan artikel ke Omnivore",
"form.integration.omnivore_url": "Titik URL API Omnivore",
"form.integration.omnivore_api_key": "Kunci API Omnivore",
"form.integration.espial_activate": "Simpan artikel ke Espial",
"form.integration.espial_endpoint": "Titik URL API Espial",
"form.integration.espial_api_key": "Kunci API Espial",
"form.integration.espial_tags": "Tanda di Espial",
"form.integration.readwise_activate": "Save entries to Readwise Reader",
"form.integration.readwise_api_key": "Readwise Reader Access Token",
"form.integration.readwise_api_key_link": "Get your Readwise Access Token",
"form.integration.telegram_bot_activate": "Kirim artikel baru ke percakapan Telegram",
"form.integration.telegram_bot_token": "Token Bot",
"form.integration.telegram_chat_id": "ID Obrolan",
"form.integration.telegram_topic_id": "Topic ID",
"form.integration.telegram_bot_disable_web_page_preview": "Disable web page preview",
"form.integration.telegram_bot_disable_notification": "Disable notification",
"form.integration.telegram_bot_disable_buttons": "Disable buttons",
"form.integration.linkding_activate": "Simpan artikel ke Linkding",
"form.integration.linkding_endpoint": "Titik URL API Linkding",
"form.integration.linkding_api_key": "Kunci API Linkding",
"form.integration.linkding_tags": "Linkding Tags",
"form.integration.linkding_bookmark": "Tandai markah sebagai belum dibaca",
"form.integration.matrix_bot_activate": "Kirim entri baru ke Matrix",
"form.integration.matrix_bot_user": "Nama Pengguna Matrix",
"form.integration.matrix_bot_password": "Kata Sandi Matrix",
"form.integration.matrix_bot_url": "URL Peladen Matrix",
"form.integration.matrix_bot_chat_id": "ID Ruang Matrix",
"form.integration.shiori_activate": "Save articles to Shiori",
"form.integration.shiori_endpoint": "Shiori API Endpoint",
"form.integration.shiori_username": "Shiori Username",
"form.integration.shiori_password": "Shiori Password",
"form.integration.shaarli_activate": "Save articles to Shaarli",
"form.integration.shaarli_endpoint": "Shaarli URL",
"form.integration.shaarli_api_secret": "Shaarli API Secret",
"form.integration.webhook_activate": "Enable Webhook",
"form.integration.webhook_url": "Webhook URL",
"form.integration.webhook_secret": "Webhook Secret",
"form.integration.rssbridge_activate": "Check RSS-Bridge when adding subscriptions",
"form.integration.rssbridge_url": "RSS-Bridge server URL",
"form.api_key.label.description": "Label Kunci API",
"form.submit.loading": "Memuat...",
"form.submit.saving": "Menyimpan...",
"time_elapsed.not_yet": "belum",
"time_elapsed.yesterday": "kemarin",
"time_elapsed.now": "baru saja",
"time_elapsed.minutes": [
"%d menit yang lalu"
],
"time_elapsed.hours": [
"%d jam yang lalu"
],
"time_elapsed.days": [
"%d hari yang lalu"
],
"time_elapsed.weeks": [
"%d pekan yang lalu"
],
"time_elapsed.months": [
"%d bulan yang lalu"
],
"time_elapsed.years": [
"%d tahun yang lalu"
],
"alert.too_many_feeds_refresh": "You have triggered too many feed refreshes. Please wait 30 minutes before trying again.",
"alert.background_feed_refresh": "All feeds are being refreshed in the background. You can continue to use Miniflux while this process is running.",
"error.http_response_too_large": "The HTTP response is too large. You could increase the HTTP response size limit in the global settings (requires a server restart).",
"error.http_body_read": "Unable to read the HTTP body: %v.",
"error.http_empty_response_body": "The HTTP response body is empty.",
"error.http_empty_response": "The HTTP response is empty. Perhaps, this website is using a bot protection mechanism?",
"error.tls_error": "TLS error: %v. You could disable TLS verification in the feed settings if you would like.",
"error.network_operation": "Miniflux is not able to reach this website due to a network error: %v.",
"error.network_timeout": "This website is too slow and the request timed out: %v",
"error.http_client_error": "HTTP client error: %v.",
"error.http_not_authorized": "Access to this website is not authorized. It could be a bad username or password.",
"error.http_too_many_requests": "Miniflux generated too many requests to this website. Please, try again later or change the application configuration.",
"error.http_forbidden": "Access to this website is forbidden. Perhaps, this website has a bot protection mechanism?",
"error.http_resource_not_found": "The requested resource is not found. Please, verify the URL.",
"error.http_internal_server_error": "The website is not available at the moment due to a server error. The problem is not on Miniflux side. Please, try again later.",
"error.http_bad_gateway": "The website is not available at the moment due to a bad gateway error. The problem is not on Miniflux side. Please, try again later.",
"error.http_service_unavailable": "The website is not available at the moment due to an internal server error. The problem is not on Miniflux side. Please, try again later.",
"error.http_gateway_timeout": "The website is not available at the moment due to a gateway timeout error. The problem is not on Miniflux side. Please, try again later.",
"error.http_unexpected_status_code": "The website is not available at the moment due to an unexpected HTTP status code: %d. The problem is not on Miniflux side. Please, try again later.",
"error.database_error": "Database error: %v.",
"error.category_not_found": "This category does not exist or does not belong to this user.",
"error.duplicated_feed": "This feed already exists.",
"error.unable_to_parse_feed": "Unable to parse this feed: %v.",
"error.feed_not_found": "This feed does not exist or does not belong to this user.",
"error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.",
"error.feed_format_not_detected": "Unable to detect feed format: %v."
}
miniflux-2.0.51/internal/locale/translations/it_IT.json 0000664 0000000 0000000 00000071442 14546226260 0023150 0 ustar 00root root 0000000 0000000 {
"confirm.question": "Sei sicuro?",
"confirm.question.refresh": "Vuoi forzare l'aggiornamento?",
"confirm.yes": "sì",
"confirm.no": "no",
"confirm.loading": "In corso...",
"action.subscribe": "Abbonati",
"action.save": "Salva",
"action.or": "o",
"action.cancel": "cancella",
"action.remove": "Elimina",
"action.remove_feed": "Elimina questo feed",
"action.update": "Aggiorna",
"action.edit": "Modifica",
"action.download": "Scarica",
"action.import": "Importa",
"action.login": "Accedi",
"action.home_screen": "Aggiungere alla schermata Home",
"tooltip.keyboard_shortcuts": "Scorciatoia da tastiera: %s",
"tooltip.logged_user": "Autenticato come %s",
"menu.unread": "Da leggere",
"menu.starred": "Preferiti",
"menu.history": "Cronologia",
"menu.feeds": "Feed",
"menu.categories": "Categorie",
"menu.settings": "Impostazioni",
"menu.logout": "Esci",
"menu.preferences": "Preferenze",
"menu.integrations": "Integrazioni",
"menu.sessions": "Sessioni",
"menu.users": "Utenti",
"menu.about": "Informazioni",
"menu.export": "Esporta",
"menu.import": "Importa",
"menu.create_category": "Aggiungi una categoria",
"menu.mark_page_as_read": "Segna questa pagina come letta",
"menu.mark_all_as_read": "Segna tutti gli articoli come letti",
"menu.show_all_entries": "Mostra tutte le voci",
"menu.show_only_unread_entries": "Mostra solo voci non lette",
"menu.refresh_feed": "Aggiorna",
"menu.refresh_all_feeds": "Aggiorna tutti i feed in background",
"menu.edit_feed": "Modifica",
"menu.edit_category": "Modifica",
"menu.add_feed": "Aggiungi feed",
"menu.add_user": "Aggiungi utente",
"menu.flush_history": "Svuota la cronologia",
"menu.feed_entries": "Articoli",
"menu.api_keys": "Chiavi API",
"menu.create_api_key": "Crea una nuova chiave API",
"menu.shared_entries": "Voci condivise",
"search.label": "Cerca",
"search.placeholder": "Cerca...",
"pagination.next": "Successivo",
"pagination.previous": "Precedente",
"entry.status.unread": "Da leggere",
"entry.status.read": "Letto",
"entry.status.toast.unread": "Contrassegnato come non letto",
"entry.status.toast.read": "Contrassegnato come letto",
"entry.status.title": "Cambia lo stato dell'articolo",
"entry.bookmark.toggle.on": "Aggiungi ai preferiti",
"entry.bookmark.toggle.off": "Rimuovi dai preferiti",
"entry.bookmark.toast.on": "Ha recitato",
"entry.bookmark.toast.off": "Non speciali",
"entry.state.saving": "Salvataggio in corso...",
"entry.state.loading": "Caricamento in corso...",
"entry.save.label": "Salva",
"entry.save.title": "Salva questo articolo",
"entry.save.completed": "Fatto!",
"entry.save.toast.completed": "Articolo salvato",
"entry.scraper.label": "Scarica",
"entry.scraper.title": "Scarica il contenuto integrale",
"entry.scraper.completed": "Fatto!",
"entry.external_link.label": "Link esterno",
"entry.comments.label": "Commenti",
"entry.comments.title": "Mostra i commenti",
"entry.share.label": "Condividi",
"entry.share.title": "Condividi questo articolo",
"entry.unshare.label": "Unshare",
"entry.shared_entry.title": "Apri il link pubblico",
"entry.shared_entry.label": "Condivisione",
"entry.estimated_reading_time": [
"%d minuto di lettura",
"%d minuti di lettura"
],
"entry.tags.label": "Tag:",
"page.shared_entries.title": "Voci condivise",
"page.unread.title": "Da leggere",
"page.starred.title": "Preferiti",
"page.categories.title": "Categorie",
"page.categories.no_feed": "Nessun feed.",
"page.categories.entries": "Articoli",
"page.categories.feeds": "Abbonamenti",
"page.categories.feed_count": [
"C'è %d feed.",
"Ci sono %d feed."
],
"page.categories.unread_counter": "Numero di voci non lette",
"page.new_category.title": "Nuova categoria",
"page.new_user.title": "Nuovo utente",
"page.edit_category.title": "Modifica categoria: %s",
"page.edit_user.title": "Modifica utente: %s",
"page.feeds.title": "Feed",
"page.feeds.last_check": "Ultimo controllo:",
"page.feeds.next_check": "Next check:",
"page.feeds.unread_counter": "Numero di voci non lette",
"page.feeds.read_counter": "Numero di voci lette",
"page.feeds.error_count": [
"%d errore",
"%d errori"
],
"page.history.title": "Cronologia",
"page.import.title": "Importa",
"page.search.title": "Risultati della ricerca",
"page.about.title": "Informazioni",
"page.about.credits": "Crediti",
"page.about.version": "Versione:",
"page.about.build_date": "Data della build:",
"page.about.author": "Autore:",
"page.about.license": "Licenza:",
"page.about.global_config_options": "Opzioni di configurazione globali",
"page.about.postgres_version": "Postgres versione:",
"page.about.go_version": "Go versione:",
"page.add_feed.title": "Nuovo feed",
"page.add_feed.no_category": "Nessuna categoria selezionata. Devi scegliere almeno una categoria.",
"page.add_feed.label.url": "URL",
"page.add_feed.submit": "Abbonati al feed",
"page.add_feed.legend.advanced_options": "Opzioni avanzate",
"page.add_feed.choose_feed": "Scegli un feed",
"page.edit_feed.title": "Modifica feed: %s",
"page.edit_feed.last_check": "Ultimo controllo:",
"page.edit_feed.last_modified_header": "Header LastModified:",
"page.edit_feed.etag_header": "Header ETag:",
"page.edit_feed.no_header": "Nessun header",
"page.edit_feed.last_parsing_error": "Ultimo errore di parsing",
"page.entry.attachments": "Allegati",
"page.keyboard_shortcuts.title": "Scorciatoie da tastiera",
"page.keyboard_shortcuts.subtitle.sections": "Navigazione sezioni",
"page.keyboard_shortcuts.subtitle.items": "Navigazione articoli",
"page.keyboard_shortcuts.subtitle.pages": "Navigazione pagine",
"page.keyboard_shortcuts.subtitle.actions": "Azioni",
"page.keyboard_shortcuts.go_to_unread": "Mostra gli articoli da leggere",
"page.keyboard_shortcuts.go_to_starred": "Mostra i preferiti",
"page.keyboard_shortcuts.go_to_history": "Mostra la cronologia",
"page.keyboard_shortcuts.go_to_feeds": "Mostra i feed",
"page.keyboard_shortcuts.go_to_categories": "Mostra le categorie",
"page.keyboard_shortcuts.go_to_settings": "Mostra le impostazioni",
"page.keyboard_shortcuts.show_keyboard_shortcuts": "Mostra le scorciatoie da tastiera",
"page.keyboard_shortcuts.go_to_previous_item": "Mostra l'articolo precedente",
"page.keyboard_shortcuts.go_to_next_item": "Mostra l'articolo successivo",
"page.keyboard_shortcuts.go_to_feed": "Mostra il feed",
"page.keyboard_shortcuts.go_to_previous_page": "Mostra la pagina precedente",
"page.keyboard_shortcuts.go_to_next_page": "Mostra la pagina successiva",
"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.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.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.keyboard_shortcuts.refresh_all_feeds": "Aggiorna tutti i feed in background",
"page.keyboard_shortcuts.mark_page_as_read": "Segna la pagina attuale come letta",
"page.keyboard_shortcuts.download_content": "Scarica il contenuto integrale",
"page.keyboard_shortcuts.toggle_bookmark_status": "Aggiungi/rimuovi dai preferiti",
"page.keyboard_shortcuts.save_article": "Salva l'articolo",
"page.keyboard_shortcuts.scroll_item_to_top": "Scorri l'articolo in alto",
"page.keyboard_shortcuts.remove_feed": "Rimuovi questo feed",
"page.keyboard_shortcuts.go_to_search": "Apri la casella di ricerca",
"page.keyboard_shortcuts.toggle_entry_attachments": "Toggle open/close entry attachments",
"page.keyboard_shortcuts.close_modal": "Chiudi la finestra di dialogo",
"page.users.title": "Utenti",
"page.users.username": "Nome utente",
"page.users.never_logged": "Mai",
"page.users.admin.yes": "Sì",
"page.users.admin.no": "No",
"page.users.actions": "Azioni",
"page.users.last_login": "Ultimo accesso",
"page.users.is_admin": "Amministratore",
"page.settings.title": "Impostazioni",
"page.settings.link_google_account": "Collega il mio account Google",
"page.settings.unlink_google_account": "Scollega il mio account Google",
"page.settings.link_oidc_account": "Collega il mio account OpenID Connect",
"page.settings.unlink_oidc_account": "Scollega il mio account OpenID Connect",
"page.settings.webauthn.passkeys": "Passkeys",
"page.settings.webauthn.actions": "Actions",
"page.settings.webauthn.passkey_name": "Passkey Name",
"page.settings.webauthn.added_on": "Added On",
"page.settings.webauthn.last_seen_on": "Last Used",
"page.settings.webauthn.register": "Registra la chiave di accesso",
"page.settings.webauthn.register.error": "Impossibile registrare la passkey",
"page.settings.webauthn.delete": [
"Rimuovi %d passkey",
"Rimuovi %d passkey"
],
"page.login.title": "Accedi",
"page.login.google_signin": "Accedi tramite Google",
"page.login.oidc_signin": "Accedi tramite OpenID Connect",
"page.login.webauthn_login": "Accedi con passkey",
"page.login.webauthn_login.error": "Impossibile accedere con passkey",
"page.integrations.title": "Integrazioni",
"page.integration.miniflux_api": "API di Miniflux",
"page.integration.miniflux_api_endpoint": "Endpoint dell'API di Miniflux",
"page.integration.miniflux_api_username": "Nome utente",
"page.integration.miniflux_api_password": "Password",
"page.integration.miniflux_api_password_value": "La password del tuo account",
"page.integration.bookmarklet": "Segnalibro",
"page.integration.bookmarklet.name": "Aggiungi a Miniflux",
"page.integration.bookmarklet.instructions": "Trascina questo collegamento sui tuoi segnalibri.",
"page.integration.bookmarklet.help": "Questo collegamento speciale ti consente di abbonarti ad un sito web semplicemente usando un segnalibro del tuo browser.",
"page.sessions.title": "Sessioni",
"page.sessions.table.date": "Data",
"page.sessions.table.ip": "Indirizzo IP",
"page.sessions.table.user_agent": "User Agent",
"page.sessions.table.actions": "Azioni",
"page.sessions.table.current_session": "Sessione corrente",
"page.api_keys.title": "Chiavi API",
"page.api_keys.table.description": "Descrizione",
"page.api_keys.table.token": "Gettone",
"page.api_keys.table.last_used_at": "Ultimo uso",
"page.api_keys.table.created_at": "Data di creazione",
"page.api_keys.table.actions": "Azioni",
"page.api_keys.never_used": "Mai usato",
"page.new_api_key.title": "Nuova chiave API",
"page.offline.title": "Modalità offline",
"page.offline.message": "Sei offline",
"page.offline.refresh_page": "Prova ad aggiornare la pagina",
"page.webauthn_rename.title": "Rename Passkey",
"alert.no_shared_entry": "Non ci sono voci condivise.",
"alert.no_bookmark": "Nessun preferito disponibile.",
"alert.no_category": "Nessuna categoria disponibile.",
"alert.no_category_entry": "Questa categoria non contiene alcun articolo.",
"alert.no_feed_entry": "Questo feed non contiene alcun articolo.",
"alert.no_feed": "Nessun feed disponibile.",
"alert.no_feed_in_category": "Non esiste un abbonamento per questa categoria.",
"alert.no_history": "La tua cronologia al momento è vuota.",
"alert.feed_error": "Sembra ci sia un problema con questo feed",
"alert.no_search_result": "La ricerca non ha prodotto risultati.",
"alert.no_unread_entry": "Nessun articolo da leggere.",
"alert.no_user": "Tu sei l'unico utente.",
"alert.account_unlinked": "Il tuo account esterno ora è scollegato!",
"alert.account_linked": "Il tuo account esterno ora è collegato!",
"alert.pocket_linked": "Il tuo account Pocket ora è collegato!",
"alert.prefs_saved": "Preferenze salvate!",
"error.unlink_account_without_password": "Devi scegliere una password altrimenti la prossima volta non riuscirai ad accedere.",
"error.duplicate_linked_account": "Esiste già un account configurato per questo servizio!",
"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.pocket_request_token": "Non sono riuscito ad ottenere il request token da Pocket!",
"error.pocket_access_token": "Non sono riuscito ad ottenere l'access token da Pocket!",
"error.category_already_exists": "Questa categoria esiste già.",
"error.unable_to_create_category": "Non sono riuscito ad aggiungere questa categoria.",
"error.unable_to_update_category": "Non sono riuscito ad aggiornare questa categoria.",
"error.user_already_exists": "Questo utente esiste già.",
"error.unable_to_create_user": "Non sono riuscito ad aggiungere questo user.",
"error.unable_to_update_user": "Non sono riuscito ad aggiornare questo utente.",
"error.unable_to_update_feed": "Non sono riuscito ad aggiornare questo feed.",
"error.subscription_not_found": "Non ho trovato nessun feed.",
"error.empty_file": "Questo file è vuoto.",
"error.bad_credentials": "Nome utente o password non validi.",
"error.fields_mandatory": "Tutti i campi sono obbligatori.",
"error.title_required": "Il titolo è obbligatorio.",
"error.different_passwords": "Le password non coincidono.",
"error.password_min_length": "La password deve contenere almeno 6 caratteri.",
"error.settings_mandatory_fields": "Il nome utente, il tema, la lingua ed il fuso orario sono campi obbligatori.",
"error.settings_reading_speed_is_positive": "Le velocità di lettura devono essere numeri interi positivi.",
"error.entries_per_page_invalid": "Il numero di articoli per pagina non è valido.",
"error.feed_mandatory_fields": "L'URL e la categoria sono obbligatori.",
"error.feed_already_exists": "Questo feed esiste già.",
"error.invalid_feed_url": "URL del feed non valido.",
"error.invalid_site_url": "URL del sito non valido.",
"error.feed_url_not_empty": "L'URL del feed non può essere vuoto.",
"error.site_url_not_empty": "L'URL del sito non può essere vuoto.",
"error.feed_title_not_empty": "Il titolo del feed non può essere vuoto.",
"error.feed_category_not_found": "Questa categoria non esiste o non appartiene a questo utente.",
"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.user_mandatory_fields": "Il nome utente è obbligatorio.",
"error.api_key_already_exists": "Questa chiave API esiste già.",
"error.unable_to_create_api_key": "Impossibile creare questa chiave API.",
"error.invalid_theme": "Tema non valido.",
"error.invalid_language": "Lingua non valida.",
"error.invalid_timezone": "Fuso orario non valido.",
"error.invalid_entry_direction": "Ordinamento non valido.",
"error.invalid_display_mode": "Modalità di visualizzazione web app non valida.",
"error.invalid_gesture_nav": "Navigazione gestuale non valida.",
"error.invalid_default_home_page": "Pagina iniziale predefinita non valida!",
"form.feed.label.title": "Titolo",
"form.feed.label.site_url": "URL del sito",
"form.feed.label.feed_url": "URL del feed",
"form.feed.label.category": "Categoria",
"form.feed.label.crawler": "Scarica il contenuto integrale",
"form.feed.label.feed_username": "Nome utente del feed",
"form.feed.label.feed_password": "Password del feed",
"form.feed.label.user_agent": "Usa user agent personalizzato",
"form.feed.label.cookie": "Installare i cookies",
"form.feed.label.scraper_rules": "Regole di estrazione del contenuto",
"form.feed.label.rewrite_rules": "Regole di impaginazione del contenuto",
"form.feed.label.apprise_service_urls": "Comma separated list of Apprise service URLs",
"form.feed.label.blocklist_rules": "Regole di blocco",
"form.feed.label.keeplist_rules": "Regole di autorizzazione",
"form.feed.label.urlrewrite_rules": "Regole di riscrittura URL",
"form.feed.label.ignore_http_cache": "Ignora cache HTTP",
"form.feed.label.allow_self_signed_certificates": "Consenti certificati autofirmati o non validi",
"form.feed.label.fetch_via_proxy": "Recuperare tramite proxy",
"form.feed.label.disabled": "Non aggiornare questo feed",
"form.feed.label.no_media_player": "No media player (audio/video)",
"form.feed.label.hide_globally": "Nascondere le voci nella lista globale dei non letti",
"form.feed.fieldset.general": "General",
"form.feed.fieldset.rules": "Rules",
"form.feed.fieldset.network_settings": "Network Settings",
"form.feed.fieldset.integration": "Third-Party Services",
"form.category.label.title": "Titolo",
"form.category.hide_globally": "Nascondere le voci nella lista globale dei non letti",
"form.user.label.username": "Nome utente",
"form.user.label.password": "Password",
"form.user.label.confirmation": "Conferma password",
"form.user.label.admin": "Amministratore",
"form.prefs.label.language": "Lingua",
"form.prefs.label.timezone": "Fuso orario",
"form.prefs.label.theme": "Tema",
"form.prefs.label.entry_sorting": "Ordinamento articoli",
"form.prefs.label.entries_per_page": "Articoli per pagina",
"form.prefs.label.default_reading_speed": "Velocità di lettura di altre lingue (parole al minuto)",
"form.prefs.label.cjk_reading_speed": "Velocità di lettura per cinese, coreano e giapponese (caratteri al minuto)",
"form.prefs.label.display_mode": "Modalità di visualizzazione dell'app Web progressiva (PWA).",
"form.prefs.select.older_first": "Prima i più vecchi",
"form.prefs.select.recent_first": "Prima i più recenti",
"form.prefs.select.fullscreen": "Schermo intero",
"form.prefs.select.standalone": "Autonoma",
"form.prefs.select.minimal_ui": "Minimale",
"form.prefs.select.browser": "Browser",
"form.prefs.select.publish_time": "Ora di pubblicazione dell'entrata",
"form.prefs.select.created_time": "Tempo di creazione dell'entrata",
"form.prefs.select.alphabetical": "In ordine alfabetico",
"form.prefs.select.unread_count": "Conteggio dei non letti",
"form.prefs.select.none": "Nessuno",
"form.prefs.select.tap": "Tocca due volte",
"form.prefs.select.swipe": "Scorri",
"form.prefs.label.keyboard_shortcuts": "Abilita le scorciatoie da tastiera",
"form.prefs.label.entry_swipe": "Abilita lo scorrimento della voce sui touch screen",
"form.prefs.label.gesture_nav": "Gesto per navigare tra le voci",
"form.prefs.label.show_reading_time": "Mostra il tempo di lettura stimato per gli articoli",
"form.prefs.label.custom_css": "CSS personalizzati",
"form.prefs.label.entry_order": "Colonna di ordinamento delle voci",
"form.prefs.label.default_home_page": "Pagina iniziale predefinita",
"form.prefs.label.categories_sorting_order": "Ordinamento delle categorie",
"form.prefs.label.mark_read_on_view": "Contrassegna automaticamente le voci come lette quando visualizzate",
"form.prefs.fieldset.application_settings": "Application Settings",
"form.prefs.fieldset.authentication_settings": "Authentication Settings",
"form.prefs.fieldset.reader_settings": "Reader Settings",
"form.import.label.file": "File OPML",
"form.import.label.url": "URL",
"form.integration.fever_activate": "Abilita l'API di Fever",
"form.integration.fever_username": "Nome utente dell'account Fever",
"form.integration.fever_password": "Password dell'account Fever",
"form.integration.fever_endpoint": "Endpoint dell'API di Fever:",
"form.integration.googlereader_activate": "Abilita l'API di Google Reader",
"form.integration.googlereader_username": "Nome utente dell'account Google Reader",
"form.integration.googlereader_password": "Password dell'account Google Reader",
"form.integration.googlereader_endpoint": "Endpoint dell'API di Google Reader:",
"form.integration.pinboard_activate": "Salva gli articoli su Pinboard",
"form.integration.pinboard_token": "Token dell'API di Pinboard",
"form.integration.pinboard_tags": "Tag di Pinboard",
"form.integration.pinboard_bookmark": "Segna i preferiti come non letti",
"form.integration.instapaper_activate": "Salva gli articoli su Instapaper",
"form.integration.instapaper_username": "Nome utente dell'account Instapaper",
"form.integration.instapaper_password": "Password dell'account Instapaper",
"form.integration.pocket_activate": "Salva gli articoli su Pocket",
"form.integration.pocket_consumer_key": "Consumer key dell'account Pocket",
"form.integration.pocket_access_token": "Access token dell'account Pocket",
"form.integration.pocket_connect_link": "Collega il tuo account Pocket",
"form.integration.wallabag_activate": "Salva gli articoli su Wallabag",
"form.integration.wallabag_endpoint": "Endpoint dell'API di Wallabag",
"form.integration.wallabag_only_url": "Invia solo URL (invece del contenuto completo)",
"form.integration.wallabag_client_id": "Client ID dell'account Wallabag",
"form.integration.wallabag_client_secret": "Client secret dell'account Wallabag",
"form.integration.wallabag_username": "Nome utente dell'account Wallabag",
"form.integration.wallabag_password": "Password dell'account Wallabag",
"form.integration.notion_activate": "Save entries to Notion",
"form.integration.notion_page_id": "Notion Page ID",
"form.integration.notion_token": "Notion Secret Token",
"form.integration.apprise_activate": "Push entries to Apprise",
"form.integration.apprise_url": "Apprise API URL",
"form.integration.apprise_services_url": "Comma separated list of Apprise service URLs",
"form.integration.nunux_keeper_activate": "Salva gli articoli su Nunux Keeper",
"form.integration.nunux_keeper_endpoint": "Endpoint dell'API di Nunux Keeper",
"form.integration.nunux_keeper_api_key": "API key dell'account Nunux Keeper",
"form.integration.omnivore_activate": "Salva gli articoli su Omnivore",
"form.integration.omnivore_url": "Endpoint dell'API di Omnivore",
"form.integration.omnivore_api_key": "API key dell'account Omnivore",
"form.integration.espial_activate": "Salva gli articoli su Espial",
"form.integration.espial_endpoint": "Endpoint dell'API di Espial",
"form.integration.espial_api_key": "API key dell'account Espial",
"form.integration.espial_tags": "Tag di Espial",
"form.integration.readwise_activate": "Save entries to Readwise Reader",
"form.integration.readwise_api_key": "Readwise Reader Access Token",
"form.integration.readwise_api_key_link": "Get your Readwise Access Token",
"form.integration.telegram_bot_activate": "Invia nuovi articoli alla chat di Telegram",
"form.integration.telegram_bot_token": "Token bot",
"form.integration.telegram_chat_id": "ID chat",
"form.integration.telegram_topic_id": "Topic ID",
"form.integration.telegram_bot_disable_web_page_preview": "Disable web page preview",
"form.integration.telegram_bot_disable_notification": "Disable notification",
"form.integration.telegram_bot_disable_buttons": "Disable buttons",
"form.integration.linkding_activate": "Salva gli articoli su Linkding",
"form.integration.linkding_endpoint": "Endpoint dell'API di Linkding",
"form.integration.linkding_api_key": "API key dell'account Linkding",
"form.integration.linkding_tags": "Linkding Tags",
"form.integration.linkding_bookmark": "Segna i preferiti come non letti",
"form.integration.matrix_bot_activate": "Trasferimento di nuovi articoli a Matrix",
"form.integration.matrix_bot_user": "Nome utente per Matrix",
"form.integration.matrix_bot_password": "Password per l'utente Matrix",
"form.integration.matrix_bot_url": "URL del server Matrix",
"form.integration.matrix_bot_chat_id": "ID della stanza Matrix",
"form.integration.shiori_activate": "Salva gli articoli su Shiori",
"form.integration.shiori_endpoint": "Endpoint dell'API di Shiori",
"form.integration.shiori_username": "Nome utente dell'account Shiori",
"form.integration.shiori_password": "Password dell'account Shiori",
"form.integration.shaarli_activate": "Save articles to Shaarli",
"form.integration.shaarli_endpoint": "Shaarli URL",
"form.integration.shaarli_api_secret": "Shaarli API Secret",
"form.integration.webhook_activate": "Enable Webhook",
"form.integration.webhook_url": "Webhook URL",
"form.integration.webhook_secret": "Webhook Secret",
"form.integration.rssbridge_activate": "Check RSS-Bridge when adding subscriptions",
"form.integration.rssbridge_url": "RSS-Bridge server URL",
"form.api_key.label.description": "Etichetta chiave API",
"form.submit.loading": "Caricamento in corso...",
"form.submit.saving": "Salvataggio in corso...",
"time_elapsed.not_yet": "non ancora",
"time_elapsed.yesterday": "ieri",
"time_elapsed.now": "adesso",
"time_elapsed.minutes": [
"%d minuto fa",
"%d minuti fa"
],
"time_elapsed.hours": [
"%d ora fa",
"%d ore fa"
],
"time_elapsed.days": [
"%d giorno fa",
"%d giorni fa"
],
"time_elapsed.weeks": [
"%d settimana fa",
"%d settimane fa"
],
"time_elapsed.months": [
"%d mese fa",
"%d mesi fa"
],
"time_elapsed.years": [
"%d anno fa",
"%d anni fa"
],
"alert.too_many_feeds_refresh": "You have triggered too many feed refreshes. Please wait 30 minutes before trying again.",
"alert.background_feed_refresh": "All feeds are being refreshed in the background. You can continue to use Miniflux while this process is running.",
"error.http_response_too_large": "The HTTP response is too large. You could increase the HTTP response size limit in the global settings (requires a server restart).",
"error.http_body_read": "Unable to read the HTTP body: %v.",
"error.http_empty_response_body": "The HTTP response body is empty.",
"error.http_empty_response": "The HTTP response is empty. Perhaps, this website is using a bot protection mechanism?",
"error.tls_error": "TLS error: %v. You could disable TLS verification in the feed settings if you would like.",
"error.network_operation": "Miniflux is not able to reach this website due to a network error: %v.",
"error.network_timeout": "This website is too slow and the request timed out: %v",
"error.http_client_error": "HTTP client error: %v.",
"error.http_not_authorized": "Access to this website is not authorized. It could be a bad username or password.",
"error.http_too_many_requests": "Miniflux generated too many requests to this website. Please, try again later or change the application configuration.",
"error.http_forbidden": "Access to this website is forbidden. Perhaps, this website has a bot protection mechanism?",
"error.http_resource_not_found": "The requested resource is not found. Please, verify the URL.",
"error.http_internal_server_error": "The website is not available at the moment due to a server error. The problem is not on Miniflux side. Please, try again later.",
"error.http_bad_gateway": "The website is not available at the moment due to a bad gateway error. The problem is not on Miniflux side. Please, try again later.",
"error.http_service_unavailable": "The website is not available at the moment due to an internal server error. The problem is not on Miniflux side. Please, try again later.",
"error.http_gateway_timeout": "The website is not available at the moment due to a gateway timeout error. The problem is not on Miniflux side. Please, try again later.",
"error.http_unexpected_status_code": "The website is not available at the moment due to an unexpected HTTP status code: %d. The problem is not on Miniflux side. Please, try again later.",
"error.database_error": "Database error: %v.",
"error.category_not_found": "This category does not exist or does not belong to this user.",
"error.duplicated_feed": "This feed already exists.",
"error.unable_to_parse_feed": "Unable to parse this feed: %v.",
"error.feed_not_found": "This feed does not exist or does not belong to this user.",
"error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.",
"error.feed_format_not_detected": "Unable to detect feed format: %v."
}
miniflux-2.0.51/internal/locale/translations/ja_JP.json 0000664 0000000 0000000 00000074251 14546226260 0023124 0 ustar 00root root 0000000 0000000 {
"confirm.question": "よろしいですか?",
"confirm.question.refresh": "強制的に更新しますか?",
"confirm.yes": "はい",
"confirm.no": "いいえ",
"confirm.loading": "実行中…",
"action.subscribe": "フィードを購読",
"action.save": "保存",
"action.or": "または",
"action.cancel": "取り消し",
"action.remove": "削除",
"action.remove_feed": "このフィードを削除",
"action.update": "更新",
"action.edit": "編集",
"action.download": "ダウンロード",
"action.import": "インポート",
"action.login": "ログイン",
"action.home_screen": "ホームスクリーンに追加",
"tooltip.keyboard_shortcuts": "キーボードショートカット: %s",
"tooltip.logged_user": "%s としてログイン中",
"menu.unread": "未読",
"menu.starred": "星付き",
"menu.history": "履歴",
"menu.feeds": "フィード一覧",
"menu.categories": "カテゴリ",
"menu.settings": "設定",
"menu.logout": "ログアウト",
"menu.preferences": "設定情報",
"menu.integrations": "連携",
"menu.sessions": "セッション",
"menu.users": "ユーザー一覧",
"menu.about": "ソフトウェア情報",
"menu.export": "エクスポート",
"menu.import": "インポート",
"menu.create_category": "カテゴリを作成",
"menu.mark_page_as_read": "このページを既読にする",
"menu.mark_all_as_read": "すべて既読にする",
"menu.show_all_entries": "すべての記事を表示",
"menu.show_only_unread_entries": "未読の記事だけを表示",
"menu.refresh_feed": "更新",
"menu.refresh_all_feeds": "すべてのフィードをバックグラウンドで更新",
"menu.edit_feed": "編集",
"menu.edit_category": "編集",
"menu.add_feed": "フィードを購読",
"menu.add_user": "ユーザーを追加",
"menu.flush_history": "履歴をクリア",
"menu.feed_entries": "記事一覧",
"menu.api_keys": "API キー",
"menu.create_api_key": "新しい API キーを作成する",
"menu.shared_entries": "共有エントリ",
"search.label": "検索",
"search.placeholder": "…を検索",
"pagination.next": "次",
"pagination.previous": "前",
"entry.status.unread": "未読にする",
"entry.status.read": "既読にする",
"entry.status.toast.unread": "未読にしました",
"entry.status.toast.read": "既読にしました",
"entry.status.title": "記事の状態を変更",
"entry.bookmark.toggle.on": "星を付ける",
"entry.bookmark.toggle.off": "星を外す",
"entry.bookmark.toast.on": "星を付けました",
"entry.bookmark.toast.off": "星を外しました",
"entry.state.saving": "保存中…",
"entry.state.loading": "読み込み中…",
"entry.save.label": "保存",
"entry.save.title": "この記事を保存",
"entry.save.completed": "完了!",
"entry.save.toast.completed": "記事は保存されました",
"entry.scraper.label": "ダウンロード",
"entry.scraper.title": "オリジナルの内容を取得",
"entry.scraper.completed": "完了!",
"entry.external_link.label": "外部リンク",
"entry.comments.label": "コメント",
"entry.comments.title": "コメントを見る",
"entry.share.label": "共有",
"entry.share.title": "この記事を共有する",
"entry.unshare.label": "共有を解除",
"entry.shared_entry.title": "公開リンクを開く",
"entry.shared_entry.label": "共有する",
"entry.estimated_reading_time": [
"%d 分で読めます",
"%d 分で読めます"
],
"entry.tags.label": "タグ:",
"page.shared_entries.title": "共有エントリ",
"page.unread.title": "未読",
"page.starred.title": "星付き",
"page.categories.title": "カテゴリ",
"page.categories.no_feed": "フィードはありません。",
"page.categories.entries": "記事一覧",
"page.categories.feeds": "フィード一覧",
"page.categories.feed_count": [
"%d 件のフィードがあります。",
"%d 件のフィードがあります。"
],
"page.categories.unread_counter": "未読記事の数",
"page.new_category.title": "新規カテゴリ",
"page.new_user.title": "新規ユーザー",
"page.edit_category.title": "カテゴリを編集: %s",
"page.edit_user.title": "ユーザーを編集: %s",
"page.feeds.title": "フィード一覧",
"page.feeds.last_check": "最終チェック:",
"page.feeds.next_check": "Next check:",
"page.feeds.unread_counter": "未読記事の数",
"page.feeds.read_counter": "既読記事の数",
"page.feeds.error_count": [
"%d 個のエラー",
"%d 個のエラー"
],
"page.history.title": "履歴",
"page.import.title": "インポート",
"page.search.title": "検索結果",
"page.about.title": "ソフトウェア情報",
"page.about.credits": "著作権表示",
"page.about.version": "バージョン:",
"page.about.build_date": "ビルド日時:",
"page.about.author": "作者:",
"page.about.license": "ライセンス:",
"page.about.global_config_options": "グローバル構成オプション",
"page.about.postgres_version": "Postgres バージョン:",
"page.about.go_version": "Go バージョン:",
"page.add_feed.title": "新規フィード",
"page.add_feed.no_category": "カテゴリが存在しません。カテゴリが少なくとも1つ必要です。",
"page.add_feed.label.url": "URL",
"page.add_feed.submit": "フィードを探索して追加",
"page.add_feed.legend.advanced_options": "高度な設定",
"page.add_feed.choose_feed": "フィードを選択",
"page.edit_feed.title": "フィードを編集: %s",
"page.edit_feed.last_check": "最終チェック:",
"page.edit_feed.last_modified_header": "Last-Modified ヘッダー:",
"page.edit_feed.etag_header": "ETag ヘッダー:",
"page.edit_feed.no_header": "なし",
"page.edit_feed.last_parsing_error": "直近の解析エラー",
"page.entry.attachments": "添付ファイル",
"page.keyboard_shortcuts.title": "キーボードショートカット",
"page.keyboard_shortcuts.subtitle.sections": "セクションを移動する",
"page.keyboard_shortcuts.subtitle.items": "アイテム間を移動する",
"page.keyboard_shortcuts.subtitle.pages": "ページ間を移動する",
"page.keyboard_shortcuts.subtitle.actions": "アクション",
"page.keyboard_shortcuts.go_to_unread": "未読",
"page.keyboard_shortcuts.go_to_starred": "星付き",
"page.keyboard_shortcuts.go_to_history": "履歴",
"page.keyboard_shortcuts.go_to_feeds": "フィード一覧",
"page.keyboard_shortcuts.go_to_categories": "カテゴリ",
"page.keyboard_shortcuts.go_to_settings": "設定",
"page.keyboard_shortcuts.show_keyboard_shortcuts": "キーボードショートカットを表示",
"page.keyboard_shortcuts.go_to_previous_item": "前のアイテム",
"page.keyboard_shortcuts.go_to_next_item": "次のアイテム",
"page.keyboard_shortcuts.go_to_feed": "フィード",
"page.keyboard_shortcuts.go_to_previous_page": "前のページ",
"page.keyboard_shortcuts.go_to_next_page": "次のページ",
"page.keyboard_shortcuts.open_item": "選択されたアイテムを開く",
"page.keyboard_shortcuts.open_original": "オリジナルのリンクを開く",
"page.keyboard_shortcuts.open_original_same_window": "現在のタブでオリジナルのリンクを開く",
"page.keyboard_shortcuts.open_comments": "コメントリンクを開く",
"page.keyboard_shortcuts.open_comments_same_window": "現在のタブでコメントリンクを開く",
"page.keyboard_shortcuts.toggle_read_status_next": "既読/未読を切り替えて次のアイテムに移動",
"page.keyboard_shortcuts.toggle_read_status_prev": "既読/未読を切り替えて前のアイテムに移動",
"page.keyboard_shortcuts.refresh_all_feeds": "すべてのフィードをバックグラウンドで更新",
"page.keyboard_shortcuts.mark_page_as_read": "現在のページの記事をすべて既読にする",
"page.keyboard_shortcuts.download_content": "オリジナルの内容をダウンロード",
"page.keyboard_shortcuts.toggle_bookmark_status": "星を付ける/外す",
"page.keyboard_shortcuts.save_article": "記事を保存",
"page.keyboard_shortcuts.scroll_item_to_top": "アイテムが上端になるようにスクロール",
"page.keyboard_shortcuts.remove_feed": "このフィードを削除",
"page.keyboard_shortcuts.go_to_search": "検索フォームに移動",
"page.keyboard_shortcuts.toggle_entry_attachments": "添付ファイルを開く/閉じる",
"page.keyboard_shortcuts.close_modal": "モーダルダイアログを閉じる",
"page.users.title": "ユーザー一覧",
"page.users.username": "ユーザー名",
"page.users.never_logged": "未ログイン",
"page.users.admin.yes": "管理者",
"page.users.admin.no": "非管理者",
"page.users.actions": "アクション",
"page.users.last_login": "最終ログイン",
"page.users.is_admin": "管理者",
"page.settings.title": "設定",
"page.settings.link_google_account": "Google アカウントと接続する",
"page.settings.unlink_google_account": "Google アカウントと接続を解除する",
"page.settings.link_oidc_account": "OpenID Connect アカウントと接続する",
"page.settings.unlink_oidc_account": "OpenID Connect アカウントと接続を解除する",
"page.settings.webauthn.passkeys": "Passkeys",
"page.settings.webauthn.actions": "Actions",
"page.settings.webauthn.passkey_name": "Passkey Name",
"page.settings.webauthn.added_on": "Added On",
"page.settings.webauthn.last_seen_on": "Last Used",
"page.settings.webauthn.register": "パスキーを登録する",
"page.settings.webauthn.register.error": "パスキーを登録できません",
"page.settings.webauthn.delete": [
"%d 個のパスキーを削除",
"%d 個のパスキーを削除"
],
"page.login.title": "ログイン",
"page.login.google_signin": "Google アカウントでログイン",
"page.login.oidc_signin": "OpenID Connect アカウントでログイン",
"page.login.webauthn_login": "パスキーでログイン",
"page.login.webauthn_login.error": "パスキーでログインできない",
"page.integrations.title": "連携",
"page.integration.miniflux_api": "Miniflux API",
"page.integration.miniflux_api_endpoint": "API Endpoint",
"page.integration.miniflux_api_username": "ユーザー名",
"page.integration.miniflux_api_password": "パスワード",
"page.integration.miniflux_api_password_value": "アカウントのパスワード",
"page.integration.bookmarklet": "ブックマークレット",
"page.integration.bookmarklet.name": "Miniflux に追加",
"page.integration.bookmarklet.instructions": "このリンクをブラウザのブックマークへドラッグしてください。",
"page.integration.bookmarklet.help": "この特別なリンクを使ってブラウザから直接ウェブサイトのフィードを購読できます。",
"page.sessions.title": "セッション",
"page.sessions.table.date": "日付",
"page.sessions.table.ip": "IP アドレス",
"page.sessions.table.user_agent": "User Agent",
"page.sessions.table.actions": "アクション",
"page.sessions.table.current_session": "現在のセッション",
"page.api_keys.title": "API キー",
"page.api_keys.table.description": "説明",
"page.api_keys.table.token": "トークン",
"page.api_keys.table.last_used_at": "最終使用",
"page.api_keys.table.created_at": "作成日",
"page.api_keys.table.actions": "アクション",
"page.api_keys.never_used": "未使用",
"page.new_api_key.title": "新しい API キー",
"page.offline.title": "オフラインモード",
"page.offline.message": "オフラインです",
"page.offline.refresh_page": "ページを更新してみてください",
"page.webauthn_rename.title": "Rename Passkey",
"alert.no_shared_entry": "共有エントリはありません。",
"alert.no_bookmark": "現在星付きはありません。",
"alert.no_category": "カテゴリが存在しません。",
"alert.no_category_entry": "このカテゴリには記事がありません。",
"alert.no_feed_entry": "このフィードには記事がありません。",
"alert.no_feed": "何も購読していません。",
"alert.no_feed_in_category": "このカテゴリには購読中のフィードがありません。",
"alert.no_history": "現在履歴はありません。",
"alert.feed_error": "このフィードには問題があります。",
"alert.no_search_result": "検索で何も見つかりませんでした。",
"alert.no_unread_entry": "未読の記事はありません。",
"alert.no_user": "あなたが唯一のユーザーです。",
"alert.account_unlinked": "外部アカウントとのリンクが解除されました!",
"alert.account_linked": "外部アカウントとリンクされました!",
"alert.pocket_linked": "Pocket アカウントとリンクされました!",
"alert.prefs_saved": "設定情報は保存されました!",
"error.unlink_account_without_password": "パスワードを設定しなければ再びログインすることはできません。",
"error.duplicate_linked_account": "別なユーザーが既にこのサービスの同じユーザーとリンクしています。",
"error.duplicate_fever_username": "既に同じ名前の Fever ユーザー名が使われています!",
"error.duplicate_googlereader_username": "既に同じ名前の Google Reader ユーザー名が使われています!",
"error.pocket_request_token": "Pocket の request token が取得できません!",
"error.pocket_access_token": "Pocket の access token が取得できません!",
"error.category_already_exists": "このカテゴリは既に存在します。",
"error.unable_to_create_category": "このカテゴリは作成できません。",
"error.unable_to_update_category": "このカテゴリは更新できません。",
"error.user_already_exists": "このユーザーは既に存在します。",
"error.unable_to_create_user": "このユーザーは作成できません。",
"error.unable_to_update_user": "このユーザーは更新できません。",
"error.unable_to_update_feed": "このフィードは更新できません。",
"error.subscription_not_found": "フィードが見つかりません。",
"error.invalid_theme": "テーマが無効です。",
"error.invalid_language": "言語が無効です。",
"error.invalid_timezone": "タイムゾーンが無効です。",
"error.invalid_entry_direction": "記事の表示順が無効です。",
"error.invalid_display_mode": "Web アプリの表示モードが無効です。",
"error.invalid_gesture_nav": "ジェスチャー ナビゲーションが無効です。",
"error.invalid_default_home_page": "デフォルトのトップページが無効です",
"error.empty_file": "このファイルは空です。",
"error.bad_credentials": "ユーザー名かパスワードが間違っています。",
"error.fields_mandatory": "すべての項目が必要です。",
"error.title_required": "タイトルが必要です。",
"error.different_passwords": "パスワードが一致しません。",
"error.password_min_length": "パスワードは6文字以上である必要があります。",
"error.settings_mandatory_fields": "ユーザー名、テーマ、言語、タイムゾーンのすべてが必要です。",
"error.settings_reading_speed_is_positive": "読書速度は正の整数である必要があります。",
"error.entries_per_page_invalid": "ページあたりの記事数が無効です。",
"error.feed_mandatory_fields": "URL と カテゴリが必要です。",
"error.feed_already_exists": "このフィードは既に存在します。",
"error.invalid_feed_url": "フィード URL が無効です。",
"error.invalid_site_url": "サイト URL が無効です。",
"error.feed_url_not_empty": "フィード URL を空にすることはできません。",
"error.site_url_not_empty": "サイトの URL を空にすることはできません。",
"error.feed_title_not_empty": "フィードのタイトルを空にすることはできません。",
"error.feed_category_not_found": "このカテゴリは存在しないか、このユーザーに属していません。",
"error.feed_invalid_blocklist_rule": "ブロックリストルールが無効です。",
"error.feed_invalid_keeplist_rule": "リストの保持ルールが無効です。",
"error.user_mandatory_fields": "ユーザー名が必要です。",
"error.api_key_already_exists": "この API キーは既に存在します。",
"error.unable_to_create_api_key": "この API キーを作成できません。",
"form.feed.label.title": "タイトル",
"form.feed.label.site_url": "サイト URL",
"form.feed.label.feed_url": "フィード URL",
"form.feed.label.category": "カテゴリ",
"form.feed.label.crawler": "オリジナルの内容を取得",
"form.feed.label.feed_username": "フィードのユーザー名",
"form.feed.label.feed_password": "フィードのパスワード",
"form.feed.label.user_agent": "デフォルトの User Agent を上書きする",
"form.feed.label.cookie": "Cookie の設定",
"form.feed.label.scraper_rules": "Scraper ルール",
"form.feed.label.rewrite_rules": "Rewrite ルール",
"form.feed.label.blocklist_rules": "Block ルール",
"form.feed.label.keeplist_rules": "Keep ルール",
"form.feed.label.urlrewrite_rules": "Rewrite URL ルール",
"form.feed.label.apprise_service_urls": "Comma separated list of Apprise service URLs",
"form.feed.label.ignore_http_cache": "HTTPキャッシュを無視",
"form.feed.label.allow_self_signed_certificates": "自己署名証明書または無効な証明書を許可する",
"form.feed.label.fetch_via_proxy": "プロキシ経由で取得",
"form.feed.label.disabled": "このフィードを更新しない",
"form.feed.label.no_media_player": "No media player (audio/video)",
"form.feed.label.hide_globally": "未読一覧に記事を表示しない",
"form.feed.fieldset.general": "General",
"form.feed.fieldset.rules": "Rules",
"form.feed.fieldset.network_settings": "Network Settings",
"form.feed.fieldset.integration": "Third-Party Services",
"form.category.label.title": "タイトル",
"form.category.hide_globally": "未読一覧に記事を表示しない",
"form.user.label.username": "ユーザー名",
"form.user.label.password": "パスワード",
"form.user.label.confirmation": "パスワード確認",
"form.user.label.admin": "管理者",
"form.prefs.label.language": "言語",
"form.prefs.label.timezone": "タイムゾーン",
"form.prefs.label.theme": "テーマ",
"form.prefs.label.entry_sorting": "記事の表示順",
"form.prefs.label.entries_per_page": "ページあたりの記事数",
"form.prefs.label.default_reading_speed": "他言語の読書速度(単語/分)",
"form.prefs.label.cjk_reading_speed": "中国語、韓国語、日本語の読書速度(文字数/分)",
"form.prefs.label.display_mode": "プログレッシブ Web アプリ (PWA) 表示モード",
"form.prefs.select.older_first": "古い記事を最初に",
"form.prefs.select.recent_first": "新しい記事を最初に",
"form.prefs.select.fullscreen": "Fullscreen",
"form.prefs.select.standalone": "Standalone",
"form.prefs.select.minimal_ui": "Minimal",
"form.prefs.select.browser": "Browser",
"form.prefs.select.publish_time": "記事の公開時刻",
"form.prefs.select.created_time": "記事の取得時刻",
"form.prefs.select.alphabetical": "アルファベット順",
"form.prefs.select.unread_count": "未読数",
"form.prefs.select.none": "なし",
"form.prefs.select.tap": "ダブルタップ",
"form.prefs.select.swipe": "スワイプ",
"form.prefs.label.keyboard_shortcuts": "キーボードショートカットを有効にする",
"form.prefs.label.entry_swipe": "タッチスクリーンでスワイプ入力を有効にする",
"form.prefs.label.gesture_nav": "エントリ間を移動するジェスチャー",
"form.prefs.label.show_reading_time": "記事の推定読書時間を表示する",
"form.prefs.label.custom_css": "カスタム CSS",
"form.prefs.label.entry_order": "記事の表示順の基準",
"form.prefs.label.default_home_page": "デフォルトのトップページ",
"form.prefs.label.categories_sorting_order": "カテゴリの表示順",
"form.prefs.label.mark_read_on_view": "表示時にエントリを自動的に既読としてマークします",
"form.prefs.fieldset.application_settings": "Application Settings",
"form.prefs.fieldset.authentication_settings": "Authentication Settings",
"form.prefs.fieldset.reader_settings": "Reader Settings",
"form.import.label.file": "OPML ファイル",
"form.import.label.url": "URL",
"form.integration.fever_activate": "Fever API を有効にする",
"form.integration.fever_username": "Fever のユーザー名",
"form.integration.fever_password": "Fever のパスワード",
"form.integration.fever_endpoint": "Fever API endpoint:",
"form.integration.googlereader_activate": "Google Reader API を有効にする",
"form.integration.googlereader_username": "Google Reader のユーザー名",
"form.integration.googlereader_password": "Google Reader のパスワード",
"form.integration.googlereader_endpoint": "Google Reader API endpoint:",
"form.integration.pinboard_activate": "Pinboard に記事を保存する",
"form.integration.pinboard_token": "Pinboard の API Token",
"form.integration.pinboard_tags": "Pinboard の Tag",
"form.integration.pinboard_bookmark": "ブックマークを未読にする",
"form.integration.instapaper_activate": "Instapaper に記事を保存する",
"form.integration.instapaper_username": "Instapaper のユーザー名",
"form.integration.instapaper_password": "Instapaper のパスワード",
"form.integration.pocket_activate": "Pocket に記事を保存する",
"form.integration.pocket_consumer_key": "Pocket の Consumer Key",
"form.integration.pocket_access_token": "Pocket の Access Token",
"form.integration.pocket_connect_link": "Pocket account に接続",
"form.integration.wallabag_activate": "Wallabag に記事を保存する",
"form.integration.wallabag_only_url": "URL のみを送信 (完全なコンテンツではなく)",
"form.integration.wallabag_endpoint": "Wallabag の API Endpoint",
"form.integration.wallabag_client_id": "Wallabag の Client ID",
"form.integration.wallabag_client_secret": "Wallabag の Client Secret",
"form.integration.wallabag_username": "Wallabag のユーザー名",
"form.integration.wallabag_password": "Wallabag のパスワード",
"form.integration.notion_activate": "Save entries to Notion",
"form.integration.notion_page_id": "Notion Page ID",
"form.integration.notion_token": "Notion Secret Token",
"form.integration.apprise_activate": "Push entries to Apprise",
"form.integration.apprise_url": "Apprise API URL",
"form.integration.apprise_services_url": "Comma separated list of Apprise service URLs",
"form.integration.nunux_keeper_activate": "Nunux Keeper に記事を保存する",
"form.integration.nunux_keeper_endpoint": "Nunux Keeper の API Endpoint",
"form.integration.nunux_keeper_api_key": "Nunux Keeper の API key",
"form.integration.omnivore_activate": "Omnivore に記事を保存する",
"form.integration.omnivore_url": "Omnivore の API Endpoint",
"form.integration.omnivore_api_key": "Omnivore の API key",
"form.integration.espial_activate": "Espial に記事を保存する",
"form.integration.espial_endpoint": "Espial の API Endpoint",
"form.integration.espial_api_key": "Espial の API key",
"form.integration.espial_tags": "Espial の Tag",
"form.integration.readwise_activate": "Save entries to Readwise Reader",
"form.integration.readwise_api_key": "Readwise Reader Access Token",
"form.integration.readwise_api_key_link": "Get your Readwise Access Token",
"form.integration.telegram_bot_activate": "新しい記事を Telegram チャットにプッシュする",
"form.integration.telegram_bot_token": "ボットトークン",
"form.integration.telegram_chat_id": "チャット ID",
"form.integration.telegram_topic_id": "Topic ID",
"form.integration.telegram_bot_disable_web_page_preview": "Disable web page preview",
"form.integration.telegram_bot_disable_notification": "Disable notification",
"form.integration.telegram_bot_disable_buttons": "Disable buttons",
"form.integration.linkding_activate": "Linkding に記事を保存する",
"form.integration.linkding_endpoint": "Linkding の API Endpoint",
"form.integration.linkding_api_key": "Linkding の API key",
"form.integration.linkding_tags": "Linkding Tags",
"form.integration.linkding_bookmark": "ブックマークを未読にする",
"form.integration.matrix_bot_activate": "新しい記事をMatrixに転送する",
"form.integration.matrix_bot_user": "Matrixのユーザー名",
"form.integration.matrix_bot_password": "Matrixユーザ用パスワード",
"form.integration.matrix_bot_url": "MatrixサーバーのURL",
"form.integration.matrix_bot_chat_id": "MatrixルームのID",
"form.integration.shiori_activate": "Shiori に記事を保存する",
"form.integration.shiori_endpoint": "Shiori の API Endpoint",
"form.integration.shiori_username": "Shiori の ユーザー名",
"form.integration.shiori_password": "Shiori の パスワード",
"form.integration.shaarli_activate": "Save articles to Shaarli",
"form.integration.shaarli_endpoint": "Shaarli URL",
"form.integration.shaarli_api_secret": "Shaarli API Secret",
"form.integration.webhook_activate": "Enable Webhook",
"form.integration.webhook_url": "Webhook URL",
"form.integration.webhook_secret": "Webhook Secret",
"form.integration.rssbridge_activate": "Check RSS-Bridge when adding subscriptions",
"form.integration.rssbridge_url": "RSS-Bridge server URL",
"form.api_key.label.description": "API キーラベル",
"form.submit.loading": "読み込み中…",
"form.submit.saving": "保存中…",
"time_elapsed.not_yet": "未来",
"time_elapsed.yesterday": "昨日",
"time_elapsed.now": "今",
"time_elapsed.minutes": [
"%d 分前",
"%d 分前"
],
"time_elapsed.hours": [
"%d 時間前",
"%d 時間前"
],
"time_elapsed.days": [
"%d 日前",
"%d 日前"
],
"time_elapsed.weeks": [
"%d 週間前",
"%d 週間前"
],
"time_elapsed.months": [
"%d か月前",
"%d か月前"
],
"time_elapsed.years": [
"%d 年前",
"%d 年前"
],
"alert.too_many_feeds_refresh": "You have triggered too many feed refreshes. Please wait 30 minutes before trying again.",
"alert.background_feed_refresh": "All feeds are being refreshed in the background. You can continue to use Miniflux while this process is running.",
"error.http_response_too_large": "The HTTP response is too large. You could increase the HTTP response size limit in the global settings (requires a server restart).",
"error.http_body_read": "Unable to read the HTTP body: %v.",
"error.http_empty_response_body": "The HTTP response body is empty.",
"error.http_empty_response": "The HTTP response is empty. Perhaps, this website is using a bot protection mechanism?",
"error.tls_error": "TLS error: %v. You could disable TLS verification in the feed settings if you would like.",
"error.network_operation": "Miniflux is not able to reach this website due to a network error: %v.",
"error.network_timeout": "This website is too slow and the request timed out: %v",
"error.http_client_error": "HTTP client error: %v.",
"error.http_not_authorized": "Access to this website is not authorized. It could be a bad username or password.",
"error.http_too_many_requests": "Miniflux generated too many requests to this website. Please, try again later or change the application configuration.",
"error.http_forbidden": "Access to this website is forbidden. Perhaps, this website has a bot protection mechanism?",
"error.http_resource_not_found": "The requested resource is not found. Please, verify the URL.",
"error.http_internal_server_error": "The website is not available at the moment due to a server error. The problem is not on Miniflux side. Please, try again later.",
"error.http_bad_gateway": "The website is not available at the moment due to a bad gateway error. The problem is not on Miniflux side. Please, try again later.",
"error.http_service_unavailable": "The website is not available at the moment due to an internal server error. The problem is not on Miniflux side. Please, try again later.",
"error.http_gateway_timeout": "The website is not available at the moment due to a gateway timeout error. The problem is not on Miniflux side. Please, try again later.",
"error.http_unexpected_status_code": "The website is not available at the moment due to an unexpected HTTP status code: %d. The problem is not on Miniflux side. Please, try again later.",
"error.database_error": "Database error: %v.",
"error.category_not_found": "This category does not exist or does not belong to this user.",
"error.duplicated_feed": "This feed already exists.",
"error.unable_to_parse_feed": "Unable to parse this feed: %v.",
"error.feed_not_found": "This feed does not exist or does not belong to this user.",
"error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.",
"error.feed_format_not_detected": "Unable to detect feed format: %v."
}
miniflux-2.0.51/internal/locale/translations/nl_NL.json 0000664 0000000 0000000 00000067412 14546226260 0023144 0 ustar 00root root 0000000 0000000 {
"confirm.question": "Weet je het zeker?",
"confirm.question.refresh": "Wil je een gedwongen vernieuwing uitvoeren?",
"confirm.yes": "ja",
"confirm.no": "nee",
"confirm.loading": "Bezig...",
"action.subscribe": "Abboneren",
"action.save": "Opslaan",
"action.or": "of",
"action.cancel": "annuleren",
"action.remove": "Verwijderen",
"action.remove_feed": "Verwijder deze feed",
"action.update": "Updaten",
"action.edit": "Bewerken",
"action.download": "Download",
"action.import": "Importeren",
"action.login": "Inloggen",
"action.home_screen": "Toevoegen aan startscherm",
"tooltip.keyboard_shortcuts": "Sneltoets: %s",
"tooltip.logged_user": "Ingelogd als %s",
"menu.unread": "Ongelezen",
"menu.starred": "Favorieten",
"menu.history": "Geschiedenis",
"menu.feeds": "Feeds",
"menu.categories": "Categorieën",
"menu.settings": "Instellingen",
"menu.logout": "Uitloggen",
"menu.preferences": "Voorkeuren",
"menu.integrations": "Integraties",
"menu.sessions": "Sessies",
"menu.users": "Users",
"menu.about": "Over",
"menu.export": "Exporteren",
"menu.import": "Importeren",
"menu.create_category": "Categorie toevoegen",
"menu.mark_page_as_read": "Markeer deze pagina als gelezen",
"menu.mark_all_as_read": "Markeer alle items als gelezen",
"menu.show_all_entries": "Toon alle artikelen",
"menu.show_only_unread_entries": "Toon alleen ongelezen artikelen",
"menu.refresh_feed": "Vernieuwen",
"menu.refresh_all_feeds": "Vernieuw alle feeds in de achtergrond",
"menu.edit_feed": "Bewerken",
"menu.edit_category": "Bewerken",
"menu.add_feed": "Feed toevoegen",
"menu.add_user": "Gebruiker toevoegen",
"menu.flush_history": "Verwijder geschiedenis",
"menu.feed_entries": "Lidwoord",
"menu.api_keys": "API-sleutels",
"menu.create_api_key": "Maak een nieuwe API-sleutel",
"menu.shared_entries": "Gedeelde vermeldingen",
"search.label": "Zoeken",
"search.placeholder": "Zoeken...",
"pagination.next": "Volgende",
"pagination.previous": "Vorige",
"entry.status.unread": "Ongelezen",
"entry.status.read": "Gelezen",
"entry.status.toast.unread": "Gemarkeerd als ongelezen",
"entry.status.toast.read": "Gemarkeerd als gelezen",
"entry.status.title": "Verander status van item",
"entry.bookmark.toggle.on": "Ster toevoegen",
"entry.bookmark.toggle.off": "Ster weghalen",
"entry.bookmark.toast.on": "Met ster",
"entry.bookmark.toast.off": "Ster verwijderd",
"entry.state.saving": "Opslaag...",
"entry.state.loading": "Laden...",
"entry.save.label": "Opslaan",
"entry.save.title": "Artikel opslaan",
"entry.save.completed": "Done!",
"entry.save.toast.completed": "Artikel opgeslagen",
"entry.scraper.label": "Downloaden",
"entry.scraper.title": "Fetch original content",
"entry.scraper.completed": "Klaar!",
"entry.external_link.label": "Externe link",
"entry.comments.label": "Comments",
"entry.comments.title": "Bekijk de reacties",
"entry.share.label": "Deel",
"entry.share.title": "Deel dit artikel",
"entry.unshare.label": "Delen ongedaan maken",
"entry.shared_entry.title": "Open de openbare link",
"entry.shared_entry.label": "Delen",
"entry.estimated_reading_time": [
"%d minuut leestijd",
"%d minuten leestijd"
],
"entry.tags.label": "Labels:",
"page.shared_entries.title": "Gedeelde vermeldingen",
"page.unread.title": "Ongelezen",
"page.starred.title": "Favorieten",
"page.categories.title": "Categorieën",
"page.categories.no_feed": "Geen feeds.",
"page.categories.entries": "Lidwoord",
"page.categories.feeds": "Abonnementen",
"page.categories.feed_count": [
"Er is %d feed.",
"Er zijn %d feeds."
],
"page.categories.unread_counter": "Aantal ongelezen vermeldingen",
"page.new_category.title": "Nieuwe categorie",
"page.new_user.title": "Nieuwe gebruiker",
"page.edit_category.title": "Bewerken van categorie: %s",
"page.edit_user.title": "Bewerk gebruiker: %s",
"page.feeds.title": "Feeds",
"page.feeds.last_check": "Laatste update:",
"page.feeds.next_check": "Next check:",
"page.feeds.unread_counter": "Aantal ongelezen vermeldingen",
"page.feeds.read_counter": "Aantal gelezen vermeldingen",
"page.feeds.error_count": [
"%d error",
"%d errors"
],
"page.history.title": "Geschiedenis",
"page.import.title": "Importeren",
"page.login.title": "Inloggen",
"page.search.title": "Zoekresultaten",
"page.about.title": "Over",
"page.about.credits": "Copyrights",
"page.about.version": "Versie:",
"page.about.build_date": "Datum build:",
"page.about.author": "Auteur:",
"page.about.license": "Licentie:",
"page.about.global_config_options": "globale configuratie-opties",
"page.about.postgres_version": "Postgres versie:",
"page.about.go_version": "Go versie:",
"page.add_feed.title": "Nieuwe feed",
"page.add_feed.no_category": "Er zijn geen categorieën. Je moet op zijn minst één caterogie hebben.",
"page.add_feed.label.url": "URL",
"page.add_feed.submit": "Feed zoeken",
"page.add_feed.legend.advanced_options": "Geavanceerde mogelijkheden",
"page.add_feed.choose_feed": "Feed kiezen",
"page.edit_feed.title": "Bewerken van feed: %s",
"page.edit_feed.last_check": "Laatste update:",
"page.edit_feed.last_modified_header": "LastModified-header:",
"page.edit_feed.etag_header": "ETAG-header:",
"page.edit_feed.no_header": "Geen",
"page.edit_feed.last_parsing_error": "Laatste parse error",
"page.entry.attachments": "Bijlagen",
"page.keyboard_shortcuts.title": "Sneltoetsen",
"page.keyboard_shortcuts.subtitle.sections": "Naviguatie tussen menu's",
"page.keyboard_shortcuts.subtitle.items": "Navigatie tussen items",
"page.keyboard_shortcuts.subtitle.pages": "Naviguatie tussen pagina's",
"page.keyboard_shortcuts.subtitle.actions": "Actions",
"page.keyboard_shortcuts.go_to_unread": "Ga naar ongelezen",
"page.keyboard_shortcuts.go_to_starred": "Ga naar favorieten",
"page.keyboard_shortcuts.go_to_history": "Ga naar geschiedenis",
"page.keyboard_shortcuts.go_to_feeds": "Ga naar feeds",
"page.keyboard_shortcuts.go_to_categories": "Ga naar categorieën",
"page.keyboard_shortcuts.go_to_settings": "Ga naar instellingen",
"page.keyboard_shortcuts.show_keyboard_shortcuts": "Laat sneltoetsen zien",
"page.keyboard_shortcuts.go_to_previous_item": "Vorige item",
"page.keyboard_shortcuts.go_to_next_item": "Volgende item",
"page.keyboard_shortcuts.go_to_feed": "Ga naar feed",
"page.keyboard_shortcuts.go_to_previous_page": "Vorige pagina",
"page.keyboard_shortcuts.go_to_next_page": "Volgende pagina",
"page.keyboard_shortcuts.open_item": "Open geselecteerde link",
"page.keyboard_shortcuts.open_original": "Open originele link",
"page.keyboard_shortcuts.open_original_same_window": "Oorspronkelijke koppeling op huidig tabblad openen",
"page.keyboard_shortcuts.open_comments": "Open opmerkingen link",
"page.keyboard_shortcuts.open_comments_same_window": "Open de reactiekoppeling op het huidige tabblad",
"page.keyboard_shortcuts.toggle_read_status_next": "Markeer gelezen/ongelezen, focus volgende",
"page.keyboard_shortcuts.toggle_read_status_prev": "Markeer gelezen/ongelezen, focus vorige",
"page.keyboard_shortcuts.refresh_all_feeds": "Vernieuw alle feeds in de achtergrond",
"page.keyboard_shortcuts.mark_page_as_read": "Markeer deze pagina als gelezen",
"page.keyboard_shortcuts.download_content": "Download originele content",
"page.keyboard_shortcuts.toggle_bookmark_status": "Ster toevoegen/weghalen",
"page.keyboard_shortcuts.save_article": "Artikel opslaan",
"page.keyboard_shortcuts.scroll_item_to_top": "Scroll artikel naar boven",
"page.keyboard_shortcuts.remove_feed": "Verwijder deze feed",
"page.keyboard_shortcuts.go_to_search": "Focus instellen op zoekformulier",
"page.keyboard_shortcuts.toggle_entry_attachments": "Toggle open/close entry attachments",
"page.keyboard_shortcuts.close_modal": "Sluit dialoogscherm",
"page.users.title": "Gebruikers",
"page.users.username": "Gebruikersnaam",
"page.users.never_logged": "Nooit",
"page.users.admin.yes": "Ja",
"page.users.admin.no": "Nee",
"page.users.actions": "Acties",
"page.users.last_login": "Laatste login",
"page.users.is_admin": "Administrator",
"page.settings.title": "Instellingen",
"page.settings.link_google_account": "Koppel mijn Google-account",
"page.settings.unlink_google_account": "Ontkoppel mijn Google-account",
"page.settings.link_oidc_account": "Koppel mijn OpenID Connect-account",
"page.settings.unlink_oidc_account": "Ontkoppel mijn OpenID Connect-account",
"page.settings.webauthn.passkeys": "Passkeys",
"page.settings.webauthn.actions": "Actions",
"page.settings.webauthn.passkey_name": "Passkey Name",
"page.settings.webauthn.added_on": "Added On",
"page.settings.webauthn.last_seen_on": "Last Used",
"page.settings.webauthn.register": "Wachtwoord registreren",
"page.settings.webauthn.register.error": "Kan wachtwoord niet registreren",
"page.settings.webauthn.delete": [
"Verwijder %d wachtwoord",
"Verwijder %d wachtwoordsleutels"
],
"page.login.oidc_signin": "Inloggen via OpenID Connect",
"page.login.webauthn_login": "Inloggen met wachtwoord",
"page.login.webauthn_login.error": "Kan niet inloggen met wachtwoord",
"page.login.google_signin": "Inloggen via Google",
"page.integrations.title": "Integraties",
"page.integration.miniflux_api": "Miniflux API",
"page.integration.miniflux_api_endpoint": "API-URL",
"page.integration.miniflux_api_username": "Gebruikersnaam",
"page.integration.miniflux_api_password": "Wachtwoord",
"page.integration.miniflux_api_password_value": "Wachtwoord van jouw account",
"page.integration.bookmarklet": "Bookmarklet",
"page.integration.bookmarklet.name": "Toevoegen aan Miniflux",
"page.integration.bookmarklet.instructions": "Sleep deze link naar je bookmarks.",
"page.integration.bookmarklet.help": "Gebruik deze link als bookmark in je browser om je direct te abboneren op een website.",
"page.sessions.title": "Sessies",
"page.sessions.table.date": "Datum",
"page.sessions.table.ip": "IP-adres",
"page.sessions.table.user_agent": "User-agent",
"page.sessions.table.actions": "Acties",
"page.sessions.table.current_session": "Huidige sessie",
"page.api_keys.title": "API-sleutels",
"page.api_keys.table.description": "Beschrijving",
"page.api_keys.table.token": "Blijk",
"page.api_keys.table.last_used_at": "Laatst gebruikt",
"page.api_keys.table.created_at": "Aanmaakdatum",
"page.api_keys.table.actions": "Acties",
"page.api_keys.never_used": "Nooit gebruikt",
"page.new_api_key.title": "Nieuwe API-sleutel",
"page.offline.title": "Offline modus",
"page.offline.message": "Je bent offline",
"page.offline.refresh_page": "Probeer de pagina te vernieuwen",
"page.webauthn_rename.title": "Rename Passkey",
"alert.no_shared_entry": "Er is geen gedeelde toegang.",
"alert.no_bookmark": "Er zijn op dit moment geen favorieten.",
"alert.no_category": "Er zijn geen categorieën.",
"alert.no_category_entry": "Deze categorie bevat geen feeds.",
"alert.no_feed_entry": "Er zijn geen artikelen in deze feed.",
"alert.no_feed": "Je hebt nog geen feeds geabboneerd staan.",
"alert.no_feed_in_category": "Er is geen abonnement voor deze categorie.",
"alert.no_history": "Geschiedenis is op dit moment leeg.",
"alert.feed_error": "Er is een probleem met deze feed",
"alert.no_search_result": "Er is geen resultaat voor deze zoekopdracht.",
"alert.no_unread_entry": "Er zijn geen ongelezen artikelen.",
"alert.no_user": "Je bent de enige gebruiker.",
"alert.account_unlinked": "Uw externe account is nu gedissocieerd!",
"alert.account_linked": "Uw externe account is nu gekoppeld!",
"alert.pocket_linked": "Uw Pocket-account is nu gekoppeld!",
"alert.prefs_saved": "Instellingen opgeslagen!",
"error.unlink_account_without_password": "U moet een wachtwoord definiëren anders kunt u zich niet opnieuw aanmelden.",
"error.duplicate_linked_account": "Er is al iemand geregistreerd met deze provider!",
"error.duplicate_fever_username": "Er is al iemand met dezelfde Fever gebruikersnaam!",
"error.duplicate_googlereader_username": "Er is al iemand met dezelfde Google Reader gebruikersnaam!",
"error.pocket_request_token": "Kon geen aanvraagtoken ophalen van Pocket!",
"error.pocket_access_token": "Kon geen toegangstoken ophalen van Pocket!",
"error.category_already_exists": "Deze categorie bestaat al.",
"error.unable_to_create_category": "Kan deze categorie niet maken.",
"error.unable_to_update_category": "Kon categorie niet updaten.",
"error.user_already_exists": "Deze gebruiker bestaat al.",
"error.unable_to_create_user": "Kan deze gebruiker niet maken.",
"error.unable_to_update_user": "Kan deze gebruiker niet updaten.",
"error.unable_to_update_feed": "Kan deze feed niet bijwerken.",
"error.subscription_not_found": "Kon geen feeds vinden.",
"error.empty_file": "Dit bestand is leeg.",
"error.bad_credentials": "Onjuiste gebruikersnaam of wachtwoord.",
"error.fields_mandatory": "Alle velden moeten ingevuld zijn.",
"error.title_required": "Naam van categorie is verplicht.",
"error.different_passwords": "Wachtwoorden zijn niet hetzelfde.",
"error.password_min_length": "Je moet minstens 6 tekens gebruiken.",
"error.settings_mandatory_fields": "Gebruikersnaam, skin, taal en tijdzone zijn verplicht.",
"error.settings_reading_speed_is_positive": "De leessnelheden moeten positieve gehele getallen zijn.",
"error.entries_per_page_invalid": "Het aantal inzendingen per pagina is niet geldig.",
"error.feed_mandatory_fields": "The URL en de categorie zijn verplicht.",
"error.feed_already_exists": "Deze feed bestaat al.",
"error.invalid_feed_url": "Ongeldige feed-URL.",
"error.invalid_site_url": "Ongeldige site-URL.",
"error.feed_url_not_empty": "De feed-URL mag niet leeg zijn.",
"error.site_url_not_empty": "De site-URL mag niet leeg zijn.",
"error.feed_title_not_empty": "De feedtitel mag niet leeg zijn.",
"error.feed_category_not_found": "Deze categorie bestaat niet of behoort niet tot deze gebruiker.",
"error.feed_invalid_blocklist_rule": "De regel voor de blokkeerlijst is ongeldig.",
"error.feed_invalid_keeplist_rule": "De regel voor het bewaren van een lijst is ongeldig.",
"error.user_mandatory_fields": "Gebruikersnaam is verplicht",
"error.api_key_already_exists": "This API Key already exists.",
"error.unable_to_create_api_key": "Kan deze API-sleutel niet maken.",
"error.invalid_theme": "Ongeldig thema.",
"error.invalid_language": "Ongeldige taal.",
"error.invalid_timezone": "Ongeldige tijdzone.",
"error.invalid_entry_direction": "Ongeldige sorteervolgorde.",
"error.invalid_display_mode": "Ongeldige weergavemodus voor webapp.",
"error.invalid_gesture_nav": "Ongeldige gebarennavigatie.",
"error.invalid_default_home_page": "Ongeldige standaard homepage!",
"form.feed.label.title": "Naam",
"form.feed.label.site_url": "Website URL",
"form.feed.label.feed_url": "Feed URL",
"form.feed.label.category": "Categorie",
"form.feed.label.crawler": "Download originele content",
"form.feed.label.feed_username": "Feed-gebruikersnaam",
"form.feed.label.feed_password": "Feed wachtwoord",
"form.feed.label.user_agent": "Standaard User Agent overschrijven",
"form.feed.label.cookie": "Cookies instellen",
"form.feed.label.scraper_rules": "Scraper regels",
"form.feed.label.rewrite_rules": "Rewrite regels",
"form.feed.label.blocklist_rules": "Blokkeer regels",
"form.feed.label.keeplist_rules": "toestemmingsregels",
"form.feed.label.urlrewrite_rules": "Regels voor het herschrijven van URL's",
"form.feed.label.apprise_service_urls": "Comma separated list of Apprise service URLs",
"form.feed.label.ignore_http_cache": "Negeer HTTP-cache",
"form.feed.label.allow_self_signed_certificates": "Sta zelfondertekende of ongeldige certificaten toe",
"form.feed.label.fetch_via_proxy": "Ophalen via proxy",
"form.feed.label.disabled": "Vernieuw deze feed niet",
"form.feed.label.no_media_player": "No media player (audio/video)",
"form.feed.label.hide_globally": "Verberg items in de globale ongelezen lijst",
"form.feed.fieldset.general": "General",
"form.feed.fieldset.rules": "Rules",
"form.feed.fieldset.network_settings": "Network Settings",
"form.feed.fieldset.integration": "Third-Party Services",
"form.category.label.title": "Naam",
"form.category.hide_globally": "Verberg items in de globale ongelezen lijst",
"form.user.label.username": "Gebruikersnaam",
"form.user.label.password": "Wachtwoord",
"form.user.label.confirmation": "Bevestig wachtwoord",
"form.user.label.admin": "Administrator",
"form.prefs.label.language": "Taal",
"form.prefs.label.timezone": "Tijdzone",
"form.prefs.label.theme": "Skin",
"form.prefs.label.entry_sorting": "Volgorde van items",
"form.prefs.label.entries_per_page": "Inzendingen per pagina",
"form.prefs.label.default_reading_speed": "Leessnelheid voor andere talen (woorden per minuut)",
"form.prefs.label.cjk_reading_speed": "Leessnelheid voor Chinees, Koreaans en Japans (tekens per minuut)",
"form.prefs.label.display_mode": "Weergavemodus Progressive Web App (PWA).",
"form.prefs.select.older_first": "Oudere items eerst",
"form.prefs.select.recent_first": "Recente items eerst",
"form.prefs.select.fullscreen": "Volledig scherm",
"form.prefs.select.standalone": "Standalone",
"form.prefs.select.minimal_ui": "Minimaal",
"form.prefs.select.browser": "Browser",
"form.prefs.select.publish_time": "Tijd van binnenkomst",
"form.prefs.select.created_time": "Tijdstip van binnenkomst",
"form.prefs.select.alphabetical": "Alfabetisch",
"form.prefs.select.unread_count": "Ongelezen tellen",
"form.prefs.select.none": "Geen",
"form.prefs.select.tap": "Dubbeltik",
"form.prefs.select.swipe": "Vegen",
"form.prefs.label.keyboard_shortcuts": "Schakel sneltoetsen in",
"form.prefs.label.entry_swipe": "Invoervegen inschakelen op aanraakschermen",
"form.prefs.label.gesture_nav": "Gebaar om tussen ingangen te navigeren",
"form.prefs.label.show_reading_time": "Toon geschatte leestijd voor artikelen",
"form.prefs.label.custom_css": "Aangepaste CSS",
"form.prefs.label.entry_order": "Ingang Sorteerkolom",
"form.prefs.label.default_home_page": "Standaard startpagina",
"form.prefs.label.categories_sorting_order": "Categorieën sorteren",
"form.prefs.label.mark_read_on_view": "Items automatisch markeren als gelezen wanneer ze worden bekeken",
"form.prefs.fieldset.application_settings": "Application Settings",
"form.prefs.fieldset.authentication_settings": "Authentication Settings",
"form.prefs.fieldset.reader_settings": "Reader Settings",
"form.import.label.file": "OPML-bestand",
"form.import.label.url": "URL",
"form.integration.fever_activate": "Activeer Fever API",
"form.integration.fever_username": "Fever gebruikersnaam",
"form.integration.fever_password": "Fever wachtwoord",
"form.integration.fever_endpoint": "Fever URL:",
"form.integration.googlereader_activate": "Activeer Google Reader API",
"form.integration.googlereader_username": "Google Reader gebruikersnaam",
"form.integration.googlereader_password": "Google Reader wachtwoord",
"form.integration.googlereader_endpoint": "Google Reader URL:",
"form.integration.pinboard_activate": "Artikelen opslaan naar Pinboard",
"form.integration.pinboard_token": "Pinboard API token",
"form.integration.pinboard_tags": "Pinboard tags",
"form.integration.pinboard_bookmark": "Markeer bookmark als gelezen",
"form.integration.instapaper_activate": "Artikelen opstaan naar Instapaper",
"form.integration.instapaper_username": "Instapaper gebruikersnaam",
"form.integration.instapaper_password": "Instapaper wachtwoord",
"form.integration.pocket_activate": "Bewaar artikelen in Pocket",
"form.integration.pocket_consumer_key": "Pocket Consumer Key",
"form.integration.pocket_access_token": "Pocket Access Token",
"form.integration.pocket_connect_link": "Verbind je Pocket-account",
"form.integration.wallabag_activate": "Opslaan naar Wallabag",
"form.integration.wallabag_only_url": "Alleen URL verzenden (in plaats van volledige inhoud)",
"form.integration.wallabag_endpoint": "Wallabag URL",
"form.integration.wallabag_client_id": "Wallabag Client-ID",
"form.integration.wallabag_client_secret": "Wallabag Client-Secret",
"form.integration.wallabag_username": "Wallabag gebruikersnaam",
"form.integration.wallabag_password": "Wallabag wachtwoord",
"form.integration.notion_activate": "Save entries to Notion",
"form.integration.notion_page_id": "Notion Page ID",
"form.integration.notion_token": "Notion Secret Token",
"form.integration.apprise_activate": "Push entries to Apprise",
"form.integration.apprise_url": "Apprise API URL",
"form.integration.apprise_services_url": "Comma separated list of Apprise service URLs",
"form.integration.nunux_keeper_activate": "Opslaan naar Nunux Keeper",
"form.integration.nunux_keeper_endpoint": "Nunux Keeper URL",
"form.integration.nunux_keeper_api_key": "Nunux Keeper API-sleutel",
"form.integration.omnivore_activate": "Opslaan naar Omnivore",
"form.integration.omnivore_url": "Omnivore URL",
"form.integration.omnivore_api_key": "Omnivore API-sleutel",
"form.integration.espial_activate": "Opslaan naar Espial",
"form.integration.espial_endpoint": "Espial URL",
"form.integration.espial_api_key": "Espial API-sleutel",
"form.integration.espial_tags": "Espial tags",
"form.integration.readwise_activate": "Save entries to Readwise Reader",
"form.integration.readwise_api_key": "Readwise Reader Access Token",
"form.integration.readwise_api_key_link": "Get your Readwise Access Token",
"form.integration.telegram_bot_activate": "Push nieuwe artikelen naar Telegram-chat",
"form.integration.telegram_bot_token": "Bot token",
"form.integration.telegram_chat_id": "Chat ID",
"form.integration.telegram_topic_id": "Topic ID",
"form.integration.telegram_bot_disable_web_page_preview": "Disable web page preview",
"form.integration.telegram_bot_disable_notification": "Disable notification",
"form.integration.telegram_bot_disable_buttons": "Disable buttons",
"form.integration.linkding_activate": "Opslaan naar Linkding",
"form.integration.linkding_endpoint": "Linkding URL",
"form.integration.linkding_api_key": "Linkding API-sleutel",
"form.integration.linkding_tags": "Linkding Tags",
"form.integration.linkding_bookmark": "Markeer bookmark als gelezen",
"form.integration.matrix_bot_activate": "Nieuwe artikelen overbrengen naar Matrix",
"form.integration.matrix_bot_user": "Gebruikersnaam voor Matrix",
"form.integration.matrix_bot_password": "Wachtwoord voor Matrix-gebruiker",
"form.integration.matrix_bot_url": "URL van de Matrix-server",
"form.integration.matrix_bot_chat_id": "ID van Matrix-kamer",
"form.integration.shiori_activate": "Opslaan naar Shiori",
"form.integration.shiori_endpoint": "Shiori URL",
"form.integration.shiori_username": "Shiori gebruikersnaam",
"form.integration.shiori_password": "Shiori wachtwoord",
"form.integration.shaarli_activate": "Save articles to Shaarli",
"form.integration.shaarli_endpoint": "Shaarli URL",
"form.integration.shaarli_api_secret": "Shaarli API Secret",
"form.integration.webhook_activate": "Enable Webhook",
"form.integration.webhook_url": "Webhook URL",
"form.integration.webhook_secret": "Webhook Secret",
"form.integration.rssbridge_activate": "Check RSS-Bridge when adding subscriptions",
"form.integration.rssbridge_url": "RSS-Bridge server URL",
"form.api_key.label.description": "API-sleutellabel",
"form.submit.loading": "Laden...",
"form.submit.saving": "Opslaag...",
"time_elapsed.not_yet": "in de toekomst",
"time_elapsed.yesterday": "gisteren",
"time_elapsed.now": "minder dan een minuut geleden",
"time_elapsed.minutes": [
"%d minuut geleden",
"%d minuten geleden"
],
"time_elapsed.hours": [
"%d uur geleden",
"%d uur geleden"
],
"time_elapsed.days": [
"%d dag geleden",
"%d dagen geleden"
],
"time_elapsed.weeks": [
"%d week geleden",
"%d weken geleden"
],
"time_elapsed.months": [
"%d maand geleden",
"%d maanden geleden"
],
"time_elapsed.years": [
"%d jaar geleden",
"%d jaar geleden"
],
"alert.too_many_feeds_refresh": "You have triggered too many feed refreshes. Please wait 30 minutes before trying again.",
"alert.background_feed_refresh": "All feeds are being refreshed in the background. You can continue to use Miniflux while this process is running.",
"error.http_response_too_large": "The HTTP response is too large. You could increase the HTTP response size limit in the global settings (requires a server restart).",
"error.http_body_read": "Unable to read the HTTP body: %v.",
"error.http_empty_response_body": "The HTTP response body is empty.",
"error.http_empty_response": "The HTTP response is empty. Perhaps, this website is using a bot protection mechanism?",
"error.tls_error": "TLS error: %v. You could disable TLS verification in the feed settings if you would like.",
"error.network_operation": "Miniflux is not able to reach this website due to a network error: %v.",
"error.network_timeout": "This website is too slow and the request timed out: %v",
"error.http_client_error": "HTTP client error: %v.",
"error.http_not_authorized": "Access to this website is not authorized. It could be a bad username or password.",
"error.http_too_many_requests": "Miniflux generated too many requests to this website. Please, try again later or change the application configuration.",
"error.http_forbidden": "Access to this website is forbidden. Perhaps, this website has a bot protection mechanism?",
"error.http_resource_not_found": "The requested resource is not found. Please, verify the URL.",
"error.http_internal_server_error": "The website is not available at the moment due to a server error. The problem is not on Miniflux side. Please, try again later.",
"error.http_bad_gateway": "The website is not available at the moment due to a bad gateway error. The problem is not on Miniflux side. Please, try again later.",
"error.http_service_unavailable": "The website is not available at the moment due to an internal server error. The problem is not on Miniflux side. Please, try again later.",
"error.http_gateway_timeout": "The website is not available at the moment due to a gateway timeout error. The problem is not on Miniflux side. Please, try again later.",
"error.http_unexpected_status_code": "The website is not available at the moment due to an unexpected HTTP status code: %d. The problem is not on Miniflux side. Please, try again later.",
"error.database_error": "Database error: %v.",
"error.category_not_found": "This category does not exist or does not belong to this user.",
"error.duplicated_feed": "This feed already exists.",
"error.unable_to_parse_feed": "Unable to parse this feed: %v.",
"error.feed_not_found": "This feed does not exist or does not belong to this user.",
"error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.",
"error.feed_format_not_detected": "Unable to detect feed format: %v."
}
miniflux-2.0.51/internal/locale/translations/pl_PL.json 0000664 0000000 0000000 00000071706 14546226260 0023151 0 ustar 00root root 0000000 0000000 {
"confirm.question": "Czy jesteś pewny?",
"confirm.question.refresh": "Czy chcesz wymusić odświeżenie?",
"confirm.yes": "tak",
"confirm.no": "nie",
"confirm.loading": "W toku...",
"action.subscribe": "Subskrypcja",
"action.save": "Zapisz",
"action.or": "lub",
"action.cancel": "anuluj",
"action.remove": "Usuń",
"action.remove_feed": "Usuń ten kanał",
"action.update": "Zaktualizuj",
"action.edit": "Edytuj",
"action.download": "Pobierz",
"action.import": "Importuj",
"action.login": "Zaloguj się",
"action.home_screen": "Dodaj do ekranu głównego",
"tooltip.keyboard_shortcuts": "Skróty klawiszowe: %s",
"tooltip.logged_user": "Zalogowany jako %s",
"menu.unread": "Nieprzeczytane",
"menu.starred": "Ulubione",
"menu.history": "Historia",
"menu.feeds": "Kanały",
"menu.categories": "Kategorie",
"menu.settings": "Ustawienia",
"menu.logout": "Wyloguj się",
"menu.preferences": "Preferencje",
"menu.integrations": "Usługi",
"menu.sessions": "Sesje",
"menu.users": "Użytkownicy",
"menu.about": "O stronie",
"menu.export": "Eksportuj",
"menu.import": "Importuj",
"menu.create_category": "Utwórz kategorię",
"menu.mark_page_as_read": "Oznacz jako przeczytane",
"menu.mark_all_as_read": "Oznacz wszystko jako przeczytane",
"menu.show_all_entries": "Pokaż wszystkie artykuły",
"menu.show_only_unread_entries": "Pokaż tylko nieprzeczytane artykuły",
"menu.refresh_feed": "Odśwież",
"menu.refresh_all_feeds": "Odśwież wszystkie subskrypcje w tle",
"menu.edit_feed": "Edytuj",
"menu.edit_category": "Edytuj",
"menu.add_feed": "Dodaj subskrypcję",
"menu.add_user": "Dodaj użytkownika",
"menu.flush_history": "Usuń historię",
"menu.feed_entries": "Artykuły",
"menu.api_keys": "Klucze API",
"menu.create_api_key": "Utwórz nowy klucz API",
"menu.shared_entries": "Udostępnione wpisy",
"search.label": "Szukaj",
"search.placeholder": "Szukaj...",
"pagination.next": "Następny",
"pagination.previous": "Poprzedni",
"entry.status.unread": "Nieprzeczytane",
"entry.status.read": "Przeczytane",
"entry.status.toast.unread": "Oznaczone jako nieprzeczytane",
"entry.status.toast.read": "Oznaczone jako przeczytane",
"entry.status.title": "Zmień status artykułu",
"entry.bookmark.toggle.on": "Oznacz gwiazdką",
"entry.bookmark.toggle.off": "Usuń gwiazdkę",
"entry.bookmark.toast.on": "Oznaczone gwiazdką",
"entry.bookmark.toast.off": "Bez gwiazdek",
"entry.state.saving": "Zapisywanie...",
"entry.state.loading": "Ładowanie...",
"entry.save.label": "Zapisz",
"entry.save.title": "Zapisz ten artykuł",
"entry.save.completed": "Gotowe!",
"entry.save.toast.completed": "Artykuł zapisany",
"entry.scraper.label": "Ściągnij",
"entry.scraper.title": "Pobierz oryginalną treść",
"entry.scraper.completed": "Gotowe!",
"entry.external_link.label": "Link zewnętrzny",
"entry.comments.label": "Komentarze",
"entry.comments.title": "Zobacz komentarze",
"entry.share.label": "Podzielić się",
"entry.share.title": "Podzielić się ten artykuł",
"entry.unshare.label": "Unshare",
"entry.shared_entry.title": "Otwórz publiczny link",
"entry.shared_entry.label": "Udostępnianie",
"entry.estimated_reading_time": [
"%d minuta czytania",
"%d minut czytania"
],
"entry.tags.label": "Tagi:",
"page.shared_entries.title": "Udostępnione wpisy",
"page.unread.title": "Nieprzeczytane",
"page.starred.title": "Oznaczone gwiazdką",
"page.categories.title": "Kategorie",
"page.categories.no_feed": "Brak kanałów.",
"page.categories.entries": "Artykuły",
"page.categories.feeds": "Subskrypcje",
"page.categories.feed_count": [
"Jest %d kanał.",
"Są %d kanały.",
"Jest %d kanałów."
],
"page.categories.unread_counter": "Liczba nieprzeczytanych wpisów",
"page.new_category.title": "Nowa kategoria",
"page.new_user.title": "Nowy użytkownik",
"page.edit_category.title": "Edycja Kategorii: %s",
"page.edit_user.title": "Edytuj użytkownika: %s",
"page.feeds.title": "Kanały",
"page.feeds.last_check": "Ostatnia aktualizacja:",
"page.feeds.next_check": "Next check:",
"page.feeds.unread_counter": "Liczba nieprzeczytanych wpisów",
"page.feeds.read_counter": "Liczba przeczytanych wpisów",
"page.feeds.error_count": [
"%d błąd",
"%d błąd",
"%d błędów"
],
"page.history.title": "Historia",
"page.import.title": "Importuj",
"page.search.title": "Wyniki wyszukiwania",
"page.about.title": "O",
"page.about.credits": "Prawa autorskie",
"page.about.version": "Wersja:",
"page.about.build_date": "Data opracowania:",
"page.about.author": "Autor:",
"page.about.license": "Licencja:",
"page.about.postgres_version": "Postgres wersja:",
"page.about.go_version": "Go wersja:",
"page.about.global_config_options": "globalne opcje konfiguracji",
"page.add_feed.title": "Nowa subskrypcja",
"page.add_feed.no_category": "Nie ma żadnej kategorii. Musisz mieć co najmniej jedną kategorię.",
"page.add_feed.label.url": "URL",
"page.add_feed.submit": "Znajdź subskrypcję",
"page.add_feed.legend.advanced_options": "Zaawansowane opcje",
"page.add_feed.choose_feed": "Wybierz subskrypcję",
"page.edit_feed.title": "Edytuj kanał: %s",
"page.edit_feed.last_check": "Ostatnia aktualizacja:",
"page.edit_feed.last_modified_header": "Ostatnio zmienione:",
"page.edit_feed.etag_header": "Nagłówek ETag:",
"page.edit_feed.no_header": "Brak",
"page.edit_feed.last_parsing_error": "Ostatni błąd analizy",
"page.entry.attachments": "Załączniki",
"page.keyboard_shortcuts.title": "Skróty klawiszowe",
"page.keyboard_shortcuts.subtitle.sections": "Nawigacja między punktami menu",
"page.keyboard_shortcuts.subtitle.items": "Nawigacja między artykułami",
"page.keyboard_shortcuts.subtitle.pages": "Nawigacja między stronami",
"page.keyboard_shortcuts.subtitle.actions": "Działania",
"page.keyboard_shortcuts.go_to_unread": "Przejdź do nieprzeczytanych artykułów",
"page.keyboard_shortcuts.go_to_starred": "Przejdź do zakładek",
"page.keyboard_shortcuts.go_to_history": "Przejdź do historii",
"page.keyboard_shortcuts.go_to_feeds": "Przejdź do kanałów",
"page.keyboard_shortcuts.go_to_categories": "Przejdź do kategorii",
"page.keyboard_shortcuts.go_to_settings": "Przejdź do ustawień",
"page.keyboard_shortcuts.show_keyboard_shortcuts": "Pokaż listę skrótów klawiszowych",
"page.keyboard_shortcuts.go_to_previous_item": "Przejdź do poprzedniego artykułu",
"page.keyboard_shortcuts.go_to_next_item": "Przejdź do następnego punktu artykułu",
"page.keyboard_shortcuts.go_to_feed": "Przejdź do subskrypcji",
"page.keyboard_shortcuts.go_to_previous_page": "Przejdź do poprzedniej strony",
"page.keyboard_shortcuts.go_to_next_page": "Przejdź do następnej strony",
"page.keyboard_shortcuts.open_item": "Otwórz zaznaczony artykuł",
"page.keyboard_shortcuts.open_original": "Otwórz oryginalny artykuł",
"page.keyboard_shortcuts.open_original_same_window": "Otwórz oryginalny link w bieżącej karcie",
"page.keyboard_shortcuts.open_comments": "Otwórz link do komentarzy",
"page.keyboard_shortcuts.open_comments_same_window": "Otwórz link do komentarzy w bieżącej karcie",
"page.keyboard_shortcuts.toggle_read_status_next": "Oznacz jako przeczytane/nieprzeczytane, skup się dalej",
"page.keyboard_shortcuts.toggle_read_status_prev": "Oznacz jako przeczytane/nieprzeczytane, skup poprzednie",
"page.keyboard_shortcuts.refresh_all_feeds": "Odśwież wszystkie subskrypcje w tle",
"page.keyboard_shortcuts.mark_page_as_read": "Zaznacz aktualną stronę jako przeczytaną",
"page.keyboard_shortcuts.download_content": "Pobierz oryginalną zawartość",
"page.keyboard_shortcuts.toggle_bookmark_status": "Dodaj/usuń zakładki",
"page.keyboard_shortcuts.save_article": "Zapisz artykuł",
"page.keyboard_shortcuts.scroll_item_to_top": "Przewiń artykuł do góry",
"page.keyboard_shortcuts.remove_feed": "Usuń ten kanał",
"page.keyboard_shortcuts.go_to_search": "Ustaw fokus na formularzu wyszukiwania",
"page.keyboard_shortcuts.toggle_entry_attachments": "Toggle open/close entry attachments",
"page.keyboard_shortcuts.close_modal": "Zamknij listę skrótów klawiszowych",
"page.users.title": "Użytkownicy",
"page.users.username": "Nazwa użytkownika",
"page.users.never_logged": "Nigdy",
"page.users.admin.yes": "Tak",
"page.users.admin.no": "Nie",
"page.users.actions": "Działania",
"page.users.last_login": "Ostatnie logowanie",
"page.users.is_admin": "Administrator",
"page.settings.title": "Ustawienia",
"page.settings.link_google_account": "Połącz z moim kontem Google",
"page.settings.unlink_google_account": "Odłącz moje konto Google",
"page.settings.link_oidc_account": "Połącz z moim kontem OpenID Connect",
"page.settings.unlink_oidc_account": "Odłącz moje konto OpenID Connect",
"page.settings.webauthn.passkeys": "Passkeys",
"page.settings.webauthn.actions": "Actions",
"page.settings.webauthn.passkey_name": "Passkey Name",
"page.settings.webauthn.added_on": "Added On",
"page.settings.webauthn.last_seen_on": "Last Used",
"page.settings.webauthn.register": "Zarejestruj klucz dostępu",
"page.settings.webauthn.register.error": "Nie można zarejestrować klucza dostępu",
"page.settings.webauthn.delete": [
"Usuń %d klucz dostępu",
"Usuń %d klucze dostępu",
"Usuń %d klucze dostępu"
],
"page.login.title": "Zaloguj się",
"page.login.google_signin": "Zaloguj przez Google",
"page.login.oidc_signin": "Zaloguj przez OpenID Connect",
"page.login.webauthn_login": "Zaloguj się za pomocą hasła",
"page.login.webauthn_login.error": "Nie można zalogować się za pomocą klucza dostępu",
"page.integrations.title": "Usługi",
"page.integration.miniflux_api": "Miniflux API",
"page.integration.miniflux_api_endpoint": "Punkt końcowy API",
"page.integration.miniflux_api_username": "Nazwa Użytkownika",
"page.integration.miniflux_api_password": "Hasło",
"page.integration.miniflux_api_password_value": "Hasło konta",
"page.integration.bookmarklet": "Bookmarklet",
"page.integration.bookmarklet.name": "Dodaj do Miniflux",
"page.integration.bookmarklet.instructions": "Przeciągnij i upuść to łącze do zakładek.",
"page.integration.bookmarklet.help": "Ten link umożliwia subskrypcję strony internetowej bezpośrednio za pomocą zakładki w przeglądarce internetowej.",
"page.sessions.title": "Sesje",
"page.sessions.table.date": "Data",
"page.sessions.table.ip": "Adres IP",
"page.sessions.table.user_agent": "Agent użytkownika",
"page.sessions.table.actions": "Działania",
"page.sessions.table.current_session": "Bieżąca sesja",
"page.api_keys.title": "Klucze API",
"page.api_keys.table.description": "Opis",
"page.api_keys.table.token": "Znak",
"page.api_keys.table.last_used_at": "Ostatnio używane",
"page.api_keys.table.created_at": "Data utworzenia",
"page.api_keys.table.actions": "Działania",
"page.api_keys.never_used": "Nigdy nie używany",
"page.new_api_key.title": "Nowy klucz API",
"page.offline.title": "Tryb offline",
"page.offline.message": "Jesteś odłączony od sieci",
"page.offline.refresh_page": "Spróbuj odświeżyć stronę",
"page.webauthn_rename.title": "Rename Passkey",
"alert.no_shared_entry": "Brak wspólnego wpisu.",
"alert.no_bookmark": "Obecnie nie ma żadnych zakładek.",
"alert.no_category": "Nie ma żadnej kategorii!",
"alert.no_category_entry": "W tej kategorii nie ma żadnych artykułów",
"alert.no_feed_entry": "Nie ma artykułu dla tego kanału.",
"alert.no_feed": "Nie masz żadnej subskrypcji.",
"alert.no_feed_in_category": "Nie ma subskrypcji dla tej kategorii.",
"alert.no_history": "Obecnie nie ma żadnej historii.",
"alert.feed_error": "Z tym kanałem jest problem",
"alert.no_search_result": "Brak wyników dla tego wyszukiwania.",
"alert.no_unread_entry": "Nie ma żadnych nieprzeczytanych artykułów.",
"alert.no_user": "Jesteś jedynym użytkownikiem.",
"alert.account_unlinked": "Twoje konto zewnętrzne jest teraz zdysocjowane!",
"alert.account_linked": "Twoje konto zewnętrzne jest teraz połączone!",
"alert.pocket_linked": "Twoje konto Pocket jest teraz połączone!",
"alert.prefs_saved": "Ustawienia zapisane!",
"error.unlink_account_without_password": "Musisz zdefiniować hasło, inaczej nie będziesz mógł się ponownie zalogować.",
"error.duplicate_linked_account": "Już ktoś jest powiązany z tym dostawcą!",
"error.duplicate_fever_username": "Już ktoś inny używa tej nazwy użytkownika Fever!",
"error.duplicate_googlereader_username": "Już ktoś inny używa tej nazwy użytkownika Google Reader!",
"error.pocket_request_token": "Nie można pobrać tokena żądania z Pocket!",
"error.pocket_access_token": "Nie można pobrać tokena dostępu z Pocket!",
"error.category_already_exists": "Ta kategoria już istnieje.",
"error.unable_to_create_category": "Ta kategoria nie mogła zostać utworzona.",
"error.unable_to_update_category": "Ta kategoria nie mogła zostać zaktualizowana.",
"error.user_already_exists": "Ten użytkownik już istnieje.",
"error.unable_to_create_user": "Nie można utworzyć tego użytkownika.",
"error.unable_to_update_user": "Nie można zaktualizować tego użytkownika.",
"error.unable_to_update_feed": "Nie można zaktualizować tego kanału.",
"error.subscription_not_found": "Nie znaleziono żadnych subskrypcji.",
"error.empty_file": "Ten plik jest pusty.",
"error.bad_credentials": "Nieprawidłowa nazwa użytkownika lub hasło.",
"error.fields_mandatory": "Wszystkie pola są obowiązkowe.",
"error.title_required": "Tytuł jest obowiązkowy.",
"error.different_passwords": "Hasła nie są identyczne.",
"error.password_min_length": "Musisz użyć co najmniej 6 znaków.",
"error.settings_mandatory_fields": "Pola nazwy użytkownika, tematu, języka i strefy czasowej są obowiązkowe.",
"error.settings_reading_speed_is_positive": "Prędkości odczytu muszą być dodatnimi liczbami całkowitymi.",
"error.entries_per_page_invalid": "Liczba wpisów na stronę jest nieprawidłowa.",
"error.feed_mandatory_fields": "URL i kategoria są obowiązkowe.",
"error.feed_already_exists": "Ten kanał już istnieje.",
"error.invalid_feed_url": "Nieprawidłowy adres URL kanału.",
"error.invalid_site_url": "Nieprawidłowy adres URL witryny.",
"error.feed_url_not_empty": "Adres URL kanału nie może być pusty.",
"error.site_url_not_empty": "Adres URL witryny nie może być pusty.",
"error.feed_title_not_empty": "Tytuł kanału nie może być pusty.",
"error.feed_category_not_found": "Ta kategoria nie istnieje lub nie należy do tego użytkownika.",
"error.feed_invalid_blocklist_rule": "Reguła listy zablokowanych jest nieprawidłowa.",
"error.feed_invalid_keeplist_rule": "Reguła listy zachowania jest nieprawidłowa.",
"error.user_mandatory_fields": "Nazwa użytkownika jest obowiązkowa.",
"error.api_key_already_exists": "Deze API-sleutel bestaat al.",
"error.unable_to_create_api_key": "Nie można utworzyć tego klucza API.",
"error.invalid_theme": "Nieprawidłowy motyw.",
"error.invalid_language": "Nieprawidłowy język.",
"error.invalid_timezone": "Nieprawidłowa strefa czasowa.",
"error.invalid_entry_direction": "Nieprawidłowa kolejność sortowania.",
"error.invalid_display_mode": "Nieprawidłowy tryb wyświetlania aplikacji internetowej.",
"error.invalid_gesture_nav": "Nieprawidłowa nawigacja gestami.",
"error.invalid_default_home_page": "Nieprawidłowa domyślna strona główna!",
"form.feed.label.title": "Tytuł",
"form.feed.label.site_url": "URL strony",
"form.feed.label.feed_url": "URL kanału",
"form.feed.label.category": "Kategoria",
"form.feed.label.crawler": "Pobierz oryginalną treść",
"form.feed.label.feed_username": "Subskrypcję nazwa użytkownika",
"form.feed.label.feed_password": "Subskrypcję Hasło",
"form.feed.label.user_agent": "Zastąp domyślny agent użytkownika",
"form.feed.label.cookie": "Ustawianie ciasteczek",
"form.feed.label.scraper_rules": "Zasady ekstrakcji",
"form.feed.label.rewrite_rules": "Reguły zapisu",
"form.feed.label.blocklist_rules": "Zasady blokowania",
"form.feed.label.keeplist_rules": "Zasady zezwoleń",
"form.feed.label.urlrewrite_rules": "Zasady przepisywania adresów URL",
"form.feed.label.apprise_service_urls": "Comma separated list of Apprise service URLs",
"form.feed.label.ignore_http_cache": "Zignoruj pamięć podręczną HTTP",
"form.feed.label.allow_self_signed_certificates": "Zezwalaj na certyfikaty z podpisem własnym lub nieprawidłowe certyfikaty",
"form.feed.label.fetch_via_proxy": "Pobierz przez proxy",
"form.feed.label.disabled": "Nie odświeżaj tego kanału",
"form.feed.label.no_media_player": "No media player (audio/video)",
"form.feed.label.hide_globally": "Ukryj wpisy na globalnej liście nieprzeczytanych",
"form.feed.fieldset.general": "General",
"form.feed.fieldset.rules": "Rules",
"form.feed.fieldset.network_settings": "Network Settings",
"form.feed.fieldset.integration": "Third-Party Services",
"form.category.label.title": "Tytuł",
"form.category.hide_globally": "Ukryj wpisy na globalnej liście nieprzeczytanych",
"form.user.label.username": "Nazwa użytkownika",
"form.user.label.password": "Hasło",
"form.user.label.confirmation": "Potwierdzenie hasła",
"form.user.label.admin": "Administrator",
"form.prefs.label.language": "Język",
"form.prefs.label.timezone": "Strefa czasowa",
"form.prefs.label.theme": "Wygląd",
"form.prefs.label.entry_sorting": "Sortowanie artykułów",
"form.prefs.label.entries_per_page": "Wpisy na stronie",
"form.prefs.label.default_reading_speed": "Tryb wyświetlania Progressive Web App (PWA).",
"form.prefs.label.cjk_reading_speed": "Prędkość czytania dla języka chińskiego, koreańskiego i japońskiego (znaki na minutę)",
"form.prefs.label.display_mode": "Tryb wyświetlania aplikacji internetowej (wymaga ponownej instalacji)",
"form.prefs.select.older_first": "Najstarsze wpisy jako pierwsze",
"form.prefs.label.keyboard_shortcuts": "Włącz skróty klawiaturowe",
"form.prefs.label.entry_swipe": "Włącz machnięcie wpisu na ekranach dotykowych",
"form.prefs.label.gesture_nav": "Gest, aby poruszać się między wpisami",
"form.prefs.label.show_reading_time": "Pokaż szacowany czas czytania artykułów",
"form.prefs.select.recent_first": "Najnowsze wpisy jako pierwsze",
"form.prefs.select.fullscreen": "Pełny ekran",
"form.prefs.select.standalone": "Samodzielny",
"form.prefs.select.minimal_ui": "Minimalny",
"form.prefs.select.browser": "Przeglądarka",
"form.prefs.select.publish_time": "Czas publikacji wpisu",
"form.prefs.select.created_time": "Czas utworzenia wpisu",
"form.prefs.select.alphabetical": "Alfabetycznie",
"form.prefs.select.unread_count": "Liczba nieprzeczytanych",
"form.prefs.select.none": "Nic",
"form.prefs.select.tap": "Podwójne wciśnięcie",
"form.prefs.select.swipe": "Trzepnąć",
"form.prefs.label.custom_css": "Niestandardowy CSS",
"form.prefs.label.entry_order": "Kolumna sortowania wpisów",
"form.prefs.label.default_home_page": "Domyślna strona główna",
"form.prefs.label.categories_sorting_order": "Sortowanie kategorii",
"form.prefs.label.mark_read_on_view": "Automatycznie oznaczaj wpisy jako przeczytane podczas przeglądania",
"form.prefs.fieldset.application_settings": "Application Settings",
"form.prefs.fieldset.authentication_settings": "Authentication Settings",
"form.prefs.fieldset.reader_settings": "Reader Settings",
"form.import.label.file": "Plik OPML",
"form.import.label.url": "URL",
"form.integration.fever_activate": "Aktywuj Fever API",
"form.integration.fever_username": "Login do Fever",
"form.integration.fever_password": "Hasło do Fever",
"form.integration.fever_endpoint": "Punkt końcowy API gorączka:",
"form.integration.googlereader_activate": "Aktywuj Google Reader API",
"form.integration.googlereader_username": "Login do Google Reader",
"form.integration.googlereader_password": "Hasło do Google Reader",
"form.integration.googlereader_endpoint": "Punkt końcowy API gorączka:",
"form.integration.pinboard_activate": "Zapisz artykuł w Pinboard",
"form.integration.pinboard_token": "Token Pinboard API",
"form.integration.pinboard_tags": "Pinboard Tags",
"form.integration.pinboard_bookmark": "Zaznacz zakładkę jako nieprzeczytaną",
"form.integration.instapaper_activate": "Zapisz artykuł w Instapaper",
"form.integration.instapaper_username": "Login do Instapaper",
"form.integration.instapaper_password": "Hasło do Instapaper",
"form.integration.pocket_activate": "Zapisz artykuły w Pocket",
"form.integration.pocket_consumer_key": "Pocket Consumer Key",
"form.integration.pocket_access_token": "Token dostępu kieszeń",
"form.integration.pocket_connect_link": "Połącz swoje konto Pocket",
"form.integration.wallabag_activate": "Zapisz artykuły do Wallabag",
"form.integration.wallabag_only_url": "Wyślij tylko adres URL (zamiast pełnej treści)",
"form.integration.wallabag_endpoint": "Wallabag URL",
"form.integration.wallabag_client_id": "Wallabag Client-ID",
"form.integration.wallabag_client_secret": "Wallabag Client Secret",
"form.integration.wallabag_username": "Login do Wallabag",
"form.integration.wallabag_password": "Hasło do Wallabag",
"form.integration.notion_activate": "Save entries to Notion",
"form.integration.notion_page_id": "Notion Page ID",
"form.integration.notion_token": "Notion Secret Token",
"form.integration.apprise_activate": "Push entries to Apprise",
"form.integration.apprise_url": "Apprise API URL",
"form.integration.apprise_services_url": "Comma separated list of Apprise service URLs",
"form.integration.nunux_keeper_activate": "Zapisz artykuly do Nunux Keeper",
"form.integration.nunux_keeper_endpoint": "Nunux Keeper URL",
"form.integration.nunux_keeper_api_key": "Nunux Keeper API key",
"form.integration.omnivore_activate": "Zapisz artykuly do Omnivore",
"form.integration.omnivore_url": "Omnivore URL",
"form.integration.omnivore_api_key": "Omnivore API key",
"form.integration.espial_activate": "Zapisz artykuly do Espial",
"form.integration.espial_endpoint": "Espial URL",
"form.integration.espial_api_key": "Espial API key",
"form.integration.espial_tags": "Espial Tags",
"form.integration.readwise_activate": "Save entries to Readwise Reader",
"form.integration.readwise_api_key": "Readwise Reader Access Token",
"form.integration.readwise_api_key_link": "Get your Readwise Access Token",
"form.integration.telegram_bot_activate": "Przesyłaj nowe artykuły do czatu Telegram",
"form.integration.telegram_bot_token": "Token bota",
"form.integration.telegram_chat_id": "Identyfikator czatu",
"form.integration.telegram_topic_id": "Topic ID",
"form.integration.telegram_bot_disable_web_page_preview": "Disable web page preview",
"form.integration.telegram_bot_disable_notification": "Disable notification",
"form.integration.telegram_bot_disable_buttons": "Disable buttons",
"form.integration.linkding_activate": "Zapisz artykuły do Linkding",
"form.integration.linkding_endpoint": "Linkding URL",
"form.integration.linkding_api_key": "Linkding API key",
"form.integration.linkding_tags": "Linkding Tags",
"form.integration.linkding_bookmark": "Zaznacz zakładkę jako nieprzeczytaną",
"form.integration.matrix_bot_activate": "Przenieś nowe artykuły do Matrix",
"form.integration.matrix_bot_user": "Nazwa użytkownika dla Matrix",
"form.integration.matrix_bot_password": "Hasło dla użytkownika Matrix",
"form.integration.matrix_bot_url": "URL serwera Matrix",
"form.integration.matrix_bot_chat_id": "Identyfikator pokoju Matrix",
"form.integration.shiori_activate": "Zapisz artykuły do Shiori",
"form.integration.shiori_endpoint": "Shiori URL",
"form.integration.shiori_username": "Login do Shiori",
"form.integration.shiori_password": "Hasło do Shiori",
"form.integration.shaarli_activate": "Save articles to Shaarli",
"form.integration.shaarli_endpoint": "Shaarli URL",
"form.integration.shaarli_api_secret": "Shaarli API Secret",
"form.integration.webhook_activate": "Enable Webhook",
"form.integration.webhook_url": "Webhook URL",
"form.integration.webhook_secret": "Webhook Secret",
"form.integration.rssbridge_activate": "Check RSS-Bridge when adding subscriptions",
"form.integration.rssbridge_url": "RSS-Bridge server URL",
"form.api_key.label.description": "Etykieta klucza API",
"form.submit.loading": "Ładowanie...",
"form.submit.saving": "Zapisywanie...",
"time_elapsed.not_yet": "jeszcze nie",
"time_elapsed.yesterday": "wczoraj",
"time_elapsed.now": "przed chwilą",
"time_elapsed.minutes": [
"%d minuta temu",
"%d minuty temu",
"%d minut temu"
],
"time_elapsed.hours": [
"%d godzinę temu",
"%d godziny temu",
"%d godzin temu"
],
"time_elapsed.days": [
"%d dzień temu",
"%d dni temu",
"%d dni temu"
],
"time_elapsed.weeks": [
"%d tydzień temu",
"%d tygodni temu",
"%d tygodni temu"
],
"time_elapsed.months": [
"%d miesiąc temu",
"%d miesięcy temu",
"%d miesięcy temu"
],
"time_elapsed.years": [
"%d rok temu",
"%d lat temu",
"%d lat temu"
],
"alert.too_many_feeds_refresh": "You have triggered too many feed refreshes. Please wait 30 minutes before trying again.",
"alert.background_feed_refresh": "All feeds are being refreshed in the background. You can continue to use Miniflux while this process is running.",
"error.http_response_too_large": "The HTTP response is too large. You could increase the HTTP response size limit in the global settings (requires a server restart).",
"error.http_body_read": "Unable to read the HTTP body: %v.",
"error.http_empty_response_body": "The HTTP response body is empty.",
"error.http_empty_response": "The HTTP response is empty. Perhaps, this website is using a bot protection mechanism?",
"error.tls_error": "TLS error: %v. You could disable TLS verification in the feed settings if you would like.",
"error.network_operation": "Miniflux is not able to reach this website due to a network error: %v.",
"error.network_timeout": "This website is too slow and the request timed out: %v",
"error.http_client_error": "HTTP client error: %v.",
"error.http_not_authorized": "Access to this website is not authorized. It could be a bad username or password.",
"error.http_too_many_requests": "Miniflux generated too many requests to this website. Please, try again later or change the application configuration.",
"error.http_forbidden": "Access to this website is forbidden. Perhaps, this website has a bot protection mechanism?",
"error.http_resource_not_found": "The requested resource is not found. Please, verify the URL.",
"error.http_internal_server_error": "The website is not available at the moment due to a server error. The problem is not on Miniflux side. Please, try again later.",
"error.http_bad_gateway": "The website is not available at the moment due to a bad gateway error. The problem is not on Miniflux side. Please, try again later.",
"error.http_service_unavailable": "The website is not available at the moment due to an internal server error. The problem is not on Miniflux side. Please, try again later.",
"error.http_gateway_timeout": "The website is not available at the moment due to a gateway timeout error. The problem is not on Miniflux side. Please, try again later.",
"error.http_unexpected_status_code": "The website is not available at the moment due to an unexpected HTTP status code: %d. The problem is not on Miniflux side. Please, try again later.",
"error.database_error": "Database error: %v.",
"error.category_not_found": "This category does not exist or does not belong to this user.",
"error.duplicated_feed": "This feed already exists.",
"error.unable_to_parse_feed": "Unable to parse this feed: %v.",
"error.feed_not_found": "This feed does not exist or does not belong to this user.",
"error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.",
"error.feed_format_not_detected": "Unable to detect feed format: %v."
}
miniflux-2.0.51/internal/locale/translations/pt_BR.json 0000664 0000000 0000000 00000070723 14546226260 0023147 0 ustar 00root root 0000000 0000000 {
"confirm.question": "Tem certeza?",
"confirm.question.refresh": "Você deseja forçar a atualização?",
"confirm.yes": "Sim",
"confirm.no": "Não",
"confirm.loading": "Carregando...",
"action.subscribe": "Inscrever",
"action.save": "Salvar",
"action.or": "Ou",
"action.cancel": "Cancelar",
"action.remove": "Remover",
"action.remove_feed": "Remover fonte",
"action.update": "Atualizar",
"action.edit": "Editar",
"action.download": "Baixar",
"action.import": "Importar",
"action.login": "Iniciar sessão",
"action.home_screen": "Voltar para a tela inicial",
"tooltip.keyboard_shortcuts": "Atalho do teclado: %s",
"tooltip.logged_user": "Autenticado como %s",
"menu.unread": "Não lido",
"menu.starred": "Favoritos",
"menu.history": "Histórico",
"menu.feeds": "Fontes",
"menu.categories": "Categorias",
"menu.settings": "Configurações",
"menu.logout": "Encerrar sessão",
"menu.preferences": "Preferências",
"menu.integrations": "Integrações",
"menu.sessions": "Sessões",
"menu.users": "Usuários",
"menu.about": "Sobre",
"menu.export": "Exportar",
"menu.import": "Importar",
"menu.create_category": "Criar uma categoria",
"menu.mark_page_as_read": "Marcar essa página como lida",
"menu.mark_all_as_read": "Marcar todos como lido",
"menu.show_all_entries": "Mostrar todas os itens",
"menu.show_only_unread_entries": "Mostrar apenas itens não lidos",
"menu.refresh_feed": "Atualizar",
"menu.refresh_all_feeds": "Atualizar todas as fontes",
"menu.edit_feed": "Editar",
"menu.edit_category": "Editar",
"menu.add_feed": "Adicionar inscrição",
"menu.add_user": "Adicionar usuário",
"menu.flush_history": "Limpar histórico",
"menu.feed_entries": "Itens",
"menu.api_keys": "Chaves de API",
"menu.create_api_key": "Criar uma nova chave de API",
"menu.shared_entries": "Itens compartilhados",
"search.label": "Buscar",
"search.placeholder": "Buscar por...",
"pagination.next": "Próximo",
"pagination.previous": "Anterior",
"entry.status.unread": "Não lido",
"entry.status.read": "Lido",
"entry.status.toast.unread": "Marcado como não lido",
"entry.status.toast.read": "Marcado como lido",
"entry.status.title": "Modificar estado deste item",
"entry.bookmark.toggle.on": "Favoritar",
"entry.bookmark.toggle.off": "Remover dos Favoritos",
"entry.bookmark.toast.on": "Favoritado",
"entry.bookmark.toast.off": "Desfavoritado",
"entry.state.saving": "Salvando...",
"entry.state.loading": "Carregando...",
"entry.save.label": "Salvar",
"entry.save.title": "Salvar esse item",
"entry.save.completed": "Feito!",
"entry.save.toast.completed": "Item guardado",
"entry.scraper.label": "Baixar",
"entry.scraper.title": "Obter conteúdo completo",
"entry.scraper.completed": "Feito!",
"entry.external_link.label": "Link externo",
"entry.comments.label": "Comentários",
"entry.comments.title": "Ver comentários",
"entry.share.label": "Compartilhar",
"entry.share.title": "Compartilhar esse item",
"entry.unshare.label": "Descompartilhar",
"entry.shared_entry.title": "Abrir link público",
"entry.shared_entry.label": "Compartilhar",
"entry.estimated_reading_time": [
"Leitura de %d minuto",
"Leitura de %d minutos"
],
"entry.tags.label": "Etiquetas:",
"page.shared_entries.title": "Itens compartilhados",
"page.unread.title": "Não lidos",
"page.starred.title": "Favoritos",
"page.categories.title": "Categorias",
"page.categories.no_feed": "Sem fonte.",
"page.categories.entries": "Itens",
"page.categories.feeds": "Inscrições",
"page.categories.feed_count": [
"Existe %d fonte.",
"Existem %d fontes."
],
"page.categories.unread_counter": "Numero de itens não lidos",
"page.new_category.title": "Nova categoria",
"page.new_user.title": "Novo usuário",
"page.edit_category.title": "Editar categoria: %s",
"page.edit_user.title": "Editar usuário: %s",
"page.feeds.title": "Fontes",
"page.feeds.last_check": "Última verificação:",
"page.feeds.next_check": "Next check:",
"page.feeds.unread_counter": "Numero de itens não lidos",
"page.feeds.read_counter": "Número de itens lidos",
"page.feeds.error_count": [
"%d erro",
"%d erros"
],
"page.history.title": "Histórico",
"page.import.title": "Importar",
"page.search.title": "Resultados da busca",
"page.about.title": "Sobre",
"page.about.credits": "Créditos",
"page.about.version": "Versão:",
"page.about.build_date": "Compilado em:",
"page.about.author": "Autor:",
"page.about.license": "Licença:",
"page.about.global_config_options": "opções de configuração global",
"page.about.postgres_version": "Postgres versão:",
"page.about.go_version": "Go versão:",
"page.add_feed.title": "Nova inscrição",
"page.add_feed.no_category": "Não existe uma categoria. Deve existir pelo menos uma categoria.",
"page.add_feed.label.url": "URL",
"page.add_feed.submit": "Buscar uma fonte",
"page.add_feed.legend.advanced_options": "Opções avançadas",
"page.add_feed.choose_feed": "Escolher uma fonte",
"page.edit_feed.title": "Editar fonte: %s",
"page.edit_feed.last_check": "Última verificação:",
"page.edit_feed.last_modified_header": "Cabeçalho 'LastModified':",
"page.edit_feed.etag_header": "Cabeçalho 'ETag':",
"page.edit_feed.no_header": "Sem cabeçalhos",
"page.edit_feed.last_parsing_error": "Último erro durante processamento",
"page.entry.attachments": "Anexos",
"page.keyboard_shortcuts.title": "Atalhos de teclado",
"page.keyboard_shortcuts.subtitle.sections": "Navegação de seções",
"page.keyboard_shortcuts.subtitle.items": "Navegação de itens",
"page.keyboard_shortcuts.subtitle.pages": "Navegação de páginas",
"page.keyboard_shortcuts.subtitle.actions": "Ações",
"page.keyboard_shortcuts.go_to_unread": "Ir aos não lidos",
"page.keyboard_shortcuts.go_to_starred": "Ir aos favoritos",
"page.keyboard_shortcuts.go_to_history": "Ir ao histórico",
"page.keyboard_shortcuts.go_to_feeds": "Ir as inscrições",
"page.keyboard_shortcuts.go_to_categories": "Ir as categorias",
"page.keyboard_shortcuts.go_to_settings": "Ir as configurações",
"page.keyboard_shortcuts.show_keyboard_shortcuts": "Mostrar atalhos de teclado",
"page.keyboard_shortcuts.go_to_previous_item": "Ir ao item anterior",
"page.keyboard_shortcuts.go_to_next_item": "Ir ao tem seguinte",
"page.keyboard_shortcuts.go_to_feed": "Ir a fonte",
"page.keyboard_shortcuts.go_to_previous_page": "Ir a página anterior",
"page.keyboard_shortcuts.go_to_next_page": "Ir a página seguinte",
"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.open_comments": "Abrir os comentários",
"page.keyboard_shortcuts.open_comments_same_window": "Abrir os comentários na janela atual",
"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.keyboard_shortcuts.refresh_all_feeds": "Atualizar todas as fontes",
"page.keyboard_shortcuts.mark_page_as_read": "Marcar página atual como lida",
"page.keyboard_shortcuts.download_content": "Buscar o conteúdo original",
"page.keyboard_shortcuts.toggle_bookmark_status": "Marcar ou desmarcar como favorito",
"page.keyboard_shortcuts.save_article": "Salvar item",
"page.keyboard_shortcuts.scroll_item_to_top": "Role o item para cima",
"page.keyboard_shortcuts.remove_feed": "Remover essa fonte",
"page.keyboard_shortcuts.go_to_search": "Ir para o campo de busca",
"page.keyboard_shortcuts.toggle_entry_attachments": "Toggle open/close entry attachments",
"page.keyboard_shortcuts.close_modal": "Fechar janela",
"page.users.title": "Usuários",
"page.users.username": "Nome de usuário",
"page.users.never_logged": "Nunca",
"page.users.admin.yes": "Sim",
"page.users.admin.no": "Não",
"page.users.actions": "Ações",
"page.users.last_login": "Último acesso",
"page.users.is_admin": "Administrador",
"page.settings.title": "Ajustes",
"page.settings.link_google_account": "Vincular minha conta do Google",
"page.settings.unlink_google_account": "Desvincular minha conta do Google",
"page.settings.link_oidc_account": "Vincular minha conta do OpenID Connect",
"page.settings.unlink_oidc_account": "Desvincular minha conta do OpenID Connect",
"page.settings.webauthn.passkeys": "Passkeys",
"page.settings.webauthn.actions": "Actions",
"page.settings.webauthn.passkey_name": "Passkey Name",
"page.settings.webauthn.added_on": "Added On",
"page.settings.webauthn.last_seen_on": "Last Used",
"page.settings.webauthn.register": "Registrar senha",
"page.settings.webauthn.register.error": "Não foi possível registrar a senha",
"page.settings.webauthn.delete": [
"Remover %d senha",
"Remover %d senhas"
],
"page.login.title": "Iniciar Sessão",
"page.login.google_signin": "Iniciar Sessão com sua conta do Google",
"page.login.oidc_signin": "Iniciar Sessão com sua conta do OpenID Connect",
"page.login.webauthn_login": "Entrar com senha",
"page.login.webauthn_login.error": "Não é possível fazer login com senha",
"page.integrations.title": "Integrações",
"page.integration.miniflux_api": "API do Miniflux",
"page.integration.miniflux_api_endpoint": "Endpoint da API",
"page.integration.miniflux_api_username": "Nome de usuário",
"page.integration.miniflux_api_password": "Senha",
"page.integration.miniflux_api_password_value": "Senha da sua Conta",
"page.integration.bookmarklet": "Bookmarklet",
"page.integration.bookmarklet.name": "Adicionar ao Miniflux",
"page.integration.bookmarklet.instructions": "Arrasta e solta esse link para os favoritos do teu navegador.",
"page.integration.bookmarklet.help": "Esse link especial permite você se inscrever a um site diretamente usando favorito do navegador.",
"page.sessions.title": "Sessões",
"page.sessions.table.date": "Data",
"page.sessions.table.ip": "Endereço IP",
"page.sessions.table.user_agent": "Agente de usuário",
"page.sessions.table.actions": "Ações",
"page.sessions.table.current_session": "Sessão Atual",
"page.api_keys.title": "Chaves de API",
"page.api_keys.table.description": "Descrição",
"page.api_keys.table.token": "Token",
"page.api_keys.table.last_used_at": "Ultima utilização",
"page.api_keys.table.created_at": "Data de criação",
"page.api_keys.table.actions": "Ações",
"page.api_keys.never_used": "Nunca usado",
"page.new_api_key.title": "Nova chave de API",
"page.offline.title": "Modo offline",
"page.offline.message": "Você está offline",
"page.offline.refresh_page": "Tente atualizar a página",
"page.webauthn_rename.title": "Rename Passkey",
"alert.no_shared_entry": "Não há itens compartilhados.",
"alert.no_bookmark": "Não há favorito neste momento.",
"alert.no_category": "Não há categoria.",
"alert.no_category_entry": "Não há itens nesta categoria.",
"alert.no_feed_entry": "Não há itens nessa fonte.",
"alert.no_feed": "Não há inscrições.",
"alert.no_feed_in_category": "Não há inscrições nessa categoria.",
"alert.no_history": "Não há histórico nesse momento.",
"alert.feed_error": "Ocorreu um problema com esta fonte.",
"alert.no_search_result": "Não há resultados para essa busca.",
"alert.no_unread_entry": "Não há itens não lidos.",
"alert.no_user": "Você é o único usuário.",
"alert.account_unlinked": "Sua conta externa está desvinculada!",
"alert.account_linked": "Sua conta externa está vinculada!",
"alert.pocket_linked": "Sua conta do Pocket está vinculada!",
"alert.prefs_saved": "Suas preferências foram salvas!",
"error.unlink_account_without_password": "Você deve definir uma senha, senão não será possível efetuar a sessão novamente.",
"error.duplicate_linked_account": "Alguém já está vinculado a esse serviço!",
"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.pocket_request_token": "Não foi possível obter um pedido de token no Pocket!",
"error.pocket_access_token": "Não foi possível obter um token de acesso no Pocket!",
"error.category_already_exists": "Esta categoria já existe.",
"error.unable_to_create_category": "Não foi possível criar essa categoria.",
"error.unable_to_update_category": "Não foi possível atualizar essa categoria.",
"error.user_already_exists": "Esse usuário já existe.",
"error.unable_to_create_user": "Não foi possível criar esse usuário.",
"error.unable_to_update_user": "Não foi possível atualizar esse usuário.",
"error.unable_to_update_feed": "Não foi possível atualizar essa fonte.",
"error.subscription_not_found": "Não foi possível encontrar uma inscrição.",
"error.empty_file": "Esse arquivo está vazio.",
"error.bad_credentials": "Usuário ou senha são inválidos.",
"error.fields_mandatory": "Todos os campos são obrigatórios.",
"error.title_required": "O título é obrigatório.",
"error.different_passwords": "As senhas não são iguais.",
"error.password_min_length": "A senha deve ter no mínimo 6 caracteres.",
"error.settings_mandatory_fields": "Os campos de nome de usuário, tema, idioma e fuso horário são obrigatórios.",
"error.settings_reading_speed_is_positive": "As velocidades de leitura devem ser inteiros positivos.",
"error.entries_per_page_invalid": "O número de itens por página é inválido.",
"error.feed_mandatory_fields": "O campo de URL e categoria são obrigatórios.",
"error.feed_already_exists": "Este feed já existe.",
"error.invalid_feed_url": "URL de feed inválido.",
"error.invalid_site_url": "URL de site inválido.",
"error.feed_url_not_empty": "O URL do feed não pode estar vazio.",
"error.site_url_not_empty": "O URL do site não pode estar vazio.",
"error.feed_title_not_empty": "O título do feed não pode estar vazio.",
"error.feed_category_not_found": "Esta categoria não existe ou não pertence a este usuário.",
"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.user_mandatory_fields": "O nome de usuário é obrigatório.",
"error.api_key_already_exists": "Essa chave de API já existe.",
"error.unable_to_create_api_key": "Não foi possível criar uma chave de API.",
"error.invalid_theme": "Tema inválido.",
"error.invalid_language": "Idioma inválido.",
"error.invalid_timezone": "Fuso horário inválido.",
"error.invalid_entry_direction": "Direção de entrada inválida.",
"error.invalid_display_mode": "Modo de exibição de aplicativo inválido da web.",
"error.invalid_gesture_nav": "Navegação por gestos inválida.",
"error.invalid_default_home_page": "Página inicial por defeito inválida!",
"form.feed.label.title": "Título",
"form.feed.label.site_url": "URL do site",
"form.feed.label.feed_url": "URL da fonte",
"form.feed.label.category": "Categoria",
"form.feed.label.crawler": "Obter conteúdo original",
"form.feed.label.feed_username": "Nome de usuário da fonte",
"form.feed.label.feed_password": "Senha da fonte",
"form.feed.label.user_agent": "Sobrescrever o agente de usuário (user-agent) padrão",
"form.feed.label.cookie": "Definir Cookies",
"form.feed.label.scraper_rules": "Regras do scraper",
"form.feed.label.rewrite_rules": "Regras para o Rewrite",
"form.feed.label.blocklist_rules": "Regras de bloqueio",
"form.feed.label.keeplist_rules": "Regras de permissão",
"form.feed.label.urlrewrite_rules": "Regras de reescrita de URL",
"form.feed.label.apprise_service_urls": "Comma separated list of Apprise service URLs",
"form.feed.label.ignore_http_cache": "Ignorar cache HTTP",
"form.feed.label.allow_self_signed_certificates": "Permitir certificados autoassinados ou inválidos",
"form.feed.label.disabled": "Não atualizar esta fonte",
"form.feed.label.no_media_player": "No media player (audio/video)",
"form.feed.label.fetch_via_proxy": "Buscar via proxy",
"form.feed.label.hide_globally": "Ocultar entradas na lista global não lida",
"form.feed.fieldset.general": "General",
"form.feed.fieldset.rules": "Rules",
"form.feed.fieldset.network_settings": "Network Settings",
"form.feed.fieldset.integration": "Third-Party Services",
"form.category.label.title": "Título",
"form.category.hide_globally": "Ocultar entradas na lista global não lida",
"form.user.label.username": "Nome de usuário",
"form.user.label.password": "Senha",
"form.user.label.confirmation": "Confirmação de senha",
"form.user.label.admin": "Administrador",
"form.prefs.label.language": "Idioma",
"form.prefs.label.timezone": "Fuso horário",
"form.prefs.label.theme": "Tema",
"form.prefs.label.entry_sorting": "Ordenação dos itens",
"form.prefs.label.entries_per_page": "Itens por página",
"form.prefs.label.default_reading_speed": "Velocidade de leitura para outros idiomas (palavras por minuto)",
"form.prefs.label.cjk_reading_speed": "Velocidade de leitura para chinês, coreano e japonês (caracteres por minuto)",
"form.prefs.label.display_mode": "Modo de exibição Progressive Web App (PWA)",
"form.prefs.select.older_first": "Itens mais velhos primeiro",
"form.prefs.select.recent_first": "Itens mais recentes",
"form.prefs.select.fullscreen": "Tela completa",
"form.prefs.select.standalone": "Autônomo",
"form.prefs.select.minimal_ui": "Mínimo",
"form.prefs.select.browser": "Navegador",
"form.prefs.select.publish_time": "Entrada hora de publicação",
"form.prefs.select.created_time": "Entrada tempo criado",
"form.prefs.select.alphabetical": "Por ordem alfabética",
"form.prefs.select.unread_count": "Contagem não lida",
"form.prefs.select.none": "Nenhum",
"form.prefs.select.tap": "Toque duplo",
"form.prefs.select.swipe": "Deslize",
"form.prefs.label.keyboard_shortcuts": "Habilitar atalhos do teclado",
"form.prefs.label.entry_swipe": "Ativar entrada de furto em telas sensíveis ao toque",
"form.prefs.label.gesture_nav": "Gesto para navegar entre as entradas",
"form.prefs.label.show_reading_time": "Mostrar tempo estimado de leitura de artigos",
"form.prefs.label.custom_css": "CSS customizado",
"form.prefs.label.entry_order": "Coluna de Ordenação de Entrada",
"form.prefs.label.default_home_page": "Página inicial predefinida",
"form.prefs.label.categories_sorting_order": "Classificação das categorias",
"form.prefs.label.mark_read_on_view": "Marcar automaticamente as entradas como lidas quando visualizadas",
"form.prefs.fieldset.application_settings": "Application Settings",
"form.prefs.fieldset.authentication_settings": "Authentication Settings",
"form.prefs.fieldset.reader_settings": "Reader Settings",
"form.import.label.file": "Arquivo OPML",
"form.import.label.url": "URL",
"form.integration.fever_activate": "Ativar API do Fever",
"form.integration.fever_username": "Nome de usuário do Fever",
"form.integration.fever_password": "Senha do Fever",
"form.integration.fever_endpoint": "Endpoint da API do Fever:",
"form.integration.googlereader_activate": "Ativar API do Google Reader",
"form.integration.googlereader_username": "Nome de usuário do Google Reader",
"form.integration.googlereader_password": "Senha do Google Reader",
"form.integration.googlereader_endpoint": "Endpoint da API do Google Reader:",
"form.integration.pinboard_activate": "Salvar itens no Pinboard",
"form.integration.pinboard_token": "Token de API do Pinboard",
"form.integration.pinboard_tags": "Etiquetas (tags) do Pinboard",
"form.integration.pinboard_bookmark": "Salvar marcador como não lido",
"form.integration.instapaper_activate": "Salvar itens no Instapaper",
"form.integration.instapaper_username": "Nome do usuário do Instapaper",
"form.integration.instapaper_password": "Senha do Instapaper",
"form.integration.pocket_activate": "Salvar itens no Pocket",
"form.integration.pocket_consumer_key": "Chave de consumo (Consumer Key) do Pocket",
"form.integration.pocket_access_token": "Token de acesso do Pocket",
"form.integration.pocket_connect_link": "Conectar a conta do Pocket",
"form.integration.wallabag_activate": "Salvar itens no Wallabag",
"form.integration.wallabag_only_url": "Enviar apenas URL (em vez de conteúdo completo)",
"form.integration.wallabag_endpoint": "Endpoint da API do 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_username": "Nome de usuário do Wallabag",
"form.integration.wallabag_password": "Senha do Wallabag",
"form.integration.notion_activate": "Save entries to Notion",
"form.integration.notion_page_id": "Notion Page ID",
"form.integration.notion_token": "Notion Secret Token",
"form.integration.apprise_activate": "Push entries to Apprise",
"form.integration.apprise_url": "Apprise API URL",
"form.integration.apprise_services_url": "Comma separated list of Apprise service URLs",
"form.integration.nunux_keeper_activate": "Salvar itens no Nunux Keeper",
"form.integration.nunux_keeper_endpoint": "Endpoint de API do Nunux Keeper",
"form.integration.nunux_keeper_api_key": "Chave de API do Nunux Keeper",
"form.integration.omnivore_activate": "Salvar itens no Omnivore",
"form.integration.omnivore_url": "Endpoint de API do Omnivore",
"form.integration.omnivore_api_key": "Chave de API do Omnivore",
"form.integration.espial_activate": "Salvar itens no Espial",
"form.integration.espial_endpoint": "Endpoint de API do Espial",
"form.integration.espial_api_key": "Chave de API do Espial",
"form.integration.espial_tags": "Etiquetas (tags) do Espial",
"form.integration.readwise_activate": "Save entries to Readwise Reader",
"form.integration.readwise_api_key": "Readwise Reader Access Token",
"form.integration.readwise_api_key_link": "Get your Readwise Access Token",
"form.integration.telegram_bot_activate": "Envie novos artigos para o chat do Telegram",
"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.telegram_bot_disable_web_page_preview": "Disable web page preview",
"form.integration.telegram_bot_disable_notification": "Disable notification",
"form.integration.telegram_bot_disable_buttons": "Disable buttons",
"form.integration.linkding_activate": "Salvar itens no Linkding",
"form.integration.linkding_endpoint": "Endpoint de API do Linkding",
"form.integration.linkding_api_key": "Chave de API do Linkding",
"form.integration.linkding_tags": "Linkding Tags",
"form.integration.linkding_bookmark": "Salvar marcador como não lido",
"form.integration.matrix_bot_activate": "Transferir novos artigos para o Matrix",
"form.integration.matrix_bot_user": "Nome de utilizador para 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_chat_id": "Identificação da sala Matrix",
"form.integration.shiori_activate": "Salvar itens no Shiori",
"form.integration.shiori_endpoint": "Endpoint da API do Shiori",
"form.integration.shiori_username": "Nome de usuário do Shiori",
"form.integration.shiori_password": "Senha do Shiori",
"form.integration.shaarli_activate": "Save articles to Shaarli",
"form.integration.shaarli_endpoint": "Shaarli URL",
"form.integration.shaarli_api_secret": "Shaarli API Secret",
"form.integration.webhook_activate": "Enable Webhook",
"form.integration.webhook_url": "Webhook URL",
"form.integration.webhook_secret": "Webhook Secret",
"form.integration.rssbridge_activate": "Check RSS-Bridge when adding subscriptions",
"form.integration.rssbridge_url": "RSS-Bridge server URL",
"form.api_key.label.description": "Etiqueta da chave de API",
"form.submit.loading": "Carregando...",
"form.submit.saving": "Salvando...",
"time_elapsed.not_yet": "ainda não",
"time_elapsed.yesterday": "ontem",
"time_elapsed.now": "agora mesmo",
"time_elapsed.minutes": [
"há %d minuto",
"há %d minutos"
],
"time_elapsed.hours": [
"há %d hora",
"há %d horas"
],
"time_elapsed.days": [
"há %d dia",
"há %d dias"
],
"time_elapsed.weeks": [
"há %d semana",
"há %d semanas"
],
"time_elapsed.months": [
"há %d mês",
"há %d meses"
],
"time_elapsed.years": [
"há %d ano",
"há %d anos"
],
"alert.too_many_feeds_refresh": "You have triggered too many feed refreshes. Please wait 30 minutes before trying again.",
"alert.background_feed_refresh": "All feeds are being refreshed in the background. You can continue to use Miniflux while this process is running.",
"error.http_response_too_large": "The HTTP response is too large. You could increase the HTTP response size limit in the global settings (requires a server restart).",
"error.http_body_read": "Unable to read the HTTP body: %v.",
"error.http_empty_response_body": "The HTTP response body is empty.",
"error.http_empty_response": "The HTTP response is empty. Perhaps, this website is using a bot protection mechanism?",
"error.tls_error": "TLS error: %v. You could disable TLS verification in the feed settings if you would like.",
"error.network_operation": "Miniflux is not able to reach this website due to a network error: %v.",
"error.network_timeout": "This website is too slow and the request timed out: %v",
"error.http_client_error": "HTTP client error: %v.",
"error.http_not_authorized": "Access to this website is not authorized. It could be a bad username or password.",
"error.http_too_many_requests": "Miniflux generated too many requests to this website. Please, try again later or change the application configuration.",
"error.http_forbidden": "Access to this website is forbidden. Perhaps, this website has a bot protection mechanism?",
"error.http_resource_not_found": "The requested resource is not found. Please, verify the URL.",
"error.http_internal_server_error": "The website is not available at the moment due to a server error. The problem is not on Miniflux side. Please, try again later.",
"error.http_bad_gateway": "The website is not available at the moment due to a bad gateway error. The problem is not on Miniflux side. Please, try again later.",
"error.http_service_unavailable": "The website is not available at the moment due to an internal server error. The problem is not on Miniflux side. Please, try again later.",
"error.http_gateway_timeout": "The website is not available at the moment due to a gateway timeout error. The problem is not on Miniflux side. Please, try again later.",
"error.http_unexpected_status_code": "The website is not available at the moment due to an unexpected HTTP status code: %d. The problem is not on Miniflux side. Please, try again later.",
"error.database_error": "Database error: %v.",
"error.category_not_found": "This category does not exist or does not belong to this user.",
"error.duplicated_feed": "This feed already exists.",
"error.unable_to_parse_feed": "Unable to parse this feed: %v.",
"error.feed_not_found": "This feed does not exist or does not belong to this user.",
"error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.",
"error.feed_format_not_detected": "Unable to detect feed format: %v."
}
miniflux-2.0.51/internal/locale/translations/ru_RU.json 0000664 0000000 0000000 00000107201 14546226260 0023165 0 ustar 00root root 0000000 0000000 {
"confirm.question": "Вы уверены?",
"confirm.question.refresh": "Вы хотите выполнить принудительное обновление?",
"confirm.yes": "да",
"confirm.no": "нет",
"confirm.loading": "В процессе…",
"action.subscribe": "Подписаться",
"action.save": "Сохранить",
"action.or": "или",
"action.cancel": "закрыть",
"action.remove": "Удалить",
"action.remove_feed": "Удалить эту подписку",
"action.update": "Обновить",
"action.edit": "Изменить",
"action.download": "Загрузить",
"action.import": "Импорт",
"action.login": "Войти",
"action.home_screen": "Добавить на домашний экран",
"tooltip.keyboard_shortcuts": "Сочетания клавиш: %s",
"tooltip.logged_user": "Авторизован как %s",
"menu.unread": "Непрочитанное",
"menu.starred": "Избранное",
"menu.history": "История",
"menu.feeds": "Подписки",
"menu.categories": "Категории",
"menu.settings": "Настройки",
"menu.logout": "Выйти",
"menu.preferences": "Предпочтения",
"menu.integrations": "Интеграции",
"menu.sessions": "Сессии",
"menu.users": "Пользователи",
"menu.about": "О приложении",
"menu.export": "Экспорт",
"menu.import": "Импорт",
"menu.create_category": "Создать категорию",
"menu.mark_page_as_read": "Отметить эту страницу прочитанной",
"menu.mark_all_as_read": "Отметить всё как прочитанное",
"menu.show_all_entries": "Показать все статьи",
"menu.show_only_unread_entries": "Показывать только непрочитанные статьи",
"menu.refresh_feed": "Обновить",
"menu.refresh_all_feeds": "Обновить все подписки в фоне",
"menu.edit_feed": "Изменить",
"menu.edit_category": "Изменить",
"menu.add_feed": "Добавить подписку",
"menu.add_user": "Добавить пользователя",
"menu.flush_history": "Очистить историю",
"menu.feed_entries": "Статьи",
"menu.api_keys": "API-ключи",
"menu.create_api_key": "Создать новый API-ключ",
"menu.shared_entries": "Общие записи",
"search.label": "Поиск",
"search.placeholder": "Поиск…",
"pagination.next": "Следующая",
"pagination.previous": "Предыдущая",
"entry.status.unread": "Не прочитано",
"entry.status.read": "Прочитано",
"entry.status.toast.unread": "Помечено как непрочитанное",
"entry.status.toast.read": "Помечено как прочитанное",
"entry.status.title": "Изменить статус записи",
"entry.bookmark.toggle.on": "Добавить в Избранное",
"entry.bookmark.toggle.off": "Удалить из Избранного",
"entry.bookmark.toast.on": "Помеченные",
"entry.bookmark.toast.off": "Без пометок",
"entry.state.saving": "Сохранение…",
"entry.state.loading": "Загрузка…",
"entry.save.label": "Сохранить",
"entry.save.title": "Сохранить эту статью",
"entry.save.completed": "Готово!",
"entry.save.toast.completed": "Статья сохранена",
"entry.scraper.label": "Скачать",
"entry.scraper.title": "Извлечь оригинальное содержимое",
"entry.scraper.completed": "Готово!",
"entry.external_link.label": "Внешняя ссылка",
"entry.comments.label": "Комментарии",
"entry.comments.title": "Показать комментарии",
"entry.share.label": "Поделиться",
"entry.share.title": "Поделиться этой статьёй",
"entry.unshare.label": "Удалить из общедоступных",
"entry.shared_entry.title": "Открыть публичную ссылку",
"entry.shared_entry.label": "Поделиться",
"entry.estimated_reading_time": [
"%d минута чтения",
"%d минут чтения"
],
"entry.tags.label": "Теги:",
"page.shared_entries.title": "Общедоступные статьи",
"page.unread.title": "Непрочитанное",
"page.starred.title": "Избранное",
"page.categories.title": "Категории",
"page.categories.no_feed": "Нет подписок.",
"page.categories.entries": "Cтатьи",
"page.categories.feeds": "Подписки",
"page.categories.feed_count": [
"Есть %d подписка.",
"Есть %d подписки.",
"Есть %d подписок."
],
"page.categories.unread_counter": "Количество непрочитанных статей",
"page.new_category.title": "Новая категория",
"page.new_user.title": "Новый пользователь",
"page.edit_category.title": "Изменить категорию: %s",
"page.edit_user.title": "Изменить пользователя: %s",
"page.feeds.title": "Подписки",
"page.feeds.last_check": "Последняя проверка:",
"page.feeds.next_check": "Next check:",
"page.feeds.unread_counter": "Количество непрочитанных статей",
"page.feeds.read_counter": "Количество прочитанных статей",
"page.feeds.error_count": [
"%d ошибка",
"%d ошибки",
"%d ошибок"
],
"page.history.title": "История",
"page.import.title": "Импорт",
"page.search.title": "Результаты поиска",
"page.about.title": "О приложении",
"page.about.credits": "Авторы",
"page.about.version": "Версия:",
"page.about.build_date": "Дата сборки:",
"page.about.author": "Автор:",
"page.about.license": "Лицензия:",
"page.about.postgres_version": "Версия Postgres:",
"page.about.go_version": "Версия Go:",
"page.about.global_config_options": "Глобальные параметры конфигурации",
"page.add_feed.title": "Новая подписка",
"page.add_feed.no_category": "Категории отсутствуют. У вас должна быть хотя бы одна категория.",
"page.add_feed.label.url": "Ссылка",
"page.add_feed.submit": "Найти подписку",
"page.add_feed.legend.advanced_options": "Расширенные настройки",
"page.add_feed.choose_feed": "Выберите подписку",
"page.edit_feed.title": "Изменить подписку: %s",
"page.edit_feed.last_check": "Последняя проверка:",
"page.edit_feed.last_modified_header": "Заголовок LastModified:",
"page.edit_feed.etag_header": "Заголовок ETag:",
"page.edit_feed.no_header": "Отсутствует",
"page.edit_feed.last_parsing_error": "Последняя ошибка парсинга",
"page.entry.attachments": "Вложения",
"page.keyboard_shortcuts.title": "Горячие клавиши",
"page.keyboard_shortcuts.subtitle.sections": "Навигация по секциям",
"page.keyboard_shortcuts.subtitle.items": "Навигация по элементам",
"page.keyboard_shortcuts.subtitle.pages": "Навигация по страницам",
"page.keyboard_shortcuts.subtitle.actions": "Действия",
"page.keyboard_shortcuts.go_to_unread": "Перейти к Непрочитанным",
"page.keyboard_shortcuts.go_to_starred": "Перейти к Избранному",
"page.keyboard_shortcuts.go_to_history": "Перейти к Истории",
"page.keyboard_shortcuts.go_to_feeds": "Перейти к Подпискам",
"page.keyboard_shortcuts.go_to_categories": "Перейти к Категориям",
"page.keyboard_shortcuts.go_to_settings": "Перейти к Настройкам",
"page.keyboard_shortcuts.show_keyboard_shortcuts": "Показать сочетания клавиш",
"page.keyboard_shortcuts.go_to_previous_item": "Перейти к предыдущему элементу",
"page.keyboard_shortcuts.go_to_next_item": "Перейти к следующему элементу",
"page.keyboard_shortcuts.go_to_feed": "Перейти к подписке",
"page.keyboard_shortcuts.go_to_previous_page": "Перейти к предыдущей странице",
"page.keyboard_shortcuts.go_to_next_page": "Перейти к следующей странице",
"page.keyboard_shortcuts.open_item": "Открыть выбранный элемент",
"page.keyboard_shortcuts.open_original_same_window": "Открыть оригинальную ссылку в текущей вкладке",
"page.keyboard_shortcuts.open_original": "Открыть оригинальную ссылку",
"page.keyboard_shortcuts.open_comments_same_window": "Открыть ссылку на комментарии в текущей вкладке",
"page.keyboard_shortcuts.open_comments": "Открыть ссылку для комментариев",
"page.keyboard_shortcuts.toggle_read_status_next": "Переключатель прочитанного, сосредоточиться на следующем",
"page.keyboard_shortcuts.toggle_read_status_prev": "Переключатель прочитанного, фокус предыдущий",
"page.keyboard_shortcuts.refresh_all_feeds": "Обновить все подписки в фоне",
"page.keyboard_shortcuts.mark_page_as_read": "Отметить текущую страницу прочитанной",
"page.keyboard_shortcuts.download_content": "Загрузить оригинальное содержимое",
"page.keyboard_shortcuts.toggle_bookmark_status": "Переключатель избранного",
"page.keyboard_shortcuts.save_article": "Сохранить статью",
"page.keyboard_shortcuts.scroll_item_to_top": "Прокрутите элемент вверх",
"page.keyboard_shortcuts.remove_feed": "Удалить эту подписку",
"page.keyboard_shortcuts.go_to_search": "Установить фокус в поисковой форме",
"page.keyboard_shortcuts.toggle_entry_attachments": "Переключатель показать/скрыть вложения",
"page.keyboard_shortcuts.close_modal": "Закрыть модальный диалог",
"page.users.title": "Пользователи",
"page.users.username": "Имя пользователя",
"page.users.never_logged": "Никогда",
"page.users.admin.yes": "Да",
"page.users.admin.no": "Нет",
"page.users.actions": "Действия",
"page.users.last_login": "Последний вход",
"page.users.is_admin": "Администратор",
"page.settings.title": "Настройки",
"page.settings.link_google_account": "Привязать мой Google аккаунт",
"page.settings.unlink_google_account": "Отвязать мой Google аккаунт",
"page.settings.link_oidc_account": "Привязать мой OpenID Connect аккаунт",
"page.settings.unlink_oidc_account": "Отвязать мой OpenID Connect аккаунт",
"page.settings.webauthn.passkeys": "Passkeys",
"page.settings.webauthn.actions": "Actions",
"page.settings.webauthn.passkey_name": "Passkey Name",
"page.settings.webauthn.added_on": "Added On",
"page.settings.webauthn.last_seen_on": "Last Used",
"page.settings.webauthn.register": "Зарегистрировать пароль",
"page.settings.webauthn.register.error": "Не удается зарегистрировать пароль",
"page.settings.webauthn.delete": [
"Удалить %d пароль",
"Удалить %d пароля",
"Удалить %d пароля"
],
"page.login.title": "Войти",
"page.login.google_signin": "Войти с помощью Google",
"page.login.oidc_signin": "Войти с помощью OpenID Connect",
"page.login.webauthn_login": "Войти с паролем",
"page.login.webauthn_login.error": "Невозможно войти с паролем",
"page.integrations.title": "Интеграции",
"page.integration.miniflux_api": "Miniflux API",
"page.integration.miniflux_api_endpoint": "Конечная точка API",
"page.integration.miniflux_api_username": "Имя пользователя",
"page.integration.miniflux_api_password": "Пароль",
"page.integration.miniflux_api_password_value": "Пароль вашего аккаунта",
"page.integration.bookmarklet": "Букмарклет",
"page.integration.bookmarklet.name": "Добавить в Miniflux",
"page.integration.bookmarklet.instructions": "Перетащите эту ссылку в ваши закладки.",
"page.integration.bookmarklet.help": "Эта специальная ссылка позволит вам подписаться на сайт, используя обыкновенную закладку в вашем браузере.",
"page.sessions.title": "Сессии",
"page.sessions.table.date": "Время",
"page.sessions.table.ip": "IP адрес",
"page.sessions.table.user_agent": "User-Agent",
"page.sessions.table.actions": "Действия",
"page.sessions.table.current_session": "Текущая сессия",
"page.api_keys.title": "API-ключи",
"page.api_keys.table.description": "Описание",
"page.api_keys.table.token": "Токен",
"page.api_keys.table.last_used_at": "Последнее использование",
"page.api_keys.table.created_at": "Дата создания",
"page.api_keys.table.actions": "Действия",
"page.api_keys.never_used": "Никогда не использовался",
"page.new_api_key.title": "Новый API-ключ",
"page.offline.title": "Автономный режим",
"page.offline.message": "Нет соединения",
"page.offline.refresh_page": "Попробуйте обновить страницу",
"page.webauthn_rename.title": "Rename Passkey",
"alert.no_shared_entry": "Общедоступные статьи отсутствуют.",
"alert.no_bookmark": "Избранное отсутствует.",
"alert.no_category": "Категории отсутствуют.",
"alert.no_category_entry": "В этой категории нет статей.",
"alert.no_feed_entry": "В этой подписке отсутствуют статьи.",
"alert.no_feed": "У вас нет ни одной подписки.",
"alert.no_feed_in_category": "Для этой категории нет подписки.",
"alert.no_history": "Истории пока что нет.",
"alert.feed_error": "С этой подпиской есть проблема",
"alert.no_search_result": "Нет результатов для данного поискового запроса.",
"alert.no_unread_entry": "Нет непрочитанных статей.",
"alert.no_user": "Вы единственный пользователь.",
"alert.account_unlinked": "Ваш внешний аккаунт теперь отвязан!",
"alert.account_linked": "Ваш внешний аккаунт теперь привязан!",
"alert.pocket_linked": "Ваш Pocket аккаунт теперь привязан!",
"alert.prefs_saved": "Предпочтения сохранены!",
"error.unlink_account_without_password": "Вы должны установить пароль, иначе вы не сможете войти снова.",
"error.duplicate_linked_account": "Уже есть кто-то, кто ассоциирован с этим аккаунтом!",
"error.duplicate_fever_username": "Уже есть кто-то с таким же именем пользователя Fever!",
"error.duplicate_googlereader_username": "Уже есть кто-то с таким же именем пользователя Google Reader!",
"error.pocket_request_token": "Не удалось получить request token от Pocket!",
"error.pocket_access_token": "Не удалось получить ключ доступа от Pocket!",
"error.category_already_exists": "Эта категория уже существует.",
"error.unable_to_create_category": "Не удалось создать эту категорию.",
"error.unable_to_update_category": "Не удалось обновить эту категорию.",
"error.user_already_exists": "Этот пользователь уже существует.",
"error.unable_to_create_user": "Не удалось создать этого пользователя.",
"error.unable_to_update_user": "Не удалось обновить этого пользователя.",
"error.unable_to_update_feed": "Не удалось обновить эту подписку.",
"error.subscription_not_found": "Не удалось найти подписки.",
"error.empty_file": "Этот файл пуст.",
"error.bad_credentials": "Неверное имя пользователя или пароль.",
"error.fields_mandatory": "Все поля обязательны.",
"error.title_required": "Название обязательно.",
"error.different_passwords": "Пароли не совпадают.",
"error.password_min_length": "Вы должны использовать минимум 6 символов.",
"error.settings_mandatory_fields": "Имя пользователя, тема, язык и часовой пояс обязательны.",
"error.settings_reading_speed_is_positive": "Скорость чтения должна быть целым положительным числом.",
"error.entries_per_page_invalid": "Недопустимое значение количества записей на странице.",
"error.feed_mandatory_fields": "Ссылка и категория обязательны.",
"error.feed_already_exists": "Эта подписка уже существует.",
"error.invalid_feed_url": "Недействительная ссылка подписки.",
"error.invalid_site_url": "Недействительный ссылка сайта.",
"error.feed_url_not_empty": "URL-адрес подписки не может быть пустым.",
"error.site_url_not_empty": "Ссылка на сайт не может быть пустой.",
"error.feed_title_not_empty": "Заголовок подписки не может быть пустым.",
"error.feed_category_not_found": "Эта категория не существует или не принадлежит этому пользователю.",
"error.feed_invalid_blocklist_rule": "Правило черного списка некорректно.",
"error.feed_invalid_keeplist_rule": "Правило белого списка некорректно.",
"error.user_mandatory_fields": "Имя пользователя обязательно.",
"error.api_key_already_exists": "Этот API-ключ уже существует.",
"error.unable_to_create_api_key": "Невозможно создать этот API-ключ.",
"error.invalid_theme": "Недопустимая тема.",
"error.invalid_language": "Недопустимый язык.",
"error.invalid_timezone": "Недопустымый часовой пояс.",
"error.invalid_entry_direction": "Недопустимая сортировка записей.",
"error.invalid_display_mode": "Недопустимый режим отображения веб-приложения.",
"error.invalid_gesture_nav": "Недопустимая навигация жестами.",
"error.invalid_default_home_page": "Недопустимая домашняя страница по умолчанию!",
"form.feed.label.title": "Название",
"form.feed.label.site_url": "Адрес сайта",
"form.feed.label.feed_url": "Адрес подписки",
"form.feed.label.category": "Категория",
"form.feed.label.crawler": "Извлечь оригинальное содержимое",
"form.feed.label.feed_username": "Имя пользователя подписки",
"form.feed.label.feed_password": "Пароль подписки",
"form.feed.label.user_agent": "Переопределить User-Agent по умолчанию",
"form.feed.label.cookie": "Установить куки",
"form.feed.label.scraper_rules": "Правила сборщика",
"form.feed.label.rewrite_rules": "Правила перезаписи",
"form.feed.label.blocklist_rules": "Правила черного списка",
"form.feed.label.keeplist_rules": "Правила белого списка",
"form.feed.label.urlrewrite_rules": "Правила перезаписи URL",
"form.feed.label.apprise_service_urls": "Список ссылок сервисов Apprise, разделенный запятой",
"form.feed.label.ignore_http_cache": "Игнорировать HTTP кеш",
"form.feed.label.allow_self_signed_certificates": "Разрешить самоподписанные или недействительные сертификаты",
"form.feed.label.fetch_via_proxy": "Использовать прокси",
"form.feed.label.disabled": "Не обновлять эту подписку",
"form.feed.label.no_media_player": "Отключить медиаплеер (аудио и видео)",
"form.feed.label.hide_globally": "Скрыть записи в глобальном списке непрочитанных",
"form.feed.fieldset.general": "General",
"form.feed.fieldset.rules": "Rules",
"form.feed.fieldset.network_settings": "Network Settings",
"form.feed.fieldset.integration": "Third-Party Services",
"form.category.label.title": "Название",
"form.category.hide_globally": "Скрыть записи в глобальном списке непрочитанных",
"form.user.label.username": "Имя пользователя",
"form.user.label.password": "Пароль",
"form.user.label.confirmation": "Подтверждение пароля",
"form.user.label.admin": "Администратор",
"form.prefs.label.language": "Язык",
"form.prefs.label.timezone": "Часовой пояс",
"form.prefs.label.theme": "Тема",
"form.prefs.label.entry_sorting": "Сортировка статей",
"form.prefs.label.entries_per_page": "Количество статей на страницу",
"form.prefs.label.default_reading_speed": "Скорость чтения на других языках (слов в минуту)",
"form.prefs.label.cjk_reading_speed": "Скорость чтения на китайском, корейском и японском языках (знаков в минуту)",
"form.prefs.label.display_mode": "Режим отображения Progressive Web App (PWA)",
"form.prefs.select.older_first": "Сначала старые записи",
"form.prefs.select.recent_first": "Сначала новые записи",
"form.prefs.select.fullscreen": "Полноэкранный",
"form.prefs.select.standalone": "Автономный",
"form.prefs.select.minimal_ui": "Минимальный",
"form.prefs.select.browser": "Браузер",
"form.prefs.select.publish_time": "Время публикации статьи",
"form.prefs.select.created_time": "Время создания статьи",
"form.prefs.select.alphabetical": "В алфавитном порядке",
"form.prefs.select.unread_count": "Количество непрочитанных",
"form.prefs.select.none": "Отключить",
"form.prefs.select.tap": "Двойное нажатие",
"form.prefs.select.swipe": "Свайп",
"form.prefs.label.keyboard_shortcuts": "Включить горячие клавиши",
"form.prefs.label.entry_swipe": "Включить пролистывание свайпом на сенсорных экранах",
"form.prefs.label.gesture_nav": "Жест для перехода между статьями",
"form.prefs.label.show_reading_time": "Показать примерное время чтения статей",
"form.prefs.label.custom_css": "Пользовательский CSS",
"form.prefs.label.entry_order": "Столбец сортировки статей",
"form.prefs.label.default_home_page": "Домашняя страница по умолчанию",
"form.prefs.label.categories_sorting_order": "Сортировка категорий",
"form.prefs.label.mark_read_on_view": "Автоматически отмечать записи как прочитанные при просмотре",
"form.prefs.fieldset.application_settings": "Application Settings",
"form.prefs.fieldset.authentication_settings": "Authentication Settings",
"form.prefs.fieldset.reader_settings": "Reader Settings",
"form.import.label.file": "OPML файл",
"form.import.label.url": "Ссылка",
"form.integration.fever_activate": "Активировать Fever API",
"form.integration.fever_username": "Имя пользователя Fever",
"form.integration.fever_password": "Пароль Fever",
"form.integration.fever_endpoint": "Конечная точка Fever API:",
"form.integration.googlereader_activate": "Активировать Google Reader API",
"form.integration.googlereader_username": "Имя пользователя Google Reader",
"form.integration.googlereader_password": "Пароль Google Reader",
"form.integration.googlereader_endpoint": "Конечная точка Google Reader API:",
"form.integration.pinboard_activate": "Сохранять статьи в Pinboard",
"form.integration.pinboard_token": "Токен Pinboard API",
"form.integration.pinboard_tags": "Теги Pinboard",
"form.integration.pinboard_bookmark": "Помечать закладки как непрочитанное",
"form.integration.instapaper_activate": "Сохранять статьи в Instapaper",
"form.integration.instapaper_username": "Имя пользователя Instapaper",
"form.integration.instapaper_password": "Пароль Instapaper",
"form.integration.pocket_activate": "Сохранять статьи в Pocket",
"form.integration.pocket_consumer_key": "Ключ пользователя Pocket",
"form.integration.pocket_access_token": "Ключ доступа к Pocket",
"form.integration.pocket_connect_link": "Подключить аккаунт Pocket",
"form.integration.wallabag_only_url": "Отправлять только ссылку (без содержимого)",
"form.integration.wallabag_activate": "Сохранять статьи в Wallabag",
"form.integration.wallabag_endpoint": "Конечная точка Wallabag API",
"form.integration.wallabag_client_id": "Номер клиента Wallabag",
"form.integration.wallabag_client_secret": "Секретный код клиента Wallabag",
"form.integration.wallabag_username": "Имя пользователя Wallabag",
"form.integration.wallabag_password": "Пароль Wallabag",
"form.integration.notion_activate": "Сохранить статьи в Notion",
"form.integration.notion_page_id": "Идентификатор страницы Notion",
"form.integration.notion_token": "Секретный токен Notion",
"form.integration.apprise_activate": "Отправить статьи в Apprise",
"form.integration.apprise_url": "Ссылка на Apprise API",
"form.integration.apprise_services_url": "Список ссылок сервисов Apprise, разделенный запятой",
"form.integration.nunux_keeper_activate": "Сохранять статьи в Nunux Keeper",
"form.integration.nunux_keeper_endpoint": "Конечная точка Nunux Keeper API",
"form.integration.nunux_keeper_api_key": "API-ключ Nunux Keeper",
"form.integration.omnivore_activate": "Сохранять статьи в Omnivore",
"form.integration.omnivore_url": "Конечная точка Omnivore API",
"form.integration.omnivore_api_key": "API-ключ Omnivore",
"form.integration.espial_activate": "Сохранять статьи в Espial",
"form.integration.espial_endpoint": "Конечная точка Espial API",
"form.integration.espial_api_key": "API-ключ Espial",
"form.integration.espial_tags": "Теги Espial",
"form.integration.readwise_activate": "Сохранить статьи в Readwise",
"form.integration.readwise_api_key": "Токен доступа в Readwise",
"form.integration.readwise_api_key_link": "Получить токен доступа Readwise",
"form.integration.telegram_bot_activate": "Репостить новые статьи в Telegram-чат",
"form.integration.telegram_bot_token": "Токен бота",
"form.integration.telegram_chat_id": "ID чата",
"form.integration.telegram_topic_id": "Topic ID",
"form.integration.telegram_bot_disable_web_page_preview": "Disable web page preview",
"form.integration.telegram_bot_disable_notification": "Disable notification",
"form.integration.telegram_bot_disable_buttons": "Disable buttons",
"form.integration.linkding_activate": "Сохранять статьи в Linkding",
"form.integration.linkding_endpoint": "Конечная точка Linkding API",
"form.integration.linkding_api_key": "API-ключ Linkding",
"form.integration.linkding_tags": "Теги Linkding",
"form.integration.linkding_bookmark": "Помечать закладки как непрочитанное",
"form.integration.matrix_bot_activate": "Репостить новые статьи в Matrix",
"form.integration.matrix_bot_user": "Имя пользователя Matrix",
"form.integration.matrix_bot_password": "Пароль пользователя Matrix",
"form.integration.matrix_bot_url": "Ссылка на сервер Matrix",
"form.integration.matrix_bot_chat_id": "ID комнаты Matrix",
"form.integration.shiori_activate": "Сохранять статьи в Shiori",
"form.integration.shiori_endpoint": "Конечная точка Shiori API",
"form.integration.shiori_username": "Имя пользователя Shiori",
"form.integration.shiori_password": "Пароль Shiori",
"form.integration.shaarli_activate": "Сохранить статьи в Shaarli",
"form.integration.shaarli_endpoint": "Ссылка Shaarli",
"form.integration.shaarli_api_secret": "Секретный ключ Shaarli API",
"form.integration.webhook_activate": "Включить вебхуки",
"form.integration.webhook_url": "Адрес вебхуков",
"form.integration.webhook_secret": "Секретный ключ для вебхуков",
"form.integration.rssbridge_activate": "Check RSS-Bridge when adding subscriptions",
"form.integration.rssbridge_url": "RSS-Bridge server URL",
"form.api_key.label.description": "Описание API-ключа",
"form.submit.loading": "Загрузка…",
"form.submit.saving": "Сохранение…",
"time_elapsed.not_yet": "ещё нет",
"time_elapsed.yesterday": "вчера",
"time_elapsed.now": "только что",
"time_elapsed.minutes": [
"%d минуту назад",
"%d минуты назад",
"%d минут назад"
],
"time_elapsed.hours": [
"%d час назад",
"%d часа назад",
"%d часов назад"
],
"time_elapsed.days": [
"%d день назад",
"%d дня назад",
"%d дней назад"
],
"time_elapsed.weeks": [
"%d неделю назад",
"%d недели назад",
"%d недель назад"
],
"time_elapsed.months": [
"%d месяц назад",
"%d месяца назад",
"%d месяцев назад"
],
"time_elapsed.years": [
"%d год назад",
"%d года назад",
"%d лет назад"
],
"alert.too_many_feeds_refresh": "You have triggered too many feed refreshes. Please wait 30 minutes before trying again.",
"alert.background_feed_refresh": "All feeds are being refreshed in the background. You can continue to use Miniflux while this process is running.",
"error.http_response_too_large": "The HTTP response is too large. You could increase the HTTP response size limit in the global settings (requires a server restart).",
"error.http_body_read": "Unable to read the HTTP body: %v.",
"error.http_empty_response_body": "The HTTP response body is empty.",
"error.http_empty_response": "The HTTP response is empty. Perhaps, this website is using a bot protection mechanism?",
"error.tls_error": "TLS error: %v. You could disable TLS verification in the feed settings if you would like.",
"error.network_operation": "Miniflux is not able to reach this website due to a network error: %v.",
"error.network_timeout": "This website is too slow and the request timed out: %v",
"error.http_client_error": "HTTP client error: %v.",
"error.http_not_authorized": "Access to this website is not authorized. It could be a bad username or password.",
"error.http_too_many_requests": "Miniflux generated too many requests to this website. Please, try again later or change the application configuration.",
"error.http_forbidden": "Access to this website is forbidden. Perhaps, this website has a bot protection mechanism?",
"error.http_resource_not_found": "The requested resource is not found. Please, verify the URL.",
"error.http_internal_server_error": "The website is not available at the moment due to a server error. The problem is not on Miniflux side. Please, try again later.",
"error.http_bad_gateway": "The website is not available at the moment due to a bad gateway error. The problem is not on Miniflux side. Please, try again later.",
"error.http_service_unavailable": "The website is not available at the moment due to an internal server error. The problem is not on Miniflux side. Please, try again later.",
"error.http_gateway_timeout": "The website is not available at the moment due to a gateway timeout error. The problem is not on Miniflux side. Please, try again later.",
"error.http_unexpected_status_code": "The website is not available at the moment due to an unexpected HTTP status code: %d. The problem is not on Miniflux side. Please, try again later.",
"error.database_error": "Database error: %v.",
"error.category_not_found": "This category does not exist or does not belong to this user.",
"error.duplicated_feed": "This feed already exists.",
"error.unable_to_parse_feed": "Unable to parse this feed: %v.",
"error.feed_not_found": "This feed does not exist or does not belong to this user.",
"error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.",
"error.feed_format_not_detected": "Unable to detect feed format: %v."
}
miniflux-2.0.51/internal/locale/translations/tr_TR.json 0000664 0000000 0000000 00000070245 14546226260 0023172 0 ustar 00root root 0000000 0000000 {
"confirm.question": "Emin misiniz?",
"confirm.question.refresh": "Zorla yenilemek istiyor musunuz?",
"confirm.yes": "evet",
"confirm.no": "hayır",
"confirm.loading": "Devam ediyor...",
"action.subscribe": "Abone Ol",
"action.save": "Kaydet",
"action.or": "veya",
"action.cancel": "iptal",
"action.remove": "Kaldır",
"action.remove_feed": "Bu beslemeyi kaldır",
"action.update": "Güncelle",
"action.edit": "Düzenle",
"action.download": "İndir",
"action.import": "İçeri Aktar",
"action.login": "Giriş",
"action.home_screen": "Ana ekrana ekle",
"tooltip.keyboard_shortcuts": "Klavye Kısayolu: %s",
"tooltip.logged_user": "%s olarak giriş yapıldı",
"menu.unread": "Okunmadı",
"menu.starred": "Yıldız",
"menu.history": "Geçmiş",
"menu.feeds": "Beslemeler",
"menu.categories": "Kategoriler",
"menu.settings": "Ayarlar",
"menu.logout": "Çıkış",
"menu.preferences": "Tercihler",
"menu.integrations": "Bütünleşmeler",
"menu.sessions": "Oturumlar",
"menu.users": "Kullanıcılar",
"menu.about": "Hakkında",
"menu.export": "Dışarı Aktar",
"menu.import": "İçeri Aktar",
"menu.create_category": "Kategori oluştur",
"menu.mark_page_as_read": "Bu sayfayı okundu olarak işaretle",
"menu.mark_all_as_read": "Tümünü okundu olarak işaretle",
"menu.show_all_entries": "Tüm iletileri göster",
"menu.show_only_unread_entries": "Sadece okunmamış iletileri göster",
"menu.refresh_feed": "Yenile",
"menu.refresh_all_feeds": "Tüm beslemeleri arka planda yenile",
"menu.edit_feed": "Düzenle",
"menu.edit_category": "Düzenle",
"menu.add_feed": "Abonelik ekle",
"menu.add_user": "Kullanıcı ekle",
"menu.flush_history": "Geçmişi temizle",
"menu.feed_entries": "İletiler",
"menu.api_keys": "API Anahtarları",
"menu.create_api_key": "Yeni bir API anahtarı oluştur",
"menu.shared_entries": "Paylaşılan iletiler",
"search.label": "Ara",
"search.placeholder": "Ara...",
"pagination.next": "Sonraki",
"pagination.previous": "Önceki",
"entry.status.unread": "Okunmadı",
"entry.status.read": "Okundu",
"entry.status.toast.unread": "Okunmadı olarak işaretle",
"entry.status.toast.read": "Okundu olarak işaretle",
"entry.status.title": "İleti durumunu değiştir",
"entry.bookmark.toggle.on": "Yıldız ekle",
"entry.bookmark.toggle.off": "Yıldızı kaldır",
"entry.bookmark.toast.on": "Yıldızlı",
"entry.bookmark.toast.off": "Yıldızsız",
"entry.state.saving": "Kaydediliyor...",
"entry.state.loading": "Yükleniyor...",
"entry.save.label": "Kaydet",
"entry.save.title": "Bu makaleyi kaydet",
"entry.save.completed": "Bitti!",
"entry.save.toast.completed": "Makale kaydedildi",
"entry.scraper.label": "İndir",
"entry.scraper.title": "Orijinal içeriği çek",
"entry.scraper.completed": "Bitti!",
"entry.external_link.label": "Dış bağlantı",
"entry.comments.label": "Yorumlar",
"entry.comments.title": "Yorumları Göster",
"entry.share.label": "Paylaş",
"entry.share.title": "Bu makaleyi paylaş",
"entry.unshare.label": "Paylaşma",
"entry.shared_entry.title": "Herkese açık bağlantıyı aç",
"entry.shared_entry.label": "Paylaş",
"entry.estimated_reading_time": [
"%d dakikalık okuma",
"%d dakikalık okuma"
],
"entry.tags.label": "Etiketleri:",
"page.shared_entries.title": "Paylaşılan iletiler",
"page.unread.title": "Okunmadı",
"page.starred.title": "Yıldızlı",
"page.categories.title": "Kategoriler",
"page.categories.no_feed": "Besleme yok.",
"page.categories.entries": "Makaleler",
"page.categories.feeds": "Abonelikler",
"page.categories.feed_count": [
"%d besleme var.",
"%d besleme var."
],
"page.categories.unread_counter": "Okunmamış iletilerin sayısı",
"page.new_category.title": "Yeni Kategori",
"page.new_user.title": "Yeni Kullanıcı",
"page.edit_category.title": "Kategoriyi Düzenle: %s",
"page.edit_user.title": "Kullanıcıyı Düzenle: %s",
"page.feeds.title": "Beslemeler",
"page.feeds.last_check": "Son kontrol:",
"page.feeds.next_check": "Next check:",
"page.feeds.unread_counter": "Okunmamış iletilerin sayısı",
"page.feeds.read_counter": "Okunmuş iletilerin sayısı",
"page.feeds.error_count": [
"%d hata",
"%d hata"
],
"page.history.title": "Geçmiş",
"page.import.title": "İçeri Aktar",
"page.search.title": "Arama Sonuçları",
"page.about.title": "Hakkında",
"page.about.credits": "Katkıda Bulunanlar",
"page.about.version": "Sürüm:",
"page.about.build_date": "Oluşturulma Tarihi:",
"page.about.author": "Yazar:",
"page.about.license": "Lisans:",
"page.about.global_config_options": "Global yapılandırma seçenekleri",
"page.about.postgres_version": "Postgres sürümü:",
"page.about.go_version": "Go sürümü:",
"page.add_feed.title": "Yeni Abonelik",
"page.add_feed.no_category": "Kategori yok. En az bir kategoriye sahip olmalısınız.",
"page.add_feed.label.url": "URL",
"page.add_feed.submit": "Bir abonelik bul",
"page.add_feed.legend.advanced_options": "Gelişmiş Seçenekler",
"page.add_feed.choose_feed": "Bir Abonelik Seçin",
"page.edit_feed.title": "Beslemeyi düzenle: %s",
"page.edit_feed.last_check": "Son kontrol:",
"page.edit_feed.last_modified_header": "LastModified başlığı:",
"page.edit_feed.etag_header": "ETag başlığı:",
"page.edit_feed.no_header": "Hiçbiri",
"page.edit_feed.last_parsing_error": "Son Ayrıştırma Hatası",
"page.entry.attachments": "Ekler",
"page.keyboard_shortcuts.title": "Klavye Kısayolları",
"page.keyboard_shortcuts.subtitle.sections": "Bölüm Gezinmesi",
"page.keyboard_shortcuts.subtitle.items": "Öğe Gezinmesi",
"page.keyboard_shortcuts.subtitle.pages": "Sayfa Gezinmesi",
"page.keyboard_shortcuts.subtitle.actions": "Hareketler",
"page.keyboard_shortcuts.go_to_unread": "Okunmamışa git",
"page.keyboard_shortcuts.go_to_starred": "Yer imlerine git",
"page.keyboard_shortcuts.go_to_history": "Geçmişe git",
"page.keyboard_shortcuts.go_to_feeds": "Beslemelere git",
"page.keyboard_shortcuts.go_to_categories": "Kategorilere git",
"page.keyboard_shortcuts.go_to_settings": "Ayarlara git",
"page.keyboard_shortcuts.show_keyboard_shortcuts": "Klavye kısayollarını göster",
"page.keyboard_shortcuts.go_to_previous_item": "Önceki öğeye git",
"page.keyboard_shortcuts.go_to_next_item": "Sonraki öğeye git",
"page.keyboard_shortcuts.go_to_feed": "Beslemeye git",
"page.keyboard_shortcuts.go_to_previous_page": "Önceki sayfaya git",
"page.keyboard_shortcuts.go_to_next_page": "Sonraki sayfaya git",
"page.keyboard_shortcuts.open_item": "Seçili öğeyi 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.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.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.keyboard_shortcuts.refresh_all_feeds": "Tüm beslemeleri arka planda yenile",
"page.keyboard_shortcuts.mark_page_as_read": "Mevcut sayfayı okundu olarak işaretle",
"page.keyboard_shortcuts.download_content": "Orijinal içeriği indir",
"page.keyboard_shortcuts.toggle_bookmark_status": "Yer işaretini değiştir",
"page.keyboard_shortcuts.save_article": "Makaleyi kaydet",
"page.keyboard_shortcuts.scroll_item_to_top": "Öğeyi en üste kaydır",
"page.keyboard_shortcuts.remove_feed": "Bu beslemeyi kaldır",
"page.keyboard_shortcuts.go_to_search": "Arama formuna odakla",
"page.keyboard_shortcuts.toggle_entry_attachments": "Toggle open/close entry attachments",
"page.keyboard_shortcuts.close_modal": "İletişim kutusunu kapat",
"page.users.title": "Kullanıcılar",
"page.users.username": "Kullanıcı adı",
"page.users.never_logged": "Asla",
"page.users.admin.yes": "Evet",
"page.users.admin.no": "Hayır",
"page.users.actions": "Hareketler",
"page.users.last_login": "Son Giriş",
"page.users.is_admin": "Yönetici",
"page.settings.title": "Ayarlar",
"page.settings.link_google_account": "Google hesabımı bağla",
"page.settings.unlink_google_account": "Google hesabımın bağlantısını kaldır",
"page.settings.link_oidc_account": "OpenID Connect hesabımı bağla",
"page.settings.unlink_oidc_account": "OpenID Connect hesabımın bağlantısını kaldır",
"page.settings.webauthn.passkeys": "Passkeys",
"page.settings.webauthn.actions": "Actions",
"page.settings.webauthn.passkey_name": "Passkey Name",
"page.settings.webauthn.added_on": "Added On",
"page.settings.webauthn.last_seen_on": "Last Used",
"page.settings.webauthn.register": "şifreyi kaydet",
"page.settings.webauthn.register.error": "Geçiş anahtarı kaydedilemiyor",
"page.settings.webauthn.delete": [
"%d geçiş anahtarını kaldır",
"%d geçiş anahtarını kaldır"
],
"page.login.title": "Oturum aç",
"page.login.google_signin": "Google ile oturum aç",
"page.login.oidc_signin": "OpenID Connect ile oturum aç",
"page.login.webauthn_login": "şifre ile giriş yap",
"page.login.webauthn_login.error": "şifre ile giriş yapılamıyor",
"page.integrations.title": "Bütünleşmeler",
"page.integration.miniflux_api": "Miniflux API",
"page.integration.miniflux_api_endpoint": "API Uç Noktası",
"page.integration.miniflux_api_username": "Kullanıcı adı",
"page.integration.miniflux_api_password": "Parola",
"page.integration.miniflux_api_password_value": "Hesap parolan",
"page.integration.bookmarklet": "Bookmarklet",
"page.integration.bookmarklet.name": "Miniflux'a Ekle",
"page.integration.bookmarklet.instructions": "Bu bağlantıyı yer imlerinize sürükleyip bırakın",
"page.integration.bookmarklet.help": "Bu özel bağlantı, web tarayıcınızdaki yer imini kullanarak bir web sitesine doğrudan abone olmanızı sağlar.",
"page.sessions.title": "Oturumlar",
"page.sessions.table.date": "Tarih",
"page.sessions.table.ip": "IP Adresi",
"page.sessions.table.user_agent": "User Agent",
"page.sessions.table.actions": "Hareketler",
"page.sessions.table.current_session": "Mevcut Oturum",
"page.api_keys.title": "API Anahtarları",
"page.api_keys.table.description": "Açıklama",
"page.api_keys.table.token": "Token",
"page.api_keys.table.last_used_at": "Son Kullanılma",
"page.api_keys.table.created_at": "Oluşturulma Tarihi",
"page.api_keys.table.actions": "Hareketler",
"page.api_keys.never_used": "Hiç Kullanılmadı",
"page.new_api_key.title": "Yeni API Anahtarı",
"page.offline.title": "Çevrimdışı Modu",
"page.offline.message": "Çevrimdışısınız",
"page.offline.refresh_page": "Sayfayı yenilemeyi dene",
"page.webauthn_rename.title": "Rename Passkey",
"alert.no_shared_entry": "Paylaşılan ileti yok.",
"alert.no_bookmark": "Şu anda hiç yer imi yok.",
"alert.no_category": "Hiç kategori yok.",
"alert.no_category_entry": "Bu kategoride hiç makale yok.",
"alert.no_feed_entry": "Bu besleme için makale yok.",
"alert.no_feed": "Hiç aboneliğiniz yok.",
"alert.no_feed_in_category": "Bu kategori için aboneliğiniz yok.",
"alert.no_history": "Şu anda hiç geçmiş yok.",
"alert.feed_error": "Bu beslemeyle ilgili bir problem var",
"alert.no_search_result": "Bu arama için sonuç yok",
"alert.no_unread_entry": "Okunmamış makale yok",
"alert.no_user": "Tek kullanıcı sizsiniz",
"alert.account_unlinked": "Harici hesabınızın bağlantısı kaldırıldı!",
"alert.account_linked": "Harici hesabınız bağlandı.",
"alert.pocket_linked": "Pocket hesabınız bağlandı.",
"alert.prefs_saved": "Tercihler kaydedildi!",
"error.unlink_account_without_password": "Bir şifre belirlemelisiniz, aksi takdirde tekrar oturum açamazsınız.",
"error.duplicate_linked_account": "Bu sağlayıcıyla ilişkilendirilmiş biri zaten var!",
"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.pocket_request_token": "Pocket'tan istek tokeni alınamıyor!",
"error.pocket_access_token": "Pocket'tan erişim tokeni alınamıyor!",
"error.category_already_exists": "Bu kategori zaten mevcut.",
"error.unable_to_create_category": "Bu kategori oluşturulamıyor.",
"error.unable_to_update_category": "Bu kategori güncellenemiyor.",
"error.user_already_exists": "Bu kullanıcı zaten mevcut.",
"error.unable_to_create_user": "Bu kullanıcı oluşturulamıyor.",
"error.unable_to_update_user": "Bu kullanıcı güncellenemiyor.",
"error.unable_to_update_feed": "Bu besleme güncellenemiyor.",
"error.subscription_not_found": "Herhangi bir abonelik bulunamadı.",
"error.invalid_theme": "Geçersiz tema.",
"error.invalid_language": "Geçersiz dil.",
"error.invalid_timezone": "Geçersiz saat dilimi",
"error.invalid_entry_direction": "Geçersiz giriş yönü.",
"error.invalid_display_mode": "Geçersiz web uygulaması görüntüleme modu.",
"error.invalid_gesture_nav": "Hareketle gezinme geçersiz.",
"error.invalid_default_home_page": "Geçersiz varsayılan ana sayfa!",
"error.empty_file": "Bu dosya boş.",
"error.bad_credentials": "Geçersiz kullanıcı veya parola.",
"error.fields_mandatory": "Tüm alanlar zorunlu.",
"error.title_required": "Başlık zorunlu.",
"error.different_passwords": "Parolalar eşleşmiyor.",
"error.password_min_length": "Parola en az 6 karakter içermeli.",
"error.settings_mandatory_fields": "Kullanıcı ad, tema, dil ve saat dilimi zorunlu.",
"error.settings_reading_speed_is_positive": "Okuma hızları pozitif tam sayılar olmalıdır.",
"error.entries_per_page_invalid": "Sayfa başına ileti sayısı geçersiz.",
"error.feed_mandatory_fields": "URL ve kategori zorunlu.",
"error.feed_already_exists": "Bu besleme zaten mevcut.",
"error.invalid_feed_url": "Geçersiz besleme URL'si.",
"error.invalid_site_url": "Geçersiz site URL'si.",
"error.feed_url_not_empty": "Besleme URL'si boş olamaz.",
"error.site_url_not_empty": "Site URL'si boş olamaz.",
"error.feed_title_not_empty": "Besleme başlığı boş olamaz.",
"error.feed_category_not_found": "Bu kategori mevcut değil ya da bu kullanıcıya ait değil.",
"error.feed_invalid_blocklist_rule": "Engelleme listesi kuralı geçersiz.",
"error.feed_invalid_keeplist_rule": "Saklama listesi kuralı geçersiz.",
"error.user_mandatory_fields": "Kullanıcı adı zorunlu.",
"error.api_key_already_exists": "Bu API anahtarı zaten mevcut.",
"error.unable_to_create_api_key": "Bu API anahtarı oluşturulamıyor.",
"form.feed.label.title": "Başlık",
"form.feed.label.site_url": "Site URL'si",
"form.feed.label.feed_url": "Besleme URL'si",
"form.feed.label.category": "Kategori",
"form.feed.label.crawler": "Orijinal içeriği çek",
"form.feed.label.feed_username": "Besleme Kullanıcı Adı",
"form.feed.label.feed_password": "Besleme Parolası",
"form.feed.label.user_agent": "Varsayılan User Agent'i Geçersiz Kıl",
"form.feed.label.cookie": "Çerezleri Ayarla",
"form.feed.label.scraper_rules": "Scrapper Kuralları",
"form.feed.label.rewrite_rules": "Yeniden Yazma Kuralları",
"form.feed.label.blocklist_rules": "Engelleme Kuralları",
"form.feed.label.keeplist_rules": "Saklama Kuralları",
"form.feed.label.urlrewrite_rules": "URL Yeniden Yazma Kuralları",
"form.feed.label.apprise_service_urls": "Comma separated list of Apprise service URLs",
"form.feed.label.ignore_http_cache": "HTTP önbelleğini yoksay",
"form.feed.label.allow_self_signed_certificates": "Kendinden imzalı veya geçersiz sertifikalara izin ver",
"form.feed.label.fetch_via_proxy": "Proxy ile çek",
"form.feed.label.disabled": "Bu beslemeyi yenileme",
"form.feed.label.no_media_player": "No media player (audio/video)",
"form.feed.label.hide_globally": "Genel okunmamış listesindeki girişleri gizle",
"form.feed.fieldset.general": "General",
"form.feed.fieldset.rules": "Rules",
"form.feed.fieldset.network_settings": "Network Settings",
"form.feed.fieldset.integration": "Third-Party Services",
"form.category.label.title": "Başlık",
"form.category.hide_globally": "Genel okunmamış listesindeki girişleri gizle",
"form.user.label.username": "Kullanıcı Adı",
"form.user.label.password": "Parola",
"form.user.label.confirmation": "Parola Doğrulama",
"form.user.label.admin": "Yönetici",
"form.prefs.label.language": "Dil",
"form.prefs.label.timezone": "Saat Dilimi",
"form.prefs.label.theme": "Tema",
"form.prefs.label.entry_sorting": "İleti Sıralaması",
"form.prefs.label.entries_per_page": "Sayfa başına ileti",
"form.prefs.label.default_reading_speed": "Diğer diller için okuma hızı (dakika başına kelime)",
"form.prefs.label.cjk_reading_speed": "Çince, Korece ve Japonca için okuma hızı (dakika başına karakter)",
"form.prefs.label.display_mode": "Aşamalı Web Uygulaması (PWA) görüntüleme modu",
"form.prefs.select.older_first": "Önce eski iletiler",
"form.prefs.select.recent_first": "Önce yeni iletiler",
"form.prefs.select.fullscreen": "Tam Ekran",
"form.prefs.select.standalone": "Bağımsız",
"form.prefs.select.minimal_ui": "Minimal",
"form.prefs.select.browser": "Tarayıcı",
"form.prefs.select.publish_time": "Giriş yayınlanma zamanı",
"form.prefs.select.created_time": "Girişin oluşturulma zamanı",
"form.prefs.select.alphabetical": "Alfabetik",
"form.prefs.select.unread_count": "Okunmamış sayısı",
"form.prefs.select.none": "Hiçbiri",
"form.prefs.select.tap": "çift dokunma",
"form.prefs.select.swipe": "Tokatlamak",
"form.prefs.label.keyboard_shortcuts": "Klavye kısayollarını etkinleştir",
"form.prefs.label.entry_swipe": "Увімкніть введення пальцем на сенсорних екранах",
"form.prefs.label.gesture_nav": "Girişler arasında gezinmek için hareket",
"form.prefs.label.show_reading_time": "Makaleler için tahmini okuma süresini göster",
"form.prefs.label.custom_css": "Özel CSS",
"form.prefs.label.entry_order": "Giriş Sıralama Sütunu",
"form.prefs.label.default_home_page": "Varsayılan ana sayfa",
"form.prefs.label.categories_sorting_order": "Kategoriler sıralama",
"form.prefs.label.mark_read_on_view": "Girişleri görüntülendiğinde otomatik olarak okundu olarak işaretle",
"form.prefs.fieldset.application_settings": "Application Settings",
"form.prefs.fieldset.authentication_settings": "Authentication Settings",
"form.prefs.fieldset.reader_settings": "Reader Settings",
"form.import.label.file": "OPML dosyası",
"form.import.label.url": "URL",
"form.integration.fever_activate": "Fever API'yi Etkinleştir",
"form.integration.fever_username": "Fever Kullanıcı Adı",
"form.integration.fever_password": "Fever Parolası",
"form.integration.fever_endpoint": "Fever API uç noktası:",
"form.integration.googlereader_activate": "Google Reader API'yi Etkinleştir",
"form.integration.googlereader_username": "Google Reader Kullanıcı Adı",
"form.integration.googlereader_password": "Google Reader Parolası",
"form.integration.googlereader_endpoint": "Google Reader API uç noktası:",
"form.integration.pinboard_activate": "Makaleleri Pinboard'a kaydet",
"form.integration.pinboard_token": "Pinboard API Token",
"form.integration.pinboard_tags": "Pinboard Etiketleri",
"form.integration.pinboard_bookmark": "Yer imini okunmadı olarak işaretle",
"form.integration.instapaper_activate": "Makaleleri Instapaper'a kaydet",
"form.integration.instapaper_username": "Instapaper Kullanıcı Adı",
"form.integration.instapaper_password": "Instapaper Parolası",
"form.integration.pocket_activate": "Makaleleri Pocket'a kaydet",
"form.integration.pocket_consumer_key": "Pocket Consumer Anahtarı",
"form.integration.pocket_access_token": "Pocket Access Token",
"form.integration.pocket_connect_link": "Pocket hesabını bağla",
"form.integration.wallabag_activate": "Makaleleri Wallabag'e kaydet",
"form.integration.wallabag_only_url": "Yalnızca URL gönder (tam içerik yerine)",
"form.integration.wallabag_endpoint": "Wallabag API Uç Noktası",
"form.integration.wallabag_client_id": "Wallabag Client ID",
"form.integration.wallabag_client_secret": "Wallabag Client Secret",
"form.integration.wallabag_username": "Wallabag Kullanıcı Adı",
"form.integration.wallabag_password": "Wallabag Parolası",
"form.integration.notion_activate": "Save entries to Notion",
"form.integration.notion_page_id": "Notion Page ID",
"form.integration.notion_token": "Notion Secret Token",
"form.integration.apprise_activate": "Push entries to Apprise",
"form.integration.apprise_url": "Apprise API URL",
"form.integration.apprise_services_url": "Comma separated list of Apprise service URLs",
"form.integration.nunux_keeper_activate": "Makaleleri Nunux Keeper'a kaydet",
"form.integration.nunux_keeper_endpoint": "Nunux Keeper API Uç Noktası",
"form.integration.nunux_keeper_api_key": "Nunux Keeper API anahtarı",
"form.integration.omnivore_activate": "Makaleleri Omnivore'a kaydet",
"form.integration.omnivore_url": "Omnivore API Uç Noktası",
"form.integration.omnivore_api_key": "Omnivore API anahtarı",
"form.integration.espial_activate": "Makaleleri Espial'e kaydet",
"form.integration.espial_endpoint": "Espial API Uç Noktası",
"form.integration.espial_api_key": "Espial API Anahtarı",
"form.integration.espial_tags": "Espial Etiketleri",
"form.integration.readwise_activate": "Save entries to Readwise Reader",
"form.integration.readwise_api_key": "Readwise Reader Access Token",
"form.integration.readwise_api_key_link": "Get your Readwise Access Token",
"form.integration.telegram_bot_activate": "Yeni makaleleri Telegram sohbetine gönderin",
"form.integration.telegram_bot_token": "Bot jetonu",
"form.integration.telegram_chat_id": "Sohbet kimliği",
"form.integration.telegram_topic_id": "Topic ID",
"form.integration.telegram_bot_disable_web_page_preview": "Disable web page preview",
"form.integration.telegram_bot_disable_notification": "Disable notification",
"form.integration.telegram_bot_disable_buttons": "Disable buttons",
"form.integration.linkding_activate": "Makaleleri Linkding'e kaydet",
"form.integration.linkding_endpoint": "Linkding API Uç Noktası",
"form.integration.linkding_api_key": "Linkding API Anahtarı",
"form.integration.linkding_tags": "Linkding Tags",
"form.integration.linkding_bookmark": "Yer imini okunmadı olarak işaretle",
"form.integration.matrix_bot_activate": "Yeni makaleleri Matrix'e aktarın",
"form.integration.matrix_bot_user": "Matrix için Kullanıcı Adı",
"form.integration.matrix_bot_password": "Matrix kullanıcısı için şifre",
"form.integration.matrix_bot_url": "Matris sunucusu URL'si",
"form.integration.matrix_bot_chat_id": "Matris odasının kimliği",
"form.integration.shiori_activate": "Makaleleri Shiori'e kaydet",
"form.integration.shiori_endpoint": "Shiori API Uç Noktası",
"form.integration.shiori_username": "Shiori Kullanıcı Adı",
"form.integration.shiori_password": "Shiori Parolası",
"form.integration.shaarli_activate": "Save articles to Shaarli",
"form.integration.shaarli_endpoint": "Shaarli URL",
"form.integration.shaarli_api_secret": "Shaarli API Secret",
"form.integration.webhook_activate": "Enable Webhook",
"form.integration.webhook_url": "Webhook URL",
"form.integration.webhook_secret": "Webhook Secret",
"form.integration.rssbridge_activate": "Check RSS-Bridge when adding subscriptions",
"form.integration.rssbridge_url": "RSS-Bridge server URL",
"form.api_key.label.description": "API Anahtar Etiketi",
"form.submit.loading": "Yükleniyor...",
"form.submit.saving": "Kaydediliyor...",
"time_elapsed.not_yet": "henüz değil",
"time_elapsed.yesterday": "dün",
"time_elapsed.now": "şimdi",
"time_elapsed.minutes": [
"%d dakika önce",
"%d dakika önce"
],
"time_elapsed.hours": [
"%d saat önce",
"%d saat önce"
],
"time_elapsed.days": [
"%d gün önce",
"%d gün önce"
],
"time_elapsed.weeks": [
"%d hafta önce",
"%d hafta önce"
],
"time_elapsed.months": [
"%d ay önce",
"%d ay önce"
],
"time_elapsed.years": [
"%d yıl önce",
"%d yıl önce"
],
"alert.too_many_feeds_refresh": "You have triggered too many feed refreshes. Please wait 30 minutes before trying again.",
"alert.background_feed_refresh": "All feeds are being refreshed in the background. You can continue to use Miniflux while this process is running.",
"error.http_response_too_large": "The HTTP response is too large. You could increase the HTTP response size limit in the global settings (requires a server restart).",
"error.http_body_read": "Unable to read the HTTP body: %v.",
"error.http_empty_response_body": "The HTTP response body is empty.",
"error.http_empty_response": "The HTTP response is empty. Perhaps, this website is using a bot protection mechanism?",
"error.tls_error": "TLS error: %v. You could disable TLS verification in the feed settings if you would like.",
"error.network_operation": "Miniflux is not able to reach this website due to a network error: %v.",
"error.network_timeout": "This website is too slow and the request timed out: %v",
"error.http_client_error": "HTTP client error: %v.",
"error.http_not_authorized": "Access to this website is not authorized. It could be a bad username or password.",
"error.http_too_many_requests": "Miniflux generated too many requests to this website. Please, try again later or change the application configuration.",
"error.http_forbidden": "Access to this website is forbidden. Perhaps, this website has a bot protection mechanism?",
"error.http_resource_not_found": "The requested resource is not found. Please, verify the URL.",
"error.http_internal_server_error": "The website is not available at the moment due to a server error. The problem is not on Miniflux side. Please, try again later.",
"error.http_bad_gateway": "The website is not available at the moment due to a bad gateway error. The problem is not on Miniflux side. Please, try again later.",
"error.http_service_unavailable": "The website is not available at the moment due to an internal server error. The problem is not on Miniflux side. Please, try again later.",
"error.http_gateway_timeout": "The website is not available at the moment due to a gateway timeout error. The problem is not on Miniflux side. Please, try again later.",
"error.http_unexpected_status_code": "The website is not available at the moment due to an unexpected HTTP status code: %d. The problem is not on Miniflux side. Please, try again later.",
"error.database_error": "Database error: %v.",
"error.category_not_found": "This category does not exist or does not belong to this user.",
"error.duplicated_feed": "This feed already exists.",
"error.unable_to_parse_feed": "Unable to parse this feed: %v.",
"error.feed_not_found": "This feed does not exist or does not belong to this user.",
"error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.",
"error.feed_format_not_detected": "Unable to detect feed format: %v."
}
miniflux-2.0.51/internal/locale/translations/uk_UA.json 0000664 0000000 0000000 00000106043 14546226260 0023140 0 ustar 00root root 0000000 0000000 {
"confirm.question": "Ви впевнені?",
"confirm.question.refresh": "Ви хочете змусити оновити?",
"confirm.yes": "так",
"confirm.no": "ні",
"confirm.loading": "В процесі...",
"action.subscribe": "Підписатись",
"action.save": "Зберегти",
"action.or": "або",
"action.cancel": "скасувати",
"action.remove": "Видалити",
"action.remove_feed": "Видалити стрічку",
"action.update": "Зберегти",
"action.edit": "Редагувати",
"action.download": "Завантажити",
"action.import": "Імпортувати",
"action.login": "Увійти",
"action.home_screen": "Додати до головного екрану",
"tooltip.keyboard_shortcuts": "Комбінація клавіш: %s",
"tooltip.logged_user": "Здійснено вхід як %s",
"menu.unread": "Непрочитане",
"menu.starred": "З зірочкою",
"menu.history": "Історія",
"menu.feeds": "Стрічки",
"menu.categories": "Категорії",
"menu.settings": "Налаштування",
"menu.logout": "Вийти",
"menu.preferences": "Уподобання",
"menu.integrations": "Інтеграції",
"menu.sessions": "Сеанси",
"menu.users": "Користувачі",
"menu.about": "Про додаток",
"menu.export": "Експорт",
"menu.import": "Імпорт",
"menu.create_category": "Створити категорію",
"menu.mark_page_as_read": "Відмітити цю сторінку як прочитане",
"menu.mark_all_as_read": "Відмітити все як прочитане",
"menu.show_all_entries": "Показати всі записи",
"menu.show_only_unread_entries": "Показати тільки непрочитані записи",
"menu.refresh_feed": "Оновити",
"menu.refresh_all_feeds": "Оновити всі стрічки у фоновому режимі",
"menu.edit_feed": "Редагувати",
"menu.edit_category": "Редагувати",
"menu.add_feed": "Додати підписку",
"menu.add_user": "Додати користувачв",
"menu.flush_history": "Очистити історію",
"menu.feed_entries": "Записи",
"menu.api_keys": "Ключі API",
"menu.create_api_key": "Створити новий ключ API",
"menu.shared_entries": "Спільні записи",
"search.label": "Пошук",
"search.placeholder": "Шукати...",
"pagination.next": "Вперед",
"pagination.previous": "Назад",
"entry.status.unread": "Непрочитане",
"entry.status.read": "Прочитане",
"entry.status.toast.unread": "Відмічено непрочитаним",
"entry.status.toast.read": "Відмічено прочитаним",
"entry.status.title": "Змінити стан запису",
"entry.bookmark.toggle.on": "Поставити зірочку",
"entry.bookmark.toggle.off": "Прибрати зірочку",
"entry.bookmark.toast.on": "З зірочкою",
"entry.bookmark.toast.off": "Без зірочки",
"entry.state.saving": "Зберігаю...",
"entry.state.loading": "Завантаження...",
"entry.save.label": "Зберегти",
"entry.save.title": "Зберегти цю статтю",
"entry.save.completed": "Готово!",
"entry.save.toast.completed": "Стаття збережена",
"entry.scraper.label": "Завантажити",
"entry.scraper.title": "Отримати оригінальний зміст",
"entry.scraper.completed": "Готово!",
"entry.external_link.label": "Зовнішнє посилання",
"entry.comments.label": "Коментарі",
"entry.comments.title": "Дивитися коментарі",
"entry.share.label": "Поділитись",
"entry.share.title": "Поділитись статтєю",
"entry.unshare.label": "Не ділитися",
"entry.shared_entry.title": "Відкрити публічне посилання",
"entry.shared_entry.label": "Поділитись",
"entry.estimated_reading_time": [
"читати %d хвилину",
"читати %d хвилини",
"читати %d хвилин"
],
"entry.tags.label": "Теги:",
"page.shared_entries.title": "Спильні записи",
"page.unread.title": "Непрочитане",
"page.starred.title": "З зірочкою",
"page.categories.title": "Категорії",
"page.categories.no_feed": "Немає стрічки.",
"page.categories.entries": "Статті",
"page.categories.feeds": "Підписки",
"page.categories.feed_count": [
"Містить %d стрічку.",
"Містить %d стрічки.",
"Містить %d стрічок."
],
"page.categories.unread_counter": "Кількість непрочитаних записів",
"page.new_category.title": "Нова категорія",
"page.new_user.title": "Новий користувач",
"page.edit_category.title": "Редагування категорії: %s",
"page.edit_user.title": "Редагування користувача: %s",
"page.feeds.title": "Стрічки",
"page.feeds.last_check": "Остання перевірка:",
"page.feeds.next_check": "Next check:",
"page.feeds.unread_counter": "Кількість непрочитаних записів",
"page.feeds.read_counter": "Кількість прочитаних записів",
"page.feeds.error_count": [
"%d помилка",
"%d помилки",
"%d помилок"
],
"page.history.title": "Історія",
"page.import.title": "Імпорт",
"page.search.title": "Результати пошуку",
"page.about.title": "Про додадок",
"page.about.credits": "Титри",
"page.about.version": "Версія:",
"page.about.build_date": "Дата побудови:",
"page.about.author": "Автор:",
"page.about.license": "Ліцензія:",
"page.about.global_config_options": "Параметри глобальної конфігурації",
"page.about.postgres_version": "Версія Postgres:",
"page.about.go_version": "Версія Go:",
"page.add_feed.title": "Нова підписка",
"page.add_feed.no_category": "Немає категорії. Ви маєте додати принаймні одну категорію.",
"page.add_feed.label.url": "URL",
"page.add_feed.submit": "Знайти підписку",
"page.add_feed.legend.advanced_options": "Розширені опції",
"page.add_feed.choose_feed": "Обрати підписку",
"page.edit_feed.title": "Редагування стрічки: %s",
"page.edit_feed.last_check": "Остання перевірка:",
"page.edit_feed.last_modified_header": "Заголовок LastModified:",
"page.edit_feed.etag_header": "Заголовок ETag:",
"page.edit_feed.no_header": "Немає",
"page.edit_feed.last_parsing_error": "Остання помилка аналізу",
"page.entry.attachments": "Додатки",
"page.keyboard_shortcuts.title": "Комбінації клавиш",
"page.keyboard_shortcuts.subtitle.sections": "Навігація по розділах",
"page.keyboard_shortcuts.subtitle.items": "Навігація по записах",
"page.keyboard_shortcuts.subtitle.pages": "Навігація по сторінках",
"page.keyboard_shortcuts.subtitle.actions": "Дії",
"page.keyboard_shortcuts.go_to_unread": "Перейти до непрочитаних",
"page.keyboard_shortcuts.go_to_starred": "Перейти до закладок",
"page.keyboard_shortcuts.go_to_history": "Перейти до історії",
"page.keyboard_shortcuts.go_to_feeds": "Перейти до стрічок",
"page.keyboard_shortcuts.go_to_categories": "Перейти до категорій",
"page.keyboard_shortcuts.go_to_settings": "Перейти до налаштувань",
"page.keyboard_shortcuts.show_keyboard_shortcuts": "Показати комбінації клавиш",
"page.keyboard_shortcuts.go_to_previous_item": "Перейти до попереднього запису",
"page.keyboard_shortcuts.go_to_next_item": "Перейти до наступного запису",
"page.keyboard_shortcuts.go_to_feed": "Перейти до стрічки",
"page.keyboard_shortcuts.go_to_previous_page": "Перейти до попередньої сторінки",
"page.keyboard_shortcuts.go_to_next_page": "Перейти до наступної сторінки",
"page.keyboard_shortcuts.open_item": "Відкрити виділений запис",
"page.keyboard_shortcuts.open_original": "Відкрити оригінальне посилання",
"page.keyboard_shortcuts.open_original_same_window": "Відкрити оригінальне посилання в поточній вкладці",
"page.keyboard_shortcuts.open_comments": "Відкрити посилання на коментарі",
"page.keyboard_shortcuts.open_comments_same_window": "Відкрити посилання на коментарі в поточній вкладці",
"page.keyboard_shortcuts.toggle_read_status_next": "Переключити статус читання, перейти до наступного",
"page.keyboard_shortcuts.toggle_read_status_prev": "Переключити статус читання, перейти до попереднього",
"page.keyboard_shortcuts.refresh_all_feeds": "Оновити всі стрічки в фоновому режимі",
"page.keyboard_shortcuts.mark_page_as_read": "Відмітити поточну сторінку як прочитане",
"page.keyboard_shortcuts.download_content": "Завантажити оригінальний зміст",
"page.keyboard_shortcuts.toggle_bookmark_status": "Переключити статус закладки",
"page.keyboard_shortcuts.save_article": "Зберегти статтю",
"page.keyboard_shortcuts.scroll_item_to_top": "Прокрутити запис догори",
"page.keyboard_shortcuts.remove_feed": "Видалити цю стрічку",
"page.keyboard_shortcuts.go_to_search": "Поставити фокус на поле пошуку",
"page.keyboard_shortcuts.toggle_entry_attachments": "Toggle open/close entry attachments",
"page.keyboard_shortcuts.close_modal": "Закрити модальне діалогове вікно",
"page.users.title": "Користувачі",
"page.users.username": "Ім’я користувача",
"page.users.never_logged": "Ніколи",
"page.users.admin.yes": "Так",
"page.users.admin.no": "Ні",
"page.users.actions": "Дії",
"page.users.last_login": "Дата останнього входу",
"page.users.is_admin": "Адміністратор",
"page.settings.title": "Налаштування ",
"page.settings.link_google_account": "Підключити мій обліковий запис Google",
"page.settings.unlink_google_account": "Відключити мій обліковий запис Google",
"page.settings.link_oidc_account": "Підключити мій обліковий запис OpenID Connect",
"page.settings.unlink_oidc_account": "Відключити мій обліковий запис OpenID Connect",
"page.settings.webauthn.passkeys": "Passkeys",
"page.settings.webauthn.actions": "Actions",
"page.settings.webauthn.passkey_name": "Passkey Name",
"page.settings.webauthn.added_on": "Added On",
"page.settings.webauthn.last_seen_on": "Last Used",
"page.settings.webauthn.register": "Зареєструвати пароль",
"page.settings.webauthn.register.error": "Не вдалося зареєструвати ключ доступу",
"page.settings.webauthn.delete": [
"Видалити %d ключ доступу",
"Видаліть %d ключа доступу",
"Видаліть %d ключа доступу"
],
"page.login.title": "Вхід",
"page.login.google_signin": "Увійти через Google",
"page.login.oidc_signin": "Увійти через OpenID Connect",
"page.login.webauthn_login": "Увійти за допомогою пароля",
"page.login.webauthn_login.error": "Неможливо ввійти за допомогою ключа доступу",
"page.integrations.title": "Інтеграції",
"page.integration.miniflux_api": "Miniflux API",
"page.integration.miniflux_api_endpoint": "Адреса доступу API",
"page.integration.miniflux_api_username": "Ім’я користувача",
"page.integration.miniflux_api_password": "Пароль",
"page.integration.miniflux_api_password_value": "Пароль до вашого облікового запису",
"page.integration.bookmarklet": "Букмарклет",
"page.integration.bookmarklet.name": "Додати до Miniflux",
"page.integration.bookmarklet.instructions": "Перетягніть це посилання до своїх закладок.",
"page.integration.bookmarklet.help": "Це спеціальне посилання дозволяє підписатися на веб-сайт безпосередньо за допомогою закладки у вашому веб-браузері.",
"page.sessions.title": "Сеанси",
"page.sessions.table.date": "Дата",
"page.sessions.table.ip": "IP адреса",
"page.sessions.table.user_agent": "User Agent",
"page.sessions.table.actions": "Дії",
"page.sessions.table.current_session": "Поточний сеанс",
"page.api_keys.title": "Ключі API",
"page.api_keys.table.description": "Опис",
"page.api_keys.table.token": "Токен",
"page.api_keys.table.last_used_at": "Дата останнього використання",
"page.api_keys.table.created_at": "Дата створення",
"page.api_keys.table.actions": "Дії",
"page.api_keys.never_used": "Ніколи не використався",
"page.new_api_key.title": "Створити ключ API",
"page.offline.title": "Автономний режим",
"page.offline.message": "Ви офлайн",
"page.offline.refresh_page": "Спробуйте оновити сторінку",
"page.webauthn_rename.title": "Rename Passkey",
"alert.no_shared_entry": "Немає спільного запису.",
"alert.no_bookmark": "Наразі закладки відсутні.",
"alert.no_category": "Немає категорії.",
"alert.no_category_entry": "У цій категорії немає записів.",
"alert.no_feed_entry": "У цій стрічці немає записів.",
"alert.no_feed": "У вас немає підписок.",
"alert.no_feed_in_category": "У цій категорії немає підписок.",
"alert.no_history": "Наразі історія порожня.",
"alert.feed_error": "З цією стрічкою трапилась помилка",
"alert.no_search_result": "Немає результатів для цього пошуку.",
"alert.no_unread_entry": "Немає непрочитаних статей.",
"alert.no_user": "Ви єдиний користувач.",
"alert.account_unlinked": "Тепер ваш зовнішній обліковий запис підключено!",
"alert.account_linked": "Тепер ваш зовнішній обліковий запис від’єднано!",
"alert.pocket_linked": "Тепер ваш обліковий запис Pocket підключено!",
"alert.prefs_saved": "Уподобання збережено!",
"error.unlink_account_without_password": "Ви маєте встановити пароль, щоб мати можливість увійти наступного разу",
"error.duplicate_linked_account": "Вже є обліковий запис, під’єднаний до цього провайдера!",
"error.duplicate_fever_username": "Вже є обліковий запис з таким самим користувачем Fever!",
"error.duplicate_googlereader_username": "Вже є обліковий запис з таким самим користувачем Google Reader!",
"error.pocket_request_token": "Не вдалося отримати токен доступу з Pocket!",
"error.pocket_access_token": "Не вдалося отримати токен доступу з Pocket!",
"error.category_already_exists": "Така категорія вже існує.",
"error.unable_to_create_category": "Не вдається сворити категорію.",
"error.unable_to_update_category": "Не вдається відредагувати категорію.",
"error.user_already_exists": "Такий користувач вже існує.",
"error.unable_to_create_user": "Не вдається створити користувача.",
"error.unable_to_update_user": "Не вдається оновити користувача.",
"error.unable_to_update_feed": "Не вдається оновити стрічку.",
"error.subscription_not_found": "Не знайшлося жодної підписки.",
"error.invalid_theme": "Недійсна тема.",
"error.invalid_language": "Недійсна мова.",
"error.invalid_timezone": "Недійсний часовий пояс.",
"error.invalid_entry_direction": "Недійсний напрямок запису.",
"error.invalid_display_mode": "Недійсний режим відображення.",
"error.invalid_gesture_nav": "Недійсна навігація жестами.",
"error.invalid_default_home_page": "Недійсна домашня сторінка за замовчуванням!",
"error.empty_file": "Цей файл порожній.",
"error.bad_credentials": "Невірне ім’я користувача або пароль.",
"error.fields_mandatory": "Всі поля є обов’язковими.",
"error.title_required": "Назва є обов’язковою.",
"error.different_passwords": "Паролі не співпадають.",
"error.password_min_length": "Пароль має складати щонайменше 6 символів.",
"error.settings_mandatory_fields": "Поля імені, теми, мови та часового поясу є обов’язковими.",
"error.settings_reading_speed_is_positive": "Швидкість читання має бути додатнім цілим числом.",
"error.entries_per_page_invalid": "Число записів на сторінку недійсне.",
"error.feed_mandatory_fields": "URL та категорія є обов’язковими.",
"error.feed_already_exists": "Така стрічка вже існує.",
"error.invalid_feed_url": "Недійсна URL-адреса стрічки.",
"error.invalid_site_url": "Недійсна URL-адреса сайту.",
"error.feed_url_not_empty": "URL-адреса стрічки не може бути порожньою.",
"error.site_url_not_empty": "URL-адреса сайту не може бути порожньою.",
"error.feed_title_not_empty": "Назва стрічки не може бути порожньою.",
"error.feed_category_not_found": "Категорія не існує або належить до іншого користувача.",
"error.feed_invalid_blocklist_rule": "Правило списку блокувань недійсне.",
"error.feed_invalid_keeplist_rule": "Правило списку дозволень недійсне.",
"error.user_mandatory_fields": "Ім’я користувача є обов’язковим.",
"error.api_key_already_exists": "Такий ключ API вже існує.",
"error.unable_to_create_api_key": "Не вдається створити такий ключ API",
"form.feed.label.title": "Назва",
"form.feed.label.site_url": "URL-адреса сайту",
"form.feed.label.feed_url": "URL-адреса стрічки",
"form.feed.label.category": "Категорія",
"form.feed.label.crawler": "Завантажувати оригінальний вміст",
"form.feed.label.feed_username": "Ім’я користувача для завантаження",
"form.feed.label.feed_password": "Пароль для завантаження",
"form.feed.label.user_agent": "Назначити User Agent",
"form.feed.label.cookie": "Встановити кукі",
"form.feed.label.scraper_rules": "Правила Scraper",
"form.feed.label.rewrite_rules": "Правила Rewrite",
"form.feed.label.blocklist_rules": "Правила блокування",
"form.feed.label.keeplist_rules": "Правила дозволення",
"form.feed.label.urlrewrite_rules": "Правила перезапису URL-адрес",
"form.feed.label.apprise_service_urls": "Comma separated list of Apprise service URLs",
"form.feed.label.ignore_http_cache": "Ігнорувати кеш HTTP",
"form.feed.label.allow_self_signed_certificates": "Дозволити сертифікати з власним підписом або недійсні",
"form.feed.label.fetch_via_proxy": "Використати проксі-сервер",
"form.feed.label.disabled": "Не оновлювати цю стрічку",
"form.feed.label.no_media_player": "No media player (audio/video)",
"form.feed.label.hide_globally": "Приховати записи в глобальному списку непрочитаного",
"form.category.label.title": "Назва",
"form.category.hide_globally": "Приховати записи в глобальному списку непрочитаного",
"form.feed.fieldset.general": "General",
"form.feed.fieldset.rules": "Rules",
"form.feed.fieldset.network_settings": "Network Settings",
"form.feed.fieldset.integration": "Third-Party Services",
"form.user.label.username": "Ім’я користувача",
"form.user.label.password": "Пароль",
"form.user.label.confirmation": "Підтверждення паролю",
"form.user.label.admin": "Адміністратор",
"form.prefs.label.language": "Мова",
"form.prefs.label.timezone": "Часовий пояс",
"form.prefs.label.theme": "Тема",
"form.prefs.label.entry_sorting": "Сортування записів",
"form.prefs.label.entries_per_page": "Кількість записів на сторінку",
"form.prefs.label.default_reading_speed": "Швидкість читання для інших мов (слів на хвилину)",
"form.prefs.label.cjk_reading_speed": "Швидкість читання для китайської, корейської та японської мови (символів на хвилину)",
"form.prefs.label.display_mode": "Режим відображення Progressive Web App (PWA).",
"form.prefs.select.older_first": "Старіші записи спочатку",
"form.prefs.select.recent_first": "Останні записи спочатку",
"form.prefs.select.fullscreen": "Повний екран",
"form.prefs.select.standalone": "Автономний",
"form.prefs.select.minimal_ui": "Мінімальний",
"form.prefs.select.browser": "Браузер",
"form.prefs.select.publish_time": "Дата публікації запису",
"form.prefs.select.created_time": "Дата створення запису",
"form.prefs.select.alphabetical": "За алфавітом",
"form.prefs.select.unread_count": "Кількість непрочитаних",
"form.prefs.select.none": "Жодного",
"form.prefs.select.tap": "Двічі натисніть",
"form.prefs.select.swipe": "Проведіть пальцем",
"form.prefs.label.keyboard_shortcuts": "Увімкнути комбінації клавиш",
"form.prefs.label.entry_swipe": "Увімкніть введення пальцем на сенсорних екранах",
"form.prefs.label.gesture_nav": "Жест для переходу між записами",
"form.prefs.label.show_reading_time": "Показувати приблизний час читання для записів",
"form.prefs.label.custom_css": "Спеціальний CSS",
"form.prefs.label.entry_order": "Стовпець сортування записів",
"form.prefs.label.default_home_page": "Домашня сторінка за умовчанням",
"form.prefs.label.categories_sorting_order": "Сортування за категоріями",
"form.prefs.label.mark_read_on_view": "Автоматично позначати записи як прочитані під час перегляду",
"form.prefs.fieldset.application_settings": "Application Settings",
"form.prefs.fieldset.authentication_settings": "Authentication Settings",
"form.prefs.fieldset.reader_settings": "Reader Settings",
"form.import.label.file": "Файл OPML",
"form.import.label.url": "URL-адреса",
"form.integration.fever_activate": "Увімкнути API Fever",
"form.integration.fever_username": "Ім’я користувача Fever",
"form.integration.fever_password": "Пароль Fever",
"form.integration.fever_endpoint": "Адреса доступу API Fever:",
"form.integration.googlereader_activate": "Увімкнути API Google Reader",
"form.integration.googlereader_username": "Ім’я користувача Google Reader",
"form.integration.googlereader_password": "Пароль Google Reader",
"form.integration.googlereader_endpoint": "Адреса доступу API Google Reader:",
"form.integration.pinboard_activate": "Зберігати статті до Pinboard",
"form.integration.pinboard_token": "API ключ від Pinboard",
"form.integration.pinboard_tags": "Теги для Pinboard",
"form.integration.pinboard_bookmark": "Відмічати закладку як непрочитану",
"form.integration.instapaper_activate": "Зберігати статті до Instapaper",
"form.integration.instapaper_username": "Ім’я користувача Instapaper",
"form.integration.instapaper_password": "Пароль Instapaper",
"form.integration.pocket_activate": "Зберігати статті до Pocket",
"form.integration.pocket_consumer_key": "Pocket Consumer Key",
"form.integration.pocket_access_token": "Pocket Access Token",
"form.integration.pocket_connect_link": "Підключити ваш обліковий запис Pocket",
"form.integration.wallabag_activate": "Зберігати статті до Wallabag",
"form.integration.wallabag_only_url": "Надіслати лише URL (замість повного вмісту)",
"form.integration.wallabag_endpoint": "Wallabag API Endpoint",
"form.integration.wallabag_client_id": "Wallabag Client ID",
"form.integration.wallabag_client_secret": "Wallabag Client Secret",
"form.integration.wallabag_username": "Ім’я користувача Wallabag",
"form.integration.wallabag_password": "Пароль Wallabag",
"form.integration.notion_activate": "Save entries to Notion",
"form.integration.notion_page_id": "Notion Page ID",
"form.integration.notion_token": "Notion Secret Token",
"form.integration.apprise_activate": "Push entries to Apprise",
"form.integration.apprise_url": "Apprise API URL",
"form.integration.apprise_services_url": "Comma separated list of Apprise service URLs",
"form.integration.nunux_keeper_activate": "Зберігати статті до Nunux Keeper",
"form.integration.nunux_keeper_endpoint": "Nunux Keeper API Endpoint",
"form.integration.nunux_keeper_api_key": "Ключ API Nunux Keeper",
"form.integration.omnivore_activate": "Зберігати статті до Omnivore",
"form.integration.omnivore_url": "Omnivore API Endpoint",
"form.integration.omnivore_api_key": "Ключ API Omnivore",
"form.integration.espial_activate": "Зберігати статті до Espial",
"form.integration.espial_endpoint": "Espial API Endpoint",
"form.integration.espial_api_key": "Ключ API Espial",
"form.integration.espial_tags": "Теги для Espial",
"form.integration.readwise_activate": "Save entries to Readwise Reader",
"form.integration.readwise_api_key": "Readwise Reader Access Token",
"form.integration.readwise_api_key_link": "Get your Readwise Access Token",
"form.integration.telegram_bot_activate": "Відправляти нові статті до чату Telegram",
"form.integration.telegram_bot_token": "Токен боту",
"form.integration.telegram_topic_id": "Topic ID",
"form.integration.telegram_bot_disable_web_page_preview": "Disable web page preview",
"form.integration.telegram_bot_disable_notification": "Disable notification",
"form.integration.telegram_bot_disable_buttons": "Disable buttons",
"form.integration.telegram_chat_id": "ID чату",
"form.integration.linkding_activate": "Зберігати статті до Linkding",
"form.integration.linkding_endpoint": "Linkding API Endpoint",
"form.integration.linkding_api_key": "Ключ API Linkding",
"form.integration.linkding_tags": "Linkding Tags",
"form.integration.linkding_bookmark": "Відмічати закладку як непрочитану",
"form.integration.matrix_bot_activate": "Перенесення нових статей в Матрицю",
"form.integration.matrix_bot_user": "Ім'я користувача для Matrix",
"form.integration.matrix_bot_password": "Пароль для користувача Matrix",
"form.integration.matrix_bot_url": "URL-адреса сервера Матриці",
"form.integration.matrix_bot_chat_id": "Ідентифікатор кімнати Матриці",
"form.integration.shiori_activate": "Save articles to Shiori",
"form.integration.shiori_endpoint": "Shiori API Endpoint",
"form.integration.shiori_username": "Shiori Username",
"form.integration.shiori_password": "Shiori Password",
"form.integration.shaarli_activate": "Save articles to Shaarli",
"form.integration.shaarli_endpoint": "Shaarli URL",
"form.integration.shaarli_api_secret": "Shaarli API Secret",
"form.integration.webhook_activate": "Enable Webhook",
"form.integration.webhook_url": "Webhook URL",
"form.integration.webhook_secret": "Webhook Secret",
"form.integration.rssbridge_activate": "Check RSS-Bridge when adding subscriptions",
"form.integration.rssbridge_url": "RSS-Bridge server URL",
"form.api_key.label.description": "Назва ключа API",
"form.submit.loading": "Завантаження...",
"form.submit.saving": "Зберігаю...",
"time_elapsed.not_yet": "ще ні",
"time_elapsed.yesterday": "вчора",
"time_elapsed.now": "прямо зараз",
"time_elapsed.minutes": [
"%d хвилину тому",
"%d хвилини тому",
"%d хвилин тому"
],
"time_elapsed.hours": [
"%d годину тому",
"%d години тому",
"%d годин тому"
],
"time_elapsed.days": [
"%d день тому",
"%d дні тому",
"%d днів тому"
],
"time_elapsed.weeks": [
"%d тиждень тому",
"%d тижня тому",
"%d тижнів тому"
],
"time_elapsed.months": [
"%d місяць тому",
"%d місяця тому",
"%d місяців тому"
],
"time_elapsed.years": [
"%d рік тому",
"%d роки тому",
"%d років тому"
],
"alert.too_many_feeds_refresh": "You have triggered too many feed refreshes. Please wait 30 minutes before trying again.",
"alert.background_feed_refresh": "All feeds are being refreshed in the background. You can continue to use Miniflux while this process is running.",
"error.http_response_too_large": "The HTTP response is too large. You could increase the HTTP response size limit in the global settings (requires a server restart).",
"error.http_body_read": "Unable to read the HTTP body: %v.",
"error.http_empty_response_body": "The HTTP response body is empty.",
"error.http_empty_response": "The HTTP response is empty. Perhaps, this website is using a bot protection mechanism?",
"error.tls_error": "TLS error: %v. You could disable TLS verification in the feed settings if you would like.",
"error.network_operation": "Miniflux is not able to reach this website due to a network error: %v.",
"error.network_timeout": "This website is too slow and the request timed out: %v",
"error.http_client_error": "HTTP client error: %v.",
"error.http_not_authorized": "Access to this website is not authorized. It could be a bad username or password.",
"error.http_too_many_requests": "Miniflux generated too many requests to this website. Please, try again later or change the application configuration.",
"error.http_forbidden": "Access to this website is forbidden. Perhaps, this website has a bot protection mechanism?",
"error.http_resource_not_found": "The requested resource is not found. Please, verify the URL.",
"error.http_internal_server_error": "The website is not available at the moment due to a server error. The problem is not on Miniflux side. Please, try again later.",
"error.http_bad_gateway": "The website is not available at the moment due to a bad gateway error. The problem is not on Miniflux side. Please, try again later.",
"error.http_service_unavailable": "The website is not available at the moment due to an internal server error. The problem is not on Miniflux side. Please, try again later.",
"error.http_gateway_timeout": "The website is not available at the moment due to a gateway timeout error. The problem is not on Miniflux side. Please, try again later.",
"error.http_unexpected_status_code": "The website is not available at the moment due to an unexpected HTTP status code: %d. The problem is not on Miniflux side. Please, try again later.",
"error.database_error": "Database error: %v.",
"error.category_not_found": "This category does not exist or does not belong to this user.",
"error.duplicated_feed": "This feed already exists.",
"error.unable_to_parse_feed": "Unable to parse this feed: %v.",
"error.feed_not_found": "This feed does not exist or does not belong to this user.",
"error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.",
"error.feed_format_not_detected": "Unable to detect feed format: %v."
}
miniflux-2.0.51/internal/locale/translations/zh_CN.json 0000664 0000000 0000000 00000064235 14546226260 0023143 0 ustar 00root root 0000000 0000000 {
"confirm.question": "您确认吗?",
"confirm.question.refresh": "您是否要强制刷新?",
"confirm.yes": "是",
"confirm.no": "否",
"confirm.loading": "执行中…",
"action.subscribe": "订阅",
"action.save": "保存",
"action.or": "或",
"action.cancel": "取消",
"action.remove": "删除",
"action.remove_feed": "删除此源",
"action.update": "更新",
"action.edit": "编辑",
"action.download": "下载",
"action.import": "导入",
"action.login": "登录",
"action.home_screen": "添加到主屏幕",
"tooltip.keyboard_shortcuts": "快捷键: %s",
"tooltip.logged_user": "当前登录 %s",
"menu.unread": "未读",
"menu.starred": "收藏",
"menu.history": "历史",
"menu.feeds": "源",
"menu.categories": "分类",
"menu.settings": "设置",
"menu.logout": "登出",
"menu.preferences": "设置",
"menu.integrations": "集成",
"menu.sessions": "会话",
"menu.users": "用户",
"menu.about": "关于",
"menu.export": "导出",
"menu.import": "导入",
"menu.create_category": "新建分类",
"menu.mark_page_as_read": "标记为已读",
"menu.mark_all_as_read": "全部标为已读",
"menu.show_all_entries": "显示所有文章",
"menu.show_only_unread_entries": "仅显示未读文章",
"menu.refresh_feed": "更新",
"menu.refresh_all_feeds": "在后台更新全部源",
"menu.edit_feed": "编辑",
"menu.edit_category": "编辑",
"menu.add_feed": "新增源",
"menu.add_user": "新建用户",
"menu.flush_history": "清理历史",
"menu.feed_entries": "文章",
"menu.api_keys": "API 密钥",
"menu.create_api_key": "创建一个新的 API 密钥",
"menu.shared_entries": "已分享的文章",
"search.label": "搜索",
"search.placeholder": "搜索…",
"pagination.next": "下一页",
"pagination.previous": "上一页",
"entry.status.unread": "标为未读",
"entry.status.read": "标为已读",
"entry.status.toast.unread": "已标为未读",
"entry.status.toast.read": "已标为已读",
"entry.status.title": "更改状态",
"entry.bookmark.toggle.on": "添加收藏",
"entry.bookmark.toggle.off": "取消收藏",
"entry.bookmark.toast.on": "已添加收藏",
"entry.bookmark.toast.off": "已取消收藏",
"entry.state.saving": "保存中…",
"entry.state.loading": "载入中…",
"entry.save.label": "保存",
"entry.save.title": "保存这篇文章",
"entry.save.completed": "完成",
"entry.save.toast.completed": "已保存文章",
"entry.scraper.label": "抓取全文",
"entry.scraper.title": "抓取全文内容",
"entry.scraper.completed": "抓取完成",
"entry.external_link.label": "外部链接",
"entry.comments.label": "评论",
"entry.comments.title": "查看评论",
"entry.share.label": "分享",
"entry.share.title": "分享这篇文章",
"entry.unshare.label": "取消分享",
"entry.shared_entry.title": "打开公共链接",
"entry.shared_entry.label": "分享",
"entry.estimated_reading_time": [
"需要 %d 分钟阅读",
"需要 %d 分钟阅读"
],
"entry.tags.label": "标签:",
"page.shared_entries.title": "已分享的文章",
"page.unread.title": "未读",
"page.starred.title": "收藏",
"page.categories.title": "分类",
"page.categories.no_feed": "没有源",
"page.categories.entries": "查看内容",
"page.categories.feeds": "查看源",
"page.categories.feed_count": [
"有 %d 个源"
],
"page.categories.unread_counter": "未读文章数",
"page.new_category.title": "新分类",
"page.new_user.title": "新用户",
"page.edit_category.title": "编辑分类 : %s",
"page.edit_user.title": "编辑用户 : %s",
"page.feeds.title": "源",
"page.feeds.last_check": "最后检查时间:",
"page.feeds.next_check": "下次检查时间:",
"page.feeds.unread_counter": "未读文章数",
"page.feeds.read_counter": "已读文章数",
"page.feeds.error_count": [
"%d 错误"
],
"page.history.title": "历史",
"page.import.title": "导入",
"page.search.title": "搜索结果",
"page.about.title": "关于",
"page.about.credits": "版权",
"page.about.version": "版本号:",
"page.about.build_date": "构建日期:",
"page.about.author": "作者:",
"page.about.license": "协议:",
"page.about.postgres_version": "Postgres 版本号:",
"page.about.go_version": "Go 版本号:",
"page.about.global_config_options": "全局配置选项",
"page.add_feed.title": "新增源",
"page.add_feed.no_category": "没有类别,至少需要有一个类别",
"page.add_feed.label.url": "网址",
"page.add_feed.submit": "查找源",
"page.add_feed.legend.advanced_options": "高级选项",
"page.add_feed.choose_feed": "选择一个源",
"page.edit_feed.title": "编辑源 : %s",
"page.edit_feed.last_check": "最后检查时间:",
"page.edit_feed.last_modified_header": "最后修改的 Header:",
"page.edit_feed.etag_header": "ETag 标题:",
"page.edit_feed.no_header": "无 Header",
"page.edit_feed.last_parsing_error": "最后一次解析错误",
"page.entry.attachments": "附件",
"page.keyboard_shortcuts.title": "快捷键",
"page.keyboard_shortcuts.subtitle.sections": "分区导航",
"page.keyboard_shortcuts.subtitle.items": "文章导航",
"page.keyboard_shortcuts.subtitle.pages": "页面导航",
"page.keyboard_shortcuts.subtitle.actions": "操作",
"page.keyboard_shortcuts.go_to_unread": "打开未读页面",
"page.keyboard_shortcuts.go_to_starred": "打开收藏页面",
"page.keyboard_shortcuts.go_to_history": "打开历史页面",
"page.keyboard_shortcuts.go_to_feeds": "打开源页面",
"page.keyboard_shortcuts.go_to_categories": "打开分类页面",
"page.keyboard_shortcuts.go_to_settings": "打开设置页面",
"page.keyboard_shortcuts.show_keyboard_shortcuts": "显示快捷键帮助",
"page.keyboard_shortcuts.go_to_previous_item": "上一文章",
"page.keyboard_shortcuts.go_to_next_item": "下一文章",
"page.keyboard_shortcuts.go_to_feed": "转到源页面",
"page.keyboard_shortcuts.go_to_previous_page": "上一页",
"page.keyboard_shortcuts.go_to_next_page": "下一页",
"page.keyboard_shortcuts.open_item": "打开选定的文章",
"page.keyboard_shortcuts.open_original": "打开原始链接",
"page.keyboard_shortcuts.open_original_same_window": "在当前标签页中打开原始链接",
"page.keyboard_shortcuts.open_comments": "打开评论链接",
"page.keyboard_shortcuts.open_comments_same_window": "在当前标签页中打开评论链接",
"page.keyboard_shortcuts.toggle_read_status_next": "切换已读/未读状态, 关注下一个",
"page.keyboard_shortcuts.toggle_read_status_prev": "切换已读/未读状态, 关注前一个",
"page.keyboard_shortcuts.refresh_all_feeds": "在后台更新全部源",
"page.keyboard_shortcuts.mark_page_as_read": "标记当前页已读",
"page.keyboard_shortcuts.download_content": "抓取全文内容",
"page.keyboard_shortcuts.toggle_bookmark_status": "切换收藏状态",
"page.keyboard_shortcuts.save_article": "保存文章",
"page.keyboard_shortcuts.scroll_item_to_top": "滚动到顶部",
"page.keyboard_shortcuts.remove_feed": "删除此源",
"page.keyboard_shortcuts.go_to_search": "将焦点放在搜索表单上",
"page.keyboard_shortcuts.toggle_entry_attachments": "展开/折叠文章附件",
"page.keyboard_shortcuts.close_modal": "关闭对话窗口",
"page.users.title": "用户",
"page.users.username": "用户名",
"page.users.never_logged": "从未登录",
"page.users.admin.yes": "是",
"page.users.admin.no": "否",
"page.users.actions": "操作",
"page.users.last_login": "最后登录时间",
"page.users.is_admin": "管理员",
"page.settings.title": "设置",
"page.settings.link_google_account": "关联我的 Google 账户",
"page.settings.unlink_google_account": "解除 Google 账号关联",
"page.settings.link_oidc_account": "关联我的 OpenID Connect 账户",
"page.settings.unlink_oidc_account": "解除 OpenID Connect 账号关联",
"page.settings.webauthn.passkeys": "Passkeys",
"page.settings.webauthn.actions": "操作",
"page.settings.webauthn.passkey_name": "Passkey 名称",
"page.settings.webauthn.added_on": "添加时间",
"page.settings.webauthn.last_seen_on": "最后使用时间",
"page.settings.webauthn.register": "注册 Passkey",
"page.settings.webauthn.register.error": "无法注册 Passkey",
"page.settings.webauthn.delete": [
"删除 %d 个 Passkey",
"删除 %d 个 Passkey"
],
"page.login.title": "登录",
"page.login.google_signin": "使用 Google 登录",
"page.login.oidc_signin": "使用 OpenID Connect 登录",
"page.login.webauthn_login": "使用密码登录",
"page.login.webauthn_login.error": "无法使用密码登录",
"page.integrations.title": "集成",
"page.integration.miniflux_api": "Miniflux API",
"page.integration.miniflux_api_endpoint": "API 端点",
"page.integration.miniflux_api_username": "用户名",
"page.integration.miniflux_api_password": "密码",
"page.integration.miniflux_api_password_value": "您账户的密码",
"page.integration.bookmarklet": "书签小应用",
"page.integration.bookmarklet.name": "收藏 Miniflux",
"page.integration.bookmarklet.instructions": "拖动这个链接到浏览器书签栏",
"page.integration.bookmarklet.help": "你可以打开这个特殊的书签来直接收藏网站",
"page.sessions.title": "会话",
"page.sessions.table.date": "日期",
"page.sessions.table.ip": "IP 地址",
"page.sessions.table.user_agent": "用户代理",
"page.sessions.table.actions": "操作",
"page.sessions.table.current_session": "当前会话",
"page.api_keys.title": "API 密钥",
"page.api_keys.table.description": "描述",
"page.api_keys.table.token": "密钥",
"page.api_keys.table.last_used_at": "最后使用",
"page.api_keys.table.created_at": "创建日期",
"page.api_keys.table.actions": "操作",
"page.api_keys.never_used": "没用过",
"page.new_api_key.title": "新的 API 密钥",
"page.offline.title": "离线模式",
"page.offline.message": "您已离线",
"page.offline.refresh_page": "尝试刷新页面",
"page.webauthn_rename.title": "重命名 Passkey",
"alert.no_shared_entry": "没有分享文章。",
"alert.no_bookmark": "目前没有收藏",
"alert.no_category": "目前没有分类",
"alert.no_category_entry": "该分类下没有文章",
"alert.no_feed_entry": "该源中没有文章",
"alert.no_feed": "目前没有源",
"alert.no_history": "目前没有历史",
"alert.feed_error": "该源存在问题",
"alert.no_search_result": "该搜索没有结果",
"alert.no_feed_in_category": "没有该类别的源。",
"alert.no_unread_entry": "目前没有未读文章",
"alert.no_user": "您是目前仅有的用户",
"alert.account_unlinked": "您的外部帐户现已解除关联!",
"alert.account_linked": "您的外部账号已关联!",
"alert.pocket_linked": "您的 Pocket 帐户现已关联",
"alert.prefs_saved": "设置已存储!",
"error.unlink_account_without_password": "您必须设置密码,否则您将无法再次登录。",
"error.duplicate_linked_account": "该 Provider 已被关联!",
"error.duplicate_fever_username": "Fever 用户名已被占用!",
"error.duplicate_googlereader_username": "Google Reader 用户名已被占用!",
"error.pocket_request_token": "无法从 Pocket 获取请求令牌!",
"error.pocket_access_token": "无法从 Pocket 获取访问令牌!",
"error.category_already_exists": "分类已存在",
"error.unable_to_create_category": "无法建立这个分类",
"error.unable_to_update_category": "无法更新该分类",
"error.user_already_exists": "用户已存在",
"error.unable_to_create_user": "无法创建此用户",
"error.unable_to_update_user": "无法更新此用户",
"error.unable_to_update_feed": "无法更新此源",
"error.subscription_not_found": "找不到任何源",
"error.empty_file": "该文件为空",
"error.bad_credentials": "用户名或密码无效",
"error.fields_mandatory": "必须填写全部信息",
"error.title_required": "必须填写标题",
"error.different_passwords": "两次输入的密码不同",
"error.password_min_length": "请至少输入 6 个字符",
"error.settings_mandatory_fields": "必须填写用户名、主题、语言以及时区",
"error.entries_per_page_invalid": "每页的文章数无效。",
"error.feed_mandatory_fields": "必须填写网址和分类",
"error.feed_already_exists": "此源已存在。",
"error.invalid_feed_url": "订阅源的网址无效。",
"error.invalid_site_url": "源网站的网址无效。",
"error.feed_url_not_empty": "订阅源的网址不能为空。",
"error.site_url_not_empty": "源网站的网址不能为空。",
"error.feed_title_not_empty": "订阅源的标题不能为空。",
"error.settings_reading_speed_is_positive": "阅读速度必须是正整数。",
"error.feed_category_not_found": "此类别不存在或不属于该用户。",
"error.feed_invalid_blocklist_rule": "阻止列表规则无效。",
"error.feed_invalid_keeplist_rule": "保留列表规则无效。",
"error.user_mandatory_fields": "必须填写用户名",
"error.api_key_already_exists": "此 API 密钥已存在。",
"error.unable_to_create_api_key": "无法创建此 API 密钥。",
"error.invalid_theme": "无效的主题。",
"error.invalid_language": "无效的语言。",
"error.invalid_timezone": "无效的时区。",
"error.invalid_entry_direction": "无效的输入方向。",
"error.invalid_display_mode": "无效的网页应用显示模式。",
"error.invalid_gesture_nav": "手势导航无效。",
"error.invalid_default_home_page": "无效的默认主页!",
"form.feed.label.title": "标题",
"form.feed.label.site_url": "源网站 URL",
"form.feed.label.feed_url": "订阅源 URL",
"form.feed.label.category": "类别",
"form.feed.label.crawler": "抓取全文内容",
"form.feed.label.feed_username": "源用户名",
"form.feed.label.feed_password": "源密码",
"form.feed.label.user_agent": "覆盖默认的用户代理",
"form.feed.label.cookie": "设置 Cookies",
"form.feed.label.scraper_rules": "抓取规则",
"form.feed.label.rewrite_rules": "重写规则",
"form.feed.label.blocklist_rules": "阻止规则",
"form.feed.label.keeplist_rules": "保留规则",
"form.feed.label.urlrewrite_rules": "URL 重写规则",
"form.feed.label.apprise_service_urls": "使用逗号分隔的 Apprise 服务 URL 列表",
"form.feed.label.ignore_http_cache": "忽略 HTTP 缓存",
"form.feed.label.allow_self_signed_certificates": "允许自签名证书或无效证书",
"form.feed.label.fetch_via_proxy": "通过代理获取",
"form.feed.label.disabled": "请勿刷新此源",
"form.feed.label.no_media_player": "没有媒体播放器(音频/视频)",
"form.feed.label.hide_globally": "隐藏全局未读列表中的文章",
"form.feed.fieldset.general": "通用",
"form.feed.fieldset.rules": "规则",
"form.feed.fieldset.network_settings": "网络设置",
"form.feed.fieldset.integration": "第三方服务",
"form.category.label.title": "标题",
"form.category.hide_globally": "隐藏全局未读列表中的文章",
"form.user.label.username": "用户名",
"form.user.label.password": "密码",
"form.user.label.confirmation": "再次输入密码",
"form.user.label.admin": "管理员",
"form.prefs.label.language": "语言",
"form.prefs.label.timezone": "时区",
"form.prefs.label.theme": "主题",
"form.prefs.label.entry_sorting": "文章排序",
"form.prefs.label.entries_per_page": "每页文章数",
"form.prefs.label.display_mode": "渐进式网络应用程序 (PWA) 显示模式",
"form.prefs.label.default_reading_speed": "其他语言的阅读速度(每分钟字数)",
"form.prefs.label.cjk_reading_speed": "中文、韩文和日文的阅读速度(每分钟字符数)",
"form.prefs.select.older_first": "旧->新",
"form.prefs.select.recent_first": "新->旧",
"form.prefs.select.fullscreen": "全屏",
"form.prefs.select.standalone": "独立",
"form.prefs.select.minimal_ui": "最小",
"form.prefs.select.browser": "浏览器",
"form.prefs.select.publish_time": "文章发布时间",
"form.prefs.select.created_time": "文章创建时间",
"form.prefs.select.alphabetical": "按字母顺序",
"form.prefs.select.unread_count": "未读计数",
"form.prefs.select.none": "没有任何",
"form.prefs.select.tap": "双击",
"form.prefs.select.swipe": "滑动",
"form.prefs.label.keyboard_shortcuts": "启用键盘快捷键",
"form.prefs.label.entry_swipe": "在触摸屏上启用输入滑动",
"form.prefs.label.gesture_nav": "在条目之间导航的手势",
"form.prefs.label.show_reading_time": "显示文章的预计阅读时间",
"form.prefs.label.custom_css": "自定义 CSS",
"form.prefs.label.entry_order": "文章排序依据",
"form.prefs.label.default_home_page": "默认主页",
"form.prefs.label.categories_sorting_order": "分类排序",
"form.prefs.label.mark_read_on_view": "查看时自动将条目标记为已读",
"form.prefs.fieldset.application_settings": "应用设置",
"form.prefs.fieldset.authentication_settings": "用户认证设置",
"form.prefs.fieldset.reader_settings": "阅读器设置",
"form.import.label.file": "OPML 文件",
"form.import.label.url": "URL",
"form.integration.fever_activate": "启用 Fever API",
"form.integration.fever_username": "Fever 用户名",
"form.integration.fever_password": "Fever 密码",
"form.integration.fever_endpoint": "Fever API 端点",
"form.integration.googlereader_activate": "启用 Google Reader API",
"form.integration.googlereader_username": "Google Reader 用户名",
"form.integration.googlereader_password": "Google Reader 密码",
"form.integration.googlereader_endpoint": "Google Reader API 端点:",
"form.integration.pinboard_activate": "保存文章到 Pinboard",
"form.integration.pinboard_token": "Pinboard API Token",
"form.integration.pinboard_tags": "Pinboard 标签",
"form.integration.pinboard_bookmark": "标记为未读",
"form.integration.instapaper_activate": "保存文章到 Instapaper",
"form.integration.instapaper_username": "Instapaper 用户名",
"form.integration.instapaper_password": "Instapaper 密码",
"form.integration.pocket_activate": "将文章保存到 Pocket",
"form.integration.pocket_consumer_key": "Pocket 用户密钥",
"form.integration.pocket_access_token": "Pocket 访问密钥",
"form.integration.pocket_connect_link": "连接您的 Pocket 帐户",
"form.integration.wallabag_activate": "保存文章到 Wallabag",
"form.integration.wallabag_only_url": "仅发送 URL(而不是完整内容)",
"form.integration.wallabag_endpoint": "Wallabag URL",
"form.integration.wallabag_client_id": "Wallabag 客户端 ID",
"form.integration.wallabag_client_secret": "Wallabag 客户端 Secret",
"form.integration.wallabag_username": "Wallabag 用户名",
"form.integration.wallabag_password": "Wallabag 密码",
"form.integration.notion_activate": "保存文章到 Notion",
"form.integration.notion_page_id": "Notion Page ID",
"form.integration.notion_token": "Notion Secret Token",
"form.integration.apprise_activate": "将新文章推送到 Apprise",
"form.integration.apprise_url": "Apprise API URL",
"form.integration.apprise_services_url": "使用逗号分隔的 Apprise 服务 URL 列表",
"form.integration.nunux_keeper_activate": "保存文章到 Nunux Keeper",
"form.integration.nunux_keeper_endpoint": "Nunux Keeper API 端点",
"form.integration.nunux_keeper_api_key": "Nunux Keeper API 密钥",
"form.integration.omnivore_activate": "保存文章到 Omnivore",
"form.integration.omnivore_url": "Omnivore API 端点",
"form.integration.omnivore_api_key": "Omnivore API 密钥",
"form.integration.espial_activate": "保存文章到 Espial",
"form.integration.espial_endpoint": "Espial API 端点",
"form.integration.espial_api_key": "Espial API 密钥",
"form.integration.espial_tags": "Espial 标签",
"form.integration.readwise_activate": "保存文章到 Readwise Reader",
"form.integration.readwise_api_key": "Readwise Reader Access Token",
"form.integration.readwise_api_key_link": "获取你的 Readwise Access Token",
"form.integration.telegram_bot_activate": "将新文章推送到 Telegram",
"form.integration.telegram_bot_token": "机器人令牌",
"form.integration.telegram_topic_id": "Topic ID",
"form.integration.telegram_bot_disable_web_page_preview": "禁用网页预览",
"form.integration.telegram_bot_disable_notification": "禁用通知",
"form.integration.telegram_bot_disable_buttons": "不展示按钮",
"form.integration.telegram_chat_id": "聊天ID",
"form.integration.linkding_activate": "保存文章到 Linkding",
"form.integration.linkding_endpoint": "Linkding API 端点",
"form.integration.linkding_api_key": "Linkding API 密钥",
"form.integration.linkding_tags": "Linkding 默认标签",
"form.integration.linkding_bookmark": "标记为未读",
"form.integration.matrix_bot_activate": "将新文章推送到 Matrix",
"form.integration.matrix_bot_user": "Matrix Bot 用户名",
"form.integration.matrix_bot_password": "Matrix Bot 密码",
"form.integration.matrix_bot_url": "Matrix 服务器 URL",
"form.integration.matrix_bot_chat_id": "Matrix 聊天 ID",
"form.integration.shiori_activate": "保存文章到 Shiori",
"form.integration.shiori_endpoint": "Shiori API 端点",
"form.integration.shiori_username": "Shiori 用户名",
"form.integration.shiori_password": "Shiori 密码",
"form.integration.shaarli_activate": "保存文章到 Shaarli",
"form.integration.shaarli_endpoint": "Shaarli URL",
"form.integration.shaarli_api_secret": "Shaarli API Secret",
"form.integration.webhook_activate": "启用 Webhook",
"form.integration.webhook_url": "Webhook URL",
"form.integration.webhook_secret": "Webhook Secret",
"form.integration.rssbridge_activate": "添加订阅时检查 RSS-Bridge",
"form.integration.rssbridge_url": "RSS-Bridge 服务器 URL",
"form.api_key.label.description": "API密钥标签",
"form.submit.loading": "载入中…",
"form.submit.saving": "保存中…",
"time_elapsed.not_yet": "未来",
"time_elapsed.yesterday": "昨天",
"time_elapsed.now": "刚刚",
"time_elapsed.minutes": [
"%d 分钟前"
],
"time_elapsed.hours": [
"%d 小时前"
],
"time_elapsed.days": [
"%d 天前"
],
"time_elapsed.weeks": [
"%d 周前"
],
"time_elapsed.months": [
"%d 月前"
],
"time_elapsed.years": [
"%d 年前"
],
"alert.too_many_feeds_refresh": "You have triggered too many feed refreshes. Please wait 30 minutes before trying again.",
"alert.background_feed_refresh": "All feeds are being refreshed in the background. You can continue to use Miniflux while this process is running.",
"error.http_response_too_large": "The HTTP response is too large. You could increase the HTTP response size limit in the global settings (requires a server restart).",
"error.http_body_read": "Unable to read the HTTP body: %v.",
"error.http_empty_response_body": "The HTTP response body is empty.",
"error.http_empty_response": "The HTTP response is empty. Perhaps, this website is using a bot protection mechanism?",
"error.tls_error": "TLS error: %v. You could disable TLS verification in the feed settings if you would like.",
"error.network_operation": "Miniflux is not able to reach this website due to a network error: %v.",
"error.network_timeout": "This website is too slow and the request timed out: %v",
"error.http_client_error": "HTTP client error: %v.",
"error.http_not_authorized": "Access to this website is not authorized. It could be a bad username or password.",
"error.http_too_many_requests": "Miniflux generated too many requests to this website. Please, try again later or change the application configuration.",
"error.http_forbidden": "Access to this website is forbidden. Perhaps, this website has a bot protection mechanism?",
"error.http_resource_not_found": "The requested resource is not found. Please, verify the URL.",
"error.http_internal_server_error": "The website is not available at the moment due to a server error. The problem is not on Miniflux side. Please, try again later.",
"error.http_bad_gateway": "The website is not available at the moment due to a bad gateway error. The problem is not on Miniflux side. Please, try again later.",
"error.http_service_unavailable": "The website is not available at the moment due to an internal server error. The problem is not on Miniflux side. Please, try again later.",
"error.http_gateway_timeout": "The website is not available at the moment due to a gateway timeout error. The problem is not on Miniflux side. Please, try again later.",
"error.http_unexpected_status_code": "The website is not available at the moment due to an unexpected HTTP status code: %d. The problem is not on Miniflux side. Please, try again later.",
"error.database_error": "Database error: %v.",
"error.category_not_found": "This category does not exist or does not belong to this user.",
"error.duplicated_feed": "This feed already exists.",
"error.unable_to_parse_feed": "Unable to parse this feed: %v.",
"error.feed_not_found": "This feed does not exist or does not belong to this user.",
"error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.",
"error.feed_format_not_detected": "Unable to detect feed format: %v."
}
miniflux-2.0.51/internal/locale/translations/zh_TW.json 0000664 0000000 0000000 00000064751 14546226260 0023200 0 ustar 00root root 0000000 0000000 {
"confirm.question": "您確認嗎?",
"confirm.question.refresh": "您想要強制刷新嗎?",
"confirm.yes": "是",
"confirm.no": "否",
"confirm.loading": "執行中…",
"action.subscribe": "訂閱",
"action.save": "儲存",
"action.or": "或",
"action.cancel": "取消",
"action.remove": "刪除",
"action.remove_feed": "刪除此Feed",
"action.update": "更新",
"action.edit": "編輯",
"action.download": "下載",
"action.import": "匯入",
"action.login": "登入",
"action.home_screen": "新增到主螢幕",
"tooltip.keyboard_shortcuts": "快捷鍵: %s",
"tooltip.logged_user": "當前登入 %s",
"menu.unread": "未讀",
"menu.starred": "收藏",
"menu.history": "歷史",
"menu.feeds": "Feeds",
"menu.categories": "分類",
"menu.settings": "設定",
"menu.logout": "登出",
"menu.preferences": "設定",
"menu.integrations": "整合",
"menu.sessions": "會話",
"menu.users": "使用者",
"menu.about": "關於",
"menu.export": "匯出",
"menu.import": "匯入",
"menu.create_category": "新建分類",
"menu.mark_page_as_read": "將此頁面標記為已讀",
"menu.mark_all_as_read": "全部標為已讀",
"menu.show_all_entries": "顯示所有文章",
"menu.show_only_unread_entries": "僅顯示未讀文章",
"menu.refresh_feed": "更新",
"menu.refresh_all_feeds": "背景更新全部Feeds",
"menu.edit_feed": "編輯",
"menu.edit_category": "編輯",
"menu.add_feed": "新增Feed",
"menu.add_user": "新建使用者",
"menu.flush_history": "清理歷史",
"menu.feed_entries": "文章",
"menu.api_keys": "API 金鑰",
"menu.create_api_key": "建立一個新的 API 金鑰",
"menu.shared_entries": "已分享的文章",
"search.label": "搜尋",
"search.placeholder": "搜尋…",
"pagination.next": "下一頁",
"pagination.previous": "上一頁",
"entry.status.unread": "標為未讀",
"entry.status.read": "標為已讀",
"entry.status.toast.unread": "已標為未讀",
"entry.status.toast.read": "已標為已讀",
"entry.status.title": "更改狀態",
"entry.bookmark.toggle.on": "新增收藏",
"entry.bookmark.toggle.off": "取消收藏",
"entry.bookmark.toast.on": "已新增收藏",
"entry.bookmark.toast.off": "已取消收藏",
"entry.state.saving": "儲存中…",
"entry.state.loading": "載入中…",
"entry.save.label": "儲存",
"entry.save.title": "儲存這篇文章",
"entry.save.completed": "完成",
"entry.save.toast.completed": "已儲存文章",
"entry.scraper.label": "下載原文",
"entry.scraper.title": "下載原文內容",
"entry.scraper.completed": "下載完成",
"entry.external_link.label": "外部連結",
"entry.comments.label": "評論",
"entry.comments.title": "檢視評論",
"entry.share.label": "分享",
"entry.share.title": "分享這篇文章",
"entry.unshare.label": "取消分享",
"entry.shared_entry.title": "開啟公共連結",
"entry.shared_entry.label": "分享",
"entry.estimated_reading_time": [
"需要 %d 分鐘閱讀",
"需要 %d 分鐘閱讀"
],
"entry.tags.label": "標籤:",
"page.shared_entries.title": "已分享的文章",
"page.unread.title": "未讀",
"page.starred.title": "收藏",
"page.categories.title": "分類",
"page.categories.no_feed": "沒有Feed",
"page.categories.entries": "檢視內容",
"page.categories.feeds": "檢視Feeds",
"page.categories.feed_count": [
"有 %d 個Feed",
"有 %d 個Feeds"
],
"page.categories.unread_counter": "未讀文章數",
"page.new_category.title": "新分類",
"page.new_user.title": "新使用者",
"page.edit_category.title": "編輯分類 : %s",
"page.edit_user.title": "編輯使用者 : %s",
"page.feeds.title": "Feeds",
"page.feeds.last_check": "最後檢查時間:",
"page.feeds.next_check": "下次檢查時間:",
"page.feeds.unread_counter": "未讀文章數",
"page.feeds.read_counter": "已讀文章數",
"page.feeds.error_count": [
"%d 錯誤",
"%d 錯誤"
],
"page.history.title": "歷史",
"page.import.title": "匯入",
"page.search.title": "搜尋結果",
"page.about.title": "關於",
"page.about.credits": "版權",
"page.about.version": "版本號:",
"page.about.build_date": "構建日期:",
"page.about.author": "作者:",
"page.about.license": "授權:",
"page.about.postgres_version": "Postgres 版本號:",
"page.about.go_version": "Go 版本號:",
"page.about.global_config_options": "全域性配置選項",
"page.add_feed.title": "新增Feed",
"page.add_feed.no_category": "沒有類別,至少需要有一個類別",
"page.add_feed.label.url": "網址",
"page.add_feed.submit": "查詢Feed",
"page.add_feed.legend.advanced_options": "高階選項",
"page.add_feed.choose_feed": "選擇一個Feed",
"page.edit_feed.title": "編輯Feed : %s",
"page.edit_feed.last_check": "最後檢查時間:",
"page.edit_feed.last_modified_header": "最後修改的 Header:",
"page.edit_feed.etag_header": "ETag 標題:",
"page.edit_feed.no_header": "無 Header",
"page.edit_feed.last_parsing_error": "最後一次解析錯誤",
"page.entry.attachments": "附件",
"page.keyboard_shortcuts.title": "快捷鍵",
"page.keyboard_shortcuts.subtitle.sections": "分割槽導航",
"page.keyboard_shortcuts.subtitle.items": "文章導航",
"page.keyboard_shortcuts.subtitle.pages": "頁面導航",
"page.keyboard_shortcuts.subtitle.actions": "操作",
"page.keyboard_shortcuts.go_to_unread": "開啟未讀頁面",
"page.keyboard_shortcuts.go_to_starred": "開啟收藏頁面",
"page.keyboard_shortcuts.go_to_history": "開啟歷史頁面",
"page.keyboard_shortcuts.go_to_feeds": "開啟Feed頁面",
"page.keyboard_shortcuts.go_to_categories": "開啟分類頁面",
"page.keyboard_shortcuts.go_to_settings": "開啟設定頁面",
"page.keyboard_shortcuts.show_keyboard_shortcuts": "顯示快捷鍵幫助",
"page.keyboard_shortcuts.go_to_previous_item": "上一文章",
"page.keyboard_shortcuts.go_to_next_item": "下一文章",
"page.keyboard_shortcuts.go_to_feed": "轉到Feed頁面",
"page.keyboard_shortcuts.go_to_previous_page": "上一頁",
"page.keyboard_shortcuts.go_to_next_page": "下一頁",
"page.keyboard_shortcuts.open_item": "開啟選定的文章",
"page.keyboard_shortcuts.open_original": "開啟原始連結",
"page.keyboard_shortcuts.open_original_same_window": "在當前標籤頁中開啟原始連結",
"page.keyboard_shortcuts.open_comments": "開啟評論連結",
"page.keyboard_shortcuts.open_comments_same_window": "在當前標籤頁中開啟評論連結",
"page.keyboard_shortcuts.toggle_read_status_next": "切換已讀/未讀狀態, 關注下一個",
"page.keyboard_shortcuts.toggle_read_status_prev": "切換已讀/未讀狀態, 關注前一個",
"page.keyboard_shortcuts.refresh_all_feeds": "背景更新全部Feeds",
"page.keyboard_shortcuts.mark_page_as_read": "將此頁面標記為已讀",
"page.keyboard_shortcuts.download_content": "下載原文內容",
"page.keyboard_shortcuts.toggle_bookmark_status": "切換收藏狀態",
"page.keyboard_shortcuts.save_article": "儲存文章",
"page.keyboard_shortcuts.scroll_item_to_top": "滾動到頂部",
"page.keyboard_shortcuts.remove_feed": "刪除此Feed",
"page.keyboard_shortcuts.go_to_search": "將焦點放在搜尋表單上",
"page.keyboard_shortcuts.toggle_entry_attachments": "展開/折疊文章附件",
"page.keyboard_shortcuts.close_modal": "關閉對話視窗",
"page.users.title": "使用者",
"page.users.username": "使用者名稱",
"page.users.never_logged": "從未登入",
"page.users.admin.yes": "是",
"page.users.admin.no": "否",
"page.users.actions": "操作",
"page.users.last_login": "最後登入時間",
"page.users.is_admin": "管理員",
"page.settings.title": "設定",
"page.settings.link_google_account": "關聯我的 Google 賬戶",
"page.settings.unlink_google_account": "解除 Google 帳號關聯",
"page.settings.link_oidc_account": "關聯我的 OpenID Connect 賬戶",
"page.settings.unlink_oidc_account": "解除 OpenID Connect 帳號關聯",
"page.settings.webauthn.passkeys": "Passkeys",
"page.settings.webauthn.actions": "操作",
"page.settings.webauthn.passkey_name": "Passkey 名稱",
"page.settings.webauthn.added_on": "添加時間",
"page.settings.webauthn.last_seen_on": "最後使用時間",
"page.settings.webauthn.register": "註冊 Passkey",
"page.settings.webauthn.register.error": "無法註冊 Passkey",
"page.settings.webauthn.delete": [
"刪除 %d 個 Passkey",
"刪除 %d 個 Passkey"
],
"page.login.title": "登入",
"page.login.google_signin": "使用 Google 登入",
"page.login.oidc_signin": "使用 OpenID Connect 登入",
"page.login.webauthn_login": "使用密碼登錄",
"page.login.webauthn_login.error": "無法使用密碼登錄",
"page.integrations.title": "整合",
"page.integration.miniflux_api": "Miniflux API",
"page.integration.miniflux_api_endpoint": "API 端點",
"page.integration.miniflux_api_username": "使用者名稱",
"page.integration.miniflux_api_password": "密碼",
"page.integration.miniflux_api_password_value": "您帳號的密碼",
"page.integration.bookmarklet": "書籤小應用",
"page.integration.bookmarklet.name": "收藏 Miniflux",
"page.integration.bookmarklet.instructions": "拖動這個連結到瀏覽器書籤欄",
"page.integration.bookmarklet.help": "你可以開啟這個特殊的書籤來直接收藏網站",
"page.sessions.title": "會話",
"page.sessions.table.date": "日期",
"page.sessions.table.ip": "IP 地址",
"page.sessions.table.user_agent": "使用者代理",
"page.sessions.table.actions": "操作",
"page.sessions.table.current_session": "當前會話",
"page.api_keys.title": "API 金鑰",
"page.api_keys.table.description": "描述",
"page.api_keys.table.token": "金鑰",
"page.api_keys.table.last_used_at": "最後使用",
"page.api_keys.table.created_at": "建立日期",
"page.api_keys.table.actions": "操作",
"page.api_keys.never_used": "沒用過",
"page.new_api_key.title": "新的 API 金鑰",
"page.offline.title": "離線模式",
"page.offline.message": "您已離線",
"page.offline.refresh_page": "嘗試重新整理頁面",
"page.webauthn_rename.title": "重新命名 Passkey",
"alert.no_shared_entry": "沒有分享文章。",
"alert.no_bookmark": "目前沒有收藏",
"alert.no_category": "目前沒有分類",
"alert.no_category_entry": "該分類下沒有文章",
"alert.no_feed_entry": "該Feed中沒有文章",
"alert.no_feed": "目前沒有Feed",
"alert.no_history": "目前沒有歷史",
"alert.feed_error": "該Feed存在問題",
"alert.no_search_result": "該搜尋沒有結果",
"alert.no_feed_in_category": "沒有該類別的Feed。",
"alert.no_unread_entry": "目前沒有未讀文章",
"alert.no_user": "您是唯一的使用者",
"alert.account_unlinked": "您的外部帳戶現已解除關聯!",
"alert.account_linked": "您的外部帳號已關聯!",
"alert.pocket_linked": "您的 Pocket 帳戶現已關聯",
"alert.prefs_saved": "設定已儲存!",
"error.unlink_account_without_password": "您必須設定密碼,否則您將無法再次登入。",
"error.duplicate_linked_account": "該 Provider 已被關聯!",
"error.duplicate_fever_username": "Fever 使用者名稱已被佔用!",
"error.duplicate_googlereader_username": "Google Reader 使用者名稱已被佔用!",
"error.pocket_request_token": "無法從 Pocket 獲取請求令牌!",
"error.pocket_access_token": "無法從 Pocket 獲取訪問令牌!",
"error.category_already_exists": "分類已存在",
"error.unable_to_create_category": "無法建立這個分類",
"error.unable_to_update_category": "無法更新該分類",
"error.user_already_exists": "使用者已存在",
"error.unable_to_create_user": "無法建立此使用者",
"error.unable_to_update_user": "無法更新此使用者",
"error.unable_to_update_feed": "無法更新此源",
"error.subscription_not_found": "找不到任何源",
"error.empty_file": "該檔案為空",
"error.bad_credentials": "使用者名稱或密碼無效",
"error.fields_mandatory": "必須填寫全部資訊",
"error.title_required": "必須填寫標題",
"error.different_passwords": "兩次輸入的密碼不同",
"error.password_min_length": "請至少輸入 6 個字元",
"error.settings_mandatory_fields": "必須填寫使用者名稱、主題、語言以及時區",
"error.settings_reading_speed_is_positive": "閱讀速度必須是正整數。",
"error.entries_per_page_invalid": "每頁的文章數無效。",
"error.feed_mandatory_fields": "必須填寫網址和分類",
"error.feed_already_exists": "此Feed已存在。",
"error.invalid_feed_url": "訂閱Feed的網址無效。",
"error.invalid_site_url": "Feed網站的網址無效。",
"error.feed_url_not_empty": "訂閱Feed的網址不能為空。",
"error.site_url_not_empty": "Feed網站的網址不能為空。",
"error.feed_title_not_empty": "訂閱Feed的標題不能為空。",
"error.feed_category_not_found": "此類別不存在或不屬於該使用者。",
"error.feed_invalid_blocklist_rule": "阻止列表規則無效。",
"error.feed_invalid_keeplist_rule": "保留列表規則無效。",
"error.user_mandatory_fields": "必須填寫使用者名稱",
"error.api_key_already_exists": "此 API 金鑰已存在。",
"error.unable_to_create_api_key": "無法建立此 API 金鑰。",
"error.invalid_theme": "無效的主題。",
"error.invalid_language": "無效的語言。",
"error.invalid_timezone": "無效的時區。",
"error.invalid_entry_direction": "無效的輸入方向。",
"error.invalid_display_mode": "無效的網頁應用顯示模式。",
"error.invalid_gesture_nav": "手勢導航無效.",
"error.invalid_default_home_page": "默認主頁無效!",
"form.feed.label.title": "標題",
"form.feed.label.site_url": "網站 URL",
"form.feed.label.feed_url": "訂閱 Feed URL",
"form.feed.label.category": "類別",
"form.feed.label.crawler": "下載原文內容",
"form.feed.label.feed_username": "Feed 使用者名稱",
"form.feed.label.feed_password": "Feed 密碼",
"form.feed.label.user_agent": "覆蓋預設的使用者代理",
"form.feed.label.cookie": "設定 Cookies",
"form.feed.label.scraper_rules": "抓取規則",
"form.feed.label.rewrite_rules": "重寫規則",
"form.feed.label.blocklist_rules": "過濾規則",
"form.feed.label.keeplist_rules": "保留規則",
"form.feed.label.urlrewrite_rules": "URL 重写规则",
"form.feed.label.apprise_service_urls": "使用逗號分隔的 Apprise 服務 URL 列表",
"form.feed.label.ignore_http_cache": "忽略 HTTP 快取",
"form.feed.label.allow_self_signed_certificates": "允許自簽章憑證或無效憑證",
"form.feed.label.fetch_via_proxy": "透過代理獲取",
"form.feed.label.disabled": "請勿更新此 Feed",
"form.feed.label.no_media_player": "沒有媒體播放器(音訊/視訊)",
"form.feed.label.hide_globally": "隱藏全域性未讀列表中的文章",
"form.feed.fieldset.general": "通用",
"form.feed.fieldset.rules": "規則",
"form.feed.fieldset.network_settings": "網路設定",
"form.feed.fieldset.integration": "第三方服務",
"form.category.label.title": "標題",
"form.category.hide_globally": "隱藏全域性未讀列表中的文章",
"form.user.label.username": "使用者名稱",
"form.user.label.password": "密碼",
"form.user.label.confirmation": "再次輸入密碼",
"form.user.label.admin": "管理員",
"form.prefs.label.language": "語言",
"form.prefs.label.timezone": "時區",
"form.prefs.label.theme": "主題",
"form.prefs.label.entry_sorting": "文章排序",
"form.prefs.label.entries_per_page": "每頁文章數",
"form.prefs.label.default_reading_speed": "其他語言的閱讀速度(每分鐘字)",
"form.prefs.label.cjk_reading_speed": "中文、韓文和日文的閱讀速度(每分鐘字元數)",
"form.prefs.label.display_mode": "漸進式網絡應用程序 (PWA) 顯示模式",
"form.prefs.select.older_first": "舊->新",
"form.prefs.select.recent_first": "新->舊",
"form.prefs.select.fullscreen": "全屏",
"form.prefs.select.standalone": "獨立",
"form.prefs.select.minimal_ui": "最小",
"form.prefs.select.browser": "瀏覽器",
"form.prefs.select.publish_time": "文章發佈時間",
"form.prefs.select.created_time": "文章創建時間",
"form.prefs.select.alphabetical": "按字母順序",
"form.prefs.select.unread_count": "未讀計數",
"form.prefs.select.none": "沒有任何",
"form.prefs.select.tap": "雙擊",
"form.prefs.select.swipe": "滑動",
"form.prefs.label.keyboard_shortcuts": "啟用鍵盤快捷鍵",
"form.prefs.label.entry_swipe": "在触摸屏上启用输入滑动",
"form.prefs.label.gesture_nav": "在條目之間導航的手勢",
"form.prefs.label.show_reading_time": "顯示文章的預計閱讀時間",
"form.prefs.label.custom_css": "自定義 CSS",
"form.prefs.label.entry_order": "文章排序依據",
"form.prefs.label.default_home_page": "默認主頁",
"form.prefs.label.categories_sorting_order": "分類排序",
"form.prefs.label.mark_read_on_view": "查看時自動將條目標記為已讀",
"form.prefs.fieldset.application_settings": "應用程式設定",
"form.prefs.fieldset.authentication_settings": "使用者認證設定",
"form.prefs.fieldset.reader_settings": "閱讀器設定",
"form.import.label.file": "OPML 檔案",
"form.import.label.url": "URL",
"form.integration.fever_activate": "啟用 Fever API",
"form.integration.fever_username": "Fever 使用者名稱",
"form.integration.fever_password": "Fever 密碼",
"form.integration.fever_endpoint": "Fever API 端點",
"form.integration.googlereader_activate": "啟用 Google Reader API",
"form.integration.googlereader_username": "Google Reader 使用者名稱",
"form.integration.googlereader_password": "Google Reader 密碼",
"form.integration.googlereader_endpoint": "Google Reader API 端點:",
"form.integration.pinboard_activate": "儲存文章到 Pinboard",
"form.integration.pinboard_token": "Pinboard API Token",
"form.integration.pinboard_tags": "Pinboard 標籤",
"form.integration.pinboard_bookmark": "標記為未讀",
"form.integration.instapaper_activate": "儲存文章到 Instapaper",
"form.integration.instapaper_username": "Instapaper 使用者名稱",
"form.integration.instapaper_password": "Instapaper 密碼",
"form.integration.pocket_activate": "儲存文章到 Pocket",
"form.integration.pocket_consumer_key": "Pocket 使用者金鑰",
"form.integration.pocket_access_token": "Pocket 訪問金鑰",
"form.integration.pocket_connect_link": "連線您的 Pocket 帳戶",
"form.integration.wallabag_activate": "儲存文章到 Wallabag",
"form.integration.wallabag_only_url": "仅发送 URL(而不是完整内容)",
"form.integration.wallabag_endpoint": "Wallabag URL",
"form.integration.wallabag_client_id": "Wallabag 客戶端 ID",
"form.integration.wallabag_client_secret": "Wallabag 客戶端 Secret",
"form.integration.wallabag_username": "Wallabag 使用者名稱",
"form.integration.wallabag_password": "Wallabag 密碼",
"form.integration.notion_activate": "儲存文章到 Notion",
"form.integration.notion_page_id": "Notion Page ID",
"form.integration.notion_token": "Notion Secret Token",
"form.integration.apprise_activate": "推送文章到 Apprise",
"form.integration.apprise_url": "Apprise API URL",
"form.integration.apprise_services_url": "使用逗號分隔的 Apprise 服務 URL 列表",
"form.integration.nunux_keeper_activate": "儲存文章到 Nunux Keeper",
"form.integration.nunux_keeper_endpoint": "Nunux Keeper API 端點",
"form.integration.nunux_keeper_api_key": "Nunux Keeper API 金鑰",
"form.integration.omnivore_activate": "儲存文章到 Omnivore",
"form.integration.omnivore_url": "Omnivore API 端點",
"form.integration.omnivore_api_key": "Omnivore API 金鑰",
"form.integration.espial_activate": "儲存文章到 Espial",
"form.integration.espial_endpoint": "Espial API 端點",
"form.integration.espial_api_key": "Espial API 金鑰",
"form.integration.espial_tags": "Espial 標籤",
"form.integration.readwise_activate": "儲存文章到 Readwise Reader",
"form.integration.readwise_api_key": "Readwise Reader Access Token",
"form.integration.readwise_api_key_link": "取得你的 Readwise Access Token",
"form.integration.telegram_bot_activate": "推送文章到 Telegram",
"form.integration.telegram_bot_token": "Bot token",
"form.integration.telegram_chat_id": "Chat ID",
"form.integration.telegram_topic_id": "Topic ID",
"form.integration.telegram_bot_disable_web_page_preview": "停用網頁預覽",
"form.integration.telegram_bot_disable_notification": "停用通知",
"form.integration.telegram_bot_disable_buttons": "不展示按鈕",
"form.integration.linkding_activate": "儲存文章到 Linkding",
"form.integration.linkding_endpoint": "Linkding API 端點",
"form.integration.linkding_api_key": "Linkding API 金鑰",
"form.integration.linkding_tags": "Linkding Tags",
"form.integration.linkding_bookmark": "標記為未讀",
"form.integration.matrix_bot_activate": "推送文章到 Matrix",
"form.integration.matrix_bot_user": "Matrix 的用戶名",
"form.integration.matrix_bot_password": "Matrix 的密碼",
"form.integration.matrix_bot_url": "Matrix 伺服器的 URL",
"form.integration.matrix_bot_chat_id": "Matrix 房間 ID",
"form.integration.shiori_activate": "儲存文章到 Shiori",
"form.integration.shiori_endpoint": "Shiori API 端點",
"form.integration.shiori_username": "Shiori 使用者名稱",
"form.integration.shiori_password": "Shiori 密碼",
"form.integration.shaarli_activate": "儲存文章到 Shaarli",
"form.integration.shaarli_endpoint": "Shaarli URL",
"form.integration.shaarli_api_secret": "Shaarli API 金鑰",
"form.integration.webhook_activate": "啟用 Webhook",
"form.integration.webhook_url": "Webhook URL",
"form.integration.webhook_secret": "Webhook Secret",
"form.integration.rssbridge_activate": "新增訂閱時檢查 RSS-Bridge",
"form.integration.rssbridge_url": "RSS-Bridge 伺服器的 URL",
"form.api_key.label.description": "API金鑰標籤",
"form.submit.loading": "載入中…",
"form.submit.saving": "儲存中…",
"time_elapsed.not_yet": "未來",
"time_elapsed.yesterday": "昨天",
"time_elapsed.now": "剛剛",
"time_elapsed.minutes": [
"%d 分鐘前",
"%d 分鐘前"
],
"time_elapsed.hours": [
"%d 小時前",
"%d 小時前"
],
"time_elapsed.days": [
"%d 天前",
"%d 天前"
],
"time_elapsed.weeks": [
"%d 周前",
"%d 周前"
],
"time_elapsed.months": [
"%d 月前",
"%d 月前"
],
"time_elapsed.years": [
"%d 年前",
"%d 年前"
],
"alert.too_many_feeds_refresh": "You have triggered too many feed refreshes. Please wait 30 minutes before trying again.",
"alert.background_feed_refresh": "All feeds are being refreshed in the background. You can continue to use Miniflux while this process is running.",
"error.http_response_too_large": "The HTTP response is too large. You could increase the HTTP response size limit in the global settings (requires a server restart).",
"error.http_body_read": "Unable to read the HTTP body: %v.",
"error.http_empty_response_body": "The HTTP response body is empty.",
"error.http_empty_response": "The HTTP response is empty. Perhaps, this website is using a bot protection mechanism?",
"error.tls_error": "TLS error: %v. You could disable TLS verification in the feed settings if you would like.",
"error.network_operation": "Miniflux is not able to reach this website due to a network error: %v.",
"error.network_timeout": "This website is too slow and the request timed out: %v",
"error.http_client_error": "HTTP client error: %v.",
"error.http_not_authorized": "Access to this website is not authorized. It could be a bad username or password.",
"error.http_too_many_requests": "Miniflux generated too many requests to this website. Please, try again later or change the application configuration.",
"error.http_forbidden": "Access to this website is forbidden. Perhaps, this website has a bot protection mechanism?",
"error.http_resource_not_found": "The requested resource is not found. Please, verify the URL.",
"error.http_internal_server_error": "The website is not available at the moment due to a server error. The problem is not on Miniflux side. Please, try again later.",
"error.http_bad_gateway": "The website is not available at the moment due to a bad gateway error. The problem is not on Miniflux side. Please, try again later.",
"error.http_service_unavailable": "The website is not available at the moment due to an internal server error. The problem is not on Miniflux side. Please, try again later.",
"error.http_gateway_timeout": "The website is not available at the moment due to a gateway timeout error. The problem is not on Miniflux side. Please, try again later.",
"error.http_unexpected_status_code": "The website is not available at the moment due to an unexpected HTTP status code: %d. The problem is not on Miniflux side. Please, try again later.",
"error.database_error": "Database error: %v.",
"error.category_not_found": "This category does not exist or does not belong to this user.",
"error.duplicated_feed": "This feed already exists.",
"error.unable_to_parse_feed": "Unable to parse this feed: %v.",
"error.feed_not_found": "This feed does not exist or does not belong to this user.",
"error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.",
"error.feed_format_not_detected": "Unable to detect feed format: %v."
}
miniflux-2.0.51/internal/metric/ 0000775 0000000 0000000 00000000000 14546226260 0016540 5 ustar 00root root 0000000 0000000 miniflux-2.0.51/internal/metric/metric.go 0000664 0000000 0000000 00000012755 14546226260 0020364 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 int
}
// NewCollector initializes a new metric collector.
func NewCollector(store *storage.Storage, refreshInterval int) *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(time.Duration(c.refreshInterval) * time.Second) {
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))
}
}
miniflux-2.0.51/internal/model/ 0000775 0000000 0000000 00000000000 14546226260 0016355 5 ustar 00root root 0000000 0000000 miniflux-2.0.51/internal/model/api_key.go 0000664 0000000 0000000 00000001316 14546226260 0020326 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/crypto"
)
// APIKey represents an application API key.
type APIKey struct {
ID int64
UserID int64
Token string
Description string
LastUsedAt *time.Time
CreatedAt time.Time
}
// NewAPIKey initializes a new APIKey.
func NewAPIKey(userID int64, description string) *APIKey {
return &APIKey{
UserID: userID,
Token: crypto.GenerateRandomString(32),
Description: description,
}
}
// APIKeys represents a collection of API Key.
type APIKeys []*APIKey
miniflux-2.0.51/internal/model/app_session.go 0000664 0000000 0000000 00000003705 14546226260 0021234 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"`
PocketRequestToken string `json:"pocket_request_token"`
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, PocketTkn=%q, LastForceRefresh=%s, WebAuthnSession=%q`,
s.CSRF,
s.OAuth2State,
s.OAuth2CodeVerifier,
s.FlashMessage,
s.FlashErrorMessage,
s.Language,
s.Theme,
s.PocketRequestToken,
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 interface{}) 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="%s", Data={%v}`, s.ID, s.Data)
}
miniflux-2.0.51/internal/model/categories_sort_options.go 0000664 0000000 0000000 00000000556 14546226260 0023661 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",
}
}
miniflux-2.0.51/internal/model/category.go 0000664 0000000 0000000 00000002040 14546226260 0020515 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"`
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)
}
// CategoryRequest represents the request to create or update a category.
type CategoryRequest struct {
Title string `json:"title"`
HideGlobally string `json:"hide_globally"`
}
// Patch updates category fields.
func (cr *CategoryRequest) Patch(category *Category) {
category.Title = cr.Title
category.HideGlobally = cr.HideGlobally != ""
}
// Categories represents a list of categories.
type Categories []*Category
miniflux-2.0.51/internal/model/enclosure.go 0000664 0000000 0000000 00000002327 14546226260 0020707 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"
// 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"`
}
// Html5MimeType will modify the actual MimeType to allow direct playback from HTML5 player for some kind of MimeType
func (e Enclosure) Html5MimeType() string {
if strings.HasPrefix(e.MimeType, "video") {
switch e.MimeType {
// Solution from this stackoverflow discussion:
// https://stackoverflow.com/questions/15277147/m4v-mimetype-video-mp4-or-video-m4v/66945470#66945470
// tested at the time of this commit (06/2023) on latest Firefox & Vivaldi on this feed
// https://www.florenceporcel.com/podcast/lfhdu.xml
case "video/m4v":
return "video/x-m4v"
}
}
return e.MimeType
}
// EnclosureList represents a list of attachments.
type EnclosureList []*Enclosure
miniflux-2.0.51/internal/model/enclosure_test.go 0000664 0000000 0000000 00000002257 14546226260 0021750 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package model
import (
"testing"
)
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 brownser. Got '%s'",
enclosure.Html5MimeType(),
)
}
}
miniflux-2.0.51/internal/model/entry.go 0000664 0000000 0000000 00000004164 14546226260 0020052 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{},
},
}
}
// 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
}
}
miniflux-2.0.51/internal/model/feed.go 0000664 0000000 0000000 00000023434 14546226260 0017615 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"
"math"
"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"`
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"`
Crawler bool `json:"crawler"`
BlocklistRules string `json:"blocklist_rules"`
KeeplistRules string `json:"keeplist_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"`
AppriseServiceURLs string `json:"apprise_service_urls"`
// Non persisted attributes
Category *Category `json:"category,omitempty"`
Icon *FeedIcon `json:"icon"`
Entries Entries `json:"entries,omitempty"`
TTL int `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, newTTL int) {
f.TTL = newTTL
// Default to the global config Polling Frequency.
var intervalMinutes int
switch config.Opts.PollingScheduler() {
case SchedulerEntryFrequency:
if weeklyCount <= 0 {
intervalMinutes = config.Opts.SchedulerEntryFrequencyMaxInterval()
} else {
intervalMinutes = int(math.Round(float64(7*24*60) / float64(weeklyCount*config.Opts.SchedulerEntryFrequencyFactor())))
intervalMinutes = int(math.Min(float64(intervalMinutes), float64(config.Opts.SchedulerEntryFrequencyMaxInterval())))
intervalMinutes = int(math.Max(float64(intervalMinutes), float64(config.Opts.SchedulerEntryFrequencyMinInterval())))
}
default:
intervalMinutes = config.Opts.SchedulerRoundRobinMinInterval()
}
// If the feed has a TTL defined, we use it to make sure we don't check it too often.
if newTTL > intervalMinutes && newTTL > 0 {
intervalMinutes = newTTL
}
f.NextCheckAt = time.Now().Add(time.Minute * time.Duration(intervalMinutes))
}
// 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"`
ScraperRules string `json:"scraper_rules"`
RewriteRules string `json:"rewrite_rules"`
BlocklistRules string `json:"blocklist_rules"`
KeeplistRules string `json:"keeplist_rules"`
HideGlobally bool `json:"hide_globally"`
UrlRewriteRules string `json:"urlrewrite_rules"`
}
type FeedCreationRequestFromSubscriptionDiscovery struct {
Content io.ReadSeeker
ETag string
LastModified string
FeedURL string `json:"feed_url"`
CategoryID int64 `json:"category_id"`
UserAgent string `json:"user_agent"`
Cookie string `json:"cookie"`
Username string `json:"username"`
Password string `json:"password"`
Crawler bool `json:"crawler"`
Disabled bool `json:"disabled"`
NoMediaPlayer bool `json:"no_media_player"`
IgnoreHTTPCache bool `json:"ignore_http_cache"`
AllowSelfSignedCertificates bool `json:"allow_self_signed_certificates"`
FetchViaProxy bool `json:"fetch_via_proxy"`
ScraperRules string `json:"scraper_rules"`
RewriteRules string `json:"rewrite_rules"`
BlocklistRules string `json:"blocklist_rules"`
KeeplistRules string `json:"keeplist_rules"`
HideGlobally bool `json:"hide_globally"`
UrlRewriteRules string `json:"urlrewrite_rules"`
}
// 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"`
BlocklistRules *string `json:"blocklist_rules"`
KeeplistRules *string `json:"keeplist_rules"`
UrlRewriteRules *string `json:"urlrewrite_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"`
}
// 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.ScraperRules != nil {
feed.ScraperRules = *f.ScraperRules
}
if f.RewriteRules != nil {
feed.RewriteRules = *f.RewriteRules
}
if f.KeeplistRules != nil {
feed.KeeplistRules = *f.KeeplistRules
}
if f.UrlRewriteRules != nil {
feed.UrlRewriteRules = *f.UrlRewriteRules
}
if f.BlocklistRules != nil {
feed.BlocklistRules = *f.BlocklistRules
}
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
}
}
// Feeds is a list of feed
type Feeds []*Feed
miniflux-2.0.51/internal/model/feed_test.go 0000664 0000000 0000000 00000021232 14546226260 0020646 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"
"os"
"testing"
"time"
"miniflux.app/v2/internal/config"
)
const (
largeWeeklyCount = 10080
noNewTTL = 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 int, timeBefore time.Time, message string) {
if feed.NextCheckAt.Before(timeBefore.Add(time.Minute * time.Duration(targetInterval))) {
t.Errorf(`The next_check_at should be after timeBefore + %s`, message)
}
if feed.NextCheckAt.After(time.Now().Add(time.Minute * time.Duration(targetInterval))) {
t.Errorf(`The next_check_at should be before now + %s`, message)
}
}
func TestFeedScheduleNextCheckDefault(t *testing.T) {
os.Clearenv()
var err error
parser := config.NewParser()
config.Opts, err = parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
timeBefore := time.Now()
feed := &Feed{}
weeklyCount := 10
feed.ScheduleNextCheck(weeklyCount, noNewTTL)
if feed.NextCheckAt.IsZero() {
t.Error(`The next_check_at must be set`)
}
targetInterval := config.Opts.SchedulerRoundRobinMinInterval()
checkTargetInterval(t, feed, targetInterval, timeBefore, "default SchedulerRoundRobinMinInterval")
}
func TestFeedScheduleNextCheckRoundRobinMinInterval(t *testing.T) {
minInterval := 1
os.Clearenv()
os.Setenv("POLLING_SCHEDULER", "round_robin")
os.Setenv("SCHEDULER_ROUND_ROBIN_MIN_INTERVAL", fmt.Sprintf("%d", minInterval))
var err error
parser := config.NewParser()
config.Opts, err = parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
timeBefore := time.Now()
feed := &Feed{}
weeklyCount := 100
feed.ScheduleNextCheck(weeklyCount, noNewTTL)
if feed.NextCheckAt.IsZero() {
t.Error(`The next_check_at must be set`)
}
targetInterval := minInterval
checkTargetInterval(t, feed, targetInterval, timeBefore, "round robin min interval")
}
func TestFeedScheduleNextCheckEntryFrequencyMaxInterval(t *testing.T) {
maxInterval := 5
minInterval := 1
os.Clearenv()
os.Setenv("POLLING_SCHEDULER", "entry_frequency")
os.Setenv("SCHEDULER_ENTRY_FREQUENCY_MAX_INTERVAL", fmt.Sprintf("%d", maxInterval))
os.Setenv("SCHEDULER_ENTRY_FREQUENCY_MIN_INTERVAL", fmt.Sprintf("%d", minInterval))
var err error
parser := config.NewParser()
config.Opts, err = parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
timeBefore := time.Now()
feed := &Feed{}
// Use a very small weekly count to trigger the max interval
weeklyCount := 1
feed.ScheduleNextCheck(weeklyCount, noNewTTL)
if feed.NextCheckAt.IsZero() {
t.Error(`The next_check_at must be set`)
}
targetInterval := maxInterval
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", fmt.Sprintf("%d", maxInterval))
os.Setenv("SCHEDULER_ENTRY_FREQUENCY_MIN_INTERVAL", fmt.Sprintf("%d", minInterval))
var err error
parser := config.NewParser()
config.Opts, err = parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
timeBefore := time.Now()
feed := &Feed{}
// Use a very small weekly count to trigger the max interval
weeklyCount := 0
feed.ScheduleNextCheck(weeklyCount, noNewTTL)
if feed.NextCheckAt.IsZero() {
t.Error(`The next_check_at must be set`)
}
targetInterval := maxInterval
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", fmt.Sprintf("%d", maxInterval))
os.Setenv("SCHEDULER_ENTRY_FREQUENCY_MIN_INTERVAL", fmt.Sprintf("%d", minInterval))
var err error
parser := config.NewParser()
config.Opts, err = parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
timeBefore := time.Now()
feed := &Feed{}
// Use a very large weekly count to trigger the min interval
weeklyCount := largeWeeklyCount
feed.ScheduleNextCheck(weeklyCount, noNewTTL)
if feed.NextCheckAt.IsZero() {
t.Error(`The next_check_at must be set`)
}
targetInterval := minInterval
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", fmt.Sprintf("%d", factor))
var err error
parser := config.NewParser()
config.Opts, err = parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
timeBefore := time.Now()
feed := &Feed{}
weeklyCount := 7
feed.ScheduleNextCheck(weeklyCount, noNewTTL)
if feed.NextCheckAt.IsZero() {
t.Error(`The next_check_at must be set`)
}
targetInterval := config.Opts.SchedulerEntryFrequencyMaxInterval() / 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", fmt.Sprintf("%d", maxInterval))
os.Setenv("SCHEDULER_ENTRY_FREQUENCY_MIN_INTERVAL", fmt.Sprintf("%d", minInterval))
var err error
parser := config.NewParser()
config.Opts, err = parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
timeBefore := time.Now()
feed := &Feed{}
// Use a very large weekly count to trigger the min interval
weeklyCount := largeWeeklyCount
// TTL is smaller than minInterval.
newTTL := minInterval / 2
feed.ScheduleNextCheck(weeklyCount, newTTL)
if feed.NextCheckAt.IsZero() {
t.Error(`The next_check_at must be set`)
}
targetInterval := minInterval
checkTargetInterval(t, feed, targetInterval, timeBefore, "entry frequency min interval")
if feed.NextCheckAt.Before(timeBefore.Add(time.Minute * time.Duration(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", fmt.Sprintf("%d", maxInterval))
os.Setenv("SCHEDULER_ENTRY_FREQUENCY_MIN_INTERVAL", fmt.Sprintf("%d", minInterval))
var err error
parser := config.NewParser()
config.Opts, err = parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
timeBefore := time.Now()
feed := &Feed{}
// Use a very large weekly count to trigger the min interval
weeklyCount := largeWeeklyCount
// TTL is larger than minInterval.
newTTL := minInterval * 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`)
}
}
miniflux-2.0.51/internal/model/home_page.go 0000664 0000000 0000000 00000000716 14546226260 0020634 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",
}
}
miniflux-2.0.51/internal/model/icon.go 0000664 0000000 0000000 00000001417 14546226260 0017637 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"
"fmt"
)
// 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:"-"`
}
// DataURL returns the data URL of the icon.
func (i *Icon) DataURL() string {
return fmt.Sprintf("%s;base64,%s", i.MimeType, 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"`
}
miniflux-2.0.51/internal/model/integration.go 0000664 0000000 0000000 00000006057 14546226260 0021237 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
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
NunuxKeeperEnabled bool
NunuxKeeperURL string
NunuxKeeperAPIKey string
NotionEnabled bool
NotionToken string
NotionPageID string
EspialEnabled bool
EspialURL string
EspialAPIKey string
EspialTags string
ReadwiseEnabled bool
ReadwiseAPIKey string
PocketEnabled bool
PocketAccessToken string
PocketConsumerKey string
TelegramBotEnabled bool
TelegramBotToken string
TelegramBotChatID string
TelegramBotTopicID *int64
TelegramBotDisableWebPagePreview bool
TelegramBotDisableNotification bool
TelegramBotDisableButtons bool
LinkdingEnabled bool
LinkdingURL string
LinkdingAPIKey string
LinkdingTags string
LinkdingMarkAsUnread bool
MatrixBotEnabled bool
MatrixBotUser string
MatrixBotPassword string
MatrixBotURL string
MatrixBotChatID string
AppriseEnabled bool
AppriseURL string
AppriseServicesURL string
ShioriEnabled bool
ShioriURL string
ShioriUsername string
ShioriPassword string
ShaarliEnabled bool
ShaarliURL string
ShaarliAPISecret string
WebhookEnabled bool
WebhookURL string
WebhookSecret string
RSSBridgeEnabled bool
RSSBridgeURL string
OmnivoreEnabled bool
OmnivoreAPIKey string
OmnivoreURL string
}
miniflux-2.0.51/internal/model/job.go 0000664 0000000 0000000 00000000526 14546226260 0017461 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
}
// JobList represents a list of jobs.
type JobList []Job
miniflux-2.0.51/internal/model/model.go 0000664 0000000 0000000 00000001133 14546226260 0020002 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"
// OptionalString populates an optional string field.
func OptionalString(value string) *string {
if value != "" {
return &value
}
return nil
}
// OptionalInt populates an optional int field.
func OptionalInt(value int) *int {
if value > 0 {
return &value
}
return nil
}
// OptionalInt64 populates an optional int64 field.
func OptionalInt64(value int64) *int64 {
if value > 0 {
return &value
}
return nil
}
miniflux-2.0.51/internal/model/subscription.go 0000664 0000000 0000000 00000001301 14546226260 0021423 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"`
FetchViaProxy bool `json:"fetch_via_proxy"`
AllowSelfSignedCertificates bool `json:"allow_self_signed_certificates"`
}
miniflux-2.0.51/internal/model/theme.go 0000664 0000000 0000000 00000002133 14546226260 0020005 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"
}
}
miniflux-2.0.51/internal/model/user.go 0000664 0000000 0000000 00000012466 14546226260 0017673 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"`
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"`
}
// 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"`
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"`
}
// 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.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
}
}
// 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)
}
}
miniflux-2.0.51/internal/model/user_session.go 0000664 0000000 0000000 00000001751 14546226260 0021431 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="%d", UserID="%d", IP="%s", Token="%s"`, 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)
}
// UserSessions represents a list of sessions.
type UserSessions []*UserSession
// UseTimezone converts creation date of all sessions to the given timezone.
func (u UserSessions) UseTimezone(tz string) {
for _, session := range u {
session.UseTimezone(tz)
}
}
miniflux-2.0.51/internal/model/webauthn.go 0000664 0000000 0000000 00000002144 14546226260 0020522 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"
)
// handle 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 interface{}) 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.SessionData.Challenge, s.SessionData.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)
}
miniflux-2.0.51/internal/oauth2/ 0000775 0000000 0000000 00000000000 14546226260 0016457 5 ustar 00root root 0000000 0000000 miniflux-2.0.51/internal/oauth2/authorization.go 0000664 0000000 0000000 00000002177 14546226260 0021715 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"
"io"
"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)
sha2 := sha256.New()
io.WriteString(sha2, codeVerifier)
codeChallenge := base64.RawURLEncoding.EncodeToString(sha2.Sum(nil))
state := crypto.GenerateRandomStringHex(24)
authUrl := config.AuthCodeURL(
state,
oauth2.SetAuthURLParam("code_challenge_method", "S256"),
oauth2.SetAuthURLParam("code_challenge", codeChallenge),
)
return &Authorization{
url: authUrl,
state: state,
codeVerifier: codeVerifier,
}
}
miniflux-2.0.51/internal/oauth2/google.go 0000664 0000000 0000000 00000004347 14546226260 0020272 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 = ""
}
miniflux-2.0.51/internal/oauth2/manager.go 0000664 0000000 0000000 00000002175 14546226260 0020425 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)
}
}
return m
}
miniflux-2.0.51/internal/oauth2/oidc.go 0000664 0000000 0000000 00000005455 14546226260 0017735 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)
}
for _, value := range []string{userClaims.Email, userClaims.PreferredUsername, 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"`
}
miniflux-2.0.51/internal/oauth2/profile.go 0000664 0000000 0000000 00000000654 14546226260 0020453 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)
}
miniflux-2.0.51/internal/oauth2/provider.go 0000664 0000000 0000000 00000001223 14546226260 0020636 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)
}
miniflux-2.0.51/internal/proxy/ 0000775 0000000 0000000 00000000000 14546226260 0016436 5 ustar 00root root 0000000 0000000 miniflux-2.0.51/internal/proxy/media_proxy.go 0000664 0000000 0000000 00000007663 14546226260 0021321 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package proxy // import "miniflux.app/v2/internal/proxy"
import (
"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
// ProxyRewriter replaces media URLs with internal proxy URLs.
func ProxyRewriter(router *mux.Router, data string) string {
return genericProxyRewriter(router, ProxifyURL, data)
}
// AbsoluteProxyRewriter do the same as ProxyRewriter except it uses absolute URLs.
func AbsoluteProxyRewriter(router *mux.Router, host, data string) string {
proxifyFunction := func(router *mux.Router, url string) string {
return AbsoluteProxifyURL(router, host, url)
}
return genericProxyRewriter(router, proxifyFunction, data)
}
func genericProxyRewriter(router *mux.Router, proxifyFunction urlProxyRewriter, data string) string {
proxyOption := config.Opts.ProxyOption()
if proxyOption == "none" {
return data
}
doc, err := goquery.NewDocumentFromReader(strings.NewReader(data))
if err != nil {
return data
}
for _, mediaType := range config.Opts.ProxyMediaTypes() {
switch mediaType {
case "image":
doc.Find("img").Each(func(i int, img *goquery.Selection) {
if srcAttrValue, ok := img.Attr("src"); ok {
if !isDataURL(srcAttrValue) && (proxyOption == "all" || !urllib.IsHTTPS(srcAttrValue)) {
img.SetAttr("src", proxifyFunction(router, srcAttrValue))
}
}
if srcsetAttrValue, ok := img.Attr("srcset"); ok {
proxifySourceSet(img, router, proxifyFunction, proxyOption, srcsetAttrValue)
}
})
doc.Find("picture source").Each(func(i int, sourceElement *goquery.Selection) {
if srcsetAttrValue, ok := sourceElement.Attr("srcset"); ok {
proxifySourceSet(sourceElement, router, proxifyFunction, proxyOption, srcsetAttrValue)
}
})
case "audio":
doc.Find("audio").Each(func(i int, audio *goquery.Selection) {
if srcAttrValue, ok := audio.Attr("src"); ok {
if !isDataURL(srcAttrValue) && (proxyOption == "all" || !urllib.IsHTTPS(srcAttrValue)) {
audio.SetAttr("src", proxifyFunction(router, srcAttrValue))
}
}
})
doc.Find("audio source").Each(func(i int, sourceElement *goquery.Selection) {
if srcAttrValue, ok := sourceElement.Attr("src"); ok {
if !isDataURL(srcAttrValue) && (proxyOption == "all" || !urllib.IsHTTPS(srcAttrValue)) {
sourceElement.SetAttr("src", proxifyFunction(router, srcAttrValue))
}
}
})
case "video":
doc.Find("video").Each(func(i int, video *goquery.Selection) {
if srcAttrValue, ok := video.Attr("src"); ok {
if !isDataURL(srcAttrValue) && (proxyOption == "all" || !urllib.IsHTTPS(srcAttrValue)) {
video.SetAttr("src", proxifyFunction(router, srcAttrValue))
}
}
})
doc.Find("video source").Each(func(i int, sourceElement *goquery.Selection) {
if srcAttrValue, ok := sourceElement.Attr("src"); ok {
if !isDataURL(srcAttrValue) && (proxyOption == "all" || !urllib.IsHTTPS(srcAttrValue)) {
sourceElement.SetAttr("src", proxifyFunction(router, srcAttrValue))
}
}
})
}
}
output, err := doc.Find("body").First().Html()
if err != nil {
return data
}
return output
}
func proxifySourceSet(element *goquery.Selection, router *mux.Router, proxifyFunction urlProxyRewriter, proxyOption, srcsetAttrValue string) {
imageCandidates := sanitizer.ParseSrcSetAttribute(srcsetAttrValue)
for _, imageCandidate := range imageCandidates {
if !isDataURL(imageCandidate.ImageURL) && (proxyOption == "all" || !urllib.IsHTTPS(imageCandidate.ImageURL)) {
imageCandidate.ImageURL = proxifyFunction(router, imageCandidate.ImageURL)
}
}
element.SetAttr("srcset", imageCandidates.String())
}
func isDataURL(s string) bool {
return strings.HasPrefix(s, "data:")
}
miniflux-2.0.51/internal/proxy/media_proxy_test.go 0000664 0000000 0000000 00000030526 14546226260 0022352 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package proxy // import "miniflux.app/v2/internal/proxy"
import (
"net/http"
"os"
"testing"
"github.com/gorilla/mux"
"miniflux.app/v2/internal/config"
)
func TestProxyFilterWithHttpDefault(t *testing.T) {
os.Clearenv()
os.Setenv("PROXY_OPTION", "http-only")
os.Setenv("PROXY_MEDIA_TYPES", "image")
os.Setenv("PROXY_PRIVATE_KEY", "test")
var err error
parser := config.NewParser()
config.Opts, err = parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
r := mux.NewRouter()
r.HandleFunc("/proxy/{encodedDigest}/{encodedURL}", func(w http.ResponseWriter, r *http.Request) {}).Name("proxy")
input := `
`
output := ProxyRewriter(r, input)
expected := `
`
if expected != output {
t.Errorf(`Not expected output: got "%s" instead of "%s"`, output, expected)
}
}
func TestProxyFilterWithHttpsDefault(t *testing.T) {
os.Clearenv()
os.Setenv("PROXY_OPTION", "http-only")
os.Setenv("PROXY_MEDIA_TYPES", "image")
var err error
parser := config.NewParser()
config.Opts, err = parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
r := mux.NewRouter()
r.HandleFunc("/proxy/{encodedDigest}/{encodedURL}", func(w http.ResponseWriter, r *http.Request) {}).Name("proxy")
input := `
`
output := ProxyRewriter(r, input)
expected := `
`
if expected != output {
t.Errorf(`Not expected output: got "%s" instead of "%s"`, output, expected)
}
}
func TestProxyFilterWithHttpNever(t *testing.T) {
os.Clearenv()
os.Setenv("PROXY_OPTION", "none")
var err error
parser := config.NewParser()
config.Opts, err = parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
r := mux.NewRouter()
r.HandleFunc("/proxy/{encodedDigest}/{encodedURL}", func(w http.ResponseWriter, r *http.Request) {}).Name("proxy")
input := `