diff --git a/client/model.go b/client/model.go index 0a6cd4d1016..57ec7a38df9 100644 --- a/client/model.go +++ b/client/model.go @@ -46,6 +46,7 @@ type User struct { BlockFilterEntryRules string `json:"block_filter_entry_rules"` KeepFilterEntryRules string `json:"keep_filter_entry_rules"` ExternalFontHosts string `json:"external_font_hosts"` + CacheForOffline bool `json:"cache_for_offline"` } func (u User) String() string { diff --git a/internal/database/migrations.go b/internal/database/migrations.go index c8a950e079d..f195ec8632c 100644 --- a/internal/database/migrations.go +++ b/internal/database/migrations.go @@ -969,4 +969,9 @@ var migrations = []func(tx *sql.Tx, driver string) error{ _, err = tx.Exec(sql) return err }, + func(tx *sql.Tx, _ string) (err error) { + sql := `ALTER TABLE users ADD COLUMN cache_for_offline boolean default 'f'` + _, err = tx.Exec(sql) + return err + }, } diff --git a/internal/http/request/context.go b/internal/http/request/context.go index ba6ee40de97..eadff476dfa 100644 --- a/internal/http/request/context.go +++ b/internal/http/request/context.go @@ -140,6 +140,11 @@ func LastForceRefresh(r *http.Request) int64 { return timestamp } +// Determine if the request is from a service worker. +func IsServiceWorker(r *http.Request) bool { + return r.Header.Get("Client-Type") == "service-worker" +} + // ClientIP returns the client IP address stored in the context. func ClientIP(r *http.Request) string { return getContextStringValue(r, ClientIPContextKey) diff --git a/internal/http/response/builder.go b/internal/http/response/builder.go index db89654a1b6..a2b32772f38 100644 --- a/internal/http/response/builder.go +++ b/internal/http/response/builder.go @@ -100,7 +100,7 @@ func (b *Builder) Write() { func (b *Builder) writeHeaders() { b.headers["X-Content-Type-Options"] = "nosniff" b.headers["X-Frame-Options"] = "DENY" - b.headers["Referrer-Policy"] = "no-referrer" + b.headers["Referrer-Policy"] = "strict-origin" for key, value := range b.headers { b.w.Header().Set(key, value) diff --git a/internal/locale/translations/en_US.json b/internal/locale/translations/en_US.json index 4ca46332d86..819d56b8eed 100644 --- a/internal/locale/translations/en_US.json +++ b/internal/locale/translations/en_US.json @@ -1,586 +1,540 @@ { - "skip_to_content": "Skip to content", - "confirm.question": "Are you sure?", - "confirm.question.refresh": "Are you sure you want to force refresh?", - "confirm.yes": "yes", - "confirm.no": "no", - "confirm.loading": "In progress…", - "action.subscribe": "Subscribe", - "action.save": "Save", - "action.or": "or", - "action.cancel": "cancel", - "action.remove": "Remove", - "action.remove_feed": "Remove this feed", - "action.update": "Update", - "action.edit": "Edit", - "action.download": "Download", - "action.import": "Import", - "action.login": "Login", - "action.home_screen": "Add to home screen", - "tooltip.keyboard_shortcuts": "Keyboard Shortcut: %s", - "tooltip.logged_user": "Logged in as %s", - "menu.title": "Menu", - "menu.home_page": "Home page", - "menu.unread": "Unread", - "menu.starred": "Starred", - "menu.history": "History", - "menu.feeds": "Feeds", - "menu.categories": "Categories", - "menu.settings": "Settings", - "menu.logout": "Logout", - "menu.preferences": "Preferences", - "menu.integrations": "Integrations", - "menu.sessions": "Sessions", - "menu.users": "Users", - "menu.about": "About", - "menu.export": "Export", - "menu.import": "Import", - "menu.search": "Search", - "menu.create_category": "Create a category", - "menu.mark_page_as_read": "Mark this page as read", - "menu.mark_all_as_read": "Mark all as read", - "menu.show_all_entries": "Show all entries", - "menu.show_only_starred_entries": "Show only starred entries", - "menu.show_only_unread_entries": "Show only unread entries", - "menu.refresh_feed": "Refresh", - "menu.refresh_all_feeds": "Refresh all feeds in the background", - "menu.edit_feed": "Edit", - "menu.edit_category": "Edit", - "menu.add_feed": "Add feed", - "menu.add_user": "Add user", - "menu.flush_history": "Flush history", - "menu.feed_entries": "Entries", - "menu.api_keys": "API Keys", - "menu.create_api_key": "Create a new API key", - "menu.shared_entries": "Shared entries", - "search.label": "Search", - "search.placeholder": "Search…", - "search.submit": "Search", - "pagination.last": "Last", - "pagination.next": "Next", - "pagination.first": "First", - "pagination.previous": "Previous", - "entry.status.unread": "Unread", - "entry.status.read": "Read", - "entry.status.toast.unread": "Marked as unread", - "entry.status.toast.read": "Marked as read", - "entry.status.title": "Change entry status", - "entry.bookmark.toggle.on": "Star", - "entry.bookmark.toggle.off": "Unstar", - "entry.bookmark.toast.on": "Starred", - "entry.bookmark.toast.off": "Unstarred", - "entry.state.saving": "Saving…", - "entry.state.loading": "Loading…", - "entry.save.label": "Save", - "entry.save.title": "Save this entry", - "entry.save.completed": "Done!", - "entry.save.toast.completed": "Entry saved", - "entry.scraper.label": "Download", - "entry.scraper.title": "Fetch original content", - "entry.scraper.completed": "Done!", - "entry.external_link.label": "External link", - "entry.comments.label": "Comments", - "entry.comments.title": "View Comments", - "entry.share.label": "Share", - "entry.share.title": "Share this entry", - "entry.unshare.label": "Unshare", - "entry.shared_entry.title": "Open the public link", - "entry.shared_entry.label": "Share", - "entry.estimated_reading_time": [ - "%d minute read", - "%d minutes read" - ], - "entry.tags.label": "Tags:", - "page.shared_entries.title": "Shared entries", - "page.shared_entries_count": [ - "%d shared entry", - "%d shared entries" - ], - "page.unread.title": "Unread", - "page.unread_entry_count": [ - "%d unread entry", - "%d unread entries" - ], - "page.total_entry_count": [ - "%d entry in total", - "%d entries in total" - ], - "page.starred.title": "Starred", - "page.starred_entry_count": [ - "%d starred entry", - "%d starred entries" - ], - "page.categories.title": "Categories", - "page.categories.no_feed": "No feed.", - "page.categories.entries": "Entries", - "page.categories.feeds": "Feeds", - "page.categories.feed_count": [ - "There is %d feed.", - "There are %d feeds." - ], - "page.categories_count": [ - "%d category", - "%d categories" - ], - "page.new_category.title": "New Category", - "page.new_user.title": "New User", - "page.edit_category.title": "Edit Category: %s", - "page.edit_user.title": "Edit User: %s", - "page.feeds.title": "Feeds", - "page.category_label": "Category: %s", - "page.feeds.last_check": "Last check:", - "page.feeds.next_check": "Next check:", - "page.feeds.read_counter": "Number of read entries", - "page.feeds.error_count": [ - "%d error", - "%d errors" - ], - "page.history.title": "History", - "page.read_entry_count": [ - "%d read entry", - "%d read entries" - ], - "page.import.title": "Import", - "page.search.title": "Search Results", - "page.about.title": "About", - "page.about.credits": "Credits", - "page.about.version": "Version:", - "page.about.build_date": "Build Date:", - "page.about.author": "Author:", - "page.about.license": "License:", - "page.about.global_config_options": "Global configuration options", - "page.about.postgres_version": "Postgres version:", - "page.about.go_version": "Go version:", - "page.add_feed.title": "New feed", - "page.add_feed.no_category": "There is no category. You must have at least one category.", - "page.add_feed.label.url": "URL", - "page.add_feed.submit": "Find a feed", - "page.add_feed.legend.advanced_options": "Advanced Options", - "page.add_feed.choose_feed": "Choose a feed", - "page.edit_feed.title": "Edit Feed: %s", - "page.edit_feed.last_check": "Last check:", - "page.edit_feed.last_modified_header": "LastModified header:", - "page.edit_feed.etag_header": "ETag header:", - "page.edit_feed.no_header": "None", - "page.edit_feed.last_parsing_error": "Last Parsing Error", - "page.entry.attachments": "Attachments", - "page.keyboard_shortcuts.title": "Keyboard Shortcuts", - "page.keyboard_shortcuts.subtitle.sections": "Sections Navigation", - "page.keyboard_shortcuts.subtitle.items": "Items Navigation", - "page.keyboard_shortcuts.subtitle.pages": "Pages Navigation", - "page.keyboard_shortcuts.subtitle.actions": "Actions", - "page.keyboard_shortcuts.go_to_unread": "Go to unread", - "page.keyboard_shortcuts.go_to_starred": "Go to starred", - "page.keyboard_shortcuts.go_to_history": "Go to history", - "page.keyboard_shortcuts.go_to_feeds": "Go to feeds", - "page.keyboard_shortcuts.go_to_categories": "Go to categories", - "page.keyboard_shortcuts.go_to_settings": "Go to settings", - "page.keyboard_shortcuts.show_keyboard_shortcuts": "Show keyboard shortcuts", - "page.keyboard_shortcuts.go_to_previous_item": "Go to previous item", - "page.keyboard_shortcuts.go_to_next_item": "Go to next item", - "page.keyboard_shortcuts.go_to_feed": "Go to feed", - "page.keyboard_shortcuts.go_to_top_item": "Go to top item", - "page.keyboard_shortcuts.go_to_bottom_item": "Go to bottom item", - "page.keyboard_shortcuts.go_to_previous_page": "Go to previous page", - "page.keyboard_shortcuts.go_to_next_page": "Go to next page", - "page.keyboard_shortcuts.open_item": "Open selected item", - "page.keyboard_shortcuts.open_original": "Open original link", - "page.keyboard_shortcuts.open_original_same_window": "Open original link in current tab", - "page.keyboard_shortcuts.open_comments": "Open comments link", - "page.keyboard_shortcuts.open_comments_same_window": "Open comments link in current tab", - "page.keyboard_shortcuts.toggle_read_status_next": "Toggle read/unread, focus next", - "page.keyboard_shortcuts.toggle_read_status_prev": "Toggle read/unread, focus previous", - "page.keyboard_shortcuts.refresh_all_feeds": "Refresh all feeds in the background", - "page.keyboard_shortcuts.mark_page_as_read": "Mark current page as read", - "page.keyboard_shortcuts.download_content": "Download original content", - "page.keyboard_shortcuts.toggle_bookmark_status": "Toggle starred", - "page.keyboard_shortcuts.save_article": "Save entry", - "page.keyboard_shortcuts.scroll_item_to_top": "Scroll item to top", - "page.keyboard_shortcuts.remove_feed": "Remove this feed", - "page.keyboard_shortcuts.go_to_search": "Set focus on search form", - "page.keyboard_shortcuts.toggle_entry_attachments": "Toggle open/close entry attachments", - "page.keyboard_shortcuts.close_modal": "Close modal dialog", - "page.users.title": "Users", - "page.users.username": "Username", - "page.users.never_logged": "Never", - "page.users.admin.yes": "Yes", - "page.users.admin.no": "No", - "page.users.actions": "Actions", - "page.users.last_login": "Last Login", - "page.users.is_admin": "Administrator", - "page.settings.title": "Settings", - "page.settings.link_google_account": "Link my Google account", - "page.settings.unlink_google_account": "Unlink my Google account", - "page.settings.link_oidc_account": "Link my %s account", - "page.settings.unlink_oidc_account": "Unlink my %s account", - "page.settings.webauthn.passkeys": "Passkeys", - "page.settings.webauthn.actions": "Actions", - "page.settings.webauthn.passkey_name": "Passkey Name", - "page.settings.webauthn.added_on": "Added On", - "page.settings.webauthn.last_seen_on": "Last Used", - "page.settings.webauthn.register": "Register passkey", - "page.settings.webauthn.register.error": "Unable to register passkey", - "page.settings.webauthn.delete": [ - "Remove %d passkey", - "Remove %d passkeys" - ], - "page.login.title": "Sign In", - "page.login.google_signin": "Sign in with Google", - "page.login.oidc_signin": "Sign in with %s", - "page.login.webauthn_login": "Login with passkey", - "page.login.webauthn_login.error": "Unable to login with passkey", - "page.login.webauthn_login.help": "Please enter your username if you're using a security key. This is not required if you are using a Passkey (discoverable credentials).", - "page.integrations.title": "Integrations", - "page.integration.miniflux_api": "Miniflux API", - "page.integration.miniflux_api_endpoint": "API Endpoint", - "page.integration.miniflux_api_username": "Username", - "page.integration.miniflux_api_password": "Password", - "page.integration.miniflux_api_password_value": "Your account password", - "page.integration.bookmarklet": "Bookmarklet", - "page.integration.bookmarklet.name": "Add to Miniflux", - "page.integration.bookmarklet.instructions": "Drag and drop this link to your bookmarks.", - "page.integration.bookmarklet.help": "This special link allows you to subscribe to a website directly by using a bookmark in your web browser.", - "page.sessions.title": "Sessions", - "page.sessions.table.date": "Date", - "page.sessions.table.ip": "IP Address", - "page.sessions.table.user_agent": "User Agent", - "page.sessions.table.actions": "Actions", - "page.sessions.table.current_session": "Current Session", - "page.api_keys.title": "API Keys", - "page.api_keys.table.description": "Description", - "page.api_keys.table.token": "Token", - "page.api_keys.table.last_used_at": "Last Used", - "page.api_keys.table.created_at": "Creation Date", - "page.api_keys.table.actions": "Actions", - "page.api_keys.never_used": "Never Used", - "page.new_api_key.title": "New API Key", - "page.offline.title": "Offline Mode", - "page.offline.message": "You are offline", - "page.offline.refresh_page": "Try to refresh the page", - "page.webauthn_rename.title": "Rename Passkey", - "alert.no_shared_entry": "There is no shared entry.", - "alert.no_bookmark": "There are no starred entries.", - "alert.no_category": "There is no category.", - "alert.no_category_entry": "There are no entries in this category.", - "alert.no_tag_entry": "There are no entries matching this tag.", - "alert.no_feed_entry": "There are no entries for this feed.", - "alert.no_feed": "You don’t have any feeds.", - "alert.no_feed_in_category": "There is no feed for this category.", - "alert.no_history": "There is no history at the moment.", - "alert.feed_error": "There is a problem with this feed", - "alert.no_search_result": "There are no results for this search.", - "alert.no_unread_entry": "There are no unread entries.", - "alert.no_user": "You are the only user.", - "alert.account_unlinked": "Your external account is now dissociated!", - "alert.account_linked": "Your external account is now linked!", - "alert.pocket_linked": "Your Pocket account is now linked!", - "alert.prefs_saved": "Preferences saved!", - "error.unlink_account_without_password": "You must define a password otherwise you won’t be able to login again.", - "error.duplicate_linked_account": "There is already someone associated with this provider!", - "error.duplicate_fever_username": "There is already someone else with the same Fever username!", - "error.duplicate_googlereader_username": "There is already someone else with the same Google Reader username!", - "error.pocket_request_token": "Unable to fetch request token from Pocket!", - "error.pocket_access_token": "Unable to fetch access token from Pocket!", - "error.category_already_exists": "This category already exists.", - "error.unable_to_create_category": "Unable to create this category.", - "error.unable_to_update_category": "Unable to update this category.", - "error.user_already_exists": "This user already exists.", - "error.unable_to_create_user": "Unable to create this user.", - "error.unable_to_update_user": "Unable to update this user.", - "error.unable_to_update_feed": "Unable to update this feed.", - "error.subscription_not_found": "Unable to find any feed.", - "error.invalid_theme": "Invalid theme.", - "error.invalid_language": "Invalid language.", - "error.invalid_timezone": "Invalid timezone.", - "error.invalid_entry_direction": "Invalid entry direction.", - "error.invalid_display_mode": "Invalid web app display mode.", - "error.invalid_gesture_nav": "Invalid gesture navigation.", - "error.invalid_default_home_page": "Invalid default homepage!", - "error.empty_file": "This file is empty.", - "error.bad_credentials": "Invalid username or password.", - "error.fields_mandatory": "All fields are mandatory.", - "error.title_required": "The title is mandatory.", - "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.settings_block_rule_fieldname_invalid": "Invalid Block rule: rule #%d is missing a valid field name (Options: %s)", - "error.settings_block_rule_separator_required": "Invalid Block rule: rule #%d's pattern is required to be seperated by a '='", - "error.settings_block_rule_regex_required": "Invalid Block rule: rule #%d's pattern is not provided", - "error.settings_block_rule_invalid_regex": "Invalid Block rule: rule #%d's pattern is not a valid regex", - "error.settings_keep_rule_fieldname_invalid": "Invalid Keep rule: rule #%d is missing a valid field name (Options: %s)", - "error.settings_keep_rule_separator_required": "Invalid Keep rule: rule #%d's pattern is required to be seperated by a '='", - "error.settings_keep_rule_regex_required": "Invalid Keep rule: rule #%d pattern is not provided", - "error.settings_keep_rule_invalid_regex": "Invalid Keep rule: rule #%d's pattern is not a valid regex", - "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.", - "error.invalid_feed_url": "Invalid feed URL.", - "error.invalid_site_url": "Invalid site URL.", - "error.feed_url_not_empty": "The feed URL cannot be empty.", - "error.site_url_not_empty": "The site URL cannot be empty.", - "error.feed_title_not_empty": "The feed title cannot be empty.", - "error.feed_category_not_found": "This category does not exist or does not belong to this user.", - "error.feed_invalid_blocklist_rule": "The block list rule is invalid.", - "error.feed_invalid_keeplist_rule": "The keep list rule is invalid.", - "error.user_mandatory_fields": "The username is mandatory.", - "error.api_key_already_exists": "This API Key already exists.", - "error.unable_to_create_api_key": "Unable to create this API Key.", - "form.feed.label.title": "Title", - "form.feed.label.site_url": "Site URL", - "form.feed.label.feed_url": "Feed URL", - "form.feed.label.description": "Description", - "form.feed.label.category": "Category", - "form.feed.label.crawler": "Fetch original content", - "form.feed.label.feed_username": "Feed Username", - "form.feed.label.feed_password": "Feed Password", - "form.feed.label.user_agent": "Override Default User Agent", - "form.feed.label.cookie": "Set Cookies", - "form.feed.label.scraper_rules": "Scraper Rules", - "form.feed.label.rewrite_rules": "Rewrite Rules", - "form.feed.label.apprise_service_urls": "Comma separated list of Apprise service URLs", - "form.feed.label.blocklist_rules": "Block Rules", - "form.feed.label.keeplist_rules": "Keep Rules", - "form.feed.label.urlrewrite_rules": "URL Rewrite Rules", - "form.feed.label.ignore_http_cache": "Ignore HTTP cache", - "form.feed.label.allow_self_signed_certificates": "Allow self-signed or invalid certificates", - "form.feed.label.disable_http2": "Disable HTTP/2 to avoid fingerprinting", - "form.feed.label.fetch_via_proxy": "Fetch via proxy", - "form.feed.label.disabled": "Do not refresh this feed", - "form.feed.label.no_media_player": "No media player (audio/video)", - "form.feed.label.hide_globally": "Hide entries in global unread list", - "form.feed.label.ntfy_activate": "Push entries to ntfy", - "form.feed.label.ntfy_priority": "Ntfy priority", - "form.feed.label.ntfy_max_priority": "Ntfy max priority", - "form.feed.label.ntfy_high_priority": "Ntfy high priority", - "form.feed.label.ntfy_default_priority": "Ntfy default priority", - "form.feed.label.ntfy_low_priority": "Ntfy low priority", - "form.feed.label.ntfy_min_priority": "Ntfy min priority", - "form.feed.fieldset.general": "General", - "form.feed.fieldset.rules": "Rules", - "form.feed.fieldset.network_settings": "Network Settings", - "form.feed.fieldset.integration": "Third-Party Services", - "form.category.label.title": "Title", - "form.category.hide_globally": "Hide entries in global unread list", - "form.user.label.username": "Username", - "form.user.label.password": "Password", - "form.user.label.confirmation": "Password Confirmation", - "form.user.label.admin": "Administrator", - "form.prefs.label.language": "Language", - "form.prefs.label.timezone": "Timezone", - "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": "Progressive Web App (PWA) display mode", - "form.prefs.select.older_first": "Older entries first", - "form.prefs.select.recent_first": "Recent entries first", - "form.prefs.select.fullscreen": "Fullscreen", - "form.prefs.select.standalone": "Standalone", - "form.prefs.select.minimal_ui": "Minimal", - "form.prefs.select.browser": "Browser", - "form.prefs.select.publish_time": "Entry published time", - "form.prefs.select.created_time": "Entry created time", - "form.prefs.select.alphabetical": "Alphabetical", - "form.prefs.select.unread_count": "Unread count", - "form.prefs.select.none": "None", - "form.prefs.select.tap": "Double tap", - "form.prefs.select.swipe": "Swipe", - "form.prefs.label.keyboard_shortcuts": "Enable keyboard shortcuts", - "form.prefs.label.entry_swipe": "Enable entry swipe on touch screens", - "form.prefs.label.gesture_nav": "Gesture to navigate between entries", - "form.prefs.label.show_reading_time": "Show estimated reading time for entries", - "form.prefs.label.custom_css": "Custom CSS", - "form.prefs.label.custom_js": "Custom JavaScript", - "form.prefs.label.entry_order": "Entry sorting column", - "form.prefs.label.default_home_page": "Default home page", - "form.prefs.label.categories_sorting_order": "Categories sorting", - "form.prefs.label.mark_read_on_view": "Automatically mark entries as read when viewed", - "form.prefs.label.mark_read_on_view_or_media_completion": "Mark entries as read when viewed. For audio/video, mark as read at 90%% completion", - "form.prefs.label.mark_read_on_media_completion": "Only mark as read when audio/video playback reaches 90%% completion", - "form.prefs.label.mark_read_manually": "Mark entries as read manually", - "form.prefs.fieldset.application_settings": "Application Settings", - "form.prefs.fieldset.authentication_settings": "Authentication Settings", - "form.prefs.fieldset.reader_settings": "Reader Settings", - "form.prefs.fieldset.global_feed_settings": "Global Feed Settings", - "form.prefs.label.external_font_hosts": "External font hosts", - "form.prefs.help.external_font_hosts": "Space separated list of external font hosts to allow. For example: \"fonts.gstatic.com fonts.googleapis.com\".", - "error.settings_invalid_domain_list": "Invalid domain list. Please provide a space separated list of domains.", - "form.import.label.file": "OPML file", - "form.import.label.url": "URL", - "form.integration.betula_activate": "Save entries to Betula", - "form.integration.betula_url": "Betula server URL", - "form.integration.betula_token": "Betula Token", - "form.integration.fever_activate": "Activate Fever API", - "form.integration.fever_username": "Fever Username", - "form.integration.fever_password": "Fever Password", - "form.integration.fever_endpoint": "Fever API endpoint:", - "form.integration.googlereader_activate": "Activate Google Reader API", - "form.integration.googlereader_username": "Google Reader Username", - "form.integration.googlereader_password": "Google Reader Password", - "form.integration.googlereader_endpoint": "Google Reader API endpoint:", - "form.integration.pinboard_activate": "Save entries to Pinboard", - "form.integration.pinboard_token": "Pinboard API Token", - "form.integration.pinboard_tags": "Pinboard Tags", - "form.integration.pinboard_bookmark": "Mark bookmark as unread", - "form.integration.instapaper_activate": "Save entries to Instapaper", - "form.integration.instapaper_username": "Instapaper Username", - "form.integration.instapaper_password": "Instapaper Password", - "form.integration.pocket_activate": "Save entries to Pocket", - "form.integration.pocket_consumer_key": "Pocket Consumer Key", - "form.integration.pocket_access_token": "Pocket Access Token", - "form.integration.pocket_connect_link": "Connect your Pocket account", - "form.integration.wallabag_activate": "Save entries to Wallabag", - "form.integration.wallabag_only_url": "Send only URL (instead of full content)", - "form.integration.wallabag_endpoint": "Wallabag API Endpoint", - "form.integration.wallabag_client_id": "Wallabag Client ID", - "form.integration.wallabag_client_secret": "Wallabag Client Secret", - "form.integration.wallabag_username": "Wallabag Username", - "form.integration.wallabag_password": "Wallabag Password", - "form.integration.notion_activate": "Save entries to Notion", - "form.integration.notion_page_id": "Notion Page ID", - "form.integration.notion_token": "Notion Secret Token", - "form.integration.apprise_activate": "Push entries to Apprise", - "form.integration.apprise_url": "Apprise API URL", - "form.integration.apprise_services_url": "Comma separated list of Apprise service URLs", - "form.integration.nunux_keeper_activate": "Save entries to Nunux Keeper", - "form.integration.nunux_keeper_endpoint": "Nunux Keeper API Endpoint", - "form.integration.nunux_keeper_api_key": "Nunux Keeper API key", - "form.integration.omnivore_activate": "Save entries to Omnivore", - "form.integration.omnivore_api_key": "Omnivore API key", - "form.integration.omnivore_url": "Omnivore API Endpoint", - "form.integration.espial_activate": "Save entries to Espial", - "form.integration.espial_endpoint": "Espial API Endpoint", - "form.integration.espial_api_key": "Espial API key", - "form.integration.espial_tags": "Espial Tags", - "form.integration.readwise_activate": "Save entries to Readwise Reader", - "form.integration.readwise_api_key": "Readwise Reader Access Token", - "form.integration.readwise_api_key_link": "Get your Readwise Access Token", - "form.integration.telegram_bot_activate": "Push new entries to Telegram chat", - "form.integration.telegram_bot_token": "Bot token", - "form.integration.telegram_chat_id": "Chat ID", - "form.integration.telegram_topic_id": "Topic ID", - "form.integration.telegram_bot_disable_web_page_preview": "Disable web page preview", - "form.integration.telegram_bot_disable_notification": "Disable notification", - "form.integration.telegram_bot_disable_buttons": "Disable buttons", - "form.integration.linkace_activate": "Save entries to LinkAce", - "form.integration.linkace_endpoint": "LinkAce API Endpoint", - "form.integration.linkace_api_key": "LinkAce API key", - "form.integration.linkace_tags": "LinkAce Tags", - "form.integration.linkace_is_private": "Mark link as private", - "form.integration.linkace_check_disabled": "Disable link check", - "form.integration.linkding_activate": "Save entries to Linkding", - "form.integration.linkding_endpoint": "Linkding API Endpoint", - "form.integration.linkding_api_key": "Linkding API key", - "form.integration.linkding_tags": "Linkding Tags", - "form.integration.linkding_bookmark": "Mark bookmark as unread", - "form.integration.linkwarden_activate": "Save entries to Linkwarden", - "form.integration.linkwarden_endpoint": "Linkwarden API Endpoint", - "form.integration.linkwarden_api_key": "Linkwarden API key", - "form.integration.matrix_bot_activate": "Push new entries to Matrix", - "form.integration.matrix_bot_user": "Username for Matrix", - "form.integration.matrix_bot_password": "Password for Matrix user", - "form.integration.matrix_bot_url": "Matrix server URL", - "form.integration.matrix_bot_chat_id": "ID of Matrix Room", - "form.integration.raindrop_activate": "Save entries to Raindrop", - "form.integration.raindrop_token": "(Test) Token", - "form.integration.raindrop_collection_id": "Collection ID", - "form.integration.raindrop_tags": "Tags (comma-separated)", - "form.integration.readeck_activate": "Save entries to readeck", - "form.integration.readeck_endpoint": "Readeck URL", - "form.integration.readeck_api_key": "Readeck API key", - "form.integration.readeck_labels": "Readeck Labels", - "form.integration.readeck_only_url": "Send only URL (instead of full content)", - "form.integration.shiori_activate": "Save articles to Shiori", - "form.integration.shiori_endpoint": "Shiori API Endpoint", - "form.integration.shiori_username": "Shiori Username", - "form.integration.shiori_password": "Shiori Password", - "form.integration.shaarli_activate": "Save articles to Shaarli", - "form.integration.shaarli_endpoint": "Shaarli URL", - "form.integration.shaarli_api_secret": "Shaarli API Secret", - "form.integration.webhook_activate": "Enable Webhook", - "form.integration.webhook_url": "Webhook URL", - "form.integration.webhook_secret": "Webhook Secret", - "form.integration.rssbridge_activate": "Check RSS-Bridge when adding subscriptions", - "form.integration.rssbridge_url": "RSS-Bridge server URL", - "form.integration.ntfy_activate": "Push entries to ntfy", - "form.integration.ntfy_topic": "Ntfy topic", - "form.integration.ntfy_url": "Ntfy URL (optional, default is ntfy.sh)", - "form.integration.ntfy_api_token": "Ntfy API Token (optional)", - "form.integration.ntfy_username": "Ntfy Username (optional)", - "form.integration.ntfy_password": "Ntfy Password (optional)", - "form.integration.ntfy_icon_url": "Ntfy Icon URL (optional)", - "form.integration.cubox_activate": "Save entries to Cubox", - "form.integration.cubox_api_link": "Cubox API link", - "form.api_key.label.description": "API Key Label", - "form.submit.loading": "Loading…", - "form.submit.saving": "Saving…", - "time_elapsed.not_yet": "not yet", - "time_elapsed.yesterday": "yesterday", - "time_elapsed.now": "just now", - "time_elapsed.minutes": [ - "%d minute ago", - "%d minutes ago" - ], - "time_elapsed.hours": [ - "%d hour ago", - "%d hours ago" - ], - "time_elapsed.days": [ - "%d day ago", - "%d days ago" - ], - "time_elapsed.weeks": [ - "%d week ago", - "%d weeks ago" - ], - "time_elapsed.months": [ - "%d month ago", - "%d months ago" - ], - "time_elapsed.years": [ - "%d year ago", - "%d years ago" - ], - "alert.too_many_feeds_refresh": [ - "You have triggered too many feed refreshes. Please wait %d minute before trying again.", - "You have triggered too many feed refreshes. Please wait %d minutes before trying again." - ], - "alert.background_feed_refresh": "All feeds are being refreshed in the background. You can continue to use Miniflux while this process is running.", - "error.http_response_too_large": "The HTTP response is too large. You could increase the HTTP response size limit in the global settings (requires a server restart).", - "error.http_body_read": "Unable to read the HTTP body: %v.", - "error.http_empty_response_body": "The HTTP response body is empty.", - "error.http_empty_response": "The HTTP response is empty. Perhaps, this website is using a bot protection mechanism?", - "error.tls_error": "TLS error: %q. You could disable TLS verification in the feed settings if you would like.", - "error.network_operation": "Miniflux is not able to reach this website due to a network error: %v.", - "error.network_timeout": "This website is too slow and the request timed out: %v", - "error.http_client_error": "HTTP client error: %v.", - "error.http_not_authorized": "Access to this website is not authorized. It could be a bad username or password.", - "error.http_too_many_requests": "Miniflux generated too many requests to this website. Please, try again later or change the application configuration.", - "error.http_forbidden": "Access to this website is forbidden. Perhaps, this website has a bot protection mechanism?", - "error.http_resource_not_found": "The requested resource is not found. Please, verify the URL.", - "error.http_internal_server_error": "The website is not available at the moment due to a server error. The problem is not on Miniflux side. Please, try again later.", - "error.http_bad_gateway": "The website is not available at the moment due to a bad gateway error. The problem is not on Miniflux side. Please, try again later.", - "error.http_service_unavailable": "The website is not available at the moment due to an internal server error. The problem is not on Miniflux side. Please, try again later.", - "error.http_gateway_timeout": "The website is not available at the moment due to a gateway timeout error. The problem is not on Miniflux side. Please, try again later.", - "error.http_unexpected_status_code": "The website is not available at the moment due to an unexpected HTTP status code: %d. The problem is not on Miniflux side. Please, try again later.", - "error.database_error": "Database error: %v.", - "error.category_not_found": "This category does not exist or does not belong to this user.", - "error.duplicated_feed": "This feed already exists.", - "error.unable_to_parse_feed": "Unable to parse this feed: %v.", - "error.feed_not_found": "This feed does not exist or does not belong to this user.", - "error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.", - "error.feed_format_not_detected": "Unable to detect feed format: %v.", - "form.prefs.label.media_playback_rate": "Playback speed of the audio/video", - "error.settings_media_playback_rate_range": "Playback speed is out of range", - "enclosure_media_controls.seek" : "Seek:", - "enclosure_media_controls.seek.title" : "Seek %s seconds", - "enclosure_media_controls.speed" : "Speed:", - "enclosure_media_controls.speed.faster" : "Faster", - "enclosure_media_controls.speed.faster.title" : "Faster by %sx", - "enclosure_media_controls.speed.slower" : "Slower", - "enclosure_media_controls.speed.slower.title" : "Slower by %sx", - "enclosure_media_controls.speed.reset" : "Reset", - "enclosure_media_controls.speed.reset.title" : "Reset speed to 1x" + "skip_to_content": "Skip to content", + "confirm.question": "Are you sure?", + "confirm.question.refresh": "Are you sure you want to force refresh?", + "confirm.yes": "yes", + "confirm.no": "no", + "confirm.loading": "In progress…", + "action.subscribe": "Subscribe", + "action.save": "Save", + "action.or": "or", + "action.cancel": "cancel", + "action.remove": "Remove", + "action.remove_feed": "Remove this feed", + "action.update": "Update", + "action.edit": "Edit", + "action.download": "Download", + "action.import": "Import", + "action.login": "Login", + "action.home_screen": "Add to home screen", + "tooltip.keyboard_shortcuts": "Keyboard Shortcut: %s", + "tooltip.logged_user": "Logged in as %s", + "menu.title": "Menu", + "menu.home_page": "Home page", + "menu.unread": "Unread", + "menu.starred": "Starred", + "menu.history": "History", + "menu.feeds": "Feeds", + "menu.categories": "Categories", + "menu.settings": "Settings", + "menu.logout": "Logout", + "menu.preferences": "Preferences", + "menu.integrations": "Integrations", + "menu.sessions": "Sessions", + "menu.users": "Users", + "menu.about": "About", + "menu.export": "Export", + "menu.import": "Import", + "menu.search": "Search", + "menu.create_category": "Create a category", + "menu.mark_page_as_read": "Mark this page as read", + "menu.mark_all_as_read": "Mark all as read", + "menu.show_all_entries": "Show all entries", + "menu.show_only_starred_entries": "Show only starred entries", + "menu.show_only_unread_entries": "Show only unread entries", + "menu.refresh_feed": "Refresh", + "menu.refresh_all_feeds": "Refresh all feeds in the background", + "menu.edit_feed": "Edit", + "menu.edit_category": "Edit", + "menu.add_feed": "Add feed", + "menu.add_user": "Add user", + "menu.flush_history": "Flush history", + "menu.feed_entries": "Entries", + "menu.api_keys": "API Keys", + "menu.create_api_key": "Create a new API key", + "menu.shared_entries": "Shared entries", + "search.label": "Search", + "search.placeholder": "Search…", + "search.submit": "Search", + "pagination.last": "Last", + "pagination.next": "Next", + "pagination.first": "First", + "pagination.previous": "Previous", + "entry.status.unread": "Unread", + "entry.status.read": "Read", + "entry.status.toast.unread": "Marked as unread", + "entry.status.toast.read": "Marked as read", + "entry.status.title": "Change entry status", + "entry.bookmark.toggle.on": "Star", + "entry.bookmark.toggle.off": "Unstar", + "entry.bookmark.toast.on": "Starred", + "entry.bookmark.toast.off": "Unstarred", + "entry.state.saving": "Saving…", + "entry.state.loading": "Loading…", + "entry.save.label": "Save", + "entry.save.title": "Save this entry", + "entry.save.completed": "Done!", + "entry.save.toast.completed": "Entry saved", + "entry.scraper.label": "Download", + "entry.scraper.title": "Fetch original content", + "entry.scraper.completed": "Done!", + "entry.external_link.label": "External link", + "entry.comments.label": "Comments", + "entry.comments.title": "View Comments", + "entry.share.label": "Share", + "entry.share.title": "Share this entry", + "entry.unshare.label": "Unshare", + "entry.shared_entry.title": "Open the public link", + "entry.shared_entry.label": "Share", + "entry.estimated_reading_time": ["%d minute read", "%d minutes read"], + "entry.tags.label": "Tags:", + "page.shared_entries.title": "Shared entries", + "page.shared_entries_count": ["%d shared entry", "%d shared entries"], + "page.unread.title": "Unread", + "page.unread_entry_count": ["%d unread entry", "%d unread entries"], + "page.total_entry_count": ["%d entry in total", "%d entries in total"], + "page.starred.title": "Starred", + "page.starred_entry_count": ["%d starred entry", "%d starred entries"], + "page.categories.title": "Categories", + "page.categories.no_feed": "No feed.", + "page.categories.entries": "Entries", + "page.categories.feeds": "Feeds", + "page.categories.feed_count": ["There is %d feed.", "There are %d feeds."], + "page.categories_count": ["%d category", "%d categories"], + "page.new_category.title": "New Category", + "page.new_user.title": "New User", + "page.edit_category.title": "Edit Category: %s", + "page.edit_user.title": "Edit User: %s", + "page.feeds.title": "Feeds", + "page.category_label": "Category: %s", + "page.feeds.last_check": "Last check:", + "page.feeds.next_check": "Next check:", + "page.feeds.read_counter": "Number of read entries", + "page.feeds.error_count": ["%d error", "%d errors"], + "page.history.title": "History", + "page.read_entry_count": ["%d read entry", "%d read entries"], + "page.import.title": "Import", + "page.search.title": "Search Results", + "page.about.title": "About", + "page.about.credits": "Credits", + "page.about.version": "Version:", + "page.about.build_date": "Build Date:", + "page.about.author": "Author:", + "page.about.license": "License:", + "page.about.global_config_options": "Global configuration options", + "page.about.postgres_version": "Postgres version:", + "page.about.go_version": "Go version:", + "page.add_feed.title": "New feed", + "page.add_feed.no_category": "There is no category. You must have at least one category.", + "page.add_feed.label.url": "URL", + "page.add_feed.submit": "Find a feed", + "page.add_feed.legend.advanced_options": "Advanced Options", + "page.add_feed.choose_feed": "Choose a feed", + "page.edit_feed.title": "Edit Feed: %s", + "page.edit_feed.last_check": "Last check:", + "page.edit_feed.last_modified_header": "LastModified header:", + "page.edit_feed.etag_header": "ETag header:", + "page.edit_feed.no_header": "None", + "page.edit_feed.last_parsing_error": "Last Parsing Error", + "page.entry.attachments": "Attachments", + "page.keyboard_shortcuts.title": "Keyboard Shortcuts", + "page.keyboard_shortcuts.subtitle.sections": "Sections Navigation", + "page.keyboard_shortcuts.subtitle.items": "Items Navigation", + "page.keyboard_shortcuts.subtitle.pages": "Pages Navigation", + "page.keyboard_shortcuts.subtitle.actions": "Actions", + "page.keyboard_shortcuts.go_to_unread": "Go to unread", + "page.keyboard_shortcuts.go_to_starred": "Go to starred", + "page.keyboard_shortcuts.go_to_history": "Go to history", + "page.keyboard_shortcuts.go_to_feeds": "Go to feeds", + "page.keyboard_shortcuts.go_to_categories": "Go to categories", + "page.keyboard_shortcuts.go_to_settings": "Go to settings", + "page.keyboard_shortcuts.show_keyboard_shortcuts": "Show keyboard shortcuts", + "page.keyboard_shortcuts.go_to_previous_item": "Go to previous item", + "page.keyboard_shortcuts.go_to_next_item": "Go to next item", + "page.keyboard_shortcuts.go_to_feed": "Go to feed", + "page.keyboard_shortcuts.go_to_top_item": "Go to top item", + "page.keyboard_shortcuts.go_to_bottom_item": "Go to bottom item", + "page.keyboard_shortcuts.go_to_previous_page": "Go to previous page", + "page.keyboard_shortcuts.go_to_next_page": "Go to next page", + "page.keyboard_shortcuts.open_item": "Open selected item", + "page.keyboard_shortcuts.open_original": "Open original link", + "page.keyboard_shortcuts.open_original_same_window": "Open original link in current tab", + "page.keyboard_shortcuts.open_comments": "Open comments link", + "page.keyboard_shortcuts.open_comments_same_window": "Open comments link in current tab", + "page.keyboard_shortcuts.toggle_read_status_next": "Toggle read/unread, focus next", + "page.keyboard_shortcuts.toggle_read_status_prev": "Toggle read/unread, focus previous", + "page.keyboard_shortcuts.refresh_all_feeds": "Refresh all feeds in the background", + "page.keyboard_shortcuts.mark_page_as_read": "Mark current page as read", + "page.keyboard_shortcuts.download_content": "Download original content", + "page.keyboard_shortcuts.toggle_bookmark_status": "Toggle starred", + "page.keyboard_shortcuts.save_article": "Save entry", + "page.keyboard_shortcuts.scroll_item_to_top": "Scroll item to top", + "page.keyboard_shortcuts.remove_feed": "Remove this feed", + "page.keyboard_shortcuts.go_to_search": "Set focus on search form", + "page.keyboard_shortcuts.toggle_entry_attachments": "Toggle open/close entry attachments", + "page.keyboard_shortcuts.close_modal": "Close modal dialog", + "page.users.title": "Users", + "page.users.username": "Username", + "page.users.never_logged": "Never", + "page.users.admin.yes": "Yes", + "page.users.admin.no": "No", + "page.users.actions": "Actions", + "page.users.last_login": "Last Login", + "page.users.is_admin": "Administrator", + "page.settings.title": "Settings", + "page.settings.link_google_account": "Link my Google account", + "page.settings.unlink_google_account": "Unlink my Google account", + "page.settings.link_oidc_account": "Link my %s account", + "page.settings.unlink_oidc_account": "Unlink my %s account", + "page.settings.webauthn.passkeys": "Passkeys", + "page.settings.webauthn.actions": "Actions", + "page.settings.webauthn.passkey_name": "Passkey Name", + "page.settings.webauthn.added_on": "Added On", + "page.settings.webauthn.last_seen_on": "Last Used", + "page.settings.webauthn.register": "Register passkey", + "page.settings.webauthn.register.error": "Unable to register passkey", + "page.settings.webauthn.delete": ["Remove %d passkey", "Remove %d passkeys"], + "page.login.title": "Sign In", + "page.login.google_signin": "Sign in with Google", + "page.login.oidc_signin": "Sign in with %s", + "page.login.webauthn_login": "Login with passkey", + "page.login.webauthn_login.error": "Unable to login with passkey", + "page.login.webauthn_login.help": "Please enter your username if you're using a security key. This is not required if you are using a Passkey (discoverable credentials).", + "page.integrations.title": "Integrations", + "page.integration.miniflux_api": "Miniflux API", + "page.integration.miniflux_api_endpoint": "API Endpoint", + "page.integration.miniflux_api_username": "Username", + "page.integration.miniflux_api_password": "Password", + "page.integration.miniflux_api_password_value": "Your account password", + "page.integration.bookmarklet": "Bookmarklet", + "page.integration.bookmarklet.name": "Add to Miniflux", + "page.integration.bookmarklet.instructions": "Drag and drop this link to your bookmarks.", + "page.integration.bookmarklet.help": "This special link allows you to subscribe to a website directly by using a bookmark in your web browser.", + "page.sessions.title": "Sessions", + "page.sessions.table.date": "Date", + "page.sessions.table.ip": "IP Address", + "page.sessions.table.user_agent": "User Agent", + "page.sessions.table.actions": "Actions", + "page.sessions.table.current_session": "Current Session", + "page.api_keys.title": "API Keys", + "page.api_keys.table.description": "Description", + "page.api_keys.table.token": "Token", + "page.api_keys.table.last_used_at": "Last Used", + "page.api_keys.table.created_at": "Creation Date", + "page.api_keys.table.actions": "Actions", + "page.api_keys.never_used": "Never Used", + "page.new_api_key.title": "New API Key", + "page.offline.title": "Offline Mode", + "page.offline.message": "You are offline", + "page.offline.refresh_page": "Try to refresh the page", + "page.webauthn_rename.title": "Rename Passkey", + "page.cache.warning": "You're viewing a cached version. Refresh to see the latest one.", + "alert.no_shared_entry": "There is no shared entry.", + "alert.no_bookmark": "There are no starred entries.", + "alert.no_category": "There is no category.", + "alert.no_category_entry": "There are no entries in this category.", + "alert.no_tag_entry": "There are no entries matching this tag.", + "alert.no_feed_entry": "There are no entries for this feed.", + "alert.no_feed": "You don’t have any feeds.", + "alert.no_feed_in_category": "There is no feed for this category.", + "alert.no_history": "There is no history at the moment.", + "alert.feed_error": "There is a problem with this feed", + "alert.no_search_result": "There are no results for this search.", + "alert.no_unread_entry": "There are no unread entries.", + "alert.no_user": "You are the only user.", + "alert.account_unlinked": "Your external account is now dissociated!", + "alert.account_linked": "Your external account is now linked!", + "alert.pocket_linked": "Your Pocket account is now linked!", + "alert.prefs_saved": "Preferences saved!", + "error.unlink_account_without_password": "You must define a password otherwise you won’t be able to login again.", + "error.duplicate_linked_account": "There is already someone associated with this provider!", + "error.duplicate_fever_username": "There is already someone else with the same Fever username!", + "error.duplicate_googlereader_username": "There is already someone else with the same Google Reader username!", + "error.pocket_request_token": "Unable to fetch request token from Pocket!", + "error.pocket_access_token": "Unable to fetch access token from Pocket!", + "error.category_already_exists": "This category already exists.", + "error.unable_to_create_category": "Unable to create this category.", + "error.unable_to_update_category": "Unable to update this category.", + "error.user_already_exists": "This user already exists.", + "error.unable_to_create_user": "Unable to create this user.", + "error.unable_to_update_user": "Unable to update this user.", + "error.unable_to_update_feed": "Unable to update this feed.", + "error.subscription_not_found": "Unable to find any feed.", + "error.invalid_theme": "Invalid theme.", + "error.invalid_language": "Invalid language.", + "error.invalid_timezone": "Invalid timezone.", + "error.invalid_entry_direction": "Invalid entry direction.", + "error.invalid_display_mode": "Invalid web app display mode.", + "error.invalid_gesture_nav": "Invalid gesture navigation.", + "error.invalid_default_home_page": "Invalid default homepage!", + "error.empty_file": "This file is empty.", + "error.bad_credentials": "Invalid username or password.", + "error.fields_mandatory": "All fields are mandatory.", + "error.title_required": "The title is mandatory.", + "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.settings_block_rule_fieldname_invalid": "Invalid Block rule: rule #%d is missing a valid field name (Options: %s)", + "error.settings_block_rule_separator_required": "Invalid Block rule: rule #%d's pattern is required to be seperated by a '='", + "error.settings_block_rule_regex_required": "Invalid Block rule: rule #%d's pattern is not provided", + "error.settings_block_rule_invalid_regex": "Invalid Block rule: rule #%d's pattern is not a valid regex", + "error.settings_keep_rule_fieldname_invalid": "Invalid Keep rule: rule #%d is missing a valid field name (Options: %s)", + "error.settings_keep_rule_separator_required": "Invalid Keep rule: rule #%d's pattern is required to be seperated by a '='", + "error.settings_keep_rule_regex_required": "Invalid Keep rule: rule #%d pattern is not provided", + "error.settings_keep_rule_invalid_regex": "Invalid Keep rule: rule #%d's pattern is not a valid regex", + "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.", + "error.invalid_feed_url": "Invalid feed URL.", + "error.invalid_site_url": "Invalid site URL.", + "error.feed_url_not_empty": "The feed URL cannot be empty.", + "error.site_url_not_empty": "The site URL cannot be empty.", + "error.feed_title_not_empty": "The feed title cannot be empty.", + "error.feed_category_not_found": "This category does not exist or does not belong to this user.", + "error.feed_invalid_blocklist_rule": "The block list rule is invalid.", + "error.feed_invalid_keeplist_rule": "The keep list rule is invalid.", + "error.user_mandatory_fields": "The username is mandatory.", + "error.api_key_already_exists": "This API Key already exists.", + "error.unable_to_create_api_key": "Unable to create this API Key.", + "form.feed.label.title": "Title", + "form.feed.label.site_url": "Site URL", + "form.feed.label.feed_url": "Feed URL", + "form.feed.label.description": "Description", + "form.feed.label.category": "Category", + "form.feed.label.crawler": "Fetch original content", + "form.feed.label.feed_username": "Feed Username", + "form.feed.label.feed_password": "Feed Password", + "form.feed.label.user_agent": "Override Default User Agent", + "form.feed.label.cookie": "Set Cookies", + "form.feed.label.scraper_rules": "Scraper Rules", + "form.feed.label.rewrite_rules": "Rewrite Rules", + "form.feed.label.apprise_service_urls": "Comma separated list of Apprise service URLs", + "form.feed.label.blocklist_rules": "Block Rules", + "form.feed.label.keeplist_rules": "Keep Rules", + "form.feed.label.urlrewrite_rules": "URL Rewrite Rules", + "form.feed.label.ignore_http_cache": "Ignore HTTP cache", + "form.feed.label.allow_self_signed_certificates": "Allow self-signed or invalid certificates", + "form.feed.label.disable_http2": "Disable HTTP/2 to avoid fingerprinting", + "form.feed.label.fetch_via_proxy": "Fetch via proxy", + "form.feed.label.disabled": "Do not refresh this feed", + "form.feed.label.no_media_player": "No media player (audio/video)", + "form.feed.label.hide_globally": "Hide entries in global unread list", + "form.feed.label.ntfy_activate": "Push entries to ntfy", + "form.feed.label.ntfy_priority": "Ntfy priority", + "form.feed.label.ntfy_max_priority": "Ntfy max priority", + "form.feed.label.ntfy_high_priority": "Ntfy high priority", + "form.feed.label.ntfy_default_priority": "Ntfy default priority", + "form.feed.label.ntfy_low_priority": "Ntfy low priority", + "form.feed.label.ntfy_min_priority": "Ntfy min priority", + "form.feed.fieldset.general": "General", + "form.feed.fieldset.rules": "Rules", + "form.feed.fieldset.network_settings": "Network Settings", + "form.feed.fieldset.integration": "Third-Party Services", + "form.category.label.title": "Title", + "form.category.hide_globally": "Hide entries in global unread list", + "form.user.label.username": "Username", + "form.user.label.password": "Password", + "form.user.label.confirmation": "Password Confirmation", + "form.user.label.admin": "Administrator", + "form.prefs.label.language": "Language", + "form.prefs.label.timezone": "Timezone", + "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": "Progressive Web App (PWA) display mode", + "form.prefs.select.older_first": "Older entries first", + "form.prefs.select.recent_first": "Recent entries first", + "form.prefs.select.fullscreen": "Fullscreen", + "form.prefs.select.standalone": "Standalone", + "form.prefs.select.minimal_ui": "Minimal", + "form.prefs.select.browser": "Browser", + "form.prefs.select.publish_time": "Entry published time", + "form.prefs.select.created_time": "Entry created time", + "form.prefs.select.alphabetical": "Alphabetical", + "form.prefs.select.unread_count": "Unread count", + "form.prefs.select.none": "None", + "form.prefs.select.tap": "Double tap", + "form.prefs.select.swipe": "Swipe", + "form.prefs.label.keyboard_shortcuts": "Enable keyboard shortcuts", + "form.prefs.label.entry_swipe": "Enable entry swipe on touch screens", + "form.prefs.label.gesture_nav": "Gesture to navigate between entries", + "form.prefs.label.show_reading_time": "Show estimated reading time for entries", + "form.prefs.label.custom_css": "Custom CSS", + "form.prefs.label.custom_js": "Custom JavaScript", + "form.prefs.label.entry_order": "Entry sorting column", + "form.prefs.label.default_home_page": "Default home page", + "form.prefs.label.categories_sorting_order": "Categories sorting", + "form.prefs.label.mark_read_on_view": "Automatically mark entries as read when viewed", + "form.prefs.label.mark_read_on_view_or_media_completion": "Mark entries as read when viewed. For audio/video, mark as read at 90%% completion", + "form.prefs.label.mark_read_on_media_completion": "Only mark as read when audio/video playback reaches 90%% completion", + "form.prefs.label.mark_read_manually": "Mark entries as read manually", + "form.prefs.label.cache_for_offline": "Cache entries for offline reading", + "form.prefs.fieldset.application_settings": "Application Settings", + "form.prefs.fieldset.authentication_settings": "Authentication Settings", + "form.prefs.fieldset.reader_settings": "Reader Settings", + "form.prefs.fieldset.global_feed_settings": "Global Feed Settings", + "form.prefs.label.external_font_hosts": "External font hosts", + "form.prefs.help.external_font_hosts": "Space separated list of external font hosts to allow. For example: \"fonts.gstatic.com fonts.googleapis.com\".", + "error.settings_invalid_domain_list": "Invalid domain list. Please provide a space separated list of domains.", + "form.import.label.file": "OPML file", + "form.import.label.url": "URL", + "form.integration.betula_activate": "Save entries to Betula", + "form.integration.betula_url": "Betula server URL", + "form.integration.betula_token": "Betula Token", + "form.integration.fever_activate": "Activate Fever API", + "form.integration.fever_username": "Fever Username", + "form.integration.fever_password": "Fever Password", + "form.integration.fever_endpoint": "Fever API endpoint:", + "form.integration.googlereader_activate": "Activate Google Reader API", + "form.integration.googlereader_username": "Google Reader Username", + "form.integration.googlereader_password": "Google Reader Password", + "form.integration.googlereader_endpoint": "Google Reader API endpoint:", + "form.integration.pinboard_activate": "Save entries to Pinboard", + "form.integration.pinboard_token": "Pinboard API Token", + "form.integration.pinboard_tags": "Pinboard Tags", + "form.integration.pinboard_bookmark": "Mark bookmark as unread", + "form.integration.instapaper_activate": "Save entries to Instapaper", + "form.integration.instapaper_username": "Instapaper Username", + "form.integration.instapaper_password": "Instapaper Password", + "form.integration.pocket_activate": "Save entries to Pocket", + "form.integration.pocket_consumer_key": "Pocket Consumer Key", + "form.integration.pocket_access_token": "Pocket Access Token", + "form.integration.pocket_connect_link": "Connect your Pocket account", + "form.integration.wallabag_activate": "Save entries to Wallabag", + "form.integration.wallabag_only_url": "Send only URL (instead of full content)", + "form.integration.wallabag_endpoint": "Wallabag API Endpoint", + "form.integration.wallabag_client_id": "Wallabag Client ID", + "form.integration.wallabag_client_secret": "Wallabag Client Secret", + "form.integration.wallabag_username": "Wallabag Username", + "form.integration.wallabag_password": "Wallabag Password", + "form.integration.notion_activate": "Save entries to Notion", + "form.integration.notion_page_id": "Notion Page ID", + "form.integration.notion_token": "Notion Secret Token", + "form.integration.apprise_activate": "Push entries to Apprise", + "form.integration.apprise_url": "Apprise API URL", + "form.integration.apprise_services_url": "Comma separated list of Apprise service URLs", + "form.integration.nunux_keeper_activate": "Save entries to Nunux Keeper", + "form.integration.nunux_keeper_endpoint": "Nunux Keeper API Endpoint", + "form.integration.nunux_keeper_api_key": "Nunux Keeper API key", + "form.integration.omnivore_activate": "Save entries to Omnivore", + "form.integration.omnivore_api_key": "Omnivore API key", + "form.integration.omnivore_url": "Omnivore API Endpoint", + "form.integration.espial_activate": "Save entries to Espial", + "form.integration.espial_endpoint": "Espial API Endpoint", + "form.integration.espial_api_key": "Espial API key", + "form.integration.espial_tags": "Espial Tags", + "form.integration.readwise_activate": "Save entries to Readwise Reader", + "form.integration.readwise_api_key": "Readwise Reader Access Token", + "form.integration.readwise_api_key_link": "Get your Readwise Access Token", + "form.integration.telegram_bot_activate": "Push new entries to Telegram chat", + "form.integration.telegram_bot_token": "Bot token", + "form.integration.telegram_chat_id": "Chat ID", + "form.integration.telegram_topic_id": "Topic ID", + "form.integration.telegram_bot_disable_web_page_preview": "Disable web page preview", + "form.integration.telegram_bot_disable_notification": "Disable notification", + "form.integration.telegram_bot_disable_buttons": "Disable buttons", + "form.integration.linkace_activate": "Save entries to LinkAce", + "form.integration.linkace_endpoint": "LinkAce API Endpoint", + "form.integration.linkace_api_key": "LinkAce API key", + "form.integration.linkace_tags": "LinkAce Tags", + "form.integration.linkace_is_private": "Mark link as private", + "form.integration.linkace_check_disabled": "Disable link check", + "form.integration.linkding_activate": "Save entries to Linkding", + "form.integration.linkding_endpoint": "Linkding API Endpoint", + "form.integration.linkding_api_key": "Linkding API key", + "form.integration.linkding_tags": "Linkding Tags", + "form.integration.linkding_bookmark": "Mark bookmark as unread", + "form.integration.linkwarden_activate": "Save entries to Linkwarden", + "form.integration.linkwarden_endpoint": "Linkwarden API Endpoint", + "form.integration.linkwarden_api_key": "Linkwarden API key", + "form.integration.matrix_bot_activate": "Push new entries to Matrix", + "form.integration.matrix_bot_user": "Username for Matrix", + "form.integration.matrix_bot_password": "Password for Matrix user", + "form.integration.matrix_bot_url": "Matrix server URL", + "form.integration.matrix_bot_chat_id": "ID of Matrix Room", + "form.integration.raindrop_activate": "Save entries to Raindrop", + "form.integration.raindrop_token": "(Test) Token", + "form.integration.raindrop_collection_id": "Collection ID", + "form.integration.raindrop_tags": "Tags (comma-separated)", + "form.integration.readeck_activate": "Save entries to readeck", + "form.integration.readeck_endpoint": "Readeck URL", + "form.integration.readeck_api_key": "Readeck API key", + "form.integration.readeck_labels": "Readeck Labels", + "form.integration.readeck_only_url": "Send only URL (instead of full content)", + "form.integration.shiori_activate": "Save articles to Shiori", + "form.integration.shiori_endpoint": "Shiori API Endpoint", + "form.integration.shiori_username": "Shiori Username", + "form.integration.shiori_password": "Shiori Password", + "form.integration.shaarli_activate": "Save articles to Shaarli", + "form.integration.shaarli_endpoint": "Shaarli URL", + "form.integration.shaarli_api_secret": "Shaarli API Secret", + "form.integration.webhook_activate": "Enable Webhook", + "form.integration.webhook_url": "Webhook URL", + "form.integration.webhook_secret": "Webhook Secret", + "form.integration.rssbridge_activate": "Check RSS-Bridge when adding subscriptions", + "form.integration.rssbridge_url": "RSS-Bridge server URL", + "form.integration.ntfy_activate": "Push entries to ntfy", + "form.integration.ntfy_topic": "Ntfy topic", + "form.integration.ntfy_url": "Ntfy URL (optional, default is ntfy.sh)", + "form.integration.ntfy_api_token": "Ntfy API Token (optional)", + "form.integration.ntfy_username": "Ntfy Username (optional)", + "form.integration.ntfy_password": "Ntfy Password (optional)", + "form.integration.ntfy_icon_url": "Ntfy Icon URL (optional)", + "form.integration.cubox_activate": "Save entries to Cubox", + "form.integration.cubox_api_link": "Cubox API link", + "form.api_key.label.description": "API Key Label", + "form.submit.loading": "Loading…", + "form.submit.saving": "Saving…", + "time_elapsed.not_yet": "not yet", + "time_elapsed.yesterday": "yesterday", + "time_elapsed.now": "just now", + "time_elapsed.minutes": ["%d minute ago", "%d minutes ago"], + "time_elapsed.hours": ["%d hour ago", "%d hours ago"], + "time_elapsed.days": ["%d day ago", "%d days ago"], + "time_elapsed.weeks": ["%d week ago", "%d weeks ago"], + "time_elapsed.months": ["%d month ago", "%d months ago"], + "time_elapsed.years": ["%d year ago", "%d years ago"], + "alert.too_many_feeds_refresh": [ + "You have triggered too many feed refreshes. Please wait %d minute before trying again.", + "You have triggered too many feed refreshes. Please wait %d minutes before trying again." + ], + "alert.background_feed_refresh": "All feeds are being refreshed in the background. You can continue to use Miniflux while this process is running.", + "error.http_response_too_large": "The HTTP response is too large. You could increase the HTTP response size limit in the global settings (requires a server restart).", + "error.http_body_read": "Unable to read the HTTP body: %v.", + "error.http_empty_response_body": "The HTTP response body is empty.", + "error.http_empty_response": "The HTTP response is empty. Perhaps, this website is using a bot protection mechanism?", + "error.tls_error": "TLS error: %q. You could disable TLS verification in the feed settings if you would like.", + "error.network_operation": "Miniflux is not able to reach this website due to a network error: %v.", + "error.network_timeout": "This website is too slow and the request timed out: %v", + "error.http_client_error": "HTTP client error: %v.", + "error.http_not_authorized": "Access to this website is not authorized. It could be a bad username or password.", + "error.http_too_many_requests": "Miniflux generated too many requests to this website. Please, try again later or change the application configuration.", + "error.http_forbidden": "Access to this website is forbidden. Perhaps, this website has a bot protection mechanism?", + "error.http_resource_not_found": "The requested resource is not found. Please, verify the URL.", + "error.http_internal_server_error": "The website is not available at the moment due to a server error. The problem is not on Miniflux side. Please, try again later.", + "error.http_bad_gateway": "The website is not available at the moment due to a bad gateway error. The problem is not on Miniflux side. Please, try again later.", + "error.http_service_unavailable": "The website is not available at the moment due to an internal server error. The problem is not on Miniflux side. Please, try again later.", + "error.http_gateway_timeout": "The website is not available at the moment due to a gateway timeout error. The problem is not on Miniflux side. Please, try again later.", + "error.http_unexpected_status_code": "The website is not available at the moment due to an unexpected HTTP status code: %d. The problem is not on Miniflux side. Please, try again later.", + "error.database_error": "Database error: %v.", + "error.category_not_found": "This category does not exist or does not belong to this user.", + "error.duplicated_feed": "This feed already exists.", + "error.unable_to_parse_feed": "Unable to parse this feed: %v.", + "error.feed_not_found": "This feed does not exist or does not belong to this user.", + "error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.", + "error.feed_format_not_detected": "Unable to detect feed format: %v.", + "form.prefs.label.media_playback_rate": "Playback speed of the audio/video", + "error.settings_media_playback_rate_range": "Playback speed is out of range", + "enclosure_media_controls.seek": "Seek:", + "enclosure_media_controls.seek.title": "Seek %s seconds", + "enclosure_media_controls.speed": "Speed:", + "enclosure_media_controls.speed.faster": "Faster", + "enclosure_media_controls.speed.faster.title": "Faster by %sx", + "enclosure_media_controls.speed.slower": "Slower", + "enclosure_media_controls.speed.slower.title": "Slower by %sx", + "enclosure_media_controls.speed.reset": "Reset", + "enclosure_media_controls.speed.reset.title": "Reset speed to 1x" } diff --git a/internal/model/user.go b/internal/model/user.go index ad070904e55..cc0aecdc7ff 100644 --- a/internal/model/user.go +++ b/internal/model/user.go @@ -41,6 +41,7 @@ type User struct { MediaPlaybackRate float64 `json:"media_playback_rate"` BlockFilterEntryRules string `json:"block_filter_entry_rules"` KeepFilterEntryRules string `json:"keep_filter_entry_rules"` + CacheForOffline bool `json:"cache_for_offline"` } // UserCreationRequest represents the request to create a user. @@ -82,6 +83,7 @@ type UserModificationRequest struct { MediaPlaybackRate *float64 `json:"media_playback_rate"` BlockFilterEntryRules *string `json:"block_filter_entry_rules"` KeepFilterEntryRules *string `json:"keep_filter_entry_rules"` + CacheForOffline *bool `json:"cache_for_offline"` } // Patch updates the User object with the modification request. @@ -197,6 +199,9 @@ func (u *UserModificationRequest) Patch(user *User) { if u.KeepFilterEntryRules != nil { user.KeepFilterEntryRules = *u.KeepFilterEntryRules } + if u.CacheForOffline != nil { + user.CacheForOffline = *u.CacheForOffline + } } // UseTimezone converts last login date to the given timezone. diff --git a/internal/reader/sanitizer/sanitizer.go b/internal/reader/sanitizer/sanitizer.go index 38c06ce989a..f80e63011f5 100644 --- a/internal/reader/sanitizer/sanitizer.go +++ b/internal/reader/sanitizer/sanitizer.go @@ -237,7 +237,7 @@ func sanitizeAttributes(baseURL, tagName string, attributes []html.Attribute) ([ func getExtraAttributes(tagName string) ([]string, []string) { switch tagName { case "a": - return []string{"rel", "target", "referrerpolicy"}, []string{`rel="noopener noreferrer"`, `target="_blank"`, `referrerpolicy="no-referrer"`} + return []string{"rel", "target", "referrerpolicy"}, []string{`rel="noopener noreferrer"`, `target="_blank"`, `referrerpolicy="strict-origin"`} case "video", "audio": return []string{"controls"}, []string{"controls"} case "iframe": diff --git a/internal/storage/user.go b/internal/storage/user.go index 64b6d503d7d..94e3ec24684 100644 --- a/internal/storage/user.go +++ b/internal/storage/user.go @@ -96,7 +96,8 @@ func (s *Storage) CreateUser(userCreationRequest *model.UserCreationRequest) (*m mark_read_on_view, media_playback_rate, block_filter_entry_rules, - keep_filter_entry_rules + keep_filter_entry_rules, + cache_for_offline ` tx, err := s.db.Begin() @@ -140,6 +141,7 @@ func (s *Storage) CreateUser(userCreationRequest *model.UserCreationRequest) (*m &user.MediaPlaybackRate, &user.BlockFilterEntryRules, &user.KeepFilterEntryRules, + &user.CacheForOffline, ) if err != nil { tx.Rollback() @@ -204,9 +206,10 @@ func (s *Storage) UpdateUser(user *model.User) error { mark_read_on_media_player_completion=$25, media_playback_rate=$26, block_filter_entry_rules=$27, - keep_filter_entry_rules=$28 + keep_filter_entry_rules=$28, + cache_for_offline=$29 WHERE - id=$29 + id=$30 ` _, err = s.db.Exec( @@ -239,6 +242,7 @@ func (s *Storage) UpdateUser(user *model.User) error { user.MediaPlaybackRate, user.BlockFilterEntryRules, user.KeepFilterEntryRules, + user.CacheForOffline, user.ID, ) if err != nil { @@ -273,9 +277,10 @@ func (s *Storage) UpdateUser(user *model.User) error { mark_read_on_media_player_completion=$24, media_playback_rate=$25, block_filter_entry_rules=$26, - keep_filter_entry_rules=$27 + keep_filter_entry_rules=$27, + cache_for_offline=$28 WHERE - id=$28 + id=$29 ` _, err := s.db.Exec( @@ -307,6 +312,7 @@ func (s *Storage) UpdateUser(user *model.User) error { user.MediaPlaybackRate, user.BlockFilterEntryRules, user.KeepFilterEntryRules, + user.CacheForOffline, user.ID, ) @@ -360,7 +366,8 @@ func (s *Storage) UserByID(userID int64) (*model.User, error) { mark_read_on_media_player_completion, media_playback_rate, block_filter_entry_rules, - keep_filter_entry_rules + keep_filter_entry_rules, + cache_for_offline FROM users WHERE @@ -401,7 +408,8 @@ func (s *Storage) UserByUsername(username string) (*model.User, error) { mark_read_on_media_player_completion, media_playback_rate, block_filter_entry_rules, - keep_filter_entry_rules + keep_filter_entry_rules, + cache_for_offline FROM users WHERE @@ -442,7 +450,8 @@ func (s *Storage) UserByField(field, value string) (*model.User, error) { mark_read_on_media_player_completion, media_playback_rate, block_filter_entry_rules, - keep_filter_entry_rules + keep_filter_entry_rules, + cache_for_offline FROM users WHERE @@ -490,7 +499,8 @@ func (s *Storage) UserByAPIKey(token string) (*model.User, error) { u.mark_read_on_media_player_completion, media_playback_rate, u.block_filter_entry_rules, - u.keep_filter_entry_rules + u.keep_filter_entry_rules, + u.cache_for_offline FROM users u LEFT JOIN @@ -533,6 +543,7 @@ func (s *Storage) fetchUser(query string, args ...interface{}) (*model.User, err &user.MediaPlaybackRate, &user.BlockFilterEntryRules, &user.KeepFilterEntryRules, + &user.CacheForOffline, ) if err == sql.ErrNoRows { @@ -646,7 +657,9 @@ func (s *Storage) Users() (model.Users, error) { mark_read_on_media_player_completion, media_playback_rate, block_filter_entry_rules, - keep_filter_entry_rules + keep_filter_entry_rules, + media_playback_rate, + cache_for_offline FROM users ORDER BY username ASC @@ -690,6 +703,7 @@ func (s *Storage) Users() (model.Users, error) { &user.MediaPlaybackRate, &user.BlockFilterEntryRules, &user.KeepFilterEntryRules, + &user.CacheForOffline, ) if err != nil { diff --git a/internal/template/templates/common/layout.html b/internal/template/templates/common/layout.html index 13c8c652556..da3b7b38aa5 100644 --- a/internal/template/templates/common/layout.html +++ b/internal/template/templates/common/layout.html @@ -132,6 +132,7 @@ {{ if .flashErrorMessage }} {{ end }} + {{template "page_header" .}} diff --git a/internal/template/templates/views/entry.html b/internal/template/templates/views/entry.html index 0d507a28a42..42a46cc6692 100644 --- a/internal/template/templates/views/entry.html +++ b/internal/template/templates/views/entry.html @@ -4,7 +4,7 @@

- {{ .entry.Title }} + {{ .entry.Title }}

{{ if .user }}
@@ -79,7 +79,7 @@

class="page-link" target="_blank" rel="noopener noreferrer" - referrerpolicy="no-referrer" + referrerpolicy="strict-origin" data-original-link="{{ .user.MarkReadOnView }}">{{ icon "external-link" }}{{ t "entry.external_link.label" }}
  • @@ -98,7 +98,7 @@

    title="{{ t "entry.comments.title" }}" target="_blank" rel="noopener noreferrer" - referrerpolicy="no-referrer" + referrerpolicy="strict-origin" data-comments-link="true" >{{ icon "comment" }}{{ t "entry.comments.label" }}

  • @@ -232,7 +232,7 @@

    {{ end }}
    - {{ .URL | safeURL }} + {{ .URL | safeURL }} {{ if gt .Size 0 }} - {{ formatFileSize .Size }}{{ end }}

    diff --git a/internal/template/templates/views/settings.html b/internal/template/templates/views/settings.html index c584e02a994..6197d729232 100644 --- a/internal/template/templates/views/settings.html +++ b/internal/template/templates/views/settings.html @@ -123,6 +123,7 @@

    {{ t "page.settings.title" }}

    {{ if eq .form.MarkReadBehavior .const.MarkAsReadOnViewButWaitForPlayerCompletion }}checked{{end}}> {{ t "form.prefs.label.mark_read_on_view_or_media_completion" }} +
    diff --git a/internal/ui/entry_unread.go b/internal/ui/entry_unread.go index 07d9007c207..ece9c8af6bc 100644 --- a/internal/ui/entry_unread.go +++ b/internal/ui/entry_unread.go @@ -66,7 +66,7 @@ func (h *handler) showUnreadEntryPage(w http.ResponseWriter, r *http.Request) { prevEntryRoute = route.Path(h.router, "unreadEntry", "entryID", prevEntry.ID) } - if entry.ShouldMarkAsReadOnView(user) { + if entry.ShouldMarkAsReadOnView(user) && !request.IsServiceWorker(r) { entry.Status = model.EntryStatusRead } @@ -90,6 +90,7 @@ func (h *handler) showUnreadEntryPage(w http.ResponseWriter, r *http.Request) { view.Set("user", user) view.Set("hasSaveEntry", h.store.HasSaveEntry(user.ID)) view.Set("countErrorFeeds", h.store.CountUserFeedsWithErrors(user.ID)) + view.Set("useCachedVersion", r.Header.Get("X-Cache-Hit") != "") // Fetching the counter here avoid to be off by one. view.Set("countUnread", h.store.CountUnreadEntries(user.ID)) diff --git a/internal/ui/form/settings.go b/internal/ui/form/settings.go index 1b9e48ddeae..8423e6cf99b 100644 --- a/internal/ui/form/settings.go +++ b/internal/ui/form/settings.go @@ -52,6 +52,7 @@ type SettingsForm struct { MediaPlaybackRate float64 BlockFilterEntryRules string KeepFilterEntryRules string + CacheForOffline bool } // MarkAsReadBehavior returns the MarkReadBehavior from the given MarkReadOnView and MarkReadOnMediaPlayerCompletion values. @@ -119,6 +120,8 @@ func (s *SettingsForm) Merge(user *model.User) *model.User { user.MarkReadOnView = MarkReadOnView user.MarkReadOnMediaPlayerCompletion = MarkReadOnMediaPlayerCompletion + user.CacheForOffline = s.CacheForOffline + if s.Password != "" { user.Password = s.Password } @@ -205,5 +208,6 @@ func NewSettingsForm(r *http.Request) *SettingsForm { MediaPlaybackRate: mediaPlaybackRate, BlockFilterEntryRules: r.FormValue("block_filter_entry_rules"), KeepFilterEntryRules: r.FormValue("keep_filter_entry_rules"), + CacheForOffline: r.FormValue("cache_for_offline") == "1", } } diff --git a/internal/ui/settings_show.go b/internal/ui/settings_show.go index eae72a7fb2f..e04ad9715b5 100644 --- a/internal/ui/settings_show.go +++ b/internal/ui/settings_show.go @@ -46,6 +46,7 @@ func (h *handler) showSettingsPage(w http.ResponseWriter, r *http.Request) { MediaPlaybackRate: user.MediaPlaybackRate, BlockFilterEntryRules: user.BlockFilterEntryRules, KeepFilterEntryRules: user.KeepFilterEntryRules, + CacheForOffline: user.CacheForOffline, } timezones, err := h.store.Timezones() diff --git a/internal/ui/static/css/common.css b/internal/ui/static/css/common.css index c8e08412ea2..d0087cdee25 100644 --- a/internal/ui/static/css/common.css +++ b/internal/ui/static/css/common.css @@ -1,417 +1,443 @@ /* Layout */ * { - margin: 0; - padding: 0; - box-sizing: border-box; + margin: 0; + padding: 0; + box-sizing: border-box; } html { - -webkit-text-size-adjust: 100%; - text-size-adjust: 100%; + -webkit-text-size-adjust: 100%; + text-size-adjust: 100%; } body { - font-family: var(--font-family); - text-rendering: optimizeLegibility; - color: var(--body-color); - background: var(--body-background); + font-family: var(--font-family); + text-rendering: optimizeLegibility; + color: var(--body-color); + background: var(--body-background); } hr { - border: 0; - height: 0; - border-top: 1px dotted var(--hr-border-color); - padding-bottom: 10px; + border: 0; + height: 0; + border-top: 1px dotted var(--hr-border-color); + padding-bottom: 10px; } -h1, h2, h3 { - color: var(--title-color); +h1, +h2, +h3 { + color: var(--title-color); } main { - padding-left: 3px; - padding-right: 3px; - margin-bottom: 30px; + padding-left: 3px; + padding-right: 3px; + margin-bottom: 30px; } a { - color: var(--link-color); + color: var(--link-color); } a:focus { - outline: 0; - color: var(--link-focus-color); - text-decoration: none; - outline: 1px dotted #aaa; + outline: 0; + color: var(--link-focus-color); + text-decoration: none; + outline: 1px dotted #aaa; } a:hover { - color: var(--link-hover-color); - text-decoration: none; + color: var(--link-hover-color); + text-decoration: none; } .sr-only { - border: 0 !important; - clip: rect(1px, 1px, 1px, 1px) !important; - clip-path: inset(50%) !important; - height: 1px !important; - overflow: hidden !important; - margin: -1px !important; - padding: 0 !important; - position: absolute !important; - width: 1px !important; - white-space: nowrap !important; + border: 0 !important; + clip: rect(1px, 1px, 1px, 1px) !important; + clip-path: inset(50%) !important; + height: 1px !important; + overflow: hidden !important; + margin: -1px !important; + padding: 0 !important; + position: absolute !important; + width: 1px !important; + white-space: nowrap !important; } .skip-to-content-link { - --padding-size: 8px; - --border-size: 1px; + --padding-size: 8px; + --border-size: 1px; - background-color: var(--category-background-color); - color: var(--category-color); - border: var(--border-size) solid var(--category-border-color); - border-radius: 5px; - inset-inline-start: 50%; - padding: var(--padding-size); - position: absolute; - transition: translate 0.3s; - translate: -50% calc(-100% - calc(var(--padding-size) * 2) - calc(var(--border-size) * 2)); + background-color: var(--category-background-color); + color: var(--category-color); + border: var(--border-size) solid var(--category-border-color); + border-radius: 5px; + inset-inline-start: 50%; + padding: var(--padding-size); + position: absolute; + transition: translate 0.3s; + translate: -50% + calc(-100% - calc(var(--padding-size) * 2) - calc(var(--border-size) * 2)); } .skip-to-content-link:focus { - translate: -50% 0; + translate: -50% 0; } /* Header and main menu */ .header { - margin-top: 10px; - margin-bottom: 20px; + margin-top: 10px; + margin-bottom: 20px; } .header nav { - display: flex; - align-items: stretch; - flex-direction: column; + display: flex; + align-items: stretch; + flex-direction: column; } .header nav .logo svg { - padding: 5px; - inline-size: 24px; - block-size: 24px; + padding: 5px; + inline-size: 24px; + block-size: 24px; } .header nav .logo[aria-expanded="true"] svg { - rotate: 180deg; + rotate: 180deg; } .header ul.js-menu-show { - display: initial; + display: initial; } .header ul:not(.js-menu-show) { - display: none; + display: none; } .header li { - cursor: pointer; - padding-left: 10px; - line-height: 2.1em; - font-size: 1.2em; - border-bottom: 1px dotted var(--header-list-border-color); + cursor: pointer; + padding-left: 10px; + line-height: 2.1em; + font-size: 1.2em; + border-bottom: 1px dotted var(--header-list-border-color); } .header li a:hover { - color: #888; + color: #888; } .header :is(a, summary) { - font-size: 0.9em; - color: var(--header-link-color); - text-decoration: none; - border: none; - font-weight: 400; + font-size: 0.9em; + color: var(--header-link-color); + text-decoration: none; + border: none; + font-weight: 400; } .header .active a { - color: var(--header-active-link-color); - /* Note: Firefox on Windows does not show the link as bold if the value is under 600 */ - font-weight: 600; + color: var(--header-active-link-color); + /* Note: Firefox on Windows does not show the link as bold if the value is under 600 */ + font-weight: 600; } .header a:focus { - color: var(--header-link-focus-color); + color: var(--header-link-focus-color); } /* Page header and footer*/ .page-header { - padding-inline: 3px; - margin-bottom: 25px; + padding-inline: 3px; + margin-bottom: 25px; } .page-footer { - margin-bottom: 10px; + margin-bottom: 10px; } .page-header h1 { - font-weight: 500; - border-bottom: 1px dotted var(--page-header-title-border-color); - font-size: 1.5rem; + font-weight: 500; + border-bottom: 1px dotted var(--page-header-title-border-color); + font-size: 1.5rem; } .page-header h1 a { - text-decoration: none; - color: var(--page-header-title-color); + text-decoration: none; + color: var(--page-header-title-color); } .page-header h1 a:hover, .page-header h1 a:focus { - color: #666; + color: #666; } .page-header li, .page-footer li { - list-style-type: none; - line-height: 1.8em; - white-space: nowrap; + list-style-type: none; + line-height: 1.8em; + white-space: nowrap; } .page-header ul a .icon { - padding-bottom: 2px; + padding-bottom: 2px; } .page-header-action-form { - display: inline-flex; + display: inline-flex; } :is(.page-button, .page-link) { - color: var(--link-color); - border: none; - background-color: transparent; - font-size: 1rem; - cursor: pointer; + color: var(--link-color); + border: none; + background-color: transparent; + font-size: 1rem; + cursor: pointer; - &:is(:hover, :focus) { - color: var(--link-hover-color); - } + &:is(:hover, :focus) { + color: var(--link-hover-color); + } } .page-button:active { - translate: 1px 1px; + translate: 1px 1px; } /* Logo */ .logo { - text-align: center; - display: flex; - justify-content: center; + text-align: center; + display: flex; + justify-content: center; } .logo a { - color: var(--logo-color); - letter-spacing: 1px; - display: flex; - align-items: center; + color: var(--logo-color); + letter-spacing: 1px; + display: flex; + align-items: center; } .logo a:hover { - color: #339966; + color: #339966; } .logo a span { - color: #339966; + color: #339966; } .logo a:hover span { - color: var(--logo-hover-color-span); + color: var(--logo-hover-color-span); } /* PWA prompt */ #prompt-home-screen { - display: none; - position: fixed; - bottom: 0; - right: 0; - width: 100%; - text-align: center; - background: #000; - opacity: 85%; + display: none; + position: fixed; + bottom: 0; + right: 0; + width: 100%; + text-align: center; + background: #000; + opacity: 85%; } #btn-add-to-home-screen { - text-decoration: none; - line-height: 30px; - color: #fff; - background-color: transparent; - border: 0; + text-decoration: none; + line-height: 30px; + color: #fff; + background-color: transparent; + border: 0; } #btn-add-to-home-screen:hover { - color: red; + color: red; } /* Notification - "Toast" */ #toast-wrapper { - visibility: hidden; - opacity: 0; - position: fixed; - left: 0; - bottom: 10%; - color: #fff; - width: 100%; - text-align: center; + visibility: hidden; + opacity: 0; + position: fixed; + left: 0; + bottom: 10%; + color: #fff; + width: 100%; + text-align: center; } #toast-msg { - background-color: rgba(0,0,0,0.7); - padding-bottom: 4px; - padding-left: 4px; - padding-right: 5px; - border-radius: 5px; + background-color: rgba(0, 0, 0, 0.7); + padding-bottom: 4px; + padding-left: 4px; + padding-right: 5px; + border-radius: 5px; } .toast-animate { - animation: toastKeyFrames 2s; + animation: toastKeyFrames 2s; } @keyframes toastKeyFrames { - 0% {visibility: hidden; opacity: 0;} - 25% {visibility: visible; opacity: 1; z-index: 9999} - 50% {visibility: visible; opacity: 1; z-index: 9999} - 75% {visibility: visible; opacity: 1; z-index: 9999} - 100% {visibility: hidden; opacity: 0; z-index: 0} + 0% { + visibility: hidden; + opacity: 0; + } + 25% { + visibility: visible; + opacity: 1; + z-index: 9999; + } + 50% { + visibility: visible; + opacity: 1; + z-index: 9999; + } + 75% { + visibility: visible; + opacity: 1; + z-index: 9999; + } + 100% { + visibility: hidden; + opacity: 0; + z-index: 0; + } } /* Hide the logo when there is not enough space to display menus when using languages more verbose than English */ @media (min-width: 620px) and (max-width: 830px) { - .logo { - display: none; - } + .logo { + display: none; + } } @media (min-width: 830px) { - .logo { - padding-right: 8px; - } + .logo { + padding-right: 8px; + } } @media (min-width: 620px) { - body { - margin: auto; - max-width: 820px; - } - - .header { - padding-left: 3px; - } - - .header li { - display: inline-block; - padding: 0; - padding-right: 12px; - line-height: normal; - border: none; - font-size: 1.0em; - } - - .header nav { - align-items: end; - flex-direction: row; - } - - .header .logo svg { - display: none; - } - - .header ul:not(.js-menu-show), .header ul.js-menu-show { - display: revert; - } - - .header :is(a, summary):hover { - color: var(--header-link-hover-color); - } - - .page-header li, - .page-footer li { - display: inline; - padding-right: 15px; - } - - .pagination-backward, - .pagination-forward { - display: flex; - } + body { + margin: auto; + max-width: 820px; + } + + .header { + padding-left: 3px; + } + + .header li { + display: inline-block; + padding: 0; + padding-right: 12px; + line-height: normal; + border: none; + font-size: 1em; + } + + .header nav { + align-items: end; + flex-direction: row; + } + + .header .logo svg { + display: none; + } + + .header ul:not(.js-menu-show), + .header ul.js-menu-show { + display: revert; + } + + .header :is(a, summary):hover { + color: var(--header-link-hover-color); + } + + .page-header li, + .page-footer li { + display: inline; + padding-right: 15px; + } + + .pagination-backward, + .pagination-forward { + display: flex; + } } /* Tables */ table { - width: 100%; - border-collapse: collapse; + width: 100%; + border-collapse: collapse; } -table, th, td { - border: 1px solid var(--table-border-color); +table, +th, +td { + border: 1px solid var(--table-border-color); } -th, td { - padding: 5px; - text-align: left; +th, +td { + padding: 5px; + text-align: left; } td { - vertical-align: top; + vertical-align: top; } th { - background: var(--table-th-background); - color: var(--table-th-color); - font-weight: 400; + background: var(--table-th-background); + color: var(--table-th-color); + font-weight: 400; } tr:hover { - color: var(--table-tr-hover-color); - background-color: var(--table-tr-hover-background-color); + color: var(--table-tr-hover-color); + background-color: var(--table-tr-hover-background-color); } .column-40 { - width: 40%; + width: 40%; } .column-25 { - width: 25%; + width: 25%; } .column-20 { - width: 20%; + width: 20%; } /* Forms */ fieldset { - border: 1px dotted #ddd; - padding: 8px; - margin-bottom: 20px; + border: 1px dotted #ddd; + padding: 8px; + margin-bottom: 20px; } legend { - font-weight: 500; - padding-left: 3px; - padding-right: 3px; + font-weight: 500; + padding-left: 3px; + padding-right: 3px; } label { - cursor: pointer; - display: block; + cursor: pointer; + display: block; } .radio-group { - line-height: 1.9em; + line-height: 1.9em; } div.radio-group label { - display: inline-block; + display: inline-block; } select { - margin-bottom: 15px; + margin-bottom: 15px; } input[type="search"], @@ -419,15 +445,15 @@ input[type="url"], input[type="password"], input[type="text"], input[type="number"] { - color: var(--input-color); - background: var(--input-background); - border: var(--input-border); - padding: 3px; - line-height: 20px; - width: 250px; - font-size: 99%; - margin-top: 5px; - appearance: none; + color: var(--input-color); + background: var(--input-background); + border: var(--input-border); + padding: 3px; + line-height: 20px; + width: 250px; + font-size: 99%; + margin-top: 5px; + appearance: none; } input[type="search"]:focus, @@ -435,865 +461,872 @@ input[type="url"]:focus, input[type="password"]:focus, input[type="text"]:focus, input[type="number"]:focus { - color: var(--input-focus-color); - border-color: var(--input-focus-border-color); - outline: 0; - box-shadow: var(--input-focus-box-shadow); + color: var(--input-focus-color); + border-color: var(--input-focus-border-color); + outline: 0; + box-shadow: var(--input-focus-box-shadow); } #form-entries-per-page { - max-width: 80px; + max-width: 80px; } input[type="checkbox"] { - margin-top: 10px; - margin-bottom: 10px; + margin-top: 10px; + margin-bottom: 10px; } textarea { - width: 350px; - color: var(--input-color); - background: var(--input-background); - border: var(--input-border); - padding: 3px; - margin-bottom: 10px; - margin-top: 5px; - appearance: none; + width: 350px; + color: var(--input-color); + background: var(--input-background); + border: var(--input-border); + padding: 3px; + margin-bottom: 10px; + margin-top: 5px; + appearance: none; } textarea:focus { - color: var(--input-focus-color); - border-color: var(--input-focus-border-color); - outline: 0; - box-shadow: var(--input-focus-box-shadow); + color: var(--input-focus-color); + border-color: var(--input-focus-border-color); + outline: 0; + box-shadow: var(--input-focus-box-shadow); } input::placeholder { - color: var(--input-placeholder-color); - padding-top: 2px; + color: var(--input-placeholder-color); + padding-top: 2px; } .form-label-row { - display: flex; + display: flex; } .form-help { - font-size: 0.9em; - color: brown; - margin-bottom: 15px; + font-size: 0.9em; + color: brown; + margin-bottom: 15px; } .form-section { - border-left: 2px dotted #ddd; - padding-left: 20px; - margin-left: 10px; + border-left: 2px dotted #ddd; + padding-left: 20px; + margin-left: 10px; } details > summary { - outline: none; - cursor: pointer; + outline: none; + cursor: pointer; } .details-content { - margin-top: 15px; + margin-top: 15px; } /* Buttons */ a.button { - text-decoration: none; + text-decoration: none; } .button { - display: inline-block; - appearance: none; - font-size: 1.1em; - cursor: pointer; - padding: 3px 10px; - border: 1px solid; - border-radius: unset; + display: inline-block; + appearance: none; + font-size: 1.1em; + cursor: pointer; + padding: 3px 10px; + border: 1px solid; + border-radius: unset; } .button-primary { - border-color: var(--button-primary-border-color); - background: var(--button-primary-background); - color: var(--button-primary-color); + border-color: var(--button-primary-border-color); + background: var(--button-primary-background); + color: var(--button-primary-color); } a.button-primary:hover, a.button-primary:focus, .button-primary:hover, .button-primary:focus { - border-color: var(--button-primary-focus-border-color); - background: var(--button-primary-focus-background); - color: var(--button-primary-color); + border-color: var(--button-primary-focus-border-color); + background: var(--button-primary-focus-background); + color: var(--button-primary-color); } .button-danger { - border-color: #b0281a; - background: #d14836; - color: #fff; + border-color: #b0281a; + background: #d14836; + color: #fff; } .button-danger:hover, .button-danger:focus { - color: #fff; - background: #c53727; + color: #fff; + background: #c53727; } .button:disabled { - color: #ccc; - background: #f7f7f7; - border-color: #ccc; + color: #ccc; + background: #f7f7f7; + border-color: #ccc; } .buttons { - margin-top: 10px; - margin-bottom: 20px; + margin-top: 10px; + margin-bottom: 20px; } fieldset .buttons { - margin-bottom: 0; + margin-bottom: 0; } /* Alerts */ .alert { - padding: 8px 35px 8px 14px; - margin-bottom: 20px; - color: var(--alert-color); - background-color: var(--alert-background-color); - border: 1px solid var(--alert-border-color); - border-radius: 4px; - overflow: auto; + padding: 8px 35px 8px 14px; + margin-bottom: 20px; + color: var(--alert-color); + background-color: var(--alert-background-color); + border: 1px solid var(--alert-border-color); + border-radius: 4px; + overflow: auto; } .alert h3 { - margin-top: 0; - margin-bottom: 15px; + margin-top: 0; + margin-bottom: 15px; } .alert-success { - color: var(--alert-success-color); - background-color: var(--alert-success-background-color); - border-color: var(--alert-success-border-color); + color: var(--alert-success-color); + background-color: var(--alert-success-background-color); + border-color: var(--alert-success-border-color); } .alert-error { - color: var(--alert-error-color); - background-color: var(--alert-error-background-color); - border-color: var(--alert-error-border-color); + color: var(--alert-error-color); + background-color: var(--alert-error-background-color); + border-color: var(--alert-error-border-color); } .alert-error h3, .alert-error a { - color: var(--alert-error-color); + color: var(--alert-error-color); } .alert-info { - color: var(--alert-info-color); - background-color: var(--alert-info-background-color); - border-color: var(--alert-info-border-color); + color: var(--alert-info-color); + background-color: var(--alert-info-background-color); + border-color: var(--alert-info-border-color); } /* Panel */ .panel { - color: var(--panel-color); - background-color: var(--panel-background); - border: 1px solid var(--panel-border-color); - border-radius: 5px; - padding: 10px; - margin-bottom: 15px; + color: var(--panel-color); + background-color: var(--panel-background); + border: 1px solid var(--panel-border-color); + border-radius: 5px; + padding: 10px; + margin-bottom: 15px; } .panel h3 { - font-weight: 500; - margin-top: 0; - margin-bottom: 20px; + font-weight: 500; + margin-top: 0; + margin-bottom: 20px; } .panel ul { - margin-left: 30px; + margin-left: 30px; } /* Modals */ template { - display: none; + display: none; } #modal-left { - position: fixed; - top: 0; - left: 0; - bottom: 0; - width: 380px; - overflow: auto; - color: var(--modal-color); - background: var(--modal-background); - box-shadow: var(--modal-box-shadow); - padding: 5px; - padding-top: 30px; + position: fixed; + top: 0; + left: 0; + bottom: 0; + width: 380px; + overflow: auto; + color: var(--modal-color); + background: var(--modal-background); + box-shadow: var(--modal-box-shadow); + padding: 5px; + padding-top: 30px; } #modal-left h3 { - font-weight: 400; - margin: 0; + font-weight: 400; + margin: 0; } .btn-close-modal { - position: absolute; - top: 0; - right: 0; - font-size: 1.7em; - color: #ccc; - padding:0 .2em; - margin: 10px; - text-decoration: none; - background-color: transparent; - border: none; + position: absolute; + top: 0; + right: 0; + font-size: 1.7em; + color: #ccc; + padding: 0 0.2em; + margin: 10px; + text-decoration: none; + background-color: transparent; + border: none; } .btn-close-modal:hover { - color: #999; + color: #999; } /* Keyboard Shortcuts */ .keyboard-shortcuts li { - margin-left: 25px; - list-style-type: square; - color: var(--keyboard-shortcuts-li-color); - font-size: 0.95em; - line-height: 1.45em; + margin-left: 25px; + list-style-type: square; + color: var(--keyboard-shortcuts-li-color); + font-size: 0.95em; + line-height: 1.45em; } .keyboard-shortcuts p { - line-height: 1.9em; + line-height: 1.9em; } /* Login form */ .login-form { - margin: 50px auto 0; - max-width: 300px; + margin: 50px auto 0; + max-width: 300px; } .webauthn { - margin-bottom: 20px; + margin-bottom: 20px; } /* Counters */ .unread-counter-wrapper, .error-feeds-counter-wrapper { - font-size: 0.9em; - font-weight: 300; - color: var(--counter-color); + font-size: 0.9em; + font-weight: 300; + color: var(--counter-color); } /* Category label */ .category { - font-size: 0.75em; - background-color: var(--category-background-color); - border: 1px solid var(--category-border-color); - border-radius: 5px; - margin-left: 0.25em; - padding: 1px 0.4em 1px 0.4em; - white-space: nowrap; - color: var(--category-color); + font-size: 0.75em; + background-color: var(--category-background-color); + border: 1px solid var(--category-border-color); + border-radius: 5px; + margin-left: 0.25em; + padding: 1px 0.4em 1px 0.4em; + white-space: nowrap; + color: var(--category-color); } .category a { - color: var(--category-link-color); - text-decoration: none; + color: var(--category-link-color); + text-decoration: none; } .category a:hover, .category a:focus { - color: var(--category-link-hover-color); + color: var(--category-link-hover-color); } - .category-item-total { - color: var(--body-color); + color: var(--body-color); } /* Pagination */ .pagination { - font-size: 1.1em; - display: flex; - align-items: center; - justify-content: space-between; + font-size: 1.1em; + display: flex; + align-items: center; + justify-content: space-between; } .pagination-top { - padding-bottom: 8px; + padding-bottom: 8px; } .pagination-bottom { - padding-top: 8px; + padding-top: 8px; } .pagination-entry-top { - padding-top: 8px; + padding-top: 8px; } .pagination-entry-bottom { - border-top: 1px dotted var(--pagination-border-color); - margin-bottom: 15px; - margin-top: 50px; - padding-top: 8px; + border-top: 1px dotted var(--pagination-border-color); + margin-bottom: 15px; + margin-top: 50px; + padding-top: 8px; } .pagination > div.pagination-backward > div { - padding-right: 15px; + padding-right: 15px; } .pagination > div.pagination-forward > div { - padding-left: 15px; + padding-left: 15px; } -.pagination-next, .pagination-last { - text-align: right; +.pagination-next, +.pagination-last { + text-align: right; } .pagination-next:after { - content: " ›"; + content: " ›"; } .pagination-last:after { - content: " »"; + content: " »"; } .pagination-prev:before { - content: "‹ "; + content: "‹ "; } .pagination-first:before { - content: "« "; + content: "« "; } .pagination a { - color: var(--pagination-link-color); + color: var(--pagination-link-color); } .pagination a:hover, .pagination a:focus { - text-decoration: none; + text-decoration: none; } /* List view */ .item { - border: 1px dotted var(--item-border-color); - margin-bottom: 20px; - padding: var(--item-padding); - overflow: hidden; + border: 1px dotted var(--item-border-color); + margin-bottom: 20px; + padding: var(--item-padding); + overflow: hidden; } .item.current-item { - border: var(--current-item-border-width) solid var(--current-item-border-color); - padding: 3px; - box-shadow: var(--current-item-box-shadow); + border: var(--current-item-border-width) solid + var(--current-item-border-color); + padding: 3px; + box-shadow: var(--current-item-box-shadow); } .item.current-item:focus { - outline: none; + outline: none; } - .item-header { - font-size: 1rem; + font-size: 1rem; } .item-header span { - font-weight: normal; - display: inline-block; + font-weight: normal; + display: inline-block; } .item-title { - font-size: 1rem; - margin: 0; - display: inline; + font-size: 1rem; + margin: 0; + display: inline; } .item-title a { - text-decoration: none; - font-weight: var(--item-title-link-font-weight); - font-size: inherit; + text-decoration: none; + font-weight: var(--item-title-link-font-weight); + font-size: inherit; } .feed-entries-counter { - display: inline-flex; - gap: 2px; - align-items: center; + display: inline-flex; + gap: 2px; + align-items: center; } .item-status-read .item-title a { - color: var(--item-status-read-title-link-color); + color: var(--item-status-read-title-link-color); } .item-meta { - color: var(--item-meta-focus-color); - font-size: 0.8em; + color: var(--item-meta-focus-color); + font-size: 0.8em; } .item-meta a { - color: #777; - text-decoration: none; + color: #777; + text-decoration: none; } .item-meta :is(a:is(:focus, :hover), button:is(:focus, :hover)) { - color: #333; + color: #333; } .item-meta ul { - margin-top: 5px; + margin-top: 5px; } .item-meta li { - display: inline-block; + display: inline-block; } .item-meta-info { - font-size: 0.85em; + font-size: 0.85em; } .item-meta-info li:not(:last-child):after { - content: "|"; - color: var(--item-meta-li-color); + content: "|"; + color: var(--item-meta-li-color); } .item-meta-icons li { - margin-right: 8px; - margin-top: 4px; + margin-right: 8px; + margin-top: 4px; } .item-meta-icons li:last-child { - margin-right: 0; + margin-right: 0; } .item-meta-icons li > :is(a, button) { - color: #777; - text-decoration: none; - font-size: 0.8rem; - border: none; - background-color: transparent; - cursor: pointer; + color: #777; + text-decoration: none; + font-size: 0.8rem; + border: none; + background-color: transparent; + cursor: pointer; } .item-meta-icons a span { - text-decoration: underline; + text-decoration: underline; } .item-meta-icons button:active { - translate: 1px 1px; + translate: 1px 1px; } .items { - overflow-x: hidden; - touch-action: pan-y; + overflow-x: hidden; + touch-action: pan-y; } .hide-read-items .item-status-read:not(.current-item) { - display: none; + display: none; } .entry-swipe { - transition-property: transform; - transition-duration: 0s; - transition-timing-function: ease-out; + transition-property: transform; + transition-duration: 0s; + transition-timing-function: ease-out; } /* Feeds list */ article.feed-parsing-error { - background-color: var(--feed-parsing-error-background-color); - border-style: var(--feed-parsing-error-border-style); - border-color: var(--feed-parsing-error-border-color); + background-color: var(--feed-parsing-error-background-color); + border-style: var(--feed-parsing-error-border-style); + border-color: var(--feed-parsing-error-border-color); } article.feed-has-unread { - background-color: var(--feed-has-unread-background-color); - border-style: var(--feed-has-unread-border-style); - border-color: var(--feed-has-unread-border-color); + background-color: var(--feed-has-unread-background-color); + border-style: var(--feed-has-unread-border-style); + border-color: var(--feed-has-unread-border-color); } .parsing-error { - font-size: 0.85em; - margin-top: 2px; - color: var(--parsing-error-color); + font-size: 0.85em; + margin-top: 2px; + color: var(--parsing-error-color); } .parsing-error-count { - cursor: pointer; + cursor: pointer; } /* Categories list */ article.category-has-unread { - background-color: var(--category-has-unread-background-color); - border-style: var(--category-has-unread-border-style); - border-color: var(--category-has-unread-border-color); + background-color: var(--category-has-unread-background-color); + border-style: var(--category-has-unread-border-style); + border-color: var(--category-has-unread-border-color); } /* Icons */ .icon, .icon-label { - vertical-align: middle; - display: inline-block; - padding-right: 2px; + vertical-align: middle; + display: inline-block; + padding-right: 2px; } .icon { - width: 16px; - height: 16px; + width: 16px; + height: 16px; } /* Entry view */ .entry header { - padding-bottom: 5px; - padding-inline: 5px; - border-bottom: 1px dotted var(--entry-header-border-color); + padding-bottom: 5px; + padding-inline: 5px; + border-bottom: 1px dotted var(--entry-header-border-color); } .entry header h1 { - font-size: 2.0em; - line-height: 1.25em; - margin: 5px 0 30px 0; - overflow-wrap: break-word; + font-size: 2em; + line-height: 1.25em; + margin: 5px 0 30px 0; + overflow-wrap: break-word; } .entry header h1 a { - text-decoration: none; - color: var(--entry-header-title-link-color); + text-decoration: none; + color: var(--entry-header-title-link-color); } .entry header h1 a:hover, .entry header h1 a:focus { - color: #666; + color: #666; } .entry-actions { - margin-bottom: 20px; + margin-bottom: 20px; } .entry-actions a span { - text-decoration: underline; + text-decoration: underline; } .entry-actions li { - display: inline-block; - margin-right: 15px; - line-height: 1.7em; + display: inline-block; + margin-right: 15px; + line-height: 1.7em; } .entry-meta { - font-size: 0.95em; - margin: 0 0 20px; - color: #666; - overflow-wrap: break-word; + font-size: 0.95em; + margin: 0 0 20px; + color: #666; + overflow-wrap: break-word; } .entry-tags { - margin-top: 20px; - margin-bottom: 20px; + margin-top: 20px; + margin-bottom: 20px; } .entry-tags strong { - font-weight: 600; + font-weight: 600; } .entry-website img { - vertical-align: top; + vertical-align: top; } .entry-website a { - color: #666; - vertical-align: top; - text-decoration: none; + color: #666; + vertical-align: top; + text-decoration: none; } .entry-website a:hover, .entry-website a:focus { - text-decoration: underline; + text-decoration: underline; } .entry-date { - font-size: 0.65em; - font-style: italic; - color: #555; + font-size: 0.65em; + font-style: italic; + color: #555; } .entry-content { - padding-top: 15px; - font-size: 1.2em; - font-weight: var(--entry-content-font-weight); - font-family: var(--entry-content-font-family); - color: var(--entry-content-color); - line-height: 1.4em; - overflow-wrap: break-word; - touch-action: pan-y pinch-zoom; -} - -.entry-content h1, h2, h3, h4, h5, h6 { - margin-top: 15px; - margin-bottom: 10px; + padding-top: 15px; + font-size: 1.2em; + font-weight: var(--entry-content-font-weight); + font-family: var(--entry-content-font-family); + color: var(--entry-content-color); + line-height: 1.4em; + overflow-wrap: break-word; + touch-action: pan-y pinch-zoom; +} + +.entry-content h1, +h2, +h3, +h4, +h5, +h6 { + margin-top: 15px; + margin-bottom: 10px; } .entry-content iframe, .entry-content video, .entry-content img { - max-width: 100%; + max-width: 100%; } .entry-content img { - height: auto; + height: auto; } .entry-content figure { - margin-top: 15px; - margin-bottom: 15px; + margin-top: 15px; + margin-bottom: 15px; } .entry-content figure img { - border: 1px solid #000; + border: 1px solid #000; } .entry-content figcaption { - font-size: 0.75em; - text-transform: uppercase; - color: #777; + font-size: 0.75em; + text-transform: uppercase; + color: #777; } .entry-content p { - margin-top: 10px; - margin-bottom: 15px; + margin-top: 10px; + margin-bottom: 15px; } .entry-content a { - overflow-wrap: break-word; + overflow-wrap: break-word; } .entry-content a:visited { - color: var(--link-visited-color); + color: var(--link-visited-color); } .entry-content dt { - font-weight: 500; - margin-top: 15px; - color: #555; + font-weight: 500; + margin-top: 15px; + color: #555; } .entry-content dd { - margin-left: 15px; - margin-top: 5px; - padding-left: 20px; - border-left: 3px solid #ddd; - color: #777; - font-weight: 300; - line-height: 1.4em; + margin-left: 15px; + margin-top: 5px; + padding-left: 20px; + border-left: 3px solid #ddd; + color: #777; + font-weight: 300; + line-height: 1.4em; } .entry-content blockquote { - border-left: 4px solid #ddd; - padding-left: 25px; - margin-left: 20px; - margin-top: 20px; - margin-bottom: 20px; - line-height: 1.4em; - font-family: var(--entry-content-quote-font-family); + border-left: 4px solid #ddd; + padding-left: 25px; + margin-left: 20px; + margin-top: 20px; + margin-bottom: 20px; + line-height: 1.4em; + font-family: var(--entry-content-quote-font-family); } .entry-content q { - color: var(--entry-content-quote-color); - font-family: var(--entry-content-quote-font-family); - font-style: italic; + color: var(--entry-content-quote-color); + font-family: var(--entry-content-quote-font-family); + font-style: italic; } .entry-content q:before { - content: "“"; + content: "“"; } .entry-content q:after { - content: "”"; + content: "”"; } .entry-content pre { - padding: 5px; - overflow: auto; - overflow-wrap: initial; - border-width: 1px; - border-style: solid; + padding: 5px; + overflow: auto; + overflow-wrap: initial; + border-width: 1px; + border-style: solid; } .entry-content pre, .entry-content code { - color: var(--entry-content-code-color); - background: var(--entry-content-code-background); - border-color: var(--entry-content-code-border-color); + color: var(--entry-content-code-color); + background: var(--entry-content-code-background); + border-color: var(--entry-content-code-border-color); } .entry-content table { - max-width: 100%; + max-width: 100%; } .entry-content ul, .entry-content ol { - margin-left: 30px; - margin-top: 15px; - margin-bottom: 15px; + margin-left: 30px; + margin-top: 15px; + margin-bottom: 15px; } .entry-content li ul, .entry-content li ol { - margin-top: 0; - margin-bottom: 0; + margin-top: 0; + margin-bottom: 0; } .entry-content ul { - list-style-type: square; + list-style-type: square; } .entry-content strong { - font-weight: 600; + font-weight: 600; } .entry-content sub, .entry-content sup { - font-size: 75%; - line-height: 0; - position: relative; - vertical-align: baseline; + font-size: 75%; + line-height: 0; + position: relative; + vertical-align: baseline; } .entry-content sub { - bottom: -0.25em; + bottom: -0.25em; } .entry-content sup { - top: -0.5em; + top: -0.5em; } .entry-content abbr { - cursor: pointer; - text-decoration: none; - border-bottom: 1px dashed var(--entry-content-abbr-border-color); + cursor: pointer; + text-decoration: none; + border-bottom: 1px dashed var(--entry-content-abbr-border-color); } .entry-content aside { - width: 30%; - padding: 1ch; - margin-left: 15px; - float: right; - font-style: italic; - border: dotted var(--entry-content-aside-border-color) 2px; + width: 30%; + padding: 1ch; + margin-left: 15px; + float: right; + font-style: italic; + border: dotted var(--entry-content-aside-border-color) 2px; } details.entry-enclosures { - margin-top: 25px; + margin-top: 25px; } .entry-enclosures summary { - font-weight: 500; - font-size: 1.2em; + font-weight: 500; + font-size: 1.2em; } .entry-enclosure { - border: 1px dotted var(--entry-enclosure-border-color); - padding: 5px; - margin-top: 10px; - max-width: 100%; + border: 1px dotted var(--entry-enclosure-border-color); + padding: 5px; + margin-top: 10px; + max-width: 100%; } .entry-enclosure-download { - font-size: 0.85em; - overflow-wrap: break-word; + font-size: 0.85em; + overflow-wrap: break-word; } .enclosure-video video, .enclosure-image img { - max-width: 100%; + max-width: 100%; } /* Confirmation */ .confirm { - font-weight: 500; - color: #ed2d04; + font-weight: 500; + color: #ed2d04; } .confirm button { - color: #ed2d04; - border: none; - background-color: transparent; - cursor: pointer; - font-size: inherit; + color: #ed2d04; + border: none; + background-color: transparent; + cursor: pointer; + font-size: inherit; } .loading { - font-style: italic; + font-style: italic; } /* Bookmarlet */ .bookmarklet { - border: 1px dashed #ccc; - border-radius: 5px; - padding: 15px; - margin: 15px; - text-align: center; + border: 1px dashed #ccc; + border-radius: 5px; + padding: 15px; + margin: 15px; + text-align: center; } .bookmarklet a { - font-weight: 600; - text-decoration: none; - font-size: 1.2em; + font-weight: 600; + text-decoration: none; + font-size: 1.2em; } .disabled { - opacity: 20%; + opacity: 20%; } -audio, video { - width: 100%; +audio, +video { + width: 100%; } -.media-controls{ - font-size: .9em; - display: flex; - flex-wrap: wrap; +.media-controls { + font-size: 0.9em; + display: flex; + flex-wrap: wrap; } -.media-controls .media-control-label{ - line-height: 1em; +.media-controls .media-control-label { + line-height: 1em; } -.media-controls>div{ - display: flex; - flex-wrap: nowrap; - justify-content:center; - min-width: 50%; - align-items: center; +.media-controls > div { + display: flex; + flex-wrap: nowrap; + justify-content: center; + min-width: 50%; + align-items: center; } -.media-controls>div>*{ - padding-left:12px; +.media-controls > div > * { + padding-left: 12px; } -.media-controls>div>*:first-child{ - padding-left:0; +.media-controls > div > *:first-child { + padding-left: 0; } -.media-controls span.speed-indicator{ - /*monospace to ensure constant width even when value change. JS ensure the value is always on 4 characters (in most cases) +.media-controls span.speed-indicator { + /*monospace to ensure constant width even when value change. JS ensure the value is always on 4 characters (in most cases) This reduce ui flickering due to element moving around a bit */ - font-family: monospace; + font-family: monospace; } .integration-form summary { - font-weight: 700; + font-weight: 700; } .integration-form details { - margin-bottom: 15px; + margin-bottom: 15px; } .integration-form details .form-section { - margin-top: 15px; + margin-top: 15px; } -.hidden { - display: none; +.hidden, +.offline-hidden { + display: none; } diff --git a/internal/ui/static/js/app.js b/internal/ui/static/js/app.js index d2732fff863..da011545c5b 100644 --- a/internal/ui/static/js/app.js +++ b/internal/ui/static/js/app.js @@ -1,144 +1,161 @@ // OnClick attaches a listener to the elements that match the selector. function onClick(selector, callback, noPreventDefault) { - document.querySelectorAll(selector).forEach((element) => { - element.onclick = (event) => { - if (!noPreventDefault) { - event.preventDefault(); - } - callback(event); - }; - }); + document.querySelectorAll(selector).forEach((element) => { + element.onclick = (event) => { + if (!noPreventDefault) { + event.preventDefault(); + } + callback(event); + }; + }); } function onAuxClick(selector, callback, noPreventDefault) { - document.querySelectorAll(selector).forEach((element) => { - element.onauxclick = (event) => { - if (!noPreventDefault) { - event.preventDefault(); - } - callback(event); - }; - }); + document.querySelectorAll(selector).forEach((element) => { + element.onauxclick = (event) => { + if (!noPreventDefault) { + event.preventDefault(); + } + callback(event); + }; + }); } // make logo element as button on mobile layout function checkMenuToggleModeByLayout() { - const logoElement = document.querySelector(".logo"); - if (!logoElement) return; - - const homePageLinkElement = document.querySelector(".logo > a"); - - if (document.documentElement.clientWidth < 620) { - const navMenuElement = document.getElementById("header-menu"); - const navMenuElementIsExpanded = navMenuElement.classList.contains("js-menu-show"); - const logoToggleButtonLabel = logoElement.getAttribute("data-toggle-button-label"); - logoElement.setAttribute("role", "button"); - logoElement.setAttribute("tabindex", "0"); - logoElement.setAttribute("aria-label", logoToggleButtonLabel); - logoElement.setAttribute("aria-expanded", navMenuElementIsExpanded?"true":"false"); - homePageLinkElement.setAttribute("tabindex", "-1"); - } else { - logoElement.removeAttribute("role"); - logoElement.removeAttribute("tabindex"); - logoElement.removeAttribute("aria-expanded"); - logoElement.removeAttribute("aria-label"); - homePageLinkElement.removeAttribute("tabindex"); - } + const logoElement = document.querySelector(".logo"); + if (!logoElement) return; + + const homePageLinkElement = document.querySelector(".logo > a"); + + if (document.documentElement.clientWidth < 620) { + const navMenuElement = document.getElementById("header-menu"); + const navMenuElementIsExpanded = + navMenuElement.classList.contains("js-menu-show"); + const logoToggleButtonLabel = logoElement.getAttribute( + "data-toggle-button-label", + ); + logoElement.setAttribute("role", "button"); + logoElement.setAttribute("tabindex", "0"); + logoElement.setAttribute("aria-label", logoToggleButtonLabel); + logoElement.setAttribute( + "aria-expanded", + navMenuElementIsExpanded ? "true" : "false", + ); + homePageLinkElement.setAttribute("tabindex", "-1"); + } else { + logoElement.removeAttribute("role"); + logoElement.removeAttribute("tabindex"); + logoElement.removeAttribute("aria-expanded"); + logoElement.removeAttribute("aria-label"); + homePageLinkElement.removeAttribute("tabindex"); + } } function fixVoiceOverDetailsSummaryBug() { - document.querySelectorAll("details").forEach((details) => { - const summaryElement = details.querySelector("summary"); - summaryElement.setAttribute("role", "button"); - summaryElement.setAttribute("aria-expanded", details.open? "true": "false"); - - details.addEventListener("toggle", () => { - summaryElement.setAttribute("aria-expanded", details.open? "true": "false"); - }); + document.querySelectorAll("details").forEach((details) => { + const summaryElement = details.querySelector("summary"); + summaryElement.setAttribute("role", "button"); + summaryElement.setAttribute( + "aria-expanded", + details.open ? "true" : "false", + ); + + details.addEventListener("toggle", () => { + summaryElement.setAttribute( + "aria-expanded", + details.open ? "true" : "false", + ); }); + }); } // Show and hide the main menu on mobile devices. function toggleMainMenu(event) { - if (event.type === "keydown" && !(event.key === "Enter" || event.key === " ")) { - return; - } - - if (event.currentTarget.getAttribute("role")) { - event.preventDefault(); - } - - const menu = document.querySelector(".header nav ul"); - const menuToggleButton = document.querySelector(".logo"); - if (menu.classList.contains("js-menu-show")) { - menu.classList.remove("js-menu-show"); - menuToggleButton.setAttribute("aria-expanded", false); - } else { - menu.classList.add("js-menu-show"); - menuToggleButton.setAttribute("aria-expanded", true); - } + if ( + event.type === "keydown" && + !(event.key === "Enter" || event.key === " ") + ) { + return; + } + + if (event.currentTarget.getAttribute("role")) { + event.preventDefault(); + } + + const menu = document.querySelector(".header nav ul"); + const menuToggleButton = document.querySelector(".logo"); + if (menu.classList.contains("js-menu-show")) { + menu.classList.remove("js-menu-show"); + menuToggleButton.setAttribute("aria-expanded", false); + } else { + menu.classList.add("js-menu-show"); + menuToggleButton.setAttribute("aria-expanded", true); + } } // Handle click events for the main menu (
  • and ). function onClickMainMenuListItem(event) { - const element = event.target; + const element = event.target; - if (element.tagName === "A") { - window.location.href = element.getAttribute("href"); - } else { - const linkElement = element.querySelector("a") || element.closest("a"); - window.location.href = linkElement.getAttribute("href"); - } + if (element.tagName === "A") { + window.location.href = element.getAttribute("href"); + } else { + const linkElement = element.querySelector("a") || element.closest("a"); + window.location.href = linkElement.getAttribute("href"); + } } // Change the button label when the page is loading. function handleSubmitButtons() { - document.querySelectorAll("form").forEach((element) => { - element.onsubmit = () => { - const button = element.querySelector("button"); - if (button) { - button.textContent = button.dataset.labelLoading; - button.disabled = true; - } - }; - }); + document.querySelectorAll("form").forEach((element) => { + element.onsubmit = () => { + const button = element.querySelector("button"); + if (button) { + button.textContent = button.dataset.labelLoading; + button.disabled = true; + } + }; + }); } // Show modal dialog with the list of keyboard shortcuts. function showKeyboardShortcuts() { - const template = document.getElementById("keyboard-shortcuts"); - if (template !== null) { - ModalHandler.open(template.content, "dialog-title"); - } + const template = document.getElementById("keyboard-shortcuts"); + if (template !== null) { + ModalHandler.open(template.content, "dialog-title"); + } } // Mark as read visible items of the current page. function markPageAsRead() { - const items = DomHelper.getVisibleElements(".items .item"); - const entryIDs = []; - - items.forEach((element) => { - element.classList.add("item-status-read"); - entryIDs.push(parseInt(element.dataset.id, 10)); + const items = DomHelper.getVisibleElements(".items .item"); + const entryIDs = []; + + items.forEach((element) => { + element.classList.add("item-status-read"); + entryIDs.push(parseInt(element.dataset.id, 10)); + }); + + if (entryIDs.length > 0) { + updateEntriesStatus(entryIDs, "read", () => { + // Make sure the Ajax request reach the server before we reload the page. + + const element = document.querySelector( + ":is(a, button)[data-action=markPageAsRead]", + ); + let showOnlyUnread = false; + if (element) { + showOnlyUnread = element.dataset.showOnlyUnread || false; + } + + if (showOnlyUnread) { + window.location.href = window.location.href; + } else { + goToPage("next", true); + } }); - - if (entryIDs.length > 0) { - updateEntriesStatus(entryIDs, "read", () => { - // Make sure the Ajax request reach the server before we reload the page. - - const element = document.querySelector(":is(a, button)[data-action=markPageAsRead]"); - let showOnlyUnread = false; - if (element) { - showOnlyUnread = element.dataset.showOnlyUnread || false; - } - - if (showOnlyUnread) { - window.location.href = window.location.href; - } else { - goToPage("next", true); - } - }); - } + } } /** @@ -149,286 +166,314 @@ function markPageAsRead() { * @param {boolean} setToRead */ function handleEntryStatus(item, element, setToRead) { - const toasting = !element; - const currentEntry = findEntry(element); - if (currentEntry) { - if (!setToRead || currentEntry.querySelector(":is(a, button)[data-toggle-status]").dataset.value == "unread") { - toggleEntryStatus(currentEntry, toasting); - } - if (isListView() && currentEntry.classList.contains('current-item')) { - switch (item) { - case "previous": - goToListItem(-1); - break; - case "next": - goToListItem(1); - break; - } - } + const toasting = !element; + const currentEntry = findEntry(element); + if (currentEntry) { + if ( + !setToRead || + currentEntry.querySelector(":is(a, button)[data-toggle-status]").dataset + .value == "unread" + ) { + toggleEntryStatus(currentEntry, toasting); } + if (isListView() && currentEntry.classList.contains("current-item")) { + switch (item) { + case "previous": + goToListItem(-1); + break; + case "next": + goToListItem(1); + break; + } + } + } } // Add an icon-label span element. function appendIconLabel(element, labelTextContent) { - const span = document.createElement('span'); - span.classList.add('icon-label'); - span.textContent = labelTextContent; - element.appendChild(span); + const span = document.createElement("span"); + span.classList.add("icon-label"); + span.textContent = labelTextContent; + element.appendChild(span); } // Change the entry status to the opposite value. function toggleEntryStatus(element, toasting) { - const entryID = parseInt(element.dataset.id, 10); - const link = element.querySelector(":is(a, button)[data-toggle-status]"); - - const currentStatus = link.dataset.value; - const newStatus = currentStatus === "read" ? "unread" : "read"; - - link.querySelector("span").textContent = link.dataset.labelLoading; - updateEntriesStatus([entryID], newStatus, () => { - let iconElement, label; - - if (currentStatus === "read") { - iconElement = document.querySelector("template#icon-read"); - label = link.dataset.labelRead; - if (toasting) { - showToast(link.dataset.toastUnread, iconElement); - } - } else { - iconElement = document.querySelector("template#icon-unread"); - label = link.dataset.labelUnread; - if (toasting) { - showToast(link.dataset.toastRead, iconElement); - } - } + const entryID = parseInt(element.dataset.id, 10); + const link = element.querySelector(":is(a, button)[data-toggle-status]"); + + const currentStatus = link.dataset.value; + const newStatus = currentStatus === "read" ? "unread" : "read"; + + link.querySelector("span").textContent = link.dataset.labelLoading; + updateEntriesStatus([entryID], newStatus, () => { + let iconElement, label; + + if (currentStatus === "read") { + iconElement = document.querySelector("template#icon-read"); + label = link.dataset.labelRead; + if (toasting) { + showToast(link.dataset.toastUnread, iconElement); + } + } else { + iconElement = document.querySelector("template#icon-unread"); + label = link.dataset.labelUnread; + if (toasting) { + showToast(link.dataset.toastRead, iconElement); + } + } - link.replaceChildren(iconElement.content.cloneNode(true)); - appendIconLabel(link, label); - link.dataset.value = newStatus; + link.replaceChildren(iconElement.content.cloneNode(true)); + appendIconLabel(link, label); + link.dataset.value = newStatus; - if (element.classList.contains("item-status-" + currentStatus)) { - element.classList.remove("item-status-" + currentStatus); - element.classList.add("item-status-" + newStatus); - } - }); + if (element.classList.contains("item-status-" + currentStatus)) { + element.classList.remove("item-status-" + currentStatus); + element.classList.add("item-status-" + newStatus); + } + }); } // Mark a single entry as read. function markEntryAsRead(element) { - if (element.classList.contains("item-status-unread")) { - element.classList.remove("item-status-unread"); - element.classList.add("item-status-read"); + if (element.classList.contains("item-status-unread")) { + element.classList.remove("item-status-unread"); + element.classList.add("item-status-read"); - const entryID = parseInt(element.dataset.id, 10); - updateEntriesStatus([entryID], "read"); - } + const entryID = parseInt(element.dataset.id, 10); + updateEntriesStatus([entryID], "read"); + } } // Send the Ajax request to refresh all feeds in the background function handleRefreshAllFeeds() { - const url = document.body.dataset.refreshAllFeedsUrl; - if (url) { - window.location.href = url; - } + const url = document.body.dataset.refreshAllFeedsUrl; + if (url) { + window.location.href = url; + } } // Send the Ajax request to change entries statuses. function updateEntriesStatus(entryIDs, status, callback) { - const url = document.body.dataset.entriesStatusUrl; - const request = new RequestBuilder(url); - request.withBody({ entry_ids: entryIDs, status: status }); - request.withCallback((resp) => { - resp.json().then(count => { - if (callback) { - callback(resp); - } - - if (status === "read") { - decrementUnreadCounter(count); - } else { - incrementUnreadCounter(count); - } - }); + const url = document.body.dataset.entriesStatusUrl; + const request = new RequestBuilder(url); + request.withBody({ entry_ids: entryIDs, status: status }); + request.withCallback((resp) => { + resp.json().then((count) => { + if (callback) { + callback(resp); + } + + if (status === "read") { + decrementUnreadCounter(count); + } else { + incrementUnreadCounter(count); + } }); - request.execute(); + }); + request.execute(); } // Handle save entry from list view and entry view. function handleSaveEntry(element) { - const toasting = !element; - const currentEntry = findEntry(element); - if (currentEntry) { - saveEntry(currentEntry.querySelector(":is(a, button)[data-save-entry]"), toasting); - } + const toasting = !element; + const currentEntry = findEntry(element); + if (currentEntry) { + saveEntry( + currentEntry.querySelector(":is(a, button)[data-save-entry]"), + toasting, + ); + } } // Send the Ajax request to save an entry. function saveEntry(element, toasting) { - if (!element || element.dataset.completed) { - return; - } + if (!element || element.dataset.completed) { + return; + } - element.textContent = ""; - appendIconLabel(element, element.dataset.labelLoading); + element.textContent = ""; + appendIconLabel(element, element.dataset.labelLoading); - const request = new RequestBuilder(element.dataset.saveUrl); - request.withCallback(() => { - element.textContent = ""; - appendIconLabel(element, element.dataset.labelDone); - element.dataset.completed = true; - if (toasting) { - const iconElement = document.querySelector("template#icon-save"); - showToast(element.dataset.toastDone, iconElement); - } - }); - request.execute(); + const request = new RequestBuilder(element.dataset.saveUrl); + request.withCallback(() => { + element.textContent = ""; + appendIconLabel(element, element.dataset.labelDone); + element.dataset.completed = true; + if (toasting) { + const iconElement = document.querySelector("template#icon-save"); + showToast(element.dataset.toastDone, iconElement); + } + }); + request.execute(); } // Handle bookmark from the list view and entry view. function handleBookmark(element) { - const toasting = !element; - const currentEntry = findEntry(element); - if (currentEntry) { - toggleBookmark(currentEntry, toasting); - } + const toasting = !element; + const currentEntry = findEntry(element); + if (currentEntry) { + toggleBookmark(currentEntry, toasting); + } } // Send the Ajax request and change the icon when bookmarking an entry. function toggleBookmark(parentElement, toasting) { - const buttonElement = parentElement.querySelector(":is(a, button)[data-toggle-bookmark]"); - if (!buttonElement) { - return; + const buttonElement = parentElement.querySelector( + ":is(a, button)[data-toggle-bookmark]", + ); + if (!buttonElement) { + return; + } + + buttonElement.textContent = ""; + appendIconLabel(buttonElement, buttonElement.dataset.labelLoading); + + const request = new RequestBuilder(buttonElement.dataset.bookmarkUrl); + request.withCallback(() => { + const currentStarStatus = buttonElement.dataset.value; + const newStarStatus = currentStarStatus === "star" ? "unstar" : "star"; + + let iconElement, label; + if (currentStarStatus === "star") { + iconElement = document.querySelector("template#icon-star"); + label = buttonElement.dataset.labelStar; + if (toasting) { + showToast(buttonElement.dataset.toastUnstar, iconElement); + } + } else { + iconElement = document.querySelector("template#icon-unstar"); + label = buttonElement.dataset.labelUnstar; + if (toasting) { + showToast(buttonElement.dataset.toastStar, iconElement); + } } - buttonElement.textContent = ""; - appendIconLabel(buttonElement, buttonElement.dataset.labelLoading); - - const request = new RequestBuilder(buttonElement.dataset.bookmarkUrl); - request.withCallback(() => { - const currentStarStatus = buttonElement.dataset.value; - const newStarStatus = currentStarStatus === "star" ? "unstar" : "star"; - - let iconElement, label; - if (currentStarStatus === "star") { - iconElement = document.querySelector("template#icon-star"); - label = buttonElement.dataset.labelStar; - if (toasting) { - showToast(buttonElement.dataset.toastUnstar, iconElement); - } - } else { - iconElement = document.querySelector("template#icon-unstar"); - label = buttonElement.dataset.labelUnstar; - if (toasting) { - showToast(buttonElement.dataset.toastStar, iconElement); - } - } - - buttonElement.replaceChildren(iconElement.content.cloneNode(true)); - appendIconLabel(buttonElement, label); - buttonElement.dataset.value = newStarStatus; - }); - request.execute(); + buttonElement.replaceChildren(iconElement.content.cloneNode(true)); + appendIconLabel(buttonElement, label); + buttonElement.dataset.value = newStarStatus; + }); + request.execute(); } // Send the Ajax request to download the original web page. function handleFetchOriginalContent() { - if (isListView()) { - return; - } + if (isListView()) { + return; + } - const buttonElement = document.querySelector(":is(a, button)[data-fetch-content-entry]"); - if (!buttonElement) { - return; - } + const buttonElement = document.querySelector( + ":is(a, button)[data-fetch-content-entry]", + ); + if (!buttonElement) { + return; + } - const previousElement = buttonElement.cloneNode(true); + const previousElement = buttonElement.cloneNode(true); + buttonElement.textContent = ""; + appendIconLabel(buttonElement, buttonElement.dataset.labelLoading); + + const request = new RequestBuilder(buttonElement.dataset.fetchContentUrl); + request.withCallback((response) => { buttonElement.textContent = ""; - appendIconLabel(buttonElement, buttonElement.dataset.labelLoading); - - const request = new RequestBuilder(buttonElement.dataset.fetchContentUrl); - request.withCallback((response) => { - buttonElement.textContent = ''; - buttonElement.appendChild(previousElement); - - response.json().then((data) => { - if (data.hasOwnProperty("content") && data.hasOwnProperty("reading_time")) { - document.querySelector(".entry-content").innerHTML = ttpolicy.createHTML(data.content); - const entryReadingtimeElement = document.querySelector(".entry-reading-time"); - if (entryReadingtimeElement) { - entryReadingtimeElement.textContent = data.reading_time; - } - } - }); + buttonElement.appendChild(previousElement); + + response.json().then((data) => { + if ( + data.hasOwnProperty("content") && + data.hasOwnProperty("reading_time") + ) { + document.querySelector(".entry-content").innerHTML = + ttpolicy.createHTML(data.content); + const entryReadingtimeElement = document.querySelector( + ".entry-reading-time", + ); + if (entryReadingtimeElement) { + entryReadingtimeElement.textContent = data.reading_time; + } + } }); - request.execute(); + }); + request.execute(); } function openOriginalLink(openLinkInCurrentTab) { - const entryLink = document.querySelector(".entry h1 a"); - if (entryLink !== null) { - if (openLinkInCurrentTab) { - window.location.href = entryLink.getAttribute("href"); - } else { - DomHelper.openNewTab(entryLink.getAttribute("href")); - } - return; + const entryLink = document.querySelector(".entry h1 a"); + if (entryLink !== null) { + if (openLinkInCurrentTab) { + window.location.href = entryLink.getAttribute("href"); + } else { + DomHelper.openNewTab(entryLink.getAttribute("href")); } + return; + } - const currentItemOriginalLink = document.querySelector(".current-item :is(a, button)[data-original-link]"); - if (currentItemOriginalLink !== null) { - DomHelper.openNewTab(currentItemOriginalLink.getAttribute("href")); + const currentItemOriginalLink = document.querySelector( + ".current-item :is(a, button)[data-original-link]", + ); + if (currentItemOriginalLink !== null) { + DomHelper.openNewTab(currentItemOriginalLink.getAttribute("href")); - const currentItem = document.querySelector(".current-item"); - // If we are not on the list of starred items, move to the next item - if (document.location.href != document.querySelector(':is(a, button)[data-page=starred]').href) { - goToListItem(1); - } - markEntryAsRead(currentItem); + const currentItem = document.querySelector(".current-item"); + // If we are not on the list of starred items, move to the next item + if ( + document.location.href != + document.querySelector(":is(a, button)[data-page=starred]").href + ) { + goToListItem(1); } + markEntryAsRead(currentItem); + } } function openCommentLink(openLinkInCurrentTab) { - if (!isListView()) { - const entryLink = document.querySelector(":is(a, button)[data-comments-link]"); - if (entryLink !== null) { - if (openLinkInCurrentTab) { - window.location.href = entryLink.getAttribute("href"); - } else { - DomHelper.openNewTab(entryLink.getAttribute("href")); - } - return; - } - } else { - const currentItemCommentsLink = document.querySelector(".current-item :is(a, button)[data-comments-link]"); - if (currentItemCommentsLink !== null) { - DomHelper.openNewTab(currentItemCommentsLink.getAttribute("href")); - } + if (!isListView()) { + const entryLink = document.querySelector( + ":is(a, button)[data-comments-link]", + ); + if (entryLink !== null) { + if (openLinkInCurrentTab) { + window.location.href = entryLink.getAttribute("href"); + } else { + DomHelper.openNewTab(entryLink.getAttribute("href")); + } + return; + } + } else { + const currentItemCommentsLink = document.querySelector( + ".current-item :is(a, button)[data-comments-link]", + ); + if (currentItemCommentsLink !== null) { + DomHelper.openNewTab(currentItemCommentsLink.getAttribute("href")); } + } } function openSelectedItem() { - const currentItemLink = document.querySelector(".current-item .item-title a"); - if (currentItemLink !== null) { - window.location.href = currentItemLink.getAttribute("href"); - } + const currentItemLink = document.querySelector(".current-item .item-title a"); + if (currentItemLink !== null) { + window.location.href = currentItemLink.getAttribute("href"); + } } function unsubscribeFromFeed() { - const unsubscribeLinks = document.querySelectorAll("[data-action=remove-feed]"); - if (unsubscribeLinks.length === 1) { - const unsubscribeLink = unsubscribeLinks[0]; - - const request = new RequestBuilder(unsubscribeLink.dataset.url); - request.withCallback(() => { - if (unsubscribeLink.dataset.redirectUrl) { - window.location.href = unsubscribeLink.dataset.redirectUrl; - } else { - window.location.reload(); - } - }); - request.execute(); - } + const unsubscribeLinks = document.querySelectorAll( + "[data-action=remove-feed]", + ); + if (unsubscribeLinks.length === 1) { + const unsubscribeLink = unsubscribeLinks[0]; + + const request = new RequestBuilder(unsubscribeLink.dataset.url); + request.withCallback(() => { + if (unsubscribeLink.dataset.redirectUrl) { + window.location.href = unsubscribeLink.dataset.redirectUrl; + } else { + window.location.reload(); + } + }); + request.execute(); + } } /** @@ -436,13 +481,15 @@ function unsubscribeFromFeed() { * @param {boolean} fallbackSelf Refresh actual page if the page is not found. */ function goToPage(page, fallbackSelf) { - const element = document.querySelector(":is(a, button)[data-page=" + page + "]"); + const element = document.querySelector( + ":is(a, button)[data-page=" + page + "]", + ); - if (element) { - document.location.href = element.href; - } else if (fallbackSelf) { - window.location.reload(); - } + if (element) { + document.location.href = element.href; + } else if (fallbackSelf) { + window.location.reload(); + } } /** @@ -450,14 +497,14 @@ function goToPage(page, fallbackSelf) { * @param {(number|event)} offset - many items to jump for focus. */ function goToPrevious(offset) { - if (offset instanceof KeyboardEvent) { - offset = -1; - } - if (isListView()) { - goToListItem(offset); - } else { - goToPage("previous"); - } + if (offset instanceof KeyboardEvent) { + offset = -1; + } + if (isListView()) { + goToListItem(offset); + } else { + goToPage("previous"); + } } /** @@ -465,36 +512,38 @@ function goToPrevious(offset) { * @param {(number|event)} offset - How many items to jump for focus. */ function goToNext(offset) { - if (offset instanceof KeyboardEvent) { - offset = 1; - } - if (isListView()) { - goToListItem(offset); - } else { - goToPage("next"); - } + if (offset instanceof KeyboardEvent) { + offset = 1; + } + if (isListView()) { + goToListItem(offset); + } else { + goToPage("next"); + } } function goToFeedOrFeeds() { - if (isEntry()) { - goToFeed(); - } else { - goToPage('feeds'); - } + if (isEntry()) { + goToFeed(); + } else { + goToPage("feeds"); + } } function goToFeed() { - if (isEntry()) { - const feedAnchor = document.querySelector("span.entry-website a"); - if (feedAnchor !== null) { - window.location.href = feedAnchor.href; - } - } else { - const currentItemFeed = document.querySelector(".current-item :is(a, button)[data-feed-link]"); - if (currentItemFeed !== null) { - window.location.href = currentItemFeed.getAttribute("href"); - } + if (isEntry()) { + const feedAnchor = document.querySelector("span.entry-website a"); + if (feedAnchor !== null) { + window.location.href = feedAnchor.href; + } + } else { + const currentItemFeed = document.querySelector( + ".current-item :is(a, button)[data-feed-link]", + ); + if (currentItemFeed !== null) { + window.location.href = currentItemFeed.getAttribute("href"); } + } } // Sentinel values for specific list navigation @@ -505,173 +554,177 @@ const BOTTOM = -9999; * @param {number} offset How many items to jump for focus. */ function goToListItem(offset) { - const items = DomHelper.getVisibleElements(".items .item"); - if (items.length === 0) { - return; - } + const items = DomHelper.getVisibleElements(".items .item"); + if (items.length === 0) { + return; + } - if (document.querySelector(".current-item") === null) { - items[0].classList.add("current-item"); - items[0].focus(); - return; - } + if (document.querySelector(".current-item") === null) { + items[0].classList.add("current-item"); + items[0].focus(); + return; + } - for (let i = 0; i < items.length; i++) { - if (items[i].classList.contains("current-item")) { - items[i].classList.remove("current-item"); - - // By default adjust selection by offset - let itemOffset = (i + offset + items.length) % items.length; - // Allow jumping to top or bottom - if (offset == TOP) { - itemOffset = 0; - } else if (offset == BOTTOM) { - itemOffset = items.length - 1; - } - const item = items[itemOffset]; - - item.classList.add("current-item"); - DomHelper.scrollPageTo(item); - item.focus(); - - break; - } + for (let i = 0; i < items.length; i++) { + if (items[i].classList.contains("current-item")) { + items[i].classList.remove("current-item"); + + // By default adjust selection by offset + let itemOffset = (i + offset + items.length) % items.length; + // Allow jumping to top or bottom + if (offset == TOP) { + itemOffset = 0; + } else if (offset == BOTTOM) { + itemOffset = items.length - 1; + } + const item = items[itemOffset]; + + item.classList.add("current-item"); + DomHelper.scrollPageTo(item); + item.focus(); + + break; } + } } function scrollToCurrentItem() { - const currentItem = document.querySelector(".current-item"); - if (currentItem !== null) { - DomHelper.scrollPageTo(currentItem, true); - } + const currentItem = document.querySelector(".current-item"); + if (currentItem !== null) { + DomHelper.scrollPageTo(currentItem, true); + } } function decrementUnreadCounter(n) { - updateUnreadCounterValue((current) => { - return current - n; - }); + updateUnreadCounterValue((current) => { + return current - n; + }); } function incrementUnreadCounter(n) { - updateUnreadCounterValue((current) => { - return current + n; - }); + updateUnreadCounterValue((current) => { + return current + n; + }); } function updateUnreadCounterValue(callback) { - document.querySelectorAll("span.unread-counter").forEach((element) => { - const oldValue = parseInt(element.textContent, 10); - element.textContent = callback(oldValue); - }); + document.querySelectorAll("span.unread-counter").forEach((element) => { + const oldValue = parseInt(element.textContent, 10); + element.textContent = callback(oldValue); + }); - if (window.location.href.endsWith('/unread')) { - const oldValue = parseInt(document.title.split('(')[1], 10); - const newValue = callback(oldValue); + if (window.location.href.endsWith("/unread")) { + const oldValue = parseInt(document.title.split("(")[1], 10); + const newValue = callback(oldValue); - document.title = document.title.replace( - /(.*?)\(\d+\)(.*?)/, - function (match, prefix, suffix, offset, string) { - return prefix + '(' + newValue + ')' + suffix; - } - ); - } + document.title = document.title.replace( + /(.*?)\(\d+\)(.*?)/, + function (match, prefix, suffix, offset, string) { + return prefix + "(" + newValue + ")" + suffix; + }, + ); + } } function isEntry() { - return document.querySelector("section.entry") !== null; + return document.querySelector("section.entry") !== null; } function isListView() { - return document.querySelector(".items") !== null; + return document.querySelector(".items") !== null; } function findEntry(element) { - if (isListView()) { - if (element) { - return element.closest(".item"); - } - return document.querySelector(".current-item"); + if (isListView()) { + if (element) { + return element.closest(".item"); } - return document.querySelector(".entry"); + return document.querySelector(".current-item"); + } + return document.querySelector(".entry"); } function handleConfirmationMessage(linkElement, callback) { - if (linkElement.tagName != 'A' && linkElement.tagName != "BUTTON") { - linkElement = linkElement.parentNode; - } + if (linkElement.tagName != "A" && linkElement.tagName != "BUTTON") { + linkElement = linkElement.parentNode; + } - linkElement.style.display = "none"; + linkElement.style.display = "none"; - const containerElement = linkElement.parentNode; - const questionElement = document.createElement("span"); + const containerElement = linkElement.parentNode; + const questionElement = document.createElement("span"); - function createLoadingElement() { - const loadingElement = document.createElement("span"); - loadingElement.className = "loading"; - loadingElement.appendChild(document.createTextNode(linkElement.dataset.labelLoading)); + function createLoadingElement() { + const loadingElement = document.createElement("span"); + loadingElement.className = "loading"; + loadingElement.appendChild( + document.createTextNode(linkElement.dataset.labelLoading), + ); - questionElement.remove(); - containerElement.appendChild(loadingElement); - } + questionElement.remove(); + containerElement.appendChild(loadingElement); + } - const yesElement = document.createElement("button"); - yesElement.appendChild(document.createTextNode(linkElement.dataset.labelYes)); - yesElement.onclick = (event) => { - event.preventDefault(); + const yesElement = document.createElement("button"); + yesElement.appendChild(document.createTextNode(linkElement.dataset.labelYes)); + yesElement.onclick = (event) => { + event.preventDefault(); - createLoadingElement(); + createLoadingElement(); - callback(linkElement.dataset.url, linkElement.dataset.redirectUrl); - }; + callback(linkElement.dataset.url, linkElement.dataset.redirectUrl); + }; - const noElement = document.createElement("button"); - noElement.appendChild(document.createTextNode(linkElement.dataset.labelNo)); - noElement.onclick = (event) => { - event.preventDefault(); + const noElement = document.createElement("button"); + noElement.appendChild(document.createTextNode(linkElement.dataset.labelNo)); + noElement.onclick = (event) => { + event.preventDefault(); - const noActionUrl = linkElement.dataset.noActionUrl; - if (noActionUrl) { - createLoadingElement(); + const noActionUrl = linkElement.dataset.noActionUrl; + if (noActionUrl) { + createLoadingElement(); - callback(noActionUrl, linkElement.dataset.redirectUrl); - } else { - linkElement.style.display = "inline"; - questionElement.remove(); - } - }; + callback(noActionUrl, linkElement.dataset.redirectUrl); + } else { + linkElement.style.display = "inline"; + questionElement.remove(); + } + }; - questionElement.className = "confirm"; - questionElement.appendChild(document.createTextNode(linkElement.dataset.labelQuestion + " ")); - questionElement.appendChild(yesElement); - questionElement.appendChild(document.createTextNode(", ")); - questionElement.appendChild(noElement); + questionElement.className = "confirm"; + questionElement.appendChild( + document.createTextNode(linkElement.dataset.labelQuestion + " "), + ); + questionElement.appendChild(yesElement); + questionElement.appendChild(document.createTextNode(", ")); + questionElement.appendChild(noElement); - containerElement.appendChild(questionElement); + containerElement.appendChild(questionElement); } function showToast(label, iconElement) { - if (!label || !iconElement) { - return; - } + if (!label || !iconElement) { + return; + } - const toastMsgElement = document.getElementById("toast-msg"); - if (toastMsgElement) { - toastMsgElement.replaceChildren(iconElement.content.cloneNode(true)); - appendIconLabel(toastMsgElement, label); - - const toastElementWrapper = document.getElementById("toast-wrapper"); - if (toastElementWrapper) { - toastElementWrapper.classList.remove('toast-animate'); - setTimeout(function () { - toastElementWrapper.classList.add('toast-animate'); - }, 100); - } + const toastMsgElement = document.getElementById("toast-msg"); + if (toastMsgElement) { + toastMsgElement.replaceChildren(iconElement.content.cloneNode(true)); + appendIconLabel(toastMsgElement, label); + + const toastElementWrapper = document.getElementById("toast-wrapper"); + if (toastElementWrapper) { + toastElementWrapper.classList.remove("toast-animate"); + setTimeout(function () { + toastElementWrapper.classList.add("toast-animate"); + }, 100); } + } } /** Navigate to the new subscription page. */ function goToAddSubscription() { - window.location.href = document.body.dataset.addSubscriptionUrl; + window.location.href = document.body.dataset.addSubscriptionUrl; } /** @@ -679,30 +732,40 @@ function goToAddSubscription() { * @param {Element} playerElement */ function handlePlayerProgressionSaveAndMarkAsReadOnCompletion(playerElement) { - if (!isPlayerPlaying(playerElement)) { - return; //If the player is not playing, we do not want to save the progression and mark as read on completion - } - const currentPositionInSeconds = Math.floor(playerElement.currentTime); // we do not need a precise value - const lastKnownPositionInSeconds = parseInt(playerElement.dataset.lastPosition, 10); - const markAsReadOnCompletion = parseFloat(playerElement.dataset.markReadOnCompletion); //completion percentage to mark as read - const recordInterval = 10; - - // we limit the number of update to only one by interval. Otherwise, we would have multiple update per seconds - if (currentPositionInSeconds >= (lastKnownPositionInSeconds + recordInterval) || - currentPositionInSeconds <= (lastKnownPositionInSeconds - recordInterval) - ) { - playerElement.dataset.lastPosition = currentPositionInSeconds.toString(); - const request = new RequestBuilder(playerElement.dataset.saveUrl); - request.withBody({ progression: currentPositionInSeconds }); - request.execute(); - // Handle the mark as read on completion - if (markAsReadOnCompletion >= 0 && playerElement.duration > 0) { - const completion = currentPositionInSeconds / playerElement.duration; - if (completion >= markAsReadOnCompletion) { - handleEntryStatus("none", document.querySelector(":is(a, button)[data-toggle-status]"), true); - } - } + if (!isPlayerPlaying(playerElement)) { + return; //If the player is not playing, we do not want to save the progression and mark as read on completion + } + const currentPositionInSeconds = Math.floor(playerElement.currentTime); // we do not need a precise value + const lastKnownPositionInSeconds = parseInt( + playerElement.dataset.lastPosition, + 10, + ); + const markAsReadOnCompletion = parseFloat( + playerElement.dataset.markReadOnCompletion, + ); //completion percentage to mark as read + const recordInterval = 10; + + // we limit the number of update to only one by interval. Otherwise, we would have multiple update per seconds + if ( + currentPositionInSeconds >= lastKnownPositionInSeconds + recordInterval || + currentPositionInSeconds <= lastKnownPositionInSeconds - recordInterval + ) { + playerElement.dataset.lastPosition = currentPositionInSeconds.toString(); + const request = new RequestBuilder(playerElement.dataset.saveUrl); + request.withBody({ progression: currentPositionInSeconds }); + request.execute(); + // Handle the mark as read on completion + if (markAsReadOnCompletion >= 0 && playerElement.duration > 0) { + const completion = currentPositionInSeconds / playerElement.duration; + if (completion >= markAsReadOnCompletion) { + handleEntryStatus( + "none", + document.querySelector(":is(a, button)[data-toggle-status]"), + true, + ); + } } + } } /** @@ -711,59 +774,63 @@ function handlePlayerProgressionSaveAndMarkAsReadOnCompletion(playerElement) { * @returns {boolean} */ function isPlayerPlaying(element) { - return element && - element.currentTime > 0 && - !element.paused && - !element.ended && - element.readyState > 2; //https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/readyState + return ( + element && + element.currentTime > 0 && + !element.paused && + !element.ended && + element.readyState > 2 + ); //https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/readyState } /** * handle new share entires and already shared entries */ function handleShare() { - const link = document.querySelector(':is(a, button)[data-share-status]'); - const title = document.querySelector("body > main > section > header > h1 > a"); - if (link.dataset.shareStatus === "shared") { - checkShareAPI(title, link.href); - } - if (link.dataset.shareStatus === "share") { - const request = new RequestBuilder(link.href); - request.withCallback((r) => { - checkShareAPI(title, r.url); - }); - request.withHttpMethod("GET"); - request.execute(); - } + const link = document.querySelector(":is(a, button)[data-share-status]"); + const title = document.querySelector( + "body > main > section > header > h1 > a", + ); + if (link.dataset.shareStatus === "shared") { + checkShareAPI(title, link.href); + } + if (link.dataset.shareStatus === "share") { + const request = new RequestBuilder(link.href); + request.withCallback((r) => { + checkShareAPI(title, r.url); + }); + request.withHttpMethod("GET"); + request.execute(); + } } /** -* wrapper for Web Share API -*/ + * wrapper for Web Share API + */ function checkShareAPI(title, url) { - if (!navigator.canShare) { - console.error("Your browser doesn't support the Web Share API."); - window.location = url; - return; - } - try { - navigator.share({ - title: title, - url: url - }); - } catch (err) { - console.error(err); - } - window.location.reload(); + if (!navigator.canShare) { + console.error("Your browser doesn't support the Web Share API."); + window.location = url; + return; + } + try { + navigator.share({ + title: title, + url: url, + }); + } catch (err) { + console.error(err); + } + window.location.reload(); } function getCsrfToken() { - const element = document.querySelector("body[data-csrf-token]"); - if (element !== null) { - return element.dataset.csrfToken; - } + const element = document.querySelector("body[data-csrf-token]"); + if (element !== null) { + return element.dataset.csrfToken; + } - return ""; + return ""; } /** @@ -774,34 +841,39 @@ function getCsrfToken() { * @param {Element} button */ function handleMediaControl(button) { - const action = button.dataset.enclosureAction; - const value = parseFloat(button.dataset.actionValue); - const targetEnclosureId = button.dataset.enclosureId; - const enclosures = document.querySelectorAll(`audio[data-enclosure-id="${targetEnclosureId}"],video[data-enclosure-id="${targetEnclosureId}"]`); - const speedIndicator = document.querySelectorAll(`span.speed-indicator[data-enclosure-id="${targetEnclosureId}"]`); - enclosures.forEach((enclosure) => { - switch (action) { - case "seek": - enclosure.currentTime = enclosure.currentTime + value > 0 ? enclosure.currentTime + value : 0; - break; - case "speed": - // I set a floor speed of 0.25 to avoid too slow speed where it gives the impression it stopped. - // 0.25 was chosen because it will allow to get back to 1x in two "faster" click, and lower value with same property would be 0. - enclosure.playbackRate = Math.max(0.25, enclosure.playbackRate + value); - speedIndicator.forEach((speedI) => { - // Two digit precision to ensure we always have the same number of characters (4) to avoid controls moving when clicking buttons because of more or less characters. - // The trick only work on rate less than 10, but it feels an acceptable tread of considering the feature - speedI.innerText = `${enclosure.playbackRate.toFixed(2)}x`; - }); - break; - case "speed-reset": - enclosure.playbackRate = value ; - speedIndicator.forEach((speedI) => { - // Two digit precision to ensure we always have the same number of characters (4) to avoid controls moving when clicking buttons because of more or less characters. - // The trick only work on rate less than 10, but it feels an acceptable tread of considering the feature - speedI.innerText = `${enclosure.playbackRate.toFixed(2)}x`; - }); - break; - } - }); + const action = button.dataset.enclosureAction; + const value = parseFloat(button.dataset.actionValue); + const targetEnclosureId = button.dataset.enclosureId; + const enclosures = document.querySelectorAll( + `audio[data-enclosure-id="${targetEnclosureId}"],video[data-enclosure-id="${targetEnclosureId}"]`, + ); + const speedIndicator = document.querySelectorAll( + `span.speed-indicator[data-enclosure-id="${targetEnclosureId}"]`, + ); + enclosures.forEach((enclosure) => { + switch (action) { + case "seek": + enclosure.currentTime = + enclosure.currentTime + value > 0 ? enclosure.currentTime + value : 0; + break; + case "speed": + // I set a floor speed of 0.25 to avoid too slow speed where it gives the impression it stopped. + // 0.25 was chosen because it will allow to get back to 1x in two "faster" click, and lower value with same property would be 0. + enclosure.playbackRate = Math.max(0.25, enclosure.playbackRate + value); + speedIndicator.forEach((speedI) => { + // Two digit precision to ensure we always have the same number of characters (4) to avoid controls moving when clicking buttons because of more or less characters. + // The trick only work on rate less than 10, but it feels an acceptable tread of considering the feature + speedI.innerText = `${enclosure.playbackRate.toFixed(2)}x`; + }); + break; + case "speed-reset": + enclosure.playbackRate = value; + speedIndicator.forEach((speedI) => { + // Two digit precision to ensure we always have the same number of characters (4) to avoid controls moving when clicking buttons because of more or less characters. + // The trick only work on rate less than 10, but it feels an acceptable tread of considering the feature + speedI.innerText = `${enclosure.playbackRate.toFixed(2)}x`; + }); + break; + } + }); } diff --git a/internal/ui/static/js/service_worker.js b/internal/ui/static/js/service_worker.js index 37cce257a22..7141bc9fc59 100644 --- a/internal/ui/static/js/service_worker.js +++ b/internal/ui/static/js/service_worker.js @@ -1,44 +1,126 @@ - // Incrementing OFFLINE_VERSION will kick off the install event and force // previously cached resources to be updated from the network. -const OFFLINE_VERSION = 1; +const OFFLINE_VERSION = 2; const CACHE_NAME = "offline"; +const cachedPages = [ + "/unread", + "/starred", + "/stylesheets", + "/app", + "/service-worker", + "/manifest.json", + "/feed/icon", + "/icon", +]; + self.addEventListener("install", (event) => { - event.waitUntil( - (async () => { - const cache = await caches.open(CACHE_NAME); - - // Setting {cache: 'reload'} in the new request will ensure that the - // response isn't fulfilled from the HTTP cache; i.e., it will be from - // the network. - await cache.add(new Request(OFFLINE_URL, { cache: "reload" })); - })() - ); + event.waitUntil( + (async () => { + const cache = await caches.open(CACHE_NAME); - // Force the waiting service worker to become the active service worker. - self.skipWaiting(); + if (USE_CACHE) { + await cache.addAll(["/", "/unread", "/starred", OFFLINE_URL]); + } else { + // Setting {cache: 'reload'} in the new request will ensure that the + // response isn't fulfilled from the HTTP cache; i.e., it will be from + // the network. + await cache.add(new Request(OFFLINE_URL, { cache: "reload" })); + } + })(), + ); + + // Force the waiting service worker to become the active service worker. + self.skipWaiting(); }); +async function cacheFirstWithRefresh(request) { + const cache = await caches.open(CACHE_NAME); + + const fetchResponsePromise = fetch(request).then(async (networkResponse) => { + if (!networkResponse.ok) return networkResponse; + + const contentType = networkResponse.headers.get("Content-Type"); + if (!contentType || !contentType.includes("text/html")) { + cache.put(request, networkResponse.clone()); + return networkResponse; + } + + const text = await networkResponse.clone().text(); + + const modifiedHtml = text.replace(/offline-hidden/g, "offline-visibe"); + + const clonedResponse = new Response(modifiedHtml, { + status: networkResponse.status, + statusText: networkResponse.statusText, + headers: networkResponse.headers, + }); + + cache.put(request, clonedResponse.clone()); + return networkResponse; + }); + + try { + return (await cache.match(request)) || (await fetchResponsePromise); + } catch (error) { + const cache = await caches.open(CACHE_NAME); + return await cache.match(OFFLINE_URL); + } +} + self.addEventListener("fetch", (event) => { - // We proxify requests through fetch() only if we are offline because it's slower. - if (navigator.onLine === false && event.request.mode === "navigate") { - event.respondWith( - (async () => { - try { - // Always try the network first. - const networkResponse = await fetch(event.request); - return networkResponse; - } catch (error) { - // catch is only triggered if an exception is thrown, which is likely - // due to a network error. - // If fetch() returns a valid HTTP response with a response code in - // the 4xx or 5xx range, the catch() will NOT be called. - const cache = await caches.open(CACHE_NAME); - const cachedResponse = await cache.match(OFFLINE_URL); - return cachedResponse; - } - })() - ); + if (USE_CACHE) { + const url = new URL(event.request.url); + if (cachedPages.some((page) => url.pathname.startsWith(page))) { + return event.respondWith(cacheFirstWithRefresh(event.request)); + } + } + + // We proxify requests through fetch() only if we are offline because it's slower. + if (navigator.onLine === false && event.request.mode === "navigate") { + event.respondWith( + (async () => { + try { + // Always try the network first. + return await fetch(event.request); + } catch (error) { + // catch is only triggered if an exception is thrown, which is likely + // due to a network error. + // If fetch() returns a valid HTTP response with a response code in + // the 4xx or 5xx range, the catch() will NOT be called. + const cache = await caches.open(CACHE_NAME); + return await cache.match(OFFLINE_URL); + } + })(), + ); + } +}); + +self.addEventListener("load", async (event) => { + if ( + navigator.onLine === true && + event.target.location.pathname === "/unread" && + USE_CACHE + ) { + const cache = await caches.open(CACHE_NAME); + + for (let article of document.getElementsByTagName("article")) { + const as = article.getElementsByTagName("a"); + if (as.length > 0) { + const a = as[0]; + const href = a.href; + cache + .add( + new Request(href, { + headers: new Headers({ + "Client-Type": "service-worker", + }), + }), + ) + .then(() => { + article; + }); + } } + } }); diff --git a/internal/ui/static_javascript.go b/internal/ui/static_javascript.go index 36061ce94ea..46b119181ff 100644 --- a/internal/ui/static_javascript.go +++ b/internal/ui/static_javascript.go @@ -31,7 +31,19 @@ func (h *handler) showJavascript(w http.ResponseWriter, r *http.Request) { contents := static.JavascriptBundles[filename] if filename == "service-worker" { - variables := fmt.Sprintf(`const OFFLINE_URL=%q;`, route.Path(h.router, "offline")) + user, err := h.store.UserByID(request.UserID(r)) + if err != nil { + html.ServerError(w, r, err) + return + } + + cacheForOffline := 0 + if user.CacheForOffline { + cacheForOffline = 1 + } + + variables := fmt.Sprintf(`const OFFLINE_URL=%q;const USE_CACHE=%d;`, route.Path(h.router, "offline"), cacheForOffline) + contents = append([]byte(variables), contents...) }