Merge remote-tracking branch 'upstream/master' into patch-1

This commit is contained in:
mcnesium 2022-07-20 13:55:04 +02:00
commit e2ee4606ac
No known key found for this signature in database
GPG Key ID: 7D6CC73E428F633F
63 changed files with 1435 additions and 357 deletions

View File

@ -18,7 +18,7 @@ jobs:
- name: Generate Alpine Docker tag
id: docker_alpine_tag
run: |
DOCKER_IMAGE=miniflux/miniflux
DOCKER_IMAGE=${{ github.repository_owner }}/miniflux
DOCKER_VERSION=dev
if [ "${{ github.event_name }}" = "schedule" ]; then
DOCKER_VERSION=nightly
@ -32,7 +32,7 @@ jobs:
- name: Generate Distroless Docker tag
id: docker_distroless_tag
run: |
DOCKER_IMAGE=miniflux/miniflux
DOCKER_IMAGE=${{ github.repository_owner }}/miniflux
DOCKER_VERSION=dev-distroless
if [ "${{ github.event_name }}" = "schedule" ]; then
DOCKER_VERSION=nightly-distroless

View File

@ -193,6 +193,17 @@ func (h *handler) fetchContent(w http.ResponseWriter, r *http.Request) {
return
}
user, err := h.store.UserByID(entry.UserID)
if err != nil {
json.ServerError(w, r, err)
return
}
if user == nil {
json.NotFound(w, r)
return
}
feedBuilder := storage.NewFeedQueryBuilder(h.store, loggedUserID)
feedBuilder.WithFeedID(entry.FeedID)
feed, err := feedBuilder.GetFeed()
@ -206,7 +217,7 @@ func (h *handler) fetchContent(w http.ResponseWriter, r *http.Request) {
return
}
if err := processor.ProcessEntryWebPage(feed, entry); err != nil {
if err := processor.ProcessEntryWebPage(feed, entry, user); err != nil {
json.ServerError(w, r, err)
return
}

View File

@ -18,24 +18,26 @@ const (
// User represents a user in the system.
type User struct {
ID int64 `json:"id"`
Username string `json:"username"`
Password string `json:"password,omitempty"`
IsAdmin bool `json:"is_admin"`
Theme string `json:"theme"`
Language string `json:"language"`
Timezone string `json:"timezone"`
EntryDirection string `json:"entry_sorting_direction"`
EntryOrder string `json:"entry_sorting_order"`
Stylesheet string `json:"stylesheet"`
GoogleID string `json:"google_id"`
OpenIDConnectID string `json:"openid_connect_id"`
EntriesPerPage int `json:"entries_per_page"`
KeyboardShortcuts bool `json:"keyboard_shortcuts"`
ShowReadingTime bool `json:"show_reading_time"`
EntrySwipe bool `json:"entry_swipe"`
LastLoginAt *time.Time `json:"last_login_at"`
DisplayMode string `json:"display_mode"`
ID int64 `json:"id"`
Username string `json:"username"`
Password string `json:"password,omitempty"`
IsAdmin bool `json:"is_admin"`
Theme string `json:"theme"`
Language string `json:"language"`
Timezone string `json:"timezone"`
EntryDirection string `json:"entry_sorting_direction"`
EntryOrder string `json:"entry_sorting_order"`
Stylesheet string `json:"stylesheet"`
GoogleID string `json:"google_id"`
OpenIDConnectID string `json:"openid_connect_id"`
EntriesPerPage int `json:"entries_per_page"`
KeyboardShortcuts bool `json:"keyboard_shortcuts"`
ShowReadingTime bool `json:"show_reading_time"`
EntrySwipe bool `json:"entry_swipe"`
LastLoginAt *time.Time `json:"last_login_at"`
DisplayMode string `json:"display_mode"`
DefaultReadingSpeed int `json:"default_reading_speed"`
CJKReadingSpeed int `json:"cjk_reading_speed"`
}
func (u User) String() string {
@ -53,22 +55,24 @@ type UserCreationRequest struct {
// UserModificationRequest represents the request to update a user.
type UserModificationRequest struct {
Username *string `json:"username"`
Password *string `json:"password"`
IsAdmin *bool `json:"is_admin"`
Theme *string `json:"theme"`
Language *string `json:"language"`
Timezone *string `json:"timezone"`
EntryDirection *string `json:"entry_sorting_direction"`
EntryOrder *string `json:"entry_sorting_order"`
Stylesheet *string `json:"stylesheet"`
GoogleID *string `json:"google_id"`
OpenIDConnectID *string `json:"openid_connect_id"`
EntriesPerPage *int `json:"entries_per_page"`
KeyboardShortcuts *bool `json:"keyboard_shortcuts"`
ShowReadingTime *bool `json:"show_reading_time"`
EntrySwipe *bool `json:"entry_swipe"`
DisplayMode *string `json:"display_mode"`
Username *string `json:"username"`
Password *string `json:"password"`
IsAdmin *bool `json:"is_admin"`
Theme *string `json:"theme"`
Language *string `json:"language"`
Timezone *string `json:"timezone"`
EntryDirection *string `json:"entry_sorting_direction"`
EntryOrder *string `json:"entry_sorting_order"`
Stylesheet *string `json:"stylesheet"`
GoogleID *string `json:"google_id"`
OpenIDConnectID *string `json:"openid_connect_id"`
EntriesPerPage *int `json:"entries_per_page"`
KeyboardShortcuts *bool `json:"keyboard_shortcuts"`
ShowReadingTime *bool `json:"show_reading_time"`
EntrySwipe *bool `json:"entry_swipe"`
DisplayMode *string `json:"display_mode"`
DefaultReadingSpeed *int `json:"default_reading_speed"`
CJKReadingSpeed *int `json:"cjk_reading_speed"`
}
// Users represents a list of users.

View File

@ -0,0 +1,7 @@
System-V init for e.g. http://devuan.org
Assumes an executable `/usr/local/bin/miniflux`.
Configure in `etc/default/miniflux`

View File

@ -0,0 +1,6 @@
# sourced by /etc/init.d/miniflux
# see cluster port in pg_lsclusters and ls -Al /var/run/postgresql/
export DATABASE_URL='host=/var/run/postgresql/ port=5433 user=miniflux password=<my secrect db password> dbname=miniflux sslmode=disable'
export LISTEN_ADDR='127.0.0.1:8081'
export BASE_URL='https://<my miniflux domain> and path/'

View File

@ -0,0 +1,127 @@
#! /bin/sh
### BEGIN INIT INFO
# Provides: miniflux
# Required-Start: $syslog $network
# Required-Stop: $syslog
# Should-Start: postgresql
# Should-Stop: postgresql
# Default-Start: 2 3 4 5
# Default-Stop: 0 1 6
# Short-Description: A rss reader
# Description: A RSS reader
### END INIT INFO
# Author: Danny Boisvert
# Do NOT "set -e"
# PATH should only include /usr/* if it runs after the mountnfs.sh script
PATH=/sbin:/usr/sbin:/bin:/usr/bin
DESC="Miniflux"
NAME=miniflux
SERVICEVERBOSE=yes
PIDFILE=/var/run/$NAME.pid
SCRIPTNAME=/etc/init.d/$NAME
WORKINGDIR=/usr/local/bin
DAEMON=$WORKINGDIR/$NAME
DAEMON_ARGS=""
USER=nobody
# Read configuration variable file if it is present
[ -r /etc/default/$NAME ] && . /etc/default/$NAME
# Exit if the package is not installed
[ -x "$DAEMON" ] || exit 0
# Load the VERBOSE setting and other rcS variables
. /lib/init/vars.sh
# Define LSB log_* functions.
# Depend on lsb-base (>= 3.2-14) to ensure that this file is present
# and status_of_proc is working.
. /lib/lsb/init-functions
#
# Function that starts the daemon/service
#
do_start()
{
# Return
# 0 if daemon has been started
# 1 if daemon was already running
# 2 if daemon could not be started
sh -c "USER=$USER start-stop-daemon --start --quiet --pidfile $PIDFILE --make-pidfile \\
--test --chdir $WORKINGDIR --chuid $USER \\
--exec $DAEMON -- $DAEMON_ARGS > /dev/null \\
|| return 1"
sh -c "USER=$USER start-stop-daemon --start --quiet --pidfile $PIDFILE --make-pidfile \\
--background --chdir $WORKINGDIR --chuid $USER \\
--exec $DAEMON -- $DAEMON_ARGS \\
|| return 2"
}
#
# Function that stops the daemon/service
#
do_stop()
{
# Return
# 0 if daemon has been stopped
# 1 if daemon was already stopped
# 2 if daemon could not be stopped
# other if a failure occurred
start-stop-daemon --stop --quiet --retry=TERM/1/KILL/5 --pidfile $PIDFILE --name $NAME
RETVAL="$?"
[ "$RETVAL" = 2 ] && return 2
start-stop-daemon --stop --quiet --oknodo --retry=0/1/KILL/5 --exec $DAEMON
[ "$?" = 2 ] && return 2
# Many daemons don't delete their pidfiles when they exit.
rm -f $PIDFILE
return "$RETVAL"
}
case "$1" in
start)
[ "$SERVICEVERBOSE" != no ] && log_daemon_msg "Starting $DESC" "$NAME"
do_start
case "$?" in
0|1) [ "$SERVICEVERBOSE" != no ] && log_end_msg 0 ;;
2) [ "$SERVICEVERBOSE" != no ] && log_end_msg 1 ;;
esac
;;
stop)
[ "$SERVICEVERBOSE" != no ] && log_daemon_msg "Stopping $DESC" "$NAME"
do_stop
case "$?" in
0|1) [ "$SERVICEVERBOSE" != no ] && log_end_msg 0 ;;
2) [ "$SERVICEVERBOSE" != no ] && log_end_msg 1 ;;
esac
;;
status)
status_of_proc "$DAEMON" "$NAME" && exit 0 || exit $?
;;
restart|force-reload)
log_daemon_msg "Restarting $DESC" "$NAME"
do_stop
case "$?" in
0|1)
do_start
case "$?" in
0) log_end_msg 0 ;;
1) log_end_msg 1 ;; # Old process is still running
*) log_end_msg 1 ;; # Failed to start
esac
;;
*)
# Failed to stop
log_end_msg 1
;;
esac
;;
*)
echo "Usage: $SCRIPTNAME {start|stop|status|restart|force-reload}" >&2
exit 3
;;
esac

View File

@ -591,4 +591,17 @@ var migrations = []func(tx *sql.Tx) error{
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
_, err = tx.Exec(`
ALTER TABLE feeds ADD COLUMN url_rewrite_rules text not null default ''
`)
return err
},
func(tx *sql.Tx) (err error) {
_, err = tx.Exec(`
ALTER TABLE users ADD COLUMN default_reading_speed int default 265;
ALTER TABLE users ADD COLUMN cjk_reading_speed int default 500;
`)
return
},
}

2
go.mod
View File

@ -15,7 +15,7 @@ require (
github.com/prometheus/client_golang v1.12.2
github.com/rylans/getlang v0.0.0-20200505200108-4c3188ff8a2d
github.com/stretchr/testify v1.6.1 // indirect
github.com/tdewolff/minify/v2 v2.11.5
github.com/tdewolff/minify/v2 v2.12.0
github.com/technoweenie/multipartstreamer v1.0.1 // indirect
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9
golang.org/x/net v0.0.0-20210916014120-12bc252f5db8

17
go.sum
View File

@ -89,7 +89,7 @@ github.com/felixge/httpsnoop v1.0.1 h1:lvB5Jl89CsZtGIWuTcDM1E/vkVs49/Ml7JJe07l8S
github.com/felixge/httpsnoop v1.0.1/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/fsnotify/fsnotify v1.4.3-0.20170329110642-4da3e2cfbabc/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/fsnotify/fsnotify v1.5.3/go.mod h1:T3375wBYaZdLLcVNkcVbzGHY7f1l/uK5T5Ai1i3InKU=
github.com/fsnotify/fsnotify v1.5.4/go.mod h1:OVB6XrOHzAwXMpEM7uPOzcehqUV2UqJxmVXmkdnm1bU=
github.com/garyburd/redigo v1.1.1-0.20170914051019-70e1b1943d4f/go.mod h1:NR3MbYisc3/PwhQ00EMzDiPmrwpPxAn5GI05/YaO1SY=
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
github.com/go-delve/delve v1.5.0/go.mod h1:c6b3a1Gry6x8a4LGCe/CWzrocrfaHvkUxCj3k4bvSUQ=
@ -308,12 +308,13 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/tdewolff/minify/v2 v2.11.5 h1:wfRJ6JMmu87Azx+Lj7NcCTg8D/L/92SNuYF5fv31fjg=
github.com/tdewolff/minify/v2 v2.11.5/go.mod h1:ZR6VK7tbgF0XXBVwDHXrbqM4tGYchBpdwhsbKeDmAww=
github.com/tdewolff/parse/v2 v2.5.31 h1:PrJN/trWTUYaYxg7jRpKnKXpBK4kBjT9rAFhFAh2Lus=
github.com/tdewolff/parse/v2 v2.5.31/go.mod h1:WzaJpRSbwq++EIQHYIRTpbYKNA3gn9it1Ik++q4zyho=
github.com/tdewolff/test v1.0.6 h1:76mzYJQ83Op284kMT+63iCNCI7NEERsIN8dLM+RiKr4=
github.com/tdewolff/minify/v2 v2.12.0 h1:ZyvMKeciyR3vzJrK/oHyBcSmpttQ/V+ah7qOqTZclaU=
github.com/tdewolff/minify/v2 v2.12.0/go.mod h1:8mvf+KglD7XurfvvFZDUYvVURy6bA/r0oTvmakXMnyg=
github.com/tdewolff/parse/v2 v2.6.1 h1:RIfy1erADkO90ynJWvty8VIkqqKYRzf2iLp8ObG174I=
github.com/tdewolff/parse/v2 v2.6.1/go.mod h1:WzaJpRSbwq++EIQHYIRTpbYKNA3gn9it1Ik++q4zyho=
github.com/tdewolff/test v1.0.6/go.mod h1:6DAvZliBAAnD7rhVgwaM7DE5/d9NMOAJ09SqYqeK4QE=
github.com/tdewolff/test v1.0.7 h1:8Vs0142DmPFW/bQeHRP3MV19m1gvndjUb1sn8yy74LM=
github.com/tdewolff/test v1.0.7/go.mod h1:6DAvZliBAAnD7rhVgwaM7DE5/d9NMOAJ09SqYqeK4QE=
github.com/technoweenie/multipartstreamer v1.0.1 h1:XRztA5MXiR1TIRHxH2uNxXxaIkKQDeX7m2XsSOlQEnM=
github.com/technoweenie/multipartstreamer v1.0.1/go.mod h1:jNVxdtShOxzAsukZwTSw6MDx5eUJoiEBsSvzDU9uzog=
github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U=
@ -465,9 +466,9 @@ golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210603081109-ebe580a85c40/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220114195835-da31bd327af9 h1:XfKQ4OlFl8okEOr5UvAqFRVj8pY/4yfcXrddB8qAbU0=
golang.org/x/sys v0.0.0-20220114195835-da31bd327af9/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220412211240-33da011f77ad h1:ntjMns5wyP/fN65tdBD4g8J5w8n015+iIIs9rtjXkY0=
golang.org/x/sys v0.0.0-20220412211240-33da011f77ad/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=

View File

@ -22,5 +22,6 @@ func AvailableLanguages() map[string]string {
"tr_TR": "Türkçe",
"el_EL": "Ελληνικά",
"fi_FI": "Suomi",
"hi_IN": "हिन्दी",
}
}

View File

@ -243,6 +243,7 @@
"error.different_passwords": "Passwörter stimmen nicht überein.",
"error.password_min_length": "Wenigstens 6 Zeichen müssen genutzt werden.",
"error.settings_mandatory_fields": "Die Felder für Benutzername, Thema, Sprache und Zeitzone sind obligatorisch.",
"error.settings_reading_speed_is_positive": "Die Lesegeschwindigkeiten müssen positive ganze Zahlen sein.",
"error.entries_per_page_invalid": "Die Anzahl der Einträge pro Seite ist ungültig.",
"error.feed_mandatory_fields": "Die URL und die Kategorie sind obligatorisch.",
"error.feed_already_exists": "Dieser Feed existiert bereits.",
@ -275,6 +276,7 @@
"form.feed.label.rewrite_rules": "Umschreiberegeln",
"form.feed.label.blocklist_rules": "Blockierregeln",
"form.feed.label.keeplist_rules": "Erlaubnisregeln",
"form.feed.label.urlrewrite_rules": "Umschreibregeln für URL",
"form.feed.label.ignore_http_cache": "Ignoriere HTTP-cache",
"form.feed.label.allow_self_signed_certificates": "Erlaube selbstsignierte oder ungültige Zertifikate",
"form.feed.label.fetch_via_proxy": "Über Proxy abrufen",
@ -291,6 +293,8 @@
"form.prefs.label.theme": "Thema",
"form.prefs.label.entry_sorting": "Sortierung der Artikel",
"form.prefs.label.entries_per_page": "Einträge pro Seite",
"form.prefs.label.default_reading_speed": "Lesegeschwindigkeit für andere Sprachen (Wörter pro Minute)",
"form.prefs.label.cjk_reading_speed": "Lesegeschwindigkeit für Chinesisch, Koreanisch und Japanisch (Zeichen pro Minute)",
"form.prefs.label.display_mode": "Anzeigemodus der Web-App (muss neu installiert werden)",
"form.prefs.select.older_first": "Älteste Artikel zuerst",
"form.prefs.select.recent_first": "Neueste Artikel zuerst",

View File

@ -248,6 +248,7 @@
"error.different_passwords": "Οι κωδικοί πρόσβασης δεν είναι οι ίδιοι.",
"error.password_min_length": "Ο κωδικός πρόσβασης πρέπει να έχει τουλάχιστον 6 χαρακτήρες.",
"error.settings_mandatory_fields": "Τα πεδία όνομα χρήστη, θέμα, Γλώσσα και ζώνη ώρας είναι υποχρεωτικά.",
"error.settings_reading_speed_is_positive": "Οι ταχύτητες ανάγνωσης πρέπει να είναι θετικοί ακέραιοι αριθμοί.",
"error.entries_per_page_invalid": "Ο αριθμός των καταχωρήσεων ανά σελίδα δεν είναι έγκυρος.",
"error.feed_mandatory_fields": "Η διεύθυνση URL και η κατηγορία είναι υποχρεωτικά.",
"error.feed_already_exists": "Αυτή η ροή υπάρχει ήδη.",
@ -259,6 +260,7 @@
"error.feed_category_not_found": "Αυτή η κατηγορία δεν υπάρχει ή δεν ανήκει σε αυτόν τον χρήστη.",
"error.feed_invalid_blocklist_rule": "Ο κανόνας λίστας μπλοκ δεν είναι έγκυρος.",
"error.feed_invalid_keeplist_rule": "Ο κανόνας keep list δεν είναι έγκυρος.",
"form.feed.label.urlrewrite_rules": "επανεγγραφή κανόνων για τη διεύθυνση URL.",
"error.user_mandatory_fields": "Το όνομα χρήστη είναι υποχρεωτικό.",
"error.api_key_already_exists": "Αυτό το κλειδί API υπάρχει ήδη.",
"error.unable_to_create_api_key": "Δεν είναι δυνατή η δημιουργία αυτού του κλειδιού API.",
@ -291,6 +293,8 @@
"form.prefs.label.theme": "Θέμα",
"form.prefs.label.entry_sorting": "Ταξινόμηση",
"form.prefs.label.entries_per_page": "Καταχωρήσεις ανά σελίδα",
"form.prefs.label.default_reading_speed": "Ταχύτητα ανάγνωσης άλλων γλωσσών (λέξεις ανά λεπτό)",
"form.prefs.label.cjk_reading_speed": "Ταχύτητα ανάγνωσης για κινέζικα, κορεάτικα και ιαπωνικά (χαρακτήρες ανά λεπτό)",
"form.prefs.label.display_mode": "Λειτουργία προβολής εφαρμογών ιστού (χρειάζεται επανεγκατάσταση)",
"form.prefs.select.older_first": "Παλαιότερες καταχωρήσεις πρώτα",
"form.prefs.select.recent_first": "Πρόσφατες καταχωρήσεις πρώτα",

View File

@ -248,6 +248,7 @@
"error.different_passwords": "Passwords are not the same.",
"error.password_min_length": "The password must have at least 6 characters.",
"error.settings_mandatory_fields": "The username, theme, language and timezone fields are mandatory.",
"error.settings_reading_speed_is_positive": "The reading speeds must be positive integers.",
"error.entries_per_page_invalid": "The number of entries per page is not valid.",
"error.feed_mandatory_fields": "The URL and the category are mandatory.",
"error.feed_already_exists": "This feed already exists.",
@ -275,6 +276,7 @@
"form.feed.label.rewrite_rules": "Rewrite Rules",
"form.feed.label.blocklist_rules": "Block Rules",
"form.feed.label.keeplist_rules": "Keep Rules",
"form.feed.label.urlrewrite_rules": "URL Rewrite Rules",
"form.feed.label.ignore_http_cache": "Ignore HTTP cache",
"form.feed.label.allow_self_signed_certificates": "Allow self-signed or invalid certificates",
"form.feed.label.fetch_via_proxy": "Fetch via proxy",
@ -291,6 +293,8 @@
"form.prefs.label.theme": "Theme",
"form.prefs.label.entry_sorting": "Entry Sorting",
"form.prefs.label.entries_per_page": "Entries per page",
"form.prefs.label.default_reading_speed": "Reading speed for other languages (words per minute)",
"form.prefs.label.cjk_reading_speed": "Reading speed for Chinese, Korean and Japanese (characters per minute)",
"form.prefs.label.display_mode": "Web app display mode (needs reinstalling)",
"form.prefs.select.older_first": "Older entries first",
"form.prefs.select.recent_first": "Recent entries first",

View File

@ -243,6 +243,7 @@
"error.different_passwords": "Las contraseñas no son las mismas.",
"error.password_min_length": "La contraseña debería tener al menos 6 caracteres.",
"error.settings_mandatory_fields": "Los campos de nombre de usuario, tema, idioma y zona horaria son obligatorios.",
"error.settings_reading_speed_is_positive": "Las velocidades de lectura deben ser números enteros positivos.",
"error.entries_per_page_invalid": "El número de entradas por página no es válido.",
"error.feed_mandatory_fields": "Los campos de URL y categoría son obligatorios.",
"error.feed_already_exists": "Este feed ya existe.",
@ -275,6 +276,7 @@
"form.feed.label.rewrite_rules": "Reglas de reescribir",
"form.feed.label.blocklist_rules": "Reglas de Filtrado(Bloquear)",
"form.feed.label.keeplist_rules": "Reglas de Filtrado(Permitir)",
"form.feed.label.urlrewrite_rules": "Reglas de Filtrado(reescritura)",
"form.feed.label.ignore_http_cache": "Ignorar caché HTTP",
"form.feed.label.allow_self_signed_certificates": "Permitir certificados autofirmados o no válidos",
"form.feed.label.fetch_via_proxy": "Buscar a través de proxy",
@ -291,6 +293,8 @@
"form.prefs.label.theme": "Tema",
"form.prefs.label.entry_sorting": "Clasificación de entradas",
"form.prefs.label.entries_per_page": "Entradas por página",
"form.prefs.label.default_reading_speed": "Velocidad de lectura de otras lenguas (palabras por minuto)",
"form.prefs.label.cjk_reading_speed": "Velocidad de lectura en chino, coreano y japonés (caracteres por minuto)",
"form.prefs.label.display_mode": "Modo de visualización de la aplicación web (necesita reinstalación)",
"form.prefs.select.older_first": "Entradas más viejas primero",
"form.prefs.select.recent_first": "Entradas recientes primero",

View File

@ -248,6 +248,7 @@
"error.different_passwords": "Salasanat eivät ole samat.",
"error.password_min_length": "Salasanassa on oltava vähintään 6 merkkiä.",
"error.settings_mandatory_fields": "Käyttäjätunnus, teema, kieli ja aikavyöhyke ovat pakollisia.",
"error.settings_reading_speed_is_positive": "Lukunopeuksien on oltava positiivisia kokonaislukuja.",
"error.entries_per_page_invalid": "Artikkelien määrä sivulla ei kelpaa.",
"error.feed_mandatory_fields": "URL-osoite ja kategoria ovat pakollisia.",
"error.feed_already_exists": "Tämä syöte on jo olemassa.",
@ -259,6 +260,7 @@
"error.feed_category_not_found": "Tätä kategoriaa ei ole olemassa tai se ei kuulu tälle käyttäjälle.",
"error.feed_invalid_blocklist_rule": "The block list rule is invalid.",
"error.feed_invalid_keeplist_rule": "The keep list rule is invalid.",
"form.feed.label.urlrewrite_rules": "URL-osoitteen uudelleenkirjoitussäännöt",
"error.user_mandatory_fields": "Käyttäjätunnus on pakollinen.",
"error.api_key_already_exists": "API-avain on jo olemassa.",
"error.unable_to_create_api_key": "API-avainta ei voi luoda.",
@ -291,6 +293,8 @@
"form.prefs.label.theme": "Teema",
"form.prefs.label.entry_sorting": "Lajittelu",
"form.prefs.label.entries_per_page": "Artikkelia sivulla",
"form.prefs.label.default_reading_speed": "Muiden kielten lukunopeus (sanaa minuutissa)",
"form.prefs.label.cjk_reading_speed": "Kiinan, Korean ja Japanin lukunopeus (merkkejä minuutissa)",
"form.prefs.label.display_mode": "Verkkosovelluksen näyttötila (vaatii uudelleenasennuksen)",
"form.prefs.select.older_first": "Vanhin ensin",
"form.prefs.select.recent_first": "Uusin ensin",

View File

@ -243,6 +243,7 @@
"error.different_passwords": "Les mots de passe ne sont pas les mêmes.",
"error.password_min_length": "Vous devez utiliser au moins 6 caractères pour le mot de passe.",
"error.settings_mandatory_fields": "Le nom d'utilisateur, le thème, la langue et le fuseau horaire sont obligatoire.",
"error.settings_reading_speed_is_positive": "Les vitesses de lecture doivent être des entiers positifs.",
"error.entries_per_page_invalid": "Le nombre d'entrées par page n'est pas valide.",
"error.feed_mandatory_fields": "L'URL et la catégorie sont obligatoire.",
"error.feed_already_exists": "Ce flux existe déjà.",
@ -275,6 +276,7 @@
"form.feed.label.rewrite_rules": "Règles de réécriture",
"form.feed.label.blocklist_rules": "Règles de blocage",
"form.feed.label.keeplist_rules": "Règles d'autorisation",
"form.feed.label.urlrewrite_rules": "Règles de réécriture d'URL",
"form.feed.label.ignore_http_cache": "Ignorer le cache HTTP",
"form.feed.label.allow_self_signed_certificates": "Autoriser les certificats auto-signés ou non valides",
"form.feed.label.fetch_via_proxy": "Récupérer via proxy",
@ -291,6 +293,8 @@
"form.prefs.label.theme": "Thème",
"form.prefs.label.entry_sorting": "Ordre des éléments",
"form.prefs.label.entries_per_page": "Entrées par page",
"form.prefs.label.default_reading_speed": "Vitesse de lecture pour les autres langues (mots par minute)",
"form.prefs.label.cjk_reading_speed": "Vitesse de lecture pour le Chinois, le Coréen et le Japonais (caractères par minute)",
"form.prefs.label.display_mode": "Mode d'affichage de l'application web (doit être réinstallé)",
"form.prefs.select.older_first": "Ancien éléments en premier",
"form.prefs.select.recent_first": "Éléments récents en premier",

View File

@ -0,0 +1,382 @@
{
"confirm.question": "मंजूर है?",
"confirm.yes": "हाँ",
"confirm.no": " नहीं",
"confirm.loading": " प्रगति में है ...",
"action.subscribe": "सदस्यता लें",
"action.save": "सहेजें",
"action.or": "या",
"action.cancel": "रद्द करें",
"action.remove": "हटाएँ",
"action.remove_feed": "इस फ़ीड को हटाएँ",
"action.update": "नवीनीकरण करे",
"action.edit": "संपाद करे",
"action.download": "डाउनलोड",
"action.import": "आयात करे",
"action.login": "लॉग इन करें",
"action.home_screen": "होम स्क्रीन में शामिल करें",
"tooltip.keyboard_shortcuts": "कुंजीपटल संक्षिप्त रीति: %s",
"tooltip.logged_user": "%s के रूप में लॉग इन किया",
"menu.unread": "अपठित",
"menu.starred": "तारांकित",
"menu.history": "इतिहास",
"menu.feeds": "फ़ीड",
"menu.categories": "श्रेणियाँ",
"menu.settings": "समायोजन",
"menu.logout": "लॉग आउट",
"menu.preferences": "पसंद",
"menu.integrations": "एकीकरण",
"menu.sessions": "सत्र",
"menu.users": "उपयोगकर्ताओं",
"menu.about": "के बारे में",
"menu.export": "निर्यात करे",
"menu.import": "आयात करे",
"menu.create_category": "श्रेणी बनाए",
"menu.mark_page_as_read": "इस पृष्ठ को पढ़ा हुआ चिह्नित करें",
"menu.mark_all_as_read": "सभी को पढ़ा हुआ मार्क करें",
"menu.show_all_entries": "सभी प्रविष्टियाँ दिखाए",
"menu.show_only_unread_entries": "सभी अपठित प्रविष्टियाँ दिखाए",
"menu.refresh_feed": "ताज़ा करें",
"menu.refresh_all_feeds": "पृष्ठभूमि में सभी फ़ीड को ताज़ा करें",
"menu.edit_feed": "फ़ीड संपाद करे",
"menu.edit_category": "श्रेणी संपाद करे",
"menu.add_feed": "सदस्यता जोरीय",
"menu.add_user": "उपयोगकर्ता जोड़ें",
"menu.flush_history": "इतिहास मिटाएँ",
"menu.feed_entries": "प्रविष्टियाँ",
"menu.api_keys": "एपीआई कुंजी",
"menu.create_api_key": "नई एपीआई कुंजी बनाएं",
"menu.shared_entries": "साझा प्रविष्टियां",
"search.label": "खोजे",
"search.placeholder": "खोजे...",
"pagination.next": "अगला",
"pagination.previous": "पिछला",
"entry.status.unread": "अपठित",
"entry.status.read": "पढ़े",
"entry.status.toast.unread": "अपठित के रूप में चिह्नित",
"entry.status.toast.read": "पढ़ा हुआ चिह्नित करे",
"entry.status.title": "प्रविष्टि स्थिति बदलें",
"entry.bookmark.toggle.on": "सितारा दे",
"entry.bookmark.toggle.off": "सितारा हटा दो",
"entry.bookmark.toast.on": "तारांकित",
"entry.bookmark.toast.off": "तारांकित न करे",
"entry.state.saving": "सहेजा जा रहा है...",
"entry.state.loading": "लोड हो रहा है...",
"entry.save.label": "सहेजे",
"entry.save.title": "एस लेख को सहेजे",
"entry.save.completed": "कार्य समाप्त हुआ!",
"entry.save.toast.completed": "लेख को सहेज लिया",
"entry.scraper.label": "डाउनलोड",
"entry.scraper.title": "मूल विषयवस्तु लाए",
"entry.scraper.completed": "कार्य समाप्त हुआ!",
"entry.external_link.label": "बाहरी संपर्क",
"entry.comments.label": "टिप्पणियाँ",
"entry.comments.title": "टिप्पणियाँ देखे",
"entry.share.label": "साझा करें",
"entry.share.title": "विषयवस्तु साझा करें",
"entry.unshare.label": "न साझा कारें",
"entry.shared_entry.title": "सार्वजनिक लिंक खोले",
"entry.shared_entry.label": "साझा करें",
"entry.estimated_reading_time": [
"पढ़ने मे %d मिनट मागेगा",
"पढ़ने मे %d मिनट मागेगा"
],
"page.shared_entries.title": "साझा किया हुआ प्रविष्टि",
"page.unread.title": "अपठित",
"page.starred.title": "तारांकित",
"page.categories.title": "श्रेणियाँ",
"page.categories.no_feed": "कोई फ़ीड नहीं है।",
"page.categories.entries": "विषयवस्तुया",
"page.categories.feeds": "सदस्यता ले",
"page.categories.feed_count": [
"%d फ़ीड बाकी है।",
"%d फ़ीड बाकी है।"
],
"page.categories.unread_counter": "अपठित प्रविष्टिया",
"page.new_category.title": "नया श्रेणी",
"page.new_user.title": "नया उपभोक्ता",
"page.edit_category.title": "%s श्रेणी संपाद करे",
"page.edit_user.title": "%s उपभोक्ता संपाद करे",
"page.feeds.title": "फ़ीड",
"page.feeds.last_check": "आखरी जाँच",
"page.feeds.unread_counter": "अपठित विषयवस्तुया",
"page.feeds.read_counter": "पड़े हुए विषयवस्तुया",
"page.feeds.error_count": [
"%d समस्या",
"%d समस्याए"
],
"page.history.title": "इतिहास",
"page.import.title": "आयात",
"page.search.title": "खोज का परिणाम",
"page.about.title": "पृष्ठ के बारे में",
"page.about.credits": "आभार सूची",
"page.about.version": "संस्करण:",
"page.about.build_date": "बनाने की तिथि:",
"page.about.author": "रचयिता:",
"page.about.license": "अनुज्ञा:",
"page.about.global_config_options": "वैश्विक विन्यास विकल्प",
"page.about.postgres_version": "पोस्तग्राइस संस्करण:",
"page.about.go_version": "गो संस्करण:",
"page.add_feed.title": "नया सदस्यता",
"page.add_feed.no_category": "कोई श्रेणी नहीं है। एक श्रेणी अव्यशाक है।",
"page.add_feed.label.url": "यूआरएल",
"page.add_feed.submit": "सदस्यता खोजे",
"page.add_feed.legend.advanced_options": "उन्नत विकल्प",
"page.add_feed.choose_feed": "एक सदस्यता का चयन करे",
"page.edit_feed.title": "%s फ़ीड संपाद करे",
"page.edit_feed.last_check": "अंतिम जांच:",
"page.edit_feed.last_modified_header": "अंतिम बार संशोधित हैडर:",
"page.edit_feed.etag_header": "ईटाग हैडर:",
"page.edit_feed.no_header": "कोई भी नहीं",
"page.edit_feed.last_parsing_error": "अंतिम पार्सिंग त्रुटि",
"page.entry.attachments": "संलग्नक",
"page.keyboard_shortcuts.title": "कुंजीपटल अल्प मार्ग",
"page.keyboard_shortcuts.subtitle.sections": "अनुभाग नेविगेशन",
"page.keyboard_shortcuts.subtitle.items": "आइटम नेविगेशन",
"page.keyboard_shortcuts.subtitle.pages": "पेज नेविगेशन",
"page.keyboard_shortcuts.subtitle.actions": "कार्रवाई",
"page.keyboard_shortcuts.go_to_unread": "अपठित पर जाएं",
"page.keyboard_shortcuts.go_to_starred": "बुकमार्क पर जाएं",
"page.keyboard_shortcuts.go_to_history": "इतिहास पर जाएं",
"page.keyboard_shortcuts.go_to_feeds": "फ़ीड पर जाएं",
"page.keyboard_shortcuts.go_to_categories": "श्रेणि पर जाएं",
"page.keyboard_shortcuts.go_to_settings": "सेटिंग्स में जाओ",
"page.keyboard_shortcuts.show_keyboard_shortcuts": "कीबोर्ड शॉर्टकट दिखाएं",
"page.keyboard_shortcuts.go_to_previous_item": "पिछले आइटम पर जाएं",
"page.keyboard_shortcuts.go_to_next_item": "अगले आइटम पर जाएं",
"page.keyboard_shortcuts.go_to_feed": "फ़ीड पर जाएं",
"page.keyboard_shortcuts.go_to_previous_page": "पिछले पृष्ठ पर जाएं",
"page.keyboard_shortcuts.go_to_next_page": "अगले पेज पर जाएं",
"page.keyboard_shortcuts.open_item": "चयनित आइटम खोलें",
"page.keyboard_shortcuts.open_original": "मूल लिंक खोलें",
"page.keyboard_shortcuts.open_original_same_window": "वर्तमान टैब में मूल लिंक खोलें",
"page.keyboard_shortcuts.open_comments": "टिप्पणी लिंक खोलें",
"page.keyboard_shortcuts.open_comments_same_window": "मौजूदा टैब में टिप्पणी लिंक खोलें",
"page.keyboard_shortcuts.toggle_read_status_next": "पढ़ें/अपठित टॉगल करें, अगला फ़ोकस करें",
"page.keyboard_shortcuts.toggle_read_status_prev": "पढ़ें/अपठित टॉगल करें, पिछला फ़ोकस करें",
"page.keyboard_shortcuts.refresh_all_feeds": "बैकग्राउंड में सभी फ़ीड्स रीफ़्रेश करें",
"page.keyboard_shortcuts.mark_page_as_read": "मौजूदा पेज को पढ़ा हुआ चिह्नित करें",
"page.keyboard_shortcuts.download_content": "मूल सामग्री डाउनलोड करें",
"page.keyboard_shortcuts.toggle_bookmark_status": "बुकमार्क टॉगल करें",
"page.keyboard_shortcuts.save_article": "विषयवस्तु सहेजें",
"page.keyboard_shortcuts.scroll_item_to_top": "आइटम को ऊपर तक स्क्रॉल करें",
"page.keyboard_shortcuts.remove_feed": "यह फ़ीड हटाएं",
"page.keyboard_shortcuts.go_to_search": "सर्च फॉर्म पर फोकस सेट करें",
"page.keyboard_shortcuts.close_modal": "मोडल डायलॉग बंद करें",
"page.users.title": "उपभोक्ता",
"page.users.username": "यूसर्नेम",
"page.users.never_logged": "कभी नहीं",
"page.users.admin.yes": "हां",
"page.users.admin.no": "नहीं",
"page.users.actions": "कार्रवाई",
"page.users.last_login": "आखरी लॉगइन",
"page.users.is_admin": "प्रशासक",
"page.settings.title": "समायोजन",
"page.settings.link_google_account": "मेरा गूगल खाता जोरीय",
"page.settings.unlink_google_account": "मेरा गूगल खाता हटाय",
"page.settings.link_oidc_account": "मेरा ओपन-ईद खाता जोरीय",
"page.settings.unlink_oidc_account": "मेरा ओपन-ईद खाता हटाय",
"page.login.title": "साइन इन करें",
"page.login.google_signin": "गूगल के साथ साइन इन करें",
"page.login.oidc_signin": "ओपन-ईद के साथ साइन इन करें",
"page.integrations.title": "एकीकरण",
"page.integration.miniflux_api": "मिनिफलक्ष एपीआई",
"page.integration.miniflux_api_endpoint": "एपीआई समापन बिंदु",
"page.integration.miniflux_api_username": "यूसर्नेम",
"page.integration.miniflux_api_password": "पासवर्ड",
"page.integration.miniflux_api_password_value": "आपका खाता पासवर्ड",
"page.integration.bookmarklet": "बुकमार्कलेट",
"page.integration.bookmarklet.name": "मिनीफ्लक्स में जोड़ें",
"page.integration.bookmarklet.instructions": "इस लिंक को खींचकर अपने बुकमार्क पर छोड़ दें।",
"page.integration.bookmarklet.help": "यह विशेष लिंक आपको अपने वेब ब्राउज़र में बुकमार्क का उपयोग करके सीधे वेबसाइट की सदस्यता लेने की अनुमति देता है।",
"page.sessions.title": "सत्र",
"page.sessions.table.date": "दिनांक",
"page.sessions.table.ip": "आईपी ​​पता",
"page.sessions.table.user_agent": "उपभोक्ता अभिकर्ता",
"page.sessions.table.actions": "कार्रवाई",
"page.sessions.table.current_session": "वर्तमान सत्र",
"page.api_keys.title": "एपीआई कुंजी",
"page.api_keys.table.description": "विवरण",
"page.api_keys.table.token": "टोकन",
"page.api_keys.table.last_used_at": "आखरी इस्त्तमाल किया गया",
"page.api_keys.table.created_at": "निर्माण तिथि",
"page.api_keys.table.actions": "कार्रवाई",
"page.api_keys.never_used": "कभी प्रयोग नहीं हुआ",
"page.new_api_key.title": "नई एपीआई कुंजी",
"page.offline.title": "ऑफ़लाइन मोड",
"page.offline.message": "आप संपर्क में नहीं हैं",
"page.offline.refresh_page": "पृष्ठ को ताज़ा करने का प्रयास करें",
"alert.no_shared_entry": "कोई साझा प्रविष्टि नहीं है",
"alert.no_bookmark": "इस समय कोई बुकमार्क नहीं है",
"alert.no_category": "कोई श्रेणी नहीं है।",
"alert.no_category_entry": "इस श्रेणी में कोई विषय-वस्तु नहीं है।",
"alert.no_feed_entry": "इस फ़ीड के लिए कोई विषय-वस्तु नहीं है।",
"alert.no_feed": "आपके पास कोई सदस्यता नहीं है।",
"alert.no_feed_in_category": "इस श्रेणी के लिए कोई सदस्यता नहीं है।",
"alert.no_history": "इस समय कोई इतिहास नहीं है",
"alert.feed_error": "इस फ़ीड में एक समस्या है",
"alert.no_search_result": "इस खोज के लिए कोई परिणाम नहीं हैं।",
"alert.no_unread_entry": "कोई अपठित वस्तुत नहीं है।",
"alert.no_user": "आप एकमात्र उपयोगकर्ता हैं।",
"alert.account_unlinked": "आपका बाहरी खाता अब अलग कर दिया गया है!",
"alert.account_linked": "आपका बाहरी खाता अब लिंक हो गया है!",
"alert.pocket_linked": "आपका पॉकेट खाता अब लिंक हो गया है!",
"alert.prefs_saved": "प्राथमिकताएं सहेजी गईं!",
"error.unlink_account_without_password": "आपको एक पासवर्ड परिभाषित करना होगा अन्यथा आप फिर से लॉगिन नहीं कर पाएंगे।",
"error.duplicate_linked_account": "इस प्रदाता के साथ पहले से ही कोई व्यक्ति जुड़ा हुआ है!",
"error.duplicate_fever_username": "पहले से ही समान फीवर उपयोगकर्ता नाम वाला कोई और है!",
"error.duplicate_googlereader_username": "समान गूगल रीडर उपयोगकर्ता नाम वाला कोई और पहले से मौजूद है!",
"error.pocket_request_token": "पॉकेट से अनुरोध टोकन लाने में असमर्थ!",
"error.pocket_access_token": "पॉकेट से एक्सेस टोकन प्राप्त करने में असमर्थ!",
"error.category_already_exists": "यह श्रेणी पहले से मौजूद है।",
"error.unable_to_create_category": "यह श्रेणी बनाने में असमर्थ.",
"error.unable_to_update_category": "इस श्रेणी को अपडेट करने में असमर्थ।",
"error.user_already_exists": "यह उपयोगकर्ता पहले से ही मौजूद है।",
"error.unable_to_create_user": "इस उपयोगकर्ता को बनाने में असमर्थ।",
"error.unable_to_update_user": "इस उपयोगकर्ता को अपडेट करने में असमर्थ.",
"error.unable_to_update_feed": "इस फ़ीड को अपडेट करने में असमर्थ.",
"error.subscription_not_found": "कोई सदस्यता ढूँढने में असमर्थ.",
"error.invalid_theme": "अमान्य थीम.",
"error.invalid_language": "अमान्य भाषा.",
"error.invalid_timezone": "अमान्य समयक्षेत्र.",
"error.invalid_entry_direction": "अमान्य प्रवेश दिशा।",
"error.invalid_display_mode": "अमान्य वेब ऐप्लिकेशन प्रदर्शन मोड.",
"error.empty_file": "यह फ़ाइल खाली है।",
"error.bad_credentials": "अमान्य उपयोगकर्ता नाम या पासवर्ड।",
"error.fields_mandatory": "सभी फील्ड अनिवार्य।",
"error.title_required": "शीर्षक अनिवार्य है।",
"error.different_passwords": "पासवर्ड एक जैसे नहीं हैं।",
"error.password_min_length": "पासवर्ड में कम से कम 6 अक्षर होने चाहिए।",
"error.settings_mandatory_fields": "उपयोगकर्ता नाम, विषयवस्तु, भाषा और समयक्षेत्र फ़ील्ड अनिवार्य हैं।",
"error.settings_reading_speed_is_positive": "पढ़ने की गति सकारात्मक पूर्णांक होनी चाहिए।",
"error.entries_per_page_invalid": "प्रति पृष्ठ प्रविष्टियों की संख्या मान्य नहीं है।",
"error.feed_mandatory_fields": "URL और श्रेणी अनिवार्य हैं।",
"error.feed_already_exists": "यह फ़ीड पहले से मौजूद है.",
"error.invalid_feed_url": "दृष्टिकोण यूआरएल.",
"error.invalid_site_url": "अमान्य साइट यूआरएल",
"error.feed_url_not_empty": "फ़ीड यूआरएल खाली नहीं हो सकता.",
"error.site_url_not_empty": "साइट का यूआरएल खाली नहीं हो सकता.",
"error.feed_title_not_empty": "फ़ीड शीर्षक खाली नहीं हो सकता.",
"error.feed_category_not_found": "यह श्रेणी मौजूद नहीं है या इस उपयोगकर्ता से संबंधित नहीं है।",
"error.feed_invalid_blocklist_rule": "ब्लॉक सूची नियम अमान्य है।",
"error.feed_invalid_keeplist_rule": "सूची रखें नियम अमान्य है।",
"error.user_mandatory_fields": "उपयोगकर्ता नाम अनिवार्य है।",
"error.api_key_already_exists": "यह एपीआई कुंजी पहले से मौजूद है।",
"error.unable_to_create_api_key": "यह एपीआई कुंजी बनाने में असमर्थ।",
"form.feed.label.title": "शीर्षक",
"form.feed.label.site_url": "साइट यूआरएल",
"form.feed.label.feed_url": "फ़ीड यूआरएल",
"form.feed.label.category": "श्रेणी",
"form.feed.label.crawler": "मूल सामग्री प्राप्त करें",
"form.feed.label.feed_username": "फ़ीड उपयोगकर्ता नाम",
"form.feed.label.feed_password": "फ़ीड पासवर्ड",
"form.feed.label.user_agent": "डिफ़ॉल्ट उपयोगकर्ता एजेंट को ओवरराइड करें",
"form.feed.label.cookie": "कुकीज़ सेट करें",
"form.feed.label.scraper_rules": "खुरचनी नियम",
"form.feed.label.rewrite_rules": "नियम फिर से लिखें",
"form.feed.label.blocklist_rules": "ब्लॉक नियम",
"form.feed.label.keeplist_rules": "नियम बनाए रखें",
"form.feed.label.urlrewrite_rules": " यूआरएल पुनर्लेखन नियम",
"form.feed.label.ignore_http_cache": "एचटीटीपी कैश पर ध्यान न दें",
"form.feed.label.allow_self_signed_certificates": "स्व-हस्ताक्षरित या अमान्य प्रमाणपत्रों की अनुमति दें",
"form.feed.label.fetch_via_proxy": "प्रॉक्सी के माध्यम से प्राप्त करें",
"form.feed.label.disabled": "इस फ़ीड को रीफ़्रेश न करें",
"form.feed.label.hide_globally": "वैश्विक अपठित सूची में प्रविष्टियां छिपाएं",
"form.category.label.title": "शीर्षक",
"form.category.hide_globally": "वैश्विक अपठित सूची में प्रविष्टियां छिपाएं",
"form.user.label.username": "उपयोगकर्ता नाम",
"form.user.label.password": "पासवर्ड",
"form.user.label.confirmation": "पासवर्ड पुष्टि",
"form.user.label.admin": "प्रशासक",
"form.prefs.label.language": "भाषाओं",
"form.prefs.label.timezone": "समय क्षेत्र",
"form.prefs.label.theme": "थीम",
"form.prefs.label.entry_sorting": "प्रवेश छँटाई",
"form.prefs.label.entries_per_page": "प्रति पृष्ठ प्रविष्टियाँ",
"form.prefs.label.default_reading_speed": "अन्य भाषाओं के लिए पढ़ने की गति (प्रति मिनट शब्द)",
"form.prefs.label.cjk_reading_speed": "चीनी, कोरियाई और जापानी के लिए पढ़ने की गति (प्रति मिनट वर्ण)",
"form.prefs.label.display_mode": "वेब ऐप डिस्प्ले मोड (पुनः स्थापित करने की आवश्यकता है)",
"form.prefs.select.older_first": "पहले पुरानी प्रविष्टियाँ",
"form.prefs.select.recent_first": "हाल की प्रविष्टियाँ पहले",
"form.prefs.select.fullscreen": "पूर्ण स्क्रीन",
"form.prefs.select.standalone": "स्टैंडअलोन",
"form.prefs.select.minimal_ui": "कम से कम",
"form.prefs.select.browser": "ब्राउज़र",
"form.prefs.select.publish_time": "प्रवेश प्रकाशित समय",
"form.prefs.select.created_time": "प्रवेश बनाया समय",
"form.prefs.label.keyboard_shortcuts": "कीबोर्ड शॉर्टकट सक्षम करें",
"form.prefs.label.entry_swipe": "मोबाइल पर प्रविष्टियों पर स्वाइप जेस्चर सक्षम करें",
"form.prefs.label.show_reading_time": "विषय के लिए अनुमानित पढ़ने का समय दिखाएं",
"form.prefs.label.custom_css": "कस्टम सीएसएस",
"form.prefs.label.entry_order": "प्रवेश छँटाई कॉलम",
"form.import.label.file": "ओपीएमएल फ़ाइल",
"form.import.label.url": "यूआरएल",
"form.integration.fever_activate": "फीवर एपीआई सक्रिय करें",
"form.integration.fever_username": "फीवर उपयोगकर्ता नाम",
"form.integration.fever_password": "फीवर पासवर्ड",
"form.integration.fever_endpoint": "फीवर एपीआई समापन बिंदु:",
"form.integration.googlereader_activate": "गूगल रीडर एपीआई सक्रिय करें",
"form.integration.googlereader_username": "गूगल रीडर उपयोगकर्ता नाम",
"form.integration.googlereader_password": "गूगल रीडर पासवर्ड",
"form.integration.googlereader_endpoint": "गूगल रीडर एपीआई समापन बिंदु:",
"form.integration.pinboard_activate": "सहेजें विषयवस्तु प्रति का बोर्ड ",
"form.integration.pinboard_token": "पिनबोर्ड एपीआई टोकन",
"form.integration.pinboard_tags": "पिनबोर्ड टैग",
"form.integration.pinboard_bookmark": "बुकमार्क को अपठित के रूप में चिह्नित करें",
"form.integration.instapaper_activate": "विषय-वस्तु को इंस्टापेपर में सहेजें",
"form.integration.instapaper_username": "इंस्टापेपर यूजरनेम",
"form.integration.instapaper_password": "इंस्टापेपर पासवर्ड",
"form.integration.pocket_activate": "विषय-कविता को पॉकेट में सहेजें",
"form.integration.pocket_consumer_key": "पॉकेट उपभोक्ता कुंजी",
"form.integration.pocket_access_token": "पॉकेट एक्सेस टोकन",
"form.integration.pocket_connect_link": "अपना पॉकेट खाता कनेक्ट करें",
"form.integration.wallabag_activate": "विषय सहेजें वालाबाग में ",
"form.integration.wallabag_endpoint": "वालबैग एपीआई एंडपॉइंट",
"form.integration.wallabag_client_id": "वालाबैग क्लाइंट आईडी",
"form.integration.wallabag_client_secret": "वालाबैग क्लाइंट सीक्रेट",
"form.integration.wallabag_username": "वालाबैग उपयोगकर्ता नाम",
"form.integration.wallabag_password": "वालाबैग पासवर्ड",
"form.integration.nunux_keeper_activate": "विषय-वस्तु को ननक्स कीपर में सहेजें",
"form.integration.nunux_keeper_endpoint": "ननक्स कीपर एपीआई समापन बिंदु",
"form.integration.nunux_keeper_api_key": "ननक्स कीपर एपीआई कुंजी",
"form.integration.espial_activate": "विषय-वस्तु को जासूसी में सहेजें",
"form.integration.espial_endpoint": "जासूसी एपीआई समापन बिंदु",
"form.integration.espial_api_key": "जासूसी एपीआई कुंजी",
"form.integration.espial_tags": "जासूसी टैग",
"form.integration.telegram_bot_activate": "टेलीग्राम चैट के लिए नई विषय-कविता पुश करें",
"form.integration.telegram_bot_token": "बॉट टोकन",
"form.integration.telegram_chat_id": "चैट आईडी",
"form.integration.linkding_activate": "लिंक्डिन में विषयवस्तु सहेजें",
"form.integration.linkding_endpoint": "लिंकिंग एपीआई समापन बिंदु",
"form.integration.linkding_api_key": "लिंकिंग एपीआई कुंजी",
"form.api_key.label.description": "एपीआई कुंजी लेबल",
"form.submit.loading": "लोड हो रहा है...",
"form.submit.saving": "सहेजा जा रहा है...",
"time_elapsed.not_yet": "अभी तक नहीं",
"time_elapsed.yesterday": "कल",
"time_elapsed.now": "बिल्कुल अभी",
"time_elapsed.minutes": [
"%d मिनट पहले",
"%d मिनट पहले"
],
"time_elapsed.hours": [
"%d घंटेभर पहले",
"%d घंटो पहले"
],
"time_elapsed.days": [
"%d दिन पहले",
"%d दिन पहले"
],
"time_elapsed.weeks": [
"%d सप्ताह पहले",
"%d हफ्तों पहले"
],
"time_elapsed.months": [
"%d महीने पहले",
"%d महिनो पहले"
],
"time_elapsed.years": [
"%d साल पहले",
"%d वर्षों पहले"
]
}

View File

@ -243,6 +243,7 @@
"error.different_passwords": "Le password non coincidono.",
"error.password_min_length": "La password deve contenere almeno 6 caratteri.",
"error.settings_mandatory_fields": "Il nome utente, il tema, la lingua ed il fuso orario sono campi obbligatori.",
"error.settings_reading_speed_is_positive": "Le velocità di lettura devono essere numeri interi positivi.",
"error.entries_per_page_invalid": "Il numero di articoli per pagina non è valido.",
"error.feed_mandatory_fields": "L'URL e la categoria sono obbligatori.",
"error.feed_already_exists": "Questo feed esiste già.",
@ -275,6 +276,7 @@
"form.feed.label.rewrite_rules": "Regole di impaginazione del contenuto",
"form.feed.label.blocklist_rules": "Regole di blocco",
"form.feed.label.keeplist_rules": "Regole di autorizzazione",
"form.feed.label.urlrewrite_rules": "Regole di riscrittura URL",
"form.feed.label.ignore_http_cache": "Ignora cache HTTP",
"form.feed.label.allow_self_signed_certificates": "Consenti certificati autofirmati o non validi",
"form.feed.label.fetch_via_proxy": "Recuperare tramite proxy",
@ -291,6 +293,8 @@
"form.prefs.label.theme": "Tema",
"form.prefs.label.entry_sorting": "Ordinamento articoli",
"form.prefs.label.entries_per_page": "Articoli per pagina",
"form.prefs.label.default_reading_speed": "Velocità di lettura di altre lingue (parole al minuto)",
"form.prefs.label.cjk_reading_speed": "Velocità di lettura per cinese, coreano e giapponese (caratteri al minuto)",
"form.prefs.label.display_mode": "Modalità di visualizzazione web app (necessita la reinstallazione)",
"form.prefs.select.older_first": "Prima i più vecchi",
"form.prefs.select.recent_first": "Prima i più recenti",

View File

@ -243,6 +243,7 @@
"error.different_passwords": "パスワードが一致しません。",
"error.password_min_length": "パスワードは6文字以上である必要があります。",
"error.settings_mandatory_fields": "ユーザー名、テーマ、言語、タイムゾーンの全てが必要です。",
"error.settings_reading_speed_is_positive": "読み取り速度は正の整数でなければならない。",
"error.entries_per_page_invalid": "ページあたりのエントリ数が無効です。",
"error.feed_mandatory_fields": "URL と カテゴリが必要です。",
"error.feed_already_exists": "このフィードはすでに存在します。",
@ -275,6 +276,7 @@
"form.feed.label.rewrite_rules": "Rewrite ルール",
"form.feed.label.blocklist_rules": "ブロックルール",
"form.feed.label.keeplist_rules": "許可規則",
"form.feed.label.urlrewrite_rules": "URL書き換えルール",
"form.feed.label.ignore_http_cache": "HTTPキャッシュを無視",
"form.feed.label.allow_self_signed_certificates": "自己署名証明書または無効な証明書を許可する",
"form.feed.label.fetch_via_proxy": "プロキシ経由でフェッチ",
@ -291,6 +293,8 @@
"form.prefs.label.theme": "テーマ",
"form.prefs.label.entry_sorting": "記事の並べ替え",
"form.prefs.label.entries_per_page": "ページあたりのエントリ",
"form.prefs.label.default_reading_speed": "他言語の読解速度(単語/分)",
"form.prefs.label.cjk_reading_speed": "中国語、韓国語、日本語の読書速度1分間あたりの文字数",
"form.prefs.label.display_mode": "Webアプリの表示モード (再インストールが必要)",
"form.prefs.select.older_first": "古い記事を最初に",
"form.prefs.select.recent_first": "新しい記事を最初に",

View File

@ -243,6 +243,7 @@
"error.different_passwords": "Wachtwoorden zijn niet hetzelfde.",
"error.password_min_length": "Je moet minstens 6 tekens gebruiken.",
"error.settings_mandatory_fields": "Gebruikersnaam, skin, taal en tijdzone zijn verplicht.",
"error.settings_reading_speed_is_positive": "De leessnelheden moeten positieve gehele getallen zijn.",
"error.entries_per_page_invalid": "Het aantal inzendingen per pagina is niet geldig.",
"error.feed_mandatory_fields": "The URL en de categorie zijn verplicht.",
"error.feed_already_exists": "Deze feed bestaat al.",
@ -275,6 +276,7 @@
"form.feed.label.rewrite_rules": "Rewrite regels",
"form.feed.label.blocklist_rules": "Blokkeer regels",
"form.feed.label.keeplist_rules": "toestemmingsregels",
"form.feed.label.urlrewrite_rules": "Regels voor het herschrijven van URL's",
"form.feed.label.ignore_http_cache": "Negeer HTTP-cache",
"form.feed.label.allow_self_signed_certificates": "Sta zelfondertekende of ongeldige certificaten toe",
"form.feed.label.fetch_via_proxy": "Ophalen via proxy",
@ -291,6 +293,8 @@
"form.prefs.label.theme": "Skin",
"form.prefs.label.entry_sorting": "Volgorde van items",
"form.prefs.label.entries_per_page": "Inzendingen per pagina",
"form.prefs.label.default_reading_speed": "Leessnelheid voor andere talen (woorden per minuut)",
"form.prefs.label.cjk_reading_speed": "Leessnelheid voor Chinees, Koreaans en Japans (tekens per minuut)",
"form.prefs.label.display_mode": "Weergavemodus voor webapp (moet opnieuw worden geïnstalleerd)",
"form.prefs.select.older_first": "Oudere items eerst",
"form.prefs.select.recent_first": "Recente items eerst",

View File

@ -245,6 +245,7 @@
"error.different_passwords": "Hasła nie są identyczne.",
"error.password_min_length": "Musisz użyć co najmniej 6 znaków.",
"error.settings_mandatory_fields": "Pola nazwy użytkownika, tematu, języka i strefy czasowej są obowiązkowe.",
"error.settings_reading_speed_is_positive": "Prędkości odczytu muszą być dodatnimi liczbami całkowitymi.",
"error.entries_per_page_invalid": "Liczba wpisów na stronę jest nieprawidłowa.",
"error.feed_mandatory_fields": "URL i kategoria są obowiązkowe.",
"error.feed_already_exists": "Ten kanał już istnieje.",
@ -277,6 +278,7 @@
"form.feed.label.rewrite_rules": "Reguły zapisu",
"form.feed.label.blocklist_rules": "Zasady blokowania",
"form.feed.label.keeplist_rules": "Zasady zezwoleń",
"form.feed.label.urlrewrite_rules": "Zasady przepisywania adresów URL",
"form.feed.label.ignore_http_cache": "Zignoruj pamięć podręczną HTTP",
"form.feed.label.allow_self_signed_certificates": "Zezwalaj na certyfikaty z podpisem własnym lub nieprawidłowe certyfikaty",
"form.feed.label.fetch_via_proxy": "Pobierz przez proxy",
@ -293,6 +295,8 @@
"form.prefs.label.theme": "Wygląd",
"form.prefs.label.entry_sorting": "Sortowanie artykułów",
"form.prefs.label.entries_per_page": "Wpisy na stronie",
"form.prefs.label.default_reading_speed": "Prędkość czytania dla innych języków (słowa na minutę)",
"form.prefs.label.cjk_reading_speed": "Prędkość czytania dla języka chińskiego, koreańskiego i japońskiego (znaki na minutę)",
"form.prefs.label.display_mode": "Tryb wyświetlania aplikacji internetowej (wymaga ponownej instalacji)",
"form.prefs.select.older_first": "Najstarsze wpisy jako pierwsze",
"form.prefs.label.keyboard_shortcuts": "Włącz skróty klawiaturowe",

View File

@ -243,6 +243,7 @@
"error.different_passwords": "As senhas não são iguais.",
"error.password_min_length": "A senha deve ter no mínimo 6 caracteres.",
"error.settings_mandatory_fields": "Os campos de nome de usuário, tema, idioma e fuso horário são obrigatórios.",
"error.settings_reading_speed_is_positive": "As velocidades de leitura devem ser inteiros positivos.",
"error.entries_per_page_invalid": "O número de itens por página é inválido.",
"error.feed_mandatory_fields": "O campo de URL e categoria são obrigatórios.",
"error.feed_already_exists": "Este feed já existe.",
@ -275,6 +276,7 @@
"form.feed.label.rewrite_rules": "Regras para o Rewrite",
"form.feed.label.blocklist_rules": "Regras de bloqueio",
"form.feed.label.keeplist_rules": "Regras de permissão",
"form.feed.label.urlrewrite_rules": "Regras de reescrita de URL",
"form.feed.label.ignore_http_cache": "Ignorar cache HTTP",
"form.feed.label.allow_self_signed_certificates": "Permitir certificados autoassinados ou inválidos",
"form.feed.label.disabled": "Não atualizar esta fonte",
@ -291,6 +293,8 @@
"form.prefs.label.theme": "Tema",
"form.prefs.label.entry_sorting": "Ordenação dos itens",
"form.prefs.label.entries_per_page": "Itens por página",
"form.prefs.label.default_reading_speed": "Velocidade de leitura para outros idiomas (palavras por minuto)",
"form.prefs.label.cjk_reading_speed": "Velocidade de leitura para chinês, coreano e japonês (caracteres por minuto)",
"form.prefs.label.display_mode": "Modo de exibição do aplicativo Web (precisa ser reinstalado)",
"form.prefs.select.older_first": "Itens mais velhos primeiro",
"form.prefs.select.recent_first": "Itens mais recentes",

View File

@ -42,7 +42,7 @@
"menu.edit_category": "Изменить",
"menu.add_feed": "Добавить подписку",
"menu.add_user": "Добавить пользователя",
"menu.flush_history": "Отчистить историю",
"menu.flush_history": "Очистить историю",
"menu.feed_entries": "Статьи",
"menu.api_keys": "API-ключи",
"menu.create_api_key": "Создать новый API-ключ",
@ -51,7 +51,7 @@
"search.placeholder": "Поиск…",
"pagination.next": "Следующая",
"pagination.previous": "Предыдущая",
"entry.status.unread": "Непрочитано",
"entry.status.unread": "Не прочитано",
"entry.status.read": "Прочитано",
"entry.status.toast.unread": "Помечено как непрочитанное",
"entry.status.toast.read": "Помечено как прочитанное",
@ -116,8 +116,8 @@
"page.about.build_date": "Дата сборки:",
"page.about.author": "Автор:",
"page.about.license": "Лицензия:",
"page.about.postgres_version": "Postgres bерсия:",
"page.about.go_version": "Go bерсия:",
"page.about.postgres_version": "Postgres версия:",
"page.about.go_version": "Go версия:",
"page.about.global_config_options": "глобальные параметры конфигурации",
"page.add_feed.title": "Новая подписка",
"page.add_feed.no_category": "Категории отсутствуют. У вас должна быть хотя бы одна категория.",
@ -245,6 +245,7 @@
"error.different_passwords": "Пароли не совпадают.",
"error.password_min_length": "Вы должны использовать минимум 6 символов.",
"error.settings_mandatory_fields": "Имя пользователя, тема, язык и часовой пояс обязательны.",
"error.settings_reading_speed_is_positive": "Скорости считывания должны быть целыми положительными числами.",
"error.entries_per_page_invalid": "Количество записей на странице недействительно.",
"error.feed_mandatory_fields": "URL и категория обязательны.",
"error.feed_already_exists": "Этот фид уже существует.",
@ -277,6 +278,7 @@
"form.feed.label.rewrite_rules": "Правила Rewrite",
"form.feed.label.blocklist_rules": "Правила блокировки",
"form.feed.label.keeplist_rules": "правила разрешений",
"form.feed.label.urlrewrite_rules": "Правила перезаписи URL",
"form.feed.label.ignore_http_cache": "Игнорировать HTTP-кеш",
"form.feed.label.allow_self_signed_certificates": "Разрешить самоподписанные или недействительные сертификаты",
"form.feed.label.fetch_via_proxy": "Получить через прокси",
@ -293,6 +295,8 @@
"form.prefs.label.theme": "Тема",
"form.prefs.label.entry_sorting": "Сортировка записей",
"form.prefs.label.entries_per_page": "Записи на странице",
"form.prefs.label.default_reading_speed": "Скорость чтения на других языках (слов в минуту)",
"form.prefs.label.cjk_reading_speed": "Скорость чтения на китайском, корейском и японском языках (знаков в минуту)",
"form.prefs.label.display_mode": "Режим отображения веб-приложения (требуется переустановка)",
"form.prefs.select.older_first": "Сначала старые записи",
"form.prefs.select.recent_first": "Сначала последние записи",

View File

@ -248,6 +248,7 @@
"error.different_passwords": "Parolalar eşleşmiyor.",
"error.password_min_length": "Parola en az 6 karakter içermeli.",
"error.settings_mandatory_fields": "Kullanıcı ad, tema, dil ve saat dilimi zorunlu.",
"error.settings_reading_speed_is_positive": "Okuma hızları pozitif tam sayılar olmalıdır.",
"error.entries_per_page_invalid": "Sayfa başına ileti sayısı geçersiz.",
"error.feed_mandatory_fields": "URL ve kategori zorunlu.",
"error.feed_already_exists": "Bu besleme zaten mevcut.",
@ -275,6 +276,7 @@
"form.feed.label.rewrite_rules": "Yeniden Yazma Kuralları",
"form.feed.label.blocklist_rules": "Engelleme Kuralları",
"form.feed.label.keeplist_rules": "Saklama Kuralları",
"form.feed.label.urlrewrite_rules": "URL Yeniden Yazma Kuralları",
"form.feed.label.ignore_http_cache": "HTTP önbelleğini yoksay",
"form.feed.label.allow_self_signed_certificates": "Kendinden imzalı veya geçersiz sertifikalara izin ver",
"form.feed.label.fetch_via_proxy": "Proxy ile çek",
@ -291,6 +293,8 @@
"form.prefs.label.theme": "Tema",
"form.prefs.label.entry_sorting": "İleti Sıralaması",
"form.prefs.label.entries_per_page": "Sayfa başına ileti",
"form.prefs.label.default_reading_speed": "Diğer diller için okuma hızı (dakika başına kelime)",
"form.prefs.label.cjk_reading_speed": "Çince, Korece ve Japonca için okuma hızı (dakika başına karakter)",
"form.prefs.label.display_mode": "Web uygulaması görüntüleme modu (yeniden kurulum gerektirir)",
"form.prefs.select.older_first": "Önce eski iletiler",
"form.prefs.select.recent_first": "Önce yeni iletiler",

View File

@ -249,6 +249,7 @@
"error.feed_url_not_empty": "订阅源的网址不能为空。",
"error.site_url_not_empty": "源网站的网址不能为空。",
"error.feed_title_not_empty": "订阅源的标题不能为空。",
"error.settings_reading_speed_is_positive": "阅读速度必须是正整数。",
"error.feed_category_not_found": "此类别不存在或不属于该用户。",
"error.feed_invalid_blocklist_rule": "阻止列表规则无效。",
"error.feed_invalid_keeplist_rule": "保留列表规则无效。",
@ -273,6 +274,7 @@
"form.feed.label.rewrite_rules": "重写规则",
"form.feed.label.blocklist_rules": "阻止规则",
"form.feed.label.keeplist_rules": "保留规则",
"form.feed.label.urlrewrite_rules": "URL 重写规则",
"form.feed.label.ignore_http_cache": "忽略 HTTP 缓存",
"form.feed.label.allow_self_signed_certificates": "允许自签名证书或无效证书",
"form.feed.label.fetch_via_proxy": "通过代理获取",
@ -290,6 +292,8 @@
"form.prefs.label.entry_sorting": "文章排序",
"form.prefs.label.entries_per_page": "每页文章数",
"form.prefs.label.display_mode": "渐进式网页应用显示模式(需要重新添加)",
"form.prefs.label.default_reading_speed": "其他语言的阅读速度(每分钟字数)",
"form.prefs.label.cjk_reading_speed": "中文、韩文和日文的阅读速度(每分钟字符数)",
"form.prefs.select.older_first": "旧->新",
"form.prefs.select.recent_first": "新->旧",
"form.prefs.select.fullscreen": "全屏",

View File

@ -243,6 +243,7 @@
"error.different_passwords": "兩次輸入的密碼不同",
"error.password_min_length": "請至少輸入 6 個字元",
"error.settings_mandatory_fields": "必須填寫使用者名稱、主題、語言以及時區",
"error.settings_reading_speed_is_positive": "The reading speeds must be positive integers.",
"error.entries_per_page_invalid": "每頁的文章數無效。",
"error.feed_mandatory_fields": "必須填寫網址和分類",
"error.feed_already_exists": "此Feed已存在。",
@ -275,6 +276,7 @@
"form.feed.label.rewrite_rules": "重寫規則",
"form.feed.label.blocklist_rules": "過濾規則",
"form.feed.label.keeplist_rules": "保留規則",
"form.feed.label.urlrewrite_rules": "URL 重写规则",
"form.feed.label.ignore_http_cache": "忽略 HTTP 快取",
"form.feed.label.allow_self_signed_certificates": "允許自簽章憑證或無效憑證",
"form.feed.label.fetch_via_proxy": "透過代理獲取",
@ -291,6 +293,8 @@
"form.prefs.label.theme": "主題",
"form.prefs.label.entry_sorting": "文章排序",
"form.prefs.label.entries_per_page": "每頁文章數",
"form.prefs.label.default_reading_speed": "Reading speed for other languages (words per minute)",
"form.prefs.label.cjk_reading_speed": "Reading speed for Chinese, Korean and Japanese (characters per minute)",
"form.prefs.label.display_mode": "漸進式網頁應用顯示模式(需要重新新增)",
"form.prefs.select.older_first": "舊->新",
"form.prefs.select.recent_first": "新->舊",

View File

@ -25,7 +25,7 @@ Load configuration file\&.
.PP
.B \-config-dump
.RS 4
Print parsed configuration values\&.
Print parsed configuration values. This will include sensitive information like passwords\&.
.RE
.PP
.B \-create-admin

View File

@ -40,6 +40,7 @@ type Feed struct {
Crawler bool `json:"crawler"`
BlocklistRules string `json:"blocklist_rules"`
KeeplistRules string `json:"keeplist_rules"`
UrlRewriteRules string `json:"urlrewrite_rules"`
UserAgent string `json:"user_agent"`
Cookie string `json:"cookie"`
Username string `json:"username"`
@ -141,6 +142,7 @@ type FeedCreationRequest struct {
BlocklistRules string `json:"blocklist_rules"`
KeeplistRules string `json:"keeplist_rules"`
HideGlobally bool `json:"hide_globally"`
UrlRewriteRules string `json:"urlrewrite_rules"`
}
// FeedModificationRequest represents the request to update a feed.
@ -152,6 +154,7 @@ type FeedModificationRequest struct {
RewriteRules *string `json:"rewrite_rules"`
BlocklistRules *string `json:"blocklist_rules"`
KeeplistRules *string `json:"keeplist_rules"`
UrlRewriteRules *string `json:"urlrewrite_rules"`
Crawler *bool `json:"crawler"`
UserAgent *string `json:"user_agent"`
Cookie *string `json:"cookie"`
@ -191,6 +194,10 @@ func (f *FeedModificationRequest) Patch(feed *Feed) {
feed.KeeplistRules = *f.KeeplistRules
}
if f.UrlRewriteRules != nil {
feed.UrlRewriteRules = *f.UrlRewriteRules
}
if f.BlocklistRules != nil {
feed.BlocklistRules = *f.BlocklistRules
}

View File

@ -12,24 +12,26 @@ import (
// User represents a user in the system.
type User struct {
ID int64 `json:"id"`
Username string `json:"username"`
Password string `json:"-"`
IsAdmin bool `json:"is_admin"`
Theme string `json:"theme"`
Language string `json:"language"`
Timezone string `json:"timezone"`
EntryDirection string `json:"entry_sorting_direction"`
EntryOrder string `json:"entry_sorting_order"`
Stylesheet string `json:"stylesheet"`
GoogleID string `json:"google_id"`
OpenIDConnectID string `json:"openid_connect_id"`
EntriesPerPage int `json:"entries_per_page"`
KeyboardShortcuts bool `json:"keyboard_shortcuts"`
ShowReadingTime bool `json:"show_reading_time"`
EntrySwipe bool `json:"entry_swipe"`
LastLoginAt *time.Time `json:"last_login_at"`
DisplayMode string `json:"display_mode"`
ID int64 `json:"id"`
Username string `json:"username"`
Password string `json:"-"`
IsAdmin bool `json:"is_admin"`
Theme string `json:"theme"`
Language string `json:"language"`
Timezone string `json:"timezone"`
EntryDirection string `json:"entry_sorting_direction"`
EntryOrder string `json:"entry_sorting_order"`
Stylesheet string `json:"stylesheet"`
GoogleID string `json:"google_id"`
OpenIDConnectID string `json:"openid_connect_id"`
EntriesPerPage int `json:"entries_per_page"`
KeyboardShortcuts bool `json:"keyboard_shortcuts"`
ShowReadingTime bool `json:"show_reading_time"`
EntrySwipe bool `json:"entry_swipe"`
LastLoginAt *time.Time `json:"last_login_at"`
DisplayMode string `json:"display_mode"`
DefaultReadingSpeed int `json:"default_reading_speed"`
CJKReadingSpeed int `json:"cjk_reading_speed"`
}
// UserCreationRequest represents the request to create a user.
@ -43,22 +45,24 @@ type UserCreationRequest struct {
// UserModificationRequest represents the request to update a user.
type UserModificationRequest struct {
Username *string `json:"username"`
Password *string `json:"password"`
Theme *string `json:"theme"`
Language *string `json:"language"`
Timezone *string `json:"timezone"`
EntryDirection *string `json:"entry_sorting_direction"`
EntryOrder *string `json:"entry_sorting_order"`
Stylesheet *string `json:"stylesheet"`
GoogleID *string `json:"google_id"`
OpenIDConnectID *string `json:"openid_connect_id"`
EntriesPerPage *int `json:"entries_per_page"`
IsAdmin *bool `json:"is_admin"`
KeyboardShortcuts *bool `json:"keyboard_shortcuts"`
ShowReadingTime *bool `json:"show_reading_time"`
EntrySwipe *bool `json:"entry_swipe"`
DisplayMode *string `json:"display_mode"`
Username *string `json:"username"`
Password *string `json:"password"`
Theme *string `json:"theme"`
Language *string `json:"language"`
Timezone *string `json:"timezone"`
EntryDirection *string `json:"entry_sorting_direction"`
EntryOrder *string `json:"entry_sorting_order"`
Stylesheet *string `json:"stylesheet"`
GoogleID *string `json:"google_id"`
OpenIDConnectID *string `json:"openid_connect_id"`
EntriesPerPage *int `json:"entries_per_page"`
IsAdmin *bool `json:"is_admin"`
KeyboardShortcuts *bool `json:"keyboard_shortcuts"`
ShowReadingTime *bool `json:"show_reading_time"`
EntrySwipe *bool `json:"entry_swipe"`
DisplayMode *string `json:"display_mode"`
DefaultReadingSpeed *int `json:"default_reading_speed"`
CJKReadingSpeed *int `json:"cjk_reading_speed"`
}
// Patch updates the User object with the modification request.
@ -126,6 +130,14 @@ func (u *UserModificationRequest) Patch(user *User) {
if u.DisplayMode != nil {
user.DisplayMode = *u.DisplayMode
}
if u.DefaultReadingSpeed != nil {
user.DefaultReadingSpeed = *u.DefaultReadingSpeed
}
if u.CJKReadingSpeed != nil {
user.CJKReadingSpeed = *u.CJKReadingSpeed
}
}
// UseTimezone converts last login date to the given timezone.

View File

@ -5,18 +5,16 @@
package proxy // import "miniflux.app/proxy"
import (
"regexp"
"strings"
"miniflux.app/config"
"miniflux.app/reader/sanitizer"
"miniflux.app/url"
"github.com/PuerkitoBio/goquery"
"github.com/gorilla/mux"
)
var regexSplitSrcset = regexp.MustCompile(`,\s+`)
// ImageProxyRewriter replaces image URLs with internal proxy URLs.
func ImageProxyRewriter(router *mux.Router, data string) string {
proxyImages := config.Opts.ProxyImages()
@ -30,24 +28,20 @@ func ImageProxyRewriter(router *mux.Router, data string) string {
}
doc.Find("img").Each(func(i int, img *goquery.Selection) {
if srcAttr, ok := img.Attr("src"); ok {
if !isDataURL(srcAttr) && (proxyImages == "all" || !url.IsHTTPS(srcAttr)) {
img.SetAttr("src", ProxifyURL(router, srcAttr))
if srcAttrValue, ok := img.Attr("src"); ok {
if !isDataURL(srcAttrValue) && (proxyImages == "all" || !url.IsHTTPS(srcAttrValue)) {
img.SetAttr("src", ProxifyURL(router, srcAttrValue))
}
}
if srcsetAttr, ok := img.Attr("srcset"); ok {
if proxyImages == "all" || !url.IsHTTPS(srcsetAttr) {
proxifySourceSet(img, router, srcsetAttr)
}
if srcsetAttrValue, ok := img.Attr("srcset"); ok {
proxifySourceSet(img, router, proxyImages, srcsetAttrValue)
}
})
doc.Find("picture source").Each(func(i int, sourceElement *goquery.Selection) {
if srcsetAttr, ok := sourceElement.Attr("srcset"); ok {
if proxyImages == "all" || !url.IsHTTPS(srcsetAttr) {
proxifySourceSet(sourceElement, router, srcsetAttr)
}
if srcsetAttrValue, ok := sourceElement.Attr("srcset"); ok {
proxifySourceSet(sourceElement, router, proxyImages, srcsetAttrValue)
}
})
@ -59,30 +53,16 @@ func ImageProxyRewriter(router *mux.Router, data string) string {
return output
}
func proxifySourceSet(element *goquery.Selection, router *mux.Router, attributeValue string) {
var proxifiedSources []string
func proxifySourceSet(element *goquery.Selection, router *mux.Router, proxyImages, srcsetAttrValue string) {
imageCandidates := sanitizer.ParseSrcSetAttribute(srcsetAttrValue)
for _, source := range regexSplitSrcset.Split(attributeValue, -1) {
parts := strings.Split(strings.TrimSpace(source), " ")
nbParts := len(parts)
if nbParts > 0 {
rewrittenSource := parts[0]
if !isDataURL(rewrittenSource) {
rewrittenSource = ProxifyURL(router, rewrittenSource)
}
if nbParts > 1 {
rewrittenSource += " " + parts[1]
}
proxifiedSources = append(proxifiedSources, rewrittenSource)
for _, imageCandidate := range imageCandidates {
if !isDataURL(imageCandidate.ImageURL) && (proxyImages == "all" || !url.IsHTTPS(imageCandidate.ImageURL)) {
imageCandidate.ImageURL = ProxifyURL(router, imageCandidate.ImageURL)
}
}
if len(proxifiedSources) > 0 {
element.SetAttr("srcset", strings.Join(proxifiedSources, ", "))
}
element.SetAttr("srcset", imageCandidates.String())
}
func isDataURL(s string) bool {

View File

@ -220,6 +220,29 @@ func TestProxyFilterWithSrcset(t *testing.T) {
}
}
func TestProxyFilterWithEmptySrcset(t *testing.T) {
os.Clearenv()
os.Setenv("PROXY_IMAGES", "all")
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/{encodedURL}", func(w http.ResponseWriter, r *http.Request) {}).Name("proxy")
input := `<p><img src="http://website/folder/image.png" srcset="" alt="test"></p>`
expected := `<p><img src="/proxy/aHR0cDovL3dlYnNpdGUvZm9sZGVyL2ltYWdlLnBuZw==" srcset="" alt="test"/></p>`
output := ImageProxyRewriter(r, input)
if expected != output {
t.Errorf(`Not expected output: got %s`, output)
}
}
func TestProxyFilterWithPictureSource(t *testing.T) {
os.Clearenv()
os.Setenv("PROXY_IMAGES", "all")
@ -234,8 +257,31 @@ func TestProxyFilterWithPictureSource(t *testing.T) {
r := mux.NewRouter()
r.HandleFunc("/proxy/{encodedURL}", func(w http.ResponseWriter, r *http.Request) {}).Name("proxy")
input := `<picture><source srcset="http://website/folder/image2.png 656w, http://website/folder/image3.png 360w"></picture>`
expected := `<picture><source srcset="/proxy/aHR0cDovL3dlYnNpdGUvZm9sZGVyL2ltYWdlMi5wbmc= 656w, /proxy/aHR0cDovL3dlYnNpdGUvZm9sZGVyL2ltYWdlMy5wbmc= 360w"/></picture>`
input := `<picture><source srcset="http://website/folder/image2.png 656w, http://website/folder/image3.png 360w, https://website/some,image.png 2x"></picture>`
expected := `<picture><source srcset="/proxy/aHR0cDovL3dlYnNpdGUvZm9sZGVyL2ltYWdlMi5wbmc= 656w, /proxy/aHR0cDovL3dlYnNpdGUvZm9sZGVyL2ltYWdlMy5wbmc= 360w, /proxy/aHR0cHM6Ly93ZWJzaXRlL3NvbWUsaW1hZ2UucG5n 2x"/></picture>`
output := ImageProxyRewriter(r, input)
if expected != output {
t.Errorf(`Not expected output: got %s`, output)
}
}
func TestProxyFilterOnlyNonHTTPWithPictureSource(t *testing.T) {
os.Clearenv()
os.Setenv("PROXY_IMAGES", "https")
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/{encodedURL}", func(w http.ResponseWriter, r *http.Request) {}).Name("proxy")
input := `<picture><source srcset="http://website/folder/image2.png 656w, https://website/some,image.png 2x"></picture>`
expected := `<picture><source srcset="/proxy/aHR0cDovL3dlYnNpdGUvZm9sZGVyL2ltYWdlMi5wbmc= 656w, https://website/some,image.png 2x"/></picture>`
output := ImageProxyRewriter(r, input)
if expected != output {

View File

@ -14,5 +14,8 @@ import (
// ProxifyURL generates an URL for a proxified resource.
func ProxifyURL(router *mux.Router, link string) string {
return route.Path(router, "proxy", "encodedURL", base64.URLEncoding.EncodeToString([]byte(link)))
if link != "" {
return route.Path(router, "proxy", "encodedURL", base64.URLEncoding.EncodeToString([]byte(link)))
}
return ""
}

View File

@ -32,6 +32,11 @@ var (
func CreateFeed(store *storage.Storage, userID int64, feedCreationRequest *model.FeedCreationRequest) (*model.Feed, error) {
defer timer.ExecutionTime(time.Now(), fmt.Sprintf("[CreateFeed] FeedURL=%s", feedCreationRequest.FeedURL))
user, storeErr := store.UserByID(userID)
if storeErr != nil {
return nil, storeErr
}
if !store.CategoryIDExists(userID, feedCreationRequest.CategoryID) {
return nil, errors.NewLocalizedError(errCategoryNotFound)
}
@ -74,11 +79,12 @@ func CreateFeed(store *storage.Storage, userID int64, feedCreationRequest *model
subscription.RewriteRules = feedCreationRequest.RewriteRules
subscription.BlocklistRules = feedCreationRequest.BlocklistRules
subscription.KeeplistRules = feedCreationRequest.KeeplistRules
subscription.UrlRewriteRules = feedCreationRequest.UrlRewriteRules
subscription.WithCategoryID(feedCreationRequest.CategoryID)
subscription.WithClientResponse(response)
subscription.CheckedNow()
processor.ProcessFeedEntries(store, subscription)
processor.ProcessFeedEntries(store, subscription, user)
if storeErr := store.CreateFeed(subscription); storeErr != nil {
return nil, storeErr
@ -100,8 +106,12 @@ func CreateFeed(store *storage.Storage, userID int64, feedCreationRequest *model
// RefreshFeed refreshes a feed.
func RefreshFeed(store *storage.Storage, userID, feedID int64) error {
defer timer.ExecutionTime(time.Now(), fmt.Sprintf("[RefreshFeed] feedID=%d", feedID))
userLanguage := store.UserLanguage(userID)
printer := locale.NewPrinter(userLanguage)
user, storeErr := store.UserByID(userID)
if storeErr != nil {
return storeErr
}
printer := locale.NewPrinter(user.Language)
originalFeed, storeErr := store.FeedByID(userID, feedID)
if storeErr != nil {
@ -163,7 +173,7 @@ func RefreshFeed(store *storage.Storage, userID, feedID int64) error {
}
originalFeed.Entries = updatedFeed.Entries
processor.ProcessFeedEntries(store, originalFeed)
processor.ProcessFeedEntries(store, originalFeed, user)
// We don't update existing entries when the crawler is enabled (we crawl only inexisting entries).
if storeErr := store.RefreshFeedEntries(originalFeed.UserID, originalFeed.ID, originalFeed.Entries, !originalFeed.Crawler); storeErr != nil {

View File

@ -6,36 +6,21 @@ package opml // import "miniflux.app/reader/opml"
import (
"encoding/xml"
"strings"
)
// Specs: http://opml.org/spec2.opml
type opmlDocument struct {
XMLName xml.Name `xml:"opml"`
Version string `xml:"version,attr"`
Header opmlHeader `xml:"head"`
Outlines []opmlOutline `xml:"body>outline"`
XMLName xml.Name `xml:"opml"`
Version string `xml:"version,attr"`
Header opmlHeader `xml:"head"`
Outlines opmlOutlineCollection `xml:"body>outline"`
}
func NewOPMLDocument() *opmlDocument {
return &opmlDocument{}
}
func (o *opmlDocument) GetSubscriptionList() SubcriptionList {
var subscriptions SubcriptionList
for _, outline := range o.Outlines {
if len(outline.Outlines) > 0 {
for _, element := range outline.Outlines {
// outline.Text is only available in OPML v2.
subscriptions = element.Append(subscriptions, outline.Text)
}
} else {
subscriptions = outline.Append(subscriptions, "")
}
}
return subscriptions
}
type opmlHeader struct {
Title string `xml:"title,omitempty"`
DateCreated string `xml:"dateCreated,omitempty"`
@ -43,11 +28,15 @@ type opmlHeader struct {
}
type opmlOutline struct {
Title string `xml:"title,attr,omitempty"`
Text string `xml:"text,attr"`
FeedURL string `xml:"xmlUrl,attr,omitempty"`
SiteURL string `xml:"htmlUrl,attr,omitempty"`
Outlines []opmlOutline `xml:"outline,omitempty"`
Title string `xml:"title,attr,omitempty"`
Text string `xml:"text,attr"`
FeedURL string `xml:"xmlUrl,attr,omitempty"`
SiteURL string `xml:"htmlUrl,attr,omitempty"`
Outlines opmlOutlineCollection `xml:"outline,omitempty"`
}
func (o *opmlOutline) IsSubscription() bool {
return strings.TrimSpace(o.FeedURL) != ""
}
func (o *opmlOutline) GetTitle() string {
@ -78,15 +67,8 @@ func (o *opmlOutline) GetSiteURL() string {
return o.FeedURL
}
func (o *opmlOutline) Append(subscriptions SubcriptionList, category string) SubcriptionList {
if o.FeedURL != "" {
subscriptions = append(subscriptions, &Subcription{
Title: o.GetTitle(),
FeedURL: o.FeedURL,
SiteURL: o.GetSiteURL(),
CategoryName: category,
})
}
type opmlOutlineCollection []opmlOutline
return subscriptions
func (o opmlOutlineCollection) HasChildren() bool {
return len(o) > 0
}

View File

@ -25,5 +25,21 @@ func Parse(data io.Reader) (SubcriptionList, *errors.LocalizedError) {
return nil, errors.NewLocalizedError("Unable to parse OPML file: %q", err)
}
return opmlDocument.GetSubscriptionList(), nil
return getSubscriptionsFromOutlines(opmlDocument.Outlines, ""), nil
}
func getSubscriptionsFromOutlines(outlines opmlOutlineCollection, category string) (subscriptions SubcriptionList) {
for _, outline := range outlines {
if outline.IsSubscription() {
subscriptions = append(subscriptions, &Subcription{
Title: outline.GetTitle(),
FeedURL: outline.FeedURL,
SiteURL: outline.GetSiteURL(),
CategoryName: category,
})
} else if outline.Outlines.HasChildren() {
subscriptions = append(subscriptions, getSubscriptionsFromOutlines(outline.Outlines, outline.Text)...)
}
}
return subscriptions
}

View File

@ -38,15 +38,15 @@ func TestParseOpmlWithoutCategories(t *testing.T) {
subscriptions, err := Parse(bytes.NewBufferString(data))
if err != nil {
t.Error(err)
t.Fatal(err)
}
if len(subscriptions) != 13 {
t.Errorf("Wrong number of subscriptions: %d instead of %d", len(subscriptions), 13)
t.Fatalf("Wrong number of subscriptions: %d instead of %d", len(subscriptions), 13)
}
if !subscriptions[0].Equals(expected[0]) {
t.Errorf(`Subscription are different: "%v" vs "%v"`, subscriptions[0], expected[0])
t.Errorf(`Subscription is different: "%v" vs "%v"`, subscriptions[0], expected[0])
}
}
@ -75,16 +75,16 @@ func TestParseOpmlWithCategories(t *testing.T) {
subscriptions, err := Parse(bytes.NewBufferString(data))
if err != nil {
t.Error(err)
t.Fatal(err)
}
if len(subscriptions) != 3 {
t.Errorf("Wrong number of subscriptions: %d instead of %d", len(subscriptions), 3)
t.Fatalf("Wrong number of subscriptions: %d instead of %d", len(subscriptions), 3)
}
for i := 0; i < len(subscriptions); i++ {
if !subscriptions[i].Equals(expected[i]) {
t.Errorf(`Subscription are different: "%v" vs "%v"`, subscriptions[i], expected[i])
t.Errorf(`Subscription is different: "%v" vs "%v"`, subscriptions[i], expected[i])
}
}
}
@ -108,16 +108,16 @@ func TestParseOpmlWithEmptyTitleAndEmptySiteURL(t *testing.T) {
subscriptions, err := Parse(bytes.NewBufferString(data))
if err != nil {
t.Error(err)
t.Fatal(err)
}
if len(subscriptions) != 2 {
t.Errorf("Wrong number of subscriptions: %d instead of %d", len(subscriptions), 2)
t.Fatalf("Wrong number of subscriptions: %d instead of %d", len(subscriptions), 2)
}
for i := 0; i < len(subscriptions); i++ {
if !subscriptions[i].Equals(expected[i]) {
t.Errorf(`Subscription are different: "%v" vs "%v"`, subscriptions[i], expected[i])
t.Errorf(`Subscription is different: "%v" vs "%v"`, subscriptions[i], expected[i])
}
}
}
@ -146,16 +146,16 @@ func TestParseOpmlVersion1(t *testing.T) {
subscriptions, err := Parse(bytes.NewBufferString(data))
if err != nil {
t.Error(err)
t.Fatal(err)
}
if len(subscriptions) != 2 {
t.Errorf("Wrong number of subscriptions: %d instead of %d", len(subscriptions), 2)
t.Fatalf("Wrong number of subscriptions: %d instead of %d", len(subscriptions), 2)
}
for i := 0; i < len(subscriptions); i++ {
if !subscriptions[i].Equals(expected[i]) {
t.Errorf(`Subscription are different: "%v" vs "%v"`, subscriptions[i], expected[i])
t.Errorf(`Subscription is different: "%v" vs "%v"`, subscriptions[i], expected[i])
}
}
}
@ -180,16 +180,58 @@ func TestParseOpmlVersion1WithoutOuterOutline(t *testing.T) {
subscriptions, err := Parse(bytes.NewBufferString(data))
if err != nil {
t.Error(err)
t.Fatal(err)
}
if len(subscriptions) != 2 {
t.Errorf("Wrong number of subscriptions: %d instead of %d", len(subscriptions), 2)
t.Fatalf("Wrong number of subscriptions: %d instead of %d", len(subscriptions), 2)
}
for i := 0; i < len(subscriptions); i++ {
if !subscriptions[i].Equals(expected[i]) {
t.Errorf(`Subscription are different: "%v" vs "%v"`, subscriptions[i], expected[i])
t.Errorf(`Subscription is different: "%v" vs "%v"`, subscriptions[i], expected[i])
}
}
}
func TestParseOpmlVersion1WithSeveralNestedOutlines(t *testing.T) {
data := `<?xml version="1.0"?>
<opml xmlns:rssowl="http://www.rssowl.org" version="1.1">
<head>
<title>RSSOwl Subscriptions</title>
<dateCreated>星期二, 26 四月 2022 00:12:04 CST</dateCreated>
</head>
<body>
<outline text="My Feeds" rssowl:isSet="true" rssowl:id="7">
<outline text="Some Category" rssowl:isSet="false" rssowl:id="55">
<outline type="rss" title="Feed 1" xmlUrl="http://example.org/feed1/" htmlUrl="http://example.org/1"></outline>
<outline type="rss" title="Feed 2" xmlUrl="http://example.org/feed2/" htmlUrl="http://example.org/2"></outline>
</outline>
<outline text="Another Category" rssowl:isSet="false" rssowl:id="87">
<outline type="rss" title="Feed 3" xmlUrl="http://example.org/feed3/" htmlUrl="http://example.org/3"></outline>
</outline>
</outline>
</body>
</opml>
`
var expected SubcriptionList
expected = append(expected, &Subcription{Title: "Feed 1", FeedURL: "http://example.org/feed1/", SiteURL: "http://example.org/1", CategoryName: "Some Category"})
expected = append(expected, &Subcription{Title: "Feed 2", FeedURL: "http://example.org/feed2/", SiteURL: "http://example.org/2", CategoryName: "Some Category"})
expected = append(expected, &Subcription{Title: "Feed 3", FeedURL: "http://example.org/feed3/", SiteURL: "http://example.org/3", CategoryName: "Another Category"})
subscriptions, err := Parse(bytes.NewBufferString(data))
if err != nil {
t.Fatal(err)
}
if len(subscriptions) != 3 {
t.Fatalf("Wrong number of subscriptions: %d instead of %d", len(subscriptions), 3)
}
for i := 0; i < len(subscriptions); i++ {
if !subscriptions[i].Equals(expected[i]) {
t.Errorf(`Subscription is different: "%v" vs "%v"`, subscriptions[i], expected[i])
}
}
}
@ -213,16 +255,16 @@ func TestParseOpmlWithInvalidCharacterEntity(t *testing.T) {
subscriptions, err := Parse(bytes.NewBufferString(data))
if err != nil {
t.Error(err)
t.Fatal(err)
}
if len(subscriptions) != 1 {
t.Errorf("Wrong number of subscriptions: %d instead of %d", len(subscriptions), 1)
t.Fatalf("Wrong number of subscriptions: %d instead of %d", len(subscriptions), 1)
}
for i := 0; i < len(subscriptions); i++ {
if !subscriptions[i].Equals(expected[i]) {
t.Errorf(`Subscription are different: "%v" vs "%v"`, subscriptions[i], expected[i])
t.Errorf(`Subscription is different: "%v" vs "%v"`, subscriptions[i], expected[i])
}
}
}

View File

@ -32,12 +32,13 @@ import (
)
var (
youtubeRegex = regexp.MustCompile(`youtube\.com/watch\?v=(.*)`)
iso8601Regex = regexp.MustCompile(`^P((?P<year>\d+)Y)?((?P<month>\d+)M)?((?P<week>\d+)W)?((?P<day>\d+)D)?(T((?P<hour>\d+)H)?((?P<minute>\d+)M)?((?P<second>\d+)S)?)?$`)
youtubeRegex = regexp.MustCompile(`youtube\.com/watch\?v=(.*)`)
iso8601Regex = regexp.MustCompile(`^P((?P<year>\d+)Y)?((?P<month>\d+)M)?((?P<week>\d+)W)?((?P<day>\d+)D)?(T((?P<hour>\d+)H)?((?P<minute>\d+)M)?((?P<second>\d+)S)?)?$`)
customReplaceRuleRegex = regexp.MustCompile(`rewrite\("(.*)"\|"(.*)"\)`)
)
// ProcessFeedEntries downloads original web page for entries and apply filters.
func ProcessFeedEntries(store *storage.Storage, feed *model.Feed) {
func ProcessFeedEntries(store *storage.Storage, feed *model.Feed, user *model.User) {
var filteredEntries model.Entries
for _, entry := range feed.Entries {
@ -47,13 +48,14 @@ func ProcessFeedEntries(store *storage.Storage, feed *model.Feed) {
continue
}
url := getUrlFromEntry(feed, entry)
entryIsNew := !store.EntryURLExists(feed.ID, entry.URL)
if feed.Crawler && entryIsNew {
logger.Debug("[Processor] Crawling entry %q from feed %q", entry.URL, feed.FeedURL)
logger.Debug("[Processor] Crawling entry %q from feed %q", url, feed.FeedURL)
startTime := time.Now()
content, scraperErr := scraper.Fetch(
entry.URL,
url,
feed.ScraperRules,
feed.UserAgent,
feed.Cookie,
@ -77,10 +79,10 @@ func ProcessFeedEntries(store *storage.Storage, feed *model.Feed) {
}
}
entry.Content = rewrite.Rewriter(entry.URL, entry.Content, feed.RewriteRules)
entry.Content = rewrite.Rewriter(url, entry.Content, feed.RewriteRules)
// The sanitizer should always run at the end of the process to make sure unsafe HTML is filtered.
entry.Content = sanitizer.Sanitize(entry.URL, entry.Content)
entry.Content = sanitizer.Sanitize(url, entry.Content)
if entryIsNew {
intg, err := store.Integration(feed.UserID)
@ -94,7 +96,7 @@ func ProcessFeedEntries(store *storage.Storage, feed *model.Feed) {
}
}
updateEntryReadingTime(store, feed, entry, entryIsNew)
updateEntryReadingTime(store, feed, entry, entryIsNew, user)
filteredEntries = append(filteredEntries, entry)
}
@ -125,10 +127,12 @@ func isAllowedEntry(feed *model.Feed, entry *model.Entry) bool {
}
// ProcessEntryWebPage downloads the entry web page and apply rewrite rules.
func ProcessEntryWebPage(feed *model.Feed, entry *model.Entry) error {
func ProcessEntryWebPage(feed *model.Feed, entry *model.Entry, user *model.User) error {
startTime := time.Now()
url := getUrlFromEntry(feed, entry)
content, scraperErr := scraper.Fetch(
entry.URL,
url,
entry.Feed.ScraperRules,
entry.Feed.UserAgent,
entry.Feed.Cookie,
@ -148,18 +152,34 @@ func ProcessEntryWebPage(feed *model.Feed, entry *model.Entry) error {
return scraperErr
}
content = rewrite.Rewriter(entry.URL, content, entry.Feed.RewriteRules)
content = sanitizer.Sanitize(entry.URL, content)
content = rewrite.Rewriter(url, content, entry.Feed.RewriteRules)
content = sanitizer.Sanitize(url, content)
if content != "" {
entry.Content = content
entry.ReadingTime = calculateReadingTime(content)
entry.ReadingTime = calculateReadingTime(content, user)
}
return nil
}
func updateEntryReadingTime(store *storage.Storage, feed *model.Feed, entry *model.Entry, entryIsNew bool) {
func getUrlFromEntry(feed *model.Feed, entry *model.Entry) string {
var url = entry.URL
if feed.UrlRewriteRules != "" {
parts := customReplaceRuleRegex.FindStringSubmatch(feed.UrlRewriteRules)
if len(parts) >= 3 {
re := regexp.MustCompile(parts[1])
url = re.ReplaceAllString(entry.URL, parts[2])
logger.Debug(`[Processor] Rewriting entry URL %s to %s`, entry.URL, url)
} else {
logger.Debug("[Processor] Cannot find search and replace terms for replace rule %s", feed.UrlRewriteRules)
}
}
return url
}
func updateEntryReadingTime(store *storage.Storage, feed *model.Feed, entry *model.Entry, entryIsNew bool, user *model.User) {
if shouldFetchYouTubeWatchTime(entry) {
if entryIsNew {
watchTime, err := fetchYouTubeWatchTime(entry.URL)
@ -174,7 +194,7 @@ func updateEntryReadingTime(store *storage.Storage, feed *model.Feed, entry *mod
// Handle YT error case and non-YT entries.
if entry.ReadingTime == 0 {
entry.ReadingTime = calculateReadingTime(entry.Content)
entry.ReadingTime = calculateReadingTime(entry.Content, user)
}
}
@ -249,16 +269,16 @@ func parseISO8601(from string) (time.Duration, error) {
return d, nil
}
func calculateReadingTime(content string) int {
func calculateReadingTime(content string, user *model.User) int {
sanitizedContent := sanitizer.StripTags(content)
languageInfo := getlang.FromString(sanitizedContent)
var timeToReadInt int
if languageInfo.LanguageCode() == "ko" || languageInfo.LanguageCode() == "zh" || languageInfo.LanguageCode() == "jp" {
timeToReadInt = int(math.Ceil(float64(utf8.RuneCountInString(sanitizedContent)) / 500))
timeToReadInt = int(math.Ceil(float64(utf8.RuneCountInString(sanitizedContent)) / float64(user.CJKReadingSpeed)))
} else {
nbOfWords := len(strings.Fields(sanitizedContent))
timeToReadInt = int(math.Ceil(float64(nbOfWords) / 265))
timeToReadInt = int(math.Ceil(float64(nbOfWords) / float64(user.DefaultReadingSpeed)))
}
return timeToReadInt

View File

@ -20,7 +20,6 @@ import (
var (
youtubeEmbedRegex = regexp.MustCompile(`//www\.youtube\.com/embed/(.*)`)
splitSrcsetRegex = regexp.MustCompile(`,\s?`)
)
// Sanitize returns safe HTML.
@ -101,6 +100,12 @@ func Sanitize(baseURL, input string) string {
func sanitizeAttributes(baseURL, tagName string, attributes []html.Attribute) ([]string, string) {
var htmlAttrs, attrNames []string
var err error
var isImageLargerThanLayout bool
if tagName == "img" {
imgWidth := getIntegerAttributeValue("width", attributes)
isImageLargerThanLayout = imgWidth > 750
}
for _, attribute := range attributes {
value := attribute.Val
@ -113,6 +118,16 @@ func sanitizeAttributes(baseURL, tagName string, attributes []html.Attribute) ([
value = sanitizeSrcsetAttr(baseURL, value)
}
if tagName == "img" && (attribute.Key == "width" || attribute.Key == "height") {
if !isPositiveInteger(value) {
continue
}
if isImageLargerThanLayout {
continue
}
}
if isExternalResourceAttribute(attribute.Key) {
if tagName == "iframe" {
if isValidIframeSource(baseURL, attribute.Val) {
@ -350,7 +365,7 @@ func isValidIframeSource(baseURL, src string) bool {
func getTagAllowList() map[string][]string {
whitelist := make(map[string][]string)
whitelist["img"] = []string{"alt", "title", "src", "srcset", "sizes"}
whitelist["img"] = []string{"alt", "title", "src", "srcset", "sizes", "width", "height"}
whitelist["picture"] = []string{}
whitelist["audio"] = []string{"src"}
whitelist["video"] = []string{"poster", "height", "width", "src"}
@ -443,52 +458,17 @@ func isBlockedTag(tagName string) bool {
return false
}
/*
One or more strings separated by commas, indicating possible image sources for the user agent to use.
Each string is composed of:
- A URL to an image
- Optionally, whitespace followed by one of:
- A width descriptor (a positive integer directly followed by w). The width descriptor is divided by the source size given in the sizes attribute to calculate the effective pixel density.
- A pixel density descriptor (a positive floating point number directly followed by x).
*/
func sanitizeSrcsetAttr(baseURL, value string) string {
var sanitizedSources []string
rawSources := splitSrcsetRegex.Split(value, -1)
for _, rawSource := range rawSources {
parts := strings.Split(strings.TrimSpace(rawSource), " ")
nbParts := len(parts)
imageCandidates := ParseSrcSetAttribute(value)
if nbParts > 0 {
sanitizedSource, err := url.AbsoluteURL(baseURL, parts[0])
if err != nil {
continue
}
if nbParts == 2 && isValidWidthOrDensityDescriptor(parts[1]) {
sanitizedSource += " " + parts[1]
}
sanitizedSources = append(sanitizedSources, sanitizedSource)
for _, imageCandidate := range imageCandidates {
absoluteURL, err := url.AbsoluteURL(baseURL, imageCandidate.ImageURL)
if err == nil {
imageCandidate.ImageURL = absoluteURL
}
}
return strings.Join(sanitizedSources, ", ")
}
func isValidWidthOrDensityDescriptor(value string) bool {
if value == "" {
return false
}
lastChar := value[len(value)-1:]
if lastChar != "w" && lastChar != "x" {
return false
}
_, err := strconv.ParseFloat(value[0:len(value)-1], 32)
return err == nil
return imageCandidates.String()
}
func isValidDataAttribute(value string) bool {
@ -511,3 +491,24 @@ func isValidDataAttribute(value string) bool {
}
return false
}
func isPositiveInteger(value string) bool {
if number, err := strconv.Atoi(value); err == nil {
return number > 0
}
return false
}
func getAttributeValue(name string, attributes []html.Attribute) string {
for _, attribute := range attributes {
if attribute.Key == name {
return attribute.Val
}
}
return ""
}
func getIntegerAttributeValue(name string, attributes []html.Attribute) int {
number, _ := strconv.Atoi(getAttributeValue(name, attributes))
return number
}

View File

@ -15,6 +15,36 @@ func TestValidInput(t *testing.T) {
}
}
func TestImgWithWidthAndHeightAttribute(t *testing.T) {
input := `<img src="https://example.org/image.png" width="10" height="20">`
expected := `<img src="https://example.org/image.png" width="10" height="20" loading="lazy">`
output := Sanitize("http://example.org/", input)
if output != expected {
t.Errorf(`Wrong output: %s`, output)
}
}
func TestImgWithWidthAndHeightAttributeLargerThanMinifluxLayout(t *testing.T) {
input := `<img src="https://example.org/image.png" width="1200" height="675">`
expected := `<img src="https://example.org/image.png" loading="lazy">`
output := Sanitize("http://example.org/", input)
if output != expected {
t.Errorf(`Wrong output: %s`, output)
}
}
func TestImgWithIncorrectWidthAndHeightAttribute(t *testing.T) {
input := `<img src="https://example.org/image.png" width="10px" height="20px">`
expected := `<img src="https://example.org/image.png" loading="lazy">`
output := Sanitize("http://example.org/", input)
if output != expected {
t.Errorf(`Wrong output: %s`, output)
}
}
func TestImgWithTextDataURL(t *testing.T) {
input := `<img src="data:text/plain;base64,SGVsbG8sIFdvcmxkIQ==" alt="Example">`
expected := ``
@ -65,16 +95,6 @@ func TestMediumImgWithSrcset(t *testing.T) {
}
}
func TestEconomistImgWithSrcset(t *testing.T) {
input := `<img loading="lazy" src="https://www.economist.com/img/b/608/634/90/sites/default/files/images/print-edition/20211009_WWC585.png" srcSet="https://www.economist.com/img/b/200/209/90/sites/default/files/images/print-edition/20211009_WWC585.png 200w,https://www.economist.com/img/b/300/313/90/sites/default/files/images/print-edition/20211009_WWC585.png 300w,https://www.economist.com/img/b/400/417/90/sites/default/files/images/print-edition/20211009_WWC585.png 400w,https://www.economist.com/img/b/600/626/90/sites/default/files/images/print-edition/20211009_WWC585.png 600w,https://www.economist.com/img/b/640/667/90/sites/default/files/images/print-edition/20211009_WWC585.png 640w,https://www.economist.com/img/b/800/834/90/sites/default/files/images/print-edition/20211009_WWC585.png 800w,https://www.economist.com/img/b/1000/1043/90/sites/default/files/images/print-edition/20211009_WWC585.png 1000w,https://www.economist.com/img/b/1280/1335/90/sites/default/files/images/print-edition/20211009_WWC585.png 1280w" sizes="300px" alt=""/>`
expected := `<img src="https://www.economist.com/img/b/608/634/90/sites/default/files/images/print-edition/20211009_WWC585.png" srcset="https://www.economist.com/img/b/200/209/90/sites/default/files/images/print-edition/20211009_WWC585.png 200w, https://www.economist.com/img/b/300/313/90/sites/default/files/images/print-edition/20211009_WWC585.png 300w, https://www.economist.com/img/b/400/417/90/sites/default/files/images/print-edition/20211009_WWC585.png 400w, https://www.economist.com/img/b/600/626/90/sites/default/files/images/print-edition/20211009_WWC585.png 600w, https://www.economist.com/img/b/640/667/90/sites/default/files/images/print-edition/20211009_WWC585.png 640w, https://www.economist.com/img/b/800/834/90/sites/default/files/images/print-edition/20211009_WWC585.png 800w, https://www.economist.com/img/b/1000/1043/90/sites/default/files/images/print-edition/20211009_WWC585.png 1000w, https://www.economist.com/img/b/1280/1335/90/sites/default/files/images/print-edition/20211009_WWC585.png 1280w" sizes="300px" alt="" loading="lazy"/>`
output := Sanitize("http://example.org/", input)
if output != expected {
t.Errorf(`Wrong output: %s`, output)
}
}
func TestSelfClosingTags(t *testing.T) {
input := `<p>This <br> is a <strong>text</strong> <br/>with an image: <img src="http://example.org/" alt="Test" loading="lazy"/>.</p>`
output := Sanitize("http://example.org/", input)

View File

@ -0,0 +1,82 @@
// Copyright 2022 Frédéric Guillot. All rights reserved.
// Use of this source code is governed by the Apache 2.0
// license that can be found in the LICENSE file.
package sanitizer
import (
"fmt"
"strconv"
"strings"
)
type ImageCandidate struct {
ImageURL string
Descriptor string
}
type ImageCandidates []*ImageCandidate
func (c ImageCandidates) String() string {
var htmlCandidates []string
for _, imageCandidate := range c {
var htmlCandidate string
if imageCandidate.Descriptor != "" {
htmlCandidate = fmt.Sprintf(`%s %s`, imageCandidate.ImageURL, imageCandidate.Descriptor)
} else {
htmlCandidate = imageCandidate.ImageURL
}
htmlCandidates = append(htmlCandidates, htmlCandidate)
}
return strings.Join(htmlCandidates, ", ")
}
// ParseSrcSetAttribute returns the list of image candidates from the set.
// https://html.spec.whatwg.org/#parse-a-srcset-attribute
func ParseSrcSetAttribute(attributeValue string) (imageCandidates ImageCandidates) {
unparsedCandidates := strings.Split(attributeValue, ", ")
for _, unparsedCandidate := range unparsedCandidates {
if candidate, err := parseImageCandidate(unparsedCandidate); err == nil {
imageCandidates = append(imageCandidates, candidate)
}
}
return imageCandidates
}
func parseImageCandidate(input string) (*ImageCandidate, error) {
input = strings.TrimSpace(input)
parts := strings.Split(strings.TrimSpace(input), " ")
nbParts := len(parts)
if nbParts > 2 || nbParts == 0 {
return nil, fmt.Errorf(`srcset: invalid number of descriptors`)
}
if nbParts == 2 {
if !isValidWidthOrDensityDescriptor(parts[1]) {
return nil, fmt.Errorf(`srcset: invalid descriptor`)
}
return &ImageCandidate{ImageURL: parts[0], Descriptor: parts[1]}, nil
}
return &ImageCandidate{ImageURL: parts[0]}, nil
}
func isValidWidthOrDensityDescriptor(value string) bool {
if value == "" {
return false
}
lastChar := value[len(value)-1:]
if lastChar != "w" && lastChar != "x" {
return false
}
_, err := strconv.ParseFloat(value[0:len(value)-1], 32)
return err == nil
}

View File

@ -0,0 +1,85 @@
// Copyright 2022 Frédéric Guillot. All rights reserved.
// Use of this source code is governed by the Apache 2.0
// license that can be found in the LICENSE file.
package sanitizer
import "testing"
func TestParseSrcSetAttributeWithRelativeURLs(t *testing.T) {
input := `example-320w.jpg, example-480w.jpg 1.5x, example-640,w.jpg 2x, example-640w.jpg 640w`
candidates := ParseSrcSetAttribute(input)
if len(candidates) != 4 {
t.Error(`Incorrect number of candidates`)
}
if candidates.String() != `example-320w.jpg, example-480w.jpg 1.5x, example-640,w.jpg 2x, example-640w.jpg 640w` {
t.Errorf(`Unexpected string output`)
}
}
func TestParseSrcSetAttributeWithAbsoluteURLs(t *testing.T) {
input := `http://example.org/example-320w.jpg 320w, http://example.org/example-480w.jpg 1.5x`
candidates := ParseSrcSetAttribute(input)
if len(candidates) != 2 {
t.Error(`Incorrect number of candidates`)
}
if candidates.String() != `http://example.org/example-320w.jpg 320w, http://example.org/example-480w.jpg 1.5x` {
t.Errorf(`Unexpected string output`)
}
}
func TestParseSrcSetAttributeWithOneCandidate(t *testing.T) {
input := `http://example.org/example-320w.jpg`
candidates := ParseSrcSetAttribute(input)
if len(candidates) != 1 {
t.Error(`Incorrect number of candidates`)
}
if candidates.String() != `http://example.org/example-320w.jpg` {
t.Errorf(`Unexpected string output`)
}
}
func TestParseSrcSetAttributeWithCommaURL(t *testing.T) {
input := `http://example.org/example,a:b/d.jpg , example-480w.jpg 1.5x`
candidates := ParseSrcSetAttribute(input)
if len(candidates) != 2 {
t.Error(`Incorrect number of candidates`)
}
if candidates.String() != `http://example.org/example,a:b/d.jpg, example-480w.jpg 1.5x` {
t.Errorf(`Unexpected string output`)
}
}
func TestParseSrcSetAttributeWithIncorrectDescriptor(t *testing.T) {
input := `http://example.org/example-320w.jpg test`
candidates := ParseSrcSetAttribute(input)
if len(candidates) != 0 {
t.Error(`Incorrect number of candidates`)
}
if candidates.String() != `` {
t.Errorf(`Unexpected string output`)
}
}
func TestParseSrcSetAttributeWithTooManyDescriptors(t *testing.T) {
input := `http://example.org/example-320w.jpg 10w 1x`
candidates := ParseSrcSetAttribute(input)
if len(candidates) != 0 {
t.Error(`Incorrect number of candidates`)
}
if candidates.String() != `` {
t.Errorf(`Unexpected string output`)
}
}

View File

@ -242,10 +242,11 @@ func (s *Storage) CreateFeed(feed *model.Feed) error {
ignore_http_cache,
allow_self_signed_certificates,
fetch_via_proxy,
hide_globally
hide_globally,
url_rewrite_rules
)
VALUES
($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21)
($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22)
RETURNING
id
`
@ -272,6 +273,7 @@ func (s *Storage) CreateFeed(feed *model.Feed) error {
feed.AllowSelfSignedCertificates,
feed.FetchViaProxy,
feed.HideGlobally,
feed.UrlRewriteRules,
).Scan(&feed.ID)
if err != nil {
return fmt.Errorf(`store: unable to create feed %q: %v`, feed.FeedURL, err)
@ -330,9 +332,10 @@ func (s *Storage) UpdateFeed(feed *model.Feed) (err error) {
ignore_http_cache=$21,
allow_self_signed_certificates=$22,
fetch_via_proxy=$23,
hide_globally=$24
hide_globally=$24,
url_rewrite_rules=$25
WHERE
id=$25 AND user_id=$26
id=$26 AND user_id=$27
`
_, err = s.db.Exec(query,
feed.FeedURL,
@ -359,6 +362,7 @@ func (s *Storage) UpdateFeed(feed *model.Feed) (err error) {
feed.AllowSelfSignedCertificates,
feed.FetchViaProxy,
feed.HideGlobally,
feed.UrlRewriteRules,
feed.ID,
feed.UserID,
)

View File

@ -157,6 +157,7 @@ func (f *FeedQueryBuilder) GetFeeds() (model.Feeds, error) {
f.rewrite_rules,
f.blocklist_rules,
f.keeplist_rules,
f.url_rewrite_rules,
f.crawler,
f.user_agent,
f.cookie,
@ -219,6 +220,7 @@ func (f *FeedQueryBuilder) GetFeeds() (model.Feeds, error) {
&feed.RewriteRules,
&feed.BlocklistRules,
&feed.KeeplistRules,
&feed.UrlRewriteRules,
&feed.Crawler,
&feed.UserAgent,
&feed.Cookie,

View File

@ -85,7 +85,9 @@ func (s *Storage) CreateUser(userCreationRequest *model.UserCreationRequest) (*m
google_id,
openid_connect_id,
display_mode,
entry_order
entry_order,
default_reading_speed,
cjk_reading_speed
`
tx, err := s.db.Begin()
@ -118,6 +120,8 @@ func (s *Storage) CreateUser(userCreationRequest *model.UserCreationRequest) (*m
&user.OpenIDConnectID,
&user.DisplayMode,
&user.EntryOrder,
&user.DefaultReadingSpeed,
&user.CJKReadingSpeed,
)
if err != nil {
tx.Rollback()
@ -168,9 +172,11 @@ func (s *Storage) UpdateUser(user *model.User) error {
google_id=$13,
openid_connect_id=$14,
display_mode=$15,
entry_order=$16
entry_order=$16,
default_reading_speed=$17,
cjk_reading_speed=$18
WHERE
id=$17
id=$19
`
_, err = s.db.Exec(
@ -191,6 +197,8 @@ func (s *Storage) UpdateUser(user *model.User) error {
user.OpenIDConnectID,
user.DisplayMode,
user.EntryOrder,
user.DefaultReadingSpeed,
user.CJKReadingSpeed,
user.ID,
)
if err != nil {
@ -213,9 +221,11 @@ func (s *Storage) UpdateUser(user *model.User) error {
google_id=$12,
openid_connect_id=$13,
display_mode=$14,
entry_order=$15
entry_order=$15,
default_reading_speed=$16,
cjk_reading_speed=$17
WHERE
id=$16
id=$18
`
_, err := s.db.Exec(
@ -235,6 +245,8 @@ func (s *Storage) UpdateUser(user *model.User) error {
user.OpenIDConnectID,
user.DisplayMode,
user.EntryOrder,
user.DefaultReadingSpeed,
user.CJKReadingSpeed,
user.ID,
)
@ -276,7 +288,9 @@ func (s *Storage) UserByID(userID int64) (*model.User, error) {
google_id,
openid_connect_id,
display_mode,
entry_order
entry_order,
default_reading_speed,
cjk_reading_speed
FROM
users
WHERE
@ -305,7 +319,9 @@ func (s *Storage) UserByUsername(username string) (*model.User, error) {
google_id,
openid_connect_id,
display_mode,
entry_order
entry_order,
default_reading_speed,
cjk_reading_speed
FROM
users
WHERE
@ -334,7 +350,9 @@ func (s *Storage) UserByField(field, value string) (*model.User, error) {
google_id,
openid_connect_id,
display_mode,
entry_order
entry_order,
default_reading_speed,
cjk_reading_speed
FROM
users
WHERE
@ -370,7 +388,9 @@ func (s *Storage) UserByAPIKey(token string) (*model.User, error) {
u.google_id,
u.openid_connect_id,
u.display_mode,
u.entry_order
u.entry_order,
u.default_reading_speed,
u.cjk_reading_speed
FROM
users u
LEFT JOIN
@ -401,6 +421,8 @@ func (s *Storage) fetchUser(query string, args ...interface{}) (*model.User, err
&user.OpenIDConnectID,
&user.DisplayMode,
&user.EntryOrder,
&user.DefaultReadingSpeed,
&user.CJKReadingSpeed,
)
if err == sql.ErrNoRows {
@ -492,7 +514,9 @@ func (s *Storage) Users() (model.Users, error) {
google_id,
openid_connect_id,
display_mode,
entry_order
entry_order,
default_reading_speed,
cjk_reading_speed
FROM
users
ORDER BY username ASC
@ -524,6 +548,8 @@ func (s *Storage) Users() (model.Users, error) {
&user.OpenIDConnectID,
&user.DisplayMode,
&user.EntryOrder,
&user.DefaultReadingSpeed,
&user.CJKReadingSpeed,
)
if err != nil {

View File

@ -97,6 +97,9 @@
</a>
</div>
<input type="text" name="keeplist_rules" id="form-keeplist-rules" value="{{ .form.KeeplistRules }}" spellcheck="false">
<label for="form-urlrewrite-rules">{{ t "form.feed.label.urlrewrite_rules" }}</label>
<input type="text" name="urlrewrite_rules" id="form-urlrewrite-rules" value="{{ .form.UrlRewriteRules }}" spellcheck="false">
</div>
</details>

View File

@ -17,6 +17,7 @@
<input type="hidden" name="rewrite_rules" value="{{ .form.RewriteRules }}">
<input type="hidden" name="blocklist_rules" value="{{ .form.BlocklistRules }}">
<input type="hidden" name="keeplist_rules" value="{{ .form.KeeplistRules }}">
<input type="hidden" name="urlrewrite_rules" value="{{ .form.UrlRewriteRules }}">
{{ if .form.FetchViaProxy }}
<input type="hidden" name="fetch_via_proxy" value="1">
{{ end }}

View File

@ -32,7 +32,7 @@
{{ if .errorMessage }}
<div class="alert alert-error">{{ t .errorMessage }}</div>
{{ end }}
<label for="form-category">{{ t "form.feed.label.category" }}</label>
<select id="form-category" name="category_id" autofocus>
{{ range .categories }}
@ -111,6 +111,9 @@
</div>
<input type="text" name="keeplist_rules" id="form-keeplist-rules" value="{{ .form.KeeplistRules }}" spellcheck="false">
<label for="form-urlrewrite-rules">{{ t "form.feed.label.urlrewrite_rules" }}</label>
<input type="text" name="urlrewrite_rules" id="form-urlrewrite-rules" value="{{ .form.UrlRewriteRules }}" spellcheck="false">
<label><input type="checkbox" name="crawler" value="1" {{ if .form.Crawler }}checked{{ end }}> {{ t "form.feed.label.crawler" }}</label>
<label><input type="checkbox" name="ignore_http_cache" value="1" {{ if .form.IgnoreHTTPCache }}checked{{ end }}> {{ t "form.feed.label.ignore_http_cache" }}</label>
<label><input type="checkbox" name="allow_self_signed_certificates" value="1" {{ if .form.AllowSelfSignedCertificates }}checked{{ end }}> {{ t "form.feed.label.allow_self_signed_certificates" }}</label>
@ -118,7 +121,7 @@
<label><input type="checkbox" name="fetch_via_proxy" value="1" {{ if .form.FetchViaProxy }}checked{{ end }}> {{ t "form.feed.label.fetch_via_proxy" }}</label>
{{ end }}
<label><input type="checkbox" name="disabled" value="1" {{ if .form.Disabled }}checked{{ end }}> {{ t "form.feed.label.disabled" }}</label>
{{ if not .form.CategoryHidden }}
<label><input type="checkbox" name="hide_globally" value="1"{{ if .form.HideGlobally }} checked{{ end }}> {{ t "form.feed.label.hide_globally" }}</label>
{{ end }}

View File

@ -72,6 +72,12 @@
<label><input type="checkbox" name="entry_swipe" value="1" {{ if .form.EntrySwipe }}checked{{ end }}> {{ t "form.prefs.label.entry_swipe" }}</label>
<label for="form-cjk-reading-speed">{{ t "form.prefs.label.cjk_reading_speed" }}</label>
<input type="number" name="cjk_reading_speed" id="form-cjk-reading-speed" value="{{ .form.CJKReadingSpeed }}" min="1">
<label for="form-default-reading-speed">{{ t "form.prefs.label.default_reading_speed" }}</label>
<input type="number" name="default_reading_speed" id="form-default-reading-speed" value="{{ .form.DefaultReadingSpeed }}" min="1">
<label>{{t "form.prefs.label.custom_css" }}</label><textarea name="custom_css" cols="40" rows="8" spellcheck="false">{{ .form.CustomCSS }}</textarea>
<div class="buttons">
<button type="submit" class="button button-primary" data-label-loading="{{ t "form.submit.saving" }}">{{ t "action.update" }}</button>

View File

@ -2,6 +2,7 @@
// Use of this source code is governed by the Apache 2.0
// license that can be found in the LICENSE file.
//go:build integration
// +build integration
package tests
@ -86,6 +87,14 @@ func TestGetUsers(t *testing.T) {
if users[0].DisplayMode != "standalone" {
t.Fatalf(`Invalid web app display mode, got "%v"`, users[0].DisplayMode)
}
if users[0].DefaultReadingSpeed != 265 {
t.Fatalf(`Invalid default reading speed, got "%v"`, users[0].DefaultReadingSpeed)
}
if users[0].CJKReadingSpeed != 500 {
t.Fatalf(`Invalid cjk reading speed, got "%v"`, users[0].CJKReadingSpeed)
}
}
func TestCreateStandardUser(t *testing.T) {
@ -135,6 +144,14 @@ func TestCreateStandardUser(t *testing.T) {
if user.DisplayMode != "standalone" {
t.Fatalf(`Invalid web app display mode, got "%v"`, user.DisplayMode)
}
if user.DefaultReadingSpeed != 265 {
t.Fatalf(`Invalid default reading speed, got "%v"`, user.DefaultReadingSpeed)
}
if user.CJKReadingSpeed != 500 {
t.Fatalf(`Invalid cjk reading speed, got "%v"`, user.CJKReadingSpeed)
}
}
func TestRemoveUser(t *testing.T) {
@ -207,6 +224,14 @@ func TestGetUserByID(t *testing.T) {
if user.DisplayMode != "standalone" {
t.Fatalf(`Invalid web app display mode, got "%v"`, user.DisplayMode)
}
if user.DefaultReadingSpeed != 265 {
t.Fatalf(`Invalid default reading speed, got "%v"`, user.DefaultReadingSpeed)
}
if user.CJKReadingSpeed != 500 {
t.Fatalf(`Invalid cjk reading speed, got "%v"`, user.CJKReadingSpeed)
}
}
func TestGetUserByUsername(t *testing.T) {
@ -266,6 +291,14 @@ func TestGetUserByUsername(t *testing.T) {
if user.DisplayMode != "standalone" {
t.Fatalf(`Invalid web app display mode, got "%v"`, user.DisplayMode)
}
if user.DefaultReadingSpeed != 265 {
t.Fatalf(`Invalid default reading speed, got "%v"`, user.DefaultReadingSpeed)
}
if user.CJKReadingSpeed != 500 {
t.Fatalf(`Invalid cjk reading speed, got "%v"`, user.CJKReadingSpeed)
}
}
func TestUpdateUserTheme(t *testing.T) {
@ -299,11 +332,15 @@ func TestUpdateUserFields(t *testing.T) {
swipe := false
entriesPerPage := 5
displayMode := "fullscreen"
defaultReadingSpeed := 380
cjkReadingSpeed := 200
user, err = client.UpdateUser(user.ID, &miniflux.UserModificationRequest{
Stylesheet: &stylesheet,
EntrySwipe: &swipe,
EntriesPerPage: &entriesPerPage,
DisplayMode: &displayMode,
Stylesheet: &stylesheet,
EntrySwipe: &swipe,
EntriesPerPage: &entriesPerPage,
DisplayMode: &displayMode,
DefaultReadingSpeed: &defaultReadingSpeed,
CJKReadingSpeed: &cjkReadingSpeed,
})
if err != nil {
t.Fatal(err)
@ -324,6 +361,14 @@ func TestUpdateUserFields(t *testing.T) {
if user.DisplayMode != displayMode {
t.Fatalf(`Unable to update user DisplayMode: got %q instead of %q`, user.DisplayMode, displayMode)
}
if user.DefaultReadingSpeed != defaultReadingSpeed {
t.Fatalf(`Invalid default reading speed, got %v instead of %v`, user.DefaultReadingSpeed, defaultReadingSpeed)
}
if user.CJKReadingSpeed != cjkReadingSpeed {
t.Fatalf(`Invalid cjk reading speed, got %v instead of %v`, user.CJKReadingSpeed, cjkReadingSpeed)
}
}
func TestUpdateUserThemeWithInvalidValue(t *testing.T) {

View File

@ -34,6 +34,14 @@ func (h *handler) fetchContent(w http.ResponseWriter, r *http.Request) {
return
}
user, err := h.store.UserByID(entry.UserID)
if err != nil {
json.ServerError(w, r, err)
}
if user == nil {
json.NotFound(w, r)
}
feedBuilder := storage.NewFeedQueryBuilder(h.store, loggedUserID)
feedBuilder.WithFeedID(entry.FeedID)
feed, err := feedBuilder.GetFeed()
@ -47,12 +55,14 @@ func (h *handler) fetchContent(w http.ResponseWriter, r *http.Request) {
return
}
if err := processor.ProcessEntryWebPage(feed, entry); err != nil {
if err := processor.ProcessEntryWebPage(feed, entry, user); err != nil {
json.ServerError(w, r, err)
return
}
h.store.UpdateEntryContent(entry)
if err := h.store.UpdateEntryContent(entry); err != nil {
json.ServerError(w, r, err)
}
json.OK(w, r, map[string]string{"content": proxy.ImageProxyRewriter(h.router, entry.Content)})
}

View File

@ -48,6 +48,7 @@ func (h *handler) showEditFeedPage(w http.ResponseWriter, r *http.Request) {
RewriteRules: feed.RewriteRules,
BlocklistRules: feed.BlocklistRules,
KeeplistRules: feed.KeeplistRules,
UrlRewriteRules: feed.UrlRewriteRules,
Crawler: feed.Crawler,
UserAgent: feed.UserAgent,
Cookie: feed.Cookie,

View File

@ -58,12 +58,13 @@ func (h *handler) updateFeed(w http.ResponseWriter, r *http.Request) {
view.Set("defaultUserAgent", config.Opts.HTTPClientUserAgent())
feedModificationRequest := &model.FeedModificationRequest{
FeedURL: model.OptionalString(feedForm.FeedURL),
SiteURL: model.OptionalString(feedForm.SiteURL),
Title: model.OptionalString(feedForm.Title),
CategoryID: model.OptionalInt64(feedForm.CategoryID),
BlocklistRules: model.OptionalString(feedForm.BlocklistRules),
KeeplistRules: model.OptionalString(feedForm.KeeplistRules),
FeedURL: model.OptionalString(feedForm.FeedURL),
SiteURL: model.OptionalString(feedForm.SiteURL),
Title: model.OptionalString(feedForm.Title),
CategoryID: model.OptionalInt64(feedForm.CategoryID),
BlocklistRules: model.OptionalString(feedForm.BlocklistRules),
KeeplistRules: model.OptionalString(feedForm.KeeplistRules),
UrlRewriteRules: model.OptionalString(feedForm.UrlRewriteRules),
}
if validationErr := validator.ValidateFeedModification(h.store, loggedUser.ID, feedModificationRequest); validationErr != nil {

View File

@ -20,6 +20,7 @@ type FeedForm struct {
RewriteRules string
BlocklistRules string
KeeplistRules string
UrlRewriteRules string
Crawler bool
UserAgent string
Cookie string
@ -44,6 +45,7 @@ func (f FeedForm) Merge(feed *model.Feed) *model.Feed {
feed.RewriteRules = f.RewriteRules
feed.BlocklistRules = f.BlocklistRules
feed.KeeplistRules = f.KeeplistRules
feed.UrlRewriteRules = f.UrlRewriteRules
feed.Crawler = f.Crawler
feed.UserAgent = f.UserAgent
feed.Cookie = f.Cookie
@ -75,6 +77,7 @@ func NewFeedForm(r *http.Request) *FeedForm {
RewriteRules: r.FormValue("rewrite_rules"),
BlocklistRules: r.FormValue("blocklist_rules"),
KeeplistRules: r.FormValue("keeplist_rules"),
UrlRewriteRules: r.FormValue("urlrewrite_rules"),
Crawler: r.FormValue("crawler") == "1",
CategoryID: int64(categoryID),
Username: r.FormValue("feed_username"),

View File

@ -14,20 +14,22 @@ import (
// SettingsForm represents the settings form.
type SettingsForm struct {
Username string
Password string
Confirmation string
Theme string
Language string
Timezone string
EntryDirection string
EntryOrder string
EntriesPerPage int
KeyboardShortcuts bool
ShowReadingTime bool
CustomCSS string
EntrySwipe bool
DisplayMode string
Username string
Password string
Confirmation string
Theme string
Language string
Timezone string
EntryDirection string
EntryOrder string
EntriesPerPage int
KeyboardShortcuts bool
ShowReadingTime bool
CustomCSS string
EntrySwipe bool
DisplayMode string
DefaultReadingSpeed int
CJKReadingSpeed int
}
// Merge updates the fields of the given user.
@ -44,6 +46,8 @@ func (s *SettingsForm) Merge(user *model.User) *model.User {
user.Stylesheet = s.CustomCSS
user.EntrySwipe = s.EntrySwipe
user.DisplayMode = s.DisplayMode
user.CJKReadingSpeed = s.CJKReadingSpeed
user.DefaultReadingSpeed = s.DefaultReadingSpeed
if s.Password != "" {
user.Password = s.Password
@ -58,6 +62,10 @@ func (s *SettingsForm) Validate() error {
return errors.NewLocalizedError("error.settings_mandatory_fields")
}
if s.CJKReadingSpeed <= 0 || s.DefaultReadingSpeed <= 0 {
return errors.NewLocalizedError("error.settings_reading_speed_is_positive")
}
if s.Confirmation == "" {
// Firefox insists on auto-completing the password field.
// If the confirmation field is blank, the user probably
@ -78,20 +86,30 @@ func NewSettingsForm(r *http.Request) *SettingsForm {
if err != nil {
entriesPerPage = 0
}
defaultReadingSpeed, err := strconv.ParseInt(r.FormValue("default_reading_speed"), 10, 0)
if err != nil {
defaultReadingSpeed = 0
}
cjkReadingSpeed, err := strconv.ParseInt(r.FormValue("cjk_reading_speed"), 10, 0)
if err != nil {
cjkReadingSpeed = 0
}
return &SettingsForm{
Username: r.FormValue("username"),
Password: r.FormValue("password"),
Confirmation: r.FormValue("confirmation"),
Theme: r.FormValue("theme"),
Language: r.FormValue("language"),
Timezone: r.FormValue("timezone"),
EntryDirection: r.FormValue("entry_direction"),
EntryOrder: r.FormValue("entry_order"),
EntriesPerPage: int(entriesPerPage),
KeyboardShortcuts: r.FormValue("keyboard_shortcuts") == "1",
ShowReadingTime: r.FormValue("show_reading_time") == "1",
CustomCSS: r.FormValue("custom_css"),
EntrySwipe: r.FormValue("entry_swipe") == "1",
DisplayMode: r.FormValue("display_mode"),
Username: r.FormValue("username"),
Password: r.FormValue("password"),
Confirmation: r.FormValue("confirmation"),
Theme: r.FormValue("theme"),
Language: r.FormValue("language"),
Timezone: r.FormValue("timezone"),
EntryDirection: r.FormValue("entry_direction"),
EntryOrder: r.FormValue("entry_order"),
EntriesPerPage: int(entriesPerPage),
KeyboardShortcuts: r.FormValue("keyboard_shortcuts") == "1",
ShowReadingTime: r.FormValue("show_reading_time") == "1",
CustomCSS: r.FormValue("custom_css"),
EntrySwipe: r.FormValue("entry_swipe") == "1",
DisplayMode: r.FormValue("display_mode"),
DefaultReadingSpeed: int(defaultReadingSpeed),
CJKReadingSpeed: int(cjkReadingSpeed),
}
}

View File

@ -6,15 +6,17 @@ import (
func TestValid(t *testing.T) {
settings := &SettingsForm{
Username: "user",
Password: "hunter2",
Confirmation: "hunter2",
Theme: "default",
Language: "en_US",
Timezone: "UTC",
EntryDirection: "asc",
EntriesPerPage: 50,
DisplayMode: "standalone",
Username: "user",
Password: "hunter2",
Confirmation: "hunter2",
Theme: "default",
Language: "en_US",
Timezone: "UTC",
EntryDirection: "asc",
EntriesPerPage: 50,
DisplayMode: "standalone",
DefaultReadingSpeed: 35,
CJKReadingSpeed: 25,
}
err := settings.Validate()
@ -25,15 +27,17 @@ func TestValid(t *testing.T) {
func TestConfirmationEmpty(t *testing.T) {
settings := &SettingsForm{
Username: "user",
Password: "hunter2",
Confirmation: "",
Theme: "default",
Language: "en_US",
Timezone: "UTC",
EntryDirection: "asc",
EntriesPerPage: 50,
DisplayMode: "standalone",
Username: "user",
Password: "hunter2",
Confirmation: "",
Theme: "default",
Language: "en_US",
Timezone: "UTC",
EntryDirection: "asc",
EntriesPerPage: 50,
DisplayMode: "standalone",
DefaultReadingSpeed: 35,
CJKReadingSpeed: 25,
}
err := settings.Validate()
@ -48,15 +52,17 @@ func TestConfirmationEmpty(t *testing.T) {
func TestConfirmationIncorrect(t *testing.T) {
settings := &SettingsForm{
Username: "user",
Password: "hunter2",
Confirmation: "unter2",
Theme: "default",
Language: "en_US",
Timezone: "UTC",
EntryDirection: "asc",
EntriesPerPage: 50,
DisplayMode: "standalone",
Username: "user",
Password: "hunter2",
Confirmation: "unter2",
Theme: "default",
Language: "en_US",
Timezone: "UTC",
EntryDirection: "asc",
EntriesPerPage: 50,
DisplayMode: "standalone",
DefaultReadingSpeed: 35,
CJKReadingSpeed: 25,
}
err := settings.Validate()

View File

@ -27,6 +27,7 @@ type SubscriptionForm struct {
RewriteRules string
BlocklistRules string
KeeplistRules string
UrlRewriteRules string
}
// Validate makes sure the form values are valid.
@ -47,6 +48,10 @@ func (s *SubscriptionForm) Validate() error {
return errors.NewLocalizedError("error.feed_invalid_keeplist_rule")
}
if !validator.IsValidRegex(s.UrlRewriteRules) {
return errors.NewLocalizedError("error.feed_invalid_urlrewrite_rule")
}
return nil
}
@ -71,5 +76,6 @@ func NewSubscriptionForm(r *http.Request) *SubscriptionForm {
RewriteRules: r.FormValue("rewrite_rules"),
BlocklistRules: r.FormValue("blocklist_rules"),
KeeplistRules: r.FormValue("keeplist_rules"),
UrlRewriteRules: r.FormValue("urlrewrite_rules"),
}
}

View File

@ -27,18 +27,20 @@ func (h *handler) showSettingsPage(w http.ResponseWriter, r *http.Request) {
}
settingsForm := form.SettingsForm{
Username: user.Username,
Theme: user.Theme,
Language: user.Language,
Timezone: user.Timezone,
EntryDirection: user.EntryDirection,
EntryOrder: user.EntryOrder,
EntriesPerPage: user.EntriesPerPage,
KeyboardShortcuts: user.KeyboardShortcuts,
ShowReadingTime: user.ShowReadingTime,
CustomCSS: user.Stylesheet,
EntrySwipe: user.EntrySwipe,
DisplayMode: user.DisplayMode,
Username: user.Username,
Theme: user.Theme,
Language: user.Language,
Timezone: user.Timezone,
EntryDirection: user.EntryDirection,
EntryOrder: user.EntryOrder,
EntriesPerPage: user.EntriesPerPage,
KeyboardShortcuts: user.KeyboardShortcuts,
ShowReadingTime: user.ShowReadingTime,
CustomCSS: user.Stylesheet,
EntrySwipe: user.EntrySwipe,
DisplayMode: user.DisplayMode,
DefaultReadingSpeed: user.DefaultReadingSpeed,
CJKReadingSpeed: user.CJKReadingSpeed,
}
timezones, err := h.store.Timezones()

View File

@ -53,14 +53,16 @@ func (h *handler) updateSettings(w http.ResponseWriter, r *http.Request) {
}
userModificationRequest := &model.UserModificationRequest{
Username: model.OptionalString(settingsForm.Username),
Password: model.OptionalString(settingsForm.Password),
Theme: model.OptionalString(settingsForm.Theme),
Language: model.OptionalString(settingsForm.Language),
Timezone: model.OptionalString(settingsForm.Timezone),
EntryDirection: model.OptionalString(settingsForm.EntryDirection),
EntriesPerPage: model.OptionalInt(settingsForm.EntriesPerPage),
DisplayMode: model.OptionalString(settingsForm.DisplayMode),
Username: model.OptionalString(settingsForm.Username),
Password: model.OptionalString(settingsForm.Password),
Theme: model.OptionalString(settingsForm.Theme),
Language: model.OptionalString(settingsForm.Language),
Timezone: model.OptionalString(settingsForm.Timezone),
EntryDirection: model.OptionalString(settingsForm.EntryDirection),
EntriesPerPage: model.OptionalInt(settingsForm.EntriesPerPage),
DisplayMode: model.OptionalString(settingsForm.DisplayMode),
DefaultReadingSpeed: model.OptionalInt(settingsForm.DefaultReadingSpeed),
CJKReadingSpeed: model.OptionalInt(settingsForm.CJKReadingSpeed),
}
if validationErr := validator.ValidateUserModification(h.store, loggedUser.ID, userModificationRequest); validationErr != nil {

View File

@ -30,7 +30,7 @@ class RequestBuilder {
}
getCsrfToken() {
let element = document.querySelector("body[data-csrf-token");
let element = document.querySelector("body[data-csrf-token]");
if (element !== null) {
return element.dataset.csrfToken;
}

View File

@ -62,6 +62,7 @@ func (h *handler) showChooseSubscriptionPage(w http.ResponseWriter, r *http.Requ
RewriteRules: subscriptionForm.RewriteRules,
BlocklistRules: subscriptionForm.BlocklistRules,
KeeplistRules: subscriptionForm.KeeplistRules,
UrlRewriteRules: subscriptionForm.UrlRewriteRules,
FetchViaProxy: subscriptionForm.FetchViaProxy,
})
if err != nil {

View File

@ -91,6 +91,7 @@ func (h *handler) submitSubscription(w http.ResponseWriter, r *http.Request) {
RewriteRules: subscriptionForm.RewriteRules,
BlocklistRules: subscriptionForm.BlocklistRules,
KeeplistRules: subscriptionForm.KeeplistRules,
UrlRewriteRules: subscriptionForm.UrlRewriteRules,
FetchViaProxy: subscriptionForm.FetchViaProxy,
})
if err != nil {

View File

@ -32,9 +32,9 @@ func ValidateEntryStatus(status string) error {
// ValidateEntryOrder makes sure the sorting order is valid.
func ValidateEntryOrder(order string) error {
switch order {
case "id", "status", "changed_at", "published_at", "created_at", "category_title", "category_id":
case "id", "status", "changed_at", "published_at", "created_at", "category_title", "category_id", "title", "author":
return nil
}
return fmt.Errorf(`Invalid entry order, valid order values are: "id", "status", "changed_at", "published_at", "created_at", "category_title", "category_id"`)
return fmt.Errorf(`Invalid entry order, valid order values are: "id", "status", "changed_at", "published_at", "created_at", "category_title", "category_id", "title", "author"`)
}

View File

@ -79,6 +79,25 @@ func ValidateUserModification(store *storage.Storage, userID int64, changes *mod
}
}
if changes.DefaultReadingSpeed != nil {
if err := validateReadingSpeed(*changes.DefaultReadingSpeed); err != nil {
return err
}
}
if changes.CJKReadingSpeed != nil {
if err := validateReadingSpeed(*changes.CJKReadingSpeed); err != nil {
return err
}
}
return nil
}
func validateReadingSpeed(readingSpeed int) *ValidationError {
if readingSpeed <= 0 {
return NewValidationError("error.settings_reading_speed_is_positive")
}
return nil
}