Compare commits
351 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| 459b1bf1ee | |||
| fdc12bf18c | |||
| 1b1ceaf8b4 | |||
| cad40cc158 | |||
| d0e43f8682 | |||
| c105b14f58 | |||
| 49575c8902 | |||
| b25f9651fe | |||
| 953ea885e0 | |||
| 9536ce7dbc | |||
| f6532e16f2 | |||
| 84ae1d5dc0 | |||
| 5403ca09f6 | |||
| 905d652511 | |||
| a16ac51326 | |||
| 5c26e06780 | |||
| 9e722839b5 | |||
| 9e4248c7c1 | |||
| a654a5f710 | |||
| 6bf3b3c464 | |||
| a010544200 | |||
| c1af510ead | |||
| 88d9682f5f | |||
| ce6cadc176 | |||
| 40fa77851c | |||
| dd8011a4aa | |||
| 3955df43f5 | |||
| 6f2ec8b3e9 | |||
| 2fa813fe01 | |||
| 7c1602c2c0 | |||
| 5e3bba9ae1 | |||
| ab26a4e20f | |||
| 2c4f4e2ae6 | |||
| 261b72f149 | |||
| 6c60d61f36 | |||
| fa85a0eff4 | |||
| eb6f7f30bb | |||
| 93fc206f42 | |||
| 8bca777a6d | |||
| 1e6d227e40 | |||
| 37d539eb42 | |||
| f5188b1edc | |||
| 5d9d0b2652 | |||
| 50c5996280 | |||
| 884521a7dd | |||
| 3a01f8a691 | |||
| 68984da332 | |||
| 485baf9654 | |||
| 06cbf1b3b3 | |||
| b20a8c97b0 | |||
| e0ca92fca4 | |||
| 598d4d4f51 | |||
| a2229198ae | |||
| 34499b887b | |||
| a4f672b589 | |||
| 98da7b3f22 | |||
| b4c82a4c53 | |||
| 4d7c601f6d | |||
| f7e672452b | |||
| 6532435db9 | |||
| 14cd5e9c0d | |||
| 566670cc06 | |||
| a4d51b5586 | |||
| e6d01896e0 | |||
| b4a814a5b2 | |||
| 940d94e793 | |||
| 80f48c88c7 | |||
| 045f2f1747 | |||
| 2565ff930c | |||
| b3fce752d2 | |||
| 798bc4cd2d | |||
| b1cbaae71c | |||
| 924293ee5c | |||
| 826977bc8c | |||
| ed0e4a667d | |||
| 5affd78f4f | |||
| d3ad460c9d | |||
| 0f3c04a98a | |||
| e8e3a600b4 | |||
| a6ce5c92dc | |||
| 76ef8f3579 | |||
| a43d150a27 | |||
| 3bb965913d | |||
| b505a63f3b | |||
| e9d9256ae2 | |||
| 391792a424 | |||
| d2cfca589b | |||
| f2e34cf07f | |||
| 5c3be3e74f | |||
| 546fbcff8f | |||
| 2e28bf78bd | |||
| 52c1386450 | |||
| 4910f1f0f4 | |||
| bbe3c2ea71 | |||
| b116da85a9 | |||
| 07246e2b59 | |||
| 62410659d5 | |||
| 3e1a7e411c | |||
| bfbc1c88c3 | |||
| 50197c2be3 | |||
| 7a25cf5037 | |||
| 1ec90e34f5 | |||
| 5e07278e87 | |||
| cce0e7bd29 | |||
| 1f7843e313 | |||
| 181e1341e1 | |||
| f3052eb8ed | |||
| 078eb39db9 | |||
| 6aeb565edc | |||
| 0c3e251884 | |||
| bfd8cb3d22 | |||
| 9eea9873b5 | |||
| 240ec8bd0a | |||
| f3ac4dacf6 | |||
| 13986e9cc0 | |||
| 4d7d554df8 | |||
| 66b269e6cd | |||
| 54abd0a736 | |||
| 5eab4753e8 | |||
| bf466425db | |||
| a62b97bddd | |||
| 1de9cf4241 | |||
| 7912b9b8fb | |||
| 2d24f5d04e | |||
| 20825a92c5 | |||
| 1d1162327e | |||
| 202de7c787 | |||
| b470b186b3 | |||
| 5877cf340a | |||
| d417b0ff12 | |||
| 703f113fbd | |||
| f02213a168 | |||
| 410b43a787 | |||
| d9de9d1852 | |||
| 33d55cc4e9 | |||
| 1825320369 | |||
| d80fb242db | |||
| 4336a0bd85 | |||
| dc81725788 | |||
| 86e2ce6d0b | |||
| 4679691c94 | |||
| 0d5f4a710f | |||
| 92d2ac4f58 | |||
| 1cfee27a50 | |||
| 0e9da3a090 | |||
| 57bd384951 | |||
| fdbd5b08a1 | |||
| f455c18c66 | |||
| 9dea26c923 | |||
| 46adb0ffad | |||
| 61583d53d5 | |||
| 7c42e777ec | |||
| 335dffbb75 | |||
| f0cdfb33dd | |||
| 13ef89f785 | |||
| 32fbb4e882 | |||
| 135ce1d546 | |||
| abed7b11ce | |||
| 2e26f5ca75 | |||
| d6d18a2d61 | |||
| 63891501e5 | |||
| 7107ff985f | |||
| 50d5cb96c8 | |||
| f860daef7f | |||
| e7b98afdbe | |||
| 2cfeefc8d2 | |||
| b48e6472f5 | |||
| 24043ece07 | |||
| a09129d220 | |||
| f864a2ed70 | |||
| 052e8dd0aa | |||
| 7a394b0bf8 | |||
| dcfe0a7d94 | |||
| 33c648825f | |||
| 78c7f66df7 | |||
| f2b805850c | |||
| 915b7b3cf7 | |||
| 8e86004936 | |||
| a8b4e88742 | |||
| 15e4c3a374 | |||
| 69a74c4abf | |||
| 766d4ab834 | |||
| cb617ff6e0 | |||
| 8c3f280f32 | |||
| 8a98926674 | |||
| 435a950d64 | |||
| 89c32d518d | |||
| 2f7b2e7375 | |||
| 6eeccae7cd | |||
| 99c5bcdb01 | |||
| aed99e65c1 | |||
| d1a3f98df9 | |||
| 112494bb66 | |||
| 9f7ecdb75a | |||
| 4e1f836266 | |||
| a68de4ee6a | |||
| c064891314 | |||
| 5129f53d58 | |||
| e60f0fd142 | |||
| 2b26a345cd | |||
| 3de31a1a4d | |||
| 560be66147 | |||
| fcf86e33b9 | |||
| 113f6b8982 | |||
| cbdcf1a56c | |||
| 95eb6c1230 | |||
| 643b89ec89 | |||
| 84ebf1a033 | |||
| 875618d786 | |||
| 1503a5c946 | |||
| 8641f5f2a3 | |||
| 93b17af78b | |||
| 92876a0c61 | |||
| d62df4e02a | |||
| 8fa5041c37 | |||
| dc05965895 | |||
| 109e668ac7 | |||
| 6d58052504 | |||
| 491d51c95f | |||
| db49e41acf | |||
| e6b814199b | |||
| b0a10f02fd | |||
| 9c05c3c493 | |||
| 2a9d91c783 | |||
| cb59944d6b | |||
| c12476c1a9 | |||
| bc6ab44ff2 | |||
| 6282ac1f38 | |||
| 96c0ef4efd | |||
| b139ac4a2c | |||
| c818d5bbb8 | |||
| e366710529 | |||
| 5cff4d7117 | |||
| f31a784eaa | |||
| 7edfcc3cf7 | |||
| fe4b00b9f8 | |||
| 46b159ac58 | |||
| 86c58e11f6 | |||
| 9a1d9593b3 | |||
| 43546976d2 | |||
| 3ab9ca9e4d | |||
| 1af6df7cb9 | |||
| 50dff576b0 | |||
| 6af4d69c39 | |||
| 27015a5e34 | |||
| cdb57b3843 | |||
| 152ef578d2 | |||
| 72486b9bd1 | |||
| 81df0b2a16 | |||
| b296f21e98 | |||
| af15032145 | |||
| 6a6a88d06d | |||
| 8660f5e3c7 | |||
| da4ab4263c | |||
| 237672a62c | |||
| e9d4a130fd | |||
| d291d6a74d | |||
| e0f7e6f2a8 | |||
| ef3dbd3707 | |||
| 32f08053aa | |||
| 117c031f1c | |||
| b95c9023ee | |||
| 3538c4271b | |||
| ac44507af2 | |||
| 44c48d109f | |||
| 40727704c2 | |||
| 8a014c6abc | |||
| 60ad19c427 | |||
| f40c1e7f63 | |||
| a4d16cc5c1 | |||
| 7c857bdc72 | |||
| 0caadf82f2 | |||
| 0086e0b356 | |||
| 70b513b8db | |||
| cecc18420d | |||
| d53fd17e10 | |||
| 21d22d7f0b | |||
| d59990f1dd | |||
| 49085daefe | |||
| 5872710d22 | |||
| d2212dee12 | |||
| 8db637cb39 | |||
| 699deea72c | |||
| c41d189a7a | |||
| adfc38d237 | |||
| a8bb7a48d7 | |||
| f9dce3d10f | |||
| 567e8cfc89 | |||
| a8e73d6875 | |||
| fa7f2b18a0 | |||
| 43d302e768 | |||
| 4e181330d0 | |||
| 0369f03940 | |||
| 4597d9b289 | |||
| 7bfd22aab7 |
@@ -63,3 +63,5 @@ body:
|
||||
required: true
|
||||
- label: "I understand that feature requests are not guaranteed to be implemented."
|
||||
required: true
|
||||
- label: "I agree to follow the project's contribution guidelines."
|
||||
required: true
|
||||
|
||||
@@ -84,3 +84,5 @@ body:
|
||||
required: true
|
||||
- label: "I agree to provide follow-up updates and maintain discussion on this proposal."
|
||||
required: true
|
||||
- label: "I agree to follow the project's contribution guidelines."
|
||||
required: true
|
||||
|
||||
@@ -4,4 +4,4 @@ Have you followed these guidelines?
|
||||
- [ ] There are no breaking changes
|
||||
- [ ] I have thoroughly tested my changes and verified there are no regressions
|
||||
- [ ] My commit messages follow the [Conventional Commits specification](https://www.conventionalcommits.org/)
|
||||
- [ ] I have read this document: https://miniflux.app/faq.html#pull-request
|
||||
- [ ] I have read and understood the [contribution guidelines](https://github.com/miniflux/v2/blob/main/CONTRIBUTING.md)
|
||||
|
||||
@@ -10,11 +10,11 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
- name: Set up Golang
|
||||
uses: actions/setup-go@v5
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: "1.24.x"
|
||||
go-version: stable
|
||||
check-latest: true
|
||||
- name: Compile binaries
|
||||
env:
|
||||
|
||||
@@ -33,11 +33,11 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- uses: actions/setup-go@v5
|
||||
- uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: "1.24.x"
|
||||
go-version: stable
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v3
|
||||
|
||||
@@ -17,7 +17,7 @@ jobs:
|
||||
name: Test Packages
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
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@v4
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Set up QEMU
|
||||
@@ -64,7 +64,7 @@ jobs:
|
||||
name: Publish Packages
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Set up QEMU
|
||||
|
||||
@@ -17,7 +17,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ jobs:
|
||||
name: Javascript Linter
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
- name: Install linters
|
||||
run: |
|
||||
sudo npm install -g jshint@2.13.6 eslint@8.57.0
|
||||
@@ -25,10 +25,10 @@ jobs:
|
||||
name: Golang Linters
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: "1.24.x"
|
||||
go-version: stable
|
||||
- uses: golangci/golangci-lint-action@v8
|
||||
with:
|
||||
args: >
|
||||
@@ -43,11 +43,11 @@ jobs:
|
||||
name: Commit Linter
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
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
|
||||
|
||||
@@ -17,7 +17,7 @@ jobs:
|
||||
name: Test Packages
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Build RPM Package
|
||||
@@ -29,7 +29,7 @@ jobs:
|
||||
name: Build Packages Manually
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Build RPM Package
|
||||
@@ -46,7 +46,7 @@ jobs:
|
||||
name: Publish Packages
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Build RPM Package
|
||||
|
||||
@@ -4,8 +4,10 @@ import sys
|
||||
import argparse
|
||||
from typing import Match
|
||||
|
||||
# Conventional commit pattern
|
||||
CONVENTIONAL_COMMIT_PATTERN: str = r"^(build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test)(\([a-z0-9-]+\))?!?: .{1,100}"
|
||||
# Conventional commit pattern (including Git revert messages)
|
||||
CONVENTIONAL_COMMIT_PATTERN: str = (
|
||||
r"^((build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test)(\([a-z0-9-]+\))?!?: .{1,100}|Revert .+)"
|
||||
)
|
||||
|
||||
|
||||
def get_commit_message(commit_hash: str) -> str:
|
||||
@@ -23,9 +25,7 @@ def get_commit_message(commit_hash: str) -> str:
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def check_commit_message(
|
||||
message: str, pattern: str = CONVENTIONAL_COMMIT_PATTERN
|
||||
) -> bool:
|
||||
def check_commit_message(message: str, pattern: str = CONVENTIONAL_COMMIT_PATTERN) -> bool:
|
||||
"""Check if commit message follows conventional commit format."""
|
||||
first_line: str = message.split("\n")[0]
|
||||
match: Match[str] | None = re.match(pattern, first_line)
|
||||
@@ -50,9 +50,7 @@ def check_commit_range(base_ref: str, head_ref: str) -> list[dict[str, str]]:
|
||||
for commit_hash in commit_hashes:
|
||||
message: str = get_commit_message(commit_hash)
|
||||
if not check_commit_message(message):
|
||||
non_compliant.append(
|
||||
{"hash": commit_hash, "message": message.split("\n")[0]}
|
||||
)
|
||||
non_compliant.append({"hash": commit_hash, "message": message.split("\n")[0]})
|
||||
|
||||
return non_compliant
|
||||
except subprocess.CalledProcessError as e:
|
||||
@@ -61,15 +59,9 @@ def check_commit_range(base_ref: str, head_ref: str) -> list[dict[str, str]]:
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser: argparse.ArgumentParser = argparse.ArgumentParser(
|
||||
description="Check conventional commit compliance"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--base", required=True, help="Base ref (starting commit, exclusive)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--head", required=True, help="Head ref (ending commit, inclusive)"
|
||||
)
|
||||
parser: argparse.ArgumentParser = argparse.ArgumentParser(description="Check conventional commit compliance")
|
||||
parser.add_argument("--base", required=True, help="Base ref (starting commit, exclusive)")
|
||||
parser.add_argument("--head", required=True, help="Head ref (ending commit, inclusive)")
|
||||
args: argparse.Namespace = parser.parse_args()
|
||||
|
||||
non_compliant: list[dict[str, str]] = check_commit_range(args.base, args.head)
|
||||
@@ -80,9 +72,7 @@ def main() -> None:
|
||||
print(f"- {commit['hash'][:8]}: {commit['message']}")
|
||||
print("\nPlease ensure your commit messages follow the format:")
|
||||
print("type(scope): subject")
|
||||
print(
|
||||
"\nWhere type is one of: build, chore, ci, docs, feat, fix, perf, refactor, revert, style, test"
|
||||
)
|
||||
print("\nWhere type is one of: build, chore, ci, docs, feat, fix, perf, refactor, revert, style, test")
|
||||
sys.exit(1)
|
||||
else:
|
||||
print("All commits follow the conventional commit format!")
|
||||
|
||||
@@ -15,14 +15,13 @@ jobs:
|
||||
max-parallel: 4
|
||||
matrix:
|
||||
os: [ubuntu-latest, windows-latest, macOS-latest]
|
||||
go-version: ["1.24.x"]
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: ${{ matrix.go-version }}
|
||||
go-version: stable
|
||||
- name: Run unit tests with coverage and race conditions checking
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
run: make test
|
||||
@@ -45,16 +44,15 @@ jobs:
|
||||
options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: "1.24.x"
|
||||
go-version: stable
|
||||
- name: Install Postgres client
|
||||
run: sudo apt update && sudo apt install -y postgresql-client
|
||||
- name: Run integration tests
|
||||
run: make integration-test
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
PGHOST: 127.0.0.1
|
||||
PGPASSWORD: postgres
|
||||
|
||||
+178
@@ -0,0 +1,178 @@
|
||||
# Contributing to Miniflux
|
||||
|
||||
This document outlines how to contribute effectively to Miniflux.
|
||||
|
||||
## Philosophy
|
||||
|
||||
Miniflux follows a **minimalist philosophy**. The feature set is intentionally kept limited to avoid bloatware. Before contributing, please understand that:
|
||||
|
||||
- **Improving existing features takes priority over adding new ones**
|
||||
- **Quality over quantity** - well-implemented, focused features are preferred
|
||||
- **Simplicity is key** - complex solutions are discouraged in favor of simple, maintainable code
|
||||
|
||||
## Before You Start
|
||||
|
||||
### Feature Requests
|
||||
|
||||
Before implementing a new feature:
|
||||
|
||||
- Check if it aligns with Miniflux's philosophy
|
||||
- Consider if the feature could be implemented differently to maintain simplicity
|
||||
- Remember that developing software takes significant time, and this is a volunteer-driven project
|
||||
- If you need a specific feature, the best approach is to contribute it yourself
|
||||
|
||||
### Bug Reports
|
||||
|
||||
When reporting bugs:
|
||||
|
||||
- Search existing issues first to avoid duplicates
|
||||
- Provide clear reproduction steps
|
||||
- Include relevant system information (OS, browser, Miniflux version)
|
||||
- Include error messages, screenshots, and logs when applicable
|
||||
|
||||
## Development Setup
|
||||
|
||||
### Requirements
|
||||
|
||||
- **Git**
|
||||
- **Go >= 1.24**
|
||||
- **PostgreSQL**
|
||||
|
||||
### Getting Started
|
||||
|
||||
1. **Fork the repository** on GitHub
|
||||
2. **Clone your fork locally:**
|
||||
```bash
|
||||
git clone https://github.com/YOUR_USERNAME/miniflux.git
|
||||
cd miniflux
|
||||
```
|
||||
|
||||
3. **Build the application binary:**
|
||||
```bash
|
||||
make miniflux
|
||||
```
|
||||
|
||||
4. **Run locally in debug mode:**
|
||||
```bash
|
||||
make run
|
||||
```
|
||||
|
||||
### Database Setup
|
||||
|
||||
For development and testing, you can run a local PostgreSQL database with Docker:
|
||||
|
||||
```bash
|
||||
# Start PostgreSQL container
|
||||
docker run --rm --name miniflux2-db -p 5432:5432 \
|
||||
-e POSTGRES_DB=miniflux2 \
|
||||
-e POSTGRES_USER=postgres \
|
||||
-e POSTGRES_PASSWORD=postgres \
|
||||
postgres
|
||||
```
|
||||
|
||||
You can also use an existing PostgreSQL instance. Make sure to set the `DATABASE_URL` environment variable accordingly.
|
||||
|
||||
## Development Workflow
|
||||
|
||||
### Code Quality
|
||||
|
||||
1. **Run the linter:**
|
||||
```bash
|
||||
make lint
|
||||
```
|
||||
Requires `staticcheck` and `golangci-lint` to be installed.
|
||||
|
||||
2. **Run unit tests:**
|
||||
```bash
|
||||
make test
|
||||
```
|
||||
|
||||
3. **Run integration tests:**
|
||||
```bash
|
||||
make integration-test
|
||||
make clean-integration-test
|
||||
```
|
||||
|
||||
### Building
|
||||
|
||||
- **Current platform:** `make miniflux`
|
||||
- **All platforms:** `make build`
|
||||
- **Specific platforms:** `make linux-amd64`, `make darwin-arm64`, etc.
|
||||
- **Docker image:** `make docker-image`
|
||||
|
||||
### Cross-Platform Support
|
||||
|
||||
Miniflux supports multiple architectures. When making changes, ensure compatibility across:
|
||||
- Linux (amd64, arm64, armv7, armv6, armv5)
|
||||
- macOS (amd64, arm64)
|
||||
- FreeBSD, OpenBSD, Windows (amd64)
|
||||
|
||||
## Pull Request Guidelines
|
||||
|
||||
### What Is Preferred
|
||||
|
||||
✅ **Good Pull Requests:**
|
||||
|
||||
- Focus on a single issue or feature
|
||||
- Include tests for new functionality
|
||||
- Maintain or improve performance
|
||||
- Follow existing code style and patterns
|
||||
- The commit messages follow the [conventional commit format](https://www.conventionalcommits.org/) (e.g., `feat: add new feature`, `fix: resolve bug`)
|
||||
- Update documentation when necessary
|
||||
|
||||
### What to Avoid
|
||||
|
||||
❌ **Pull Requests That Cannot Be Accepted:**
|
||||
|
||||
- **Too many changes** - makes review difficult
|
||||
- **Breaking changes** - disrupts existing functionality
|
||||
- **New bugs or regressions** - reduces software quality
|
||||
- **Unnecessary dependencies** - conflicts with minimalist approach
|
||||
- **Performance degradation** - slows down the software
|
||||
- **Poor-quality code** - hard to maintain
|
||||
- **Dependent PRs** - creates review complexity
|
||||
- **Radical UI changes** - disrupts user experience
|
||||
- **Conflicts with philosophy** - doesn't align with minimalist approach
|
||||
|
||||
### Pull Request Template
|
||||
|
||||
When creating a pull request, please include:
|
||||
|
||||
- **Description:** What does this PR do?
|
||||
- **Motivation:** Why is this change needed?
|
||||
- **Testing:** How was this tested?
|
||||
- **Breaking Changes:** Are there any breaking changes?
|
||||
- **Related Issues:** Link to any related issues
|
||||
|
||||
## Code Style
|
||||
|
||||
- Follow Go conventions and best practices
|
||||
- Use `gofmt` to format your Go code, and `jshint` for JavaScript
|
||||
- Write clear, descriptive variable and function names
|
||||
- Include comments for complex logic
|
||||
- Keep functions small and focused
|
||||
|
||||
## Testing
|
||||
|
||||
### Unit Tests
|
||||
- Write unit tests for new functions and methods
|
||||
- Ensure tests are fast and don't require external dependencies
|
||||
- Aim for good test coverage
|
||||
|
||||
### Integration Tests
|
||||
- Add integration tests for new API endpoints
|
||||
- Tests run against a real PostgreSQL database
|
||||
- Ensure tests clean up after themselves
|
||||
|
||||
## Communication
|
||||
|
||||
- **Discussions:** Use GitHub Discussions for general questions and community interaction
|
||||
- **Issues:** Use GitHub issues for bug reports and feature requests
|
||||
- **Pull Requests:** Use PR comments for code-specific discussions
|
||||
- **Philosophy Questions:** Refer to the FAQ for common questions about project direction
|
||||
|
||||
## Questions?
|
||||
|
||||
- Check the [FAQ](https://miniflux.app/faq.html) for common questions
|
||||
- Review the [development documentation](https://miniflux.app/docs/development.html) and [internationalization guide](https://miniflux.app/docs/i18n.html)
|
||||
- Look at existing issues and pull requests for examples
|
||||
@@ -1,9 +1,7 @@
|
||||
APP := miniflux
|
||||
DOCKER_IMAGE := miniflux/miniflux
|
||||
VERSION := $(shell git describe --tags --abbrev=0 2>/dev/null)
|
||||
COMMIT := $(shell git rev-parse --short HEAD 2>/dev/null)
|
||||
BUILD_DATE := `date +%FT%T%z`
|
||||
LD_FLAGS := "-s -w -X 'miniflux.app/v2/internal/version.Version=$(VERSION)' -X 'miniflux.app/v2/internal/version.Commit=$(COMMIT)' -X 'miniflux.app/v2/internal/version.BuildDate=$(BUILD_DATE)'"
|
||||
VERSION := $(shell git describe --tags --exact-match 2>/dev/null)
|
||||
LD_FLAGS := "-s -w -X 'miniflux.app/v2/internal/version.Version=$(VERSION)'"
|
||||
PKG_LIST := $(shell go list ./... | grep -v /vendor/)
|
||||
DB_URL := postgres://postgres:postgres@localhost/miniflux_test?sslmode=disable
|
||||
DOCKER_PLATFORM := amd64
|
||||
@@ -22,16 +20,12 @@ export PGPASSWORD := postgres
|
||||
darwin-amd64 \
|
||||
darwin-arm64 \
|
||||
freebsd-amd64 \
|
||||
freebsd-x86 \
|
||||
openbsd-amd64 \
|
||||
openbsd-x86 \
|
||||
netbsd-x86 \
|
||||
netbsd-amd64 \
|
||||
windows-amd64 \
|
||||
windows-x86 \
|
||||
build \
|
||||
run \
|
||||
clean \
|
||||
add-string \
|
||||
test \
|
||||
lint \
|
||||
integration-test \
|
||||
@@ -44,71 +38,48 @@ export PGPASSWORD := postgres
|
||||
debian-packages
|
||||
|
||||
miniflux:
|
||||
@ go build -buildmode=pie -ldflags=$(LD_FLAGS) -o $(APP) main.go
|
||||
@ go build -buildmode=pie -ldflags=$(LD_FLAGS) -o $(APP)
|
||||
|
||||
miniflux-no-pie:
|
||||
@ go build -ldflags=$(LD_FLAGS) -o $(APP) main.go
|
||||
@ go build -ldflags=$(LD_FLAGS) -o $(APP)
|
||||
|
||||
linux-amd64:
|
||||
@ CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@ main.go
|
||||
@ CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@
|
||||
@ sha256sum $(APP)-$@ > $(APP)-$@.sha256
|
||||
|
||||
linux-arm64:
|
||||
@ CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@ main.go
|
||||
@ CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@
|
||||
@ sha256sum $(APP)-$@ > $(APP)-$@.sha256
|
||||
|
||||
linux-armv7:
|
||||
@ CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=7 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@ main.go
|
||||
@ CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=7 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@
|
||||
@ sha256sum $(APP)-$@ > $(APP)-$@.sha256
|
||||
|
||||
linux-armv6:
|
||||
@ CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=6 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@ main.go
|
||||
@ CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=6 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@
|
||||
@ sha256sum $(APP)-$@ > $(APP)-$@.sha256
|
||||
|
||||
linux-armv5:
|
||||
@ CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=5 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@ main.go
|
||||
@ CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=5 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@
|
||||
@ sha256sum $(APP)-$@ > $(APP)-$@.sha256
|
||||
|
||||
darwin-amd64:
|
||||
@ GOOS=darwin GOARCH=amd64 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@ main.go
|
||||
@ GOOS=darwin GOARCH=amd64 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@
|
||||
@ sha256sum $(APP)-$@ > $(APP)-$@.sha256
|
||||
|
||||
darwin-arm64:
|
||||
@ GOOS=darwin GOARCH=arm64 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@ main.go
|
||||
@ GOOS=darwin GOARCH=arm64 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@
|
||||
@ sha256sum $(APP)-$@ > $(APP)-$@.sha256
|
||||
|
||||
freebsd-amd64:
|
||||
@ CGO_ENABLED=0 GOOS=freebsd GOARCH=amd64 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@ main.go
|
||||
@ CGO_ENABLED=0 GOOS=freebsd GOARCH=amd64 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@
|
||||
@ sha256sum $(APP)-$@ > $(APP)-$@.sha256
|
||||
|
||||
openbsd-amd64:
|
||||
@ GOOS=openbsd GOARCH=amd64 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@ main.go
|
||||
@ GOOS=openbsd GOARCH=amd64 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@
|
||||
@ sha256sum $(APP)-$@ > $(APP)-$@.sha256
|
||||
|
||||
windows-amd64:
|
||||
@ GOOS=windows GOARCH=amd64 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@.exe main.go
|
||||
@ sha256sum $(APP)-$@.exe > $(APP)-$@.exe.sha256
|
||||
|
||||
build: linux-amd64 linux-arm64 linux-armv7 linux-armv6 linux-armv5 darwin-amd64 darwin-arm64 freebsd-amd64 openbsd-amd64 windows-amd64
|
||||
|
||||
# NOTE: unsupported targets
|
||||
netbsd-amd64:
|
||||
@ CGO_ENABLED=0 GOOS=netbsd GOARCH=amd64 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@ main.go
|
||||
|
||||
linux-x86:
|
||||
@ CGO_ENABLED=0 GOOS=linux GOARCH=386 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@ main.go
|
||||
|
||||
freebsd-x86:
|
||||
@ CGO_ENABLED=0 GOOS=freebsd GOARCH=386 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@ main.go
|
||||
|
||||
netbsd-x86:
|
||||
@ CGO_ENABLED=0 GOOS=netbsd GOARCH=386 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@ main.go
|
||||
|
||||
openbsd-x86:
|
||||
@ GOOS=openbsd GOARCH=386 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@ main.go
|
||||
|
||||
windows-x86:
|
||||
@ GOOS=windows GOARCH=386 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@.exe main.go
|
||||
build: linux-amd64 linux-arm64 linux-armv7 linux-armv6 linux-armv5 darwin-amd64 darwin-arm64 freebsd-amd64 openbsd-amd64
|
||||
|
||||
run:
|
||||
@ LOG_DATE_TIME=1 LOG_LEVEL=debug RUN_MIGRATIONS=1 CREATE_ADMIN=1 ADMIN_USERNAME=admin ADMIN_PASSWORD=test123 go run main.go
|
||||
@@ -116,7 +87,6 @@ run:
|
||||
clean:
|
||||
@ rm -f $(APP)-* $(APP) $(APP)*.rpm $(APP)*.deb $(APP)*.exe $(APP)*.sha256
|
||||
|
||||
.PHONY: add-string
|
||||
add-string:
|
||||
cd internal/locale/translations && \
|
||||
for file in *.json; do \
|
||||
@@ -125,7 +95,6 @@ add-string:
|
||||
mv tmp "$$file"; \
|
||||
done
|
||||
|
||||
|
||||
test:
|
||||
go test -cover -race -count=1 ./...
|
||||
|
||||
@@ -137,7 +106,6 @@ lint:
|
||||
integration-test:
|
||||
psql -U postgres -c 'drop database if exists miniflux_test;'
|
||||
psql -U postgres -c 'create database miniflux_test;'
|
||||
go build -o miniflux-test main.go
|
||||
|
||||
DATABASE_URL=$(DB_URL) \
|
||||
ADMIN_USERNAME=admin \
|
||||
@@ -145,7 +113,7 @@ integration-test:
|
||||
CREATE_ADMIN=1 \
|
||||
RUN_MIGRATIONS=1 \
|
||||
LOG_LEVEL=debug \
|
||||
./miniflux-test >/tmp/miniflux.log 2>&1 & echo "$$!" > "/tmp/miniflux.pid"
|
||||
go run main.go >/tmp/miniflux.log 2>&1 & echo "$$!" > "/tmp/miniflux.pid"
|
||||
|
||||
while ! nc -z localhost 8080; do sleep 1; done
|
||||
|
||||
@@ -157,7 +125,6 @@ integration-test:
|
||||
clean-integration-test:
|
||||
@ kill -9 `cat /tmp/miniflux.pid`
|
||||
@ rm -f /tmp/miniflux.pid /tmp/miniflux.log
|
||||
@ rm miniflux-test
|
||||
@ psql -U postgres -c 'drop database if exists miniflux_test;'
|
||||
|
||||
docker-image:
|
||||
|
||||
@@ -22,7 +22,7 @@ Features
|
||||
- Provides full-text search (powered by Postgres).
|
||||
- Available in 20 languages: Portuguese (Brazilian), Chinese (Simplified and Traditional), Dutch, English (US), Finnish, French, German, Greek, Hindi, Indonesian, Italian, Japanese, Polish, Romanian, Russian, Taiwanese POJ, Ukrainian, Spanish, and Turkish.
|
||||
|
||||
### Privacy
|
||||
### Privacy and Security
|
||||
|
||||
- Removes pixel trackers.
|
||||
- Strips tracking parameters from URLs (e.g., `utm_source`, `utm_medium`, `utm_campaign`, `fbclid`, etc.).
|
||||
@@ -33,6 +33,8 @@ Features
|
||||
- Plays YouTube videos via the privacy-focused domain `youtube-nocookie.com`.
|
||||
- Supports alternative YouTube video players such as [Invidious](https://invidio.us).
|
||||
- Blocks external JavaScript to prevent tracking and enhance security.
|
||||
- Sanitizes external content before rendering it.
|
||||
- Enforces a [Content Security](https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP) and a [Trusted Types Policy](https://developer.mozilla.org/en-US/docs/Web/API/Trusted_Types_API) to only application JavaScript and blocks inline scripts and styles.
|
||||
|
||||
### Bot Protection Bypass Mechanisms
|
||||
|
||||
@@ -70,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/), [Pocket](https://getpocket.com/), [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.
|
||||
@@ -97,13 +99,15 @@ Features
|
||||
- Allows the use of custom <abbr title="Secure Sockets Layer">SSL</abbr> certificates.
|
||||
- Supports [HTTP/2](https://en.wikipedia.org/wiki/HTTP/2) when TLS is enabled.
|
||||
- Updates feeds in the background using an internal scheduler or a traditional cron job.
|
||||
- Sanitizes external content before rendering it.
|
||||
- Enforces a [Content Security Policy](https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP) that permits only application JavaScript and blocks inline scripts and styles.
|
||||
- Uses native lazy loading for images and iframes.
|
||||
- Compatible only with modern browsers.
|
||||
- Adheres to the [Twelve-Factor App](https://12factor.net/) methodology.
|
||||
- Provides official Debian/RPM packages and pre-built binaries.
|
||||
- Publishes a Docker image to Docker Hub, GitHub Registry, and Quay.io Registry, with ARM architecture support.
|
||||
- Uses a limited amount of third-party go dependencies
|
||||
- Has a comprehensive testsuite, with both unit tests and integration tests.
|
||||
- Only uses a couple of MB of memory and a negligible amount of CPU, even with several hundreds of feeds.
|
||||
- Respects/sends Last-Modified, If-Modified-Since, If-None-Match, Cache-Control, Expires and ETags headers, and has a default polling interval of 1h.
|
||||
|
||||
Documentation
|
||||
-------------
|
||||
|
||||
+19
-3
@@ -276,6 +276,22 @@ func (c *Client) Categories() (Categories, error) {
|
||||
return categories, nil
|
||||
}
|
||||
|
||||
// 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")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer body.Close()
|
||||
|
||||
var categories Categories
|
||||
if err := json.NewDecoder(body).Decode(&categories); err != nil {
|
||||
return nil, fmt.Errorf("miniflux: response error (%v)", err)
|
||||
}
|
||||
|
||||
return categories, nil
|
||||
}
|
||||
|
||||
// CreateCategory creates a new category.
|
||||
func (c *Client) CreateCategory(title string) (*Category, error) {
|
||||
body, err := c.request.Post("/v1/categories", &CategoryCreationRequest{
|
||||
@@ -634,9 +650,9 @@ 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 entry starred value.
|
||||
func (c *Client) ToggleStarred(entryID int64) error {
|
||||
_, err := c.request.Put(fmt.Sprintf("/v1/entries/%d/star", entryID), nil)
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
+69
-58
@@ -17,36 +17,37 @@ const (
|
||||
|
||||
// User represents a user in the system.
|
||||
type User struct {
|
||||
ID int64 `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password,omitempty"`
|
||||
IsAdmin bool `json:"is_admin"`
|
||||
Theme string `json:"theme"`
|
||||
Language string `json:"language"`
|
||||
Timezone string `json:"timezone"`
|
||||
EntryDirection string `json:"entry_sorting_direction"`
|
||||
EntryOrder string `json:"entry_sorting_order"`
|
||||
Stylesheet string `json:"stylesheet"`
|
||||
CustomJS string `json:"custom_js"`
|
||||
GoogleID string `json:"google_id"`
|
||||
OpenIDConnectID string `json:"openid_connect_id"`
|
||||
EntriesPerPage int `json:"entries_per_page"`
|
||||
KeyboardShortcuts bool `json:"keyboard_shortcuts"`
|
||||
ShowReadingTime bool `json:"show_reading_time"`
|
||||
EntrySwipe bool `json:"entry_swipe"`
|
||||
GestureNav string `json:"gesture_nav"`
|
||||
LastLoginAt *time.Time `json:"last_login_at"`
|
||||
DisplayMode string `json:"display_mode"`
|
||||
DefaultReadingSpeed int `json:"default_reading_speed"`
|
||||
CJKReadingSpeed int `json:"cjk_reading_speed"`
|
||||
DefaultHomePage string `json:"default_home_page"`
|
||||
CategoriesSortingOrder string `json:"categories_sorting_order"`
|
||||
MarkReadOnView bool `json:"mark_read_on_view"`
|
||||
MediaPlaybackRate float64 `json:"media_playback_rate"`
|
||||
BlockFilterEntryRules string `json:"block_filter_entry_rules"`
|
||||
KeepFilterEntryRules string `json:"keep_filter_entry_rules"`
|
||||
ExternalFontHosts string `json:"external_font_hosts"`
|
||||
AlwaysOpenExternalLinks bool `json:"always_open_external_links"`
|
||||
ID int64 `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password,omitempty"`
|
||||
IsAdmin bool `json:"is_admin"`
|
||||
Theme string `json:"theme"`
|
||||
Language string `json:"language"`
|
||||
Timezone string `json:"timezone"`
|
||||
EntryDirection string `json:"entry_sorting_direction"`
|
||||
EntryOrder string `json:"entry_sorting_order"`
|
||||
Stylesheet string `json:"stylesheet"`
|
||||
CustomJS string `json:"custom_js"`
|
||||
GoogleID string `json:"google_id"`
|
||||
OpenIDConnectID string `json:"openid_connect_id"`
|
||||
EntriesPerPage int `json:"entries_per_page"`
|
||||
KeyboardShortcuts bool `json:"keyboard_shortcuts"`
|
||||
ShowReadingTime bool `json:"show_reading_time"`
|
||||
EntrySwipe bool `json:"entry_swipe"`
|
||||
GestureNav string `json:"gesture_nav"`
|
||||
LastLoginAt *time.Time `json:"last_login_at"`
|
||||
DisplayMode string `json:"display_mode"`
|
||||
DefaultReadingSpeed int `json:"default_reading_speed"`
|
||||
CJKReadingSpeed int `json:"cjk_reading_speed"`
|
||||
DefaultHomePage string `json:"default_home_page"`
|
||||
CategoriesSortingOrder string `json:"categories_sorting_order"`
|
||||
MarkReadOnView bool `json:"mark_read_on_view"`
|
||||
MediaPlaybackRate float64 `json:"media_playback_rate"`
|
||||
BlockFilterEntryRules string `json:"block_filter_entry_rules"`
|
||||
KeepFilterEntryRules string `json:"keep_filter_entry_rules"`
|
||||
ExternalFontHosts string `json:"external_font_hosts"`
|
||||
AlwaysOpenExternalLinks bool `json:"always_open_external_links"`
|
||||
OpenExternalLinksInNewTab bool `json:"open_external_links_in_new_tab"`
|
||||
}
|
||||
|
||||
func (u User) String() string {
|
||||
@@ -64,34 +65,35 @@ type UserCreationRequest struct {
|
||||
|
||||
// UserModificationRequest represents the request to update a user.
|
||||
type UserModificationRequest struct {
|
||||
Username *string `json:"username"`
|
||||
Password *string `json:"password"`
|
||||
IsAdmin *bool `json:"is_admin"`
|
||||
Theme *string `json:"theme"`
|
||||
Language *string `json:"language"`
|
||||
Timezone *string `json:"timezone"`
|
||||
EntryDirection *string `json:"entry_sorting_direction"`
|
||||
EntryOrder *string `json:"entry_sorting_order"`
|
||||
Stylesheet *string `json:"stylesheet"`
|
||||
CustomJS *string `json:"custom_js"`
|
||||
GoogleID *string `json:"google_id"`
|
||||
OpenIDConnectID *string `json:"openid_connect_id"`
|
||||
EntriesPerPage *int `json:"entries_per_page"`
|
||||
KeyboardShortcuts *bool `json:"keyboard_shortcuts"`
|
||||
ShowReadingTime *bool `json:"show_reading_time"`
|
||||
EntrySwipe *bool `json:"entry_swipe"`
|
||||
GestureNav *string `json:"gesture_nav"`
|
||||
DisplayMode *string `json:"display_mode"`
|
||||
DefaultReadingSpeed *int `json:"default_reading_speed"`
|
||||
CJKReadingSpeed *int `json:"cjk_reading_speed"`
|
||||
DefaultHomePage *string `json:"default_home_page"`
|
||||
CategoriesSortingOrder *string `json:"categories_sorting_order"`
|
||||
MarkReadOnView *bool `json:"mark_read_on_view"`
|
||||
MediaPlaybackRate *float64 `json:"media_playback_rate"`
|
||||
BlockFilterEntryRules *string `json:"block_filter_entry_rules"`
|
||||
KeepFilterEntryRules *string `json:"keep_filter_entry_rules"`
|
||||
ExternalFontHosts *string `json:"external_font_hosts"`
|
||||
AlwaysOpenExternalLinks *bool `json:"always_open_external_links"`
|
||||
Username *string `json:"username"`
|
||||
Password *string `json:"password"`
|
||||
IsAdmin *bool `json:"is_admin"`
|
||||
Theme *string `json:"theme"`
|
||||
Language *string `json:"language"`
|
||||
Timezone *string `json:"timezone"`
|
||||
EntryDirection *string `json:"entry_sorting_direction"`
|
||||
EntryOrder *string `json:"entry_sorting_order"`
|
||||
Stylesheet *string `json:"stylesheet"`
|
||||
CustomJS *string `json:"custom_js"`
|
||||
GoogleID *string `json:"google_id"`
|
||||
OpenIDConnectID *string `json:"openid_connect_id"`
|
||||
EntriesPerPage *int `json:"entries_per_page"`
|
||||
KeyboardShortcuts *bool `json:"keyboard_shortcuts"`
|
||||
ShowReadingTime *bool `json:"show_reading_time"`
|
||||
EntrySwipe *bool `json:"entry_swipe"`
|
||||
GestureNav *string `json:"gesture_nav"`
|
||||
DisplayMode *string `json:"display_mode"`
|
||||
DefaultReadingSpeed *int `json:"default_reading_speed"`
|
||||
CJKReadingSpeed *int `json:"cjk_reading_speed"`
|
||||
DefaultHomePage *string `json:"default_home_page"`
|
||||
CategoriesSortingOrder *string `json:"categories_sorting_order"`
|
||||
MarkReadOnView *bool `json:"mark_read_on_view"`
|
||||
MediaPlaybackRate *float64 `json:"media_playback_rate"`
|
||||
BlockFilterEntryRules *string `json:"block_filter_entry_rules"`
|
||||
KeepFilterEntryRules *string `json:"keep_filter_entry_rules"`
|
||||
ExternalFontHosts *string `json:"external_font_hosts"`
|
||||
AlwaysOpenExternalLinks *bool `json:"always_open_external_links"`
|
||||
OpenExternalLinksInNewTab *bool `json:"open_external_links_in_new_tab"`
|
||||
}
|
||||
|
||||
// Users represents a list of users.
|
||||
@@ -158,8 +160,11 @@ type Feed struct {
|
||||
FetchViaProxy bool `json:"fetch_via_proxy"`
|
||||
ScraperRules string `json:"scraper_rules"`
|
||||
RewriteRules string `json:"rewrite_rules"`
|
||||
UrlRewriteRules string `json:"urlrewrite_rules"`
|
||||
BlocklistRules string `json:"blocklist_rules"`
|
||||
KeeplistRules string `json:"keeplist_rules"`
|
||||
BlockFilterEntryRules string `json:"block_filter_entry_rules"`
|
||||
KeepFilterEntryRules string `json:"keep_filter_entry_rules"`
|
||||
Crawler bool `json:"crawler"`
|
||||
UserAgent string `json:"user_agent"`
|
||||
Cookie string `json:"cookie"`
|
||||
@@ -186,8 +191,11 @@ type FeedCreationRequest struct {
|
||||
FetchViaProxy bool `json:"fetch_via_proxy"`
|
||||
ScraperRules string `json:"scraper_rules"`
|
||||
RewriteRules string `json:"rewrite_rules"`
|
||||
UrlRewriteRules string `json:"urlrewrite_rules"`
|
||||
BlocklistRules string `json:"blocklist_rules"`
|
||||
KeeplistRules string `json:"keeplist_rules"`
|
||||
BlockFilterEntryRules string `json:"block_filter_entry_rules"`
|
||||
KeepFilterEntryRules string `json:"keep_filter_entry_rules"`
|
||||
HideGlobally bool `json:"hide_globally"`
|
||||
DisableHTTP2 bool `json:"disable_http2"`
|
||||
ProxyURL string `json:"proxy_url"`
|
||||
@@ -200,8 +208,11 @@ type FeedModificationRequest struct {
|
||||
Title *string `json:"title"`
|
||||
ScraperRules *string `json:"scraper_rules"`
|
||||
RewriteRules *string `json:"rewrite_rules"`
|
||||
UrlRewriteRules *string `json:"urlrewrite_rules"`
|
||||
BlocklistRules *string `json:"blocklist_rules"`
|
||||
KeeplistRules *string `json:"keeplist_rules"`
|
||||
BlockFilterEntryRules *string `json:"block_filter_entry_rules"`
|
||||
KeepFilterEntryRules *string `json:"keep_filter_entry_rules"`
|
||||
Crawler *bool `json:"crawler"`
|
||||
UserAgent *string `json:"user_agent"`
|
||||
Cookie *string `json:"cookie"`
|
||||
|
||||
+4
-4
@@ -45,7 +45,7 @@ func (r *request) Get(path string) (io.ReadCloser, error) {
|
||||
return r.execute(http.MethodGet, path, nil)
|
||||
}
|
||||
|
||||
func (r *request) Post(path string, data interface{}) (io.ReadCloser, error) {
|
||||
func (r *request) Post(path string, data any) (io.ReadCloser, error) {
|
||||
return r.execute(http.MethodPost, path, data)
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@ func (r *request) PostFile(path string, f io.ReadCloser) (io.ReadCloser, error)
|
||||
return r.execute(http.MethodPost, path, f)
|
||||
}
|
||||
|
||||
func (r *request) Put(path string, data interface{}) (io.ReadCloser, error) {
|
||||
func (r *request) Put(path string, data any) (io.ReadCloser, error) {
|
||||
return r.execute(http.MethodPut, path, data)
|
||||
}
|
||||
|
||||
@@ -62,7 +62,7 @@ func (r *request) Delete(path string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *request) execute(method, path string, data interface{}) (io.ReadCloser, error) {
|
||||
func (r *request) execute(method, path string, data any) (io.ReadCloser, error) {
|
||||
if r.endpoint == "" {
|
||||
return nil, ErrEmptyEndpoint
|
||||
}
|
||||
@@ -160,7 +160,7 @@ func (r *request) buildHeaders() http.Header {
|
||||
return headers
|
||||
}
|
||||
|
||||
func (r *request) toJSON(v interface{}) []byte {
|
||||
func (r *request) toJSON(v any) []byte {
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
log.Println("Unable to convert interface to JSON:", err)
|
||||
|
||||
@@ -19,7 +19,7 @@ services:
|
||||
# healthcheck:
|
||||
# test: ["CMD", "/usr/bin/miniflux", "-healthcheck", "auto"]
|
||||
db:
|
||||
image: postgres:15
|
||||
image: postgres:latest
|
||||
container_name: postgres
|
||||
environment:
|
||||
- POSTGRES_USER=miniflux
|
||||
|
||||
@@ -25,7 +25,7 @@ services:
|
||||
- ADMIN_PASSWORD=test123
|
||||
- BASE_URL=https://miniflux.example.org
|
||||
db:
|
||||
image: postgres:15
|
||||
image: postgres:latest
|
||||
container_name: postgres
|
||||
environment:
|
||||
- POSTGRES_USER=miniflux
|
||||
|
||||
@@ -37,7 +37,7 @@ services:
|
||||
- "traefik.http.routers.miniflux.entrypoints=websecure"
|
||||
- "traefik.http.routers.miniflux.tls.certresolver=myresolver"
|
||||
db:
|
||||
image: postgres:15
|
||||
image: postgres:latest
|
||||
container_name: postgres
|
||||
environment:
|
||||
- POSTGRES_USER=miniflux
|
||||
|
||||
@@ -1,27 +1,26 @@
|
||||
module miniflux.app/v2
|
||||
|
||||
// +heroku goVersion go1.23
|
||||
// +heroku goVersion go1.24
|
||||
|
||||
require (
|
||||
github.com/PuerkitoBio/goquery v1.10.3
|
||||
github.com/andybalholm/brotli v1.1.1
|
||||
github.com/coreos/go-oidc/v3 v3.14.1
|
||||
github.com/go-webauthn/webauthn v0.13.0
|
||||
github.com/andybalholm/brotli v1.2.0
|
||||
github.com/coreos/go-oidc/v3 v3.15.0
|
||||
github.com/go-webauthn/webauthn v0.14.0
|
||||
github.com/gorilla/mux v1.8.1
|
||||
github.com/lib/pq v1.10.9
|
||||
github.com/mattn/go-sqlite3 v1.14.28
|
||||
github.com/prometheus/client_golang v1.22.0
|
||||
github.com/tdewolff/minify/v2 v2.23.8
|
||||
golang.org/x/crypto v0.38.0
|
||||
golang.org/x/image v0.27.0
|
||||
golang.org/x/net v0.40.0
|
||||
golang.org/x/oauth2 v0.30.0
|
||||
golang.org/x/term v0.32.0
|
||||
github.com/prometheus/client_golang v1.23.2
|
||||
github.com/tdewolff/minify/v2 v2.24.3
|
||||
golang.org/x/crypto v0.42.0
|
||||
golang.org/x/image v0.31.0
|
||||
golang.org/x/net v0.44.0
|
||||
golang.org/x/oauth2 v0.31.0
|
||||
golang.org/x/term v0.35.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/go-webauthn/x v0.1.21 // indirect
|
||||
github.com/golang-jwt/jwt/v5 v5.2.2 // indirect
|
||||
github.com/go-webauthn/x v0.1.25 // indirect
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0 // indirect
|
||||
github.com/google/go-tpm v0.9.5 // indirect
|
||||
)
|
||||
|
||||
@@ -29,21 +28,23 @@ require (
|
||||
github.com/andybalholm/cascadia v1.3.3 // indirect
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/fxamacker/cbor/v2 v2.8.0 // indirect
|
||||
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
|
||||
github.com/go-jose/go-jose/v4 v4.0.5 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/kr/text v0.2.0 // indirect
|
||||
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||
github.com/prometheus/client_model v0.6.1 // indirect
|
||||
github.com/prometheus/common v0.62.0 // indirect
|
||||
github.com/prometheus/procfs v0.15.1 // indirect
|
||||
github.com/tdewolff/parse/v2 v2.8.1 // indirect
|
||||
github.com/prometheus/client_model v0.6.2 // indirect
|
||||
github.com/prometheus/common v0.66.1 // indirect
|
||||
github.com/prometheus/procfs v0.16.1 // indirect
|
||||
github.com/tdewolff/parse/v2 v2.8.3 // indirect
|
||||
github.com/x448/float16 v0.8.4 // indirect
|
||||
golang.org/x/sys v0.33.0 // indirect
|
||||
golang.org/x/text v0.25.0 // indirect
|
||||
google.golang.org/protobuf v1.36.5 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.2 // indirect
|
||||
golang.org/x/sys v0.36.0 // indirect
|
||||
golang.org/x/text v0.29.0 // indirect
|
||||
google.golang.org/protobuf v1.36.8 // indirect
|
||||
)
|
||||
|
||||
go 1.23.0
|
||||
go 1.24.0
|
||||
|
||||
toolchain go1.24.1
|
||||
|
||||
@@ -1,27 +1,28 @@
|
||||
github.com/PuerkitoBio/goquery v1.10.3 h1:pFYcNSqHxBD06Fpj/KsbStFRsgRATgnf3LeXiUkhzPo=
|
||||
github.com/PuerkitoBio/goquery v1.10.3/go.mod h1:tMUX0zDMHXYlAQk6p35XxQMqMweEKB7iK7iLNd4RH4Y=
|
||||
github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA=
|
||||
github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA=
|
||||
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
|
||||
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||
github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
|
||||
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
|
||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/coreos/go-oidc/v3 v3.14.1 h1:9ePWwfdwC4QKRlCXsJGou56adA/owXczOzwKdOumLqk=
|
||||
github.com/coreos/go-oidc/v3 v3.14.1/go.mod h1:HaZ3szPaZ0e4r6ebqvsLWlk2Tn+aejfmrfah6hnSYEU=
|
||||
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/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.8.0 h1:fFtUGXUzXPHTIUdne5+zzMPTfffl3RD5qYnkY40vtxU=
|
||||
github.com/fxamacker/cbor/v2 v2.8.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
|
||||
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.0 h1:cJIL1/1l+22UekVhipziAaSgESJxokYkowUqAIsWs0Y=
|
||||
github.com/go-webauthn/webauthn v0.13.0/go.mod h1:Oy9o2o79dbLKRPZWWgRIOdtBGAhKnDIaBp2PFkICRHs=
|
||||
github.com/go-webauthn/x v0.1.21 h1:nFbckQxudvHEJn2uy1VEi713MeSpApoAv9eRqsb9AdQ=
|
||||
github.com/go-webauthn/x v0.1.21/go.mod h1:sEYohtg1zL4An1TXIUIQ5csdmoO+WO0R4R2pGKaHYKA=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/go-webauthn/webauthn v0.14.0 h1:ZLNPUgPcDlAeoxe+5umWG/tEeCoQIDr7gE2Zx2QnhL0=
|
||||
github.com/go-webauthn/webauthn v0.14.0/go.mod h1:QZzPFH3LJ48u5uEPAu+8/nWJImoLBWM7iAH/kSVSo6k=
|
||||
github.com/go-webauthn/x v0.1.25 h1:g/0noooIGcz/yCVqebcFgNnGIgBlJIccS+LYAa+0Z88=
|
||||
github.com/go-webauthn/x v0.1.25/go.mod h1:ieblaPY1/BVCV0oQTsA/VAo08/TWayQuJuo5Q+XxmTY=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/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=
|
||||
@@ -33,32 +34,36 @@ 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/mattn/go-sqlite3 v1.14.28 h1:ThEiQrnbtumT+QMknw63Befp/ce/nUPgBPMlRFEum7A=
|
||||
github.com/mattn/go-sqlite3 v1.14.28/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
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/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.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/Y92Vm0Zc6Q=
|
||||
github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0=
|
||||
github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=
|
||||
github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY=
|
||||
github.com/prometheus/common v0.62.0 h1:xasJaQlnWAeyHdUBeGjXmutelfJHWMRr+Fg4QszZ2Io=
|
||||
github.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkqml0W5zIY1I=
|
||||
github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc=
|
||||
github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk=
|
||||
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.8 h1:tvjHzRer46kwOfpdCBCWsDblCw3QtnLJRd61pTVkyZ8=
|
||||
github.com/tdewolff/minify/v2 v2.23.8/go.mod h1:VW3ISUd3gDOZuQ/jwZr4sCzsuX+Qvsx87FDMjk6Rvno=
|
||||
github.com/tdewolff/parse/v2 v2.8.1 h1:J5GSHru6o3jF1uLlEKVXkDxxcVx6yzOlIVIotK4w2po=
|
||||
github.com/tdewolff/parse/v2 v2.8.1/go.mod h1:Hwlni2tiVNKyzR1o6nUs4FOF07URA+JLBLd6dlIXYqo=
|
||||
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
|
||||
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
|
||||
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
|
||||
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
|
||||
github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs=
|
||||
github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA=
|
||||
github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg=
|
||||
github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
|
||||
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
|
||||
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/tdewolff/minify/v2 v2.24.3 h1:BaKgWSFLKbKDiUskbeRgbe2n5d1Ci1x3cN/eXna8zOA=
|
||||
github.com/tdewolff/minify/v2 v2.24.3/go.mod h1:1JrCtoZXaDbqioQZfk3Jdmr0GPJKiU7c1Apmb+7tCeE=
|
||||
github.com/tdewolff/parse/v2 v2.8.3 h1:5VbvtJ83cfb289A1HzRA9sf02iT8YyUwN84ezjkdY1I=
|
||||
github.com/tdewolff/parse/v2 v2.8.3/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 +71,22 @@ github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcY
|
||||
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
||||
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
||||
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
||||
go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
|
||||
go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
|
||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||
golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8=
|
||||
golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw=
|
||||
golang.org/x/image v0.27.0 h1:C8gA4oWU/tKkdCfYT6T2u4faJu3MeNS5O8UPWlPF61w=
|
||||
golang.org/x/image v0.27.0/go.mod h1:xbdrClrAUway1MUTEZDq9mz/UpRwYAkFFNUslZtcB+g=
|
||||
golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI=
|
||||
golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8=
|
||||
golang.org/x/image v0.31.0 h1:mLChjE2MV6g1S7oqbXC0/UcKijjm5fnJLUYKIYrLESA=
|
||||
golang.org/x/image v0.31.0/go.mod h1:R9ec5Lcp96v9FTF+ajwaH3uGxPH4fKfHHAVbUILxghA=
|
||||
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.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY=
|
||||
golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds=
|
||||
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.44.0 h1:evd8IRDyfNBMBTTY5XRF1vaZlD+EmWx6x8PkhR04H/I=
|
||||
golang.org/x/net v0.44.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY=
|
||||
golang.org/x/oauth2 v0.31.0 h1:8Fq0yVZLh4j4YA47vHKFTa9Ew5XIrCP8LC6UeNZnLxo=
|
||||
golang.org/x/oauth2 v0.31.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.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
|
||||
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
|
||||
golang.org/x/sys v0.36.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.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg=
|
||||
golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ=
|
||||
golang.org/x/term v0.35.0 h1:bZBVKBudEyhRcajGcNc3jIfWPqV4y/Kt2XcoigOWtDQ=
|
||||
golang.org/x/term v0.35.0/go.mod h1:TPGtkTLesOwf2DE8CgVYiZinHAOuy5AYUYT1lENIZnA=
|
||||
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.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
|
||||
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
|
||||
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
|
||||
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
|
||||
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.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM=
|
||||
google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
|
||||
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=
|
||||
|
||||
+2
-1
@@ -67,7 +67,8 @@ func Serve(router *mux.Router, store *storage.Storage, pool *worker.Pool) {
|
||||
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)
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math/rand"
|
||||
"math/rand/v2"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -1262,6 +1262,14 @@ func TestGetCategoriesEndpoint(t *testing.T) {
|
||||
t.Fatalf(`Invalid title, got %q instead of %q`, categories[0].Title, "All")
|
||||
}
|
||||
|
||||
if categories[0].FeedCount != nil {
|
||||
t.Errorf(`Expected FeedCount to be nil, got %d`, *categories[0].FeedCount)
|
||||
}
|
||||
|
||||
if categories[0].TotalUnread != nil {
|
||||
t.Errorf(`Expected TotalUnread to be nil, got %d`, *categories[0].TotalUnread)
|
||||
}
|
||||
|
||||
if categories[1].ID != category.ID {
|
||||
t.Fatalf(`Invalid categoryID, got %d`, categories[0].ID)
|
||||
}
|
||||
@@ -1273,6 +1281,40 @@ func TestGetCategoriesEndpoint(t *testing.T) {
|
||||
if categories[1].Title != "My category" {
|
||||
t.Fatalf(`Invalid title, got %q instead of %q`, categories[0].Title, "My category")
|
||||
}
|
||||
|
||||
if categories[1].FeedCount != nil {
|
||||
t.Errorf(`Expected FeedCount to be nil, got %d`, *categories[1].FeedCount)
|
||||
}
|
||||
|
||||
if categories[1].TotalUnread != nil {
|
||||
t.Errorf(`Expected TotalUnread to be nil, got %d`, *categories[1].TotalUnread)
|
||||
}
|
||||
|
||||
categories, err = regularUserClient.CategoriesWithCounters()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if len(categories) != 2 {
|
||||
t.Fatalf(`Invalid number of categories, got %d instead of %d`, len(categories), 1)
|
||||
}
|
||||
|
||||
if categories[1].FeedCount == nil {
|
||||
t.Fatalf(`Expected FeedCount to be not nil`)
|
||||
}
|
||||
|
||||
if categories[1].TotalUnread == nil {
|
||||
t.Fatalf(`Expected TotalUnread to be not nil`)
|
||||
}
|
||||
|
||||
expectedCounterValue := 0
|
||||
if *categories[1].FeedCount != expectedCounterValue {
|
||||
t.Errorf(`Expected FeedCount to be %d, got %d`, expectedCounterValue, *categories[1].FeedCount)
|
||||
}
|
||||
|
||||
if *categories[1].TotalUnread != expectedCounterValue {
|
||||
t.Errorf(`Expected TotalUnread to be %d, got %d`, expectedCounterValue, *categories[1].TotalUnread)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkCategoryAsReadEndpoint(t *testing.T) {
|
||||
@@ -2339,7 +2381,6 @@ func TestGetGlobalEntriesEndpoint(t *testing.T) {
|
||||
}
|
||||
|
||||
feedIDEntry, err := regularUserClient.Feed(feedID)
|
||||
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -2371,6 +2412,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() {
|
||||
@@ -2649,7 +2748,7 @@ func TestUpdateEntryEndpoint(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestToggleBookmarkEndpoint(t *testing.T) {
|
||||
func TestToggleStarredEndpoint(t *testing.T) {
|
||||
testConfig := newIntegrationTestConfig()
|
||||
if !testConfig.isConfigured() {
|
||||
t.Skip(skipIntegrationTestsMessage)
|
||||
@@ -2677,7 +2776,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)
|
||||
}
|
||||
|
||||
@@ -2687,7 +2786,7 @@ func TestToggleBookmarkEndpoint(t *testing.T) {
|
||||
}
|
||||
|
||||
if !entry.Starred {
|
||||
t.Fatalf(`The entry should be bookmarked`)
|
||||
t.Fatalf(`The entry should be starred`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2832,13 +2931,4 @@ 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})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if removedEntries.Total != 2 {
|
||||
t.Fatalf(`Invalid total, got %d`, removedEntries.Total)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,6 +143,7 @@ func (h *handler) refreshCategory(w http.ResponseWriter, r *http.Request) {
|
||||
batchBuilder.WithUserID(userID)
|
||||
batchBuilder.WithCategoryID(categoryID)
|
||||
batchBuilder.WithNextCheckExpired()
|
||||
batchBuilder.WithLimitPerHost(config.Opts.PollingLimitPerHost())
|
||||
|
||||
jobs, err := batchBuilder.FetchJobs()
|
||||
if err != nil {
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
json_parser "encoding/json"
|
||||
"net/http"
|
||||
|
||||
"miniflux.app/v2/internal/config"
|
||||
"miniflux.app/v2/internal/http/request"
|
||||
"miniflux.app/v2/internal/http/response/json"
|
||||
"miniflux.app/v2/internal/model"
|
||||
@@ -33,7 +34,7 @@ func (h *handler) getEnclosureByID(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
enclosure.ProxifyEnclosureURL(h.router)
|
||||
enclosure.ProxifyEnclosureURL(h.router, config.Opts.MediaProxyMode(), config.Opts.MediaProxyResourceTypes())
|
||||
|
||||
json.OK(w, r, enclosure)
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"miniflux.app/v2/internal/config"
|
||||
"miniflux.app/v2/internal/http/request"
|
||||
"miniflux.app/v2/internal/http/response/json"
|
||||
"miniflux.app/v2/internal/integration"
|
||||
@@ -34,8 +35,7 @@ func (h *handler) getEntryFromBuilder(w http.ResponseWriter, r *http.Request, b
|
||||
}
|
||||
|
||||
entry.Content = mediaproxy.RewriteDocumentWithAbsoluteProxyURL(h.router, entry.Content)
|
||||
|
||||
entry.Enclosures.ProxifyEnclosureURL(h.router)
|
||||
entry.Enclosures.ProxifyEnclosureURL(h.router, config.Opts.MediaProxyMode(), config.Opts.MediaProxyResourceTypes())
|
||||
|
||||
json.OK(w, r, entry)
|
||||
}
|
||||
@@ -47,6 +47,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 +59,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 +68,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 +139,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 +190,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
|
||||
}
|
||||
@@ -338,7 +342,7 @@ func (h *handler) fetchContent(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
json.OK(w, r, map[string]interface{}{"content": mediaproxy.RewriteDocumentWithRelativeProxyURL(h.router, entry.Content), "reading_time": entry.ReadingTime})
|
||||
json.OK(w, r, map[string]any{"content": mediaproxy.RewriteDocumentWithRelativeProxyURL(h.router, entry.Content), "reading_time": entry.ReadingTime})
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
@@ -76,6 +76,7 @@ func (h *handler) refreshAllFeeds(w http.ResponseWriter, r *http.Request) {
|
||||
batchBuilder.WithoutDisabledFeeds()
|
||||
batchBuilder.WithNextCheckExpired()
|
||||
batchBuilder.WithUserID(userID)
|
||||
batchBuilder.WithLimitPerHost(config.Opts.PollingLimitPerHost())
|
||||
|
||||
jobs, err := batchBuilder.FetchJobs()
|
||||
if err != nil {
|
||||
@@ -140,13 +141,7 @@ func (h *handler) markFeedAsRead(w http.ResponseWriter, r *http.Request) {
|
||||
feedID := request.RouteInt64Param(r, "feedID")
|
||||
userID := request.UserID(r)
|
||||
|
||||
feed, err := h.store.FeedByID(userID, feedID)
|
||||
if err != nil {
|
||||
json.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if feed == nil {
|
||||
if !h.store.FeedExists(userID, feedID) {
|
||||
json.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ func (m *middleware) apiKeyAuth(next http.Handler) http.Handler {
|
||||
slog.Debug("[API] Skipped API token authentication because no API Key has been provided",
|
||||
slog.String("client_ip", clientIP),
|
||||
slog.String("user_agent", r.UserAgent()),
|
||||
slog.String("request_uri", r.RequestURI),
|
||||
)
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
@@ -59,6 +60,7 @@ func (m *middleware) apiKeyAuth(next http.Handler) http.Handler {
|
||||
slog.Bool("authentication_failed", true),
|
||||
slog.String("client_ip", clientIP),
|
||||
slog.String("user_agent", r.UserAgent()),
|
||||
slog.String("request_uri", r.RequestURI),
|
||||
)
|
||||
json.Unauthorized(w, r)
|
||||
return
|
||||
@@ -69,6 +71,7 @@ func (m *middleware) apiKeyAuth(next http.Handler) http.Handler {
|
||||
slog.String("client_ip", clientIP),
|
||||
slog.String("user_agent", r.UserAgent()),
|
||||
slog.String("username", user.Username),
|
||||
slog.String("request_uri", r.RequestURI),
|
||||
)
|
||||
|
||||
m.store.SetLastLogin(user.ID)
|
||||
@@ -100,6 +103,7 @@ func (m *middleware) basicAuth(next http.Handler) http.Handler {
|
||||
slog.Bool("authentication_failed", true),
|
||||
slog.String("client_ip", clientIP),
|
||||
slog.String("user_agent", r.UserAgent()),
|
||||
slog.String("request_uri", r.RequestURI),
|
||||
)
|
||||
json.Unauthorized(w, r)
|
||||
return
|
||||
@@ -110,6 +114,7 @@ func (m *middleware) basicAuth(next http.Handler) http.Handler {
|
||||
slog.Bool("authentication_failed", true),
|
||||
slog.String("client_ip", clientIP),
|
||||
slog.String("user_agent", r.UserAgent()),
|
||||
slog.String("request_uri", r.RequestURI),
|
||||
)
|
||||
json.Unauthorized(w, r)
|
||||
return
|
||||
@@ -121,6 +126,7 @@ func (m *middleware) basicAuth(next http.Handler) http.Handler {
|
||||
slog.String("client_ip", clientIP),
|
||||
slog.String("user_agent", r.UserAgent()),
|
||||
slog.String("username", username),
|
||||
slog.String("request_uri", r.RequestURI),
|
||||
)
|
||||
json.Unauthorized(w, r)
|
||||
return
|
||||
@@ -138,6 +144,7 @@ func (m *middleware) basicAuth(next http.Handler) http.Handler {
|
||||
slog.String("client_ip", clientIP),
|
||||
slog.String("user_agent", r.UserAgent()),
|
||||
slog.String("username", username),
|
||||
slog.String("request_uri", r.RequestURI),
|
||||
)
|
||||
json.Unauthorized(w, r)
|
||||
return
|
||||
@@ -148,6 +155,7 @@ func (m *middleware) basicAuth(next http.Handler) http.Handler {
|
||||
slog.String("client_ip", clientIP),
|
||||
slog.String("user_agent", r.UserAgent()),
|
||||
slog.String("username", username),
|
||||
slog.String("request_uri", r.RequestURI),
|
||||
)
|
||||
|
||||
m.store.SetLastLogin(user.ID)
|
||||
|
||||
@@ -7,8 +7,6 @@ import (
|
||||
json_parser "encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"miniflux.app/v2/internal/http/request"
|
||||
"miniflux.app/v2/internal/http/response/json"
|
||||
@@ -84,18 +82,6 @@ func (h *handler) updateUser(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
cleanEnd := regexp.MustCompile(`(?m)\r\n\s*$`)
|
||||
if userModificationRequest.BlockFilterEntryRules != nil {
|
||||
*userModificationRequest.BlockFilterEntryRules = cleanEnd.ReplaceAllLiteralString(*userModificationRequest.BlockFilterEntryRules, "")
|
||||
// Clean carriage returns for Windows environments
|
||||
*userModificationRequest.BlockFilterEntryRules = strings.ReplaceAll(*userModificationRequest.BlockFilterEntryRules, "\r\n", "\n")
|
||||
}
|
||||
if userModificationRequest.KeepFilterEntryRules != nil {
|
||||
*userModificationRequest.KeepFilterEntryRules = cleanEnd.ReplaceAllLiteralString(*userModificationRequest.KeepFilterEntryRules, "")
|
||||
// Clean carriage returns for Windows environments
|
||||
*userModificationRequest.KeepFilterEntryRules = strings.ReplaceAll(*userModificationRequest.KeepFilterEntryRules, "\r\n", "\n")
|
||||
}
|
||||
|
||||
if validationErr := validator.ValidateUserModification(h.store, originalUser.ID, &userModificationRequest); validationErr != nil {
|
||||
json.BadRequest(w, r, validationErr.Error())
|
||||
return
|
||||
|
||||
@@ -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",
|
||||
@@ -46,4 +46,18 @@ func runCleanupTasks(store *storage.Storage) {
|
||||
metric.ArchiveEntriesDuration.WithLabelValues(model.EntryStatusUnread).Observe(time.Since(startTime).Seconds())
|
||||
}
|
||||
}
|
||||
|
||||
if enclosuresAffected, err := store.DeleteRemovedEntriesEnclosures(); err != nil {
|
||||
slog.Error("Unable to delete enclosures from removed entries", slog.Any("error", err))
|
||||
} else {
|
||||
slog.Info("Deleting enclosures from removed entries completed",
|
||||
slog.Int64("removed_entries_enclosures_deleted", enclosuresAffected))
|
||||
}
|
||||
|
||||
if contentAffected, err := store.ClearRemovedEntriesContent(config.Opts.CleanupArchiveBatchSize()); err != nil {
|
||||
slog.Error("Unable to clear content from removed entries", slog.Any("error", err))
|
||||
} else {
|
||||
slog.Info("Clearing content from removed entries completed",
|
||||
slog.Int64("removed_entries_content_cleared", contentAffected))
|
||||
}
|
||||
}
|
||||
|
||||
+7
-7
@@ -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)
|
||||
@@ -156,16 +156,16 @@ func Parse() {
|
||||
slog.Info("The default value for DATABASE_URL is used")
|
||||
}
|
||||
|
||||
if err := static.CalculateBinaryFileChecksums(); err != nil {
|
||||
printErrorAndExit(fmt.Errorf("unable to calculate binary file checksums: %v", err))
|
||||
if err := static.GenerateBinaryBundles(); err != nil {
|
||||
printErrorAndExit(fmt.Errorf("unable to generate binary files bundle: %v", err))
|
||||
}
|
||||
|
||||
if err := static.GenerateStylesheetsBundles(); err != nil {
|
||||
printErrorAndExit(fmt.Errorf("unable to generate stylesheets bundles: %v", err))
|
||||
printErrorAndExit(fmt.Errorf("unable to generate stylesheets bundle: %v", err))
|
||||
}
|
||||
|
||||
if err := static.GenerateJavascriptBundles(); err != nil {
|
||||
printErrorAndExit(fmt.Errorf("unable to generate javascript bundles: %v", err))
|
||||
if err := static.GenerateJavascriptBundles(config.Opts.WebAuthn()); err != nil {
|
||||
printErrorAndExit(fmt.Errorf("unable to generate javascript bundle: %v", err))
|
||||
}
|
||||
|
||||
db, err := database.NewConnectionPool(
|
||||
@@ -263,6 +263,6 @@ func Parse() {
|
||||
}
|
||||
|
||||
func printErrorAndExit(err error) {
|
||||
fmt.Fprintf(os.Stderr, "%v\n", err)
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
+15
-5
@@ -13,7 +13,7 @@ import (
|
||||
"time"
|
||||
|
||||
"miniflux.app/v2/internal/config"
|
||||
httpd "miniflux.app/v2/internal/http/server"
|
||||
"miniflux.app/v2/internal/http/server"
|
||||
"miniflux.app/v2/internal/metric"
|
||||
"miniflux.app/v2/internal/storage"
|
||||
"miniflux.app/v2/internal/systemd"
|
||||
@@ -33,9 +33,9 @@ func startDaemon(store *storage.Storage) {
|
||||
runScheduler(store, pool)
|
||||
}
|
||||
|
||||
var httpServer *http.Server
|
||||
var httpServers []*http.Server
|
||||
if config.Opts.HasHTTPService() {
|
||||
httpServer = httpd.StartWebServer(store, pool)
|
||||
httpServers = server.StartWebServer(store, pool)
|
||||
}
|
||||
|
||||
if config.Opts.HasMetricsCollector() {
|
||||
@@ -78,8 +78,18 @@ func startDaemon(store *storage.Storage) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if httpServer != nil {
|
||||
httpServer.Shutdown(ctx)
|
||||
if len(httpServers) > 0 {
|
||||
slog.Debug("Shutting down HTTP servers...")
|
||||
for _, server := range httpServers {
|
||||
if server != nil {
|
||||
if err := server.Shutdown(ctx); err != nil {
|
||||
slog.Error("HTTP server shutdown error", slog.Any("error", err), slog.String("addr", server.Addr))
|
||||
}
|
||||
}
|
||||
}
|
||||
slog.Debug("All HTTP servers shut down.")
|
||||
} else {
|
||||
slog.Debug("No HTTP servers to shut down.")
|
||||
}
|
||||
|
||||
slog.Debug("Process gracefully stopped")
|
||||
|
||||
@@ -14,7 +14,7 @@ import (
|
||||
|
||||
func doHealthCheck(healthCheckEndpoint string) {
|
||||
if healthCheckEndpoint == "auto" {
|
||||
healthCheckEndpoint = "http://" + config.Opts.ListenAddr() + config.Opts.BasePath() + "/healthcheck"
|
||||
healthCheckEndpoint = "http://" + config.Opts.ListenAddr()[0] + config.Opts.BasePath() + "/healthcheck"
|
||||
}
|
||||
|
||||
slog.Debug("Executing health check request", slog.String("endpoint", healthCheckEndpoint))
|
||||
|
||||
@@ -25,6 +25,7 @@ func refreshFeeds(store *storage.Storage) {
|
||||
batchBuilder.WithErrorLimit(config.Opts.PollingParsingErrorLimit())
|
||||
batchBuilder.WithoutDisabledFeeds()
|
||||
batchBuilder.WithNextCheckExpired()
|
||||
batchBuilder.WithLimitPerHost(config.Opts.PollingLimitPerHost())
|
||||
|
||||
jobs, err := batchBuilder.FetchJobs()
|
||||
if err != nil {
|
||||
@@ -32,13 +33,9 @@ func refreshFeeds(store *storage.Storage) {
|
||||
return
|
||||
}
|
||||
|
||||
slog.Debug("Feed URLs in this batch", slog.Any("feed_urls", jobs.FeedURLs()))
|
||||
|
||||
nbJobs := len(jobs)
|
||||
|
||||
slog.Info("Created a batch of feeds",
|
||||
slog.Int("nb_jobs", nbJobs),
|
||||
slog.Int("batch_size", config.Opts.BatchSize()),
|
||||
)
|
||||
|
||||
var jobQueue = make(chan model.Job, nbJobs)
|
||||
|
||||
slog.Info("Starting a pool of workers",
|
||||
|
||||
@@ -21,36 +21,36 @@ func runScheduler(store *storage.Storage, pool *worker.Pool) {
|
||||
config.Opts.PollingFrequency(),
|
||||
config.Opts.BatchSize(),
|
||||
config.Opts.PollingParsingErrorLimit(),
|
||||
config.Opts.PollingLimitPerHost(),
|
||||
)
|
||||
|
||||
go cleanupScheduler(
|
||||
store,
|
||||
config.Opts.CleanupFrequencyHours(),
|
||||
config.Opts.CleanupFrequency(),
|
||||
)
|
||||
}
|
||||
|
||||
func feedScheduler(store *storage.Storage, pool *worker.Pool, frequency, batchSize, errorLimit 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)
|
||||
batchBuilder.WithErrorLimit(errorLimit)
|
||||
batchBuilder.WithoutDisabledFeeds()
|
||||
batchBuilder.WithNextCheckExpired()
|
||||
batchBuilder.WithLimitPerHost(limitPerHost)
|
||||
|
||||
if jobs, err := batchBuilder.FetchJobs(); err != nil {
|
||||
slog.Error("Unable to fetch jobs from database", slog.Any("error", err))
|
||||
} else if len(jobs) > 0 {
|
||||
slog.Info("Created a batch of feeds",
|
||||
slog.Int("nb_jobs", len(jobs)),
|
||||
)
|
||||
slog.Debug("Feed URLs in this batch", slog.Any("feed_urls", jobs.FeedURLs()))
|
||||
pool.Push(jobs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,5 +3,9 @@
|
||||
|
||||
package config // import "miniflux.app/v2/internal/config"
|
||||
|
||||
import "miniflux.app/v2/internal/version"
|
||||
|
||||
// Opts holds parsed configuration options.
|
||||
var Opts *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
+833
-666
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+214
-304
@@ -7,338 +7,214 @@ import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"crypto/rand"
|
||||
"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 _, line := range lines {
|
||||
fields := strings.SplitN(line, "=", 2)
|
||||
key := strings.TrimSpace(fields[0])
|
||||
value := strings.TrimSpace(fields[1])
|
||||
|
||||
switch key {
|
||||
case "LOG_FILE":
|
||||
p.opts.logFile = parseString(value, defaultLogFile)
|
||||
case "LOG_DATE_TIME":
|
||||
p.opts.logDateTime = parseBool(value, defaultLogDateTime)
|
||||
case "LOG_LEVEL":
|
||||
parsedValue := parseString(value, defaultLogLevel)
|
||||
if parsedValue == "debug" || parsedValue == "info" || parsedValue == "warning" || parsedValue == "error" {
|
||||
p.opts.logLevel = parsedValue
|
||||
}
|
||||
case "LOG_FORMAT":
|
||||
parsedValue := parseString(value, defaultLogFormat)
|
||||
if parsedValue == "json" || parsedValue == "text" {
|
||||
p.opts.logFormat = parsedValue
|
||||
}
|
||||
case "DEBUG":
|
||||
slog.Warn("The DEBUG environment variable is deprecated, use LOG_LEVEL instead")
|
||||
parsedValue := parseBool(value, defaultDebug)
|
||||
if parsedValue {
|
||||
p.opts.logLevel = "debug"
|
||||
}
|
||||
case "SERVER_TIMING_HEADER":
|
||||
p.opts.serverTimingHeader = parseBool(value, defaultTiming)
|
||||
case "BASE_URL":
|
||||
p.opts.baseURL, p.opts.rootURL, p.opts.basePath, err = parseBaseURL(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
case "PORT":
|
||||
port = value
|
||||
case "LISTEN_ADDR":
|
||||
p.opts.listenAddr = parseString(value, defaultListenAddr)
|
||||
case "DATABASE_URL":
|
||||
p.opts.databaseURL = parseString(value, defaultDatabaseURL)
|
||||
case "DATABASE_URL_FILE":
|
||||
p.opts.databaseURL = readSecretFile(value, defaultDatabaseURL)
|
||||
case "DATABASE_MAX_CONNS":
|
||||
p.opts.databaseMaxConns = parseInt(value, defaultDatabaseMaxConns)
|
||||
case "DATABASE_MIN_CONNS":
|
||||
p.opts.databaseMinConns = parseInt(value, defaultDatabaseMinConns)
|
||||
case "DATABASE_CONNECTION_LIFETIME":
|
||||
p.opts.databaseConnectionLifetime = parseInt(value, defaultDatabaseConnectionLifetime)
|
||||
case "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 "POLLING_FREQUENCY":
|
||||
p.opts.pollingFrequency = parseInt(value, defaultPollingFrequency)
|
||||
case "FORCE_REFRESH_INTERVAL":
|
||||
p.opts.forceRefreshInterval = parseInt(value, defaultForceRefreshInterval)
|
||||
case "BATCH_SIZE":
|
||||
p.opts.batchSize = parseInt(value, defaultBatchSize)
|
||||
case "POLLING_SCHEDULER":
|
||||
p.opts.pollingScheduler = strings.ToLower(parseString(value, defaultPollingScheduler))
|
||||
case "SCHEDULER_ENTRY_FREQUENCY_MAX_INTERVAL":
|
||||
p.opts.schedulerEntryFrequencyMaxInterval = parseInt(value, defaultSchedulerEntryFrequencyMaxInterval)
|
||||
case "SCHEDULER_ENTRY_FREQUENCY_MIN_INTERVAL":
|
||||
p.opts.schedulerEntryFrequencyMinInterval = parseInt(value, defaultSchedulerEntryFrequencyMinInterval)
|
||||
case "SCHEDULER_ENTRY_FREQUENCY_FACTOR":
|
||||
p.opts.schedulerEntryFrequencyFactor = parseInt(value, defaultSchedulerEntryFrequencyFactor)
|
||||
case "SCHEDULER_ROUND_ROBIN_MIN_INTERVAL":
|
||||
p.opts.schedulerRoundRobinMinInterval = parseInt(value, defaultSchedulerRoundRobinMinInterval)
|
||||
case "SCHEDULER_ROUND_ROBIN_MAX_INTERVAL":
|
||||
p.opts.schedulerRoundRobinMaxInterval = parseInt(value, defaultSchedulerRoundRobinMaxInterval)
|
||||
case "POLLING_PARSING_ERROR_LIMIT":
|
||||
p.opts.pollingParsingErrorLimit = parseInt(value, defaultPollingParsingErrorLimit)
|
||||
case "PROXY_IMAGES":
|
||||
slog.Warn("The PROXY_IMAGES environment variable is deprecated, use MEDIA_PROXY_MODE instead")
|
||||
p.opts.mediaProxyMode = parseString(value, defaultMediaProxyMode)
|
||||
case "PROXY_HTTP_CLIENT_TIMEOUT":
|
||||
slog.Warn("The PROXY_HTTP_CLIENT_TIMEOUT environment variable is deprecated, use MEDIA_PROXY_HTTP_CLIENT_TIMEOUT instead")
|
||||
p.opts.mediaProxyHTTPClientTimeout = parseInt(value, defaultMediaProxyHTTPClientTimeout)
|
||||
case "MEDIA_PROXY_HTTP_CLIENT_TIMEOUT":
|
||||
p.opts.mediaProxyHTTPClientTimeout = parseInt(value, defaultMediaProxyHTTPClientTimeout)
|
||||
case "PROXY_OPTION":
|
||||
slog.Warn("The PROXY_OPTION environment variable is deprecated, use MEDIA_PROXY_MODE instead")
|
||||
p.opts.mediaProxyMode = parseString(value, defaultMediaProxyMode)
|
||||
case "MEDIA_PROXY_MODE":
|
||||
p.opts.mediaProxyMode = parseString(value, defaultMediaProxyMode)
|
||||
case "PROXY_MEDIA_TYPES":
|
||||
slog.Warn("The PROXY_MEDIA_TYPES environment variable is deprecated, use MEDIA_PROXY_RESOURCE_TYPES instead")
|
||||
p.opts.mediaProxyResourceTypes = parseStringList(value, []string{defaultMediaResourceTypes})
|
||||
case "MEDIA_PROXY_RESOURCE_TYPES":
|
||||
p.opts.mediaProxyResourceTypes = parseStringList(value, []string{defaultMediaResourceTypes})
|
||||
case "PROXY_IMAGE_URL":
|
||||
slog.Warn("The PROXY_IMAGE_URL environment variable is deprecated, use MEDIA_PROXY_CUSTOM_URL instead")
|
||||
p.opts.mediaProxyCustomURL = parseString(value, defaultMediaProxyURL)
|
||||
case "PROXY_URL":
|
||||
slog.Warn("The PROXY_URL environment variable is deprecated, use MEDIA_PROXY_CUSTOM_URL instead")
|
||||
p.opts.mediaProxyCustomURL = parseString(value, defaultMediaProxyURL)
|
||||
case "PROXY_PRIVATE_KEY":
|
||||
slog.Warn("The PROXY_PRIVATE_KEY environment variable is deprecated, use MEDIA_PROXY_PRIVATE_KEY instead")
|
||||
randomKey := make([]byte, 16)
|
||||
if _, err := rand.Read(randomKey); err != nil {
|
||||
return fmt.Errorf("config: unable to generate random key: %w", err)
|
||||
}
|
||||
p.opts.mediaProxyPrivateKey = parseBytes(value, randomKey)
|
||||
case "MEDIA_PROXY_PRIVATE_KEY":
|
||||
randomKey := make([]byte, 16)
|
||||
if _, err := rand.Read(randomKey); err != nil {
|
||||
return fmt.Errorf("config: unable to generate random key: %w", err)
|
||||
}
|
||||
p.opts.mediaProxyPrivateKey = parseBytes(value, randomKey)
|
||||
case "MEDIA_PROXY_CUSTOM_URL":
|
||||
p.opts.mediaProxyCustomURL = parseString(value, defaultMediaProxyURL)
|
||||
case "CREATE_ADMIN":
|
||||
p.opts.createAdmin = parseBool(value, defaultCreateAdmin)
|
||||
case "ADMIN_USERNAME":
|
||||
p.opts.adminUsername = parseString(value, defaultAdminUsername)
|
||||
case "ADMIN_USERNAME_FILE":
|
||||
p.opts.adminUsername = readSecretFile(value, defaultAdminUsername)
|
||||
case "ADMIN_PASSWORD":
|
||||
p.opts.adminPassword = parseString(value, defaultAdminPassword)
|
||||
case "ADMIN_PASSWORD_FILE":
|
||||
p.opts.adminPassword = readSecretFile(value, defaultAdminPassword)
|
||||
case "POCKET_CONSUMER_KEY":
|
||||
p.opts.pocketConsumerKey = parseString(value, defaultPocketConsumerKey)
|
||||
case "POCKET_CONSUMER_KEY_FILE":
|
||||
p.opts.pocketConsumerKey = readSecretFile(value, defaultPocketConsumerKey)
|
||||
case "OAUTH2_USER_CREATION":
|
||||
p.opts.oauth2UserCreationAllowed = parseBool(value, defaultOAuth2UserCreation)
|
||||
case "OAUTH2_CLIENT_ID":
|
||||
p.opts.oauth2ClientID = parseString(value, defaultOAuth2ClientID)
|
||||
case "OAUTH2_CLIENT_ID_FILE":
|
||||
p.opts.oauth2ClientID = readSecretFile(value, defaultOAuth2ClientID)
|
||||
case "OAUTH2_CLIENT_SECRET":
|
||||
p.opts.oauth2ClientSecret = parseString(value, defaultOAuth2ClientSecret)
|
||||
case "OAUTH2_CLIENT_SECRET_FILE":
|
||||
p.opts.oauth2ClientSecret = readSecretFile(value, defaultOAuth2ClientSecret)
|
||||
case "OAUTH2_REDIRECT_URL":
|
||||
p.opts.oauth2RedirectURL = parseString(value, defaultOAuth2RedirectURL)
|
||||
case "OAUTH2_OIDC_DISCOVERY_ENDPOINT":
|
||||
p.opts.oidcDiscoveryEndpoint = parseString(value, defaultOAuth2OidcDiscoveryEndpoint)
|
||||
case "OAUTH2_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 = ":" + port
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseBaseURL(value string) (string, string, string, error) {
|
||||
if value == "" {
|
||||
return defaultBaseURL, defaultRootURL, "", nil
|
||||
}
|
||||
|
||||
if value[len(value)-1:] == "/" {
|
||||
value = value[:len(value)-1]
|
||||
}
|
||||
|
||||
parsedURL, err := url.Parse(value)
|
||||
parsedURL, err := url.Parse(baseURL)
|
||||
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 fmt.Errorf("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 {
|
||||
// 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
|
||||
}
|
||||
@@ -351,52 +227,86 @@ 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
|
||||
}
|
||||
|
||||
var strList []string
|
||||
strMap := make(map[string]bool)
|
||||
present := make(map[string]bool)
|
||||
|
||||
items := strings.Split(value, ",")
|
||||
for _, item := range items {
|
||||
itemValue := strings.TrimSpace(item)
|
||||
|
||||
if _, found := strMap[itemValue]; !found {
|
||||
strMap[itemValue] = true
|
||||
strList = append(strList, itemValue)
|
||||
for item := range strings.SplitSeq(value, ",") {
|
||||
if itemValue := strings.TrimSpace(item); itemValue != "" {
|
||||
if !present[itemValue] {
|
||||
present[itemValue] = true
|
||||
strList = append(strList, itemValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return strList
|
||||
}
|
||||
|
||||
func 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 "", fmt.Errorf("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
|
||||
}
|
||||
|
||||
+413
-38
@@ -4,57 +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 TestParsedInt64Value(t *testing.T) {
|
||||
// Test with empty value - should return fallback
|
||||
result := ParsedInt64Value("", 42)
|
||||
if result != 42 {
|
||||
t.Errorf("Expected 42, got %d", result)
|
||||
}
|
||||
|
||||
// Test with valid int64
|
||||
result = ParsedInt64Value("9223372036854775807", 42)
|
||||
if result != 9223372036854775807 {
|
||||
t.Errorf("Expected 9223372036854775807, got %d", result)
|
||||
}
|
||||
|
||||
// Test with invalid int64 - should return fallback
|
||||
result = ParsedInt64Value("invalid", 42)
|
||||
if result != 42 {
|
||||
t.Errorf("Expected 42, got %d", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseStringListValue(t *testing.T) {
|
||||
// Test with empty value - should return fallback
|
||||
fallback := []string{"a", "b"}
|
||||
result := parseStringListValue("", fallback)
|
||||
if !reflect.DeepEqual(result, fallback) {
|
||||
t.Errorf("Expected %v, got %v", fallback, result)
|
||||
}
|
||||
|
||||
// Test with single value
|
||||
result = parseStringListValue("item1", nil)
|
||||
expected := []string{"item1"}
|
||||
if !reflect.DeepEqual(result, expected) {
|
||||
t.Errorf("Expected %v, got %v", expected, result)
|
||||
}
|
||||
|
||||
// Test with multiple values
|
||||
result = parseStringListValue("item1,item2,item3", nil)
|
||||
expected = []string{"item1", "item2", "item3"}
|
||||
if !reflect.DeepEqual(result, expected) {
|
||||
t.Errorf("Expected %v, got %v", expected, result)
|
||||
}
|
||||
|
||||
// Test with duplicates - should remove duplicates
|
||||
result = parseStringListValue("item1,item2,item1", nil)
|
||||
expected = []string{"item1", "item2"}
|
||||
if !reflect.DeepEqual(result, expected) {
|
||||
t.Errorf("Expected %v, got %v", expected, result)
|
||||
}
|
||||
|
||||
// Test with spaces
|
||||
result = parseStringListValue(" item1 , item2 , item3 ", nil)
|
||||
expected = []string{"item1", "item2", "item3"}
|
||||
if !reflect.DeepEqual(result, expected) {
|
||||
t.Errorf("Expected %v, got %v", expected, result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDurationValue(t *testing.T) {
|
||||
// Test with empty value - should return fallback
|
||||
fallback := 5 * time.Second
|
||||
result := parseDurationValue("", time.Second, fallback)
|
||||
if result != fallback {
|
||||
t.Errorf("Expected %v, got %v", fallback, result)
|
||||
}
|
||||
|
||||
// Test with valid duration
|
||||
result = parseDurationValue("30", time.Second, fallback)
|
||||
expected := 30 * time.Second
|
||||
if result != expected {
|
||||
t.Errorf("Expected %v, got %v", expected, result)
|
||||
}
|
||||
|
||||
// Test with minutes
|
||||
result = parseDurationValue("5", time.Minute, fallback)
|
||||
expected = 5 * time.Minute
|
||||
if result != expected {
|
||||
t.Errorf("Expected %v, got %v", expected, result)
|
||||
}
|
||||
|
||||
// Test with invalid value - should return fallback
|
||||
result = parseDurationValue("invalid", time.Second, fallback)
|
||||
if result != fallback {
|
||||
t.Errorf("Expected %v, got %v", fallback, result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseURLValue(t *testing.T) {
|
||||
// Test with empty value - should return fallback
|
||||
fallbackURL, _ := url.Parse("https://fallback.com")
|
||||
result, err := parseURLValue("", fallbackURL)
|
||||
if err != nil {
|
||||
t.Errorf("Unexpected error: %v", err)
|
||||
}
|
||||
if result != fallbackURL {
|
||||
t.Errorf("Expected %v, got %v", fallbackURL, result)
|
||||
}
|
||||
|
||||
// Test with valid URL
|
||||
result, err = parseURLValue("https://example.com", nil)
|
||||
if err != nil {
|
||||
t.Errorf("Unexpected error: %v", err)
|
||||
}
|
||||
if result.String() != "https://example.com" {
|
||||
t.Errorf("Expected https://example.com, got %s", result.String())
|
||||
}
|
||||
|
||||
// Test with invalid URL - should return fallback and error
|
||||
result, err = parseURLValue("://invalid", fallbackURL)
|
||||
if err == nil {
|
||||
t.Error("Expected error for invalid URL")
|
||||
}
|
||||
if result != fallbackURL {
|
||||
t.Errorf("Expected fallback URL, got %v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigFileParsing(t *testing.T) {
|
||||
fileContent := `
|
||||
# This is a comment
|
||||
LOG_FILE=miniflux.log
|
||||
LOG_DATE_TIME=1
|
||||
LOG_FORMAT=json
|
||||
LISTEN_ADDR=:8080,:8443
|
||||
`
|
||||
|
||||
// Write a temporary config file and parse it
|
||||
tmpFile, err := os.CreateTemp("", "miniflux-*.txt")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temporary file: %v", err)
|
||||
}
|
||||
|
||||
defer os.Remove(tmpFile.Name())
|
||||
defer tmpFile.Close()
|
||||
|
||||
filename := tmpFile.Name()
|
||||
if _, err := tmpFile.WriteString(fileContent); err != nil {
|
||||
t.Fatalf("Failed to write to temporary file: %v", err)
|
||||
}
|
||||
|
||||
configParser := NewConfigParser()
|
||||
configOptions, err := configParser.ParseFile(filename)
|
||||
if err != nil {
|
||||
t.Fatalf("Unexpected parsing error: %v", err)
|
||||
}
|
||||
|
||||
if configOptions.LogFile() != "miniflux.log" {
|
||||
t.Fatalf("Unexpected log file, got %q", configOptions.LogFile())
|
||||
}
|
||||
|
||||
if configOptions.LogDateTime() != true {
|
||||
t.Fatalf("Unexpected log datetime, got %v", configOptions.LogDateTime())
|
||||
}
|
||||
|
||||
if configOptions.LogFormat() != "json" {
|
||||
t.Fatalf("Unexpected log format, got %q", configOptions.LogFormat())
|
||||
}
|
||||
|
||||
if configOptions.LogLevel() != "info" {
|
||||
t.Fatalf("Unexpected log level, got %q", configOptions.LogLevel())
|
||||
}
|
||||
|
||||
if len(configOptions.ListenAddr()) != 2 || configOptions.ListenAddr()[0] != ":8080" || configOptions.ListenAddr()[1] != ":8443" {
|
||||
t.Fatalf("Unexpected listen addresses, got %v", configOptions.ListenAddr())
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigFileParsingWithIncorrectKeyValuePair(t *testing.T) {
|
||||
fileContent := `
|
||||
LOG_FILE=miniflux.log
|
||||
INVALID_LINE
|
||||
`
|
||||
|
||||
// Write a temporary config file and parse it
|
||||
tmpFile, err := os.CreateTemp("", "miniflux-*.txt")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temporary file: %v", err)
|
||||
}
|
||||
|
||||
defer os.Remove(tmpFile.Name())
|
||||
defer tmpFile.Close()
|
||||
|
||||
filename := tmpFile.Name()
|
||||
if _, err := tmpFile.WriteString(fileContent); err != nil {
|
||||
t.Fatalf("Failed to write to temporary file: %v", err)
|
||||
}
|
||||
|
||||
configParser := NewConfigParser()
|
||||
_, err = configParser.ParseFile(filename)
|
||||
if err != nil {
|
||||
t.Fatal("Invalid lines should be ignored, but got error:", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAdminPasswordFileOption(t *testing.T) {
|
||||
tmpFile, err := os.CreateTemp("", "password-*.txt")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temporary file: %v", err)
|
||||
}
|
||||
defer os.Remove(tmpFile.Name())
|
||||
defer tmpFile.Close()
|
||||
|
||||
password := "supersecret"
|
||||
if _, err := tmpFile.WriteString(password); err != nil {
|
||||
t.Fatalf("Failed to write to temporary file: %v", err)
|
||||
}
|
||||
|
||||
os.Clearenv()
|
||||
os.Setenv("ADMIN_PASSWORD_FILE", tmpFile.Name())
|
||||
|
||||
configParser := NewConfigParser()
|
||||
configOptions, err := configParser.ParseEnvironmentVariables()
|
||||
if err != nil {
|
||||
t.Fatalf("Unexpected parsing error: %v", err)
|
||||
}
|
||||
|
||||
if configOptions.AdminPassword() != password {
|
||||
t.Fatalf("Unexpected admin password, got %q", configOptions.AdminPassword())
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAdminPasswordFileOptionWithEmptyFile(t *testing.T) {
|
||||
tmpFile, err := os.CreateTemp("", "empty-password-*.txt")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temporary file: %v", err)
|
||||
}
|
||||
defer os.Remove(tmpFile.Name())
|
||||
defer tmpFile.Close()
|
||||
|
||||
os.Clearenv()
|
||||
os.Setenv("ADMIN_PASSWORD_FILE", tmpFile.Name())
|
||||
|
||||
configParser := NewConfigParser()
|
||||
_, err = configParser.ParseEnvironmentVariables()
|
||||
if err == nil {
|
||||
t.Fatal("Expected error due to empty password file, but got none")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseLogFileOptionDefaultValue(t *testing.T) {
|
||||
os.Clearenv()
|
||||
|
||||
configParser := NewConfigParser()
|
||||
configOptions, err := configParser.ParseEnvironmentVariables()
|
||||
if err != nil {
|
||||
t.Fatalf("Unexpected parsing error: %v", err)
|
||||
}
|
||||
|
||||
if configOptions.LogFile() != "stderr" {
|
||||
t.Fatalf("Unexpected default log file, got %q", configOptions.LogFile())
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseLogFileOptionWithCustomFilename(t *testing.T) {
|
||||
os.Clearenv()
|
||||
os.Setenv("LOG_FILE", "miniflux.log")
|
||||
|
||||
configParser := NewConfigParser()
|
||||
configOptions, err := configParser.ParseEnvironmentVariables()
|
||||
if err != nil {
|
||||
t.Fatalf("Unexpected parsing error: %v", err)
|
||||
}
|
||||
|
||||
if configOptions.LogFile() != "miniflux.log" {
|
||||
t.Fatalf("Unexpected log file, got %q", configOptions.LogFile())
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseLogFileOptionWithEmptyValue(t *testing.T) {
|
||||
os.Clearenv()
|
||||
os.Setenv("LOG_FILE", "")
|
||||
|
||||
configParser := NewConfigParser()
|
||||
configOptions, err := configParser.ParseEnvironmentVariables()
|
||||
if err != nil {
|
||||
t.Fatalf("Unexpected parsing error: %v", err)
|
||||
}
|
||||
|
||||
if configOptions.LogFile() != "stderr" {
|
||||
t.Fatalf("Unexpected log file, got %q", configOptions.LogFile())
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseLogDateTimeOptionDefaultValue(t *testing.T) {
|
||||
os.Clearenv()
|
||||
|
||||
configParser := NewConfigParser()
|
||||
configOptions, err := configParser.ParseEnvironmentVariables()
|
||||
if err != nil {
|
||||
t.Fatalf("Unexpected parsing error: %v", err)
|
||||
}
|
||||
|
||||
if configOptions.LogDateTime() != false {
|
||||
t.Fatalf("Unexpected default log datetime, got %v", configOptions.LogDateTime())
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseLogDateTimeOptionWithCustomValue(t *testing.T) {
|
||||
os.Clearenv()
|
||||
os.Setenv("LOG_DATE_TIME", "true")
|
||||
|
||||
configParser := NewConfigParser()
|
||||
configOptions, err := configParser.ParseEnvironmentVariables()
|
||||
if err != nil {
|
||||
t.Fatalf("Unexpected parsing error: %v", err)
|
||||
}
|
||||
|
||||
if configOptions.LogDateTime() != true {
|
||||
t.Fatalf("Unexpected log datetime, got %v", configOptions.LogDateTime())
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseLogDateTimeOptionWithEmptyValue(t *testing.T) {
|
||||
os.Clearenv()
|
||||
os.Setenv("LOG_DATE_TIME", "")
|
||||
|
||||
configParser := NewConfigParser()
|
||||
configOptions, err := configParser.ParseEnvironmentVariables()
|
||||
if err != nil {
|
||||
t.Fatalf("Unexpected parsing error: %v", err)
|
||||
}
|
||||
|
||||
if configOptions.LogDateTime() != false {
|
||||
t.Fatalf("Unexpected log datetime, got %v", configOptions.LogDateTime())
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseLogDateTimeOptionWithIncorrectValue(t *testing.T) {
|
||||
os.Clearenv()
|
||||
os.Setenv("LOG_DATE_TIME", "invalid")
|
||||
|
||||
configParser := NewConfigParser()
|
||||
if _, err := configParser.ParseEnvironmentVariables(); err == nil {
|
||||
t.Fatal("Expected parsing error, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package config // import "miniflux.app/v2/internal/config"
|
||||
|
||||
import (
|
||||
"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 !slices.Contains(choices, value) {
|
||||
return fmt.Errorf("value must be one of: %v", strings.Join(choices, ", "))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateGreaterThan(rawValue string, min int) error {
|
||||
intValue, err := strconv.Atoi(rawValue)
|
||||
if err != nil {
|
||||
return fmt.Errorf("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 fmt.Errorf("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 fmt.Errorf("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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+10
-15
@@ -8,38 +8,33 @@ import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"hash/fnv"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// HashFromBytes returns a SHA-256 checksum of the input.
|
||||
// HashFromBytes returns a non-cryptographic checksum of the input.
|
||||
func HashFromBytes(value []byte) string {
|
||||
return fmt.Sprintf("%x", sha256.Sum256(value))
|
||||
h := fnv.New128a()
|
||||
h.Write(value)
|
||||
return hex.EncodeToString(h.Sum(nil))
|
||||
}
|
||||
|
||||
// Hash returns a SHA-256 checksum of a string.
|
||||
func Hash(value string) string {
|
||||
return HashFromBytes([]byte(value))
|
||||
// SHA256 returns a SHA-256 checksum of a string.
|
||||
func SHA256(value string) string {
|
||||
h := sha256.Sum256([]byte(value))
|
||||
return hex.EncodeToString(h[:])
|
||||
}
|
||||
|
||||
// GenerateRandomBytes returns random bytes.
|
||||
func GenerateRandomBytes(size int) []byte {
|
||||
b := make([]byte, size)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
rand.Read(b)
|
||||
return b
|
||||
}
|
||||
|
||||
// GenerateRandomString returns a random string.
|
||||
func GenerateRandomString(size int) string {
|
||||
return base64.URLEncoding.EncodeToString(GenerateRandomBytes(size))
|
||||
}
|
||||
|
||||
// GenerateRandomStringHex returns a random hexadecimal string.
|
||||
func GenerateRandomStringHex(size int) string {
|
||||
return hex.EncodeToString(GenerateRandomBytes(size))
|
||||
|
||||
@@ -14,11 +14,9 @@ func Migrate(db *sql.DB) error {
|
||||
var currentVersion int
|
||||
db.QueryRow(`SELECT version FROM schema_version`).Scan(¤tVersion)
|
||||
|
||||
driver := getDriverStr()
|
||||
slog.Info("Running database migrations",
|
||||
slog.Int("current_version", currentVersion),
|
||||
slog.Int("latest_version", schemaVersion),
|
||||
slog.String("driver", driver),
|
||||
)
|
||||
|
||||
for version := currentVersion; version < schemaVersion; version++ {
|
||||
@@ -29,12 +27,12 @@ func Migrate(db *sql.DB) error {
|
||||
return fmt.Errorf("[Migration v%d] %v", newVersion, err)
|
||||
}
|
||||
|
||||
if err := migrations[version](tx, driver); err != nil {
|
||||
if err := migrations[version](tx); err != nil {
|
||||
tx.Rollback()
|
||||
return fmt.Errorf("[Migration v%d] %v", newVersion, err)
|
||||
}
|
||||
|
||||
if _, err := tx.Exec(`DELETE FROM schema_version`); err != nil {
|
||||
if _, err := tx.Exec(`TRUNCATE schema_version`); err != nil {
|
||||
tx.Rollback()
|
||||
return fmt.Errorf("[Migration v%d] %v", newVersion, err)
|
||||
}
|
||||
|
||||
+516
-252
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,3 @@
|
||||
//go:build !sqlite
|
||||
|
||||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
@@ -25,7 +23,3 @@ func NewConnectionPool(dsn string, minConnections, maxConnections int, connectio
|
||||
|
||||
return db, nil
|
||||
}
|
||||
|
||||
func getDriverStr() string {
|
||||
return "postgresql"
|
||||
}
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
//go:build sqlite
|
||||
|
||||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package database // import "miniflux.app/v2/internal/database"
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"time"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
)
|
||||
|
||||
// NewConnectionPool configures the database connection pool.
|
||||
func NewConnectionPool(dsn string, _, _ int, _ time.Duration) (*sql.DB, error) {
|
||||
db, err := sql.Open("sqlite3", dsn)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return db, nil
|
||||
}
|
||||
|
||||
func getDriverStr() string {
|
||||
return "sqlite3"
|
||||
}
|
||||
@@ -145,7 +145,7 @@ func (h *handler) handleFeeds(w http.ResponseWriter, r *http.Request) {
|
||||
var result feedsResponse
|
||||
result.Feeds = make([]feed, 0)
|
||||
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)
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -71,12 +71,12 @@ func checkAndSimplifyTags(addTags []Stream, removeTags []Stream) (map[StreamType
|
||||
switch s.Type {
|
||||
case ReadStream:
|
||||
if _, ok := tags[KeptUnreadStream]; ok {
|
||||
return nil, fmt.Errorf("googlereader: %s and %s should not be supplied simultaneously", KeptUnread, Read)
|
||||
return nil, fmt.Errorf("googlereader: %s and %s should not be supplied simultaneously", keptUnreadStreamSuffix, readStreamSuffix)
|
||||
}
|
||||
tags[ReadStream] = true
|
||||
case KeptUnreadStream:
|
||||
if _, ok := tags[ReadStream]; ok {
|
||||
return nil, fmt.Errorf("googlereader: %s and %s should not be supplied simultaneously", KeptUnread, Read)
|
||||
return nil, fmt.Errorf("googlereader: %s and %s should not be supplied simultaneously", keptUnreadStreamSuffix, readStreamSuffix)
|
||||
}
|
||||
tags[ReadStream] = false
|
||||
case StarredStream:
|
||||
@@ -91,17 +91,17 @@ func checkAndSimplifyTags(addTags []Stream, removeTags []Stream) (map[StreamType
|
||||
switch s.Type {
|
||||
case ReadStream:
|
||||
if _, ok := tags[ReadStream]; ok {
|
||||
return nil, fmt.Errorf("googlereader: %s and %s should not be supplied simultaneously", KeptUnread, Read)
|
||||
return nil, fmt.Errorf("googlereader: %s and %s should not be supplied simultaneously", keptUnreadStreamSuffix, readStreamSuffix)
|
||||
}
|
||||
tags[ReadStream] = false
|
||||
case KeptUnreadStream:
|
||||
if _, ok := tags[ReadStream]; ok {
|
||||
return nil, fmt.Errorf("googlereader: %s and %s should not be supplied simultaneously", KeptUnread, Read)
|
||||
return nil, fmt.Errorf("googlereader: %s and %s should not be supplied simultaneously", keptUnreadStreamSuffix, readStreamSuffix)
|
||||
}
|
||||
tags[ReadStream] = true
|
||||
case StarredStream:
|
||||
if _, ok := tags[StarredStream]; ok {
|
||||
return nil, fmt.Errorf("googlereader: %s should not be supplied for add and remove simultaneously", Starred)
|
||||
return nil, fmt.Errorf("googlereader: %s should not be supplied for add and remove simultaneously", starredStreamSuffix)
|
||||
}
|
||||
tags[StarredStream] = false
|
||||
case BroadcastStream, LikeStream:
|
||||
@@ -200,7 +200,7 @@ func (h *handler) clientLoginHandler(w http.ResponseWriter, r *http.Request) {
|
||||
slog.String("username", username),
|
||||
)
|
||||
|
||||
result := login{SID: token, LSID: token, Auth: token}
|
||||
result := loginResponse{SID: token, LSID: token, Auth: token}
|
||||
if output == "json" {
|
||||
json.OK(w, r, result)
|
||||
return
|
||||
@@ -230,7 +230,7 @@ func (h *handler) tokenHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
token := request.GoolgeReaderToken(r)
|
||||
token := request.GoogleReaderToken(r)
|
||||
if token == "" {
|
||||
slog.Warn("[GoogleReader] User does not have token",
|
||||
slog.String("client_ip", clientIP),
|
||||
@@ -269,12 +269,12 @@ func (h *handler) editTagHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
addTags, err := getStreams(r.PostForm[ParamTagsAdd], userID)
|
||||
addTags, err := getStreams(r.PostForm[paramTagsAdd], userID)
|
||||
if err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
removeTags, err := getStreams(r.PostForm[ParamTagsRemove], userID)
|
||||
removeTags, err := getStreams(r.PostForm[paramTagsRemove], userID)
|
||||
if err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
return
|
||||
@@ -357,7 +357,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 +365,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
|
||||
@@ -387,7 +387,7 @@ func (h *handler) editTagHandler(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
OK(w, r)
|
||||
sendOkayResponse(w)
|
||||
}
|
||||
|
||||
func (h *handler) quickAddHandler(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -407,7 +407,7 @@ func (h *handler) quickAddHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
feedURL := r.Form.Get(ParamQuickAdd)
|
||||
feedURL := r.Form.Get(paramQuickAdd)
|
||||
if !validator.IsValidURL(feedURL) {
|
||||
json.BadRequest(w, r, fmt.Errorf("googlereader: invalid URL: %s", feedURL))
|
||||
return
|
||||
@@ -456,7 +456,7 @@ func (h *handler) quickAddHandler(w http.ResponseWriter, r *http.Request) {
|
||||
json.OK(w, r, quickAddResponse{
|
||||
NumResults: 1,
|
||||
Query: newFeed.FeedURL,
|
||||
StreamID: fmt.Sprintf(FeedPrefix+"%d", newFeed.ID),
|
||||
StreamID: fmt.Sprintf(feedPrefix+"%d", newFeed.ID),
|
||||
StreamName: newFeed.Title,
|
||||
})
|
||||
}
|
||||
@@ -609,20 +609,20 @@ func (h *handler) editSubscriptionHandler(w http.ResponseWriter, r *http.Request
|
||||
return
|
||||
}
|
||||
|
||||
streamIds, err := getStreams(r.Form[ParamStreamID], userID)
|
||||
streamIds, err := getStreams(r.Form[paramStreamID], userID)
|
||||
if err != nil || len(streamIds) == 0 {
|
||||
json.BadRequest(w, r, errors.New("googlereader: no valid stream IDs provided"))
|
||||
return
|
||||
}
|
||||
|
||||
newLabel, err := getStream(r.Form.Get(ParamTagsAdd), userID)
|
||||
newLabel, err := getStream(r.Form.Get(paramTagsAdd), userID)
|
||||
if err != nil {
|
||||
json.BadRequest(w, r, fmt.Errorf("googlereader: invalid data in %s", ParamTagsAdd))
|
||||
json.BadRequest(w, r, fmt.Errorf("googlereader: invalid data in %s", paramTagsAdd))
|
||||
return
|
||||
}
|
||||
|
||||
title := r.Form.Get(ParamTitle)
|
||||
action := r.Form.Get(ParamSubscribeAction)
|
||||
title := r.Form.Get(paramTitle)
|
||||
action := r.Form.Get(paramSubscribeAction)
|
||||
|
||||
switch action {
|
||||
case "subscribe":
|
||||
@@ -649,7 +649,7 @@ func (h *handler) editSubscriptionHandler(w http.ResponseWriter, r *http.Request
|
||||
}
|
||||
}
|
||||
|
||||
if r.Form.Has(ParamTagsAdd) {
|
||||
if r.Form.Has(paramTagsAdd) {
|
||||
if newLabel.Type != LabelStream {
|
||||
json.BadRequest(w, r, errors.New("destination must be a label"))
|
||||
return
|
||||
@@ -669,7 +669,7 @@ func (h *handler) editSubscriptionHandler(w http.ResponseWriter, r *http.Request
|
||||
return
|
||||
}
|
||||
|
||||
OK(w, r)
|
||||
sendOkayResponse(w)
|
||||
}
|
||||
|
||||
func (h *handler) streamItemContentsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -701,9 +701,9 @@ func (h *handler) streamItemContentsHandler(w http.ResponseWriter, r *http.Reque
|
||||
return
|
||||
}
|
||||
|
||||
userReadingList := fmt.Sprintf(UserStreamPrefix, userID) + ReadingList
|
||||
userRead := fmt.Sprintf(UserStreamPrefix, userID) + Read
|
||||
userStarred := fmt.Sprintf(UserStreamPrefix, userID) + Starred
|
||||
userReadingList := fmt.Sprintf(userStreamPrefix, userID) + readingListStreamSuffix
|
||||
userRead := fmt.Sprintf(userStreamPrefix, userID) + readStreamSuffix
|
||||
userStarred := fmt.Sprintf(userStreamPrefix, userID) + starredStreamSuffix
|
||||
|
||||
itemIDs, err := parseItemIDsFromRequest(r)
|
||||
if err != nil {
|
||||
@@ -731,19 +731,18 @@ func (h *handler) streamItemContentsHandler(w http.ResponseWriter, r *http.Reque
|
||||
return
|
||||
}
|
||||
|
||||
result := streamContentItems{
|
||||
result := streamContentItemsResponse{
|
||||
Direction: "ltr",
|
||||
ID: "user/-/state/com.google/reading-list",
|
||||
Title: "Reading List",
|
||||
Updated: time.Now().Unix(),
|
||||
Self: []contentHREF{
|
||||
{
|
||||
HREF: config.Opts.RootURL() + route.Path(h.router, "StreamItemsContents"),
|
||||
},
|
||||
},
|
||||
Self: []contentHREF{{
|
||||
HREF: config.Opts.RootURL() + route.Path(h.router, "StreamItemsContents"),
|
||||
}},
|
||||
Author: userName,
|
||||
Items: make([]contentItem, len(entries)),
|
||||
}
|
||||
contentItems := make([]contentItem, len(entries))
|
||||
|
||||
for i, entry := range entries {
|
||||
enclosures := make([]contentItemEnclosure, 0, len(entry.Enclosures))
|
||||
for _, enclosure := range entry.Enclosures {
|
||||
@@ -752,7 +751,7 @@ func (h *handler) streamItemContentsHandler(w http.ResponseWriter, r *http.Reque
|
||||
categories := make([]string, 0)
|
||||
categories = append(categories, userReadingList)
|
||||
if entry.Feed.Category.Title != "" {
|
||||
categories = append(categories, fmt.Sprintf(UserLabelPrefix, userID)+entry.Feed.Category.Title)
|
||||
categories = append(categories, fmt.Sprintf(userLabelPrefix, userID)+entry.Feed.Category.Title)
|
||||
}
|
||||
if entry.Status == model.EntryStatusRead {
|
||||
categories = append(categories, userRead)
|
||||
@@ -763,15 +762,14 @@ func (h *handler) streamItemContentsHandler(w http.ResponseWriter, r *http.Reque
|
||||
}
|
||||
|
||||
entry.Content = mediaproxy.RewriteDocumentWithAbsoluteProxyURL(h.router, entry.Content)
|
||||
entry.Enclosures.ProxifyEnclosureURL(h.router, config.Opts.MediaProxyMode(), config.Opts.MediaProxyResourceTypes())
|
||||
|
||||
entry.Enclosures.ProxifyEnclosureURL(h.router)
|
||||
|
||||
contentItems[i] = contentItem{
|
||||
result.Items[i] = contentItem{
|
||||
ID: convertEntryIDToLongFormItemID(entry.ID),
|
||||
Title: entry.Title,
|
||||
Author: entry.Author,
|
||||
TimestampUsec: fmt.Sprintf("%d", entry.Date.UnixMicro()),
|
||||
CrawlTimeMsec: fmt.Sprintf("%d", entry.CreatedAt.UnixMilli()),
|
||||
TimestampUsec: strconv.FormatInt(entry.Date.UnixMicro(), 10),
|
||||
CrawlTimeMsec: strconv.FormatInt(entry.CreatedAt.UnixMilli(), 10),
|
||||
Published: entry.Date.Unix(),
|
||||
Updated: entry.ChangedAt.Unix(),
|
||||
Categories: categories,
|
||||
@@ -795,14 +793,14 @@ 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,
|
||||
},
|
||||
Enclosure: enclosures,
|
||||
}
|
||||
}
|
||||
result.Items = contentItems
|
||||
|
||||
json.OK(w, r, result)
|
||||
}
|
||||
|
||||
@@ -823,9 +821,9 @@ func (h *handler) disableTagHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
streams, err := getStreams(r.Form[ParamStreamID], userID)
|
||||
streams, err := getStreams(r.Form[paramStreamID], userID)
|
||||
if err != nil {
|
||||
json.BadRequest(w, r, fmt.Errorf("googlereader: invalid data in %s", ParamStreamID))
|
||||
json.BadRequest(w, r, fmt.Errorf("googlereader: invalid data in %s", paramStreamID))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -844,7 +842,7 @@ func (h *handler) disableTagHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
OK(w, r)
|
||||
sendOkayResponse(w)
|
||||
}
|
||||
|
||||
func (h *handler) renameTagHandler(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -863,15 +861,15 @@ func (h *handler) renameTagHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
source, err := getStream(r.Form.Get(ParamStreamID), userID)
|
||||
source, err := getStream(r.Form.Get(paramStreamID), userID)
|
||||
if err != nil {
|
||||
json.BadRequest(w, r, fmt.Errorf("googlereader: invalid data in %s", ParamStreamID))
|
||||
json.BadRequest(w, r, fmt.Errorf("googlereader: invalid data in %s", paramStreamID))
|
||||
return
|
||||
}
|
||||
|
||||
destination, err := getStream(r.Form.Get(ParamDestination), userID)
|
||||
destination, err := getStream(r.Form.Get(paramDestination), userID)
|
||||
if err != nil {
|
||||
json.BadRequest(w, r, fmt.Errorf("googlereader: invalid data in %s", ParamDestination))
|
||||
json.BadRequest(w, r, fmt.Errorf("googlereader: invalid data in %s", paramDestination))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -911,7 +909,7 @@ func (h *handler) renameTagHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
OK(w, r)
|
||||
sendOkayResponse(w)
|
||||
}
|
||||
|
||||
func (h *handler) tagListHandler(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -935,13 +933,13 @@ func (h *handler) tagListHandler(w http.ResponseWriter, r *http.Request) {
|
||||
json.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
result.Tags = make([]subscriptionCategory, 0)
|
||||
result.Tags = append(result.Tags, subscriptionCategory{
|
||||
ID: fmt.Sprintf(UserStreamPrefix, userID) + Starred,
|
||||
result.Tags = make([]subscriptionCategoryResponse, 0)
|
||||
result.Tags = append(result.Tags, subscriptionCategoryResponse{
|
||||
ID: fmt.Sprintf(userStreamPrefix, userID) + starredStreamSuffix,
|
||||
})
|
||||
for _, category := range categories {
|
||||
result.Tags = append(result.Tags, subscriptionCategory{
|
||||
ID: fmt.Sprintf(UserLabelPrefix, userID) + category.Title,
|
||||
result.Tags = append(result.Tags, subscriptionCategoryResponse{
|
||||
ID: fmt.Sprintf(userLabelPrefix, userID) + category.Title,
|
||||
Label: category.Title,
|
||||
Type: "folder",
|
||||
})
|
||||
@@ -971,13 +969,13 @@ func (h *handler) subscriptionListHandler(w http.ResponseWriter, r *http.Request
|
||||
return
|
||||
}
|
||||
|
||||
result.Subscriptions = make([]subscription, 0)
|
||||
result.Subscriptions = make([]subscriptionResponse, 0)
|
||||
for _, feed := range feeds {
|
||||
result.Subscriptions = append(result.Subscriptions, subscription{
|
||||
ID: fmt.Sprintf(FeedPrefix+"%d", feed.ID),
|
||||
result.Subscriptions = append(result.Subscriptions, subscriptionResponse{
|
||||
ID: fmt.Sprintf(feedPrefix+"%d", feed.ID),
|
||||
Title: feed.Title,
|
||||
URL: feed.FeedURL,
|
||||
Categories: []subscriptionCategory{{fmt.Sprintf(UserLabelPrefix, userID) + feed.Category.Title, feed.Category.Title, "folder"}},
|
||||
Categories: []subscriptionCategoryResponse{{fmt.Sprintf(userLabelPrefix, userID) + feed.Category.Title, feed.Category.Title, "folder"}},
|
||||
HTMLURL: feed.SiteURL,
|
||||
IconURL: h.feedIconURL(feed),
|
||||
})
|
||||
@@ -1016,7 +1014,7 @@ func (h *handler) userInfoHandler(w http.ResponseWriter, r *http.Request) {
|
||||
json.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
userInfo := userInfo{UserID: fmt.Sprint(user.ID), UserName: user.Username, UserProfileID: fmt.Sprint(user.ID), UserEmail: user.Username}
|
||||
userInfo := userInfoResponse{UserID: fmt.Sprint(user.ID), UserName: user.Username, UserProfileID: fmt.Sprint(user.ID), UserEmail: user.Username}
|
||||
json.OK(w, r, userInfo)
|
||||
}
|
||||
|
||||
@@ -1092,7 +1090,7 @@ func (h *handler) handleReadingListStreamHandler(w http.ResponseWriter, r *http.
|
||||
slog.String("handler", "handleReadingListStreamHandler"),
|
||||
slog.String("client_ip", clientIP),
|
||||
slog.String("user_agent", r.UserAgent()),
|
||||
slog.Any("filter_type", s.Type),
|
||||
slog.Int("filter_type", int(s.Type)),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1279,14 +1277,14 @@ func (h *handler) markAllAsReadHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
stream, err := getStream(r.Form.Get(ParamStreamID), userID)
|
||||
stream, err := getStream(r.Form.Get(paramStreamID), userID)
|
||||
if err != nil {
|
||||
json.BadRequest(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
var before time.Time
|
||||
if timestampParamValue := r.Form.Get(ParamTimestamp); timestampParamValue != "" {
|
||||
if timestampParamValue := r.Form.Get(paramTimestamp); timestampParamValue != "" {
|
||||
timestampParsedValue, err := strconv.ParseInt(timestampParamValue, 10, 64)
|
||||
if err != nil {
|
||||
json.BadRequest(w, r, err)
|
||||
@@ -1340,5 +1338,5 @@ func (h *handler) markAllAsReadHandler(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
OK(w, r)
|
||||
sendOkayResponse(w)
|
||||
}
|
||||
|
||||
@@ -56,7 +56,7 @@ func parseItemID(itemIDValue string) (int64, error) {
|
||||
}
|
||||
|
||||
func parseItemIDsFromRequest(r *http.Request) ([]int64, error) {
|
||||
items := r.Form[ParamItemIDs]
|
||||
items := r.Form[paramItemIDs]
|
||||
if len(items) == 0 {
|
||||
return nil, fmt.Errorf("googlereader: no items requested")
|
||||
}
|
||||
|
||||
@@ -51,7 +51,7 @@ func (m *middleware) apiKeyAuth(next http.Handler) http.Handler {
|
||||
slog.String("user_agent", r.UserAgent()),
|
||||
slog.Any("error", err),
|
||||
)
|
||||
Unauthorized(w, r)
|
||||
sendUnauthorizedResponse(w)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -62,7 +62,7 @@ func (m *middleware) apiKeyAuth(next http.Handler) http.Handler {
|
||||
slog.String("client_ip", clientIP),
|
||||
slog.String("user_agent", r.UserAgent()),
|
||||
)
|
||||
Unauthorized(w, r)
|
||||
sendUnauthorizedResponse(w)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
@@ -74,7 +74,7 @@ func (m *middleware) apiKeyAuth(next http.Handler) http.Handler {
|
||||
slog.String("client_ip", clientIP),
|
||||
slog.String("user_agent", r.UserAgent()),
|
||||
)
|
||||
Unauthorized(w, r)
|
||||
sendUnauthorizedResponse(w)
|
||||
return
|
||||
}
|
||||
fields := strings.Fields(authorization)
|
||||
@@ -84,7 +84,7 @@ func (m *middleware) apiKeyAuth(next http.Handler) http.Handler {
|
||||
slog.String("client_ip", clientIP),
|
||||
slog.String("user_agent", r.UserAgent()),
|
||||
)
|
||||
Unauthorized(w, r)
|
||||
sendUnauthorizedResponse(w)
|
||||
return
|
||||
}
|
||||
if fields[0] != "GoogleLogin" {
|
||||
@@ -93,7 +93,7 @@ func (m *middleware) apiKeyAuth(next http.Handler) http.Handler {
|
||||
slog.String("client_ip", clientIP),
|
||||
slog.String("user_agent", r.UserAgent()),
|
||||
)
|
||||
Unauthorized(w, r)
|
||||
sendUnauthorizedResponse(w)
|
||||
return
|
||||
}
|
||||
auths := strings.Split(fields[1], "=")
|
||||
@@ -103,7 +103,7 @@ func (m *middleware) apiKeyAuth(next http.Handler) http.Handler {
|
||||
slog.String("client_ip", clientIP),
|
||||
slog.String("user_agent", r.UserAgent()),
|
||||
)
|
||||
Unauthorized(w, r)
|
||||
sendUnauthorizedResponse(w)
|
||||
return
|
||||
}
|
||||
if auths[0] != "auth" {
|
||||
@@ -112,7 +112,7 @@ func (m *middleware) apiKeyAuth(next http.Handler) http.Handler {
|
||||
slog.String("client_ip", clientIP),
|
||||
slog.String("user_agent", r.UserAgent()),
|
||||
)
|
||||
Unauthorized(w, r)
|
||||
sendUnauthorizedResponse(w)
|
||||
return
|
||||
}
|
||||
token = auths[1]
|
||||
@@ -126,7 +126,7 @@ func (m *middleware) apiKeyAuth(next http.Handler) http.Handler {
|
||||
slog.String("user_agent", r.UserAgent()),
|
||||
slog.String("token", token),
|
||||
)
|
||||
Unauthorized(w, r)
|
||||
sendUnauthorizedResponse(w)
|
||||
return
|
||||
}
|
||||
var integration *model.Integration
|
||||
@@ -139,7 +139,7 @@ func (m *middleware) apiKeyAuth(next http.Handler) http.Handler {
|
||||
slog.String("user_agent", r.UserAgent()),
|
||||
slog.Any("error", err),
|
||||
)
|
||||
Unauthorized(w, r)
|
||||
sendUnauthorizedResponse(w)
|
||||
return
|
||||
}
|
||||
expectedToken := getAuthToken(integration.GoogleReaderUsername, integration.GoogleReaderPassword)
|
||||
@@ -149,7 +149,7 @@ func (m *middleware) apiKeyAuth(next http.Handler) http.Handler {
|
||||
slog.String("client_ip", clientIP),
|
||||
slog.String("user_agent", r.UserAgent()),
|
||||
)
|
||||
Unauthorized(w, r)
|
||||
sendUnauthorizedResponse(w)
|
||||
return
|
||||
}
|
||||
if user, err = m.store.UserByID(integration.UserID); err != nil {
|
||||
@@ -159,7 +159,7 @@ func (m *middleware) apiKeyAuth(next http.Handler) http.Handler {
|
||||
slog.String("user_agent", r.UserAgent()),
|
||||
slog.Any("error", err),
|
||||
)
|
||||
Unauthorized(w, r)
|
||||
sendUnauthorizedResponse(w)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -169,7 +169,7 @@ func (m *middleware) apiKeyAuth(next http.Handler) http.Handler {
|
||||
slog.String("client_ip", clientIP),
|
||||
slog.String("user_agent", r.UserAgent()),
|
||||
)
|
||||
Unauthorized(w, r)
|
||||
sendUnauthorizedResponse(w)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -181,7 +181,7 @@ func (m *middleware) apiKeyAuth(next http.Handler) http.Handler {
|
||||
ctx = context.WithValue(ctx, request.UserTimezoneContextKey, user.Timezone)
|
||||
ctx = context.WithValue(ctx, request.IsAdminUserContextKey, user.IsAdmin)
|
||||
ctx = context.WithValue(ctx, request.IsAuthenticatedContextKey, true)
|
||||
ctx = context.WithValue(ctx, request.GoogleReaderToken, token)
|
||||
ctx = context.WithValue(ctx, request.GoogleReaderTokenKey, token)
|
||||
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
|
||||
@@ -4,36 +4,36 @@
|
||||
package googlereader // import "miniflux.app/v2/internal/googlereader"
|
||||
|
||||
const (
|
||||
// ParamItemIDs - name of the parameter with the item ids
|
||||
ParamItemIDs = "i"
|
||||
// ParamStreamID - name of the parameter containing the stream to be included
|
||||
ParamStreamID = "s"
|
||||
// ParamStreamExcludes - name of the parameter containing streams to be excluded
|
||||
ParamStreamExcludes = "xt"
|
||||
// ParamStreamFilters - name of the parameter containing streams to be included
|
||||
ParamStreamFilters = "it"
|
||||
// ParamStreamMaxItems - name of the parameter containing number of items per page/max items returned
|
||||
ParamStreamMaxItems = "n"
|
||||
// ParamStreamOrder - name of the parameter containing the sort criteria
|
||||
ParamStreamOrder = "r"
|
||||
// ParamStreamStartTime - name of the parameter containing epoch timestamp, filtering items older than
|
||||
ParamStreamStartTime = "ot"
|
||||
// ParamStreamStopTime - name of the parameter containing epoch timestamp, filtering items newer than
|
||||
ParamStreamStopTime = "nt"
|
||||
// ParamTagsRemove - name of the parameter containing tags (streams) to be removed
|
||||
ParamTagsRemove = "r"
|
||||
// ParamTagsAdd - name of the parameter containing tags (streams) to be added
|
||||
ParamTagsAdd = "a"
|
||||
// ParamSubscribeAction - name of the parameter indicating the action to take for subscription/edit
|
||||
ParamSubscribeAction = "ac"
|
||||
// ParamTitle - name of the parameter for the title of the subscription
|
||||
ParamTitle = "t"
|
||||
// ParamQuickAdd - name of the parameter for a URL being quick subscribed to
|
||||
ParamQuickAdd = "quickadd"
|
||||
// ParamDestination - name of the parameter for the new name of a tag
|
||||
ParamDestination = "dest"
|
||||
// ParamContinuation - name of the parameter for callers to pass to receive the next page of results
|
||||
ParamContinuation = "c"
|
||||
// ParamStreamType - name of the parameter for unix timestamp
|
||||
ParamTimestamp = "ts"
|
||||
// paramItemIDs - name of the parameter with the item ids
|
||||
paramItemIDs = "i"
|
||||
// paramStreamID - name of the parameter containing the stream to be included
|
||||
paramStreamID = "s"
|
||||
// paramStreamExcludes - name of the parameter containing streams to be excluded
|
||||
paramStreamExcludes = "xt"
|
||||
// paramStreamFilters - name of the parameter containing streams to be included
|
||||
paramStreamFilters = "it"
|
||||
// paramStreamMaxItems - name of the parameter containing number of items per page/max items returned
|
||||
paramStreamMaxItems = "n"
|
||||
// paramStreamOrder - name of the parameter containing the sort criteria
|
||||
paramStreamOrder = "r"
|
||||
// paramStreamStartTime - name of the parameter containing epoch timestamp, filtering items older than
|
||||
paramStreamStartTime = "ot"
|
||||
// paramStreamStopTime - name of the parameter containing epoch timestamp, filtering items newer than
|
||||
paramStreamStopTime = "nt"
|
||||
// paramTagsRemove - name of the parameter containing tags (streams) to be removed
|
||||
paramTagsRemove = "r"
|
||||
// paramTagsAdd - name of the parameter containing tags (streams) to be added
|
||||
paramTagsAdd = "a"
|
||||
// paramSubscribeAction - name of the parameter indicating the action to take for subscription/edit
|
||||
paramSubscribeAction = "ac"
|
||||
// paramTitle - name of the parameter for the title of the subscription
|
||||
paramTitle = "t"
|
||||
// paramQuickAdd - name of the parameter for a URL being quick subscribed to
|
||||
paramQuickAdd = "quickadd"
|
||||
// paramDestination - name of the parameter for the new name of a tag
|
||||
paramDestination = "dest"
|
||||
// paramContinuation - name of the parameter for callers to pass to receive the next page of results
|
||||
paramContinuation = "c"
|
||||
// paramTimestamp - name of the parameter for unix timestamp
|
||||
paramTimestamp = "ts"
|
||||
)
|
||||
|
||||
@@ -4,28 +4,28 @@
|
||||
package googlereader // import "miniflux.app/v2/internal/googlereader"
|
||||
|
||||
const (
|
||||
// StreamPrefix is the prefix for astreams (read/starred/reading list and so on)
|
||||
StreamPrefix = "user/-/state/com.google/"
|
||||
// UserStreamPrefix is the user specific prefix for streams (read/starred/reading list and so on)
|
||||
UserStreamPrefix = "user/%d/state/com.google/"
|
||||
// LabelPrefix is the prefix for a label stream
|
||||
LabelPrefix = "user/-/label/"
|
||||
// UserLabelPrefix is the user specific prefix prefix for a label stream
|
||||
UserLabelPrefix = "user/%d/label/"
|
||||
// FeedPrefix is the prefix for a feed stream
|
||||
FeedPrefix = "feed/"
|
||||
// Read is the suffix for read stream
|
||||
Read = "read"
|
||||
// Starred is the suffix for starred stream
|
||||
Starred = "starred"
|
||||
// ReadingList is the suffix for reading list stream
|
||||
ReadingList = "reading-list"
|
||||
// KeptUnread is the suffix for kept unread stream
|
||||
KeptUnread = "kept-unread"
|
||||
// Broadcast is the suffix for broadcast stream
|
||||
Broadcast = "broadcast"
|
||||
// BroadcastFriends is the suffix for broadcast friends stream
|
||||
BroadcastFriends = "broadcast-friends"
|
||||
// Like is the suffix for like stream
|
||||
Like = "like"
|
||||
// streamPrefix is the prefix for streams (read/starred/reading list and so on)
|
||||
streamPrefix = "user/-/state/com.google/"
|
||||
// userStreamPrefix is the user specific prefix for streams (read/starred/reading list and so on)
|
||||
userStreamPrefix = "user/%d/state/com.google/"
|
||||
// labelPrefix is the prefix for a label stream
|
||||
labelPrefix = "user/-/label/"
|
||||
// userLabelPrefix is the user specific prefix prefix for a label stream
|
||||
userLabelPrefix = "user/%d/label/"
|
||||
// feedPrefix is the prefix for a feed stream
|
||||
feedPrefix = "feed/"
|
||||
// readStreamSuffix is the suffix for read stream
|
||||
readStreamSuffix = "read"
|
||||
// starredStreamSuffix is the suffix for starred stream
|
||||
starredStreamSuffix = "starred"
|
||||
// readingListStreamSuffix is the suffix for reading list stream
|
||||
readingListStreamSuffix = "reading-list"
|
||||
// keptUnreadStreamSuffix is the suffix for kept unread stream
|
||||
keptUnreadStreamSuffix = "kept-unread"
|
||||
// broadcastStreamSuffix is the suffix for broadcast stream
|
||||
broadcastStreamSuffix = "broadcast"
|
||||
// broadcastFriendsStreamSuffix is the suffix for broadcast friends stream
|
||||
broadcastFriendsStreamSuffix = "broadcast-friends"
|
||||
// likeStreamSuffix is the suffix for like stream
|
||||
likeStreamSuffix = "like"
|
||||
)
|
||||
|
||||
@@ -64,28 +64,28 @@ func parseStreamFilterFromRequest(r *http.Request) (RequestModifiers, error) {
|
||||
UserID: userID,
|
||||
}
|
||||
|
||||
streamOrder := request.QueryStringParam(r, ParamStreamOrder, "d")
|
||||
streamOrder := request.QueryStringParam(r, paramStreamOrder, "d")
|
||||
if streamOrder == "o" {
|
||||
result.SortDirection = "asc"
|
||||
}
|
||||
var err error
|
||||
result.Streams, err = getStreams(request.QueryStringParamList(r, ParamStreamID), userID)
|
||||
result.Streams, err = getStreams(request.QueryStringParamList(r, paramStreamID), userID)
|
||||
if err != nil {
|
||||
return RequestModifiers{}, err
|
||||
}
|
||||
result.ExcludeTargets, err = getStreams(request.QueryStringParamList(r, ParamStreamExcludes), userID)
|
||||
result.ExcludeTargets, err = getStreams(request.QueryStringParamList(r, paramStreamExcludes), userID)
|
||||
if err != nil {
|
||||
return RequestModifiers{}, err
|
||||
}
|
||||
|
||||
result.FilterTargets, err = getStreams(request.QueryStringParamList(r, ParamStreamFilters), userID)
|
||||
result.FilterTargets, err = getStreams(request.QueryStringParamList(r, paramStreamFilters), userID)
|
||||
if err != nil {
|
||||
return RequestModifiers{}, err
|
||||
}
|
||||
|
||||
result.Count = request.QueryIntParam(r, ParamStreamMaxItems, 0)
|
||||
result.Offset = request.QueryIntParam(r, ParamContinuation, 0)
|
||||
result.StartTime = request.QueryInt64Param(r, ParamStreamStartTime, int64(0))
|
||||
result.StopTime = request.QueryInt64Param(r, ParamStreamStopTime, int64(0))
|
||||
result.Count = request.QueryIntParam(r, paramStreamMaxItems, 0)
|
||||
result.Offset = request.QueryIntParam(r, paramContinuation, 0)
|
||||
result.StartTime = request.QueryInt64Param(r, paramStreamStartTime, int64(0))
|
||||
result.StopTime = request.QueryInt64Param(r, paramStreamStopTime, int64(0))
|
||||
return result, nil
|
||||
}
|
||||
|
||||
@@ -6,34 +6,36 @@ package googlereader // import "miniflux.app/v2/internal/googlereader"
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"miniflux.app/v2/internal/http/response"
|
||||
)
|
||||
|
||||
type login struct {
|
||||
type loginResponse struct {
|
||||
SID string `json:"SID,omitempty"`
|
||||
LSID string `json:"LSID,omitempty"`
|
||||
Auth string `json:"Auth,omitempty"`
|
||||
}
|
||||
|
||||
func (l login) String() string {
|
||||
func (l loginResponse) String() string {
|
||||
return fmt.Sprintf("SID=%s\nLSID=%s\nAuth=%s\n", l.SID, l.LSID, l.Auth)
|
||||
}
|
||||
|
||||
type userInfo struct {
|
||||
type userInfoResponse struct {
|
||||
UserID string `json:"userId"`
|
||||
UserName string `json:"userName"`
|
||||
UserProfileID string `json:"userProfileId"`
|
||||
UserEmail string `json:"userEmail"`
|
||||
}
|
||||
|
||||
type subscription struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Categories []subscriptionCategory `json:"categories"`
|
||||
URL string `json:"url"`
|
||||
HTMLURL string `json:"htmlUrl"`
|
||||
IconURL string `json:"iconUrl"`
|
||||
type subscriptionResponse struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Categories []subscriptionCategoryResponse `json:"categories"`
|
||||
URL string `json:"url"`
|
||||
HTMLURL string `json:"htmlUrl"`
|
||||
IconURL string `json:"iconUrl"`
|
||||
}
|
||||
|
||||
type subscriptionsResponse struct {
|
||||
Subscriptions []subscriptionResponse `json:"subscriptions"`
|
||||
}
|
||||
|
||||
type quickAddResponse struct {
|
||||
@@ -43,14 +45,11 @@ type quickAddResponse struct {
|
||||
StreamName string `json:"streamName,omitempty"`
|
||||
}
|
||||
|
||||
type subscriptionCategory struct {
|
||||
type subscriptionCategoryResponse struct {
|
||||
ID string `json:"id"`
|
||||
Label string `json:"label,omitempty"`
|
||||
Type string `json:"type,omitempty"`
|
||||
}
|
||||
type subscriptionsResponse struct {
|
||||
Subscriptions []subscription `json:"subscriptions"`
|
||||
}
|
||||
|
||||
type itemRef struct {
|
||||
ID string `json:"id"`
|
||||
@@ -64,10 +63,10 @@ type streamIDResponse struct {
|
||||
}
|
||||
|
||||
type tagsResponse struct {
|
||||
Tags []subscriptionCategory `json:"tags"`
|
||||
Tags []subscriptionCategoryResponse `json:"tags"`
|
||||
}
|
||||
|
||||
type streamContentItems struct {
|
||||
type streamContentItemsResponse struct {
|
||||
Direction string `json:"direction"`
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
@@ -118,21 +117,15 @@ type contentItemOrigin struct {
|
||||
HTMLUrl string `json:"htmlUrl"`
|
||||
}
|
||||
|
||||
// Unauthorized sends a not authorized error to the client.
|
||||
func Unauthorized(w http.ResponseWriter, r *http.Request) {
|
||||
builder := response.New(w, r)
|
||||
builder.WithStatus(http.StatusUnauthorized)
|
||||
builder.WithHeader("Content-Type", "text/plain")
|
||||
builder.WithHeader("X-Reader-Google-Bad-Token", "true")
|
||||
builder.WithBody("Unauthorized")
|
||||
builder.Write()
|
||||
func sendUnauthorizedResponse(w http.ResponseWriter) {
|
||||
w.Header().Set("Content-Type", "text/plain")
|
||||
w.Header().Set("X-Reader-Google-Bad-Token", "true")
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
w.Write([]byte("Unauthorized"))
|
||||
}
|
||||
|
||||
// OK sends a ok response to the client.
|
||||
func OK(w http.ResponseWriter, r *http.Request) {
|
||||
builder := response.New(w, r)
|
||||
builder.WithStatus(http.StatusOK)
|
||||
builder.WithHeader("Content-Type", "text/plain")
|
||||
builder.WithBody("OK")
|
||||
builder.Write()
|
||||
func sendOkayResponse(w http.ResponseWriter) {
|
||||
w.Header().Set("Content-Type", "text/plain")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("OK"))
|
||||
}
|
||||
|
||||
@@ -72,32 +72,32 @@ func (st StreamType) String() string {
|
||||
|
||||
func getStream(streamID string, userID int64) (Stream, error) {
|
||||
switch {
|
||||
case strings.HasPrefix(streamID, FeedPrefix):
|
||||
return Stream{Type: FeedStream, ID: strings.TrimPrefix(streamID, FeedPrefix)}, nil
|
||||
case strings.HasPrefix(streamID, fmt.Sprintf(UserStreamPrefix, userID)) || strings.HasPrefix(streamID, StreamPrefix):
|
||||
id := strings.TrimPrefix(streamID, fmt.Sprintf(UserStreamPrefix, userID))
|
||||
id = strings.TrimPrefix(id, StreamPrefix)
|
||||
case strings.HasPrefix(streamID, feedPrefix):
|
||||
return Stream{Type: FeedStream, ID: strings.TrimPrefix(streamID, feedPrefix)}, nil
|
||||
case strings.HasPrefix(streamID, fmt.Sprintf(userStreamPrefix, userID)), strings.HasPrefix(streamID, streamPrefix):
|
||||
id := strings.TrimPrefix(streamID, fmt.Sprintf(userStreamPrefix, userID))
|
||||
id = strings.TrimPrefix(id, streamPrefix)
|
||||
switch id {
|
||||
case Read:
|
||||
case readStreamSuffix:
|
||||
return Stream{ReadStream, ""}, nil
|
||||
case Starred:
|
||||
case starredStreamSuffix:
|
||||
return Stream{StarredStream, ""}, nil
|
||||
case ReadingList:
|
||||
case readingListStreamSuffix:
|
||||
return Stream{ReadingListStream, ""}, nil
|
||||
case KeptUnread:
|
||||
case keptUnreadStreamSuffix:
|
||||
return Stream{KeptUnreadStream, ""}, nil
|
||||
case Broadcast:
|
||||
case broadcastStreamSuffix:
|
||||
return Stream{BroadcastStream, ""}, nil
|
||||
case BroadcastFriends:
|
||||
case broadcastFriendsStreamSuffix:
|
||||
return Stream{BroadcastFriendsStream, ""}, nil
|
||||
case Like:
|
||||
case likeStreamSuffix:
|
||||
return Stream{LikeStream, ""}, nil
|
||||
default:
|
||||
return Stream{NoStream, ""}, fmt.Errorf("googlereader: unknown stream with id: %s", id)
|
||||
}
|
||||
case strings.HasPrefix(streamID, fmt.Sprintf(UserLabelPrefix, userID)) || strings.HasPrefix(streamID, LabelPrefix):
|
||||
id := strings.TrimPrefix(streamID, fmt.Sprintf(UserLabelPrefix, userID))
|
||||
id = strings.TrimPrefix(id, LabelPrefix)
|
||||
case strings.HasPrefix(streamID, fmt.Sprintf(userLabelPrefix, userID)), strings.HasPrefix(streamID, labelPrefix):
|
||||
id := strings.TrimPrefix(streamID, fmt.Sprintf(userLabelPrefix, userID))
|
||||
id = strings.TrimPrefix(id, labelPrefix)
|
||||
return Stream{LabelStream, id}, nil
|
||||
case streamID == "":
|
||||
return Stream{NoStream, ""}, nil
|
||||
@@ -107,7 +107,7 @@ func getStream(streamID string, userID int64) (Stream, error) {
|
||||
}
|
||||
|
||||
func getStreams(streamIDs []string, userID int64) ([]Stream, error) {
|
||||
streams := make([]Stream, 0)
|
||||
streams := make([]Stream, 0, len(streamIDs))
|
||||
for _, streamID := range streamIDs {
|
||||
stream, err := getStream(streamID, userID)
|
||||
if err != nil {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
|
||||
// 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"}
|
||||
headers := [...]string{"X-Forwarded-For", "X-Real-Ip"}
|
||||
for _, header := range headers {
|
||||
value := r.Header.Get(header)
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ package request // import "miniflux.app/v2/internal/http/request"
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"miniflux.app/v2/internal/model"
|
||||
)
|
||||
@@ -29,10 +30,9 @@ const (
|
||||
OAuth2CodeVerifierContextKey
|
||||
FlashMessageContextKey
|
||||
FlashErrorMessageContextKey
|
||||
PocketRequestTokenContextKey
|
||||
LastForceRefreshContextKey
|
||||
ClientIPContextKey
|
||||
GoogleReaderToken
|
||||
GoogleReaderTokenKey
|
||||
WebAuthnDataContextKey
|
||||
)
|
||||
|
||||
@@ -45,9 +45,9 @@ func WebAuthnSessionData(r *http.Request) *model.WebAuthnSession {
|
||||
return nil
|
||||
}
|
||||
|
||||
// GoolgeReaderToken returns the google reader token if it exists.
|
||||
func GoolgeReaderToken(r *http.Request) string {
|
||||
return getContextStringValue(r, GoogleReaderToken)
|
||||
// GoogleReaderToken returns the google reader token if it exists.
|
||||
func GoogleReaderToken(r *http.Request) string {
|
||||
return getContextStringValue(r, GoogleReaderTokenKey)
|
||||
}
|
||||
|
||||
// IsAdminUser checks if the logged user is administrator.
|
||||
@@ -135,19 +135,14 @@ func FlashErrorMessage(r *http.Request) string {
|
||||
return getContextStringValue(r, FlashErrorMessageContextKey)
|
||||
}
|
||||
|
||||
// PocketRequestToken returns the Pocket Request Token if any.
|
||||
func PocketRequestToken(r *http.Request) string {
|
||||
return getContextStringValue(r, PocketRequestTokenContextKey)
|
||||
}
|
||||
|
||||
// LastForceRefresh returns the last force refresh timestamp.
|
||||
func LastForceRefresh(r *http.Request) int64 {
|
||||
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.
|
||||
|
||||
@@ -390,28 +390,6 @@ func TestFlashErrorMessage(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPocketRequestToken(t *testing.T) {
|
||||
r, _ := http.NewRequest("GET", "http://example.org", nil)
|
||||
|
||||
result := PocketRequestToken(r)
|
||||
expected := ""
|
||||
|
||||
if result != expected {
|
||||
t.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
ctx = context.WithValue(ctx, PocketRequestTokenContextKey, "request token")
|
||||
r = r.WithContext(ctx)
|
||||
|
||||
result = PocketRequestToken(r)
|
||||
expected = "request token"
|
||||
|
||||
if result != expected {
|
||||
t.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientIP(t *testing.T) {
|
||||
r, _ := http.NewRequest("GET", "http://example.org", nil)
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ package response // import "miniflux.app/v2/internal/http/response"
|
||||
import (
|
||||
"compress/flate"
|
||||
"compress/gzip"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
@@ -48,7 +47,7 @@ func (b *Builder) WithBody(body any) *Builder {
|
||||
|
||||
// WithAttachment forces the document to be downloaded by the web browser.
|
||||
func (b *Builder) WithAttachment(filename string) *Builder {
|
||||
b.headers["Content-Disposition"] = fmt.Sprintf("attachment; filename=%s", filename)
|
||||
b.headers["Content-Disposition"] = "attachment; filename=" + filename
|
||||
return b
|
||||
}
|
||||
|
||||
@@ -85,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()
|
||||
@@ -118,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 {
|
||||
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
)
|
||||
|
||||
// OK creates a new HTML response with a 200 status code.
|
||||
func OK(w http.ResponseWriter, r *http.Request, body interface{}) {
|
||||
func OK[T []byte | string](w http.ResponseWriter, r *http.Request, body T) {
|
||||
builder := response.New(w, r)
|
||||
builder.WithHeader("Content-Type", "text/html; charset=utf-8")
|
||||
builder.WithHeader("Cache-Control", "no-cache, max-age=0, must-revalidate, no-store")
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
)
|
||||
|
||||
// OK writes a standard XML response with a status 200 OK.
|
||||
func OK(w http.ResponseWriter, r *http.Request, body interface{}) {
|
||||
func OK[T []byte | string](w http.ResponseWriter, r *http.Request, body T) {
|
||||
builder := response.New(w, r)
|
||||
builder.WithHeader("Content-Type", "text/xml; charset=utf-8")
|
||||
builder.WithBody(body)
|
||||
@@ -18,7 +18,7 @@ func OK(w http.ResponseWriter, r *http.Request, body interface{}) {
|
||||
}
|
||||
|
||||
// Attachment forces the XML document to be downloaded by the web browser.
|
||||
func Attachment(w http.ResponseWriter, r *http.Request, filename string, body interface{}) {
|
||||
func Attachment[T []byte | string](w http.ResponseWriter, r *http.Request, filename string, body T) {
|
||||
builder := response.New(w, r)
|
||||
builder.WithHeader("Content-Type", "text/xml; charset=utf-8")
|
||||
builder.WithAttachment(filename)
|
||||
|
||||
+111
-81
@@ -1,7 +1,7 @@
|
||||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package httpd // import "miniflux.app/v2/internal/http/server"
|
||||
package server // import "miniflux.app/v2/internal/http/server"
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
@@ -12,7 +12,6 @@ import (
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"miniflux.app/v2/internal/api"
|
||||
"miniflux.app/v2/internal/config"
|
||||
@@ -30,36 +29,84 @@ import (
|
||||
"golang.org/x/crypto/acme/autocert"
|
||||
)
|
||||
|
||||
func StartWebServer(store *storage.Storage, pool *worker.Pool) *http.Server {
|
||||
func StartWebServer(store *storage.Storage, pool *worker.Pool) []*http.Server {
|
||||
listenAddresses := config.Opts.ListenAddr()
|
||||
var httpServers []*http.Server
|
||||
|
||||
certFile := config.Opts.CertFile()
|
||||
keyFile := config.Opts.CertKeyFile()
|
||||
certDomain := config.Opts.CertDomain()
|
||||
listenAddr := config.Opts.ListenAddr()
|
||||
server := &http.Server{
|
||||
ReadTimeout: time.Duration(config.Opts.HTTPServerTimeout()) * time.Second,
|
||||
WriteTimeout: time.Duration(config.Opts.HTTPServerTimeout()) * time.Second,
|
||||
IdleTimeout: time.Duration(config.Opts.HTTPServerTimeout()) * time.Second,
|
||||
Handler: setupHandler(store, pool),
|
||||
var sharedAutocertTLSConfig *tls.Config
|
||||
|
||||
if certDomain != "" {
|
||||
slog.Debug("Configuring autocert manager and shared TLS config", slog.String("domain", certDomain))
|
||||
certManager := autocert.Manager{
|
||||
Cache: storage.NewCertificateCache(store),
|
||||
Prompt: autocert.AcceptTOS,
|
||||
HostPolicy: autocert.HostWhitelist(certDomain),
|
||||
}
|
||||
|
||||
sharedAutocertTLSConfig = &tls.Config{}
|
||||
sharedAutocertTLSConfig.GetCertificate = certManager.GetCertificate
|
||||
sharedAutocertTLSConfig.NextProtos = []string{"h2", "http/1.1", acme.ALPNProto}
|
||||
|
||||
challengeServer := &http.Server{
|
||||
Handler: certManager.HTTPHandler(nil),
|
||||
Addr: ":http",
|
||||
}
|
||||
slog.Info("Starting ACME HTTP challenge server for autocert", slog.String("address", challengeServer.Addr))
|
||||
go func() {
|
||||
if err := challengeServer.ListenAndServe(); err != http.ErrServerClosed {
|
||||
slog.Error("ACME HTTP challenge server failed", slog.Any("error", err))
|
||||
}
|
||||
}()
|
||||
config.Opts.SetHTTPSValue(true)
|
||||
httpServers = append(httpServers, challengeServer)
|
||||
}
|
||||
|
||||
switch {
|
||||
case os.Getenv("LISTEN_PID") == strconv.Itoa(os.Getpid()):
|
||||
startSystemdSocketServer(server)
|
||||
case strings.HasPrefix(listenAddr, "/"):
|
||||
startUnixSocketServer(server, listenAddr)
|
||||
case certDomain != "":
|
||||
config.Opts.HTTPS = true
|
||||
startAutoCertTLSServer(server, certDomain, store)
|
||||
case certFile != "" && keyFile != "":
|
||||
config.Opts.HTTPS = true
|
||||
server.Addr = listenAddr
|
||||
startTLSServer(server, certFile, keyFile)
|
||||
default:
|
||||
server.Addr = listenAddr
|
||||
startHTTPServer(server)
|
||||
for i, listenAddr := range listenAddresses {
|
||||
server := &http.Server{
|
||||
ReadTimeout: config.Opts.HTTPServerTimeout(),
|
||||
WriteTimeout: config.Opts.HTTPServerTimeout(),
|
||||
IdleTimeout: config.Opts.HTTPServerTimeout(),
|
||||
Handler: setupHandler(store, pool),
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(listenAddr, "/") && os.Getenv("LISTEN_PID") != strconv.Itoa(os.Getpid()) {
|
||||
server.Addr = listenAddr
|
||||
}
|
||||
|
||||
shouldAddServer := true
|
||||
|
||||
switch {
|
||||
case os.Getenv("LISTEN_PID") == strconv.Itoa(os.Getpid()):
|
||||
if i == 0 {
|
||||
slog.Info("Starting server using systemd socket for the first listen address", slog.String("address_info", listenAddr))
|
||||
startSystemdSocketServer(server)
|
||||
} else {
|
||||
slog.Warn("Systemd socket activation: Only the first listen address is used by systemd. Other addresses ignored.", slog.String("skipped_address", listenAddr))
|
||||
shouldAddServer = false
|
||||
}
|
||||
case strings.HasPrefix(listenAddr, "/"): // Unix socket
|
||||
startUnixSocketServer(server, listenAddr)
|
||||
case certDomain != "" && (listenAddr == ":https" || (i == 0 && strings.Contains(listenAddr, ":"))):
|
||||
server.Addr = listenAddr
|
||||
startAutoCertTLSServer(server, sharedAutocertTLSConfig)
|
||||
case certFile != "" && keyFile != "":
|
||||
server.Addr = listenAddr
|
||||
startTLSServer(server, certFile, keyFile)
|
||||
config.Opts.SetHTTPSValue(true)
|
||||
default:
|
||||
server.Addr = listenAddr
|
||||
startHTTPServer(server)
|
||||
}
|
||||
|
||||
if shouldAddServer {
|
||||
httpServers = append(httpServers, server)
|
||||
}
|
||||
}
|
||||
|
||||
return server
|
||||
return httpServers
|
||||
}
|
||||
|
||||
func startSystemdSocketServer(server *http.Server) {
|
||||
@@ -72,83 +119,66 @@ func startSystemdSocketServer(server *http.Server) {
|
||||
|
||||
slog.Info(`Starting server using systemd socket`)
|
||||
if err := server.Serve(listener); err != http.ErrServerClosed {
|
||||
printErrorAndExit(`Server failed to start: %v`, err)
|
||||
printErrorAndExit(`Systemd socket server failed to start: %v`, err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func startUnixSocketServer(server *http.Server, socketFile string) {
|
||||
os.Remove(socketFile)
|
||||
if err := os.Remove(socketFile); err != nil && !os.IsNotExist(err) {
|
||||
printErrorAndExit("Unable to remove existing Unix socket %s: %v", socketFile, err)
|
||||
}
|
||||
listener, err := net.Listen("unix", socketFile)
|
||||
if err != nil {
|
||||
printErrorAndExit(`Server failed to listen on Unix socket %s: %v`, socketFile, err)
|
||||
}
|
||||
|
||||
go func(sock string) {
|
||||
listener, err := net.Listen("unix", sock)
|
||||
if err != nil {
|
||||
printErrorAndExit(`Server failed to start: %v`, err)
|
||||
}
|
||||
defer listener.Close()
|
||||
if err := os.Chmod(socketFile, 0666); err != nil {
|
||||
printErrorAndExit(`Unable to change socket permission for %s: %v`, socketFile, err)
|
||||
}
|
||||
|
||||
if err := os.Chmod(sock, 0666); err != nil {
|
||||
printErrorAndExit(`Unable to change socket permission: %v`, err)
|
||||
}
|
||||
go func() {
|
||||
certFile := config.Opts.CertFile()
|
||||
keyFile := config.Opts.CertKeyFile()
|
||||
|
||||
slog.Info("Starting server using a Unix socket", slog.String("socket", sock))
|
||||
if err := server.Serve(listener); err != http.ErrServerClosed {
|
||||
printErrorAndExit(`Server failed to start: %v`, err)
|
||||
if certFile != "" && keyFile != "" {
|
||||
slog.Info("Starting TLS server using a Unix socket",
|
||||
slog.String("socket", socketFile),
|
||||
slog.String("cert_file", certFile),
|
||||
slog.String("key_file", keyFile),
|
||||
)
|
||||
// Ensure HTTPS is marked as true if any listener uses TLS
|
||||
config.Opts.SetHTTPSValue(true)
|
||||
if err := server.ServeTLS(listener, certFile, keyFile); err != http.ErrServerClosed {
|
||||
printErrorAndExit("TLS Unix socket server failed to start on %s: %v", socketFile, err)
|
||||
}
|
||||
} else {
|
||||
slog.Info("Starting server using a Unix socket", slog.String("socket", socketFile))
|
||||
if err := server.Serve(listener); err != http.ErrServerClosed {
|
||||
printErrorAndExit("Unix socket server failed to start on %s: %v", socketFile, err)
|
||||
}
|
||||
}
|
||||
}(socketFile)
|
||||
}()
|
||||
}
|
||||
|
||||
func tlsConfig() *tls.Config {
|
||||
// See https://blog.cloudflare.com/exposing-go-on-the-internet/
|
||||
// And https://wiki.mozilla.org/Security/Server_Side_TLS
|
||||
return &tls.Config{
|
||||
MinVersion: tls.VersionTLS12,
|
||||
CurvePreferences: []tls.CurveID{
|
||||
tls.CurveP256,
|
||||
tls.X25519,
|
||||
},
|
||||
CipherSuites: []uint16{
|
||||
tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384,
|
||||
tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384,
|
||||
tls.TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305,
|
||||
tls.TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305,
|
||||
tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,
|
||||
tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,
|
||||
},
|
||||
func startAutoCertTLSServer(server *http.Server, autoTLSConfig *tls.Config) {
|
||||
if server.TLSConfig == nil {
|
||||
server.TLSConfig = &tls.Config{}
|
||||
}
|
||||
}
|
||||
|
||||
func startAutoCertTLSServer(server *http.Server, certDomain string, store *storage.Storage) {
|
||||
server.Addr = ":https"
|
||||
certManager := autocert.Manager{
|
||||
Cache: storage.NewCertificateCache(store),
|
||||
Prompt: autocert.AcceptTOS,
|
||||
HostPolicy: autocert.HostWhitelist(certDomain),
|
||||
}
|
||||
server.TLSConfig = tlsConfig()
|
||||
server.TLSConfig.GetCertificate = certManager.GetCertificate
|
||||
server.TLSConfig.NextProtos = []string{"h2", "http/1.1", acme.ALPNProto}
|
||||
|
||||
// Handle http-01 challenge.
|
||||
s := &http.Server{
|
||||
Handler: certManager.HTTPHandler(nil),
|
||||
Addr: ":http",
|
||||
}
|
||||
go s.ListenAndServe()
|
||||
server.TLSConfig.GetCertificate = autoTLSConfig.GetCertificate
|
||||
server.TLSConfig.NextProtos = autoTLSConfig.NextProtos
|
||||
|
||||
go func() {
|
||||
slog.Info("Starting TLS server using automatic certificate management",
|
||||
slog.String("listen_address", server.Addr),
|
||||
slog.String("domain", certDomain),
|
||||
)
|
||||
if err := server.ListenAndServeTLS("", ""); err != http.ErrServerClosed {
|
||||
printErrorAndExit(`Server failed to start: %v`, err)
|
||||
printErrorAndExit("Autocert server failed to start on %s: %v", server.Addr, err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func startTLSServer(server *http.Server, certFile, keyFile string) {
|
||||
server.TLSConfig = tlsConfig()
|
||||
go func() {
|
||||
slog.Info("Starting TLS server using a certificate",
|
||||
slog.String("listen_address", server.Addr),
|
||||
@@ -156,7 +186,7 @@ func startTLSServer(server *http.Server, certFile, keyFile string) {
|
||||
slog.String("key_file", keyFile),
|
||||
)
|
||||
if err := server.ListenAndServeTLS(certFile, keyFile); err != http.ErrServerClosed {
|
||||
printErrorAndExit(`Server failed to start: %v`, err)
|
||||
printErrorAndExit("TLS server failed to start on %s: %v", server.Addr, err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
@@ -167,7 +197,7 @@ func startHTTPServer(server *http.Server) {
|
||||
slog.String("listen_address", server.Addr),
|
||||
)
|
||||
if err := server.ListenAndServe(); err != http.ErrServerClosed {
|
||||
printErrorAndExit(`Server failed to start: %v`, err)
|
||||
printErrorAndExit("HTTP server failed to start on %s: %v", server.Addr, err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package httpd // import "miniflux.app/v2/internal/http/server"
|
||||
package server // import "miniflux.app/v2/internal/http/server"
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -20,7 +20,7 @@ func middleware(next http.Handler) http.Handler {
|
||||
ctx = context.WithValue(ctx, request.ClientIPContextKey, clientIP)
|
||||
|
||||
if r.Header.Get("X-Forwarded-Proto") == "https" {
|
||||
config.Opts.HTTPS = true
|
||||
config.Opts.SetHTTPSValue(true)
|
||||
}
|
||||
|
||||
t1 := time.Now()
|
||||
@@ -36,7 +36,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,15 +6,16 @@ package integration // import "miniflux.app/v2/internal/integration"
|
||||
import (
|
||||
"log/slog"
|
||||
|
||||
"miniflux.app/v2/internal/config"
|
||||
"miniflux.app/v2/internal/integration/apprise"
|
||||
"miniflux.app/v2/internal/integration/betula"
|
||||
"miniflux.app/v2/internal/integration/cubox"
|
||||
"miniflux.app/v2/internal/integration/discord"
|
||||
"miniflux.app/v2/internal/integration/espial"
|
||||
"miniflux.app/v2/internal/integration/instapaper"
|
||||
"miniflux.app/v2/internal/integration/karakeep"
|
||||
"miniflux.app/v2/internal/integration/linkace"
|
||||
"miniflux.app/v2/internal/integration/linkding"
|
||||
"miniflux.app/v2/internal/integration/linktaco"
|
||||
"miniflux.app/v2/internal/integration/linkwarden"
|
||||
"miniflux.app/v2/internal/integration/matrixbot"
|
||||
"miniflux.app/v2/internal/integration/notion"
|
||||
@@ -22,7 +23,6 @@ import (
|
||||
"miniflux.app/v2/internal/integration/nunuxkeeper"
|
||||
"miniflux.app/v2/internal/integration/omnivore"
|
||||
"miniflux.app/v2/internal/integration/pinboard"
|
||||
"miniflux.app/v2/internal/integration/pocket"
|
||||
"miniflux.app/v2/internal/integration/pushover"
|
||||
"miniflux.app/v2/internal/integration/raindrop"
|
||||
"miniflux.app/v2/internal/integration/readeck"
|
||||
@@ -108,6 +108,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),
|
||||
)
|
||||
@@ -118,12 +119,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),
|
||||
@@ -196,24 +199,6 @@ func SendEntry(entry *model.Entry, userIntegrations *model.Integration) {
|
||||
}
|
||||
}
|
||||
|
||||
if userIntegrations.PocketEnabled {
|
||||
slog.Debug("Sending entry to Pocket",
|
||||
slog.Int64("user_id", userIntegrations.UserID),
|
||||
slog.Int64("entry_id", entry.ID),
|
||||
slog.String("entry_url", entry.URL),
|
||||
)
|
||||
|
||||
client := pocket.NewClient(config.Opts.PocketConsumerKey(userIntegrations.PocketConsumerKey), userIntegrations.PocketAccessToken)
|
||||
if err := client.AddURL(entry.URL, entry.Title); err != nil {
|
||||
slog.Error("Unable to send entry to Pocket",
|
||||
slog.Int64("user_id", userIntegrations.UserID),
|
||||
slog.Int64("entry_id", entry.ID),
|
||||
slog.String("entry_url", entry.URL),
|
||||
slog.Any("error", err),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if userIntegrations.LinkAceEnabled {
|
||||
slog.Debug("Sending entry to LinkAce",
|
||||
slog.Int64("user_id", userIntegrations.UserID),
|
||||
@@ -261,6 +246,29 @@ func SendEntry(entry *model.Entry, userIntegrations *model.Integration) {
|
||||
}
|
||||
}
|
||||
|
||||
if userIntegrations.LinktacoEnabled {
|
||||
slog.Debug("Sending entry to LinkTaco",
|
||||
slog.Int64("user_id", userIntegrations.UserID),
|
||||
slog.Int64("entry_id", entry.ID),
|
||||
slog.String("entry_url", entry.URL),
|
||||
)
|
||||
|
||||
client := linktaco.NewClient(
|
||||
userIntegrations.LinktacoAPIToken,
|
||||
userIntegrations.LinktacoOrgSlug,
|
||||
userIntegrations.LinktacoTags,
|
||||
userIntegrations.LinktacoVisibility,
|
||||
)
|
||||
if err := client.CreateBookmark(entry.URL, entry.Title, entry.Content); err != nil {
|
||||
slog.Error("Unable to send entry to LinkTaco",
|
||||
slog.Int64("user_id", userIntegrations.UserID),
|
||||
slog.Int64("entry_id", entry.ID),
|
||||
slog.String("entry_url", entry.URL),
|
||||
slog.Any("error", err),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if userIntegrations.LinkwardenEnabled {
|
||||
slog.Debug("Sending entry to linkwarden",
|
||||
slog.Int64("user_id", userIntegrations.UserID),
|
||||
@@ -391,20 +399,27 @@ func SendEntry(entry *model.Entry, userIntegrations *model.Integration) {
|
||||
}
|
||||
|
||||
if userIntegrations.WebhookEnabled {
|
||||
var webhookURL string
|
||||
if entry.Feed != nil && entry.Feed.WebhookURL != "" {
|
||||
webhookURL = entry.Feed.WebhookURL
|
||||
} else {
|
||||
webhookURL = userIntegrations.WebhookURL
|
||||
}
|
||||
|
||||
slog.Debug("Sending entry to Webhook",
|
||||
slog.Int64("user_id", userIntegrations.UserID),
|
||||
slog.Int64("entry_id", entry.ID),
|
||||
slog.String("entry_url", entry.URL),
|
||||
slog.String("webhook_url", userIntegrations.WebhookURL),
|
||||
slog.String("webhook_url", webhookURL),
|
||||
)
|
||||
|
||||
webhookClient := webhook.NewClient(userIntegrations.WebhookURL, userIntegrations.WebhookSecret)
|
||||
webhookClient := webhook.NewClient(webhookURL, userIntegrations.WebhookSecret)
|
||||
if err := webhookClient.SendSaveEntryWebhookEvent(entry); err != nil {
|
||||
slog.Error("Unable to send entry to Webhook",
|
||||
slog.Int64("user_id", userIntegrations.UserID),
|
||||
slog.Int64("entry_id", entry.ID),
|
||||
slog.String("entry_url", entry.URL),
|
||||
slog.String("webhook_url", userIntegrations.WebhookURL),
|
||||
slog.String("webhook_url", webhookURL),
|
||||
slog.Any("error", err),
|
||||
)
|
||||
}
|
||||
@@ -428,6 +443,24 @@ func SendEntry(entry *model.Entry, userIntegrations *model.Integration) {
|
||||
}
|
||||
}
|
||||
|
||||
if userIntegrations.KarakeepEnabled {
|
||||
slog.Debug("Sending entry to Karakeep",
|
||||
slog.Int64("user_id", userIntegrations.UserID),
|
||||
slog.Int64("entry_id", entry.ID),
|
||||
slog.String("entry_url", entry.URL),
|
||||
)
|
||||
|
||||
client := karakeep.NewClient(userIntegrations.KarakeepAPIKey, userIntegrations.KarakeepURL)
|
||||
if err := client.SaveURL(entry.URL); err != nil {
|
||||
slog.Error("Unable to send entry to Karakeep",
|
||||
slog.Int64("user_id", userIntegrations.UserID),
|
||||
slog.Int64("entry_id", entry.ID),
|
||||
slog.String("entry_url", entry.URL),
|
||||
slog.Any("error", err),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if userIntegrations.RaindropEnabled {
|
||||
slog.Debug("Sending entry to Raindrop",
|
||||
slog.Int64("user_id", userIntegrations.UserID),
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package karakeep // import "miniflux.app/v2/internal/integration/karakeep"
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"miniflux.app/v2/internal/version"
|
||||
)
|
||||
|
||||
const defaultClientTimeout = 10 * time.Second
|
||||
|
||||
type errorResponse struct {
|
||||
Code string `json:"code"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
type saveURLPayload struct {
|
||||
Type string `json:"type"`
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
type Client struct {
|
||||
wrapped *http.Client
|
||||
apiEndpoint string
|
||||
apiToken string
|
||||
}
|
||||
|
||||
func NewClient(apiToken string, apiEndpoint string) *Client {
|
||||
return &Client{wrapped: &http.Client{Timeout: defaultClientTimeout}, apiEndpoint: apiEndpoint, apiToken: apiToken}
|
||||
}
|
||||
|
||||
func (c *Client) SaveURL(entryURL string) error {
|
||||
requestBody, err := json.Marshal(&saveURLPayload{
|
||||
Type: "link",
|
||||
URL: entryURL,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("karakeep: unable to encode request body: %v", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(http.MethodPost, c.apiEndpoint, bytes.NewReader(requestBody))
|
||||
if err != nil {
|
||||
return fmt.Errorf("karakeep: unable to create request: %v", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Authorization", "Bearer "+c.apiToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("User-Agent", "Miniflux/"+version.Version)
|
||||
|
||||
resp, err := c.wrapped.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("karakeep: unable to send request: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
responseBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("karakeep: failed to parse response: %s", err)
|
||||
}
|
||||
|
||||
if resp.Header.Get("Content-Type") != "application/json" {
|
||||
return fmt.Errorf("karakeep: unexpected content type response: %s", resp.Header.Get("Content-Type"))
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
var errResponse errorResponse
|
||||
if err := json.Unmarshal(responseBody, &errResponse); err != nil {
|
||||
return fmt.Errorf("karakeep: unable to parse error response: status=%d body=%s", resp.StatusCode, string(responseBody))
|
||||
}
|
||||
return fmt.Errorf("karakeep: failed to save URL: status=%d errorcode=%s %s", resp.StatusCode, errResponse.Code, errResponse.Error)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
// 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"
|
||||
"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 fmt.Errorf("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, ",")
|
||||
}
|
||||
}
|
||||
@@ -35,12 +35,9 @@ func (c *Client) CreateBookmark(entryURL, entryTitle string) error {
|
||||
return fmt.Errorf(`linkwarden: invalid API endpoint: %v`, err)
|
||||
}
|
||||
|
||||
requestBody, err := json.Marshal(&linkwardenBookmark{
|
||||
Url: entryURL,
|
||||
Name: "",
|
||||
Description: "",
|
||||
Tags: []string{},
|
||||
Collection: map[string]interface{}{},
|
||||
requestBody, err := json.Marshal(map[string]string{
|
||||
"url": entryURL,
|
||||
"name": entryTitle,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
@@ -54,8 +51,7 @@ func (c *Client) CreateBookmark(entryURL, entryTitle string) error {
|
||||
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
|
||||
request.AddCookie(&http.Cookie{Name: "__Secure-next-auth.session-token", Value: c.apiKey})
|
||||
request.AddCookie(&http.Cookie{Name: "next-auth.session-token", Value: c.apiKey})
|
||||
request.Header.Set("Authorization", "Bearer "+c.apiKey)
|
||||
|
||||
httpClient := &http.Client{Timeout: defaultClientTimeout}
|
||||
response, err := httpClient.Do(request)
|
||||
@@ -70,11 +66,3 @@ func (c *Client) CreateBookmark(entryURL, entryTitle string) error {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type linkwardenBookmark struct {
|
||||
Url string `json:"url"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Tags []string `json:"tags"`
|
||||
Collection map[string]interface{} `json:"collection"`
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -74,10 +74,10 @@ func NewClient(apiToken string, apiEndpoint string) Client {
|
||||
}
|
||||
|
||||
func (c *client) SaveUrl(url string) error {
|
||||
var payload = map[string]interface{}{
|
||||
var payload = map[string]any{
|
||||
"query": mutation,
|
||||
"variables": map[string]interface{}{
|
||||
"input": map[string]interface{}{
|
||||
"variables": map[string]any{
|
||||
"input": map[string]any{
|
||||
"clientRequestId": crypto.GenerateUUID(),
|
||||
"source": "api",
|
||||
"url": url,
|
||||
|
||||
@@ -1,132 +0,0 @@
|
||||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package pocket // import "miniflux.app/v2/internal/integration/pocket"
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"miniflux.app/v2/internal/version"
|
||||
)
|
||||
|
||||
// Connector manages the authorization flow with Pocket to get a personal access token.
|
||||
type Connector struct {
|
||||
consumerKey string
|
||||
}
|
||||
|
||||
// NewConnector returns a new Pocket Connector.
|
||||
func NewConnector(consumerKey string) *Connector {
|
||||
return &Connector{consumerKey}
|
||||
}
|
||||
|
||||
// RequestToken fetches a new request token from Pocket API.
|
||||
func (c *Connector) RequestToken(redirectURL string) (string, error) {
|
||||
apiEndpoint := "https://getpocket.com/v3/oauth/request"
|
||||
requestBody, err := json.Marshal(&createTokenRequest{ConsumerKey: c.consumerKey, RedirectURI: redirectURL})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("pocket: unable to encode request body: %v", err)
|
||||
}
|
||||
|
||||
request, err := http.NewRequest(http.MethodPost, apiEndpoint, bytes.NewReader(requestBody))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("pocket: unable to create request: %v", err)
|
||||
}
|
||||
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request.Header.Set("X-Accept", "application/json")
|
||||
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
|
||||
|
||||
httpClient := &http.Client{Timeout: defaultClientTimeout}
|
||||
response, err := httpClient.Do(request)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("pocket: unable to send request: %v", err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
|
||||
if response.StatusCode >= 400 {
|
||||
return "", fmt.Errorf("pocket: unable get request token: url=%s status=%d", apiEndpoint, response.StatusCode)
|
||||
}
|
||||
|
||||
var result createTokenResponse
|
||||
if err := json.NewDecoder(response.Body).Decode(&result); err != nil {
|
||||
return "", fmt.Errorf("pocket: unable to decode response: %v", err)
|
||||
}
|
||||
|
||||
if result.Code == "" {
|
||||
return "", errors.New("pocket: request token is empty")
|
||||
}
|
||||
|
||||
return result.Code, nil
|
||||
}
|
||||
|
||||
// AccessToken fetches a new access token once the end-user authorized the application.
|
||||
func (c *Connector) AccessToken(requestToken string) (string, error) {
|
||||
apiEndpoint := "https://getpocket.com/v3/oauth/authorize"
|
||||
requestBody, err := json.Marshal(&authorizeRequest{ConsumerKey: c.consumerKey, Code: requestToken})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("pocket: unable to encode request body: %v", err)
|
||||
}
|
||||
|
||||
request, err := http.NewRequest(http.MethodPost, apiEndpoint, bytes.NewReader(requestBody))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("pocket: unable to create request: %v", err)
|
||||
}
|
||||
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request.Header.Set("X-Accept", "application/json")
|
||||
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
|
||||
|
||||
httpClient := &http.Client{Timeout: defaultClientTimeout}
|
||||
response, err := httpClient.Do(request)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("pocket: unable to send request: %v", err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
|
||||
if response.StatusCode >= 400 {
|
||||
return "", fmt.Errorf("pocket: unable get access token: url=%s status=%d", apiEndpoint, response.StatusCode)
|
||||
}
|
||||
|
||||
var result authorizeReponse
|
||||
if err := json.NewDecoder(response.Body).Decode(&result); err != nil {
|
||||
return "", fmt.Errorf("pocket: unable to decode response: %v", err)
|
||||
}
|
||||
|
||||
if result.AccessToken == "" {
|
||||
return "", errors.New("pocket: access token is empty")
|
||||
}
|
||||
|
||||
return result.AccessToken, nil
|
||||
}
|
||||
|
||||
// AuthorizationURL returns the authorization URL for the end-user.
|
||||
func (c *Connector) AuthorizationURL(requestToken, redirectURL string) string {
|
||||
return fmt.Sprintf(
|
||||
"https://getpocket.com/auth/authorize?request_token=%s&redirect_uri=%s",
|
||||
requestToken,
|
||||
redirectURL,
|
||||
)
|
||||
}
|
||||
|
||||
type createTokenRequest struct {
|
||||
ConsumerKey string `json:"consumer_key"`
|
||||
RedirectURI string `json:"redirect_uri"`
|
||||
}
|
||||
|
||||
type createTokenResponse struct {
|
||||
Code string `json:"code"`
|
||||
}
|
||||
|
||||
type authorizeRequest struct {
|
||||
ConsumerKey string `json:"consumer_key"`
|
||||
Code string `json:"code"`
|
||||
}
|
||||
|
||||
type authorizeReponse struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
Username string `json:"username"`
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package pocket // import "miniflux.app/v2/internal/integration/pocket"
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"miniflux.app/v2/internal/version"
|
||||
)
|
||||
|
||||
const defaultClientTimeout = 10 * time.Second
|
||||
|
||||
type Client struct {
|
||||
consumerKey string
|
||||
accessToken string
|
||||
}
|
||||
|
||||
func NewClient(consumerKey, accessToken string) *Client {
|
||||
return &Client{consumerKey, accessToken}
|
||||
}
|
||||
|
||||
func (c *Client) AddURL(entryURL, entryTitle string) error {
|
||||
if c.consumerKey == "" || c.accessToken == "" {
|
||||
return fmt.Errorf("pocket: missing consumer key or access token")
|
||||
}
|
||||
|
||||
apiEndpoint := "https://getpocket.com/v3/add"
|
||||
requestBody, err := json.Marshal(&createItemRequest{
|
||||
AccessToken: c.accessToken,
|
||||
ConsumerKey: c.consumerKey,
|
||||
Title: entryTitle,
|
||||
URL: entryURL,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("pocket: unable to encode request body: %v", err)
|
||||
}
|
||||
|
||||
request, err := http.NewRequest(http.MethodPost, apiEndpoint, bytes.NewReader(requestBody))
|
||||
if err != nil {
|
||||
return fmt.Errorf("pocket: unable to create request: %v", err)
|
||||
}
|
||||
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
|
||||
|
||||
httpClient := &http.Client{Timeout: defaultClientTimeout}
|
||||
response, err := httpClient.Do(request)
|
||||
if err != nil {
|
||||
return fmt.Errorf("pocket: unable to send request: %v", err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
|
||||
if response.StatusCode >= 400 {
|
||||
return fmt.Errorf("pocket: unable to create item: url=%s status=%d", apiEndpoint, response.StatusCode)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type createItemRequest struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
ConsumerKey string `json:"consumer_key"`
|
||||
Title string `json:"title,omitempty"`
|
||||
URL string `json:"url"`
|
||||
}
|
||||
@@ -24,11 +24,12 @@ 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 {
|
||||
@@ -41,10 +42,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 +59,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 +83,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 +142,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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+44
-20
@@ -9,7 +9,10 @@ import (
|
||||
"fmt"
|
||||
)
|
||||
|
||||
type translationDict map[string]interface{}
|
||||
type translationDict struct {
|
||||
singulars map[string]string
|
||||
plurals map[string][]string
|
||||
}
|
||||
type catalog map[string]translationDict
|
||||
|
||||
var defaultCatalog = make(catalog, len(AvailableLanguages))
|
||||
@@ -17,47 +20,68 @@ var defaultCatalog = make(catalog, len(AvailableLanguages))
|
||||
//go:embed translations/*.json
|
||||
var translationFiles embed.FS
|
||||
|
||||
func GetTranslationDict(language string) (translationDict, error) {
|
||||
func getTranslationDict(language string) (translationDict, error) {
|
||||
if _, ok := defaultCatalog[language]; !ok {
|
||||
var err error
|
||||
if defaultCatalog[language], err = loadTranslationFile(language); err != nil {
|
||||
return nil, err
|
||||
return translationDict{}, err
|
||||
}
|
||||
}
|
||||
return defaultCatalog[language], nil
|
||||
}
|
||||
|
||||
// LoadCatalogMessages loads and parses all translations encoded in JSON.
|
||||
func LoadCatalogMessages() error {
|
||||
var err error
|
||||
|
||||
for language := range AvailableLanguages {
|
||||
defaultCatalog[language], err = loadTranslationFile(language)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func loadTranslationFile(language string) (translationDict, error) {
|
||||
translationFileData, err := translationFiles.ReadFile(fmt.Sprintf("translations/%s.json", language))
|
||||
translationFileData, err := translationFiles.ReadFile("translations/" + language + ".json")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return translationDict{}, err
|
||||
}
|
||||
|
||||
translationMessages, err := parseTranslationMessages(translationFileData)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return translationDict{}, err
|
||||
}
|
||||
|
||||
return translationMessages, nil
|
||||
}
|
||||
|
||||
func (t *translationDict) UnmarshalJSON(data []byte) error {
|
||||
var tmpMap map[string]any
|
||||
err := json.Unmarshal(data, &tmpMap)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
m := translationDict{
|
||||
singulars: make(map[string]string),
|
||||
plurals: make(map[string][]string),
|
||||
}
|
||||
|
||||
for key, value := range tmpMap {
|
||||
switch vtype := value.(type) {
|
||||
case string:
|
||||
m.singulars[key] = vtype
|
||||
case []any:
|
||||
for _, translation := range vtype {
|
||||
if translationStr, ok := translation.(string); ok {
|
||||
m.plurals[key] = append(m.plurals[key], translationStr)
|
||||
} else {
|
||||
return fmt.Errorf("invalid type for translation in an array: %v", translation)
|
||||
}
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("invalid type (%T) for translation: %v", vtype, value)
|
||||
}
|
||||
}
|
||||
|
||||
*t = m
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseTranslationMessages(data []byte) (translationDict, error) {
|
||||
var translationMessages translationDict
|
||||
if err := json.Unmarshal(data, &translationMessages); err != nil {
|
||||
return nil, fmt.Errorf(`invalid translation file: %w`, err)
|
||||
return translationDict{}, fmt.Errorf(`invalid translation file: %w`, err)
|
||||
}
|
||||
return translationMessages, nil
|
||||
}
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
|
||||
package locale // import "miniflux.app/v2/internal/locale"
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParserWithInvalidData(t *testing.T) {
|
||||
_, err := parseTranslationMessages([]byte(`{`))
|
||||
@@ -18,23 +20,22 @@ func TestParser(t *testing.T) {
|
||||
t.Fatalf(`Unexpected parsing error: %v`, err)
|
||||
}
|
||||
|
||||
if translations == nil {
|
||||
t.Fatal(`Translations should not be nil`)
|
||||
}
|
||||
|
||||
value, found := translations["k"]
|
||||
value, found := translations.singulars["k"]
|
||||
if !found {
|
||||
t.Fatal(`The translation should contains the defined key`)
|
||||
t.Fatalf(`The translation %v should contains the defined key`, translations.singulars)
|
||||
}
|
||||
|
||||
if value.(string) != "v" {
|
||||
if value != "v" {
|
||||
t.Fatal(`The translation key should contains the defined value`)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadCatalog(t *testing.T) {
|
||||
if err := LoadCatalogMessages(); err != nil {
|
||||
t.Fatal(err)
|
||||
for language := range AvailableLanguages {
|
||||
_, err := loadTranslationFile(language)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,20 +46,22 @@ func TestAllKeysHaveValue(t *testing.T) {
|
||||
t.Fatalf(`Unable to load translation messages for language %q`, language)
|
||||
}
|
||||
|
||||
if len(messages) == 0 {
|
||||
t.Fatalf(`The language %q doesn't have any messages`, language)
|
||||
if len(messages.singulars) == 0 {
|
||||
t.Fatalf(`The language %q doesn't have any messages for singulars`, language)
|
||||
}
|
||||
|
||||
for k, v := range messages {
|
||||
switch value := v.(type) {
|
||||
case string:
|
||||
if value == "" {
|
||||
t.Errorf(`The key %q for the language %q has an empty string as value`, k, language)
|
||||
}
|
||||
case []any:
|
||||
if len(value) == 0 {
|
||||
t.Errorf(`The key %q for the language %q has an empty list as value`, k, language)
|
||||
}
|
||||
if len(messages.plurals) == 0 {
|
||||
t.Fatalf(`The language %q doesn't have any messages for plurals`, language)
|
||||
}
|
||||
|
||||
for k, v := range messages.singulars {
|
||||
if len(v) == 0 {
|
||||
t.Errorf(`The key %q for singulars for the language %q has an empty list as value`, k, language)
|
||||
}
|
||||
}
|
||||
for k, v := range messages.plurals {
|
||||
if len(v) == 0 {
|
||||
t.Errorf(`The key %q for plurals for the language %q has an empty list as value`, k, language)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -81,9 +84,14 @@ func TestMissingTranslations(t *testing.T) {
|
||||
t.Fatalf(`Parsing error for language %q`, language)
|
||||
}
|
||||
|
||||
for key := range references {
|
||||
if _, found := messages[key]; !found {
|
||||
t.Errorf(`Translation key %q not found in language %q`, key, language)
|
||||
for key := range references.singulars {
|
||||
if _, found := messages.singulars[key]; !found {
|
||||
t.Errorf(`Translation key %q not found in language %q singulars`, key, language)
|
||||
}
|
||||
}
|
||||
for key := range references.plurals {
|
||||
if _, found := messages.plurals[key]; !found {
|
||||
t.Errorf(`Translation key %q not found in language %q plurals`, key, language)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -118,11 +126,9 @@ func TestTranslationFilePluralForms(t *testing.T) {
|
||||
t.Fatalf(`Unable to load translation messages for language %q`, language)
|
||||
}
|
||||
|
||||
for k, v := range messages {
|
||||
if value, ok := v.([]any); ok {
|
||||
if len(value) != numberOfPluralFormsPerLanguage[language] {
|
||||
t.Errorf(`The key %q for the language %q does not have the expected number of plurals, got %d instead of %d`, k, language, len(value), numberOfPluralFormsPerLanguage[language])
|
||||
}
|
||||
for k, v := range messages.plurals {
|
||||
if len(v) != numberOfPluralFormsPerLanguage[language] {
|
||||
t.Errorf(`The key %q for the language %q does not have the expected number of plurals, got %d instead of %d`, k, language, len(v), numberOfPluralFormsPerLanguage[language])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,299 @@
|
||||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package locale // import "miniflux.app/v2/internal/locale"
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNewLocalizedErrorWrapper(t *testing.T) {
|
||||
originalErr := errors.New("original error message")
|
||||
translationKey := "error.test_key"
|
||||
args := []any{"arg1", 42}
|
||||
|
||||
wrapper := NewLocalizedErrorWrapper(originalErr, translationKey, args...)
|
||||
|
||||
if wrapper.originalErr != originalErr {
|
||||
t.Errorf("Expected original error to be %v, got %v", originalErr, wrapper.originalErr)
|
||||
}
|
||||
|
||||
if wrapper.translationKey != translationKey {
|
||||
t.Errorf("Expected translation key to be %q, got %q", translationKey, wrapper.translationKey)
|
||||
}
|
||||
|
||||
if len(wrapper.translationArgs) != 2 {
|
||||
t.Errorf("Expected 2 translation args, got %d", len(wrapper.translationArgs))
|
||||
}
|
||||
|
||||
if wrapper.translationArgs[0] != "arg1" || wrapper.translationArgs[1] != 42 {
|
||||
t.Errorf("Expected translation args [arg1, 42], got %v", wrapper.translationArgs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalizedErrorWrapper_Error(t *testing.T) {
|
||||
originalErr := errors.New("original error message")
|
||||
wrapper := NewLocalizedErrorWrapper(originalErr, "error.test_key")
|
||||
|
||||
result := wrapper.Error()
|
||||
if result != originalErr {
|
||||
t.Errorf("Expected Error() to return original error %v, got %v", originalErr, result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalizedErrorWrapper_Translate(t *testing.T) {
|
||||
// Set up test catalog
|
||||
defaultCatalog = catalog{
|
||||
"en_US": translationDict{
|
||||
singulars: map[string]string{
|
||||
"error.test_key": "Error: %s (code: %d)",
|
||||
},
|
||||
},
|
||||
"fr_FR": translationDict{
|
||||
singulars: map[string]string{
|
||||
"error.test_key": "Erreur : %s (code : %d)",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
originalErr := errors.New("original error")
|
||||
wrapper := NewLocalizedErrorWrapper(originalErr, "error.test_key", "test message", 404)
|
||||
|
||||
// Test English translation
|
||||
result := wrapper.Translate("en_US")
|
||||
expected := "Error: test message (code: 404)"
|
||||
if result != expected {
|
||||
t.Errorf("Expected English translation %q, got %q", expected, result)
|
||||
}
|
||||
|
||||
// Test French translation
|
||||
result = wrapper.Translate("fr_FR")
|
||||
expected = "Erreur : test message (code : 404)"
|
||||
if result != expected {
|
||||
t.Errorf("Expected French translation %q, got %q", expected, result)
|
||||
}
|
||||
|
||||
// Test with missing language (should use key as fallback with args applied)
|
||||
result = wrapper.Translate("invalid_lang")
|
||||
expected = "error.test_key%!(EXTRA string=test message, int=404)"
|
||||
if result != expected {
|
||||
t.Errorf("Expected fallback translation %q, got %q", expected, result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalizedErrorWrapper_TranslateWithEmptyKey(t *testing.T) {
|
||||
originalErr := errors.New("original error message")
|
||||
wrapper := NewLocalizedErrorWrapper(originalErr, "")
|
||||
|
||||
result := wrapper.Translate("en_US")
|
||||
expected := "original error message"
|
||||
if result != expected {
|
||||
t.Errorf("Expected original error message %q, got %q", expected, result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalizedErrorWrapper_TranslateWithNoArgs(t *testing.T) {
|
||||
defaultCatalog = catalog{
|
||||
"en_US": translationDict{
|
||||
singulars: map[string]string{
|
||||
"error.simple": "Simple error message",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
originalErr := errors.New("original error")
|
||||
wrapper := NewLocalizedErrorWrapper(originalErr, "error.simple")
|
||||
|
||||
result := wrapper.Translate("en_US")
|
||||
expected := "Simple error message"
|
||||
if result != expected {
|
||||
t.Errorf("Expected translation %q, got %q", expected, result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewLocalizedError(t *testing.T) {
|
||||
translationKey := "error.validation"
|
||||
args := []any{"field1", "invalid"}
|
||||
|
||||
localizedErr := NewLocalizedError(translationKey, args...)
|
||||
|
||||
if localizedErr.translationKey != translationKey {
|
||||
t.Errorf("Expected translation key to be %q, got %q", translationKey, localizedErr.translationKey)
|
||||
}
|
||||
|
||||
if len(localizedErr.translationArgs) != 2 {
|
||||
t.Errorf("Expected 2 translation args, got %d", len(localizedErr.translationArgs))
|
||||
}
|
||||
|
||||
if localizedErr.translationArgs[0] != "field1" || localizedErr.translationArgs[1] != "invalid" {
|
||||
t.Errorf("Expected translation args [field1, invalid], got %v", localizedErr.translationArgs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalizedError_String(t *testing.T) {
|
||||
defaultCatalog = catalog{
|
||||
"en_US": translationDict{
|
||||
singulars: map[string]string{
|
||||
"error.validation": "Validation failed for %s: %s",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
localizedErr := NewLocalizedError("error.validation", "username", "too short")
|
||||
|
||||
result := localizedErr.String()
|
||||
expected := "Validation failed for username: too short"
|
||||
if result != expected {
|
||||
t.Errorf("Expected String() result %q, got %q", expected, result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalizedError_StringWithMissingTranslation(t *testing.T) {
|
||||
defaultCatalog = catalog{
|
||||
"en_US": translationDict{},
|
||||
}
|
||||
|
||||
localizedErr := NewLocalizedError("error.missing", "arg1")
|
||||
|
||||
result := localizedErr.String()
|
||||
expected := "error.missing%!(EXTRA string=arg1)"
|
||||
if result != expected {
|
||||
t.Errorf("Expected String() result %q, got %q", expected, result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalizedError_Error(t *testing.T) {
|
||||
defaultCatalog = catalog{
|
||||
"en_US": translationDict{
|
||||
singulars: map[string]string{
|
||||
"error.database": "Database connection failed: %s",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
localizedErr := NewLocalizedError("error.database", "timeout")
|
||||
|
||||
result := localizedErr.Error()
|
||||
if result == nil {
|
||||
t.Error("Expected Error() to return a non-nil error")
|
||||
}
|
||||
|
||||
expected := "Database connection failed: timeout"
|
||||
if result.Error() != expected {
|
||||
t.Errorf("Expected Error() message %q, got %q", expected, result.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalizedError_Translate(t *testing.T) {
|
||||
defaultCatalog = catalog{
|
||||
"en_US": translationDict{
|
||||
singulars: map[string]string{
|
||||
"error.permission": "Permission denied for %s",
|
||||
},
|
||||
},
|
||||
"es_ES": translationDict{
|
||||
singulars: map[string]string{
|
||||
"error.permission": "Permiso denegado para %s",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
localizedErr := NewLocalizedError("error.permission", "admin panel")
|
||||
|
||||
// Test English translation
|
||||
result := localizedErr.Translate("en_US")
|
||||
expected := "Permission denied for admin panel"
|
||||
if result != expected {
|
||||
t.Errorf("Expected English translation %q, got %q", expected, result)
|
||||
}
|
||||
|
||||
// Test Spanish translation
|
||||
result = localizedErr.Translate("es_ES")
|
||||
expected = "Permiso denegado para admin panel"
|
||||
if result != expected {
|
||||
t.Errorf("Expected Spanish translation %q, got %q", expected, result)
|
||||
}
|
||||
|
||||
// Test with missing language
|
||||
result = localizedErr.Translate("invalid_lang")
|
||||
expected = "error.permission%!(EXTRA string=admin panel)"
|
||||
if result != expected {
|
||||
t.Errorf("Expected fallback translation %q, got %q", expected, result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalizedError_TranslateWithNoArgs(t *testing.T) {
|
||||
defaultCatalog = catalog{
|
||||
"en_US": translationDict{
|
||||
singulars: map[string]string{
|
||||
"error.generic": "An error occurred",
|
||||
},
|
||||
},
|
||||
"de_DE": translationDict{
|
||||
singulars: map[string]string{
|
||||
"error.generic": "Ein Fehler ist aufgetreten",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
localizedErr := NewLocalizedError("error.generic")
|
||||
|
||||
// Test English
|
||||
result := localizedErr.Translate("en_US")
|
||||
expected := "An error occurred"
|
||||
if result != expected {
|
||||
t.Errorf("Expected English translation %q, got %q", expected, result)
|
||||
}
|
||||
|
||||
// Test German
|
||||
result = localizedErr.Translate("de_DE")
|
||||
expected = "Ein Fehler ist aufgetreten"
|
||||
if result != expected {
|
||||
t.Errorf("Expected German translation %q, got %q", expected, result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalizedError_TranslateWithComplexArgs(t *testing.T) {
|
||||
defaultCatalog = catalog{
|
||||
"en_US": translationDict{
|
||||
singulars: map[string]string{
|
||||
"error.complex": "Error %d: %s occurred at %s with severity %s",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
localizedErr := NewLocalizedError("error.complex", 500, "Internal Server Error", "2024-01-01", "high")
|
||||
|
||||
result := localizedErr.Translate("en_US")
|
||||
expected := "Error 500: Internal Server Error occurred at 2024-01-01 with severity high"
|
||||
if result != expected {
|
||||
t.Errorf("Expected complex translation %q, got %q", expected, result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalizedErrorWrapper_WithNilError(t *testing.T) {
|
||||
// This tests edge case behavior - what happens with nil error
|
||||
wrapper := NewLocalizedErrorWrapper(nil, "error.test")
|
||||
|
||||
// Error() should return nil
|
||||
result := wrapper.Error()
|
||||
if result != nil {
|
||||
t.Errorf("Expected Error() to return nil, got %v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalizedError_EmptyKey(t *testing.T) {
|
||||
localizedErr := NewLocalizedError("")
|
||||
|
||||
result := localizedErr.String()
|
||||
expected := ""
|
||||
if result != expected {
|
||||
t.Errorf("Expected empty string for empty key, got %q", result)
|
||||
}
|
||||
|
||||
result = localizedErr.Translate("en_US")
|
||||
if result != expected {
|
||||
t.Errorf("Expected empty string for empty key translation, got %q", result)
|
||||
}
|
||||
}
|
||||
+27
-71
@@ -5,16 +5,9 @@ package locale // import "miniflux.app/v2/internal/locale"
|
||||
|
||||
// See https://localization-guide.readthedocs.io/en/latest/l10n/pluralforms.html
|
||||
// And http://www.unicode.org/cldr/charts/29/supplemental/language_plural_rules.html
|
||||
var pluralForms = map[string]func(n int) int{
|
||||
// nplurals=2; plural=(n != 1);
|
||||
"default": func(n int) int {
|
||||
if n != 1 {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
},
|
||||
// nplurals=6; plural=(n==0 ? 0 : n==1 ? 1 : n==2 ? 2 : n%100>=3 && n%100<=10 ? 3 : n%100>=11 ? 4 : 5);
|
||||
"ar_AR": func(n int) int {
|
||||
func getPluralForm(lang string, n int) int {
|
||||
switch lang {
|
||||
case "ar_AR":
|
||||
switch {
|
||||
case n == 0:
|
||||
return 0
|
||||
@@ -26,90 +19,53 @@ var pluralForms = map[string]func(n int) int{
|
||||
return 3
|
||||
case n%100 >= 11:
|
||||
return 4
|
||||
default:
|
||||
return 5
|
||||
}
|
||||
return 5
|
||||
},
|
||||
// nplurals=3; plural=(n==1) ? 0 : (n>=2 && n<=4) ? 1 : 2;
|
||||
"cs_CZ": func(n int) int {
|
||||
case "cs_CZ":
|
||||
switch {
|
||||
case n == 1:
|
||||
return 0
|
||||
case n >= 2 && n <= 4:
|
||||
return 1
|
||||
default:
|
||||
return 2
|
||||
}
|
||||
return 2
|
||||
},
|
||||
// nplurals=2; plural=(n > 1);
|
||||
"fr_FR": func(n int) int {
|
||||
if n > 1 {
|
||||
return 1
|
||||
}
|
||||
case "id_ID", "ja_JP":
|
||||
return 0
|
||||
},
|
||||
// nplurals=1; plural=0;
|
||||
"id_ID": func(n int) int {
|
||||
return 0
|
||||
},
|
||||
// nplurals=1; plural=0;
|
||||
"ja_JP": func(n int) int {
|
||||
return 0
|
||||
},
|
||||
// nplurals=3; plural=(n==1 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);
|
||||
"pl_PL": func(n int) int {
|
||||
case "pl_PL":
|
||||
switch {
|
||||
case n == 1:
|
||||
return 0
|
||||
case n%10 >= 2 && n%10 <= 4 && (n%100 < 10 || n%100 >= 20):
|
||||
return 1
|
||||
default:
|
||||
return 2
|
||||
}
|
||||
return 2
|
||||
},
|
||||
// nplurals=2; plural=(n > 1);
|
||||
"pt_BR": func(n int) int {
|
||||
if n > 1 {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
},
|
||||
// nplurals=3; plural=(n==1 ? 0 : n==0 || (n%100 > 0 && n%100 < 20) ? 1 : 2);
|
||||
"ro_RO": func(n int) int {
|
||||
case "ro_RO":
|
||||
switch {
|
||||
case n == 1:
|
||||
return 0
|
||||
case n == 0 || (n%100 > 0 && n%100 < 20):
|
||||
return 1
|
||||
default:
|
||||
return 2
|
||||
}
|
||||
return 2
|
||||
},
|
||||
"ru_RU": pluralFormRuSrUa,
|
||||
// nplurals=2; plural=(n > 1);
|
||||
"tr_TR": func(n int) int {
|
||||
case "ru_RU", "uk_UA", "sr_RS":
|
||||
switch {
|
||||
case n%10 == 1 && n%100 != 11:
|
||||
return 0
|
||||
case n%10 >= 2 && n%10 <= 4 && (n%100 < 10 || n%100 >= 20):
|
||||
return 1
|
||||
default:
|
||||
return 2
|
||||
}
|
||||
case "zh_CN", "zh_TW", "nan_Latn_pehoeji":
|
||||
return 0
|
||||
default: // includes fr_FR, pr_BR, tr_TR
|
||||
if n > 1 {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
},
|
||||
"uk_UA": pluralFormRuSrUa,
|
||||
"sr_RS": pluralFormRuSrUa,
|
||||
// nplurals=1; plural=0;
|
||||
"zh_CN": func(n int) int {
|
||||
return 0
|
||||
},
|
||||
"zh_TW": func(n int) int {
|
||||
return 0
|
||||
},
|
||||
"nan_Latn_pehoeji": func(n int) int {
|
||||
return 0
|
||||
},
|
||||
}
|
||||
|
||||
// nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);
|
||||
func pluralFormRuSrUa(n int) int {
|
||||
switch {
|
||||
case n%10 == 1 && n%100 != 11:
|
||||
return 0
|
||||
case n%10 >= 2 && n%10 <= 4 && (n%100 < 10 || n%100 >= 20):
|
||||
return 1
|
||||
}
|
||||
return 2
|
||||
}
|
||||
|
||||
+156
-48
@@ -7,90 +7,198 @@ import "testing"
|
||||
|
||||
func TestPluralRules(t *testing.T) {
|
||||
scenarios := map[string]map[int]int{
|
||||
// Default rule (covers fr_FR, pt_BR, tr_TR, and other unlisted languages)
|
||||
"default": {
|
||||
1: 0,
|
||||
2: 1,
|
||||
5: 1,
|
||||
0: 0, // n <= 1
|
||||
1: 0, // n <= 1
|
||||
2: 1, // n > 1
|
||||
5: 1, // n > 1
|
||||
},
|
||||
// Arabic (ar_AR) - 6 forms
|
||||
"ar_AR": {
|
||||
0: 0,
|
||||
1: 1,
|
||||
2: 2,
|
||||
5: 3,
|
||||
11: 4,
|
||||
200: 5,
|
||||
0: 0, // n == 0
|
||||
1: 1, // n == 1
|
||||
2: 2, // n == 2
|
||||
3: 3, // n%100 >= 3 && n%100 <= 10
|
||||
5: 3, // n%100 >= 3 && n%100 <= 10
|
||||
10: 3, // n%100 >= 3 && n%100 <= 10
|
||||
11: 4, // n%100 >= 11
|
||||
15: 4, // n%100 >= 11
|
||||
99: 4, // n%100 >= 11
|
||||
100: 5, // default case (n%100 == 0, doesn't match any condition)
|
||||
101: 5, // default case (n%100 == 1, but n != 1)
|
||||
200: 5, // default case
|
||||
},
|
||||
// Czech (cs_CZ) - 3 forms
|
||||
"cs_CZ": {
|
||||
1: 0,
|
||||
2: 1,
|
||||
5: 2,
|
||||
1: 0, // n == 1
|
||||
2: 1, // n >= 2 && n <= 4
|
||||
3: 1, // n >= 2 && n <= 4
|
||||
4: 1, // n >= 2 && n <= 4
|
||||
5: 2, // default case
|
||||
},
|
||||
// French (fr_FR) - uses default rule
|
||||
"fr_FR": {
|
||||
1: 0,
|
||||
2: 1,
|
||||
5: 1,
|
||||
0: 0, // n <= 1
|
||||
1: 0, // n <= 1
|
||||
2: 1, // n > 1
|
||||
5: 1, // n > 1
|
||||
},
|
||||
// Indonesian (id_ID) - always form 0
|
||||
"id_ID": {
|
||||
1: 0,
|
||||
5: 0,
|
||||
0: 0,
|
||||
1: 0,
|
||||
5: 0,
|
||||
100: 0,
|
||||
},
|
||||
// Japanese (ja_JP) - always form 0
|
||||
"ja_JP": {
|
||||
1: 0,
|
||||
2: 0,
|
||||
5: 0,
|
||||
0: 0,
|
||||
1: 0,
|
||||
2: 0,
|
||||
5: 0,
|
||||
100: 0,
|
||||
},
|
||||
// Polish (pl_PL) - 3 forms
|
||||
"pl_PL": {
|
||||
1: 0,
|
||||
2: 1,
|
||||
5: 2,
|
||||
1: 0, // n == 1
|
||||
2: 1, // n%10 >= 2 && n%10 <= 4 && (n%100 < 10 || n%100 >= 20)
|
||||
3: 1, // n%10 >= 2 && n%10 <= 4 && (n%100 < 10 || n%100 >= 20)
|
||||
4: 1, // n%10 >= 2 && n%10 <= 4 && (n%100 < 10 || n%100 >= 20)
|
||||
5: 2, // default case
|
||||
10: 2, // default case (n%100 < 10, but n%10 not in 2-4)
|
||||
11: 2, // default case (n%100 >= 10 and < 20)
|
||||
12: 2, // default case (n%100 >= 10 and < 20)
|
||||
22: 1, // n%10 >= 2 && n%10 <= 4 && (n%100 >= 20)
|
||||
24: 1, // n%10 >= 2 && n%10 <= 4 && (n%100 >= 20)
|
||||
},
|
||||
// Portuguese Brazilian (pt_BR) - uses default rule
|
||||
"pt_BR": {
|
||||
1: 0,
|
||||
2: 1,
|
||||
5: 1,
|
||||
0: 0, // n <= 1
|
||||
1: 0, // n <= 1
|
||||
2: 1, // n > 1
|
||||
5: 1, // n > 1
|
||||
},
|
||||
// Romanian (ro_RO) - 3 forms
|
||||
"ro_RO": {
|
||||
1: 0,
|
||||
2: 1,
|
||||
5: 1,
|
||||
0: 1, // n == 0 || (n%100 > 0 && n%100 < 20)
|
||||
1: 0, // n == 1
|
||||
2: 1, // n == 0 || (n%100 > 0 && n%100 < 20)
|
||||
5: 1, // n == 0 || (n%100 > 0 && n%100 < 20)
|
||||
19: 1, // n == 0 || (n%100 > 0 && n%100 < 20)
|
||||
20: 2, // default case
|
||||
21: 2, // default case
|
||||
100: 2, // default case (n%100 == 0, so condition fails)
|
||||
101: 1, // n%100 == 1, so n%100 > 0 && n%100 < 20
|
||||
},
|
||||
// Russian (ru_RU) - 3 forms
|
||||
"ru_RU": {
|
||||
1: 0,
|
||||
2: 1,
|
||||
5: 2,
|
||||
1: 0, // n%10 == 1 && n%100 != 11
|
||||
2: 1, // n%10 >= 2 && n%10 <= 4 && (n%100 < 10 || n%100 >= 20)
|
||||
3: 1, // n%10 >= 2 && n%10 <= 4 && (n%100 < 10 || n%100 >= 20)
|
||||
4: 1, // n%10 >= 2 && n%10 <= 4 && (n%100 < 10 || n%100 >= 20)
|
||||
5: 2, // default case
|
||||
11: 2, // n%10 == 1 but n%100 == 11, so default case
|
||||
12: 2, // default case
|
||||
21: 0, // n%10 == 1 && n%100 != 11
|
||||
22: 1, // n%10 >= 2 && n%10 <= 4 && (n%100 >= 20)
|
||||
},
|
||||
// Serbian (sr_RS) - same as Russian
|
||||
"sr_RS": {
|
||||
1: 0,
|
||||
2: 1,
|
||||
5: 2,
|
||||
1: 0, // n%10 == 1 && n%100 != 11
|
||||
2: 1, // n%10 >= 2 && n%10 <= 4 && (n%100 < 10 || n%100 >= 20)
|
||||
5: 2, // default case
|
||||
11: 2, // n%10 == 1 but n%100 == 11, so default case
|
||||
21: 0, // n%10 == 1 && n%100 != 11
|
||||
},
|
||||
// Turkish (tr_TR) - uses default rule
|
||||
"tr_TR": {
|
||||
1: 0,
|
||||
2: 1,
|
||||
5: 1,
|
||||
0: 0, // n <= 1
|
||||
1: 0, // n <= 1
|
||||
2: 1, // n > 1
|
||||
5: 1, // n > 1
|
||||
},
|
||||
// Ukrainian (uk_UA) - same as Russian
|
||||
"uk_UA": {
|
||||
1: 0,
|
||||
2: 1,
|
||||
5: 2,
|
||||
1: 0, // n%10 == 1 && n%100 != 11
|
||||
2: 1, // n%10 >= 2 && n%10 <= 4 && (n%100 < 10 || n%100 >= 20)
|
||||
5: 2, // default case
|
||||
11: 2, // n%10 == 1 but n%100 == 11, so default case
|
||||
21: 0, // n%10 == 1 && n%100 != 11
|
||||
},
|
||||
// Chinese Simplified (zh_CN) - always form 0
|
||||
"zh_CN": {
|
||||
1: 0,
|
||||
5: 0,
|
||||
0: 0,
|
||||
1: 0,
|
||||
5: 0,
|
||||
100: 0,
|
||||
},
|
||||
// Chinese Traditional (zh_TW) - always form 0
|
||||
"zh_TW": {
|
||||
1: 0,
|
||||
5: 0,
|
||||
0: 0,
|
||||
1: 0,
|
||||
5: 0,
|
||||
100: 0,
|
||||
},
|
||||
// Min Nan (nan_Latn_pehoeji) - always form 0
|
||||
"nan_Latn_pehoeji": {
|
||||
1: 0,
|
||||
5: 0,
|
||||
0: 0,
|
||||
1: 0,
|
||||
5: 0,
|
||||
100: 0,
|
||||
},
|
||||
// Additional languages from AvailableLanguages that use default rule
|
||||
"de_DE": {
|
||||
0: 0, // n <= 1
|
||||
1: 0, // n <= 1
|
||||
2: 1, // n > 1
|
||||
},
|
||||
"el_EL": {
|
||||
0: 0, // n <= 1
|
||||
1: 0, // n <= 1
|
||||
2: 1, // n > 1
|
||||
},
|
||||
"en_US": {
|
||||
0: 0, // n <= 1
|
||||
1: 0, // n <= 1
|
||||
2: 1, // n > 1
|
||||
},
|
||||
"es_ES": {
|
||||
0: 0, // n <= 1
|
||||
1: 0, // n <= 1
|
||||
2: 1, // n > 1
|
||||
},
|
||||
"fi_FI": {
|
||||
0: 0, // n <= 1
|
||||
1: 0, // n <= 1
|
||||
2: 1, // n > 1
|
||||
},
|
||||
"hi_IN": {
|
||||
0: 0, // n <= 1
|
||||
1: 0, // n <= 1
|
||||
2: 1, // n > 1
|
||||
},
|
||||
"it_IT": {
|
||||
0: 0, // n <= 1
|
||||
1: 0, // n <= 1
|
||||
2: 1, // n > 1
|
||||
},
|
||||
"nl_NL": {
|
||||
0: 0, // n <= 1
|
||||
1: 0, // n <= 1
|
||||
2: 1, // n > 1
|
||||
},
|
||||
// Test a language not in the switch (should use default rule)
|
||||
"unknown_language": {
|
||||
0: 0, // n <= 1
|
||||
1: 0, // n <= 1
|
||||
2: 1, // n > 1
|
||||
},
|
||||
}
|
||||
|
||||
for rule, values := range scenarios {
|
||||
for input, expected := range values {
|
||||
result := pluralForms[rule](input)
|
||||
result := getPluralForm(rule, input)
|
||||
if result != expected {
|
||||
t.Errorf(`Unexpected result for %q rule, got %d instead of %d for %d as input`, rule, result, expected, input)
|
||||
}
|
||||
|
||||
+16
-49
@@ -10,71 +10,38 @@ type Printer struct {
|
||||
language string
|
||||
}
|
||||
|
||||
// NewPrinter creates a new Printer instance for the given language.
|
||||
func NewPrinter(language string) *Printer {
|
||||
return &Printer{language}
|
||||
}
|
||||
|
||||
func (p *Printer) Print(key string) string {
|
||||
if dict, err := GetTranslationDict(p.language); err == nil {
|
||||
if str, ok := dict[key]; ok {
|
||||
if translation, ok := str.(string); ok {
|
||||
return translation
|
||||
}
|
||||
if dict, err := getTranslationDict(p.language); err == nil {
|
||||
if str, ok := dict.singulars[key]; ok {
|
||||
return str
|
||||
}
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
||||
// Printf is like fmt.Printf, but using language-specific formatting.
|
||||
func (p *Printer) Printf(key string, args ...interface{}) string {
|
||||
translation := key
|
||||
|
||||
if dict, err := GetTranslationDict(p.language); err == nil {
|
||||
str, found := dict[key]
|
||||
if found {
|
||||
var valid bool
|
||||
translation, valid = str.(string)
|
||||
if !valid {
|
||||
translation = key
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return fmt.Sprintf(translation, args...)
|
||||
func (p *Printer) Printf(key string, args ...any) string {
|
||||
return fmt.Sprintf(p.Print(key), args...)
|
||||
}
|
||||
|
||||
// Plural returns the translation of the given key by using the language plural form.
|
||||
func (p *Printer) Plural(key string, n int, args ...interface{}) string {
|
||||
dict, err := GetTranslationDict(p.language)
|
||||
func (p *Printer) Plural(key string, n int, args ...any) string {
|
||||
dict, err := getTranslationDict(p.language)
|
||||
if err != nil {
|
||||
return key
|
||||
}
|
||||
|
||||
if choices, found := dict[key]; found {
|
||||
var plurals []string
|
||||
|
||||
switch v := choices.(type) {
|
||||
case []interface{}:
|
||||
for _, v := range v {
|
||||
plurals = append(plurals, fmt.Sprint(v))
|
||||
}
|
||||
case []string:
|
||||
plurals = v
|
||||
default:
|
||||
return key
|
||||
}
|
||||
|
||||
pluralForm, found := pluralForms[p.language]
|
||||
if !found {
|
||||
pluralForm = pluralForms["default"]
|
||||
}
|
||||
|
||||
index := pluralForm(n)
|
||||
if len(plurals) > index {
|
||||
return fmt.Sprintf(plurals[index], args...)
|
||||
if choices, found := dict.plurals[key]; found {
|
||||
index := getPluralForm(p.language, n)
|
||||
if len(choices) > index {
|
||||
return fmt.Sprintf(choices[index], args...)
|
||||
}
|
||||
}
|
||||
|
||||
return key
|
||||
}
|
||||
|
||||
// NewPrinter creates a new Printer.
|
||||
func NewPrinter(language string) *Printer {
|
||||
return &Printer{language}
|
||||
}
|
||||
|
||||
+223
-41
@@ -5,7 +5,7 @@ package locale // import "miniflux.app/v2/internal/locale"
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestTranslateWithMissingLanguage(t *testing.T) {
|
||||
func TestPrintfWithMissingLanguage(t *testing.T) {
|
||||
defaultCatalog = catalog{}
|
||||
translation := NewPrinter("invalid").Printf("missing.key")
|
||||
|
||||
@@ -14,10 +14,12 @@ func TestTranslateWithMissingLanguage(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestTranslateWithMissingKey(t *testing.T) {
|
||||
func TestPrintfWithMissingKey(t *testing.T) {
|
||||
defaultCatalog = catalog{
|
||||
"en_US": translationDict{
|
||||
"k": "v",
|
||||
singulars: map[string]string{
|
||||
"k": "v",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -27,10 +29,12 @@ func TestTranslateWithMissingKey(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestTranslateWithExistingKey(t *testing.T) {
|
||||
func TestPrintfWithExistingKey(t *testing.T) {
|
||||
defaultCatalog = catalog{
|
||||
"en_US": translationDict{
|
||||
"auth.username": "Login",
|
||||
singulars: map[string]string{
|
||||
"auth.username": "Login",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -40,13 +44,17 @@ func TestTranslateWithExistingKey(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestTranslateWithExistingKeyAndPlaceholder(t *testing.T) {
|
||||
func TestPrintfWithExistingKeyAndPlaceholder(t *testing.T) {
|
||||
defaultCatalog = catalog{
|
||||
"en_US": translationDict{
|
||||
"key": "Test: %s",
|
||||
singulars: map[string]string{
|
||||
"key": "Test: %s",
|
||||
},
|
||||
},
|
||||
"fr_FR": translationDict{
|
||||
"key": "Test : %s",
|
||||
singulars: map[string]string{
|
||||
"key": "Test : %s",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -56,13 +64,17 @@ func TestTranslateWithExistingKeyAndPlaceholder(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestTranslateWithMissingKeyAndPlaceholder(t *testing.T) {
|
||||
func TestPrintfWithMissingKeyAndPlaceholder(t *testing.T) {
|
||||
defaultCatalog = catalog{
|
||||
"en_US": translationDict{
|
||||
"auth.username": "Login",
|
||||
singulars: map[string]string{
|
||||
"auth.username": "Login",
|
||||
},
|
||||
},
|
||||
"fr_FR": translationDict{
|
||||
"auth.username": "Identifiant",
|
||||
singulars: map[string]string{
|
||||
"auth.username": "Identifiant",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -72,29 +84,122 @@ func TestTranslateWithMissingKeyAndPlaceholder(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestTranslateWithInvalidValue(t *testing.T) {
|
||||
defaultCatalog = catalog{
|
||||
"en_US": translationDict{
|
||||
"auth.username": "Login",
|
||||
},
|
||||
"fr_FR": translationDict{
|
||||
"auth.username": true,
|
||||
},
|
||||
}
|
||||
func TestPrintWithMissingLanguage(t *testing.T) {
|
||||
defaultCatalog = catalog{}
|
||||
translation := NewPrinter("invalid").Print("missing.key")
|
||||
|
||||
translation := NewPrinter("fr_FR").Printf("auth.username")
|
||||
if translation != "auth.username" {
|
||||
if translation != "missing.key" {
|
||||
t.Errorf(`Wrong translation, got %q`, translation)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTranslatePluralWithDefaultRule(t *testing.T) {
|
||||
func TestPrintWithMissingKey(t *testing.T) {
|
||||
defaultCatalog = catalog{
|
||||
"en_US": translationDict{
|
||||
"number_of_users": []string{"%d user (%s)", "%d users (%s)"},
|
||||
singulars: map[string]string{
|
||||
"existing.key": "value",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
translation := NewPrinter("en_US").Print("missing.key")
|
||||
if translation != "missing.key" {
|
||||
t.Errorf(`Wrong translation, got %q`, translation)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintWithExistingKey(t *testing.T) {
|
||||
defaultCatalog = catalog{
|
||||
"en_US": translationDict{
|
||||
singulars: map[string]string{
|
||||
"auth.username": "Login",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
translation := NewPrinter("en_US").Print("auth.username")
|
||||
if translation != "Login" {
|
||||
t.Errorf(`Wrong translation, got %q`, translation)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintWithDifferentLanguages(t *testing.T) {
|
||||
defaultCatalog = catalog{
|
||||
"en_US": translationDict{
|
||||
singulars: map[string]string{
|
||||
"greeting": "Hello",
|
||||
},
|
||||
},
|
||||
"fr_FR": translationDict{
|
||||
"number_of_users": []string{"%d utilisateur (%s)", "%d utilisateurs (%s)"},
|
||||
singulars: map[string]string{
|
||||
"greeting": "Bonjour",
|
||||
},
|
||||
},
|
||||
"es_ES": translationDict{
|
||||
singulars: map[string]string{
|
||||
"greeting": "Hola",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
language string
|
||||
expected string
|
||||
}{
|
||||
{"en_US", "Hello"},
|
||||
{"fr_FR", "Bonjour"},
|
||||
{"es_ES", "Hola"},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
translation := NewPrinter(test.language).Print("greeting")
|
||||
if translation != test.expected {
|
||||
t.Errorf(`Wrong translation for %s, got %q instead of %q`, test.language, translation, test.expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintWithEmptyKey(t *testing.T) {
|
||||
defaultCatalog = catalog{
|
||||
"en_US": translationDict{
|
||||
singulars: map[string]string{
|
||||
"": "empty key translation",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
translation := NewPrinter("en_US").Print("")
|
||||
if translation != "empty key translation" {
|
||||
t.Errorf(`Wrong translation for empty key, got %q`, translation)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintWithEmptyTranslation(t *testing.T) {
|
||||
defaultCatalog = catalog{
|
||||
"en_US": translationDict{
|
||||
singulars: map[string]string{
|
||||
"empty.value": "",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
translation := NewPrinter("en_US").Print("empty.value")
|
||||
if translation != "" {
|
||||
t.Errorf(`Wrong translation for empty value, got %q`, translation)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluralWithDefaultRule(t *testing.T) {
|
||||
defaultCatalog = catalog{
|
||||
"en_US": translationDict{
|
||||
plurals: map[string][]string{
|
||||
"number_of_users": {"%d user (%s)", "%d users (%s)"},
|
||||
},
|
||||
},
|
||||
"fr_FR": translationDict{
|
||||
plurals: map[string][]string{
|
||||
"number_of_users": {"%d utilisateur (%s)", "%d utilisateurs (%s)"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -112,13 +217,17 @@ func TestTranslatePluralWithDefaultRule(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestTranslatePluralWithRussianRule(t *testing.T) {
|
||||
func TestPluralWithRussianRule(t *testing.T) {
|
||||
defaultCatalog = catalog{
|
||||
"en_US": translationDict{
|
||||
"time_elapsed.years": []string{"%d year", "%d years"},
|
||||
plurals: map[string][]string{
|
||||
"time_elapsed.years": {"%d year", "%d years"},
|
||||
},
|
||||
},
|
||||
"ru_RU": translationDict{
|
||||
"time_elapsed.years": []string{"%d год назад", "%d года назад", "%d лет назад"},
|
||||
plurals: map[string][]string{
|
||||
"time_elapsed.years": {"%d год назад", "%d года назад", "%d лет назад"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -143,10 +252,12 @@ func TestTranslatePluralWithRussianRule(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestTranslatePluralWithMissingTranslation(t *testing.T) {
|
||||
func TestPluralWithMissingTranslation(t *testing.T) {
|
||||
defaultCatalog = catalog{
|
||||
"en_US": translationDict{
|
||||
"number_of_users": []string{"%d user (%s)", "%d users (%s)"},
|
||||
plurals: map[string][]string{
|
||||
"number_of_users": {"%d user (%s)", "%d users (%s)"},
|
||||
},
|
||||
},
|
||||
"fr_FR": translationDict{},
|
||||
}
|
||||
@@ -157,18 +268,89 @@ func TestTranslatePluralWithMissingTranslation(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestTranslatePluralWithInvalidValues(t *testing.T) {
|
||||
defaultCatalog = catalog{
|
||||
"en_US": translationDict{
|
||||
"number_of_users": []string{"%d user (%s)", "%d users (%s)"},
|
||||
},
|
||||
"fr_FR": translationDict{
|
||||
"number_of_users": "must be a slice",
|
||||
},
|
||||
}
|
||||
translation := NewPrinter("fr_FR").Plural("number_of_users", 2)
|
||||
expected := "number_of_users"
|
||||
func TestPluralWithMissingLanguage(t *testing.T) {
|
||||
defaultCatalog = catalog{}
|
||||
translation := NewPrinter("invalid_language").Plural("test.key", 2)
|
||||
expected := "test.key"
|
||||
if translation != expected {
|
||||
t.Errorf(`Wrong translation, got %q instead of %q`, translation, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluralWithIndexOutOfBounds(t *testing.T) {
|
||||
defaultCatalog = catalog{
|
||||
"test_lang": translationDict{
|
||||
plurals: map[string][]string{
|
||||
"limited.key": {"only one form"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Force a scenario where getPluralForm might return an index >= len(plurals)
|
||||
// We'll create a scenario with Czech language rules
|
||||
defaultCatalog["cs_CZ"] = translationDict{
|
||||
plurals: map[string][]string{
|
||||
"limited.key": {"one form only"}, // Only one form, but Czech has 3 plural forms
|
||||
},
|
||||
}
|
||||
|
||||
printer := NewPrinter("cs_CZ")
|
||||
// n=5 should return index 2 for Czech, but we only have 1 form (index 0)
|
||||
translation := printer.Plural("limited.key", 5)
|
||||
expected := "limited.key"
|
||||
if translation != expected {
|
||||
t.Errorf(`Wrong translation for out of bounds index, got %q instead of %q`, translation, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluralWithVariousLanguageRules(t *testing.T) {
|
||||
defaultCatalog = catalog{
|
||||
"ar_AR": translationDict{
|
||||
plurals: map[string][]string{
|
||||
"items": {"no items", "one item", "two items", "few items", "many items", "other items"},
|
||||
},
|
||||
},
|
||||
"pl_PL": translationDict{
|
||||
plurals: map[string][]string{
|
||||
"files": {"one file", "few files", "many files"},
|
||||
},
|
||||
},
|
||||
"ja_JP": translationDict{
|
||||
plurals: map[string][]string{
|
||||
"photos": {"photos"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
language string
|
||||
key string
|
||||
n int
|
||||
expected string
|
||||
}{
|
||||
// Arabic tests
|
||||
{"ar_AR", "items", 0, "no items"},
|
||||
{"ar_AR", "items", 1, "one item"},
|
||||
{"ar_AR", "items", 2, "two items"},
|
||||
{"ar_AR", "items", 5, "few items"}, // n%100 >= 3 && n%100 <= 10
|
||||
{"ar_AR", "items", 15, "many items"}, // n%100 >= 11
|
||||
|
||||
// Polish tests
|
||||
{"pl_PL", "files", 1, "one file"},
|
||||
{"pl_PL", "files", 3, "few files"}, // n%10 >= 2 && n%10 <= 4
|
||||
{"pl_PL", "files", 5, "many files"}, // default case
|
||||
|
||||
// Japanese tests (always uses same form)
|
||||
{"ja_JP", "photos", 1, "photos"},
|
||||
{"ja_JP", "photos", 10, "photos"},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
printer := NewPrinter(test.language)
|
||||
translation := printer.Plural(test.key, test.n)
|
||||
if translation != test.expected {
|
||||
t.Errorf(`Wrong translation for %s with n=%d, got %q instead of %q`,
|
||||
test.language, test.n, translation, test.expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.",
|
||||
@@ -27,7 +27,6 @@
|
||||
"alert.no_tag_entry": "Es gibt keine Artikel, die diesem Tag entsprechen.",
|
||||
"alert.no_unread_entry": "Es existiert kein ungelesener Artikel.",
|
||||
"alert.no_user": "Sie sind der einzige Benutzer.",
|
||||
"alert.pocket_linked": "Ihr Pocket-Konto ist jetzt verknüpft!",
|
||||
"alert.prefs_saved": "Einstellungen gespeichert!",
|
||||
"alert.too_many_feeds_refresh": [
|
||||
"Sie haben zu viele Aktualisierungen ausgelöst. Bitte warten Sie %d Minute, bevor Sie es erneut versuchen.",
|
||||
@@ -47,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": [
|
||||
@@ -77,6 +76,10 @@
|
||||
"entry.status.toast.read": "Als gelesen markiert",
|
||||
"entry.status.toast.unread": "Als ungelesen markiert",
|
||||
"entry.tags.label": "Stichworte:",
|
||||
"entry.tags.more_tags_label": [
|
||||
"Zeige %d weiteres Schlagwort",
|
||||
"Zeige %d weitere Schlagwörter"
|
||||
],
|
||||
"entry.unshare.label": "Nicht teilen",
|
||||
"error.api_key_already_exists": "Dieser API-Schlüssel ist bereits vorhanden.",
|
||||
"error.bad_credentials": "Benutzername oder Passwort ungültig.",
|
||||
@@ -89,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.",
|
||||
@@ -129,8 +132,6 @@
|
||||
"error.network_operation": "Miniflux kann die Webseite aufgrund eines Netzwerk-Fehlers nicht erreichen: %v",
|
||||
"error.network_timeout": "Die Webseite ist zu langsam und die Anfrage ist abgelaufen: %v.",
|
||||
"error.password_min_length": "Wenigstens 6 Zeichen müssen genutzt werden.",
|
||||
"error.pocket_access_token": "Zugriffstoken konnte nicht von Pocket abgerufen werden!",
|
||||
"error.pocket_request_token": "Anfrage-Token konnte nicht von Pocket abgerufen werden!",
|
||||
"error.proxy_url_not_empty": "Die Proxy-URL darf nicht leer sein.",
|
||||
"error.settings_block_rule_fieldname_invalid": "Ungültige Blockierregel: Regel #%d hat keinen gültigen Feldnamen (Optionen: %s)",
|
||||
"error.settings_block_rule_invalid_regex": "Ungültige Blockierregel: Das Muster für Regel #%d ist kein zulässiger regulärer Ausdruck",
|
||||
@@ -159,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",
|
||||
@@ -168,7 +170,8 @@
|
||||
"form.feed.fieldset.rules": "Regeln",
|
||||
"form.feed.label.allow_self_signed_certificates": "Erlaube selbstsignierte oder ungültige Zertifikate",
|
||||
"form.feed.label.apprise_service_urls": "Kommaseparierte Liste der Apprise-Service-URLs",
|
||||
"form.feed.label.blocklist_rules": "Blockierregeln",
|
||||
"form.feed.label.block_filter_entry_rules": "Eintrags-Sperrregeln",
|
||||
"form.feed.label.blocklist_rules": "Regex-basierte Sperrfilter",
|
||||
"form.feed.label.category": "Kategorie",
|
||||
"form.feed.label.cookie": "Cookies setzen",
|
||||
"form.feed.label.crawler": "Originalinhalt herunterladen",
|
||||
@@ -179,11 +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.keeplist_rules": "Erlaubnisregeln",
|
||||
"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",
|
||||
@@ -192,14 +196,14 @@
|
||||
"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",
|
||||
"form.feed.label.pushover_max_priority": "Höchste Pushoverpriorität",
|
||||
"form.feed.label.pushover_min_priority": "Niedrigste Pushoverpriorität",
|
||||
"form.feed.label.pushover_priority": "Pushover-Nachrichtenpriorität",
|
||||
"form.feed.label.rewrite_rules": "Umschreiberegeln",
|
||||
"form.feed.label.rewrite_rules": "Inhalts-Umschreibregeln",
|
||||
"form.feed.label.scraper_rules": "Extraktionsregeln",
|
||||
"form.feed.label.site_url": "URL der Webseite",
|
||||
"form.feed.label.title": "Titel",
|
||||
@@ -208,17 +212,17 @@
|
||||
"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.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",
|
||||
@@ -230,10 +234,13 @@
|
||||
"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.linkace_activate": "Einträge in LinkAce 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": "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",
|
||||
@@ -244,18 +251,28 @@
|
||||
"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-API-Endpunkt",
|
||||
"form.integration.linkwarden_endpoint": "Linkwarden-Base-URL",
|
||||
"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)",
|
||||
@@ -266,23 +283,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.pocket_access_token": "Pocket-Zugangstoken",
|
||||
"form.integration.pocket_activate": "Einträge in Pocket speichern",
|
||||
"form.integration.pocket_connect_link": "Verbinden Sie Ihr Pocket-Konto",
|
||||
"form.integration.pocket_consumer_key": "Pocket-Verbraucher-Schlüssel",
|
||||
"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",
|
||||
@@ -291,11 +304,11 @@
|
||||
"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.readwise_activate": "Artikel in Readwise Reader speichern",
|
||||
"form.integration.readwise_api_key": "Readwise-Reader-Zugangstoken",
|
||||
"form.integration.readwise_api_key_link": "Erhalten Sie Ihren Readwise-Zugangstoken",
|
||||
"form.integration.rssbridge_activate": "Beim Hinzufügen von Abonnements RSS-Bridge prüfen.",
|
||||
"form.integration.rssbridge_token": "RSS-Bridge authentication token",
|
||||
"form.integration.rssbridge_token": "RSS-Bridge-Authentifizierungs-Token",
|
||||
"form.integration.rssbridge_url": "RSS-Bridge-Server-URL",
|
||||
"form.integration.shaarli_activate": "Artikel in Shaarli speichern",
|
||||
"form.integration.shaarli_api_secret": "Shaarli-API-Geheimnis",
|
||||
@@ -304,7 +317,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",
|
||||
@@ -313,13 +326,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",
|
||||
@@ -328,6 +342,7 @@
|
||||
"form.prefs.fieldset.global_feed_settings": "Globale Feedeinstellungen",
|
||||
"form.prefs.fieldset.reader_settings": "Reader-Einstellungen",
|
||||
"form.prefs.help.external_font_hosts": "Per Leerzeichen getrennte Liste externer Schriftarten-Hosts, die erlaubt werden sollen. Beispiel: \"fonts.gstatic.com fonts.googleapis.com\".",
|
||||
"form.prefs.label.always_open_external_links": "Artikel immer mit Öffnen der Links lesen",
|
||||
"form.prefs.label.categories_sorting_order": "Kategorie-Sortierung",
|
||||
"form.prefs.label.cjk_reading_speed": "Lesegeschwindigkeit für Chinesisch, Koreanisch und Japanisch (Zeichen pro Minute)",
|
||||
"form.prefs.label.custom_css": "Benutzerdefiniertes CSS",
|
||||
@@ -335,20 +350,20 @@
|
||||
"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.always_open_external_links": "Read articles by opening external links",
|
||||
"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",
|
||||
@@ -358,9 +373,9 @@
|
||||
"form.prefs.select.fullscreen": "Vollbildschirm",
|
||||
"form.prefs.select.minimal_ui": "Minimal",
|
||||
"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",
|
||||
@@ -401,15 +416,15 @@
|
||||
"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.title": "Menu",
|
||||
"menu.starred": "Markiert",
|
||||
"menu.title": "Menü",
|
||||
"menu.unread": "Ungelesen",
|
||||
"menu.users": "Benutzer",
|
||||
"page.about.author": "Autor:",
|
||||
"page.about.build_date": "Datum der Kompilierung:",
|
||||
"page.about.credits": "Urheberrechte",
|
||||
"page.about.db_usage": "Datenbankgröße:",
|
||||
"page.about.git_commit": "Git Commit:",
|
||||
"page.about.git_commit": "Git-Commit:",
|
||||
"page.about.global_config_options": "Globale Konfigurationsoptionen",
|
||||
"page.about.go_version": "Go-Version:",
|
||||
"page.about.license": "Lizenz:",
|
||||
@@ -459,6 +474,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",
|
||||
@@ -484,7 +500,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",
|
||||
@@ -503,10 +519,10 @@
|
||||
"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, fokus als nächstes",
|
||||
"page.keyboard_shortcuts.toggle_read_status_prev": "Gewählten Artikel als gelesen/ungelesen markieren, fokus vorherige",
|
||||
"page.keyboard_shortcuts.toggle_read_status_next": "Gewählten Artikel als gelesen/ungelesen markieren, nächsten auswählen",
|
||||
"page.keyboard_shortcuts.toggle_read_status_prev": "Gewählten Artikel als gelesen/ungelesen markieren, vorherigen auswählen",
|
||||
"page.login.google_signin": "Anmeldung mit Google",
|
||||
"page.login.oidc_signin": "Anmeldung mit %s",
|
||||
"page.login.title": "Anmeldung",
|
||||
@@ -527,7 +543,7 @@
|
||||
"page.sessions.table.actions": "Aktionen",
|
||||
"page.sessions.table.current_session": "Aktuelle Sitzung",
|
||||
"page.sessions.table.date": "Datum",
|
||||
"page.sessions.table.ip": "IP-Addresse",
|
||||
"page.sessions.table.ip": "IP-Adresse",
|
||||
"page.sessions.table.user_agent": "Benutzeragent",
|
||||
"page.sessions.title": "Sitzungen",
|
||||
"page.settings.link_google_account": "Google-Konto verknüpfen",
|
||||
@@ -551,10 +567,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",
|
||||
@@ -574,13 +590,13 @@
|
||||
"page.users.title": "Benutzer",
|
||||
"page.users.username": "Benutzername",
|
||||
"page.webauthn_rename.title": "Passkey umbenennen",
|
||||
"pagination.first": "First",
|
||||
"pagination.last": "Last",
|
||||
"pagination.first": "Erste",
|
||||
"pagination.last": "Letzte",
|
||||
"pagination.next": "Nächste",
|
||||
"pagination.previous": "Vorherige",
|
||||
"search.label": "Suche",
|
||||
"search.placeholder": "Suche...",
|
||||
"search.submit": "Search",
|
||||
"search.submit": "Suchen",
|
||||
"skip_to_content": "Zum Inhalt springen",
|
||||
"time_elapsed.days": [
|
||||
"vor %d Tag",
|
||||
|
||||
@@ -13,9 +13,9 @@
|
||||
"action.update": "Ενημέρωση",
|
||||
"alert.account_linked": "Ο εξωτερικός σας λογαριασμός είναι πλέον συνδεδεμένος!",
|
||||
"alert.account_unlinked": "Ο εξωτερικός σας λογαριασμός είναι πλέον αποσυνδεδεμένος!",
|
||||
"alert.background_feed_refresh": "All feeds are being refreshed in the background. You can continue to use Miniflux while this process is running.",
|
||||
"alert.background_feed_refresh": "Όλες οι ροές ανανεώνονται στο παρασκήνιο. Μπορείτε να συνεχίσετε να χρησιμοποιείτε το Miniflux όσο εκτελείται αυτή η διαδικασία.",
|
||||
"alert.feed_error": "Υπάρχει πρόβλημα με αυτήν τη ροή",
|
||||
"alert.no_bookmark": "Δεν υπάρχει σελιδοδείκτης αυτή τη στιγμή.",
|
||||
"alert.no_starred": "Δεν υπάρχει σελιδοδείκτης αυτή τη στιγμή.",
|
||||
"alert.no_category": "Δεν υπάρχει κατηγορία.",
|
||||
"alert.no_category_entry": "Δεν υπάρχουν άρθρα σε αυτήν την κατηγορία.",
|
||||
"alert.no_feed": "Δεν έχετε συνδρομές.",
|
||||
@@ -27,30 +27,29 @@
|
||||
"alert.no_tag_entry": "Δεν υπάρχουν αντικείμενα που να ταιριάζουν με αυτή την ετικέτα.",
|
||||
"alert.no_unread_entry": "Δεν υπάρχουν μη αναγνωσμένα άρθρα.",
|
||||
"alert.no_user": "Είστε ο μόνος χρήστης.",
|
||||
"alert.pocket_linked": "Ο λογαριασμός Pocket είναι τώρα συνδεδεμένος!",
|
||||
"alert.prefs_saved": "Οι προτιμήσεις αποθηκεύτηκαν!",
|
||||
"alert.too_many_feeds_refresh": [
|
||||
"You have triggered too many feed refreshes. Please wait %d minute before trying again.",
|
||||
"You have triggered too many feed refreshes. Please wait %d minutes before trying again."
|
||||
"Έχετε ενεργοποιήσει πάρα πολλές ανανεώσεις ροών. Παρακαλώ περιμένετε %d λεπτό πριν προσπαθήσετε ξανά.",
|
||||
"Έχετε ενεργοποιήσει πάρα πολλές ανανεώσεις ροών. Παρακαλώ περιμένετε %d λεπτά πριν προσπαθήσετε ξανά."
|
||||
],
|
||||
"confirm.loading": "Σε εξέλιξη...",
|
||||
"confirm.no": "όχι",
|
||||
"confirm.question": "Είστε σίγουροι;",
|
||||
"confirm.question.refresh": "Θέλετε να επιτελέσετε μια υποχρεωτική ανανέωση;",
|
||||
"confirm.yes": "ναι",
|
||||
"enclosure_media_controls.seek": "Seek:",
|
||||
"enclosure_media_controls.seek.title": "Seek %s seconds",
|
||||
"enclosure_media_controls.speed": "Speed:",
|
||||
"enclosure_media_controls.speed.faster": "Faster",
|
||||
"enclosure_media_controls.speed.faster.title": "Faster by %sx",
|
||||
"enclosure_media_controls.speed.reset": "Reset",
|
||||
"enclosure_media_controls.speed.reset.title": "Reset speed to 1x",
|
||||
"enclosure_media_controls.speed.slower": "Slower",
|
||||
"enclosure_media_controls.speed.slower.title": "Slower by %sx",
|
||||
"entry.bookmark.toast.off": "Μη αγαπημένα",
|
||||
"entry.bookmark.toast.on": "Αγαπημένα",
|
||||
"entry.bookmark.toggle.off": "Αναίρεση αγαπημένου",
|
||||
"entry.bookmark.toggle.on": "Αγαπημένο",
|
||||
"enclosure_media_controls.seek": "Αναζήτηση:",
|
||||
"enclosure_media_controls.seek.title": "Αναζήτηση %s δευτερόλεπτα",
|
||||
"enclosure_media_controls.speed": "Ταχύτητα:",
|
||||
"enclosure_media_controls.speed.faster": "Γρηγορότερα",
|
||||
"enclosure_media_controls.speed.faster.title": "Γρηγορότερα κατά %sx",
|
||||
"enclosure_media_controls.speed.reset": "Επαναφορά",
|
||||
"enclosure_media_controls.speed.reset.title": "Επαναφορά ταχύτητας σε 1x",
|
||||
"enclosure_media_controls.speed.slower": "Πιο αργά",
|
||||
"enclosure_media_controls.speed.slower.title": "Πιο αργά κατά %sx",
|
||||
"entry.starred.toast.off": "Μη αγαπημένα",
|
||||
"entry.starred.toast.on": "Αγαπημένα",
|
||||
"entry.starred.toggle.off": "Αναίρεση αγαπημένου",
|
||||
"entry.starred.toggle.on": "Αγαπημένο",
|
||||
"entry.comments.label": "Σχόλια",
|
||||
"entry.comments.title": "Δείτε Σχόλια",
|
||||
"entry.estimated_reading_time": [
|
||||
@@ -77,103 +76,107 @@
|
||||
"entry.status.toast.read": "Επισήμανση ως αναγνωσμένο",
|
||||
"entry.status.toast.unread": "Επισήμανση ως μη αναγνωσμένο",
|
||||
"entry.tags.label": "Ετικέτες:",
|
||||
"entry.tags.more_tags_label": [
|
||||
"Εμφάνιση %d ακόμη ετικέτας",
|
||||
"Εμφάνιση %d ακόμη ετικετών"
|
||||
],
|
||||
"entry.unshare.label": "Aναίρεση Διαμοιρασμού",
|
||||
"error.api_key_already_exists": "Αυτό το κλειδί API υπάρχει ήδη.",
|
||||
"error.bad_credentials": "Μη έγκυρο όνομα χρήστη ή κωδικό πρόσβασης.",
|
||||
"error.category_already_exists": "Αυτή η κατηγορία υπάρχει ήδη.",
|
||||
"error.category_not_found": "This category does not exist or does not belong to this user.",
|
||||
"error.database_error": "Database error: %v.",
|
||||
"error.category_not_found": "Αυτή η κατηγορία δεν υπάρχει ή δεν ανήκει σε αυτόν τον χρήστη.",
|
||||
"error.database_error": "Σφάλμα βάσης δεδομένων: %v.",
|
||||
"error.different_passwords": "Οι κωδικοί πρόσβασης δεν είναι οι ίδιοι.",
|
||||
"error.duplicate_fever_username": "Υπάρχει ήδη κάποιος άλλος με το ίδιο όνομα χρήστη Fever!",
|
||||
"error.duplicate_googlereader_username": "Υπάρχει ήδη κάποιος άλλος με το ίδιο όνομα χρήστη Google Reader!",
|
||||
"error.duplicate_linked_account": "Υπάρχει ήδη κάποιος που σχετίζεται με αυτόν τον πάροχο!",
|
||||
"error.duplicated_feed": "This feed already exists.",
|
||||
"error.duplicated_feed": "Αυτή η ροή υπάρχει ήδη.",
|
||||
"error.empty_file": "Αυτό το αρχείο είναι κενό.",
|
||||
"error.entries_per_page_invalid": "Ο αριθμός των καταχωρήσεων ανά σελίδα δεν είναι έγκυρος.",
|
||||
"error.feed_already_exists": "Αυτή η ροή υπάρχει ήδη.",
|
||||
"error.feed_category_not_found": "Αυτή η κατηγορία δεν υπάρχει ή δεν ανήκει σε αυτόν τον χρήστη.",
|
||||
"error.feed_format_not_detected": "Unable to detect feed format: %v.",
|
||||
"error.feed_format_not_detected": "Δεν είναι δυνατή η ανίχνευση της μορφής ροής: %v.",
|
||||
"error.feed_invalid_blocklist_rule": "Ο κανόνας λίστας μπλοκ δεν είναι έγκυρος.",
|
||||
"error.feed_invalid_keeplist_rule": "Ο κανόνας keep list δεν είναι έγκυρος.",
|
||||
"error.feed_mandatory_fields": "Η διεύθυνση URL και η κατηγορία είναι υποχρεωτικά.",
|
||||
"error.feed_not_found": "This feed does not exist or does not belong to this user.",
|
||||
"error.feed_not_found": "Αυτή η ροή δεν υπάρχει ή δεν ανήκει σε αυτόν τον χρήστη.",
|
||||
"error.feed_title_not_empty": "Ο τίτλος ροής δεν μπορεί να είναι κενός.",
|
||||
"error.feed_url_not_empty": "Η διεύθυνση URL ροής δεν μπορεί να είναι κενή.",
|
||||
"error.fields_mandatory": "Όλα τα πεδία είναι υποχρεωτικά.",
|
||||
"error.http_bad_gateway": "The website is not available at the moment due to a bad gateway error. The problem is not on Miniflux side. Please, try again later.",
|
||||
"error.http_body_read": "Unable to read the HTTP body: %v.",
|
||||
"error.http_client_error": "HTTP client error: %v.",
|
||||
"error.http_empty_response": "The HTTP response is empty. Perhaps, this website is using a bot protection mechanism?",
|
||||
"error.http_empty_response_body": "The HTTP response body is empty.",
|
||||
"error.http_forbidden": "Access to this website is forbidden. Perhaps, this website has a bot protection mechanism?",
|
||||
"error.http_gateway_timeout": "The website is not available at the moment due to a gateway timeout error. The problem is not on Miniflux side. Please, try again later.",
|
||||
"error.http_internal_server_error": "The website is not available at the moment due to a server error. The problem is not on Miniflux side. Please, try again later.",
|
||||
"error.http_not_authorized": "Access to this website is not authorized. It could be a bad username or password.",
|
||||
"error.http_resource_not_found": "The requested resource is not found. Please, verify the URL.",
|
||||
"error.http_response_too_large": "The HTTP response is too large. You could increase the HTTP response size limit in the global settings (requires a server restart).",
|
||||
"error.http_service_unavailable": "The website is not available at the moment due to an internal server error. The problem is not on Miniflux side. Please, try again later.",
|
||||
"error.http_too_many_requests": "Miniflux generated too many requests to this website. Please, try again later or change the application configuration.",
|
||||
"error.http_unexpected_status_code": "The website is not available at the moment due to an unexpected HTTP status code: %d. The problem is not on Miniflux side. Please, try again later.",
|
||||
"error.http_bad_gateway": "Ο ιστότοπος δεν είναι διαθέσιμος αυτήν τη στιγμή λόγω σφάλματος κακής πύλης. Το πρόβλημα δεν είναι στην πλευρά του Miniflux. Παρακαλώ δοκιμάστε ξανά αργότερα.",
|
||||
"error.http_body_read": "Δεν είναι δυνατή η ανάγνωση του σώματος HTTP: %v.",
|
||||
"error.http_client_error": "Σφάλμα πελάτη HTTP: %v.",
|
||||
"error.http_empty_response": "Η απάντηση HTTP είναι κενή. Ίσως αυτός ο ιστότοπος χρησιμοποιεί μηχανισμό προστασίας από bot;",
|
||||
"error.http_empty_response_body": "Το σώμα απάντησης HTTP είναι κενό.",
|
||||
"error.http_forbidden": "Η πρόσβαση σε αυτόν τον ιστότοπο απαγορεύεται. Ίσως αυτός ο ιστότοπος διαθέτει μηχανισμό προστασίας από bot;",
|
||||
"error.http_gateway_timeout": "Ο ιστότοπος δεν είναι διαθέσιμος αυτήν τη στιγμή λόγω σφάλματος χρονικού ορίου πύλης. Το πρόβλημα δεν είναι στην πλευρά του Miniflux. Παρακαλώ δοκιμάστε ξανά αργότερα.",
|
||||
"error.http_internal_server_error": "Ο ιστότοπος δεν είναι διαθέσιμος αυτήν τη στιγμή λόγω σφάλματος διακομιστή. Το πρόβλημα δεν είναι στην πλευρά του Miniflux. Παρακαλώ δοκιμάστε ξανά αργότερα.",
|
||||
"error.http_not_authorized": "Η πρόσβαση σε αυτόν τον ιστότοπο δεν είναι εξουσιοδοτημένη. Μπορεί να είναι λανθασμένο όνομα χρήστη ή κωδικός πρόσβασης.",
|
||||
"error.http_resource_not_found": "Ο ζητούμενος πόρος δεν βρέθηκε. Επαληθεύστε τη διεύθυνση URL.",
|
||||
"error.http_response_too_large": "Η απάντηση HTTP είναι πολύ μεγάλη. Μπορείτε να αυξήσετε το όριο μεγέθους απάντησης HTTP στις καθολικές ρυθμίσεις (απαιτεί επανεκκίνηση του διακομιστή).",
|
||||
"error.http_service_unavailable": "Ο ιστότοπος δεν είναι διαθέσιμος αυτήν τη στιγμή λόγω εσωτερικού σφάλματος διακομιστή. Το πρόβλημα δεν είναι στην πλευρά του Miniflux. Παρακαλώ δοκιμάστε ξανά αργότερα.",
|
||||
"error.http_too_many_requests": "Το Miniflux δημιούργησε πάρα πολλά αιτήματα σε αυτόν τον ιστότοπο. Παρακαλώ δοκιμάστε ξανά αργότερα ή αλλάξτε τη διαμόρφωση της εφαρμογής.",
|
||||
"error.http_unexpected_status_code": "Ο ιστότοπος δεν είναι διαθέσιμος αυτήν τη στιγμή λόγω μη αναμενόμενου κωδικού κατάστασης HTTP: %d. Το πρόβλημα δεν είναι στην πλευρά του Miniflux. Παρακαλώ δοκιμάστε ξανά αργότερα.",
|
||||
"error.invalid_categories_sorting_order": "Η κατηγορία δεν μπορεί να είναι κενή.",
|
||||
"error.invalid_default_home_page": "Μη έγκυρη προεπιλεγμένη αρχική σελίδα!",
|
||||
"error.invalid_display_mode": "Μη έγκυρη λειτουργία εμφάνισης εφαρμογών ιστού.",
|
||||
"error.invalid_entry_direction": "Μη έγκυρη κατεύθυνση ταξινόμησης άρθρων.",
|
||||
"error.invalid_entry_order": "Η σειρά των καταχωρήσεων είναι μη έγκυρη.",
|
||||
"error.invalid_feed_proxy_url": "Invalid proxy URL.",
|
||||
"error.invalid_feed_proxy_url": "Μη έγκυρη διεύθυνση URL διακομιστή μεσολάβησης.",
|
||||
"error.invalid_feed_url": "Μη έγκυρη διεύθυνση URL ροής.",
|
||||
"error.invalid_gesture_nav": "Μη έγκυρη πλοήγηση με χειρονομίες.",
|
||||
"error.invalid_language": "Μη έγκυρη γλώσσα.",
|
||||
"error.invalid_site_url": "Μη έγκυρη διεύθυνση URL ιστότοπου.",
|
||||
"error.invalid_theme": "Μη έγκυρο θέμα.",
|
||||
"error.invalid_timezone": "Μη έγκυρη ζώνη ώρας.",
|
||||
"error.network_operation": "Miniflux 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 δεν μπορεί να φτάσει σε αυτόν τον ιστότοπο λόγω σφάλματος δικτύου: %v.",
|
||||
"error.network_timeout": "Αυτός ο ιστότοπος είναι πολύ αργός και το αίτημα έληξε: %v",
|
||||
"error.password_min_length": "Ο κωδικός πρόσβασης πρέπει να έχει τουλάχιστον 6 χαρακτήρες.",
|
||||
"error.pocket_access_token": "Δεν είναι δυνατή η λήψη του access token από το Pocket!",
|
||||
"error.pocket_request_token": "Δεν είναι δυνατή η λήψη του request token από το Pocket!",
|
||||
"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": "Η διεύθυνση URL του διακομιστή μεσολάβησης δεν μπορεί να είναι κενή.",
|
||||
"error.settings_block_rule_fieldname_invalid": "Μη έγκυρος κανόνας αποκλεισμού: ο κανόνας #%d λείπει ένα έγκυρο όνομα πεδίου (Επιλογές: %s)",
|
||||
"error.settings_block_rule_invalid_regex": "Μη έγκυρος κανόνας αποκλεισμού: το μοτίβο του κανόνα #%d δεν είναι έγκυρη κανονική έκφραση",
|
||||
"error.settings_block_rule_regex_required": "Μη έγκυρος κανόνας αποκλεισμού: το μοτίβο του κανόνα #%d δεν παρέχεται",
|
||||
"error.settings_block_rule_separator_required": "Μη έγκυρος κανόνας αποκλεισμού: το μοτίβο του κανόνα #%d απαιτείται να διαχωρίζεται με ένα '='",
|
||||
"error.settings_invalid_domain_list": "Μη έγκυρη λίστα τομέων. Παρακαλώ δώστε μια λίστα τομέων διαχωρισμένων με κενό.",
|
||||
"error.settings_keep_rule_fieldname_invalid": "Μη έγκυρος κανόνας διατήρησης: ο κανόνας #%d λείπει ένα έγκυρο όνομα πεδίου (Επιλογές: %s)",
|
||||
"error.settings_keep_rule_invalid_regex": "Μη έγκυρος κανόνας διατήρησης: το μοτίβο του κανόνα #%d δεν είναι έγκυρη κανονική έκφραση",
|
||||
"error.settings_keep_rule_regex_required": "Μη έγκυρος κανόνας διατήρησης: το μοτίβο του κανόνα #%d δεν παρέχεται",
|
||||
"error.settings_keep_rule_separator_required": "Μη έγκυρος κανόνας διατήρησης: το μοτίβο του κανόνα #%d απαιτείται να διαχωρίζεται με ένα '='",
|
||||
"error.settings_mandatory_fields": "Τα πεδία όνομα χρήστη, θέμα, Γλώσσα και ζώνη ώρας είναι υποχρεωτικά.",
|
||||
"error.settings_media_playback_rate_range": "Η ταχύτητα αναπαραγωγής είναι εκτός εύρους",
|
||||
"error.settings_reading_speed_is_positive": "Οι ταχύτητες ανάγνωσης πρέπει να είναι θετικοί ακέραιοι αριθμοί.",
|
||||
"error.site_url_not_empty": "Η διεύθυνση URL του ιστότοπου δεν μπορεί να είναι κενή.",
|
||||
"error.subscription_not_found": "Δεν είναι δυνατή η εύρεση συνδρομής.",
|
||||
"error.title_required": "Ο τίτλος είναι υποχρεωτικός.",
|
||||
"error.tls_error": "TLS 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": "Δεν είναι δυνατή η δημιουργία αυτού του κλειδιού API.",
|
||||
"error.unable_to_create_category": "Δεν είναι δυνατή η δημιουργία αυτής της κατηγορίας.",
|
||||
"error.unable_to_create_user": "Δεν είναι δυνατή η δημιουργία αυτού του χρήστη.",
|
||||
"error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.",
|
||||
"error.unable_to_parse_feed": "Unable to parse this feed: %v.",
|
||||
"error.unable_to_detect_rssbridge": "Δεν είναι δυνατή η ανίχνευση ροής με χρήση RSS-Bridge: %v.",
|
||||
"error.unable_to_parse_feed": "Δεν είναι δυνατή η ανάλυση αυτής της ροής: %v.",
|
||||
"error.unable_to_update_category": "Δεν είναι δυνατή η ενημέρωση αυτής της κατηγορίας.",
|
||||
"error.unable_to_update_feed": "Δεν είναι δυνατή η ενημέρωση αυτής της ροής.",
|
||||
"error.unable_to_update_user": "Δεν είναι δυνατή η ενημέρωση αυτού του χρήστη.",
|
||||
"error.unlink_account_without_password": "Πρέπει να ορίσετε έναν κωδικό πρόσβασης διαφορετικά δεν θα μπορείτε να συνδεθείτε ξανά.",
|
||||
"error.user_already_exists": "Αυτός ο χρήστης υπάρχει ήδη.",
|
||||
"error.user_mandatory_fields": "Το όνομα χρήστη είναι υποχρεωτικό.",
|
||||
"error.linktaco_missing_required_fields": "Το LinkTaco API Token και το Organization Slug είναι απαραίτητα",
|
||||
"form.api_key.label.description": "Ετικέτα κλειδιού API",
|
||||
"form.category.hide_globally": "Απόκρυψη καταχωρήσεων σε γενική λίστα μη αναγνωσμένων",
|
||||
"form.category.label.title": "Τίτλος",
|
||||
"form.feed.fieldset.general": "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.blocklist_rules": "Κανόνες Αποκλεισμού",
|
||||
"form.feed.label.apprise_service_urls": "Λίστα διευθύνσεων URL υπηρεσιών Apprise διαχωρισμένων με κόμμα",
|
||||
"form.feed.label.block_filter_entry_rules": "Κανόνες Αποκλεισμού Καταχωρήσεων",
|
||||
"form.feed.label.blocklist_rules": "Φίλτρα Αποκλεισμού Βασισμένα σε Regex",
|
||||
"form.feed.label.category": "Κατηγορία",
|
||||
"form.feed.label.cookie": "Ορισμός Cookies",
|
||||
"form.feed.label.crawler": "Λήψη αρχικού περιεχομένου",
|
||||
"form.feed.label.description": "Περιγραφή",
|
||||
"form.feed.label.disable_http2": "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": "Διεύθυνση URL ροής",
|
||||
@@ -181,43 +184,44 @@
|
||||
"form.feed.label.fetch_via_proxy": "Χρησιμοποιήστε τον διακομιστή μεσολάβησης που έχει ρυθμιστεί σε επίπεδο εφαρμογής",
|
||||
"form.feed.label.hide_globally": "Απόκρυψη καταχωρήσεων σε γενική λίστα μη αναγνωσμένων",
|
||||
"form.feed.label.ignore_http_cache": "Αγνοήστε την προσωρινή μνήμη HTTP",
|
||||
"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.rewrite_rules": "Κανόνες Μετατροπής",
|
||||
"form.feed.label.keep_filter_entry_rules": "Κανόνες Επιτρεπόμενων Καταχωρήσεων",
|
||||
"form.feed.label.keeplist_rules": "Φίλτρα Διατήρησης Βασισμένα σε Regex",
|
||||
"form.feed.label.no_media_player": "Χωρίς πρόγραμμα αναπαραγωγής πολυμέσων (ήχος/βίντεο)",
|
||||
"form.feed.label.ntfy_activate": "Προώθηση καταχωρήσεων στο ntfy",
|
||||
"form.feed.label.ntfy_default_priority": "Προεπιλεγμένη προτεραιότητα Ntfy",
|
||||
"form.feed.label.ntfy_high_priority": "Υψηλή προτεραιότητα Ntfy",
|
||||
"form.feed.label.ntfy_low_priority": "Χαμηλή προτεραιότητα Ntfy",
|
||||
"form.feed.label.ntfy_max_priority": "Μέγιστη προτεραιότητα Ntfy",
|
||||
"form.feed.label.ntfy_min_priority": "Ελάχιστη προτεραιότητα Ntfy",
|
||||
"form.feed.label.ntfy_priority": "Προτεραιότητα Ntfy",
|
||||
"form.feed.label.ntfy_topic": "Θέμα Ntfy (προαιρετικό)",
|
||||
"form.feed.label.proxy_url": "Διεύθυνση URL διακομιστή μεσολάβησης",
|
||||
"form.feed.label.pushover_activate": "Προώθηση καταχωρήσεων στο pushover.net",
|
||||
"form.feed.label.pushover_default_priority": "Προεπιλεγμένη προτεραιότητα Pushover",
|
||||
"form.feed.label.pushover_high_priority": "Υψηλή προτεραιότητα Pushover",
|
||||
"form.feed.label.pushover_low_priority": "Χαμηλή προτεραιότητα Pushover",
|
||||
"form.feed.label.pushover_max_priority": "Μέγιστη προτεραιότητα Pushover",
|
||||
"form.feed.label.pushover_min_priority": "Ελάχιστη προτεραιότητα Pushover",
|
||||
"form.feed.label.pushover_priority": "Προτεραιότητα μηνύματος Pushover",
|
||||
"form.feed.label.rewrite_rules": "Κανόνες Επανασύνταξης Περιεχομένου",
|
||||
"form.feed.label.scraper_rules": "Κανόνες Scraper",
|
||||
"form.feed.label.site_url": "Διεύθυνση URL ιστότοπου",
|
||||
"form.feed.label.title": "Τίτλος",
|
||||
"form.feed.label.urlrewrite_rules": "επανεγγραφή κανόνων για τη διεύθυνση URL.",
|
||||
"form.feed.label.urlrewrite_rules": "κανόνες επανεγγραφής για τη διεύθυνση URL.",
|
||||
"form.feed.label.user_agent": "Παράκαμψη Προεπιλεγμένου User Agent Χρήστη",
|
||||
"form.feed.label.webhook_url": "Override webhook url",
|
||||
"form.feed.label.webhook_url": "Παράκαμψη διεύθυνσης URL webhook",
|
||||
"form.import.label.file": "Αρχείο OPML",
|
||||
"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",
|
||||
"form.integration.apprise_activate": "Προώθηση καταχωρήσεων στο Apprise",
|
||||
"form.integration.apprise_services_url": "Λίστα διευθύνσεων URL υπηρεσιών Apprise διαχωρισμένων με κόμμα",
|
||||
"form.integration.apprise_url": "Διεύθυνση URL API Apprise",
|
||||
"form.integration.betula_activate": "Αποθήκευση καταχωρήσεων στο Betula",
|
||||
"form.integration.betula_token": "Διακριτικό Betula",
|
||||
"form.integration.betula_url": "Διεύθυνση URL διακομιστή Betula",
|
||||
"form.integration.cubox_activate": "Αποθήκευση καταχωρήσεων στο Cubox",
|
||||
"form.integration.cubox_api_link": "Σύνδεσμος API Cubox",
|
||||
"form.integration.discord_activate": "Προώθηση καταχωρήσεων στο Discord",
|
||||
"form.integration.discord_webhook_link": "Σύνδεσμος Webhook Discord",
|
||||
"form.integration.espial_activate": "Αποθήκευση άρθρων στο Espial",
|
||||
"form.integration.espial_api_key": "Κλειδί API Espial",
|
||||
"form.integration.espial_endpoint": "Τελικό σημείο Espial API",
|
||||
@@ -233,36 +237,49 @@
|
||||
"form.integration.instapaper_activate": "Αποθήκευση άρθρων στο Instapaper",
|
||||
"form.integration.instapaper_password": "Κωδικός Πρόσβασης Instapaper",
|
||||
"form.integration.instapaper_username": "Όνομα Χρήστη Instapaper",
|
||||
"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": "Κλειδί API Karakeep",
|
||||
"form.integration.karakeep_url": "Τελικό σημείο Karakeep API",
|
||||
"form.integration.linkace_activate": "Αποθήκευση καταχωρήσεων στο LinkAce",
|
||||
"form.integration.linkace_api_key": "Κλειδί API LinkAce",
|
||||
"form.integration.linkace_check_disabled": "Απενεργοποίηση ελέγχου συνδέσμου",
|
||||
"form.integration.linkace_endpoint": "Τελικό σημείο API LinkAce",
|
||||
"form.integration.linkace_is_private": "Σήμανση συνδέσμου ως ιδιωτικού",
|
||||
"form.integration.linkace_tags": "Ετικέτες LinkAce",
|
||||
"form.integration.linkding_activate": "Αποθήκευση άρθρων στο Linkding",
|
||||
"form.integration.linkding_api_key": "Κλειδί API Linkding",
|
||||
"form.integration.linkding_bookmark": "Σημείωση του σελιδοδείκτη ως μη αναγνωσμένου",
|
||||
"form.integration.linkding_endpoint": "Τελικό σημείο Linkding API",
|
||||
"form.integration.linkding_tags": "Linkding Tags",
|
||||
"form.integration.linkding_tags": "Ετικέτες Linkding",
|
||||
"form.integration.linktaco_activate": "Αποθήκευση καταχωρήσεων στο LinkTaco",
|
||||
"form.integration.linktaco_api_token": "LinkTaco API Token",
|
||||
"form.integration.linktaco_api_token_hint": "Λάβετε το προσωπικό σας διακριτικό πρόσβασης στο",
|
||||
"form.integration.linktaco_org_slug": "Organization Slug",
|
||||
"form.integration.linktaco_tags": "Ετικέτες (μέγιστο 10, διαχωρισμένες με κόμμα)",
|
||||
"form.integration.linktaco_tags_hint": "Μέγιστο 10 ετικέτες, διαχωρισμένες με κόμμα",
|
||||
"form.integration.linktaco_visibility": "Ορατότητα",
|
||||
"form.integration.linktaco_visibility_public": "Δημόσια",
|
||||
"form.integration.linktaco_visibility_private": "Ιδιωτική",
|
||||
"form.integration.linktaco_visibility_hint": "Η ΙΔΙΩΤΙΚΗ ορατότητα απαιτεί επί πληρωμή λογαριασμό LinkTaco",
|
||||
"form.integration.linkwarden_activate": "Αποθήκευση άρθρων στο Linkwarden",
|
||||
"form.integration.linkwarden_api_key": "Κλειδί API Linkwarden",
|
||||
"form.integration.linkwarden_endpoint": "Τελικό σημείο Linkwarden API",
|
||||
"form.integration.linkwarden_endpoint": "URL βάσης Linkwarden",
|
||||
"form.integration.matrix_bot_activate": "Μεταφορά νέων άρθρων στο Matrix",
|
||||
"form.integration.matrix_bot_chat_id": "Αναγνωριστικό της αίθουσας Matrix",
|
||||
"form.integration.matrix_bot_password": "Κωδικός πρόσβασης για τον χρήστη Matrix",
|
||||
"form.integration.matrix_bot_url": "URL διακομιστή Matrix",
|
||||
"form.integration.matrix_bot_user": "Όνομα χρήστη για το Matrix",
|
||||
"form.integration.notion_activate": "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",
|
||||
"form.integration.notion_token": "Μυστικό διακριτικό Notion",
|
||||
"form.integration.ntfy_activate": "Προώθηση καταχωρήσεων στο ntfy",
|
||||
"form.integration.ntfy_api_token": "Διακριτικό API Ntfy (προαιρετικό)",
|
||||
"form.integration.ntfy_icon_url": "Διεύθυνση URL εικονιδίου Ntfy (προαιρετικό)",
|
||||
"form.integration.ntfy_internal_links": "Χρήση εσωτερικών συνδέσμων με κλικ (προαιρετικό)",
|
||||
"form.integration.ntfy_password": "Κωδικός πρόσβασης Ntfy (προαιρετικό)",
|
||||
"form.integration.ntfy_topic": "Θέμα Ntfy (προεπιλογή χρησιμοποιείται εάν δεν οριστεί στη ροή)",
|
||||
"form.integration.ntfy_url": "Διεύθυνση URL Ntfy (προαιρετικό, προεπιλογή είναι ntfy.sh)",
|
||||
"form.integration.ntfy_username": "Όνομα χρήστη Ntfy (προαιρετικό)",
|
||||
"form.integration.nunux_keeper_activate": "Αποθήκευση άρθρων στο Nunux Keeper",
|
||||
"form.integration.nunux_keeper_api_key": "Κλειδί API Nunux Keeper",
|
||||
"form.integration.nunux_keeper_endpoint": "Τελικό σημείο Nunux Keeper API",
|
||||
@@ -272,47 +289,43 @@
|
||||
"form.integration.pinboard_activate": "Αποθήκευση άρθρων στο Pinboard",
|
||||
"form.integration.pinboard_bookmark": "Σημείωση του σελιδοδείκτη ως μη αναγνωσμένου",
|
||||
"form.integration.pinboard_tags": "Ετικέτες Pinboard",
|
||||
"form.integration.pinboard_token": "Pinboard API Token",
|
||||
"form.integration.pocket_access_token": "Pocket Access Token",
|
||||
"form.integration.pocket_activate": "Αποθήκευση άρθρων στο Pocket",
|
||||
"form.integration.pocket_connect_link": "Συνδέστε τον λογαριασμό Pocket σας",
|
||||
"form.integration.pocket_consumer_key": "Pocket Consumer Key",
|
||||
"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.pinboard_token": "Διακριτικό API Pinboard",
|
||||
"form.integration.pushover_activate": "Προώθηση καταχωρήσεων στο Pushover",
|
||||
"form.integration.pushover_device": "Συσκευή Pushover (προαιρετικό)",
|
||||
"form.integration.pushover_prefix": "Πρόθεμα διεύθυνσης URL Pushover (προαιρετικό)",
|
||||
"form.integration.pushover_token": "Διακριτικό API εφαρμογής Pushover",
|
||||
"form.integration.pushover_user": "Κλειδί χρήστη Pushover",
|
||||
"form.integration.raindrop_activate": "Αποθήκευση καταχωρήσεων στο Raindrop",
|
||||
"form.integration.raindrop_collection_id": "Αναγνωριστικό συλλογής",
|
||||
"form.integration.raindrop_tags": "Ετικέτες (διαχωρισμένες με κόμμα)",
|
||||
"form.integration.raindrop_token": "Διακριτικό (Δοκιμή)",
|
||||
"form.integration.readeck_activate": "Αποθήκευση άρθρων στο Readeck",
|
||||
"form.integration.readeck_api_key": "Κλειδί API Readeck",
|
||||
"form.integration.readeck_endpoint": "Τελικό σημείο Readeck API",
|
||||
"form.integration.readeck_labels": "Readeck 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.readwise_activate": "Αποθήκευση καταχωρήσεων στο Readwise Reader",
|
||||
"form.integration.readwise_api_key": "Διακριτικό πρόσβασης Readwise Reader",
|
||||
"form.integration.readwise_api_key_link": "Λήψη του διακριτικού πρόσβασης Readwise",
|
||||
"form.integration.rssbridge_activate": "Έλεγχος RSS-Bridge κατά την προσθήκη συνδρομών",
|
||||
"form.integration.rssbridge_token": "Διακριτικό ελέγχου ταυτότητας RSS-Bridge",
|
||||
"form.integration.rssbridge_url": "Διεύθυνση URL διακομιστή RSS-Bridge",
|
||||
"form.integration.shaarli_activate": "Αποθήκευση άρθρων στο Shaarli",
|
||||
"form.integration.shaarli_api_secret": "Μυστικό API Shaarli",
|
||||
"form.integration.shaarli_endpoint": "Διεύθυνση URL Shaarli",
|
||||
"form.integration.shiori_activate": "Αποθήκευση άρθρων στο Shiori",
|
||||
"form.integration.shiori_endpoint": "Τελικό σημείο Shiori",
|
||||
"form.integration.shiori_password": "Κωδικός Πρόσβασης Shiori",
|
||||
"form.integration.shiori_username": "Όνομα Χρήστη Shiori",
|
||||
"form.integration.slack_activate": "Push entries to Slack",
|
||||
"form.integration.slack_webhook_link": "Slack Webhook link",
|
||||
"form.integration.slack_activate": "Προώθηση καταχωρήσεων στο Slack",
|
||||
"form.integration.slack_webhook_link": "Σύνδεσμος Webhook Slack",
|
||||
"form.integration.telegram_bot_activate": "Προωθήστε νέα άρθρα στη συνομιλία Telegram",
|
||||
"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": "Διακριτικό bot",
|
||||
"form.integration.telegram_chat_id": "Αναγνωριστικό συνομιλίας",
|
||||
"form.integration.telegram_topic_id": "Topic ID",
|
||||
"form.integration.telegram_topic_id": "Αναγνωριστικό θέματος",
|
||||
"form.integration.wallabag_activate": "Αποθήκευση άρθρων στο Wallabag",
|
||||
"form.integration.wallabag_client_id": "Ταυτότητα πελάτη Wallabag",
|
||||
"form.integration.wallabag_client_secret": "Wallabag Μυστικό Πελάτη",
|
||||
@@ -320,14 +333,16 @@
|
||||
"form.integration.wallabag_only_url": "Αποστολή μόνο URL (αντί για πλήρες περιεχόμενο)",
|
||||
"form.integration.wallabag_password": "Wallabag Κωδικός Πρόσβασης",
|
||||
"form.integration.wallabag_username": "Όνομα Χρήστη Wallabag",
|
||||
"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.integration.wallabag_tags": "Wallabag Tags",
|
||||
"form.integration.webhook_activate": "Ενεργοποίηση Webhooks",
|
||||
"form.integration.webhook_secret": "Μυστικό Webhooks",
|
||||
"form.integration.webhook_url": "Προεπιλεγμένη διεύθυνση URL Webhook",
|
||||
"form.prefs.fieldset.application_settings": "Ρυθμίσεις εφαρμογής",
|
||||
"form.prefs.fieldset.authentication_settings": "Ρυθμίσεις ελέγχου ταυτότητας",
|
||||
"form.prefs.fieldset.global_feed_settings": "Καθολικές ρυθμίσεις ροής",
|
||||
"form.prefs.fieldset.reader_settings": "Ρυθμίσεις αναγνώστη",
|
||||
"form.prefs.help.external_font_hosts": "Λίστα εξωτερικών κεντρικών υπολογιστών γραμματοσειρών διαχωρισμένων με κενό για να επιτρέπονται. Για παράδειγμα: \"fonts.gstatic.com fonts.googleapis.com\".",
|
||||
"form.prefs.label.always_open_external_links": "Ανάγνωση άρθρων ανοίγοντας εξωτερικούς συνδέσμους",
|
||||
"form.prefs.label.categories_sorting_order": "Ταξινόμηση κατηγοριών",
|
||||
"form.prefs.label.cjk_reading_speed": "Ταχύτητα ανάγνωσης για κινέζικα, κορεάτικα και ιαπωνικά (χαρακτήρες ανά λεπτό)",
|
||||
"form.prefs.label.custom_css": "Προσαρμοσμένο CSS",
|
||||
@@ -339,16 +354,16 @@
|
||||
"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.always_open_external_links": "Read articles by opening external links",
|
||||
"form.prefs.label.open_external_links_in_new_tab": "Άνοιγμα εξωτερικών συνδέσμων σε νέα καρτέλα (προσθέτει target=\"_blank\" στους συνδέσμους)",
|
||||
"form.prefs.label.show_reading_time": "Εμφάνιση εκτιμώμενου χρόνου ανάγνωσης για άρθρα",
|
||||
"form.prefs.label.theme": "Θέμα",
|
||||
"form.prefs.label.timezone": "Ζώνη Ώρας",
|
||||
@@ -385,7 +400,7 @@
|
||||
"menu.feeds": "Ροές",
|
||||
"menu.flush_history": "Εκκαθάριση ιστορικού",
|
||||
"menu.history": "Ιστορικό",
|
||||
"menu.home_page": "Home page",
|
||||
"menu.home_page": "Αρχική σελίδα",
|
||||
"menu.import": "Εισαγωγή",
|
||||
"menu.integrations": "Ενσωμάτωσεις",
|
||||
"menu.logout": "Αποσύνδεση",
|
||||
@@ -402,14 +417,14 @@
|
||||
"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": "Έκδοση Go:",
|
||||
"page.about.license": "Άδεια:",
|
||||
@@ -438,10 +453,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": "Κεφαλίδα ETag:",
|
||||
"page.edit_feed.last_check": "Τελευταίος έλεγχος:",
|
||||
@@ -456,9 +471,10 @@
|
||||
"%d σφάλματα"
|
||||
],
|
||||
"page.feeds.last_check": "Τελευταίος έλεγχος:",
|
||||
"page.feeds.next_check": "Next check:",
|
||||
"page.feeds.next_check": "Επόμενος έλεγχος:",
|
||||
"page.feeds.read_counter": "Αριθμός αναγνωσμένων καταχωρήσεων",
|
||||
"page.feeds.title": "Ροές",
|
||||
"page.footer.elevator": "Back to top",
|
||||
"page.history.title": "Ιστορικό",
|
||||
"page.import.title": "Εισαγωγή",
|
||||
"page.integration.bookmarklet": "Bookmarklet",
|
||||
@@ -503,8 +519,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": "Συνδεθείτε με τo Google",
|
||||
@@ -512,7 +528,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": "Παρακαλώ εισαγάγετε το όνομα χρήστη σας εάν χρησιμοποιείτε κλειδί ασφαλείας. Αυτό δεν απαιτείται εάν χρησιμοποιείτε Passkey (ανακαλύψιμα διαπιστευτήρια).",
|
||||
"page.new_api_key.title": "Νέο κλειδί API",
|
||||
"page.new_category.title": "Νέα Κατηγορία",
|
||||
"page.new_user.title": "Νέος Χρήστης",
|
||||
@@ -520,8 +536,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": "Eνέργειες",
|
||||
@@ -535,35 +551,35 @@
|
||||
"page.settings.title": "Ρυθμίσεις",
|
||||
"page.settings.unlink_google_account": "Αποσύνδεση του λογαριασμού μου Google",
|
||||
"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 κωδικούς πρόσβασης"
|
||||
"Κατάργηση %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": "Αγαπημένo",
|
||||
"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": "Eνέργειες",
|
||||
"page.users.admin.no": "Όχι",
|
||||
@@ -573,15 +589,15 @@
|
||||
"page.users.never_logged": "Ποτέ",
|
||||
"page.users.title": "Χρήστες",
|
||||
"page.users.username": "Χρήστης",
|
||||
"page.webauthn_rename.title": "Rename Passkey",
|
||||
"pagination.first": "First",
|
||||
"pagination.last": "Last",
|
||||
"page.webauthn_rename.title": "Μετονομασία κωδικού πρόσβασης",
|
||||
"pagination.first": "Πρώτο",
|
||||
"pagination.last": "Τελευταίο",
|
||||
"pagination.next": "Επόμενη",
|
||||
"pagination.previous": "Προηγούμενη",
|
||||
"search.label": "Αναζήτηση",
|
||||
"search.placeholder": "Αναζήτηση...",
|
||||
"search.submit": "Search",
|
||||
"skip_to_content": "Skip to content",
|
||||
"search.submit": "Αναζήτηση",
|
||||
"skip_to_content": "Μετάβαση στο περιεχόμενο",
|
||||
"time_elapsed.days": [
|
||||
"πριν %d ημέρα",
|
||||
"πριν %d ημέρες"
|
||||
@@ -611,4 +627,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.",
|
||||
@@ -27,7 +27,6 @@
|
||||
"alert.no_tag_entry": "There are no entries matching this tag.",
|
||||
"alert.no_unread_entry": "There are no unread entries.",
|
||||
"alert.no_user": "You are the only user.",
|
||||
"alert.pocket_linked": "Your Pocket account is now linked!",
|
||||
"alert.prefs_saved": "Preferences saved!",
|
||||
"alert.too_many_feeds_refresh": [
|
||||
"You have triggered too many feed refreshes. Please wait %d minute before trying again.",
|
||||
@@ -47,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": [
|
||||
@@ -77,6 +76,10 @@
|
||||
"entry.status.toast.read": "Marked as read",
|
||||
"entry.status.toast.unread": "Marked as unread",
|
||||
"entry.tags.label": "Tags:",
|
||||
"entry.tags.more_tags_label": [
|
||||
"Show %d more tag",
|
||||
"Show %d more tags"
|
||||
],
|
||||
"entry.unshare.label": "Unshare",
|
||||
"error.api_key_already_exists": "This API Key already exists.",
|
||||
"error.bad_credentials": "Invalid username or password.",
|
||||
@@ -86,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.",
|
||||
@@ -129,8 +133,6 @@
|
||||
"error.network_operation": "Miniflux is not able to reach this website due to a network error: %v.",
|
||||
"error.network_timeout": "This website is too slow and the request timed out: %v",
|
||||
"error.password_min_length": "The password must have at least 6 characters.",
|
||||
"error.pocket_access_token": "Unable to fetch access token from Pocket!",
|
||||
"error.pocket_request_token": "Unable to fetch request token from Pocket!",
|
||||
"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",
|
||||
@@ -168,7 +170,8 @@
|
||||
"form.feed.fieldset.rules": "Rules",
|
||||
"form.feed.label.allow_self_signed_certificates": "Allow self-signed or invalid certificates",
|
||||
"form.feed.label.apprise_service_urls": "Comma separated list of Apprise service URLs",
|
||||
"form.feed.label.blocklist_rules": "Block Rules",
|
||||
"form.feed.label.block_filter_entry_rules": "Entry Blocking Rules",
|
||||
"form.feed.label.blocklist_rules": "Regex-Based Blocking Filters",
|
||||
"form.feed.label.category": "Category",
|
||||
"form.feed.label.cookie": "Set Cookies",
|
||||
"form.feed.label.crawler": "Fetch original content",
|
||||
@@ -181,7 +184,8 @@
|
||||
"form.feed.label.fetch_via_proxy": "Use the proxy configured at the application level",
|
||||
"form.feed.label.hide_globally": "Hide entries in global unread list",
|
||||
"form.feed.label.ignore_http_cache": "Ignore HTTP cache",
|
||||
"form.feed.label.keeplist_rules": "Keep Rules",
|
||||
"form.feed.label.keep_filter_entry_rules": "Entry Allow Rules",
|
||||
"form.feed.label.keeplist_rules": "Regex-Based Keep Filters",
|
||||
"form.feed.label.no_media_player": "No media player (audio/video)",
|
||||
"form.feed.label.ntfy_activate": "Push entries to ntfy",
|
||||
"form.feed.label.ntfy_default_priority": "Ntfy default priority",
|
||||
@@ -199,7 +203,7 @@
|
||||
"form.feed.label.pushover_max_priority": "Max priority",
|
||||
"form.feed.label.pushover_min_priority": "Minimal priority",
|
||||
"form.feed.label.pushover_priority": "Pushover message priority",
|
||||
"form.feed.label.rewrite_rules": "Rewrite Rules",
|
||||
"form.feed.label.rewrite_rules": "Content Rewrite Rules",
|
||||
"form.feed.label.scraper_rules": "Scraper Rules",
|
||||
"form.feed.label.site_url": "Site URL",
|
||||
"form.feed.label.title": "Title",
|
||||
@@ -233,6 +237,9 @@
|
||||
"form.integration.instapaper_activate": "Save entries to Instapaper",
|
||||
"form.integration.instapaper_password": "Instapaper Password",
|
||||
"form.integration.instapaper_username": "Instapaper Username",
|
||||
"form.integration.karakeep_activate": "Save entries to Karakeep",
|
||||
"form.integration.karakeep_api_key": "Karakeep API key",
|
||||
"form.integration.karakeep_url": "Karakeep API Endpoint",
|
||||
"form.integration.linkace_activate": "Save entries to LinkAce",
|
||||
"form.integration.linkace_api_key": "LinkAce API key",
|
||||
"form.integration.linkace_check_disabled": "Disable link check",
|
||||
@@ -244,9 +251,19 @@
|
||||
"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 API Endpoint",
|
||||
"form.integration.linkwarden_endpoint": "Linkwarden Base URL",
|
||||
"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",
|
||||
@@ -273,10 +290,6 @@
|
||||
"form.integration.pinboard_bookmark": "Mark bookmark as unread",
|
||||
"form.integration.pinboard_tags": "Pinboard Tags",
|
||||
"form.integration.pinboard_token": "Pinboard API Token",
|
||||
"form.integration.pocket_access_token": "Pocket Access Token",
|
||||
"form.integration.pocket_activate": "Save entries to Pocket",
|
||||
"form.integration.pocket_connect_link": "Connect your Pocket account",
|
||||
"form.integration.pocket_consumer_key": "Pocket Consumer Key",
|
||||
"form.integration.pushover_activate": "Push entries to Pushover",
|
||||
"form.integration.pushover_device": "Pushover device (optional)",
|
||||
"form.integration.pushover_prefix": "Pushover URL prefix (optional)",
|
||||
@@ -320,6 +333,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",
|
||||
@@ -328,6 +342,7 @@
|
||||
"form.prefs.fieldset.global_feed_settings": "Global Feed Settings",
|
||||
"form.prefs.fieldset.reader_settings": "Reader Settings",
|
||||
"form.prefs.help.external_font_hosts": "Space separated list of external font hosts to allow. For example: \"fonts.gstatic.com fonts.googleapis.com\".",
|
||||
"form.prefs.label.always_open_external_links": "Read articles by opening external links",
|
||||
"form.prefs.label.categories_sorting_order": "Categories sorting",
|
||||
"form.prefs.label.cjk_reading_speed": "Reading speed for Chinese, Korean and Japanese (characters per minute)",
|
||||
"form.prefs.label.custom_css": "Custom CSS",
|
||||
@@ -348,7 +363,7 @@
|
||||
"form.prefs.label.mark_read_on_view": "Automatically mark entries as read when viewed",
|
||||
"form.prefs.label.mark_read_on_view_or_media_completion": "Mark entries as read when viewed. For audio/video, mark as read at 90%% completion",
|
||||
"form.prefs.label.media_playback_rate": "Playback speed of the audio/video",
|
||||
"form.prefs.label.always_open_external_links": "Read articles by opening external links",
|
||||
"form.prefs.label.open_external_links_in_new_tab": "Open external links in a new tab (adds target=\"_blank\" to links)",
|
||||
"form.prefs.label.show_reading_time": "Show estimated reading time for entries",
|
||||
"form.prefs.label.theme": "Theme",
|
||||
"form.prefs.label.timezone": "Timezone",
|
||||
@@ -459,6 +474,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",
|
||||
@@ -503,7 +519,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",
|
||||
|
||||
@@ -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.",
|
||||
@@ -27,7 +27,6 @@
|
||||
"alert.no_tag_entry": "No hay artículos con esta etiqueta.",
|
||||
"alert.no_unread_entry": "No hay artículos sin leer.",
|
||||
"alert.no_user": "Eres el único usuario.",
|
||||
"alert.pocket_linked": "¡Tu cuenta de Pocket ya está vinculada!",
|
||||
"alert.prefs_saved": "¡Las preferencias se han guardado!",
|
||||
"alert.too_many_feeds_refresh": [
|
||||
"Has activado demasiadas actualizaciones del feed. Espere %d minuto antes de volver a intentarlo.",
|
||||
@@ -47,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": [
|
||||
@@ -77,6 +76,10 @@
|
||||
"entry.status.toast.read": "Marcado como leído",
|
||||
"entry.status.toast.unread": "Marcado como no leído",
|
||||
"entry.tags.label": "Etiquetas:",
|
||||
"entry.tags.more_tags_label": [
|
||||
"Mostrar %d etiqueta más",
|
||||
"Mostrar %d etiquetas más"
|
||||
],
|
||||
"entry.unshare.label": "No compartir",
|
||||
"error.api_key_already_exists": "Esta clave API ya existe.",
|
||||
"error.bad_credentials": "Usuario o contraseña no válido.",
|
||||
@@ -119,7 +122,7 @@
|
||||
"error.invalid_display_mode": "Modo de visualización de la aplicación web no válido.",
|
||||
"error.invalid_entry_direction": "Dirección de artículo no válida.",
|
||||
"error.invalid_entry_order": "Orden de artículo no válido.",
|
||||
"error.invalid_feed_proxy_url": "Invalid proxy URL.",
|
||||
"error.invalid_feed_proxy_url": "URL de proxy inválida.",
|
||||
"error.invalid_feed_url": "URL de feed no válida.",
|
||||
"error.invalid_gesture_nav": "Navegación por gestos no válida.",
|
||||
"error.invalid_language": "Idioma no válido.",
|
||||
@@ -129,14 +132,12 @@
|
||||
"error.network_operation": "Miniflux no puede acceder a este sitio web debido a un error de red: %v.",
|
||||
"error.network_timeout": "Este sitio web es demasiado lento y se agotó el tiempo de espera de la solicitud: %v",
|
||||
"error.password_min_length": "La contraseña debería tener al menos 6 caracteres.",
|
||||
"error.pocket_access_token": "Incapaz de obtener un token de acceso de Pocket!",
|
||||
"error.pocket_request_token": "Incapaz de obtener un token de solicitud de Pocket!",
|
||||
"error.proxy_url_not_empty": "The proxy URL cannot be empty.",
|
||||
"error.proxy_url_not_empty": "La URL del proxy no puede estar vacía.",
|
||||
"error.settings_block_rule_fieldname_invalid": "Regla de bloqueo no válida: a la regla #%d le falta un nombre de campo válido (Opciones: %s)",
|
||||
"error.settings_block_rule_invalid_regex": "Regla de bloqueo no válida: el patrón de la regla #%d no es una expresión regular válida",
|
||||
"error.settings_block_rule_regex_required": "Regla de bloqueo no válida: no se ha proporcionado el patrón de la regla #%d",
|
||||
"error.settings_block_rule_separator_required": "Regla de bloqueo no válida: el patrón de la regla #%d debe estar separado por un '='",
|
||||
"error.settings_invalid_domain_list": "Invalid domain list. Please provide a space separated list of domains.",
|
||||
"error.settings_invalid_domain_list": "Lista de dominios inválida. Por favor proporcione una lista de dominios separados por espacios.",
|
||||
"error.settings_keep_rule_fieldname_invalid": "Regla de mantenimiento no válida: a la regla #%d le falta un nombre de campo válido (Opciones: %s)",
|
||||
"error.settings_keep_rule_invalid_regex": "Regla de mantenimiento no válida: el patrón de la regla #%d no es una expresión regular válida",
|
||||
"error.settings_keep_rule_regex_required": "Regla de conservación no válida: no se ha proporcionado la regla #%d patrón",
|
||||
@@ -159,6 +160,7 @@
|
||||
"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",
|
||||
@@ -168,7 +170,8 @@
|
||||
"form.feed.fieldset.rules": "Reglas",
|
||||
"form.feed.label.allow_self_signed_certificates": "Permitir certificados autofirmados o no válidos",
|
||||
"form.feed.label.apprise_service_urls": "Lista separada por comas de las URL del servicio Apprise",
|
||||
"form.feed.label.blocklist_rules": "Reglas de Filtrado (Bloquear)",
|
||||
"form.feed.label.block_filter_entry_rules": "Reglas de Bloqueo de Entradas",
|
||||
"form.feed.label.blocklist_rules": "Filtros de Bloqueo Basados en Regex",
|
||||
"form.feed.label.category": "Categoría",
|
||||
"form.feed.label.cookie": "Configurar las cookies",
|
||||
"form.feed.label.crawler": "Obtener rastreador original",
|
||||
@@ -181,7 +184,8 @@
|
||||
"form.feed.label.fetch_via_proxy": "Usar el proxy configurado a nivel de la aplicación",
|
||||
"form.feed.label.hide_globally": "Ocultar artículos en la lista global de no leídos",
|
||||
"form.feed.label.ignore_http_cache": "Ignorar caché HTTP",
|
||||
"form.feed.label.keeplist_rules": "Reglas de Filtrado (Permitir)",
|
||||
"form.feed.label.keep_filter_entry_rules": "Reglas de Permitir Entradas",
|
||||
"form.feed.label.keeplist_rules": "Filtros de Mantener Basados en Regex",
|
||||
"form.feed.label.no_media_player": "Sin reproductor multimedia (audio/video)",
|
||||
"form.feed.label.ntfy_activate": "Enviar entradas a ntfy",
|
||||
"form.feed.label.ntfy_default_priority": "Prioridad predeterminada a Ntfy",
|
||||
@@ -191,21 +195,21 @@
|
||||
"form.feed.label.ntfy_min_priority": "Prioridad mínima a Ntfy",
|
||||
"form.feed.label.ntfy_priority": "Prioridad Ntfy",
|
||||
"form.feed.label.ntfy_topic": "Tema Ntfy (opcional)",
|
||||
"form.feed.label.proxy_url": "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.rewrite_rules": "Reglas de reescribir",
|
||||
"form.feed.label.proxy_url": "URL del Proxy",
|
||||
"form.feed.label.pushover_activate": "Enviar artículos a pushover.net",
|
||||
"form.feed.label.pushover_default_priority": "Prioridad predeterminada de Pushover",
|
||||
"form.feed.label.pushover_high_priority": "Prioridad alta de Pushover",
|
||||
"form.feed.label.pushover_low_priority": "Prioridad baja de Pushover",
|
||||
"form.feed.label.pushover_max_priority": "Prioridad máxima de Pushover",
|
||||
"form.feed.label.pushover_min_priority": "Prioridad mínima de Pushover",
|
||||
"form.feed.label.pushover_priority": "Prioridad del mensaje de Pushover",
|
||||
"form.feed.label.rewrite_rules": "Reglas de Reescritura de Contenido",
|
||||
"form.feed.label.scraper_rules": "Reglas de extracción de información",
|
||||
"form.feed.label.site_url": "URL del sitio",
|
||||
"form.feed.label.title": "Título",
|
||||
"form.feed.label.urlrewrite_rules": "Reglas de Filtrado (Reescritura)",
|
||||
"form.feed.label.user_agent": "Invalidar el agente de usuario predeterminado",
|
||||
"form.feed.label.webhook_url": "Override webhook url",
|
||||
"form.feed.label.webhook_url": "Invalidar la URL del webhook",
|
||||
"form.import.label.file": "Archivo OPML",
|
||||
"form.import.label.url": "URL",
|
||||
"form.integration.apprise_activate": "Enviar artículos a Apprise",
|
||||
@@ -214,8 +218,8 @@
|
||||
"form.integration.betula_activate": "Guardar artículos en Betula",
|
||||
"form.integration.betula_token": "Token de Betula",
|
||||
"form.integration.betula_url": "URL del servidor Betula",
|
||||
"form.integration.cubox_activate": "Save entries to Cubox",
|
||||
"form.integration.cubox_api_link": "Cubox API link",
|
||||
"form.integration.cubox_activate": "Guardar artículos en Cubox",
|
||||
"form.integration.cubox_api_link": "Enlace de la API de Cubox",
|
||||
"form.integration.discord_activate": "Enviar artículos a Discord",
|
||||
"form.integration.discord_webhook_link": "URL de la Webhook de Discord",
|
||||
"form.integration.espial_activate": "Enviar artículos a Espial",
|
||||
@@ -233,6 +237,9 @@
|
||||
"form.integration.instapaper_activate": "Enviar artículos a Instapaper",
|
||||
"form.integration.instapaper_password": "Contraseña de Instapaper",
|
||||
"form.integration.instapaper_username": "Nombre de usuario de Instapaper",
|
||||
"form.integration.karakeep_activate": "Enviar artículos a Karakeep",
|
||||
"form.integration.karakeep_api_key": "Clave de API de Karakeep",
|
||||
"form.integration.karakeep_url": "Acceso API de Karakeep",
|
||||
"form.integration.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",
|
||||
@@ -244,9 +251,19 @@
|
||||
"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": "LinkTaco API Token",
|
||||
"form.integration.linktaco_api_token_hint": "Obtenga su token de acceso personal en",
|
||||
"form.integration.linktaco_org_slug": "Organization Slug",
|
||||
"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": "Acceso API de Linkwarden",
|
||||
"form.integration.linkwarden_endpoint": "URL base 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",
|
||||
@@ -273,15 +290,11 @@
|
||||
"form.integration.pinboard_bookmark": "Marcar marcador como no leído",
|
||||
"form.integration.pinboard_tags": "Etiquetas de Pinboard",
|
||||
"form.integration.pinboard_token": "Token de API de Pinboard",
|
||||
"form.integration.pocket_access_token": "Token de acceso de Pocket",
|
||||
"form.integration.pocket_activate": "Enviar artículos a Pocket",
|
||||
"form.integration.pocket_connect_link": "Conectar a la cuenta de Pocket",
|
||||
"form.integration.pocket_consumer_key": "Clave del consumidor de Pocket",
|
||||
"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.pushover_activate": "Enviar artículos a Pushover",
|
||||
"form.integration.pushover_device": "Dispositivo Pushover (opcional)",
|
||||
"form.integration.pushover_prefix": "Prefijo de URL de Pushover (opcional)",
|
||||
"form.integration.pushover_token": "Token de API de la aplicación Pushover",
|
||||
"form.integration.pushover_user": "Clave de usuario de Pushover",
|
||||
"form.integration.raindrop_activate": "Guardar artículos en Raindrop",
|
||||
"form.integration.raindrop_collection_id": "Colección ID",
|
||||
"form.integration.raindrop_tags": "Etiquetas (separadas por comas)",
|
||||
@@ -320,6 +333,7 @@
|
||||
"form.integration.wallabag_only_url": "Enviar solo URL (en lugar de contenido completo)",
|
||||
"form.integration.wallabag_password": "Contraseña de Wallabag",
|
||||
"form.integration.wallabag_username": "Nombre de usuario de Wallabag",
|
||||
"form.integration.wallabag_tags": "Wallabag Tags",
|
||||
"form.integration.webhook_activate": "Habilitar Webhooks",
|
||||
"form.integration.webhook_secret": "Secreto de Webhooks",
|
||||
"form.integration.webhook_url": "Defecto URL de Webhook",
|
||||
@@ -327,7 +341,8 @@
|
||||
"form.prefs.fieldset.authentication_settings": "Ajustes de la autentificación",
|
||||
"form.prefs.fieldset.global_feed_settings": "Ajustes globales del feed",
|
||||
"form.prefs.fieldset.reader_settings": "Ajustes del lector",
|
||||
"form.prefs.help.external_font_hosts": "Space separated list of external font hosts to allow. For example: \"fonts.gstatic.com fonts.googleapis.com\".",
|
||||
"form.prefs.help.external_font_hosts": "Lista separada por espacios de hosts de fuentes externas permitidos. Por ejemplo: \"fonts.gstatic.com fonts.googleapis.com\".",
|
||||
"form.prefs.label.always_open_external_links": "Leer artículos abriendo enlaces externos",
|
||||
"form.prefs.label.categories_sorting_order": "Clasificación por categorías",
|
||||
"form.prefs.label.cjk_reading_speed": "Velocidad de lectura en chino, coreano y japonés (caracteres por minuto)",
|
||||
"form.prefs.label.custom_css": "CSS personalizado",
|
||||
@@ -339,7 +354,7 @@
|
||||
"form.prefs.label.entry_order": "Columna de clasificación de artículos",
|
||||
"form.prefs.label.entry_sorting": "Clasificación de artículos",
|
||||
"form.prefs.label.entry_swipe": "Habilitar deslizamiento de entrada en pantallas táctiles",
|
||||
"form.prefs.label.external_font_hosts": "External font hosts",
|
||||
"form.prefs.label.external_font_hosts": "Hosts de fuentes externas",
|
||||
"form.prefs.label.gesture_nav": "Gesto para navegar entre entradas",
|
||||
"form.prefs.label.keyboard_shortcuts": "Habilitar atajos de teclado",
|
||||
"form.prefs.label.language": "Idioma",
|
||||
@@ -348,7 +363,7 @@
|
||||
"form.prefs.label.mark_read_on_view": "Marcar automáticamente las entradas como leídas cuando se vean",
|
||||
"form.prefs.label.mark_read_on_view_or_media_completion": "Marcar las entradas como leídas cuando se vean. Para audio/video, marcar como leído al 90%% de finalización",
|
||||
"form.prefs.label.media_playback_rate": "Velocidad de reproducción del audio/vídeo",
|
||||
"form.prefs.label.always_open_external_links": "Read articles by opening external links",
|
||||
"form.prefs.label.open_external_links_in_new_tab": "Abrir enlaces externos en una nueva pestaña (agrega target=\"_blank\" a los enlaces)",
|
||||
"form.prefs.label.show_reading_time": "Mostrar el tiempo estimado de lectura de los artículos",
|
||||
"form.prefs.label.theme": "Tema",
|
||||
"form.prefs.label.timezone": "Zona horaria",
|
||||
@@ -408,7 +423,7 @@
|
||||
"page.about.author": "Autor:",
|
||||
"page.about.build_date": "Fecha de compilación:",
|
||||
"page.about.credits": "Créditos",
|
||||
"page.about.db_usage": "Database size:",
|
||||
"page.about.db_usage": "Tamaño de la base de datos:",
|
||||
"page.about.git_commit": "Git Commit:",
|
||||
"page.about.global_config_options": "Opciones de configuración global",
|
||||
"page.about.go_version": "Go versión:",
|
||||
@@ -459,6 +474,7 @@
|
||||
"page.feeds.next_check": "Próxima verificación:",
|
||||
"page.feeds.read_counter": "Número de artículos leídos",
|
||||
"page.feeds.title": "Fuentes",
|
||||
"page.footer.elevator": "Back to top",
|
||||
"page.history.title": "Historial",
|
||||
"page.import.title": "Importar",
|
||||
"page.integration.bookmarklet": "Marcapáginas",
|
||||
@@ -472,7 +488,7 @@
|
||||
"page.integration.miniflux_api_username": "Nombre de usuario",
|
||||
"page.integrations.title": "Integraciones",
|
||||
"page.keyboard_shortcuts.close_modal": "Cerrar el cuadro de diálogo modal",
|
||||
"page.keyboard_shortcuts.download_content": "Descargar el contento original",
|
||||
"page.keyboard_shortcuts.download_content": "Descargar el contenido original",
|
||||
"page.keyboard_shortcuts.go_to_bottom_item": "Ir al elemento inferior",
|
||||
"page.keyboard_shortcuts.go_to_categories": "Ir a las categorías",
|
||||
"page.keyboard_shortcuts.go_to_feed": "Ir a la fuente",
|
||||
@@ -503,7 +519,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",
|
||||
@@ -512,7 +528,7 @@
|
||||
"page.login.title": "Iniciar sesión",
|
||||
"page.login.webauthn_login": "Iniciar sesión con clave de acceso",
|
||||
"page.login.webauthn_login.error": "No se puede iniciar sesión con la clave de acceso",
|
||||
"page.login.webauthn_login.help": "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": "Por favor, introduce tu nombre de usuario si usas una clave de seguridad. Esto no es necesario si usas una Passkey (credenciales detectables).",
|
||||
"page.new_api_key.title": "Nueva clave API",
|
||||
"page.new_category.title": "Nueva categoría",
|
||||
"page.new_user.title": "Nuevo usuario",
|
||||
@@ -535,7 +551,7 @@
|
||||
"page.settings.title": "Ajustes",
|
||||
"page.settings.unlink_google_account": "Desvincular mi cuenta de Google",
|
||||
"page.settings.unlink_oidc_account": "Desvincular mi cuenta de %s",
|
||||
"page.settings.webauthn.actions": "Accioness",
|
||||
"page.settings.webauthn.actions": "Acciones",
|
||||
"page.settings.webauthn.added_on": "Añadido",
|
||||
"page.settings.webauthn.delete": [
|
||||
"Eliminar %d clave de acceso",
|
||||
@@ -611,4 +627,4 @@
|
||||
"time_elapsed.yesterday": "ayer",
|
||||
"tooltip.keyboard_shortcuts": "Atajo de teclado: %s",
|
||||
"tooltip.logged_user": "Registrado como %s"
|
||||
}
|
||||
}
|
||||
@@ -13,9 +13,9 @@
|
||||
"action.update": "Päivitä",
|
||||
"alert.account_linked": "Ulkoinen tilisi on nyt linkitetty!",
|
||||
"alert.account_unlinked": "Ulkoinen tilisi on nyt irrotettu!",
|
||||
"alert.background_feed_refresh": "All feeds are being refreshed in the background. You can continue to use Miniflux while this process is running.",
|
||||
"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.",
|
||||
@@ -27,30 +27,29 @@
|
||||
"alert.no_tag_entry": "Tätä tunnistetta vastaavia merkintöjä ei ole.",
|
||||
"alert.no_unread_entry": "Ei ole lukemattomia artikkeleita.",
|
||||
"alert.no_user": "Olet ainoa käyttäjä.",
|
||||
"alert.pocket_linked": "Pocket-tilisi on nyt linkitetty!",
|
||||
"alert.prefs_saved": "Asetukset tallennettu!",
|
||||
"alert.too_many_feeds_refresh": [
|
||||
"You have triggered too many feed refreshes. Please wait %d minute before trying again.",
|
||||
"You have triggered too many feed refreshes. Please wait %d minutes before trying again."
|
||||
"Olet käynnistänyt liian monta syötteen päivitystä. Odota %d minuutti ennen kuin yrität uudelleen.",
|
||||
"Olet käynnistänyt liian monta syötteen päivitystä. Odota %d minuuttia ennen kuin yrität uudelleen."
|
||||
],
|
||||
"confirm.loading": "Käynnissä...",
|
||||
"confirm.no": "ei",
|
||||
"confirm.question": "Oletko varma?",
|
||||
"confirm.question.refresh": "Haluatko pakottaa päivityksen?",
|
||||
"confirm.yes": "kyllä",
|
||||
"enclosure_media_controls.seek": "Seek:",
|
||||
"enclosure_media_controls.seek.title": "Seek %s seconds",
|
||||
"enclosure_media_controls.speed": "Speed:",
|
||||
"enclosure_media_controls.speed.faster": "Faster",
|
||||
"enclosure_media_controls.speed.faster.title": "Faster by %sx",
|
||||
"enclosure_media_controls.speed.reset": "Reset",
|
||||
"enclosure_media_controls.speed.reset.title": "Reset speed to 1x",
|
||||
"enclosure_media_controls.speed.slower": "Slower",
|
||||
"enclosure_media_controls.speed.slower.title": "Slower by %sx",
|
||||
"entry.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",
|
||||
"enclosure_media_controls.seek": "Siirry:",
|
||||
"enclosure_media_controls.seek.title": "Siirry %s sekuntia",
|
||||
"enclosure_media_controls.speed": "Nopeus:",
|
||||
"enclosure_media_controls.speed.faster": "Nopeammin",
|
||||
"enclosure_media_controls.speed.faster.title": "Nopeampi %sx",
|
||||
"enclosure_media_controls.speed.reset": "Palauta",
|
||||
"enclosure_media_controls.speed.reset.title": "Palauta nopeus 1x",
|
||||
"enclosure_media_controls.speed.slower": "Hitaammin",
|
||||
"enclosure_media_controls.speed.slower.title": "Hitaampi %sx",
|
||||
"entry.starred.toast.off": "Tähdettömät",
|
||||
"entry.starred.toast.on": "Tähdellä merkityt",
|
||||
"entry.starred.toggle.off": "Poista suosikeista",
|
||||
"entry.starred.toggle.on": "Lisää suosikkeihin",
|
||||
"entry.comments.label": "Kommentit",
|
||||
"entry.comments.title": "Näytä kommentit",
|
||||
"entry.estimated_reading_time": [
|
||||
@@ -76,31 +75,35 @@
|
||||
"entry.status.title": "Vaihda artikkelin tilaa",
|
||||
"entry.status.toast.read": "Merkitty luetuksi",
|
||||
"entry.status.toast.unread": "Merkitty lukemattomaksi",
|
||||
"entry.tags.label": "Tags:",
|
||||
"entry.tags.label": "Tunnisteet:",
|
||||
"entry.tags.more_tags_label": [
|
||||
"Näytä %d lisää tunnistetta",
|
||||
"Näytä %d lisää tunnisteita"
|
||||
],
|
||||
"entry.unshare.label": "Poista jako",
|
||||
"error.api_key_already_exists": "API-avain on jo olemassa.",
|
||||
"error.bad_credentials": "Virheellinen käyttäjänimi tai salasana.",
|
||||
"error.category_already_exists": "Kategoria on jo olemassa. ",
|
||||
"error.category_not_found": "This category does not exist or does not belong to this user.",
|
||||
"error.database_error": "Database error: %v.",
|
||||
"error.category_not_found": "Tämä kategoria ei ole olemassa tai se ei kuulu tälle käyttäjälle.",
|
||||
"error.database_error": "Tietokantavirhe: %v.",
|
||||
"error.different_passwords": "Salasanat eivät ole samat.",
|
||||
"error.duplicate_fever_username": "There is already someone else with the same Fever username!",
|
||||
"error.duplicate_fever_username": "Joku muu käyttää jo samaa Fever-käyttäjänimeä!",
|
||||
"error.duplicate_googlereader_username": "On jo joku muu, jolla on sama Google-syötteenlukijan käyttäjätunnus!",
|
||||
"error.duplicate_linked_account": "There is already someone associated with this provider!",
|
||||
"error.duplicated_feed": "This feed already exists.",
|
||||
"error.duplicate_linked_account": "Joku on jo yhdistetty tähän palveluntarjoajaan!",
|
||||
"error.duplicated_feed": "Tämä syöte on jo olemassa.",
|
||||
"error.empty_file": "Tiedosto on tyhjä.",
|
||||
"error.entries_per_page_invalid": "Artikkelien määrä sivulla ei kelpaa.",
|
||||
"error.feed_already_exists": "Tämä syöte on jo olemassa.",
|
||||
"error.feed_category_not_found": "Tätä kategoriaa ei ole olemassa tai se ei kuulu tälle käyttäjälle.",
|
||||
"error.feed_format_not_detected": "Unable to detect feed format: %v.",
|
||||
"error.feed_invalid_blocklist_rule": "The block list rule is invalid.",
|
||||
"error.feed_invalid_keeplist_rule": "The keep list rule is invalid.",
|
||||
"error.feed_format_not_detected": "Syötteen muotoa ei voitu tunnistaa: %v.",
|
||||
"error.feed_invalid_blocklist_rule": "Estolistan sääntö on virheellinen.",
|
||||
"error.feed_invalid_keeplist_rule": "Säilytettävien listan sääntö on virheellinen.",
|
||||
"error.feed_mandatory_fields": "URL-osoite ja kategoria ovat pakollisia.",
|
||||
"error.feed_not_found": "This feed does not exist or does not belong to this user.",
|
||||
"error.feed_not_found": "Tämä syöte ei ole olemassa tai se ei kuulu tälle käyttäjälle.",
|
||||
"error.feed_title_not_empty": "Syötteen otsikko ei voi olla tyhjä.",
|
||||
"error.feed_url_not_empty": "Syötteen URL-osoite ei voi olla tyhjä.",
|
||||
"error.fields_mandatory": "Kaikki kentät ovat pakollisia.",
|
||||
"error.http_bad_gateway": "The website is not available at the moment due to a bad gateway error. The problem is not on Miniflux side. Please, try again later.",
|
||||
"error.http_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?",
|
||||
@@ -129,8 +132,6 @@
|
||||
"error.network_operation": "Miniflux is not able to reach this website due to a network error: %v.",
|
||||
"error.network_timeout": "This website is too slow and the request timed out: %v",
|
||||
"error.password_min_length": "Salasanassa on oltava vähintään 6 merkkiä.",
|
||||
"error.pocket_access_token": "Unable to fetch access token from Pocket!",
|
||||
"error.pocket_request_token": "Unable to fetch request token from Pocket!",
|
||||
"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",
|
||||
@@ -159,6 +160,7 @@
|
||||
"error.unlink_account_without_password": "Sinun on määritettävä salasana, muuten et voi kirjautua uudelleen.",
|
||||
"error.user_already_exists": "Käyttäjä on jo olemassa.",
|
||||
"error.user_mandatory_fields": "Käyttäjätunnus on pakollinen.",
|
||||
"error.linktaco_missing_required_fields": "LinkTaco API Token ja Organization Slug vaaditaan",
|
||||
"form.api_key.label.description": "API Key Label",
|
||||
"form.category.hide_globally": "Piilota artikkelit lukemattomien listassa",
|
||||
"form.category.label.title": "Otsikko",
|
||||
@@ -168,7 +170,8 @@
|
||||
"form.feed.fieldset.rules": "Rules",
|
||||
"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.blocklist_rules": "Block-säännöt",
|
||||
"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ö",
|
||||
@@ -181,7 +184,8 @@
|
||||
"form.feed.label.fetch_via_proxy": "Käytä sovellustasolla määritettyä välityspalvelinta",
|
||||
"form.feed.label.hide_globally": "Piilota artikkelit lukemattomien listassa",
|
||||
"form.feed.label.ignore_http_cache": "Ohita HTTP-välimuisti",
|
||||
"form.feed.label.keeplist_rules": "Keep-säännöt",
|
||||
"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",
|
||||
@@ -199,7 +203,7 @@
|
||||
"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.rewrite_rules": "Rewrite-säännöt",
|
||||
"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",
|
||||
@@ -233,6 +237,9 @@
|
||||
"form.integration.instapaper_activate": "Tallenna artikkelit Instapaperiin",
|
||||
"form.integration.instapaper_password": "Instapaper-salasana",
|
||||
"form.integration.instapaper_username": "Instapaper-käyttäjätunnus",
|
||||
"form.integration.karakeep_activate": "Tallenna artikkelit Karakeepiin",
|
||||
"form.integration.karakeep_api_key": "Karakeep API-avain",
|
||||
"form.integration.karakeep_url": "Karakeep API-päätepiste",
|
||||
"form.integration.linkace_activate": "Save entries to LinkAce",
|
||||
"form.integration.linkace_api_key": "LinkAce API key",
|
||||
"form.integration.linkace_check_disabled": "Disable link check",
|
||||
@@ -244,9 +251,19 @@
|
||||
"form.integration.linkding_bookmark": "Merkitse kirjanmerkki lukemattomaksi",
|
||||
"form.integration.linkding_endpoint": "Linkding API-päätepiste",
|
||||
"form.integration.linkding_tags": "Linkding Tags",
|
||||
"form.integration.linktaco_activate": "Tallenna kirjoituksia LinkTacoon",
|
||||
"form.integration.linktaco_api_token": "LinkTaco API Token",
|
||||
"form.integration.linktaco_api_token_hint": "Hanki henkilökohtainen pääsytunnistesi osoitteesta",
|
||||
"form.integration.linktaco_org_slug": "Organization Slug",
|
||||
"form.integration.linktaco_tags": "Tagit (enintään 10, pilkuilla erotettu)",
|
||||
"form.integration.linktaco_tags_hint": "Enintään 10 tagia, pilkuilla erotettu",
|
||||
"form.integration.linktaco_visibility": "Näkyvyys",
|
||||
"form.integration.linktaco_visibility_public": "Julkinen",
|
||||
"form.integration.linktaco_visibility_private": "Yksityinen",
|
||||
"form.integration.linktaco_visibility_hint": "YKSITYINEN näkyvyys vaatii maksullisen LinkTaco-tilin",
|
||||
"form.integration.linkwarden_activate": "Tallenna artikkelit Linkkiin",
|
||||
"form.integration.linkwarden_api_key": "Linkwarden API-avain",
|
||||
"form.integration.linkwarden_endpoint": "Linkwarden API-päätepiste",
|
||||
"form.integration.linkwarden_endpoint": "Linkwarden Base URL",
|
||||
"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",
|
||||
@@ -273,10 +290,6 @@
|
||||
"form.integration.pinboard_bookmark": "Merkitse kirjanmerkki lukemattomaksi",
|
||||
"form.integration.pinboard_tags": "Pinboard-tagit",
|
||||
"form.integration.pinboard_token": "Pinboard API-tunnus",
|
||||
"form.integration.pocket_access_token": "Pocket-käyttöoikeustunnus",
|
||||
"form.integration.pocket_activate": "Tallenna artikkelit Pocketiin",
|
||||
"form.integration.pocket_connect_link": "Yhdistä Pocket-tilisi",
|
||||
"form.integration.pocket_consumer_key": "Pocket Consumer Key",
|
||||
"form.integration.pushover_activate": "Push entries to Pushover",
|
||||
"form.integration.pushover_device": "Pushover device (optional)",
|
||||
"form.integration.pushover_prefix": "Pushover URL prefix (optional)",
|
||||
@@ -320,6 +333,7 @@
|
||||
"form.integration.wallabag_only_url": "Lähetä vain URL-osoite (koko sisällön sijaan)",
|
||||
"form.integration.wallabag_password": "Wallabag-salasana",
|
||||
"form.integration.wallabag_username": "Wallabag-käyttäjätunnus",
|
||||
"form.integration.wallabag_tags": "Wallabag Tags",
|
||||
"form.integration.webhook_activate": "Enable Webhooks",
|
||||
"form.integration.webhook_secret": "Webhooks Secret",
|
||||
"form.integration.webhook_url": "Default Webhook URL",
|
||||
@@ -328,6 +342,7 @@
|
||||
"form.prefs.fieldset.global_feed_settings": "Global Feed Settings",
|
||||
"form.prefs.fieldset.reader_settings": "Reader Settings",
|
||||
"form.prefs.help.external_font_hosts": "Space separated list of external font hosts to allow. For example: \"fonts.gstatic.com fonts.googleapis.com\".",
|
||||
"form.prefs.label.always_open_external_links": "Read articles by opening external links",
|
||||
"form.prefs.label.categories_sorting_order": "Kategorioiden lajittelu",
|
||||
"form.prefs.label.cjk_reading_speed": "Kiinan, Korean ja Japanin lukunopeus (merkkejä minuutissa)",
|
||||
"form.prefs.label.custom_css": "Mukautettu CSS",
|
||||
@@ -348,7 +363,7 @@
|
||||
"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.media_playback_rate": "Äänen/videon toistonopeus",
|
||||
"form.prefs.label.always_open_external_links": "Read articles by opening external links",
|
||||
"form.prefs.label.open_external_links_in_new_tab": "Avaa ulkoiset linkit uuteen välilehteen (lisää target=\"_blank\" linkkeihin)",
|
||||
"form.prefs.label.show_reading_time": "Näytä artikkeleiden arvioitu lukuaika",
|
||||
"form.prefs.label.theme": "Teema",
|
||||
"form.prefs.label.timezone": "Aikavyöhyke",
|
||||
@@ -385,7 +400,7 @@
|
||||
"menu.feeds": "Syötteet",
|
||||
"menu.flush_history": "Tyhjennä historia",
|
||||
"menu.history": "Historia",
|
||||
"menu.home_page": "Home page",
|
||||
"menu.home_page": "Etusivu",
|
||||
"menu.import": "Tuo",
|
||||
"menu.integrations": "Integraatiot",
|
||||
"menu.logout": "Kirjaudu ulos",
|
||||
@@ -399,7 +414,7 @@
|
||||
"menu.settings": "Asetukset",
|
||||
"menu.shared_entries": "Jaetut artikkelit",
|
||||
"menu.show_all_entries": "Näytä kaikki artikkelit",
|
||||
"menu.show_only_starred_entries": "Show only starred entries",
|
||||
"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",
|
||||
@@ -459,6 +474,7 @@
|
||||
"page.feeds.next_check": "Next check:",
|
||||
"page.feeds.read_counter": "Luettujen artikkeleiden määrä",
|
||||
"page.feeds.title": "Syötteet",
|
||||
"page.footer.elevator": "Back to top",
|
||||
"page.history.title": "Historia",
|
||||
"page.import.title": "Tuo",
|
||||
"page.integration.bookmarklet": "Sovelluskirjanmerkki",
|
||||
@@ -503,7 +519,7 @@
|
||||
"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_star_status": "Vaihda kirjanmerkki",
|
||||
"page.keyboard_shortcuts.toggle_entry_attachments": "Toggle open/close entry attachments",
|
||||
"page.keyboard_shortcuts.toggle_read_status_next": "Vaihda luettu/lukematon, keskity seuraavaksi",
|
||||
"page.keyboard_shortcuts.toggle_read_status_prev": "Vaihda luettu/lukematon, keskity edelliseen",
|
||||
@@ -574,14 +590,14 @@
|
||||
"page.users.title": "Käyttäjät",
|
||||
"page.users.username": "Käyttäjätunnus",
|
||||
"page.webauthn_rename.title": "Rename Passkey",
|
||||
"pagination.first": "First",
|
||||
"pagination.last": "Last",
|
||||
"pagination.first": "Ensimmäinen",
|
||||
"pagination.last": "Viimeinen",
|
||||
"pagination.next": "Seuraava",
|
||||
"pagination.previous": "Edellinen",
|
||||
"search.label": "Haku",
|
||||
"search.placeholder": "Hae...",
|
||||
"search.submit": "Search",
|
||||
"skip_to_content": "Skip to content",
|
||||
"search.submit": "Hae",
|
||||
"skip_to_content": "Siirry sisältöön",
|
||||
"time_elapsed.days": [
|
||||
"%d päivä sitten",
|
||||
"%d päivää sitten"
|
||||
@@ -611,4 +627,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.",
|
||||
@@ -27,7 +27,6 @@
|
||||
"alert.no_tag_entry": "Il n'y a aucun article correspondant à ce tag.",
|
||||
"alert.no_unread_entry": "Il n'y a rien de nouveau à lire.",
|
||||
"alert.no_user": "Vous êtes le seul utilisateur.",
|
||||
"alert.pocket_linked": "Votre compte Pocket est maintenant connecté !",
|
||||
"alert.prefs_saved": "Préférences sauvegardées !",
|
||||
"alert.too_many_feeds_refresh": [
|
||||
"Vous avez déclenché trop d'actualisations de flux. Veuillez attendre %d minute avant de réessayer.",
|
||||
@@ -47,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": [
|
||||
@@ -77,6 +76,10 @@
|
||||
"entry.status.toast.read": "Marqué comme lu",
|
||||
"entry.status.toast.unread": "Marqué comme non lu",
|
||||
"entry.tags.label": "Libellés :",
|
||||
"entry.tags.more_tags_label": [
|
||||
"Afficher %d libellé supplémentaire",
|
||||
"Afficher %d libellés supplémentaires"
|
||||
],
|
||||
"entry.unshare.label": "Enlever le partage",
|
||||
"error.api_key_already_exists": "Cette clé d'API existe déjà.",
|
||||
"error.bad_credentials": "Mauvais identifiant ou mot de passe.",
|
||||
@@ -129,8 +132,6 @@
|
||||
"error.network_operation": "Miniflux n'est pas en mesure de se connecter à ce site web à cause d'un problème réseau : %v.",
|
||||
"error.network_timeout": "Ce site web est trop lent à répondre : %v.",
|
||||
"error.password_min_length": "Vous devez utiliser au moins 6 caractères pour le mot de passe.",
|
||||
"error.pocket_access_token": "Impossible de récupérer le jeton d'accès depuis Pocket !",
|
||||
"error.pocket_request_token": "Impossible de récupérer le jeton d'accès depuis Pocket !",
|
||||
"error.proxy_url_not_empty": "L'URL du proxy ne peut pas être vide.",
|
||||
"error.settings_block_rule_fieldname_invalid": "Règle de blocage invalide : la règle n°%d ne contient pas un nom de champ valide (Options : %s)",
|
||||
"error.settings_block_rule_invalid_regex": "Règle de blocage invalide : le motif de la règle n°%d n'est pas une expression régulière valide",
|
||||
@@ -159,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",
|
||||
@@ -168,7 +170,8 @@
|
||||
"form.feed.fieldset.rules": "Règles",
|
||||
"form.feed.label.allow_self_signed_certificates": "Autoriser les certificats auto-signés ou non valides",
|
||||
"form.feed.label.apprise_service_urls": "Liste séparée par des virgules des URL du service Apprise",
|
||||
"form.feed.label.blocklist_rules": "Règles de blocage",
|
||||
"form.feed.label.block_filter_entry_rules": "Règles de blocage des entrées",
|
||||
"form.feed.label.blocklist_rules": "Filtres de blocage basés sur des expressions régulières",
|
||||
"form.feed.label.category": "Catégorie",
|
||||
"form.feed.label.cookie": "Définir les cookies",
|
||||
"form.feed.label.crawler": "Récupérer le contenu original",
|
||||
@@ -181,7 +184,8 @@
|
||||
"form.feed.label.fetch_via_proxy": "Utiliser le proxy configuré au niveau de l'application",
|
||||
"form.feed.label.hide_globally": "Masquer les entrées dans la liste globale non lue",
|
||||
"form.feed.label.ignore_http_cache": "Ignorer le cache HTTP",
|
||||
"form.feed.label.keeplist_rules": "Règles d'autorisation",
|
||||
"form.feed.label.keep_filter_entry_rules": "Règles d'autorisation des entrées",
|
||||
"form.feed.label.keeplist_rules": "Filtres de conservation basés sur des expressions régulières",
|
||||
"form.feed.label.no_media_player": "Pas de lecteur multimedia (audio/vidéo)",
|
||||
"form.feed.label.ntfy_activate": "Activer les notifications",
|
||||
"form.feed.label.ntfy_default_priority": "Priorité par défaut de notification",
|
||||
@@ -199,7 +203,7 @@
|
||||
"form.feed.label.pushover_max_priority": "Priorité maximale",
|
||||
"form.feed.label.pushover_min_priority": "Priorité minimale",
|
||||
"form.feed.label.pushover_priority": "Priorité des notifications Pushover",
|
||||
"form.feed.label.rewrite_rules": "Règles de réécriture",
|
||||
"form.feed.label.rewrite_rules": "Règles de réécriture du contenu",
|
||||
"form.feed.label.scraper_rules": "Règles pour récupérer le contenu original",
|
||||
"form.feed.label.site_url": "URL du site web",
|
||||
"form.feed.label.title": "Titre",
|
||||
@@ -233,6 +237,9 @@
|
||||
"form.integration.instapaper_activate": "Sauvegarder les articles vers Instapaper",
|
||||
"form.integration.instapaper_password": "Mot de passe Instapaper",
|
||||
"form.integration.instapaper_username": "Nom d'utilisateur Instapaper",
|
||||
"form.integration.karakeep_activate": "Sauvegarder les articles vers Karakeep",
|
||||
"form.integration.karakeep_api_key": "Clé d'API de Karakeep",
|
||||
"form.integration.karakeep_url": "URL de l'API de Karakeep",
|
||||
"form.integration.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",
|
||||
@@ -244,9 +251,19 @@
|
||||
"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": "Tags (max. 10, séparés par des virgules)",
|
||||
"form.integration.linktaco_tags_hint": "Maximum 10 tags, séparés par des virgules",
|
||||
"form.integration.linktaco_visibility": "Visibilité",
|
||||
"form.integration.linktaco_visibility_public": "Public",
|
||||
"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 l'API de Linkwarden",
|
||||
"form.integration.linkwarden_endpoint": "URL de base de 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",
|
||||
@@ -273,10 +290,6 @@
|
||||
"form.integration.pinboard_bookmark": "Marquer le lien comme non lu",
|
||||
"form.integration.pinboard_tags": "Libellés de Pinboard",
|
||||
"form.integration.pinboard_token": "Jeton de sécurité de l'API de Pinboard",
|
||||
"form.integration.pocket_access_token": "Jeton d'accès de l'API de Pocket",
|
||||
"form.integration.pocket_activate": "Sauvegarder les articles vers Pocket",
|
||||
"form.integration.pocket_connect_link": "Connectez votre compte Pocket",
|
||||
"form.integration.pocket_consumer_key": "Clé de l'API de Pocket",
|
||||
"form.integration.pushover_activate": "Envoyer les articles vers Pushover",
|
||||
"form.integration.pushover_device": "Nom de l'appareil Pushover (facultatif)",
|
||||
"form.integration.pushover_prefix": "URL de préfixe Pushover (facultatif)",
|
||||
@@ -289,7 +302,7 @@
|
||||
"form.integration.readeck_activate": "Sauvegarder les articles vers Readeck",
|
||||
"form.integration.readeck_api_key": "Clé d'API de Readeck",
|
||||
"form.integration.readeck_endpoint": "URL de l'API de Readeck",
|
||||
"form.integration.readeck_labels": "Readeck Labels",
|
||||
"form.integration.readeck_labels": "Libellés Readeck",
|
||||
"form.integration.readeck_only_url": "Envoyer uniquement l'URL (au lieu du contenu complet)",
|
||||
"form.integration.readwise_activate": "Enregistrer les entrées vers Readwise Reader",
|
||||
"form.integration.readwise_api_key": "Jeton d'accès au lecteur Readwise",
|
||||
@@ -320,6 +333,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",
|
||||
@@ -328,6 +342,7 @@
|
||||
"form.prefs.fieldset.global_feed_settings": "Paramètres globaux des abonnements",
|
||||
"form.prefs.fieldset.reader_settings": "Paramètres du lecteur",
|
||||
"form.prefs.help.external_font_hosts": "Liste de domaine externes autorisés, séparés par des espaces. Par exemple : « fonts.gstatic.com fonts.googleapis.com ».",
|
||||
"form.prefs.label.always_open_external_links": "Lire les articles en ouvrant les liens externes",
|
||||
"form.prefs.label.categories_sorting_order": "Colonne de tri des catégories",
|
||||
"form.prefs.label.cjk_reading_speed": "Vitesse de lecture pour le chinois, le coréen et le japonais (caractères par minute)",
|
||||
"form.prefs.label.custom_css": "Feuille de style personnalisée",
|
||||
@@ -348,7 +363,7 @@
|
||||
"form.prefs.label.mark_read_on_view": "Marquer automatiquement les entrées comme lues lorsqu'elles sont consultées",
|
||||
"form.prefs.label.mark_read_on_view_or_media_completion": "Marquer automatiquement les entrées comme lues lorsqu'elles sont consultées. Pour l'audio/vidéo, marquer comme lues après 90%%",
|
||||
"form.prefs.label.media_playback_rate": "Vitesse de lecture de l'audio/vidéo",
|
||||
"form.prefs.label.always_open_external_links": "Read articles by opening external links",
|
||||
"form.prefs.label.open_external_links_in_new_tab": "Ouvrir les liens externes dans un nouvel onglet (ajoute target=\"_blank\" aux liens)",
|
||||
"form.prefs.label.show_reading_time": "Afficher le temps de lecture estimé des articles",
|
||||
"form.prefs.label.theme": "Thème",
|
||||
"form.prefs.label.timezone": "Fuseau horaire",
|
||||
@@ -459,6 +474,7 @@
|
||||
"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",
|
||||
@@ -503,7 +519,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",
|
||||
@@ -544,7 +560,7 @@
|
||||
"page.settings.webauthn.last_seen_on": "Dernière utilisation",
|
||||
"page.settings.webauthn.passkey_name": "Nom de la clé d’accès",
|
||||
"page.settings.webauthn.passkeys": "Clés d’accès",
|
||||
"page.settings.webauthn.register": "Enregister une nouvelle clé d’accès",
|
||||
"page.settings.webauthn.register": "Enregistrer une nouvelle clé d’accès",
|
||||
"page.settings.webauthn.register.error": "Impossible d'enregistrer la clé d’accès",
|
||||
"page.shared_entries.title": "Articles partagés",
|
||||
"page.shared_entries_count": [
|
||||
@@ -611,4 +627,4 @@
|
||||
"time_elapsed.yesterday": "hier",
|
||||
"tooltip.keyboard_shortcuts": "Raccourci clavier : %s",
|
||||
"tooltip.logged_user": "Connecté en tant que %s"
|
||||
}
|
||||
}
|
||||
@@ -13,9 +13,9 @@
|
||||
"action.update": "नवीनीकरण करे",
|
||||
"alert.account_linked": "आपका बाहरी खाता अब लिंक हो गया है!",
|
||||
"alert.account_unlinked": "आपका बाहरी खाता अब अलग कर दिया गया है!",
|
||||
"alert.background_feed_refresh": "All feeds are being refreshed in the background. You can continue to use Miniflux while this process is running.",
|
||||
"alert.background_feed_refresh": "सभी फ़ीड्स पृष्ठभूमि में ताज़ा की जा रही हैं। जब यह प्रक्रिया चल रही हो, तो आप मिनीफ्लक्स का उपयोग जारी रख सकते हैं।",
|
||||
"alert.feed_error": "इस फ़ीड में एक समस्या है",
|
||||
"alert.no_bookmark": "इस समय कोई बुकमार्क नहीं है",
|
||||
"alert.no_starred": "इस समय कोई बुकमार्क नहीं है",
|
||||
"alert.no_category": "कोई श्रेणी नहीं है।",
|
||||
"alert.no_category_entry": "इस श्रेणी में कोई विषय-वस्तु नहीं है।",
|
||||
"alert.no_feed": "आपके पास कोई सदस्यता नहीं है।",
|
||||
@@ -27,30 +27,29 @@
|
||||
"alert.no_tag_entry": "इस टैग से मेल खाती कोई प्रविष्टियाँ नहीं हैं।",
|
||||
"alert.no_unread_entry": "कोई अपठित वस्तुत नहीं है।",
|
||||
"alert.no_user": "आप एकमात्र उपयोगकर्ता हैं।",
|
||||
"alert.pocket_linked": "आपका पॉकेट खाता अब लिंक हो गया है!",
|
||||
"alert.prefs_saved": "प्राथमिकताएं सहेजी गईं!",
|
||||
"alert.too_many_feeds_refresh": [
|
||||
"You have triggered too many feed refreshes. Please wait %d minute before trying again.",
|
||||
"You have triggered too many feed refreshes. Please wait %d minutes before trying again."
|
||||
"आपने बहुत अधिक फ़ीड ताज़ा करने की प्रक्रिया शुरू कर दी है। कृपया पुनः प्रयास करने से पहले %d मिनट प्रतीक्षा करें।",
|
||||
"आपने बहुत अधिक फ़ीड ताज़ा करने की प्रक्रिया शुरू कर दी है। कृपया पुनः प्रयास करने से पहले %d मिनट प्रतीक्षा करें।"
|
||||
],
|
||||
"confirm.loading": " प्रगति में है ...",
|
||||
"confirm.no": " नहीं",
|
||||
"confirm.question": "मंजूर है?",
|
||||
"confirm.question.refresh": "क्या आप बल द्वारा ताज़ा करना चाहते हैं?",
|
||||
"confirm.yes": "हाँ",
|
||||
"enclosure_media_controls.seek": "Seek:",
|
||||
"enclosure_media_controls.seek.title": "Seek %s seconds",
|
||||
"enclosure_media_controls.speed": "Speed:",
|
||||
"enclosure_media_controls.speed.faster": "Faster",
|
||||
"enclosure_media_controls.speed.faster.title": "Faster by %sx",
|
||||
"enclosure_media_controls.speed.reset": "Reset",
|
||||
"enclosure_media_controls.speed.reset.title": "Reset speed to 1x",
|
||||
"enclosure_media_controls.speed.slower": "Slower",
|
||||
"enclosure_media_controls.speed.slower.title": "Slower by %sx",
|
||||
"entry.bookmark.toast.off": "तारांकित न करे",
|
||||
"entry.bookmark.toast.on": "तारांकित",
|
||||
"entry.bookmark.toggle.off": "सितारा हटा दो",
|
||||
"entry.bookmark.toggle.on": "सितारा दे",
|
||||
"enclosure_media_controls.seek": "खोजें:",
|
||||
"enclosure_media_controls.seek.title": "%s सेकंड खोजें",
|
||||
"enclosure_media_controls.speed": "गति:",
|
||||
"enclosure_media_controls.speed.faster": "तेज",
|
||||
"enclosure_media_controls.speed.faster.title": "%sx गुना तेज",
|
||||
"enclosure_media_controls.speed.reset": "रीसेट करें",
|
||||
"enclosure_media_controls.speed.reset.title": "गति 1x पर रीसेट करें",
|
||||
"enclosure_media_controls.speed.slower": "धीमा",
|
||||
"enclosure_media_controls.speed.slower.title": "%sx गुना धीमा",
|
||||
"entry.starred.toast.off": "तारांकित न करे",
|
||||
"entry.starred.toast.on": "तारांकित",
|
||||
"entry.starred.toggle.off": "सितारा हटा दो",
|
||||
"entry.starred.toggle.on": "सितारा दे",
|
||||
"entry.comments.label": "टिप्पणियाँ",
|
||||
"entry.comments.title": "टिप्पणियाँ देखे",
|
||||
"entry.estimated_reading_time": [
|
||||
@@ -77,35 +76,39 @@
|
||||
"entry.status.toast.read": "पढ़ा हुआ चिह्नित करे",
|
||||
"entry.status.toast.unread": "अपठित के रूप में चिह्नित",
|
||||
"entry.tags.label": "टैग:",
|
||||
"entry.tags.more_tags_label": [
|
||||
"%d और टैग दिखाएँ",
|
||||
"%d और टैग दिखाएँ"
|
||||
],
|
||||
"entry.unshare.label": "न साझा कारें",
|
||||
"error.api_key_already_exists": "यह एपीआई कुंजी पहले से मौजूद है।",
|
||||
"error.bad_credentials": "अमान्य उपयोगकर्ता नाम या पासवर्ड।",
|
||||
"error.category_already_exists": "यह श्रेणी पहले से मौजूद है।",
|
||||
"error.category_not_found": "This category does not exist or does not belong to this user.",
|
||||
"error.database_error": "Database error: %v.",
|
||||
"error.category_not_found": "यह श्रेणी मौजूद नहीं है या इस उपयोगकर्ता से संबंधित नहीं है।",
|
||||
"error.database_error": "डेटाबेस त्रुटि: %v।",
|
||||
"error.different_passwords": "पासवर्ड एक जैसे नहीं हैं।",
|
||||
"error.duplicate_fever_username": "पहले से ही समान फीवर उपयोगकर्ता नाम वाला कोई और है!",
|
||||
"error.duplicate_googlereader_username": "समान गूगल रीडर उपयोगकर्ता नाम वाला कोई और पहले से मौजूद है!",
|
||||
"error.duplicate_linked_account": "इस प्रदाता के साथ पहले से ही कोई व्यक्ति जुड़ा हुआ है!",
|
||||
"error.duplicated_feed": "This feed already exists.",
|
||||
"error.duplicated_feed": "यह फ़ीड पहले से मौजूद है।",
|
||||
"error.empty_file": "यह फ़ाइल खाली है।",
|
||||
"error.entries_per_page_invalid": "प्रति पृष्ठ प्रविष्टियों की संख्या मान्य नहीं है।",
|
||||
"error.feed_already_exists": "यह फ़ीड पहले से मौजूद है.",
|
||||
"error.feed_category_not_found": "यह श्रेणी मौजूद नहीं है या इस उपयोगकर्ता से संबंधित नहीं है।",
|
||||
"error.feed_format_not_detected": "Unable to detect feed format: %v.",
|
||||
"error.feed_format_not_detected": "फ़ीड प्रारूप का पता नहीं लगा सकते: %v।",
|
||||
"error.feed_invalid_blocklist_rule": "ब्लॉक सूची नियम अमान्य है।",
|
||||
"error.feed_invalid_keeplist_rule": "सूची रखें नियम अमान्य है।",
|
||||
"error.feed_mandatory_fields": "URL और श्रेणी अनिवार्य हैं।",
|
||||
"error.feed_not_found": "This feed does not exist or does not belong to this user.",
|
||||
"error.feed_not_found": "यह फ़ीड मौजूद नहीं है या इस उपयोगकर्ता से संबंधित नहीं है।",
|
||||
"error.feed_title_not_empty": "फ़ीड शीर्षक खाली नहीं हो सकता.",
|
||||
"error.feed_url_not_empty": "फ़ीड यूआरएल खाली नहीं हो सकता.",
|
||||
"error.fields_mandatory": "सभी फील्ड अनिवार्य।",
|
||||
"error.http_bad_gateway": "The website is not available at the moment due to a bad gateway error. The problem is not on Miniflux side. Please, try again later.",
|
||||
"error.http_body_read": "Unable to read the HTTP body: %v.",
|
||||
"error.http_client_error": "HTTP client error: %v.",
|
||||
"error.http_empty_response": "The HTTP response is empty. Perhaps, this website is using a bot protection mechanism?",
|
||||
"error.http_empty_response_body": "The HTTP response body is empty.",
|
||||
"error.http_forbidden": "Access to this website is forbidden. Perhaps, this website has a bot protection mechanism?",
|
||||
"error.http_bad_gateway": "खराब गेटवे त्रुटि के कारण वेबसाइट फिलहाल उपलब्ध नहीं है। समस्या Miniflux की तरफ नहीं है। कृपया बाद में फिर से कोशिश करें।",
|
||||
"error.http_body_read": "HTTP बॉडी पढ़ने में असमर्थ: %v।",
|
||||
"error.http_client_error": "HTTP क्लाइंट त्रुटि: %v।",
|
||||
"error.http_empty_response": "HTTP प्रतिक्रिया खाली है। शायद यह वेबसाइट बॉट सुरक्षा तंत्र का उपयोग कर रही है?",
|
||||
"error.http_empty_response_body": "HTTP प्रतिक्रिया बॉडी खाली है।",
|
||||
"error.http_forbidden": "इस वेबसाइट तक पहुंच वर्जित है। शायद इस वेबसाइट में बॉट सुरक्षा तंत्र है?",
|
||||
"error.http_gateway_timeout": "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.",
|
||||
@@ -119,7 +122,7 @@
|
||||
"error.invalid_display_mode": "अमान्य वेब ऐप्लिकेशन प्रदर्शन मोड.",
|
||||
"error.invalid_entry_direction": "अमान्य प्रवेश दिशा।",
|
||||
"error.invalid_entry_order": "अमान्य प्रविष्टि क्रम।",
|
||||
"error.invalid_feed_proxy_url": "Invalid proxy URL.",
|
||||
"error.invalid_feed_proxy_url": "अमान्य प्रॉक्सी यूआरएल।",
|
||||
"error.invalid_feed_url": "दृष्टिकोण यूआरएल.",
|
||||
"error.invalid_gesture_nav": "अमान्य इशारा नेविगेशन।",
|
||||
"error.invalid_language": "अमान्य भाषा.",
|
||||
@@ -129,8 +132,6 @@
|
||||
"error.network_operation": "Miniflux is not able to reach this website due to a network error: %v.",
|
||||
"error.network_timeout": "This website is too slow and the request timed out: %v",
|
||||
"error.password_min_length": "पासवर्ड में कम से कम 6 अक्षर होने चाहिए।",
|
||||
"error.pocket_access_token": "पॉकेट से एक्सेस टोकन प्राप्त करने में असमर्थ!",
|
||||
"error.pocket_request_token": "पॉकेट से अनुरोध टोकन लाने में असमर्थ!",
|
||||
"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",
|
||||
@@ -151,14 +152,15 @@
|
||||
"error.unable_to_create_api_key": "यह एपीआई कुंजी बनाने में असमर्थ।",
|
||||
"error.unable_to_create_category": "यह श्रेणी बनाने में असमर्थ.",
|
||||
"error.unable_to_create_user": "इस उपयोगकर्ता को बनाने में असमर्थ।",
|
||||
"error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.",
|
||||
"error.unable_to_parse_feed": "Unable to parse this feed: %v.",
|
||||
"error.unable_to_detect_rssbridge": "RSS-Bridge का उपयोग करके फ़ीड का पता लगाने में असमर्थ: %v.",
|
||||
"error.unable_to_parse_feed": "इस फ़ीड को पार्स करने में असमर्थ: %v.",
|
||||
"error.unable_to_update_category": "इस श्रेणी को अपडेट करने में असमर्थ।",
|
||||
"error.unable_to_update_feed": "इस फ़ीड को अपडेट करने में असमर्थ.",
|
||||
"error.unable_to_update_user": "इस उपयोगकर्ता को अपडेट करने में असमर्थ.",
|
||||
"error.unlink_account_without_password": "आपको एक पासवर्ड परिभाषित करना होगा अन्यथा आप फिर से लॉगिन नहीं कर पाएंगे।",
|
||||
"error.user_already_exists": "यह उपयोगकर्ता पहले से ही मौजूद है।",
|
||||
"error.user_mandatory_fields": "उपयोगकर्ता नाम अनिवार्य है।",
|
||||
"error.linktaco_missing_required_fields": "LinkTaco API Token और Organization Slug आवश्यक हैं",
|
||||
"form.api_key.label.description": "एपीआई कुंजी लेबल",
|
||||
"form.category.hide_globally": "वैश्विक अपठित सूची में प्रविष्टियां छिपाएं",
|
||||
"form.category.label.title": "शीर्षक",
|
||||
@@ -168,7 +170,8 @@
|
||||
"form.feed.fieldset.rules": "Rules",
|
||||
"form.feed.label.allow_self_signed_certificates": "स्व-हस्ताक्षरित या अमान्य प्रमाणपत्रों की अनुमति दें",
|
||||
"form.feed.label.apprise_service_urls": "Comma separated list of Apprise service URLs",
|
||||
"form.feed.label.blocklist_rules": "ब्लॉक नियम",
|
||||
"form.feed.label.block_filter_entry_rules": "प्रविष्टि अवरोधन नियम",
|
||||
"form.feed.label.blocklist_rules": "रेगेक्स-आधारित अवरोधन फिल्टर",
|
||||
"form.feed.label.category": "श्रेणी",
|
||||
"form.feed.label.cookie": "कुकीज़ सेट करें",
|
||||
"form.feed.label.crawler": "मूल सामग्री प्राप्त करें",
|
||||
@@ -181,7 +184,8 @@
|
||||
"form.feed.label.fetch_via_proxy": "एप्लिकेशन स्तर पर कॉन्फ़िगर किए गए प्रॉक्सी का उपयोग करें",
|
||||
"form.feed.label.hide_globally": "वैश्विक अपठित सूची में प्रविष्टियां छिपाएं",
|
||||
"form.feed.label.ignore_http_cache": "एचटीटीपी कैश पर ध्यान न दें",
|
||||
"form.feed.label.keeplist_rules": "नियम बनाए रखें",
|
||||
"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",
|
||||
@@ -199,7 +203,7 @@
|
||||
"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.rewrite_rules": "नियम फिर से लिखें",
|
||||
"form.feed.label.rewrite_rules": "सामग्री पुनर्लेखन नियम",
|
||||
"form.feed.label.scraper_rules": "खुरचनी नियम",
|
||||
"form.feed.label.site_url": "साइट यूआरएल",
|
||||
"form.feed.label.title": "शीर्षक",
|
||||
@@ -233,6 +237,9 @@
|
||||
"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",
|
||||
@@ -244,9 +251,19 @@
|
||||
"form.integration.linkding_bookmark": "बुकमार्क को अपठित के रूप में चिह्नित करें",
|
||||
"form.integration.linkding_endpoint": "लिंकिंग एपीआई समापन बिंदु",
|
||||
"form.integration.linkding_tags": "Linkding Tags",
|
||||
"form.integration.linktaco_activate": "LinkTaco में प्रविष्टियाँ सहेजें",
|
||||
"form.integration.linktaco_api_token": "LinkTaco API Token",
|
||||
"form.integration.linktaco_api_token_hint": "अपना व्यक्तिगत पहुँच टोकन प्राप्त करें",
|
||||
"form.integration.linktaco_org_slug": "Organization Slug",
|
||||
"form.integration.linktaco_tags": "टैग (अधिकतम 10, कॉमा से अलग किए गए)",
|
||||
"form.integration.linktaco_tags_hint": "अधिकतम 10 टैग, कॉमा से अलग किए गए",
|
||||
"form.integration.linktaco_visibility": "दृश्यता",
|
||||
"form.integration.linktaco_visibility_public": "सार्वजनिक",
|
||||
"form.integration.linktaco_visibility_private": "निजी",
|
||||
"form.integration.linktaco_visibility_hint": "निजी दृश्यता के लिए भुगतान LinkTaco खाता आवश्यक है",
|
||||
"form.integration.linkwarden_activate": "Save entries to Linkwarden",
|
||||
"form.integration.linkwarden_api_key": "Linkwarden API key",
|
||||
"form.integration.linkwarden_endpoint": "Linkwarden API Endpoint",
|
||||
"form.integration.linkwarden_endpoint": "लिंकवर्डन बेस यूआरएलL",
|
||||
"form.integration.matrix_bot_activate": "नए लेखों को मैट्रिक्स में स्थानांतरित करें",
|
||||
"form.integration.matrix_bot_chat_id": "मैट्रिक्स रूम की आईडी",
|
||||
"form.integration.matrix_bot_password": "मैट्रिक्स उपयोगकर्ता के लिए पासवर्ड",
|
||||
@@ -273,10 +290,6 @@
|
||||
"form.integration.pinboard_bookmark": "बुकमार्क को अपठित के रूप में चिह्नित करें",
|
||||
"form.integration.pinboard_tags": "पिनबोर्ड टैग",
|
||||
"form.integration.pinboard_token": "पिनबोर्ड एपीआई टोकन",
|
||||
"form.integration.pocket_access_token": "पॉकेट एक्सेस टोकन",
|
||||
"form.integration.pocket_activate": "विषय-कविता को पॉकेट में सहेजें",
|
||||
"form.integration.pocket_connect_link": "अपना पॉकेट खाता कनेक्ट करें",
|
||||
"form.integration.pocket_consumer_key": "पॉकेट उपभोक्ता कुंजी",
|
||||
"form.integration.pushover_activate": "Push entries to Pushover",
|
||||
"form.integration.pushover_device": "Pushover device (optional)",
|
||||
"form.integration.pushover_prefix": "Pushover URL prefix (optional)",
|
||||
@@ -320,6 +333,7 @@
|
||||
"form.integration.wallabag_only_url": "केवल URL भेजें (पूर्ण सामग्री के बजाय)",
|
||||
"form.integration.wallabag_password": "वालाबैग पासवर्ड",
|
||||
"form.integration.wallabag_username": "वालाबैग उपयोगकर्ता नाम",
|
||||
"form.integration.wallabag_tags": "Wallabag Tags",
|
||||
"form.integration.webhook_activate": "Enable Webhooks",
|
||||
"form.integration.webhook_secret": "Webhooks Secret",
|
||||
"form.integration.webhook_url": "Default Webhook URL",
|
||||
@@ -328,6 +342,7 @@
|
||||
"form.prefs.fieldset.global_feed_settings": "Global Feed Settings",
|
||||
"form.prefs.fieldset.reader_settings": "Reader Settings",
|
||||
"form.prefs.help.external_font_hosts": "Space separated list of external font hosts to allow. For example: \"fonts.gstatic.com fonts.googleapis.com\".",
|
||||
"form.prefs.label.always_open_external_links": "Read articles by opening external links",
|
||||
"form.prefs.label.categories_sorting_order": "श्रेणियाँ छँटाई",
|
||||
"form.prefs.label.cjk_reading_speed": "चीनी, कोरियाई और जापानी के लिए पढ़ने की गति (प्रति मिनट वर्ण)",
|
||||
"form.prefs.label.custom_css": "कस्टम सीएसएस",
|
||||
@@ -348,7 +363,7 @@
|
||||
"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.media_playback_rate": "ऑडियो/वीडियो की प्लेबैक गति",
|
||||
"form.prefs.label.always_open_external_links": "Read articles by opening external links",
|
||||
"form.prefs.label.open_external_links_in_new_tab": "बाहरी लिंक को एक नए टैब में खोलें (लिंक में target=\"_blank\" जोड़ता है)",
|
||||
"form.prefs.label.show_reading_time": "विषय के लिए अनुमानित पढ़ने का समय दिखाएं",
|
||||
"form.prefs.label.theme": "थीम",
|
||||
"form.prefs.label.timezone": "समय क्षेत्र",
|
||||
@@ -459,6 +474,7 @@
|
||||
"page.feeds.next_check": "Next check:",
|
||||
"page.feeds.read_counter": "पड़े हुए विषयवस्तुया",
|
||||
"page.feeds.title": "फ़ीड",
|
||||
"page.footer.elevator": "Back to top",
|
||||
"page.history.title": "इतिहास",
|
||||
"page.import.title": "आयात",
|
||||
"page.integration.bookmarklet": "बुकमार्कलेट",
|
||||
@@ -503,7 +519,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": "Toggle open/close entry attachments",
|
||||
"page.keyboard_shortcuts.toggle_read_status_next": "पढ़ें/अपठित टॉगल करें, अगला फ़ोकस करें",
|
||||
"page.keyboard_shortcuts.toggle_read_status_prev": "पढ़ें/अपठित टॉगल करें, पिछला फ़ोकस करें",
|
||||
@@ -574,14 +590,14 @@
|
||||
"page.users.title": "उपभोक्ता",
|
||||
"page.users.username": "यूसर्नेम",
|
||||
"page.webauthn_rename.title": "Rename Passkey",
|
||||
"pagination.first": "First",
|
||||
"pagination.last": "Last",
|
||||
"pagination.first": "पहला",
|
||||
"pagination.last": "अंतिम",
|
||||
"pagination.next": "अगला",
|
||||
"pagination.previous": "पिछला",
|
||||
"search.label": "खोजे",
|
||||
"search.placeholder": "खोजे...",
|
||||
"search.submit": "Search",
|
||||
"skip_to_content": "Skip to content",
|
||||
"search.submit": "खोजें",
|
||||
"skip_to_content": "सामग्री पर जाएं",
|
||||
"time_elapsed.days": [
|
||||
"%d दिन पहले",
|
||||
"%d दिन पहले"
|
||||
@@ -599,7 +615,7 @@
|
||||
"%d महिनो पहले"
|
||||
],
|
||||
"time_elapsed.not_yet": "अभी तक नहीं",
|
||||
"time_elapsed.now": "बिल्कुल अभी",
|
||||
"time_elapsed.now": "अभी",
|
||||
"time_elapsed.weeks": [
|
||||
"%d सप्ताह पहले",
|
||||
"%d हफ्तों पहले"
|
||||
@@ -609,6 +625,6 @@
|
||||
"%d वर्षों पहले"
|
||||
],
|
||||
"time_elapsed.yesterday": "कल",
|
||||
"tooltip.keyboard_shortcuts": "कुंजीपटल संक्षिप्त रीति: %s",
|
||||
"tooltip.keyboard_shortcuts": "कुंजीपटल शॉर्टकट: %s",
|
||||
"tooltip.logged_user": "%s के रूप में लॉग इन किया"
|
||||
}
|
||||
}
|
||||
@@ -13,9 +13,9 @@
|
||||
"action.update": "Perbarui",
|
||||
"alert.account_linked": "Akun eksternal Anda sudah terhubung!",
|
||||
"alert.account_unlinked": "Akun eksternal Anda sudah terputus!",
|
||||
"alert.background_feed_refresh": "All feeds are being refreshed in the background. You can continue to use Miniflux while this process is running.",
|
||||
"alert.background_feed_refresh": "Semua umpan sedang disegarkan di latar belakang. Anda bisa lanjut menggunakan Miniflux sembari proses ini berlanjut.",
|
||||
"alert.feed_error": "Ada masalah dengan umpan ini",
|
||||
"alert.no_bookmark": "Tidak ada markah.",
|
||||
"alert.no_starred": "Tidak ada markah.",
|
||||
"alert.no_category": "Tidak ada kategori.",
|
||||
"alert.no_category_entry": "Tidak ada artikel di kategori ini.",
|
||||
"alert.no_feed": "Anda tidak memiliki langganan.",
|
||||
@@ -27,29 +27,28 @@
|
||||
"alert.no_tag_entry": "Tidak ada entri yang cocok dengan tag ini.",
|
||||
"alert.no_unread_entry": "Belum ada artikel yang dibaca.",
|
||||
"alert.no_user": "Anda adalah satu-satunya pengguna.",
|
||||
"alert.pocket_linked": "Akun Pocket Anda sudah terhubung!",
|
||||
"alert.prefs_saved": "Preferensi disimpan!",
|
||||
"alert.too_many_feeds_refresh": [
|
||||
"You have triggered too many feed refreshes. Please wait %d minute before trying again."
|
||||
"Anda terlalu banyak menyegarkan umpan. Mohon tunggu %d menit sebelum mencoba lagi."
|
||||
],
|
||||
"confirm.loading": "Sedang progres...",
|
||||
"confirm.no": "tidak",
|
||||
"confirm.question": "Apakah Anda yakin?",
|
||||
"confirm.question.refresh": "Apakah Anda ingin memaksa penyegaran?",
|
||||
"confirm.yes": "ya",
|
||||
"enclosure_media_controls.seek": "Seek:",
|
||||
"enclosure_media_controls.seek.title": "Seek %s seconds",
|
||||
"enclosure_media_controls.speed": "Speed:",
|
||||
"enclosure_media_controls.speed.faster": "Faster",
|
||||
"enclosure_media_controls.speed.faster.title": "Faster by %sx",
|
||||
"enclosure_media_controls.speed.reset": "Reset",
|
||||
"enclosure_media_controls.speed.reset.title": "Reset speed to 1x",
|
||||
"enclosure_media_controls.speed.slower": "Slower",
|
||||
"enclosure_media_controls.speed.slower.title": "Slower by %sx",
|
||||
"entry.bookmark.toast.off": "Batal Markahi",
|
||||
"entry.bookmark.toast.on": "Markahi",
|
||||
"entry.bookmark.toggle.off": "Batal Markahi",
|
||||
"entry.bookmark.toggle.on": "Markahi",
|
||||
"enclosure_media_controls.seek": "Putar:",
|
||||
"enclosure_media_controls.seek.title": "Putar %s detik",
|
||||
"enclosure_media_controls.speed": "Kecepatan:",
|
||||
"enclosure_media_controls.speed.faster": "Lebih cepat",
|
||||
"enclosure_media_controls.speed.faster.title": "Lebih cepat %sx",
|
||||
"enclosure_media_controls.speed.reset": "Atur ulang",
|
||||
"enclosure_media_controls.speed.reset.title": "Atur ulang ke 1x",
|
||||
"enclosure_media_controls.speed.slower": "Lebih lambat",
|
||||
"enclosure_media_controls.speed.slower.title": "Lebih lambat %sx",
|
||||
"entry.starred.toast.off": "Batal Markahi",
|
||||
"entry.starred.toast.on": "Markahi",
|
||||
"entry.starred.toggle.off": "Batal Markahi",
|
||||
"entry.starred.toggle.on": "Markahi",
|
||||
"entry.comments.label": "Komentar",
|
||||
"entry.comments.title": "Lihat Komentar",
|
||||
"entry.estimated_reading_time": [
|
||||
@@ -75,147 +74,151 @@
|
||||
"entry.status.toast.read": "Ditandai sebagai telah dibaca",
|
||||
"entry.status.toast.unread": "Ditandai sebagai belum dibaca",
|
||||
"entry.tags.label": "Tanda:",
|
||||
"entry.tags.more_tags_label": [
|
||||
"Tampilkan %d tag lainnya"
|
||||
],
|
||||
"entry.unshare.label": "Batal bagikan",
|
||||
"error.api_key_already_exists": "Kunci API ini sudah ada.",
|
||||
"error.bad_credentials": "Nama pengguna atau kata sandi tidak valid.",
|
||||
"error.category_already_exists": "Kategori ini telah ada.",
|
||||
"error.category_not_found": "This category does not exist or does not belong to this user.",
|
||||
"error.database_error": "Database error: %v.",
|
||||
"error.category_not_found": "Kategori ini tidak ada atau tidak dipunyai oleh pengguna ini.",
|
||||
"error.database_error": "Galat basis data: %v.",
|
||||
"error.different_passwords": "Kata sandi tidak sama.",
|
||||
"error.duplicate_fever_username": "Sudah ada orang lain dengan nama pengguna Fever yang sama!",
|
||||
"error.duplicate_googlereader_username": "Sudah ada orang lain dengan nama pengguna Google Reader yang sama!",
|
||||
"error.duplicate_linked_account": "Sudah ada orang lain yang terhubung dengan penyedia ini!",
|
||||
"error.duplicated_feed": "This feed already exists.",
|
||||
"error.duplicate_fever_username": "Sudah ada pengguna lain dengan nama pengguna Fever yang sama!",
|
||||
"error.duplicate_googlereader_username": "Sudah ada pengguna lain dengan nama pengguna Google Reader yang sama!",
|
||||
"error.duplicate_linked_account": "Sudah ada pengguna lain yang terhubung dengan penyedia ini!",
|
||||
"error.duplicated_feed": "Umpan ini sudah ada.",
|
||||
"error.empty_file": "Berkas ini kosong.",
|
||||
"error.entries_per_page_invalid": "Jumlah entri per halaman tidak valid.",
|
||||
"error.feed_already_exists": "Umpan ini sudah ada.",
|
||||
"error.feed_category_not_found": "Kategori ini tidak ada atau tidak dipunyai oleh pengguna ini.",
|
||||
"error.feed_format_not_detected": "Unable to detect feed format: %v.",
|
||||
"error.feed_format_not_detected": "Tidak dapat mendeteksi format umpan: %v.",
|
||||
"error.feed_invalid_blocklist_rule": "Aturan blokir tidak valid.",
|
||||
"error.feed_invalid_keeplist_rule": "Aturan simpan tidak valid.",
|
||||
"error.feed_mandatory_fields": "Harus ada URL dan kategorinya.",
|
||||
"error.feed_not_found": "This feed does not exist or does not belong to this user.",
|
||||
"error.feed_not_found": "Umpan ini tidak ada atau tidak dipunyai oleh pengguna ini",
|
||||
"error.feed_title_not_empty": "Judul umpan tidak boleh kosong.",
|
||||
"error.feed_url_not_empty": "URL umpan tidak boleh kosong.",
|
||||
"error.fields_mandatory": "Semua bidang diharuskan.",
|
||||
"error.http_bad_gateway": "The website is not available at the moment due to a bad gateway error. The problem is not on Miniflux side. Please, try again later.",
|
||||
"error.http_body_read": "Unable to read the HTTP body: %v.",
|
||||
"error.http_client_error": "HTTP client error: %v.",
|
||||
"error.http_empty_response": "The HTTP response is empty. Perhaps, this website is using a bot protection mechanism?",
|
||||
"error.http_empty_response_body": "The HTTP response body is empty.",
|
||||
"error.http_forbidden": "Access to this website is forbidden. Perhaps, this website has a bot protection mechanism?",
|
||||
"error.http_gateway_timeout": "The website is not available at the moment due to a gateway timeout error. The problem is not on Miniflux side. Please, try again later.",
|
||||
"error.http_internal_server_error": "The website is not available at the moment due to a server error. The problem is not on Miniflux side. Please, try again later.",
|
||||
"error.http_not_authorized": "Access to this website is not authorized. It could be a bad username or password.",
|
||||
"error.http_resource_not_found": "The requested resource is not found. Please, verify the URL.",
|
||||
"error.http_response_too_large": "The HTTP response is too large. You could increase the HTTP response size limit in the global settings (requires a server restart).",
|
||||
"error.http_service_unavailable": "The website is not available at the moment due to an internal server error. The problem is not on Miniflux side. Please, try again later.",
|
||||
"error.http_too_many_requests": "Miniflux generated too many requests to this website. Please, try again later or change the application configuration.",
|
||||
"error.http_unexpected_status_code": "The website is not available at the moment due to an unexpected HTTP status code: %d. The problem is not on Miniflux side. Please, try again later.",
|
||||
"error.http_bad_gateway": "Situs ini tidak tersedia saat ini karena kesalahan akses peladen situs. Masalah ini bukan pada sisi Miniflux. Coba lagi nanti.",
|
||||
"error.http_body_read": "Tidak dapat membaca badan HTTP: %v.",
|
||||
"error.http_client_error": "Galat klien HTTP: %v.",
|
||||
"error.http_empty_response": "Balasan HTTP kosong. Mungkin, situs ini menggunakan mekanisme perlindungan dari bot?",
|
||||
"error.http_empty_response_body": "Badan balasan HTTP kosong.",
|
||||
"error.http_forbidden": "Akses ke situs ini terlarang. Mungkin, situs ini menggunakan mekanisme perlindungan dari bot?",
|
||||
"error.http_gateway_timeout": "Situs ini tidak tersedia saat ini karena kesalahan akses jaringan peladen situs. Masalah ini bukan pada sisi Miniflux. Coba lagi nanti.",
|
||||
"error.http_internal_server_error": "Situs ini tidak tersedia saat ini karena galat peladen situs. Masalah ini bukan pada sisi Miniflux. Coba lagi nanti.",
|
||||
"error.http_not_authorized": "Akses ke situs ini tidak diizinkan. Mungkin nama pengguna atau kata sandinya salah.",
|
||||
"error.http_resource_not_found": "Sumber daya yang diminta tidak ditemukan. Periksa kembali URL-nya.",
|
||||
"error.http_response_too_large": "Balasan HTTP terlalu besar. Anda bisa menaikkan batas ukuran balasan HTTP di pengaturan global (membutuhkan pemulaian ulang peladen).",
|
||||
"error.http_service_unavailable": "Situs ini tidak tersedia saat ini dikarenakan galat internal peladen situs. Masalah ini bukan pada sisi Miniflux. Coba lagi nanti.",
|
||||
"error.http_too_many_requests": "Terlalu banyak koneksi dari Miniflux yang dibuat ke situs ini. Coba lagi nanti atau ubah konfigurasi aplikasi.",
|
||||
"error.http_unexpected_status_code": "Situs ini tidak dapat dijangkau saat ini dikarenakan kode status HTTP tak diduga: %d Masalah ini bukan pada sisi Miniflux. Coba lagi nanti.",
|
||||
"error.invalid_categories_sorting_order": "Urutan penyortiran kategori tidak valid.",
|
||||
"error.invalid_default_home_page": "Beranda baku tidak valid!",
|
||||
"error.invalid_display_mode": "Mode tampilan aplikasi web tidak valid.",
|
||||
"error.invalid_entry_direction": "Urutan entri tidak valid.",
|
||||
"error.invalid_entry_order": "Urutan entri tidak valid.",
|
||||
"error.invalid_feed_proxy_url": "Invalid proxy URL.",
|
||||
"error.invalid_feed_proxy_url": "URL proksi tidak valid.",
|
||||
"error.invalid_feed_url": "URL umpan tidak valid.",
|
||||
"error.invalid_gesture_nav": "Navigasi gestur tidak valid.",
|
||||
"error.invalid_language": "Bahasa tidak valid.",
|
||||
"error.invalid_site_url": "URL situs tidak valid.",
|
||||
"error.invalid_theme": "Tema tidak valid.",
|
||||
"error.invalid_timezone": "Zona waktu tidak valid.",
|
||||
"error.network_operation": "Miniflux 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 tidak dapat menjangkau situs ini dikarenakan galat jaringan: %v.",
|
||||
"error.network_timeout": "Situs ini terlalu lambat dan permintaan ke situs terlalu lama: %v",
|
||||
"error.password_min_length": "Kata sandi harus memiliki setidaknya 6 karakter.",
|
||||
"error.pocket_access_token": "Tidak bisa mendapatkan token akses dari Pocket!",
|
||||
"error.pocket_request_token": "Tidak bisa mendapatkan token permintaan dari Pocket!",
|
||||
"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": "URL proksi tidak boleh kosong.",
|
||||
"error.settings_block_rule_fieldname_invalid": "Aturan blokir tidak valid: aturan #%d tidak mempunyai nama bidang yang valid (Opsi: %s)",
|
||||
"error.settings_block_rule_invalid_regex": "Aturan blokir tidak valid: aturan pola #%d bukan ekspresi regular (regex) yang valid",
|
||||
"error.settings_block_rule_regex_required": "Aturan blokir tidak valid: aturan pola #%d tidak disediakan",
|
||||
"error.settings_block_rule_separator_required": "Aturan blokir tidak valid: aturan pola #%d diharuskan dipisah menggunakan '='",
|
||||
"error.settings_invalid_domain_list": "Daftar domain tidak valid. Mohon sediakan daftar domain yang dipisah spasi.",
|
||||
"error.settings_keep_rule_fieldname_invalid": "Aturan simpan tidak valid: aturan #%d tidak mempunyai nama bidang yang valid (Opsi: %s)",
|
||||
"error.settings_keep_rule_invalid_regex": "Aturan simpan tidak valid: aturan pola #%d bukan ekspresi regular (regex) yang valid",
|
||||
"error.settings_keep_rule_regex_required": "Aturan simpan tidak valid: aturan pola #%d tidak disediakan",
|
||||
"error.settings_keep_rule_separator_required": "Aturan simpan tidak valid: aturan pola #%d diharuskan dipisah menggunakan '='",
|
||||
"error.settings_mandatory_fields": "Harus ada nama pengguna, tema, bahasa, dan zona waktu.",
|
||||
"error.settings_media_playback_rate_range": "Kecepatan pemutaran di luar jangkauan",
|
||||
"error.settings_reading_speed_is_positive": "Kecepatan membaca harus integer positif.",
|
||||
"error.site_url_not_empty": "URL situs tidak boleh kosong.",
|
||||
"error.subscription_not_found": "Tidak bisa mencari langganan apa pun.",
|
||||
"error.title_required": "Judul diharuskan.",
|
||||
"error.tls_error": "TLS error: %q. You could disable TLS verification in the feed settings if you would like.",
|
||||
"error.title_required": "Judul harus ada.",
|
||||
"error.tls_error": "Galat TLS: %q. Anda bisa mematikan verifikasi TLS di pengaturan umpan jika Anda mau.",
|
||||
"error.unable_to_create_api_key": "Tidak bisa membuat kunci API ini.",
|
||||
"error.unable_to_create_category": "Tidak bisa membuat kategori ini.",
|
||||
"error.unable_to_create_user": "Tidak bisa membuat pengguna tersebut.",
|
||||
"error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.",
|
||||
"error.unable_to_parse_feed": "Unable to parse this feed: %v.",
|
||||
"error.unable_to_detect_rssbridge": "Tidak dapat mendeteksi umpan menggunakan RSS-Bridge: %v.",
|
||||
"error.unable_to_parse_feed": "Tidak dapat membaca umpan: %v.",
|
||||
"error.unable_to_update_category": "Tidak bisa memperbarui kategori ini.",
|
||||
"error.unable_to_update_feed": "Tidak bisa memperbarui umpan ini.",
|
||||
"error.unable_to_update_user": "Tidak bisa memperbarui pengguna tersebut.",
|
||||
"error.unlink_account_without_password": "Anda harus mengatur kata sandi atau Anda tidak bisa masuk kembali.",
|
||||
"error.user_already_exists": "Pengguna ini sudah ada.",
|
||||
"error.user_mandatory_fields": "Harus ada nama pengguna.",
|
||||
"error.linktaco_missing_required_fields": "LinkTaco API Token dan Organization Slug diperlukan",
|
||||
"form.api_key.label.description": "Label Kunci API",
|
||||
"form.category.hide_globally": "Sembunyikan entri di daftar belum dibaca global",
|
||||
"form.category.label.title": "Judul",
|
||||
"form.feed.fieldset.general": "General",
|
||||
"form.feed.fieldset.integration": "Third-Party Services",
|
||||
"form.feed.fieldset.network_settings": "Network Settings",
|
||||
"form.feed.fieldset.rules": "Rules",
|
||||
"form.feed.fieldset.general": "Umum",
|
||||
"form.feed.fieldset.integration": "Pengaturan Pihak Ketiga",
|
||||
"form.feed.fieldset.network_settings": "Pengaturan Jaringan",
|
||||
"form.feed.fieldset.rules": "Aturan",
|
||||
"form.feed.label.allow_self_signed_certificates": "Perbolehkan sertifikat web tidak valid atau sertifikasi sendiri",
|
||||
"form.feed.label.apprise_service_urls": "Comma separated list of Apprise service URLs",
|
||||
"form.feed.label.blocklist_rules": "Aturan Blokir",
|
||||
"form.feed.label.apprise_service_urls": "Daftar yang dipisahkan koma untuk URL layanan Apprise",
|
||||
"form.feed.label.block_filter_entry_rules": "Aturan Pemblokiran Entri",
|
||||
"form.feed.label.blocklist_rules": "Filter Pemblokiran Berbasis Regex",
|
||||
"form.feed.label.category": "Kategori",
|
||||
"form.feed.label.cookie": "Atur Kuki",
|
||||
"form.feed.label.crawler": "Ambil konten asli",
|
||||
"form.feed.label.description": "Deskripsi",
|
||||
"form.feed.label.disable_http2": "Disable HTTP/2 to avoid fingerprinting",
|
||||
"form.feed.label.disable_http2": "Matikan HTTP/2 untuk menghindari pelacakan",
|
||||
"form.feed.label.disabled": "Jangan perbarui umpan ini",
|
||||
"form.feed.label.feed_password": "Kata Sandi Umpan",
|
||||
"form.feed.label.feed_url": "URL Umpan",
|
||||
"form.feed.label.feed_username": "Nama Pengguna Umpan",
|
||||
"form.feed.label.fetch_via_proxy": "Gunakan proxy yang dikonfigurasi di tingkat aplikasi",
|
||||
"form.feed.label.fetch_via_proxy": "Gunakan proksi yang dikonfigurasi di tingkat aplikasi",
|
||||
"form.feed.label.hide_globally": "Sembunyikan entri di daftar belum dibaca global",
|
||||
"form.feed.label.ignore_http_cache": "Abaikan Tembolok HTTP",
|
||||
"form.feed.label.keeplist_rules": "Aturan Simpan",
|
||||
"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.rewrite_rules": "Aturan Tulis Ulang",
|
||||
"form.feed.label.keep_filter_entry_rules": "Aturan Izin Entri",
|
||||
"form.feed.label.keeplist_rules": "Filter Simpan Berbasis Regex",
|
||||
"form.feed.label.no_media_player": "Tidak ada pemutar media (audio/video)",
|
||||
"form.feed.label.ntfy_activate": "Kirim artikel ke ntfy",
|
||||
"form.feed.label.ntfy_default_priority": "Prioritas baku Ntfy",
|
||||
"form.feed.label.ntfy_high_priority": "Priroritas tinggi Ntfy",
|
||||
"form.feed.label.ntfy_low_priority": "Prioritas rendah Ntfy",
|
||||
"form.feed.label.ntfy_max_priority": "Prioritas maksimal Ntfy",
|
||||
"form.feed.label.ntfy_min_priority": "Prioritas minimal Ntfy",
|
||||
"form.feed.label.ntfy_priority": "Prioritas Ntfy",
|
||||
"form.feed.label.ntfy_topic": "Topik Ntfy (opsional)",
|
||||
"form.feed.label.proxy_url": "URL Proksi",
|
||||
"form.feed.label.pushover_activate": "Kirim artikel ke pushover.net",
|
||||
"form.feed.label.pushover_default_priority": "Prioritas baku Pushover",
|
||||
"form.feed.label.pushover_high_priority": "Prioritas tinggi Pushover",
|
||||
"form.feed.label.pushover_low_priority": "Prioritas rendah Pushover",
|
||||
"form.feed.label.pushover_max_priority": "Prioritas maksimal Pushover",
|
||||
"form.feed.label.pushover_min_priority": "Prioritas minimal Pushover",
|
||||
"form.feed.label.pushover_priority": "Prioritas pesan Pushover",
|
||||
"form.feed.label.rewrite_rules": "Aturan Penulisan Ulang Konten",
|
||||
"form.feed.label.scraper_rules": "Aturan Pengambil Data",
|
||||
"form.feed.label.site_url": "URL Situs",
|
||||
"form.feed.label.title": "Judul",
|
||||
"form.feed.label.urlrewrite_rules": "Aturan Tulis Ulang URL",
|
||||
"form.feed.label.user_agent": "Timpa User Agent Baku",
|
||||
"form.feed.label.webhook_url": "Override webhook url",
|
||||
"form.feed.label.webhook_url": "Timpa URL Webhook",
|
||||
"form.import.label.file": "Berkas OPML",
|
||||
"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.integration.apprise_activate": "Kirim artikel ke Apprise",
|
||||
"form.integration.apprise_services_url": "Daftar yang dipisahkan koma untuk URL layanan Apprise",
|
||||
"form.integration.apprise_url": "URL API Apprise",
|
||||
"form.integration.betula_activate": "Simpan artikel ke Betula",
|
||||
"form.integration.betula_token": "Token Betula",
|
||||
"form.integration.betula_url": "URL Peladen Betula",
|
||||
"form.integration.cubox_activate": "Simpan artikel ke Cubox",
|
||||
"form.integration.cubox_api_link": "Tautan API Cubox",
|
||||
"form.integration.discord_activate": "Kirim artikel ke Discord",
|
||||
"form.integration.discord_webhook_link": "Tautan Webhook Discord",
|
||||
"form.integration.espial_activate": "Simpan artikel ke Espial",
|
||||
"form.integration.espial_api_key": "Kunci API Espial",
|
||||
"form.integration.espial_endpoint": "Titik URL API Espial",
|
||||
@@ -231,36 +234,49 @@
|
||||
"form.integration.instapaper_activate": "Simpan artikel ke Instapaper",
|
||||
"form.integration.instapaper_password": "Kata Sandi Instapaper",
|
||||
"form.integration.instapaper_username": "Nama Pengguna Instapaper",
|
||||
"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": "Simpan artikel ke Karakeep",
|
||||
"form.integration.karakeep_api_key": "Kunci API Karakeep",
|
||||
"form.integration.karakeep_url": "Titik URL API Karakeep",
|
||||
"form.integration.linkace_activate": "Simpan artikel ke LinkAce",
|
||||
"form.integration.linkace_api_key": "Kunci API LinkAce",
|
||||
"form.integration.linkace_check_disabled": "Matikan pemeriksaan tautan",
|
||||
"form.integration.linkace_endpoint": "Titik URL API LinkAce",
|
||||
"form.integration.linkace_is_private": "Tandai tautan sebagai pribadi",
|
||||
"form.integration.linkace_tags": "Tanda LinkAce",
|
||||
"form.integration.linkding_activate": "Simpan artikel ke Linkding",
|
||||
"form.integration.linkding_api_key": "Kunci API Linkding",
|
||||
"form.integration.linkding_bookmark": "Tandai markah sebagai belum dibaca",
|
||||
"form.integration.linkding_endpoint": "Titik URL API Linkding",
|
||||
"form.integration.linkding_tags": "Linkding Tags",
|
||||
"form.integration.linkding_tags": "Tanda Linkding",
|
||||
"form.integration.linktaco_activate": "Simpan entri ke LinkTaco",
|
||||
"form.integration.linktaco_api_token": "LinkTaco API Token",
|
||||
"form.integration.linktaco_api_token_hint": "Dapatkan token akses pribadi Anda di",
|
||||
"form.integration.linktaco_org_slug": "Organization Slug",
|
||||
"form.integration.linktaco_tags": "Tag (maksimal 10, dipisahkan koma)",
|
||||
"form.integration.linktaco_tags_hint": "Maksimal 10 tag, dipisahkan koma",
|
||||
"form.integration.linktaco_visibility": "Visibilitas",
|
||||
"form.integration.linktaco_visibility_public": "Publik",
|
||||
"form.integration.linktaco_visibility_private": "Pribadi",
|
||||
"form.integration.linktaco_visibility_hint": "Visibilitas PRIBADI membutuhkan akun LinkTaco berbayar",
|
||||
"form.integration.linkwarden_activate": "Simpan artikel ke Linkwarden",
|
||||
"form.integration.linkwarden_api_key": "Kunci API Linkwarden",
|
||||
"form.integration.linkwarden_endpoint": "Titik URL API Linkwarden",
|
||||
"form.integration.linkwarden_endpoint": "URL Dasar Linkwarden",
|
||||
"form.integration.matrix_bot_activate": "Kirim entri baru ke Matrix",
|
||||
"form.integration.matrix_bot_chat_id": "ID Ruang Matrix",
|
||||
"form.integration.matrix_bot_password": "Kata Sandi Matrix",
|
||||
"form.integration.matrix_bot_url": "URL Peladen Matrix",
|
||||
"form.integration.matrix_bot_user": "Nama Pengguna Matrix",
|
||||
"form.integration.notion_activate": "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": "Simpan artikel ke Notion",
|
||||
"form.integration.notion_page_id": "ID Halaman Notion",
|
||||
"form.integration.notion_token": "Token Rahasia Notion",
|
||||
"form.integration.ntfy_activate": "Kirim artikel ke ntfy",
|
||||
"form.integration.ntfy_api_token": "Token API Ntfy (opsional)",
|
||||
"form.integration.ntfy_icon_url": "URL ikon Ntfy (opsional)",
|
||||
"form.integration.ntfy_internal_links": "Gunakan tautan internal ketika mengklik (opsional)",
|
||||
"form.integration.ntfy_password": "Kata sandi Ntfy (opsional)",
|
||||
"form.integration.ntfy_topic": "Topik Ntfy (yang akan digunakan jika tidak diatur di umpan)",
|
||||
"form.integration.ntfy_url": "URL Ntfy (opsional, bawaan ke ntfy.sh)",
|
||||
"form.integration.ntfy_username": "Nama pengguna Ntfy (opsional)",
|
||||
"form.integration.nunux_keeper_activate": "Simpan artikel ke Nunux Keeper",
|
||||
"form.integration.nunux_keeper_api_key": "Kunci API Nunux Keeper",
|
||||
"form.integration.nunux_keeper_endpoint": "Titik URL API Nunux Keeper",
|
||||
@@ -271,46 +287,42 @@
|
||||
"form.integration.pinboard_bookmark": "Tandai markah sebagai belum dibaca",
|
||||
"form.integration.pinboard_tags": "Tanda di Pinboard",
|
||||
"form.integration.pinboard_token": "Token API Pinboard",
|
||||
"form.integration.pocket_access_token": "Token Akses Pocket",
|
||||
"form.integration.pocket_activate": "Simpan artikel ke Pocket",
|
||||
"form.integration.pocket_connect_link": "Hubungkan akun Pocket Anda",
|
||||
"form.integration.pocket_consumer_key": "Kunci Pelanggan Pocket",
|
||||
"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": "Kirim artikel ke Pushover",
|
||||
"form.integration.pushover_device": "Perangkat Pushover (opsional)",
|
||||
"form.integration.pushover_prefix": "Prefiks URL Pushover (opsional)",
|
||||
"form.integration.pushover_token": "Token API aplikasi Pushover",
|
||||
"form.integration.pushover_user": "Kunci pengguna Pushover",
|
||||
"form.integration.raindrop_activate": "Simpan artikel ke Raindrop",
|
||||
"form.integration.raindrop_collection_id": "ID Koleksi",
|
||||
"form.integration.raindrop_tags": "Tanda (dipisahkan koma)",
|
||||
"form.integration.raindrop_token": "Token (Tes)",
|
||||
"form.integration.readeck_activate": "Simpan artikel ke Readeck",
|
||||
"form.integration.readeck_api_key": "Kunci API Readeck",
|
||||
"form.integration.readeck_endpoint": "Titik URL API Readeck",
|
||||
"form.integration.readeck_labels": "Readeck Labels",
|
||||
"form.integration.readeck_labels": "Tagar Readeck",
|
||||
"form.integration.readeck_only_url": "Kirim hanya URL (alih-alih konten penuh)",
|
||||
"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.readwise_activate": "Simpan artikel ke Readwise",
|
||||
"form.integration.readwise_api_key": "Token Akses Readwise",
|
||||
"form.integration.readwise_api_key_link": "Dapatkan Token Akses Readwise Anda",
|
||||
"form.integration.rssbridge_activate": "Periksa RSS-Bridge ketika menambahkan langganan",
|
||||
"form.integration.rssbridge_token": "Token autentikasi RSS-Bridge",
|
||||
"form.integration.rssbridge_url": "URL peladen RSS-Bridge",
|
||||
"form.integration.shaarli_activate": "Simpan artikel ke Shaarli",
|
||||
"form.integration.shaarli_api_secret": "Rahasia API Shaarli",
|
||||
"form.integration.shaarli_endpoint": "URL Shaarli",
|
||||
"form.integration.shiori_activate": "Simpan artikel ke Shiori",
|
||||
"form.integration.shiori_endpoint": "Titik URL API Shiori",
|
||||
"form.integration.shiori_password": "Kata Sandi Shiori",
|
||||
"form.integration.shiori_username": "Nama Pengguna Shiori",
|
||||
"form.integration.slack_activate": "Kirim artikel ke Slack",
|
||||
"form.integration.slack_webhook_link": "Tautan Webhook Slack",
|
||||
"form.integration.telegram_bot_activate": "Kirim artikel baru ke percakapan Telegram",
|
||||
"form.integration.telegram_bot_disable_buttons": "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": "Matikan tombol",
|
||||
"form.integration.telegram_bot_disable_notification": "Matikan notifikasi",
|
||||
"form.integration.telegram_bot_disable_web_page_preview": "Matikan tinjauan halaman web",
|
||||
"form.integration.telegram_bot_token": "Token Bot",
|
||||
"form.integration.telegram_chat_id": "ID Obrolan",
|
||||
"form.integration.telegram_topic_id": "Topic ID",
|
||||
"form.integration.telegram_topic_id": "ID Topik",
|
||||
"form.integration.wallabag_activate": "Simpan artikel ke Wallabag",
|
||||
"form.integration.wallabag_client_id": "ID Klien Wallabag",
|
||||
"form.integration.wallabag_client_secret": "Rahasia Klien Wallabag",
|
||||
@@ -318,14 +330,16 @@
|
||||
"form.integration.wallabag_only_url": "Kirim hanya URL (alih-alih konten penuh)",
|
||||
"form.integration.wallabag_password": "Kata Sandi Wallabag",
|
||||
"form.integration.wallabag_username": "Nama Pengguna Wallabag",
|
||||
"form.integration.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.integration.wallabag_tags": "Wallabag Tags",
|
||||
"form.integration.webhook_activate": "Aktifkan Webhook",
|
||||
"form.integration.webhook_secret": "Rahasia Webhook",
|
||||
"form.integration.webhook_url": "URL Webhook baku",
|
||||
"form.prefs.fieldset.application_settings": "Pengaturan Aplikasi",
|
||||
"form.prefs.fieldset.authentication_settings": "Pengaturan Autentikasi",
|
||||
"form.prefs.fieldset.global_feed_settings": "Pengaturan Umpan Global",
|
||||
"form.prefs.fieldset.reader_settings": "Pengaturan Pembaca",
|
||||
"form.prefs.help.external_font_hosts": "Daftar yang dipisah spasi untuk peladen penyedia fonta eksternal yang diperbolehkan. Seperti: \"fonts.gstatic.com fonts.googleapis.com\".",
|
||||
"form.prefs.label.always_open_external_links": "Baca artikel dengan membuka tautan eksternal",
|
||||
"form.prefs.label.categories_sorting_order": "Pengurutan Kategori",
|
||||
"form.prefs.label.cjk_reading_speed": "Kecepatan membaca untuk bahasa Tiongkok, Korea, dan Jepang (karakter per menit)",
|
||||
"form.prefs.label.custom_css": "Modifikasi CSS",
|
||||
@@ -337,16 +351,16 @@
|
||||
"form.prefs.label.entry_order": "Pengurutan Kolom Entri",
|
||||
"form.prefs.label.entry_sorting": "Pengurutan Entri",
|
||||
"form.prefs.label.entry_swipe": "Aktifkan tindakan geser pada entri di ponsel",
|
||||
"form.prefs.label.external_font_hosts": "External font hosts",
|
||||
"form.prefs.label.external_font_hosts": "Peladen penyedia fonta eksternal",
|
||||
"form.prefs.label.gesture_nav": "Isyarat untuk menavigasi antar entri",
|
||||
"form.prefs.label.keyboard_shortcuts": "Aktifkan pintasan papan tik",
|
||||
"form.prefs.label.language": "Bahasa",
|
||||
"form.prefs.label.mark_read_manually": "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": "Tandai entri sebagai telah dibaca secara manual",
|
||||
"form.prefs.label.mark_read_on_media_completion": "Tandai entri sebagai telah dibaca ketika audio/video sudah 90% didengar/ditonton",
|
||||
"form.prefs.label.mark_read_on_view": "Secara otomatis menandai entri sebagai telah dibaca saat dilihat",
|
||||
"form.prefs.label.mark_read_on_view_or_media_completion": "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": "Tandai entri sebagai telah dibaca ketika dilihat. Untuk audio/video, tandai sebagai telah dibaca ketika sudah 90% didengar/ditonton.",
|
||||
"form.prefs.label.media_playback_rate": "Kecepatan pemutaran audio/video",
|
||||
"form.prefs.label.always_open_external_links": "Read articles by opening external links",
|
||||
"form.prefs.label.open_external_links_in_new_tab": "Buka tautan eksternal di tab baru (menambahkan target=\"_blank\" ke tautan)",
|
||||
"form.prefs.label.show_reading_time": "Tampilkan perkiraan waktu baca untuk artikel",
|
||||
"form.prefs.label.theme": "Tema",
|
||||
"form.prefs.label.timezone": "Zona Waktu",
|
||||
@@ -383,7 +397,7 @@
|
||||
"menu.feeds": "Umpan",
|
||||
"menu.flush_history": "Hapus riwayat",
|
||||
"menu.history": "Riwayat",
|
||||
"menu.home_page": "Home page",
|
||||
"menu.home_page": "Beranda",
|
||||
"menu.import": "Impor",
|
||||
"menu.integrations": "Integrasi",
|
||||
"menu.logout": "Keluar",
|
||||
@@ -397,7 +411,7 @@
|
||||
"menu.settings": "Pengaturan",
|
||||
"menu.shared_entries": "Entri yang Dibagikan",
|
||||
"menu.show_all_entries": "Tampilkan semua entri",
|
||||
"menu.show_only_starred_entries": "Show only starred entries",
|
||||
"menu.show_only_starred_entries": "Tampilkan hanya entri yang dimarkahkan",
|
||||
"menu.show_only_unread_entries": "Tampilkan hanya entri yang belum dibaca",
|
||||
"menu.starred": "Markah",
|
||||
"menu.title": "Menu",
|
||||
@@ -435,7 +449,7 @@
|
||||
"page.categories.no_feed": "Tidak ada umpan.",
|
||||
"page.categories.title": "Kategori",
|
||||
"page.categories_count": [
|
||||
"%d category"
|
||||
"%d kategori"
|
||||
],
|
||||
"page.category_label": "Category: %s",
|
||||
"page.edit_category.title": "Sunting Kategori: %s",
|
||||
@@ -451,9 +465,10 @@
|
||||
"%d galat"
|
||||
],
|
||||
"page.feeds.last_check": "Terakhir diperiksa:",
|
||||
"page.feeds.next_check": "Next check:",
|
||||
"page.feeds.next_check": "Akan diperiksa kembali:",
|
||||
"page.feeds.read_counter": "Jumlah entri yang telah dibaca",
|
||||
"page.feeds.title": "Umpan",
|
||||
"page.footer.elevator": "Back to top",
|
||||
"page.history.title": "Riwayat",
|
||||
"page.import.title": "Impor",
|
||||
"page.integration.bookmarklet": "Bookmarklet",
|
||||
@@ -498,16 +513,16 @@
|
||||
"page.keyboard_shortcuts.subtitle.pages": "Navigasi Halaman",
|
||||
"page.keyboard_shortcuts.subtitle.sections": "Navigasi Bagian",
|
||||
"page.keyboard_shortcuts.title": "Pintasan Papan Tik",
|
||||
"page.keyboard_shortcuts.toggle_bookmark_status": "Ubah status markah",
|
||||
"page.keyboard_shortcuts.toggle_star_status": "Ubah status markah",
|
||||
"page.keyboard_shortcuts.toggle_entry_attachments": "Buka/tutup lampiran entri",
|
||||
"page.keyboard_shortcuts.toggle_read_status_next": "Ubah status baca, fokus ke selanjutnya",
|
||||
"page.keyboard_shortcuts.toggle_read_status_prev": "Ubah status baca, fokus ke sebelumnya",
|
||||
"page.login.google_signin": "Masuk dengan Google",
|
||||
"page.login.oidc_signin": "Masuk dengan %s",
|
||||
"page.login.google_signin": "Masuk menggunakan Google",
|
||||
"page.login.oidc_signin": "Masuk menggunakan %s",
|
||||
"page.login.title": "Masuk",
|
||||
"page.login.webauthn_login": "Login with passkey",
|
||||
"page.login.webauthn_login.error": "Unable to login with passkey",
|
||||
"page.login.webauthn_login.help": "Please enter your username if you're using a security key. This is not required if you are using a Passkey (discoverable credentials).",
|
||||
"page.login.webauthn_login": "Masuk menggunakan passkey",
|
||||
"page.login.webauthn_login.error": "Tidak dapat masuk menggunakan passkey",
|
||||
"page.login.webauthn_login.help": "Mohon untuk memasukkan nama pengguna Anda jika Anda menggunakan kunci keamanan. Tidak diperlukan jika anda menggunakan Passkey (kredensial dapat ditemukan).",
|
||||
"page.new_api_key.title": "Kunci API Baru",
|
||||
"page.new_category.title": "Kategori Baru",
|
||||
"page.new_user.title": "Pengguna Baru",
|
||||
@@ -515,7 +530,7 @@
|
||||
"page.offline.refresh_page": "Coba untuk memuat ulang halaman ini",
|
||||
"page.offline.title": "Mode Luring",
|
||||
"page.read_entry_count": [
|
||||
"%d read entry"
|
||||
"%d entri dibaca"
|
||||
],
|
||||
"page.search.title": "Hasil Pencarian",
|
||||
"page.sessions.table.actions": "Tindakan",
|
||||
@@ -529,30 +544,30 @@
|
||||
"page.settings.title": "Pengaturan",
|
||||
"page.settings.unlink_google_account": "Putuskan akun Google saya",
|
||||
"page.settings.unlink_oidc_account": "Putuskan akun %s saya",
|
||||
"page.settings.webauthn.actions": "Actions",
|
||||
"page.settings.webauthn.added_on": "Added On",
|
||||
"page.settings.webauthn.actions": "Tindakan",
|
||||
"page.settings.webauthn.added_on": "Ditambahkan Pada",
|
||||
"page.settings.webauthn.delete": [
|
||||
"Remove %d passkey"
|
||||
"Hapus %d passkey"
|
||||
],
|
||||
"page.settings.webauthn.last_seen_on": "Last Used",
|
||||
"page.settings.webauthn.passkey_name": "Passkey Name",
|
||||
"page.settings.webauthn.passkeys": "Passkeys",
|
||||
"page.settings.webauthn.register": "Register passkey",
|
||||
"page.settings.webauthn.register.error": "Unable to register passkey",
|
||||
"page.settings.webauthn.last_seen_on": "Terakhir Digunakan",
|
||||
"page.settings.webauthn.passkey_name": "Nama Passkey",
|
||||
"page.settings.webauthn.passkeys": "Passkey",
|
||||
"page.settings.webauthn.register": "Daftar passkey",
|
||||
"page.settings.webauthn.register.error": "Tidak dapat mendaftarkan passkey",
|
||||
"page.shared_entries.title": "Entri yang Dibagikan",
|
||||
"page.shared_entries_count": [
|
||||
"%d shared entry"
|
||||
"%d entri yang dibagikan"
|
||||
],
|
||||
"page.starred.title": "Markah",
|
||||
"page.starred_entry_count": [
|
||||
"%d starred entry"
|
||||
"%d entri dimarkahi"
|
||||
],
|
||||
"page.total_entry_count": [
|
||||
"%d entry in total"
|
||||
"%d entri secara total"
|
||||
],
|
||||
"page.unread.title": "Belum Dibaca",
|
||||
"page.unread_entry_count": [
|
||||
"%d unread entry"
|
||||
"%d entri belum dibaca"
|
||||
],
|
||||
"page.users.actions": "Tindakan",
|
||||
"page.users.admin.no": "Tidak",
|
||||
@@ -562,15 +577,15 @@
|
||||
"page.users.never_logged": "Tidak Pernah",
|
||||
"page.users.title": "Pengguna",
|
||||
"page.users.username": "Nama Pengguna",
|
||||
"page.webauthn_rename.title": "Rename Passkey",
|
||||
"pagination.first": "First",
|
||||
"pagination.last": "Last",
|
||||
"page.webauthn_rename.title": "Ubah Nama Passkey",
|
||||
"pagination.first": "Pertama",
|
||||
"pagination.last": "Terakhir",
|
||||
"pagination.next": "Berikutnya",
|
||||
"pagination.previous": "Sebelumnya",
|
||||
"search.label": "Cari",
|
||||
"search.placeholder": "Cari...",
|
||||
"search.submit": "Search",
|
||||
"skip_to_content": "Skip to content",
|
||||
"search.submit": "Cari",
|
||||
"skip_to_content": "Langsung ke konten",
|
||||
"time_elapsed.days": [
|
||||
"%d hari yang lalu"
|
||||
],
|
||||
@@ -594,4 +609,4 @@
|
||||
"time_elapsed.yesterday": "kemarin",
|
||||
"tooltip.keyboard_shortcuts": "Pintasan Papan Tik: %s",
|
||||
"tooltip.logged_user": "Masuk sebagai %s"
|
||||
}
|
||||
}
|
||||
@@ -13,9 +13,9 @@
|
||||
"action.update": "Aggiorna",
|
||||
"alert.account_linked": "Il tuo account esterno ora è collegato!",
|
||||
"alert.account_unlinked": "Il tuo account esterno ora è scollegato!",
|
||||
"alert.background_feed_refresh": "All feeds are being refreshed in the background. You can continue to use Miniflux while this process is running.",
|
||||
"alert.background_feed_refresh": "Tutti i feed vengono aggiornati in background. Puoi continuare a usare Miniflux mentre questo processo è in esecuzione.",
|
||||
"alert.feed_error": "Sembra ci sia un problema con questo feed",
|
||||
"alert.no_bookmark": "Nessun preferito disponibile.",
|
||||
"alert.no_starred": "Nessun preferito disponibile.",
|
||||
"alert.no_category": "Nessuna categoria disponibile.",
|
||||
"alert.no_category_entry": "Questa categoria non contiene alcun articolo.",
|
||||
"alert.no_feed": "Nessun feed disponibile.",
|
||||
@@ -27,30 +27,29 @@
|
||||
"alert.no_tag_entry": "Non ci sono voci corrispondenti a questo tag.",
|
||||
"alert.no_unread_entry": "Nessun articolo da leggere.",
|
||||
"alert.no_user": "Tu sei l'unico utente.",
|
||||
"alert.pocket_linked": "Il tuo account Pocket ora è collegato!",
|
||||
"alert.prefs_saved": "Preferenze salvate!",
|
||||
"alert.too_many_feeds_refresh": [
|
||||
"You have triggered too many feed refreshes. Please wait %d minute before trying again.",
|
||||
"You have triggered too many feed refreshes. Please wait %d minutes before trying again."
|
||||
"Hai richiesto troppi aggiornamenti dei feed. Attendi %d minuto prima di riprovare.",
|
||||
"Hai richiesto troppi aggiornamenti dei feed. Attendi %d minuti prima di riprovare."
|
||||
],
|
||||
"confirm.loading": "In corso...",
|
||||
"confirm.no": "no",
|
||||
"confirm.question": "Sei sicuro?",
|
||||
"confirm.question.refresh": "Vuoi forzare l'aggiornamento?",
|
||||
"confirm.yes": "sì",
|
||||
"enclosure_media_controls.seek": "Seek:",
|
||||
"enclosure_media_controls.seek.title": "Seek %s seconds",
|
||||
"enclosure_media_controls.speed": "Speed:",
|
||||
"enclosure_media_controls.speed.faster": "Faster",
|
||||
"enclosure_media_controls.speed.faster.title": "Faster by %sx",
|
||||
"enclosure_media_controls.speed.reset": "Reset",
|
||||
"enclosure_media_controls.speed.reset.title": "Reset speed to 1x",
|
||||
"enclosure_media_controls.speed.slower": "Slower",
|
||||
"enclosure_media_controls.speed.slower.title": "Slower by %sx",
|
||||
"entry.bookmark.toast.off": "Non speciali",
|
||||
"entry.bookmark.toast.on": "Ha recitato",
|
||||
"entry.bookmark.toggle.off": "Rimuovi dai preferiti",
|
||||
"entry.bookmark.toggle.on": "Aggiungi ai preferiti",
|
||||
"enclosure_media_controls.seek": "Sposta:",
|
||||
"enclosure_media_controls.seek.title": "Sposta di %s secondi",
|
||||
"enclosure_media_controls.speed": "Velocità:",
|
||||
"enclosure_media_controls.speed.faster": "Più veloce",
|
||||
"enclosure_media_controls.speed.faster.title": "Più veloce di %sx",
|
||||
"enclosure_media_controls.speed.reset": "Reimposta",
|
||||
"enclosure_media_controls.speed.reset.title": "Reimposta velocità a 1x",
|
||||
"enclosure_media_controls.speed.slower": "Più lento",
|
||||
"enclosure_media_controls.speed.slower.title": "Più lento di %sx",
|
||||
"entry.starred.toast.off": "Non preferito",
|
||||
"entry.starred.toast.on": "Preferito",
|
||||
"entry.starred.toggle.off": "Rimuovi dai preferiti",
|
||||
"entry.starred.toggle.on": "Aggiungi ai preferiti",
|
||||
"entry.comments.label": "Commenti",
|
||||
"entry.comments.title": "Mostra i commenti",
|
||||
"entry.estimated_reading_time": [
|
||||
@@ -77,35 +76,39 @@
|
||||
"entry.status.toast.read": "Contrassegnato come letto",
|
||||
"entry.status.toast.unread": "Contrassegnato come non letto",
|
||||
"entry.tags.label": "Tag:",
|
||||
"entry.unshare.label": "Unshare",
|
||||
"entry.tags.more_tags_label": [
|
||||
"Mostra %d altro tag",
|
||||
"Mostra %d altri tag"
|
||||
],
|
||||
"entry.unshare.label": "Rimuovi condivisione",
|
||||
"error.api_key_already_exists": "Questa chiave API esiste già.",
|
||||
"error.bad_credentials": "Nome utente o password non validi.",
|
||||
"error.category_already_exists": "Questa categoria esiste già.",
|
||||
"error.category_not_found": "This category does not exist or does not belong to this user.",
|
||||
"error.database_error": "Database error: %v.",
|
||||
"error.category_not_found": "Questa categoria non esiste o non appartiene a questo utente.",
|
||||
"error.database_error": "Errore del database: %v.",
|
||||
"error.different_passwords": "Le password non coincidono.",
|
||||
"error.duplicate_fever_username": "Esiste già un account Fever con lo stesso nome utente!",
|
||||
"error.duplicate_googlereader_username": "Esiste già un account Google Reader con lo stesso nome utente!",
|
||||
"error.duplicate_linked_account": "Esiste già un account configurato per questo servizio!",
|
||||
"error.duplicated_feed": "This feed already exists.",
|
||||
"error.duplicated_feed": "Questo feed esiste già.",
|
||||
"error.empty_file": "Questo file è vuoto.",
|
||||
"error.entries_per_page_invalid": "Il numero di articoli per pagina non è valido.",
|
||||
"error.feed_already_exists": "Questo feed esiste già.",
|
||||
"error.feed_category_not_found": "Questa categoria non esiste o non appartiene a questo utente.",
|
||||
"error.feed_format_not_detected": "Unable to detect feed format: %v.",
|
||||
"error.feed_format_not_detected": "Impossibile rilevare il formato del feed: %v.",
|
||||
"error.feed_invalid_blocklist_rule": "La regola dell'elenco di blocco non è valida.",
|
||||
"error.feed_invalid_keeplist_rule": "La regola dell'elenco di conservazione non è valida.",
|
||||
"error.feed_mandatory_fields": "L'URL e la categoria sono obbligatori.",
|
||||
"error.feed_not_found": "This feed does not exist or does not belong to this user.",
|
||||
"error.feed_not_found": "Questo feed non esiste o non appartiene a questo utente.",
|
||||
"error.feed_title_not_empty": "Il titolo del feed non può essere vuoto.",
|
||||
"error.feed_url_not_empty": "L'URL del feed non può essere vuoto.",
|
||||
"error.fields_mandatory": "Tutti i campi sono obbligatori.",
|
||||
"error.http_bad_gateway": "The website is not available at the moment due to a bad gateway error. The problem is not on Miniflux side. Please, try again later.",
|
||||
"error.http_body_read": "Unable to read the HTTP body: %v.",
|
||||
"error.http_client_error": "HTTP client error: %v.",
|
||||
"error.http_empty_response": "The HTTP response is empty. Perhaps, this website is using a bot protection mechanism?",
|
||||
"error.http_empty_response_body": "The HTTP response body is empty.",
|
||||
"error.http_forbidden": "Access to this website is forbidden. Perhaps, this website has a bot protection mechanism?",
|
||||
"error.http_bad_gateway": "Il sito web non è disponibile al momento a causa di un errore di gateway. Il problema non è dal lato di Miniflux. Per favore, riprova più tardi.",
|
||||
"error.http_body_read": "Impossibile leggere il corpo HTTP: %v.",
|
||||
"error.http_client_error": "Errore del client HTTP: %v.",
|
||||
"error.http_empty_response": "La risposta HTTP è vuota. Forse questo sito web utilizza un meccanismo di protezione dai bot?",
|
||||
"error.http_empty_response_body": "Il corpo della risposta HTTP è vuoto.",
|
||||
"error.http_forbidden": "L'accesso a questo sito web è vietato. Forse questo sito web ha un meccanismo di protezione dai bot?",
|
||||
"error.http_gateway_timeout": "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.",
|
||||
@@ -119,19 +122,17 @@
|
||||
"error.invalid_display_mode": "Modalità di visualizzazione web app non valida.",
|
||||
"error.invalid_entry_direction": "Ordinamento non valido.",
|
||||
"error.invalid_entry_order": "L'ordinamento delle voci non è valido.",
|
||||
"error.invalid_feed_proxy_url": "Invalid proxy URL.",
|
||||
"error.invalid_feed_proxy_url": "URL del proxy non valido.",
|
||||
"error.invalid_feed_url": "URL del feed non valido.",
|
||||
"error.invalid_gesture_nav": "Navigazione gestuale non valida.",
|
||||
"error.invalid_language": "Lingua non valida.",
|
||||
"error.invalid_site_url": "URL del sito non valido.",
|
||||
"error.invalid_theme": "Tema non valido.",
|
||||
"error.invalid_timezone": "Fuso orario non valido.",
|
||||
"error.network_operation": "Miniflux 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 non riesce a raggiungere questo sito web a causa di un errore di rete: %v.",
|
||||
"error.network_timeout": "Questo sito web è troppo lento e la richiesta è scaduta: %v",
|
||||
"error.password_min_length": "La password deve contenere almeno 6 caratteri.",
|
||||
"error.pocket_access_token": "Non sono riuscito ad ottenere l'access token da Pocket!",
|
||||
"error.pocket_request_token": "Non sono riuscito ad ottenere il request token da Pocket!",
|
||||
"error.proxy_url_not_empty": "The proxy URL cannot be empty.",
|
||||
"error.proxy_url_not_empty": "L'URL del proxy non può essere vuoto.",
|
||||
"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",
|
||||
@@ -151,14 +152,15 @@
|
||||
"error.unable_to_create_api_key": "Impossibile creare questa chiave API.",
|
||||
"error.unable_to_create_category": "Non sono riuscito ad aggiungere questa categoria.",
|
||||
"error.unable_to_create_user": "Non sono riuscito ad aggiungere questo user.",
|
||||
"error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.",
|
||||
"error.unable_to_parse_feed": "Unable to parse this feed: %v.",
|
||||
"error.unable_to_detect_rssbridge": "Impossibile rilevare il feed usando RSS-Bridge: %v.",
|
||||
"error.unable_to_parse_feed": "Impossibile analizzare questo feed: %v.",
|
||||
"error.unable_to_update_category": "Non sono riuscito ad aggiornare questa categoria.",
|
||||
"error.unable_to_update_feed": "Non sono riuscito ad aggiornare questo feed.",
|
||||
"error.unable_to_update_user": "Non sono riuscito ad aggiornare questo utente.",
|
||||
"error.unlink_account_without_password": "Devi scegliere una password altrimenti la prossima volta non riuscirai ad accedere.",
|
||||
"error.user_already_exists": "Questo utente esiste già.",
|
||||
"error.user_mandatory_fields": "Il nome utente è obbligatorio.",
|
||||
"error.linktaco_missing_required_fields": "LinkTaco API Token e Organization Slug sono richiesti",
|
||||
"form.api_key.label.description": "Etichetta chiave API",
|
||||
"form.category.hide_globally": "Nascondere le voci nella lista globale dei non letti",
|
||||
"form.category.label.title": "Titolo",
|
||||
@@ -168,7 +170,8 @@
|
||||
"form.feed.fieldset.rules": "Rules",
|
||||
"form.feed.label.allow_self_signed_certificates": "Consenti certificati autofirmati o non validi",
|
||||
"form.feed.label.apprise_service_urls": "Comma separated list of Apprise service URLs",
|
||||
"form.feed.label.blocklist_rules": "Regole di blocco",
|
||||
"form.feed.label.block_filter_entry_rules": "Regole di Blocco delle Voci",
|
||||
"form.feed.label.blocklist_rules": "Filtri di Blocco Basati su Regex",
|
||||
"form.feed.label.category": "Categoria",
|
||||
"form.feed.label.cookie": "Installare i cookies",
|
||||
"form.feed.label.crawler": "Scarica il contenuto integrale",
|
||||
@@ -181,7 +184,8 @@
|
||||
"form.feed.label.fetch_via_proxy": "Usa il proxy configurato a livello di applicazione",
|
||||
"form.feed.label.hide_globally": "Nascondere le voci nella lista globale dei non letti",
|
||||
"form.feed.label.ignore_http_cache": "Ignora cache HTTP",
|
||||
"form.feed.label.keeplist_rules": "Regole di autorizzazione",
|
||||
"form.feed.label.keep_filter_entry_rules": "Regole di Permesso delle Voci",
|
||||
"form.feed.label.keeplist_rules": "Filtri di Mantenimento Basati su Regex",
|
||||
"form.feed.label.no_media_player": "No media player (audio/video)",
|
||||
"form.feed.label.ntfy_activate": "Push entries to ntfy",
|
||||
"form.feed.label.ntfy_default_priority": "Ntfy default priority",
|
||||
@@ -199,7 +203,7 @@
|
||||
"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.rewrite_rules": "Regole di impaginazione del contenuto",
|
||||
"form.feed.label.rewrite_rules": "Regole di Riscrittura del Contenuto",
|
||||
"form.feed.label.scraper_rules": "Regole di estrazione del contenuto",
|
||||
"form.feed.label.site_url": "URL del sito",
|
||||
"form.feed.label.title": "Titolo",
|
||||
@@ -233,6 +237,9 @@
|
||||
"form.integration.instapaper_activate": "Salva gli articoli su Instapaper",
|
||||
"form.integration.instapaper_password": "Password dell'account Instapaper",
|
||||
"form.integration.instapaper_username": "Nome utente dell'account Instapaper",
|
||||
"form.integration.karakeep_activate": "Salva gli articoli su Karakeep",
|
||||
"form.integration.karakeep_api_key": "API key dell'account Karakeep",
|
||||
"form.integration.karakeep_url": "Endpoint dell'API di Karakeep",
|
||||
"form.integration.linkace_activate": "Salva gli articoli su LinkAce",
|
||||
"form.integration.linkace_api_key": "API key dell'account LinkAce",
|
||||
"form.integration.linkace_check_disabled": "Disabilita i controlli",
|
||||
@@ -244,9 +251,19 @@
|
||||
"form.integration.linkding_bookmark": "Segna i preferiti come non letti",
|
||||
"form.integration.linkding_endpoint": "Endpoint dell'API di Linkding",
|
||||
"form.integration.linkding_tags": "Linkding Tags",
|
||||
"form.integration.linktaco_activate": "Salva le voci in LinkTaco",
|
||||
"form.integration.linktaco_api_token": "LinkTaco API Token",
|
||||
"form.integration.linktaco_api_token_hint": "Ottieni il tuo token di accesso personale su",
|
||||
"form.integration.linktaco_org_slug": "Organization Slug",
|
||||
"form.integration.linktaco_tags": "Tag (massimo 10, separati da virgola)",
|
||||
"form.integration.linktaco_tags_hint": "Massimo 10 tag, separati da virgola",
|
||||
"form.integration.linktaco_visibility": "Visibilità",
|
||||
"form.integration.linktaco_visibility_public": "Pubblico",
|
||||
"form.integration.linktaco_visibility_private": "Privato",
|
||||
"form.integration.linktaco_visibility_hint": "La visibilità PRIVATA richiede un account LinkTaco a pagamento",
|
||||
"form.integration.linkwarden_activate": "Salva gli articoli su Linkwarden",
|
||||
"form.integration.linkwarden_api_key": "API key dell'account Linkwarden",
|
||||
"form.integration.linkwarden_endpoint": "Endpoint dell'API di Linkwarden",
|
||||
"form.integration.linkwarden_endpoint": "URL di base di Linkwarden",
|
||||
"form.integration.matrix_bot_activate": "Trasferimento di nuovi articoli a Matrix",
|
||||
"form.integration.matrix_bot_chat_id": "ID della stanza Matrix",
|
||||
"form.integration.matrix_bot_password": "Password per l'utente Matrix",
|
||||
@@ -273,10 +290,6 @@
|
||||
"form.integration.pinboard_bookmark": "Segna i preferiti come non letti",
|
||||
"form.integration.pinboard_tags": "Tag di Pinboard",
|
||||
"form.integration.pinboard_token": "Token dell'API di Pinboard",
|
||||
"form.integration.pocket_access_token": "Access token dell'account Pocket",
|
||||
"form.integration.pocket_activate": "Salva gli articoli su Pocket",
|
||||
"form.integration.pocket_connect_link": "Collega il tuo account Pocket",
|
||||
"form.integration.pocket_consumer_key": "Consumer key dell'account Pocket",
|
||||
"form.integration.pushover_activate": "Push entries to Pushover",
|
||||
"form.integration.pushover_device": "Pushover device (optional)",
|
||||
"form.integration.pushover_prefix": "Pushover URL prefix (optional)",
|
||||
@@ -320,6 +333,7 @@
|
||||
"form.integration.wallabag_only_url": "Invia solo URL (invece del contenuto completo)",
|
||||
"form.integration.wallabag_password": "Password dell'account Wallabag",
|
||||
"form.integration.wallabag_username": "Nome utente dell'account Wallabag",
|
||||
"form.integration.wallabag_tags": "Wallabag Tags",
|
||||
"form.integration.webhook_activate": "Enable Webhooks",
|
||||
"form.integration.webhook_secret": "Webhooks Secret",
|
||||
"form.integration.webhook_url": "Default Webhook URL",
|
||||
@@ -328,6 +342,7 @@
|
||||
"form.prefs.fieldset.global_feed_settings": "Global Feed Settings",
|
||||
"form.prefs.fieldset.reader_settings": "Reader Settings",
|
||||
"form.prefs.help.external_font_hosts": "Space separated list of external font hosts to allow. For example: \"fonts.gstatic.com fonts.googleapis.com\".",
|
||||
"form.prefs.label.always_open_external_links": "Read articles by opening external links",
|
||||
"form.prefs.label.categories_sorting_order": "Ordinamento delle categorie",
|
||||
"form.prefs.label.cjk_reading_speed": "Velocità di lettura per cinese, coreano e giapponese (caratteri al minuto)",
|
||||
"form.prefs.label.custom_css": "CSS personalizzati",
|
||||
@@ -348,7 +363,7 @@
|
||||
"form.prefs.label.mark_read_on_view": "Contrassegna automaticamente le voci come lette quando visualizzate",
|
||||
"form.prefs.label.mark_read_on_view_or_media_completion": "Mark entries as read when viewed. For audio/video, mark as read at 90%% completion",
|
||||
"form.prefs.label.media_playback_rate": "Velocità di riproduzione dell'audio/video",
|
||||
"form.prefs.label.always_open_external_links": "Read articles by opening external links",
|
||||
"form.prefs.label.open_external_links_in_new_tab": "Apri i link esterni in una nuova scheda (aggiunge target=\"_blank\" ai link)",
|
||||
"form.prefs.label.show_reading_time": "Mostra il tempo di lettura stimato per gli articoli",
|
||||
"form.prefs.label.theme": "Tema",
|
||||
"form.prefs.label.timezone": "Fuso orario",
|
||||
@@ -459,6 +474,7 @@
|
||||
"page.feeds.next_check": "Next check:",
|
||||
"page.feeds.read_counter": "Numero di voci lette",
|
||||
"page.feeds.title": "Feed",
|
||||
"page.footer.elevator": "Back to top",
|
||||
"page.history.title": "Cronologia",
|
||||
"page.import.title": "Importa",
|
||||
"page.integration.bookmarklet": "Segnalibro",
|
||||
@@ -503,7 +519,7 @@
|
||||
"page.keyboard_shortcuts.subtitle.pages": "Navigazione pagine",
|
||||
"page.keyboard_shortcuts.subtitle.sections": "Navigazione sezioni",
|
||||
"page.keyboard_shortcuts.title": "Scorciatoie da tastiera",
|
||||
"page.keyboard_shortcuts.toggle_bookmark_status": "Aggiungi/rimuovi dai preferiti",
|
||||
"page.keyboard_shortcuts.toggle_star_status": "Aggiungi/rimuovi dai preferiti",
|
||||
"page.keyboard_shortcuts.toggle_entry_attachments": "Toggle open/close entry attachments",
|
||||
"page.keyboard_shortcuts.toggle_read_status_next": "Cambia lo stato di lettura (letto/da leggere), concentrati dopo",
|
||||
"page.keyboard_shortcuts.toggle_read_status_prev": "Cambia lo stato di lettura (letto/da leggere), focus precedente",
|
||||
@@ -574,14 +590,14 @@
|
||||
"page.users.title": "Utenti",
|
||||
"page.users.username": "Nome utente",
|
||||
"page.webauthn_rename.title": "Rename Passkey",
|
||||
"pagination.first": "First",
|
||||
"pagination.last": "Last",
|
||||
"pagination.first": "Primo",
|
||||
"pagination.last": "Ultimo",
|
||||
"pagination.next": "Successivo",
|
||||
"pagination.previous": "Precedente",
|
||||
"search.label": "Cerca",
|
||||
"search.placeholder": "Cerca...",
|
||||
"search.submit": "Search",
|
||||
"skip_to_content": "Skip to content",
|
||||
"search.submit": "Cerca",
|
||||
"skip_to_content": "Salta al contenuto",
|
||||
"time_elapsed.days": [
|
||||
"%d giorno fa",
|
||||
"%d giorni fa"
|
||||
@@ -611,4 +627,4 @@
|
||||
"time_elapsed.yesterday": "ieri",
|
||||
"tooltip.keyboard_shortcuts": "Scorciatoia da tastiera: %s",
|
||||
"tooltip.logged_user": "Autenticato come %s"
|
||||
}
|
||||
}
|
||||
@@ -13,9 +13,9 @@
|
||||
"action.update": "更新",
|
||||
"alert.account_linked": "外部アカウントとリンクされました!",
|
||||
"alert.account_unlinked": "外部アカウントとのリンクが解除されました!",
|
||||
"alert.background_feed_refresh": "All feeds are being refreshed in the background. You can continue to use Miniflux while this process is running.",
|
||||
"alert.background_feed_refresh": "すべてのフィードがバックグラウンドで更新されています。この処理中も Miniflux を使い続けることができます。",
|
||||
"alert.feed_error": "このフィードには問題があります。",
|
||||
"alert.no_bookmark": "現在星付きはありません。",
|
||||
"alert.no_starred": "現在星付きはありません。",
|
||||
"alert.no_category": "カテゴリが存在しません。",
|
||||
"alert.no_category_entry": "このカテゴリには記事がありません。",
|
||||
"alert.no_feed": "何も購読していません。",
|
||||
@@ -27,29 +27,28 @@
|
||||
"alert.no_tag_entry": "このタグに一致するエントリーはありません。",
|
||||
"alert.no_unread_entry": "未読の記事はありません。",
|
||||
"alert.no_user": "あなたが唯一のユーザーです。",
|
||||
"alert.pocket_linked": "Pocket アカウントとリンクされました!",
|
||||
"alert.prefs_saved": "設定情報は保存されました!",
|
||||
"alert.too_many_feeds_refresh": [
|
||||
"You have triggered too many feed refreshes. Please wait %d minute before trying again."
|
||||
"フィードの更新を要求しすぎました。%d 分後に再度お試しください。"
|
||||
],
|
||||
"confirm.loading": "実行中…",
|
||||
"confirm.no": "いいえ",
|
||||
"confirm.question": "よろしいですか?",
|
||||
"confirm.question.refresh": "強制的に更新しますか?",
|
||||
"confirm.yes": "はい",
|
||||
"enclosure_media_controls.seek": "Seek:",
|
||||
"enclosure_media_controls.seek.title": "Seek %s seconds",
|
||||
"enclosure_media_controls.speed": "Speed:",
|
||||
"enclosure_media_controls.speed.faster": "Faster",
|
||||
"enclosure_media_controls.speed.faster.title": "Faster by %sx",
|
||||
"enclosure_media_controls.speed.reset": "Reset",
|
||||
"enclosure_media_controls.speed.reset.title": "Reset speed to 1x",
|
||||
"enclosure_media_controls.speed.slower": "Slower",
|
||||
"enclosure_media_controls.speed.slower.title": "Slower by %sx",
|
||||
"entry.bookmark.toast.off": "星を外しました",
|
||||
"entry.bookmark.toast.on": "星を付けました",
|
||||
"entry.bookmark.toggle.off": "星を外す",
|
||||
"entry.bookmark.toggle.on": "星を付ける",
|
||||
"enclosure_media_controls.seek": "シーク:",
|
||||
"enclosure_media_controls.seek.title": "%s 秒シーク",
|
||||
"enclosure_media_controls.speed": "速度:",
|
||||
"enclosure_media_controls.speed.faster": "速く",
|
||||
"enclosure_media_controls.speed.faster.title": "%sx 速く",
|
||||
"enclosure_media_controls.speed.reset": "リセット",
|
||||
"enclosure_media_controls.speed.reset.title": "速度を1xにリセット",
|
||||
"enclosure_media_controls.speed.slower": "遅く",
|
||||
"enclosure_media_controls.speed.slower.title": "%sx 遅く",
|
||||
"entry.starred.toast.off": "星を外しました",
|
||||
"entry.starred.toast.on": "星を付けました",
|
||||
"entry.starred.toggle.off": "星を外す",
|
||||
"entry.starred.toggle.on": "星を付ける",
|
||||
"entry.comments.label": "コメント",
|
||||
"entry.comments.title": "コメントを見る",
|
||||
"entry.estimated_reading_time": [
|
||||
@@ -75,35 +74,38 @@
|
||||
"entry.status.toast.read": "既読にしました",
|
||||
"entry.status.toast.unread": "未読にしました",
|
||||
"entry.tags.label": "タグ:",
|
||||
"entry.tags.more_tags_label": [
|
||||
"%d 個のタグ"
|
||||
],
|
||||
"entry.unshare.label": "共有を解除",
|
||||
"error.api_key_already_exists": "この API キーは既に存在します。",
|
||||
"error.bad_credentials": "ユーザー名かパスワードが間違っています。",
|
||||
"error.category_already_exists": "このカテゴリは既に存在します。",
|
||||
"error.category_not_found": "This category does not exist or does not belong to this user.",
|
||||
"error.database_error": "Database error: %v.",
|
||||
"error.category_not_found": "このカテゴリは存在しないか、このユーザーに属していません。",
|
||||
"error.database_error": "データベースエラー: %v。",
|
||||
"error.different_passwords": "パスワードが一致しません。",
|
||||
"error.duplicate_fever_username": "既に同じ名前の Fever ユーザー名が使われています!",
|
||||
"error.duplicate_googlereader_username": "既に同じ名前の Google Reader ユーザー名が使われています!",
|
||||
"error.duplicate_linked_account": "別なユーザーが既にこのサービスの同じユーザーとリンクしています。",
|
||||
"error.duplicated_feed": "This feed already exists.",
|
||||
"error.duplicated_feed": "このフィードは既に存在します。",
|
||||
"error.empty_file": "このファイルは空です。",
|
||||
"error.entries_per_page_invalid": "ページあたりの記事数が無効です。",
|
||||
"error.feed_already_exists": "このフィードは既に存在します。",
|
||||
"error.feed_category_not_found": "このカテゴリは存在しないか、このユーザーに属していません。",
|
||||
"error.feed_format_not_detected": "Unable to detect feed format: %v.",
|
||||
"error.feed_format_not_detected": "フィードの形式を検出できません: %v.",
|
||||
"error.feed_invalid_blocklist_rule": "ブロックリストルールが無効です。",
|
||||
"error.feed_invalid_keeplist_rule": "リストの保持ルールが無効です。",
|
||||
"error.feed_mandatory_fields": "URL と カテゴリが必要です。",
|
||||
"error.feed_not_found": "This feed does not exist or does not belong to this user.",
|
||||
"error.feed_not_found": "このフィードは存在しないか、このユーザーに属していません。",
|
||||
"error.feed_title_not_empty": "フィードのタイトルを空にすることはできません。",
|
||||
"error.feed_url_not_empty": "フィード URL を空にすることはできません。",
|
||||
"error.fields_mandatory": "すべての項目が必要です。",
|
||||
"error.http_bad_gateway": "The website is not available at the moment due to a bad gateway error. The problem is not on Miniflux side. Please, try again later.",
|
||||
"error.http_body_read": "Unable to read the HTTP body: %v.",
|
||||
"error.http_client_error": "HTTP client error: %v.",
|
||||
"error.http_empty_response": "The HTTP response is empty. Perhaps, this website is using a bot protection mechanism?",
|
||||
"error.http_empty_response_body": "The HTTP response body is empty.",
|
||||
"error.http_forbidden": "Access to this website is forbidden. Perhaps, this website has a bot protection mechanism?",
|
||||
"error.http_bad_gateway": "ウェブサイトは、不正なゲートウェイエラーのため現在利用できません。問題はMiniflux側にはありません。後でもう一度お試しください。",
|
||||
"error.http_body_read": "HTTP本文を読み取れません: %v。",
|
||||
"error.http_client_error": "HTTPクライアントエラー: %v。",
|
||||
"error.http_empty_response": "HTTP応答が空です。おそらく、このウェブサイトはボット保護メカニズムを使用していますか?",
|
||||
"error.http_empty_response_body": "HTTP応答本文が空です。",
|
||||
"error.http_forbidden": "このウェブサイトへのアクセスは禁止されています。おそらく、このウェブサイトはボット保護メカニズムを持っていますか?",
|
||||
"error.http_gateway_timeout": "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.",
|
||||
@@ -117,19 +119,17 @@
|
||||
"error.invalid_display_mode": "Web アプリの表示モードが無効です。",
|
||||
"error.invalid_entry_direction": "記事の表示順が無効です。",
|
||||
"error.invalid_entry_order": "記事の表示順が無効です。",
|
||||
"error.invalid_feed_proxy_url": "Invalid proxy URL.",
|
||||
"error.invalid_feed_proxy_url": "プロキシURLが無効です。",
|
||||
"error.invalid_feed_url": "フィード URL が無効です。",
|
||||
"error.invalid_gesture_nav": "ジェスチャー ナビゲーションが無効です。",
|
||||
"error.invalid_language": "言語が無効です。",
|
||||
"error.invalid_site_url": "サイト URL が無効です。",
|
||||
"error.invalid_theme": "テーマが無効です。",
|
||||
"error.invalid_timezone": "タイムゾーンが無効です。",
|
||||
"error.network_operation": "Miniflux 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 はネットワークエラーのためこのウェブサイトに到達できません: %v.",
|
||||
"error.network_timeout": "このウェブサイトは応答が遅すぎるためタイムアウトしました: %v",
|
||||
"error.password_min_length": "パスワードは6文字以上である必要があります。",
|
||||
"error.pocket_access_token": "Pocket の access token が取得できません!",
|
||||
"error.pocket_request_token": "Pocket の request token が取得できません!",
|
||||
"error.proxy_url_not_empty": "The proxy URL cannot be empty.",
|
||||
"error.proxy_url_not_empty": "プロキシURLを空にすることはできません。",
|
||||
"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",
|
||||
@@ -149,14 +149,15 @@
|
||||
"error.unable_to_create_api_key": "この API キーを作成できません。",
|
||||
"error.unable_to_create_category": "このカテゴリは作成できません。",
|
||||
"error.unable_to_create_user": "このユーザーは作成できません。",
|
||||
"error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.",
|
||||
"error.unable_to_parse_feed": "Unable to parse this feed: %v.",
|
||||
"error.unable_to_detect_rssbridge": "RSS-Bridge を使ってフィードを検出できません: %v.",
|
||||
"error.unable_to_parse_feed": "このフィードを解析できません: %v.",
|
||||
"error.unable_to_update_category": "このカテゴリは更新できません。",
|
||||
"error.unable_to_update_feed": "このフィードは更新できません。",
|
||||
"error.unable_to_update_user": "このユーザーは更新できません。",
|
||||
"error.unlink_account_without_password": "パスワードを設定しなければ再びログインすることはできません。",
|
||||
"error.user_already_exists": "このユーザーは既に存在します。",
|
||||
"error.user_mandatory_fields": "ユーザー名が必要です。",
|
||||
"error.linktaco_missing_required_fields": "LinkTaco API TokenとOrganization Slugが必要です",
|
||||
"form.api_key.label.description": "API キーラベル",
|
||||
"form.category.hide_globally": "未読一覧に記事を表示しない",
|
||||
"form.category.label.title": "タイトル",
|
||||
@@ -166,7 +167,8 @@
|
||||
"form.feed.fieldset.rules": "Rules",
|
||||
"form.feed.label.allow_self_signed_certificates": "自己署名証明書または無効な証明書を許可する",
|
||||
"form.feed.label.apprise_service_urls": "Comma separated list of Apprise service URLs",
|
||||
"form.feed.label.blocklist_rules": "Block ルール",
|
||||
"form.feed.label.block_filter_entry_rules": "エントリブロッキングルール",
|
||||
"form.feed.label.blocklist_rules": "正規表現ベースのブロッキングフィルター",
|
||||
"form.feed.label.category": "カテゴリ",
|
||||
"form.feed.label.cookie": "Cookie の設定",
|
||||
"form.feed.label.crawler": "オリジナルの内容を取得",
|
||||
@@ -179,7 +181,8 @@
|
||||
"form.feed.label.fetch_via_proxy": "アプリケーションレベルで設定されたプロキシを使用する",
|
||||
"form.feed.label.hide_globally": "未読一覧に記事を表示しない",
|
||||
"form.feed.label.ignore_http_cache": "HTTPキャッシュを無視",
|
||||
"form.feed.label.keeplist_rules": "Keep ルール",
|
||||
"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",
|
||||
@@ -197,7 +200,7 @@
|
||||
"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.rewrite_rules": "Rewrite ルール",
|
||||
"form.feed.label.rewrite_rules": "コンテンツ書き換えルール",
|
||||
"form.feed.label.scraper_rules": "Scraper ルール",
|
||||
"form.feed.label.site_url": "サイト URL",
|
||||
"form.feed.label.title": "タイトル",
|
||||
@@ -231,6 +234,9 @@
|
||||
"form.integration.instapaper_activate": "Instapaper に記事を保存する",
|
||||
"form.integration.instapaper_password": "Instapaper のパスワード",
|
||||
"form.integration.instapaper_username": "Instapaper のユーザー名",
|
||||
"form.integration.karakeep_activate": "Karakeep に記事を保存する",
|
||||
"form.integration.karakeep_api_key": "Karakeep の API key",
|
||||
"form.integration.karakeep_url": "Karakeep の API Endpoint",
|
||||
"form.integration.linkace_activate": "Save entries to LinkAce",
|
||||
"form.integration.linkace_api_key": "LinkAce API key",
|
||||
"form.integration.linkace_check_disabled": "Disable link check",
|
||||
@@ -242,9 +248,19 @@
|
||||
"form.integration.linkding_bookmark": "ブックマークを未読にする",
|
||||
"form.integration.linkding_endpoint": "Linkding の API Endpoint",
|
||||
"form.integration.linkding_tags": "Linkding Tags",
|
||||
"form.integration.linktaco_activate": "LinkTacoでエントリを保存する",
|
||||
"form.integration.linktaco_api_token": "LinkTaco API Token",
|
||||
"form.integration.linktaco_api_token_hint": "パーソナルアクセストークンを取得する",
|
||||
"form.integration.linktaco_org_slug": "Organization Slug",
|
||||
"form.integration.linktaco_tags": "タグ (最大10件、カンマ区切り)",
|
||||
"form.integration.linktaco_tags_hint": "最大10件のタグ、カンマ区切り",
|
||||
"form.integration.linktaco_visibility": "公開設定",
|
||||
"form.integration.linktaco_visibility_public": "公開",
|
||||
"form.integration.linktaco_visibility_private": "非公開",
|
||||
"form.integration.linktaco_visibility_hint": "非公開設定には有料のLinkTacoアカウントが必要です",
|
||||
"form.integration.linkwarden_activate": "Linkwarden に記事を保存する",
|
||||
"form.integration.linkwarden_api_key": "Linkwarden の API key",
|
||||
"form.integration.linkwarden_endpoint": "Linkwarden の API Endpoint",
|
||||
"form.integration.linkwarden_endpoint": "リンクワーデン ベース URL",
|
||||
"form.integration.matrix_bot_activate": "新しい記事をMatrixに転送する",
|
||||
"form.integration.matrix_bot_chat_id": "MatrixルームのID",
|
||||
"form.integration.matrix_bot_password": "Matrixユーザ用パスワード",
|
||||
@@ -271,10 +287,6 @@
|
||||
"form.integration.pinboard_bookmark": "ブックマークを未読にする",
|
||||
"form.integration.pinboard_tags": "Pinboard の Tag",
|
||||
"form.integration.pinboard_token": "Pinboard の API Token",
|
||||
"form.integration.pocket_access_token": "Pocket の Access Token",
|
||||
"form.integration.pocket_activate": "Pocket に記事を保存する",
|
||||
"form.integration.pocket_connect_link": "Pocket account に接続",
|
||||
"form.integration.pocket_consumer_key": "Pocket の Consumer Key",
|
||||
"form.integration.pushover_activate": "Push entries to Pushover",
|
||||
"form.integration.pushover_device": "Pushover device (optional)",
|
||||
"form.integration.pushover_prefix": "Pushover URL prefix (optional)",
|
||||
@@ -318,6 +330,7 @@
|
||||
"form.integration.wallabag_only_url": "URL のみを送信 (完全なコンテンツではなく)",
|
||||
"form.integration.wallabag_password": "Wallabag のパスワード",
|
||||
"form.integration.wallabag_username": "Wallabag のユーザー名",
|
||||
"form.integration.wallabag_tags": "Wallabag Tags",
|
||||
"form.integration.webhook_activate": "Enable Webhooks",
|
||||
"form.integration.webhook_secret": "Webhooks Secret",
|
||||
"form.integration.webhook_url": "Default Webhook URL",
|
||||
@@ -326,6 +339,7 @@
|
||||
"form.prefs.fieldset.global_feed_settings": "Global Feed Settings",
|
||||
"form.prefs.fieldset.reader_settings": "Reader Settings",
|
||||
"form.prefs.help.external_font_hosts": "Space separated list of external font hosts to allow. For example: \"fonts.gstatic.com fonts.googleapis.com\".",
|
||||
"form.prefs.label.always_open_external_links": "Read articles by opening external links",
|
||||
"form.prefs.label.categories_sorting_order": "カテゴリの表示順",
|
||||
"form.prefs.label.cjk_reading_speed": "中国語、韓国語、日本語の読書速度(文字数/分)",
|
||||
"form.prefs.label.custom_css": "カスタム CSS",
|
||||
@@ -346,7 +360,7 @@
|
||||
"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.media_playback_rate": "オーディオ/ビデオの再生速度",
|
||||
"form.prefs.label.always_open_external_links": "Read articles by opening external links",
|
||||
"form.prefs.label.open_external_links_in_new_tab": "外部リンクを新しいタブで開く(リンクに target=\"_blank\" を追加)",
|
||||
"form.prefs.label.show_reading_time": "記事の推定読書時間を表示する",
|
||||
"form.prefs.label.theme": "テーマ",
|
||||
"form.prefs.label.timezone": "タイムゾーン",
|
||||
@@ -435,7 +449,7 @@
|
||||
"page.categories.no_feed": "フィードはありません。",
|
||||
"page.categories.title": "カテゴリ",
|
||||
"page.categories_count": [
|
||||
"%d category"
|
||||
"%d 件のカテゴリ"
|
||||
],
|
||||
"page.category_label": "Category: %s",
|
||||
"page.edit_category.title": "カテゴリを編集: %s",
|
||||
@@ -454,6 +468,7 @@
|
||||
"page.feeds.next_check": "Next check:",
|
||||
"page.feeds.read_counter": "既読記事の数",
|
||||
"page.feeds.title": "フィード一覧",
|
||||
"page.footer.elevator": "Back to top",
|
||||
"page.history.title": "履歴",
|
||||
"page.import.title": "インポート",
|
||||
"page.integration.bookmarklet": "ブックマークレット",
|
||||
@@ -498,7 +513,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": "既読/未読を切り替えて前のアイテムに移動",
|
||||
@@ -515,7 +530,7 @@
|
||||
"page.offline.refresh_page": "ページを更新してみてください",
|
||||
"page.offline.title": "オフラインモード",
|
||||
"page.read_entry_count": [
|
||||
"%d read entry"
|
||||
"%d 件の既読エントリ"
|
||||
],
|
||||
"page.search.title": "検索結果",
|
||||
"page.sessions.table.actions": "アクション",
|
||||
@@ -541,18 +556,18 @@
|
||||
"page.settings.webauthn.register.error": "パスキーを登録できません",
|
||||
"page.shared_entries.title": "共有エントリ",
|
||||
"page.shared_entries_count": [
|
||||
"%d shared entry"
|
||||
"%d 件の共有エントリ"
|
||||
],
|
||||
"page.starred.title": "星付き",
|
||||
"page.starred_entry_count": [
|
||||
"%d starred entry"
|
||||
"%d 件の星付きエントリ"
|
||||
],
|
||||
"page.total_entry_count": [
|
||||
"%d entry in total"
|
||||
"合計 %d 件のエントリ"
|
||||
],
|
||||
"page.unread.title": "未読",
|
||||
"page.unread_entry_count": [
|
||||
"%d unread entry"
|
||||
"%d 件の未読エントリ"
|
||||
],
|
||||
"page.users.actions": "アクション",
|
||||
"page.users.admin.no": "非管理者",
|
||||
@@ -563,14 +578,14 @@
|
||||
"page.users.title": "ユーザー一覧",
|
||||
"page.users.username": "ユーザー名",
|
||||
"page.webauthn_rename.title": "Rename Passkey",
|
||||
"pagination.first": "First",
|
||||
"pagination.last": "Last",
|
||||
"pagination.first": "最初",
|
||||
"pagination.last": "最後",
|
||||
"pagination.next": "次",
|
||||
"pagination.previous": "前",
|
||||
"search.label": "検索",
|
||||
"search.placeholder": "…を検索",
|
||||
"search.submit": "Search",
|
||||
"skip_to_content": "Skip to content",
|
||||
"search.submit": "検索",
|
||||
"skip_to_content": "コンテンツへスキップ",
|
||||
"time_elapsed.days": [
|
||||
"%d 日前"
|
||||
],
|
||||
@@ -594,4 +609,4 @@
|
||||
"time_elapsed.yesterday": "昨日",
|
||||
"tooltip.keyboard_shortcuts": "キーボードショートカット: %s",
|
||||
"tooltip.logged_user": "%s としてログイン中"
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,7 @@
|
||||
"alert.account_unlinked": "Kah lí ê gōa-pō͘ kháu-chō ê kiat í-keng phah khui--ah!",
|
||||
"alert.background_feed_refresh": "Tng leh pōe-āu ōaⁿ-sin só͘-ū siau-sit lâi-goân, lí ē-sái kè-sio̍k sú-iōng Miniflux。",
|
||||
"alert.feed_error": "Chit ê siau-sit lâi-goân ū būn-tôe",
|
||||
"alert.no_bookmark": "Chit-má ah bô siu-chông",
|
||||
"alert.no_starred": "Chit-má ah bô siu-chông",
|
||||
"alert.no_category": "Chit-má ah bô lūi-pia̍t",
|
||||
"alert.no_category_entry": "Chit ê lūi-pah ah bô siau-sit",
|
||||
"alert.no_feed": "Chit-má ah bô siau-sit lâi-goân",
|
||||
@@ -27,7 +27,6 @@
|
||||
"alert.no_tag_entry": "Bô kah chit ê khan-á ū hû-ha̍p ê siau-sit",
|
||||
"alert.no_unread_entry": "Chit-má ah-bô tha̍k kè ê siau-sit",
|
||||
"alert.no_user": "Lí sī ûi-it ê sú-iōng-lâng",
|
||||
"alert.pocket_linked": "Í-keng kā lí ê Pocket kháu-chō kiat chòe-hé--ah!",
|
||||
"alert.prefs_saved": "Siat-tēng í-keng pó-chûn--ah!",
|
||||
"alert.too_many_feeds_refresh": [
|
||||
"Lí í-keng ín-khí siuⁿ chōe pái siau-sit lâi-goân ōaⁿ-sin, chhiáⁿ tán-hāu %d hun-cheng āu koh chhì-khòaⁿ-māi."
|
||||
@@ -37,8 +36,8 @@
|
||||
"confirm.question": "Kám ū khak-tēng?",
|
||||
"confirm.question.refresh": "Kám beh kiông-chè têng lia̍h?",
|
||||
"confirm.yes": "Sī",
|
||||
"enclosure_media_controls.seek": "sóa-ūi:",
|
||||
"enclosure_media_controls.seek.title": "sóa %s bió",
|
||||
"enclosure_media_controls.seek": "Sóa-ūi:",
|
||||
"enclosure_media_controls.seek.title": "Sóa %s bió",
|
||||
"enclosure_media_controls.speed": "Sok-tō͘",
|
||||
"enclosure_media_controls.speed.faster": "Cheng-ka sok-tō͘",
|
||||
"enclosure_media_controls.speed.faster.title": "Cheng-ka sok-tō͘ %sx",
|
||||
@@ -46,10 +45,10 @@
|
||||
"enclosure_media_controls.speed.reset.title": "Têng siat-tēng pàng ê sok-tō͘ chòe 1x",
|
||||
"enclosure_media_controls.speed.slower": "Pàng bān",
|
||||
"enclosure_media_controls.speed.slower.title": "Pàng bān %sx",
|
||||
"entry.bookmark.toast.off": "Chhú-siau siu-chông chòe soah",
|
||||
"entry.bookmark.toast.on": "Sin cheng-ka siu-chông chòe soah",
|
||||
"entry.bookmark.toggle.off": "Chhú-siau siu-chông",
|
||||
"entry.bookmark.toggle.on": "Siu-chông khí-lâi",
|
||||
"entry.starred.toast.off": "Chhú-siau siu-chông chòe soah",
|
||||
"entry.starred.toast.on": "Sin cheng-ka siu-chông chòe soah",
|
||||
"entry.starred.toggle.off": "Chhú-siau siu-chông",
|
||||
"entry.starred.toggle.on": "Siu-chông khí-lâi",
|
||||
"entry.comments.label": "Hôe-èng",
|
||||
"entry.comments.title": "Khòaⁿ hôe-èng",
|
||||
"entry.estimated_reading_time": [
|
||||
@@ -75,6 +74,9 @@
|
||||
"entry.status.toast.read": "Chù chòe tha̍k kè chòe soah",
|
||||
"entry.status.toast.unread": "Chù chòe ah-bōe tha̍k chòe soah",
|
||||
"entry.tags.label": "Khan-á:",
|
||||
"entry.tags.more_tags_label": [
|
||||
"Kah %d khan-á"
|
||||
],
|
||||
"entry.unshare.label": "Chhú-siau hun-hióng",
|
||||
"error.api_key_already_exists": "Chit ê API só-sî í-keng chûn-chāi",
|
||||
"error.bad_credentials": "M̄-tio̍h ê kháu-chō miâ ah-sī bi̍t-bé.",
|
||||
@@ -117,7 +119,7 @@
|
||||
"error.invalid_display_mode": "Ū būn-tôe ê su-li̍p bô͘-sek.",
|
||||
"error.invalid_entry_direction": "Ū būn-tôe ê su-li̍p hong-hiòng.",
|
||||
"error.invalid_entry_order": "Siau-sit ê chōe pái bô-hāu, chhiáⁿ tán-hāu %d hun-cheng āu koh chhì-khòaⁿ-māi.",
|
||||
"error.invalid_feed_proxy_url": "Invalid proxy URL.",
|
||||
"error.invalid_feed_proxy_url": "Proxy URL ū būn-tôe.",
|
||||
"error.invalid_feed_url": "Beh tēng ê siau-sit lâi-goân ê bāng-chí ū būn-tôe.",
|
||||
"error.invalid_gesture_nav": "Chhiú-sè tō-lám ū būn-tôe.",
|
||||
"error.invalid_language": "Ū būn-tôe ê gú-giân.",
|
||||
@@ -127,9 +129,7 @@
|
||||
"error.network_operation": "Miniflux bô-hoat-tō͘ liân kàu chit ê bāng-chām, ū khó-lêng sī bāng-lō͘ būn-tôe: %v.",
|
||||
"error.network_timeout": "Chit ê bāng-chām ê hôe-èng siuⁿ bān, chhéng-kiû chhiau-kè sî-kan: %v.",
|
||||
"error.password_min_length": "Chhiáⁿ chì-chió ài su-li̍p la̍k ê lī goân.",
|
||||
"error.pocket_access_token": "Bô-hoat-tō͘ ùi Pocket thê tio̍h access token",
|
||||
"error.pocket_request_token": "Bô-hoat-tō͘ ùi Pocket thê tio̍h request token",
|
||||
"error.proxy_url_not_empty": "The proxy URL cannot be empty.",
|
||||
"error.proxy_url_not_empty": "Proxy URL bōe-sái sī khang--ê.",
|
||||
"error.settings_block_rule_fieldname_invalid": "Bô-hāu ê hong-só kui-chek: kui-chek #%d khiàm ū-hāu ê lân-ūi miâ (e-sai ê soán-hāng: %s)",
|
||||
"error.settings_block_rule_invalid_regex": "Bô-hāu ê hong-só kui-chek: kui-chek #%d ê bô͘-sek m̄ sī ha̍p-hoat ê chiàⁿ-kui piáu-ta̍t sek",
|
||||
"error.settings_block_rule_regex_required": "Bô-hāu ê hong-só kui-chek: kui-chek #%d bô thê-kiong chiàⁿ-kui piáu-ta̍t sek",
|
||||
@@ -157,6 +157,7 @@
|
||||
"error.unlink_account_without_password": "Lí it-tēng ài siat-tēng bi̍t-bé, bô lí ē bô-hoat-tō͘ koh teng-lo̍k.",
|
||||
"error.user_already_exists": "Chit ê sú-iōng-lâng í-keng chûn-chāi.",
|
||||
"error.user_mandatory_fields": "Tio̍h-ài su-li̍p kháu-chō miâ",
|
||||
"error.linktaco_missing_required_fields": "LinkTaco API Token kâh Organization Slug sio̍kêi",
|
||||
"form.api_key.label.description": "API só-sîkhan-á",
|
||||
"form.category.hide_globally": "Mài hián-sī siau-sit tī choân-he̍k ah-bōe tha̍k lia̍t-pió lāi",
|
||||
"form.category.label.title": "Piau-tôe",
|
||||
@@ -166,7 +167,8 @@
|
||||
"form.feed.fieldset.rules": "Kui-chek",
|
||||
"form.feed.label.allow_self_signed_certificates": "ún-chún chū chhiam ah-sī bô-hāu ê pîn-chèng",
|
||||
"form.feed.label.apprise_service_urls": "Sú-iōng tō͘-tiám keh khui ê Apprise ho̍k-bū bāng-chí lia̍t-pió",
|
||||
"form.feed.label.blocklist_rules": "Hong-só kui-chek",
|
||||
"form.feed.label.block_filter_entry_rules": "Entry Blocking Rules",
|
||||
"form.feed.label.blocklist_rules": "Regex-Based Blocking Filters",
|
||||
"form.feed.label.category": "lūi-pia̍t",
|
||||
"form.feed.label.cookie": "Siat-tēng Cookies",
|
||||
"form.feed.label.crawler": "Lia̍h goân-tóe lōe-iông",
|
||||
@@ -179,7 +181,8 @@
|
||||
"form.feed.label.fetch_via_proxy": "Iōng tī su-hāu-khì siat-tēng ê proxy",
|
||||
"form.feed.label.hide_globally": "Tī choân-he̍k ah-bōe tha̍k--ê lia̍t-pió am-khàm siau-sit",
|
||||
"form.feed.label.ignore_http_cache": "Pàng-ba̍k HTTP cache",
|
||||
"form.feed.label.keeplist_rules": "Pó-liû kui-chek",
|
||||
"form.feed.label.keep_filter_entry_rules": "Entry Allow Rules",
|
||||
"form.feed.label.keeplist_rules": "Regex-Based Keep Filters",
|
||||
"form.feed.label.no_media_player": "Bô mûi-thé hòng-sàng khì (im-sìn, sī-sìn)",
|
||||
"form.feed.label.ntfy_activate": "Thui-sàng siau-sit khì ntfy",
|
||||
"form.feed.label.ntfy_default_priority": "Ntfy ū-siat iu-sian sūn-sū",
|
||||
@@ -190,14 +193,14 @@
|
||||
"form.feed.label.ntfy_priority": "Ntfy iu-sian sūn-sū",
|
||||
"form.feed.label.ntfy_topic": "Ntfy topic (soán thiⁿ)",
|
||||
"form.feed.label.proxy_url": "Proxy URL",
|
||||
"form.feed.label.pushover_activate": "Push entries to Pushover",
|
||||
"form.feed.label.pushover_default_priority": "Default priority",
|
||||
"form.feed.label.pushover_high_priority": "High priority",
|
||||
"form.feed.label.pushover_low_priority": "Low priority",
|
||||
"form.feed.label.pushover_max_priority": "Max priority",
|
||||
"form.feed.label.pushover_min_priority": "Minimal priority",
|
||||
"form.feed.label.pushover_priority": "Pushover message priority",
|
||||
"form.feed.label.rewrite_rules": "Têng siá kui-chek",
|
||||
"form.feed.label.pushover_activate": "Pó-chûn siau-sit kàu pushover.net",
|
||||
"form.feed.label.pushover_default_priority": "Pushover ū-siat iu-sian sūn-sū",
|
||||
"form.feed.label.pushover_high_priority": "Pushover koân iu-sian sūn-sū",
|
||||
"form.feed.label.pushover_low_priority": "Pushover kē iu-sian sūn-sū",
|
||||
"form.feed.label.pushover_max_priority": "Pushover siōng koân iu-sian sūn-sū",
|
||||
"form.feed.label.pushover_min_priority": "Pushover siōng kē iu-sian sūn-sū",
|
||||
"form.feed.label.pushover_priority": "Pushover siau-sit iu-sian sūn-sū",
|
||||
"form.feed.label.rewrite_rules": "Content Rewrite Rules",
|
||||
"form.feed.label.scraper_rules": "Lia̍h ê kui-chek",
|
||||
"form.feed.label.site_url": "Bāng-chām bāng-chí",
|
||||
"form.feed.label.title": "Piau-tôe",
|
||||
@@ -231,6 +234,9 @@
|
||||
"form.integration.instapaper_activate": "Pó-chûn siau-sit kàu Instapaper",
|
||||
"form.integration.instapaper_password": "Instapaper bi̍t-bé",
|
||||
"form.integration.instapaper_username": "Instapaper Kháu-chō miâ",
|
||||
"form.integration.karakeep_activate": "Pó-chûn siau-sit kàu Karakeep",
|
||||
"form.integration.karakeep_api_key": "Karakeep API só-sî",
|
||||
"form.integration.karakeep_url": "Karakeep API thâu",
|
||||
"form.integration.linkace_activate": "Pó-chûn siau-sit kàu LinkAce",
|
||||
"form.integration.linkace_api_key": "LinkAce API só-sî",
|
||||
"form.integration.linkace_check_disabled": "Thêng iōng liân-kiat kiám-cha",
|
||||
@@ -242,9 +248,19 @@
|
||||
"form.integration.linkding_bookmark": "Chù chòe ah-bōe tha̍k",
|
||||
"form.integration.linkding_endpoint": "Linkding API thâu",
|
||||
"form.integration.linkding_tags": "Linkding khan-á",
|
||||
"form.integration.linktaco_activate": "Pó-chûn siau-sit kàu LinkTaco",
|
||||
"form.integration.linktaco_api_token": "LinkTaco API Token",
|
||||
"form.integration.linktaco_api_token_hint": "Chhú-tek lí ê kò-jîn chún-chhú token tī",
|
||||
"form.integration.linktaco_org_slug": "Organization Slug",
|
||||
"form.integration.linktaco_tags": "khan-á (siōn-koân 10, iōng tō͘-tiám keh khui)",
|
||||
"form.integration.linktaco_tags_hint": "Siōn-koân 10 khan-á, iōng tō͘-tiám keh khui",
|
||||
"form.integration.linktaco_visibility": "Kò-chhiah-kì sìa?",
|
||||
"form.integration.linktaco_visibility_public": "Kò-chhiah-kì",
|
||||
"form.integration.linktaco_visibility_private": "Su-lîn",
|
||||
"form.integration.linktaco_visibility_hint": "Su-lîn sìa tík tio̍h-ài chù-hêng LinkTaco kháu-chō",
|
||||
"form.integration.linkwarden_activate": "Pó-chûn siau-sit kàu Linkwarden",
|
||||
"form.integration.linkwarden_api_key": "Linkwarden API só-sî",
|
||||
"form.integration.linkwarden_endpoint": "Linkwarden API thâu",
|
||||
"form.integration.linkwarden_endpoint": "Linkwarden Base URL",
|
||||
"form.integration.matrix_bot_activate": "Thui-sàng siau-sit kàu Matrix",
|
||||
"form.integration.matrix_bot_chat_id": "Matrix pâng-keng ID",
|
||||
"form.integration.matrix_bot_password": "Matrix bi̍t-bé",
|
||||
@@ -271,15 +287,11 @@
|
||||
"form.integration.pinboard_bookmark": "Chù chòe ah-bōe tha̍k",
|
||||
"form.integration.pinboard_tags": "Pinboard khan-á",
|
||||
"form.integration.pinboard_token": "Pinboard API Token",
|
||||
"form.integration.pocket_access_token": "Pocket token",
|
||||
"form.integration.pocket_activate": "Pó-chûn siau-sit kàu Pocket",
|
||||
"form.integration.pocket_connect_link": "Kah Pocket kháu-chō kiat chòe-hé",
|
||||
"form.integration.pocket_consumer_key": "Pocket sú-iōng-lâng só-sî",
|
||||
"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.pushover_activate": "Pó-chûn siau-sit kàu Pushover",
|
||||
"form.integration.pushover_device": "Pushover ki-hì (soán thiⁿ)",
|
||||
"form.integration.pushover_prefix": "Pushover URL tó͘-bí (soán thiⁿ)",
|
||||
"form.integration.pushover_token": "Pushover application API só-sî",
|
||||
"form.integration.pushover_user": "Pushover sú-iōng-lâng só-sî",
|
||||
"form.integration.raindrop_activate": "Pó-chûn siau-sit kàu Raindrop",
|
||||
"form.integration.raindrop_collection_id": "Collection ID",
|
||||
"form.integration.raindrop_tags": "khan-á (iōng tō͘-tiám keh khui)",
|
||||
@@ -318,6 +330,7 @@
|
||||
"form.integration.wallabag_only_url": "Kan-na thoân bāng-chí (m̄ sī oân-chéng ê lōe-iông)",
|
||||
"form.integration.wallabag_password": "Wallabag bi̍t-bé",
|
||||
"form.integration.wallabag_username": "Wallabag kháu-chō miâ",
|
||||
"form.integration.wallabag_tags": "Wallabag Tags",
|
||||
"form.integration.webhook_activate": "Khai-sí iōng Webhooks",
|
||||
"form.integration.webhook_secret": "Webhooks Secret",
|
||||
"form.integration.webhook_url": "Default Webhook bāng-chí",
|
||||
@@ -326,6 +339,7 @@
|
||||
"form.prefs.fieldset.global_feed_settings": "Choân-he̍k siau-sit lâi-goân siat-tēng",
|
||||
"form.prefs.fieldset.reader_settings": "Ia̍t-tha̍k khì siat-tēng",
|
||||
"form.prefs.help.external_font_hosts": "Iōng khang-keh keh khui ún-chún ê gōa-pō͘ lī-hêng lâi-goân. Phì-lû \"fonts.gstatic.com fonts.googleapis.com\"",
|
||||
"form.prefs.label.always_open_external_links": "Chhiau-chhē bûn-chiong sī iōng gōa-pō͘ liân-kiat phah khui",
|
||||
"form.prefs.label.categories_sorting_order": "Lūi-pia̍t hián-sī sūn-sū",
|
||||
"form.prefs.label.cjk_reading_speed": "Tiong-bûn, Hân-bûn, Li̍t-bûn tha̍k ê sok-tō͘ (múi hun-cheng ē-sái tha̍k kúi ê lī-goân)",
|
||||
"form.prefs.label.custom_css": "Chū tēng ê CSS",
|
||||
@@ -346,15 +360,15 @@
|
||||
"form.prefs.label.mark_read_on_view": "Phah khui ê sî-chūn sūn-sòa kā siau-sit chù chòe tha̍k kè",
|
||||
"form.prefs.label.mark_read_on_view_or_media_completion": "Phah khui ê sî-chūn sūn-sòa kā siau-sit chù chòe tha̍k kè, m̄-koh nā-sī im-sìn, sī-sìn tio̍h tī hòng-sàng kàu 90%% ê si-chun chiah lâi chù",
|
||||
"form.prefs.label.media_playback_rate": "Im-sìn, sī-sìn pàng ê sok-tō͘",
|
||||
"form.prefs.label.always_open_external_links": "Read articles by opening external links",
|
||||
"form.prefs.label.open_external_links_in_new_tab": "Chhiau-chhē gōa-pō͘ liân-kiat sī tī sin ê ia̍h phah khui (kā liân-kiat chhē target=\"_blank\")",
|
||||
"form.prefs.label.show_reading_time": "Hián-sī siau-sit àn-sǹg ài gōa-kú lâi tha̍k",
|
||||
"form.prefs.label.theme": "Chú-tôe",
|
||||
"form.prefs.label.timezone": "Sî-khu",
|
||||
"form.prefs.select.alphabetical": "Chiàu lī-bú pâi",
|
||||
"form.prefs.select.browser": "Tī iû-lám khì phah khui",
|
||||
"form.prefs.select.browser": "Iû-lâm-khì",
|
||||
"form.prefs.select.created_time": "Siau-sit kiàn-li̍p sî-kan",
|
||||
"form.prefs.select.fullscreen": "Choân êng-bō͘",
|
||||
"form.prefs.select.minimal_ui": "Siōng iông-chhun--ê",
|
||||
"form.prefs.select.minimal_ui": "Siōng sió UI",
|
||||
"form.prefs.select.none": "Bô",
|
||||
"form.prefs.select.older_first": "Ùi kū--ê khai-sí pâi",
|
||||
"form.prefs.select.publish_time": "Siau-sit hoat-pò͘ sî-kan",
|
||||
@@ -406,7 +420,7 @@
|
||||
"page.about.author": "Chok-chiá: ",
|
||||
"page.about.build_date": "Kiàn-tì li̍t-kî:",
|
||||
"page.about.credits": "Pán-koân",
|
||||
"page.about.db_usage": "Database size:",
|
||||
"page.about.db_usage": "Database chhài-chhiú:",
|
||||
"page.about.git_commit": "Git Commit:",
|
||||
"page.about.global_config_options": "Choân-he̍k siat-tēng soán-hāng",
|
||||
"page.about.go_version": "Go pán-pún:",
|
||||
@@ -454,6 +468,7 @@
|
||||
"page.feeds.next_check": "Āu-pái kiám-cha sî-kan:",
|
||||
"page.feeds.read_counter": "Tha̍k kè--ê siau-sit sò͘",
|
||||
"page.feeds.title": "Siau-sit lâi-goân",
|
||||
"page.footer.elevator": "Back to top",
|
||||
"page.history.title": "Kì-lo̍k",
|
||||
"page.import.title": "Hōe-li̍p",
|
||||
"page.integration.bookmarklet": "Chheh-chhiam ke-si",
|
||||
@@ -498,7 +513,7 @@
|
||||
"page.keyboard_shortcuts.subtitle.pages": "Ia̍h bīn tō-lám",
|
||||
"page.keyboard_shortcuts.subtitle.sections": "Hun lân tō-lám",
|
||||
"page.keyboard_shortcuts.title": "Khoài-sok khí",
|
||||
"page.keyboard_shortcuts.toggle_bookmark_status": "Chhet-li̍p siu-chông chōng-thài",
|
||||
"page.keyboard_shortcuts.toggle_star_status": "Chhet-li̍p siu-chông chōng-thài",
|
||||
"page.keyboard_shortcuts.toggle_entry_attachments": "Chhet-li̍p thián khui kah siu-ha̍p siau-sit hù-kiāⁿ ê chōng-thài",
|
||||
"page.keyboard_shortcuts.toggle_read_status_next": "Chhet-li̍p tha̍k--kè, ah-bōe tha̍k ê chōng-thài, koh chiau-tiám tī āu-chi̍t--ê",
|
||||
"page.keyboard_shortcuts.toggle_read_status_prev": "Chhet-li̍p tha̍k--kè, ah-bōe tha̍k ê chōng-thài, koh chiau-tiám tī téng-chi̍t--ê",
|
||||
@@ -570,7 +585,7 @@
|
||||
"search.label": "Chhiau-chhē",
|
||||
"search.placeholder": "Chhiau-chhē...",
|
||||
"search.submit": "Chhiau-chhē",
|
||||
"skip_to_content": "Sóa kah chú-iàu ê lōe-iông",
|
||||
"skip_to_content": "Thiaⁿ--khì chhòng-bûn",
|
||||
"time_elapsed.days": [
|
||||
"%d kang chêng"
|
||||
],
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"action.cancel": "annuleren",
|
||||
"action.download": "Download",
|
||||
"action.download": "Downloaden",
|
||||
"action.edit": "Bewerken",
|
||||
"action.home_screen": "Toevoegen aan startscherm",
|
||||
"action.import": "Importeren",
|
||||
@@ -15,7 +15,7 @@
|
||||
"alert.account_unlinked": "Jouw externe account is nu ontkoppeld!",
|
||||
"alert.background_feed_refresh": "Alle feeds worden op de achtergrond vernieuwd. Je kunt Miniflux blijven gebruiker terwijl dit proces draait.",
|
||||
"alert.feed_error": "Er is een probleem met deze feed",
|
||||
"alert.no_bookmark": "Er zijn geen favorieten.",
|
||||
"alert.no_starred": "Er zijn geen favorieten.",
|
||||
"alert.no_category": "Er zijn geen categorieën.",
|
||||
"alert.no_category_entry": "Er zijn geen artikelen in deze categorie.",
|
||||
"alert.no_feed": "Je hebt nog geen feed geabonneerd.",
|
||||
@@ -27,7 +27,6 @@
|
||||
"alert.no_tag_entry": "Er zijn geen artikelen die overeenkomen met deze tag.",
|
||||
"alert.no_unread_entry": "Er zijn geen ongelezen artikelen.",
|
||||
"alert.no_user": "Je bent de enige gebruiker.",
|
||||
"alert.pocket_linked": "Jouw Pocket-account is nu gekoppeld!",
|
||||
"alert.prefs_saved": "Instellingen opgeslagen!",
|
||||
"alert.too_many_feeds_refresh": [
|
||||
"Je hebt te veel feed-vernieuwingen getriggered. Wacht aub %d minuut voor opnieuw proberen.",
|
||||
@@ -43,14 +42,14 @@
|
||||
"enclosure_media_controls.speed": "Snelheid:",
|
||||
"enclosure_media_controls.speed.faster": "Versnel",
|
||||
"enclosure_media_controls.speed.faster.title": "Versnel met %sx",
|
||||
"enclosure_media_controls.speed.reset": "Reset",
|
||||
"enclosure_media_controls.speed.reset": "Resetten",
|
||||
"enclosure_media_controls.speed.reset.title": "Reset snelheid naar 1x",
|
||||
"enclosure_media_controls.speed.slower": "Vertraag",
|
||||
"enclosure_media_controls.speed.slower.title": "Vertraag met %sx",
|
||||
"entry.bookmark.toast.off": "Favoriet verwijderd",
|
||||
"entry.bookmark.toast.on": "Favoriet toegevoegd",
|
||||
"entry.bookmark.toggle.off": "Favoriet verwijderen",
|
||||
"entry.bookmark.toggle.on": "Favoriet",
|
||||
"entry.starred.toast.off": "Favoriet verwijderd",
|
||||
"entry.starred.toast.on": "Favoriet toegevoegd",
|
||||
"entry.starred.toggle.off": "Favoriet verwijderen",
|
||||
"entry.starred.toggle.on": "Favoriet",
|
||||
"entry.comments.label": "Reacties",
|
||||
"entry.comments.title": "Bekijk reacties",
|
||||
"entry.estimated_reading_time": [
|
||||
@@ -77,6 +76,10 @@
|
||||
"entry.status.toast.read": "Gemarkeerd als gelezen",
|
||||
"entry.status.toast.unread": "Gemarkeerd als ongelezen",
|
||||
"entry.tags.label": "Tags:",
|
||||
"entry.tags.more_tags_label": [
|
||||
"Toon %d extra tag",
|
||||
"Toon %d extra tags"
|
||||
],
|
||||
"entry.unshare.label": "Delen ongedaan maken",
|
||||
"error.api_key_already_exists": "Deze API-sleutel bestaat al.",
|
||||
"error.bad_credentials": "Onjuiste gebruikersnaam of wachtwoord.",
|
||||
@@ -119,7 +122,7 @@
|
||||
"error.invalid_display_mode": "Ongeldige weergavemodus voor de webapp.",
|
||||
"error.invalid_entry_direction": "Ongeldige sorteervolgorde.",
|
||||
"error.invalid_entry_order": "Ongeldige volgorde van artikelen.",
|
||||
"error.invalid_feed_proxy_url": "Invalid proxy URL.",
|
||||
"error.invalid_feed_proxy_url": "Ongeldige proxy-URL.",
|
||||
"error.invalid_feed_url": "Ongeldige feed URL.",
|
||||
"error.invalid_gesture_nav": "Ongeldige gebarennavigatie.",
|
||||
"error.invalid_language": "Ongeldige taal.",
|
||||
@@ -129,14 +132,12 @@
|
||||
"error.network_operation": "Miniflux kan deze website niet bereiken vanwege een netwerkfout: %v.",
|
||||
"error.network_timeout": "Deze website is te traag en de aanvraag gaf timeout: %v",
|
||||
"error.password_min_length": "Minimaal 6 tekens gebruiken.",
|
||||
"error.pocket_access_token": "Kon geen toegangstoken ophalen van Pocket!",
|
||||
"error.pocket_request_token": "Kon geen aanvraagtoken ophalen van Pocket!",
|
||||
"error.proxy_url_not_empty": "The proxy URL cannot be empty.",
|
||||
"error.proxy_url_not_empty": "De proxy-URL mag niet leeg zijn.",
|
||||
"error.settings_block_rule_fieldname_invalid": "Ongeldige blokkeerregel: regel #%d mist een geldige veldnaam (Opties: %s)",
|
||||
"error.settings_block_rule_invalid_regex": "Ongeldige blokkeerregel: het patroon van regel #%d is geen geldige regex",
|
||||
"error.settings_block_rule_regex_required": "Ongeldige blokkeerregel: het patroon van regel #%d is niet opgegeven",
|
||||
"error.settings_block_rule_separator_required": "Ongeldige blokkeerregel: het patroon van regel #%d moet worden gescheiden door een '='",
|
||||
"error.settings_invalid_domain_list": "Invalid domain list. Please provide a space separated list of domains.",
|
||||
"error.settings_invalid_domain_list": "Ongeldige domeinlijst. Geef een spatiegescheiden lijst van domeinen op.",
|
||||
"error.settings_keep_rule_fieldname_invalid": "Ongeldige bewaarregel: regel #%d mist een geldige veldnaam (Options: %s)",
|
||||
"error.settings_keep_rule_invalid_regex": "Ongeldige bewaarregel: het patroon van regel #%d is geen geldige regex",
|
||||
"error.settings_keep_rule_regex_required": "Ongeldige bewaarregel: het patroon van regel #%d is niet opgegeven",
|
||||
@@ -159,6 +160,7 @@
|
||||
"error.unlink_account_without_password": "Je moet een wachtwoord opgeven anders kun je niet meer inloggen.",
|
||||
"error.user_already_exists": "Deze gebruiker bestaat al.",
|
||||
"error.user_mandatory_fields": "Gebruikersnaam is verplicht",
|
||||
"error.linktaco_missing_required_fields": "LinkTaco API Token en Organization Slug zijn verplicht",
|
||||
"form.api_key.label.description": "API-sleutel omschrijving",
|
||||
"form.category.hide_globally": "Verberg artikelen in de globale ongelezen lijst",
|
||||
"form.category.label.title": "Titel",
|
||||
@@ -168,7 +170,8 @@
|
||||
"form.feed.fieldset.rules": "Regels",
|
||||
"form.feed.label.allow_self_signed_certificates": "Zelfondertekende of ongeldige certificaten toestaan",
|
||||
"form.feed.label.apprise_service_urls": "Door komma's gescheiden lijst van Apprise service URL's",
|
||||
"form.feed.label.blocklist_rules": "Blokkeerregels",
|
||||
"form.feed.label.block_filter_entry_rules": "Blokkeerregels voor Items",
|
||||
"form.feed.label.blocklist_rules": "Regex-gebaseerde Blokkeerfilters",
|
||||
"form.feed.label.category": "Categorie",
|
||||
"form.feed.label.cookie": "Cookies instellen",
|
||||
"form.feed.label.crawler": "Download originele inhoud",
|
||||
@@ -181,7 +184,8 @@
|
||||
"form.feed.label.fetch_via_proxy": "Gebruik de proxy die op applicatieniveau is geconfigureerd",
|
||||
"form.feed.label.hide_globally": "Verberg artikelen in de globale ongelezen lijst",
|
||||
"form.feed.label.ignore_http_cache": "Negeer HTTP-cache",
|
||||
"form.feed.label.keeplist_rules": "Bewaarregels",
|
||||
"form.feed.label.keep_filter_entry_rules": "Toestaan Regels voor Items",
|
||||
"form.feed.label.keeplist_rules": "Regex-gebaseerde Bewaarfilters",
|
||||
"form.feed.label.no_media_player": "Geen mediaspeler (audio/video)",
|
||||
"form.feed.label.ntfy_activate": "Artikelen naar ntfy sturen",
|
||||
"form.feed.label.ntfy_default_priority": "Ntfy standaard prioriteit",
|
||||
@@ -190,16 +194,16 @@
|
||||
"form.feed.label.ntfy_max_priority": "Ntfy maximale prioriteit",
|
||||
"form.feed.label.ntfy_min_priority": "Ntfy minimale prioriteit",
|
||||
"form.feed.label.ntfy_priority": "Ntfy prioriteit",
|
||||
"form.feed.label.ntfy_topic": "Ntfy topic (optional)",
|
||||
"form.feed.label.ntfy_topic": "Ntfy onderwerp (optioneel)",
|
||||
"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.rewrite_rules": "Herschrijfregels",
|
||||
"form.feed.label.pushover_activate": "Stuur artikelen naar pushover.net",
|
||||
"form.feed.label.pushover_default_priority": "Pushover standaard prioriteit",
|
||||
"form.feed.label.pushover_high_priority": "Pushover hoge prioriteit",
|
||||
"form.feed.label.pushover_low_priority": "Pushover lage prioriteit",
|
||||
"form.feed.label.pushover_max_priority": "Pushover maximale prioriteit",
|
||||
"form.feed.label.pushover_min_priority": "Pushover minimale prioriteit",
|
||||
"form.feed.label.pushover_priority": "Pushover berichtprioriteit",
|
||||
"form.feed.label.rewrite_rules": "Inhoud Herschrijfregels",
|
||||
"form.feed.label.scraper_rules": "Extractieregels",
|
||||
"form.feed.label.site_url": "Website URL",
|
||||
"form.feed.label.title": "Titel",
|
||||
@@ -214,8 +218,8 @@
|
||||
"form.integration.betula_activate": "Artikelen opslaan in Betula",
|
||||
"form.integration.betula_token": "Betula Token",
|
||||
"form.integration.betula_url": "Betula server URL",
|
||||
"form.integration.cubox_activate": "Save entries to Cubox",
|
||||
"form.integration.cubox_api_link": "Cubox API link",
|
||||
"form.integration.cubox_activate": "Artikelen opslaan in Cubox",
|
||||
"form.integration.cubox_api_link": "Cubox API-link",
|
||||
"form.integration.discord_activate": "Artikelen opslaan in Discord",
|
||||
"form.integration.discord_webhook_link": "Discord Webhook link",
|
||||
"form.integration.espial_activate": "Artikelen opslaan in Espial",
|
||||
@@ -233,6 +237,9 @@
|
||||
"form.integration.instapaper_activate": "Artikelen opslaan in Instapaper",
|
||||
"form.integration.instapaper_password": "Instapaper wachtwoord",
|
||||
"form.integration.instapaper_username": "Instapaper gebruikersnaam",
|
||||
"form.integration.karakeep_activate": "Artikelen opslaan in Karakeep",
|
||||
"form.integration.karakeep_api_key": "Karakeep API-sleutel",
|
||||
"form.integration.karakeep_url": "Karakeep URL",
|
||||
"form.integration.linkace_activate": "Artikelen opslaan in LinkAce",
|
||||
"form.integration.linkace_api_key": "LinkAce API-sleutel",
|
||||
"form.integration.linkace_check_disabled": "Koppelingcontrole uitschakelen",
|
||||
@@ -244,9 +251,19 @@
|
||||
"form.integration.linkding_bookmark": "Markeer favoriet als ongelezen",
|
||||
"form.integration.linkding_endpoint": "Linkding URL",
|
||||
"form.integration.linkding_tags": "Linkding tags",
|
||||
"form.integration.linktaco_activate": "Artikelen opslaan in LinkTaco",
|
||||
"form.integration.linktaco_api_token": "LinkTaco API Token",
|
||||
"form.integration.linktaco_api_token_hint": "Verkrijg uw persoonlijke toegangstoken op",
|
||||
"form.integration.linktaco_org_slug": "Organization Slug",
|
||||
"form.integration.linktaco_tags": "Tags (max 10, kommagescheiden)",
|
||||
"form.integration.linktaco_tags_hint": "Maximaal 10 tags, kommagescheiden",
|
||||
"form.integration.linktaco_visibility": "Zichtbaarheid",
|
||||
"form.integration.linktaco_visibility_public": "Openbaar",
|
||||
"form.integration.linktaco_visibility_private": "Privé",
|
||||
"form.integration.linktaco_visibility_hint": "PRIVÉ zichtbaarheid vereist een betaald LinkTaco account",
|
||||
"form.integration.linkwarden_activate": "Artikelen opslaan in Linkwarden",
|
||||
"form.integration.linkwarden_api_key": "Linkwarden API-sleutel",
|
||||
"form.integration.linkwarden_endpoint": "Linkwarden URL",
|
||||
"form.integration.linkwarden_endpoint": "Linkwarden Basis URL",
|
||||
"form.integration.matrix_bot_activate": "Nieuwe artikelen opslaan in Matrix",
|
||||
"form.integration.matrix_bot_chat_id": "ID van Matrix-kamer",
|
||||
"form.integration.matrix_bot_password": "Wachtwoord voor Matrix-gebruiker",
|
||||
@@ -258,7 +275,7 @@
|
||||
"form.integration.ntfy_activate": "Stuur artikelen naar ntfy",
|
||||
"form.integration.ntfy_api_token": "Ntfy API Token (optioneel)",
|
||||
"form.integration.ntfy_icon_url": "Ntfy Icon URL (optioneel)",
|
||||
"form.integration.ntfy_internal_links": "Use internal links on click (optional)",
|
||||
"form.integration.ntfy_internal_links": "Gebruik interne links bij klikken (optioneel)",
|
||||
"form.integration.ntfy_password": "Ntfy wachtwoord (optioneel)",
|
||||
"form.integration.ntfy_topic": "Ntfy topic (standaard gebruikt als deze niet is ingesteld in feed)",
|
||||
"form.integration.ntfy_url": "Ntfy URL (optioneel, standaard is ntfy.sh)",
|
||||
@@ -273,10 +290,6 @@
|
||||
"form.integration.pinboard_bookmark": "Markeer favoriet als ongelezen",
|
||||
"form.integration.pinboard_tags": "Pinboard tags",
|
||||
"form.integration.pinboard_token": "Pinboard API token",
|
||||
"form.integration.pocket_access_token": "Pocket Access Token",
|
||||
"form.integration.pocket_activate": "Artikelen opslaan in Pocket",
|
||||
"form.integration.pocket_connect_link": "Verbind je Pocket-account",
|
||||
"form.integration.pocket_consumer_key": "Pocket Consumer Key",
|
||||
"form.integration.pushover_activate": "Push entries to Pushover",
|
||||
"form.integration.pushover_device": "Pushover device (optional)",
|
||||
"form.integration.pushover_prefix": "Pushover URL prefix (optional)",
|
||||
@@ -320,6 +333,7 @@
|
||||
"form.integration.wallabag_only_url": "Alleen URL verzenden (in plaats van volledige inhoud)",
|
||||
"form.integration.wallabag_password": "Wallabag wachtwoord",
|
||||
"form.integration.wallabag_username": "Wallabag gebruikersnaam",
|
||||
"form.integration.wallabag_tags": "Wallabag Tags",
|
||||
"form.integration.webhook_activate": "Webhooks activeren",
|
||||
"form.integration.webhook_secret": "Webhooks Secret",
|
||||
"form.integration.webhook_url": "Standard Webhook URL",
|
||||
@@ -327,7 +341,8 @@
|
||||
"form.prefs.fieldset.authentication_settings": "Authenticatie Instellingen",
|
||||
"form.prefs.fieldset.global_feed_settings": "Globale Feed Instellingen",
|
||||
"form.prefs.fieldset.reader_settings": "Lees Instellingen",
|
||||
"form.prefs.help.external_font_hosts": "Space separated list of external font hosts to allow. For example: \"fonts.gstatic.com fonts.googleapis.com\".",
|
||||
"form.prefs.help.external_font_hosts": "Spatiegescheiden lijst van externe font-hosts die zijn toegestaan. Bijvoorbeeld: 'fonts.gstatic.com fonts.googleapis.com'.",
|
||||
"form.prefs.label.always_open_external_links": "Lees artikelen door externe links te openen",
|
||||
"form.prefs.label.categories_sorting_order": "Volgorde categorieën",
|
||||
"form.prefs.label.cjk_reading_speed": "Leessnelheid voor Chinees, Koreaans en Japans (tekens per minuut)",
|
||||
"form.prefs.label.custom_css": "Aangepaste CSS",
|
||||
@@ -339,7 +354,7 @@
|
||||
"form.prefs.label.entry_order": "Artikelen sorteren",
|
||||
"form.prefs.label.entry_sorting": "Volgorde van artikelen",
|
||||
"form.prefs.label.entry_swipe": "Vegen tussen artikelen inschakelen op aanraakschermen",
|
||||
"form.prefs.label.external_font_hosts": "External font hosts",
|
||||
"form.prefs.label.external_font_hosts": "Externe font-hosts",
|
||||
"form.prefs.label.gesture_nav": "Gebaar om tussen artikelen te navigeren",
|
||||
"form.prefs.label.keyboard_shortcuts": "Sneltoetsen inschakelen",
|
||||
"form.prefs.label.language": "Taal",
|
||||
@@ -348,7 +363,7 @@
|
||||
"form.prefs.label.mark_read_on_view": "Markeer artikelen automatisch als gelezen wanneer ze worden bekeken",
|
||||
"form.prefs.label.mark_read_on_view_or_media_completion": "Markeer artikelen als gelezen wanneer ze worden bekeken. Voor audio/video, markeer als gelezen bij 90%% voltooiing",
|
||||
"form.prefs.label.media_playback_rate": "Afspeelsnelheid van de audio/video",
|
||||
"form.prefs.label.always_open_external_links": "Read articles by opening external links",
|
||||
"form.prefs.label.open_external_links_in_new_tab": "Open externe links in een nieuw tabblad (voegt target=\"_blank\" toe aan links)",
|
||||
"form.prefs.label.show_reading_time": "Toon geschatte leestijd van artikelen",
|
||||
"form.prefs.label.theme": "Thema",
|
||||
"form.prefs.label.timezone": "Tijdzone",
|
||||
@@ -408,7 +423,7 @@
|
||||
"page.about.author": "Auteur:",
|
||||
"page.about.build_date": "Compilatiedatum:",
|
||||
"page.about.credits": "Credits",
|
||||
"page.about.db_usage": "Database size:",
|
||||
"page.about.db_usage": "Databasegrootte:",
|
||||
"page.about.git_commit": "Git Commit:",
|
||||
"page.about.global_config_options": "Globale Configuratie Opties",
|
||||
"page.about.go_version": "Go versie:",
|
||||
@@ -459,6 +474,7 @@
|
||||
"page.feeds.next_check": "Volgende controle:",
|
||||
"page.feeds.read_counter": "Aantal gelezen artikelen",
|
||||
"page.feeds.title": "Feeds",
|
||||
"page.footer.elevator": "Back to top",
|
||||
"page.history.title": "Geschiedenis",
|
||||
"page.import.title": "Importeren",
|
||||
"page.integration.bookmarklet": "Bookmarklet",
|
||||
@@ -503,7 +519,7 @@
|
||||
"page.keyboard_shortcuts.subtitle.pages": "Navigeren door pagina's",
|
||||
"page.keyboard_shortcuts.subtitle.sections": "Navigeren door menu's",
|
||||
"page.keyboard_shortcuts.title": "Sneltoetsen",
|
||||
"page.keyboard_shortcuts.toggle_bookmark_status": "Favoriet toevoegen/verwijderen",
|
||||
"page.keyboard_shortcuts.toggle_star_status": "Favoriet toevoegen/verwijderen",
|
||||
"page.keyboard_shortcuts.toggle_entry_attachments": "Bijlagen van artikel openen/sluiten",
|
||||
"page.keyboard_shortcuts.toggle_read_status_next": "Markeer gelezen/ongelezen, focus volgende",
|
||||
"page.keyboard_shortcuts.toggle_read_status_prev": "Markeer gelezen/ongelezen, focus vorige",
|
||||
@@ -512,7 +528,7 @@
|
||||
"page.login.title": "Inloggen",
|
||||
"page.login.webauthn_login": "Inloggen met passkey",
|
||||
"page.login.webauthn_login.error": "Kan niet inloggen met passkey",
|
||||
"page.login.webauthn_login.help": "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": "Voer je gebruikersnaam in als je een beveiligingssleutel gebruikt. Dit is niet nodig als je een Passkey (ontdekkingsbare referenties) gebruikt.",
|
||||
"page.new_api_key.title": "Nieuwe API-sleutel",
|
||||
"page.new_category.title": "Nieuwe categorie",
|
||||
"page.new_user.title": "Nieuwe gebruiker",
|
||||
@@ -611,4 +627,4 @@
|
||||
"time_elapsed.yesterday": "gisteren",
|
||||
"tooltip.keyboard_shortcuts": "Sneltoets: %s",
|
||||
"tooltip.logged_user": "Ingelogd als %s"
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user