您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Customizable Google Search sidebar: quick filters (lang, time, filetype, country, date), site search, Verbatim & Personalization tools.
// ==UserScript== // @name Google Search Custom Sidebar // @name:zh-TW Google 搜尋自訂側邊欄 // @name:ja Google検索カスタムサイドバー // @namespace https://greasyfork.org/en/users/1467948-stonedkhajiit // @version 0.4.1 // @description Customizable Google Search sidebar: quick filters (lang, time, filetype, country, date), site search, Verbatim & Personalization tools. // @description:zh-TW Google 搜尋自訂側邊欄:快速篩選(語言、時間、檔案類型、國家、日期)、站內搜尋、一字不差與個人化工具。 // @description:ja Google検索カスタムサイドバー:高速フィルター(言語,期間,ファイル形式,国,日付)、サイト検索、完全一致検索とパーソナライズツール。 // @match https://www.google.com/search* // @include /^https:\/\/(?:ipv4|ipv6|www)\.google\.(?:[a-z\.]+)\/search\?(?:.+&)?q=[^&]+(?:&.+)?$/ // @exclude /^https:\/\/(?:ipv4|ipv6|www)\.google\.(?:[a-z\.]+)\/search\?(?:.+&)?(?:tbm=(?:isch|shop|bks|flm|fin|lcl)|udm=(?:2|28))(?:&.+)?$/ // @icon https://www.google.com/favicon.ico // @grant GM_addStyle // @grant GM_getValue // @grant GM_setValue // @grant GM_registerMenuCommand // @grant GM_deleteValue // @grant GM_openInTab // @run-at document-idle // @author StonedKhajiit // @license MIT // @require https://update.greasyfork.org/scripts/535624/1629293/Google%20Search%20Custom%20Sidebar%20-%20i18n.js // @require https://update.greasyfork.org/scripts/535625/1629294/Google%20Search%20Custom%20Sidebar%20-%20Styles.js // ==/UserScript== /** * @file Google Search Custom Sidebar * This script injects a customizable sidebar into Google search results pages, * offering advanced filtering options, site search capabilities, and quick-access tools. * It is designed to be highly modular, configurable, and maintainable. */ (function() { 'use strict'; // --- Script Constants & Configuration --- const SCRIPT_INTERNAL_NAME = 'GoogleSearchCustomSidebar'; const SCRIPT_VERSION = '0.4.1'; // version bumped for changes const LOG_PREFIX = `[${SCRIPT_INTERNAL_NAME} v${SCRIPT_VERSION}]`; /** * @constant {string[]} DEFAULT_SECTION_ORDER * Defines the default display order for sections in the sidebar. */ const DEFAULT_SECTION_ORDER = [ 'sidebar-section-language', 'sidebar-section-time', 'sidebar-section-filetype', 'sidebar-section-occurrence', 'sidebar-section-country', 'sidebar-section-date-range', 'sidebar-section-site-search', 'sidebar-section-tools' ]; /** * @constant {Object} defaultSettings * Contains the default configuration for the script. This object serves as a fallback * for any missing settings and is used when resetting the configuration. */ const defaultSettings = { sidebarPosition: { left: 0, top: 80 }, sectionStates: {}, theme: 'system', hoverMode: false, idleOpacity: 0.8, sidebarWidth: 135, sidebarHeight: 85, fontSize: 12.5, headerIconSize: 16, verticalSpacingMultiplier: 0.5, interfaceLanguage: 'auto', customColors: { bgColor: '', textColor: '', linkColor: '', selectedColor: '', inputTextColor: '', borderColor: '', dividerColor: '', btnBgColor: '', btnHoverBgColor: '', activeBgColor: '', activeTextColor: '', activeBorderColor: '', headerIconColor: '' }, visibleSections: { 'sidebar-section-language': true, 'sidebar-section-time': true, 'sidebar-section-filetype': true, 'sidebar-section-occurrence': true, 'sidebar-section-country': true, 'sidebar-section-date-range': true, 'sidebar-section-site-search': true, 'sidebar-section-tools': true }, sectionDisplayMode: 'remember', accordionMode: false, resetButtonLocation: 'topBlock', verbatimButtonLocation: 'header', advancedSearchLinkLocation: 'header', personalizationButtonLocation: 'tools', googleScholarShortcutLocation: 'tools', googleTrendsShortcutLocation: 'tools', googleDatasetSearchShortcutLocation: 'tools', countryDisplayMode: 'iconAndText', scrollbarPosition: 'right', showResultStats: true, customLanguages: [], customTimeRanges: [], customFiletypes: [ { text: "📄Documents", value: "pdf OR docx OR doc OR odt OR rtf OR txt" }, { text: "💹Spreadsheets", value: "xlsx OR xls OR ods OR csv" }, { text: "📊Presentations", value: "pptx OR ppt OR odp OR key" }, ], customCountries: [], displayLanguages: [], displayCountries: [], favoriteSites: [ // == SINGLE SITES: GENERAL KNOWLEDGE & REFERENCE == { text: 'Wikipedia (EN)', url: 'en.wikipedia.org' }, { text: 'Wiktionary', url: 'wiktionary.org' }, { text: 'Internet Archive', url: 'archive.org' }, // == SINGLE SITES: DEVELOPER & TECH == { text: 'GitHub', url: 'github.com' }, { text: 'GitLab', url: 'gitlab.com' }, { text: 'Stack Overflow', url: 'stackoverflow.com' }, { text: 'Hacker News', url: 'news.ycombinator.com' }, { text: 'Greasy Fork', url: 'greasyfork.org' }, // == SINGLE SITES: SOCIAL, FORUMS & COMMUNITIES == { text: 'Reddit', url: 'reddit.com' }, { text: 'X', url: 'x.com' }, { text: 'Mastodon', url: 'mastodon.social' }, { text: 'Bluesky', url: 'bsky.app' }, { text: 'Lemmy', url: 'lemmy.world' }, // == SINGLE SITES: ENTERTAINMENT, ARTS & HOBBIES == { text: 'IMDb', url: 'imdb.com' }, { text: 'TMDb', url: 'themoviedb.org' }, { text: 'Letterboxd', url: 'letterboxd.com' }, { text: 'Metacritic', url: 'metacritic.com' }, { text: 'OpenCritic', url: 'opencritic.com' }, { text: 'Steam', url: 'store.steampowered.com' }, { text: 'Bandcamp', url: 'bandcamp.com' }, { text: 'Last.fm', url: 'last.fm' }, // == COMBINED SITE GROUPS == { text: '💬Social', url: 'x.com OR facebook.com OR instagram.com OR threads.net OR bluesky.social OR mastodon.social OR reddit.com OR tumblr.com OR linkedin.com OR lemmy.world' }, { text: '📦Repositories', url: 'github.com OR gitlab.com OR bitbucket.org OR codeberg.org OR sourceforge.net' }, { text: '🎓Academics', url: 'scholar.google.com OR arxiv.org OR researchgate.net OR jstor.org OR academia.edu OR pubmed.ncbi.nlm.nih.gov OR semanticscholar.org OR core.ac.uk' }, { text: '📰News', url: 'bbc.com/news OR reuters.com OR apnews.com OR nytimes.com OR theguardian.com OR cnn.com OR wsj.com' }, { text: '🎨Creative', url: 'behance.net OR dribbble.com OR artstation.com OR deviantart.com' } ], enableSiteSearchCheckboxMode: true, showFaviconsForSiteSearch: true, enableFiletypeCheckboxMode: true, sidebarCollapsed: false, draggableHandleEnabled: true, enabledPredefinedOptions: { language: ['lang_en'], country: ['countryUS'], time: ['d', 'w', 'm', 'y', 'h'], filetype: ['pdf', 'docx', 'doc', 'pptx', 'ppt', 'xlsx', 'xls', 'txt'] }, sidebarSectionOrder: [...DEFAULT_SECTION_ORDER], hideGoogleLogoWhenExpanded: false, }; /** * @constant {Object<string, string>} IDS * A centralized collection of all DOM element IDs used throughout the script. * Uses a 'gscs-' prefix and a BEM-like naming convention to prevent conflicts with the host page. */ const IDS = { SIDEBAR: 'gscs-sidebar', SETTINGS_OVERLAY: 'gscs-settings-overlay', SETTINGS_WINDOW: 'gscs-settings-window', COLLAPSE_BUTTON: 'gscs-sidebar-collapse-button', SETTINGS_BUTTON: 'gscs-sidebar-settings-button', TOOL_RESET_BUTTON: 'gscs-tool-reset-button', TOOL_VERBATIM: 'gscs-tool-verbatim', TOOL_PERSONALIZE: 'gscs-tool-personalize-search', TOOL_GOOGLE_SCHOLAR: 'gscs-tool-google-scholar', TOOL_GOOGLE_TRENDS: 'gscs-tool-google-trends', TOOL_GOOGLE_DATASET_SEARCH: 'gscs-tool-google-dataset-search', APPLY_SELECTED_SITES_BUTTON: 'gscs-apply-selected-sites-button', APPLY_SELECTED_FILETYPES_BUTTON: 'gscs-apply-selected-filetypes-button', FIXED_TOP_BUTTONS: 'gscs-sidebar-fixed-top-buttons', SETTINGS_MESSAGE_BAR: 'gscs-settings-message-bar', SETTING_WIDTH: 'gscs-setting-sidebar-width', SETTING_HEIGHT: 'gscs-setting-sidebar-height', SETTING_FONT_SIZE: 'gscs-setting-font-size', SETTING_HEADER_ICON_SIZE: 'gscs-setting-header-icon-size', SETTING_VERTICAL_SPACING: 'gscs-setting-vertical-spacing', SETTING_INTERFACE_LANGUAGE: 'gscs-setting-interface-language', SETTING_SECTION_MODE: 'gscs-setting-section-display-mode', SETTING_ACCORDION: 'gscs-setting-accordion-mode', SETTING_DRAGGABLE: 'gscs-setting-draggable-handle', SETTING_RESET_LOCATION: 'gscs-setting-reset-button-location', SETTING_VERBATIM_LOCATION: 'gscs-setting-verbatim-button-location', SETTING_ADV_SEARCH_LOCATION: 'gscs-setting-adv-search-link-location', SETTING_PERSONALIZE_LOCATION: 'gscs-setting-personalize-button-location', SETTING_SCHOLAR_LOCATION: 'gscs-setting-scholar-shortcut-location', SETTING_TRENDS_LOCATION: 'gscs-setting-trends-shortcut-location', SETTING_DATASET_SEARCH_LOCATION: 'gscs-setting-dataset-search-shortcut-location', SETTING_SITE_SEARCH_CHECKBOX_MODE: 'gscs-setting-site-search-checkbox-mode', SETTING_SHOW_FAVICONS: 'gscs-setting-show-favicons', SETTING_FILETYPE_SEARCH_CHECKBOX_MODE: 'gscs-setting-filetype-search-checkbox-mode', SETTING_COUNTRY_DISPLAY_MODE: 'gscs-setting-country-display-mode', SETTING_THEME: 'gscs-setting-theme', SETTING_HOVER: 'gscs-setting-hover-mode', SETTING_OPACITY: 'gscs-setting-idle-opacity', SETTING_HIDE_GOOGLE_LOGO: 'gscs-setting-hide-google-logo', SETTING_SCROLLBAR_POSITION: 'gscs-setting-scrollbar-position', SETTING_SHOW_RESULT_STATS: 'gscs-setting-show-result-stats', CUSTOM_COLORS_CONTAINER: 'gscs-custom-colors-container', SETTING_COLOR_BG_COLOR: 'gscs-setting-color-bg-color', SETTING_COLOR_TEXT_COLOR: 'gscs-setting-color-text-color', SETTING_COLOR_LINK_COLOR: 'gscs-setting-color-link-color', SETTING_COLOR_SELECTED_COLOR: 'gscs-setting-color-selected-color', SETTING_COLOR_INPUT_TEXT_COLOR: 'gscs-setting-color-input-text-color', SETTING_COLOR_BORDER_COLOR: 'gscs-setting-color-border-color', SETTING_COLOR_DIVIDER_COLOR: 'gscs-setting-color-divider-color', SETTING_COLOR_BTN_BG_COLOR: 'gscs-setting-color-btn-bg-color', SETTING_COLOR_BTN_HOVER_BG_COLOR: 'gscs-setting-color-btn-hover-bg-color', SETTING_COLOR_ACTIVE_BG_COLOR: 'gscs-setting-color-active-bg-color', SETTING_COLOR_ACTIVE_TEXT_COLOR: 'gscs-setting-color-active-text-color', SETTING_COLOR_ACTIVE_BORDER_COLOR: 'gscs-setting-color-active-border-color', SETTING_COLOR_HEADER_ICON_COLOR: 'gscs-setting-color-header-icon-color', RESET_CUSTOM_COLORS_BTN: 'gscs-reset-custom-colors-btn', TAB_PANE_GENERAL: 'gscs-tab-pane-general', TAB_PANE_APPEARANCE: 'gscs-tab-pane-appearance', TAB_PANE_FEATURES: 'gscs-tab-pane-features', TAB_PANE_CUSTOM: 'gscs-tab-pane-custom', SITES_LIST: 'gscs-custom-sites-list', LANG_LIST: 'gscs-custom-languages-list', TIME_LIST: 'gscs-custom-time-ranges-list', FT_LIST: 'gscs-custom-filetypes-list', COUNTRIES_LIST: 'gscs-custom-countries-list', NEW_SITE_NAME: 'gscs-new-site-name', NEW_SITE_URL: 'gscs-new-site-url', ADD_SITE_BTN: 'gscs-add-site-button', NEW_LANG_TEXT: 'gscs-new-lang-text', NEW_LANG_VALUE: 'gscs-new-lang-value', ADD_LANG_BTN: 'gscs-add-lang-button', NEW_TIME_TEXT: 'gscs-new-timerange-text', NEW_TIME_VALUE: 'gscs-new-timerange-value', ADD_TIME_BTN: 'gscs-add-timerange-button', NEW_FT_TEXT: 'gscs-new-ft-text', NEW_FT_VALUE: 'gscs-new-ft-value', ADD_FT_BTN: 'gscs-add-ft-button', NEW_COUNTRY_TEXT: 'gscs-new-country-text', NEW_COUNTRY_VALUE: 'gscs-new-country-value', ADD_COUNTRY_BTN: 'gscs-add-country-button', DATE_MIN: 'gscs-date-min', DATE_MAX: 'gscs-date-max', DATE_RANGE_ERROR_MSG: 'gscs-date-range-error-msg', SIDEBAR_SECTION_ORDER_LIST: 'gscs-sidebar-section-order-list', NOTIFICATION_CONTAINER: 'gscs-notification-container', MODAL_ADD_NEW_OPTION_BTN: 'gscs-modal-add-new-option-btn', MODAL_PREDEFINED_CHOOSER_CONTAINER: 'gscs-modal-predefined-chooser-container', MODAL_PREDEFINED_CHOOSER_LIST: 'gscs-modal-predefined-chooser-list', MODAL_PREDEFINED_CHOOSER_ADD_BTN: 'gscs-modal-predefined-chooser-add-btn', MODAL_PREDEFINED_CHOOSER_CANCEL_BTN: 'gscs-modal-predefined-chooser-cancel-btn', CLEAR_SITE_SEARCH_OPTION: 'gscs-clear-site-search-option', CLEAR_FILETYPE_SEARCH_OPTION: 'gscs-clear-filetype-search-option', RESULT_STATS_CONTAINER: 'gscs-result-stats-container' }; /** * @constant {Object[]} COLOR_MAPPINGS * Maps settings UI color pickers to their corresponding CSS custom properties. * Each object defines the DOM element ID of the color picker, the key in the settings object, * and an array of CSS variables it controls. */ const COLOR_MAPPINGS = [ { id: IDS.SETTING_COLOR_BG_COLOR, key: 'bgColor', cssVars: ['--sidebar-bg-color'] }, { id: IDS.SETTING_COLOR_TEXT_COLOR, key: 'textColor', cssVars: ['--sidebar-text-color', '--sidebar-tool-btn-hover-text'] }, { id: IDS.SETTING_COLOR_LINK_COLOR, key: 'linkColor', cssVars: ['--sidebar-link-color', '--sidebar-link-hover-color', '--sidebar-header-btn-hover-color'] }, { id: IDS.SETTING_COLOR_SELECTED_COLOR, key: 'selectedColor', cssVars: ['--sidebar-selected-color'] }, { id: IDS.SETTING_COLOR_INPUT_TEXT_COLOR, key: 'inputTextColor', cssVars: ['--sidebar-input-text'] }, { id: IDS.SETTING_COLOR_BORDER_COLOR, key: 'borderColor', cssVars: ['--sidebar-border-color', '--sidebar-tool-btn-border', '--sidebar-input-border', '--sidebar-tool-btn-hover-border'] }, { id: IDS.SETTING_COLOR_DIVIDER_COLOR, key: 'dividerColor', cssVars: ['--sidebar-section-border-color'] }, { id: IDS.SETTING_COLOR_BTN_BG_COLOR, key: 'btnBgColor', cssVars: ['--sidebar-tool-btn-bg', '--sidebar-input-bg'] }, { id: IDS.SETTING_COLOR_BTN_HOVER_BG_COLOR, key: 'btnHoverBgColor', cssVars: ['--sidebar-tool-btn-hover-bg'] }, { id: IDS.SETTING_COLOR_ACTIVE_BG_COLOR, key: 'activeBgColor', cssVars: ['--sidebar-tool-btn-active-bg', '--sidebar-header-btn-active-bg'] }, { id: IDS.SETTING_COLOR_ACTIVE_TEXT_COLOR, key: 'activeTextColor', cssVars: ['--sidebar-tool-btn-active-text', '--sidebar-header-btn-active-color', '--sidebar-tool-btn-text'] }, { id: IDS.SETTING_COLOR_ACTIVE_BORDER_COLOR,key: 'activeBorderColor', cssVars: ['--sidebar-tool-btn-active-border'] }, { id: IDS.SETTING_COLOR_HEADER_ICON_COLOR, key: 'headerIconColor', cssVars: ['--sidebar-header-btn-color'] }, ]; /** * @constant {Object<string, string>} CSS * A centralized collection of all CSS class names used for styling and state management. * Follows a BEM-like convention (Block__Element--Modifier) for clarity and to prevent style conflicts. */ const CSS = { // State Modifiers (BEM-like) IS_SIDEBAR_COLLAPSED: 'is-sidebar-collapsed', IS_SECTION_COLLAPSED: 'is-section-collapsed', IS_SELECTED: 'is-selected', IS_ACTIVE: 'is-active', IS_DRAGGING: 'is-dragging', IS_DRAG_OVER: 'is-drag-over', IS_ERROR_VISIBLE: 'is-error-visible', HAS_ERROR: 'has-error', // Theme Classes THEME_LIGHT: 'gscs-theme-light', THEME_DARK: 'gscs-theme-dark', THEME_MINIMAL: 'gscs-theme-minimal', THEME_MINIMAL_LIGHT: 'gscs-theme-minimal--light', THEME_MINIMAL_DARK: 'gscs-theme-minimal--dark', // Components & Blocks SIDEBAR_HEADER: 'gscs-sidebar__header', SIDEBAR_CONTENT_WRAPPER: 'gscs-sidebar__content-wrapper', DRAG_HANDLE: 'gscs-sidebar__drag-handle', SETTINGS_BUTTON: 'gscs-settings-button', HEADER_BUTTON: 'gscs-header-button', SECTION: 'gscs-section', FIXED_TOP_BUTTONS_ITEM: 'gscs-fixed-top-buttons__item', SECTION_TITLE: 'gscs-section__title', SECTION_CONTENT: 'gscs-section__content', FILTER_OPTION: 'gscs-filter-option', CHECKBOX_SITE: 'gscs-checkbox--site', CHECKBOX_FILETYPE: 'gscs-checkbox--filetype', BUTTON_APPLY_SITES: 'gscs-button--apply-sites', BUTTON_APPLY_FILETYPES: 'gscs-button--apply-filetypes', DATE_INPUT_LABEL: 'gscs-date-input__label', DATE_INPUT_FIELD: 'gscs-date-input__field', BUTTON: 'gscs-button', CUSTOM_LIST: 'gscs-custom-list', CUSTOM_LIST_ITEM_CONTROLS: 'gscs-custom-list__item-controls', BUTTON_EDIT_ITEM: 'gscs-button--edit-item', BUTTON_DELETE_ITEM: 'gscs-button--delete-item', CUSTOM_LIST_INPUT_GROUP: 'gscs-custom-list__input-group', BUTTON_ADD_CUSTOM: 'gscs-button--add-custom', SETTINGS_HEADER: 'gscs-settings__header', SETTINGS_CLOSE_BTN: 'gscs-settings__close-button', SETTINGS_TABS: 'gscs-settings__tabs', TAB_BUTTON: 'gscs-tab-button', SETTINGS_TAB_CONTENT: 'gscs-settings__tab-content', TAB_PANE: 'gscs-tab-pane', SETTING_ITEM: 'gscs-setting-item', SETTING_ITEM_LABEL_INLINE: 'gscs-setting-item__label--inline', SETTINGS_FOOTER: 'gscs-settings__footer', BUTTON_SAVE: 'gscs-button--save', BUTTON_CANCEL: 'gscs-button--cancel', BUTTON_RESET: 'gscs-button--reset', SETTING_ITEM_SIMPLE: 'gscs-setting-item--simple', SETTING_RANGE_VALUE: 'gscs-setting-item__range-value', SETTING_RANGE_HINT: 'gscs-setting-item__range-hint', SECTION_ORDER_LIST: 'gscs-section-order-list', INPUT_ERROR_MSG: 'gscs-input-error-message', DATE_RANGE_ERROR_MSG: 'gscs-date-range-error-message', MESSAGE_BAR: 'gscs-message-bar', MSG_INFO: 'gscs-message-bar--info', MSG_SUCCESS: 'gscs-message-bar--success', MSG_WARNING: 'gscs-message-bar--warning', MSG_ERROR: 'gscs-message-bar--error', BUTTON_MANAGE_CUSTOM: 'gscs-button--manage-custom', NOTIFICATION: 'gscs-notification', NTF_INFO: 'gscs-notification--info', NTF_SUCCESS: 'gscs-notification--success', NTF_WARNING: 'gscs-notification--warning', NTF_ERROR: 'gscs-notification--error', DRAG_ICON: 'gscs-drag-icon', FAVICON: 'gscs-favicon', BUTTON_REMOVE_FROM_LIST: 'gscs-button--remove-from-list', MODAL_BUTTON_ADD_NEW: 'gscs-modal__add-new-button', MODAL_PREDEFINED_CHOOSER: 'gscs-modal-predefined-chooser', MODAL_PREDEFINED_CHOOSER_ITEM: 'gscs-modal-predefined-chooser__item', SETTING_VALUE_HINT: 'gscs-setting-value-hint' }; /** * @constant {Object<string, string>} DATA_ATTR * A collection of `data-*` attribute names used to store state or metadata directly on DOM elements. */ const DATA_ATTR = { FILTER_TYPE: 'filterType', FILTER_VALUE: 'filterValue', SITE_URL: 'siteUrl', SECTION_ID: 'sectionId', FILETYPE_VALUE: 'filetypeValue', LIST_ID: 'listId', INDEX: 'index', LISTENER_ATTACHED: 'listenerAttached', TAB: 'tab', MANAGE_TYPE: 'managetype', ITEM_TYPE: 'itemType', ITEM_ID: 'itemId' }; /** * @constant {string} STORAGE_KEY * The key used for storing the script's settings in the browser's local storage via GM_setValue/GM_getValue. */ const STORAGE_KEY = 'googleSearchCustomSidebarSettings_v1'; /** * @constant {Object<string, string>} SVG_ICONS * A collection of SVG icon strings. Storing them as strings allows them to be easily injected into the DOM * without needing to fetch external files. */ const SVG_ICONS = { chevronLeft: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 18 9 12 15 6"></polyline></svg>`, chevronRight: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"></polyline></svg>`, settings: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06-.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path></svg>`, reset: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"></polyline><polyline points="1 20 1 14 7 14"></polyline><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"></path></svg>`, verbatim: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><g transform="translate(-4.3875 -3.2375) scale(1.15)"><path d="M6 17.5c0 1.5 1.5 2.5 3 2.5h1.5c1.5 0 3-1 3-2.5V9c0-1.5-1.5-2.5-3-2.5H9C7.5 6.5 6 7.5 6 9v8.5z"/><path d="M15 17.5c0 1.5 1.5 2.5 3 2.5h1.5c1.5 0 3-1 3-2.5V9c0-1.5-1.5-2.5-3-2.5H18c-1.5 0-3 1-3 2.5v8.5z"/></g></svg>`, magnifyingGlass: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"></circle><line x1="21" y1="21" x2="16.65" y2="16.65"></line></svg>`, close: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>`, edit: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path></svg>`, delete: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"></polyline><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path><line x1="10" y1="11" x2="10" y2="17"></line><line x1="14" y1="11" x2="14" y2="17"></line></svg>`, add: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"></line><line x1="5" y1="12" x2="19" y2="12"></line></svg>`, update: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg>`, personalization: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path><circle cx="12" cy="7" r="4"></circle></svg>`, dragGrip: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="1em" height="1em" fill="currentColor"><circle cx="9" cy="6" r="1.5"/><circle cx="15" cy="6" r="1.5"/><circle cx="9" cy="12" r="1.5"/><circle cx="15" cy="12" r="1.5"/><circle cx="9" cy="18" r="1.5"/><circle cx="15" cy="18" r="1.5"/></svg>`, removeFromList: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>`, googleScholar: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" width="1em" height="1em"><path d="M12 3L1 9l11 6 9-4.91V17h2V9L12 3zm0 11.24L3.62 9 12 5.11 20.38 9 12 14.24zM5 13.18V17.5a1.5 1.5 0 001.5 1.5h11A1.5 1.5 0 0019 17.5v-4.32l-7 3.82-7-3.82z"/></svg>`, googleTrends: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" width="1em" height="1em"><path d="M16 6l2.29 2.29-4.88 4.88-4-4L2 16.59 3.41 18l6-6 4 4 6.3-6.29L22 12V6h-6z"/></svg>`, googleDatasetSearch: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" width="1em" height="1em"><path d="M3 18v-2h18v2H3Zm0-5v-2h18v2H3Zm0-5V6h18v2H3Z"/></svg>` }; /** * @constant {Object} SERVICE_SHORTCUT_CONFIG * A centralized configuration for all external service shortcut buttons. * This object drives the generic shortcut button creation function. */ const SERVICE_SHORTCUT_CONFIG = { googleScholar: { id: IDS.TOOL_GOOGLE_SCHOLAR, svgIcon: SVG_ICONS.googleScholar, titleKey: 'tooltip_google_scholar_search', textKey: 'tool_google_scholar', serviceNameKey: 'service_name_google_scholar', baseUrl: 'https://scholar.google.com/scholar', queryParam: 'q', homepage: 'https://scholar.google.com/' }, googleTrends: { id: IDS.TOOL_GOOGLE_TRENDS, svgIcon: SVG_ICONS.googleTrends, titleKey: 'tooltip_google_trends_search', textKey: 'tool_google_trends', serviceNameKey: 'service_name_google_trends', baseUrl: 'https://trends.google.com/trends/explore', queryParam: 'q', homepage: 'https://trends.google.com/trends/' }, googleDatasetSearch: { id: IDS.TOOL_GOOGLE_DATASET_SEARCH, svgIcon: SVG_ICONS.googleDatasetSearch, titleKey: 'tooltip_google_dataset_search', textKey: 'tool_google_dataset_search', serviceNameKey: 'service_name_google_dataset_search', baseUrl: 'https://datasetsearch.research.google.com/search', queryParam: 'query', homepage: 'https://datasetsearch.research.google.com/' } }; /** * @constant {Object} PREDEFINED_OPTIONS * A data structure containing all built-in, non-customizable filter options * for various categories like language, country, etc. The text is stored as a key * for internationalization. */ const PREDEFINED_OPTIONS = { language: [ { textKey: 'predefined_lang_en', value: 'lang_en' }, { textKey: 'predefined_lang_ja', value: 'lang_ja' }, { textKey: 'predefined_lang_ko', value: 'lang_ko' }, { textKey: 'predefined_lang_fr', value: 'lang_fr' }, { textKey: 'predefined_lang_de', value: 'lang_de' }, { textKey: 'predefined_lang_es', value: 'lang_es' }, { textKey: 'predefined_lang_it', value: 'lang_it' }, { textKey: 'predefined_lang_pt', value: 'lang_pt' }, { textKey: 'predefined_lang_ru', value: 'lang_ru' }, { textKey: 'predefined_lang_ar', value: 'lang_ar' }, { textKey: 'predefined_lang_hi', value: 'lang_hi' }, { textKey: 'predefined_lang_nl', value: 'lang_nl' }, { textKey: 'predefined_lang_tr', value: 'lang_tr' }, { textKey: 'predefined_lang_vi', value: 'lang_vi' }, { textKey: 'predefined_lang_th', value: 'lang_th' }, { textKey: 'predefined_lang_id', value: 'lang_id' }, { textKey: 'predefined_lang_zh_tw', value: 'lang_zh-TW' }, { textKey: 'predefined_lang_zh_cn', value: 'lang_zh-CN' }, { textKey: 'predefined_lang_zh_all', value: 'lang_zh-TW|lang_zh-CN' }, ], country: [ { textKey: 'predefined_country_us', value: 'countryUS' }, { textKey: 'predefined_country_gb', value: 'countryGB' }, { textKey: 'predefined_country_ca', value: 'countryCA' }, { textKey: 'predefined_country_au', value: 'countryAU' }, { textKey: 'predefined_country_de', value: 'countryDE' }, { textKey: 'predefined_country_fr', value: 'countryFR' }, { textKey: 'predefined_country_jp', value: 'countryJP' }, { textKey: 'predefined_country_kr', value: 'countryKR' }, { textKey: 'predefined_country_cn', value: 'countryCN' }, { textKey: 'predefined_country_in', value: 'countryIN' }, { textKey: 'predefined_country_br', value: 'countryBR' }, { textKey: 'predefined_country_mx', value: 'countryMX' }, { textKey: 'predefined_country_es', value: 'countryES' }, { textKey: 'predefined_country_it', value: 'countryIT' }, { textKey: 'predefined_country_ru', value: 'countryRU' }, { textKey: 'predefined_country_nl', value: 'countryNL' }, { textKey: 'predefined_country_sg', value: 'countrySG' }, { textKey: 'predefined_country_hk', value: 'countryHK' }, { textKey: 'predefined_country_tw', value: 'countryTW' }, { textKey: 'predefined_country_my', value: 'countryMY' }, { textKey: 'predefined_country_vn', value: 'countryVN' }, { textKey: 'predefined_country_ph', value: 'countryPH' }, { textKey: 'predefined_country_th', value: 'countryTH' }, { textKey: 'predefined_country_za', value: 'countryZA' }, { textKey: 'predefined_country_tr', value: 'countryTR' }, ], time: [ { textKey: 'predefined_time_h', value: 'h' }, { textKey: 'predefined_time_h2', value: 'h2' }, { textKey: 'predefined_time_h6', value: 'h6' }, { textKey: 'predefined_time_h12', value: 'h12' }, { textKey: 'predefined_time_d', value: 'd' }, { textKey: 'predefined_time_d2', value: 'd2' }, { textKey: 'predefined_time_d3', value: 'd3' }, { textKey: 'predefined_time_w', value: 'w' }, { textKey: 'predefined_time_m', value: 'm' }, { textKey: 'predefined_time_y', value: 'y' }, ], filetype: [ { textKey: 'predefined_filetype_pdf', value: 'pdf' }, { textKey: 'predefined_filetype_docx', value: 'docx' }, { textKey: 'predefined_filetype_doc', value: 'doc' }, { textKey: 'predefined_filetype_xlsx', value: 'xlsx' }, { textKey: 'predefined_filetype_xls', value: 'xls' }, { textKey: 'predefined_filetype_pptx', value: 'pptx' }, { textKey: 'predefined_filetype_ppt', value: 'ppt' }, { textKey: 'predefined_filetype_txt', value: 'txt' }, { textKey: 'predefined_filetype_rtf', value: 'rtf' }, { textKey: 'predefined_filetype_html', value: 'html' }, { textKey: 'predefined_filetype_htm', value: 'htm' }, { textKey: 'predefined_filetype_xml', value: 'xml' }, { textKey: 'predefined_filetype_jpg', value: 'jpg' }, { textKey: 'predefined_filetype_png', value: 'png' }, { textKey: 'predefined_filetype_gif', value: 'gif' }, { textKey: 'predefined_filetype_svg', value: 'svg' }, { textKey: 'predefined_filetype_bmp', value: 'bmp' }, { textKey: 'predefined_filetype_js', value: 'js' }, { textKey: 'predefined_filetype_css', value: 'css' }, { textKey: 'predefined_filetype_py', value: 'py' }, { textKey: 'predefined_filetype_java', value: 'java' }, { textKey: 'predefined_filetype_cpp', value: 'cpp' }, { textKey: 'predefined_filetype_cs', value: 'cs' }, { textKey: 'predefined_filetype_kml', value: 'kml'}, { textKey: 'predefined_filetype_kmz', value: 'kmz'}, ] }; /** * @constant {Object[]} ALL_SECTION_DEFINITIONS * The single source of truth for all sidebar sections. This array of objects defines * the properties and behavior of each section, such as its ID, type, title, * associated URL parameters, and keys for accessing settings. */ const ALL_SECTION_DEFINITIONS = [ { id: 'sidebar-section-language', type: 'filter', titleKey: 'section_language', scriptDefined: [{textKey:'filter_any_language',v:''}], param: 'lr', predefinedOptionsKey: 'language', customItemsKey: 'customLanguages', displayItemsKey: 'displayLanguages' }, { id: 'sidebar-section-time', type: 'filter', titleKey: 'section_time', scriptDefined: [{textKey:'filter_any_time',v:''}], param: 'qdr', predefinedOptionsKey: 'time', customItemsKey: 'customTimeRanges' }, { id: 'sidebar-section-filetype', type: 'filetype', titleKey: 'section_filetype', scriptDefined: [{ textKey: 'filter_any_format', v: '' }], param: 'as_filetype', predefinedOptionsKey: 'filetype', customItemsKey: 'customFiletypes' }, { id: 'sidebar-section-occurrence', type: 'filter', titleKey: 'section_occurrence', scriptDefined: [ { textKey: 'filter_occurrence_any', v: 'any' }, { textKey: 'filter_occurrence_title', v: 'title' }, { textKey: 'filter_occurrence_text', v: 'body' }, { textKey: 'filter_occurrence_url', v: 'url' }, { textKey: 'filter_occurrence_links', v: 'links' } ], param: 'as_occt' }, { id: 'sidebar-section-country', type: 'filter', titleKey: 'section_country', scriptDefined: [{textKey:'filter_any_country',v:''}], param: 'cr', predefinedOptionsKey: 'country', customItemsKey: 'customCountries', displayItemsKey: 'displayCountries' }, { id: 'sidebar-section-date-range', type: 'date', titleKey: 'section_date_range' }, { id: 'sidebar-section-site-search', type: 'site', titleKey: 'section_site_search', scriptDefined: [{ textKey: 'filter_any_site', v:''}] }, { id: 'sidebar-section-tools', type: 'tools', titleKey: 'section_tools' } ]; // --- Global State Variables --- let sidebar = null, systemThemeMediaQuery = null; const MIN_SIDEBAR_TOP_POSITION = 1; let debouncedSaveSettings; let globalMessageTimeout = null; /** * @module LocalizationService * Manages all internationalization (i18n) aspects of the script. * It merges built-in and external translation packs, detects the user's locale, * and provides a unified interface for retrieving translated strings. * This module ensures that all user-facing text can be easily localized. */ const LocalizationService = (function() { const builtInTranslations = { 'en': { scriptName: 'Google Search Custom Sidebar', settingsTitle: 'Google Search Custom Sidebar Settings', manageOptionsTitle: 'Manage Options', manageSitesTitle: 'Manage Favorite Sites', manageLanguagesTitle: 'Manage Language Options', manageCountriesTitle: 'Manage Country/Region Options', manageTimeRangesTitle: 'Manage Time Ranges', manageFileTypesTitle: 'Manage File Types', section_language: 'Language', section_time: 'Time', section_filetype: 'File Type', section_country: 'Country/Region', section_date_range: 'Date Range', section_site_search: 'Site Search', section_tools: 'Tools', section_occurrence: 'Keyword Location', filter_any_language: 'Any Language', filter_any_time: 'Any Time', filter_any_format: 'Any Format', filter_any_country: 'Any Country/Region', filter_any_site: 'Any Site', filter_occurrence_any: 'Anywhere in the page', filter_occurrence_title: 'In the title of the page', filter_occurrence_text: 'In the text of the page', filter_occurrence_url: 'In the URL of the page', filter_occurrence_links: 'In links to the page', filter_clear_site_search: 'Clear Site Search', filter_clear_tooltip_suffix: '(Clear)', predefined_lang_zh_tw: 'Traditional Chinese', predefined_lang_zh_cn: 'Simplified Chinese', predefined_lang_zh_all: 'All Chinese', predefined_lang_en: 'English', predefined_lang_ja: 'Japanese', predefined_lang_ko: 'Korean', predefined_lang_fr: 'French', predefined_lang_de: 'German', predefined_lang_es: 'Spanish', predefined_lang_it: 'Italian', predefined_lang_pt: 'Portuguese', predefined_lang_ru: 'Russian', predefined_lang_ar: 'Arabic', predefined_lang_hi: 'Hindi', predefined_lang_nl: 'Dutch', predefined_lang_tr: 'Turkish', predefined_lang_vi: 'Vietnamese', predefined_lang_th: 'Thai', predefined_lang_id: 'Indonesian', predefined_country_tw: '🇹🇼 Taiwan', predefined_country_jp: '🇯🇵 Japan', predefined_country_kr: '🇰🇷 South Korea', predefined_country_cn: '🇨🇳 China', predefined_country_hk: '🇭🇰 Hong Kong', predefined_country_sg: '🇸🇬 Singapore', predefined_country_my: '🇲🇾 Malaysia', predefined_country_vn: '🇻🇳 Vietnam', predefined_country_ph: '🇵🇭 Philippines', predefined_country_th: '🇹🇭 Thailand', predefined_country_us: '🇺🇸 United States', predefined_country_ca: '🇨🇦 Canada', predefined_country_br: '🇧🇷 Brazil', predefined_country_mx: '🇲🇽 Mexico', predefined_country_gb: '🇬🇧 United Kingdom', predefined_country_de: '🇩🇪 Germany', predefined_country_fr: '🇫🇷 France', predefined_country_it: '🇮🇹 Italy', predefined_country_es: '🇪🇸 Spain', predefined_country_ru: '🇷🇺 Russia', predefined_country_nl: '🇳🇱 Netherlands', predefined_country_au: '🇦🇺 Australia', predefined_country_in: '🇮🇳 India', predefined_country_za: '🇿🇦 South Africa', predefined_country_tr: '🇹🇷 Turkey', predefined_time_h: 'Past hour', predefined_time_h2: 'Past 2 hours', predefined_time_h6: 'Past 6 hours', predefined_time_h12: 'Past 12 hours', predefined_time_d: 'Past 24 hours', predefined_time_d2: 'Past 2 days', predefined_time_d3: 'Past 3 days', predefined_time_w: 'Past week', predefined_time_m: 'Past month', predefined_time_y: 'Past year', predefined_filetype_pdf: 'PDF', predefined_filetype_docx: 'Word (docx)', predefined_filetype_doc: 'Word (doc)', predefined_filetype_xlsx: 'Excel (xlsx)', predefined_filetype_xls: 'Excel (xls)', predefined_filetype_pptx: 'PowerPoint (pptx)', predefined_filetype_ppt: 'PowerPoint (ppt)', predefined_filetype_txt: 'Plain Text', predefined_filetype_rtf: 'Rich Text Format', predefined_filetype_html: 'Web Page (html)', predefined_filetype_htm: 'Web Page (htm)', predefined_filetype_xml: 'XML', predefined_filetype_jpg: 'JPEG Image', predefined_filetype_png: 'PNG Image', predefined_filetype_gif: 'GIF Image', predefined_filetype_svg: 'SVG Image', predefined_filetype_bmp: 'BMP Image', predefined_filetype_js: 'JavaScript', predefined_filetype_css: 'CSS', predefined_filetype_py: 'Python', predefined_filetype_java: 'Java', predefined_filetype_cpp: 'C++', predefined_filetype_cs: 'C#', predefined_filetype_kml: 'Google Earth (kml)', predefined_filetype_kmz: 'Google Earth (kmz)', tool_reset_filters: 'Reset Filters', tool_verbatim_search: 'Verbatim Search', tool_advanced_search: 'Advanced Search', tool_apply_date: 'Apply Dates', tool_personalization_toggle: 'Personalization', tool_apply_selected_sites: 'Apply Selected', tool_apply_selected_filetypes: 'Apply Selected', tool_google_scholar: 'Scholar', tooltip_google_scholar_search: 'Search current keywords on Google Scholar', service_name_google_scholar: 'Google Scholar', tool_google_trends: 'Trends', tooltip_google_trends_search: 'Explore current keywords on Google Trends', service_name_google_trends: 'Google Trends', tool_google_dataset_search: 'Dataset Search', tooltip_google_dataset_search: 'Search keywords on Google Dataset Search', service_name_google_dataset_search: 'Google Dataset Search', link_advanced_search_title: 'Open Google Advanced Search page', tooltip_site_search: 'Search within {siteUrl}', tooltip_clear_site_search: 'Remove site: restriction', tooltip_toggle_personalization_on: 'Click to turn Personalization ON (Results tailored to you)', tooltip_toggle_personalization_off: 'Click to turn Personalization OFF (More generic results)', settings_tab_general: 'General', settings_tab_appearance: 'Appearance', settings_tab_features: 'Features', settings_tab_custom: 'Custom', settings_close_button_title: 'Close', settings_interface_language: 'Interface Language:', settings_language_auto: 'Auto (Browser Default)', settings_section_mode: 'Section Collapse Mode:', settings_section_mode_remember: 'Remember State', settings_section_mode_expand: 'Expand All', settings_section_mode_collapse: 'Collapse All', settings_accordion_mode: 'Accordion Mode (only when "Remember State" is active)', settings_accordion_mode_hint_desc: 'When enabled, expanding one section will automatically collapse other open sections.', settings_enable_drag: 'Enable Dragging', settings_reset_button_location: 'Reset Button Location:', settings_verbatim_button_location: 'Verbatim Button Location:', settings_adv_search_location: '"Advanced Search" Link Location:', settings_personalize_button_location: 'Personalization Button Location:', settings_scholar_location: 'Google Scholar Shortcut Location:', settings_trends_location: 'Google Trends Shortcut Location:', settings_dataset_search_location: 'Dataset Search Shortcut Location:', settings_enable_site_search_checkbox_mode: 'Enable Checkbox Mode for Site Search', settings_enable_site_search_checkbox_mode_hint: 'Allows selecting multiple favorite sites for a combined (OR) search.', settings_show_favicons: 'Show Favicons for Site Search', settings_show_favicons_hint: 'Displays a website icon next to single-site entries for better identification.', settings_enable_filetype_search_checkbox_mode: 'Enable Checkbox Mode for Filetype Search', settings_enable_filetype_search_checkbox_mode_hint: 'Allows selecting multiple filetypes for a combined (OR) search.', settings_location_tools: 'Tools Section', settings_location_top: 'Top Block', settings_location_header: 'Sidebar Header', settings_location_hide: 'Hide', settings_sidebar_width: 'Sidebar Width (px)', settings_width_range_hint: '(Range: 90-270, Step: 5)', settings_sidebar_height: 'Sidebar Height (vh)', settings_height_range_hint: '(Range: 25-100, Step: 5)', settings_font_size: 'Base Font Size (px)', settings_font_size_range_hint: '(Range: 8-24, Step: 0.5)', settings_header_icon_size: 'Header Icon Size (px)', settings_header_icon_size_range_hint: '(Range: 8-32, Step: 0.5)', settings_vertical_spacing: 'Vertical Spacing', settings_vertical_spacing_range_hint: '(Multiplier Range: 0.05-1.5, Step: 0.05)', settings_theme: 'Theme:', settings_theme_system: 'Follow System', settings_theme_light: 'Light', settings_theme_dark: 'Dark', settings_theme_minimal_light: 'Minimal (Light)', settings_theme_minimal_dark: 'Minimal (Dark)', settings_hover_mode: 'Hover Mode', settings_idle_opacity: 'Idle Opacity:', settings_opacity_range_hint: '(Range: 0.1-1.0, Step: 0.05)', settings_country_display: 'Country/Region Display:', settings_country_display_icontext: 'Icon & Text', settings_country_display_text: 'Text Only', settings_country_display_icon: 'Icon Only', settings_scrollbar_position: 'Scrollbar Position:', settings_scrollbar_right: 'Right (Default)', settings_scrollbar_left: 'Left', settings_scrollbar_hidden: 'Hidden', settings_show_result_stats: 'Show Search Result Stats', settings_advanced_color_options: 'Advanced Color Options', settings_reset_colors_button: 'Reset Colors', settings_color_bg_color: 'Background Color', settings_color_text_color: 'Main Text Color', settings_color_link_color: 'Link & Title Color', settings_color_selected_color: 'Selected Item Text Color', settings_color_input_text_color: 'Input Field Text Color', settings_color_border_color: 'Main Border Color', settings_color_divider_color: 'Section Divider Color', settings_color_btn_bg_color: 'Button Background Color', settings_color_btn_hover_bg_color: 'Button Hover BG Color', settings_color_active_bg_color: 'Active Item BG Color', settings_color_active_text_color: 'Active Item Text/Icon Color', settings_color_active_border_color: 'Active Item Border Color', settings_color_header_icon_color: 'Header Icon Color', settings_visible_sections: 'Visible Sections:', settings_section_order: 'Adjust Sidebar Section Order (Drag & Drop):', settings_section_order_hint: '(Drag items to reorder. Only affects checked sections)', settings_no_orderable_sections: 'No visible sections to order.', settings_move_up_title: 'Move Up', settings_move_down_title: 'Move Down', settings_hide_google_logo: 'Hide Google Logo when sidebar is expanded', settings_hide_google_logo_hint: 'Useful if the sidebar is placed in the top-left corner with a minimal theme.', settings_custom_intro: 'Manage filter options for each section:', settings_manage_sites_button: 'Manage Favorite Sites...', settings_manage_languages_button: 'Manage Language Options...', settings_manage_countries_button: 'Manage Country/Region Options...', settings_manage_time_ranges_button: 'Manage Time Ranges...', settings_manage_file_types_button: 'Manage File Types...', settings_save_button: 'Save Settings', settings_cancel_button: 'Cancel', settings_reset_all_button: 'Reset All', modal_label_enable_predefined: 'Enable Predefined {type}:', modal_label_my_custom: 'My Custom {type}:', modal_label_display_options_for: 'Display Options for {type} (Drag to Sort):', modal_button_add_new_option: 'Add New Option...', modal_button_add_predefined_option: 'Add Predefined...', modal_button_add_custom_option: 'Add Custom...', modal_placeholder_name: 'Name', modal_placeholder_domain: 'Domain (e.g., site.com OR example.net/path)', modal_placeholder_text: 'Text', modal_placeholder_value: 'Value (e.g., pdf OR docx)', modal_hint_domain: 'Format: domain/path (e.g., `wikipedia.org/wiki/Page` or `site.com`). Use `OR` (case-insensitive, space separated) for multiple.', modal_hint_language: 'Format: starts with `lang_`, e.g., `lang_ja`, `lang_zh-TW`. Use `|` for multiple.', modal_hint_country: 'Format: `country` + 2-letter uppercase code, e.g., `countryDE`', modal_hint_time: 'Format: `h`, `d`, `w`, `m`, `y`, optionally followed by numbers, e.g., `h1`, `d7`, `w`', modal_hint_filetype: 'Format: extension (e.g., `pdf`). Use `OR` (case-insensitive, space separated) for multiple (e.g., `docx OR xls`).', modal_tooltip_domain: 'Enter domain(s) with optional path(s). Use OR for multiple, e.g., site.com/path OR example.org', modal_tooltip_language: 'Format: lang_xx or lang_xx-XX, separate multiple with |', modal_tooltip_country: 'Format: countryXX (XX = uppercase country code)', modal_tooltip_time: 'Format: h, d, w, m, y, optionally followed by numbers', modal_tooltip_filetype: 'File extension(s). Use OR for multiple, e.g., pdf OR docx', modal_button_add_title: 'Add', modal_button_update_title: 'Update Item', modal_button_cancel_edit_title: 'Cancel Edit', modal_button_edit_title: 'Edit', modal_button_delete_title: 'Delete', modal_button_remove_from_list_title: 'Remove from list', modal_button_complete: 'Done', value_empty: '(empty)', date_range_from: 'From:', date_range_to: 'To:', sidebar_collapse_title: 'Collapse', sidebar_expand_title: 'Expand', sidebar_drag_title: 'Drag', sidebar_settings_title: 'Settings', alert_invalid_start_date: 'Invalid start date', alert_invalid_end_date: 'Invalid end date', alert_end_before_start: 'End date cannot be earlier than start date', alert_start_in_future: 'Start date cannot be in the future', alert_end_in_future: 'End date cannot be in the future', alert_select_date: 'Please select a date', alert_error_applying_date: 'Error applying date range', alert_error_applying_filter: 'Error applying filter {type}={value}', alert_error_applying_site_search: 'Error applying site search for {site}', alert_error_clearing_site_search: 'Error clearing site search', alert_error_resetting_filters: 'Error resetting filters', alert_error_toggling_verbatim: 'Error toggling Verbatim search', alert_error_toggling_personalization: 'Error toggling Personalization search', alert_enter_display_name: 'Please enter the display name for {type}.', alert_enter_value: 'Please enter the corresponding value for {type}.', alert_invalid_value_format: 'The value format for {type} is incorrect. {hint}', alert_duplicate_name: 'Custom item display name "{name}" already exists. Please use a different name.', alert_update_failed_invalid_index: 'Update failed: Invalid item index.', alert_edit_failed_missing_fields: 'Cannot edit: Input or button fields not found.', alert_no_more_predefined_to_add: 'No more predefined {type} options available to add.', alert_no_keywords_for_shortcut: 'No keywords found in current search to use for {service_name}.', alert_error_opening_link: 'Error opening link for {service_name}.', alert_generic_error: 'An unexpected error occurred. Please check the console or try again. Context: {context}', confirm_delete_item: 'Are you sure you want to delete the custom item "{name}"?', confirm_remove_item_from_list: 'Are you sure you want to remove "{name}" from this display list?', confirm_reset_settings: 'Are you sure you want to reset all settings to their default values?', alert_settings_reset_success: 'Settings have been reset to default. You can continue editing or click "Save Settings" to confirm.', confirm_reset_all_menu: 'Are you sure you want to reset all settings to their default values?\nThis cannot be undone and requires a page refresh to take effect.', alert_reset_all_menu_success: 'All settings have been reset to defaults.\nPlease refresh the page to apply the changes.', alert_reset_all_menu_fail: 'Failed to reset settings via menu command! Please check the console.', alert_init_fail: '{scriptName} initialization failed. Some features may not work. Please check the console for technical details.\nTechnical Error: {error}', menu_open_settings: '⚙️ Open Settings', menu_reset_all_settings: '🚨 Reset All Settings', }, }; let effectiveTranslations = JSON.parse(JSON.stringify(builtInTranslations)); let _currentLocale = 'en'; /** * Merges external translation data (from the i18n companion script) * with the built-in English translations. It also ensures that all languages * have a complete set of keys, falling back to English if a key is missing. * @private */ function _mergeExternalTranslations() { if (typeof window.GSCS_Namespace !== 'undefined' && typeof window.GSCS_Namespace.i18nPack === 'object' && typeof window.GSCS_Namespace.i18nPack.translations === 'object') { const externalTranslations = window.GSCS_Namespace.i18nPack.translations; for (const langCode in externalTranslations) { if (Object.prototype.hasOwnProperty.call(externalTranslations, langCode)) { if (!effectiveTranslations[langCode]) { effectiveTranslations[langCode] = {}; } for (const key in externalTranslations[langCode]) { if (Object.prototype.hasOwnProperty.call(externalTranslations[langCode], key)) { effectiveTranslations[langCode][key] = externalTranslations[langCode][key]; } } } } // After merging, ensure 'en' from builtInTranslations acts as a fallback for all known languages const englishDefaults = builtInTranslations.en; for (const langCode in effectiveTranslations) { if (langCode !== 'en' && Object.prototype.hasOwnProperty.call(effectiveTranslations, langCode)) { for (const key in englishDefaults) { if (Object.prototype.hasOwnProperty.call(englishDefaults, key) && typeof effectiveTranslations[langCode][key] === 'undefined') { effectiveTranslations[langCode][key] = englishDefaults[key]; } } } } } else { console.warn(`${LOG_PREFIX} [i18n] External i18n pack (window.GSCS_Namespace.i18nPack) not found or invalid. Using built-in translations only.`); } // Ensure all keys from builtInTranslations.en exist in 'en' to prevent errors // if i18n.js is older or missing keys. const ensureKeys = (lang, defaults) => { if (!effectiveTranslations[lang]) effectiveTranslations[lang] = {}; for (const key in defaults) { if (!effectiveTranslations[lang][key]) { effectiveTranslations[lang][key] = defaults[key]; // Fallback to built-in English if key is missing in target lang } } }; ensureKeys('en', builtInTranslations.en); // Ensure English is complete based on built-in } /** * Detects the user's preferred language from the browser settings. * It attempts to match specific locales (e.g., "en-US") first, then generic * languages (e.g., "en"), and finally falls back to English. * @private * @returns {string} The detected locale code. */ function _detectBrowserLocale() { let locale = 'en'; // Default try { if (navigator.languages && navigator.languages.length) { locale = navigator.languages[0]; } else if (navigator.language) { locale = navigator.language; } } catch (e) { console.warn(`${LOG_PREFIX} [i18n] Error accessing navigator.language(s):`, e); } // Try to match full locale (e.g., "zh-TW") if (effectiveTranslations[locale]) return locale; // Try to match generic part (e.g., "zh" from "zh-TW") if (locale.includes('-')) { const parts = locale.split('-'); if (parts.length > 0 && effectiveTranslations[parts[0]]) return parts[0]; // Try "language-Script" (e.g., "zh-Hant") if applicable, though less common for userscripts if (parts.length > 2 && effectiveTranslations[`${parts[0]}-${parts[1]}`]) return `${parts[0]}-${parts[1]}`; } return 'en'; // Fallback to English } /** * Updates the active locale based on user settings or browser detection. * If the user has selected a specific language, it's used; otherwise, it auto-detects. * @private * @param {Object} settingsToUse - The current settings object to read the interfaceLanguage from. */ function _updateActiveLocale(settingsToUse) { let newLocale = 'en'; // Default const langSettingSource = (settingsToUse && Object.keys(settingsToUse).length > 0 && typeof settingsToUse.interfaceLanguage === 'string') ? settingsToUse : defaultSettings; // Fallback to defaultSettings if settingsToUse is empty/invalid const userSelectedLang = langSettingSource.interfaceLanguage; if (userSelectedLang && userSelectedLang !== 'auto') { if (effectiveTranslations[userSelectedLang]) { newLocale = userSelectedLang; } else if (userSelectedLang.includes('-')) { const genericLang = userSelectedLang.split('-')[0]; if (effectiveTranslations[genericLang]) { newLocale = genericLang; } else { newLocale = _detectBrowserLocale(); // Fallback to browser if specific parts aren't found } } else { newLocale = _detectBrowserLocale(); // Fallback if selected lang doesn't exist } } else { // 'auto' or undefined newLocale = _detectBrowserLocale(); } if (_currentLocale !== newLocale) { _currentLocale = newLocale; } // Warn if the chosen language isn't exactly what was set (e.g. "fr-CA" setting becomes "fr" due to availability) if (userSelectedLang && userSelectedLang !== 'auto' && _currentLocale !== userSelectedLang && !userSelectedLang.startsWith(_currentLocale.split('-')[0])) { console.warn(`${LOG_PREFIX} [i18n] User selected language "${userSelectedLang}" was not fully available or matched. Using best match: "${_currentLocale}".`); } } _mergeExternalTranslations(); // Merge external translations once at service creation /** * Retrieves a translated string for a given key. * It searches in the current locale, then the generic part of the locale, and finally * falls back to English if the key is not found. * @param {string} key - The translation key (e.g., 'settingsTitle'). * @param {Object} [replacements={}] - An object of placeholders to replace in the string (e.g., {siteUrl: 'example.com'}). * @returns {string} The translated (and formatted) string. */ function getString(key, replacements = {}) { let str = `[ERR: ${key} @ ${_currentLocale}]`; // Default error string let found = false; // 1. Try current locale if (effectiveTranslations[_currentLocale] && typeof effectiveTranslations[_currentLocale][key] !== 'undefined') { str = effectiveTranslations[_currentLocale][key]; found = true; } // 2. If current locale has a generic part (e.g., "zh" from "zh-TW"), try that else if (_currentLocale.includes('-')) { const genericLang = _currentLocale.split('-')[0]; if (effectiveTranslations[genericLang] && typeof effectiveTranslations[genericLang][key] !== 'undefined') { str = effectiveTranslations[genericLang][key]; found = true; } } // 3. If not found and current locale is not English, fallback to English if (!found && _currentLocale !== 'en') { if (effectiveTranslations['en'] && typeof effectiveTranslations['en'][key] !== 'undefined') { str = effectiveTranslations['en'][key]; found = true; } } // 4. If still not found (even in English), it's a critical miss if (!found) { if (!(effectiveTranslations['en'] && typeof effectiveTranslations['en'][key] !== 'undefined')) { console.error(`${LOG_PREFIX} [i18n] CRITICAL: Missing translation for key: "${key}" in BOTH locale: "${_currentLocale}" AND default locale "en".`); } else { // This case should ideally not be hit if English is complete in builtInTranslations str = effectiveTranslations['en'][key]; // Should have been caught by step 3 if _currentLocale wasn't 'en' found = true; } if(!found) str = `[ERR_NF: ${key}]`; // Final error if truly not found anywhere } // Replace placeholders if (typeof str === 'string') { for (const placeholder in replacements) { if (Object.prototype.hasOwnProperty.call(replacements, placeholder)) { str = str.replace(new RegExp(`\\{${placeholder}\\}`, 'g'), replacements[placeholder]); } } } else { console.error(`${LOG_PREFIX} [i18n] CRITICAL: Translation for key "${key}" is not a string:`, str); return `[INVALID_TYPE_FOR_KEY: ${key}]`; } return str; } return { getString: getString, getCurrentLocale: function() { return _currentLocale; }, getTranslationsForLocale: function(locale = 'en') { return effectiveTranslations[locale] || effectiveTranslations['en']; }, initializeBaseLocale: function() { _updateActiveLocale(defaultSettings); }, updateActiveLocale: function(activeSettings) { _updateActiveLocale(activeSettings); }, getAvailableLocales: function() { const locales = new Set(['auto', 'en']); // 'auto' and 'en' are always options Object.keys(effectiveTranslations).forEach(lang => { // Only add if it's a valid language pack (not just an empty object) if (Object.keys(effectiveTranslations[lang]).length > 0) { locales.add(lang); } }); return Array.from(locales).sort((a, b) => { if (a === 'auto') return -1; if (b === 'auto') return 1; if (a === 'en' && b !== 'auto') return -1; if (b === 'en' && a !== 'auto') return 1; let nameA = a, nameB = b; try { nameA = new Intl.DisplayNames([a],{type:'language'}).of(a); } catch(e){} try { nameB = new Intl.DisplayNames([b],{type:'language'}).of(b); } catch(e){} return nameA.localeCompare(nameB); }); } }; })(); // A convenient shorthand for accessing the localization service's getString method. const _ = LocalizationService.getString; /** * @module Utils * A collection of general-purpose utility functions used throughout the script. * These functions are stateless and perform common tasks like debouncing, * deep object merging, and number clamping. */ const Utils = { /** * Creates a debounced function that delays invoking `func` until after `wait` * milliseconds have elapsed since the last time the debounced function was invoked. * @param {Function} func - The function to debounce. * @param {number} wait - The number of milliseconds to delay. * @returns {Function} Returns the new debounced function. */ debounce: function(func, wait) { let timeout; return function executedFunction(...args) { const context = this; const later = () => { timeout = null; func.apply(context, args); }; clearTimeout(timeout); timeout = setTimeout(later, wait); }; }, /** * Recursively merges properties of one or more source objects into a target object. * If a key exists in both the target and source, and both values are objects, * it will recursively merge them. Otherwise, the value from the source will overwrite the target. * @param {Object} target - The object to merge properties into. * @param {Object} source - The object to merge properties from. * @returns {Object} The modified target object. */ mergeDeep: function(target, source) { if (!source) return target; // If source is undefined or null, return target as is. target = target || {}; // Ensure target is an object if it's initially null/undefined. for (const key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { const targetValue = target[key]; const sourceValue = source[key]; if (sourceValue && typeof sourceValue === 'object' && !Array.isArray(sourceValue)) { // Recurse for nested objects target[key] = Utils.mergeDeep(targetValue, sourceValue); } else if (typeof sourceValue !== 'undefined') { // Assign if sourceValue is a primitive, array, or explicitly undefined target[key] = sourceValue; } // If sourceValue is undefined, target[key] remains unchanged (implicit else) } } return target; }, /** * Clamps a number within the inclusive lower and upper bounds. * @param {number} num - The number to clamp. * @param {number} min - The lower bound. * @param {number} max - The upper bound. * @returns {number} The clamped number. */ clamp: function(num, min, max) { return Math.min(Math.max(num, min), max); }, /** * Parses a string that may contain an emoji or symbol at the beginning. * This is primarily used for country/region names that include a flag. * @param {string} fullText - The text to parse. * @returns {{icon: string, text: string}} An object with the extracted icon and the remaining text. */ parseIconAndText: function(fullText) { // Regex to match one or more non-letter, non-number characters at the beginning, followed by optional whitespace const match = fullText.match(/^(\P{L}\P{N}\s*)+/u); let icon = ''; let text = fullText; if (match && match[0].trim() !== '') { icon = match[0].trim(); // Trim to remove trailing spaces from the icon part text = fullText.substring(icon.length).trim(); // Trim to remove leading spaces from the text part } return { icon, text }; }, /** * Safely gets the current window's URL as a URL object. * @returns {URL|null} A URL object, or null if an error occurs. */ getCurrentURL: function() { try { return new URL(window.location.href); } catch (e) { console.error(`${LOG_PREFIX} Error creating URL object:`, e); return null; } }, /** * Parses a string containing values separated by " OR " into an array. * The separator is case-insensitive and handles multiple spaces. * @param {string} valueString - The string to parse (e.g., "pdf OR docx"). * @returns {string[]} An array of the parsed values. */ parseCombinedValue: function(valueString) { if (typeof valueString !== 'string' || !valueString.trim()) { return []; } // Split by " OR " (case-insensitive, with spaces around OR) return valueString.split(/\s+OR\s+/i).map(s => s.trim()).filter(s => s.length > 0); }, /** * Cleans a specific operator (like 'site' or 'filetype') from the query string. * @param {string} query - The original query string. * @param {string} operator - The operator name to clean (e.g., 'site'). * @returns {string} The cleaned query string. */ _cleanQueryByOperator: function(query, operator) { if (!query || !operator) return ''; // Create a dynamic regex to match only the specified operator const singleOperatorRegexComplex = new RegExp(`\\s*(?:\\(\\s*)?(?:(?:${operator}):[\\w.:\\/~%?#=&+-]+(?:\\s+OR\\s+|$))+[^)]*\\)?\\s*`, 'gi'); const singleOperatorRegexSimple = new RegExp(`\\s*${operator}:[\\w.:\\/~%?#=&+-]+\\s*`, 'gi'); let cleanedQuery = query.replace(singleOperatorRegexComplex, ' '); cleanedQuery = cleanedQuery.replace(singleOperatorRegexSimple, ' '); return cleanedQuery.replace(/\s\s+/g, ' ').trim(); } }; /** * @module NotificationManager * Handles the display of temporary, non-blocking notifications to the user. * It creates a dedicated container in the DOM and manages the lifecycle of * notification elements, including timed fade-outs. */ const NotificationManager = (function() { let container = null; /** * Initializes the notification container, creating and appending it to the DOM if it doesn't exist. */ function init() { if (document.getElementById(IDS.NOTIFICATION_CONTAINER)) { container = document.getElementById(IDS.NOTIFICATION_CONTAINER); return; } container = document.createElement('div'); container.id = IDS.NOTIFICATION_CONTAINER; if (document.body) { document.body.appendChild(container); } else { // This case should be rare as script runs at document-idle console.error(LOG_PREFIX + " NotificationManager.init(): document.body is not available!"); container = null; // Ensure container is null if append fails } } /** * Displays a notification message. * @param {string} messageKey - The localization key for the message. * @param {Object} [messageArgs={}] - Placeholders to replace in the message string. * @param {('info'|'success'|'warning'|'error')} [type='info'] - The type of notification, affecting its appearance. * @param {number} [duration=3000] - The duration in milliseconds before the notification fades out. A duration <= 0 creates a persistent notification. * @returns {HTMLElement|null} The created notification element, or null if the container is not available. */ function show(messageKey, messageArgs = {}, type = 'info', duration = 3000) { if (!container) { // Fallback to alert if container isn't initialized const alertMsg = (typeof _ === 'function' && _(messageKey, messageArgs) && !(_(messageKey, messageArgs).startsWith('[ERR:'))) ? _(messageKey, messageArgs) : `${messageKey} (args: ${JSON.stringify(messageArgs)})`; // Basic fallback if _ is not ready alert(alertMsg); return null; } const notificationElement = document.createElement('div'); notificationElement.classList.add(CSS.NOTIFICATION); const typeClass = CSS[`NTF_${type.toUpperCase()}`] || CSS.NTF_INFO; // Fallback to info type notificationElement.classList.add(typeClass); notificationElement.textContent = _(messageKey, messageArgs); if (duration <= 0) { // Persistent notification, add a close button const closeButton = document.createElement('span'); closeButton.innerHTML = '×'; // Simple 'x' closeButton.style.cursor = 'pointer'; closeButton.style.marginLeft = '10px'; closeButton.style.float = 'right'; // Position to the right closeButton.onclick = () => notificationElement.remove(); notificationElement.appendChild(closeButton); } container.appendChild(notificationElement); if (duration > 0) { setTimeout(() => { notificationElement.style.opacity = '0'; // Start fade out setTimeout(() => notificationElement.remove(), 500); // Remove after fade out }, duration); } return notificationElement; // Return the element for potential further manipulation } return { init: init, show: show }; })(); /** * Creates a generic list item element for use in management modals. * This function handles the creation of the item's text, drag handle, and control buttons (edit/delete). * @param {number} index - The index of the item in its source array. * @param {Object} item - The data object for the list item. * @param {string} listId - The ID of the parent list element. * @param {Object} mapping - The configuration mapping object for this list type from `getListMapping`. * @returns {HTMLLIElement} The fully constructed list item element. */ function createGenericListItem(index, item, listId, mapping) { const listItem = document.createElement('li'); listItem.dataset[DATA_ATTR.INDEX] = index; listItem.dataset[DATA_ATTR.LIST_ID] = listId; listItem.dataset[DATA_ATTR.ITEM_ID] = item.id || item.value || item.url; // Unique ID for the item itself listItem.draggable = true; // All modal list items are draggable const dragIconSpan = document.createElement('span'); dragIconSpan.classList.add(CSS.DRAG_ICON); dragIconSpan.innerHTML = SVG_ICONS.dragGrip; listItem.appendChild(dragIconSpan); const textSpan = document.createElement('span'); // Favicon logic for Site Search list in the modal const currentSettings = SettingsManager.getCurrentSettings(); if (listId === IDS.SITES_LIST && currentSettings.showFaviconsForSiteSearch && item.url && !item.url.includes(' OR ')) { const favicon = document.createElement('img'); favicon.src = `https://www.google.com/s2/favicons?sz=32&domain_url=${item.url}`; favicon.classList.add(CSS.FAVICON); favicon.loading = 'lazy'; textSpan.prepend(favicon); } let displayText = item.text; let paramName = ''; // To show "param=value" if (item.type === 'predefined' && item.originalKey) { displayText = _(item.originalKey); if (listId === IDS.COUNTRIES_LIST) { // Special handling for country icon+text const parsed = Utils.parseIconAndText(displayText); displayText = `${parsed.icon} ${parsed.text}`.trim(); } } // Determine param name for display if (mapping) { // Mapping comes from getListMapping if (listId === IDS.LANG_LIST) paramName = ALL_SECTION_DEFINITIONS.find(s => s.id === 'sidebar-section-language').param; else if (listId === IDS.COUNTRIES_LIST) paramName = ALL_SECTION_DEFINITIONS.find(s => s.id === 'sidebar-section-country').param; else if (listId === IDS.SITES_LIST) paramName = 'site'; // Site search uses `site:` in query else if (listId === IDS.TIME_LIST) paramName = ALL_SECTION_DEFINITIONS.find(s => s.id === 'sidebar-section-time').param; else if (listId === IDS.FT_LIST) { const ftSection = ALL_SECTION_DEFINITIONS.find(s => s.id === 'sidebar-section-filetype'); if (ftSection) paramName = ftSection.param; } } const valueForDisplay = item.value || item.url || _('value_empty'); const fullTextContent = `${displayText} (${paramName}=${valueForDisplay})`; textSpan.appendChild(document.createTextNode(fullTextContent)); textSpan.title = fullTextContent; listItem.appendChild(textSpan); const controlsSpan = document.createElement('span'); controlsSpan.classList.add(CSS.CUSTOM_LIST_ITEM_CONTROLS); // Determine if item is "custom" or "predefined" for button display if (item.type === 'custom' || listId === IDS.SITES_LIST || listId === IDS.TIME_LIST || listId === IDS.FT_LIST) { // Sites, Time, Filetype lists are always treated as "custom" in terms of editability controlsSpan.innerHTML = `<button class="${CSS.BUTTON_EDIT_ITEM}" title="${_('modal_button_edit_title')}">${SVG_ICONS.edit}</button> ` + `<button class="${CSS.BUTTON_DELETE_ITEM}" title="${_('modal_button_delete_title')}">${SVG_ICONS.delete}</button>`; listItem.dataset[DATA_ATTR.ITEM_TYPE] = 'custom'; } else if (item.type === 'predefined') { // Languages, Countries in mixed mode can have predefined items that can be removed (not deleted from source) controlsSpan.innerHTML = `<button class="${CSS.BUTTON_REMOVE_FROM_LIST}" title="${_('modal_button_remove_from_list_title')}">${SVG_ICONS.removeFromList}</button>`; listItem.dataset[DATA_ATTR.ITEM_TYPE] = 'predefined'; } listItem.appendChild(controlsSpan); return listItem; } /** * Populates a list element within a modal with items. * @param {string} listId - The ID of the <ul> element to populate. * @param {Array<Object>} items - An array of item data objects to render. * @param {Document|HTMLElement} [contextElement=document] - The context in which to find the list element. */ function populateListInModal(listId, items, contextElement = document) { const listElement = contextElement.querySelector(`#${listId}`); if (!listElement) { console.warn(`${LOG_PREFIX} List element not found: #${listId} in context`, contextElement); return; } listElement.innerHTML = ''; // Clear existing items const fragment = document.createDocumentFragment(); const mapping = getListMapping(listId); // Get mapping for param name display if (!Array.isArray(items)) items = []; // Ensure items is an array items.forEach((item, index) => { fragment.appendChild(createGenericListItem(index, item, listId, mapping)); }); listElement.appendChild(fragment); } /** * Retrieves the configuration object for a specific custom list type. * This centralized mapping provides all necessary information for managing a list, * such as the keys for accessing settings, DOM element selectors, and localization keys. * @param {string} listId - The ID of the list to get the mapping for. * @returns {Object|null} The configuration object, or null if not found. */ function getListMapping(listId) { const listMappings = { [IDS.SITES_LIST]: { itemsArrayKey: 'favoriteSites', customItemsMasterKey: null, valueKey: 'url', populateFn: populateListInModal, textInput: `#${IDS.NEW_SITE_NAME}`, valueInput: `#${IDS.NEW_SITE_URL}`, addButton: `#${IDS.ADD_SITE_BTN}`, nameKey: 'section_site_search', isSortableMixed: false, predefinedSourceKey: null }, [IDS.LANG_LIST]: { itemsArrayKey: 'displayLanguages', customItemsMasterKey: 'customLanguages', valueKey: 'value', populateFn: populateListInModal, textInput: `#${IDS.NEW_LANG_TEXT}`, valueInput: `#${IDS.NEW_LANG_VALUE}`, addButton: `#${IDS.ADD_LANG_BTN}`, nameKey: 'section_language', isSortableMixed: true, predefinedSourceKey: 'language' }, [IDS.COUNTRIES_LIST]: { itemsArrayKey: 'displayCountries', customItemsMasterKey: 'customCountries', valueKey: 'value', populateFn: populateListInModal, textInput: `#${IDS.NEW_COUNTRY_TEXT}`, valueInput: `#${IDS.NEW_COUNTRY_VALUE}`, addButton: `#${IDS.ADD_COUNTRY_BTN}`, nameKey: 'section_country', isSortableMixed: true, predefinedSourceKey: 'country' }, [IDS.TIME_LIST]: { itemsArrayKey: 'customTimeRanges', customItemsMasterKey: null, valueKey: 'value', populateFn: populateListInModal, textInput: `#${IDS.NEW_TIME_TEXT}`, valueInput: `#${IDS.NEW_TIME_VALUE}`, addButton: `#${IDS.ADD_TIME_BTN}`, nameKey: 'section_time', isSortableMixed: false, predefinedSourceKey: 'time' }, // predefinedSourceKey for enabling checkbox list [IDS.FT_LIST]: { itemsArrayKey: 'customFiletypes', customItemsMasterKey: null, valueKey: 'value', populateFn: populateListInModal, textInput: `#${IDS.NEW_FT_TEXT}`, valueInput: `#${IDS.NEW_FT_VALUE}`, addButton: `#${IDS.ADD_FT_BTN}`, nameKey: 'section_filetype', isSortableMixed: false, predefinedSourceKey: 'filetype' },// predefinedSourceKey for enabling checkbox list }; return listMappings[listId] || null; } /** * Validates the format of a user-provided value in a custom item input field. * It uses regular expressions to check against expected formats for different types * (e.g., language codes, domains, filetypes). It also provides visual feedback on the input element. * @param {HTMLInputElement} inputElement - The input element to validate. * @returns {boolean} True if the input is valid or empty, false otherwise. */ function validateCustomInput(inputElement) { if (!inputElement) return false; // Should not happen if called correctly const value = inputElement.value.trim(); const id = inputElement.id; let isValid = false; let isEmpty = value === ''; // Basic validation: name/text fields cannot be empty if (id === IDS.NEW_SITE_NAME || id === IDS.NEW_LANG_TEXT || id === IDS.NEW_TIME_TEXT || id === IDS.NEW_FT_TEXT || id === IDS.NEW_COUNTRY_TEXT) { isValid = !isEmpty; } else if (id === IDS.NEW_SITE_URL) { // Allow domains with paths, or TLD/SLD const singleSiteRegex = /^(?:(?:(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,})(?:\/[a-zA-Z0-9_.\-~%!$&()*+,;=:@/]+)*\/?)$|(?:^\.(?:[a-zA-Z0-9-]{1,63}\.)*[a-zA-Z]{2,63}$)/; const parts = Utils.parseCombinedValue(value); // Handles " OR " separation if (isEmpty) isValid = true; else if (parts.length > 0) isValid = parts.every(part => singleSiteRegex.test(part)); else isValid = false; } else if (id === IDS.NEW_LANG_VALUE) { // Language code format: lang_xx or lang_xx-XX, multiple with | isValid = isEmpty || /^lang_[a-zA-Z]{2,3}(?:-[a-zA-Z0-9]{2,4})?(?:\|lang_[a-zA-Z]{2,3}(?:-[a-zA-Z0-9]{2,4})?)*$/.test(value); } else if (id === IDS.NEW_TIME_VALUE) { // Time value format: h, d, w, m, y, optionally followed by numbers isValid = isEmpty || /^[hdwmy]\d*$/.test(value); } else if (id === IDS.NEW_FT_VALUE) { // Filetype format: extension, multiple with " OR " const singleFiletypeRegex = /^[a-zA-Z0-9]+$/; const parts = Utils.parseCombinedValue(value); if (isEmpty) isValid = true; else if (parts.length > 0) isValid = parts.every(part => singleFiletypeRegex.test(part)); else isValid = false; } else if (id === IDS.NEW_COUNTRY_VALUE) { // Country code format: countryXX (XX = uppercase country code) isValid = isEmpty || /^country[A-Z]{2}$/.test(value); } // Visual feedback inputElement.classList.remove('input-valid', 'input-invalid', CSS.HAS_ERROR); // Clear previous states _clearInputError(inputElement); // Clear any existing error message for this input if (!isEmpty) { // Only add validation classes if not empty inputElement.classList.add(isValid ? 'input-valid' : 'input-invalid'); if (!isValid) inputElement.classList.add(CSS.HAS_ERROR); // Red border for error } return isValid || isEmpty; // Return true if format is valid OR if it's empty (emptiness check is separate) } /** * Finds the dedicated error message element associated with a specific input field. * @private * @param {HTMLInputElement} inputElement - The input field. * @returns {HTMLElement|null} The error message element, or null if not found. */ function _getInputErrorElement(inputElement) { if (!inputElement || !inputElement.id) return null; // Try to find the specific error message span for this input let errorEl = inputElement.nextElementSibling; if (errorEl && errorEl.classList.contains(CSS.INPUT_ERROR_MSG) && errorEl.id === `${inputElement.id}-error-msg`) { return errorEl; } // Fallback: search within parent div if structured that way const parentDiv = inputElement.parentElement; if (parentDiv) { return parentDiv.querySelector(`#${inputElement.id}-error-msg`); } return null; } /** * Displays a validation error message for a specific input field. * @private * @param {HTMLInputElement} inputElement - The input field to show the error for. * @param {string} messageKey - The localization key for the error message. * @param {Object} [messageArgs={}] - Placeholders for the error message. */ function _showInputError(inputElement, messageKey, messageArgs = {}) { if (!inputElement) return; const errorElement = _getInputErrorElement(inputElement); if (errorElement) { errorElement.textContent = _(messageKey, messageArgs); errorElement.classList.add(CSS.IS_ERROR_VISIBLE); } inputElement.classList.add(CSS.HAS_ERROR); inputElement.classList.remove('input-valid'); // Remove valid class if error } /** * Clears any validation error message and styling from an input field. * @private * @param {HTMLInputElement} inputElement - The input field to clear the error from. */ function _clearInputError(inputElement) { if (!inputElement) return; const errorElement = _getInputErrorElement(inputElement); if (errorElement) { errorElement.textContent = ''; errorElement.classList.remove(CSS.IS_ERROR_VISIBLE); } inputElement.classList.remove(CSS.HAS_ERROR, 'input-invalid'); } /** * Clears all validation errors within a specific input group container. * @private * @param {HTMLElement} inputGroupElement - The container element for the input group. */ function _clearAllInputErrorsInGroup(inputGroupElement) { if (!inputGroupElement) return; inputGroupElement.querySelectorAll(`input[type="text"]`).forEach(input => { _clearInputError(input); input.classList.remove('input-valid', 'input-invalid'); // Also clear validation classes }); } /** * Shows a global message, typically in a message bar within the settings window. * Can also fall back to the NotificationManager or a standard alert if the target element isn't found. * @private * @param {string} messageKey - The localization key for the message. * @param {Object} [messageArgs={}] - Placeholders for the message string. * @param {('info'|'success'|'warning'|'error')} [type='info'] - The type of message. * @param {number} [duration=3000] - Duration in ms. A value <= 0 makes it persistent until cleared. * @param {string} [targetElementId=IDS.SETTINGS_MESSAGE_BAR] - The ID of the message bar element. */ function _showGlobalMessage(messageKey, messageArgs = {}, type = 'info', duration = 3000, targetElementId = IDS.SETTINGS_MESSAGE_BAR) { const messageBar = document.getElementById(targetElementId); if (!messageBar) { // If specific target (like modal message bar) not found, try general notification or alert if (targetElementId !== IDS.SETTINGS_MESSAGE_BAR && NotificationManager && typeof NotificationManager.show === 'function') { NotificationManager.show(messageKey, messageArgs, type, duration > 0 ? duration : 5000); // Longer for notifications if persistent } else { const alertMsg = (typeof _ === 'function' && _(messageKey, messageArgs) && !(_(messageKey, messageArgs).startsWith('[ERR:'))) ? _(messageKey, messageArgs) : `${messageKey} (args: ${JSON.stringify(messageArgs)})`; alert(alertMsg); } return; } if (globalMessageTimeout && targetElementId === IDS.SETTINGS_MESSAGE_BAR) { // Clear previous timeout for main settings bar clearTimeout(globalMessageTimeout); globalMessageTimeout = null; } messageBar.textContent = _(messageKey, messageArgs); messageBar.className = `${CSS.MESSAGE_BAR}`; // Reset classes messageBar.classList.add(CSS[`MSG_${type.toUpperCase()}`] || CSS.MSG_INFO); // Add type-specific class messageBar.style.display = 'block'; if (duration > 0 && targetElementId === IDS.SETTINGS_MESSAGE_BAR) { globalMessageTimeout = setTimeout(() => { messageBar.style.display = 'none'; messageBar.textContent = ''; messageBar.className = CSS.MESSAGE_BAR; // Reset classes }, duration); } } /** * Validates input fields for a new or edited custom item and prepares the data object. * @private * @param {HTMLInputElement} textInput - The input for the item's display text. * @param {HTMLInputElement} valueInput - The input for the item's value. * @param {string} itemTypeName - The localized name of the item type (e.g., "Language") for error messages. * @param {string} listId - The ID of the list the item belongs to. * @returns {{isValid: boolean, text?: string, value?: string, errorField?: HTMLInputElement}} An object indicating validity and containing data or the field with an error. */ function _validateAndPrepareCustomItemData(textInput, valueInput, itemTypeName, listId) { if (!textInput || !valueInput) { _showGlobalMessage('alert_edit_failed_missing_fields', {}, 'error', 0); // Persistent error return { isValid: false }; } _clearInputError(textInput); _clearInputError(valueInput); const text = textInput.value.trim(); const value = valueInput.value.trim(); let hint = ''; if (text === '') { _showInputError(textInput, 'alert_enter_display_name', { type: itemTypeName }); textInput.focus(); return { isValid: false, errorField: textInput }; } else { // If text is not empty, ensure no lingering error style textInput.classList.remove(CSS.HAS_ERROR); } if (value === '') { _showInputError(valueInput, 'alert_enter_value', { type: itemTypeName }); valueInput.focus(); return { isValid: false, errorField: valueInput }; } else { const isValueFormatValid = validateCustomInput(valueInput); // This also handles visual feedback if (!isValueFormatValid) { if (valueInput.classList.contains('input-invalid')) { valueInput.focus(); return { isValid: false, errorField: valueInput }; } if (listId === IDS.COUNTRIES_LIST) hint = _('modal_tooltip_country'); else if (listId === IDS.LANG_LIST) hint = _('modal_tooltip_language'); else if (listId === IDS.TIME_LIST) hint = _('modal_tooltip_time'); else if (listId === IDS.FT_LIST) hint = _('modal_tooltip_filetype'); else if (listId === IDS.SITES_LIST) hint = _('modal_tooltip_domain'); _showInputError(valueInput, 'alert_invalid_value_format', { type: itemTypeName, hint: hint }); valueInput.focus(); return { isValid: false, errorField: valueInput }; } } return { isValid: true, text: text, value: value }; } /** * Checks if a custom item with the same display name already exists in a list. * The check is case-insensitive. * @private * @param {string} text - The display text to check for duplicates. * @param {Array<Object>} itemsToCheck - The array of items to check against. * @param {string} listId - The ID of the list being checked. * @param {number} editingIndex - The index of the item being edited, to exclude it from the check. * @param {Object|null} editingItemInfoRef - Reference to the object holding information about the item currently being edited. * @returns {boolean} True if a duplicate is found, false otherwise. */ function _isDuplicateCustomItem(text, itemsToCheck, listId, editingIndex, editingItemInfoRef) { const lowerText = text.toLowerCase(); return itemsToCheck.some((item, idx) => { const itemIsCustom = item.type === 'custom' || listId === IDS.SITES_LIST || listId === IDS.TIME_LIST || listId === IDS.FT_LIST; if (!itemIsCustom) return false; if (editingItemInfoRef && editingItemInfoRef.listId === listId && editingIndex === idx) { if (editingItemInfoRef.originalText?.toLowerCase() === lowerText) { return false; } } return item.text.toLowerCase() === lowerText; }); } /** * Applies the appropriate theme classes to a given DOM element based on the current theme setting. * It handles standard themes (light, dark, system) and minimal themes. * @param {HTMLElement} element - The DOM element to apply the theme to. * @param {string} themeSetting - The current theme setting (e.g., 'system', 'dark', 'minimal-light'). */ function applyThemeToElement(element, themeSetting) { if (!element) return; // Remove all potential theme classes first element.classList.remove( CSS.THEME_LIGHT, CSS.THEME_DARK, CSS.THEME_MINIMAL, CSS.THEME_MINIMAL_LIGHT, CSS.THEME_MINIMAL_DARK ); let effectiveTheme = themeSetting; const isSettingsOrModal = element.id === IDS.SETTINGS_WINDOW || element.id === IDS.SETTINGS_OVERLAY || element.classList.contains('settings-modal-content') || element.classList.contains('settings-modal-overlay'); if (isSettingsOrModal) { if (themeSetting === 'minimal-light') effectiveTheme = 'light'; else if (themeSetting === 'minimal-dark') effectiveTheme = 'dark'; } switch (effectiveTheme) { case 'dark': element.classList.add(CSS.THEME_DARK); break; case 'minimal-light': element.classList.add(CSS.THEME_MINIMAL, CSS.THEME_MINIMAL_LIGHT); break; case 'minimal-dark': element.classList.add(CSS.THEME_MINIMAL, CSS.THEME_MINIMAL_DARK); break; case 'system': const systemIsDark = systemThemeMediaQuery && systemThemeMediaQuery.matches; element.classList.add(systemIsDark ? CSS.THEME_DARK : CSS.THEME_LIGHT); break; case 'light': default: element.classList.add(CSS.THEME_LIGHT); break; } } /** * @module PredefinedOptionChooser * A UI component that appears within a modal, allowing users to select and add * predefined options (like languages or countries) to a sortable display list. */ const PredefinedOptionChooser = (function() { let _chooserContainer = null; let _currentListId = null; let _currentPredefinedSourceKey = null; let _currentDisplayItemsArrayRef = null; // Reference to the array like settings.displayLanguages let _currentModalContentContext = null; // The modal body where this chooser is shown let _onAddCallback = null; /** * Builds the HTML for the predefined option chooser modal. * @param {string} listId - The ID of the list being managed. * @param {string} predefinedSourceKey - The key for the predefined options in PREDEFINED_OPTIONS. * @param {Array<Object>} displayItemsArrayRef - A reference to the current array of items being displayed. * @returns {string|null} The generated HTML string for the chooser, or null if no options are available. */ function _buildChooserHTML(listId, predefinedSourceKey, displayItemsArrayRef) { const allPredefinedSystemOptions = PREDEFINED_OPTIONS[predefinedSourceKey] || []; const currentDisplayedValues = new Set( displayItemsArrayRef.filter(item => item.type === 'predefined').map(item => item.value) ); const availablePredefinedToAdd = allPredefinedSystemOptions.filter( opt => !currentDisplayedValues.has(opt.value) ); if (availablePredefinedToAdd.length === 0) { const itemTypeName = getListMapping(listId)?.nameKey ? _(getListMapping(listId).nameKey) : predefinedSourceKey; _showGlobalMessage('alert_no_more_predefined_to_add', { type: itemTypeName }, 'info', 3000, IDS.SETTINGS_MESSAGE_BAR); return null; } const listItemsHTML = availablePredefinedToAdd.map(opt => { let displayText = _(opt.textKey); if (listId === IDS.COUNTRIES_LIST) { const parsed = Utils.parseIconAndText(displayText); displayText = `${parsed.icon} ${parsed.text}`.trim(); } const sanitizedValueForId = opt.value.replace(/[^a-zA-Z0-9-_]/g, ''); return ` <li class="${CSS.MODAL_PREDEFINED_CHOOSER_ITEM}"> <input type="checkbox" value="${opt.value}" id="chooser-${sanitizedValueForId}"> <label for="chooser-${sanitizedValueForId}">${displayText}</label> </li>`; }).join(''); return ` <ul id="${IDS.MODAL_PREDEFINED_CHOOSER_LIST}">${listItemsHTML}</ul> <div class="chooser-buttons" style="text-align: right; margin-top: 10px;"> <button id="${IDS.MODAL_PREDEFINED_CHOOSER_ADD_BTN}" class="${CSS.BUTTON}" style="margin-right: 5px;">${_('modal_button_add_title')}</button> <button id="${IDS.MODAL_PREDEFINED_CHOOSER_CANCEL_BTN}" class="${CSS.BUTTON}">${_('settings_cancel_button')}</button> </div>`; } /** * Handles the click event of the "Add" button in the chooser. It gathers selected * values and triggers the callback function. * @private */ function _handleAdd() { if (!_chooserContainer) return; const selectedValues = []; _chooserContainer.querySelectorAll(`#${IDS.MODAL_PREDEFINED_CHOOSER_LIST} input[type="checkbox"]:checked`).forEach(cb => { selectedValues.push(cb.value); }); if (selectedValues.length > 0 && typeof _onAddCallback === 'function') { _onAddCallback(selectedValues, _currentPredefinedSourceKey, _currentDisplayItemsArrayRef, _currentListId, _currentModalContentContext); } hide(); } /** * Shows the predefined option chooser UI. * @param {string} manageType - The type of item being managed (e.g., 'language'). * @param {string} listId - The ID of the list to add items to. * @param {string} predefinedSourceKey - The key to look up predefined options. * @param {Array<Object>} displayItemsArrayRef - A reference to the settings array that will be modified. * @param {HTMLElement} contextElement - The parent element where the chooser will be inserted. * @param {Function} onAddCb - The callback function to execute when items are added. */ function show(manageType, listId, predefinedSourceKey, displayItemsArrayRef, contextElement, onAddCb) { hide(); _currentListId = listId; _currentPredefinedSourceKey = predefinedSourceKey; _currentDisplayItemsArrayRef = displayItemsArrayRef; _currentModalContentContext = contextElement; _onAddCallback = onAddCb; const chooserHTML = _buildChooserHTML(listId, predefinedSourceKey, displayItemsArrayRef); if (!chooserHTML) return; _chooserContainer = document.createElement('div'); _chooserContainer.id = IDS.MODAL_PREDEFINED_CHOOSER_CONTAINER; _chooserContainer.classList.add(CSS.MODAL_PREDEFINED_CHOOSER); _chooserContainer.innerHTML = chooserHTML; const addNewBtn = contextElement.querySelector(`#${IDS.MODAL_ADD_NEW_OPTION_BTN}`); if (addNewBtn && addNewBtn.parentNode) { addNewBtn.insertAdjacentElement('afterend', _chooserContainer); } else { const mainListElement = contextElement.querySelector(`#${listId}`); mainListElement?.insertAdjacentElement('beforebegin', _chooserContainer); } _chooserContainer.style.display = 'block'; _chooserContainer.querySelector(`#${IDS.MODAL_PREDEFINED_CHOOSER_ADD_BTN}`).addEventListener('click', _handleAdd); _chooserContainer.querySelector(`#${IDS.MODAL_PREDEFINED_CHOOSER_CANCEL_BTN}`).addEventListener('click', hide); } /** * Hides and cleans up the chooser UI. */ function hide() { if (_chooserContainer) { _chooserContainer.remove(); _chooserContainer = null; } _currentListId = null; _currentPredefinedSourceKey = null; _currentDisplayItemsArrayRef = null; _currentModalContentContext = null; _onAddCallback = null; } return { show: show, hide: hide, isOpen: function() { return !!_chooserContainer; } }; })(); /** * @module ModalManager * Manages the creation, display, and interaction logic for all modal dialogs. * This module is responsible for the complex UI where users manage their custom * lists of sites, languages, etc., including adding, editing, deleting, and reordering items. */ const ModalManager = (function() { let _currentModal = null; let _currentModalContent = null; let _editingItemInfo = null; let _draggedListItem = null; const modalConfigsData = { 'site': { modalTitleKey: 'manageSitesTitle', listId: IDS.SITES_LIST, itemsArrayKey: 'favoriteSites', customItemsMasterKey: null, textPKey: 'modal_placeholder_name', valPKey: 'modal_placeholder_domain', hintKey: 'modal_hint_domain', fmtKey: 'modal_tooltip_domain', isSortableMixed: false, predefinedSourceKey: null, hasPredefinedToggles: false, manageType: 'site' }, 'language': { modalTitleKey: 'manageLanguagesTitle', listId: IDS.LANG_LIST, itemsArrayKey: 'displayLanguages', customItemsMasterKey: 'customLanguages', textPKey: 'modal_placeholder_text', valPKey: 'modal_placeholder_value', hintKey: 'modal_hint_language', fmtKey: 'modal_tooltip_language', predefinedSourceKey: 'language', isSortableMixed: true, hasPredefinedToggles: false, manageType: 'language' }, 'country': { modalTitleKey: 'manageCountriesTitle', listId: IDS.COUNTRIES_LIST, itemsArrayKey: 'displayCountries', customItemsMasterKey: 'customCountries', textPKey: 'modal_placeholder_text', valPKey: 'modal_placeholder_value', hintKey: 'modal_hint_country', fmtKey: 'modal_tooltip_country', predefinedSourceKey: 'country', isSortableMixed: true, hasPredefinedToggles: false, manageType: 'country' }, 'time': { modalTitleKey: 'manageTimeRangesTitle',listId: IDS.TIME_LIST, itemsArrayKey: 'customTimeRanges', customItemsMasterKey: null, textPKey: 'modal_placeholder_text', valPKey: 'modal_placeholder_value', hintKey: 'modal_hint_time', fmtKey: 'modal_tooltip_time', predefinedSourceKey: 'time', isSortableMixed: false, hasPredefinedToggles: true, manageType: 'time' }, 'filetype': { modalTitleKey: 'manageFileTypesTitle', listId: IDS.FT_LIST, itemsArrayKey: 'customFiletypes', customItemsMasterKey: null, textPKey: 'modal_placeholder_text', valPKey: 'modal_placeholder_value', hintKey: 'modal_hint_filetype', fmtKey: 'modal_tooltip_filetype', predefinedSourceKey: 'filetype', isSortableMixed: false, hasPredefinedToggles: true, manageType: 'filetype' }, }; /** * Creates the HTML for the section that allows users to enable/disable * predefined options (e.g., for time ranges or filetypes). * @private * @param {string} currentOptionType - The key for the option type in `PREDEFINED_OPTIONS`. * @param {string} typeNameKey - The localization key for the name of this option type. * @param {Object} predefinedOptionsSource - A reference to the `PREDEFINED_OPTIONS` object. * @param {Set<string>} enabledPredefinedValues - A set of the currently enabled predefined values. * @returns {string} The generated HTML string. */ function _createPredefinedOptionsSectionHTML(currentOptionType, typeNameKey, predefinedOptionsSource, enabledPredefinedValues) { const label = _(typeNameKey); const optionsHTML = (predefinedOptionsSource[currentOptionType] || []).map(option => { const checkboxId = `predefined-${currentOptionType}-${option.value.replace(/[^a-zA-Z0-9-_]/g, '')}`; const translatedOptionText = _(option.textKey); const isChecked = enabledPredefinedValues.has(option.value); return ` <li> <input type="checkbox" id="${checkboxId}" value="${option.value}" data-option-type="${currentOptionType}" ${isChecked ? 'checked' : ''}> <label for="${checkboxId}">${translatedOptionText}</label> </li>`; }).join(''); return ` <label style="font-weight: bold;">${_('modal_label_enable_predefined', { type: label })}</label> <ul class="predefined-options-list" data-option-type="${currentOptionType}">${optionsHTML}</ul>`; } /** * Creates the HTML for the list of custom/display items and the input fields * for adding/editing them within a modal. * @private * @param {string} currentListId - The ID for the `<ul>` element. * @param {string} textPlaceholderKey - The localization key for the text input's placeholder. * @param {string} valuePlaceholderKey - The localization key for the value input's placeholder. * @param {string} hintKey - The localization key for the hint text below the inputs. * @param {string} formatTooltipKey - The localization key for the value input's tooltip. * @param {string} itemTypeName - The localized name of the item type. * @param {boolean} [isSortableMixed=false] - True if the list mixes user-custom and predefined items. * @returns {string} The generated HTML string. */ function _createModalListAndInputHTML(currentListId, textPlaceholderKey, valuePlaceholderKey, hintKey, formatTooltipKey, itemTypeName, isSortableMixed = false) { const mapping = getListMapping(currentListId); const typeNameToDisplay = itemTypeName || (mapping ? _(mapping.nameKey) : 'Items'); let headerHTML = ''; let addNewOptionButtonHTML = ''; if (isSortableMixed) { headerHTML = `<label style="font-weight: bold; margin-top: 0.5em; display: block;">${_('modal_label_display_options_for', {type: typeNameToDisplay})}</label>`; addNewOptionButtonHTML = ` <div style="margin-bottom: 0.5em;"> <button id="${IDS.MODAL_ADD_NEW_OPTION_BTN}" class="${CSS.MODAL_BUTTON_ADD_NEW} ${CSS.BUTTON}">${_('modal_button_add_new_option')}</button> </div>`; } else { headerHTML = `<label style="font-weight: bold; margin-top: 0.5em; display: block;">${_('modal_label_my_custom', { type: typeNameToDisplay })}</label>`; } const textInputId = mapping ? mapping.textInput.substring(1) : `new-custom-${currentListId}-text`; const valueInputId = mapping ? mapping.valueInput.substring(1) : `new-custom-${currentListId}-value`; const addButtonId = mapping ? mapping.addButton.substring(1) : `add-custom-${currentListId}-button`; const inputGroupHTML = ` <div class="${CSS.CUSTOM_LIST_INPUT_GROUP}"> <div> <input type="text" id="${textInputId}" placeholder="${_(textPlaceholderKey)}"> <span id="${textInputId}-error-msg" class="${CSS.INPUT_ERROR_MSG}"></span> </div> <div> <input type="text" id="${valueInputId}" placeholder="${_(valuePlaceholderKey)}" title="${_(formatTooltipKey)}"> <span id="${valueInputId}-error-msg" class="${CSS.INPUT_ERROR_MSG}"></span> </div> <button id="${addButtonId}" class="${CSS.BUTTON_ADD_CUSTOM} custom-list-action-button" data-list-id="${currentListId}" title="${_('modal_button_add_title')}">${SVG_ICONS.add}</button> <button class="cancel-edit-button" style="display: none;" title="${_('modal_button_cancel_edit_title')}">${SVG_ICONS.close}</button> </div>`; const hintHTML = `<span class="${CSS.SETTING_VALUE_HINT}">${_(hintKey)}</span>`; return `${addNewOptionButtonHTML}${headerHTML}<ul id="${currentListId}" class="${CSS.CUSTOM_LIST}"></ul>${inputGroupHTML}${hintHTML}`; } /** * Resets the modal's input fields and buttons from an "editing" state back to a "new item" state. * @private * @param {HTMLElement} [contextElement=_currentModalContent] - The context in which to find the elements. */ function _resetEditStateInternal(contextElement = _currentModalContent) { if (_editingItemInfo) { const mapping = getListMapping(_editingItemInfo.listId); if (_editingItemInfo.addButton) { _editingItemInfo.addButton.innerHTML = SVG_ICONS.add; _editingItemInfo.addButton.title = _('modal_button_add_title'); _editingItemInfo.addButton.classList.remove('update-mode'); } if (_editingItemInfo.cancelButton) { _editingItemInfo.cancelButton.style.display = 'none'; } if (mapping && contextElement) { const textInput = contextElement.querySelector(mapping.textInput); const valueInput = contextElement.querySelector(mapping.valueInput); const inputGroup = textInput?.closest(`.${CSS.CUSTOM_LIST_INPUT_GROUP}`); if(inputGroup) _clearAllInputErrorsInGroup(inputGroup); if (textInput) { textInput.value = ''; textInput.classList.remove('input-valid', 'input-invalid', CSS.HAS_ERROR); _clearInputError(textInput); } if (valueInput) { valueInput.value = ''; valueInput.classList.remove('input-valid', 'input-invalid', CSS.HAS_ERROR); _clearInputError(valueInput); } } } _editingItemInfo = null; } /** * Prepares the modal's input fields for editing an existing item by populating them * with the item's data and changing the "Add" button to an "Update" button. * @private * @param {Object} item - The item data object. * @param {number} index - The index of the item. * @param {string} listId - The ID of the parent list. * @param {Object} mapping - The configuration mapping for this list type. * @param {HTMLElement} contextElement - The modal's content element. */ function _prepareEditItemActionInternal(item, index, listId, mapping, contextElement) { const textInput = contextElement.querySelector(mapping.textInput); const valueInput = contextElement.querySelector(mapping.valueInput); const addButton = contextElement.querySelector(mapping.addButton); const cancelButton = addButton?.parentElement?.querySelector('.cancel-edit-button'); if (textInput && valueInput && addButton && cancelButton) { if (_editingItemInfo && (_editingItemInfo.listId !== listId || _editingItemInfo.index !== index)) { _resetEditStateInternal(contextElement); } textInput.value = item.text; valueInput.value = item[mapping.valueKey] || item.value; _editingItemInfo = { listId, index, originalValue: item[mapping.valueKey] || item.value, originalText: item.text, addButton, cancelButton, arrayKey: mapping.itemsArrayKey || mapping.displayArrayKey }; addButton.innerHTML = SVG_ICONS.update; addButton.title = _('modal_button_update_title'); addButton.classList.add('update-mode'); cancelButton.style.display = 'inline-block'; validateCustomInput(valueInput); validateCustomInput(textInput); textInput.focus(); } else { const errorSourceInput = textInput || valueInput || addButton?.closest(`.${CSS.CUSTOM_LIST_INPUT_GROUP}`)?.querySelector('input[type="text"]'); if (errorSourceInput) _showInputError(errorSourceInput, 'alert_edit_failed_missing_fields'); else _showGlobalMessage('alert_edit_failed_missing_fields', {}, 'error', 0, _currentModalContent?.querySelector(`#${IDS.SETTINGS_MESSAGE_BAR}`) ? IDS.SETTINGS_MESSAGE_BAR : null); } } /** * Handles clicks on edit, delete, or remove buttons within a custom list in a modal. * @private * @param {Event} event - The click event. * @param {HTMLElement} contextElement - The modal's content element. * @param {Array<Object>} itemsArrayRef - A reference to the array of items being modified. */ function _handleCustomListActionsInternal(event, contextElement, itemsArrayRef) { const button = event.target.closest(`button.${CSS.BUTTON_EDIT_ITEM}, button.${CSS.BUTTON_DELETE_ITEM}, button.${CSS.BUTTON_REMOVE_FROM_LIST}`); if (!button) return; const listItem = button.closest(`li[data-${DATA_ATTR.INDEX}][data-list-id]`); if (!listItem) return; const index = parseInt(listItem.dataset[DATA_ATTR.INDEX], 10); const listId = listItem.getAttribute('data-list-id'); if (isNaN(index) || !listId || index < 0 || index >= itemsArrayRef.length) return; const mapping = getListMapping(listId); if (!mapping) return; const item = itemsArrayRef[index]; if (!item) return; const itemIsTrulyCustom = item.type === 'custom' || (!item.type && (listId === IDS.SITES_LIST || listId === IDS.TIME_LIST || listId === IDS.FT_LIST)); if (button.classList.contains(CSS.BUTTON_DELETE_ITEM) && itemIsTrulyCustom) { if (confirm(_('confirm_delete_item', { name: item.text }))) { if (_editingItemInfo && _editingItemInfo.listId === listId && _editingItemInfo.index === index) { _resetEditStateInternal(contextElement); } itemsArrayRef.splice(index, 1); mapping.populateFn(listId, itemsArrayRef, contextElement); } } else if (button.classList.contains(CSS.BUTTON_REMOVE_FROM_LIST) && item.type === 'predefined') { if (confirm(_('confirm_remove_item_from_list', { name: (item.originalKey ? _(item.originalKey) : item.text) }))) { itemsArrayRef.splice(index, 1); mapping.populateFn(listId, itemsArrayRef, contextElement); } } else if (button.classList.contains(CSS.BUTTON_EDIT_ITEM) && itemIsTrulyCustom) { _prepareEditItemActionInternal(item, index, listId, mapping, contextElement); } } /** * Handles the submission of the form for adding or updating a custom item. * It performs validation, checks for duplicates, and then updates the item array. * @private * @param {string} listId - The ID of the list being modified. * @param {HTMLElement} contextElement - The modal's content element. * @param {Array<Object>} itemsArrayRef - A reference to the array of items. */ function _handleCustomItemSubmitInternal(listId, contextElement, itemsArrayRef) { const mapping = getListMapping(listId); if (!mapping) return; const itemTypeName = _(mapping.nameKey); const textInput = contextElement.querySelector(mapping.textInput); const valueInput = contextElement.querySelector(mapping.valueInput); const inputGroup = textInput?.closest(`.${CSS.CUSTOM_LIST_INPUT_GROUP}`); if (inputGroup) _clearAllInputErrorsInGroup(inputGroup); const validationResult = _validateAndPrepareCustomItemData(textInput, valueInput, itemTypeName, listId); if (!validationResult.isValid) { if (validationResult.errorField) validationResult.errorField.focus(); return; } const { text, value } = validationResult; const editingIdx = (_editingItemInfo && _editingItemInfo.listId === listId) ? _editingItemInfo.index : -1; let isDuplicate; if (mapping.isSortableMixed) { isDuplicate = _isDuplicateCustomItem(text, itemsArrayRef, listId, editingIdx, _editingItemInfo); } else { isDuplicate = itemsArrayRef.some((item, idx) => { if (editingIdx === idx && (_editingItemInfo?.originalText?.toLowerCase() === text.toLowerCase())) return false; return item.text.toLowerCase() === text.toLowerCase(); }); } if (isDuplicate) { if (textInput) _showInputError(textInput, 'alert_duplicate_name', { name: text }); textInput?.focus(); return; } let newItemData; if (listId === IDS.SITES_LIST) { newItemData = { text: text, url: value }; } else if (mapping.isSortableMixed) { newItemData = { id: value, text: text, value: value, type: 'custom' }; } else { newItemData = { text: text, value: value }; } const itemBeingEdited = (editingIdx > -1) ? itemsArrayRef[editingIdx] : null; const itemBeingEditedIsCustom = itemBeingEdited && (itemBeingEdited.type === 'custom' || listId === IDS.SITES_LIST || listId === IDS.TIME_LIST || listId === IDS.FT_LIST); if (editingIdx > -1 && itemBeingEditedIsCustom) { itemsArrayRef[editingIdx] = {...itemsArrayRef[editingIdx], ...newItemData }; _resetEditStateInternal(contextElement); } else { itemsArrayRef.push(newItemData); } mapping.populateFn(listId, itemsArrayRef, contextElement); if (!_editingItemInfo || _editingItemInfo.listId !== listId) { if (textInput) { textInput.value = ''; _clearInputError(textInput); textInput.focus(); } if (valueInput) { valueInput.value = ''; _clearInputError(valueInput); } } } /** * Determines which list item is the drop target during a drag-and-drop operation. * @private * @param {HTMLElement} container - The list container (`<ul>`). * @param {number} y - The current y-coordinate of the cursor. * @returns {HTMLElement|undefined} The list item element that is the drop target. */ function _getDragAfterModalListItem(container, y) { const draggableElements = [...container.querySelectorAll(`li[draggable="true"]:not(.${CSS.IS_DRAGGING})`)]; return draggableElements.reduce((closest, child) => { const box = child.getBoundingClientRect(); const offset = y - box.top - box.height / 2; if (offset < 0 && offset > closest.offset) { return { offset: offset, element: child }; } else { return closest; } }, { offset: Number.NEGATIVE_INFINITY }).element; } /** * Handles the `dragstart` event for a list item in a modal. * @private * @param {DragEvent} event - The `dragstart` event. */ function _handleModalListDragStart(event) { if (!event.target.matches('li[draggable="true"]')) return; _draggedListItem = event.target; event.dataTransfer.effectAllowed = 'move'; event.dataTransfer.setData('text/plain', _draggedListItem.dataset.index); _draggedListItem.classList.add(CSS.IS_DRAGGING); const list = _draggedListItem.closest('ul'); if (list) { list.querySelectorAll(`li:not(.${CSS.IS_DRAGGING})`).forEach(li => li.style.pointerEvents = 'none'); } } /** * Handles the `dragover` event to provide visual feedback for the drop target. * @private * @param {DragEvent} event - The `dragover` event. */ function _handleModalListDragOver(event) { event.preventDefault(); const listElement = event.currentTarget; listElement.querySelectorAll(`li.${CSS.IS_DRAG_OVER}`).forEach(li => li.classList.remove(CSS.IS_DRAG_OVER)); const targetItem = event.target.closest('li[draggable="true"]'); if (targetItem && targetItem !== _draggedListItem) { targetItem.classList.add(CSS.IS_DRAG_OVER); } else { const afterElement = _getDragAfterModalListItem(listElement, event.clientY); if (afterElement) { afterElement.classList.add(CSS.IS_DRAG_OVER); } } } /** * Handles the `dragleave` event to clear visual feedback. * @private * @param {DragEvent} event - The `dragleave` event. */ function _handleModalListDragLeave(event) { const listElement = event.currentTarget; if (event.relatedTarget && listElement.contains(event.relatedTarget)) return; listElement.querySelectorAll(`li.${CSS.IS_DRAG_OVER}`).forEach(li => li.classList.remove(CSS.IS_DRAG_OVER)); } /** * Handles the `drop` event to reorder the items in the backing array. * @private * @param {DragEvent} event - The `drop` event. * @param {string} listId - The ID of the list being modified. * @param {Array<Object>} itemsArrayRef - A reference to the array of items. */ function _handleModalListDrop(event, listId, itemsArrayRef) { event.preventDefault(); if (!_draggedListItem) return; const draggedIndexOriginal = parseInt(event.dataTransfer.getData('text/plain'), 10); if (isNaN(draggedIndexOriginal) || draggedIndexOriginal < 0 || draggedIndexOriginal >= itemsArrayRef.length) { _handleModalListDragEnd(event.currentTarget); return; } const listElement = event.currentTarget; const mapping = getListMapping(listId); if (!mapping) { _handleModalListDragEnd(listElement); return; } const draggedItemData = itemsArrayRef[draggedIndexOriginal]; if (!draggedItemData) { _handleModalListDragEnd(listElement); return; } const itemsWithoutDragged = itemsArrayRef.filter((item, index) => index !== draggedIndexOriginal); const afterElement = _getDragAfterModalListItem(listElement, event.clientY); let newIndexInSplicedArray; if (afterElement) { const originalIndexOfAfterElement = parseInt(afterElement.dataset.index, 10); let countSkipped = 0; newIndexInSplicedArray = -1; for(let i=0; i < itemsArrayRef.length; i++) { if (i === draggedIndexOriginal) continue; if (i === originalIndexOfAfterElement) { newIndexInSplicedArray = countSkipped; break; } countSkipped++; } if (newIndexInSplicedArray === -1 && originalIndexOfAfterElement === itemsArrayRef.length -1 && draggedIndexOriginal < originalIndexOfAfterElement) { newIndexInSplicedArray = itemsWithoutDragged.length; } else if (newIndexInSplicedArray === -1) { newIndexInSplicedArray = itemsWithoutDragged.length; } } else { newIndexInSplicedArray = itemsWithoutDragged.length; } itemsWithoutDragged.splice(newIndexInSplicedArray, 0, draggedItemData); itemsArrayRef.length = 0; itemsWithoutDragged.forEach(item => itemsArrayRef.push(item)); _handleModalListDragEnd(listElement); mapping.populateFn(listId, itemsArrayRef, _currentModalContent); const newLiElements = listElement.querySelectorAll('li'); newLiElements.forEach((li, idx) => { li.dataset.index = idx; }); } /** * Cleans up the state and styling after a drag-and-drop operation ends. * @private * @param {HTMLElement} listElement - The list container element. */ function _handleModalListDragEnd(listElement) { if (_draggedListItem) { _draggedListItem.classList.remove(CSS.IS_DRAGGING); } _draggedListItem = null; (listElement || _currentModalContent)?.querySelectorAll(`li.${CSS.IS_DRAG_OVER}`).forEach(li => li.classList.remove(CSS.IS_DRAG_OVER)); _currentModalContent?.querySelectorAll(`ul.${CSS.CUSTOM_LIST} li[draggable="true"]`).forEach(li => li.style.pointerEvents = ''); } /** * Adds a new predefined item to a display list in a modal. * @private * @param {string[]} selectedValues - The values of the predefined items to add. * @param {string} predefinedSourceKey - The key for looking up the predefined item data. * @param {Array<Object>} displayItemsArrayRef - A reference to the array of items to modify. * @param {string} listIdToUpdate - The ID of the list to repopulate. * @param {HTMLElement} modalContentContext - The modal's content element. */ function _addPredefinedItemsToModalList(selectedValues, predefinedSourceKey, displayItemsArrayRef, listIdToUpdate, modalContentContext) { const mapping = getListMapping(listIdToUpdate); if (!mapping) return; selectedValues.forEach(value => { const predefinedOpt = PREDEFINED_OPTIONS[predefinedSourceKey]?.find(p => p.value === value); if (predefinedOpt && !displayItemsArrayRef.some(item => item.value === value && item.type === 'predefined')) { displayItemsArrayRef.push({ id: predefinedOpt.value, text: _(predefinedOpt.textKey), value: predefinedOpt.value, type: 'predefined', originalKey: predefinedOpt.textKey }); } }); mapping.populateFn(listIdToUpdate, displayItemsArrayRef, modalContentContext); } /** * Binds all necessary event listeners for a modal's content. * Uses event delegation to efficiently handle events for list items and controls. * @private * @param {HTMLElement} modalContent - The modal's content element. * @param {Array<Object>} itemsArrayRef - A reference to the array of items being managed. * @param {string|null} [listIdForDragDrop=null] - The ID of the list that should have drag-and-drop enabled. */ function _bindModalContentEventsInternal(modalContent, itemsArrayRef, listIdForDragDrop = null) { if (!modalContent || modalContent.dataset.modalEventsBound === 'true') return; modalContent.addEventListener('click', (event) => { const target = event.target; const listIdForAction = listIdForDragDrop || target.closest('[data-list-id]')?.dataset.listId || target.closest(`.${CSS.BUTTON_ADD_CUSTOM}`)?.dataset.listId; const addNewOptionButton = target.closest(`#${IDS.MODAL_ADD_NEW_OPTION_BTN}`); if (addNewOptionButton && listIdForAction) { const mapping = getListMapping(listIdForAction); const configForModal = modalConfigsData[Object.keys(modalConfigsData).find(key => modalConfigsData[key].listId === listIdForAction)]; if (mapping && configForModal && configForModal.predefinedSourceKey && configForModal.isSortableMixed) { PredefinedOptionChooser.show( configForModal.manageType, listIdForAction, configForModal.predefinedSourceKey, itemsArrayRef, modalContent, _addPredefinedItemsToModalList ); } else if (mapping) { modalContent.querySelector(mapping.textInput)?.focus(); } return; } const addButton = target.closest(`.${CSS.BUTTON_ADD_CUSTOM}.custom-list-action-button`); const itemControlButton = target.closest(`button.${CSS.BUTTON_EDIT_ITEM}, button.${CSS.BUTTON_DELETE_ITEM}, button.${CSS.BUTTON_REMOVE_FROM_LIST}`); const cancelEditButton = target.closest('.cancel-edit-button'); if (itemControlButton && listIdForAction) { _handleCustomListActionsInternal(event, modalContent, itemsArrayRef); } else if (addButton && listIdForAction) { _handleCustomItemSubmitInternal(listIdForAction, modalContent, itemsArrayRef); } else if (cancelEditButton) { _resetEditStateInternal(modalContent); } }); modalContent.addEventListener('input', (event) => { const target = event.target; if (target.matches('input[type="text"]')) { _clearInputError(target); validateCustomInput(target); } }); if (listIdForDragDrop) { const draggableListElement = modalContent.querySelector(`#${listIdForDragDrop}`); if (draggableListElement && draggableListElement.dataset.dragEventsBound !== 'true') { draggableListElement.dataset.dragEventsBound = 'true'; draggableListElement.addEventListener('dragstart', _handleModalListDragStart); draggableListElement.addEventListener('dragover', _handleModalListDragOver); draggableListElement.addEventListener('dragleave', _handleModalListDragLeave); draggableListElement.addEventListener('drop', (e) => _handleModalListDrop(e, listIdForDragDrop, itemsArrayRef)); draggableListElement.addEventListener('dragend', () => _handleModalListDragEnd(draggableListElement)); } } modalContent.dataset.modalEventsBound = 'true'; } return { /** * Shows a modal dialog. * @param {string} titleKey - The localization key for the modal's title. * @param {string} contentHTML - The HTML string to be injected into the modal's body. * @param {Function} onCompleteCallback - A callback function to execute when the user clicks the "Done" button. * @param {string} currentTheme - The current theme to apply to the modal. * @returns {HTMLElement} The content element of the created modal. */ show: function(titleKey, contentHTML, onCompleteCallback, currentTheme) { this.hide(); _currentModal = document.createElement('div'); _currentModal.className = 'settings-modal-overlay'; applyThemeToElement(_currentModal, currentTheme); _currentModalContent = document.createElement('div'); _currentModalContent.className = 'settings-modal-content'; applyThemeToElement(_currentModalContent, currentTheme); const headerHTML = ` <div class="settings-modal-header"> <h4>${_(titleKey)}</h4> <button class="settings-modal-close-btn" title="${_('settings_close_button_title')}">${SVG_ICONS.close}</button> </div>`; const bodyHTML = `<div class="settings-modal-body">${contentHTML}</div>`; const footerHTML = ` <div class="settings-modal-footer"> <button class="modal-complete-btn">${_('modal_button_complete')}</button> </div>`; _currentModalContent.innerHTML = headerHTML + bodyHTML + footerHTML; _currentModal.appendChild(_currentModalContent); const self = this; const closeModalHandler = (event) => { if (event.target === _currentModal || event.target.closest('.settings-modal-close-btn')) { self.hide(true); } }; const completeModalHandler = () => { if (typeof onCompleteCallback === 'function') { onCompleteCallback(_currentModalContent); } self.hide(false); }; _currentModal.addEventListener('click', closeModalHandler); _currentModalContent.querySelector('.modal-complete-btn').addEventListener('click', completeModalHandler); _currentModalContent.querySelector('.settings-modal-close-btn').addEventListener('click', () => self.hide(true)); _currentModalContent.addEventListener('click', (event) => event.stopPropagation()); document.body.appendChild(_currentModal); return _currentModalContent; }, /** * Hides and destroys the currently visible modal. * @param {boolean} [isCancel=false] - Indicates if the hide action was a cancellation. */ hide: function(isCancel = false) { PredefinedOptionChooser.hide(); if (_currentModal) { const inputGroup = _currentModalContent?.querySelector(`.${CSS.CUSTOM_LIST_INPUT_GROUP}`); if (inputGroup) _clearAllInputErrorsInGroup(inputGroup); _resetEditStateInternal(); _currentModal.remove(); } _currentModal = null; _currentModalContent = null; _handleModalListDragEnd(); }, /** * Opens a specific type of management modal based on the provided configuration. * @param {string} manageType - The type of items to manage (e.g., 'site', 'language'). * @param {Object} currentSettingsRef - A reference to the main settings object. * @param {Object} PREDEFINED_OPTIONS_REF - A reference to the predefined options data. * @param {Function} onModalCompleteCallback - The callback to execute when the modal is completed. */ openManageCustomOptions: function(manageType, currentSettingsRef, PREDEFINED_OPTIONS_REF, onModalCompleteCallback) { const config = modalConfigsData[manageType]; if (!config) { console.error("Error: Could not get config for manageType:", manageType); return; } const mapping = getListMapping(config.listId); if (!mapping) { console.error("Error: Could not get mapping for listId:", config.listId); return; } const tempItems = JSON.parse(JSON.stringify(currentSettingsRef[config.itemsArrayKey] || [])); let contentHTML = ''; const itemTypeNameForDisplay = _(mapping.nameKey); if (config.isSortableMixed) { contentHTML += _createModalListAndInputHTML(config.listId, config.textPKey, config.valPKey, config.hintKey, config.fmtKey, itemTypeNameForDisplay, true); } else if (config.hasPredefinedToggles && config.predefinedSourceKey && PREDEFINED_OPTIONS_REF[config.predefinedSourceKey]) { const enabledValues = new Set(currentSettingsRef.enabledPredefinedOptions[config.predefinedSourceKey] || []); contentHTML += _createPredefinedOptionsSectionHTML(config.predefinedSourceKey, mapping.nameKey, PREDEFINED_OPTIONS_REF, enabledValues); contentHTML += '<hr style="margin: 1em 0;">'; contentHTML += _createModalListAndInputHTML(config.listId, config.textPKey, config.valPKey, config.hintKey, config.fmtKey, itemTypeNameForDisplay, false); } else { contentHTML += _createModalListAndInputHTML(config.listId, config.textPKey, config.valPKey, config.hintKey, config.fmtKey, itemTypeNameForDisplay, false); } const modalContentElement = this.show( config.modalTitleKey, contentHTML, (modalContent) => { let newEnabledPredefs = null; if (config.hasPredefinedToggles && config.predefinedSourceKey) { newEnabledPredefs = Array.from(modalContent.querySelectorAll(`.predefined-options-list input[data-option-type="${config.predefinedSourceKey}"]:checked`)).map(cb => cb.value); } onModalCompleteCallback(tempItems, newEnabledPredefs, config.itemsArrayKey, config.predefinedSourceKey, config.customItemsMasterKey, config.isSortableMixed, manageType); }, currentSettingsRef.theme ); if (modalContentElement) { if (mapping && mapping.populateFn) { mapping.populateFn(config.listId, tempItems, modalContentElement); } _bindModalContentEventsInternal(modalContentElement, tempItems, config.listId); } }, /** * Globally resets the edit state, useful when closing modals or switching tabs. */ resetEditStateGlobally: function() { _resetEditStateInternal(_currentModalContent || document); }, /** * Checks if a modal is currently open. * @returns {boolean} True if a modal is open. */ isModalOpen: function() { return !!_currentModal; } }; })(); /** * @module SettingsUIPaneGenerator * A utility module responsible for generating the HTML content for each tab * within the main settings window. It decouples the HTML structure from the * core logic of the `SettingsManager`. */ const SettingsUIPaneGenerator = (function() { /** * Creates the HTML content for the "General" settings tab. * @returns {string} The HTML string for the pane. */ function createGeneralPaneHTML() { const langOpts = LocalizationService.getAvailableLocales().map(lc => { let dn; if (lc === 'auto') { dn = _('settings_language_auto'); } else { try { dn = new Intl.DisplayNames([lc], { type: 'language' }).of(lc); dn = dn.charAt(0).toUpperCase() + dn.slice(1); } catch (e) { dn = lc; } dn = `${dn} (${lc})`; } return `<option value="${lc}">${dn}</option>`; }).join(''); const locationOptionsHTML = ` <option value="tools">${_('settings_location_tools')}</option> <option value="topBlock">${_('settings_location_top')}</option> <option value="header">${_('settings_location_header')}</option> <option value="none">${_('settings_location_hide')}</option>`; return ` <div class="${CSS.SETTING_ITEM}"> <label for="${IDS.SETTING_INTERFACE_LANGUAGE}">${_('settings_interface_language')}</label> <select id="${IDS.SETTING_INTERFACE_LANGUAGE}">${langOpts}</select> </div> <div class="${CSS.SETTING_ITEM}"> <label for="${IDS.SETTING_SECTION_MODE}">${_('settings_section_mode')}</label> <select id="${IDS.SETTING_SECTION_MODE}"> <option value="remember">${_('settings_section_mode_remember')}</option> <option value="expandAll">${_('settings_section_mode_expand')}</option> <option value="collapseAll">${_('settings_section_mode_collapse')}</option> </select> <div style="margin-top:0.6em;"> <input type="checkbox" id="${IDS.SETTING_ACCORDION}"> <label for="${IDS.SETTING_ACCORDION}" class="${CSS.SETTING_ITEM_LABEL_INLINE}">${_('settings_accordion_mode')}</label> <div class="${CSS.SETTING_VALUE_HINT}" style="margin-top:0.3em; margin-left:1.7em; font-weight:normal;">${_('settings_accordion_mode_hint_desc')}</div> </div> </div> <div class="${CSS.SETTING_ITEM}"> <input type="checkbox" id="${IDS.SETTING_DRAGGABLE}"> <label for="${IDS.SETTING_DRAGGABLE}" class="${CSS.SETTING_ITEM_LABEL_INLINE}">${_('settings_enable_drag')}</label> </div> <div class="${CSS.SETTING_ITEM}"> <label for="${IDS.SETTING_RESET_LOCATION}">${_('settings_reset_button_location')}</label> <select id="${IDS.SETTING_RESET_LOCATION}">${locationOptionsHTML}</select> </div> <div class="${CSS.SETTING_ITEM}"> <label for="${IDS.SETTING_VERBATIM_LOCATION}">${_('settings_verbatim_button_location')}</label> <select id="${IDS.SETTING_VERBATIM_LOCATION}">${locationOptionsHTML}</select> </div> <div class="${CSS.SETTING_ITEM}"> <label for="${IDS.SETTING_ADV_SEARCH_LOCATION}">${_('settings_adv_search_location')}</label> <select id="${IDS.SETTING_ADV_SEARCH_LOCATION}">${locationOptionsHTML}</select> </div> <div class="${CSS.SETTING_ITEM}"> <label for="${IDS.SETTING_PERSONALIZE_LOCATION}">${_('settings_personalize_button_location')}</label> <select id="${IDS.SETTING_PERSONALIZE_LOCATION}">${locationOptionsHTML}</select> </div> <div class="${CSS.SETTING_ITEM}"> <label for="${IDS.SETTING_SCHOLAR_LOCATION}">${_('settings_scholar_location')}</label> <select id="${IDS.SETTING_SCHOLAR_LOCATION}">${locationOptionsHTML}</select> </div> <div class="${CSS.SETTING_ITEM}"> <label for="${IDS.SETTING_TRENDS_LOCATION}">${_('settings_trends_location')}</label> <select id="${IDS.SETTING_TRENDS_LOCATION}">${locationOptionsHTML}</select> </div> <div class="${CSS.SETTING_ITEM}"> <label for="${IDS.SETTING_DATASET_SEARCH_LOCATION}">${_('settings_dataset_search_location')}</label> <select id="${IDS.SETTING_DATASET_SEARCH_LOCATION}">${locationOptionsHTML}</select> </div>`; } /** * Creates the HTML content for the "Appearance" settings tab. * @returns {string} The HTML string for the pane. */ function createAppearancePaneHTML() { const colorOptionsGrid = COLOR_MAPPINGS.map(map => { const labelTextKey = `settings_color_${map.key.replace(/([A-Z])/g, '_$1').toLowerCase()}`; return `<label for="${map.id}">${_(labelTextKey)}</label><input type="color" id="${map.id}">`; }).join(''); return ` <div class="${CSS.SETTING_ITEM}"> <label for="${IDS.SETTING_WIDTH}">${_('settings_sidebar_width')}</label> <span class="${CSS.SETTING_RANGE_HINT}">${_('settings_width_range_hint')}</span> <input type="range" id="${IDS.SETTING_WIDTH}" min="90" max="270" step="5"><span class="${CSS.SETTING_RANGE_VALUE}"></span> </div> <div class="${CSS.SETTING_ITEM}"> <label for="${IDS.SETTING_HEIGHT}">${_('settings_sidebar_height')}</label> <span class="${CSS.SETTING_RANGE_HINT}">${_('settings_height_range_hint')}</span> <input type="range" id="${IDS.SETTING_HEIGHT}" min="25" max="100" step="5"><span class="${CSS.SETTING_RANGE_VALUE}"></span> </div> <div class="${CSS.SETTING_ITEM}"> <label for="${IDS.SETTING_FONT_SIZE}">${_('settings_font_size')}</label> <span class="${CSS.SETTING_RANGE_HINT}">${_('settings_font_size_range_hint')}</span> <input type="range" id="${IDS.SETTING_FONT_SIZE}" min="8" max="24" step="0.5"><span class="${CSS.SETTING_RANGE_VALUE}"></span> </div> <div class="${CSS.SETTING_ITEM}"> <label for="${IDS.SETTING_HEADER_ICON_SIZE}">${_('settings_header_icon_size')}</label> <span class="${CSS.SETTING_RANGE_HINT}">${_('settings_header_icon_size_range_hint')}</span> <input type="range" id="${IDS.SETTING_HEADER_ICON_SIZE}" min="8" max="32" step="0.5"><span class="${CSS.SETTING_RANGE_VALUE}"></span> </div> <div class="${CSS.SETTING_ITEM}"> <label for="${IDS.SETTING_VERTICAL_SPACING}">${_('settings_vertical_spacing')}</label> <span class="${CSS.SETTING_RANGE_HINT}">${_('settings_vertical_spacing_range_hint')}</span> <input type="range" id="${IDS.SETTING_VERTICAL_SPACING}" min="0.05" max="1.5" step="0.05"><span class="${CSS.SETTING_RANGE_VALUE}"></span> </div> <div class="${CSS.SETTING_ITEM}"> <label for="${IDS.SETTING_THEME}">${_('settings_theme')}</label> <select id="${IDS.SETTING_THEME}"> <option value="system">${_('settings_theme_system')}</option> <option value="light">${_('settings_theme_light')}</option> <option value="dark">${_('settings_theme_dark')}</option> <option value="minimal-light">${_('settings_theme_minimal_light')}</option> <option value="minimal-dark">${_('settings_theme_minimal_dark')}</option> </select> </div> <div class="${CSS.SETTING_ITEM}"> <input type="checkbox" id="${IDS.SETTING_HOVER}"><label for="${IDS.SETTING_HOVER}" class="${CSS.SETTING_ITEM_LABEL_INLINE}">${_('settings_hover_mode')}</label> <div style="margin-top:0.8em;padding-left:1.5em;"> <label for="${IDS.SETTING_OPACITY}" style="display:block;margin-bottom:0.4em;font-weight:normal;">${_('settings_idle_opacity')}</label> <span class="${CSS.SETTING_RANGE_HINT}" style="width:auto;display:inline-block;margin-right:1em;">${_('settings_opacity_range_hint')}</span> <input type="range" id="${IDS.SETTING_OPACITY}" min="0.1" max="1.0" step="0.05" style="width:calc(100% - 18em);vertical-align:middle;display:inline-block;"> <span class="${CSS.SETTING_RANGE_VALUE}" style="display:inline-block;min-width:3em;text-align:right;vertical-align:middle;"></span> </div> </div> <div class="${CSS.SETTING_ITEM}"> <label for="${IDS.SETTING_COUNTRY_DISPLAY_MODE}">${_('settings_country_display')}</label> <select id="${IDS.SETTING_COUNTRY_DISPLAY_MODE}"> <option value="iconAndText">${_('settings_country_display_icontext')}</option> <option value="textOnly">${_('settings_country_display_text')}</option> <option value="iconOnly">${_('settings_country_display_icon')}</option> </select> </div> <div class="${CSS.SETTING_ITEM}"> <label for="${IDS.SETTING_SCROLLBAR_POSITION}">${_('settings_scrollbar_position')}</label> <select id="${IDS.SETTING_SCROLLBAR_POSITION}"> <option value="right">${_('settings_scrollbar_right')}</option> <option value="left">${_('settings_scrollbar_left')}</option> <option value="hidden">${_('settings_scrollbar_hidden')}</option> </select> </div> <div class="${CSS.SETTING_ITEM}"> <input type="checkbox" id="${IDS.SETTING_HIDE_GOOGLE_LOGO}"> <label for="${IDS.SETTING_HIDE_GOOGLE_LOGO}" class="${CSS.SETTING_ITEM_LABEL_INLINE}">${_('settings_hide_google_logo')}</label> <div class="${CSS.SETTING_VALUE_HINT}" style="margin-top:0.3em; margin-left:1.7em; font-weight:normal;">${_('settings_hide_google_logo_hint')}</div> </div> <hr style="margin:1.2em 0;"> <div id="${IDS.CUSTOM_COLORS_CONTAINER}" class="${CSS.SETTING_ITEM}"> <label style="font-weight:bold; width:100%; margin-bottom: 0.8em;">${_('settings_advanced_color_options')}</label> <div style="display:grid; grid-template-columns: auto 1fr; gap: 0.8em 1em; align-items: center; width: 100%;">${colorOptionsGrid}</div> <button id="${IDS.RESET_CUSTOM_COLORS_BTN}" style="margin-top: 1em;">${_('settings_reset_colors_button')}</button> </div>`; } /** * Creates the HTML content for the "Features" settings tab. * @returns {string} The HTML string for the pane. */ function createFeaturesPaneHTML() { const visItemsHTML = ALL_SECTION_DEFINITIONS.map(def => { const dn = _(def.titleKey) || def.id; return `<div class="${CSS.SETTING_ITEM} ${CSS.SETTING_ITEM_SIMPLE}"><input type="checkbox" id="setting-visible-${def.id}" data-${DATA_ATTR.SECTION_ID}="${def.id}"><label for="setting-visible-${def.id}" class="${CSS.SETTING_ITEM_LABEL_INLINE}">${dn}</label></div>`; }).join(''); return ` <p>${_('settings_visible_sections')}</p>${visItemsHTML} <div class="${CSS.SETTING_ITEM}"> <input type="checkbox" id="${IDS.SETTING_SITE_SEARCH_CHECKBOX_MODE}"> <label for="${IDS.SETTING_SITE_SEARCH_CHECKBOX_MODE}" class="${CSS.SETTING_ITEM_LABEL_INLINE}">${_('settings_enable_site_search_checkbox_mode')}</label> <div class="${CSS.SETTING_VALUE_HINT}" style="margin-top:0.3em; margin-left:1.7em; font-weight:normal;">${_('settings_enable_site_search_checkbox_mode_hint')}</div> </div> <div class="${CSS.SETTING_ITEM}"> <input type="checkbox" id="${IDS.SETTING_SHOW_FAVICONS}"> <label for="${IDS.SETTING_SHOW_FAVICONS}" class="${CSS.SETTING_ITEM_LABEL_INLINE}">${_('settings_show_favicons')}</label> <div class="${CSS.SETTING_VALUE_HINT}" style="margin-top:0.3em; margin-left:1.7em; font-weight:normal;">${_('settings_show_favicons_hint')}</div> </div> <div class="${CSS.SETTING_ITEM}"> <input type="checkbox" id="${IDS.SETTING_FILETYPE_SEARCH_CHECKBOX_MODE}"> <label for="${IDS.SETTING_FILETYPE_SEARCH_CHECKBOX_MODE}" class="${CSS.SETTING_ITEM_LABEL_INLINE}">${_('settings_enable_filetype_search_checkbox_mode')}</label> <div class="${CSS.SETTING_VALUE_HINT}" style="margin-top:0.3em; margin-left:1.7em; font-weight:normal;">${_('settings_enable_filetype_search_checkbox_mode_hint')}</div> </div> <div class="${CSS.SETTING_ITEM}"> <input type="checkbox" id="${IDS.SETTING_SHOW_RESULT_STATS}"> <label for="${IDS.SETTING_SHOW_RESULT_STATS}" class="${CSS.SETTING_ITEM_LABEL_INLINE}">${_('settings_show_result_stats')}</label> </div> <hr style="margin:1.2em 0;"> <p style="font-weight:bold;margin-bottom:0.5em;">${_('settings_section_order')}</p> <p class="${CSS.SETTING_VALUE_HINT}" style="font-size:0.9em;margin-top:-0.3em;margin-bottom:0.7em;">${_('settings_section_order_hint')}</p> <ul id="${IDS.SIDEBAR_SECTION_ORDER_LIST}" class="${CSS.SECTION_ORDER_LIST}"></ul>`; } /** * Creates the HTML content for the "Custom" settings tab. * @returns {string} The HTML string for the pane. */ function createCustomPaneHTML() { return ` <div class="${CSS.SETTING_ITEM}"> <p>${_('settings_custom_intro')}</p> <button class="${CSS.BUTTON_MANAGE_CUSTOM}" data-${DATA_ATTR.MANAGE_TYPE}="site">${_('settings_manage_sites_button')}</button> </div> <div class="${CSS.SETTING_ITEM}"> <button class="${CSS.BUTTON_MANAGE_CUSTOM}" data-${DATA_ATTR.MANAGE_TYPE}="language">${_('settings_manage_languages_button')}</button> </div> <div class="${CSS.SETTING_ITEM}"> <button class="${CSS.BUTTON_MANAGE_CUSTOM}" data-${DATA_ATTR.MANAGE_TYPE}="country">${_('settings_manage_countries_button')}</button> </div> <div class="${CSS.SETTING_ITEM}"> <button class="${CSS.BUTTON_MANAGE_CUSTOM}" data-${DATA_ATTR.MANAGE_TYPE}="time">${_('settings_manage_time_ranges_button')}</button> </div> <div class="${CSS.SETTING_ITEM}"> <button class="${CSS.BUTTON_MANAGE_CUSTOM}" data-${DATA_ATTR.MANAGE_TYPE}="filetype">${_('settings_manage_file_types_button')}</button> </div>`; } return { createGeneralPaneHTML, createAppearancePaneHTML, createFeaturesPaneHTML, createCustomPaneHTML }; })(); /** * @module SectionOrderDragHandler * Manages the drag-and-drop functionality for reordering sections in the settings window. * It is a self-contained module that handles all necessary drag events. */ const SectionOrderDragHandler = (function() { let _draggedItem = null; let _listElement = null; let _settingsRef = null; let _onOrderUpdateCallback = null; function getDragAfterElement(container, y) { const draggableElements = [...container.querySelectorAll(`li[draggable="true"]:not(.${CSS.IS_DRAGGING})`)]; return draggableElements.reduce((closest, child) => { const box = child.getBoundingClientRect(); const offset = y - box.top - box.height / 2; if (offset < 0 && offset > closest.offset) { return { offset: offset, element: child }; } else { return closest; } }, { offset: Number.NEGATIVE_INFINITY }).element; } function handleDragStart(event) { _draggedItem = event.target; event.dataTransfer.effectAllowed = 'move'; event.dataTransfer.setData('text/plain', _draggedItem.dataset.sectionId); _draggedItem.classList.add(CSS.IS_DRAGGING); if (_listElement) { _listElement.querySelectorAll(`li:not(.${CSS.IS_DRAGGING})`).forEach(li => li.style.pointerEvents = 'none'); } } function handleDragOver(event) { event.preventDefault(); if (!_listElement) return; _listElement.querySelectorAll(`li.${CSS.IS_DRAG_OVER}`).forEach(li => { li.classList.remove(CSS.IS_DRAG_OVER); }); const targetItem = event.target.closest('li[draggable="true"]'); if (targetItem && targetItem !== _draggedItem) { targetItem.classList.add(CSS.IS_DRAG_OVER); } else if (!targetItem && _listElement.contains(event.target)) { const afterElement = getDragAfterElement(_listElement, event.clientY); if (afterElement) { afterElement.classList.add(CSS.IS_DRAG_OVER); } } } function handleDragLeave(event) { const relatedTarget = event.relatedTarget; if (_listElement && (!relatedTarget || !_listElement.contains(relatedTarget))) { _listElement.querySelectorAll(`li.${CSS.IS_DRAG_OVER}`).forEach(li => { li.classList.remove(CSS.IS_DRAG_OVER); }); } } function handleDrop(event) { event.preventDefault(); if (!_draggedItem || !_listElement || !_settingsRef || !_onOrderUpdateCallback) return; const draggedSectionId = event.dataTransfer.getData('text/plain'); let currentVisibleOrder = _settingsRef.sidebarSectionOrder.filter(id => _settingsRef.visibleSections[id]); const oldIndexInVisible = currentVisibleOrder.indexOf(draggedSectionId); if (oldIndexInVisible > -1) { currentVisibleOrder.splice(oldIndexInVisible, 1); } else { handleDragEnd(); return; } const afterElement = getDragAfterElement(_listElement, event.clientY); if (afterElement) { const targetId = afterElement.dataset.sectionId; const newIndexInVisible = currentVisibleOrder.indexOf(targetId); if (newIndexInVisible > -1) { currentVisibleOrder.splice(newIndexInVisible, 0, draggedSectionId); } else { currentVisibleOrder.push(draggedSectionId); } } else { currentVisibleOrder.push(draggedSectionId); } const hiddenSectionOrder = _settingsRef.sidebarSectionOrder.filter(id => !_settingsRef.visibleSections[id]); _settingsRef.sidebarSectionOrder = [...currentVisibleOrder, ...hiddenSectionOrder]; handleDragEnd(); _onOrderUpdateCallback(); } function handleDragEnd() { if (_draggedItem) { _draggedItem.classList.remove(CSS.IS_DRAGGING); } _draggedItem = null; if (_listElement) { _listElement.querySelectorAll('li').forEach(li => { li.classList.remove(CSS.IS_DRAG_OVER); li.style.pointerEvents = ''; }); } } function initialize(listEl, currentSettings, orderUpdateCallback) { _listElement = listEl; _settingsRef = currentSettings; _onOrderUpdateCallback = orderUpdateCallback; if (_listElement && _listElement.dataset.sectionOrderDragBound !== 'true') { _listElement.addEventListener('dragstart', handleDragStart); _listElement.addEventListener('dragover', handleDragOver); _listElement.addEventListener('dragleave', handleDragLeave); _listElement.addEventListener('drop', handleDrop); _listElement.addEventListener('dragend', handleDragEnd); _listElement.dataset.sectionOrderDragBound = 'true'; } } function destroy() { if (_listElement && _listElement.dataset.sectionOrderDragBound === 'true') { _listElement.removeEventListener('dragstart', handleDragStart); _listElement.removeEventListener('dragover', handleDragOver); _listElement.removeEventListener('dragleave', handleDragLeave); _listElement.removeEventListener('drop', handleDrop); _listElement.removeEventListener('dragend', handleDragEnd); delete _listElement.dataset.sectionOrderDragBound; } _listElement = null; _settingsRef = null; _onOrderUpdateCallback = null; } return { initialize, destroy }; })(); /** * @module SettingsManager * The central controller for all script settings. It handles loading settings from storage, * saving them, validating their integrity, and managing the entire settings UI, * including its creation, population with data, and event handling. */ const SettingsManager = (function() { let _settingsWindow = null; let _settingsOverlay = null; let _currentSettings = {}; let _settingsBackup = {}; let _defaultSettingsRef = null; let _isInitialized = false; let _applySettingsToSidebar_cb = ()=>{}; let _buildSidebarUI_cb = ()=>{}; let _applySectionCollapseStates_cb = ()=>{}; let _initMenuCommands_cb = ()=>{}; let _renderSectionOrderList_ext_cb = ()=>{}; /** * A helper to populate a range slider and its associated value display in the settings UI. * @private * @param {HTMLElement} win - The settings window element. * @param {string} id - The ID of the range input element. * @param {number|string} value - The value to set. * @param {Function} [formatFn=(val) => val] - An optional function to format the displayed value. */ function _populateSliderSetting_internal(win, id, value, formatFn = (val) => val) { const i = win.querySelector(`#${id}`); if (i) { i.value = value; let vs = i.parentNode.querySelector(`.${CSS.SETTING_RANGE_VALUE}`); if (vs) { vs.textContent = formatFn(value); } } } /** * Populates the "General" tab in the settings window with current setting values. * @private * @param {HTMLElement} win - The settings window element. * @param {Object} s - The current settings object. */ function _populateGeneralSettings_internal(win, s) { const lS = win.querySelector(`#${IDS.SETTING_INTERFACE_LANGUAGE}`); if (lS) lS.value = s.interfaceLanguage; const sMS = win.querySelector(`#${IDS.SETTING_SECTION_MODE}`), acC = win.querySelector(`#${IDS.SETTING_ACCORDION}`); if (sMS && acC) { sMS.value = s.sectionDisplayMode; const iRM = s.sectionDisplayMode === 'remember'; acC.disabled = !iRM; acC.checked = iRM ? s.accordionMode : false; const accordionHint = acC.parentElement.querySelector(`.${CSS.SETTING_VALUE_HINT}`); if (accordionHint) accordionHint.style.color = iRM ? '' : 'grey'; } const dC = win.querySelector(`#${IDS.SETTING_DRAGGABLE}`); if (dC) dC.checked = s.draggableHandleEnabled; const rLS = win.querySelector(`#${IDS.SETTING_RESET_LOCATION}`); if (rLS) rLS.value = s.resetButtonLocation; const vLS = win.querySelector(`#${IDS.SETTING_VERBATIM_LOCATION}`); if (vLS) vLS.value = s.verbatimButtonLocation; const aSLS = win.querySelector(`#${IDS.SETTING_ADV_SEARCH_LOCATION}`); if (aSLS) aSLS.value = s.advancedSearchLinkLocation; const pznLS = win.querySelector(`#${IDS.SETTING_PERSONALIZE_LOCATION}`); if (pznLS) pznLS.value = s.personalizationButtonLocation; const schLS = win.querySelector(`#${IDS.SETTING_SCHOLAR_LOCATION}`); if (schLS) schLS.value = s.googleScholarShortcutLocation; const trnLS = win.querySelector(`#${IDS.SETTING_TRENDS_LOCATION}`); if (trnLS) trnLS.value = s.googleTrendsShortcutLocation; const dsLS = win.querySelector(`#${IDS.SETTING_DATASET_SEARCH_LOCATION}`); if (dsLS) dsLS.value = s.googleDatasetSearchShortcutLocation; } /** * Populates the "Appearance" tab in the settings window with current setting values. * @private * @param {HTMLElement} win - The settings window element. * @param {Object} s - The current settings object. */ function _populateAppearanceSettings_internal(win, s) { _populateSliderSetting_internal(win, IDS.SETTING_WIDTH, s.sidebarWidth); _populateSliderSetting_internal(win, IDS.SETTING_HEIGHT, s.sidebarHeight); _populateSliderSetting_internal(win, IDS.SETTING_FONT_SIZE, s.fontSize, v => parseFloat(v).toFixed(1)); _populateSliderSetting_internal(win, IDS.SETTING_HEADER_ICON_SIZE, s.headerIconSize, v => parseFloat(v).toFixed(1)); _populateSliderSetting_internal(win, IDS.SETTING_VERTICAL_SPACING, s.verticalSpacingMultiplier, v => `x ${parseFloat(v).toFixed(2)}`); _populateSliderSetting_internal(win, IDS.SETTING_OPACITY, s.idleOpacity, v => parseFloat(v).toFixed(2)); const tS = win.querySelector(`#${IDS.SETTING_THEME}`); if (tS) tS.value = s.theme; const cDS = win.querySelector(`#${IDS.SETTING_COUNTRY_DISPLAY_MODE}`); if (cDS) cDS.value = s.countryDisplayMode; const scrollbarPos = win.querySelector(`#${IDS.SETTING_SCROLLBAR_POSITION}`); if (scrollbarPos) scrollbarPos.value = s.scrollbarPosition; const hC = win.querySelector(`#${IDS.SETTING_HOVER}`), oI = win.querySelector(`#${IDS.SETTING_OPACITY}`); if (hC && oI) { hC.checked = s.hoverMode; const iHE = s.hoverMode; oI.disabled = !iHE; const oC = oI.closest('div'); if (oC) { oC.style.opacity = iHE ? '1' : '0.6'; oC.style.pointerEvents = iHE ? 'auto' : 'none'; } } const hideLogoCb = win.querySelector(`#${IDS.SETTING_HIDE_GOOGLE_LOGO}`); if (hideLogoCb) hideLogoCb.checked = s.hideGoogleLogoWhenExpanded; const isDark = s.theme.includes('dark') || (s.theme === 'system' && systemThemeMediaQuery && systemThemeMediaQuery.matches); const colorDefaults = { bgColor: isDark ? '#202124' : '#ffffff', textColor: isDark ? '#bdc1c6' : '#3c4043', linkColor: isDark ? '#8ab4f8' : '#1a0dab', selectedColor: isDark ? '#e8eaed' : '#000000', inputTextColor: isDark ? '#e8eaed' : '#202124', borderColor: isDark ? '#5f6368' : '#dadce0', dividerColor: isDark ? '#3c4043' : '#eeeeee', btnBgColor: isDark ? '#303134' : '#f8f9fa', btnHoverBgColor: isDark ? '#3c4043' : '#e8eaed', activeBgColor: isDark ? '#8ab4f8' : '#e8f0fe', activeTextColor: isDark ? '#202124' : '#1967d2', activeBorderColor: isDark ? '#8ab4f8' : '#aecbfa', headerIconColor: isDark ? '#bdc1c6' : '#5f6368', }; COLOR_MAPPINGS.forEach(map => { const picker = win.querySelector(`#${map.id}`); if (picker) { picker.value = s.customColors[map.key] || colorDefaults[map.key]; } }); } /** * Populates the "Features" tab in the settings window with current setting values. * @private * @param {HTMLElement} win - The settings window element. * @param {Object} s - The current settings object. * @param {Function} renderFn - A callback function to render the section order list. */ function _populateFeatureSettings_internal(win, s, renderFn) { win.querySelectorAll(`#${IDS.TAB_PANE_FEATURES} input[type="checkbox"][data-${DATA_ATTR.SECTION_ID}]`)?.forEach(cb => { const sId = cb.getAttribute(`data-${DATA_ATTR.SECTION_ID}`); if (sId && s.visibleSections.hasOwnProperty(sId)) { cb.checked = s.visibleSections[sId]; } else if (sId && _defaultSettingsRef.visibleSections.hasOwnProperty(sId)) { cb.checked = _defaultSettingsRef.visibleSections[sId] ?? false; } }); const siteSearchCheckboxModeEl = win.querySelector(`#${IDS.SETTING_SITE_SEARCH_CHECKBOX_MODE}`); if (siteSearchCheckboxModeEl) siteSearchCheckboxModeEl.checked = s.enableSiteSearchCheckboxMode; const showFaviconsEl = win.querySelector(`#${IDS.SETTING_SHOW_FAVICONS}`); if (showFaviconsEl) showFaviconsEl.checked = s.showFaviconsForSiteSearch; const filetypeSearchCheckboxModeEl = win.querySelector(`#${IDS.SETTING_FILETYPE_SEARCH_CHECKBOX_MODE}`); if (filetypeSearchCheckboxModeEl) filetypeSearchCheckboxModeEl.checked = s.enableFiletypeCheckboxMode; const showResultStatsEl = win.querySelector(`#${IDS.SETTING_SHOW_RESULT_STATS}`); if (showResultStatsEl) showResultStatsEl.checked = s.showResultStats; renderFn(s); } /** * Ensures the correct settings tab is visible based on the last active tab or a default. * @private */ function _initializeActiveSettingsTab_internal() { if (!_settingsWindow) return; const tC = _settingsWindow.querySelector(`.${CSS.SETTINGS_TABS}`), cC = _settingsWindow.querySelector(`.${CSS.SETTINGS_TAB_CONTENT}`); if (!tC || !cC) return; const aTB = tC.querySelector(`.${CSS.TAB_BUTTON}.${CSS.IS_ACTIVE}`); const tT = (aTB && aTB.dataset[DATA_ATTR.TAB]) ? aTB.dataset[DATA_ATTR.TAB] : 'general'; tC.querySelectorAll(`.${CSS.TAB_BUTTON}`).forEach(b => b.classList.toggle(CSS.IS_ACTIVE, b.dataset[DATA_ATTR.TAB] === tT)); cC.querySelectorAll(`.${CSS.TAB_PANE}`).forEach(p => p.classList.toggle(CSS.IS_ACTIVE, p.dataset[DATA_ATTR.TAB] === tT)); } /** * Loads settings from storage using GM_getValue. * @private * @returns {Object} The parsed settings object from storage, or an empty object on error. */ function _loadFromStorage() { try { const s = GM_getValue(STORAGE_KEY, '{}'); return JSON.parse(s || '{}'); } catch (e) { console.error(`${LOG_PREFIX} Error loading/parsing settings:`, e); return {}; } } /** * Ensures the display arrays for languages and countries are valid and populated. * For new users or corrupted settings where these arrays are missing or empty, * this function populates them based on the script's default predefined options. * This replaces a more complex legacy migration system. * @private * @param {Object} settings - The settings object to validate and potentially modify. */ function _migrateToDisplayArraysIfNecessary(settings) { const displayTypes = [{ displayKey: 'displayLanguages', predefinedKey: 'language', defaultEnabled: defaultSettings.enabledPredefinedOptions.language }, { displayKey: 'displayCountries', predefinedKey: 'country', defaultEnabled: defaultSettings.enabledPredefinedOptions.country }]; displayTypes.forEach(typeInfo => { // Check if the display array is missing, invalid, or empty. if (!Array.isArray(settings[typeInfo.displayKey]) || settings[typeInfo.displayKey].length === 0) { console.log(`${LOG_PREFIX} Initializing '${typeInfo.displayKey}' with default predefined options.`); const newDisplayArray = []; const addedValues = new Set(); const defaultOptionsToEnable = typeInfo.defaultEnabled || []; defaultOptionsToEnable.forEach(val => { const predefinedOpt = PREDEFINED_OPTIONS[typeInfo.predefinedKey]?.find(p => p.value === val); if (predefinedOpt && !addedValues.has(predefinedOpt.value)) { newDisplayArray.push({ id: predefinedOpt.value, text: _(predefinedOpt.textKey), // Text is for reference, will be re-translated on UI build value: predefinedOpt.value, type: 'predefined', originalKey: predefinedOpt.textKey }); addedValues.add(predefinedOpt.value); } }); settings[typeInfo.displayKey] = newDisplayArray; } }); } /** * Validates and merges core settings like sidebar position and state. * @private */ function _validateAndMergeCoreSettings_internal(target, source, defaults) { if (typeof target.sidebarPosition !== 'object' || target.sidebarPosition === null || Array.isArray(target.sidebarPosition)) { target.sidebarPosition = JSON.parse(JSON.stringify(defaults.sidebarPosition)); } target.sidebarPosition.left = parseInt(target.sidebarPosition.left, 10) || defaults.sidebarPosition.left; target.sidebarPosition.top = parseInt(target.sidebarPosition.top, 10) || defaults.sidebarPosition.top; if (typeof target.sectionStates !== 'object' || target.sectionStates === null || Array.isArray(target.sectionStates)) { target.sectionStates = {}; } target.sidebarCollapsed = !!target.sidebarCollapsed; target.draggableHandleEnabled = typeof target.draggableHandleEnabled === 'boolean' ? target.draggableHandleEnabled : defaults.draggableHandleEnabled; target.interfaceLanguage = typeof source.interfaceLanguage === 'string' ? source.interfaceLanguage : defaults.interfaceLanguage; } /** * Validates and merges appearance-related settings like dimensions, fonts, and colors. * @private */ function _validateAndMergeAppearanceSettings_internal(target, source, defaults) { target.sidebarWidth = Utils.clamp(parseInt(target.sidebarWidth, 10) || defaults.sidebarWidth, 90, 270); target.sidebarHeight = Utils.clamp(parseInt(target.sidebarHeight, 10) || defaults.sidebarHeight, 25, 100); target.fontSize = Utils.clamp(parseFloat(target.fontSize) || defaults.fontSize, 8, 24); target.headerIconSize = Utils.clamp(parseFloat(target.headerIconSize) || defaults.headerIconSize, 8, 32); target.verticalSpacingMultiplier = Utils.clamp(parseFloat(target.verticalSpacingMultiplier) || defaults.verticalSpacingMultiplier, 0.05, 1.5); target.idleOpacity = Utils.clamp(parseFloat(target.idleOpacity) || defaults.idleOpacity, 0.1, 1.0); target.hoverMode = !!target.hoverMode; const validThemes = ['system', 'light', 'dark', 'minimal-light', 'minimal-dark']; if (target.theme === 'minimal') target.theme = 'minimal-light'; else if (!validThemes.includes(target.theme)) target.theme = defaults.theme; target.hideGoogleLogoWhenExpanded = typeof source.hideGoogleLogoWhenExpanded === 'boolean' ? source.hideGoogleLogoWhenExpanded : defaults.hideGoogleLogoWhenExpanded; if (typeof source.customColors === 'object' && source.customColors !== null && !Array.isArray(source.customColors)) { target.customColors = {}; const colorRegex = /^#[0-9a-fA-F]{6}$/; COLOR_MAPPINGS.forEach(map => { if (typeof source.customColors[map.key] === 'string' && (source.customColors[map.key] === '' || colorRegex.test(source.customColors[map.key]))) { target.customColors[map.key] = source.customColors[map.key]; } else { target.customColors[map.key] = ''; } }); } else { target.customColors = JSON.parse(JSON.stringify(defaults.customColors)); } } /** * Validates and merges feature-related settings like section visibility and button locations. * @private */ function _validateAndMergeFeatureSettings_internal(target, source, defaults) { if (typeof target.visibleSections !== 'object' || target.visibleSections === null || Array.isArray(target.visibleSections)) { target.visibleSections = JSON.parse(JSON.stringify(defaults.visibleSections)); } const validSectionIDs = new Set(ALL_SECTION_DEFINITIONS.map(def => def.id)); Object.keys(defaults.visibleSections).forEach(id => { if (!validSectionIDs.has(id)) { console.warn(`${LOG_PREFIX} Invalid section ID in defaultSettings.visibleSections: ${id}`); } else if (typeof target.visibleSections[id] !== 'boolean') { target.visibleSections[id] = defaults.visibleSections[id] ?? true; } }); const validSectionModes = ['remember', 'expandAll', 'collapseAll']; if (!validSectionModes.includes(target.sectionDisplayMode)) target.sectionDisplayMode = defaults.sectionDisplayMode; target.accordionMode = !!target.accordionMode; target.enableSiteSearchCheckboxMode = typeof target.enableSiteSearchCheckboxMode === 'boolean' ? target.enableSiteSearchCheckboxMode : defaults.enableSiteSearchCheckboxMode; target.showFaviconsForSiteSearch = typeof target.showFaviconsForSiteSearch === 'boolean' ? target.showFaviconsForSiteSearch : defaults.showFaviconsForSiteSearch; target.enableFiletypeCheckboxMode = typeof target.enableFiletypeCheckboxMode === 'boolean' ? target.enableFiletypeCheckboxMode : defaults.enableFiletypeCheckboxMode; const validButtonLocations = ['header', 'topBlock', 'tools', 'none']; if (!validButtonLocations.includes(target.resetButtonLocation)) target.resetButtonLocation = defaults.resetButtonLocation; if (!validButtonLocations.includes(target.verbatimButtonLocation)) target.verbatimButtonLocation = defaults.verbatimButtonLocation; if (!validButtonLocations.includes(target.advancedSearchLinkLocation)) target.advancedSearchLinkLocation = defaults.advancedSearchLinkLocation; if (!validButtonLocations.includes(target.personalizationButtonLocation)) { target.personalizationButtonLocation = defaults.personalizationButtonLocation; } if (!validButtonLocations.includes(target.googleScholarShortcutLocation)) { target.googleScholarShortcutLocation = defaults.googleScholarShortcutLocation; } if (!validButtonLocations.includes(target.googleTrendsShortcutLocation)) { target.googleTrendsShortcutLocation = defaults.googleTrendsShortcutLocation; } if (!validButtonLocations.includes(target.googleDatasetSearchShortcutLocation)) { target.googleDatasetSearchShortcutLocation = defaults.googleDatasetSearchShortcutLocation; } const validCountryDisplayModes = ['iconAndText', 'textOnly', 'iconOnly']; if (!validCountryDisplayModes.includes(target.countryDisplayMode)) target.countryDisplayMode = defaults.countryDisplayMode; const validScrollbarPositions = ['right', 'left', 'hidden']; if (!validScrollbarPositions.includes(target.scrollbarPosition)) target.scrollbarPosition = defaults.scrollbarPosition; target.showResultStats = typeof source.showResultStats === 'boolean' ? source.showResultStats : defaults.showResultStats; } /** * Validates and merges user-defined custom lists (e.g., favorite sites). * @private */ function _validateAndMergeCustomLists_internal(target, source, defaults) { const listKeys = ['favoriteSites', 'customLanguages', 'customTimeRanges', 'customFiletypes', 'customCountries']; listKeys.forEach(key => { target[key] = Array.isArray(target[key]) ? target[key].filter(item => item && typeof item.text === 'string' && typeof item[key === 'favoriteSites' ? 'url' : 'value'] === 'string' && item.text.trim() !== '' && item[key === 'favoriteSites' ? 'url' : 'value'].trim() !== '') : JSON.parse(JSON.stringify(defaults[key])); }); } /** * Validates and merges the settings for enabled predefined options. * @private */ function _validateAndMergePredefinedOptions_internal(target, source, defaults) { target.enabledPredefinedOptions = target.enabledPredefinedOptions || {}; ['time', 'filetype'].forEach(type => { if (!target.enabledPredefinedOptions[type] || !Array.isArray(target.enabledPredefinedOptions[type])) { target.enabledPredefinedOptions[type] = JSON.parse(JSON.stringify(defaults.enabledPredefinedOptions[type] || [])); } const savedTypeOptions = source.enabledPredefinedOptions?.[type]; if (PREDEFINED_OPTIONS[type] && Array.isArray(savedTypeOptions)) { const validValues = new Set(PREDEFINED_OPTIONS[type].map(opt => opt.value)); target.enabledPredefinedOptions[type] = savedTypeOptions.filter(val => typeof val === 'string' && validValues.has(val)); } else if (!PREDEFINED_OPTIONS[type]) { target.enabledPredefinedOptions[type] = []; } }); if (target.displayLanguages && target.enabledPredefinedOptions) target.enabledPredefinedOptions.language = []; if (target.displayCountries && target.enabledPredefinedOptions) target.enabledPredefinedOptions.country = []; } /** * Validates and finalizes the order of sidebar sections. * @private */ function _finalizeSectionOrder_internal(target, source, defaults) { const finalOrder = []; const currentVisibleOrderSet = new Set(); const validSectionIDs = new Set(ALL_SECTION_DEFINITIONS.map(def => def.id)); const orderSource = (Array.isArray(source.sidebarSectionOrder) && source.sidebarSectionOrder.length > 0) ? source.sidebarSectionOrder : defaults.sidebarSectionOrder; orderSource.forEach(id => { if (typeof id === 'string' && validSectionIDs.has(id) && target.visibleSections[id] === true && !currentVisibleOrderSet.has(id)) { finalOrder.push(id); currentVisibleOrderSet.add(id); } }); defaults.sidebarSectionOrder.forEach(id => { if (typeof id === 'string' && validSectionIDs.has(id) && target.visibleSections[id] === true && !currentVisibleOrderSet.has(id)) { finalOrder.push(id); } }); target.sidebarSectionOrder = finalOrder; } /** * Takes a raw settings object (e.g., from storage) and merges it with default settings, * performing validation and sanitation to ensure a clean, usable settings object. * @private * @param {Object} saved - The raw settings object loaded from storage. * @returns {Object} The validated and merged settings object. */ function _validateAndMergeSettings(saved) { let newSettings = JSON.parse(JSON.stringify(_defaultSettingsRef)); newSettings = Utils.mergeDeep(newSettings, saved); _validateAndMergeCoreSettings_internal(newSettings, saved, _defaultSettingsRef); _validateAndMergeAppearanceSettings_internal(newSettings, saved, _defaultSettingsRef); _validateAndMergeFeatureSettings_internal(newSettings, saved, _defaultSettingsRef); _validateAndMergeCustomLists_internal(newSettings, saved, _defaultSettingsRef); _migrateToDisplayArraysIfNecessary(newSettings); ['displayLanguages', 'displayCountries'].forEach(displayKey => { if (!Array.isArray(newSettings[displayKey])) { newSettings[displayKey] = JSON.parse(JSON.stringify(_defaultSettingsRef[displayKey])) || []; } newSettings[displayKey] = newSettings[displayKey].filter(item => item && typeof item.id === 'string' && (item.type === 'predefined' ? (typeof item.text === 'string' && typeof item.originalKey === 'string') : typeof item.text === 'string') && typeof item.value === 'string' && (item.type === 'predefined' || item.type === 'custom') ); }); _validateAndMergePredefinedOptions_internal(newSettings, saved, _defaultSettingsRef); _finalizeSectionOrder_internal(newSettings, saved, _defaultSettingsRef); return newSettings; } // A map of element IDs to their live update event handler functions. const _sEH_internal = { [IDS.SETTING_WIDTH]: (t, vS) => _hSLI(t, 'sidebarWidth', vS, 90, 270, 5), [IDS.SETTING_HEIGHT]: (t, vS) => _hSLI(t, 'sidebarHeight', vS, 25, 100, 5), [IDS.SETTING_FONT_SIZE]: (t, vS) => _hSLI(t, 'fontSize', vS, 8, 24, 0.5, v => parseFloat(v).toFixed(1)), [IDS.SETTING_HEADER_ICON_SIZE]: (t, vS) => _hSLI(t, 'headerIconSize', vS, 8, 32, 0.5, v => parseFloat(v).toFixed(1)), [IDS.SETTING_VERTICAL_SPACING]: (t, vS) => _hSLI(t, 'verticalSpacingMultiplier', vS, 0.05, 1.5, 0.05, v => `x ${parseFloat(v).toFixed(2)}`), [IDS.SETTING_OPACITY]: (t, vS) => _hSLI(t, 'idleOpacity', vS, 0.1, 1.0, 0.05, v => parseFloat(v).toFixed(2)), [IDS.SETTING_INTERFACE_LANGUAGE]: (t) => { const nL = t.value; if (_currentSettings.interfaceLanguage !== nL) { _currentSettings.interfaceLanguage = nL; LocalizationService.updateActiveLocale(_currentSettings); _initMenuCommands_cb(); publicApi.populateWindow(); _buildSidebarUI_cb(); } }, [IDS.SETTING_THEME]: (t) => { _currentSettings.theme = t.value; _populateAppearanceSettings_internal(_settingsWindow, _currentSettings); _applySettingsToSidebar_cb(_currentSettings); }, [IDS.SETTING_HOVER]: (t) => { _currentSettings.hoverMode = t.checked; const oI = _settingsWindow.querySelector(`#${IDS.SETTING_OPACITY}`); if (oI) { const iHE = _currentSettings.hoverMode; oI.disabled = !iHE; const oC = oI.closest('div'); if (oC) { oC.style.opacity = iHE ? '1' : '0.6'; oC.style.pointerEvents = iHE ? 'auto' : 'none'; } } _applySettingsToSidebar_cb(_currentSettings); }, [IDS.SETTING_DRAGGABLE]: (t) => { _currentSettings.draggableHandleEnabled = t.checked; _applySettingsToSidebar_cb(_currentSettings); DragManager.setDraggable(t.checked, sidebar, sidebar?.querySelector(`.${CSS.DRAG_HANDLE}`), _currentSettings, debouncedSaveSettings); }, [IDS.SETTING_ACCORDION]: (t) => { const sMS = _settingsWindow.querySelector(`#${IDS.SETTING_SECTION_MODE}`); if (sMS?.value === 'remember') _currentSettings.accordionMode = t.checked; else { t.checked = false; _currentSettings.accordionMode = false; } _applySettingsToSidebar_cb(_currentSettings); _applySectionCollapseStates_cb(); }, [IDS.SETTING_SECTION_MODE]: (t) => { _currentSettings.sectionDisplayMode = t.value; const aC = _settingsWindow.querySelector(`#${IDS.SETTING_ACCORDION}`); if (aC) { const iRM = t.value === 'remember'; aC.disabled = !iRM; if (aC.parentElement.querySelector(`.${CSS.SETTING_VALUE_HINT}`)) aC.parentElement.querySelector(`.${CSS.SETTING_VALUE_HINT}`).style.color = iRM ? '' : 'grey'; if (!iRM) { aC.checked = false; _currentSettings.accordionMode = false; } else { aC.checked = _settingsBackup?.accordionMode ?? _currentSettings.accordionMode ?? _defaultSettingsRef.accordionMode; _currentSettings.accordionMode = aC.checked; } } _applySettingsToSidebar_cb(_currentSettings); _applySectionCollapseStates_cb(); }, [IDS.SETTING_RESET_LOCATION]: (t) => { _currentSettings.resetButtonLocation = t.value; _buildSidebarUI_cb(); }, [IDS.SETTING_VERBATIM_LOCATION]: (t) => { _currentSettings.verbatimButtonLocation = t.value; _buildSidebarUI_cb(); }, [IDS.SETTING_ADV_SEARCH_LOCATION]: (t) => { _currentSettings.advancedSearchLinkLocation = t.value; _buildSidebarUI_cb(); }, [IDS.SETTING_PERSONALIZE_LOCATION]: (target) => { _currentSettings.personalizationButtonLocation = target.value; _buildSidebarUI_cb(); }, [IDS.SETTING_SCHOLAR_LOCATION]: (target) => { _currentSettings.googleScholarShortcutLocation = target.value; _buildSidebarUI_cb(); }, [IDS.SETTING_TRENDS_LOCATION]: (target) => { _currentSettings.googleTrendsShortcutLocation = target.value; _buildSidebarUI_cb(); }, [IDS.SETTING_DATASET_SEARCH_LOCATION]: (target) => { _currentSettings.googleDatasetSearchShortcutLocation = target.value; _buildSidebarUI_cb(); }, [IDS.SETTING_SITE_SEARCH_CHECKBOX_MODE]: (target) => { _currentSettings.enableSiteSearchCheckboxMode = target.checked; _buildSidebarUI_cb(); }, [IDS.SETTING_SHOW_FAVICONS]: (target) => { _currentSettings.showFaviconsForSiteSearch = target.checked; _buildSidebarUI_cb(); }, [IDS.SETTING_FILETYPE_SEARCH_CHECKBOX_MODE]: (target) => { _currentSettings.enableFiletypeCheckboxMode = target.checked; _buildSidebarUI_cb(); }, [IDS.SETTING_COUNTRY_DISPLAY_MODE]: (t) => { _currentSettings.countryDisplayMode = t.value; _buildSidebarUI_cb(); }, [IDS.SETTING_SCROLLBAR_POSITION]: (t) => { _currentSettings.scrollbarPosition = t.value; _applySettingsToSidebar_cb(_currentSettings); }, [IDS.SETTING_SHOW_RESULT_STATS]: (t) => { _currentSettings.showResultStats = t.checked; ResultStatsManager.toggle(_currentSettings.showResultStats); }, [IDS.SETTING_HIDE_GOOGLE_LOGO]: (t) => { _currentSettings.hideGoogleLogoWhenExpanded = t.checked; _applySettingsToSidebar_cb(_currentSettings); }, [IDS.RESET_CUSTOM_COLORS_BTN]: () => { _currentSettings.customColors = JSON.parse(JSON.stringify(_defaultSettingsRef.customColors)); _populateAppearanceSettings_internal(_settingsWindow, _currentSettings); _applySettingsToSidebar_cb(_currentSettings); } }; /** * Generic handler for range slider input events. * @private */ function _hSLI(t, sK, vS, min, max, step, fFn = v => v) { const v = Utils.clamp((step === 1 || step === 5) ? parseInt(t.value, 10) : parseFloat(t.value), min, max); if (isNaN(v)) _currentSettings[sK] = _defaultSettingsRef[sK]; else _currentSettings[sK] = v; if (vS) vS.textContent = fFn(_currentSettings[sK]); _applySettingsToSidebar_cb(_currentSettings); } /** * The main event handler for live updates in the settings window. * @private * @param {Event} e - The input or change event. */ function _lUH_internal(e) { const t = e.target; if (!t) return; const sI = t.id; const vS = (t.type === 'range') ? t.parentNode.querySelector(`.${CSS.SETTING_RANGE_VALUE}`) : null; if (t.type === 'color') { const colorMapping = COLOR_MAPPINGS.find(m => m.id === t.id); if (colorMapping) { if (!_currentSettings.customColors) _currentSettings.customColors = {}; _currentSettings.customColors[colorMapping.key] = t.value; _applySettingsToSidebar_cb(_currentSettings); return; } } if (_sEH_internal[sI]) { if (t.type === 'range') _sEH_internal[sI](t, vS); else _sEH_internal[sI](t); } } const publicApi = { /** * Initializes the SettingsManager. * @param {Object} defaultSettingsObj - The default settings. * @param {Function} applyCb - Callback to apply settings to the sidebar. * @param {Function} buildCb - Callback to rebuild the sidebar UI. * @param {Function} collapseCb - Callback to apply section collapse states. * @param {Function} menuCb - Callback to initialize menu commands. * @param {Function} renderOrderCb - Callback to render the section order list. */ initialize: function(defaultSettingsObj, applyCb, buildCb, collapseCb, menuCb, renderOrderCb) { if (_isInitialized) return; _defaultSettingsRef = defaultSettingsObj; _applySettingsToSidebar_cb = applyCb; _buildSidebarUI_cb = buildCb; _applySectionCollapseStates_cb = collapseCb; _initMenuCommands_cb = menuCb; _renderSectionOrderList_ext_cb = renderOrderCb; this.load(); this.buildSkeleton(); _isInitialized = true; }, /** * Loads settings from storage and validates them. */ load: function() { const s = _loadFromStorage(); _currentSettings = _validateAndMergeSettings(s); LocalizationService.updateActiveLocale(_currentSettings); }, /** * Saves the current settings to storage. * @param {string} [logContext='SaveBtn'] - Context for logging the save action. */ save: function(logContext = 'SaveBtn') { try { ['displayLanguages', 'displayCountries'].forEach(displayKey => { const mapping = getListMapping(displayKey === 'displayLanguages' ? IDS.LANG_LIST : IDS.COUNTRIES_LIST); if (mapping && mapping.customItemsMasterKey && _currentSettings[displayKey] && Array.isArray(_currentSettings[mapping.customItemsMasterKey])) { const displayItems = _currentSettings[displayKey]; const currentDisplayCustomItems = displayItems.filter(item => item.type === 'custom'); const currentDisplayCustomItemValues = new Set(currentDisplayCustomItems.map(item => item.value)); const newMasterList = (_currentSettings[mapping.customItemsMasterKey] || []) .filter(masterItem => currentDisplayCustomItemValues.has(masterItem.value)) .map(oldMasterItem => { const correspondingDisplayItem = currentDisplayCustomItems.find(d => d.value === oldMasterItem.value); return correspondingDisplayItem ? { text: correspondingDisplayItem.text, value: oldMasterItem.value } : oldMasterItem; }); currentDisplayCustomItems.forEach(dispItem => { if (!newMasterList.find(mi => mi.value === dispItem.value)) { newMasterList.push({ text: dispItem.text, value: dispItem.value }); } }); _currentSettings[mapping.customItemsMasterKey] = newMasterList; } }); GM_setValue(STORAGE_KEY, JSON.stringify(_currentSettings)); console.log(`${LOG_PREFIX} Settings saved by SM${logContext ? ` (${logContext})` : ''}.`); _settingsBackup = JSON.parse(JSON.stringify(_currentSettings)); } catch (e) { console.error(`${LOG_PREFIX} SM save error:`, e); NotificationManager.show('alert_generic_error', { context: 'saving settings' }, 'error', 5000); } }, /** * Resets the current settings to the script's defaults. */ reset: function() { if (confirm(_('confirm_reset_settings'))) { _currentSettings = JSON.parse(JSON.stringify(_defaultSettingsRef)); _migrateToDisplayArraysIfNecessary(_currentSettings); if (!_currentSettings.sidebarSectionOrder || _currentSettings.sidebarSectionOrder.length === 0) { _currentSettings.sidebarSectionOrder = [..._defaultSettingsRef.sidebarSectionOrder]; } LocalizationService.updateActiveLocale(_currentSettings); this.populateWindow(); _applySettingsToSidebar_cb(_currentSettings); _buildSidebarUI_cb(); _initMenuCommands_cb(); _showGlobalMessage('alert_settings_reset_success', {}, 'success', 4000); } }, /** * Resets all settings from a menu command, requiring a page refresh. */ resetAllFromMenu: function() { if (confirm(_('confirm_reset_all_menu'))) { try { GM_setValue(STORAGE_KEY, JSON.stringify(_defaultSettingsRef)); alert(_('alert_reset_all_menu_success')); } catch (e) { _showGlobalMessage('alert_reset_all_menu_fail', {}, 'error', 0); } } }, /** * Returns the current settings object. * @returns {Object} The current settings. */ getCurrentSettings: function() { return _currentSettings; }, /** * Builds the skeleton HTML for the settings window and overlay. */ buildSkeleton: function() { if (_settingsWindow) return; _settingsOverlay = document.createElement('div'); _settingsOverlay.id = IDS.SETTINGS_OVERLAY; _settingsWindow = document.createElement('div'); _settingsWindow.id = IDS.SETTINGS_WINDOW; _settingsWindow.innerHTML = ` <div class="${CSS.SETTINGS_HEADER}"> <h3>${_('settingsTitle')}</h3> <button class="${CSS.SETTINGS_CLOSE_BTN}" title="${_('settings_close_button_title')}">${SVG_ICONS.close}</button> </div> <div id="${IDS.SETTINGS_MESSAGE_BAR}" class="${CSS.MESSAGE_BAR}" style="display: none;"></div> <div class="${CSS.SETTINGS_TABS}"> <button class="${CSS.TAB_BUTTON} ${CSS.IS_ACTIVE}" data-${DATA_ATTR.TAB}="general">${_('settings_tab_general')}</button> <button class="${CSS.TAB_BUTTON}" data-${DATA_ATTR.TAB}="appearance">${_('settings_tab_appearance')}</button> <button class="${CSS.TAB_BUTTON}" data-${DATA_ATTR.TAB}="features">${_('settings_tab_features')}</button> <button class="${CSS.TAB_BUTTON}" data-${DATA_ATTR.TAB}="custom">${_('settings_tab_custom')}</button> </div> <div class="${CSS.SETTINGS_TAB_CONTENT}"> <div class="${CSS.TAB_PANE} ${CSS.IS_ACTIVE}" data-${DATA_ATTR.TAB}="general" id="${IDS.TAB_PANE_GENERAL}"></div> <div class="${CSS.TAB_PANE}" data-${DATA_ATTR.TAB}="appearance" id="${IDS.TAB_PANE_APPEARANCE}"></div> <div class="${CSS.TAB_PANE}" data-${DATA_ATTR.TAB}="features" id="${IDS.TAB_PANE_FEATURES}"></div> <div class="${CSS.TAB_PANE}" data-${DATA_ATTR.TAB}="custom" id="${IDS.TAB_PANE_CUSTOM}"></div> </div> <div class="${CSS.SETTINGS_FOOTER}"> <button class="${CSS.BUTTON_RESET}">${_('settings_reset_all_button')}</button> <button class="${CSS.BUTTON_CANCEL}">${_('settings_cancel_button')}</button> <button class="${CSS.BUTTON_SAVE}">${_('settings_save_button')}</button> </div>`; _settingsOverlay.appendChild(_settingsWindow); document.body.appendChild(_settingsOverlay); this.bindEvents(); }, /** * Populates the settings window with content and current values. */ populateWindow: function() { if (!_settingsWindow) return; try { // Update translatable texts _settingsWindow.querySelector(`.${CSS.SETTINGS_HEADER} h3`).textContent = _('settingsTitle'); _settingsWindow.querySelector(`.${CSS.SETTINGS_CLOSE_BTN}`).title = _('settings_close_button_title'); _settingsWindow.querySelector(`button[data-${DATA_ATTR.TAB}="general"]`).textContent = _('settings_tab_general'); _settingsWindow.querySelector(`button[data-${DATA_ATTR.TAB}="appearance"]`).textContent = _('settings_tab_appearance'); _settingsWindow.querySelector(`button[data-${DATA_ATTR.TAB}="features"]`).textContent = _('settings_tab_features'); _settingsWindow.querySelector(`button[data-${DATA_ATTR.TAB}="custom"]`).textContent = _('settings_tab_custom'); _settingsWindow.querySelector(`.${CSS.BUTTON_RESET}`).textContent = _('settings_reset_all_button'); _settingsWindow.querySelector(`.${CSS.BUTTON_CANCEL}`).textContent = _('settings_cancel_button'); _settingsWindow.querySelector(`.${CSS.BUTTON_SAVE}`).textContent = _('settings_save_button'); // Re-generate tab content const paneGeneral = _settingsWindow.querySelector(`#${IDS.TAB_PANE_GENERAL}`); if (paneGeneral) paneGeneral.innerHTML = SettingsUIPaneGenerator.createGeneralPaneHTML(); const paneAppearance = _settingsWindow.querySelector(`#${IDS.TAB_PANE_APPEARANCE}`); if (paneAppearance) paneAppearance.innerHTML = SettingsUIPaneGenerator.createAppearancePaneHTML(); const paneFeatures = _settingsWindow.querySelector(`#${IDS.TAB_PANE_FEATURES}`); if (paneFeatures) paneFeatures.innerHTML = SettingsUIPaneGenerator.createFeaturesPaneHTML(); const paneCustom = _settingsWindow.querySelector(`#${IDS.TAB_PANE_CUSTOM}`); if (paneCustom) paneCustom.innerHTML = SettingsUIPaneGenerator.createCustomPaneHTML(); // Populate values _populateGeneralSettings_internal(_settingsWindow, _currentSettings); _populateAppearanceSettings_internal(_settingsWindow, _currentSettings); _populateFeatureSettings_internal(_settingsWindow, _currentSettings, _renderSectionOrderList_ext_cb); ModalManager.resetEditStateGlobally(); _initializeActiveSettingsTab_internal(); this.bindLiveUpdateEvents(); this.bindFeaturesTabEvents(); } catch (e) { _showGlobalMessage('alert_init_fail', { scriptName: SCRIPT_INTERNAL_NAME, error: "Settings UI pop err" }, 'error', 0); } }, /** * Shows the settings window. */ show: function() { if (!_settingsOverlay || !_settingsWindow) return; _settingsBackup = JSON.parse(JSON.stringify(_currentSettings)); LocalizationService.updateActiveLocale(_currentSettings); this.populateWindow(); applyThemeToElement(_settingsWindow, _currentSettings.theme); applyThemeToElement(_settingsOverlay, _currentSettings.theme); _settingsOverlay.style.display = 'flex'; }, /** * Hides the settings window. * @param {boolean} [isCancel=false] - If true, reverts any live changes to their pre-opening state. */ hide: function(isCancel = false) { if (!_settingsOverlay) return; ModalManager.resetEditStateGlobally(); if (ModalManager.isModalOpen()) ModalManager.hide(true); _settingsOverlay.style.display = 'none'; const messageBar = document.getElementById(IDS.SETTINGS_MESSAGE_BAR); if (messageBar) messageBar.style.display = 'none'; if (isCancel && _settingsBackup && Object.keys(_settingsBackup).length > 0) { _currentSettings = JSON.parse(JSON.stringify(_settingsBackup)); LocalizationService.updateActiveLocale(_currentSettings); this.populateWindow(); _applySettingsToSidebar_cb(_currentSettings); _buildSidebarUI_cb(); _initMenuCommands_cb(); } else if (isCancel) { console.warn(`${LOG_PREFIX} SM: Cancelled, no backup to restore or backup was identical.`); } }, /** * Binds the main, one-time events for the settings window using event delegation. */ bindEvents: function() { if (!_settingsWindow || _settingsWindow.dataset.eventsBound === 'true') return; _settingsWindow.addEventListener('click', (e) => { const target = e.target; // Close, Cancel, Save, Reset buttons if (target.closest(`.${CSS.SETTINGS_CLOSE_BTN}`)) this.hide(true); else if (target.closest(`.${CSS.BUTTON_CANCEL}`)) this.hide(true); else if (target.closest(`.${CSS.BUTTON_SAVE}`)) { this.save(); LocalizationService.updateActiveLocale(_currentSettings); _initMenuCommands_cb(); _buildSidebarUI_cb(); this.hide(false); } else if (target.closest(`.${CSS.BUTTON_RESET}`)) this.reset(); // Tab navigation else if (target.closest(`.${CSS.TAB_BUTTON}`) && !target.closest(`.${CSS.IS_ACTIVE}`)) { ModalManager.resetEditStateGlobally(); const tabToActivate = target.closest(`.${CSS.TAB_BUTTON}`).dataset[DATA_ATTR.TAB]; if (!tabToActivate) return; _settingsWindow.querySelectorAll(`.${CSS.TAB_BUTTON}`).forEach(b => b.classList.remove(CSS.IS_ACTIVE)); target.closest(`.${CSS.TAB_BUTTON}`).classList.add(CSS.IS_ACTIVE); _settingsWindow.querySelector(`.${CSS.SETTINGS_TAB_CONTENT}`)?.querySelectorAll(`.${CSS.TAB_PANE}`)?.forEach(p => p.classList.remove(CSS.IS_ACTIVE)); _settingsWindow.querySelector(`.${CSS.SETTINGS_TAB_CONTENT} .${CSS.TAB_PANE}[data-${DATA_ATTR.TAB}="${tabToActivate}"]`)?.classList.add(CSS.IS_ACTIVE); } // Manage custom buttons else if (target.closest(`.${CSS.BUTTON_MANAGE_CUSTOM}`)) { const manageType = target.closest(`.${CSS.BUTTON_MANAGE_CUSTOM}`).dataset[DATA_ATTR.MANAGE_TYPE]; if (manageType) { ModalManager.openManageCustomOptions(manageType, _currentSettings, PREDEFINED_OPTIONS, (updatedItemsArray, newEnabledPredefs, itemsArrayKey, predefinedOptKey) => { if (itemsArrayKey) _currentSettings[itemsArrayKey] = updatedItemsArray; if (predefinedOptKey && newEnabledPredefs) { if (!_currentSettings.enabledPredefinedOptions) _currentSettings.enabledPredefinedOptions = {}; _currentSettings.enabledPredefinedOptions[predefinedOptKey] = newEnabledPredefs; } _buildSidebarUI_cb(); } ); } } }); _settingsWindow.dataset.eventsBound = 'true'; }, /** * Binds events for controls that provide a live preview of changes (e.g., sliders, color pickers). */ bindLiveUpdateEvents: function() { if (!_settingsWindow) return; const liveUpdateHandler = (e) => { const target = e.target; if (target && _sEH_internal[target.id]) { const rangeValueSpan = (target.type === 'range') ? target.parentNode.querySelector(`.${CSS.SETTING_RANGE_VALUE}`) : null; _sEH_internal[target.id](target, rangeValueSpan); } else if (target && target.type === 'color') { _lUH_internal(e); } }; _settingsWindow.removeEventListener('input', _lUH_internal); _settingsWindow.removeEventListener('change', _lUH_internal); _settingsWindow.addEventListener('input', liveUpdateHandler); _settingsWindow.addEventListener('change', liveUpdateHandler); const resetColorsBtn = _settingsWindow.querySelector(`#${IDS.RESET_CUSTOM_COLORS_BTN}`); if (resetColorsBtn) { resetColorsBtn.removeEventListener('click', _sEH_internal[IDS.RESET_CUSTOM_COLORS_BTN]); resetColorsBtn.addEventListener('click', _sEH_internal[IDS.RESET_CUSTOM_COLORS_BTN]); } }, /** * Binds events specific to the "Features" tab, such as section visibility checkboxes. */ bindFeaturesTabEvents: function() { const featuresPane = _settingsWindow?.querySelector(`#${IDS.TAB_PANE_FEATURES}`); if (!featuresPane) return; featuresPane.querySelectorAll(`input[type="checkbox"][data-${DATA_ATTR.SECTION_ID}]`).forEach(checkbox => { checkbox.removeEventListener('change', this._handleVisibleSectionChange); checkbox.addEventListener('change', this._handleVisibleSectionChange.bind(this)); }); const orderListElement = featuresPane.querySelector(`#${IDS.SIDEBAR_SECTION_ORDER_LIST}`); if (orderListElement) { SectionOrderDragHandler.initialize(orderListElement, _currentSettings, () => { _renderSectionOrderList_ext_cb(_currentSettings); _buildSidebarUI_cb(); }); } }, /** * Handles changes to the visibility of a section. * @private * @param {Event} e - The change event from a visibility checkbox. */ _handleVisibleSectionChange: function(e) { const target = e.target; const sectionId = target.getAttribute(`data-${DATA_ATTR.SECTION_ID}`); if (sectionId && _currentSettings.visibleSections.hasOwnProperty(sectionId)) { _currentSettings.visibleSections[sectionId] = target.checked; _finalizeSectionOrder_internal(_currentSettings, _currentSettings, _defaultSettingsRef); _renderSectionOrderList_ext_cb(_currentSettings); _buildSidebarUI_cb(); } }, }; return publicApi; })(); /** * @module DragManager * Manages the dragging functionality for the main sidebar, allowing the user * to reposition it on the screen. */ const DragManager = (function() { let _isDragging = false; let _dragStartX, _dragStartY, _sidebarStartX, _sidebarStartY; let _sidebarElement, _handleElement; let _settingsManagerRef, _saveCallbackRef; /** * Safely extracts client coordinates (x, y) from a mouse or touch event. * This utility function handles both desktop (mousedown, mousemove) and mobile (touchstart, touchmove) events. * @param {MouseEvent|TouchEvent} e - The browser event object. * @returns {{x: number, y: number}} An object containing the x and y coordinates. */ function _getEventCoordinates(e) { return (e.touches && e.touches.length > 0) ? { x: e.touches[0].clientX, y: e.touches[0].clientY } : { x: e.clientX, y: e.clientY }; } /** * Initiates a drag operation. * @private * @param {MouseEvent|TouchEvent} e - The start event. */ function _startDrag(e) { const currentSettings = _settingsManagerRef.getCurrentSettings(); if (!currentSettings.draggableHandleEnabled || currentSettings.sidebarCollapsed || (e.type === 'mousedown' && e.button !== 0)) { return; } e.preventDefault(); _isDragging = true; const coords = _getEventCoordinates(e); _dragStartX = coords.x; _dragStartY = coords.y; _sidebarStartX = _sidebarElement.offsetLeft; _sidebarStartY = _sidebarElement.offsetTop; _sidebarElement.style.cursor = 'grabbing'; _sidebarElement.style.userSelect = 'none'; document.body.style.cursor = 'grabbing'; } /** * Handles the dragging movement. * @private * @param {MouseEvent|TouchEvent} e - The move event. */ function _drag(e) { if (!_isDragging) return; e.preventDefault(); const coords = _getEventCoordinates(e); const dx = coords.x - _dragStartX; const dy = coords.y - _dragStartY; let newLeft = _sidebarStartX + dx; let newTop = _sidebarStartY + dy; const maxLeft = window.innerWidth - (_sidebarElement?.offsetWidth ?? 0); const maxTop = window.innerHeight - (_sidebarElement?.offsetHeight ?? 0); newLeft = Utils.clamp(newLeft, 0, maxLeft); newTop = Utils.clamp(newTop, MIN_SIDEBAR_TOP_POSITION, maxTop); if (_sidebarElement) { _sidebarElement.style.left = `${newLeft}px`; _sidebarElement.style.top = `${newTop}px`; } } /** * Stops the drag operation and saves the new position. * @private */ function _stopDrag() { if (_isDragging) { _isDragging = false; if (_sidebarElement) { _sidebarElement.style.cursor = 'default'; _sidebarElement.style.userSelect = ''; } document.body.style.cursor = ''; const currentSettings = _settingsManagerRef.getCurrentSettings(); if (!currentSettings.sidebarPosition) currentSettings.sidebarPosition = {}; currentSettings.sidebarPosition.left = _sidebarElement.offsetLeft; currentSettings.sidebarPosition.top = _sidebarElement.offsetTop; if (typeof _saveCallbackRef === 'function') { _saveCallbackRef('Drag Stop'); } } } return { /** * Initializes the DragManager with necessary elements and callbacks. * @param {HTMLElement} sidebarEl - The main sidebar element. * @param {HTMLElement} handleEl - The specific drag handle element. * @param {Object} settingsMgr - A reference to the SettingsManager. * @param {Function} saveCb - The debounced save settings callback. */ init: function(sidebarEl, handleEl, settingsMgr, saveCb) { _sidebarElement = sidebarEl; _handleElement = handleEl; _settingsManagerRef = settingsMgr; _saveCallbackRef = saveCb; if (_handleElement) { _handleElement.addEventListener('mousedown', _startDrag); _handleElement.addEventListener('touchstart', _startDrag, { passive: false }); } document.addEventListener('mousemove', _drag); document.addEventListener('touchmove', _drag, { passive: false }); document.addEventListener('mouseup', _stopDrag); document.addEventListener('touchend', _stopDrag); document.addEventListener('touchcancel', _stopDrag); }, /** * Enables or disables the dragging functionality. * @param {boolean} isEnabled - Whether dragging should be enabled. * @param {HTMLElement} sidebarEl - The main sidebar element. * @param {HTMLElement} handleEl - The drag handle element. */ setDraggable: function(isEnabled, sidebarEl, handleEl) { _sidebarElement = sidebarEl; _handleElement = handleEl; if (_handleElement) { _handleElement.style.display = isEnabled ? 'block' : 'none'; } } }; })(); /** * @module ResultStatsManager * Manages the display of search result statistics (count and time) in the sidebar. * It uses a robust combination of polling and MutationObserver to reliably find * the statistics element on the Google search results page, which can load asynchronously. */ const ResultStatsManager = (function() { let _container = null; let _observer = null; let _isUpdating = false; let _pollingInterval = null; let _pollCount = 0; const MAX_POLLS = 16; const POLLING_INTERVAL_MS = 250; let _statsDisplayed = false; /** * Parses the raw statistics string from Google's result-stats element * and formats it into a more compact "count (time)" string. * @private * @param {string} text - The raw text content from the #result-stats element. * @returns {string} The formatted statistics string, or an empty string if parsing fails. */ function _parseAndFormatStats(text) { if (!text) return ''; const timeRegex = /[(\(]([\d,.]+)\s*\S+[)\)]/i; const timeMatch = text.match(timeRegex); if (!timeMatch) { const fallbackTimeRegex = /[(\(]([\d,.]+)\s*s[)\)]/i; const fallbackTimeMatch = text.match(fallbackTimeRegex); if (!fallbackTimeMatch) { return ''; } timeMatch = fallbackTimeMatch; } const timeStr = timeMatch[1].replace(',', '.'); let textWithoutTime = text.replace(timeMatch[0], ''); const numberRegex = /[\d.,\s ]+/g; let allNumbers = textWithoutTime.match(numberRegex) || []; if (allNumbers.length === 0) { return ''; } let largestNumber = 0; allNumbers.forEach(numStr => { const cleanNumStr = numStr.trim().replace(/[.,\s ]/g, ''); if (cleanNumStr) { const num = parseInt(cleanNumStr, 10); if (!isNaN(num) && num > largestNumber) { largestNumber = num; } } }); if (largestNumber === 0) { if (!/\s0\s/.test(textWithoutTime)) { return ''; } } const formattedCount = new Intl.NumberFormat('en-US').format(largestNumber); return `${formattedCount} (${timeStr}s)`; } /** * Updates the displayed statistics in the sidebar if the source element is found. * @private * @returns {boolean} True if the stats were successfully displayed, false otherwise. */ function _updateDisplay() { if (_isUpdating) return false; if (!SettingsManager.getCurrentSettings().showResultStats) { _clearDisplay(); return true; } const sourceEl = document.getElementById('result-stats'); if (!sourceEl || !_container) { _clearDisplay(); return false; } const formattedText = _parseAndFormatStats(sourceEl.textContent); const displayEl = _container.querySelector('#gscs-result-stats-display'); if (formattedText) { _isUpdating = true; if (displayEl && displayEl.textContent !== formattedText) { displayEl.textContent = formattedText; } if (_container.style.display === 'none') { _container.style.display = ''; } _statsDisplayed = true; _isUpdating = false; return true; } else { _clearDisplay(); return false; } } /** * Clears the displayed statistics from the sidebar. * @private */ function _clearDisplay() { if (_container) { _isUpdating = true; const displayEl = _container.querySelector('#gscs-result-stats-display'); if (displayEl && displayEl.textContent !== '') { displayEl.textContent = ''; } if (_container.style.display !== 'none') { _container.style.display = 'none'; } _isUpdating = false; } } /** * Stops the polling interval used to find the stats element. * @private */ function _stopPolling() { if (_pollingInterval) { clearInterval(_pollingInterval); _pollingInterval = null; } } /** * Starts a robust check for the statistics element, using both polling and a MutationObserver. * @private */ function _startRobustCheck() { if (_updateDisplay()) { return; } _stopPolling(); _pollCount = 0; _pollingInterval = setInterval(() => { _pollCount++; if (_updateDisplay() || _pollCount >= MAX_POLLS) { _stopPolling(); } }, POLLING_INTERVAL_MS); if (_observer) _observer.disconnect(); const targetNode = document.body; if (!targetNode) return; _observer = new MutationObserver(() => { if (document.getElementById('result-stats')) { _updateDisplay(); _stopPolling(); _observer.disconnect(); } }); _observer.observe(targetNode, { childList: true, subtree: true }); } window.addEventListener('load', () => { setTimeout(() => { if (!_statsDisplayed) { _updateDisplay(); } }, 500); }); return { /** * Initializes the manager, starting the search for the stats element. */ init: function() { _statsDisplayed = false; _startRobustCheck(); }, /** * Creates the container element for the stats display. * @param {DocumentFragment} parentFragment - The fragment to append the container to. */ createContainer: function(parentFragment) { _container = document.createElement('div'); _container.id = IDS.RESULT_STATS_CONTAINER; const display = document.createElement('div'); display.id = 'gscs-result-stats-display'; _container.appendChild(display); parentFragment.appendChild(_container); _container.style.display = 'none'; }, /** * Public method to trigger a manual update of the stats display. */ update: _updateDisplay, /** * Toggles the visibility of the stats display based on user settings. * @param {boolean} show - Whether to show or hide the stats. */ toggle: function(show) { if (show) { if (!_statsDisplayed) { _startRobustCheck(); } else { _updateDisplay(); } } else { _clearDisplay(); } } }; })(); /** * @module URLActionManager * The core action handler of the script. It is responsible for applying all filters * by generating new Google search URLs with the appropriate parameters and then * navigating the browser to them. It handles a variety of URL parameters, including * the complex `tbs` parameter and query-modifying operators like `site:`. */ const URLActionManager = (function() { function _getURLObject() { try { return new URL(window.location.href); } catch (e) { console.error(`${LOG_PREFIX} Error creating URL object: `, e); return null; }} function _navigateTo(url) { window.location.href = url.toString(); } function _setSearchParam(urlObj, paramName, value) { urlObj.searchParams.set(paramName, value); } function _deleteSearchParam(urlObj, paramName) { urlObj.searchParams.delete(paramName); } function _getTbsParts(urlObj) { const tbs = urlObj.searchParams.get('tbs'); return tbs ? tbs.split(',').filter(p => p.trim() !== '') : []; } function _setTbsParam(urlObj, tbsPartsArray) { const newTbsValue = tbsPartsArray.join(','); if (newTbsValue) { _setSearchParam(urlObj, 'tbs', newTbsValue); } else { _deleteSearchParam(urlObj, 'tbs'); }} /** * A generic function to generate a new URL based on a modification function. * @param {Function} urlModifier - A function that takes a URL object and modifies it. * @returns {string|null} The string representation of the modified URL, or null on error. */ function generateURL(urlModifier) { try { const u = _getURLObject(); if (!u) return null; urlModifier(u); return u.toString(); } catch (e) { console.error(`${LOG_PREFIX} Error generating URL:`, e); return null; } } const publicApi = { generateURLObject: _getURLObject, generateResetFiltersURL: function() { return generateURL(u => { const q = u.searchParams.get('q') || ''; const nP = new URLSearchParams(); // Clean both site and filetype operators let cQ = Utils._cleanQueryByOperator(q, 'site'); cQ = Utils._cleanQueryByOperator(cQ, 'filetype'); if (cQ) { nP.set('q', cQ); } u.search = nP.toString(); _deleteSearchParam(u, 'tbs'); _deleteSearchParam(u, 'lr'); _deleteSearchParam(u, 'cr'); _deleteSearchParam(u, 'as_filetype'); _deleteSearchParam(u, 'as_occt'); }); }, generateToggleVerbatimURL: function() { return generateURL(u => { let tP = _getTbsParts(u); const vP = 'li:1'; const iCA = tP.includes(vP); tP = tP.filter(p => p !== vP); if (!iCA) { tP.push(vP); } _setTbsParam(u, tP); }); }, generateTogglePersonalizationURL: function() { return generateURL(u => { const isActive = publicApi.isPersonalizationActive(); if (isActive) { _setSearchParam(u, 'pws', '0'); } else { _deleteSearchParam(u, 'pws'); } }); }, generateFilterURL: function(type, value) { return generateURL(u => { let tbsParts = _getTbsParts(u); const isTimeFilter = type === 'qdr'; const isStandaloneParam = ['lr', 'cr', 'as_occt'].includes(type); if (isTimeFilter) { let processedTbsParts = tbsParts.filter(p => !p.startsWith(`qdr:`) && !p.startsWith('cdr:') && !p.startsWith('cd_min:') && !p.startsWith('cd_max:')); if (value !== '') processedTbsParts.push(`qdr:${value}`); _setTbsParam(u, processedTbsParts); } else if (isStandaloneParam) { _deleteSearchParam(u, type); if (value !== '' && !(type === 'as_occt' && value === 'any')) { _setSearchParam(u, type, value); } } else if (type === 'as_filetype') { let currentQuery = u.searchParams.get('q') || ''; // MODIFIED: Only clean 'filetype:' operators currentQuery = Utils._cleanQueryByOperator(currentQuery, 'filetype'); if (value !== '') { _setSearchParam(u, 'q', (currentQuery + ` filetype:${value}`).trim()); } else { if (currentQuery) _setSearchParam(u, 'q', currentQuery); else _deleteSearchParam(u, 'q'); } _deleteSearchParam(u, 'as_filetype'); } }); }, generateSiteSearchURL: function(siteCriteria) { return generateURL(u => { const sitesToSearch = Array.isArray(siteCriteria) ? siteCriteria.flatMap(sc => Utils.parseCombinedValue(sc)) : Utils.parseCombinedValue(siteCriteria); const uniqueSites = [...new Set(sitesToSearch.map(s => s.toLowerCase()))]; if (uniqueSites.length === 0) return; let q = u.searchParams.get('q') || ''; // MODIFIED: Only clean 'site:' operators q = Utils._cleanQueryByOperator(q, 'site'); let siteQueryPart = uniqueSites.map(s => `site:${s}`).join(' OR '); const nQ = `${q} ${siteQueryPart}`.trim(); _setSearchParam(u, 'q', nQ); // Clear other filters that conflict with site search _deleteSearchParam(u, 'cr'); _deleteSearchParam(u, 'as_filetype'); }); }, generateCombinedFiletypeSearchURL: function(filetypeCriteria) { return generateURL(u => { const filetypesToSearch = Array.isArray(filetypeCriteria) ? filetypeCriteria.flatMap(fc => Utils.parseCombinedValue(fc)) : Utils.parseCombinedValue(filetypeCriteria); const uniqueFiletypes = [...new Set(filetypesToSearch.map(f => f.toLowerCase()))]; if (uniqueFiletypes.length === 0) return; let q = u.searchParams.get('q') || ''; // MODIFIED: Only clean 'filetype:' operators q = Utils._cleanQueryByOperator(q, 'filetype'); let filetypeQueryPart = uniqueFiletypes.map(ft => `filetype:${ft}`).join(' OR '); const nQ = `${q} ${filetypeQueryPart}`.trim(); _setSearchParam(u, 'q', nQ); _deleteSearchParam(u, 'as_filetype'); }); }, generateClearSiteSearchURL: function() { return generateURL(u => { const q = u.searchParams.get('q') || ''; // MODIFIED: Only clean 'site:' operators let nQ = Utils._cleanQueryByOperator(q, 'site'); if (nQ) { _setSearchParam(u, 'q', nQ); } else { _deleteSearchParam(u, 'q'); } }); }, generateClearFiletypeSearchURL: function() { return generateURL(u => { let q = u.searchParams.get('q') || ''; // MODIFIED: Only clean 'filetype:' operators q = Utils._cleanQueryByOperator(q, 'filetype'); if (q) { _setSearchParam(u, 'q', q); } else { _deleteSearchParam(u, 'q'); } _deleteSearchParam(u, 'as_filetype'); }); }, generateDateRangeURL: function(dateMinStr, dateMaxStr) { return generateURL(u => { let dateTbsPart = 'cdr:1'; if (dateMinStr) { const [y, m, d] = dateMinStr.split('-'); dateTbsPart += `,cd_min:${m}/${d}/${y}`; } if (dateMaxStr) { const [y, m, d] = dateMaxStr.split('-'); dateTbsPart += `,cd_max:${m}/${d}/${y}`; } let tbsParts = _getTbsParts(u); let preservedTbsParts = tbsParts.filter(p => !p.startsWith('qdr:') && !p.startsWith('cdr:') && !p.startsWith('cd_min:') && !p.startsWith('cd_max:')); let newTbsParts = [...preservedTbsParts, dateTbsPart]; _setTbsParam(u, newTbsParts); }); }, triggerResetFilters: function() { const url = publicApi.generateResetFiltersURL(); if(url) _navigateTo(url); else NotificationManager.show('alert_error_resetting_filters', {}, 'error', 5000); }, triggerToggleVerbatim: function() { const url = publicApi.generateToggleVerbatimURL(); if(url) _navigateTo(url); else NotificationManager.show('alert_error_toggling_verbatim', {}, 'error', 5000); }, triggerTogglePersonalization: function() { const url = publicApi.generateTogglePersonalizationURL(); if (url) _navigateTo(url); else NotificationManager.show('alert_error_toggling_personalization', {}, 'error', 5000); }, applyFilter: function(type, value) { if (type === 'as_filetype' && Utils.parseCombinedValue(value).length > 1) { publicApi.applyCombinedFiletypeSearch(value); return; } const url = publicApi.generateFilterURL(type, value); if (url) _navigateTo(url); else NotificationManager.show('alert_error_applying_filter', { type, value }, 'error', 5000); }, applySiteSearch: function(siteCriteria) { const url = publicApi.generateSiteSearchURL(siteCriteria); if (url) _navigateTo(url); else { const siteForError = Array.isArray(siteCriteria) ? siteCriteria.join(', ') : siteCriteria; NotificationManager.show('alert_error_applying_site_search', { site: siteForError }, 'error', 5000); } }, applyCombinedFiletypeSearch: function(filetypeCriteria) { const url = publicApi.generateCombinedFiletypeSearchURL(filetypeCriteria); if (url) _navigateTo(url); else { const ftForError = Array.isArray(filetypeCriteria) ? filetypeCriteria.join(', ') : filetypeCriteria; NotificationManager.show('alert_error_applying_filter', { type: 'filetype (combined)', value: ftForError }, 'error', 5000); } }, clearSiteSearch: function() { const url = publicApi.generateClearSiteSearchURL(); if(url) _navigateTo(url); else NotificationManager.show('alert_error_clearing_site_search', {}, 'error', 5000); }, clearFiletypeSearch: function() { const url = publicApi.generateClearFiletypeSearchURL(); if (url) _navigateTo(url); else NotificationManager.show('alert_error_applying_filter', { type: 'filetype', value: '(clear)' }, 'error', 5000); }, applyDateRange: function(dateMinStr, dateMaxStr) { const url = publicApi.generateDateRangeURL(dateMinStr, dateMaxStr); if(url) _navigateTo(url); else NotificationManager.show('alert_error_applying_date', {}, 'error', 5000); }, isPersonalizationActive: function() { try { const u = _getURLObject(); return u ? u.searchParams.get('pws') !== '0' : true; } catch(e) { console.warn(`${LOG_PREFIX} [URLActionManager.isPersonalizationActive] Error:`, e); return true; }}, isVerbatimActive: function() { try { const u = _getURLObject(); return u ? /li:1/.test(u.searchParams.get('tbs') || '') : false; } catch (e) { console.warn(`${LOG_PREFIX} Error checking verbatim status:`, e); return false; }}, }; return publicApi; })(); /** * Injects the script's CSS styles into the page. * It relies on the companion style script to populate `window.GSCS_Namespace.stylesText`. */ function addGlobalStyles() { if (typeof window.GSCS_Namespace !== 'undefined' && typeof window.GSCS_Namespace.stylesText === 'string') { const cleanedCSS = window.GSCS_Namespace.stylesText.replace(/\/\*[\s\S]*?\*\/|([^\\:]|^)\/\/.*$/gm, '$1').replace(/\n\s*\n/g, '\n'); GM_addStyle(cleanedCSS); } else { console.error(`${LOG_PREFIX} CRITICAL: CSS styles provider not found.`); if (typeof IDS !== 'undefined' && IDS.SIDEBAR) { GM_addStyle(`#${IDS.SIDEBAR} { border: 3px dashed red !important; padding: 15px !important; background: white !important; color: red !important; } #${IDS.SIDEBAR}::before { content: "Error: CSS Missing!"; }`);} } } /** * Sets up a listener to detect changes in the system's color scheme (light/dark mode). * This allows the sidebar to automatically update its theme when the user's system theme changes, * provided the "Follow System" theme option is selected. */ function setupSystemThemeListener() { if (systemThemeMediaQuery && systemThemeMediaQuery._sidebarThemeListener) { try { systemThemeMediaQuery.removeEventListener('change', systemThemeMediaQuery._sidebarThemeListener); } catch (e) {} systemThemeMediaQuery._sidebarThemeListener = null; } if (window.matchMedia) { systemThemeMediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); const listener = () => { const cs = SettingsManager.getCurrentSettings(); if (sidebar && cs.theme === 'system') { applyThemeToElement(sidebar, 'system'); } }; systemThemeMediaQuery.addEventListener('change', listener); systemThemeMediaQuery._sidebarThemeListener = listener; } } /** * Builds the basic HTML structure (skeleton) for the sidebar and injects it into the page. * This initial structure includes the header with collapse, drag, and settings buttons. */ function buildSidebarSkeleton() { sidebar = document.createElement('div'); sidebar.id = IDS.SIDEBAR; const header = document.createElement('div'); header.classList.add(CSS.SIDEBAR_HEADER); const collapseBtn = document.createElement('button'); collapseBtn.id = IDS.COLLAPSE_BUTTON; collapseBtn.innerHTML = SVG_ICONS.chevronLeft; collapseBtn.title = _('sidebar_collapse_title'); const dragHandle = document.createElement('div'); dragHandle.classList.add(CSS.DRAG_HANDLE); dragHandle.title = _('sidebar_drag_title'); const settingsBtn = document.createElement('button'); settingsBtn.id = IDS.SETTINGS_BUTTON; settingsBtn.classList.add(CSS.SETTINGS_BUTTON); settingsBtn.innerHTML = SVG_ICONS.settings; settingsBtn.title = _('sidebar_settings_title'); header.appendChild(collapseBtn); header.appendChild(dragHandle); header.appendChild(settingsBtn); sidebar.appendChild(header); document.body.appendChild(sidebar); } /** * Applies all visual settings (position, dimensions, theme, colors, etc.) to the sidebar element. * This function is called whenever a relevant setting is changed. * @param {Object} [settingsToApply] - The settings object to apply. If not provided, it uses the current settings. */ function applySettings(settingsToApply) { if (!sidebar) return; const currentSettings = settingsToApply || SettingsManager.getCurrentSettings(); let targetTop = currentSettings.sidebarPosition.top; targetTop = Math.max(MIN_SIDEBAR_TOP_POSITION, targetTop); sidebar.style.left = `${currentSettings.sidebarPosition.left}px`; sidebar.style.top = `${targetTop}px`; sidebar.style.setProperty('--sidebar-font-base-size', `${currentSettings.fontSize}px`); sidebar.style.setProperty('--sidebar-header-icon-base-size', `${currentSettings.headerIconSize}px`); sidebar.style.setProperty('--sidebar-spacing-multiplier', currentSettings.verticalSpacingMultiplier); sidebar.style.setProperty('--sidebar-max-height', `${currentSettings.sidebarHeight}vh`); if (!currentSettings.sidebarCollapsed) { sidebar.style.width = `${currentSettings.sidebarWidth}px`; } else { sidebar.style.width = '40px';} applyThemeToElement(sidebar, currentSettings.theme); if (sidebar._hoverListeners) { sidebar.removeEventListener('mouseenter', sidebar._hoverListeners.enter); sidebar.removeEventListener('mouseleave', sidebar._hoverListeners.leave); sidebar._hoverListeners = null; sidebar.style.opacity = '1';} if (currentSettings.hoverMode && !currentSettings.sidebarCollapsed) { const idleOpacityValue = currentSettings.idleOpacity; sidebar.style.opacity = idleOpacityValue.toString(); const enterL = () => { if (!currentSettings.sidebarCollapsed) sidebar.style.opacity = '1'; }; const leaveL = () => { if (!currentSettings.sidebarCollapsed) sidebar.style.opacity = idleOpacityValue.toString(); }; sidebar.addEventListener('mouseenter', enterL); sidebar.addEventListener('mouseleave', leaveL); sidebar._hoverListeners = { enter: enterL, leave: leaveL }; } else { sidebar.style.opacity = '1'; } applySidebarCollapseVisuals(currentSettings.sidebarCollapsed); const colors = currentSettings.customColors; COLOR_MAPPINGS.forEach(map => { map.cssVars.forEach(cssVar => { if (colors[map.key]) { sidebar.style.setProperty(cssVar, colors[map.key]); } else { sidebar.style.removeProperty(cssVar); } }); }); sidebar.classList.remove('scrollbar-left', 'scrollbar-hidden'); if (currentSettings.scrollbarPosition === 'left') { sidebar.classList.add('scrollbar-left'); } else if (currentSettings.scrollbarPosition === 'hidden') { sidebar.classList.add('scrollbar-hidden'); } const googleLogo = document.querySelector('#logo'); if (googleLogo) { if (currentSettings.hideGoogleLogoWhenExpanded && !currentSettings.sidebarCollapsed) { googleLogo.style.visibility = 'hidden'; } else { googleLogo.style.visibility = 'visible'; } } ResultStatsManager.toggle(currentSettings.showResultStats); } /** * Parses a time filter value (e.g., 'h', 'd2', 'w') into a comparable number of minutes. * This is used for sorting time filter options logically. * @private * @param {string} timeValue - The time value string. * @returns {number} The equivalent number of minutes, or Infinity for invalid/any-time values. */ function _parseTimeValueToMinutes(timeValue) { if (!timeValue || typeof timeValue !== 'string') return Infinity; const match = timeValue.match(/^([hdwmy])(\d*)$/i); if (!match) return Infinity; const unit = match[1].toLowerCase(); const number = parseInt(match[2] || '1', 10); if (isNaN(number)) return Infinity; switch (unit) { case 'h': return number * 60; case 'd': return number * 24 * 60; case 'w': return number * 7 * 24 * 60; case 'm': return number * 30 * 24 * 60; case 'y': return number * 365 * 24 * 60; default: return Infinity; } } /** * Prepares and sorts the final list of filter options to be displayed in a section. * It combines script-defined options, predefined user-enabled options, and custom user-added options. * @private * @param {string} sectionId - The ID of the section being prepared. * @param {Object[]} scriptDefinedOptions - Options hardcoded in the script (e.g., "Any Time"). * @param {Object} currentSettings - The current settings object. * @param {Object} predefinedOptionsSource - A reference to the PREDEFINED_OPTIONS object. * @returns {Object[]} The final, sorted array of option objects to be rendered. */ function _prepareFilterOptions(sectionId, scriptDefinedOptions, currentSettings, predefinedOptionsSource) { const finalOptions = []; const tempAddedValues = new Set(); const sectionDef = ALL_SECTION_DEFINITIONS.find(s => s.id === sectionId); if (!sectionDef) return []; const isSortableMixedType = sectionDef.displayItemsKey && Array.isArray(currentSettings[sectionDef.displayItemsKey]); const isFiletypeCheckboxModeActive = sectionId === 'sidebar-section-filetype' && currentSettings.enableFiletypeCheckboxMode; const isSiteCheckboxModeActive = sectionId === 'sidebar-section-site-search' && currentSettings.enableSiteSearchCheckboxMode; const isOccurrenceSection = sectionId === 'sidebar-section-occurrence'; if (scriptDefinedOptions) { scriptDefinedOptions.forEach(opt => { if (opt && typeof opt.textKey === 'string' && typeof opt.v === 'string') { if (isOccurrenceSection) { const translatedText = _(opt.textKey); finalOptions.push({ text: translatedText, value: opt.v, originalText: translatedText, isCustom: false, isAnyOption: (opt.v === '') }); tempAddedValues.add(opt.v); } else if (opt.v === '') { if (!((isFiletypeCheckboxModeActive && sectionId === 'sidebar-section-filetype') || (isSiteCheckboxModeActive && sectionId === 'sidebar-section-site-search'))) { const translatedText = (sectionId === 'sidebar-section-site-search') ? _('filter_any_site') : _(opt.textKey); finalOptions.push({ text: translatedText, value: opt.v, originalText: translatedText, isCustom: false, isAnyOption: true }); tempAddedValues.add(opt.v); } } } }); } if (isOccurrenceSection) return finalOptions; if (isSortableMixedType) { const displayItems = currentSettings[sectionDef.displayItemsKey] || []; displayItems.forEach(item => { if (!tempAddedValues.has(item.value)) { let displayText = item.text; if (item.type === 'predefined' && item.originalKey) { displayText = _(item.originalKey); if (sectionId === 'sidebar-section-country') { const parsed = Utils.parseIconAndText(displayText); displayText = `${parsed.icon} ${parsed.text}`.trim(); } } finalOptions.push({ text: displayText, value: item.value, originalText: displayText, isCustom: item.type === 'custom' }); tempAddedValues.add(item.value); } }); } else { const predefinedKey = sectionDef.predefinedOptionsKey; const customKey = sectionDef.customItemsKey; const predefinedOptsFromSource = predefinedOptionsSource && predefinedKey ? (predefinedOptionsSource[predefinedKey] || []) : []; const customOptsFromSettings = customKey ? (currentSettings[customKey] || []) : []; let enabledPredefinedSystemVals; if (isFiletypeCheckboxModeActive && sectionId === 'sidebar-section-filetype') { enabledPredefinedSystemVals = currentSettings.enabledPredefinedOptions[predefinedKey] || []; } else { enabledPredefinedSystemVals = predefinedKey ? (currentSettings.enabledPredefinedOptions[predefinedKey] || []) : []; } const itemsForThisSection = []; const enabledSet = new Set(enabledPredefinedSystemVals); if (Array.isArray(predefinedOptsFromSource)) { predefinedOptsFromSource.forEach(opt => { if (opt && typeof opt.textKey === 'string' && typeof opt.value === 'string' && enabledSet.has(opt.value) && !tempAddedValues.has(opt.value)) { const translatedText = _(opt.textKey); itemsForThisSection.push({ text: translatedText, value: opt.value, originalText: translatedText, isCustom: false }); } }); } const validCustomOptions = Array.isArray(customOptsFromSettings) ? customOptsFromSettings.filter(cOpt => cOpt && typeof cOpt.text === 'string' && typeof cOpt.value === 'string') : []; validCustomOptions.forEach(opt => { if (!tempAddedValues.has(opt.value)){ itemsForThisSection.push({ text: opt.text, value: opt.value, originalText: opt.text, isCustom: true }); } }); itemsForThisSection.forEach(opt => { if (!tempAddedValues.has(opt.value)){ finalOptions.push(opt); tempAddedValues.add(opt.value); } }); } if (scriptDefinedOptions && !isOccurrenceSection) { scriptDefinedOptions.forEach(opt => { if (opt && typeof opt.textKey === 'string' && typeof opt.v === 'string' && opt.v !== '' && !tempAddedValues.has(opt.v)) { const translatedText = _(opt.textKey); finalOptions.push({ text: translatedText, value: opt.v, originalText: translatedText, isCustom: false }); tempAddedValues.add(opt.v); } }); } if (!isSortableMixedType && !isOccurrenceSection) { let anyOptionToSortSeparately = null; const anyOptionIdx = finalOptions.findIndex(opt => opt.isAnyOption === true); if (anyOptionIdx !== -1) { anyOptionToSortSeparately = finalOptions.splice(anyOptionIdx, 1)[0]; } finalOptions.sort((a, b) => { const isTimeSection = (sectionId === 'sidebar-section-time'); if (isTimeSection) { const timeA = _parseTimeValueToMinutes(a.value); const timeB = _parseTimeValueToMinutes(b.value); if (timeA !== Infinity || timeB !== Infinity) { if (timeA !== timeB) return timeA - timeB; } } const sTA = a.originalText || a.text; const sTB = b.originalText || b.text; const sL = LocalizationService.getCurrentLocale() === 'en' ? undefined : LocalizationService.getCurrentLocale(); return sTA.localeCompare(sTB, sL, { numeric: true, sensitivity: 'base' }); }); if (anyOptionToSortSeparately) { finalOptions.unshift(anyOptionToSortSeparately); } } return finalOptions; } /** * Creates a DOM element for a single filter option. * @private * @param {Object} optionData - The data for the option. * @param {string} filterParam - The URL parameter this option controls. * @param {boolean} isCountrySection - Flag indicating if this is for the country section (for special icon handling). * @param {string} countryDisplayMode - The current display mode for countries. * @returns {HTMLElement} The created option element. */ function _createFilterOptionElement(optionData, filterParam, isCountrySection, countryDisplayMode) { const optionElement = document.createElement('div'); optionElement.classList.add(CSS.FILTER_OPTION); const displayText = optionData.text; if (isCountrySection) { const { icon, text: countryTextOnly } = Utils.parseIconAndText(displayText); switch (countryDisplayMode) { case 'textOnly': optionElement.textContent = countryTextOnly || displayText; break; case 'iconOnly': if (icon) { optionElement.innerHTML = `<span class="country-icon-container">${icon}</span>`; } else { optionElement.textContent = countryTextOnly || displayText; } break; case 'iconAndText': default: if (icon) { const textPart = countryTextOnly || displayText.substring(icon.length).trim(); optionElement.innerHTML = `<span class="country-icon-container">${icon}</span>${textPart}`; } else { optionElement.textContent = displayText; } break; } } else { optionElement.textContent = displayText; } optionElement.title = `${displayText} (${filterParam}=${optionData.value || _('filter_clear_tooltip_suffix')})`; optionElement.dataset[DATA_ATTR.FILTER_TYPE] = filterParam; optionElement.dataset[DATA_ATTR.FILTER_VALUE] = optionData.value; return optionElement; } /** * The main function for building or rebuilding the entire sidebar UI based on current settings. * It clears the existing content and dynamically creates all visible sections and controls in the correct order. */ function buildSidebarUI() { if (!sidebar) { console.error("Sidebar element not ready for buildSidebarUI"); return; } const currentSettings = SettingsManager.getCurrentSettings(); const header = sidebar.querySelector(`.${CSS.SIDEBAR_HEADER}`); if (!header) { console.error("Sidebar header not found in buildSidebarUI"); return; } sidebar.querySelectorAll(`#${IDS.FIXED_TOP_BUTTONS}, .${CSS.SIDEBAR_CONTENT_WRAPPER}`).forEach(el => el.remove()); header.querySelectorAll(`.${CSS.HEADER_BUTTON}:not(#${IDS.SETTINGS_BUTTON}):not(#${IDS.COLLAPSE_BUTTON}), a.${CSS.HEADER_BUTTON}`).forEach(el => el.remove()); const rBL = currentSettings.resetButtonLocation; const vBL = currentSettings.verbatimButtonLocation; const aSL = currentSettings.advancedSearchLinkLocation; const pznBL = currentSettings.personalizationButtonLocation; const schL = currentSettings.googleScholarShortcutLocation; const trnL = currentSettings.googleTrendsShortcutLocation; const dsL = currentSettings.googleDatasetSearchShortcutLocation; const settingsButtonRef = header.querySelector(`#${IDS.SETTINGS_BUTTON}`); _buildSidebarHeaderControls(header, settingsButtonRef, rBL, vBL, aSL, pznBL, schL, trnL, dsL, currentSettings); const fixedTopControlsContainer = _buildSidebarFixedTopControls(rBL, vBL, aSL, pznBL, schL, trnL, dsL, currentSettings); if (fixedTopControlsContainer) { header.after(fixedTopControlsContainer); } const contentWrapper = document.createElement('div'); contentWrapper.classList.add(CSS.SIDEBAR_CONTENT_WRAPPER); const sectionDefinitionsMap = new Map(ALL_SECTION_DEFINITIONS.map(def => [def.id, def])); const sectionsFragment = _buildSidebarSections(sectionDefinitionsMap, rBL, vBL, aSL, pznBL, schL, trnL, dsL, currentSettings, PREDEFINED_OPTIONS); contentWrapper.appendChild(sectionsFragment); sidebar.appendChild(contentWrapper); _initializeSidebarEventListenersAndStates(); } /** * Builds all the individual section elements for the sidebar. * @private * @returns {DocumentFragment} A document fragment containing all the generated section elements. */ function _buildSidebarSections(sectionDefinitionMap, rBL, vBL, aSL, pznBL, schL, trnL, dsL, currentSettings, PREDEFINED_OPTIONS_REF) { const contentFragment = document.createDocumentFragment(); currentSettings.sidebarSectionOrder.forEach(sectionId => { if (!currentSettings.visibleSections[sectionId]) return; const sectionData = sectionDefinitionMap.get(sectionId); if (!sectionData) { console.warn(`${LOG_PREFIX} No definition for section ID: ${sectionId}`); return; } let sectionElement = null; const sectionTitleKey = sectionData.titleKey; const sectionIdForDisplay = sectionData.id; switch (sectionData.type) { case 'filter': sectionElement = createFilterSection(sectionIdForDisplay, sectionTitleKey, sectionData.scriptDefined, sectionData.param, currentSettings, PREDEFINED_OPTIONS_REF, currentSettings.countryDisplayMode); break; case 'filetype': sectionElement = _createFiletypeSectionElement(sectionIdForDisplay, sectionTitleKey, sectionData.scriptDefined, sectionData.param, currentSettings, PREDEFINED_OPTIONS_REF); break; case 'date': sectionElement = _createDateSectionElement(sectionIdForDisplay, sectionTitleKey); break; case 'site': sectionElement = _createSiteSearchSectionElement(sectionIdForDisplay, sectionTitleKey, currentSettings); break; case 'tools': sectionElement = _createToolsSectionElement( sectionIdForDisplay, sectionTitleKey, rBL, vBL, aSL, pznBL, schL, trnL, dsL ); break; default: console.warn(`${LOG_PREFIX} Unknown section type: ${sectionData.type} for ID: ${sectionIdForDisplay}`); break; } if (sectionElement) contentFragment.appendChild(sectionElement); }); return contentFragment; } /** * Creates a filter section element with its options. * @param {string} id - The section's ID. * @param {string} titleKey - The localization key for the section title. * @param {Object[]} scriptDefinedOptions - Hardcoded options for this section. * @param {string} filterParam - The URL parameter this section controls. * @param {Object} currentSettings - The current settings object. * @param {Object} predefinedOptionsSource - The source of predefined options. * @param {string} countryDisplayMode - The display mode for countries. * @returns {HTMLElement|null} The created section element. */ function createFilterSection(id, titleKey, scriptDefinedOptions, filterParam, currentSettings, predefinedOptionsSource, countryDisplayMode) { if (!sidebar) return null; const { section, sectionContent, sectionTitle } = _createSectionShell(id, titleKey); sectionTitle.textContent = _(titleKey); const fragment = document.createDocumentFragment(); const isCountrySection = (id === 'sidebar-section-country'); const combinedOptions = _prepareFilterOptions(id, scriptDefinedOptions, currentSettings, predefinedOptionsSource); combinedOptions.forEach(option => { fragment.appendChild(_createFilterOptionElement(option, filterParam, isCountrySection, countryDisplayMode)); }); sectionContent.innerHTML = ''; sectionContent.appendChild(fragment); if (!sectionContent.dataset.filterClickListenerAttached) { sectionContent.addEventListener('click', function(event) { const target = event.target.closest(`.${CSS.FILTER_OPTION}`); if (target && target.classList.contains(CSS.FILTER_OPTION)) { event.preventDefault(); const clickedFilterType = target.dataset[DATA_ATTR.FILTER_TYPE]; const clickedFilterValue = target.dataset[DATA_ATTR.FILTER_VALUE]; if (typeof clickedFilterType !== 'undefined' && typeof clickedFilterValue !== 'undefined') { this.querySelectorAll(`.${CSS.FILTER_OPTION}`).forEach(opt => opt.classList.remove(CSS.IS_SELECTED)); target.classList.add(CSS.IS_SELECTED); if (clickedFilterValue === '' || (clickedFilterType === 'as_occt' && clickedFilterValue === 'any') ) { const defaultVal = (clickedFilterType === 'as_occt') ? 'any' : ''; const anyOpt = this.querySelector(`.${CSS.FILTER_OPTION}[data-${DATA_ATTR.FILTER_VALUE}="${defaultVal}"]`); if (anyOpt) anyOpt.classList.add(CSS.IS_SELECTED); } URLActionManager.applyFilter(clickedFilterType, clickedFilterValue); } } }); sectionContent.dataset.filterClickListenerAttached = 'true'; } return section; } /** * Creates the "Site Search" section element. * @private * @param {string} sectionId - The section's ID. * @param {string} titleKey - The localization key for the section title. * @param {Object} currentSettings - The current settings object. * @returns {HTMLElement} The created section element. */ function _createSiteSearchSectionElement(sectionId, titleKey, currentSettings) { const { section, sectionContent, sectionTitle } = _createSectionShell(sectionId, titleKey); sectionTitle.textContent = _(titleKey); populateSiteSearchList(sectionContent, currentSettings.favoriteSites, currentSettings.enableSiteSearchCheckboxMode, currentSettings.showFaviconsForSiteSearch); return section; } /** * Populates the content of the "Site Search" section. This function can be called * to rebuild the list when settings change. * @param {HTMLElement} sectionContentElement - The content element of the site search section. * @param {Object[]} favoriteSitesArray - The array of favorite sites from settings. * @param {boolean} checkboxModeEnabled - Whether checkbox mode is active. * @param {boolean} showFaviconsEnabled - Whether to show favicons. */ function populateSiteSearchList(sectionContentElement, favoriteSitesArray, checkboxModeEnabled, showFaviconsEnabled) { if (!sectionContentElement) { console.error("Site search section content element missing"); return; } sectionContentElement.innerHTML = ''; const sites = Array.isArray(favoriteSitesArray) ? favoriteSitesArray : []; const listFragment = document.createDocumentFragment(); const clearOptDiv = document.createElement('div'); clearOptDiv.classList.add(CSS.FILTER_OPTION); clearOptDiv.id = IDS.CLEAR_SITE_SEARCH_OPTION; clearOptDiv.title = _('tooltip_clear_site_search'); clearOptDiv.textContent = _('filter_any_site'); clearOptDiv.dataset[DATA_ATTR.FILTER_TYPE] = 'site_clear'; sectionContentElement.appendChild(clearOptDiv); const listElement = document.createElement('ul'); listElement.classList.add(CSS.CUSTOM_LIST); if (checkboxModeEnabled) { listElement.classList.add('checkbox-mode-enabled'); } sites.forEach((site, index) => { if (site?.text && site?.url) { const li = document.createElement('li'); const siteValue = site.url; const isGroup = siteValue.includes(' OR '); if (checkboxModeEnabled) { const uniqueId = `site-cb-${index}-${Date.now()}`; const checkbox = document.createElement('input'); checkbox.id = uniqueId; checkbox.type = 'checkbox'; checkbox.value = siteValue; checkbox.classList.add(CSS.CHECKBOX_SITE); checkbox.dataset[DATA_ATTR.SITE_URL] = siteValue; li.appendChild(checkbox); const label = document.createElement('label'); label.htmlFor = uniqueId; if (showFaviconsEnabled && !isGroup) { const favicon = document.createElement('img'); favicon.src = `https://www.google.com/s2/favicons?sz=32&domain_url=${siteValue}`; favicon.classList.add(CSS.FAVICON); favicon.loading = 'lazy'; label.appendChild(favicon); } label.appendChild(document.createTextNode(site.text)); label.dataset[DATA_ATTR.SITE_URL] = siteValue; label.title = _('tooltip_site_search', { siteUrl: siteValue.replace(/\s+OR\s+/gi, ', ') }); li.appendChild(label); } else { const divOpt = document.createElement('div'); divOpt.classList.add(CSS.FILTER_OPTION); if (showFaviconsEnabled && !isGroup) { const favicon = document.createElement('img'); favicon.src = `https://www.google.com/s2/favicons?sz=32&domain_url=${siteValue}`; favicon.classList.add(CSS.FAVICON); favicon.loading = 'lazy'; divOpt.appendChild(favicon); } divOpt.appendChild(document.createTextNode(site.text)); divOpt.dataset[DATA_ATTR.SITE_URL] = siteValue; divOpt.title = _('tooltip_site_search', { siteUrl: siteValue.replace(/\s+OR\s+/gi, ', ') }); li.appendChild(divOpt); } listFragment.appendChild(li); } }); listElement.appendChild(listFragment); sectionContentElement.appendChild(listElement); if (checkboxModeEnabled) { let applyButton = sectionContentElement.querySelector(`#${IDS.APPLY_SELECTED_SITES_BUTTON}`); if (!applyButton) { applyButton = document.createElement('button'); applyButton.id = IDS.APPLY_SELECTED_SITES_BUTTON; applyButton.classList.add(CSS.BUTTON, CSS.BUTTON_APPLY_SITES); applyButton.textContent = _('tool_apply_selected_sites'); sectionContentElement.appendChild(applyButton); } applyButton.disabled = true; applyButton.style.display = 'none'; } // Use event delegation for site search interactions. if (!sectionContentElement.dataset.siteSearchListenerAttached) { sectionContentElement.dataset.siteSearchListenerAttached = 'true'; sectionContentElement.addEventListener('click', (event) => { const target = event.target; const currentSettings = SettingsManager.getCurrentSettings(); const isCheckboxMode = currentSettings.enableSiteSearchCheckboxMode; const clearSiteOpt = target.closest(`#${IDS.CLEAR_SITE_SEARCH_OPTION}`); if (clearSiteOpt) { URLActionManager.clearSiteSearch(); sectionContentElement.querySelectorAll(`.${CSS.FILTER_OPTION}.${CSS.IS_SELECTED}, label.${CSS.IS_SELECTED}`).forEach(o => o.classList.remove(CSS.IS_SELECTED)); clearSiteOpt.classList.add(CSS.IS_SELECTED); if (isCheckboxMode) { sectionContentElement.querySelectorAll(`input[type="checkbox"].${CSS.CHECKBOX_SITE}`).forEach(cb => cb.checked = false); _updateApplySitesButtonState(sectionContentElement); } } else if (isCheckboxMode) { const labelElement = target.closest('label'); if (labelElement && labelElement.dataset[DATA_ATTR.SITE_URL]) { event.preventDefault(); const siteUrlOrCombined = labelElement.dataset[DATA_ATTR.SITE_URL]; sectionContentElement.querySelectorAll(`input[type="checkbox"].${CSS.CHECKBOX_SITE}`).forEach(cb => { const correspondingLabel = sectionContentElement.querySelector(`label[for="${cb.id}"]`); cb.checked = (cb.value === siteUrlOrCombined); if(correspondingLabel) correspondingLabel.classList.toggle(CSS.IS_SELECTED, cb.checked); }); URLActionManager.applySiteSearch(siteUrlOrCombined); _updateApplySitesButtonState(sectionContentElement); sectionContentElement.querySelector(`#${IDS.CLEAR_SITE_SEARCH_OPTION}`)?.classList.remove(CSS.IS_SELECTED); } } else { const siteOptionDiv = target.closest(`div.${CSS.FILTER_OPTION}:not(#${IDS.CLEAR_SITE_SEARCH_OPTION})`); if (siteOptionDiv && siteOptionDiv.dataset[DATA_ATTR.SITE_URL]) { const siteUrlOrCombined = siteOptionDiv.dataset[DATA_ATTR.SITE_URL]; sectionContentElement.querySelectorAll(`.${CSS.FILTER_OPTION}.${CSS.IS_SELECTED}`).forEach(o => o.classList.remove(CSS.IS_SELECTED)); URLActionManager.applySiteSearch(siteUrlOrCombined); siteOptionDiv.classList.add(CSS.IS_SELECTED); sectionContentElement.querySelector(`#${IDS.CLEAR_SITE_SEARCH_OPTION}`)?.classList.remove(CSS.IS_SELECTED); } } }); sectionContentElement.addEventListener('change', (event) => { if (event.target.matches(`input[type="checkbox"].${CSS.CHECKBOX_SITE}`)) { _updateApplySitesButtonState(sectionContentElement); const label = sectionContentElement.querySelector(`label[for="${event.target.id}"]`); if (label) label.classList.toggle(CSS.IS_SELECTED, event.target.checked); if (event.target.checked) { sectionContentElement.querySelector(`#${IDS.CLEAR_SITE_SEARCH_OPTION}`)?.classList.remove(CSS.IS_SELECTED); } } }); const applyBtn = sectionContentElement.querySelector(`#${IDS.APPLY_SELECTED_SITES_BUTTON}`); if(applyBtn && !applyBtn.dataset[DATA_ATTR.LISTENER_ATTACHED]){ applyBtn.dataset[DATA_ATTR.LISTENER_ATTACHED] = 'true'; applyBtn.addEventListener('click', () => { const selectedValues = Array.from(sectionContentElement.querySelectorAll(`input[type="checkbox"].${CSS.CHECKBOX_SITE}:checked`)).map(cb => cb.value); if (selectedValues.length > 0) { URLActionManager.applySiteSearch(selectedValues); sectionContentElement.querySelector(`#${IDS.CLEAR_SITE_SEARCH_OPTION}`)?.classList.remove(CSS.IS_SELECTED); } }); } } } /** * Creates the "File Type" section element. * @private * @param {string} sectionId - The section's ID. * @param {string} titleKey - The localization key for the section title. * @param {Object[]} scriptDefinedOptions - Hardcoded options for this section. * @param {string} filterParam - The URL parameter this section controls. * @param {Object} currentSettings - The current settings object. * @param {Object} predefinedOptionsSource - The source of predefined options. * @returns {HTMLElement} The created section element. */ function _createFiletypeSectionElement(sectionId, titleKey, scriptDefinedOptions, filterParam, currentSettings, predefinedOptionsSource) { const { section, sectionContent, sectionTitle } = _createSectionShell(sectionId, titleKey); sectionTitle.textContent = _(titleKey); populateFiletypeList(sectionContent, scriptDefinedOptions, currentSettings, predefinedOptionsSource, filterParam); return section; } /** * Populates the content of the "File Type" section. * @param {HTMLElement} sectionContentElement - The content element of the file type section. * @param {Object[]} scriptDefinedOpts - Hardcoded options for this section. * @param {Object} currentSettings - The current settings object. * @param {Object} predefinedOptsSource - The source of predefined options. * @param {string} filterParam - The URL parameter this section controls. */ function populateFiletypeList(sectionContentElement, scriptDefinedOpts, currentSettings, predefinedOptsSource, filterParam) { if (!sectionContentElement) { console.error("Filetype section content element missing"); return; } sectionContentElement.innerHTML = ''; const checkboxModeEnabled = currentSettings.enableFiletypeCheckboxMode; const combinedOptions = _prepareFilterOptions('sidebar-section-filetype', scriptDefinedOpts, currentSettings, predefinedOptsSource); const listFragment = document.createDocumentFragment(); const clearOptDiv = document.createElement('div'); clearOptDiv.classList.add(CSS.FILTER_OPTION); clearOptDiv.id = IDS.CLEAR_FILETYPE_SEARCH_OPTION; clearOptDiv.title = _('filter_clear_tooltip_suffix'); clearOptDiv.textContent = _('filter_any_format'); clearOptDiv.dataset[DATA_ATTR.FILTER_TYPE] = 'filetype_clear'; sectionContentElement.appendChild(clearOptDiv); const listElement = document.createElement('ul'); listElement.classList.add(CSS.CUSTOM_LIST); if (checkboxModeEnabled) { listElement.classList.add('checkbox-mode-enabled'); } combinedOptions.forEach((option, index) => { if (option.isAnyOption) return; const li = document.createElement('li'); const filetypeValue = option.value; if (checkboxModeEnabled) { const checkbox = document.createElement('input'); const uniqueId = `ft-cb-${index}-${Date.now()}`; checkbox.type = 'checkbox'; checkbox.id = uniqueId; checkbox.value = filetypeValue; checkbox.classList.add(CSS.CHECKBOX_FILETYPE); checkbox.dataset[DATA_ATTR.FILETYPE_VALUE] = filetypeValue; li.appendChild(checkbox); const label = document.createElement('label'); label.htmlFor = checkbox.id; label.dataset[DATA_ATTR.FILETYPE_VALUE] = filetypeValue; label.title = `${option.text} (${filterParam}=${filetypeValue.replace(/\s+OR\s+/gi, ', ')})`; label.textContent = option.text; li.appendChild(label); } else { const divOpt = document.createElement('div'); divOpt.classList.add(CSS.FILTER_OPTION); divOpt.dataset[DATA_ATTR.FILTER_TYPE] = filterParam; divOpt.dataset[DATA_ATTR.FILTER_VALUE] = filetypeValue; divOpt.title = `${option.text} (${filterParam}=${filetypeValue.replace(/\s+OR\s+/gi, ', ')})`; divOpt.textContent = option.text; li.appendChild(divOpt); } listFragment.appendChild(li); }); listElement.appendChild(listFragment); sectionContentElement.appendChild(listElement); if (checkboxModeEnabled) { let applyButton = sectionContentElement.querySelector(`#${IDS.APPLY_SELECTED_FILETYPES_BUTTON}`); if(!applyButton) { applyButton = document.createElement('button'); applyButton.id = IDS.APPLY_SELECTED_FILETYPES_BUTTON; applyButton.classList.add(CSS.BUTTON, CSS.BUTTON_APPLY_FILETYPES); applyButton.textContent = _('tool_apply_selected_filetypes'); sectionContentElement.appendChild(applyButton); } applyButton.disabled = true; applyButton.style.display = 'none'; } // Use event delegation for filetype search interactions. if (!sectionContentElement.dataset.filetypeClickListenerAttached) { sectionContentElement.dataset.filetypeClickListenerAttached = 'true'; sectionContentElement.addEventListener('click', (event) => { const target = event.target; const isCheckboxMode = SettingsManager.getCurrentSettings().enableFiletypeCheckboxMode; const clearFiletypeOpt = target.closest(`#${IDS.CLEAR_FILETYPE_SEARCH_OPTION}`); if (clearFiletypeOpt) { URLActionManager.clearFiletypeSearch(); sectionContentElement.querySelectorAll(`.${CSS.FILTER_OPTION}.${CSS.IS_SELECTED}, label.${CSS.IS_SELECTED}`).forEach(o => o.classList.remove(CSS.IS_SELECTED)); clearFiletypeOpt.classList.add(CSS.IS_SELECTED); if (isCheckboxMode) { sectionContentElement.querySelectorAll(`input[type="checkbox"].${CSS.CHECKBOX_FILETYPE}`).forEach(cb => cb.checked = false); _updateApplyFiletypesButtonState(sectionContentElement); } } else if (isCheckboxMode) { const labelElement = target.closest('label'); if (labelElement && labelElement.dataset[DATA_ATTR.FILETYPE_VALUE]) { event.preventDefault(); const filetypeValueOrCombined = labelElement.dataset[DATA_ATTR.FILETYPE_VALUE]; sectionContentElement.querySelectorAll(`input[type="checkbox"].${CSS.CHECKBOX_FILETYPE}`).forEach(cb => { const correspondingLabel = sectionContentElement.querySelector(`label[for="${cb.id}"]`); cb.checked = (cb.value === filetypeValueOrCombined); if(correspondingLabel) correspondingLabel.classList.toggle(CSS.IS_SELECTED, cb.checked); }); URLActionManager.applyCombinedFiletypeSearch(filetypeValueOrCombined); _updateApplyFiletypesButtonState(sectionContentElement); sectionContentElement.querySelector(`#${IDS.CLEAR_FILETYPE_SEARCH_OPTION}`)?.classList.remove(CSS.IS_SELECTED); } } else { const optionDiv = target.closest(`div.${CSS.FILTER_OPTION}:not(#${IDS.CLEAR_FILETYPE_SEARCH_OPTION})`); if (optionDiv && optionDiv.dataset[DATA_ATTR.FILTER_VALUE]) { const clickedFilterType = optionDiv.dataset[DATA_ATTR.FILTER_TYPE]; const clickedFilterValueOrCombined = optionDiv.dataset[DATA_ATTR.FILTER_VALUE]; sectionContentElement.querySelectorAll(`.${CSS.FILTER_OPTION}.${CSS.IS_SELECTED}`).forEach(o => o.classList.remove(CSS.IS_SELECTED)); optionDiv.classList.add(CSS.IS_SELECTED); URLActionManager.applyFilter(clickedFilterType, clickedFilterValueOrCombined); sectionContentElement.querySelector(`#${IDS.CLEAR_FILETYPE_SEARCH_OPTION}`)?.classList.remove(CSS.IS_SELECTED); } } }); sectionContentElement.addEventListener('change', (event) => { if (event.target.matches(`input[type="checkbox"].${CSS.CHECKBOX_FILETYPE}`)) { _updateApplyFiletypesButtonState(sectionContentElement); const label = sectionContentElement.querySelector(`label[for="${event.target.id}"]`); if (label) label.classList.toggle(CSS.IS_SELECTED, event.target.checked); if (event.target.checked) { sectionContentElement.querySelector(`#${IDS.CLEAR_FILETYPE_SEARCH_OPTION}`)?.classList.remove(CSS.IS_SELECTED); } } }); const applyBtn = sectionContentElement.querySelector(`#${IDS.APPLY_SELECTED_FILETYPES_BUTTON}`); if (applyBtn && !applyBtn.dataset[DATA_ATTR.LISTENER_ATTACHED]) { applyBtn.dataset[DATA_ATTR.LISTENER_ATTACHED] = 'true'; applyBtn.addEventListener('click', () => { const selectedValues = Array.from(sectionContentElement.querySelectorAll(`input[type="checkbox"].${CSS.CHECKBOX_FILETYPE}:checked`)).map(cb => cb.value); if (selectedValues.length > 0) { URLActionManager.applyCombinedFiletypeSearch(selectedValues); sectionContentElement.querySelector(`#${IDS.CLEAR_FILETYPE_SEARCH_OPTION}`)?.classList.remove(CSS.IS_SELECTED); } }); } } } /** * Updates the state (enabled/disabled, visible/hidden) of the "Apply Selected" button * in the site search section based on how many checkboxes are checked. * @private * @param {HTMLElement} sectionContentElement - The content element of the site search section. */ function _updateApplySitesButtonState(sectionContentElement) { if (!sectionContentElement) return; const applyButton = sectionContentElement.querySelector(`#${IDS.APPLY_SELECTED_SITES_BUTTON}`); if (!applyButton) return; const checkedCount = sectionContentElement.querySelectorAll(`input[type="checkbox"].${CSS.CHECKBOX_SITE}:checked`).length; applyButton.disabled = checkedCount === 0; applyButton.style.display = checkedCount > 0 ? 'inline-flex' : 'none'; } /** * Updates the state of the "Apply Selected" button in the file type section. * @private * @param {HTMLElement} sectionContentElement - The content element of the file type section. */ function _updateApplyFiletypesButtonState(sectionContentElement) { if (!sectionContentElement) return; const applyButton = sectionContentElement.querySelector(`#${IDS.APPLY_SELECTED_FILETYPES_BUTTON}`); if (!applyButton) return; const checkedCount = sectionContentElement.querySelectorAll(`input[type="checkbox"].${CSS.CHECKBOX_FILETYPE}:checked`).length; applyButton.disabled = checkedCount === 0; applyButton.style.display = checkedCount > 0 ? 'inline-flex' : 'none'; } /** * Renders the draggable list of sections in the "Features" tab of the settings window. * @param {Object} settingsRef - A reference to the current settings object. */ function renderSectionOrderList(settingsRef) { const settingsWindowEl = document.getElementById(IDS.SETTINGS_WINDOW); const orderListElement = settingsWindowEl?.querySelector(`#${IDS.SIDEBAR_SECTION_ORDER_LIST}`); if (!orderListElement) return; orderListElement.innerHTML = ''; const currentSettings = settingsRef || SettingsManager.getCurrentSettings(); const visibleOrderedSections = currentSettings.sidebarSectionOrder.filter(id => currentSettings.visibleSections[id]); if (visibleOrderedSections.length === 0) { orderListElement.innerHTML = `<li><span style="font-style:italic;color:var(--settings-tab-color);">${_('settings_no_orderable_sections')}</span></li>`; return; } const fragment = document.createDocumentFragment(); visibleOrderedSections.forEach((sectionId) => { const definition = ALL_SECTION_DEFINITIONS.find(def => def.id === sectionId); const displayName = definition ? _(definition.titleKey) : sectionId; const listItem = document.createElement('li'); listItem.dataset.sectionId = sectionId; listItem.draggable = true; const dragIconSpan = document.createElement('span'); dragIconSpan.classList.add(CSS.DRAG_ICON); dragIconSpan.innerHTML = SVG_ICONS.dragGrip; listItem.appendChild(dragIconSpan); const nameSpan = document.createElement('span'); nameSpan.textContent = displayName; listItem.appendChild(nameSpan); fragment.appendChild(listItem); }); orderListElement.appendChild(fragment); } /** * Initializes the script's menu commands using `GM_registerMenuCommand`. * This adds "Open Settings" and "Reset All Settings" options to the Tampermonkey menu. */ function _initMenuCommands() { if (typeof GM_registerMenuCommand === 'function') { const openSettingsText = _('menu_open_settings'); const resetAllText = _('menu_reset_all_settings'); if (typeof GM_unregisterMenuCommand === 'function') { try { GM_unregisterMenuCommand(openSettingsText); } catch (e) {} try { GM_unregisterMenuCommand(resetAllText); } catch (e) {} } GM_registerMenuCommand(openSettingsText, SettingsManager.show.bind(SettingsManager)); GM_registerMenuCommand(resetAllText, SettingsManager.resetAllFromMenu.bind(SettingsManager)); } } /** * Creates the basic shell for a sidebar section (the section container, title, and content elements). * @private * @param {string} id - The ID for the main section element. * @param {string} titleKey - The localization key for the section's title. * @returns {{section: HTMLElement, sectionContent: HTMLElement, sectionTitle: HTMLElement}} An object containing the created elements. */ function _createSectionShell(id, titleKey) { const section = document.createElement('div'); section.id = id; section.classList.add(CSS.SECTION); const sectionTitle = document.createElement('div'); sectionTitle.classList.add(CSS.SECTION_TITLE); sectionTitle.textContent = _(titleKey); section.appendChild(sectionTitle); const sectionContent = document.createElement('div'); sectionContent.classList.add(CSS.SECTION_CONTENT); section.appendChild(sectionContent); return { section, sectionContent, sectionTitle }; } /** * Creates the "Date Range" section element. * @private * @param {string} sectionId - The section's ID. * @param {string} titleKey - The localization key for the section title. * @returns {HTMLElement} The created section element. */ function _createDateSectionElement(sectionId, titleKey) { const { section, sectionContent, sectionTitle } = _createSectionShell(sectionId, titleKey); sectionTitle.textContent = _(titleKey); const today = new Date(); const yyyy = today.getFullYear(); const mm = String(today.getMonth() + 1).padStart(2, '0'); const dd = String(today.getDate()).padStart(2, '0'); const todayString = `${yyyy}-${mm}-${dd}`; sectionContent.innerHTML = `<label class="${CSS.DATE_INPUT_LABEL}" for="${IDS.DATE_MIN}">${_('date_range_from')}</label><input type="date" class="${CSS.DATE_INPUT_FIELD}" id="${IDS.DATE_MIN}" max="${todayString}"><label class="${CSS.DATE_INPUT_LABEL}" for="${IDS.DATE_MAX}">${_('date_range_to')}</label><input type="date" class="${CSS.DATE_INPUT_FIELD}" id="${IDS.DATE_MAX}" max="${todayString}"><span id="${IDS.DATE_RANGE_ERROR_MSG}" class="${CSS.DATE_RANGE_ERROR_MSG} ${CSS.INPUT_ERROR_MSG}"></span><button class="${CSS.BUTTON} apply-date-range">${_('tool_apply_date')}</button>`; return section; } /** * A factory function to create a standard button element used throughout the sidebar. * @private * @param {Object} options - The button's configuration. * @param {string|null} [options.id=null] - The ID for the button. * @param {string} options.className - The CSS class for the button. * @param {string} options.svgIcon - The SVG icon string. * @param {string|null} [options.textContent=null] - The text content for the button. * @param {string} options.title - The title attribute (tooltip) for the button. * @param {Function} options.clickHandler - The click event handler. * @param {boolean} [options.isActive=false] - Whether the button should be in an active state. * @returns {HTMLButtonElement} The created button element. */ function _createStandardButton({ id = null, className, svgIcon, textContent = null, title, clickHandler, isActive = false }) { const button = document.createElement('button'); if (id) button.id = id; button.classList.add(className); if (isActive) button.classList.add(CSS.IS_ACTIVE); button.title = title; let content = svgIcon || ''; if (textContent) { content = svgIcon ? `${svgIcon} <span>${textContent}</span>` : `<span>${textContent}</span>`; } button.innerHTML = content.trim(); if (clickHandler) { if (!button.dataset[DATA_ATTR.LISTENER_ATTACHED]) { button.addEventListener('click', clickHandler); button.dataset[DATA_ATTR.LISTENER_ATTACHED] = 'true'; } } return button; } /** * Creates the Personalization toggle button. * @private * @param {('tools'|'header'|'topBlock')} [forLocation='tools'] - The location where the button will be placed. * @returns {HTMLButtonElement} The created button element. */ function _createPersonalizationButtonHTML(forLocation = 'tools') { const personalizationActive = URLActionManager.isPersonalizationActive(); const isIconOnlyLocation = (forLocation === 'header'); const svgIcon = SVG_ICONS.personalization || ''; const displayText = !isIconOnlyLocation ? _('tool_personalization_toggle') : ''; const titleKey = personalizationActive ? 'tooltip_toggle_personalization_off' : 'tooltip_toggle_personalization_on'; return _createStandardButton({ id: IDS.TOOL_PERSONALIZE, className: (forLocation === 'header') ? CSS.HEADER_BUTTON : CSS.BUTTON, svgIcon: svgIcon, textContent: displayText, title: _(titleKey), clickHandler: () => URLActionManager.triggerTogglePersonalization(), isActive: personalizationActive }); } /** * Creates the "Advanced Search" link element. * @private * @param {boolean} [isButtonLike=false] - If true, styles the link to look like a button. * @returns {HTMLAnchorElement} The created anchor element. */ function _createAdvancedSearchElementHTML(isButtonLike = false) { const el = document.createElement('a'); let iconHTML = SVG_ICONS.magnifyingGlass || ''; if (isButtonLike) { el.classList.add(CSS.BUTTON); el.innerHTML = `${iconHTML} <span>${_('tool_advanced_search')}</span>`; } else { el.classList.add(CSS.HEADER_BUTTON); el.innerHTML = iconHTML; } const baseUrl = "https://www.google.com/advanced_search"; let finalUrl = baseUrl; try { const currentFullUrl = Utils.getCurrentURL(); if (currentFullUrl) { const currentQuery = currentFullUrl.searchParams.get('q'); if (currentQuery) { let queryWithoutSite = Utils._cleanQueryByOperator(currentQuery, 'site'); queryWithoutSite = Utils._cleanQueryByOperator(queryWithoutSite, 'filetype'); if (queryWithoutSite) { finalUrl = `${baseUrl}?as_q=${encodeURIComponent(queryWithoutSite)}`; } } } } catch (e) { console.warn(`${LOG_PREFIX} Error constructing advanced search URL with query:`, e); } el.href = finalUrl; el.target = "_blank"; el.rel = "noopener noreferrer"; el.title = _('link_advanced_search_title'); return el; } /** * Creates a generic shortcut button for a specified Google service. * @private * @param {string} serviceId - The key for the service in SERVICE_SHORTCUT_CONFIG (e.g., 'googleScholar'). * @param {('tools'|'header'|'topBlock')} [forLocation='tools'] - The location where the button will be placed. * @returns {HTMLButtonElement|null} The created button element, or null if config is not found. */ function _createServiceShortcutButton(serviceId, forLocation = 'tools') { const config = SERVICE_SHORTCUT_CONFIG[serviceId]; if (!config) { console.warn(`${LOG_PREFIX} No configuration found for service shortcut: ${serviceId}`); return null; } const isIconOnlyLocation = (forLocation === 'header'); const displayText = !isIconOnlyLocation ? _(config.textKey) : ''; // The click handler logic is now generic and reusable const clickHandler = () => { try { const currentUrl = Utils.getCurrentURL(); if (currentUrl) { const query = currentUrl.searchParams.get('q'); if (query) { const serviceUrl = `${config.baseUrl}?${config.queryParam}=${encodeURIComponent(query)}`; // Use GM_openInTab for better control, falling back to window.open if (typeof GM_openInTab === 'function') { GM_openInTab(serviceUrl, { active: true, insert: true }); } else { window.open(serviceUrl, '_blank'); } } else { window.open(config.homepage, '_blank'); NotificationManager.show('alert_no_keywords_for_shortcut', { service_name: _(config.serviceNameKey) }, 'info'); } } } catch (e) { console.error(`${LOG_PREFIX} Error opening ${config.serviceNameKey}:`, e); NotificationManager.show('alert_error_opening_link', { service_name: _(config.serviceNameKey) }, 'error'); } }; return _createStandardButton({ id: config.id, className: (forLocation === 'header') ? CSS.HEADER_BUTTON : CSS.BUTTON, svgIcon: config.svgIcon || '', textContent: displayText, title: _(config.titleKey), clickHandler: clickHandler }); } /** * Builds and inserts the control buttons and links into the sidebar header. * @private */ function _buildSidebarHeaderControls(headerEl, settingsBtnRef, rBL, vBL, aSL, pznBL, schL, trnL, dsL, settings) { const verbatimActive = URLActionManager.isVerbatimActive(); const buttonsInOrder = []; if (aSL === 'header' && settings.advancedSearchLinkLocation !== 'none') { buttonsInOrder.push(_createAdvancedSearchElementHTML(false)); } if (schL === 'header' && settings.googleScholarShortcutLocation !== 'none') { buttonsInOrder.push(_createServiceShortcutButton('googleScholar', 'header')); } if (trnL === 'header' && settings.googleTrendsShortcutLocation !== 'none') { buttonsInOrder.push(_createServiceShortcutButton('googleTrends', 'header')); } if (dsL === 'header' && settings.googleDatasetSearchShortcutLocation !== 'none') { buttonsInOrder.push(_createServiceShortcutButton('googleDatasetSearch', 'header')); } if (vBL === 'header' && settings.verbatimButtonLocation !== 'none') { buttonsInOrder.push(_createStandardButton({ id: IDS.TOOL_VERBATIM, className: CSS.HEADER_BUTTON, svgIcon: SVG_ICONS.verbatim, title: _('tool_verbatim_search'), clickHandler: URLActionManager.triggerToggleVerbatim, isActive: verbatimActive })); } if (pznBL === 'header' && settings.personalizationButtonLocation !== 'none') { buttonsInOrder.push(_createPersonalizationButtonHTML('header')); } if (rBL === 'header' && settings.resetButtonLocation !== 'none') { buttonsInOrder.push(_createStandardButton({ id: IDS.TOOL_RESET_BUTTON, className: CSS.HEADER_BUTTON, svgIcon: SVG_ICONS.reset, title: _('tool_reset_filters'), clickHandler: URLActionManager.triggerResetFilters })); } buttonsInOrder.forEach(btn => { if (btn && settingsBtnRef) { headerEl.insertBefore(btn, settingsBtnRef); } else if (btn) { headerEl.appendChild(btn); } }); } /** * Builds the container and controls for the "Top Block" area of the sidebar. * @private * @returns {HTMLElement|null} The created container element, or null if no controls are placed there. */ function _buildSidebarFixedTopControls(rBL, vBL, aSL, pznBL, schL, trnL, dsL, currentSettings) { const fTBC = document.createElement('div'); fTBC.id = IDS.FIXED_TOP_BUTTONS; const fTF = document.createDocumentFragment(); const verbatimActive = URLActionManager.isVerbatimActive(); if (currentSettings.showResultStats) { ResultStatsManager.createContainer(fTF); } if (rBL === 'topBlock' && currentSettings.resetButtonLocation !== 'none') { const btn = _createStandardButton({ id: IDS.TOOL_RESET_BUTTON, className: CSS.BUTTON, svgIcon: SVG_ICONS.reset, textContent: _('tool_reset_filters'), title: _('tool_reset_filters'), clickHandler: URLActionManager.triggerResetFilters }); const bD = document.createElement('div'); bD.classList.add(CSS.FIXED_TOP_BUTTONS_ITEM); bD.appendChild(btn); fTF.appendChild(bD); } if (pznBL === 'topBlock' && currentSettings.personalizationButtonLocation !== 'none') { const btnPzn = _createPersonalizationButtonHTML('topBlock'); const bDPzn = document.createElement('div'); bDPzn.classList.add(CSS.FIXED_TOP_BUTTONS_ITEM); bDPzn.appendChild(btnPzn); fTF.appendChild(bDPzn); } if (vBL === 'topBlock' && currentSettings.verbatimButtonLocation !== 'none') { const btnVerbatim = _createStandardButton({ id: IDS.TOOL_VERBATIM, className: CSS.BUTTON, svgIcon: SVG_ICONS.verbatim, textContent: _('tool_verbatim_search'), title: _('tool_verbatim_search'), clickHandler: URLActionManager.triggerToggleVerbatim, isActive: verbatimActive }); const bDVerbatim = document.createElement('div'); bDVerbatim.classList.add(CSS.FIXED_TOP_BUTTONS_ITEM); bDVerbatim.appendChild(btnVerbatim); fTF.appendChild(bDVerbatim); } if (aSL === 'topBlock' && currentSettings.advancedSearchLinkLocation !== 'none') { const linkEl = _createAdvancedSearchElementHTML(true); const bDAdv = document.createElement('div'); bDAdv.classList.add(CSS.FIXED_TOP_BUTTONS_ITEM); bDAdv.appendChild(linkEl); fTF.appendChild(bDAdv); } if (schL === 'topBlock' && currentSettings.googleScholarShortcutLocation !== 'none') { const btnSch = _createServiceShortcutButton('googleScholar', 'topBlock'); if (btnSch) { const bDSch = document.createElement('div'); bDSch.classList.add(CSS.FIXED_TOP_BUTTONS_ITEM); bDSch.appendChild(btnSch); fTF.appendChild(bDSch); } } if (trnL === 'topBlock' && currentSettings.googleTrendsShortcutLocation !== 'none') { const btnTrn = _createServiceShortcutButton('googleTrends', 'topBlock'); if (btnTrn) { const bDTrn = document.createElement('div'); bDTrn.classList.add(CSS.FIXED_TOP_BUTTONS_ITEM); bDTrn.appendChild(btnTrn); fTF.appendChild(bDTrn); } } if (dsL === 'topBlock' && currentSettings.googleDatasetSearchShortcutLocation !== 'none') { const btnDs = _createServiceShortcutButton('googleDatasetSearch', 'topBlock'); if (btnDs) { const bDDs = document.createElement('div'); bDDs.classList.add(CSS.FIXED_TOP_BUTTONS_ITEM); bDDs.appendChild(btnDs); fTF.appendChild(bDDs); } } if (fTF.childElementCount > 0) { fTBC.appendChild(fTF); return fTBC; } return null; } /** * Creates the "Tools" section element. * @private * @returns {HTMLElement|null} The created section element, or null if no tools are configured to be in this section. */ function _createToolsSectionElement(sectionId, titleKey, rBL, vBL, aSL, pznBL, schL, trnL, dsL) { const { section, sectionContent, sectionTitle } = _createSectionShell(sectionId, titleKey); sectionTitle.textContent = _(titleKey); const frag = document.createDocumentFragment(); const verbatimActive = URLActionManager.isVerbatimActive(); const currentSettings = SettingsManager.getCurrentSettings(); if (rBL === 'tools' && currentSettings.resetButtonLocation !== 'none') { const btn = _createStandardButton({ id: IDS.TOOL_RESET_BUTTON, className: CSS.BUTTON, svgIcon: SVG_ICONS.reset, textContent: _('tool_reset_filters'), title: _('tool_reset_filters'), clickHandler: URLActionManager.triggerResetFilters }); frag.appendChild(btn); } if (pznBL === 'tools' && currentSettings.personalizationButtonLocation !== 'none') { const btnPzn = _createPersonalizationButtonHTML('tools'); frag.appendChild(btnPzn); } if (vBL === 'tools' && currentSettings.verbatimButtonLocation !== 'none') { const btnVerbatim = _createStandardButton({ id: IDS.TOOL_VERBATIM, className: CSS.BUTTON, svgIcon: SVG_ICONS.verbatim, textContent: _('tool_verbatim_search'), title: _('tool_verbatim_search'), clickHandler: URLActionManager.triggerToggleVerbatim, isActive: verbatimActive }); frag.appendChild(btnVerbatim); } if (aSL === 'tools' && currentSettings.advancedSearchLinkLocation !== 'none') { frag.appendChild(_createAdvancedSearchElementHTML(true)); } if (schL === 'tools' && currentSettings.googleScholarShortcutLocation !== 'none') { const btnSch = _createServiceShortcutButton('googleScholar', 'tools'); if (btnSch) frag.appendChild(btnSch); } if (trnL === 'tools' && currentSettings.googleTrendsShortcutLocation !== 'none') { const btnTrn = _createServiceShortcutButton('googleTrends', 'tools'); if (btnTrn) frag.appendChild(btnTrn); } if (dsL === 'tools' && currentSettings.googleDatasetSearchShortcutLocation !== 'none') { const btnDs = _createServiceShortcutButton('googleDatasetSearch', 'tools'); if (btnDs) frag.appendChild(btnDs); } if (frag.childElementCount > 0) { sectionContent.appendChild(frag); return section; } return null; } /** * Validates the date range input fields. * @private * @param {HTMLInputElement} minInput - The start date input. * @param {HTMLInputElement} maxInput - The end date input. * @param {HTMLElement} errorMsgElement - The element for displaying errors. * @returns {boolean} True if the dates are valid. */ function _validateDateInputs(minInput, maxInput, errorMsgElement) { _clearElementMessage(errorMsgElement, CSS.IS_ERROR_VISIBLE); minInput.classList.remove(CSS.HAS_ERROR); maxInput.classList.remove(CSS.HAS_ERROR); let isValid = true; const today = new Date(); today.setHours(0, 0, 0, 0); const startDateStr = minInput.value; const endDateStr = maxInput.value; let startDate = null; let endDate = null; if (startDateStr) { startDate = new Date(startDateStr); startDate.setHours(0,0,0,0); if (startDate > today) { _showElementMessage(errorMsgElement, 'alert_start_in_future', {}, CSS.IS_ERROR_VISIBLE); minInput.classList.add(CSS.HAS_ERROR); isValid = false; } } if (endDateStr) { endDate = new Date(endDateStr); endDate.setHours(0,0,0,0); if (endDate > today && !maxInput.getAttribute('max')) { if (isValid) _showElementMessage(errorMsgElement, 'alert_end_in_future', {}, CSS.IS_ERROR_VISIBLE); else errorMsgElement.textContent += " " + _('alert_end_in_future'); maxInput.classList.add(CSS.HAS_ERROR); isValid = false; } } if (startDate && endDate && startDate > endDate) { if (isValid) _showElementMessage(errorMsgElement, 'alert_end_before_start', {}, CSS.IS_ERROR_VISIBLE); else errorMsgElement.textContent += " " + _('alert_end_before_start'); minInput.classList.add(CSS.HAS_ERROR); maxInput.classList.add(CSS.HAS_ERROR); isValid = false; } return isValid; } /** * Adds event listeners to the date range input fields. */ function addDateRangeListener() { const dateRangeSection = sidebar?.querySelector('#sidebar-section-date-range'); if (!dateRangeSection) return; const applyButton = dateRangeSection.querySelector('.apply-date-range'); const errorMsgElement = dateRangeSection.querySelector(`#${IDS.DATE_RANGE_ERROR_MSG}`); const dateMinInput = dateRangeSection.querySelector(`#${IDS.DATE_MIN}`); const dateMaxInput = dateRangeSection.querySelector(`#${IDS.DATE_MAX}`); if (!applyButton || !errorMsgElement || !dateMinInput || !dateMaxInput) { console.warn(`${LOG_PREFIX} Date range elements not found for listener setup.`); return; } const handleDateValidation = () => { const isValid = _validateDateInputs(dateMinInput, dateMaxInput, errorMsgElement); applyButton.disabled = !isValid; }; if (!dateMinInput.dataset[DATA_ATTR.LISTENER_ATTACHED]) { dateMinInput.addEventListener('input', handleDateValidation); dateMinInput.addEventListener('change', handleDateValidation); dateMinInput.dataset[DATA_ATTR.LISTENER_ATTACHED] = 'true'; } if (!dateMaxInput.dataset[DATA_ATTR.LISTENER_ATTACHED]) { dateMaxInput.addEventListener('input', handleDateValidation); dateMaxInput.addEventListener('change', handleDateValidation); dateMaxInput.dataset[DATA_ATTR.LISTENER_ATTACHED] = 'true'; } if (!applyButton.dataset[DATA_ATTR.LISTENER_ATTACHED]) { applyButton.dataset[DATA_ATTR.LISTENER_ATTACHED] = 'true'; applyButton.addEventListener('click', () => { if (!_validateDateInputs(dateMinInput, dateMaxInput, errorMsgElement)) return; URLActionManager.applyDateRange(dateMinInput.value, dateMaxInput.value); }); } handleDateValidation(); } /** * A helper function that calls other functions to initialize all sidebar states and listeners after the UI is built. */ function _initializeSidebarEventListenersAndStates() { addDateRangeListener(); addToolButtonListeners(); initializeSelectedFilters(); applySectionCollapseStates(); ResultStatsManager.update(); } /** * Clears the text content and removes the visibility class from a message element. * @private */ function _clearElementMessage(element, visibleClass = CSS.IS_ERROR_VISIBLE) { if(!element)return; element.textContent=''; element.classList.remove(visibleClass);} /** * Shows a message in a specific DOM element. * @private */ function _showElementMessage(element, messageKey, messageArgs = {}, visibleClass = CSS.IS_ERROR_VISIBLE) { if(!element)return; element.textContent=_(messageKey,messageArgs); element.classList.add(visibleClass);} /** * Adds event listeners to tool buttons that might be in different locations. */ function addToolButtonListeners() { const queryAreas = [ sidebar?.querySelector(`.${CSS.SIDEBAR_HEADER}`), sidebar?.querySelector(`#${IDS.FIXED_TOP_BUTTONS}`), sidebar?.querySelector(`#sidebar-section-tools .${CSS.SECTION_CONTENT}`) ].filter(Boolean); queryAreas.forEach(area => { area.querySelectorAll(`#${IDS.TOOL_VERBATIM}:not([data-${DATA_ATTR.LISTENER_ATTACHED}])`).forEach(b => { b.addEventListener('click', URLActionManager.triggerToggleVerbatim); b.dataset[DATA_ATTR.LISTENER_ATTACHED] = 'true'; }); area.querySelectorAll(`#${IDS.TOOL_RESET_BUTTON}:not([data-${DATA_ATTR.LISTENER_ATTACHED}])`).forEach(b => { b.addEventListener('click', URLActionManager.triggerResetFilters); b.dataset[DATA_ATTR.LISTENER_ATTACHED] = 'true'; }); }); } /** * Applies the visual styles for the sidebar's collapsed or expanded state. * @param {boolean} isCollapsed - Whether the sidebar is currently collapsed. */ function applySidebarCollapseVisuals(isCollapsed) { if(!sidebar)return; const collapseButton = sidebar.querySelector(`#${IDS.COLLAPSE_BUTTON}`); if(isCollapsed){ sidebar.classList.add(CSS.IS_SIDEBAR_COLLAPSED); if(collapseButton){ collapseButton.innerHTML = SVG_ICONS.chevronRight; collapseButton.title = _('sidebar_expand_title');}} else{ sidebar.classList.remove(CSS.IS_SIDEBAR_COLLAPSED); if(collapseButton){ collapseButton.innerHTML = SVG_ICONS.chevronLeft; collapseButton.title = _('sidebar_collapse_title');}} } /** * Applies the collapsed/expanded state to all sections based on the current settings. */ function applySectionCollapseStates() { if(!sidebar)return; const currentSettings = SettingsManager.getCurrentSettings(); const sections = sidebar.querySelectorAll(`.${CSS.SIDEBAR_CONTENT_WRAPPER} .${CSS.SECTION}`); sections.forEach(section => { const content = section.querySelector(`.${CSS.SECTION_CONTENT}`); const title = section.querySelector(`.${CSS.SECTION_TITLE}`); const sectionId = section.id; if (content && title && sectionId) { let shouldBeCollapsed = false; if (currentSettings.sectionDisplayMode === 'collapseAll') { shouldBeCollapsed = true; } else if (currentSettings.sectionDisplayMode === 'expandAll') { shouldBeCollapsed = false; } else { shouldBeCollapsed = currentSettings.sectionStates?.[sectionId] === true; } content.classList.toggle(CSS.IS_SECTION_COLLAPSED, shouldBeCollapsed); title.classList.toggle(CSS.IS_SECTION_COLLAPSED, shouldBeCollapsed); if (currentSettings.sectionDisplayMode === 'remember') { if (!currentSettings.sectionStates) currentSettings.sectionStates = {}; currentSettings.sectionStates[sectionId] = shouldBeCollapsed; } } }); } /** * Initializes the visual state of all filter options in the sidebar to reflect the * current URL's search parameters upon page load. */ function initializeSelectedFilters() { if (!sidebar) return; try { const currentUrl = URLActionManager.generateURLObject(); if (!currentUrl) return; const params = currentUrl.searchParams; const currentTbs = params.get('tbs') || ''; const currentQuery = params.get('q') || ''; ALL_SECTION_DEFINITIONS.forEach(sectionDef => { if (sectionDef.type === 'filter' && sectionDef.param && sectionDef.id !== 'sidebar-section-filetype' && sectionDef.id !== 'sidebar-section-site-search') { _initializeStandaloneFilterState(params, sectionDef.id, sectionDef.param); } }); _initializeTimeFilterState(currentTbs); _initializeVerbatimState(); _initializePersonalizationState(); _initializeDateRangeInputs(currentTbs); _initializeSiteSearchState(currentQuery); _initializeFiletypeSearchState(currentQuery, params.get('as_filetype')); } catch (e) { console.error(`${LOG_PREFIX} Error initializing filter highlights:`, e); } } /** * Sets the selected state for options in a standard filter section based on URL parameters. * @private * @param {URLSearchParams} params - The current URL search parameters. * @param {string} sectionId - The ID of the section to initialize. * @param {string} paramToGetFromURL - The URL parameter key to check. */ function _initializeStandaloneFilterState(params, sectionId, paramToGetFromURL) { const sectionElement = sidebar?.querySelector(`#${sectionId}`); if (!sectionElement) return; const urlValue = params.get(paramToGetFromURL); const options = sectionElement.querySelectorAll(`.${CSS.FILTER_OPTION}`); let anOptionWasSelectedBasedOnUrl = false; options.forEach(opt => { const optionValue = opt.dataset[DATA_ATTR.FILTER_VALUE]; const isSelected = (urlValue !== null && urlValue === optionValue); opt.classList.toggle(CSS.IS_SELECTED, isSelected); if (isSelected) anOptionWasSelectedBasedOnUrl = true; }); if (!anOptionWasSelectedBasedOnUrl) { const defaultOptionQuery = (paramToGetFromURL === 'as_occt') ? `.${CSS.FILTER_OPTION}[data-${DATA_ATTR.FILTER_VALUE}="any"]` : `.${CSS.FILTER_OPTION}[data-${DATA_ATTR.FILTER_VALUE}=""]`; const defaultOpt = sectionElement.querySelector(defaultOptionQuery); if (defaultOpt) { defaultOpt.classList.add(CSS.IS_SELECTED); } } } /** * Sets the selected state for the time filter based on the 'tbs' URL parameter. * @private * @param {string} currentTbs - The current value of the 'tbs' URL parameter. */ function _initializeTimeFilterState(currentTbs){ const timeSection = sidebar?.querySelector('#sidebar-section-time'); if(!timeSection) return; const qdrMatch = currentTbs.match(/qdr:([^,]+)/); const activeQdrValue = qdrMatch ? qdrMatch[1] : null; const hasDateRange = /cdr:1/.test(currentTbs); const timeOptions = timeSection.querySelectorAll(`.${CSS.FILTER_OPTION}`); timeOptions.forEach(opt => { const optionValue = opt.dataset[DATA_ATTR.FILTER_VALUE]; let shouldBeSelected = false; if(hasDateRange){ shouldBeSelected = (optionValue === ''); } else if(activeQdrValue){ shouldBeSelected = (optionValue === activeQdrValue); } else { shouldBeSelected = (optionValue === ''); } opt.classList.toggle(CSS.IS_SELECTED, shouldBeSelected); }); } /** * Sets the active state of the Verbatim search button. * @private */ function _initializeVerbatimState(){ const isVerbatimActiveNow = URLActionManager.isVerbatimActive(); sidebar?.querySelectorAll(`#${IDS.TOOL_VERBATIM}`).forEach(b=>b.classList.toggle(CSS.IS_ACTIVE, isVerbatimActiveNow)); } /** * Sets the active state of the Personalization toggle button. * @private */ function _initializePersonalizationState() { const isActive = URLActionManager.isPersonalizationActive(); sidebar?.querySelectorAll(`#${IDS.TOOL_PERSONALIZE}`).forEach(button => { button.classList.toggle(CSS.IS_ACTIVE, isActive); const titleKey = isActive ? 'tooltip_toggle_personalization_off' : 'tooltip_toggle_personalization_on'; button.title = _(titleKey); const svgIcon = SVG_ICONS.personalization || ''; const isIconOnly = button.classList.contains(CSS.HEADER_BUTTON) && !button.classList.contains(CSS.BUTTON); const currentText = !isIconOnly ? _('tool_personalization_toggle') : ''; let newHTML = ''; if(svgIcon) newHTML += svgIcon; if(currentText) newHTML += (svgIcon && currentText ? ' ' : '') + `<span>${currentText}</span>`; button.innerHTML = newHTML.trim(); }); } /** * Sets the values of the date range inputs based on the 'tbs' URL parameter. * @private * @param {string} currentTbs - The current value of the 'tbs' URL parameter. */ function _initializeDateRangeInputs(currentTbs){ const dateSection = sidebar?.querySelector('#sidebar-section-date-range'); if (!dateSection) return; const dateMinInput = dateSection.querySelector(`#${IDS.DATE_MIN}`); const dateMaxInput = dateSection.querySelector(`#${IDS.DATE_MAX}`); const errorMsgElement = dateSection.querySelector(`#${IDS.DATE_RANGE_ERROR_MSG}`); const applyButton = dateSection.querySelector('.apply-date-range'); if (errorMsgElement) _clearElementMessage(errorMsgElement, CSS.IS_ERROR_VISIBLE); if (/cdr:1/.test(currentTbs)) { const minMatch = currentTbs.match(/cd_min:(\d{1,2})\/(\d{1,2})\/(\d{4})/); const maxMatch = currentTbs.match(/cd_max:(\d{1,2})\/(\d{1,2})\/(\d{4})/); if (dateMinInput) dateMinInput.value = minMatch ? `${minMatch[3]}-${minMatch[1].padStart(2, '0')}-${minMatch[2].padStart(2, '0')}` : ''; if (dateMaxInput) dateMaxInput.value = maxMatch ? `${maxMatch[3]}-${maxMatch[1].padStart(2, '0')}-${maxMatch[2].padStart(2, '0')}` : ''; } else { if (dateMinInput) dateMinInput.value = ''; if (dateMaxInput) dateMaxInput.value = ''; } if (dateMinInput && dateMaxInput && errorMsgElement && applyButton) { const isValid = _validateDateInputs(dateMinInput, dateMaxInput, errorMsgElement); applyButton.disabled = !isValid; } } /** * Sets the selected state for the site search options based on 'site:' operators in the query. * @private * @param {string} currentQuery - The current search query string. */ function _initializeSiteSearchState(currentQuery){ const siteSearchSectionContent = sidebar?.querySelector('#sidebar-section-site-search .'+CSS.SECTION_CONTENT); if (!siteSearchSectionContent) return; const clearSiteOptDiv = siteSearchSectionContent.querySelector(`#${IDS.CLEAR_SITE_SEARCH_OPTION}`); const listElement = siteSearchSectionContent.querySelector('ul.' + CSS.CUSTOM_LIST); const currentSettings = SettingsManager.getCurrentSettings(); const checkboxModeEnabled = currentSettings.enableSiteSearchCheckboxMode; siteSearchSectionContent.querySelectorAll(`.${CSS.FILTER_OPTION}.${CSS.IS_SELECTED}, label.${CSS.IS_SELECTED}`).forEach(opt => opt.classList.remove(CSS.IS_SELECTED)); if (checkboxModeEnabled && listElement) { listElement.querySelectorAll(`input[type="checkbox"].${CSS.CHECKBOX_SITE}`).forEach(cb => cb.checked = false); } const siteMatches = [...currentQuery.matchAll(/(?<!\S)site:([\w.:\/~%?#=&+-]+)(?!\S)/gi)]; let activeSiteUrlsFromQuery = siteMatches.map(match => match[1].toLowerCase()); activeSiteUrlsFromQuery.sort(); if (activeSiteUrlsFromQuery.length > 0) { if(clearSiteOptDiv) clearSiteOptDiv.classList.remove(CSS.IS_SELECTED); let customOptionFullyMatched = false; if (listElement) { const customSiteOptions = Array.from(listElement.querySelectorAll(checkboxModeEnabled ? 'label' : `div.${CSS.FILTER_OPTION}`)); for (const optElement of customSiteOptions) { const customSiteValue = optElement.dataset[DATA_ATTR.SITE_URL]; if (!customSiteValue) continue; const definedCustomSites = Utils.parseCombinedValue(customSiteValue).map(s => s.toLowerCase()).sort(); if (definedCustomSites.length > 0 && definedCustomSites.length === activeSiteUrlsFromQuery.length && definedCustomSites.every((val, index) => val === activeSiteUrlsFromQuery[index])) { if (checkboxModeEnabled) { const checkbox = listElement.querySelector(`input[type="checkbox"][value="${customSiteValue}"]`); if (checkbox) checkbox.checked = true; } optElement.classList.add(CSS.IS_SELECTED); customOptionFullyMatched = true; break; } } } if (!customOptionFullyMatched && listElement && checkboxModeEnabled) { activeSiteUrlsFromQuery.forEach(url => { const checkbox = listElement.querySelector(`input[type="checkbox"].${CSS.CHECKBOX_SITE}[value="${url}"]`); if (checkbox) { checkbox.checked = true; const label = listElement.querySelector(`label[for="${checkbox.id}"]`); if(label) label.classList.add(CSS.IS_SELECTED); } }); } else if (!customOptionFullyMatched && listElement && !checkboxModeEnabled && activeSiteUrlsFromQuery.length === 1) { const singleSiteInQuery = activeSiteUrlsFromQuery[0]; const optionDiv = listElement.querySelector(`div.${CSS.FILTER_OPTION}[data-${DATA_ATTR.SITE_URL}="${singleSiteInQuery}"]`); if (optionDiv) optionDiv.classList.add(CSS.IS_SELECTED); } } else { if (clearSiteOptDiv) clearSiteOptDiv.classList.add(CSS.IS_SELECTED); } if (checkboxModeEnabled) { _updateApplySitesButtonState(siteSearchSectionContent); } } /** * Sets the selected state for the file type options based on 'filetype:' operators or the 'as_filetype' parameter. * @private * @param {string} currentQuery - The current search query string. * @param {string|null} asFiletypeParam - The value of the 'as_filetype' URL parameter. */ function _initializeFiletypeSearchState(currentQuery, asFiletypeParam) { const filetypeSectionContent = sidebar?.querySelector('#sidebar-section-filetype .'+CSS.SECTION_CONTENT); if (!filetypeSectionContent) return; const clearFiletypeOptDiv = filetypeSectionContent.querySelector(`#${IDS.CLEAR_FILETYPE_SEARCH_OPTION}`); const listElement = filetypeSectionContent.querySelector('ul.' + CSS.CUSTOM_LIST); const currentSettings = SettingsManager.getCurrentSettings(); const checkboxModeEnabled = currentSettings.enableFiletypeCheckboxMode; filetypeSectionContent.querySelectorAll(`.${CSS.FILTER_OPTION}.${CSS.IS_SELECTED}, label.${CSS.IS_SELECTED}`).forEach(opt => opt.classList.remove(CSS.IS_SELECTED)); if (checkboxModeEnabled && listElement) { listElement.querySelectorAll(`input[type="checkbox"].${CSS.CHECKBOX_FILETYPE}`).forEach(cb => cb.checked = false); } let activeFiletypesFromQuery = []; const filetypeMatches = [...currentQuery.matchAll(/(?<!\S)filetype:([\w.:\/~%?#=&+-]+)(?!\S)/gi)]; if (filetypeMatches.length > 0) { activeFiletypesFromQuery = filetypeMatches.map(match => match[1].toLowerCase()); } else if (asFiletypeParam && !currentQuery.includes('filetype:')) { activeFiletypesFromQuery = Utils.parseCombinedValue(asFiletypeParam).map(ft => ft.toLowerCase()); } activeFiletypesFromQuery.sort(); if (activeFiletypesFromQuery.length > 0) { if(clearFiletypeOptDiv) clearFiletypeOptDiv.classList.remove(CSS.IS_SELECTED); let customOptionFullyMatched = false; if (listElement) { const customFiletypeOptions = Array.from(listElement.querySelectorAll(checkboxModeEnabled ? 'label' : `div.${CSS.FILTER_OPTION}`)); for (const optElement of customFiletypeOptions) { const customFtValueAttr = checkboxModeEnabled ? optElement.dataset[DATA_ATTR.FILETYPE_VALUE] : optElement.dataset[DATA_ATTR.FILTER_VALUE]; if (!customFtValueAttr) continue; const definedCustomFiletypes = Utils.parseCombinedValue(customFtValueAttr).map(s => s.toLowerCase()).sort(); if (definedCustomFiletypes.length > 0 && definedCustomFiletypes.length === activeFiletypesFromQuery.length && definedCustomFiletypes.every((val, index) => val === activeFiletypesFromQuery[index])) { if (checkboxModeEnabled) { const checkbox = listElement.querySelector(`input[type="checkbox"][value="${customFtValueAttr}"]`); if (checkbox) checkbox.checked = true; } optElement.classList.add(CSS.IS_SELECTED); customOptionFullyMatched = true; break; } } } if (!customOptionFullyMatched && listElement && checkboxModeEnabled) { activeFiletypesFromQuery.forEach(ft => { const checkbox = listElement.querySelector(`input[type="checkbox"].${CSS.CHECKBOX_FILETYPE}[value="${ft}"]`); if (checkbox) { checkbox.checked = true; const label = listElement.querySelector(`label[for="${checkbox.id}"]`); if(label) label.classList.add(CSS.IS_SELECTED); } }); } else if (!customOptionFullyMatched && listElement && !checkboxModeEnabled && activeFiletypesFromQuery.length === 1) { const singleFtInQuery = activeFiletypesFromQuery[0]; const optionDiv = listElement.querySelector(`div.${CSS.FILTER_OPTION}[data-${DATA_ATTR.FILTER_VALUE}="${singleFtInQuery}"]`); if (optionDiv) optionDiv.classList.add(CSS.IS_SELECTED); } } else { if (clearFiletypeOptDiv) clearFiletypeOptDiv.classList.add(CSS.IS_SELECTED); } if (checkboxModeEnabled) { _updateApplyFiletypesButtonState(filetypeSectionContent); } } /** * Binds the main event listeners for the sidebar itself, primarily using event delegation. * This includes clicks on the settings and collapse buttons, as well as section titles. */ function bindSidebarEvents() { if (!sidebar) return; const collapseButton = sidebar.querySelector(`#${IDS.COLLAPSE_BUTTON}`); const settingsButton = sidebar.querySelector(`#${IDS.SETTINGS_BUTTON}`); if (collapseButton) collapseButton.title = _('sidebar_collapse_title'); if (settingsButton) settingsButton.title = _('sidebar_settings_title'); // Use a single delegated event listener for the entire sidebar for efficiency. sidebar.addEventListener('click', (e) => { const settingsBtnTarget = e.target.closest(`#${IDS.SETTINGS_BUTTON}`); if (settingsBtnTarget) { SettingsManager.show(); return; } const collapseBtnTarget = e.target.closest(`#${IDS.COLLAPSE_BUTTON}`); if (collapseBtnTarget) { toggleSidebarCollapse(); return; } const sectionTitleTarget = e.target.closest(`.${CSS.SIDEBAR_CONTENT_WRAPPER} .${CSS.SECTION_TITLE}`); if (sectionTitleTarget && !sidebar.classList.contains(CSS.IS_SIDEBAR_COLLAPSED)) { handleSectionCollapse(e); return; } }); sidebar.addEventListener('mousedown', (event) => { if (event.button !== 1) { return; } // Middle-click only const target = event.target.closest(`.${CSS.FILTER_OPTION}, label[data-site-url], label[data-filetype-value]`); if (!target) { return; } event.preventDefault(); let targetUrl = null; const dataset = target.dataset; if (target.id === IDS.CLEAR_SITE_SEARCH_OPTION) { targetUrl = URLActionManager.generateClearSiteSearchURL(); } else if (target.id === IDS.CLEAR_FILETYPE_SEARCH_OPTION) { targetUrl = URLActionManager.generateClearFiletypeSearchURL(); } else if (dataset.siteUrl) { targetUrl = URLActionManager.generateSiteSearchURL(dataset.siteUrl); } else if (dataset.filetypeValue) { targetUrl = URLActionManager.generateCombinedFiletypeSearchURL(dataset.filetypeValue); } else if (dataset.filterType && typeof dataset.filterValue !== 'undefined') { targetUrl = URLActionManager.generateFilterURL(dataset.filterType, dataset.filterValue); } if (targetUrl && typeof GM_openInTab === 'function') { GM_openInTab(targetUrl, { active: false, insert: true }); } }); } /** * Toggles the collapsed/expanded state of the entire sidebar and saves the new state. */ function toggleSidebarCollapse() { const cs = SettingsManager.getCurrentSettings(); cs.sidebarCollapsed = !cs.sidebarCollapsed; applySettings(cs); SettingsManager.save('Sidebar Collapse');} /** * Handles a click on a section title to expand or collapse it. * It also manages the accordion effect if enabled. * @param {Event} event - The click event. */ function handleSectionCollapse(event) { const title = event.target.closest(`.${CSS.SECTION_TITLE}`); if (!title || sidebar?.classList.contains(CSS.IS_SIDEBAR_COLLAPSED) || title.closest(`#${IDS.FIXED_TOP_BUTTONS}`)) return; const section = title.closest(`.${CSS.SECTION}`); if (!section) return; const content = section.querySelector(`.${CSS.SECTION_CONTENT}`); const sectionId = section.id; if (!content || !sectionId) return; const currentSettings = SettingsManager.getCurrentSettings(); const isCurrentlyCollapsed = content.classList.contains(CSS.IS_SECTION_COLLAPSED); const shouldBeCollapsedAfterClick = !isCurrentlyCollapsed; let overallStateChanged = false; if (currentSettings.accordionMode && !shouldBeCollapsedAfterClick) { const sectionsContainer = section.parentElement; if (_applyAccordionEffectToSections(sectionId, sectionsContainer, currentSettings)) overallStateChanged = true; } if (_toggleSectionVisualState(section, title, content, sectionId, shouldBeCollapsedAfterClick, currentSettings)) overallStateChanged = true; if (overallStateChanged && currentSettings.sectionDisplayMode === 'remember') { debouncedSaveSettings('Section Collapse/Accordion'); } } /** * Collapses all other sections when one is expanded in accordion mode. * @private * @returns {boolean} True if any section's state was changed. */ function _applyAccordionEffectToSections(clickedSectionId, allSectionsContainer, currentSettings) { let stateChangedForAccordion = false; allSectionsContainer?.querySelectorAll(`.${CSS.SECTION}`)?.forEach(otherSection => { if (otherSection.id !== clickedSectionId) { const otherContent = otherSection.querySelector(`.${CSS.SECTION_CONTENT}`); const otherTitle = otherSection.querySelector(`.${CSS.SECTION_TITLE}`); if (otherContent && !otherContent.classList.contains(CSS.IS_SECTION_COLLAPSED)) { otherContent.classList.add(CSS.IS_SECTION_COLLAPSED); otherTitle?.classList.add(CSS.IS_SECTION_COLLAPSED); if (currentSettings.sectionDisplayMode === 'remember') { if (!currentSettings.sectionStates) currentSettings.sectionStates = {}; if (currentSettings.sectionStates[otherSection.id] !== true) { currentSettings.sectionStates[otherSection.id] = true; stateChangedForAccordion = true; } } } } }); return stateChangedForAccordion; } /** * Toggles the visual state of a single section and updates its state in the settings if necessary. * @private * @returns {boolean} True if the section's state was changed. */ function _toggleSectionVisualState(sectionEl, titleEl, contentEl, sectionId, newCollapsedState, currentSettings) { let sectionStateActuallyChanged = false; const isCurrentlyCollapsed = contentEl.classList.contains(CSS.IS_SECTION_COLLAPSED); if (isCurrentlyCollapsed !== newCollapsedState) { contentEl.classList.toggle(CSS.IS_SECTION_COLLAPSED, newCollapsedState); titleEl.classList.toggle(CSS.IS_SECTION_COLLAPSED, newCollapsedState); sectionStateActuallyChanged = true; } if (currentSettings.sectionDisplayMode === 'remember') { if (!currentSettings.sectionStates) currentSettings.sectionStates = {}; if (currentSettings.sectionStates[sectionId] !== newCollapsedState) { currentSettings.sectionStates[sectionId] = newCollapsedState; if (!sectionStateActuallyChanged) sectionStateActuallyChanged = true; } } return sectionStateActuallyChanged; } /** * The main entry point for the script. It orchestrates the initialization process, * including loading dependencies, settings, and building the UI. */ function initializeScript() { console.log(LOG_PREFIX + " Initializing script..."); debouncedSaveSettings = Utils.debounce(() => SettingsManager.save('Debounced Save'), 800); try { addGlobalStyles(); NotificationManager.init(); LocalizationService.initializeBaseLocale(); SettingsManager.initialize( defaultSettings, applySettings, buildSidebarUI, applySectionCollapseStates, _initMenuCommands, renderSectionOrderList ); setupSystemThemeListener(); buildSidebarSkeleton(); DragManager.init( sidebar, sidebar.querySelector(`.${CSS.DRAG_HANDLE}`), SettingsManager, debouncedSaveSettings ); const initialSettings = SettingsManager.getCurrentSettings(); DragManager.setDraggable(initialSettings.draggableHandleEnabled, sidebar, sidebar.querySelector(`.${CSS.DRAG_HANDLE}`)); applySettings(initialSettings); buildSidebarUI(); bindSidebarEvents(); _initMenuCommands(); ResultStatsManager.init(); console.log(`${LOG_PREFIX} Script initialization complete. Final effective locale: ${LocalizationService.getCurrentLocale()}`); } catch (error) { console.error(`${LOG_PREFIX} [initializeScript] CRITICAL ERROR DURING INITIALIZATION:`, error, error.stack); const scriptNameForAlert = (typeof _ === 'function' && _('scriptName') && !(_('scriptName').startsWith('[ERR:'))) ? _('scriptName') : SCRIPT_INTERNAL_NAME; if (typeof NotificationManager !== 'undefined' && NotificationManager.show) { NotificationManager.show('alert_init_fail', { scriptName: scriptNameForAlert, error: error.message }, 'error', 0); } else { _showGlobalMessage('alert_init_fail', { scriptName: scriptNameForAlert, error: error.message }, 'error', 0); } if(sidebar && sidebar.remove) sidebar.remove(); const settingsOverlayEl = document.getElementById(IDS.SETTINGS_OVERLAY); if(settingsOverlayEl) settingsOverlayEl.remove(); ModalManager.hide(); } } /** * This section handles the loading of external dependencies (styles and i18n). * It waits for custom events dispatched by the companion scripts before initializing the main script. * A timeout is included as a fallback in case the events do not fire. */ if (document.getElementById(IDS.SIDEBAR)) { console.warn(`${LOG_PREFIX} Sidebar with ID "${IDS.SIDEBAR}" already exists. Skipping initialization.`); return; } const dependenciesReady = { styles: false, i18n: false }; let initializationAttempted = false; let timeoutFallback; function checkDependenciesAndInitialize() { if (initializationAttempted) return; if (dependenciesReady.styles && dependenciesReady.i18n) { console.log(`${LOG_PREFIX} All dependencies ready. Initializing script.`); clearTimeout(timeoutFallback); initializationAttempted = true; if (document.readyState === 'complete' || document.readyState === 'interactive' || document.readyState === 'loaded') { initializeScript(); } else { window.addEventListener('DOMContentLoaded', initializeScript, { once: true }); } } } document.addEventListener('gscsStylesLoaded', function stylesLoadedHandler() { console.log(`${LOG_PREFIX} Event "gscsStylesLoaded" received.`); dependenciesReady.styles = true; checkDependenciesAndInitialize(); }, { once: true }); document.addEventListener('gscsi18nLoaded', function i18nLoadedHandler() { console.log(`${LOG_PREFIX} Event "gscsi18nLoaded" received.`); dependenciesReady.i18n = true; checkDependenciesAndInitialize(); }, { once: true }); timeoutFallback = setTimeout(() => { if (initializationAttempted) return; console.log(`${LOG_PREFIX} Fallback: Checking dependencies after timeout.`); if (typeof window.GSCS_Namespace !== 'undefined') { if (typeof window.GSCS_Namespace.stylesText === 'string' && window.GSCS_Namespace.stylesText.trim() !== '' && !dependenciesReady.styles) { console.log(`${LOG_PREFIX} Fallback: Styles found via namespace.`); dependenciesReady.styles = true; } if (typeof window.GSCS_Namespace.i18nPack === 'object' && Object.keys(window.GSCS_Namespace.i18nPack.translations || {}).length > 0 && !dependenciesReady.i18n) { console.log(`${LOG_PREFIX} Fallback: i18n pack found via namespace.`); dependenciesReady.i18n = true; } } if (dependenciesReady.styles && dependenciesReady.i18n) { checkDependenciesAndInitialize(); } else { console.error(`${LOG_PREFIX} Fallback: Dependencies still not fully loaded after timeout. Styles: ${dependenciesReady.styles}, i18n: ${dependenciesReady.i18n}.`); if (!initializationAttempted) { console.warn(`${LOG_PREFIX} Attempting to initialize with potentially incomplete dependencies due to fallback timeout.`); if (!dependenciesReady.styles) { console.warn(`${LOG_PREFIX} Styles dependency forced true in fallback.`); dependenciesReady.styles = true; } if (!dependenciesReady.i18n) { console.warn(`${LOG_PREFIX} i18n dependency forced true in fallback.`); dependenciesReady.i18n = true; } checkDependenciesAndInitialize(); } } }, 2000); if (document.readyState === 'complete' || document.readyState === 'interactive' || document.readyState === 'loaded') { if (typeof window.GSCS_Namespace !== 'undefined') { if (typeof window.GSCS_Namespace.stylesText === 'string' && window.GSCS_Namespace.stylesText.trim() !== '' && !dependenciesReady.styles) { dependenciesReady.styles = true; } if (typeof window.GSCS_Namespace.i18nPack === 'object' && Object.keys(window.GSCS_Namespace.i18nPack.translations || {}).length > 0 && !dependenciesReady.i18n) { dependenciesReady.i18n = true; } } if (dependenciesReady.styles && dependenciesReady.i18n && !initializationAttempted) { checkDependenciesAndInitialize(); } } })();