Bläddra i källkod

Make reading speed user-configurable

Gabriel Augendre 4 år sedan
förälder
incheckning
6e50ce3293

+ 9 - 1
api/entry.go

@@ -193,6 +193,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()
@@ -206,7 +214,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
 	}

+ 38 - 34
client/model.go

@@ -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.

+ 7 - 0
database/migrations.go

@@ -597,4 +597,11 @@ var migrations = []func(tx *sql.Tx) error{
 		`)
 		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
+	},
 }

+ 3 - 0
locale/translations/de_DE.json

@@ -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.",
@@ -292,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",

+ 3 - 0
locale/translations/el_EL.json

@@ -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": "Αυτή η ροή υπάρχει ήδη.",
@@ -292,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": "Πρόσφατες καταχωρήσεις πρώτα",

+ 3 - 0
locale/translations/en_US.json

@@ -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.",
@@ -292,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",

+ 3 - 0
locale/translations/es_ES.json

@@ -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.",
@@ -292,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",

+ 3 - 0
locale/translations/fi_FI.json

@@ -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.",
@@ -292,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",

+ 3 - 0
locale/translations/fr_FR.json

@@ -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à.",
@@ -292,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",

+ 3 - 0
locale/translations/hi_IN.json

@@ -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": "यह फ़ीड पहले से मौजूद है.",
@@ -292,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": "हाल की प्रविष्टियाँ पहले",

+ 3 - 0
locale/translations/it_IT.json

@@ -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à.",
@@ -292,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",

+ 3 - 0
locale/translations/ja_JP.json

@@ -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": "このフィードはすでに存在します。",
@@ -292,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": "新しい記事を最初に",

+ 3 - 0
locale/translations/nl_NL.json

@@ -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.",
@@ -292,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",

+ 3 - 0
locale/translations/pl_PL.json

@@ -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.",
@@ -294,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",

+ 3 - 0
locale/translations/pt_BR.json

@@ -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.",
@@ -292,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",

+ 3 - 0
locale/translations/ru_RU.json

@@ -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": "Этот фид уже существует.",
@@ -294,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": "Сначала последние записи",

+ 3 - 0
locale/translations/tr_TR.json

@@ -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.",
@@ -292,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",

+ 3 - 0
locale/translations/zh_CN.json

@@ -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": "保留列表规则无效。",
@@ -291,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": "全屏",

+ 3 - 0
locale/translations/zh_TW.json

@@ -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已存在。",
@@ -292,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": "新->舊",

+ 46 - 34
model/user.go

@@ -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.

+ 13 - 4
reader/handler/handler.go

@@ -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)
 	}
@@ -79,7 +84,7 @@ func CreateFeed(store *storage.Storage, userID int64, feedCreationRequest *model
 	subscription.WithClientResponse(response)
 	subscription.CheckedNow()
 
-	processor.ProcessFeedEntries(store, subscription)
+	processor.ProcessFeedEntries(store, subscription, user)
 
 	if storeErr := store.CreateFeed(subscription); storeErr != nil {
 		return nil, storeErr
@@ -101,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 {
@@ -164,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 {

+ 9 - 9
reader/processor/processor.go

@@ -38,7 +38,7 @@ var (
 )
 
 // 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 {
@@ -96,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)
 	}
 
@@ -127,7 +127,7 @@ 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)
 
@@ -157,7 +157,7 @@ func ProcessEntryWebPage(feed *model.Feed, entry *model.Entry) error {
 
 	if content != "" {
 		entry.Content = content
-		entry.ReadingTime = calculateReadingTime(content)
+		entry.ReadingTime = calculateReadingTime(content, user)
 	}
 
 	return nil
@@ -179,7 +179,7 @@ func getUrlFromEntry(feed *model.Feed, entry *model.Entry) string {
 	return url
 }
 
-func updateEntryReadingTime(store *storage.Storage, feed *model.Feed, entry *model.Entry, entryIsNew bool) {
+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)
@@ -194,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)
 	}
 }
 
@@ -269,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

+ 36 - 10
storage/user.go

@@ -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 {

+ 6 - 0
template/templates/views/settings.html

@@ -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>

+ 49 - 4
tests/user_test.go

@@ -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) {

+ 12 - 2
ui/entry_scraper.go

@@ -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)})
 }

+ 46 - 28
ui/form/settings.go

@@ -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),
 	}
 }

+ 33 - 27
ui/form/settings_test.go

@@ -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()

+ 14 - 12
ui/settings_show.go

@@ -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()

+ 10 - 8
ui/settings_update.go

@@ -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 {

+ 19 - 0
validator/user.go

@@ -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
 }