diff --git a/api/entry.go b/api/entry.go index 275a775a..ea944589 100644 --- a/api/entry.go +++ b/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 } diff --git a/client/model.go b/client/model.go index 660b2829..c54f1113 100644 --- a/client/model.go +++ b/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. diff --git a/database/migrations.go b/database/migrations.go index 7e68682f..093be271 100644 --- a/database/migrations.go +++ b/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 + }, } diff --git a/locale/translations/de_DE.json b/locale/translations/de_DE.json index 3b7afb13..f4299068 100644 --- a/locale/translations/de_DE.json +++ b/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", diff --git a/locale/translations/el_EL.json b/locale/translations/el_EL.json index f17a2f5b..8525ad29 100644 --- a/locale/translations/el_EL.json +++ b/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": "Πρόσφατες καταχωρήσεις πρώτα", diff --git a/locale/translations/en_US.json b/locale/translations/en_US.json index d6aacce4..85623f9e 100644 --- a/locale/translations/en_US.json +++ b/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", diff --git a/locale/translations/es_ES.json b/locale/translations/es_ES.json index 38a13444..1cac1de8 100644 --- a/locale/translations/es_ES.json +++ b/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", diff --git a/locale/translations/fi_FI.json b/locale/translations/fi_FI.json index 89d74d14..f5ab63cb 100644 --- a/locale/translations/fi_FI.json +++ b/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", diff --git a/locale/translations/fr_FR.json b/locale/translations/fr_FR.json index 505f9e8e..679ef80d 100644 --- a/locale/translations/fr_FR.json +++ b/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", diff --git a/locale/translations/hi_IN.json b/locale/translations/hi_IN.json index c9695863..8c889079 100644 --- a/locale/translations/hi_IN.json +++ b/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": "हाल की प्रविष्टियाँ पहले", diff --git a/locale/translations/it_IT.json b/locale/translations/it_IT.json index e46249c4..fe8476b8 100644 --- a/locale/translations/it_IT.json +++ b/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", diff --git a/locale/translations/ja_JP.json b/locale/translations/ja_JP.json index 1535c3ee..8b9d53c8 100644 --- a/locale/translations/ja_JP.json +++ b/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": "新しい記事を最初に", diff --git a/locale/translations/nl_NL.json b/locale/translations/nl_NL.json index 291cf85d..79adda7c 100644 --- a/locale/translations/nl_NL.json +++ b/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", diff --git a/locale/translations/pl_PL.json b/locale/translations/pl_PL.json index 28abe75b..58fa35fc 100644 --- a/locale/translations/pl_PL.json +++ b/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", diff --git a/locale/translations/pt_BR.json b/locale/translations/pt_BR.json index 15e338fd..73cb3ba9 100644 --- a/locale/translations/pt_BR.json +++ b/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", diff --git a/locale/translations/ru_RU.json b/locale/translations/ru_RU.json index 4fe38f5f..9e402949 100644 --- a/locale/translations/ru_RU.json +++ b/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": "Сначала последние записи", diff --git a/locale/translations/tr_TR.json b/locale/translations/tr_TR.json index 6a3abeff..ae83346f 100644 --- a/locale/translations/tr_TR.json +++ b/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", diff --git a/locale/translations/zh_CN.json b/locale/translations/zh_CN.json index 1ead7e4e..586d08d3 100644 --- a/locale/translations/zh_CN.json +++ b/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": "全屏", diff --git a/locale/translations/zh_TW.json b/locale/translations/zh_TW.json index 12f9a3db..fe25c882 100644 --- a/locale/translations/zh_TW.json +++ b/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": "新->舊", diff --git a/model/user.go b/model/user.go index 6ff7332a..b1f335c9 100644 --- a/model/user.go +++ b/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. diff --git a/reader/handler/handler.go b/reader/handler/handler.go index bd775104..be9d6a63 100644 --- a/reader/handler/handler.go +++ b/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 { diff --git a/reader/processor/processor.go b/reader/processor/processor.go index 4df0e510..26b5b87e 100644 --- a/reader/processor/processor.go +++ b/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 diff --git a/storage/user.go b/storage/user.go index 2195ddfc..dded2185 100644 --- a/storage/user.go +++ b/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 { diff --git a/template/templates/views/settings.html b/template/templates/views/settings.html index 3826473c..87a77bbb 100644 --- a/template/templates/views/settings.html +++ b/template/templates/views/settings.html @@ -72,6 +72,12 @@ + + + + + +