Compare commits
227 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| 385d8bb969 | |||
| c171da1734 | |||
| 11ea137027 | |||
| 0a15075c00 | |||
| b1fda599ac | |||
| 509b7682ad | |||
| 06c2e50ffa | |||
| ec93656ef5 | |||
| 317aaeeec7 | |||
| d862f79123 | |||
| 1bea41b19c | |||
| 65a39096e7 | |||
| 131dc674e4 | |||
| b0dced42c0 | |||
| a34e33b5c5 | |||
| af12fe309e | |||
| dd44fbcc76 | |||
| 9c956d1b0d | |||
| bf7f55e28a | |||
| b8bc367a00 | |||
| 04a360a536 | |||
| fac18d5c57 | |||
| a3d1ecc58a | |||
| 8adcaed29e | |||
| 5a97bf8b5e | |||
| e279b955c4 | |||
| 1620f8d3f2 | |||
| 1a29c1568c | |||
| ff07f02716 | |||
| 79b0d0b9cc | |||
| 5fa0709663 | |||
| a7fa2ecc8c | |||
| 10b2b36895 | |||
| 87e65f800e | |||
| 907cf16af1 | |||
| 854a78a7d1 | |||
| 5e607be86a | |||
| 502e7108dd | |||
| 0b93d8abcc | |||
| 93a8629910 | |||
| b1742168e1 | |||
| 4eff9129ab | |||
| eb22d90b56 | |||
| 8f2dd02f3f | |||
| da8d4d86c3 | |||
| 7ada5d54be | |||
| 8129500296 | |||
| f1143151c5 | |||
| baf8e40152 | |||
| a98374b81f | |||
| 77e0b07f9f | |||
| 645800ce3c | |||
| a60a153003 | |||
| 7d40e2993b | |||
| 91a625c559 | |||
| dbb3855901 | |||
| 3051acf369 | |||
| 5f38054965 | |||
| fa361ab1ce | |||
| afe80e6bae | |||
| f2976bff5d | |||
| 84078c7c20 | |||
| d70817e441 | |||
| e8f5c2446c | |||
| 737f25f441 | |||
| f83d66769d | |||
| af149e46df | |||
| 4f252b33c9 | |||
| da9c3a4032 | |||
| 8e1d0bb693 | |||
| 60cd7ffe88 | |||
| 4d656d2739 | |||
| 983291c78b | |||
| 7060ecc163 | |||
| 4af12a4129 | |||
| c6536e8d90 | |||
| 30453ad7ec | |||
| 71af68becd | |||
| ed3bf59356 | |||
| 03021af53c | |||
| 83254a1f26 | |||
| 3acb888309 | |||
| 7c29166ef9 | |||
| 71105bec56 | |||
| 2a372674a2 | |||
| 9d32b23ab0 | |||
| da8bf3890c | |||
| b30eac4ebe |
@@ -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,12 +9,13 @@ 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@v5
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: stable
|
||||
check-latest: true
|
||||
@@ -21,7 +24,7 @@ jobs:
|
||||
CGO_ENABLED: 0
|
||||
run: make build
|
||||
- name: Upload binaries
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v6
|
||||
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@v5
|
||||
- uses: actions/setup-go@v6
|
||||
if: matrix.language == 'go'
|
||||
with:
|
||||
go-version: stable
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v3
|
||||
uses: github/codeql-action/init@v4
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@v3
|
||||
uses: github/codeql-action/autobuild@v4
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v3
|
||||
uses: github/codeql-action/analyze@v4
|
||||
with:
|
||||
category: "/language:${{ matrix.language }}"
|
||||
|
||||
@@ -17,7 +17,7 @@ jobs:
|
||||
name: Test Packages
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Set up QEMU
|
||||
@@ -38,7 +38,7 @@ 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
|
||||
@@ -53,7 +53,7 @@ jobs:
|
||||
- name: Build Debian Packages
|
||||
run: make debian-packages
|
||||
- name: Upload package
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: packages
|
||||
path: "*.deb"
|
||||
@@ -64,7 +64,7 @@ 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
|
||||
|
||||
@@ -12,12 +12,13 @@ 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
|
||||
|
||||
|
||||
@@ -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,16 +25,11 @@ jobs:
|
||||
name: Golang Linters
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/setup-go@v5
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: stable
|
||||
- uses: golangci/golangci-lint-action@v8
|
||||
with:
|
||||
args: >
|
||||
--timeout 10m
|
||||
--disable errcheck
|
||||
--enable sqlclosecheck,misspell,whitespace,gocritic
|
||||
- uses: golangci/golangci-lint-action@v9
|
||||
- name: Run gofmt linter
|
||||
run: gofmt -d -e .
|
||||
|
||||
@@ -43,11 +38,11 @@ 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
|
||||
uses: actions/setup-python@v5
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: '3.13'
|
||||
- name: Validate PR commits
|
||||
|
||||
@@ -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@v6
|
||||
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,9 +17,9 @@ 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@v5
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: stable
|
||||
- name: Run unit tests with coverage and race conditions checking
|
||||
@@ -44,9 +44,9 @@ 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@v5
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: stable
|
||||
- name: Install Postgres client
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
version: "2"
|
||||
linters:
|
||||
default: standard
|
||||
disable:
|
||||
- errcheck
|
||||
enable:
|
||||
- errname
|
||||
- gocritic
|
||||
- goheader
|
||||
- loggercheck
|
||||
- misspell
|
||||
- perfsprint
|
||||
- sqlclosecheck
|
||||
- staticcheck
|
||||
- whitespace
|
||||
settings:
|
||||
loggercheck:
|
||||
slog: true
|
||||
goheader:
|
||||
template: |-
|
||||
SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
SPDX-License-Identifier: Apache-2.0
|
||||
@@ -100,8 +100,8 @@ test:
|
||||
|
||||
lint:
|
||||
go vet ./...
|
||||
staticcheck ./...
|
||||
golangci-lint run --disable errcheck --enable sqlclosecheck --enable misspell --enable gofmt --enable goimports --enable whitespace
|
||||
gofmt -d -e .
|
||||
golangci-lint run
|
||||
|
||||
integration-test:
|
||||
psql -U postgres -c 'drop database if exists miniflux_test;'
|
||||
|
||||
@@ -72,7 +72,7 @@ Features
|
||||
|
||||
### Integrations
|
||||
|
||||
- 25+ integrations with third-party services: [Apprise](https://github.com/caronc/apprise), [Betula](https://sr.ht/~bouncepaw/betula/), [Cubox](https://cubox.cc/), [Discord](https://discord.com/), [Espial](https://github.com/jonschoning/espial), [Instapaper](https://www.instapaper.com/), [LinkAce](https://www.linkace.org/), [Linkding](https://github.com/sissbruecker/linkding), [LinkWarden](https://linkwarden.app/), [Matrix](https://matrix.org), [Notion](https://www.notion.com/), [Ntfy](https://ntfy.sh/), [Nunux Keeper](https://keeper.nunux.org/), [Pinboard](https://pinboard.in/), [Pushover](https://pushover.net), [RainDrop](https://raindrop.io/), [Readeck](https://readeck.org/en/), [Readwise Reader](https://readwise.io/read), [RssBridge](https://rss-bridge.org/), [Shaarli](https://github.com/shaarli/Shaarli), [Shiori](https://github.com/go-shiori/shiori), [Slack](https://slack.com/), [Telegram](https://telegram.org), [Wallabag](https://www.wallabag.org/), etc.
|
||||
- 25+ integrations with third-party services: [Apprise](https://github.com/caronc/apprise), [Betula](https://sr.ht/~bouncepaw/betula/), [Cubox](https://cubox.cc/), [Discord](https://discord.com/), [Espial](https://github.com/jonschoning/espial), [Instapaper](https://www.instapaper.com/), [LinkAce](https://www.linkace.org/), [Linkding](https://github.com/sissbruecker/linkding), [LinkTaco](https://linktaco.com), [LinkWarden](https://linkwarden.app/), [Matrix](https://matrix.org), [Notion](https://www.notion.com/), [Ntfy](https://ntfy.sh/), [Nunux Keeper](https://keeper.nunux.org/), [Pinboard](https://pinboard.in/), [Pushover](https://pushover.net), [RainDrop](https://raindrop.io/), [Readeck](https://readeck.org/en/), [Readwise Reader](https://readwise.io/read), [RssBridge](https://rss-bridge.org/), [Shaarli](https://github.com/shaarli/Shaarli), [Shiori](https://github.com/go-shiori/shiori), [Slack](https://slack.com/), [Telegram](https://telegram.org), [Wallabag](https://www.wallabag.org/), etc.
|
||||
- Bookmarklet for subscribing to websites directly from any web browser.
|
||||
- Webhooks for real-time notifications or custom integrations.
|
||||
- Compatibility with existing mobile applications using the Fever or Google Reader API.
|
||||
|
||||
+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
|
||||
------------
|
||||
|
||||
+477
-69
@@ -4,9 +4,11 @@
|
||||
package client // import "miniflux.app/v2/client"
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -26,22 +28,45 @@ func New(endpoint string, credentials ...string) *Client {
|
||||
|
||||
// NewClient returns a new Miniflux client.
|
||||
func NewClient(endpoint string, credentials ...string) *Client {
|
||||
switch len(credentials) {
|
||||
case 2:
|
||||
return NewClientWithOptions(endpoint, WithCredentials(credentials[0], credentials[1]))
|
||||
case 1:
|
||||
return NewClientWithOptions(endpoint, WithAPIKey(credentials[0]))
|
||||
default:
|
||||
return NewClientWithOptions(endpoint)
|
||||
}
|
||||
}
|
||||
|
||||
// NewClientWithOptions returns a new Miniflux client with options.
|
||||
func NewClientWithOptions(endpoint string, options ...Option) *Client {
|
||||
// Trim trailing slashes and /v1 from the endpoint.
|
||||
endpoint = strings.TrimSuffix(endpoint, "/")
|
||||
endpoint = strings.TrimSuffix(endpoint, "/v1")
|
||||
switch len(credentials) {
|
||||
case 2:
|
||||
return &Client{request: &request{endpoint: endpoint, username: credentials[0], password: credentials[1]}}
|
||||
case 1:
|
||||
return &Client{request: &request{endpoint: endpoint, apiKey: credentials[0]}}
|
||||
default:
|
||||
return &Client{request: &request{endpoint: endpoint}}
|
||||
request := &request{endpoint: endpoint, client: http.DefaultClient}
|
||||
|
||||
for _, option := range options {
|
||||
option(request)
|
||||
}
|
||||
|
||||
return &Client{request: request}
|
||||
}
|
||||
|
||||
func withDefaultTimeout() (context.Context, func()) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), defaultTimeout)
|
||||
return ctx, cancel
|
||||
}
|
||||
|
||||
// Healthcheck checks if the application is up and running.
|
||||
func (c *Client) Healthcheck() error {
|
||||
body, err := c.request.Get("/healthcheck")
|
||||
ctx, cancel := withDefaultTimeout()
|
||||
defer cancel()
|
||||
return c.HealthcheckContext(ctx)
|
||||
}
|
||||
|
||||
// HealthcheckContext checks if the application is up and running.
|
||||
func (c *Client) HealthcheckContext(ctx context.Context) error {
|
||||
body, err := c.request.Get(ctx, "/healthcheck")
|
||||
if err != nil {
|
||||
return fmt.Errorf("miniflux: unable to perform healthcheck: %w", err)
|
||||
}
|
||||
@@ -61,7 +86,14 @@ func (c *Client) Healthcheck() error {
|
||||
|
||||
// Version returns the version of the Miniflux instance.
|
||||
func (c *Client) Version() (*VersionResponse, error) {
|
||||
body, err := c.request.Get("/v1/version")
|
||||
ctx, cancel := withDefaultTimeout()
|
||||
defer cancel()
|
||||
return c.VersionContext(ctx)
|
||||
}
|
||||
|
||||
// VersionContext returns the version of the Miniflux instance.
|
||||
func (c *Client) VersionContext(ctx context.Context) (*VersionResponse, error) {
|
||||
body, err := c.request.Get(ctx, "/v1/version")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -77,7 +109,14 @@ func (c *Client) Version() (*VersionResponse, error) {
|
||||
|
||||
// Me returns the logged user information.
|
||||
func (c *Client) Me() (*User, error) {
|
||||
body, err := c.request.Get("/v1/me")
|
||||
ctx, cancel := withDefaultTimeout()
|
||||
defer cancel()
|
||||
return c.MeContext(ctx)
|
||||
}
|
||||
|
||||
// MeContext returns the logged user information.
|
||||
func (c *Client) MeContext(ctx context.Context) (*User, error) {
|
||||
body, err := c.request.Get(ctx, "/v1/me")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -93,7 +132,14 @@ func (c *Client) Me() (*User, error) {
|
||||
|
||||
// Users returns all users.
|
||||
func (c *Client) Users() (Users, error) {
|
||||
body, err := c.request.Get("/v1/users")
|
||||
ctx, cancel := withDefaultTimeout()
|
||||
defer cancel()
|
||||
return c.UsersContext(ctx)
|
||||
}
|
||||
|
||||
// UsersContext returns all users.
|
||||
func (c *Client) UsersContext(ctx context.Context) (Users, error) {
|
||||
body, err := c.request.Get(ctx, "/v1/users")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -109,7 +155,14 @@ func (c *Client) Users() (Users, error) {
|
||||
|
||||
// UserByID returns a single user.
|
||||
func (c *Client) UserByID(userID int64) (*User, error) {
|
||||
body, err := c.request.Get(fmt.Sprintf("/v1/users/%d", userID))
|
||||
ctx, cancel := withDefaultTimeout()
|
||||
defer cancel()
|
||||
return c.UserByIDContext(ctx, userID)
|
||||
}
|
||||
|
||||
// UserByIDContext returns a single user.
|
||||
func (c *Client) UserByIDContext(ctx context.Context, userID int64) (*User, error) {
|
||||
body, err := c.request.Get(ctx, fmt.Sprintf("/v1/users/%d", userID))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -125,7 +178,14 @@ func (c *Client) UserByID(userID int64) (*User, error) {
|
||||
|
||||
// UserByUsername returns a single user.
|
||||
func (c *Client) UserByUsername(username string) (*User, error) {
|
||||
body, err := c.request.Get(fmt.Sprintf("/v1/users/%s", username))
|
||||
ctx, cancel := withDefaultTimeout()
|
||||
defer cancel()
|
||||
return c.UserByUsernameContext(ctx, username)
|
||||
}
|
||||
|
||||
// UserByUsernameContext returns a single user.
|
||||
func (c *Client) UserByUsernameContext(ctx context.Context, username string) (*User, error) {
|
||||
body, err := c.request.Get(ctx, "/v1/users/"+username)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -141,7 +201,14 @@ func (c *Client) UserByUsername(username string) (*User, error) {
|
||||
|
||||
// CreateUser creates a new user in the system.
|
||||
func (c *Client) CreateUser(username, password string, isAdmin bool) (*User, error) {
|
||||
body, err := c.request.Post("/v1/users", &UserCreationRequest{
|
||||
ctx, cancel := withDefaultTimeout()
|
||||
defer cancel()
|
||||
return c.CreateUserContext(ctx, username, password, isAdmin)
|
||||
}
|
||||
|
||||
// CreateUserContext creates a new user in the system.
|
||||
func (c *Client) CreateUserContext(ctx context.Context, username, password string, isAdmin bool) (*User, error) {
|
||||
body, err := c.request.Post(ctx, "/v1/users", &UserCreationRequest{
|
||||
Username: username,
|
||||
Password: password,
|
||||
IsAdmin: isAdmin,
|
||||
@@ -161,7 +228,14 @@ func (c *Client) CreateUser(username, password string, isAdmin bool) (*User, err
|
||||
|
||||
// UpdateUser updates a user in the system.
|
||||
func (c *Client) UpdateUser(userID int64, userChanges *UserModificationRequest) (*User, error) {
|
||||
body, err := c.request.Put(fmt.Sprintf("/v1/users/%d", userID), userChanges)
|
||||
ctx, cancel := withDefaultTimeout()
|
||||
defer cancel()
|
||||
return c.UpdateUserContext(ctx, userID, userChanges)
|
||||
}
|
||||
|
||||
// UpdateUserContext updates a user in the system.
|
||||
func (c *Client) UpdateUserContext(ctx context.Context, userID int64, userChanges *UserModificationRequest) (*User, error) {
|
||||
body, err := c.request.Put(ctx, fmt.Sprintf("/v1/users/%d", userID), userChanges)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -177,12 +251,26 @@ func (c *Client) UpdateUser(userID int64, userChanges *UserModificationRequest)
|
||||
|
||||
// DeleteUser removes a user from the system.
|
||||
func (c *Client) DeleteUser(userID int64) error {
|
||||
return c.request.Delete(fmt.Sprintf("/v1/users/%d", userID))
|
||||
ctx, cancel := withDefaultTimeout()
|
||||
defer cancel()
|
||||
return c.DeleteUserContext(ctx, userID)
|
||||
}
|
||||
|
||||
// DeleteUserContext removes a user from the system.
|
||||
func (c *Client) DeleteUserContext(ctx context.Context, userID int64) error {
|
||||
return c.request.Delete(ctx, fmt.Sprintf("/v1/users/%d", userID))
|
||||
}
|
||||
|
||||
// APIKeys returns all API keys for the authenticated user.
|
||||
func (c *Client) APIKeys() (APIKeys, error) {
|
||||
body, err := c.request.Get("/v1/api-keys")
|
||||
ctx, cancel := withDefaultTimeout()
|
||||
defer cancel()
|
||||
return c.APIKeysContext(ctx)
|
||||
}
|
||||
|
||||
// APIKeysContext returns all API keys for the authenticated user.
|
||||
func (c *Client) APIKeysContext(ctx context.Context) (APIKeys, error) {
|
||||
body, err := c.request.Get(ctx, "/v1/api-keys")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -198,7 +286,14 @@ func (c *Client) APIKeys() (APIKeys, error) {
|
||||
|
||||
// CreateAPIKey creates a new API key for the authenticated user.
|
||||
func (c *Client) CreateAPIKey(description string) (*APIKey, error) {
|
||||
body, err := c.request.Post("/v1/api-keys", &APIKeyCreationRequest{
|
||||
ctx, cancel := withDefaultTimeout()
|
||||
defer cancel()
|
||||
return c.CreateAPIKeyContext(ctx, description)
|
||||
}
|
||||
|
||||
// CreateAPIKeyContext creates a new API key for the authenticated user.
|
||||
func (c *Client) CreateAPIKeyContext(ctx context.Context, description string) (*APIKey, error) {
|
||||
body, err := c.request.Post(ctx, "/v1/api-keys", &APIKeyCreationRequest{
|
||||
Description: description,
|
||||
})
|
||||
if err != nil {
|
||||
@@ -216,18 +311,39 @@ func (c *Client) CreateAPIKey(description string) (*APIKey, error) {
|
||||
|
||||
// DeleteAPIKey removes an API key for the authenticated user.
|
||||
func (c *Client) DeleteAPIKey(apiKeyID int64) error {
|
||||
return c.request.Delete(fmt.Sprintf("/v1/api-keys/%d", apiKeyID))
|
||||
ctx, cancel := withDefaultTimeout()
|
||||
defer cancel()
|
||||
return c.DeleteAPIKeyContext(ctx, apiKeyID)
|
||||
}
|
||||
|
||||
// DeleteAPIKeyContext removes an API key for the authenticated user.
|
||||
func (c *Client) DeleteAPIKeyContext(ctx context.Context, apiKeyID int64) error {
|
||||
return c.request.Delete(ctx, fmt.Sprintf("/v1/api-keys/%d", apiKeyID))
|
||||
}
|
||||
|
||||
// MarkAllAsRead marks all unread entries as read for a given user.
|
||||
func (c *Client) MarkAllAsRead(userID int64) error {
|
||||
_, err := c.request.Put(fmt.Sprintf("/v1/users/%d/mark-all-as-read", userID), nil)
|
||||
ctx, cancel := withDefaultTimeout()
|
||||
defer cancel()
|
||||
return c.MarkAllAsReadContext(ctx, userID)
|
||||
}
|
||||
|
||||
// MarkAllAsReadContext marks all unread entries as read for a given user.
|
||||
func (c *Client) MarkAllAsReadContext(ctx context.Context, userID int64) error {
|
||||
_, err := c.request.Put(ctx, fmt.Sprintf("/v1/users/%d/mark-all-as-read", userID), nil)
|
||||
return err
|
||||
}
|
||||
|
||||
// IntegrationsStatus fetches the integrations status for the logged user.
|
||||
// IntegrationsStatus fetches the integrations status for the signed-in user.
|
||||
func (c *Client) IntegrationsStatus() (bool, error) {
|
||||
body, err := c.request.Get("/v1/integrations/status")
|
||||
ctx, cancel := withDefaultTimeout()
|
||||
defer cancel()
|
||||
return c.IntegrationsStatusContext(ctx)
|
||||
}
|
||||
|
||||
// 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 {
|
||||
return false, err
|
||||
}
|
||||
@@ -244,9 +360,16 @@ func (c *Client) IntegrationsStatus() (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) {
|
||||
body, err := c.request.Post("/v1/discover", map[string]string{"url": url})
|
||||
ctx, cancel := withDefaultTimeout()
|
||||
defer cancel()
|
||||
return c.DiscoverContext(ctx, url)
|
||||
}
|
||||
|
||||
// DiscoverContext tries to find subscriptions from a website.
|
||||
func (c *Client) DiscoverContext(ctx context.Context, url string) (Subscriptions, error) {
|
||||
body, err := c.request.Post(ctx, "/v1/discover", map[string]string{"url": url})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -260,9 +383,16 @@ func (c *Client) Discover(url string) (Subscriptions, error) {
|
||||
return subscriptions, nil
|
||||
}
|
||||
|
||||
// Categories gets the list of categories.
|
||||
// Categories retrieves the list of categories.
|
||||
func (c *Client) Categories() (Categories, error) {
|
||||
body, err := c.request.Get("/v1/categories")
|
||||
ctx, cancel := withDefaultTimeout()
|
||||
defer cancel()
|
||||
return c.CategoriesContext(ctx)
|
||||
}
|
||||
|
||||
// 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 {
|
||||
return nil, err
|
||||
}
|
||||
@@ -278,7 +408,14 @@ func (c *Client) Categories() (Categories, error) {
|
||||
|
||||
// CategoriesWithCounters fetches the categories with their respective feed and unread counts.
|
||||
func (c *Client) CategoriesWithCounters() (Categories, error) {
|
||||
body, err := c.request.Get("/v1/categories?counts=true")
|
||||
ctx, cancel := withDefaultTimeout()
|
||||
defer cancel()
|
||||
return c.CategoriesWithCountersContext(ctx)
|
||||
}
|
||||
|
||||
// CategoriesWithCountersContext fetches the categories with their respective feed and unread counts.
|
||||
func (c *Client) CategoriesWithCountersContext(ctx context.Context) (Categories, error) {
|
||||
body, err := c.request.Get(ctx, "/v1/categories?counts=true")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -294,7 +431,14 @@ func (c *Client) CategoriesWithCounters() (Categories, error) {
|
||||
|
||||
// CreateCategory creates a new category.
|
||||
func (c *Client) CreateCategory(title string) (*Category, error) {
|
||||
body, err := c.request.Post("/v1/categories", &CategoryCreationRequest{
|
||||
ctx, cancel := withDefaultTimeout()
|
||||
defer cancel()
|
||||
return c.CreateCategoryContext(ctx, title)
|
||||
}
|
||||
|
||||
// CreateCategoryContext creates a new category.
|
||||
func (c *Client) CreateCategoryContext(ctx context.Context, title string) (*Category, error) {
|
||||
body, err := c.request.Post(ctx, "/v1/categories", &CategoryCreationRequest{
|
||||
Title: title,
|
||||
})
|
||||
if err != nil {
|
||||
@@ -312,7 +456,14 @@ func (c *Client) CreateCategory(title string) (*Category, error) {
|
||||
|
||||
// CreateCategoryWithOptions creates a new category with options.
|
||||
func (c *Client) CreateCategoryWithOptions(createRequest *CategoryCreationRequest) (*Category, error) {
|
||||
body, err := c.request.Post("/v1/categories", createRequest)
|
||||
ctx, cancel := withDefaultTimeout()
|
||||
defer cancel()
|
||||
return c.CreateCategoryWithOptionsContext(ctx, createRequest)
|
||||
}
|
||||
|
||||
// CreateCategoryWithOptionsContext creates a new category with options.
|
||||
func (c *Client) CreateCategoryWithOptionsContext(ctx context.Context, createRequest *CategoryCreationRequest) (*Category, error) {
|
||||
body, err := c.request.Post(ctx, "/v1/categories", createRequest)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -327,7 +478,14 @@ func (c *Client) CreateCategoryWithOptions(createRequest *CategoryCreationReques
|
||||
|
||||
// UpdateCategory updates a category.
|
||||
func (c *Client) UpdateCategory(categoryID int64, title string) (*Category, error) {
|
||||
body, err := c.request.Put(fmt.Sprintf("/v1/categories/%d", categoryID), &CategoryModificationRequest{
|
||||
ctx, cancel := withDefaultTimeout()
|
||||
defer cancel()
|
||||
return c.UpdateCategoryContext(ctx, categoryID, title)
|
||||
}
|
||||
|
||||
// UpdateCategoryContext updates a category.
|
||||
func (c *Client) UpdateCategoryContext(ctx context.Context, categoryID int64, title string) (*Category, error) {
|
||||
body, err := c.request.Put(ctx, fmt.Sprintf("/v1/categories/%d", categoryID), &CategoryModificationRequest{
|
||||
Title: SetOptionalField(title),
|
||||
})
|
||||
if err != nil {
|
||||
@@ -345,7 +503,14 @@ func (c *Client) UpdateCategory(categoryID int64, title string) (*Category, erro
|
||||
|
||||
// UpdateCategoryWithOptions updates a category with options.
|
||||
func (c *Client) UpdateCategoryWithOptions(categoryID int64, categoryChanges *CategoryModificationRequest) (*Category, error) {
|
||||
body, err := c.request.Put(fmt.Sprintf("/v1/categories/%d", categoryID), categoryChanges)
|
||||
ctx, cancel := withDefaultTimeout()
|
||||
defer cancel()
|
||||
return c.UpdateCategoryWithOptionsContext(ctx, categoryID, categoryChanges)
|
||||
}
|
||||
|
||||
// UpdateCategoryWithOptionsContext updates a category with options.
|
||||
func (c *Client) UpdateCategoryWithOptionsContext(ctx context.Context, categoryID int64, categoryChanges *CategoryModificationRequest) (*Category, error) {
|
||||
body, err := c.request.Put(ctx, fmt.Sprintf("/v1/categories/%d", categoryID), categoryChanges)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -361,13 +526,27 @@ func (c *Client) UpdateCategoryWithOptions(categoryID int64, categoryChanges *Ca
|
||||
|
||||
// MarkCategoryAsRead marks all unread entries in a category as read.
|
||||
func (c *Client) MarkCategoryAsRead(categoryID int64) error {
|
||||
_, err := c.request.Put(fmt.Sprintf("/v1/categories/%d/mark-all-as-read", categoryID), nil)
|
||||
ctx, cancel := withDefaultTimeout()
|
||||
defer cancel()
|
||||
return c.MarkCategoryAsReadContext(ctx, categoryID)
|
||||
}
|
||||
|
||||
// MarkCategoryAsReadContext marks all unread entries in a category as read.
|
||||
func (c *Client) MarkCategoryAsReadContext(ctx context.Context, categoryID int64) error {
|
||||
_, err := c.request.Put(ctx, fmt.Sprintf("/v1/categories/%d/mark-all-as-read", categoryID), nil)
|
||||
return err
|
||||
}
|
||||
|
||||
// CategoryFeeds gets feeds of a category.
|
||||
// CategoryFeeds returns all feeds for a category.
|
||||
func (c *Client) CategoryFeeds(categoryID int64) (Feeds, error) {
|
||||
body, err := c.request.Get(fmt.Sprintf("/v1/categories/%d/feeds", categoryID))
|
||||
ctx, cancel := withDefaultTimeout()
|
||||
defer cancel()
|
||||
return c.CategoryFeedsContext(ctx, categoryID)
|
||||
}
|
||||
|
||||
// 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 {
|
||||
return nil, err
|
||||
}
|
||||
@@ -383,18 +562,39 @@ func (c *Client) CategoryFeeds(categoryID int64) (Feeds, error) {
|
||||
|
||||
// DeleteCategory removes a category.
|
||||
func (c *Client) DeleteCategory(categoryID int64) error {
|
||||
return c.request.Delete(fmt.Sprintf("/v1/categories/%d", categoryID))
|
||||
ctx, cancel := withDefaultTimeout()
|
||||
defer cancel()
|
||||
return c.DeleteCategoryContext(ctx, categoryID)
|
||||
}
|
||||
|
||||
// DeleteCategoryContext removes a category.
|
||||
func (c *Client) DeleteCategoryContext(ctx context.Context, categoryID int64) error {
|
||||
return c.request.Delete(ctx, fmt.Sprintf("/v1/categories/%d", categoryID))
|
||||
}
|
||||
|
||||
// RefreshCategory refreshes a category.
|
||||
func (c *Client) RefreshCategory(categoryID int64) error {
|
||||
_, err := c.request.Put(fmt.Sprintf("/v1/categories/%d/refresh", categoryID), nil)
|
||||
ctx, cancel := withDefaultTimeout()
|
||||
defer cancel()
|
||||
return c.RefreshCategoryContext(ctx, categoryID)
|
||||
}
|
||||
|
||||
// RefreshCategoryContext refreshes a category.
|
||||
func (c *Client) RefreshCategoryContext(ctx context.Context, categoryID int64) error {
|
||||
_, err := c.request.Put(ctx, fmt.Sprintf("/v1/categories/%d/refresh", categoryID), nil)
|
||||
return err
|
||||
}
|
||||
|
||||
// Feeds gets all feeds.
|
||||
func (c *Client) Feeds() (Feeds, error) {
|
||||
body, err := c.request.Get("/v1/feeds")
|
||||
ctx, cancel := withDefaultTimeout()
|
||||
defer cancel()
|
||||
return c.FeedsContext(ctx)
|
||||
}
|
||||
|
||||
// FeedsContext gets all feeds.
|
||||
func (c *Client) FeedsContext(ctx context.Context) (Feeds, error) {
|
||||
body, err := c.request.Get(ctx, "/v1/feeds")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -408,9 +608,16 @@ func (c *Client) Feeds() (Feeds, error) {
|
||||
return feeds, nil
|
||||
}
|
||||
|
||||
// Export creates OPML file.
|
||||
// Export exports subscriptions as an OPML document.
|
||||
func (c *Client) Export() ([]byte, error) {
|
||||
body, err := c.request.Get("/v1/export")
|
||||
ctx, cancel := withDefaultTimeout()
|
||||
defer cancel()
|
||||
return c.ExportContext(ctx)
|
||||
}
|
||||
|
||||
// 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 {
|
||||
return nil, err
|
||||
}
|
||||
@@ -426,13 +633,27 @@ func (c *Client) Export() ([]byte, error) {
|
||||
|
||||
// Import imports an OPML file.
|
||||
func (c *Client) Import(f io.ReadCloser) error {
|
||||
_, err := c.request.PostFile("/v1/import", f)
|
||||
ctx, cancel := withDefaultTimeout()
|
||||
defer cancel()
|
||||
return c.ImportContext(ctx, f)
|
||||
}
|
||||
|
||||
// ImportContext imports an OPML file.
|
||||
func (c *Client) ImportContext(ctx context.Context, f io.ReadCloser) error {
|
||||
_, err := c.request.PostFile(ctx, "/v1/import", f)
|
||||
return err
|
||||
}
|
||||
|
||||
// Feed gets a feed.
|
||||
func (c *Client) Feed(feedID int64) (*Feed, error) {
|
||||
body, err := c.request.Get(fmt.Sprintf("/v1/feeds/%d", feedID))
|
||||
ctx, cancel := withDefaultTimeout()
|
||||
defer cancel()
|
||||
return c.FeedContext(ctx, feedID)
|
||||
}
|
||||
|
||||
// FeedContext gets a feed.
|
||||
func (c *Client) FeedContext(ctx context.Context, feedID int64) (*Feed, error) {
|
||||
body, err := c.request.Get(ctx, fmt.Sprintf("/v1/feeds/%d", feedID))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -448,7 +669,14 @@ func (c *Client) Feed(feedID int64) (*Feed, error) {
|
||||
|
||||
// CreateFeed creates a new feed.
|
||||
func (c *Client) CreateFeed(feedCreationRequest *FeedCreationRequest) (int64, error) {
|
||||
body, err := c.request.Post("/v1/feeds", feedCreationRequest)
|
||||
ctx, cancel := withDefaultTimeout()
|
||||
defer cancel()
|
||||
return c.CreateFeedContext(ctx, feedCreationRequest)
|
||||
}
|
||||
|
||||
// CreateFeedContext creates a new feed.
|
||||
func (c *Client) CreateFeedContext(ctx context.Context, feedCreationRequest *FeedCreationRequest) (int64, error) {
|
||||
body, err := c.request.Post(ctx, "/v1/feeds", feedCreationRequest)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
@@ -468,7 +696,14 @@ func (c *Client) CreateFeed(feedCreationRequest *FeedCreationRequest) (int64, er
|
||||
|
||||
// UpdateFeed updates a feed.
|
||||
func (c *Client) UpdateFeed(feedID int64, feedChanges *FeedModificationRequest) (*Feed, error) {
|
||||
body, err := c.request.Put(fmt.Sprintf("/v1/feeds/%d", feedID), feedChanges)
|
||||
ctx, cancel := withDefaultTimeout()
|
||||
defer cancel()
|
||||
return c.UpdateFeedContext(ctx, feedID, feedChanges)
|
||||
}
|
||||
|
||||
// UpdateFeedContext updates a feed.
|
||||
func (c *Client) UpdateFeedContext(ctx context.Context, feedID int64, feedChanges *FeedModificationRequest) (*Feed, error) {
|
||||
body, err := c.request.Put(ctx, fmt.Sprintf("/v1/feeds/%d", feedID), feedChanges)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -482,32 +717,93 @@ func (c *Client) UpdateFeed(feedID int64, feedChanges *FeedModificationRequest)
|
||||
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 {
|
||||
_, err := c.request.Put(fmt.Sprintf("/v1/feeds/%d/mark-all-as-read", feedID), nil)
|
||||
ctx, cancel := withDefaultTimeout()
|
||||
defer cancel()
|
||||
return c.MarkFeedAsReadContext(ctx, feedID)
|
||||
}
|
||||
|
||||
// MarkFeedAsReadContext marks all unread entries of the feed as read.
|
||||
func (c *Client) MarkFeedAsReadContext(ctx context.Context, feedID int64) error {
|
||||
_, err := c.request.Put(ctx, fmt.Sprintf("/v1/feeds/%d/mark-all-as-read", feedID), nil)
|
||||
return err
|
||||
}
|
||||
|
||||
// RefreshAllFeeds refreshes all feeds.
|
||||
func (c *Client) RefreshAllFeeds() error {
|
||||
_, err := c.request.Put("/v1/feeds/refresh", nil)
|
||||
ctx, cancel := withDefaultTimeout()
|
||||
defer cancel()
|
||||
return c.RefreshAllFeedsContext(ctx)
|
||||
}
|
||||
|
||||
// RefreshAllFeedsContext refreshes all feeds.
|
||||
func (c *Client) RefreshAllFeedsContext(ctx context.Context) error {
|
||||
_, err := c.request.Put(ctx, "/v1/feeds/refresh", nil)
|
||||
return err
|
||||
}
|
||||
|
||||
// RefreshFeed refreshes a feed.
|
||||
func (c *Client) RefreshFeed(feedID int64) error {
|
||||
_, err := c.request.Put(fmt.Sprintf("/v1/feeds/%d/refresh", feedID), nil)
|
||||
ctx, cancel := withDefaultTimeout()
|
||||
defer cancel()
|
||||
return c.RefreshFeedContext(ctx, feedID)
|
||||
}
|
||||
|
||||
// RefreshFeedContext refreshes a feed.
|
||||
func (c *Client) RefreshFeedContext(ctx context.Context, feedID int64) error {
|
||||
_, err := c.request.Put(ctx, fmt.Sprintf("/v1/feeds/%d/refresh", feedID), nil)
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteFeed removes a feed.
|
||||
func (c *Client) DeleteFeed(feedID int64) error {
|
||||
return c.request.Delete(fmt.Sprintf("/v1/feeds/%d", feedID))
|
||||
ctx, cancel := withDefaultTimeout()
|
||||
defer cancel()
|
||||
return c.DeleteFeedContext(ctx, feedID)
|
||||
}
|
||||
|
||||
// DeleteFeedContext removes a feed.
|
||||
func (c *Client) DeleteFeedContext(ctx context.Context, feedID int64) error {
|
||||
return c.request.Delete(ctx, fmt.Sprintf("/v1/feeds/%d", feedID))
|
||||
}
|
||||
|
||||
// FeedIcon gets a feed icon.
|
||||
func (c *Client) FeedIcon(feedID int64) (*FeedIcon, error) {
|
||||
body, err := c.request.Get(fmt.Sprintf("/v1/feeds/%d/icon", feedID))
|
||||
ctx, cancel := withDefaultTimeout()
|
||||
defer cancel()
|
||||
return c.FeedIconContext(ctx, feedID)
|
||||
}
|
||||
|
||||
// FeedIconContext gets a feed icon.
|
||||
func (c *Client) FeedIconContext(ctx context.Context, feedID int64) (*FeedIcon, error) {
|
||||
body, err := c.request.Get(ctx, fmt.Sprintf("/v1/feeds/%d/icon", feedID))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -523,7 +819,14 @@ func (c *Client) FeedIcon(feedID int64) (*FeedIcon, error) {
|
||||
|
||||
// FeedEntry gets a single feed entry.
|
||||
func (c *Client) FeedEntry(feedID, entryID int64) (*Entry, error) {
|
||||
body, err := c.request.Get(fmt.Sprintf("/v1/feeds/%d/entries/%d", feedID, entryID))
|
||||
ctx, cancel := withDefaultTimeout()
|
||||
defer cancel()
|
||||
return c.FeedEntryContext(ctx, feedID, entryID)
|
||||
}
|
||||
|
||||
// FeedEntryContext gets a single feed entry.
|
||||
func (c *Client) FeedEntryContext(ctx context.Context, feedID, entryID int64) (*Entry, error) {
|
||||
body, err := c.request.Get(ctx, fmt.Sprintf("/v1/feeds/%d/entries/%d", feedID, entryID))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -539,7 +842,14 @@ func (c *Client) FeedEntry(feedID, entryID int64) (*Entry, error) {
|
||||
|
||||
// CategoryEntry gets a single category entry.
|
||||
func (c *Client) CategoryEntry(categoryID, entryID int64) (*Entry, error) {
|
||||
body, err := c.request.Get(fmt.Sprintf("/v1/categories/%d/entries/%d", categoryID, entryID))
|
||||
ctx, cancel := withDefaultTimeout()
|
||||
defer cancel()
|
||||
return c.CategoryEntryContext(ctx, categoryID, entryID)
|
||||
}
|
||||
|
||||
// CategoryEntryContext gets a single category entry.
|
||||
func (c *Client) CategoryEntryContext(ctx context.Context, categoryID, entryID int64) (*Entry, error) {
|
||||
body, err := c.request.Get(ctx, fmt.Sprintf("/v1/categories/%d/entries/%d", categoryID, entryID))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -555,7 +865,14 @@ func (c *Client) CategoryEntry(categoryID, entryID int64) (*Entry, error) {
|
||||
|
||||
// Entry gets a single entry.
|
||||
func (c *Client) Entry(entryID int64) (*Entry, error) {
|
||||
body, err := c.request.Get(fmt.Sprintf("/v1/entries/%d", entryID))
|
||||
ctx, cancel := withDefaultTimeout()
|
||||
defer cancel()
|
||||
return c.EntryContext(ctx, entryID)
|
||||
}
|
||||
|
||||
// EntryContext gets a single entry.
|
||||
func (c *Client) EntryContext(ctx context.Context, entryID int64) (*Entry, error) {
|
||||
body, err := c.request.Get(ctx, fmt.Sprintf("/v1/entries/%d", entryID))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -569,11 +886,18 @@ func (c *Client) Entry(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()
|
||||
return c.EntriesContext(ctx, filter)
|
||||
}
|
||||
|
||||
// EntriesContext fetches entries.
|
||||
func (c *Client) EntriesContext(ctx context.Context, filter *Filter) (*EntryResultSet, error) {
|
||||
path := buildFilterQueryString("/v1/entries", filter)
|
||||
|
||||
body, err := c.request.Get(path)
|
||||
body, err := c.request.Get(ctx, path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -587,11 +911,18 @@ func (c *Client) Entries(filter *Filter) (*EntryResultSet, error) {
|
||||
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()
|
||||
return c.FeedEntriesContext(ctx, feedID, filter)
|
||||
}
|
||||
|
||||
// FeedEntriesContext fetches feed entries.
|
||||
func (c *Client) FeedEntriesContext(ctx context.Context, feedID int64, filter *Filter) (*EntryResultSet, error) {
|
||||
path := buildFilterQueryString(fmt.Sprintf("/v1/feeds/%d/entries", feedID), filter)
|
||||
|
||||
body, err := c.request.Get(path)
|
||||
body, err := c.request.Get(ctx, path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -605,11 +936,18 @@ func (c *Client) FeedEntries(feedID int64, filter *Filter) (*EntryResultSet, err
|
||||
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()
|
||||
return c.CategoryEntriesContext(ctx, categoryID, filter)
|
||||
}
|
||||
|
||||
// CategoryEntriesContext fetches category entries.
|
||||
func (c *Client) CategoryEntriesContext(ctx context.Context, categoryID int64, filter *Filter) (*EntryResultSet, error) {
|
||||
path := buildFilterQueryString(fmt.Sprintf("/v1/categories/%d/entries", categoryID), filter)
|
||||
|
||||
body, err := c.request.Get(path)
|
||||
body, err := c.request.Get(ctx, path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -625,18 +963,32 @@ func (c *Client) CategoryEntries(categoryID int64, filter *Filter) (*EntryResult
|
||||
|
||||
// UpdateEntries updates the status of a list of entries.
|
||||
func (c *Client) UpdateEntries(entryIDs []int64, status string) error {
|
||||
ctx, cancel := withDefaultTimeout()
|
||||
defer cancel()
|
||||
return c.UpdateEntriesContext(ctx, entryIDs, status)
|
||||
}
|
||||
|
||||
// UpdateEntriesContext updates the status of a list of entries.
|
||||
func (c *Client) UpdateEntriesContext(ctx context.Context, entryIDs []int64, status string) error {
|
||||
type payload struct {
|
||||
EntryIDs []int64 `json:"entry_ids"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
_, err := c.request.Put("/v1/entries", &payload{EntryIDs: entryIDs, Status: status})
|
||||
_, err := c.request.Put(ctx, "/v1/entries", &payload{EntryIDs: entryIDs, Status: status})
|
||||
return err
|
||||
}
|
||||
|
||||
// UpdateEntry updates an entry.
|
||||
func (c *Client) UpdateEntry(entryID int64, entryChanges *EntryModificationRequest) (*Entry, error) {
|
||||
body, err := c.request.Put(fmt.Sprintf("/v1/entries/%d", entryID), entryChanges)
|
||||
ctx, cancel := withDefaultTimeout()
|
||||
defer cancel()
|
||||
return c.UpdateEntryContext(ctx, entryID, entryChanges)
|
||||
}
|
||||
|
||||
// UpdateEntryContext updates an entry.
|
||||
func (c *Client) UpdateEntryContext(ctx context.Context, entryID int64, entryChanges *EntryModificationRequest) (*Entry, error) {
|
||||
body, err := c.request.Put(ctx, fmt.Sprintf("/v1/entries/%d", entryID), entryChanges)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -650,21 +1002,42 @@ func (c *Client) UpdateEntry(entryID int64, entryChanges *EntryModificationReque
|
||||
return entry, nil
|
||||
}
|
||||
|
||||
// ToggleBookmark toggles entry bookmark value.
|
||||
func (c *Client) ToggleBookmark(entryID int64) error {
|
||||
_, err := c.request.Put(fmt.Sprintf("/v1/entries/%d/bookmark", entryID), nil)
|
||||
// ToggleStarred toggles the starred flag of an entry.
|
||||
func (c *Client) ToggleStarred(entryID int64) error {
|
||||
ctx, cancel := withDefaultTimeout()
|
||||
defer cancel()
|
||||
return c.ToggleStarredContext(ctx, entryID)
|
||||
}
|
||||
|
||||
// ToggleStarredContext toggles entry starred value.
|
||||
func (c *Client) ToggleStarredContext(ctx context.Context, entryID int64) error {
|
||||
_, err := c.request.Put(ctx, fmt.Sprintf("/v1/entries/%d/star", entryID), nil)
|
||||
return err
|
||||
}
|
||||
|
||||
// SaveEntry sends an entry to a third-party service.
|
||||
func (c *Client) SaveEntry(entryID int64) error {
|
||||
_, err := c.request.Post(fmt.Sprintf("/v1/entries/%d/save", entryID), nil)
|
||||
ctx, cancel := withDefaultTimeout()
|
||||
defer cancel()
|
||||
return c.SaveEntryContext(ctx, entryID)
|
||||
}
|
||||
|
||||
// SaveEntryContext sends an entry to a third-party service.
|
||||
func (c *Client) SaveEntryContext(ctx context.Context, entryID int64) error {
|
||||
_, err := c.request.Post(ctx, fmt.Sprintf("/v1/entries/%d/save", entryID), nil)
|
||||
return err
|
||||
}
|
||||
|
||||
// FetchEntryOriginalContent fetches the original content of an entry using the scraper.
|
||||
func (c *Client) FetchEntryOriginalContent(entryID int64) (string, error) {
|
||||
body, err := c.request.Get(fmt.Sprintf("/v1/entries/%d/fetch-content", entryID))
|
||||
ctx, cancel := withDefaultTimeout()
|
||||
defer cancel()
|
||||
return c.FetchEntryOriginalContentContext(ctx, entryID)
|
||||
}
|
||||
|
||||
// FetchEntryOriginalContentContext fetches the original content of an entry using the scraper.
|
||||
func (c *Client) FetchEntryOriginalContentContext(ctx context.Context, entryID int64) (string, error) {
|
||||
body, err := c.request.Get(ctx, fmt.Sprintf("/v1/entries/%d/fetch-content", entryID))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -683,7 +1056,14 @@ func (c *Client) FetchEntryOriginalContent(entryID int64) (string, error) {
|
||||
|
||||
// FetchCounters fetches feed counters.
|
||||
func (c *Client) FetchCounters() (*FeedCounters, error) {
|
||||
body, err := c.request.Get("/v1/feeds/counters")
|
||||
ctx, cancel := withDefaultTimeout()
|
||||
defer cancel()
|
||||
return c.FetchCountersContext(ctx)
|
||||
}
|
||||
|
||||
// FetchCountersContext fetches feed counters.
|
||||
func (c *Client) FetchCountersContext(ctx context.Context) (*FeedCounters, error) {
|
||||
body, err := c.request.Get(ctx, "/v1/feeds/counters")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -699,13 +1079,27 @@ func (c *Client) FetchCounters() (*FeedCounters, error) {
|
||||
|
||||
// FlushHistory changes all entries with the status "read" to "removed".
|
||||
func (c *Client) FlushHistory() error {
|
||||
_, err := c.request.Put("/v1/flush-history", nil)
|
||||
ctx, cancel := withDefaultTimeout()
|
||||
defer cancel()
|
||||
return c.FlushHistoryContext(ctx)
|
||||
}
|
||||
|
||||
// FlushHistoryContext changes all entries with the status "read" to "removed".
|
||||
func (c *Client) FlushHistoryContext(ctx context.Context) error {
|
||||
_, err := c.request.Put(ctx, "/v1/flush-history", nil)
|
||||
return err
|
||||
}
|
||||
|
||||
// Icon fetches a feed icon.
|
||||
func (c *Client) Icon(iconID int64) (*FeedIcon, error) {
|
||||
body, err := c.request.Get(fmt.Sprintf("/v1/icons/%d", iconID))
|
||||
ctx, cancel := withDefaultTimeout()
|
||||
defer cancel()
|
||||
return c.IconContext(ctx, iconID)
|
||||
}
|
||||
|
||||
// IconContext fetches a feed icon.
|
||||
func (c *Client) IconContext(ctx context.Context, iconID int64) (*FeedIcon, error) {
|
||||
body, err := c.request.Get(ctx, fmt.Sprintf("/v1/icons/%d", iconID))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -721,7 +1115,14 @@ func (c *Client) Icon(iconID int64) (*FeedIcon, error) {
|
||||
|
||||
// Enclosure fetches a specific enclosure.
|
||||
func (c *Client) Enclosure(enclosureID int64) (*Enclosure, error) {
|
||||
body, err := c.request.Get(fmt.Sprintf("/v1/enclosures/%d", enclosureID))
|
||||
ctx, cancel := withDefaultTimeout()
|
||||
defer cancel()
|
||||
return c.EnclosureContext(ctx, enclosureID)
|
||||
}
|
||||
|
||||
// EnclosureContext fetches a specific enclosure.
|
||||
func (c *Client) EnclosureContext(ctx context.Context, enclosureID int64) (*Enclosure, error) {
|
||||
body, err := c.request.Get(ctx, fmt.Sprintf("/v1/enclosures/%d", enclosureID))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -737,7 +1138,14 @@ func (c *Client) Enclosure(enclosureID int64) (*Enclosure, error) {
|
||||
|
||||
// UpdateEnclosure updates an enclosure.
|
||||
func (c *Client) UpdateEnclosure(enclosureID int64, enclosureUpdate *EnclosureUpdateRequest) error {
|
||||
_, err := c.request.Put(fmt.Sprintf("/v1/enclosures/%d", enclosureID), enclosureUpdate)
|
||||
ctx, cancel := withDefaultTimeout()
|
||||
defer cancel()
|
||||
return c.UpdateEnclosureContext(ctx, enclosureID, enclosureUpdate)
|
||||
}
|
||||
|
||||
// UpdateEnclosureContext updates an enclosure.
|
||||
func (c *Client) UpdateEnclosureContext(ctx context.Context, enclosureID int64, enclosureUpdate *EnclosureUpdateRequest) error {
|
||||
_, err := c.request.Put(ctx, fmt.Sprintf("/v1/enclosures/%d", enclosureID), enclosureUpdate)
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+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 {
|
||||
|
||||
@@ -356,6 +356,7 @@ type APIKeyCreationRequest struct {
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
// SetOptionalField returns a pointer to the given value so optional request fields can be marked as set.
|
||||
func SetOptionalField[T any](value T) *T {
|
||||
return &value
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package client // import "miniflux.app/v2/client"
|
||||
|
||||
import "net/http"
|
||||
|
||||
type Option func(*request)
|
||||
|
||||
// WithAPIKey sets the API key for the client.
|
||||
func WithAPIKey(apiKey string) Option {
|
||||
return func(r *request) {
|
||||
r.apiKey = apiKey
|
||||
}
|
||||
}
|
||||
|
||||
// WithCredentials sets the username and password for the client.
|
||||
func WithCredentials(username, password string) Option {
|
||||
return func(r *request) {
|
||||
r.username = username
|
||||
r.password = password
|
||||
}
|
||||
}
|
||||
|
||||
// WithHTTPClient sets the HTTP client for the client.
|
||||
func WithHTTPClient(client *http.Client) Option {
|
||||
return func(r *request) {
|
||||
r.client = client
|
||||
}
|
||||
}
|
||||
+25
-23
@@ -5,6 +5,7 @@ package client // import "miniflux.app/v2/client"
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -17,7 +18,7 @@ import (
|
||||
|
||||
const (
|
||||
userAgent = "Miniflux Client Library"
|
||||
defaultTimeout = 80
|
||||
defaultTimeout = 80 * time.Second
|
||||
)
|
||||
|
||||
// List of exposed errors.
|
||||
@@ -39,30 +40,36 @@ type request struct {
|
||||
username string
|
||||
password string
|
||||
apiKey string
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
func (r *request) Get(path string) (io.ReadCloser, error) {
|
||||
return r.execute(http.MethodGet, path, nil)
|
||||
func (r *request) Get(ctx context.Context, path string) (io.ReadCloser, error) {
|
||||
return r.execute(ctx, http.MethodGet, path, nil)
|
||||
}
|
||||
|
||||
func (r *request) Post(path string, data any) (io.ReadCloser, error) {
|
||||
return r.execute(http.MethodPost, path, data)
|
||||
func (r *request) Post(ctx context.Context, path string, data any) (io.ReadCloser, error) {
|
||||
return r.execute(ctx, http.MethodPost, path, data)
|
||||
}
|
||||
|
||||
func (r *request) PostFile(path string, f io.ReadCloser) (io.ReadCloser, error) {
|
||||
return r.execute(http.MethodPost, path, f)
|
||||
func (r *request) PostFile(ctx context.Context, path string, f io.ReadCloser) (io.ReadCloser, error) {
|
||||
return r.execute(ctx, http.MethodPost, path, f)
|
||||
}
|
||||
|
||||
func (r *request) Put(path string, data any) (io.ReadCloser, error) {
|
||||
return r.execute(http.MethodPut, path, data)
|
||||
func (r *request) Put(ctx context.Context, path string, data any) (io.ReadCloser, error) {
|
||||
return r.execute(ctx, http.MethodPut, path, data)
|
||||
}
|
||||
|
||||
func (r *request) Delete(path string) error {
|
||||
_, err := r.execute(http.MethodDelete, path, nil)
|
||||
func (r *request) Delete(ctx context.Context, path string) error {
|
||||
_, err := r.execute(ctx, http.MethodDelete, path, nil)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *request) execute(method, path string, data any) (io.ReadCloser, error) {
|
||||
func (r *request) execute(
|
||||
ctx context.Context,
|
||||
method string,
|
||||
path string,
|
||||
data any,
|
||||
) (io.ReadCloser, error) {
|
||||
if r.endpoint == "" {
|
||||
return nil, ErrEmptyEndpoint
|
||||
}
|
||||
@@ -75,12 +82,13 @@ func (r *request) execute(method, path string, data any) (io.ReadCloser, error)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
request := &http.Request{
|
||||
URL: u,
|
||||
Method: method,
|
||||
Header: r.buildHeaders(),
|
||||
request, err := http.NewRequestWithContext(ctx, method, u.String(), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
request.Header = r.buildHeaders()
|
||||
|
||||
if r.username != "" && r.password != "" {
|
||||
request.SetBasicAuth(r.username, r.password)
|
||||
}
|
||||
@@ -94,7 +102,7 @@ func (r *request) execute(method, path string, data any) (io.ReadCloser, error)
|
||||
}
|
||||
}
|
||||
|
||||
client := r.buildClient()
|
||||
client := r.client
|
||||
response, err := client.Do(request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -143,12 +151,6 @@ func (r *request) execute(method, path string, data any) (io.ReadCloser, error)
|
||||
return response.Body, nil
|
||||
}
|
||||
|
||||
func (r *request) buildClient() http.Client {
|
||||
return http.Client{
|
||||
Timeout: defaultTimeout * time.Second,
|
||||
}
|
||||
}
|
||||
|
||||
func (r *request) buildHeaders() http.Header {
|
||||
headers := make(http.Header)
|
||||
headers.Add("User-Agent", userAgent)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -3,25 +3,25 @@ module miniflux.app/v2
|
||||
// +heroku goVersion go1.24
|
||||
|
||||
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.15.0
|
||||
github.com/go-webauthn/webauthn v0.13.4
|
||||
github.com/coreos/go-oidc/v3 v3.17.0
|
||||
github.com/go-webauthn/webauthn v0.15.0
|
||||
github.com/gorilla/mux v1.8.1
|
||||
github.com/lib/pq v1.10.9
|
||||
github.com/prometheus/client_golang v1.23.0
|
||||
github.com/tdewolff/minify/v2 v2.23.11
|
||||
golang.org/x/crypto v0.41.0
|
||||
golang.org/x/image v0.30.0
|
||||
golang.org/x/net v0.43.0
|
||||
golang.org/x/oauth2 v0.30.0
|
||||
golang.org/x/term v0.34.0
|
||||
github.com/lib/pq v1.11.2
|
||||
github.com/prometheus/client_golang v1.23.2
|
||||
github.com/tdewolff/minify/v2 v2.24.8
|
||||
golang.org/x/crypto v0.48.0
|
||||
golang.org/x/image v0.36.0
|
||||
golang.org/x/net v0.50.0
|
||||
golang.org/x/oauth2 v0.35.0
|
||||
golang.org/x/term v0.40.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/go-webauthn/x v0.1.23 // indirect
|
||||
github.com/golang-jwt/jwt/v5 v5.2.3 // indirect
|
||||
github.com/google/go-tpm v0.9.5 // indirect
|
||||
github.com/go-webauthn/x v0.1.26 // indirect
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0 // indirect
|
||||
github.com/google/go-tpm v0.9.6 // indirect
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -29,18 +29,20 @@ require (
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
|
||||
github.com/go-jose/go-jose/v4 v4.0.5 // indirect
|
||||
github.com/go-jose/go-jose/v4 v4.1.3 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
||||
github.com/kr/text v0.2.0 // indirect
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||
github.com/prometheus/client_model v0.6.2 // indirect
|
||||
github.com/prometheus/common v0.65.0 // indirect
|
||||
github.com/prometheus/common v0.66.1 // indirect
|
||||
github.com/prometheus/procfs v0.16.1 // indirect
|
||||
github.com/tdewolff/parse/v2 v2.8.2-0.20250806174018-50048bb39781 // indirect
|
||||
github.com/tdewolff/parse/v2 v2.8.5 // indirect
|
||||
github.com/x448/float16 v0.8.4 // indirect
|
||||
golang.org/x/sys v0.35.0 // indirect
|
||||
golang.org/x/text v0.28.0 // indirect
|
||||
google.golang.org/protobuf v1.36.6 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.2 // indirect
|
||||
golang.org/x/sys v0.41.0 // indirect
|
||||
golang.org/x/text v0.34.0 // indirect
|
||||
google.golang.org/protobuf v1.36.8 // indirect
|
||||
)
|
||||
|
||||
go 1.24.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,55 +8,62 @@ 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.15.0 h1:R6Oz8Z4bqWR7VFQ+sPSvZPQv4x8M+sJkDO5ojgwlyAg=
|
||||
github.com/coreos/go-oidc/v3 v3.15.0/go.mod h1:HaZ3szPaZ0e4r6ebqvsLWlk2Tn+aejfmrfah6hnSYEU=
|
||||
github.com/coreos/go-oidc/v3 v3.17.0 h1:hWBGaQfbi0iVviX4ibC7bk8OKT5qNr4klBaCHVNvehc=
|
||||
github.com/coreos/go-oidc/v3 v3.17.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
|
||||
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
|
||||
github.com/go-jose/go-jose/v4 v4.0.5 h1:M6T8+mKZl/+fNNuFHvGIzDz7BTLQPIounk/b9dw3AaE=
|
||||
github.com/go-jose/go-jose/v4 v4.0.5/go.mod h1:s3P1lRrkT8igV8D9OjyL4WRyHvjB6a4JSllnOrmmBOA=
|
||||
github.com/go-webauthn/webauthn v0.13.4 h1:q68qusWPcqHbg9STSxBLBHnsKaLxNO0RnVKaAqMuAuQ=
|
||||
github.com/go-webauthn/webauthn v0.13.4/go.mod h1:MglN6OH9ECxvhDqoq1wMoF6P6JRYDiQpC9nc5OomQmI=
|
||||
github.com/go-webauthn/x v0.1.23 h1:9lEO0s+g8iTyz5Vszlg/rXTGrx3CjcD0RZQ1GPZCaxI=
|
||||
github.com/go-webauthn/x v0.1.23/go.mod h1:AJd3hI7NfEp/4fI6T4CHD753u91l510lglU7/NMN6+E=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.3 h1:kkGXqQOBSDDWRhWNXTFpqGSCMyh/PLnqUvMGJPDJDs0=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.3/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs=
|
||||
github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||
github.com/go-webauthn/webauthn v0.15.0 h1:LR1vPv62E0/6+sTenX35QrCmpMCzLeVAcnXeH4MrbJY=
|
||||
github.com/go-webauthn/webauthn v0.15.0/go.mod h1:hcAOhVChPRG7oqG7Xj6XKN1mb+8eXTGP/B7zBLzkX5A=
|
||||
github.com/go-webauthn/x v0.1.26 h1:eNzreFKnwNLDFoywGh9FA8YOMebBWTUNlNSdolQRebs=
|
||||
github.com/go-webauthn/x v0.1.26/go.mod h1:jmf/phPV6oIsF6hmdVre+ovHkxjDOmNH0t6fekWUxvg=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/go-tpm v0.9.5 h1:ocUmnDebX54dnW+MQWGQRbdaAcJELsa6PqZhJ48KwVU=
|
||||
github.com/google/go-tpm v0.9.5/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
|
||||
github.com/google/go-tpm v0.9.6 h1:Ku42PT4LmjDu1H5C5ISWLlpI1mj+Zq7sPGKoRw2XROA=
|
||||
github.com/google/go-tpm v0.9.6/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
|
||||
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
|
||||
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
||||
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
||||
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||
github.com/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=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/prometheus/client_golang v1.23.0 h1:ust4zpdl9r4trLY/gSjlm07PuiBq2ynaXXlptpfy8Uc=
|
||||
github.com/prometheus/client_golang v1.23.0/go.mod h1:i/o0R9ByOnHX0McrTMTyhYvKE4haaf2mW08I+jGAjEE=
|
||||
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
|
||||
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
|
||||
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
|
||||
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
|
||||
github.com/prometheus/common v0.65.0 h1:QDwzd+G1twt//Kwj/Ww6E9FQq1iVMmODnILtW1t2VzE=
|
||||
github.com/prometheus/common v0.65.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8=
|
||||
github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs=
|
||||
github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA=
|
||||
github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg=
|
||||
github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
|
||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/tdewolff/minify/v2 v2.23.11 h1:cZqTVCtuVvPC8/GbCvYgIcdAQGmoxEObZzKeKIUixTE=
|
||||
github.com/tdewolff/minify/v2 v2.23.11/go.mod h1:vmkbfGQ5hp/eYB+TswNWKma67S0a+32HBL+mFWxjZ2Q=
|
||||
github.com/tdewolff/parse/v2 v2.8.2-0.20250806174018-50048bb39781 h1:2qicgFovKg1XtX7Wf6GwexUdpb7q/jMIE2IgkYsVAvE=
|
||||
github.com/tdewolff/parse/v2 v2.8.2-0.20250806174018-50048bb39781/go.mod h1:Hwlni2tiVNKyzR1o6nUs4FOF07URA+JLBLd6dlIXYqo=
|
||||
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
|
||||
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/tdewolff/minify/v2 v2.24.8 h1:58/VjsbevI4d5FGV0ZSuBrHMSSkH4MCH0sIz/eKIauE=
|
||||
github.com/tdewolff/minify/v2 v2.24.8/go.mod h1:0Ukj0CRpo/sW/nd8uZ4ccXaV1rEVIWA3dj8U7+Shhfw=
|
||||
github.com/tdewolff/parse/v2 v2.8.5 h1:ZmBiA/8Do5Rpk7bDye0jbbDUpXXbCdc3iah4VeUvwYU=
|
||||
github.com/tdewolff/parse/v2 v2.8.5/go.mod h1:Hwlni2tiVNKyzR1o6nUs4FOF07URA+JLBLd6dlIXYqo=
|
||||
github.com/tdewolff/test v1.0.11 h1:FdLbwQVHxqG16SlkGveC0JVyrJN62COWTRyUFzfbtBE=
|
||||
github.com/tdewolff/test v1.0.11/go.mod h1:XPuWBzvdUzhCuxWO1ojpXsyzsA5bFoS3tO/Q3kFuTG8=
|
||||
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
||||
@@ -66,16 +73,20 @@ github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3i
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
||||
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
||||
go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
|
||||
go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
|
||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||
golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4=
|
||||
golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc=
|
||||
golang.org/x/image v0.30.0 h1:jD5RhkmVAnjqaCUXfbGBrn3lpxbknfN9w2UhHHU+5B4=
|
||||
golang.org/x/image v0.30.0/go.mod h1:SAEUTxCCMWSrJcCy/4HwavEsfZZJlYxeHLc6tTiAe/c=
|
||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||
golang.org/x/image v0.36.0 h1:Iknbfm1afbgtwPTmHnS2gTM/6PPZfH+z2EFuOkSbqwc=
|
||||
golang.org/x/image v0.36.0/go.mod h1:YsWD2TyyGKiIX1kZlu9QfKIsQ4nAAK9bdgdrIsE7xy4=
|
||||
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=
|
||||
@@ -90,10 +101,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.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
|
||||
golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
|
||||
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
|
||||
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
|
||||
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
|
||||
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
|
||||
golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
|
||||
golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
@@ -112,8 +123,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.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
|
||||
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
@@ -123,8 +134,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.34.0 h1:O/2T7POpk0ZZ7MAzMeWFSg6S5IpWd/RXDlM9hgM3DR4=
|
||||
golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw=
|
||||
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
|
||||
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
|
||||
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=
|
||||
@@ -134,8 +145,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.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
|
||||
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
|
||||
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||
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=
|
||||
@@ -143,7 +154,10 @@ golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
|
||||
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
|
||||
google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc=
|
||||
google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
+3
-1
@@ -62,12 +62,14 @@ 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)
|
||||
sr.HandleFunc("/entries/{entryID}", handler.getEntry).Methods(http.MethodGet)
|
||||
sr.HandleFunc("/entries/{entryID}", handler.updateEntry).Methods(http.MethodPut)
|
||||
sr.HandleFunc("/entries/{entryID}/bookmark", handler.toggleBookmark).Methods(http.MethodPut)
|
||||
sr.HandleFunc("/entries/{entryID}/bookmark", handler.toggleStarred).Methods(http.MethodPut)
|
||||
sr.HandleFunc("/entries/{entryID}/star", handler.toggleStarred).Methods(http.MethodPut)
|
||||
sr.HandleFunc("/entries/{entryID}/save", handler.saveEntry).Methods(http.MethodPost)
|
||||
sr.HandleFunc("/entries/{entryID}/fetch-content", handler.fetchContent).Methods(http.MethodGet)
|
||||
sr.HandleFunc("/flush-history", handler.flushHistory).Methods(http.MethodPut, http.MethodDelete)
|
||||
|
||||
@@ -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`
|
||||
@@ -2381,7 +2382,6 @@ func TestGetGlobalEntriesEndpoint(t *testing.T) {
|
||||
}
|
||||
|
||||
feedIDEntry, err := regularUserClient.Feed(feedID)
|
||||
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -2413,6 +2413,64 @@ func TestGetGlobalEntriesEndpoint(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCannotGetRemovedEntries(t *testing.T) {
|
||||
testConfig := newIntegrationTestConfig()
|
||||
if !testConfig.isConfigured() {
|
||||
t.Skip(skipIntegrationTestsMessage)
|
||||
}
|
||||
|
||||
adminClient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)
|
||||
|
||||
regularTestUser, err := adminClient.CreateUser(testConfig.genRandomUsername(), testConfig.testRegularPassword, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer adminClient.DeleteUser(regularTestUser.ID)
|
||||
|
||||
regularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)
|
||||
|
||||
feedID, err := regularUserClient.CreateFeed(&miniflux.FeedCreationRequest{
|
||||
FeedURL: testConfig.testFeedURL,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
feedEntries, err := regularUserClient.Entries(&miniflux.Filter{FeedID: feedID})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if feedEntries.Total == 0 {
|
||||
t.Fatalf(`Expected at least one entry, got none`)
|
||||
}
|
||||
|
||||
if err := regularUserClient.UpdateEntries([]int64{feedEntries.Entries[0].ID}, miniflux.EntryStatusRemoved); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := regularUserClient.Entry(feedEntries.Entries[0].ID); err != miniflux.ErrNotFound {
|
||||
t.Fatalf(`Expected entry to be not found, got %v`, err)
|
||||
}
|
||||
|
||||
if _, err := regularUserClient.FeedEntry(feedID, feedEntries.Entries[0].ID); err != miniflux.ErrNotFound {
|
||||
t.Fatalf(`Expected entry to be not found, got %v`, err)
|
||||
}
|
||||
|
||||
if _, err := regularUserClient.CategoryEntry(feedEntries.Entries[0].Feed.Category.ID, feedEntries.Entries[0].ID); err != miniflux.ErrNotFound {
|
||||
t.Fatalf(`Expected entry to be not found, got %v`, err)
|
||||
}
|
||||
|
||||
updatedFeedEntries, err := regularUserClient.Entries(&miniflux.Filter{FeedID: feedID})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if updatedFeedEntries.Total != feedEntries.Total-1 {
|
||||
t.Fatalf(`Expected %d entries, got %d`, feedEntries.Total-1, updatedFeedEntries.Total)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateEnclosureEndpoint(t *testing.T) {
|
||||
testConfig := newIntegrationTestConfig()
|
||||
if !testConfig.isConfigured() {
|
||||
@@ -2631,64 +2689,6 @@ func TestUpdateEntryStatusEndpoint(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateEntryRemovedStatusEndpoint(t *testing.T) {
|
||||
testConfig := newIntegrationTestConfig()
|
||||
if !testConfig.isConfigured() {
|
||||
t.Skip(skipIntegrationTestsMessage)
|
||||
}
|
||||
|
||||
adminClient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)
|
||||
|
||||
regularTestUser, err := adminClient.CreateUser(testConfig.genRandomUsername(), testConfig.testRegularPassword, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer adminClient.DeleteUser(regularTestUser.ID)
|
||||
|
||||
regularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)
|
||||
|
||||
feedID, err := regularUserClient.CreateFeed(&miniflux.FeedCreationRequest{
|
||||
FeedURL: testConfig.testFeedURL,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
result, err := regularUserClient.FeedEntries(feedID, nil)
|
||||
if err != nil {
|
||||
t.Fatalf(`Failed to get entries: %v`, err)
|
||||
}
|
||||
|
||||
// First we set the entry as "removed"
|
||||
if err := regularUserClient.UpdateEntries([]int64{result.Entries[0].ID}, miniflux.EntryStatusRemoved); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
entry, err := regularUserClient.Entry(result.Entries[0].ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if entry.Status != miniflux.EntryStatusRemoved {
|
||||
t.Fatalf(`Invalid status, got %q instead of %q`, entry.Status, miniflux.EntryStatusRemoved)
|
||||
}
|
||||
|
||||
// Then we try to set it to "unread"
|
||||
if err := regularUserClient.UpdateEntries([]int64{result.Entries[0].ID}, miniflux.EntryStatusUnread); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
entry, err = regularUserClient.Entry(result.Entries[0].ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// It should stay set to "removed"
|
||||
if entry.Status != miniflux.EntryStatusRemoved {
|
||||
t.Fatalf(`Modified immutable status: got %q instead of %q`, entry.Status, miniflux.EntryStatusRemoved)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateEntryEndpoint(t *testing.T) {
|
||||
testConfig := newIntegrationTestConfig()
|
||||
if !testConfig.isConfigured() {
|
||||
@@ -2749,7 +2749,7 @@ func TestUpdateEntryEndpoint(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestToggleBookmarkEndpoint(t *testing.T) {
|
||||
func TestToggleStarredEndpoint(t *testing.T) {
|
||||
testConfig := newIntegrationTestConfig()
|
||||
if !testConfig.isConfigured() {
|
||||
t.Skip(skipIntegrationTestsMessage)
|
||||
@@ -2777,7 +2777,7 @@ func TestToggleBookmarkEndpoint(t *testing.T) {
|
||||
t.Fatalf(`Failed to get entries: %v`, err)
|
||||
}
|
||||
|
||||
if err := regularUserClient.ToggleBookmark(result.Entries[0].ID); err != nil {
|
||||
if err := regularUserClient.ToggleStarred(result.Entries[0].ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -2787,7 +2787,7 @@ func TestToggleBookmarkEndpoint(t *testing.T) {
|
||||
}
|
||||
|
||||
if !entry.Starred {
|
||||
t.Fatalf(`The entry should be bookmarked`)
|
||||
t.Fatalf(`The entry should be starred`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2932,13 +2932,56 @@ func TestFlushHistoryEndpoint(t *testing.T) {
|
||||
if readEntries.Total != 0 {
|
||||
t.Fatalf(`Invalid total, got %d`, readEntries.Total)
|
||||
}
|
||||
}
|
||||
|
||||
removedEntries, err := regularUserClient.Entries(&miniflux.Filter{Status: miniflux.EntryStatusRemoved})
|
||||
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 removedEntries.Total != 2 {
|
||||
t.Fatalf(`Invalid total, got %d`, removedEntries.Total)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
+118
-7
@@ -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"
|
||||
)
|
||||
@@ -47,6 +49,7 @@ func (h *handler) getFeedEntry(w http.ResponseWriter, r *http.Request) {
|
||||
builder := h.store.NewEntryQueryBuilder(request.UserID(r))
|
||||
builder.WithFeedID(feedID)
|
||||
builder.WithEntryID(entryID)
|
||||
builder.WithoutStatus(model.EntryStatusRemoved)
|
||||
|
||||
h.getEntryFromBuilder(w, r, builder)
|
||||
}
|
||||
@@ -58,6 +61,7 @@ func (h *handler) getCategoryEntry(w http.ResponseWriter, r *http.Request) {
|
||||
builder := h.store.NewEntryQueryBuilder(request.UserID(r))
|
||||
builder.WithCategoryID(categoryID)
|
||||
builder.WithEntryID(entryID)
|
||||
builder.WithoutStatus(model.EntryStatusRemoved)
|
||||
|
||||
h.getEntryFromBuilder(w, r, builder)
|
||||
}
|
||||
@@ -66,6 +70,7 @@ func (h *handler) getEntry(w http.ResponseWriter, r *http.Request) {
|
||||
entryID := request.RouteInt64Param(r, "entryID")
|
||||
builder := h.store.NewEntryQueryBuilder(request.UserID(r))
|
||||
builder.WithEntryID(entryID)
|
||||
builder.WithoutStatus(model.EntryStatusRemoved)
|
||||
|
||||
h.getEntryFromBuilder(w, r, builder)
|
||||
}
|
||||
@@ -136,6 +141,7 @@ func (h *handler) findEntries(w http.ResponseWriter, r *http.Request, feedID int
|
||||
builder.WithLimit(limit)
|
||||
builder.WithTags(tags)
|
||||
builder.WithEnclosures()
|
||||
builder.WithoutStatus(model.EntryStatusRemoved)
|
||||
|
||||
if request.HasQueryParam(r, "globally_visible") {
|
||||
globallyVisible := request.QueryBoolParam(r, "globally_visible", true)
|
||||
@@ -186,9 +192,9 @@ func (h *handler) setEntryStatus(w http.ResponseWriter, r *http.Request) {
|
||||
json.NoContent(w, r)
|
||||
}
|
||||
|
||||
func (h *handler) toggleBookmark(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *handler) toggleStarred(w http.ResponseWriter, r *http.Request) {
|
||||
entryID := request.RouteInt64Param(r, "entryID")
|
||||
if err := h.store.ToggleBookmark(request.UserID(r), entryID); err != nil {
|
||||
if err := h.store.ToggleStarred(request.UserID(r), entryID); err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
@@ -270,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)
|
||||
@@ -283,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")
|
||||
@@ -337,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) {
|
||||
|
||||
@@ -5,6 +5,7 @@ package cli // import "miniflux.app/v2/internal/cli"
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
@@ -16,7 +17,7 @@ func askCredentials() (string, string) {
|
||||
fd := int(os.Stdin.Fd())
|
||||
|
||||
if !term.IsTerminal(fd) {
|
||||
printErrorAndExit(fmt.Errorf("this is not an interactive terminal, exiting"))
|
||||
printErrorAndExit(errors.New("this is not an interactive terminal, exiting"))
|
||||
}
|
||||
|
||||
fmt.Print("Enter Username: ")
|
||||
@@ -30,6 +31,6 @@ func askCredentials() (string, string) {
|
||||
defer term.Restore(fd, state)
|
||||
bytePassword, _ := term.ReadPassword(fd)
|
||||
|
||||
fmt.Printf("\n")
|
||||
fmt.Print("\n")
|
||||
return strings.TrimSpace(username), strings.TrimSpace(string(bytePassword))
|
||||
}
|
||||
|
||||
@@ -14,15 +14,15 @@ import (
|
||||
)
|
||||
|
||||
func runCleanupTasks(store *storage.Storage) {
|
||||
nbSessions := store.CleanOldSessions(config.Opts.CleanupRemoveSessionsDays())
|
||||
nbUserSessions := store.CleanOldUserSessions(config.Opts.CleanupRemoveSessionsDays())
|
||||
nbSessions := store.CleanOldSessions(config.Opts.CleanupRemoveSessionsInterval())
|
||||
nbUserSessions := store.CleanOldUserSessions(config.Opts.CleanupRemoveSessionsInterval())
|
||||
slog.Info("Sessions cleanup completed",
|
||||
slog.Int64("application_sessions_removed", nbSessions),
|
||||
slog.Int64("user_sessions_removed", nbUserSessions),
|
||||
)
|
||||
|
||||
startTime := time.Now()
|
||||
if rowsAffected, err := store.ArchiveEntries(model.EntryStatusRead, config.Opts.CleanupArchiveReadDays(), config.Opts.CleanupArchiveBatchSize()); err != nil {
|
||||
if rowsAffected, err := store.ArchiveEntries(model.EntryStatusRead, config.Opts.CleanupArchiveReadInterval(), config.Opts.CleanupArchiveBatchSize()); err != nil {
|
||||
slog.Error("Unable to archive read entries", slog.Any("error", err))
|
||||
} else {
|
||||
slog.Info("Archiving read entries completed",
|
||||
@@ -35,7 +35,7 @@ func runCleanupTasks(store *storage.Storage) {
|
||||
}
|
||||
|
||||
startTime = time.Now()
|
||||
if rowsAffected, err := store.ArchiveEntries(model.EntryStatusUnread, config.Opts.CleanupArchiveUnreadDays(), config.Opts.CleanupArchiveBatchSize()); err != nil {
|
||||
if rowsAffected, err := store.ArchiveEntries(model.EntryStatusUnread, config.Opts.CleanupArchiveUnreadInterval(), config.Opts.CleanupArchiveBatchSize()); err != nil {
|
||||
slog.Error("Unable to archive unread entries", slog.Any("error", err))
|
||||
} else {
|
||||
slog.Info("Archiving unread entries completed",
|
||||
@@ -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",
|
||||
|
||||
+10
-4
@@ -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"
|
||||
)
|
||||
@@ -78,7 +78,7 @@ func Parse() {
|
||||
flag.StringVar(&flagExportUserFeeds, "export-user-feeds", "", flagExportUserFeedsHelp)
|
||||
flag.Parse()
|
||||
|
||||
cfg := config.NewParser()
|
||||
cfg := config.NewConfigParser()
|
||||
|
||||
if flagConfigFile != "" {
|
||||
config.Opts, err = cfg.ParseFile(flagConfigFile)
|
||||
@@ -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
|
||||
@@ -263,6 +269,6 @@ func Parse() {
|
||||
}
|
||||
|
||||
func printErrorAndExit(err error) {
|
||||
fmt.Fprintf(os.Stderr, "%v\n", err)
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
package cli // import "miniflux.app/v2/internal/cli"
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"miniflux.app/v2/internal/model"
|
||||
@@ -19,7 +20,7 @@ func resetPassword(store *storage.Storage) {
|
||||
}
|
||||
|
||||
if user == nil {
|
||||
printErrorAndExit(fmt.Errorf("user not found"))
|
||||
printErrorAndExit(errors.New("user not found"))
|
||||
}
|
||||
|
||||
userModificationRequest := &model.UserModificationRequest{
|
||||
|
||||
@@ -26,12 +26,12 @@ func runScheduler(store *storage.Storage, pool *worker.Pool) {
|
||||
|
||||
go cleanupScheduler(
|
||||
store,
|
||||
config.Opts.CleanupFrequencyHours(),
|
||||
config.Opts.CleanupFrequency(),
|
||||
)
|
||||
}
|
||||
|
||||
func feedScheduler(store *storage.Storage, pool *worker.Pool, frequency, batchSize, errorLimit, limitPerHost int) {
|
||||
for range time.Tick(time.Duration(frequency) * time.Minute) {
|
||||
func feedScheduler(store *storage.Storage, pool *worker.Pool, frequency time.Duration, batchSize, errorLimit, limitPerHost int) {
|
||||
for range time.Tick(frequency) {
|
||||
// Generate a batch of feeds for any user that has feeds to refresh.
|
||||
batchBuilder := store.NewBatchBuilder()
|
||||
batchBuilder.WithBatchSize(batchSize)
|
||||
@@ -49,8 +49,8 @@ func feedScheduler(store *storage.Storage, pool *worker.Pool, frequency, batchSi
|
||||
}
|
||||
}
|
||||
|
||||
func cleanupScheduler(store *storage.Storage, frequency int) {
|
||||
for range time.Tick(time.Duration(frequency) * time.Hour) {
|
||||
func cleanupScheduler(store *storage.Storage, frequency time.Duration) {
|
||||
for range time.Tick(frequency) {
|
||||
runCleanupTasks(store)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package config // import "miniflux.app/v2/internal/config"
|
||||
|
||||
import "miniflux.app/v2/internal/version"
|
||||
|
||||
// Opts holds parsed configuration options.
|
||||
var Opts *configOptions
|
||||
|
||||
var defaultHTTPClientUserAgent = "Mozilla/5.0 (compatible; Miniflux/" + version.Version + "; +https://miniflux.app)"
|
||||
File diff suppressed because it is too large
Load Diff
+844
-635
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+211
-267
@@ -10,307 +10,216 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// parser handles configuration parsing.
|
||||
type parser struct {
|
||||
opts *options
|
||||
type configParser struct {
|
||||
options *configOptions
|
||||
}
|
||||
|
||||
// NewParser returns a new Parser.
|
||||
func NewParser() *parser {
|
||||
return &parser{
|
||||
opts: NewOptions(),
|
||||
func NewConfigParser() *configParser {
|
||||
return &configParser{
|
||||
options: NewConfigOptions(),
|
||||
}
|
||||
}
|
||||
|
||||
// ParseEnvironmentVariables loads configuration values from environment variables.
|
||||
func (p *parser) ParseEnvironmentVariables() (*options, error) {
|
||||
err := p.parseLines(os.Environ())
|
||||
if err != nil {
|
||||
func (cp *configParser) ParseEnvironmentVariables() (*configOptions, error) {
|
||||
if err := cp.parseLines(os.Environ()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return p.opts, nil
|
||||
|
||||
return cp.options, nil
|
||||
}
|
||||
|
||||
// ParseFile loads configuration values from a local file.
|
||||
func (p *parser) ParseFile(filename string) (*options, error) {
|
||||
func (cp *configParser) ParseFile(filename string) (*configOptions, error) {
|
||||
fp, err := os.Open(filename)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer fp.Close()
|
||||
|
||||
err = p.parseLines(p.parseFileContent(fp))
|
||||
if err != nil {
|
||||
if err := cp.parseLines(parseFileContent(fp)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return p.opts, nil
|
||||
|
||||
return cp.options, nil
|
||||
}
|
||||
|
||||
func (p *parser) parseFileContent(r io.Reader) (lines []string) {
|
||||
scanner := bufio.NewScanner(r)
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if !strings.HasPrefix(line, "#") && strings.Index(line, "=") > 0 {
|
||||
lines = append(lines, line)
|
||||
}
|
||||
}
|
||||
return lines
|
||||
}
|
||||
func (cp *configParser) postParsing() error {
|
||||
// Parse basePath and rootURL based on BASE_URL
|
||||
baseURL := cp.options.options["BASE_URL"].parsedStringValue
|
||||
baseURL = strings.TrimSuffix(baseURL, "/")
|
||||
|
||||
func (p *parser) parseLines(lines []string) (err error) {
|
||||
var port string
|
||||
|
||||
for lineNum, line := range lines {
|
||||
key, value, ok := strings.Cut(line, "=")
|
||||
if !ok {
|
||||
return fmt.Errorf("config: unable to parse configuration, invalid format on line %d", lineNum)
|
||||
}
|
||||
key, value = strings.TrimSpace(key), strings.TrimSpace(value)
|
||||
|
||||
switch key {
|
||||
case "LOG_FILE":
|
||||
p.opts.logFile = parseString(value, defaultLogFile)
|
||||
case "LOG_DATE_TIME":
|
||||
p.opts.logDateTime = parseBool(value, defaultLogDateTime)
|
||||
case "LOG_LEVEL":
|
||||
parsedValue := parseString(value, defaultLogLevel)
|
||||
if parsedValue == "debug" || parsedValue == "info" || parsedValue == "warning" || parsedValue == "error" {
|
||||
p.opts.logLevel = parsedValue
|
||||
}
|
||||
case "LOG_FORMAT":
|
||||
parsedValue := parseString(value, defaultLogFormat)
|
||||
if parsedValue == "json" || parsedValue == "text" {
|
||||
p.opts.logFormat = parsedValue
|
||||
}
|
||||
case "BASE_URL":
|
||||
p.opts.baseURL, p.opts.rootURL, p.opts.basePath, err = parseBaseURL(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
case "PORT":
|
||||
port = value
|
||||
case "LISTEN_ADDR":
|
||||
p.opts.listenAddr = parseStringList(value, []string{defaultListenAddr})
|
||||
case "DATABASE_URL":
|
||||
p.opts.databaseURL = parseString(value, defaultDatabaseURL)
|
||||
case "DATABASE_URL_FILE":
|
||||
p.opts.databaseURL = readSecretFile(value, defaultDatabaseURL)
|
||||
case "DATABASE_MAX_CONNS":
|
||||
p.opts.databaseMaxConns = parseInt(value, defaultDatabaseMaxConns)
|
||||
case "DATABASE_MIN_CONNS":
|
||||
p.opts.databaseMinConns = parseInt(value, defaultDatabaseMinConns)
|
||||
case "DATABASE_CONNECTION_LIFETIME":
|
||||
p.opts.databaseConnectionLifetime = parseInt(value, defaultDatabaseConnectionLifetime)
|
||||
case "FILTER_ENTRY_MAX_AGE_DAYS":
|
||||
p.opts.filterEntryMaxAgeDays = parseInt(value, defaultFilterEntryMaxAgeDays)
|
||||
case "RUN_MIGRATIONS":
|
||||
p.opts.runMigrations = parseBool(value, defaultRunMigrations)
|
||||
case "DISABLE_HSTS":
|
||||
p.opts.hsts = !parseBool(value, defaultHSTS)
|
||||
case "HTTPS":
|
||||
p.opts.HTTPS = parseBool(value, defaultHTTPS)
|
||||
case "DISABLE_SCHEDULER_SERVICE":
|
||||
p.opts.schedulerService = !parseBool(value, defaultSchedulerService)
|
||||
case "DISABLE_HTTP_SERVICE":
|
||||
p.opts.httpService = !parseBool(value, defaultHTTPService)
|
||||
case "CERT_FILE":
|
||||
p.opts.certFile = parseString(value, defaultCertFile)
|
||||
case "KEY_FILE":
|
||||
p.opts.certKeyFile = parseString(value, defaultKeyFile)
|
||||
case "CERT_DOMAIN":
|
||||
p.opts.certDomain = parseString(value, defaultCertDomain)
|
||||
case "CLEANUP_FREQUENCY_HOURS":
|
||||
p.opts.cleanupFrequencyHours = parseInt(value, defaultCleanupFrequencyHours)
|
||||
case "CLEANUP_ARCHIVE_READ_DAYS":
|
||||
p.opts.cleanupArchiveReadDays = parseInt(value, defaultCleanupArchiveReadDays)
|
||||
case "CLEANUP_ARCHIVE_UNREAD_DAYS":
|
||||
p.opts.cleanupArchiveUnreadDays = parseInt(value, defaultCleanupArchiveUnreadDays)
|
||||
case "CLEANUP_ARCHIVE_BATCH_SIZE":
|
||||
p.opts.cleanupArchiveBatchSize = parseInt(value, defaultCleanupArchiveBatchSize)
|
||||
case "CLEANUP_REMOVE_SESSIONS_DAYS":
|
||||
p.opts.cleanupRemoveSessionsDays = parseInt(value, defaultCleanupRemoveSessionsDays)
|
||||
case "WORKER_POOL_SIZE":
|
||||
p.opts.workerPoolSize = parseInt(value, defaultWorkerPoolSize)
|
||||
case "FORCE_REFRESH_INTERVAL":
|
||||
p.opts.forceRefreshInterval = parseInt(value, defaultForceRefreshInterval)
|
||||
case "BATCH_SIZE":
|
||||
p.opts.batchSize = parseInt(value, defaultBatchSize)
|
||||
case "POLLING_FREQUENCY":
|
||||
p.opts.pollingFrequency = parseInt(value, defaultPollingFrequency)
|
||||
case "POLLING_LIMIT_PER_HOST":
|
||||
p.opts.pollingLimitPerHost = parseInt(value, 0)
|
||||
case "POLLING_PARSING_ERROR_LIMIT":
|
||||
p.opts.pollingParsingErrorLimit = parseInt(value, defaultPollingParsingErrorLimit)
|
||||
case "POLLING_SCHEDULER":
|
||||
p.opts.pollingScheduler = strings.ToLower(parseString(value, defaultPollingScheduler))
|
||||
case "SCHEDULER_ENTRY_FREQUENCY_MAX_INTERVAL":
|
||||
p.opts.schedulerEntryFrequencyMaxInterval = parseInt(value, defaultSchedulerEntryFrequencyMaxInterval)
|
||||
case "SCHEDULER_ENTRY_FREQUENCY_MIN_INTERVAL":
|
||||
p.opts.schedulerEntryFrequencyMinInterval = parseInt(value, defaultSchedulerEntryFrequencyMinInterval)
|
||||
case "SCHEDULER_ENTRY_FREQUENCY_FACTOR":
|
||||
p.opts.schedulerEntryFrequencyFactor = parseInt(value, defaultSchedulerEntryFrequencyFactor)
|
||||
case "SCHEDULER_ROUND_ROBIN_MIN_INTERVAL":
|
||||
p.opts.schedulerRoundRobinMinInterval = parseInt(value, defaultSchedulerRoundRobinMinInterval)
|
||||
case "SCHEDULER_ROUND_ROBIN_MAX_INTERVAL":
|
||||
p.opts.schedulerRoundRobinMaxInterval = parseInt(value, defaultSchedulerRoundRobinMaxInterval)
|
||||
case "MEDIA_PROXY_HTTP_CLIENT_TIMEOUT":
|
||||
p.opts.mediaProxyHTTPClientTimeout = parseInt(value, defaultMediaProxyHTTPClientTimeout)
|
||||
case "MEDIA_PROXY_MODE":
|
||||
p.opts.mediaProxyMode = parseString(value, defaultMediaProxyMode)
|
||||
case "MEDIA_PROXY_RESOURCE_TYPES":
|
||||
p.opts.mediaProxyResourceTypes = parseStringList(value, []string{defaultMediaResourceTypes})
|
||||
case "MEDIA_PROXY_PRIVATE_KEY":
|
||||
randomKey := make([]byte, 16)
|
||||
rand.Read(randomKey)
|
||||
p.opts.mediaProxyPrivateKey = parseBytes(value, randomKey)
|
||||
case "MEDIA_PROXY_CUSTOM_URL":
|
||||
p.opts.mediaProxyCustomURL, err = url.Parse(parseString(value, defaultMediaProxyURL))
|
||||
if err != nil {
|
||||
return fmt.Errorf("config: invalid MEDIA_PROXY_CUSTOM_URL value: %w", err)
|
||||
}
|
||||
case "CREATE_ADMIN":
|
||||
p.opts.createAdmin = parseBool(value, defaultCreateAdmin)
|
||||
case "ADMIN_USERNAME":
|
||||
p.opts.adminUsername = parseString(value, defaultAdminUsername)
|
||||
case "ADMIN_USERNAME_FILE":
|
||||
p.opts.adminUsername = readSecretFile(value, defaultAdminUsername)
|
||||
case "ADMIN_PASSWORD":
|
||||
p.opts.adminPassword = parseString(value, defaultAdminPassword)
|
||||
case "ADMIN_PASSWORD_FILE":
|
||||
p.opts.adminPassword = readSecretFile(value, defaultAdminPassword)
|
||||
case "OAUTH2_USER_CREATION":
|
||||
p.opts.oauth2UserCreationAllowed = parseBool(value, defaultOAuth2UserCreation)
|
||||
case "OAUTH2_CLIENT_ID":
|
||||
p.opts.oauth2ClientID = parseString(value, defaultOAuth2ClientID)
|
||||
case "OAUTH2_CLIENT_ID_FILE":
|
||||
p.opts.oauth2ClientID = readSecretFile(value, defaultOAuth2ClientID)
|
||||
case "OAUTH2_CLIENT_SECRET":
|
||||
p.opts.oauth2ClientSecret = parseString(value, defaultOAuth2ClientSecret)
|
||||
case "OAUTH2_CLIENT_SECRET_FILE":
|
||||
p.opts.oauth2ClientSecret = readSecretFile(value, defaultOAuth2ClientSecret)
|
||||
case "OAUTH2_REDIRECT_URL":
|
||||
p.opts.oauth2RedirectURL = parseString(value, defaultOAuth2RedirectURL)
|
||||
case "OAUTH2_OIDC_DISCOVERY_ENDPOINT":
|
||||
p.opts.oidcDiscoveryEndpoint = parseString(value, defaultOAuth2OidcDiscoveryEndpoint)
|
||||
case "OAUTH2_OIDC_PROVIDER_NAME":
|
||||
p.opts.oidcProviderName = parseString(value, defaultOauth2OidcProviderName)
|
||||
case "OAUTH2_PROVIDER":
|
||||
p.opts.oauth2Provider = parseString(value, defaultOAuth2Provider)
|
||||
case "DISABLE_LOCAL_AUTH":
|
||||
p.opts.disableLocalAuth = parseBool(value, defaultDisableLocalAuth)
|
||||
case "HTTP_CLIENT_TIMEOUT":
|
||||
p.opts.httpClientTimeout = parseInt(value, defaultHTTPClientTimeout)
|
||||
case "HTTP_CLIENT_MAX_BODY_SIZE":
|
||||
p.opts.httpClientMaxBodySize = int64(parseInt(value, defaultHTTPClientMaxBodySize) * 1024 * 1024)
|
||||
case "HTTP_CLIENT_PROXY":
|
||||
p.opts.httpClientProxyURL, err = url.Parse(parseString(value, defaultHTTPClientProxy))
|
||||
if err != nil {
|
||||
return fmt.Errorf("config: invalid HTTP_CLIENT_PROXY value: %w", err)
|
||||
}
|
||||
case "HTTP_CLIENT_PROXIES":
|
||||
p.opts.httpClientProxies = parseStringList(value, []string{})
|
||||
case "HTTP_CLIENT_USER_AGENT":
|
||||
p.opts.httpClientUserAgent = parseString(value, defaultHTTPClientUserAgent)
|
||||
case "HTTP_SERVER_TIMEOUT":
|
||||
p.opts.httpServerTimeout = parseInt(value, defaultHTTPServerTimeout)
|
||||
case "AUTH_PROXY_HEADER":
|
||||
p.opts.authProxyHeader = parseString(value, defaultAuthProxyHeader)
|
||||
case "AUTH_PROXY_USER_CREATION":
|
||||
p.opts.authProxyUserCreation = parseBool(value, defaultAuthProxyUserCreation)
|
||||
case "MAINTENANCE_MODE":
|
||||
p.opts.maintenanceMode = parseBool(value, defaultMaintenanceMode)
|
||||
case "MAINTENANCE_MESSAGE":
|
||||
p.opts.maintenanceMessage = parseString(value, defaultMaintenanceMessage)
|
||||
case "METRICS_COLLECTOR":
|
||||
p.opts.metricsCollector = parseBool(value, defaultMetricsCollector)
|
||||
case "METRICS_REFRESH_INTERVAL":
|
||||
p.opts.metricsRefreshInterval = parseInt(value, defaultMetricsRefreshInterval)
|
||||
case "METRICS_ALLOWED_NETWORKS":
|
||||
p.opts.metricsAllowedNetworks = parseStringList(value, []string{defaultMetricsAllowedNetworks})
|
||||
case "METRICS_USERNAME":
|
||||
p.opts.metricsUsername = parseString(value, defaultMetricsUsername)
|
||||
case "METRICS_USERNAME_FILE":
|
||||
p.opts.metricsUsername = readSecretFile(value, defaultMetricsUsername)
|
||||
case "METRICS_PASSWORD":
|
||||
p.opts.metricsPassword = parseString(value, defaultMetricsPassword)
|
||||
case "METRICS_PASSWORD_FILE":
|
||||
p.opts.metricsPassword = readSecretFile(value, defaultMetricsPassword)
|
||||
case "FETCH_BILIBILI_WATCH_TIME":
|
||||
p.opts.fetchBilibiliWatchTime = parseBool(value, defaultFetchBilibiliWatchTime)
|
||||
case "FETCH_NEBULA_WATCH_TIME":
|
||||
p.opts.fetchNebulaWatchTime = parseBool(value, defaultFetchNebulaWatchTime)
|
||||
case "FETCH_ODYSEE_WATCH_TIME":
|
||||
p.opts.fetchOdyseeWatchTime = parseBool(value, defaultFetchOdyseeWatchTime)
|
||||
case "FETCH_YOUTUBE_WATCH_TIME":
|
||||
p.opts.fetchYouTubeWatchTime = parseBool(value, defaultFetchYouTubeWatchTime)
|
||||
case "YOUTUBE_API_KEY":
|
||||
p.opts.youTubeApiKey = parseString(value, defaultYouTubeApiKey)
|
||||
case "YOUTUBE_EMBED_URL_OVERRIDE":
|
||||
p.opts.youTubeEmbedUrlOverride = parseString(value, defaultYouTubeEmbedUrlOverride)
|
||||
case "WATCHDOG":
|
||||
p.opts.watchdog = parseBool(value, defaultWatchdog)
|
||||
case "INVIDIOUS_INSTANCE":
|
||||
p.opts.invidiousInstance = parseString(value, defaultInvidiousInstance)
|
||||
case "WEBAUTHN":
|
||||
p.opts.webAuthn = parseBool(value, defaultWebAuthn)
|
||||
}
|
||||
}
|
||||
|
||||
if port != "" {
|
||||
p.opts.listenAddr = []string{":" + port}
|
||||
}
|
||||
|
||||
youtubeEmbedURL, err := url.Parse(p.opts.youTubeEmbedUrlOverride)
|
||||
parsedURL, err := url.Parse(baseURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("config: invalid YOUTUBE_EMBED_URL_OVERRIDE value: %w", err)
|
||||
}
|
||||
p.opts.youTubeEmbedDomain = youtubeEmbedURL.Hostname()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseBaseURL(value string) (string, string, string, error) {
|
||||
if value == "" {
|
||||
return defaultBaseURL, defaultRootURL, "", nil
|
||||
}
|
||||
|
||||
value = strings.TrimSuffix(value, "/")
|
||||
|
||||
parsedURL, err := url.Parse(value)
|
||||
if err != nil {
|
||||
return "", "", "", fmt.Errorf("config: invalid BASE_URL: %w", err)
|
||||
return fmt.Errorf("invalid BASE_URL: %v", err)
|
||||
}
|
||||
|
||||
scheme := strings.ToLower(parsedURL.Scheme)
|
||||
if scheme != "https" && scheme != "http" {
|
||||
return "", "", "", errors.New("config: invalid BASE_URL: scheme must be http or https")
|
||||
return errors.New("BASE_URL scheme must be http or https")
|
||||
}
|
||||
|
||||
basePath := parsedURL.Path
|
||||
cp.options.options["BASE_URL"].parsedStringValue = baseURL
|
||||
cp.options.basePath = parsedURL.Path
|
||||
|
||||
parsedURL.Path = ""
|
||||
return value, parsedURL.String(), basePath, nil
|
||||
cp.options.rootURL = parsedURL.String()
|
||||
|
||||
// Parse YouTube embed domain based on YOUTUBE_EMBED_URL_OVERRIDE
|
||||
youTubeEmbedURLOverride := cp.options.options["YOUTUBE_EMBED_URL_OVERRIDE"].parsedStringValue
|
||||
if youTubeEmbedURLOverride != "" {
|
||||
parsedYouTubeEmbedURL, err := url.Parse(youTubeEmbedURLOverride)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid YOUTUBE_EMBED_URL_OVERRIDE: %v", err)
|
||||
}
|
||||
cp.options.youTubeEmbedDomain = parsedYouTubeEmbedURL.Hostname()
|
||||
}
|
||||
|
||||
// Generate a media proxy private key if not set
|
||||
if len(cp.options.options["MEDIA_PROXY_PRIVATE_KEY"].parsedBytesValue) == 0 {
|
||||
randomKey := make([]byte, 16)
|
||||
rand.Read(randomKey)
|
||||
cp.options.options["MEDIA_PROXY_PRIVATE_KEY"].parsedBytesValue = randomKey
|
||||
}
|
||||
|
||||
// Override LISTEN_ADDR with PORT if set (for compatibility reasons)
|
||||
if cp.options.Port() != "" {
|
||||
cp.options.options["LISTEN_ADDR"].parsedStringList = []string{":" + cp.options.Port()}
|
||||
cp.options.options["LISTEN_ADDR"].rawValue = ":" + cp.options.Port()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseBool(value string, fallback bool) bool {
|
||||
func (cp *configParser) parseLines(lines []string) error {
|
||||
for lineNum, line := range lines {
|
||||
key, value, ok := strings.Cut(line, "=")
|
||||
if !ok {
|
||||
return fmt.Errorf("unable to parse configuration, invalid format on line %d", lineNum)
|
||||
}
|
||||
|
||||
key, value = strings.TrimSpace(key), strings.TrimSpace(value)
|
||||
if err := cp.parseLine(key, value); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if err := cp.postParsing(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (cp *configParser) parseLine(key, value string) error {
|
||||
field, exists := cp.options.options[key]
|
||||
if !exists {
|
||||
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 {
|
||||
return fmt.Errorf("invalid value for key %s: %v", key, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Convert the raw value based on its type
|
||||
switch field.valueType {
|
||||
case stringType:
|
||||
field.parsedStringValue = parseStringValue(value, field.parsedStringValue)
|
||||
field.rawValue = value
|
||||
case stringListType:
|
||||
field.parsedStringList = parseStringListValue(value, field.parsedStringList)
|
||||
field.rawValue = value
|
||||
case boolType:
|
||||
parsedValue, err := parseBoolValue(value, field.parsedBoolValue)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid boolean value for key %s: %v", key, err)
|
||||
}
|
||||
field.parsedBoolValue = parsedValue
|
||||
field.rawValue = value
|
||||
case intType:
|
||||
field.parsedIntValue = parseIntValue(value, field.parsedIntValue)
|
||||
field.rawValue = value
|
||||
case int64Type:
|
||||
field.parsedInt64Value = ParsedInt64Value(value, field.parsedInt64Value)
|
||||
field.rawValue = value
|
||||
case secondType:
|
||||
field.parsedDuration = parseDurationValue(value, time.Second, field.parsedDuration)
|
||||
field.rawValue = value
|
||||
case minuteType:
|
||||
field.parsedDuration = parseDurationValue(value, time.Minute, field.parsedDuration)
|
||||
field.rawValue = value
|
||||
case hourType:
|
||||
field.parsedDuration = parseDurationValue(value, time.Hour, field.parsedDuration)
|
||||
field.rawValue = value
|
||||
case dayType:
|
||||
field.parsedDuration = parseDurationValue(value, time.Hour*24, field.parsedDuration)
|
||||
field.rawValue = value
|
||||
case urlType:
|
||||
parsedURL, err := parseURLValue(value, field.parsedURLValue)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid URL for key %s: %v", key, err)
|
||||
}
|
||||
field.parsedURLValue = parsedURL
|
||||
field.rawValue = value
|
||||
case secretFileType:
|
||||
secretValue, err := readSecretFileValue(value)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error reading secret file for key %s: %v", key, err)
|
||||
}
|
||||
if field.targetKey != "" {
|
||||
if targetField, ok := cp.options.options[field.targetKey]; ok {
|
||||
targetField.parsedStringValue = secretValue
|
||||
targetField.rawValue = secretValue
|
||||
}
|
||||
}
|
||||
field.rawValue = value
|
||||
case bytesType:
|
||||
if value != "" {
|
||||
field.parsedBytesValue = []byte(value)
|
||||
field.rawValue = value
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseStringValue(value string, fallback string) string {
|
||||
if value == "" {
|
||||
return fallback
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func parseBoolValue(value string, fallback bool) (bool, error) {
|
||||
if value == "" {
|
||||
return fallback, nil
|
||||
}
|
||||
|
||||
value = strings.ToLower(value)
|
||||
if value == "1" || value == "yes" || value == "true" || value == "on" {
|
||||
return true
|
||||
return true, nil
|
||||
}
|
||||
if value == "0" || value == "no" || value == "false" || value == "off" {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
return false
|
||||
return false, fmt.Errorf("invalid boolean value: %q", value)
|
||||
}
|
||||
|
||||
func parseInt(value string, fallback int) int {
|
||||
func parseIntValue(value string, fallback int) int {
|
||||
if value == "" {
|
||||
return fallback
|
||||
}
|
||||
@@ -323,14 +232,20 @@ func parseInt(value string, fallback int) int {
|
||||
return v
|
||||
}
|
||||
|
||||
func parseString(value string, fallback string) string {
|
||||
func ParsedInt64Value(value string, fallback int64) int64 {
|
||||
if value == "" {
|
||||
return fallback
|
||||
}
|
||||
return value
|
||||
|
||||
v, err := strconv.ParseInt(value, 10, 64)
|
||||
if err != nil {
|
||||
return fallback
|
||||
}
|
||||
|
||||
return v
|
||||
}
|
||||
|
||||
func parseStringList(value string, fallback []string) []string {
|
||||
func parseStringListValue(value string, fallback []string) []string {
|
||||
if value == "" {
|
||||
return fallback
|
||||
}
|
||||
@@ -350,24 +265,53 @@ func parseStringList(value string, fallback []string) []string {
|
||||
return strList
|
||||
}
|
||||
|
||||
func parseBytes(value string, fallback []byte) []byte {
|
||||
func parseDurationValue(value string, unit time.Duration, fallback time.Duration) time.Duration {
|
||||
if value == "" {
|
||||
return fallback
|
||||
}
|
||||
|
||||
return []byte(value)
|
||||
}
|
||||
|
||||
func readSecretFile(filename, fallback string) string {
|
||||
data, err := os.ReadFile(filename)
|
||||
v, err := strconv.Atoi(value)
|
||||
if err != nil {
|
||||
return fallback
|
||||
}
|
||||
|
||||
value := string(bytes.TrimSpace(data))
|
||||
return time.Duration(v) * unit
|
||||
}
|
||||
|
||||
func parseURLValue(value string, fallback *url.URL) (*url.URL, error) {
|
||||
if value == "" {
|
||||
return fallback
|
||||
return fallback, nil
|
||||
}
|
||||
|
||||
return value
|
||||
parsedURL, err := url.Parse(value)
|
||||
if err != nil {
|
||||
return fallback, err
|
||||
}
|
||||
|
||||
return parsedURL, nil
|
||||
}
|
||||
|
||||
func readSecretFileValue(filename string) (string, error) {
|
||||
data, err := os.ReadFile(filename)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
value := string(bytes.TrimSpace(data))
|
||||
if value == "" {
|
||||
return "", errors.New("secret file is empty")
|
||||
}
|
||||
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func parseFileContent(r io.Reader) (lines []string) {
|
||||
scanner := bufio.NewScanner(r)
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if !strings.HasPrefix(line, "#") && strings.Index(line, "=") > 0 {
|
||||
lines = append(lines, line)
|
||||
}
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
+406
-137
@@ -4,163 +4,432 @@
|
||||
package config // import "miniflux.app/v2/internal/config"
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"os"
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestParseBoolValue(t *testing.T) {
|
||||
scenarios := map[string]bool{
|
||||
"": true,
|
||||
"1": true,
|
||||
"Yes": true,
|
||||
"yes": true,
|
||||
"True": true,
|
||||
"true": true,
|
||||
"on": true,
|
||||
"false": false,
|
||||
"off": false,
|
||||
"invalid": false,
|
||||
func TestParseStringValue(t *testing.T) {
|
||||
// Test with non-empty value
|
||||
result := parseStringValue("test", "fallback")
|
||||
if result != "test" {
|
||||
t.Errorf("Expected 'test', got '%s'", result)
|
||||
}
|
||||
|
||||
for input, expected := range scenarios {
|
||||
result := parseBool(input, true)
|
||||
if result != expected {
|
||||
t.Errorf(`Unexpected result for %q, got %v instead of %v`, input, result, expected)
|
||||
// Test with empty value
|
||||
result = parseStringValue("", "fallback")
|
||||
if result != "fallback" {
|
||||
t.Errorf("Expected 'fallback', got '%s'", result)
|
||||
}
|
||||
|
||||
// Test with empty value and empty fallback
|
||||
result = parseStringValue("", "")
|
||||
if result != "" {
|
||||
t.Errorf("Expected empty string, got '%s'", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseBoolValue(t *testing.T) {
|
||||
// Test with empty value - should return fallback
|
||||
result, err := parseBoolValue("", true)
|
||||
if err != nil {
|
||||
t.Errorf("Unexpected error: %v", err)
|
||||
}
|
||||
if result != true {
|
||||
t.Errorf("Expected true, got %v", result)
|
||||
}
|
||||
|
||||
// Test true values
|
||||
trueValues := []string{"1", "yes", "true", "on", "YES", "TRUE", "ON"}
|
||||
for _, value := range trueValues {
|
||||
result, err := parseBoolValue(value, false)
|
||||
if err != nil {
|
||||
t.Errorf("Unexpected error for value '%s': %v", value, err)
|
||||
}
|
||||
if result != true {
|
||||
t.Errorf("Expected true for '%s', got %v", value, result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseStringValueWithUnsetVariable(t *testing.T) {
|
||||
if parseString("", "defaultValue") != "defaultValue" {
|
||||
t.Errorf(`Unset variables should returns the default value`)
|
||||
// Test false values
|
||||
falseValues := []string{"0", "no", "false", "off", "NO", "FALSE", "OFF"}
|
||||
for _, value := range falseValues {
|
||||
result, err := parseBoolValue(value, true)
|
||||
if err != nil {
|
||||
t.Errorf("Unexpected error for value '%s': %v", value, err)
|
||||
}
|
||||
if result != false {
|
||||
t.Errorf("Expected false for '%s', got %v", value, result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseStringValue(t *testing.T) {
|
||||
if parseString("test", "defaultValue") != "test" {
|
||||
t.Errorf(`Defined variables should returns the specified value`)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseIntValueWithUnsetVariable(t *testing.T) {
|
||||
if parseInt("", 42) != 42 {
|
||||
t.Errorf(`Unset variables should returns the default value`)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseIntValueWithInvalidInput(t *testing.T) {
|
||||
if parseInt("invalid integer", 42) != 42 {
|
||||
t.Errorf(`Invalid integer should returns the default value`)
|
||||
// Test invalid value - should return error
|
||||
_, err = parseBoolValue("invalid", false)
|
||||
if err == nil {
|
||||
t.Error("Expected error for invalid boolean value")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseIntValue(t *testing.T) {
|
||||
if parseInt("2018", 42) != 2018 {
|
||||
t.Errorf(`Defined variables should returns the specified value`)
|
||||
// Test with empty value - should return fallback
|
||||
result := parseIntValue("", 42)
|
||||
if result != 42 {
|
||||
t.Errorf("Expected 42, got %d", result)
|
||||
}
|
||||
|
||||
// Test with valid integer
|
||||
result = parseIntValue("123", 42)
|
||||
if result != 123 {
|
||||
t.Errorf("Expected 123, got %d", result)
|
||||
}
|
||||
|
||||
// Test with invalid integer - should return fallback
|
||||
result = parseIntValue("invalid", 42)
|
||||
if result != 42 {
|
||||
t.Errorf("Expected 42, got %d", result)
|
||||
}
|
||||
|
||||
// Test with zero
|
||||
result = parseIntValue("0", 42)
|
||||
if result != 0 {
|
||||
t.Errorf("Expected 0, got %d", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseListenAddr(t *testing.T) {
|
||||
defaultExpected := []string{defaultListenAddr}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
listenAddr string
|
||||
port string
|
||||
expected []string
|
||||
lines []string // Used for direct lines parsing instead of individual env vars
|
||||
isLineOriented bool // Flag to indicate if we use lines
|
||||
}{
|
||||
{
|
||||
name: "Single LISTEN_ADDR",
|
||||
listenAddr: "127.0.0.1:8080",
|
||||
expected: []string{"127.0.0.1:8080"},
|
||||
},
|
||||
{
|
||||
name: "Multiple LISTEN_ADDR comma-separated",
|
||||
listenAddr: "127.0.0.1:8080,:8081,/tmp/miniflux.sock",
|
||||
expected: []string{"127.0.0.1:8080", ":8081", "/tmp/miniflux.sock"},
|
||||
},
|
||||
{
|
||||
name: "Multiple LISTEN_ADDR with spaces around commas",
|
||||
listenAddr: "127.0.0.1:8080 , :8081",
|
||||
expected: []string{"127.0.0.1:8080", ":8081"},
|
||||
},
|
||||
{
|
||||
name: "Empty LISTEN_ADDR",
|
||||
listenAddr: "",
|
||||
expected: defaultExpected,
|
||||
},
|
||||
{
|
||||
name: "PORT overrides LISTEN_ADDR",
|
||||
listenAddr: "127.0.0.1:8000",
|
||||
port: "8082",
|
||||
expected: []string{":8082"},
|
||||
},
|
||||
{
|
||||
name: "PORT overrides empty LISTEN_ADDR",
|
||||
listenAddr: "",
|
||||
port: "8083",
|
||||
expected: []string{":8083"},
|
||||
},
|
||||
{
|
||||
name: "LISTEN_ADDR with empty segment (comma)",
|
||||
listenAddr: "127.0.0.1:8080,,:8081",
|
||||
expected: []string{"127.0.0.1:8080", ":8081"},
|
||||
},
|
||||
{
|
||||
name: "PORT override with lines parsing",
|
||||
isLineOriented: true,
|
||||
lines: []string{"LISTEN_ADDR=127.0.0.1:8000", "PORT=8082"},
|
||||
expected: []string{":8082"},
|
||||
},
|
||||
{
|
||||
name: "LISTEN_ADDR only with lines parsing (comma)",
|
||||
isLineOriented: true,
|
||||
lines: []string{"LISTEN_ADDR=10.0.0.1:9090,10.0.0.2:9091"},
|
||||
expected: []string{"10.0.0.1:9090", "10.0.0.2:9091"},
|
||||
},
|
||||
{
|
||||
name: "Empty LISTEN_ADDR with lines parsing (default)",
|
||||
isLineOriented: true,
|
||||
lines: []string{"LISTEN_ADDR="},
|
||||
expected: defaultExpected,
|
||||
},
|
||||
func TestParsedInt64Value(t *testing.T) {
|
||||
// Test with empty value - should return fallback
|
||||
result := ParsedInt64Value("", 42)
|
||||
if result != 42 {
|
||||
t.Errorf("Expected 42, got %d", result)
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
parser := NewParser()
|
||||
var err error
|
||||
// Test with valid int64
|
||||
result = ParsedInt64Value("9223372036854775807", 42)
|
||||
if result != 9223372036854775807 {
|
||||
t.Errorf("Expected 9223372036854775807, got %d", result)
|
||||
}
|
||||
|
||||
if tt.isLineOriented {
|
||||
err = parser.parseLines(tt.lines)
|
||||
} else {
|
||||
// Simulate os.Environ() behaviour for individual var testing
|
||||
var envLines []string
|
||||
if tt.listenAddr != "" {
|
||||
envLines = append(envLines, "LISTEN_ADDR="+tt.listenAddr)
|
||||
}
|
||||
if tt.port != "" {
|
||||
envLines = append(envLines, "PORT="+tt.port)
|
||||
}
|
||||
// Add a dummy var if both are empty to avoid empty lines slice if not intended
|
||||
if tt.listenAddr == "" && tt.port == "" && tt.name == "Empty LISTEN_ADDR" {
|
||||
// This case specifically tests empty LISTEN_ADDR resulting in default
|
||||
// So, we pass LISTEN_ADDR=
|
||||
envLines = append(envLines, "LISTEN_ADDR=")
|
||||
}
|
||||
err = parser.parseLines(envLines)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("parseLines() error = %v", err)
|
||||
}
|
||||
|
||||
opts := parser.opts
|
||||
if !reflect.DeepEqual(opts.ListenAddr(), tt.expected) {
|
||||
t.Errorf("ListenAddr() got = %v, want %v", opts.ListenAddr(), tt.expected)
|
||||
}
|
||||
})
|
||||
// Test with invalid int64 - should return fallback
|
||||
result = ParsedInt64Value("invalid", 42)
|
||||
if result != 42 {
|
||||
t.Errorf("Expected 42, got %d", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseStringListValue(t *testing.T) {
|
||||
// Test with empty value - should return fallback
|
||||
fallback := []string{"a", "b"}
|
||||
result := parseStringListValue("", fallback)
|
||||
if !reflect.DeepEqual(result, fallback) {
|
||||
t.Errorf("Expected %v, got %v", fallback, result)
|
||||
}
|
||||
|
||||
// Test with single value
|
||||
result = parseStringListValue("item1", nil)
|
||||
expected := []string{"item1"}
|
||||
if !reflect.DeepEqual(result, expected) {
|
||||
t.Errorf("Expected %v, got %v", expected, result)
|
||||
}
|
||||
|
||||
// Test with multiple values
|
||||
result = parseStringListValue("item1,item2,item3", nil)
|
||||
expected = []string{"item1", "item2", "item3"}
|
||||
if !reflect.DeepEqual(result, expected) {
|
||||
t.Errorf("Expected %v, got %v", expected, result)
|
||||
}
|
||||
|
||||
// Test with duplicates - should remove duplicates
|
||||
result = parseStringListValue("item1,item2,item1", nil)
|
||||
expected = []string{"item1", "item2"}
|
||||
if !reflect.DeepEqual(result, expected) {
|
||||
t.Errorf("Expected %v, got %v", expected, result)
|
||||
}
|
||||
|
||||
// Test with spaces
|
||||
result = parseStringListValue(" item1 , item2 , item3 ", nil)
|
||||
expected = []string{"item1", "item2", "item3"}
|
||||
if !reflect.DeepEqual(result, expected) {
|
||||
t.Errorf("Expected %v, got %v", expected, result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDurationValue(t *testing.T) {
|
||||
// Test with empty value - should return fallback
|
||||
fallback := 5 * time.Second
|
||||
result := parseDurationValue("", time.Second, fallback)
|
||||
if result != fallback {
|
||||
t.Errorf("Expected %v, got %v", fallback, result)
|
||||
}
|
||||
|
||||
// Test with valid duration
|
||||
result = parseDurationValue("30", time.Second, fallback)
|
||||
expected := 30 * time.Second
|
||||
if result != expected {
|
||||
t.Errorf("Expected %v, got %v", expected, result)
|
||||
}
|
||||
|
||||
// Test with minutes
|
||||
result = parseDurationValue("5", time.Minute, fallback)
|
||||
expected = 5 * time.Minute
|
||||
if result != expected {
|
||||
t.Errorf("Expected %v, got %v", expected, result)
|
||||
}
|
||||
|
||||
// Test with invalid value - should return fallback
|
||||
result = parseDurationValue("invalid", time.Second, fallback)
|
||||
if result != fallback {
|
||||
t.Errorf("Expected %v, got %v", fallback, result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseURLValue(t *testing.T) {
|
||||
// Test with empty value - should return fallback
|
||||
fallbackURL, _ := url.Parse("https://fallback.com")
|
||||
result, err := parseURLValue("", fallbackURL)
|
||||
if err != nil {
|
||||
t.Errorf("Unexpected error: %v", err)
|
||||
}
|
||||
if result != fallbackURL {
|
||||
t.Errorf("Expected %v, got %v", fallbackURL, result)
|
||||
}
|
||||
|
||||
// Test with valid URL
|
||||
result, err = parseURLValue("https://example.com", nil)
|
||||
if err != nil {
|
||||
t.Errorf("Unexpected error: %v", err)
|
||||
}
|
||||
if result.String() != "https://example.com" {
|
||||
t.Errorf("Expected https://example.com, got %s", result.String())
|
||||
}
|
||||
|
||||
// Test with invalid URL - should return fallback and error
|
||||
result, err = parseURLValue("://invalid", fallbackURL)
|
||||
if err == nil {
|
||||
t.Error("Expected error for invalid URL")
|
||||
}
|
||||
if result != fallbackURL {
|
||||
t.Errorf("Expected fallback URL, got %v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigFileParsing(t *testing.T) {
|
||||
fileContent := `
|
||||
# This is a comment
|
||||
LOG_FILE=miniflux.log
|
||||
LOG_DATE_TIME=1
|
||||
LOG_FORMAT=json
|
||||
LISTEN_ADDR=:8080,:8443
|
||||
`
|
||||
|
||||
// Write a temporary config file and parse it
|
||||
tmpFile, err := os.CreateTemp("", "miniflux-*.txt")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temporary file: %v", err)
|
||||
}
|
||||
|
||||
defer os.Remove(tmpFile.Name())
|
||||
defer tmpFile.Close()
|
||||
|
||||
filename := tmpFile.Name()
|
||||
if _, err := tmpFile.WriteString(fileContent); err != nil {
|
||||
t.Fatalf("Failed to write to temporary file: %v", err)
|
||||
}
|
||||
|
||||
configParser := NewConfigParser()
|
||||
configOptions, err := configParser.ParseFile(filename)
|
||||
if err != nil {
|
||||
t.Fatalf("Unexpected parsing error: %v", err)
|
||||
}
|
||||
|
||||
if configOptions.LogFile() != "miniflux.log" {
|
||||
t.Fatalf("Unexpected log file, got %q", configOptions.LogFile())
|
||||
}
|
||||
|
||||
if configOptions.LogDateTime() != true {
|
||||
t.Fatalf("Unexpected log datetime, got %v", configOptions.LogDateTime())
|
||||
}
|
||||
|
||||
if configOptions.LogFormat() != "json" {
|
||||
t.Fatalf("Unexpected log format, got %q", configOptions.LogFormat())
|
||||
}
|
||||
|
||||
if configOptions.LogLevel() != "info" {
|
||||
t.Fatalf("Unexpected log level, got %q", configOptions.LogLevel())
|
||||
}
|
||||
|
||||
if len(configOptions.ListenAddr()) != 2 || configOptions.ListenAddr()[0] != ":8080" || configOptions.ListenAddr()[1] != ":8443" {
|
||||
t.Fatalf("Unexpected listen addresses, got %v", configOptions.ListenAddr())
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigFileParsingWithIncorrectKeyValuePair(t *testing.T) {
|
||||
fileContent := `
|
||||
LOG_FILE=miniflux.log
|
||||
INVALID_LINE
|
||||
`
|
||||
|
||||
// Write a temporary config file and parse it
|
||||
tmpFile, err := os.CreateTemp("", "miniflux-*.txt")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temporary file: %v", err)
|
||||
}
|
||||
|
||||
defer os.Remove(tmpFile.Name())
|
||||
defer tmpFile.Close()
|
||||
|
||||
filename := tmpFile.Name()
|
||||
if _, err := tmpFile.WriteString(fileContent); err != nil {
|
||||
t.Fatalf("Failed to write to temporary file: %v", err)
|
||||
}
|
||||
|
||||
configParser := NewConfigParser()
|
||||
_, err = configParser.ParseFile(filename)
|
||||
if err != nil {
|
||||
t.Fatal("Invalid lines should be ignored, but got error:", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAdminPasswordFileOption(t *testing.T) {
|
||||
tmpFile, err := os.CreateTemp("", "password-*.txt")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temporary file: %v", err)
|
||||
}
|
||||
defer os.Remove(tmpFile.Name())
|
||||
defer tmpFile.Close()
|
||||
|
||||
password := "supersecret"
|
||||
if _, err := tmpFile.WriteString(password); err != nil {
|
||||
t.Fatalf("Failed to write to temporary file: %v", err)
|
||||
}
|
||||
|
||||
os.Clearenv()
|
||||
os.Setenv("ADMIN_PASSWORD_FILE", tmpFile.Name())
|
||||
|
||||
configParser := NewConfigParser()
|
||||
configOptions, err := configParser.ParseEnvironmentVariables()
|
||||
if err != nil {
|
||||
t.Fatalf("Unexpected parsing error: %v", err)
|
||||
}
|
||||
|
||||
if configOptions.AdminPassword() != password {
|
||||
t.Fatalf("Unexpected admin password, got %q", configOptions.AdminPassword())
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAdminPasswordFileOptionWithEmptyFile(t *testing.T) {
|
||||
tmpFile, err := os.CreateTemp("", "empty-password-*.txt")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temporary file: %v", err)
|
||||
}
|
||||
defer os.Remove(tmpFile.Name())
|
||||
defer tmpFile.Close()
|
||||
|
||||
os.Clearenv()
|
||||
os.Setenv("ADMIN_PASSWORD_FILE", tmpFile.Name())
|
||||
|
||||
configParser := NewConfigParser()
|
||||
_, err = configParser.ParseEnvironmentVariables()
|
||||
if err == nil {
|
||||
t.Fatal("Expected error due to empty password file, but got none")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseLogFileOptionDefaultValue(t *testing.T) {
|
||||
os.Clearenv()
|
||||
|
||||
configParser := NewConfigParser()
|
||||
configOptions, err := configParser.ParseEnvironmentVariables()
|
||||
if err != nil {
|
||||
t.Fatalf("Unexpected parsing error: %v", err)
|
||||
}
|
||||
|
||||
if configOptions.LogFile() != "stderr" {
|
||||
t.Fatalf("Unexpected default log file, got %q", configOptions.LogFile())
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseLogFileOptionWithCustomFilename(t *testing.T) {
|
||||
os.Clearenv()
|
||||
os.Setenv("LOG_FILE", "miniflux.log")
|
||||
|
||||
configParser := NewConfigParser()
|
||||
configOptions, err := configParser.ParseEnvironmentVariables()
|
||||
if err != nil {
|
||||
t.Fatalf("Unexpected parsing error: %v", err)
|
||||
}
|
||||
|
||||
if configOptions.LogFile() != "miniflux.log" {
|
||||
t.Fatalf("Unexpected log file, got %q", configOptions.LogFile())
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseLogFileOptionWithEmptyValue(t *testing.T) {
|
||||
os.Clearenv()
|
||||
os.Setenv("LOG_FILE", "")
|
||||
|
||||
configParser := NewConfigParser()
|
||||
configOptions, err := configParser.ParseEnvironmentVariables()
|
||||
if err != nil {
|
||||
t.Fatalf("Unexpected parsing error: %v", err)
|
||||
}
|
||||
|
||||
if configOptions.LogFile() != "stderr" {
|
||||
t.Fatalf("Unexpected log file, got %q", configOptions.LogFile())
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseLogDateTimeOptionDefaultValue(t *testing.T) {
|
||||
os.Clearenv()
|
||||
|
||||
configParser := NewConfigParser()
|
||||
configOptions, err := configParser.ParseEnvironmentVariables()
|
||||
if err != nil {
|
||||
t.Fatalf("Unexpected parsing error: %v", err)
|
||||
}
|
||||
|
||||
if configOptions.LogDateTime() != false {
|
||||
t.Fatalf("Unexpected default log datetime, got %v", configOptions.LogDateTime())
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseLogDateTimeOptionWithCustomValue(t *testing.T) {
|
||||
os.Clearenv()
|
||||
os.Setenv("LOG_DATE_TIME", "true")
|
||||
|
||||
configParser := NewConfigParser()
|
||||
configOptions, err := configParser.ParseEnvironmentVariables()
|
||||
if err != nil {
|
||||
t.Fatalf("Unexpected parsing error: %v", err)
|
||||
}
|
||||
|
||||
if configOptions.LogDateTime() != true {
|
||||
t.Fatalf("Unexpected log datetime, got %v", configOptions.LogDateTime())
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseLogDateTimeOptionWithEmptyValue(t *testing.T) {
|
||||
os.Clearenv()
|
||||
os.Setenv("LOG_DATE_TIME", "")
|
||||
|
||||
configParser := NewConfigParser()
|
||||
configOptions, err := configParser.ParseEnvironmentVariables()
|
||||
if err != nil {
|
||||
t.Fatalf("Unexpected parsing error: %v", err)
|
||||
}
|
||||
|
||||
if configOptions.LogDateTime() != false {
|
||||
t.Fatalf("Unexpected log datetime, got %v", configOptions.LogDateTime())
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseLogDateTimeOptionWithIncorrectValue(t *testing.T) {
|
||||
os.Clearenv()
|
||||
os.Setenv("LOG_DATE_TIME", "invalid")
|
||||
|
||||
configParser := NewConfigParser()
|
||||
if _, err := configParser.ParseEnvironmentVariables(); err == nil {
|
||||
t.Fatal("Expected parsing error, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package config // import "miniflux.app/v2/internal/config"
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func validateChoices(rawValue string, choices []string) error {
|
||||
if !slices.Contains(choices, rawValue) {
|
||||
return fmt.Errorf("value must be one of: %v", strings.Join(choices, ", "))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateListChoices(inputValues, choices []string) error {
|
||||
for _, value := range inputValues {
|
||||
if err := validateChoices(value, choices); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateGreaterThan(rawValue string, min int) error {
|
||||
intValue, err := strconv.Atoi(rawValue)
|
||||
if err != nil {
|
||||
return errors.New("value must be an integer")
|
||||
}
|
||||
if intValue > min {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("value must be at least %d", min)
|
||||
}
|
||||
|
||||
func validateGreaterOrEqualThan(rawValue string, min int) error {
|
||||
intValue, err := strconv.Atoi(rawValue)
|
||||
if err != nil {
|
||||
return errors.New("value must be an integer")
|
||||
}
|
||||
if intValue >= min {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("value must be greater or equal than %d", min)
|
||||
}
|
||||
|
||||
func validateRange(rawValue string, min, max int) error {
|
||||
intValue, err := strconv.Atoi(rawValue)
|
||||
if err != nil {
|
||||
return errors.New("value must be an integer")
|
||||
}
|
||||
if intValue < min || intValue > max {
|
||||
return fmt.Errorf("value must be between %d and %d", min, max)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,372 @@
|
||||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package config // import "miniflux.app/v2/internal/config"
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestValidateChoices(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
rawValue string
|
||||
choices []string
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
name: "valid choice",
|
||||
rawValue: "option1",
|
||||
choices: []string{"option1", "option2", "option3"},
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "valid choice from middle",
|
||||
rawValue: "option2",
|
||||
choices: []string{"option1", "option2", "option3"},
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "valid choice from end",
|
||||
rawValue: "option3",
|
||||
choices: []string{"option1", "option2", "option3"},
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "invalid choice",
|
||||
rawValue: "invalid",
|
||||
choices: []string{"option1", "option2", "option3"},
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "empty value with non-empty choices",
|
||||
rawValue: "",
|
||||
choices: []string{"option1", "option2"},
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "case sensitive - different case",
|
||||
rawValue: "OPTION1",
|
||||
choices: []string{"option1", "option2"},
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "single choice valid",
|
||||
rawValue: "only",
|
||||
choices: []string{"only"},
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "empty choices list",
|
||||
rawValue: "anything",
|
||||
choices: []string{},
|
||||
expectError: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := validateChoices(tt.rawValue, tt.choices)
|
||||
if tt.expectError {
|
||||
if err == nil {
|
||||
t.Errorf("expected error but got none")
|
||||
} else {
|
||||
// Verify error message format
|
||||
expectedPrefix := "value must be one of:"
|
||||
if !strings.Contains(err.Error(), expectedPrefix) {
|
||||
t.Errorf("error message should contain '%s', got: %s", expectedPrefix, err.Error())
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if err != nil {
|
||||
t.Errorf("expected no error but got: %v", err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateListChoices(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
inputValues []string
|
||||
choices []string
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
name: "all valid choices",
|
||||
inputValues: []string{"option1", "option2"},
|
||||
choices: []string{"option1", "option2", "option3"},
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "single valid choice",
|
||||
inputValues: []string{"option1"},
|
||||
choices: []string{"option1", "option2", "option3"},
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "empty input list",
|
||||
inputValues: []string{},
|
||||
choices: []string{"option1", "option2", "option3"},
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "all choices from available list",
|
||||
inputValues: []string{"option1", "option2", "option3"},
|
||||
choices: []string{"option1", "option2", "option3"},
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "duplicate valid choices",
|
||||
inputValues: []string{"option1", "option1", "option2"},
|
||||
choices: []string{"option1", "option2", "option3"},
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "one invalid choice",
|
||||
inputValues: []string{"option1", "invalid"},
|
||||
choices: []string{"option1", "option2", "option3"},
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "all invalid choices",
|
||||
inputValues: []string{"invalid1", "invalid2"},
|
||||
choices: []string{"option1", "option2", "option3"},
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "case sensitive - different case",
|
||||
inputValues: []string{"OPTION1"},
|
||||
choices: []string{"option1", "option2"},
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "empty string in input",
|
||||
inputValues: []string{""},
|
||||
choices: []string{"option1", "option2"},
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "empty choices list with non-empty input",
|
||||
inputValues: []string{"anything"},
|
||||
choices: []string{},
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "mixed valid and invalid choices",
|
||||
inputValues: []string{"option1", "invalid", "option2"},
|
||||
choices: []string{"option1", "option2", "option3"},
|
||||
expectError: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := validateListChoices(tt.inputValues, tt.choices)
|
||||
if tt.expectError {
|
||||
if err == nil {
|
||||
t.Errorf("expected error but got none")
|
||||
} else {
|
||||
// Verify error message format
|
||||
expectedPrefix := "value must be one of:"
|
||||
if !strings.Contains(err.Error(), expectedPrefix) {
|
||||
t.Errorf("error message should contain '%s', got: %s", expectedPrefix, err.Error())
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if err != nil {
|
||||
t.Errorf("expected no error but got: %v", err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateGreaterThan(t *testing.T) {
|
||||
if err := validateGreaterThan("10", 5); err != nil {
|
||||
t.Errorf("expected no error, got: %v", err)
|
||||
}
|
||||
|
||||
if err := validateGreaterThan("5", 5); err == nil {
|
||||
t.Errorf("expected error, got none")
|
||||
}
|
||||
|
||||
if err := validateGreaterThan("abc", 5); err == nil {
|
||||
t.Errorf("expected error for non-integer input, got none")
|
||||
}
|
||||
|
||||
if err := validateGreaterThan("-1", 0); err == nil {
|
||||
t.Errorf("expected error for value below minimum, got none")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateGreaterOrEqualThan(t *testing.T) {
|
||||
if err := validateGreaterOrEqualThan("10", 5); err != nil {
|
||||
t.Errorf("expected no error, got: %v", err)
|
||||
}
|
||||
|
||||
if err := validateGreaterOrEqualThan("5", 5); err != nil {
|
||||
t.Errorf("expected no error for equal value, got: %v", err)
|
||||
}
|
||||
|
||||
if err := validateGreaterOrEqualThan("abc", 5); err == nil {
|
||||
t.Errorf("expected error for non-integer input, got none")
|
||||
}
|
||||
|
||||
if err := validateGreaterOrEqualThan("-1", 0); err == nil {
|
||||
t.Errorf("expected error for value below minimum, got none")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRange(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
rawValue string
|
||||
min int
|
||||
max int
|
||||
expectError bool
|
||||
errorMsg string
|
||||
}{
|
||||
{
|
||||
name: "valid integer within range",
|
||||
rawValue: "5",
|
||||
min: 1,
|
||||
max: 10,
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "valid integer at minimum",
|
||||
rawValue: "1",
|
||||
min: 1,
|
||||
max: 10,
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "valid integer at maximum",
|
||||
rawValue: "10",
|
||||
min: 1,
|
||||
max: 10,
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "valid zero in range",
|
||||
rawValue: "0",
|
||||
min: -5,
|
||||
max: 5,
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "valid negative in range",
|
||||
rawValue: "-3",
|
||||
min: -5,
|
||||
max: 5,
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "integer below minimum",
|
||||
rawValue: "0",
|
||||
min: 1,
|
||||
max: 10,
|
||||
expectError: true,
|
||||
errorMsg: "value must be between 1 and 10",
|
||||
},
|
||||
{
|
||||
name: "integer above maximum",
|
||||
rawValue: "11",
|
||||
min: 1,
|
||||
max: 10,
|
||||
expectError: true,
|
||||
errorMsg: "value must be between 1 and 10",
|
||||
},
|
||||
{
|
||||
name: "integer far below minimum",
|
||||
rawValue: "-100",
|
||||
min: 1,
|
||||
max: 10,
|
||||
expectError: true,
|
||||
errorMsg: "value must be between 1 and 10",
|
||||
},
|
||||
{
|
||||
name: "integer far above maximum",
|
||||
rawValue: "100",
|
||||
min: 1,
|
||||
max: 10,
|
||||
expectError: true,
|
||||
errorMsg: "value must be between 1 and 10",
|
||||
},
|
||||
{
|
||||
name: "non-integer string",
|
||||
rawValue: "abc",
|
||||
min: 1,
|
||||
max: 10,
|
||||
expectError: true,
|
||||
errorMsg: "value must be an integer",
|
||||
},
|
||||
{
|
||||
name: "empty string",
|
||||
rawValue: "",
|
||||
min: 1,
|
||||
max: 10,
|
||||
expectError: true,
|
||||
errorMsg: "value must be an integer",
|
||||
},
|
||||
{
|
||||
name: "float string",
|
||||
rawValue: "5.5",
|
||||
min: 1,
|
||||
max: 10,
|
||||
expectError: true,
|
||||
errorMsg: "value must be an integer",
|
||||
},
|
||||
{
|
||||
name: "string with spaces",
|
||||
rawValue: " 5 ",
|
||||
min: 1,
|
||||
max: 10,
|
||||
expectError: true,
|
||||
errorMsg: "value must be an integer",
|
||||
},
|
||||
{
|
||||
name: "single value range",
|
||||
rawValue: "5",
|
||||
min: 5,
|
||||
max: 5,
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "single value range - below",
|
||||
rawValue: "4",
|
||||
min: 5,
|
||||
max: 5,
|
||||
expectError: true,
|
||||
errorMsg: "value must be between 5 and 5",
|
||||
},
|
||||
{
|
||||
name: "single value range - above",
|
||||
rawValue: "6",
|
||||
min: 5,
|
||||
max: 5,
|
||||
expectError: true,
|
||||
errorMsg: "value must be between 5 and 5",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := validateRange(tt.rawValue, tt.min, tt.max)
|
||||
if tt.expectError {
|
||||
if err == nil {
|
||||
t.Errorf("expected error but got none")
|
||||
} else if tt.errorMsg != "" && err.Error() != tt.errorMsg {
|
||||
t.Errorf("expected error message '%s', got '%s'", tt.errorMsg, err.Error())
|
||||
}
|
||||
} else {
|
||||
if err != nil {
|
||||
t.Errorf("expected no error but got: %v", err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+309
-17
@@ -12,7 +12,7 @@ import (
|
||||
var schemaVersion = len(migrations)
|
||||
|
||||
// Order is important. Add new migrations at the end of the list.
|
||||
var migrations = []func(tx *sql.Tx) error{
|
||||
var migrations = [...]func(tx *sql.Tx) error{
|
||||
func(tx *sql.Tx) (err error) {
|
||||
sql := `
|
||||
CREATE TABLE schema_version (
|
||||
@@ -20,7 +20,7 @@ var migrations = []func(tx *sql.Tx) error{
|
||||
);
|
||||
|
||||
CREATE TABLE users (
|
||||
id serial not null,
|
||||
id SERIAL,
|
||||
username text not null unique,
|
||||
password text,
|
||||
is_admin bool default 'f',
|
||||
@@ -32,7 +32,7 @@ var migrations = []func(tx *sql.Tx) error{
|
||||
);
|
||||
|
||||
CREATE TABLE sessions (
|
||||
id serial not null,
|
||||
id SERIAL,
|
||||
user_id int not null,
|
||||
token text not null unique,
|
||||
created_at timestamp with time zone default now(),
|
||||
@@ -44,7 +44,7 @@ var migrations = []func(tx *sql.Tx) error{
|
||||
);
|
||||
|
||||
CREATE TABLE categories (
|
||||
id serial not null,
|
||||
id SERIAL,
|
||||
user_id int not null,
|
||||
title text not null,
|
||||
primary key (id),
|
||||
@@ -53,7 +53,7 @@ var migrations = []func(tx *sql.Tx) error{
|
||||
);
|
||||
|
||||
CREATE TABLE feeds (
|
||||
id bigserial not null,
|
||||
id BIGSERIAL,
|
||||
user_id int not null,
|
||||
category_id int not null,
|
||||
title text not null,
|
||||
@@ -73,7 +73,7 @@ var migrations = []func(tx *sql.Tx) error{
|
||||
CREATE TYPE entry_status as enum('unread', 'read', 'removed');
|
||||
|
||||
CREATE TABLE entries (
|
||||
id bigserial not null,
|
||||
id BIGSERIAL,
|
||||
user_id int not null,
|
||||
feed_id bigint not null,
|
||||
hash text not null,
|
||||
@@ -92,7 +92,7 @@ var migrations = []func(tx *sql.Tx) error{
|
||||
CREATE INDEX entries_feed_idx on entries using btree(feed_id);
|
||||
|
||||
CREATE TABLE enclosures (
|
||||
id bigserial not null,
|
||||
id BIGSERIAL,
|
||||
user_id int not null,
|
||||
entry_id bigint not null,
|
||||
url text not null,
|
||||
@@ -104,7 +104,7 @@ var migrations = []func(tx *sql.Tx) error{
|
||||
);
|
||||
|
||||
CREATE TABLE icons (
|
||||
id bigserial not null,
|
||||
id BIGSERIAL,
|
||||
hash text not null unique,
|
||||
mime_type text not null,
|
||||
content bytea not null,
|
||||
@@ -123,13 +123,9 @@ var migrations = []func(tx *sql.Tx) error{
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
sql := `
|
||||
CREATE EXTENSION IF NOT EXISTS hstore;
|
||||
ALTER TABLE users ADD COLUMN extra hstore;
|
||||
CREATE INDEX users_extra_idx ON users using gin(extra);
|
||||
`
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
// This used to create a HSTORE `extra` column in the table `users`,
|
||||
// which hasn't been used since Miniflux 2.0.27.
|
||||
return nil
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
sql := `
|
||||
@@ -334,7 +330,7 @@ var migrations = []func(tx *sql.Tx) error{
|
||||
func(tx *sql.Tx) (err error) {
|
||||
sql := `
|
||||
CREATE TABLE api_keys (
|
||||
id serial not null,
|
||||
id SERIAL,
|
||||
user_id int not null references users(id) on delete cascade,
|
||||
token text not null unique,
|
||||
description text not null,
|
||||
@@ -436,6 +432,18 @@ var migrations = []func(tx *sql.Tx) error{
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
|
||||
hasExtra := false
|
||||
if err := tx.QueryRow(`
|
||||
SELECT true
|
||||
FROM information_schema.columns
|
||||
WHERE
|
||||
table_name='users' AND
|
||||
column_name='extra';
|
||||
`).Scan(&hasExtra); err != nil && err != sql.ErrNoRows {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = tx.Exec(`
|
||||
ALTER TABLE users
|
||||
ADD column stylesheet text not null default '',
|
||||
@@ -446,6 +454,11 @@ var migrations = []func(tx *sql.Tx) error{
|
||||
return err
|
||||
}
|
||||
|
||||
if !hasExtra {
|
||||
// No need to migrate things from the `extra` column if it's not present
|
||||
return nil
|
||||
}
|
||||
|
||||
_, err = tx.Exec(`
|
||||
DECLARE my_cursor CURSOR FOR
|
||||
SELECT
|
||||
@@ -495,7 +508,7 @@ var migrations = []func(tx *sql.Tx) error{
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
if _, err = tx.Exec(`ALTER TABLE users DROP COLUMN extra;`); err != nil {
|
||||
if _, err = tx.Exec(`ALTER TABLE users DROP COLUMN IF EXISTS extra;`); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = tx.Exec(`
|
||||
@@ -1135,4 +1148,283 @@ var migrations = []func(tx *sql.Tx) error{
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
sql := `
|
||||
CREATE TYPE linktaco_link_visibility AS ENUM (
|
||||
'PUBLIC',
|
||||
'PRIVATE'
|
||||
);
|
||||
ALTER TABLE integrations
|
||||
ADD COLUMN linktaco_enabled bool default 'f',
|
||||
ADD COLUMN linktaco_api_token text default '',
|
||||
ADD COLUMN linktaco_org_slug text default '',
|
||||
ADD COLUMN linktaco_tags text default '',
|
||||
ADD COLUMN linktaco_visibility linktaco_link_visibility default 'PUBLIC';
|
||||
`
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
sql := `
|
||||
ALTER TABLE integrations ADD COLUMN wallabag_tags text default '';
|
||||
`
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
// This migration replaces deprecated timezones by their equivalent on Debian Trixie.
|
||||
func(tx *sql.Tx) (err error) {
|
||||
var deprecatedTimeZoneMap = map[string]string{
|
||||
// Africa
|
||||
"Africa/Asmera": "Africa/Asmara",
|
||||
|
||||
// America - Argentina
|
||||
"America/Argentina/ComodRivadavia": "America/Argentina/Catamarca",
|
||||
"America/Buenos_Aires": "America/Argentina/Buenos_Aires",
|
||||
"America/Catamarca": "America/Argentina/Catamarca",
|
||||
"America/Cordoba": "America/Argentina/Cordoba",
|
||||
"America/Jujuy": "America/Argentina/Jujuy",
|
||||
"America/Mendoza": "America/Argentina/Mendoza",
|
||||
"America/Rosario": "America/Argentina/Cordoba",
|
||||
|
||||
// America - US
|
||||
"America/Fort_Wayne": "America/Indiana/Indianapolis",
|
||||
"America/Indianapolis": "America/Indiana/Indianapolis",
|
||||
"America/Knox_IN": "America/Indiana/Knox",
|
||||
"America/Louisville": "America/Kentucky/Louisville",
|
||||
|
||||
// America - Greenland
|
||||
"America/Godthab": "America/Nuuk",
|
||||
|
||||
// Antarctica
|
||||
"Antarctica/South_Pole": "Pacific/Auckland",
|
||||
|
||||
// Asia
|
||||
"Asia/Ashkhabad": "Asia/Ashgabat",
|
||||
"Asia/Calcutta": "Asia/Kolkata",
|
||||
"Asia/Choibalsan": "Asia/Ulaanbaatar",
|
||||
"Asia/Chungking": "Asia/Chongqing",
|
||||
"Asia/Dacca": "Asia/Dhaka",
|
||||
"Asia/Katmandu": "Asia/Kathmandu",
|
||||
"Asia/Macao": "Asia/Macau",
|
||||
"Asia/Rangoon": "Asia/Yangon",
|
||||
"Asia/Saigon": "Asia/Ho_Chi_Minh",
|
||||
"Asia/Thimbu": "Asia/Thimphu",
|
||||
"Asia/Ujung_Pandang": "Asia/Makassar",
|
||||
"Asia/Ulan_Bator": "Asia/Ulaanbaatar",
|
||||
|
||||
// Atlantic
|
||||
"Atlantic/Faeroe": "Atlantic/Faroe",
|
||||
|
||||
// Australia
|
||||
"Australia/ACT": "Australia/Sydney",
|
||||
"Australia/LHI": "Australia/Lord_Howe",
|
||||
"Australia/North": "Australia/Darwin",
|
||||
"Australia/NSW": "Australia/Sydney",
|
||||
"Australia/Queensland": "Australia/Brisbane",
|
||||
"Australia/South": "Australia/Adelaide",
|
||||
"Australia/Tasmania": "Australia/Hobart",
|
||||
"Australia/Victoria": "Australia/Melbourne",
|
||||
"Australia/West": "Australia/Perth",
|
||||
|
||||
// Brazil
|
||||
"Brazil/Acre": "America/Rio_Branco",
|
||||
"Brazil/DeNoronha": "America/Noronha",
|
||||
"Brazil/East": "America/Sao_Paulo",
|
||||
"Brazil/West": "America/Manaus",
|
||||
|
||||
// Canada
|
||||
"Canada/Atlantic": "America/Halifax",
|
||||
"Canada/Central": "America/Winnipeg",
|
||||
"Canada/Eastern": "America/Toronto",
|
||||
"Canada/Mountain": "America/Edmonton",
|
||||
"Canada/Newfoundland": "America/St_Johns",
|
||||
"Canada/Pacific": "America/Vancouver",
|
||||
"Canada/Saskatchewan": "America/Regina",
|
||||
"Canada/Yukon": "America/Whitehorse",
|
||||
|
||||
// Europe
|
||||
"CET": "Europe/Paris",
|
||||
"EET": "Europe/Sofia",
|
||||
"Europe/Kiev": "Europe/Kyiv",
|
||||
"Europe/Uzhgorod": "Europe/Kyiv",
|
||||
"Europe/Zaporozhye": "Europe/Kyiv",
|
||||
"MET": "Europe/Paris",
|
||||
"WET": "Europe/Lisbon",
|
||||
|
||||
// Chile
|
||||
"Chile/Continental": "America/Santiago",
|
||||
"Chile/EasterIsland": "Pacific/Easter",
|
||||
|
||||
// Fixed offset and generic zones
|
||||
"CST6CDT": "America/Chicago",
|
||||
"EST": "America/New_York",
|
||||
"EST5EDT": "America/New_York",
|
||||
"HST": "Pacific/Honolulu",
|
||||
"MST": "America/Denver",
|
||||
"MST7MDT": "America/Denver",
|
||||
"PST8PDT": "America/Los_Angeles",
|
||||
|
||||
// Countries/Regions
|
||||
"Cuba": "America/Havana",
|
||||
"Egypt": "Africa/Cairo",
|
||||
"Eire": "Europe/Dublin",
|
||||
"GB": "Europe/London",
|
||||
"GB-Eire": "Europe/London",
|
||||
"Hongkong": "Asia/Hong_Kong",
|
||||
"Iceland": "Atlantic/Reykjavik",
|
||||
"Iran": "Asia/Tehran",
|
||||
"Israel": "Asia/Jerusalem",
|
||||
"Jamaica": "America/Jamaica",
|
||||
"Japan": "Asia/Tokyo",
|
||||
"Libya": "Africa/Tripoli",
|
||||
"Poland": "Europe/Warsaw",
|
||||
"Portugal": "Europe/Lisbon",
|
||||
"PRC": "Asia/Shanghai",
|
||||
"ROC": "Asia/Taipei",
|
||||
"ROK": "Asia/Seoul",
|
||||
"Singapore": "Asia/Singapore",
|
||||
"Turkey": "Europe/Istanbul",
|
||||
|
||||
// GMT variations
|
||||
"GMT+0": "GMT",
|
||||
"GMT-0": "GMT",
|
||||
"GMT0": "GMT",
|
||||
"Greenwich": "GMT",
|
||||
"UCT": "UTC",
|
||||
"Universal": "UTC",
|
||||
"Zulu": "UTC",
|
||||
|
||||
// Mexico
|
||||
"Mexico/BajaNorte": "America/Tijuana",
|
||||
"Mexico/BajaSur": "America/Mazatlan",
|
||||
"Mexico/General": "America/Mexico_City",
|
||||
|
||||
// US zones
|
||||
"Navajo": "America/Denver",
|
||||
"US/Alaska": "America/Anchorage",
|
||||
"US/Aleutian": "America/Adak",
|
||||
"US/Arizona": "America/Phoenix",
|
||||
"US/Central": "America/Chicago",
|
||||
"US/Eastern": "America/New_York",
|
||||
"US/East-Indiana": "America/Indiana/Indianapolis",
|
||||
"US/Hawaii": "Pacific/Honolulu",
|
||||
"US/Indiana-Starke": "America/Indiana/Knox",
|
||||
"US/Michigan": "America/Detroit",
|
||||
"US/Mountain": "America/Denver",
|
||||
"US/Pacific": "America/Los_Angeles",
|
||||
"US/Samoa": "Pacific/Pago_Pago",
|
||||
|
||||
// Pacific
|
||||
"Kwajalein": "Pacific/Kwajalein",
|
||||
"NZ": "Pacific/Auckland",
|
||||
"NZ-CHAT": "Pacific/Chatham",
|
||||
"Pacific/Enderbury": "Pacific/Kanton",
|
||||
"Pacific/Ponape": "Pacific/Pohnpei",
|
||||
"Pacific/Truk": "Pacific/Chuuk",
|
||||
|
||||
// Special cases
|
||||
"Factory": "UTC", // Factory is used for unconfigured systems
|
||||
"W-SU": "Europe/Moscow",
|
||||
}
|
||||
|
||||
// Loop through each user and correct the timezone
|
||||
rows, err := tx.Query(`SELECT id, timezone FROM users`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
userTimezoneMap := make(map[int64]string)
|
||||
for rows.Next() {
|
||||
var userID int64
|
||||
var userTimezone string
|
||||
if err := rows.Scan(&userID, &userTimezone); err != nil {
|
||||
return err
|
||||
}
|
||||
userTimezoneMap[userID] = userTimezone
|
||||
}
|
||||
rows.Close()
|
||||
|
||||
for userID, userTimezone := range userTimezoneMap {
|
||||
if newTimezone, found := deprecatedTimeZoneMap[userTimezone]; found {
|
||||
if _, err := tx.Exec(`UPDATE users SET timezone = $1 WHERE id = $2`, newTimezone, userID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
sql := `
|
||||
ALTER TABLE integrations ADD COLUMN archiveorg_enabled bool default 'f'
|
||||
`
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
sql := `DROP EXTENSION IF EXISTS hstore;`
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
sql := `
|
||||
ALTER TABLE integrations ADD COLUMN karakeep_tags text default '';
|
||||
`
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
sql := `
|
||||
ALTER TABLE integrations ADD COLUMN linkwarden_collection_id int;
|
||||
`
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
sql := `
|
||||
ALTER TABLE integrations ADD COLUMN readeck_push_enabled bool default 'f';
|
||||
`
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
// There is no need to keep an index on the content of deleted entries.
|
||||
_, err = tx.Exec(`DROP INDEX document_vectors_idx;`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
sql := `
|
||||
CREATE INDEX document_vectors_idx
|
||||
ON entries
|
||||
USING gin(document_vectors)
|
||||
WHERE status != 'removed';
|
||||
`
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
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
|
||||
},
|
||||
}
|
||||
|
||||
+12
-12
@@ -143,9 +143,9 @@ func (h *handler) handleFeeds(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
var result feedsResponse
|
||||
result.Feeds = make([]feed, 0)
|
||||
result.Feeds = make([]feed, 0, len(feeds))
|
||||
for _, f := range feeds {
|
||||
subscripion := feed{
|
||||
subscription := feed{
|
||||
ID: f.ID,
|
||||
Title: f.Title,
|
||||
URL: f.FeedURL,
|
||||
@@ -155,10 +155,10 @@ func (h *handler) handleFeeds(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
if f.Icon != nil {
|
||||
subscripion.FaviconID = f.Icon.IconID
|
||||
subscription.FaviconID = f.Icon.IconID
|
||||
}
|
||||
|
||||
result.Feeds = append(result.Feeds, subscripion)
|
||||
result.Feeds = append(result.Feeds, subscription)
|
||||
}
|
||||
|
||||
result.FeedsGroups = h.buildFeedGroups(feeds)
|
||||
@@ -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)
|
||||
@@ -307,7 +307,7 @@ func (h *handler) handleItems(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
result.Items = make([]item, 0)
|
||||
result.Items = make([]item, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
isRead := 0
|
||||
if entry.Status == model.EntryStatusRead {
|
||||
@@ -358,7 +358,7 @@ func (h *handler) handleUnreadItems(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
var itemIDs []string
|
||||
itemIDs := make([]string, 0, len(rawEntryIDs))
|
||||
for _, entryID := range rawEntryIDs {
|
||||
itemIDs = append(itemIDs, strconv.FormatInt(entryID, 10))
|
||||
}
|
||||
@@ -392,7 +392,7 @@ func (h *handler) handleSavedItems(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
var itemsIDs []string
|
||||
itemsIDs := make([]string, 0, len(entryIDs))
|
||||
for _, entryID := range entryIDs {
|
||||
itemsIDs = append(itemsIDs, strconv.FormatInt(entryID, 10))
|
||||
}
|
||||
@@ -455,7 +455,7 @@ func (h *handler) handleWriteItems(w http.ResponseWriter, r *http.Request) {
|
||||
slog.Int64("user_id", userID),
|
||||
slog.Int64("entry_id", entryID),
|
||||
)
|
||||
if err := h.store.ToggleBookmark(userID, entryID); err != nil {
|
||||
if err := h.store.ToggleStarred(userID, entryID); err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
@@ -474,7 +474,7 @@ func (h *handler) handleWriteItems(w http.ResponseWriter, r *http.Request) {
|
||||
slog.Int64("user_id", userID),
|
||||
slog.Int64("entry_id", entryID),
|
||||
)
|
||||
if err := h.store.ToggleBookmark(userID, entryID); err != nil {
|
||||
if err := h.store.ToggleStarred(userID, entryID); err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
@@ -568,12 +568,12 @@ A feeds_group object has the following members:
|
||||
feed_ids (string/comma-separated list of positive integers)
|
||||
*/
|
||||
func (h *handler) buildFeedGroups(feeds model.Feeds) []feedsGroups {
|
||||
feedsGroupedByCategory := make(map[int64][]string)
|
||||
feedsGroupedByCategory := make(map[int64][]string, len(feeds))
|
||||
for _, feed := range feeds {
|
||||
feedsGroupedByCategory[feed.Category.ID] = append(feedsGroupedByCategory[feed.Category.ID], strconv.FormatInt(feed.ID, 10))
|
||||
}
|
||||
|
||||
result := make([]feedsGroups, 0)
|
||||
result := make([]feedsGroups, 0, len(feedsGroupedByCategory))
|
||||
for categoryID, feedIDs := range feedsGroupedByCategory {
|
||||
result = append(result, feedsGroups{
|
||||
GroupID: categoryID,
|
||||
|
||||
@@ -47,9 +47,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")
|
||||
@@ -126,8 +124,7 @@ func checkOutputFormat(r *http.Request) error {
|
||||
output = request.QueryStringParam(r, "output", "")
|
||||
}
|
||||
if output != "json" {
|
||||
err := fmt.Errorf("googlereader: only json output is supported")
|
||||
return err
|
||||
return errors.New("googlereader: only json output is supported")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -245,7 +242,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")
|
||||
@@ -280,7 +276,7 @@ func (h *handler) editTagHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if len(addTags) == 0 && len(removeTags) == 0 {
|
||||
err = fmt.Errorf("googlreader: add or/and remove tags should be supplied")
|
||||
err = errors.New("googlreader: add or/and remove tags should be supplied")
|
||||
json.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
@@ -357,7 +353,7 @@ func (h *handler) editTagHandler(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
if len(unstarredEntryIDs) > 0 {
|
||||
err = h.store.SetEntriesBookmarkedState(userID, unstarredEntryIDs, false)
|
||||
err = h.store.SetEntriesStarredState(userID, unstarredEntryIDs, false)
|
||||
if err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
return
|
||||
@@ -365,7 +361,7 @@ func (h *handler) editTagHandler(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
if len(starredEntryIDs) > 0 {
|
||||
err = h.store.SetEntriesBookmarkedState(userID, starredEntryIDs, true)
|
||||
err = h.store.SetEntriesStarredState(userID, starredEntryIDs, true)
|
||||
if err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
return
|
||||
@@ -793,7 +789,7 @@ func (h *handler) streamItemContentsHandler(w http.ResponseWriter, r *http.Reque
|
||||
Content: entry.Content,
|
||||
},
|
||||
Origin: contentItemOrigin{
|
||||
StreamID: fmt.Sprintf("feed/%d", entry.FeedID),
|
||||
StreamID: fmt.Sprintf(feedPrefix+"%d", entry.FeedID),
|
||||
Title: entry.Feed.Title,
|
||||
HTMLUrl: entry.Feed.SiteURL,
|
||||
},
|
||||
@@ -1004,17 +1000,17 @@ 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
|
||||
}
|
||||
userInfo := userInfoResponse{UserID: fmt.Sprint(user.ID), UserName: user.Username, UserProfileID: fmt.Sprint(user.ID), UserEmail: user.Username}
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -1048,7 +1044,7 @@ func (h *handler) streamItemIDsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
)
|
||||
|
||||
if len(rm.Streams) != 1 {
|
||||
json.ServerError(w, r, fmt.Errorf("googlereader: only one stream type expected"))
|
||||
json.ServerError(w, r, errors.New("googlereader: only one stream type expected"))
|
||||
return
|
||||
}
|
||||
switch rm.Streams[0].Type {
|
||||
@@ -1071,7 +1067,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",
|
||||
@@ -1130,7 +1126,7 @@ func (h *handler) handleReadingListStreamHandler(w http.ResponseWriter, r *http.
|
||||
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)
|
||||
@@ -1168,7 +1164,7 @@ func (h *handler) handleStarredStreamHandler(w http.ResponseWriter, r *http.Requ
|
||||
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)
|
||||
@@ -1206,7 +1202,7 @@ func (h *handler) handleReadStreamHandler(w http.ResponseWriter, r *http.Request
|
||||
json.OK(w, r, streamIDResponse{itemRefs, continuation})
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
package googlereader // import "miniflux.app/v2/internal/googlereader"
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
@@ -58,7 +59,7 @@ func parseItemID(itemIDValue string) (int64, error) {
|
||||
func parseItemIDsFromRequest(r *http.Request) ([]int64, error) {
|
||||
items := r.Form[paramItemIDs]
|
||||
if len(items) == 0 {
|
||||
return nil, fmt.Errorf("googlereader: no items requested")
|
||||
return nil, errors.New("googlereader: no items requested")
|
||||
}
|
||||
|
||||
itemIDs := make([]int64, len(items))
|
||||
|
||||
@@ -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,24 +24,24 @@ 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))
|
||||
|
||||
var streamStr []string
|
||||
streamStr := make([]string, 0, len(r.Streams))
|
||||
for _, s := range r.Streams {
|
||||
streamStr = append(streamStr, s.String())
|
||||
}
|
||||
results = append(results, fmt.Sprintf("Streams: [%s]", strings.Join(streamStr, ", ")))
|
||||
|
||||
var exclusions []string
|
||||
exclusions := make([]string, 0, len(r.ExcludeTargets))
|
||||
for _, s := range r.ExcludeTargets {
|
||||
exclusions = append(exclusions, s.String())
|
||||
}
|
||||
results = append(results, fmt.Sprintf("Exclusions: [%s]", strings.Join(exclusions, ", ")))
|
||||
|
||||
var filters []string
|
||||
filters := make([]string, 0, len(r.FilterTargets))
|
||||
for _, s := range r.FilterTargets {
|
||||
filters = append(filters, s.String())
|
||||
}
|
||||
@@ -49,17 +49,17 @@ func (r RequestModifiers) String() string {
|
||||
|
||||
results = append(results, fmt.Sprintf("Count: %d", r.Count))
|
||||
results = append(results, fmt.Sprintf("Offset: %d", r.Offset))
|
||||
results = append(results, fmt.Sprintf("Sort Direction: %s", r.SortDirection))
|
||||
results = append(results, fmt.Sprintf("Continuation Token: %s", r.ContinuationToken))
|
||||
results = append(results, "Sort Direction: "+r.SortDirection)
|
||||
results = append(results, "Continuation Token: "+r.ContinuationToken)
|
||||
results = append(results, fmt.Sprintf("Start Time: %d", r.StartTime))
|
||||
results = append(results, fmt.Sprintf("Stop Time: %d", r.StopTime))
|
||||
|
||||
return strings.Join(results, "; ")
|
||||
}
|
||||
|
||||
func parseStreamFilterFromRequest(r *http.Request) (RequestModifiers, error) {
|
||||
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)
|
||||
|
||||
@@ -24,7 +24,7 @@ func New(name, value string, isHTTPS bool, path string) *http.Cookie {
|
||||
Path: basePath(path),
|
||||
Secure: isHTTPS,
|
||||
HttpOnly: true,
|
||||
Expires: time.Now().Add(time.Duration(config.Opts.CleanupRemoveSessionsDays()) * 24 * time.Hour),
|
||||
Expires: time.Now().Add(config.Opts.CleanupRemoveSessionsInterval()),
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ package request // import "miniflux.app/v2/internal/http/request"
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"miniflux.app/v2/internal/model"
|
||||
)
|
||||
@@ -35,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 {
|
||||
@@ -44,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 == "" {
|
||||
@@ -73,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 == "" {
|
||||
@@ -82,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 == "" {
|
||||
@@ -91,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 == "" {
|
||||
@@ -100,51 +102,52 @@ 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.
|
||||
func LastForceRefresh(r *http.Request) int64 {
|
||||
// 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)
|
||||
if err != nil {
|
||||
return 0
|
||||
return time.Time{}
|
||||
}
|
||||
return timestamp
|
||||
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}
|
||||
|
||||
@@ -84,8 +84,6 @@ func (b *Builder) Write() {
|
||||
b.compress(v)
|
||||
case string:
|
||||
b.compress([]byte(v))
|
||||
case error:
|
||||
b.compress([]byte(v.Error()))
|
||||
case io.Reader:
|
||||
// Compression not implemented in this case
|
||||
b.writeHeaders()
|
||||
@@ -117,24 +115,24 @@ func (b *Builder) compress(data []byte) {
|
||||
b.writeHeaders()
|
||||
|
||||
brotliWriter := brotli.NewWriterV2(b.w, brotli.DefaultCompression)
|
||||
defer brotliWriter.Close()
|
||||
brotliWriter.Write(data)
|
||||
brotliWriter.Close()
|
||||
return
|
||||
case strings.Contains(acceptEncoding, "gzip"):
|
||||
b.headers["Content-Encoding"] = "gzip"
|
||||
b.writeHeaders()
|
||||
|
||||
gzipWriter := gzip.NewWriter(b.w)
|
||||
defer gzipWriter.Close()
|
||||
gzipWriter.Write(data)
|
||||
gzipWriter.Close()
|
||||
return
|
||||
case strings.Contains(acceptEncoding, "deflate"):
|
||||
b.headers["Content-Encoding"] = "deflate"
|
||||
b.writeHeaders()
|
||||
|
||||
flateWriter, _ := flate.NewWriter(b.w, -1)
|
||||
defer flateWriter.Close()
|
||||
flateWriter.Write(data)
|
||||
flateWriter.Close()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
package response // import "miniflux.app/v2/internal/http/response"
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
@@ -105,27 +104,6 @@ func TestBuildResponseWithAttachment(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildResponseWithError(t *testing.T) {
|
||||
r, err := http.NewRequest("GET", "/", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
New(w, r).WithBody(errors.New("Some error")).Write()
|
||||
})
|
||||
|
||||
handler.ServeHTTP(w, r)
|
||||
|
||||
expectedBody := `Some error`
|
||||
actualBody := w.Body.String()
|
||||
if actualBody != expectedBody {
|
||||
t.Fatalf(`Unexpected body, got %s instead of %s`, actualBody, expectedBody)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildResponseWithByteBody(t *testing.T) {
|
||||
r, err := http.NewRequest("GET", "/", nil)
|
||||
if err != nil {
|
||||
|
||||
@@ -12,7 +12,6 @@ import (
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"miniflux.app/v2/internal/api"
|
||||
"miniflux.app/v2/internal/config"
|
||||
@@ -21,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"
|
||||
@@ -61,34 +59,35 @@ func StartWebServer(store *storage.Storage, pool *worker.Pool) []*http.Server {
|
||||
slog.Error("ACME HTTP challenge server failed", slog.Any("error", err))
|
||||
}
|
||||
}()
|
||||
config.Opts.HTTPS = true
|
||||
config.Opts.SetHTTPSValue(true)
|
||||
httpServers = append(httpServers, challengeServer)
|
||||
}
|
||||
|
||||
for i, listenAddr := range listenAddresses {
|
||||
server := &http.Server{
|
||||
ReadTimeout: time.Duration(config.Opts.HTTPServerTimeout()) * time.Second,
|
||||
WriteTimeout: time.Duration(config.Opts.HTTPServerTimeout()) * time.Second,
|
||||
IdleTimeout: time.Duration(config.Opts.HTTPServerTimeout()) * time.Second,
|
||||
ReadTimeout: config.Opts.HTTPServerTimeout(),
|
||||
WriteTimeout: config.Opts.HTTPServerTimeout(),
|
||||
IdleTimeout: config.Opts.HTTPServerTimeout(),
|
||||
Handler: setupHandler(store, pool),
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(listenAddr, "/") && os.Getenv("LISTEN_PID") != strconv.Itoa(os.Getpid()) {
|
||||
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
|
||||
@@ -96,15 +95,13 @@ func StartWebServer(store *storage.Storage, pool *worker.Pool) []*http.Server {
|
||||
case certFile != "" && keyFile != "":
|
||||
server.Addr = listenAddr
|
||||
startTLSServer(server, certFile, keyFile)
|
||||
config.Opts.HTTPS = true
|
||||
config.Opts.SetHTTPSValue(true)
|
||||
default:
|
||||
server.Addr = listenAddr
|
||||
startHTTPServer(server)
|
||||
}
|
||||
|
||||
if shouldAddServer {
|
||||
httpServers = append(httpServers, server)
|
||||
}
|
||||
httpServers = append(httpServers, server)
|
||||
}
|
||||
|
||||
return httpServers
|
||||
@@ -149,7 +146,7 @@ func startUnixSocketServer(server *http.Server, socketFile string) {
|
||||
slog.String("key_file", keyFile),
|
||||
)
|
||||
// Ensure HTTPS is marked as true if any listener uses TLS
|
||||
config.Opts.HTTPS = true
|
||||
config.Opts.SetHTTPSValue(true)
|
||||
if err := server.ServeTLS(listener, certFile, keyFile); err != http.ErrServerClosed {
|
||||
printErrorAndExit("TLS Unix socket server failed to start on %s: %v", socketFile, err)
|
||||
}
|
||||
@@ -244,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 {
|
||||
@@ -315,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,12 +15,14 @@ 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" {
|
||||
config.Opts.HTTPS = true
|
||||
if isTrustedProxyClientIP && r.Header.Get("X-Forwarded-Proto") == "https" {
|
||||
config.Opts.SetHTTPSValue(true)
|
||||
}
|
||||
|
||||
t1 := time.Now()
|
||||
@@ -36,7 +38,7 @@ func middleware(next http.Handler) http.Handler {
|
||||
)
|
||||
}()
|
||||
|
||||
if config.Opts.HTTPS && config.Opts.HasHSTS() {
|
||||
if config.Opts.HTTPS() && config.Opts.HasHSTS() {
|
||||
w.Header().Set("Strict-Transport-Security", "max-age=31536000")
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ package apprise
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
@@ -29,7 +30,7 @@ func NewClient(serviceURL, baseURL string) *Client {
|
||||
|
||||
func (c *Client) SendNotification(feed *model.Feed, entries model.Entries) error {
|
||||
if c.baseURL == "" || c.servicesURL == "" {
|
||||
return fmt.Errorf("apprise: missing base URL or services URL")
|
||||
return errors.New("apprise: missing base URL or services URL")
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package archiveorg
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
// See https://docs.google.com/document/d/1Nsv52MvSjbLb2PCpHlat0gkzw0EvtSgpKHu4mk0MnrA/edit?tab=t.0
|
||||
const options = "delay_wb_availability=1&if_not_archived_within=15d"
|
||||
|
||||
type Client struct{}
|
||||
|
||||
func NewClient() *Client {
|
||||
return &Client{}
|
||||
}
|
||||
|
||||
func (c *Client) SendURL(entryURL, title string) {
|
||||
// We're using a goroutine here as submissions to archive.org might take a long time
|
||||
// and trigger a timeout on miniflux' side.
|
||||
go func(entryURL string) {
|
||||
res, err := http.Get("https://web.archive.org/save/" + url.QueryEscape(entryURL) + "?" + options)
|
||||
if err != nil {
|
||||
slog.Error("archiveorg: unable to send request: %v",
|
||||
slog.Any("err", err),
|
||||
slog.String("title", title),
|
||||
slog.String("url", entryURL),
|
||||
)
|
||||
return
|
||||
}
|
||||
if res.StatusCode > 299 {
|
||||
slog.Error("archiveorg: failed with status code",
|
||||
slog.String("title", title),
|
||||
slog.String("url", entryURL),
|
||||
slog.Int("code", res.StatusCode),
|
||||
)
|
||||
}
|
||||
res.Body.Close()
|
||||
}(entryURL)
|
||||
}
|
||||
@@ -1,4 +1,7 @@
|
||||
package betula
|
||||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package betula // import "miniflux.app/v2/internal/integration/betula"
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
@@ -6,6 +6,7 @@ package espial // import "miniflux.app/v2/internal/integration/espial"
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
@@ -27,7 +28,7 @@ func NewClient(baseURL, apiKey string) *Client {
|
||||
|
||||
func (c *Client) CreateLink(entryURL, entryTitle, espialTags string) error {
|
||||
if c.baseURL == "" || c.apiKey == "" {
|
||||
return fmt.Errorf("espial: missing base URL or API key")
|
||||
return errors.New("espial: missing base URL or API key")
|
||||
}
|
||||
|
||||
apiEndpoint, err := urllib.JoinBaseURLAndPath(c.baseURL, "/api/add")
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
package instapaper // import "miniflux.app/v2/internal/integration/instapaper"
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
@@ -25,7 +26,7 @@ func NewClient(username, password string) *Client {
|
||||
|
||||
func (c *Client) AddURL(entryURL, entryTitle string) error {
|
||||
if c.username == "" || c.password == "" {
|
||||
return fmt.Errorf("instapaper: missing username or password")
|
||||
return errors.New("instapaper: missing username or password")
|
||||
}
|
||||
|
||||
values := url.Values{}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"log/slog"
|
||||
|
||||
"miniflux.app/v2/internal/integration/apprise"
|
||||
"miniflux.app/v2/internal/integration/archiveorg"
|
||||
"miniflux.app/v2/internal/integration/betula"
|
||||
"miniflux.app/v2/internal/integration/cubox"
|
||||
"miniflux.app/v2/internal/integration/discord"
|
||||
@@ -15,6 +16,7 @@ import (
|
||||
"miniflux.app/v2/internal/integration/karakeep"
|
||||
"miniflux.app/v2/internal/integration/linkace"
|
||||
"miniflux.app/v2/internal/integration/linkding"
|
||||
"miniflux.app/v2/internal/integration/linktaco"
|
||||
"miniflux.app/v2/internal/integration/linkwarden"
|
||||
"miniflux.app/v2/internal/integration/matrixbot"
|
||||
"miniflux.app/v2/internal/integration/notion"
|
||||
@@ -107,6 +109,7 @@ func SendEntry(entry *model.Entry, userIntegrations *model.Integration) {
|
||||
if userIntegrations.WallabagEnabled {
|
||||
slog.Debug("Sending entry to Wallabag",
|
||||
slog.Int64("user_id", userIntegrations.UserID),
|
||||
slog.String("user_tags", userIntegrations.WallabagTags),
|
||||
slog.Int64("entry_id", entry.ID),
|
||||
slog.String("entry_url", entry.URL),
|
||||
)
|
||||
@@ -117,12 +120,14 @@ func SendEntry(entry *model.Entry, userIntegrations *model.Integration) {
|
||||
userIntegrations.WallabagClientSecret,
|
||||
userIntegrations.WallabagUsername,
|
||||
userIntegrations.WallabagPassword,
|
||||
userIntegrations.WallabagTags,
|
||||
userIntegrations.WallabagOnlyURL,
|
||||
)
|
||||
|
||||
if err := client.CreateEntry(entry.URL, entry.Title, entry.Content); err != nil {
|
||||
slog.Error("Unable to send entry to Wallabag",
|
||||
slog.Int64("user_id", userIntegrations.UserID),
|
||||
slog.String("user_tags", userIntegrations.WallabagTags),
|
||||
slog.Int64("entry_id", entry.ID),
|
||||
slog.String("entry_url", entry.URL),
|
||||
slog.Any("error", err),
|
||||
@@ -242,19 +247,21 @@ func SendEntry(entry *model.Entry, userIntegrations *model.Integration) {
|
||||
}
|
||||
}
|
||||
|
||||
if userIntegrations.LinkwardenEnabled {
|
||||
slog.Debug("Sending entry to linkwarden",
|
||||
if userIntegrations.LinktacoEnabled {
|
||||
slog.Debug("Sending entry to LinkTaco",
|
||||
slog.Int64("user_id", userIntegrations.UserID),
|
||||
slog.Int64("entry_id", entry.ID),
|
||||
slog.String("entry_url", entry.URL),
|
||||
)
|
||||
|
||||
client := linkwarden.NewClient(
|
||||
userIntegrations.LinkwardenURL,
|
||||
userIntegrations.LinkwardenAPIKey,
|
||||
client := linktaco.NewClient(
|
||||
userIntegrations.LinktacoAPIToken,
|
||||
userIntegrations.LinktacoOrgSlug,
|
||||
userIntegrations.LinktacoTags,
|
||||
userIntegrations.LinktacoVisibility,
|
||||
)
|
||||
if err := client.CreateBookmark(entry.URL, entry.Title); err != nil {
|
||||
slog.Error("Unable to send entry to Linkwarden",
|
||||
if err := client.CreateBookmark(entry.URL, entry.Title, entry.Content); err != nil {
|
||||
slog.Error("Unable to send entry to LinkTaco",
|
||||
slog.Int64("user_id", userIntegrations.UserID),
|
||||
slog.Int64("entry_id", entry.ID),
|
||||
slog.String("entry_url", entry.URL),
|
||||
@@ -263,6 +270,30 @@ func SendEntry(entry *model.Entry, userIntegrations *model.Integration) {
|
||||
}
|
||||
}
|
||||
|
||||
if userIntegrations.LinkwardenEnabled {
|
||||
attrs := []any{
|
||||
slog.Int64("user_id", userIntegrations.UserID),
|
||||
slog.Int64("entry_id", entry.ID),
|
||||
slog.String("entry_url", entry.URL),
|
||||
}
|
||||
|
||||
if userIntegrations.LinkwardenCollectionID != nil {
|
||||
attrs = append(attrs, slog.Int64("collection_id", *userIntegrations.LinkwardenCollectionID))
|
||||
}
|
||||
|
||||
slog.Debug("Sending entry to linkwarden", attrs...)
|
||||
|
||||
client := linkwarden.NewClient(
|
||||
userIntegrations.LinkwardenURL,
|
||||
userIntegrations.LinkwardenAPIKey,
|
||||
userIntegrations.LinkwardenCollectionID,
|
||||
)
|
||||
if err := client.CreateBookmark(entry.URL, entry.Title); err != nil {
|
||||
attrs = append(attrs, slog.Any("error", err))
|
||||
slog.Error("Unable to send entry to Linkwarden", attrs...)
|
||||
}
|
||||
}
|
||||
|
||||
if userIntegrations.ReadeckEnabled {
|
||||
slog.Debug("Sending entry to Readeck",
|
||||
slog.Int64("user_id", userIntegrations.UserID),
|
||||
@@ -371,6 +402,16 @@ func SendEntry(entry *model.Entry, userIntegrations *model.Integration) {
|
||||
}
|
||||
}
|
||||
|
||||
if userIntegrations.ArchiveorgEnabled {
|
||||
slog.Debug("Sending entry to archive.org",
|
||||
slog.Int64("user_id", userIntegrations.UserID),
|
||||
slog.Int64("entry_id", entry.ID),
|
||||
slog.String("entry_url", entry.URL),
|
||||
)
|
||||
|
||||
archiveorg.NewClient().SendURL(entry.URL, entry.Title)
|
||||
}
|
||||
|
||||
if userIntegrations.WebhookEnabled {
|
||||
var webhookURL string
|
||||
if entry.Feed != nil && entry.Feed.WebhookURL != "" {
|
||||
@@ -419,14 +460,20 @@ func SendEntry(entry *model.Entry, userIntegrations *model.Integration) {
|
||||
if userIntegrations.KarakeepEnabled {
|
||||
slog.Debug("Sending entry to Karakeep",
|
||||
slog.Int64("user_id", userIntegrations.UserID),
|
||||
slog.String("user_tags", userIntegrations.KarakeepTags),
|
||||
slog.Int64("entry_id", entry.ID),
|
||||
slog.String("entry_url", entry.URL),
|
||||
)
|
||||
|
||||
client := karakeep.NewClient(userIntegrations.KarakeepAPIKey, userIntegrations.KarakeepURL)
|
||||
client := karakeep.NewClient(
|
||||
userIntegrations.KarakeepAPIKey,
|
||||
userIntegrations.KarakeepURL,
|
||||
userIntegrations.KarakeepTags,
|
||||
)
|
||||
if err := client.SaveURL(entry.URL); err != nil {
|
||||
slog.Error("Unable to send entry to Karakeep",
|
||||
slog.Int64("user_id", userIntegrations.UserID),
|
||||
slog.String("user_tags", userIntegrations.KarakeepTags),
|
||||
slog.Int64("entry_id", entry.ID),
|
||||
slog.String("entry_url", entry.URL),
|
||||
slog.Any("error", err),
|
||||
@@ -479,7 +526,6 @@ func PushEntries(feed *model.Feed, entries model.Entries, userIntegrations *mode
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if userIntegrations.WebhookEnabled {
|
||||
var webhookURL string
|
||||
if feed.WebhookURL != "" {
|
||||
@@ -639,4 +685,30 @@ func PushEntries(feed *model.Feed, entries model.Entries, userIntegrations *mode
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
@@ -6,9 +6,11 @@ package karakeep // import "miniflux.app/v2/internal/integration/karakeep"
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"miniflux.app/v2/internal/version"
|
||||
@@ -16,9 +18,15 @@ import (
|
||||
|
||||
const defaultClientTimeout = 10 * time.Second
|
||||
|
||||
type errorResponse struct {
|
||||
Code string `json:"code"`
|
||||
Error string `json:"error"`
|
||||
type Client struct {
|
||||
wrapped *http.Client
|
||||
apiEndpoint string
|
||||
apiToken string
|
||||
tags string
|
||||
}
|
||||
|
||||
type tagItem struct {
|
||||
TagName string `json:"tagName"`
|
||||
}
|
||||
|
||||
type saveURLPayload struct {
|
||||
@@ -26,14 +34,75 @@ type saveURLPayload struct {
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
type Client struct {
|
||||
wrapped *http.Client
|
||||
apiEndpoint string
|
||||
apiToken string
|
||||
type saveURLResponse struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
|
||||
func NewClient(apiToken string, apiEndpoint string) *Client {
|
||||
return &Client{wrapped: &http.Client{Timeout: defaultClientTimeout}, apiEndpoint: apiEndpoint, apiToken: apiToken}
|
||||
type attachTagsPayload struct {
|
||||
Tags []tagItem `json:"tags"`
|
||||
}
|
||||
|
||||
type errorResponse struct {
|
||||
Code string `json:"code"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
func NewClient(apiToken string, apiEndpoint string, tags string) *Client {
|
||||
return &Client{wrapped: &http.Client{Timeout: defaultClientTimeout}, apiEndpoint: apiEndpoint, apiToken: apiToken, tags: tags}
|
||||
}
|
||||
|
||||
func (c *Client) attachTags(entryID string) error {
|
||||
if c.tags == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
tagItems := make([]tagItem, 0)
|
||||
for tag := range strings.SplitSeq(c.tags, ",") {
|
||||
if trimmedTag := strings.TrimSpace(tag); trimmedTag != "" {
|
||||
tagItems = append(tagItems, tagItem{TagName: trimmedTag})
|
||||
}
|
||||
}
|
||||
|
||||
if len(tagItems) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
tagRequestBody, err := json.Marshal(&attachTagsPayload{
|
||||
Tags: tagItems,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("karakeep: unable to encode tag request body: %v", err)
|
||||
}
|
||||
|
||||
tagRequest, err := http.NewRequest(http.MethodPost, fmt.Sprintf("%s/%s/tags", c.apiEndpoint, entryID), bytes.NewReader(tagRequestBody))
|
||||
if err != nil {
|
||||
return fmt.Errorf("karakeep: unable to create tag request: %v", err)
|
||||
}
|
||||
|
||||
tagRequest.Header.Set("Authorization", "Bearer "+c.apiToken)
|
||||
tagRequest.Header.Set("Content-Type", "application/json")
|
||||
tagRequest.Header.Set("User-Agent", "Miniflux/"+version.Version)
|
||||
|
||||
tagResponse, err := c.wrapped.Do(tagRequest)
|
||||
if err != nil {
|
||||
return fmt.Errorf("karakeep: unable to send tag request: %v", err)
|
||||
}
|
||||
defer tagResponse.Body.Close()
|
||||
|
||||
if tagResponse.StatusCode != http.StatusOK && tagResponse.StatusCode != http.StatusCreated {
|
||||
tagResponseBody, err := io.ReadAll(tagResponse.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("karakeep: failed to parse tag response: %s", err)
|
||||
}
|
||||
|
||||
var errResponse errorResponse
|
||||
if err := json.Unmarshal(tagResponseBody, &errResponse); err != nil {
|
||||
return fmt.Errorf("karakeep: unable to parse tag error response: status=%d body=%s", tagResponse.StatusCode, string(tagResponseBody))
|
||||
}
|
||||
return fmt.Errorf("karakeep: failed to attach tags: status=%d errorcode=%s %s", tagResponse.StatusCode, errResponse.Code, errResponse.Error)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) SaveURL(entryURL string) error {
|
||||
@@ -50,7 +119,7 @@ func (c *Client) SaveURL(entryURL string) error {
|
||||
return fmt.Errorf("karakeep: unable to create request: %v", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", c.apiToken))
|
||||
req.Header.Set("Authorization", "Bearer "+c.apiToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("User-Agent", "Miniflux/"+version.Version)
|
||||
|
||||
@@ -77,5 +146,18 @@ func (c *Client) SaveURL(entryURL string) error {
|
||||
return fmt.Errorf("karakeep: failed to save URL: status=%d errorcode=%s %s", resp.StatusCode, errResponse.Code, errResponse.Error)
|
||||
}
|
||||
|
||||
var response saveURLResponse
|
||||
if err := json.Unmarshal(responseBody, &response); err != nil {
|
||||
return fmt.Errorf("karakeep: unable to parse response: %v", err)
|
||||
}
|
||||
|
||||
if response.ID == "" {
|
||||
return errors.New("karakeep: unable to get ID from response")
|
||||
}
|
||||
|
||||
if err := c.attachTags(response.ID); err != nil {
|
||||
return fmt.Errorf("karakeep: unable to attach tags: %v", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
package linkace
|
||||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package linkace // import "miniflux.app/v2/internal/integration/linkace"
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
@@ -28,7 +32,7 @@ func NewClient(baseURL, apiKey, tags string, private bool, checkDisabled bool) *
|
||||
|
||||
func (c *Client) AddURL(entryURL, entryTitle string) error {
|
||||
if c.baseURL == "" || c.apiKey == "" {
|
||||
return fmt.Errorf("linkace: missing base URL or API key")
|
||||
return errors.New("linkace: missing base URL or API key")
|
||||
}
|
||||
|
||||
tagsSplitFn := func(c rune) bool {
|
||||
|
||||
@@ -6,6 +6,7 @@ package linkding // import "miniflux.app/v2/internal/integration/linkding"
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
@@ -30,7 +31,7 @@ func NewClient(baseURL, apiKey, tags string, unread bool) *Client {
|
||||
|
||||
func (c *Client) CreateBookmark(entryURL, entryTitle string) error {
|
||||
if c.baseURL == "" || c.apiKey == "" {
|
||||
return fmt.Errorf("linkding: missing base URL or API key")
|
||||
return errors.New("linkding: missing base URL or API key")
|
||||
}
|
||||
|
||||
tagsSplitFn := func(c rune) bool {
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package linktaco // import "miniflux.app/v2/internal/integration/linktaco"
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"miniflux.app/v2/internal/version"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultClientTimeout = 10 * time.Second
|
||||
defaultGraphQLURL = "https://api.linktaco.com/query"
|
||||
maxTags = 10
|
||||
maxDescriptionLength = 500
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
graphqlURL string
|
||||
apiToken string
|
||||
orgSlug string
|
||||
tags string
|
||||
visibility string
|
||||
}
|
||||
|
||||
func NewClient(apiToken, orgSlug, tags, visibility string) *Client {
|
||||
if visibility == "" {
|
||||
visibility = "PUBLIC"
|
||||
}
|
||||
return &Client{
|
||||
graphqlURL: defaultGraphQLURL,
|
||||
apiToken: apiToken,
|
||||
orgSlug: orgSlug,
|
||||
tags: tags,
|
||||
visibility: visibility,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) CreateBookmark(entryURL, entryTitle, entryContent string) error {
|
||||
if c.apiToken == "" || c.orgSlug == "" {
|
||||
return errors.New("linktaco: missing API token or organization slug")
|
||||
}
|
||||
|
||||
description := entryContent
|
||||
if len(description) > maxDescriptionLength {
|
||||
description = description[:maxDescriptionLength]
|
||||
}
|
||||
|
||||
// tags (limit to 10)
|
||||
tags := strings.FieldsFunc(c.tags, func(c rune) bool {
|
||||
return c == ',' || c == ' '
|
||||
})
|
||||
if len(tags) > maxTags {
|
||||
tags = tags[:maxTags]
|
||||
}
|
||||
// tagsStr is used in GraphQL query to pass comma separated tags
|
||||
tagsStr := strings.Join(tags, ",")
|
||||
|
||||
mutation := `
|
||||
mutation AddLink($input: LinkInput!) {
|
||||
addLink(input: $input) {
|
||||
id
|
||||
url
|
||||
title
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
variables := map[string]any{
|
||||
"input": map[string]any{
|
||||
"url": entryURL,
|
||||
"title": entryTitle,
|
||||
"description": description,
|
||||
"orgSlug": c.orgSlug,
|
||||
"visibility": c.visibility,
|
||||
"unread": true,
|
||||
"starred": false,
|
||||
"archive": false,
|
||||
"tags": tagsStr,
|
||||
},
|
||||
}
|
||||
|
||||
requestBody, err := json.Marshal(map[string]any{
|
||||
"query": mutation,
|
||||
"variables": variables,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("linktaco: unable to encode request body: %v", err)
|
||||
}
|
||||
|
||||
request, err := http.NewRequest(http.MethodPost, c.graphqlURL, bytes.NewReader(requestBody))
|
||||
if err != nil {
|
||||
return fmt.Errorf("linktaco: unable to create request: %v", err)
|
||||
}
|
||||
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
|
||||
request.Header.Set("Authorization", "Bearer "+c.apiToken)
|
||||
|
||||
httpClient := &http.Client{Timeout: defaultClientTimeout}
|
||||
response, err := httpClient.Do(request)
|
||||
if err != nil {
|
||||
return fmt.Errorf("linktaco: unable to send request: %v", err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
|
||||
if response.StatusCode >= 400 {
|
||||
return fmt.Errorf("linktaco: unable to create bookmark: status=%d", response.StatusCode)
|
||||
}
|
||||
|
||||
var graphqlResponse struct {
|
||||
Data json.RawMessage `json:"data"`
|
||||
Errors []json.RawMessage `json:"errors"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(response.Body).Decode(&graphqlResponse); err != nil {
|
||||
return fmt.Errorf("linktaco: unable to decode response: %v", err)
|
||||
}
|
||||
|
||||
if len(graphqlResponse.Errors) > 0 {
|
||||
// Try to extract error message
|
||||
var errorMsg string
|
||||
for _, errJSON := range graphqlResponse.Errors {
|
||||
var errObj struct {
|
||||
Message string `json:"message"`
|
||||
}
|
||||
if json.Unmarshal(errJSON, &errObj) == nil && errObj.Message != "" {
|
||||
errorMsg = errObj.Message
|
||||
break
|
||||
}
|
||||
}
|
||||
if errorMsg == "" {
|
||||
// Fallback. Should never be reached.
|
||||
errorMsg = "GraphQL error occurred (fallback message)"
|
||||
}
|
||||
return fmt.Errorf("linktaco: %s", errorMsg)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,440 @@
|
||||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package linktaco
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCreateBookmark(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
apiToken string
|
||||
orgSlug string
|
||||
tags string
|
||||
visibility string
|
||||
entryURL string
|
||||
entryTitle string
|
||||
entryContent string
|
||||
serverResponse func(w http.ResponseWriter, r *http.Request)
|
||||
wantErr bool
|
||||
errContains string
|
||||
}{
|
||||
{
|
||||
name: "successful bookmark creation",
|
||||
apiToken: "test-token",
|
||||
orgSlug: "test-org",
|
||||
tags: "tag1, tag2",
|
||||
visibility: "PUBLIC",
|
||||
entryURL: "https://example.com",
|
||||
entryTitle: "Test Article",
|
||||
entryContent: "Test content",
|
||||
serverResponse: func(w http.ResponseWriter, r *http.Request) {
|
||||
// Verify authorization header
|
||||
auth := r.Header.Get("Authorization")
|
||||
if auth != "Bearer test-token" {
|
||||
t.Errorf("Expected Authorization header 'Bearer test-token', got %s", auth)
|
||||
}
|
||||
|
||||
// Verify content type
|
||||
contentType := r.Header.Get("Content-Type")
|
||||
if contentType != "application/json" {
|
||||
t.Errorf("Expected Content-Type 'application/json', got %s", contentType)
|
||||
}
|
||||
|
||||
// Parse and verify request
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
var req map[string]interface{}
|
||||
if err := json.Unmarshal(body, &req); err != nil {
|
||||
t.Errorf("Failed to parse request body: %v", err)
|
||||
}
|
||||
|
||||
// Verify mutation exists
|
||||
if _, ok := req["query"]; !ok {
|
||||
t.Error("Missing 'query' field in request")
|
||||
}
|
||||
|
||||
// Return success response
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"data": map[string]interface{}{
|
||||
"addLink": map[string]interface{}{
|
||||
"id": "123",
|
||||
"url": "https://example.com",
|
||||
"title": "Test Article",
|
||||
},
|
||||
},
|
||||
})
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "missing API token",
|
||||
apiToken: "",
|
||||
orgSlug: "test-org",
|
||||
entryURL: "https://example.com",
|
||||
entryTitle: "Test",
|
||||
entryContent: "Content",
|
||||
serverResponse: func(w http.ResponseWriter, r *http.Request) {
|
||||
// Should not be called
|
||||
t.Error("Server should not be called when API token is missing")
|
||||
},
|
||||
wantErr: true,
|
||||
errContains: "missing API token or organization slug",
|
||||
},
|
||||
{
|
||||
name: "missing organization slug",
|
||||
apiToken: "test-token",
|
||||
orgSlug: "",
|
||||
entryURL: "https://example.com",
|
||||
entryTitle: "Test",
|
||||
entryContent: "Content",
|
||||
serverResponse: func(w http.ResponseWriter, r *http.Request) {
|
||||
// Should not be called
|
||||
t.Error("Server should not be called when org slug is missing")
|
||||
},
|
||||
wantErr: true,
|
||||
errContains: "missing API token or organization slug",
|
||||
},
|
||||
{
|
||||
name: "GraphQL error response",
|
||||
apiToken: "test-token",
|
||||
orgSlug: "test-org",
|
||||
entryURL: "https://example.com",
|
||||
entryTitle: "Test",
|
||||
entryContent: "Content",
|
||||
serverResponse: func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"errors": []interface{}{
|
||||
map[string]interface{}{
|
||||
"message": "Invalid input",
|
||||
},
|
||||
},
|
||||
})
|
||||
},
|
||||
wantErr: true,
|
||||
errContains: "Invalid input",
|
||||
},
|
||||
{
|
||||
name: "HTTP error status",
|
||||
apiToken: "test-token",
|
||||
orgSlug: "test-org",
|
||||
entryURL: "https://example.com",
|
||||
entryTitle: "Test",
|
||||
entryContent: "Content",
|
||||
serverResponse: func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
},
|
||||
wantErr: true,
|
||||
errContains: "status=401",
|
||||
},
|
||||
{
|
||||
name: "private visibility permission error",
|
||||
apiToken: "test-token",
|
||||
orgSlug: "test-org",
|
||||
visibility: "PRIVATE",
|
||||
entryURL: "https://example.com",
|
||||
entryTitle: "Test",
|
||||
entryContent: "Content",
|
||||
serverResponse: func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"errors": []interface{}{
|
||||
map[string]interface{}{
|
||||
"message": "PRIVATE visibility requires a paid LinkTaco account",
|
||||
},
|
||||
},
|
||||
})
|
||||
},
|
||||
wantErr: true,
|
||||
errContains: "PRIVATE visibility requires a paid LinkTaco account",
|
||||
},
|
||||
{
|
||||
name: "content truncation",
|
||||
apiToken: "test-token",
|
||||
orgSlug: "test-org",
|
||||
entryURL: "https://example.com",
|
||||
entryTitle: "Test",
|
||||
entryContent: strings.Repeat("a", 600), // Content longer than 500 chars
|
||||
serverResponse: func(w http.ResponseWriter, r *http.Request) {
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
var req map[string]interface{}
|
||||
json.Unmarshal(body, &req)
|
||||
|
||||
// Check that description was truncated
|
||||
variables := req["variables"].(map[string]interface{})
|
||||
input := variables["input"].(map[string]interface{})
|
||||
description := input["description"].(string)
|
||||
|
||||
if len(description) != maxDescriptionLength {
|
||||
t.Errorf("Expected description length %d, got %d", maxDescriptionLength, len(description))
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"data": map[string]interface{}{
|
||||
"addLink": map[string]interface{}{"id": "123"},
|
||||
},
|
||||
})
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "tag limiting",
|
||||
apiToken: "test-token",
|
||||
orgSlug: "test-org",
|
||||
tags: "tag1,tag2,tag3,tag4,tag5,tag6,tag7,tag8,tag9,tag10,tag11,tag12",
|
||||
entryURL: "https://example.com",
|
||||
entryTitle: "Test",
|
||||
entryContent: "Content",
|
||||
serverResponse: func(w http.ResponseWriter, r *http.Request) {
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
var req map[string]interface{}
|
||||
json.Unmarshal(body, &req)
|
||||
|
||||
// Check that only 10 tags were sent
|
||||
variables := req["variables"].(map[string]interface{})
|
||||
input := variables["input"].(map[string]interface{})
|
||||
tags := input["tags"].(string)
|
||||
|
||||
tagCount := len(strings.Split(tags, ","))
|
||||
if tagCount != maxTags {
|
||||
t.Errorf("Expected %d tags, got %d", maxTags, tagCount)
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"data": map[string]interface{}{
|
||||
"addLink": map[string]interface{}{"id": "123"},
|
||||
},
|
||||
})
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "invalid JSON response",
|
||||
apiToken: "test-token",
|
||||
orgSlug: "test-org",
|
||||
entryURL: "https://example.com",
|
||||
entryTitle: "Test",
|
||||
entryContent: "Content",
|
||||
serverResponse: func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("invalid json"))
|
||||
},
|
||||
wantErr: true,
|
||||
errContains: "unable to decode response",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Create test server if we have a server response function
|
||||
var serverURL string
|
||||
if tt.serverResponse != nil {
|
||||
server := httptest.NewServer(http.HandlerFunc(tt.serverResponse))
|
||||
defer server.Close()
|
||||
serverURL = server.URL
|
||||
}
|
||||
|
||||
// Create client with test server URL
|
||||
client := &Client{
|
||||
graphqlURL: serverURL,
|
||||
apiToken: tt.apiToken,
|
||||
orgSlug: tt.orgSlug,
|
||||
tags: tt.tags,
|
||||
visibility: tt.visibility,
|
||||
}
|
||||
|
||||
// Call CreateBookmark
|
||||
err := client.CreateBookmark(tt.entryURL, tt.entryTitle, tt.entryContent)
|
||||
|
||||
// Check error expectations
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Errorf("Expected error but got none")
|
||||
} else if tt.errContains != "" && !strings.Contains(err.Error(), tt.errContains) {
|
||||
t.Errorf("Expected error containing '%s', got '%s'", tt.errContains, err.Error())
|
||||
}
|
||||
} else {
|
||||
if err != nil {
|
||||
t.Errorf("Unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewClient(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
apiToken string
|
||||
orgSlug string
|
||||
tags string
|
||||
visibility string
|
||||
expectedVisibility string
|
||||
}{
|
||||
{
|
||||
name: "with all parameters",
|
||||
apiToken: "token",
|
||||
orgSlug: "org",
|
||||
tags: "tag1,tag2",
|
||||
visibility: "PRIVATE",
|
||||
expectedVisibility: "PRIVATE",
|
||||
},
|
||||
{
|
||||
name: "empty visibility defaults to PUBLIC",
|
||||
apiToken: "token",
|
||||
orgSlug: "org",
|
||||
tags: "tag1",
|
||||
visibility: "",
|
||||
expectedVisibility: "PUBLIC",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
client := NewClient(tt.apiToken, tt.orgSlug, tt.tags, tt.visibility)
|
||||
|
||||
if client.apiToken != tt.apiToken {
|
||||
t.Errorf("Expected apiToken %s, got %s", tt.apiToken, client.apiToken)
|
||||
}
|
||||
if client.orgSlug != tt.orgSlug {
|
||||
t.Errorf("Expected orgSlug %s, got %s", tt.orgSlug, client.orgSlug)
|
||||
}
|
||||
if client.tags != tt.tags {
|
||||
t.Errorf("Expected tags %s, got %s", tt.tags, client.tags)
|
||||
}
|
||||
if client.visibility != tt.expectedVisibility {
|
||||
t.Errorf("Expected visibility %s, got %s", tt.expectedVisibility, client.visibility)
|
||||
}
|
||||
if client.graphqlURL != defaultGraphQLURL {
|
||||
t.Errorf("Expected graphqlURL %s, got %s", defaultGraphQLURL, client.graphqlURL)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGraphQLMutation(t *testing.T) {
|
||||
// Test that the GraphQL mutation is properly formatted
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
var req map[string]interface{}
|
||||
if err := json.Unmarshal(body, &req); err != nil {
|
||||
t.Fatalf("Failed to parse request: %v", err)
|
||||
}
|
||||
|
||||
// Verify mutation structure
|
||||
query, ok := req["query"].(string)
|
||||
if !ok {
|
||||
t.Fatal("Missing query field")
|
||||
}
|
||||
|
||||
// Check that mutation contains expected parts
|
||||
if !strings.Contains(query, "mutation AddLink") {
|
||||
t.Error("Mutation should contain 'mutation AddLink'")
|
||||
}
|
||||
if !strings.Contains(query, "$input: LinkInput!") {
|
||||
t.Error("Mutation should contain input parameter")
|
||||
}
|
||||
if !strings.Contains(query, "addLink(input: $input)") {
|
||||
t.Error("Mutation should contain addLink call")
|
||||
}
|
||||
|
||||
// Verify variables structure
|
||||
variables, ok := req["variables"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatal("Missing variables field")
|
||||
}
|
||||
|
||||
input, ok := variables["input"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatal("Missing input in variables")
|
||||
}
|
||||
|
||||
// Check all required fields
|
||||
requiredFields := []string{"url", "title", "description", "orgSlug", "visibility", "unread", "starred", "archive", "tags"}
|
||||
for _, field := range requiredFields {
|
||||
if _, ok := input[field]; !ok {
|
||||
t.Errorf("Missing required field: %s", field)
|
||||
}
|
||||
}
|
||||
|
||||
// Return success
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"data": map[string]interface{}{
|
||||
"addLink": map[string]interface{}{
|
||||
"id": "123",
|
||||
},
|
||||
},
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := &Client{
|
||||
graphqlURL: server.URL,
|
||||
apiToken: "test-token",
|
||||
orgSlug: "test-org",
|
||||
tags: "test",
|
||||
visibility: "PUBLIC",
|
||||
}
|
||||
|
||||
err := client.CreateBookmark("https://example.com", "Test Title", "Test Content")
|
||||
if err != nil {
|
||||
t.Errorf("Unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkCreateBookmark(b *testing.B) {
|
||||
// Create a mock server that always returns success
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"data": map[string]interface{}{
|
||||
"addLink": map[string]interface{}{
|
||||
"id": "123",
|
||||
},
|
||||
},
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := &Client{
|
||||
graphqlURL: server.URL,
|
||||
apiToken: "test-token",
|
||||
orgSlug: "test-org",
|
||||
tags: "tag1,tag2,tag3",
|
||||
visibility: "PUBLIC",
|
||||
}
|
||||
|
||||
// Run benchmark
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = client.CreateBookmark("https://example.com", "Test Title", "Test Content")
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkTagProcessing(b *testing.B) {
|
||||
// Benchmark tag splitting and limiting
|
||||
tags := "tag1,tag2,tag3,tag4,tag5,tag6,tag7,tag8,tag9,tag10,tag11,tag12,tag13,tag14,tag15"
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
tagsSplitFn := func(c rune) bool {
|
||||
return c == ',' || c == ' '
|
||||
}
|
||||
splitTags := strings.FieldsFunc(tags, tagsSplitFn)
|
||||
if len(splitTags) > maxTags {
|
||||
splitTags = splitTags[:maxTags]
|
||||
}
|
||||
_ = strings.Join(splitTags, ",")
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,9 @@ package linkwarden // import "miniflux.app/v2/internal/integration/linkwarden"
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
@@ -17,17 +19,28 @@ 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 {
|
||||
if c.baseURL == "" || c.apiKey == "" {
|
||||
return fmt.Errorf("linkwarden: missing base URL or API key")
|
||||
return errors.New("linkwarden: missing base URL or API key")
|
||||
}
|
||||
|
||||
apiEndpoint, err := urllib.JoinBaseURLAndPath(c.baseURL, "/api/v1/links")
|
||||
@@ -35,10 +48,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)
|
||||
@@ -60,8 +79,13 @@ func (c *Client) CreateBookmark(entryURL, entryTitle string) error {
|
||||
}
|
||||
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,325 @@
|
||||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package linkwarden
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"miniflux.app/v2/internal/model"
|
||||
)
|
||||
|
||||
func TestCreateBookmark(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
baseURL string
|
||||
apiKey string
|
||||
collectionID *int64
|
||||
entryURL string
|
||||
entryTitle string
|
||||
serverResponse func(w http.ResponseWriter, r *http.Request, t *testing.T, collectionID *int64)
|
||||
wantErr bool
|
||||
errContains string
|
||||
}{
|
||||
{
|
||||
name: "successful bookmark creation without collection",
|
||||
baseURL: "",
|
||||
apiKey: "test-api-key",
|
||||
collectionID: nil,
|
||||
entryURL: "https://example.com",
|
||||
entryTitle: "Test Article",
|
||||
serverResponse: func(w http.ResponseWriter, r *http.Request, t *testing.T, collectionId *int64) {
|
||||
// Verify authorization header
|
||||
auth := r.Header.Get("Authorization")
|
||||
if auth != "Bearer test-api-key" {
|
||||
t.Errorf("Expected Authorization header 'Bearer test-api-key', got %s", auth)
|
||||
}
|
||||
|
||||
// Verify content type
|
||||
contentType := r.Header.Get("Content-Type")
|
||||
if contentType != "application/json" {
|
||||
t.Errorf("Expected Content-Type 'application/json', got %s", contentType)
|
||||
}
|
||||
|
||||
// Parse and verify request
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
var req map[string]any
|
||||
if err := json.Unmarshal(body, &req); err != nil {
|
||||
t.Errorf("Failed to parse request body: %v", err)
|
||||
}
|
||||
|
||||
// Verify URL
|
||||
if reqURL := req["url"]; reqURL != "https://example.com" {
|
||||
t.Errorf("Expected URL 'https://example.com', got %v", reqURL)
|
||||
}
|
||||
|
||||
// Verify title/name
|
||||
if reqName := req["name"]; reqName != "Test Article" {
|
||||
t.Errorf("Expected name 'Test Article', got %v", reqName)
|
||||
}
|
||||
|
||||
// Verify collection is not present when nil
|
||||
if _, ok := req["collection"]; ok {
|
||||
t.Error("Expected collection field to be omitted when collectionId is nil")
|
||||
}
|
||||
|
||||
// Return success response
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"id": "123",
|
||||
"url": "https://example.com",
|
||||
"name": "Test Article",
|
||||
})
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "successful bookmark creation with collection",
|
||||
baseURL: "",
|
||||
apiKey: "test-api-key",
|
||||
collectionID: model.OptionalNumber(int64(42)),
|
||||
entryURL: "https://example.com/article",
|
||||
entryTitle: "Test Article With Collection",
|
||||
serverResponse: func(w http.ResponseWriter, r *http.Request, t *testing.T, collectionID *int64) {
|
||||
// Verify authorization header
|
||||
auth := r.Header.Get("Authorization")
|
||||
if auth != "Bearer test-api-key" {
|
||||
t.Errorf("Expected Authorization header 'Bearer test-api-key', got %s", auth)
|
||||
}
|
||||
|
||||
// Parse and verify request
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
var req map[string]any
|
||||
if err := json.Unmarshal(body, &req); err != nil {
|
||||
t.Errorf("Failed to parse request body: %v", err)
|
||||
}
|
||||
|
||||
// Verify URL
|
||||
if reqURL := req["url"]; reqURL != "https://example.com/article" {
|
||||
t.Errorf("Expected URL 'https://example.com/article', got %v", reqURL)
|
||||
}
|
||||
|
||||
// Verify title/name
|
||||
if reqName := req["name"]; reqName != "Test Article With Collection" {
|
||||
t.Errorf("Expected name 'Test Article With Collection', got %v", reqName)
|
||||
}
|
||||
|
||||
// Verify collection is present and correct
|
||||
if collection, ok := req["collection"]; ok {
|
||||
collectionMap, ok := collection.(map[string]any)
|
||||
if !ok {
|
||||
t.Error("Expected collection to be a map")
|
||||
}
|
||||
if collectionID, ok := collectionMap["id"]; ok {
|
||||
// JSON numbers are float64
|
||||
if collectionIDFloat, ok := collectionID.(float64); !ok || int64(collectionIDFloat) != 42 {
|
||||
t.Errorf("Expected collection id 42, got %v", collectionID)
|
||||
}
|
||||
} else {
|
||||
t.Error("Expected collection to have 'id' field")
|
||||
}
|
||||
} else {
|
||||
t.Error("Expected collection field to be present when collectionId is set")
|
||||
}
|
||||
|
||||
// Return success response
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"id": "124",
|
||||
"url": "https://example.com/article",
|
||||
"name": "Test Article With Collection",
|
||||
})
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "missing API key",
|
||||
baseURL: "",
|
||||
apiKey: "",
|
||||
collectionID: nil,
|
||||
entryURL: "https://example.com",
|
||||
entryTitle: "Test",
|
||||
serverResponse: func(w http.ResponseWriter, r *http.Request, t *testing.T, collectionId *int64) {
|
||||
// Should not be called
|
||||
t.Error("Server should not be called when API key is missing")
|
||||
},
|
||||
wantErr: true,
|
||||
errContains: "missing base URL or API key",
|
||||
},
|
||||
{
|
||||
name: "server error",
|
||||
baseURL: "",
|
||||
apiKey: "test-api-key",
|
||||
collectionID: nil,
|
||||
entryURL: "https://example.com",
|
||||
entryTitle: "Test",
|
||||
serverResponse: func(w http.ResponseWriter, r *http.Request, t *testing.T, collectionId *int64) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
w.Write([]byte(`{"error": "Internal server error"}`))
|
||||
},
|
||||
wantErr: true,
|
||||
errContains: "unable to create link: status=500",
|
||||
},
|
||||
{
|
||||
name: "bad request with null collection id error",
|
||||
baseURL: "",
|
||||
apiKey: "test-api-key",
|
||||
collectionID: nil,
|
||||
entryURL: "https://example.com",
|
||||
entryTitle: "Test",
|
||||
serverResponse: func(w http.ResponseWriter, r *http.Request, t *testing.T, collectionId *int64) {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
w.Write([]byte(`{"response":"Error: Expected number, received null [collection, id]"}`))
|
||||
},
|
||||
wantErr: true,
|
||||
errContains: "unable to create link: status=400",
|
||||
},
|
||||
{
|
||||
name: "unauthorized",
|
||||
baseURL: "",
|
||||
apiKey: "invalid-key",
|
||||
collectionID: nil,
|
||||
entryURL: "https://example.com",
|
||||
entryTitle: "Test",
|
||||
serverResponse: func(w http.ResponseWriter, r *http.Request, t *testing.T, collectionId *int64) {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
w.Write([]byte(`{"error": "Unauthorized"}`))
|
||||
},
|
||||
wantErr: true,
|
||||
errContains: "unable to create link: status=401",
|
||||
},
|
||||
{
|
||||
name: "invalid base URL",
|
||||
baseURL: ":",
|
||||
apiKey: "test-api-key",
|
||||
collectionID: nil,
|
||||
entryURL: "https://example.com",
|
||||
entryTitle: "Test",
|
||||
serverResponse: func(w http.ResponseWriter, r *http.Request, t *testing.T, collectionId *int64) {
|
||||
// Should not be called
|
||||
t.Error("Server should not be called when base URL is invalid")
|
||||
},
|
||||
wantErr: true,
|
||||
errContains: "invalid API endpoint",
|
||||
},
|
||||
{
|
||||
name: "missing base URL",
|
||||
baseURL: "",
|
||||
apiKey: "",
|
||||
collectionID: nil,
|
||||
entryURL: "https://example.com",
|
||||
entryTitle: "Test",
|
||||
serverResponse: func(w http.ResponseWriter, r *http.Request, t *testing.T, collectionId *int64) {
|
||||
// Should not be called
|
||||
t.Error("Server should not be called when base URL is missing")
|
||||
},
|
||||
wantErr: true,
|
||||
errContains: "missing base URL or API key",
|
||||
},
|
||||
{
|
||||
name: "network connection error",
|
||||
baseURL: "http://localhost:1", // Invalid port that should fail to connect
|
||||
apiKey: "test-api-key",
|
||||
collectionID: nil,
|
||||
entryURL: "https://example.com",
|
||||
entryTitle: "Test",
|
||||
serverResponse: func(w http.ResponseWriter, r *http.Request, t *testing.T, collectionId *int64) {
|
||||
// Should not be called due to connection failure
|
||||
t.Error("Server should not be called when connection fails")
|
||||
},
|
||||
wantErr: true,
|
||||
errContains: "unable to send request",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Create test server only if we have a valid apiKey and don't have a custom baseURL for error testing
|
||||
var server *httptest.Server
|
||||
if tt.apiKey != "" && tt.baseURL != ":" && tt.baseURL != "http://localhost:1" {
|
||||
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
tt.serverResponse(w, r, t, tt.collectionID)
|
||||
}))
|
||||
defer server.Close()
|
||||
}
|
||||
|
||||
// Use test server URL if baseURL is empty and we have a server
|
||||
baseURL := tt.baseURL
|
||||
if baseURL == "" && server != nil {
|
||||
baseURL = server.URL
|
||||
}
|
||||
|
||||
// Create client
|
||||
client := NewClient(baseURL, tt.apiKey, tt.collectionID)
|
||||
|
||||
// Call CreateBookmark
|
||||
err := client.CreateBookmark(tt.entryURL, tt.entryTitle)
|
||||
|
||||
// Check error
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Error("Expected error, got nil")
|
||||
} else if tt.errContains != "" && !strings.Contains(err.Error(), tt.errContains) {
|
||||
t.Errorf("Expected error to contain '%s', got '%s'", tt.errContains, err.Error())
|
||||
}
|
||||
} else {
|
||||
if err != nil {
|
||||
t.Errorf("Expected no error, got %v", err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewClient(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
baseURL string
|
||||
apiKey string
|
||||
collectionID *int64
|
||||
}{
|
||||
{
|
||||
name: "client without collection",
|
||||
baseURL: "https://linkwarden.example.com",
|
||||
apiKey: "test-key",
|
||||
collectionID: nil,
|
||||
},
|
||||
{
|
||||
name: "client with collection",
|
||||
baseURL: "https://linkwarden.example.com",
|
||||
apiKey: "test-key",
|
||||
collectionID: model.OptionalNumber(int64(123)),
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
client := NewClient(tt.baseURL, tt.apiKey, tt.collectionID)
|
||||
|
||||
if client.baseURL != tt.baseURL {
|
||||
t.Errorf("Expected baseURL %s, got %s", tt.baseURL, client.baseURL)
|
||||
}
|
||||
|
||||
if client.apiKey != tt.apiKey {
|
||||
t.Errorf("Expected apiKey %s, got %s", tt.apiKey, client.apiKey)
|
||||
}
|
||||
|
||||
if tt.collectionID == nil {
|
||||
if client.collectionID != nil {
|
||||
t.Errorf("Expected collectionId to be nil, got %v", *client.collectionID)
|
||||
}
|
||||
} else {
|
||||
if client.collectionID == nil {
|
||||
t.Error("Expected collectionId to be set, got nil")
|
||||
} else if *client.collectionID != *tt.collectionID {
|
||||
t.Errorf("Expected collectionId %d, got %d", *tt.collectionID, *client.collectionID)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -23,8 +23,8 @@ func PushEntries(feed *model.Feed, entries model.Entries, matrixBaseURL, matrixU
|
||||
return err
|
||||
}
|
||||
|
||||
var textMessages []string
|
||||
var formattedTextMessages []string
|
||||
textMessages := make([]string, 0, len(entries))
|
||||
formattedTextMessages := make([]string, 0, len(entries))
|
||||
|
||||
for _, entry := range entries {
|
||||
textMessages = append(textMessages, fmt.Sprintf(`[%s] %s - %s`, feed.Title, entry.Title, entry.URL))
|
||||
|
||||
@@ -6,6 +6,7 @@ package notion
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
@@ -26,7 +27,7 @@ func NewClient(apiToken, pageID string) *Client {
|
||||
|
||||
func (c *Client) UpdateDocument(entryURL string, entryTitle string) error {
|
||||
if c.apiToken == "" || c.pageID == "" {
|
||||
return fmt.Errorf("notion: missing API token or page ID")
|
||||
return errors.New("notion: missing API token or page ID")
|
||||
}
|
||||
|
||||
apiEndpoint := "https://api.notion.com/v1/blocks/" + c.pageID + "/children"
|
||||
|
||||
@@ -90,7 +90,7 @@ func (c *Client) makeRequest(payload any) error {
|
||||
|
||||
// See https://docs.ntfy.sh/publish/#access-tokens
|
||||
if c.ntfyApiToken != "" {
|
||||
request.Header.Set("Authorization", fmt.Sprintf("Bearer %s", c.ntfyApiToken))
|
||||
request.Header.Set("Authorization", "Bearer "+c.ntfyApiToken)
|
||||
}
|
||||
|
||||
// See https://docs.ntfy.sh/publish/#username-password
|
||||
|
||||
@@ -6,6 +6,7 @@ package nunuxkeeper // import "miniflux.app/v2/internal/integration/nunuxkeeper"
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
@@ -27,7 +28,7 @@ func NewClient(baseURL, apiKey string) *Client {
|
||||
|
||||
func (c *Client) AddEntry(entryURL, entryTitle, entryContent string) error {
|
||||
if c.baseURL == "" || c.apiKey == "" {
|
||||
return fmt.Errorf("nunux-keeper: missing base URL or API key")
|
||||
return errors.New("nunux-keeper: missing base URL or API key")
|
||||
}
|
||||
|
||||
apiEndpoint, err := urllib.JoinBaseURLAndPath(c.baseURL, "/v2/documents")
|
||||
|
||||
@@ -14,8 +14,8 @@ import (
|
||||
"miniflux.app/v2/internal/version"
|
||||
)
|
||||
|
||||
var errPostNotFound = fmt.Errorf("pinboard: post not found")
|
||||
var errMissingCredentials = fmt.Errorf("pinboard: missing auth token")
|
||||
var errPostNotFound = errors.New("pinboard: post not found")
|
||||
var errMissingCredentials = errors.New("pinboard: missing auth token")
|
||||
|
||||
const defaultClientTimeout = 10 * time.Second
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ package pushover // import "miniflux.app/v2/internal/integration/pushover"
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
@@ -72,7 +73,7 @@ func New(user, token string, priority int, device, urlPrefix string) *Client {
|
||||
|
||||
func (c *Client) SendMessages(feed *model.Feed, entries model.Entries) error {
|
||||
if c.token == "" || c.user == "" {
|
||||
return fmt.Errorf("pushover token and user are required")
|
||||
return errors.New("pushover token and user are required")
|
||||
}
|
||||
for _, entry := range entries {
|
||||
msg := &Message{
|
||||
|
||||
@@ -6,6 +6,7 @@ package raindrop // import "miniflux.app/v2/internal/integration/raindrop"
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
@@ -29,7 +30,7 @@ func NewClient(token, collectionID, tags string) *Client {
|
||||
// https://developer.raindrop.io/v1/raindrops/single#create-raindrop
|
||||
func (c *Client) CreateRaindrop(entryURL, entryTitle string) error {
|
||||
if c.token == "" {
|
||||
return fmt.Errorf("raindrop: missing token")
|
||||
return errors.New("raindrop: missing token")
|
||||
}
|
||||
|
||||
var request *http.Request
|
||||
|
||||
@@ -6,6 +6,7 @@ package readeck // import "miniflux.app/v2/internal/integration/readeck"
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
@@ -31,7 +32,7 @@ func NewClient(baseURL, apiKey, labels string, onlyURL bool) *Client {
|
||||
|
||||
func (c *Client) CreateBookmark(entryURL, entryTitle string, entryContent string) error {
|
||||
if c.baseURL == "" || c.apiKey == "" {
|
||||
return fmt.Errorf("readeck: missing base URL or API key")
|
||||
return errors.New("readeck: missing base URL or API key")
|
||||
}
|
||||
|
||||
apiEndpoint, err := urllib.JoinBaseURLAndPath(c.baseURL, "/api/bookmarks/")
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package readeck
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCreateBookmark(t *testing.T) {
|
||||
entryURL := "https://example.com/article"
|
||||
entryTitle := "Example Title"
|
||||
entryContent := "<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)
|
||||
}
|
||||
boundaryIdx := strings.Index(ct, "boundary=")
|
||||
if boundaryIdx == -1 {
|
||||
t.Fatalf("missing multipart boundary in Content-Type: %s", ct)
|
||||
}
|
||||
boundary := ct[boundaryIdx+len("boundary="):]
|
||||
mr := multipart.NewReader(r.Body, boundary)
|
||||
|
||||
seenLabels := []string{}
|
||||
var seenURL, seenTitle, seenFeature string
|
||||
var resourceHeader map[string]any
|
||||
var resourceBody string
|
||||
|
||||
for {
|
||||
part, err := mr.NextPart()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("reading multipart: %v", err)
|
||||
}
|
||||
name := part.FormName()
|
||||
data, _ := io.ReadAll(part)
|
||||
switch name {
|
||||
case "url":
|
||||
seenURL = string(data)
|
||||
case "title":
|
||||
seenTitle = string(data)
|
||||
case "feature_find_main":
|
||||
seenFeature = string(data)
|
||||
case "labels":
|
||||
seenLabels = append(seenLabels, string(data))
|
||||
case "resource":
|
||||
// First line is JSON header, then newline, then content
|
||||
all := string(data)
|
||||
idx := strings.IndexByte(all, '\n')
|
||||
if idx == -1 {
|
||||
t.Fatalf("resource content missing header separator")
|
||||
}
|
||||
headerJSON := all[:idx]
|
||||
resourceBody = all[idx+1:]
|
||||
if err := json.Unmarshal([]byte(headerJSON), &resourceHeader); err != nil {
|
||||
t.Fatalf("invalid resource header JSON: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if seenURL != entryURL {
|
||||
t.Errorf("expected url %s, got %s", entryURL, seenURL)
|
||||
}
|
||||
if seenTitle != entryTitle {
|
||||
t.Errorf("expected title %s, got %s", entryTitle, seenTitle)
|
||||
}
|
||||
if seenFeature != "false" {
|
||||
t.Errorf("expected feature_find_main to be 'false', got %s", seenFeature)
|
||||
}
|
||||
if len(seenLabels) != 2 || seenLabels[0] != "tag1" || seenLabels[1] != "tag2" {
|
||||
t.Errorf("unexpected labels: %#v", seenLabels)
|
||||
}
|
||||
if resourceHeader == nil {
|
||||
t.Fatalf("missing resource header")
|
||||
}
|
||||
if hURL, _ := resourceHeader["url"].(string); hURL != entryURL {
|
||||
t.Errorf("expected resource header url %s, got %v", entryURL, hURL)
|
||||
}
|
||||
if headers, ok := resourceHeader["headers"].(map[string]any); ok {
|
||||
if ct, _ := headers["content-type"].(string); ct != "text/html; charset=utf-8" {
|
||||
t.Errorf("expected resource header content-type text/html; charset=utf-8, got %v", ct)
|
||||
}
|
||||
} else {
|
||||
t.Errorf("missing resource header 'headers' field")
|
||||
}
|
||||
if resourceBody != entryContent {
|
||||
t.Errorf("expected resource body %q, got %q", entryContent, resourceBody)
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "error when server returns 400",
|
||||
onlyURL: true,
|
||||
labels: labels,
|
||||
entryURL: entryURL,
|
||||
entryTitle: entryTitle,
|
||||
entryContent: entryContent,
|
||||
serverResponse: func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
},
|
||||
wantErr: true,
|
||||
errContains: "unable to create bookmark",
|
||||
},
|
||||
{
|
||||
name: "error when missing baseURL or apiKey",
|
||||
onlyURL: true,
|
||||
baseURL: "",
|
||||
apiKey: "",
|
||||
labels: labels,
|
||||
entryURL: entryURL,
|
||||
entryTitle: entryTitle,
|
||||
entryContent: entryContent,
|
||||
serverResponse: nil,
|
||||
wantErr: true,
|
||||
errContains: "missing base URL or API key",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var serverURL string
|
||||
if tt.serverResponse != nil {
|
||||
srv := httptest.NewServer(http.HandlerFunc(tt.serverResponse))
|
||||
defer srv.Close()
|
||||
serverURL = srv.URL
|
||||
}
|
||||
baseURL := tt.baseURL
|
||||
if baseURL == "" {
|
||||
baseURL = serverURL
|
||||
}
|
||||
apiKey := tt.apiKey
|
||||
if apiKey == "" {
|
||||
apiKey = "test-api-key"
|
||||
}
|
||||
|
||||
client := NewClient(baseURL, apiKey, tt.labels, tt.onlyURL)
|
||||
err := client.CreateBookmark(tt.entryURL, tt.entryTitle, tt.entryContent)
|
||||
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Fatalf("expected error, got none")
|
||||
}
|
||||
if tt.errContains != "" && !strings.Contains(err.Error(), tt.errContains) {
|
||||
t.Fatalf("expected error containing %q, got %q", tt.errContains, err.Error())
|
||||
}
|
||||
} else if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewClient(t *testing.T) {
|
||||
baseURL := "https://readeck.example.com"
|
||||
apiKey := "key"
|
||||
labels := "tag1,tag2"
|
||||
onlyURL := true
|
||||
|
||||
c := NewClient(baseURL, apiKey, labels, onlyURL)
|
||||
if c.baseURL != baseURL {
|
||||
t.Errorf("expected baseURL %s, got %s", baseURL, c.baseURL)
|
||||
}
|
||||
if c.apiKey != apiKey {
|
||||
t.Errorf("expected apiKey %s, got %s", apiKey, c.apiKey)
|
||||
}
|
||||
if c.labels != labels {
|
||||
t.Errorf("expected labels %s, got %s", labels, c.labels)
|
||||
}
|
||||
if c.onlyURL != onlyURL {
|
||||
t.Errorf("expected onlyURL %v, got %v", onlyURL, c.onlyURL)
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ package readwise // import "miniflux.app/v2/internal/integration/readwise"
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
@@ -30,7 +31,7 @@ func NewClient(apiKey string) *Client {
|
||||
|
||||
func (c *Client) CreateDocument(entryURL string) error {
|
||||
if c.apiKey == "" {
|
||||
return fmt.Errorf("readwise: missing API key")
|
||||
return errors.New("readwise: missing API key")
|
||||
}
|
||||
|
||||
requestBody, err := json.Marshal(&readwiseDocument{
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"crypto/sha512"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
@@ -30,7 +31,7 @@ func NewClient(baseURL, apiSecret string) *Client {
|
||||
|
||||
func (c *Client) CreateLink(entryURL, entryTitle string) error {
|
||||
if c.baseURL == "" || c.apiSecret == "" {
|
||||
return fmt.Errorf("shaarli: missing base URL or API secret")
|
||||
return errors.New("shaarli: missing base URL or API secret")
|
||||
}
|
||||
|
||||
apiEndpoint, err := urllib.JoinBaseURLAndPath(c.baseURL, "/api/v1/links")
|
||||
|
||||
@@ -6,6 +6,7 @@ package shiori // import "miniflux.app/v2/internal/integration/shiori"
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
@@ -28,7 +29,7 @@ func NewClient(baseURL, username, password string) *Client {
|
||||
|
||||
func (c *Client) CreateBookmark(entryURL, entryTitle string) error {
|
||||
if c.baseURL == "" || c.username == "" || c.password == "" {
|
||||
return fmt.Errorf("shiori: missing base URL, username or password")
|
||||
return errors.New("shiori: missing base URL, username or password")
|
||||
}
|
||||
|
||||
token, err := c.authenticate()
|
||||
|
||||
@@ -6,6 +6,7 @@ package wallabag // import "miniflux.app/v2/internal/integration/wallabag"
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
@@ -24,16 +25,17 @@ type Client struct {
|
||||
clientSecret string
|
||||
username string
|
||||
password string
|
||||
tags string
|
||||
onlyURL bool
|
||||
}
|
||||
|
||||
func NewClient(baseURL, clientID, clientSecret, username, password string, onlyURL bool) *Client {
|
||||
return &Client{baseURL, clientID, clientSecret, username, password, onlyURL}
|
||||
func NewClient(baseURL, clientID, clientSecret, username, password, tags string, onlyURL bool) *Client {
|
||||
return &Client{baseURL, clientID, clientSecret, username, password, tags, onlyURL}
|
||||
}
|
||||
|
||||
func (c *Client) CreateEntry(entryURL, entryTitle, entryContent string) error {
|
||||
if c.baseURL == "" || c.clientID == "" || c.clientSecret == "" || c.username == "" || c.password == "" {
|
||||
return fmt.Errorf("wallabag: missing base URL, client ID, client secret, username or password")
|
||||
return errors.New("wallabag: missing base URL, client ID, client secret, username or password")
|
||||
}
|
||||
|
||||
accessToken, err := c.getAccessToken()
|
||||
@@ -41,10 +43,10 @@ func (c *Client) CreateEntry(entryURL, entryTitle, entryContent string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
return c.createEntry(accessToken, entryURL, entryTitle, entryContent)
|
||||
return c.createEntry(accessToken, entryURL, entryTitle, entryContent, c.tags)
|
||||
}
|
||||
|
||||
func (c *Client) createEntry(accessToken, 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)
|
||||
@@ -58,6 +60,7 @@ func (c *Client) createEntry(accessToken, entryURL, entryTitle, entryContent str
|
||||
URL: entryURL,
|
||||
Title: entryTitle,
|
||||
Content: entryContent,
|
||||
Tags: tags,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("wallbag: unable to encode request body: %v", err)
|
||||
@@ -81,7 +84,7 @@ func (c *Client) createEntry(accessToken, entryURL, entryTitle, entryContent str
|
||||
defer response.Body.Close()
|
||||
|
||||
if response.StatusCode >= 400 {
|
||||
return fmt.Errorf("wallabag: unable to get access token: url=%s status=%d", apiEndpoint, response.StatusCode)
|
||||
return fmt.Errorf("wallabag: unable to get save entry: url=%s status=%d", apiEndpoint, response.StatusCode)
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -140,4 +143,5 @@ type createEntryRequest struct {
|
||||
URL string `json:"url"`
|
||||
Title string `json:"title"`
|
||||
Content string `json:"content,omitempty"`
|
||||
Tags string `json:"tags,omitempty"`
|
||||
}
|
||||
|
||||
@@ -0,0 +1,321 @@
|
||||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package wallabag
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCreateEntry(t *testing.T) {
|
||||
entryURL := "https://example.com"
|
||||
entryTitle := "title"
|
||||
entryContent := "content"
|
||||
tags := "tag1,tag2,tag3"
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
username string
|
||||
password string
|
||||
clientID string
|
||||
clientSecret string
|
||||
tags string
|
||||
onlyURL bool
|
||||
entryURL string
|
||||
entryTitle string
|
||||
entryContent string
|
||||
serverResponse func(w http.ResponseWriter, r *http.Request)
|
||||
wantErr bool
|
||||
errContains string
|
||||
}{
|
||||
{
|
||||
name: "successful entry creation with url only",
|
||||
wantErr: false,
|
||||
onlyURL: true,
|
||||
username: "username",
|
||||
password: "password",
|
||||
clientID: "clientId",
|
||||
clientSecret: "clientSecret",
|
||||
tags: tags,
|
||||
entryURL: entryURL,
|
||||
entryTitle: entryTitle,
|
||||
entryContent: entryContent,
|
||||
serverResponse: func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.Contains(r.URL.Path, "/oauth/v2/token") {
|
||||
// Return success response
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"access_token": "test-token",
|
||||
"expires_in": 3600,
|
||||
"refresh_token": "token",
|
||||
"scope": "scope",
|
||||
"token_type": "token_type",
|
||||
})
|
||||
return
|
||||
}
|
||||
// Verify authorization header
|
||||
auth := r.Header.Get("Authorization")
|
||||
if auth != "Bearer test-token" {
|
||||
t.Errorf("Expected Authorization header 'Bearer test-token', got %s", auth)
|
||||
}
|
||||
// Verify content type
|
||||
contentType := r.Header.Get("Content-Type")
|
||||
if contentType != "application/json" {
|
||||
t.Errorf("Expected Content-Type 'application/json', got %s", contentType)
|
||||
}
|
||||
// Parse and verify request
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
var req map[string]any
|
||||
if err := json.Unmarshal(body, &req); err != nil {
|
||||
t.Errorf("Failed to parse request body: %v", err)
|
||||
}
|
||||
if requstEntryURL := req["url"]; requstEntryURL != entryURL {
|
||||
t.Errorf("Expected entryURL %s, got %s", entryURL, requstEntryURL)
|
||||
}
|
||||
if requestEntryTitle := req["title"]; requestEntryTitle != entryTitle {
|
||||
t.Errorf("Expected entryTitle %s, got %s", entryTitle, requestEntryTitle)
|
||||
}
|
||||
if _, ok := req["content"]; ok {
|
||||
t.Errorf("Expected entryContent to be empty, got value")
|
||||
}
|
||||
if requestTags := req["tags"]; requestTags != tags {
|
||||
t.Errorf("Expected tags %s, got %s", tags, requestTags)
|
||||
} // Return success response
|
||||
w.WriteHeader(http.StatusOK)
|
||||
},
|
||||
errContains: "",
|
||||
},
|
||||
{
|
||||
name: "successful entry creation with content",
|
||||
wantErr: false,
|
||||
onlyURL: false,
|
||||
username: "username",
|
||||
password: "password",
|
||||
clientID: "clientId",
|
||||
clientSecret: "clientSecret",
|
||||
tags: tags,
|
||||
entryURL: entryURL,
|
||||
entryTitle: entryTitle,
|
||||
entryContent: entryContent,
|
||||
serverResponse: func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.Contains(r.URL.Path, "/oauth/v2/token") {
|
||||
// Return success response
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"access_token": "test-token",
|
||||
"expires_in": 3600,
|
||||
"refresh_token": "token",
|
||||
"scope": "scope",
|
||||
"token_type": "token_type",
|
||||
})
|
||||
return
|
||||
}
|
||||
// Verify authorization header
|
||||
auth := r.Header.Get("Authorization")
|
||||
if auth != "Bearer test-token" {
|
||||
t.Errorf("Expected Authorization header 'Bearer test-token', got %s", auth)
|
||||
}
|
||||
// Verify content type
|
||||
contentType := r.Header.Get("Content-Type")
|
||||
if contentType != "application/json" {
|
||||
t.Errorf("Expected Content-Type 'application/json', got %s", contentType)
|
||||
}
|
||||
// Parse and verify request
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
var req map[string]any
|
||||
if err := json.Unmarshal(body, &req); err != nil {
|
||||
t.Errorf("Failed to parse request body: %v", err)
|
||||
}
|
||||
if requstEntryURL := req["url"]; requstEntryURL != entryURL {
|
||||
t.Errorf("Expected entryURL %s, got %s", entryURL, requstEntryURL)
|
||||
}
|
||||
if requestEntryTitle := req["title"]; requestEntryTitle != entryTitle {
|
||||
t.Errorf("Expected entryTitle %s, got %s", entryTitle, requestEntryTitle)
|
||||
}
|
||||
if requestEntryContent := req["content"]; requestEntryContent != entryContent {
|
||||
t.Errorf("Expected entryContent %s, got %s", entryContent, requestEntryContent)
|
||||
}
|
||||
if requestTags := req["tags"]; requestTags != tags {
|
||||
t.Errorf("Expected tags %s, got %s", tags, requestTags)
|
||||
} // Return success response
|
||||
w.WriteHeader(http.StatusOK)
|
||||
},
|
||||
errContains: "",
|
||||
},
|
||||
{
|
||||
name: "failed when unable to decode accessToken response",
|
||||
wantErr: true,
|
||||
onlyURL: true,
|
||||
username: "username",
|
||||
password: "password",
|
||||
clientID: "clientId",
|
||||
clientSecret: "clientSecret",
|
||||
tags: tags,
|
||||
entryURL: entryURL,
|
||||
entryTitle: entryTitle,
|
||||
entryContent: entryContent,
|
||||
serverResponse: func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.Contains(r.URL.Path, "/oauth/v2/token") {
|
||||
// Return success response
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("invalid json"))
|
||||
return
|
||||
}
|
||||
t.Error("Server should not be called when failed to get accessToken")
|
||||
},
|
||||
errContains: "unable to decode token response",
|
||||
},
|
||||
{
|
||||
name: "failed when saving entry",
|
||||
wantErr: true,
|
||||
onlyURL: true,
|
||||
username: "username",
|
||||
password: "password",
|
||||
clientID: "clientId",
|
||||
clientSecret: "clientSecret",
|
||||
tags: tags,
|
||||
entryURL: entryURL,
|
||||
entryTitle: entryTitle,
|
||||
entryContent: entryContent,
|
||||
serverResponse: func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.Contains(r.URL.Path, "/oauth/v2/token") {
|
||||
// Return success response
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"access_token": "test-token",
|
||||
"expires_in": 3600,
|
||||
"refresh_token": "token",
|
||||
"scope": "scope",
|
||||
"token_type": "token_type",
|
||||
})
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
},
|
||||
errContains: "unable to get save entry",
|
||||
},
|
||||
{
|
||||
name: "failure due to no accessToken",
|
||||
wantErr: true,
|
||||
onlyURL: false,
|
||||
username: "username",
|
||||
password: "password",
|
||||
clientID: "clientId",
|
||||
clientSecret: "clientSecret",
|
||||
tags: tags,
|
||||
entryURL: entryURL,
|
||||
entryTitle: entryTitle,
|
||||
entryContent: entryContent,
|
||||
serverResponse: func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.Contains(r.URL.Path, "/oauth/v2/token") {
|
||||
// Return error response
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
t.Error("Server should not be called when failed to get accessToken")
|
||||
},
|
||||
errContains: "unable to get access token",
|
||||
},
|
||||
{
|
||||
name: "failure due to missing client parameters",
|
||||
wantErr: true,
|
||||
onlyURL: false,
|
||||
tags: tags,
|
||||
entryURL: entryURL,
|
||||
entryTitle: entryTitle,
|
||||
entryContent: entryContent,
|
||||
serverResponse: func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Error("Server should not be called when failed to get accessToken")
|
||||
},
|
||||
errContains: "wallabag: missing base URL, client ID, client secret, username or password",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Create test server if we have a server response function
|
||||
var serverURL string
|
||||
if tt.serverResponse != nil {
|
||||
server := httptest.NewServer(http.HandlerFunc(tt.serverResponse))
|
||||
defer server.Close()
|
||||
serverURL = server.URL
|
||||
}
|
||||
|
||||
// Create client with test server URL
|
||||
client := NewClient(serverURL, tt.clientID, tt.clientSecret, tt.username, tt.password, tt.tags, tt.onlyURL)
|
||||
|
||||
// Call CreateBookmark
|
||||
err := client.CreateEntry(tt.entryURL, tt.entryTitle, tt.entryContent)
|
||||
|
||||
// Check error expectations
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Errorf("Expected error but got none")
|
||||
} else if tt.errContains != "" && !strings.Contains(err.Error(), tt.errContains) {
|
||||
t.Errorf("Expected error containing '%s', got '%s'", tt.errContains, err.Error())
|
||||
}
|
||||
} else {
|
||||
if err != nil {
|
||||
t.Errorf("Unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewClient(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
baseURL string
|
||||
clientID string
|
||||
clientSecret string
|
||||
username string
|
||||
password string
|
||||
tags string
|
||||
onlyURL bool
|
||||
}{
|
||||
{
|
||||
name: "with all parameters",
|
||||
baseURL: "https://wallabag.example.com",
|
||||
clientID: "clientID",
|
||||
clientSecret: "clientSecret",
|
||||
username: "wallabag",
|
||||
password: "wallabag",
|
||||
tags: "",
|
||||
onlyURL: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
client := NewClient(tt.baseURL, tt.clientID, tt.clientSecret, tt.username, tt.password, tt.tags, tt.onlyURL)
|
||||
|
||||
if client.baseURL != tt.baseURL {
|
||||
t.Errorf("Expected.baseURL %s, got %s", tt.baseURL, client.baseURL)
|
||||
}
|
||||
if client.username != tt.username {
|
||||
t.Errorf("Expected username %s, got %s", tt.username, client.username)
|
||||
}
|
||||
if client.password != tt.password {
|
||||
t.Errorf("Expected password %s, got %s", tt.password, client.password)
|
||||
}
|
||||
if client.clientID != tt.clientID {
|
||||
t.Errorf("Expected clientID %s, got %s", tt.clientID, client.clientID)
|
||||
}
|
||||
if client.clientSecret != tt.clientSecret {
|
||||
t.Errorf("Expected clientSecret %s, got %s", tt.clientSecret, client.clientSecret)
|
||||
}
|
||||
if client.tags != tt.tags {
|
||||
t.Errorf("Expected tags %s, got %s", tt.tags, client.tags)
|
||||
}
|
||||
if client.onlyURL != tt.onlyURL {
|
||||
t.Errorf("Expected onlyURL %v, got %v", tt.onlyURL, client.onlyURL)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ package webhook // import "miniflux.app/v2/internal/integration/webhook"
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
@@ -72,7 +73,7 @@ func (c *Client) SendNewEntriesWebhookEvent(feed *model.Feed, entries model.Entr
|
||||
return nil
|
||||
}
|
||||
|
||||
var webhookEntries []*WebhookEntry
|
||||
webhookEntries := make([]*WebhookEntry, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
webhookEntries = append(webhookEntries, &WebhookEntry{
|
||||
ID: entry.ID,
|
||||
@@ -113,7 +114,7 @@ func (c *Client) SendNewEntriesWebhookEvent(feed *model.Feed, entries model.Entr
|
||||
|
||||
func (c *Client) makeRequest(eventType string, payload any) error {
|
||||
if c.webhookURL == "" {
|
||||
return fmt.Errorf(`webhook: missing webhook URL`)
|
||||
return errors.New(`webhook: missing webhook URL`)
|
||||
}
|
||||
|
||||
requestBody, err := json.Marshal(payload)
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
"alert.account_unlinked": "Ihr externer Account ist jetzt getrennt!",
|
||||
"alert.background_feed_refresh": "Alle Abonnements werden derzeit im Hintergrund aktualisiert. Sie können Miniflux weiterhin benutzen, während dieser Prozess ausgeführt wird.",
|
||||
"alert.feed_error": "Es gibt ein Problem mit diesem Abonnement",
|
||||
"alert.no_bookmark": "Es existiert derzeit kein Lesezeichen.",
|
||||
"alert.no_starred": "Es existieren derzeit keine markierten Artikel.",
|
||||
"alert.no_category": "Es ist keine Kategorie vorhanden.",
|
||||
"alert.no_category_entry": "Es befindet sich kein Artikel in dieser Kategorie.",
|
||||
"alert.no_feed": "Es sind keine Abonnements vorhanden.",
|
||||
@@ -46,10 +46,10 @@
|
||||
"enclosure_media_controls.speed.reset.title": "Wiedergabegeschwindigkeit auf 1x zurücksetzen",
|
||||
"enclosure_media_controls.speed.slower": "Langsamer",
|
||||
"enclosure_media_controls.speed.slower.title": "%sx langsamer",
|
||||
"entry.bookmark.toast.off": "Nicht markiert",
|
||||
"entry.bookmark.toast.on": "Markiert",
|
||||
"entry.bookmark.toggle.off": "Lesezeichen entfernen",
|
||||
"entry.bookmark.toggle.on": "Lesezeichen hinzufügen",
|
||||
"entry.starred.toast.off": "Nicht markiert",
|
||||
"entry.starred.toast.on": "Markiert",
|
||||
"entry.starred.toggle.off": "Markierung entfernen",
|
||||
"entry.starred.toggle.on": "Markierung hinzufügen",
|
||||
"entry.comments.label": "Kommentare",
|
||||
"entry.comments.title": "Kommentare anzeigen",
|
||||
"entry.estimated_reading_time": [
|
||||
@@ -92,7 +92,7 @@
|
||||
"error.duplicate_linked_account": "Es ist bereits jemand mit diesem Anbieter assoziiert!",
|
||||
"error.duplicated_feed": "Dieses Abonnement existiert bereits.",
|
||||
"error.empty_file": "Diese Datei ist leer.",
|
||||
"error.entries_per_page_invalid": "Die Anzahl der Einträge pro Seite ist ungültig.",
|
||||
"error.entries_per_page_invalid": "Die Anzahl der Artikel pro Seite ist ungültig.",
|
||||
"error.feed_already_exists": "Dieser Feed existiert bereits.",
|
||||
"error.feed_category_not_found": "Diese Kategorie existiert nicht oder gehört nicht zu diesem Benutzer.",
|
||||
"error.feed_format_not_detected": "Das Format des Abonnements kann nicht erkannt werden: %v.",
|
||||
@@ -160,8 +160,9 @@
|
||||
"error.unlink_account_without_password": "Sie müssen ein Passwort festlegen, sonst können Sie sich nicht erneut anmelden.",
|
||||
"error.user_already_exists": "Dieser Benutzer existiert bereits.",
|
||||
"error.user_mandatory_fields": "Der Benutzername ist obligatorisch.",
|
||||
"error.linktaco_missing_required_fields": "LinkTaco API Token und Organization Slug sind erforderlich.",
|
||||
"form.api_key.label.description": "API-Schlüsselbezeichnung",
|
||||
"form.category.hide_globally": "Einträge in der globalen Ungelesen-Liste ausblenden",
|
||||
"form.category.hide_globally": "Artikel in der globalen Ungelesen-Liste ausblenden",
|
||||
"form.category.label.title": "Titel",
|
||||
"form.feed.fieldset.general": "Allgemein",
|
||||
"form.feed.fieldset.integration": "Drittanbieter-Dienste",
|
||||
@@ -181,12 +182,12 @@
|
||||
"form.feed.label.feed_url": "URL des Abonnements",
|
||||
"form.feed.label.feed_username": "Benutzername des Abonnements",
|
||||
"form.feed.label.fetch_via_proxy": "Den auf Anwendungsebene konfigurierten Proxy verwenden",
|
||||
"form.feed.label.hide_globally": "Einträge in der globalen Ungelesen-Liste ausblenden",
|
||||
"form.feed.label.hide_globally": "Artikel in der globalen Ungelesen-Liste ausblenden",
|
||||
"form.feed.label.ignore_http_cache": "Ignoriere HTTP-Cache",
|
||||
"form.feed.label.keep_filter_entry_rules": "Eintrags-Erlaubnisregeln",
|
||||
"form.feed.label.keeplist_rules": "Regex-basierte Behalte-Filter",
|
||||
"form.feed.label.no_media_player": "Kein Media-Player (Audio/Video)",
|
||||
"form.feed.label.ntfy_activate": "Einträge zu ntfy pushen",
|
||||
"form.feed.label.ntfy_activate": "Artikel zu ntfy pushen",
|
||||
"form.feed.label.ntfy_default_priority": "Normale Ntfy-Priorität",
|
||||
"form.feed.label.ntfy_high_priority": "Hohe Ntfy-Priorität",
|
||||
"form.feed.label.ntfy_low_priority": "Niedrige Ntfy-Priorität",
|
||||
@@ -195,7 +196,7 @@
|
||||
"form.feed.label.ntfy_priority": "Ntfy-Priorität",
|
||||
"form.feed.label.ntfy_topic": "Ntfy-Thema (optional)",
|
||||
"form.feed.label.proxy_url": "Proxy-URL",
|
||||
"form.feed.label.pushover_activate": "Einträge an pushover.net senden",
|
||||
"form.feed.label.pushover_activate": "Artikel an pushover.net senden",
|
||||
"form.feed.label.pushover_default_priority": "Pushover-Standardpriorität",
|
||||
"form.feed.label.pushover_high_priority": "Hohe Pushoverpriorität",
|
||||
"form.feed.label.pushover_low_priority": "Niedrige Pushoverpriorität",
|
||||
@@ -211,17 +212,18 @@
|
||||
"form.feed.label.webhook_url": "Webhook-URL überschreiben",
|
||||
"form.import.label.file": "OPML-Datei",
|
||||
"form.import.label.url": "URL",
|
||||
"form.integration.apprise_activate": "Einträge zu Apprise pushen",
|
||||
"form.integration.archiveorg_activate": "Artikel zu archive.org pushen",
|
||||
"form.integration.apprise_activate": "Artikel zu Apprise pushen",
|
||||
"form.integration.apprise_services_url": "Kommaseparierte Liste von Apprise-Dienst-URLs",
|
||||
"form.integration.apprise_url": "Apprise-API-URL",
|
||||
"form.integration.betula_activate": "Einträge in Betula speichern",
|
||||
"form.integration.betula_activate": "Artikel in Betula speichern",
|
||||
"form.integration.betula_token": "Betula-Token",
|
||||
"form.integration.betula_url": "Betula-Server-URL",
|
||||
"form.integration.cubox_activate": "Einträge in Cubox speichern",
|
||||
"form.integration.cubox_activate": "Artikel in Cubox speichern",
|
||||
"form.integration.cubox_api_link": "Cubox-API-Link",
|
||||
"form.integration.discord_activate": "Einträge zu Discord pushen",
|
||||
"form.integration.discord_activate": "Artikel zu Discord pushen",
|
||||
"form.integration.discord_webhook_link": "Discord-Webhook-URL",
|
||||
"form.integration.espial_activate": "Einträge in Espial speichern",
|
||||
"form.integration.espial_activate": "Artikel in Espial speichern",
|
||||
"form.integration.espial_api_key": "Espial-API-Schlüssel",
|
||||
"form.integration.espial_endpoint": "Espial-API-Endpunkt",
|
||||
"form.integration.espial_tags": "Espial-Tags",
|
||||
@@ -233,13 +235,14 @@
|
||||
"form.integration.googlereader_endpoint": "Google-Reader-API-Endpunkt:",
|
||||
"form.integration.googlereader_password": "Google-Reader-Passwort",
|
||||
"form.integration.googlereader_username": "Google-Reader-Benutzername",
|
||||
"form.integration.instapaper_activate": "Einträge in Instapaper speichern",
|
||||
"form.integration.instapaper_activate": "Artikel in Instapaper speichern",
|
||||
"form.integration.instapaper_password": "Instapaper-Passwort",
|
||||
"form.integration.instapaper_username": "Instapaper-Benutzername",
|
||||
"form.integration.karakeep_activate": "Einträge in Karakeep speichern",
|
||||
"form.integration.karakeep_activate": "Artikel in Karakeep speichern",
|
||||
"form.integration.karakeep_api_key": "Karakeep-API-Schlüssel",
|
||||
"form.integration.karakeep_url": "Karakeep-API-Endpunkt",
|
||||
"form.integration.linkace_activate": "Einträge in LinkAce speichern",
|
||||
"form.integration.karakeep_tags": "Karakeep-Tags",
|
||||
"form.integration.linkace_activate": "Artikel in LinkAce speichern",
|
||||
"form.integration.linkace_api_key": "LinkAce-API-Schlüssel",
|
||||
"form.integration.linkace_check_disabled": "Linkprüfung deaktivieren",
|
||||
"form.integration.linkace_endpoint": "LinkAce-API-Endpunkt",
|
||||
@@ -250,18 +253,29 @@
|
||||
"form.integration.linkding_bookmark": "Lesezeichen als ungelesen markieren",
|
||||
"form.integration.linkding_endpoint": "Linkding-API-Endpunkt",
|
||||
"form.integration.linkding_tags": "Linkding-Tags",
|
||||
"form.integration.linktaco_activate": "Artikel in LinkTaco speichern",
|
||||
"form.integration.linktaco_api_token": "LinkTaco-API-Token",
|
||||
"form.integration.linktaco_api_token_hint": "Holen Sie sich Ihr persönliches Zugriffstoken unter",
|
||||
"form.integration.linktaco_org_slug": "Organisationstitel",
|
||||
"form.integration.linktaco_tags": "Tags (max. 10, kommagetrennt)",
|
||||
"form.integration.linktaco_tags_hint": "Maximal 10 Tags, kommagetrennt",
|
||||
"form.integration.linktaco_visibility": "Sichtbarkeit",
|
||||
"form.integration.linktaco_visibility_public": "Öffentlich",
|
||||
"form.integration.linktaco_visibility_private": "Privat",
|
||||
"form.integration.linktaco_visibility_hint": "PRIVATE Sichtbarkeit erfordert ein kostenpflichtiges LinkTaco-Konto",
|
||||
"form.integration.linkwarden_activate": "Artikel in Linkwarden speichern",
|
||||
"form.integration.linkwarden_api_key": "Linkwarden-API-Schlüssel",
|
||||
"form.integration.linkwarden_endpoint": "Linkwarden-Base-URL",
|
||||
"form.integration.linkwarden_collection_id": "Linkwarden-Sammlungs-ID",
|
||||
"form.integration.matrix_bot_activate": "Neue Artikel in Matrix übertragen",
|
||||
"form.integration.matrix_bot_chat_id": "ID des Matrix-Raums",
|
||||
"form.integration.matrix_bot_password": "Passwort für Matrix-Benutzer",
|
||||
"form.integration.matrix_bot_url": "URL des Matrix-Servers",
|
||||
"form.integration.matrix_bot_user": "Benutzername für Matrix",
|
||||
"form.integration.notion_activate": "Einträge in Notion speichern",
|
||||
"form.integration.notion_activate": "Artikel in Notion speichern",
|
||||
"form.integration.notion_page_id": "Notion-Page-ID",
|
||||
"form.integration.notion_token": "Notion-Geheimnis-Token",
|
||||
"form.integration.ntfy_activate": "Einträge zu ntfy pushen",
|
||||
"form.integration.ntfy_activate": "Artikel zu ntfy pushen",
|
||||
"form.integration.ntfy_api_token": "Ntfy-API-Token (optional)",
|
||||
"form.integration.ntfy_icon_url": "Ntfy-Symbol-URL (optional)",
|
||||
"form.integration.ntfy_internal_links": "Interne Links beim Klicken verwenden (optional)",
|
||||
@@ -272,19 +286,19 @@
|
||||
"form.integration.nunux_keeper_activate": "Artikel in Nunux Keeper speichern",
|
||||
"form.integration.nunux_keeper_api_key": "Nunux-Keeper-API-Schlüssel",
|
||||
"form.integration.nunux_keeper_endpoint": "Nunux-Keeper-API-Endpunkt",
|
||||
"form.integration.omnivore_activate": "Einträge in Omnivore speichern",
|
||||
"form.integration.omnivore_activate": "Artikel in Omnivore speichern",
|
||||
"form.integration.omnivore_api_key": "Omnivore-API-Schlüssel",
|
||||
"form.integration.omnivore_url": "Omnivore-API-Endpunkt",
|
||||
"form.integration.pinboard_activate": "Einträge in Pinboard speichern",
|
||||
"form.integration.pinboard_activate": "Artikel in Pinboard speichern",
|
||||
"form.integration.pinboard_bookmark": "Lesezeichen als ungelesen markieren",
|
||||
"form.integration.pinboard_tags": "Pinboard-Tags",
|
||||
"form.integration.pinboard_token": "Pinboard-API-Token",
|
||||
"form.integration.pushover_activate": "Einträge an Pushover senden",
|
||||
"form.integration.pushover_activate": "Artikel an Pushover senden",
|
||||
"form.integration.pushover_device": "Pushovergerät (optional)",
|
||||
"form.integration.pushover_prefix": "Pushover-URL-Präfix (optional)",
|
||||
"form.integration.pushover_token": "Pushover-Anwendungs-API-Token",
|
||||
"form.integration.pushover_user": "Pushover-Benutzerschlüssel",
|
||||
"form.integration.raindrop_activate": "Einträge in Raindrop speichern",
|
||||
"form.integration.raindrop_activate": "Artikel in Raindrop speichern",
|
||||
"form.integration.raindrop_collection_id": "Sammlungs-ID",
|
||||
"form.integration.raindrop_tags": "Tags (kommagetrennt)",
|
||||
"form.integration.raindrop_token": "(Test-)Token",
|
||||
@@ -293,7 +307,8 @@
|
||||
"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.readwise_activate": "Einträge in Readwise Reader speichern",
|
||||
"form.integration.readeck_push_activate": "Neue Artikel automatisch in Readeck speichern",
|
||||
"form.integration.readwise_activate": "Artikel in Readwise Reader speichern",
|
||||
"form.integration.readwise_api_key": "Readwise-Reader-Zugangstoken",
|
||||
"form.integration.readwise_api_key_link": "Erhalten Sie Ihren Readwise-Zugangstoken",
|
||||
"form.integration.rssbridge_activate": "Beim Hinzufügen von Abonnements RSS-Bridge prüfen.",
|
||||
@@ -306,7 +321,7 @@
|
||||
"form.integration.shiori_endpoint": "Shiori-API-Endpunkt",
|
||||
"form.integration.shiori_password": "Shiori-Passwort",
|
||||
"form.integration.shiori_username": "Shiori-Benutzername",
|
||||
"form.integration.slack_activate": "Einträge zu Slack pushen",
|
||||
"form.integration.slack_activate": "Artikel zu Slack pushen",
|
||||
"form.integration.slack_webhook_link": "Slack-Webhook-URL",
|
||||
"form.integration.telegram_bot_activate": "Schicken Sie neue Artikel in den Telegram-Chat",
|
||||
"form.integration.telegram_bot_disable_buttons": "Schaltfächen deaktivieren",
|
||||
@@ -315,13 +330,14 @@
|
||||
"form.integration.telegram_bot_token": "Bot-Token",
|
||||
"form.integration.telegram_chat_id": "Chat-ID",
|
||||
"form.integration.telegram_topic_id": "Thema-ID",
|
||||
"form.integration.wallabag_activate": "Einträge in Wallabag speichern",
|
||||
"form.integration.wallabag_activate": "Artikel in Wallabag speichern",
|
||||
"form.integration.wallabag_client_id": "Wallabag-Client-ID",
|
||||
"form.integration.wallabag_client_secret": "Wallabag-Client-Geheimnis",
|
||||
"form.integration.wallabag_endpoint": "Wallabag-Basis-URL",
|
||||
"form.integration.wallabag_only_url": "Nur URL senden (anstelle des vollständigen Inhalts)",
|
||||
"form.integration.wallabag_password": "Wallabag-Passwort",
|
||||
"form.integration.wallabag_username": "Wallabag-Benutzername",
|
||||
"form.integration.wallabag_tags": "Wallabag-Tags",
|
||||
"form.integration.webhook_activate": "Webhooks aktivieren",
|
||||
"form.integration.webhook_secret": "Webhook-Geheimnis",
|
||||
"form.integration.webhook_url": "Standard-Webhook-URL",
|
||||
@@ -338,32 +354,32 @@
|
||||
"form.prefs.label.default_home_page": "Standard-Startseite",
|
||||
"form.prefs.label.default_reading_speed": "Lesegeschwindigkeit für andere Sprachen (Wörter pro Minute)",
|
||||
"form.prefs.label.display_mode": "Anzeigemodus der progressiven Web-Anwendung (PWA)",
|
||||
"form.prefs.label.entries_per_page": "Einträge pro Seite",
|
||||
"form.prefs.label.entries_per_page": "Artikel pro Seite",
|
||||
"form.prefs.label.entry_order": "Artikel-Sortierspalte",
|
||||
"form.prefs.label.entry_sorting": "Sortierung der Einträge",
|
||||
"form.prefs.label.entry_swipe": "Aktivieren Sie das Wischen von Einträgen auf Touchscreens",
|
||||
"form.prefs.label.entry_sorting": "Sortierung der Artikel",
|
||||
"form.prefs.label.entry_swipe": "Aktivieren Sie das Wischen von Artikeln auf Touchscreens",
|
||||
"form.prefs.label.external_font_hosts": "Externe Schriftarten-Hosts",
|
||||
"form.prefs.label.gesture_nav": "Geste zum Navigieren zwischen Einträgen",
|
||||
"form.prefs.label.gesture_nav": "Geste zum Navigieren zwischen Artikeln",
|
||||
"form.prefs.label.keyboard_shortcuts": "Tastaturkürzel aktivieren",
|
||||
"form.prefs.label.language": "Sprache",
|
||||
"form.prefs.label.mark_read_manually": "Einträge manuell als gelesen markieren",
|
||||
"form.prefs.label.mark_read_manually": "Artikel manuell als gelesen markieren",
|
||||
"form.prefs.label.mark_read_on_media_completion": "Nur als gelesen markieren, wenn Audio/Video zu 90%% wiedergegeben wurden",
|
||||
"form.prefs.label.mark_read_on_view": "Einträge automatisch als gelesen markieren, wenn sie angezeigt werden",
|
||||
"form.prefs.label.mark_read_on_view_or_media_completion": "Einträge automatisch als gelesen markieren, wenn sie angezeigt werden. Audio/Video bei 90%% Wiedergabe als gelesen markieren",
|
||||
"form.prefs.label.mark_read_on_view": "Artikel automatisch als gelesen markieren, wenn sie angezeigt werden",
|
||||
"form.prefs.label.mark_read_on_view_or_media_completion": "Artikel automatisch als gelesen markieren, wenn sie angezeigt werden. Audio/Video bei 90%% Wiedergabe als gelesen markieren",
|
||||
"form.prefs.label.media_playback_rate": "Wiedergabegeschwindigkeit von Audio/Video",
|
||||
"form.prefs.label.open_external_links_in_new_tab": "Externe Links in einem neuen Tab öffnen (fügt target=\"_blank\" zu Links hinzu)",
|
||||
"form.prefs.label.show_reading_time": "Geschätzte Lesezeit für Artikel anzeigen",
|
||||
"form.prefs.label.theme": "Thema",
|
||||
"form.prefs.label.timezone": "Zeitzone",
|
||||
"form.prefs.select.alphabetical": "Alphabetisch",
|
||||
"form.prefs.select.browser": "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 Einträge zuerst",
|
||||
"form.prefs.select.older_first": "Ältere Artikel zuerst",
|
||||
"form.prefs.select.publish_time": "Artikel veröffentlicht am",
|
||||
"form.prefs.select.recent_first": "Neue Einträge zuerst",
|
||||
"form.prefs.select.recent_first": "Neue Artikel zuerst",
|
||||
"form.prefs.select.standalone": "Eigenständige",
|
||||
"form.prefs.select.swipe": "Wischen",
|
||||
"form.prefs.select.tap": "Doppeltippen",
|
||||
@@ -404,7 +420,7 @@
|
||||
"menu.show_all_entries": "Zeige alle Artikel",
|
||||
"menu.show_only_starred_entries": "Nur markierte Artikel anzeigen",
|
||||
"menu.show_only_unread_entries": "Nur ungelesene Artikel anzeigen",
|
||||
"menu.starred": "Lesezeichen",
|
||||
"menu.starred": "Markiert",
|
||||
"menu.title": "Menü",
|
||||
"menu.unread": "Ungelesen",
|
||||
"menu.users": "Benutzer",
|
||||
@@ -462,6 +478,7 @@
|
||||
"page.feeds.next_check": "Nächste Aktualisierung:",
|
||||
"page.feeds.read_counter": "Anzahl der gelesenen Artikel",
|
||||
"page.feeds.title": "Abonnements",
|
||||
"page.footer.elevator": "Zurück nach oben",
|
||||
"page.history.title": "Verlauf",
|
||||
"page.import.title": "Importieren",
|
||||
"page.integration.bookmarklet": "Bookmarklet",
|
||||
@@ -487,7 +504,7 @@
|
||||
"page.keyboard_shortcuts.go_to_previous_page": "Zur vorherigen Seite gehen",
|
||||
"page.keyboard_shortcuts.go_to_search": "Fokus auf das Suchformular setzen",
|
||||
"page.keyboard_shortcuts.go_to_settings": "Zu den Einstellungen gehen",
|
||||
"page.keyboard_shortcuts.go_to_starred": "Zu den Lesezeichen gehen",
|
||||
"page.keyboard_shortcuts.go_to_starred": "Zu den markierten Artikeln gehen",
|
||||
"page.keyboard_shortcuts.go_to_top_item": "Zum obersten Artikel gehen",
|
||||
"page.keyboard_shortcuts.go_to_unread": "Zu den ungelesenen Artikeln gehen",
|
||||
"page.keyboard_shortcuts.mark_page_as_read": "Aktuelle Seite als gelesen markieren",
|
||||
@@ -506,7 +523,7 @@
|
||||
"page.keyboard_shortcuts.subtitle.pages": "Navigation zwischen den Seiten",
|
||||
"page.keyboard_shortcuts.subtitle.sections": "Navigation zwischen den Menüpunkten",
|
||||
"page.keyboard_shortcuts.title": "Tastenkürzel",
|
||||
"page.keyboard_shortcuts.toggle_bookmark_status": "Lesezeichen hinzufügen/entfernen",
|
||||
"page.keyboard_shortcuts.toggle_star_status": "Markierung hinzufügen/entfernen",
|
||||
"page.keyboard_shortcuts.toggle_entry_attachments": "Artikelanhänge öffnen/schließen",
|
||||
"page.keyboard_shortcuts.toggle_read_status_next": "Gewählten Artikel als gelesen/ungelesen markieren, nächsten auswählen",
|
||||
"page.keyboard_shortcuts.toggle_read_status_prev": "Gewählten Artikel als gelesen/ungelesen markieren, vorherigen auswählen",
|
||||
@@ -554,10 +571,10 @@
|
||||
"%d geteilter Artikel",
|
||||
"%d geteilte Artikel"
|
||||
],
|
||||
"page.starred.title": "Lesezeichen",
|
||||
"page.starred.title": "Markiert",
|
||||
"page.starred_entry_count": [
|
||||
"%d Lesezeichen",
|
||||
"%d Lesezeichen"
|
||||
"%d markierter Artikel",
|
||||
"%d markierte Artikel"
|
||||
],
|
||||
"page.total_entry_count": [
|
||||
"%d Artikel insgesamt",
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
"alert.account_unlinked": "Ο εξωτερικός σας λογαριασμός είναι πλέον αποσυνδεδεμένος!",
|
||||
"alert.background_feed_refresh": "Όλες οι ροές ανανεώνονται στο παρασκήνιο. Μπορείτε να συνεχίσετε να χρησιμοποιείτε το Miniflux όσο εκτελείται αυτή η διαδικασία.",
|
||||
"alert.feed_error": "Υπάρχει πρόβλημα με αυτήν τη ροή",
|
||||
"alert.no_bookmark": "Δεν υπάρχει σελιδοδείκτης αυτή τη στιγμή.",
|
||||
"alert.no_starred": "Δεν υπάρχει σελιδοδείκτης αυτή τη στιγμή.",
|
||||
"alert.no_category": "Δεν υπάρχει κατηγορία.",
|
||||
"alert.no_category_entry": "Δεν υπάρχουν άρθρα σε αυτήν την κατηγορία.",
|
||||
"alert.no_feed": "Δεν έχετε συνδρομές.",
|
||||
@@ -46,10 +46,10 @@
|
||||
"enclosure_media_controls.speed.reset.title": "Επαναφορά ταχύτητας σε 1x",
|
||||
"enclosure_media_controls.speed.slower": "Πιο αργά",
|
||||
"enclosure_media_controls.speed.slower.title": "Πιο αργά κατά %sx",
|
||||
"entry.bookmark.toast.off": "Μη αγαπημένα",
|
||||
"entry.bookmark.toast.on": "Αγαπημένα",
|
||||
"entry.bookmark.toggle.off": "Αναίρεση αγαπημένου",
|
||||
"entry.bookmark.toggle.on": "Αγαπημένο",
|
||||
"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": [
|
||||
@@ -160,6 +160,7 @@
|
||||
"error.unlink_account_without_password": "Πρέπει να ορίσετε έναν κωδικό πρόσβασης διαφορετικά δεν θα μπορείτε να συνδεθείτε ξανά.",
|
||||
"error.user_already_exists": "Αυτός ο χρήστης υπάρχει ήδη.",
|
||||
"error.user_mandatory_fields": "Το όνομα χρήστη είναι υποχρεωτικό.",
|
||||
"error.linktaco_missing_required_fields": "Το LinkTaco API Token και το Organization Slug είναι απαραίτητα",
|
||||
"form.api_key.label.description": "Ετικέτα κλειδιού API",
|
||||
"form.category.hide_globally": "Απόκρυψη καταχωρήσεων σε γενική λίστα μη αναγνωσμένων",
|
||||
"form.category.label.title": "Τίτλος",
|
||||
@@ -211,6 +212,7 @@
|
||||
"form.feed.label.webhook_url": "Παράκαμψη διεύθυνσης URL webhook",
|
||||
"form.import.label.file": "Αρχείο OPML",
|
||||
"form.import.label.url": "Διεύθυνση URL",
|
||||
"form.integration.archiveorg_activate": "Προώθηση καταχωρήσεων στο archive.org",
|
||||
"form.integration.apprise_activate": "Προώθηση καταχωρήσεων στο Apprise",
|
||||
"form.integration.apprise_services_url": "Λίστα διευθύνσεων URL υπηρεσιών Apprise διαχωρισμένων με κόμμα",
|
||||
"form.integration.apprise_url": "Διεύθυνση URL API Apprise",
|
||||
@@ -239,6 +241,7 @@
|
||||
"form.integration.karakeep_activate": "Αποθήκευση άρθρων στο Karakeep",
|
||||
"form.integration.karakeep_api_key": "Κλειδί API Karakeep",
|
||||
"form.integration.karakeep_url": "Τελικό σημείο Karakeep API",
|
||||
"form.integration.karakeep_tags": "Ετικέτες Karakeep",
|
||||
"form.integration.linkace_activate": "Αποθήκευση καταχωρήσεων στο LinkAce",
|
||||
"form.integration.linkace_api_key": "Κλειδί API LinkAce",
|
||||
"form.integration.linkace_check_disabled": "Απενεργοποίηση ελέγχου συνδέσμου",
|
||||
@@ -250,9 +253,20 @@
|
||||
"form.integration.linkding_bookmark": "Σημείωση του σελιδοδείκτη ως μη αναγνωσμένου",
|
||||
"form.integration.linkding_endpoint": "Τελικό σημείο Linkding API",
|
||||
"form.integration.linkding_tags": "Ετικέτες Linkding",
|
||||
"form.integration.linktaco_activate": "Αποθήκευση καταχωρήσεων στο LinkTaco",
|
||||
"form.integration.linktaco_api_token": "Διακριτικό API LinkTaco",
|
||||
"form.integration.linktaco_api_token_hint": "Λάβετε το προσωπικό σας διακριτικό πρόσβασης στο",
|
||||
"form.integration.linktaco_org_slug": "Σύντομο όνομα οργανισμού",
|
||||
"form.integration.linktaco_tags": "Ετικέτες (μέγιστο 10, διαχωρισμένες με κόμμα)",
|
||||
"form.integration.linktaco_tags_hint": "Μέγιστο 10 ετικέτες, διαχωρισμένες με κόμμα",
|
||||
"form.integration.linktaco_visibility": "Ορατότητα",
|
||||
"form.integration.linktaco_visibility_public": "Δημόσια",
|
||||
"form.integration.linktaco_visibility_private": "Ιδιωτική",
|
||||
"form.integration.linktaco_visibility_hint": "Η ΙΔΙΩΤΙΚΗ ορατότητα απαιτεί επί πληρωμή λογαριασμό LinkTaco",
|
||||
"form.integration.linkwarden_activate": "Αποθήκευση άρθρων στο Linkwarden",
|
||||
"form.integration.linkwarden_api_key": "Κλειδί API Linkwarden",
|
||||
"form.integration.linkwarden_endpoint": "URL βάσης Linkwarden",
|
||||
"form.integration.linkwarden_collection_id": "ID συλλογής Linkwarden",
|
||||
"form.integration.matrix_bot_activate": "Μεταφορά νέων άρθρων στο Matrix",
|
||||
"form.integration.matrix_bot_chat_id": "Αναγνωριστικό της αίθουσας Matrix",
|
||||
"form.integration.matrix_bot_password": "Κωδικός πρόσβασης για τον χρήστη Matrix",
|
||||
@@ -293,6 +307,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",
|
||||
@@ -322,6 +337,7 @@
|
||||
"form.integration.wallabag_only_url": "Αποστολή μόνο URL (αντί για πλήρες περιεχόμενο)",
|
||||
"form.integration.wallabag_password": "Wallabag Κωδικός Πρόσβασης",
|
||||
"form.integration.wallabag_username": "Όνομα Χρήστη Wallabag",
|
||||
"form.integration.wallabag_tags": "Ετικέτες Wallabag",
|
||||
"form.integration.webhook_activate": "Ενεργοποίηση Webhooks",
|
||||
"form.integration.webhook_secret": "Μυστικό Webhooks",
|
||||
"form.integration.webhook_url": "Προεπιλεγμένη διεύθυνση URL Webhook",
|
||||
@@ -420,7 +436,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": "Βρείτε μια συνδρομή",
|
||||
@@ -430,7 +446,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": [
|
||||
@@ -462,13 +478,14 @@
|
||||
"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": "Σελιδοδείκτης (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": "Ο κωδικός πρόσβασης του λογαριασμού σας",
|
||||
@@ -506,7 +523,7 @@
|
||||
"page.keyboard_shortcuts.subtitle.pages": "Πλοήγηση Σελίδων",
|
||||
"page.keyboard_shortcuts.subtitle.sections": "Πλοήγηση Τμημάτων",
|
||||
"page.keyboard_shortcuts.title": "Συντομεύσεις Πληκτρολογίου",
|
||||
"page.keyboard_shortcuts.toggle_bookmark_status": "Εναλλαγή σελιδοδείκτη",
|
||||
"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": "Εναλλαγή ανάγνωσης / μη αναγνωσμένης, εστίαση στο προηγούμενο",
|
||||
@@ -531,7 +548,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",
|
||||
@@ -614,4 +631,4 @@
|
||||
"time_elapsed.yesterday": "χθες",
|
||||
"tooltip.keyboard_shortcuts": "Συντόμευση πληκτρολογίου: % s",
|
||||
"tooltip.logged_user": "Συνδεδεμένος/η ως %s"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
"alert.account_unlinked": "Your external account is now dissociated!",
|
||||
"alert.background_feed_refresh": "All feeds are being refreshed in the background. You can continue to use Miniflux while this process is running.",
|
||||
"alert.feed_error": "There is a problem with this feed",
|
||||
"alert.no_bookmark": "There are no starred entries.",
|
||||
"alert.no_starred": "There are no starred entries.",
|
||||
"alert.no_category": "There is no category.",
|
||||
"alert.no_category_entry": "There are no entries in this category.",
|
||||
"alert.no_feed": "You don’t have any feeds.",
|
||||
@@ -46,10 +46,10 @@
|
||||
"enclosure_media_controls.speed.reset.title": "Reset speed to 1x",
|
||||
"enclosure_media_controls.speed.slower": "Slower",
|
||||
"enclosure_media_controls.speed.slower.title": "Slower by %sx",
|
||||
"entry.bookmark.toast.off": "Unstarred",
|
||||
"entry.bookmark.toast.on": "Starred",
|
||||
"entry.bookmark.toggle.off": "Unstar",
|
||||
"entry.bookmark.toggle.on": "Star",
|
||||
"entry.starred.toast.off": "Unstarred",
|
||||
"entry.starred.toast.on": "Starred",
|
||||
"entry.starred.toggle.off": "Unstar",
|
||||
"entry.starred.toggle.on": "Star",
|
||||
"entry.comments.label": "Comments",
|
||||
"entry.comments.title": "View Comments",
|
||||
"entry.estimated_reading_time": [
|
||||
@@ -89,6 +89,7 @@
|
||||
"error.different_passwords": "Passwords are not the same.",
|
||||
"error.duplicate_fever_username": "There is already someone else with the same Fever username!",
|
||||
"error.duplicate_googlereader_username": "There is already someone else with the same Google Reader username!",
|
||||
"error.linktaco_missing_required_fields": "LinkTaco API Token and Organization Slug are required",
|
||||
"error.duplicate_linked_account": "There is already someone associated with this provider!",
|
||||
"error.duplicated_feed": "This feed already exists.",
|
||||
"error.empty_file": "This file is empty.",
|
||||
@@ -211,6 +212,7 @@
|
||||
"form.feed.label.webhook_url": "Override webhook url",
|
||||
"form.import.label.file": "OPML file",
|
||||
"form.import.label.url": "URL",
|
||||
"form.integration.archiveorg_activate": "Push entries to archive.org",
|
||||
"form.integration.apprise_activate": "Push entries to Apprise",
|
||||
"form.integration.apprise_services_url": "Comma separated list of Apprise service URLs",
|
||||
"form.integration.apprise_url": "Apprise API URL",
|
||||
@@ -239,6 +241,7 @@
|
||||
"form.integration.karakeep_activate": "Save entries to Karakeep",
|
||||
"form.integration.karakeep_api_key": "Karakeep API key",
|
||||
"form.integration.karakeep_url": "Karakeep API Endpoint",
|
||||
"form.integration.karakeep_tags": "Karakeep Tags",
|
||||
"form.integration.linkace_activate": "Save entries to LinkAce",
|
||||
"form.integration.linkace_api_key": "LinkAce API key",
|
||||
"form.integration.linkace_check_disabled": "Disable link check",
|
||||
@@ -250,9 +253,20 @@
|
||||
"form.integration.linkding_bookmark": "Mark bookmark as unread",
|
||||
"form.integration.linkding_endpoint": "Linkding API Endpoint",
|
||||
"form.integration.linkding_tags": "Linkding Tags",
|
||||
"form.integration.linktaco_activate": "Save entries to LinkTaco",
|
||||
"form.integration.linktaco_api_token": "LinkTaco API Token",
|
||||
"form.integration.linktaco_api_token_hint": "Get your personal access token at",
|
||||
"form.integration.linktaco_org_slug": "Organization Slug",
|
||||
"form.integration.linktaco_tags": "Tags (max 10, comma-separated)",
|
||||
"form.integration.linktaco_tags_hint": "Maximum 10 tags, comma-separated",
|
||||
"form.integration.linktaco_visibility": "Visibility",
|
||||
"form.integration.linktaco_visibility_public": "Public",
|
||||
"form.integration.linktaco_visibility_private": "Private",
|
||||
"form.integration.linktaco_visibility_hint": "PRIVATE visibility requires a paid LinkTaco account",
|
||||
"form.integration.linkwarden_activate": "Save entries to Linkwarden",
|
||||
"form.integration.linkwarden_api_key": "Linkwarden API key",
|
||||
"form.integration.linkwarden_endpoint": "Linkwarden Base URL",
|
||||
"form.integration.linkwarden_collection_id": "Linkwarden Collection ID",
|
||||
"form.integration.matrix_bot_activate": "Push new entries to Matrix",
|
||||
"form.integration.matrix_bot_chat_id": "ID of Matrix Room",
|
||||
"form.integration.matrix_bot_password": "Password for Matrix user",
|
||||
@@ -293,6 +307,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",
|
||||
@@ -322,6 +337,7 @@
|
||||
"form.integration.wallabag_only_url": "Send only URL (instead of full content)",
|
||||
"form.integration.wallabag_password": "Wallabag Password",
|
||||
"form.integration.wallabag_username": "Wallabag Username",
|
||||
"form.integration.wallabag_tags": "Wallabag Tags",
|
||||
"form.integration.webhook_activate": "Enable Webhooks",
|
||||
"form.integration.webhook_secret": "Webhooks Secret",
|
||||
"form.integration.webhook_url": "Default Webhook URL",
|
||||
@@ -462,6 +478,7 @@
|
||||
"page.feeds.next_check": "Next check:",
|
||||
"page.feeds.read_counter": "Number of read entries",
|
||||
"page.feeds.title": "Feeds",
|
||||
"page.footer.elevator": "Back to top",
|
||||
"page.history.title": "History",
|
||||
"page.import.title": "Import",
|
||||
"page.integration.bookmarklet": "Bookmarklet",
|
||||
@@ -506,7 +523,7 @@
|
||||
"page.keyboard_shortcuts.subtitle.pages": "Pages Navigation",
|
||||
"page.keyboard_shortcuts.subtitle.sections": "Sections Navigation",
|
||||
"page.keyboard_shortcuts.title": "Keyboard Shortcuts",
|
||||
"page.keyboard_shortcuts.toggle_bookmark_status": "Toggle starred",
|
||||
"page.keyboard_shortcuts.toggle_star_status": "Toggle starred",
|
||||
"page.keyboard_shortcuts.toggle_entry_attachments": "Toggle open/close entry attachments",
|
||||
"page.keyboard_shortcuts.toggle_read_status_next": "Toggle read/unread, focus next",
|
||||
"page.keyboard_shortcuts.toggle_read_status_prev": "Toggle read/unread, focus previous",
|
||||
@@ -614,4 +631,4 @@
|
||||
"time_elapsed.yesterday": "yesterday",
|
||||
"tooltip.keyboard_shortcuts": "Keyboard Shortcut: %s",
|
||||
"tooltip.logged_user": "Logged in as %s"
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,7 @@
|
||||
"alert.account_unlinked": "¡Tu cuenta externa ya está desvinculada!",
|
||||
"alert.background_feed_refresh": "Todos los feeds se actualizan en segundo plano. Puede continuar usando Miniflux mientras se ejecuta este proceso.",
|
||||
"alert.feed_error": "Hay un problema con esta fuente.",
|
||||
"alert.no_bookmark": "No hay marcador en este momento.",
|
||||
"alert.no_starred": "No hay marcador en este momento.",
|
||||
"alert.no_category": "No hay categoría.",
|
||||
"alert.no_category_entry": "No hay artículos en esta categoría.",
|
||||
"alert.no_feed": "No tienes fuentes.",
|
||||
@@ -46,10 +46,10 @@
|
||||
"enclosure_media_controls.speed.reset.title": "Restablecer la velocidad a 1x",
|
||||
"enclosure_media_controls.speed.slower": "Despacio",
|
||||
"enclosure_media_controls.speed.slower.title": "Más despacio a %sx",
|
||||
"entry.bookmark.toast.off": "Sin estrellas",
|
||||
"entry.bookmark.toast.on": "Sembrado de estrellas",
|
||||
"entry.bookmark.toggle.off": "Desmarcar",
|
||||
"entry.bookmark.toggle.on": "Marcar",
|
||||
"entry.starred.toast.off": "Sin estrellas",
|
||||
"entry.starred.toast.on": "Sembrado de estrellas",
|
||||
"entry.starred.toggle.off": "Desmarcar",
|
||||
"entry.starred.toggle.on": "Marcar",
|
||||
"entry.comments.label": "Comentarios",
|
||||
"entry.comments.title": "Ver comentarios",
|
||||
"entry.estimated_reading_time": [
|
||||
@@ -160,10 +160,11 @@
|
||||
"error.unlink_account_without_password": "Debe definir una contraseña, de lo contrario no podrá volver a iniciar sesión.",
|
||||
"error.user_already_exists": "Este usuario ya existe.",
|
||||
"error.user_mandatory_fields": "El nombre de usuario es obligatorio.",
|
||||
"error.linktaco_missing_required_fields": "LinkTaco API Token y Organization Slug son obligatorios.",
|
||||
"form.api_key.label.description": "Etiqueta de clave API",
|
||||
"form.category.hide_globally": "Ocultar artículos en la lista global de no leídos",
|
||||
"form.category.label.title": "Título",
|
||||
"form.feed.fieldset.general": "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",
|
||||
@@ -211,6 +212,7 @@
|
||||
"form.feed.label.webhook_url": "Invalidar la URL del webhook",
|
||||
"form.import.label.file": "Archivo OPML",
|
||||
"form.import.label.url": "URL",
|
||||
"form.integration.archiveorg_activate": "Enviar entradas a archive.org",
|
||||
"form.integration.apprise_activate": "Enviar artículos a Apprise",
|
||||
"form.integration.apprise_services_url": "Lista separada por comas de las URL del servicio Apprise",
|
||||
"form.integration.apprise_url": "URL de la API de Apprise",
|
||||
@@ -239,10 +241,11 @@
|
||||
"form.integration.karakeep_activate": "Enviar artículos a Karakeep",
|
||||
"form.integration.karakeep_api_key": "Clave de API de Karakeep",
|
||||
"form.integration.karakeep_url": "Acceso API de Karakeep",
|
||||
"form.integration.karakeep_tags": "Etiquetas de Karakeep",
|
||||
"form.integration.linkace_activate": "Guardar artículos en LinkAce",
|
||||
"form.integration.linkace_api_key": "Clave API de LinkAce",
|
||||
"form.integration.linkace_check_disabled": "Deshabilitar la comprobación de enlace",
|
||||
"form.integration.linkace_endpoint": "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",
|
||||
@@ -250,9 +253,20 @@
|
||||
"form.integration.linkding_bookmark": "Marcar marcador como no leído",
|
||||
"form.integration.linkding_endpoint": "Acceso API de Linkding",
|
||||
"form.integration.linkding_tags": "Etiquetas de Linkding",
|
||||
"form.integration.linktaco_activate": "Guardar entradas en LinkTaco",
|
||||
"form.integration.linktaco_api_token": "Token de la API de LinkTaco",
|
||||
"form.integration.linktaco_api_token_hint": "Obtenga su token de acceso personal en",
|
||||
"form.integration.linktaco_org_slug": "Slug de la organización",
|
||||
"form.integration.linktaco_tags": "Etiquetas (máx. 10, separadas por comas)",
|
||||
"form.integration.linktaco_tags_hint": "Máximo 10 etiquetas, separadas por comas",
|
||||
"form.integration.linktaco_visibility": "Visibilidad",
|
||||
"form.integration.linktaco_visibility_public": "Público",
|
||||
"form.integration.linktaco_visibility_private": "Privado",
|
||||
"form.integration.linktaco_visibility_hint": "La visibilidad PRIVADA requiere una cuenta de pago de LinkTaco",
|
||||
"form.integration.linkwarden_activate": "Enviar artículos a Linkwarden",
|
||||
"form.integration.linkwarden_api_key": "Clave de API de Linkwarden",
|
||||
"form.integration.linkwarden_endpoint": "URL base de Linkwarden",
|
||||
"form.integration.linkwarden_collection_id": "ID de colección de Linkwarden",
|
||||
"form.integration.matrix_bot_activate": "Transferir nuevos artículos a Matrix",
|
||||
"form.integration.matrix_bot_chat_id": "ID de la sala de Matrix",
|
||||
"form.integration.matrix_bot_password": "Contraseña para el usuario de Matrix",
|
||||
@@ -264,7 +278,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)",
|
||||
@@ -287,17 +301,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",
|
||||
@@ -314,7 +329,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",
|
||||
@@ -322,6 +337,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": "Etiquetas de Wallabag",
|
||||
"form.integration.webhook_activate": "Habilitar Webhooks",
|
||||
"form.integration.webhook_secret": "Secreto de Webhooks",
|
||||
"form.integration.webhook_url": "Defecto URL de Webhook",
|
||||
@@ -412,7 +428,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:",
|
||||
@@ -462,6 +478,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": "Volver arriba",
|
||||
"page.history.title": "Historial",
|
||||
"page.import.title": "Importar",
|
||||
"page.integration.bookmarklet": "Marcapáginas",
|
||||
@@ -506,7 +523,7 @@
|
||||
"page.keyboard_shortcuts.subtitle.pages": "Navegación de páginas",
|
||||
"page.keyboard_shortcuts.subtitle.sections": "Navegación de secciones",
|
||||
"page.keyboard_shortcuts.title": "Atajos de teclado",
|
||||
"page.keyboard_shortcuts.toggle_bookmark_status": "Agregar o quitar marcador",
|
||||
"page.keyboard_shortcuts.toggle_star_status": "Agregar o quitar marcador",
|
||||
"page.keyboard_shortcuts.toggle_entry_attachments": "Alternar abrir/cerrar adjuntos de la entrada",
|
||||
"page.keyboard_shortcuts.toggle_read_status_next": "Marcar como leído o no leído, enfoque siguiente",
|
||||
"page.keyboard_shortcuts.toggle_read_status_prev": "Marcar como leído o no leído, foco anterior",
|
||||
@@ -614,4 +631,4 @@
|
||||
"time_elapsed.yesterday": "ayer",
|
||||
"tooltip.keyboard_shortcuts": "Atajo de teclado: %s",
|
||||
"tooltip.logged_user": "Registrado como %s"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
"alert.account_unlinked": "Ulkoinen tilisi on nyt irrotettu!",
|
||||
"alert.background_feed_refresh": "Kaikki syötteet päivitetään taustalla. Voit jatkaa Minifluxin käyttöä tämän prosessin aikana.",
|
||||
"alert.feed_error": "Tässä syötteessä on ongelma",
|
||||
"alert.no_bookmark": "Tällä hetkellä ei ole kirjanmerkkiä.",
|
||||
"alert.no_starred": "Tällä hetkellä ei ole kirjanmerkkiä.",
|
||||
"alert.no_category": "Ei ole kategoriaa.",
|
||||
"alert.no_category_entry": "Tässä kategoriassa ei ole artikkeleita.",
|
||||
"alert.no_feed": "Sinulla ei ole tilauksia.",
|
||||
@@ -46,10 +46,10 @@
|
||||
"enclosure_media_controls.speed.reset.title": "Palauta nopeus 1x",
|
||||
"enclosure_media_controls.speed.slower": "Hitaammin",
|
||||
"enclosure_media_controls.speed.slower.title": "Hitaampi %sx",
|
||||
"entry.bookmark.toast.off": "Tähdettömät",
|
||||
"entry.bookmark.toast.on": "Tähdellä merkityt",
|
||||
"entry.bookmark.toggle.off": "Poista suosikeista",
|
||||
"entry.bookmark.toggle.on": "Lisää suosikkeihin",
|
||||
"entry.starred.toast.off": "Tähdettömät",
|
||||
"entry.starred.toast.on": "Tähdellä merkityt",
|
||||
"entry.starred.toggle.off": "Poista suosikeista",
|
||||
"entry.starred.toggle.on": "Lisää suosikkeihin",
|
||||
"entry.comments.label": "Kommentit",
|
||||
"entry.comments.title": "Näytä kommentit",
|
||||
"entry.estimated_reading_time": [
|
||||
@@ -104,78 +104,79 @@
|
||||
"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ää.",
|
||||
"error.unlink_account_without_password": "Sinun on määritettävä salasana, muuten et voi kirjautua uudelleen.",
|
||||
"error.user_already_exists": "Käyttäjä on jo olemassa.",
|
||||
"error.user_mandatory_fields": "Käyttäjätunnus on pakollinen.",
|
||||
"form.api_key.label.description": "API Key Label",
|
||||
"error.linktaco_missing_required_fields": "LinkTaco API Token ja Organization Slug vaaditaan",
|
||||
"form.api_key.label.description": "API-avaimen nimi",
|
||||
"form.category.hide_globally": "Piilota artikkelit lukemattomien listassa",
|
||||
"form.category.label.title": "Otsikko",
|
||||
"form.feed.fieldset.general": "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.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",
|
||||
@@ -185,42 +186,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.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.import.label.url": "URL-osoite",
|
||||
"form.integration.archiveorg_activate": "Työnnä merkinnät osoitteeseen archive.org",
|
||||
"form.integration.apprise_activate": "Lähetä merkinnät Appriseen",
|
||||
"form.integration.apprise_services_url": "Pilkuilla eroteltu Apprise-palvelujen URL-lista",
|
||||
"form.integration.apprise_url": "Apprise API -osoite",
|
||||
"form.integration.betula_activate": "Tallenna merkinnät Betulaan",
|
||||
"form.integration.betula_token": "Betula-tunnus",
|
||||
"form.integration.betula_url": "Betula-palvelimen URL",
|
||||
"form.integration.cubox_activate": "Tallenna merkinnät Cuboxiin",
|
||||
"form.integration.cubox_api_link": "Cubox API -linkki",
|
||||
"form.integration.discord_activate": "Lähetä merkinnät Discordiin",
|
||||
"form.integration.discord_webhook_link": "Discord-webhook-linkki",
|
||||
"form.integration.espial_activate": "Tallenna artikkelit Espialiin",
|
||||
"form.integration.espial_api_key": "Espial API-avain",
|
||||
"form.integration.espial_endpoint": "Espial API-päätepiste",
|
||||
@@ -239,36 +241,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.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.linkwarden_activate": "Tallenna artikkelit Linkkiin",
|
||||
"form.integration.linkwarden_api_key": "Linkwarden API-avain",
|
||||
"form.integration.linkwarden_endpoint": "Linkwarden Base URL",
|
||||
"form.integration.linkding_tags": "Linkding-tunnisteet",
|
||||
"form.integration.linktaco_activate": "Tallenna kirjoituksia LinkTacoon",
|
||||
"form.integration.linktaco_api_token": "LinkTaco API -tunnus",
|
||||
"form.integration.linktaco_api_token_hint": "Hanki henkilökohtainen pääsytunniste osoitteesta",
|
||||
"form.integration.linktaco_org_slug": "Organisaation slug",
|
||||
"form.integration.linktaco_tags": "Tunnisteet (enintään 10, pilkuilla eroteltu)",
|
||||
"form.integration.linktaco_tags_hint": "Enintään 10 tunnistetta, pilkuilla eroteltuna",
|
||||
"form.integration.linktaco_visibility": "Näkyvyys",
|
||||
"form.integration.linktaco_visibility_public": "Julkinen",
|
||||
"form.integration.linktaco_visibility_private": "Yksityinen",
|
||||
"form.integration.linktaco_visibility_hint": "Yksityinen näkyvyys vaatii maksullisen LinkTaco-tilin",
|
||||
"form.integration.linkwarden_activate": "Tallenna artikkelit Linkwardeniin",
|
||||
"form.integration.linkwarden_api_key": "Linkwarden API -avain",
|
||||
"form.integration.linkwarden_endpoint": "Linkwardenin perus-URL",
|
||||
"form.integration.linkwarden_collection_id": "Linkwarden-kokoelman tunnus",
|
||||
"form.integration.matrix_bot_activate": "Siirrä uudet artikkelit Matrixiin",
|
||||
"form.integration.matrix_bot_chat_id": "Matrix-huoneen tunnus",
|
||||
"form.integration.matrix_bot_password": "Matrix-käyttäjän salasana",
|
||||
"form.integration.matrix_bot_url": "Matrix-palvelimen URL-osoite",
|
||||
"form.integration.matrix_bot_user": "Matrixin käyttäjätunnus",
|
||||
"form.integration.notion_activate": "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",
|
||||
@@ -279,58 +293,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.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",
|
||||
@@ -342,14 +358,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",
|
||||
@@ -405,14 +421,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:",
|
||||
@@ -441,10 +457,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:",
|
||||
@@ -459,16 +475,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": "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",
|
||||
@@ -506,8 +523,8 @@
|
||||
"page.keyboard_shortcuts.subtitle.pages": "Sivujen navigointi",
|
||||
"page.keyboard_shortcuts.subtitle.sections": "Osion navigointi",
|
||||
"page.keyboard_shortcuts.title": "Pikanäppäimet",
|
||||
"page.keyboard_shortcuts.toggle_bookmark_status": "Vaihda kirjanmerkki",
|
||||
"page.keyboard_shortcuts.toggle_entry_attachments": "Toggle open/close entry attachments",
|
||||
"page.keyboard_shortcuts.toggle_star_status": "Vaihda kirjanmerkki",
|
||||
"page.keyboard_shortcuts.toggle_entry_attachments": "Avaa tai sulje merkinnän liitteet",
|
||||
"page.keyboard_shortcuts.toggle_read_status_next": "Vaihda luettu/lukematon, keskity seuraavaksi",
|
||||
"page.keyboard_shortcuts.toggle_read_status_prev": "Vaihda luettu/lukematon, keskity edelliseen",
|
||||
"page.login.google_signin": "Kirjaudu sisään Googlella",
|
||||
@@ -515,7 +532,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ä",
|
||||
@@ -523,8 +540,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",
|
||||
@@ -538,35 +555,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",
|
||||
@@ -576,7 +593,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",
|
||||
@@ -614,4 +631,4 @@
|
||||
"time_elapsed.yesterday": "eilen",
|
||||
"tooltip.keyboard_shortcuts": "Pikanäppäin: %s",
|
||||
"tooltip.logged_user": "Kirjautunut %s-käyttäjänä"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
"alert.account_unlinked": "Votre compte externe est maintenant dissocié !",
|
||||
"alert.background_feed_refresh": "Les abonnements sont en cours d'actualisation en arrière-plan. Vous pouvez continuer à naviguer dans l'application.",
|
||||
"alert.feed_error": "Il y a un problème avec cet abonnement",
|
||||
"alert.no_bookmark": "Il n'y a aucun favoris pour le moment.",
|
||||
"alert.no_starred": "Il n'y a aucun favoris pour le moment.",
|
||||
"alert.no_category": "Il n'y a aucune catégorie.",
|
||||
"alert.no_category_entry": "Il n'y a aucun article dans cette catégorie.",
|
||||
"alert.no_feed": "Vous n'avez aucun abonnement.",
|
||||
@@ -46,10 +46,10 @@
|
||||
"enclosure_media_controls.speed.reset.title": "Réinitialiser la vitesse de lecture à 1x",
|
||||
"enclosure_media_controls.speed.slower": "Ralentir",
|
||||
"enclosure_media_controls.speed.slower.title": "Ralentir de %sx",
|
||||
"entry.bookmark.toast.off": "Enlevé des favoris",
|
||||
"entry.bookmark.toast.on": "Ajouté aux favoris",
|
||||
"entry.bookmark.toggle.off": "Enlever favoris",
|
||||
"entry.bookmark.toggle.on": "Favoris",
|
||||
"entry.starred.toast.off": "Enlevé des favoris",
|
||||
"entry.starred.toast.on": "Ajouté aux favoris",
|
||||
"entry.starred.toggle.off": "Enlever favoris",
|
||||
"entry.starred.toggle.on": "Favoris",
|
||||
"entry.comments.label": "Commentaires",
|
||||
"entry.comments.title": "Voir les commentaires",
|
||||
"entry.estimated_reading_time": [
|
||||
@@ -160,6 +160,7 @@
|
||||
"error.unlink_account_without_password": "Vous devez définir un mot de passe sinon vous ne pourrez plus vous connecter par la suite.",
|
||||
"error.user_already_exists": "Cet utilisateur existe déjà.",
|
||||
"error.user_mandatory_fields": "Le nom d'utilisateur est obligatoire.",
|
||||
"error.linktaco_missing_required_fields": "Le token API LinkTaco et le slug de l'organisation sont requis.",
|
||||
"form.api_key.label.description": "Libellé de la clé d'API",
|
||||
"form.category.hide_globally": "Masquer les entrées dans la liste globale non lue",
|
||||
"form.category.label.title": "Titre",
|
||||
@@ -211,6 +212,7 @@
|
||||
"form.feed.label.webhook_url": "Remplacer l'URL du webhook",
|
||||
"form.import.label.file": "Fichier OPML",
|
||||
"form.import.label.url": "URL",
|
||||
"form.integration.archiveorg_activate": "Envoyer les articles vers archive.org",
|
||||
"form.integration.apprise_activate": "Envoyer les articles vers Apprise",
|
||||
"form.integration.apprise_services_url": "Liste des services Apprise séparés par des virgules",
|
||||
"form.integration.apprise_url": "URL de l'API Apprise",
|
||||
@@ -239,6 +241,7 @@
|
||||
"form.integration.karakeep_activate": "Sauvegarder les articles vers Karakeep",
|
||||
"form.integration.karakeep_api_key": "Clé d'API de Karakeep",
|
||||
"form.integration.karakeep_url": "URL de l'API de Karakeep",
|
||||
"form.integration.karakeep_tags": "Libellés Karakeep",
|
||||
"form.integration.linkace_activate": "Enregistrer les entrées vers LinkAce",
|
||||
"form.integration.linkace_api_key": "Clé d'API LinkAce",
|
||||
"form.integration.linkace_check_disabled": "Désactiver la vérification des liens",
|
||||
@@ -250,9 +253,20 @@
|
||||
"form.integration.linkding_bookmark": "Marquer le lien comme non lu",
|
||||
"form.integration.linkding_endpoint": "URL de l'API de Linkding",
|
||||
"form.integration.linkding_tags": "Libellés",
|
||||
"form.integration.linktaco_activate": "Sauvegarder les entrées vers LinkTaco",
|
||||
"form.integration.linktaco_api_token": "Token API LinkTaco",
|
||||
"form.integration.linktaco_api_token_hint": "Obtenez votre token d'accès personnel sur",
|
||||
"form.integration.linktaco_org_slug": "Slug de l'organisation",
|
||||
"form.integration.linktaco_tags": "Libellés (max. 10, séparés par des virgules)",
|
||||
"form.integration.linktaco_tags_hint": "Maximum 10 libellés, séparés par des virgules",
|
||||
"form.integration.linktaco_visibility": "Visibilité",
|
||||
"form.integration.linktaco_visibility_public": "Publique",
|
||||
"form.integration.linktaco_visibility_private": "Privé",
|
||||
"form.integration.linktaco_visibility_hint": "La visibilité PRIVÉE nécessite un compte LinkTaco payant",
|
||||
"form.integration.linkwarden_activate": "Sauvegarder les articles vers Linkwarden",
|
||||
"form.integration.linkwarden_api_key": "Clé d'API de Linkwarden",
|
||||
"form.integration.linkwarden_endpoint": "URL de base de Linkwarden",
|
||||
"form.integration.linkwarden_collection_id": "ID de collection Linkwarden",
|
||||
"form.integration.matrix_bot_activate": "Envoyer les nouveaux articles vers Matrix",
|
||||
"form.integration.matrix_bot_chat_id": "Identifiant de la salle Matrix",
|
||||
"form.integration.matrix_bot_password": "Mot de passe de l'utilisateur Matrix",
|
||||
@@ -293,6 +307,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",
|
||||
@@ -322,6 +337,7 @@
|
||||
"form.integration.wallabag_only_url": "Envoyer uniquement l'URL (au lieu du contenu complet)",
|
||||
"form.integration.wallabag_password": "Mot de passe de Wallabag",
|
||||
"form.integration.wallabag_username": "Nom d'utilisateur de Wallabag",
|
||||
"form.integration.wallabag_tags": "Libellés Wallabag",
|
||||
"form.integration.webhook_activate": "Activer le webhook",
|
||||
"form.integration.webhook_secret": "Secret du webhook",
|
||||
"form.integration.webhook_url": "URL du webhook",
|
||||
@@ -359,7 +375,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",
|
||||
@@ -462,9 +478,10 @@
|
||||
"page.feeds.next_check": "Prochaine vérification :",
|
||||
"page.feeds.read_counter": "Nombre d'entrées lues",
|
||||
"page.feeds.title": "Abonnements",
|
||||
"page.footer.elevator": "Retour en haut",
|
||||
"page.history.title": "Historique",
|
||||
"page.import.title": "Importation",
|
||||
"page.integration.bookmarklet": "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",
|
||||
@@ -506,7 +523,7 @@
|
||||
"page.keyboard_shortcuts.subtitle.pages": "Navigation entre les pages",
|
||||
"page.keyboard_shortcuts.subtitle.sections": "Navigation entre les sections",
|
||||
"page.keyboard_shortcuts.title": "Raccourcis clavier",
|
||||
"page.keyboard_shortcuts.toggle_bookmark_status": "Ajouter/Enlever favoris",
|
||||
"page.keyboard_shortcuts.toggle_star_status": "Ajouter/Enlever favoris",
|
||||
"page.keyboard_shortcuts.toggle_entry_attachments": "Ouvrir/Fermer les pièces jointes de l'entrée",
|
||||
"page.keyboard_shortcuts.toggle_read_status_next": "Basculer entre lu/non lu, et changer le focus sur l'élément suivant",
|
||||
"page.keyboard_shortcuts.toggle_read_status_prev": "Basculer entre lu/non lu, et changer le focus sur l'élément précédent",
|
||||
@@ -614,4 +631,4 @@
|
||||
"time_elapsed.yesterday": "hier",
|
||||
"tooltip.keyboard_shortcuts": "Raccourci clavier : %s",
|
||||
"tooltip.logged_user": "Connecté en tant que %s"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
"alert.account_unlinked": "आपका बाहरी खाता अब अलग कर दिया गया है!",
|
||||
"alert.background_feed_refresh": "सभी फ़ीड्स पृष्ठभूमि में ताज़ा की जा रही हैं। जब यह प्रक्रिया चल रही हो, तो आप मिनीफ्लक्स का उपयोग जारी रख सकते हैं।",
|
||||
"alert.feed_error": "इस फ़ीड में एक समस्या है",
|
||||
"alert.no_bookmark": "इस समय कोई बुकमार्क नहीं है",
|
||||
"alert.no_starred": "इस समय कोई बुकमार्क नहीं है",
|
||||
"alert.no_category": "कोई श्रेणी नहीं है।",
|
||||
"alert.no_category_entry": "इस श्रेणी में कोई विषय-वस्तु नहीं है।",
|
||||
"alert.no_feed": "आपके पास कोई सदस्यता नहीं है।",
|
||||
@@ -46,10 +46,10 @@
|
||||
"enclosure_media_controls.speed.reset.title": "गति 1x पर रीसेट करें",
|
||||
"enclosure_media_controls.speed.slower": "धीमा",
|
||||
"enclosure_media_controls.speed.slower.title": "%sx गुना धीमा",
|
||||
"entry.bookmark.toast.off": "तारांकित न करे",
|
||||
"entry.bookmark.toast.on": "तारांकित",
|
||||
"entry.bookmark.toggle.off": "सितारा हटा दो",
|
||||
"entry.bookmark.toggle.on": "सितारा दे",
|
||||
"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": [
|
||||
@@ -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": "इस उपयोगकर्ता को बनाने में असमर्थ।",
|
||||
@@ -160,22 +160,23 @@
|
||||
"error.unlink_account_without_password": "आपको एक पासवर्ड परिभाषित करना होगा अन्यथा आप फिर से लॉगिन नहीं कर पाएंगे।",
|
||||
"error.user_already_exists": "यह उपयोगकर्ता पहले से ही मौजूद है।",
|
||||
"error.user_mandatory_fields": "उपयोगकर्ता नाम अनिवार्य है।",
|
||||
"error.linktaco_missing_required_fields": "LinkTaco API Token और Organization Slug आवश्यक हैं",
|
||||
"form.api_key.label.description": "एपीआई कुंजी लेबल",
|
||||
"form.category.hide_globally": "वैश्विक अपठित सूची में प्रविष्टियां छिपाएं",
|
||||
"form.category.label.title": "शीर्षक",
|
||||
"form.feed.fieldset.general": "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.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": "फ़ीड यूआरएल",
|
||||
@@ -185,42 +186,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.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.archiveorg_activate": "प्रविष्टियों को archive.org पर भेजें",
|
||||
"form.integration.apprise_activate": "प्रविष्टियाँ Apprise पर भेजें",
|
||||
"form.integration.apprise_services_url": "Apprise सेवा URLs की कॉमा से पृथक सूची",
|
||||
"form.integration.apprise_url": "Apprise API यूआरएल",
|
||||
"form.integration.betula_activate": "प्रविष्टियाँ Betula में सहेजें",
|
||||
"form.integration.betula_token": "Betula टोकन",
|
||||
"form.integration.betula_url": "Betula सर्वर URL",
|
||||
"form.integration.cubox_activate": "प्रविष्टियाँ Cubox में सहेजें",
|
||||
"form.integration.cubox_api_link": "Cubox API लिंक",
|
||||
"form.integration.discord_activate": "प्रविष्टियाँ Discord पर भेजें",
|
||||
"form.integration.discord_webhook_link": "Discord वेबहुक लिंक",
|
||||
"form.integration.espial_activate": "विषय-वस्तु को जासूसी में सहेजें",
|
||||
"form.integration.espial_api_key": "जासूसी एपीआई कुंजी",
|
||||
"form.integration.espial_endpoint": "जासूसी एपीआई समापन बिंदु",
|
||||
@@ -236,85 +238,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.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.linkwarden_activate": "Save entries to Linkwarden",
|
||||
"form.integration.linkwarden_api_key": "Linkwarden API key",
|
||||
"form.integration.linkwarden_endpoint": "लिंकवर्डन बेस यूआरएलL",
|
||||
"form.integration.linkding_tags": "Linkding टैग",
|
||||
"form.integration.linktaco_activate": "LinkTaco में प्रविष्टियाँ सहेजें",
|
||||
"form.integration.linktaco_api_token": "LinkTaco API टोकन",
|
||||
"form.integration.linktaco_api_token_hint": "अपना व्यक्तिगत पहुँच टोकन प्राप्त करें",
|
||||
"form.integration.linktaco_org_slug": "संगठन स्लग",
|
||||
"form.integration.linktaco_tags": "टैग (अधिकतम 10, कॉमा से अलग किए गए)",
|
||||
"form.integration.linktaco_tags_hint": "अधिकतम 10 टैग, कॉमा से अलग किए गए",
|
||||
"form.integration.linktaco_visibility": "दृश्यता",
|
||||
"form.integration.linktaco_visibility_public": "सार्वजनिक",
|
||||
"form.integration.linktaco_visibility_private": "निजी",
|
||||
"form.integration.linktaco_visibility_hint": "निजी दृश्यता के लिए भुगतान LinkTaco खाता आवश्यक है",
|
||||
"form.integration.linkwarden_activate": "प्रविष्टियाँ Linkwarden में सहेजें",
|
||||
"form.integration.linkwarden_api_key": "Linkwarden API कुंजी",
|
||||
"form.integration.linkwarden_endpoint": "Linkwarden बेस URL",
|
||||
"form.integration.linkwarden_collection_id": "Linkwarden संग्रह ID",
|
||||
"form.integration.matrix_bot_activate": "नए लेखों को मैट्रिक्स में स्थानांतरित करें",
|
||||
"form.integration.matrix_bot_chat_id": "मैट्रिक्स रूम की आईडी",
|
||||
"form.integration.matrix_bot_password": "मैट्रिक्स उपयोगकर्ता के लिए पासवर्ड",
|
||||
"form.integration.matrix_bot_url": "मैट्रिक्स सर्वर URL",
|
||||
"form.integration.matrix_bot_user": "मैट्रिक्स के लिए उपयोगकर्ता नाम",
|
||||
"form.integration.notion_activate": "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": "वालाबैग क्लाइंट सीक्रेट",
|
||||
@@ -322,15 +337,16 @@
|
||||
"form.integration.wallabag_only_url": "केवल URL भेजें (पूर्ण सामग्री के बजाय)",
|
||||
"form.integration.wallabag_password": "वालाबैग पासवर्ड",
|
||||
"form.integration.wallabag_username": "वालाबैग उपयोगकर्ता नाम",
|
||||
"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": "कस्टम सीएसएस",
|
||||
@@ -342,14 +358,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": "विषय के लिए अनुमानित पढ़ने का समय दिखाएं",
|
||||
@@ -388,7 +404,7 @@
|
||||
"menu.feeds": "फ़ीड",
|
||||
"menu.flush_history": "इतिहास मिटाएँ",
|
||||
"menu.history": "इतिहास",
|
||||
"menu.home_page": "Home page",
|
||||
"menu.home_page": "मुखपृष्ठ",
|
||||
"menu.import": "आयात करे",
|
||||
"menu.integrations": "एकीकरण",
|
||||
"menu.logout": "लॉग आउट",
|
||||
@@ -402,17 +418,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": "अनुज्ञा:",
|
||||
@@ -441,10 +457,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": "अंतिम जांच:",
|
||||
@@ -459,9 +475,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": "ऊपर जाएँ",
|
||||
"page.history.title": "इतिहास",
|
||||
"page.import.title": "आयात",
|
||||
"page.integration.bookmarklet": "बुकमार्कलेट",
|
||||
@@ -506,8 +523,8 @@
|
||||
"page.keyboard_shortcuts.subtitle.pages": "पेज नेविगेशन",
|
||||
"page.keyboard_shortcuts.subtitle.sections": "अनुभाग नेविगेशन",
|
||||
"page.keyboard_shortcuts.title": "कुंजीपटल अल्प मार्ग",
|
||||
"page.keyboard_shortcuts.toggle_bookmark_status": "बुकमार्क टॉगल करें",
|
||||
"page.keyboard_shortcuts.toggle_entry_attachments": "Toggle open/close entry attachments",
|
||||
"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": "गूगल के साथ साइन इन करें",
|
||||
@@ -515,7 +532,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": "नया उपभोक्ता",
|
||||
@@ -523,8 +540,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": "कार्रवाई",
|
||||
@@ -538,35 +555,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": "नहीं",
|
||||
@@ -576,7 +593,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": "अगला",
|
||||
@@ -614,4 +631,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