Make reading speed user-configurable

This commit is contained in:
Gabriel Augendre 2021-08-30 16:53:05 +02:00 committed by Frédéric Guillot
parent 3a0aaddafd
commit 6e50ce3293
31 changed files with 395 additions and 173 deletions

View file

@ -193,6 +193,14 @@ func (h *handler) fetchContent(w http.ResponseWriter, r *http.Request) {
return 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 := storage.NewFeedQueryBuilder(h.store, loggedUserID)
feedBuilder.WithFeedID(entry.FeedID) feedBuilder.WithFeedID(entry.FeedID)
feed, err := feedBuilder.GetFeed() feed, err := feedBuilder.GetFeed()
@ -206,7 +214,7 @@ func (h *handler) fetchContent(w http.ResponseWriter, r *http.Request) {
return return
} }
if err := processor.ProcessEntryWebPage(feed, entry); err != nil { if err := processor.ProcessEntryWebPage(feed, entry, user); err != nil {
json.ServerError(w, r, err) json.ServerError(w, r, err)
return return
} }

View file

@ -36,6 +36,8 @@ type User struct {
EntrySwipe bool `json:"entry_swipe"` EntrySwipe bool `json:"entry_swipe"`
LastLoginAt *time.Time `json:"last_login_at"` LastLoginAt *time.Time `json:"last_login_at"`
DisplayMode string `json:"display_mode"` DisplayMode string `json:"display_mode"`
DefaultReadingSpeed int `json:"default_reading_speed"`
CJKReadingSpeed int `json:"cjk_reading_speed"`
} }
func (u User) String() string { func (u User) String() string {
@ -69,6 +71,8 @@ type UserModificationRequest struct {
ShowReadingTime *bool `json:"show_reading_time"` ShowReadingTime *bool `json:"show_reading_time"`
EntrySwipe *bool `json:"entry_swipe"` EntrySwipe *bool `json:"entry_swipe"`
DisplayMode *string `json:"display_mode"` DisplayMode *string `json:"display_mode"`
DefaultReadingSpeed *int `json:"default_reading_speed"`
CJKReadingSpeed *int `json:"cjk_reading_speed"`
} }
// Users represents a list of users. // Users represents a list of users.

View file

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

View file

@ -243,6 +243,7 @@
"error.different_passwords": "Passwörter stimmen nicht überein.", "error.different_passwords": "Passwörter stimmen nicht überein.",
"error.password_min_length": "Wenigstens 6 Zeichen müssen genutzt werden.", "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_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.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_mandatory_fields": "Die URL und die Kategorie sind obligatorisch.",
"error.feed_already_exists": "Dieser Feed existiert bereits.", "error.feed_already_exists": "Dieser Feed existiert bereits.",
@ -292,6 +293,8 @@
"form.prefs.label.theme": "Thema", "form.prefs.label.theme": "Thema",
"form.prefs.label.entry_sorting": "Sortierung der Artikel", "form.prefs.label.entry_sorting": "Sortierung der Artikel",
"form.prefs.label.entries_per_page": "Einträge pro Seite", "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.label.display_mode": "Anzeigemodus der Web-App (muss neu installiert werden)",
"form.prefs.select.older_first": "Älteste Artikel zuerst", "form.prefs.select.older_first": "Älteste Artikel zuerst",
"form.prefs.select.recent_first": "Neueste Artikel zuerst", "form.prefs.select.recent_first": "Neueste Artikel zuerst",

View file

@ -248,6 +248,7 @@
"error.different_passwords": "Οι κωδικοί πρόσβασης δεν είναι οι ίδιοι.", "error.different_passwords": "Οι κωδικοί πρόσβασης δεν είναι οι ίδιοι.",
"error.password_min_length": "Ο κωδικός πρόσβασης πρέπει να έχει τουλάχιστον 6 χαρακτήρες.", "error.password_min_length": "Ο κωδικός πρόσβασης πρέπει να έχει τουλάχιστον 6 χαρακτήρες.",
"error.settings_mandatory_fields": "Τα πεδία όνομα χρήστη, θέμα, Γλώσσα και ζώνη ώρας είναι υποχρεωτικά.", "error.settings_mandatory_fields": "Τα πεδία όνομα χρήστη, θέμα, Γλώσσα και ζώνη ώρας είναι υποχρεωτικά.",
"error.settings_reading_speed_is_positive": "Οι ταχύτητες ανάγνωσης πρέπει να είναι θετικοί ακέραιοι αριθμοί.",
"error.entries_per_page_invalid": "Ο αριθμός των καταχωρήσεων ανά σελίδα δεν είναι έγκυρος.", "error.entries_per_page_invalid": "Ο αριθμός των καταχωρήσεων ανά σελίδα δεν είναι έγκυρος.",
"error.feed_mandatory_fields": "Η διεύθυνση URL και η κατηγορία είναι υποχρεωτικά.", "error.feed_mandatory_fields": "Η διεύθυνση URL και η κατηγορία είναι υποχρεωτικά.",
"error.feed_already_exists": "Αυτή η ροή υπάρχει ήδη.", "error.feed_already_exists": "Αυτή η ροή υπάρχει ήδη.",
@ -292,6 +293,8 @@
"form.prefs.label.theme": "Θέμα", "form.prefs.label.theme": "Θέμα",
"form.prefs.label.entry_sorting": "Ταξινόμηση", "form.prefs.label.entry_sorting": "Ταξινόμηση",
"form.prefs.label.entries_per_page": "Καταχωρήσεις ανά σελίδα", "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.label.display_mode": "Λειτουργία προβολής εφαρμογών ιστού (χρειάζεται επανεγκατάσταση)",
"form.prefs.select.older_first": "Παλαιότερες καταχωρήσεις πρώτα", "form.prefs.select.older_first": "Παλαιότερες καταχωρήσεις πρώτα",
"form.prefs.select.recent_first": "Πρόσφατες καταχωρήσεις πρώτα", "form.prefs.select.recent_first": "Πρόσφατες καταχωρήσεις πρώτα",

View file

@ -248,6 +248,7 @@
"error.different_passwords": "Passwords are not the same.", "error.different_passwords": "Passwords are not the same.",
"error.password_min_length": "The password must have at least 6 characters.", "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_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.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_mandatory_fields": "The URL and the category are mandatory.",
"error.feed_already_exists": "This feed already exists.", "error.feed_already_exists": "This feed already exists.",
@ -292,6 +293,8 @@
"form.prefs.label.theme": "Theme", "form.prefs.label.theme": "Theme",
"form.prefs.label.entry_sorting": "Entry Sorting", "form.prefs.label.entry_sorting": "Entry Sorting",
"form.prefs.label.entries_per_page": "Entries per page", "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.label.display_mode": "Web app display mode (needs reinstalling)",
"form.prefs.select.older_first": "Older entries first", "form.prefs.select.older_first": "Older entries first",
"form.prefs.select.recent_first": "Recent entries first", "form.prefs.select.recent_first": "Recent entries first",

View file

@ -243,6 +243,7 @@
"error.different_passwords": "Las contraseñas no son las mismas.", "error.different_passwords": "Las contraseñas no son las mismas.",
"error.password_min_length": "La contraseña debería tener al menos 6 caracteres.", "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_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.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_mandatory_fields": "Los campos de URL y categoría son obligatorios.",
"error.feed_already_exists": "Este feed ya existe.", "error.feed_already_exists": "Este feed ya existe.",
@ -292,6 +293,8 @@
"form.prefs.label.theme": "Tema", "form.prefs.label.theme": "Tema",
"form.prefs.label.entry_sorting": "Clasificación de entradas", "form.prefs.label.entry_sorting": "Clasificación de entradas",
"form.prefs.label.entries_per_page": "Entradas por página", "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.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.older_first": "Entradas más viejas primero",
"form.prefs.select.recent_first": "Entradas recientes primero", "form.prefs.select.recent_first": "Entradas recientes primero",

View file

@ -248,6 +248,7 @@
"error.different_passwords": "Salasanat eivät ole samat.", "error.different_passwords": "Salasanat eivät ole samat.",
"error.password_min_length": "Salasanassa on oltava vähintään 6 merkkiä.", "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_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.entries_per_page_invalid": "Artikkelien määrä sivulla ei kelpaa.",
"error.feed_mandatory_fields": "URL-osoite ja kategoria ovat pakollisia.", "error.feed_mandatory_fields": "URL-osoite ja kategoria ovat pakollisia.",
"error.feed_already_exists": "Tämä syöte on jo olemassa.", "error.feed_already_exists": "Tämä syöte on jo olemassa.",
@ -292,6 +293,8 @@
"form.prefs.label.theme": "Teema", "form.prefs.label.theme": "Teema",
"form.prefs.label.entry_sorting": "Lajittelu", "form.prefs.label.entry_sorting": "Lajittelu",
"form.prefs.label.entries_per_page": "Artikkelia sivulla", "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.label.display_mode": "Verkkosovelluksen näyttötila (vaatii uudelleenasennuksen)",
"form.prefs.select.older_first": "Vanhin ensin", "form.prefs.select.older_first": "Vanhin ensin",
"form.prefs.select.recent_first": "Uusin ensin", "form.prefs.select.recent_first": "Uusin ensin",

View file

@ -243,6 +243,7 @@
"error.different_passwords": "Les mots de passe ne sont pas les mêmes.", "error.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.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_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.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_mandatory_fields": "L'URL et la catégorie sont obligatoire.",
"error.feed_already_exists": "Ce flux existe déjà.", "error.feed_already_exists": "Ce flux existe déjà.",
@ -292,6 +293,8 @@
"form.prefs.label.theme": "Thème", "form.prefs.label.theme": "Thème",
"form.prefs.label.entry_sorting": "Ordre des éléments", "form.prefs.label.entry_sorting": "Ordre des éléments",
"form.prefs.label.entries_per_page": "Entrées par page", "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.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.older_first": "Ancien éléments en premier",
"form.prefs.select.recent_first": "Éléments récents en premier", "form.prefs.select.recent_first": "Éléments récents en premier",

View file

@ -248,6 +248,7 @@
"error.different_passwords": "पासवर्ड एक जैसे नहीं हैं।", "error.different_passwords": "पासवर्ड एक जैसे नहीं हैं।",
"error.password_min_length": "पासवर्ड में कम से कम 6 अक्षर होने चाहिए।", "error.password_min_length": "पासवर्ड में कम से कम 6 अक्षर होने चाहिए।",
"error.settings_mandatory_fields": "उपयोगकर्ता नाम, विषयवस्तु, भाषा और समयक्षेत्र फ़ील्ड अनिवार्य हैं।", "error.settings_mandatory_fields": "उपयोगकर्ता नाम, विषयवस्तु, भाषा और समयक्षेत्र फ़ील्ड अनिवार्य हैं।",
"error.settings_reading_speed_is_positive": "पढ़ने की गति सकारात्मक पूर्णांक होनी चाहिए।",
"error.entries_per_page_invalid": "प्रति पृष्ठ प्रविष्टियों की संख्या मान्य नहीं है।", "error.entries_per_page_invalid": "प्रति पृष्ठ प्रविष्टियों की संख्या मान्य नहीं है।",
"error.feed_mandatory_fields": "URL और श्रेणी अनिवार्य हैं।", "error.feed_mandatory_fields": "URL और श्रेणी अनिवार्य हैं।",
"error.feed_already_exists": "यह फ़ीड पहले से मौजूद है.", "error.feed_already_exists": "यह फ़ीड पहले से मौजूद है.",
@ -292,6 +293,8 @@
"form.prefs.label.theme": "थीम", "form.prefs.label.theme": "थीम",
"form.prefs.label.entry_sorting": "प्रवेश छँटाई", "form.prefs.label.entry_sorting": "प्रवेश छँटाई",
"form.prefs.label.entries_per_page": "प्रति पृष्ठ प्रविष्टियाँ", "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.label.display_mode": "वेब ऐप डिस्प्ले मोड (पुनः स्थापित करने की आवश्यकता है)",
"form.prefs.select.older_first": "पहले पुरानी प्रविष्टियाँ", "form.prefs.select.older_first": "पहले पुरानी प्रविष्टियाँ",
"form.prefs.select.recent_first": "हाल की प्रविष्टियाँ पहले", "form.prefs.select.recent_first": "हाल की प्रविष्टियाँ पहले",

View file

@ -243,6 +243,7 @@
"error.different_passwords": "Le password non coincidono.", "error.different_passwords": "Le password non coincidono.",
"error.password_min_length": "La password deve contenere almeno 6 caratteri.", "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_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.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_mandatory_fields": "L'URL e la categoria sono obbligatori.",
"error.feed_already_exists": "Questo feed esiste già.", "error.feed_already_exists": "Questo feed esiste già.",
@ -292,6 +293,8 @@
"form.prefs.label.theme": "Tema", "form.prefs.label.theme": "Tema",
"form.prefs.label.entry_sorting": "Ordinamento articoli", "form.prefs.label.entry_sorting": "Ordinamento articoli",
"form.prefs.label.entries_per_page": "Articoli per pagina", "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.label.display_mode": "Modalità di visualizzazione web app (necessita la reinstallazione)",
"form.prefs.select.older_first": "Prima i più vecchi", "form.prefs.select.older_first": "Prima i più vecchi",
"form.prefs.select.recent_first": "Prima i più recenti", "form.prefs.select.recent_first": "Prima i più recenti",

View file

@ -243,6 +243,7 @@
"error.different_passwords": "パスワードが一致しません。", "error.different_passwords": "パスワードが一致しません。",
"error.password_min_length": "パスワードは6文字以上である必要があります。", "error.password_min_length": "パスワードは6文字以上である必要があります。",
"error.settings_mandatory_fields": "ユーザー名、テーマ、言語、タイムゾーンの全てが必要です。", "error.settings_mandatory_fields": "ユーザー名、テーマ、言語、タイムゾーンの全てが必要です。",
"error.settings_reading_speed_is_positive": "読み取り速度は正の整数でなければならない。",
"error.entries_per_page_invalid": "ページあたりのエントリ数が無効です。", "error.entries_per_page_invalid": "ページあたりのエントリ数が無効です。",
"error.feed_mandatory_fields": "URL と カテゴリが必要です。", "error.feed_mandatory_fields": "URL と カテゴリが必要です。",
"error.feed_already_exists": "このフィードはすでに存在します。", "error.feed_already_exists": "このフィードはすでに存在します。",
@ -292,6 +293,8 @@
"form.prefs.label.theme": "テーマ", "form.prefs.label.theme": "テーマ",
"form.prefs.label.entry_sorting": "記事の並べ替え", "form.prefs.label.entry_sorting": "記事の並べ替え",
"form.prefs.label.entries_per_page": "ページあたりのエントリ", "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.label.display_mode": "Webアプリの表示モード (再インストールが必要)",
"form.prefs.select.older_first": "古い記事を最初に", "form.prefs.select.older_first": "古い記事を最初に",
"form.prefs.select.recent_first": "新しい記事を最初に", "form.prefs.select.recent_first": "新しい記事を最初に",

View file

@ -243,6 +243,7 @@
"error.different_passwords": "Wachtwoorden zijn niet hetzelfde.", "error.different_passwords": "Wachtwoorden zijn niet hetzelfde.",
"error.password_min_length": "Je moet minstens 6 tekens gebruiken.", "error.password_min_length": "Je moet minstens 6 tekens gebruiken.",
"error.settings_mandatory_fields": "Gebruikersnaam, skin, taal en tijdzone zijn verplicht.", "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.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_mandatory_fields": "The URL en de categorie zijn verplicht.",
"error.feed_already_exists": "Deze feed bestaat al.", "error.feed_already_exists": "Deze feed bestaat al.",
@ -292,6 +293,8 @@
"form.prefs.label.theme": "Skin", "form.prefs.label.theme": "Skin",
"form.prefs.label.entry_sorting": "Volgorde van items", "form.prefs.label.entry_sorting": "Volgorde van items",
"form.prefs.label.entries_per_page": "Inzendingen per pagina", "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.label.display_mode": "Weergavemodus voor webapp (moet opnieuw worden geïnstalleerd)",
"form.prefs.select.older_first": "Oudere items eerst", "form.prefs.select.older_first": "Oudere items eerst",
"form.prefs.select.recent_first": "Recente items eerst", "form.prefs.select.recent_first": "Recente items eerst",

View file

@ -245,6 +245,7 @@
"error.different_passwords": "Hasła nie są identyczne.", "error.different_passwords": "Hasła nie są identyczne.",
"error.password_min_length": "Musisz użyć co najmniej 6 znaków.", "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_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.entries_per_page_invalid": "Liczba wpisów na stronę jest nieprawidłowa.",
"error.feed_mandatory_fields": "URL i kategoria są obowiązkowe.", "error.feed_mandatory_fields": "URL i kategoria są obowiązkowe.",
"error.feed_already_exists": "Ten kanał już istnieje.", "error.feed_already_exists": "Ten kanał już istnieje.",
@ -294,6 +295,8 @@
"form.prefs.label.theme": "Wygląd", "form.prefs.label.theme": "Wygląd",
"form.prefs.label.entry_sorting": "Sortowanie artykułów", "form.prefs.label.entry_sorting": "Sortowanie artykułów",
"form.prefs.label.entries_per_page": "Wpisy na stronie", "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.label.display_mode": "Tryb wyświetlania aplikacji internetowej (wymaga ponownej instalacji)",
"form.prefs.select.older_first": "Najstarsze wpisy jako pierwsze", "form.prefs.select.older_first": "Najstarsze wpisy jako pierwsze",
"form.prefs.label.keyboard_shortcuts": "Włącz skróty klawiaturowe", "form.prefs.label.keyboard_shortcuts": "Włącz skróty klawiaturowe",

View file

@ -243,6 +243,7 @@
"error.different_passwords": "As senhas não são iguais.", "error.different_passwords": "As senhas não são iguais.",
"error.password_min_length": "A senha deve ter no mínimo 6 caracteres.", "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_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.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_mandatory_fields": "O campo de URL e categoria são obrigatórios.",
"error.feed_already_exists": "Este feed já existe.", "error.feed_already_exists": "Este feed já existe.",
@ -292,6 +293,8 @@
"form.prefs.label.theme": "Tema", "form.prefs.label.theme": "Tema",
"form.prefs.label.entry_sorting": "Ordenação dos itens", "form.prefs.label.entry_sorting": "Ordenação dos itens",
"form.prefs.label.entries_per_page": "Itens por página", "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.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.older_first": "Itens mais velhos primeiro",
"form.prefs.select.recent_first": "Itens mais recentes", "form.prefs.select.recent_first": "Itens mais recentes",

View file

@ -245,6 +245,7 @@
"error.different_passwords": "Пароли не совпадают.", "error.different_passwords": "Пароли не совпадают.",
"error.password_min_length": "Вы должны использовать минимум 6 символов.", "error.password_min_length": "Вы должны использовать минимум 6 символов.",
"error.settings_mandatory_fields": "Имя пользователя, тема, язык и часовой пояс обязательны.", "error.settings_mandatory_fields": "Имя пользователя, тема, язык и часовой пояс обязательны.",
"error.settings_reading_speed_is_positive": "Скорости считывания должны быть целыми положительными числами.",
"error.entries_per_page_invalid": "Количество записей на странице недействительно.", "error.entries_per_page_invalid": "Количество записей на странице недействительно.",
"error.feed_mandatory_fields": "URL и категория обязательны.", "error.feed_mandatory_fields": "URL и категория обязательны.",
"error.feed_already_exists": "Этот фид уже существует.", "error.feed_already_exists": "Этот фид уже существует.",
@ -294,6 +295,8 @@
"form.prefs.label.theme": "Тема", "form.prefs.label.theme": "Тема",
"form.prefs.label.entry_sorting": "Сортировка записей", "form.prefs.label.entry_sorting": "Сортировка записей",
"form.prefs.label.entries_per_page": "Записи на странице", "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.label.display_mode": "Режим отображения веб-приложения (требуется переустановка)",
"form.prefs.select.older_first": "Сначала старые записи", "form.prefs.select.older_first": "Сначала старые записи",
"form.prefs.select.recent_first": "Сначала последние записи", "form.prefs.select.recent_first": "Сначала последние записи",

View file

@ -248,6 +248,7 @@
"error.different_passwords": "Parolalar eşleşmiyor.", "error.different_passwords": "Parolalar eşleşmiyor.",
"error.password_min_length": "Parola en az 6 karakter içermeli.", "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_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.entries_per_page_invalid": "Sayfa başına ileti sayısı geçersiz.",
"error.feed_mandatory_fields": "URL ve kategori zorunlu.", "error.feed_mandatory_fields": "URL ve kategori zorunlu.",
"error.feed_already_exists": "Bu besleme zaten mevcut.", "error.feed_already_exists": "Bu besleme zaten mevcut.",
@ -292,6 +293,8 @@
"form.prefs.label.theme": "Tema", "form.prefs.label.theme": "Tema",
"form.prefs.label.entry_sorting": "İleti Sıralaması", "form.prefs.label.entry_sorting": "İleti Sıralaması",
"form.prefs.label.entries_per_page": "Sayfa başına ileti", "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.label.display_mode": "Web uygulaması görüntüleme modu (yeniden kurulum gerektirir)",
"form.prefs.select.older_first": "Önce eski iletiler", "form.prefs.select.older_first": "Önce eski iletiler",
"form.prefs.select.recent_first": "Önce yeni iletiler", "form.prefs.select.recent_first": "Önce yeni iletiler",

View file

@ -249,6 +249,7 @@
"error.feed_url_not_empty": "订阅源的网址不能为空。", "error.feed_url_not_empty": "订阅源的网址不能为空。",
"error.site_url_not_empty": "源网站的网址不能为空。", "error.site_url_not_empty": "源网站的网址不能为空。",
"error.feed_title_not_empty": "订阅源的标题不能为空。", "error.feed_title_not_empty": "订阅源的标题不能为空。",
"error.settings_reading_speed_is_positive": "阅读速度必须是正整数。",
"error.feed_category_not_found": "此类别不存在或不属于该用户。", "error.feed_category_not_found": "此类别不存在或不属于该用户。",
"error.feed_invalid_blocklist_rule": "阻止列表规则无效。", "error.feed_invalid_blocklist_rule": "阻止列表规则无效。",
"error.feed_invalid_keeplist_rule": "保留列表规则无效。", "error.feed_invalid_keeplist_rule": "保留列表规则无效。",
@ -291,6 +292,8 @@
"form.prefs.label.entry_sorting": "文章排序", "form.prefs.label.entry_sorting": "文章排序",
"form.prefs.label.entries_per_page": "每页文章数", "form.prefs.label.entries_per_page": "每页文章数",
"form.prefs.label.display_mode": "渐进式网页应用显示模式(需要重新添加)", "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.older_first": "旧->新",
"form.prefs.select.recent_first": "新->旧", "form.prefs.select.recent_first": "新->旧",
"form.prefs.select.fullscreen": "全屏", "form.prefs.select.fullscreen": "全屏",

View file

@ -243,6 +243,7 @@
"error.different_passwords": "兩次輸入的密碼不同", "error.different_passwords": "兩次輸入的密碼不同",
"error.password_min_length": "請至少輸入 6 個字元", "error.password_min_length": "請至少輸入 6 個字元",
"error.settings_mandatory_fields": "必須填寫使用者名稱、主題、語言以及時區", "error.settings_mandatory_fields": "必須填寫使用者名稱、主題、語言以及時區",
"error.settings_reading_speed_is_positive": "The reading speeds must be positive integers.",
"error.entries_per_page_invalid": "每頁的文章數無效。", "error.entries_per_page_invalid": "每頁的文章數無效。",
"error.feed_mandatory_fields": "必須填寫網址和分類", "error.feed_mandatory_fields": "必須填寫網址和分類",
"error.feed_already_exists": "此Feed已存在。", "error.feed_already_exists": "此Feed已存在。",
@ -292,6 +293,8 @@
"form.prefs.label.theme": "主題", "form.prefs.label.theme": "主題",
"form.prefs.label.entry_sorting": "文章排序", "form.prefs.label.entry_sorting": "文章排序",
"form.prefs.label.entries_per_page": "每頁文章數", "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.label.display_mode": "漸進式網頁應用顯示模式(需要重新新增)",
"form.prefs.select.older_first": "舊->新", "form.prefs.select.older_first": "舊->新",
"form.prefs.select.recent_first": "新->舊", "form.prefs.select.recent_first": "新->舊",

View file

@ -30,6 +30,8 @@ type User struct {
EntrySwipe bool `json:"entry_swipe"` EntrySwipe bool `json:"entry_swipe"`
LastLoginAt *time.Time `json:"last_login_at"` LastLoginAt *time.Time `json:"last_login_at"`
DisplayMode string `json:"display_mode"` 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. // UserCreationRequest represents the request to create a user.
@ -59,6 +61,8 @@ type UserModificationRequest struct {
ShowReadingTime *bool `json:"show_reading_time"` ShowReadingTime *bool `json:"show_reading_time"`
EntrySwipe *bool `json:"entry_swipe"` EntrySwipe *bool `json:"entry_swipe"`
DisplayMode *string `json:"display_mode"` 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. // Patch updates the User object with the modification request.
@ -126,6 +130,14 @@ func (u *UserModificationRequest) Patch(user *User) {
if u.DisplayMode != nil { if u.DisplayMode != nil {
user.DisplayMode = *u.DisplayMode 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. // UseTimezone converts last login date to the given timezone.

View file

@ -32,6 +32,11 @@ var (
func CreateFeed(store *storage.Storage, userID int64, feedCreationRequest *model.FeedCreationRequest) (*model.Feed, error) { 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)) 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) { if !store.CategoryIDExists(userID, feedCreationRequest.CategoryID) {
return nil, errors.NewLocalizedError(errCategoryNotFound) return nil, errors.NewLocalizedError(errCategoryNotFound)
} }
@ -79,7 +84,7 @@ func CreateFeed(store *storage.Storage, userID int64, feedCreationRequest *model
subscription.WithClientResponse(response) subscription.WithClientResponse(response)
subscription.CheckedNow() subscription.CheckedNow()
processor.ProcessFeedEntries(store, subscription) processor.ProcessFeedEntries(store, subscription, user)
if storeErr := store.CreateFeed(subscription); storeErr != nil { if storeErr := store.CreateFeed(subscription); storeErr != nil {
return nil, storeErr return nil, storeErr
@ -101,8 +106,12 @@ func CreateFeed(store *storage.Storage, userID int64, feedCreationRequest *model
// RefreshFeed refreshes a feed. // RefreshFeed refreshes a feed.
func RefreshFeed(store *storage.Storage, userID, feedID int64) error { func RefreshFeed(store *storage.Storage, userID, feedID int64) error {
defer timer.ExecutionTime(time.Now(), fmt.Sprintf("[RefreshFeed] feedID=%d", feedID)) defer timer.ExecutionTime(time.Now(), fmt.Sprintf("[RefreshFeed] feedID=%d", feedID))
userLanguage := store.UserLanguage(userID) user, storeErr := store.UserByID(userID)
printer := locale.NewPrinter(userLanguage) if storeErr != nil {
return storeErr
}
printer := locale.NewPrinter(user.Language)
originalFeed, storeErr := store.FeedByID(userID, feedID) originalFeed, storeErr := store.FeedByID(userID, feedID)
if storeErr != nil { if storeErr != nil {
@ -164,7 +173,7 @@ func RefreshFeed(store *storage.Storage, userID, feedID int64) error {
} }
originalFeed.Entries = updatedFeed.Entries 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). // 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 { if storeErr := store.RefreshFeedEntries(originalFeed.UserID, originalFeed.ID, originalFeed.Entries, !originalFeed.Crawler); storeErr != nil {

View file

@ -38,7 +38,7 @@ var (
) )
// ProcessFeedEntries downloads original web page for entries and apply filters. // 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 var filteredEntries model.Entries
for _, entry := range feed.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) 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. // 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() startTime := time.Now()
url := getUrlFromEntry(feed, entry) url := getUrlFromEntry(feed, entry)
@ -157,7 +157,7 @@ func ProcessEntryWebPage(feed *model.Feed, entry *model.Entry) error {
if content != "" { if content != "" {
entry.Content = content entry.Content = content
entry.ReadingTime = calculateReadingTime(content) entry.ReadingTime = calculateReadingTime(content, user)
} }
return nil return nil
@ -179,7 +179,7 @@ func getUrlFromEntry(feed *model.Feed, entry *model.Entry) string {
return url 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 shouldFetchYouTubeWatchTime(entry) {
if entryIsNew { if entryIsNew {
watchTime, err := fetchYouTubeWatchTime(entry.URL) 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. // Handle YT error case and non-YT entries.
if entry.ReadingTime == 0 { 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 return d, nil
} }
func calculateReadingTime(content string) int { func calculateReadingTime(content string, user *model.User) int {
sanitizedContent := sanitizer.StripTags(content) sanitizedContent := sanitizer.StripTags(content)
languageInfo := getlang.FromString(sanitizedContent) languageInfo := getlang.FromString(sanitizedContent)
var timeToReadInt int var timeToReadInt int
if languageInfo.LanguageCode() == "ko" || languageInfo.LanguageCode() == "zh" || languageInfo.LanguageCode() == "jp" { 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 { } else {
nbOfWords := len(strings.Fields(sanitizedContent)) nbOfWords := len(strings.Fields(sanitizedContent))
timeToReadInt = int(math.Ceil(float64(nbOfWords) / 265)) timeToReadInt = int(math.Ceil(float64(nbOfWords) / float64(user.DefaultReadingSpeed)))
} }
return timeToReadInt return timeToReadInt

View file

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

View file

@ -72,6 +72,12 @@
<label><input type="checkbox" name="entry_swipe" value="1" {{ if .form.EntrySwipe }}checked{{ end }}> {{ t "form.prefs.label.entry_swipe" }}</label> <label><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> <label>{{t "form.prefs.label.custom_css" }}</label><textarea name="custom_css" cols="40" rows="8" spellcheck="false">{{ .form.CustomCSS }}</textarea>
<div class="buttons"> <div class="buttons">
<button type="submit" class="button button-primary" data-label-loading="{{ t "form.submit.saving" }}">{{ t "action.update" }}</button> <button type="submit" class="button button-primary" data-label-loading="{{ t "form.submit.saving" }}">{{ t "action.update" }}</button>

View file

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

View file

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

View file

@ -28,6 +28,8 @@ type SettingsForm struct {
CustomCSS string CustomCSS string
EntrySwipe bool EntrySwipe bool
DisplayMode string DisplayMode string
DefaultReadingSpeed int
CJKReadingSpeed int
} }
// Merge updates the fields of the given user. // 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.Stylesheet = s.CustomCSS
user.EntrySwipe = s.EntrySwipe user.EntrySwipe = s.EntrySwipe
user.DisplayMode = s.DisplayMode user.DisplayMode = s.DisplayMode
user.CJKReadingSpeed = s.CJKReadingSpeed
user.DefaultReadingSpeed = s.DefaultReadingSpeed
if s.Password != "" { if s.Password != "" {
user.Password = s.Password user.Password = s.Password
@ -58,6 +62,10 @@ func (s *SettingsForm) Validate() error {
return errors.NewLocalizedError("error.settings_mandatory_fields") 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 == "" { if s.Confirmation == "" {
// Firefox insists on auto-completing the password field. // Firefox insists on auto-completing the password field.
// If the confirmation field is blank, the user probably // If the confirmation field is blank, the user probably
@ -78,6 +86,14 @@ func NewSettingsForm(r *http.Request) *SettingsForm {
if err != nil { if err != nil {
entriesPerPage = 0 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{ return &SettingsForm{
Username: r.FormValue("username"), Username: r.FormValue("username"),
Password: r.FormValue("password"), Password: r.FormValue("password"),
@ -93,5 +109,7 @@ func NewSettingsForm(r *http.Request) *SettingsForm {
CustomCSS: r.FormValue("custom_css"), CustomCSS: r.FormValue("custom_css"),
EntrySwipe: r.FormValue("entry_swipe") == "1", EntrySwipe: r.FormValue("entry_swipe") == "1",
DisplayMode: r.FormValue("display_mode"), DisplayMode: r.FormValue("display_mode"),
DefaultReadingSpeed: int(defaultReadingSpeed),
CJKReadingSpeed: int(cjkReadingSpeed),
} }
} }

View file

@ -15,6 +15,8 @@ func TestValid(t *testing.T) {
EntryDirection: "asc", EntryDirection: "asc",
EntriesPerPage: 50, EntriesPerPage: 50,
DisplayMode: "standalone", DisplayMode: "standalone",
DefaultReadingSpeed: 35,
CJKReadingSpeed: 25,
} }
err := settings.Validate() err := settings.Validate()
@ -34,6 +36,8 @@ func TestConfirmationEmpty(t *testing.T) {
EntryDirection: "asc", EntryDirection: "asc",
EntriesPerPage: 50, EntriesPerPage: 50,
DisplayMode: "standalone", DisplayMode: "standalone",
DefaultReadingSpeed: 35,
CJKReadingSpeed: 25,
} }
err := settings.Validate() err := settings.Validate()
@ -57,6 +61,8 @@ func TestConfirmationIncorrect(t *testing.T) {
EntryDirection: "asc", EntryDirection: "asc",
EntriesPerPage: 50, EntriesPerPage: 50,
DisplayMode: "standalone", DisplayMode: "standalone",
DefaultReadingSpeed: 35,
CJKReadingSpeed: 25,
} }
err := settings.Validate() err := settings.Validate()

View file

@ -39,6 +39,8 @@ func (h *handler) showSettingsPage(w http.ResponseWriter, r *http.Request) {
CustomCSS: user.Stylesheet, CustomCSS: user.Stylesheet,
EntrySwipe: user.EntrySwipe, EntrySwipe: user.EntrySwipe,
DisplayMode: user.DisplayMode, DisplayMode: user.DisplayMode,
DefaultReadingSpeed: user.DefaultReadingSpeed,
CJKReadingSpeed: user.CJKReadingSpeed,
} }
timezones, err := h.store.Timezones() timezones, err := h.store.Timezones()

View file

@ -61,6 +61,8 @@ func (h *handler) updateSettings(w http.ResponseWriter, r *http.Request) {
EntryDirection: model.OptionalString(settingsForm.EntryDirection), EntryDirection: model.OptionalString(settingsForm.EntryDirection),
EntriesPerPage: model.OptionalInt(settingsForm.EntriesPerPage), EntriesPerPage: model.OptionalInt(settingsForm.EntriesPerPage),
DisplayMode: model.OptionalString(settingsForm.DisplayMode), 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 { if validationErr := validator.ValidateUserModification(h.store, loggedUser.ID, userModificationRequest); validationErr != nil {

View file

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