Compare commits
185 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c166f80b58 | |||
| 075127c56f | |||
| 34fcf0e54f | |||
| 7f4a1094f4 | |||
| 7846ddb3d7 | |||
| 6ff4f5ac67 | |||
| bf738d4b7f | |||
| 7242890d96 | |||
| b624cfd165 | |||
| bce1697c95 | |||
| 14462c4b2e | |||
| 3ae595ea1c | |||
| 4d76de1722 | |||
| 171ed7d2f6 | |||
| d9a97e268d | |||
| 365f2e6ee7 | |||
| 210993e146 | |||
| fe2bfb27cf | |||
| f9b756ecf8 | |||
| e8b2785329 | |||
| df7fc1e853 | |||
| 26824211aa | |||
| 0e13773d25 | |||
| 8d01ca3637 | |||
| 8b84a86f0c | |||
| be49cba34b | |||
| d7e049f187 | |||
| 4d7e8b2796 | |||
| 7f7f23d33d | |||
| 01888d8e32 | |||
| 223d29e5d9 | |||
| d58cb5b72c | |||
| 313f23fd65 | |||
| 54df471d0e | |||
| 72784c4943 | |||
| 7fa38aafe7 | |||
| fb7f16ecf2 | |||
| 8898c42f24 | |||
| 0054314355 | |||
| 28441d1e27 | |||
| 8d19529948 | |||
| 6838f3a6e9 | |||
| 8483f06595 | |||
| da15700205 | |||
| cd6236b1cd | |||
| 7b07b8256b | |||
| d3a1e7dbec | |||
| 7edf42fe18 | |||
| 5513827cdd | |||
| 052ad27e38 | |||
| 96c45874f5 | |||
| 3e022fb86e | |||
| aa814491f6 | |||
| 90c2199f0f | |||
| d40e523d25 | |||
| 46e77eccb0 | |||
| f19fc2ff53 | |||
| 0201c80db1 | |||
| 0022d0646b | |||
| 253119ed5f | |||
| 2619eba43c | |||
| 31f4f366a9 | |||
| fc9cb9e5d5 | |||
| 9b859e7f3f | |||
| 6f3c92db74 | |||
| 0dcc0a852b | |||
| 7b65255757 | |||
| 6a9d7894d1 | |||
| f3d0e0378b | |||
| 4d27f66ff9 | |||
| 5a8431b144 | |||
| 9e760e14cd | |||
| 879e40670c | |||
| bf24fd1ec9 | |||
| b57ebac757 | |||
| acfb7dc213 | |||
| 62f316c7cf | |||
| 89638b448a | |||
| d8181b1f65 | |||
| ce6cd0b2b6 | |||
| ba05c96d88 | |||
| 58674b9277 | |||
| 6b1f208209 | |||
| 7c41128d88 | |||
| 3aea68e725 | |||
| a5fb91e3e8 | |||
| 8357e6decf | |||
| ad4ca71f68 | |||
| 660c79bbdd | |||
| 2fa8995a35 | |||
| ffed2b4fa1 | |||
| 9fc69fd2e0 | |||
| cddd2eb168 | |||
| 33b780d9c0 | |||
| 5d8540b324 | |||
| a2a5244387 | |||
| 3a232d0c8d | |||
| 5a8df8abf0 | |||
| a4883ca11e | |||
| 4cd66aef5f | |||
| 0ab3bb5efb | |||
| 71c551ffe0 | |||
| bb05b25530 | |||
| 7b7fd48e15 | |||
| fb0fcfb266 | |||
| 39840bb0d2 | |||
| 79a3369699 | |||
| 696b98bb16 | |||
| 85fa69ba38 | |||
| c5a9111d58 | |||
| a46afe6979 | |||
| 2f67a68e75 | |||
| 83ca211cfb | |||
| 55aa15dd52 | |||
| e54e6c097c | |||
| 56a11d1d45 | |||
| df28028b24 | |||
| 6439d352d2 | |||
| a33cfb2b7d | |||
| 590adb9967 | |||
| 9e817e646f | |||
| 5594586941 | |||
| 5db66fc279 | |||
| 561389da69 | |||
| cd7d61966c | |||
| 29f6dc8896 | |||
| 6c83e8c477 | |||
| 29015903ba | |||
| 709e671168 | |||
| 473c9f225e | |||
| 90a389ac25 | |||
| 6106546a32 | |||
| ad82191ce1 | |||
| 6080d56a5c | |||
| 854985bcfa | |||
| 1bf7c0bb51 | |||
| bc409480e7 | |||
| 3cb74e152b | |||
| fbc4d700d5 | |||
| 40d9965b28 | |||
| 28e670d989 | |||
| dc12713be1 | |||
| 7bf58e7ab0 | |||
| c502b80fa9 | |||
| c23a62184b | |||
| 327119c828 | |||
| 488a3bba5f | |||
| 7f3c6db4d8 | |||
| 884569fc76 | |||
| 0960624e85 | |||
| 4063ca39c9 | |||
| f6476db767 | |||
| 142d8d7679 | |||
| 5bd5993a24 | |||
| 18cd544fab | |||
| 88f9f279e6 | |||
| dc1653ba3a | |||
| 3ea4aee4d6 | |||
| f447307359 | |||
| 4892e04e9b | |||
| 579b1efc68 | |||
| a16aa10fed | |||
| ea9adc8978 | |||
| fd7c886997 | |||
| b3a63dbd86 | |||
| 7ac8eb96c4 | |||
| c43a90716b | |||
| 0a08f4832e | |||
| bc375e3c59 | |||
| d3ebfd67dd | |||
| c1cfd184ee | |||
| e580126b8d | |||
| 86fca5d060 | |||
| c365118cf7 | |||
| 6f2d10c436 | |||
| 093004b724 | |||
| d2c783b06c | |||
| 7e2dd3afe6 | |||
| 4ace959667 | |||
| 14f31954d1 | |||
| 907daf78bf | |||
| cf6386b471 | |||
| 76df99f3a3 | |||
| 57e3eaecd9 | |||
| 21d3a5a61c |
@@ -9,7 +9,9 @@
|
||||
],
|
||||
"features": {
|
||||
"ghcr.io/devcontainers/features/github-cli:1": {},
|
||||
"ghcr.io/devcontainers/features/docker-outside-of-docker:1": {}
|
||||
"ghcr.io/devcontainers/features/docker-outside-of-docker:1": {
|
||||
"moby": false
|
||||
}
|
||||
},
|
||||
"customizations": {
|
||||
"vscode": {
|
||||
@@ -28,4 +30,4 @@
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
version: '3.8'
|
||||
services:
|
||||
app:
|
||||
image: mcr.microsoft.com/devcontainers/go:1.23
|
||||
image: mcr.microsoft.com/devcontainers/go:1-trixie # https://www.debian.org/releases/trixie/index.en.html
|
||||
volumes:
|
||||
- ..:/workspace:cached
|
||||
command: sleep infinity
|
||||
@@ -11,10 +10,10 @@ services:
|
||||
- ADMIN_USERNAME=admin
|
||||
- ADMIN_PASSWORD=test123
|
||||
db:
|
||||
image: postgres:15
|
||||
image: postgres:latest
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- postgres-data:/var/lib/postgresql/data
|
||||
- postgres-data:/var/lib/postgresql
|
||||
hostname: postgres
|
||||
environment:
|
||||
POSTGRES_DB: miniflux2
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
name: Build Binaries
|
||||
permissions:
|
||||
contents: read
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
@@ -7,10 +9,11 @@ on:
|
||||
jobs:
|
||||
build:
|
||||
name: Build
|
||||
if: github.repository_owner == 'miniflux'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v6
|
||||
- name: Set up Golang
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
@@ -21,7 +24,7 @@ jobs:
|
||||
CGO_ENABLED: 0
|
||||
run: make build
|
||||
- name: Upload binaries
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: binaries
|
||||
path: miniflux-*
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
name: Mirror to Codeberg
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
delete:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
mirror:
|
||||
if: github.repository_owner == 'miniflux'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Mirror to Codeberg
|
||||
env:
|
||||
CODEBERG_USERNAME: ${{ secrets.CODEBERG_USERNAME }}
|
||||
CODEBERG_TOKEN: ${{ secrets.CODEBERG_TOKEN }}
|
||||
run: |
|
||||
git remote add codeberg https://${{ secrets.CODEBERG_USERNAME }}:${{ secrets.CODEBERG_TOKEN }}@codeberg.org/miniflux/v2.git
|
||||
git push --force --prune codeberg \
|
||||
"refs/heads/*:refs/heads/*" \
|
||||
"refs/tags/*:refs/tags/*"
|
||||
@@ -9,6 +9,7 @@ on:
|
||||
- '**.js'
|
||||
- '**.go'
|
||||
- '!**_test.go'
|
||||
- '.github/workflows/codeql-analysis.yml'
|
||||
pull_request:
|
||||
# The branches below must be a subset of the branches above
|
||||
branches: [ main ]
|
||||
@@ -16,12 +17,14 @@ on:
|
||||
- '**.js'
|
||||
- '**.go'
|
||||
- '!**_test.go'
|
||||
- '.github/workflows/codeql-analysis.yml'
|
||||
schedule:
|
||||
- cron: '45 22 * * 3'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
name: Analyze
|
||||
name: Analyze (${{ matrix.language }})
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
actions: read
|
||||
@@ -30,20 +33,27 @@ jobs:
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
language: [ 'go', 'javascript' ]
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- uses: actions/setup-go@v6
|
||||
if: matrix.language == 'go'
|
||||
with:
|
||||
go-version: stable
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v4
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@v4
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v4
|
||||
with:
|
||||
category: "/language:${{ matrix.language }}"
|
||||
|
||||
@@ -13,17 +13,18 @@ on:
|
||||
- 'packaging/debian/**' # Only run on changes to the debian packaging files
|
||||
jobs:
|
||||
test-packages:
|
||||
if: github.event_name == 'schedule' || github.event_name == 'pull_request'
|
||||
if: (github.event_name == 'schedule' && github.repository_owner == 'miniflux' )
|
||||
|| github.event_name == 'pull_request'
|
||||
name: Test Packages
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
uses: docker/setup-qemu-action@v4
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@v4
|
||||
id: buildx
|
||||
with:
|
||||
install: true
|
||||
@@ -38,13 +39,13 @@ jobs:
|
||||
name: Build Packages Manually
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
uses: docker/setup-qemu-action@v4
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@v4
|
||||
id: buildx
|
||||
with:
|
||||
install: true
|
||||
@@ -53,7 +54,7 @@ jobs:
|
||||
- name: Build Debian Packages
|
||||
run: make debian-packages
|
||||
- name: Upload package
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: packages
|
||||
path: "*.deb"
|
||||
@@ -64,13 +65,13 @@ jobs:
|
||||
name: Publish Packages
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
uses: docker/setup-qemu-action@v4
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@v4
|
||||
id: buildx
|
||||
with:
|
||||
install: true
|
||||
|
||||
@@ -12,18 +12,19 @@ on:
|
||||
jobs:
|
||||
docker-images:
|
||||
name: Docker Images
|
||||
if: github.repository_owner == 'miniflux'
|
||||
permissions:
|
||||
packages: write
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Generate Alpine Docker tags
|
||||
id: docker_alpine_tags
|
||||
uses: docker/metadata-action@v5
|
||||
uses: docker/metadata-action@v6
|
||||
with:
|
||||
images: |
|
||||
docker.io/${{ github.repository_owner }}/miniflux
|
||||
@@ -36,7 +37,7 @@ jobs:
|
||||
|
||||
- name: Generate Distroless Docker tags
|
||||
id: docker_distroless_tags
|
||||
uses: docker/metadata-action@v5
|
||||
uses: docker/metadata-action@v6
|
||||
with:
|
||||
images: |
|
||||
docker.io/${{ github.repository_owner }}/miniflux
|
||||
@@ -50,21 +51,21 @@ jobs:
|
||||
suffix=-distroless,onlatest=true
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
uses: docker/setup-qemu-action@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Login to DockerHub
|
||||
if: ${{ github.event_name != 'pull_request' && vars.PUBLISH_DOCKER_IMAGES == 'true' }}
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
if: ${{ github.event_name != 'pull_request' && vars.PUBLISH_DOCKER_IMAGES == 'true' }}
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
@@ -72,14 +73,14 @@ jobs:
|
||||
|
||||
- name: Login to Quay Container Registry
|
||||
if: ${{ github.event_name != 'pull_request' && vars.PUBLISH_DOCKER_IMAGES == 'true' }}
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: quay.io
|
||||
username: ${{ secrets.QUAY_USERNAME }}
|
||||
password: ${{ secrets.QUAY_TOKEN }}
|
||||
|
||||
- name: Build and Push Alpine images
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@v7
|
||||
if: ${{ vars.PUBLISH_DOCKER_IMAGES == 'true' }}
|
||||
with:
|
||||
context: .
|
||||
@@ -89,7 +90,7 @@ jobs:
|
||||
tags: ${{ steps.docker_alpine_tags.outputs.tags }}
|
||||
|
||||
- name: Build and Push Distroless images
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@v7
|
||||
if: ${{ vars.PUBLISH_DOCKER_IMAGES == 'true' }}
|
||||
with:
|
||||
context: .
|
||||
|
||||
@@ -12,7 +12,7 @@ jobs:
|
||||
name: Javascript Linter
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
- name: Install linters
|
||||
run: |
|
||||
sudo npm install -g jshint@2.13.6 eslint@8.57.0
|
||||
@@ -25,11 +25,11 @@ jobs:
|
||||
name: Golang Linters
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: stable
|
||||
- uses: golangci/golangci-lint-action@v8
|
||||
- uses: golangci/golangci-lint-action@v9
|
||||
- name: Run gofmt linter
|
||||
run: gofmt -d -e .
|
||||
|
||||
@@ -38,7 +38,7 @@ jobs:
|
||||
name: Commit Linter
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Set up Python
|
||||
|
||||
@@ -11,17 +11,18 @@ on:
|
||||
branches: [ main ]
|
||||
paths:
|
||||
- 'packaging/rpm/**' # Only run on changes to the rpm packaging files
|
||||
- '.github/workflows/rpm_packages.yml'
|
||||
jobs:
|
||||
test-package:
|
||||
if: github.event_name == 'schedule' || github.event_name == 'pull_request'
|
||||
name: Test Packages
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Build RPM Package
|
||||
run: make rpm
|
||||
run: make rpm VERSION=2.2.x_dev
|
||||
- name: List generated files
|
||||
run: ls -l *.rpm
|
||||
build-package-manually:
|
||||
@@ -29,13 +30,13 @@ jobs:
|
||||
name: Build Packages Manually
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Build RPM Package
|
||||
run: make rpm
|
||||
- name: Upload package
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: packages
|
||||
path: "*.rpm"
|
||||
@@ -46,7 +47,7 @@ jobs:
|
||||
name: Publish Packages
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Build RPM Package
|
||||
|
||||
@@ -6,7 +6,7 @@ from typing import Match
|
||||
|
||||
# Conventional commit pattern (including Git revert messages)
|
||||
CONVENTIONAL_COMMIT_PATTERN: str = (
|
||||
r"^((build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test)(\([a-z0-9-]+\))?!?: .{1,100}|Revert .+)"
|
||||
r"^((build|chore|ci|docs|feat|fix|perf|refactor|revert|security|style|test)(\([a-z0-9-]+\))?!?: .{1,100}|Revert .+)"
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ jobs:
|
||||
os: [ubuntu-latest, windows-latest, macOS-latest]
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v6
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
@@ -44,7 +44,7 @@ jobs:
|
||||
options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v6
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
|
||||
@@ -10,7 +10,6 @@ linters:
|
||||
- loggercheck
|
||||
- misspell
|
||||
- perfsprint
|
||||
- prealloc
|
||||
- sqlclosecheck
|
||||
- staticcheck
|
||||
- whitespace
|
||||
|
||||
@@ -113,6 +113,8 @@ integration-test:
|
||||
CREATE_ADMIN=1 \
|
||||
RUN_MIGRATIONS=1 \
|
||||
LOG_LEVEL=debug \
|
||||
FETCHER_ALLOW_PRIVATE_NETWORKS=1 \
|
||||
INTEGRATION_ALLOW_PRIVATE_NETWORKS=1 \
|
||||
go run main.go >/tmp/miniflux.log 2>&1 & echo "$$!" > "/tmp/miniflux.pid"
|
||||
|
||||
while ! nc -z localhost 8080; do sleep 1; done
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@ Miniflux API Client
|
||||
|
||||
[](https://pkg.go.dev/miniflux.app/v2/client)
|
||||
|
||||
Client library for Miniflux REST API.
|
||||
Go client for the Miniflux REST API. It supports API tokens or basic authentication and mirrors the server endpoints closely.
|
||||
|
||||
Installation
|
||||
------------
|
||||
|
||||
+42
-14
@@ -22,6 +22,8 @@ type Client struct {
|
||||
// New returns a new Miniflux client.
|
||||
//
|
||||
// Deprecated: use NewClient instead.
|
||||
//
|
||||
//go:fix inline
|
||||
func New(endpoint string, credentials ...string) *Client {
|
||||
return NewClient(endpoint, credentials...)
|
||||
}
|
||||
@@ -334,14 +336,14 @@ func (c *Client) MarkAllAsReadContext(ctx context.Context, userID int64) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// IntegrationsStatus fetches the integrations status for the logged user.
|
||||
// IntegrationsStatus fetches the integrations status for the signed-in user.
|
||||
func (c *Client) IntegrationsStatus() (bool, error) {
|
||||
ctx, cancel := withDefaultTimeout()
|
||||
defer cancel()
|
||||
return c.IntegrationsStatusContext(ctx)
|
||||
}
|
||||
|
||||
// IntegrationsStatusContext fetches the integrations status for the logged user.
|
||||
// IntegrationsStatusContext fetches the integrations status for the signed-in user.
|
||||
func (c *Client) IntegrationsStatusContext(ctx context.Context) (bool, error) {
|
||||
body, err := c.request.Get(ctx, "/v1/integrations/status")
|
||||
if err != nil {
|
||||
@@ -360,7 +362,7 @@ func (c *Client) IntegrationsStatusContext(ctx context.Context) (bool, error) {
|
||||
return response.HasIntegrations, nil
|
||||
}
|
||||
|
||||
// Discover try to find subscriptions from a website.
|
||||
// Discover tries to find subscriptions on a website.
|
||||
func (c *Client) Discover(url string) (Subscriptions, error) {
|
||||
ctx, cancel := withDefaultTimeout()
|
||||
defer cancel()
|
||||
@@ -383,14 +385,14 @@ func (c *Client) DiscoverContext(ctx context.Context, url string) (Subscriptions
|
||||
return subscriptions, nil
|
||||
}
|
||||
|
||||
// Categories gets the list of categories.
|
||||
// Categories retrieves the list of categories.
|
||||
func (c *Client) Categories() (Categories, error) {
|
||||
ctx, cancel := withDefaultTimeout()
|
||||
defer cancel()
|
||||
return c.CategoriesContext(ctx)
|
||||
}
|
||||
|
||||
// CategoriesContext gets the list of categories.
|
||||
// CategoriesContext retrieves the list of categories.
|
||||
func (c *Client) CategoriesContext(ctx context.Context) (Categories, error) {
|
||||
body, err := c.request.Get(ctx, "/v1/categories")
|
||||
if err != nil {
|
||||
@@ -486,7 +488,7 @@ func (c *Client) UpdateCategory(categoryID int64, title string) (*Category, erro
|
||||
// UpdateCategoryContext updates a category.
|
||||
func (c *Client) UpdateCategoryContext(ctx context.Context, categoryID int64, title string) (*Category, error) {
|
||||
body, err := c.request.Put(ctx, fmt.Sprintf("/v1/categories/%d", categoryID), &CategoryModificationRequest{
|
||||
Title: SetOptionalField(title),
|
||||
Title: new(title),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -537,14 +539,14 @@ func (c *Client) MarkCategoryAsReadContext(ctx context.Context, categoryID int64
|
||||
return err
|
||||
}
|
||||
|
||||
// CategoryFeeds gets feeds of a category.
|
||||
// CategoryFeeds returns all feeds for a category.
|
||||
func (c *Client) CategoryFeeds(categoryID int64) (Feeds, error) {
|
||||
ctx, cancel := withDefaultTimeout()
|
||||
defer cancel()
|
||||
return c.CategoryFeedsContext(ctx, categoryID)
|
||||
}
|
||||
|
||||
// CategoryFeedsContext gets feeds of a category.
|
||||
// CategoryFeedsContext returns all feeds for a category.
|
||||
func (c *Client) CategoryFeedsContext(ctx context.Context, categoryID int64) (Feeds, error) {
|
||||
body, err := c.request.Get(ctx, fmt.Sprintf("/v1/categories/%d/feeds", categoryID))
|
||||
if err != nil {
|
||||
@@ -608,14 +610,14 @@ func (c *Client) FeedsContext(ctx context.Context) (Feeds, error) {
|
||||
return feeds, nil
|
||||
}
|
||||
|
||||
// Export creates OPML file.
|
||||
// Export exports subscriptions as an OPML document.
|
||||
func (c *Client) Export() ([]byte, error) {
|
||||
ctx, cancel := withDefaultTimeout()
|
||||
defer cancel()
|
||||
return c.ExportContext(ctx)
|
||||
}
|
||||
|
||||
// ExportContext creates OPML file.
|
||||
// ExportContext exports subscriptions as an OPML document.
|
||||
func (c *Client) ExportContext(ctx context.Context) ([]byte, error) {
|
||||
body, err := c.request.Get(ctx, "/v1/export")
|
||||
if err != nil {
|
||||
@@ -717,6 +719,32 @@ func (c *Client) UpdateFeedContext(ctx context.Context, feedID int64, feedChange
|
||||
return f, nil
|
||||
}
|
||||
|
||||
// ImportFeedEntry imports a single entry into a feed.
|
||||
func (c *Client) ImportFeedEntry(feedID int64, payload any) (int64, error) {
|
||||
ctx, cancel := withDefaultTimeout()
|
||||
defer cancel()
|
||||
|
||||
body, err := c.request.Post(
|
||||
ctx,
|
||||
fmt.Sprintf("/v1/feeds/%d/entries/import", feedID),
|
||||
payload,
|
||||
)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer body.Close()
|
||||
|
||||
var response struct {
|
||||
ID int64 `json:"id"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(body).Decode(&response); err != nil {
|
||||
return 0, fmt.Errorf("miniflux: json error (%v)", err)
|
||||
}
|
||||
|
||||
return response.ID, nil
|
||||
}
|
||||
|
||||
// MarkFeedAsRead marks all unread entries of the feed as read.
|
||||
func (c *Client) MarkFeedAsRead(feedID int64) error {
|
||||
ctx, cancel := withDefaultTimeout()
|
||||
@@ -860,7 +888,7 @@ func (c *Client) EntryContext(ctx context.Context, entryID int64) (*Entry, error
|
||||
return entry, nil
|
||||
}
|
||||
|
||||
// Entries fetch entries.
|
||||
// Entries fetches entries using the given filter.
|
||||
func (c *Client) Entries(filter *Filter) (*EntryResultSet, error) {
|
||||
ctx, cancel := withDefaultTimeout()
|
||||
defer cancel()
|
||||
@@ -885,7 +913,7 @@ func (c *Client) EntriesContext(ctx context.Context, filter *Filter) (*EntryResu
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// FeedEntries fetch feed entries.
|
||||
// FeedEntries fetches entries for a feed using the given filter.
|
||||
func (c *Client) FeedEntries(feedID int64, filter *Filter) (*EntryResultSet, error) {
|
||||
ctx, cancel := withDefaultTimeout()
|
||||
defer cancel()
|
||||
@@ -910,7 +938,7 @@ func (c *Client) FeedEntriesContext(ctx context.Context, feedID int64, filter *F
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// CategoryEntries fetch entries of a category.
|
||||
// CategoryEntries fetches entries for a category using the given filter.
|
||||
func (c *Client) CategoryEntries(categoryID int64, filter *Filter) (*EntryResultSet, error) {
|
||||
ctx, cancel := withDefaultTimeout()
|
||||
defer cancel()
|
||||
@@ -976,7 +1004,7 @@ func (c *Client) UpdateEntryContext(ctx context.Context, entryID int64, entryCha
|
||||
return entry, nil
|
||||
}
|
||||
|
||||
// ToggleStarred toggles entry starred value.
|
||||
// ToggleStarred toggles the starred flag of an entry.
|
||||
func (c *Client) ToggleStarred(entryID int64) error {
|
||||
ctx, cancel := withDefaultTimeout()
|
||||
defer cancel()
|
||||
|
||||
+2
-2
@@ -6,7 +6,7 @@ Package client implements a client library for the Miniflux REST API.
|
||||
|
||||
# Examples
|
||||
|
||||
This code snippet fetch the list of users:
|
||||
This example fetches the list of users:
|
||||
|
||||
import (
|
||||
miniflux "miniflux.app/v2/client"
|
||||
@@ -20,7 +20,7 @@ This code snippet fetch the list of users:
|
||||
}
|
||||
fmt.Println(users, err)
|
||||
|
||||
This one discover subscriptions on a website:
|
||||
This example discovers subscriptions on a website:
|
||||
|
||||
subscriptions, err := client.Discover("https://example.org/")
|
||||
if err != nil {
|
||||
|
||||
+8
-2
@@ -149,7 +149,7 @@ type Feed struct {
|
||||
FeedURL string `json:"feed_url"`
|
||||
SiteURL string `json:"site_url"`
|
||||
Title string `json:"title"`
|
||||
CheckedAt time.Time `json:"checked_at,omitempty"`
|
||||
CheckedAt time.Time `json:"checked_at"`
|
||||
EtagHeader string `json:"etag_header,omitempty"`
|
||||
LastModifiedHeader string `json:"last_modified_header,omitempty"`
|
||||
ParsingErrorMsg string `json:"parsing_error_message,omitempty"`
|
||||
@@ -166,6 +166,7 @@ type Feed struct {
|
||||
BlockFilterEntryRules string `json:"block_filter_entry_rules"`
|
||||
KeepFilterEntryRules string `json:"keep_filter_entry_rules"`
|
||||
Crawler bool `json:"crawler"`
|
||||
IgnoreEntryUpdates bool `json:"ignore_entry_updates"`
|
||||
UserAgent string `json:"user_agent"`
|
||||
Cookie string `json:"cookie"`
|
||||
Username string `json:"username"`
|
||||
@@ -185,6 +186,7 @@ type FeedCreationRequest struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
Crawler bool `json:"crawler"`
|
||||
IgnoreEntryUpdates bool `json:"ignore_entry_updates"`
|
||||
Disabled bool `json:"disabled"`
|
||||
IgnoreHTTPCache bool `json:"ignore_http_cache"`
|
||||
AllowSelfSignedCertificates bool `json:"allow_self_signed_certificates"`
|
||||
@@ -214,6 +216,7 @@ type FeedModificationRequest struct {
|
||||
BlockFilterEntryRules *string `json:"block_filter_entry_rules"`
|
||||
KeepFilterEntryRules *string `json:"keep_filter_entry_rules"`
|
||||
Crawler *bool `json:"crawler"`
|
||||
IgnoreEntryUpdates *bool `json:"ignore_entry_updates"`
|
||||
UserAgent *string `json:"user_agent"`
|
||||
Cookie *string `json:"cookie"`
|
||||
Username *string `json:"username"`
|
||||
@@ -356,6 +359,9 @@ type APIKeyCreationRequest struct {
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
// SetOptionalField returns a pointer to the given value so optional request fields can be marked as set.
|
||||
//
|
||||
//go:fix inline
|
||||
func SetOptionalField[T any](value T) *T {
|
||||
return &value
|
||||
return new(value)
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ services:
|
||||
- POSTGRES_PASSWORD=secret
|
||||
- POSTGRES_DB=miniflux
|
||||
volumes:
|
||||
- miniflux-db:/var/lib/postgresql/data
|
||||
- miniflux-db:/var/lib/postgresql
|
||||
healthcheck:
|
||||
test: ["CMD", "pg_isready", "-U", "miniflux"]
|
||||
interval: 10s
|
||||
|
||||
@@ -31,7 +31,7 @@ services:
|
||||
- POSTGRES_USER=miniflux
|
||||
- POSTGRES_PASSWORD=secret
|
||||
volumes:
|
||||
- miniflux-db:/var/lib/postgresql/data
|
||||
- miniflux-db:/var/lib/postgresql
|
||||
healthcheck:
|
||||
test: ["CMD", "pg_isready", "-U", "miniflux"]
|
||||
interval: 10s
|
||||
|
||||
@@ -43,7 +43,7 @@ services:
|
||||
- POSTGRES_USER=miniflux
|
||||
- POSTGRES_PASSWORD=secret
|
||||
volumes:
|
||||
- miniflux-db:/var/lib/postgresql/data
|
||||
- miniflux-db:/var/lib/postgresql
|
||||
healthcheck:
|
||||
test: ["CMD", "pg_isready", "-U", "miniflux"]
|
||||
interval: 10s
|
||||
|
||||
@@ -1,27 +1,28 @@
|
||||
module miniflux.app/v2
|
||||
|
||||
// +heroku goVersion go1.24
|
||||
// +heroku goVersion go1.26
|
||||
|
||||
require (
|
||||
github.com/PuerkitoBio/goquery v1.10.3
|
||||
github.com/PuerkitoBio/goquery v1.11.0
|
||||
github.com/andybalholm/brotli v1.2.0
|
||||
github.com/coreos/go-oidc/v3 v3.16.0
|
||||
github.com/go-webauthn/webauthn v0.14.0
|
||||
github.com/coreos/go-oidc/v3 v3.17.0
|
||||
github.com/go-webauthn/webauthn v0.16.1
|
||||
github.com/gorilla/mux v1.8.1
|
||||
github.com/lib/pq v1.10.9
|
||||
github.com/lib/pq v1.11.2
|
||||
github.com/prometheus/client_golang v1.23.2
|
||||
github.com/tdewolff/minify/v2 v2.24.4
|
||||
golang.org/x/crypto v0.43.0
|
||||
golang.org/x/image v0.32.0
|
||||
golang.org/x/net v0.46.0
|
||||
golang.org/x/oauth2 v0.32.0
|
||||
golang.org/x/term v0.36.0
|
||||
github.com/tdewolff/minify/v2 v2.24.10
|
||||
golang.org/x/crypto v0.49.0
|
||||
golang.org/x/image v0.37.0
|
||||
golang.org/x/net v0.52.0
|
||||
golang.org/x/oauth2 v0.36.0
|
||||
golang.org/x/term v0.41.0
|
||||
golang.org/x/text v0.35.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/go-webauthn/x v0.1.25 // indirect
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0 // indirect
|
||||
github.com/google/go-tpm v0.9.5 // indirect
|
||||
github.com/go-webauthn/x v0.2.2 // indirect
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
|
||||
github.com/google/go-tpm v0.9.8 // indirect
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -30,21 +31,18 @@ require (
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
|
||||
github.com/go-jose/go-jose/v4 v4.1.3 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/kr/text v0.2.0 // indirect
|
||||
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||
github.com/prometheus/client_model v0.6.2 // indirect
|
||||
github.com/prometheus/common v0.66.1 // indirect
|
||||
github.com/prometheus/procfs v0.16.1 // indirect
|
||||
github.com/tdewolff/parse/v2 v2.8.4 // indirect
|
||||
github.com/tdewolff/parse/v2 v2.8.10 // indirect
|
||||
github.com/x448/float16 v0.8.4 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.2 // indirect
|
||||
golang.org/x/sys v0.37.0 // indirect
|
||||
golang.org/x/text v0.30.0 // indirect
|
||||
golang.org/x/sys v0.42.0 // indirect
|
||||
google.golang.org/protobuf v1.36.8 // indirect
|
||||
)
|
||||
|
||||
go 1.24.0
|
||||
|
||||
toolchain go1.24.1
|
||||
go 1.26.0
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
github.com/PuerkitoBio/goquery v1.10.3 h1:pFYcNSqHxBD06Fpj/KsbStFRsgRATgnf3LeXiUkhzPo=
|
||||
github.com/PuerkitoBio/goquery v1.10.3/go.mod h1:tMUX0zDMHXYlAQk6p35XxQMqMweEKB7iK7iLNd4RH4Y=
|
||||
github.com/PuerkitoBio/goquery v1.11.0 h1:jZ7pwMQXIITcUXNH83LLk+txlaEy6NVOfTuP43xxfqw=
|
||||
github.com/PuerkitoBio/goquery v1.11.0/go.mod h1:wQHgxUOU3JGuj3oD/QFfxUdlzW6xPHfqyHre6VMY4DQ=
|
||||
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
|
||||
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||
github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
|
||||
@@ -8,8 +8,8 @@ github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/coreos/go-oidc/v3 v3.16.0 h1:qRQUCFstKpXwmEjDQTIbyY/5jF00+asXzSkmkoa/mow=
|
||||
github.com/coreos/go-oidc/v3 v3.16.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8=
|
||||
github.com/coreos/go-oidc/v3 v3.17.0 h1:hWBGaQfbi0iVviX4ibC7bk8OKT5qNr4klBaCHVNvehc=
|
||||
github.com/coreos/go-oidc/v3 v3.17.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
@@ -17,17 +17,21 @@ github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sa
|
||||
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
|
||||
github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs=
|
||||
github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
|
||||
github.com/go-webauthn/webauthn v0.14.0 h1:ZLNPUgPcDlAeoxe+5umWG/tEeCoQIDr7gE2Zx2QnhL0=
|
||||
github.com/go-webauthn/webauthn v0.14.0/go.mod h1:QZzPFH3LJ48u5uEPAu+8/nWJImoLBWM7iAH/kSVSo6k=
|
||||
github.com/go-webauthn/x v0.1.25 h1:g/0noooIGcz/yCVqebcFgNnGIgBlJIccS+LYAa+0Z88=
|
||||
github.com/go-webauthn/x v0.1.25/go.mod h1:ieblaPY1/BVCV0oQTsA/VAo08/TWayQuJuo5Q+XxmTY=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||
github.com/go-webauthn/webauthn v0.16.1 h1:x5/SSki5/aIfogaRukqvbg/RXa3Sgxy/9vU7UfFPHKU=
|
||||
github.com/go-webauthn/webauthn v0.16.1/go.mod h1:RBS+rtQJMkE5VfMQ4diDA2VNrEL8OeUhp4Srz37FHbQ=
|
||||
github.com/go-webauthn/x v0.2.2 h1:zIiipvMbr48CXi5RG0XdBJR94kd8I5LfzHPb/q+YYmk=
|
||||
github.com/go-webauthn/x v0.2.2/go.mod h1:IpJ5qyWB9NRhLX3C7gIfjTU7RZLXEP6kzFkoVSE7Fz4=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/go-tpm v0.9.5 h1:ocUmnDebX54dnW+MQWGQRbdaAcJELsa6PqZhJ48KwVU=
|
||||
github.com/google/go-tpm v0.9.5/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
|
||||
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
|
||||
github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
|
||||
github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba h1:qJEJcuLzH5KDR0gKc0zcktin6KSAwL7+jWKBYceddTc=
|
||||
github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba/go.mod h1:EFYHy8/1y2KfgTAsx7Luu7NGhoxtuVHnNo8jE7FikKc=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
|
||||
@@ -40,10 +44,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
||||
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
|
||||
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
||||
github.com/lib/pq v1.11.2 h1:x6gxUeu39V0BHZiugWe8LXZYZ+Utk7hSJGThs8sdzfs=
|
||||
github.com/lib/pq v1.11.2/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
@@ -60,10 +62,10 @@ github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjR
|
||||
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/tdewolff/minify/v2 v2.24.4 h1:pQyr6eWDa+RXtAoZg+6wurh0jB9ojqw/qc5LlU7/z6c=
|
||||
github.com/tdewolff/minify/v2 v2.24.4/go.mod h1:iD9Qn7/brhKY9d0KLKMkZrqS8/bqxSxRKruBi7V6m+w=
|
||||
github.com/tdewolff/parse/v2 v2.8.4 h1:A6slgBLGGDPBMGA28KQZfHpaKffuNvhOe7zSag+x/rw=
|
||||
github.com/tdewolff/parse/v2 v2.8.4/go.mod h1:Hwlni2tiVNKyzR1o6nUs4FOF07URA+JLBLd6dlIXYqo=
|
||||
github.com/tdewolff/minify/v2 v2.24.10 h1:SjOOY2Y3Uv34WY4wtyUzJA2T1Xd1v1zQVSZvPP0A/h4=
|
||||
github.com/tdewolff/minify/v2 v2.24.10/go.mod h1:fXkGpJ4gel+z1nmeIjVtKmxGZ4ZXd7g1gA3dfTz5/j8=
|
||||
github.com/tdewolff/parse/v2 v2.8.10 h1:5a8o388UmuiU3zlOBJ56PN0rxVi67LRNED/zzuHAfC0=
|
||||
github.com/tdewolff/parse/v2 v2.8.10/go.mod h1:Hwlni2tiVNKyzR1o6nUs4FOF07URA+JLBLd6dlIXYqo=
|
||||
github.com/tdewolff/test v1.0.11 h1:FdLbwQVHxqG16SlkGveC0JVyrJN62COWTRyUFzfbtBE=
|
||||
github.com/tdewolff/test v1.0.11/go.mod h1:XPuWBzvdUzhCuxWO1ojpXsyzsA5bFoS3tO/Q3kFuTG8=
|
||||
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
||||
@@ -83,10 +85,10 @@ golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliY
|
||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||
golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
|
||||
golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
|
||||
golang.org/x/image v0.32.0 h1:6lZQWq75h7L5IWNk0r+SCpUJ6tUVd3v4ZHnbRKLkUDQ=
|
||||
golang.org/x/image v0.32.0/go.mod h1:/R37rrQmKXtO6tYXAjtDLwQgFLHmhW+V6ayXlxzP2Pc=
|
||||
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
|
||||
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
|
||||
golang.org/x/image v0.37.0 h1:ZiRjArKI8GwxZOoEtUfhrBtaCN+4b/7709dlT6SSnQA=
|
||||
golang.org/x/image v0.37.0/go.mod h1:/3f6vaXC+6CEanU4KJxbcUZyEePbyKbaLoDOe4ehFYY=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
@@ -101,10 +103,10 @@ golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
||||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
|
||||
golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4=
|
||||
golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210=
|
||||
golang.org/x/oauth2 v0.32.0 h1:jsCblLleRMDrxMN29H3z/k1KliIvpLgCkE6R8FXXNgY=
|
||||
golang.org/x/oauth2 v0.32.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
|
||||
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
|
||||
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
||||
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
||||
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=
|
||||
@@ -123,8 +125,8 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
|
||||
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
@@ -134,8 +136,8 @@ golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
|
||||
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
||||
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
|
||||
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
|
||||
golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q=
|
||||
golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss=
|
||||
golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
|
||||
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
@@ -145,8 +147,8 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
|
||||
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
|
||||
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
|
||||
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
|
||||
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=
|
||||
|
||||
@@ -62,6 +62,7 @@ func Serve(router *mux.Router, store *storage.Storage, pool *worker.Pool) {
|
||||
sr.HandleFunc("/export", handler.exportFeeds).Methods(http.MethodGet)
|
||||
sr.HandleFunc("/import", handler.importFeeds).Methods(http.MethodPost)
|
||||
sr.HandleFunc("/feeds/{feedID}/entries", handler.getFeedEntries).Methods(http.MethodGet)
|
||||
sr.HandleFunc("/feeds/{feedID}/entries/import", handler.importFeedEntry).Methods(http.MethodPost)
|
||||
sr.HandleFunc("/feeds/{feedID}/entries/{entryID}", handler.getFeedEntry).Methods(http.MethodGet)
|
||||
sr.HandleFunc("/entries", handler.getEntries).Methods(http.MethodGet)
|
||||
sr.HandleFunc("/entries", handler.setEntryStatus).Methods(http.MethodPut)
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"testing"
|
||||
|
||||
miniflux "miniflux.app/v2/client"
|
||||
"miniflux.app/v2/internal/model"
|
||||
)
|
||||
|
||||
const skipIntegrationTestsMessage = `Set TEST_MINIFLUX_* environment variables to run the API integration tests`
|
||||
@@ -579,7 +580,7 @@ func TestUpdateUserEndpointByChangingDefaultTheme(t *testing.T) {
|
||||
regularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)
|
||||
|
||||
userUpdateRequest := &miniflux.UserModificationRequest{
|
||||
Theme: miniflux.SetOptionalField("dark_serif"),
|
||||
Theme: new("dark_serif"),
|
||||
}
|
||||
|
||||
updatedUser, err := regularUserClient.UpdateUser(regularTestUser.ID, userUpdateRequest)
|
||||
@@ -608,7 +609,7 @@ func TestUpdateUserEndpointByChangingExternalFonts(t *testing.T) {
|
||||
regularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)
|
||||
|
||||
userUpdateRequest := &miniflux.UserModificationRequest{
|
||||
ExternalFontHosts: miniflux.SetOptionalField(" fonts.example.org "),
|
||||
ExternalFontHosts: new(" fonts.example.org "),
|
||||
}
|
||||
|
||||
updatedUser, err := regularUserClient.UpdateUser(regularTestUser.ID, userUpdateRequest)
|
||||
@@ -637,7 +638,7 @@ func TestUpdateUserEndpointByChangingExternalFontsWithInvalidValue(t *testing.T)
|
||||
regularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)
|
||||
|
||||
userUpdateRequest := &miniflux.UserModificationRequest{
|
||||
ExternalFontHosts: miniflux.SetOptionalField("'self' *"),
|
||||
ExternalFontHosts: new("'self' *"),
|
||||
}
|
||||
|
||||
if _, err := regularUserClient.UpdateUser(regularTestUser.ID, userUpdateRequest); err == nil {
|
||||
@@ -661,7 +662,7 @@ func TestUpdateUserEndpointByChangingCustomJS(t *testing.T) {
|
||||
regularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)
|
||||
|
||||
userUpdateRequest := &miniflux.UserModificationRequest{
|
||||
CustomJS: miniflux.SetOptionalField("alert('Hello, World!');"),
|
||||
CustomJS: new("alert('Hello, World!');"),
|
||||
}
|
||||
|
||||
updatedUser, err := regularUserClient.UpdateUser(regularTestUser.ID, userUpdateRequest)
|
||||
@@ -690,7 +691,7 @@ func TestUpdateUserEndpointByChangingDefaultThemeToInvalidValue(t *testing.T) {
|
||||
regularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)
|
||||
|
||||
userUpdateRequest := &miniflux.UserModificationRequest{
|
||||
Theme: miniflux.SetOptionalField("invalid_theme"),
|
||||
Theme: new("invalid_theme"),
|
||||
}
|
||||
|
||||
_, err = regularUserClient.UpdateUser(regularTestUser.ID, userUpdateRequest)
|
||||
@@ -720,7 +721,7 @@ func TestRegularUsersCannotUpdateOtherUsers(t *testing.T) {
|
||||
regularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)
|
||||
|
||||
userUpdateRequest := &miniflux.UserModificationRequest{
|
||||
Theme: miniflux.SetOptionalField("dark_serif"),
|
||||
Theme: new("dark_serif"),
|
||||
}
|
||||
|
||||
_, err = regularUserClient.UpdateUser(adminUser.ID, userUpdateRequest)
|
||||
@@ -1089,7 +1090,7 @@ func TestUpdateCategoryWithOptions(t *testing.T) {
|
||||
}
|
||||
|
||||
updatedCategory, err := regularUserClient.UpdateCategoryWithOptions(newCategory.ID, &miniflux.CategoryModificationRequest{
|
||||
Title: miniflux.SetOptionalField("new title"),
|
||||
Title: new("new title"),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -1108,7 +1109,7 @@ func TestUpdateCategoryWithOptions(t *testing.T) {
|
||||
}
|
||||
|
||||
updatedCategory, err = regularUserClient.UpdateCategoryWithOptions(newCategory.ID, &miniflux.CategoryModificationRequest{
|
||||
HideGlobally: miniflux.SetOptionalField(true),
|
||||
HideGlobally: new(true),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -1127,14 +1128,14 @@ func TestUpdateCategoryWithOptions(t *testing.T) {
|
||||
}
|
||||
|
||||
updatedCategory, err = regularUserClient.UpdateCategoryWithOptions(newCategory.ID, &miniflux.CategoryModificationRequest{
|
||||
HideGlobally: miniflux.SetOptionalField(false),
|
||||
HideGlobally: new(false),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if updatedCategory.ID != newCategory.ID {
|
||||
t.Errorf(`Invalid categoryID, got %q`, updatedCategory.ID)
|
||||
t.Errorf(`Invalid categoryID, got %d`, updatedCategory.ID)
|
||||
}
|
||||
|
||||
if updatedCategory.Title != "new title" {
|
||||
@@ -1611,7 +1612,7 @@ func TestUpdateFeedEndpoint(t *testing.T) {
|
||||
}
|
||||
|
||||
feedUpdateRequest := &miniflux.FeedModificationRequest{
|
||||
FeedURL: miniflux.SetOptionalField("https://example.org/feed.xml"),
|
||||
FeedURL: new("https://example.org/feed.xml"),
|
||||
}
|
||||
|
||||
updatedFeed, err := regularUserClient.UpdateFeed(feedID, feedUpdateRequest)
|
||||
@@ -1652,7 +1653,7 @@ func TestCannotHaveDuplicateFeedWhenUpdatingFeed(t *testing.T) {
|
||||
}
|
||||
|
||||
feedUpdateRequest := &miniflux.FeedModificationRequest{
|
||||
FeedURL: miniflux.SetOptionalField(testConfig.testFeedURL),
|
||||
FeedURL: new(testConfig.testFeedURL),
|
||||
}
|
||||
|
||||
if _, err := regularUserClient.UpdateFeed(feedID, feedUpdateRequest); err == nil {
|
||||
@@ -1684,7 +1685,7 @@ func TestUpdateFeedWithInvalidCategory(t *testing.T) {
|
||||
}
|
||||
|
||||
feedUpdateRequest := &miniflux.FeedModificationRequest{
|
||||
CategoryID: miniflux.SetOptionalField(int64(123456789)),
|
||||
CategoryID: new(int64(123456789)),
|
||||
}
|
||||
|
||||
if _, err := regularUserClient.UpdateFeed(feedID, feedUpdateRequest); err == nil {
|
||||
@@ -2717,8 +2718,8 @@ func TestUpdateEntryEndpoint(t *testing.T) {
|
||||
}
|
||||
|
||||
entryUpdateRequest := &miniflux.EntryModificationRequest{
|
||||
Title: miniflux.SetOptionalField("New title"),
|
||||
Content: miniflux.SetOptionalField("New content"),
|
||||
Title: new("New title"),
|
||||
Content: new("New content"),
|
||||
}
|
||||
|
||||
updatedEntry, err := regularUserClient.UpdateEntry(result.Entries[0].ID, entryUpdateRequest)
|
||||
@@ -2932,3 +2933,55 @@ func TestFlushHistoryEndpoint(t *testing.T) {
|
||||
t.Fatalf(`Invalid total, got %d`, readEntries.Total)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportFeedEntryEndpoint(t *testing.T) {
|
||||
testConfig := newIntegrationTestConfig()
|
||||
if !testConfig.isConfigured() {
|
||||
t.Skip(skipIntegrationTestsMessage)
|
||||
}
|
||||
|
||||
client := miniflux.NewClient(
|
||||
testConfig.testBaseURL,
|
||||
testConfig.testAdminUsername,
|
||||
testConfig.testAdminPassword,
|
||||
)
|
||||
|
||||
// Create a feed
|
||||
feedID, err := client.CreateFeed(&miniflux.FeedCreationRequest{
|
||||
FeedURL: testConfig.testFeedURL,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer client.DeleteFeed(feedID)
|
||||
|
||||
payload := map[string]any{
|
||||
"title": "Imported Entry",
|
||||
"url": "https://example.org/imported-entry",
|
||||
"content": "Hello world",
|
||||
"external_id": "integration-test-entry-1",
|
||||
"status": model.EntryStatusUnread,
|
||||
"starred": false,
|
||||
"published_at": 0,
|
||||
}
|
||||
|
||||
// First import
|
||||
firstID, err := client.ImportFeedEntry(feedID, payload)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if firstID == 0 {
|
||||
t.Fatal("expected non-zero entry ID on first import")
|
||||
}
|
||||
|
||||
// Second import (same payload)
|
||||
secondID, err := client.ImportFeedEntry(feedID, payload)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if secondID != firstID {
|
||||
t.Fatalf("expected same entry ID on re-import, got %d and %d", firstID, secondID)
|
||||
}
|
||||
}
|
||||
|
||||
+112
-5
@@ -11,6 +11,7 @@ import (
|
||||
"time"
|
||||
|
||||
"miniflux.app/v2/internal/config"
|
||||
"miniflux.app/v2/internal/crypto"
|
||||
"miniflux.app/v2/internal/http/request"
|
||||
"miniflux.app/v2/internal/http/response/json"
|
||||
"miniflux.app/v2/internal/integration"
|
||||
@@ -18,6 +19,7 @@ import (
|
||||
"miniflux.app/v2/internal/model"
|
||||
"miniflux.app/v2/internal/reader/processor"
|
||||
"miniflux.app/v2/internal/reader/readingtime"
|
||||
"miniflux.app/v2/internal/reader/sanitizer"
|
||||
"miniflux.app/v2/internal/storage"
|
||||
"miniflux.app/v2/internal/validator"
|
||||
)
|
||||
@@ -274,6 +276,11 @@ func (h *handler) updateEntry(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if entryUpdateRequest.Content != nil {
|
||||
sanitizedContent := sanitizer.SanitizeHTML(entry.URL, *entryUpdateRequest.Content, &sanitizer.SanitizerOptions{OpenLinksInNewTab: user.OpenExternalLinksInNewTab})
|
||||
entryUpdateRequest.Content = &sanitizedContent
|
||||
}
|
||||
|
||||
entryUpdateRequest.Patch(entry)
|
||||
if user.ShowReadingTime {
|
||||
entry.ReadingTime = readingtime.EstimateReadingTime(entry.Content, user.DefaultReadingSpeed, user.CJKReadingSpeed)
|
||||
@@ -287,6 +294,110 @@ func (h *handler) updateEntry(w http.ResponseWriter, r *http.Request) {
|
||||
json.Created(w, r, entry)
|
||||
}
|
||||
|
||||
func (h *handler) importFeedEntry(w http.ResponseWriter, r *http.Request) {
|
||||
userID := request.UserID(r)
|
||||
feedID := request.RouteInt64Param(r, "feedID")
|
||||
|
||||
if feedID <= 0 {
|
||||
json.BadRequest(w, r, errors.New("invalid feed ID"))
|
||||
return
|
||||
}
|
||||
|
||||
if !h.store.FeedExists(userID, feedID) {
|
||||
json.BadRequest(w, r, errors.New("feed does not exist"))
|
||||
return
|
||||
}
|
||||
|
||||
var importRequest entryImportRequest
|
||||
if err := json_parser.NewDecoder(r.Body).Decode(&importRequest); err != nil {
|
||||
json.BadRequest(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if importRequest.URL == "" {
|
||||
json.BadRequest(w, r, errors.New("url is required"))
|
||||
return
|
||||
}
|
||||
|
||||
if importRequest.Status == "" {
|
||||
importRequest.Status = model.EntryStatusRead
|
||||
}
|
||||
|
||||
if err := validator.ValidateEntryStatus(importRequest.Status); err != nil {
|
||||
json.BadRequest(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
entry := model.NewEntry()
|
||||
entry.URL = importRequest.URL
|
||||
entry.CommentsURL = importRequest.CommentsURL
|
||||
entry.Author = importRequest.Author
|
||||
entry.Tags = importRequest.Tags
|
||||
|
||||
if importRequest.PublishedAt > 0 {
|
||||
entry.Date = time.Unix(importRequest.PublishedAt, 0).UTC()
|
||||
} else {
|
||||
entry.Date = time.Now().UTC()
|
||||
}
|
||||
|
||||
if importRequest.Title == "" {
|
||||
entry.Title = entry.URL
|
||||
} else {
|
||||
entry.Title = importRequest.Title
|
||||
}
|
||||
|
||||
hashInput := importRequest.ExternalID
|
||||
if hashInput == "" {
|
||||
hashInput = importRequest.URL
|
||||
}
|
||||
entry.Hash = crypto.HashFromBytes([]byte(hashInput))
|
||||
|
||||
user, err := h.store.UserByID(userID)
|
||||
if err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if user == nil {
|
||||
json.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if importRequest.Content != "" {
|
||||
entry.Content = sanitizer.SanitizeHTML(entry.URL, importRequest.Content, &sanitizer.SanitizerOptions{OpenLinksInNewTab: user.OpenExternalLinksInNewTab})
|
||||
}
|
||||
|
||||
if user.ShowReadingTime {
|
||||
entry.ReadingTime = readingtime.EstimateReadingTime(entry.Content, user.DefaultReadingSpeed, user.CJKReadingSpeed)
|
||||
}
|
||||
|
||||
created, err := h.store.InsertEntryForFeed(userID, feedID, entry)
|
||||
if err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.store.SetEntriesStatus(userID, []int64{entry.ID}, importRequest.Status); err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
entry.Status = importRequest.Status
|
||||
|
||||
if importRequest.Starred {
|
||||
if err := h.store.SetEntriesStarredState(userID, []int64{entry.ID}, true); err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
entry.Starred = true
|
||||
}
|
||||
|
||||
if created {
|
||||
json.Created(w, r, entryIDResponse{ID: entry.ID})
|
||||
} else {
|
||||
json.OK(w, r, entryIDResponse{ID: entry.ID})
|
||||
}
|
||||
}
|
||||
|
||||
func (h *handler) fetchContent(w http.ResponseWriter, r *http.Request) {
|
||||
loggedUserID := request.UserID(r)
|
||||
entryID := request.RouteInt64Param(r, "entryID")
|
||||
@@ -341,13 +452,9 @@ func (h *handler) fetchContent(w http.ResponseWriter, r *http.Request) {
|
||||
json.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
json.OK(w, r, map[string]any{"content": mediaproxy.RewriteDocumentWithRelativeProxyURL(h.router, entry.Content), "reading_time": entry.ReadingTime})
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
json.OK(w, r, map[string]string{"content": entry.Content})
|
||||
json.OK(w, r, entryContentResponse{Content: mediaproxy.RewriteDocumentWithAbsoluteProxyURL(h.router, entry.Content), ReadingTime: entry.ReadingTime})
|
||||
}
|
||||
|
||||
func (h *handler) flushHistory(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -13,11 +13,6 @@ import (
|
||||
func (h *handler) getIconByFeedID(w http.ResponseWriter, r *http.Request) {
|
||||
feedID := request.RouteInt64Param(r, "feedID")
|
||||
|
||||
if !h.store.HasFeedIcon(feedID) {
|
||||
json.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
icon, err := h.store.IconByFeedID(request.UserID(r), feedID)
|
||||
if err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
|
||||
@@ -32,5 +32,5 @@ func (h *handler) importFeeds(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
json.Created(w, r, map[string]string{"message": "Feeds imported successfully"})
|
||||
json.Created(w, r, importFeedsResponse{Message: "Feeds imported successfully"})
|
||||
}
|
||||
|
||||
@@ -18,10 +18,40 @@ type entriesResponse struct {
|
||||
Entries model.Entries `json:"entries"`
|
||||
}
|
||||
|
||||
type integrationsStatusResponse struct {
|
||||
HasIntegrations bool `json:"has_integrations"`
|
||||
}
|
||||
|
||||
type entryIDResponse struct {
|
||||
ID int64 `json:"id"`
|
||||
}
|
||||
|
||||
type entryContentResponse struct {
|
||||
Content string `json:"content"`
|
||||
ReadingTime int `json:"reading_time"`
|
||||
}
|
||||
|
||||
type entryImportRequest struct {
|
||||
URL string `json:"url"`
|
||||
Title string `json:"title"`
|
||||
Content string `json:"content"`
|
||||
Author string `json:"author"`
|
||||
CommentsURL string `json:"comments_url"`
|
||||
PublishedAt int64 `json:"published_at"`
|
||||
Status string `json:"status"`
|
||||
Starred bool `json:"starred"`
|
||||
Tags []string `json:"tags"`
|
||||
ExternalID string `json:"external_id"`
|
||||
}
|
||||
|
||||
type feedCreationResponse struct {
|
||||
FeedID int64 `json:"feed_id"`
|
||||
}
|
||||
|
||||
type importFeedsResponse struct {
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
type versionResponse struct {
|
||||
Version string `json:"version"`
|
||||
Commit string `json:"commit"`
|
||||
|
||||
@@ -126,13 +126,7 @@ func (h *handler) getIntegrationsStatus(w http.ResponseWriter, r *http.Request)
|
||||
|
||||
hasIntegrations := h.store.HasSaveEntry(userID)
|
||||
|
||||
response := struct {
|
||||
HasIntegrations bool `json:"has_integrations"`
|
||||
}{
|
||||
HasIntegrations: hasIntegrations,
|
||||
}
|
||||
|
||||
json.OK(w, r, response)
|
||||
json.OK(w, r, integrationsStatusResponse{HasIntegrations: hasIntegrations})
|
||||
}
|
||||
|
||||
func (h *handler) users(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -47,7 +47,7 @@ func runCleanupTasks(store *storage.Storage) {
|
||||
}
|
||||
}
|
||||
|
||||
if enclosuresAffected, err := store.DeleteRemovedEntriesEnclosures(); err != nil {
|
||||
if enclosuresAffected, err := store.DeleteEnclosuresOfRemovedEntries(); err != nil {
|
||||
slog.Error("Unable to delete enclosures from removed entries", slog.Any("error", err))
|
||||
} else {
|
||||
slog.Info("Deleting enclosures from removed entries completed",
|
||||
|
||||
+8
-2
@@ -30,9 +30,9 @@ const (
|
||||
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).`
|
||||
flagHealthCheckHelp = `Perform a health check on the given endpoint (the value "auto" tries to guess the health check endpoint).`
|
||||
flagRefreshFeedsHelp = "Refresh a batch of feeds and exit"
|
||||
flagRunCleanupTasksHelp = "Run cleanup tasks (delete old sessions and archives old entries)"
|
||||
flagRunCleanupTasksHelp = "Run cleanup tasks (delete old sessions and archive old entries)"
|
||||
flagExportUserFeedsHelp = "Export user feeds (provide the username as argument)"
|
||||
flagResetNextCheckAtHelp = "Reset the next check time for all feeds"
|
||||
)
|
||||
@@ -109,6 +109,12 @@ func Parse() {
|
||||
}
|
||||
}
|
||||
|
||||
if config.Opts.AuthProxyHeader() != "" {
|
||||
if len(config.Opts.TrustedReverseProxyNetworks()) == 0 {
|
||||
printErrorAndExit(errors.New("TRUSTED_REVERSE_PROXY_NETWORKS must be configured when AUTH_PROXY_HEADER is used"))
|
||||
}
|
||||
}
|
||||
|
||||
if flagConfigDump {
|
||||
fmt.Print(config.Opts)
|
||||
return
|
||||
|
||||
+450
-411
File diff suppressed because it is too large
Load Diff
@@ -3,7 +3,10 @@
|
||||
|
||||
package config // import "miniflux.app/v2/internal/config"
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"slices"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestBaseURLOptionParsing(t *testing.T) {
|
||||
configParser := NewConfigParser()
|
||||
@@ -1270,22 +1273,6 @@ func TestDatabaseConnectionLifetimeOptionParsing(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterEntryMaxAgeDaysOptionParsing(t *testing.T) {
|
||||
configParser := NewConfigParser()
|
||||
|
||||
if configParser.options.FilterEntryMaxAgeDays() != 0 {
|
||||
t.Fatalf("Expected FILTER_ENTRY_MAX_AGE_DAYS to be 0 by default")
|
||||
}
|
||||
|
||||
if err := configParser.parseLines([]string{"FILTER_ENTRY_MAX_AGE_DAYS=7"}); err != nil {
|
||||
t.Fatalf("Unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if configParser.options.FilterEntryMaxAgeDays() != 7 {
|
||||
t.Fatalf("Expected FILTER_ENTRY_MAX_AGE_DAYS to be 7 days")
|
||||
}
|
||||
}
|
||||
|
||||
func TestForceRefreshIntervalOptionParsing(t *testing.T) {
|
||||
configParser := NewConfigParser()
|
||||
|
||||
@@ -1364,6 +1351,54 @@ func TestHTTPClientTimeoutOptionParsing(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetcherAllowPrivateNetworksOptionParsing(t *testing.T) {
|
||||
configParser := NewConfigParser()
|
||||
|
||||
if configParser.options.FetcherAllowPrivateNetworks() {
|
||||
t.Fatalf("Expected FETCHER_ALLOW_PRIVATE_NETWORKS to be disabled by default")
|
||||
}
|
||||
|
||||
if err := configParser.parseLines([]string{"FETCHER_ALLOW_PRIVATE_NETWORKS=1"}); err != nil {
|
||||
t.Fatalf("Unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if !configParser.options.FetcherAllowPrivateNetworks() {
|
||||
t.Fatalf("Expected FETCHER_ALLOW_PRIVATE_NETWORKS to be enabled")
|
||||
}
|
||||
|
||||
if err := configParser.parseLines([]string{"FETCHER_ALLOW_PRIVATE_NETWORKS=0"}); err != nil {
|
||||
t.Fatalf("Unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if configParser.options.FetcherAllowPrivateNetworks() {
|
||||
t.Fatalf("Expected FETCHER_ALLOW_PRIVATE_NETWORKS to be disabled")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegrationAllowPrivateNetworksOptionParsing(t *testing.T) {
|
||||
configParser := NewConfigParser()
|
||||
|
||||
if configParser.options.IntegrationAllowPrivateNetworks() {
|
||||
t.Fatalf("Expected INTEGRATION_ALLOW_PRIVATE_NETWORKS to be disabled by default")
|
||||
}
|
||||
|
||||
if err := configParser.parseLines([]string{"INTEGRATION_ALLOW_PRIVATE_NETWORKS=1"}); err != nil {
|
||||
t.Fatalf("Unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if !configParser.options.IntegrationAllowPrivateNetworks() {
|
||||
t.Fatalf("Expected INTEGRATION_ALLOW_PRIVATE_NETWORKS to be enabled")
|
||||
}
|
||||
|
||||
if err := configParser.parseLines([]string{"INTEGRATION_ALLOW_PRIVATE_NETWORKS=0"}); err != nil {
|
||||
t.Fatalf("Unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if configParser.options.IntegrationAllowPrivateNetworks() {
|
||||
t.Fatalf("Expected INTEGRATION_ALLOW_PRIVATE_NETWORKS to be disabled")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPServerTimeoutOptionParsing(t *testing.T) {
|
||||
configParser := NewConfigParser()
|
||||
|
||||
@@ -1584,6 +1619,37 @@ func TestSchedulerRoundRobinMinIntervalOptionParsing(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrustedReverseProxyNetworksOptionParsing(t *testing.T) {
|
||||
configParser := NewConfigParser()
|
||||
|
||||
// Test default value
|
||||
defaultNetworks := configParser.options.TrustedReverseProxyNetworks()
|
||||
if len(defaultNetworks) != 0 {
|
||||
t.Fatalf("Expected 0 allowed networks by default, got %d", len(defaultNetworks))
|
||||
}
|
||||
|
||||
// Test valid value
|
||||
if err := configParser.parseLines([]string{"TRUSTED_REVERSE_PROXY_NETWORKS=10.0.0.0/8,192.168.1.0/24"}); err != nil {
|
||||
t.Fatalf("Unexpected error: %v", err)
|
||||
}
|
||||
|
||||
allowedNetworks := configParser.options.TrustedReverseProxyNetworks()
|
||||
if len(allowedNetworks) != 2 {
|
||||
t.Fatalf("Expected 2 allowed networks, got %d", len(allowedNetworks))
|
||||
}
|
||||
if !slices.Contains(allowedNetworks, "10.0.0.0/8") {
|
||||
t.Errorf("Expected 10.0.0.0/8 in allowed networks")
|
||||
}
|
||||
if !slices.Contains(allowedNetworks, "192.168.1.0/24") {
|
||||
t.Errorf("Expected 192.168.1.0/24 in allowed networks")
|
||||
}
|
||||
|
||||
// Test invalid value
|
||||
if err := configParser.parseLines([]string{"TRUSTED_REVERSE_PROXY_NETWORKS=127.0.0.1"}); err == nil {
|
||||
t.Fatal("Expected error when parsing invalid CIDR notation IP 127.0.0.1, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestYouTubeEmbedDomainOptionParsing(t *testing.T) {
|
||||
configParser := NewConfigParser()
|
||||
|
||||
@@ -1614,8 +1680,8 @@ func TestSetLogLevelFunction(t *testing.T) {
|
||||
if configParser.options.LogLevel() != "debug" {
|
||||
t.Fatalf("Expected LOG_LEVEL to be 'debug' after SetLogLevel('debug'), got '%s'", configParser.options.LogLevel())
|
||||
}
|
||||
if configParser.options.options["LOG_LEVEL"].RawValue != "debug" {
|
||||
t.Fatalf("Expected LOG_LEVEL RawValue to be 'debug', got '%s'", configParser.options.options["LOG_LEVEL"].RawValue)
|
||||
if configParser.options.options["LOG_LEVEL"].rawValue != "debug" {
|
||||
t.Fatalf("Expected LOG_LEVEL RawValue to be 'debug', got '%s'", configParser.options.options["LOG_LEVEL"].rawValue)
|
||||
}
|
||||
|
||||
// Test setting log level to warning
|
||||
@@ -1623,8 +1689,8 @@ func TestSetLogLevelFunction(t *testing.T) {
|
||||
if configParser.options.LogLevel() != "warning" {
|
||||
t.Fatalf("Expected LOG_LEVEL to be 'warning' after SetLogLevel('warning'), got '%s'", configParser.options.LogLevel())
|
||||
}
|
||||
if configParser.options.options["LOG_LEVEL"].RawValue != "warning" {
|
||||
t.Fatalf("Expected LOG_LEVEL RawValue to be 'warning', got '%s'", configParser.options.options["LOG_LEVEL"].RawValue)
|
||||
if configParser.options.options["LOG_LEVEL"].rawValue != "warning" {
|
||||
t.Fatalf("Expected LOG_LEVEL RawValue to be 'warning', got '%s'", configParser.options.options["LOG_LEVEL"].rawValue)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+43
-39
@@ -10,6 +10,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
@@ -51,7 +52,7 @@ func (cp *configParser) ParseFile(filename string) (*configOptions, error) {
|
||||
|
||||
func (cp *configParser) postParsing() error {
|
||||
// Parse basePath and rootURL based on BASE_URL
|
||||
baseURL := cp.options.options["BASE_URL"].ParsedStringValue
|
||||
baseURL := cp.options.options["BASE_URL"].parsedStringValue
|
||||
baseURL = strings.TrimSuffix(baseURL, "/")
|
||||
|
||||
parsedURL, err := url.Parse(baseURL)
|
||||
@@ -64,14 +65,14 @@ func (cp *configParser) postParsing() error {
|
||||
return errors.New("BASE_URL scheme must be http or https")
|
||||
}
|
||||
|
||||
cp.options.options["BASE_URL"].ParsedStringValue = baseURL
|
||||
cp.options.options["BASE_URL"].parsedStringValue = baseURL
|
||||
cp.options.basePath = parsedURL.Path
|
||||
|
||||
parsedURL.Path = ""
|
||||
cp.options.rootURL = parsedURL.String()
|
||||
|
||||
// Parse YouTube embed domain based on YOUTUBE_EMBED_URL_OVERRIDE
|
||||
youTubeEmbedURLOverride := cp.options.options["YOUTUBE_EMBED_URL_OVERRIDE"].ParsedStringValue
|
||||
youTubeEmbedURLOverride := cp.options.options["YOUTUBE_EMBED_URL_OVERRIDE"].parsedStringValue
|
||||
if youTubeEmbedURLOverride != "" {
|
||||
parsedYouTubeEmbedURL, err := url.Parse(youTubeEmbedURLOverride)
|
||||
if err != nil {
|
||||
@@ -81,16 +82,16 @@ func (cp *configParser) postParsing() error {
|
||||
}
|
||||
|
||||
// Generate a media proxy private key if not set
|
||||
if len(cp.options.options["MEDIA_PROXY_PRIVATE_KEY"].ParsedBytesValue) == 0 {
|
||||
if len(cp.options.options["MEDIA_PROXY_PRIVATE_KEY"].parsedBytesValue) == 0 {
|
||||
randomKey := make([]byte, 16)
|
||||
rand.Read(randomKey)
|
||||
cp.options.options["MEDIA_PROXY_PRIVATE_KEY"].ParsedBytesValue = randomKey
|
||||
cp.options.options["MEDIA_PROXY_PRIVATE_KEY"].parsedBytesValue = randomKey
|
||||
}
|
||||
|
||||
// Override LISTEN_ADDR with PORT if set (for compatibility reasons)
|
||||
if cp.options.Port() != "" {
|
||||
cp.options.options["LISTEN_ADDR"].ParsedStringList = []string{":" + cp.options.Port()}
|
||||
cp.options.options["LISTEN_ADDR"].RawValue = ":" + cp.options.Port()
|
||||
cp.options.options["LISTEN_ADDR"].parsedStringList = []string{":" + cp.options.Port()}
|
||||
cp.options.options["LISTEN_ADDR"].rawValue = ":" + cp.options.Port()
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -119,73 +120,76 @@ func (cp *configParser) parseLines(lines []string) error {
|
||||
func (cp *configParser) parseLine(key, value string) error {
|
||||
field, exists := cp.options.options[key]
|
||||
if !exists {
|
||||
if key == "FILTER_ENTRY_MAX_AGE_DAYS" {
|
||||
slog.Warn("Configuration option FILTER_ENTRY_MAX_AGE_DAYS is deprecated; use user filter rule max-age:<duration> instead")
|
||||
}
|
||||
// Ignore unknown configuration keys to avoid parsing unrelated environment variables.
|
||||
return nil
|
||||
}
|
||||
|
||||
// Validate the option if a validator is provided
|
||||
if field.Validator != nil {
|
||||
if err := field.Validator(value); err != nil {
|
||||
if field.validator != nil {
|
||||
if err := field.validator(value); err != nil {
|
||||
return fmt.Errorf("invalid value for key %s: %v", key, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Convert the raw value based on its type
|
||||
switch field.ValueType {
|
||||
switch field.valueType {
|
||||
case stringType:
|
||||
field.ParsedStringValue = parseStringValue(value, field.ParsedStringValue)
|
||||
field.RawValue = value
|
||||
field.parsedStringValue = parseStringValue(value, field.parsedStringValue)
|
||||
field.rawValue = value
|
||||
case stringListType:
|
||||
field.ParsedStringList = parseStringListValue(value, field.ParsedStringList)
|
||||
field.RawValue = value
|
||||
field.parsedStringList = parseStringListValue(value, field.parsedStringList)
|
||||
field.rawValue = value
|
||||
case boolType:
|
||||
parsedValue, err := parseBoolValue(value, field.ParsedBoolValue)
|
||||
parsedValue, err := parseBoolValue(value, field.parsedBoolValue)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid boolean value for key %s: %v", key, err)
|
||||
}
|
||||
field.ParsedBoolValue = parsedValue
|
||||
field.RawValue = value
|
||||
field.parsedBoolValue = parsedValue
|
||||
field.rawValue = value
|
||||
case intType:
|
||||
field.ParsedIntValue = parseIntValue(value, field.ParsedIntValue)
|
||||
field.RawValue = value
|
||||
field.parsedIntValue = parseIntValue(value, field.parsedIntValue)
|
||||
field.rawValue = value
|
||||
case int64Type:
|
||||
field.ParsedInt64Value = ParsedInt64Value(value, field.ParsedInt64Value)
|
||||
field.RawValue = value
|
||||
field.parsedInt64Value = ParsedInt64Value(value, field.parsedInt64Value)
|
||||
field.rawValue = value
|
||||
case secondType:
|
||||
field.ParsedDuration = parseDurationValue(value, time.Second, field.ParsedDuration)
|
||||
field.RawValue = value
|
||||
field.parsedDuration = parseDurationValue(value, time.Second, field.parsedDuration)
|
||||
field.rawValue = value
|
||||
case minuteType:
|
||||
field.ParsedDuration = parseDurationValue(value, time.Minute, field.ParsedDuration)
|
||||
field.RawValue = value
|
||||
field.parsedDuration = parseDurationValue(value, time.Minute, field.parsedDuration)
|
||||
field.rawValue = value
|
||||
case hourType:
|
||||
field.ParsedDuration = parseDurationValue(value, time.Hour, field.ParsedDuration)
|
||||
field.RawValue = value
|
||||
field.parsedDuration = parseDurationValue(value, time.Hour, field.parsedDuration)
|
||||
field.rawValue = value
|
||||
case dayType:
|
||||
field.ParsedDuration = parseDurationValue(value, time.Hour*24, field.ParsedDuration)
|
||||
field.RawValue = value
|
||||
field.parsedDuration = parseDurationValue(value, time.Hour*24, field.parsedDuration)
|
||||
field.rawValue = value
|
||||
case urlType:
|
||||
parsedURL, err := parseURLValue(value, field.ParsedURLValue)
|
||||
parsedURL, err := parseURLValue(value, field.parsedURLValue)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid URL for key %s: %v", key, err)
|
||||
}
|
||||
field.ParsedURLValue = parsedURL
|
||||
field.RawValue = value
|
||||
field.parsedURLValue = parsedURL
|
||||
field.rawValue = value
|
||||
case secretFileType:
|
||||
secretValue, err := readSecretFileValue(value)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error reading secret file for key %s: %v", key, err)
|
||||
}
|
||||
if field.TargetKey != "" {
|
||||
if targetField, ok := cp.options.options[field.TargetKey]; ok {
|
||||
targetField.ParsedStringValue = secretValue
|
||||
targetField.RawValue = secretValue
|
||||
if field.targetKey != "" {
|
||||
if targetField, ok := cp.options.options[field.targetKey]; ok {
|
||||
targetField.parsedStringValue = secretValue
|
||||
targetField.rawValue = secretValue
|
||||
}
|
||||
}
|
||||
field.RawValue = value
|
||||
field.rawValue = value
|
||||
case bytesType:
|
||||
if value != "" {
|
||||
field.ParsedBytesValue = []byte(value)
|
||||
field.RawValue = value
|
||||
field.parsedBytesValue = []byte(value)
|
||||
field.rawValue = value
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -20,8 +20,8 @@ func validateChoices(rawValue string, choices []string) error {
|
||||
|
||||
func validateListChoices(inputValues, choices []string) error {
|
||||
for _, value := range inputValues {
|
||||
if !slices.Contains(choices, value) {
|
||||
return fmt.Errorf("value must be one of: %v", strings.Join(choices, ", "))
|
||||
if err := validateChoices(value, choices); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -435,7 +435,7 @@ var migrations = [...]func(tx *sql.Tx) error{
|
||||
|
||||
hasExtra := false
|
||||
if err := tx.QueryRow(`
|
||||
SELECT true
|
||||
SELECT true
|
||||
FROM information_schema.columns
|
||||
WHERE
|
||||
table_name='users' AND
|
||||
@@ -1373,4 +1373,62 @@ var migrations = [...]func(tx *sql.Tx) error{
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
sql := `
|
||||
ALTER TABLE integrations ADD COLUMN linkwarden_collection_id int;
|
||||
`
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
sql := `
|
||||
ALTER TABLE integrations ADD COLUMN readeck_push_enabled bool default 'f';
|
||||
`
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
// There is no need to keep an index on the content of deleted entries.
|
||||
_, err = tx.Exec(`DROP INDEX document_vectors_idx;`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
sql := `
|
||||
CREATE INDEX document_vectors_idx
|
||||
ON entries
|
||||
USING gin(document_vectors)
|
||||
WHERE status != 'removed';
|
||||
`
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
_, err = tx.Exec(`UPDATE user_sessions SET ip = '127.0.0.1'::inet WHERE ip IS NULL`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = tx.Exec(`UPDATE user_sessions SET created_at = now() WHERE created_at IS NULL`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = tx.Exec(`UPDATE user_sessions SET user_agent = '' WHERE user_agent IS NULL`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = tx.Exec(`
|
||||
ALTER TABLE user_sessions
|
||||
ALTER COLUMN ip SET DEFAULT '127.0.0.1'::inet,
|
||||
ALTER COLUMN ip SET NOT NULL,
|
||||
ALTER COLUMN created_at SET DEFAULT now(),
|
||||
ALTER COLUMN created_at SET NOT NULL,
|
||||
ALTER COLUMN user_agent SET DEFAULT '',
|
||||
ALTER COLUMN user_agent SET NOT NULL
|
||||
`)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
_, err = tx.Exec(`ALTER TABLE feeds ADD COLUMN ignore_entry_updates bool default 'f'`)
|
||||
return err
|
||||
},
|
||||
}
|
||||
|
||||
@@ -279,7 +279,7 @@ func (h *handler) handleItems(w http.ResponseWriter, r *http.Request) {
|
||||
if csvItemIDs != "" {
|
||||
var itemIDs []int64
|
||||
|
||||
for _, strItemID := range strings.Split(csvItemIDs, ",") {
|
||||
for strItemID := range strings.SplitSeq(csvItemIDs, ",") {
|
||||
strItemID = strings.TrimSpace(strItemID)
|
||||
itemID, _ := strconv.ParseInt(strItemID, 10, 64)
|
||||
itemIDs = append(itemIDs, itemID)
|
||||
|
||||
@@ -38,6 +38,7 @@ var (
|
||||
errEmptyFeedTitle = errors.New("googlereader: empty feed title")
|
||||
errFeedNotFound = errors.New("googlereader: feed not found")
|
||||
errCategoryNotFound = errors.New("googlereader: category not found")
|
||||
errSimultaneously = fmt.Errorf("googlereader: %s and %s should not be supplied simultaneously", keptUnreadStreamSuffix, readStreamSuffix)
|
||||
)
|
||||
|
||||
// Serve handles Google Reader API calls.
|
||||
@@ -47,9 +48,7 @@ func Serve(router *mux.Router, store *storage.Storage) {
|
||||
|
||||
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")
|
||||
@@ -71,12 +70,12 @@ func checkAndSimplifyTags(addTags []Stream, removeTags []Stream) (map[StreamType
|
||||
switch s.Type {
|
||||
case ReadStream:
|
||||
if _, ok := tags[KeptUnreadStream]; ok {
|
||||
return nil, fmt.Errorf("googlereader: %s and %s should not be supplied simultaneously", keptUnreadStreamSuffix, readStreamSuffix)
|
||||
return nil, errSimultaneously
|
||||
}
|
||||
tags[ReadStream] = true
|
||||
case KeptUnreadStream:
|
||||
if _, ok := tags[ReadStream]; ok {
|
||||
return nil, fmt.Errorf("googlereader: %s and %s should not be supplied simultaneously", keptUnreadStreamSuffix, readStreamSuffix)
|
||||
return nil, errSimultaneously
|
||||
}
|
||||
tags[ReadStream] = false
|
||||
case StarredStream:
|
||||
@@ -91,12 +90,12 @@ func checkAndSimplifyTags(addTags []Stream, removeTags []Stream) (map[StreamType
|
||||
switch s.Type {
|
||||
case ReadStream:
|
||||
if _, ok := tags[ReadStream]; ok {
|
||||
return nil, fmt.Errorf("googlereader: %s and %s should not be supplied simultaneously", keptUnreadStreamSuffix, readStreamSuffix)
|
||||
return nil, errSimultaneously
|
||||
}
|
||||
tags[ReadStream] = false
|
||||
case KeptUnreadStream:
|
||||
if _, ok := tags[ReadStream]; ok {
|
||||
return nil, fmt.Errorf("googlereader: %s and %s should not be supplied simultaneously", keptUnreadStreamSuffix, readStreamSuffix)
|
||||
return nil, errSimultaneously
|
||||
}
|
||||
tags[ReadStream] = true
|
||||
case StarredStream:
|
||||
@@ -244,7 +243,6 @@ func (h *handler) tokenHandler(w http.ResponseWriter, r *http.Request) {
|
||||
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")
|
||||
@@ -455,7 +453,7 @@ func (h *handler) quickAddHandler(w http.ResponseWriter, r *http.Request) {
|
||||
json.OK(w, r, quickAddResponse{
|
||||
NumResults: 1,
|
||||
Query: newFeed.FeedURL,
|
||||
StreamID: fmt.Sprintf(feedPrefix+"%d", newFeed.ID),
|
||||
StreamID: feedPrefix + strconv.FormatInt(newFeed.ID, 10),
|
||||
StreamName: newFeed.Title,
|
||||
})
|
||||
}
|
||||
@@ -587,9 +585,8 @@ func move(feedStream Stream, labelStream Stream, store *storage.Storage, userID
|
||||
func (h *handler) feedIconURL(f *model.Feed) string {
|
||||
if f.Icon != nil && f.Icon.ExternalIconID != "" {
|
||||
return config.Opts.RootURL() + route.Path(h.router, "feedIcon", "externalIconID", f.Icon.ExternalIconID)
|
||||
} else {
|
||||
return ""
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (h *handler) editSubscriptionHandler(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -700,9 +697,10 @@ func (h *handler) streamItemContentsHandler(w http.ResponseWriter, r *http.Reque
|
||||
return
|
||||
}
|
||||
|
||||
userReadingList := fmt.Sprintf(userStreamPrefix, userID) + readingListStreamSuffix
|
||||
userRead := fmt.Sprintf(userStreamPrefix, userID) + readStreamSuffix
|
||||
userStarred := fmt.Sprintf(userStreamPrefix, userID) + starredStreamSuffix
|
||||
streamPrefix := fmt.Sprintf(userStreamPrefix, userID)
|
||||
userReadingList := streamPrefix + readingListStreamSuffix
|
||||
userRead := streamPrefix + readStreamSuffix
|
||||
userStarred := streamPrefix + starredStreamSuffix
|
||||
|
||||
itemIDs, err := parseItemIDsFromRequest(r)
|
||||
if err != nil {
|
||||
@@ -742,6 +740,7 @@ func (h *handler) streamItemContentsHandler(w http.ResponseWriter, r *http.Reque
|
||||
Items: make([]contentItem, len(entries)),
|
||||
}
|
||||
|
||||
labelPrefix := fmt.Sprintf(userLabelPrefix, userID)
|
||||
for i, entry := range entries {
|
||||
enclosures := make([]contentItemEnclosure, 0, len(entry.Enclosures))
|
||||
for _, enclosure := range entry.Enclosures {
|
||||
@@ -750,7 +749,7 @@ func (h *handler) streamItemContentsHandler(w http.ResponseWriter, r *http.Reque
|
||||
categories := make([]string, 0)
|
||||
categories = append(categories, userReadingList)
|
||||
if entry.Feed.Category.Title != "" {
|
||||
categories = append(categories, fmt.Sprintf(userLabelPrefix, userID)+entry.Feed.Category.Title)
|
||||
categories = append(categories, labelPrefix+entry.Feed.Category.Title)
|
||||
}
|
||||
if entry.Status == model.EntryStatusRead {
|
||||
categories = append(categories, userRead)
|
||||
@@ -792,7 +791,7 @@ func (h *handler) streamItemContentsHandler(w http.ResponseWriter, r *http.Reque
|
||||
Content: entry.Content,
|
||||
},
|
||||
Origin: contentItemOrigin{
|
||||
StreamID: fmt.Sprintf(feedPrefix+"%d", entry.FeedID),
|
||||
StreamID: feedPrefix + strconv.FormatInt(entry.FeedID, 10),
|
||||
Title: entry.Feed.Title,
|
||||
HTMLUrl: entry.Feed.SiteURL,
|
||||
},
|
||||
@@ -893,7 +892,7 @@ func (h *handler) renameTagHandler(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
categoryModificationRequest := model.CategoryModificationRequest{
|
||||
Title: model.SetOptionalField(destination.ID),
|
||||
Title: new(destination.ID),
|
||||
}
|
||||
|
||||
if validationError := validator.ValidateCategoryModification(h.store, userID, category.ID, &categoryModificationRequest); validationError != nil {
|
||||
@@ -936,9 +935,10 @@ func (h *handler) tagListHandler(w http.ResponseWriter, r *http.Request) {
|
||||
result.Tags = append(result.Tags, subscriptionCategoryResponse{
|
||||
ID: fmt.Sprintf(userStreamPrefix, userID) + starredStreamSuffix,
|
||||
})
|
||||
labelPrefix := fmt.Sprintf(userLabelPrefix, userID)
|
||||
for _, category := range categories {
|
||||
result.Tags = append(result.Tags, subscriptionCategoryResponse{
|
||||
ID: fmt.Sprintf(userLabelPrefix, userID) + category.Title,
|
||||
ID: labelPrefix + category.Title,
|
||||
Label: category.Title,
|
||||
Type: "folder",
|
||||
})
|
||||
@@ -968,13 +968,14 @@ func (h *handler) subscriptionListHandler(w http.ResponseWriter, r *http.Request
|
||||
return
|
||||
}
|
||||
|
||||
labelPrefix := fmt.Sprintf(userLabelPrefix, userID)
|
||||
result.Subscriptions = make([]subscriptionResponse, 0)
|
||||
for _, feed := range feeds {
|
||||
result.Subscriptions = append(result.Subscriptions, subscriptionResponse{
|
||||
ID: fmt.Sprintf(feedPrefix+"%d", feed.ID),
|
||||
ID: feedPrefix + strconv.FormatInt(feed.ID, 10),
|
||||
Title: feed.Title,
|
||||
URL: feed.FeedURL,
|
||||
Categories: []subscriptionCategoryResponse{{fmt.Sprintf(userLabelPrefix, userID) + feed.Category.Title, feed.Category.Title, "folder"}},
|
||||
Categories: []subscriptionCategoryResponse{{labelPrefix + feed.Category.Title, feed.Category.Title, "folder"}},
|
||||
HTMLURL: feed.SiteURL,
|
||||
IconURL: h.feedIconURL(feed),
|
||||
})
|
||||
@@ -1003,16 +1004,16 @@ func (h *handler) userInfoHandler(w http.ResponseWriter, r *http.Request) {
|
||||
slog.String("user_agent", r.UserAgent()),
|
||||
)
|
||||
|
||||
if err := checkOutputFormat(r); err != nil {
|
||||
json.BadRequest(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.store.UserByID(request.UserID(r))
|
||||
if err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
if user == nil {
|
||||
json.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
userInfo := userInfoResponse{UserID: strconv.FormatInt(user.ID, 10), UserName: user.Username, UserProfileID: strconv.FormatInt(user.ID, 10), UserEmail: user.Username}
|
||||
json.OK(w, r, userInfo)
|
||||
}
|
||||
@@ -1070,7 +1071,7 @@ func (h *handler) streamItemIDsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
func (h *handler) handleReadingListStreamHandler(w http.ResponseWriter, r *http.Request, rm RequestModifiers) {
|
||||
func (h *handler) handleReadingListStreamHandler(w http.ResponseWriter, r *http.Request, rm requestModifiers) {
|
||||
clientIP := request.ClientIP(r)
|
||||
|
||||
slog.Debug("[GoogleReader] Handle ReadingListStream",
|
||||
@@ -1105,31 +1106,15 @@ func (h *handler) handleReadingListStreamHandler(w http.ResponseWriter, r *http.
|
||||
builder.BeforePublishedDate(time.Unix(rm.StopTime, 0))
|
||||
}
|
||||
|
||||
rawEntryIDs, err := builder.GetEntryIDs()
|
||||
itemRefs, continuation, err := getItemRefsAndContinuation(*builder, rm)
|
||||
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) {
|
||||
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)
|
||||
@@ -1142,32 +1127,15 @@ func (h *handler) handleStarredStreamHandler(w http.ResponseWriter, r *http.Requ
|
||||
if rm.StopTime > 0 {
|
||||
builder.BeforePublishedDate(time.Unix(rm.StopTime, 0))
|
||||
}
|
||||
|
||||
rawEntryIDs, err := builder.GetEntryIDs()
|
||||
itemRefs, continuation, err := getItemRefsAndContinuation(*builder, rm)
|
||||
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) {
|
||||
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)
|
||||
@@ -1181,12 +1149,20 @@ func (h *handler) handleReadStreamHandler(w http.ResponseWriter, r *http.Request
|
||||
builder.BeforePublishedDate(time.Unix(rm.StopTime, 0))
|
||||
}
|
||||
|
||||
rawEntryIDs, err := builder.GetEntryIDs()
|
||||
itemRefs, continuation, err := getItemRefsAndContinuation(*builder, rm)
|
||||
if err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
var itemRefs = make([]itemRef, 0)
|
||||
json.OK(w, r, streamIDResponse{itemRefs, continuation})
|
||||
}
|
||||
|
||||
func getItemRefsAndContinuation(builder storage.EntryQueryBuilder, rm requestModifiers) ([]itemRef, int, error) {
|
||||
rawEntryIDs, err := builder.GetEntryIDs()
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
var itemRefs = make([]itemRef, 0, len(rawEntryIDs))
|
||||
for _, entryID := range rawEntryIDs {
|
||||
formattedID := strconv.FormatInt(entryID, 10)
|
||||
itemRefs = append(itemRefs, itemRef{ID: formattedID})
|
||||
@@ -1194,18 +1170,16 @@ func (h *handler) handleReadStreamHandler(w http.ResponseWriter, r *http.Request
|
||||
|
||||
totalEntries, err := builder.CountEntries()
|
||||
if err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
return
|
||||
return nil, 0, err
|
||||
}
|
||||
continuation := 0
|
||||
if len(itemRefs)+rm.Offset < totalEntries {
|
||||
continuation = len(itemRefs) + rm.Offset
|
||||
}
|
||||
|
||||
json.OK(w, r, streamIDResponse{itemRefs, continuation})
|
||||
return itemRefs, continuation, nil
|
||||
}
|
||||
|
||||
func (h *handler) handleFeedStreamHandler(w http.ResponseWriter, r *http.Request, rm RequestModifiers) {
|
||||
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)
|
||||
@@ -1234,30 +1208,11 @@ func (h *handler) handleFeedStreamHandler(w http.ResponseWriter, r *http.Request
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
rawEntryIDs, err := builder.GetEntryIDs()
|
||||
itemRefs, continuation, err := getItemRefsAndContinuation(*builder, rm)
|
||||
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})
|
||||
}
|
||||
|
||||
|
||||
@@ -25,19 +25,6 @@ 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)
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
"miniflux.app/v2/internal/http/request"
|
||||
)
|
||||
|
||||
type RequestModifiers struct {
|
||||
type requestModifiers struct {
|
||||
ExcludeTargets []Stream
|
||||
FilterTargets []Stream
|
||||
Streams []Stream
|
||||
@@ -24,7 +24,7 @@ type RequestModifiers struct {
|
||||
UserID int64
|
||||
}
|
||||
|
||||
func (r RequestModifiers) String() string {
|
||||
func (r requestModifiers) String() string {
|
||||
var results []string
|
||||
|
||||
results = append(results, fmt.Sprintf("UserID: %d", r.UserID))
|
||||
@@ -57,9 +57,9 @@ func (r RequestModifiers) String() string {
|
||||
return strings.Join(results, "; ")
|
||||
}
|
||||
|
||||
func parseStreamFilterFromRequest(r *http.Request) (RequestModifiers, error) {
|
||||
func parseStreamFilterFromRequest(r *http.Request) (requestModifiers, error) {
|
||||
userID := request.UserID(r)
|
||||
result := RequestModifiers{
|
||||
result := requestModifiers{
|
||||
SortDirection: "desc",
|
||||
UserID: userID,
|
||||
}
|
||||
@@ -71,16 +71,16 @@ func parseStreamFilterFromRequest(r *http.Request) (RequestModifiers, error) {
|
||||
var err error
|
||||
result.Streams, err = getStreams(request.QueryStringParamList(r, paramStreamID), userID)
|
||||
if err != nil {
|
||||
return RequestModifiers{}, err
|
||||
return requestModifiers{}, err
|
||||
}
|
||||
result.ExcludeTargets, err = getStreams(request.QueryStringParamList(r, paramStreamExcludes), userID)
|
||||
if err != nil {
|
||||
return RequestModifiers{}, err
|
||||
return requestModifiers{}, err
|
||||
}
|
||||
|
||||
result.FilterTargets, err = getStreams(request.QueryStringParamList(r, paramStreamFilters), userID)
|
||||
if err != nil {
|
||||
return RequestModifiers{}, err
|
||||
return requestModifiers{}, err
|
||||
}
|
||||
|
||||
result.Count = request.QueryIntParam(r, paramStreamMaxItems, 0)
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package client // import "miniflux.app/v2/internal/http/client"
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"miniflux.app/v2/internal/urllib"
|
||||
)
|
||||
|
||||
// ErrPrivateNetwork is returned when a connection to a private network is blocked.
|
||||
var ErrPrivateNetwork = errors.New("client: connection to private network is blocked")
|
||||
|
||||
// Options holds configuration for creating an HTTP client.
|
||||
type Options struct {
|
||||
Timeout time.Duration
|
||||
BlockPrivateNetworks bool
|
||||
}
|
||||
|
||||
// NewClientWithOptions creates a new HTTP client with the specified options.
|
||||
func NewClientWithOptions(opts Options) *http.Client {
|
||||
if !opts.BlockPrivateNetworks {
|
||||
return &http.Client{Timeout: opts.Timeout}
|
||||
}
|
||||
|
||||
dialer := &net.Dialer{
|
||||
Timeout: opts.Timeout,
|
||||
}
|
||||
|
||||
transport := &http.Transport{
|
||||
// The check is performed at connect time on the actual resolved IP, which eliminates TOCTOU / DNS-rebinding vulnerabilities.
|
||||
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
host, port, err := net.SplitHostPort(addr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("client: unable to parse address %q: %w", addr, err)
|
||||
}
|
||||
|
||||
ips, err := net.LookupIP(host)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("client: unable to resolve host %q: %w", host, err)
|
||||
}
|
||||
|
||||
var safeIP net.IP
|
||||
for _, ip := range ips {
|
||||
if !urllib.IsNonPublicIP(ip) {
|
||||
safeIP = ip
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if safeIP == nil {
|
||||
return nil, fmt.Errorf("%w: host %q resolves to a non-public IP address", ErrPrivateNetwork, host)
|
||||
}
|
||||
|
||||
safeAddr := net.JoinHostPort(safeIP.String(), port)
|
||||
return dialer.DialContext(ctx, network, safeAddr)
|
||||
},
|
||||
}
|
||||
|
||||
return &http.Client{
|
||||
Timeout: opts.Timeout,
|
||||
Transport: transport,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package client
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestNewClientWithoutBlockingPrivateNetworks(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClientWithOptions(Options{Timeout: 5 * time.Second})
|
||||
resp, err := client.Get(server.URL)
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("Expected status 200, got %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBlockPrivateNetworksBlocksLoopback(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClientWithOptions(Options{Timeout: 5 * time.Second, BlockPrivateNetworks: true})
|
||||
_, err := client.Get(server.URL)
|
||||
if err == nil {
|
||||
t.Fatal("Expected an error when connecting to loopback address, got nil")
|
||||
}
|
||||
|
||||
if !errors.Is(err, ErrPrivateNetwork) {
|
||||
t.Fatalf("Expected ErrPrivateNetwork, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBlockPrivateNetworksAllowsPublicIPs(t *testing.T) {
|
||||
client := NewClientWithOptions(Options{Timeout: 5 * time.Second, BlockPrivateNetworks: true})
|
||||
if client == nil {
|
||||
t.Fatal("Expected non-nil client")
|
||||
}
|
||||
|
||||
transport, ok := client.Transport.(*http.Transport)
|
||||
if !ok {
|
||||
t.Fatal("Expected custom http.Transport when blockPrivateNetworks is true")
|
||||
}
|
||||
if transport.DialContext == nil {
|
||||
t.Fatal("Expected custom DialContext when blockPrivateNetworks is true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoCustomTransportWhenNotBlocking(t *testing.T) {
|
||||
client := NewClientWithOptions(Options{Timeout: 5 * time.Second})
|
||||
if client.Transport != nil {
|
||||
t.Fatal("Expected nil transport when blockPrivateNetworks is false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBlockPrivateNetworksBlocksPrivateIP(t *testing.T) {
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create listener: %v", err)
|
||||
}
|
||||
defer listener.Close()
|
||||
|
||||
server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
server.Listener = listener
|
||||
server.Start()
|
||||
defer server.Close()
|
||||
|
||||
client := NewClientWithOptions(Options{Timeout: 5 * time.Second, BlockPrivateNetworks: true})
|
||||
_, err = client.Get(server.URL)
|
||||
if err == nil {
|
||||
t.Fatal("Expected error when connecting to private IP")
|
||||
}
|
||||
|
||||
if !errors.Is(err, ErrPrivateNetwork) {
|
||||
t.Fatalf("Expected ErrPrivateNetwork, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBlockPrivateNetworksAllowsLoopbackWhenDisabled(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClientWithOptions(Options{Timeout: 5 * time.Second})
|
||||
resp, err := client.Get(server.URL)
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error when blockPrivateNetworks is false, got %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("Expected status 200, got %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
@@ -9,19 +9,46 @@ import (
|
||||
"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)
|
||||
// IsTrustedIP reports whether the given remote IP address belongs to one of the trusted networks.
|
||||
func IsTrustedIP(remoteIP string, trustedNetworks []string) bool {
|
||||
if len(trustedNetworks) == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
if value != "" {
|
||||
addresses := strings.Split(value, ",")
|
||||
address := strings.TrimSpace(addresses[0])
|
||||
address = dropIPv6zone(address)
|
||||
ip := net.ParseIP(remoteIP)
|
||||
if ip == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
if net.ParseIP(address) != nil {
|
||||
return address
|
||||
for _, cidr := range trustedNetworks {
|
||||
_, network, err := net.ParseCIDR(cidr)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if network.Contains(ip) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// FindClientIP returns the real client IP address using trusted reverse-proxy headers when allowed.
|
||||
func FindClientIP(r *http.Request, isTrustedProxyClient bool) string {
|
||||
if isTrustedProxyClient {
|
||||
headers := [...]string{"X-Forwarded-For", "X-Real-Ip"}
|
||||
for _, header := range headers {
|
||||
value := r.Header.Get(header)
|
||||
|
||||
if value != "" {
|
||||
addresses := strings.Split(value, ",")
|
||||
address := strings.TrimSpace(addresses[0])
|
||||
address = dropIPv6zone(address)
|
||||
|
||||
if net.ParseIP(address) != nil {
|
||||
return address
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -30,19 +57,35 @@ func FindClientIP(r *http.Request) string {
|
||||
return FindRemoteIP(r)
|
||||
}
|
||||
|
||||
// FindRemoteIP returns remote client IP address without considering HTTP headers.
|
||||
// FindRemoteIP returns the parsed remote IP address from the request,
|
||||
// falling back to 127.0.0.1 if the address is empty, a unix socket, or invalid.
|
||||
func FindRemoteIP(r *http.Request) string {
|
||||
remoteIP, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||
if err != nil {
|
||||
remoteIP = r.RemoteAddr
|
||||
if r.RemoteAddr == "@" || r.RemoteAddr == "" {
|
||||
return "127.0.0.1"
|
||||
}
|
||||
return dropIPv6zone(remoteIP)
|
||||
|
||||
// If it looks like it has a port (IPv4:port or [IPv6]:port), try to split it.
|
||||
ip, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||
if err != nil {
|
||||
// No port — could be a bare IPv4, IPv6, or IPv6 with zone.
|
||||
ip = r.RemoteAddr
|
||||
}
|
||||
|
||||
// Strip IPv6 zone identifier if present (e.g., %eth0).
|
||||
ip = dropIPv6zone(ip)
|
||||
|
||||
// Validate the IP address.
|
||||
if net.ParseIP(ip) == nil {
|
||||
return "127.0.0.1"
|
||||
}
|
||||
|
||||
return ip
|
||||
}
|
||||
|
||||
func dropIPv6zone(address string) string {
|
||||
i := strings.IndexByte(address, '%')
|
||||
if i != -1 {
|
||||
address = address[:i]
|
||||
idx := strings.IndexByte(address, '%')
|
||||
if idx != -1 {
|
||||
address = address[:idx]
|
||||
}
|
||||
return address
|
||||
}
|
||||
|
||||
@@ -10,27 +10,37 @@ import (
|
||||
|
||||
func TestFindClientIPWithoutHeaders(t *testing.T) {
|
||||
r := &http.Request{RemoteAddr: "192.168.0.1:4242"}
|
||||
if ip := FindClientIP(r); ip != "192.168.0.1" {
|
||||
if ip := FindClientIP(r, false); ip != "192.168.0.1" {
|
||||
t.Fatalf(`Unexpected result, got: %q`, ip)
|
||||
}
|
||||
|
||||
r = &http.Request{RemoteAddr: "192.168.0.1"}
|
||||
if ip := FindClientIP(r); ip != "192.168.0.1" {
|
||||
if ip := FindClientIP(r, false); ip != "192.168.0.1" {
|
||||
t.Fatalf(`Unexpected result, got: %q`, ip)
|
||||
}
|
||||
|
||||
r = &http.Request{RemoteAddr: "fe80::14c2:f039:edc7:edc7"}
|
||||
if ip := FindClientIP(r); ip != "fe80::14c2:f039:edc7:edc7" {
|
||||
if ip := FindClientIP(r, false); ip != "fe80::14c2:f039:edc7:edc7" {
|
||||
t.Fatalf(`Unexpected result, got: %q`, ip)
|
||||
}
|
||||
|
||||
r = &http.Request{RemoteAddr: "fe80::14c2:f039:edc7:edc7%eth0"}
|
||||
if ip := FindClientIP(r); ip != "fe80::14c2:f039:edc7:edc7" {
|
||||
if ip := FindClientIP(r, false); ip != "fe80::14c2:f039:edc7:edc7" {
|
||||
t.Fatalf(`Unexpected result, got: %q`, ip)
|
||||
}
|
||||
|
||||
r = &http.Request{RemoteAddr: "[fe80::14c2:f039:edc7:edc7%eth0]:4242"}
|
||||
if ip := FindClientIP(r); ip != "fe80::14c2:f039:edc7:edc7" {
|
||||
if ip := FindClientIP(r, false); ip != "fe80::14c2:f039:edc7:edc7" {
|
||||
t.Fatalf(`Unexpected result, got: %q`, ip)
|
||||
}
|
||||
|
||||
r = &http.Request{RemoteAddr: "@"}
|
||||
if ip := FindClientIP(r, false); ip != "127.0.0.1" {
|
||||
t.Fatalf(`Unexpected result, got: %q`, ip)
|
||||
}
|
||||
|
||||
r = &http.Request{RemoteAddr: ""}
|
||||
if ip := FindClientIP(r, false); ip != "127.0.0.1" {
|
||||
t.Fatalf(`Unexpected result, got: %q`, ip)
|
||||
}
|
||||
}
|
||||
@@ -41,7 +51,7 @@ func TestFindClientIPWithXFFHeader(t *testing.T) {
|
||||
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" {
|
||||
if ip := FindClientIP(r, true); ip != "203.0.113.195" {
|
||||
t.Fatalf(`Unexpected result, got: %q`, ip)
|
||||
}
|
||||
|
||||
@@ -50,7 +60,7 @@ func TestFindClientIPWithXFFHeader(t *testing.T) {
|
||||
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" {
|
||||
if ip := FindClientIP(r, true); ip != "2001:db8:85a3:8d3:1319:8a2e:370:7348" {
|
||||
t.Fatalf(`Unexpected result, got: %q`, ip)
|
||||
}
|
||||
|
||||
@@ -59,7 +69,7 @@ func TestFindClientIPWithXFFHeader(t *testing.T) {
|
||||
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" {
|
||||
if ip := FindClientIP(r, true); ip != "fe80::14c2:f039:edc7:edc7" {
|
||||
t.Fatalf(`Unexpected result, got: %q`, ip)
|
||||
}
|
||||
|
||||
@@ -68,7 +78,7 @@ func TestFindClientIPWithXFFHeader(t *testing.T) {
|
||||
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" {
|
||||
if ip := FindClientIP(r, true); ip != "70.41.3.18" {
|
||||
t.Fatalf(`Unexpected result, got: %q`, ip)
|
||||
}
|
||||
|
||||
@@ -77,7 +87,7 @@ func TestFindClientIPWithXFFHeader(t *testing.T) {
|
||||
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" {
|
||||
if ip := FindClientIP(r, true); ip != "192.168.0.1" {
|
||||
t.Fatalf(`Unexpected result, got: %q`, ip)
|
||||
}
|
||||
}
|
||||
@@ -87,7 +97,7 @@ func TestClientIPWithXRealIPHeader(t *testing.T) {
|
||||
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" {
|
||||
if ip := FindClientIP(r, true); ip != "192.168.122.1" {
|
||||
t.Fatalf(`Unexpected result, got: %q`, ip)
|
||||
}
|
||||
}
|
||||
@@ -99,15 +109,7 @@ func TestClientIPWithBothHeaders(t *testing.T) {
|
||||
|
||||
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 != "@" {
|
||||
if ip := FindClientIP(r, true); ip != "203.0.113.195" {
|
||||
t.Fatalf(`Unexpected result, got: %q`, ip)
|
||||
}
|
||||
}
|
||||
@@ -119,7 +121,62 @@ func TestClientIPWithUnixSocketRemoteAddrAndBothHeaders(t *testing.T) {
|
||||
|
||||
r := &http.Request{RemoteAddr: "@", Header: headers}
|
||||
|
||||
if ip := FindClientIP(r); ip != "203.0.113.195" {
|
||||
if ip := FindClientIP(r, true); ip != "203.0.113.195" {
|
||||
t.Fatalf(`Unexpected result, got: %q`, ip)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsTrustedIP(t *testing.T) {
|
||||
trustedNetworks := []string{"127.0.0.1/8", "10.0.0.0/8", "::1/128", "invalid"}
|
||||
|
||||
scenarios := []struct {
|
||||
ip string
|
||||
expected bool
|
||||
}{
|
||||
{"127.0.0.1", true},
|
||||
{"10.0.0.1", true},
|
||||
{"::1", true},
|
||||
{"192.168.1.1", false},
|
||||
{"invalid", false},
|
||||
{"@", false},
|
||||
{"/tmp/miniflux.sock", false},
|
||||
{"", false},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
result := IsTrustedIP(scenario.ip, trustedNetworks)
|
||||
if result != scenario.expected {
|
||||
t.Errorf("Expected %v for IP %s, got %v", scenario.expected, scenario.ip, result)
|
||||
}
|
||||
}
|
||||
|
||||
if IsTrustedIP("127.0.0.1", nil) {
|
||||
t.Error("Expected false when no trusted networks are defined")
|
||||
}
|
||||
|
||||
if IsTrustedIP("127.0.0.1", []string{}) {
|
||||
t.Error("Expected false when trusted networks list is empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindRemoteIP(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
ip string
|
||||
expected string
|
||||
}{
|
||||
{"192.168.0.1:4242", "192.168.0.1"},
|
||||
{"[2001:db8::1]:4242", "2001:db8::1"},
|
||||
{"fe80::14c2:f039:edc7:edc7%eth0", "fe80::14c2:f039:edc7:edc7"},
|
||||
{"", "127.0.0.1"},
|
||||
{"@", "127.0.0.1"},
|
||||
{"invalid", "127.0.0.1"},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
r := &http.Request{RemoteAddr: scenario.ip}
|
||||
result := FindRemoteIP(r)
|
||||
if result != scenario.expected {
|
||||
t.Errorf("Expected %q for RemoteAddr %q, got %q", scenario.expected, scenario.ip, result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@ const (
|
||||
WebAuthnDataContextKey
|
||||
)
|
||||
|
||||
// WebAuthnSessionData returns WebAuthn session data from the request context, or nil if absent.
|
||||
func WebAuthnSessionData(r *http.Request) *model.WebAuthnSession {
|
||||
if v := r.Context().Value(WebAuthnDataContextKey); v != nil {
|
||||
if value, valid := v.(model.WebAuthnSession); valid {
|
||||
@@ -45,27 +46,27 @@ func WebAuthnSessionData(r *http.Request) *model.WebAuthnSession {
|
||||
return nil
|
||||
}
|
||||
|
||||
// GoogleReaderToken returns the google reader token if it exists.
|
||||
// GoogleReaderToken returns the Google Reader token from the request context, if present.
|
||||
func GoogleReaderToken(r *http.Request) string {
|
||||
return getContextStringValue(r, GoogleReaderTokenKey)
|
||||
}
|
||||
|
||||
// IsAdminUser checks if the logged user is administrator.
|
||||
// IsAdminUser reports whether the logged-in user is an administrator.
|
||||
func IsAdminUser(r *http.Request) bool {
|
||||
return getContextBoolValue(r, IsAdminUserContextKey)
|
||||
}
|
||||
|
||||
// IsAuthenticated returns a boolean if the user is authenticated.
|
||||
// IsAuthenticated reports whether the user is authenticated.
|
||||
func IsAuthenticated(r *http.Request) bool {
|
||||
return getContextBoolValue(r, IsAuthenticatedContextKey)
|
||||
}
|
||||
|
||||
// UserID returns the UserID of the logged user.
|
||||
// UserID returns the logged-in user's ID from the request context.
|
||||
func UserID(r *http.Request) int64 {
|
||||
return getContextInt64Value(r, UserIDContextKey)
|
||||
}
|
||||
|
||||
// UserName returns the username of the logged user.
|
||||
// UserName returns the logged-in user's username, or "unknown" when unset.
|
||||
func UserName(r *http.Request) string {
|
||||
value := getContextStringValue(r, UserNameContextKey)
|
||||
if value == "" {
|
||||
@@ -74,7 +75,7 @@ func UserName(r *http.Request) string {
|
||||
return value
|
||||
}
|
||||
|
||||
// UserTimezone returns the timezone used by the logged user.
|
||||
// UserTimezone returns the user's timezone, defaulting to "UTC" when unset.
|
||||
func UserTimezone(r *http.Request) string {
|
||||
value := getContextStringValue(r, UserTimezoneContextKey)
|
||||
if value == "" {
|
||||
@@ -83,7 +84,7 @@ func UserTimezone(r *http.Request) string {
|
||||
return value
|
||||
}
|
||||
|
||||
// UserLanguage get the locale used by the current logged user.
|
||||
// UserLanguage returns the user's locale, defaulting to "en_US" when unset.
|
||||
func UserLanguage(r *http.Request) string {
|
||||
language := getContextStringValue(r, UserLanguageContextKey)
|
||||
if language == "" {
|
||||
@@ -92,7 +93,7 @@ func UserLanguage(r *http.Request) string {
|
||||
return language
|
||||
}
|
||||
|
||||
// UserTheme get the theme used by the current logged user.
|
||||
// UserTheme returns the user's theme, defaulting to "system_serif" when unset.
|
||||
func UserTheme(r *http.Request) string {
|
||||
theme := getContextStringValue(r, UserThemeContextKey)
|
||||
if theme == "" {
|
||||
@@ -101,41 +102,42 @@ func UserTheme(r *http.Request) string {
|
||||
return theme
|
||||
}
|
||||
|
||||
// CSRF returns the current CSRF token.
|
||||
// CSRF returns the CSRF token from the request context.
|
||||
func CSRF(r *http.Request) string {
|
||||
return getContextStringValue(r, CSRFContextKey)
|
||||
}
|
||||
|
||||
// SessionID returns the current session ID.
|
||||
// SessionID returns the current session ID from the request context.
|
||||
func SessionID(r *http.Request) string {
|
||||
return getContextStringValue(r, SessionIDContextKey)
|
||||
}
|
||||
|
||||
// UserSessionToken returns the current user session token.
|
||||
// UserSessionToken returns the current user session token from the request context.
|
||||
func UserSessionToken(r *http.Request) string {
|
||||
return getContextStringValue(r, UserSessionTokenContextKey)
|
||||
}
|
||||
|
||||
// OAuth2State returns the current OAuth2 state.
|
||||
// OAuth2State returns the OAuth2 state value from the request context.
|
||||
func OAuth2State(r *http.Request) string {
|
||||
return getContextStringValue(r, OAuth2StateContextKey)
|
||||
}
|
||||
|
||||
// OAuth2CodeVerifier returns the OAuth2 PKCE code verifier from the request context.
|
||||
func OAuth2CodeVerifier(r *http.Request) string {
|
||||
return getContextStringValue(r, OAuth2CodeVerifierContextKey)
|
||||
}
|
||||
|
||||
// FlashMessage returns the message message if any.
|
||||
// FlashMessage returns the flash message from the request context, if any.
|
||||
func FlashMessage(r *http.Request) string {
|
||||
return getContextStringValue(r, FlashMessageContextKey)
|
||||
}
|
||||
|
||||
// FlashErrorMessage returns the message error message if any.
|
||||
// FlashErrorMessage returns the flash error message from the request context, if any.
|
||||
func FlashErrorMessage(r *http.Request) string {
|
||||
return getContextStringValue(r, FlashErrorMessageContextKey)
|
||||
}
|
||||
|
||||
// LastForceRefresh returns the last force refresh timestamp.
|
||||
// LastForceRefresh returns the last force refresh timestamp from the request context.
|
||||
func LastForceRefresh(r *http.Request) time.Time {
|
||||
jsonStringValue := getContextStringValue(r, LastForceRefreshContextKey)
|
||||
timestamp, err := strconv.ParseInt(jsonStringValue, 10, 64)
|
||||
@@ -145,7 +147,7 @@ func LastForceRefresh(r *http.Request) time.Time {
|
||||
return time.Unix(timestamp, 0)
|
||||
}
|
||||
|
||||
// ClientIP returns the client IP address stored in the context.
|
||||
// ClientIP returns the client IP address stored in the request context.
|
||||
func ClientIP(r *http.Request) string {
|
||||
return getContextStringValue(r, ClientIPContextKey)
|
||||
}
|
||||
|
||||
@@ -7,6 +7,9 @@ import (
|
||||
"context"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"miniflux.app/v2/internal/model"
|
||||
)
|
||||
|
||||
func TestContextStringValue(t *testing.T) {
|
||||
@@ -192,6 +195,28 @@ func TestUserID(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserName(t *testing.T) {
|
||||
r, _ := http.NewRequest("GET", "http://example.org", nil)
|
||||
|
||||
result := UserName(r)
|
||||
expected := "unknown"
|
||||
|
||||
if result != expected {
|
||||
t.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
ctx = context.WithValue(ctx, UserNameContextKey, "jane")
|
||||
r = r.WithContext(ctx)
|
||||
|
||||
result = UserName(r)
|
||||
expected = "jane"
|
||||
|
||||
if result != expected {
|
||||
t.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserTimezone(t *testing.T) {
|
||||
r, _ := http.NewRequest("GET", "http://example.org", nil)
|
||||
|
||||
@@ -346,6 +371,28 @@ func TestOAuth2State(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestOAuth2CodeVerifier(t *testing.T) {
|
||||
r, _ := http.NewRequest("GET", "http://example.org", nil)
|
||||
|
||||
result := OAuth2CodeVerifier(r)
|
||||
expected := ""
|
||||
|
||||
if result != expected {
|
||||
t.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
ctx = context.WithValue(ctx, OAuth2CodeVerifierContextKey, "verifier")
|
||||
r = r.WithContext(ctx)
|
||||
|
||||
result = OAuth2CodeVerifier(r)
|
||||
expected = "verifier"
|
||||
|
||||
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)
|
||||
|
||||
@@ -390,6 +437,67 @@ func TestFlashErrorMessage(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLastForceRefresh(t *testing.T) {
|
||||
r, _ := http.NewRequest("GET", "http://example.org", nil)
|
||||
|
||||
result := LastForceRefresh(r)
|
||||
expected := time.Time{}
|
||||
|
||||
if !result.Equal(expected) {
|
||||
t.Errorf(`Unexpected context value, got %v instead of %v`, result, expected)
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
ctx = context.WithValue(ctx, LastForceRefreshContextKey, "not-a-timestamp")
|
||||
r = r.WithContext(ctx)
|
||||
|
||||
result = LastForceRefresh(r)
|
||||
expected = time.Time{}
|
||||
|
||||
if !result.Equal(expected) {
|
||||
t.Errorf(`Unexpected context value, got %v instead of %v`, result, expected)
|
||||
}
|
||||
|
||||
ctx = r.Context()
|
||||
ctx = context.WithValue(ctx, LastForceRefreshContextKey, "1700000000")
|
||||
r = r.WithContext(ctx)
|
||||
|
||||
result = LastForceRefresh(r)
|
||||
expected = time.Unix(1700000000, 0)
|
||||
|
||||
if !result.Equal(expected) {
|
||||
t.Errorf(`Unexpected context value, got %v instead of %v`, result, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebAuthnSessionData(t *testing.T) {
|
||||
r, _ := http.NewRequest("GET", "http://example.org", nil)
|
||||
|
||||
result := WebAuthnSessionData(r)
|
||||
if result != nil {
|
||||
t.Errorf("Unexpected context value, got %v instead of nil", result)
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
ctx = context.WithValue(ctx, WebAuthnDataContextKey, "invalid")
|
||||
r = r.WithContext(ctx)
|
||||
|
||||
result = WebAuthnSessionData(r)
|
||||
if result != nil {
|
||||
t.Errorf("Unexpected context value, got %v instead of nil", result)
|
||||
}
|
||||
|
||||
session := model.WebAuthnSession{}
|
||||
ctx = r.Context()
|
||||
ctx = context.WithValue(ctx, WebAuthnDataContextKey, session)
|
||||
r = r.WithContext(ctx)
|
||||
|
||||
result = WebAuthnSessionData(r)
|
||||
if result == nil {
|
||||
t.Errorf("Unexpected context value, got nil instead of session")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientIP(t *testing.T) {
|
||||
r, _ := http.NewRequest("GET", "http://example.org", nil)
|
||||
|
||||
@@ -411,3 +519,25 @@ func TestClientIP(t *testing.T) {
|
||||
t.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGoogleReaderToken(t *testing.T) {
|
||||
r, _ := http.NewRequest("GET", "http://example.org", nil)
|
||||
|
||||
result := GoogleReaderToken(r)
|
||||
expected := ""
|
||||
|
||||
if result != expected {
|
||||
t.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
ctx = context.WithValue(ctx, GoogleReaderTokenKey, "token")
|
||||
r = r.WithContext(ctx)
|
||||
|
||||
result = GoogleReaderToken(r)
|
||||
expected = "token"
|
||||
|
||||
if result != expected {
|
||||
t.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ package request // import "miniflux.app/v2/internal/http/request"
|
||||
|
||||
import "net/http"
|
||||
|
||||
// CookieValue returns the cookie value.
|
||||
// CookieValue returns the named cookie value, or an empty string if the cookie is missing.
|
||||
func CookieValue(r *http.Request, name string) string {
|
||||
cookie, err := r.Cookie(name)
|
||||
if err != nil {
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
// FormInt64Value returns a form value as integer.
|
||||
// FormInt64Value returns the named form value parsed as int64, or 0 on error.
|
||||
func FormInt64Value(r *http.Request, param string) int64 {
|
||||
value := r.FormValue(param)
|
||||
integer, err := strconv.ParseInt(value, 10, 64)
|
||||
@@ -22,7 +22,7 @@ func FormInt64Value(r *http.Request, param string) int64 {
|
||||
return integer
|
||||
}
|
||||
|
||||
// RouteInt64Param returns an URL route parameter as int64.
|
||||
// RouteInt64Param returns the named route parameter parsed as int64, or 0 when missing or invalid.
|
||||
func RouteInt64Param(r *http.Request, param string) int64 {
|
||||
vars := mux.Vars(r)
|
||||
value, err := strconv.ParseInt(vars[param], 10, 64)
|
||||
@@ -37,13 +37,13 @@ func RouteInt64Param(r *http.Request, param string) int64 {
|
||||
return value
|
||||
}
|
||||
|
||||
// RouteStringParam returns a URL route parameter as string.
|
||||
// RouteStringParam returns the named route parameter as a string.
|
||||
func RouteStringParam(r *http.Request, param string) string {
|
||||
vars := mux.Vars(r)
|
||||
return vars[param]
|
||||
}
|
||||
|
||||
// QueryStringParam returns a query string parameter as string.
|
||||
// QueryStringParam returns the named query parameter, or defaultValue if it is empty.
|
||||
func QueryStringParam(r *http.Request, param, defaultValue string) string {
|
||||
value := r.URL.Query().Get(param)
|
||||
if value == "" {
|
||||
@@ -52,7 +52,7 @@ func QueryStringParam(r *http.Request, param, defaultValue string) string {
|
||||
return value
|
||||
}
|
||||
|
||||
// QueryStringParamList returns all values associated to the parameter.
|
||||
// QueryStringParamList returns the non-empty, trimmed values for the named query parameter.
|
||||
func QueryStringParamList(r *http.Request, param string) []string {
|
||||
var results []string
|
||||
values := r.URL.Query()
|
||||
@@ -69,7 +69,7 @@ func QueryStringParamList(r *http.Request, param string) []string {
|
||||
return results
|
||||
}
|
||||
|
||||
// QueryIntParam returns a query string parameter as integer.
|
||||
// QueryIntParam returns the named query parameter parsed as int, or defaultValue when missing, invalid, or negative.
|
||||
func QueryIntParam(r *http.Request, param string, defaultValue int) int {
|
||||
value := r.URL.Query().Get(param)
|
||||
if value == "" {
|
||||
@@ -88,7 +88,7 @@ func QueryIntParam(r *http.Request, param string, defaultValue int) int {
|
||||
return int(val)
|
||||
}
|
||||
|
||||
// QueryInt64Param returns a query string parameter as int64.
|
||||
// QueryInt64Param returns the named query parameter parsed as int64, or defaultValue when missing, invalid, or negative.
|
||||
func QueryInt64Param(r *http.Request, param string, defaultValue int64) int64 {
|
||||
value := r.URL.Query().Get(param)
|
||||
if value == "" {
|
||||
@@ -107,7 +107,7 @@ func QueryInt64Param(r *http.Request, param string, defaultValue int64) int64 {
|
||||
return val
|
||||
}
|
||||
|
||||
// QueryBoolParam returns a query string parameter as bool.
|
||||
// QueryBoolParam returns the named query parameter parsed as bool, or defaultValue when missing or invalid.
|
||||
func QueryBoolParam(r *http.Request, param string, defaultValue bool) bool {
|
||||
value := r.URL.Query().Get(param)
|
||||
if value == "" {
|
||||
@@ -123,7 +123,7 @@ func QueryBoolParam(r *http.Request, param string, defaultValue bool) bool {
|
||||
return val
|
||||
}
|
||||
|
||||
// HasQueryParam checks if the query string contains the given parameter.
|
||||
// HasQueryParam reports whether the query string contains the named parameter.
|
||||
func HasQueryParam(r *http.Request, param string) bool {
|
||||
values := r.URL.Query()
|
||||
_, ok := values[param]
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
@@ -179,7 +180,7 @@ func TestQueryInt64Param(t *testing.T) {
|
||||
t.Errorf(`Unexpected result, got %d instead of %d`, result, expected)
|
||||
}
|
||||
|
||||
result = QueryInt64Param(r, "invalid", int64(69))
|
||||
result = QueryInt64Param(r, "negative", int64(69))
|
||||
expected = int64(69)
|
||||
|
||||
if result != expected {
|
||||
@@ -194,6 +195,58 @@ func TestQueryInt64Param(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryBoolParam(t *testing.T) {
|
||||
u, _ := url.Parse("http://example.org/?truthy=true&falsy=false&invalid=wat")
|
||||
r := &http.Request{URL: u}
|
||||
|
||||
result := QueryBoolParam(r, "truthy", false)
|
||||
expected := true
|
||||
|
||||
if result != expected {
|
||||
t.Errorf(`Unexpected result, got %v instead of %v`, result, expected)
|
||||
}
|
||||
|
||||
result = QueryBoolParam(r, "falsy", true)
|
||||
expected = false
|
||||
|
||||
if result != expected {
|
||||
t.Errorf(`Unexpected result, got %v instead of %v`, result, expected)
|
||||
}
|
||||
|
||||
result = QueryBoolParam(r, "missing", true)
|
||||
expected = true
|
||||
|
||||
if result != expected {
|
||||
t.Errorf(`Unexpected result, got %v instead of %v`, result, expected)
|
||||
}
|
||||
|
||||
result = QueryBoolParam(r, "invalid", true)
|
||||
expected = true
|
||||
|
||||
if result != expected {
|
||||
t.Errorf(`Unexpected result, got %v instead of %v`, result, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryStringParamList(t *testing.T) {
|
||||
u, _ := url.Parse("http://example.org/?tag=alpha&tag=beta&tag=+&tag=%20%20gamma%20%20&empty=")
|
||||
r := &http.Request{URL: u}
|
||||
|
||||
result := QueryStringParamList(r, "tag")
|
||||
expected := []string{"alpha", "beta", "gamma"}
|
||||
|
||||
if !reflect.DeepEqual(result, expected) {
|
||||
t.Errorf(`Unexpected result, got %v instead of %v`, result, expected)
|
||||
}
|
||||
|
||||
result = QueryStringParamList(r, "missing")
|
||||
expected = nil
|
||||
|
||||
if !reflect.DeepEqual(result, expected) {
|
||||
t.Errorf(`Unexpected result, got %v instead of %v`, result, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasQueryParam(t *testing.T) {
|
||||
u, _ := url.Parse("http://example.org/?key=42")
|
||||
r := &http.Request{URL: u}
|
||||
|
||||
@@ -20,7 +20,6 @@ import (
|
||||
"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"
|
||||
@@ -72,22 +71,23 @@ func StartWebServer(store *storage.Storage, pool *worker.Pool) []*http.Server {
|
||||
Handler: setupHandler(store, pool),
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(listenAddr, "/") && os.Getenv("LISTEN_PID") != strconv.Itoa(os.Getpid()) {
|
||||
isUNIXSocket := strings.HasPrefix(listenAddr, "/")
|
||||
isListenPID := os.Getenv("LISTEN_PID") == strconv.Itoa(os.Getpid())
|
||||
|
||||
if !isUNIXSocket && !isListenPID {
|
||||
server.Addr = listenAddr
|
||||
}
|
||||
|
||||
shouldAddServer := true
|
||||
|
||||
switch {
|
||||
case os.Getenv("LISTEN_PID") == strconv.Itoa(os.Getpid()):
|
||||
case isListenPID:
|
||||
if i == 0 {
|
||||
slog.Info("Starting server using systemd socket for the first listen address", slog.String("address_info", listenAddr))
|
||||
startSystemdSocketServer(server)
|
||||
} else {
|
||||
slog.Warn("Systemd socket activation: Only the first listen address is used by systemd. Other addresses ignored.", slog.String("skipped_address", listenAddr))
|
||||
shouldAddServer = false
|
||||
slog.Warn("Systemd socket activation: Only the first listen address is used by systemd. Other addresses are ignored.", slog.String("skipped_address", listenAddr))
|
||||
continue
|
||||
}
|
||||
case strings.HasPrefix(listenAddr, "/"): // Unix socket
|
||||
case isUNIXSocket:
|
||||
startUnixSocketServer(server, listenAddr)
|
||||
case certDomain != "" && (listenAddr == ":https" || (i == 0 && strings.Contains(listenAddr, ":"))):
|
||||
server.Addr = listenAddr
|
||||
@@ -101,9 +101,7 @@ func StartWebServer(store *storage.Storage, pool *worker.Pool) []*http.Server {
|
||||
startHTTPServer(server)
|
||||
}
|
||||
|
||||
if shouldAddServer {
|
||||
httpServers = append(httpServers, server)
|
||||
}
|
||||
httpServers = append(httpServers, server)
|
||||
}
|
||||
|
||||
return httpServers
|
||||
@@ -243,15 +241,13 @@ func setupHandler(store *storage.Storage, pool *worker.Pool) *mux.Router {
|
||||
|
||||
fever.Serve(subrouter, store)
|
||||
googlereader.Serve(subrouter, store)
|
||||
api.Serve(subrouter, store, pool)
|
||||
if config.Opts.HasAPI() {
|
||||
api.Serve(subrouter, store, pool)
|
||||
}
|
||||
ui.Serve(subrouter, store, pool)
|
||||
|
||||
subrouter.HandleFunc("/healthcheck", readinessProbe).Name("healthcheck")
|
||||
|
||||
subrouter.HandleFunc("/version", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte(version.Version))
|
||||
}).Name("version")
|
||||
|
||||
if config.Opts.HasMetricsCollector() {
|
||||
subrouter.Handle("/metrics", promhttp.Handler()).Name("metrics")
|
||||
subrouter.Use(func(next http.Handler) http.Handler {
|
||||
@@ -314,32 +310,7 @@ func isAllowedToAccessMetricsEndpoint(r *http.Request) bool {
|
||||
}
|
||||
|
||||
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
|
||||
return request.IsTrustedIP(remoteIP, config.Opts.MetricsAllowedNetworks())
|
||||
}
|
||||
|
||||
func printErrorAndExit(format string, a ...any) {
|
||||
|
||||
@@ -15,11 +15,13 @@ import (
|
||||
|
||||
func middleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
clientIP := request.FindClientIP(r)
|
||||
remoteIP := request.FindRemoteIP(r)
|
||||
isTrustedProxyClientIP := request.IsTrustedIP(remoteIP, config.Opts.TrustedReverseProxyNetworks())
|
||||
clientIP := request.FindClientIP(r, isTrustedProxyClientIP)
|
||||
ctx := r.Context()
|
||||
ctx = context.WithValue(ctx, request.ClientIPContextKey, clientIP)
|
||||
|
||||
if r.Header.Get("X-Forwarded-Proto") == "https" {
|
||||
if isTrustedProxyClientIP && r.Header.Get("X-Forwarded-Proto") == "https" {
|
||||
config.Opts.SetHTTPSValue(true)
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,8 @@ import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"miniflux.app/v2/internal/config"
|
||||
"miniflux.app/v2/internal/http/client"
|
||||
"miniflux.app/v2/internal/model"
|
||||
"miniflux.app/v2/internal/urllib"
|
||||
"miniflux.app/v2/internal/version"
|
||||
@@ -25,7 +27,7 @@ type Client struct {
|
||||
}
|
||||
|
||||
func NewClient(serviceURL, baseURL string) *Client {
|
||||
return &Client{serviceURL, baseURL}
|
||||
return &Client{servicesURL: serviceURL, baseURL: baseURL}
|
||||
}
|
||||
|
||||
func (c *Client) SendNotification(feed *model.Feed, entries model.Entries) error {
|
||||
@@ -65,12 +67,12 @@ func (c *Client) SendNotification(feed *model.Feed, entries model.Entries) error
|
||||
slog.String("entry_url", entry.URL),
|
||||
)
|
||||
|
||||
httpClient := &http.Client{Timeout: defaultClientTimeout}
|
||||
httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})
|
||||
response, err := httpClient.Do(request)
|
||||
if err != nil {
|
||||
return fmt.Errorf("apprise: unable to send request: %v", err)
|
||||
}
|
||||
response.Body.Close()
|
||||
defer response.Body.Close()
|
||||
|
||||
if response.StatusCode >= 400 {
|
||||
return fmt.Errorf("apprise: unable to send a notification: url=%s status=%d", apiEndpoint, response.StatusCode)
|
||||
|
||||
@@ -4,11 +4,17 @@
|
||||
package archiveorg
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"miniflux.app/v2/internal/http/client"
|
||||
"miniflux.app/v2/internal/version"
|
||||
)
|
||||
|
||||
const defaultClientTimeout = 30 * time.Second
|
||||
|
||||
// See https://docs.google.com/document/d/1Nsv52MvSjbLb2PCpHlat0gkzw0EvtSgpKHu4mk0MnrA/edit?tab=t.0
|
||||
const options = "delay_wb_availability=1&if_not_archived_within=15d"
|
||||
|
||||
@@ -18,26 +24,25 @@ func NewClient() *Client {
|
||||
return &Client{}
|
||||
}
|
||||
|
||||
func (c *Client) SendURL(entryURL, title string) {
|
||||
// We're using a goroutine here as submissions to archive.org might take a long time
|
||||
// and trigger a timeout on miniflux' side.
|
||||
go func(entryURL string) {
|
||||
res, err := http.Get("https://web.archive.org/save/" + url.QueryEscape(entryURL) + "?" + options)
|
||||
if err != nil {
|
||||
slog.Error("archiveorg: unable to send request: %v",
|
||||
slog.Any("err", err),
|
||||
slog.String("title", title),
|
||||
slog.String("url", entryURL),
|
||||
)
|
||||
return
|
||||
}
|
||||
if res.StatusCode > 299 {
|
||||
slog.Error("archiveorg: failed with status code",
|
||||
slog.String("title", title),
|
||||
slog.String("url", entryURL),
|
||||
slog.Int("code", res.StatusCode),
|
||||
)
|
||||
}
|
||||
res.Body.Close()
|
||||
}(entryURL)
|
||||
func (c *Client) SendURL(entryURL string) error {
|
||||
requestURL := "https://web.archive.org/save/" + url.QueryEscape(entryURL) + "?" + options
|
||||
request, err := http.NewRequest(http.MethodGet, requestURL, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("archiveorg: unable to create request: %v", err)
|
||||
}
|
||||
|
||||
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
|
||||
|
||||
httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout})
|
||||
response, err := httpClient.Do(request)
|
||||
if err != nil {
|
||||
return fmt.Errorf("archiveorg: unable to send request: %v", err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
|
||||
if response.StatusCode >= 400 {
|
||||
return fmt.Errorf("archiveorg: unexpected status code: url=%s status=%d", requestURL, response.StatusCode)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -10,6 +10,8 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"miniflux.app/v2/internal/config"
|
||||
"miniflux.app/v2/internal/http/client"
|
||||
"miniflux.app/v2/internal/urllib"
|
||||
"miniflux.app/v2/internal/version"
|
||||
)
|
||||
@@ -41,11 +43,10 @@ func (c *Client) CreateBookmark(entryURL, entryTitle string, tags []string) erro
|
||||
return fmt.Errorf("betula: unable to create request: %v", err)
|
||||
}
|
||||
|
||||
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
|
||||
request.AddCookie(&http.Cookie{Name: "betula-token", Value: c.token})
|
||||
|
||||
httpClient := &http.Client{Timeout: defaultClientTimeout}
|
||||
httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})
|
||||
response, err := httpClient.Do(request)
|
||||
if err != nil {
|
||||
return fmt.Errorf("betula: unable to send request: %v", err)
|
||||
|
||||
@@ -14,6 +14,8 @@ import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"miniflux.app/v2/internal/config"
|
||||
"miniflux.app/v2/internal/http/client"
|
||||
"miniflux.app/v2/internal/version"
|
||||
)
|
||||
|
||||
@@ -51,7 +53,7 @@ func (c *Client) SaveLink(entryURL string) error {
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
|
||||
|
||||
response, err := http.DefaultClient.Do(request)
|
||||
response, err := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()}).Do(request)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cubox: unable to send request: %w", err)
|
||||
}
|
||||
|
||||
@@ -13,6 +13,8 @@ import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"miniflux.app/v2/internal/config"
|
||||
"miniflux.app/v2/internal/http/client"
|
||||
"miniflux.app/v2/internal/model"
|
||||
"miniflux.app/v2/internal/urllib"
|
||||
"miniflux.app/v2/internal/version"
|
||||
@@ -77,12 +79,12 @@ func (c *Client) SendDiscordMsg(feed *model.Feed, entries model.Entries) error {
|
||||
slog.String("entry_url", entry.URL),
|
||||
)
|
||||
|
||||
httpClient := &http.Client{Timeout: defaultClientTimeout}
|
||||
httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})
|
||||
response, err := httpClient.Do(request)
|
||||
if err != nil {
|
||||
return fmt.Errorf("discord: unable to send request: %v", err)
|
||||
}
|
||||
response.Body.Close()
|
||||
defer response.Body.Close()
|
||||
|
||||
if response.StatusCode >= 400 {
|
||||
return fmt.Errorf("discord: unable to send a notification: url=%s status=%d", c.webhookURL, response.StatusCode)
|
||||
|
||||
@@ -11,6 +11,8 @@ import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"miniflux.app/v2/internal/config"
|
||||
"miniflux.app/v2/internal/http/client"
|
||||
"miniflux.app/v2/internal/urllib"
|
||||
"miniflux.app/v2/internal/version"
|
||||
)
|
||||
@@ -38,7 +40,7 @@ func (c *Client) CreateLink(entryURL, entryTitle, espialTags string) error {
|
||||
|
||||
requestBody, err := json.Marshal(&espialDocument{
|
||||
Title: entryTitle,
|
||||
Url: entryURL,
|
||||
URL: entryURL,
|
||||
ToRead: true,
|
||||
Tags: espialTags,
|
||||
})
|
||||
@@ -56,7 +58,7 @@ func (c *Client) CreateLink(entryURL, entryTitle, espialTags string) error {
|
||||
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
|
||||
request.Header.Set("Authorization", "ApiKey "+c.apiKey)
|
||||
|
||||
httpClient := &http.Client{Timeout: defaultClientTimeout}
|
||||
httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})
|
||||
response, err := httpClient.Do(request)
|
||||
if err != nil {
|
||||
return fmt.Errorf("espial: unable to send request: %v", err)
|
||||
@@ -75,7 +77,7 @@ func (c *Client) CreateLink(entryURL, entryTitle, espialTags string) error {
|
||||
|
||||
type espialDocument struct {
|
||||
Title string `json:"title,omitempty"`
|
||||
Url string `json:"url,omitempty"`
|
||||
URL string `json:"url,omitempty"`
|
||||
ToRead bool `json:"toread,omitempty"`
|
||||
Tags string `json:"tags,omitempty"`
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"miniflux.app/v2/internal/http/client"
|
||||
"miniflux.app/v2/internal/version"
|
||||
)
|
||||
|
||||
@@ -40,10 +41,9 @@ func (c *Client) AddURL(entryURL, entryTitle string) error {
|
||||
}
|
||||
|
||||
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}
|
||||
httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout})
|
||||
response, err := httpClient.Do(request)
|
||||
if err != nil {
|
||||
return fmt.Errorf("instapaper: unable to send request: %v", err)
|
||||
|
||||
@@ -271,23 +271,26 @@ func SendEntry(entry *model.Entry, userIntegrations *model.Integration) {
|
||||
}
|
||||
|
||||
if userIntegrations.LinkwardenEnabled {
|
||||
slog.Debug("Sending entry to linkwarden",
|
||||
attrs := []any{
|
||||
slog.Int64("user_id", userIntegrations.UserID),
|
||||
slog.Int64("entry_id", entry.ID),
|
||||
slog.String("entry_url", entry.URL),
|
||||
)
|
||||
}
|
||||
|
||||
if userIntegrations.LinkwardenCollectionID != nil {
|
||||
attrs = append(attrs, slog.Int64("collection_id", *userIntegrations.LinkwardenCollectionID))
|
||||
}
|
||||
|
||||
slog.Debug("Sending entry to linkwarden", attrs...)
|
||||
|
||||
client := linkwarden.NewClient(
|
||||
userIntegrations.LinkwardenURL,
|
||||
userIntegrations.LinkwardenAPIKey,
|
||||
userIntegrations.LinkwardenCollectionID,
|
||||
)
|
||||
if err := client.CreateBookmark(entry.URL, entry.Title); err != nil {
|
||||
slog.Error("Unable to send entry to Linkwarden",
|
||||
slog.Int64("user_id", userIntegrations.UserID),
|
||||
slog.Int64("entry_id", entry.ID),
|
||||
slog.String("entry_url", entry.URL),
|
||||
slog.Any("error", err),
|
||||
)
|
||||
attrs = append(attrs, slog.Any("error", err))
|
||||
slog.Error("Unable to send entry to Linkwarden", attrs...)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -406,7 +409,14 @@ func SendEntry(entry *model.Entry, userIntegrations *model.Integration) {
|
||||
slog.String("entry_url", entry.URL),
|
||||
)
|
||||
|
||||
archiveorg.NewClient().SendURL(entry.URL, entry.Title)
|
||||
if err := archiveorg.NewClient().SendURL(entry.URL); err != nil {
|
||||
slog.Error("Unable to send entry to Archive.org",
|
||||
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 {
|
||||
@@ -444,7 +454,7 @@ func SendEntry(entry *model.Entry, userIntegrations *model.Integration) {
|
||||
)
|
||||
|
||||
client := omnivore.NewClient(userIntegrations.OmnivoreAPIKey, userIntegrations.OmnivoreURL)
|
||||
if err := client.SaveUrl(entry.URL); err != nil {
|
||||
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),
|
||||
@@ -540,7 +550,7 @@ func PushEntries(feed *model.Feed, entries model.Entries, userIntegrations *mode
|
||||
|
||||
webhookClient := webhook.NewClient(webhookURL, userIntegrations.WebhookSecret)
|
||||
if err := webhookClient.SendNewEntriesWebhookEvent(feed, entries); err != nil {
|
||||
slog.Debug("Unable to send new entries to Webhook",
|
||||
slog.Warn("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),
|
||||
@@ -639,7 +649,7 @@ func PushEntries(feed *model.Feed, entries model.Entries, userIntegrations *mode
|
||||
slog.Int64("feed_id", feed.ID),
|
||||
)
|
||||
|
||||
client := pushover.New(
|
||||
client := pushover.NewClient(
|
||||
userIntegrations.PushoverUser,
|
||||
userIntegrations.PushoverToken,
|
||||
feed.PushoverPriority,
|
||||
@@ -655,30 +665,54 @@ func PushEntries(feed *model.Feed, entries model.Entries, userIntegrations *mode
|
||||
// Integrations that only support sending individual entries
|
||||
if userIntegrations.TelegramBotEnabled {
|
||||
for _, entry := range entries {
|
||||
if userIntegrations.TelegramBotEnabled {
|
||||
slog.Debug("Sending a new entry to Telegram",
|
||||
slog.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 err := telegrambot.PushEntry(
|
||||
feed,
|
||||
entry,
|
||||
userIntegrations.TelegramBotToken,
|
||||
userIntegrations.TelegramBotChatID,
|
||||
userIntegrations.TelegramBotTopicID,
|
||||
userIntegrations.TelegramBotDisableWebPagePreview,
|
||||
userIntegrations.TelegramBotDisableNotification,
|
||||
userIntegrations.TelegramBotDisableButtons,
|
||||
); err != nil {
|
||||
slog.Error("Unable to send entry to Telegram",
|
||||
slog.Int64("user_id", userIntegrations.UserID),
|
||||
slog.Int64("entry_id", entry.ID),
|
||||
slog.String("entry_url", entry.URL),
|
||||
slog.Any("error", err),
|
||||
)
|
||||
}
|
||||
// Push each new entry to Readeck when push is enabled
|
||||
if userIntegrations.ReadeckPushEnabled {
|
||||
client := readeck.NewClient(
|
||||
userIntegrations.ReadeckURL,
|
||||
userIntegrations.ReadeckAPIKey,
|
||||
userIntegrations.ReadeckLabels,
|
||||
userIntegrations.ReadeckOnlyURL,
|
||||
)
|
||||
for _, entry := range entries {
|
||||
slog.Debug("Sending a new entry to Readeck",
|
||||
slog.Int64("user_id", userIntegrations.UserID),
|
||||
slog.Int64("entry_id", entry.ID),
|
||||
slog.String("entry_url", entry.URL),
|
||||
)
|
||||
|
||||
if err := client.CreateBookmark(entry.URL, entry.Title, entry.Content); err != nil {
|
||||
slog.Error("Unable to send entry to Readeck",
|
||||
slog.Int64("user_id", userIntegrations.UserID),
|
||||
slog.Int64("entry_id", entry.ID),
|
||||
slog.String("entry_url", entry.URL),
|
||||
slog.Any("error", err),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package integration
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"miniflux.app/v2/internal/model"
|
||||
)
|
||||
|
||||
func TestSendEntryLogsLinkwardenCollectionID(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
handler := slog.NewJSONHandler(&buf, nil)
|
||||
logger := slog.New(handler)
|
||||
prev := slog.Default()
|
||||
slog.SetDefault(logger)
|
||||
defer slog.SetDefault(prev)
|
||||
|
||||
entry := &model.Entry{ID: 52, URL: "https://example.org/test.html", Title: "Test"}
|
||||
coll := int64(12345)
|
||||
userIntegrations := &model.Integration{
|
||||
UserID: 1,
|
||||
LinkwardenEnabled: true,
|
||||
LinkwardenCollectionID: &coll,
|
||||
LinkwardenURL: "",
|
||||
LinkwardenAPIKey: "",
|
||||
}
|
||||
|
||||
SendEntry(entry, userIntegrations)
|
||||
|
||||
out := buf.String()
|
||||
if !strings.Contains(out, `"collection_id":12345`) {
|
||||
t.Fatalf("expected collection_id in logs; got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendEntryLogsLinkwardenWithoutCollectionID(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
handler := slog.NewJSONHandler(&buf, nil)
|
||||
logger := slog.New(handler)
|
||||
prev := slog.Default()
|
||||
slog.SetDefault(logger)
|
||||
defer slog.SetDefault(prev)
|
||||
|
||||
entry := &model.Entry{ID: 52, URL: "https://example.org/test.html", Title: "Test"}
|
||||
userIntegrations := &model.Integration{
|
||||
UserID: 1,
|
||||
LinkwardenEnabled: true,
|
||||
LinkwardenURL: "",
|
||||
LinkwardenAPIKey: "",
|
||||
}
|
||||
|
||||
SendEntry(entry, userIntegrations)
|
||||
|
||||
out := buf.String()
|
||||
if strings.Contains(out, "collection_id") {
|
||||
t.Fatalf("did not expect collection_id in logs; got: %s", out)
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,8 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"miniflux.app/v2/internal/config"
|
||||
"miniflux.app/v2/internal/http/client"
|
||||
"miniflux.app/v2/internal/version"
|
||||
)
|
||||
|
||||
@@ -48,7 +50,7 @@ type errorResponse struct {
|
||||
}
|
||||
|
||||
func NewClient(apiToken string, apiEndpoint string, tags string) *Client {
|
||||
return &Client{wrapped: &http.Client{Timeout: defaultClientTimeout}, apiEndpoint: apiEndpoint, apiToken: apiToken, tags: tags}
|
||||
return &Client{wrapped: client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()}), apiEndpoint: apiEndpoint, apiToken: apiToken, tags: tags}
|
||||
}
|
||||
|
||||
func (c *Client) attachTags(entryID string) error {
|
||||
|
||||
@@ -12,6 +12,8 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"miniflux.app/v2/internal/config"
|
||||
"miniflux.app/v2/internal/http/client"
|
||||
"miniflux.app/v2/internal/urllib"
|
||||
"miniflux.app/v2/internal/version"
|
||||
)
|
||||
@@ -44,7 +46,7 @@ func (c *Client) AddURL(entryURL, entryTitle string) error {
|
||||
return fmt.Errorf("linkace: invalid API endpoint: %v", err)
|
||||
}
|
||||
requestBody, err := json.Marshal(&createItemRequest{
|
||||
Url: entryURL,
|
||||
URL: entryURL,
|
||||
Title: entryTitle,
|
||||
Tags: strings.FieldsFunc(c.tags, tagsSplitFn),
|
||||
Private: c.private,
|
||||
@@ -64,7 +66,7 @@ func (c *Client) AddURL(entryURL, entryTitle string) error {
|
||||
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
|
||||
request.Header.Set("Authorization", "Bearer "+c.apiKey)
|
||||
|
||||
httpClient := &http.Client{Timeout: defaultClientTimeout}
|
||||
httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})
|
||||
response, err := httpClient.Do(request)
|
||||
if err != nil {
|
||||
return fmt.Errorf("linkace: unable to send request: %v", err)
|
||||
@@ -80,7 +82,7 @@ func (c *Client) AddURL(entryURL, entryTitle string) error {
|
||||
|
||||
type createItemRequest struct {
|
||||
Title string `json:"title,omitempty"`
|
||||
Url string `json:"url"`
|
||||
URL string `json:"url"`
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
Private bool `json:"is_private,omitempty"`
|
||||
CheckDisabled bool `json:"check_disabled,omitempty"`
|
||||
|
||||
@@ -12,6 +12,8 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"miniflux.app/v2/internal/config"
|
||||
"miniflux.app/v2/internal/http/client"
|
||||
"miniflux.app/v2/internal/urllib"
|
||||
"miniflux.app/v2/internal/version"
|
||||
)
|
||||
@@ -44,7 +46,7 @@ func (c *Client) CreateBookmark(entryURL, entryTitle string) error {
|
||||
}
|
||||
|
||||
requestBody, err := json.Marshal(&linkdingBookmark{
|
||||
Url: entryURL,
|
||||
URL: entryURL,
|
||||
Title: entryTitle,
|
||||
TagNames: strings.FieldsFunc(c.tags, tagsSplitFn),
|
||||
Unread: c.unread,
|
||||
@@ -63,7 +65,7 @@ func (c *Client) CreateBookmark(entryURL, entryTitle string) error {
|
||||
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
|
||||
request.Header.Set("Authorization", "Token "+c.apiKey)
|
||||
|
||||
httpClient := &http.Client{Timeout: defaultClientTimeout}
|
||||
httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})
|
||||
response, err := httpClient.Do(request)
|
||||
if err != nil {
|
||||
return fmt.Errorf("linkding: unable to send request: %v", err)
|
||||
@@ -78,7 +80,7 @@ func (c *Client) CreateBookmark(entryURL, entryTitle string) error {
|
||||
}
|
||||
|
||||
type linkdingBookmark struct {
|
||||
Url string `json:"url,omitempty"`
|
||||
URL string `json:"url,omitempty"`
|
||||
Title string `json:"title,omitempty"`
|
||||
TagNames []string `json:"tag_names,omitempty"`
|
||||
Unread bool `json:"unread,omitempty"`
|
||||
|
||||
@@ -12,6 +12,8 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"miniflux.app/v2/internal/config"
|
||||
"miniflux.app/v2/internal/http/client"
|
||||
"miniflux.app/v2/internal/version"
|
||||
)
|
||||
|
||||
@@ -104,7 +106,7 @@ func (c *Client) CreateBookmark(entryURL, entryTitle, entryContent string) error
|
||||
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
|
||||
request.Header.Set("Authorization", "Bearer "+c.apiToken)
|
||||
|
||||
httpClient := &http.Client{Timeout: defaultClientTimeout}
|
||||
httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})
|
||||
response, err := httpClient.Do(request)
|
||||
if err != nil {
|
||||
return fmt.Errorf("linktaco: unable to send request: %v", err)
|
||||
|
||||
@@ -10,9 +10,13 @@ import (
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"miniflux.app/v2/internal/config"
|
||||
)
|
||||
|
||||
func TestCreateBookmark(t *testing.T) {
|
||||
configureIntegrationAllowPrivateNetworksOption(t)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
apiToken string
|
||||
@@ -50,7 +54,7 @@ func TestCreateBookmark(t *testing.T) {
|
||||
|
||||
// Parse and verify request
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
var req map[string]interface{}
|
||||
var req map[string]any
|
||||
if err := json.Unmarshal(body, &req); err != nil {
|
||||
t.Errorf("Failed to parse request body: %v", err)
|
||||
}
|
||||
@@ -62,9 +66,9 @@ func TestCreateBookmark(t *testing.T) {
|
||||
|
||||
// Return success response
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"data": map[string]interface{}{
|
||||
"addLink": map[string]interface{}{
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"data": map[string]any{
|
||||
"addLink": map[string]any{
|
||||
"id": "123",
|
||||
"url": "https://example.com",
|
||||
"title": "Test Article",
|
||||
@@ -111,9 +115,9 @@ func TestCreateBookmark(t *testing.T) {
|
||||
entryContent: "Content",
|
||||
serverResponse: func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"errors": []interface{}{
|
||||
map[string]interface{}{
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"errors": []any{
|
||||
map[string]any{
|
||||
"message": "Invalid input",
|
||||
},
|
||||
},
|
||||
@@ -145,9 +149,9 @@ func TestCreateBookmark(t *testing.T) {
|
||||
entryContent: "Content",
|
||||
serverResponse: func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"errors": []interface{}{
|
||||
map[string]interface{}{
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"errors": []any{
|
||||
map[string]any{
|
||||
"message": "PRIVATE visibility requires a paid LinkTaco account",
|
||||
},
|
||||
},
|
||||
@@ -165,12 +169,12 @@ func TestCreateBookmark(t *testing.T) {
|
||||
entryContent: strings.Repeat("a", 600), // Content longer than 500 chars
|
||||
serverResponse: func(w http.ResponseWriter, r *http.Request) {
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
var req map[string]interface{}
|
||||
var req map[string]any
|
||||
json.Unmarshal(body, &req)
|
||||
|
||||
// Check that description was truncated
|
||||
variables := req["variables"].(map[string]interface{})
|
||||
input := variables["input"].(map[string]interface{})
|
||||
variables := req["variables"].(map[string]any)
|
||||
input := variables["input"].(map[string]any)
|
||||
description := input["description"].(string)
|
||||
|
||||
if len(description) != maxDescriptionLength {
|
||||
@@ -178,9 +182,9 @@ func TestCreateBookmark(t *testing.T) {
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"data": map[string]interface{}{
|
||||
"addLink": map[string]interface{}{"id": "123"},
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"data": map[string]any{
|
||||
"addLink": map[string]any{"id": "123"},
|
||||
},
|
||||
})
|
||||
},
|
||||
@@ -196,12 +200,12 @@ func TestCreateBookmark(t *testing.T) {
|
||||
entryContent: "Content",
|
||||
serverResponse: func(w http.ResponseWriter, r *http.Request) {
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
var req map[string]interface{}
|
||||
var req map[string]any
|
||||
json.Unmarshal(body, &req)
|
||||
|
||||
// Check that only 10 tags were sent
|
||||
variables := req["variables"].(map[string]interface{})
|
||||
input := variables["input"].(map[string]interface{})
|
||||
variables := req["variables"].(map[string]any)
|
||||
input := variables["input"].(map[string]any)
|
||||
tags := input["tags"].(string)
|
||||
|
||||
tagCount := len(strings.Split(tags, ","))
|
||||
@@ -210,9 +214,9 @@ func TestCreateBookmark(t *testing.T) {
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"data": map[string]interface{}{
|
||||
"addLink": map[string]interface{}{"id": "123"},
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"data": map[string]any{
|
||||
"addLink": map[string]any{"id": "123"},
|
||||
},
|
||||
})
|
||||
},
|
||||
@@ -323,10 +327,12 @@ func TestNewClient(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestGraphQLMutation(t *testing.T) {
|
||||
configureIntegrationAllowPrivateNetworksOption(t)
|
||||
|
||||
// Test that the GraphQL mutation is properly formatted
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
var req map[string]interface{}
|
||||
var req map[string]any
|
||||
if err := json.Unmarshal(body, &req); err != nil {
|
||||
t.Fatalf("Failed to parse request: %v", err)
|
||||
}
|
||||
@@ -349,12 +355,12 @@ func TestGraphQLMutation(t *testing.T) {
|
||||
}
|
||||
|
||||
// Verify variables structure
|
||||
variables, ok := req["variables"].(map[string]interface{})
|
||||
variables, ok := req["variables"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatal("Missing variables field")
|
||||
}
|
||||
|
||||
input, ok := variables["input"].(map[string]interface{})
|
||||
input, ok := variables["input"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatal("Missing input in variables")
|
||||
}
|
||||
@@ -369,9 +375,9 @@ func TestGraphQLMutation(t *testing.T) {
|
||||
|
||||
// Return success
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"data": map[string]interface{}{
|
||||
"addLink": map[string]interface{}{
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"data": map[string]any{
|
||||
"addLink": map[string]any{
|
||||
"id": "123",
|
||||
},
|
||||
},
|
||||
@@ -397,9 +403,9 @@ func BenchmarkCreateBookmark(b *testing.B) {
|
||||
// Create a mock server that always returns success
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"data": map[string]interface{}{
|
||||
"addLink": map[string]interface{}{
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"data": map[string]any{
|
||||
"addLink": map[string]any{
|
||||
"id": "123",
|
||||
},
|
||||
},
|
||||
@@ -438,3 +444,21 @@ func BenchmarkTagProcessing(b *testing.B) {
|
||||
_ = strings.Join(splitTags, ",")
|
||||
}
|
||||
}
|
||||
|
||||
func configureIntegrationAllowPrivateNetworksOption(t *testing.T) {
|
||||
t.Helper()
|
||||
|
||||
t.Setenv("INTEGRATION_ALLOW_PRIVATE_NETWORKS", "1")
|
||||
|
||||
configParser := config.NewConfigParser()
|
||||
parsedOptions, err := configParser.ParseEnvironmentVariables()
|
||||
if err != nil {
|
||||
t.Fatalf("Unable to configure test options: %v", err)
|
||||
}
|
||||
|
||||
previousOptions := config.Opts
|
||||
config.Opts = parsedOptions
|
||||
t.Cleanup(func() {
|
||||
config.Opts = previousOptions
|
||||
})
|
||||
}
|
||||
|
||||
@@ -8,9 +8,12 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"miniflux.app/v2/internal/config"
|
||||
"miniflux.app/v2/internal/http/client"
|
||||
"miniflux.app/v2/internal/urllib"
|
||||
"miniflux.app/v2/internal/version"
|
||||
)
|
||||
@@ -18,12 +21,23 @@ import (
|
||||
const defaultClientTimeout = 10 * time.Second
|
||||
|
||||
type Client struct {
|
||||
baseURL string
|
||||
apiKey string
|
||||
baseURL string
|
||||
apiKey string
|
||||
collectionID *int64
|
||||
}
|
||||
|
||||
func NewClient(baseURL, apiKey string) *Client {
|
||||
return &Client{baseURL: baseURL, apiKey: apiKey}
|
||||
type linkwardenCollection struct {
|
||||
ID *int64 `json:"id"`
|
||||
}
|
||||
|
||||
type linkwardenRequest struct {
|
||||
URL string `json:"url"`
|
||||
Name string `json:"name"`
|
||||
Collection *linkwardenCollection `json:"collection,omitempty"`
|
||||
}
|
||||
|
||||
func NewClient(baseURL, apiKey string, collectionID *int64) *Client {
|
||||
return &Client{baseURL: baseURL, apiKey: apiKey, collectionID: collectionID}
|
||||
}
|
||||
|
||||
func (c *Client) CreateBookmark(entryURL, entryTitle string) error {
|
||||
@@ -36,10 +50,16 @@ func (c *Client) CreateBookmark(entryURL, entryTitle string) error {
|
||||
return fmt.Errorf(`linkwarden: invalid API endpoint: %v`, err)
|
||||
}
|
||||
|
||||
requestBody, err := json.Marshal(map[string]string{
|
||||
"url": entryURL,
|
||||
"name": entryTitle,
|
||||
})
|
||||
payload := linkwardenRequest{
|
||||
URL: entryURL,
|
||||
Name: entryTitle,
|
||||
}
|
||||
|
||||
if c.collectionID != nil {
|
||||
payload.Collection = &linkwardenCollection{ID: c.collectionID}
|
||||
}
|
||||
|
||||
requestBody, err := json.Marshal(payload)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("linkwarden: unable to encode request body: %v", err)
|
||||
@@ -54,15 +74,20 @@ func (c *Client) CreateBookmark(entryURL, entryTitle string) error {
|
||||
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
|
||||
request.Header.Set("Authorization", "Bearer "+c.apiKey)
|
||||
|
||||
httpClient := &http.Client{Timeout: defaultClientTimeout}
|
||||
httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})
|
||||
response, err := httpClient.Do(request)
|
||||
if err != nil {
|
||||
return fmt.Errorf("linkwarden: unable to send request: %v", err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
|
||||
responseBody, err := io.ReadAll(response.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("linkwarden: unable to read response body: %v", err)
|
||||
}
|
||||
|
||||
if response.StatusCode >= 400 {
|
||||
return fmt.Errorf("linkwarden: unable to create link: url=%s status=%d", apiEndpoint, response.StatusCode)
|
||||
return fmt.Errorf("linkwarden: unable to create link: status=%d body=%s", response.StatusCode, string(responseBody))
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
@@ -0,0 +1,346 @@
|
||||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package linkwarden
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"miniflux.app/v2/internal/config"
|
||||
"miniflux.app/v2/internal/model"
|
||||
)
|
||||
|
||||
func TestCreateBookmark(t *testing.T) {
|
||||
configureIntegrationAllowPrivateNetworksOption(t)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
baseURL string
|
||||
apiKey string
|
||||
collectionID *int64
|
||||
entryURL string
|
||||
entryTitle string
|
||||
serverResponse func(w http.ResponseWriter, r *http.Request, t *testing.T, collectionID *int64)
|
||||
wantErr bool
|
||||
errContains string
|
||||
}{
|
||||
{
|
||||
name: "successful bookmark creation without collection",
|
||||
baseURL: "",
|
||||
apiKey: "test-api-key",
|
||||
collectionID: nil,
|
||||
entryURL: "https://example.com",
|
||||
entryTitle: "Test Article",
|
||||
serverResponse: func(w http.ResponseWriter, r *http.Request, t *testing.T, collectionId *int64) {
|
||||
// Verify authorization header
|
||||
auth := r.Header.Get("Authorization")
|
||||
if auth != "Bearer test-api-key" {
|
||||
t.Errorf("Expected Authorization header 'Bearer test-api-key', got %s", auth)
|
||||
}
|
||||
|
||||
// Verify content type
|
||||
contentType := r.Header.Get("Content-Type")
|
||||
if contentType != "application/json" {
|
||||
t.Errorf("Expected Content-Type 'application/json', got %s", contentType)
|
||||
}
|
||||
|
||||
// Parse and verify request
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
var req map[string]any
|
||||
if err := json.Unmarshal(body, &req); err != nil {
|
||||
t.Errorf("Failed to parse request body: %v", err)
|
||||
}
|
||||
|
||||
// Verify URL
|
||||
if reqURL := req["url"]; reqURL != "https://example.com" {
|
||||
t.Errorf("Expected URL 'https://example.com', got %v", reqURL)
|
||||
}
|
||||
|
||||
// Verify title/name
|
||||
if reqName := req["name"]; reqName != "Test Article" {
|
||||
t.Errorf("Expected name 'Test Article', got %v", reqName)
|
||||
}
|
||||
|
||||
// Verify collection is not present when nil
|
||||
if _, ok := req["collection"]; ok {
|
||||
t.Error("Expected collection field to be omitted when collectionId is nil")
|
||||
}
|
||||
|
||||
// Return success response
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"id": "123",
|
||||
"url": "https://example.com",
|
||||
"name": "Test Article",
|
||||
})
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "successful bookmark creation with collection",
|
||||
baseURL: "",
|
||||
apiKey: "test-api-key",
|
||||
collectionID: model.OptionalNumber(int64(42)),
|
||||
entryURL: "https://example.com/article",
|
||||
entryTitle: "Test Article With Collection",
|
||||
serverResponse: func(w http.ResponseWriter, r *http.Request, t *testing.T, collectionID *int64) {
|
||||
// Verify authorization header
|
||||
auth := r.Header.Get("Authorization")
|
||||
if auth != "Bearer test-api-key" {
|
||||
t.Errorf("Expected Authorization header 'Bearer test-api-key', got %s", auth)
|
||||
}
|
||||
|
||||
// Parse and verify request
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
var req map[string]any
|
||||
if err := json.Unmarshal(body, &req); err != nil {
|
||||
t.Errorf("Failed to parse request body: %v", err)
|
||||
}
|
||||
|
||||
// Verify URL
|
||||
if reqURL := req["url"]; reqURL != "https://example.com/article" {
|
||||
t.Errorf("Expected URL 'https://example.com/article', got %v", reqURL)
|
||||
}
|
||||
|
||||
// Verify title/name
|
||||
if reqName := req["name"]; reqName != "Test Article With Collection" {
|
||||
t.Errorf("Expected name 'Test Article With Collection', got %v", reqName)
|
||||
}
|
||||
|
||||
// Verify collection is present and correct
|
||||
if collection, ok := req["collection"]; ok {
|
||||
collectionMap, ok := collection.(map[string]any)
|
||||
if !ok {
|
||||
t.Error("Expected collection to be a map")
|
||||
}
|
||||
if collectionID, ok := collectionMap["id"]; ok {
|
||||
// JSON numbers are float64
|
||||
if collectionIDFloat, ok := collectionID.(float64); !ok || int64(collectionIDFloat) != 42 {
|
||||
t.Errorf("Expected collection id 42, got %v", collectionID)
|
||||
}
|
||||
} else {
|
||||
t.Error("Expected collection to have 'id' field")
|
||||
}
|
||||
} else {
|
||||
t.Error("Expected collection field to be present when collectionId is set")
|
||||
}
|
||||
|
||||
// Return success response
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"id": "124",
|
||||
"url": "https://example.com/article",
|
||||
"name": "Test Article With Collection",
|
||||
})
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "missing API key",
|
||||
baseURL: "",
|
||||
apiKey: "",
|
||||
collectionID: nil,
|
||||
entryURL: "https://example.com",
|
||||
entryTitle: "Test",
|
||||
serverResponse: func(w http.ResponseWriter, r *http.Request, t *testing.T, collectionId *int64) {
|
||||
// Should not be called
|
||||
t.Error("Server should not be called when API key is missing")
|
||||
},
|
||||
wantErr: true,
|
||||
errContains: "missing base URL or API key",
|
||||
},
|
||||
{
|
||||
name: "server error",
|
||||
baseURL: "",
|
||||
apiKey: "test-api-key",
|
||||
collectionID: nil,
|
||||
entryURL: "https://example.com",
|
||||
entryTitle: "Test",
|
||||
serverResponse: func(w http.ResponseWriter, r *http.Request, t *testing.T, collectionId *int64) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
w.Write([]byte(`{"error": "Internal server error"}`))
|
||||
},
|
||||
wantErr: true,
|
||||
errContains: "unable to create link: status=500",
|
||||
},
|
||||
{
|
||||
name: "bad request with null collection id error",
|
||||
baseURL: "",
|
||||
apiKey: "test-api-key",
|
||||
collectionID: nil,
|
||||
entryURL: "https://example.com",
|
||||
entryTitle: "Test",
|
||||
serverResponse: func(w http.ResponseWriter, r *http.Request, t *testing.T, collectionId *int64) {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
w.Write([]byte(`{"response":"Error: Expected number, received null [collection, id]"}`))
|
||||
},
|
||||
wantErr: true,
|
||||
errContains: "unable to create link: status=400",
|
||||
},
|
||||
{
|
||||
name: "unauthorized",
|
||||
baseURL: "",
|
||||
apiKey: "invalid-key",
|
||||
collectionID: nil,
|
||||
entryURL: "https://example.com",
|
||||
entryTitle: "Test",
|
||||
serverResponse: func(w http.ResponseWriter, r *http.Request, t *testing.T, collectionId *int64) {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
w.Write([]byte(`{"error": "Unauthorized"}`))
|
||||
},
|
||||
wantErr: true,
|
||||
errContains: "unable to create link: status=401",
|
||||
},
|
||||
{
|
||||
name: "invalid base URL",
|
||||
baseURL: ":",
|
||||
apiKey: "test-api-key",
|
||||
collectionID: nil,
|
||||
entryURL: "https://example.com",
|
||||
entryTitle: "Test",
|
||||
serverResponse: func(w http.ResponseWriter, r *http.Request, t *testing.T, collectionId *int64) {
|
||||
// Should not be called
|
||||
t.Error("Server should not be called when base URL is invalid")
|
||||
},
|
||||
wantErr: true,
|
||||
errContains: "invalid API endpoint",
|
||||
},
|
||||
{
|
||||
name: "missing base URL",
|
||||
baseURL: "",
|
||||
apiKey: "",
|
||||
collectionID: nil,
|
||||
entryURL: "https://example.com",
|
||||
entryTitle: "Test",
|
||||
serverResponse: func(w http.ResponseWriter, r *http.Request, t *testing.T, collectionId *int64) {
|
||||
// Should not be called
|
||||
t.Error("Server should not be called when base URL is missing")
|
||||
},
|
||||
wantErr: true,
|
||||
errContains: "missing base URL or API key",
|
||||
},
|
||||
{
|
||||
name: "network connection error",
|
||||
baseURL: "http://localhost:1", // Invalid port that should fail to connect
|
||||
apiKey: "test-api-key",
|
||||
collectionID: nil,
|
||||
entryURL: "https://example.com",
|
||||
entryTitle: "Test",
|
||||
serverResponse: func(w http.ResponseWriter, r *http.Request, t *testing.T, collectionId *int64) {
|
||||
// Should not be called due to connection failure
|
||||
t.Error("Server should not be called when connection fails")
|
||||
},
|
||||
wantErr: true,
|
||||
errContains: "unable to send request",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Create test server only if we have a valid apiKey and don't have a custom baseURL for error testing
|
||||
var server *httptest.Server
|
||||
if tt.apiKey != "" && tt.baseURL != ":" && tt.baseURL != "http://localhost:1" {
|
||||
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
tt.serverResponse(w, r, t, tt.collectionID)
|
||||
}))
|
||||
defer server.Close()
|
||||
}
|
||||
|
||||
// Use test server URL if baseURL is empty and we have a server
|
||||
baseURL := tt.baseURL
|
||||
if baseURL == "" && server != nil {
|
||||
baseURL = server.URL
|
||||
}
|
||||
|
||||
// Create client
|
||||
client := NewClient(baseURL, tt.apiKey, tt.collectionID)
|
||||
|
||||
// Call CreateBookmark
|
||||
err := client.CreateBookmark(tt.entryURL, tt.entryTitle)
|
||||
|
||||
// Check error
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Error("Expected error, got nil")
|
||||
} else if tt.errContains != "" && !strings.Contains(err.Error(), tt.errContains) {
|
||||
t.Errorf("Expected error to contain '%s', got '%s'", tt.errContains, err.Error())
|
||||
}
|
||||
} else {
|
||||
if err != nil {
|
||||
t.Errorf("Expected no error, got %v", err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewClient(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
baseURL string
|
||||
apiKey string
|
||||
collectionID *int64
|
||||
}{
|
||||
{
|
||||
name: "client without collection",
|
||||
baseURL: "https://linkwarden.example.com",
|
||||
apiKey: "test-key",
|
||||
collectionID: nil,
|
||||
},
|
||||
{
|
||||
name: "client with collection",
|
||||
baseURL: "https://linkwarden.example.com",
|
||||
apiKey: "test-key",
|
||||
collectionID: model.OptionalNumber(int64(123)),
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
client := NewClient(tt.baseURL, tt.apiKey, tt.collectionID)
|
||||
|
||||
if client.baseURL != tt.baseURL {
|
||||
t.Errorf("Expected baseURL %s, got %s", tt.baseURL, client.baseURL)
|
||||
}
|
||||
|
||||
if client.apiKey != tt.apiKey {
|
||||
t.Errorf("Expected apiKey %s, got %s", tt.apiKey, client.apiKey)
|
||||
}
|
||||
|
||||
if tt.collectionID == nil {
|
||||
if client.collectionID != nil {
|
||||
t.Errorf("Expected collectionId to be nil, got %v", *client.collectionID)
|
||||
}
|
||||
} else {
|
||||
if client.collectionID == nil {
|
||||
t.Error("Expected collectionId to be set, got nil")
|
||||
} else if *client.collectionID != *tt.collectionID {
|
||||
t.Errorf("Expected collectionId %d, got %d", *tt.collectionID, *client.collectionID)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func configureIntegrationAllowPrivateNetworksOption(t *testing.T) {
|
||||
t.Helper()
|
||||
|
||||
t.Setenv("INTEGRATION_ALLOW_PRIVATE_NETWORKS", "1")
|
||||
|
||||
configParser := config.NewConfigParser()
|
||||
parsedOptions, err := configParser.ParseEnvironmentVariables()
|
||||
if err != nil {
|
||||
t.Fatalf("Unable to configure test options: %v", err)
|
||||
}
|
||||
|
||||
previousOptions := config.Opts
|
||||
config.Opts = parsedOptions
|
||||
t.Cleanup(func() {
|
||||
config.Opts = previousOptions
|
||||
})
|
||||
}
|
||||
@@ -11,7 +11,9 @@ import (
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"miniflux.app/v2/internal/config"
|
||||
"miniflux.app/v2/internal/crypto"
|
||||
"miniflux.app/v2/internal/http/client"
|
||||
"miniflux.app/v2/internal/version"
|
||||
)
|
||||
|
||||
@@ -40,7 +42,7 @@ func (c *Client) DiscoverEndpoints() (*DiscoveryEndpointResponse, error) {
|
||||
request.Header.Set("Accept", "application/json")
|
||||
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
|
||||
|
||||
httpClient := &http.Client{Timeout: defaultClientTimeout}
|
||||
httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})
|
||||
response, err := httpClient.Do(request)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("matrix: unable to send request: %v", err)
|
||||
@@ -89,7 +91,7 @@ func (c *Client) Login(homeServerURL, matrixUsername, matrixPassword string) (*L
|
||||
request.Header.Set("Accept", "application/json")
|
||||
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
|
||||
|
||||
httpClient := &http.Client{Timeout: defaultClientTimeout}
|
||||
httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})
|
||||
response, err := httpClient.Do(request)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("matrix: unable to send request: %v", err)
|
||||
@@ -138,7 +140,7 @@ func (c *Client) SendFormattedTextMessage(homeServerURL, accessToken, roomID, te
|
||||
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
|
||||
request.Header.Set("Authorization", "Bearer "+accessToken)
|
||||
|
||||
httpClient := &http.Client{Timeout: defaultClientTimeout}
|
||||
httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})
|
||||
response, err := httpClient.Do(request)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("matrix: unable to send request: %v", err)
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"miniflux.app/v2/internal/http/client"
|
||||
"miniflux.app/v2/internal/version"
|
||||
)
|
||||
|
||||
@@ -57,7 +58,7 @@ func (c *Client) UpdateDocument(entryURL string, entryTitle string) error {
|
||||
request.Header.Set("Notion-Version", "2022-06-28")
|
||||
request.Header.Set("Authorization", "Bearer "+c.apiToken)
|
||||
|
||||
httpClient := &http.Client{Timeout: defaultClientTimeout}
|
||||
httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout})
|
||||
response, err := httpClient.Do(request)
|
||||
if err != nil {
|
||||
return fmt.Errorf("notion: unable to send request: %v", err)
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"time"
|
||||
|
||||
"miniflux.app/v2/internal/config"
|
||||
"miniflux.app/v2/internal/http/client"
|
||||
"miniflux.app/v2/internal/model"
|
||||
"miniflux.app/v2/internal/version"
|
||||
)
|
||||
@@ -32,7 +33,16 @@ func NewClient(ntfyURL, ntfyTopic, ntfyApiToken, ntfyUsername, ntfyPassword, ntf
|
||||
if ntfyURL == "" {
|
||||
ntfyURL = defaultNtfyURL
|
||||
}
|
||||
return &Client{ntfyURL, ntfyTopic, ntfyApiToken, ntfyUsername, ntfyPassword, ntfyIconURL, ntfyInternalLinks, ntfyPriority}
|
||||
return &Client{
|
||||
ntfyURL: ntfyURL,
|
||||
ntfyTopic: ntfyTopic,
|
||||
ntfyApiToken: ntfyApiToken,
|
||||
ntfyUsername: ntfyUsername,
|
||||
ntfyPassword: ntfyPassword,
|
||||
ntfyIconURL: ntfyIconURL,
|
||||
ntfyInternalLinks: ntfyInternalLinks,
|
||||
ntfyPriority: ntfyPriority,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) SendMessages(feed *model.Feed, entries model.Entries) error {
|
||||
@@ -98,7 +108,7 @@ func (c *Client) makeRequest(payload any) error {
|
||||
request.SetBasicAuth(c.ntfyUsername, c.ntfyPassword)
|
||||
}
|
||||
|
||||
httpClient := &http.Client{Timeout: defaultClientTimeout}
|
||||
httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})
|
||||
response, err := httpClient.Do(request)
|
||||
if err != nil {
|
||||
return fmt.Errorf("ntfy: unable to send request: %v", err)
|
||||
|
||||
@@ -11,6 +11,8 @@ import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"miniflux.app/v2/internal/config"
|
||||
"miniflux.app/v2/internal/http/client"
|
||||
"miniflux.app/v2/internal/urllib"
|
||||
"miniflux.app/v2/internal/version"
|
||||
)
|
||||
@@ -43,7 +45,7 @@ func (c *Client) AddEntry(entryURL, entryTitle, entryContent string) error {
|
||||
ContentType: "text/html",
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("notion: unable to encode request body: %v", err)
|
||||
return fmt.Errorf("nunux-keeper: unable to encode request body: %v", err)
|
||||
}
|
||||
|
||||
request, err := http.NewRequest(http.MethodPost, apiEndpoint, bytes.NewReader(requestBody))
|
||||
@@ -55,7 +57,7 @@ func (c *Client) AddEntry(entryURL, entryTitle, entryContent string) error {
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
|
||||
|
||||
httpClient := &http.Client{Timeout: defaultClientTimeout}
|
||||
httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})
|
||||
response, err := httpClient.Do(request)
|
||||
if err != nil {
|
||||
return fmt.Errorf("nunux-keeper: unable to send request: %v", err)
|
||||
|
||||
@@ -11,7 +11,9 @@ import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"miniflux.app/v2/internal/config"
|
||||
"miniflux.app/v2/internal/crypto"
|
||||
"miniflux.app/v2/internal/http/client"
|
||||
"miniflux.app/v2/internal/version"
|
||||
)
|
||||
|
||||
@@ -34,12 +36,6 @@ mutation SaveUrl($input: SaveUrlInput!) {
|
||||
}
|
||||
`
|
||||
|
||||
type SaveUrlInput struct {
|
||||
ClientRequestId string `json:"clientRequestId"`
|
||||
Source string `json:"source"`
|
||||
Url string `json:"url"`
|
||||
}
|
||||
|
||||
type errorResponse struct {
|
||||
Errors []struct {
|
||||
Message string `json:"message"`
|
||||
@@ -49,31 +45,27 @@ type errorResponse struct {
|
||||
type successResponse struct {
|
||||
Data struct {
|
||||
SaveUrl struct {
|
||||
Url string `json:"url"`
|
||||
ClientRequestId string `json:"clientRequestId"`
|
||||
URL string `json:"url"`
|
||||
ClientRequestID string `json:"clientRequestId"`
|
||||
} `json:"saveUrl"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
type Client interface {
|
||||
SaveUrl(url string) error
|
||||
}
|
||||
|
||||
type client struct {
|
||||
type Client struct {
|
||||
wrapped *http.Client
|
||||
apiEndpoint string
|
||||
apiToken string
|
||||
}
|
||||
|
||||
func NewClient(apiToken string, apiEndpoint string) Client {
|
||||
func NewClient(apiToken string, apiEndpoint string) *Client {
|
||||
if apiEndpoint == "" {
|
||||
apiEndpoint = defaultApiEndpoint
|
||||
}
|
||||
|
||||
return &client{wrapped: &http.Client{Timeout: defaultClientTimeout}, apiEndpoint: apiEndpoint, apiToken: apiToken}
|
||||
return &Client{wrapped: client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()}), apiEndpoint: apiEndpoint, apiToken: apiToken}
|
||||
}
|
||||
|
||||
func (c *client) SaveUrl(url string) error {
|
||||
func (c *Client) SaveURL(url string) error {
|
||||
var payload = map[string]any{
|
||||
"query": mutation,
|
||||
"variables": map[string]any{
|
||||
@@ -105,7 +97,7 @@ func (c *client) SaveUrl(url string) error {
|
||||
defer resp.Body.Close()
|
||||
b, err = io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("omnivore: failed to parse response: %s", err)
|
||||
return fmt.Errorf("omnivore: failed to parse response: %v", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
@@ -113,11 +105,14 @@ func (c *client) SaveUrl(url string) error {
|
||||
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)
|
||||
if len(errResponse.Errors) > 0 {
|
||||
return fmt.Errorf("omnivore: failed to save URL: status=%d %s", resp.StatusCode, errResponse.Errors[0].Message)
|
||||
}
|
||||
return fmt.Errorf("omnivore: failed to save URL: status=%d %s", resp.StatusCode, string(b))
|
||||
}
|
||||
|
||||
var successReponse successResponse
|
||||
if err = json.Unmarshal(b, &successReponse); err != nil {
|
||||
var successResp successResponse
|
||||
if err = json.Unmarshal(b, &successResp); 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))
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"miniflux.app/v2/internal/http/client"
|
||||
"miniflux.app/v2/internal/version"
|
||||
)
|
||||
|
||||
@@ -57,10 +58,9 @@ func (c *Client) CreateBookmark(entryURL, entryTitle, pinboardTags string, markA
|
||||
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}
|
||||
httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout})
|
||||
response, err := httpClient.Do(request)
|
||||
if err != nil {
|
||||
return fmt.Errorf("pinboard: unable to send request: %v", err)
|
||||
@@ -90,10 +90,9 @@ func (c *Client) getBookmark(entryURL string) (*Post, error) {
|
||||
return nil, fmt.Errorf("pinboard: unable to create request: %v", err)
|
||||
}
|
||||
|
||||
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
|
||||
|
||||
httpClient := &http.Client{Timeout: defaultClientTimeout}
|
||||
httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout})
|
||||
response, err := httpClient.Do(request)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("pinboard: unable fetch bookmark: %v", err)
|
||||
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
// Post a Pinboard bookmark. "inspiration" from https://github.com/drags/pinboard/blob/master/posts.go#L32-L42
|
||||
type Post struct {
|
||||
XMLName xml.Name `xml:"post"`
|
||||
Url string `xml:"href,attr"`
|
||||
URL string `xml:"href,attr"`
|
||||
Description string `xml:"description,attr"`
|
||||
Tags string `xml:"tag,attr"`
|
||||
Extended string `xml:"extended,attr"`
|
||||
@@ -30,7 +30,7 @@ type posts struct {
|
||||
|
||||
func NewPost(url string, description string) *Post {
|
||||
return &Post{
|
||||
Url: url,
|
||||
URL: url,
|
||||
Description: description,
|
||||
Date: time.Now(),
|
||||
Toread: "no",
|
||||
@@ -48,7 +48,7 @@ func (p *Post) SetToread() {
|
||||
}
|
||||
|
||||
func (p *Post) AddValues(values url.Values) {
|
||||
values.Add("url", p.Url)
|
||||
values.Add("url", p.URL)
|
||||
values.Add("description", p.Description)
|
||||
values.Add("tags", p.Tags)
|
||||
if p.Toread != "" {
|
||||
|
||||
@@ -12,6 +12,8 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"miniflux.app/v2/internal/config"
|
||||
"miniflux.app/v2/internal/http/client"
|
||||
"miniflux.app/v2/internal/model"
|
||||
"miniflux.app/v2/internal/version"
|
||||
)
|
||||
@@ -31,7 +33,7 @@ type Client struct {
|
||||
priority int
|
||||
}
|
||||
|
||||
type Message struct {
|
||||
type message struct {
|
||||
Token string `json:"token"`
|
||||
User string `json:"user"`
|
||||
|
||||
@@ -44,14 +46,14 @@ type Message struct {
|
||||
Device string `json:"device,omitempty"`
|
||||
}
|
||||
|
||||
type ErrorResponse struct {
|
||||
type errorResponse struct {
|
||||
User string `json:"user"`
|
||||
Errors []string `json:"errors"`
|
||||
Status int `json:"status"`
|
||||
Request string `json:"request"`
|
||||
}
|
||||
|
||||
func New(user, token string, priority int, device, urlPrefix string) *Client {
|
||||
func NewClient(user, token string, priority int, device, urlPrefix string) *Client {
|
||||
if urlPrefix == "" {
|
||||
urlPrefix = defaultPushoverURL
|
||||
}
|
||||
@@ -76,7 +78,7 @@ func (c *Client) SendMessages(feed *model.Feed, entries model.Entries) error {
|
||||
return errors.New("pushover token and user are required")
|
||||
}
|
||||
for _, entry := range entries {
|
||||
msg := &Message{
|
||||
msg := &message{
|
||||
User: c.user,
|
||||
Token: c.token,
|
||||
Device: c.device,
|
||||
@@ -94,45 +96,45 @@ func (c *Client) SendMessages(feed *model.Feed, entries model.Entries) error {
|
||||
)
|
||||
|
||||
if err := c.makeRequest(msg); err != nil {
|
||||
return fmt.Errorf("c.makeRequest: %w", err)
|
||||
return fmt.Errorf("pushover: unable to send message: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) makeRequest(payload *Message) error {
|
||||
func (c *Client) makeRequest(payload *message) error {
|
||||
jsonData, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("json.Marshal: %w", err)
|
||||
return fmt.Errorf("pushover: unable to encode request body: %w", err)
|
||||
}
|
||||
url := c.prefix + "/1/messages.json"
|
||||
req, err := http.NewRequest(http.MethodPost, url, bytes.NewBuffer(jsonData))
|
||||
if err != nil {
|
||||
return fmt.Errorf("http.NewRequest: %w", err)
|
||||
return fmt.Errorf("pushover: unable to create request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Add("Content-Type", "application/json")
|
||||
req.Header.Set("User-Agent", "Miniflux/"+version.Version)
|
||||
|
||||
httpClient := &http.Client{Timeout: defaultClientTimeout}
|
||||
httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("httpClient.Do: %w", err)
|
||||
return fmt.Errorf("pushover: unable to send request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode >= http.StatusBadRequest {
|
||||
errorMessage := resp.Status
|
||||
|
||||
var errResp ErrorResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&errResp); err != nil {
|
||||
var errResp errorResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&errResp); err == nil {
|
||||
if len(errResp.Errors) > 0 {
|
||||
errorMessage = strings.Join(errResp.Errors, ",")
|
||||
}
|
||||
}
|
||||
|
||||
return fmt.Errorf("pushover API error (%d): %s", resp.StatusCode, errorMessage)
|
||||
return fmt.Errorf("pushover: API error: status=%d %s", resp.StatusCode, errorMessage)
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
@@ -12,6 +12,8 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"miniflux.app/v2/internal/config"
|
||||
"miniflux.app/v2/internal/http/client"
|
||||
"miniflux.app/v2/internal/version"
|
||||
)
|
||||
|
||||
@@ -53,7 +55,7 @@ func (c *Client) CreateRaindrop(entryURL, entryTitle string) error {
|
||||
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
|
||||
request.Header.Set("Authorization", "Bearer "+c.token)
|
||||
|
||||
httpClient := &http.Client{Timeout: defaultClientTimeout}
|
||||
httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})
|
||||
response, err := httpClient.Do(request)
|
||||
if err != nil {
|
||||
return fmt.Errorf("raindrop: unable to send request: %v", err)
|
||||
@@ -70,7 +72,7 @@ func (c *Client) CreateRaindrop(entryURL, entryTitle string) error {
|
||||
type raindrop struct {
|
||||
Link string `json:"link"`
|
||||
Title string `json:"title"`
|
||||
Collection collection `json:"collection,omitempty"`
|
||||
Collection collection `json:"collection"`
|
||||
Tags []string `json:"tags"`
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,8 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"miniflux.app/v2/internal/config"
|
||||
"miniflux.app/v2/internal/http/client"
|
||||
"miniflux.app/v2/internal/urllib"
|
||||
"miniflux.app/v2/internal/version"
|
||||
)
|
||||
@@ -48,7 +50,7 @@ func (c *Client) CreateBookmark(entryURL, entryTitle string, entryContent string
|
||||
var request *http.Request
|
||||
if c.onlyURL {
|
||||
requestBodyJson, err := json.Marshal(&readeckBookmark{
|
||||
Url: entryURL,
|
||||
URL: entryURL,
|
||||
Title: entryTitle,
|
||||
Labels: labelsSplit,
|
||||
})
|
||||
@@ -91,7 +93,7 @@ func (c *Client) CreateBookmark(entryURL, entryTitle string, entryContent string
|
||||
}
|
||||
|
||||
contentBodyHeader, err := json.Marshal(&partContentHeader{
|
||||
Url: entryURL,
|
||||
URL: entryURL,
|
||||
ContentHeader: contentHeader{ContentType: "text/html; charset=utf-8"},
|
||||
})
|
||||
if err != nil {
|
||||
@@ -120,7 +122,7 @@ func (c *Client) CreateBookmark(entryURL, entryTitle string, entryContent string
|
||||
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
|
||||
request.Header.Set("Authorization", "Bearer "+c.apiKey)
|
||||
|
||||
httpClient := &http.Client{Timeout: defaultClientTimeout}
|
||||
httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})
|
||||
response, err := httpClient.Do(request)
|
||||
if err != nil {
|
||||
return fmt.Errorf("readeck: unable to send request: %v", err)
|
||||
@@ -135,7 +137,7 @@ func (c *Client) CreateBookmark(entryURL, entryTitle string, entryContent string
|
||||
}
|
||||
|
||||
type readeckBookmark struct {
|
||||
Url string `json:"url"`
|
||||
URL string `json:"url"`
|
||||
Title string `json:"title"`
|
||||
Labels []string `json:"labels,omitempty"`
|
||||
}
|
||||
@@ -145,6 +147,6 @@ type contentHeader struct {
|
||||
}
|
||||
|
||||
type partContentHeader struct {
|
||||
Url string `json:"url"`
|
||||
URL string `json:"url"`
|
||||
ContentHeader contentHeader `json:"headers"`
|
||||
}
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package readeck
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"miniflux.app/v2/internal/config"
|
||||
)
|
||||
|
||||
func TestCreateBookmark(t *testing.T) {
|
||||
configureIntegrationAllowPrivateNetworksOption(t)
|
||||
|
||||
entryURL := "https://example.com/article"
|
||||
entryTitle := "Example Title"
|
||||
entryContent := "<p>Some HTML content</p>"
|
||||
labels := "tag1,tag2"
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
onlyURL bool
|
||||
baseURL string
|
||||
apiKey string
|
||||
labels string
|
||||
entryURL string
|
||||
entryTitle string
|
||||
entryContent string
|
||||
serverResponse func(w http.ResponseWriter, r *http.Request)
|
||||
wantErr bool
|
||||
errContains string
|
||||
}{
|
||||
{
|
||||
name: "successful bookmark creation with only URL",
|
||||
onlyURL: true,
|
||||
labels: labels,
|
||||
entryURL: entryURL,
|
||||
entryTitle: entryTitle,
|
||||
entryContent: entryContent,
|
||||
serverResponse: func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
t.Errorf("expected POST, got %s", r.Method)
|
||||
}
|
||||
if r.URL.Path != "/api/bookmarks/" {
|
||||
t.Errorf("expected path /api/bookmarks/, got %s", r.URL.Path)
|
||||
}
|
||||
if got := r.Header.Get("Authorization"); !strings.HasPrefix(got, "Bearer ") {
|
||||
t.Errorf("expected Authorization Bearer header, got %q", got)
|
||||
}
|
||||
if ct := r.Header.Get("Content-Type"); ct != "application/json" {
|
||||
t.Errorf("expected Content-Type application/json, got %s", ct)
|
||||
}
|
||||
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(body, &payload); err != nil {
|
||||
t.Fatalf("failed to parse JSON body: %v", err)
|
||||
}
|
||||
if u := payload["url"]; u != entryURL {
|
||||
t.Errorf("expected url %s, got %v", entryURL, u)
|
||||
}
|
||||
if title := payload["title"]; title != entryTitle {
|
||||
t.Errorf("expected title %s, got %v", entryTitle, title)
|
||||
}
|
||||
// Labels should be split into an array
|
||||
if raw := payload["labels"]; raw == nil {
|
||||
t.Errorf("expected labels to be set")
|
||||
} else if arr, ok := raw.([]any); ok {
|
||||
if len(arr) != 2 || arr[0] != "tag1" || arr[1] != "tag2" {
|
||||
t.Errorf("unexpected labels: %#v", arr)
|
||||
}
|
||||
} else {
|
||||
t.Errorf("labels should be an array, got %T", raw)
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "successful bookmark creation with content (multipart)",
|
||||
onlyURL: false,
|
||||
labels: labels,
|
||||
entryURL: entryURL,
|
||||
entryTitle: entryTitle,
|
||||
entryContent: entryContent,
|
||||
serverResponse: func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
t.Errorf("expected POST, got %s", r.Method)
|
||||
}
|
||||
if r.URL.Path != "/api/bookmarks/" {
|
||||
t.Errorf("expected path /api/bookmarks/, got %s", r.URL.Path)
|
||||
}
|
||||
if got := r.Header.Get("Authorization"); !strings.HasPrefix(got, "Bearer ") {
|
||||
t.Errorf("expected Authorization Bearer header, got %q", got)
|
||||
}
|
||||
ct := r.Header.Get("Content-Type")
|
||||
if !strings.HasPrefix(ct, "multipart/form-data;") {
|
||||
t.Errorf("expected multipart/form-data, got %s", ct)
|
||||
}
|
||||
_, after, ok := strings.Cut(ct, "boundary=")
|
||||
if !ok {
|
||||
t.Fatalf("missing multipart boundary in Content-Type: %s", ct)
|
||||
}
|
||||
boundary := after
|
||||
mr := multipart.NewReader(r.Body, boundary)
|
||||
|
||||
seenLabels := []string{}
|
||||
var seenURL, seenTitle, seenFeature string
|
||||
var resourceHeader map[string]any
|
||||
var resourceBody string
|
||||
|
||||
for {
|
||||
part, err := mr.NextPart()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("reading multipart: %v", err)
|
||||
}
|
||||
name := part.FormName()
|
||||
data, _ := io.ReadAll(part)
|
||||
switch name {
|
||||
case "url":
|
||||
seenURL = string(data)
|
||||
case "title":
|
||||
seenTitle = string(data)
|
||||
case "feature_find_main":
|
||||
seenFeature = string(data)
|
||||
case "labels":
|
||||
seenLabels = append(seenLabels, string(data))
|
||||
case "resource":
|
||||
// First line is JSON header, then newline, then content
|
||||
all := string(data)
|
||||
before, after, ok := strings.Cut(all, "\n")
|
||||
if !ok {
|
||||
t.Fatalf("resource content missing header separator")
|
||||
}
|
||||
headerJSON := before
|
||||
resourceBody = after
|
||||
if err := json.Unmarshal([]byte(headerJSON), &resourceHeader); err != nil {
|
||||
t.Fatalf("invalid resource header JSON: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if seenURL != entryURL {
|
||||
t.Errorf("expected url %s, got %s", entryURL, seenURL)
|
||||
}
|
||||
if seenTitle != entryTitle {
|
||||
t.Errorf("expected title %s, got %s", entryTitle, seenTitle)
|
||||
}
|
||||
if seenFeature != "false" {
|
||||
t.Errorf("expected feature_find_main to be 'false', got %s", seenFeature)
|
||||
}
|
||||
if len(seenLabels) != 2 || seenLabels[0] != "tag1" || seenLabels[1] != "tag2" {
|
||||
t.Errorf("unexpected labels: %#v", seenLabels)
|
||||
}
|
||||
if resourceHeader == nil {
|
||||
t.Fatalf("missing resource header")
|
||||
}
|
||||
if hURL, _ := resourceHeader["url"].(string); hURL != entryURL {
|
||||
t.Errorf("expected resource header url %s, got %v", entryURL, hURL)
|
||||
}
|
||||
if headers, ok := resourceHeader["headers"].(map[string]any); ok {
|
||||
if ct, _ := headers["content-type"].(string); ct != "text/html; charset=utf-8" {
|
||||
t.Errorf("expected resource header content-type text/html; charset=utf-8, got %v", ct)
|
||||
}
|
||||
} else {
|
||||
t.Errorf("missing resource header 'headers' field")
|
||||
}
|
||||
if resourceBody != entryContent {
|
||||
t.Errorf("expected resource body %q, got %q", entryContent, resourceBody)
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "error when server returns 400",
|
||||
onlyURL: true,
|
||||
labels: labels,
|
||||
entryURL: entryURL,
|
||||
entryTitle: entryTitle,
|
||||
entryContent: entryContent,
|
||||
serverResponse: func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
},
|
||||
wantErr: true,
|
||||
errContains: "unable to create bookmark",
|
||||
},
|
||||
{
|
||||
name: "error when missing baseURL or apiKey",
|
||||
onlyURL: true,
|
||||
baseURL: "",
|
||||
apiKey: "",
|
||||
labels: labels,
|
||||
entryURL: entryURL,
|
||||
entryTitle: entryTitle,
|
||||
entryContent: entryContent,
|
||||
serverResponse: nil,
|
||||
wantErr: true,
|
||||
errContains: "missing base URL or API key",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var serverURL string
|
||||
if tt.serverResponse != nil {
|
||||
srv := httptest.NewServer(http.HandlerFunc(tt.serverResponse))
|
||||
defer srv.Close()
|
||||
serverURL = srv.URL
|
||||
}
|
||||
baseURL := tt.baseURL
|
||||
if baseURL == "" {
|
||||
baseURL = serverURL
|
||||
}
|
||||
apiKey := tt.apiKey
|
||||
if apiKey == "" {
|
||||
apiKey = "test-api-key"
|
||||
}
|
||||
|
||||
client := NewClient(baseURL, apiKey, tt.labels, tt.onlyURL)
|
||||
err := client.CreateBookmark(tt.entryURL, tt.entryTitle, tt.entryContent)
|
||||
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Fatalf("expected error, got none")
|
||||
}
|
||||
if tt.errContains != "" && !strings.Contains(err.Error(), tt.errContains) {
|
||||
t.Fatalf("expected error containing %q, got %q", tt.errContains, err.Error())
|
||||
}
|
||||
} else if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewClient(t *testing.T) {
|
||||
baseURL := "https://readeck.example.com"
|
||||
apiKey := "key"
|
||||
labels := "tag1,tag2"
|
||||
onlyURL := true
|
||||
|
||||
c := NewClient(baseURL, apiKey, labels, onlyURL)
|
||||
if c.baseURL != baseURL {
|
||||
t.Errorf("expected baseURL %s, got %s", baseURL, c.baseURL)
|
||||
}
|
||||
if c.apiKey != apiKey {
|
||||
t.Errorf("expected apiKey %s, got %s", apiKey, c.apiKey)
|
||||
}
|
||||
if c.labels != labels {
|
||||
t.Errorf("expected labels %s, got %s", labels, c.labels)
|
||||
}
|
||||
if c.onlyURL != onlyURL {
|
||||
t.Errorf("expected onlyURL %v, got %v", onlyURL, c.onlyURL)
|
||||
}
|
||||
}
|
||||
|
||||
func configureIntegrationAllowPrivateNetworksOption(t *testing.T) {
|
||||
t.Helper()
|
||||
|
||||
t.Setenv("INTEGRATION_ALLOW_PRIVATE_NETWORKS", "1")
|
||||
|
||||
configParser := config.NewConfigParser()
|
||||
parsedOptions, err := configParser.ParseEnvironmentVariables()
|
||||
if err != nil {
|
||||
t.Fatalf("Unable to configure test options: %v", err)
|
||||
}
|
||||
|
||||
previousOptions := config.Opts
|
||||
config.Opts = parsedOptions
|
||||
t.Cleanup(func() {
|
||||
config.Opts = previousOptions
|
||||
})
|
||||
}
|
||||
@@ -13,6 +13,8 @@ import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"miniflux.app/v2/internal/config"
|
||||
"miniflux.app/v2/internal/http/client"
|
||||
"miniflux.app/v2/internal/version"
|
||||
)
|
||||
|
||||
@@ -51,7 +53,7 @@ func (c *Client) CreateDocument(entryURL string) error {
|
||||
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
|
||||
request.Header.Set("Authorization", "Token "+c.apiKey)
|
||||
|
||||
httpClient := &http.Client{Timeout: defaultClientTimeout}
|
||||
httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})
|
||||
response, err := httpClient.Do(request)
|
||||
if err != nil {
|
||||
return fmt.Errorf("readwise: unable to send request: %v", err)
|
||||
|
||||
@@ -11,6 +11,10 @@ import (
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"miniflux.app/v2/internal/config"
|
||||
"miniflux.app/v2/internal/http/client"
|
||||
"miniflux.app/v2/internal/version"
|
||||
)
|
||||
|
||||
const defaultClientTimeout = 30 * time.Second
|
||||
@@ -27,7 +31,7 @@ type BridgeMeta struct {
|
||||
func DetectBridges(rssBridgeURL, rssBridgeToken, websiteURL string) ([]*Bridge, error) {
|
||||
endpointURL, err := url.Parse(rssBridgeURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("RSS-Bridge: unable to parse bridge URL: %w", err)
|
||||
return nil, fmt.Errorf("rssbridge: unable to parse bridge URL: %w", err)
|
||||
}
|
||||
|
||||
values := endpointURL.Query()
|
||||
@@ -43,14 +47,16 @@ func DetectBridges(rssBridgeURL, rssBridgeToken, websiteURL string) ([]*Bridge,
|
||||
|
||||
request, err := http.NewRequest(http.MethodGet, endpointURL.String(), nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("RSS-Bridge: unable to create request: %w", err)
|
||||
return nil, fmt.Errorf("rssbridge: unable to create request: %w", err)
|
||||
}
|
||||
|
||||
httpClient := &http.Client{Timeout: defaultClientTimeout}
|
||||
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
|
||||
|
||||
httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})
|
||||
|
||||
response, err := httpClient.Do(request)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("RSS-Bridge: unable to execute request: %w", err)
|
||||
return nil, fmt.Errorf("rssbridge: unable to execute request: %w", err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
|
||||
@@ -58,13 +64,13 @@ func DetectBridges(rssBridgeURL, rssBridgeToken, websiteURL string) ([]*Bridge,
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
if response.StatusCode > 400 {
|
||||
return nil, fmt.Errorf("RSS-Bridge: unexpected status code %d", response.StatusCode)
|
||||
if response.StatusCode >= 400 {
|
||||
return nil, fmt.Errorf("rssbridge: unexpected status code %d", response.StatusCode)
|
||||
}
|
||||
|
||||
var bridgeResponse []*Bridge
|
||||
if err := json.NewDecoder(response.Body).Decode(&bridgeResponse); err != nil {
|
||||
return nil, fmt.Errorf("RSS-Bridge: unable to decode bridge response: %w", err)
|
||||
return nil, fmt.Errorf("rssbridge: unable to decode bridge response: %w", err)
|
||||
}
|
||||
|
||||
for _, bridge := range bridgeResponse {
|
||||
@@ -76,7 +82,7 @@ func DetectBridges(rssBridgeURL, rssBridgeToken, websiteURL string) ([]*Bridge,
|
||||
if strings.HasPrefix(bridge.URL, "./") {
|
||||
bridge.URL = rssBridgeURL + bridge.URL[2:]
|
||||
|
||||
slog.Debug("Rewrited relative RSS bridge URL",
|
||||
slog.Debug("Rewrote relative RSS bridge URL",
|
||||
slog.String("name", bridge.BridgeMeta.Name),
|
||||
slog.String("url", bridge.URL),
|
||||
)
|
||||
|
||||
@@ -14,6 +14,8 @@ import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"miniflux.app/v2/internal/config"
|
||||
"miniflux.app/v2/internal/http/client"
|
||||
"miniflux.app/v2/internal/urllib"
|
||||
"miniflux.app/v2/internal/version"
|
||||
)
|
||||
@@ -59,7 +61,7 @@ func (c *Client) CreateLink(entryURL, entryTitle string) error {
|
||||
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
|
||||
request.Header.Set("Authorization", "Bearer "+c.generateBearerToken())
|
||||
|
||||
httpClient := &http.Client{Timeout: defaultClientTimeout}
|
||||
httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})
|
||||
response, err := httpClient.Do(request)
|
||||
if err != nil {
|
||||
return fmt.Errorf("shaarli: unable to send request: %v", err)
|
||||
@@ -75,7 +77,7 @@ func (c *Client) CreateLink(entryURL, entryTitle string) error {
|
||||
|
||||
func (c *Client) generateBearerToken() string {
|
||||
header := base64.RawURLEncoding.EncodeToString([]byte(`{"typ":"JWT","alg":"HS512"}`))
|
||||
payload := base64.RawURLEncoding.EncodeToString([]byte(fmt.Sprintf(`{"iat":%d}`, time.Now().Unix())))
|
||||
payload := base64.RawURLEncoding.EncodeToString(fmt.Appendf(nil, `{"iat":%d}`, time.Now().Unix()))
|
||||
data := header + "." + payload
|
||||
|
||||
mac := hmac.New(sha512.New, []byte(c.apiSecret))
|
||||
|
||||
@@ -11,6 +11,8 @@ import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"miniflux.app/v2/internal/config"
|
||||
"miniflux.app/v2/internal/http/client"
|
||||
"miniflux.app/v2/internal/urllib"
|
||||
"miniflux.app/v2/internal/version"
|
||||
)
|
||||
@@ -65,7 +67,7 @@ func (c *Client) CreateBookmark(entryURL, entryTitle string) error {
|
||||
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
|
||||
request.Header.Set("Authorization", "Bearer "+token)
|
||||
|
||||
httpClient := &http.Client{Timeout: defaultClientTimeout}
|
||||
httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})
|
||||
|
||||
response, err := httpClient.Do(request)
|
||||
if err != nil {
|
||||
@@ -80,7 +82,7 @@ func (c *Client) CreateBookmark(entryURL, entryTitle string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) authenticate() (token string, err error) {
|
||||
func (c *Client) authenticate() (string, error) {
|
||||
apiEndpoint, err := urllib.JoinBaseURLAndPath(c.baseURL, "/api/v1/auth/login")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("shiori: invalid API endpoint: %v", err)
|
||||
@@ -100,7 +102,7 @@ func (c *Client) authenticate() (token string, err error) {
|
||||
request.Header.Set("Accept", "application/json")
|
||||
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
|
||||
|
||||
httpClient := &http.Client{Timeout: defaultClientTimeout}
|
||||
httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})
|
||||
|
||||
response, err := httpClient.Do(request)
|
||||
if err != nil {
|
||||
|
||||
@@ -13,6 +13,8 @@ import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"miniflux.app/v2/internal/config"
|
||||
"miniflux.app/v2/internal/http/client"
|
||||
"miniflux.app/v2/internal/model"
|
||||
"miniflux.app/v2/internal/urllib"
|
||||
"miniflux.app/v2/internal/version"
|
||||
@@ -81,12 +83,12 @@ func (c *Client) SendSlackMsg(feed *model.Feed, entries model.Entries) error {
|
||||
slog.String("entry_url", entry.URL),
|
||||
)
|
||||
|
||||
httpClient := &http.Client{Timeout: defaultClientTimeout}
|
||||
httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})
|
||||
response, err := httpClient.Do(request)
|
||||
if err != nil {
|
||||
return fmt.Errorf("slack: unable to send request: %v", err)
|
||||
}
|
||||
response.Body.Close()
|
||||
defer response.Body.Close()
|
||||
|
||||
if response.StatusCode >= 400 {
|
||||
return fmt.Errorf("slack: unable to send a notification: url=%s status=%d", c.webhookURL, response.StatusCode)
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"miniflux.app/v2/internal/http/client"
|
||||
"miniflux.app/v2/internal/version"
|
||||
)
|
||||
|
||||
@@ -50,7 +51,7 @@ func (c *Client) GetMe() (*User, error) {
|
||||
request.Header.Set("Accept", "application/json")
|
||||
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
|
||||
|
||||
httpClient := &http.Client{Timeout: defaultClientTimeout}
|
||||
httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout})
|
||||
response, err := httpClient.Do(request)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("telegram: unable to send request: %v", err)
|
||||
@@ -90,7 +91,7 @@ func (c *Client) SendMessage(message *MessageRequest) (*Message, error) {
|
||||
request.Header.Set("Accept", "application/json")
|
||||
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
|
||||
|
||||
httpClient := &http.Client{Timeout: defaultClientTimeout}
|
||||
httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout})
|
||||
response, err := httpClient.Do(request)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("telegram: unable to send request: %v", err)
|
||||
|
||||
@@ -13,6 +13,8 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"miniflux.app/v2/internal/config"
|
||||
"miniflux.app/v2/internal/http/client"
|
||||
"miniflux.app/v2/internal/urllib"
|
||||
"miniflux.app/v2/internal/version"
|
||||
)
|
||||
@@ -30,7 +32,15 @@ type Client struct {
|
||||
}
|
||||
|
||||
func NewClient(baseURL, clientID, clientSecret, username, password, tags string, onlyURL bool) *Client {
|
||||
return &Client{baseURL, clientID, clientSecret, username, password, tags, onlyURL}
|
||||
return &Client{
|
||||
baseURL: baseURL,
|
||||
clientID: clientID,
|
||||
clientSecret: clientSecret,
|
||||
username: username,
|
||||
password: password,
|
||||
tags: tags,
|
||||
onlyURL: onlyURL,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) CreateEntry(entryURL, entryTitle, entryContent string) error {
|
||||
@@ -49,7 +59,7 @@ func (c *Client) CreateEntry(entryURL, entryTitle, entryContent string) error {
|
||||
func (c *Client) createEntry(accessToken, entryURL, entryTitle, entryContent, tags string) error {
|
||||
apiEndpoint, err := urllib.JoinBaseURLAndPath(c.baseURL, "/api/entries.json")
|
||||
if err != nil {
|
||||
return fmt.Errorf("wallbag: unable to generate entries endpoint: %v", err)
|
||||
return fmt.Errorf("wallabag: unable to generate entries endpoint: %v", err)
|
||||
}
|
||||
|
||||
if c.onlyURL {
|
||||
@@ -63,12 +73,12 @@ func (c *Client) createEntry(accessToken, entryURL, entryTitle, entryContent, ta
|
||||
Tags: tags,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("wallbag: unable to encode request body: %v", err)
|
||||
return fmt.Errorf("wallabag: 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)
|
||||
return fmt.Errorf("wallabag: unable to create request: %v", err)
|
||||
}
|
||||
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
@@ -76,7 +86,7 @@ func (c *Client) createEntry(accessToken, entryURL, entryTitle, entryContent, ta
|
||||
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
|
||||
request.Header.Set("Authorization", "Bearer "+accessToken)
|
||||
|
||||
httpClient := &http.Client{Timeout: defaultClientTimeout}
|
||||
httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})
|
||||
response, err := httpClient.Do(request)
|
||||
if err != nil {
|
||||
return fmt.Errorf("wallabag: unable to send request: %v", err)
|
||||
@@ -100,19 +110,19 @@ func (c *Client) getAccessToken() (string, error) {
|
||||
|
||||
apiEndpoint, err := urllib.JoinBaseURLAndPath(c.baseURL, "/oauth/v2/token")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("wallbag: unable to generate token endpoint: %v", err)
|
||||
return "", fmt.Errorf("wallabag: 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)
|
||||
return "", fmt.Errorf("wallabag: 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}
|
||||
httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})
|
||||
response, err := httpClient.Do(request)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("wallabag: unable to send request: %v", err)
|
||||
|
||||
@@ -10,9 +10,13 @@ import (
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"miniflux.app/v2/internal/config"
|
||||
)
|
||||
|
||||
func TestCreateEntry(t *testing.T) {
|
||||
configureIntegrationAllowPrivateNetworksOption(t)
|
||||
|
||||
entryURL := "https://example.com"
|
||||
entryTitle := "title"
|
||||
entryContent := "content"
|
||||
@@ -319,3 +323,21 @@ func TestNewClient(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func configureIntegrationAllowPrivateNetworksOption(t *testing.T) {
|
||||
t.Helper()
|
||||
|
||||
t.Setenv("INTEGRATION_ALLOW_PRIVATE_NETWORKS", "1")
|
||||
|
||||
configParser := config.NewConfigParser()
|
||||
parsedOptions, err := configParser.ParseEnvironmentVariables()
|
||||
if err != nil {
|
||||
t.Fatalf("Unable to configure test options: %v", err)
|
||||
}
|
||||
|
||||
previousOptions := config.Opts
|
||||
config.Opts = parsedOptions
|
||||
t.Cleanup(func() {
|
||||
config.Opts = previousOptions
|
||||
})
|
||||
}
|
||||
|
||||
@@ -11,7 +11,9 @@ import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"miniflux.app/v2/internal/config"
|
||||
"miniflux.app/v2/internal/crypto"
|
||||
"miniflux.app/v2/internal/http/client"
|
||||
"miniflux.app/v2/internal/model"
|
||||
"miniflux.app/v2/internal/version"
|
||||
)
|
||||
@@ -132,7 +134,7 @@ func (c *Client) makeRequest(eventType string, payload any) error {
|
||||
request.Header.Set("X-Miniflux-Signature", crypto.GenerateSHA256Hmac(c.webhookSecret, requestBody))
|
||||
request.Header.Set("X-Miniflux-Event-Type", eventType)
|
||||
|
||||
httpClient := &http.Client{Timeout: defaultClientTimeout}
|
||||
httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})
|
||||
response, err := httpClient.Do(request)
|
||||
if err != nil {
|
||||
return fmt.Errorf("webhook: unable to send request: %v", err)
|
||||
|
||||
@@ -99,12 +99,14 @@ func TestMissingTranslations(t *testing.T) {
|
||||
|
||||
func TestTranslationFilePluralForms(t *testing.T) {
|
||||
var numberOfPluralFormsPerLanguage = map[string]int{
|
||||
"ar_SA": 6,
|
||||
"de_DE": 2,
|
||||
"el_EL": 2,
|
||||
"en_US": 2,
|
||||
"es_ES": 2,
|
||||
"fi_FI": 2,
|
||||
"fr_FR": 2,
|
||||
"gl_ES": 2,
|
||||
"hi_IN": 2,
|
||||
"id_ID": 1,
|
||||
"it_IT": 2,
|
||||
|
||||
@@ -5,12 +5,14 @@ package locale // import "miniflux.app/v2/internal/locale"
|
||||
|
||||
// AvailableLanguages is the list of available languages.
|
||||
var AvailableLanguages = map[string]string{
|
||||
"ar_SA": "العربية",
|
||||
"de_DE": "Deutsch",
|
||||
"el_EL": "Ελληνικά",
|
||||
"en_US": "English",
|
||||
"es_ES": "Español",
|
||||
"fi_FI": "Suomi",
|
||||
"fr_FR": "Français",
|
||||
"gl_ES": "Galego",
|
||||
"hi_IN": "हिन्दी",
|
||||
"id_ID": "Bahasa Indonesia",
|
||||
"it_IT": "Italiano",
|
||||
|
||||
@@ -31,6 +31,11 @@ func getPluralForm(lang string, n int) int {
|
||||
default:
|
||||
return 2
|
||||
}
|
||||
case "gl_ES":
|
||||
if n != 1 {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
case "id_ID", "ja_JP":
|
||||
return 0
|
||||
case "pl_PL":
|
||||
|
||||
@@ -44,6 +44,13 @@ func TestPluralRules(t *testing.T) {
|
||||
2: 1, // n > 1
|
||||
5: 1, // n > 1
|
||||
},
|
||||
// Galician (gl_ES) - n != 1
|
||||
"gl_ES": {
|
||||
0: 1, // n != 1
|
||||
1: 0, // n == 1
|
||||
2: 1, // n != 1
|
||||
5: 1, // n != 1
|
||||
},
|
||||
// Indonesian (id_ID) - always form 0
|
||||
"id_ID": {
|
||||
0: 0,
|
||||
|
||||
@@ -0,0 +1,707 @@
|
||||
{
|
||||
"action.cancel": "إلغاء",
|
||||
"action.download": "تحميل",
|
||||
"action.edit": "تعديل",
|
||||
"action.home_screen": "إضافة إلى الشاشة الرئيسية",
|
||||
"action.import": "استيراد",
|
||||
"action.login": "تسجيل الدخول",
|
||||
"action.or": "أو",
|
||||
"action.remove": "حذف",
|
||||
"action.remove_feed": "حذف هذا المصدر",
|
||||
"action.save": "حفظ",
|
||||
"action.subscribe": "اشتراك",
|
||||
"action.update": "تحديث",
|
||||
"alert.account_linked": "تم ربط حسابك الخارجي!",
|
||||
"alert.account_unlinked": "تم فك ارتباط حسابك الخارجي!",
|
||||
"alert.background_feed_refresh": "يتم تحديث جميع المصادر في الخلفية. يمكنك الاستمرار في استخدام Miniflux أثناء تشغيل هذه العملية.",
|
||||
"alert.feed_error": "توجد مشكلة في هذا المصدر",
|
||||
"alert.no_starred": "لا توجد في المُفضلة.",
|
||||
"alert.no_category": "لا توجد فئة.",
|
||||
"alert.no_category_entry": "لا توجد مقالات في هذه الفئة.",
|
||||
"alert.no_feed": "ليس لديك أي مصادر.",
|
||||
"alert.no_feed_entry": "لا توجد مقالات لهذا المصدر.",
|
||||
"alert.no_feed_in_category": "لا يوجد مصدر لهذه الفئة.",
|
||||
"alert.no_history": "لا يوجد سجل في الوقت الحالي.",
|
||||
"alert.no_search_result": "لا توجد نتائج لهذا البحث.",
|
||||
"alert.no_shared_entry": "لا توجد مشاركات.",
|
||||
"alert.no_tag_entry": "لا توجد مقالات تطابق هذا الوسم.",
|
||||
"alert.no_unread_entry": "لا توجد مقالات غير مقروءة.",
|
||||
"alert.no_user": "أنت المستخدم الوحيد.",
|
||||
"alert.prefs_saved": "تم حفظ التفضيلات!",
|
||||
"alert.too_many_feeds_refresh": [
|
||||
"لقد طلبت تحديث عدد كبير جداً من المصادر. يرجى الانتظار %d دقيقة قبل المحاولة مرة أخرى.",
|
||||
"لقد طلبت تحديث عدد كبير جداً من المصادر. يرجى الانتظار دقيقة واحدة قبل المحاولة مرة أخرى.",
|
||||
"لقد طلبت تحديث عدد كبير جداً من المصادر. يرجى الانتظار دقيقتين قبل المحاولة مرة أخرى.",
|
||||
"لقد طلبت تحديث عدد كبير جداً من المصادر. يرجى الانتظار %d دقائق قبل المحاولة مرة أخرى.",
|
||||
"لقد طلبت تحديث عدد كبير جداً من المصادر. يرجى الانتظار %d دقيقة قبل المحاولة مرة أخرى.",
|
||||
"لقد طلبت تحديث عدد كبير جداً من المصادر. يرجى الانتظار %d دقيقة قبل المحاولة مرة أخرى."
|
||||
],
|
||||
"confirm.loading": "جارٍ التحميل...",
|
||||
"confirm.no": "لا",
|
||||
"confirm.question": "هل أنت متأكد؟",
|
||||
"confirm.question.refresh": "هل أنت متأكد أنك تريد فرض التحديث؟",
|
||||
"confirm.yes": "نعم",
|
||||
"enclosure_media_controls.seek": "بحث:",
|
||||
"enclosure_media_controls.seek.title": "بحث %s ثانية",
|
||||
"enclosure_media_controls.speed": "السرعة:",
|
||||
"enclosure_media_controls.speed.faster": "أسرع",
|
||||
"enclosure_media_controls.speed.faster.title": "أسرع بـ %sx",
|
||||
"enclosure_media_controls.speed.reset": "إعادة تعيين",
|
||||
"enclosure_media_controls.speed.reset.title": "إعادة تعيين السرعة إلى 1x",
|
||||
"enclosure_media_controls.speed.slower": "أبطأ",
|
||||
"enclosure_media_controls.speed.slower.title": "أبطأ بـ %sx",
|
||||
"entry.starred.toast.off": "أزيلت من المفضلة",
|
||||
"entry.starred.toast.on": "أضيفت للمفضلة",
|
||||
"entry.starred.toggle.off": "إزالة من المفضلة",
|
||||
"entry.starred.toggle.on": "إضافة للمفضلة",
|
||||
"entry.comments.label": "تعليقات",
|
||||
"entry.comments.title": "عرض التعليقات",
|
||||
"entry.estimated_reading_time": [
|
||||
"قراءة %d دقيقة",
|
||||
"قراءة دقيقة واحدة",
|
||||
"قراءة دقيقتين",
|
||||
"قراءة %d دقائق",
|
||||
"قراءة %d دقيقة",
|
||||
"قراءة %d دقيقة"
|
||||
],
|
||||
"entry.external_link.label": "رابط خارجي",
|
||||
"entry.save.completed": "تم!",
|
||||
"entry.save.label": "حفظ",
|
||||
"entry.save.title": "حفظ هذا المقال",
|
||||
"entry.save.toast.completed": "تم حفظ المقال",
|
||||
"entry.scraper.completed": "تم!",
|
||||
"entry.scraper.label": "تحميل",
|
||||
"entry.scraper.title": "جلب المحتوى الأصلي",
|
||||
"entry.share.label": "مشاركة",
|
||||
"entry.share.title": "مشاركة هذا المقال",
|
||||
"entry.shared_entry.label": "مشاركة",
|
||||
"entry.shared_entry.title": "فتح الرابط العام",
|
||||
"entry.state.loading": "جارٍ التحميل...",
|
||||
"entry.state.saving": "جارٍ الحفظ...",
|
||||
"entry.status.mark_as_read": "تحديد كمقروء",
|
||||
"entry.status.mark_as_unread": "تحديد كغير مقروء",
|
||||
"entry.status.title": "تغيير حالة المقال",
|
||||
"entry.status.toast.read": "تم تحديده كمقروء",
|
||||
"entry.status.toast.unread": "تم تحديده كغير مقروء",
|
||||
"entry.tags.label": "الوسوم:",
|
||||
"entry.tags.more_tags_label": [
|
||||
"إظهار %d وسم",
|
||||
"إظهار وسم واحد",
|
||||
"إظهار وسمين",
|
||||
"إظهار %d وسوم",
|
||||
"إظهار %d وسماً",
|
||||
"إظهار %d وسماً"
|
||||
],
|
||||
"entry.unshare.label": "إلغاء المشاركة",
|
||||
"error.api_key_already_exists": "مفتاح API هذا موجود بالفعل.",
|
||||
"error.bad_credentials": "اسم المستخدم أو كلمة المرور غير صالحة.",
|
||||
"error.category_already_exists": "هذه الفئة موجودة بالفعل.",
|
||||
"error.category_not_found": "هذه الفئة غير موجودة أو لا تنتمي لهذا المستخدم.",
|
||||
"error.database_error": "خطأ في قاعدة البيانات: %v.",
|
||||
"error.different_passwords": "كلمات المرور غير متطابقة.",
|
||||
"error.duplicate_fever_username": "يوجد بالفعل شخص آخر بنفس اسم مستخدم Fever!",
|
||||
"error.duplicate_googlereader_username": "يوجد بالفعل شخص آخر بنفس اسم مستخدم Google Reader!",
|
||||
"error.linktaco_missing_required_fields": "مطلوب رمز LinkTaco API و Organization Slug",
|
||||
"error.duplicate_linked_account": "يوجد بالفعل شخص مرتبط بهذا الموفر!",
|
||||
"error.duplicated_feed": "هذا المصدر موجود بالفعل.",
|
||||
"error.empty_file": "هذا الملف فارغ.",
|
||||
"error.entries_per_page_invalid": "عدد المقالات في الصفحة غير صالح.",
|
||||
"error.feed_already_exists": "هذا المصدر موجود بالفعل.",
|
||||
"error.feed_category_not_found": "هذه الفئة غير موجودة أو لا تنتمي لهذا المستخدم.",
|
||||
"error.feed_format_not_detected": "تعذر اكتشاف تنسيق المصدر: %v.",
|
||||
"error.feed_invalid_blocklist_rule": "قاعدة قائمة الحظر غير صالحة.",
|
||||
"error.feed_invalid_keeplist_rule": "قاعدة قائمة الاحتفاظ غير صالحة.",
|
||||
"error.feed_mandatory_fields": "الرابط والفئة إلزاميان.",
|
||||
"error.feed_not_found": "هذا المصدر غير موجود أو لا ينتمي لهذا المستخدم.",
|
||||
"error.feed_title_not_empty": "عنوان المصدر لا يمكن أن يكون فارغاً.",
|
||||
"error.feed_url_not_empty": "رابط المصدر لا يمكن أن يكون فارغاً.",
|
||||
"error.fields_mandatory": "جميع الحقول إلزامية.",
|
||||
"error.http_bad_gateway": "الموقع غير متاح حالياً بسبب خطأ في البوابة (Bad Gateway). المشكلة ليست من جانب Miniflux. يرجى المحاولة لاحقاً.",
|
||||
"error.http_body_read": "تعذر قراءة محتوى استجابة HTTP: %v.",
|
||||
"error.http_client_error": "خطأ في عميل HTTP: %v.",
|
||||
"error.http_empty_response": "استجابة HTTP فارغة. ربما يستخدم هذا الموقع آلية حماية ضد الروبوتات؟",
|
||||
"error.http_empty_response_body": "محتوى استجابة HTTP فارغ.",
|
||||
"error.http_forbidden": "الوصول إلى هذا الموقع ممنوع. ربما يوجد آلية حماية ضد الروبوتات؟",
|
||||
"error.http_gateway_timeout": "الموقع غير متاح حالياً بسبب انتهاء مهلة البوابة. المشكلة ليست من جانب Miniflux. يرجى المحاولة لاحقاً.",
|
||||
"error.http_internal_server_error": "الموقع غير متاح حالياً بسبب خطأ في الخادم. المشكلة ليست من جانب Miniflux. يرجى المحاولة لاحقاً.",
|
||||
"error.http_not_authorized": "الوصول إلى هذا الموقع غير مصرح به. قد يكون اسم المستخدم أو كلمة المرور غير صحيحة.",
|
||||
"error.http_resource_not_found": "المورد المطلوب غير موجود. يرجى التحقق من الرابط.",
|
||||
"error.http_response_too_large": "استجابة HTTP كبيرة جداً. يمكنك زيادة حد حجم استجابة HTTP في الإعدادات العامة (يتطلب إعادة تشغيل الخادم).",
|
||||
"error.http_service_unavailable": "الموقع غير متاح حالياً بسبب خطأ داخلي في الخادم. المشكلة ليست من جانب Miniflux. يرجى المحاولة لاحقاً.",
|
||||
"error.http_too_many_requests": "أنشأ Miniflux عدداً كبيراً جداً من الطلبات لهذا الموقع. يرجى المحاولة لاحقاً أو تغيير إعدادات التطبيق.",
|
||||
"error.http_unexpected_status_code": "الموقع غير متاح حالياً بسبب رمز حالة HTTP غير متوقع: %d. المشكلة ليست من جانب Miniflux. يرجى المحاولة لاحقاً.",
|
||||
"error.invalid_categories_sorting_order": "ترتيب فرز الفئات غير صالح.",
|
||||
"error.invalid_default_home_page": "الصفحة الرئيسية الافتراضية غير صالحة!",
|
||||
"error.invalid_display_mode": "وضع عرض تطبيق الويب غير صالح.",
|
||||
"error.invalid_entry_direction": "اتجاه المقال غير صالح.",
|
||||
"error.invalid_entry_order": "ترتيب المقال غير صالح.",
|
||||
"error.invalid_feed_proxy_url": "رابط الوكيل (Proxy) غير صالح.",
|
||||
"error.invalid_feed_url": "رابط المصدر غير صالح.",
|
||||
"error.invalid_gesture_nav": "تصفح الإيماءات غير صالح.",
|
||||
"error.invalid_language": "اللغة غير صالحة.",
|
||||
"error.invalid_site_url": "رابط الموقع غير صالح.",
|
||||
"error.invalid_theme": "السمة غير صالحة.",
|
||||
"error.invalid_timezone": "المنطقة الزمنية غير صالحة.",
|
||||
"error.network_operation": "Miniflux غير قادر على الوصول إلى هذا الموقع بسبب خطأ في الشبكة: %v.",
|
||||
"error.network_timeout": "هذا الموقع بطيء جداً وانتهى وقت الطلب: %v",
|
||||
"error.password_min_length": "يجب أن تتكون كلمة المرور من 6 أحرف على الأقل.",
|
||||
"error.proxy_url_not_empty": "رابط الوكيل لا يمكن أن يكون فارغاً.",
|
||||
"error.settings_block_rule_fieldname_invalid": "قاعدة الحظر غير صالحة: القاعدة رقم #%d تفتقد لاسم حقل صالح (الخيارات: %s)",
|
||||
"error.settings_block_rule_invalid_regex": "قاعدة الحظر غير صالحة: نمط القاعدة #%d ليس تعبيرًا نمطيًا (regex) صالحًا",
|
||||
"error.settings_block_rule_regex_required": "قاعدة الحظر غير صالحة: لم يتم توفير نمط للقاعدة #%d",
|
||||
"error.settings_block_rule_separator_required": "قاعدة الحظر غير صالحة: يجب فصل نمط القاعدة #%d بـ '='",
|
||||
"error.settings_invalid_domain_list": "قائمة النطاقات غير صالحة. يرجى تقديم قائمة مفصولة بمسافات للنطاقات.",
|
||||
"error.settings_keep_rule_fieldname_invalid": "قاعدة الاحتفاظ غير صالحة: القاعدة رقم #%d تفتقد لاسم حقل صالح (الخيارات: %s)",
|
||||
"error.settings_keep_rule_invalid_regex": "قاعدة الاحتفاظ غير صالحة: نمط القاعدة #%d ليس تعبيرًا نمطيًا (regex) صالحًا",
|
||||
"error.settings_keep_rule_regex_required": "قاعدة الاحتفاظ غير صالحة: لم يتم توفير نمط للقاعدة #%d",
|
||||
"error.settings_keep_rule_separator_required": "قاعدة الاحتفاظ غير صالحة: يجب فصل نمط القاعدة #%d بـ '='",
|
||||
"error.settings_mandatory_fields": "حقول اسم المستخدم، السمة، اللغة، والمنطقة الزمنية إلزامية.",
|
||||
"error.settings_media_playback_rate_range": "سرعة التشغيل خارج النطاق",
|
||||
"error.settings_reading_speed_is_positive": "يجب أن تكون سرعة القراءة أرقاماً صحيحة موجبة.",
|
||||
"error.site_url_not_empty": "رابط الموقع لا يمكن أن يكون فارغاً.",
|
||||
"error.subscription_not_found": "تعذر العثور على أي مصدر.",
|
||||
"error.title_required": "العنوان إلزامي.",
|
||||
"error.tls_error": "خطأ TLS: %q. يمكنك تعطيل التحقق من TLS في إعدادات المصدر إذا كنت ترغب في ذلك.",
|
||||
"error.unable_to_create_api_key": "تعذر إنشاء مفتاح API هذا.",
|
||||
"error.unable_to_create_category": "تعذر إنشاء هذه الفئة.",
|
||||
"error.unable_to_create_user": "تعذر إنشاء هذا المستخدم.",
|
||||
"error.unable_to_detect_rssbridge": "تعذر اكتشاف المصدر باستخدام RSS-Bridge: %v.",
|
||||
"error.unable_to_parse_feed": "تعذر تحليل هذا المصدر: %v.",
|
||||
"error.unable_to_update_category": "تعذر تحديث هذه الفئة.",
|
||||
"error.unable_to_update_feed": "تعذر تحديث هذا المصدر.",
|
||||
"error.unable_to_update_user": "تعذر تحديث هذا المستخدم.",
|
||||
"error.unlink_account_without_password": "يجب عليك تحديد كلمة مرور وإلا لن تتمكن من تسجيل الدخول مرة أخرى.",
|
||||
"error.user_already_exists": "هذا المستخدم موجود بالفعل.",
|
||||
"error.user_mandatory_fields": "اسم المستخدم إلزامي.",
|
||||
"form.api_key.label.description": "تسمية مفتاح API",
|
||||
"form.category.hide_globally": "إخفاء المقالات من القائمة العامة غير المقروءة",
|
||||
"form.category.label.title": "العنوان",
|
||||
"form.feed.fieldset.general": "عام",
|
||||
"form.feed.fieldset.integration": "خدمات الطرف الثالث",
|
||||
"form.feed.fieldset.network_settings": "إعدادات الشبكة",
|
||||
"form.feed.fieldset.rules": "قواعد",
|
||||
"form.feed.label.allow_self_signed_certificates": "السماح بالشهادات الموقعة ذاتياً أو غير الصالحة",
|
||||
"form.feed.label.apprise_service_urls": "قائمة عناوين URL لخدمة Apprise مفصولة بفاصلة",
|
||||
"form.feed.label.block_filter_entry_rules": "قواعد حظر المقالات",
|
||||
"form.feed.label.blocklist_rules": "مرشحات الحظر المعتمدة على Regex",
|
||||
"form.feed.label.category": "الفئة",
|
||||
"form.feed.label.cookie": "تعيين ملفات تعريف الارتباط (Cookies)",
|
||||
"form.feed.label.crawler": "جلب المحتوى الأصلي",
|
||||
"form.feed.label.ignore_entry_updates": "تجاهل تحديثات المقالات",
|
||||
"form.feed.label.description": "الوصف",
|
||||
"form.feed.label.disable_http2": "تعطيل HTTP/2 لتجنب التتبع",
|
||||
"form.feed.label.disabled": "لا تقم بتحديث هذا المصدر",
|
||||
"form.feed.label.feed_password": "كلمة مرور المصدر",
|
||||
"form.feed.label.feed_url": "رابط المصدر",
|
||||
"form.feed.label.feed_username": "اسم مستخدم المصدر",
|
||||
"form.feed.label.fetch_via_proxy": "استخدم الوكيل الذي تم تكوينه على مستوى التطبيق",
|
||||
"form.feed.label.hide_globally": "إخفاء المقالات من القائمة العامة غير المقروءة",
|
||||
"form.feed.label.ignore_http_cache": "تجاهل ذاكرة التخزين المؤقت لـ HTTP",
|
||||
"form.feed.label.keep_filter_entry_rules": "قواعد السماح للمقالات",
|
||||
"form.feed.label.keeplist_rules": "مرشحات الاحتفاظ المعتمدة على Regex",
|
||||
"form.feed.label.no_media_player": "بدون مشغل الوسائط (صوت / فيديو)",
|
||||
"form.feed.label.ntfy_activate": "إرسال المقالات إلى ntfy",
|
||||
"form.feed.label.ntfy_default_priority": "أولوية Ntfy الافتراضية",
|
||||
"form.feed.label.ntfy_high_priority": "أولوية Ntfy عالية",
|
||||
"form.feed.label.ntfy_low_priority": "أولوية Ntfy منخفضة",
|
||||
"form.feed.label.ntfy_max_priority": "أقصى أولوية Ntfy",
|
||||
"form.feed.label.ntfy_min_priority": "أدنى أولوية Ntfy",
|
||||
"form.feed.label.ntfy_priority": "أولوية Ntfy",
|
||||
"form.feed.label.ntfy_topic": "موضوع Ntfy (اختياري)",
|
||||
"form.feed.label.proxy_url": "رابط الوكيل (Proxy)",
|
||||
"form.feed.label.pushover_activate": "إرسال المقالات إلى Pushover",
|
||||
"form.feed.label.pushover_default_priority": "الأولوية الافتراضية",
|
||||
"form.feed.label.pushover_high_priority": "أولوية عالية",
|
||||
"form.feed.label.pushover_low_priority": "أولوية منخفضة",
|
||||
"form.feed.label.pushover_max_priority": "أولوية قصوى",
|
||||
"form.feed.label.pushover_min_priority": "أولوية دنيا",
|
||||
"form.feed.label.pushover_priority": "أولوية رسالة Pushover",
|
||||
"form.feed.label.rewrite_rules": "قواعد إعادة كتابة المحتوى",
|
||||
"form.feed.label.scraper_rules": "قواعد الكاشط (Scraper)",
|
||||
"form.feed.label.site_url": "رابط الموقع",
|
||||
"form.feed.label.title": "العنوان",
|
||||
"form.feed.label.urlrewrite_rules": "قواعد إعادة كتابة الروابط",
|
||||
"form.feed.label.user_agent": "تجاوز وكيل المستخدم الافتراضي (User Agent)",
|
||||
"form.feed.label.webhook_url": "تجاوز رابط الويب هوك (Webhook)",
|
||||
"form.import.label.file": "ملف OPML",
|
||||
"form.import.label.url": "الرابط",
|
||||
"form.integration.archiveorg_activate": "إرسال المقالات إلى archive.org",
|
||||
"form.integration.apprise_activate": "إرسال المقالات إلى Apprise",
|
||||
"form.integration.apprise_services_url": "قائمة عناوين URL لخدمة Apprise مفصولة بفاصلة",
|
||||
"form.integration.apprise_url": "رابط Apprise API",
|
||||
"form.integration.betula_activate": "حفظ المقالات في Betula",
|
||||
"form.integration.betula_token": "رمز Betula",
|
||||
"form.integration.betula_url": "رابط خادم Betula",
|
||||
"form.integration.cubox_activate": "حفظ المقالات في Cubox",
|
||||
"form.integration.cubox_api_link": "رابط Cubox API",
|
||||
"form.integration.discord_activate": "إرسال المقالات إلى Discord",
|
||||
"form.integration.discord_webhook_link": "رابط Discord Webhook",
|
||||
"form.integration.espial_activate": "حفظ المقالات في Espial",
|
||||
"form.integration.espial_api_key": "مفتاح Espial API",
|
||||
"form.integration.espial_endpoint": "نقطة نهاية Espial API",
|
||||
"form.integration.espial_tags": "وسوم Espial",
|
||||
"form.integration.fever_activate": "تفعيل Fever API",
|
||||
"form.integration.fever_endpoint": "نقطة نهاية Fever API:",
|
||||
"form.integration.fever_password": "كلمة مرور Fever",
|
||||
"form.integration.fever_username": "اسم مستخدم Fever",
|
||||
"form.integration.googlereader_activate": "تفعيل Google Reader API",
|
||||
"form.integration.googlereader_endpoint": "نقطة نهاية Google Reader API:",
|
||||
"form.integration.googlereader_password": "كلمة مرور Google Reader",
|
||||
"form.integration.googlereader_username": "اسم مستخدم Google Reader",
|
||||
"form.integration.instapaper_activate": "حفظ المقالات في Instapaper",
|
||||
"form.integration.instapaper_password": "كلمة مرور Instapaper",
|
||||
"form.integration.instapaper_username": "اسم مستخدم Instapaper",
|
||||
"form.integration.karakeep_activate": "حفظ المقالات في Karakeep",
|
||||
"form.integration.karakeep_api_key": "مفتاح Karakeep API",
|
||||
"form.integration.karakeep_url": "نقطة نهاية Karakeep API",
|
||||
"form.integration.karakeep_tags": "وسوم Karakeep",
|
||||
"form.integration.linkace_activate": "حفظ المقالات في LinkAce",
|
||||
"form.integration.linkace_api_key": "مفتاح LinkAce API",
|
||||
"form.integration.linkace_check_disabled": "تعطيل فحص الرابط",
|
||||
"form.integration.linkace_endpoint": "نقطة نهاية LinkAce API",
|
||||
"form.integration.linkace_is_private": "تحديد الرابط كخاص",
|
||||
"form.integration.linkace_tags": "وسوم LinkAce",
|
||||
"form.integration.linkding_activate": "حفظ المقالات في Linkding",
|
||||
"form.integration.linkding_api_key": "مفتاح Linkding API",
|
||||
"form.integration.linkding_bookmark": "تحديد الإشارة المرجعية كغير مقروءة",
|
||||
"form.integration.linkding_endpoint": "نقطة نهاية Linkding API",
|
||||
"form.integration.linkding_tags": "وسوم Linkding",
|
||||
"form.integration.linktaco_activate": "حفظ المقالات في LinkTaco",
|
||||
"form.integration.linktaco_api_token": "رمز LinkTaco API",
|
||||
"form.integration.linktaco_api_token_hint": "احصل على رمز الوصول الشخصي الخاص بك في",
|
||||
"form.integration.linktaco_org_slug": "Slug المؤسسة",
|
||||
"form.integration.linktaco_tags": "الوسوم (بحد أقصى 10، مفصولة بفواصل)",
|
||||
"form.integration.linktaco_tags_hint": "حد أقصى 10 وسوم، مفصولة بفواصل",
|
||||
"form.integration.linktaco_visibility": "الرؤية",
|
||||
"form.integration.linktaco_visibility_public": "عام",
|
||||
"form.integration.linktaco_visibility_private": "خاص",
|
||||
"form.integration.linktaco_visibility_hint": "تتطلب رؤية خاصة حساب LinkTaco مدفوع",
|
||||
"form.integration.linkwarden_activate": "حفظ المقالات في Linkwarden",
|
||||
"form.integration.linkwarden_api_key": "مفتاح Linkwarden API",
|
||||
"form.integration.linkwarden_endpoint": "رابط Linkwarden الأساسي",
|
||||
"form.integration.linkwarden_collection_id": "معرف مجموعة Linkwarden",
|
||||
"form.integration.matrix_bot_activate": "إرسال مقالات جديدة إلى Matrix",
|
||||
"form.integration.matrix_bot_chat_id": "معرف غرفة Matrix",
|
||||
"form.integration.matrix_bot_password": "كلمة مرور مستخدم Matrix",
|
||||
"form.integration.matrix_bot_url": "رابط خادم Matrix",
|
||||
"form.integration.matrix_bot_user": "اسم المستخدم في Matrix",
|
||||
"form.integration.notion_activate": "حفظ المقالات في Notion",
|
||||
"form.integration.notion_page_id": "معرف صفحة Notion",
|
||||
"form.integration.notion_token": "رمز Notion السري",
|
||||
"form.integration.ntfy_activate": "إرسال المقالات إلى ntfy",
|
||||
"form.integration.ntfy_api_token": "رمز Ntfy API (اختياري)",
|
||||
"form.integration.ntfy_icon_url": "رابط أيقونة Ntfy (اختياري)",
|
||||
"form.integration.ntfy_internal_links": "استخدام روابط داخلية عند النقر (اختياري)",
|
||||
"form.integration.ntfy_password": "كلمة مرور Ntfy (اختياري)",
|
||||
"form.integration.ntfy_topic": "موضوع Ntfy (يستخدم الافتراضي إذا لم يُحدد في المصدر)",
|
||||
"form.integration.ntfy_url": "رابط Ntfy (اختياري، الافتراضي هو ntfy.sh)",
|
||||
"form.integration.ntfy_username": "اسم مستخدم Ntfy (اختياري)",
|
||||
"form.integration.nunux_keeper_activate": "حفظ المقالات في Nunux Keeper",
|
||||
"form.integration.nunux_keeper_api_key": "مفتاح Nunux Keeper API",
|
||||
"form.integration.nunux_keeper_endpoint": "نقطة نهاية Nunux Keeper API",
|
||||
"form.integration.omnivore_activate": "حفظ المقالات في Omnivore",
|
||||
"form.integration.omnivore_api_key": "مفتاح Omnivore API",
|
||||
"form.integration.omnivore_url": "نقطة نهاية Omnivore API",
|
||||
"form.integration.pinboard_activate": "حفظ المقالات في Pinboard",
|
||||
"form.integration.pinboard_bookmark": "تحديد الإشارة المرجعية كغير مقروءة",
|
||||
"form.integration.pinboard_tags": "وسوم Pinboard",
|
||||
"form.integration.pinboard_token": "رمز Pinboard API",
|
||||
"form.integration.pushover_activate": "إرسال المقالات إلى Pushover",
|
||||
"form.integration.pushover_device": "جهاز Pushover (اختياري)",
|
||||
"form.integration.pushover_prefix": "بادئة رابط Pushover (اختياري)",
|
||||
"form.integration.pushover_token": "رمز API لتطبيق Pushover",
|
||||
"form.integration.pushover_user": "مفتاح مستخدم Pushover",
|
||||
"form.integration.raindrop_activate": "حفظ المقالات في Raindrop",
|
||||
"form.integration.raindrop_collection_id": "معرف المجموعة",
|
||||
"form.integration.raindrop_tags": "الوسوم (مفصولة بفواصل)",
|
||||
"form.integration.raindrop_token": "رمز (تجريبي)",
|
||||
"form.integration.readeck_activate": "حفظ المقالات في readeck",
|
||||
"form.integration.readeck_api_key": "مفتاح Readeck API",
|
||||
"form.integration.readeck_endpoint": "رابط Readeck",
|
||||
"form.integration.readeck_labels": "تسميات Readeck",
|
||||
"form.integration.readeck_only_url": "إرسال الرابط فقط (بدلاً من المحتوى الكامل)",
|
||||
"form.integration.readeck_push_activate": "إرسال المقالات الجديدة تلقائياً إلى Readeck",
|
||||
"form.integration.readwise_activate": "حفظ المقالات في Readwise Reader",
|
||||
"form.integration.readwise_api_key": "رمز الوصول لـ Readwise Reader",
|
||||
"form.integration.readwise_api_key_link": "احصل على رمز الوصول لـ Readwise الخاص بك",
|
||||
"form.integration.rssbridge_activate": "تحقق من RSS-Bridge عند إضافة الاشتراكات",
|
||||
"form.integration.rssbridge_token": "رمز مصادقة RSS-Bridge",
|
||||
"form.integration.rssbridge_url": "رابط خادم RSS-Bridge",
|
||||
"form.integration.shaarli_activate": "حفظ المقالات في Shaarli",
|
||||
"form.integration.shaarli_api_secret": "سر Shaarli API",
|
||||
"form.integration.shaarli_endpoint": "رابط Shaarli",
|
||||
"form.integration.shiori_activate": "حفظ المقالات في Shiori",
|
||||
"form.integration.shiori_endpoint": "نقطة نهاية Shiori API",
|
||||
"form.integration.shiori_password": "كلمة مرور Shiori",
|
||||
"form.integration.shiori_username": "اسم مستخدم Shiori",
|
||||
"form.integration.slack_activate": "إرسال المقالات إلى Slack",
|
||||
"form.integration.slack_webhook_link": "رابط Slack Webhook",
|
||||
"form.integration.telegram_bot_activate": "إرسال المقالات الجديدة إلى دردشة Telegram",
|
||||
"form.integration.telegram_bot_disable_buttons": "تعطيل الأزرار",
|
||||
"form.integration.telegram_bot_disable_notification": "تعطيل الإشعارات",
|
||||
"form.integration.telegram_bot_disable_web_page_preview": "تعطيل معاينة الرابط",
|
||||
"form.integration.telegram_bot_token": "رمز البوت (Token)",
|
||||
"form.integration.telegram_chat_id": "معرف الدردشة",
|
||||
"form.integration.telegram_topic_id": "معرف الموضوع (Topic ID)",
|
||||
"form.integration.wallabag_activate": "حفظ المقالات في Wallabag",
|
||||
"form.integration.wallabag_client_id": "معرف عميل Wallabag",
|
||||
"form.integration.wallabag_client_secret": "سر عميل Wallabag",
|
||||
"form.integration.wallabag_endpoint": "رابط Wallabag الأساسي",
|
||||
"form.integration.wallabag_only_url": "إرسال الرابط فقط (بدلاً من المحتوى الكامل)",
|
||||
"form.integration.wallabag_password": "كلمة مرور Wallabag",
|
||||
"form.integration.wallabag_username": "اسم مستخدم Wallabag",
|
||||
"form.integration.wallabag_tags": "وسوم Wallabag",
|
||||
"form.integration.webhook_activate": "تفعيل Webhooks",
|
||||
"form.integration.webhook_secret": "سر Webhooks",
|
||||
"form.integration.webhook_url": "رابط Webhook الافتراضي",
|
||||
"form.prefs.fieldset.application_settings": "إعدادات التطبيق",
|
||||
"form.prefs.fieldset.authentication_settings": "إعدادات المصادقة",
|
||||
"form.prefs.fieldset.global_feed_settings": "إعدادات المصادر العامة",
|
||||
"form.prefs.fieldset.reader_settings": "إعدادات القارئ",
|
||||
"form.prefs.help.external_font_hosts": "قائمة مفصولة بمسافات لمضيفي الخطوط الخارجية للسماح بها. مثال: \"fonts.gstatic.com fonts.googleapis.com\".",
|
||||
"form.prefs.label.always_open_external_links": "قراءة المقالات عن طريق فتح الروابط الخارجية",
|
||||
"form.prefs.label.categories_sorting_order": "فرز الفئات",
|
||||
"form.prefs.label.cjk_reading_speed": "سرعة القراءة للغات الصينية والكورية واليابانية (حرف في الدقيقة)",
|
||||
"form.prefs.label.custom_css": "CSS مخصص",
|
||||
"form.prefs.label.custom_js": "JavaScript مخصص",
|
||||
"form.prefs.label.default_home_page": "الصفحة الرئيسية الافتراضية",
|
||||
"form.prefs.label.default_reading_speed": "سرعة القراءة للغات الأخرى (كلمة في الدقيقة)",
|
||||
"form.prefs.label.display_mode": "وضع العرض (Progressive Web App - PWA)",
|
||||
"form.prefs.label.entries_per_page": "عدد المقالات في الصفحة",
|
||||
"form.prefs.label.entry_order": "عمود فرز المقالات",
|
||||
"form.prefs.label.entry_sorting": "فرز المقالات",
|
||||
"form.prefs.label.entry_swipe": "تفعيل التمرير للمقالات على الشاشات التي تعمل باللمس",
|
||||
"form.prefs.label.external_font_hosts": "مضيفو الخطوط الخارجية",
|
||||
"form.prefs.label.gesture_nav": "إيماءة للتنقل بين المقالات",
|
||||
"form.prefs.label.keyboard_shortcuts": "تفعيل اختصارات لوحة المفاتيح",
|
||||
"form.prefs.label.language": "اللغة",
|
||||
"form.prefs.label.mark_read_manually": "تحديد المقالات كمقروءة يدوياً",
|
||||
"form.prefs.label.mark_read_on_media_completion": "حدد كمقروء فقط عندما يصل تشغيل الصوت/الفيديو إلى 90% من الاكتمال",
|
||||
"form.prefs.label.mark_read_on_view": "تحديد المقالات تلقائياً كمقروءة عند عرضها",
|
||||
"form.prefs.label.mark_read_on_view_or_media_completion": "حدد المقالات كمقروءة عند عرضها. بالنسبة للصوت/الفيديو، حدد كمقروء عند اكتمال 90%",
|
||||
"form.prefs.label.media_playback_rate": "سرعة تشغيل الصوت/فيديو",
|
||||
"form.prefs.label.open_external_links_in_new_tab": "فتح الروابط الخارجية في تبويب جديد (يضيف target=\"_blank\" للروابط)",
|
||||
"form.prefs.label.show_reading_time": "إظهار الوقت المقدر للقراءة للمقالات",
|
||||
"form.prefs.label.theme": "السمة",
|
||||
"form.prefs.label.timezone": "المنطقة الزمنية",
|
||||
"form.prefs.select.alphabetical": "أبجدي",
|
||||
"form.prefs.select.browser": "المتصفح",
|
||||
"form.prefs.select.created_time": "وقت إنشاء المقال",
|
||||
"form.prefs.select.fullscreen": "ملء الشاشة",
|
||||
"form.prefs.select.minimal_ui": "الحد الأدنى",
|
||||
"form.prefs.select.none": "بدون",
|
||||
"form.prefs.select.older_first": "المقالات القديمة أولاً",
|
||||
"form.prefs.select.publish_time": "وقت نشر المقال",
|
||||
"form.prefs.select.recent_first": "المقالات الحديثة أولاً",
|
||||
"form.prefs.select.standalone": "مستقل",
|
||||
"form.prefs.select.swipe": "تمرير سريع",
|
||||
"form.prefs.select.tap": "نقر مزدوج",
|
||||
"form.prefs.select.unread_count": "عدد غير المقروءة",
|
||||
"form.submit.loading": "جارٍ التحميل...",
|
||||
"form.submit.saving": "جارٍ الحفظ...",
|
||||
"form.user.label.admin": "مدير",
|
||||
"form.user.label.confirmation": "تأكيد كلمة المرور",
|
||||
"form.user.label.password": "كلمة المرور",
|
||||
"form.user.label.username": "اسم المستخدم",
|
||||
"menu.about": "حول",
|
||||
"menu.add_feed": "إضافة مصدر",
|
||||
"menu.add_user": "إضافة مستخدم",
|
||||
"menu.api_keys": "مفاتيح API",
|
||||
"menu.categories": "الفئات",
|
||||
"menu.create_api_key": "إنشاء مفتاح API جديد",
|
||||
"menu.create_category": "إنشاء فئة",
|
||||
"menu.edit_category": "تعديل",
|
||||
"menu.edit_feed": "تعديل",
|
||||
"menu.export": "تصدير",
|
||||
"menu.feed_entries": "المقالات",
|
||||
"menu.feeds": "المصادر",
|
||||
"menu.flush_history": "مسح السجل",
|
||||
"menu.history": "السجل",
|
||||
"menu.home_page": "الصفحة الرئيسية",
|
||||
"menu.import": "استيراد",
|
||||
"menu.integrations": "خدمات مرتبطة",
|
||||
"menu.logout": "تسجيل الخروج",
|
||||
"menu.mark_all_as_read": "تحديد الكل كمقروء",
|
||||
"menu.mark_page_as_read": "تحديد هذه الصفحة كمقروءة",
|
||||
"menu.preferences": "التفضيلات",
|
||||
"menu.refresh_all_feeds": "تحديث جميع المصادر في الخلفية",
|
||||
"menu.refresh_feed": "تحديث",
|
||||
"menu.search": "بحث",
|
||||
"menu.sessions": "الجلسات",
|
||||
"menu.settings": "الإعدادات",
|
||||
"menu.shared_entries": "المقالات المشاركة",
|
||||
"menu.show_all_entries": "إظهار كل المقالات",
|
||||
"menu.show_only_starred_entries": "إظهار المقالات المفضلة فقط",
|
||||
"menu.show_only_unread_entries": "إظهار المقالات غير المقروءة فقط",
|
||||
"menu.starred": "المفضلة",
|
||||
"menu.title": "القائمة",
|
||||
"menu.unread": "غير مقروء",
|
||||
"menu.users": "المستخدمون",
|
||||
"page.about.author": "المؤلف:",
|
||||
"page.about.build_date": "تاريخ البناء:",
|
||||
"page.about.credits": "شكر وتقدير",
|
||||
"page.about.db_usage": "حجم قاعدة البيانات:",
|
||||
"page.about.git_commit": "التزام Git:",
|
||||
"page.about.global_config_options": "خيارات التكوين العامة",
|
||||
"page.about.go_version": "إصدار Go:",
|
||||
"page.about.license": "الرخصة:",
|
||||
"page.about.postgres_version": "إصدار Postgres:",
|
||||
"page.about.title": "حول",
|
||||
"page.about.version": "الإصدار:",
|
||||
"page.add_feed.choose_feed": "اختر مصدراً",
|
||||
"page.add_feed.label.url": "الرابط",
|
||||
"page.add_feed.legend.advanced_options": "خيارات متقدمة",
|
||||
"page.add_feed.no_category": "لا توجد فئة. يجب أن يكون لديك فئة واحدة على الأقل.",
|
||||
"page.add_feed.submit": "البحث عن مصدر",
|
||||
"page.add_feed.title": "مصدر جديد",
|
||||
"page.api_keys.never_used": "لم يُستخدم أبداً",
|
||||
"page.api_keys.table.actions": "الإجراءات",
|
||||
"page.api_keys.table.created_at": "تاريخ الإنشاء",
|
||||
"page.api_keys.table.description": "الوصف",
|
||||
"page.api_keys.table.last_used_at": "آخر استخدام",
|
||||
"page.api_keys.table.token": "الرمز",
|
||||
"page.api_keys.title": "مفاتيح API",
|
||||
"page.categories.entries": "المقالات",
|
||||
"page.categories.feed_count": [
|
||||
"لا يوجد مصادر.",
|
||||
"يوجد مصدر واحد.",
|
||||
"يوجد مصدران.",
|
||||
"يوجد %d مصادر.",
|
||||
"يوجد %d مصدراً.",
|
||||
"يوجد %d مصدراً."
|
||||
],
|
||||
"page.categories.feeds": "المصادر",
|
||||
"page.categories.no_feed": "لا يوجد مصدر.",
|
||||
"page.categories.title": "الفئات",
|
||||
"page.categories_count": [
|
||||
"%d فئة",
|
||||
"فئة واحدة",
|
||||
"فئتان",
|
||||
"%d فئات",
|
||||
"%d فئة",
|
||||
"%d فئة"
|
||||
],
|
||||
"page.category_label": "الفئة: %s",
|
||||
"page.edit_category.title": "تعديل الفئة: %s",
|
||||
"page.edit_feed.etag_header": "رأس ETag:",
|
||||
"page.edit_feed.last_check": "آخر فحص:",
|
||||
"page.edit_feed.last_modified_header": "رأس LastModified:",
|
||||
"page.edit_feed.last_parsing_error": "آخر خطأ تحليل",
|
||||
"page.edit_feed.no_header": "لا يوجد",
|
||||
"page.edit_feed.title": "تعديل المصدر: %s",
|
||||
"page.edit_user.title": "تعديل المستخدم: %s",
|
||||
"page.entry.attachments": "مرفقات",
|
||||
"page.feeds.error_count": [
|
||||
"%d خطأ",
|
||||
"خطأ واحد",
|
||||
"خطآن",
|
||||
"%d أخطاء",
|
||||
"%d خطأً",
|
||||
"%d خطأً"
|
||||
],
|
||||
"page.feeds.last_check": "آخر فحص:",
|
||||
"page.feeds.next_check": "الفحص التالي:",
|
||||
"page.feeds.read_counter": "عدد المقالات المقروءة",
|
||||
"page.feeds.title": "المصادر",
|
||||
"page.footer.elevator": "العودة للأعلى",
|
||||
"page.history.title": "السجل",
|
||||
"page.import.title": "استيراد",
|
||||
"page.integration.bookmarklet": "أداة الإشارة المرجعية (Bookmarklet)",
|
||||
"page.integration.bookmarklet.help": "يتيح لك هذا الرابط الخاص الاشتراك في أي موقع ويب مباشرةً باستخدام إشارة مرجعية في متصفح الويب الخاص بك.",
|
||||
"page.integration.bookmarklet.instructions": "اسحب وأفلت هذا الرابط إلى إشاراتك المرجعية.",
|
||||
"page.integration.bookmarklet.name": "إضافة إلى Miniflux",
|
||||
"page.integration.miniflux_api": "Miniflux API",
|
||||
"page.integration.miniflux_api_endpoint": "نقطة نهاية API",
|
||||
"page.integration.miniflux_api_password": "كلمة المرور",
|
||||
"page.integration.miniflux_api_password_value": "كلمة مرور حسابك",
|
||||
"page.integration.miniflux_api_username": "اسم المستخدم",
|
||||
"page.integrations.title": "خدمات مرتبطة",
|
||||
"page.keyboard_shortcuts.close_modal": "إغلاق النافذة المنبثقة",
|
||||
"page.keyboard_shortcuts.download_content": "تحميل المحتوى الأصلي",
|
||||
"page.keyboard_shortcuts.go_to_bottom_item": "الذهاب إلى آخر عنصر",
|
||||
"page.keyboard_shortcuts.go_to_categories": "الذهاب إلى الفئات",
|
||||
"page.keyboard_shortcuts.go_to_feed": "الذهاب إلى المصدر",
|
||||
"page.keyboard_shortcuts.go_to_feeds": "الذهاب إلى المصادر",
|
||||
"page.keyboard_shortcuts.go_to_history": "الذهاب إلى السجل",
|
||||
"page.keyboard_shortcuts.go_to_next_item": "الذهاب إلى العنصر التالي",
|
||||
"page.keyboard_shortcuts.go_to_next_page": "الذهاب إلى الصفحة التالية",
|
||||
"page.keyboard_shortcuts.go_to_previous_item": "الذهاب إلى العنصر السابق",
|
||||
"page.keyboard_shortcuts.go_to_previous_page": "الذهاب إلى الصفحة السابقة",
|
||||
"page.keyboard_shortcuts.go_to_search": "التركيز على نموذج البحث",
|
||||
"page.keyboard_shortcuts.go_to_settings": "الذهاب إلى الإعدادات",
|
||||
"page.keyboard_shortcuts.go_to_starred": "الذهاب إلى المفضلة",
|
||||
"page.keyboard_shortcuts.go_to_top_item": "الذهاب إلى أعلى عنصر",
|
||||
"page.keyboard_shortcuts.go_to_unread": "الذهاب إلى غير المقروءة",
|
||||
"page.keyboard_shortcuts.mark_page_as_read": "تحديد الصفحة الحالية كمقروءة",
|
||||
"page.keyboard_shortcuts.open_comments": "فتح رابط التعليقات",
|
||||
"page.keyboard_shortcuts.open_comments_same_window": "فتح رابط التعليقات في التبويب الحالي",
|
||||
"page.keyboard_shortcuts.open_item": "فتح العنصر المحدد",
|
||||
"page.keyboard_shortcuts.open_original": "فتح الرابط الأصلي",
|
||||
"page.keyboard_shortcuts.open_original_same_window": "فتح الرابط الأصلي في التبويب الحالي",
|
||||
"page.keyboard_shortcuts.refresh_all_feeds": "تحديث جميع المصادر في الخلفية",
|
||||
"page.keyboard_shortcuts.remove_feed": "حذف هذا المصدر",
|
||||
"page.keyboard_shortcuts.save_article": "حفظ المقال",
|
||||
"page.keyboard_shortcuts.scroll_item_to_top": "تمرير العنصر إلى الأعلى",
|
||||
"page.keyboard_shortcuts.show_keyboard_shortcuts": "إظهار اختصارات لوحة المفاتيح",
|
||||
"page.keyboard_shortcuts.subtitle.actions": "الإجراءات",
|
||||
"page.keyboard_shortcuts.subtitle.items": "التنقل بين العناصر",
|
||||
"page.keyboard_shortcuts.subtitle.pages": "التنقل بين الصفحات",
|
||||
"page.keyboard_shortcuts.subtitle.sections": "التنقل بين الأقسام",
|
||||
"page.keyboard_shortcuts.title": "اختصارات لوحة المفاتيح",
|
||||
"page.keyboard_shortcuts.toggle_star_status": "تبديل المفضلة",
|
||||
"page.keyboard_shortcuts.toggle_entry_attachments": "تبديل فتح/إغلاق مرفقات المقال",
|
||||
"page.keyboard_shortcuts.toggle_read_status_next": "تبديل مقروء/غير مقروء، التركيز على التالي",
|
||||
"page.keyboard_shortcuts.toggle_read_status_prev": "تبديل مقروء/غير مقروء، التركيز على السابق",
|
||||
"page.login.google_signin": "تسجيل الدخول باستخدام Google",
|
||||
"page.login.oidc_signin": "تسجيل الدخول باستخدام %s",
|
||||
"page.login.title": "تسجيل الدخول",
|
||||
"page.login.webauthn_login": "تسجيل الدخول عبر مفتاح مرور (Passkey)",
|
||||
"page.login.webauthn_login.error": "تعذر تسجيل الدخول باستخدام مفتاح المرور",
|
||||
"page.login.webauthn_login.help": "يرجى إدخال اسم المستخدم إذا كنت تستخدم مفتاح أمان. هذا غير مطلوب إذا كنت تستخدم مفتاح مرور (بيانات اعتماد قابلة للاكتشاف).",
|
||||
"page.new_api_key.title": "مفتاح API جديد",
|
||||
"page.new_category.title": "فئة جديدة",
|
||||
"page.new_user.title": "مستخدم جديد",
|
||||
"page.offline.message": "أنت غير متصل بالإنترنت",
|
||||
"page.offline.refresh_page": "حاول تحديث الصفحة",
|
||||
"page.offline.title": "وضع عدم الاتصال",
|
||||
"page.read_entry_count": [
|
||||
"%d مقال مقروء",
|
||||
"مقال واحد مقروء",
|
||||
"مقالان مقروءان",
|
||||
"%d مقالات مقروءة",
|
||||
"%d مقالاً مقروءاً",
|
||||
"%d مقالاً مقروءاً"
|
||||
],
|
||||
"page.search.title": "نتائج البحث",
|
||||
"page.sessions.table.actions": "الإجراءات",
|
||||
"page.sessions.table.current_session": "الجلسة الحالية",
|
||||
"page.sessions.table.date": "التاريخ",
|
||||
"page.sessions.table.ip": "عنوان IP",
|
||||
"page.sessions.table.user_agent": "وكيل المستخدم (User Agent)",
|
||||
"page.sessions.title": "الجلسات",
|
||||
"page.settings.link_google_account": "ربط حسابي في Google",
|
||||
"page.settings.link_oidc_account": "ربط حسابي في %s",
|
||||
"page.settings.title": "الإعدادات",
|
||||
"page.settings.unlink_google_account": "فك ارتباط حسابي في Google",
|
||||
"page.settings.unlink_oidc_account": "فك ارتباط حسابي في %s",
|
||||
"page.settings.webauthn.actions": "الإجراءات",
|
||||
"page.settings.webauthn.added_on": "أضيف في",
|
||||
"page.settings.webauthn.delete": [
|
||||
"إزالة %d مفتاح مرور",
|
||||
"إزالة مفتاح مرور واحد",
|
||||
"إزالة مفتاحي مرور",
|
||||
"إزالة %d مفاتيح مرور",
|
||||
"إزالة %d مفتاح مرور",
|
||||
"إزالة %d مفتاح مرور"
|
||||
],
|
||||
"page.settings.webauthn.last_seen_on": "آخر استخدام",
|
||||
"page.settings.webauthn.passkey_name": "اسم مفتاح المرور",
|
||||
"page.settings.webauthn.passkeys": "مفاتيح المرور",
|
||||
"page.settings.webauthn.register": "تسجيل مفتاح مرور",
|
||||
"page.settings.webauthn.register.error": "تعذر تسجيل مفتاح المرور",
|
||||
"page.shared_entries.title": "المقالات المشاركة",
|
||||
"page.shared_entries_count": [
|
||||
"%d مقال مشترك",
|
||||
"مقال واحد مشترك",
|
||||
"مقالان مشتركان",
|
||||
"%d مقالات مشتركة",
|
||||
"%d مقالاً مشتركاً",
|
||||
"%d مقالاً مشتركاً"
|
||||
],
|
||||
"page.starred.title": "المفضلة",
|
||||
"page.starred_entry_count": [
|
||||
"%d مقال مفضل",
|
||||
"مقال واحد مفضل",
|
||||
"مقالان مفضلان",
|
||||
"%d مقالات مفضلة",
|
||||
"%d مقالاً مفضلاً",
|
||||
"%d مقالاً مفضلاً"
|
||||
],
|
||||
"page.total_entry_count": [
|
||||
"%d مقال في الإجمالي",
|
||||
"مقال واحد في الإجمالي",
|
||||
"مقالان في الإجمالي",
|
||||
"%d مقالات في الإجمالي",
|
||||
"%d مقالاً في الإجمالي",
|
||||
"%d مقالاً في الإجمالي"
|
||||
],
|
||||
"page.unread.title": "غير المقروءة",
|
||||
"page.unread_entry_count": [
|
||||
"%d مقال غير مقروء",
|
||||
"مقال واحد غير مقروء",
|
||||
"مقالان غير مقروءين",
|
||||
"%d مقالات غير مقروءة",
|
||||
"%d مقالاً غير مقروء",
|
||||
"%d مقالاً غير مقروء"
|
||||
],
|
||||
"page.users.actions": "الإجراءات",
|
||||
"page.users.admin.no": "لا",
|
||||
"page.users.admin.yes": "نعم",
|
||||
"page.users.is_admin": "مدير",
|
||||
"page.users.last_login": "آخر دخول",
|
||||
"page.users.never_logged": "أبداً",
|
||||
"page.users.title": "المستخدمون",
|
||||
"page.users.username": "اسم المستخدم",
|
||||
"page.webauthn_rename.title": "إعادة تسمية مفتاح المرور",
|
||||
"pagination.first": "الأول",
|
||||
"pagination.last": "الأخير",
|
||||
"pagination.next": "التالي",
|
||||
"pagination.previous": "السابق",
|
||||
"search.label": "بحث",
|
||||
"search.placeholder": "بحث...",
|
||||
"search.submit": "بحث",
|
||||
"skip_to_content": "تخطي إلى المحتوى",
|
||||
"time_elapsed.days": [
|
||||
"منذ %d يوم",
|
||||
"منذ يوم واحد",
|
||||
"منذ يومين",
|
||||
"منذ %d أيام",
|
||||
"منذ %d يوماً",
|
||||
"منذ %d يوماً"
|
||||
],
|
||||
"time_elapsed.hours": [
|
||||
"منذ %d ساعة",
|
||||
"منذ ساعة واحدة",
|
||||
"منذ ساعتين",
|
||||
"منذ %d ساعات",
|
||||
"منذ %d ساعة",
|
||||
"منذ %d ساعة"
|
||||
],
|
||||
"time_elapsed.minutes": [
|
||||
"منذ %d دقيقة",
|
||||
"منذ دقيقة واحدة",
|
||||
"منذ دقيقتين",
|
||||
"منذ %d دقائق",
|
||||
"منذ %d دقيقة",
|
||||
"منذ %d دقيقة"
|
||||
],
|
||||
"time_elapsed.months": [
|
||||
"منذ %d شهر",
|
||||
"منذ شهر واحد",
|
||||
"منذ شهرين",
|
||||
"منذ %d أشهر",
|
||||
"منذ %d شهراً",
|
||||
"منذ %d شهراً"
|
||||
],
|
||||
"time_elapsed.not_yet": "ليس بعد",
|
||||
"time_elapsed.now": "الآن",
|
||||
"time_elapsed.weeks": [
|
||||
"منذ %d أسبوع",
|
||||
"منذ أسبوع واحد",
|
||||
"منذ أسبوعين",
|
||||
"منذ %d أسابيع",
|
||||
"منذ %d أسبوعاً",
|
||||
"منذ %d أسبوعاً"
|
||||
],
|
||||
"time_elapsed.years": [
|
||||
"منذ %d سنة",
|
||||
"منذ سنة واحدة",
|
||||
"منذ سنتين",
|
||||
"منذ %d سنوات",
|
||||
"منذ %d سنة",
|
||||
"منذ %d سنة"
|
||||
],
|
||||
"time_elapsed.yesterday": "أمس",
|
||||
"tooltip.keyboard_shortcuts": "اختصار لوحة المفاتيح: %s",
|
||||
"tooltip.logged_user": "تم تسجيل الدخول باسم %s"
|
||||
}
|
||||
@@ -175,6 +175,7 @@
|
||||
"form.feed.label.category": "Kategorie",
|
||||
"form.feed.label.cookie": "Cookies setzen",
|
||||
"form.feed.label.crawler": "Originalinhalt herunterladen",
|
||||
"form.feed.label.ignore_entry_updates": "Ignore entry updates",
|
||||
"form.feed.label.description": "Beschreibung",
|
||||
"form.feed.label.disable_http2": "HTTP/2 deaktivieren, um Fingerprinting zu verhindern",
|
||||
"form.feed.label.disabled": "Dieses Abonnement nicht aktualisieren",
|
||||
@@ -266,6 +267,7 @@
|
||||
"form.integration.linkwarden_activate": "Artikel in Linkwarden speichern",
|
||||
"form.integration.linkwarden_api_key": "Linkwarden-API-Schlüssel",
|
||||
"form.integration.linkwarden_endpoint": "Linkwarden-Base-URL",
|
||||
"form.integration.linkwarden_collection_id": "Linkwarden-Sammlungs-ID",
|
||||
"form.integration.matrix_bot_activate": "Neue Artikel in Matrix übertragen",
|
||||
"form.integration.matrix_bot_chat_id": "ID des Matrix-Raums",
|
||||
"form.integration.matrix_bot_password": "Passwort für Matrix-Benutzer",
|
||||
@@ -306,6 +308,7 @@
|
||||
"form.integration.readeck_endpoint": "Readeck-URL",
|
||||
"form.integration.readeck_labels": "Readeck-Labels",
|
||||
"form.integration.readeck_only_url": "Nur URL senden (anstelle des vollständigen Inhalts)",
|
||||
"form.integration.readeck_push_activate": "Neue Artikel automatisch in Readeck speichern",
|
||||
"form.integration.readwise_activate": "Artikel in Readwise Reader speichern",
|
||||
"form.integration.readwise_api_key": "Readwise-Reader-Zugangstoken",
|
||||
"form.integration.readwise_api_key_link": "Erhalten Sie Ihren Readwise-Zugangstoken",
|
||||
@@ -370,10 +373,10 @@
|
||||
"form.prefs.label.theme": "Thema",
|
||||
"form.prefs.label.timezone": "Zeitzone",
|
||||
"form.prefs.select.alphabetical": "Alphabetisch",
|
||||
"form.prefs.select.browser": "Browser",
|
||||
"form.prefs.select.browser": "Systembrowser",
|
||||
"form.prefs.select.created_time": "Artikel erstellt am",
|
||||
"form.prefs.select.fullscreen": "Vollbildschirm",
|
||||
"form.prefs.select.minimal_ui": "Minimal",
|
||||
"form.prefs.select.minimal_ui": "Minimale Oberfläche",
|
||||
"form.prefs.select.none": "Keine",
|
||||
"form.prefs.select.older_first": "Ältere Artikel zuerst",
|
||||
"form.prefs.select.publish_time": "Artikel veröffentlicht am",
|
||||
|
||||
@@ -175,6 +175,7 @@
|
||||
"form.feed.label.category": "Κατηγορία",
|
||||
"form.feed.label.cookie": "Ορισμός Cookies",
|
||||
"form.feed.label.crawler": "Λήψη αρχικού περιεχομένου",
|
||||
"form.feed.label.ignore_entry_updates": "Ignore entry updates",
|
||||
"form.feed.label.description": "Περιγραφή",
|
||||
"form.feed.label.disable_http2": "Απενεργοποίηση HTTP/2 για αποφυγή δακτυλικών αποτυπωμάτων",
|
||||
"form.feed.label.disabled": "Μη ανανέωση αυτής της ροής",
|
||||
@@ -254,9 +255,9 @@
|
||||
"form.integration.linkding_endpoint": "Τελικό σημείο Linkding API",
|
||||
"form.integration.linkding_tags": "Ετικέτες Linkding",
|
||||
"form.integration.linktaco_activate": "Αποθήκευση καταχωρήσεων στο LinkTaco",
|
||||
"form.integration.linktaco_api_token": "LinkTaco API Token",
|
||||
"form.integration.linktaco_api_token": "Διακριτικό API LinkTaco",
|
||||
"form.integration.linktaco_api_token_hint": "Λάβετε το προσωπικό σας διακριτικό πρόσβασης στο",
|
||||
"form.integration.linktaco_org_slug": "Organization Slug",
|
||||
"form.integration.linktaco_org_slug": "Σύντομο όνομα οργανισμού",
|
||||
"form.integration.linktaco_tags": "Ετικέτες (μέγιστο 10, διαχωρισμένες με κόμμα)",
|
||||
"form.integration.linktaco_tags_hint": "Μέγιστο 10 ετικέτες, διαχωρισμένες με κόμμα",
|
||||
"form.integration.linktaco_visibility": "Ορατότητα",
|
||||
@@ -266,6 +267,7 @@
|
||||
"form.integration.linkwarden_activate": "Αποθήκευση άρθρων στο Linkwarden",
|
||||
"form.integration.linkwarden_api_key": "Κλειδί API Linkwarden",
|
||||
"form.integration.linkwarden_endpoint": "URL βάσης Linkwarden",
|
||||
"form.integration.linkwarden_collection_id": "ID συλλογής Linkwarden",
|
||||
"form.integration.matrix_bot_activate": "Μεταφορά νέων άρθρων στο Matrix",
|
||||
"form.integration.matrix_bot_chat_id": "Αναγνωριστικό της αίθουσας Matrix",
|
||||
"form.integration.matrix_bot_password": "Κωδικός πρόσβασης για τον χρήστη Matrix",
|
||||
@@ -306,6 +308,7 @@
|
||||
"form.integration.readeck_endpoint": "Τελικό σημείο Readeck API",
|
||||
"form.integration.readeck_labels": "Ετικέτες Readeck",
|
||||
"form.integration.readeck_only_url": "Αποστολή μόνο URL (αντί για πλήρες περιεχόμενο)",
|
||||
"form.integration.readeck_push_activate": "Αυτόματη αποστολή νέων καταχωρήσεων στο Readeck",
|
||||
"form.integration.readwise_activate": "Αποθήκευση καταχωρήσεων στο Readwise Reader",
|
||||
"form.integration.readwise_api_key": "Διακριτικό πρόσβασης Readwise Reader",
|
||||
"form.integration.readwise_api_key_link": "Λήψη του διακριτικού πρόσβασης Readwise",
|
||||
@@ -335,7 +338,7 @@
|
||||
"form.integration.wallabag_only_url": "Αποστολή μόνο URL (αντί για πλήρες περιεχόμενο)",
|
||||
"form.integration.wallabag_password": "Wallabag Κωδικός Πρόσβασης",
|
||||
"form.integration.wallabag_username": "Όνομα Χρήστη Wallabag",
|
||||
"form.integration.wallabag_tags": "Wallabag Tags",
|
||||
"form.integration.wallabag_tags": "Ετικέτες Wallabag",
|
||||
"form.integration.webhook_activate": "Ενεργοποίηση Webhooks",
|
||||
"form.integration.webhook_secret": "Μυστικό Webhooks",
|
||||
"form.integration.webhook_url": "Προεπιλεγμένη διεύθυνση URL Webhook",
|
||||
@@ -434,7 +437,7 @@
|
||||
"page.about.title": "Περί",
|
||||
"page.about.version": "Έκδοση:",
|
||||
"page.add_feed.choose_feed": "Επιλέξτε μια συνδρομή",
|
||||
"page.add_feed.label.url": "URL",
|
||||
"page.add_feed.label.url": "Διεύθυνση URL",
|
||||
"page.add_feed.legend.advanced_options": "Προχωρημένες Επιλογές",
|
||||
"page.add_feed.no_category": "Δεν υπάρχει κατηγορία. Πρέπει να έχετε τουλάχιστον μία κατηγορία.",
|
||||
"page.add_feed.submit": "Βρείτε μια συνδρομή",
|
||||
@@ -444,7 +447,7 @@
|
||||
"page.api_keys.table.created_at": "Ημερομηνία Δημιουργίας",
|
||||
"page.api_keys.table.description": "Περιγραφή",
|
||||
"page.api_keys.table.last_used_at": "Τελευταία Χρήση",
|
||||
"page.api_keys.table.token": "Token",
|
||||
"page.api_keys.table.token": "Διακριτικό",
|
||||
"page.api_keys.title": "Κλειδιά API",
|
||||
"page.categories.entries": "Άρθρα",
|
||||
"page.categories.feed_count": [
|
||||
@@ -476,14 +479,14 @@
|
||||
"page.feeds.next_check": "Επόμενος έλεγχος:",
|
||||
"page.feeds.read_counter": "Αριθμός αναγνωσμένων καταχωρήσεων",
|
||||
"page.feeds.title": "Ροές",
|
||||
"page.footer.elevator": "Back to top",
|
||||
"page.footer.elevator": "Επιστροφή στην κορυφή",
|
||||
"page.history.title": "Ιστορικό",
|
||||
"page.import.title": "Εισαγωγή",
|
||||
"page.integration.bookmarklet": "Bookmarklet",
|
||||
"page.integration.bookmarklet": "Σελιδοδείκτης (bookmarklet)",
|
||||
"page.integration.bookmarklet.help": "Αυτός ο ειδικός σύνδεσμος σάς επιτρέπει να εγγραφείτε απευθείας σε έναν ιστότοπο χρησιμοποιώντας ένα σελιδοδείκτη στο πρόγραμμα περιήγησης ιστού σας.",
|
||||
"page.integration.bookmarklet.instructions": "Σύρετε και αποθέστε αυτόν τον σύνδεσμο στους σελιδοδείκτες σας.",
|
||||
"page.integration.bookmarklet.name": "Προσθήκη στο Miniflux",
|
||||
"page.integration.miniflux_api": "Miniflux API",
|
||||
"page.integration.miniflux_api": "API του Miniflux",
|
||||
"page.integration.miniflux_api_endpoint": "Τελικό σημείο API",
|
||||
"page.integration.miniflux_api_password": "Κωδικός",
|
||||
"page.integration.miniflux_api_password_value": "Ο κωδικός πρόσβασης του λογαριασμού σας",
|
||||
@@ -546,7 +549,7 @@
|
||||
"page.sessions.table.current_session": "Τρέχουσα Συνεδρία",
|
||||
"page.sessions.table.date": "Ημερομηνία",
|
||||
"page.sessions.table.ip": "Διεύθυνση IP",
|
||||
"page.sessions.table.user_agent": "User Agent",
|
||||
"page.sessions.table.user_agent": "Πρόγραμμα περιήγησης (User Agent)",
|
||||
"page.sessions.title": "Συνεδρίες",
|
||||
"page.settings.link_google_account": "Σύνδεση του λογαριασμό μου Google",
|
||||
"page.settings.link_oidc_account": "Σύνδεση του λογαριασμού μου %s",
|
||||
@@ -629,4 +632,4 @@
|
||||
"time_elapsed.yesterday": "χθες",
|
||||
"tooltip.keyboard_shortcuts": "Συντόμευση πληκτρολογίου: % s",
|
||||
"tooltip.logged_user": "Συνδεδεμένος/η ως %s"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -175,6 +175,7 @@
|
||||
"form.feed.label.category": "Category",
|
||||
"form.feed.label.cookie": "Set Cookies",
|
||||
"form.feed.label.crawler": "Fetch original content",
|
||||
"form.feed.label.ignore_entry_updates": "Ignore entry updates",
|
||||
"form.feed.label.description": "Description",
|
||||
"form.feed.label.disable_http2": "Disable HTTP/2 to avoid fingerprinting",
|
||||
"form.feed.label.disabled": "Do not refresh this feed",
|
||||
@@ -266,6 +267,7 @@
|
||||
"form.integration.linkwarden_activate": "Save entries to Linkwarden",
|
||||
"form.integration.linkwarden_api_key": "Linkwarden API key",
|
||||
"form.integration.linkwarden_endpoint": "Linkwarden Base URL",
|
||||
"form.integration.linkwarden_collection_id": "Linkwarden Collection ID",
|
||||
"form.integration.matrix_bot_activate": "Push new entries to Matrix",
|
||||
"form.integration.matrix_bot_chat_id": "ID of Matrix Room",
|
||||
"form.integration.matrix_bot_password": "Password for Matrix user",
|
||||
@@ -306,6 +308,7 @@
|
||||
"form.integration.readeck_endpoint": "Readeck URL",
|
||||
"form.integration.readeck_labels": "Readeck Labels",
|
||||
"form.integration.readeck_only_url": "Send only URL (instead of full content)",
|
||||
"form.integration.readeck_push_activate": "Automatically push new entries to Readeck",
|
||||
"form.integration.readwise_activate": "Save entries to Readwise Reader",
|
||||
"form.integration.readwise_api_key": "Readwise Reader Access Token",
|
||||
"form.integration.readwise_api_key_link": "Get your Readwise Access Token",
|
||||
@@ -629,4 +632,4 @@
|
||||
"time_elapsed.yesterday": "yesterday",
|
||||
"tooltip.keyboard_shortcuts": "Keyboard Shortcut: %s",
|
||||
"tooltip.logged_user": "Logged in as %s"
|
||||
}
|
||||
}
|
||||
@@ -164,7 +164,7 @@
|
||||
"form.api_key.label.description": "Etiqueta de clave API",
|
||||
"form.category.hide_globally": "Ocultar artículos en la lista global de no leídos",
|
||||
"form.category.label.title": "Título",
|
||||
"form.feed.fieldset.general": "General",
|
||||
"form.feed.fieldset.general": "Generalidades",
|
||||
"form.feed.fieldset.integration": "Servicios de terceros",
|
||||
"form.feed.fieldset.network_settings": "Ajustes de red",
|
||||
"form.feed.fieldset.rules": "Reglas",
|
||||
@@ -175,6 +175,7 @@
|
||||
"form.feed.label.category": "Categoría",
|
||||
"form.feed.label.cookie": "Configurar las cookies",
|
||||
"form.feed.label.crawler": "Obtener rastreador original",
|
||||
"form.feed.label.ignore_entry_updates": "Ignore entry updates",
|
||||
"form.feed.label.description": "Descripción",
|
||||
"form.feed.label.disable_http2": "Deshabilite HTTP/2 para evitar huellas digitales",
|
||||
"form.feed.label.disabled": "No actualice este feed",
|
||||
@@ -245,7 +246,7 @@
|
||||
"form.integration.linkace_activate": "Guardar artículos en LinkAce",
|
||||
"form.integration.linkace_api_key": "Clave API de LinkAce",
|
||||
"form.integration.linkace_check_disabled": "Deshabilitar la comprobación de enlace",
|
||||
"form.integration.linkace_endpoint": "LinkAce API Endpoint",
|
||||
"form.integration.linkace_endpoint": "Extremo de la API de LinkAce",
|
||||
"form.integration.linkace_is_private": "Marcar enlace como privado",
|
||||
"form.integration.linkace_tags": "Etiquetas de LinkAce",
|
||||
"form.integration.linkding_activate": "Enviar artículos a Linkding",
|
||||
@@ -254,9 +255,9 @@
|
||||
"form.integration.linkding_endpoint": "Acceso API de Linkding",
|
||||
"form.integration.linkding_tags": "Etiquetas de Linkding",
|
||||
"form.integration.linktaco_activate": "Guardar entradas en LinkTaco",
|
||||
"form.integration.linktaco_api_token": "LinkTaco API Token",
|
||||
"form.integration.linktaco_api_token": "Token de la API de LinkTaco",
|
||||
"form.integration.linktaco_api_token_hint": "Obtenga su token de acceso personal en",
|
||||
"form.integration.linktaco_org_slug": "Organization Slug",
|
||||
"form.integration.linktaco_org_slug": "Slug de la organización",
|
||||
"form.integration.linktaco_tags": "Etiquetas (máx. 10, separadas por comas)",
|
||||
"form.integration.linktaco_tags_hint": "Máximo 10 etiquetas, separadas por comas",
|
||||
"form.integration.linktaco_visibility": "Visibilidad",
|
||||
@@ -266,6 +267,7 @@
|
||||
"form.integration.linkwarden_activate": "Enviar artículos a Linkwarden",
|
||||
"form.integration.linkwarden_api_key": "Clave de API de Linkwarden",
|
||||
"form.integration.linkwarden_endpoint": "URL base de Linkwarden",
|
||||
"form.integration.linkwarden_collection_id": "ID de colección de Linkwarden",
|
||||
"form.integration.matrix_bot_activate": "Transferir nuevos artículos a Matrix",
|
||||
"form.integration.matrix_bot_chat_id": "ID de la sala de Matrix",
|
||||
"form.integration.matrix_bot_password": "Contraseña para el usuario de Matrix",
|
||||
@@ -277,7 +279,7 @@
|
||||
"form.integration.ntfy_activate": "Enviar artículos a ntfy",
|
||||
"form.integration.ntfy_api_token": "Token de API de Ntfy (opcional)",
|
||||
"form.integration.ntfy_icon_url": "URL del icono de Ntfy (opcional)",
|
||||
"form.integration.ntfy_internal_links": "Use internal links on click (optional)",
|
||||
"form.integration.ntfy_internal_links": "Usar enlaces internos al hacer clic (opcional)",
|
||||
"form.integration.ntfy_password": "Contraseña de Ntfy (opcional)",
|
||||
"form.integration.ntfy_topic": "Tema Ntfy (por defecto, si no se establece en el feed)",
|
||||
"form.integration.ntfy_url": "URL de Ntfy (opcional, la predeterminada es ntfy.sh)",
|
||||
@@ -300,17 +302,18 @@
|
||||
"form.integration.raindrop_activate": "Guardar artículos en Raindrop",
|
||||
"form.integration.raindrop_collection_id": "Colección ID",
|
||||
"form.integration.raindrop_tags": "Etiquetas (separadas por comas)",
|
||||
"form.integration.raindrop_token": "(Test) Token",
|
||||
"form.integration.raindrop_token": "Token (prueba)",
|
||||
"form.integration.readeck_activate": "Enviar artículos a Readeck",
|
||||
"form.integration.readeck_api_key": "Clave de API de Readeck",
|
||||
"form.integration.readeck_endpoint": "Acceso API de Readeck",
|
||||
"form.integration.readeck_labels": "Etiquetas de Readeck",
|
||||
"form.integration.readeck_only_url": "Enviar solo URL (en lugar de contenido completo)",
|
||||
"form.integration.readeck_push_activate": "Enviar automáticamente nuevas entradas a Readeck",
|
||||
"form.integration.readwise_activate": "Guardar artículos en Readwise Reader",
|
||||
"form.integration.readwise_api_key": "Token de acceso a Readwise Reader",
|
||||
"form.integration.readwise_api_key_link": "Obtener tu token de acceso a Readwise",
|
||||
"form.integration.rssbridge_activate": "Vericar RSS-Bridge al agregar suscripciones",
|
||||
"form.integration.rssbridge_token": "RSS-Bridge authentication token",
|
||||
"form.integration.rssbridge_token": "Token de autenticación de RSS-Bridge",
|
||||
"form.integration.rssbridge_url": "URL del servidro RSS-Bridge",
|
||||
"form.integration.shaarli_activate": "Guardar artículos en Shaarli",
|
||||
"form.integration.shaarli_api_secret": "Secreto API de Shaarli",
|
||||
@@ -327,7 +330,7 @@
|
||||
"form.integration.telegram_bot_disable_web_page_preview": "Deshabilitar la vista previa de la página web",
|
||||
"form.integration.telegram_bot_token": "Token de bot",
|
||||
"form.integration.telegram_chat_id": "ID de chat",
|
||||
"form.integration.telegram_topic_id": "Topic ID",
|
||||
"form.integration.telegram_topic_id": "ID de tema",
|
||||
"form.integration.wallabag_activate": "Enviar artículos a Wallabag",
|
||||
"form.integration.wallabag_client_id": "ID de cliente de Wallabag",
|
||||
"form.integration.wallabag_client_secret": "Secreto de cliente de Wallabag",
|
||||
@@ -335,7 +338,7 @@
|
||||
"form.integration.wallabag_only_url": "Enviar solo URL (en lugar de contenido completo)",
|
||||
"form.integration.wallabag_password": "Contraseña de Wallabag",
|
||||
"form.integration.wallabag_username": "Nombre de usuario de Wallabag",
|
||||
"form.integration.wallabag_tags": "Wallabag Tags",
|
||||
"form.integration.wallabag_tags": "Etiquetas de Wallabag",
|
||||
"form.integration.webhook_activate": "Habilitar Webhooks",
|
||||
"form.integration.webhook_secret": "Secreto de Webhooks",
|
||||
"form.integration.webhook_url": "Defecto URL de Webhook",
|
||||
@@ -426,7 +429,7 @@
|
||||
"page.about.build_date": "Fecha de compilación:",
|
||||
"page.about.credits": "Créditos",
|
||||
"page.about.db_usage": "Tamaño de la base de datos:",
|
||||
"page.about.git_commit": "Git Commit:",
|
||||
"page.about.git_commit": "Commit de Git:",
|
||||
"page.about.global_config_options": "Opciones de configuración global",
|
||||
"page.about.go_version": "Go versión:",
|
||||
"page.about.license": "Licencia:",
|
||||
@@ -476,7 +479,7 @@
|
||||
"page.feeds.next_check": "Próxima verificación:",
|
||||
"page.feeds.read_counter": "Número de artículos leídos",
|
||||
"page.feeds.title": "Fuentes",
|
||||
"page.footer.elevator": "Back to top",
|
||||
"page.footer.elevator": "Volver arriba",
|
||||
"page.history.title": "Historial",
|
||||
"page.import.title": "Importar",
|
||||
"page.integration.bookmarklet": "Marcapáginas",
|
||||
@@ -629,4 +632,4 @@
|
||||
"time_elapsed.yesterday": "ayer",
|
||||
"tooltip.keyboard_shortcuts": "Atajo de teclado: %s",
|
||||
"tooltip.logged_user": "Registrado como %s"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,56 +104,56 @@
|
||||
"error.feed_url_not_empty": "Syötteen URL-osoite ei voi olla tyhjä.",
|
||||
"error.fields_mandatory": "Kaikki kentät ovat pakollisia.",
|
||||
"error.http_bad_gateway": "Verkkosivusto ei ole tällä hetkellä saatavilla huonon yhdyskäytävän virheen vuoksi. Ongelma ei ole Miniflux-puolella. Yritä uudelleen myöhemmin.",
|
||||
"error.http_body_read": "Unable to read the HTTP body: %v.",
|
||||
"error.http_client_error": "HTTP client error: %v.",
|
||||
"error.http_empty_response": "The HTTP response is empty. Perhaps, this website is using a bot protection mechanism?",
|
||||
"error.http_empty_response_body": "The HTTP response body is empty.",
|
||||
"error.http_forbidden": "Access to this website is forbidden. Perhaps, this website has a bot protection mechanism?",
|
||||
"error.http_gateway_timeout": "The website is not available at the moment due to a gateway timeout error. The problem is not on Miniflux side. Please, try again later.",
|
||||
"error.http_internal_server_error": "The website is not available at the moment due to a server error. The problem is not on Miniflux side. Please, try again later.",
|
||||
"error.http_not_authorized": "Access to this website is not authorized. It could be a bad username or password.",
|
||||
"error.http_resource_not_found": "The requested resource is not found. Please, verify the URL.",
|
||||
"error.http_response_too_large": "The HTTP response is too large. You could increase the HTTP response size limit in the global settings (requires a server restart).",
|
||||
"error.http_service_unavailable": "The website is not available at the moment due to an internal server error. The problem is not on Miniflux side. Please, try again later.",
|
||||
"error.http_too_many_requests": "Miniflux generated too many requests to this website. Please, try again later or change the application configuration.",
|
||||
"error.http_unexpected_status_code": "The website is not available at the moment due to an unexpected HTTP status code: %d. The problem is not on Miniflux side. Please, try again later.",
|
||||
"error.http_body_read": "HTTP-rungon lukeminen epäonnistui: %v.",
|
||||
"error.http_client_error": "HTTP-asiakasvirhe: %v.",
|
||||
"error.http_empty_response": "HTTP-vastaus on tyhjä. Sivusto saattaa käyttää bottisuojausta?",
|
||||
"error.http_empty_response_body": "HTTP-vastauksen runko on tyhjä.",
|
||||
"error.http_forbidden": "Pääsy tälle sivustolle on kielletty. Sivustolla saattaa olla bottisuojaus?",
|
||||
"error.http_gateway_timeout": "Sivusto ei ole nyt käytettävissä yhdyskäytävän aikakatkaisun vuoksi. Ongelma ei ole Minifluxin puolella. Yritä myöhemmin uudelleen.",
|
||||
"error.http_internal_server_error": "Sivusto ei ole nyt käytettävissä palvelinvirheen vuoksi. Ongelma ei ole Minifluxin puolella. Yritä myöhemmin uudelleen.",
|
||||
"error.http_not_authorized": "Pääsy tälle sivustolle ei ole sallittu. Käyttäjänimi tai salasana voi olla väärä.",
|
||||
"error.http_resource_not_found": "Pyydettyä resurssia ei löytynyt. Tarkista URL-osoite.",
|
||||
"error.http_response_too_large": "HTTP-vastaus on liian suuri. Voit kasvattaa rajan yleisasetuksissa (vaatii palvelimen uudelleenkäynnistyksen).",
|
||||
"error.http_service_unavailable": "Sivusto ei ole nyt käytettävissä sisäisen palvelinvirheen vuoksi. Ongelma ei ole Minifluxin puolella. Yritä myöhemmin uudelleen.",
|
||||
"error.http_too_many_requests": "Miniflux lähetti liikaa pyyntöjä tälle sivustolle. Yritä myöhemmin uudelleen tai muuta sovelluksen asetuksia.",
|
||||
"error.http_unexpected_status_code": "Sivusto ei ole nyt käytettävissä odottamattoman HTTP-tilakoodin %d vuoksi. Ongelma ei ole Minifluxin puolella. Yritä myöhemmin uudelleen.",
|
||||
"error.invalid_categories_sorting_order": "Virheellinen kategorioiden lajittelujärjestys.",
|
||||
"error.invalid_default_home_page": "Väärä oletusarvoinen kotisivu!",
|
||||
"error.invalid_display_mode": "Virheellinen verkkosovelluksen näyttötila.",
|
||||
"error.invalid_entry_direction": "Invalid entry direction.",
|
||||
"error.invalid_entry_direction": "Virheellinen merkintäsuunta.",
|
||||
"error.invalid_entry_order": "Virheellinen artikkelin lajittelu.",
|
||||
"error.invalid_feed_proxy_url": "Invalid proxy URL.",
|
||||
"error.invalid_feed_proxy_url": "Virheellinen välityspalvelimen URL.",
|
||||
"error.invalid_feed_url": "Virheellinen syötteen URL-osoite.",
|
||||
"error.invalid_gesture_nav": "Virheellinen ele-navigointi.",
|
||||
"error.invalid_language": "Virheellinen kieli.",
|
||||
"error.invalid_site_url": "Virheellinen sivuston URL-osoite.",
|
||||
"error.invalid_theme": "Virheellinen teema.",
|
||||
"error.invalid_timezone": "Virheellinen aikavyöhyke.",
|
||||
"error.network_operation": "Miniflux is not able to reach this website due to a network error: %v.",
|
||||
"error.network_timeout": "This website is too slow and the request timed out: %v",
|
||||
"error.network_operation": "Miniflux ei tavoita tätä sivustoa verkkovirheen vuoksi: %v.",
|
||||
"error.network_timeout": "Tämä sivusto on liian hidas ja pyyntö aikakatkaistiin: %v",
|
||||
"error.password_min_length": "Salasanassa on oltava vähintään 6 merkkiä.",
|
||||
"error.proxy_url_not_empty": "The proxy URL cannot be empty.",
|
||||
"error.settings_block_rule_fieldname_invalid": "Invalid Block rule: rule #%d is missing a valid field name (Options: %s)",
|
||||
"error.settings_block_rule_invalid_regex": "Invalid Block rule: rule #%d's pattern is not a valid regex",
|
||||
"error.settings_block_rule_regex_required": "Invalid Block rule: rule #%d's pattern is not provided",
|
||||
"error.settings_block_rule_separator_required": "Invalid Block rule: rule #%d's pattern is required to be seperated by a '='",
|
||||
"error.settings_invalid_domain_list": "Invalid domain list. Please provide a space separated list of domains.",
|
||||
"error.settings_keep_rule_fieldname_invalid": "Invalid Keep rule: rule #%d is missing a valid field name (Options: %s)",
|
||||
"error.settings_keep_rule_invalid_regex": "Invalid Keep rule: rule #%d's pattern is not a valid regex",
|
||||
"error.settings_keep_rule_regex_required": "Invalid Keep rule: rule #%d pattern is not provided",
|
||||
"error.settings_keep_rule_separator_required": "Invalid Keep rule: rule #%d's pattern is required to be seperated by a '='",
|
||||
"error.proxy_url_not_empty": "Välityspalvelimen URL ei voi olla tyhjä.",
|
||||
"error.settings_block_rule_fieldname_invalid": "Virheellinen estosääntö: säännöltä #%d puuttuu kelvollinen kentän nimi (vaihtoehdot: %s)",
|
||||
"error.settings_block_rule_invalid_regex": "Virheellinen estosääntö: säännön #%d kuvio ei ole kelvollinen regex",
|
||||
"error.settings_block_rule_regex_required": "Virheellinen estosääntö: säännöltä #%d puuttuu kuvio",
|
||||
"error.settings_block_rule_separator_required": "Virheellinen estosääntö: säännön #%d kuvio tulee erottaa merkillä '='",
|
||||
"error.settings_invalid_domain_list": "Virheellinen verkkotunnuslista. Anna välilyönnein eroteltu luettelo.",
|
||||
"error.settings_keep_rule_fieldname_invalid": "Virheellinen säilytyssääntö: säännöltä #%d puuttuu kelvollinen kentän nimi (vaihtoehdot: %s)",
|
||||
"error.settings_keep_rule_invalid_regex": "Virheellinen säilytyssääntö: säännön #%d kuvio ei ole kelvollinen regex",
|
||||
"error.settings_keep_rule_regex_required": "Virheellinen säilytyssääntö: säännöltä #%d puuttuu kuvio",
|
||||
"error.settings_keep_rule_separator_required": "Virheellinen säilytyssääntö: säännön #%d kuvio tulee erottaa merkillä '='",
|
||||
"error.settings_mandatory_fields": "Käyttäjätunnus, teema, kieli ja aikavyöhyke ovat pakollisia.",
|
||||
"error.settings_media_playback_rate_range": "Toistonopeus on alueen ulkopuolella",
|
||||
"error.settings_reading_speed_is_positive": "Lukunopeuksien on oltava positiivisia kokonaislukuja.",
|
||||
"error.site_url_not_empty": "Sivuston URL-osoite ei voi olla tyhjä.",
|
||||
"error.subscription_not_found": "Tilausta ei löydy.",
|
||||
"error.title_required": "Otsikko on pakollinen.",
|
||||
"error.tls_error": "TLS error: %q. You could disable TLS verification in the feed settings if you would like.",
|
||||
"error.tls_error": "TLS-virhe: %q. Voit halutessasi poistaa TLS-tarkistuksen syöteasetuksista.",
|
||||
"error.unable_to_create_api_key": "API-avainta ei voi luoda.",
|
||||
"error.unable_to_create_category": "Kategoriaa ei voi luoda.",
|
||||
"error.unable_to_create_user": "Käyttäjää ei voi luoda.",
|
||||
"error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.",
|
||||
"error.unable_to_parse_feed": "Unable to parse this feed: %v.",
|
||||
"error.unable_to_detect_rssbridge": "Syötettä ei voitu havaita RSS-Bridgea käyttäen: %v.",
|
||||
"error.unable_to_parse_feed": "Tätä syötettä ei voitu jäsentää: %v.",
|
||||
"error.unable_to_update_category": "Kategoriaa ei voi päivittää.",
|
||||
"error.unable_to_update_feed": "Syötettä ei voi päivittää.",
|
||||
"error.unable_to_update_user": "Käyttäjää ei voi päivittää.",
|
||||
@@ -161,22 +161,23 @@
|
||||
"error.user_already_exists": "Käyttäjä on jo olemassa.",
|
||||
"error.user_mandatory_fields": "Käyttäjätunnus on pakollinen.",
|
||||
"error.linktaco_missing_required_fields": "LinkTaco API Token ja Organization Slug vaaditaan",
|
||||
"form.api_key.label.description": "API Key Label",
|
||||
"form.api_key.label.description": "API-avaimen nimi",
|
||||
"form.category.hide_globally": "Piilota artikkelit lukemattomien listassa",
|
||||
"form.category.label.title": "Otsikko",
|
||||
"form.feed.fieldset.general": "General",
|
||||
"form.feed.fieldset.integration": "Third-Party Services",
|
||||
"form.feed.fieldset.network_settings": "Network Settings",
|
||||
"form.feed.fieldset.rules": "Rules",
|
||||
"form.feed.fieldset.general": "Yleiset",
|
||||
"form.feed.fieldset.integration": "Kolmannen osapuolen palvelut",
|
||||
"form.feed.fieldset.network_settings": "Verkkoasetukset",
|
||||
"form.feed.fieldset.rules": "Säännöt",
|
||||
"form.feed.label.allow_self_signed_certificates": "Salli itseallekirjoitetut tai virheelliset varmenteet",
|
||||
"form.feed.label.apprise_service_urls": "Comma separated list of Apprise service URLs",
|
||||
"form.feed.label.apprise_service_urls": "Apprise-palvelujen URL-osoitteet pilkuilla eroteltuna",
|
||||
"form.feed.label.block_filter_entry_rules": "Merkinnän estosäännöt",
|
||||
"form.feed.label.blocklist_rules": "Regex-pohjaiset estosuodattimet",
|
||||
"form.feed.label.category": "Kategoria",
|
||||
"form.feed.label.cookie": "Aseta evästeet",
|
||||
"form.feed.label.crawler": "Nouda alkuperäinen sisältö",
|
||||
"form.feed.label.ignore_entry_updates": "Ignore entry updates",
|
||||
"form.feed.label.description": "Kuvaus",
|
||||
"form.feed.label.disable_http2": "Disable HTTP/2 to avoid fingerprinting",
|
||||
"form.feed.label.disable_http2": "Poista HTTP/2 käytöstä sormenjälkien välttämiseksi",
|
||||
"form.feed.label.disabled": "Älä päivitä tätä syötettä",
|
||||
"form.feed.label.feed_password": "Syötteen salasana",
|
||||
"form.feed.label.feed_url": "Syötteen URL-osoite",
|
||||
@@ -186,43 +187,43 @@
|
||||
"form.feed.label.ignore_http_cache": "Ohita HTTP-välimuisti",
|
||||
"form.feed.label.keep_filter_entry_rules": "Merkinnän sallimissäännöt",
|
||||
"form.feed.label.keeplist_rules": "Regex-pohjaiset säilytyssuodattimet",
|
||||
"form.feed.label.no_media_player": "No media player (audio/video)",
|
||||
"form.feed.label.ntfy_activate": "Push entries to ntfy",
|
||||
"form.feed.label.ntfy_default_priority": "Ntfy default priority",
|
||||
"form.feed.label.ntfy_high_priority": "Ntfy high priority",
|
||||
"form.feed.label.ntfy_low_priority": "Ntfy low priority",
|
||||
"form.feed.label.ntfy_max_priority": "Ntfy max priority",
|
||||
"form.feed.label.ntfy_min_priority": "Ntfy min priority",
|
||||
"form.feed.label.ntfy_priority": "Ntfy priority",
|
||||
"form.feed.label.ntfy_topic": "Ntfy topic (optional)",
|
||||
"form.feed.label.proxy_url": "Proxy URL",
|
||||
"form.feed.label.pushover_activate": "Push entries to pushover.net",
|
||||
"form.feed.label.pushover_default_priority": "Pushover default priority",
|
||||
"form.feed.label.pushover_high_priority": "Pushover high priority",
|
||||
"form.feed.label.pushover_low_priority": "Pushover low priority",
|
||||
"form.feed.label.pushover_max_priority": "Pushover max priority",
|
||||
"form.feed.label.pushover_min_priority": "Pushover min priority",
|
||||
"form.feed.label.pushover_priority": "Pushover message priority",
|
||||
"form.feed.label.no_media_player": "Ei mediasoitinta (ääni/video)",
|
||||
"form.feed.label.ntfy_activate": "Lähetä merkinnät ntfy-palveluun",
|
||||
"form.feed.label.ntfy_default_priority": "Ntfy-oletusprioriteetti",
|
||||
"form.feed.label.ntfy_high_priority": "Ntfy-korkea prioriteetti",
|
||||
"form.feed.label.ntfy_low_priority": "Ntfy-matala prioriteetti",
|
||||
"form.feed.label.ntfy_max_priority": "Ntfy-enimmäisprioriteetti",
|
||||
"form.feed.label.ntfy_min_priority": "Ntfy-vähimmäisprioriteetti",
|
||||
"form.feed.label.ntfy_priority": "Ntfy-prioriteetti",
|
||||
"form.feed.label.ntfy_topic": "Ntfy-aihe (valinnainen)",
|
||||
"form.feed.label.proxy_url": "Välityspalvelimen URL",
|
||||
"form.feed.label.pushover_activate": "Lähetä merkinnät pushover.net-palveluun",
|
||||
"form.feed.label.pushover_default_priority": "Pushover-oletusprioriteetti",
|
||||
"form.feed.label.pushover_high_priority": "Pushover-korkea prioriteetti",
|
||||
"form.feed.label.pushover_low_priority": "Pushover-matala prioriteetti",
|
||||
"form.feed.label.pushover_max_priority": "Pushover-enimmäisprioriteetti",
|
||||
"form.feed.label.pushover_min_priority": "Pushover-vähimmäisprioriteetti",
|
||||
"form.feed.label.pushover_priority": "Pushover-viestin prioriteetti",
|
||||
"form.feed.label.rewrite_rules": "Sisällön uudelleenkirjoitussäännöt",
|
||||
"form.feed.label.scraper_rules": "Scraper-säännöt",
|
||||
"form.feed.label.site_url": "Sivuston URL-osoite",
|
||||
"form.feed.label.title": "Otsikko",
|
||||
"form.feed.label.urlrewrite_rules": "URL-osoitteen uudelleenkirjoitussäännöt",
|
||||
"form.feed.label.user_agent": "Ohita oletuskäyttäjäagentti",
|
||||
"form.feed.label.webhook_url": "Override webhook url",
|
||||
"form.feed.label.webhook_url": "Ohita oletus-webhook-osoite",
|
||||
"form.import.label.file": "OPML-tiedosto",
|
||||
"form.import.label.url": "URL",
|
||||
"form.import.label.url": "URL-osoite",
|
||||
"form.integration.archiveorg_activate": "Työnnä merkinnät osoitteeseen archive.org",
|
||||
"form.integration.apprise_activate": "Push entries to Apprise",
|
||||
"form.integration.apprise_services_url": "Comma separated list of Apprise service URLs",
|
||||
"form.integration.apprise_url": "Apprise API URL",
|
||||
"form.integration.betula_activate": "Save entries to Betula",
|
||||
"form.integration.betula_token": "Betula Token",
|
||||
"form.integration.betula_url": "Betula server URL",
|
||||
"form.integration.cubox_activate": "Save entries to Cubox",
|
||||
"form.integration.cubox_api_link": "Cubox API link",
|
||||
"form.integration.discord_activate": "Push entries to Discord",
|
||||
"form.integration.discord_webhook_link": "Discord Webhook link",
|
||||
"form.integration.apprise_activate": "Lähetä merkinnät Appriseen",
|
||||
"form.integration.apprise_services_url": "Pilkuilla eroteltu Apprise-palvelujen URL-lista",
|
||||
"form.integration.apprise_url": "Apprise API -osoite",
|
||||
"form.integration.betula_activate": "Tallenna merkinnät Betulaan",
|
||||
"form.integration.betula_token": "Betula-tunnus",
|
||||
"form.integration.betula_url": "Betula-palvelimen URL",
|
||||
"form.integration.cubox_activate": "Tallenna merkinnät Cuboxiin",
|
||||
"form.integration.cubox_api_link": "Cubox API -linkki",
|
||||
"form.integration.discord_activate": "Lähetä merkinnät Discordiin",
|
||||
"form.integration.discord_webhook_link": "Discord-webhook-linkki",
|
||||
"form.integration.espial_activate": "Tallenna artikkelit Espialiin",
|
||||
"form.integration.espial_api_key": "Espial API-avain",
|
||||
"form.integration.espial_endpoint": "Espial API-päätepiste",
|
||||
@@ -241,47 +242,48 @@
|
||||
"form.integration.karakeep_activate": "Tallenna artikkelit Karakeepiin",
|
||||
"form.integration.karakeep_api_key": "Karakeep API-avain",
|
||||
"form.integration.karakeep_url": "Karakeep API-päätepiste",
|
||||
"form.integration.karakeep_tags": "Karakeep Tags",
|
||||
"form.integration.linkace_activate": "Save entries to LinkAce",
|
||||
"form.integration.linkace_api_key": "LinkAce API key",
|
||||
"form.integration.linkace_check_disabled": "Disable link check",
|
||||
"form.integration.linkace_endpoint": "LinkAce API Endpoint",
|
||||
"form.integration.linkace_is_private": "Mark link as private",
|
||||
"form.integration.linkace_tags": "LinkAce Tags",
|
||||
"form.integration.karakeep_tags": "Karakeep-tunnisteet",
|
||||
"form.integration.linkace_activate": "Tallenna merkinnät LinkAceen",
|
||||
"form.integration.linkace_api_key": "LinkAce API -avain",
|
||||
"form.integration.linkace_check_disabled": "Poista linkkitarkistus käytöstä",
|
||||
"form.integration.linkace_endpoint": "LinkAce API -päätepiste",
|
||||
"form.integration.linkace_is_private": "Merkitse linkki yksityiseksi",
|
||||
"form.integration.linkace_tags": "LinkAce-tunnisteet",
|
||||
"form.integration.linkding_activate": "Tallenna artikkelit Linkkiin",
|
||||
"form.integration.linkding_api_key": "Linkding API-avain",
|
||||
"form.integration.linkding_bookmark": "Merkitse kirjanmerkki lukemattomaksi",
|
||||
"form.integration.linkding_endpoint": "Linkding API-päätepiste",
|
||||
"form.integration.linkding_tags": "Linkding Tags",
|
||||
"form.integration.linkding_tags": "Linkding-tunnisteet",
|
||||
"form.integration.linktaco_activate": "Tallenna kirjoituksia LinkTacoon",
|
||||
"form.integration.linktaco_api_token": "LinkTaco API Token",
|
||||
"form.integration.linktaco_api_token_hint": "Hanki henkilökohtainen pääsytunnistesi osoitteesta",
|
||||
"form.integration.linktaco_org_slug": "Organization Slug",
|
||||
"form.integration.linktaco_tags": "Tagit (enintään 10, pilkuilla erotettu)",
|
||||
"form.integration.linktaco_tags_hint": "Enintään 10 tagia, pilkuilla erotettu",
|
||||
"form.integration.linktaco_api_token": "LinkTaco API -tunnus",
|
||||
"form.integration.linktaco_api_token_hint": "Hanki henkilökohtainen pääsytunniste osoitteesta",
|
||||
"form.integration.linktaco_org_slug": "Organisaation slug",
|
||||
"form.integration.linktaco_tags": "Tunnisteet (enintään 10, pilkuilla eroteltu)",
|
||||
"form.integration.linktaco_tags_hint": "Enintään 10 tunnistetta, pilkuilla eroteltuna",
|
||||
"form.integration.linktaco_visibility": "Näkyvyys",
|
||||
"form.integration.linktaco_visibility_public": "Julkinen",
|
||||
"form.integration.linktaco_visibility_private": "Yksityinen",
|
||||
"form.integration.linktaco_visibility_hint": "YKSITYINEN näkyvyys vaatii maksullisen LinkTaco-tilin",
|
||||
"form.integration.linkwarden_activate": "Tallenna artikkelit Linkkiin",
|
||||
"form.integration.linkwarden_api_key": "Linkwarden API-avain",
|
||||
"form.integration.linkwarden_endpoint": "Linkwarden Base URL",
|
||||
"form.integration.linktaco_visibility_hint": "Yksityinen näkyvyys vaatii maksullisen LinkTaco-tilin",
|
||||
"form.integration.linkwarden_activate": "Tallenna artikkelit Linkwardeniin",
|
||||
"form.integration.linkwarden_api_key": "Linkwarden API -avain",
|
||||
"form.integration.linkwarden_endpoint": "Linkwardenin perus-URL",
|
||||
"form.integration.linkwarden_collection_id": "Linkwarden-kokoelman tunnus",
|
||||
"form.integration.matrix_bot_activate": "Siirrä uudet artikkelit Matrixiin",
|
||||
"form.integration.matrix_bot_chat_id": "Matrix-huoneen tunnus",
|
||||
"form.integration.matrix_bot_password": "Matrix-käyttäjän salasana",
|
||||
"form.integration.matrix_bot_url": "Matrix-palvelimen URL-osoite",
|
||||
"form.integration.matrix_bot_user": "Matrixin käyttäjätunnus",
|
||||
"form.integration.notion_activate": "Save entries to Notion",
|
||||
"form.integration.notion_page_id": "Notion Page ID",
|
||||
"form.integration.notion_token": "Notion Secret Token",
|
||||
"form.integration.ntfy_activate": "Push entries to ntfy",
|
||||
"form.integration.ntfy_api_token": "Ntfy API Token (optional)",
|
||||
"form.integration.ntfy_icon_url": "Ntfy Icon URL (optional)",
|
||||
"form.integration.ntfy_internal_links": "Use internal links on click (optional)",
|
||||
"form.integration.ntfy_password": "Ntfy Password (optional)",
|
||||
"form.integration.ntfy_topic": "Ntfy topic (default used if not set in feed)",
|
||||
"form.integration.ntfy_url": "Ntfy URL (optional, default is ntfy.sh)",
|
||||
"form.integration.ntfy_username": "Ntfy Username (optional)",
|
||||
"form.integration.notion_activate": "Tallenna merkinnät Notioniin",
|
||||
"form.integration.notion_page_id": "Notion-sivun tunnus",
|
||||
"form.integration.notion_token": "Notion-salaisuustunnus",
|
||||
"form.integration.ntfy_activate": "Lähetä merkinnät ntfy-palveluun",
|
||||
"form.integration.ntfy_api_token": "Ntfy API -tunnus (valinnainen)",
|
||||
"form.integration.ntfy_icon_url": "Ntfy-kuvakkeen URL (valinnainen)",
|
||||
"form.integration.ntfy_internal_links": "Käytä sisäisiä linkkejä napsautettaessa (valinnainen)",
|
||||
"form.integration.ntfy_password": "Ntfy-salasana (valinnainen)",
|
||||
"form.integration.ntfy_topic": "Ntfy-aihe (oletus jos ei määritetty syötteessä)",
|
||||
"form.integration.ntfy_url": "Ntfy-URL (valinnainen, oletus ntfy.sh)",
|
||||
"form.integration.ntfy_username": "Ntfy-käyttäjätunnus (valinnainen)",
|
||||
"form.integration.nunux_keeper_activate": "Tallenna artikkelit Nunux Keeperiin",
|
||||
"form.integration.nunux_keeper_api_key": "Nunux Keeper API-avain",
|
||||
"form.integration.nunux_keeper_endpoint": "Nunux Keeper API-päätepiste",
|
||||
@@ -292,59 +294,60 @@
|
||||
"form.integration.pinboard_bookmark": "Merkitse kirjanmerkki lukemattomaksi",
|
||||
"form.integration.pinboard_tags": "Pinboard-tagit",
|
||||
"form.integration.pinboard_token": "Pinboard API-tunnus",
|
||||
"form.integration.pushover_activate": "Push entries to Pushover",
|
||||
"form.integration.pushover_device": "Pushover device (optional)",
|
||||
"form.integration.pushover_prefix": "Pushover URL prefix (optional)",
|
||||
"form.integration.pushover_token": "Pushover application API token",
|
||||
"form.integration.pushover_user": "Pushover user key",
|
||||
"form.integration.raindrop_activate": "Save entries to Raindrop",
|
||||
"form.integration.raindrop_collection_id": "Collection ID",
|
||||
"form.integration.raindrop_tags": "Tags (comma-separated)",
|
||||
"form.integration.raindrop_token": "(Test) Token",
|
||||
"form.integration.pushover_activate": "Lähetä merkinnät Pushoveriin",
|
||||
"form.integration.pushover_device": "Pushover-laite (valinnainen)",
|
||||
"form.integration.pushover_prefix": "Pushover-URL:n etuliite (valinnainen)",
|
||||
"form.integration.pushover_token": "Pushover-sovelluksen API-tunnus",
|
||||
"form.integration.pushover_user": "Pushover-käyttäjän avain",
|
||||
"form.integration.raindrop_activate": "Tallenna merkinnät Raindropiin",
|
||||
"form.integration.raindrop_collection_id": "Kokoelman tunnus",
|
||||
"form.integration.raindrop_tags": "Tunnisteet (pilkuilla eroteltu)",
|
||||
"form.integration.raindrop_token": "(Testi) tunnus",
|
||||
"form.integration.readeck_activate": "Tallenna artikkelit Readeckiin",
|
||||
"form.integration.readeck_api_key": "Readeck API-avain",
|
||||
"form.integration.readeck_endpoint": "Readeck API-päätepiste",
|
||||
"form.integration.readeck_labels": "Readeck Labels",
|
||||
"form.integration.readeck_labels": "Readeck-tunnisteet",
|
||||
"form.integration.readeck_only_url": "Lähetä vain URL-osoite (koko sisällön sijaan)",
|
||||
"form.integration.readwise_activate": "Save entries to Readwise Reader",
|
||||
"form.integration.readwise_api_key": "Readwise Reader Access Token",
|
||||
"form.integration.readwise_api_key_link": "Get your Readwise Access Token",
|
||||
"form.integration.rssbridge_activate": "Check RSS-Bridge when adding subscriptions",
|
||||
"form.integration.rssbridge_token": "RSS-Bridge authentication token",
|
||||
"form.integration.rssbridge_url": "RSS-Bridge server URL",
|
||||
"form.integration.shaarli_activate": "Save articles to Shaarli",
|
||||
"form.integration.shaarli_api_secret": "Shaarli API Secret",
|
||||
"form.integration.shaarli_endpoint": "Shaarli URL",
|
||||
"form.integration.shiori_activate": "Save articles to Shiori",
|
||||
"form.integration.shiori_endpoint": "Shiori API Endpoint",
|
||||
"form.integration.shiori_password": "Shiori Password",
|
||||
"form.integration.shiori_username": "Shiori Username",
|
||||
"form.integration.slack_activate": "Push entries to Slack",
|
||||
"form.integration.slack_webhook_link": "Slack Webhook link",
|
||||
"form.integration.readeck_push_activate": "Lähetä uudet merkinnät automaattisesti Readeckiin",
|
||||
"form.integration.readwise_activate": "Tallenna merkinnät Readwise Readeriin",
|
||||
"form.integration.readwise_api_key": "Readwise Reader -pääsytunnus",
|
||||
"form.integration.readwise_api_key_link": "Hanki Readwise-pääsytunnus",
|
||||
"form.integration.rssbridge_activate": "Tarkista RSS-Bridge tilauksia lisättäessä",
|
||||
"form.integration.rssbridge_token": "RSS-Bridge-todennustunnus",
|
||||
"form.integration.rssbridge_url": "RSS-Bridge-palvelimen URL",
|
||||
"form.integration.shaarli_activate": "Tallenna artikkelit Shaarliin",
|
||||
"form.integration.shaarli_api_secret": "Shaarli API -salaisuus",
|
||||
"form.integration.shaarli_endpoint": "Shaarli-osoite",
|
||||
"form.integration.shiori_activate": "Tallenna artikkelit Shioriin",
|
||||
"form.integration.shiori_endpoint": "Shiori API -päätepiste",
|
||||
"form.integration.shiori_password": "Shiori-salasana",
|
||||
"form.integration.shiori_username": "Shiori-käyttäjätunnus",
|
||||
"form.integration.slack_activate": "Lähetä merkinnät Slackiin",
|
||||
"form.integration.slack_webhook_link": "Slack-webhook-linkki",
|
||||
"form.integration.telegram_bot_activate": "Lähetä uusia artikkeleita Telegram-chatiin",
|
||||
"form.integration.telegram_bot_disable_buttons": "Disable buttons",
|
||||
"form.integration.telegram_bot_disable_notification": "Disable notification",
|
||||
"form.integration.telegram_bot_disable_web_page_preview": "Disable web page preview",
|
||||
"form.integration.telegram_bot_disable_buttons": "Poista painikkeet käytöstä",
|
||||
"form.integration.telegram_bot_disable_notification": "Poista ilmoitukset käytöstä",
|
||||
"form.integration.telegram_bot_disable_web_page_preview": "Poista sivun esikatselu käytöstä",
|
||||
"form.integration.telegram_bot_token": "Bot-tunnus",
|
||||
"form.integration.telegram_chat_id": "Chat ID",
|
||||
"form.integration.telegram_topic_id": "Topic ID",
|
||||
"form.integration.telegram_chat_id": "Keskustelun tunnus",
|
||||
"form.integration.telegram_topic_id": "Aiheen tunnus",
|
||||
"form.integration.wallabag_activate": "Tallenna artikkelit Wallabagiin",
|
||||
"form.integration.wallabag_client_id": "Wallabag Client ID",
|
||||
"form.integration.wallabag_client_secret": "Wallabag Client Secret",
|
||||
"form.integration.wallabag_client_id": "Wallabag-asiakastunnus",
|
||||
"form.integration.wallabag_client_secret": "Wallabag-asiakassalaisuus",
|
||||
"form.integration.wallabag_endpoint": "Wallabagin perus-URL-osoite",
|
||||
"form.integration.wallabag_only_url": "Lähetä vain URL-osoite (koko sisällön sijaan)",
|
||||
"form.integration.wallabag_password": "Wallabag-salasana",
|
||||
"form.integration.wallabag_username": "Wallabag-käyttäjätunnus",
|
||||
"form.integration.wallabag_tags": "Wallabag Tags",
|
||||
"form.integration.webhook_activate": "Enable Webhooks",
|
||||
"form.integration.webhook_secret": "Webhooks Secret",
|
||||
"form.integration.webhook_url": "Default Webhook URL",
|
||||
"form.prefs.fieldset.application_settings": "Application Settings",
|
||||
"form.prefs.fieldset.authentication_settings": "Authentication Settings",
|
||||
"form.prefs.fieldset.global_feed_settings": "Global Feed Settings",
|
||||
"form.prefs.fieldset.reader_settings": "Reader Settings",
|
||||
"form.prefs.help.external_font_hosts": "Space separated list of external font hosts to allow. For example: \"fonts.gstatic.com fonts.googleapis.com\".",
|
||||
"form.prefs.label.always_open_external_links": "Read articles by opening external links",
|
||||
"form.integration.wallabag_tags": "Wallabag-tunnisteet",
|
||||
"form.integration.webhook_activate": "Ota webhookit käyttöön",
|
||||
"form.integration.webhook_secret": "Webhookien salaisuus",
|
||||
"form.integration.webhook_url": "Oletus-webhook-URL",
|
||||
"form.prefs.fieldset.application_settings": "Sovellusasetukset",
|
||||
"form.prefs.fieldset.authentication_settings": "Todennusasetukset",
|
||||
"form.prefs.fieldset.global_feed_settings": "Syötteiden yleisasetukset",
|
||||
"form.prefs.fieldset.reader_settings": "Lukija-asetukset",
|
||||
"form.prefs.help.external_font_hosts": "Sallittujen ulkoisten fonttipalvelinten lista välilyönnein eroteltuna. Esimerkiksi: \"fonts.gstatic.com fonts.googleapis.com\".",
|
||||
"form.prefs.label.always_open_external_links": "Lue artikkelit avaamalla ulkoiset linkit",
|
||||
"form.prefs.label.categories_sorting_order": "Kategorioiden lajittelu",
|
||||
"form.prefs.label.cjk_reading_speed": "Kiinan, Korean ja Japanin lukunopeus (merkkejä minuutissa)",
|
||||
"form.prefs.label.custom_css": "Mukautettu CSS",
|
||||
@@ -356,14 +359,14 @@
|
||||
"form.prefs.label.entry_order": "Lajittele sarakkeen mukaan",
|
||||
"form.prefs.label.entry_sorting": "Lajittelu",
|
||||
"form.prefs.label.entry_swipe": "Ota syöttöpyyhkäisy käyttöön kosketusnäytöissä",
|
||||
"form.prefs.label.external_font_hosts": "External font hosts",
|
||||
"form.prefs.label.external_font_hosts": "Ulkoiset fonttipalvelimet",
|
||||
"form.prefs.label.gesture_nav": "Ele siirtyäksesi merkintöjen välillä",
|
||||
"form.prefs.label.keyboard_shortcuts": "Ota pikanäppäimet käyttöön",
|
||||
"form.prefs.label.language": "Kieli",
|
||||
"form.prefs.label.mark_read_manually": "Mark entries as read manually",
|
||||
"form.prefs.label.mark_read_on_media_completion": "Only mark as read when audio/video playback reaches 90%% completion",
|
||||
"form.prefs.label.mark_read_manually": "Merkitse merkinnät luetuiksi manuaalisesti",
|
||||
"form.prefs.label.mark_read_on_media_completion": "Merkitse luetuksi vasta, kun ääni/video on 90%% toistettu",
|
||||
"form.prefs.label.mark_read_on_view": "Merkitse kohdat automaattisesti luetuiksi, kun niitä tarkastellaan",
|
||||
"form.prefs.label.mark_read_on_view_or_media_completion": "Mark entries as read when viewed. For audio/video, mark as read at 90%% completion",
|
||||
"form.prefs.label.mark_read_on_view_or_media_completion": "Merkitse merkinnät luetuiksi katsottaessa. Ääni/videolle merkitse 90%% toistettuna",
|
||||
"form.prefs.label.media_playback_rate": "Äänen/videon toistonopeus",
|
||||
"form.prefs.label.open_external_links_in_new_tab": "Avaa ulkoiset linkit uuteen välilehteen (lisää target=\"_blank\" linkkeihin)",
|
||||
"form.prefs.label.show_reading_time": "Näytä artikkeleiden arvioitu lukuaika",
|
||||
@@ -419,14 +422,14 @@
|
||||
"menu.show_only_starred_entries": "Näytä vain suosikit",
|
||||
"menu.show_only_unread_entries": "Näytä vain lukemattomat artikkelit",
|
||||
"menu.starred": "Suosikit",
|
||||
"menu.title": "Menu",
|
||||
"menu.title": "Valikko",
|
||||
"menu.unread": "Lukemattomat",
|
||||
"menu.users": "Käyttäjät",
|
||||
"page.about.author": "Tekijä:",
|
||||
"page.about.build_date": "Valmistuspäivä:",
|
||||
"page.about.credits": "Kiitokset",
|
||||
"page.about.db_usage": "Database size:",
|
||||
"page.about.git_commit": "Git Commit:",
|
||||
"page.about.db_usage": "Tietokannan koko:",
|
||||
"page.about.git_commit": "Git-toimite:",
|
||||
"page.about.global_config_options": "Yleiset asetukset",
|
||||
"page.about.go_version": "Go-versio:",
|
||||
"page.about.license": "Lisenssi:",
|
||||
@@ -455,10 +458,10 @@
|
||||
"page.categories.no_feed": "Ei syötettä.",
|
||||
"page.categories.title": "Kategoriat",
|
||||
"page.categories_count": [
|
||||
"%d category",
|
||||
"%d categories"
|
||||
"%d kategoria",
|
||||
"%d kategoriaa"
|
||||
],
|
||||
"page.category_label": "Category: %s",
|
||||
"page.category_label": "Kategoria: %s",
|
||||
"page.edit_category.title": "Muokkaa kategoria: %s",
|
||||
"page.edit_feed.etag_header": "ETag-otsikko:",
|
||||
"page.edit_feed.last_check": "Viimeisin tarkistus:",
|
||||
@@ -473,17 +476,17 @@
|
||||
"%d virhettä"
|
||||
],
|
||||
"page.feeds.last_check": "Viimeisin tarkistus:",
|
||||
"page.feeds.next_check": "Next check:",
|
||||
"page.feeds.next_check": "Seuraava tarkistus:",
|
||||
"page.feeds.read_counter": "Luettujen artikkeleiden määrä",
|
||||
"page.feeds.title": "Syötteet",
|
||||
"page.footer.elevator": "Back to top",
|
||||
"page.footer.elevator": "Takaisin ylös",
|
||||
"page.history.title": "Historia",
|
||||
"page.import.title": "Tuo",
|
||||
"page.integration.bookmarklet": "Sovelluskirjanmerkki",
|
||||
"page.integration.bookmarklet.help": "This special link allows you to subscribe to a website directly by using a bookmark in your web browser.",
|
||||
"page.integration.bookmarklet.help": "Tämä erityinen linkki antaa sinun tilata verkkosivuston suoraan selaimen kirjanmerkillä.",
|
||||
"page.integration.bookmarklet.instructions": "Vedä ja pudota tämä linkki kirjanmerkkeihisi.",
|
||||
"page.integration.bookmarklet.name": "Lisää Minifluxiin",
|
||||
"page.integration.miniflux_api": "Miniflux API",
|
||||
"page.integration.miniflux_api": "Minifluxin API",
|
||||
"page.integration.miniflux_api_endpoint": "API-päätepiste",
|
||||
"page.integration.miniflux_api_password": "Salasana",
|
||||
"page.integration.miniflux_api_password_value": "Tilisi salasana",
|
||||
@@ -522,7 +525,7 @@
|
||||
"page.keyboard_shortcuts.subtitle.sections": "Osion navigointi",
|
||||
"page.keyboard_shortcuts.title": "Pikanäppäimet",
|
||||
"page.keyboard_shortcuts.toggle_star_status": "Vaihda kirjanmerkki",
|
||||
"page.keyboard_shortcuts.toggle_entry_attachments": "Toggle open/close entry attachments",
|
||||
"page.keyboard_shortcuts.toggle_entry_attachments": "Avaa tai sulje merkinnän liitteet",
|
||||
"page.keyboard_shortcuts.toggle_read_status_next": "Vaihda luettu/lukematon, keskity seuraavaksi",
|
||||
"page.keyboard_shortcuts.toggle_read_status_prev": "Vaihda luettu/lukematon, keskity edelliseen",
|
||||
"page.login.google_signin": "Kirjaudu sisään Googlella",
|
||||
@@ -530,7 +533,7 @@
|
||||
"page.login.title": "Kirjaudu sisään",
|
||||
"page.login.webauthn_login": "Kirjaudu sisään salasanalla",
|
||||
"page.login.webauthn_login.error": "Ei voida kirjautua sisään salasanalla",
|
||||
"page.login.webauthn_login.help": "Please enter your username if you're using a security key. This is not required if you are using a Passkey (discoverable credentials).",
|
||||
"page.login.webauthn_login.help": "Jos käytät turva-avainta, kirjoita käyttäjätunnus. Passkeytä käyttäessä tämä ei ole tarpeen.",
|
||||
"page.new_api_key.title": "Uusi API-avain",
|
||||
"page.new_category.title": "Uusi kategoria",
|
||||
"page.new_user.title": "Uusi käyttäjä",
|
||||
@@ -538,8 +541,8 @@
|
||||
"page.offline.refresh_page": "Yritä päivittää sivu",
|
||||
"page.offline.title": "Offline-tila",
|
||||
"page.read_entry_count": [
|
||||
"%d read entry",
|
||||
"%d read entries"
|
||||
"%d luettu merkintä",
|
||||
"%d luettua merkintää"
|
||||
],
|
||||
"page.search.title": "Hakutulokset",
|
||||
"page.sessions.table.actions": "Toiminnot",
|
||||
@@ -553,35 +556,35 @@
|
||||
"page.settings.title": "Asetukset",
|
||||
"page.settings.unlink_google_account": "Poista Google-tilini linkitys",
|
||||
"page.settings.unlink_oidc_account": "Poista %s -tilini linkitys",
|
||||
"page.settings.webauthn.actions": "Actions",
|
||||
"page.settings.webauthn.added_on": "Added On",
|
||||
"page.settings.webauthn.actions": "Toiminnot",
|
||||
"page.settings.webauthn.added_on": "Lisätty",
|
||||
"page.settings.webauthn.delete": [
|
||||
"Poista %d salasana",
|
||||
"Poista %d salasanaa"
|
||||
],
|
||||
"page.settings.webauthn.last_seen_on": "Last Used",
|
||||
"page.settings.webauthn.passkey_name": "Passkey Name",
|
||||
"page.settings.webauthn.passkeys": "Passkeys",
|
||||
"page.settings.webauthn.last_seen_on": "Viimeksi käytetty",
|
||||
"page.settings.webauthn.passkey_name": "Passkey-nimi",
|
||||
"page.settings.webauthn.passkeys": "Passkeyt",
|
||||
"page.settings.webauthn.register": "Rekisteröi salasana",
|
||||
"page.settings.webauthn.register.error": "Salasanaa ei voi rekisteröidä",
|
||||
"page.shared_entries.title": "Jaetut artikkelit",
|
||||
"page.shared_entries_count": [
|
||||
"%d shared entry",
|
||||
"%d shared entries"
|
||||
"%d jaettu merkintä",
|
||||
"%d jaettua merkintää"
|
||||
],
|
||||
"page.starred.title": "Suosikit",
|
||||
"page.starred_entry_count": [
|
||||
"%d starred entry",
|
||||
"%d starred entries"
|
||||
"%d suosikkimerkintä",
|
||||
"%d suosikkimerkintää"
|
||||
],
|
||||
"page.total_entry_count": [
|
||||
"%d entry in total",
|
||||
"%d entries in total"
|
||||
"Yhteensä %d merkintä",
|
||||
"Yhteensä %d merkintää"
|
||||
],
|
||||
"page.unread.title": "Lukemattomat",
|
||||
"page.unread_entry_count": [
|
||||
"%d unread entry",
|
||||
"%d unread entries"
|
||||
"%d lukematon merkintä",
|
||||
"%d lukematonta merkintää"
|
||||
],
|
||||
"page.users.actions": "Toiminnot",
|
||||
"page.users.admin.no": "Ei",
|
||||
@@ -591,7 +594,7 @@
|
||||
"page.users.never_logged": "Ei koskaan",
|
||||
"page.users.title": "Käyttäjät",
|
||||
"page.users.username": "Käyttäjätunnus",
|
||||
"page.webauthn_rename.title": "Rename Passkey",
|
||||
"page.webauthn_rename.title": "Nimeä passkey uudelleen",
|
||||
"pagination.first": "Ensimmäinen",
|
||||
"pagination.last": "Viimeinen",
|
||||
"pagination.next": "Seuraava",
|
||||
@@ -629,4 +632,4 @@
|
||||
"time_elapsed.yesterday": "eilen",
|
||||
"tooltip.keyboard_shortcuts": "Pikanäppäin: %s",
|
||||
"tooltip.logged_user": "Kirjautunut %s-käyttäjänä"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -175,6 +175,7 @@
|
||||
"form.feed.label.category": "Catégorie",
|
||||
"form.feed.label.cookie": "Définir les cookies",
|
||||
"form.feed.label.crawler": "Récupérer le contenu original",
|
||||
"form.feed.label.ignore_entry_updates": "Ignore entry updates",
|
||||
"form.feed.label.description": "Description",
|
||||
"form.feed.label.disable_http2": "Désactiver HTTP/2",
|
||||
"form.feed.label.disabled": "Ne pas actualiser ce flux",
|
||||
@@ -257,15 +258,16 @@
|
||||
"form.integration.linktaco_api_token": "Token API LinkTaco",
|
||||
"form.integration.linktaco_api_token_hint": "Obtenez votre token d'accès personnel sur",
|
||||
"form.integration.linktaco_org_slug": "Slug de l'organisation",
|
||||
"form.integration.linktaco_tags": "Tags (max. 10, séparés par des virgules)",
|
||||
"form.integration.linktaco_tags_hint": "Maximum 10 tags, séparés par des virgules",
|
||||
"form.integration.linktaco_tags": "Libellés (max. 10, séparés par des virgules)",
|
||||
"form.integration.linktaco_tags_hint": "Maximum 10 libellés, séparés par des virgules",
|
||||
"form.integration.linktaco_visibility": "Visibilité",
|
||||
"form.integration.linktaco_visibility_public": "Public",
|
||||
"form.integration.linktaco_visibility_public": "Publique",
|
||||
"form.integration.linktaco_visibility_private": "Privé",
|
||||
"form.integration.linktaco_visibility_hint": "La visibilité PRIVÉE nécessite un compte LinkTaco payant",
|
||||
"form.integration.linkwarden_activate": "Sauvegarder les articles vers Linkwarden",
|
||||
"form.integration.linkwarden_api_key": "Clé d'API de Linkwarden",
|
||||
"form.integration.linkwarden_endpoint": "URL de base de Linkwarden",
|
||||
"form.integration.linkwarden_collection_id": "ID de collection Linkwarden",
|
||||
"form.integration.matrix_bot_activate": "Envoyer les nouveaux articles vers Matrix",
|
||||
"form.integration.matrix_bot_chat_id": "Identifiant de la salle Matrix",
|
||||
"form.integration.matrix_bot_password": "Mot de passe de l'utilisateur Matrix",
|
||||
@@ -306,6 +308,7 @@
|
||||
"form.integration.readeck_endpoint": "URL de l'API de Readeck",
|
||||
"form.integration.readeck_labels": "Libellés Readeck",
|
||||
"form.integration.readeck_only_url": "Envoyer uniquement l'URL (au lieu du contenu complet)",
|
||||
"form.integration.readeck_push_activate": "Envoyer automatiquement les nouvelles entrées vers Readeck",
|
||||
"form.integration.readwise_activate": "Enregistrer les entrées vers Readwise Reader",
|
||||
"form.integration.readwise_api_key": "Jeton d'accès au lecteur Readwise",
|
||||
"form.integration.readwise_api_key_link": "Obtenez votre jeton d'accès Readwise",
|
||||
@@ -373,7 +376,7 @@
|
||||
"form.prefs.select.browser": "Navigateur",
|
||||
"form.prefs.select.created_time": "Heure de création de l'entrée",
|
||||
"form.prefs.select.fullscreen": "Plein écran",
|
||||
"form.prefs.select.minimal_ui": "Minimal",
|
||||
"form.prefs.select.minimal_ui": "Minimaliste",
|
||||
"form.prefs.select.none": "Aucun",
|
||||
"form.prefs.select.older_first": "Anciens éléments en premier",
|
||||
"form.prefs.select.publish_time": "Heure de publication de l'entrée",
|
||||
@@ -479,7 +482,7 @@
|
||||
"page.footer.elevator": "Retour en haut",
|
||||
"page.history.title": "Historique",
|
||||
"page.import.title": "Importation",
|
||||
"page.integration.bookmarklet": "Bookmarklet",
|
||||
"page.integration.bookmarklet": "Signet (bookmarklet)",
|
||||
"page.integration.bookmarklet.help": "Ce lien spécial vous permet de vous abonner à un site web directement en utilisant un marque page dans votre navigateur web.",
|
||||
"page.integration.bookmarklet.instructions": "Glisser-déposer ce lien dans vos favoris.",
|
||||
"page.integration.bookmarklet.name": "Ajouter à Miniflux",
|
||||
@@ -629,4 +632,4 @@
|
||||
"time_elapsed.yesterday": "hier",
|
||||
"tooltip.keyboard_shortcuts": "Raccourci clavier : %s",
|
||||
"tooltip.logged_user": "Connecté en tant que %s"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,635 @@
|
||||
{
|
||||
"action.cancel": "cancelar",
|
||||
"action.download": "Descargar",
|
||||
"action.edit": "Editar",
|
||||
"action.home_screen": "Engadir á pantalla de inicio",
|
||||
"action.import": "Importar",
|
||||
"action.login": "Acceso",
|
||||
"action.or": "ou",
|
||||
"action.remove": "Retirar",
|
||||
"action.remove_feed": "Retirar esta canle",
|
||||
"action.save": "Gardar",
|
||||
"action.subscribe": "Dar de baixa",
|
||||
"action.update": "Actualizar",
|
||||
"alert.account_linked": "Conectouse a túa conta externa!",
|
||||
"alert.account_unlinked": "Desconectouse a túa conta externa!",
|
||||
"alert.background_feed_refresh": "Estanse actualizando en segundo plano todas as canles. Podes continuar usando Miniflux mentras se realiza a actualización.",
|
||||
"alert.feed_error": "Hai un problema con esta canle.",
|
||||
"alert.no_starred": "Non hai artigos con estrela.",
|
||||
"alert.no_category": "Non hai categorías.",
|
||||
"alert.no_category_entry": "Non hai artigos nesta categoría.",
|
||||
"alert.no_feed": "Non engadiches ningunha canle.",
|
||||
"alert.no_feed_entry": "Non hai artigos para esta canle.",
|
||||
"alert.no_feed_in_category": "Non hai canles nesta categoría.",
|
||||
"alert.no_history": "Por agora non hai historial.",
|
||||
"alert.no_search_result": "Non hai resultados para esta busca.",
|
||||
"alert.no_shared_entry": "Non hai artigos compartidos.",
|
||||
"alert.no_tag_entry": "Non hai artigos con esta etiqueta.",
|
||||
"alert.no_unread_entry": "Non hai artigos sen ler.",
|
||||
"alert.no_user": "Es a única conta usuaria.",
|
||||
"alert.prefs_saved": "Gardáronse as preferencias!",
|
||||
"alert.too_many_feeds_refresh": [
|
||||
"Intentaches demasiadas actualizacións da canle. Agarda %d minuto antes de volver intentalo.",
|
||||
"Intentaches demasiadas actualizacións da canle. Agarda %d minutos antes de volver intentalo."
|
||||
],
|
||||
"confirm.loading": "En proceso…",
|
||||
"confirm.no": "non",
|
||||
"confirm.question": "Tes certeza?",
|
||||
"confirm.question.refresh": "Tes certeza de querer forzar a actualización?",
|
||||
"confirm.yes": "si",
|
||||
"enclosure_media_controls.seek": "Avanzar:",
|
||||
"enclosure_media_controls.seek.title": "Avanzar %s segundos",
|
||||
"enclosure_media_controls.speed": "Velocidade:",
|
||||
"enclosure_media_controls.speed.faster": "Máis rápido",
|
||||
"enclosure_media_controls.speed.faster.title": "Máis rápido %sx",
|
||||
"enclosure_media_controls.speed.reset": "Restablecer",
|
||||
"enclosure_media_controls.speed.reset.title": "Restablecer velocidade a 1x",
|
||||
"enclosure_media_controls.speed.slower": "Máis lento",
|
||||
"enclosure_media_controls.speed.slower.title": "Máis lento %sx",
|
||||
"entry.starred.toast.off": "Sen estrela",
|
||||
"entry.starred.toast.on": "Con estrela",
|
||||
"entry.starred.toggle.off": "Retirar estrela",
|
||||
"entry.starred.toggle.on": "Pór estrela",
|
||||
"entry.comments.label": "Comentarios",
|
||||
"entry.comments.title": "Ver comentarios",
|
||||
"entry.estimated_reading_time": [
|
||||
"%d minuto de lectura",
|
||||
"%d minutos de lectura"
|
||||
],
|
||||
"entry.external_link.label": "Ligazón externa",
|
||||
"entry.save.completed": "Feito!",
|
||||
"entry.save.label": "Gardar",
|
||||
"entry.save.title": "Gardar este artigo",
|
||||
"entry.save.toast.completed": "Gardouse o artigo",
|
||||
"entry.scraper.completed": "Feito!",
|
||||
"entry.scraper.label": "Descargar",
|
||||
"entry.scraper.title": "Obter contido orixinal",
|
||||
"entry.share.label": "Compartir",
|
||||
"entry.share.title": "Compartir este artigo",
|
||||
"entry.shared_entry.label": "Compartir",
|
||||
"entry.shared_entry.title": "Abrir ligazón pública",
|
||||
"entry.state.loading": "Cargando…",
|
||||
"entry.state.saving": "Gardando…",
|
||||
"entry.status.mark_as_read": "Marcar como lido",
|
||||
"entry.status.mark_as_unread": "Marcar como non lido",
|
||||
"entry.status.title": "Cambiar estado do artigo",
|
||||
"entry.status.toast.read": "Marcado como lido",
|
||||
"entry.status.toast.unread": "Marcado como non lido",
|
||||
"entry.tags.label": "Etiquetas:",
|
||||
"entry.tags.more_tags_label": [
|
||||
"Mostrar %d etiqueta máis",
|
||||
"Mostrar %d etiquetas máis"
|
||||
],
|
||||
"entry.unshare.label": "Non compartir",
|
||||
"error.api_key_already_exists": "Xa existe esta clave da API.",
|
||||
"error.bad_credentials": "Credenciais incorrectas.",
|
||||
"error.category_already_exists": "Xa existe a categoría.",
|
||||
"error.category_not_found": "Non existe a categoría ou non pertence a esta usuaria.",
|
||||
"error.database_error": "Erro na base de datos: %v.",
|
||||
"error.different_passwords": "Os contrasinais non coinciden.",
|
||||
"error.duplicate_fever_username": "Xa hai alguén con ese identificador en Fever!",
|
||||
"error.duplicate_googlereader_username": "Xa hai alguén con ese identificador en Google Reader!",
|
||||
"error.linktaco_missing_required_fields": "Requírese LinkTaco API Token e Organization Slug",
|
||||
"error.duplicate_linked_account": "Xa hai alguén asociado con este provedor!",
|
||||
"error.duplicated_feed": "Xa existe a canle.",
|
||||
"error.empty_file": "O ficheiro está baleiro.",
|
||||
"error.entries_per_page_invalid": "O número de artigos por páxina non é válido.",
|
||||
"error.feed_already_exists": "Xa existe a canle.",
|
||||
"error.feed_category_not_found": "Non existe a categoría ou non pertence a esta usuaria.",
|
||||
"error.feed_format_not_detected": "Non se puido detectar o formato da canle: %v.",
|
||||
"error.feed_invalid_blocklist_rule": "A regra da lista de bloqueo non é válida.",
|
||||
"error.feed_invalid_keeplist_rule": "A regra da lista a manter non é válida.",
|
||||
"error.feed_mandatory_fields": "É obrigatorio engadir un URL para a categoría.",
|
||||
"error.feed_not_found": "A canle non existe ou non pertence a esta usuaria.",
|
||||
"error.feed_title_not_empty": "O título da canle non pode quedar baleiro.",
|
||||
"error.feed_url_not_empty": "O URL da canle non pode quedar baleiro.",
|
||||
"error.fields_mandatory": "Son obrigatorios todos os campos.",
|
||||
"error.http_bad_gateway": "O sitio web non está dispoñible debido a un erro na pasarela. O problema non está en Miniflux. Por favor, inténtao máis tarde.",
|
||||
"error.http_body_read": "Non se pode ler o corpo HTTP: %v.",
|
||||
"error.http_client_error": "Erro HTTP no cliente: %v.",
|
||||
"error.http_empty_response": "A resposta HTTP está baleira. Podería o sitio web estar usando unha protección contra robots?",
|
||||
"error.http_empty_response_body": "O corpo da resposta HTTP está baleiro.",
|
||||
"error.http_forbidden": "Esta prohibido o acceso a esta páxina web. Podería estar usando unha protección contra robots?",
|
||||
"error.http_gateway_timeout": "O sitio web non está dispoñible debido a un erro de caducidade na pasarela. O problema non está en Miniflux. Volve a intentalo máis tarde.",
|
||||
"error.http_internal_server_error": "O sitio web non está dispoñible debido a un fallo no servidor. O problema non está en Miniflux. Volve a intentalo máis tarde.",
|
||||
"error.http_not_authorized": "Non hai autorización para acceder a este sitio web. Podería deberse a unhas credenciais non válidas.",
|
||||
"error.http_resource_not_found": "Non se atopa o recurso solicitado. Por favor, comproba o URL.",
|
||||
"error.http_response_too_large": "A resposta HTTP é demasiado grande. Podes aumentar o límite da resposta HTTP nos axustes xerais (require reinicio do servidor).",
|
||||
"error.http_service_unavailable": "O sitio web non está dispoñible neste momento debido a un erro interno do servidor. O problema non está en Miniflux, inténtao máis tarde.",
|
||||
"error.http_too_many_requests": "Miniflux xerou demasiadas peticións a este servidor. Inténtao máis tarde ou cambia a configuración da aplicación.",
|
||||
"error.http_unexpected_status_code": "O sitio web non está dispoñible neste momento debido a un código de estado HTTP non agardado: %d. O problema non está en Miniflux, inténtao máis tarde.",
|
||||
"error.invalid_categories_sorting_order": "Orde por categorías non válido.",
|
||||
"error.invalid_default_home_page": "Páxina de inicio predeterminada non válida!",
|
||||
"error.invalid_display_mode": "Modo para mostrar a aplicación web non válido.",
|
||||
"error.invalid_entry_direction": "Dirección da entrada non válida.",
|
||||
"error.invalid_entry_order": "Orde da entrada non válida.",
|
||||
"error.invalid_feed_proxy_url": "URL do mandatario non válido.",
|
||||
"error.invalid_feed_url": "URL da canle non válido.",
|
||||
"error.invalid_gesture_nav": "Xesto de navegación non válido.",
|
||||
"error.invalid_language": "Idioma non válido.",
|
||||
"error.invalid_site_url": "URL da web non válido.",
|
||||
"error.invalid_theme": "Decorado non válido.",
|
||||
"error.invalid_timezone": "Zona horaria non válida.",
|
||||
"error.network_operation": "Miniflux non pode acadar esta web por mor dun erro na rede: %v.",
|
||||
"error.network_timeout": "Esta web é demasiado lenta e caducou a petición: %v",
|
||||
"error.password_min_length": "O contrasinal ten que ter 6 caracteres polo menos.",
|
||||
"error.proxy_url_not_empty": "O URL do mandatario non pode quedar baleiro.",
|
||||
"error.settings_block_rule_fieldname_invalid": "Regra do Bloque non válida: á regra #%d fáltalle un nome de campo válido (Opcións: %s)",
|
||||
"error.settings_block_rule_invalid_regex": "Regra do Bloque non válida: o patrón da regra #%d non é unha expresión regex válida",
|
||||
"error.settings_block_rule_regex_required": "Regra do Bloque non válida: non se proporcionou o patrón da regra #%d",
|
||||
"error.settings_block_rule_separator_required": "Regra do Bloque non válida:: o patrón da regra #%d require estar separado por un '='",
|
||||
"error.settings_invalid_domain_list": "Lista de dominios non válida. Proporciona unha lista de dominios separados por espazos.",
|
||||
"error.settings_keep_rule_fieldname_invalid": "Regra para Manter non válida: a regra #%d non ten un nome de campo válido (Opcións: %s)",
|
||||
"error.settings_keep_rule_invalid_regex": "Regra para Manter non válida: o patrón da regra #%d non é unha expresión regex válida",
|
||||
"error.settings_keep_rule_regex_required": "Regra para Manter non válida: non se proporcionou o patrón para #%d",
|
||||
"error.settings_keep_rule_separator_required": "Regra para Manter non válida: o patrón da regra #%d ten que estar separado por un '='",
|
||||
"error.settings_mandatory_fields": "O identificador, decorado, idioma e zona horaria son campos obrigatorios.",
|
||||
"error.settings_media_playback_rate_range": "A velocidade de reprodución está fóra do rango admitido",
|
||||
"error.settings_reading_speed_is_positive": "A velocidade de lectura ten que ser un número enteiro positivo.",
|
||||
"error.site_url_not_empty": "O URL da web non pode estar baleiro.",
|
||||
"error.subscription_not_found": "Non se atopou ningunha canle.",
|
||||
"error.title_required": "O título é obrigatorio.",
|
||||
"error.tls_error": "Erro TLS: %q. Podes desactivar a verificación TLS nos axustes da canle se queres.",
|
||||
"error.unable_to_create_api_key": "Non se puido crear a clave da API.",
|
||||
"error.unable_to_create_category": "Non se puido crear a categoría.",
|
||||
"error.unable_to_create_user": "Non se puido crear a conta.",
|
||||
"error.unable_to_detect_rssbridge": "Non se detectou a canle a usar RSS-Bridge: %v.",
|
||||
"error.unable_to_parse_feed": "Non se puido procesar a canle: %v.",
|
||||
"error.unable_to_update_category": "Non se puido actualizar a categoría.",
|
||||
"error.unable_to_update_feed": "Non se puido actualizar a canle.",
|
||||
"error.unable_to_update_user": "Non se puido actualizar a usuaria.",
|
||||
"error.unlink_account_without_password": "Tes que crear un contrasinal, se non non poderás volver acceder.",
|
||||
"error.user_already_exists": "Xa existe esta usuaria.",
|
||||
"error.user_mandatory_fields": "O identificador é obrigatorio.",
|
||||
"form.api_key.label.description": "Etiqueta da Clave da API",
|
||||
"form.category.hide_globally": "Ocultar entradas na lista global de non lidos",
|
||||
"form.category.label.title": "Título",
|
||||
"form.feed.fieldset.general": "Xeral",
|
||||
"form.feed.fieldset.integration": "Servizos de Terceiras Partes",
|
||||
"form.feed.fieldset.network_settings": "Axustes da rede",
|
||||
"form.feed.fieldset.rules": "Regras",
|
||||
"form.feed.label.allow_self_signed_certificates": "Permitir certificados auto-asinados ou non válidos",
|
||||
"form.feed.label.apprise_service_urls": "Lista separada por comas de URLs do servizo Apprise",
|
||||
"form.feed.label.block_filter_entry_rules": "Regras de Bloqueo de entradas",
|
||||
"form.feed.label.blocklist_rules": "Filtros de bloqueo baseados en RegEx",
|
||||
"form.feed.label.category": "Categoría",
|
||||
"form.feed.label.cookie": "Establecer rastros",
|
||||
"form.feed.label.crawler": "Obter contido orixinal",
|
||||
"form.feed.label.description": "Descrición",
|
||||
"form.feed.label.disable_http2": "Desactivar HTTP/2 para evitar «fingerprinting»",
|
||||
"form.feed.label.disabled": "Non actualizar esta canle",
|
||||
"form.feed.label.feed_password": "Contrasinal para a canle",
|
||||
"form.feed.label.feed_url": "URL da canle",
|
||||
"form.feed.label.feed_username": "Identificador para a canle",
|
||||
"form.feed.label.fetch_via_proxy": "Usar o mandatario configurado a nivel da aplicación",
|
||||
"form.feed.label.hide_globally": "Ocultar entradas na lista global de non lidos",
|
||||
"form.feed.label.ignore_entry_updates": "Ignorar actualizacións da entrada",
|
||||
"form.feed.label.ignore_http_cache": "Ignorar memoria tobo HTTP",
|
||||
"form.feed.label.keep_filter_entry_rules": "Regra para Entradas permitidas",
|
||||
"form.feed.label.keeplist_rules": "Filtros para Manter baseados en RegEx",
|
||||
"form.feed.label.no_media_player": "Sen reprodutor (son/vídeo)",
|
||||
"form.feed.label.ntfy_activate": "Enviar novidades a Ntfy",
|
||||
"form.feed.label.ntfy_default_priority": "Prioridade predeterminada Ntfy",
|
||||
"form.feed.label.ntfy_high_priority": "Alta prioridade Ntfy",
|
||||
"form.feed.label.ntfy_low_priority": "Baix prioridade Ntfy",
|
||||
"form.feed.label.ntfy_max_priority": "Prioridade máx. Ntfy",
|
||||
"form.feed.label.ntfy_min_priority": "Prioridade mín. Ntfy",
|
||||
"form.feed.label.ntfy_priority": "Prioridade en Ntfy",
|
||||
"form.feed.label.ntfy_topic": "Tema en Ntfy (optativo)",
|
||||
"form.feed.label.proxy_url": "URL do mandatario",
|
||||
"form.feed.label.pushover_activate": "Enviar novidades a Pushover",
|
||||
"form.feed.label.pushover_default_priority": "Prioridade predeterminada",
|
||||
"form.feed.label.pushover_high_priority": "Alta prioridade",
|
||||
"form.feed.label.pushover_low_priority": "Baixa prioridade",
|
||||
"form.feed.label.pushover_max_priority": "Prioridade máx.",
|
||||
"form.feed.label.pushover_min_priority": "Prioridade mín.",
|
||||
"form.feed.label.pushover_priority": "Prioridade da mensaxe Pushover",
|
||||
"form.feed.label.rewrite_rules": "Regras de Reescritura do contido",
|
||||
"form.feed.label.scraper_rules": "Regras ao obter contido",
|
||||
"form.feed.label.site_url": "URL do sitio",
|
||||
"form.feed.label.title": "Título",
|
||||
"form.feed.label.urlrewrite_rules": "Regras de rescritura URL",
|
||||
"form.feed.label.user_agent": "Sobrescribir User Agent predeterminado",
|
||||
"form.feed.label.webhook_url": "Override webhook url",
|
||||
"form.import.label.file": "Ficheiro OPML",
|
||||
"form.import.label.url": "URL",
|
||||
"form.integration.archiveorg_activate": "Enviar entradas a archive.org",
|
||||
"form.integration.apprise_activate": "Enviar entradas a Apprise",
|
||||
"form.integration.apprise_services_url": "Lista separada por comas de URLs do servizo Apprise",
|
||||
"form.integration.apprise_url": "URL de Apprise API",
|
||||
"form.integration.betula_activate": "Gardar entradas en Betula",
|
||||
"form.integration.betula_token": "Token de Betula",
|
||||
"form.integration.betula_url": "URL do servidor Betula",
|
||||
"form.integration.cubox_activate": "Gardar entradas en Cubox",
|
||||
"form.integration.cubox_api_link": "Ligazón a Cubox API",
|
||||
"form.integration.discord_activate": "Enviar entradas a Discord",
|
||||
"form.integration.discord_webhook_link": "Ligazón a Discord Webhook",
|
||||
"form.integration.espial_activate": "Enviar entradas a Espial",
|
||||
"form.integration.espial_api_key": "Clave de Espial API",
|
||||
"form.integration.espial_endpoint": "Acceso na Espial API",
|
||||
"form.integration.espial_tags": "Etiquetas Espial",
|
||||
"form.integration.fever_activate": "Activar Fever API",
|
||||
"form.integration.fever_endpoint": "Punto de acceso da Fever API:",
|
||||
"form.integration.fever_password": "Contrasinal Fever",
|
||||
"form.integration.fever_username": "Identificador Fever",
|
||||
"form.integration.googlereader_activate": "Activar API Google Reader",
|
||||
"form.integration.googlereader_endpoint": "Punto de acceso de Google Reader API:",
|
||||
"form.integration.googlereader_password": "Contrasinal Google Reader",
|
||||
"form.integration.googlereader_username": "Identificador Google Reader",
|
||||
"form.integration.instapaper_activate": "Gardar entradas en Instapaper",
|
||||
"form.integration.instapaper_password": "Contrasinal Instapaper",
|
||||
"form.integration.instapaper_username": "Identificador Instapaper",
|
||||
"form.integration.karakeep_activate": "Gardar entradas en Karakeep",
|
||||
"form.integration.karakeep_api_key": "Clave de Karakeep API",
|
||||
"form.integration.karakeep_url": "Punto de acceso da Karakeep API",
|
||||
"form.integration.karakeep_tags": "Etiquetas Karakeep",
|
||||
"form.integration.linkace_activate": "Gardar entradas en LinkAce",
|
||||
"form.integration.linkace_api_key": "Clave de LinkAce API",
|
||||
"form.integration.linkace_check_disabled": "Desactivar comprobación de ligazóns",
|
||||
"form.integration.linkace_endpoint": "Punto de acceso da LinkAce API",
|
||||
"form.integration.linkace_is_private": "Marcar ligazón como privada",
|
||||
"form.integration.linkace_tags": "Etiquetas LinkAce",
|
||||
"form.integration.linkding_activate": "Gardar entradas en Linkding",
|
||||
"form.integration.linkding_api_key": "Clave da Linkding API",
|
||||
"form.integration.linkding_bookmark": "Marcar marcador como non lido",
|
||||
"form.integration.linkding_endpoint": "Punto de acceso da Linkding API",
|
||||
"form.integration.linkding_tags": "Etiquetas Linkding",
|
||||
"form.integration.linktaco_activate": "Gardar entradas en LinkTaco",
|
||||
"form.integration.linktaco_api_token": "Token da API de LinkTaco",
|
||||
"form.integration.linktaco_api_token_hint": "Obtén o token de acceso persoal en",
|
||||
"form.integration.linktaco_org_slug": "Sobrenome da Organización",
|
||||
"form.integration.linktaco_tags": "Etiquetas (máx 10, separadas por comas)",
|
||||
"form.integration.linktaco_tags_hint": "Máximo 10 etiquetas, separadas por comas",
|
||||
"form.integration.linktaco_visibility": "Visibilidade",
|
||||
"form.integration.linktaco_visibility_public": "Pública",
|
||||
"form.integration.linktaco_visibility_private": "Privada",
|
||||
"form.integration.linktaco_visibility_hint": "A visibilidade privada require unha conta de pago de LinkTaco",
|
||||
"form.integration.linkwarden_activate": "Gardar entradas en Linkwarden",
|
||||
"form.integration.linkwarden_api_key": "Clave da Linkwarden API",
|
||||
"form.integration.linkwarden_endpoint": "URL Base de Linkwarden",
|
||||
"form.integration.linkwarden_collection_id": "ID da colección Linkwarden",
|
||||
"form.integration.matrix_bot_activate": "Enviar entradas a Matrix",
|
||||
"form.integration.matrix_bot_chat_id": "ID da Sala Matrix",
|
||||
"form.integration.matrix_bot_password": "Contrasinal da usuaria Matrix",
|
||||
"form.integration.matrix_bot_url": "URL do servidor Matrix",
|
||||
"form.integration.matrix_bot_user": "Identificador en Matrix",
|
||||
"form.integration.notion_activate": "Gardar entradas en Notion",
|
||||
"form.integration.notion_page_id": "ID da páxina Notion",
|
||||
"form.integration.notion_token": "Token secreto para Notion",
|
||||
"form.integration.ntfy_activate": "Enviar entradas a ntfy",
|
||||
"form.integration.ntfy_api_token": "Token da Ntfy API (optativo)",
|
||||
"form.integration.ntfy_icon_url": "Ntfy Icon URL (optativo)",
|
||||
"form.integration.ntfy_internal_links": "Usar ligazóns internas ao premer (optativo)",
|
||||
"form.integration.ntfy_password": "Contrasinal Ntfy (optativo)",
|
||||
"form.integration.ntfy_topic": "Tema en Ntfy (úsase por defecto se non o establece a canle)",
|
||||
"form.integration.ntfy_url": "Ntfy URL (optativo, predeterminado ntfy.sh)",
|
||||
"form.integration.ntfy_username": "Contrasinal Ntfy (optativo)",
|
||||
"form.integration.nunux_keeper_activate": "Gardar entradas en Nunux Keeper",
|
||||
"form.integration.nunux_keeper_api_key": "Clave da Nunux Keeper API",
|
||||
"form.integration.nunux_keeper_endpoint": "Punto de acceso Nunux Keeper API",
|
||||
"form.integration.omnivore_activate": "Gardar entradas en Omnivore",
|
||||
"form.integration.omnivore_api_key": "Clave da Omnivore API",
|
||||
"form.integration.omnivore_url": "Punto de acceso de Omnivore API",
|
||||
"form.integration.pinboard_activate": "Gardar entradas en Pinboard",
|
||||
"form.integration.pinboard_bookmark": "Marcar marcador como non lido",
|
||||
"form.integration.pinboard_tags": "Etiquetas Pinboard",
|
||||
"form.integration.pinboard_token": "Token da Pinboard API",
|
||||
"form.integration.pushover_activate": "Enviar entradas a Pushover",
|
||||
"form.integration.pushover_device": "Dispositivo Pushover (optativo)",
|
||||
"form.integration.pushover_prefix": "Prefixo do URL Pushover (optativo)",
|
||||
"form.integration.pushover_token": "Token da aplicación na Pushover API",
|
||||
"form.integration.pushover_user": "Clave da usuaria Pushover",
|
||||
"form.integration.raindrop_activate": "Gardar entradas en Raindrop",
|
||||
"form.integration.raindrop_collection_id": "ID da colección",
|
||||
"form.integration.raindrop_tags": "Etiquetas (separadas por comas)",
|
||||
"form.integration.raindrop_token": "Token (de proba)",
|
||||
"form.integration.readeck_activate": "Gardar entradas en Readeck",
|
||||
"form.integration.readeck_api_key": "Clave da Readeck API",
|
||||
"form.integration.readeck_endpoint": "URL de Readeck",
|
||||
"form.integration.readeck_labels": "Etiquetas Readeck",
|
||||
"form.integration.readeck_only_url": "Enviar só URL (e non todo o contido)",
|
||||
"form.integration.readeck_push_activate": "Enviar automaticamente todas as entradas a Readeck",
|
||||
"form.integration.readwise_activate": "Gardar entradas en Readwise Reader",
|
||||
"form.integration.readwise_api_key": "Token de acceso a Readwise Reader",
|
||||
"form.integration.readwise_api_key_link": "Obter o token de acceso a Readwise",
|
||||
"form.integration.rssbridge_activate": "Comprobar RSS-Bridge ao engadir subscricións",
|
||||
"form.integration.rssbridge_token": "Token de autenticación RSS-Bridge",
|
||||
"form.integration.rssbridge_url": "URL do servidor RSS-Bridge",
|
||||
"form.integration.shaarli_activate": "Gardar artigos en Shaarli",
|
||||
"form.integration.shaarli_api_secret": "Clave secreta da API Shaarli",
|
||||
"form.integration.shaarli_endpoint": "Punto de acceso Shaarli",
|
||||
"form.integration.shiori_activate": "Gardar artigos en Shiori",
|
||||
"form.integration.shiori_endpoint": "Punto de acceso Shiori API",
|
||||
"form.integration.shiori_password": "Contrasinal Shiori",
|
||||
"form.integration.shiori_username": "Identificador Shiori",
|
||||
"form.integration.slack_activate": "Enviar entradas a Slack",
|
||||
"form.integration.slack_webhook_link": "Ligzón de Slack Webhook",
|
||||
"form.integration.telegram_bot_activate": "Enviar novas entradas a parola Telegram",
|
||||
"form.integration.telegram_bot_disable_buttons": "Desactivar botóns",
|
||||
"form.integration.telegram_bot_disable_notification": "Desactivar notificación",
|
||||
"form.integration.telegram_bot_disable_web_page_preview": "Disactivar vista previa da páxina",
|
||||
"form.integration.telegram_bot_token": "Toke do Bot",
|
||||
"form.integration.telegram_chat_id": "ID da parola",
|
||||
"form.integration.telegram_topic_id": "ID do tema",
|
||||
"form.integration.wallabag_activate": "Gardar entradas en Wallabag",
|
||||
"form.integration.wallabag_client_id": "ID do cliente en Wallabag",
|
||||
"form.integration.wallabag_client_secret": "Clave Secreta en Wallabag",
|
||||
"form.integration.wallabag_endpoint": "URL Base de Wallabag",
|
||||
"form.integration.wallabag_only_url": "Enviar só URL (e non todo o contido)",
|
||||
"form.integration.wallabag_password": "Contrasinal en Wallabag",
|
||||
"form.integration.wallabag_username": "Identificador en Wallabag",
|
||||
"form.integration.wallabag_tags": "Etiquetas Wallabag",
|
||||
"form.integration.webhook_activate": "Activar Webhooks",
|
||||
"form.integration.webhook_secret": "Clave secreta Webhooks",
|
||||
"form.integration.webhook_url": "URL predeterminada Webhook",
|
||||
"form.prefs.fieldset.application_settings": "Axustes da aplicción",
|
||||
"form.prefs.fieldset.authentication_settings": "Axustes da autenticación",
|
||||
"form.prefs.fieldset.global_feed_settings": "Axustes da canle global",
|
||||
"form.prefs.fieldset.reader_settings": "Axustes de lectura",
|
||||
"form.prefs.help.external_font_hosts": "Lista separada por espazos de servidores de tipos de letra externos permitidos. Exemplo: \"fonts.gstatic.com fonts.googleapis.com\".",
|
||||
"form.prefs.label.always_open_external_links": "Ler artigos abringo ligazóns externas",
|
||||
"form.prefs.label.categories_sorting_order": "Orde para Categorías",
|
||||
"form.prefs.label.cjk_reading_speed": "Velocidade de lectura para chinés, koreano e xaponés (caracteres por minuto)",
|
||||
"form.prefs.label.custom_css": "CSS persoal",
|
||||
"form.prefs.label.custom_js": "JavaScript persoal",
|
||||
"form.prefs.label.default_home_page": "Páxina de inicio predeterminada",
|
||||
"form.prefs.label.default_reading_speed": "Velocidade de lectura para outros idiomas (palabras por minuto)",
|
||||
"form.prefs.label.display_mode": "Disposición da interface Progressive Web App (PWA)",
|
||||
"form.prefs.label.entries_per_page": "Entradas por páxina",
|
||||
"form.prefs.label.entry_order": "Columna para orde das entradas",
|
||||
"form.prefs.label.entry_sorting": "Orde das entradas",
|
||||
"form.prefs.label.entry_swipe": "Activar o desprazamento de entradas en pantallas táctiles",
|
||||
"form.prefs.label.external_font_hosts": "Servidores externos de tipografías",
|
||||
"form.prefs.label.gesture_nav": "Xestos para moverse entre entradas",
|
||||
"form.prefs.label.keyboard_shortcuts": "Activar atallos do teclado",
|
||||
"form.prefs.label.language": "Idioma",
|
||||
"form.prefs.label.mark_read_manually": "Marcar manualmente as entradas como lidas",
|
||||
"form.prefs.label.mark_read_on_media_completion": "Só marcar como lido cando a reprodución acada o 90%",
|
||||
"form.prefs.label.mark_read_on_view": "Marcar automaticamente as entradas ao velas",
|
||||
"form.prefs.label.mark_read_on_view_or_media_completion": "Marcar entradas como vistas ao velas. Para son/vídeo, marcar como lido ao chegar ao 90%",
|
||||
"form.prefs.label.media_playback_rate": "Velocidade de reprodución do son/vídeo",
|
||||
"form.prefs.label.open_external_links_in_new_tab": "Abrir ligazóns externas en nova lapela (engade target=\"_blank\" ás ligazóns)",
|
||||
"form.prefs.label.show_reading_time": "Mostrar tempo de lectura estimado para as entradas",
|
||||
"form.prefs.label.theme": "Decorado",
|
||||
"form.prefs.label.timezone": "Zona horaria",
|
||||
"form.prefs.select.alphabetical": "Alfabética",
|
||||
"form.prefs.select.browser": "Navegador",
|
||||
"form.prefs.select.created_time": "Hora de creación da entrada",
|
||||
"form.prefs.select.fullscreen": "Pantalla completa",
|
||||
"form.prefs.select.minimal_ui": "Mínima",
|
||||
"form.prefs.select.none": "Ningunha",
|
||||
"form.prefs.select.older_first": "Primeiro as antigas",
|
||||
"form.prefs.select.publish_time": "Hora de publicación da entrada",
|
||||
"form.prefs.select.recent_first": "Primeiro as recentes",
|
||||
"form.prefs.select.standalone": "Standalone",
|
||||
"form.prefs.select.swipe": "Desprazar",
|
||||
"form.prefs.select.tap": "Doble toque",
|
||||
"form.prefs.select.unread_count": "Conta de sen ler",
|
||||
"form.submit.loading": "Cargando…",
|
||||
"form.submit.saving": "Gardando…",
|
||||
"form.user.label.admin": "Admin",
|
||||
"form.user.label.confirmation": "Confirmar contrasinal",
|
||||
"form.user.label.password": "Contrasinal",
|
||||
"form.user.label.username": "Identificador",
|
||||
"menu.about": "Sobre",
|
||||
"menu.add_feed": "Engadir canle",
|
||||
"menu.add_user": "Engadir usuaria",
|
||||
"menu.api_keys": "Claves da API",
|
||||
"menu.categories": "Categorías",
|
||||
"menu.create_api_key": "Crear nova clave da API",
|
||||
"menu.create_category": "Crear unha categoría",
|
||||
"menu.edit_category": "Editar",
|
||||
"menu.edit_feed": "Editar",
|
||||
"menu.export": "Exportar",
|
||||
"menu.feed_entries": "Entradas",
|
||||
"menu.feeds": "Canles",
|
||||
"menu.flush_history": "Eliminar historial",
|
||||
"menu.history": "Historial",
|
||||
"menu.home_page": "Páxina de inicio",
|
||||
"menu.import": "Importar",
|
||||
"menu.integrations": "Integracións",
|
||||
"menu.logout": "Fechar sesión",
|
||||
"menu.mark_all_as_read": "Marca todo como lido",
|
||||
"menu.mark_page_as_read": "Marca esta páxina como lida",
|
||||
"menu.preferences": "Preferencias",
|
||||
"menu.refresh_all_feeds": "Actualizar en segundo plano todas as canles",
|
||||
"menu.refresh_feed": "Actualizar",
|
||||
"menu.search": "Buscar",
|
||||
"menu.sessions": "Sesións",
|
||||
"menu.settings": "Axustes",
|
||||
"menu.shared_entries": "Entradas compartidas",
|
||||
"menu.show_all_entries": "Motrar todas as entradas",
|
||||
"menu.show_only_starred_entries": "Mostrar só entradas con estrela",
|
||||
"menu.show_only_unread_entries": "Mostrar só entradas sen ler",
|
||||
"menu.starred": "Con estrela",
|
||||
"menu.title": "Menú",
|
||||
"menu.unread": "Sen ler",
|
||||
"menu.users": "Usuarias",
|
||||
"page.about.author": "Autoría:",
|
||||
"page.about.build_date": "Data da versión:",
|
||||
"page.about.credits": "Crédito",
|
||||
"page.about.db_usage": "Tamaño da BDD:",
|
||||
"page.about.git_commit": "Git Commit:",
|
||||
"page.about.global_config_options": "Configuración global",
|
||||
"page.about.go_version": "Versión de Go:",
|
||||
"page.about.license": "Licenza:",
|
||||
"page.about.postgres_version": "Versión de Postgres:",
|
||||
"page.about.title": "Sobre",
|
||||
"page.about.version": "Versión:",
|
||||
"page.add_feed.choose_feed": "Elixe unha canle",
|
||||
"page.add_feed.label.url": "URL",
|
||||
"page.add_feed.legend.advanced_options": "Opcións avanzadas",
|
||||
"page.add_feed.no_category": "Non hai categoría. Tes que ter polo menos unha categoría.",
|
||||
"page.add_feed.submit": "Atopa unha canle",
|
||||
"page.add_feed.title": "Nova canle",
|
||||
"page.api_keys.never_used": "Nunca utilizado",
|
||||
"page.api_keys.table.actions": "Accións",
|
||||
"page.api_keys.table.created_at": "Data de creación",
|
||||
"page.api_keys.table.description": "Descrición",
|
||||
"page.api_keys.table.last_used_at": "Último uso",
|
||||
"page.api_keys.table.token": "Token",
|
||||
"page.api_keys.title": "Claves da API",
|
||||
"page.categories.entries": "Entradas",
|
||||
"page.categories.feed_count": [
|
||||
"Hai %d canle.",
|
||||
"Hai %d canles."
|
||||
],
|
||||
"page.categories.feeds": "Canles",
|
||||
"page.categories.no_feed": "No hai canle.",
|
||||
"page.categories.title": "Categorías",
|
||||
"page.categories_count": [
|
||||
"%d categoría",
|
||||
"%d categorías"
|
||||
],
|
||||
"page.category_label": "Categoría: %s",
|
||||
"page.edit_category.title": "Editar categoría: %s",
|
||||
"page.edit_feed.etag_header": "Cabeceira ETag:",
|
||||
"page.edit_feed.last_check": "Última comprobación:",
|
||||
"page.edit_feed.last_modified_header": "Cabeceira LastModified:",
|
||||
"page.edit_feed.last_parsing_error": "Erro Last Parsing",
|
||||
"page.edit_feed.no_header": "Ningún",
|
||||
"page.edit_feed.title": "Editar canle: %s",
|
||||
"page.edit_user.title": "Editar usuaria: %s",
|
||||
"page.entry.attachments": "Anexos",
|
||||
"page.feeds.error_count": [
|
||||
"%d erro",
|
||||
"%d erros"
|
||||
],
|
||||
"page.feeds.last_check": "Última comprobación:",
|
||||
"page.feeds.next_check": "Próxima comprobación:",
|
||||
"page.feeds.read_counter": "Número de entradas lidas",
|
||||
"page.feeds.title": "Canles",
|
||||
"page.footer.elevator": "Volver arriba",
|
||||
"page.history.title": "Historial",
|
||||
"page.import.title": "Importar",
|
||||
"page.integration.bookmarklet": "Bookmarklet",
|
||||
"page.integration.bookmarklet.help": "Esta é unha ligazón especial que che permite subscribirte a unha web directamente usando un marcador no teu navegador.",
|
||||
"page.integration.bookmarklet.instructions": "Arrastra e solta esta ligazón nos teus marcadores.",
|
||||
"page.integration.bookmarklet.name": "Engadir a Miniflux",
|
||||
"page.integration.miniflux_api": "API de Miniflux",
|
||||
"page.integration.miniflux_api_endpoint": "Punto de acceso da API",
|
||||
"page.integration.miniflux_api_password": "Contrasinal",
|
||||
"page.integration.miniflux_api_password_value": "Contrasinal da túa conta",
|
||||
"page.integration.miniflux_api_username": "Identificador",
|
||||
"page.integrations.title": "Integracións",
|
||||
"page.keyboard_shortcuts.close_modal": "Fechar diálogo modal",
|
||||
"page.keyboard_shortcuts.download_content": "Descargar contido orixinal",
|
||||
"page.keyboard_shortcuts.go_to_bottom_item": "Ir ao elemento de abaixo de todo",
|
||||
"page.keyboard_shortcuts.go_to_categories": "Ir a categorías",
|
||||
"page.keyboard_shortcuts.go_to_feed": "Ir á canle",
|
||||
"page.keyboard_shortcuts.go_to_feeds": "Ir ás canles",
|
||||
"page.keyboard_shortcuts.go_to_history": "Ir ao historial",
|
||||
"page.keyboard_shortcuts.go_to_next_item": "Ir ao seguinte elemento",
|
||||
"page.keyboard_shortcuts.go_to_next_page": "Ir á páxina seguinte",
|
||||
"page.keyboard_shortcuts.go_to_previous_item": "Ir ao elemento anterior",
|
||||
"page.keyboard_shortcuts.go_to_previous_page": "Ir á páxina anterior",
|
||||
"page.keyboard_shortcuts.go_to_search": "Pór o foco no formulario de busca",
|
||||
"page.keyboard_shortcuts.go_to_settings": "Ir aos axustes",
|
||||
"page.keyboard_shortcuts.go_to_starred": "Ir a artigos con estrela",
|
||||
"page.keyboard_shortcuts.go_to_top_item": "Ir ao elemento de arriba de todo",
|
||||
"page.keyboard_shortcuts.go_to_unread": "Ir aos non lidos",
|
||||
"page.keyboard_shortcuts.mark_page_as_read": "Marcar páxina actual como lida",
|
||||
"page.keyboard_shortcuts.open_comments": "Abrir ligazón de comentarios",
|
||||
"page.keyboard_shortcuts.open_comments_same_window": "Abrir ligazón de comentarios na pestana actual",
|
||||
"page.keyboard_shortcuts.open_item": "Abrir elemento seleccionado",
|
||||
"page.keyboard_shortcuts.open_original": "Abrir ligazón orixinal",
|
||||
"page.keyboard_shortcuts.open_original_same_window": "Abrir ligazón orixinal na pestana actual",
|
||||
"page.keyboard_shortcuts.refresh_all_feeds": "Actualizar en segundo plano todas as canles",
|
||||
"page.keyboard_shortcuts.remove_feed": "Retirar esta canle",
|
||||
"page.keyboard_shortcuts.save_article": "Gardar entrada",
|
||||
"page.keyboard_shortcuts.scroll_item_to_top": "Desprazar o elemento arriba de todo",
|
||||
"page.keyboard_shortcuts.show_keyboard_shortcuts": "Mostrar atallos do teclado",
|
||||
"page.keyboard_shortcuts.subtitle.actions": "Accións",
|
||||
"page.keyboard_shortcuts.subtitle.items": "Moverse polos elementos",
|
||||
"page.keyboard_shortcuts.subtitle.pages": "Moverse polas páxinas",
|
||||
"page.keyboard_shortcuts.subtitle.sections": "Moverse polas seccións",
|
||||
"page.keyboard_shortcuts.title": "Atallos do teclado",
|
||||
"page.keyboard_shortcuts.toggle_star_status": "Pór/quitar estrela",
|
||||
"page.keyboard_shortcuts.toggle_entry_attachments": "Cambiar abrir/fechar anexos da entrada",
|
||||
"page.keyboard_shortcuts.toggle_read_status_next": "Cambiar lido/non lido, foco na seguinte",
|
||||
"page.keyboard_shortcuts.toggle_read_status_prev": "Cambiar lido/non lido, foco na anterior",
|
||||
"page.login.google_signin": "Acceder con Google",
|
||||
"page.login.oidc_signin": "Acceder con %s",
|
||||
"page.login.title": "Acceder",
|
||||
"page.login.webauthn_login": "Acceso con clave de paso",
|
||||
"page.login.webauthn_login.error": "Non se puido acceder coa clave de paso",
|
||||
"page.login.webauthn_login.help": "Por favor escribe o teu identificador se estás a usar unha clave de seguridade. Non se require isto se estás a usar unha «Passkey» (credenciais descubribles).",
|
||||
"page.new_api_key.title": "Nova clave da API",
|
||||
"page.new_category.title": "Nova Categoría",
|
||||
"page.new_user.title": "Nova Usuaria",
|
||||
"page.offline.message": "Non tes conexión",
|
||||
"page.offline.refresh_page": "Intenta actualizar a páxina",
|
||||
"page.offline.title": "Modo sen conexión",
|
||||
"page.read_entry_count": [
|
||||
"%d entrada lida",
|
||||
"%d entradas lidas"
|
||||
],
|
||||
"page.search.title": "Resultados da busca",
|
||||
"page.sessions.table.actions": "Accións",
|
||||
"page.sessions.table.current_session": "Sesión actual",
|
||||
"page.sessions.table.date": "Data",
|
||||
"page.sessions.table.ip": "Enderezo IP",
|
||||
"page.sessions.table.user_agent": "User Agent",
|
||||
"page.sessions.title": "Sesións",
|
||||
"page.settings.link_google_account": "Ligar coa miña conta Google",
|
||||
"page.settings.link_oidc_account": "Ligar coa miña conta %s",
|
||||
"page.settings.title": "Axustes",
|
||||
"page.settings.unlink_google_account": "Desligar da miña conta Google",
|
||||
"page.settings.unlink_oidc_account": "Desligar da miña conta %s",
|
||||
"page.settings.webauthn.actions": "Accións",
|
||||
"page.settings.webauthn.added_on": "Engadida o",
|
||||
"page.settings.webauthn.delete": [
|
||||
"Retirar %d passkey",
|
||||
"Retirar %d passkeys"
|
||||
],
|
||||
"page.settings.webauthn.last_seen_on": "Último uso",
|
||||
"page.settings.webauthn.passkey_name": "Nome da Passkey",
|
||||
"page.settings.webauthn.passkeys": "Passkeys",
|
||||
"page.settings.webauthn.register": "Rexistrar Passkey",
|
||||
"page.settings.webauthn.register.error": "Non se puido rexistrar Passkey",
|
||||
"page.shared_entries.title": "Entradas compartidas",
|
||||
"page.shared_entries_count": [
|
||||
"%d entrada compartida",
|
||||
"%d entradas compartidas"
|
||||
],
|
||||
"page.starred.title": "Con estrela",
|
||||
"page.starred_entry_count": [
|
||||
"%d entrada con estrela",
|
||||
"%d entradas con estrela"
|
||||
],
|
||||
"page.total_entry_count": [
|
||||
"%d entrada en total",
|
||||
"%d entradas en total"
|
||||
],
|
||||
"page.unread.title": "Sen ler",
|
||||
"page.unread_entry_count": [
|
||||
"%d entrada sen ler",
|
||||
"%d entradas sen ler"
|
||||
],
|
||||
"page.users.actions": "Accións",
|
||||
"page.users.admin.no": "Non",
|
||||
"page.users.admin.yes": "Si",
|
||||
"page.users.is_admin": "Administración",
|
||||
"page.users.last_login": "Último acceso",
|
||||
"page.users.never_logged": "Nunca",
|
||||
"page.users.title": "Usuarias",
|
||||
"page.users.username": "Identificador",
|
||||
"page.webauthn_rename.title": "Cambiar nome a Passkey",
|
||||
"pagination.first": "Primeiro",
|
||||
"pagination.last": "Último",
|
||||
"pagination.next": "Seguinte",
|
||||
"pagination.previous": "Anterior",
|
||||
"search.label": "Buscar",
|
||||
"search.placeholder": "Buscar…",
|
||||
"search.submit": "Buscar",
|
||||
"skip_to_content": "Ir ao contido",
|
||||
"time_elapsed.days": [
|
||||
"hai %d día",
|
||||
"hai %d días"
|
||||
],
|
||||
"time_elapsed.hours": [
|
||||
"hai %d hora",
|
||||
"hai %d horas"
|
||||
],
|
||||
"time_elapsed.minutes": [
|
||||
"hai %d minuto",
|
||||
"hai %d minutos"
|
||||
],
|
||||
"time_elapsed.months": [
|
||||
"hai %d mes",
|
||||
"hai %d meses"
|
||||
],
|
||||
"time_elapsed.not_yet": "aínda non",
|
||||
"time_elapsed.now": "xusto agora",
|
||||
"time_elapsed.weeks": [
|
||||
"hai %d semana",
|
||||
"hai %d semanas"
|
||||
],
|
||||
"time_elapsed.years": [
|
||||
"hai %d ano",
|
||||
"hai %d anos"
|
||||
],
|
||||
"time_elapsed.yesterday": "onte",
|
||||
"tooltip.keyboard_shortcuts": "Atallo do teclado: %s",
|
||||
"tooltip.logged_user": "Sesión de %s"
|
||||
}
|
||||
@@ -109,14 +109,14 @@
|
||||
"error.http_empty_response": "HTTP प्रतिक्रिया खाली है। शायद यह वेबसाइट बॉट सुरक्षा तंत्र का उपयोग कर रही है?",
|
||||
"error.http_empty_response_body": "HTTP प्रतिक्रिया बॉडी खाली है।",
|
||||
"error.http_forbidden": "इस वेबसाइट तक पहुंच वर्जित है। शायद इस वेबसाइट में बॉट सुरक्षा तंत्र है?",
|
||||
"error.http_gateway_timeout": "The website is not available at the moment due to a gateway timeout error. The problem is not on Miniflux side. Please, try again later.",
|
||||
"error.http_internal_server_error": "The website is not available at the moment due to a server error. The problem is not on Miniflux side. Please, try again later.",
|
||||
"error.http_not_authorized": "Access to this website is not authorized. It could be a bad username or password.",
|
||||
"error.http_resource_not_found": "The requested resource is not found. Please, verify the URL.",
|
||||
"error.http_response_too_large": "The HTTP response is too large. You could increase the HTTP response size limit in the global settings (requires a server restart).",
|
||||
"error.http_service_unavailable": "The website is not available at the moment due to an internal server error. The problem is not on Miniflux side. Please, try again later.",
|
||||
"error.http_too_many_requests": "Miniflux generated too many requests to this website. Please, try again later or change the application configuration.",
|
||||
"error.http_unexpected_status_code": "The website is not available at the moment due to an unexpected HTTP status code: %d. The problem is not on Miniflux side. Please, try again later.",
|
||||
"error.http_gateway_timeout": "गेटवे टाइमआउट त्रुटि के कारण वेबसाइट फिलहाल उपलब्ध नहीं है। समस्या मिनीफ्लक्स की तरफ नहीं है। कृपया बाद में पुनः प्रयास करें।",
|
||||
"error.http_internal_server_error": "सर्वर त्रुटि के कारण वेबसाइट फिलहाल उपलब्ध नहीं है। समस्या मिनीफ्लक्स की तरफ नहीं है। कृपया बाद में पुनः प्रयास करें।",
|
||||
"error.http_not_authorized": "इस वेबसाइट तक पहुंच अधिकृत नहीं है। यह गलत उपयोगकर्ता नाम या पासवर्ड हो सकता है।",
|
||||
"error.http_resource_not_found": "अनुरोधित संसाधन नहीं मिला। कृपया यूआरएल सत्यापित करें।",
|
||||
"error.http_response_too_large": "HTTP प्रतिक्रिया बहुत बड़ी है। आप वैश्विक सेटिंग्स में HTTP प्रतिक्रिया आकार सीमा बढ़ा सकते हैं (सर्वर पुनःआरंभ आवश्यक)।",
|
||||
"error.http_service_unavailable": "आंतरिक सर्वर त्रुटि के कारण वेबसाइट फिलहाल उपलब्ध नहीं है। समस्या मिनीफ्लक्स की तरफ नहीं है। कृपया बाद में पुनः प्रयास करें।",
|
||||
"error.http_too_many_requests": "मिनीफ्लक्स ने इस वेबसाइट पर बहुत अधिक अनुरोध भेजे हैं। कृपया बाद में पुनः प्रयास करें या एप्लिकेशन कॉन्फ़िगरेशन बदलें।",
|
||||
"error.http_unexpected_status_code": "अप्रत्याशित HTTP स्थिति कोड %d के कारण वेबसाइट उपलब्ध नहीं है। समस्या मिनीफ्लक्स की तरफ नहीं है। कृपया बाद में पुनः प्रयास करें।",
|
||||
"error.invalid_categories_sorting_order": "अमान्य श्रेणी क्रम।",
|
||||
"error.invalid_default_home_page": "अमान्य डिफ़ॉल्ट मुखपृष्ठ!",
|
||||
"error.invalid_display_mode": "अमान्य वेब ऐप्लिकेशन प्रदर्शन मोड.",
|
||||
@@ -129,26 +129,26 @@
|
||||
"error.invalid_site_url": "अमान्य साइट यूआरएल",
|
||||
"error.invalid_theme": "अमान्य थीम.",
|
||||
"error.invalid_timezone": "अमान्य समयक्षेत्र.",
|
||||
"error.network_operation": "Miniflux is not able to reach this website due to a network error: %v.",
|
||||
"error.network_timeout": "This website is too slow and the request timed out: %v",
|
||||
"error.network_operation": "नेटवर्क त्रुटि के कारण मिनीफ्लक्स इस वेबसाइट तक नहीं पहुँच पा रहा: %v.",
|
||||
"error.network_timeout": "यह वेबसाइट बहुत धीमी है और अनुरोध का समय समाप्त हो गया: %v",
|
||||
"error.password_min_length": "पासवर्ड में कम से कम 6 अक्षर होने चाहिए।",
|
||||
"error.proxy_url_not_empty": "The proxy URL cannot be empty.",
|
||||
"error.settings_block_rule_fieldname_invalid": "Invalid Block rule: rule #%d is missing a valid field name (Options: %s)",
|
||||
"error.settings_block_rule_invalid_regex": "Invalid Block rule: rule #%d's pattern is not a valid regex",
|
||||
"error.settings_block_rule_regex_required": "Invalid Block rule: rule #%d's pattern is not provided",
|
||||
"error.settings_block_rule_separator_required": "Invalid Block rule: rule #%d's pattern is required to be seperated by a '='",
|
||||
"error.settings_invalid_domain_list": "Invalid domain list. Please provide a space separated list of domains.",
|
||||
"error.settings_keep_rule_fieldname_invalid": "Invalid Keep rule: rule #%d is missing a valid field name (Options: %s)",
|
||||
"error.settings_keep_rule_invalid_regex": "Invalid Keep rule: rule #%d's pattern is not a valid regex",
|
||||
"error.settings_keep_rule_regex_required": "Invalid Keep rule: rule #%d pattern is not provided",
|
||||
"error.settings_keep_rule_separator_required": "Invalid Keep rule: rule #%d's pattern is required to be seperated by a '='",
|
||||
"error.proxy_url_not_empty": "प्रॉक्सी यूआरएल खाली नहीं हो सकता।",
|
||||
"error.settings_block_rule_fieldname_invalid": "अमान्य ब्लॉक नियम: नियम #%d में मान्य फील्ड नाम नहीं है (विकल्प: %s)",
|
||||
"error.settings_block_rule_invalid_regex": "अमान्य ब्लॉक नियम: नियम #%d का पैटर्न मान्य रेगेक्स नहीं है",
|
||||
"error.settings_block_rule_regex_required": "अमान्य ब्लॉक नियम: नियम #%d का पैटर्न प्रदान नहीं किया गया",
|
||||
"error.settings_block_rule_separator_required": "अमान्य ब्लॉक नियम: नियम #%d के पैटर्न को '=' द्वारा अलग होना आवश्यक है",
|
||||
"error.settings_invalid_domain_list": "अमान्य डोमेन सूची। कृपया स्पेस से अलग किए गए डोमेन दें।",
|
||||
"error.settings_keep_rule_fieldname_invalid": "अमान्य रखने का नियम: नियम #%d में मान्य फील्ड नाम नहीं है (विकल्प: %s)",
|
||||
"error.settings_keep_rule_invalid_regex": "अमान्य रखने का नियम: नियम #%d का पैटर्न मान्य रेगेक्स नहीं है",
|
||||
"error.settings_keep_rule_regex_required": "अमान्य रखने का नियम: नियम #%d का पैटर्न नहीं दिया गया",
|
||||
"error.settings_keep_rule_separator_required": "अमान्य रखने का नियम: नियम #%d के पैटर्न को '=' से अलग किया जाना आवश्यक है",
|
||||
"error.settings_mandatory_fields": "उपयोगकर्ता नाम, विषयवस्तु, भाषा और समयक्षेत्र फ़ील्ड अनिवार्य हैं।",
|
||||
"error.settings_media_playback_rate_range": "प्लेबैक गति सीमा से बाहर है",
|
||||
"error.settings_reading_speed_is_positive": "पढ़ने की गति सकारात्मक पूर्णांक होनी चाहिए।",
|
||||
"error.site_url_not_empty": "साइट का यूआरएल खाली नहीं हो सकता.",
|
||||
"error.subscription_not_found": "कोई सदस्यता ढूँढने में असमर्थ.",
|
||||
"error.title_required": "शीर्षक अनिवार्य है।",
|
||||
"error.tls_error": "TLS error: %q. You could disable TLS verification in the feed settings if you would like.",
|
||||
"error.tls_error": "TLS त्रुटि: %q. यदि आप चाहें तो फ़ीड सेटिंग्स में TLS सत्यापन अक्षम कर सकते हैं।",
|
||||
"error.unable_to_create_api_key": "यह एपीआई कुंजी बनाने में असमर्थ।",
|
||||
"error.unable_to_create_category": "यह श्रेणी बनाने में असमर्थ.",
|
||||
"error.unable_to_create_user": "इस उपयोगकर्ता को बनाने में असमर्थ।",
|
||||
@@ -164,19 +164,20 @@
|
||||
"form.api_key.label.description": "एपीआई कुंजी लेबल",
|
||||
"form.category.hide_globally": "वैश्विक अपठित सूची में प्रविष्टियां छिपाएं",
|
||||
"form.category.label.title": "शीर्षक",
|
||||
"form.feed.fieldset.general": "General",
|
||||
"form.feed.fieldset.integration": "Third-Party Services",
|
||||
"form.feed.fieldset.network_settings": "Network Settings",
|
||||
"form.feed.fieldset.rules": "Rules",
|
||||
"form.feed.fieldset.general": "सामान्य",
|
||||
"form.feed.fieldset.integration": "तृतीय-पक्ष सेवाएँ",
|
||||
"form.feed.fieldset.network_settings": "नेटवर्क सेटिंग्स",
|
||||
"form.feed.fieldset.rules": "नियम",
|
||||
"form.feed.label.allow_self_signed_certificates": "स्व-हस्ताक्षरित या अमान्य प्रमाणपत्रों की अनुमति दें",
|
||||
"form.feed.label.apprise_service_urls": "Comma separated list of Apprise service URLs",
|
||||
"form.feed.label.apprise_service_urls": "Apprise सेवा URL की कॉमा से अलग सूची",
|
||||
"form.feed.label.block_filter_entry_rules": "प्रविष्टि अवरोधन नियम",
|
||||
"form.feed.label.blocklist_rules": "रेगेक्स-आधारित अवरोधन फिल्टर",
|
||||
"form.feed.label.category": "श्रेणी",
|
||||
"form.feed.label.cookie": "कुकीज़ सेट करें",
|
||||
"form.feed.label.crawler": "मूल सामग्री प्राप्त करें",
|
||||
"form.feed.label.ignore_entry_updates": "Ignore entry updates",
|
||||
"form.feed.label.description": "विवरण",
|
||||
"form.feed.label.disable_http2": "Disable HTTP/2 to avoid fingerprinting",
|
||||
"form.feed.label.disable_http2": "फिंगरप्रिंटिंग से बचने के लिए HTTP/2 अक्षम करें",
|
||||
"form.feed.label.disabled": "इस फ़ीड को रीफ़्रेश न करें",
|
||||
"form.feed.label.feed_password": "फ़ीड पासवर्ड",
|
||||
"form.feed.label.feed_url": "फ़ीड यूआरएल",
|
||||
@@ -186,43 +187,43 @@
|
||||
"form.feed.label.ignore_http_cache": "एचटीटीपी कैश पर ध्यान न दें",
|
||||
"form.feed.label.keep_filter_entry_rules": "प्रविष्टि अनुमति नियम",
|
||||
"form.feed.label.keeplist_rules": "रेगेक्स-आधारित रखने वाले फिल्टर",
|
||||
"form.feed.label.no_media_player": "No media player (audio/video)",
|
||||
"form.feed.label.ntfy_activate": "Push entries to ntfy",
|
||||
"form.feed.label.ntfy_default_priority": "Ntfy default priority",
|
||||
"form.feed.label.ntfy_high_priority": "Ntfy high priority",
|
||||
"form.feed.label.ntfy_low_priority": "Ntfy low priority",
|
||||
"form.feed.label.ntfy_max_priority": "Ntfy max priority",
|
||||
"form.feed.label.ntfy_min_priority": "Ntfy min priority",
|
||||
"form.feed.label.ntfy_priority": "Ntfy priority",
|
||||
"form.feed.label.ntfy_topic": "Ntfy topic (optional)",
|
||||
"form.feed.label.proxy_url": "Proxy URL",
|
||||
"form.feed.label.pushover_activate": "Push entries to pushover.net",
|
||||
"form.feed.label.pushover_default_priority": "Pushover default priority",
|
||||
"form.feed.label.pushover_high_priority": "Pushover high priority",
|
||||
"form.feed.label.pushover_low_priority": "Pushover low priority",
|
||||
"form.feed.label.pushover_max_priority": "Pushover max priority",
|
||||
"form.feed.label.pushover_min_priority": "Pushover min priority",
|
||||
"form.feed.label.pushover_priority": "Pushover message priority",
|
||||
"form.feed.label.no_media_player": "कोई मीडिया प्लेयर नहीं (ऑडियो/वीडियो)",
|
||||
"form.feed.label.ntfy_activate": "प्रविष्टियाँ ntfy पर भेजें",
|
||||
"form.feed.label.ntfy_default_priority": "Ntfy डिफ़ॉल्ट प्राथमिकता",
|
||||
"form.feed.label.ntfy_high_priority": "Ntfy उच्च प्राथमिकता",
|
||||
"form.feed.label.ntfy_low_priority": "Ntfy निम्न प्राथमिकता",
|
||||
"form.feed.label.ntfy_max_priority": "Ntfy अधिकतम प्राथमिकता",
|
||||
"form.feed.label.ntfy_min_priority": "Ntfy न्यूनतम प्राथमिकता",
|
||||
"form.feed.label.ntfy_priority": "Ntfy प्राथमिकता",
|
||||
"form.feed.label.ntfy_topic": "Ntfy विषय (वैकल्पिक)",
|
||||
"form.feed.label.proxy_url": "प्रॉक्सी URL",
|
||||
"form.feed.label.pushover_activate": "प्रविष्टियाँ pushover.net पर भेजें",
|
||||
"form.feed.label.pushover_default_priority": "Pushover डिफ़ॉल्ट प्राथमिकता",
|
||||
"form.feed.label.pushover_high_priority": "Pushover उच्च प्राथमिकता",
|
||||
"form.feed.label.pushover_low_priority": "Pushover निम्न प्राथमिकता",
|
||||
"form.feed.label.pushover_max_priority": "Pushover अधिकतम प्राथमिकता",
|
||||
"form.feed.label.pushover_min_priority": "Pushover न्यूनतम प्राथमिकता",
|
||||
"form.feed.label.pushover_priority": "Pushover संदेश प्राथमिकता",
|
||||
"form.feed.label.rewrite_rules": "सामग्री पुनर्लेखन नियम",
|
||||
"form.feed.label.scraper_rules": "खुरचनी नियम",
|
||||
"form.feed.label.site_url": "साइट यूआरएल",
|
||||
"form.feed.label.title": "शीर्षक",
|
||||
"form.feed.label.urlrewrite_rules": " यूआरएल पुनर्लेखन नियम",
|
||||
"form.feed.label.user_agent": "डिफ़ॉल्ट उपयोगकर्ता एजेंट को ओवरराइड करें",
|
||||
"form.feed.label.webhook_url": "Override webhook url",
|
||||
"form.feed.label.webhook_url": "वेबहुक URL को अधिलेखित करें",
|
||||
"form.import.label.file": "ओपीएमएल फ़ाइल",
|
||||
"form.import.label.url": "यूआरएल",
|
||||
"form.integration.archiveorg_activate": "प्रविष्टियों को archive.org पर भेजें",
|
||||
"form.integration.apprise_activate": "Push entries to Apprise",
|
||||
"form.integration.apprise_services_url": "Comma separated list of Apprise service URLs",
|
||||
"form.integration.apprise_url": "Apprise API URL",
|
||||
"form.integration.betula_activate": "Save entries to Betula",
|
||||
"form.integration.betula_token": "Betula Token",
|
||||
"form.integration.betula_url": "Betula server URL",
|
||||
"form.integration.cubox_activate": "Save entries to Cubox",
|
||||
"form.integration.cubox_api_link": "Cubox API link",
|
||||
"form.integration.discord_activate": "Push entries to Discord",
|
||||
"form.integration.discord_webhook_link": "Discord Webhook link",
|
||||
"form.integration.apprise_activate": "प्रविष्टियाँ Apprise पर भेजें",
|
||||
"form.integration.apprise_services_url": "Apprise सेवा URLs की कॉमा से पृथक सूची",
|
||||
"form.integration.apprise_url": "Apprise API यूआरएल",
|
||||
"form.integration.betula_activate": "प्रविष्टियाँ Betula में सहेजें",
|
||||
"form.integration.betula_token": "Betula टोकन",
|
||||
"form.integration.betula_url": "Betula सर्वर URL",
|
||||
"form.integration.cubox_activate": "प्रविष्टियाँ Cubox में सहेजें",
|
||||
"form.integration.cubox_api_link": "Cubox API लिंक",
|
||||
"form.integration.discord_activate": "प्रविष्टियाँ Discord पर भेजें",
|
||||
"form.integration.discord_webhook_link": "Discord वेबहुक लिंक",
|
||||
"form.integration.espial_activate": "विषय-वस्तु को जासूसी में सहेजें",
|
||||
"form.integration.espial_api_key": "जासूसी एपीआई कुंजी",
|
||||
"form.integration.espial_endpoint": "जासूसी एपीआई समापन बिंदु",
|
||||
@@ -238,96 +239,98 @@
|
||||
"form.integration.instapaper_activate": "विषय-वस्तु को इंस्टापेपर में सहेजें",
|
||||
"form.integration.instapaper_password": "इंस्टापेपर पासवर्ड",
|
||||
"form.integration.instapaper_username": "इंस्टापेपर यूजरनेम",
|
||||
"form.integration.karakeep_activate": "Save entries to Karakeep",
|
||||
"form.integration.karakeep_api_key": "Karakeep API key",
|
||||
"form.integration.karakeep_url": "Karakeep API Endpoint",
|
||||
"form.integration.karakeep_tags": "Karakeep Labels",
|
||||
"form.integration.linkace_activate": "Save entries to LinkAce",
|
||||
"form.integration.linkace_api_key": "LinkAce API key",
|
||||
"form.integration.linkace_check_disabled": "Disable link check",
|
||||
"form.integration.linkace_endpoint": "LinkAce API Endpoint",
|
||||
"form.integration.linkace_is_private": "Mark link as private",
|
||||
"form.integration.linkace_tags": "LinkAce Tags",
|
||||
"form.integration.karakeep_activate": "प्रविष्टियाँ Karakeep में सहेजें",
|
||||
"form.integration.karakeep_api_key": "Karakeep API कुंजी",
|
||||
"form.integration.karakeep_url": "Karakeep API समापन बिंदु",
|
||||
"form.integration.karakeep_tags": "Karakeep लेबल",
|
||||
"form.integration.linkace_activate": "प्रविष्टियाँ LinkAce में सहेजें",
|
||||
"form.integration.linkace_api_key": "LinkAce API कुंजी",
|
||||
"form.integration.linkace_check_disabled": "लिंक जांच अक्षम करें",
|
||||
"form.integration.linkace_endpoint": "LinkAce API समापन बिंदु",
|
||||
"form.integration.linkace_is_private": "लिंक को निजी चिह्नित करें",
|
||||
"form.integration.linkace_tags": "LinkAce टैग",
|
||||
"form.integration.linkding_activate": "लिंक्डिन में विषयवस्तु सहेजें",
|
||||
"form.integration.linkding_api_key": "लिंकिंग एपीआई कुंजी",
|
||||
"form.integration.linkding_bookmark": "बुकमार्क को अपठित के रूप में चिह्नित करें",
|
||||
"form.integration.linkding_endpoint": "लिंकिंग एपीआई समापन बिंदु",
|
||||
"form.integration.linkding_tags": "Linkding Tags",
|
||||
"form.integration.linkding_tags": "Linkding टैग",
|
||||
"form.integration.linktaco_activate": "LinkTaco में प्रविष्टियाँ सहेजें",
|
||||
"form.integration.linktaco_api_token": "LinkTaco API Token",
|
||||
"form.integration.linktaco_api_token": "LinkTaco API टोकन",
|
||||
"form.integration.linktaco_api_token_hint": "अपना व्यक्तिगत पहुँच टोकन प्राप्त करें",
|
||||
"form.integration.linktaco_org_slug": "Organization Slug",
|
||||
"form.integration.linktaco_org_slug": "संगठन स्लग",
|
||||
"form.integration.linktaco_tags": "टैग (अधिकतम 10, कॉमा से अलग किए गए)",
|
||||
"form.integration.linktaco_tags_hint": "अधिकतम 10 टैग, कॉमा से अलग किए गए",
|
||||
"form.integration.linktaco_visibility": "दृश्यता",
|
||||
"form.integration.linktaco_visibility_public": "सार्वजनिक",
|
||||
"form.integration.linktaco_visibility_private": "निजी",
|
||||
"form.integration.linktaco_visibility_hint": "निजी दृश्यता के लिए भुगतान LinkTaco खाता आवश्यक है",
|
||||
"form.integration.linkwarden_activate": "Save entries to Linkwarden",
|
||||
"form.integration.linkwarden_api_key": "Linkwarden API key",
|
||||
"form.integration.linkwarden_endpoint": "लिंकवर्डन बेस यूआरएलL",
|
||||
"form.integration.linkwarden_activate": "प्रविष्टियाँ Linkwarden में सहेजें",
|
||||
"form.integration.linkwarden_api_key": "Linkwarden API कुंजी",
|
||||
"form.integration.linkwarden_endpoint": "Linkwarden बेस URL",
|
||||
"form.integration.linkwarden_collection_id": "Linkwarden संग्रह ID",
|
||||
"form.integration.matrix_bot_activate": "नए लेखों को मैट्रिक्स में स्थानांतरित करें",
|
||||
"form.integration.matrix_bot_chat_id": "मैट्रिक्स रूम की आईडी",
|
||||
"form.integration.matrix_bot_password": "मैट्रिक्स उपयोगकर्ता के लिए पासवर्ड",
|
||||
"form.integration.matrix_bot_url": "मैट्रिक्स सर्वर URL",
|
||||
"form.integration.matrix_bot_user": "मैट्रिक्स के लिए उपयोगकर्ता नाम",
|
||||
"form.integration.notion_activate": "Save entries to Notion",
|
||||
"form.integration.notion_page_id": "Notion Page ID",
|
||||
"form.integration.notion_token": "Notion Secret Token",
|
||||
"form.integration.ntfy_activate": "Push entries to ntfy",
|
||||
"form.integration.ntfy_api_token": "Ntfy API Token (optional)",
|
||||
"form.integration.ntfy_icon_url": "Ntfy Icon URL (optional)",
|
||||
"form.integration.ntfy_internal_links": "Use internal links on click (optional)",
|
||||
"form.integration.ntfy_password": "Ntfy Password (optional)",
|
||||
"form.integration.ntfy_topic": "Ntfy topic (default used if not set in feed)",
|
||||
"form.integration.ntfy_url": "Ntfy URL (optional, default is ntfy.sh)",
|
||||
"form.integration.ntfy_username": "Ntfy Username (optional)",
|
||||
"form.integration.notion_activate": "प्रविष्टियाँ Notion में सहेजें",
|
||||
"form.integration.notion_page_id": "Notion पेज ID",
|
||||
"form.integration.notion_token": "Notion गुप्त टोकन",
|
||||
"form.integration.ntfy_activate": "प्रविष्टियाँ ntfy पर भेजें",
|
||||
"form.integration.ntfy_api_token": "Ntfy API टोकन (वैकल्पिक)",
|
||||
"form.integration.ntfy_icon_url": "Ntfy आइकन URL (वैकल्पिक)",
|
||||
"form.integration.ntfy_internal_links": "क्लिक पर आंतरिक लिंक इस्तेमाल करें (वैकल्पिक)",
|
||||
"form.integration.ntfy_password": "Ntfy पासवर्ड (वैकल्पिक)",
|
||||
"form.integration.ntfy_topic": "Ntfy विषय (यदि फ़ीड में नहीं सेट है तो डिफ़ॉल्ट)",
|
||||
"form.integration.ntfy_url": "Ntfy URL (वैकल्पिक, डिफ़ॉल्ट ntfy.sh)",
|
||||
"form.integration.ntfy_username": "Ntfy उपयोगकर्ता नाम (वैकल्पिक)",
|
||||
"form.integration.nunux_keeper_activate": "विषय-वस्तु को ननक्स कीपर में सहेजें",
|
||||
"form.integration.nunux_keeper_api_key": "ननक्स कीपर एपीआई कुंजी",
|
||||
"form.integration.nunux_keeper_endpoint": "ननक्स कीपर एपीआई समापन बिंदु",
|
||||
"form.integration.omnivore_activate": "Save entries to Omnivore",
|
||||
"form.integration.omnivore_api_key": "Omnivore API key",
|
||||
"form.integration.omnivore_url": "Omnivore API Endpoint",
|
||||
"form.integration.omnivore_activate": "प्रविष्टियाँ Omnivore में सहेजें",
|
||||
"form.integration.omnivore_api_key": "Omnivore API कुंजी",
|
||||
"form.integration.omnivore_url": "Omnivore API समापन बिंदु",
|
||||
"form.integration.pinboard_activate": "सहेजें विषयवस्तु प्रति का बोर्ड ",
|
||||
"form.integration.pinboard_bookmark": "बुकमार्क को अपठित के रूप में चिह्नित करें",
|
||||
"form.integration.pinboard_tags": "पिनबोर्ड टैग",
|
||||
"form.integration.pinboard_token": "पिनबोर्ड एपीआई टोकन",
|
||||
"form.integration.pushover_activate": "Push entries to Pushover",
|
||||
"form.integration.pushover_device": "Pushover device (optional)",
|
||||
"form.integration.pushover_prefix": "Pushover URL prefix (optional)",
|
||||
"form.integration.pushover_token": "Pushover application API token",
|
||||
"form.integration.pushover_user": "Pushover user key",
|
||||
"form.integration.raindrop_activate": "Save entries to Raindrop",
|
||||
"form.integration.raindrop_collection_id": "Collection ID",
|
||||
"form.integration.raindrop_tags": "Tags (comma-separated)",
|
||||
"form.integration.raindrop_token": "(Test) Token",
|
||||
"form.integration.pushover_activate": "प्रविष्टियाँ Pushover पर भेजें",
|
||||
"form.integration.pushover_device": "Pushover डिवाइस (वैकल्पिक)",
|
||||
"form.integration.pushover_prefix": "Pushover URL उपसर्ग (वैकल्पिक)",
|
||||
"form.integration.pushover_token": "Pushover ऐप्लिकेशन API टोकन",
|
||||
"form.integration.pushover_user": "Pushover उपयोगकर्ता कुंजी",
|
||||
"form.integration.raindrop_activate": "प्रविष्टियाँ Raindrop में सहेजें",
|
||||
"form.integration.raindrop_collection_id": "संग्रह ID",
|
||||
"form.integration.raindrop_tags": "टैग (कॉमा से अलग)",
|
||||
"form.integration.raindrop_token": "(टेस्ट) टोकन",
|
||||
"form.integration.readeck_activate": "Readeck में विषयवस्तु सहेजें",
|
||||
"form.integration.readeck_api_key": "Readeck एपीआई कुंजी",
|
||||
"form.integration.readeck_endpoint": "Readeck यूआरएल",
|
||||
"form.integration.readeck_labels": "Readeck Labels",
|
||||
"form.integration.readeck_labels": "Readeck लेबल",
|
||||
"form.integration.readeck_only_url": "केवल URL भेजें (पूर्ण सामग्री के बजाय)",
|
||||
"form.integration.readwise_activate": "Save entries to Readwise Reader",
|
||||
"form.integration.readwise_api_key": "Readwise Reader Access Token",
|
||||
"form.integration.readwise_api_key_link": "Get your Readwise Access Token",
|
||||
"form.integration.rssbridge_activate": "Check RSS-Bridge when adding subscriptions",
|
||||
"form.integration.rssbridge_token": "RSS-Bridge authentication token",
|
||||
"form.integration.rssbridge_url": "RSS-Bridge server URL",
|
||||
"form.integration.shaarli_activate": "Save articles to Shaarli",
|
||||
"form.integration.shaarli_api_secret": "Shaarli API Secret",
|
||||
"form.integration.shaarli_endpoint": "Shaarli URL",
|
||||
"form.integration.shiori_activate": "Save articles to Shiori",
|
||||
"form.integration.shiori_endpoint": "Shiori API Endpoint",
|
||||
"form.integration.shiori_password": "Shiori Password",
|
||||
"form.integration.shiori_username": "Shiori Username",
|
||||
"form.integration.slack_activate": "Push entries to Slack",
|
||||
"form.integration.slack_webhook_link": "Slack Webhook link",
|
||||
"form.integration.readeck_push_activate": "नई प्रविष्टियाँ स्वतः Readeck पर भेजें",
|
||||
"form.integration.readwise_activate": "प्रविष्टियाँ Readwise Reader में सहेजें",
|
||||
"form.integration.readwise_api_key": "Readwise Reader एक्सेस टोकन",
|
||||
"form.integration.readwise_api_key_link": "अपना Readwise एक्सेस टोकन प्राप्त करें",
|
||||
"form.integration.rssbridge_activate": "सदस्यता जोड़ते समय RSS-Bridge जांचें",
|
||||
"form.integration.rssbridge_token": "RSS-Bridge प्रमाणीकरण टोकन",
|
||||
"form.integration.rssbridge_url": "RSS-Bridge सर्वर URL",
|
||||
"form.integration.shaarli_activate": "लेखों को Shaarli में सहेजें",
|
||||
"form.integration.shaarli_api_secret": "Shaarli API रहस्य",
|
||||
"form.integration.shaarli_endpoint": "Shaarli यूआरएल",
|
||||
"form.integration.shiori_activate": "लेखों को Shiori में सहेजें",
|
||||
"form.integration.shiori_endpoint": "Shiori API समापन बिंदु",
|
||||
"form.integration.shiori_password": "Shiori पासवर्ड",
|
||||
"form.integration.shiori_username": "Shiori उपयोगकर्ता नाम",
|
||||
"form.integration.slack_activate": "प्रविष्टियाँ Slack पर भेजें",
|
||||
"form.integration.slack_webhook_link": "Slack वेबहुक लिंक",
|
||||
"form.integration.telegram_bot_activate": "टेलीग्राम चैट के लिए नई विषय-कविता पुश करें",
|
||||
"form.integration.telegram_bot_disable_buttons": "Disable buttons",
|
||||
"form.integration.telegram_bot_disable_notification": "Disable notification",
|
||||
"form.integration.telegram_bot_disable_web_page_preview": "Disable web page preview",
|
||||
"form.integration.telegram_bot_disable_buttons": "बटन अक्षम करें",
|
||||
"form.integration.telegram_bot_disable_notification": "सूचनाएँ अक्षम करें",
|
||||
"form.integration.telegram_bot_disable_web_page_preview": "वेब पृष्ठ पूर्वावलोकन अक्षम करें",
|
||||
"form.integration.telegram_bot_token": "बॉट टोकन",
|
||||
"form.integration.telegram_chat_id": "चैट आईडी",
|
||||
"form.integration.telegram_topic_id": "Topic ID",
|
||||
"form.integration.telegram_topic_id": "टॉपिक ID",
|
||||
"form.integration.wallabag_activate": "विषय सहेजें वालाबाग में ",
|
||||
"form.integration.wallabag_client_id": "वालाबैग क्लाइंट आईडी",
|
||||
"form.integration.wallabag_client_secret": "वालाबैग क्लाइंट सीक्रेट",
|
||||
@@ -335,16 +338,16 @@
|
||||
"form.integration.wallabag_only_url": "केवल URL भेजें (पूर्ण सामग्री के बजाय)",
|
||||
"form.integration.wallabag_password": "वालाबैग पासवर्ड",
|
||||
"form.integration.wallabag_username": "वालाबैग उपयोगकर्ता नाम",
|
||||
"form.integration.wallabag_tags": "Wallabag Tags",
|
||||
"form.integration.webhook_activate": "Enable Webhooks",
|
||||
"form.integration.webhook_secret": "Webhooks Secret",
|
||||
"form.integration.webhook_url": "Default Webhook URL",
|
||||
"form.prefs.fieldset.application_settings": "Application Settings",
|
||||
"form.prefs.fieldset.authentication_settings": "Authentication Settings",
|
||||
"form.prefs.fieldset.global_feed_settings": "Global Feed Settings",
|
||||
"form.prefs.fieldset.reader_settings": "Reader Settings",
|
||||
"form.prefs.help.external_font_hosts": "Space separated list of external font hosts to allow. For example: \"fonts.gstatic.com fonts.googleapis.com\".",
|
||||
"form.prefs.label.always_open_external_links": "Read articles by opening external links",
|
||||
"form.integration.wallabag_tags": "Wallabag टैग",
|
||||
"form.integration.webhook_activate": "वेबहुक सक्षम करें",
|
||||
"form.integration.webhook_secret": "वेबहुक रहस्य",
|
||||
"form.integration.webhook_url": "डिफ़ॉल्ट वेबहुक URL",
|
||||
"form.prefs.fieldset.application_settings": "एप्लिकेशन सेटिंग्स",
|
||||
"form.prefs.fieldset.authentication_settings": "प्रमाणीकरण सेटिंग्स",
|
||||
"form.prefs.fieldset.global_feed_settings": "वैश्विक फ़ीड सेटिंग्स",
|
||||
"form.prefs.fieldset.reader_settings": "रीडर सेटिंग्स",
|
||||
"form.prefs.help.external_font_hosts": "अनुमति प्राप्त बाहरी फ़ॉन्ट होस्ट की सूची (स्पेस से पृथक). उदाहरण: \"fonts.gstatic.com fonts.googleapis.com\".",
|
||||
"form.prefs.label.always_open_external_links": "बाहरी लिंक खोलकर लेख पढ़ें",
|
||||
"form.prefs.label.categories_sorting_order": "श्रेणियाँ छँटाई",
|
||||
"form.prefs.label.cjk_reading_speed": "चीनी, कोरियाई और जापानी के लिए पढ़ने की गति (प्रति मिनट वर्ण)",
|
||||
"form.prefs.label.custom_css": "कस्टम सीएसएस",
|
||||
@@ -356,14 +359,14 @@
|
||||
"form.prefs.label.entry_order": "प्रवेश छँटाई कॉलम",
|
||||
"form.prefs.label.entry_sorting": "प्रवेश छँटाई",
|
||||
"form.prefs.label.entry_swipe": "टच स्क्रीन पर एंट्री स्वाइप सक्षम करें",
|
||||
"form.prefs.label.external_font_hosts": "External font hosts",
|
||||
"form.prefs.label.external_font_hosts": "बाहरी फ़ॉन्ट होस्ट",
|
||||
"form.prefs.label.gesture_nav": "प्रविष्टियों के बीच नेविगेट करने के लिए इशारा",
|
||||
"form.prefs.label.keyboard_shortcuts": "कीबोर्ड शॉर्टकट सक्षम करें",
|
||||
"form.prefs.label.language": "भाषाओं",
|
||||
"form.prefs.label.mark_read_manually": "Mark entries as read manually",
|
||||
"form.prefs.label.mark_read_on_media_completion": "Only mark as read when audio/video playback reaches 90%% completion",
|
||||
"form.prefs.label.mark_read_manually": "प्रविष्टियों को मैन्युअल रूप से पढ़ा हुआ चिह्नित करें",
|
||||
"form.prefs.label.mark_read_on_media_completion": "केवल तब पढ़ा हुआ चिह्नित करें जब ऑडियो/वीडियो का 90%% चल चुका हो",
|
||||
"form.prefs.label.mark_read_on_view": "देखे जाने पर स्वचालित रूप से प्रविष्टियों को पढ़ने के रूप में चिह्नित करें",
|
||||
"form.prefs.label.mark_read_on_view_or_media_completion": "Mark entries as read when viewed. For audio/video, mark as read at 90%% completion",
|
||||
"form.prefs.label.mark_read_on_view_or_media_completion": "देखने पर पढ़ा हुआ चिह्नित करें; ऑडियो/वीडियो 90%% पर पढ़ा हुआ करें",
|
||||
"form.prefs.label.media_playback_rate": "ऑडियो/वीडियो की प्लेबैक गति",
|
||||
"form.prefs.label.open_external_links_in_new_tab": "बाहरी लिंक को एक नए टैब में खोलें (लिंक में target=\"_blank\" जोड़ता है)",
|
||||
"form.prefs.label.show_reading_time": "विषय के लिए अनुमानित पढ़ने का समय दिखाएं",
|
||||
@@ -402,7 +405,7 @@
|
||||
"menu.feeds": "फ़ीड",
|
||||
"menu.flush_history": "इतिहास मिटाएँ",
|
||||
"menu.history": "इतिहास",
|
||||
"menu.home_page": "Home page",
|
||||
"menu.home_page": "मुखपृष्ठ",
|
||||
"menu.import": "आयात करे",
|
||||
"menu.integrations": "एकीकरण",
|
||||
"menu.logout": "लॉग आउट",
|
||||
@@ -416,17 +419,17 @@
|
||||
"menu.settings": "समायोजन",
|
||||
"menu.shared_entries": "साझा प्रविष्टियां",
|
||||
"menu.show_all_entries": "सभी प्रविष्टियाँ दिखाए",
|
||||
"menu.show_only_starred_entries": "Show only starred entries",
|
||||
"menu.show_only_starred_entries": "केवल पसंदीदा प्रविष्टियाँ दिखाएं",
|
||||
"menu.show_only_unread_entries": "सभी अपठित प्रविष्टियाँ दिखाए",
|
||||
"menu.starred": "तारांकित",
|
||||
"menu.title": "Menu",
|
||||
"menu.title": "मेनू",
|
||||
"menu.unread": "अपठित",
|
||||
"menu.users": "उपयोगकर्ताओं",
|
||||
"page.about.author": "रचयिता:",
|
||||
"page.about.build_date": "बनाने की तिथि:",
|
||||
"page.about.credits": "आभार सूची",
|
||||
"page.about.db_usage": "Database size:",
|
||||
"page.about.git_commit": "Git Commit:",
|
||||
"page.about.db_usage": "डेटाबेस आकार:",
|
||||
"page.about.git_commit": "Git कमिट:",
|
||||
"page.about.global_config_options": "वैश्विक विन्यास विकल्प",
|
||||
"page.about.go_version": "गो संस्करण:",
|
||||
"page.about.license": "अनुज्ञा:",
|
||||
@@ -455,10 +458,10 @@
|
||||
"page.categories.no_feed": "कोई फ़ीड नहीं है।",
|
||||
"page.categories.title": "श्रेणियाँ",
|
||||
"page.categories_count": [
|
||||
"%d category",
|
||||
"%d categories"
|
||||
"%d श्रेणी",
|
||||
"%d श्रेणियाँ"
|
||||
],
|
||||
"page.category_label": "Category: %s",
|
||||
"page.category_label": "श्रेणी: %s",
|
||||
"page.edit_category.title": "%s श्रेणी संपाद करे",
|
||||
"page.edit_feed.etag_header": "ईटाग हैडर:",
|
||||
"page.edit_feed.last_check": "अंतिम जांच:",
|
||||
@@ -473,10 +476,10 @@
|
||||
"%d समस्याए"
|
||||
],
|
||||
"page.feeds.last_check": "आखरी जाँच",
|
||||
"page.feeds.next_check": "Next check:",
|
||||
"page.feeds.next_check": "अगली जाँच:",
|
||||
"page.feeds.read_counter": "पड़े हुए विषयवस्तुया",
|
||||
"page.feeds.title": "फ़ीड",
|
||||
"page.footer.elevator": "Back to top",
|
||||
"page.footer.elevator": "ऊपर जाएँ",
|
||||
"page.history.title": "इतिहास",
|
||||
"page.import.title": "आयात",
|
||||
"page.integration.bookmarklet": "बुकमार्कलेट",
|
||||
@@ -522,7 +525,7 @@
|
||||
"page.keyboard_shortcuts.subtitle.sections": "अनुभाग नेविगेशन",
|
||||
"page.keyboard_shortcuts.title": "कुंजीपटल अल्प मार्ग",
|
||||
"page.keyboard_shortcuts.toggle_star_status": "बुकमार्क टॉगल करें",
|
||||
"page.keyboard_shortcuts.toggle_entry_attachments": "Toggle open/close entry attachments",
|
||||
"page.keyboard_shortcuts.toggle_entry_attachments": "प्रविष्टि संलग्नक खोलें/बंद करें",
|
||||
"page.keyboard_shortcuts.toggle_read_status_next": "पढ़ें/अपठित टॉगल करें, अगला फ़ोकस करें",
|
||||
"page.keyboard_shortcuts.toggle_read_status_prev": "पढ़ें/अपठित टॉगल करें, पिछला फ़ोकस करें",
|
||||
"page.login.google_signin": "गूगल के साथ साइन इन करें",
|
||||
@@ -530,7 +533,7 @@
|
||||
"page.login.title": "साइन इन करें",
|
||||
"page.login.webauthn_login": "पासकी से लॉगिन करें",
|
||||
"page.login.webauthn_login.error": "पासकी से लॉगिन करने में असमर्थ",
|
||||
"page.login.webauthn_login.help": "Please enter your username if you're using a security key. This is not required if you are using a Passkey (discoverable credentials).",
|
||||
"page.login.webauthn_login.help": "यदि आप सुरक्षा कुंजी का उपयोग कर रहे हैं तो कृपया अपना उपयोगकर्ता नाम दर्ज करें। पासकी (discoverable credentials) के लिए यह आवश्यक नहीं है।",
|
||||
"page.new_api_key.title": "नई एपीआई कुंजी",
|
||||
"page.new_category.title": "नया श्रेणी",
|
||||
"page.new_user.title": "नया उपभोक्ता",
|
||||
@@ -538,8 +541,8 @@
|
||||
"page.offline.refresh_page": "पृष्ठ को ताज़ा करने का प्रयास करें",
|
||||
"page.offline.title": "ऑफ़लाइन मोड",
|
||||
"page.read_entry_count": [
|
||||
"%d read entry",
|
||||
"%d read entries"
|
||||
"%d पढ़ी गई प्रविष्टि",
|
||||
"%d पढ़ी गई प्रविष्टियाँ"
|
||||
],
|
||||
"page.search.title": "खोज का परिणाम",
|
||||
"page.sessions.table.actions": "कार्रवाई",
|
||||
@@ -553,35 +556,35 @@
|
||||
"page.settings.title": "समायोजन",
|
||||
"page.settings.unlink_google_account": "मेरा गूगल खाता हटाय",
|
||||
"page.settings.unlink_oidc_account": "मेरा ओपन-ईद खाता हटाय (%s)",
|
||||
"page.settings.webauthn.actions": "Actions",
|
||||
"page.settings.webauthn.added_on": "Added On",
|
||||
"page.settings.webauthn.actions": "कार्रवाई",
|
||||
"page.settings.webauthn.added_on": "जोड़ा गया",
|
||||
"page.settings.webauthn.delete": [
|
||||
"%d पासकुंजी निकालें",
|
||||
"%d पासकी हटाएं"
|
||||
],
|
||||
"page.settings.webauthn.last_seen_on": "Last Used",
|
||||
"page.settings.webauthn.passkey_name": "Passkey Name",
|
||||
"page.settings.webauthn.passkeys": "Passkeys",
|
||||
"page.settings.webauthn.last_seen_on": "अंतिम उपयोग",
|
||||
"page.settings.webauthn.passkey_name": "पासकी का नाम",
|
||||
"page.settings.webauthn.passkeys": "पासकी",
|
||||
"page.settings.webauthn.register": "रजिस्टर पासकी",
|
||||
"page.settings.webauthn.register.error": "पासकी पंजीकृत करने में असमर्थ",
|
||||
"page.shared_entries.title": "साझा किया हुआ प्रविष्टि",
|
||||
"page.shared_entries_count": [
|
||||
"%d shared entry",
|
||||
"%d shared entries"
|
||||
"%d साझा प्रविष्टि",
|
||||
"%d साझा प्रविष्टियाँ"
|
||||
],
|
||||
"page.starred.title": "तारांकित",
|
||||
"page.starred_entry_count": [
|
||||
"%d starred entry",
|
||||
"%d starred entries"
|
||||
"%d तारांकित प्रविष्टि",
|
||||
"%d तारांकित प्रविष्टियाँ"
|
||||
],
|
||||
"page.total_entry_count": [
|
||||
"%d entry in total",
|
||||
"%d entries in total"
|
||||
"कुल %d प्रविष्टि",
|
||||
"कुल %d प्रविष्टियाँ"
|
||||
],
|
||||
"page.unread.title": "अपठित",
|
||||
"page.unread_entry_count": [
|
||||
"%d unread entry",
|
||||
"%d unread entries"
|
||||
"%d अपठित प्रविष्टि",
|
||||
"%d अपठित प्रविष्टियाँ"
|
||||
],
|
||||
"page.users.actions": "कार्रवाई",
|
||||
"page.users.admin.no": "नहीं",
|
||||
@@ -591,7 +594,7 @@
|
||||
"page.users.never_logged": "कभी नहीं",
|
||||
"page.users.title": "उपभोक्ता",
|
||||
"page.users.username": "यूसर्नेम",
|
||||
"page.webauthn_rename.title": "Rename Passkey",
|
||||
"page.webauthn_rename.title": "पासकी का नाम बदलें",
|
||||
"pagination.first": "पहला",
|
||||
"pagination.last": "अंतिम",
|
||||
"pagination.next": "अगला",
|
||||
@@ -629,4 +632,4 @@
|
||||
"time_elapsed.yesterday": "कल",
|
||||
"tooltip.keyboard_shortcuts": "कुंजीपटल शॉर्टकट: %s",
|
||||
"tooltip.logged_user": "%s के रूप में लॉग इन किया"
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user