Compare commits
208 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
@@ -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
|
||||
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
|
||||
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/checkout@v5
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.24.x"
|
||||
go-version: stable
|
||||
- uses: golangci/golangci-lint-action@v8
|
||||
with:
|
||||
args: >
|
||||
@@ -43,7 +43,7 @@ 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
|
||||
|
||||
@@ -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
|
||||
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
|
||||
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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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{
|
||||
|
||||
+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
|
||||
|
||||
@@ -4,24 +4,23 @@ module miniflux.app/v2
|
||||
|
||||
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.13.4
|
||||
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.39.0
|
||||
golang.org/x/image v0.28.0
|
||||
golang.org/x/net v0.41.0
|
||||
github.com/prometheus/client_golang v1.23.0
|
||||
github.com/tdewolff/minify/v2 v2.23.11
|
||||
golang.org/x/crypto v0.41.0
|
||||
golang.org/x/image v0.30.0
|
||||
golang.org/x/net v0.43.0
|
||||
golang.org/x/oauth2 v0.30.0
|
||||
golang.org/x/term v0.32.0
|
||||
golang.org/x/term v0.34.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.23 // indirect
|
||||
github.com/golang-jwt/jwt/v5 v5.2.3 // indirect
|
||||
github.com/google/go-tpm v0.9.5 // indirect
|
||||
)
|
||||
|
||||
@@ -29,19 +28,19 @@ 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/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.65.0 // indirect
|
||||
github.com/prometheus/procfs v0.16.1 // indirect
|
||||
github.com/tdewolff/parse/v2 v2.8.2-0.20250806174018-50048bb39781 // indirect
|
||||
github.com/x448/float16 v0.8.4 // indirect
|
||||
golang.org/x/sys v0.33.0 // indirect
|
||||
golang.org/x/text v0.26.0 // indirect
|
||||
google.golang.org/protobuf v1.36.5 // indirect
|
||||
golang.org/x/sys v0.35.0 // indirect
|
||||
golang.org/x/text v0.28.0 // indirect
|
||||
google.golang.org/protobuf v1.36.6 // indirect
|
||||
)
|
||||
|
||||
go 1.24.0
|
||||
|
||||
@@ -1,27 +1,27 @@
|
||||
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/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.13.4 h1:q68qusWPcqHbg9STSxBLBHnsKaLxNO0RnVKaAqMuAuQ=
|
||||
github.com/go-webauthn/webauthn v0.13.4/go.mod h1:MglN6OH9ECxvhDqoq1wMoF6P6JRYDiQpC9nc5OomQmI=
|
||||
github.com/go-webauthn/x v0.1.23 h1:9lEO0s+g8iTyz5Vszlg/rXTGrx3CjcD0RZQ1GPZCaxI=
|
||||
github.com/go-webauthn/x v0.1.23/go.mod h1:AJd3hI7NfEp/4fI6T4CHD753u91l510lglU7/NMN6+E=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.3 h1:kkGXqQOBSDDWRhWNXTFpqGSCMyh/PLnqUvMGJPDJDs0=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.3/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/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=
|
||||
@@ -37,28 +37,26 @@ github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0
|
||||
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/prometheus/client_golang v1.23.0 h1:ust4zpdl9r4trLY/gSjlm07PuiBq2ynaXXlptpfy8Uc=
|
||||
github.com/prometheus/client_golang v1.23.0/go.mod h1:i/o0R9ByOnHX0McrTMTyhYvKE4haaf2mW08I+jGAjEE=
|
||||
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
|
||||
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
|
||||
github.com/prometheus/common v0.65.0 h1:QDwzd+G1twt//Kwj/Ww6E9FQq1iVMmODnILtW1t2VzE=
|
||||
github.com/prometheus/common v0.65.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8=
|
||||
github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg=
|
||||
github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
|
||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/tdewolff/minify/v2 v2.23.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/tdewolff/minify/v2 v2.23.11 h1:cZqTVCtuVvPC8/GbCvYgIcdAQGmoxEObZzKeKIUixTE=
|
||||
github.com/tdewolff/minify/v2 v2.23.11/go.mod h1:vmkbfGQ5hp/eYB+TswNWKma67S0a+32HBL+mFWxjZ2Q=
|
||||
github.com/tdewolff/parse/v2 v2.8.2-0.20250806174018-50048bb39781 h1:2qicgFovKg1XtX7Wf6GwexUdpb7q/jMIE2IgkYsVAvE=
|
||||
github.com/tdewolff/parse/v2 v2.8.2-0.20250806174018-50048bb39781/go.mod h1:Hwlni2tiVNKyzR1o6nUs4FOF07URA+JLBLd6dlIXYqo=
|
||||
github.com/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 +64,18 @@ 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=
|
||||
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.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM=
|
||||
golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U=
|
||||
golang.org/x/image v0.28.0 h1:gdem5JW1OLS4FbkWgLO+7ZeFzYtL3xClb97GaUzYMFE=
|
||||
golang.org/x/image v0.28.0/go.mod h1:GUJYXtnGKEUgggyzh+Vxt+AviiCcyiwpsl8iQ8MvwGY=
|
||||
golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4=
|
||||
golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc=
|
||||
golang.org/x/image v0.30.0 h1:jD5RhkmVAnjqaCUXfbGBrn3lpxbknfN9w2UhHHU+5B4=
|
||||
golang.org/x/image v0.30.0/go.mod h1:SAEUTxCCMWSrJcCy/4HwavEsfZZJlYxeHLc6tTiAe/c=
|
||||
golang.org/x/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,8 +90,8 @@ 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.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw=
|
||||
golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA=
|
||||
golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
|
||||
golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
|
||||
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
|
||||
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
@@ -112,8 +112,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.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
|
||||
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
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 +123,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.34.0 h1:O/2T7POpk0ZZ7MAzMeWFSg6S5IpWd/RXDlM9hgM3DR4=
|
||||
golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw=
|
||||
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 +134,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.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M=
|
||||
golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA=
|
||||
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
|
||||
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
|
||||
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 +143,7 @@ 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.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
|
||||
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
@@ -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) {
|
||||
@@ -2589,6 +2631,64 @@ func TestUpdateEntryStatusEndpoint(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateEntryRemovedStatusEndpoint(t *testing.T) {
|
||||
testConfig := newIntegrationTestConfig()
|
||||
if !testConfig.isConfigured() {
|
||||
t.Skip(skipIntegrationTestsMessage)
|
||||
}
|
||||
|
||||
adminClient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)
|
||||
|
||||
regularTestUser, err := adminClient.CreateUser(testConfig.genRandomUsername(), testConfig.testRegularPassword, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer adminClient.DeleteUser(regularTestUser.ID)
|
||||
|
||||
regularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)
|
||||
|
||||
feedID, err := regularUserClient.CreateFeed(&miniflux.FeedCreationRequest{
|
||||
FeedURL: testConfig.testFeedURL,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
result, err := regularUserClient.FeedEntries(feedID, nil)
|
||||
if err != nil {
|
||||
t.Fatalf(`Failed to get entries: %v`, err)
|
||||
}
|
||||
|
||||
// First we set the entry as "removed"
|
||||
if err := regularUserClient.UpdateEntries([]int64{result.Entries[0].ID}, miniflux.EntryStatusRemoved); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
entry, err := regularUserClient.Entry(result.Entries[0].ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if entry.Status != miniflux.EntryStatusRemoved {
|
||||
t.Fatalf(`Invalid status, got %q instead of %q`, entry.Status, miniflux.EntryStatusRemoved)
|
||||
}
|
||||
|
||||
// Then we try to set it to "unread"
|
||||
if err := regularUserClient.UpdateEntries([]int64{result.Entries[0].ID}, miniflux.EntryStatusUnread); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
entry, err = regularUserClient.Entry(result.Entries[0].ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// It should stay set to "removed"
|
||||
if entry.Status != miniflux.EntryStatusRemoved {
|
||||
t.Fatalf(`Modified immutable status: got %q instead of %q`, entry.Status, miniflux.EntryStatusRemoved)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateEntryEndpoint(t *testing.T) {
|
||||
testConfig := newIntegrationTestConfig()
|
||||
if !testConfig.isConfigured() {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -338,7 +338,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
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
+5
-5
@@ -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(
|
||||
|
||||
@@ -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,6 +21,7 @@ func runScheduler(store *storage.Storage, pool *worker.Pool) {
|
||||
config.Opts.PollingFrequency(),
|
||||
config.Opts.BatchSize(),
|
||||
config.Opts.PollingParsingErrorLimit(),
|
||||
config.Opts.PollingLimitPerHost(),
|
||||
)
|
||||
|
||||
go cleanupScheduler(
|
||||
@@ -29,7 +30,7 @@ func runScheduler(store *storage.Storage, pool *worker.Pool) {
|
||||
)
|
||||
}
|
||||
|
||||
func feedScheduler(store *storage.Storage, pool *worker.Pool, frequency, batchSize, errorLimit int) {
|
||||
func feedScheduler(store *storage.Storage, pool *worker.Pool, frequency, batchSize, errorLimit, limitPerHost int) {
|
||||
for range time.Tick(time.Duration(frequency) * time.Minute) {
|
||||
// Generate a batch of feeds for any user that has feeds to refresh.
|
||||
batchBuilder := store.NewBatchBuilder()
|
||||
@@ -37,13 +38,12 @@ func feedScheduler(store *storage.Storage, pool *worker.Pool, frequency, batchSi
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package config // import "miniflux.app/v2/internal/config"
|
||||
|
||||
// Opts holds parsed configuration options.
|
||||
var Opts *Options
|
||||
@@ -720,7 +720,7 @@ func TestWorkerPoolSize(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefautPollingFrequencyValue(t *testing.T) {
|
||||
func TestDefaultPollingFrequencyValue(t *testing.T) {
|
||||
os.Clearenv()
|
||||
|
||||
parser := NewParser()
|
||||
@@ -755,7 +755,7 @@ func TestPollingFrequency(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefautForceRefreshInterval(t *testing.T) {
|
||||
func TestDefaultForceRefreshInterval(t *testing.T) {
|
||||
os.Clearenv()
|
||||
|
||||
parser := NewParser()
|
||||
@@ -825,7 +825,7 @@ func TestBatchSize(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefautPollingSchedulerValue(t *testing.T) {
|
||||
func TestDefaultPollingSchedulerValue(t *testing.T) {
|
||||
os.Clearenv()
|
||||
|
||||
parser := NewParser()
|
||||
@@ -860,7 +860,7 @@ func TestPollingScheduler(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefautSchedulerEntryFrequencyMaxIntervalValue(t *testing.T) {
|
||||
func TestDefaultSchedulerEntryFrequencyMaxIntervalValue(t *testing.T) {
|
||||
os.Clearenv()
|
||||
|
||||
parser := NewParser()
|
||||
@@ -895,7 +895,7 @@ func TestSchedulerEntryFrequencyMaxInterval(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefautSchedulerEntryFrequencyMinIntervalValue(t *testing.T) {
|
||||
func TestDefaultSchedulerEntryFrequencyMinIntervalValue(t *testing.T) {
|
||||
os.Clearenv()
|
||||
|
||||
parser := NewParser()
|
||||
@@ -930,7 +930,7 @@ func TestSchedulerEntryFrequencyMinInterval(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefautSchedulerEntryFrequencyFactorValue(t *testing.T) {
|
||||
func TestDefaultSchedulerEntryFrequencyFactorValue(t *testing.T) {
|
||||
os.Clearenv()
|
||||
|
||||
parser := NewParser()
|
||||
@@ -1601,7 +1601,8 @@ func TestMediaProxyCustomURL(t *testing.T) {
|
||||
}
|
||||
expected := "http://example.org/proxy"
|
||||
result := opts.MediaCustomProxyURL()
|
||||
if result != expected {
|
||||
|
||||
if result == nil || result.String() != expected {
|
||||
t.Fatalf(`Unexpected MEDIA_PROXY_CUSTOM_URL value, got %q instead of %q`, result, expected)
|
||||
}
|
||||
}
|
||||
@@ -2104,3 +2105,36 @@ func TestInvalidHTTPClientProxy(t *testing.T) {
|
||||
t.Fatalf(`Expected error for invalid HTTP_CLIENT_PROXY value, but got none`)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultPollingLimitPerHost(t *testing.T) {
|
||||
os.Clearenv()
|
||||
|
||||
parser := NewParser()
|
||||
opts, err := parser.ParseEnvironmentVariables()
|
||||
if err != nil {
|
||||
t.Fatalf(`Parsing failure: %v`, err)
|
||||
}
|
||||
|
||||
expected := 0
|
||||
result := opts.PollingLimitPerHost()
|
||||
if result != expected {
|
||||
t.Fatalf(`Unexpected default PollingLimitPerHost value, got %v instead of %v`, result, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCustomPollingLimitPerHost(t *testing.T) {
|
||||
os.Clearenv()
|
||||
os.Setenv("POLLING_LIMIT_PER_HOST", "10")
|
||||
|
||||
parser := NewParser()
|
||||
opts, err := parser.ParseEnvironmentVariables()
|
||||
if err != nil {
|
||||
t.Fatalf(`Parsing failure: %v`, err)
|
||||
}
|
||||
|
||||
expected := 10
|
||||
result := opts.PollingLimitPerHost()
|
||||
if result != expected {
|
||||
t.Fatalf(`Unexpected custom PollingLimitPerHost value, got %v instead of %v`, result, expected)
|
||||
}
|
||||
}
|
||||
|
||||
+121
-116
@@ -5,8 +5,9 @@ package config // import "miniflux.app/v2/internal/config"
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"maps"
|
||||
"net/url"
|
||||
"sort"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -23,8 +24,6 @@ const (
|
||||
defaultHSTS = true
|
||||
defaultHTTPService = true
|
||||
defaultSchedulerService = true
|
||||
defaultDebug = false
|
||||
defaultTiming = false
|
||||
defaultBaseURL = "http://localhost"
|
||||
defaultRootURL = "http://localhost"
|
||||
defaultBasePath = ""
|
||||
@@ -95,14 +94,17 @@ const (
|
||||
|
||||
var defaultHTTPClientUserAgent = "Mozilla/5.0 (compatible; Miniflux/" + version.Version + "; +https://miniflux.app)"
|
||||
|
||||
// Option contains a key to value map of a single option. It may be used to output debug strings.
|
||||
type Option struct {
|
||||
// option contains a key to value map of a single option. It may be used to output debug strings.
|
||||
type option struct {
|
||||
Key string
|
||||
Value interface{}
|
||||
Value any
|
||||
}
|
||||
|
||||
// Options contains configuration options.
|
||||
type Options struct {
|
||||
// Opts holds parsed configuration options.
|
||||
var Opts *options
|
||||
|
||||
// options contains configuration options.
|
||||
type options struct {
|
||||
HTTPS bool
|
||||
logFile string
|
||||
logDateTime bool
|
||||
@@ -128,16 +130,17 @@ type Options struct {
|
||||
cleanupArchiveUnreadDays int
|
||||
cleanupArchiveBatchSize int
|
||||
cleanupRemoveSessionsDays int
|
||||
pollingFrequency int
|
||||
forceRefreshInterval int
|
||||
batchSize int
|
||||
pollingScheduler string
|
||||
schedulerEntryFrequencyMinInterval int
|
||||
schedulerEntryFrequencyMaxInterval int
|
||||
schedulerEntryFrequencyFactor int
|
||||
schedulerRoundRobinMinInterval int
|
||||
schedulerRoundRobinMaxInterval int
|
||||
pollingFrequency int
|
||||
pollingLimitPerHost int
|
||||
pollingParsingErrorLimit int
|
||||
pollingScheduler string
|
||||
workerPoolSize int
|
||||
createAdmin bool
|
||||
adminUsername string
|
||||
@@ -145,7 +148,7 @@ type Options struct {
|
||||
mediaProxyHTTPClientTimeout int
|
||||
mediaProxyMode string
|
||||
mediaProxyResourceTypes []string
|
||||
mediaProxyCustomURL string
|
||||
mediaProxyCustomURL *url.URL
|
||||
fetchBilibiliWatchTime bool
|
||||
fetchNebulaWatchTime bool
|
||||
fetchOdyseeWatchTime bool
|
||||
@@ -184,8 +187,8 @@ type Options struct {
|
||||
}
|
||||
|
||||
// NewOptions returns Options with default values.
|
||||
func NewOptions() *Options {
|
||||
return &Options{
|
||||
func NewOptions() *options {
|
||||
return &options{
|
||||
HTTPS: defaultHTTPS,
|
||||
logFile: defaultLogFile,
|
||||
logDateTime: defaultLogDateTime,
|
||||
@@ -226,7 +229,7 @@ func NewOptions() *Options {
|
||||
mediaProxyHTTPClientTimeout: defaultMediaProxyHTTPClientTimeout,
|
||||
mediaProxyMode: defaultMediaProxyMode,
|
||||
mediaProxyResourceTypes: []string{defaultMediaResourceTypes},
|
||||
mediaProxyCustomURL: defaultMediaProxyURL,
|
||||
mediaProxyCustomURL: nil,
|
||||
filterEntryMaxAgeDays: defaultFilterEntryMaxAgeDays,
|
||||
fetchBilibiliWatchTime: defaultFetchBilibiliWatchTime,
|
||||
fetchNebulaWatchTime: defaultFetchNebulaWatchTime,
|
||||
@@ -264,261 +267,267 @@ func NewOptions() *Options {
|
||||
}
|
||||
}
|
||||
|
||||
func (o *Options) LogFile() string {
|
||||
func (o *options) LogFile() string {
|
||||
return o.logFile
|
||||
}
|
||||
|
||||
// LogDateTime returns true if the date/time should be displayed in log messages.
|
||||
func (o *Options) LogDateTime() bool {
|
||||
func (o *options) LogDateTime() bool {
|
||||
return o.logDateTime
|
||||
}
|
||||
|
||||
// LogFormat returns the log format.
|
||||
func (o *Options) LogFormat() string {
|
||||
func (o *options) LogFormat() string {
|
||||
return o.logFormat
|
||||
}
|
||||
|
||||
// LogLevel returns the log level.
|
||||
func (o *Options) LogLevel() string {
|
||||
func (o *options) LogLevel() string {
|
||||
return o.logLevel
|
||||
}
|
||||
|
||||
// SetLogLevel sets the log level.
|
||||
func (o *Options) SetLogLevel(level string) {
|
||||
func (o *options) SetLogLevel(level string) {
|
||||
o.logLevel = level
|
||||
}
|
||||
|
||||
// HasMaintenanceMode returns true if maintenance mode is enabled.
|
||||
func (o *Options) HasMaintenanceMode() bool {
|
||||
func (o *options) HasMaintenanceMode() bool {
|
||||
return o.maintenanceMode
|
||||
}
|
||||
|
||||
// MaintenanceMessage returns maintenance message.
|
||||
func (o *Options) MaintenanceMessage() string {
|
||||
func (o *options) MaintenanceMessage() string {
|
||||
return o.maintenanceMessage
|
||||
}
|
||||
|
||||
// BaseURL returns the application base URL with path.
|
||||
func (o *Options) BaseURL() string {
|
||||
func (o *options) BaseURL() string {
|
||||
return o.baseURL
|
||||
}
|
||||
|
||||
// RootURL returns the base URL without path.
|
||||
func (o *Options) RootURL() string {
|
||||
func (o *options) RootURL() string {
|
||||
return o.rootURL
|
||||
}
|
||||
|
||||
// BasePath returns the application base path according to the base URL.
|
||||
func (o *Options) BasePath() string {
|
||||
func (o *options) BasePath() string {
|
||||
return o.basePath
|
||||
}
|
||||
|
||||
// IsDefaultDatabaseURL returns true if the default database URL is used.
|
||||
func (o *Options) IsDefaultDatabaseURL() bool {
|
||||
func (o *options) IsDefaultDatabaseURL() bool {
|
||||
return o.databaseURL == defaultDatabaseURL
|
||||
}
|
||||
|
||||
// DatabaseURL returns the database URL.
|
||||
func (o *Options) DatabaseURL() string {
|
||||
func (o *options) DatabaseURL() string {
|
||||
return o.databaseURL
|
||||
}
|
||||
|
||||
// DatabaseMaxConns returns the maximum number of database connections.
|
||||
func (o *Options) DatabaseMaxConns() int {
|
||||
func (o *options) DatabaseMaxConns() int {
|
||||
return o.databaseMaxConns
|
||||
}
|
||||
|
||||
// DatabaseMinConns returns the minimum number of database connections.
|
||||
func (o *Options) DatabaseMinConns() int {
|
||||
func (o *options) DatabaseMinConns() int {
|
||||
return o.databaseMinConns
|
||||
}
|
||||
|
||||
// DatabaseConnectionLifetime returns the maximum amount of time a connection may be reused.
|
||||
func (o *Options) DatabaseConnectionLifetime() time.Duration {
|
||||
func (o *options) DatabaseConnectionLifetime() time.Duration {
|
||||
return time.Duration(o.databaseConnectionLifetime) * time.Minute
|
||||
}
|
||||
|
||||
// ListenAddr returns the listen address for the HTTP server.
|
||||
func (o *Options) ListenAddr() []string {
|
||||
func (o *options) ListenAddr() []string {
|
||||
return o.listenAddr
|
||||
}
|
||||
|
||||
// CertFile returns the SSL certificate filename if any.
|
||||
func (o *Options) CertFile() string {
|
||||
func (o *options) CertFile() string {
|
||||
return o.certFile
|
||||
}
|
||||
|
||||
// CertKeyFile returns the private key filename for custom SSL certificate.
|
||||
func (o *Options) CertKeyFile() string {
|
||||
func (o *options) CertKeyFile() string {
|
||||
return o.certKeyFile
|
||||
}
|
||||
|
||||
// CertDomain returns the domain to use for Let's Encrypt certificate.
|
||||
func (o *Options) CertDomain() string {
|
||||
func (o *options) CertDomain() string {
|
||||
return o.certDomain
|
||||
}
|
||||
|
||||
// CleanupFrequencyHours returns the interval in hours for cleanup jobs.
|
||||
func (o *Options) CleanupFrequencyHours() int {
|
||||
func (o *options) CleanupFrequencyHours() int {
|
||||
return o.cleanupFrequencyHours
|
||||
}
|
||||
|
||||
// CleanupArchiveReadDays returns the number of days after which marking read items as removed.
|
||||
func (o *Options) CleanupArchiveReadDays() int {
|
||||
func (o *options) CleanupArchiveReadDays() int {
|
||||
return o.cleanupArchiveReadDays
|
||||
}
|
||||
|
||||
// CleanupArchiveUnreadDays returns the number of days after which marking unread items as removed.
|
||||
func (o *Options) CleanupArchiveUnreadDays() int {
|
||||
func (o *options) CleanupArchiveUnreadDays() int {
|
||||
return o.cleanupArchiveUnreadDays
|
||||
}
|
||||
|
||||
// CleanupArchiveBatchSize returns the number of entries to archive for each interval.
|
||||
func (o *Options) CleanupArchiveBatchSize() int {
|
||||
func (o *options) CleanupArchiveBatchSize() int {
|
||||
return o.cleanupArchiveBatchSize
|
||||
}
|
||||
|
||||
// CleanupRemoveSessionsDays returns the number of days after which to remove sessions.
|
||||
func (o *Options) CleanupRemoveSessionsDays() int {
|
||||
func (o *options) CleanupRemoveSessionsDays() int {
|
||||
return o.cleanupRemoveSessionsDays
|
||||
}
|
||||
|
||||
// WorkerPoolSize returns the number of background worker.
|
||||
func (o *Options) WorkerPoolSize() int {
|
||||
func (o *options) WorkerPoolSize() int {
|
||||
return o.workerPoolSize
|
||||
}
|
||||
|
||||
// PollingFrequency returns the interval to refresh feeds in the background.
|
||||
func (o *Options) PollingFrequency() int {
|
||||
return o.pollingFrequency
|
||||
}
|
||||
|
||||
// ForceRefreshInterval returns the force refresh interval
|
||||
func (o *Options) ForceRefreshInterval() int {
|
||||
func (o *options) ForceRefreshInterval() int {
|
||||
return o.forceRefreshInterval
|
||||
}
|
||||
|
||||
// BatchSize returns the number of feeds to send for background processing.
|
||||
func (o *Options) BatchSize() int {
|
||||
func (o *options) BatchSize() int {
|
||||
return o.batchSize
|
||||
}
|
||||
|
||||
// PollingFrequency returns the interval to refresh feeds in the background.
|
||||
func (o *options) PollingFrequency() int {
|
||||
return o.pollingFrequency
|
||||
}
|
||||
|
||||
// PollingLimitPerHost returns the limit of concurrent requests per host.
|
||||
// Set to zero to disable.
|
||||
func (o *options) PollingLimitPerHost() int {
|
||||
return o.pollingLimitPerHost
|
||||
}
|
||||
|
||||
// PollingParsingErrorLimit returns the limit of errors when to stop polling.
|
||||
func (o *options) PollingParsingErrorLimit() int {
|
||||
return o.pollingParsingErrorLimit
|
||||
}
|
||||
|
||||
// PollingScheduler returns the scheduler used for polling feeds.
|
||||
func (o *Options) PollingScheduler() string {
|
||||
func (o *options) PollingScheduler() string {
|
||||
return o.pollingScheduler
|
||||
}
|
||||
|
||||
// SchedulerEntryFrequencyMaxInterval returns the maximum interval in minutes for the entry frequency scheduler.
|
||||
func (o *Options) SchedulerEntryFrequencyMaxInterval() int {
|
||||
func (o *options) SchedulerEntryFrequencyMaxInterval() int {
|
||||
return o.schedulerEntryFrequencyMaxInterval
|
||||
}
|
||||
|
||||
// SchedulerEntryFrequencyMinInterval returns the minimum interval in minutes for the entry frequency scheduler.
|
||||
func (o *Options) SchedulerEntryFrequencyMinInterval() int {
|
||||
func (o *options) SchedulerEntryFrequencyMinInterval() int {
|
||||
return o.schedulerEntryFrequencyMinInterval
|
||||
}
|
||||
|
||||
// SchedulerEntryFrequencyFactor returns the factor for the entry frequency scheduler.
|
||||
func (o *Options) SchedulerEntryFrequencyFactor() int {
|
||||
func (o *options) SchedulerEntryFrequencyFactor() int {
|
||||
return o.schedulerEntryFrequencyFactor
|
||||
}
|
||||
|
||||
func (o *Options) SchedulerRoundRobinMinInterval() int {
|
||||
func (o *options) SchedulerRoundRobinMinInterval() int {
|
||||
return o.schedulerRoundRobinMinInterval
|
||||
}
|
||||
|
||||
func (o *Options) SchedulerRoundRobinMaxInterval() int {
|
||||
func (o *options) SchedulerRoundRobinMaxInterval() int {
|
||||
return o.schedulerRoundRobinMaxInterval
|
||||
}
|
||||
|
||||
// PollingParsingErrorLimit returns the limit of errors when to stop polling.
|
||||
func (o *Options) PollingParsingErrorLimit() int {
|
||||
return o.pollingParsingErrorLimit
|
||||
}
|
||||
|
||||
// IsOAuth2UserCreationAllowed returns true if user creation is allowed for OAuth2 users.
|
||||
func (o *Options) IsOAuth2UserCreationAllowed() bool {
|
||||
func (o *options) IsOAuth2UserCreationAllowed() bool {
|
||||
return o.oauth2UserCreationAllowed
|
||||
}
|
||||
|
||||
// OAuth2ClientID returns the OAuth2 Client ID.
|
||||
func (o *Options) OAuth2ClientID() string {
|
||||
func (o *options) OAuth2ClientID() string {
|
||||
return o.oauth2ClientID
|
||||
}
|
||||
|
||||
// OAuth2ClientSecret returns the OAuth2 client secret.
|
||||
func (o *Options) OAuth2ClientSecret() string {
|
||||
func (o *options) OAuth2ClientSecret() string {
|
||||
return o.oauth2ClientSecret
|
||||
}
|
||||
|
||||
// OAuth2RedirectURL returns the OAuth2 redirect URL.
|
||||
func (o *Options) OAuth2RedirectURL() string {
|
||||
func (o *options) OAuth2RedirectURL() string {
|
||||
return o.oauth2RedirectURL
|
||||
}
|
||||
|
||||
// OIDCDiscoveryEndpoint returns the OAuth2 OIDC discovery endpoint.
|
||||
func (o *Options) OIDCDiscoveryEndpoint() string {
|
||||
func (o *options) OIDCDiscoveryEndpoint() string {
|
||||
return o.oidcDiscoveryEndpoint
|
||||
}
|
||||
|
||||
// OIDCProviderName returns the OAuth2 OIDC provider's display name
|
||||
func (o *Options) OIDCProviderName() string {
|
||||
func (o *options) OIDCProviderName() string {
|
||||
return o.oidcProviderName
|
||||
}
|
||||
|
||||
// OAuth2Provider returns the name of the OAuth2 provider configured.
|
||||
func (o *Options) OAuth2Provider() string {
|
||||
func (o *options) OAuth2Provider() string {
|
||||
return o.oauth2Provider
|
||||
}
|
||||
|
||||
// DisableLocalAUth returns true if the local user database should not be used to authenticate users
|
||||
func (o *Options) DisableLocalAuth() bool {
|
||||
func (o *options) DisableLocalAuth() bool {
|
||||
return o.disableLocalAuth
|
||||
}
|
||||
|
||||
// HasHSTS returns true if HTTP Strict Transport Security is enabled.
|
||||
func (o *Options) HasHSTS() bool {
|
||||
func (o *options) HasHSTS() bool {
|
||||
return o.hsts
|
||||
}
|
||||
|
||||
// RunMigrations returns true if the environment variable RUN_MIGRATIONS is not empty.
|
||||
func (o *Options) RunMigrations() bool {
|
||||
func (o *options) RunMigrations() bool {
|
||||
return o.runMigrations
|
||||
}
|
||||
|
||||
// CreateAdmin returns true if the environment variable CREATE_ADMIN is not empty.
|
||||
func (o *Options) CreateAdmin() bool {
|
||||
func (o *options) CreateAdmin() bool {
|
||||
return o.createAdmin
|
||||
}
|
||||
|
||||
// AdminUsername returns the admin username if defined.
|
||||
func (o *Options) AdminUsername() string {
|
||||
func (o *options) AdminUsername() string {
|
||||
return o.adminUsername
|
||||
}
|
||||
|
||||
// AdminPassword returns the admin password if defined.
|
||||
func (o *Options) AdminPassword() string {
|
||||
func (o *options) AdminPassword() string {
|
||||
return o.adminPassword
|
||||
}
|
||||
|
||||
// FetchYouTubeWatchTime returns true if the YouTube video duration
|
||||
// should be fetched and used as a reading time.
|
||||
func (o *Options) FetchYouTubeWatchTime() bool {
|
||||
func (o *options) FetchYouTubeWatchTime() bool {
|
||||
return o.fetchYouTubeWatchTime
|
||||
}
|
||||
|
||||
// YouTubeApiKey returns the YouTube API key if defined.
|
||||
func (o *Options) YouTubeApiKey() string {
|
||||
func (o *options) YouTubeApiKey() string {
|
||||
return o.youTubeApiKey
|
||||
}
|
||||
|
||||
// YouTubeEmbedUrlOverride returns the YouTube embed URL override if defined.
|
||||
func (o *Options) YouTubeEmbedUrlOverride() string {
|
||||
func (o *options) YouTubeEmbedUrlOverride() string {
|
||||
return o.youTubeEmbedUrlOverride
|
||||
}
|
||||
|
||||
// YouTubeEmbedDomain returns the domain used for YouTube embeds.
|
||||
func (o *Options) YouTubeEmbedDomain() string {
|
||||
func (o *options) YouTubeEmbedDomain() string {
|
||||
if o.youTubeEmbedDomain != "" {
|
||||
return o.youTubeEmbedDomain
|
||||
}
|
||||
@@ -527,154 +536,154 @@ func (o *Options) YouTubeEmbedDomain() string {
|
||||
|
||||
// FetchNebulaWatchTime returns true if the Nebula video duration
|
||||
// should be fetched and used as a reading time.
|
||||
func (o *Options) FetchNebulaWatchTime() bool {
|
||||
func (o *options) FetchNebulaWatchTime() bool {
|
||||
return o.fetchNebulaWatchTime
|
||||
}
|
||||
|
||||
// FetchOdyseeWatchTime returns true if the Odysee video duration
|
||||
// should be fetched and used as a reading time.
|
||||
func (o *Options) FetchOdyseeWatchTime() bool {
|
||||
func (o *options) FetchOdyseeWatchTime() bool {
|
||||
return o.fetchOdyseeWatchTime
|
||||
}
|
||||
|
||||
// FetchBilibiliWatchTime returns true if the Bilibili video duration
|
||||
// should be fetched and used as a reading time.
|
||||
func (o *Options) FetchBilibiliWatchTime() bool {
|
||||
func (o *options) FetchBilibiliWatchTime() bool {
|
||||
return o.fetchBilibiliWatchTime
|
||||
}
|
||||
|
||||
// MediaProxyMode returns "none" to never proxy, "http-only" to proxy non-HTTPS, "all" to always proxy.
|
||||
func (o *Options) MediaProxyMode() string {
|
||||
func (o *options) MediaProxyMode() string {
|
||||
return o.mediaProxyMode
|
||||
}
|
||||
|
||||
// MediaProxyResourceTypes returns a slice of resource types to proxy.
|
||||
func (o *Options) MediaProxyResourceTypes() []string {
|
||||
func (o *options) MediaProxyResourceTypes() []string {
|
||||
return o.mediaProxyResourceTypes
|
||||
}
|
||||
|
||||
// MediaCustomProxyURL returns the custom proxy URL for medias.
|
||||
func (o *Options) MediaCustomProxyURL() string {
|
||||
func (o *options) MediaCustomProxyURL() *url.URL {
|
||||
return o.mediaProxyCustomURL
|
||||
}
|
||||
|
||||
// MediaProxyHTTPClientTimeout returns the time limit in seconds before the proxy HTTP client cancel the request.
|
||||
func (o *Options) MediaProxyHTTPClientTimeout() int {
|
||||
func (o *options) MediaProxyHTTPClientTimeout() int {
|
||||
return o.mediaProxyHTTPClientTimeout
|
||||
}
|
||||
|
||||
// MediaProxyPrivateKey returns the private key used by the media proxy.
|
||||
func (o *Options) MediaProxyPrivateKey() []byte {
|
||||
func (o *options) MediaProxyPrivateKey() []byte {
|
||||
return o.mediaProxyPrivateKey
|
||||
}
|
||||
|
||||
// HasHTTPService returns true if the HTTP service is enabled.
|
||||
func (o *Options) HasHTTPService() bool {
|
||||
func (o *options) HasHTTPService() bool {
|
||||
return o.httpService
|
||||
}
|
||||
|
||||
// HasSchedulerService returns true if the scheduler service is enabled.
|
||||
func (o *Options) HasSchedulerService() bool {
|
||||
func (o *options) HasSchedulerService() bool {
|
||||
return o.schedulerService
|
||||
}
|
||||
|
||||
// HTTPClientTimeout returns the time limit in seconds before the HTTP client cancel the request.
|
||||
func (o *Options) HTTPClientTimeout() int {
|
||||
func (o *options) HTTPClientTimeout() int {
|
||||
return o.httpClientTimeout
|
||||
}
|
||||
|
||||
// HTTPClientMaxBodySize returns the number of bytes allowed for the HTTP client to transfer.
|
||||
func (o *Options) HTTPClientMaxBodySize() int64 {
|
||||
func (o *options) HTTPClientMaxBodySize() int64 {
|
||||
return o.httpClientMaxBodySize
|
||||
}
|
||||
|
||||
// HTTPClientProxyURL returns the client HTTP proxy URL if configured.
|
||||
func (o *Options) HTTPClientProxyURL() *url.URL {
|
||||
func (o *options) HTTPClientProxyURL() *url.URL {
|
||||
return o.httpClientProxyURL
|
||||
}
|
||||
|
||||
// HasHTTPClientProxyURLConfigured returns true if the client HTTP proxy URL if configured.
|
||||
func (o *Options) HasHTTPClientProxyURLConfigured() bool {
|
||||
func (o *options) HasHTTPClientProxyURLConfigured() bool {
|
||||
return o.httpClientProxyURL != nil
|
||||
}
|
||||
|
||||
// HTTPClientProxies returns the list of proxies.
|
||||
func (o *Options) HTTPClientProxies() []string {
|
||||
func (o *options) HTTPClientProxies() []string {
|
||||
return o.httpClientProxies
|
||||
}
|
||||
|
||||
// HTTPClientProxiesString returns true if the list of rotating proxies are configured.
|
||||
func (o *Options) HasHTTPClientProxiesConfigured() bool {
|
||||
func (o *options) HasHTTPClientProxiesConfigured() bool {
|
||||
return len(o.httpClientProxies) > 0
|
||||
}
|
||||
|
||||
// HTTPServerTimeout returns the time limit in seconds before the HTTP server cancel the request.
|
||||
func (o *Options) HTTPServerTimeout() int {
|
||||
func (o *options) HTTPServerTimeout() int {
|
||||
return o.httpServerTimeout
|
||||
}
|
||||
|
||||
// AuthProxyHeader returns an HTTP header name that contains username for
|
||||
// authentication using auth proxy.
|
||||
func (o *Options) AuthProxyHeader() string {
|
||||
func (o *options) AuthProxyHeader() string {
|
||||
return o.authProxyHeader
|
||||
}
|
||||
|
||||
// IsAuthProxyUserCreationAllowed returns true if user creation is allowed for
|
||||
// users authenticated using auth proxy.
|
||||
func (o *Options) IsAuthProxyUserCreationAllowed() bool {
|
||||
func (o *options) IsAuthProxyUserCreationAllowed() bool {
|
||||
return o.authProxyUserCreation
|
||||
}
|
||||
|
||||
// HasMetricsCollector returns true if metrics collection is enabled.
|
||||
func (o *Options) HasMetricsCollector() bool {
|
||||
func (o *options) HasMetricsCollector() bool {
|
||||
return o.metricsCollector
|
||||
}
|
||||
|
||||
// MetricsRefreshInterval returns the refresh interval in seconds.
|
||||
func (o *Options) MetricsRefreshInterval() int {
|
||||
func (o *options) MetricsRefreshInterval() int {
|
||||
return o.metricsRefreshInterval
|
||||
}
|
||||
|
||||
// MetricsAllowedNetworks returns the list of networks allowed to connect to the metrics endpoint.
|
||||
func (o *Options) MetricsAllowedNetworks() []string {
|
||||
func (o *options) MetricsAllowedNetworks() []string {
|
||||
return o.metricsAllowedNetworks
|
||||
}
|
||||
|
||||
func (o *Options) MetricsUsername() string {
|
||||
func (o *options) MetricsUsername() string {
|
||||
return o.metricsUsername
|
||||
}
|
||||
|
||||
func (o *Options) MetricsPassword() string {
|
||||
func (o *options) MetricsPassword() string {
|
||||
return o.metricsPassword
|
||||
}
|
||||
|
||||
// HTTPClientUserAgent returns the global User-Agent header for miniflux.
|
||||
func (o *Options) HTTPClientUserAgent() string {
|
||||
func (o *options) HTTPClientUserAgent() string {
|
||||
return o.httpClientUserAgent
|
||||
}
|
||||
|
||||
// HasWatchdog returns true if the systemd watchdog is enabled.
|
||||
func (o *Options) HasWatchdog() bool {
|
||||
func (o *options) HasWatchdog() bool {
|
||||
return o.watchdog
|
||||
}
|
||||
|
||||
// InvidiousInstance returns the invidious instance used by miniflux
|
||||
func (o *Options) InvidiousInstance() string {
|
||||
func (o *options) InvidiousInstance() string {
|
||||
return o.invidiousInstance
|
||||
}
|
||||
|
||||
// WebAuthn returns true if WebAuthn logins are supported
|
||||
func (o *Options) WebAuthn() bool {
|
||||
func (o *options) WebAuthn() bool {
|
||||
return o.webAuthn
|
||||
}
|
||||
|
||||
// FilterEntryMaxAgeDays returns the number of days after which entries should be retained.
|
||||
func (o *Options) FilterEntryMaxAgeDays() int {
|
||||
func (o *options) FilterEntryMaxAgeDays() int {
|
||||
return o.filterEntryMaxAgeDays
|
||||
}
|
||||
|
||||
// SortedOptions returns options as a list of key value pairs, sorted by keys.
|
||||
func (o *Options) SortedOptions(redactSecret bool) []*Option {
|
||||
func (o *options) SortedOptions(redactSecret bool) []*option {
|
||||
var clientProxyURLRedacted string
|
||||
if o.httpClientProxyURL != nil {
|
||||
if redactSecret {
|
||||
@@ -702,7 +711,7 @@ func (o *Options) SortedOptions(redactSecret bool) []*Option {
|
||||
mediaProxyPrivateKeyValue = "<binary-data>"
|
||||
}
|
||||
|
||||
var keyValues = map[string]interface{}{
|
||||
var keyValues = map[string]any{
|
||||
"ADMIN_PASSWORD": redactSecretValue(o.adminPassword, redactSecret),
|
||||
"ADMIN_USERNAME": o.adminUsername,
|
||||
"AUTH_PROXY_HEADER": o.authProxyHeader,
|
||||
@@ -760,8 +769,9 @@ func (o *Options) SortedOptions(redactSecret bool) []*Option {
|
||||
"OAUTH2_REDIRECT_URL": o.oauth2RedirectURL,
|
||||
"OAUTH2_USER_CREATION": o.oauth2UserCreationAllowed,
|
||||
"DISABLE_LOCAL_AUTH": o.disableLocalAuth,
|
||||
"POLLING_FREQUENCY": o.pollingFrequency,
|
||||
"FORCE_REFRESH_INTERVAL": o.forceRefreshInterval,
|
||||
"POLLING_FREQUENCY": o.pollingFrequency,
|
||||
"POLLING_LIMIT_PER_HOST": o.pollingLimitPerHost,
|
||||
"POLLING_PARSING_ERROR_LIMIT": o.pollingParsingErrorLimit,
|
||||
"POLLING_SCHEDULER": o.pollingScheduler,
|
||||
"MEDIA_PROXY_HTTP_CLIENT_TIMEOUT": o.mediaProxyHTTPClientTimeout,
|
||||
@@ -784,20 +794,15 @@ func (o *Options) SortedOptions(redactSecret bool) []*Option {
|
||||
"WEBAUTHN": o.webAuthn,
|
||||
}
|
||||
|
||||
keys := make([]string, 0, len(keyValues))
|
||||
for key := range keyValues {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
|
||||
var sortedOptions []*Option
|
||||
for _, key := range keys {
|
||||
sortedOptions = append(sortedOptions, &Option{Key: key, Value: keyValues[key]})
|
||||
sortedKeys := slices.Sorted(maps.Keys(keyValues))
|
||||
var sortedOptions = make([]*option, 0, len(sortedKeys))
|
||||
for _, key := range sortedKeys {
|
||||
sortedOptions = append(sortedOptions, &option{Key: key, Value: keyValues[key]})
|
||||
}
|
||||
return sortedOptions
|
||||
}
|
||||
|
||||
func (o *Options) String() string {
|
||||
func (o *options) String() string {
|
||||
var builder strings.Builder
|
||||
|
||||
for _, option := range o.SortedOptions(false) {
|
||||
|
||||
+34
-36
@@ -16,20 +16,20 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Parser handles configuration parsing.
|
||||
type Parser struct {
|
||||
opts *Options
|
||||
// parser handles configuration parsing.
|
||||
type parser struct {
|
||||
opts *options
|
||||
}
|
||||
|
||||
// NewParser returns a new Parser.
|
||||
func NewParser() *Parser {
|
||||
return &Parser{
|
||||
func NewParser() *parser {
|
||||
return &parser{
|
||||
opts: NewOptions(),
|
||||
}
|
||||
}
|
||||
|
||||
// ParseEnvironmentVariables loads configuration values from environment variables.
|
||||
func (p *Parser) ParseEnvironmentVariables() (*Options, error) {
|
||||
func (p *parser) ParseEnvironmentVariables() (*options, error) {
|
||||
err := p.parseLines(os.Environ())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -38,7 +38,7 @@ func (p *Parser) ParseEnvironmentVariables() (*Options, error) {
|
||||
}
|
||||
|
||||
// ParseFile loads configuration values from a local file.
|
||||
func (p *Parser) ParseFile(filename string) (*Options, error) {
|
||||
func (p *parser) ParseFile(filename string) (*options, error) {
|
||||
fp, err := os.Open(filename)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -52,7 +52,7 @@ func (p *Parser) ParseFile(filename string) (*Options, error) {
|
||||
return p.opts, nil
|
||||
}
|
||||
|
||||
func (p *Parser) parseFileContent(r io.Reader) (lines []string) {
|
||||
func (p *parser) parseFileContent(r io.Reader) (lines []string) {
|
||||
scanner := bufio.NewScanner(r)
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
@@ -63,13 +63,15 @@ func (p *Parser) parseFileContent(r io.Reader) (lines []string) {
|
||||
return lines
|
||||
}
|
||||
|
||||
func (p *Parser) parseLines(lines []string) (err error) {
|
||||
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])
|
||||
for lineNum, line := range lines {
|
||||
key, value, ok := strings.Cut(line, "=")
|
||||
if !ok {
|
||||
return fmt.Errorf("config: unable to parse configuration, invalid format on line %d", lineNum)
|
||||
}
|
||||
key, value = strings.TrimSpace(key), strings.TrimSpace(value)
|
||||
|
||||
switch key {
|
||||
case "LOG_FILE":
|
||||
@@ -135,12 +137,16 @@ func (p *Parser) parseLines(lines []string) (err error) {
|
||||
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_FREQUENCY":
|
||||
p.opts.pollingFrequency = parseInt(value, defaultPollingFrequency)
|
||||
case "POLLING_LIMIT_PER_HOST":
|
||||
p.opts.pollingLimitPerHost = parseInt(value, 0)
|
||||
case "POLLING_PARSING_ERROR_LIMIT":
|
||||
p.opts.pollingParsingErrorLimit = parseInt(value, defaultPollingParsingErrorLimit)
|
||||
case "POLLING_SCHEDULER":
|
||||
p.opts.pollingScheduler = strings.ToLower(parseString(value, defaultPollingScheduler))
|
||||
case "SCHEDULER_ENTRY_FREQUENCY_MAX_INTERVAL":
|
||||
@@ -153,8 +159,6 @@ func (p *Parser) parseLines(lines []string) (err error) {
|
||||
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 "MEDIA_PROXY_HTTP_CLIENT_TIMEOUT":
|
||||
p.opts.mediaProxyHTTPClientTimeout = parseInt(value, defaultMediaProxyHTTPClientTimeout)
|
||||
case "MEDIA_PROXY_MODE":
|
||||
@@ -163,12 +167,13 @@ func (p *Parser) parseLines(lines []string) (err error) {
|
||||
p.opts.mediaProxyResourceTypes = parseStringList(value, []string{defaultMediaResourceTypes})
|
||||
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)
|
||||
}
|
||||
rand.Read(randomKey)
|
||||
p.opts.mediaProxyPrivateKey = parseBytes(value, randomKey)
|
||||
case "MEDIA_PROXY_CUSTOM_URL":
|
||||
p.opts.mediaProxyCustomURL = parseString(value, defaultMediaProxyURL)
|
||||
p.opts.mediaProxyCustomURL, err = url.Parse(parseString(value, defaultMediaProxyURL))
|
||||
if err != nil {
|
||||
return fmt.Errorf("config: invalid MEDIA_PROXY_CUSTOM_URL value: %w", err)
|
||||
}
|
||||
case "CREATE_ADMIN":
|
||||
p.opts.createAdmin = parseBool(value, defaultCreateAdmin)
|
||||
case "ADMIN_USERNAME":
|
||||
@@ -275,9 +280,7 @@ func parseBaseURL(value string) (string, string, string, error) {
|
||||
return defaultBaseURL, defaultRootURL, "", nil
|
||||
}
|
||||
|
||||
if value[len(value)-1:] == "/" {
|
||||
value = value[:len(value)-1]
|
||||
}
|
||||
value = strings.TrimSuffix(value, "/")
|
||||
|
||||
parsedURL, err := url.Parse(value)
|
||||
if err != nil {
|
||||
@@ -333,19 +336,14 @@ func parseStringList(value string, fallback []string) []string {
|
||||
}
|
||||
|
||||
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 itemValue == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,7 +27,7 @@ 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)
|
||||
}
|
||||
|
||||
+143
-150
@@ -12,8 +12,8 @@ import (
|
||||
var schemaVersion = len(migrations)
|
||||
|
||||
// Order is important. Add new migrations at the end of the list.
|
||||
var migrations = []func(tx *sql.Tx, driver string) error{
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
var migrations = []func(tx *sql.Tx) error{
|
||||
func(tx *sql.Tx) (err error) {
|
||||
sql := `
|
||||
CREATE TABLE schema_version (
|
||||
version text not null
|
||||
@@ -122,19 +122,16 @@ var migrations = []func(tx *sql.Tx, driver string) error{
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx, driver string) (err error) {
|
||||
if driver == "postgresql" {
|
||||
sql := `
|
||||
func(tx *sql.Tx) (err error) {
|
||||
sql := `
|
||||
CREATE EXTENSION IF NOT EXISTS hstore;
|
||||
ALTER TABLE users ADD COLUMN extra hstore;
|
||||
CREATE INDEX users_extra_idx ON users using gin(extra);
|
||||
`
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
func(tx *sql.Tx) (err error) {
|
||||
sql := `
|
||||
CREATE TABLE tokens (
|
||||
id text not null,
|
||||
@@ -146,7 +143,7 @@ var migrations = []func(tx *sql.Tx, driver string) error{
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
func(tx *sql.Tx) (err error) {
|
||||
sql := `
|
||||
CREATE TYPE entry_sorting_direction AS enum('asc', 'desc');
|
||||
ALTER TABLE users ADD COLUMN entry_direction entry_sorting_direction default 'asc';
|
||||
@@ -154,7 +151,7 @@ var migrations = []func(tx *sql.Tx, driver string) error{
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
func(tx *sql.Tx) (err error) {
|
||||
sql := `
|
||||
CREATE TABLE integrations (
|
||||
user_id int not null,
|
||||
@@ -175,27 +172,27 @@ var migrations = []func(tx *sql.Tx, driver string) error{
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
func(tx *sql.Tx) (err error) {
|
||||
sql := `ALTER TABLE feeds ADD COLUMN scraper_rules text default ''`
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
func(tx *sql.Tx) (err error) {
|
||||
sql := `ALTER TABLE feeds ADD COLUMN rewrite_rules text default ''`
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
func(tx *sql.Tx) (err error) {
|
||||
sql := `ALTER TABLE feeds ADD COLUMN crawler boolean default 'f'`
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
func(tx *sql.Tx) (err error) {
|
||||
sql := `ALTER TABLE sessions rename to user_sessions`
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
func(tx *sql.Tx) (err error) {
|
||||
sql := `
|
||||
DROP TABLE tokens;
|
||||
|
||||
@@ -209,7 +206,7 @@ var migrations = []func(tx *sql.Tx, driver string) error{
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
func(tx *sql.Tx) (err error) {
|
||||
sql := `
|
||||
ALTER TABLE integrations
|
||||
ADD COLUMN wallabag_enabled bool default 'f',
|
||||
@@ -222,12 +219,12 @@ var migrations = []func(tx *sql.Tx, driver string) error{
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
func(tx *sql.Tx) (err error) {
|
||||
sql := `ALTER TABLE entries ADD COLUMN starred bool default 'f'`
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
func(tx *sql.Tx) (err error) {
|
||||
sql := `
|
||||
CREATE INDEX entries_user_status_idx ON entries(user_id, status);
|
||||
CREATE INDEX feeds_user_category_idx ON feeds(user_id, category_id);
|
||||
@@ -235,7 +232,7 @@ var migrations = []func(tx *sql.Tx, driver string) error{
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
func(tx *sql.Tx) (err error) {
|
||||
sql := `
|
||||
ALTER TABLE integrations
|
||||
ADD COLUMN nunux_keeper_enabled bool default 'f',
|
||||
@@ -245,17 +242,17 @@ var migrations = []func(tx *sql.Tx, driver string) error{
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
func(tx *sql.Tx) (err error) {
|
||||
sql := `ALTER TABLE enclosures ALTER COLUMN size SET DATA TYPE bigint`
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
func(tx *sql.Tx) (err error) {
|
||||
sql := `ALTER TABLE entries ADD COLUMN comments_url text default ''`
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
func(tx *sql.Tx) (err error) {
|
||||
sql := `
|
||||
ALTER TABLE integrations
|
||||
ADD COLUMN pocket_enabled bool default 'f',
|
||||
@@ -265,14 +262,14 @@ var migrations = []func(tx *sql.Tx, driver string) error{
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
func(tx *sql.Tx) (err error) {
|
||||
sql := `
|
||||
ALTER TABLE user_sessions ALTER COLUMN ip SET DATA TYPE inet using ip::inet;
|
||||
`
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
func(tx *sql.Tx) (err error) {
|
||||
sql := `
|
||||
ALTER TABLE feeds
|
||||
ADD COLUMN username text default '',
|
||||
@@ -281,7 +278,7 @@ var migrations = []func(tx *sql.Tx, driver string) error{
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
func(tx *sql.Tx) (err error) {
|
||||
sql := `
|
||||
ALTER TABLE entries ADD COLUMN document_vectors tsvector;
|
||||
UPDATE entries SET document_vectors = to_tsvector(substring(title || ' ' || coalesce(content, '') for 1000000));
|
||||
@@ -290,12 +287,12 @@ var migrations = []func(tx *sql.Tx, driver string) error{
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
func(tx *sql.Tx) (err error) {
|
||||
sql := `ALTER TABLE feeds ADD COLUMN user_agent text default ''`
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
func(tx *sql.Tx) (err error) {
|
||||
sql := `
|
||||
UPDATE
|
||||
entries
|
||||
@@ -305,17 +302,17 @@ var migrations = []func(tx *sql.Tx, driver string) error{
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
func(tx *sql.Tx) (err error) {
|
||||
sql := `ALTER TABLE users ADD COLUMN keyboard_shortcuts boolean default 't'`
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
func(tx *sql.Tx) (err error) {
|
||||
sql := `ALTER TABLE feeds ADD COLUMN disabled boolean default 'f';`
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
func(tx *sql.Tx) (err error) {
|
||||
sql := `
|
||||
ALTER TABLE users ALTER COLUMN theme SET DEFAULT 'light_serif';
|
||||
UPDATE users SET theme='light_serif' WHERE theme='default';
|
||||
@@ -325,7 +322,7 @@ var migrations = []func(tx *sql.Tx, driver string) error{
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
func(tx *sql.Tx) (err error) {
|
||||
sql := `
|
||||
ALTER TABLE entries ADD COLUMN changed_at timestamp with time zone;
|
||||
UPDATE entries SET changed_at = published_at;
|
||||
@@ -334,7 +331,7 @@ var migrations = []func(tx *sql.Tx, driver string) error{
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
func(tx *sql.Tx) (err error) {
|
||||
sql := `
|
||||
CREATE TABLE api_keys (
|
||||
id serial not null,
|
||||
@@ -350,7 +347,7 @@ var migrations = []func(tx *sql.Tx, driver string) error{
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
func(tx *sql.Tx) (err error) {
|
||||
sql := `
|
||||
ALTER TABLE entries ADD COLUMN share_code text not null default '';
|
||||
CREATE UNIQUE INDEX entries_share_code_idx ON entries USING btree(share_code) WHERE share_code <> '';
|
||||
@@ -358,12 +355,12 @@ var migrations = []func(tx *sql.Tx, driver string) error{
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
func(tx *sql.Tx) (err error) {
|
||||
sql := `CREATE INDEX enclosures_user_entry_url_idx ON enclosures(user_id, entry_id, md5(url))`
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
func(tx *sql.Tx) (err error) {
|
||||
sql := `
|
||||
ALTER TABLE feeds ADD COLUMN next_check_at timestamp with time zone default now();
|
||||
CREATE INDEX entries_user_feed_idx ON entries (user_id, feed_id);
|
||||
@@ -371,52 +368,52 @@ var migrations = []func(tx *sql.Tx, driver string) error{
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
func(tx *sql.Tx) (err error) {
|
||||
sql := `ALTER TABLE feeds ADD COLUMN ignore_http_cache bool default false`
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
func(tx *sql.Tx) (err error) {
|
||||
sql := `ALTER TABLE users ADD COLUMN entries_per_page int default 100`
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
func(tx *sql.Tx) (err error) {
|
||||
sql := `ALTER TABLE users ADD COLUMN show_reading_time boolean default 't'`
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
func(tx *sql.Tx) (err error) {
|
||||
sql := `CREATE INDEX entries_id_user_status_idx ON entries USING btree (id, user_id, status)`
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
func(tx *sql.Tx) (err error) {
|
||||
sql := `ALTER TABLE feeds ADD COLUMN fetch_via_proxy bool default false`
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
func(tx *sql.Tx) (err error) {
|
||||
sql := `CREATE INDEX entries_feed_id_status_hash_idx ON entries USING btree (feed_id, status, hash)`
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
func(tx *sql.Tx) (err error) {
|
||||
sql := `CREATE INDEX entries_user_id_status_starred_idx ON entries (user_id, status, starred)`
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
func(tx *sql.Tx) (err error) {
|
||||
sql := `ALTER TABLE users ADD COLUMN entry_swipe boolean default 't'`
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
func(tx *sql.Tx) (err error) {
|
||||
sql := `ALTER TABLE integrations DROP COLUMN fever_password`
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
func(tx *sql.Tx) (err error) {
|
||||
sql := `
|
||||
ALTER TABLE feeds
|
||||
ADD COLUMN blocklist_rules text not null default '',
|
||||
@@ -425,12 +422,12 @@ var migrations = []func(tx *sql.Tx, driver string) error{
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
func(tx *sql.Tx) (err error) {
|
||||
sql := `ALTER TABLE entries ADD COLUMN reading_time int not null default 0`
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
func(tx *sql.Tx) (err error) {
|
||||
sql := `
|
||||
ALTER TABLE entries ADD COLUMN created_at timestamp with time zone not null default now();
|
||||
UPDATE entries SET created_at = published_at;
|
||||
@@ -438,7 +435,7 @@ var migrations = []func(tx *sql.Tx, driver string) error{
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx, driver string) (err error) {
|
||||
func(tx *sql.Tx) (err error) {
|
||||
_, err = tx.Exec(`
|
||||
ALTER TABLE users
|
||||
ADD column stylesheet text not null default '',
|
||||
@@ -449,8 +446,7 @@ var migrations = []func(tx *sql.Tx, driver string) error{
|
||||
return err
|
||||
}
|
||||
|
||||
if driver == "postgresql" {
|
||||
_, err = tx.Exec(`
|
||||
_, err = tx.Exec(`
|
||||
DECLARE my_cursor CURSOR FOR
|
||||
SELECT
|
||||
id,
|
||||
@@ -460,28 +456,28 @@ var migrations = []func(tx *sql.Tx, driver string) error{
|
||||
FROM users
|
||||
FOR UPDATE
|
||||
`)
|
||||
if err != nil {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Exec("CLOSE my_cursor")
|
||||
|
||||
for {
|
||||
var (
|
||||
userID int64
|
||||
customStylesheet string
|
||||
googleID string
|
||||
oidcID string
|
||||
)
|
||||
|
||||
if err := tx.QueryRow(`FETCH NEXT FROM my_cursor`).Scan(&userID, &customStylesheet, &googleID, &oidcID); err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
break
|
||||
}
|
||||
return err
|
||||
}
|
||||
defer tx.Exec("CLOSE my_cursor")
|
||||
|
||||
for {
|
||||
var (
|
||||
userID int64
|
||||
customStylesheet string
|
||||
googleID string
|
||||
oidcID string
|
||||
)
|
||||
|
||||
if err := tx.QueryRow(`FETCH NEXT FROM my_cursor`).Scan(&userID, &customStylesheet, &googleID, &oidcID); err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
break
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
_, err := tx.Exec(
|
||||
`UPDATE
|
||||
_, err := tx.Exec(
|
||||
`UPDATE
|
||||
users
|
||||
SET
|
||||
stylesheet=$2,
|
||||
@@ -490,20 +486,17 @@ var migrations = []func(tx *sql.Tx, driver string) error{
|
||||
WHERE
|
||||
id=$1
|
||||
`,
|
||||
userID, customStylesheet, googleID, oidcID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
userID, customStylesheet, googleID, oidcID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx, driver string) (err error) {
|
||||
if driver == "postgresql" {
|
||||
if _, err = tx.Exec(`ALTER TABLE users DROP COLUMN extra;`); err != nil {
|
||||
return err
|
||||
}
|
||||
func(tx *sql.Tx) (err error) {
|
||||
if _, err = tx.Exec(`ALTER TABLE users DROP COLUMN extra;`); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = tx.Exec(`
|
||||
CREATE UNIQUE INDEX users_google_id_idx ON users(google_id) WHERE google_id <> '';
|
||||
@@ -511,7 +504,7 @@ var migrations = []func(tx *sql.Tx, driver string) error{
|
||||
`)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
func(tx *sql.Tx) (err error) {
|
||||
_, err = tx.Exec(`
|
||||
CREATE INDEX entries_feed_url_idx ON entries(feed_id, url) WHERE length(url) < 2000;
|
||||
CREATE INDEX entries_user_status_feed_idx ON entries(user_id, status, feed_id);
|
||||
@@ -519,7 +512,7 @@ var migrations = []func(tx *sql.Tx, driver string) error{
|
||||
`)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
func(tx *sql.Tx) (err error) {
|
||||
_, err = tx.Exec(`
|
||||
CREATE TABLE acme_cache (
|
||||
key varchar(400) not null primary key,
|
||||
@@ -529,13 +522,13 @@ var migrations = []func(tx *sql.Tx, driver string) error{
|
||||
`)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
func(tx *sql.Tx) (err error) {
|
||||
_, err = tx.Exec(`
|
||||
ALTER TABLE feeds ADD COLUMN allow_self_signed_certificates boolean not null default false
|
||||
`)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
func(tx *sql.Tx) (err error) {
|
||||
sql := `
|
||||
CREATE TYPE webapp_display_mode AS enum('fullscreen', 'standalone', 'minimal-ui', 'browser');
|
||||
ALTER TABLE users ADD COLUMN display_mode webapp_display_mode default 'standalone';
|
||||
@@ -543,24 +536,24 @@ var migrations = []func(tx *sql.Tx, driver string) error{
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
func(tx *sql.Tx) (err error) {
|
||||
sql := `ALTER TABLE feeds ADD COLUMN cookie text default ''`
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
func(tx *sql.Tx) (err error) {
|
||||
_, err = tx.Exec(`
|
||||
ALTER TABLE categories ADD COLUMN hide_globally boolean not null default false
|
||||
`)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
func(tx *sql.Tx) (err error) {
|
||||
_, err = tx.Exec(`
|
||||
ALTER TABLE feeds ADD COLUMN hide_globally boolean not null default false
|
||||
`)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
func(tx *sql.Tx) (err error) {
|
||||
sql := `
|
||||
ALTER TABLE integrations
|
||||
ADD COLUMN telegram_bot_enabled bool default 'f',
|
||||
@@ -570,7 +563,7 @@ var migrations = []func(tx *sql.Tx, driver string) error{
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
func(tx *sql.Tx) (err error) {
|
||||
sql := `
|
||||
CREATE TYPE entry_sorting_order AS enum('published_at', 'created_at');
|
||||
ALTER TABLE users ADD COLUMN entry_order entry_sorting_order default 'published_at';
|
||||
@@ -578,7 +571,7 @@ var migrations = []func(tx *sql.Tx, driver string) error{
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
func(tx *sql.Tx) (err error) {
|
||||
sql := `
|
||||
ALTER TABLE integrations
|
||||
ADD COLUMN googlereader_enabled bool default 'f',
|
||||
@@ -588,7 +581,7 @@ var migrations = []func(tx *sql.Tx, driver string) error{
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
func(tx *sql.Tx) (err error) {
|
||||
sql := `
|
||||
ALTER TABLE integrations
|
||||
ADD COLUMN espial_enabled bool default 'f',
|
||||
@@ -599,7 +592,7 @@ var migrations = []func(tx *sql.Tx, driver string) error{
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
func(tx *sql.Tx) (err error) {
|
||||
sql := `
|
||||
ALTER TABLE integrations
|
||||
ADD COLUMN linkding_enabled bool default 'f',
|
||||
@@ -609,13 +602,13 @@ var migrations = []func(tx *sql.Tx, driver string) error{
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
func(tx *sql.Tx) (err error) {
|
||||
_, err = tx.Exec(`
|
||||
ALTER TABLE feeds ADD COLUMN url_rewrite_rules text not null default ''
|
||||
`)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
func(tx *sql.Tx) (err error) {
|
||||
_, err = tx.Exec(`
|
||||
ALTER TABLE users
|
||||
ADD COLUMN default_reading_speed int default 265,
|
||||
@@ -623,25 +616,25 @@ var migrations = []func(tx *sql.Tx, driver string) error{
|
||||
`)
|
||||
return
|
||||
},
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
func(tx *sql.Tx) (err error) {
|
||||
_, err = tx.Exec(`
|
||||
ALTER TABLE users ADD COLUMN default_home_page text default 'unread';
|
||||
`)
|
||||
return
|
||||
},
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
func(tx *sql.Tx) (err error) {
|
||||
_, err = tx.Exec(`
|
||||
ALTER TABLE integrations ADD COLUMN wallabag_only_url bool default 'f';
|
||||
`)
|
||||
return
|
||||
},
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
func(tx *sql.Tx) (err error) {
|
||||
_, err = tx.Exec(`
|
||||
ALTER TABLE users ADD COLUMN categories_sorting_order text not null default 'unread_count';
|
||||
`)
|
||||
return
|
||||
},
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
func(tx *sql.Tx) (err error) {
|
||||
sql := `
|
||||
ALTER TABLE integrations
|
||||
ADD COLUMN matrix_bot_enabled bool default 'f',
|
||||
@@ -653,18 +646,18 @@ var migrations = []func(tx *sql.Tx, driver string) error{
|
||||
_, err = tx.Exec(sql)
|
||||
return
|
||||
},
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
func(tx *sql.Tx) (err error) {
|
||||
sql := `ALTER TABLE users ADD COLUMN double_tap boolean default 't'`
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
func(tx *sql.Tx) (err error) {
|
||||
_, err = tx.Exec(`
|
||||
ALTER TABLE entries ADD COLUMN tags text[] default '{}';
|
||||
`)
|
||||
return
|
||||
},
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
func(tx *sql.Tx) (err error) {
|
||||
sql := `
|
||||
ALTER TABLE users RENAME double_tap TO gesture_nav;
|
||||
ALTER TABLE users
|
||||
@@ -674,14 +667,14 @@ var migrations = []func(tx *sql.Tx, driver string) error{
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
func(tx *sql.Tx) (err error) {
|
||||
sql := `
|
||||
ALTER TABLE integrations ADD COLUMN linkding_tags text default '';
|
||||
`
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
func(tx *sql.Tx) (err error) {
|
||||
sql := `
|
||||
ALTER TABLE feeds ADD COLUMN no_media_player boolean default 'f';
|
||||
ALTER TABLE enclosures ADD COLUMN media_progression int default 0;
|
||||
@@ -689,14 +682,14 @@ var migrations = []func(tx *sql.Tx, driver string) error{
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
func(tx *sql.Tx) (err error) {
|
||||
sql := `
|
||||
ALTER TABLE integrations ADD COLUMN linkding_mark_as_unread bool default 'f';
|
||||
`
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
func(tx *sql.Tx) (err error) {
|
||||
// Delete duplicated rows
|
||||
sql := `
|
||||
DELETE FROM enclosures a USING enclosures b
|
||||
@@ -724,12 +717,12 @@ var migrations = []func(tx *sql.Tx, driver string) error{
|
||||
|
||||
return nil
|
||||
},
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
func(tx *sql.Tx) (err error) {
|
||||
sql := `ALTER TABLE users ADD COLUMN mark_read_on_view boolean default 't'`
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
func(tx *sql.Tx) (err error) {
|
||||
sql := `
|
||||
ALTER TABLE integrations
|
||||
ADD COLUMN notion_enabled bool default 'f',
|
||||
@@ -739,7 +732,7 @@ var migrations = []func(tx *sql.Tx, driver string) error{
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
func(tx *sql.Tx) (err error) {
|
||||
sql := `
|
||||
ALTER TABLE integrations
|
||||
ADD COLUMN readwise_enabled bool default 'f',
|
||||
@@ -748,7 +741,7 @@ var migrations = []func(tx *sql.Tx, driver string) error{
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
func(tx *sql.Tx) (err error) {
|
||||
sql := `
|
||||
ALTER TABLE integrations
|
||||
ADD COLUMN apprise_enabled bool default 'f',
|
||||
@@ -758,7 +751,7 @@ var migrations = []func(tx *sql.Tx, driver string) error{
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
func(tx *sql.Tx) (err error) {
|
||||
sql := `
|
||||
ALTER TABLE integrations
|
||||
ADD COLUMN shiori_enabled bool default 'f',
|
||||
@@ -769,7 +762,7 @@ var migrations = []func(tx *sql.Tx, driver string) error{
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
func(tx *sql.Tx) (err error) {
|
||||
sql := `
|
||||
ALTER TABLE integrations
|
||||
ADD COLUMN shaarli_enabled bool default 'f',
|
||||
@@ -779,13 +772,13 @@ var migrations = []func(tx *sql.Tx, driver string) error{
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
func(tx *sql.Tx) (err error) {
|
||||
_, err = tx.Exec(`
|
||||
ALTER TABLE feeds ADD COLUMN apprise_service_urls text default '';
|
||||
`)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
func(tx *sql.Tx) (err error) {
|
||||
sql := `
|
||||
ALTER TABLE integrations
|
||||
ADD COLUMN webhook_enabled bool default 'f',
|
||||
@@ -795,7 +788,7 @@ var migrations = []func(tx *sql.Tx, driver string) error{
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
func(tx *sql.Tx) (err error) {
|
||||
sql := `
|
||||
ALTER TABLE integrations
|
||||
ADD COLUMN telegram_bot_topic_id int,
|
||||
@@ -805,14 +798,14 @@ var migrations = []func(tx *sql.Tx, driver string) error{
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
func(tx *sql.Tx) (err error) {
|
||||
sql := `
|
||||
ALTER TABLE integrations ADD COLUMN telegram_bot_disable_buttons bool default 'f';
|
||||
`
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
func(tx *sql.Tx) (err error) {
|
||||
sql := `
|
||||
-- Speed up has_enclosure
|
||||
CREATE INDEX enclosures_entry_id_idx ON enclosures(entry_id);
|
||||
@@ -828,7 +821,7 @@ var migrations = []func(tx *sql.Tx, driver string) error{
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
func(tx *sql.Tx) (err error) {
|
||||
sql := `
|
||||
ALTER TABLE integrations
|
||||
ADD COLUMN rssbridge_enabled bool default 'f',
|
||||
@@ -837,7 +830,7 @@ var migrations = []func(tx *sql.Tx, driver string) error{
|
||||
_, err = tx.Exec(sql)
|
||||
return
|
||||
},
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
func(tx *sql.Tx) (err error) {
|
||||
_, err = tx.Exec(`
|
||||
CREATE TABLE webauthn_credentials (
|
||||
handle bytea primary key,
|
||||
@@ -855,7 +848,7 @@ var migrations = []func(tx *sql.Tx, driver string) error{
|
||||
`)
|
||||
return
|
||||
},
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
func(tx *sql.Tx) (err error) {
|
||||
sql := `
|
||||
ALTER TABLE integrations
|
||||
ADD COLUMN omnivore_enabled bool default 'f',
|
||||
@@ -865,7 +858,7 @@ var migrations = []func(tx *sql.Tx, driver string) error{
|
||||
_, err = tx.Exec(sql)
|
||||
return
|
||||
},
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
func(tx *sql.Tx) (err error) {
|
||||
sql := `
|
||||
ALTER TABLE integrations
|
||||
ADD COLUMN linkace_enabled bool default 'f',
|
||||
@@ -878,7 +871,7 @@ var migrations = []func(tx *sql.Tx, driver string) error{
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
func(tx *sql.Tx) (err error) {
|
||||
sql := `
|
||||
ALTER TABLE integrations
|
||||
ADD COLUMN linkwarden_enabled bool default 'f',
|
||||
@@ -888,7 +881,7 @@ var migrations = []func(tx *sql.Tx, driver string) error{
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
func(tx *sql.Tx) (err error) {
|
||||
sql := `
|
||||
ALTER TABLE integrations
|
||||
ADD COLUMN readeck_enabled bool default 'f',
|
||||
@@ -900,29 +893,29 @@ var migrations = []func(tx *sql.Tx, driver string) error{
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
func(tx *sql.Tx) (err error) {
|
||||
sql := `ALTER TABLE feeds ADD COLUMN disable_http2 bool default 'f'`
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
func(tx *sql.Tx) (err error) {
|
||||
sql := `ALTER TABLE users ADD COLUMN media_playback_rate numeric default 1;`
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
func(tx *sql.Tx) (err error) {
|
||||
// the WHERE part speed-up the request a lot
|
||||
sql := `UPDATE entries SET tags = array_remove(tags, '') WHERE '' = ANY(tags);`
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
func(tx *sql.Tx) (err error) {
|
||||
// Entry URLs can exceeds btree maximum size
|
||||
// Checking entry existence is now using entries_feed_id_status_hash_idx index
|
||||
_, err = tx.Exec(`DROP INDEX entries_feed_url_idx`)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
func(tx *sql.Tx) (err error) {
|
||||
sql := `
|
||||
ALTER TABLE integrations
|
||||
ADD COLUMN raindrop_enabled bool default 'f',
|
||||
@@ -933,12 +926,12 @@ var migrations = []func(tx *sql.Tx, driver string) error{
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
func(tx *sql.Tx) (err error) {
|
||||
sql := `ALTER TABLE feeds ADD COLUMN description text default ''`
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
func(tx *sql.Tx) (err error) {
|
||||
sql := `
|
||||
ALTER TABLE users
|
||||
ADD COLUMN block_filter_entry_rules text not null default '',
|
||||
@@ -947,7 +940,7 @@ var migrations = []func(tx *sql.Tx, driver string) error{
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
func(tx *sql.Tx) (err error) {
|
||||
sql := `
|
||||
ALTER TABLE integrations
|
||||
ADD COLUMN betula_url text default '',
|
||||
@@ -957,7 +950,7 @@ var migrations = []func(tx *sql.Tx, driver string) error{
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
func(tx *sql.Tx) (err error) {
|
||||
sql := `
|
||||
ALTER TABLE integrations
|
||||
ADD COLUMN ntfy_enabled bool default 'f',
|
||||
@@ -975,22 +968,22 @@ var migrations = []func(tx *sql.Tx, driver string) error{
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
func(tx *sql.Tx) (err error) {
|
||||
sql := `ALTER TABLE users ADD COLUMN mark_read_on_media_player_completion bool default 'f';`
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
func(tx *sql.Tx) (err error) {
|
||||
sql := `ALTER TABLE users ADD COLUMN custom_js text not null default '';`
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
func(tx *sql.Tx) (err error) {
|
||||
sql := `ALTER TABLE users ADD COLUMN external_font_hosts text not null default '';`
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
func(tx *sql.Tx) (err error) {
|
||||
sql := `
|
||||
ALTER TABLE integrations
|
||||
ADD COLUMN cubox_enabled bool default 'f',
|
||||
@@ -999,7 +992,7 @@ var migrations = []func(tx *sql.Tx, driver string) error{
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
func(tx *sql.Tx) (err error) {
|
||||
sql := `
|
||||
ALTER TABLE integrations
|
||||
ADD COLUMN discord_enabled bool default 'f',
|
||||
@@ -1008,12 +1001,12 @@ var migrations = []func(tx *sql.Tx, driver string) error{
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
func(tx *sql.Tx) (err error) {
|
||||
sql := `ALTER TABLE integrations ADD COLUMN ntfy_internal_links bool default 'f';`
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
func(tx *sql.Tx) (err error) {
|
||||
sql := `
|
||||
ALTER TABLE integrations
|
||||
ADD COLUMN slack_enabled bool default 'f',
|
||||
@@ -1022,11 +1015,11 @@ var migrations = []func(tx *sql.Tx, driver string) error{
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
func(tx *sql.Tx) (err error) {
|
||||
_, err = tx.Exec(`ALTER TABLE feeds ADD COLUMN webhook_url text default '';`)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
func(tx *sql.Tx) (err error) {
|
||||
sql := `
|
||||
ALTER TABLE integrations
|
||||
ADD COLUMN pushover_enabled bool default 'f',
|
||||
@@ -1042,14 +1035,14 @@ var migrations = []func(tx *sql.Tx, driver string) error{
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
func(tx *sql.Tx) (err error) {
|
||||
sql := `
|
||||
ALTER TABLE feeds ADD COLUMN ntfy_topic text default '';
|
||||
`
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
func(tx *sql.Tx) (err error) {
|
||||
sql := `
|
||||
ALTER TABLE icons ADD COLUMN external_id text default '';
|
||||
CREATE UNIQUE INDEX icons_external_id_idx ON icons USING btree(external_id) WHERE external_id <> '';
|
||||
@@ -1058,7 +1051,7 @@ var migrations = []func(tx *sql.Tx, driver string) error{
|
||||
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
func(tx *sql.Tx) (err error) {
|
||||
_, err = tx.Exec(`
|
||||
DECLARE id_cursor CURSOR FOR
|
||||
SELECT
|
||||
@@ -1094,22 +1087,22 @@ var migrations = []func(tx *sql.Tx, driver string) error{
|
||||
|
||||
return nil
|
||||
},
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
func(tx *sql.Tx) (err error) {
|
||||
_, err = tx.Exec(`ALTER TABLE feeds ADD COLUMN proxy_url text default ''`)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
func(tx *sql.Tx) (err error) {
|
||||
sql := `
|
||||
ALTER TABLE integrations ADD COLUMN rssbridge_token text default '';
|
||||
`
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
func(tx *sql.Tx) (err error) {
|
||||
_, err = tx.Exec(`ALTER TABLE users ADD COLUMN always_open_external_links bool default 'f'`)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
func(tx *sql.Tx) (err error) {
|
||||
sql := `
|
||||
ALTER TABLE integrations
|
||||
ADD COLUMN karakeep_enabled bool default 'f',
|
||||
@@ -1119,11 +1112,11 @@ var migrations = []func(tx *sql.Tx, driver string) error{
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
func(tx *sql.Tx) (err error) {
|
||||
_, err = tx.Exec(`ALTER TABLE users ADD COLUMN open_external_links_in_new_tab bool default 't'`)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
func(tx *sql.Tx) (err error) {
|
||||
sql := `
|
||||
ALTER TABLE integrations
|
||||
DROP COLUMN pocket_enabled,
|
||||
@@ -1133,7 +1126,7 @@ var migrations = []func(tx *sql.Tx, driver string) error{
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
func(tx *sql.Tx) (err error) {
|
||||
sql := `
|
||||
ALTER TABLE feeds
|
||||
ADD COLUMN block_filter_entry_rules text not null default '',
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
@@ -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,
|
||||
@@ -802,7 +800,7 @@ func (h *handler) streamItemContentsHandler(w http.ResponseWriter, r *http.Reque
|
||||
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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ const (
|
||||
FlashErrorMessageContextKey
|
||||
LastForceRefreshContextKey
|
||||
ClientIPContextKey
|
||||
GoogleReaderToken
|
||||
GoogleReaderTokenKey
|
||||
WebAuthnDataContextKey
|
||||
)
|
||||
|
||||
@@ -44,9 +44,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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -139,14 +139,33 @@ func startUnixSocketServer(server *http.Server, socketFile string) {
|
||||
}
|
||||
|
||||
go func() {
|
||||
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)
|
||||
certFile := config.Opts.CertFile()
|
||||
keyFile := config.Opts.CertKeyFile()
|
||||
|
||||
if certFile != "" && keyFile != "" {
|
||||
slog.Info("Starting TLS server using a Unix socket",
|
||||
slog.String("socket", socketFile),
|
||||
slog.String("cert_file", certFile),
|
||||
slog.String("key_file", keyFile),
|
||||
)
|
||||
// Ensure HTTPS is marked as true if any listener uses TLS
|
||||
config.Opts.HTTPS = true
|
||||
if err := server.ServeTLS(listener, certFile, keyFile); err != http.ErrServerClosed {
|
||||
printErrorAndExit("TLS Unix socket server failed to start on %s: %v", socketFile, err)
|
||||
}
|
||||
} else {
|
||||
slog.Info("Starting server using a Unix socket", slog.String("socket", socketFile))
|
||||
if err := server.Serve(listener); err != http.ErrServerClosed {
|
||||
printErrorAndExit("Unix socket server failed to start on %s: %v", socketFile, err)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func startAutoCertTLSServer(server *http.Server, autoTLSConfig *tls.Config) {
|
||||
if server.TLSConfig == nil {
|
||||
server.TLSConfig = &tls.Config{}
|
||||
}
|
||||
server.TLSConfig.GetCertificate = autoTLSConfig.GetCertificate
|
||||
server.TLSConfig.NextProtos = autoTLSConfig.NextProtos
|
||||
|
||||
|
||||
@@ -372,20 +372,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),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
+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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -252,7 +252,7 @@
|
||||
"form.integration.linkding_tags": "Linkding-Tags",
|
||||
"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",
|
||||
@@ -508,8 +508,8 @@
|
||||
"page.keyboard_shortcuts.title": "Tastenkürzel",
|
||||
"page.keyboard_shortcuts.toggle_bookmark_status": "Lesezeichen 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",
|
||||
@@ -530,7 +530,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",
|
||||
@@ -614,4 +614,4 @@
|
||||
"time_elapsed.yesterday": "gestern",
|
||||
"tooltip.keyboard_shortcuts": "Tastenkürzel: %s",
|
||||
"tooltip.logged_user": "Angemeldet als %s"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -252,7 +252,7 @@
|
||||
"form.integration.linkding_tags": "Ετικέτες Linkding",
|
||||
"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",
|
||||
|
||||
@@ -252,7 +252,7 @@
|
||||
"form.integration.linkding_tags": "Linkding Tags",
|
||||
"form.integration.linkwarden_activate": "Save entries to Linkwarden",
|
||||
"form.integration.linkwarden_api_key": "Linkwarden API key",
|
||||
"form.integration.linkwarden_endpoint": "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",
|
||||
|
||||
@@ -252,7 +252,7 @@
|
||||
"form.integration.linkding_tags": "Etiquetas de Linkding",
|
||||
"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",
|
||||
|
||||
@@ -252,7 +252,7 @@
|
||||
"form.integration.linkding_tags": "Linkding Tags",
|
||||
"form.integration.linkwarden_activate": "Tallenna artikkelit Linkkiin",
|
||||
"form.integration.linkwarden_api_key": "Linkwarden API-avain",
|
||||
"form.integration.linkwarden_endpoint": "Linkwarden 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",
|
||||
|
||||
@@ -252,7 +252,7 @@
|
||||
"form.integration.linkding_tags": "Libellés",
|
||||
"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",
|
||||
|
||||
@@ -252,7 +252,7 @@
|
||||
"form.integration.linkding_tags": "Linkding Tags",
|
||||
"form.integration.linkwarden_activate": "Save entries to Linkwarden",
|
||||
"form.integration.linkwarden_api_key": "Linkwarden API key",
|
||||
"form.integration.linkwarden_endpoint": "Linkwarden API Endpoint",
|
||||
"form.integration.linkwarden_endpoint": "लिंकवर्डन बेस यूआरएलL",
|
||||
"form.integration.matrix_bot_activate": "नए लेखों को मैट्रिक्स में स्थानांतरित करें",
|
||||
"form.integration.matrix_bot_chat_id": "मैट्रिक्स रूम की आईडी",
|
||||
"form.integration.matrix_bot_password": "मैट्रिक्स उपयोगकर्ता के लिए पासवर्ड",
|
||||
|
||||
@@ -249,7 +249,7 @@
|
||||
"form.integration.linkding_tags": "Tanda Linkding",
|
||||
"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",
|
||||
|
||||
@@ -252,7 +252,7 @@
|
||||
"form.integration.linkding_tags": "Linkding Tags",
|
||||
"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",
|
||||
|
||||
@@ -249,7 +249,7 @@
|
||||
"form.integration.linkding_tags": "Linkding Tags",
|
||||
"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ユーザ用パスワード",
|
||||
|
||||
@@ -249,7 +249,7 @@
|
||||
"form.integration.linkding_tags": "Linkding khan-á",
|
||||
"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é",
|
||||
|
||||
@@ -252,7 +252,7 @@
|
||||
"form.integration.linkding_tags": "Linkding tags",
|
||||
"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",
|
||||
|
||||
@@ -255,7 +255,7 @@
|
||||
"form.integration.linkding_tags": "Znaczniki Linkding",
|
||||
"form.integration.linkwarden_activate": "Zapisuj wpisy w Linkwarden",
|
||||
"form.integration.linkwarden_api_key": "Klucz API do Linkwarden",
|
||||
"form.integration.linkwarden_endpoint": "Punkt końcowy API Linkwarden",
|
||||
"form.integration.linkwarden_endpoint": "Podstawowy adres URL Linkwardena",
|
||||
"form.integration.matrix_bot_activate": "Przesyłaj nowe wpisy do Matrix",
|
||||
"form.integration.matrix_bot_chat_id": "Identyfikator pokoju Matrix",
|
||||
"form.integration.matrix_bot_password": "Hasło do Matrix",
|
||||
|
||||
@@ -252,7 +252,7 @@
|
||||
"form.integration.linkding_tags": "Linkding Tags",
|
||||
"form.integration.linkwarden_activate": "Salvar itens no Linkwarden",
|
||||
"form.integration.linkwarden_api_key": "Chave de API do Linkwarden",
|
||||
"form.integration.linkwarden_endpoint": "Endpoint de API do Linkwarden",
|
||||
"form.integration.linkwarden_endpoint": "URL base do Linkwarden",
|
||||
"form.integration.matrix_bot_activate": "Transferir novos artigos para o Matrix",
|
||||
"form.integration.matrix_bot_chat_id": "Identificação da sala Matrix",
|
||||
"form.integration.matrix_bot_password": "Palavra-passe para utilizador da Matrix",
|
||||
|
||||
@@ -255,7 +255,7 @@
|
||||
"form.integration.linkding_tags": "TAG-uri Linkding",
|
||||
"form.integration.linkwarden_activate": "Salvează intrările în Linkwarden",
|
||||
"form.integration.linkwarden_api_key": "Cheie API Linkwarden",
|
||||
"form.integration.linkwarden_endpoint": "Endpoint API Linkwarden",
|
||||
"form.integration.linkwarden_endpoint": "URL-ul de bază Linkwarden",
|
||||
"form.integration.matrix_bot_activate": "Împinge intrările noi pe Matrix",
|
||||
"form.integration.matrix_bot_chat_id": "ID-ul Camerei Matrix",
|
||||
"form.integration.matrix_bot_password": "Parola utilizatorului Matrix",
|
||||
|
||||
@@ -255,7 +255,7 @@
|
||||
"form.integration.linkding_tags": "Теги Linkding",
|
||||
"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": "ID комнаты Matrix",
|
||||
"form.integration.matrix_bot_password": "Пароль пользователя Matrix",
|
||||
|
||||
@@ -252,7 +252,7 @@
|
||||
"form.integration.linkding_tags": "Linkding Etiketleri",
|
||||
"form.integration.linkwarden_activate": "Makaleleri Linkwarden'e kaydet",
|
||||
"form.integration.linkwarden_api_key": "Linkwarden API Anahtarı",
|
||||
"form.integration.linkwarden_endpoint": "Linkwarden API Uç Noktası",
|
||||
"form.integration.linkwarden_endpoint": "Linkwarden Temel URL'si",
|
||||
"form.integration.matrix_bot_activate": "Yeni makaleleri Matrix'e aktarın",
|
||||
"form.integration.matrix_bot_chat_id": "Matrix odasının kimliği",
|
||||
"form.integration.matrix_bot_password": "Matrix kullanıcısı için parola",
|
||||
|
||||
@@ -255,7 +255,7 @@
|
||||
"form.integration.linkding_tags": "Linkding Tags",
|
||||
"form.integration.linkwarden_activate": "Зберігати статті до Linkwarden",
|
||||
"form.integration.linkwarden_api_key": "Ключ API Linkwarden",
|
||||
"form.integration.linkwarden_endpoint": "Linkwarden API Endpoint",
|
||||
"form.integration.linkwarden_endpoint": "Базова URL-адреса Linkwarden",
|
||||
"form.integration.matrix_bot_activate": "Перенесення нових статей в Матрицю",
|
||||
"form.integration.matrix_bot_chat_id": "Ідентифікатор кімнати Матриці",
|
||||
"form.integration.matrix_bot_password": "Пароль для користувача Matrix",
|
||||
|
||||
@@ -6,39 +6,39 @@
|
||||
"action.import": "导入",
|
||||
"action.login": "登录",
|
||||
"action.or": "或",
|
||||
"action.remove": "删除",
|
||||
"action.remove_feed": "删除此源",
|
||||
"action.remove": "移除",
|
||||
"action.remove_feed": "移除此订阅源",
|
||||
"action.save": "保存",
|
||||
"action.subscribe": "订阅",
|
||||
"action.update": "更新",
|
||||
"alert.account_linked": "您的外部账号已关联!",
|
||||
"alert.account_unlinked": "您的外部帐户现已解除关联!",
|
||||
"alert.background_feed_refresh": "所有订阅源正在后台更新。此过程中您仍可继续使用 Miniflux。",
|
||||
"alert.feed_error": "该源存在问题",
|
||||
"alert.no_bookmark": "目前没有收藏",
|
||||
"alert.no_category": "目前没有分类",
|
||||
"alert.no_category_entry": "该分类下没有文章",
|
||||
"alert.no_feed": "目前没有源",
|
||||
"alert.no_feed_entry": "该源中没有文章",
|
||||
"alert.no_feed_in_category": "没有该类别的源。",
|
||||
"alert.no_history": "目前没有历史",
|
||||
"alert.no_search_result": "该搜索没有结果",
|
||||
"alert.no_shared_entry": "没有分享文章。",
|
||||
"alert.no_tag_entry": "没有与此标签匹配的条目。",
|
||||
"alert.no_unread_entry": "目前没有未读文章",
|
||||
"alert.no_user": "您是目前仅有的用户",
|
||||
"alert.prefs_saved": "设置已存储!",
|
||||
"alert.account_unlinked": "您的外部帐户已解除关联!",
|
||||
"alert.background_feed_refresh": "所有订阅源正在后台刷新。您可以在刷新过程中继续使用 Miniflux。",
|
||||
"alert.feed_error": "此订阅源存在问题",
|
||||
"alert.no_bookmark": "没有收藏的条目。",
|
||||
"alert.no_category": "没有分类。",
|
||||
"alert.no_category_entry": "此分类下没有条目。",
|
||||
"alert.no_feed": "你没有任何订阅源。",
|
||||
"alert.no_feed_entry": "此订阅源中没有条目。",
|
||||
"alert.no_feed_in_category": "此分类中没有订阅源。",
|
||||
"alert.no_history": "当前没有历史记录。",
|
||||
"alert.no_search_result": "此搜索没有结果。",
|
||||
"alert.no_shared_entry": "没有已分享条目。",
|
||||
"alert.no_tag_entry": "没有匹配此标签的条目。",
|
||||
"alert.no_unread_entry": "没有未读条目。",
|
||||
"alert.no_user": "您是唯一的用户。",
|
||||
"alert.prefs_saved": "偏好设置已保存!",
|
||||
"alert.too_many_feeds_refresh": [
|
||||
"多次触发订阅源更新,请等待 %d 分钟后重试。"
|
||||
"您触发了太多次订阅源刷新。请在 %d 分钟后重试。"
|
||||
],
|
||||
"confirm.loading": "执行中…",
|
||||
"confirm.loading": "进行中…",
|
||||
"confirm.no": "否",
|
||||
"confirm.question": "您确认吗?",
|
||||
"confirm.question.refresh": "您是否要强制刷新?",
|
||||
"confirm.question": "您确定吗?",
|
||||
"confirm.question.refresh": "您确定要强制刷新吗?",
|
||||
"confirm.yes": "是",
|
||||
"enclosure_media_controls.seek": "查找:",
|
||||
"enclosure_media_controls.seek": "查找:",
|
||||
"enclosure_media_controls.seek.title": "查找 %s 秒",
|
||||
"enclosure_media_controls.speed": "速度:",
|
||||
"enclosure_media_controls.speed": "速度:",
|
||||
"enclosure_media_controls.speed.faster": "快进",
|
||||
"enclosure_media_controls.speed.faster.title": "速度快进到 %sx",
|
||||
"enclosure_media_controls.speed.reset": "重置",
|
||||
@@ -55,112 +55,112 @@
|
||||
"需要 %d 分钟阅读"
|
||||
],
|
||||
"entry.external_link.label": "外部链接",
|
||||
"entry.save.completed": "完成",
|
||||
"entry.save.completed": "完成!",
|
||||
"entry.save.label": "保存",
|
||||
"entry.save.title": "保存这篇文章",
|
||||
"entry.save.toast.completed": "已保存文章",
|
||||
"entry.scraper.completed": "抓取完成",
|
||||
"entry.scraper.label": "抓取全文",
|
||||
"entry.scraper.title": "抓取全文内容",
|
||||
"entry.save.title": "保存此条目",
|
||||
"entry.save.toast.completed": "条目已保存",
|
||||
"entry.scraper.completed": "完成!",
|
||||
"entry.scraper.label": "下载",
|
||||
"entry.scraper.title": "获取原始内容",
|
||||
"entry.share.label": "分享",
|
||||
"entry.share.title": "分享这篇文章",
|
||||
"entry.share.title": "分享此条目",
|
||||
"entry.shared_entry.label": "分享",
|
||||
"entry.shared_entry.title": "打开公共链接",
|
||||
"entry.state.loading": "载入中…",
|
||||
"entry.shared_entry.title": "打开公开链接",
|
||||
"entry.state.loading": "加载中…",
|
||||
"entry.state.saving": "保存中…",
|
||||
"entry.status.mark_as_read": "标为已读",
|
||||
"entry.status.mark_as_unread": "标为未读",
|
||||
"entry.status.title": "更改状态",
|
||||
"entry.status.title": "更改条目状态",
|
||||
"entry.status.toast.read": "已标为已读",
|
||||
"entry.status.toast.unread": "已标为未读",
|
||||
"entry.tags.label": "标签:",
|
||||
"entry.tags.more_tags_label": [
|
||||
"更多标签 (%d)"
|
||||
"显示 %d 个更多标签"
|
||||
],
|
||||
"entry.unshare.label": "取消分享",
|
||||
"error.api_key_already_exists": "此 API 密钥已存在。",
|
||||
"error.bad_credentials": "用户名或密码无效",
|
||||
"error.category_already_exists": "分类已存在",
|
||||
"error.category_not_found": "该分类不存在或不属于该用户。",
|
||||
"error.bad_credentials": "用户名或密码无效。",
|
||||
"error.category_already_exists": "此分类已存在。",
|
||||
"error.category_not_found": "此分类不存在或不属于此用户。",
|
||||
"error.database_error": "数据库错误: %v。",
|
||||
"error.different_passwords": "两次输入的密码不同",
|
||||
"error.duplicate_fever_username": "Fever 用户名已被占用!",
|
||||
"error.duplicate_googlereader_username": "Google Reader 用户名已被占用!",
|
||||
"error.duplicate_linked_account": "该 Provider 已被关联!",
|
||||
"error.duplicated_feed": "该订阅源已经存在。",
|
||||
"error.empty_file": "该文件为空",
|
||||
"error.entries_per_page_invalid": "每页的文章数无效。",
|
||||
"error.feed_already_exists": "此源已存在。",
|
||||
"error.feed_category_not_found": "此类别不存在或不属于该用户。",
|
||||
"error.feed_format_not_detected": "无法解析订阅源格式: %v。",
|
||||
"error.different_passwords": "密码不一致。",
|
||||
"error.duplicate_fever_username": "已存在其他用户使用相同的 Fever 用户名!",
|
||||
"error.duplicate_googlereader_username": "已存在其他用户使用相同的 Google Reader 用户名!",
|
||||
"error.duplicate_linked_account": "已有人与该提供商关联!",
|
||||
"error.duplicated_feed": "此订阅源已经存在。",
|
||||
"error.empty_file": "此文件为空。",
|
||||
"error.entries_per_page_invalid": "每页的条目数无效。",
|
||||
"error.feed_already_exists": "此订阅源已存在。",
|
||||
"error.feed_category_not_found": "此分类不存在或不属于此用户。",
|
||||
"error.feed_format_not_detected": "无法解析订阅源格式:%v。",
|
||||
"error.feed_invalid_blocklist_rule": "阻止列表规则无效。",
|
||||
"error.feed_invalid_keeplist_rule": "保留列表规则无效。",
|
||||
"error.feed_mandatory_fields": "必须填写网址和分类",
|
||||
"error.feed_not_found": "该订阅源不存在或不属于该用户。",
|
||||
"error.feed_mandatory_fields": "必须填写 URL 和分类。",
|
||||
"error.feed_not_found": "此订阅源不存在或不属于此用户。",
|
||||
"error.feed_title_not_empty": "订阅源的标题不能为空。",
|
||||
"error.feed_url_not_empty": "订阅源的 URL 不能为空。",
|
||||
"error.fields_mandatory": "必须填写全部信息",
|
||||
"error.http_bad_gateway": "当前由于错误的网关导致该网站无法访问,问题不在 Miniflux,请稍后重试。",
|
||||
"error.http_body_read": "无法读取HTTP主体: %v。",
|
||||
"error.http_client_error": "HTTP 客户端错误r: %v。",
|
||||
"error.http_empty_response": "HTTP 响应内容为空,该网站可能正在使用机器人保护机制。",
|
||||
"error.http_empty_response_body": "HTTP 响应主体为空。",
|
||||
"error.http_forbidden": "该网站被禁止访问,网站可能有机器人保护机制?",
|
||||
"error.http_gateway_timeout": "当前由于网关超时导致该网站无法访问,问题不在 Miniflux,请稍后重试。",
|
||||
"error.http_internal_server_error": "当前由于服务器错误导致该网站无法访问,问题不在 Miniflux,请稍后重试。",
|
||||
"error.http_not_authorized": "该网站访问未授权,可能用户名和密码错误。",
|
||||
"error.http_resource_not_found": "请求资源无法找到,请检查 URL。",
|
||||
"error.http_response_too_large": "HTTP 响应内容过大,您可以在全局设置中增加 HTTP 响应大小限制(需要服务器重新启动)。",
|
||||
"error.http_service_unavailable": "当前由于服务器内部错误导致该网站无法访问,问题不在 Miniflux,请稍后重试。",
|
||||
"error.http_too_many_requests": "Miniflux 对该网站请求过多次数,请稍后重试或修改应用配置项。",
|
||||
"error.http_unexpected_status_code": "当前由于意外的 HTTP 状态码:%d 导致该网站无法访问,问题不在 Miniflux,请稍后重试。",
|
||||
"error.invalid_categories_sorting_order": "无效的分类排序",
|
||||
"error.fields_mandatory": "必须填写全部信息。",
|
||||
"error.http_bad_gateway": "由于网关错误,网站暂不可用。这不是 Miniflux 的问题,请稍后重试。",
|
||||
"error.http_body_read": "无法读取 HTTP 正文:%v。",
|
||||
"error.http_client_error": "HTTP 客户端错误:%v。",
|
||||
"error.http_empty_response": "HTTP 响应为空,该网站可能使用了反爬虫机制。",
|
||||
"error.http_empty_response_body": "HTTP 响应正文为空。",
|
||||
"error.http_forbidden": "禁止访问该网站。可能该网站使用了反爬虫机制?",
|
||||
"error.http_gateway_timeout": "由于网关超时,网站暂不可用。这不是 Miniflux 的问题,请稍后重试。",
|
||||
"error.http_internal_server_error": "由于服务器错误,网站暂不可用。这不是 Miniflux 的问题,请稍后重试。",
|
||||
"error.http_not_authorized": "未经授权访问此网站。可能是用户名或密码错误。",
|
||||
"error.http_resource_not_found": "未找到请求的资源。请检查 URL。",
|
||||
"error.http_response_too_large": "HTTP 响应过大。您可以在全局设置中增加 HTTP 响应大小限制(需重启服务器)。",
|
||||
"error.http_service_unavailable": "由于内部服务器错误,网站暂不可用。这不是 Miniflux 的问题,请稍后重试。",
|
||||
"error.http_too_many_requests": "Miniflux 向此网站生成了过多请求。请稍后重试或更改应用程序配置。",
|
||||
"error.http_unexpected_status_code": "由于意外的 HTTP 状态码 %d,网站暂不可用。这不是 Miniflux 的问题,请稍后重试。",
|
||||
"error.invalid_categories_sorting_order": "无效的分类排序顺序。",
|
||||
"error.invalid_default_home_page": "无效的默认主页!",
|
||||
"error.invalid_display_mode": "无效的网页应用显示模式。",
|
||||
"error.invalid_entry_direction": "无效的输入方向。",
|
||||
"error.invalid_entry_direction": "无效的条目方向。",
|
||||
"error.invalid_entry_order": "无效的条目排序。",
|
||||
"error.invalid_feed_proxy_url": "无效的代理 URL。",
|
||||
"error.invalid_feed_url": "订阅源的 URL 无效。",
|
||||
"error.invalid_gesture_nav": "手势导航无效。",
|
||||
"error.invalid_feed_url": "无效的订阅源 URL。",
|
||||
"error.invalid_gesture_nav": "无效的手势导航。",
|
||||
"error.invalid_language": "无效的语言。",
|
||||
"error.invalid_site_url": "源网站的 URL 无效。",
|
||||
"error.invalid_site_url": "无效的网站 URL。",
|
||||
"error.invalid_theme": "无效的主题。",
|
||||
"error.invalid_timezone": "无效的时区。",
|
||||
"error.network_operation": "Miniflux 无法访问该网站由于网络错误: %v。",
|
||||
"error.network_timeout": "该网站响应过慢,请求超时: %v",
|
||||
"error.password_min_length": "请至少输入 6 个字符",
|
||||
"error.network_operation": "由于网络错误,Miniflux 无法访问此网站:%v。",
|
||||
"error.network_timeout": "该网站响应过慢,请求已超时:%v",
|
||||
"error.password_min_length": "密码长度至少为 6 个字符。",
|
||||
"error.proxy_url_not_empty": "代理 URL 不能为空。",
|
||||
"error.settings_block_rule_fieldname_invalid": "无效的阻止规则: 规则 #%d 缺少合法的字段名 (可选: %s)",
|
||||
"error.settings_block_rule_invalid_regex": "无效的阻止规则: 规则 #%d 的模式字符不是合法的正则表达式。",
|
||||
"error.settings_block_rule_regex_required": "无效的阻止规则: 规则 #%d 的模式字符没有提供。",
|
||||
"error.settings_block_rule_separator_required": "无效的阻止规则: 规则 #%d 的模式字符必须用‘=’分开。",
|
||||
"error.settings_invalid_domain_list": "域名列表无效。请提供一个用空格分隔的域名列表。",
|
||||
"error.settings_keep_rule_fieldname_invalid": "无效的保留规则: 规则 #%d 缺少合法的字段名 (可选: %s)",
|
||||
"error.settings_keep_rule_invalid_regex": "无效的保留规则: 规则 #%d 的模式字符不是合法的正则表达式。",
|
||||
"error.settings_keep_rule_regex_required": "无效的保留规则: 规则 #%d 的模式字符没有提供。",
|
||||
"error.settings_keep_rule_separator_required": "无效的保留规则: 规则 #%d 的模式字符必须用‘=’分开。",
|
||||
"error.settings_mandatory_fields": "必须填写用户名、主题、语言以及时区",
|
||||
"error.settings_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.site_url_not_empty": "站点 URL 不能为空。",
|
||||
"error.subscription_not_found": "无法找到任何订阅源。",
|
||||
"error.title_required": "必须填写标题。",
|
||||
"error.tls_error": "TLS 错误: %q。如果您愿意的话可以在订阅源设置里关闭 TLS 验证。",
|
||||
"error.unable_to_create_api_key": "无法创建此 API 密钥。",
|
||||
"error.unable_to_create_category": "无法建立这个分类",
|
||||
"error.unable_to_create_user": "无法创建此用户",
|
||||
"error.unable_to_detect_rssbridge": "无法使用 RSS-Bridge 去检测订阅源: %v。",
|
||||
"error.unable_to_parse_feed": "无法解析该订阅源: %v。",
|
||||
"error.unable_to_update_category": "无法更新该分类",
|
||||
"error.unable_to_update_feed": "无法更新此源",
|
||||
"error.unable_to_update_user": "无法更新此用户",
|
||||
"error.unable_to_create_category": "无法创建此分类。",
|
||||
"error.unable_to_create_user": "无法创建此用户。",
|
||||
"error.unable_to_detect_rssbridge": "无法使用 RSS-Bridge 检测订阅源:%v。",
|
||||
"error.unable_to_parse_feed": "无法解析此订阅源:%v。",
|
||||
"error.unable_to_update_category": "无法更新此分类。",
|
||||
"error.unable_to_update_feed": "无法更新此订阅源。",
|
||||
"error.unable_to_update_user": "无法更新此用户。",
|
||||
"error.unlink_account_without_password": "您必须设置密码,否则您将无法再次登录。",
|
||||
"error.user_already_exists": "用户已存在",
|
||||
"error.user_mandatory_fields": "必须填写用户名",
|
||||
"error.user_already_exists": "此用户已存在。",
|
||||
"error.user_mandatory_fields": "必须填写用户名。",
|
||||
"form.api_key.label.description": "API 密钥标签",
|
||||
"form.category.hide_globally": "隐藏全局未读列表中的文章",
|
||||
"form.category.hide_globally": "在全局未读列表中隐藏条目",
|
||||
"form.category.label.title": "标题",
|
||||
"form.feed.fieldset.general": "通用",
|
||||
"form.feed.fieldset.general": "常规",
|
||||
"form.feed.fieldset.integration": "第三方服务",
|
||||
"form.feed.fieldset.network_settings": "网络设置",
|
||||
"form.feed.fieldset.rules": "规则",
|
||||
@@ -168,22 +168,22 @@
|
||||
"form.feed.label.apprise_service_urls": "使用逗号分隔的 Apprise 服务 URL 列表",
|
||||
"form.feed.label.block_filter_entry_rules": "条目屏蔽规则",
|
||||
"form.feed.label.blocklist_rules": "基于正则表达式的屏蔽过滤器",
|
||||
"form.feed.label.category": "类别",
|
||||
"form.feed.label.cookie": "设置 Cookies",
|
||||
"form.feed.label.crawler": "抓取全文内容",
|
||||
"form.feed.label.category": "分类",
|
||||
"form.feed.label.cookie": "设置 Cookie",
|
||||
"form.feed.label.crawler": "获取原始内容",
|
||||
"form.feed.label.description": "描述",
|
||||
"form.feed.label.disable_http2": "关闭 HTTP/2 避免记录指纹",
|
||||
"form.feed.label.disabled": "请勿刷新此源",
|
||||
"form.feed.label.feed_password": "源密码",
|
||||
"form.feed.label.disable_http2": "禁用 HTTP/2 以避免指纹识别",
|
||||
"form.feed.label.disabled": "不刷新此订阅",
|
||||
"form.feed.label.feed_password": "订阅源密码",
|
||||
"form.feed.label.feed_url": "订阅源 URL",
|
||||
"form.feed.label.feed_username": "源用户名",
|
||||
"form.feed.label.feed_username": "订阅源用户名",
|
||||
"form.feed.label.fetch_via_proxy": "使用在应用程序级别配置的代理",
|
||||
"form.feed.label.hide_globally": "隐藏全局未读列表中的文章",
|
||||
"form.feed.label.hide_globally": "在全局未读列表中隐藏条目",
|
||||
"form.feed.label.ignore_http_cache": "忽略 HTTP 缓存",
|
||||
"form.feed.label.keep_filter_entry_rules": "条目允许规则",
|
||||
"form.feed.label.keeplist_rules": "基于正则表达式的保留过滤器",
|
||||
"form.feed.label.no_media_player": "没有媒体播放器(音频/视频)",
|
||||
"form.feed.label.ntfy_activate": "推送条目到ntfy",
|
||||
"form.feed.label.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 低优先级",
|
||||
@@ -192,7 +192,7 @@
|
||||
"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_activate": "推送条目到 Pushover",
|
||||
"form.feed.label.pushover_default_priority": "Pushover 默认优先级",
|
||||
"form.feed.label.pushover_high_priority": "Pushover 高优先级",
|
||||
"form.feed.label.pushover_low_priority": "Pushover 低优先级",
|
||||
@@ -201,24 +201,24 @@
|
||||
"form.feed.label.pushover_priority": "Pushover 消息优先级",
|
||||
"form.feed.label.rewrite_rules": "内容重写规则",
|
||||
"form.feed.label.scraper_rules": "抓取规则",
|
||||
"form.feed.label.site_url": "源网站 URL",
|
||||
"form.feed.label.site_url": "站点 URL",
|
||||
"form.feed.label.title": "标题",
|
||||
"form.feed.label.urlrewrite_rules": "URL 重写规则",
|
||||
"form.feed.label.user_agent": "覆盖默认的用户代理",
|
||||
"form.feed.label.webhook_url": "覆盖 Webhook URL",
|
||||
"form.import.label.file": "OPML 文件",
|
||||
"form.import.label.url": "URL",
|
||||
"form.integration.apprise_activate": "将新文章推送到 Apprise",
|
||||
"form.integration.apprise_activate": "将新条目推送到 Apprise",
|
||||
"form.integration.apprise_services_url": "使用逗号分隔的 Apprise 服务 URL 列表",
|
||||
"form.integration.apprise_url": "Apprise API URL",
|
||||
"form.integration.betula_activate": "保存文章到 Betula",
|
||||
"form.integration.betula_activate": "保存条目到 Betula",
|
||||
"form.integration.betula_token": "Betula 令牌",
|
||||
"form.integration.betula_url": "Betula 服务端 URL",
|
||||
"form.integration.cubox_activate": "保存文章到 Cubox",
|
||||
"form.integration.cubox_activate": "保存条目到 Cubox",
|
||||
"form.integration.cubox_api_link": "Cubox API 链接",
|
||||
"form.integration.discord_activate": "将新文章推送到 Discord",
|
||||
"form.integration.discord_activate": "推送条目到 Discord",
|
||||
"form.integration.discord_webhook_link": "Discord Webhook 链接",
|
||||
"form.integration.espial_activate": "保存文章到 Espial",
|
||||
"form.integration.espial_activate": "保存条目到 Espial",
|
||||
"form.integration.espial_api_key": "Espial API 密钥",
|
||||
"form.integration.espial_endpoint": "Espial API 端点",
|
||||
"form.integration.espial_tags": "Espial 标签",
|
||||
@@ -227,297 +227,297 @@
|
||||
"form.integration.fever_password": "Fever 密码",
|
||||
"form.integration.fever_username": "Fever 用户名",
|
||||
"form.integration.googlereader_activate": "启用 Google Reader API",
|
||||
"form.integration.googlereader_endpoint": "Google Reader API 端点:",
|
||||
"form.integration.googlereader_endpoint": "Google Reader API 端点:",
|
||||
"form.integration.googlereader_password": "Google Reader 密码",
|
||||
"form.integration.googlereader_username": "Google Reader 用户名",
|
||||
"form.integration.instapaper_activate": "保存文章到 Instapaper",
|
||||
"form.integration.instapaper_activate": "保存条目到 Instapaper",
|
||||
"form.integration.instapaper_password": "Instapaper 密码",
|
||||
"form.integration.instapaper_username": "Instapaper 用户名",
|
||||
"form.integration.karakeep_activate": "保存文章到 Karakeep",
|
||||
"form.integration.karakeep_activate": "保存条目到 Karakeep",
|
||||
"form.integration.karakeep_api_key": "Karakeep API 密钥",
|
||||
"form.integration.karakeep_url": "Karakeep API 端点",
|
||||
"form.integration.linkace_activate": "保存文章到 LinkAce",
|
||||
"form.integration.linkace_activate": "保存条目到 LinkAce",
|
||||
"form.integration.linkace_api_key": "LinkAce API 密钥",
|
||||
"form.integration.linkace_check_disabled": "关闭链接检查",
|
||||
"form.integration.linkace_endpoint": "LinkAce API URL",
|
||||
"form.integration.linkace_check_disabled": "禁用链接检查",
|
||||
"form.integration.linkace_endpoint": "LinkAce API 端点",
|
||||
"form.integration.linkace_is_private": "将链接标记为私有",
|
||||
"form.integration.linkace_tags": "LinkAce 标签",
|
||||
"form.integration.linkding_activate": "保存文章到 Linkding",
|
||||
"form.integration.linkding_activate": "保存条目到 Linkding",
|
||||
"form.integration.linkding_api_key": "Linkding API 密钥",
|
||||
"form.integration.linkding_bookmark": "标记为未读",
|
||||
"form.integration.linkding_bookmark": "将书签标记为未读",
|
||||
"form.integration.linkding_endpoint": "Linkding API 端点",
|
||||
"form.integration.linkding_tags": "Linkding 默认标签",
|
||||
"form.integration.linkwarden_activate": "保存文章到 Linkwarden",
|
||||
"form.integration.linkding_tags": "Linkding 标签",
|
||||
"form.integration.linkwarden_activate": "保存条目到 Linkwarden",
|
||||
"form.integration.linkwarden_api_key": "Linkwarden API 密钥",
|
||||
"form.integration.linkwarden_endpoint": "Linkwarden API 端点",
|
||||
"form.integration.matrix_bot_activate": "将新文章推送到 Matrix",
|
||||
"form.integration.matrix_bot_chat_id": "Matrix 聊天 ID",
|
||||
"form.integration.matrix_bot_password": "Matrix Bot 密码",
|
||||
"form.integration.linkwarden_endpoint": "Linkwarden 基本 URL",
|
||||
"form.integration.matrix_bot_activate": "推送新条目到 Matrix",
|
||||
"form.integration.matrix_bot_chat_id": "Matrix 房间 ID",
|
||||
"form.integration.matrix_bot_password": "Matrix 用户密码",
|
||||
"form.integration.matrix_bot_url": "Matrix 服务器 URL",
|
||||
"form.integration.matrix_bot_user": "Matrix Bot 用户名",
|
||||
"form.integration.notion_activate": "保存文章到 Notion",
|
||||
"form.integration.notion_page_id": "Notion 页面ID",
|
||||
"form.integration.notion_token": "Notion 令牌",
|
||||
"form.integration.ntfy_activate": "推送条目到 ntfy",
|
||||
"form.integration.ntfy_api_token": "Ntfy API令牌(可选)",
|
||||
"form.integration.ntfy_icon_url": "Ntfy 图标 URL (可选)",
|
||||
"form.integration.matrix_bot_user": "Matrix 用户名",
|
||||
"form.integration.notion_activate": "保存条目到 Notion",
|
||||
"form.integration.notion_page_id": "Notion 页面 ID",
|
||||
"form.integration.notion_token": "Notion 密钥令牌",
|
||||
"form.integration.ntfy_activate": "推送条目到 Ntfy",
|
||||
"form.integration.ntfy_api_token": "Ntfy API 令牌(可选)",
|
||||
"form.integration.ntfy_icon_url": "Ntfy 图标 URL(可选)",
|
||||
"form.integration.ntfy_internal_links": "点击时使用内部链接(可选)",
|
||||
"form.integration.ntfy_password": "Ntfy 密码(可选)",
|
||||
"form.integration.ntfy_topic": "Ntfy 主题(預設,如果未在此源中定義)",
|
||||
"form.integration.ntfy_topic": "Ntfy 主题(如果订阅源中未设置则使用默认值)",
|
||||
"form.integration.ntfy_url": "Ntfy URL(可选,默认为 ntfy.sh)",
|
||||
"form.integration.ntfy_username": "Ntfy 用户名(可选)",
|
||||
"form.integration.nunux_keeper_activate": "保存文章到 Nunux Keeper",
|
||||
"form.integration.nunux_keeper_activate": "保存条目到 Nunux Keeper",
|
||||
"form.integration.nunux_keeper_api_key": "Nunux Keeper API 密钥",
|
||||
"form.integration.nunux_keeper_endpoint": "Nunux Keeper API 端点",
|
||||
"form.integration.omnivore_activate": "保存文章到 Omnivore",
|
||||
"form.integration.omnivore_activate": "保存条目到 Omnivore",
|
||||
"form.integration.omnivore_api_key": "Omnivore API 密钥",
|
||||
"form.integration.omnivore_url": "Omnivore API 端点",
|
||||
"form.integration.pinboard_activate": "保存文章到 Pinboard",
|
||||
"form.integration.pinboard_bookmark": "标记为未读",
|
||||
"form.integration.pinboard_activate": "保存条目到 Pinboard",
|
||||
"form.integration.pinboard_bookmark": "将书签标记为未读",
|
||||
"form.integration.pinboard_tags": "Pinboard 标签",
|
||||
"form.integration.pinboard_token": "Pinboard API 令牌",
|
||||
"form.integration.pushover_activate": "将条目推送至 Pushover",
|
||||
"form.integration.pushover_device": "Pushover 装置(可选)",
|
||||
"form.integration.pushover_activate": "推送条目到 Pushover",
|
||||
"form.integration.pushover_device": "Pushover 设备(可选)",
|
||||
"form.integration.pushover_prefix": "Pushover URL 前缀(可选)",
|
||||
"form.integration.pushover_token": "Pushover 应用程序 API 令牌",
|
||||
"form.integration.pushover_token": "Pushover 应用 API 令牌",
|
||||
"form.integration.pushover_user": "Pushover 用户密钥",
|
||||
"form.integration.raindrop_activate": "保存文章到 Raindrop",
|
||||
"form.integration.raindrop_activate": "保存条目到 Raindrop",
|
||||
"form.integration.raindrop_collection_id": "集合 ID",
|
||||
"form.integration.raindrop_tags": "Tags (逗号分隔)",
|
||||
"form.integration.raindrop_token": "(Test) 令牌",
|
||||
"form.integration.readeck_activate": "保存文章到 Readeck",
|
||||
"form.integration.raindrop_tags": "标签(逗号分隔)",
|
||||
"form.integration.raindrop_token": "(测试)令牌",
|
||||
"form.integration.readeck_activate": "保存条目到 Readeck",
|
||||
"form.integration.readeck_api_key": "Readeck API 密钥",
|
||||
"form.integration.readeck_endpoint": "Readeck API 端点",
|
||||
"form.integration.readeck_labels": "Readeck 默认标签",
|
||||
"form.integration.readeck_only_url": "仅发送 URL(而不是完整内容)",
|
||||
"form.integration.readwise_activate": "保存文章到 Readwise Reader",
|
||||
"form.integration.readeck_labels": "Readeck 标签",
|
||||
"form.integration.readeck_only_url": "仅发送 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": "RSS-Bridge 服务器 URL",
|
||||
"form.integration.shaarli_activate": "保存文章到 Shaarli",
|
||||
"form.integration.shaarli_activate": "保存条目到 Shaarli",
|
||||
"form.integration.shaarli_api_secret": "Shaarli API 密钥",
|
||||
"form.integration.shaarli_endpoint": "Shaarli URL",
|
||||
"form.integration.shiori_activate": "保存文章到 Shiori",
|
||||
"form.integration.shiori_activate": "保存条目到 Shiori",
|
||||
"form.integration.shiori_endpoint": "Shiori API 端点",
|
||||
"form.integration.shiori_password": "Shiori 密码",
|
||||
"form.integration.shiori_username": "Shiori 用户名",
|
||||
"form.integration.slack_activate": "将新文章推送到 Slack",
|
||||
"form.integration.slack_activate": "推送条目到 Slack",
|
||||
"form.integration.slack_webhook_link": "Slack Webhook 链接",
|
||||
"form.integration.telegram_bot_activate": "将新文章推送到 Telegram",
|
||||
"form.integration.telegram_bot_disable_buttons": "不展示按钮",
|
||||
"form.integration.telegram_bot_activate": "推送新条目到 Telegram 聊天",
|
||||
"form.integration.telegram_bot_disable_buttons": "禁用按钮",
|
||||
"form.integration.telegram_bot_disable_notification": "禁用通知",
|
||||
"form.integration.telegram_bot_disable_web_page_preview": "禁用网页预览",
|
||||
"form.integration.telegram_bot_token": "机器人令牌",
|
||||
"form.integration.telegram_chat_id": "聊天 ID",
|
||||
"form.integration.telegram_topic_id": "主题ID",
|
||||
"form.integration.wallabag_activate": "保存文章到 Wallabag",
|
||||
"form.integration.telegram_topic_id": "主题 ID",
|
||||
"form.integration.wallabag_activate": "保存条目到 Wallabag",
|
||||
"form.integration.wallabag_client_id": "Wallabag 客户端 ID",
|
||||
"form.integration.wallabag_client_secret": "Wallabag 客户端 密钥",
|
||||
"form.integration.wallabag_endpoint": "Wallabag 基本 URL",
|
||||
"form.integration.wallabag_only_url": "仅发送 URL(而不是完整内容)",
|
||||
"form.integration.wallabag_client_secret": "Wallabag 客户端密钥",
|
||||
"form.integration.wallabag_endpoint": "Wallabag 基础 URL",
|
||||
"form.integration.wallabag_only_url": "仅发送 URL(而非完整内容)",
|
||||
"form.integration.wallabag_password": "Wallabag 密码",
|
||||
"form.integration.wallabag_username": "Wallabag 用户名",
|
||||
"form.integration.webhook_activate": "启用 Webhooks",
|
||||
"form.integration.webhook_secret": "Webhooks 密钥",
|
||||
"form.integration.webhook_url": "默认的 Webhook URL",
|
||||
"form.integration.webhook_url": "默认 Webhook URL",
|
||||
"form.prefs.fieldset.application_settings": "应用设置",
|
||||
"form.prefs.fieldset.authentication_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.always_open_external_links": "打开外部链接阅读条目",
|
||||
"form.prefs.label.categories_sorting_order": "分类排序",
|
||||
"form.prefs.label.cjk_reading_speed": "中文、韩文和日文的阅读速度(每分钟字符数)",
|
||||
"form.prefs.label.custom_css": "自定义 CSS",
|
||||
"form.prefs.label.custom_js": "自定义 JavaScript",
|
||||
"form.prefs.label.default_home_page": "默认主页",
|
||||
"form.prefs.label.default_reading_speed": "其他语言的阅读速度(每分钟字数)",
|
||||
"form.prefs.label.display_mode": "渐进式网络应用程序 (PWA) 显示模式",
|
||||
"form.prefs.label.entries_per_page": "每页文章数",
|
||||
"form.prefs.label.entry_order": "文章排序依据",
|
||||
"form.prefs.label.entry_sorting": "文章排序",
|
||||
"form.prefs.label.entry_swipe": "在触摸屏上启用输入滑动",
|
||||
"form.prefs.label.external_font_hosts": "外部字体托管",
|
||||
"form.prefs.label.gesture_nav": "在条目之间导航的手势",
|
||||
"form.prefs.label.display_mode": "渐进式网络应用程序(PWA)显示模式",
|
||||
"form.prefs.label.entries_per_page": "每页条目数",
|
||||
"form.prefs.label.entry_order": "条目排序字段",
|
||||
"form.prefs.label.entry_sorting": "条目排序",
|
||||
"form.prefs.label.entry_swipe": "在触摸屏上启用条目滑动",
|
||||
"form.prefs.label.external_font_hosts": "外部字体主机",
|
||||
"form.prefs.label.gesture_nav": "在条目间导航的手势",
|
||||
"form.prefs.label.keyboard_shortcuts": "启用键盘快捷键",
|
||||
"form.prefs.label.language": "语言",
|
||||
"form.prefs.label.mark_read_manually": "手动标记条目为已读",
|
||||
"form.prefs.label.mark_read_on_media_completion": "仅当音频/视频播放完成90%%时标记为已读",
|
||||
"form.prefs.label.mark_read_on_media_completion": "仅当音频/视频播放完成 90%% 时标记为已读",
|
||||
"form.prefs.label.mark_read_on_view": "查看时自动将条目标记为已读",
|
||||
"form.prefs.label.mark_read_on_view_or_media_completion": "当浏览时标记条目为已读。对于音频/视频,当播放完成90%%时标记为已读",
|
||||
"form.prefs.label.mark_read_on_view_or_media_completion": "当浏览时标记条目为已读。对于音频/视频,当播放完成 90%% 时标记为已读",
|
||||
"form.prefs.label.media_playback_rate": "音频/视频的播放速度",
|
||||
"form.prefs.label.open_external_links_in_new_tab": "在新标签页中打开外部链接(为链接添加 target=\"_blank\")",
|
||||
"form.prefs.label.show_reading_time": "显示文章的预计阅读时间",
|
||||
"form.prefs.label.show_reading_time": "显示条目的预计阅读时间",
|
||||
"form.prefs.label.theme": "主题",
|
||||
"form.prefs.label.timezone": "时区",
|
||||
"form.prefs.select.alphabetical": "按字母顺序",
|
||||
"form.prefs.select.alphabetical": "字母顺序",
|
||||
"form.prefs.select.browser": "浏览器",
|
||||
"form.prefs.select.created_time": "文章创建时间",
|
||||
"form.prefs.select.created_time": "条目创建时间",
|
||||
"form.prefs.select.fullscreen": "全屏",
|
||||
"form.prefs.select.minimal_ui": "最小",
|
||||
"form.prefs.select.none": "没有任何",
|
||||
"form.prefs.select.older_first": "旧->新",
|
||||
"form.prefs.select.publish_time": "文章发布时间",
|
||||
"form.prefs.select.publish_time": "条目发布时间",
|
||||
"form.prefs.select.recent_first": "新->旧",
|
||||
"form.prefs.select.standalone": "独立",
|
||||
"form.prefs.select.swipe": "滑动",
|
||||
"form.prefs.select.tap": "双击",
|
||||
"form.prefs.select.unread_count": "未读计数",
|
||||
"form.submit.loading": "载入中…",
|
||||
"form.submit.loading": "加载中…",
|
||||
"form.submit.saving": "保存中…",
|
||||
"form.user.label.admin": "管理员",
|
||||
"form.user.label.confirmation": "再次输入密码",
|
||||
"form.user.label.confirmation": "确认密码",
|
||||
"form.user.label.password": "密码",
|
||||
"form.user.label.username": "用户名",
|
||||
"menu.about": "关于",
|
||||
"menu.add_feed": "新增源",
|
||||
"menu.add_user": "新建用户",
|
||||
"menu.add_feed": "添加订阅源",
|
||||
"menu.add_user": "添加用户",
|
||||
"menu.api_keys": "API 密钥",
|
||||
"menu.categories": "分类",
|
||||
"menu.create_api_key": "创建一个新的 API 密钥",
|
||||
"menu.create_category": "新建分类",
|
||||
"menu.create_api_key": "创建新 API 密钥",
|
||||
"menu.create_category": "创建分类",
|
||||
"menu.edit_category": "编辑",
|
||||
"menu.edit_feed": "编辑",
|
||||
"menu.export": "导出",
|
||||
"menu.feed_entries": "文章",
|
||||
"menu.feeds": "源",
|
||||
"menu.flush_history": "清理历史",
|
||||
"menu.history": "历史",
|
||||
"menu.home_page": "首页",
|
||||
"menu.feed_entries": "条目",
|
||||
"menu.feeds": "订阅源",
|
||||
"menu.flush_history": "清除历史记录",
|
||||
"menu.history": "历史记录",
|
||||
"menu.home_page": "主页",
|
||||
"menu.import": "导入",
|
||||
"menu.integrations": "集成",
|
||||
"menu.logout": "登出",
|
||||
"menu.mark_all_as_read": "全部标为已读",
|
||||
"menu.mark_page_as_read": "标记为已读",
|
||||
"menu.preferences": "设置",
|
||||
"menu.refresh_all_feeds": "在后台更新全部源",
|
||||
"menu.refresh_feed": "更新",
|
||||
"menu.mark_page_as_read": "将此页标为已读",
|
||||
"menu.preferences": "偏好设置",
|
||||
"menu.refresh_all_feeds": "后台刷新所有订阅源",
|
||||
"menu.refresh_feed": "刷新",
|
||||
"menu.search": "搜索",
|
||||
"menu.sessions": "会话",
|
||||
"menu.settings": "设置",
|
||||
"menu.shared_entries": "已分享的文章",
|
||||
"menu.show_all_entries": "显示所有文章",
|
||||
"menu.show_only_starred_entries": "仅显示已收藏文章",
|
||||
"menu.show_only_unread_entries": "仅显示未读文章",
|
||||
"menu.shared_entries": "已共享的条目",
|
||||
"menu.show_all_entries": "显示所有条目",
|
||||
"menu.show_only_starred_entries": "仅显示已收藏条目",
|
||||
"menu.show_only_unread_entries": "仅显示未读条目",
|
||||
"menu.starred": "收藏",
|
||||
"menu.title": "菜单",
|
||||
"menu.unread": "未读",
|
||||
"menu.users": "用户",
|
||||
"page.about.author": "作者:",
|
||||
"page.about.build_date": "构建日期:",
|
||||
"page.about.credits": "版权",
|
||||
"page.about.db_usage": "数据库容量",
|
||||
"page.about.git_commit": "Git提交:",
|
||||
"page.about.credits": "鸣谢",
|
||||
"page.about.db_usage": "数据库大小:",
|
||||
"page.about.git_commit": "Git 提交:",
|
||||
"page.about.global_config_options": "全局配置选项",
|
||||
"page.about.go_version": "Go 版本号:",
|
||||
"page.about.license": "协议:",
|
||||
"page.about.postgres_version": "Postgres 版本号:",
|
||||
"page.about.go_version": "Go 版本:",
|
||||
"page.about.license": "许可证:",
|
||||
"page.about.postgres_version": "Postgres 版本:",
|
||||
"page.about.title": "关于",
|
||||
"page.about.version": "版本号:",
|
||||
"page.add_feed.choose_feed": "选择一个源",
|
||||
"page.about.version": "版本:",
|
||||
"page.add_feed.choose_feed": "选择订阅源",
|
||||
"page.add_feed.label.url": "URL",
|
||||
"page.add_feed.legend.advanced_options": "高级选项",
|
||||
"page.add_feed.no_category": "没有类别,至少需要有一个类别",
|
||||
"page.add_feed.submit": "查找源",
|
||||
"page.add_feed.title": "新增源",
|
||||
"page.api_keys.never_used": "没用过",
|
||||
"page.add_feed.no_category": "没有分类。您必须至少有一个分类。",
|
||||
"page.add_feed.submit": "查找订阅源",
|
||||
"page.add_feed.title": "新建订阅源",
|
||||
"page.api_keys.never_used": "从未使用",
|
||||
"page.api_keys.table.actions": "操作",
|
||||
"page.api_keys.table.created_at": "创建日期",
|
||||
"page.api_keys.table.description": "描述",
|
||||
"page.api_keys.table.last_used_at": "最后使用",
|
||||
"page.api_keys.table.token": "令牌",
|
||||
"page.api_keys.title": "API 密钥",
|
||||
"page.categories.entries": "查看内容",
|
||||
"page.categories.entries": "条目",
|
||||
"page.categories.feed_count": [
|
||||
"有 %d 个源"
|
||||
"有 %d 个订阅源"
|
||||
],
|
||||
"page.categories.feeds": "查看源",
|
||||
"page.categories.no_feed": "没有源",
|
||||
"page.categories.feeds": "订阅源",
|
||||
"page.categories.no_feed": "无订阅源。",
|
||||
"page.categories.title": "分类",
|
||||
"page.categories_count": [
|
||||
"%d 分类"
|
||||
"%d 个分类"
|
||||
],
|
||||
"page.category_label": "分类: %s",
|
||||
"page.edit_category.title": "编辑分类 : %s",
|
||||
"page.edit_category.title": "编辑分类:%s",
|
||||
"page.edit_feed.etag_header": "ETag 标题:",
|
||||
"page.edit_feed.last_check": "最后检查时间:",
|
||||
"page.edit_feed.last_modified_header": "最后修改的 Header:",
|
||||
"page.edit_feed.last_parsing_error": "最后一次解析错误",
|
||||
"page.edit_feed.no_header": "无 Header",
|
||||
"page.edit_feed.title": "编辑源 : %s",
|
||||
"page.edit_user.title": "编辑用户 : %s",
|
||||
"page.edit_feed.title": "编辑订阅源: %s",
|
||||
"page.edit_user.title": "编辑用户: %s",
|
||||
"page.entry.attachments": "附件",
|
||||
"page.feeds.error_count": [
|
||||
"%d 错误"
|
||||
],
|
||||
"page.feeds.last_check": "最后检查时间:",
|
||||
"page.feeds.next_check": "下次检查时间:",
|
||||
"page.feeds.read_counter": "已读文章数",
|
||||
"page.feeds.title": "源",
|
||||
"page.history.title": "历史",
|
||||
"page.feeds.last_check": "最后检查:",
|
||||
"page.feeds.next_check": "下次检查:",
|
||||
"page.feeds.read_counter": "已读条目数",
|
||||
"page.feeds.title": "订阅源",
|
||||
"page.history.title": "历史记录",
|
||||
"page.import.title": "导入",
|
||||
"page.integration.bookmarklet": "书签小应用",
|
||||
"page.integration.bookmarklet.help": "你可以打开这个特殊的书签来直接收藏网站",
|
||||
"page.integration.bookmarklet.instructions": "拖动这个链接到浏览器书签栏",
|
||||
"page.integration.bookmarklet.name": "收藏 Miniflux",
|
||||
"page.integration.bookmarklet.help": "此链接允许您通过浏览器书签直接订阅网站。",
|
||||
"page.integration.bookmarklet.instructions": "将此链接拖动到您的书签栏。",
|
||||
"page.integration.bookmarklet.name": "添加到 Miniflux",
|
||||
"page.integration.miniflux_api": "Miniflux API",
|
||||
"page.integration.miniflux_api_endpoint": "API 端点",
|
||||
"page.integration.miniflux_api_password": "密码",
|
||||
"page.integration.miniflux_api_password_value": "您账户的密码",
|
||||
"page.integration.miniflux_api_password_value": "您账号的密码",
|
||||
"page.integration.miniflux_api_username": "用户名",
|
||||
"page.integrations.title": "集成",
|
||||
"page.keyboard_shortcuts.close_modal": "关闭对话窗口",
|
||||
"page.keyboard_shortcuts.download_content": "抓取全文内容",
|
||||
"page.keyboard_shortcuts.go_to_bottom_item": "转到底部项目",
|
||||
"page.keyboard_shortcuts.go_to_categories": "打开分类页面",
|
||||
"page.keyboard_shortcuts.go_to_feed": "转到源页面",
|
||||
"page.keyboard_shortcuts.go_to_feeds": "打开源页面",
|
||||
"page.keyboard_shortcuts.go_to_history": "打开历史页面",
|
||||
"page.keyboard_shortcuts.go_to_next_item": "下一文章",
|
||||
"page.keyboard_shortcuts.go_to_next_page": "下一页",
|
||||
"page.keyboard_shortcuts.go_to_previous_item": "上一文章",
|
||||
"page.keyboard_shortcuts.go_to_previous_page": "上一页",
|
||||
"page.keyboard_shortcuts.go_to_search": "将焦点放在搜索表单上",
|
||||
"page.keyboard_shortcuts.go_to_settings": "打开设置页面",
|
||||
"page.keyboard_shortcuts.go_to_starred": "打开收藏页面",
|
||||
"page.keyboard_shortcuts.go_to_top_item": "转到顶部项目",
|
||||
"page.keyboard_shortcuts.go_to_unread": "打开未读页面",
|
||||
"page.keyboard_shortcuts.mark_page_as_read": "标记当前页已读",
|
||||
"page.keyboard_shortcuts.download_content": "下载原始内容",
|
||||
"page.keyboard_shortcuts.go_to_bottom_item": "跳转到最后一条",
|
||||
"page.keyboard_shortcuts.go_to_categories": "转到分类",
|
||||
"page.keyboard_shortcuts.go_to_feed": "转到订阅源",
|
||||
"page.keyboard_shortcuts.go_to_feeds": "转到订阅源列表",
|
||||
"page.keyboard_shortcuts.go_to_history": "转到历史记录",
|
||||
"page.keyboard_shortcuts.go_to_next_item": "转到下一条目",
|
||||
"page.keyboard_shortcuts.go_to_next_page": "转到下一页",
|
||||
"page.keyboard_shortcuts.go_to_previous_item": "转到上一条目",
|
||||
"page.keyboard_shortcuts.go_to_previous_page": "转到上一页",
|
||||
"page.keyboard_shortcuts.go_to_search": "聚焦到搜索框",
|
||||
"page.keyboard_shortcuts.go_to_settings": "转到设置",
|
||||
"page.keyboard_shortcuts.go_to_starred": "转到收藏",
|
||||
"page.keyboard_shortcuts.go_to_top_item": "转到第一条",
|
||||
"page.keyboard_shortcuts.go_to_unread": "转到未读",
|
||||
"page.keyboard_shortcuts.mark_page_as_read": "标记当前页为已读",
|
||||
"page.keyboard_shortcuts.open_comments": "打开评论链接",
|
||||
"page.keyboard_shortcuts.open_comments_same_window": "在当前标签页中打开评论链接",
|
||||
"page.keyboard_shortcuts.open_item": "打开选定的文章",
|
||||
"page.keyboard_shortcuts.open_item": "打开选定的条目",
|
||||
"page.keyboard_shortcuts.open_original": "打开原始链接",
|
||||
"page.keyboard_shortcuts.open_original_same_window": "在当前标签页中打开原始链接",
|
||||
"page.keyboard_shortcuts.refresh_all_feeds": "在后台更新全部源",
|
||||
"page.keyboard_shortcuts.remove_feed": "删除此源",
|
||||
"page.keyboard_shortcuts.save_article": "保存文章",
|
||||
"page.keyboard_shortcuts.refresh_all_feeds": "在后台刷新全部订阅源",
|
||||
"page.keyboard_shortcuts.remove_feed": "移除此订阅源",
|
||||
"page.keyboard_shortcuts.save_article": "保存条目",
|
||||
"page.keyboard_shortcuts.scroll_item_to_top": "滚动到顶部",
|
||||
"page.keyboard_shortcuts.show_keyboard_shortcuts": "显示快捷键帮助",
|
||||
"page.keyboard_shortcuts.subtitle.actions": "操作",
|
||||
"page.keyboard_shortcuts.subtitle.items": "文章导航",
|
||||
"page.keyboard_shortcuts.subtitle.items": "条目导航",
|
||||
"page.keyboard_shortcuts.subtitle.pages": "页面导航",
|
||||
"page.keyboard_shortcuts.subtitle.sections": "分区导航",
|
||||
"page.keyboard_shortcuts.title": "快捷键",
|
||||
"page.keyboard_shortcuts.subtitle.sections": "区域导航",
|
||||
"page.keyboard_shortcuts.title": "键盘快捷键",
|
||||
"page.keyboard_shortcuts.toggle_bookmark_status": "切换收藏状态",
|
||||
"page.keyboard_shortcuts.toggle_entry_attachments": "展开/折叠文章附件",
|
||||
"page.keyboard_shortcuts.toggle_read_status_next": "切换已读/未读状态, 关注下一个",
|
||||
"page.keyboard_shortcuts.toggle_read_status_prev": "切换已读/未读状态, 关注前一个",
|
||||
"page.keyboard_shortcuts.toggle_entry_attachments": "切换展开/折叠条目附件",
|
||||
"page.keyboard_shortcuts.toggle_read_status_next": "切换已读/未读状态,并切换到下一项",
|
||||
"page.keyboard_shortcuts.toggle_read_status_prev": "切换已读/未读状态,并切换到上一项",
|
||||
"page.login.google_signin": "使用 Google 登录",
|
||||
"page.login.oidc_signin": "使用 %s 登录",
|
||||
"page.login.title": "登录",
|
||||
"page.login.webauthn_login": "使用通行密钥登录",
|
||||
"page.login.webauthn_login.error": "无法使用通行密钥登录",
|
||||
"page.login.webauthn_login.help": "如果您正在使用安全密钥,请输入您的用户名;如果您正在使用通行密钥(可发现凭证),则无需输入。",
|
||||
"page.login.webauthn_login.help": "如果您正在使用安全密钥,请输入您的用户名。如果您正在使用通行密钥(可发现凭证),则无需输入。",
|
||||
"page.new_api_key.title": "新的 API 密钥",
|
||||
"page.new_category.title": "新分类",
|
||||
"page.new_user.title": "新用户",
|
||||
"page.new_category.title": "新建分类",
|
||||
"page.new_user.title": "新建用户",
|
||||
"page.offline.message": "您已离线",
|
||||
"page.offline.refresh_page": "尝试刷新页面",
|
||||
"page.offline.title": "离线模式",
|
||||
"page.read_entry_count": [
|
||||
"%d 阅读文章"
|
||||
"%d 个已读条目"
|
||||
],
|
||||
"page.search.title": "搜索结果",
|
||||
"page.sessions.table.actions": "操作",
|
||||
@@ -526,42 +526,42 @@
|
||||
"page.sessions.table.ip": "IP 地址",
|
||||
"page.sessions.table.user_agent": "用户代理",
|
||||
"page.sessions.title": "会话",
|
||||
"page.settings.link_google_account": "关联我的 Google 账户",
|
||||
"page.settings.link_oidc_account": "关联我的 %s 账户",
|
||||
"page.settings.link_google_account": "关联我的 Google 账号",
|
||||
"page.settings.link_oidc_account": "关联我的 %s 账号",
|
||||
"page.settings.title": "设置",
|
||||
"page.settings.unlink_google_account": "解除 Google 账号关联",
|
||||
"page.settings.unlink_oidc_account": "解除 %s 账号关联",
|
||||
"page.settings.webauthn.actions": "操作",
|
||||
"page.settings.webauthn.added_on": "添加时间",
|
||||
"page.settings.webauthn.added_on": "添加于",
|
||||
"page.settings.webauthn.delete": [
|
||||
"删除 %d 个通行密钥"
|
||||
],
|
||||
"page.settings.webauthn.last_seen_on": "最后使用时间",
|
||||
"page.settings.webauthn.last_seen_on": "最后使用",
|
||||
"page.settings.webauthn.passkey_name": "通行密钥名称",
|
||||
"page.settings.webauthn.passkeys": "通行密钥列表",
|
||||
"page.settings.webauthn.passkeys": "通行密钥",
|
||||
"page.settings.webauthn.register": "注册通行密钥",
|
||||
"page.settings.webauthn.register.error": "无法注册通行密钥",
|
||||
"page.shared_entries.title": "已分享的文章",
|
||||
"page.shared_entries.title": "已共享的条目",
|
||||
"page.shared_entries_count": [
|
||||
"%d 已分享的文章"
|
||||
"%d 个共享条目"
|
||||
],
|
||||
"page.starred.title": "收藏",
|
||||
"page.starred_entry_count": [
|
||||
"%d 收藏的文章"
|
||||
"%d 个收藏条目"
|
||||
],
|
||||
"page.total_entry_count": [
|
||||
"%d 文章总数"
|
||||
"%d 个条目"
|
||||
],
|
||||
"page.unread.title": "未读",
|
||||
"page.unread_entry_count": [
|
||||
"%d 未读的文章"
|
||||
"%d 个未读条目"
|
||||
],
|
||||
"page.users.actions": "操作",
|
||||
"page.users.admin.no": "否",
|
||||
"page.users.admin.yes": "是",
|
||||
"page.users.is_admin": "管理员",
|
||||
"page.users.last_login": "最后登录时间",
|
||||
"page.users.never_logged": "从未登录",
|
||||
"page.users.last_login": "最后登录",
|
||||
"page.users.never_logged": "从未",
|
||||
"page.users.title": "用户",
|
||||
"page.users.username": "用户名",
|
||||
"page.webauthn_rename.title": "重命名通行密钥",
|
||||
@@ -571,7 +571,7 @@
|
||||
"pagination.previous": "上一页",
|
||||
"search.label": "搜索",
|
||||
"search.placeholder": "搜索…",
|
||||
"search.submit": "查找",
|
||||
"search.submit": "搜索",
|
||||
"skip_to_content": "跳转至内容",
|
||||
"time_elapsed.days": [
|
||||
"%d 天前"
|
||||
@@ -594,6 +594,6 @@
|
||||
"%d 年前"
|
||||
],
|
||||
"time_elapsed.yesterday": "昨天",
|
||||
"tooltip.keyboard_shortcuts": "快捷键: %s",
|
||||
"tooltip.logged_user": "当前登录 %s"
|
||||
}
|
||||
"tooltip.keyboard_shortcuts": "键盘快捷键:%s",
|
||||
"tooltip.logged_user": "登录用户:%s"
|
||||
}
|
||||
|
||||
@@ -249,7 +249,7 @@
|
||||
"form.integration.linkding_tags": "Linkding 標籤",
|
||||
"form.integration.linkwarden_activate": "儲存文章到 Linkwarden",
|
||||
"form.integration.linkwarden_api_key": "Linkwarden API 金鑰",
|
||||
"form.integration.linkwarden_endpoint": "Linkwarden API 端點",
|
||||
"form.integration.linkwarden_endpoint": "Linkwarden 基本 URL",
|
||||
"form.integration.matrix_bot_activate": "推送文章到 Matrix",
|
||||
"form.integration.matrix_bot_chat_id": "Matrix 房間 ID",
|
||||
"form.integration.matrix_bot_password": "Matrix 密碼",
|
||||
|
||||
@@ -278,19 +278,8 @@ func TestProxyFilterWithHttpsAlwaysAndIncorrectCustomProxyServer(t *testing.T) {
|
||||
var err error
|
||||
parser := config.NewParser()
|
||||
config.Opts, err = parser.ParseEnvironmentVariables()
|
||||
if err != nil {
|
||||
t.Fatalf(`Parsing failure: %v`, err)
|
||||
}
|
||||
|
||||
r := mux.NewRouter()
|
||||
r.HandleFunc("/proxy/{encodedDigest}/{encodedURL}", func(w http.ResponseWriter, r *http.Request) {}).Name("proxy")
|
||||
|
||||
input := `<p><img src="https://website/folder/image.png" alt="Test"/></p>`
|
||||
output := RewriteDocumentWithRelativeProxyURL(r, input)
|
||||
expected := `<p><img src="https://website/folder/image.png" alt="Test"/></p>`
|
||||
|
||||
if expected != output {
|
||||
t.Errorf(`Not expected output: got %q instead of %q`, output, expected)
|
||||
if err == nil {
|
||||
t.Fatalf(`Incorrect proxy URL silently accepted (MEDIA_PROXY_CUSTOM_URL=%q): %q`, os.Getenv("MEDIA_PROXY_CUSTOM_URL"), config.Opts.MediaCustomProxyURL())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -589,3 +578,145 @@ func TestProxyFilterVideoPosterOnce(t *testing.T) {
|
||||
t.Errorf(`Not expected output: got %s`, output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShouldProxifyURLWithMimeType(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
mediaURL string
|
||||
mediaMimeType string
|
||||
mediaProxyOption string
|
||||
mediaProxyResourceTypes []string
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "Empty URL should not be proxified",
|
||||
mediaURL: "",
|
||||
mediaMimeType: "image/jpeg",
|
||||
mediaProxyOption: "all",
|
||||
mediaProxyResourceTypes: []string{"image"},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "Data URL should not be proxified",
|
||||
mediaURL: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==",
|
||||
mediaMimeType: "image/png",
|
||||
mediaProxyOption: "all",
|
||||
mediaProxyResourceTypes: []string{"image"},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "HTTP URL with all mode and matching MIME type should be proxified",
|
||||
mediaURL: "http://example.com/image.jpg",
|
||||
mediaMimeType: "image/jpeg",
|
||||
mediaProxyOption: "all",
|
||||
mediaProxyResourceTypes: []string{"image"},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "HTTPS URL with all mode and matching MIME type should be proxified",
|
||||
mediaURL: "https://example.com/image.jpg",
|
||||
mediaMimeType: "image/jpeg",
|
||||
mediaProxyOption: "all",
|
||||
mediaProxyResourceTypes: []string{"image"},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "HTTP URL with http-only mode and matching MIME type should be proxified",
|
||||
mediaURL: "http://example.com/image.jpg",
|
||||
mediaMimeType: "image/jpeg",
|
||||
mediaProxyOption: "http-only",
|
||||
mediaProxyResourceTypes: []string{"image"},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "HTTPS URL with http-only mode should not be proxified",
|
||||
mediaURL: "https://example.com/image.jpg",
|
||||
mediaMimeType: "image/jpeg",
|
||||
mediaProxyOption: "http-only",
|
||||
mediaProxyResourceTypes: []string{"image"},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "URL with none mode should not be proxified",
|
||||
mediaURL: "http://example.com/image.jpg",
|
||||
mediaMimeType: "image/jpeg",
|
||||
mediaProxyOption: "none",
|
||||
mediaProxyResourceTypes: []string{"image"},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "URL with matching MIME type should be proxified",
|
||||
mediaURL: "http://example.com/video.mp4",
|
||||
mediaMimeType: "video/mp4",
|
||||
mediaProxyOption: "all",
|
||||
mediaProxyResourceTypes: []string{"video"},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "URL with non-matching MIME type should not be proxified",
|
||||
mediaURL: "http://example.com/video.mp4",
|
||||
mediaMimeType: "video/mp4",
|
||||
mediaProxyOption: "all",
|
||||
mediaProxyResourceTypes: []string{"image"},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "URL with multiple resource types and matching MIME type should be proxified",
|
||||
mediaURL: "http://example.com/audio.mp3",
|
||||
mediaMimeType: "audio/mp3",
|
||||
mediaProxyOption: "all",
|
||||
mediaProxyResourceTypes: []string{"image", "audio", "video"},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "URL with multiple resource types but non-matching MIME type should not be proxified",
|
||||
mediaURL: "http://example.com/document.pdf",
|
||||
mediaMimeType: "application/pdf",
|
||||
mediaProxyOption: "all",
|
||||
mediaProxyResourceTypes: []string{"image", "audio", "video"},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "URL with empty resource types should not be proxified",
|
||||
mediaURL: "http://example.com/image.jpg",
|
||||
mediaMimeType: "image/jpeg",
|
||||
mediaProxyOption: "all",
|
||||
mediaProxyResourceTypes: []string{},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "URL with partial MIME type match should be proxified",
|
||||
mediaURL: "http://example.com/image.jpg",
|
||||
mediaMimeType: "image/jpeg",
|
||||
mediaProxyOption: "all",
|
||||
mediaProxyResourceTypes: []string{"image"},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "URL with audio MIME type and audio resource type should be proxified",
|
||||
mediaURL: "http://example.com/song.ogg",
|
||||
mediaMimeType: "audio/ogg",
|
||||
mediaProxyOption: "all",
|
||||
mediaProxyResourceTypes: []string{"audio"},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "URL with video MIME type and video resource type should be proxified",
|
||||
mediaURL: "http://example.com/movie.webm",
|
||||
mediaMimeType: "video/webm",
|
||||
mediaProxyOption: "all",
|
||||
mediaProxyResourceTypes: []string{"video"},
|
||||
expected: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
result := ShouldProxifyURLWithMimeType(tc.mediaURL, tc.mediaMimeType, tc.mediaProxyOption, tc.mediaProxyResourceTypes)
|
||||
if result != tc.expected {
|
||||
t.Errorf("Expected %v, got %v for URL: %s, MIME type: %s, proxy option: %s, resource types: %v",
|
||||
tc.expected, result, tc.mediaURL, tc.mediaMimeType, tc.mediaProxyOption, tc.mediaProxyResourceTypes)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ func genericProxyRewriter(router *mux.Router, proxifyFunction urlProxyRewriter,
|
||||
case "image":
|
||||
doc.Find("img, picture source").Each(func(i int, img *goquery.Selection) {
|
||||
if srcAttrValue, ok := img.Attr("src"); ok {
|
||||
if shouldProxy(srcAttrValue, proxyOption) {
|
||||
if shouldProxifyURL(srcAttrValue, proxyOption) {
|
||||
img.SetAttr("src", proxifyFunction(router, srcAttrValue))
|
||||
}
|
||||
}
|
||||
@@ -54,7 +54,7 @@ func genericProxyRewriter(router *mux.Router, proxifyFunction urlProxyRewriter,
|
||||
if !slices.Contains(config.Opts.MediaProxyResourceTypes(), "video") {
|
||||
doc.Find("video").Each(func(i int, video *goquery.Selection) {
|
||||
if posterAttrValue, ok := video.Attr("poster"); ok {
|
||||
if shouldProxy(posterAttrValue, proxyOption) {
|
||||
if shouldProxifyURL(posterAttrValue, proxyOption) {
|
||||
video.SetAttr("poster", proxifyFunction(router, posterAttrValue))
|
||||
}
|
||||
}
|
||||
@@ -64,7 +64,7 @@ func genericProxyRewriter(router *mux.Router, proxifyFunction urlProxyRewriter,
|
||||
case "audio":
|
||||
doc.Find("audio, audio source").Each(func(i int, audio *goquery.Selection) {
|
||||
if srcAttrValue, ok := audio.Attr("src"); ok {
|
||||
if shouldProxy(srcAttrValue, proxyOption) {
|
||||
if shouldProxifyURL(srcAttrValue, proxyOption) {
|
||||
audio.SetAttr("src", proxifyFunction(router, srcAttrValue))
|
||||
}
|
||||
}
|
||||
@@ -73,13 +73,13 @@ func genericProxyRewriter(router *mux.Router, proxifyFunction urlProxyRewriter,
|
||||
case "video":
|
||||
doc.Find("video, video source").Each(func(i int, video *goquery.Selection) {
|
||||
if srcAttrValue, ok := video.Attr("src"); ok {
|
||||
if shouldProxy(srcAttrValue, proxyOption) {
|
||||
if shouldProxifyURL(srcAttrValue, proxyOption) {
|
||||
video.SetAttr("src", proxifyFunction(router, srcAttrValue))
|
||||
}
|
||||
}
|
||||
|
||||
if posterAttrValue, ok := video.Attr("poster"); ok {
|
||||
if shouldProxy(posterAttrValue, proxyOption) {
|
||||
if shouldProxifyURL(posterAttrValue, proxyOption) {
|
||||
video.SetAttr("poster", proxifyFunction(router, posterAttrValue))
|
||||
}
|
||||
}
|
||||
@@ -99,7 +99,7 @@ func proxifySourceSet(element *goquery.Selection, router *mux.Router, proxifyFun
|
||||
imageCandidates := sanitizer.ParseSrcSetAttribute(srcsetAttrValue)
|
||||
|
||||
for _, imageCandidate := range imageCandidates {
|
||||
if shouldProxy(imageCandidate.ImageURL, proxyOption) {
|
||||
if shouldProxifyURL(imageCandidate.ImageURL, proxyOption) {
|
||||
imageCandidate.ImageURL = proxifyFunction(router, imageCandidate.ImageURL)
|
||||
}
|
||||
}
|
||||
@@ -107,7 +107,33 @@ func proxifySourceSet(element *goquery.Selection, router *mux.Router, proxifyFun
|
||||
element.SetAttr("srcset", imageCandidates.String())
|
||||
}
|
||||
|
||||
func shouldProxy(attrValue, proxyOption string) bool {
|
||||
return !strings.HasPrefix(attrValue, "data:") &&
|
||||
(proxyOption == "all" || !urllib.IsHTTPS(attrValue))
|
||||
// shouldProxifyURL checks if the media URL should be proxified based on the media proxy option and URL scheme.
|
||||
func shouldProxifyURL(mediaURL, mediaProxyOption string) bool {
|
||||
switch {
|
||||
case mediaURL == "":
|
||||
return false
|
||||
case strings.HasPrefix(mediaURL, "data:"):
|
||||
return false
|
||||
case mediaProxyOption == "all":
|
||||
return true
|
||||
case mediaProxyOption != "none" && !urllib.IsHTTPS(mediaURL):
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// ShouldProxifyURLWithMimeType checks if the media URL should be proxified based on the media proxy option, URL scheme, and MIME type.
|
||||
func ShouldProxifyURLWithMimeType(mediaURL, mediaMimeType, mediaProxyOption string, mediaProxyResourceTypes []string) bool {
|
||||
if !shouldProxifyURL(mediaURL, mediaProxyOption) {
|
||||
return false
|
||||
}
|
||||
|
||||
for _, mediaType := range mediaProxyResourceTypes {
|
||||
if strings.HasPrefix(mediaMimeType, mediaType+"/") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"log/slog"
|
||||
"net/url"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
@@ -21,7 +20,7 @@ func ProxifyRelativeURL(router *mux.Router, mediaURL string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
if customProxyURL := config.Opts.MediaCustomProxyURL(); customProxyURL != "" {
|
||||
if customProxyURL := config.Opts.MediaCustomProxyURL(); customProxyURL != nil {
|
||||
return proxifyURLWithCustomProxy(mediaURL, customProxyURL)
|
||||
}
|
||||
|
||||
@@ -36,7 +35,7 @@ func ProxifyAbsoluteURL(router *mux.Router, mediaURL string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
if customProxyURL := config.Opts.MediaCustomProxyURL(); customProxyURL != "" {
|
||||
if customProxyURL := config.Opts.MediaCustomProxyURL(); customProxyURL != nil {
|
||||
return proxifyURLWithCustomProxy(mediaURL, customProxyURL)
|
||||
}
|
||||
|
||||
@@ -50,19 +49,11 @@ func ProxifyAbsoluteURL(router *mux.Router, mediaURL string) string {
|
||||
return absoluteURL
|
||||
}
|
||||
|
||||
func proxifyURLWithCustomProxy(mediaURL, customProxyURL string) string {
|
||||
if customProxyURL == "" {
|
||||
func proxifyURLWithCustomProxy(mediaURL string, customProxyURL *url.URL) string {
|
||||
if customProxyURL == nil {
|
||||
return mediaURL
|
||||
}
|
||||
|
||||
absoluteURL, err := url.JoinPath(customProxyURL, base64.URLEncoding.EncodeToString([]byte(mediaURL)))
|
||||
if err != nil {
|
||||
slog.Error("Incorrect custom media proxy URL",
|
||||
slog.String("custom_proxy_url", customProxyURL),
|
||||
slog.Any("error", err),
|
||||
)
|
||||
return mediaURL
|
||||
}
|
||||
|
||||
return absoluteURL
|
||||
absoluteURL := customProxyURL.JoinPath(base64.URLEncoding.EncodeToString([]byte(mediaURL)))
|
||||
return absoluteURL.String()
|
||||
}
|
||||
|
||||
@@ -135,14 +135,14 @@ var (
|
||||
)
|
||||
)
|
||||
|
||||
// Collector represents a metric collector.
|
||||
type Collector struct {
|
||||
// collector represents a metric collector.
|
||||
type collector struct {
|
||||
store *storage.Storage
|
||||
refreshInterval int
|
||||
}
|
||||
|
||||
// NewCollector initializes a new metric collector.
|
||||
func NewCollector(store *storage.Storage, refreshInterval int) *Collector {
|
||||
func NewCollector(store *storage.Storage, refreshInterval int) *collector {
|
||||
prometheus.MustRegister(BackgroundFeedRefreshDuration)
|
||||
prometheus.MustRegister(ScraperRequestDuration)
|
||||
prometheus.MustRegister(ArchiveEntriesDuration)
|
||||
@@ -158,11 +158,11 @@ func NewCollector(store *storage.Storage, refreshInterval int) *Collector {
|
||||
prometheus.MustRegister(dbConnectionsMaxIdleTimeClosedGauge)
|
||||
prometheus.MustRegister(dbConnectionsMaxLifetimeClosedGauge)
|
||||
|
||||
return &Collector{store, refreshInterval}
|
||||
return &collector{store, refreshInterval}
|
||||
}
|
||||
|
||||
// GatherStorageMetrics polls the database to fetch metrics.
|
||||
func (c *Collector) GatherStorageMetrics() {
|
||||
func (c *collector) GatherStorageMetrics() {
|
||||
for range time.Tick(time.Duration(c.refreshInterval) * time.Second) {
|
||||
slog.Debug("Collecting metrics from the database")
|
||||
|
||||
|
||||
@@ -8,6 +8,8 @@ import (
|
||||
)
|
||||
|
||||
// APIKey represents an application API key.
|
||||
// We need to use a pointer for LastUsedAt,
|
||||
// as the value obtained from the database might sometimes be nil.
|
||||
type APIKey struct {
|
||||
ID int64 `json:"id"`
|
||||
UserID int64 `json:"user_id"`
|
||||
@@ -18,7 +20,7 @@ type APIKey struct {
|
||||
}
|
||||
|
||||
// APIKeys represents a collection of API Key.
|
||||
type APIKeys []*APIKey
|
||||
type APIKeys []APIKey
|
||||
|
||||
// APIKeyCreationRequest represents the request to create a new API Key.
|
||||
type APIKeyCreationRequest struct {
|
||||
|
||||
@@ -44,7 +44,7 @@ func (s *SessionData) Value() (driver.Value, error) {
|
||||
}
|
||||
|
||||
// Scan converts raw JSON data.
|
||||
func (s *SessionData) Scan(src interface{}) error {
|
||||
func (s *SessionData) Scan(src any) error {
|
||||
source, ok := src.([]byte)
|
||||
if !ok {
|
||||
return errors.New("session: unable to assert type of src")
|
||||
|
||||
@@ -11,8 +11,9 @@ type Category struct {
|
||||
Title string `json:"title"`
|
||||
UserID int64 `json:"user_id"`
|
||||
HideGlobally bool `json:"hide_globally"`
|
||||
FeedCount *int `json:"feed_count,omitempty"`
|
||||
TotalUnread *int `json:"total_unread,omitempty"`
|
||||
// Pointers are needed to avoid breaking /v1/categories?counts=true
|
||||
FeedCount *int `json:"feed_count,omitempty"`
|
||||
TotalUnread *int `json:"total_unread,omitempty"`
|
||||
}
|
||||
|
||||
func (c *Category) String() string {
|
||||
@@ -40,4 +41,4 @@ func (c *CategoryModificationRequest) Patch(category *Category) {
|
||||
}
|
||||
|
||||
// Categories represents a list of categories.
|
||||
type Categories []*Category
|
||||
type Categories []Category
|
||||
|
||||
+20
-33
@@ -7,9 +7,8 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"miniflux.app/v2/internal/config"
|
||||
|
||||
"miniflux.app/v2/internal/mediaproxy"
|
||||
"miniflux.app/v2/internal/urllib"
|
||||
)
|
||||
|
||||
// Enclosure represents an attachment.
|
||||
@@ -45,8 +44,18 @@ func (e *Enclosure) IsVideo() bool {
|
||||
|
||||
func (e *Enclosure) IsImage() bool {
|
||||
mimeType := strings.ToLower(e.MimeType)
|
||||
if strings.HasPrefix(mimeType, "image/") {
|
||||
return true
|
||||
}
|
||||
mediaURL := strings.ToLower(e.URL)
|
||||
return strings.HasPrefix(mimeType, "image/") || strings.HasSuffix(mediaURL, ".jpg") || strings.HasSuffix(mediaURL, ".jpeg") || strings.HasSuffix(mediaURL, ".png") || strings.HasSuffix(mediaURL, ".gif")
|
||||
return strings.HasSuffix(mediaURL, ".jpg") || strings.HasSuffix(mediaURL, ".jpeg") || strings.HasSuffix(mediaURL, ".png") || strings.HasSuffix(mediaURL, ".gif")
|
||||
}
|
||||
|
||||
// ProxifyEnclosureURL modifies the enclosure URL to use the media proxy if necessary.
|
||||
func (e *Enclosure) ProxifyEnclosureURL(router *mux.Router, mediaProxyOption string, mediaProxyResourceTypes []string) {
|
||||
if mediaproxy.ShouldProxifyURLWithMimeType(e.URL, e.MimeType, mediaProxyOption, mediaProxyResourceTypes) {
|
||||
e.URL = mediaproxy.ProxifyAbsoluteURL(router, e.URL)
|
||||
}
|
||||
}
|
||||
|
||||
// EnclosureList represents a list of attachments.
|
||||
@@ -55,8 +64,10 @@ type EnclosureList []*Enclosure
|
||||
// FindMediaPlayerEnclosure returns the first enclosure that can be played by a media player.
|
||||
func (el EnclosureList) FindMediaPlayerEnclosure() *Enclosure {
|
||||
for _, enclosure := range el {
|
||||
if enclosure.URL != "" && strings.Contains(enclosure.MimeType, "audio/") || strings.Contains(enclosure.MimeType, "video/") {
|
||||
return enclosure
|
||||
if enclosure.URL != "" {
|
||||
if enclosure.IsAudio() || enclosure.IsVideo() {
|
||||
return enclosure
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,39 +76,15 @@ func (el EnclosureList) FindMediaPlayerEnclosure() *Enclosure {
|
||||
|
||||
func (el EnclosureList) ContainsAudioOrVideo() bool {
|
||||
for _, enclosure := range el {
|
||||
if strings.Contains(enclosure.MimeType, "audio/") || strings.Contains(enclosure.MimeType, "video/") {
|
||||
if enclosure.IsAudio() || enclosure.IsVideo() {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (el EnclosureList) ProxifyEnclosureURL(router *mux.Router) {
|
||||
proxyOption := config.Opts.MediaProxyMode()
|
||||
|
||||
if proxyOption != "none" {
|
||||
for i := range el {
|
||||
if urllib.IsHTTPS(el[i].URL) {
|
||||
for _, mediaType := range config.Opts.MediaProxyResourceTypes() {
|
||||
if strings.HasPrefix(el[i].MimeType, mediaType+"/") {
|
||||
el[i].URL = mediaproxy.ProxifyAbsoluteURL(router, el[i].URL)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (e *Enclosure) ProxifyEnclosureURL(router *mux.Router) {
|
||||
proxyOption := config.Opts.MediaProxyMode()
|
||||
|
||||
if proxyOption == "all" || proxyOption != "none" && !urllib.IsHTTPS(e.URL) {
|
||||
for _, mediaType := range config.Opts.MediaProxyResourceTypes() {
|
||||
if strings.HasPrefix(e.MimeType, mediaType+"/") {
|
||||
e.URL = mediaproxy.ProxifyAbsoluteURL(router, e.URL)
|
||||
break
|
||||
}
|
||||
}
|
||||
func (el EnclosureList) ProxifyEnclosureURL(router *mux.Router, mediaProxyOption string, mediaProxyResourceTypes []string) {
|
||||
for _, enclosure := range el {
|
||||
enclosure.ProxifyEnclosureURL(router, mediaProxyOption, mediaProxyResourceTypes)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,12 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"miniflux.app/v2/internal/config"
|
||||
)
|
||||
|
||||
func TestEnclosure_Html5MimeTypeGivesOriginalMimeType(t *testing.T) {
|
||||
@@ -26,8 +31,560 @@ func TestEnclosure_Html5MimeTypeReplaceStandardM4vByAppleSpecificMimeType(t *tes
|
||||
// tested at the time of this commit (06/2023) on latest Firefox & Vivaldi on this feed
|
||||
// https://www.florenceporcel.com/podcast/lfhdu.xml
|
||||
t.Fatalf(
|
||||
"HTML5 MimeType must be replaced by 'video/x-m4v' when originally video/m4v to ensure playbacks in brownser. Got '%s'",
|
||||
"HTML5 MimeType must be replaced by 'video/x-m4v' when originally video/m4v to ensure playbacks in browsers. Got '%s'",
|
||||
enclosure.Html5MimeType(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnclosure_IsAudio(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
mimeType string
|
||||
expected bool
|
||||
}{
|
||||
{"MP3 audio", "audio/mpeg", true},
|
||||
{"WAV audio", "audio/wav", true},
|
||||
{"OGG audio", "audio/ogg", true},
|
||||
{"Mixed case audio", "Audio/MP3", true},
|
||||
{"Video file", "video/mp4", false},
|
||||
{"Image file", "image/jpeg", false},
|
||||
{"Text file", "text/plain", false},
|
||||
{"Empty mime type", "", false},
|
||||
{"Audio with extra info", "audio/mpeg; charset=utf-8", true},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
enclosure := &Enclosure{MimeType: tc.mimeType}
|
||||
if got := enclosure.IsAudio(); got != tc.expected {
|
||||
t.Errorf("IsAudio() = %v, want %v for mime type %s", got, tc.expected, tc.mimeType)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnclosure_IsVideo(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
mimeType string
|
||||
expected bool
|
||||
}{
|
||||
{"MP4 video", "video/mp4", true},
|
||||
{"AVI video", "video/avi", true},
|
||||
{"WebM video", "video/webm", true},
|
||||
{"M4V video", "video/m4v", true},
|
||||
{"Mixed case video", "Video/MP4", true},
|
||||
{"Audio file", "audio/mpeg", false},
|
||||
{"Image file", "image/jpeg", false},
|
||||
{"Text file", "text/plain", false},
|
||||
{"Empty mime type", "", false},
|
||||
{"Video with extra info", "video/mp4; codecs=\"avc1.42E01E\"", true},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
enclosure := &Enclosure{MimeType: tc.mimeType}
|
||||
if got := enclosure.IsVideo(); got != tc.expected {
|
||||
t.Errorf("IsVideo() = %v, want %v for mime type %s", got, tc.expected, tc.mimeType)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnclosure_IsImage(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
mimeType string
|
||||
url string
|
||||
expected bool
|
||||
}{
|
||||
{"JPEG image by mime", "image/jpeg", "http://example.com/file", true},
|
||||
{"PNG image by mime", "image/png", "http://example.com/file", true},
|
||||
{"GIF image by mime", "image/gif", "http://example.com/file", true},
|
||||
{"Mixed case image mime", "Image/JPEG", "http://example.com/file", true},
|
||||
{"JPG file extension", "application/octet-stream", "http://example.com/photo.jpg", true},
|
||||
{"JPEG file extension", "text/plain", "http://example.com/photo.jpeg", true},
|
||||
{"PNG file extension", "unknown/type", "http://example.com/photo.png", true},
|
||||
{"GIF file extension", "binary/data", "http://example.com/photo.gif", true},
|
||||
{"Mixed case extension", "text/plain", "http://example.com/photo.JPG", true},
|
||||
{"Image mime and extension", "image/jpeg", "http://example.com/photo.jpg", true},
|
||||
{"Video file", "video/mp4", "http://example.com/video.mp4", false},
|
||||
{"Audio file", "audio/mpeg", "http://example.com/audio.mp3", false},
|
||||
{"Text file", "text/plain", "http://example.com/file.txt", false},
|
||||
{"No extension", "text/plain", "http://example.com/file", false},
|
||||
{"Other extension", "text/plain", "http://example.com/file.pdf", false},
|
||||
{"Empty values", "", "", false},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
enclosure := &Enclosure{MimeType: tc.mimeType, URL: tc.url}
|
||||
if got := enclosure.IsImage(); got != tc.expected {
|
||||
t.Errorf("IsImage() = %v, want %v for mime type %s and URL %s", got, tc.expected, tc.mimeType, tc.url)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnclosureList_FindMediaPlayerEnclosure(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
enclosures EnclosureList
|
||||
expectedNil bool
|
||||
}{
|
||||
{
|
||||
name: "Returns first audio enclosure",
|
||||
enclosures: EnclosureList{
|
||||
&Enclosure{URL: "http://example.com/audio.mp3", MimeType: "audio/mpeg"},
|
||||
&Enclosure{URL: "http://example.com/video.mp4", MimeType: "video/mp4"},
|
||||
},
|
||||
expectedNil: false,
|
||||
},
|
||||
{
|
||||
name: "Returns first video enclosure",
|
||||
enclosures: EnclosureList{
|
||||
&Enclosure{URL: "http://example.com/video.mp4", MimeType: "video/mp4"},
|
||||
&Enclosure{URL: "http://example.com/audio.mp3", MimeType: "audio/mpeg"},
|
||||
},
|
||||
expectedNil: false,
|
||||
},
|
||||
{
|
||||
name: "Skips image enclosure and returns audio",
|
||||
enclosures: EnclosureList{
|
||||
&Enclosure{URL: "http://example.com/image.jpg", MimeType: "image/jpeg"},
|
||||
&Enclosure{URL: "http://example.com/audio.mp3", MimeType: "audio/mpeg"},
|
||||
},
|
||||
expectedNil: false,
|
||||
},
|
||||
{
|
||||
name: "Skips enclosure with empty URL",
|
||||
enclosures: EnclosureList{
|
||||
&Enclosure{URL: "", MimeType: "audio/mpeg"},
|
||||
&Enclosure{URL: "http://example.com/audio.mp3", MimeType: "audio/mpeg"},
|
||||
},
|
||||
expectedNil: false,
|
||||
},
|
||||
{
|
||||
name: "Returns nil for no media enclosures",
|
||||
enclosures: EnclosureList{
|
||||
&Enclosure{URL: "http://example.com/image.jpg", MimeType: "image/jpeg"},
|
||||
&Enclosure{URL: "http://example.com/doc.pdf", MimeType: "application/pdf"},
|
||||
},
|
||||
expectedNil: true,
|
||||
},
|
||||
{
|
||||
name: "Returns nil for empty list",
|
||||
enclosures: EnclosureList{},
|
||||
expectedNil: true,
|
||||
},
|
||||
{
|
||||
name: "Returns nil for all empty URLs",
|
||||
enclosures: EnclosureList{
|
||||
&Enclosure{URL: "", MimeType: "audio/mpeg"},
|
||||
&Enclosure{URL: "", MimeType: "video/mp4"},
|
||||
},
|
||||
expectedNil: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
result := tc.enclosures.FindMediaPlayerEnclosure()
|
||||
if tc.expectedNil {
|
||||
if result != nil {
|
||||
t.Errorf("FindMediaPlayerEnclosure() = %v, want nil", result)
|
||||
}
|
||||
} else {
|
||||
if result == nil {
|
||||
t.Errorf("FindMediaPlayerEnclosure() = nil, want non-nil")
|
||||
} else if !result.IsAudio() && !result.IsVideo() {
|
||||
t.Errorf("FindMediaPlayerEnclosure() returned non-media enclosure: %s", result.MimeType)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnclosureList_ContainsAudioOrVideo(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
enclosures EnclosureList
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "Contains audio",
|
||||
enclosures: EnclosureList{
|
||||
&Enclosure{MimeType: "audio/mpeg"},
|
||||
&Enclosure{MimeType: "image/jpeg"},
|
||||
},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "Contains video",
|
||||
enclosures: EnclosureList{
|
||||
&Enclosure{MimeType: "image/jpeg"},
|
||||
&Enclosure{MimeType: "video/mp4"},
|
||||
},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "Contains both audio and video",
|
||||
enclosures: EnclosureList{
|
||||
&Enclosure{MimeType: "audio/mpeg"},
|
||||
&Enclosure{MimeType: "video/mp4"},
|
||||
},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "Contains only images",
|
||||
enclosures: EnclosureList{
|
||||
&Enclosure{MimeType: "image/jpeg"},
|
||||
&Enclosure{MimeType: "image/png"},
|
||||
},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "Contains only documents",
|
||||
enclosures: EnclosureList{
|
||||
&Enclosure{MimeType: "application/pdf"},
|
||||
&Enclosure{MimeType: "text/plain"},
|
||||
},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "Empty list",
|
||||
enclosures: EnclosureList{},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "Single audio enclosure",
|
||||
enclosures: EnclosureList{
|
||||
&Enclosure{MimeType: "audio/wav"},
|
||||
},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "Single video enclosure",
|
||||
enclosures: EnclosureList{
|
||||
&Enclosure{MimeType: "video/webm"},
|
||||
},
|
||||
expected: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
result := tc.enclosures.ContainsAudioOrVideo()
|
||||
if result != tc.expected {
|
||||
t.Errorf("ContainsAudioOrVideo() = %v, want %v", result, tc.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnclosure_ProxifyEnclosureURL(t *testing.T) {
|
||||
// Initialize config for testing
|
||||
os.Clearenv()
|
||||
os.Setenv("BASE_URL", "http://localhost")
|
||||
os.Setenv("MEDIA_PROXY_PRIVATE_KEY", "test-private-key")
|
||||
|
||||
var err error
|
||||
parser := config.NewParser()
|
||||
config.Opts, err = parser.ParseEnvironmentVariables()
|
||||
if err != nil {
|
||||
t.Fatalf(`Config parsing failure: %v`, err)
|
||||
}
|
||||
|
||||
router := mux.NewRouter()
|
||||
router.HandleFunc("/proxy/{encodedDigest}/{encodedURL}", func(w http.ResponseWriter, r *http.Request) {}).Name("proxy")
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
url string
|
||||
mimeType string
|
||||
mediaProxyOption string
|
||||
mediaProxyResourceTypes []string
|
||||
expectedURLChanged bool
|
||||
}{
|
||||
{
|
||||
name: "HTTP URL with audio type - proxy mode all",
|
||||
url: "http://example.com/audio.mp3",
|
||||
mimeType: "audio/mpeg",
|
||||
mediaProxyOption: "all",
|
||||
mediaProxyResourceTypes: []string{"audio", "video"},
|
||||
expectedURLChanged: true,
|
||||
},
|
||||
{
|
||||
name: "HTTPS URL with video type - proxy mode all",
|
||||
url: "https://example.com/video.mp4",
|
||||
mimeType: "video/mp4",
|
||||
mediaProxyOption: "all",
|
||||
mediaProxyResourceTypes: []string{"audio", "video"},
|
||||
expectedURLChanged: true,
|
||||
},
|
||||
{
|
||||
name: "HTTP URL with video type - proxy mode http-only",
|
||||
url: "http://example.com/video.mp4",
|
||||
mimeType: "video/mp4",
|
||||
mediaProxyOption: "http-only",
|
||||
mediaProxyResourceTypes: []string{"audio", "video"},
|
||||
expectedURLChanged: true,
|
||||
},
|
||||
{
|
||||
name: "HTTPS URL with video type - proxy mode http-only",
|
||||
url: "https://example.com/video.mp4",
|
||||
mimeType: "video/mp4",
|
||||
mediaProxyOption: "http-only",
|
||||
mediaProxyResourceTypes: []string{"audio", "video"},
|
||||
expectedURLChanged: false,
|
||||
},
|
||||
{
|
||||
name: "HTTP URL with image type - not in resource types",
|
||||
url: "http://example.com/image.jpg",
|
||||
mimeType: "image/jpeg",
|
||||
mediaProxyOption: "all",
|
||||
mediaProxyResourceTypes: []string{"audio", "video"},
|
||||
expectedURLChanged: false,
|
||||
},
|
||||
{
|
||||
name: "HTTP URL with image type - in resource types",
|
||||
url: "http://example.com/image.jpg",
|
||||
mimeType: "image/jpeg",
|
||||
mediaProxyOption: "all",
|
||||
mediaProxyResourceTypes: []string{"audio", "video", "image"},
|
||||
expectedURLChanged: true,
|
||||
},
|
||||
{
|
||||
name: "HTTP URL - proxy mode none",
|
||||
url: "http://example.com/audio.mp3",
|
||||
mimeType: "audio/mpeg",
|
||||
mediaProxyOption: "none",
|
||||
mediaProxyResourceTypes: []string{"audio", "video"},
|
||||
expectedURLChanged: false,
|
||||
},
|
||||
{
|
||||
name: "Empty URL",
|
||||
url: "",
|
||||
mimeType: "audio/mpeg",
|
||||
mediaProxyOption: "all",
|
||||
mediaProxyResourceTypes: []string{"audio", "video"},
|
||||
expectedURLChanged: false,
|
||||
},
|
||||
{
|
||||
name: "Non-media MIME type",
|
||||
url: "http://example.com/doc.pdf",
|
||||
mimeType: "application/pdf",
|
||||
mediaProxyOption: "all",
|
||||
mediaProxyResourceTypes: []string{"audio", "video"},
|
||||
expectedURLChanged: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
enclosure := &Enclosure{
|
||||
URL: tc.url,
|
||||
MimeType: tc.mimeType,
|
||||
}
|
||||
|
||||
originalURL := enclosure.URL
|
||||
|
||||
// Call the method
|
||||
enclosure.ProxifyEnclosureURL(router, tc.mediaProxyOption, tc.mediaProxyResourceTypes)
|
||||
|
||||
// Check if URL changed as expected
|
||||
urlChanged := enclosure.URL != originalURL
|
||||
if urlChanged != tc.expectedURLChanged {
|
||||
t.Errorf("ProxifyEnclosureURL() URL changed = %v, want %v. Original: %s, New: %s",
|
||||
urlChanged, tc.expectedURLChanged, originalURL, enclosure.URL)
|
||||
}
|
||||
|
||||
// If URL should have changed, verify it's not empty
|
||||
if tc.expectedURLChanged && enclosure.URL == "" {
|
||||
t.Error("ProxifyEnclosureURL() resulted in empty URL when proxification was expected")
|
||||
}
|
||||
|
||||
// If URL shouldn't have changed, verify it's identical
|
||||
if !tc.expectedURLChanged && enclosure.URL != originalURL {
|
||||
t.Errorf("ProxifyEnclosureURL() URL changed unexpectedly from %s to %s", originalURL, enclosure.URL)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnclosureList_ProxifyEnclosureURL(t *testing.T) {
|
||||
// Initialize config for testing
|
||||
os.Clearenv()
|
||||
os.Setenv("BASE_URL", "http://localhost")
|
||||
os.Setenv("MEDIA_PROXY_PRIVATE_KEY", "test-private-key")
|
||||
|
||||
var err error
|
||||
parser := config.NewParser()
|
||||
config.Opts, err = parser.ParseEnvironmentVariables()
|
||||
if err != nil {
|
||||
t.Fatalf(`Config parsing failure: %v`, err)
|
||||
}
|
||||
|
||||
router := mux.NewRouter()
|
||||
router.HandleFunc("/proxy/{encodedDigest}/{encodedURL}", func(w http.ResponseWriter, r *http.Request) {}).Name("proxy")
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
enclosures EnclosureList
|
||||
mediaProxyOption string
|
||||
mediaProxyResourceTypes []string
|
||||
expectedChangedCount int
|
||||
}{
|
||||
{
|
||||
name: "Mixed enclosures with all proxy mode",
|
||||
enclosures: EnclosureList{
|
||||
&Enclosure{URL: "http://example.com/audio.mp3", MimeType: "audio/mpeg"},
|
||||
&Enclosure{URL: "https://example.com/video.mp4", MimeType: "video/mp4"},
|
||||
&Enclosure{URL: "http://example.com/image.jpg", MimeType: "image/jpeg"},
|
||||
&Enclosure{URL: "http://example.com/doc.pdf", MimeType: "application/pdf"},
|
||||
},
|
||||
mediaProxyOption: "all",
|
||||
mediaProxyResourceTypes: []string{"audio", "video"},
|
||||
expectedChangedCount: 2, // audio and video should be proxified
|
||||
},
|
||||
{
|
||||
name: "Mixed enclosures with http-only proxy mode",
|
||||
enclosures: EnclosureList{
|
||||
&Enclosure{URL: "http://example.com/audio.mp3", MimeType: "audio/mpeg"},
|
||||
&Enclosure{URL: "https://example.com/video.mp4", MimeType: "video/mp4"},
|
||||
&Enclosure{URL: "http://example.com/video2.mp4", MimeType: "video/mp4"},
|
||||
},
|
||||
mediaProxyOption: "http-only",
|
||||
mediaProxyResourceTypes: []string{"audio", "video"},
|
||||
expectedChangedCount: 2, // only HTTP URLs should be proxified
|
||||
},
|
||||
{
|
||||
name: "No media types in resource list",
|
||||
enclosures: EnclosureList{
|
||||
&Enclosure{URL: "http://example.com/audio.mp3", MimeType: "audio/mpeg"},
|
||||
&Enclosure{URL: "http://example.com/video.mp4", MimeType: "video/mp4"},
|
||||
},
|
||||
mediaProxyOption: "all",
|
||||
mediaProxyResourceTypes: []string{"image"},
|
||||
expectedChangedCount: 0, // no matching resource types
|
||||
},
|
||||
{
|
||||
name: "Proxy mode none",
|
||||
enclosures: EnclosureList{
|
||||
&Enclosure{URL: "http://example.com/audio.mp3", MimeType: "audio/mpeg"},
|
||||
&Enclosure{URL: "http://example.com/video.mp4", MimeType: "video/mp4"},
|
||||
},
|
||||
mediaProxyOption: "none",
|
||||
mediaProxyResourceTypes: []string{"audio", "video"},
|
||||
expectedChangedCount: 0,
|
||||
},
|
||||
{
|
||||
name: "Empty enclosure list",
|
||||
enclosures: EnclosureList{},
|
||||
mediaProxyOption: "all",
|
||||
mediaProxyResourceTypes: []string{"audio", "video"},
|
||||
expectedChangedCount: 0,
|
||||
},
|
||||
{
|
||||
name: "Enclosures with empty URLs",
|
||||
enclosures: EnclosureList{
|
||||
&Enclosure{URL: "", MimeType: "audio/mpeg"},
|
||||
&Enclosure{URL: "http://example.com/video.mp4", MimeType: "video/mp4"},
|
||||
},
|
||||
mediaProxyOption: "all",
|
||||
mediaProxyResourceTypes: []string{"audio", "video"},
|
||||
expectedChangedCount: 1, // only the non-empty URL should be processed
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
// Store original URLs
|
||||
originalURLs := make([]string, len(tc.enclosures))
|
||||
for i, enclosure := range tc.enclosures {
|
||||
originalURLs[i] = enclosure.URL
|
||||
}
|
||||
|
||||
// Call the method
|
||||
tc.enclosures.ProxifyEnclosureURL(router, tc.mediaProxyOption, tc.mediaProxyResourceTypes)
|
||||
|
||||
// Count how many URLs actually changed
|
||||
changedCount := 0
|
||||
for i, enclosure := range tc.enclosures {
|
||||
if enclosure.URL != originalURLs[i] {
|
||||
changedCount++
|
||||
// Verify that changed URLs are not empty (unless they were empty originally)
|
||||
if originalURLs[i] != "" && enclosure.URL == "" {
|
||||
t.Errorf("Enclosure %d: ProxifyEnclosureURL resulted in empty URL", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if changedCount != tc.expectedChangedCount {
|
||||
t.Errorf("ProxifyEnclosureURL() changed %d URLs, want %d", changedCount, tc.expectedChangedCount)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnclosure_ProxifyEnclosureURL_EdgeCases(t *testing.T) {
|
||||
// Initialize config for testing
|
||||
os.Clearenv()
|
||||
os.Setenv("BASE_URL", "http://localhost")
|
||||
os.Setenv("MEDIA_PROXY_PRIVATE_KEY", "test-private-key")
|
||||
|
||||
var err error
|
||||
parser := config.NewParser()
|
||||
config.Opts, err = parser.ParseEnvironmentVariables()
|
||||
if err != nil {
|
||||
t.Fatalf(`Config parsing failure: %v`, err)
|
||||
}
|
||||
|
||||
router := mux.NewRouter()
|
||||
router.HandleFunc("/proxy/{encodedDigest}/{encodedURL}", func(w http.ResponseWriter, r *http.Request) {}).Name("proxy")
|
||||
t.Run("Empty resource types slice", func(t *testing.T) {
|
||||
enclosure := &Enclosure{
|
||||
URL: "http://example.com/audio.mp3",
|
||||
MimeType: "audio/mpeg",
|
||||
}
|
||||
|
||||
originalURL := enclosure.URL
|
||||
enclosure.ProxifyEnclosureURL(router, "all", []string{})
|
||||
|
||||
// With empty resource types, URL should not change
|
||||
if enclosure.URL != originalURL {
|
||||
t.Errorf("URL should not change with empty resource types. Original: %s, New: %s", originalURL, enclosure.URL)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Nil resource types slice", func(t *testing.T) {
|
||||
enclosure := &Enclosure{
|
||||
URL: "http://example.com/audio.mp3",
|
||||
MimeType: "audio/mpeg",
|
||||
}
|
||||
|
||||
originalURL := enclosure.URL
|
||||
enclosure.ProxifyEnclosureURL(router, "all", nil)
|
||||
|
||||
// With nil resource types, URL should not change
|
||||
if enclosure.URL != originalURL {
|
||||
t.Errorf("URL should not change with nil resource types. Original: %s, New: %s", originalURL, enclosure.URL)
|
||||
}
|
||||
})
|
||||
t.Run("Invalid proxy mode", func(t *testing.T) {
|
||||
enclosure := &Enclosure{
|
||||
URL: "http://example.com/audio.mp3",
|
||||
MimeType: "audio/mpeg",
|
||||
}
|
||||
|
||||
originalURL := enclosure.URL
|
||||
enclosure.ProxifyEnclosureURL(router, "invalid-mode", []string{"audio"})
|
||||
|
||||
// With invalid proxy mode, the function still proxifies non-HTTPS URLs
|
||||
// because shouldProxifyURL defaults to checking URL scheme
|
||||
if enclosure.URL == originalURL {
|
||||
t.Errorf("URL should change for HTTP URL even with invalid proxy mode. Original: %s, New: %s", originalURL, enclosure.URL)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -37,7 +37,6 @@ type Feed struct {
|
||||
ParsingErrorCount int `json:"parsing_error_count"`
|
||||
ScraperRules string `json:"scraper_rules"`
|
||||
RewriteRules string `json:"rewrite_rules"`
|
||||
Crawler bool `json:"crawler"`
|
||||
BlocklistRules string `json:"blocklist_rules"`
|
||||
KeeplistRules string `json:"keeplist_rules"`
|
||||
BlockFilterEntryRules string `json:"block_filter_entry_rules"`
|
||||
@@ -54,12 +53,13 @@ type Feed struct {
|
||||
FetchViaProxy bool `json:"fetch_via_proxy"`
|
||||
HideGlobally bool `json:"hide_globally"`
|
||||
DisableHTTP2 bool `json:"disable_http2"`
|
||||
PushoverEnabled bool `json:"pushover_enabled"`
|
||||
NtfyEnabled bool `json:"ntfy_enabled"`
|
||||
Crawler bool `json:"crawler"`
|
||||
AppriseServiceURLs string `json:"apprise_service_urls"`
|
||||
WebhookURL string `json:"webhook_url"`
|
||||
NtfyEnabled bool `json:"ntfy_enabled"`
|
||||
NtfyPriority int `json:"ntfy_priority"`
|
||||
NtfyTopic string `json:"ntfy_topic"`
|
||||
PushoverEnabled bool `json:"pushover_enabled"`
|
||||
PushoverPriority int `json:"pushover_priority"`
|
||||
ProxyURL string `json:"proxy_url"`
|
||||
|
||||
@@ -164,15 +164,15 @@ type FeedCreationRequest struct {
|
||||
IgnoreHTTPCache bool `json:"ignore_http_cache"`
|
||||
AllowSelfSignedCertificates bool `json:"allow_self_signed_certificates"`
|
||||
FetchViaProxy bool `json:"fetch_via_proxy"`
|
||||
HideGlobally bool `json:"hide_globally"`
|
||||
DisableHTTP2 bool `json:"disable_http2"`
|
||||
ScraperRules string `json:"scraper_rules"`
|
||||
RewriteRules string `json:"rewrite_rules"`
|
||||
BlocklistRules string `json:"blocklist_rules"`
|
||||
KeeplistRules string `json:"keeplist_rules"`
|
||||
BlockFilterEntryRules string `json:"block_filter_entry_rules"`
|
||||
KeepFilterEntryRules string `json:"keep_filter_entry_rules"`
|
||||
HideGlobally bool `json:"hide_globally"`
|
||||
UrlRewriteRules string `json:"urlrewrite_rules"`
|
||||
DisableHTTP2 bool `json:"disable_http2"`
|
||||
ProxyURL string `json:"proxy_url"`
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ package model // import "miniflux.app/v2/internal/model"
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// Icon represents a website icon (favicon)
|
||||
@@ -19,7 +18,7 @@ type Icon struct {
|
||||
|
||||
// DataURL returns the data URL of the icon.
|
||||
func (i *Icon) DataURL() string {
|
||||
return fmt.Sprintf("%s;base64,%s", i.MimeType, base64.StdEncoding.EncodeToString(i.Content))
|
||||
return i.MimeType + ";base64," + base64.StdEncoding.EncodeToString(i.Content)
|
||||
}
|
||||
|
||||
// Icons represents a list of icons.
|
||||
|
||||
+13
-2
@@ -5,9 +5,20 @@ package model // import "miniflux.app/v2/internal/model"
|
||||
|
||||
// Job represents a payload sent to the processing queue.
|
||||
type Job struct {
|
||||
UserID int64
|
||||
FeedID int64
|
||||
UserID int64
|
||||
FeedID int64
|
||||
FeedURL string
|
||||
}
|
||||
|
||||
// JobList represents a list of jobs.
|
||||
type JobList []Job
|
||||
|
||||
// FeedURLs returns a list of feed URLs from the job list.
|
||||
// This is useful for logging or debugging purposes to see which feeds are being processed.
|
||||
func (jl *JobList) FeedURLs() []string {
|
||||
feedURLs := make([]string, len(*jl))
|
||||
for i, job := range *jl {
|
||||
feedURLs[i] = job.FeedURL
|
||||
}
|
||||
return feedURLs
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ func (u *UserSession) UseTimezone(tz string) {
|
||||
}
|
||||
|
||||
// UserSessions represents a list of sessions.
|
||||
type UserSessions []*UserSession
|
||||
type UserSessions []UserSession
|
||||
|
||||
// UseTimezone converts creation date of all sessions to the given timezone.
|
||||
func (u UserSessions) UseTimezone(tz string) {
|
||||
|
||||
@@ -14,7 +14,7 @@ import (
|
||||
"github.com/go-webauthn/webauthn/webauthn"
|
||||
)
|
||||
|
||||
// handle marshalling / unmarshalling session data
|
||||
// WebAuthnSession handles marshalling / unmarshalling session data
|
||||
type WebAuthnSession struct {
|
||||
*webauthn.SessionData
|
||||
}
|
||||
@@ -23,7 +23,7 @@ func (s WebAuthnSession) Value() (driver.Value, error) {
|
||||
return json.Marshal(s)
|
||||
}
|
||||
|
||||
func (s *WebAuthnSession) Scan(value interface{}) error {
|
||||
func (s *WebAuthnSession) Scan(value any) error {
|
||||
b, ok := value.([]byte)
|
||||
if !ok {
|
||||
return errors.New("type assertion to []byte failed")
|
||||
|
||||
@@ -38,23 +38,19 @@ func NewProxyRotator(proxyURLs []string) (*ProxyRotator, error) {
|
||||
|
||||
// GetNextProxy returns the next proxy in the rotation.
|
||||
func (pr *ProxyRotator) GetNextProxy() *url.URL {
|
||||
pr.mutex.Lock()
|
||||
defer pr.mutex.Unlock()
|
||||
|
||||
if len(pr.proxies) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
pr.mutex.Lock()
|
||||
proxy := pr.proxies[pr.currentIndex]
|
||||
pr.currentIndex = (pr.currentIndex + 1) % len(pr.proxies)
|
||||
pr.mutex.Unlock()
|
||||
|
||||
return proxy
|
||||
}
|
||||
|
||||
// HasProxies checks if there are any proxies available in the rotator.
|
||||
func (pr *ProxyRotator) HasProxies() bool {
|
||||
pr.mutex.Lock()
|
||||
defer pr.mutex.Unlock()
|
||||
|
||||
return len(pr.proxies) > 0
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
)
|
||||
|
||||
// Specs: http://web.archive.org/web/20060811235523/http://www.mnot.net/drafts/draft-nottingham-atom-format-02.html
|
||||
type Atom03Feed struct {
|
||||
type atom03Feed struct {
|
||||
Version string `xml:"version,attr"`
|
||||
|
||||
// The "atom:id" element's content conveys a permanent, globally unique identifier for the feed.
|
||||
@@ -21,14 +21,14 @@ type Atom03Feed struct {
|
||||
// The "atom:title" element is a Content construct that conveys a human-readable title for the feed.
|
||||
// atom:feed elements MUST contain exactly one atom:title element.
|
||||
// If the feed describes a Web resource, its content SHOULD be the same as that resource's title.
|
||||
Title Atom03Content `xml:"http://purl.org/atom/ns# title"`
|
||||
Title atom03Content `xml:"http://purl.org/atom/ns# title"`
|
||||
|
||||
// The "atom:link" element is a Link construct that conveys a URI associated with the feed.
|
||||
// The nature of the relationship as well as the link itself is determined by the element's content.
|
||||
// atom:feed elements MUST contain at least one atom:link element with a rel attribute value of "alternate".
|
||||
// atom:feed elements MUST NOT contain more than one atom:link element with a rel attribute value of "alternate" that has the same type attribute value.
|
||||
// atom:feed elements MAY contain additional atom:link elements beyond those described above.
|
||||
Links AtomLinks `xml:"http://purl.org/atom/ns# link"`
|
||||
Links atomLinks `xml:"http://purl.org/atom/ns# link"`
|
||||
|
||||
// The "atom:author" element is a Person construct that indicates the default author of the feed.
|
||||
// atom:feed elements MUST contain exactly one atom:author element,
|
||||
@@ -38,10 +38,10 @@ type Atom03Feed struct {
|
||||
|
||||
// The "atom:entry" element's represents an individual entry that is contained by the feed.
|
||||
// atom:feed elements MAY contain one or more atom:entry elements.
|
||||
Entries []Atom03Entry `xml:"http://purl.org/atom/ns# entry"`
|
||||
Entries []atom03Entry `xml:"http://purl.org/atom/ns# entry"`
|
||||
}
|
||||
|
||||
type Atom03Entry struct {
|
||||
type atom03Entry struct {
|
||||
// The "atom:id" element's content conveys a permanent, globally unique identifier for the entry.
|
||||
// It MUST NOT change over time, even if other representations of the entry (such as a web representation pointed to by the entry's atom:link element) are relocated.
|
||||
// If the same entry is syndicated in two atom:feeds published by the same entity, the entry's atom:id MUST be the same in both feeds.
|
||||
@@ -50,7 +50,7 @@ type Atom03Entry struct {
|
||||
// The "atom:title" element is a Content construct that conveys a human-readable title for the entry.
|
||||
// atom:entry elements MUST have exactly one "atom:title" element.
|
||||
// If an entry describes a Web resource, its content SHOULD be the same as that resource's title.
|
||||
Title Atom03Content `xml:"title"`
|
||||
Title atom03Content `xml:"title"`
|
||||
|
||||
// The "atom:modified" element is a Date construct that indicates the time that the entry was last modified.
|
||||
// atom:entry elements MUST contain an atom:modified element, but MUST NOT contain more than one.
|
||||
@@ -73,15 +73,15 @@ type Atom03Entry struct {
|
||||
// atom:entry elements MUST contain at least one atom:link element with a rel attribute value of "alternate".
|
||||
// atom:entry elements MUST NOT contain more than one atom:link element with a rel attribute value of "alternate" that has the same type attribute value.
|
||||
// atom:entry elements MAY contain additional atom:link elements beyond those described above.
|
||||
Links AtomLinks `xml:"link"`
|
||||
Links atomLinks `xml:"link"`
|
||||
|
||||
// The "atom:summary" element is a Content construct that conveys a short summary, abstract or excerpt of the entry.
|
||||
// atom:entry elements MAY contain an atom:created element, but MUST NOT contain more than one.
|
||||
Summary Atom03Content `xml:"summary"`
|
||||
Summary atom03Content `xml:"summary"`
|
||||
|
||||
// The "atom:content" element is a Content construct that conveys the content of the entry.
|
||||
// atom:entry elements MAY contain one or more atom:content elements.
|
||||
Content Atom03Content `xml:"content"`
|
||||
Content atom03Content `xml:"content"`
|
||||
|
||||
// The "atom:author" element is a Person construct that indicates the default author of the entry.
|
||||
// atom:entry elements MUST contain exactly one atom:author element,
|
||||
@@ -90,7 +90,7 @@ type Atom03Entry struct {
|
||||
Author AtomPerson `xml:"author"`
|
||||
}
|
||||
|
||||
type Atom03Content struct {
|
||||
type atom03Content struct {
|
||||
// Content constructs MAY have a "type" attribute, whose value indicates the media type of the content.
|
||||
// When present, this attribute's value MUST be a registered media type [RFC2045].
|
||||
// If not present, its value MUST be considered to be "text/plain".
|
||||
@@ -113,7 +113,7 @@ type Atom03Content struct {
|
||||
InnerXML string `xml:",innerxml"`
|
||||
}
|
||||
|
||||
func (a *Atom03Content) Content() string {
|
||||
func (a *atom03Content) content() string {
|
||||
content := ""
|
||||
|
||||
switch a.Mode {
|
||||
|
||||
@@ -14,15 +14,11 @@ import (
|
||||
"miniflux.app/v2/internal/urllib"
|
||||
)
|
||||
|
||||
type Atom03Adapter struct {
|
||||
atomFeed *Atom03Feed
|
||||
type atom03Adapter struct {
|
||||
atomFeed *atom03Feed
|
||||
}
|
||||
|
||||
func NewAtom03Adapter(atomFeed *Atom03Feed) *Atom03Adapter {
|
||||
return &Atom03Adapter{atomFeed}
|
||||
}
|
||||
|
||||
func (a *Atom03Adapter) BuildFeed(baseURL string) *model.Feed {
|
||||
func (a *atom03Adapter) buildFeed(baseURL string) *model.Feed {
|
||||
feed := new(model.Feed)
|
||||
|
||||
// Populate the feed URL.
|
||||
@@ -36,7 +32,7 @@ func (a *Atom03Adapter) BuildFeed(baseURL string) *model.Feed {
|
||||
}
|
||||
|
||||
// Populate the site URL.
|
||||
siteURL := a.atomFeed.Links.OriginalLink()
|
||||
siteURL := a.atomFeed.Links.originalLink()
|
||||
if siteURL != "" {
|
||||
if absoluteSiteURL, err := urllib.AbsoluteURL(baseURL, siteURL); err == nil {
|
||||
feed.SiteURL = absoluteSiteURL
|
||||
@@ -46,7 +42,7 @@ func (a *Atom03Adapter) BuildFeed(baseURL string) *model.Feed {
|
||||
}
|
||||
|
||||
// Populate the feed title.
|
||||
feed.Title = a.atomFeed.Title.Content()
|
||||
feed.Title = a.atomFeed.Title.content()
|
||||
if feed.Title == "" {
|
||||
feed.Title = feed.SiteURL
|
||||
}
|
||||
@@ -55,7 +51,7 @@ func (a *Atom03Adapter) BuildFeed(baseURL string) *model.Feed {
|
||||
entry := model.NewEntry()
|
||||
|
||||
// Populate the entry URL.
|
||||
entry.URL = atomEntry.Links.OriginalLink()
|
||||
entry.URL = atomEntry.Links.originalLink()
|
||||
if entry.URL != "" {
|
||||
if absoluteEntryURL, err := urllib.AbsoluteURL(feed.SiteURL, entry.URL); err == nil {
|
||||
entry.URL = absoluteEntryURL
|
||||
@@ -63,13 +59,13 @@ func (a *Atom03Adapter) BuildFeed(baseURL string) *model.Feed {
|
||||
}
|
||||
|
||||
// Populate the entry content.
|
||||
entry.Content = atomEntry.Content.Content()
|
||||
entry.Content = atomEntry.Content.content()
|
||||
if entry.Content == "" {
|
||||
entry.Content = atomEntry.Summary.Content()
|
||||
entry.Content = atomEntry.Summary.content()
|
||||
}
|
||||
|
||||
// Populate the entry title.
|
||||
entry.Title = atomEntry.Title.Content()
|
||||
entry.Title = atomEntry.Title.content()
|
||||
if entry.Title == "" {
|
||||
entry.Title = sanitizer.TruncateHTML(entry.Content, 100)
|
||||
}
|
||||
@@ -101,7 +97,7 @@ func (a *Atom03Adapter) BuildFeed(baseURL string) *model.Feed {
|
||||
}
|
||||
|
||||
// Generate the entry hash.
|
||||
for _, value := range []string{atomEntry.ID, atomEntry.Links.OriginalLink()} {
|
||||
for _, value := range []string{atomEntry.ID, atomEntry.Links.originalLink()} {
|
||||
if value != "" {
|
||||
entry.Hash = crypto.SHA256(value)
|
||||
break
|
||||
|
||||
@@ -19,7 +19,7 @@ import (
|
||||
// Specs:
|
||||
// https://tools.ietf.org/html/rfc4287
|
||||
// https://validator.w3.org/feed/docs/atom.html
|
||||
type Atom10Feed struct {
|
||||
type atom10Feed struct {
|
||||
XMLName xml.Name `xml:"http://www.w3.org/2005/Atom feed"`
|
||||
|
||||
// The "atom:id" element conveys a permanent, universally unique
|
||||
@@ -37,11 +37,11 @@ type Atom10Feed struct {
|
||||
// readable title for an entry or feed.
|
||||
//
|
||||
// atom:feed elements MUST contain exactly one atom:title element.
|
||||
Title Atom10Text `xml:"http://www.w3.org/2005/Atom title"`
|
||||
Title atom10Text `xml:"http://www.w3.org/2005/Atom title"`
|
||||
|
||||
// The "atom:subtitle" element is a Text construct that
|
||||
// contains a human-readable description or subtitle for the feed.
|
||||
Subtitle Atom10Text `xml:"http://www.w3.org/2005/Atom subtitle"`
|
||||
Subtitle atom10Text `xml:"http://www.w3.org/2005/Atom subtitle"`
|
||||
|
||||
// The "atom:author" element is a Person construct that indicates the
|
||||
// author of the entry or feed.
|
||||
@@ -49,7 +49,7 @@ type Atom10Feed struct {
|
||||
// atom:feed elements MUST contain one or more atom:author elements,
|
||||
// unless all of the atom:feed element's child atom:entry elements
|
||||
// contain at least one atom:author element.
|
||||
Authors AtomPersons `xml:"http://www.w3.org/2005/Atom author"`
|
||||
Authors atomPersons `xml:"http://www.w3.org/2005/Atom author"`
|
||||
|
||||
// The "atom:icon" element's content is an IRI reference [RFC3987] that
|
||||
// identifies an image that provides iconic visual identification for a
|
||||
@@ -71,7 +71,7 @@ type Atom10Feed struct {
|
||||
// atom:feed elements MUST NOT contain more than one atom:link
|
||||
// element with a rel attribute value of "alternate" that has the
|
||||
// same combination of type and hreflang attribute values.
|
||||
Links AtomLinks `xml:"http://www.w3.org/2005/Atom link"`
|
||||
Links atomLinks `xml:"http://www.w3.org/2005/Atom link"`
|
||||
|
||||
// The "atom:category" element conveys information about a category
|
||||
// associated with an entry or feed. This specification assigns no
|
||||
@@ -79,12 +79,12 @@ type Atom10Feed struct {
|
||||
//
|
||||
// atom:feed elements MAY contain any number of atom:category
|
||||
// elements.
|
||||
Categories AtomCategories `xml:"http://www.w3.org/2005/Atom category"`
|
||||
Categories atomCategories `xml:"http://www.w3.org/2005/Atom category"`
|
||||
|
||||
Entries []Atom10Entry `xml:"http://www.w3.org/2005/Atom entry"`
|
||||
Entries []atom10Entry `xml:"http://www.w3.org/2005/Atom entry"`
|
||||
}
|
||||
|
||||
type Atom10Entry struct {
|
||||
type atom10Entry struct {
|
||||
// The "atom:id" element conveys a permanent, universally unique
|
||||
// identifier for an entry or feed.
|
||||
//
|
||||
@@ -100,7 +100,7 @@ type Atom10Entry struct {
|
||||
// readable title for an entry or feed.
|
||||
//
|
||||
// atom:entry elements MUST contain exactly one atom:title element.
|
||||
Title Atom10Text `xml:"http://www.w3.org/2005/Atom title"`
|
||||
Title atom10Text `xml:"http://www.w3.org/2005/Atom title"`
|
||||
|
||||
// The "atom:published" element is a Date construct indicating an
|
||||
// instant in time associated with an event early in the life cycle of
|
||||
@@ -118,7 +118,7 @@ type Atom10Entry struct {
|
||||
// atom:entry elements MUST NOT contain more than one atom:link
|
||||
// element with a rel attribute value of "alternate" that has the
|
||||
// same combination of type and hreflang attribute values.
|
||||
Links AtomLinks `xml:"http://www.w3.org/2005/Atom link"`
|
||||
Links atomLinks `xml:"http://www.w3.org/2005/Atom link"`
|
||||
|
||||
// atom:entry elements MUST contain an atom:summary element in either
|
||||
// of the following cases:
|
||||
@@ -131,17 +131,17 @@ type Atom10Entry struct {
|
||||
//
|
||||
// atom:entry elements MUST NOT contain more than one atom:summary
|
||||
// element.
|
||||
Summary Atom10Text `xml:"http://www.w3.org/2005/Atom summary"`
|
||||
Summary atom10Text `xml:"http://www.w3.org/2005/Atom summary"`
|
||||
|
||||
// atom:entry elements MUST NOT contain more than one atom:content
|
||||
// element.
|
||||
Content Atom10Text `xml:"http://www.w3.org/2005/Atom content"`
|
||||
Content atom10Text `xml:"http://www.w3.org/2005/Atom content"`
|
||||
|
||||
// The "atom:author" element is a Person construct that indicates the
|
||||
// author of the entry or feed.
|
||||
//
|
||||
// atom:entry elements MUST contain one or more atom:author elements
|
||||
Authors AtomPersons `xml:"http://www.w3.org/2005/Atom author"`
|
||||
Authors atomPersons `xml:"http://www.w3.org/2005/Atom author"`
|
||||
|
||||
// The "atom:category" element conveys information about a category
|
||||
// associated with an entry or feed. This specification assigns no
|
||||
@@ -149,7 +149,7 @@ type Atom10Entry struct {
|
||||
//
|
||||
// atom:entry elements MAY contain any number of atom:category
|
||||
// elements.
|
||||
Categories AtomCategories `xml:"http://www.w3.org/2005/Atom category"`
|
||||
Categories atomCategories `xml:"http://www.w3.org/2005/Atom category"`
|
||||
|
||||
media.MediaItemElement
|
||||
}
|
||||
@@ -160,14 +160,14 @@ type Atom10Entry struct {
|
||||
// Text: https://datatracker.ietf.org/doc/html/rfc4287#section-3.1.1.1
|
||||
// HTML: https://datatracker.ietf.org/doc/html/rfc4287#section-3.1.1.2
|
||||
// XHTML: https://datatracker.ietf.org/doc/html/rfc4287#section-3.1.1.3
|
||||
type Atom10Text struct {
|
||||
type atom10Text struct {
|
||||
Type string `xml:"type,attr"`
|
||||
CharData string `xml:",chardata"`
|
||||
InnerXML string `xml:",innerxml"`
|
||||
XHTMLRootElement AtomXHTMLRootElement `xml:"http://www.w3.org/1999/xhtml div"`
|
||||
XHTMLRootElement atomXHTMLRootElement `xml:"http://www.w3.org/1999/xhtml div"`
|
||||
}
|
||||
|
||||
func (a *Atom10Text) Body() string {
|
||||
func (a *atom10Text) body() string {
|
||||
var content string
|
||||
|
||||
if strings.EqualFold(a.Type, "xhtml") {
|
||||
@@ -179,7 +179,7 @@ func (a *Atom10Text) Body() string {
|
||||
return strings.TrimSpace(content)
|
||||
}
|
||||
|
||||
func (a *Atom10Text) Title() string {
|
||||
func (a *atom10Text) title() string {
|
||||
var content string
|
||||
|
||||
switch {
|
||||
@@ -194,14 +194,14 @@ func (a *Atom10Text) Title() string {
|
||||
return strings.TrimSpace(content)
|
||||
}
|
||||
|
||||
func (a *Atom10Text) xhtmlContent() string {
|
||||
func (a *atom10Text) xhtmlContent() string {
|
||||
if a.XHTMLRootElement.XMLName.Local == "div" {
|
||||
return a.XHTMLRootElement.InnerXML
|
||||
}
|
||||
return a.InnerXML
|
||||
}
|
||||
|
||||
type AtomXHTMLRootElement struct {
|
||||
type atomXHTMLRootElement struct {
|
||||
XMLName xml.Name `xml:"div"`
|
||||
InnerXML string `xml:",innerxml"`
|
||||
}
|
||||
|
||||
@@ -18,15 +18,15 @@ import (
|
||||
"miniflux.app/v2/internal/urllib"
|
||||
)
|
||||
|
||||
type Atom10Adapter struct {
|
||||
atomFeed *Atom10Feed
|
||||
type atom10Adapter struct {
|
||||
atomFeed *atom10Feed
|
||||
}
|
||||
|
||||
func NewAtom10Adapter(atomFeed *Atom10Feed) *Atom10Adapter {
|
||||
return &Atom10Adapter{atomFeed}
|
||||
func NewAtom10Adapter(atomFeed *atom10Feed) *atom10Adapter {
|
||||
return &atom10Adapter{atomFeed}
|
||||
}
|
||||
|
||||
func (a *Atom10Adapter) BuildFeed(baseURL string) *model.Feed {
|
||||
func (a *atom10Adapter) BuildFeed(baseURL string) *model.Feed {
|
||||
feed := new(model.Feed)
|
||||
|
||||
// Populate the feed URL.
|
||||
@@ -40,7 +40,7 @@ func (a *Atom10Adapter) BuildFeed(baseURL string) *model.Feed {
|
||||
}
|
||||
|
||||
// Populate the site URL.
|
||||
siteURL := a.atomFeed.Links.OriginalLink()
|
||||
siteURL := a.atomFeed.Links.originalLink()
|
||||
if siteURL != "" {
|
||||
if absoluteSiteURL, err := urllib.AbsoluteURL(baseURL, siteURL); err == nil {
|
||||
feed.SiteURL = absoluteSiteURL
|
||||
@@ -50,13 +50,13 @@ func (a *Atom10Adapter) BuildFeed(baseURL string) *model.Feed {
|
||||
}
|
||||
|
||||
// Populate the feed title.
|
||||
feed.Title = a.atomFeed.Title.Body()
|
||||
feed.Title = a.atomFeed.Title.body()
|
||||
if feed.Title == "" {
|
||||
feed.Title = feed.SiteURL
|
||||
}
|
||||
|
||||
// Populate the feed description.
|
||||
feed.Description = a.atomFeed.Subtitle.Body()
|
||||
feed.Description = a.atomFeed.Subtitle.body()
|
||||
|
||||
// Populate the feed icon.
|
||||
if a.atomFeed.Icon != "" {
|
||||
@@ -72,14 +72,14 @@ func (a *Atom10Adapter) BuildFeed(baseURL string) *model.Feed {
|
||||
return feed
|
||||
}
|
||||
|
||||
func (a *Atom10Adapter) populateEntries(siteURL string) model.Entries {
|
||||
func (a *atom10Adapter) populateEntries(siteURL string) model.Entries {
|
||||
entries := make(model.Entries, 0, len(a.atomFeed.Entries))
|
||||
|
||||
for _, atomEntry := range a.atomFeed.Entries {
|
||||
entry := model.NewEntry()
|
||||
|
||||
// Populate the entry URL.
|
||||
entry.URL = atomEntry.Links.OriginalLink()
|
||||
entry.URL = atomEntry.Links.originalLink()
|
||||
if entry.URL != "" {
|
||||
if absoluteEntryURL, err := urllib.AbsoluteURL(siteURL, entry.URL); err == nil {
|
||||
entry.URL = absoluteEntryURL
|
||||
@@ -87,16 +87,16 @@ func (a *Atom10Adapter) populateEntries(siteURL string) model.Entries {
|
||||
}
|
||||
|
||||
// Populate the entry content.
|
||||
entry.Content = atomEntry.Content.Body()
|
||||
entry.Content = atomEntry.Content.body()
|
||||
if entry.Content == "" {
|
||||
entry.Content = atomEntry.Summary.Body()
|
||||
entry.Content = atomEntry.Summary.body()
|
||||
if entry.Content == "" {
|
||||
entry.Content = atomEntry.FirstMediaDescription()
|
||||
}
|
||||
}
|
||||
|
||||
// Populate the entry title.
|
||||
entry.Title = atomEntry.Title.Title()
|
||||
entry.Title = atomEntry.Title.title()
|
||||
if entry.Title == "" {
|
||||
entry.Title = sanitizer.TruncateHTML(entry.Content, 100)
|
||||
if entry.Title == "" {
|
||||
@@ -105,9 +105,9 @@ func (a *Atom10Adapter) populateEntries(siteURL string) model.Entries {
|
||||
}
|
||||
|
||||
// Populate the entry author.
|
||||
authors := atomEntry.Authors.PersonNames()
|
||||
authors := atomEntry.Authors.personNames()
|
||||
if len(authors) == 0 {
|
||||
authors = a.atomFeed.Authors.PersonNames()
|
||||
authors = a.atomFeed.Authors.personNames()
|
||||
}
|
||||
sort.Strings(authors)
|
||||
authors = slices.Compact(authors)
|
||||
@@ -137,6 +137,8 @@ func (a *Atom10Adapter) populateEntries(siteURL string) model.Entries {
|
||||
if len(categories) == 0 {
|
||||
categories = a.atomFeed.Categories.CategoryNames()
|
||||
}
|
||||
|
||||
// Sort and deduplicate categories.
|
||||
sort.Strings(categories)
|
||||
entry.Tags = slices.Compact(categories)
|
||||
|
||||
@@ -150,7 +152,7 @@ func (a *Atom10Adapter) populateEntries(siteURL string) model.Entries {
|
||||
}
|
||||
|
||||
// Generate the entry hash.
|
||||
for _, value := range []string{atomEntry.ID, atomEntry.Links.OriginalLink()} {
|
||||
for _, value := range []string{atomEntry.ID, atomEntry.Links.originalLink()} {
|
||||
if value != "" {
|
||||
entry.Hash = crypto.SHA256(value)
|
||||
break
|
||||
|
||||
@@ -1761,6 +1761,8 @@ func TestParseItemWithCategories(t *testing.T) {
|
||||
<updated>2003-12-13T18:30:02Z</updated>
|
||||
<summary>Some text.</summary>
|
||||
<category term='ZZZZ' />
|
||||
<category term='ZZZZ' />
|
||||
<category term=" " />
|
||||
<category term='Technology' label='Science' />
|
||||
</entry>
|
||||
</feed>`
|
||||
@@ -1774,16 +1776,13 @@ func TestParseItemWithCategories(t *testing.T) {
|
||||
t.Fatalf("Incorrect number of tags, got: %d", len(feed.Entries[0].Tags))
|
||||
}
|
||||
|
||||
expected := "Science"
|
||||
result := feed.Entries[0].Tags[0]
|
||||
if result != expected {
|
||||
t.Errorf("Incorrect entry category, got %q instead of %q", result, expected)
|
||||
}
|
||||
expected := []string{"Science", "ZZZZ"}
|
||||
result := feed.Entries[0].Tags
|
||||
|
||||
expected = "ZZZZ"
|
||||
result = feed.Entries[0].Tags[1]
|
||||
if result != expected {
|
||||
t.Errorf("Incorrect entry category, got %q instead of %q", result, expected)
|
||||
for i, tag := range result {
|
||||
if tag != expected[i] {
|
||||
t.Errorf("Incorrect entry tag, got %q instead of %q", tag, expected[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1792,9 +1791,10 @@ func TestParseFeedWithCategories(t *testing.T) {
|
||||
<feed xmlns="http://www.w3.org/2005/Atom">
|
||||
<title>Example Feed</title>
|
||||
<link href="http://example.org/"/>
|
||||
<category term='Test' label='Some Label' />
|
||||
<category term='Test' label='Some Label' />
|
||||
<category term='Test' label='Some Label' />
|
||||
<category term='C term' label='C label' />
|
||||
<category term='B term' label='B label' />
|
||||
<category term='B term' label='B label' />
|
||||
<category term='A term' label='A label' />
|
||||
<entry>
|
||||
<link href="http://www.example.org/entries/1" />
|
||||
<updated>2003-12-13T18:30:02Z</updated>
|
||||
@@ -1807,14 +1807,16 @@ func TestParseFeedWithCategories(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if len(feed.Entries[0].Tags) != 1 {
|
||||
if len(feed.Entries[0].Tags) != 3 {
|
||||
t.Fatalf("Incorrect number of tags, got: %d", len(feed.Entries[0].Tags))
|
||||
}
|
||||
|
||||
expected := "Some Label"
|
||||
result := feed.Entries[0].Tags[0]
|
||||
if result != expected {
|
||||
t.Errorf("Incorrect entry category, got %q instead of %q", result, expected)
|
||||
expected := []string{"A label", "B label", "C label"}
|
||||
result := feed.Entries[0].Tags
|
||||
for i, tag := range result {
|
||||
if tag != expected[i] {
|
||||
t.Errorf("Incorrect entry tag, got %q instead of %q", tag, expected[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user