// Settings search index helpers: which translation subtrees each settings tab // renders, extra keywords per tab, and the walker that turns those subtrees // into clickable sub-results. Kept free of React so it can be unit-tested. export type SettingsSearchTab = | 'account' | 'general' | 'notifications' | 'appearance' | 'message_list' | 'reading' | 'composing' | 'vacation' | 'filters' | 'organize' | 'security' | 'calendar' | 'contacts' | 'files' | 'addons' | 'backup'; type Tab = SettingsSearchTab; /** A row a settings page draws only in some situations. */ export type SettingsSearchCondition = /** A touch screen (pointer: coarse), e.g. the swipe actions. */ | 'touch' /** The Mountain View interface layout. */ | 'mountainView' /** More than one account signed in. */ | 'multiAccount'; export interface SettingsSearchEntry { /** * Translation key of the label the page draws for the setting. The page * marks that row with the same text in `data-search-label`, so a result * always lands on its row (a test renders every page and checks). */ label: string; /** The row is hidden until the Advanced switch is on. */ advanced?: true; /** The page draws the row only then. */ when?: SettingsSearchCondition; } // The settings each page draws, as the search lists them. Only labels a page // renders belong here: settings-search-anchors.test.tsx renders every page // and fails for an entry without its row. A description is found next to // the label (see descriptionKeyFor) and searched too. export const tabSearchEntries: Record = { account: [ { label: 'settings.account.name_label' }, { label: 'settings.account.email.label' }, { label: 'settings.account.auth_method_label' }, { label: 'settings.account.server.label', advanced: true }, { label: 'settings.account.sections.addresses' }, { label: 'settings.identities.identities_count.label' }, { label: 'settings.identities.sub_addressing.label' }, { label: 'settings.email_behavior.sub_address_delimiter.label', advanced: true }, { label: 'settings.account.accounts.title', when: 'multiAccount' }, ], general: [ { label: 'settings.language_region.title' }, { label: 'settings.language_region.language.label' }, { label: 'settings.language_region.date_format.label' }, { label: 'settings.language_region.date_locale.label', advanced: true }, { label: 'settings.language_region.time_format.label' }, { label: 'settings.language_region.time_zone.label' }, { label: 'settings.language_region.first_day.label' }, { label: 'protocol_handlers.title' }, { label: 'protocol_handlers.mailto_label' }, { label: 'protocol_handlers.webcal_label' }, { label: 'protocol_handlers.protocol_open_mode_label', advanced: true }, { label: 'tour.settings_section' }, { label: 'tour.restart_title' }, { label: 'tour.show_on_new_devices_title', advanced: true }, ], notifications: [ { label: 'settings.notifications.email.title' }, { label: 'settings.notifications.email.enabled' }, { label: 'settings.notifications.email.sound' }, { label: 'settings.appearance.favicon_unread_badge.label' }, { label: 'settings.notifications.calendar.title' }, { label: 'settings.notifications.calendar.enabled' }, { label: 'settings.notifications.sound_selection.title' }, { label: 'settings.notifications.sound_selection.choose' }, { label: 'settings.notifications.push.title' }, { label: 'settings.notifications.push.relay_label', advanced: true }, { label: 'settings.notifications.push.devices_title', advanced: true }, ], appearance: [ { label: 'settings.appearance.theme.label' }, { label: 'settings.themes.title' }, { label: 'settings.appearance.sections.text' }, { label: 'settings.appearance.font_size.label' }, { label: 'settings.appearance.list_density.label' }, { label: 'settings.appearance.animations.label' }, { label: 'settings.appearance.sections.layout' }, { label: 'settings.appearance.interface_layout.label' }, { label: 'settings.email_behavior.mail_layout.label' }, { label: 'settings.appearance.toolbar_position.label' }, { label: 'settings.appearance.toolbar_labels.label' }, { label: 'settings.appearance.sections.sidebar' }, { label: 'settings.appearance.colorful_sidebar_icons.label' }, { label: 'settings.appearance.show_folder_total_count.label' }, { label: 'settings.appearance.sections.experimental', advanced: true }, { label: 'settings.appearance.pro_interface.label', advanced: true }, { label: 'settings.appearance.hide_account_switcher.label', when: 'multiAccount' }, { label: 'settings.appearance.show_rail_account_list.label', when: 'multiAccount' }, ], message_list: [ { label: 'settings.email_behavior.group_conversations.label' }, { label: 'settings.email_behavior.show_preview.label' }, { label: 'settings.appearance.message_list_order.label' }, { label: 'settings.appearance.message_list_order.scope.label' }, { label: 'settings.appearance.tint_list_rows.label' }, { label: 'settings.email_behavior.show_verification_codes.label' }, { label: 'settings.message_list.sections.senders' }, { label: 'settings.advanced.sender_favicons.label' }, { label: 'settings.advanced.sender_favicons.refresh_label' }, { label: 'settings.advanced.show_avatars_in_junk.label', advanced: true }, { label: 'settings.message_list.sections.actions' }, { label: 'settings.email_behavior.hover_actions.label' }, { label: 'settings.message_list.sections.delete' }, { label: 'settings.email_behavior.delete_action.label' }, { label: 'settings.email_behavior.archive_mode.label' }, { label: 'settings.email_behavior.confirm_bulk_delete.label' }, { label: 'settings.email_behavior.permanently_delete_junk.label', advanced: true }, { label: 'settings.message_list.sections.loading', advanced: true }, { label: 'settings.email_behavior.emails_per_page.label', advanced: true }, { label: 'settings.email_behavior.clear_search_on_folder_change.label', advanced: true }, { label: 'settings.email_behavior.swipe_right_action.label', when: 'touch' }, { label: 'settings.email_behavior.swipe_left_action.label', when: 'touch' }, { label: 'settings.email_behavior.inbox_type.label', when: 'mountainView' }, ], reading: [ { label: 'settings.email_behavior.mark_read.label' }, { label: 'settings.email_behavior.return_to_list_after_action.label' }, { label: 'settings.reading_page.sections.display' }, { label: 'settings.email_behavior.message_spacing.label' }, { label: 'settings.email_behavior.plain_text_font.label' }, { label: 'settings.email_behavior.always_light_mode.label' }, { label: 'settings.reading_page.sections.images' }, { label: 'settings.email_behavior.external_content.label' }, { label: 'settings.email_behavior.trusted_senders.label' }, { label: 'settings.email_behavior.trusted_senders.use_address_book_label' }, { label: 'settings.reading_page.sections.attachments' }, { label: 'settings.email_behavior.attachment_position.label' }, { label: 'settings.email_behavior.attachment_click_action.label' }, { label: 'settings.email_behavior.attachment_image_previews.label' }, { label: 'settings.email_behavior.hide_inline_image_attachments.label' }, { label: 'settings.downloads.title', advanced: true }, { label: 'settings.downloads.email_template.label', advanced: true }, { label: 'settings.downloads.attachment_template.label', advanced: true }, { label: 'settings.downloads.bundle_template.label', advanced: true }, { label: 'settings.downloads.spaces.label', advanced: true }, { label: 'settings.downloads.lowercase.label', advanced: true }, { label: 'settings.downloads.strip_diacritics.label', advanced: true }, { label: 'settings.downloads.collapse_separators.label', advanced: true }, { label: 'settings.downloads.after_export.label', advanced: true }, ], composing: [ { label: 'settings.email_behavior.plain_text_mode.label' }, { label: 'settings.email_behavior.recipient_mentions.label' }, { label: 'settings.email_behavior.mention_adds_recipient.label' }, { label: 'settings.email_behavior.rtl_editing.label', advanced: true }, { label: 'settings.composing_page.sections.replying' }, { label: 'settings.email_behavior.quick_reply.label' }, { label: 'settings.email_behavior.auto_select_reply_identity.label' }, { label: 'settings.email_behavior.signature_position.label' }, { label: 'settings.email_behavior.signature_separator.label', advanced: true }, { label: 'settings.composing_page.sections.sending' }, { label: 'settings.email_behavior.send_delay.label' }, { label: 'settings.email_behavior.attachment_reminder.label' }, { label: 'settings.email_behavior.empty_subject_warning.label' }, { label: 'settings.email_behavior.placeholder_warning.label' }, { label: 'settings.composing_page.sections.receipts' }, { label: 'settings.email_behavior.request_read_receipt.label' }, { label: 'settings.email_behavior.read_receipt_response.label' }, { label: 'settings.templates.title' }, { label: 'settings.templates.export_import', advanced: true }, { label: 'settings.email_behavior.signatures_link.label' }, ], vacation: [ { label: 'settings.vacation.date_range.start' }, { label: 'settings.vacation.date_range.end' }, { label: 'settings.vacation.auto_reply.title' }, { label: 'settings.vacation.status.label' }, { label: 'settings.vacation.message.subject_label' }, { label: 'settings.vacation.message.html_label' }, { label: 'settings.vacation.forward.title' }, { label: 'settings.vacation.forward.enabled_label' }, ], filters: [ { label: 'settings.filters.title' }, { label: 'settings.filters.add_rule' }, { label: 'settings.filters.raw_editor' }, ], organize: [ { label: 'settings.folders.folder_list' }, { label: 'settings.folders.standard_roles', advanced: true }, { label: 'settings.folders.role_inbox', advanced: true }, { label: 'settings.folders.role_drafts', advanced: true }, { label: 'settings.folders.role_sent', advanced: true }, { label: 'settings.folders.role_trash', advanced: true }, { label: 'settings.folders.role_junk', advanced: true }, { label: 'settings.folders.role_archive', advanced: true }, { label: 'settings.keywords.title' }, { label: 'settings.keywords.nesting.label' }, ], security: [ { label: 'settings.security.title' }, { label: 'settings.security.totp.label' }, { label: 'settings.security.sections.devices' }, { label: 'settings.security.sections.encryption', advanced: true }, { label: 'settings.security.password.title' }, { label: 'settings.security.totp.section_title' }, { label: 'settings.security.app_passwords.title' }, { label: 'settings.security.api_keys.title', advanced: true }, { label: 'settings.security.encryption.section_title', advanced: true }, { label: 'settings.security.email_client.title' }, { label: 'settings.security.link_device.title' }, ], calendar: [ { label: 'calendar.settings.title' }, { label: 'calendar.settings.default_view' }, { label: 'calendar.settings.invitation_parsing' }, { label: 'calendar.settings.show_time_in_month_view' }, { label: 'calendar.settings.show_week_numbers' }, { label: 'calendar.settings.limit_hours' }, { label: 'calendar.settings.visible_hours' }, { label: 'calendar.settings.hide_non_working_days' }, { label: 'calendar.settings.calendar_free_scroll' }, { label: 'calendar.settings.hover_preview' }, { label: 'calendar.settings.show_birthday_calendar' }, { label: 'calendar.settings.enable_tasks' }, { label: 'calendar.management.title' }, { label: 'calendar.import.title' }, { label: 'calendar.subscription.title' }, ], contacts: [ { label: 'settings.contacts.group_by_letter_label' }, { label: 'settings.contacts.sort_by_last_name_label' }, { label: 'settings.contacts.import_label' }, { label: 'settings.contacts.export_label' }, { label: 'settings.contacts.manage_title' }, { label: 'settings.contacts.categories_title' }, ], files: [ { label: 'settings.files.display.title' }, { label: 'settings.files.default_view.label' }, { label: 'settings.files.default_sort.label' }, { label: 'settings.files.sort_direction.label' }, { label: 'settings.files.icons.title' }, { label: 'settings.files.show_icons.label' }, { label: 'settings.files.colored_icons.label' }, { label: 'settings.files.show_thumbnails.label' }, { label: 'settings.files.behavior.title', advanced: true }, { label: 'settings.files.show_hidden.label', advanced: true }, ], addons: [ { label: 'settings.sidebar_apps.title' }, { label: 'settings.sidebar_apps.keep_loaded', advanced: true }, ], backup: [ { label: 'settings.advanced.sections.backup' }, { label: 'settings.advanced.export_settings.label' }, { label: 'settings.advanced.import_settings.label' }, { label: 'settings.advanced.sections.fix' }, { label: 'settings.advanced.refresh_cache.label' }, { label: 'settings.advanced.reset_settings.label' }, { label: 'settings.advanced.debug_mode.label', advanced: true }, ], }; // Extra English keywords per tab so common search terms hit even when the // translation doesn't contain the literal word. /** Settings shown only while the admin has the Archive action on (#1257). */ export const archiveActionSearchPaths: ReadonlySet = new Set(['settings.email_behavior.archive_mode.label']); export const tabKeywords: Record = { account: 'profile email user signin signout reorder rearrange drag dropdown switcher multi-account identities from address signature plus addressing unified mailbox shared group', general: 'language locale region timezone date time format mailto webcal links default app protocol handler tour onboarding', notifications: 'sound alert push badge favicon tab icon unread count background relay device', appearance: 'theme dark light font size accent color animation density custom theme css skin toolbar sidebar account switcher icons rail layout reading pane', message_list: 'inbox list preview thread conversation grouping sort order columns archive delete swipe hover favicon avatar tag color', reading: 'mark read attachment open monospace mono font plain text images remote content block sender privacy tracking dark light download filename template eml save export', composing: 'editor signature plain text reply forward draft compose template snippet quick reply mention undo send receipt attachment reminder', vacation: 'auto reply away out of office holiday responder', filters: 'sieve rules add create new block junk forward', organize: 'folders mailbox subscribe roles tags labels keywords colors nested', security: 'password 2fa two-factor passkey app password mfa api key token phone mobile app pair qr thunderbird outlook imap client encryption pgp smime', calendar: 'event schedule appointment meeting timezone invitation ics tasks birthday working hours', contacts: 'address book contact', files: 'attachments cloud drive storage upload', addons: 'apps webview iframe plugins extensions addons sidebar', backup: 'export import storage quota privacy backup reset sync cache about version debug logs developer console diagnostic', }; // Some settings texts name the app with {appName} (#1314). The index keeps // them filled in, so the search matches the name people see and a result // never carries the raw placeholder. export function withAppName(node: unknown, appName: string): unknown { if (typeof node === 'string') return node.split('{appName}').join(appName); if (Array.isArray(node)) return node.map((item) => withAppName(item, appName)); if (node && typeof node === 'object') { return Object.fromEntries(Object.entries(node).map(([key, value]) => [key, withAppName(value, appName)])); } return node; } export interface SubResult { label: string; description?: string; // For plugin setting fields: the id of the plugin whose card needs to be // expanded before the field becomes visible in the DOM. pluginId?: string; /** The row is hidden until the Advanced switch is on. */ advanced?: boolean; } /** * The key of the text a page shows under a label: `x.label` and `x.title` * have `x.description`, `x_label` has `x_description`, and a flat `x` (the * calendar settings) has `x_desc`. */ export function descriptionKeyFor(label: string, messages: unknown): string | undefined { const candidates = [ label.replace(/\.(label|title)$/, '.description'), label.replace(/_(label|title)$/, '_description'), `${label}_desc`, `${label}_description`, ].filter((key) => key !== label); return candidates.find((key) => typeof getByPath(messages, key) === 'string'); } /** * The search results a page offers, in the language of `messages`: * each entry's label and description, with the app's name filled in. * `shown` leaves out entries the page doesn't draw right now. */ export function settingsSubResults( messages: unknown, tab: SettingsSearchTab, appName: string, shown: (entry: SettingsSearchEntry) => boolean = () => true, ): SubResult[] { const list: SubResult[] = []; for (const entry of tabSearchEntries[tab]) { if (!shown(entry)) continue; const label = getByPath(messages, entry.label); if (typeof label !== 'string') continue; const descriptionKey = descriptionKeyFor(entry.label, messages); const description = descriptionKey ? (getByPath(messages, descriptionKey) as string) : undefined; list.push({ label: withAppName(label, appName) as string, ...(description ? { description: withAppName(description, appName) as string } : {}), ...(entry.advanced ? { advanced: true } : {}), }); } return list; } /** Every word of the query is somewhere in the text, in any order. */ export function matchesAllWords(text: string, query: string): boolean { const haystack = text.toLowerCase(); const words = query.toLowerCase().split(/\s+/).filter(Boolean); return words.length > 0 && words.every((word) => haystack.includes(word)); } export function getByPath(obj: unknown, path: string): unknown { let cur: unknown = obj; for (const key of path.split('.')) { if (cur && typeof cur === 'object' && key in (cur as Record)) { cur = (cur as Record)[key]; } else { return undefined; } } return cur; }