Compare commits
56 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 10b2b36895 | |||
| 87e65f800e | |||
| 907cf16af1 | |||
| 854a78a7d1 | |||
| 5e607be86a | |||
| 502e7108dd | |||
| 0b93d8abcc | |||
| 93a8629910 | |||
| b1742168e1 | |||
| 4eff9129ab | |||
| eb22d90b56 | |||
| 8f2dd02f3f | |||
| da8d4d86c3 | |||
| 7ada5d54be | |||
| 8129500296 | |||
| f1143151c5 | |||
| baf8e40152 | |||
| a98374b81f | |||
| 77e0b07f9f | |||
| 645800ce3c | |||
| a60a153003 | |||
| 7d40e2993b | |||
| 91a625c559 | |||
| dbb3855901 | |||
| 3051acf369 | |||
| 5f38054965 | |||
| fa361ab1ce | |||
| afe80e6bae | |||
| f2976bff5d | |||
| 84078c7c20 | |||
| d70817e441 | |||
| e8f5c2446c | |||
| 737f25f441 | |||
| f83d66769d | |||
| af149e46df | |||
| 4f252b33c9 | |||
| da9c3a4032 | |||
| 8e1d0bb693 | |||
| 60cd7ffe88 | |||
| 4d656d2739 | |||
| 983291c78b | |||
| 7060ecc163 | |||
| 4af12a4129 | |||
| c6536e8d90 | |||
| 30453ad7ec | |||
| 71af68becd | |||
| ed3bf59356 | |||
| 03021af53c | |||
| 83254a1f26 | |||
| 3acb888309 | |||
| 7c29166ef9 | |||
| 71105bec56 | |||
| 2a372674a2 | |||
| 9d32b23ab0 | |||
| da8bf3890c | |||
| b30eac4ebe |
@@ -12,7 +12,7 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
- name: Set up Golang
|
||||
uses: actions/setup-go@v5
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: stable
|
||||
check-latest: true
|
||||
|
||||
@@ -35,7 +35,7 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- uses: actions/setup-go@v5
|
||||
- uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: stable
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/setup-go@v5
|
||||
- uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: stable
|
||||
- uses: golangci/golangci-lint-action@v8
|
||||
@@ -47,7 +47,7 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: '3.13'
|
||||
- name: Validate PR commits
|
||||
|
||||
@@ -19,7 +19,7 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: stable
|
||||
- name: Run unit tests with coverage and race conditions checking
|
||||
@@ -46,7 +46,7 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: stable
|
||||
- name: Install Postgres client
|
||||
|
||||
@@ -72,7 +72,7 @@ Features
|
||||
|
||||
### Integrations
|
||||
|
||||
- 25+ integrations with third-party services: [Apprise](https://github.com/caronc/apprise), [Betula](https://sr.ht/~bouncepaw/betula/), [Cubox](https://cubox.cc/), [Discord](https://discord.com/), [Espial](https://github.com/jonschoning/espial), [Instapaper](https://www.instapaper.com/), [LinkAce](https://www.linkace.org/), [Linkding](https://github.com/sissbruecker/linkding), [LinkWarden](https://linkwarden.app/), [Matrix](https://matrix.org), [Notion](https://www.notion.com/), [Ntfy](https://ntfy.sh/), [Nunux Keeper](https://keeper.nunux.org/), [Pinboard](https://pinboard.in/), [Pushover](https://pushover.net), [RainDrop](https://raindrop.io/), [Readeck](https://readeck.org/en/), [Readwise Reader](https://readwise.io/read), [RssBridge](https://rss-bridge.org/), [Shaarli](https://github.com/shaarli/Shaarli), [Shiori](https://github.com/go-shiori/shiori), [Slack](https://slack.com/), [Telegram](https://telegram.org), [Wallabag](https://www.wallabag.org/), etc.
|
||||
- 25+ integrations with third-party services: [Apprise](https://github.com/caronc/apprise), [Betula](https://sr.ht/~bouncepaw/betula/), [Cubox](https://cubox.cc/), [Discord](https://discord.com/), [Espial](https://github.com/jonschoning/espial), [Instapaper](https://www.instapaper.com/), [LinkAce](https://www.linkace.org/), [Linkding](https://github.com/sissbruecker/linkding), [LinkTaco](https://linktaco.com), [LinkWarden](https://linkwarden.app/), [Matrix](https://matrix.org), [Notion](https://www.notion.com/), [Ntfy](https://ntfy.sh/), [Nunux Keeper](https://keeper.nunux.org/), [Pinboard](https://pinboard.in/), [Pushover](https://pushover.net), [RainDrop](https://raindrop.io/), [Readeck](https://readeck.org/en/), [Readwise Reader](https://readwise.io/read), [RssBridge](https://rss-bridge.org/), [Shaarli](https://github.com/shaarli/Shaarli), [Shiori](https://github.com/go-shiori/shiori), [Slack](https://slack.com/), [Telegram](https://telegram.org), [Wallabag](https://www.wallabag.org/), etc.
|
||||
- Bookmarklet for subscribing to websites directly from any web browser.
|
||||
- Webhooks for real-time notifications or custom integrations.
|
||||
- Compatibility with existing mobile applications using the Fever or Google Reader API.
|
||||
|
||||
+3
-3
@@ -650,9 +650,9 @@ func (c *Client) UpdateEntry(entryID int64, entryChanges *EntryModificationReque
|
||||
return entry, nil
|
||||
}
|
||||
|
||||
// ToggleBookmark toggles entry bookmark value.
|
||||
func (c *Client) ToggleBookmark(entryID int64) error {
|
||||
_, err := c.request.Put(fmt.Sprintf("/v1/entries/%d/bookmark", entryID), nil)
|
||||
// ToggleStarred toggles entry starred value.
|
||||
func (c *Client) ToggleStarred(entryID int64) error {
|
||||
_, err := c.request.Put(fmt.Sprintf("/v1/entries/%d/star", entryID), nil)
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
@@ -6,21 +6,21 @@ require (
|
||||
github.com/PuerkitoBio/goquery v1.10.3
|
||||
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/go-webauthn/webauthn v0.14.0
|
||||
github.com/gorilla/mux v1.8.1
|
||||
github.com/lib/pq v1.10.9
|
||||
github.com/prometheus/client_golang v1.23.0
|
||||
github.com/tdewolff/minify/v2 v2.23.11
|
||||
golang.org/x/crypto v0.41.0
|
||||
golang.org/x/image v0.30.0
|
||||
golang.org/x/net v0.43.0
|
||||
golang.org/x/oauth2 v0.30.0
|
||||
golang.org/x/term v0.34.0
|
||||
github.com/prometheus/client_golang v1.23.2
|
||||
github.com/tdewolff/minify/v2 v2.24.3
|
||||
golang.org/x/crypto v0.42.0
|
||||
golang.org/x/image v0.31.0
|
||||
golang.org/x/net v0.44.0
|
||||
golang.org/x/oauth2 v0.31.0
|
||||
golang.org/x/term v0.35.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/go-webauthn/x v0.1.23 // indirect
|
||||
github.com/golang-jwt/jwt/v5 v5.2.3 // indirect
|
||||
github.com/go-webauthn/x v0.1.25 // indirect
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0 // indirect
|
||||
github.com/google/go-tpm v0.9.5 // indirect
|
||||
)
|
||||
|
||||
@@ -31,16 +31,18 @@ require (
|
||||
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
|
||||
github.com/go-jose/go-jose/v4 v4.0.5 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/kr/text v0.2.0 // indirect
|
||||
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||
github.com/prometheus/client_model v0.6.2 // indirect
|
||||
github.com/prometheus/common v0.65.0 // indirect
|
||||
github.com/prometheus/common v0.66.1 // indirect
|
||||
github.com/prometheus/procfs v0.16.1 // indirect
|
||||
github.com/tdewolff/parse/v2 v2.8.2-0.20250806174018-50048bb39781 // indirect
|
||||
github.com/tdewolff/parse/v2 v2.8.3 // indirect
|
||||
github.com/x448/float16 v0.8.4 // indirect
|
||||
golang.org/x/sys v0.35.0 // indirect
|
||||
golang.org/x/text v0.28.0 // indirect
|
||||
google.golang.org/protobuf v1.36.6 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.2 // indirect
|
||||
golang.org/x/sys v0.36.0 // indirect
|
||||
golang.org/x/text v0.29.0 // indirect
|
||||
google.golang.org/protobuf v1.36.8 // indirect
|
||||
)
|
||||
|
||||
go 1.24.0
|
||||
|
||||
@@ -10,18 +10,19 @@ github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UF
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/coreos/go-oidc/v3 v3.15.0 h1:R6Oz8Z4bqWR7VFQ+sPSvZPQv4x8M+sJkDO5ojgwlyAg=
|
||||
github.com/coreos/go-oidc/v3 v3.15.0/go.mod h1:HaZ3szPaZ0e4r6ebqvsLWlk2Tn+aejfmrfah6hnSYEU=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
|
||||
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
|
||||
github.com/go-jose/go-jose/v4 v4.0.5 h1:M6T8+mKZl/+fNNuFHvGIzDz7BTLQPIounk/b9dw3AaE=
|
||||
github.com/go-jose/go-jose/v4 v4.0.5/go.mod h1:s3P1lRrkT8igV8D9OjyL4WRyHvjB6a4JSllnOrmmBOA=
|
||||
github.com/go-webauthn/webauthn v0.13.4 h1:q68qusWPcqHbg9STSxBLBHnsKaLxNO0RnVKaAqMuAuQ=
|
||||
github.com/go-webauthn/webauthn v0.13.4/go.mod h1:MglN6OH9ECxvhDqoq1wMoF6P6JRYDiQpC9nc5OomQmI=
|
||||
github.com/go-webauthn/x v0.1.23 h1:9lEO0s+g8iTyz5Vszlg/rXTGrx3CjcD0RZQ1GPZCaxI=
|
||||
github.com/go-webauthn/x v0.1.23/go.mod h1:AJd3hI7NfEp/4fI6T4CHD753u91l510lglU7/NMN6+E=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.3 h1:kkGXqQOBSDDWRhWNXTFpqGSCMyh/PLnqUvMGJPDJDs0=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.3/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/go-webauthn/webauthn v0.14.0 h1:ZLNPUgPcDlAeoxe+5umWG/tEeCoQIDr7gE2Zx2QnhL0=
|
||||
github.com/go-webauthn/webauthn v0.14.0/go.mod h1:QZzPFH3LJ48u5uEPAu+8/nWJImoLBWM7iAH/kSVSo6k=
|
||||
github.com/go-webauthn/x v0.1.25 h1:g/0noooIGcz/yCVqebcFgNnGIgBlJIccS+LYAa+0Z88=
|
||||
github.com/go-webauthn/x v0.1.25/go.mod h1:ieblaPY1/BVCV0oQTsA/VAo08/TWayQuJuo5Q+XxmTY=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
@@ -33,6 +34,10 @@ github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
|
||||
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
|
||||
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
||||
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
||||
@@ -43,20 +48,22 @@ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/prometheus/client_golang v1.23.0 h1:ust4zpdl9r4trLY/gSjlm07PuiBq2ynaXXlptpfy8Uc=
|
||||
github.com/prometheus/client_golang v1.23.0/go.mod h1:i/o0R9ByOnHX0McrTMTyhYvKE4haaf2mW08I+jGAjEE=
|
||||
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
|
||||
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
|
||||
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
|
||||
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
|
||||
github.com/prometheus/common v0.65.0 h1:QDwzd+G1twt//Kwj/Ww6E9FQq1iVMmODnILtW1t2VzE=
|
||||
github.com/prometheus/common v0.65.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8=
|
||||
github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs=
|
||||
github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA=
|
||||
github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg=
|
||||
github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
|
||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/tdewolff/minify/v2 v2.23.11 h1:cZqTVCtuVvPC8/GbCvYgIcdAQGmoxEObZzKeKIUixTE=
|
||||
github.com/tdewolff/minify/v2 v2.23.11/go.mod h1:vmkbfGQ5hp/eYB+TswNWKma67S0a+32HBL+mFWxjZ2Q=
|
||||
github.com/tdewolff/parse/v2 v2.8.2-0.20250806174018-50048bb39781 h1:2qicgFovKg1XtX7Wf6GwexUdpb7q/jMIE2IgkYsVAvE=
|
||||
github.com/tdewolff/parse/v2 v2.8.2-0.20250806174018-50048bb39781/go.mod h1:Hwlni2tiVNKyzR1o6nUs4FOF07URA+JLBLd6dlIXYqo=
|
||||
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
|
||||
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/tdewolff/minify/v2 v2.24.3 h1:BaKgWSFLKbKDiUskbeRgbe2n5d1Ci1x3cN/eXna8zOA=
|
||||
github.com/tdewolff/minify/v2 v2.24.3/go.mod h1:1JrCtoZXaDbqioQZfk3Jdmr0GPJKiU7c1Apmb+7tCeE=
|
||||
github.com/tdewolff/parse/v2 v2.8.3 h1:5VbvtJ83cfb289A1HzRA9sf02iT8YyUwN84ezjkdY1I=
|
||||
github.com/tdewolff/parse/v2 v2.8.3/go.mod h1:Hwlni2tiVNKyzR1o6nUs4FOF07URA+JLBLd6dlIXYqo=
|
||||
github.com/tdewolff/test v1.0.11 h1:FdLbwQVHxqG16SlkGveC0JVyrJN62COWTRyUFzfbtBE=
|
||||
github.com/tdewolff/test v1.0.11/go.mod h1:XPuWBzvdUzhCuxWO1ojpXsyzsA5bFoS3tO/Q3kFuTG8=
|
||||
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
||||
@@ -66,16 +73,20 @@ github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3i
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
||||
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
||||
go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
|
||||
go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
|
||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||
golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4=
|
||||
golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc=
|
||||
golang.org/x/image v0.30.0 h1:jD5RhkmVAnjqaCUXfbGBrn3lpxbknfN9w2UhHHU+5B4=
|
||||
golang.org/x/image v0.30.0/go.mod h1:SAEUTxCCMWSrJcCy/4HwavEsfZZJlYxeHLc6tTiAe/c=
|
||||
golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI=
|
||||
golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8=
|
||||
golang.org/x/image v0.31.0 h1:mLChjE2MV6g1S7oqbXC0/UcKijjm5fnJLUYKIYrLESA=
|
||||
golang.org/x/image v0.31.0/go.mod h1:R9ec5Lcp96v9FTF+ajwaH3uGxPH4fKfHHAVbUILxghA=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
@@ -90,10 +101,10 @@ golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
||||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
|
||||
golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
|
||||
golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
|
||||
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
|
||||
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
|
||||
golang.org/x/net v0.44.0 h1:evd8IRDyfNBMBTTY5XRF1vaZlD+EmWx6x8PkhR04H/I=
|
||||
golang.org/x/net v0.44.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY=
|
||||
golang.org/x/oauth2 v0.31.0 h1:8Fq0yVZLh4j4YA47vHKFTa9Ew5XIrCP8LC6UeNZnLxo=
|
||||
golang.org/x/oauth2 v0.31.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
@@ -112,8 +123,8 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
|
||||
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
|
||||
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
@@ -123,8 +134,8 @@ golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
|
||||
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
||||
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
|
||||
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
|
||||
golang.org/x/term v0.34.0 h1:O/2T7POpk0ZZ7MAzMeWFSg6S5IpWd/RXDlM9hgM3DR4=
|
||||
golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw=
|
||||
golang.org/x/term v0.35.0 h1:bZBVKBudEyhRcajGcNc3jIfWPqV4y/Kt2XcoigOWtDQ=
|
||||
golang.org/x/term v0.35.0/go.mod h1:TPGtkTLesOwf2DE8CgVYiZinHAOuy5AYUYT1lENIZnA=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
@@ -134,8 +145,8 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
|
||||
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
|
||||
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
|
||||
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
@@ -143,7 +154,10 @@ golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
|
||||
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
|
||||
google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc=
|
||||
google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
+2
-1
@@ -67,7 +67,8 @@ func Serve(router *mux.Router, store *storage.Storage, pool *worker.Pool) {
|
||||
sr.HandleFunc("/entries", handler.setEntryStatus).Methods(http.MethodPut)
|
||||
sr.HandleFunc("/entries/{entryID}", handler.getEntry).Methods(http.MethodGet)
|
||||
sr.HandleFunc("/entries/{entryID}", handler.updateEntry).Methods(http.MethodPut)
|
||||
sr.HandleFunc("/entries/{entryID}/bookmark", handler.toggleBookmark).Methods(http.MethodPut)
|
||||
sr.HandleFunc("/entries/{entryID}/bookmark", handler.toggleStarred).Methods(http.MethodPut)
|
||||
sr.HandleFunc("/entries/{entryID}/star", handler.toggleStarred).Methods(http.MethodPut)
|
||||
sr.HandleFunc("/entries/{entryID}/save", handler.saveEntry).Methods(http.MethodPost)
|
||||
sr.HandleFunc("/entries/{entryID}/fetch-content", handler.fetchContent).Methods(http.MethodGet)
|
||||
sr.HandleFunc("/flush-history", handler.flushHistory).Methods(http.MethodPut, http.MethodDelete)
|
||||
|
||||
@@ -2381,7 +2381,6 @@ func TestGetGlobalEntriesEndpoint(t *testing.T) {
|
||||
}
|
||||
|
||||
feedIDEntry, err := regularUserClient.Feed(feedID)
|
||||
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -2413,6 +2412,64 @@ func TestGetGlobalEntriesEndpoint(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCannotGetRemovedEntries(t *testing.T) {
|
||||
testConfig := newIntegrationTestConfig()
|
||||
if !testConfig.isConfigured() {
|
||||
t.Skip(skipIntegrationTestsMessage)
|
||||
}
|
||||
|
||||
adminClient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)
|
||||
|
||||
regularTestUser, err := adminClient.CreateUser(testConfig.genRandomUsername(), testConfig.testRegularPassword, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer adminClient.DeleteUser(regularTestUser.ID)
|
||||
|
||||
regularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)
|
||||
|
||||
feedID, err := regularUserClient.CreateFeed(&miniflux.FeedCreationRequest{
|
||||
FeedURL: testConfig.testFeedURL,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
feedEntries, err := regularUserClient.Entries(&miniflux.Filter{FeedID: feedID})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if feedEntries.Total == 0 {
|
||||
t.Fatalf(`Expected at least one entry, got none`)
|
||||
}
|
||||
|
||||
if err := regularUserClient.UpdateEntries([]int64{feedEntries.Entries[0].ID}, miniflux.EntryStatusRemoved); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := regularUserClient.Entry(feedEntries.Entries[0].ID); err != miniflux.ErrNotFound {
|
||||
t.Fatalf(`Expected entry to be not found, got %v`, err)
|
||||
}
|
||||
|
||||
if _, err := regularUserClient.FeedEntry(feedID, feedEntries.Entries[0].ID); err != miniflux.ErrNotFound {
|
||||
t.Fatalf(`Expected entry to be not found, got %v`, err)
|
||||
}
|
||||
|
||||
if _, err := regularUserClient.CategoryEntry(feedEntries.Entries[0].Feed.Category.ID, feedEntries.Entries[0].ID); err != miniflux.ErrNotFound {
|
||||
t.Fatalf(`Expected entry to be not found, got %v`, err)
|
||||
}
|
||||
|
||||
updatedFeedEntries, err := regularUserClient.Entries(&miniflux.Filter{FeedID: feedID})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if updatedFeedEntries.Total != feedEntries.Total-1 {
|
||||
t.Fatalf(`Expected %d entries, got %d`, feedEntries.Total-1, updatedFeedEntries.Total)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateEnclosureEndpoint(t *testing.T) {
|
||||
testConfig := newIntegrationTestConfig()
|
||||
if !testConfig.isConfigured() {
|
||||
@@ -2631,64 +2688,6 @@ func TestUpdateEntryStatusEndpoint(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateEntryRemovedStatusEndpoint(t *testing.T) {
|
||||
testConfig := newIntegrationTestConfig()
|
||||
if !testConfig.isConfigured() {
|
||||
t.Skip(skipIntegrationTestsMessage)
|
||||
}
|
||||
|
||||
adminClient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)
|
||||
|
||||
regularTestUser, err := adminClient.CreateUser(testConfig.genRandomUsername(), testConfig.testRegularPassword, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer adminClient.DeleteUser(regularTestUser.ID)
|
||||
|
||||
regularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)
|
||||
|
||||
feedID, err := regularUserClient.CreateFeed(&miniflux.FeedCreationRequest{
|
||||
FeedURL: testConfig.testFeedURL,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
result, err := regularUserClient.FeedEntries(feedID, nil)
|
||||
if err != nil {
|
||||
t.Fatalf(`Failed to get entries: %v`, err)
|
||||
}
|
||||
|
||||
// First we set the entry as "removed"
|
||||
if err := regularUserClient.UpdateEntries([]int64{result.Entries[0].ID}, miniflux.EntryStatusRemoved); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
entry, err := regularUserClient.Entry(result.Entries[0].ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if entry.Status != miniflux.EntryStatusRemoved {
|
||||
t.Fatalf(`Invalid status, got %q instead of %q`, entry.Status, miniflux.EntryStatusRemoved)
|
||||
}
|
||||
|
||||
// Then we try to set it to "unread"
|
||||
if err := regularUserClient.UpdateEntries([]int64{result.Entries[0].ID}, miniflux.EntryStatusUnread); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
entry, err = regularUserClient.Entry(result.Entries[0].ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// It should stay set to "removed"
|
||||
if entry.Status != miniflux.EntryStatusRemoved {
|
||||
t.Fatalf(`Modified immutable status: got %q instead of %q`, entry.Status, miniflux.EntryStatusRemoved)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateEntryEndpoint(t *testing.T) {
|
||||
testConfig := newIntegrationTestConfig()
|
||||
if !testConfig.isConfigured() {
|
||||
@@ -2749,7 +2748,7 @@ func TestUpdateEntryEndpoint(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestToggleBookmarkEndpoint(t *testing.T) {
|
||||
func TestToggleStarredEndpoint(t *testing.T) {
|
||||
testConfig := newIntegrationTestConfig()
|
||||
if !testConfig.isConfigured() {
|
||||
t.Skip(skipIntegrationTestsMessage)
|
||||
@@ -2777,7 +2776,7 @@ func TestToggleBookmarkEndpoint(t *testing.T) {
|
||||
t.Fatalf(`Failed to get entries: %v`, err)
|
||||
}
|
||||
|
||||
if err := regularUserClient.ToggleBookmark(result.Entries[0].ID); err != nil {
|
||||
if err := regularUserClient.ToggleStarred(result.Entries[0].ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -2787,7 +2786,7 @@ func TestToggleBookmarkEndpoint(t *testing.T) {
|
||||
}
|
||||
|
||||
if !entry.Starred {
|
||||
t.Fatalf(`The entry should be bookmarked`)
|
||||
t.Fatalf(`The entry should be starred`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2932,13 +2931,4 @@ func TestFlushHistoryEndpoint(t *testing.T) {
|
||||
if readEntries.Total != 0 {
|
||||
t.Fatalf(`Invalid total, got %d`, readEntries.Total)
|
||||
}
|
||||
|
||||
removedEntries, err := regularUserClient.Entries(&miniflux.Filter{Status: miniflux.EntryStatusRemoved})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if removedEntries.Total != 2 {
|
||||
t.Fatalf(`Invalid total, got %d`, removedEntries.Total)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,6 +47,7 @@ func (h *handler) getFeedEntry(w http.ResponseWriter, r *http.Request) {
|
||||
builder := h.store.NewEntryQueryBuilder(request.UserID(r))
|
||||
builder.WithFeedID(feedID)
|
||||
builder.WithEntryID(entryID)
|
||||
builder.WithoutStatus(model.EntryStatusRemoved)
|
||||
|
||||
h.getEntryFromBuilder(w, r, builder)
|
||||
}
|
||||
@@ -58,6 +59,7 @@ func (h *handler) getCategoryEntry(w http.ResponseWriter, r *http.Request) {
|
||||
builder := h.store.NewEntryQueryBuilder(request.UserID(r))
|
||||
builder.WithCategoryID(categoryID)
|
||||
builder.WithEntryID(entryID)
|
||||
builder.WithoutStatus(model.EntryStatusRemoved)
|
||||
|
||||
h.getEntryFromBuilder(w, r, builder)
|
||||
}
|
||||
@@ -66,6 +68,7 @@ func (h *handler) getEntry(w http.ResponseWriter, r *http.Request) {
|
||||
entryID := request.RouteInt64Param(r, "entryID")
|
||||
builder := h.store.NewEntryQueryBuilder(request.UserID(r))
|
||||
builder.WithEntryID(entryID)
|
||||
builder.WithoutStatus(model.EntryStatusRemoved)
|
||||
|
||||
h.getEntryFromBuilder(w, r, builder)
|
||||
}
|
||||
@@ -136,6 +139,7 @@ func (h *handler) findEntries(w http.ResponseWriter, r *http.Request, feedID int
|
||||
builder.WithLimit(limit)
|
||||
builder.WithTags(tags)
|
||||
builder.WithEnclosures()
|
||||
builder.WithoutStatus(model.EntryStatusRemoved)
|
||||
|
||||
if request.HasQueryParam(r, "globally_visible") {
|
||||
globallyVisible := request.QueryBoolParam(r, "globally_visible", true)
|
||||
@@ -186,9 +190,9 @@ func (h *handler) setEntryStatus(w http.ResponseWriter, r *http.Request) {
|
||||
json.NoContent(w, r)
|
||||
}
|
||||
|
||||
func (h *handler) toggleBookmark(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *handler) toggleStarred(w http.ResponseWriter, r *http.Request) {
|
||||
entryID := request.RouteInt64Param(r, "entryID")
|
||||
if err := h.store.ToggleBookmark(request.UserID(r), entryID); err != nil {
|
||||
if err := h.store.ToggleStarred(request.UserID(r), entryID); err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ package cli // import "miniflux.app/v2/internal/cli"
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
@@ -16,7 +17,7 @@ func askCredentials() (string, string) {
|
||||
fd := int(os.Stdin.Fd())
|
||||
|
||||
if !term.IsTerminal(fd) {
|
||||
printErrorAndExit(fmt.Errorf("this is not an interactive terminal, exiting"))
|
||||
printErrorAndExit(errors.New("this is not an interactive terminal, exiting"))
|
||||
}
|
||||
|
||||
fmt.Print("Enter Username: ")
|
||||
@@ -30,6 +31,6 @@ func askCredentials() (string, string) {
|
||||
defer term.Restore(fd, state)
|
||||
bytePassword, _ := term.ReadPassword(fd)
|
||||
|
||||
fmt.Printf("\n")
|
||||
fmt.Print("\n")
|
||||
return strings.TrimSpace(username), strings.TrimSpace(string(bytePassword))
|
||||
}
|
||||
|
||||
@@ -14,15 +14,15 @@ import (
|
||||
)
|
||||
|
||||
func runCleanupTasks(store *storage.Storage) {
|
||||
nbSessions := store.CleanOldSessions(config.Opts.CleanupRemoveSessionsDays())
|
||||
nbUserSessions := store.CleanOldUserSessions(config.Opts.CleanupRemoveSessionsDays())
|
||||
nbSessions := store.CleanOldSessions(config.Opts.CleanupRemoveSessionsInterval())
|
||||
nbUserSessions := store.CleanOldUserSessions(config.Opts.CleanupRemoveSessionsInterval())
|
||||
slog.Info("Sessions cleanup completed",
|
||||
slog.Int64("application_sessions_removed", nbSessions),
|
||||
slog.Int64("user_sessions_removed", nbUserSessions),
|
||||
)
|
||||
|
||||
startTime := time.Now()
|
||||
if rowsAffected, err := store.ArchiveEntries(model.EntryStatusRead, config.Opts.CleanupArchiveReadDays(), config.Opts.CleanupArchiveBatchSize()); err != nil {
|
||||
if rowsAffected, err := store.ArchiveEntries(model.EntryStatusRead, config.Opts.CleanupArchiveReadInterval(), config.Opts.CleanupArchiveBatchSize()); err != nil {
|
||||
slog.Error("Unable to archive read entries", slog.Any("error", err))
|
||||
} else {
|
||||
slog.Info("Archiving read entries completed",
|
||||
@@ -35,7 +35,7 @@ func runCleanupTasks(store *storage.Storage) {
|
||||
}
|
||||
|
||||
startTime = time.Now()
|
||||
if rowsAffected, err := store.ArchiveEntries(model.EntryStatusUnread, config.Opts.CleanupArchiveUnreadDays(), config.Opts.CleanupArchiveBatchSize()); err != nil {
|
||||
if rowsAffected, err := store.ArchiveEntries(model.EntryStatusUnread, config.Opts.CleanupArchiveUnreadInterval(), config.Opts.CleanupArchiveBatchSize()); err != nil {
|
||||
slog.Error("Unable to archive unread entries", slog.Any("error", err))
|
||||
} else {
|
||||
slog.Info("Archiving unread entries completed",
|
||||
|
||||
+2
-2
@@ -78,7 +78,7 @@ func Parse() {
|
||||
flag.StringVar(&flagExportUserFeeds, "export-user-feeds", "", flagExportUserFeedsHelp)
|
||||
flag.Parse()
|
||||
|
||||
cfg := config.NewParser()
|
||||
cfg := config.NewConfigParser()
|
||||
|
||||
if flagConfigFile != "" {
|
||||
config.Opts, err = cfg.ParseFile(flagConfigFile)
|
||||
@@ -263,6 +263,6 @@ func Parse() {
|
||||
}
|
||||
|
||||
func printErrorAndExit(err error) {
|
||||
fmt.Fprintf(os.Stderr, "%v\n", err)
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
@@ -26,12 +26,12 @@ func runScheduler(store *storage.Storage, pool *worker.Pool) {
|
||||
|
||||
go cleanupScheduler(
|
||||
store,
|
||||
config.Opts.CleanupFrequencyHours(),
|
||||
config.Opts.CleanupFrequency(),
|
||||
)
|
||||
}
|
||||
|
||||
func feedScheduler(store *storage.Storage, pool *worker.Pool, frequency, batchSize, errorLimit, limitPerHost int) {
|
||||
for range time.Tick(time.Duration(frequency) * time.Minute) {
|
||||
func feedScheduler(store *storage.Storage, pool *worker.Pool, frequency time.Duration, batchSize, errorLimit, limitPerHost int) {
|
||||
for range time.Tick(frequency) {
|
||||
// Generate a batch of feeds for any user that has feeds to refresh.
|
||||
batchBuilder := store.NewBatchBuilder()
|
||||
batchBuilder.WithBatchSize(batchSize)
|
||||
@@ -49,8 +49,8 @@ func feedScheduler(store *storage.Storage, pool *worker.Pool, frequency, batchSi
|
||||
}
|
||||
}
|
||||
|
||||
func cleanupScheduler(store *storage.Storage, frequency int) {
|
||||
for range time.Tick(time.Duration(frequency) * time.Hour) {
|
||||
func cleanupScheduler(store *storage.Storage, frequency time.Duration) {
|
||||
for range time.Tick(frequency) {
|
||||
runCleanupTasks(store)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package config // import "miniflux.app/v2/internal/config"
|
||||
|
||||
import "miniflux.app/v2/internal/version"
|
||||
|
||||
// Opts holds parsed configuration options.
|
||||
var Opts *configOptions
|
||||
|
||||
var defaultHTTPClientUserAgent = "Mozilla/5.0 (compatible; Miniflux/" + version.Version + "; +https://miniflux.app)"
|
||||
File diff suppressed because it is too large
Load Diff
+806
-633
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+207
-268
@@ -7,310 +7,214 @@ import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// parser handles configuration parsing.
|
||||
type parser struct {
|
||||
opts *options
|
||||
type configParser struct {
|
||||
options *configOptions
|
||||
}
|
||||
|
||||
// NewParser returns a new Parser.
|
||||
func NewParser() *parser {
|
||||
return &parser{
|
||||
opts: NewOptions(),
|
||||
func NewConfigParser() *configParser {
|
||||
return &configParser{
|
||||
options: NewConfigOptions(),
|
||||
}
|
||||
}
|
||||
|
||||
// ParseEnvironmentVariables loads configuration values from environment variables.
|
||||
func (p *parser) ParseEnvironmentVariables() (*options, error) {
|
||||
err := p.parseLines(os.Environ())
|
||||
if err != nil {
|
||||
func (cp *configParser) ParseEnvironmentVariables() (*configOptions, error) {
|
||||
if err := cp.parseLines(os.Environ()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return p.opts, nil
|
||||
|
||||
return cp.options, nil
|
||||
}
|
||||
|
||||
// ParseFile loads configuration values from a local file.
|
||||
func (p *parser) ParseFile(filename string) (*options, error) {
|
||||
func (cp *configParser) ParseFile(filename string) (*configOptions, error) {
|
||||
fp, err := os.Open(filename)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer fp.Close()
|
||||
|
||||
err = p.parseLines(p.parseFileContent(fp))
|
||||
if err != nil {
|
||||
if err := cp.parseLines(parseFileContent(fp)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return p.opts, nil
|
||||
|
||||
return cp.options, nil
|
||||
}
|
||||
|
||||
func (p *parser) parseFileContent(r io.Reader) (lines []string) {
|
||||
scanner := bufio.NewScanner(r)
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if !strings.HasPrefix(line, "#") && strings.Index(line, "=") > 0 {
|
||||
lines = append(lines, line)
|
||||
}
|
||||
}
|
||||
return lines
|
||||
}
|
||||
func (cp *configParser) postParsing() error {
|
||||
// Parse basePath and rootURL based on BASE_URL
|
||||
baseURL := cp.options.options["BASE_URL"].ParsedStringValue
|
||||
baseURL = strings.TrimSuffix(baseURL, "/")
|
||||
|
||||
func (p *parser) parseLines(lines []string) (err error) {
|
||||
var port string
|
||||
|
||||
for lineNum, line := range lines {
|
||||
key, value, ok := strings.Cut(line, "=")
|
||||
if !ok {
|
||||
return fmt.Errorf("config: unable to parse configuration, invalid format on line %d", lineNum)
|
||||
}
|
||||
key, value = strings.TrimSpace(key), strings.TrimSpace(value)
|
||||
|
||||
switch key {
|
||||
case "LOG_FILE":
|
||||
p.opts.logFile = parseString(value, defaultLogFile)
|
||||
case "LOG_DATE_TIME":
|
||||
p.opts.logDateTime = parseBool(value, defaultLogDateTime)
|
||||
case "LOG_LEVEL":
|
||||
parsedValue := parseString(value, defaultLogLevel)
|
||||
if parsedValue == "debug" || parsedValue == "info" || parsedValue == "warning" || parsedValue == "error" {
|
||||
p.opts.logLevel = parsedValue
|
||||
}
|
||||
case "LOG_FORMAT":
|
||||
parsedValue := parseString(value, defaultLogFormat)
|
||||
if parsedValue == "json" || parsedValue == "text" {
|
||||
p.opts.logFormat = parsedValue
|
||||
}
|
||||
case "BASE_URL":
|
||||
p.opts.baseURL, p.opts.rootURL, p.opts.basePath, err = parseBaseURL(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
case "PORT":
|
||||
port = value
|
||||
case "LISTEN_ADDR":
|
||||
p.opts.listenAddr = parseStringList(value, []string{defaultListenAddr})
|
||||
case "DATABASE_URL":
|
||||
p.opts.databaseURL = parseString(value, defaultDatabaseURL)
|
||||
case "DATABASE_URL_FILE":
|
||||
p.opts.databaseURL = readSecretFile(value, defaultDatabaseURL)
|
||||
case "DATABASE_MAX_CONNS":
|
||||
p.opts.databaseMaxConns = parseInt(value, defaultDatabaseMaxConns)
|
||||
case "DATABASE_MIN_CONNS":
|
||||
p.opts.databaseMinConns = parseInt(value, defaultDatabaseMinConns)
|
||||
case "DATABASE_CONNECTION_LIFETIME":
|
||||
p.opts.databaseConnectionLifetime = parseInt(value, defaultDatabaseConnectionLifetime)
|
||||
case "FILTER_ENTRY_MAX_AGE_DAYS":
|
||||
p.opts.filterEntryMaxAgeDays = parseInt(value, defaultFilterEntryMaxAgeDays)
|
||||
case "RUN_MIGRATIONS":
|
||||
p.opts.runMigrations = parseBool(value, defaultRunMigrations)
|
||||
case "DISABLE_HSTS":
|
||||
p.opts.hsts = !parseBool(value, defaultHSTS)
|
||||
case "HTTPS":
|
||||
p.opts.HTTPS = parseBool(value, defaultHTTPS)
|
||||
case "DISABLE_SCHEDULER_SERVICE":
|
||||
p.opts.schedulerService = !parseBool(value, defaultSchedulerService)
|
||||
case "DISABLE_HTTP_SERVICE":
|
||||
p.opts.httpService = !parseBool(value, defaultHTTPService)
|
||||
case "CERT_FILE":
|
||||
p.opts.certFile = parseString(value, defaultCertFile)
|
||||
case "KEY_FILE":
|
||||
p.opts.certKeyFile = parseString(value, defaultKeyFile)
|
||||
case "CERT_DOMAIN":
|
||||
p.opts.certDomain = parseString(value, defaultCertDomain)
|
||||
case "CLEANUP_FREQUENCY_HOURS":
|
||||
p.opts.cleanupFrequencyHours = parseInt(value, defaultCleanupFrequencyHours)
|
||||
case "CLEANUP_ARCHIVE_READ_DAYS":
|
||||
p.opts.cleanupArchiveReadDays = parseInt(value, defaultCleanupArchiveReadDays)
|
||||
case "CLEANUP_ARCHIVE_UNREAD_DAYS":
|
||||
p.opts.cleanupArchiveUnreadDays = parseInt(value, defaultCleanupArchiveUnreadDays)
|
||||
case "CLEANUP_ARCHIVE_BATCH_SIZE":
|
||||
p.opts.cleanupArchiveBatchSize = parseInt(value, defaultCleanupArchiveBatchSize)
|
||||
case "CLEANUP_REMOVE_SESSIONS_DAYS":
|
||||
p.opts.cleanupRemoveSessionsDays = parseInt(value, defaultCleanupRemoveSessionsDays)
|
||||
case "WORKER_POOL_SIZE":
|
||||
p.opts.workerPoolSize = parseInt(value, defaultWorkerPoolSize)
|
||||
case "FORCE_REFRESH_INTERVAL":
|
||||
p.opts.forceRefreshInterval = parseInt(value, defaultForceRefreshInterval)
|
||||
case "BATCH_SIZE":
|
||||
p.opts.batchSize = parseInt(value, defaultBatchSize)
|
||||
case "POLLING_FREQUENCY":
|
||||
p.opts.pollingFrequency = parseInt(value, defaultPollingFrequency)
|
||||
case "POLLING_LIMIT_PER_HOST":
|
||||
p.opts.pollingLimitPerHost = parseInt(value, 0)
|
||||
case "POLLING_PARSING_ERROR_LIMIT":
|
||||
p.opts.pollingParsingErrorLimit = parseInt(value, defaultPollingParsingErrorLimit)
|
||||
case "POLLING_SCHEDULER":
|
||||
p.opts.pollingScheduler = strings.ToLower(parseString(value, defaultPollingScheduler))
|
||||
case "SCHEDULER_ENTRY_FREQUENCY_MAX_INTERVAL":
|
||||
p.opts.schedulerEntryFrequencyMaxInterval = parseInt(value, defaultSchedulerEntryFrequencyMaxInterval)
|
||||
case "SCHEDULER_ENTRY_FREQUENCY_MIN_INTERVAL":
|
||||
p.opts.schedulerEntryFrequencyMinInterval = parseInt(value, defaultSchedulerEntryFrequencyMinInterval)
|
||||
case "SCHEDULER_ENTRY_FREQUENCY_FACTOR":
|
||||
p.opts.schedulerEntryFrequencyFactor = parseInt(value, defaultSchedulerEntryFrequencyFactor)
|
||||
case "SCHEDULER_ROUND_ROBIN_MIN_INTERVAL":
|
||||
p.opts.schedulerRoundRobinMinInterval = parseInt(value, defaultSchedulerRoundRobinMinInterval)
|
||||
case "SCHEDULER_ROUND_ROBIN_MAX_INTERVAL":
|
||||
p.opts.schedulerRoundRobinMaxInterval = parseInt(value, defaultSchedulerRoundRobinMaxInterval)
|
||||
case "MEDIA_PROXY_HTTP_CLIENT_TIMEOUT":
|
||||
p.opts.mediaProxyHTTPClientTimeout = parseInt(value, defaultMediaProxyHTTPClientTimeout)
|
||||
case "MEDIA_PROXY_MODE":
|
||||
p.opts.mediaProxyMode = parseString(value, defaultMediaProxyMode)
|
||||
case "MEDIA_PROXY_RESOURCE_TYPES":
|
||||
p.opts.mediaProxyResourceTypes = parseStringList(value, []string{defaultMediaResourceTypes})
|
||||
case "MEDIA_PROXY_PRIVATE_KEY":
|
||||
randomKey := make([]byte, 16)
|
||||
rand.Read(randomKey)
|
||||
p.opts.mediaProxyPrivateKey = parseBytes(value, randomKey)
|
||||
case "MEDIA_PROXY_CUSTOM_URL":
|
||||
p.opts.mediaProxyCustomURL, err = url.Parse(parseString(value, defaultMediaProxyURL))
|
||||
if err != nil {
|
||||
return fmt.Errorf("config: invalid MEDIA_PROXY_CUSTOM_URL value: %w", err)
|
||||
}
|
||||
case "CREATE_ADMIN":
|
||||
p.opts.createAdmin = parseBool(value, defaultCreateAdmin)
|
||||
case "ADMIN_USERNAME":
|
||||
p.opts.adminUsername = parseString(value, defaultAdminUsername)
|
||||
case "ADMIN_USERNAME_FILE":
|
||||
p.opts.adminUsername = readSecretFile(value, defaultAdminUsername)
|
||||
case "ADMIN_PASSWORD":
|
||||
p.opts.adminPassword = parseString(value, defaultAdminPassword)
|
||||
case "ADMIN_PASSWORD_FILE":
|
||||
p.opts.adminPassword = readSecretFile(value, defaultAdminPassword)
|
||||
case "OAUTH2_USER_CREATION":
|
||||
p.opts.oauth2UserCreationAllowed = parseBool(value, defaultOAuth2UserCreation)
|
||||
case "OAUTH2_CLIENT_ID":
|
||||
p.opts.oauth2ClientID = parseString(value, defaultOAuth2ClientID)
|
||||
case "OAUTH2_CLIENT_ID_FILE":
|
||||
p.opts.oauth2ClientID = readSecretFile(value, defaultOAuth2ClientID)
|
||||
case "OAUTH2_CLIENT_SECRET":
|
||||
p.opts.oauth2ClientSecret = parseString(value, defaultOAuth2ClientSecret)
|
||||
case "OAUTH2_CLIENT_SECRET_FILE":
|
||||
p.opts.oauth2ClientSecret = readSecretFile(value, defaultOAuth2ClientSecret)
|
||||
case "OAUTH2_REDIRECT_URL":
|
||||
p.opts.oauth2RedirectURL = parseString(value, defaultOAuth2RedirectURL)
|
||||
case "OAUTH2_OIDC_DISCOVERY_ENDPOINT":
|
||||
p.opts.oidcDiscoveryEndpoint = parseString(value, defaultOAuth2OidcDiscoveryEndpoint)
|
||||
case "OAUTH2_OIDC_PROVIDER_NAME":
|
||||
p.opts.oidcProviderName = parseString(value, defaultOauth2OidcProviderName)
|
||||
case "OAUTH2_PROVIDER":
|
||||
p.opts.oauth2Provider = parseString(value, defaultOAuth2Provider)
|
||||
case "DISABLE_LOCAL_AUTH":
|
||||
p.opts.disableLocalAuth = parseBool(value, defaultDisableLocalAuth)
|
||||
case "HTTP_CLIENT_TIMEOUT":
|
||||
p.opts.httpClientTimeout = parseInt(value, defaultHTTPClientTimeout)
|
||||
case "HTTP_CLIENT_MAX_BODY_SIZE":
|
||||
p.opts.httpClientMaxBodySize = int64(parseInt(value, defaultHTTPClientMaxBodySize) * 1024 * 1024)
|
||||
case "HTTP_CLIENT_PROXY":
|
||||
p.opts.httpClientProxyURL, err = url.Parse(parseString(value, defaultHTTPClientProxy))
|
||||
if err != nil {
|
||||
return fmt.Errorf("config: invalid HTTP_CLIENT_PROXY value: %w", err)
|
||||
}
|
||||
case "HTTP_CLIENT_PROXIES":
|
||||
p.opts.httpClientProxies = parseStringList(value, []string{})
|
||||
case "HTTP_CLIENT_USER_AGENT":
|
||||
p.opts.httpClientUserAgent = parseString(value, defaultHTTPClientUserAgent)
|
||||
case "HTTP_SERVER_TIMEOUT":
|
||||
p.opts.httpServerTimeout = parseInt(value, defaultHTTPServerTimeout)
|
||||
case "AUTH_PROXY_HEADER":
|
||||
p.opts.authProxyHeader = parseString(value, defaultAuthProxyHeader)
|
||||
case "AUTH_PROXY_USER_CREATION":
|
||||
p.opts.authProxyUserCreation = parseBool(value, defaultAuthProxyUserCreation)
|
||||
case "MAINTENANCE_MODE":
|
||||
p.opts.maintenanceMode = parseBool(value, defaultMaintenanceMode)
|
||||
case "MAINTENANCE_MESSAGE":
|
||||
p.opts.maintenanceMessage = parseString(value, defaultMaintenanceMessage)
|
||||
case "METRICS_COLLECTOR":
|
||||
p.opts.metricsCollector = parseBool(value, defaultMetricsCollector)
|
||||
case "METRICS_REFRESH_INTERVAL":
|
||||
p.opts.metricsRefreshInterval = parseInt(value, defaultMetricsRefreshInterval)
|
||||
case "METRICS_ALLOWED_NETWORKS":
|
||||
p.opts.metricsAllowedNetworks = parseStringList(value, []string{defaultMetricsAllowedNetworks})
|
||||
case "METRICS_USERNAME":
|
||||
p.opts.metricsUsername = parseString(value, defaultMetricsUsername)
|
||||
case "METRICS_USERNAME_FILE":
|
||||
p.opts.metricsUsername = readSecretFile(value, defaultMetricsUsername)
|
||||
case "METRICS_PASSWORD":
|
||||
p.opts.metricsPassword = parseString(value, defaultMetricsPassword)
|
||||
case "METRICS_PASSWORD_FILE":
|
||||
p.opts.metricsPassword = readSecretFile(value, defaultMetricsPassword)
|
||||
case "FETCH_BILIBILI_WATCH_TIME":
|
||||
p.opts.fetchBilibiliWatchTime = parseBool(value, defaultFetchBilibiliWatchTime)
|
||||
case "FETCH_NEBULA_WATCH_TIME":
|
||||
p.opts.fetchNebulaWatchTime = parseBool(value, defaultFetchNebulaWatchTime)
|
||||
case "FETCH_ODYSEE_WATCH_TIME":
|
||||
p.opts.fetchOdyseeWatchTime = parseBool(value, defaultFetchOdyseeWatchTime)
|
||||
case "FETCH_YOUTUBE_WATCH_TIME":
|
||||
p.opts.fetchYouTubeWatchTime = parseBool(value, defaultFetchYouTubeWatchTime)
|
||||
case "YOUTUBE_API_KEY":
|
||||
p.opts.youTubeApiKey = parseString(value, defaultYouTubeApiKey)
|
||||
case "YOUTUBE_EMBED_URL_OVERRIDE":
|
||||
p.opts.youTubeEmbedUrlOverride = parseString(value, defaultYouTubeEmbedUrlOverride)
|
||||
case "WATCHDOG":
|
||||
p.opts.watchdog = parseBool(value, defaultWatchdog)
|
||||
case "INVIDIOUS_INSTANCE":
|
||||
p.opts.invidiousInstance = parseString(value, defaultInvidiousInstance)
|
||||
case "WEBAUTHN":
|
||||
p.opts.webAuthn = parseBool(value, defaultWebAuthn)
|
||||
}
|
||||
}
|
||||
|
||||
if port != "" {
|
||||
p.opts.listenAddr = []string{":" + port}
|
||||
}
|
||||
|
||||
youtubeEmbedURL, err := url.Parse(p.opts.youTubeEmbedUrlOverride)
|
||||
parsedURL, err := url.Parse(baseURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("config: invalid YOUTUBE_EMBED_URL_OVERRIDE value: %w", err)
|
||||
}
|
||||
p.opts.youTubeEmbedDomain = youtubeEmbedURL.Hostname()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseBaseURL(value string) (string, string, string, error) {
|
||||
if value == "" {
|
||||
return defaultBaseURL, defaultRootURL, "", nil
|
||||
}
|
||||
|
||||
value = strings.TrimSuffix(value, "/")
|
||||
|
||||
parsedURL, err := url.Parse(value)
|
||||
if err != nil {
|
||||
return "", "", "", fmt.Errorf("config: invalid BASE_URL: %w", err)
|
||||
return fmt.Errorf("invalid BASE_URL: %v", err)
|
||||
}
|
||||
|
||||
scheme := strings.ToLower(parsedURL.Scheme)
|
||||
if scheme != "https" && scheme != "http" {
|
||||
return "", "", "", errors.New("config: invalid BASE_URL: scheme must be http or https")
|
||||
return fmt.Errorf("BASE_URL scheme must be http or https")
|
||||
}
|
||||
|
||||
basePath := parsedURL.Path
|
||||
cp.options.options["BASE_URL"].ParsedStringValue = baseURL
|
||||
cp.options.basePath = parsedURL.Path
|
||||
|
||||
parsedURL.Path = ""
|
||||
return value, parsedURL.String(), basePath, nil
|
||||
cp.options.rootURL = parsedURL.String()
|
||||
|
||||
// Parse YouTube embed domain based on YOUTUBE_EMBED_URL_OVERRIDE
|
||||
youTubeEmbedURLOverride := cp.options.options["YOUTUBE_EMBED_URL_OVERRIDE"].ParsedStringValue
|
||||
if youTubeEmbedURLOverride != "" {
|
||||
parsedYouTubeEmbedURL, err := url.Parse(youTubeEmbedURLOverride)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid YOUTUBE_EMBED_URL_OVERRIDE: %v", err)
|
||||
}
|
||||
cp.options.youTubeEmbedDomain = parsedYouTubeEmbedURL.Hostname()
|
||||
}
|
||||
|
||||
// Generate a media proxy private key if not set
|
||||
if len(cp.options.options["MEDIA_PROXY_PRIVATE_KEY"].ParsedBytesValue) == 0 {
|
||||
randomKey := make([]byte, 16)
|
||||
rand.Read(randomKey)
|
||||
cp.options.options["MEDIA_PROXY_PRIVATE_KEY"].ParsedBytesValue = randomKey
|
||||
}
|
||||
|
||||
// Override LISTEN_ADDR with PORT if set (for compatibility reasons)
|
||||
if cp.options.Port() != "" {
|
||||
cp.options.options["LISTEN_ADDR"].ParsedStringList = []string{":" + cp.options.Port()}
|
||||
cp.options.options["LISTEN_ADDR"].RawValue = ":" + cp.options.Port()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseBool(value string, fallback bool) bool {
|
||||
func (cp *configParser) parseLines(lines []string) error {
|
||||
for lineNum, line := range lines {
|
||||
key, value, ok := strings.Cut(line, "=")
|
||||
if !ok {
|
||||
return fmt.Errorf("unable to parse configuration, invalid format on line %d", lineNum)
|
||||
}
|
||||
|
||||
key, value = strings.TrimSpace(key), strings.TrimSpace(value)
|
||||
if err := cp.parseLine(key, value); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if err := cp.postParsing(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (cp *configParser) parseLine(key, value string) error {
|
||||
field, exists := cp.options.options[key]
|
||||
if !exists {
|
||||
// Ignore unknown configuration keys to avoid parsing unrelated environment variables.
|
||||
return nil
|
||||
}
|
||||
|
||||
// Validate the option if a validator is provided
|
||||
if field.Validator != nil {
|
||||
if err := field.Validator(value); err != nil {
|
||||
return fmt.Errorf("invalid value for key %s: %v", key, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Convert the raw value based on its type
|
||||
switch field.ValueType {
|
||||
case stringType:
|
||||
field.ParsedStringValue = parseStringValue(value, field.ParsedStringValue)
|
||||
field.RawValue = value
|
||||
case stringListType:
|
||||
field.ParsedStringList = parseStringListValue(value, field.ParsedStringList)
|
||||
field.RawValue = value
|
||||
case boolType:
|
||||
parsedValue, err := parseBoolValue(value, field.ParsedBoolValue)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid boolean value for key %s: %v", key, err)
|
||||
}
|
||||
field.ParsedBoolValue = parsedValue
|
||||
field.RawValue = value
|
||||
case intType:
|
||||
field.ParsedIntValue = parseIntValue(value, field.ParsedIntValue)
|
||||
field.RawValue = value
|
||||
case int64Type:
|
||||
field.ParsedInt64Value = ParsedInt64Value(value, field.ParsedInt64Value)
|
||||
field.RawValue = value
|
||||
case secondType:
|
||||
field.ParsedDuration = parseDurationValue(value, time.Second, field.ParsedDuration)
|
||||
field.RawValue = value
|
||||
case minuteType:
|
||||
field.ParsedDuration = parseDurationValue(value, time.Minute, field.ParsedDuration)
|
||||
field.RawValue = value
|
||||
case hourType:
|
||||
field.ParsedDuration = parseDurationValue(value, time.Hour, field.ParsedDuration)
|
||||
field.RawValue = value
|
||||
case dayType:
|
||||
field.ParsedDuration = parseDurationValue(value, time.Hour*24, field.ParsedDuration)
|
||||
field.RawValue = value
|
||||
case urlType:
|
||||
parsedURL, err := parseURLValue(value, field.ParsedURLValue)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid URL for key %s: %v", key, err)
|
||||
}
|
||||
field.ParsedURLValue = parsedURL
|
||||
field.RawValue = value
|
||||
case secretFileType:
|
||||
secretValue, err := readSecretFileValue(value)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error reading secret file for key %s: %v", key, err)
|
||||
}
|
||||
if field.TargetKey != "" {
|
||||
if targetField, ok := cp.options.options[field.TargetKey]; ok {
|
||||
targetField.ParsedStringValue = secretValue
|
||||
targetField.RawValue = secretValue
|
||||
}
|
||||
}
|
||||
field.RawValue = value
|
||||
case bytesType:
|
||||
if value != "" {
|
||||
field.ParsedBytesValue = []byte(value)
|
||||
field.RawValue = value
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseStringValue(value string, fallback string) string {
|
||||
if value == "" {
|
||||
return fallback
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func parseBoolValue(value string, fallback bool) (bool, error) {
|
||||
if value == "" {
|
||||
return fallback, nil
|
||||
}
|
||||
|
||||
value = strings.ToLower(value)
|
||||
if value == "1" || value == "yes" || value == "true" || value == "on" {
|
||||
return true
|
||||
return true, nil
|
||||
}
|
||||
if value == "0" || value == "no" || value == "false" || value == "off" {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
return false
|
||||
return false, fmt.Errorf("invalid boolean value: %q", value)
|
||||
}
|
||||
|
||||
func parseInt(value string, fallback int) int {
|
||||
func parseIntValue(value string, fallback int) int {
|
||||
if value == "" {
|
||||
return fallback
|
||||
}
|
||||
@@ -323,14 +227,20 @@ func parseInt(value string, fallback int) int {
|
||||
return v
|
||||
}
|
||||
|
||||
func parseString(value string, fallback string) string {
|
||||
func ParsedInt64Value(value string, fallback int64) int64 {
|
||||
if value == "" {
|
||||
return fallback
|
||||
}
|
||||
return value
|
||||
|
||||
v, err := strconv.ParseInt(value, 10, 64)
|
||||
if err != nil {
|
||||
return fallback
|
||||
}
|
||||
|
||||
return v
|
||||
}
|
||||
|
||||
func parseStringList(value string, fallback []string) []string {
|
||||
func parseStringListValue(value string, fallback []string) []string {
|
||||
if value == "" {
|
||||
return fallback
|
||||
}
|
||||
@@ -350,24 +260,53 @@ func parseStringList(value string, fallback []string) []string {
|
||||
return strList
|
||||
}
|
||||
|
||||
func parseBytes(value string, fallback []byte) []byte {
|
||||
func parseDurationValue(value string, unit time.Duration, fallback time.Duration) time.Duration {
|
||||
if value == "" {
|
||||
return fallback
|
||||
}
|
||||
|
||||
return []byte(value)
|
||||
}
|
||||
|
||||
func readSecretFile(filename, fallback string) string {
|
||||
data, err := os.ReadFile(filename)
|
||||
v, err := strconv.Atoi(value)
|
||||
if err != nil {
|
||||
return fallback
|
||||
}
|
||||
|
||||
value := string(bytes.TrimSpace(data))
|
||||
return time.Duration(v) * unit
|
||||
}
|
||||
|
||||
func parseURLValue(value string, fallback *url.URL) (*url.URL, error) {
|
||||
if value == "" {
|
||||
return fallback
|
||||
return fallback, nil
|
||||
}
|
||||
|
||||
return value
|
||||
parsedURL, err := url.Parse(value)
|
||||
if err != nil {
|
||||
return fallback, err
|
||||
}
|
||||
|
||||
return parsedURL, nil
|
||||
}
|
||||
|
||||
func readSecretFileValue(filename string) (string, error) {
|
||||
data, err := os.ReadFile(filename)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
value := string(bytes.TrimSpace(data))
|
||||
if value == "" {
|
||||
return "", fmt.Errorf("secret file is empty")
|
||||
}
|
||||
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func parseFileContent(r io.Reader) (lines []string) {
|
||||
scanner := bufio.NewScanner(r)
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if !strings.HasPrefix(line, "#") && strings.Index(line, "=") > 0 {
|
||||
lines = append(lines, line)
|
||||
}
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
+406
-137
@@ -4,163 +4,432 @@
|
||||
package config // import "miniflux.app/v2/internal/config"
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"os"
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestParseBoolValue(t *testing.T) {
|
||||
scenarios := map[string]bool{
|
||||
"": true,
|
||||
"1": true,
|
||||
"Yes": true,
|
||||
"yes": true,
|
||||
"True": true,
|
||||
"true": true,
|
||||
"on": true,
|
||||
"false": false,
|
||||
"off": false,
|
||||
"invalid": false,
|
||||
func TestParseStringValue(t *testing.T) {
|
||||
// Test with non-empty value
|
||||
result := parseStringValue("test", "fallback")
|
||||
if result != "test" {
|
||||
t.Errorf("Expected 'test', got '%s'", result)
|
||||
}
|
||||
|
||||
for input, expected := range scenarios {
|
||||
result := parseBool(input, true)
|
||||
if result != expected {
|
||||
t.Errorf(`Unexpected result for %q, got %v instead of %v`, input, result, expected)
|
||||
// Test with empty value
|
||||
result = parseStringValue("", "fallback")
|
||||
if result != "fallback" {
|
||||
t.Errorf("Expected 'fallback', got '%s'", result)
|
||||
}
|
||||
|
||||
// Test with empty value and empty fallback
|
||||
result = parseStringValue("", "")
|
||||
if result != "" {
|
||||
t.Errorf("Expected empty string, got '%s'", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseBoolValue(t *testing.T) {
|
||||
// Test with empty value - should return fallback
|
||||
result, err := parseBoolValue("", true)
|
||||
if err != nil {
|
||||
t.Errorf("Unexpected error: %v", err)
|
||||
}
|
||||
if result != true {
|
||||
t.Errorf("Expected true, got %v", result)
|
||||
}
|
||||
|
||||
// Test true values
|
||||
trueValues := []string{"1", "yes", "true", "on", "YES", "TRUE", "ON"}
|
||||
for _, value := range trueValues {
|
||||
result, err := parseBoolValue(value, false)
|
||||
if err != nil {
|
||||
t.Errorf("Unexpected error for value '%s': %v", value, err)
|
||||
}
|
||||
if result != true {
|
||||
t.Errorf("Expected true for '%s', got %v", value, result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseStringValueWithUnsetVariable(t *testing.T) {
|
||||
if parseString("", "defaultValue") != "defaultValue" {
|
||||
t.Errorf(`Unset variables should returns the default value`)
|
||||
// Test false values
|
||||
falseValues := []string{"0", "no", "false", "off", "NO", "FALSE", "OFF"}
|
||||
for _, value := range falseValues {
|
||||
result, err := parseBoolValue(value, true)
|
||||
if err != nil {
|
||||
t.Errorf("Unexpected error for value '%s': %v", value, err)
|
||||
}
|
||||
if result != false {
|
||||
t.Errorf("Expected false for '%s', got %v", value, result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseStringValue(t *testing.T) {
|
||||
if parseString("test", "defaultValue") != "test" {
|
||||
t.Errorf(`Defined variables should returns the specified value`)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseIntValueWithUnsetVariable(t *testing.T) {
|
||||
if parseInt("", 42) != 42 {
|
||||
t.Errorf(`Unset variables should returns the default value`)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseIntValueWithInvalidInput(t *testing.T) {
|
||||
if parseInt("invalid integer", 42) != 42 {
|
||||
t.Errorf(`Invalid integer should returns the default value`)
|
||||
// Test invalid value - should return error
|
||||
_, err = parseBoolValue("invalid", false)
|
||||
if err == nil {
|
||||
t.Error("Expected error for invalid boolean value")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseIntValue(t *testing.T) {
|
||||
if parseInt("2018", 42) != 2018 {
|
||||
t.Errorf(`Defined variables should returns the specified value`)
|
||||
// Test with empty value - should return fallback
|
||||
result := parseIntValue("", 42)
|
||||
if result != 42 {
|
||||
t.Errorf("Expected 42, got %d", result)
|
||||
}
|
||||
|
||||
// Test with valid integer
|
||||
result = parseIntValue("123", 42)
|
||||
if result != 123 {
|
||||
t.Errorf("Expected 123, got %d", result)
|
||||
}
|
||||
|
||||
// Test with invalid integer - should return fallback
|
||||
result = parseIntValue("invalid", 42)
|
||||
if result != 42 {
|
||||
t.Errorf("Expected 42, got %d", result)
|
||||
}
|
||||
|
||||
// Test with zero
|
||||
result = parseIntValue("0", 42)
|
||||
if result != 0 {
|
||||
t.Errorf("Expected 0, got %d", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseListenAddr(t *testing.T) {
|
||||
defaultExpected := []string{defaultListenAddr}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
listenAddr string
|
||||
port string
|
||||
expected []string
|
||||
lines []string // Used for direct lines parsing instead of individual env vars
|
||||
isLineOriented bool // Flag to indicate if we use lines
|
||||
}{
|
||||
{
|
||||
name: "Single LISTEN_ADDR",
|
||||
listenAddr: "127.0.0.1:8080",
|
||||
expected: []string{"127.0.0.1:8080"},
|
||||
},
|
||||
{
|
||||
name: "Multiple LISTEN_ADDR comma-separated",
|
||||
listenAddr: "127.0.0.1:8080,:8081,/tmp/miniflux.sock",
|
||||
expected: []string{"127.0.0.1:8080", ":8081", "/tmp/miniflux.sock"},
|
||||
},
|
||||
{
|
||||
name: "Multiple LISTEN_ADDR with spaces around commas",
|
||||
listenAddr: "127.0.0.1:8080 , :8081",
|
||||
expected: []string{"127.0.0.1:8080", ":8081"},
|
||||
},
|
||||
{
|
||||
name: "Empty LISTEN_ADDR",
|
||||
listenAddr: "",
|
||||
expected: defaultExpected,
|
||||
},
|
||||
{
|
||||
name: "PORT overrides LISTEN_ADDR",
|
||||
listenAddr: "127.0.0.1:8000",
|
||||
port: "8082",
|
||||
expected: []string{":8082"},
|
||||
},
|
||||
{
|
||||
name: "PORT overrides empty LISTEN_ADDR",
|
||||
listenAddr: "",
|
||||
port: "8083",
|
||||
expected: []string{":8083"},
|
||||
},
|
||||
{
|
||||
name: "LISTEN_ADDR with empty segment (comma)",
|
||||
listenAddr: "127.0.0.1:8080,,:8081",
|
||||
expected: []string{"127.0.0.1:8080", ":8081"},
|
||||
},
|
||||
{
|
||||
name: "PORT override with lines parsing",
|
||||
isLineOriented: true,
|
||||
lines: []string{"LISTEN_ADDR=127.0.0.1:8000", "PORT=8082"},
|
||||
expected: []string{":8082"},
|
||||
},
|
||||
{
|
||||
name: "LISTEN_ADDR only with lines parsing (comma)",
|
||||
isLineOriented: true,
|
||||
lines: []string{"LISTEN_ADDR=10.0.0.1:9090,10.0.0.2:9091"},
|
||||
expected: []string{"10.0.0.1:9090", "10.0.0.2:9091"},
|
||||
},
|
||||
{
|
||||
name: "Empty LISTEN_ADDR with lines parsing (default)",
|
||||
isLineOriented: true,
|
||||
lines: []string{"LISTEN_ADDR="},
|
||||
expected: defaultExpected,
|
||||
},
|
||||
func TestParsedInt64Value(t *testing.T) {
|
||||
// Test with empty value - should return fallback
|
||||
result := ParsedInt64Value("", 42)
|
||||
if result != 42 {
|
||||
t.Errorf("Expected 42, got %d", result)
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
parser := NewParser()
|
||||
var err error
|
||||
// Test with valid int64
|
||||
result = ParsedInt64Value("9223372036854775807", 42)
|
||||
if result != 9223372036854775807 {
|
||||
t.Errorf("Expected 9223372036854775807, got %d", result)
|
||||
}
|
||||
|
||||
if tt.isLineOriented {
|
||||
err = parser.parseLines(tt.lines)
|
||||
} else {
|
||||
// Simulate os.Environ() behaviour for individual var testing
|
||||
var envLines []string
|
||||
if tt.listenAddr != "" {
|
||||
envLines = append(envLines, "LISTEN_ADDR="+tt.listenAddr)
|
||||
}
|
||||
if tt.port != "" {
|
||||
envLines = append(envLines, "PORT="+tt.port)
|
||||
}
|
||||
// Add a dummy var if both are empty to avoid empty lines slice if not intended
|
||||
if tt.listenAddr == "" && tt.port == "" && tt.name == "Empty LISTEN_ADDR" {
|
||||
// This case specifically tests empty LISTEN_ADDR resulting in default
|
||||
// So, we pass LISTEN_ADDR=
|
||||
envLines = append(envLines, "LISTEN_ADDR=")
|
||||
}
|
||||
err = parser.parseLines(envLines)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("parseLines() error = %v", err)
|
||||
}
|
||||
|
||||
opts := parser.opts
|
||||
if !reflect.DeepEqual(opts.ListenAddr(), tt.expected) {
|
||||
t.Errorf("ListenAddr() got = %v, want %v", opts.ListenAddr(), tt.expected)
|
||||
}
|
||||
})
|
||||
// Test with invalid int64 - should return fallback
|
||||
result = ParsedInt64Value("invalid", 42)
|
||||
if result != 42 {
|
||||
t.Errorf("Expected 42, got %d", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseStringListValue(t *testing.T) {
|
||||
// Test with empty value - should return fallback
|
||||
fallback := []string{"a", "b"}
|
||||
result := parseStringListValue("", fallback)
|
||||
if !reflect.DeepEqual(result, fallback) {
|
||||
t.Errorf("Expected %v, got %v", fallback, result)
|
||||
}
|
||||
|
||||
// Test with single value
|
||||
result = parseStringListValue("item1", nil)
|
||||
expected := []string{"item1"}
|
||||
if !reflect.DeepEqual(result, expected) {
|
||||
t.Errorf("Expected %v, got %v", expected, result)
|
||||
}
|
||||
|
||||
// Test with multiple values
|
||||
result = parseStringListValue("item1,item2,item3", nil)
|
||||
expected = []string{"item1", "item2", "item3"}
|
||||
if !reflect.DeepEqual(result, expected) {
|
||||
t.Errorf("Expected %v, got %v", expected, result)
|
||||
}
|
||||
|
||||
// Test with duplicates - should remove duplicates
|
||||
result = parseStringListValue("item1,item2,item1", nil)
|
||||
expected = []string{"item1", "item2"}
|
||||
if !reflect.DeepEqual(result, expected) {
|
||||
t.Errorf("Expected %v, got %v", expected, result)
|
||||
}
|
||||
|
||||
// Test with spaces
|
||||
result = parseStringListValue(" item1 , item2 , item3 ", nil)
|
||||
expected = []string{"item1", "item2", "item3"}
|
||||
if !reflect.DeepEqual(result, expected) {
|
||||
t.Errorf("Expected %v, got %v", expected, result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDurationValue(t *testing.T) {
|
||||
// Test with empty value - should return fallback
|
||||
fallback := 5 * time.Second
|
||||
result := parseDurationValue("", time.Second, fallback)
|
||||
if result != fallback {
|
||||
t.Errorf("Expected %v, got %v", fallback, result)
|
||||
}
|
||||
|
||||
// Test with valid duration
|
||||
result = parseDurationValue("30", time.Second, fallback)
|
||||
expected := 30 * time.Second
|
||||
if result != expected {
|
||||
t.Errorf("Expected %v, got %v", expected, result)
|
||||
}
|
||||
|
||||
// Test with minutes
|
||||
result = parseDurationValue("5", time.Minute, fallback)
|
||||
expected = 5 * time.Minute
|
||||
if result != expected {
|
||||
t.Errorf("Expected %v, got %v", expected, result)
|
||||
}
|
||||
|
||||
// Test with invalid value - should return fallback
|
||||
result = parseDurationValue("invalid", time.Second, fallback)
|
||||
if result != fallback {
|
||||
t.Errorf("Expected %v, got %v", fallback, result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseURLValue(t *testing.T) {
|
||||
// Test with empty value - should return fallback
|
||||
fallbackURL, _ := url.Parse("https://fallback.com")
|
||||
result, err := parseURLValue("", fallbackURL)
|
||||
if err != nil {
|
||||
t.Errorf("Unexpected error: %v", err)
|
||||
}
|
||||
if result != fallbackURL {
|
||||
t.Errorf("Expected %v, got %v", fallbackURL, result)
|
||||
}
|
||||
|
||||
// Test with valid URL
|
||||
result, err = parseURLValue("https://example.com", nil)
|
||||
if err != nil {
|
||||
t.Errorf("Unexpected error: %v", err)
|
||||
}
|
||||
if result.String() != "https://example.com" {
|
||||
t.Errorf("Expected https://example.com, got %s", result.String())
|
||||
}
|
||||
|
||||
// Test with invalid URL - should return fallback and error
|
||||
result, err = parseURLValue("://invalid", fallbackURL)
|
||||
if err == nil {
|
||||
t.Error("Expected error for invalid URL")
|
||||
}
|
||||
if result != fallbackURL {
|
||||
t.Errorf("Expected fallback URL, got %v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigFileParsing(t *testing.T) {
|
||||
fileContent := `
|
||||
# This is a comment
|
||||
LOG_FILE=miniflux.log
|
||||
LOG_DATE_TIME=1
|
||||
LOG_FORMAT=json
|
||||
LISTEN_ADDR=:8080,:8443
|
||||
`
|
||||
|
||||
// Write a temporary config file and parse it
|
||||
tmpFile, err := os.CreateTemp("", "miniflux-*.txt")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temporary file: %v", err)
|
||||
}
|
||||
|
||||
defer os.Remove(tmpFile.Name())
|
||||
defer tmpFile.Close()
|
||||
|
||||
filename := tmpFile.Name()
|
||||
if _, err := tmpFile.WriteString(fileContent); err != nil {
|
||||
t.Fatalf("Failed to write to temporary file: %v", err)
|
||||
}
|
||||
|
||||
configParser := NewConfigParser()
|
||||
configOptions, err := configParser.ParseFile(filename)
|
||||
if err != nil {
|
||||
t.Fatalf("Unexpected parsing error: %v", err)
|
||||
}
|
||||
|
||||
if configOptions.LogFile() != "miniflux.log" {
|
||||
t.Fatalf("Unexpected log file, got %q", configOptions.LogFile())
|
||||
}
|
||||
|
||||
if configOptions.LogDateTime() != true {
|
||||
t.Fatalf("Unexpected log datetime, got %v", configOptions.LogDateTime())
|
||||
}
|
||||
|
||||
if configOptions.LogFormat() != "json" {
|
||||
t.Fatalf("Unexpected log format, got %q", configOptions.LogFormat())
|
||||
}
|
||||
|
||||
if configOptions.LogLevel() != "info" {
|
||||
t.Fatalf("Unexpected log level, got %q", configOptions.LogLevel())
|
||||
}
|
||||
|
||||
if len(configOptions.ListenAddr()) != 2 || configOptions.ListenAddr()[0] != ":8080" || configOptions.ListenAddr()[1] != ":8443" {
|
||||
t.Fatalf("Unexpected listen addresses, got %v", configOptions.ListenAddr())
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigFileParsingWithIncorrectKeyValuePair(t *testing.T) {
|
||||
fileContent := `
|
||||
LOG_FILE=miniflux.log
|
||||
INVALID_LINE
|
||||
`
|
||||
|
||||
// Write a temporary config file and parse it
|
||||
tmpFile, err := os.CreateTemp("", "miniflux-*.txt")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temporary file: %v", err)
|
||||
}
|
||||
|
||||
defer os.Remove(tmpFile.Name())
|
||||
defer tmpFile.Close()
|
||||
|
||||
filename := tmpFile.Name()
|
||||
if _, err := tmpFile.WriteString(fileContent); err != nil {
|
||||
t.Fatalf("Failed to write to temporary file: %v", err)
|
||||
}
|
||||
|
||||
configParser := NewConfigParser()
|
||||
_, err = configParser.ParseFile(filename)
|
||||
if err != nil {
|
||||
t.Fatal("Invalid lines should be ignored, but got error:", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAdminPasswordFileOption(t *testing.T) {
|
||||
tmpFile, err := os.CreateTemp("", "password-*.txt")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temporary file: %v", err)
|
||||
}
|
||||
defer os.Remove(tmpFile.Name())
|
||||
defer tmpFile.Close()
|
||||
|
||||
password := "supersecret"
|
||||
if _, err := tmpFile.WriteString(password); err != nil {
|
||||
t.Fatalf("Failed to write to temporary file: %v", err)
|
||||
}
|
||||
|
||||
os.Clearenv()
|
||||
os.Setenv("ADMIN_PASSWORD_FILE", tmpFile.Name())
|
||||
|
||||
configParser := NewConfigParser()
|
||||
configOptions, err := configParser.ParseEnvironmentVariables()
|
||||
if err != nil {
|
||||
t.Fatalf("Unexpected parsing error: %v", err)
|
||||
}
|
||||
|
||||
if configOptions.AdminPassword() != password {
|
||||
t.Fatalf("Unexpected admin password, got %q", configOptions.AdminPassword())
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAdminPasswordFileOptionWithEmptyFile(t *testing.T) {
|
||||
tmpFile, err := os.CreateTemp("", "empty-password-*.txt")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temporary file: %v", err)
|
||||
}
|
||||
defer os.Remove(tmpFile.Name())
|
||||
defer tmpFile.Close()
|
||||
|
||||
os.Clearenv()
|
||||
os.Setenv("ADMIN_PASSWORD_FILE", tmpFile.Name())
|
||||
|
||||
configParser := NewConfigParser()
|
||||
_, err = configParser.ParseEnvironmentVariables()
|
||||
if err == nil {
|
||||
t.Fatal("Expected error due to empty password file, but got none")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseLogFileOptionDefaultValue(t *testing.T) {
|
||||
os.Clearenv()
|
||||
|
||||
configParser := NewConfigParser()
|
||||
configOptions, err := configParser.ParseEnvironmentVariables()
|
||||
if err != nil {
|
||||
t.Fatalf("Unexpected parsing error: %v", err)
|
||||
}
|
||||
|
||||
if configOptions.LogFile() != "stderr" {
|
||||
t.Fatalf("Unexpected default log file, got %q", configOptions.LogFile())
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseLogFileOptionWithCustomFilename(t *testing.T) {
|
||||
os.Clearenv()
|
||||
os.Setenv("LOG_FILE", "miniflux.log")
|
||||
|
||||
configParser := NewConfigParser()
|
||||
configOptions, err := configParser.ParseEnvironmentVariables()
|
||||
if err != nil {
|
||||
t.Fatalf("Unexpected parsing error: %v", err)
|
||||
}
|
||||
|
||||
if configOptions.LogFile() != "miniflux.log" {
|
||||
t.Fatalf("Unexpected log file, got %q", configOptions.LogFile())
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseLogFileOptionWithEmptyValue(t *testing.T) {
|
||||
os.Clearenv()
|
||||
os.Setenv("LOG_FILE", "")
|
||||
|
||||
configParser := NewConfigParser()
|
||||
configOptions, err := configParser.ParseEnvironmentVariables()
|
||||
if err != nil {
|
||||
t.Fatalf("Unexpected parsing error: %v", err)
|
||||
}
|
||||
|
||||
if configOptions.LogFile() != "stderr" {
|
||||
t.Fatalf("Unexpected log file, got %q", configOptions.LogFile())
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseLogDateTimeOptionDefaultValue(t *testing.T) {
|
||||
os.Clearenv()
|
||||
|
||||
configParser := NewConfigParser()
|
||||
configOptions, err := configParser.ParseEnvironmentVariables()
|
||||
if err != nil {
|
||||
t.Fatalf("Unexpected parsing error: %v", err)
|
||||
}
|
||||
|
||||
if configOptions.LogDateTime() != false {
|
||||
t.Fatalf("Unexpected default log datetime, got %v", configOptions.LogDateTime())
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseLogDateTimeOptionWithCustomValue(t *testing.T) {
|
||||
os.Clearenv()
|
||||
os.Setenv("LOG_DATE_TIME", "true")
|
||||
|
||||
configParser := NewConfigParser()
|
||||
configOptions, err := configParser.ParseEnvironmentVariables()
|
||||
if err != nil {
|
||||
t.Fatalf("Unexpected parsing error: %v", err)
|
||||
}
|
||||
|
||||
if configOptions.LogDateTime() != true {
|
||||
t.Fatalf("Unexpected log datetime, got %v", configOptions.LogDateTime())
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseLogDateTimeOptionWithEmptyValue(t *testing.T) {
|
||||
os.Clearenv()
|
||||
os.Setenv("LOG_DATE_TIME", "")
|
||||
|
||||
configParser := NewConfigParser()
|
||||
configOptions, err := configParser.ParseEnvironmentVariables()
|
||||
if err != nil {
|
||||
t.Fatalf("Unexpected parsing error: %v", err)
|
||||
}
|
||||
|
||||
if configOptions.LogDateTime() != false {
|
||||
t.Fatalf("Unexpected log datetime, got %v", configOptions.LogDateTime())
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseLogDateTimeOptionWithIncorrectValue(t *testing.T) {
|
||||
os.Clearenv()
|
||||
os.Setenv("LOG_DATE_TIME", "invalid")
|
||||
|
||||
configParser := NewConfigParser()
|
||||
if _, err := configParser.ParseEnvironmentVariables(); err == nil {
|
||||
t.Fatal("Expected parsing error, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package config // import "miniflux.app/v2/internal/config"
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func validateChoices(rawValue string, choices []string) error {
|
||||
if !slices.Contains(choices, rawValue) {
|
||||
return fmt.Errorf("value must be one of: %v", strings.Join(choices, ", "))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateListChoices(inputValues, choices []string) error {
|
||||
for _, value := range inputValues {
|
||||
if !slices.Contains(choices, value) {
|
||||
return fmt.Errorf("value must be one of: %v", strings.Join(choices, ", "))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateGreaterThan(rawValue string, min int) error {
|
||||
intValue, err := strconv.Atoi(rawValue)
|
||||
if err != nil {
|
||||
return fmt.Errorf("value must be an integer")
|
||||
}
|
||||
if intValue > min {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("value must be at least %d", min)
|
||||
}
|
||||
|
||||
func validateGreaterOrEqualThan(rawValue string, min int) error {
|
||||
intValue, err := strconv.Atoi(rawValue)
|
||||
if err != nil {
|
||||
return fmt.Errorf("value must be an integer")
|
||||
}
|
||||
if intValue >= min {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("value must be greater or equal than %d", min)
|
||||
}
|
||||
|
||||
func validateRange(rawValue string, min, max int) error {
|
||||
intValue, err := strconv.Atoi(rawValue)
|
||||
if err != nil {
|
||||
return fmt.Errorf("value must be an integer")
|
||||
}
|
||||
if intValue < min || intValue > max {
|
||||
return fmt.Errorf("value must be between %d and %d", min, max)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,372 @@
|
||||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package config // import "miniflux.app/v2/internal/config"
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestValidateChoices(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
rawValue string
|
||||
choices []string
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
name: "valid choice",
|
||||
rawValue: "option1",
|
||||
choices: []string{"option1", "option2", "option3"},
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "valid choice from middle",
|
||||
rawValue: "option2",
|
||||
choices: []string{"option1", "option2", "option3"},
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "valid choice from end",
|
||||
rawValue: "option3",
|
||||
choices: []string{"option1", "option2", "option3"},
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "invalid choice",
|
||||
rawValue: "invalid",
|
||||
choices: []string{"option1", "option2", "option3"},
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "empty value with non-empty choices",
|
||||
rawValue: "",
|
||||
choices: []string{"option1", "option2"},
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "case sensitive - different case",
|
||||
rawValue: "OPTION1",
|
||||
choices: []string{"option1", "option2"},
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "single choice valid",
|
||||
rawValue: "only",
|
||||
choices: []string{"only"},
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "empty choices list",
|
||||
rawValue: "anything",
|
||||
choices: []string{},
|
||||
expectError: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := validateChoices(tt.rawValue, tt.choices)
|
||||
if tt.expectError {
|
||||
if err == nil {
|
||||
t.Errorf("expected error but got none")
|
||||
} else {
|
||||
// Verify error message format
|
||||
expectedPrefix := "value must be one of:"
|
||||
if !strings.Contains(err.Error(), expectedPrefix) {
|
||||
t.Errorf("error message should contain '%s', got: %s", expectedPrefix, err.Error())
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if err != nil {
|
||||
t.Errorf("expected no error but got: %v", err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateListChoices(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
inputValues []string
|
||||
choices []string
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
name: "all valid choices",
|
||||
inputValues: []string{"option1", "option2"},
|
||||
choices: []string{"option1", "option2", "option3"},
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "single valid choice",
|
||||
inputValues: []string{"option1"},
|
||||
choices: []string{"option1", "option2", "option3"},
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "empty input list",
|
||||
inputValues: []string{},
|
||||
choices: []string{"option1", "option2", "option3"},
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "all choices from available list",
|
||||
inputValues: []string{"option1", "option2", "option3"},
|
||||
choices: []string{"option1", "option2", "option3"},
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "duplicate valid choices",
|
||||
inputValues: []string{"option1", "option1", "option2"},
|
||||
choices: []string{"option1", "option2", "option3"},
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "one invalid choice",
|
||||
inputValues: []string{"option1", "invalid"},
|
||||
choices: []string{"option1", "option2", "option3"},
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "all invalid choices",
|
||||
inputValues: []string{"invalid1", "invalid2"},
|
||||
choices: []string{"option1", "option2", "option3"},
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "case sensitive - different case",
|
||||
inputValues: []string{"OPTION1"},
|
||||
choices: []string{"option1", "option2"},
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "empty string in input",
|
||||
inputValues: []string{""},
|
||||
choices: []string{"option1", "option2"},
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "empty choices list with non-empty input",
|
||||
inputValues: []string{"anything"},
|
||||
choices: []string{},
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "mixed valid and invalid choices",
|
||||
inputValues: []string{"option1", "invalid", "option2"},
|
||||
choices: []string{"option1", "option2", "option3"},
|
||||
expectError: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := validateListChoices(tt.inputValues, tt.choices)
|
||||
if tt.expectError {
|
||||
if err == nil {
|
||||
t.Errorf("expected error but got none")
|
||||
} else {
|
||||
// Verify error message format
|
||||
expectedPrefix := "value must be one of:"
|
||||
if !strings.Contains(err.Error(), expectedPrefix) {
|
||||
t.Errorf("error message should contain '%s', got: %s", expectedPrefix, err.Error())
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if err != nil {
|
||||
t.Errorf("expected no error but got: %v", err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateGreaterThan(t *testing.T) {
|
||||
if err := validateGreaterThan("10", 5); err != nil {
|
||||
t.Errorf("expected no error, got: %v", err)
|
||||
}
|
||||
|
||||
if err := validateGreaterThan("5", 5); err == nil {
|
||||
t.Errorf("expected error, got none")
|
||||
}
|
||||
|
||||
if err := validateGreaterThan("abc", 5); err == nil {
|
||||
t.Errorf("expected error for non-integer input, got none")
|
||||
}
|
||||
|
||||
if err := validateGreaterThan("-1", 0); err == nil {
|
||||
t.Errorf("expected error for value below minimum, got none")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateGreaterOrEqualThan(t *testing.T) {
|
||||
if err := validateGreaterOrEqualThan("10", 5); err != nil {
|
||||
t.Errorf("expected no error, got: %v", err)
|
||||
}
|
||||
|
||||
if err := validateGreaterOrEqualThan("5", 5); err != nil {
|
||||
t.Errorf("expected no error for equal value, got: %v", err)
|
||||
}
|
||||
|
||||
if err := validateGreaterOrEqualThan("abc", 5); err == nil {
|
||||
t.Errorf("expected error for non-integer input, got none")
|
||||
}
|
||||
|
||||
if err := validateGreaterOrEqualThan("-1", 0); err == nil {
|
||||
t.Errorf("expected error for value below minimum, got none")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRange(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
rawValue string
|
||||
min int
|
||||
max int
|
||||
expectError bool
|
||||
errorMsg string
|
||||
}{
|
||||
{
|
||||
name: "valid integer within range",
|
||||
rawValue: "5",
|
||||
min: 1,
|
||||
max: 10,
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "valid integer at minimum",
|
||||
rawValue: "1",
|
||||
min: 1,
|
||||
max: 10,
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "valid integer at maximum",
|
||||
rawValue: "10",
|
||||
min: 1,
|
||||
max: 10,
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "valid zero in range",
|
||||
rawValue: "0",
|
||||
min: -5,
|
||||
max: 5,
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "valid negative in range",
|
||||
rawValue: "-3",
|
||||
min: -5,
|
||||
max: 5,
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "integer below minimum",
|
||||
rawValue: "0",
|
||||
min: 1,
|
||||
max: 10,
|
||||
expectError: true,
|
||||
errorMsg: "value must be between 1 and 10",
|
||||
},
|
||||
{
|
||||
name: "integer above maximum",
|
||||
rawValue: "11",
|
||||
min: 1,
|
||||
max: 10,
|
||||
expectError: true,
|
||||
errorMsg: "value must be between 1 and 10",
|
||||
},
|
||||
{
|
||||
name: "integer far below minimum",
|
||||
rawValue: "-100",
|
||||
min: 1,
|
||||
max: 10,
|
||||
expectError: true,
|
||||
errorMsg: "value must be between 1 and 10",
|
||||
},
|
||||
{
|
||||
name: "integer far above maximum",
|
||||
rawValue: "100",
|
||||
min: 1,
|
||||
max: 10,
|
||||
expectError: true,
|
||||
errorMsg: "value must be between 1 and 10",
|
||||
},
|
||||
{
|
||||
name: "non-integer string",
|
||||
rawValue: "abc",
|
||||
min: 1,
|
||||
max: 10,
|
||||
expectError: true,
|
||||
errorMsg: "value must be an integer",
|
||||
},
|
||||
{
|
||||
name: "empty string",
|
||||
rawValue: "",
|
||||
min: 1,
|
||||
max: 10,
|
||||
expectError: true,
|
||||
errorMsg: "value must be an integer",
|
||||
},
|
||||
{
|
||||
name: "float string",
|
||||
rawValue: "5.5",
|
||||
min: 1,
|
||||
max: 10,
|
||||
expectError: true,
|
||||
errorMsg: "value must be an integer",
|
||||
},
|
||||
{
|
||||
name: "string with spaces",
|
||||
rawValue: " 5 ",
|
||||
min: 1,
|
||||
max: 10,
|
||||
expectError: true,
|
||||
errorMsg: "value must be an integer",
|
||||
},
|
||||
{
|
||||
name: "single value range",
|
||||
rawValue: "5",
|
||||
min: 5,
|
||||
max: 5,
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "single value range - below",
|
||||
rawValue: "4",
|
||||
min: 5,
|
||||
max: 5,
|
||||
expectError: true,
|
||||
errorMsg: "value must be between 5 and 5",
|
||||
},
|
||||
{
|
||||
name: "single value range - above",
|
||||
rawValue: "6",
|
||||
min: 5,
|
||||
max: 5,
|
||||
expectError: true,
|
||||
errorMsg: "value must be between 5 and 5",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := validateRange(tt.rawValue, tt.min, tt.max)
|
||||
if tt.expectError {
|
||||
if err == nil {
|
||||
t.Errorf("expected error but got none")
|
||||
} else if tt.errorMsg != "" && err.Error() != tt.errorMsg {
|
||||
t.Errorf("expected error message '%s', got '%s'", tt.errorMsg, err.Error())
|
||||
}
|
||||
} else {
|
||||
if err != nil {
|
||||
t.Errorf("expected no error but got: %v", err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
var schemaVersion = len(migrations)
|
||||
|
||||
// Order is important. Add new migrations at the end of the list.
|
||||
var migrations = []func(tx *sql.Tx) error{
|
||||
var migrations = [...]func(tx *sql.Tx) error{
|
||||
func(tx *sql.Tx) (err error) {
|
||||
sql := `
|
||||
CREATE TABLE schema_version (
|
||||
@@ -1135,4 +1135,210 @@ var migrations = []func(tx *sql.Tx) error{
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
sql := `
|
||||
CREATE TYPE linktaco_link_visibility AS ENUM (
|
||||
'PUBLIC',
|
||||
'PRIVATE'
|
||||
);
|
||||
ALTER TABLE integrations
|
||||
ADD COLUMN linktaco_enabled bool default 'f',
|
||||
ADD COLUMN linktaco_api_token text default '',
|
||||
ADD COLUMN linktaco_org_slug text default '',
|
||||
ADD COLUMN linktaco_tags text default '',
|
||||
ADD COLUMN linktaco_visibility linktaco_link_visibility default 'PUBLIC';
|
||||
`
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
sql := `
|
||||
ALTER TABLE integrations ADD COLUMN wallabag_tags text default '';
|
||||
`
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
// This migration replaces deprecated timezones by their equivalent on Debian Trixie.
|
||||
func(tx *sql.Tx) (err error) {
|
||||
var deprecatedTimeZoneMap = map[string]string{
|
||||
// Africa
|
||||
"Africa/Asmera": "Africa/Asmara",
|
||||
|
||||
// America - Argentina
|
||||
"America/Argentina/ComodRivadavia": "America/Argentina/Catamarca",
|
||||
"America/Buenos_Aires": "America/Argentina/Buenos_Aires",
|
||||
"America/Catamarca": "America/Argentina/Catamarca",
|
||||
"America/Cordoba": "America/Argentina/Cordoba",
|
||||
"America/Jujuy": "America/Argentina/Jujuy",
|
||||
"America/Mendoza": "America/Argentina/Mendoza",
|
||||
"America/Rosario": "America/Argentina/Cordoba",
|
||||
|
||||
// America - US
|
||||
"America/Fort_Wayne": "America/Indiana/Indianapolis",
|
||||
"America/Indianapolis": "America/Indiana/Indianapolis",
|
||||
"America/Knox_IN": "America/Indiana/Knox",
|
||||
"America/Louisville": "America/Kentucky/Louisville",
|
||||
|
||||
// America - Greenland
|
||||
"America/Godthab": "America/Nuuk",
|
||||
|
||||
// Antarctica
|
||||
"Antarctica/South_Pole": "Pacific/Auckland",
|
||||
|
||||
// Asia
|
||||
"Asia/Ashkhabad": "Asia/Ashgabat",
|
||||
"Asia/Calcutta": "Asia/Kolkata",
|
||||
"Asia/Choibalsan": "Asia/Ulaanbaatar",
|
||||
"Asia/Chungking": "Asia/Chongqing",
|
||||
"Asia/Dacca": "Asia/Dhaka",
|
||||
"Asia/Katmandu": "Asia/Kathmandu",
|
||||
"Asia/Macao": "Asia/Macau",
|
||||
"Asia/Rangoon": "Asia/Yangon",
|
||||
"Asia/Saigon": "Asia/Ho_Chi_Minh",
|
||||
"Asia/Thimbu": "Asia/Thimphu",
|
||||
"Asia/Ujung_Pandang": "Asia/Makassar",
|
||||
"Asia/Ulan_Bator": "Asia/Ulaanbaatar",
|
||||
|
||||
// Atlantic
|
||||
"Atlantic/Faeroe": "Atlantic/Faroe",
|
||||
|
||||
// Australia
|
||||
"Australia/ACT": "Australia/Sydney",
|
||||
"Australia/LHI": "Australia/Lord_Howe",
|
||||
"Australia/North": "Australia/Darwin",
|
||||
"Australia/NSW": "Australia/Sydney",
|
||||
"Australia/Queensland": "Australia/Brisbane",
|
||||
"Australia/South": "Australia/Adelaide",
|
||||
"Australia/Tasmania": "Australia/Hobart",
|
||||
"Australia/Victoria": "Australia/Melbourne",
|
||||
"Australia/West": "Australia/Perth",
|
||||
|
||||
// Brazil
|
||||
"Brazil/Acre": "America/Rio_Branco",
|
||||
"Brazil/DeNoronha": "America/Noronha",
|
||||
"Brazil/East": "America/Sao_Paulo",
|
||||
"Brazil/West": "America/Manaus",
|
||||
|
||||
// Canada
|
||||
"Canada/Atlantic": "America/Halifax",
|
||||
"Canada/Central": "America/Winnipeg",
|
||||
"Canada/Eastern": "America/Toronto",
|
||||
"Canada/Mountain": "America/Edmonton",
|
||||
"Canada/Newfoundland": "America/St_Johns",
|
||||
"Canada/Pacific": "America/Vancouver",
|
||||
"Canada/Saskatchewan": "America/Regina",
|
||||
"Canada/Yukon": "America/Whitehorse",
|
||||
|
||||
// Europe
|
||||
"CET": "Europe/Paris",
|
||||
"EET": "Europe/Sofia",
|
||||
"Europe/Kiev": "Europe/Kyiv",
|
||||
"Europe/Uzhgorod": "Europe/Kyiv",
|
||||
"Europe/Zaporozhye": "Europe/Kyiv",
|
||||
"MET": "Europe/Paris",
|
||||
"WET": "Europe/Lisbon",
|
||||
|
||||
// Chile
|
||||
"Chile/Continental": "America/Santiago",
|
||||
"Chile/EasterIsland": "Pacific/Easter",
|
||||
|
||||
// Fixed offset and generic zones
|
||||
"CST6CDT": "America/Chicago",
|
||||
"EST": "America/New_York",
|
||||
"EST5EDT": "America/New_York",
|
||||
"HST": "Pacific/Honolulu",
|
||||
"MST": "America/Denver",
|
||||
"MST7MDT": "America/Denver",
|
||||
"PST8PDT": "America/Los_Angeles",
|
||||
|
||||
// Countries/Regions
|
||||
"Cuba": "America/Havana",
|
||||
"Egypt": "Africa/Cairo",
|
||||
"Eire": "Europe/Dublin",
|
||||
"GB": "Europe/London",
|
||||
"GB-Eire": "Europe/London",
|
||||
"Hongkong": "Asia/Hong_Kong",
|
||||
"Iceland": "Atlantic/Reykjavik",
|
||||
"Iran": "Asia/Tehran",
|
||||
"Israel": "Asia/Jerusalem",
|
||||
"Jamaica": "America/Jamaica",
|
||||
"Japan": "Asia/Tokyo",
|
||||
"Libya": "Africa/Tripoli",
|
||||
"Poland": "Europe/Warsaw",
|
||||
"Portugal": "Europe/Lisbon",
|
||||
"PRC": "Asia/Shanghai",
|
||||
"ROC": "Asia/Taipei",
|
||||
"ROK": "Asia/Seoul",
|
||||
"Singapore": "Asia/Singapore",
|
||||
"Turkey": "Europe/Istanbul",
|
||||
|
||||
// GMT variations
|
||||
"GMT+0": "GMT",
|
||||
"GMT-0": "GMT",
|
||||
"GMT0": "GMT",
|
||||
"Greenwich": "GMT",
|
||||
"UCT": "UTC",
|
||||
"Universal": "UTC",
|
||||
"Zulu": "UTC",
|
||||
|
||||
// Mexico
|
||||
"Mexico/BajaNorte": "America/Tijuana",
|
||||
"Mexico/BajaSur": "America/Mazatlan",
|
||||
"Mexico/General": "America/Mexico_City",
|
||||
|
||||
// US zones
|
||||
"Navajo": "America/Denver",
|
||||
"US/Alaska": "America/Anchorage",
|
||||
"US/Aleutian": "America/Adak",
|
||||
"US/Arizona": "America/Phoenix",
|
||||
"US/Central": "America/Chicago",
|
||||
"US/Eastern": "America/New_York",
|
||||
"US/East-Indiana": "America/Indiana/Indianapolis",
|
||||
"US/Hawaii": "Pacific/Honolulu",
|
||||
"US/Indiana-Starke": "America/Indiana/Knox",
|
||||
"US/Michigan": "America/Detroit",
|
||||
"US/Mountain": "America/Denver",
|
||||
"US/Pacific": "America/Los_Angeles",
|
||||
"US/Samoa": "Pacific/Pago_Pago",
|
||||
|
||||
// Pacific
|
||||
"Kwajalein": "Pacific/Kwajalein",
|
||||
"NZ": "Pacific/Auckland",
|
||||
"NZ-CHAT": "Pacific/Chatham",
|
||||
"Pacific/Enderbury": "Pacific/Kanton",
|
||||
"Pacific/Ponape": "Pacific/Pohnpei",
|
||||
"Pacific/Truk": "Pacific/Chuuk",
|
||||
|
||||
// Special cases
|
||||
"Factory": "UTC", // Factory is used for unconfigured systems
|
||||
"W-SU": "Europe/Moscow",
|
||||
}
|
||||
|
||||
// Loop through each user and correct the timezone
|
||||
rows, err := tx.Query(`SELECT id, timezone FROM users`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
userTimezoneMap := make(map[int64]string)
|
||||
for rows.Next() {
|
||||
var userID int64
|
||||
var userTimezone string
|
||||
if err := rows.Scan(&userID, &userTimezone); err != nil {
|
||||
return err
|
||||
}
|
||||
userTimezoneMap[userID] = userTimezone
|
||||
}
|
||||
rows.Close()
|
||||
|
||||
for userID, userTimezone := range userTimezoneMap {
|
||||
if newTimezone, found := deprecatedTimeZoneMap[userTimezone]; found {
|
||||
if _, err := tx.Exec(`UPDATE users SET timezone = $1 WHERE id = $2`, newTimezone, userID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
@@ -145,7 +145,7 @@ func (h *handler) handleFeeds(w http.ResponseWriter, r *http.Request) {
|
||||
var result feedsResponse
|
||||
result.Feeds = make([]feed, 0)
|
||||
for _, f := range feeds {
|
||||
subscripion := feed{
|
||||
subscription := feed{
|
||||
ID: f.ID,
|
||||
Title: f.Title,
|
||||
URL: f.FeedURL,
|
||||
@@ -155,10 +155,10 @@ func (h *handler) handleFeeds(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
if f.Icon != nil {
|
||||
subscripion.FaviconID = f.Icon.IconID
|
||||
subscription.FaviconID = f.Icon.IconID
|
||||
}
|
||||
|
||||
result.Feeds = append(result.Feeds, subscripion)
|
||||
result.Feeds = append(result.Feeds, subscription)
|
||||
}
|
||||
|
||||
result.FeedsGroups = h.buildFeedGroups(feeds)
|
||||
@@ -455,7 +455,7 @@ func (h *handler) handleWriteItems(w http.ResponseWriter, r *http.Request) {
|
||||
slog.Int64("user_id", userID),
|
||||
slog.Int64("entry_id", entryID),
|
||||
)
|
||||
if err := h.store.ToggleBookmark(userID, entryID); err != nil {
|
||||
if err := h.store.ToggleStarred(userID, entryID); err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
@@ -474,7 +474,7 @@ func (h *handler) handleWriteItems(w http.ResponseWriter, r *http.Request) {
|
||||
slog.Int64("user_id", userID),
|
||||
slog.Int64("entry_id", entryID),
|
||||
)
|
||||
if err := h.store.ToggleBookmark(userID, entryID); err != nil {
|
||||
if err := h.store.ToggleStarred(userID, entryID); err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -357,7 +357,7 @@ func (h *handler) editTagHandler(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
if len(unstarredEntryIDs) > 0 {
|
||||
err = h.store.SetEntriesBookmarkedState(userID, unstarredEntryIDs, false)
|
||||
err = h.store.SetEntriesStarredState(userID, unstarredEntryIDs, false)
|
||||
if err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
return
|
||||
@@ -365,7 +365,7 @@ func (h *handler) editTagHandler(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
if len(starredEntryIDs) > 0 {
|
||||
err = h.store.SetEntriesBookmarkedState(userID, starredEntryIDs, true)
|
||||
err = h.store.SetEntriesStarredState(userID, starredEntryIDs, true)
|
||||
if err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
return
|
||||
@@ -793,7 +793,7 @@ func (h *handler) streamItemContentsHandler(w http.ResponseWriter, r *http.Reque
|
||||
Content: entry.Content,
|
||||
},
|
||||
Origin: contentItemOrigin{
|
||||
StreamID: fmt.Sprintf("feed/%d", entry.FeedID),
|
||||
StreamID: fmt.Sprintf(feedPrefix+"%d", entry.FeedID),
|
||||
Title: entry.Feed.Title,
|
||||
HTMLUrl: entry.Feed.SiteURL,
|
||||
},
|
||||
|
||||
@@ -24,7 +24,7 @@ func New(name, value string, isHTTPS bool, path string) *http.Cookie {
|
||||
Path: basePath(path),
|
||||
Secure: isHTTPS,
|
||||
HttpOnly: true,
|
||||
Expires: time.Now().Add(time.Duration(config.Opts.CleanupRemoveSessionsDays()) * 24 * time.Hour),
|
||||
Expires: time.Now().Add(config.Opts.CleanupRemoveSessionsInterval()),
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ package request // import "miniflux.app/v2/internal/http/request"
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"miniflux.app/v2/internal/model"
|
||||
)
|
||||
@@ -135,13 +136,13 @@ func FlashErrorMessage(r *http.Request) string {
|
||||
}
|
||||
|
||||
// LastForceRefresh returns the last force refresh timestamp.
|
||||
func LastForceRefresh(r *http.Request) int64 {
|
||||
func LastForceRefresh(r *http.Request) time.Time {
|
||||
jsonStringValue := getContextStringValue(r, LastForceRefreshContextKey)
|
||||
timestamp, err := strconv.ParseInt(jsonStringValue, 10, 64)
|
||||
if err != nil {
|
||||
return 0
|
||||
return time.Time{}
|
||||
}
|
||||
return timestamp
|
||||
return time.Unix(timestamp, 0)
|
||||
}
|
||||
|
||||
// ClientIP returns the client IP address stored in the context.
|
||||
|
||||
@@ -84,8 +84,6 @@ func (b *Builder) Write() {
|
||||
b.compress(v)
|
||||
case string:
|
||||
b.compress([]byte(v))
|
||||
case error:
|
||||
b.compress([]byte(v.Error()))
|
||||
case io.Reader:
|
||||
// Compression not implemented in this case
|
||||
b.writeHeaders()
|
||||
@@ -117,24 +115,24 @@ func (b *Builder) compress(data []byte) {
|
||||
b.writeHeaders()
|
||||
|
||||
brotliWriter := brotli.NewWriterV2(b.w, brotli.DefaultCompression)
|
||||
defer brotliWriter.Close()
|
||||
brotliWriter.Write(data)
|
||||
brotliWriter.Close()
|
||||
return
|
||||
case strings.Contains(acceptEncoding, "gzip"):
|
||||
b.headers["Content-Encoding"] = "gzip"
|
||||
b.writeHeaders()
|
||||
|
||||
gzipWriter := gzip.NewWriter(b.w)
|
||||
defer gzipWriter.Close()
|
||||
gzipWriter.Write(data)
|
||||
gzipWriter.Close()
|
||||
return
|
||||
case strings.Contains(acceptEncoding, "deflate"):
|
||||
b.headers["Content-Encoding"] = "deflate"
|
||||
b.writeHeaders()
|
||||
|
||||
flateWriter, _ := flate.NewWriter(b.w, -1)
|
||||
defer flateWriter.Close()
|
||||
flateWriter.Write(data)
|
||||
flateWriter.Close()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
package response // import "miniflux.app/v2/internal/http/response"
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
@@ -105,27 +104,6 @@ func TestBuildResponseWithAttachment(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildResponseWithError(t *testing.T) {
|
||||
r, err := http.NewRequest("GET", "/", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
New(w, r).WithBody(errors.New("Some error")).Write()
|
||||
})
|
||||
|
||||
handler.ServeHTTP(w, r)
|
||||
|
||||
expectedBody := `Some error`
|
||||
actualBody := w.Body.String()
|
||||
if actualBody != expectedBody {
|
||||
t.Fatalf(`Unexpected body, got %s instead of %s`, actualBody, expectedBody)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildResponseWithByteBody(t *testing.T) {
|
||||
r, err := http.NewRequest("GET", "/", nil)
|
||||
if err != nil {
|
||||
|
||||
@@ -12,7 +12,6 @@ import (
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"miniflux.app/v2/internal/api"
|
||||
"miniflux.app/v2/internal/config"
|
||||
@@ -61,15 +60,15 @@ func StartWebServer(store *storage.Storage, pool *worker.Pool) []*http.Server {
|
||||
slog.Error("ACME HTTP challenge server failed", slog.Any("error", err))
|
||||
}
|
||||
}()
|
||||
config.Opts.HTTPS = true
|
||||
config.Opts.SetHTTPSValue(true)
|
||||
httpServers = append(httpServers, challengeServer)
|
||||
}
|
||||
|
||||
for i, listenAddr := range listenAddresses {
|
||||
server := &http.Server{
|
||||
ReadTimeout: time.Duration(config.Opts.HTTPServerTimeout()) * time.Second,
|
||||
WriteTimeout: time.Duration(config.Opts.HTTPServerTimeout()) * time.Second,
|
||||
IdleTimeout: time.Duration(config.Opts.HTTPServerTimeout()) * time.Second,
|
||||
ReadTimeout: config.Opts.HTTPServerTimeout(),
|
||||
WriteTimeout: config.Opts.HTTPServerTimeout(),
|
||||
IdleTimeout: config.Opts.HTTPServerTimeout(),
|
||||
Handler: setupHandler(store, pool),
|
||||
}
|
||||
|
||||
@@ -96,7 +95,7 @@ func StartWebServer(store *storage.Storage, pool *worker.Pool) []*http.Server {
|
||||
case certFile != "" && keyFile != "":
|
||||
server.Addr = listenAddr
|
||||
startTLSServer(server, certFile, keyFile)
|
||||
config.Opts.HTTPS = true
|
||||
config.Opts.SetHTTPSValue(true)
|
||||
default:
|
||||
server.Addr = listenAddr
|
||||
startHTTPServer(server)
|
||||
@@ -149,7 +148,7 @@ func startUnixSocketServer(server *http.Server, socketFile string) {
|
||||
slog.String("key_file", keyFile),
|
||||
)
|
||||
// Ensure HTTPS is marked as true if any listener uses TLS
|
||||
config.Opts.HTTPS = true
|
||||
config.Opts.SetHTTPSValue(true)
|
||||
if err := server.ServeTLS(listener, certFile, keyFile); err != http.ErrServerClosed {
|
||||
printErrorAndExit("TLS Unix socket server failed to start on %s: %v", socketFile, err)
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ func middleware(next http.Handler) http.Handler {
|
||||
ctx = context.WithValue(ctx, request.ClientIPContextKey, clientIP)
|
||||
|
||||
if r.Header.Get("X-Forwarded-Proto") == "https" {
|
||||
config.Opts.HTTPS = true
|
||||
config.Opts.SetHTTPSValue(true)
|
||||
}
|
||||
|
||||
t1 := time.Now()
|
||||
@@ -36,7 +36,7 @@ func middleware(next http.Handler) http.Handler {
|
||||
)
|
||||
}()
|
||||
|
||||
if config.Opts.HTTPS && config.Opts.HasHSTS() {
|
||||
if config.Opts.HTTPS() && config.Opts.HasHSTS() {
|
||||
w.Header().Set("Strict-Transport-Security", "max-age=31536000")
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"miniflux.app/v2/internal/integration/karakeep"
|
||||
"miniflux.app/v2/internal/integration/linkace"
|
||||
"miniflux.app/v2/internal/integration/linkding"
|
||||
"miniflux.app/v2/internal/integration/linktaco"
|
||||
"miniflux.app/v2/internal/integration/linkwarden"
|
||||
"miniflux.app/v2/internal/integration/matrixbot"
|
||||
"miniflux.app/v2/internal/integration/notion"
|
||||
@@ -107,6 +108,7 @@ func SendEntry(entry *model.Entry, userIntegrations *model.Integration) {
|
||||
if userIntegrations.WallabagEnabled {
|
||||
slog.Debug("Sending entry to Wallabag",
|
||||
slog.Int64("user_id", userIntegrations.UserID),
|
||||
slog.String("user_tags", userIntegrations.WallabagTags),
|
||||
slog.Int64("entry_id", entry.ID),
|
||||
slog.String("entry_url", entry.URL),
|
||||
)
|
||||
@@ -117,12 +119,14 @@ func SendEntry(entry *model.Entry, userIntegrations *model.Integration) {
|
||||
userIntegrations.WallabagClientSecret,
|
||||
userIntegrations.WallabagUsername,
|
||||
userIntegrations.WallabagPassword,
|
||||
userIntegrations.WallabagTags,
|
||||
userIntegrations.WallabagOnlyURL,
|
||||
)
|
||||
|
||||
if err := client.CreateEntry(entry.URL, entry.Title, entry.Content); err != nil {
|
||||
slog.Error("Unable to send entry to Wallabag",
|
||||
slog.Int64("user_id", userIntegrations.UserID),
|
||||
slog.String("user_tags", userIntegrations.WallabagTags),
|
||||
slog.Int64("entry_id", entry.ID),
|
||||
slog.String("entry_url", entry.URL),
|
||||
slog.Any("error", err),
|
||||
@@ -242,6 +246,29 @@ func SendEntry(entry *model.Entry, userIntegrations *model.Integration) {
|
||||
}
|
||||
}
|
||||
|
||||
if userIntegrations.LinktacoEnabled {
|
||||
slog.Debug("Sending entry to LinkTaco",
|
||||
slog.Int64("user_id", userIntegrations.UserID),
|
||||
slog.Int64("entry_id", entry.ID),
|
||||
slog.String("entry_url", entry.URL),
|
||||
)
|
||||
|
||||
client := linktaco.NewClient(
|
||||
userIntegrations.LinktacoAPIToken,
|
||||
userIntegrations.LinktacoOrgSlug,
|
||||
userIntegrations.LinktacoTags,
|
||||
userIntegrations.LinktacoVisibility,
|
||||
)
|
||||
if err := client.CreateBookmark(entry.URL, entry.Title, entry.Content); err != nil {
|
||||
slog.Error("Unable to send entry to LinkTaco",
|
||||
slog.Int64("user_id", userIntegrations.UserID),
|
||||
slog.Int64("entry_id", entry.ID),
|
||||
slog.String("entry_url", entry.URL),
|
||||
slog.Any("error", err),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if userIntegrations.LinkwardenEnabled {
|
||||
slog.Debug("Sending entry to linkwarden",
|
||||
slog.Int64("user_id", userIntegrations.UserID),
|
||||
|
||||
@@ -50,7 +50,7 @@ func (c *Client) SaveURL(entryURL string) error {
|
||||
return fmt.Errorf("karakeep: unable to create request: %v", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", c.apiToken))
|
||||
req.Header.Set("Authorization", "Bearer "+c.apiToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("User-Agent", "Miniflux/"+version.Version)
|
||||
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package linktaco // import "miniflux.app/v2/internal/integration/linktaco"
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"miniflux.app/v2/internal/version"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultClientTimeout = 10 * time.Second
|
||||
defaultGraphQLURL = "https://api.linktaco.com/query"
|
||||
maxTags = 10
|
||||
maxDescriptionLength = 500
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
graphqlURL string
|
||||
apiToken string
|
||||
orgSlug string
|
||||
tags string
|
||||
visibility string
|
||||
}
|
||||
|
||||
func NewClient(apiToken, orgSlug, tags, visibility string) *Client {
|
||||
if visibility == "" {
|
||||
visibility = "PUBLIC"
|
||||
}
|
||||
return &Client{
|
||||
graphqlURL: defaultGraphQLURL,
|
||||
apiToken: apiToken,
|
||||
orgSlug: orgSlug,
|
||||
tags: tags,
|
||||
visibility: visibility,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) CreateBookmark(entryURL, entryTitle, entryContent string) error {
|
||||
if c.apiToken == "" || c.orgSlug == "" {
|
||||
return fmt.Errorf("linktaco: missing API token or organization slug")
|
||||
}
|
||||
|
||||
description := entryContent
|
||||
if len(description) > maxDescriptionLength {
|
||||
description = description[:maxDescriptionLength]
|
||||
}
|
||||
|
||||
// tags (limit to 10)
|
||||
tags := strings.FieldsFunc(c.tags, func(c rune) bool {
|
||||
return c == ',' || c == ' '
|
||||
})
|
||||
if len(tags) > maxTags {
|
||||
tags = tags[:maxTags]
|
||||
}
|
||||
// tagsStr is used in GraphQL query to pass comma separated tags
|
||||
tagsStr := strings.Join(tags, ",")
|
||||
|
||||
mutation := `
|
||||
mutation AddLink($input: LinkInput!) {
|
||||
addLink(input: $input) {
|
||||
id
|
||||
url
|
||||
title
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
variables := map[string]any{
|
||||
"input": map[string]any{
|
||||
"url": entryURL,
|
||||
"title": entryTitle,
|
||||
"description": description,
|
||||
"orgSlug": c.orgSlug,
|
||||
"visibility": c.visibility,
|
||||
"unread": true,
|
||||
"starred": false,
|
||||
"archive": false,
|
||||
"tags": tagsStr,
|
||||
},
|
||||
}
|
||||
|
||||
requestBody, err := json.Marshal(map[string]any{
|
||||
"query": mutation,
|
||||
"variables": variables,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("linktaco: unable to encode request body: %v", err)
|
||||
}
|
||||
|
||||
request, err := http.NewRequest(http.MethodPost, c.graphqlURL, bytes.NewReader(requestBody))
|
||||
if err != nil {
|
||||
return fmt.Errorf("linktaco: unable to create request: %v", err)
|
||||
}
|
||||
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
|
||||
request.Header.Set("Authorization", "Bearer "+c.apiToken)
|
||||
|
||||
httpClient := &http.Client{Timeout: defaultClientTimeout}
|
||||
response, err := httpClient.Do(request)
|
||||
if err != nil {
|
||||
return fmt.Errorf("linktaco: unable to send request: %v", err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
|
||||
if response.StatusCode >= 400 {
|
||||
return fmt.Errorf("linktaco: unable to create bookmark: status=%d", response.StatusCode)
|
||||
}
|
||||
|
||||
var graphqlResponse struct {
|
||||
Data json.RawMessage `json:"data"`
|
||||
Errors []json.RawMessage `json:"errors"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(response.Body).Decode(&graphqlResponse); err != nil {
|
||||
return fmt.Errorf("linktaco: unable to decode response: %v", err)
|
||||
}
|
||||
|
||||
if len(graphqlResponse.Errors) > 0 {
|
||||
// Try to extract error message
|
||||
var errorMsg string
|
||||
for _, errJSON := range graphqlResponse.Errors {
|
||||
var errObj struct {
|
||||
Message string `json:"message"`
|
||||
}
|
||||
if json.Unmarshal(errJSON, &errObj) == nil && errObj.Message != "" {
|
||||
errorMsg = errObj.Message
|
||||
break
|
||||
}
|
||||
}
|
||||
if errorMsg == "" {
|
||||
// Fallback. Should never be reached.
|
||||
errorMsg = "GraphQL error occurred (fallback message)"
|
||||
}
|
||||
return fmt.Errorf("linktaco: %s", errorMsg)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,440 @@
|
||||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package linktaco
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCreateBookmark(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
apiToken string
|
||||
orgSlug string
|
||||
tags string
|
||||
visibility string
|
||||
entryURL string
|
||||
entryTitle string
|
||||
entryContent string
|
||||
serverResponse func(w http.ResponseWriter, r *http.Request)
|
||||
wantErr bool
|
||||
errContains string
|
||||
}{
|
||||
{
|
||||
name: "successful bookmark creation",
|
||||
apiToken: "test-token",
|
||||
orgSlug: "test-org",
|
||||
tags: "tag1, tag2",
|
||||
visibility: "PUBLIC",
|
||||
entryURL: "https://example.com",
|
||||
entryTitle: "Test Article",
|
||||
entryContent: "Test content",
|
||||
serverResponse: func(w http.ResponseWriter, r *http.Request) {
|
||||
// Verify authorization header
|
||||
auth := r.Header.Get("Authorization")
|
||||
if auth != "Bearer test-token" {
|
||||
t.Errorf("Expected Authorization header 'Bearer test-token', got %s", auth)
|
||||
}
|
||||
|
||||
// Verify content type
|
||||
contentType := r.Header.Get("Content-Type")
|
||||
if contentType != "application/json" {
|
||||
t.Errorf("Expected Content-Type 'application/json', got %s", contentType)
|
||||
}
|
||||
|
||||
// Parse and verify request
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
var req map[string]interface{}
|
||||
if err := json.Unmarshal(body, &req); err != nil {
|
||||
t.Errorf("Failed to parse request body: %v", err)
|
||||
}
|
||||
|
||||
// Verify mutation exists
|
||||
if _, ok := req["query"]; !ok {
|
||||
t.Error("Missing 'query' field in request")
|
||||
}
|
||||
|
||||
// Return success response
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"data": map[string]interface{}{
|
||||
"addLink": map[string]interface{}{
|
||||
"id": "123",
|
||||
"url": "https://example.com",
|
||||
"title": "Test Article",
|
||||
},
|
||||
},
|
||||
})
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "missing API token",
|
||||
apiToken: "",
|
||||
orgSlug: "test-org",
|
||||
entryURL: "https://example.com",
|
||||
entryTitle: "Test",
|
||||
entryContent: "Content",
|
||||
serverResponse: func(w http.ResponseWriter, r *http.Request) {
|
||||
// Should not be called
|
||||
t.Error("Server should not be called when API token is missing")
|
||||
},
|
||||
wantErr: true,
|
||||
errContains: "missing API token or organization slug",
|
||||
},
|
||||
{
|
||||
name: "missing organization slug",
|
||||
apiToken: "test-token",
|
||||
orgSlug: "",
|
||||
entryURL: "https://example.com",
|
||||
entryTitle: "Test",
|
||||
entryContent: "Content",
|
||||
serverResponse: func(w http.ResponseWriter, r *http.Request) {
|
||||
// Should not be called
|
||||
t.Error("Server should not be called when org slug is missing")
|
||||
},
|
||||
wantErr: true,
|
||||
errContains: "missing API token or organization slug",
|
||||
},
|
||||
{
|
||||
name: "GraphQL error response",
|
||||
apiToken: "test-token",
|
||||
orgSlug: "test-org",
|
||||
entryURL: "https://example.com",
|
||||
entryTitle: "Test",
|
||||
entryContent: "Content",
|
||||
serverResponse: func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"errors": []interface{}{
|
||||
map[string]interface{}{
|
||||
"message": "Invalid input",
|
||||
},
|
||||
},
|
||||
})
|
||||
},
|
||||
wantErr: true,
|
||||
errContains: "Invalid input",
|
||||
},
|
||||
{
|
||||
name: "HTTP error status",
|
||||
apiToken: "test-token",
|
||||
orgSlug: "test-org",
|
||||
entryURL: "https://example.com",
|
||||
entryTitle: "Test",
|
||||
entryContent: "Content",
|
||||
serverResponse: func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
},
|
||||
wantErr: true,
|
||||
errContains: "status=401",
|
||||
},
|
||||
{
|
||||
name: "private visibility permission error",
|
||||
apiToken: "test-token",
|
||||
orgSlug: "test-org",
|
||||
visibility: "PRIVATE",
|
||||
entryURL: "https://example.com",
|
||||
entryTitle: "Test",
|
||||
entryContent: "Content",
|
||||
serverResponse: func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"errors": []interface{}{
|
||||
map[string]interface{}{
|
||||
"message": "PRIVATE visibility requires a paid LinkTaco account",
|
||||
},
|
||||
},
|
||||
})
|
||||
},
|
||||
wantErr: true,
|
||||
errContains: "PRIVATE visibility requires a paid LinkTaco account",
|
||||
},
|
||||
{
|
||||
name: "content truncation",
|
||||
apiToken: "test-token",
|
||||
orgSlug: "test-org",
|
||||
entryURL: "https://example.com",
|
||||
entryTitle: "Test",
|
||||
entryContent: strings.Repeat("a", 600), // Content longer than 500 chars
|
||||
serverResponse: func(w http.ResponseWriter, r *http.Request) {
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
var req map[string]interface{}
|
||||
json.Unmarshal(body, &req)
|
||||
|
||||
// Check that description was truncated
|
||||
variables := req["variables"].(map[string]interface{})
|
||||
input := variables["input"].(map[string]interface{})
|
||||
description := input["description"].(string)
|
||||
|
||||
if len(description) != maxDescriptionLength {
|
||||
t.Errorf("Expected description length %d, got %d", maxDescriptionLength, len(description))
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"data": map[string]interface{}{
|
||||
"addLink": map[string]interface{}{"id": "123"},
|
||||
},
|
||||
})
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "tag limiting",
|
||||
apiToken: "test-token",
|
||||
orgSlug: "test-org",
|
||||
tags: "tag1,tag2,tag3,tag4,tag5,tag6,tag7,tag8,tag9,tag10,tag11,tag12",
|
||||
entryURL: "https://example.com",
|
||||
entryTitle: "Test",
|
||||
entryContent: "Content",
|
||||
serverResponse: func(w http.ResponseWriter, r *http.Request) {
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
var req map[string]interface{}
|
||||
json.Unmarshal(body, &req)
|
||||
|
||||
// Check that only 10 tags were sent
|
||||
variables := req["variables"].(map[string]interface{})
|
||||
input := variables["input"].(map[string]interface{})
|
||||
tags := input["tags"].(string)
|
||||
|
||||
tagCount := len(strings.Split(tags, ","))
|
||||
if tagCount != maxTags {
|
||||
t.Errorf("Expected %d tags, got %d", maxTags, tagCount)
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"data": map[string]interface{}{
|
||||
"addLink": map[string]interface{}{"id": "123"},
|
||||
},
|
||||
})
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "invalid JSON response",
|
||||
apiToken: "test-token",
|
||||
orgSlug: "test-org",
|
||||
entryURL: "https://example.com",
|
||||
entryTitle: "Test",
|
||||
entryContent: "Content",
|
||||
serverResponse: func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("invalid json"))
|
||||
},
|
||||
wantErr: true,
|
||||
errContains: "unable to decode response",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Create test server if we have a server response function
|
||||
var serverURL string
|
||||
if tt.serverResponse != nil {
|
||||
server := httptest.NewServer(http.HandlerFunc(tt.serverResponse))
|
||||
defer server.Close()
|
||||
serverURL = server.URL
|
||||
}
|
||||
|
||||
// Create client with test server URL
|
||||
client := &Client{
|
||||
graphqlURL: serverURL,
|
||||
apiToken: tt.apiToken,
|
||||
orgSlug: tt.orgSlug,
|
||||
tags: tt.tags,
|
||||
visibility: tt.visibility,
|
||||
}
|
||||
|
||||
// Call CreateBookmark
|
||||
err := client.CreateBookmark(tt.entryURL, tt.entryTitle, tt.entryContent)
|
||||
|
||||
// Check error expectations
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Errorf("Expected error but got none")
|
||||
} else if tt.errContains != "" && !strings.Contains(err.Error(), tt.errContains) {
|
||||
t.Errorf("Expected error containing '%s', got '%s'", tt.errContains, err.Error())
|
||||
}
|
||||
} else {
|
||||
if err != nil {
|
||||
t.Errorf("Unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewClient(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
apiToken string
|
||||
orgSlug string
|
||||
tags string
|
||||
visibility string
|
||||
expectedVisibility string
|
||||
}{
|
||||
{
|
||||
name: "with all parameters",
|
||||
apiToken: "token",
|
||||
orgSlug: "org",
|
||||
tags: "tag1,tag2",
|
||||
visibility: "PRIVATE",
|
||||
expectedVisibility: "PRIVATE",
|
||||
},
|
||||
{
|
||||
name: "empty visibility defaults to PUBLIC",
|
||||
apiToken: "token",
|
||||
orgSlug: "org",
|
||||
tags: "tag1",
|
||||
visibility: "",
|
||||
expectedVisibility: "PUBLIC",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
client := NewClient(tt.apiToken, tt.orgSlug, tt.tags, tt.visibility)
|
||||
|
||||
if client.apiToken != tt.apiToken {
|
||||
t.Errorf("Expected apiToken %s, got %s", tt.apiToken, client.apiToken)
|
||||
}
|
||||
if client.orgSlug != tt.orgSlug {
|
||||
t.Errorf("Expected orgSlug %s, got %s", tt.orgSlug, client.orgSlug)
|
||||
}
|
||||
if client.tags != tt.tags {
|
||||
t.Errorf("Expected tags %s, got %s", tt.tags, client.tags)
|
||||
}
|
||||
if client.visibility != tt.expectedVisibility {
|
||||
t.Errorf("Expected visibility %s, got %s", tt.expectedVisibility, client.visibility)
|
||||
}
|
||||
if client.graphqlURL != defaultGraphQLURL {
|
||||
t.Errorf("Expected graphqlURL %s, got %s", defaultGraphQLURL, client.graphqlURL)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGraphQLMutation(t *testing.T) {
|
||||
// Test that the GraphQL mutation is properly formatted
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
var req map[string]interface{}
|
||||
if err := json.Unmarshal(body, &req); err != nil {
|
||||
t.Fatalf("Failed to parse request: %v", err)
|
||||
}
|
||||
|
||||
// Verify mutation structure
|
||||
query, ok := req["query"].(string)
|
||||
if !ok {
|
||||
t.Fatal("Missing query field")
|
||||
}
|
||||
|
||||
// Check that mutation contains expected parts
|
||||
if !strings.Contains(query, "mutation AddLink") {
|
||||
t.Error("Mutation should contain 'mutation AddLink'")
|
||||
}
|
||||
if !strings.Contains(query, "$input: LinkInput!") {
|
||||
t.Error("Mutation should contain input parameter")
|
||||
}
|
||||
if !strings.Contains(query, "addLink(input: $input)") {
|
||||
t.Error("Mutation should contain addLink call")
|
||||
}
|
||||
|
||||
// Verify variables structure
|
||||
variables, ok := req["variables"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatal("Missing variables field")
|
||||
}
|
||||
|
||||
input, ok := variables["input"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatal("Missing input in variables")
|
||||
}
|
||||
|
||||
// Check all required fields
|
||||
requiredFields := []string{"url", "title", "description", "orgSlug", "visibility", "unread", "starred", "archive", "tags"}
|
||||
for _, field := range requiredFields {
|
||||
if _, ok := input[field]; !ok {
|
||||
t.Errorf("Missing required field: %s", field)
|
||||
}
|
||||
}
|
||||
|
||||
// Return success
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"data": map[string]interface{}{
|
||||
"addLink": map[string]interface{}{
|
||||
"id": "123",
|
||||
},
|
||||
},
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := &Client{
|
||||
graphqlURL: server.URL,
|
||||
apiToken: "test-token",
|
||||
orgSlug: "test-org",
|
||||
tags: "test",
|
||||
visibility: "PUBLIC",
|
||||
}
|
||||
|
||||
err := client.CreateBookmark("https://example.com", "Test Title", "Test Content")
|
||||
if err != nil {
|
||||
t.Errorf("Unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkCreateBookmark(b *testing.B) {
|
||||
// Create a mock server that always returns success
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"data": map[string]interface{}{
|
||||
"addLink": map[string]interface{}{
|
||||
"id": "123",
|
||||
},
|
||||
},
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := &Client{
|
||||
graphqlURL: server.URL,
|
||||
apiToken: "test-token",
|
||||
orgSlug: "test-org",
|
||||
tags: "tag1,tag2,tag3",
|
||||
visibility: "PUBLIC",
|
||||
}
|
||||
|
||||
// Run benchmark
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = client.CreateBookmark("https://example.com", "Test Title", "Test Content")
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkTagProcessing(b *testing.B) {
|
||||
// Benchmark tag splitting and limiting
|
||||
tags := "tag1,tag2,tag3,tag4,tag5,tag6,tag7,tag8,tag9,tag10,tag11,tag12,tag13,tag14,tag15"
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
tagsSplitFn := func(c rune) bool {
|
||||
return c == ',' || c == ' '
|
||||
}
|
||||
splitTags := strings.FieldsFunc(tags, tagsSplitFn)
|
||||
if len(splitTags) > maxTags {
|
||||
splitTags = splitTags[:maxTags]
|
||||
}
|
||||
_ = strings.Join(splitTags, ",")
|
||||
}
|
||||
}
|
||||
@@ -90,7 +90,7 @@ func (c *Client) makeRequest(payload any) error {
|
||||
|
||||
// See https://docs.ntfy.sh/publish/#access-tokens
|
||||
if c.ntfyApiToken != "" {
|
||||
request.Header.Set("Authorization", fmt.Sprintf("Bearer %s", c.ntfyApiToken))
|
||||
request.Header.Set("Authorization", "Bearer "+c.ntfyApiToken)
|
||||
}
|
||||
|
||||
// See https://docs.ntfy.sh/publish/#username-password
|
||||
|
||||
@@ -24,11 +24,12 @@ type Client struct {
|
||||
clientSecret string
|
||||
username string
|
||||
password string
|
||||
tags string
|
||||
onlyURL bool
|
||||
}
|
||||
|
||||
func NewClient(baseURL, clientID, clientSecret, username, password string, onlyURL bool) *Client {
|
||||
return &Client{baseURL, clientID, clientSecret, username, password, onlyURL}
|
||||
func NewClient(baseURL, clientID, clientSecret, username, password, tags string, onlyURL bool) *Client {
|
||||
return &Client{baseURL, clientID, clientSecret, username, password, tags, onlyURL}
|
||||
}
|
||||
|
||||
func (c *Client) CreateEntry(entryURL, entryTitle, entryContent string) error {
|
||||
@@ -41,10 +42,10 @@ func (c *Client) CreateEntry(entryURL, entryTitle, entryContent string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
return c.createEntry(accessToken, entryURL, entryTitle, entryContent)
|
||||
return c.createEntry(accessToken, entryURL, entryTitle, entryContent, c.tags)
|
||||
}
|
||||
|
||||
func (c *Client) createEntry(accessToken, entryURL, entryTitle, entryContent string) error {
|
||||
func (c *Client) createEntry(accessToken, entryURL, entryTitle, entryContent, tags string) error {
|
||||
apiEndpoint, err := urllib.JoinBaseURLAndPath(c.baseURL, "/api/entries.json")
|
||||
if err != nil {
|
||||
return fmt.Errorf("wallbag: unable to generate entries endpoint: %v", err)
|
||||
@@ -58,6 +59,7 @@ func (c *Client) createEntry(accessToken, entryURL, entryTitle, entryContent str
|
||||
URL: entryURL,
|
||||
Title: entryTitle,
|
||||
Content: entryContent,
|
||||
Tags: tags,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("wallbag: unable to encode request body: %v", err)
|
||||
@@ -81,7 +83,7 @@ func (c *Client) createEntry(accessToken, entryURL, entryTitle, entryContent str
|
||||
defer response.Body.Close()
|
||||
|
||||
if response.StatusCode >= 400 {
|
||||
return fmt.Errorf("wallabag: unable to get access token: url=%s status=%d", apiEndpoint, response.StatusCode)
|
||||
return fmt.Errorf("wallabag: unable to get save entry: url=%s status=%d", apiEndpoint, response.StatusCode)
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -140,4 +142,5 @@ type createEntryRequest struct {
|
||||
URL string `json:"url"`
|
||||
Title string `json:"title"`
|
||||
Content string `json:"content,omitempty"`
|
||||
Tags string `json:"tags,omitempty"`
|
||||
}
|
||||
|
||||
@@ -0,0 +1,321 @@
|
||||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package wallabag
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCreateEntry(t *testing.T) {
|
||||
entryURL := "https://example.com"
|
||||
entryTitle := "title"
|
||||
entryContent := "content"
|
||||
tags := "tag1,tag2,tag3"
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
username string
|
||||
password string
|
||||
clientID string
|
||||
clientSecret string
|
||||
tags string
|
||||
onlyURL bool
|
||||
entryURL string
|
||||
entryTitle string
|
||||
entryContent string
|
||||
serverResponse func(w http.ResponseWriter, r *http.Request)
|
||||
wantErr bool
|
||||
errContains string
|
||||
}{
|
||||
{
|
||||
name: "successful entry creation with url only",
|
||||
wantErr: false,
|
||||
onlyURL: true,
|
||||
username: "username",
|
||||
password: "password",
|
||||
clientID: "clientId",
|
||||
clientSecret: "clientSecret",
|
||||
tags: tags,
|
||||
entryURL: entryURL,
|
||||
entryTitle: entryTitle,
|
||||
entryContent: entryContent,
|
||||
serverResponse: func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.Contains(r.URL.Path, "/oauth/v2/token") {
|
||||
// Return success response
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"access_token": "test-token",
|
||||
"expires_in": 3600,
|
||||
"refresh_token": "token",
|
||||
"scope": "scope",
|
||||
"token_type": "token_type",
|
||||
})
|
||||
return
|
||||
}
|
||||
// Verify authorization header
|
||||
auth := r.Header.Get("Authorization")
|
||||
if auth != "Bearer test-token" {
|
||||
t.Errorf("Expected Authorization header 'Bearer test-token', got %s", auth)
|
||||
}
|
||||
// Verify content type
|
||||
contentType := r.Header.Get("Content-Type")
|
||||
if contentType != "application/json" {
|
||||
t.Errorf("Expected Content-Type 'application/json', got %s", contentType)
|
||||
}
|
||||
// Parse and verify request
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
var req map[string]any
|
||||
if err := json.Unmarshal(body, &req); err != nil {
|
||||
t.Errorf("Failed to parse request body: %v", err)
|
||||
}
|
||||
if requstEntryURL := req["url"]; requstEntryURL != entryURL {
|
||||
t.Errorf("Expected entryURL %s, got %s", entryURL, requstEntryURL)
|
||||
}
|
||||
if requestEntryTitle := req["title"]; requestEntryTitle != entryTitle {
|
||||
t.Errorf("Expected entryTitle %s, got %s", entryTitle, requestEntryTitle)
|
||||
}
|
||||
if _, ok := req["content"]; ok {
|
||||
t.Errorf("Expected entryContent to be empty, got value")
|
||||
}
|
||||
if requestTags := req["tags"]; requestTags != tags {
|
||||
t.Errorf("Expected tags %s, got %s", tags, requestTags)
|
||||
} // Return success response
|
||||
w.WriteHeader(http.StatusOK)
|
||||
},
|
||||
errContains: "",
|
||||
},
|
||||
{
|
||||
name: "successful entry creation with content",
|
||||
wantErr: false,
|
||||
onlyURL: false,
|
||||
username: "username",
|
||||
password: "password",
|
||||
clientID: "clientId",
|
||||
clientSecret: "clientSecret",
|
||||
tags: tags,
|
||||
entryURL: entryURL,
|
||||
entryTitle: entryTitle,
|
||||
entryContent: entryContent,
|
||||
serverResponse: func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.Contains(r.URL.Path, "/oauth/v2/token") {
|
||||
// Return success response
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"access_token": "test-token",
|
||||
"expires_in": 3600,
|
||||
"refresh_token": "token",
|
||||
"scope": "scope",
|
||||
"token_type": "token_type",
|
||||
})
|
||||
return
|
||||
}
|
||||
// Verify authorization header
|
||||
auth := r.Header.Get("Authorization")
|
||||
if auth != "Bearer test-token" {
|
||||
t.Errorf("Expected Authorization header 'Bearer test-token', got %s", auth)
|
||||
}
|
||||
// Verify content type
|
||||
contentType := r.Header.Get("Content-Type")
|
||||
if contentType != "application/json" {
|
||||
t.Errorf("Expected Content-Type 'application/json', got %s", contentType)
|
||||
}
|
||||
// Parse and verify request
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
var req map[string]any
|
||||
if err := json.Unmarshal(body, &req); err != nil {
|
||||
t.Errorf("Failed to parse request body: %v", err)
|
||||
}
|
||||
if requstEntryURL := req["url"]; requstEntryURL != entryURL {
|
||||
t.Errorf("Expected entryURL %s, got %s", entryURL, requstEntryURL)
|
||||
}
|
||||
if requestEntryTitle := req["title"]; requestEntryTitle != entryTitle {
|
||||
t.Errorf("Expected entryTitle %s, got %s", entryTitle, requestEntryTitle)
|
||||
}
|
||||
if requestEntryContent := req["content"]; requestEntryContent != entryContent {
|
||||
t.Errorf("Expected entryContent %s, got %s", entryContent, requestEntryContent)
|
||||
}
|
||||
if requestTags := req["tags"]; requestTags != tags {
|
||||
t.Errorf("Expected tags %s, got %s", tags, requestTags)
|
||||
} // Return success response
|
||||
w.WriteHeader(http.StatusOK)
|
||||
},
|
||||
errContains: "",
|
||||
},
|
||||
{
|
||||
name: "failed when unable to decode accessToken response",
|
||||
wantErr: true,
|
||||
onlyURL: true,
|
||||
username: "username",
|
||||
password: "password",
|
||||
clientID: "clientId",
|
||||
clientSecret: "clientSecret",
|
||||
tags: tags,
|
||||
entryURL: entryURL,
|
||||
entryTitle: entryTitle,
|
||||
entryContent: entryContent,
|
||||
serverResponse: func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.Contains(r.URL.Path, "/oauth/v2/token") {
|
||||
// Return success response
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("invalid json"))
|
||||
return
|
||||
}
|
||||
t.Error("Server should not be called when failed to get accessToken")
|
||||
},
|
||||
errContains: "unable to decode token response",
|
||||
},
|
||||
{
|
||||
name: "failed when saving entry",
|
||||
wantErr: true,
|
||||
onlyURL: true,
|
||||
username: "username",
|
||||
password: "password",
|
||||
clientID: "clientId",
|
||||
clientSecret: "clientSecret",
|
||||
tags: tags,
|
||||
entryURL: entryURL,
|
||||
entryTitle: entryTitle,
|
||||
entryContent: entryContent,
|
||||
serverResponse: func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.Contains(r.URL.Path, "/oauth/v2/token") {
|
||||
// Return success response
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"access_token": "test-token",
|
||||
"expires_in": 3600,
|
||||
"refresh_token": "token",
|
||||
"scope": "scope",
|
||||
"token_type": "token_type",
|
||||
})
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
},
|
||||
errContains: "unable to get save entry",
|
||||
},
|
||||
{
|
||||
name: "failure due to no accessToken",
|
||||
wantErr: true,
|
||||
onlyURL: false,
|
||||
username: "username",
|
||||
password: "password",
|
||||
clientID: "clientId",
|
||||
clientSecret: "clientSecret",
|
||||
tags: tags,
|
||||
entryURL: entryURL,
|
||||
entryTitle: entryTitle,
|
||||
entryContent: entryContent,
|
||||
serverResponse: func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.Contains(r.URL.Path, "/oauth/v2/token") {
|
||||
// Return error response
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
t.Error("Server should not be called when failed to get accessToken")
|
||||
},
|
||||
errContains: "unable to get access token",
|
||||
},
|
||||
{
|
||||
name: "failure due to missing client parameters",
|
||||
wantErr: true,
|
||||
onlyURL: false,
|
||||
tags: tags,
|
||||
entryURL: entryURL,
|
||||
entryTitle: entryTitle,
|
||||
entryContent: entryContent,
|
||||
serverResponse: func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Error("Server should not be called when failed to get accessToken")
|
||||
},
|
||||
errContains: "wallabag: missing base URL, client ID, client secret, username or password",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Create test server if we have a server response function
|
||||
var serverURL string
|
||||
if tt.serverResponse != nil {
|
||||
server := httptest.NewServer(http.HandlerFunc(tt.serverResponse))
|
||||
defer server.Close()
|
||||
serverURL = server.URL
|
||||
}
|
||||
|
||||
// Create client with test server URL
|
||||
client := NewClient(serverURL, tt.clientID, tt.clientSecret, tt.username, tt.password, tt.tags, tt.onlyURL)
|
||||
|
||||
// Call CreateBookmark
|
||||
err := client.CreateEntry(tt.entryURL, tt.entryTitle, tt.entryContent)
|
||||
|
||||
// Check error expectations
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Errorf("Expected error but got none")
|
||||
} else if tt.errContains != "" && !strings.Contains(err.Error(), tt.errContains) {
|
||||
t.Errorf("Expected error containing '%s', got '%s'", tt.errContains, err.Error())
|
||||
}
|
||||
} else {
|
||||
if err != nil {
|
||||
t.Errorf("Unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewClient(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
baseURL string
|
||||
clientID string
|
||||
clientSecret string
|
||||
username string
|
||||
password string
|
||||
tags string
|
||||
onlyURL bool
|
||||
}{
|
||||
{
|
||||
name: "with all parameters",
|
||||
baseURL: "https://wallabag.example.com",
|
||||
clientID: "clientID",
|
||||
clientSecret: "clientSecret",
|
||||
username: "wallabag",
|
||||
password: "wallabag",
|
||||
tags: "",
|
||||
onlyURL: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
client := NewClient(tt.baseURL, tt.clientID, tt.clientSecret, tt.username, tt.password, tt.tags, tt.onlyURL)
|
||||
|
||||
if client.baseURL != tt.baseURL {
|
||||
t.Errorf("Expected.baseURL %s, got %s", tt.baseURL, client.baseURL)
|
||||
}
|
||||
if client.username != tt.username {
|
||||
t.Errorf("Expected username %s, got %s", tt.username, client.username)
|
||||
}
|
||||
if client.password != tt.password {
|
||||
t.Errorf("Expected password %s, got %s", tt.password, client.password)
|
||||
}
|
||||
if client.clientID != tt.clientID {
|
||||
t.Errorf("Expected clientID %s, got %s", tt.clientID, client.clientID)
|
||||
}
|
||||
if client.clientSecret != tt.clientSecret {
|
||||
t.Errorf("Expected clientSecret %s, got %s", tt.clientSecret, client.clientSecret)
|
||||
}
|
||||
if client.tags != tt.tags {
|
||||
t.Errorf("Expected tags %s, got %s", tt.tags, client.tags)
|
||||
}
|
||||
if client.onlyURL != tt.onlyURL {
|
||||
t.Errorf("Expected onlyURL %v, got %v", tt.onlyURL, client.onlyURL)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,7 @@
|
||||
"alert.account_unlinked": "Ihr externer Account ist jetzt getrennt!",
|
||||
"alert.background_feed_refresh": "Alle Abonnements werden derzeit im Hintergrund aktualisiert. Sie können Miniflux weiterhin benutzen, während dieser Prozess ausgeführt wird.",
|
||||
"alert.feed_error": "Es gibt ein Problem mit diesem Abonnement",
|
||||
"alert.no_bookmark": "Es existiert derzeit kein Lesezeichen.",
|
||||
"alert.no_starred": "Es existieren derzeit keine markierten Artikel.",
|
||||
"alert.no_category": "Es ist keine Kategorie vorhanden.",
|
||||
"alert.no_category_entry": "Es befindet sich kein Artikel in dieser Kategorie.",
|
||||
"alert.no_feed": "Es sind keine Abonnements vorhanden.",
|
||||
@@ -46,10 +46,10 @@
|
||||
"enclosure_media_controls.speed.reset.title": "Wiedergabegeschwindigkeit auf 1x zurücksetzen",
|
||||
"enclosure_media_controls.speed.slower": "Langsamer",
|
||||
"enclosure_media_controls.speed.slower.title": "%sx langsamer",
|
||||
"entry.bookmark.toast.off": "Nicht markiert",
|
||||
"entry.bookmark.toast.on": "Markiert",
|
||||
"entry.bookmark.toggle.off": "Lesezeichen entfernen",
|
||||
"entry.bookmark.toggle.on": "Lesezeichen hinzufügen",
|
||||
"entry.starred.toast.off": "Nicht markiert",
|
||||
"entry.starred.toast.on": "Markiert",
|
||||
"entry.starred.toggle.off": "Markierung entfernen",
|
||||
"entry.starred.toggle.on": "Markierung hinzufügen",
|
||||
"entry.comments.label": "Kommentare",
|
||||
"entry.comments.title": "Kommentare anzeigen",
|
||||
"entry.estimated_reading_time": [
|
||||
@@ -92,7 +92,7 @@
|
||||
"error.duplicate_linked_account": "Es ist bereits jemand mit diesem Anbieter assoziiert!",
|
||||
"error.duplicated_feed": "Dieses Abonnement existiert bereits.",
|
||||
"error.empty_file": "Diese Datei ist leer.",
|
||||
"error.entries_per_page_invalid": "Die Anzahl der Einträge pro Seite ist ungültig.",
|
||||
"error.entries_per_page_invalid": "Die Anzahl der Artikel pro Seite ist ungültig.",
|
||||
"error.feed_already_exists": "Dieser Feed existiert bereits.",
|
||||
"error.feed_category_not_found": "Diese Kategorie existiert nicht oder gehört nicht zu diesem Benutzer.",
|
||||
"error.feed_format_not_detected": "Das Format des Abonnements kann nicht erkannt werden: %v.",
|
||||
@@ -160,8 +160,9 @@
|
||||
"error.unlink_account_without_password": "Sie müssen ein Passwort festlegen, sonst können Sie sich nicht erneut anmelden.",
|
||||
"error.user_already_exists": "Dieser Benutzer existiert bereits.",
|
||||
"error.user_mandatory_fields": "Der Benutzername ist obligatorisch.",
|
||||
"error.linktaco_missing_required_fields": "LinkTaco API Token und Organization Slug sind erforderlich.",
|
||||
"form.api_key.label.description": "API-Schlüsselbezeichnung",
|
||||
"form.category.hide_globally": "Einträge in der globalen Ungelesen-Liste ausblenden",
|
||||
"form.category.hide_globally": "Artikel in der globalen Ungelesen-Liste ausblenden",
|
||||
"form.category.label.title": "Titel",
|
||||
"form.feed.fieldset.general": "Allgemein",
|
||||
"form.feed.fieldset.integration": "Drittanbieter-Dienste",
|
||||
@@ -181,12 +182,12 @@
|
||||
"form.feed.label.feed_url": "URL des Abonnements",
|
||||
"form.feed.label.feed_username": "Benutzername des Abonnements",
|
||||
"form.feed.label.fetch_via_proxy": "Den auf Anwendungsebene konfigurierten Proxy verwenden",
|
||||
"form.feed.label.hide_globally": "Einträge in der globalen Ungelesen-Liste ausblenden",
|
||||
"form.feed.label.hide_globally": "Artikel in der globalen Ungelesen-Liste ausblenden",
|
||||
"form.feed.label.ignore_http_cache": "Ignoriere HTTP-Cache",
|
||||
"form.feed.label.keep_filter_entry_rules": "Eintrags-Erlaubnisregeln",
|
||||
"form.feed.label.keeplist_rules": "Regex-basierte Behalte-Filter",
|
||||
"form.feed.label.no_media_player": "Kein Media-Player (Audio/Video)",
|
||||
"form.feed.label.ntfy_activate": "Einträge zu ntfy pushen",
|
||||
"form.feed.label.ntfy_activate": "Artikel zu ntfy pushen",
|
||||
"form.feed.label.ntfy_default_priority": "Normale Ntfy-Priorität",
|
||||
"form.feed.label.ntfy_high_priority": "Hohe Ntfy-Priorität",
|
||||
"form.feed.label.ntfy_low_priority": "Niedrige Ntfy-Priorität",
|
||||
@@ -195,7 +196,7 @@
|
||||
"form.feed.label.ntfy_priority": "Ntfy-Priorität",
|
||||
"form.feed.label.ntfy_topic": "Ntfy-Thema (optional)",
|
||||
"form.feed.label.proxy_url": "Proxy-URL",
|
||||
"form.feed.label.pushover_activate": "Einträge an pushover.net senden",
|
||||
"form.feed.label.pushover_activate": "Artikel an pushover.net senden",
|
||||
"form.feed.label.pushover_default_priority": "Pushover-Standardpriorität",
|
||||
"form.feed.label.pushover_high_priority": "Hohe Pushoverpriorität",
|
||||
"form.feed.label.pushover_low_priority": "Niedrige Pushoverpriorität",
|
||||
@@ -211,17 +212,17 @@
|
||||
"form.feed.label.webhook_url": "Webhook-URL überschreiben",
|
||||
"form.import.label.file": "OPML-Datei",
|
||||
"form.import.label.url": "URL",
|
||||
"form.integration.apprise_activate": "Einträge zu Apprise pushen",
|
||||
"form.integration.apprise_activate": "Artikel zu Apprise pushen",
|
||||
"form.integration.apprise_services_url": "Kommaseparierte Liste von Apprise-Dienst-URLs",
|
||||
"form.integration.apprise_url": "Apprise-API-URL",
|
||||
"form.integration.betula_activate": "Einträge in Betula speichern",
|
||||
"form.integration.betula_activate": "Artikel in Betula speichern",
|
||||
"form.integration.betula_token": "Betula-Token",
|
||||
"form.integration.betula_url": "Betula-Server-URL",
|
||||
"form.integration.cubox_activate": "Einträge in Cubox speichern",
|
||||
"form.integration.cubox_activate": "Artikel in Cubox speichern",
|
||||
"form.integration.cubox_api_link": "Cubox-API-Link",
|
||||
"form.integration.discord_activate": "Einträge zu Discord pushen",
|
||||
"form.integration.discord_activate": "Artikel zu Discord pushen",
|
||||
"form.integration.discord_webhook_link": "Discord-Webhook-URL",
|
||||
"form.integration.espial_activate": "Einträge in Espial speichern",
|
||||
"form.integration.espial_activate": "Artikel in Espial speichern",
|
||||
"form.integration.espial_api_key": "Espial-API-Schlüssel",
|
||||
"form.integration.espial_endpoint": "Espial-API-Endpunkt",
|
||||
"form.integration.espial_tags": "Espial-Tags",
|
||||
@@ -233,13 +234,13 @@
|
||||
"form.integration.googlereader_endpoint": "Google-Reader-API-Endpunkt:",
|
||||
"form.integration.googlereader_password": "Google-Reader-Passwort",
|
||||
"form.integration.googlereader_username": "Google-Reader-Benutzername",
|
||||
"form.integration.instapaper_activate": "Einträge in Instapaper speichern",
|
||||
"form.integration.instapaper_activate": "Artikel in Instapaper speichern",
|
||||
"form.integration.instapaper_password": "Instapaper-Passwort",
|
||||
"form.integration.instapaper_username": "Instapaper-Benutzername",
|
||||
"form.integration.karakeep_activate": "Einträge in Karakeep speichern",
|
||||
"form.integration.karakeep_activate": "Artikel in Karakeep speichern",
|
||||
"form.integration.karakeep_api_key": "Karakeep-API-Schlüssel",
|
||||
"form.integration.karakeep_url": "Karakeep-API-Endpunkt",
|
||||
"form.integration.linkace_activate": "Einträge in LinkAce speichern",
|
||||
"form.integration.linkace_activate": "Artikel in LinkAce speichern",
|
||||
"form.integration.linkace_api_key": "LinkAce-API-Schlüssel",
|
||||
"form.integration.linkace_check_disabled": "Linkprüfung deaktivieren",
|
||||
"form.integration.linkace_endpoint": "LinkAce-API-Endpunkt",
|
||||
@@ -250,6 +251,16 @@
|
||||
"form.integration.linkding_bookmark": "Lesezeichen als ungelesen markieren",
|
||||
"form.integration.linkding_endpoint": "Linkding-API-Endpunkt",
|
||||
"form.integration.linkding_tags": "Linkding-Tags",
|
||||
"form.integration.linktaco_activate": "Artikel in LinkTaco speichern",
|
||||
"form.integration.linktaco_api_token": "LinkTaco-API-Token",
|
||||
"form.integration.linktaco_api_token_hint": "Holen Sie sich Ihr persönliches Zugriffstoken unter",
|
||||
"form.integration.linktaco_org_slug": "Organisationstitel",
|
||||
"form.integration.linktaco_tags": "Tags (max. 10, kommagetrennt)",
|
||||
"form.integration.linktaco_tags_hint": "Maximal 10 Tags, kommagetrennt",
|
||||
"form.integration.linktaco_visibility": "Sichtbarkeit",
|
||||
"form.integration.linktaco_visibility_public": "Öffentlich",
|
||||
"form.integration.linktaco_visibility_private": "Privat",
|
||||
"form.integration.linktaco_visibility_hint": "PRIVATE Sichtbarkeit erfordert ein kostenpflichtiges LinkTaco-Konto",
|
||||
"form.integration.linkwarden_activate": "Artikel in Linkwarden speichern",
|
||||
"form.integration.linkwarden_api_key": "Linkwarden-API-Schlüssel",
|
||||
"form.integration.linkwarden_endpoint": "Linkwarden-Base-URL",
|
||||
@@ -258,10 +269,10 @@
|
||||
"form.integration.matrix_bot_password": "Passwort für Matrix-Benutzer",
|
||||
"form.integration.matrix_bot_url": "URL des Matrix-Servers",
|
||||
"form.integration.matrix_bot_user": "Benutzername für Matrix",
|
||||
"form.integration.notion_activate": "Einträge in Notion speichern",
|
||||
"form.integration.notion_activate": "Artikel in Notion speichern",
|
||||
"form.integration.notion_page_id": "Notion-Page-ID",
|
||||
"form.integration.notion_token": "Notion-Geheimnis-Token",
|
||||
"form.integration.ntfy_activate": "Einträge zu ntfy pushen",
|
||||
"form.integration.ntfy_activate": "Artikel zu ntfy pushen",
|
||||
"form.integration.ntfy_api_token": "Ntfy-API-Token (optional)",
|
||||
"form.integration.ntfy_icon_url": "Ntfy-Symbol-URL (optional)",
|
||||
"form.integration.ntfy_internal_links": "Interne Links beim Klicken verwenden (optional)",
|
||||
@@ -272,19 +283,19 @@
|
||||
"form.integration.nunux_keeper_activate": "Artikel in Nunux Keeper speichern",
|
||||
"form.integration.nunux_keeper_api_key": "Nunux-Keeper-API-Schlüssel",
|
||||
"form.integration.nunux_keeper_endpoint": "Nunux-Keeper-API-Endpunkt",
|
||||
"form.integration.omnivore_activate": "Einträge in Omnivore speichern",
|
||||
"form.integration.omnivore_activate": "Artikel in Omnivore speichern",
|
||||
"form.integration.omnivore_api_key": "Omnivore-API-Schlüssel",
|
||||
"form.integration.omnivore_url": "Omnivore-API-Endpunkt",
|
||||
"form.integration.pinboard_activate": "Einträge in Pinboard speichern",
|
||||
"form.integration.pinboard_activate": "Artikel in Pinboard speichern",
|
||||
"form.integration.pinboard_bookmark": "Lesezeichen als ungelesen markieren",
|
||||
"form.integration.pinboard_tags": "Pinboard-Tags",
|
||||
"form.integration.pinboard_token": "Pinboard-API-Token",
|
||||
"form.integration.pushover_activate": "Einträge an Pushover senden",
|
||||
"form.integration.pushover_activate": "Artikel an Pushover senden",
|
||||
"form.integration.pushover_device": "Pushovergerät (optional)",
|
||||
"form.integration.pushover_prefix": "Pushover-URL-Präfix (optional)",
|
||||
"form.integration.pushover_token": "Pushover-Anwendungs-API-Token",
|
||||
"form.integration.pushover_user": "Pushover-Benutzerschlüssel",
|
||||
"form.integration.raindrop_activate": "Einträge in Raindrop speichern",
|
||||
"form.integration.raindrop_activate": "Artikel in Raindrop speichern",
|
||||
"form.integration.raindrop_collection_id": "Sammlungs-ID",
|
||||
"form.integration.raindrop_tags": "Tags (kommagetrennt)",
|
||||
"form.integration.raindrop_token": "(Test-)Token",
|
||||
@@ -293,7 +304,7 @@
|
||||
"form.integration.readeck_endpoint": "Readeck-URL",
|
||||
"form.integration.readeck_labels": "Readeck-Labels",
|
||||
"form.integration.readeck_only_url": "Nur URL senden (anstelle des vollständigen Inhalts)",
|
||||
"form.integration.readwise_activate": "Einträge in Readwise Reader speichern",
|
||||
"form.integration.readwise_activate": "Artikel in Readwise Reader speichern",
|
||||
"form.integration.readwise_api_key": "Readwise-Reader-Zugangstoken",
|
||||
"form.integration.readwise_api_key_link": "Erhalten Sie Ihren Readwise-Zugangstoken",
|
||||
"form.integration.rssbridge_activate": "Beim Hinzufügen von Abonnements RSS-Bridge prüfen.",
|
||||
@@ -306,7 +317,7 @@
|
||||
"form.integration.shiori_endpoint": "Shiori-API-Endpunkt",
|
||||
"form.integration.shiori_password": "Shiori-Passwort",
|
||||
"form.integration.shiori_username": "Shiori-Benutzername",
|
||||
"form.integration.slack_activate": "Einträge zu Slack pushen",
|
||||
"form.integration.slack_activate": "Artikel zu Slack pushen",
|
||||
"form.integration.slack_webhook_link": "Slack-Webhook-URL",
|
||||
"form.integration.telegram_bot_activate": "Schicken Sie neue Artikel in den Telegram-Chat",
|
||||
"form.integration.telegram_bot_disable_buttons": "Schaltfächen deaktivieren",
|
||||
@@ -315,13 +326,14 @@
|
||||
"form.integration.telegram_bot_token": "Bot-Token",
|
||||
"form.integration.telegram_chat_id": "Chat-ID",
|
||||
"form.integration.telegram_topic_id": "Thema-ID",
|
||||
"form.integration.wallabag_activate": "Einträge in Wallabag speichern",
|
||||
"form.integration.wallabag_activate": "Artikel in Wallabag speichern",
|
||||
"form.integration.wallabag_client_id": "Wallabag-Client-ID",
|
||||
"form.integration.wallabag_client_secret": "Wallabag-Client-Geheimnis",
|
||||
"form.integration.wallabag_endpoint": "Wallabag-Basis-URL",
|
||||
"form.integration.wallabag_only_url": "Nur URL senden (anstelle des vollständigen Inhalts)",
|
||||
"form.integration.wallabag_password": "Wallabag-Passwort",
|
||||
"form.integration.wallabag_username": "Wallabag-Benutzername",
|
||||
"form.integration.wallabag_tags": "Wallabag-Tags",
|
||||
"form.integration.webhook_activate": "Webhooks aktivieren",
|
||||
"form.integration.webhook_secret": "Webhook-Geheimnis",
|
||||
"form.integration.webhook_url": "Standard-Webhook-URL",
|
||||
@@ -338,18 +350,18 @@
|
||||
"form.prefs.label.default_home_page": "Standard-Startseite",
|
||||
"form.prefs.label.default_reading_speed": "Lesegeschwindigkeit für andere Sprachen (Wörter pro Minute)",
|
||||
"form.prefs.label.display_mode": "Anzeigemodus der progressiven Web-Anwendung (PWA)",
|
||||
"form.prefs.label.entries_per_page": "Einträge pro Seite",
|
||||
"form.prefs.label.entries_per_page": "Artikel pro Seite",
|
||||
"form.prefs.label.entry_order": "Artikel-Sortierspalte",
|
||||
"form.prefs.label.entry_sorting": "Sortierung der Einträge",
|
||||
"form.prefs.label.entry_swipe": "Aktivieren Sie das Wischen von Einträgen auf Touchscreens",
|
||||
"form.prefs.label.entry_sorting": "Sortierung der Artikel",
|
||||
"form.prefs.label.entry_swipe": "Aktivieren Sie das Wischen von Artikeln auf Touchscreens",
|
||||
"form.prefs.label.external_font_hosts": "Externe Schriftarten-Hosts",
|
||||
"form.prefs.label.gesture_nav": "Geste zum Navigieren zwischen Einträgen",
|
||||
"form.prefs.label.gesture_nav": "Geste zum Navigieren zwischen Artikeln",
|
||||
"form.prefs.label.keyboard_shortcuts": "Tastaturkürzel aktivieren",
|
||||
"form.prefs.label.language": "Sprache",
|
||||
"form.prefs.label.mark_read_manually": "Einträge manuell als gelesen markieren",
|
||||
"form.prefs.label.mark_read_manually": "Artikel manuell als gelesen markieren",
|
||||
"form.prefs.label.mark_read_on_media_completion": "Nur als gelesen markieren, wenn Audio/Video zu 90%% wiedergegeben wurden",
|
||||
"form.prefs.label.mark_read_on_view": "Einträge automatisch als gelesen markieren, wenn sie angezeigt werden",
|
||||
"form.prefs.label.mark_read_on_view_or_media_completion": "Einträge automatisch als gelesen markieren, wenn sie angezeigt werden. Audio/Video bei 90%% Wiedergabe als gelesen markieren",
|
||||
"form.prefs.label.mark_read_on_view": "Artikel automatisch als gelesen markieren, wenn sie angezeigt werden",
|
||||
"form.prefs.label.mark_read_on_view_or_media_completion": "Artikel automatisch als gelesen markieren, wenn sie angezeigt werden. Audio/Video bei 90%% Wiedergabe als gelesen markieren",
|
||||
"form.prefs.label.media_playback_rate": "Wiedergabegeschwindigkeit von Audio/Video",
|
||||
"form.prefs.label.open_external_links_in_new_tab": "Externe Links in einem neuen Tab öffnen (fügt target=\"_blank\" zu Links hinzu)",
|
||||
"form.prefs.label.show_reading_time": "Geschätzte Lesezeit für Artikel anzeigen",
|
||||
@@ -361,9 +373,9 @@
|
||||
"form.prefs.select.fullscreen": "Vollbildschirm",
|
||||
"form.prefs.select.minimal_ui": "Minimal",
|
||||
"form.prefs.select.none": "Keine",
|
||||
"form.prefs.select.older_first": "Ältere Einträge zuerst",
|
||||
"form.prefs.select.older_first": "Ältere Artikel zuerst",
|
||||
"form.prefs.select.publish_time": "Artikel veröffentlicht am",
|
||||
"form.prefs.select.recent_first": "Neue Einträge zuerst",
|
||||
"form.prefs.select.recent_first": "Neue Artikel zuerst",
|
||||
"form.prefs.select.standalone": "Eigenständige",
|
||||
"form.prefs.select.swipe": "Wischen",
|
||||
"form.prefs.select.tap": "Doppeltippen",
|
||||
@@ -404,7 +416,7 @@
|
||||
"menu.show_all_entries": "Zeige alle Artikel",
|
||||
"menu.show_only_starred_entries": "Nur markierte Artikel anzeigen",
|
||||
"menu.show_only_unread_entries": "Nur ungelesene Artikel anzeigen",
|
||||
"menu.starred": "Lesezeichen",
|
||||
"menu.starred": "Markiert",
|
||||
"menu.title": "Menü",
|
||||
"menu.unread": "Ungelesen",
|
||||
"menu.users": "Benutzer",
|
||||
@@ -462,6 +474,7 @@
|
||||
"page.feeds.next_check": "Nächste Aktualisierung:",
|
||||
"page.feeds.read_counter": "Anzahl der gelesenen Artikel",
|
||||
"page.feeds.title": "Abonnements",
|
||||
"page.footer.elevator": "Zurück nach oben",
|
||||
"page.history.title": "Verlauf",
|
||||
"page.import.title": "Importieren",
|
||||
"page.integration.bookmarklet": "Bookmarklet",
|
||||
@@ -487,7 +500,7 @@
|
||||
"page.keyboard_shortcuts.go_to_previous_page": "Zur vorherigen Seite gehen",
|
||||
"page.keyboard_shortcuts.go_to_search": "Fokus auf das Suchformular setzen",
|
||||
"page.keyboard_shortcuts.go_to_settings": "Zu den Einstellungen gehen",
|
||||
"page.keyboard_shortcuts.go_to_starred": "Zu den Lesezeichen gehen",
|
||||
"page.keyboard_shortcuts.go_to_starred": "Zu den markierten Artikeln gehen",
|
||||
"page.keyboard_shortcuts.go_to_top_item": "Zum obersten Artikel gehen",
|
||||
"page.keyboard_shortcuts.go_to_unread": "Zu den ungelesenen Artikeln gehen",
|
||||
"page.keyboard_shortcuts.mark_page_as_read": "Aktuelle Seite als gelesen markieren",
|
||||
@@ -506,7 +519,7 @@
|
||||
"page.keyboard_shortcuts.subtitle.pages": "Navigation zwischen den Seiten",
|
||||
"page.keyboard_shortcuts.subtitle.sections": "Navigation zwischen den Menüpunkten",
|
||||
"page.keyboard_shortcuts.title": "Tastenkürzel",
|
||||
"page.keyboard_shortcuts.toggle_bookmark_status": "Lesezeichen hinzufügen/entfernen",
|
||||
"page.keyboard_shortcuts.toggle_star_status": "Markierung hinzufügen/entfernen",
|
||||
"page.keyboard_shortcuts.toggle_entry_attachments": "Artikelanhänge öffnen/schließen",
|
||||
"page.keyboard_shortcuts.toggle_read_status_next": "Gewählten Artikel als gelesen/ungelesen markieren, nächsten auswählen",
|
||||
"page.keyboard_shortcuts.toggle_read_status_prev": "Gewählten Artikel als gelesen/ungelesen markieren, vorherigen auswählen",
|
||||
@@ -554,10 +567,10 @@
|
||||
"%d geteilter Artikel",
|
||||
"%d geteilte Artikel"
|
||||
],
|
||||
"page.starred.title": "Lesezeichen",
|
||||
"page.starred.title": "Markiert",
|
||||
"page.starred_entry_count": [
|
||||
"%d Lesezeichen",
|
||||
"%d Lesezeichen"
|
||||
"%d markierter Artikel",
|
||||
"%d markierte Artikel"
|
||||
],
|
||||
"page.total_entry_count": [
|
||||
"%d Artikel insgesamt",
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
"alert.account_unlinked": "Ο εξωτερικός σας λογαριασμός είναι πλέον αποσυνδεδεμένος!",
|
||||
"alert.background_feed_refresh": "Όλες οι ροές ανανεώνονται στο παρασκήνιο. Μπορείτε να συνεχίσετε να χρησιμοποιείτε το Miniflux όσο εκτελείται αυτή η διαδικασία.",
|
||||
"alert.feed_error": "Υπάρχει πρόβλημα με αυτήν τη ροή",
|
||||
"alert.no_bookmark": "Δεν υπάρχει σελιδοδείκτης αυτή τη στιγμή.",
|
||||
"alert.no_starred": "Δεν υπάρχει σελιδοδείκτης αυτή τη στιγμή.",
|
||||
"alert.no_category": "Δεν υπάρχει κατηγορία.",
|
||||
"alert.no_category_entry": "Δεν υπάρχουν άρθρα σε αυτήν την κατηγορία.",
|
||||
"alert.no_feed": "Δεν έχετε συνδρομές.",
|
||||
@@ -46,10 +46,10 @@
|
||||
"enclosure_media_controls.speed.reset.title": "Επαναφορά ταχύτητας σε 1x",
|
||||
"enclosure_media_controls.speed.slower": "Πιο αργά",
|
||||
"enclosure_media_controls.speed.slower.title": "Πιο αργά κατά %sx",
|
||||
"entry.bookmark.toast.off": "Μη αγαπημένα",
|
||||
"entry.bookmark.toast.on": "Αγαπημένα",
|
||||
"entry.bookmark.toggle.off": "Αναίρεση αγαπημένου",
|
||||
"entry.bookmark.toggle.on": "Αγαπημένο",
|
||||
"entry.starred.toast.off": "Μη αγαπημένα",
|
||||
"entry.starred.toast.on": "Αγαπημένα",
|
||||
"entry.starred.toggle.off": "Αναίρεση αγαπημένου",
|
||||
"entry.starred.toggle.on": "Αγαπημένο",
|
||||
"entry.comments.label": "Σχόλια",
|
||||
"entry.comments.title": "Δείτε Σχόλια",
|
||||
"entry.estimated_reading_time": [
|
||||
@@ -160,6 +160,7 @@
|
||||
"error.unlink_account_without_password": "Πρέπει να ορίσετε έναν κωδικό πρόσβασης διαφορετικά δεν θα μπορείτε να συνδεθείτε ξανά.",
|
||||
"error.user_already_exists": "Αυτός ο χρήστης υπάρχει ήδη.",
|
||||
"error.user_mandatory_fields": "Το όνομα χρήστη είναι υποχρεωτικό.",
|
||||
"error.linktaco_missing_required_fields": "Το LinkTaco API Token και το Organization Slug είναι απαραίτητα",
|
||||
"form.api_key.label.description": "Ετικέτα κλειδιού API",
|
||||
"form.category.hide_globally": "Απόκρυψη καταχωρήσεων σε γενική λίστα μη αναγνωσμένων",
|
||||
"form.category.label.title": "Τίτλος",
|
||||
@@ -250,6 +251,16 @@
|
||||
"form.integration.linkding_bookmark": "Σημείωση του σελιδοδείκτη ως μη αναγνωσμένου",
|
||||
"form.integration.linkding_endpoint": "Τελικό σημείο Linkding API",
|
||||
"form.integration.linkding_tags": "Ετικέτες Linkding",
|
||||
"form.integration.linktaco_activate": "Αποθήκευση καταχωρήσεων στο LinkTaco",
|
||||
"form.integration.linktaco_api_token": "LinkTaco API Token",
|
||||
"form.integration.linktaco_api_token_hint": "Λάβετε το προσωπικό σας διακριτικό πρόσβασης στο",
|
||||
"form.integration.linktaco_org_slug": "Organization Slug",
|
||||
"form.integration.linktaco_tags": "Ετικέτες (μέγιστο 10, διαχωρισμένες με κόμμα)",
|
||||
"form.integration.linktaco_tags_hint": "Μέγιστο 10 ετικέτες, διαχωρισμένες με κόμμα",
|
||||
"form.integration.linktaco_visibility": "Ορατότητα",
|
||||
"form.integration.linktaco_visibility_public": "Δημόσια",
|
||||
"form.integration.linktaco_visibility_private": "Ιδιωτική",
|
||||
"form.integration.linktaco_visibility_hint": "Η ΙΔΙΩΤΙΚΗ ορατότητα απαιτεί επί πληρωμή λογαριασμό LinkTaco",
|
||||
"form.integration.linkwarden_activate": "Αποθήκευση άρθρων στο Linkwarden",
|
||||
"form.integration.linkwarden_api_key": "Κλειδί API Linkwarden",
|
||||
"form.integration.linkwarden_endpoint": "URL βάσης Linkwarden",
|
||||
@@ -322,6 +333,7 @@
|
||||
"form.integration.wallabag_only_url": "Αποστολή μόνο URL (αντί για πλήρες περιεχόμενο)",
|
||||
"form.integration.wallabag_password": "Wallabag Κωδικός Πρόσβασης",
|
||||
"form.integration.wallabag_username": "Όνομα Χρήστη Wallabag",
|
||||
"form.integration.wallabag_tags": "Wallabag Tags",
|
||||
"form.integration.webhook_activate": "Ενεργοποίηση Webhooks",
|
||||
"form.integration.webhook_secret": "Μυστικό Webhooks",
|
||||
"form.integration.webhook_url": "Προεπιλεγμένη διεύθυνση URL Webhook",
|
||||
@@ -462,6 +474,7 @@
|
||||
"page.feeds.next_check": "Επόμενος έλεγχος:",
|
||||
"page.feeds.read_counter": "Αριθμός αναγνωσμένων καταχωρήσεων",
|
||||
"page.feeds.title": "Ροές",
|
||||
"page.footer.elevator": "Back to top",
|
||||
"page.history.title": "Ιστορικό",
|
||||
"page.import.title": "Εισαγωγή",
|
||||
"page.integration.bookmarklet": "Bookmarklet",
|
||||
@@ -506,7 +519,7 @@
|
||||
"page.keyboard_shortcuts.subtitle.pages": "Πλοήγηση Σελίδων",
|
||||
"page.keyboard_shortcuts.subtitle.sections": "Πλοήγηση Τμημάτων",
|
||||
"page.keyboard_shortcuts.title": "Συντομεύσεις Πληκτρολογίου",
|
||||
"page.keyboard_shortcuts.toggle_bookmark_status": "Εναλλαγή σελιδοδείκτη",
|
||||
"page.keyboard_shortcuts.toggle_star_status": "Εναλλαγή σελιδοδείκτη",
|
||||
"page.keyboard_shortcuts.toggle_entry_attachments": "Εναλλαγή άνοιγμα/κλείσιμο συνημμένων καταχώρησης",
|
||||
"page.keyboard_shortcuts.toggle_read_status_next": "Εναλλαγή ανάγνωσης / μη αναγνωσμένης, εστίαση στη συνέχεια",
|
||||
"page.keyboard_shortcuts.toggle_read_status_prev": "Εναλλαγή ανάγνωσης / μη αναγνωσμένης, εστίαση στο προηγούμενο",
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
"alert.account_unlinked": "Your external account is now dissociated!",
|
||||
"alert.background_feed_refresh": "All feeds are being refreshed in the background. You can continue to use Miniflux while this process is running.",
|
||||
"alert.feed_error": "There is a problem with this feed",
|
||||
"alert.no_bookmark": "There are no starred entries.",
|
||||
"alert.no_starred": "There are no starred entries.",
|
||||
"alert.no_category": "There is no category.",
|
||||
"alert.no_category_entry": "There are no entries in this category.",
|
||||
"alert.no_feed": "You don’t have any feeds.",
|
||||
@@ -46,10 +46,10 @@
|
||||
"enclosure_media_controls.speed.reset.title": "Reset speed to 1x",
|
||||
"enclosure_media_controls.speed.slower": "Slower",
|
||||
"enclosure_media_controls.speed.slower.title": "Slower by %sx",
|
||||
"entry.bookmark.toast.off": "Unstarred",
|
||||
"entry.bookmark.toast.on": "Starred",
|
||||
"entry.bookmark.toggle.off": "Unstar",
|
||||
"entry.bookmark.toggle.on": "Star",
|
||||
"entry.starred.toast.off": "Unstarred",
|
||||
"entry.starred.toast.on": "Starred",
|
||||
"entry.starred.toggle.off": "Unstar",
|
||||
"entry.starred.toggle.on": "Star",
|
||||
"entry.comments.label": "Comments",
|
||||
"entry.comments.title": "View Comments",
|
||||
"entry.estimated_reading_time": [
|
||||
@@ -89,6 +89,7 @@
|
||||
"error.different_passwords": "Passwords are not the same.",
|
||||
"error.duplicate_fever_username": "There is already someone else with the same Fever username!",
|
||||
"error.duplicate_googlereader_username": "There is already someone else with the same Google Reader username!",
|
||||
"error.linktaco_missing_required_fields": "LinkTaco API Token and Organization Slug are required",
|
||||
"error.duplicate_linked_account": "There is already someone associated with this provider!",
|
||||
"error.duplicated_feed": "This feed already exists.",
|
||||
"error.empty_file": "This file is empty.",
|
||||
@@ -250,6 +251,16 @@
|
||||
"form.integration.linkding_bookmark": "Mark bookmark as unread",
|
||||
"form.integration.linkding_endpoint": "Linkding API Endpoint",
|
||||
"form.integration.linkding_tags": "Linkding Tags",
|
||||
"form.integration.linktaco_activate": "Save entries to LinkTaco",
|
||||
"form.integration.linktaco_api_token": "LinkTaco API Token",
|
||||
"form.integration.linktaco_api_token_hint": "Get your personal access token at",
|
||||
"form.integration.linktaco_org_slug": "Organization Slug",
|
||||
"form.integration.linktaco_tags": "Tags (max 10, comma-separated)",
|
||||
"form.integration.linktaco_tags_hint": "Maximum 10 tags, comma-separated",
|
||||
"form.integration.linktaco_visibility": "Visibility",
|
||||
"form.integration.linktaco_visibility_public": "Public",
|
||||
"form.integration.linktaco_visibility_private": "Private",
|
||||
"form.integration.linktaco_visibility_hint": "PRIVATE visibility requires a paid LinkTaco account",
|
||||
"form.integration.linkwarden_activate": "Save entries to Linkwarden",
|
||||
"form.integration.linkwarden_api_key": "Linkwarden API key",
|
||||
"form.integration.linkwarden_endpoint": "Linkwarden Base URL",
|
||||
@@ -322,6 +333,7 @@
|
||||
"form.integration.wallabag_only_url": "Send only URL (instead of full content)",
|
||||
"form.integration.wallabag_password": "Wallabag Password",
|
||||
"form.integration.wallabag_username": "Wallabag Username",
|
||||
"form.integration.wallabag_tags": "Wallabag Tags",
|
||||
"form.integration.webhook_activate": "Enable Webhooks",
|
||||
"form.integration.webhook_secret": "Webhooks Secret",
|
||||
"form.integration.webhook_url": "Default Webhook URL",
|
||||
@@ -462,6 +474,7 @@
|
||||
"page.feeds.next_check": "Next check:",
|
||||
"page.feeds.read_counter": "Number of read entries",
|
||||
"page.feeds.title": "Feeds",
|
||||
"page.footer.elevator": "Back to top",
|
||||
"page.history.title": "History",
|
||||
"page.import.title": "Import",
|
||||
"page.integration.bookmarklet": "Bookmarklet",
|
||||
@@ -506,7 +519,7 @@
|
||||
"page.keyboard_shortcuts.subtitle.pages": "Pages Navigation",
|
||||
"page.keyboard_shortcuts.subtitle.sections": "Sections Navigation",
|
||||
"page.keyboard_shortcuts.title": "Keyboard Shortcuts",
|
||||
"page.keyboard_shortcuts.toggle_bookmark_status": "Toggle starred",
|
||||
"page.keyboard_shortcuts.toggle_star_status": "Toggle starred",
|
||||
"page.keyboard_shortcuts.toggle_entry_attachments": "Toggle open/close entry attachments",
|
||||
"page.keyboard_shortcuts.toggle_read_status_next": "Toggle read/unread, focus next",
|
||||
"page.keyboard_shortcuts.toggle_read_status_prev": "Toggle read/unread, focus previous",
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
"alert.account_unlinked": "¡Tu cuenta externa ya está desvinculada!",
|
||||
"alert.background_feed_refresh": "Todos los feeds se actualizan en segundo plano. Puede continuar usando Miniflux mientras se ejecuta este proceso.",
|
||||
"alert.feed_error": "Hay un problema con esta fuente.",
|
||||
"alert.no_bookmark": "No hay marcador en este momento.",
|
||||
"alert.no_starred": "No hay marcador en este momento.",
|
||||
"alert.no_category": "No hay categoría.",
|
||||
"alert.no_category_entry": "No hay artículos en esta categoría.",
|
||||
"alert.no_feed": "No tienes fuentes.",
|
||||
@@ -46,10 +46,10 @@
|
||||
"enclosure_media_controls.speed.reset.title": "Restablecer la velocidad a 1x",
|
||||
"enclosure_media_controls.speed.slower": "Despacio",
|
||||
"enclosure_media_controls.speed.slower.title": "Más despacio a %sx",
|
||||
"entry.bookmark.toast.off": "Sin estrellas",
|
||||
"entry.bookmark.toast.on": "Sembrado de estrellas",
|
||||
"entry.bookmark.toggle.off": "Desmarcar",
|
||||
"entry.bookmark.toggle.on": "Marcar",
|
||||
"entry.starred.toast.off": "Sin estrellas",
|
||||
"entry.starred.toast.on": "Sembrado de estrellas",
|
||||
"entry.starred.toggle.off": "Desmarcar",
|
||||
"entry.starred.toggle.on": "Marcar",
|
||||
"entry.comments.label": "Comentarios",
|
||||
"entry.comments.title": "Ver comentarios",
|
||||
"entry.estimated_reading_time": [
|
||||
@@ -160,6 +160,7 @@
|
||||
"error.unlink_account_without_password": "Debe definir una contraseña, de lo contrario no podrá volver a iniciar sesión.",
|
||||
"error.user_already_exists": "Este usuario ya existe.",
|
||||
"error.user_mandatory_fields": "El nombre de usuario es obligatorio.",
|
||||
"error.linktaco_missing_required_fields": "LinkTaco API Token y Organization Slug son obligatorios.",
|
||||
"form.api_key.label.description": "Etiqueta de clave API",
|
||||
"form.category.hide_globally": "Ocultar artículos en la lista global de no leídos",
|
||||
"form.category.label.title": "Título",
|
||||
@@ -250,6 +251,16 @@
|
||||
"form.integration.linkding_bookmark": "Marcar marcador como no leído",
|
||||
"form.integration.linkding_endpoint": "Acceso API de Linkding",
|
||||
"form.integration.linkding_tags": "Etiquetas de Linkding",
|
||||
"form.integration.linktaco_activate": "Guardar entradas en LinkTaco",
|
||||
"form.integration.linktaco_api_token": "LinkTaco API Token",
|
||||
"form.integration.linktaco_api_token_hint": "Obtenga su token de acceso personal en",
|
||||
"form.integration.linktaco_org_slug": "Organization Slug",
|
||||
"form.integration.linktaco_tags": "Etiquetas (máx. 10, separadas por comas)",
|
||||
"form.integration.linktaco_tags_hint": "Máximo 10 etiquetas, separadas por comas",
|
||||
"form.integration.linktaco_visibility": "Visibilidad",
|
||||
"form.integration.linktaco_visibility_public": "Público",
|
||||
"form.integration.linktaco_visibility_private": "Privado",
|
||||
"form.integration.linktaco_visibility_hint": "La visibilidad PRIVADA requiere una cuenta de pago de LinkTaco",
|
||||
"form.integration.linkwarden_activate": "Enviar artículos a Linkwarden",
|
||||
"form.integration.linkwarden_api_key": "Clave de API de Linkwarden",
|
||||
"form.integration.linkwarden_endpoint": "URL base de Linkwarden",
|
||||
@@ -322,6 +333,7 @@
|
||||
"form.integration.wallabag_only_url": "Enviar solo URL (en lugar de contenido completo)",
|
||||
"form.integration.wallabag_password": "Contraseña de Wallabag",
|
||||
"form.integration.wallabag_username": "Nombre de usuario de Wallabag",
|
||||
"form.integration.wallabag_tags": "Wallabag Tags",
|
||||
"form.integration.webhook_activate": "Habilitar Webhooks",
|
||||
"form.integration.webhook_secret": "Secreto de Webhooks",
|
||||
"form.integration.webhook_url": "Defecto URL de Webhook",
|
||||
@@ -462,6 +474,7 @@
|
||||
"page.feeds.next_check": "Próxima verificación:",
|
||||
"page.feeds.read_counter": "Número de artículos leídos",
|
||||
"page.feeds.title": "Fuentes",
|
||||
"page.footer.elevator": "Back to top",
|
||||
"page.history.title": "Historial",
|
||||
"page.import.title": "Importar",
|
||||
"page.integration.bookmarklet": "Marcapáginas",
|
||||
@@ -506,7 +519,7 @@
|
||||
"page.keyboard_shortcuts.subtitle.pages": "Navegación de páginas",
|
||||
"page.keyboard_shortcuts.subtitle.sections": "Navegación de secciones",
|
||||
"page.keyboard_shortcuts.title": "Atajos de teclado",
|
||||
"page.keyboard_shortcuts.toggle_bookmark_status": "Agregar o quitar marcador",
|
||||
"page.keyboard_shortcuts.toggle_star_status": "Agregar o quitar marcador",
|
||||
"page.keyboard_shortcuts.toggle_entry_attachments": "Alternar abrir/cerrar adjuntos de la entrada",
|
||||
"page.keyboard_shortcuts.toggle_read_status_next": "Marcar como leído o no leído, enfoque siguiente",
|
||||
"page.keyboard_shortcuts.toggle_read_status_prev": "Marcar como leído o no leído, foco anterior",
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
"alert.account_unlinked": "Ulkoinen tilisi on nyt irrotettu!",
|
||||
"alert.background_feed_refresh": "Kaikki syötteet päivitetään taustalla. Voit jatkaa Minifluxin käyttöä tämän prosessin aikana.",
|
||||
"alert.feed_error": "Tässä syötteessä on ongelma",
|
||||
"alert.no_bookmark": "Tällä hetkellä ei ole kirjanmerkkiä.",
|
||||
"alert.no_starred": "Tällä hetkellä ei ole kirjanmerkkiä.",
|
||||
"alert.no_category": "Ei ole kategoriaa.",
|
||||
"alert.no_category_entry": "Tässä kategoriassa ei ole artikkeleita.",
|
||||
"alert.no_feed": "Sinulla ei ole tilauksia.",
|
||||
@@ -46,10 +46,10 @@
|
||||
"enclosure_media_controls.speed.reset.title": "Palauta nopeus 1x",
|
||||
"enclosure_media_controls.speed.slower": "Hitaammin",
|
||||
"enclosure_media_controls.speed.slower.title": "Hitaampi %sx",
|
||||
"entry.bookmark.toast.off": "Tähdettömät",
|
||||
"entry.bookmark.toast.on": "Tähdellä merkityt",
|
||||
"entry.bookmark.toggle.off": "Poista suosikeista",
|
||||
"entry.bookmark.toggle.on": "Lisää suosikkeihin",
|
||||
"entry.starred.toast.off": "Tähdettömät",
|
||||
"entry.starred.toast.on": "Tähdellä merkityt",
|
||||
"entry.starred.toggle.off": "Poista suosikeista",
|
||||
"entry.starred.toggle.on": "Lisää suosikkeihin",
|
||||
"entry.comments.label": "Kommentit",
|
||||
"entry.comments.title": "Näytä kommentit",
|
||||
"entry.estimated_reading_time": [
|
||||
@@ -160,6 +160,7 @@
|
||||
"error.unlink_account_without_password": "Sinun on määritettävä salasana, muuten et voi kirjautua uudelleen.",
|
||||
"error.user_already_exists": "Käyttäjä on jo olemassa.",
|
||||
"error.user_mandatory_fields": "Käyttäjätunnus on pakollinen.",
|
||||
"error.linktaco_missing_required_fields": "LinkTaco API Token ja Organization Slug vaaditaan",
|
||||
"form.api_key.label.description": "API Key Label",
|
||||
"form.category.hide_globally": "Piilota artikkelit lukemattomien listassa",
|
||||
"form.category.label.title": "Otsikko",
|
||||
@@ -250,6 +251,16 @@
|
||||
"form.integration.linkding_bookmark": "Merkitse kirjanmerkki lukemattomaksi",
|
||||
"form.integration.linkding_endpoint": "Linkding API-päätepiste",
|
||||
"form.integration.linkding_tags": "Linkding Tags",
|
||||
"form.integration.linktaco_activate": "Tallenna kirjoituksia LinkTacoon",
|
||||
"form.integration.linktaco_api_token": "LinkTaco API Token",
|
||||
"form.integration.linktaco_api_token_hint": "Hanki henkilökohtainen pääsytunnistesi osoitteesta",
|
||||
"form.integration.linktaco_org_slug": "Organization Slug",
|
||||
"form.integration.linktaco_tags": "Tagit (enintään 10, pilkuilla erotettu)",
|
||||
"form.integration.linktaco_tags_hint": "Enintään 10 tagia, pilkuilla erotettu",
|
||||
"form.integration.linktaco_visibility": "Näkyvyys",
|
||||
"form.integration.linktaco_visibility_public": "Julkinen",
|
||||
"form.integration.linktaco_visibility_private": "Yksityinen",
|
||||
"form.integration.linktaco_visibility_hint": "YKSITYINEN näkyvyys vaatii maksullisen LinkTaco-tilin",
|
||||
"form.integration.linkwarden_activate": "Tallenna artikkelit Linkkiin",
|
||||
"form.integration.linkwarden_api_key": "Linkwarden API-avain",
|
||||
"form.integration.linkwarden_endpoint": "Linkwarden Base URL",
|
||||
@@ -322,6 +333,7 @@
|
||||
"form.integration.wallabag_only_url": "Lähetä vain URL-osoite (koko sisällön sijaan)",
|
||||
"form.integration.wallabag_password": "Wallabag-salasana",
|
||||
"form.integration.wallabag_username": "Wallabag-käyttäjätunnus",
|
||||
"form.integration.wallabag_tags": "Wallabag Tags",
|
||||
"form.integration.webhook_activate": "Enable Webhooks",
|
||||
"form.integration.webhook_secret": "Webhooks Secret",
|
||||
"form.integration.webhook_url": "Default Webhook URL",
|
||||
@@ -462,6 +474,7 @@
|
||||
"page.feeds.next_check": "Next check:",
|
||||
"page.feeds.read_counter": "Luettujen artikkeleiden määrä",
|
||||
"page.feeds.title": "Syötteet",
|
||||
"page.footer.elevator": "Back to top",
|
||||
"page.history.title": "Historia",
|
||||
"page.import.title": "Tuo",
|
||||
"page.integration.bookmarklet": "Sovelluskirjanmerkki",
|
||||
@@ -506,7 +519,7 @@
|
||||
"page.keyboard_shortcuts.subtitle.pages": "Sivujen navigointi",
|
||||
"page.keyboard_shortcuts.subtitle.sections": "Osion navigointi",
|
||||
"page.keyboard_shortcuts.title": "Pikanäppäimet",
|
||||
"page.keyboard_shortcuts.toggle_bookmark_status": "Vaihda kirjanmerkki",
|
||||
"page.keyboard_shortcuts.toggle_star_status": "Vaihda kirjanmerkki",
|
||||
"page.keyboard_shortcuts.toggle_entry_attachments": "Toggle open/close entry attachments",
|
||||
"page.keyboard_shortcuts.toggle_read_status_next": "Vaihda luettu/lukematon, keskity seuraavaksi",
|
||||
"page.keyboard_shortcuts.toggle_read_status_prev": "Vaihda luettu/lukematon, keskity edelliseen",
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
"alert.account_unlinked": "Votre compte externe est maintenant dissocié !",
|
||||
"alert.background_feed_refresh": "Les abonnements sont en cours d'actualisation en arrière-plan. Vous pouvez continuer à naviguer dans l'application.",
|
||||
"alert.feed_error": "Il y a un problème avec cet abonnement",
|
||||
"alert.no_bookmark": "Il n'y a aucun favoris pour le moment.",
|
||||
"alert.no_starred": "Il n'y a aucun favoris pour le moment.",
|
||||
"alert.no_category": "Il n'y a aucune catégorie.",
|
||||
"alert.no_category_entry": "Il n'y a aucun article dans cette catégorie.",
|
||||
"alert.no_feed": "Vous n'avez aucun abonnement.",
|
||||
@@ -46,10 +46,10 @@
|
||||
"enclosure_media_controls.speed.reset.title": "Réinitialiser la vitesse de lecture à 1x",
|
||||
"enclosure_media_controls.speed.slower": "Ralentir",
|
||||
"enclosure_media_controls.speed.slower.title": "Ralentir de %sx",
|
||||
"entry.bookmark.toast.off": "Enlevé des favoris",
|
||||
"entry.bookmark.toast.on": "Ajouté aux favoris",
|
||||
"entry.bookmark.toggle.off": "Enlever favoris",
|
||||
"entry.bookmark.toggle.on": "Favoris",
|
||||
"entry.starred.toast.off": "Enlevé des favoris",
|
||||
"entry.starred.toast.on": "Ajouté aux favoris",
|
||||
"entry.starred.toggle.off": "Enlever favoris",
|
||||
"entry.starred.toggle.on": "Favoris",
|
||||
"entry.comments.label": "Commentaires",
|
||||
"entry.comments.title": "Voir les commentaires",
|
||||
"entry.estimated_reading_time": [
|
||||
@@ -160,6 +160,7 @@
|
||||
"error.unlink_account_without_password": "Vous devez définir un mot de passe sinon vous ne pourrez plus vous connecter par la suite.",
|
||||
"error.user_already_exists": "Cet utilisateur existe déjà.",
|
||||
"error.user_mandatory_fields": "Le nom d'utilisateur est obligatoire.",
|
||||
"error.linktaco_missing_required_fields": "Le token API LinkTaco et le slug de l'organisation sont requis.",
|
||||
"form.api_key.label.description": "Libellé de la clé d'API",
|
||||
"form.category.hide_globally": "Masquer les entrées dans la liste globale non lue",
|
||||
"form.category.label.title": "Titre",
|
||||
@@ -250,6 +251,16 @@
|
||||
"form.integration.linkding_bookmark": "Marquer le lien comme non lu",
|
||||
"form.integration.linkding_endpoint": "URL de l'API de Linkding",
|
||||
"form.integration.linkding_tags": "Libellés",
|
||||
"form.integration.linktaco_activate": "Sauvegarder les entrées vers LinkTaco",
|
||||
"form.integration.linktaco_api_token": "Token API LinkTaco",
|
||||
"form.integration.linktaco_api_token_hint": "Obtenez votre token d'accès personnel sur",
|
||||
"form.integration.linktaco_org_slug": "Slug de l'organisation",
|
||||
"form.integration.linktaco_tags": "Tags (max. 10, séparés par des virgules)",
|
||||
"form.integration.linktaco_tags_hint": "Maximum 10 tags, séparés par des virgules",
|
||||
"form.integration.linktaco_visibility": "Visibilité",
|
||||
"form.integration.linktaco_visibility_public": "Public",
|
||||
"form.integration.linktaco_visibility_private": "Privé",
|
||||
"form.integration.linktaco_visibility_hint": "La visibilité PRIVÉE nécessite un compte LinkTaco payant",
|
||||
"form.integration.linkwarden_activate": "Sauvegarder les articles vers Linkwarden",
|
||||
"form.integration.linkwarden_api_key": "Clé d'API de Linkwarden",
|
||||
"form.integration.linkwarden_endpoint": "URL de base de Linkwarden",
|
||||
@@ -322,6 +333,7 @@
|
||||
"form.integration.wallabag_only_url": "Envoyer uniquement l'URL (au lieu du contenu complet)",
|
||||
"form.integration.wallabag_password": "Mot de passe de Wallabag",
|
||||
"form.integration.wallabag_username": "Nom d'utilisateur de Wallabag",
|
||||
"form.integration.wallabag_tags": "Libellés Wallabag",
|
||||
"form.integration.webhook_activate": "Activer le webhook",
|
||||
"form.integration.webhook_secret": "Secret du webhook",
|
||||
"form.integration.webhook_url": "URL du webhook",
|
||||
@@ -462,6 +474,7 @@
|
||||
"page.feeds.next_check": "Prochaine vérification :",
|
||||
"page.feeds.read_counter": "Nombre d'entrées lues",
|
||||
"page.feeds.title": "Abonnements",
|
||||
"page.footer.elevator": "Retour en haut",
|
||||
"page.history.title": "Historique",
|
||||
"page.import.title": "Importation",
|
||||
"page.integration.bookmarklet": "Bookmarklet",
|
||||
@@ -506,7 +519,7 @@
|
||||
"page.keyboard_shortcuts.subtitle.pages": "Navigation entre les pages",
|
||||
"page.keyboard_shortcuts.subtitle.sections": "Navigation entre les sections",
|
||||
"page.keyboard_shortcuts.title": "Raccourcis clavier",
|
||||
"page.keyboard_shortcuts.toggle_bookmark_status": "Ajouter/Enlever favoris",
|
||||
"page.keyboard_shortcuts.toggle_star_status": "Ajouter/Enlever favoris",
|
||||
"page.keyboard_shortcuts.toggle_entry_attachments": "Ouvrir/Fermer les pièces jointes de l'entrée",
|
||||
"page.keyboard_shortcuts.toggle_read_status_next": "Basculer entre lu/non lu, et changer le focus sur l'élément suivant",
|
||||
"page.keyboard_shortcuts.toggle_read_status_prev": "Basculer entre lu/non lu, et changer le focus sur l'élément précédent",
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
"alert.account_unlinked": "आपका बाहरी खाता अब अलग कर दिया गया है!",
|
||||
"alert.background_feed_refresh": "सभी फ़ीड्स पृष्ठभूमि में ताज़ा की जा रही हैं। जब यह प्रक्रिया चल रही हो, तो आप मिनीफ्लक्स का उपयोग जारी रख सकते हैं।",
|
||||
"alert.feed_error": "इस फ़ीड में एक समस्या है",
|
||||
"alert.no_bookmark": "इस समय कोई बुकमार्क नहीं है",
|
||||
"alert.no_starred": "इस समय कोई बुकमार्क नहीं है",
|
||||
"alert.no_category": "कोई श्रेणी नहीं है।",
|
||||
"alert.no_category_entry": "इस श्रेणी में कोई विषय-वस्तु नहीं है।",
|
||||
"alert.no_feed": "आपके पास कोई सदस्यता नहीं है।",
|
||||
@@ -46,10 +46,10 @@
|
||||
"enclosure_media_controls.speed.reset.title": "गति 1x पर रीसेट करें",
|
||||
"enclosure_media_controls.speed.slower": "धीमा",
|
||||
"enclosure_media_controls.speed.slower.title": "%sx गुना धीमा",
|
||||
"entry.bookmark.toast.off": "तारांकित न करे",
|
||||
"entry.bookmark.toast.on": "तारांकित",
|
||||
"entry.bookmark.toggle.off": "सितारा हटा दो",
|
||||
"entry.bookmark.toggle.on": "सितारा दे",
|
||||
"entry.starred.toast.off": "तारांकित न करे",
|
||||
"entry.starred.toast.on": "तारांकित",
|
||||
"entry.starred.toggle.off": "सितारा हटा दो",
|
||||
"entry.starred.toggle.on": "सितारा दे",
|
||||
"entry.comments.label": "टिप्पणियाँ",
|
||||
"entry.comments.title": "टिप्पणियाँ देखे",
|
||||
"entry.estimated_reading_time": [
|
||||
@@ -160,6 +160,7 @@
|
||||
"error.unlink_account_without_password": "आपको एक पासवर्ड परिभाषित करना होगा अन्यथा आप फिर से लॉगिन नहीं कर पाएंगे।",
|
||||
"error.user_already_exists": "यह उपयोगकर्ता पहले से ही मौजूद है।",
|
||||
"error.user_mandatory_fields": "उपयोगकर्ता नाम अनिवार्य है।",
|
||||
"error.linktaco_missing_required_fields": "LinkTaco API Token और Organization Slug आवश्यक हैं",
|
||||
"form.api_key.label.description": "एपीआई कुंजी लेबल",
|
||||
"form.category.hide_globally": "वैश्विक अपठित सूची में प्रविष्टियां छिपाएं",
|
||||
"form.category.label.title": "शीर्षक",
|
||||
@@ -250,6 +251,16 @@
|
||||
"form.integration.linkding_bookmark": "बुकमार्क को अपठित के रूप में चिह्नित करें",
|
||||
"form.integration.linkding_endpoint": "लिंकिंग एपीआई समापन बिंदु",
|
||||
"form.integration.linkding_tags": "Linkding Tags",
|
||||
"form.integration.linktaco_activate": "LinkTaco में प्रविष्टियाँ सहेजें",
|
||||
"form.integration.linktaco_api_token": "LinkTaco API Token",
|
||||
"form.integration.linktaco_api_token_hint": "अपना व्यक्तिगत पहुँच टोकन प्राप्त करें",
|
||||
"form.integration.linktaco_org_slug": "Organization Slug",
|
||||
"form.integration.linktaco_tags": "टैग (अधिकतम 10, कॉमा से अलग किए गए)",
|
||||
"form.integration.linktaco_tags_hint": "अधिकतम 10 टैग, कॉमा से अलग किए गए",
|
||||
"form.integration.linktaco_visibility": "दृश्यता",
|
||||
"form.integration.linktaco_visibility_public": "सार्वजनिक",
|
||||
"form.integration.linktaco_visibility_private": "निजी",
|
||||
"form.integration.linktaco_visibility_hint": "निजी दृश्यता के लिए भुगतान LinkTaco खाता आवश्यक है",
|
||||
"form.integration.linkwarden_activate": "Save entries to Linkwarden",
|
||||
"form.integration.linkwarden_api_key": "Linkwarden API key",
|
||||
"form.integration.linkwarden_endpoint": "लिंकवर्डन बेस यूआरएलL",
|
||||
@@ -322,6 +333,7 @@
|
||||
"form.integration.wallabag_only_url": "केवल URL भेजें (पूर्ण सामग्री के बजाय)",
|
||||
"form.integration.wallabag_password": "वालाबैग पासवर्ड",
|
||||
"form.integration.wallabag_username": "वालाबैग उपयोगकर्ता नाम",
|
||||
"form.integration.wallabag_tags": "Wallabag Tags",
|
||||
"form.integration.webhook_activate": "Enable Webhooks",
|
||||
"form.integration.webhook_secret": "Webhooks Secret",
|
||||
"form.integration.webhook_url": "Default Webhook URL",
|
||||
@@ -462,6 +474,7 @@
|
||||
"page.feeds.next_check": "Next check:",
|
||||
"page.feeds.read_counter": "पड़े हुए विषयवस्तुया",
|
||||
"page.feeds.title": "फ़ीड",
|
||||
"page.footer.elevator": "Back to top",
|
||||
"page.history.title": "इतिहास",
|
||||
"page.import.title": "आयात",
|
||||
"page.integration.bookmarklet": "बुकमार्कलेट",
|
||||
@@ -506,7 +519,7 @@
|
||||
"page.keyboard_shortcuts.subtitle.pages": "पेज नेविगेशन",
|
||||
"page.keyboard_shortcuts.subtitle.sections": "अनुभाग नेविगेशन",
|
||||
"page.keyboard_shortcuts.title": "कुंजीपटल अल्प मार्ग",
|
||||
"page.keyboard_shortcuts.toggle_bookmark_status": "बुकमार्क टॉगल करें",
|
||||
"page.keyboard_shortcuts.toggle_star_status": "बुकमार्क टॉगल करें",
|
||||
"page.keyboard_shortcuts.toggle_entry_attachments": "Toggle open/close entry attachments",
|
||||
"page.keyboard_shortcuts.toggle_read_status_next": "पढ़ें/अपठित टॉगल करें, अगला फ़ोकस करें",
|
||||
"page.keyboard_shortcuts.toggle_read_status_prev": "पढ़ें/अपठित टॉगल करें, पिछला फ़ोकस करें",
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
"alert.account_unlinked": "Akun eksternal Anda sudah terputus!",
|
||||
"alert.background_feed_refresh": "Semua umpan sedang disegarkan di latar belakang. Anda bisa lanjut menggunakan Miniflux sembari proses ini berlanjut.",
|
||||
"alert.feed_error": "Ada masalah dengan umpan ini",
|
||||
"alert.no_bookmark": "Tidak ada markah.",
|
||||
"alert.no_starred": "Tidak ada markah.",
|
||||
"alert.no_category": "Tidak ada kategori.",
|
||||
"alert.no_category_entry": "Tidak ada artikel di kategori ini.",
|
||||
"alert.no_feed": "Anda tidak memiliki langganan.",
|
||||
@@ -45,10 +45,10 @@
|
||||
"enclosure_media_controls.speed.reset.title": "Atur ulang ke 1x",
|
||||
"enclosure_media_controls.speed.slower": "Lebih lambat",
|
||||
"enclosure_media_controls.speed.slower.title": "Lebih lambat %sx",
|
||||
"entry.bookmark.toast.off": "Batal Markahi",
|
||||
"entry.bookmark.toast.on": "Markahi",
|
||||
"entry.bookmark.toggle.off": "Batal Markahi",
|
||||
"entry.bookmark.toggle.on": "Markahi",
|
||||
"entry.starred.toast.off": "Batal Markahi",
|
||||
"entry.starred.toast.on": "Markahi",
|
||||
"entry.starred.toggle.off": "Batal Markahi",
|
||||
"entry.starred.toggle.on": "Markahi",
|
||||
"entry.comments.label": "Komentar",
|
||||
"entry.comments.title": "Lihat Komentar",
|
||||
"entry.estimated_reading_time": [
|
||||
@@ -157,6 +157,7 @@
|
||||
"error.unlink_account_without_password": "Anda harus mengatur kata sandi atau Anda tidak bisa masuk kembali.",
|
||||
"error.user_already_exists": "Pengguna ini sudah ada.",
|
||||
"error.user_mandatory_fields": "Harus ada nama pengguna.",
|
||||
"error.linktaco_missing_required_fields": "LinkTaco API Token dan Organization Slug diperlukan",
|
||||
"form.api_key.label.description": "Label Kunci API",
|
||||
"form.category.hide_globally": "Sembunyikan entri di daftar belum dibaca global",
|
||||
"form.category.label.title": "Judul",
|
||||
@@ -247,6 +248,16 @@
|
||||
"form.integration.linkding_bookmark": "Tandai markah sebagai belum dibaca",
|
||||
"form.integration.linkding_endpoint": "Titik URL API Linkding",
|
||||
"form.integration.linkding_tags": "Tanda Linkding",
|
||||
"form.integration.linktaco_activate": "Simpan entri ke LinkTaco",
|
||||
"form.integration.linktaco_api_token": "LinkTaco API Token",
|
||||
"form.integration.linktaco_api_token_hint": "Dapatkan token akses pribadi Anda di",
|
||||
"form.integration.linktaco_org_slug": "Organization Slug",
|
||||
"form.integration.linktaco_tags": "Tag (maksimal 10, dipisahkan koma)",
|
||||
"form.integration.linktaco_tags_hint": "Maksimal 10 tag, dipisahkan koma",
|
||||
"form.integration.linktaco_visibility": "Visibilitas",
|
||||
"form.integration.linktaco_visibility_public": "Publik",
|
||||
"form.integration.linktaco_visibility_private": "Pribadi",
|
||||
"form.integration.linktaco_visibility_hint": "Visibilitas PRIBADI membutuhkan akun LinkTaco berbayar",
|
||||
"form.integration.linkwarden_activate": "Simpan artikel ke Linkwarden",
|
||||
"form.integration.linkwarden_api_key": "Kunci API Linkwarden",
|
||||
"form.integration.linkwarden_endpoint": "URL Dasar Linkwarden",
|
||||
@@ -319,6 +330,7 @@
|
||||
"form.integration.wallabag_only_url": "Kirim hanya URL (alih-alih konten penuh)",
|
||||
"form.integration.wallabag_password": "Kata Sandi Wallabag",
|
||||
"form.integration.wallabag_username": "Nama Pengguna Wallabag",
|
||||
"form.integration.wallabag_tags": "Wallabag Tags",
|
||||
"form.integration.webhook_activate": "Aktifkan Webhook",
|
||||
"form.integration.webhook_secret": "Rahasia Webhook",
|
||||
"form.integration.webhook_url": "URL Webhook baku",
|
||||
@@ -456,6 +468,7 @@
|
||||
"page.feeds.next_check": "Akan diperiksa kembali:",
|
||||
"page.feeds.read_counter": "Jumlah entri yang telah dibaca",
|
||||
"page.feeds.title": "Umpan",
|
||||
"page.footer.elevator": "Back to top",
|
||||
"page.history.title": "Riwayat",
|
||||
"page.import.title": "Impor",
|
||||
"page.integration.bookmarklet": "Bookmarklet",
|
||||
@@ -500,7 +513,7 @@
|
||||
"page.keyboard_shortcuts.subtitle.pages": "Navigasi Halaman",
|
||||
"page.keyboard_shortcuts.subtitle.sections": "Navigasi Bagian",
|
||||
"page.keyboard_shortcuts.title": "Pintasan Papan Tik",
|
||||
"page.keyboard_shortcuts.toggle_bookmark_status": "Ubah status markah",
|
||||
"page.keyboard_shortcuts.toggle_star_status": "Ubah status markah",
|
||||
"page.keyboard_shortcuts.toggle_entry_attachments": "Buka/tutup lampiran entri",
|
||||
"page.keyboard_shortcuts.toggle_read_status_next": "Ubah status baca, fokus ke selanjutnya",
|
||||
"page.keyboard_shortcuts.toggle_read_status_prev": "Ubah status baca, fokus ke sebelumnya",
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
"alert.account_unlinked": "Il tuo account esterno ora è scollegato!",
|
||||
"alert.background_feed_refresh": "Tutti i feed vengono aggiornati in background. Puoi continuare a usare Miniflux mentre questo processo è in esecuzione.",
|
||||
"alert.feed_error": "Sembra ci sia un problema con questo feed",
|
||||
"alert.no_bookmark": "Nessun preferito disponibile.",
|
||||
"alert.no_starred": "Nessun preferito disponibile.",
|
||||
"alert.no_category": "Nessuna categoria disponibile.",
|
||||
"alert.no_category_entry": "Questa categoria non contiene alcun articolo.",
|
||||
"alert.no_feed": "Nessun feed disponibile.",
|
||||
@@ -46,10 +46,10 @@
|
||||
"enclosure_media_controls.speed.reset.title": "Reimposta velocità a 1x",
|
||||
"enclosure_media_controls.speed.slower": "Più lento",
|
||||
"enclosure_media_controls.speed.slower.title": "Più lento di %sx",
|
||||
"entry.bookmark.toast.off": "Non preferito",
|
||||
"entry.bookmark.toast.on": "Preferito",
|
||||
"entry.bookmark.toggle.off": "Rimuovi dai preferiti",
|
||||
"entry.bookmark.toggle.on": "Aggiungi ai preferiti",
|
||||
"entry.starred.toast.off": "Non preferito",
|
||||
"entry.starred.toast.on": "Preferito",
|
||||
"entry.starred.toggle.off": "Rimuovi dai preferiti",
|
||||
"entry.starred.toggle.on": "Aggiungi ai preferiti",
|
||||
"entry.comments.label": "Commenti",
|
||||
"entry.comments.title": "Mostra i commenti",
|
||||
"entry.estimated_reading_time": [
|
||||
@@ -160,6 +160,7 @@
|
||||
"error.unlink_account_without_password": "Devi scegliere una password altrimenti la prossima volta non riuscirai ad accedere.",
|
||||
"error.user_already_exists": "Questo utente esiste già.",
|
||||
"error.user_mandatory_fields": "Il nome utente è obbligatorio.",
|
||||
"error.linktaco_missing_required_fields": "LinkTaco API Token e Organization Slug sono richiesti",
|
||||
"form.api_key.label.description": "Etichetta chiave API",
|
||||
"form.category.hide_globally": "Nascondere le voci nella lista globale dei non letti",
|
||||
"form.category.label.title": "Titolo",
|
||||
@@ -250,6 +251,16 @@
|
||||
"form.integration.linkding_bookmark": "Segna i preferiti come non letti",
|
||||
"form.integration.linkding_endpoint": "Endpoint dell'API di Linkding",
|
||||
"form.integration.linkding_tags": "Linkding Tags",
|
||||
"form.integration.linktaco_activate": "Salva le voci in LinkTaco",
|
||||
"form.integration.linktaco_api_token": "LinkTaco API Token",
|
||||
"form.integration.linktaco_api_token_hint": "Ottieni il tuo token di accesso personale su",
|
||||
"form.integration.linktaco_org_slug": "Organization Slug",
|
||||
"form.integration.linktaco_tags": "Tag (massimo 10, separati da virgola)",
|
||||
"form.integration.linktaco_tags_hint": "Massimo 10 tag, separati da virgola",
|
||||
"form.integration.linktaco_visibility": "Visibilità",
|
||||
"form.integration.linktaco_visibility_public": "Pubblico",
|
||||
"form.integration.linktaco_visibility_private": "Privato",
|
||||
"form.integration.linktaco_visibility_hint": "La visibilità PRIVATA richiede un account LinkTaco a pagamento",
|
||||
"form.integration.linkwarden_activate": "Salva gli articoli su Linkwarden",
|
||||
"form.integration.linkwarden_api_key": "API key dell'account Linkwarden",
|
||||
"form.integration.linkwarden_endpoint": "URL di base di Linkwarden",
|
||||
@@ -322,6 +333,7 @@
|
||||
"form.integration.wallabag_only_url": "Invia solo URL (invece del contenuto completo)",
|
||||
"form.integration.wallabag_password": "Password dell'account Wallabag",
|
||||
"form.integration.wallabag_username": "Nome utente dell'account Wallabag",
|
||||
"form.integration.wallabag_tags": "Wallabag Tags",
|
||||
"form.integration.webhook_activate": "Enable Webhooks",
|
||||
"form.integration.webhook_secret": "Webhooks Secret",
|
||||
"form.integration.webhook_url": "Default Webhook URL",
|
||||
@@ -462,6 +474,7 @@
|
||||
"page.feeds.next_check": "Next check:",
|
||||
"page.feeds.read_counter": "Numero di voci lette",
|
||||
"page.feeds.title": "Feed",
|
||||
"page.footer.elevator": "Back to top",
|
||||
"page.history.title": "Cronologia",
|
||||
"page.import.title": "Importa",
|
||||
"page.integration.bookmarklet": "Segnalibro",
|
||||
@@ -506,7 +519,7 @@
|
||||
"page.keyboard_shortcuts.subtitle.pages": "Navigazione pagine",
|
||||
"page.keyboard_shortcuts.subtitle.sections": "Navigazione sezioni",
|
||||
"page.keyboard_shortcuts.title": "Scorciatoie da tastiera",
|
||||
"page.keyboard_shortcuts.toggle_bookmark_status": "Aggiungi/rimuovi dai preferiti",
|
||||
"page.keyboard_shortcuts.toggle_star_status": "Aggiungi/rimuovi dai preferiti",
|
||||
"page.keyboard_shortcuts.toggle_entry_attachments": "Toggle open/close entry attachments",
|
||||
"page.keyboard_shortcuts.toggle_read_status_next": "Cambia lo stato di lettura (letto/da leggere), concentrati dopo",
|
||||
"page.keyboard_shortcuts.toggle_read_status_prev": "Cambia lo stato di lettura (letto/da leggere), focus precedente",
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
"alert.account_unlinked": "外部アカウントとのリンクが解除されました!",
|
||||
"alert.background_feed_refresh": "すべてのフィードがバックグラウンドで更新されています。この処理中も Miniflux を使い続けることができます。",
|
||||
"alert.feed_error": "このフィードには問題があります。",
|
||||
"alert.no_bookmark": "現在星付きはありません。",
|
||||
"alert.no_starred": "現在星付きはありません。",
|
||||
"alert.no_category": "カテゴリが存在しません。",
|
||||
"alert.no_category_entry": "このカテゴリには記事がありません。",
|
||||
"alert.no_feed": "何も購読していません。",
|
||||
@@ -45,10 +45,10 @@
|
||||
"enclosure_media_controls.speed.reset.title": "速度を1xにリセット",
|
||||
"enclosure_media_controls.speed.slower": "遅く",
|
||||
"enclosure_media_controls.speed.slower.title": "%sx 遅く",
|
||||
"entry.bookmark.toast.off": "星を外しました",
|
||||
"entry.bookmark.toast.on": "星を付けました",
|
||||
"entry.bookmark.toggle.off": "星を外す",
|
||||
"entry.bookmark.toggle.on": "星を付ける",
|
||||
"entry.starred.toast.off": "星を外しました",
|
||||
"entry.starred.toast.on": "星を付けました",
|
||||
"entry.starred.toggle.off": "星を外す",
|
||||
"entry.starred.toggle.on": "星を付ける",
|
||||
"entry.comments.label": "コメント",
|
||||
"entry.comments.title": "コメントを見る",
|
||||
"entry.estimated_reading_time": [
|
||||
@@ -157,6 +157,7 @@
|
||||
"error.unlink_account_without_password": "パスワードを設定しなければ再びログインすることはできません。",
|
||||
"error.user_already_exists": "このユーザーは既に存在します。",
|
||||
"error.user_mandatory_fields": "ユーザー名が必要です。",
|
||||
"error.linktaco_missing_required_fields": "LinkTaco API TokenとOrganization Slugが必要です",
|
||||
"form.api_key.label.description": "API キーラベル",
|
||||
"form.category.hide_globally": "未読一覧に記事を表示しない",
|
||||
"form.category.label.title": "タイトル",
|
||||
@@ -247,6 +248,16 @@
|
||||
"form.integration.linkding_bookmark": "ブックマークを未読にする",
|
||||
"form.integration.linkding_endpoint": "Linkding の API Endpoint",
|
||||
"form.integration.linkding_tags": "Linkding Tags",
|
||||
"form.integration.linktaco_activate": "LinkTacoでエントリを保存する",
|
||||
"form.integration.linktaco_api_token": "LinkTaco API Token",
|
||||
"form.integration.linktaco_api_token_hint": "パーソナルアクセストークンを取得する",
|
||||
"form.integration.linktaco_org_slug": "Organization Slug",
|
||||
"form.integration.linktaco_tags": "タグ (最大10件、カンマ区切り)",
|
||||
"form.integration.linktaco_tags_hint": "最大10件のタグ、カンマ区切り",
|
||||
"form.integration.linktaco_visibility": "公開設定",
|
||||
"form.integration.linktaco_visibility_public": "公開",
|
||||
"form.integration.linktaco_visibility_private": "非公開",
|
||||
"form.integration.linktaco_visibility_hint": "非公開設定には有料のLinkTacoアカウントが必要です",
|
||||
"form.integration.linkwarden_activate": "Linkwarden に記事を保存する",
|
||||
"form.integration.linkwarden_api_key": "Linkwarden の API key",
|
||||
"form.integration.linkwarden_endpoint": "リンクワーデン ベース URL",
|
||||
@@ -319,6 +330,7 @@
|
||||
"form.integration.wallabag_only_url": "URL のみを送信 (完全なコンテンツではなく)",
|
||||
"form.integration.wallabag_password": "Wallabag のパスワード",
|
||||
"form.integration.wallabag_username": "Wallabag のユーザー名",
|
||||
"form.integration.wallabag_tags": "Wallabag Tags",
|
||||
"form.integration.webhook_activate": "Enable Webhooks",
|
||||
"form.integration.webhook_secret": "Webhooks Secret",
|
||||
"form.integration.webhook_url": "Default Webhook URL",
|
||||
@@ -456,6 +468,7 @@
|
||||
"page.feeds.next_check": "Next check:",
|
||||
"page.feeds.read_counter": "既読記事の数",
|
||||
"page.feeds.title": "フィード一覧",
|
||||
"page.footer.elevator": "Back to top",
|
||||
"page.history.title": "履歴",
|
||||
"page.import.title": "インポート",
|
||||
"page.integration.bookmarklet": "ブックマークレット",
|
||||
@@ -500,7 +513,7 @@
|
||||
"page.keyboard_shortcuts.subtitle.pages": "ページ間を移動する",
|
||||
"page.keyboard_shortcuts.subtitle.sections": "セクションを移動する",
|
||||
"page.keyboard_shortcuts.title": "キーボードショートカット",
|
||||
"page.keyboard_shortcuts.toggle_bookmark_status": "星を付ける/外す",
|
||||
"page.keyboard_shortcuts.toggle_star_status": "星を付ける/外す",
|
||||
"page.keyboard_shortcuts.toggle_entry_attachments": "添付ファイルを開く/閉じる",
|
||||
"page.keyboard_shortcuts.toggle_read_status_next": "既読/未読を切り替えて次のアイテムに移動",
|
||||
"page.keyboard_shortcuts.toggle_read_status_prev": "既読/未読を切り替えて前のアイテムに移動",
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
"alert.account_unlinked": "Kah lí ê gōa-pō͘ kháu-chō ê kiat í-keng phah khui--ah!",
|
||||
"alert.background_feed_refresh": "Tng leh pōe-āu ōaⁿ-sin só͘-ū siau-sit lâi-goân, lí ē-sái kè-sio̍k sú-iōng Miniflux。",
|
||||
"alert.feed_error": "Chit ê siau-sit lâi-goân ū būn-tôe",
|
||||
"alert.no_bookmark": "Chit-má ah bô siu-chông",
|
||||
"alert.no_starred": "Chit-má ah bô siu-chông",
|
||||
"alert.no_category": "Chit-má ah bô lūi-pia̍t",
|
||||
"alert.no_category_entry": "Chit ê lūi-pah ah bô siau-sit",
|
||||
"alert.no_feed": "Chit-má ah bô siau-sit lâi-goân",
|
||||
@@ -45,10 +45,10 @@
|
||||
"enclosure_media_controls.speed.reset.title": "Têng siat-tēng pàng ê sok-tō͘ chòe 1x",
|
||||
"enclosure_media_controls.speed.slower": "Pàng bān",
|
||||
"enclosure_media_controls.speed.slower.title": "Pàng bān %sx",
|
||||
"entry.bookmark.toast.off": "Chhú-siau siu-chông chòe soah",
|
||||
"entry.bookmark.toast.on": "Sin cheng-ka siu-chông chòe soah",
|
||||
"entry.bookmark.toggle.off": "Chhú-siau siu-chông",
|
||||
"entry.bookmark.toggle.on": "Siu-chông khí-lâi",
|
||||
"entry.starred.toast.off": "Chhú-siau siu-chông chòe soah",
|
||||
"entry.starred.toast.on": "Sin cheng-ka siu-chông chòe soah",
|
||||
"entry.starred.toggle.off": "Chhú-siau siu-chông",
|
||||
"entry.starred.toggle.on": "Siu-chông khí-lâi",
|
||||
"entry.comments.label": "Hôe-èng",
|
||||
"entry.comments.title": "Khòaⁿ hôe-èng",
|
||||
"entry.estimated_reading_time": [
|
||||
@@ -157,6 +157,7 @@
|
||||
"error.unlink_account_without_password": "Lí it-tēng ài siat-tēng bi̍t-bé, bô lí ē bô-hoat-tō͘ koh teng-lo̍k.",
|
||||
"error.user_already_exists": "Chit ê sú-iōng-lâng í-keng chûn-chāi.",
|
||||
"error.user_mandatory_fields": "Tio̍h-ài su-li̍p kháu-chō miâ",
|
||||
"error.linktaco_missing_required_fields": "LinkTaco API Token kâh Organization Slug sio̍kêi",
|
||||
"form.api_key.label.description": "API só-sîkhan-á",
|
||||
"form.category.hide_globally": "Mài hián-sī siau-sit tī choân-he̍k ah-bōe tha̍k lia̍t-pió lāi",
|
||||
"form.category.label.title": "Piau-tôe",
|
||||
@@ -247,6 +248,16 @@
|
||||
"form.integration.linkding_bookmark": "Chù chòe ah-bōe tha̍k",
|
||||
"form.integration.linkding_endpoint": "Linkding API thâu",
|
||||
"form.integration.linkding_tags": "Linkding khan-á",
|
||||
"form.integration.linktaco_activate": "Pó-chûn siau-sit kàu LinkTaco",
|
||||
"form.integration.linktaco_api_token": "LinkTaco API Token",
|
||||
"form.integration.linktaco_api_token_hint": "Chhú-tek lí ê kò-jîn chún-chhú token tī",
|
||||
"form.integration.linktaco_org_slug": "Organization Slug",
|
||||
"form.integration.linktaco_tags": "khan-á (siōn-koân 10, iōng tō͘-tiám keh khui)",
|
||||
"form.integration.linktaco_tags_hint": "Siōn-koân 10 khan-á, iōng tō͘-tiám keh khui",
|
||||
"form.integration.linktaco_visibility": "Kò-chhiah-kì sìa?",
|
||||
"form.integration.linktaco_visibility_public": "Kò-chhiah-kì",
|
||||
"form.integration.linktaco_visibility_private": "Su-lîn",
|
||||
"form.integration.linktaco_visibility_hint": "Su-lîn sìa tík tio̍h-ài chù-hêng LinkTaco kháu-chō",
|
||||
"form.integration.linkwarden_activate": "Pó-chûn siau-sit kàu Linkwarden",
|
||||
"form.integration.linkwarden_api_key": "Linkwarden API só-sî",
|
||||
"form.integration.linkwarden_endpoint": "Linkwarden Base URL",
|
||||
@@ -319,6 +330,7 @@
|
||||
"form.integration.wallabag_only_url": "Kan-na thoân bāng-chí (m̄ sī oân-chéng ê lōe-iông)",
|
||||
"form.integration.wallabag_password": "Wallabag bi̍t-bé",
|
||||
"form.integration.wallabag_username": "Wallabag kháu-chō miâ",
|
||||
"form.integration.wallabag_tags": "Wallabag Tags",
|
||||
"form.integration.webhook_activate": "Khai-sí iōng Webhooks",
|
||||
"form.integration.webhook_secret": "Webhooks Secret",
|
||||
"form.integration.webhook_url": "Default Webhook bāng-chí",
|
||||
@@ -456,6 +468,7 @@
|
||||
"page.feeds.next_check": "Āu-pái kiám-cha sî-kan:",
|
||||
"page.feeds.read_counter": "Tha̍k kè--ê siau-sit sò͘",
|
||||
"page.feeds.title": "Siau-sit lâi-goân",
|
||||
"page.footer.elevator": "Back to top",
|
||||
"page.history.title": "Kì-lo̍k",
|
||||
"page.import.title": "Hōe-li̍p",
|
||||
"page.integration.bookmarklet": "Chheh-chhiam ke-si",
|
||||
@@ -500,7 +513,7 @@
|
||||
"page.keyboard_shortcuts.subtitle.pages": "Ia̍h bīn tō-lám",
|
||||
"page.keyboard_shortcuts.subtitle.sections": "Hun lân tō-lám",
|
||||
"page.keyboard_shortcuts.title": "Khoài-sok khí",
|
||||
"page.keyboard_shortcuts.toggle_bookmark_status": "Chhet-li̍p siu-chông chōng-thài",
|
||||
"page.keyboard_shortcuts.toggle_star_status": "Chhet-li̍p siu-chông chōng-thài",
|
||||
"page.keyboard_shortcuts.toggle_entry_attachments": "Chhet-li̍p thián khui kah siu-ha̍p siau-sit hù-kiāⁿ ê chōng-thài",
|
||||
"page.keyboard_shortcuts.toggle_read_status_next": "Chhet-li̍p tha̍k--kè, ah-bōe tha̍k ê chōng-thài, koh chiau-tiám tī āu-chi̍t--ê",
|
||||
"page.keyboard_shortcuts.toggle_read_status_prev": "Chhet-li̍p tha̍k--kè, ah-bōe tha̍k ê chōng-thài, koh chiau-tiám tī téng-chi̍t--ê",
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
"alert.account_unlinked": "Jouw externe account is nu ontkoppeld!",
|
||||
"alert.background_feed_refresh": "Alle feeds worden op de achtergrond vernieuwd. Je kunt Miniflux blijven gebruiker terwijl dit proces draait.",
|
||||
"alert.feed_error": "Er is een probleem met deze feed",
|
||||
"alert.no_bookmark": "Er zijn geen favorieten.",
|
||||
"alert.no_starred": "Er zijn geen favorieten.",
|
||||
"alert.no_category": "Er zijn geen categorieën.",
|
||||
"alert.no_category_entry": "Er zijn geen artikelen in deze categorie.",
|
||||
"alert.no_feed": "Je hebt nog geen feed geabonneerd.",
|
||||
@@ -46,10 +46,10 @@
|
||||
"enclosure_media_controls.speed.reset.title": "Reset snelheid naar 1x",
|
||||
"enclosure_media_controls.speed.slower": "Vertraag",
|
||||
"enclosure_media_controls.speed.slower.title": "Vertraag met %sx",
|
||||
"entry.bookmark.toast.off": "Favoriet verwijderd",
|
||||
"entry.bookmark.toast.on": "Favoriet toegevoegd",
|
||||
"entry.bookmark.toggle.off": "Favoriet verwijderen",
|
||||
"entry.bookmark.toggle.on": "Favoriet",
|
||||
"entry.starred.toast.off": "Favoriet verwijderd",
|
||||
"entry.starred.toast.on": "Favoriet toegevoegd",
|
||||
"entry.starred.toggle.off": "Favoriet verwijderen",
|
||||
"entry.starred.toggle.on": "Favoriet",
|
||||
"entry.comments.label": "Reacties",
|
||||
"entry.comments.title": "Bekijk reacties",
|
||||
"entry.estimated_reading_time": [
|
||||
@@ -160,6 +160,7 @@
|
||||
"error.unlink_account_without_password": "Je moet een wachtwoord opgeven anders kun je niet meer inloggen.",
|
||||
"error.user_already_exists": "Deze gebruiker bestaat al.",
|
||||
"error.user_mandatory_fields": "Gebruikersnaam is verplicht",
|
||||
"error.linktaco_missing_required_fields": "LinkTaco API Token en Organization Slug zijn verplicht",
|
||||
"form.api_key.label.description": "API-sleutel omschrijving",
|
||||
"form.category.hide_globally": "Verberg artikelen in de globale ongelezen lijst",
|
||||
"form.category.label.title": "Titel",
|
||||
@@ -250,6 +251,16 @@
|
||||
"form.integration.linkding_bookmark": "Markeer favoriet als ongelezen",
|
||||
"form.integration.linkding_endpoint": "Linkding URL",
|
||||
"form.integration.linkding_tags": "Linkding tags",
|
||||
"form.integration.linktaco_activate": "Artikelen opslaan in LinkTaco",
|
||||
"form.integration.linktaco_api_token": "LinkTaco API Token",
|
||||
"form.integration.linktaco_api_token_hint": "Verkrijg uw persoonlijke toegangstoken op",
|
||||
"form.integration.linktaco_org_slug": "Organization Slug",
|
||||
"form.integration.linktaco_tags": "Tags (max 10, kommagescheiden)",
|
||||
"form.integration.linktaco_tags_hint": "Maximaal 10 tags, kommagescheiden",
|
||||
"form.integration.linktaco_visibility": "Zichtbaarheid",
|
||||
"form.integration.linktaco_visibility_public": "Openbaar",
|
||||
"form.integration.linktaco_visibility_private": "Privé",
|
||||
"form.integration.linktaco_visibility_hint": "PRIVÉ zichtbaarheid vereist een betaald LinkTaco account",
|
||||
"form.integration.linkwarden_activate": "Artikelen opslaan in Linkwarden",
|
||||
"form.integration.linkwarden_api_key": "Linkwarden API-sleutel",
|
||||
"form.integration.linkwarden_endpoint": "Linkwarden Basis URL",
|
||||
@@ -322,6 +333,7 @@
|
||||
"form.integration.wallabag_only_url": "Alleen URL verzenden (in plaats van volledige inhoud)",
|
||||
"form.integration.wallabag_password": "Wallabag wachtwoord",
|
||||
"form.integration.wallabag_username": "Wallabag gebruikersnaam",
|
||||
"form.integration.wallabag_tags": "Wallabag Tags",
|
||||
"form.integration.webhook_activate": "Webhooks activeren",
|
||||
"form.integration.webhook_secret": "Webhooks Secret",
|
||||
"form.integration.webhook_url": "Standard Webhook URL",
|
||||
@@ -462,6 +474,7 @@
|
||||
"page.feeds.next_check": "Volgende controle:",
|
||||
"page.feeds.read_counter": "Aantal gelezen artikelen",
|
||||
"page.feeds.title": "Feeds",
|
||||
"page.footer.elevator": "Back to top",
|
||||
"page.history.title": "Geschiedenis",
|
||||
"page.import.title": "Importeren",
|
||||
"page.integration.bookmarklet": "Bookmarklet",
|
||||
@@ -506,7 +519,7 @@
|
||||
"page.keyboard_shortcuts.subtitle.pages": "Navigeren door pagina's",
|
||||
"page.keyboard_shortcuts.subtitle.sections": "Navigeren door menu's",
|
||||
"page.keyboard_shortcuts.title": "Sneltoetsen",
|
||||
"page.keyboard_shortcuts.toggle_bookmark_status": "Favoriet toevoegen/verwijderen",
|
||||
"page.keyboard_shortcuts.toggle_star_status": "Favoriet toevoegen/verwijderen",
|
||||
"page.keyboard_shortcuts.toggle_entry_attachments": "Bijlagen van artikel openen/sluiten",
|
||||
"page.keyboard_shortcuts.toggle_read_status_next": "Markeer gelezen/ongelezen, focus volgende",
|
||||
"page.keyboard_shortcuts.toggle_read_status_prev": "Markeer gelezen/ongelezen, focus vorige",
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
"alert.account_unlinked": "Twoje konto zewnętrzne jest teraz zdysocjowane!",
|
||||
"alert.background_feed_refresh": "Wszystkie kanały są odświeżane w tle. Możesz kontynuować korzystanie z Miniflux podczas trwania tego procesu.",
|
||||
"alert.feed_error": "Z tym kanałem jest problem",
|
||||
"alert.no_bookmark": "Brak ulubionych w tej chwili.",
|
||||
"alert.no_starred": "Brak ulubionych w tej chwili.",
|
||||
"alert.no_category": "Brak kategorii!",
|
||||
"alert.no_category_entry": "Brak wpisów w tej kategorii",
|
||||
"alert.no_feed": "Nie masz żadnej subskrypcji.",
|
||||
@@ -47,10 +47,10 @@
|
||||
"enclosure_media_controls.speed.reset.title": "Przywróć szybkość do 1x",
|
||||
"enclosure_media_controls.speed.slower": "Wolniej",
|
||||
"enclosure_media_controls.speed.slower.title": "Wolniej o %sx",
|
||||
"entry.bookmark.toast.off": "Usunięto z ulubionych",
|
||||
"entry.bookmark.toast.on": "Dodano do ulubionych",
|
||||
"entry.bookmark.toggle.off": "Usuń z ulubionych",
|
||||
"entry.bookmark.toggle.on": "Dodaj do ulubionych",
|
||||
"entry.starred.toast.off": "Usunięto z ulubionych",
|
||||
"entry.starred.toast.on": "Dodano do ulubionych",
|
||||
"entry.starred.toggle.off": "Usuń z ulubionych",
|
||||
"entry.starred.toggle.on": "Dodaj do ulubionych",
|
||||
"entry.comments.label": "Komentarze",
|
||||
"entry.comments.title": "Zobacz komentarze",
|
||||
"entry.estimated_reading_time": [
|
||||
@@ -163,6 +163,7 @@
|
||||
"error.unlink_account_without_password": "Musisz zdefiniować hasło, inaczej nie będziesz mógł się ponownie zalogować.",
|
||||
"error.user_already_exists": "Ten użytkownik już istnieje.",
|
||||
"error.user_mandatory_fields": "Nazwa użytkownika jest obowiązkowa.",
|
||||
"error.linktaco_missing_required_fields": "Token API LinkTaco i ślimak organizacji są wymagane",
|
||||
"form.api_key.label.description": "Etykieta klucza API",
|
||||
"form.category.hide_globally": "Ukryj wpisy na globalnej liście nieprzeczytanych",
|
||||
"form.category.label.title": "Tytuł",
|
||||
@@ -253,9 +254,19 @@
|
||||
"form.integration.linkding_bookmark": "Oznacz zakładkę jako nieprzeczytaną",
|
||||
"form.integration.linkding_endpoint": "Punkt końcowy API Linkding",
|
||||
"form.integration.linkding_tags": "Znaczniki Linkding",
|
||||
"form.integration.linktaco_activate": "Zapisuj wpisy w LinkTaco",
|
||||
"form.integration.linktaco_api_token": "Token API LinkTaco",
|
||||
"form.integration.linktaco_api_token_hint": "Uzyskaj osobisty token dostępu na",
|
||||
"form.integration.linktaco_org_slug": "Ślimak organizacji",
|
||||
"form.integration.linktaco_tags": "Znaczniki (maks. 10, oddzielone przecinkami)",
|
||||
"form.integration.linktaco_tags_hint": "Maksymalnie 10 znaczników, oddzielone przecinkami",
|
||||
"form.integration.linktaco_visibility": "Widoczność",
|
||||
"form.integration.linktaco_visibility_public": "Publiczne",
|
||||
"form.integration.linktaco_visibility_private": "Prywatne",
|
||||
"form.integration.linktaco_visibility_hint": "Widoczność PRYWATNE wymaga płatnego konta LinkTaco",
|
||||
"form.integration.linkwarden_activate": "Zapisuj wpisy w Linkwarden",
|
||||
"form.integration.linkwarden_api_key": "Klucz API do Linkwarden",
|
||||
"form.integration.linkwarden_endpoint": "Podstawowy adres URL Linkwardena",
|
||||
"form.integration.linkwarden_endpoint": "Podstawowy adres URL Linkwarden",
|
||||
"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",
|
||||
@@ -322,6 +333,7 @@
|
||||
"form.integration.wallabag_client_id": "Identyfikator klienta Wallabag",
|
||||
"form.integration.wallabag_client_secret": "Tajny klucz klienta Wallabag",
|
||||
"form.integration.wallabag_endpoint": "Podstawowy adres URL Wallabag",
|
||||
"form.integration.wallabag_tags": "Znaczniki Wallabag",
|
||||
"form.integration.wallabag_only_url": "Przesyłaj tylko adres URL (zamiast pełnej treści)",
|
||||
"form.integration.wallabag_password": "Hasło do Wallabag",
|
||||
"form.integration.wallabag_username": "Login do Wallabag",
|
||||
@@ -468,6 +480,7 @@
|
||||
"page.feeds.next_check": "Następna aktualizacja:",
|
||||
"page.feeds.read_counter": "Liczba przeczytanych wpisów",
|
||||
"page.feeds.title": "Kanały",
|
||||
"page.footer.elevator": "Wróć do góry",
|
||||
"page.history.title": "Historia",
|
||||
"page.import.title": "Importuj",
|
||||
"page.integration.bookmarklet": "Skryptozakładka",
|
||||
@@ -512,7 +525,7 @@
|
||||
"page.keyboard_shortcuts.subtitle.pages": "Nawigacja między stronami",
|
||||
"page.keyboard_shortcuts.subtitle.sections": "Nawigacja między punktami menu",
|
||||
"page.keyboard_shortcuts.title": "Skróty klawiszowe",
|
||||
"page.keyboard_shortcuts.toggle_bookmark_status": "Przełącz dodanie do ulubionych",
|
||||
"page.keyboard_shortcuts.toggle_star_status": "Przełącz dodanie do ulubionych",
|
||||
"page.keyboard_shortcuts.toggle_entry_attachments": "Przełącz otwieranie/zamykanie załączników wpisów",
|
||||
"page.keyboard_shortcuts.toggle_read_status_next": "Przełącz przeczytane/nieprzeczytane, przejdź dalej",
|
||||
"page.keyboard_shortcuts.toggle_read_status_prev": "Przełącz przeczytane/nieprzeczytane, przejdź wstecz",
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
"alert.account_unlinked": "Sua conta externa está desvinculada!",
|
||||
"alert.background_feed_refresh": "Todas as fontes estão sendo atualizadas em segundo plano. Você pode continuar usando o Miniflux enquanto este processo está em execução.",
|
||||
"alert.feed_error": "Ocorreu um problema com esta fonte.",
|
||||
"alert.no_bookmark": "Não há favorito neste momento.",
|
||||
"alert.no_starred": "Não há favorito neste momento.",
|
||||
"alert.no_category": "Não há categoria.",
|
||||
"alert.no_category_entry": "Não há itens nesta categoria.",
|
||||
"alert.no_feed": "Não há inscrições.",
|
||||
@@ -46,10 +46,10 @@
|
||||
"enclosure_media_controls.speed.reset.title": "Resetar velocidade para 1x",
|
||||
"enclosure_media_controls.speed.slower": "Mais Lento",
|
||||
"enclosure_media_controls.speed.slower.title": "Mais lento em %sx",
|
||||
"entry.bookmark.toast.off": "Desfavoritado",
|
||||
"entry.bookmark.toast.on": "Favoritado",
|
||||
"entry.bookmark.toggle.off": "Remover dos Favoritos",
|
||||
"entry.bookmark.toggle.on": "Favoritar",
|
||||
"entry.starred.toast.off": "Desfavoritado",
|
||||
"entry.starred.toast.on": "Favoritado",
|
||||
"entry.starred.toggle.off": "Remover dos Favoritos",
|
||||
"entry.starred.toggle.on": "Favoritar",
|
||||
"entry.comments.label": "Comentários",
|
||||
"entry.comments.title": "Ver comentários",
|
||||
"entry.estimated_reading_time": [
|
||||
@@ -160,6 +160,7 @@
|
||||
"error.unlink_account_without_password": "Você deve definir uma senha, senão não será possível efetuar a sessão novamente.",
|
||||
"error.user_already_exists": "Esse usuário já existe.",
|
||||
"error.user_mandatory_fields": "O nome de usuário é obrigatório.",
|
||||
"error.linktaco_missing_required_fields": "LinkTaco API Token e Organization Slug são obrigatórios",
|
||||
"form.api_key.label.description": "Etiqueta da chave de API",
|
||||
"form.category.hide_globally": "Ocultar entradas na lista global não lida",
|
||||
"form.category.label.title": "Título",
|
||||
@@ -250,6 +251,16 @@
|
||||
"form.integration.linkding_bookmark": "Salvar marcador como não lido",
|
||||
"form.integration.linkding_endpoint": "Endpoint de API do Linkding",
|
||||
"form.integration.linkding_tags": "Linkding Tags",
|
||||
"form.integration.linktaco_activate": "Salvar itens no LinkTaco",
|
||||
"form.integration.linktaco_api_token": "LinkTaco API Token",
|
||||
"form.integration.linktaco_api_token_hint": "Obtenha seu token de acesso pessoal em",
|
||||
"form.integration.linktaco_org_slug": "Organization Slug",
|
||||
"form.integration.linktaco_tags": "Tags (máx 10, separadas por vírgula)",
|
||||
"form.integration.linktaco_tags_hint": "Máximo 10 tags, separadas por vírgula",
|
||||
"form.integration.linktaco_visibility": "Visibilidade",
|
||||
"form.integration.linktaco_visibility_public": "Público",
|
||||
"form.integration.linktaco_visibility_private": "Privado",
|
||||
"form.integration.linktaco_visibility_hint": "Visibilidade PRIVADA requer uma conta LinkTaco paga",
|
||||
"form.integration.linkwarden_activate": "Salvar itens no Linkwarden",
|
||||
"form.integration.linkwarden_api_key": "Chave de API do Linkwarden",
|
||||
"form.integration.linkwarden_endpoint": "URL base do Linkwarden",
|
||||
@@ -322,6 +333,7 @@
|
||||
"form.integration.wallabag_only_url": "Enviar apenas URL (em vez de conteúdo completo)",
|
||||
"form.integration.wallabag_password": "Senha do Wallabag",
|
||||
"form.integration.wallabag_username": "Nome de usuário do Wallabag",
|
||||
"form.integration.wallabag_tags": "Wallabag Tags",
|
||||
"form.integration.webhook_activate": "Enable Webhooks",
|
||||
"form.integration.webhook_secret": "Webhooks Secret",
|
||||
"form.integration.webhook_url": "Default Webhook URL",
|
||||
@@ -462,6 +474,7 @@
|
||||
"page.feeds.next_check": "Próxima verificação:",
|
||||
"page.feeds.read_counter": "Número de itens lidos",
|
||||
"page.feeds.title": "Fontes",
|
||||
"page.footer.elevator": "Back to top",
|
||||
"page.history.title": "Histórico",
|
||||
"page.import.title": "Importar",
|
||||
"page.integration.bookmarklet": "Bookmarklet",
|
||||
@@ -506,7 +519,7 @@
|
||||
"page.keyboard_shortcuts.subtitle.pages": "Navegação de páginas",
|
||||
"page.keyboard_shortcuts.subtitle.sections": "Navegação de seções",
|
||||
"page.keyboard_shortcuts.title": "Atalhos de teclado",
|
||||
"page.keyboard_shortcuts.toggle_bookmark_status": "Marcar ou desmarcar como favorito",
|
||||
"page.keyboard_shortcuts.toggle_star_status": "Marcar ou desmarcar como favorito",
|
||||
"page.keyboard_shortcuts.toggle_entry_attachments": "Alternar abrir/fechar anexos do item",
|
||||
"page.keyboard_shortcuts.toggle_read_status_next": "Inverter estado de leitura do item, focar próximo item",
|
||||
"page.keyboard_shortcuts.toggle_read_status_prev": "Inverter estado de leitura do item, focar item anterior",
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
"alert.account_unlinked": "Am decuplat contul dvs. extern!",
|
||||
"alert.background_feed_refresh": "Toate fluxurile sunt actualizate în fundal. Puteți să continuați utilizarea Miniflux în timp ce procesul rulează.",
|
||||
"alert.feed_error": "Este o problemă cu acest flux",
|
||||
"alert.no_bookmark": "Nu sunt înregistrări marcate.",
|
||||
"alert.no_starred": "Nu sunt înregistrări marcate.",
|
||||
"alert.no_category": "Nu sunt categorii.",
|
||||
"alert.no_category_entry": "Nu sunt înregistrări în această categorie.",
|
||||
"alert.no_feed": "Nu aveți fluxuri.",
|
||||
@@ -47,10 +47,10 @@
|
||||
"enclosure_media_controls.speed.reset.title": "Resetare viteză la 1x",
|
||||
"enclosure_media_controls.speed.slower": "Mai încet",
|
||||
"enclosure_media_controls.speed.slower.title": "Mai încet cu %sx",
|
||||
"entry.bookmark.toast.off": "Fără stea",
|
||||
"entry.bookmark.toast.on": "Cu stea",
|
||||
"entry.bookmark.toggle.off": "Fără stea",
|
||||
"entry.bookmark.toggle.on": "Stea",
|
||||
"entry.starred.toast.off": "Fără stea",
|
||||
"entry.starred.toast.on": "Cu stea",
|
||||
"entry.starred.toggle.off": "Fără stea",
|
||||
"entry.starred.toggle.on": "Stea",
|
||||
"entry.comments.label": "Comentarii",
|
||||
"entry.comments.title": "Vizualizare Comentarii",
|
||||
"entry.estimated_reading_time": [
|
||||
@@ -163,6 +163,7 @@
|
||||
"error.unlink_account_without_password": "Trebuie să definiți o parolă, altfel nu vă veți mai putea conecta.",
|
||||
"error.user_already_exists": "Acest utilizator există deja.",
|
||||
"error.user_mandatory_fields": "Numele utilizatorului este obligatoriu.",
|
||||
"error.linktaco_missing_required_fields": "LinkTaco API Token și Organization Slug sunt necesare",
|
||||
"form.api_key.label.description": "Etichetă Cheie API",
|
||||
"form.category.hide_globally": "Ascunde intrările în lista globală de articole necitite",
|
||||
"form.category.label.title": "Titlu",
|
||||
@@ -253,6 +254,16 @@
|
||||
"form.integration.linkding_bookmark": "Marchează semnele de carte ca necitite",
|
||||
"form.integration.linkding_endpoint": "Endpoint API Linkding",
|
||||
"form.integration.linkding_tags": "TAG-uri Linkding",
|
||||
"form.integration.linktaco_activate": "Salvează înregistrările în LinkTaco",
|
||||
"form.integration.linktaco_api_token": "LinkTaco API Token",
|
||||
"form.integration.linktaco_api_token_hint": "Obțineți jetonul de acces personal la",
|
||||
"form.integration.linktaco_org_slug": "Organization Slug",
|
||||
"form.integration.linktaco_tags": "Tag-uri (maxim 10, separate prin virgule)",
|
||||
"form.integration.linktaco_tags_hint": "Maxim 10 tag-uri, separate prin virgule",
|
||||
"form.integration.linktaco_visibility": "Vizibilitate",
|
||||
"form.integration.linktaco_visibility_public": "Public",
|
||||
"form.integration.linktaco_visibility_private": "Privat",
|
||||
"form.integration.linktaco_visibility_hint": "Vizibilitatea PRIVATĂ necesită un cont LinkTaco plătit",
|
||||
"form.integration.linkwarden_activate": "Salvează intrările în Linkwarden",
|
||||
"form.integration.linkwarden_api_key": "Cheie API Linkwarden",
|
||||
"form.integration.linkwarden_endpoint": "URL-ul de bază Linkwarden",
|
||||
@@ -322,6 +333,7 @@
|
||||
"form.integration.wallabag_client_id": "ID Client Wallabag",
|
||||
"form.integration.wallabag_client_secret": "Secret Client Wallabag",
|
||||
"form.integration.wallabag_endpoint": "URL Wallabag",
|
||||
"form.integration.wallabag_tags": "Wallabag Tags",
|
||||
"form.integration.wallabag_only_url": "Trimite numai URL-ul (fără conținut complet)",
|
||||
"form.integration.wallabag_password": "Parolă Wallabag",
|
||||
"form.integration.wallabag_username": "Utilizator Wallabag",
|
||||
@@ -468,6 +480,7 @@
|
||||
"page.feeds.next_check": "Următoarea verificare:",
|
||||
"page.feeds.read_counter": "Numărul de intrări citite",
|
||||
"page.feeds.title": "Fluxuri",
|
||||
"page.footer.elevator": "Back to top",
|
||||
"page.history.title": "Istoric",
|
||||
"page.import.title": "Import",
|
||||
"page.integration.bookmarklet": "Marcaje",
|
||||
@@ -512,7 +525,7 @@
|
||||
"page.keyboard_shortcuts.subtitle.pages": "Navigare Pagini",
|
||||
"page.keyboard_shortcuts.subtitle.sections": "Navigare Secțiuni",
|
||||
"page.keyboard_shortcuts.title": "Scurtături Tastatură",
|
||||
"page.keyboard_shortcuts.toggle_bookmark_status": "Comută marcate",
|
||||
"page.keyboard_shortcuts.toggle_star_status": "Comută marcate",
|
||||
"page.keyboard_shortcuts.toggle_entry_attachments": "Comută deschis/închis pe atașamentele înregistrării",
|
||||
"page.keyboard_shortcuts.toggle_read_status_next": "Comută citit/necitit focus următor",
|
||||
"page.keyboard_shortcuts.toggle_read_status_prev": "Comută citit/necitit, focus anterior",
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
"alert.account_unlinked": "Ваш внешний аккаунт теперь отвязан!",
|
||||
"alert.background_feed_refresh": "Все подписки обновляются в фоновом режиме. Вы можете продолжать использовать Miniflux пока идёт этот процесс.",
|
||||
"alert.feed_error": "С этой подпиской есть проблема",
|
||||
"alert.no_bookmark": "Избранное отсутствует.",
|
||||
"alert.no_starred": "Избранное отсутствует.",
|
||||
"alert.no_category": "Категории отсутствуют.",
|
||||
"alert.no_category_entry": "В этой категории нет статей.",
|
||||
"alert.no_feed": "У вас нет ни одной подписки.",
|
||||
@@ -47,10 +47,10 @@
|
||||
"enclosure_media_controls.speed.reset.title": "Сбросить скорость до 1x",
|
||||
"enclosure_media_controls.speed.slower": "Медленнее",
|
||||
"enclosure_media_controls.speed.slower.title": "Замедлить в %s раз",
|
||||
"entry.bookmark.toast.off": "Без пометок",
|
||||
"entry.bookmark.toast.on": "Помеченные",
|
||||
"entry.bookmark.toggle.off": "Удалить из Избранного",
|
||||
"entry.bookmark.toggle.on": "Добавить в Избранное",
|
||||
"entry.starred.toast.off": "Без пометок",
|
||||
"entry.starred.toast.on": "Помеченные",
|
||||
"entry.starred.toggle.off": "Удалить из Избранного",
|
||||
"entry.starred.toggle.on": "Добавить в Избранное",
|
||||
"entry.comments.label": "Комментарии",
|
||||
"entry.comments.title": "Показать комментарии",
|
||||
"entry.estimated_reading_time": [
|
||||
@@ -163,6 +163,7 @@
|
||||
"error.unlink_account_without_password": "Вы должны установить пароль, иначе вы не сможете войти снова.",
|
||||
"error.user_already_exists": "Этот пользователь уже существует.",
|
||||
"error.user_mandatory_fields": "Имя пользователя обязательно.",
|
||||
"error.linktaco_missing_required_fields": "LinkTaco API Token и Organization Slug обязательны",
|
||||
"form.api_key.label.description": "Описание API-ключа",
|
||||
"form.category.hide_globally": "Скрыть записи в глобальном списке непрочитанных",
|
||||
"form.category.label.title": "Название",
|
||||
@@ -253,6 +254,16 @@
|
||||
"form.integration.linkding_bookmark": "Помечать закладки как непрочитанное",
|
||||
"form.integration.linkding_endpoint": "Конечная точка Linkding API",
|
||||
"form.integration.linkding_tags": "Теги Linkding",
|
||||
"form.integration.linktaco_activate": "Сохранять статьи в LinkTaco",
|
||||
"form.integration.linktaco_api_token": "LinkTaco API Token",
|
||||
"form.integration.linktaco_api_token_hint": "Получить ваш персональный токен доступа на",
|
||||
"form.integration.linktaco_org_slug": "Organization Slug",
|
||||
"form.integration.linktaco_tags": "Теги (макс. 10, через запятую)",
|
||||
"form.integration.linktaco_tags_hint": "Максимум 10 тегов, через запятую",
|
||||
"form.integration.linktaco_visibility": "Видимость",
|
||||
"form.integration.linktaco_visibility_public": "Публично",
|
||||
"form.integration.linktaco_visibility_private": "Приватно",
|
||||
"form.integration.linktaco_visibility_hint": "ПРИВАТНАЯ видимость требует платного аккаунта LinkTaco",
|
||||
"form.integration.linkwarden_activate": "Сохранять статьи в Linkwarden",
|
||||
"form.integration.linkwarden_api_key": "API-ключ Linkwarden",
|
||||
"form.integration.linkwarden_endpoint": "Базовый URL-адрес Linkwarden",
|
||||
@@ -322,6 +333,7 @@
|
||||
"form.integration.wallabag_client_id": "Номер клиента Wallabag",
|
||||
"form.integration.wallabag_client_secret": "Секретный код клиента Wallabag",
|
||||
"form.integration.wallabag_endpoint": "URL-адрес базы Валлабаг",
|
||||
"form.integration.wallabag_tags": "Wallabag Tags",
|
||||
"form.integration.wallabag_only_url": "Отправлять только ссылку (без содержимого)",
|
||||
"form.integration.wallabag_password": "Пароль Wallabag",
|
||||
"form.integration.wallabag_username": "Имя пользователя Wallabag",
|
||||
@@ -468,6 +480,7 @@
|
||||
"page.feeds.next_check": "Следующее обновление:",
|
||||
"page.feeds.read_counter": "Количество прочитанных статей",
|
||||
"page.feeds.title": "Подписки",
|
||||
"page.footer.elevator": "Back to top",
|
||||
"page.history.title": "История",
|
||||
"page.import.title": "Импорт",
|
||||
"page.integration.bookmarklet": "Букмарклет",
|
||||
@@ -512,7 +525,7 @@
|
||||
"page.keyboard_shortcuts.subtitle.pages": "Навигация по страницам",
|
||||
"page.keyboard_shortcuts.subtitle.sections": "Навигация по секциям",
|
||||
"page.keyboard_shortcuts.title": "Горячие клавиши",
|
||||
"page.keyboard_shortcuts.toggle_bookmark_status": "Переключатель избранного",
|
||||
"page.keyboard_shortcuts.toggle_star_status": "Переключатель избранного",
|
||||
"page.keyboard_shortcuts.toggle_entry_attachments": "Переключатель показать/скрыть вложения",
|
||||
"page.keyboard_shortcuts.toggle_read_status_next": "Переключатель прочитанного, сосредоточиться на следующем",
|
||||
"page.keyboard_shortcuts.toggle_read_status_prev": "Переключатель прочитанного, фокус предыдущий",
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
"alert.account_unlinked": "Harici hesabınızın bağlantısı kaldırıldı!",
|
||||
"alert.background_feed_refresh": "Tüm beslemeler arkaplanda yenileniyor. Bu süreç devam ederken Miniflux'ı kullanmaya devam edebilirsiniz.",
|
||||
"alert.feed_error": "Bu beslemeyle ilgili bir problem var",
|
||||
"alert.no_bookmark": "Yıldızlanmış makale yok.",
|
||||
"alert.no_starred": "Yıldızlanmış makale yok.",
|
||||
"alert.no_category": "Hiç kategori yok.",
|
||||
"alert.no_category_entry": "Bu kategoride hiç makele yok.",
|
||||
"alert.no_feed": "Hiç beslemeniz yok.",
|
||||
@@ -46,10 +46,10 @@
|
||||
"enclosure_media_controls.speed.reset.title": "Hızı 1x'e sıfırla",
|
||||
"enclosure_media_controls.speed.slower": "Daha yavaş",
|
||||
"enclosure_media_controls.speed.slower.title": "%sx kat daha yavaş",
|
||||
"entry.bookmark.toast.off": "Yıldızsız",
|
||||
"entry.bookmark.toast.on": "Yıldızlı",
|
||||
"entry.bookmark.toggle.off": "Yıldızı kaldır",
|
||||
"entry.bookmark.toggle.on": "Yıldız ekle",
|
||||
"entry.starred.toast.off": "Yıldızsız",
|
||||
"entry.starred.toast.on": "Yıldızlı",
|
||||
"entry.starred.toggle.off": "Yıldızı kaldır",
|
||||
"entry.starred.toggle.on": "Yıldız ekle",
|
||||
"entry.comments.label": "Yorumlar",
|
||||
"entry.comments.title": "Yorumları Göster",
|
||||
"entry.estimated_reading_time": [
|
||||
@@ -160,6 +160,7 @@
|
||||
"error.unlink_account_without_password": "Bir şifre belirlemelisiniz, aksi takdirde tekrar oturum açamazsınız.",
|
||||
"error.user_already_exists": "Bu kullanıcı zaten mevcut.",
|
||||
"error.user_mandatory_fields": "Kullanıcı adı zorunlu.",
|
||||
"error.linktaco_missing_required_fields": "LinkTaco API Token ve Organization Slug gereklidir",
|
||||
"form.api_key.label.description": "API Anahtar Etiketi",
|
||||
"form.category.hide_globally": "Genel okunmamış listesindeki girişleri gizle",
|
||||
"form.category.label.title": "Başlık",
|
||||
@@ -250,6 +251,16 @@
|
||||
"form.integration.linkding_bookmark": "Yer imini okunmadı olarak işaretle",
|
||||
"form.integration.linkding_endpoint": "Linkding API Uç Noktası",
|
||||
"form.integration.linkding_tags": "Linkding Etiketleri",
|
||||
"form.integration.linktaco_activate": "Makaleleri LinkTaco'ya kaydet",
|
||||
"form.integration.linktaco_api_token": "LinkTaco API Token",
|
||||
"form.integration.linktaco_api_token_hint": "Kişisel erişim tokenınızı edinin",
|
||||
"form.integration.linktaco_org_slug": "Organization Slug",
|
||||
"form.integration.linktaco_tags": "Etiketler (maks 10, virgülle ayrılmış)",
|
||||
"form.integration.linktaco_tags_hint": "Maksimum 10 etiket, virgülle ayrılmış",
|
||||
"form.integration.linktaco_visibility": "Görünürlük",
|
||||
"form.integration.linktaco_visibility_public": "Genel",
|
||||
"form.integration.linktaco_visibility_private": "Özel",
|
||||
"form.integration.linktaco_visibility_hint": "ÖZEL görünürlük ücretli bir LinkTaco hesabı gerektirir",
|
||||
"form.integration.linkwarden_activate": "Makaleleri Linkwarden'e kaydet",
|
||||
"form.integration.linkwarden_api_key": "Linkwarden API Anahtarı",
|
||||
"form.integration.linkwarden_endpoint": "Linkwarden Temel URL'si",
|
||||
@@ -322,6 +333,7 @@
|
||||
"form.integration.wallabag_only_url": "Yalnızca URL gönder (tam makale yerine)",
|
||||
"form.integration.wallabag_password": "Wallabag Parolası",
|
||||
"form.integration.wallabag_username": "Wallabag Kullanıcı Adı",
|
||||
"form.integration.wallabag_tags": "Wallabag Tags",
|
||||
"form.integration.webhook_activate": "Webhook'u etkinleştir",
|
||||
"form.integration.webhook_secret": "Webhooks Secret",
|
||||
"form.integration.webhook_url": "Default Webhook URL",
|
||||
@@ -462,6 +474,7 @@
|
||||
"page.feeds.next_check": "Sonraki kontrol:",
|
||||
"page.feeds.read_counter": "Okunmuş makalelerin sayısı",
|
||||
"page.feeds.title": "Beslemeler",
|
||||
"page.footer.elevator": "Back to top",
|
||||
"page.history.title": "Geçmiş",
|
||||
"page.import.title": "İçeri Aktar",
|
||||
"page.integration.bookmarklet": "Bookmarklet",
|
||||
@@ -506,7 +519,7 @@
|
||||
"page.keyboard_shortcuts.subtitle.pages": "Sayfalarda Gezinme",
|
||||
"page.keyboard_shortcuts.subtitle.sections": "Bölümlerde Gezinme",
|
||||
"page.keyboard_shortcuts.title": "Klavye Kısayolları",
|
||||
"page.keyboard_shortcuts.toggle_bookmark_status": "Yıldız ekle/kaldır",
|
||||
"page.keyboard_shortcuts.toggle_star_status": "Yıldız ekle/kaldır",
|
||||
"page.keyboard_shortcuts.toggle_entry_attachments": "Makele eklerini açma/kapama arasında geçiş yap",
|
||||
"page.keyboard_shortcuts.toggle_read_status_next": "Okundu/okunmadı arasında geçiş yap, sonrakine odaklan",
|
||||
"page.keyboard_shortcuts.toggle_read_status_prev": "Okundu/okunmadı arasında geçiş yap, öncekine odaklan",
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
"alert.account_unlinked": "Тепер ваш зовнішній обліковий запис підключено!",
|
||||
"alert.background_feed_refresh": "Всі стрічки оновлюються у фоновому режимі. Ви можете продовжувати користуватися Miniflux, поки триває цей процес.",
|
||||
"alert.feed_error": "З цією стрічкою трапилась помилка",
|
||||
"alert.no_bookmark": "Наразі закладки відсутні.",
|
||||
"alert.no_starred": "Наразі закладки відсутні.",
|
||||
"alert.no_category": "Немає категорії.",
|
||||
"alert.no_category_entry": "У цій категорії немає записів.",
|
||||
"alert.no_feed": "У вас немає підписок.",
|
||||
@@ -47,10 +47,10 @@
|
||||
"enclosure_media_controls.speed.reset.title": "Скинути швидкість до 1x",
|
||||
"enclosure_media_controls.speed.slower": "Повільніше",
|
||||
"enclosure_media_controls.speed.slower.title": "Повільніше на %sx",
|
||||
"entry.bookmark.toast.off": "Без зірочки",
|
||||
"entry.bookmark.toast.on": "З зірочкою",
|
||||
"entry.bookmark.toggle.off": "Прибрати зірочку",
|
||||
"entry.bookmark.toggle.on": "Поставити зірочку",
|
||||
"entry.starred.toast.off": "Без зірочки",
|
||||
"entry.starred.toast.on": "З зірочкою",
|
||||
"entry.starred.toggle.off": "Прибрати зірочку",
|
||||
"entry.starred.toggle.on": "Поставити зірочку",
|
||||
"entry.comments.label": "Коментарі",
|
||||
"entry.comments.title": "Дивитися коментарі",
|
||||
"entry.estimated_reading_time": [
|
||||
@@ -162,7 +162,8 @@
|
||||
"error.unable_to_update_user": "Не вдається оновити користувача.",
|
||||
"error.unlink_account_without_password": "Ви маєте встановити пароль, щоб мати можливість увійти наступного разу",
|
||||
"error.user_already_exists": "Такий користувач вже існує.",
|
||||
"error.user_mandatory_fields": "Ім’я користувача є обов’язковим.",
|
||||
"error.user_mandatory_fields": "Ім'я користувача є обов'язковим.",
|
||||
"error.linktaco_missing_required_fields": "LinkTaco API Token і Organization Slug є обов'язковими",
|
||||
"form.api_key.label.description": "Назва ключа API",
|
||||
"form.category.hide_globally": "Приховати записи в глобальному списку непрочитаного",
|
||||
"form.category.label.title": "Назва",
|
||||
@@ -253,6 +254,16 @@
|
||||
"form.integration.linkding_bookmark": "Відмічати закладку як непрочитану",
|
||||
"form.integration.linkding_endpoint": "Linkding API Endpoint",
|
||||
"form.integration.linkding_tags": "Linkding Tags",
|
||||
"form.integration.linktaco_activate": "Зберігати статті в LinkTaco",
|
||||
"form.integration.linktaco_api_token": "LinkTaco API Token",
|
||||
"form.integration.linktaco_api_token_hint": "Отримайте ваш персональний токен доступу на",
|
||||
"form.integration.linktaco_org_slug": "Organization Slug",
|
||||
"form.integration.linktaco_tags": "Теги (макс. 10, через кому)",
|
||||
"form.integration.linktaco_tags_hint": "Максимум 10 тегів, через кому",
|
||||
"form.integration.linktaco_visibility": "Видимість",
|
||||
"form.integration.linktaco_visibility_public": "Публічно",
|
||||
"form.integration.linktaco_visibility_private": "Приватно",
|
||||
"form.integration.linktaco_visibility_hint": "ПРИВАТНА видимість потребує платного акаунта LinkTaco",
|
||||
"form.integration.linkwarden_activate": "Зберігати статті до Linkwarden",
|
||||
"form.integration.linkwarden_api_key": "Ключ API Linkwarden",
|
||||
"form.integration.linkwarden_endpoint": "Базова URL-адреса Linkwarden",
|
||||
@@ -322,6 +333,7 @@
|
||||
"form.integration.wallabag_client_id": "Wallabag Client ID",
|
||||
"form.integration.wallabag_client_secret": "Wallabag Client Secret",
|
||||
"form.integration.wallabag_endpoint": "Базова URL-адреса Wallabag",
|
||||
"form.integration.wallabag_tags": "Wallabag Tags",
|
||||
"form.integration.wallabag_only_url": "Надіслати лише URL (замість повного вмісту)",
|
||||
"form.integration.wallabag_password": "Пароль Wallabag",
|
||||
"form.integration.wallabag_username": "Ім’я користувача Wallabag",
|
||||
@@ -468,6 +480,7 @@
|
||||
"page.feeds.next_check": "Наступна перевірка:",
|
||||
"page.feeds.read_counter": "Кількість прочитаних записів",
|
||||
"page.feeds.title": "Стрічки",
|
||||
"page.footer.elevator": "Back to top",
|
||||
"page.history.title": "Історія",
|
||||
"page.import.title": "Імпорт",
|
||||
"page.integration.bookmarklet": "Букмарклет",
|
||||
@@ -512,7 +525,7 @@
|
||||
"page.keyboard_shortcuts.subtitle.pages": "Навігація по сторінках",
|
||||
"page.keyboard_shortcuts.subtitle.sections": "Навігація по розділах",
|
||||
"page.keyboard_shortcuts.title": "Комбінації клавиш",
|
||||
"page.keyboard_shortcuts.toggle_bookmark_status": "Переключити статус закладки",
|
||||
"page.keyboard_shortcuts.toggle_star_status": "Переключити статус закладки",
|
||||
"page.keyboard_shortcuts.toggle_entry_attachments": "Toggle open/close entry attachments",
|
||||
"page.keyboard_shortcuts.toggle_read_status_next": "Переключити статус читання, перейти до наступного",
|
||||
"page.keyboard_shortcuts.toggle_read_status_prev": "Переключити статус читання, перейти до попереднього",
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
"alert.account_unlinked": "您的外部帐户已解除关联!",
|
||||
"alert.background_feed_refresh": "所有订阅源正在后台刷新。您可以在刷新过程中继续使用 Miniflux。",
|
||||
"alert.feed_error": "此订阅源存在问题",
|
||||
"alert.no_bookmark": "没有收藏的条目。",
|
||||
"alert.no_starred": "没有收藏的条目。",
|
||||
"alert.no_category": "没有分类。",
|
||||
"alert.no_category_entry": "此分类下没有条目。",
|
||||
"alert.no_feed": "你没有任何订阅源。",
|
||||
@@ -45,10 +45,10 @@
|
||||
"enclosure_media_controls.speed.reset.title": "重置速度到 1x",
|
||||
"enclosure_media_controls.speed.slower": "减慢",
|
||||
"enclosure_media_controls.speed.slower.title": "速度减慢到 %sx",
|
||||
"entry.bookmark.toast.off": "已取消收藏",
|
||||
"entry.bookmark.toast.on": "已添加收藏",
|
||||
"entry.bookmark.toggle.off": "取消收藏",
|
||||
"entry.bookmark.toggle.on": "添加收藏",
|
||||
"entry.starred.toast.off": "已取消收藏",
|
||||
"entry.starred.toast.on": "已添加收藏",
|
||||
"entry.starred.toggle.off": "取消收藏",
|
||||
"entry.starred.toggle.on": "添加收藏",
|
||||
"entry.comments.label": "评论",
|
||||
"entry.comments.title": "查看评论",
|
||||
"entry.estimated_reading_time": [
|
||||
@@ -157,6 +157,7 @@
|
||||
"error.unlink_account_without_password": "您必须设置密码,否则您将无法再次登录。",
|
||||
"error.user_already_exists": "此用户已存在。",
|
||||
"error.user_mandatory_fields": "必须填写用户名。",
|
||||
"error.linktaco_missing_required_fields": "LinkTaco API Token 和 Organization Slug 是必需的",
|
||||
"form.api_key.label.description": "API 密钥标签",
|
||||
"form.category.hide_globally": "在全局未读列表中隐藏条目",
|
||||
"form.category.label.title": "标题",
|
||||
@@ -247,6 +248,16 @@
|
||||
"form.integration.linkding_bookmark": "将书签标记为未读",
|
||||
"form.integration.linkding_endpoint": "Linkding API 端点",
|
||||
"form.integration.linkding_tags": "Linkding 标签",
|
||||
"form.integration.linktaco_activate": "保存条目到 LinkTaco",
|
||||
"form.integration.linktaco_api_token": "LinkTaco API Token",
|
||||
"form.integration.linktaco_api_token_hint": "在此获取您的个人访问令牌",
|
||||
"form.integration.linktaco_org_slug": "Organization Slug",
|
||||
"form.integration.linktaco_tags": "标签(最多10个,逗号分隔)",
|
||||
"form.integration.linktaco_tags_hint": "最多10个标签,逗号分隔",
|
||||
"form.integration.linktaco_visibility": "可见性",
|
||||
"form.integration.linktaco_visibility_public": "公开",
|
||||
"form.integration.linktaco_visibility_private": "私人",
|
||||
"form.integration.linktaco_visibility_hint": "私人可见性需要付费的 LinkTaco 帐户",
|
||||
"form.integration.linkwarden_activate": "保存条目到 Linkwarden",
|
||||
"form.integration.linkwarden_api_key": "Linkwarden API 密钥",
|
||||
"form.integration.linkwarden_endpoint": "Linkwarden 基本 URL",
|
||||
@@ -319,6 +330,7 @@
|
||||
"form.integration.wallabag_only_url": "仅发送 URL(而非完整内容)",
|
||||
"form.integration.wallabag_password": "Wallabag 密码",
|
||||
"form.integration.wallabag_username": "Wallabag 用户名",
|
||||
"form.integration.wallabag_tags": "Wallabag Tags",
|
||||
"form.integration.webhook_activate": "启用 Webhooks",
|
||||
"form.integration.webhook_secret": "Webhooks 密钥",
|
||||
"form.integration.webhook_url": "默认 Webhook URL",
|
||||
@@ -456,6 +468,7 @@
|
||||
"page.feeds.next_check": "下次检查:",
|
||||
"page.feeds.read_counter": "已读条目数",
|
||||
"page.feeds.title": "订阅源",
|
||||
"page.footer.elevator": "Back to top",
|
||||
"page.history.title": "历史记录",
|
||||
"page.import.title": "导入",
|
||||
"page.integration.bookmarklet": "书签小应用",
|
||||
@@ -500,7 +513,7 @@
|
||||
"page.keyboard_shortcuts.subtitle.pages": "页面导航",
|
||||
"page.keyboard_shortcuts.subtitle.sections": "区域导航",
|
||||
"page.keyboard_shortcuts.title": "键盘快捷键",
|
||||
"page.keyboard_shortcuts.toggle_bookmark_status": "切换收藏状态",
|
||||
"page.keyboard_shortcuts.toggle_star_status": "切换收藏状态",
|
||||
"page.keyboard_shortcuts.toggle_entry_attachments": "切换展开/折叠条目附件",
|
||||
"page.keyboard_shortcuts.toggle_read_status_next": "切换已读/未读状态,并切换到下一项",
|
||||
"page.keyboard_shortcuts.toggle_read_status_prev": "切换已读/未读状态,并切换到上一项",
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
"alert.account_unlinked": "您的外部帳戶已解除關聯!",
|
||||
"alert.background_feed_refresh": "所有 Feed 正在背景中更新,您可以繼續使用 Miniflux。",
|
||||
"alert.feed_error": "該 Feed 存在問題",
|
||||
"alert.no_bookmark": "目前沒有收藏",
|
||||
"alert.no_starred": "目前沒有收藏",
|
||||
"alert.no_category": "目前沒有分類",
|
||||
"alert.no_category_entry": "該分類下沒有文章",
|
||||
"alert.no_feed": "目前沒有 Feed",
|
||||
@@ -45,10 +45,10 @@
|
||||
"enclosure_media_controls.speed.reset.title": "重設播放速度為 1x",
|
||||
"enclosure_media_controls.speed.slower": "放慢",
|
||||
"enclosure_media_controls.speed.slower.title": "放慢 %sx",
|
||||
"entry.bookmark.toast.off": "已取消收藏",
|
||||
"entry.bookmark.toast.on": "已新增收藏",
|
||||
"entry.bookmark.toggle.off": "取消收藏",
|
||||
"entry.bookmark.toggle.on": "新增收藏",
|
||||
"entry.starred.toast.off": "已取消收藏",
|
||||
"entry.starred.toast.on": "已新增收藏",
|
||||
"entry.starred.toggle.off": "取消收藏",
|
||||
"entry.starred.toggle.on": "新增收藏",
|
||||
"entry.comments.label": "評論",
|
||||
"entry.comments.title": "檢視評論",
|
||||
"entry.estimated_reading_time": [
|
||||
@@ -157,6 +157,7 @@
|
||||
"error.unlink_account_without_password": "您必須設定密碼,否則您將無法再次登入。",
|
||||
"error.user_already_exists": "使用者已存在",
|
||||
"error.user_mandatory_fields": "必須填寫使用者名稱",
|
||||
"error.linktaco_missing_required_fields": "LinkTaco API Token 和 Organization Slug 是必需的",
|
||||
"form.api_key.label.description": "API 金鑰標籤",
|
||||
"form.category.hide_globally": "在全域未讀列表中隱藏文章",
|
||||
"form.category.label.title": "標題",
|
||||
@@ -247,6 +248,16 @@
|
||||
"form.integration.linkding_bookmark": "標記為未讀",
|
||||
"form.integration.linkding_endpoint": "Linkding API 端點",
|
||||
"form.integration.linkding_tags": "Linkding 標籤",
|
||||
"form.integration.linktaco_activate": "儲存文章到 LinkTaco",
|
||||
"form.integration.linktaco_api_token": "LinkTaco API Token",
|
||||
"form.integration.linktaco_api_token_hint": "在此取得您的個人存取權杖",
|
||||
"form.integration.linktaco_org_slug": "Organization Slug",
|
||||
"form.integration.linktaco_tags": "標籤(最多10個,逗號分隔)",
|
||||
"form.integration.linktaco_tags_hint": "最多10個標籤,逗號分隔",
|
||||
"form.integration.linktaco_visibility": "可見性",
|
||||
"form.integration.linktaco_visibility_public": "公開",
|
||||
"form.integration.linktaco_visibility_private": "私人",
|
||||
"form.integration.linktaco_visibility_hint": "私人可見性需要付費的 LinkTaco 帳戶",
|
||||
"form.integration.linkwarden_activate": "儲存文章到 Linkwarden",
|
||||
"form.integration.linkwarden_api_key": "Linkwarden API 金鑰",
|
||||
"form.integration.linkwarden_endpoint": "Linkwarden 基本 URL",
|
||||
@@ -319,6 +330,7 @@
|
||||
"form.integration.wallabag_only_url": "僅傳送網址(而不是完整內容)",
|
||||
"form.integration.wallabag_password": "Wallabag 密碼",
|
||||
"form.integration.wallabag_username": "Wallabag 使用者名稱",
|
||||
"form.integration.wallabag_tags": "Wallabag Tags",
|
||||
"form.integration.webhook_activate": "啟用 Webhooks",
|
||||
"form.integration.webhook_secret": "Webhooks Secret",
|
||||
"form.integration.webhook_url": "Default Webhook 網址",
|
||||
@@ -456,6 +468,7 @@
|
||||
"page.feeds.next_check": "下次檢查時間:",
|
||||
"page.feeds.read_counter": "已讀文章數",
|
||||
"page.feeds.title": "Feeds",
|
||||
"page.footer.elevator": "Back to top",
|
||||
"page.history.title": "歷史",
|
||||
"page.import.title": "匯入",
|
||||
"page.integration.bookmarklet": "書籤小工具",
|
||||
@@ -500,7 +513,7 @@
|
||||
"page.keyboard_shortcuts.subtitle.pages": "頁面導覽",
|
||||
"page.keyboard_shortcuts.subtitle.sections": "分欄導覽",
|
||||
"page.keyboard_shortcuts.title": "快捷鍵",
|
||||
"page.keyboard_shortcuts.toggle_bookmark_status": "切換收藏狀態",
|
||||
"page.keyboard_shortcuts.toggle_star_status": "切換收藏狀態",
|
||||
"page.keyboard_shortcuts.toggle_entry_attachments": "展開/折疊文章附件",
|
||||
"page.keyboard_shortcuts.toggle_read_status_next": "切換已讀/未讀狀態,並聚焦到下一個",
|
||||
"page.keyboard_shortcuts.toggle_read_status_prev": "切換已讀/未讀狀態,並聚焦到上一個",
|
||||
|
||||
@@ -19,7 +19,7 @@ func TestProxyFilterWithHttpDefault(t *testing.T) {
|
||||
os.Setenv("MEDIA_PROXY_PRIVATE_KEY", "test")
|
||||
|
||||
var err error
|
||||
parser := config.NewParser()
|
||||
parser := config.NewConfigParser()
|
||||
config.Opts, err = parser.ParseEnvironmentVariables()
|
||||
if err != nil {
|
||||
t.Fatalf(`Parsing failure: %v`, err)
|
||||
@@ -43,7 +43,7 @@ func TestProxyFilterWithHttpsDefault(t *testing.T) {
|
||||
os.Setenv("MEDIA_PROXY_RESOURCE_TYPES", "image")
|
||||
|
||||
var err error
|
||||
parser := config.NewParser()
|
||||
parser := config.NewConfigParser()
|
||||
config.Opts, err = parser.ParseEnvironmentVariables()
|
||||
if err != nil {
|
||||
t.Fatalf(`Parsing failure: %v`, err)
|
||||
@@ -66,7 +66,7 @@ func TestProxyFilterWithHttpNever(t *testing.T) {
|
||||
os.Setenv("MEDIA_PROXY_MODE", "none")
|
||||
|
||||
var err error
|
||||
parser := config.NewParser()
|
||||
parser := config.NewConfigParser()
|
||||
config.Opts, err = parser.ParseEnvironmentVariables()
|
||||
if err != nil {
|
||||
t.Fatalf(`Parsing failure: %v`, err)
|
||||
@@ -89,7 +89,7 @@ func TestProxyFilterWithHttpsNever(t *testing.T) {
|
||||
os.Setenv("MEDIA_PROXY_MODE", "none")
|
||||
|
||||
var err error
|
||||
parser := config.NewParser()
|
||||
parser := config.NewConfigParser()
|
||||
config.Opts, err = parser.ParseEnvironmentVariables()
|
||||
if err != nil {
|
||||
t.Fatalf(`Parsing failure: %v`, err)
|
||||
@@ -114,7 +114,7 @@ func TestProxyFilterWithHttpAlways(t *testing.T) {
|
||||
os.Setenv("MEDIA_PROXY_PRIVATE_KEY", "test")
|
||||
|
||||
var err error
|
||||
parser := config.NewParser()
|
||||
parser := config.NewConfigParser()
|
||||
config.Opts, err = parser.ParseEnvironmentVariables()
|
||||
if err != nil {
|
||||
t.Fatalf(`Parsing failure: %v`, err)
|
||||
@@ -139,7 +139,7 @@ func TestProxyFilterWithHttpsAlways(t *testing.T) {
|
||||
os.Setenv("MEDIA_PROXY_PRIVATE_KEY", "test")
|
||||
|
||||
var err error
|
||||
parser := config.NewParser()
|
||||
parser := config.NewConfigParser()
|
||||
config.Opts, err = parser.ParseEnvironmentVariables()
|
||||
if err != nil {
|
||||
t.Fatalf(`Parsing failure: %v`, err)
|
||||
@@ -164,7 +164,7 @@ func TestAbsoluteProxyFilterWithHttpsAlways(t *testing.T) {
|
||||
os.Setenv("MEDIA_PROXY_PRIVATE_KEY", "test")
|
||||
|
||||
var err error
|
||||
parser := config.NewParser()
|
||||
parser := config.NewConfigParser()
|
||||
config.Opts, err = parser.ParseEnvironmentVariables()
|
||||
if err != nil {
|
||||
t.Fatalf(`Parsing failure: %v`, err)
|
||||
@@ -188,7 +188,7 @@ func TestAbsoluteProxyFilterWithCustomPortAndSubfolderInBaseURL(t *testing.T) {
|
||||
os.Setenv("MEDIA_PROXY_PRIVATE_KEY", "test")
|
||||
|
||||
var err error
|
||||
parser := config.NewParser()
|
||||
parser := config.NewConfigParser()
|
||||
config.Opts, err = parser.ParseEnvironmentVariables()
|
||||
if err != nil {
|
||||
t.Fatalf(`Parsing failure: %v`, err)
|
||||
@@ -226,7 +226,7 @@ func TestAbsoluteProxyFilterWithHttpsAlwaysAndAudioTag(t *testing.T) {
|
||||
os.Setenv("MEDIA_PROXY_PRIVATE_KEY", "test")
|
||||
|
||||
var err error
|
||||
parser := config.NewParser()
|
||||
parser := config.NewConfigParser()
|
||||
config.Opts, err = parser.ParseEnvironmentVariables()
|
||||
if err != nil {
|
||||
t.Fatalf(`Parsing failure: %v`, err)
|
||||
@@ -251,7 +251,7 @@ func TestProxyFilterWithHttpsAlwaysAndCustomProxyServer(t *testing.T) {
|
||||
os.Setenv("MEDIA_PROXY_CUSTOM_URL", "https://proxy-example/proxy")
|
||||
|
||||
var err error
|
||||
parser := config.NewParser()
|
||||
parser := config.NewConfigParser()
|
||||
config.Opts, err = parser.ParseEnvironmentVariables()
|
||||
if err != nil {
|
||||
t.Fatalf(`Parsing failure: %v`, err)
|
||||
@@ -276,7 +276,7 @@ func TestProxyFilterWithHttpsAlwaysAndIncorrectCustomProxyServer(t *testing.T) {
|
||||
os.Setenv("MEDIA_PROXY_CUSTOM_URL", "http://:8080example.com")
|
||||
|
||||
var err error
|
||||
parser := config.NewParser()
|
||||
parser := config.NewConfigParser()
|
||||
config.Opts, err = parser.ParseEnvironmentVariables()
|
||||
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())
|
||||
@@ -290,7 +290,7 @@ func TestAbsoluteProxyFilterWithHttpsAlwaysAndCustomProxyServer(t *testing.T) {
|
||||
os.Setenv("MEDIA_PROXY_CUSTOM_URL", "https://proxy-example/proxy")
|
||||
|
||||
var err error
|
||||
parser := config.NewParser()
|
||||
parser := config.NewConfigParser()
|
||||
config.Opts, err = parser.ParseEnvironmentVariables()
|
||||
if err != nil {
|
||||
t.Fatalf(`Parsing failure: %v`, err)
|
||||
@@ -313,46 +313,8 @@ func TestProxyFilterWithHttpInvalid(t *testing.T) {
|
||||
os.Setenv("MEDIA_PROXY_MODE", "invalid")
|
||||
os.Setenv("MEDIA_PROXY_PRIVATE_KEY", "test")
|
||||
|
||||
var err error
|
||||
parser := config.NewParser()
|
||||
config.Opts, err = parser.ParseEnvironmentVariables()
|
||||
if err != nil {
|
||||
t.Fatalf(`Parsing failure: %v`, err)
|
||||
}
|
||||
|
||||
r := mux.NewRouter()
|
||||
r.HandleFunc("/proxy/{encodedDigest}/{encodedURL}", func(w http.ResponseWriter, r *http.Request) {}).Name("proxy")
|
||||
|
||||
input := `<p><img src="http://website/folder/image.png" alt="Test"/></p>`
|
||||
output := RewriteDocumentWithRelativeProxyURL(r, input)
|
||||
expected := `<p><img src="/proxy/okK5PsdNY8F082UMQEAbLPeUFfbe2WnNfInNmR9T4WA=/aHR0cDovL3dlYnNpdGUvZm9sZGVyL2ltYWdlLnBuZw==" alt="Test"/></p>`
|
||||
|
||||
if expected != output {
|
||||
t.Errorf(`Not expected output: got %q instead of %q`, output, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProxyFilterWithHttpsInvalid(t *testing.T) {
|
||||
os.Clearenv()
|
||||
os.Setenv("MEDIA_PROXY_MODE", "invalid")
|
||||
os.Setenv("MEDIA_PROXY_PRIVATE_KEY", "test")
|
||||
|
||||
var err error
|
||||
parser := config.NewParser()
|
||||
config.Opts, err = parser.ParseEnvironmentVariables()
|
||||
if err != nil {
|
||||
t.Fatalf(`Parsing failure: %v`, err)
|
||||
}
|
||||
|
||||
r := mux.NewRouter()
|
||||
r.HandleFunc("/proxy/{encodedDigest}/{encodedURL}", func(w http.ResponseWriter, r *http.Request) {}).Name("proxy")
|
||||
|
||||
input := `<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 := config.NewConfigParser().ParseEnvironmentVariables(); err == nil {
|
||||
t.Fatalf(`Parsing should have failed (MEDIA_PROXY_MODE=%q): %q`, os.Getenv("MEDIA_PROXY_MODE"), config.Opts.MediaProxyMode())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -363,7 +325,7 @@ func TestProxyFilterWithSrcset(t *testing.T) {
|
||||
os.Setenv("MEDIA_PROXY_PRIVATE_KEY", "test")
|
||||
|
||||
var err error
|
||||
parser := config.NewParser()
|
||||
parser := config.NewConfigParser()
|
||||
config.Opts, err = parser.ParseEnvironmentVariables()
|
||||
if err != nil {
|
||||
t.Fatalf(`Parsing failure: %v`, err)
|
||||
@@ -388,7 +350,7 @@ func TestProxyFilterWithEmptySrcset(t *testing.T) {
|
||||
os.Setenv("MEDIA_PROXY_PRIVATE_KEY", "test")
|
||||
|
||||
var err error
|
||||
parser := config.NewParser()
|
||||
parser := config.NewConfigParser()
|
||||
config.Opts, err = parser.ParseEnvironmentVariables()
|
||||
if err != nil {
|
||||
t.Fatalf(`Parsing failure: %v`, err)
|
||||
@@ -413,7 +375,7 @@ func TestProxyFilterWithPictureSource(t *testing.T) {
|
||||
os.Setenv("MEDIA_PROXY_PRIVATE_KEY", "test")
|
||||
|
||||
var err error
|
||||
parser := config.NewParser()
|
||||
parser := config.NewConfigParser()
|
||||
config.Opts, err = parser.ParseEnvironmentVariables()
|
||||
if err != nil {
|
||||
t.Fatalf(`Parsing failure: %v`, err)
|
||||
@@ -433,12 +395,12 @@ func TestProxyFilterWithPictureSource(t *testing.T) {
|
||||
|
||||
func TestProxyFilterOnlyNonHTTPWithPictureSource(t *testing.T) {
|
||||
os.Clearenv()
|
||||
os.Setenv("MEDIA_PROXY_MODE", "https")
|
||||
os.Setenv("MEDIA_PROXY_MODE", "http-only")
|
||||
os.Setenv("MEDIA_PROXY_RESOURCE_TYPES", "image")
|
||||
os.Setenv("MEDIA_PROXY_PRIVATE_KEY", "test")
|
||||
|
||||
var err error
|
||||
parser := config.NewParser()
|
||||
parser := config.NewConfigParser()
|
||||
config.Opts, err = parser.ParseEnvironmentVariables()
|
||||
if err != nil {
|
||||
t.Fatalf(`Parsing failure: %v`, err)
|
||||
@@ -462,7 +424,7 @@ func TestProxyWithImageDataURL(t *testing.T) {
|
||||
os.Setenv("MEDIA_PROXY_RESOURCE_TYPES", "image")
|
||||
|
||||
var err error
|
||||
parser := config.NewParser()
|
||||
parser := config.NewConfigParser()
|
||||
config.Opts, err = parser.ParseEnvironmentVariables()
|
||||
if err != nil {
|
||||
t.Fatalf(`Parsing failure: %v`, err)
|
||||
@@ -486,7 +448,7 @@ func TestProxyWithImageSourceDataURL(t *testing.T) {
|
||||
os.Setenv("MEDIA_PROXY_RESOURCE_TYPES", "image")
|
||||
|
||||
var err error
|
||||
parser := config.NewParser()
|
||||
parser := config.NewConfigParser()
|
||||
config.Opts, err = parser.ParseEnvironmentVariables()
|
||||
if err != nil {
|
||||
t.Fatalf(`Parsing failure: %v`, err)
|
||||
@@ -511,7 +473,7 @@ func TestProxyFilterWithVideo(t *testing.T) {
|
||||
os.Setenv("MEDIA_PROXY_PRIVATE_KEY", "test")
|
||||
|
||||
var err error
|
||||
parser := config.NewParser()
|
||||
parser := config.NewConfigParser()
|
||||
config.Opts, err = parser.ParseEnvironmentVariables()
|
||||
if err != nil {
|
||||
t.Fatalf(`Parsing failure: %v`, err)
|
||||
@@ -536,7 +498,7 @@ func TestProxyFilterVideoPoster(t *testing.T) {
|
||||
os.Setenv("MEDIA_PROXY_PRIVATE_KEY", "test")
|
||||
|
||||
var err error
|
||||
parser := config.NewParser()
|
||||
parser := config.NewConfigParser()
|
||||
config.Opts, err = parser.ParseEnvironmentVariables()
|
||||
if err != nil {
|
||||
t.Fatalf(`Parsing failure: %v`, err)
|
||||
@@ -561,7 +523,7 @@ func TestProxyFilterVideoPosterOnce(t *testing.T) {
|
||||
os.Setenv("MEDIA_PROXY_PRIVATE_KEY", "test")
|
||||
|
||||
var err error
|
||||
parser := config.NewParser()
|
||||
parser := config.NewConfigParser()
|
||||
config.Opts, err = parser.ParseEnvironmentVariables()
|
||||
if err != nil {
|
||||
t.Fatalf(`Parsing failure: %v`, err)
|
||||
|
||||
@@ -138,11 +138,11 @@ var (
|
||||
// collector represents a metric collector.
|
||||
type collector struct {
|
||||
store *storage.Storage
|
||||
refreshInterval int
|
||||
refreshInterval time.Duration
|
||||
}
|
||||
|
||||
// NewCollector initializes a new metric collector.
|
||||
func NewCollector(store *storage.Storage, refreshInterval int) *collector {
|
||||
func NewCollector(store *storage.Storage, refreshInterval time.Duration) *collector {
|
||||
prometheus.MustRegister(BackgroundFeedRefreshDuration)
|
||||
prometheus.MustRegister(ScraperRequestDuration)
|
||||
prometheus.MustRegister(ArchiveEntriesDuration)
|
||||
@@ -163,7 +163,7 @@ func NewCollector(store *storage.Storage, refreshInterval int) *collector {
|
||||
|
||||
// GatherStorageMetrics polls the database to fetch metrics.
|
||||
func (c *collector) GatherStorageMetrics() {
|
||||
for range time.Tick(time.Duration(c.refreshInterval) * time.Second) {
|
||||
for range time.Tick(c.refreshInterval) {
|
||||
slog.Debug("Collecting metrics from the database")
|
||||
|
||||
usersGauge.Set(float64(c.store.CountUsers()))
|
||||
|
||||
@@ -290,7 +290,7 @@ func TestEnclosure_ProxifyEnclosureURL(t *testing.T) {
|
||||
os.Setenv("MEDIA_PROXY_PRIVATE_KEY", "test-private-key")
|
||||
|
||||
var err error
|
||||
parser := config.NewParser()
|
||||
parser := config.NewConfigParser()
|
||||
config.Opts, err = parser.ParseEnvironmentVariables()
|
||||
if err != nil {
|
||||
t.Fatalf(`Config parsing failure: %v`, err)
|
||||
@@ -420,7 +420,7 @@ func TestEnclosureList_ProxifyEnclosureURL(t *testing.T) {
|
||||
os.Setenv("MEDIA_PROXY_PRIVATE_KEY", "test-private-key")
|
||||
|
||||
var err error
|
||||
parser := config.NewParser()
|
||||
parser := config.NewConfigParser()
|
||||
config.Opts, err = parser.ParseEnvironmentVariables()
|
||||
if err != nil {
|
||||
t.Fatalf(`Config parsing failure: %v`, err)
|
||||
@@ -535,7 +535,7 @@ func TestEnclosure_ProxifyEnclosureURL_EdgeCases(t *testing.T) {
|
||||
os.Setenv("MEDIA_PROXY_PRIVATE_KEY", "test-private-key")
|
||||
|
||||
var err error
|
||||
parser := config.NewParser()
|
||||
parser := config.NewConfigParser()
|
||||
config.Opts, err = parser.ParseEnvironmentVariables()
|
||||
if err != nil {
|
||||
t.Fatalf(`Config parsing failure: %v`, err)
|
||||
|
||||
+16
-19
@@ -6,7 +6,6 @@ package model // import "miniflux.app/v2/internal/model"
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"time"
|
||||
|
||||
"miniflux.app/v2/internal/config"
|
||||
@@ -69,11 +68,11 @@ type Feed struct {
|
||||
Entries Entries `json:"entries,omitempty"`
|
||||
|
||||
// Internal attributes (not exposed in the API and not persisted in the database)
|
||||
TTL int `json:"-"`
|
||||
IconURL string `json:"-"`
|
||||
UnreadCount int `json:"-"`
|
||||
ReadCount int `json:"-"`
|
||||
NumberOfVisibleEntries int `json:"-"`
|
||||
TTL time.Duration `json:"-"`
|
||||
IconURL string `json:"-"`
|
||||
UnreadCount int `json:"-"`
|
||||
ReadCount int `json:"-"`
|
||||
NumberOfVisibleEntries int `json:"-"`
|
||||
}
|
||||
|
||||
type FeedCounters struct {
|
||||
@@ -119,35 +118,33 @@ func (f *Feed) CheckedNow() {
|
||||
}
|
||||
|
||||
// ScheduleNextCheck set "next_check_at" of a feed based on the scheduler selected from the configuration.
|
||||
func (f *Feed) ScheduleNextCheck(weeklyCount int, refreshDelayInMinutes int) int {
|
||||
func (f *Feed) ScheduleNextCheck(weeklyCount int, refreshDelay time.Duration) time.Duration {
|
||||
// Default to the global config Polling Frequency.
|
||||
intervalMinutes := config.Opts.SchedulerRoundRobinMinInterval()
|
||||
interval := config.Opts.SchedulerRoundRobinMinInterval()
|
||||
|
||||
if config.Opts.PollingScheduler() == SchedulerEntryFrequency {
|
||||
if weeklyCount <= 0 {
|
||||
intervalMinutes = config.Opts.SchedulerEntryFrequencyMaxInterval()
|
||||
interval = config.Opts.SchedulerEntryFrequencyMaxInterval()
|
||||
} else {
|
||||
intervalMinutes = int(math.Round(float64(7*24*60) / float64(weeklyCount*config.Opts.SchedulerEntryFrequencyFactor())))
|
||||
intervalMinutes = min(intervalMinutes, config.Opts.SchedulerEntryFrequencyMaxInterval())
|
||||
intervalMinutes = max(intervalMinutes, config.Opts.SchedulerEntryFrequencyMinInterval())
|
||||
interval = (7 * 24 * time.Hour) / time.Duration(weeklyCount*config.Opts.SchedulerEntryFrequencyFactor())
|
||||
interval = min(interval, config.Opts.SchedulerEntryFrequencyMaxInterval())
|
||||
interval = max(interval, config.Opts.SchedulerEntryFrequencyMinInterval())
|
||||
}
|
||||
}
|
||||
|
||||
// Use the RSS TTL field, Retry-After, Cache-Control or Expires HTTP headers if defined.
|
||||
if refreshDelayInMinutes > 0 && refreshDelayInMinutes > intervalMinutes {
|
||||
intervalMinutes = refreshDelayInMinutes
|
||||
}
|
||||
interval = max(interval, refreshDelay)
|
||||
|
||||
// Limit the max interval value for misconfigured feeds.
|
||||
switch config.Opts.PollingScheduler() {
|
||||
case SchedulerRoundRobin:
|
||||
intervalMinutes = min(intervalMinutes, config.Opts.SchedulerRoundRobinMaxInterval())
|
||||
interval = min(interval, config.Opts.SchedulerRoundRobinMaxInterval())
|
||||
case SchedulerEntryFrequency:
|
||||
intervalMinutes = min(intervalMinutes, config.Opts.SchedulerEntryFrequencyMaxInterval())
|
||||
interval = min(interval, config.Opts.SchedulerEntryFrequencyMaxInterval())
|
||||
}
|
||||
|
||||
f.NextCheckAt = time.Now().Add(time.Minute * time.Duration(intervalMinutes))
|
||||
return intervalMinutes
|
||||
f.NextCheckAt = time.Now().Add(interval)
|
||||
return interval
|
||||
}
|
||||
|
||||
// FeedCreationRequest represents the request to create a feed.
|
||||
|
||||
+36
-36
@@ -4,8 +4,8 @@
|
||||
package model // import "miniflux.app/v2/internal/model"
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -67,11 +67,11 @@ func TestFeedCheckedNow(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func checkTargetInterval(t *testing.T, feed *Feed, targetInterval int, timeBefore time.Time, message string) {
|
||||
if feed.NextCheckAt.Before(timeBefore.Add(time.Minute * time.Duration(targetInterval))) {
|
||||
func checkTargetInterval(t *testing.T, feed *Feed, targetInterval time.Duration, timeBefore time.Time, message string) {
|
||||
if feed.NextCheckAt.Before(timeBefore.Add(targetInterval)) {
|
||||
t.Errorf(`The next_check_at should be after timeBefore + %s`, message)
|
||||
}
|
||||
if feed.NextCheckAt.After(time.Now().Add(time.Minute * time.Duration(targetInterval))) {
|
||||
if feed.NextCheckAt.After(time.Now().Add(targetInterval)) {
|
||||
t.Errorf(`The next_check_at should be before now + %s`, message)
|
||||
}
|
||||
}
|
||||
@@ -80,7 +80,7 @@ func TestFeedScheduleNextCheckRoundRobinDefault(t *testing.T) {
|
||||
os.Clearenv()
|
||||
|
||||
var err error
|
||||
parser := config.NewParser()
|
||||
parser := config.NewConfigParser()
|
||||
config.Opts, err = parser.ParseEnvironmentVariables()
|
||||
if err != nil {
|
||||
t.Fatalf(`Parsing failure: %v`, err)
|
||||
@@ -102,7 +102,7 @@ func TestFeedScheduleNextCheckRoundRobinWithRefreshDelayAboveMinInterval(t *test
|
||||
os.Clearenv()
|
||||
|
||||
var err error
|
||||
parser := config.NewParser()
|
||||
parser := config.NewConfigParser()
|
||||
config.Opts, err = parser.ParseEnvironmentVariables()
|
||||
if err != nil {
|
||||
t.Fatalf(`Parsing failure: %v`, err)
|
||||
@@ -125,7 +125,7 @@ func TestFeedScheduleNextCheckRoundRobinWithRefreshDelayBelowMinInterval(t *test
|
||||
os.Clearenv()
|
||||
|
||||
var err error
|
||||
parser := config.NewParser()
|
||||
parser := config.NewConfigParser()
|
||||
config.Opts, err = parser.ParseEnvironmentVariables()
|
||||
if err != nil {
|
||||
t.Fatalf(`Parsing failure: %v`, err)
|
||||
@@ -148,7 +148,7 @@ func TestFeedScheduleNextCheckRoundRobinWithRefreshDelayAboveMaxInterval(t *test
|
||||
os.Clearenv()
|
||||
|
||||
var err error
|
||||
parser := config.NewParser()
|
||||
parser := config.NewConfigParser()
|
||||
config.Opts, err = parser.ParseEnvironmentVariables()
|
||||
if err != nil {
|
||||
t.Fatalf(`Parsing failure: %v`, err)
|
||||
@@ -171,10 +171,10 @@ func TestFeedScheduleNextCheckRoundRobinMinInterval(t *testing.T) {
|
||||
minInterval := 1
|
||||
os.Clearenv()
|
||||
os.Setenv("POLLING_SCHEDULER", "round_robin")
|
||||
os.Setenv("SCHEDULER_ROUND_ROBIN_MIN_INTERVAL", fmt.Sprintf("%d", minInterval))
|
||||
os.Setenv("SCHEDULER_ROUND_ROBIN_MIN_INTERVAL", strconv.Itoa(minInterval))
|
||||
|
||||
var err error
|
||||
parser := config.NewParser()
|
||||
parser := config.NewConfigParser()
|
||||
config.Opts, err = parser.ParseEnvironmentVariables()
|
||||
if err != nil {
|
||||
t.Fatalf(`Parsing failure: %v`, err)
|
||||
@@ -188,7 +188,7 @@ func TestFeedScheduleNextCheckRoundRobinMinInterval(t *testing.T) {
|
||||
t.Error(`The next_check_at must be set`)
|
||||
}
|
||||
|
||||
expectedInterval := minInterval
|
||||
expectedInterval := time.Duration(minInterval) * time.Minute
|
||||
checkTargetInterval(t, feed, expectedInterval, timeBefore, "TestFeedScheduleNextCheckRoundRobinMinInterval")
|
||||
}
|
||||
|
||||
@@ -197,11 +197,11 @@ func TestFeedScheduleNextCheckEntryFrequencyMaxInterval(t *testing.T) {
|
||||
minInterval := 1
|
||||
os.Clearenv()
|
||||
os.Setenv("POLLING_SCHEDULER", "entry_frequency")
|
||||
os.Setenv("SCHEDULER_ENTRY_FREQUENCY_MAX_INTERVAL", fmt.Sprintf("%d", maxInterval))
|
||||
os.Setenv("SCHEDULER_ENTRY_FREQUENCY_MIN_INTERVAL", fmt.Sprintf("%d", minInterval))
|
||||
os.Setenv("SCHEDULER_ENTRY_FREQUENCY_MAX_INTERVAL", strconv.Itoa(maxInterval))
|
||||
os.Setenv("SCHEDULER_ENTRY_FREQUENCY_MIN_INTERVAL", strconv.Itoa(minInterval))
|
||||
|
||||
var err error
|
||||
parser := config.NewParser()
|
||||
parser := config.NewConfigParser()
|
||||
config.Opts, err = parser.ParseEnvironmentVariables()
|
||||
if err != nil {
|
||||
t.Fatalf(`Parsing failure: %v`, err)
|
||||
@@ -217,7 +217,7 @@ func TestFeedScheduleNextCheckEntryFrequencyMaxInterval(t *testing.T) {
|
||||
t.Error(`The next_check_at must be set`)
|
||||
}
|
||||
|
||||
targetInterval := maxInterval
|
||||
targetInterval := time.Duration(maxInterval) * time.Minute
|
||||
checkTargetInterval(t, feed, targetInterval, timeBefore, "entry frequency max interval")
|
||||
}
|
||||
|
||||
@@ -226,11 +226,11 @@ func TestFeedScheduleNextCheckEntryFrequencyMaxIntervalZeroWeeklyCount(t *testin
|
||||
minInterval := 1
|
||||
os.Clearenv()
|
||||
os.Setenv("POLLING_SCHEDULER", "entry_frequency")
|
||||
os.Setenv("SCHEDULER_ENTRY_FREQUENCY_MAX_INTERVAL", fmt.Sprintf("%d", maxInterval))
|
||||
os.Setenv("SCHEDULER_ENTRY_FREQUENCY_MIN_INTERVAL", fmt.Sprintf("%d", minInterval))
|
||||
os.Setenv("SCHEDULER_ENTRY_FREQUENCY_MAX_INTERVAL", strconv.Itoa(maxInterval))
|
||||
os.Setenv("SCHEDULER_ENTRY_FREQUENCY_MIN_INTERVAL", strconv.Itoa(minInterval))
|
||||
|
||||
var err error
|
||||
parser := config.NewParser()
|
||||
parser := config.NewConfigParser()
|
||||
config.Opts, err = parser.ParseEnvironmentVariables()
|
||||
if err != nil {
|
||||
t.Fatalf(`Parsing failure: %v`, err)
|
||||
@@ -246,7 +246,7 @@ func TestFeedScheduleNextCheckEntryFrequencyMaxIntervalZeroWeeklyCount(t *testin
|
||||
t.Error(`The next_check_at must be set`)
|
||||
}
|
||||
|
||||
targetInterval := maxInterval
|
||||
targetInterval := time.Duration(maxInterval) * time.Minute
|
||||
checkTargetInterval(t, feed, targetInterval, timeBefore, "entry frequency max interval")
|
||||
}
|
||||
|
||||
@@ -255,11 +255,11 @@ func TestFeedScheduleNextCheckEntryFrequencyMinInterval(t *testing.T) {
|
||||
minInterval := 100
|
||||
os.Clearenv()
|
||||
os.Setenv("POLLING_SCHEDULER", "entry_frequency")
|
||||
os.Setenv("SCHEDULER_ENTRY_FREQUENCY_MAX_INTERVAL", fmt.Sprintf("%d", maxInterval))
|
||||
os.Setenv("SCHEDULER_ENTRY_FREQUENCY_MIN_INTERVAL", fmt.Sprintf("%d", minInterval))
|
||||
os.Setenv("SCHEDULER_ENTRY_FREQUENCY_MAX_INTERVAL", strconv.Itoa(maxInterval))
|
||||
os.Setenv("SCHEDULER_ENTRY_FREQUENCY_MIN_INTERVAL", strconv.Itoa(minInterval))
|
||||
|
||||
var err error
|
||||
parser := config.NewParser()
|
||||
parser := config.NewConfigParser()
|
||||
config.Opts, err = parser.ParseEnvironmentVariables()
|
||||
if err != nil {
|
||||
t.Fatalf(`Parsing failure: %v`, err)
|
||||
@@ -275,7 +275,7 @@ func TestFeedScheduleNextCheckEntryFrequencyMinInterval(t *testing.T) {
|
||||
t.Error(`The next_check_at must be set`)
|
||||
}
|
||||
|
||||
targetInterval := minInterval
|
||||
targetInterval := time.Duration(minInterval) * time.Minute
|
||||
checkTargetInterval(t, feed, targetInterval, timeBefore, "entry frequency min interval")
|
||||
}
|
||||
|
||||
@@ -283,10 +283,10 @@ func TestFeedScheduleNextCheckEntryFrequencyFactor(t *testing.T) {
|
||||
factor := 2
|
||||
os.Clearenv()
|
||||
os.Setenv("POLLING_SCHEDULER", "entry_frequency")
|
||||
os.Setenv("SCHEDULER_ENTRY_FREQUENCY_FACTOR", fmt.Sprintf("%d", factor))
|
||||
os.Setenv("SCHEDULER_ENTRY_FREQUENCY_FACTOR", strconv.Itoa(factor))
|
||||
|
||||
var err error
|
||||
parser := config.NewParser()
|
||||
parser := config.NewConfigParser()
|
||||
config.Opts, err = parser.ParseEnvironmentVariables()
|
||||
if err != nil {
|
||||
t.Fatalf(`Parsing failure: %v`, err)
|
||||
@@ -301,7 +301,7 @@ func TestFeedScheduleNextCheckEntryFrequencyFactor(t *testing.T) {
|
||||
t.Error(`The next_check_at must be set`)
|
||||
}
|
||||
|
||||
targetInterval := config.Opts.SchedulerEntryFrequencyMaxInterval() / factor
|
||||
targetInterval := config.Opts.SchedulerEntryFrequencyMaxInterval() / time.Duration(factor)
|
||||
checkTargetInterval(t, feed, targetInterval, timeBefore, "factor * count")
|
||||
}
|
||||
|
||||
@@ -311,11 +311,11 @@ func TestFeedScheduleNextCheckEntryFrequencySmallNewTTL(t *testing.T) {
|
||||
minInterval := 100
|
||||
os.Clearenv()
|
||||
os.Setenv("POLLING_SCHEDULER", "entry_frequency")
|
||||
os.Setenv("SCHEDULER_ENTRY_FREQUENCY_MAX_INTERVAL", fmt.Sprintf("%d", maxInterval))
|
||||
os.Setenv("SCHEDULER_ENTRY_FREQUENCY_MIN_INTERVAL", fmt.Sprintf("%d", minInterval))
|
||||
os.Setenv("SCHEDULER_ENTRY_FREQUENCY_MAX_INTERVAL", strconv.Itoa(maxInterval))
|
||||
os.Setenv("SCHEDULER_ENTRY_FREQUENCY_MIN_INTERVAL", strconv.Itoa(minInterval))
|
||||
|
||||
var err error
|
||||
parser := config.NewParser()
|
||||
parser := config.NewConfigParser()
|
||||
config.Opts, err = parser.ParseEnvironmentVariables()
|
||||
if err != nil {
|
||||
t.Fatalf(`Parsing failure: %v`, err)
|
||||
@@ -326,17 +326,17 @@ func TestFeedScheduleNextCheckEntryFrequencySmallNewTTL(t *testing.T) {
|
||||
// Use a very large weekly count to trigger the min interval
|
||||
weeklyCount := largeWeeklyCount
|
||||
// TTL is smaller than minInterval.
|
||||
newTTL := minInterval / 2
|
||||
newTTL := time.Duration(minInterval) * time.Minute / 2
|
||||
feed.ScheduleNextCheck(weeklyCount, newTTL)
|
||||
|
||||
if feed.NextCheckAt.IsZero() {
|
||||
t.Error(`The next_check_at must be set`)
|
||||
}
|
||||
|
||||
targetInterval := minInterval
|
||||
targetInterval := time.Duration(minInterval) * time.Minute
|
||||
checkTargetInterval(t, feed, targetInterval, timeBefore, "entry frequency min interval")
|
||||
|
||||
if feed.NextCheckAt.Before(timeBefore.Add(time.Minute * time.Duration(newTTL))) {
|
||||
if feed.NextCheckAt.Before(timeBefore.Add(newTTL)) {
|
||||
t.Error(`The next_check_at should be after timeBefore + TTL`)
|
||||
}
|
||||
}
|
||||
@@ -347,11 +347,11 @@ func TestFeedScheduleNextCheckEntryFrequencyLargeNewTTL(t *testing.T) {
|
||||
minInterval := 100
|
||||
os.Clearenv()
|
||||
os.Setenv("POLLING_SCHEDULER", "entry_frequency")
|
||||
os.Setenv("SCHEDULER_ENTRY_FREQUENCY_MAX_INTERVAL", fmt.Sprintf("%d", maxInterval))
|
||||
os.Setenv("SCHEDULER_ENTRY_FREQUENCY_MIN_INTERVAL", fmt.Sprintf("%d", minInterval))
|
||||
os.Setenv("SCHEDULER_ENTRY_FREQUENCY_MAX_INTERVAL", strconv.Itoa(maxInterval))
|
||||
os.Setenv("SCHEDULER_ENTRY_FREQUENCY_MIN_INTERVAL", strconv.Itoa(minInterval))
|
||||
|
||||
var err error
|
||||
parser := config.NewParser()
|
||||
parser := config.NewConfigParser()
|
||||
config.Opts, err = parser.ParseEnvironmentVariables()
|
||||
if err != nil {
|
||||
t.Fatalf(`Parsing failure: %v`, err)
|
||||
@@ -362,7 +362,7 @@ func TestFeedScheduleNextCheckEntryFrequencyLargeNewTTL(t *testing.T) {
|
||||
// Use a very large weekly count to trigger the min interval
|
||||
weeklyCount := largeWeeklyCount
|
||||
// TTL is larger than minInterval.
|
||||
newTTL := minInterval * 2
|
||||
newTTL := time.Duration(minInterval) * time.Minute * 2
|
||||
feed.ScheduleNextCheck(weeklyCount, newTTL)
|
||||
|
||||
if feed.NextCheckAt.IsZero() {
|
||||
|
||||
@@ -29,6 +29,7 @@ type Integration struct {
|
||||
WallabagClientSecret string
|
||||
WallabagUsername string
|
||||
WallabagPassword string
|
||||
WallabagTags string
|
||||
NunuxKeeperEnabled bool
|
||||
NunuxKeeperURL string
|
||||
NunuxKeeperAPIKey string
|
||||
@@ -59,6 +60,11 @@ type Integration struct {
|
||||
LinkdingAPIKey string
|
||||
LinkdingTags string
|
||||
LinkdingMarkAsUnread bool
|
||||
LinktacoEnabled bool
|
||||
LinktacoAPIToken string
|
||||
LinktacoOrgSlug string
|
||||
LinktacoTags string
|
||||
LinktacoVisibility string
|
||||
LinkwardenEnabled bool
|
||||
LinkwardenURL string
|
||||
LinkwardenAPIKey string
|
||||
|
||||
@@ -28,13 +28,3 @@ func (u *UserSession) String() string {
|
||||
func (u *UserSession) UseTimezone(tz string) {
|
||||
u.CreatedAt = timezone.Convert(tz, u.CreatedAt)
|
||||
}
|
||||
|
||||
// UserSessions represents a list of sessions.
|
||||
type UserSessions []UserSession
|
||||
|
||||
// UseTimezone converts creation date of all sessions to the given timezone.
|
||||
func (u UserSessions) UseTimezone(tz string) {
|
||||
for _, session := range u {
|
||||
session.UseTimezone(tz)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,14 +12,14 @@ import (
|
||||
)
|
||||
|
||||
// RFC822, RFC850, and RFC1123 formats should be applied only to local times.
|
||||
var dateFormatsLocalTimesOnly = []string{
|
||||
var dateFormatsLocalTimesOnly = [...]string{
|
||||
time.RFC822, // RSS
|
||||
time.RFC850,
|
||||
time.RFC1123,
|
||||
}
|
||||
|
||||
// dateFormats taken from github.com/mjibson/goread
|
||||
var dateFormats = []string{
|
||||
var dateFormats = [...]string{
|
||||
time.RFC822Z, // RSS
|
||||
time.RFC3339, // Atom
|
||||
time.UnixDate,
|
||||
|
||||
@@ -18,14 +18,14 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
defaultHTTPClientTimeout = 20
|
||||
defaultHTTPClientTimeout = 20 * time.Second
|
||||
defaultAcceptHeader = "application/xml, application/atom+xml, application/rss+xml, application/rdf+xml, application/feed+json, text/html, */*;q=0.9"
|
||||
)
|
||||
|
||||
type RequestBuilder struct {
|
||||
headers http.Header
|
||||
clientProxyURL *url.URL
|
||||
clientTimeout int
|
||||
clientTimeout time.Duration
|
||||
useClientProxy bool
|
||||
withoutRedirects bool
|
||||
ignoreTLSErrors bool
|
||||
@@ -104,7 +104,7 @@ func (r *RequestBuilder) WithCustomFeedProxyURL(proxyURL string) *RequestBuilder
|
||||
return r
|
||||
}
|
||||
|
||||
func (r *RequestBuilder) WithTimeout(timeout int) *RequestBuilder {
|
||||
func (r *RequestBuilder) WithTimeout(timeout time.Duration) *RequestBuilder {
|
||||
r.clientTimeout = timeout
|
||||
return r
|
||||
}
|
||||
@@ -185,7 +185,7 @@ func (r *RequestBuilder) ExecuteRequest(requestURL string) (*http.Response, erro
|
||||
}
|
||||
|
||||
client := &http.Client{
|
||||
Timeout: time.Duration(r.clientTimeout) * time.Second,
|
||||
Timeout: r.clientTimeout,
|
||||
}
|
||||
|
||||
if r.withoutRedirects {
|
||||
|
||||
@@ -232,9 +232,9 @@ func TestRequestBuilder_CustomAcceptHeaderNotOverridden(t *testing.T) {
|
||||
|
||||
func TestRequestBuilder_WithTimeout(t *testing.T) {
|
||||
builder := NewRequestBuilder()
|
||||
builder = builder.WithTimeout(30)
|
||||
builder = builder.WithTimeout(30 * time.Second)
|
||||
|
||||
if builder.clientTimeout != 30 {
|
||||
if builder.clientTimeout != 30*time.Second {
|
||||
t.Errorf("Expected timeout to be 30, got %d", builder.clientTimeout)
|
||||
}
|
||||
}
|
||||
@@ -382,9 +382,8 @@ func TestRequestBuilder_ChainedMethods(t *testing.T) {
|
||||
WithUserAgent("TestAgent/1.0", "DefaultAgent/1.0").
|
||||
WithCookie("test=value").
|
||||
WithETag("etag123").
|
||||
WithTimeout(10).
|
||||
WithTimeout(10 * time.Second).
|
||||
ExecuteRequest(server.URL)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got %v", err)
|
||||
}
|
||||
@@ -409,7 +408,7 @@ func TestRequestBuilder_TimeoutConfiguration(t *testing.T) {
|
||||
|
||||
builder := NewRequestBuilder()
|
||||
start := time.Now()
|
||||
_, err := builder.WithTimeout(1).ExecuteRequest(server.URL)
|
||||
_, err := builder.WithTimeout(1 * time.Second).ExecuteRequest(server.URL)
|
||||
duration := time.Since(start)
|
||||
|
||||
if err == nil {
|
||||
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"math"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
@@ -54,18 +53,19 @@ func (r *ResponseHandler) ETag() string {
|
||||
return r.httpResponse.Header.Get("ETag")
|
||||
}
|
||||
|
||||
func (r *ResponseHandler) ExpiresInMinutes() int {
|
||||
func (r *ResponseHandler) Expires() time.Duration {
|
||||
expiresHeaderValue := r.httpResponse.Header.Get("Expires")
|
||||
if expiresHeaderValue != "" {
|
||||
t, err := time.Parse(time.RFC1123, expiresHeaderValue)
|
||||
if err == nil {
|
||||
return int(math.Ceil(time.Until(t).Minutes()))
|
||||
// This rounds up to the next minute by rounding down and just adding a minute.
|
||||
return time.Until(t).Truncate(time.Minute) + time.Minute
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (r *ResponseHandler) CacheControlMaxAgeInMinutes() int {
|
||||
func (r *ResponseHandler) CacheControlMaxAge() time.Duration {
|
||||
cacheControlHeaderValue := r.httpResponse.Header.Get("Cache-Control")
|
||||
if cacheControlHeaderValue != "" {
|
||||
for _, directive := range strings.Split(cacheControlHeaderValue, ",") {
|
||||
@@ -73,7 +73,7 @@ func (r *ResponseHandler) CacheControlMaxAgeInMinutes() int {
|
||||
if strings.HasPrefix(directive, "max-age=") {
|
||||
maxAge, err := strconv.Atoi(strings.TrimPrefix(directive, "max-age="))
|
||||
if err == nil {
|
||||
return int(math.Ceil(float64(maxAge) / 60))
|
||||
return time.Duration(maxAge) * time.Second
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -81,17 +81,17 @@ func (r *ResponseHandler) CacheControlMaxAgeInMinutes() int {
|
||||
return 0
|
||||
}
|
||||
|
||||
func (r *ResponseHandler) ParseRetryDelay() int {
|
||||
func (r *ResponseHandler) ParseRetryDelay() time.Duration {
|
||||
retryAfterHeaderValue := r.httpResponse.Header.Get("Retry-After")
|
||||
if retryAfterHeaderValue != "" {
|
||||
// First, try to parse as an integer (number of seconds)
|
||||
if seconds, err := strconv.Atoi(retryAfterHeaderValue); err == nil {
|
||||
return seconds
|
||||
return time.Duration(seconds) * time.Second
|
||||
}
|
||||
|
||||
// If not an integer, try to parse as an HTTP-date
|
||||
if t, err := time.Parse(time.RFC1123, retryAfterHeaderValue); err == nil {
|
||||
return int(time.Until(t).Seconds())
|
||||
return time.Until(t).Truncate(time.Second)
|
||||
}
|
||||
}
|
||||
return 0
|
||||
|
||||
@@ -72,7 +72,7 @@ func TestIsModified(t *testing.T) {
|
||||
func TestRetryDelay(t *testing.T) {
|
||||
var testCases = map[string]struct {
|
||||
RetryAfterHeader string
|
||||
ExpectedDelay int
|
||||
ExpectedDelay time.Duration
|
||||
}{
|
||||
"Empty header": {
|
||||
RetryAfterHeader: "",
|
||||
@@ -80,11 +80,11 @@ func TestRetryDelay(t *testing.T) {
|
||||
},
|
||||
"Integer value": {
|
||||
RetryAfterHeader: "42",
|
||||
ExpectedDelay: 42,
|
||||
ExpectedDelay: 42 * time.Second,
|
||||
},
|
||||
"HTTP-date": {
|
||||
RetryAfterHeader: time.Now().Add(42 * time.Second).Format(time.RFC1123),
|
||||
ExpectedDelay: 41,
|
||||
ExpectedDelay: 41 * time.Second,
|
||||
},
|
||||
}
|
||||
for name, tc := range testCases {
|
||||
@@ -105,20 +105,20 @@ func TestRetryDelay(t *testing.T) {
|
||||
|
||||
func TestExpiresInMinutes(t *testing.T) {
|
||||
var testCases = map[string]struct {
|
||||
ExpiresHeader string
|
||||
ExpectedMinutes int
|
||||
ExpiresHeader string
|
||||
Expected time.Duration
|
||||
}{
|
||||
"Empty header": {
|
||||
ExpiresHeader: "",
|
||||
ExpectedMinutes: 0,
|
||||
ExpiresHeader: "",
|
||||
Expected: 0,
|
||||
},
|
||||
"Valid Expires header": {
|
||||
ExpiresHeader: time.Now().Add(10 * time.Minute).Format(time.RFC1123),
|
||||
ExpectedMinutes: 10,
|
||||
ExpiresHeader: time.Now().Add(10 * time.Minute).Format(time.RFC1123),
|
||||
Expected: 10 * time.Minute,
|
||||
},
|
||||
"Invalid Expires header": {
|
||||
ExpiresHeader: "invalid-date",
|
||||
ExpectedMinutes: 0,
|
||||
ExpiresHeader: "invalid-date",
|
||||
Expected: 0,
|
||||
},
|
||||
}
|
||||
for name, tc := range testCases {
|
||||
@@ -130,8 +130,8 @@ func TestExpiresInMinutes(t *testing.T) {
|
||||
Header: header,
|
||||
},
|
||||
}
|
||||
if tc.ExpectedMinutes != rh.ExpiresInMinutes() {
|
||||
t.Errorf("Expected %d, got %d for scenario %q", tc.ExpectedMinutes, rh.ExpiresInMinutes(), name)
|
||||
if tc.Expected != rh.Expires() {
|
||||
t.Errorf("Expected %d, got %d for scenario %q", tc.Expected, rh.Expires(), name)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -140,23 +140,23 @@ func TestExpiresInMinutes(t *testing.T) {
|
||||
func TestCacheControlMaxAgeInMinutes(t *testing.T) {
|
||||
var testCases = map[string]struct {
|
||||
CacheControlHeader string
|
||||
ExpectedMinutes int
|
||||
Expected time.Duration
|
||||
}{
|
||||
"Empty header": {
|
||||
CacheControlHeader: "",
|
||||
ExpectedMinutes: 0,
|
||||
Expected: 0,
|
||||
},
|
||||
"Valid max-age": {
|
||||
CacheControlHeader: "max-age=600",
|
||||
ExpectedMinutes: 10,
|
||||
Expected: 10 * time.Minute,
|
||||
},
|
||||
"Invalid max-age": {
|
||||
CacheControlHeader: "max-age=invalid",
|
||||
ExpectedMinutes: 0,
|
||||
Expected: 0,
|
||||
},
|
||||
"Multiple directives": {
|
||||
CacheControlHeader: "no-cache, max-age=300",
|
||||
ExpectedMinutes: 5,
|
||||
Expected: 5 * time.Minute,
|
||||
},
|
||||
}
|
||||
for name, tc := range testCases {
|
||||
@@ -168,8 +168,8 @@ func TestCacheControlMaxAgeInMinutes(t *testing.T) {
|
||||
Header: header,
|
||||
},
|
||||
}
|
||||
if tc.ExpectedMinutes != rh.CacheControlMaxAgeInMinutes() {
|
||||
t.Errorf("Expected %d, got %d for scenario %q", tc.ExpectedMinutes, rh.CacheControlMaxAgeInMinutes(), name)
|
||||
if tc.Expected != rh.CacheControlMaxAge() {
|
||||
t.Errorf("Expected %d, got %d for scenario %q", tc.Expected, rh.CacheControlMaxAge(), name)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -414,7 +414,7 @@ func TestKeeplistRulesBehavior(t *testing.T) {
|
||||
// Tests for isBlockedGlobally function
|
||||
func TestIsBlockedGlobally(t *testing.T) {
|
||||
var err error
|
||||
config.Opts, err = config.NewParser().ParseEnvironmentVariables()
|
||||
config.Opts, err = config.NewConfigParser().ParseEnvironmentVariables()
|
||||
if err != nil {
|
||||
t.Fatalf(`Parsing failure: %v`, err)
|
||||
}
|
||||
@@ -429,7 +429,7 @@ func TestIsBlockedGlobally(t *testing.T) {
|
||||
os.Setenv("FILTER_ENTRY_MAX_AGE_DAYS", "30")
|
||||
defer os.Clearenv()
|
||||
|
||||
config.Opts, err = config.NewParser().ParseEnvironmentVariables()
|
||||
config.Opts, err = config.NewConfigParser().ParseEnvironmentVariables()
|
||||
if err != nil {
|
||||
t.Fatalf(`Parsing failure: %v`, err)
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"miniflux.app/v2/internal/config"
|
||||
"miniflux.app/v2/internal/integration"
|
||||
@@ -208,7 +209,7 @@ func RefreshFeed(store *storage.Storage, userID, feedID int64, forceRefresh bool
|
||||
}
|
||||
|
||||
weeklyEntryCount := 0
|
||||
refreshDelayInMinutes := 0
|
||||
var refreshDelay time.Duration
|
||||
if config.Opts.PollingScheduler() == model.SchedulerEntryFrequency {
|
||||
var weeklyCountErr error
|
||||
weeklyEntryCount, weeklyCountErr = store.WeeklyFeedEntryCount(userID, feedID)
|
||||
@@ -218,7 +219,7 @@ func RefreshFeed(store *storage.Storage, userID, feedID int64, forceRefresh bool
|
||||
}
|
||||
|
||||
originalFeed.CheckedNow()
|
||||
originalFeed.ScheduleNextCheck(weeklyEntryCount, refreshDelayInMinutes)
|
||||
originalFeed.ScheduleNextCheck(weeklyEntryCount, refreshDelay)
|
||||
|
||||
requestBuilder := fetcher.NewRequestBuilder()
|
||||
requestBuilder.WithUsernameAndPassword(originalFeed.Username, originalFeed.Password)
|
||||
@@ -242,15 +243,14 @@ func RefreshFeed(store *storage.Storage, userID, feedID int64, forceRefresh bool
|
||||
defer responseHandler.Close()
|
||||
|
||||
if responseHandler.IsRateLimited() {
|
||||
retryDelayInSeconds := responseHandler.ParseRetryDelay()
|
||||
refreshDelayInMinutes = retryDelayInSeconds / 60
|
||||
calculatedNextCheckIntervalInMinutes := originalFeed.ScheduleNextCheck(weeklyEntryCount, refreshDelayInMinutes)
|
||||
retryDelay := responseHandler.ParseRetryDelay()
|
||||
calculatedNextCheckInterval := originalFeed.ScheduleNextCheck(weeklyEntryCount, retryDelay)
|
||||
|
||||
slog.Warn("Feed is rate limited",
|
||||
slog.String("feed_url", originalFeed.FeedURL),
|
||||
slog.Int("retry_delay_in_seconds", retryDelayInSeconds),
|
||||
slog.Int("refresh_delay_in_minutes", refreshDelayInMinutes),
|
||||
slog.Int("calculated_next_check_interval_in_minutes", calculatedNextCheckIntervalInMinutes),
|
||||
slog.Int("retry_delay_in_seconds", int(retryDelay.Seconds())),
|
||||
slog.Int("refresh_delay_in_minutes", int(refreshDelay.Minutes())),
|
||||
slog.Int("calculated_next_check_interval_in_minutes", int(calculatedNextCheckInterval.Minutes())),
|
||||
slog.Time("new_next_check_at", originalFeed.NextCheckAt),
|
||||
)
|
||||
}
|
||||
@@ -316,22 +316,22 @@ func RefreshFeed(store *storage.Storage, userID, feedID int64, forceRefresh bool
|
||||
// Use the RSS TTL value, or the Cache-Control or Expires HTTP headers if available.
|
||||
// Otherwise, we use the default value from the configuration (min interval parameter).
|
||||
feedTTLValue := updatedFeed.TTL
|
||||
cacheControlMaxAgeValue := responseHandler.CacheControlMaxAgeInMinutes()
|
||||
expiresValue := responseHandler.ExpiresInMinutes()
|
||||
refreshDelayInMinutes = max(feedTTLValue, cacheControlMaxAgeValue, expiresValue)
|
||||
cacheControlMaxAgeValue := responseHandler.CacheControlMaxAge()
|
||||
expiresValue := responseHandler.Expires()
|
||||
refreshDelay = max(feedTTLValue, cacheControlMaxAgeValue, expiresValue)
|
||||
|
||||
// Set the next check at with updated arguments.
|
||||
calculatedNextCheckIntervalInMinutes := originalFeed.ScheduleNextCheck(weeklyEntryCount, refreshDelayInMinutes)
|
||||
calculatedNextCheckInterval := originalFeed.ScheduleNextCheck(weeklyEntryCount, refreshDelay)
|
||||
|
||||
slog.Debug("Updated next check date",
|
||||
slog.Int64("user_id", userID),
|
||||
slog.Int64("feed_id", feedID),
|
||||
slog.String("feed_url", originalFeed.FeedURL),
|
||||
slog.Int("feed_ttl_minutes", feedTTLValue),
|
||||
slog.Int("cache_control_max_age_in_minutes", cacheControlMaxAgeValue),
|
||||
slog.Int("expires_in_minutes", expiresValue),
|
||||
slog.Int("refresh_delay_in_minutes", refreshDelayInMinutes),
|
||||
slog.Int("calculated_next_check_interval_in_minutes", calculatedNextCheckIntervalInMinutes),
|
||||
slog.Int("feed_ttl_minutes", int(feedTTLValue.Minutes())),
|
||||
slog.Int("cache_control_max_age_in_minutes", int(cacheControlMaxAgeValue.Minutes())),
|
||||
slog.Int("expires_in_minutes", int(expiresValue.Minutes())),
|
||||
slog.Int("refresh_delay_in_minutes", int(refreshDelay.Minutes())),
|
||||
slog.Int("calculated_next_check_interval_in_minutes", int(calculatedNextCheckInterval.Minutes())),
|
||||
slog.Time("new_next_check_at", originalFeed.NextCheckAt),
|
||||
)
|
||||
|
||||
|
||||
@@ -53,8 +53,8 @@ func (f *iconFinder) findIcon() (*model.Icon, error) {
|
||||
)
|
||||
|
||||
if f.feedIconURL != "" {
|
||||
if icon, err := f.fetchFeedIcon(); err != nil {
|
||||
slog.Debug("Unable to download icon from feed",
|
||||
if icon, err := f.downloadIcon(f.feedIconURL); err != nil {
|
||||
slog.Debug("Unable to fetch the feed's icon",
|
||||
slog.String("website_url", f.websiteURL),
|
||||
slog.String("feed_icon_url", f.feedIconURL),
|
||||
slog.Any("error", err),
|
||||
@@ -64,13 +64,17 @@ func (f *iconFinder) findIcon() (*model.Icon, error) {
|
||||
}
|
||||
}
|
||||
|
||||
if icon, err := f.fetchIconsFromHTMLDocument(); err != nil {
|
||||
slog.Debug("Unable to fetch icons from HTML document",
|
||||
slog.String("website_url", f.websiteURL),
|
||||
slog.Any("error", err),
|
||||
)
|
||||
} else if icon != nil {
|
||||
return icon, nil
|
||||
// Try the website URL first, then fall back to the root URL if no icon is found.
|
||||
// The website URL may include a subdirectory (e.g., https://example.org/subfolder/), and icons can be referenced relative to that path.
|
||||
for _, documentURL := range []string{f.websiteURL, urllib.RootURL(f.websiteURL)} {
|
||||
if icon, err := f.fetchIconsFromHTMLDocument(documentURL); err != nil {
|
||||
slog.Debug("Unable to fetch icons from HTML document",
|
||||
slog.String("document_url", documentURL),
|
||||
slog.Any("error", err),
|
||||
)
|
||||
} else if icon != nil {
|
||||
return icon, nil
|
||||
}
|
||||
}
|
||||
|
||||
return f.fetchDefaultIcon()
|
||||
@@ -94,28 +98,12 @@ func (f *iconFinder) fetchDefaultIcon() (*model.Icon, error) {
|
||||
return icon, nil
|
||||
}
|
||||
|
||||
func (f *iconFinder) fetchFeedIcon() (*model.Icon, error) {
|
||||
slog.Debug("Fetching feed icon",
|
||||
slog.String("website_url", f.websiteURL),
|
||||
slog.String("feed_icon_url", f.feedIconURL),
|
||||
)
|
||||
|
||||
iconURL, err := urllib.AbsoluteURL(f.websiteURL, f.feedIconURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf(`icon: unable to convert icon URL to absolute URL: %w`, err)
|
||||
}
|
||||
|
||||
return f.downloadIcon(iconURL)
|
||||
}
|
||||
|
||||
func (f *iconFinder) fetchIconsFromHTMLDocument() (*model.Icon, error) {
|
||||
func (f *iconFinder) fetchIconsFromHTMLDocument(documentURL string) (*model.Icon, error) {
|
||||
slog.Debug("Searching icons from HTML document",
|
||||
slog.String("website_url", f.websiteURL),
|
||||
slog.String("document_url", documentURL),
|
||||
)
|
||||
|
||||
rootURL := urllib.RootURL(f.websiteURL)
|
||||
|
||||
responseHandler := fetcher.NewResponseHandler(f.requestBuilder.ExecuteRequest(rootURL))
|
||||
responseHandler := fetcher.NewResponseHandler(f.requestBuilder.ExecuteRequest(documentURL))
|
||||
defer responseHandler.Close()
|
||||
|
||||
if localizedError := responseHandler.LocalizedError(); localizedError != nil {
|
||||
@@ -123,6 +111,7 @@ func (f *iconFinder) fetchIconsFromHTMLDocument() (*model.Icon, error) {
|
||||
}
|
||||
|
||||
iconURLs, err := findIconURLsFromHTMLDocument(
|
||||
documentURL,
|
||||
responseHandler.Body(config.Opts.HTTPClientMaxBodySize()),
|
||||
responseHandler.ContentType(),
|
||||
)
|
||||
@@ -131,32 +120,27 @@ func (f *iconFinder) fetchIconsFromHTMLDocument() (*model.Icon, error) {
|
||||
}
|
||||
|
||||
slog.Debug("Searched icon from HTML document",
|
||||
slog.String("website_url", f.websiteURL),
|
||||
slog.String("document_url", documentURL),
|
||||
slog.String("icon_urls", strings.Join(iconURLs, ",")),
|
||||
)
|
||||
|
||||
for _, iconURL := range iconURLs {
|
||||
if strings.HasPrefix(iconURL, "data:") {
|
||||
slog.Debug("Found icon with data URL",
|
||||
slog.String("website_url", f.websiteURL),
|
||||
slog.String("document_url", documentURL),
|
||||
)
|
||||
return parseImageDataURL(iconURL)
|
||||
}
|
||||
|
||||
iconURL, err = urllib.AbsoluteURL(f.websiteURL, iconURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf(`icon: unable to convert icon URL to absolute URL: %w`, err)
|
||||
}
|
||||
|
||||
if icon, err := f.downloadIcon(iconURL); err != nil {
|
||||
slog.Debug("Unable to download icon from HTML document",
|
||||
slog.String("website_url", f.websiteURL),
|
||||
slog.String("document_url", documentURL),
|
||||
slog.String("icon_url", iconURL),
|
||||
slog.Any("error", err),
|
||||
)
|
||||
} else if icon != nil {
|
||||
slog.Debug("Downloaded icon from HTML document",
|
||||
slog.String("website_url", f.websiteURL),
|
||||
slog.String("document_url", documentURL),
|
||||
slog.String("icon_url", iconURL),
|
||||
)
|
||||
return icon, nil
|
||||
@@ -209,7 +193,7 @@ func resizeIcon(icon *model.Icon) *model.Icon {
|
||||
}
|
||||
|
||||
if !slices.Contains([]string{"image/jpeg", "image/png", "image/gif", "image/webp"}, icon.MimeType) {
|
||||
slog.Info("Icon resize skipped: unsupported MIME type", slog.String("mime_type", icon.MimeType))
|
||||
slog.Debug("Icon resize skipped: unsupported MIME type", slog.String("mime_type", icon.MimeType))
|
||||
return icon
|
||||
}
|
||||
|
||||
@@ -258,7 +242,7 @@ func resizeIcon(icon *model.Icon) *model.Icon {
|
||||
return icon
|
||||
}
|
||||
|
||||
func findIconURLsFromHTMLDocument(body io.Reader, contentType string) ([]string, error) {
|
||||
func findIconURLsFromHTMLDocument(documentURL string, body io.Reader, contentType string) ([]string, error) {
|
||||
htmlDocumentReader, err := encoding.NewCharsetReader(body, contentType)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("icon: unable to create charset reader: %w", err)
|
||||
@@ -282,11 +266,20 @@ func findIconURLsFromHTMLDocument(body io.Reader, contentType string) ([]string,
|
||||
|
||||
for _, s := range doc.Find(query).EachIter() {
|
||||
href, _ := s.Attr("href")
|
||||
if iconURL := strings.TrimSpace(href); iconURL != "" {
|
||||
iconURLs = append(iconURLs, iconURL)
|
||||
href = strings.TrimSpace(href)
|
||||
if href == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
if absoluteIconURL, err := urllib.AbsoluteURL(documentURL, href); err != nil {
|
||||
slog.Warn("Unable to convert icon URL to absolute URL", slog.Any("error", err), slog.String("icon_href", href))
|
||||
} else {
|
||||
iconURLs = append(iconURLs, absoluteIconURL)
|
||||
slog.Debug("Found icon URL in HTML document",
|
||||
slog.String("query", query),
|
||||
slog.String("icon_url", iconURL))
|
||||
slog.String("icon_href", href),
|
||||
slog.String("absolute_icon_url", absoluteIconURL),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -123,16 +123,16 @@ func TestFindIconURLsFromHTMLDocument_MultipleIcons(t *testing.T) {
|
||||
</head>
|
||||
</html>`
|
||||
|
||||
iconURLs, err := findIconURLsFromHTMLDocument(strings.NewReader(html), "text/html")
|
||||
iconURLs, err := findIconURLsFromHTMLDocument("https://example.org", strings.NewReader(html), "text/html")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
expected := []string{
|
||||
"/favicon.ico",
|
||||
"/shortcut-favicon.ico",
|
||||
"/icon-shortcut.ico",
|
||||
"/apple-touch-icon.png",
|
||||
"https://example.org/favicon.ico",
|
||||
"https://example.org/shortcut-favicon.ico",
|
||||
"https://example.org/icon-shortcut.ico",
|
||||
"https://example.org/apple-touch-icon.png",
|
||||
}
|
||||
|
||||
if len(iconURLs) != len(expected) {
|
||||
@@ -155,22 +155,22 @@ func TestFindIconURLsFromHTMLDocument_CaseInsensitiveRel(t *testing.T) {
|
||||
<link rel="SHORTCUT ICON" href="/favicon3.ico">
|
||||
<link rel="Shortcut Icon" href="/favicon4.ico">
|
||||
<link rel="ICON SHORTCUT" href="/favicon5.ico">
|
||||
<link rel="Icon Shortcut" href="/favicon6.ico">
|
||||
<link rel="Icon Shortcut" href="favicon6.ico">
|
||||
</head>
|
||||
</html>`
|
||||
|
||||
iconURLs, err := findIconURLsFromHTMLDocument(strings.NewReader(html), "text/html")
|
||||
iconURLs, err := findIconURLsFromHTMLDocument("https://example.org/folder/", strings.NewReader(html), "text/html")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
expected := []string{
|
||||
"/favicon1.ico",
|
||||
"/favicon2.ico",
|
||||
"/favicon3.ico",
|
||||
"/favicon4.ico",
|
||||
"/favicon5.ico",
|
||||
"/favicon6.ico",
|
||||
"https://example.org/favicon1.ico",
|
||||
"https://example.org/favicon2.ico",
|
||||
"https://example.org/favicon3.ico",
|
||||
"https://example.org/favicon4.ico",
|
||||
"https://example.org/favicon5.ico",
|
||||
"https://example.org/folder/favicon6.ico",
|
||||
}
|
||||
|
||||
if len(iconURLs) != len(expected) {
|
||||
@@ -194,7 +194,7 @@ func TestFindIconURLsFromHTMLDocument_NoIcons(t *testing.T) {
|
||||
</head>
|
||||
</html>`
|
||||
|
||||
iconURLs, err := findIconURLsFromHTMLDocument(strings.NewReader(html), "text/html")
|
||||
iconURLs, err := findIconURLsFromHTMLDocument("https://example.org", strings.NewReader(html), "text/html")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -215,12 +215,12 @@ func TestFindIconURLsFromHTMLDocument_EmptyHref(t *testing.T) {
|
||||
</head>
|
||||
</html>`
|
||||
|
||||
iconURLs, err := findIconURLsFromHTMLDocument(strings.NewReader(html), "text/html")
|
||||
iconURLs, err := findIconURLsFromHTMLDocument("https://example.org", strings.NewReader(html), "text/html")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
expected := []string{"/valid-icon.ico"}
|
||||
expected := []string{"https://example.org/valid-icon.ico"}
|
||||
|
||||
if len(iconURLs) != len(expected) {
|
||||
t.Fatalf("Expected %d icon URLs, got %d", len(expected), len(iconURLs))
|
||||
@@ -241,7 +241,7 @@ func TestFindIconURLsFromHTMLDocument_DataURLs(t *testing.T) {
|
||||
</head>
|
||||
</html>`
|
||||
|
||||
iconURLs, err := findIconURLsFromHTMLDocument(strings.NewReader(html), "text/html")
|
||||
iconURLs, err := findIconURLsFromHTMLDocument("https://example.org/folder", strings.NewReader(html), "text/html")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -250,7 +250,7 @@ func TestFindIconURLsFromHTMLDocument_DataURLs(t *testing.T) {
|
||||
// So both rel="icon" links are found first, then the rel="shortcut icon" link
|
||||
expected := []string{
|
||||
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChAGAhGAQ+QAAAABJRU5ErkJggg==",
|
||||
"/regular-icon.ico",
|
||||
"https://example.org/regular-icon.ico",
|
||||
"data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg'></svg>",
|
||||
}
|
||||
|
||||
@@ -277,17 +277,17 @@ func TestFindIconURLsFromHTMLDocument_RelativeAndAbsoluteURLs(t *testing.T) {
|
||||
</head>
|
||||
</html>`
|
||||
|
||||
iconURLs, err := findIconURLsFromHTMLDocument(strings.NewReader(html), "text/html")
|
||||
iconURLs, err := findIconURLsFromHTMLDocument("https://example.org/folder/", strings.NewReader(html), "text/html")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
expected := []string{
|
||||
"/absolute-path.ico",
|
||||
"relative-path.ico",
|
||||
"../parent-dir.ico",
|
||||
"https://example.org/absolute-path.ico",
|
||||
"https://example.org/folder/relative-path.ico",
|
||||
"https://example.org/parent-dir.ico",
|
||||
"https://example.com/external.ico",
|
||||
"//cdn.example.com/protocol-relative.ico",
|
||||
"https://cdn.example.com/protocol-relative.ico",
|
||||
}
|
||||
|
||||
if len(iconURLs) != len(expected) {
|
||||
@@ -311,7 +311,7 @@ func TestFindIconURLsFromHTMLDocument_InvalidHTML(t *testing.T) {
|
||||
</head>
|
||||
</html>`
|
||||
|
||||
iconURLs, err := findIconURLsFromHTMLDocument(strings.NewReader(html), "text/html")
|
||||
iconURLs, err := findIconURLsFromHTMLDocument("https://example.org", strings.NewReader(html), "text/html")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -324,7 +324,7 @@ func TestFindIconURLsFromHTMLDocument_InvalidHTML(t *testing.T) {
|
||||
// Should at least find the valid ones
|
||||
foundValidIcon := false
|
||||
for _, url := range iconURLs {
|
||||
if url == "/valid-before-error.ico" || url == "/valid-after-error.ico" {
|
||||
if url == "https://example.org/valid-before-error.ico" || url == "https://example.org/valid-after-error.ico" {
|
||||
foundValidIcon = true
|
||||
break
|
||||
}
|
||||
@@ -336,7 +336,7 @@ func TestFindIconURLsFromHTMLDocument_InvalidHTML(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestFindIconURLsFromHTMLDocument_EmptyDocument(t *testing.T) {
|
||||
iconURLs, err := findIconURLsFromHTMLDocument(strings.NewReader(""), "text/html")
|
||||
iconURLs, err := findIconURLsFromHTMLDocument("https://example.org", strings.NewReader(""), "text/html")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ type MediaItemElement struct {
|
||||
|
||||
// AllMediaThumbnails returns all thumbnail elements merged together.
|
||||
func (e *MediaItemElement) AllMediaThumbnails() []Thumbnail {
|
||||
var items []Thumbnail
|
||||
items := make([]Thumbnail, 0, len(e.MediaThumbnails)+len(e.MediaGroups))
|
||||
items = append(items, e.MediaThumbnails...)
|
||||
for _, mediaGroup := range e.MediaGroups {
|
||||
items = append(items, mediaGroup.MediaThumbnails...)
|
||||
@@ -33,7 +33,7 @@ func (e *MediaItemElement) AllMediaThumbnails() []Thumbnail {
|
||||
|
||||
// AllMediaContents returns all content elements merged together.
|
||||
func (e *MediaItemElement) AllMediaContents() []Content {
|
||||
var items []Content
|
||||
items := make([]Content, 0, len(e.MediaContents)+len(e.MediaGroups))
|
||||
items = append(items, e.MediaContents...)
|
||||
for _, mediaGroup := range e.MediaGroups {
|
||||
items = append(items, mediaGroup.MediaContents...)
|
||||
@@ -43,7 +43,7 @@ func (e *MediaItemElement) AllMediaContents() []Content {
|
||||
|
||||
// AllMediaPeerLinks returns all peer link elements merged together.
|
||||
func (e *MediaItemElement) AllMediaPeerLinks() []PeerLink {
|
||||
var items []PeerLink
|
||||
items := make([]PeerLink, 0, len(e.MediaPeerLinks)+len(e.MediaGroups))
|
||||
items = append(items, e.MediaPeerLinks...)
|
||||
for _, mediaGroup := range e.MediaGroups {
|
||||
items = append(items, mediaGroup.MediaPeerLinks...)
|
||||
|
||||
@@ -31,11 +31,11 @@ func getVideoIDFromYouTubeURL(websiteURL string) string {
|
||||
}
|
||||
|
||||
func shouldFetchYouTubeWatchTimeForSingleEntry(entry *model.Entry) bool {
|
||||
return config.Opts.FetchYouTubeWatchTime() && config.Opts.YouTubeApiKey() == "" && isYouTubeVideoURL(entry.URL)
|
||||
return config.Opts.FetchYouTubeWatchTime() && config.Opts.YouTubeAPIKey() == "" && isYouTubeVideoURL(entry.URL)
|
||||
}
|
||||
|
||||
func shouldFetchYouTubeWatchTimeInBulk() bool {
|
||||
return config.Opts.FetchYouTubeWatchTime() && config.Opts.YouTubeApiKey() != ""
|
||||
return config.Opts.FetchYouTubeWatchTime() && config.Opts.YouTubeAPIKey() != ""
|
||||
}
|
||||
|
||||
func fetchYouTubeWatchTimeForSingleEntry(websiteURL string) (int, error) {
|
||||
@@ -82,7 +82,7 @@ func fetchYouTubeWatchTimeFromApiInBulk(videoIDs []string) (map[string]time.Dura
|
||||
|
||||
apiQuery := url.Values{}
|
||||
apiQuery.Set("id", strings.Join(videoIDs, ","))
|
||||
apiQuery.Set("key", config.Opts.YouTubeApiKey())
|
||||
apiQuery.Set("key", config.Opts.YouTubeAPIKey())
|
||||
apiQuery.Set("part", "contentDetails")
|
||||
|
||||
apiURL := url.URL{
|
||||
|
||||
@@ -67,7 +67,7 @@ func TestRewriteWithNoMatchingRule(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRewriteYoutubeVideoLink(t *testing.T) {
|
||||
config.Opts = config.NewOptions()
|
||||
config.Opts = config.NewConfigOptions()
|
||||
|
||||
controlEntry := &model.Entry{
|
||||
URL: "https://www.youtube.com/watch?v=1234",
|
||||
@@ -87,7 +87,7 @@ func TestRewriteYoutubeVideoLink(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRewriteYoutubeShortLink(t *testing.T) {
|
||||
config.Opts = config.NewOptions()
|
||||
config.Opts = config.NewConfigOptions()
|
||||
|
||||
controlEntry := &model.Entry{
|
||||
URL: "https://www.youtube.com/shorts/1LUWKWZkPjo",
|
||||
@@ -107,7 +107,7 @@ func TestRewriteYoutubeShortLink(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRewriteIncorrectYoutubeLink(t *testing.T) {
|
||||
config.Opts = config.NewOptions()
|
||||
config.Opts = config.NewConfigOptions()
|
||||
|
||||
controlEntry := &model.Entry{
|
||||
URL: "https://www.youtube.com/some-page",
|
||||
@@ -131,7 +131,7 @@ func TestRewriteYoutubeLinkAndCustomEmbedURL(t *testing.T) {
|
||||
os.Setenv("YOUTUBE_EMBED_URL_OVERRIDE", "https://invidious.custom/embed/")
|
||||
|
||||
var err error
|
||||
parser := config.NewParser()
|
||||
parser := config.NewConfigParser()
|
||||
config.Opts, err = parser.ParseEnvironmentVariables()
|
||||
|
||||
if err != nil {
|
||||
@@ -156,7 +156,7 @@ func TestRewriteYoutubeLinkAndCustomEmbedURL(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRewriteYoutubeVideoLinkUsingInvidious(t *testing.T) {
|
||||
config.Opts = config.NewOptions()
|
||||
config.Opts = config.NewConfigOptions()
|
||||
controlEntry := &model.Entry{
|
||||
URL: "https://www.youtube.com/watch?v=1234",
|
||||
Title: `A title`,
|
||||
@@ -176,7 +176,7 @@ func TestRewriteYoutubeVideoLinkUsingInvidious(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRewriteYoutubeShortLinkUsingInvidious(t *testing.T) {
|
||||
config.Opts = config.NewOptions()
|
||||
config.Opts = config.NewConfigOptions()
|
||||
controlEntry := &model.Entry{
|
||||
URL: "https://www.youtube.com/shorts/1LUWKWZkPjo",
|
||||
Title: `A title`,
|
||||
@@ -196,7 +196,7 @@ func TestRewriteYoutubeShortLinkUsingInvidious(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAddYoutubeVideoFromId(t *testing.T) {
|
||||
config.Opts = config.NewOptions()
|
||||
config.Opts = config.NewConfigOptions()
|
||||
|
||||
scenarios := map[string]string{
|
||||
// Test with single YouTube ID
|
||||
@@ -239,7 +239,7 @@ func TestAddYoutubeVideoFromIdWithCustomEmbedURL(t *testing.T) {
|
||||
os.Setenv("YOUTUBE_EMBED_URL_OVERRIDE", "https://invidious.custom/embed/")
|
||||
|
||||
var err error
|
||||
parser := config.NewParser()
|
||||
parser := config.NewConfigParser()
|
||||
config.Opts, err = parser.ParseEnvironmentVariables()
|
||||
|
||||
if err != nil {
|
||||
|
||||
@@ -55,7 +55,7 @@ func (r *rssAdapter) buildFeed(baseURL string) *model.Feed {
|
||||
// Get TTL if defined.
|
||||
if r.rss.Channel.TTL != "" {
|
||||
if ttl, err := strconv.Atoi(r.rss.Channel.TTL); err == nil {
|
||||
feed.TTL = ttl
|
||||
feed.TTL = time.Duration(ttl) * time.Minute
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2151,7 +2151,7 @@ func TestParseFeedWithTTLField(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if feed.TTL != 60 {
|
||||
if feed.TTL != 60*time.Minute {
|
||||
t.Errorf("Incorrect TTL, got: %d", feed.TTL)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,71 +18,71 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
allowedHTMLTagsAndAttributes = map[string]map[string]struct{}{
|
||||
"a": {"href": {}, "title": {}, "id": {}},
|
||||
"abbr": {"title": {}},
|
||||
"acronym": {"title": {}},
|
||||
allowedHTMLTagsAndAttributes = map[string][]string{
|
||||
"a": {"href", "title", "id"},
|
||||
"abbr": {"title"},
|
||||
"acronym": {"title"},
|
||||
"aside": {},
|
||||
"audio": {"src": {}},
|
||||
"audio": {"src"},
|
||||
"blockquote": {},
|
||||
"b": {},
|
||||
"br": {},
|
||||
"caption": {},
|
||||
"cite": {},
|
||||
"code": {},
|
||||
"dd": {"id": {}},
|
||||
"dd": {"id"},
|
||||
"del": {},
|
||||
"dfn": {},
|
||||
"dl": {"id": {}},
|
||||
"dt": {"id": {}},
|
||||
"dl": {"id"},
|
||||
"dt": {"id"},
|
||||
"em": {},
|
||||
"figcaption": {},
|
||||
"figure": {},
|
||||
"h1": {"id": {}},
|
||||
"h2": {"id": {}},
|
||||
"h3": {"id": {}},
|
||||
"h4": {"id": {}},
|
||||
"h5": {"id": {}},
|
||||
"h6": {"id": {}},
|
||||
"h1": {"id"},
|
||||
"h2": {"id"},
|
||||
"h3": {"id"},
|
||||
"h4": {"id"},
|
||||
"h5": {"id"},
|
||||
"h6": {"id"},
|
||||
"hr": {},
|
||||
"iframe": {"width": {}, "height": {}, "frameborder": {}, "src": {}, "allowfullscreen": {}},
|
||||
"img": {"alt": {}, "title": {}, "src": {}, "srcset": {}, "sizes": {}, "width": {}, "height": {}, "fetchpriority": {}, "decoding": {}},
|
||||
"iframe": {"width", "height", "frameborder", "src", "allowfullscreen"},
|
||||
"img": {"alt", "title", "src", "srcset", "sizes", "width", "height", "fetchpriority", "decoding"},
|
||||
"ins": {},
|
||||
"kbd": {},
|
||||
"li": {"id": {}},
|
||||
"ol": {"id": {}},
|
||||
"li": {"id"},
|
||||
"ol": {"id"},
|
||||
"p": {},
|
||||
"picture": {},
|
||||
"pre": {},
|
||||
"q": {"cite": {}},
|
||||
"q": {"cite"},
|
||||
"rp": {},
|
||||
"rt": {},
|
||||
"rtc": {},
|
||||
"ruby": {},
|
||||
"s": {},
|
||||
"samp": {},
|
||||
"source": {"src": {}, "type": {}, "srcset": {}, "sizes": {}, "media": {}},
|
||||
"source": {"src", "type", "srcset", "sizes", "media"},
|
||||
"strong": {},
|
||||
"sub": {},
|
||||
"sup": {"id": {}},
|
||||
"sup": {"id"},
|
||||
"table": {},
|
||||
"td": {"rowspan": {}, "colspan": {}},
|
||||
"td": {"rowspan", "colspan"},
|
||||
"tfoot": {},
|
||||
"th": {"rowspan": {}, "colspan": {}},
|
||||
"th": {"rowspan", "colspan"},
|
||||
"thead": {},
|
||||
"time": {"datetime": {}},
|
||||
"time": {"datetime"},
|
||||
"tr": {},
|
||||
"u": {},
|
||||
"ul": {"id": {}},
|
||||
"ul": {"id"},
|
||||
"var": {},
|
||||
"video": {"poster": {}, "height": {}, "width": {}, "src": {}},
|
||||
"video": {"poster", "height", "width", "src"},
|
||||
"wbr": {},
|
||||
|
||||
// MathML: https://w3c.github.io/mathml-core/ and https://developer.mozilla.org/en-US/docs/Web/MathML/Reference/Element
|
||||
"annotation": {},
|
||||
"annotation-xml": {},
|
||||
"maction": {},
|
||||
"math": {"xmlns": {}},
|
||||
"math": {"xmlns"},
|
||||
"merror": {},
|
||||
"mfrac": {},
|
||||
"mi": {},
|
||||
@@ -419,8 +419,7 @@ func isValidTag(tagName string) bool {
|
||||
|
||||
func isValidAttribute(tagName, attributeName string) bool {
|
||||
if attributes, ok := allowedHTMLTagsAndAttributes[tagName]; ok {
|
||||
_, allowed := attributes[attributeName]
|
||||
return allowed
|
||||
return slices.Contains(attributes, attributeName)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -392,7 +392,7 @@ func TestInvalidNestedTag(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestInvalidIFrame(t *testing.T) {
|
||||
config.Opts = config.NewOptions()
|
||||
config.Opts = config.NewConfigOptions()
|
||||
|
||||
input := `<iframe src="http://example.org/"></iframe>`
|
||||
expected := ``
|
||||
@@ -404,7 +404,7 @@ func TestInvalidIFrame(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestSameDomainIFrame(t *testing.T) {
|
||||
config.Opts = config.NewOptions()
|
||||
config.Opts = config.NewConfigOptions()
|
||||
|
||||
input := `<iframe src="http://example.com/test"></iframe>`
|
||||
expected := ``
|
||||
@@ -416,7 +416,7 @@ func TestSameDomainIFrame(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestInvidiousIFrame(t *testing.T) {
|
||||
config.Opts = config.NewOptions()
|
||||
config.Opts = config.NewConfigOptions()
|
||||
|
||||
input := `<iframe src="https://yewtu.be/watch?v=video_id"></iframe>`
|
||||
expected := `<iframe src="https://yewtu.be/watch?v=video_id" sandbox="allow-scripts allow-same-origin allow-popups allow-popups-to-escape-sandbox" loading="lazy"></iframe>`
|
||||
@@ -432,7 +432,7 @@ func TestCustomYoutubeEmbedURL(t *testing.T) {
|
||||
|
||||
defer os.Clearenv()
|
||||
var err error
|
||||
if config.Opts, err = config.NewParser().ParseEnvironmentVariables(); err != nil {
|
||||
if config.Opts, err = config.NewConfigParser().ParseEnvironmentVariables(); err != nil {
|
||||
t.Fatalf(`Parsing failure: %v`, err)
|
||||
}
|
||||
|
||||
@@ -446,7 +446,7 @@ func TestCustomYoutubeEmbedURL(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestIFrameWithChildElements(t *testing.T) {
|
||||
config.Opts = config.NewOptions()
|
||||
config.Opts = config.NewConfigOptions()
|
||||
|
||||
input := `<iframe src="https://www.youtube.com/"><p>test</p></iframe>`
|
||||
expected := `<iframe src="https://www.youtube.com/" sandbox="allow-scripts allow-same-origin allow-popups allow-popups-to-escape-sandbox" loading="lazy"></iframe>`
|
||||
@@ -850,7 +850,7 @@ func TestReplaceYoutubeURLWithCustomURL(t *testing.T) {
|
||||
os.Setenv("YOUTUBE_EMBED_URL_OVERRIDE", "https://invidious.custom/embed/")
|
||||
|
||||
var err error
|
||||
config.Opts, err = config.NewParser().ParseEnvironmentVariables()
|
||||
config.Opts, err = config.NewConfigParser().ParseEnvironmentVariables()
|
||||
if err != nil {
|
||||
t.Fatalf(`Parsing failure: %v`, err)
|
||||
}
|
||||
|
||||
@@ -70,24 +70,15 @@ func (f *subscriptionFinder) FindSubscriptions(websiteURL, rssBridgeURL string,
|
||||
}
|
||||
|
||||
// Step 2) Check if the website URL is a YouTube channel.
|
||||
slog.Debug("Try to detect feeds from YouTube channel page", slog.String("website_url", websiteURL))
|
||||
if subscriptions, localizedError := f.findSubscriptionsFromYouTubeChannelPage(websiteURL); localizedError != nil {
|
||||
slog.Debug("Try to detect feeds for a YouTube page", slog.String("website_url", websiteURL))
|
||||
if subscriptions, localizedError := f.findSubscriptionsFromYouTube(websiteURL); localizedError != nil {
|
||||
return nil, localizedError
|
||||
} else if len(subscriptions) > 0 {
|
||||
slog.Debug("Subscriptions found from YouTube channel page", slog.String("website_url", websiteURL), slog.Any("subscriptions", subscriptions))
|
||||
slog.Debug("Subscriptions found from YouTube page", slog.String("website_url", websiteURL), slog.Any("subscriptions", subscriptions))
|
||||
return subscriptions, nil
|
||||
}
|
||||
|
||||
// Step 3) Check if the website URL is a YouTube playlist.
|
||||
slog.Debug("Try to detect feeds from YouTube playlist page", slog.String("website_url", websiteURL))
|
||||
if subscriptions, localizedError := f.findSubscriptionsFromYouTubePlaylistPage(websiteURL); localizedError != nil {
|
||||
return nil, localizedError
|
||||
} else if len(subscriptions) > 0 {
|
||||
slog.Debug("Subscriptions found from YouTube playlist page", slog.String("website_url", websiteURL), slog.Any("subscriptions", subscriptions))
|
||||
return subscriptions, nil
|
||||
}
|
||||
|
||||
// Step 4) Parse web page to find feeds from HTML meta tags.
|
||||
// Step 3) Parse web page to find feeds from HTML meta tags.
|
||||
slog.Debug("Try to detect feeds from HTML meta tags",
|
||||
slog.String("website_url", websiteURL),
|
||||
slog.String("content_type", responseHandler.ContentType()),
|
||||
@@ -99,7 +90,7 @@ func (f *subscriptionFinder) FindSubscriptions(websiteURL, rssBridgeURL string,
|
||||
return subscriptions, nil
|
||||
}
|
||||
|
||||
// Step 5) Check if the website URL can use RSS-Bridge.
|
||||
// Step 4) Check if the website URL can use RSS-Bridge.
|
||||
if rssBridgeURL != "" {
|
||||
slog.Debug("Try to detect feeds with RSS-Bridge", slog.String("website_url", websiteURL))
|
||||
if subscriptions, localizedError := f.findSubscriptionsFromRSSBridge(websiteURL, rssBridgeURL, rssBridgeToken); localizedError != nil {
|
||||
@@ -110,7 +101,7 @@ func (f *subscriptionFinder) FindSubscriptions(websiteURL, rssBridgeURL string,
|
||||
}
|
||||
}
|
||||
|
||||
// Step 6) Check if the website has a known feed URL.
|
||||
// Step 5) Check if the website has a known feed URL.
|
||||
slog.Debug("Try to detect feeds from well-known URLs", slog.String("website_url", websiteURL))
|
||||
if subscriptions, localizedError := f.findSubscriptionsFromWellKnownURLs(websiteURL); localizedError != nil {
|
||||
return nil, localizedError
|
||||
@@ -124,10 +115,9 @@ func (f *subscriptionFinder) FindSubscriptions(websiteURL, rssBridgeURL string,
|
||||
|
||||
func (f *subscriptionFinder) findSubscriptionsFromWebPage(websiteURL, contentType string, body io.Reader) (Subscriptions, *locale.LocalizedErrorWrapper) {
|
||||
queries := map[string]string{
|
||||
"link[type='application/rss+xml']": parser.FormatRSS,
|
||||
"link[type='application/atom+xml']": parser.FormatAtom,
|
||||
"link[type='application/json']": parser.FormatJSON,
|
||||
"link[type='application/feed+json']": parser.FormatJSON,
|
||||
"link[type='application/rss+xml']": parser.FormatRSS,
|
||||
"link[type='application/atom+xml']": parser.FormatAtom,
|
||||
"link[type='application/json'], link[type='application/feed+json']": parser.FormatJSON,
|
||||
}
|
||||
|
||||
htmlDocumentReader, err := encoding.NewCharsetReader(body, contentType)
|
||||
@@ -154,24 +144,24 @@ func (f *subscriptionFinder) findSubscriptionsFromWebPage(websiteURL, contentTyp
|
||||
subscription := new(subscription)
|
||||
subscription.Type = kind
|
||||
|
||||
if title, exists := s.Attr("title"); exists {
|
||||
subscription.Title = title
|
||||
if feedURL, exists := s.Attr("href"); exists && feedURL != "" {
|
||||
subscription.URL, err = urllib.AbsoluteURL(websiteURL, feedURL)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
} else {
|
||||
return // without an url, there can be no subscription.
|
||||
}
|
||||
|
||||
if feedURL, exists := s.Attr("href"); exists {
|
||||
if feedURL != "" {
|
||||
subscription.URL, err = urllib.AbsoluteURL(websiteURL, feedURL)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
if title, exists := s.Attr("title"); exists {
|
||||
subscription.Title = title
|
||||
}
|
||||
|
||||
if subscription.Title == "" {
|
||||
subscription.Title = subscription.URL
|
||||
}
|
||||
|
||||
if subscription.URL != "" && !subscriptionURLs[subscription.URL] {
|
||||
if !subscriptionURLs[subscription.URL] {
|
||||
subscriptionURLs[subscription.URL] = true
|
||||
subscriptions = append(subscriptions, subscription)
|
||||
}
|
||||
@@ -283,40 +273,26 @@ func (f *subscriptionFinder) findSubscriptionsFromRSSBridge(websiteURL, rssBridg
|
||||
return subscriptions, nil
|
||||
}
|
||||
|
||||
func (f *subscriptionFinder) findSubscriptionsFromYouTubeChannelPage(websiteURL string) (Subscriptions, *locale.LocalizedErrorWrapper) {
|
||||
decodedUrl, err := url.Parse(websiteURL)
|
||||
func (f *subscriptionFinder) findSubscriptionsFromYouTube(websiteURL string) (Subscriptions, *locale.LocalizedErrorWrapper) {
|
||||
decodedURL, err := url.Parse(websiteURL)
|
||||
if err != nil {
|
||||
return nil, locale.NewLocalizedErrorWrapper(err, "error.invalid_site_url", err)
|
||||
}
|
||||
|
||||
if !strings.HasSuffix(decodedUrl.Host, "youtube.com") {
|
||||
slog.Debug("This website is not a YouTube page, the regex doesn't match", slog.String("website_url", websiteURL))
|
||||
if !strings.HasSuffix(decodedURL.Host, "youtube.com") {
|
||||
slog.Debug("YouTube feed discovery skipped: not a YouTube domain", slog.String("website_url", websiteURL))
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
if _, channelID, found := strings.Cut(decodedUrl.Path, "channel/"); found {
|
||||
if _, channelID, found := strings.Cut(decodedURL.Path, "channel/"); found {
|
||||
feedURL := "https://www.youtube.com/feeds/videos.xml?channel_id=" + channelID
|
||||
return Subscriptions{NewSubscription(websiteURL, feedURL, parser.FormatAtom)}, nil
|
||||
}
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (f *subscriptionFinder) findSubscriptionsFromYouTubePlaylistPage(websiteURL string) (Subscriptions, *locale.LocalizedErrorWrapper) {
|
||||
decodedUrl, err := url.Parse(websiteURL)
|
||||
if err != nil {
|
||||
return nil, locale.NewLocalizedErrorWrapper(err, "error.invalid_site_url", err)
|
||||
}
|
||||
|
||||
if !strings.HasSuffix(decodedUrl.Host, "youtube.com") {
|
||||
slog.Debug("This website is not a YouTube page, the regex doesn't match", slog.String("website_url", websiteURL))
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
if (strings.HasPrefix(decodedUrl.Path, "/watch") && decodedUrl.Query().Has("list")) || strings.HasPrefix(decodedUrl.Path, "/playlist") {
|
||||
playlistID := decodedUrl.Query().Get("list")
|
||||
feedURL := "https://www.youtube.com/feeds/videos.xml?playlist_id=" + playlistID
|
||||
return Subscriptions{NewSubscription(websiteURL, feedURL, parser.FormatAtom)}, nil
|
||||
return Subscriptions{NewSubscription(decodedURL.String(), feedURL, parser.FormatAtom)}, nil
|
||||
}
|
||||
|
||||
if strings.HasPrefix(decodedURL.Path, "/watch") || strings.HasPrefix(decodedURL.Path, "/playlist") {
|
||||
if playlistID := decodedURL.Query().Get("list"); playlistID != "" {
|
||||
feedURL := "https://www.youtube.com/feeds/videos.xml?playlist_id=" + playlistID
|
||||
return Subscriptions{NewSubscription(decodedURL.String(), feedURL, parser.FormatAtom)}, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, nil
|
||||
|
||||
@@ -8,96 +8,7 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestFindYoutubePlaylistFeed(t *testing.T) {
|
||||
type testResult struct {
|
||||
websiteURL string
|
||||
feedURL string
|
||||
discoveryError bool
|
||||
}
|
||||
|
||||
scenarios := []testResult{
|
||||
// Video URL
|
||||
{
|
||||
websiteURL: "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
|
||||
feedURL: "",
|
||||
},
|
||||
// Video URL with position argument
|
||||
{
|
||||
websiteURL: "https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=1",
|
||||
feedURL: "",
|
||||
},
|
||||
// Video URL with position argument
|
||||
{
|
||||
websiteURL: "https://www.youtube.com/watch?t=1&v=dQw4w9WgXcQ",
|
||||
feedURL: "",
|
||||
},
|
||||
// Channel URL
|
||||
{
|
||||
websiteURL: "https://www.youtube.com/channel/UC-Qj80avWItNRjkZ41rzHyw",
|
||||
feedURL: "",
|
||||
},
|
||||
// Channel URL with name
|
||||
{
|
||||
websiteURL: "https://www.youtube.com/@ABCDEFG",
|
||||
feedURL: "",
|
||||
},
|
||||
// Playlist URL
|
||||
{
|
||||
websiteURL: "https://www.youtube.com/playlist?list=PLOOwEPgFWm_NHcQd9aCi5JXWASHO_n5uR",
|
||||
feedURL: "https://www.youtube.com/feeds/videos.xml?playlist_id=PLOOwEPgFWm_NHcQd9aCi5JXWASHO_n5uR",
|
||||
},
|
||||
// Playlist URL with video ID
|
||||
{
|
||||
websiteURL: "https://www.youtube.com/watch?v=dQw4w9WgXcQ&list=PLOOwEPgFWm_N42HlCLhqyJ0ZBWr5K1QDM",
|
||||
feedURL: "https://www.youtube.com/feeds/videos.xml?playlist_id=PLOOwEPgFWm_N42HlCLhqyJ0ZBWr5K1QDM",
|
||||
},
|
||||
// Playlist URL with video ID and index argument
|
||||
{
|
||||
websiteURL: "https://www.youtube.com/watch?v=6IutBmRJNLk&list=PLOOwEPgFWm_NHcQd9aCi5JXWASHO_n5uR&index=4",
|
||||
feedURL: "https://www.youtube.com/feeds/videos.xml?playlist_id=PLOOwEPgFWm_NHcQd9aCi5JXWASHO_n5uR",
|
||||
},
|
||||
// Non-Youtube URL
|
||||
{
|
||||
websiteURL: "https://www.example.com/channel/UC-Qj80avWItNRjkZ41rzHyw",
|
||||
feedURL: "",
|
||||
},
|
||||
// Invalid URL
|
||||
{
|
||||
websiteURL: "https://example|org/",
|
||||
feedURL: "",
|
||||
discoveryError: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
subscriptions, localizedError := NewSubscriptionFinder(nil).findSubscriptionsFromYouTubePlaylistPage(scenario.websiteURL)
|
||||
if scenario.discoveryError {
|
||||
if localizedError == nil {
|
||||
t.Fatalf(`Parsing an invalid URL should return an error`)
|
||||
}
|
||||
}
|
||||
|
||||
if scenario.feedURL == "" {
|
||||
if len(subscriptions) > 0 {
|
||||
t.Fatalf(`Parsing a non-playlist URL should not return any subscription: %q`, scenario.websiteURL)
|
||||
}
|
||||
} else {
|
||||
if localizedError != nil {
|
||||
t.Fatalf(`Parsing a correctly formatted YouTube playlist page should not return any error: %v`, localizedError)
|
||||
}
|
||||
|
||||
if len(subscriptions) != 1 {
|
||||
t.Fatalf(`Incorrect number of subscriptions returned`)
|
||||
}
|
||||
|
||||
if subscriptions[0].URL != scenario.feedURL {
|
||||
t.Errorf(`Unexpected Feed, got %s, instead of %s`, subscriptions[0].URL, scenario.feedURL)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindYoutubeChannelFeed(t *testing.T) {
|
||||
func TestFindYoutubeFeed(t *testing.T) {
|
||||
type testResult struct {
|
||||
websiteURL string
|
||||
feedURL string
|
||||
@@ -133,16 +44,21 @@ func TestFindYoutubeChannelFeed(t *testing.T) {
|
||||
// Playlist URL
|
||||
{
|
||||
websiteURL: "https://www.youtube.com/playlist?list=PLOOwEPgFWm_NHcQd9aCi5JXWASHO_n5uR",
|
||||
feedURL: "",
|
||||
feedURL: "https://www.youtube.com/feeds/videos.xml?playlist_id=PLOOwEPgFWm_NHcQd9aCi5JXWASHO_n5uR",
|
||||
},
|
||||
// Playlist URL with video ID
|
||||
{
|
||||
websiteURL: "https://www.youtube.com/watch?v=dQw4w9WgXcQ&list=PLOOwEPgFWm_N42HlCLhqyJ0ZBWr5K1QDM",
|
||||
feedURL: "",
|
||||
feedURL: "https://www.youtube.com/feeds/videos.xml?playlist_id=PLOOwEPgFWm_N42HlCLhqyJ0ZBWr5K1QDM",
|
||||
},
|
||||
// Playlist URL with video ID and index argument
|
||||
{
|
||||
websiteURL: "https://www.youtube.com/watch?v=6IutBmRJNLk&list=PLOOwEPgFWm_NHcQd9aCi5JXWASHO_n5uR&index=4",
|
||||
feedURL: "https://www.youtube.com/feeds/videos.xml?playlist_id=PLOOwEPgFWm_NHcQd9aCi5JXWASHO_n5uR",
|
||||
},
|
||||
// Empty playlist ID parameter
|
||||
{
|
||||
websiteURL: "https://www.youtube.com/playlist?list=",
|
||||
feedURL: "",
|
||||
},
|
||||
// Non-Youtube URL
|
||||
@@ -159,7 +75,7 @@ func TestFindYoutubeChannelFeed(t *testing.T) {
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
subscriptions, localizedError := NewSubscriptionFinder(nil).findSubscriptionsFromYouTubeChannelPage(scenario.websiteURL)
|
||||
subscriptions, localizedError := NewSubscriptionFinder(nil).findSubscriptionsFromYouTube(scenario.websiteURL)
|
||||
if scenario.discoveryError {
|
||||
if localizedError == nil {
|
||||
t.Fatalf(`Parsing an invalid URL should return an error`)
|
||||
@@ -168,11 +84,11 @@ func TestFindYoutubeChannelFeed(t *testing.T) {
|
||||
|
||||
if scenario.feedURL == "" {
|
||||
if len(subscriptions) > 0 {
|
||||
t.Fatalf(`Parsing a non-channel URL should not return any subscription: %q`, scenario.websiteURL)
|
||||
t.Fatalf(`Parsing an invalid URL should not return any subscription: %q -> %v`, scenario.websiteURL, subscriptions)
|
||||
}
|
||||
} else {
|
||||
if localizedError != nil {
|
||||
t.Fatalf(`Parsing a correctly formatted YouTube channel page should not return any error: %v`, localizedError)
|
||||
t.Fatalf(`Parsing a correctly formatted YouTube playlist or channel page should not return any error: %v`, localizedError)
|
||||
}
|
||||
|
||||
if len(subscriptions) != 1 {
|
||||
@@ -180,7 +96,7 @@ func TestFindYoutubeChannelFeed(t *testing.T) {
|
||||
}
|
||||
|
||||
if subscriptions[0].URL != scenario.feedURL {
|
||||
t.Errorf(`Unexpected Feed, got %s, instead of %s`, subscriptions[0].URL, scenario.feedURL)
|
||||
t.Errorf(`Unexpected feed, got %s, instead of %s`, subscriptions[0].URL, scenario.feedURL)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -390,7 +306,7 @@ func TestParseWebPageWithMultipleFeeds(t *testing.T) {
|
||||
<html>
|
||||
<head>
|
||||
<link href="http://example.org/atom.xml" rel="alternate" type="application/atom+xml" title="Atom Feed">
|
||||
<link href="http://example.org/feed.json" rel="alternate" type="application/feed+json" title="JSON Feed">
|
||||
<link href="http://example.org/feed.json" rel="alternate" type="application/json" title="JSON Feed">
|
||||
</head>
|
||||
<body>
|
||||
</body>
|
||||
|
||||
@@ -54,8 +54,10 @@ func NewXMLDecoder(data io.ReadSeeker) *xml.Decoder {
|
||||
// filterValidXMLChars filters inplace invalid XML characters.
|
||||
// This function is inspired from bytes.Map
|
||||
func filterValidXMLChars(s []byte) []byte {
|
||||
j := 0
|
||||
for i := 0; i < len(s); {
|
||||
var i uint // declaring it as an uint removes a bound check in the loop.
|
||||
var j int
|
||||
|
||||
for i = 0; i < uint(len(s)); {
|
||||
wid := 1
|
||||
r := rune(s[i])
|
||||
if r >= utf8.RuneSelf {
|
||||
@@ -67,7 +69,7 @@ func filterValidXMLChars(s []byte) []byte {
|
||||
j += wid
|
||||
}
|
||||
}
|
||||
i += wid
|
||||
i += uint(wid)
|
||||
}
|
||||
return s[:j]
|
||||
}
|
||||
|
||||
+12
-10
@@ -392,9 +392,9 @@ func (s *Storage) RefreshFeedEntries(userID, feedID int64, entries model.Entries
|
||||
return newEntries, nil
|
||||
}
|
||||
|
||||
// ArchiveEntries changes the status of entries to "removed" after the given number of days.
|
||||
func (s *Storage) ArchiveEntries(status string, days, limit int) (int64, error) {
|
||||
if days < 0 || limit <= 0 {
|
||||
// ArchiveEntries changes the status of entries to "removed" after the interval (24h minimum).
|
||||
func (s *Storage) ArchiveEntries(status string, interval time.Duration, limit int) (int64, error) {
|
||||
if interval < 0 || limit <= 0 {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
@@ -419,6 +419,8 @@ func (s *Storage) ArchiveEntries(status string, days, limit int) (int64, error)
|
||||
)
|
||||
`
|
||||
|
||||
days := max(int(interval/(24*time.Hour)), 1)
|
||||
|
||||
result, err := s.db.Exec(query, model.EntryStatusRemoved, status, fmt.Sprintf("%d days", days), limit)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf(`store: unable to archive %s entries: %v`, status, err)
|
||||
@@ -477,12 +479,12 @@ func (s *Storage) SetEntriesStatusCount(userID int64, entryIDs []int64, status s
|
||||
return visible, nil
|
||||
}
|
||||
|
||||
// SetEntriesBookmarkedState updates the bookmarked state for the given list of entries.
|
||||
func (s *Storage) SetEntriesBookmarkedState(userID int64, entryIDs []int64, starred bool) error {
|
||||
// SetEntriesStarredState updates the starred state for the given list of entries.
|
||||
func (s *Storage) SetEntriesStarredState(userID int64, entryIDs []int64, starred bool) error {
|
||||
query := `UPDATE entries SET starred=$1, changed_at=now() WHERE user_id=$2 AND id=ANY($3)`
|
||||
result, err := s.db.Exec(query, starred, userID, pq.Array(entryIDs))
|
||||
if err != nil {
|
||||
return fmt.Errorf(`store: unable to update the bookmarked state %v: %v`, entryIDs, err)
|
||||
return fmt.Errorf(`store: unable to update the starred state %v: %v`, entryIDs, err)
|
||||
}
|
||||
|
||||
count, err := result.RowsAffected()
|
||||
@@ -497,17 +499,17 @@ func (s *Storage) SetEntriesBookmarkedState(userID int64, entryIDs []int64, star
|
||||
return nil
|
||||
}
|
||||
|
||||
// ToggleBookmark toggles entry bookmark value.
|
||||
func (s *Storage) ToggleBookmark(userID int64, entryID int64) error {
|
||||
// ToggleStarred toggles entry starred value.
|
||||
func (s *Storage) ToggleStarred(userID int64, entryID int64) error {
|
||||
query := `UPDATE entries SET starred = NOT starred, changed_at=now() WHERE user_id=$1 AND id=$2`
|
||||
result, err := s.db.Exec(query, userID, entryID)
|
||||
if err != nil {
|
||||
return fmt.Errorf(`store: unable to toggle bookmark flag for entry #%d: %v`, entryID, err)
|
||||
return fmt.Errorf(`store: unable to toggle starred flag for entry #%d: %v`, entryID, err)
|
||||
}
|
||||
|
||||
count, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf(`store: unable to toggle bookmark flag for entry #%d: %v`, entryID, err)
|
||||
return fmt.Errorf(`store: unable to toggle starred flag for entry #%d: %v`, entryID, err)
|
||||
}
|
||||
|
||||
if count == 0 {
|
||||
|
||||
@@ -111,10 +111,14 @@ func (s *Storage) CountAllFeeds() map[string]int64 {
|
||||
|
||||
// CountUserFeedsWithErrors returns the number of feeds with parsing errors that belong to the given user.
|
||||
func (s *Storage) CountUserFeedsWithErrors(userID int64) int {
|
||||
pollingParsingErrorLimit := min(config.Opts.PollingParsingErrorLimit(), 1)
|
||||
pollingParsingErrorLimit := config.Opts.PollingParsingErrorLimit()
|
||||
if pollingParsingErrorLimit <= 0 {
|
||||
pollingParsingErrorLimit = 1
|
||||
}
|
||||
query := `SELECT count(*) FROM feeds WHERE user_id=$1 AND parsing_error_count >= $2`
|
||||
var result int
|
||||
if s.db.QueryRow(query, userID, pollingParsingErrorLimit).Scan(&result) != nil {
|
||||
err := s.db.QueryRow(query, userID, pollingParsingErrorLimit).Scan(&result)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
|
||||
@@ -123,10 +127,14 @@ func (s *Storage) CountUserFeedsWithErrors(userID int64) int {
|
||||
|
||||
// CountAllFeedsWithErrors returns the number of feeds with parsing errors.
|
||||
func (s *Storage) CountAllFeedsWithErrors() int {
|
||||
pollingParsingErrorLimit := min(config.Opts.PollingParsingErrorLimit(), 1)
|
||||
pollingParsingErrorLimit := config.Opts.PollingParsingErrorLimit()
|
||||
if pollingParsingErrorLimit <= 0 {
|
||||
pollingParsingErrorLimit = 1
|
||||
}
|
||||
query := `SELECT count(*) FROM feeds WHERE parsing_error_count >= $1`
|
||||
var result int
|
||||
if s.db.QueryRow(query, pollingParsingErrorLimit).Scan(&result) != nil {
|
||||
err := s.db.QueryRow(query, pollingParsingErrorLimit).Scan(&result)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
|
||||
@@ -142,11 +150,11 @@ func (s *Storage) Feeds(userID int64) (model.Feeds, error) {
|
||||
|
||||
func getFeedsSorted(builder *FeedQueryBuilder) (model.Feeds, error) {
|
||||
result, err := builder.GetFeeds()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
if err == nil {
|
||||
sort.Sort(byStateAndName{result})
|
||||
return result, nil
|
||||
}
|
||||
sort.Sort(byStateAndName{result})
|
||||
return result, nil
|
||||
return result, err
|
||||
}
|
||||
|
||||
// FeedsWithCounters returns all feeds of the given user with counters of read and unread entries.
|
||||
|
||||
+121
-96
@@ -130,6 +130,7 @@ func (s *Storage) Integration(userID int64) (*model.Integration, error) {
|
||||
wallabag_client_secret,
|
||||
wallabag_username,
|
||||
wallabag_password,
|
||||
wallabag_tags,
|
||||
notion_enabled,
|
||||
notion_token,
|
||||
notion_page_id,
|
||||
@@ -220,7 +221,12 @@ func (s *Storage) Integration(userID int64) (*model.Integration, error) {
|
||||
rssbridge_token,
|
||||
karakeep_enabled,
|
||||
karakeep_api_key,
|
||||
karakeep_url
|
||||
karakeep_url,
|
||||
linktaco_enabled,
|
||||
linktaco_api_token,
|
||||
linktaco_org_slug,
|
||||
linktaco_tags,
|
||||
linktaco_visibility
|
||||
FROM
|
||||
integrations
|
||||
WHERE
|
||||
@@ -249,6 +255,7 @@ func (s *Storage) Integration(userID int64) (*model.Integration, error) {
|
||||
&integration.WallabagClientSecret,
|
||||
&integration.WallabagUsername,
|
||||
&integration.WallabagPassword,
|
||||
&integration.WallabagTags,
|
||||
&integration.NotionEnabled,
|
||||
&integration.NotionToken,
|
||||
&integration.NotionPageID,
|
||||
@@ -340,6 +347,11 @@ func (s *Storage) Integration(userID int64) (*model.Integration, error) {
|
||||
&integration.KarakeepEnabled,
|
||||
&integration.KarakeepAPIKey,
|
||||
&integration.KarakeepURL,
|
||||
&integration.LinktacoEnabled,
|
||||
&integration.LinktacoAPIToken,
|
||||
&integration.LinktacoOrgSlug,
|
||||
&integration.LinktacoTags,
|
||||
&integration.LinktacoVisibility,
|
||||
)
|
||||
switch {
|
||||
case err == sql.ErrNoRows:
|
||||
@@ -374,102 +386,108 @@ func (s *Storage) UpdateIntegration(integration *model.Integration) error {
|
||||
wallabag_client_secret=$15,
|
||||
wallabag_username=$16,
|
||||
wallabag_password=$17,
|
||||
nunux_keeper_enabled=$18,
|
||||
nunux_keeper_url=$19,
|
||||
nunux_keeper_api_key=$20,
|
||||
googlereader_enabled=$21,
|
||||
googlereader_username=$22,
|
||||
googlereader_password=$23,
|
||||
telegram_bot_enabled=$24,
|
||||
telegram_bot_token=$25,
|
||||
telegram_bot_chat_id=$26,
|
||||
telegram_bot_topic_id=$27,
|
||||
telegram_bot_disable_web_page_preview=$28,
|
||||
telegram_bot_disable_notification=$29,
|
||||
telegram_bot_disable_buttons=$30,
|
||||
espial_enabled=$31,
|
||||
espial_url=$32,
|
||||
espial_api_key=$33,
|
||||
espial_tags=$34,
|
||||
linkace_enabled=$35,
|
||||
linkace_url=$36,
|
||||
linkace_api_key=$37,
|
||||
linkace_tags=$38,
|
||||
linkace_is_private=$39,
|
||||
linkace_check_disabled=$40,
|
||||
linkding_enabled=$41,
|
||||
linkding_url=$42,
|
||||
linkding_api_key=$43,
|
||||
linkding_tags=$44,
|
||||
linkding_mark_as_unread=$45,
|
||||
matrix_bot_enabled=$46,
|
||||
matrix_bot_user=$47,
|
||||
matrix_bot_password=$48,
|
||||
matrix_bot_url=$49,
|
||||
matrix_bot_chat_id=$50,
|
||||
notion_enabled=$51,
|
||||
notion_token=$52,
|
||||
notion_page_id=$53,
|
||||
readwise_enabled=$54,
|
||||
readwise_api_key=$55,
|
||||
apprise_enabled=$56,
|
||||
apprise_url=$57,
|
||||
apprise_services_url=$58,
|
||||
readeck_enabled=$59,
|
||||
readeck_url=$60,
|
||||
readeck_api_key=$61,
|
||||
readeck_labels=$62,
|
||||
readeck_only_url=$63,
|
||||
shiori_enabled=$64,
|
||||
shiori_url=$65,
|
||||
shiori_username=$66,
|
||||
shiori_password=$67,
|
||||
shaarli_enabled=$68,
|
||||
shaarli_url=$69,
|
||||
shaarli_api_secret=$70,
|
||||
webhook_enabled=$71,
|
||||
webhook_url=$72,
|
||||
webhook_secret=$73,
|
||||
rssbridge_enabled=$74,
|
||||
rssbridge_url=$75,
|
||||
omnivore_enabled=$76,
|
||||
omnivore_api_key=$77,
|
||||
omnivore_url=$78,
|
||||
linkwarden_enabled=$79,
|
||||
linkwarden_url=$80,
|
||||
linkwarden_api_key=$81,
|
||||
raindrop_enabled=$82,
|
||||
raindrop_token=$83,
|
||||
raindrop_collection_id=$84,
|
||||
raindrop_tags=$85,
|
||||
betula_enabled=$86,
|
||||
betula_url=$87,
|
||||
betula_token=$88,
|
||||
ntfy_enabled=$89,
|
||||
ntfy_topic=$90,
|
||||
ntfy_url=$91,
|
||||
ntfy_api_token=$92,
|
||||
ntfy_username=$93,
|
||||
ntfy_password=$94,
|
||||
ntfy_icon_url=$95,
|
||||
ntfy_internal_links=$96,
|
||||
cubox_enabled=$97,
|
||||
cubox_api_link=$98,
|
||||
discord_enabled=$99,
|
||||
discord_webhook_link=$100,
|
||||
slack_enabled=$101,
|
||||
slack_webhook_link=$102,
|
||||
pushover_enabled=$103,
|
||||
pushover_user=$104,
|
||||
pushover_token=$105,
|
||||
pushover_device=$106,
|
||||
pushover_prefix=$107,
|
||||
rssbridge_token=$108,
|
||||
karakeep_enabled=$109,
|
||||
karakeep_api_key=$110,
|
||||
karakeep_url=$111
|
||||
wallabag_tags=$18,
|
||||
nunux_keeper_enabled=$19,
|
||||
nunux_keeper_url=$20,
|
||||
nunux_keeper_api_key=$21,
|
||||
googlereader_enabled=$22,
|
||||
googlereader_username=$23,
|
||||
googlereader_password=$24,
|
||||
telegram_bot_enabled=$25,
|
||||
telegram_bot_token=$26,
|
||||
telegram_bot_chat_id=$27,
|
||||
telegram_bot_topic_id=$28,
|
||||
telegram_bot_disable_web_page_preview=$29,
|
||||
telegram_bot_disable_notification=$30,
|
||||
telegram_bot_disable_buttons=$31,
|
||||
espial_enabled=$32,
|
||||
espial_url=$33,
|
||||
espial_api_key=$34,
|
||||
espial_tags=$35,
|
||||
linkace_enabled=$36,
|
||||
linkace_url=$37,
|
||||
linkace_api_key=$38,
|
||||
linkace_tags=$39,
|
||||
linkace_is_private=$40,
|
||||
linkace_check_disabled=$41,
|
||||
linkding_enabled=$42,
|
||||
linkding_url=$43,
|
||||
linkding_api_key=$44,
|
||||
linkding_tags=$45,
|
||||
linkding_mark_as_unread=$46,
|
||||
matrix_bot_enabled=$47,
|
||||
matrix_bot_user=$48,
|
||||
matrix_bot_password=$49,
|
||||
matrix_bot_url=$50,
|
||||
matrix_bot_chat_id=$51,
|
||||
notion_enabled=$52,
|
||||
notion_token=$53,
|
||||
notion_page_id=$54,
|
||||
readwise_enabled=$55,
|
||||
readwise_api_key=$56,
|
||||
apprise_enabled=$57,
|
||||
apprise_url=$58,
|
||||
apprise_services_url=$59,
|
||||
readeck_enabled=$60,
|
||||
readeck_url=$61,
|
||||
readeck_api_key=$62,
|
||||
readeck_labels=$63,
|
||||
readeck_only_url=$64,
|
||||
shiori_enabled=$65,
|
||||
shiori_url=$66,
|
||||
shiori_username=$67,
|
||||
shiori_password=$68,
|
||||
shaarli_enabled=$69,
|
||||
shaarli_url=$70,
|
||||
shaarli_api_secret=$71,
|
||||
webhook_enabled=$72,
|
||||
webhook_url=$73,
|
||||
webhook_secret=$74,
|
||||
rssbridge_enabled=$75,
|
||||
rssbridge_url=$76,
|
||||
omnivore_enabled=$77,
|
||||
omnivore_api_key=$78,
|
||||
omnivore_url=$79,
|
||||
linkwarden_enabled=$80,
|
||||
linkwarden_url=$81,
|
||||
linkwarden_api_key=$82,
|
||||
raindrop_enabled=$83,
|
||||
raindrop_token=$84,
|
||||
raindrop_collection_id=$85,
|
||||
raindrop_tags=$86,
|
||||
betula_enabled=$87,
|
||||
betula_url=$88,
|
||||
betula_token=$89,
|
||||
ntfy_enabled=$90,
|
||||
ntfy_topic=$91,
|
||||
ntfy_url=$92,
|
||||
ntfy_api_token=$93,
|
||||
ntfy_username=$94,
|
||||
ntfy_password=$95,
|
||||
ntfy_icon_url=$96,
|
||||
ntfy_internal_links=$97,
|
||||
cubox_enabled=$98,
|
||||
cubox_api_link=$99,
|
||||
discord_enabled=$100,
|
||||
discord_webhook_link=$101,
|
||||
slack_enabled=$102,
|
||||
slack_webhook_link=$103,
|
||||
pushover_enabled=$104,
|
||||
pushover_user=$105,
|
||||
pushover_token=$106,
|
||||
pushover_device=$107,
|
||||
pushover_prefix=$108,
|
||||
rssbridge_token=$109,
|
||||
karakeep_enabled=$110,
|
||||
karakeep_api_key=$111,
|
||||
karakeep_url=$112,
|
||||
linktaco_enabled=$113,
|
||||
linktaco_api_token=$114,
|
||||
linktaco_org_slug=$115,
|
||||
linktaco_tags=$116,
|
||||
linktaco_visibility=$117
|
||||
WHERE
|
||||
user_id=$112
|
||||
user_id=$118
|
||||
`
|
||||
_, err := s.db.Exec(
|
||||
query,
|
||||
@@ -490,6 +508,7 @@ func (s *Storage) UpdateIntegration(integration *model.Integration) error {
|
||||
integration.WallabagClientSecret,
|
||||
integration.WallabagUsername,
|
||||
integration.WallabagPassword,
|
||||
integration.WallabagTags,
|
||||
integration.NunuxKeeperEnabled,
|
||||
integration.NunuxKeeperURL,
|
||||
integration.NunuxKeeperAPIKey,
|
||||
@@ -584,6 +603,11 @@ func (s *Storage) UpdateIntegration(integration *model.Integration) error {
|
||||
integration.KarakeepEnabled,
|
||||
integration.KarakeepAPIKey,
|
||||
integration.KarakeepURL,
|
||||
integration.LinktacoEnabled,
|
||||
integration.LinktacoAPIToken,
|
||||
integration.LinktacoOrgSlug,
|
||||
integration.LinktacoTags,
|
||||
integration.LinktacoVisibility,
|
||||
integration.UserID,
|
||||
)
|
||||
|
||||
@@ -614,6 +638,7 @@ func (s *Storage) HasSaveEntry(userID int64) (result bool) {
|
||||
readwise_enabled='t' OR
|
||||
linkace_enabled='t' OR
|
||||
linkding_enabled='t' OR
|
||||
linktaco_enabled='t' OR
|
||||
linkwarden_enabled='t' OR
|
||||
apprise_enabled='t' OR
|
||||
shiori_enabled='t' OR
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"miniflux.app/v2/internal/model"
|
||||
)
|
||||
@@ -122,14 +123,17 @@ func (s *Storage) FlushAllSessions() (err error) {
|
||||
return nil
|
||||
}
|
||||
|
||||
// CleanOldSessions removes sessions older than specified days.
|
||||
func (s *Storage) CleanOldSessions(days int) int64 {
|
||||
// CleanOldSessions removes sessions older than specified interval (24h minimum).
|
||||
func (s *Storage) CleanOldSessions(interval time.Duration) int64 {
|
||||
query := `
|
||||
DELETE FROM
|
||||
sessions
|
||||
WHERE
|
||||
created_at < now() - $1::interval
|
||||
`
|
||||
|
||||
days := max(int(interval/(24*time.Hour)), 1)
|
||||
|
||||
result, err := s.db.Exec(query, fmt.Sprintf("%d days", days))
|
||||
if err != nil {
|
||||
return 0
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package storage // import "miniflux.app/v2/internal/storage"
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Timezones returns all timezones supported by the database.
|
||||
func (s *Storage) Timezones() (map[string]string, error) {
|
||||
timezones := make(map[string]string)
|
||||
rows, err := s.db.Query(`SELECT name FROM pg_timezone_names ORDER BY name ASC`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf(`store: unable to fetch timezones: %v`, err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var timezone string
|
||||
if err := rows.Scan(&timezone); err != nil {
|
||||
return nil, fmt.Errorf(`store: unable to fetch timezones row: %v`, err)
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(timezone, "posix") && !strings.HasPrefix(timezone, "SystemV") && timezone != "localtime" {
|
||||
timezones[timezone] = timezone
|
||||
}
|
||||
}
|
||||
|
||||
return timezones, nil
|
||||
}
|
||||
@@ -7,12 +7,13 @@ import (
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"miniflux.app/v2/internal/model"
|
||||
)
|
||||
|
||||
// UserSessions returns the list of sessions for the given user.
|
||||
func (s *Storage) UserSessions(userID int64) (model.UserSessions, error) {
|
||||
func (s *Storage) UserSessions(userID int64) ([]model.UserSession, error) {
|
||||
query := `
|
||||
SELECT
|
||||
id,
|
||||
@@ -32,7 +33,7 @@ func (s *Storage) UserSessions(userID int64) (model.UserSessions, error) {
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var sessions model.UserSessions
|
||||
var sessions []model.UserSession
|
||||
for rows.Next() {
|
||||
var session model.UserSession
|
||||
err := rows.Scan(
|
||||
@@ -43,7 +44,6 @@ func (s *Storage) UserSessions(userID int64) (model.UserSessions, error) {
|
||||
&session.UserAgent,
|
||||
&session.IP,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf(`store: unable to fetch user session row: %v`, err)
|
||||
}
|
||||
@@ -164,14 +164,17 @@ func (s *Storage) RemoveUserSessionByID(userID, sessionID int64) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// CleanOldUserSessions removes user sessions older than specified days.
|
||||
func (s *Storage) CleanOldUserSessions(days int) int64 {
|
||||
// CleanOldUserSessions removes user sessions older than specified interval (24h minimum).
|
||||
func (s *Storage) CleanOldUserSessions(interval time.Duration) int64 {
|
||||
query := `
|
||||
DELETE FROM
|
||||
user_sessions
|
||||
WHERE
|
||||
created_at < now() - $1::interval
|
||||
`
|
||||
|
||||
days := max(int(interval/(24*time.Hour)), 1)
|
||||
|
||||
result, err := s.db.Exec(query, fmt.Sprintf("%d days", days))
|
||||
if err != nil {
|
||||
return 0
|
||||
|
||||
+49
-31
@@ -7,7 +7,6 @@ import (
|
||||
"bytes"
|
||||
"embed"
|
||||
"html/template"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"miniflux.app/v2/internal/locale"
|
||||
@@ -21,9 +20,6 @@ var commonTemplateFiles embed.FS
|
||||
//go:embed templates/views/*.html
|
||||
var viewTemplateFiles embed.FS
|
||||
|
||||
//go:embed templates/standalone/*.html
|
||||
var standaloneTemplateFiles embed.FS
|
||||
|
||||
// Engine handles the templating system.
|
||||
type Engine struct {
|
||||
templates map[string]*template.Template
|
||||
@@ -38,43 +34,66 @@ func NewEngine(router *mux.Router) *Engine {
|
||||
}
|
||||
}
|
||||
|
||||
// ParseTemplates parses template files embed into the application.
|
||||
func (e *Engine) ParseTemplates() error {
|
||||
func (e *Engine) ParseTemplates() {
|
||||
funcMap := e.funcMap.Map()
|
||||
commonTemplates := template.Must(template.New("").Funcs(funcMap).ParseFS(commonTemplateFiles, "templates/common/*.html"))
|
||||
|
||||
dirEntries, err := viewTemplateFiles.ReadDir("templates/views")
|
||||
if err != nil {
|
||||
return err
|
||||
templates := map[string][]string{ // this isn't a global variable so that it can be garbage-collected.
|
||||
"about.html": {"layout.html", "settings_menu.html"},
|
||||
"add_subscription.html": {"feed_menu.html", "layout.html", "settings_menu.html"},
|
||||
"api_keys.html": {"layout.html", "settings_menu.html"},
|
||||
"starred_entries.html": {"item_meta.html", "layout.html", "pagination.html"},
|
||||
"categories.html": {"layout.html"},
|
||||
"category_entries.html": {"item_meta.html", "layout.html", "pagination.html"},
|
||||
"category_feeds.html": {"feed_list.html", "layout.html"},
|
||||
"choose_subscription.html": {"feed_menu.html", "layout.html"},
|
||||
"create_api_key.html": {"layout.html", "settings_menu.html"},
|
||||
"create_category.html": {"layout.html"},
|
||||
"create_user.html": {"layout.html", "settings_menu.html"},
|
||||
"edit_category.html": {"layout.html", "settings_menu.html"},
|
||||
"edit_feed.html": {"layout.html"},
|
||||
"edit_user.html": {"layout.html", "settings_menu.html"},
|
||||
"entry.html": {"layout.html"},
|
||||
"feed_entries.html": {"item_meta.html", "layout.html", "pagination.html"},
|
||||
"feeds.html": {"feed_list.html", "feed_menu.html", "item_meta.html", "layout.html", "pagination.html"},
|
||||
"history_entries.html": {"item_meta.html", "layout.html", "pagination.html"},
|
||||
"import.html": {"feed_menu.html", "layout.html"},
|
||||
"integrations.html": {"layout.html", "settings_menu.html"},
|
||||
"login.html": {"layout.html"},
|
||||
"offline.html": {},
|
||||
"search.html": {"item_meta.html", "layout.html", "pagination.html"},
|
||||
"sessions.html": {"layout.html", "settings_menu.html"},
|
||||
"settings.html": {"layout.html", "settings_menu.html"},
|
||||
"shared_entries.html": {"layout.html", "pagination.html"},
|
||||
"tag_entries.html": {"item_meta.html", "layout.html", "pagination.html"},
|
||||
"unread_entries.html": {"item_meta.html", "layout.html", "pagination.html"},
|
||||
"users.html": {"layout.html", "settings_menu.html"},
|
||||
"webauthn_rename.html": {"layout.html"},
|
||||
}
|
||||
for _, dirEntry := range dirEntries {
|
||||
fullName := "templates/views/" + dirEntry.Name()
|
||||
slog.Debug("Parsing template", slog.String("template_name", fullName))
|
||||
commonTemplatesClone, err := commonTemplates.Clone()
|
||||
if err != nil {
|
||||
panic("Unable to clone the common template")
|
||||
|
||||
for name, dependencies := range templates {
|
||||
tpl := template.New("").Funcs(funcMap)
|
||||
for _, dependency := range dependencies {
|
||||
template.Must(tpl.ParseFS(commonTemplateFiles, "templates/common/"+dependency))
|
||||
}
|
||||
e.templates[dirEntry.Name()] = template.Must(commonTemplatesClone.ParseFS(viewTemplateFiles, fullName))
|
||||
e.templates[name] = template.Must(tpl.ParseFS(viewTemplateFiles, "templates/views/"+name))
|
||||
}
|
||||
|
||||
dirEntries, err = standaloneTemplateFiles.ReadDir("templates/standalone")
|
||||
if err != nil {
|
||||
return err
|
||||
// Sanity check to ensure that all templates are correctly declared in `templates`.
|
||||
if entries, err := viewTemplateFiles.ReadDir("templates/views"); err == nil {
|
||||
for _, entry := range entries {
|
||||
if _, ok := e.templates[entry.Name()]; !ok {
|
||||
panic("Template " + entry.Name() + " isn't declared in ParseTemplates")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
panic("Unable to read all embedded views templates")
|
||||
}
|
||||
for _, dirEntry := range dirEntries {
|
||||
fullName := "templates/standalone/" + dirEntry.Name()
|
||||
slog.Debug("Parsing template", slog.String("template_name", fullName))
|
||||
e.templates[dirEntry.Name()] = template.Must(template.New(dirEntry.Name()).Funcs(funcMap).ParseFS(standaloneTemplateFiles, fullName))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Render process a template.
|
||||
func (e *Engine) Render(name string, data map[string]any) []byte {
|
||||
tpl, ok := e.templates[name]
|
||||
if !ok {
|
||||
panic("This template does not exists: " + name)
|
||||
panic("The template " + name + " does not exists.")
|
||||
}
|
||||
|
||||
printer := locale.NewPrinter(data["language"].(string))
|
||||
@@ -89,8 +108,7 @@ func (e *Engine) Render(name string, data map[string]any) []byte {
|
||||
})
|
||||
|
||||
var b bytes.Buffer
|
||||
err := tpl.ExecuteTemplate(&b, "base", data)
|
||||
if err != nil {
|
||||
if err := tpl.ExecuteTemplate(&b, "base", data); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@ func (f *funcMap) Map() template.FuncMap {
|
||||
"baseURL": config.Opts.BaseURL,
|
||||
"rootURL": config.Opts.RootURL,
|
||||
"disableLocalAuth": config.Opts.DisableLocalAuth,
|
||||
"oidcProviderName": config.Opts.OIDCProviderName,
|
||||
"oidcProviderName": config.Opts.OAuth2OIDCProviderName,
|
||||
"hasOAuth2Provider": func(provider string) bool {
|
||||
return config.Opts.OAuth2Provider() == provider
|
||||
},
|
||||
@@ -60,7 +60,7 @@ func (f *funcMap) Map() template.FuncMap {
|
||||
"safeJS": func(str string) template.JS {
|
||||
return template.JS(str)
|
||||
},
|
||||
"noescape": func(str string) template.HTML {
|
||||
"safeHTML": func(str string) template.HTML {
|
||||
return template.HTML(str)
|
||||
},
|
||||
"proxyFilter": func(data string) string {
|
||||
|
||||
@@ -28,13 +28,13 @@
|
||||
<li class="item-meta-icons-star">
|
||||
<button
|
||||
aria-describedby="entry-title-{{ .entry.ID }}"
|
||||
data-toggle-bookmark="true"
|
||||
data-bookmark-url="{{ route "toggleBookmark" "entryID" .entry.ID }}"
|
||||
data-toggle-starred="true"
|
||||
data-star-url="{{ route "toggleStarred" "entryID" .entry.ID }}"
|
||||
data-label-loading="{{ t "entry.state.saving" }}"
|
||||
data-label-star="{{ t "entry.bookmark.toggle.on" }}"
|
||||
data-label-unstar="{{ t "entry.bookmark.toggle.off" }}"
|
||||
data-label-star="{{ t "entry.starred.toggle.on" }}"
|
||||
data-label-unstar="{{ t "entry.starred.toggle.off" }}"
|
||||
data-value="{{ if .entry.Starred }}star{{ else }}unstar{{ end }}"
|
||||
>{{ if .entry.Starred }}{{ icon "unstar" }}{{ else }}{{ icon "star" }}{{ end }}<span class="icon-label">{{ if .entry.Starred }}{{ t "entry.bookmark.toggle.off" }}{{ else }}{{ t "entry.bookmark.toggle.on" }}{{ end }}</span></button>
|
||||
>{{ if .entry.Starred }}{{ icon "unstar" }}{{ else }}{{ icon "star" }}{{ end }}<span class="icon-label">{{ if .entry.Starred }}{{ t "entry.starred.toggle.off" }}{{ else }}{{ t "entry.starred.toggle.on" }}{{ end }}</span></button>
|
||||
</li>
|
||||
{{ if .entry.ShareCode }}
|
||||
<li class="item-meta-icons-share">
|
||||
|
||||
@@ -5,45 +5,42 @@
|
||||
<meta charset="utf-8">
|
||||
<title>{{template "title" .}} - Miniflux</title>
|
||||
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-title" content="Miniflux">
|
||||
<link rel="manifest" href="{{ route "webManifest" }}" crossorigin="use-credentials">
|
||||
|
||||
<meta name="robots" content="noindex,nofollow">
|
||||
<meta name="googlebot" content="notranslate">
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<meta name="referrer" content="no-referrer">
|
||||
<meta name="google" content="notranslate">
|
||||
<meta name="robots" content="noindex,nofollow">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
||||
<meta name="theme-color" content="{{ theme_color .theme "light" }}" media="(prefers-color-scheme: light)">
|
||||
<meta name="theme-color" content="{{ theme_color .theme "dark" }}" media="(prefers-color-scheme: dark)">
|
||||
|
||||
<link rel="manifest" href="{{ route "webManifest" }}" crossorigin="use-credentials">
|
||||
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="{{ route "appIcon" "filename" "icon-16.png" }}">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="{{ route "appIcon" "filename" "icon-32.png" }}">
|
||||
<link rel="icon" type="image/png" sizes="128x128" href="{{ route "appIcon" "filename" "icon-128.png" }}">
|
||||
<link rel="icon" type="image/png" sizes="192x192" href="{{ route "appIcon" "filename" "icon-192.png" }}">
|
||||
|
||||
<!-- iOS icons -->
|
||||
<link rel="apple-touch-icon" sizes="120x120" href="{{ route "appIcon" "filename" "icon-120.png" }}">
|
||||
<link rel="apple-touch-icon" sizes="152x152" href="{{ route "appIcon" "filename" "icon-152.png" }}">
|
||||
<link rel="apple-touch-icon" sizes="167x167" href="{{ route "appIcon" "filename" "icon-167.png" }}">
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="{{ route "appIcon" "filename" "icon-180.png" }}">
|
||||
|
||||
<meta name="theme-color" content="{{ theme_color .theme "light" }}" media="(prefers-color-scheme: light)">
|
||||
<meta name="theme-color" content="{{ theme_color .theme "dark" }}" media="(prefers-color-scheme: dark)">
|
||||
|
||||
<link rel="stylesheet" type="text/css" href="{{ route "stylesheet" "name" .theme "checksum" .theme_checksum }}">
|
||||
|
||||
{{ if .user }}
|
||||
{{ $cspNonce := nonce }}
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; img-src * data:; media-src *; frame-src *; {{ if .user.ExternalFontHosts }}font-src {{ .user.ExternalFontHosts }}; {{ end }}style-src 'self'{{ if .user.Stylesheet }}{{ if .user.ExternalFontHosts }} {{ .user.ExternalFontHosts }}{{ end }} 'nonce-{{ $cspNonce }}'{{ end }}{{ if .user.CustomJS }}; script-src 'self' 'nonce-{{ $cspNonce }}'{{ end }}; require-trusted-types-for 'script'; trusted-types ttpolicy;">
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; img-src * data:; media-src *; frame-src *; {{ if .user.ExternalFontHosts }}font-src {{ .user.ExternalFontHosts }}; {{ end }}style-src 'self'{{ if .user.Stylesheet }}{{ if .user.ExternalFontHosts }} {{ .user.ExternalFontHosts }}{{ end }} 'nonce-{{ $cspNonce }}'{{ end }}{{ if .user.CustomJS }}; script-src 'self' 'nonce-{{ $cspNonce }}'{{ end }}; require-trusted-types-for 'script'; trusted-types html url;">
|
||||
|
||||
{{ if .user.Stylesheet }}
|
||||
{{ if .user.Stylesheet -}}
|
||||
<style nonce="{{ $cspNonce }}">{{ .user.Stylesheet | safeCSS }}</style>
|
||||
{{ end }}
|
||||
{{ end -}}
|
||||
|
||||
{{ if .user.CustomJS }}
|
||||
{{ if .user.CustomJS -}}
|
||||
<script type="module" nonce="{{ $cspNonce }}">{{ .user.CustomJS | safeJS }}</script>
|
||||
{{ end }}
|
||||
{{ else }}
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; img-src * data:; media-src *; frame-src *; require-trusted-types-for 'script'; trusted-types ttpolicy;">
|
||||
{{ end }}
|
||||
{{ end -}}
|
||||
{{ else -}}
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; img-src * data:; media-src *; frame-src *; require-trusted-types-for 'script'; trusted-types html url;">
|
||||
{{ end -}}
|
||||
|
||||
<script src="{{ route "javascript" "name" "app" "checksum" .app_js_checksum }}" type="module"></script>
|
||||
</head>
|
||||
@@ -73,9 +70,7 @@
|
||||
<a aria-label="{{ t "menu.home_page" }}" href="{{ route .user.DefaultHomePage }}">
|
||||
Mini<span>flux</span>
|
||||
</a>
|
||||
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="12" height="12" fill="currentColor" class="bi bi-chevron-down" viewBox="0 0 16 16">
|
||||
<path fill-rule="evenodd" d="M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708"/>
|
||||
</svg>
|
||||
{{ icon "chevron-down"}}
|
||||
</div>
|
||||
<ul id="header-menu">
|
||||
<li {{ if eq .menu "unread" }}class="active"{{ end }} title="{{ t "tooltip.keyboard_shortcuts" "g u" }}">
|
||||
@@ -181,7 +176,7 @@
|
||||
<li>{{ t "page.keyboard_shortcuts.toggle_read_status_prev" }} = <strong>M</strong></li>
|
||||
<li>{{ t "page.keyboard_shortcuts.mark_page_as_read" }} = <strong>A</strong></li>
|
||||
<li>{{ t "page.keyboard_shortcuts.download_content" }} = <strong>d</strong></li>
|
||||
<li>{{ t "page.keyboard_shortcuts.toggle_bookmark_status" }} = <strong>f</strong></li>
|
||||
<li>{{ t "page.keyboard_shortcuts.toggle_star_status" }} = <strong>f</strong></li>
|
||||
<li>{{ t "page.keyboard_shortcuts.save_article" }} = <strong>s</strong></li>
|
||||
<li>{{ t "page.keyboard_shortcuts.toggle_entry_attachments" }} = <strong>a</strong></li>
|
||||
<li>{{ t "page.keyboard_shortcuts.scroll_item_to_top" }} = <strong>z + t</strong></li>
|
||||
|
||||
@@ -18,6 +18,8 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a href="#" class="elevator">{{ icon "up" }}{{ t "page.footer.elevator" }}</a>
|
||||
|
||||
<div class="pagination-forward">
|
||||
<div class="pagination-next {{ if not .ShowNext }}disabled{{end}}">
|
||||
{{ if .ShowNext }}
|
||||
|
||||
@@ -82,6 +82,10 @@
|
||||
</article>
|
||||
{{ end }}
|
||||
</div>
|
||||
|
||||
<footer>
|
||||
<a href="#" class="elevator">{{ icon "up" }}{{ t "page.footer.elevator" }}</a>
|
||||
</footer>
|
||||
{{ end }}
|
||||
|
||||
{{ end }}
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
{{ end }}
|
||||
</div>
|
||||
|
||||
<a href="#" class="elevator">{{ icon "up" }}{{ t "page.footer.elevator" }}</a>
|
||||
|
||||
<div class="pagination-next {{ if not .nextEntry }}disabled{{end}}">
|
||||
{{ if .nextEntry }}
|
||||
<a href="{{ .nextEntryRoute }}{{ if .searchQuery }}?q={{ .searchQuery }}{{ end }}" title="{{ .nextEntry.Title }}" data-page="next" rel="next">{{ t "pagination.next" }}</a>
|
||||
@@ -64,15 +66,15 @@
|
||||
<li>
|
||||
<button
|
||||
class="page-button"
|
||||
data-toggle-bookmark="true"
|
||||
data-bookmark-url="{{ route "toggleBookmark" "entryID" .entry.ID }}"
|
||||
data-toggle-starred="true"
|
||||
data-star-url="{{ route "toggleStarred" "entryID" .entry.ID }}"
|
||||
data-label-loading="{{ t "entry.state.saving" }}"
|
||||
data-label-star="{{ t "entry.bookmark.toggle.on" }}"
|
||||
data-label-unstar="{{ t "entry.bookmark.toggle.off" }}"
|
||||
data-toast-star="{{ t "entry.bookmark.toast.on" }}"
|
||||
data-toast-unstar="{{ t "entry.bookmark.toast.off" }}"
|
||||
data-label-star="{{ t "entry.starred.toggle.on" }}"
|
||||
data-label-unstar="{{ t "entry.starred.toggle.off" }}"
|
||||
data-toast-star="{{ t "entry.starred.toast.on" }}"
|
||||
data-toast-unstar="{{ t "entry.starred.toast.off" }}"
|
||||
data-value="{{ if .entry.Starred }}star{{ else }}unstar{{ end }}"
|
||||
>{{ if .entry.Starred }}{{ icon "unstar" }}{{ else }}{{ icon "star" }}{{ end }}<span class="icon-label">{{ if .entry.Starred }}{{ t "entry.bookmark.toggle.off" }}{{ else }}{{ t "entry.bookmark.toggle.on" }}{{ end }}</span></button>
|
||||
>{{ if .entry.Starred }}{{ icon "unstar" }}{{ else }}{{ icon "star" }}{{ end }}<span class="icon-label">{{ if .entry.Starred }}{{ t "entry.starred.toggle.off" }}{{ else }}{{ t "entry.starred.toggle.on" }}{{ end }}</span></button>
|
||||
</li>
|
||||
{{ if .hasSaveEntry }}
|
||||
<li>
|
||||
@@ -280,9 +282,9 @@
|
||||
{{ end }}
|
||||
|
||||
{{ if .user }}
|
||||
{{ noescape (proxyFilter .entry.Content) }}
|
||||
{{ safeHTML (proxyFilter .entry.Content) }}
|
||||
{{ else }}
|
||||
{{ noescape .entry.Content }}
|
||||
{{ safeHTML .entry.Content }}
|
||||
{{ end }}
|
||||
</article>
|
||||
{{ if .entry.Enclosures }}
|
||||
|
||||
@@ -228,6 +228,37 @@
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details {{ if .form.LinktacoEnabled }}open{{ end }}>
|
||||
<summary>LinkTaco</summary>
|
||||
<div class="form-section">
|
||||
<label>
|
||||
<input type="checkbox" name="linktaco_enabled" value="1" {{ if .form.LinktacoEnabled }}checked{{ end }}> {{ t "form.integration.linktaco_activate" }}
|
||||
</label>
|
||||
|
||||
<label for="form-linktaco-api-token">{{ t "form.integration.linktaco_api_token" }}</label>
|
||||
<input type="password" name="linktaco_api_token" id="form-linktaco-api-token" value="{{ .form.LinktacoAPIToken }}" spellcheck="false">
|
||||
<p class="hint">{{ t "form.integration.linktaco_api_token_hint" }} <a href="https://linktaco.com/oauth2/personal" target="_blank" rel="noopener noreferrer">https://linktaco.com/oauth2/personal</a></p>
|
||||
|
||||
<label for="form-linktaco-org-slug">{{ t "form.integration.linktaco_org_slug" }}</label>
|
||||
<input type="text" name="linktaco_org_slug" id="form-linktaco-org-slug" value="{{ .form.LinktacoOrgSlug }}" placeholder="my-organization" spellcheck="false">
|
||||
|
||||
<label for="form-linktaco-tags">{{ t "form.integration.linktaco_tags" }}</label>
|
||||
<input type="text" name="linktaco_tags" id="form-linktaco-tags" value="{{ .form.LinktacoTags }}" placeholder="miniflux, bookmarks" spellcheck="false">
|
||||
<p class="hint">{{ t "form.integration.linktaco_tags_hint" }}</p>
|
||||
|
||||
<label for="form-linktaco-visibility">{{ t "form.integration.linktaco_visibility" }}</label>
|
||||
<select name="linktaco_visibility" id="form-linktaco-visibility">
|
||||
<option value="PUBLIC" {{ if eq .form.LinktacoVisibility "PUBLIC" }}selected{{ end }}>{{ t "form.integration.linktaco_visibility_public" }}</option>
|
||||
<option value="PRIVATE" {{ if eq .form.LinktacoVisibility "PRIVATE" }}selected{{ end }}>{{ t "form.integration.linktaco_visibility_private" }}</option>
|
||||
</select>
|
||||
<p class="hint">{{ t "form.integration.linktaco_visibility_hint" }}</p>
|
||||
|
||||
<div class="buttons">
|
||||
<button type="submit" class="button button-primary" data-label-loading="{{ t "form.submit.saving" }}">{{ t "action.update" }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details {{ if .form.LinkwardenEnabled }}open{{ end }}>
|
||||
<summary>Linkwarden</summary>
|
||||
<div class="form-section">
|
||||
@@ -633,6 +664,9 @@
|
||||
<label for="form-wallabag-password">{{ t "form.integration.wallabag_password" }}</label>
|
||||
<input type="password" name="wallabag_password" id="form-wallabag-password" value="{{ .form.WallabagPassword }}" autocomplete="new-password">
|
||||
|
||||
<label for="form-wallabag-tags">{{ t "form.integration.wallabag_tags" }}</label>
|
||||
<input type="text" name="wallabag_tags" id="form-wallabag-tags" value="{{ .form.WallabagTags }}" spellcheck="false">
|
||||
|
||||
<div class="buttons">
|
||||
<button type="submit" class="button button-primary" data-label-loading="{{ t "form.submit.saving" }}">{{ t "action.update" }}</button>
|
||||
</div>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user