1
0
Fork 0
mirror of https://github.com/miniflux/v2.git synced 2025-06-27 16:36:00 +00:00
This commit is contained in:
Brieuc Dubois 2025-03-19 12:45:28 -04:00 committed by GitHub
commit 2729a76ce4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 2555 additions and 1679 deletions

View file

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

View file

@ -970,6 +970,7 @@ var migrations = []func(tx *sql.Tx, driver string) error{
return err
},
func(tx *sql.Tx, _ string) (err error) {
<<<<<<< HEAD
sql := `
ALTER TABLE integrations ADD COLUMN discord_enabled bool default 'f';
ALTER TABLE integrations ADD COLUMN discord_webhook_link text default '';
@ -1015,4 +1016,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
},
}

View file

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

View file

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

View file

@ -53,10 +53,7 @@
"entry.bookmark.toggle.on": "Star",
"entry.comments.label": "Comments",
"entry.comments.title": "View Comments",
"entry.estimated_reading_time": [
"%d minute read",
"%d minutes read"
],
"entry.estimated_reading_time": ["%d minute read", "%d minutes read"],
"entry.external_link.label": "External link",
"entry.save.completed": "Done!",
"entry.save.label": "Save",
@ -421,15 +418,9 @@
"page.api_keys.table.last_used_at": "Last Used",
"page.api_keys.table.token": "Token",
"page.api_keys.title": "API Keys",
"page.categories_count": [
"%d category",
"%d categories"
],
"page.categories_count": ["%d category", "%d categories"],
"page.categories.entries": "Entries",
"page.categories.feed_count": [
"There is %d feed.",
"There are %d feeds."
],
"page.categories.feed_count": ["There is %d feed.", "There are %d feeds."],
"page.categories.feeds": "Feeds",
"page.categories.no_feed": "No feed.",
"page.categories.title": "Categories",
@ -443,10 +434,7 @@
"page.edit_feed.title": "Edit Feed: %s",
"page.edit_user.title": "Edit User: %s",
"page.entry.attachments": "Attachments",
"page.feeds.error_count": [
"%d error",
"%d errors"
],
"page.feeds.error_count": ["%d error", "%d errors"],
"page.feeds.last_check": "Last check:",
"page.feeds.next_check": "Next check:",
"page.feeds.read_counter": "Number of read entries",
@ -511,10 +499,7 @@
"page.offline.message": "You are offline",
"page.offline.refresh_page": "Try to refresh the page",
"page.offline.title": "Offline Mode",
"page.read_entry_count": [
"%d read entry",
"%d read entries"
],
"page.read_entry_count": ["%d read entry", "%d read entries"],
"page.search.title": "Search Results",
"page.sessions.table.actions": "Actions",
"page.sessions.table.current_session": "Current Session",
@ -529,33 +514,18 @@
"page.settings.unlink_oidc_account": "Unlink my %s account",
"page.settings.webauthn.actions": "Actions",
"page.settings.webauthn.added_on": "Added On",
"page.settings.webauthn.delete": [
"Remove %d passkey",
"Remove %d passkeys"
],
"page.settings.webauthn.delete": ["Remove %d passkey", "Remove %d passkeys"],
"page.settings.webauthn.last_seen_on": "Last Used",
"page.settings.webauthn.passkey_name": "Passkey Name",
"page.settings.webauthn.passkeys": "Passkeys",
"page.settings.webauthn.register": "Register passkey",
"page.settings.webauthn.register.error": "Unable to register passkey",
"page.shared_entries_count": [
"%d shared entry",
"%d shared entries"
],
"page.shared_entries_count": ["%d shared entry", "%d shared entries"],
"page.shared_entries.title": "Shared entries",
"page.starred_entry_count": [
"%d starred entry",
"%d starred entries"
],
"page.starred_entry_count": ["%d starred entry", "%d starred entries"],
"page.starred.title": "Starred",
"page.total_entry_count": [
"%d entry in total",
"%d entries in total"
],
"page.unread_entry_count": [
"%d unread entry",
"%d unread entries"
],
"page.total_entry_count": ["%d entry in total", "%d entries in total"],
"page.unread_entry_count": ["%d unread entry", "%d unread entries"],
"page.unread.title": "Unread",
"page.users.actions": "Actions",
"page.users.admin.no": "No",
@ -574,33 +544,553 @@
"search.placeholder": "Search…",
"search.submit": "Search",
"skip_to_content": "Skip to content",
"time_elapsed.days": [
"%d day ago",
"%d days ago"
],
"time_elapsed.hours": [
"%d hour ago",
"%d hours ago"
],
"time_elapsed.minutes": [
"%d minute ago",
"%d minutes ago"
],
"time_elapsed.months": [
"%d month ago",
"%d months ago"
],
"time_elapsed.days": ["%d day ago", "%d days ago"],
"time_elapsed.hours": ["%d hour ago", "%d hours ago"],
"time_elapsed.minutes": ["%d minute ago", "%d minutes ago"],
"time_elapsed.months": ["%d month ago", "%d months ago"],
"time_elapsed.not_yet": "not yet",
"time_elapsed.now": "just now",
"time_elapsed.weeks": [
"%d week ago",
"%d weeks ago"
],
"time_elapsed.years": [
"%d year ago",
"%d years ago"
],
"time_elapsed.weeks": ["%d week ago", "%d weeks ago"],
"time_elapsed.years": ["%d year ago", "%d years ago"],
"time_elapsed.yesterday": "yesterday",
"tooltip.keyboard_shortcuts": "Keyboard Shortcut: %s",
"tooltip.logged_user": "Logged in as %s"
"tooltip.logged_user": "Logged in as %s",
"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 dont 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 wont 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"
}

View file

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

View file

@ -241,7 +241,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":

View file

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

View file

@ -132,6 +132,7 @@
{{ if .flashErrorMessage }}
<div role="alert" class="flash-error-message alert alert-error">{{ .flashErrorMessage }}</div>
{{ end }}
<div role="alert" aria-live="assertive" aria-atomic="true" class="flash-message alert alert-warning offline-hidden">{{ t "page.cache.warning" }}</div>
{{template "page_header" .}}

View file

@ -4,7 +4,7 @@
<section class="entry" data-id="{{ .entry.ID }}" aria-labelledby="page-header-title">
<header class="entry-header">
<h1 id="page-header-title" dir="auto">
<a href="{{ .entry.URL | safeURL }}" target="_blank" rel="noopener noreferrer" referrerpolicy="no-referrer">{{ .entry.Title }}</a>
<a href="{{ .entry.URL | safeURL }}" target="_blank" rel="noopener noreferrer" referrerpolicy="strict-origin">{{ .entry.Title }}</a>
</h1>
{{ if .user }}
<div class="entry-actions">
@ -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" }}<span class="icon-label">{{ t "entry.external_link.label" }}</span></a>
</li>
<li>
@ -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" }}<span class="icon-label">{{ t "entry.comments.label" }}</span></a>
</li>
@ -232,7 +232,7 @@
{{ end }}
<div class="entry-enclosure-download">
<a href="{{ .URL | safeURL }}" title="{{ t "action.download" }}{{ if gt .Size 0 }} - {{ formatFileSize .Size }}{{ end }}" target="_blank" rel="noopener noreferrer" referrerpolicy="no-referrer">{{ .URL | safeURL }}</a>
<a href="{{ .URL | safeURL }}" title="{{ t "action.download" }}{{ if gt .Size 0 }} - {{ formatFileSize .Size }}{{ end }}" target="_blank" rel="noopener noreferrer" referrerpolicy="strict-origin">{{ .URL | safeURL }}</a>
<small>{{ if gt .Size 0 }} - <strong>{{ formatFileSize .Size }}</strong>{{ end }}</small>
</div>
</div>

View file

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

View file

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

View file

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

View file

@ -24,7 +24,9 @@ hr {
padding-bottom: 10px;
}
h1, h2, h3 {
h1,
h2,
h3 {
color: var(--title-color);
}
@ -75,7 +77,8 @@ a:hover {
padding: var(--padding-size);
position: absolute;
transition: translate 0.3s;
translate: -50% calc(-100% - calc(var(--padding-size) * 2) - calc(var(--border-size) * 2));
translate: -50%
calc(-100% - calc(var(--padding-size) * 2) - calc(var(--border-size) * 2));
}
.skip-to-content-link:focus {
@ -262,7 +265,7 @@ a:hover {
}
#toast-msg {
background-color: rgba(0,0,0,0.7);
background-color: rgba(0, 0, 0, 0.7);
padding-bottom: 4px;
padding-left: 4px;
padding-right: 5px;
@ -274,11 +277,30 @@ a:hover {
}
@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 */
@ -309,7 +331,7 @@ a:hover {
padding: 0 12px 0 0;
line-height: normal;
border: none;
font-size: 1.0em;
font-size: 1em;
}
.header nav {
@ -321,7 +343,8 @@ a:hover {
display: none;
}
.header ul:not(.js-menu-show), .header ul.js-menu-show {
.header ul:not(.js-menu-show),
.header ul.js-menu-show {
display: revert;
}
@ -347,11 +370,14 @@ table {
border-collapse: collapse;
}
table, th, td {
table,
th,
td {
border: 1px solid var(--table-border-color);
}
th, td {
th,
td {
padding: 5px;
text-align: left;
}
@ -642,7 +668,7 @@ template {
right: 0;
font-size: 1.7em;
color: #ccc;
padding:0 .2em;
padding: 0 0.2em;
margin: 10px;
text-decoration: none;
background-color: transparent;
@ -706,7 +732,6 @@ template {
color: var(--category-link-hover-color);
}
.category-item-total {
color: var(--body-color);
}
@ -746,7 +771,8 @@ template {
padding-left: 15px;
}
.pagination-next, .pagination-last {
.pagination-next,
.pagination-last {
text-align: right;
}
@ -784,7 +810,8 @@ template {
}
.item.current-item {
border: var(--current-item-border-width) solid var(--current-item-border-color);
border: var(--current-item-border-width) solid
var(--current-item-border-color);
padding: 3px;
box-shadow: var(--current-item-box-shadow);
}
@ -793,7 +820,6 @@ template {
outline: none;
}
.item-header {
font-size: 1rem;
}
@ -948,7 +974,7 @@ article.category-has-unread {
}
.entry header h1 {
font-size: 2.0em;
font-size: 2em;
line-height: 1.25em;
margin: 5px 0 30px 0;
overflow-wrap: break-word;
@ -1026,7 +1052,12 @@ article.category-has-unread {
touch-action: pan-y pinch-zoom;
}
.entry-content h1, h2, h3, h4, h5, h6 {
.entry-content h1,
h2,
h3,
h4,
h5,
h6 {
margin-top: 15px;
margin-bottom: 10px;
}
@ -1242,37 +1273,38 @@ details.entry-enclosures {
opacity: 20%;
}
audio, video {
audio,
video {
width: 100%;
}
.media-controls{
font-size: .9em;
.media-controls {
font-size: 0.9em;
display: flex;
flex-wrap: wrap;
}
.media-controls .media-control-label{
.media-controls .media-control-label {
line-height: 1em;
}
.media-controls>div{
.media-controls > div {
display: flex;
flex-wrap: nowrap;
justify-content:center;
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{
.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
*/
@ -1291,6 +1323,7 @@ audio, video {
margin-top: 15px;
}
.hidden {
.hidden,
.offline-hidden {
display: none;
}

View file

@ -30,12 +30,18 @@ function checkMenuToggleModeByLayout() {
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");
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");
logoElement.setAttribute(
"aria-expanded",
navMenuElementIsExpanded ? "true" : "false",
);
homePageLinkElement.setAttribute("tabindex", "-1");
} else {
logoElement.removeAttribute("role");
@ -50,17 +56,26 @@ function fixVoiceOverDetailsSummaryBug() {
document.querySelectorAll("details").forEach((details) => {
const summaryElement = details.querySelector("summary");
summaryElement.setAttribute("role", "button");
summaryElement.setAttribute("aria-expanded", details.open? "true": "false");
summaryElement.setAttribute(
"aria-expanded",
details.open ? "true" : "false",
);
details.addEventListener("toggle", () => {
summaryElement.setAttribute("aria-expanded", details.open? "true": "false");
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 === " ")) {
if (
event.type === "keydown" &&
!(event.key === "Enter" || event.key === " ")
) {
return;
}
@ -125,7 +140,9 @@ function markPageAsRead() {
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]");
const element = document.querySelector(
":is(a, button)[data-action=markPageAsRead]",
);
let showOnlyUnread = false;
if (element) {
showOnlyUnread = element.dataset.showOnlyUnread || false;
@ -148,6 +165,7 @@ function markPageAsRead() {
* @param {boolean} setToRead
*/
function handleEntryStatus(item, element, setToRead) {
<<<<<<< HEAD
const toasting = !element;
const currentEntry = findEntry(element);
if (currentEntry) {
@ -164,13 +182,35 @@ function handleEntryStatus(item, element, setToRead) {
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);
>>>>>>> 55d8ddfa (PWA: First implementation of offline mode)
}
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');
const span = document.createElement("span");
span.classList.add("icon-label");
span.textContent = labelTextContent;
element.appendChild(span);
}
@ -237,7 +277,7 @@ function updateEntriesStatus(entryIDs, status, callback) {
const request = new RequestBuilder(url);
request.withBody({ entry_ids: entryIDs, status: status });
request.withCallback((resp) => {
resp.json().then(count => {
resp.json().then((count) => {
if (callback) {
callback(resp);
}
@ -257,7 +297,10 @@ function handleSaveEntry(element) {
const toasting = !element;
const currentEntry = findEntry(element);
if (currentEntry) {
saveEntry(currentEntry.querySelector(":is(a, button)[data-save-entry]"), toasting);
saveEntry(
currentEntry.querySelector(":is(a, button)[data-save-entry]"),
toasting,
);
}
}
@ -270,6 +313,12 @@ function saveEntry(element, toasting) {
element.textContent = "";
appendIconLabel(element, element.dataset.labelLoading);
const request = new RequestBuilder(element.dataset.saveUrl);
request.withCallback(() => {
element.textContent = "";
<<<<<<< HEAD
appendIconLabel(element, element.dataset.labelLoading);
const request = new RequestBuilder(element.dataset.saveUrl);
request.withCallback(() => {
element.textContent = "";
@ -281,6 +330,16 @@ function saveEntry(element, toasting) {
}
});
request.execute();
=======
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();
>>>>>>> 55d8ddfa (PWA: First implementation of offline mode)
}
// Handle bookmark from the list view and entry view.
@ -294,7 +353,9 @@ function handleBookmark(element) {
// 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]");
const buttonElement = parentElement.querySelector(
":is(a, button)[data-toggle-bookmark]",
);
if (!buttonElement) {
return;
}
@ -335,7 +396,9 @@ function handleFetchOriginalContent() {
return;
}
const buttonElement = document.querySelector(":is(a, button)[data-fetch-content-entry]");
const buttonElement = document.querySelector(
":is(a, button)[data-fetch-content-entry]",
);
if (!buttonElement) {
return;
}
@ -347,13 +410,19 @@ function handleFetchOriginalContent() {
const request = new RequestBuilder(buttonElement.dataset.fetchContentUrl);
request.withCallback((response) => {
buttonElement.textContent = '';
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 (
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;
}
@ -374,20 +443,35 @@ function openOriginalLink(openLinkInCurrentTab) {
return;
}
const currentItemOriginalLink = document.querySelector(".current-item :is(a, button)[data-original-link]");
const currentItemOriginalLink = document.querySelector(
".current-item :is(a, button)[data-original-link]",
);
if (currentItemOriginalLink !== null) {
DomHelper.openNewTab(currentItemOriginalLink.getAttribute("href"));
<<<<<<< HEAD
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);
>>>>>>> 55d8ddfa (PWA: First implementation of offline mode)
}
markEntryAsRead(currentItem);
}
}
function openCommentLink(openLinkInCurrentTab) {
<<<<<<< HEAD
if (!isListView()) {
const entryLink = document.querySelector(":is(a, button)[data-comments-link]");
if (entryLink !== null) {
@ -402,6 +486,27 @@ function openCommentLink(openLinkInCurrentTab) {
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;
>>>>>>> 55d8ddfa (PWA: First implementation of offline mode)
}
} else {
const currentItemCommentsLink = document.querySelector(
".current-item :is(a, button)[data-comments-link]",
);
if (currentItemCommentsLink !== null) {
DomHelper.openNewTab(currentItemCommentsLink.getAttribute("href"));
}
}
}
@ -413,7 +518,9 @@ function openSelectedItem() {
}
function unsubscribeFromFeed() {
const unsubscribeLinks = document.querySelectorAll("[data-action=remove-feed]");
const unsubscribeLinks = document.querySelectorAll(
"[data-action=remove-feed]",
);
if (unsubscribeLinks.length === 1) {
const unsubscribeLink = unsubscribeLinks[0];
@ -433,8 +540,15 @@ function unsubscribeFromFeed() {
* @param {string} page Page to redirect to.
* @param {boolean} fallbackSelf Refresh actual page if the page is not found.
*/
<<<<<<< HEAD
function goToPage(page, fallbackSelf = false) {
const element = document.querySelector(":is(a, button)[data-page=" + page + "]");
=======
function goToPage(page, fallbackSelf) {
const element = document.querySelector(
":is(a, button)[data-page=" + page + "]",
);
>>>>>>> 55d8ddfa (PWA: First implementation of offline mode)
if (element) {
document.location.href = element.href;
@ -477,7 +591,7 @@ function goToFeedOrFeeds() {
if (isEntry()) {
goToFeed();
} else {
goToPage('feeds');
goToPage("feeds");
}
}
@ -488,7 +602,9 @@ function goToFeed() {
window.location.href = feedAnchor.href;
}
} else {
const currentItemFeed = document.querySelector(".current-item :is(a, button)[data-feed-link]");
const currentItemFeed = document.querySelector(
".current-item :is(a, button)[data-feed-link]",
);
if (currentItemFeed !== null) {
window.location.href = currentItemFeed.getAttribute("href");
}
@ -503,6 +619,7 @@ const BOTTOM = -9999;
* @param {number} offset How many items to jump for focus.
*/
function goToListItem(offset) {
<<<<<<< HEAD
const items = DomHelper.getVisibleElements(".items .item");
if (items.length === 0) {
return;
@ -534,6 +651,39 @@ function goToListItem(offset) {
break;
}
=======
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;
}
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;
>>>>>>> 55d8ddfa (PWA: First implementation of offline mode)
}
}
}
@ -562,15 +712,15 @@ function updateUnreadCounterValue(callback) {
element.textContent = callback(oldValue);
});
if (window.location.href.endsWith('/unread')) {
const oldValue = parseInt(document.title.split('(')[1], 10);
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;
}
return prefix + "(" + newValue + ")" + suffix;
},
);
}
}
@ -594,8 +744,12 @@ function findEntry(element) {
}
function handleConfirmationMessage(linkElement, callback) {
<<<<<<< HEAD
if (linkElement.tagName !== 'A' && linkElement.tagName !== "BUTTON") {
linkElement = linkElement.parentNode;
=======
if (linkElement.tagName != "A" && linkElement.tagName != "BUTTON") {
linkElement = linkElement.parentNode;
}
linkElement.style.display = "none";
@ -606,7 +760,9 @@ function handleConfirmationMessage(linkElement, callback) {
function createLoadingElement() {
const loadingElement = document.createElement("span");
loadingElement.className = "loading";
loadingElement.appendChild(document.createTextNode(linkElement.dataset.labelLoading));
loadingElement.appendChild(
document.createTextNode(linkElement.dataset.labelLoading),
);
questionElement.remove();
containerElement.appendChild(loadingElement);
@ -635,11 +791,14 @@ function handleConfirmationMessage(linkElement, callback) {
} else {
linkElement.style.display = "inline";
questionElement.remove();
>>>>>>> 55d8ddfa (PWA: First implementation of offline mode)
}
};
questionElement.className = "confirm";
questionElement.appendChild(document.createTextNode(linkElement.dataset.labelQuestion + " "));
questionElement.appendChild(
document.createTextNode(linkElement.dataset.labelQuestion + " "),
);
questionElement.appendChild(yesElement);
questionElement.appendChild(document.createTextNode(", "));
questionElement.appendChild(noElement);
@ -657,6 +816,7 @@ function showToast(label, iconElement) {
toastMsgElement.replaceChildren(iconElement.content.cloneNode(true));
appendIconLabel(toastMsgElement, label);
<<<<<<< HEAD
const toastElementWrapper = document.getElementById("toast-wrapper");
if (toastElementWrapper) {
toastElementWrapper.classList.remove('toast-animate');
@ -664,6 +824,15 @@ function showToast(label, iconElement) {
toastElementWrapper.classList.add('toast-animate');
}, 100);
}
=======
const toastElementWrapper = document.getElementById("toast-wrapper");
if (toastElementWrapper) {
toastElementWrapper.classList.remove("toast-animate");
setTimeout(function () {
toastElementWrapper.classList.add("toast-animate");
}, 100);
>>>>>>> 55d8ddfa (PWA: First implementation of offline mode)
}
}
}
@ -681,13 +850,19 @@ function handlePlayerProgressionSaveAndMarkAsReadOnCompletion(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 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)
if (
currentPositionInSeconds >= lastKnownPositionInSeconds + recordInterval ||
currentPositionInSeconds <= lastKnownPositionInSeconds - recordInterval
) {
playerElement.dataset.lastPosition = currentPositionInSeconds.toString();
const request = new RequestBuilder(playerElement.dataset.saveUrl);
@ -697,7 +872,11 @@ function handlePlayerProgressionSaveAndMarkAsReadOnCompletion(playerElement) {
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);
handleEntryStatus(
"none",
document.querySelector(":is(a, button)[data-toggle-status]"),
true,
);
}
}
}
@ -709,19 +888,23 @@ function handlePlayerProgressionSaveAndMarkAsReadOnCompletion(playerElement) {
* @returns {boolean}
*/
function isPlayerPlaying(element) {
return element &&
return (
element &&
element.currentTime > 0 &&
!element.paused &&
!element.ended &&
element.readyState > 2; //https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/readyState
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");
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);
}
@ -736,8 +919,8 @@ function handleShare() {
}
/**
* 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.");
@ -747,7 +930,7 @@ function checkShareAPI(title, url) {
try {
navigator.share({
title: title,
url: url
url: url,
});
} catch (err) {
console.error(err);
@ -772,6 +955,7 @@ function getCsrfToken() {
* @param {Element} button
*/
function handleMediaControl(button) {
<<<<<<< HEAD
const action = button.dataset.enclosureAction;
const value = parseFloat(button.dataset.actionValue);
const targetEnclosureId = button.dataset.enclosureId;
@ -802,4 +986,41 @@ function handleMediaControl(button) {
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;
}
});
>>>>>>> 55d8ddfa (PWA: First implementation of offline mode)
}

View file

@ -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);
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) => {
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.
const networkResponse = await fetch(event.request);
return networkResponse;
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);
const cachedResponse = await cache.match(OFFLINE_URL);
return cachedResponse;
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;
});
}
}
}
});

View file

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