mirror of https://github.com/miniflux/v2.git
Merge remote-tracking branch 'upstream/master' into patch-1
This commit is contained in:
commit
e2ee4606ac
|
@ -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
|
||||
|
|
13
api/entry.go
13
api/entry.go
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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`
|
||||
|
|
@ -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/'
|
||||
|
|
@ -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
|
|
@ -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
2
go.mod
|
@ -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
17
go.sum
|
@ -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=
|
||||
|
|
|
@ -22,5 +22,6 @@ func AvailableLanguages() map[string]string {
|
|||
"tr_TR": "Türkçe",
|
||||
"el_EL": "Ελληνικά",
|
||||
"fi_FI": "Suomi",
|
||||
"hi_IN": "हिन्दी",
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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": "Πρόσφατες καταχωρήσεις πρώτα",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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 वर्षों पहले"
|
||||
]
|
||||
}
|
|
@ -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",
|
||||
|
|
|
@ -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": "新しい記事を最初に",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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": "Сначала последние записи",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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": "全屏",
|
||||
|
|
|
@ -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": "新->舊",
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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 ""
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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`)
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
)
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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 }}
|
||||
|
|
|
@ -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 }}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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)})
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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"),
|
||||
|
|
|
@ -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),
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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"),
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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"`)
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
Loading…
Reference in New Issue