Google Search Custom Sidebar

Customizable Google Search sidebar: quick filters (lang, time, filetype, country, date), site search, Verbatim & Personalization tools.

安装此脚本
作者推荐脚本

您可能也喜欢Google Images Tools Enhanced

安装此脚本
  1. // ==UserScript==
  2. // @name Google Search Custom Sidebar
  3. // @name:zh-TW Google 搜尋自訂側邊欄
  4. // @name:ja Google検索カスタムサイドバー
  5. // @namespace https://gf.qytechs.cn/en/users/1467948-stonedkhajiit
  6. // @version 0.4.1
  7. // @description Customizable Google Search sidebar: quick filters (lang, time, filetype, country, date), site search, Verbatim & Personalization tools.
  8. // @description:zh-TW Google 搜尋自訂側邊欄:快速篩選(語言、時間、檔案類型、國家、日期)、站內搜尋、一字不差與個人化工具。
  9. // @description:ja Google検索カスタムサイドバー:高速フィルター(言語,期間,ファイル形式,国,日付)、サイト検索、完全一致検索とパーソナライズツール。
  10. // @match https://www.google.com/search*
  11. // @include /^https:\/\/(?:ipv4|ipv6|www)\.google\.(?:[a-z\.]+)\/search\?(?:.+&)?q=[^&]+(?:&.+)?$/
  12. // @exclude /^https:\/\/(?:ipv4|ipv6|www)\.google\.(?:[a-z\.]+)\/search\?(?:.+&)?(?:tbm=(?:isch|shop|bks|flm|fin|lcl)|udm=(?:2|28))(?:&.+)?$/
  13. // @icon https://www.google.com/favicon.ico
  14. // @grant GM_addStyle
  15. // @grant GM_getValue
  16. // @grant GM_setValue
  17. // @grant GM_registerMenuCommand
  18. // @grant GM_deleteValue
  19. // @grant GM_openInTab
  20. // @run-at document-idle
  21. // @author StonedKhajiit
  22. // @license MIT
  23. // @require https://update.gf.qytechs.cn/scripts/535624/1629293/Google%20Search%20Custom%20Sidebar%20-%20i18n.js
  24. // @require https://update.gf.qytechs.cn/scripts/535625/1629294/Google%20Search%20Custom%20Sidebar%20-%20Styles.js
  25. // ==/UserScript==
  26.  
  27. /**
  28. * @file Google Search Custom Sidebar
  29. * This script injects a customizable sidebar into Google search results pages,
  30. * offering advanced filtering options, site search capabilities, and quick-access tools.
  31. * It is designed to be highly modular, configurable, and maintainable.
  32. */
  33. (function() {
  34. 'use strict';
  35.  
  36. // --- Script Constants & Configuration ---
  37.  
  38. const SCRIPT_INTERNAL_NAME = 'GoogleSearchCustomSidebar';
  39. const SCRIPT_VERSION = '0.4.1'; // version bumped for changes
  40. const LOG_PREFIX = `[${SCRIPT_INTERNAL_NAME} v${SCRIPT_VERSION}]`;
  41.  
  42. /**
  43. * @constant {string[]} DEFAULT_SECTION_ORDER
  44. * Defines the default display order for sections in the sidebar.
  45. */
  46. const DEFAULT_SECTION_ORDER = [
  47. 'sidebar-section-language', 'sidebar-section-time', 'sidebar-section-filetype',
  48. 'sidebar-section-occurrence',
  49. 'sidebar-section-country', 'sidebar-section-date-range', 'sidebar-section-site-search', 'sidebar-section-tools'
  50. ];
  51.  
  52. /**
  53. * @constant {Object} defaultSettings
  54. * Contains the default configuration for the script. This object serves as a fallback
  55. * for any missing settings and is used when resetting the configuration.
  56. */
  57. const defaultSettings = {
  58. sidebarPosition: { left: 0, top: 80 },
  59. sectionStates: {},
  60. theme: 'system',
  61. hoverMode: false,
  62. idleOpacity: 0.8,
  63. sidebarWidth: 135,
  64. sidebarHeight: 85,
  65. fontSize: 12.5,
  66. headerIconSize: 16,
  67. verticalSpacingMultiplier: 0.5,
  68. interfaceLanguage: 'auto',
  69. customColors: {
  70. bgColor: '',
  71. textColor: '',
  72. linkColor: '',
  73. selectedColor: '',
  74. inputTextColor: '',
  75. borderColor: '',
  76. dividerColor: '',
  77. btnBgColor: '',
  78. btnHoverBgColor: '',
  79. activeBgColor: '',
  80. activeTextColor: '',
  81. activeBorderColor: '',
  82. headerIconColor: ''
  83. },
  84. visibleSections: {
  85. 'sidebar-section-language': true, 'sidebar-section-time': true, 'sidebar-section-filetype': true,
  86. 'sidebar-section-occurrence': true,
  87. 'sidebar-section-country': true, 'sidebar-section-date-range': true,
  88. 'sidebar-section-site-search': true, 'sidebar-section-tools': true
  89. },
  90. sectionDisplayMode: 'remember',
  91. accordionMode: false,
  92. resetButtonLocation: 'topBlock',
  93. verbatimButtonLocation: 'header',
  94. advancedSearchLinkLocation: 'header',
  95. personalizationButtonLocation: 'tools',
  96. googleScholarShortcutLocation: 'tools',
  97. googleTrendsShortcutLocation: 'tools',
  98. googleDatasetSearchShortcutLocation: 'tools',
  99. countryDisplayMode: 'iconAndText',
  100. scrollbarPosition: 'right',
  101. showResultStats: true,
  102. customLanguages: [],
  103. customTimeRanges: [],
  104. customFiletypes: [
  105. { text: "📄Documents", value: "pdf OR docx OR doc OR odt OR rtf OR txt" },
  106. { text: "💹Spreadsheets", value: "xlsx OR xls OR ods OR csv" },
  107. { text: "📊Presentations", value: "pptx OR ppt OR odp OR key" },
  108. ],
  109. customCountries: [],
  110. displayLanguages: [],
  111. displayCountries: [],
  112. favoriteSites: [
  113. // == SINGLE SITES: GENERAL KNOWLEDGE & REFERENCE ==
  114. { text: 'Wikipedia (EN)', url: 'en.wikipedia.org' },
  115. { text: 'Wiktionary', url: 'wiktionary.org' },
  116. { text: 'Internet Archive', url: 'archive.org' },
  117.  
  118. // == SINGLE SITES: DEVELOPER & TECH ==
  119. { text: 'GitHub', url: 'github.com' },
  120. { text: 'GitLab', url: 'gitlab.com' },
  121. { text: 'Stack Overflow', url: 'stackoverflow.com' },
  122. { text: 'Hacker News', url: 'news.ycombinator.com' },
  123. { text: 'Greasy Fork镜像', url: 'gf.qytechs.cn' },
  124.  
  125. // == SINGLE SITES: SOCIAL, FORUMS & COMMUNITIES ==
  126. { text: 'Reddit', url: 'reddit.com' },
  127. { text: 'X', url: 'x.com' },
  128. { text: 'Mastodon', url: 'mastodon.social' },
  129. { text: 'Bluesky', url: 'bsky.app' },
  130. { text: 'Lemmy', url: 'lemmy.world' },
  131.  
  132. // == SINGLE SITES: ENTERTAINMENT, ARTS & HOBBIES ==
  133. { text: 'IMDb', url: 'imdb.com' },
  134. { text: 'TMDb', url: 'themoviedb.org' },
  135. { text: 'Letterboxd', url: 'letterboxd.com' },
  136. { text: 'Metacritic', url: 'metacritic.com' },
  137. { text: 'OpenCritic', url: 'opencritic.com' },
  138. { text: 'Steam', url: 'store.steampowered.com' },
  139. { text: 'Bandcamp', url: 'bandcamp.com' },
  140. { text: 'Last.fm', url: 'last.fm' },
  141.  
  142. // == COMBINED SITE GROUPS ==
  143. {
  144. text: '💬Social',
  145. 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'
  146. },
  147. {
  148. text: '📦Repositories',
  149. url: 'github.com OR gitlab.com OR bitbucket.org OR codeberg.org OR sourceforge.net'
  150. },
  151. {
  152. text: '🎓Academics',
  153. 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'
  154. },
  155. {
  156. text: '📰News',
  157. url: 'bbc.com/news OR reuters.com OR apnews.com OR nytimes.com OR theguardian.com OR cnn.com OR wsj.com'
  158. },
  159. {
  160. text: '🎨Creative',
  161. url: 'behance.net OR dribbble.com OR artstation.com OR deviantart.com'
  162. }
  163. ],
  164. enableSiteSearchCheckboxMode: true,
  165. showFaviconsForSiteSearch: true,
  166. enableFiletypeCheckboxMode: true,
  167. sidebarCollapsed: false,
  168. draggableHandleEnabled: true,
  169. enabledPredefinedOptions: {
  170. language: ['lang_en'],
  171. country: ['countryUS'],
  172. time: ['d', 'w', 'm', 'y', 'h'],
  173. filetype: ['pdf', 'docx', 'doc', 'pptx', 'ppt', 'xlsx', 'xls', 'txt']
  174. },
  175. sidebarSectionOrder: [...DEFAULT_SECTION_ORDER],
  176. hideGoogleLogoWhenExpanded: false,
  177. };
  178.  
  179. /**
  180. * @constant {Object<string, string>} IDS
  181. * A centralized collection of all DOM element IDs used throughout the script.
  182. * Uses a 'gscs-' prefix and a BEM-like naming convention to prevent conflicts with the host page.
  183. */
  184. const IDS = {
  185. SIDEBAR: 'gscs-sidebar',
  186. SETTINGS_OVERLAY: 'gscs-settings-overlay',
  187. SETTINGS_WINDOW: 'gscs-settings-window',
  188. COLLAPSE_BUTTON: 'gscs-sidebar-collapse-button',
  189. SETTINGS_BUTTON: 'gscs-sidebar-settings-button',
  190. TOOL_RESET_BUTTON: 'gscs-tool-reset-button',
  191. TOOL_VERBATIM: 'gscs-tool-verbatim',
  192. TOOL_PERSONALIZE: 'gscs-tool-personalize-search',
  193. TOOL_GOOGLE_SCHOLAR: 'gscs-tool-google-scholar',
  194. TOOL_GOOGLE_TRENDS: 'gscs-tool-google-trends',
  195. TOOL_GOOGLE_DATASET_SEARCH: 'gscs-tool-google-dataset-search',
  196. APPLY_SELECTED_SITES_BUTTON: 'gscs-apply-selected-sites-button',
  197. APPLY_SELECTED_FILETYPES_BUTTON: 'gscs-apply-selected-filetypes-button',
  198. FIXED_TOP_BUTTONS: 'gscs-sidebar-fixed-top-buttons',
  199. SETTINGS_MESSAGE_BAR: 'gscs-settings-message-bar',
  200. SETTING_WIDTH: 'gscs-setting-sidebar-width',
  201. SETTING_HEIGHT: 'gscs-setting-sidebar-height',
  202. SETTING_FONT_SIZE: 'gscs-setting-font-size',
  203. SETTING_HEADER_ICON_SIZE: 'gscs-setting-header-icon-size',
  204. SETTING_VERTICAL_SPACING: 'gscs-setting-vertical-spacing',
  205. SETTING_INTERFACE_LANGUAGE: 'gscs-setting-interface-language',
  206. SETTING_SECTION_MODE: 'gscs-setting-section-display-mode',
  207. SETTING_ACCORDION: 'gscs-setting-accordion-mode',
  208. SETTING_DRAGGABLE: 'gscs-setting-draggable-handle',
  209. SETTING_RESET_LOCATION: 'gscs-setting-reset-button-location',
  210. SETTING_VERBATIM_LOCATION: 'gscs-setting-verbatim-button-location',
  211. SETTING_ADV_SEARCH_LOCATION: 'gscs-setting-adv-search-link-location',
  212. SETTING_PERSONALIZE_LOCATION: 'gscs-setting-personalize-button-location',
  213. SETTING_SCHOLAR_LOCATION: 'gscs-setting-scholar-shortcut-location',
  214. SETTING_TRENDS_LOCATION: 'gscs-setting-trends-shortcut-location',
  215. SETTING_DATASET_SEARCH_LOCATION: 'gscs-setting-dataset-search-shortcut-location',
  216. SETTING_SITE_SEARCH_CHECKBOX_MODE: 'gscs-setting-site-search-checkbox-mode',
  217. SETTING_SHOW_FAVICONS: 'gscs-setting-show-favicons',
  218. SETTING_FILETYPE_SEARCH_CHECKBOX_MODE: 'gscs-setting-filetype-search-checkbox-mode',
  219. SETTING_COUNTRY_DISPLAY_MODE: 'gscs-setting-country-display-mode',
  220. SETTING_THEME: 'gscs-setting-theme',
  221. SETTING_HOVER: 'gscs-setting-hover-mode',
  222. SETTING_OPACITY: 'gscs-setting-idle-opacity',
  223. SETTING_HIDE_GOOGLE_LOGO: 'gscs-setting-hide-google-logo',
  224. SETTING_SCROLLBAR_POSITION: 'gscs-setting-scrollbar-position',
  225. SETTING_SHOW_RESULT_STATS: 'gscs-setting-show-result-stats',
  226. CUSTOM_COLORS_CONTAINER: 'gscs-custom-colors-container',
  227. SETTING_COLOR_BG_COLOR: 'gscs-setting-color-bg-color',
  228. SETTING_COLOR_TEXT_COLOR: 'gscs-setting-color-text-color',
  229. SETTING_COLOR_LINK_COLOR: 'gscs-setting-color-link-color',
  230. SETTING_COLOR_SELECTED_COLOR: 'gscs-setting-color-selected-color',
  231. SETTING_COLOR_INPUT_TEXT_COLOR: 'gscs-setting-color-input-text-color',
  232. SETTING_COLOR_BORDER_COLOR: 'gscs-setting-color-border-color',
  233. SETTING_COLOR_DIVIDER_COLOR: 'gscs-setting-color-divider-color',
  234. SETTING_COLOR_BTN_BG_COLOR: 'gscs-setting-color-btn-bg-color',
  235. SETTING_COLOR_BTN_HOVER_BG_COLOR: 'gscs-setting-color-btn-hover-bg-color',
  236. SETTING_COLOR_ACTIVE_BG_COLOR: 'gscs-setting-color-active-bg-color',
  237. SETTING_COLOR_ACTIVE_TEXT_COLOR: 'gscs-setting-color-active-text-color',
  238. SETTING_COLOR_ACTIVE_BORDER_COLOR: 'gscs-setting-color-active-border-color',
  239. SETTING_COLOR_HEADER_ICON_COLOR: 'gscs-setting-color-header-icon-color',
  240. RESET_CUSTOM_COLORS_BTN: 'gscs-reset-custom-colors-btn',
  241. TAB_PANE_GENERAL: 'gscs-tab-pane-general',
  242. TAB_PANE_APPEARANCE: 'gscs-tab-pane-appearance',
  243. TAB_PANE_FEATURES: 'gscs-tab-pane-features',
  244. TAB_PANE_CUSTOM: 'gscs-tab-pane-custom',
  245. SITES_LIST: 'gscs-custom-sites-list',
  246. LANG_LIST: 'gscs-custom-languages-list',
  247. TIME_LIST: 'gscs-custom-time-ranges-list',
  248. FT_LIST: 'gscs-custom-filetypes-list',
  249. COUNTRIES_LIST: 'gscs-custom-countries-list',
  250. NEW_SITE_NAME: 'gscs-new-site-name',
  251. NEW_SITE_URL: 'gscs-new-site-url',
  252. ADD_SITE_BTN: 'gscs-add-site-button',
  253. NEW_LANG_TEXT: 'gscs-new-lang-text',
  254. NEW_LANG_VALUE: 'gscs-new-lang-value',
  255. ADD_LANG_BTN: 'gscs-add-lang-button',
  256. NEW_TIME_TEXT: 'gscs-new-timerange-text',
  257. NEW_TIME_VALUE: 'gscs-new-timerange-value',
  258. ADD_TIME_BTN: 'gscs-add-timerange-button',
  259. NEW_FT_TEXT: 'gscs-new-ft-text',
  260. NEW_FT_VALUE: 'gscs-new-ft-value',
  261. ADD_FT_BTN: 'gscs-add-ft-button',
  262. NEW_COUNTRY_TEXT: 'gscs-new-country-text',
  263. NEW_COUNTRY_VALUE: 'gscs-new-country-value',
  264. ADD_COUNTRY_BTN: 'gscs-add-country-button',
  265. DATE_MIN: 'gscs-date-min',
  266. DATE_MAX: 'gscs-date-max',
  267. DATE_RANGE_ERROR_MSG: 'gscs-date-range-error-msg',
  268. SIDEBAR_SECTION_ORDER_LIST: 'gscs-sidebar-section-order-list',
  269. NOTIFICATION_CONTAINER: 'gscs-notification-container',
  270. MODAL_ADD_NEW_OPTION_BTN: 'gscs-modal-add-new-option-btn',
  271. MODAL_PREDEFINED_CHOOSER_CONTAINER: 'gscs-modal-predefined-chooser-container',
  272. MODAL_PREDEFINED_CHOOSER_LIST: 'gscs-modal-predefined-chooser-list',
  273. MODAL_PREDEFINED_CHOOSER_ADD_BTN: 'gscs-modal-predefined-chooser-add-btn',
  274. MODAL_PREDEFINED_CHOOSER_CANCEL_BTN: 'gscs-modal-predefined-chooser-cancel-btn',
  275. CLEAR_SITE_SEARCH_OPTION: 'gscs-clear-site-search-option',
  276. CLEAR_FILETYPE_SEARCH_OPTION: 'gscs-clear-filetype-search-option',
  277. RESULT_STATS_CONTAINER: 'gscs-result-stats-container'
  278. };
  279.  
  280. /**
  281. * @constant {Object[]} COLOR_MAPPINGS
  282. * Maps settings UI color pickers to their corresponding CSS custom properties.
  283. * Each object defines the DOM element ID of the color picker, the key in the settings object,
  284. * and an array of CSS variables it controls.
  285. */
  286. const COLOR_MAPPINGS = [
  287. { id: IDS.SETTING_COLOR_BG_COLOR, key: 'bgColor', cssVars: ['--sidebar-bg-color'] },
  288. { id: IDS.SETTING_COLOR_TEXT_COLOR, key: 'textColor', cssVars: ['--sidebar-text-color', '--sidebar-tool-btn-hover-text'] },
  289. { id: IDS.SETTING_COLOR_LINK_COLOR, key: 'linkColor', cssVars: ['--sidebar-link-color', '--sidebar-link-hover-color', '--sidebar-header-btn-hover-color'] },
  290. { id: IDS.SETTING_COLOR_SELECTED_COLOR, key: 'selectedColor', cssVars: ['--sidebar-selected-color'] },
  291. { id: IDS.SETTING_COLOR_INPUT_TEXT_COLOR, key: 'inputTextColor', cssVars: ['--sidebar-input-text'] },
  292. { id: IDS.SETTING_COLOR_BORDER_COLOR, key: 'borderColor', cssVars: ['--sidebar-border-color', '--sidebar-tool-btn-border', '--sidebar-input-border', '--sidebar-tool-btn-hover-border'] },
  293. { id: IDS.SETTING_COLOR_DIVIDER_COLOR, key: 'dividerColor', cssVars: ['--sidebar-section-border-color'] },
  294. { id: IDS.SETTING_COLOR_BTN_BG_COLOR, key: 'btnBgColor', cssVars: ['--sidebar-tool-btn-bg', '--sidebar-input-bg'] },
  295. { id: IDS.SETTING_COLOR_BTN_HOVER_BG_COLOR, key: 'btnHoverBgColor', cssVars: ['--sidebar-tool-btn-hover-bg'] },
  296. { id: IDS.SETTING_COLOR_ACTIVE_BG_COLOR, key: 'activeBgColor', cssVars: ['--sidebar-tool-btn-active-bg', '--sidebar-header-btn-active-bg'] },
  297. { id: IDS.SETTING_COLOR_ACTIVE_TEXT_COLOR, key: 'activeTextColor', cssVars: ['--sidebar-tool-btn-active-text', '--sidebar-header-btn-active-color', '--sidebar-tool-btn-text'] },
  298. { id: IDS.SETTING_COLOR_ACTIVE_BORDER_COLOR,key: 'activeBorderColor', cssVars: ['--sidebar-tool-btn-active-border'] },
  299. { id: IDS.SETTING_COLOR_HEADER_ICON_COLOR, key: 'headerIconColor', cssVars: ['--sidebar-header-btn-color'] },
  300. ];
  301.  
  302. /**
  303. * @constant {Object<string, string>} CSS
  304. * A centralized collection of all CSS class names used for styling and state management.
  305. * Follows a BEM-like convention (Block__Element--Modifier) for clarity and to prevent style conflicts.
  306. */
  307. const CSS = {
  308. // State Modifiers (BEM-like)
  309. IS_SIDEBAR_COLLAPSED: 'is-sidebar-collapsed',
  310. IS_SECTION_COLLAPSED: 'is-section-collapsed',
  311. IS_SELECTED: 'is-selected',
  312. IS_ACTIVE: 'is-active',
  313. IS_DRAGGING: 'is-dragging',
  314. IS_DRAG_OVER: 'is-drag-over',
  315. IS_ERROR_VISIBLE: 'is-error-visible',
  316. HAS_ERROR: 'has-error',
  317.  
  318. // Theme Classes
  319. THEME_LIGHT: 'gscs-theme-light',
  320. THEME_DARK: 'gscs-theme-dark',
  321. THEME_MINIMAL: 'gscs-theme-minimal',
  322. THEME_MINIMAL_LIGHT: 'gscs-theme-minimal--light',
  323. THEME_MINIMAL_DARK: 'gscs-theme-minimal--dark',
  324.  
  325. // Components & Blocks
  326. SIDEBAR_HEADER: 'gscs-sidebar__header',
  327. SIDEBAR_CONTENT_WRAPPER: 'gscs-sidebar__content-wrapper',
  328. DRAG_HANDLE: 'gscs-sidebar__drag-handle',
  329. SETTINGS_BUTTON: 'gscs-settings-button',
  330. HEADER_BUTTON: 'gscs-header-button',
  331. SECTION: 'gscs-section',
  332. FIXED_TOP_BUTTONS_ITEM: 'gscs-fixed-top-buttons__item',
  333. SECTION_TITLE: 'gscs-section__title',
  334. SECTION_CONTENT: 'gscs-section__content',
  335. FILTER_OPTION: 'gscs-filter-option',
  336. CHECKBOX_SITE: 'gscs-checkbox--site',
  337. CHECKBOX_FILETYPE: 'gscs-checkbox--filetype',
  338. BUTTON_APPLY_SITES: 'gscs-button--apply-sites',
  339. BUTTON_APPLY_FILETYPES: 'gscs-button--apply-filetypes',
  340. DATE_INPUT_LABEL: 'gscs-date-input__label',
  341. DATE_INPUT_FIELD: 'gscs-date-input__field',
  342. BUTTON: 'gscs-button',
  343. CUSTOM_LIST: 'gscs-custom-list',
  344. CUSTOM_LIST_ITEM_CONTROLS: 'gscs-custom-list__item-controls',
  345. BUTTON_EDIT_ITEM: 'gscs-button--edit-item',
  346. BUTTON_DELETE_ITEM: 'gscs-button--delete-item',
  347. CUSTOM_LIST_INPUT_GROUP: 'gscs-custom-list__input-group',
  348. BUTTON_ADD_CUSTOM: 'gscs-button--add-custom',
  349. SETTINGS_HEADER: 'gscs-settings__header',
  350. SETTINGS_CLOSE_BTN: 'gscs-settings__close-button',
  351. SETTINGS_TABS: 'gscs-settings__tabs',
  352. TAB_BUTTON: 'gscs-tab-button',
  353. SETTINGS_TAB_CONTENT: 'gscs-settings__tab-content',
  354. TAB_PANE: 'gscs-tab-pane',
  355. SETTING_ITEM: 'gscs-setting-item',
  356. SETTING_ITEM_LABEL_INLINE: 'gscs-setting-item__label--inline',
  357. SETTINGS_FOOTER: 'gscs-settings__footer',
  358. BUTTON_SAVE: 'gscs-button--save',
  359. BUTTON_CANCEL: 'gscs-button--cancel',
  360. BUTTON_RESET: 'gscs-button--reset',
  361. SETTING_ITEM_SIMPLE: 'gscs-setting-item--simple',
  362. SETTING_RANGE_VALUE: 'gscs-setting-item__range-value',
  363. SETTING_RANGE_HINT: 'gscs-setting-item__range-hint',
  364. SECTION_ORDER_LIST: 'gscs-section-order-list',
  365. INPUT_ERROR_MSG: 'gscs-input-error-message',
  366. DATE_RANGE_ERROR_MSG: 'gscs-date-range-error-message',
  367. MESSAGE_BAR: 'gscs-message-bar',
  368. MSG_INFO: 'gscs-message-bar--info',
  369. MSG_SUCCESS: 'gscs-message-bar--success',
  370. MSG_WARNING: 'gscs-message-bar--warning',
  371. MSG_ERROR: 'gscs-message-bar--error',
  372. BUTTON_MANAGE_CUSTOM: 'gscs-button--manage-custom',
  373. NOTIFICATION: 'gscs-notification',
  374. NTF_INFO: 'gscs-notification--info',
  375. NTF_SUCCESS: 'gscs-notification--success',
  376. NTF_WARNING: 'gscs-notification--warning',
  377. NTF_ERROR: 'gscs-notification--error',
  378. DRAG_ICON: 'gscs-drag-icon',
  379. FAVICON: 'gscs-favicon',
  380. BUTTON_REMOVE_FROM_LIST: 'gscs-button--remove-from-list',
  381. MODAL_BUTTON_ADD_NEW: 'gscs-modal__add-new-button',
  382. MODAL_PREDEFINED_CHOOSER: 'gscs-modal-predefined-chooser',
  383. MODAL_PREDEFINED_CHOOSER_ITEM: 'gscs-modal-predefined-chooser__item',
  384. SETTING_VALUE_HINT: 'gscs-setting-value-hint'
  385. };
  386.  
  387. /**
  388. * @constant {Object<string, string>} DATA_ATTR
  389. * A collection of `data-*` attribute names used to store state or metadata directly on DOM elements.
  390. */
  391. const DATA_ATTR = {
  392. FILTER_TYPE: 'filterType', FILTER_VALUE: 'filterValue', SITE_URL: 'siteUrl', SECTION_ID: 'sectionId',
  393. FILETYPE_VALUE: 'filetypeValue',
  394. LIST_ID: 'listId', INDEX: 'index', LISTENER_ATTACHED: 'listenerAttached', TAB: 'tab', MANAGE_TYPE: 'managetype',
  395. ITEM_TYPE: 'itemType', ITEM_ID: 'itemId'
  396. };
  397.  
  398. /**
  399. * @constant {string} STORAGE_KEY
  400. * The key used for storing the script's settings in the browser's local storage via GM_setValue/GM_getValue.
  401. */
  402. const STORAGE_KEY = 'googleSearchCustomSidebarSettings_v1';
  403.  
  404. /**
  405. * @constant {Object<string, string>} SVG_ICONS
  406. * A collection of SVG icon strings. Storing them as strings allows them to be easily injected into the DOM
  407. * without needing to fetch external files.
  408. */
  409. const SVG_ICONS = {
  410. 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>`,
  411. 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>`,
  412. 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>`,
  413. 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>`,
  414. 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>`,
  415. 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>`,
  416. 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>`,
  417. 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>`,
  418. 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>`,
  419. 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>`,
  420. 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>`,
  421. 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>`,
  422. 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>`,
  423. 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>`,
  424. 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>`,
  425. 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>`,
  426. 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>`
  427. };
  428.  
  429. /**
  430. * @constant {Object} SERVICE_SHORTCUT_CONFIG
  431. * A centralized configuration for all external service shortcut buttons.
  432. * This object drives the generic shortcut button creation function.
  433. */
  434. const SERVICE_SHORTCUT_CONFIG = {
  435. googleScholar: {
  436. id: IDS.TOOL_GOOGLE_SCHOLAR,
  437. svgIcon: SVG_ICONS.googleScholar,
  438. titleKey: 'tooltip_google_scholar_search',
  439. textKey: 'tool_google_scholar',
  440. serviceNameKey: 'service_name_google_scholar',
  441. baseUrl: 'https://scholar.google.com/scholar',
  442. queryParam: 'q',
  443. homepage: 'https://scholar.google.com/'
  444. },
  445. googleTrends: {
  446. id: IDS.TOOL_GOOGLE_TRENDS,
  447. svgIcon: SVG_ICONS.googleTrends,
  448. titleKey: 'tooltip_google_trends_search',
  449. textKey: 'tool_google_trends',
  450. serviceNameKey: 'service_name_google_trends',
  451. baseUrl: 'https://trends.google.com/trends/explore',
  452. queryParam: 'q',
  453. homepage: 'https://trends.google.com/trends/'
  454. },
  455. googleDatasetSearch: {
  456. id: IDS.TOOL_GOOGLE_DATASET_SEARCH,
  457. svgIcon: SVG_ICONS.googleDatasetSearch,
  458. titleKey: 'tooltip_google_dataset_search',
  459. textKey: 'tool_google_dataset_search',
  460. serviceNameKey: 'service_name_google_dataset_search',
  461. baseUrl: 'https://datasetsearch.research.google.com/search',
  462. queryParam: 'query',
  463. homepage: 'https://datasetsearch.research.google.com/'
  464. }
  465. };
  466.  
  467. /**
  468. * @constant {Object} PREDEFINED_OPTIONS
  469. * A data structure containing all built-in, non-customizable filter options
  470. * for various categories like language, country, etc. The text is stored as a key
  471. * for internationalization.
  472. */
  473. const PREDEFINED_OPTIONS = {
  474. 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' }, ],
  475. 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' }, ],
  476. 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' }, ],
  477. 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'}, ]
  478. };
  479.  
  480. /**
  481. * @constant {Object[]} ALL_SECTION_DEFINITIONS
  482. * The single source of truth for all sidebar sections. This array of objects defines
  483. * the properties and behavior of each section, such as its ID, type, title,
  484. * associated URL parameters, and keys for accessing settings.
  485. */
  486. const ALL_SECTION_DEFINITIONS = [
  487. { id: 'sidebar-section-language', type: 'filter', titleKey: 'section_language', scriptDefined: [{textKey:'filter_any_language',v:''}], param: 'lr', predefinedOptionsKey: 'language', customItemsKey: 'customLanguages', displayItemsKey: 'displayLanguages' },
  488. { id: 'sidebar-section-time', type: 'filter', titleKey: 'section_time', scriptDefined: [{textKey:'filter_any_time',v:''}], param: 'qdr', predefinedOptionsKey: 'time', customItemsKey: 'customTimeRanges' },
  489. { id: 'sidebar-section-filetype', type: 'filetype', titleKey: 'section_filetype', scriptDefined: [{ textKey: 'filter_any_format', v: '' }], param: 'as_filetype', predefinedOptionsKey: 'filetype', customItemsKey: 'customFiletypes' },
  490. {
  491. id: 'sidebar-section-occurrence',
  492. type: 'filter',
  493. titleKey: 'section_occurrence',
  494. scriptDefined: [
  495. { textKey: 'filter_occurrence_any', v: 'any' },
  496. { textKey: 'filter_occurrence_title', v: 'title' },
  497. { textKey: 'filter_occurrence_text', v: 'body' },
  498. { textKey: 'filter_occurrence_url', v: 'url' },
  499. { textKey: 'filter_occurrence_links', v: 'links' }
  500. ],
  501. param: 'as_occt'
  502. },
  503. { id: 'sidebar-section-country', type: 'filter', titleKey: 'section_country', scriptDefined: [{textKey:'filter_any_country',v:''}], param: 'cr', predefinedOptionsKey: 'country', customItemsKey: 'customCountries', displayItemsKey: 'displayCountries' },
  504. { id: 'sidebar-section-date-range', type: 'date', titleKey: 'section_date_range' },
  505. { id: 'sidebar-section-site-search', type: 'site', titleKey: 'section_site_search', scriptDefined: [{ textKey: 'filter_any_site', v:''}] },
  506. { id: 'sidebar-section-tools', type: 'tools', titleKey: 'section_tools' }
  507. ];
  508.  
  509. // --- Global State Variables ---
  510. let sidebar = null, systemThemeMediaQuery = null;
  511. const MIN_SIDEBAR_TOP_POSITION = 1;
  512. let debouncedSaveSettings;
  513. let globalMessageTimeout = null;
  514. /**
  515. * @module LocalizationService
  516. * Manages all internationalization (i18n) aspects of the script.
  517. * It merges built-in and external translation packs, detects the user's locale,
  518. * and provides a unified interface for retrieving translated strings.
  519. * This module ensures that all user-facing text can be easily localized.
  520. */
  521. const LocalizationService = (function() {
  522. const builtInTranslations = {
  523. 'en': {
  524. 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',
  525. section_occurrence: 'Keyword Location',
  526. 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',
  527. 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',
  528. filter_occurrence_links: 'In links to the page',
  529. 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)',
  530. tool_reset_filters: 'Reset Filters', tool_verbatim_search: 'Verbatim Search', tool_advanced_search: 'Advanced Search', tool_apply_date: 'Apply Dates',
  531. tool_personalization_toggle: 'Personalization', tool_apply_selected_sites: 'Apply Selected',
  532. tool_apply_selected_filetypes: 'Apply Selected',
  533. tool_google_scholar: 'Scholar',
  534. tooltip_google_scholar_search: 'Search current keywords on Google Scholar',
  535. service_name_google_scholar: 'Google Scholar',
  536. tool_google_trends: 'Trends',
  537. tooltip_google_trends_search: 'Explore current keywords on Google Trends',
  538. service_name_google_trends: 'Google Trends',
  539. tool_google_dataset_search: 'Dataset Search',
  540. tooltip_google_dataset_search: 'Search keywords on Google Dataset Search',
  541. service_name_google_dataset_search: 'Google Dataset Search',
  542. 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',
  543. settings_accordion_mode: 'Accordion Mode (only when "Remember State" is active)',
  544. settings_accordion_mode_hint_desc: 'When enabled, expanding one section will automatically collapse other open sections.',
  545. 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:',
  546. settings_scholar_location: 'Google Scholar Shortcut Location:',
  547. settings_trends_location: 'Google Trends Shortcut Location:',
  548. settings_dataset_search_location: 'Dataset Search Shortcut Location:',
  549. settings_enable_site_search_checkbox_mode: 'Enable Checkbox Mode for Site Search',
  550. settings_enable_site_search_checkbox_mode_hint: 'Allows selecting multiple favorite sites for a combined (OR) search.',
  551. settings_show_favicons: 'Show Favicons for Site Search',
  552. settings_show_favicons_hint: 'Displays a website icon next to single-site entries for better identification.',
  553. settings_enable_filetype_search_checkbox_mode: 'Enable Checkbox Mode for Filetype Search',
  554. settings_enable_filetype_search_checkbox_mode_hint: 'Allows selecting multiple filetypes for a combined (OR) search.',
  555. 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',
  556. settings_advanced_color_options: 'Advanced Color Options',
  557. settings_reset_colors_button: 'Reset Colors',
  558. settings_color_bg_color: 'Background Color',
  559. settings_color_text_color: 'Main Text Color',
  560. settings_color_link_color: 'Link & Title Color',
  561. settings_color_selected_color: 'Selected Item Text Color',
  562. settings_color_input_text_color: 'Input Field Text Color',
  563. settings_color_border_color: 'Main Border Color',
  564. settings_color_divider_color: 'Section Divider Color',
  565. settings_color_btn_bg_color: 'Button Background Color',
  566. settings_color_btn_hover_bg_color: 'Button Hover BG Color',
  567. settings_color_active_bg_color: 'Active Item BG Color',
  568. settings_color_active_text_color: 'Active Item Text/Icon Color',
  569. settings_color_active_border_color: 'Active Item Border Color',
  570. settings_color_header_icon_color: 'Header Icon Color',
  571. settings_visible_sections: 'Visible Sections:', settings_section_order: 'Adjust Sidebar Section Order (Drag & Drop):',
  572. settings_section_order_hint: '(Drag items to reorder. Only affects checked sections)',
  573. settings_no_orderable_sections: 'No visible sections to order.',
  574. settings_move_up_title: 'Move Up',
  575. settings_move_down_title: 'Move Down',
  576. settings_hide_google_logo: 'Hide Google Logo when sidebar is expanded',
  577. settings_hide_google_logo_hint: 'Useful if the sidebar is placed in the top-left corner with a minimal theme.',
  578. settings_custom_intro: 'Manage filter options for each section:',
  579. 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',
  580. modal_label_enable_predefined: 'Enable Predefined {type}:',
  581. modal_label_my_custom: 'My Custom {type}:',
  582. modal_label_display_options_for: 'Display Options for {type} (Drag to Sort):',
  583. modal_button_add_new_option: 'Add New Option...',
  584. modal_button_add_predefined_option: 'Add Predefined...',
  585. modal_button_add_custom_option: 'Add Custom...',
  586. modal_placeholder_name: 'Name', modal_placeholder_domain: 'Domain (e.g., site.com OR example.net/path)',
  587. modal_placeholder_text: 'Text', modal_placeholder_value: 'Value (e.g., pdf OR docx)',
  588. modal_hint_domain: 'Format: domain/path (e.g., `wikipedia.org/wiki/Page` or `site.com`). Use `OR` (case-insensitive, space separated) for multiple.',
  589. 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`',
  590. modal_hint_filetype: 'Format: extension (e.g., `pdf`). Use `OR` (case-insensitive, space separated) for multiple (e.g., `docx OR xls`).',
  591. modal_tooltip_domain: 'Enter domain(s) with optional path(s). Use OR for multiple, e.g., site.com/path OR example.org',
  592. 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',
  593. modal_tooltip_filetype: 'File extension(s). Use OR for multiple, e.g., pdf OR docx',
  594. 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',
  595. 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.',
  596. alert_no_more_predefined_to_add: 'No more predefined {type} options available to add.',
  597. alert_no_keywords_for_shortcut: 'No keywords found in current search to use for {service_name}.',
  598. alert_error_opening_link: 'Error opening link for {service_name}.',
  599. alert_generic_error: 'An unexpected error occurred. Please check the console or try again. Context: {context}',
  600. 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',
  601. },
  602. };
  603. let effectiveTranslations = JSON.parse(JSON.stringify(builtInTranslations));
  604. let _currentLocale = 'en';
  605.  
  606. /**
  607. * Merges external translation data (from the i18n companion script)
  608. * with the built-in English translations. It also ensures that all languages
  609. * have a complete set of keys, falling back to English if a key is missing.
  610. * @private
  611. */
  612. function _mergeExternalTranslations() {
  613. if (typeof window.GSCS_Namespace !== 'undefined' && typeof window.GSCS_Namespace.i18nPack === 'object' && typeof window.GSCS_Namespace.i18nPack.translations === 'object') {
  614. const externalTranslations = window.GSCS_Namespace.i18nPack.translations;
  615. for (const langCode in externalTranslations) {
  616. if (Object.prototype.hasOwnProperty.call(externalTranslations, langCode)) {
  617. if (!effectiveTranslations[langCode]) {
  618. effectiveTranslations[langCode] = {};
  619. }
  620. for (const key in externalTranslations[langCode]) {
  621. if (Object.prototype.hasOwnProperty.call(externalTranslations[langCode], key)) {
  622. effectiveTranslations[langCode][key] = externalTranslations[langCode][key];
  623. }
  624. }
  625. }
  626. }
  627. // After merging, ensure 'en' from builtInTranslations acts as a fallback for all known languages
  628. const englishDefaults = builtInTranslations.en;
  629. for (const langCode in effectiveTranslations) {
  630. if (langCode !== 'en' && Object.prototype.hasOwnProperty.call(effectiveTranslations, langCode)) {
  631. for (const key in englishDefaults) {
  632. if (Object.prototype.hasOwnProperty.call(englishDefaults, key) && typeof effectiveTranslations[langCode][key] === 'undefined') {
  633. effectiveTranslations[langCode][key] = englishDefaults[key];
  634. }
  635. }
  636. }
  637. }
  638.  
  639. } else {
  640. console.warn(`${LOG_PREFIX} [i18n] External i18n pack (window.GSCS_Namespace.i18nPack) not found or invalid. Using built-in translations only.`);
  641. }
  642. // Ensure all keys from builtInTranslations.en exist in 'en' to prevent errors
  643. // if i18n.js is older or missing keys.
  644. const ensureKeys = (lang, defaults) => {
  645. if (!effectiveTranslations[lang]) effectiveTranslations[lang] = {};
  646. for (const key in defaults) {
  647. if (!effectiveTranslations[lang][key]) {
  648. effectiveTranslations[lang][key] = defaults[key]; // Fallback to built-in English if key is missing in target lang
  649. }
  650. }
  651. };
  652. ensureKeys('en', builtInTranslations.en); // Ensure English is complete based on built-in
  653. }
  654.  
  655. /**
  656. * Detects the user's preferred language from the browser settings.
  657. * It attempts to match specific locales (e.g., "en-US") first, then generic
  658. * languages (e.g., "en"), and finally falls back to English.
  659. * @private
  660. * @returns {string} The detected locale code.
  661. */
  662. function _detectBrowserLocale() {
  663. let locale = 'en'; // Default
  664. try {
  665. if (navigator.languages && navigator.languages.length) {
  666. locale = navigator.languages[0];
  667. } else if (navigator.language) {
  668. locale = navigator.language;
  669. }
  670. } catch (e) {
  671. console.warn(`${LOG_PREFIX} [i18n] Error accessing navigator.language(s):`, e);
  672. }
  673.  
  674. // Try to match full locale (e.g., "zh-TW")
  675. if (effectiveTranslations[locale]) return locale;
  676.  
  677. // Try to match generic part (e.g., "zh" from "zh-TW")
  678. if (locale.includes('-')) {
  679. const parts = locale.split('-');
  680. if (parts.length > 0 && effectiveTranslations[parts[0]]) return parts[0];
  681. // Try "language-Script" (e.g., "zh-Hant") if applicable, though less common for userscripts
  682. if (parts.length > 2 && effectiveTranslations[`${parts[0]}-${parts[1]}`]) return `${parts[0]}-${parts[1]}`;
  683. }
  684. return 'en'; // Fallback to English
  685. }
  686.  
  687. /**
  688. * Updates the active locale based on user settings or browser detection.
  689. * If the user has selected a specific language, it's used; otherwise, it auto-detects.
  690. * @private
  691. * @param {Object} settingsToUse - The current settings object to read the interfaceLanguage from.
  692. */
  693. function _updateActiveLocale(settingsToUse) {
  694. let newLocale = 'en'; // Default
  695. const langSettingSource = (settingsToUse && Object.keys(settingsToUse).length > 0 && typeof settingsToUse.interfaceLanguage === 'string')
  696. ? settingsToUse
  697. : defaultSettings; // Fallback to defaultSettings if settingsToUse is empty/invalid
  698.  
  699. const userSelectedLang = langSettingSource.interfaceLanguage;
  700.  
  701. if (userSelectedLang && userSelectedLang !== 'auto') {
  702. if (effectiveTranslations[userSelectedLang]) {
  703. newLocale = userSelectedLang;
  704. } else if (userSelectedLang.includes('-')) {
  705. const genericLang = userSelectedLang.split('-')[0];
  706. if (effectiveTranslations[genericLang]) {
  707. newLocale = genericLang;
  708. } else {
  709. newLocale = _detectBrowserLocale(); // Fallback to browser if specific parts aren't found
  710. }
  711. } else {
  712. newLocale = _detectBrowserLocale(); // Fallback if selected lang doesn't exist
  713. }
  714. } else { // 'auto' or undefined
  715. newLocale = _detectBrowserLocale();
  716. }
  717.  
  718. if (_currentLocale !== newLocale) {
  719. _currentLocale = newLocale;
  720. }
  721. // Warn if the chosen language isn't exactly what was set (e.g. "fr-CA" setting becomes "fr" due to availability)
  722. if (userSelectedLang && userSelectedLang !== 'auto' && _currentLocale !== userSelectedLang && !userSelectedLang.startsWith(_currentLocale.split('-')[0])) {
  723. console.warn(`${LOG_PREFIX} [i18n] User selected language "${userSelectedLang}" was not fully available or matched. Using best match: "${_currentLocale}".`);
  724. }
  725. }
  726.  
  727. _mergeExternalTranslations(); // Merge external translations once at service creation
  728.  
  729. /**
  730. * Retrieves a translated string for a given key.
  731. * It searches in the current locale, then the generic part of the locale, and finally
  732. * falls back to English if the key is not found.
  733. * @param {string} key - The translation key (e.g., 'settingsTitle').
  734. * @param {Object} [replacements={}] - An object of placeholders to replace in the string (e.g., {siteUrl: 'example.com'}).
  735. * @returns {string} The translated (and formatted) string.
  736. */
  737. function getString(key, replacements = {}) {
  738. let str = `[ERR: ${key} @ ${_currentLocale}]`; // Default error string
  739. let found = false;
  740.  
  741. // 1. Try current locale
  742. if (effectiveTranslations[_currentLocale] && typeof effectiveTranslations[_currentLocale][key] !== 'undefined') {
  743. str = effectiveTranslations[_currentLocale][key];
  744. found = true;
  745. }
  746. // 2. If current locale has a generic part (e.g., "zh" from "zh-TW"), try that
  747. else if (_currentLocale.includes('-')) {
  748. const genericLang = _currentLocale.split('-')[0];
  749. if (effectiveTranslations[genericLang] && typeof effectiveTranslations[genericLang][key] !== 'undefined') {
  750. str = effectiveTranslations[genericLang][key];
  751. found = true;
  752. }
  753. }
  754.  
  755. // 3. If not found and current locale is not English, fallback to English
  756. if (!found && _currentLocale !== 'en') {
  757. if (effectiveTranslations['en'] && typeof effectiveTranslations['en'][key] !== 'undefined') {
  758. str = effectiveTranslations['en'][key];
  759. found = true;
  760. }
  761. }
  762.  
  763. // 4. If still not found (even in English), it's a critical miss
  764. if (!found) {
  765. if (!(effectiveTranslations['en'] && typeof effectiveTranslations['en'][key] !== 'undefined')) {
  766. console.error(`${LOG_PREFIX} [i18n] CRITICAL: Missing translation for key: "${key}" in BOTH locale: "${_currentLocale}" AND default locale "en".`);
  767. } else {
  768. // This case should ideally not be hit if English is complete in builtInTranslations
  769. str = effectiveTranslations['en'][key]; // Should have been caught by step 3 if _currentLocale wasn't 'en'
  770. found = true;
  771. }
  772. if(!found) str = `[ERR_NF: ${key}]`; // Final error if truly not found anywhere
  773. }
  774.  
  775. // Replace placeholders
  776. if (typeof str === 'string') {
  777. for (const placeholder in replacements) {
  778. if (Object.prototype.hasOwnProperty.call(replacements, placeholder)) {
  779. str = str.replace(new RegExp(`\\{${placeholder}\\}`, 'g'), replacements[placeholder]);
  780. }
  781. }
  782. } else {
  783. console.error(`${LOG_PREFIX} [i18n] CRITICAL: Translation for key "${key}" is not a string:`, str);
  784. return `[INVALID_TYPE_FOR_KEY: ${key}]`;
  785. }
  786. return str;
  787. }
  788.  
  789. return {
  790. getString: getString,
  791. getCurrentLocale: function() { return _currentLocale; },
  792. getTranslationsForLocale: function(locale = 'en') { return effectiveTranslations[locale] || effectiveTranslations['en']; },
  793. initializeBaseLocale: function() { _updateActiveLocale(defaultSettings); },
  794. updateActiveLocale: function(activeSettings) { _updateActiveLocale(activeSettings); },
  795. getAvailableLocales: function() {
  796. const locales = new Set(['auto', 'en']); // 'auto' and 'en' are always options
  797. Object.keys(effectiveTranslations).forEach(lang => {
  798. // Only add if it's a valid language pack (not just an empty object)
  799. if (Object.keys(effectiveTranslations[lang]).length > 0) {
  800. locales.add(lang);
  801. }
  802. });
  803. return Array.from(locales).sort((a, b) => {
  804. if (a === 'auto') return -1;
  805. if (b === 'auto') return 1;
  806. if (a === 'en' && b !== 'auto') return -1;
  807. if (b === 'en' && a !== 'auto') return 1;
  808.  
  809. let nameA = a, nameB = b;
  810. try { nameA = new Intl.DisplayNames([a],{type:'language'}).of(a); } catch(e){}
  811. try { nameB = new Intl.DisplayNames([b],{type:'language'}).of(b); } catch(e){}
  812. return nameA.localeCompare(nameB);
  813. });
  814. }
  815. };
  816. })();
  817.  
  818. // A convenient shorthand for accessing the localization service's getString method.
  819. const _ = LocalizationService.getString;
  820.  
  821. /**
  822. * @module Utils
  823. * A collection of general-purpose utility functions used throughout the script.
  824. * These functions are stateless and perform common tasks like debouncing,
  825. * deep object merging, and number clamping.
  826. */
  827. const Utils = {
  828. /**
  829. * Creates a debounced function that delays invoking `func` until after `wait`
  830. * milliseconds have elapsed since the last time the debounced function was invoked.
  831. * @param {Function} func - The function to debounce.
  832. * @param {number} wait - The number of milliseconds to delay.
  833. * @returns {Function} Returns the new debounced function.
  834. */
  835. debounce: function(func, wait) {
  836. let timeout;
  837. return function executedFunction(...args) {
  838. const context = this;
  839. const later = () => {
  840. timeout = null;
  841. func.apply(context, args);
  842. };
  843. clearTimeout(timeout);
  844. timeout = setTimeout(later, wait);
  845. };
  846. },
  847. /**
  848. * Recursively merges properties of one or more source objects into a target object.
  849. * If a key exists in both the target and source, and both values are objects,
  850. * it will recursively merge them. Otherwise, the value from the source will overwrite the target.
  851. * @param {Object} target - The object to merge properties into.
  852. * @param {Object} source - The object to merge properties from.
  853. * @returns {Object} The modified target object.
  854. */
  855. mergeDeep: function(target, source) {
  856. if (!source) return target; // If source is undefined or null, return target as is.
  857. target = target || {}; // Ensure target is an object if it's initially null/undefined.
  858.  
  859. for (const key in source) {
  860. if (Object.prototype.hasOwnProperty.call(source, key)) {
  861. const targetValue = target[key];
  862. const sourceValue = source[key];
  863.  
  864. if (sourceValue && typeof sourceValue === 'object' && !Array.isArray(sourceValue)) {
  865. // Recurse for nested objects
  866. target[key] = Utils.mergeDeep(targetValue, sourceValue);
  867. } else if (typeof sourceValue !== 'undefined') {
  868. // Assign if sourceValue is a primitive, array, or explicitly undefined
  869. target[key] = sourceValue;
  870. }
  871. // If sourceValue is undefined, target[key] remains unchanged (implicit else)
  872. }
  873. }
  874. return target;
  875. },
  876.  
  877. /**
  878. * Clamps a number within the inclusive lower and upper bounds.
  879. * @param {number} num - The number to clamp.
  880. * @param {number} min - The lower bound.
  881. * @param {number} max - The upper bound.
  882. * @returns {number} The clamped number.
  883. */
  884. clamp: function(num, min, max) {
  885. return Math.min(Math.max(num, min), max);
  886. },
  887.  
  888. /**
  889. * Parses a string that may contain an emoji or symbol at the beginning.
  890. * This is primarily used for country/region names that include a flag.
  891. * @param {string} fullText - The text to parse.
  892. * @returns {{icon: string, text: string}} An object with the extracted icon and the remaining text.
  893. */
  894. parseIconAndText: function(fullText) {
  895. // Regex to match one or more non-letter, non-number characters at the beginning, followed by optional whitespace
  896. const match = fullText.match(/^(\P{L}\P{N}\s*)+/u);
  897. let icon = '';
  898. let text = fullText;
  899.  
  900. if (match && match[0].trim() !== '') {
  901. icon = match[0].trim(); // Trim to remove trailing spaces from the icon part
  902. text = fullText.substring(icon.length).trim(); // Trim to remove leading spaces from the text part
  903. }
  904. return { icon, text };
  905. },
  906.  
  907. /**
  908. * Safely gets the current window's URL as a URL object.
  909. * @returns {URL|null} A URL object, or null if an error occurs.
  910. */
  911. getCurrentURL: function() {
  912. try {
  913. return new URL(window.location.href);
  914. } catch (e) {
  915. console.error(`${LOG_PREFIX} Error creating URL object:`, e);
  916. return null;
  917. }
  918. },
  919.  
  920. /**
  921. * Parses a string containing values separated by " OR " into an array.
  922. * The separator is case-insensitive and handles multiple spaces.
  923. * @param {string} valueString - The string to parse (e.g., "pdf OR docx").
  924. * @returns {string[]} An array of the parsed values.
  925. */
  926. parseCombinedValue: function(valueString) {
  927. if (typeof valueString !== 'string' || !valueString.trim()) {
  928. return [];
  929. }
  930. // Split by " OR " (case-insensitive, with spaces around OR)
  931. return valueString.split(/\s+OR\s+/i).map(s => s.trim()).filter(s => s.length > 0);
  932. },
  933.  
  934. /**
  935. * Cleans a specific operator (like 'site' or 'filetype') from the query string.
  936. * @param {string} query - The original query string.
  937. * @param {string} operator - The operator name to clean (e.g., 'site').
  938. * @returns {string} The cleaned query string.
  939. */
  940. _cleanQueryByOperator: function(query, operator) {
  941. if (!query || !operator) return '';
  942. // Create a dynamic regex to match only the specified operator
  943. const singleOperatorRegexComplex = new RegExp(`\\s*(?:\\(\\s*)?(?:(?:${operator}):[\\w.:\\/~%?#=&+-]+(?:\\s+OR\\s+|$))+[^)]*\\)?\\s*`, 'gi');
  944. const singleOperatorRegexSimple = new RegExp(`\\s*${operator}:[\\w.:\\/~%?#=&+-]+\\s*`, 'gi');
  945. let cleanedQuery = query.replace(singleOperatorRegexComplex, ' ');
  946. cleanedQuery = cleanedQuery.replace(singleOperatorRegexSimple, ' ');
  947. return cleanedQuery.replace(/\s\s+/g, ' ').trim();
  948. }
  949. };
  950. /**
  951. * @module NotificationManager
  952. * Handles the display of temporary, non-blocking notifications to the user.
  953. * It creates a dedicated container in the DOM and manages the lifecycle of
  954. * notification elements, including timed fade-outs.
  955. */
  956. const NotificationManager = (function() {
  957. let container = null;
  958.  
  959. /**
  960. * Initializes the notification container, creating and appending it to the DOM if it doesn't exist.
  961. */
  962. function init() {
  963. if (document.getElementById(IDS.NOTIFICATION_CONTAINER)) {
  964. container = document.getElementById(IDS.NOTIFICATION_CONTAINER);
  965. return;
  966. }
  967. container = document.createElement('div');
  968. container.id = IDS.NOTIFICATION_CONTAINER;
  969. if (document.body) {
  970. document.body.appendChild(container);
  971. } else {
  972. // This case should be rare as script runs at document-idle
  973. console.error(LOG_PREFIX + " NotificationManager.init(): document.body is not available!");
  974. container = null; // Ensure container is null if append fails
  975. }
  976. }
  977.  
  978. /**
  979. * Displays a notification message.
  980. * @param {string} messageKey - The localization key for the message.
  981. * @param {Object} [messageArgs={}] - Placeholders to replace in the message string.
  982. * @param {('info'|'success'|'warning'|'error')} [type='info'] - The type of notification, affecting its appearance.
  983. * @param {number} [duration=3000] - The duration in milliseconds before the notification fades out. A duration <= 0 creates a persistent notification.
  984. * @returns {HTMLElement|null} The created notification element, or null if the container is not available.
  985. */
  986. function show(messageKey, messageArgs = {}, type = 'info', duration = 3000) {
  987. if (!container) {
  988. // Fallback to alert if container isn't initialized
  989. const alertMsg = (typeof _ === 'function' && _(messageKey, messageArgs) && !(_(messageKey, messageArgs).startsWith('[ERR:')))
  990. ? _(messageKey, messageArgs)
  991. : `${messageKey} (args: ${JSON.stringify(messageArgs)})`; // Basic fallback if _ is not ready
  992. alert(alertMsg);
  993. return null;
  994. }
  995.  
  996. const notificationElement = document.createElement('div');
  997. notificationElement.classList.add(CSS.NOTIFICATION);
  998.  
  999. const typeClass = CSS[`NTF_${type.toUpperCase()}`] || CSS.NTF_INFO; // Fallback to info type
  1000. notificationElement.classList.add(typeClass);
  1001.  
  1002. notificationElement.textContent = _(messageKey, messageArgs);
  1003.  
  1004. if (duration <= 0) { // Persistent notification, add a close button
  1005. const closeButton = document.createElement('span');
  1006. closeButton.innerHTML = '×'; // Simple 'x'
  1007. closeButton.style.cursor = 'pointer';
  1008. closeButton.style.marginLeft = '10px';
  1009. closeButton.style.float = 'right'; // Position to the right
  1010. closeButton.onclick = () => notificationElement.remove();
  1011. notificationElement.appendChild(closeButton);
  1012. }
  1013.  
  1014. container.appendChild(notificationElement);
  1015.  
  1016. if (duration > 0) {
  1017. setTimeout(() => {
  1018. notificationElement.style.opacity = '0'; // Start fade out
  1019. setTimeout(() => notificationElement.remove(), 500); // Remove after fade out
  1020. }, duration);
  1021. }
  1022. return notificationElement; // Return the element for potential further manipulation
  1023. }
  1024.  
  1025. return { init: init, show: show };
  1026. })();
  1027.  
  1028. /**
  1029. * Creates a generic list item element for use in management modals.
  1030. * This function handles the creation of the item's text, drag handle, and control buttons (edit/delete).
  1031. * @param {number} index - The index of the item in its source array.
  1032. * @param {Object} item - The data object for the list item.
  1033. * @param {string} listId - The ID of the parent list element.
  1034. * @param {Object} mapping - The configuration mapping object for this list type from `getListMapping`.
  1035. * @returns {HTMLLIElement} The fully constructed list item element.
  1036. */
  1037. function createGenericListItem(index, item, listId, mapping) {
  1038. const listItem = document.createElement('li');
  1039. listItem.dataset[DATA_ATTR.INDEX] = index;
  1040. listItem.dataset[DATA_ATTR.LIST_ID] = listId;
  1041. listItem.dataset[DATA_ATTR.ITEM_ID] = item.id || item.value || item.url; // Unique ID for the item itself
  1042. listItem.draggable = true; // All modal list items are draggable
  1043.  
  1044. const dragIconSpan = document.createElement('span');
  1045. dragIconSpan.classList.add(CSS.DRAG_ICON);
  1046. dragIconSpan.innerHTML = SVG_ICONS.dragGrip;
  1047. listItem.appendChild(dragIconSpan);
  1048.  
  1049. const textSpan = document.createElement('span');
  1050.  
  1051. // Favicon logic for Site Search list in the modal
  1052. const currentSettings = SettingsManager.getCurrentSettings();
  1053. if (listId === IDS.SITES_LIST && currentSettings.showFaviconsForSiteSearch && item.url && !item.url.includes(' OR ')) {
  1054. const favicon = document.createElement('img');
  1055. favicon.src = `https://www.google.com/s2/favicons?sz=32&domain_url=${item.url}`;
  1056. favicon.classList.add(CSS.FAVICON);
  1057. favicon.loading = 'lazy';
  1058. textSpan.prepend(favicon);
  1059. }
  1060.  
  1061. let displayText = item.text;
  1062. let paramName = ''; // To show "param=value"
  1063.  
  1064. if (item.type === 'predefined' && item.originalKey) {
  1065. displayText = _(item.originalKey);
  1066. if (listId === IDS.COUNTRIES_LIST) { // Special handling for country icon+text
  1067. const parsed = Utils.parseIconAndText(displayText);
  1068. displayText = `${parsed.icon} ${parsed.text}`.trim();
  1069. }
  1070. }
  1071.  
  1072. // Determine param name for display
  1073. if (mapping) { // Mapping comes from getListMapping
  1074. if (listId === IDS.LANG_LIST) paramName = ALL_SECTION_DEFINITIONS.find(s => s.id === 'sidebar-section-language').param;
  1075. else if (listId === IDS.COUNTRIES_LIST) paramName = ALL_SECTION_DEFINITIONS.find(s => s.id === 'sidebar-section-country').param;
  1076. else if (listId === IDS.SITES_LIST) paramName = 'site'; // Site search uses `site:` in query
  1077. else if (listId === IDS.TIME_LIST) paramName = ALL_SECTION_DEFINITIONS.find(s => s.id === 'sidebar-section-time').param;
  1078. else if (listId === IDS.FT_LIST) {
  1079. const ftSection = ALL_SECTION_DEFINITIONS.find(s => s.id === 'sidebar-section-filetype');
  1080. if (ftSection) paramName = ftSection.param;
  1081. }
  1082. }
  1083.  
  1084. const valueForDisplay = item.value || item.url || _('value_empty');
  1085. const fullTextContent = `${displayText} (${paramName}=${valueForDisplay})`;
  1086. textSpan.appendChild(document.createTextNode(fullTextContent));
  1087. textSpan.title = fullTextContent;
  1088. listItem.appendChild(textSpan);
  1089.  
  1090. const controlsSpan = document.createElement('span');
  1091. controlsSpan.classList.add(CSS.CUSTOM_LIST_ITEM_CONTROLS);
  1092.  
  1093. // Determine if item is "custom" or "predefined" for button display
  1094. if (item.type === 'custom' || listId === IDS.SITES_LIST || listId === IDS.TIME_LIST || listId === IDS.FT_LIST) {
  1095. // Sites, Time, Filetype lists are always treated as "custom" in terms of editability
  1096. controlsSpan.innerHTML =
  1097. `<button class="${CSS.BUTTON_EDIT_ITEM}" title="${_('modal_button_edit_title')}">${SVG_ICONS.edit}</button> ` +
  1098. `<button class="${CSS.BUTTON_DELETE_ITEM}" title="${_('modal_button_delete_title')}">${SVG_ICONS.delete}</button>`;
  1099. listItem.dataset[DATA_ATTR.ITEM_TYPE] = 'custom';
  1100. } else if (item.type === 'predefined') {
  1101. // Languages, Countries in mixed mode can have predefined items that can be removed (not deleted from source)
  1102. controlsSpan.innerHTML =
  1103. `<button class="${CSS.BUTTON_REMOVE_FROM_LIST}" title="${_('modal_button_remove_from_list_title')}">${SVG_ICONS.removeFromList}</button>`;
  1104. listItem.dataset[DATA_ATTR.ITEM_TYPE] = 'predefined';
  1105. }
  1106. listItem.appendChild(controlsSpan);
  1107. return listItem;
  1108. }
  1109.  
  1110. /**
  1111. * Populates a list element within a modal with items.
  1112. * @param {string} listId - The ID of the <ul> element to populate.
  1113. * @param {Array<Object>} items - An array of item data objects to render.
  1114. * @param {Document|HTMLElement} [contextElement=document] - The context in which to find the list element.
  1115. */
  1116. function populateListInModal(listId, items, contextElement = document) {
  1117. const listElement = contextElement.querySelector(`#${listId}`);
  1118. if (!listElement) {
  1119. console.warn(`${LOG_PREFIX} List element not found: #${listId} in context`, contextElement);
  1120. return;
  1121. }
  1122. listElement.innerHTML = ''; // Clear existing items
  1123. const fragment = document.createDocumentFragment();
  1124. const mapping = getListMapping(listId); // Get mapping for param name display
  1125.  
  1126. if (!Array.isArray(items)) items = []; // Ensure items is an array
  1127.  
  1128. items.forEach((item, index) => {
  1129. fragment.appendChild(createGenericListItem(index, item, listId, mapping));
  1130. });
  1131. listElement.appendChild(fragment);
  1132. }
  1133.  
  1134. /**
  1135. * Retrieves the configuration object for a specific custom list type.
  1136. * This centralized mapping provides all necessary information for managing a list,
  1137. * such as the keys for accessing settings, DOM element selectors, and localization keys.
  1138. * @param {string} listId - The ID of the list to get the mapping for.
  1139. * @returns {Object|null} The configuration object, or null if not found.
  1140. */
  1141. function getListMapping(listId) {
  1142. const listMappings = {
  1143. [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 },
  1144. [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' },
  1145. [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' },
  1146. [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
  1147. [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
  1148. };
  1149. return listMappings[listId] || null;
  1150. }
  1151.  
  1152. /**
  1153. * Validates the format of a user-provided value in a custom item input field.
  1154. * It uses regular expressions to check against expected formats for different types
  1155. * (e.g., language codes, domains, filetypes). It also provides visual feedback on the input element.
  1156. * @param {HTMLInputElement} inputElement - The input element to validate.
  1157. * @returns {boolean} True if the input is valid or empty, false otherwise.
  1158. */
  1159. function validateCustomInput(inputElement) {
  1160. if (!inputElement) return false; // Should not happen if called correctly
  1161. const value = inputElement.value.trim();
  1162. const id = inputElement.id;
  1163. let isValid = false;
  1164. let isEmpty = value === '';
  1165.  
  1166. // Basic validation: name/text fields cannot be empty
  1167. 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) {
  1168. isValid = !isEmpty;
  1169. } else if (id === IDS.NEW_SITE_URL) {
  1170. // Allow domains with paths, or TLD/SLD
  1171. 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}$)/;
  1172. const parts = Utils.parseCombinedValue(value); // Handles " OR " separation
  1173. if (isEmpty) isValid = true;
  1174. else if (parts.length > 0) isValid = parts.every(part => singleSiteRegex.test(part));
  1175. else isValid = false;
  1176. } else if (id === IDS.NEW_LANG_VALUE) {
  1177. // Language code format: lang_xx or lang_xx-XX, multiple with |
  1178. 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);
  1179. } else if (id === IDS.NEW_TIME_VALUE) {
  1180. // Time value format: h, d, w, m, y, optionally followed by numbers
  1181. isValid = isEmpty || /^[hdwmy]\d*$/.test(value);
  1182. } else if (id === IDS.NEW_FT_VALUE) {
  1183. // Filetype format: extension, multiple with " OR "
  1184. const singleFiletypeRegex = /^[a-zA-Z0-9]+$/;
  1185. const parts = Utils.parseCombinedValue(value);
  1186. if (isEmpty) isValid = true;
  1187. else if (parts.length > 0) isValid = parts.every(part => singleFiletypeRegex.test(part));
  1188. else isValid = false;
  1189. } else if (id === IDS.NEW_COUNTRY_VALUE) {
  1190. // Country code format: countryXX (XX = uppercase country code)
  1191. isValid = isEmpty || /^country[A-Z]{2}$/.test(value);
  1192. }
  1193.  
  1194. // Visual feedback
  1195. inputElement.classList.remove('input-valid', 'input-invalid', CSS.HAS_ERROR); // Clear previous states
  1196. _clearInputError(inputElement); // Clear any existing error message for this input
  1197.  
  1198. if (!isEmpty) { // Only add validation classes if not empty
  1199. inputElement.classList.add(isValid ? 'input-valid' : 'input-invalid');
  1200. if (!isValid) inputElement.classList.add(CSS.HAS_ERROR); // Red border for error
  1201. }
  1202.  
  1203. return isValid || isEmpty; // Return true if format is valid OR if it's empty (emptiness check is separate)
  1204. }
  1205.  
  1206. /**
  1207. * Finds the dedicated error message element associated with a specific input field.
  1208. * @private
  1209. * @param {HTMLInputElement} inputElement - The input field.
  1210. * @returns {HTMLElement|null} The error message element, or null if not found.
  1211. */
  1212. function _getInputErrorElement(inputElement) {
  1213. if (!inputElement || !inputElement.id) return null;
  1214. // Try to find the specific error message span for this input
  1215. let errorEl = inputElement.nextElementSibling;
  1216. if (errorEl && errorEl.classList.contains(CSS.INPUT_ERROR_MSG) && errorEl.id === `${inputElement.id}-error-msg`) {
  1217. return errorEl;
  1218. }
  1219. // Fallback: search within parent div if structured that way
  1220. const parentDiv = inputElement.parentElement;
  1221. if (parentDiv) {
  1222. return parentDiv.querySelector(`#${inputElement.id}-error-msg`);
  1223. }
  1224. return null;
  1225. }
  1226.  
  1227. /**
  1228. * Displays a validation error message for a specific input field.
  1229. * @private
  1230. * @param {HTMLInputElement} inputElement - The input field to show the error for.
  1231. * @param {string} messageKey - The localization key for the error message.
  1232. * @param {Object} [messageArgs={}] - Placeholders for the error message.
  1233. */
  1234. function _showInputError(inputElement, messageKey, messageArgs = {}) {
  1235. if (!inputElement) return;
  1236. const errorElement = _getInputErrorElement(inputElement);
  1237. if (errorElement) {
  1238. errorElement.textContent = _(messageKey, messageArgs);
  1239. errorElement.classList.add(CSS.IS_ERROR_VISIBLE);
  1240. }
  1241. inputElement.classList.add(CSS.HAS_ERROR);
  1242. inputElement.classList.remove('input-valid'); // Remove valid class if error
  1243. }
  1244.  
  1245. /**
  1246. * Clears any validation error message and styling from an input field.
  1247. * @private
  1248. * @param {HTMLInputElement} inputElement - The input field to clear the error from.
  1249. */
  1250. function _clearInputError(inputElement) {
  1251. if (!inputElement) return;
  1252. const errorElement = _getInputErrorElement(inputElement);
  1253. if (errorElement) {
  1254. errorElement.textContent = '';
  1255. errorElement.classList.remove(CSS.IS_ERROR_VISIBLE);
  1256. }
  1257. inputElement.classList.remove(CSS.HAS_ERROR, 'input-invalid');
  1258. }
  1259.  
  1260. /**
  1261. * Clears all validation errors within a specific input group container.
  1262. * @private
  1263. * @param {HTMLElement} inputGroupElement - The container element for the input group.
  1264. */
  1265. function _clearAllInputErrorsInGroup(inputGroupElement) {
  1266. if (!inputGroupElement) return;
  1267. inputGroupElement.querySelectorAll(`input[type="text"]`).forEach(input => {
  1268. _clearInputError(input);
  1269. input.classList.remove('input-valid', 'input-invalid'); // Also clear validation classes
  1270. });
  1271. }
  1272.  
  1273. /**
  1274. * Shows a global message, typically in a message bar within the settings window.
  1275. * Can also fall back to the NotificationManager or a standard alert if the target element isn't found.
  1276. * @private
  1277. * @param {string} messageKey - The localization key for the message.
  1278. * @param {Object} [messageArgs={}] - Placeholders for the message string.
  1279. * @param {('info'|'success'|'warning'|'error')} [type='info'] - The type of message.
  1280. * @param {number} [duration=3000] - Duration in ms. A value <= 0 makes it persistent until cleared.
  1281. * @param {string} [targetElementId=IDS.SETTINGS_MESSAGE_BAR] - The ID of the message bar element.
  1282. */
  1283. function _showGlobalMessage(messageKey, messageArgs = {}, type = 'info', duration = 3000, targetElementId = IDS.SETTINGS_MESSAGE_BAR) {
  1284. const messageBar = document.getElementById(targetElementId);
  1285. if (!messageBar) {
  1286. // If specific target (like modal message bar) not found, try general notification or alert
  1287. if (targetElementId !== IDS.SETTINGS_MESSAGE_BAR && NotificationManager && typeof NotificationManager.show === 'function') {
  1288. NotificationManager.show(messageKey, messageArgs, type, duration > 0 ? duration : 5000); // Longer for notifications if persistent
  1289. } else {
  1290. const alertMsg = (typeof _ === 'function' && _(messageKey, messageArgs) && !(_(messageKey, messageArgs).startsWith('[ERR:')))
  1291. ? _(messageKey, messageArgs)
  1292. : `${messageKey} (args: ${JSON.stringify(messageArgs)})`;
  1293. alert(alertMsg);
  1294. }
  1295. return;
  1296. }
  1297.  
  1298. if (globalMessageTimeout && targetElementId === IDS.SETTINGS_MESSAGE_BAR) { // Clear previous timeout for main settings bar
  1299. clearTimeout(globalMessageTimeout);
  1300. globalMessageTimeout = null;
  1301. }
  1302.  
  1303. messageBar.textContent = _(messageKey, messageArgs);
  1304. messageBar.className = `${CSS.MESSAGE_BAR}`; // Reset classes
  1305. messageBar.classList.add(CSS[`MSG_${type.toUpperCase()}`] || CSS.MSG_INFO); // Add type-specific class
  1306. messageBar.style.display = 'block';
  1307.  
  1308. if (duration > 0 && targetElementId === IDS.SETTINGS_MESSAGE_BAR) {
  1309. globalMessageTimeout = setTimeout(() => {
  1310. messageBar.style.display = 'none';
  1311. messageBar.textContent = '';
  1312. messageBar.className = CSS.MESSAGE_BAR; // Reset classes
  1313. }, duration);
  1314. }
  1315. }
  1316. /**
  1317. * Validates input fields for a new or edited custom item and prepares the data object.
  1318. * @private
  1319. * @param {HTMLInputElement} textInput - The input for the item's display text.
  1320. * @param {HTMLInputElement} valueInput - The input for the item's value.
  1321. * @param {string} itemTypeName - The localized name of the item type (e.g., "Language") for error messages.
  1322. * @param {string} listId - The ID of the list the item belongs to.
  1323. * @returns {{isValid: boolean, text?: string, value?: string, errorField?: HTMLInputElement}} An object indicating validity and containing data or the field with an error.
  1324. */
  1325. function _validateAndPrepareCustomItemData(textInput, valueInput, itemTypeName, listId) {
  1326. if (!textInput || !valueInput) {
  1327. _showGlobalMessage('alert_edit_failed_missing_fields', {}, 'error', 0); // Persistent error
  1328. return { isValid: false };
  1329. }
  1330. _clearInputError(textInput);
  1331. _clearInputError(valueInput);
  1332.  
  1333. const text = textInput.value.trim();
  1334. const value = valueInput.value.trim();
  1335. let hint = '';
  1336.  
  1337. if (text === '') {
  1338. _showInputError(textInput, 'alert_enter_display_name', { type: itemTypeName });
  1339. textInput.focus();
  1340. return { isValid: false, errorField: textInput };
  1341. } else {
  1342. // If text is not empty, ensure no lingering error style
  1343. textInput.classList.remove(CSS.HAS_ERROR);
  1344. }
  1345.  
  1346.  
  1347. if (value === '') {
  1348. _showInputError(valueInput, 'alert_enter_value', { type: itemTypeName });
  1349. valueInput.focus();
  1350. return { isValid: false, errorField: valueInput };
  1351. } else {
  1352. const isValueFormatValid = validateCustomInput(valueInput); // This also handles visual feedback
  1353. if (!isValueFormatValid) {
  1354. if (valueInput.classList.contains('input-invalid')) {
  1355. valueInput.focus();
  1356. return { isValid: false, errorField: valueInput };
  1357. }
  1358. if (listId === IDS.COUNTRIES_LIST) hint = _('modal_tooltip_country');
  1359. else if (listId === IDS.LANG_LIST) hint = _('modal_tooltip_language');
  1360. else if (listId === IDS.TIME_LIST) hint = _('modal_tooltip_time');
  1361. else if (listId === IDS.FT_LIST) hint = _('modal_tooltip_filetype');
  1362. else if (listId === IDS.SITES_LIST) hint = _('modal_tooltip_domain');
  1363.  
  1364. _showInputError(valueInput, 'alert_invalid_value_format', { type: itemTypeName, hint: hint });
  1365. valueInput.focus();
  1366. return { isValid: false, errorField: valueInput };
  1367. }
  1368. }
  1369. return { isValid: true, text: text, value: value };
  1370. }
  1371.  
  1372. /**
  1373. * Checks if a custom item with the same display name already exists in a list.
  1374. * The check is case-insensitive.
  1375. * @private
  1376. * @param {string} text - The display text to check for duplicates.
  1377. * @param {Array<Object>} itemsToCheck - The array of items to check against.
  1378. * @param {string} listId - The ID of the list being checked.
  1379. * @param {number} editingIndex - The index of the item being edited, to exclude it from the check.
  1380. * @param {Object|null} editingItemInfoRef - Reference to the object holding information about the item currently being edited.
  1381. * @returns {boolean} True if a duplicate is found, false otherwise.
  1382. */
  1383. function _isDuplicateCustomItem(text, itemsToCheck, listId, editingIndex, editingItemInfoRef) {
  1384. const lowerText = text.toLowerCase();
  1385. return itemsToCheck.some((item, idx) => {
  1386. const itemIsCustom = item.type === 'custom' ||
  1387. listId === IDS.SITES_LIST ||
  1388. listId === IDS.TIME_LIST ||
  1389. listId === IDS.FT_LIST;
  1390. if (!itemIsCustom) return false;
  1391.  
  1392. if (editingItemInfoRef && editingItemInfoRef.listId === listId && editingIndex === idx) {
  1393. if (editingItemInfoRef.originalText?.toLowerCase() === lowerText) {
  1394. return false;
  1395. }
  1396. }
  1397. return item.text.toLowerCase() === lowerText;
  1398. });
  1399. }
  1400. /**
  1401. * Applies the appropriate theme classes to a given DOM element based on the current theme setting.
  1402. * It handles standard themes (light, dark, system) and minimal themes.
  1403. * @param {HTMLElement} element - The DOM element to apply the theme to.
  1404. * @param {string} themeSetting - The current theme setting (e.g., 'system', 'dark', 'minimal-light').
  1405. */
  1406. function applyThemeToElement(element, themeSetting) {
  1407. if (!element) return;
  1408. // Remove all potential theme classes first
  1409. element.classList.remove(
  1410. CSS.THEME_LIGHT, CSS.THEME_DARK,
  1411. CSS.THEME_MINIMAL, CSS.THEME_MINIMAL_LIGHT, CSS.THEME_MINIMAL_DARK
  1412. );
  1413.  
  1414. let effectiveTheme = themeSetting;
  1415. const isSettingsOrModal = element.id === IDS.SETTINGS_WINDOW ||
  1416. element.id === IDS.SETTINGS_OVERLAY ||
  1417. element.classList.contains('settings-modal-content') ||
  1418. element.classList.contains('settings-modal-overlay');
  1419.  
  1420. if (isSettingsOrModal) {
  1421. if (themeSetting === 'minimal-light') effectiveTheme = 'light';
  1422. else if (themeSetting === 'minimal-dark') effectiveTheme = 'dark';
  1423. }
  1424.  
  1425. switch (effectiveTheme) {
  1426. case 'dark':
  1427. element.classList.add(CSS.THEME_DARK);
  1428. break;
  1429. case 'minimal-light':
  1430. element.classList.add(CSS.THEME_MINIMAL, CSS.THEME_MINIMAL_LIGHT);
  1431. break;
  1432. case 'minimal-dark':
  1433. element.classList.add(CSS.THEME_MINIMAL, CSS.THEME_MINIMAL_DARK);
  1434. break;
  1435. case 'system':
  1436. const systemIsDark = systemThemeMediaQuery && systemThemeMediaQuery.matches;
  1437. element.classList.add(systemIsDark ? CSS.THEME_DARK : CSS.THEME_LIGHT);
  1438. break;
  1439. case 'light':
  1440. default:
  1441. element.classList.add(CSS.THEME_LIGHT);
  1442. break;
  1443. }
  1444. }
  1445.  
  1446. /**
  1447. * @module PredefinedOptionChooser
  1448. * A UI component that appears within a modal, allowing users to select and add
  1449. * predefined options (like languages or countries) to a sortable display list.
  1450. */
  1451. const PredefinedOptionChooser = (function() {
  1452. let _chooserContainer = null;
  1453. let _currentListId = null;
  1454. let _currentPredefinedSourceKey = null;
  1455. let _currentDisplayItemsArrayRef = null; // Reference to the array like settings.displayLanguages
  1456. let _currentModalContentContext = null; // The modal body where this chooser is shown
  1457. let _onAddCallback = null;
  1458.  
  1459. /**
  1460. * Builds the HTML for the predefined option chooser modal.
  1461. * @param {string} listId - The ID of the list being managed.
  1462. * @param {string} predefinedSourceKey - The key for the predefined options in PREDEFINED_OPTIONS.
  1463. * @param {Array<Object>} displayItemsArrayRef - A reference to the current array of items being displayed.
  1464. * @returns {string|null} The generated HTML string for the chooser, or null if no options are available.
  1465. */
  1466. function _buildChooserHTML(listId, predefinedSourceKey, displayItemsArrayRef) {
  1467. const allPredefinedSystemOptions = PREDEFINED_OPTIONS[predefinedSourceKey] || [];
  1468.  
  1469. const currentDisplayedValues = new Set(
  1470. displayItemsArrayRef.filter(item => item.type === 'predefined').map(item => item.value)
  1471. );
  1472.  
  1473. const availablePredefinedToAdd = allPredefinedSystemOptions.filter(
  1474. opt => !currentDisplayedValues.has(opt.value)
  1475. );
  1476.  
  1477. if (availablePredefinedToAdd.length === 0) {
  1478. const itemTypeName = getListMapping(listId)?.nameKey ? _(getListMapping(listId).nameKey) : predefinedSourceKey;
  1479. _showGlobalMessage('alert_no_more_predefined_to_add', { type: itemTypeName }, 'info', 3000, IDS.SETTINGS_MESSAGE_BAR);
  1480. return null;
  1481. }
  1482.  
  1483. const listItemsHTML = availablePredefinedToAdd.map(opt => {
  1484. let displayText = _(opt.textKey);
  1485. if (listId === IDS.COUNTRIES_LIST) {
  1486. const parsed = Utils.parseIconAndText(displayText);
  1487. displayText = `${parsed.icon} ${parsed.text}`.trim();
  1488. }
  1489. const sanitizedValueForId = opt.value.replace(/[^a-zA-Z0-9-_]/g, '');
  1490. return `
  1491. <li class="${CSS.MODAL_PREDEFINED_CHOOSER_ITEM}">
  1492. <input type="checkbox" value="${opt.value}" id="chooser-${sanitizedValueForId}">
  1493. <label for="chooser-${sanitizedValueForId}">${displayText}</label>
  1494. </li>`;
  1495. }).join('');
  1496.  
  1497. return `
  1498. <ul id="${IDS.MODAL_PREDEFINED_CHOOSER_LIST}">${listItemsHTML}</ul>
  1499. <div class="chooser-buttons" style="text-align: right; margin-top: 10px;">
  1500. <button id="${IDS.MODAL_PREDEFINED_CHOOSER_ADD_BTN}" class="${CSS.BUTTON}" style="margin-right: 5px;">${_('modal_button_add_title')}</button>
  1501. <button id="${IDS.MODAL_PREDEFINED_CHOOSER_CANCEL_BTN}" class="${CSS.BUTTON}">${_('settings_cancel_button')}</button>
  1502. </div>`;
  1503. }
  1504.  
  1505. /**
  1506. * Handles the click event of the "Add" button in the chooser. It gathers selected
  1507. * values and triggers the callback function.
  1508. * @private
  1509. */
  1510. function _handleAdd() {
  1511. if (!_chooserContainer) return;
  1512. const selectedValues = [];
  1513. _chooserContainer.querySelectorAll(`#${IDS.MODAL_PREDEFINED_CHOOSER_LIST} input[type="checkbox"]:checked`).forEach(cb => {
  1514. selectedValues.push(cb.value);
  1515. });
  1516.  
  1517. if (selectedValues.length > 0 && typeof _onAddCallback === 'function') {
  1518. _onAddCallback(selectedValues, _currentPredefinedSourceKey, _currentDisplayItemsArrayRef, _currentListId, _currentModalContentContext);
  1519. }
  1520. hide();
  1521. }
  1522.  
  1523. /**
  1524. * Shows the predefined option chooser UI.
  1525. * @param {string} manageType - The type of item being managed (e.g., 'language').
  1526. * @param {string} listId - The ID of the list to add items to.
  1527. * @param {string} predefinedSourceKey - The key to look up predefined options.
  1528. * @param {Array<Object>} displayItemsArrayRef - A reference to the settings array that will be modified.
  1529. * @param {HTMLElement} contextElement - The parent element where the chooser will be inserted.
  1530. * @param {Function} onAddCb - The callback function to execute when items are added.
  1531. */
  1532. function show(manageType, listId, predefinedSourceKey, displayItemsArrayRef, contextElement, onAddCb) {
  1533. hide();
  1534.  
  1535. _currentListId = listId;
  1536. _currentPredefinedSourceKey = predefinedSourceKey;
  1537. _currentDisplayItemsArrayRef = displayItemsArrayRef;
  1538. _currentModalContentContext = contextElement;
  1539. _onAddCallback = onAddCb;
  1540.  
  1541. const chooserHTML = _buildChooserHTML(listId, predefinedSourceKey, displayItemsArrayRef);
  1542. if (!chooserHTML) return;
  1543.  
  1544. _chooserContainer = document.createElement('div');
  1545. _chooserContainer.id = IDS.MODAL_PREDEFINED_CHOOSER_CONTAINER;
  1546. _chooserContainer.classList.add(CSS.MODAL_PREDEFINED_CHOOSER);
  1547. _chooserContainer.innerHTML = chooserHTML;
  1548.  
  1549. const addNewBtn = contextElement.querySelector(`#${IDS.MODAL_ADD_NEW_OPTION_BTN}`);
  1550. if (addNewBtn && addNewBtn.parentNode) {
  1551. addNewBtn.insertAdjacentElement('afterend', _chooserContainer);
  1552. } else {
  1553. const mainListElement = contextElement.querySelector(`#${listId}`);
  1554. mainListElement?.insertAdjacentElement('beforebegin', _chooserContainer);
  1555. }
  1556. _chooserContainer.style.display = 'block';
  1557.  
  1558. _chooserContainer.querySelector(`#${IDS.MODAL_PREDEFINED_CHOOSER_ADD_BTN}`).addEventListener('click', _handleAdd);
  1559. _chooserContainer.querySelector(`#${IDS.MODAL_PREDEFINED_CHOOSER_CANCEL_BTN}`).addEventListener('click', hide);
  1560. }
  1561.  
  1562. /**
  1563. * Hides and cleans up the chooser UI.
  1564. */
  1565. function hide() {
  1566. if (_chooserContainer) {
  1567. _chooserContainer.remove();
  1568. _chooserContainer = null;
  1569. }
  1570. _currentListId = null;
  1571. _currentPredefinedSourceKey = null;
  1572. _currentDisplayItemsArrayRef = null;
  1573. _currentModalContentContext = null;
  1574. _onAddCallback = null;
  1575. }
  1576.  
  1577. return {
  1578. show: show,
  1579. hide: hide,
  1580. isOpen: function() { return !!_chooserContainer; }
  1581. };
  1582. })();
  1583.  
  1584. /**
  1585. * @module ModalManager
  1586. * Manages the creation, display, and interaction logic for all modal dialogs.
  1587. * This module is responsible for the complex UI where users manage their custom
  1588. * lists of sites, languages, etc., including adding, editing, deleting, and reordering items.
  1589. */
  1590. const ModalManager = (function() {
  1591. let _currentModal = null;
  1592. let _currentModalContent = null;
  1593. let _editingItemInfo = null;
  1594. let _draggedListItem = null;
  1595.  
  1596. const modalConfigsData = {
  1597. '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' },
  1598. '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' },
  1599. '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' },
  1600. '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' },
  1601. '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' },
  1602. };
  1603.  
  1604. /**
  1605. * Creates the HTML for the section that allows users to enable/disable
  1606. * predefined options (e.g., for time ranges or filetypes).
  1607. * @private
  1608. * @param {string} currentOptionType - The key for the option type in `PREDEFINED_OPTIONS`.
  1609. * @param {string} typeNameKey - The localization key for the name of this option type.
  1610. * @param {Object} predefinedOptionsSource - A reference to the `PREDEFINED_OPTIONS` object.
  1611. * @param {Set<string>} enabledPredefinedValues - A set of the currently enabled predefined values.
  1612. * @returns {string} The generated HTML string.
  1613. */
  1614. function _createPredefinedOptionsSectionHTML(currentOptionType, typeNameKey, predefinedOptionsSource, enabledPredefinedValues) {
  1615. const label = _(typeNameKey);
  1616. const optionsHTML = (predefinedOptionsSource[currentOptionType] || []).map(option => {
  1617. const checkboxId = `predefined-${currentOptionType}-${option.value.replace(/[^a-zA-Z0-9-_]/g, '')}`;
  1618. const translatedOptionText = _(option.textKey);
  1619. const isChecked = enabledPredefinedValues.has(option.value);
  1620. return `
  1621. <li>
  1622. <input type="checkbox" id="${checkboxId}" value="${option.value}" data-option-type="${currentOptionType}" ${isChecked ? 'checked' : ''}>
  1623. <label for="${checkboxId}">${translatedOptionText}</label>
  1624. </li>`;
  1625. }).join('');
  1626.  
  1627. return `
  1628. <label style="font-weight: bold;">${_('modal_label_enable_predefined', { type: label })}</label>
  1629. <ul class="predefined-options-list" data-option-type="${currentOptionType}">${optionsHTML}</ul>`;
  1630. }
  1631. /**
  1632. * Creates the HTML for the list of custom/display items and the input fields
  1633. * for adding/editing them within a modal.
  1634. * @private
  1635. * @param {string} currentListId - The ID for the `<ul>` element.
  1636. * @param {string} textPlaceholderKey - The localization key for the text input's placeholder.
  1637. * @param {string} valuePlaceholderKey - The localization key for the value input's placeholder.
  1638. * @param {string} hintKey - The localization key for the hint text below the inputs.
  1639. * @param {string} formatTooltipKey - The localization key for the value input's tooltip.
  1640. * @param {string} itemTypeName - The localized name of the item type.
  1641. * @param {boolean} [isSortableMixed=false] - True if the list mixes user-custom and predefined items.
  1642. * @returns {string} The generated HTML string.
  1643. */
  1644. function _createModalListAndInputHTML(currentListId, textPlaceholderKey, valuePlaceholderKey, hintKey, formatTooltipKey, itemTypeName, isSortableMixed = false) {
  1645. const mapping = getListMapping(currentListId);
  1646. const typeNameToDisplay = itemTypeName || (mapping ? _(mapping.nameKey) : 'Items');
  1647.  
  1648. let headerHTML = '';
  1649. let addNewOptionButtonHTML = '';
  1650.  
  1651. if (isSortableMixed) {
  1652. headerHTML = `<label style="font-weight: bold; margin-top: 0.5em; display: block;">${_('modal_label_display_options_for', {type: typeNameToDisplay})}</label>`;
  1653. addNewOptionButtonHTML = `
  1654. <div style="margin-bottom: 0.5em;">
  1655. <button id="${IDS.MODAL_ADD_NEW_OPTION_BTN}" class="${CSS.MODAL_BUTTON_ADD_NEW} ${CSS.BUTTON}">${_('modal_button_add_new_option')}</button>
  1656. </div>`;
  1657. } else {
  1658. headerHTML = `<label style="font-weight: bold; margin-top: 0.5em; display: block;">${_('modal_label_my_custom', { type: typeNameToDisplay })}</label>`;
  1659. }
  1660.  
  1661. const textInputId = mapping ? mapping.textInput.substring(1) : `new-custom-${currentListId}-text`;
  1662. const valueInputId = mapping ? mapping.valueInput.substring(1) : `new-custom-${currentListId}-value`;
  1663. const addButtonId = mapping ? mapping.addButton.substring(1) : `add-custom-${currentListId}-button`;
  1664.  
  1665. const inputGroupHTML = `
  1666. <div class="${CSS.CUSTOM_LIST_INPUT_GROUP}">
  1667. <div>
  1668. <input type="text" id="${textInputId}" placeholder="${_(textPlaceholderKey)}">
  1669. <span id="${textInputId}-error-msg" class="${CSS.INPUT_ERROR_MSG}"></span>
  1670. </div>
  1671. <div>
  1672. <input type="text" id="${valueInputId}" placeholder="${_(valuePlaceholderKey)}" title="${_(formatTooltipKey)}">
  1673. <span id="${valueInputId}-error-msg" class="${CSS.INPUT_ERROR_MSG}"></span>
  1674. </div>
  1675. <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>
  1676. <button class="cancel-edit-button" style="display: none;" title="${_('modal_button_cancel_edit_title')}">${SVG_ICONS.close}</button>
  1677. </div>`;
  1678. const hintHTML = `<span class="${CSS.SETTING_VALUE_HINT}">${_(hintKey)}</span>`;
  1679.  
  1680. return `${addNewOptionButtonHTML}${headerHTML}<ul id="${currentListId}" class="${CSS.CUSTOM_LIST}"></ul>${inputGroupHTML}${hintHTML}`;
  1681. }
  1682.  
  1683. /**
  1684. * Resets the modal's input fields and buttons from an "editing" state back to a "new item" state.
  1685. * @private
  1686. * @param {HTMLElement} [contextElement=_currentModalContent] - The context in which to find the elements.
  1687. */
  1688. function _resetEditStateInternal(contextElement = _currentModalContent) {
  1689. if (_editingItemInfo) {
  1690. const mapping = getListMapping(_editingItemInfo.listId);
  1691. if (_editingItemInfo.addButton) {
  1692. _editingItemInfo.addButton.innerHTML = SVG_ICONS.add;
  1693. _editingItemInfo.addButton.title = _('modal_button_add_title');
  1694. _editingItemInfo.addButton.classList.remove('update-mode');
  1695. }
  1696. if (_editingItemInfo.cancelButton) {
  1697. _editingItemInfo.cancelButton.style.display = 'none';
  1698. }
  1699.  
  1700. if (mapping && contextElement) {
  1701. const textInput = contextElement.querySelector(mapping.textInput);
  1702. const valueInput = contextElement.querySelector(mapping.valueInput);
  1703. const inputGroup = textInput?.closest(`.${CSS.CUSTOM_LIST_INPUT_GROUP}`);
  1704. if(inputGroup) _clearAllInputErrorsInGroup(inputGroup);
  1705.  
  1706. if (textInput) { textInput.value = ''; textInput.classList.remove('input-valid', 'input-invalid', CSS.HAS_ERROR); _clearInputError(textInput); }
  1707. if (valueInput) { valueInput.value = ''; valueInput.classList.remove('input-valid', 'input-invalid', CSS.HAS_ERROR); _clearInputError(valueInput); }
  1708. }
  1709. }
  1710. _editingItemInfo = null;
  1711. }
  1712.  
  1713. /**
  1714. * Prepares the modal's input fields for editing an existing item by populating them
  1715. * with the item's data and changing the "Add" button to an "Update" button.
  1716. * @private
  1717. * @param {Object} item - The item data object.
  1718. * @param {number} index - The index of the item.
  1719. * @param {string} listId - The ID of the parent list.
  1720. * @param {Object} mapping - The configuration mapping for this list type.
  1721. * @param {HTMLElement} contextElement - The modal's content element.
  1722. */
  1723. function _prepareEditItemActionInternal(item, index, listId, mapping, contextElement) {
  1724. const textInput = contextElement.querySelector(mapping.textInput);
  1725. const valueInput = contextElement.querySelector(mapping.valueInput);
  1726. const addButton = contextElement.querySelector(mapping.addButton);
  1727. const cancelButton = addButton?.parentElement?.querySelector('.cancel-edit-button');
  1728.  
  1729. if (textInput && valueInput && addButton && cancelButton) {
  1730. if (_editingItemInfo && (_editingItemInfo.listId !== listId || _editingItemInfo.index !== index)) {
  1731. _resetEditStateInternal(contextElement);
  1732. }
  1733.  
  1734. textInput.value = item.text;
  1735. valueInput.value = item[mapping.valueKey] || item.value;
  1736.  
  1737. _editingItemInfo = {
  1738. listId,
  1739. index,
  1740. originalValue: item[mapping.valueKey] || item.value,
  1741. originalText: item.text,
  1742. addButton,
  1743. cancelButton,
  1744. arrayKey: mapping.itemsArrayKey || mapping.displayArrayKey
  1745. };
  1746.  
  1747. addButton.innerHTML = SVG_ICONS.update;
  1748. addButton.title = _('modal_button_update_title');
  1749. addButton.classList.add('update-mode');
  1750. cancelButton.style.display = 'inline-block';
  1751.  
  1752. validateCustomInput(valueInput);
  1753. validateCustomInput(textInput);
  1754. textInput.focus();
  1755. } else {
  1756. const errorSourceInput = textInput || valueInput || addButton?.closest(`.${CSS.CUSTOM_LIST_INPUT_GROUP}`)?.querySelector('input[type="text"]');
  1757. if (errorSourceInput) _showInputError(errorSourceInput, 'alert_edit_failed_missing_fields');
  1758. else _showGlobalMessage('alert_edit_failed_missing_fields', {}, 'error', 0, _currentModalContent?.querySelector(`#${IDS.SETTINGS_MESSAGE_BAR}`) ? IDS.SETTINGS_MESSAGE_BAR : null);
  1759. }
  1760. }
  1761.  
  1762. /**
  1763. * Handles clicks on edit, delete, or remove buttons within a custom list in a modal.
  1764. * @private
  1765. * @param {Event} event - The click event.
  1766. * @param {HTMLElement} contextElement - The modal's content element.
  1767. * @param {Array<Object>} itemsArrayRef - A reference to the array of items being modified.
  1768. */
  1769. function _handleCustomListActionsInternal(event, contextElement, itemsArrayRef) {
  1770. const button = event.target.closest(`button.${CSS.BUTTON_EDIT_ITEM}, button.${CSS.BUTTON_DELETE_ITEM}, button.${CSS.BUTTON_REMOVE_FROM_LIST}`);
  1771. if (!button) return;
  1772.  
  1773. const listItem = button.closest(`li[data-${DATA_ATTR.INDEX}][data-list-id]`);
  1774. if (!listItem) return;
  1775.  
  1776. const index = parseInt(listItem.dataset[DATA_ATTR.INDEX], 10);
  1777. const listId = listItem.getAttribute('data-list-id');
  1778.  
  1779. if (isNaN(index) || !listId || index < 0 || index >= itemsArrayRef.length) return;
  1780.  
  1781. const mapping = getListMapping(listId);
  1782. if (!mapping) return;
  1783.  
  1784. const item = itemsArrayRef[index];
  1785. if (!item) return;
  1786.  
  1787. const itemIsTrulyCustom = item.type === 'custom' ||
  1788. (!item.type && (listId === IDS.SITES_LIST || listId === IDS.TIME_LIST || listId === IDS.FT_LIST));
  1789.  
  1790. if (button.classList.contains(CSS.BUTTON_DELETE_ITEM) && itemIsTrulyCustom) {
  1791. if (confirm(_('confirm_delete_item', { name: item.text }))) {
  1792. if (_editingItemInfo && _editingItemInfo.listId === listId && _editingItemInfo.index === index) {
  1793. _resetEditStateInternal(contextElement);
  1794. }
  1795. itemsArrayRef.splice(index, 1);
  1796. mapping.populateFn(listId, itemsArrayRef, contextElement);
  1797. }
  1798. } else if (button.classList.contains(CSS.BUTTON_REMOVE_FROM_LIST) && item.type === 'predefined') {
  1799. if (confirm(_('confirm_remove_item_from_list', { name: (item.originalKey ? _(item.originalKey) : item.text) }))) {
  1800. itemsArrayRef.splice(index, 1);
  1801. mapping.populateFn(listId, itemsArrayRef, contextElement);
  1802. }
  1803. } else if (button.classList.contains(CSS.BUTTON_EDIT_ITEM) && itemIsTrulyCustom) {
  1804. _prepareEditItemActionInternal(item, index, listId, mapping, contextElement);
  1805. }
  1806. }
  1807.  
  1808. /**
  1809. * Handles the submission of the form for adding or updating a custom item.
  1810. * It performs validation, checks for duplicates, and then updates the item array.
  1811. * @private
  1812. * @param {string} listId - The ID of the list being modified.
  1813. * @param {HTMLElement} contextElement - The modal's content element.
  1814. * @param {Array<Object>} itemsArrayRef - A reference to the array of items.
  1815. */
  1816. function _handleCustomItemSubmitInternal(listId, contextElement, itemsArrayRef) {
  1817. const mapping = getListMapping(listId);
  1818. if (!mapping) return;
  1819.  
  1820. const itemTypeName = _(mapping.nameKey);
  1821. const textInput = contextElement.querySelector(mapping.textInput);
  1822. const valueInput = contextElement.querySelector(mapping.valueInput);
  1823.  
  1824. const inputGroup = textInput?.closest(`.${CSS.CUSTOM_LIST_INPUT_GROUP}`);
  1825. if (inputGroup) _clearAllInputErrorsInGroup(inputGroup);
  1826.  
  1827. const validationResult = _validateAndPrepareCustomItemData(textInput, valueInput, itemTypeName, listId);
  1828. if (!validationResult.isValid) {
  1829. if (validationResult.errorField) validationResult.errorField.focus();
  1830. return;
  1831. }
  1832.  
  1833. const { text, value } = validationResult;
  1834. const editingIdx = (_editingItemInfo && _editingItemInfo.listId === listId) ? _editingItemInfo.index : -1;
  1835.  
  1836. let isDuplicate;
  1837. if (mapping.isSortableMixed) {
  1838. isDuplicate = _isDuplicateCustomItem(text, itemsArrayRef, listId, editingIdx, _editingItemInfo);
  1839. } else {
  1840. isDuplicate = itemsArrayRef.some((item, idx) => {
  1841. if (editingIdx === idx && (_editingItemInfo?.originalText?.toLowerCase() === text.toLowerCase())) return false;
  1842. return item.text.toLowerCase() === text.toLowerCase();
  1843. });
  1844. }
  1845.  
  1846. if (isDuplicate) {
  1847. if (textInput) _showInputError(textInput, 'alert_duplicate_name', { name: text });
  1848. textInput?.focus();
  1849. return;
  1850. }
  1851.  
  1852. let newItemData;
  1853. if (listId === IDS.SITES_LIST) {
  1854. newItemData = { text: text, url: value };
  1855. } else if (mapping.isSortableMixed) {
  1856. newItemData = { id: value, text: text, value: value, type: 'custom' };
  1857. } else {
  1858. newItemData = { text: text, value: value };
  1859. }
  1860.  
  1861. const itemBeingEdited = (editingIdx > -1) ? itemsArrayRef[editingIdx] : null;
  1862. const itemBeingEditedIsCustom = itemBeingEdited &&
  1863. (itemBeingEdited.type === 'custom' ||
  1864. listId === IDS.SITES_LIST ||
  1865. listId === IDS.TIME_LIST ||
  1866. listId === IDS.FT_LIST);
  1867.  
  1868. if (editingIdx > -1 && itemBeingEditedIsCustom) {
  1869. itemsArrayRef[editingIdx] = {...itemsArrayRef[editingIdx], ...newItemData };
  1870. _resetEditStateInternal(contextElement);
  1871. } else {
  1872. itemsArrayRef.push(newItemData);
  1873. }
  1874.  
  1875. mapping.populateFn(listId, itemsArrayRef, contextElement);
  1876.  
  1877. if (!_editingItemInfo || _editingItemInfo.listId !== listId) {
  1878. if (textInput) { textInput.value = ''; _clearInputError(textInput); textInput.focus(); }
  1879. if (valueInput) { valueInput.value = ''; _clearInputError(valueInput); }
  1880. }
  1881. }
  1882.  
  1883. /**
  1884. * Determines which list item is the drop target during a drag-and-drop operation.
  1885. * @private
  1886. * @param {HTMLElement} container - The list container (`<ul>`).
  1887. * @param {number} y - The current y-coordinate of the cursor.
  1888. * @returns {HTMLElement|undefined} The list item element that is the drop target.
  1889. */
  1890. function _getDragAfterModalListItem(container, y) {
  1891. const draggableElements = [...container.querySelectorAll(`li[draggable="true"]:not(.${CSS.IS_DRAGGING})`)];
  1892. return draggableElements.reduce((closest, child) => {
  1893. const box = child.getBoundingClientRect();
  1894. const offset = y - box.top - box.height / 2;
  1895. if (offset < 0 && offset > closest.offset) {
  1896. return { offset: offset, element: child };
  1897. } else {
  1898. return closest;
  1899. }
  1900. }, { offset: Number.NEGATIVE_INFINITY }).element;
  1901. }
  1902.  
  1903. /**
  1904. * Handles the `dragstart` event for a list item in a modal.
  1905. * @private
  1906. * @param {DragEvent} event - The `dragstart` event.
  1907. */
  1908. function _handleModalListDragStart(event) {
  1909. if (!event.target.matches('li[draggable="true"]')) return;
  1910. _draggedListItem = event.target;
  1911. event.dataTransfer.effectAllowed = 'move';
  1912. event.dataTransfer.setData('text/plain', _draggedListItem.dataset.index);
  1913. _draggedListItem.classList.add(CSS.IS_DRAGGING);
  1914.  
  1915. const list = _draggedListItem.closest('ul');
  1916. if (list) { list.querySelectorAll(`li:not(.${CSS.IS_DRAGGING})`).forEach(li => li.style.pointerEvents = 'none'); }
  1917. }
  1918.  
  1919. /**
  1920. * Handles the `dragover` event to provide visual feedback for the drop target.
  1921. * @private
  1922. * @param {DragEvent} event - The `dragover` event.
  1923. */
  1924. function _handleModalListDragOver(event) {
  1925. event.preventDefault();
  1926. const listElement = event.currentTarget;
  1927. listElement.querySelectorAll(`li.${CSS.IS_DRAG_OVER}`).forEach(li => li.classList.remove(CSS.IS_DRAG_OVER));
  1928.  
  1929. const targetItem = event.target.closest('li[draggable="true"]');
  1930. if (targetItem && targetItem !== _draggedListItem) {
  1931. targetItem.classList.add(CSS.IS_DRAG_OVER);
  1932. } else {
  1933. const afterElement = _getDragAfterModalListItem(listElement, event.clientY);
  1934. if (afterElement) {
  1935. afterElement.classList.add(CSS.IS_DRAG_OVER);
  1936. }
  1937. }
  1938. }
  1939. /**
  1940. * Handles the `dragleave` event to clear visual feedback.
  1941. * @private
  1942. * @param {DragEvent} event - The `dragleave` event.
  1943. */
  1944. function _handleModalListDragLeave(event) {
  1945. const listElement = event.currentTarget;
  1946. if (event.relatedTarget && listElement.contains(event.relatedTarget)) return;
  1947. listElement.querySelectorAll(`li.${CSS.IS_DRAG_OVER}`).forEach(li => li.classList.remove(CSS.IS_DRAG_OVER));
  1948. }
  1949.  
  1950. /**
  1951. * Handles the `drop` event to reorder the items in the backing array.
  1952. * @private
  1953. * @param {DragEvent} event - The `drop` event.
  1954. * @param {string} listId - The ID of the list being modified.
  1955. * @param {Array<Object>} itemsArrayRef - A reference to the array of items.
  1956. */
  1957. function _handleModalListDrop(event, listId, itemsArrayRef) {
  1958. event.preventDefault();
  1959. if (!_draggedListItem) return;
  1960.  
  1961. const draggedIndexOriginal = parseInt(event.dataTransfer.getData('text/plain'), 10);
  1962. if (isNaN(draggedIndexOriginal) || draggedIndexOriginal < 0 || draggedIndexOriginal >= itemsArrayRef.length) {
  1963. _handleModalListDragEnd(event.currentTarget);
  1964. return;
  1965. }
  1966.  
  1967. const listElement = event.currentTarget;
  1968. const mapping = getListMapping(listId);
  1969. if (!mapping) { _handleModalListDragEnd(listElement); return; }
  1970.  
  1971. const draggedItemData = itemsArrayRef[draggedIndexOriginal];
  1972. if (!draggedItemData) { _handleModalListDragEnd(listElement); return; }
  1973.  
  1974. const itemsWithoutDragged = itemsArrayRef.filter((item, index) => index !== draggedIndexOriginal);
  1975. const afterElement = _getDragAfterModalListItem(listElement, event.clientY);
  1976. let newIndexInSplicedArray;
  1977.  
  1978. if (afterElement) {
  1979. const originalIndexOfAfterElement = parseInt(afterElement.dataset.index, 10);
  1980. let countSkipped = 0; newIndexInSplicedArray = -1;
  1981. for(let i=0; i < itemsArrayRef.length; i++) {
  1982. if (i === draggedIndexOriginal) continue;
  1983. if (i === originalIndexOfAfterElement) {
  1984. newIndexInSplicedArray = countSkipped;
  1985. break;
  1986. }
  1987. countSkipped++;
  1988. }
  1989. if (newIndexInSplicedArray === -1 && originalIndexOfAfterElement === itemsArrayRef.length -1 && draggedIndexOriginal < originalIndexOfAfterElement) {
  1990. newIndexInSplicedArray = itemsWithoutDragged.length;
  1991. } else if (newIndexInSplicedArray === -1) {
  1992. newIndexInSplicedArray = itemsWithoutDragged.length;
  1993. }
  1994. } else {
  1995. newIndexInSplicedArray = itemsWithoutDragged.length;
  1996. }
  1997.  
  1998. itemsWithoutDragged.splice(newIndexInSplicedArray, 0, draggedItemData);
  1999. itemsArrayRef.length = 0;
  2000. itemsWithoutDragged.forEach(item => itemsArrayRef.push(item));
  2001.  
  2002. _handleModalListDragEnd(listElement);
  2003. mapping.populateFn(listId, itemsArrayRef, _currentModalContent);
  2004.  
  2005. const newLiElements = listElement.querySelectorAll('li');
  2006. newLiElements.forEach((li, idx) => {
  2007. li.dataset.index = idx;
  2008. });
  2009. }
  2010.  
  2011. /**
  2012. * Cleans up the state and styling after a drag-and-drop operation ends.
  2013. * @private
  2014. * @param {HTMLElement} listElement - The list container element.
  2015. */
  2016. function _handleModalListDragEnd(listElement) {
  2017. if (_draggedListItem) {
  2018. _draggedListItem.classList.remove(CSS.IS_DRAGGING);
  2019. }
  2020. _draggedListItem = null;
  2021. (listElement || _currentModalContent)?.querySelectorAll(`li.${CSS.IS_DRAG_OVER}`).forEach(li => li.classList.remove(CSS.IS_DRAG_OVER));
  2022. _currentModalContent?.querySelectorAll(`ul.${CSS.CUSTOM_LIST} li[draggable="true"]`).forEach(li => li.style.pointerEvents = '');
  2023. }
  2024.  
  2025. /**
  2026. * Adds a new predefined item to a display list in a modal.
  2027. * @private
  2028. * @param {string[]} selectedValues - The values of the predefined items to add.
  2029. * @param {string} predefinedSourceKey - The key for looking up the predefined item data.
  2030. * @param {Array<Object>} displayItemsArrayRef - A reference to the array of items to modify.
  2031. * @param {string} listIdToUpdate - The ID of the list to repopulate.
  2032. * @param {HTMLElement} modalContentContext - The modal's content element.
  2033. */
  2034. function _addPredefinedItemsToModalList(selectedValues, predefinedSourceKey, displayItemsArrayRef, listIdToUpdate, modalContentContext) {
  2035. const mapping = getListMapping(listIdToUpdate);
  2036. if (!mapping) return;
  2037.  
  2038. selectedValues.forEach(value => {
  2039. const predefinedOpt = PREDEFINED_OPTIONS[predefinedSourceKey]?.find(p => p.value === value);
  2040. if (predefinedOpt && !displayItemsArrayRef.some(item => item.value === value && item.type === 'predefined')) {
  2041. displayItemsArrayRef.push({
  2042. id: predefinedOpt.value,
  2043. text: _(predefinedOpt.textKey),
  2044. value: predefinedOpt.value,
  2045. type: 'predefined',
  2046. originalKey: predefinedOpt.textKey
  2047. });
  2048. }
  2049. });
  2050. mapping.populateFn(listIdToUpdate, displayItemsArrayRef, modalContentContext);
  2051. }
  2052.  
  2053. /**
  2054. * Binds all necessary event listeners for a modal's content.
  2055. * Uses event delegation to efficiently handle events for list items and controls.
  2056. * @private
  2057. * @param {HTMLElement} modalContent - The modal's content element.
  2058. * @param {Array<Object>} itemsArrayRef - A reference to the array of items being managed.
  2059. * @param {string|null} [listIdForDragDrop=null] - The ID of the list that should have drag-and-drop enabled.
  2060. */
  2061. function _bindModalContentEventsInternal(modalContent, itemsArrayRef, listIdForDragDrop = null) {
  2062. if (!modalContent || modalContent.dataset.modalEventsBound === 'true') return;
  2063.  
  2064. modalContent.addEventListener('click', (event) => {
  2065. const target = event.target;
  2066. const listIdForAction = listIdForDragDrop || target.closest('[data-list-id]')?.dataset.listId || target.closest(`.${CSS.BUTTON_ADD_CUSTOM}`)?.dataset.listId;
  2067.  
  2068. const addNewOptionButton = target.closest(`#${IDS.MODAL_ADD_NEW_OPTION_BTN}`);
  2069. if (addNewOptionButton && listIdForAction) {
  2070. const mapping = getListMapping(listIdForAction);
  2071. const configForModal = modalConfigsData[Object.keys(modalConfigsData).find(key => modalConfigsData[key].listId === listIdForAction)];
  2072.  
  2073. if (mapping && configForModal && configForModal.predefinedSourceKey && configForModal.isSortableMixed) {
  2074. PredefinedOptionChooser.show(
  2075. configForModal.manageType, listIdForAction, configForModal.predefinedSourceKey,
  2076. itemsArrayRef, modalContent, _addPredefinedItemsToModalList
  2077. );
  2078. } else if (mapping) {
  2079. modalContent.querySelector(mapping.textInput)?.focus();
  2080. }
  2081. return;
  2082. }
  2083.  
  2084. const addButton = target.closest(`.${CSS.BUTTON_ADD_CUSTOM}.custom-list-action-button`);
  2085. const itemControlButton = target.closest(`button.${CSS.BUTTON_EDIT_ITEM}, button.${CSS.BUTTON_DELETE_ITEM}, button.${CSS.BUTTON_REMOVE_FROM_LIST}`);
  2086. const cancelEditButton = target.closest('.cancel-edit-button');
  2087.  
  2088. if (itemControlButton && listIdForAction) { _handleCustomListActionsInternal(event, modalContent, itemsArrayRef); }
  2089. else if (addButton && listIdForAction) { _handleCustomItemSubmitInternal(listIdForAction, modalContent, itemsArrayRef); }
  2090. else if (cancelEditButton) { _resetEditStateInternal(modalContent); }
  2091. });
  2092.  
  2093. modalContent.addEventListener('input', (event) => {
  2094. const target = event.target;
  2095. if (target.matches('input[type="text"]')) {
  2096. _clearInputError(target);
  2097. validateCustomInput(target);
  2098. }
  2099. });
  2100.  
  2101. if (listIdForDragDrop) {
  2102. const draggableListElement = modalContent.querySelector(`#${listIdForDragDrop}`);
  2103. if (draggableListElement && draggableListElement.dataset.dragEventsBound !== 'true') {
  2104. draggableListElement.dataset.dragEventsBound = 'true';
  2105. draggableListElement.addEventListener('dragstart', _handleModalListDragStart);
  2106. draggableListElement.addEventListener('dragover', _handleModalListDragOver);
  2107. draggableListElement.addEventListener('dragleave', _handleModalListDragLeave);
  2108. draggableListElement.addEventListener('drop', (e) => _handleModalListDrop(e, listIdForDragDrop, itemsArrayRef));
  2109. draggableListElement.addEventListener('dragend', () => _handleModalListDragEnd(draggableListElement));
  2110. }
  2111. }
  2112. modalContent.dataset.modalEventsBound = 'true';
  2113. }
  2114.  
  2115. return {
  2116. /**
  2117. * Shows a modal dialog.
  2118. * @param {string} titleKey - The localization key for the modal's title.
  2119. * @param {string} contentHTML - The HTML string to be injected into the modal's body.
  2120. * @param {Function} onCompleteCallback - A callback function to execute when the user clicks the "Done" button.
  2121. * @param {string} currentTheme - The current theme to apply to the modal.
  2122. * @returns {HTMLElement} The content element of the created modal.
  2123. */
  2124. show: function(titleKey, contentHTML, onCompleteCallback, currentTheme) {
  2125. this.hide();
  2126.  
  2127. _currentModal = document.createElement('div');
  2128. _currentModal.className = 'settings-modal-overlay';
  2129. applyThemeToElement(_currentModal, currentTheme);
  2130.  
  2131. _currentModalContent = document.createElement('div');
  2132. _currentModalContent.className = 'settings-modal-content';
  2133. applyThemeToElement(_currentModalContent, currentTheme);
  2134.  
  2135. const headerHTML = `
  2136. <div class="settings-modal-header">
  2137. <h4>${_(titleKey)}</h4>
  2138. <button class="settings-modal-close-btn" title="${_('settings_close_button_title')}">${SVG_ICONS.close}</button>
  2139. </div>`;
  2140. const bodyHTML = `<div class="settings-modal-body">${contentHTML}</div>`;
  2141. const footerHTML = `
  2142. <div class="settings-modal-footer">
  2143. <button class="modal-complete-btn">${_('modal_button_complete')}</button>
  2144. </div>`;
  2145.  
  2146. _currentModalContent.innerHTML = headerHTML + bodyHTML + footerHTML;
  2147. _currentModal.appendChild(_currentModalContent);
  2148.  
  2149. const self = this;
  2150. const closeModalHandler = (event) => {
  2151. if (event.target === _currentModal || event.target.closest('.settings-modal-close-btn')) {
  2152. self.hide(true);
  2153. }
  2154. };
  2155. const completeModalHandler = () => {
  2156. if (typeof onCompleteCallback === 'function') {
  2157. onCompleteCallback(_currentModalContent);
  2158. }
  2159. self.hide(false);
  2160. };
  2161.  
  2162. _currentModal.addEventListener('click', closeModalHandler);
  2163. _currentModalContent.querySelector('.modal-complete-btn').addEventListener('click', completeModalHandler);
  2164. _currentModalContent.querySelector('.settings-modal-close-btn').addEventListener('click', () => self.hide(true));
  2165. _currentModalContent.addEventListener('click', (event) => event.stopPropagation());
  2166.  
  2167. document.body.appendChild(_currentModal);
  2168. return _currentModalContent;
  2169. },
  2170.  
  2171. /**
  2172. * Hides and destroys the currently visible modal.
  2173. * @param {boolean} [isCancel=false] - Indicates if the hide action was a cancellation.
  2174. */
  2175. hide: function(isCancel = false) {
  2176. PredefinedOptionChooser.hide();
  2177. if (_currentModal) {
  2178. const inputGroup = _currentModalContent?.querySelector(`.${CSS.CUSTOM_LIST_INPUT_GROUP}`);
  2179. if (inputGroup) _clearAllInputErrorsInGroup(inputGroup);
  2180. _resetEditStateInternal();
  2181. _currentModal.remove();
  2182. }
  2183. _currentModal = null;
  2184. _currentModalContent = null;
  2185. _handleModalListDragEnd();
  2186. },
  2187.  
  2188. /**
  2189. * Opens a specific type of management modal based on the provided configuration.
  2190. * @param {string} manageType - The type of items to manage (e.g., 'site', 'language').
  2191. * @param {Object} currentSettingsRef - A reference to the main settings object.
  2192. * @param {Object} PREDEFINED_OPTIONS_REF - A reference to the predefined options data.
  2193. * @param {Function} onModalCompleteCallback - The callback to execute when the modal is completed.
  2194. */
  2195. openManageCustomOptions: function(manageType, currentSettingsRef, PREDEFINED_OPTIONS_REF, onModalCompleteCallback) {
  2196. const config = modalConfigsData[manageType];
  2197. if (!config) { console.error("Error: Could not get config for manageType:", manageType); return; }
  2198.  
  2199. const mapping = getListMapping(config.listId);
  2200. if (!mapping) { console.error("Error: Could not get mapping for listId:", config.listId); return; }
  2201.  
  2202. const tempItems = JSON.parse(JSON.stringify(currentSettingsRef[config.itemsArrayKey] || []));
  2203. let contentHTML = '';
  2204. const itemTypeNameForDisplay = _(mapping.nameKey);
  2205.  
  2206. if (config.isSortableMixed) {
  2207. contentHTML += _createModalListAndInputHTML(config.listId, config.textPKey, config.valPKey, config.hintKey, config.fmtKey, itemTypeNameForDisplay, true);
  2208. } else if (config.hasPredefinedToggles && config.predefinedSourceKey && PREDEFINED_OPTIONS_REF[config.predefinedSourceKey]) {
  2209. const enabledValues = new Set(currentSettingsRef.enabledPredefinedOptions[config.predefinedSourceKey] || []);
  2210. contentHTML += _createPredefinedOptionsSectionHTML(config.predefinedSourceKey, mapping.nameKey, PREDEFINED_OPTIONS_REF, enabledValues);
  2211. contentHTML += '<hr style="margin: 1em 0;">';
  2212. contentHTML += _createModalListAndInputHTML(config.listId, config.textPKey, config.valPKey, config.hintKey, config.fmtKey, itemTypeNameForDisplay, false);
  2213. } else {
  2214. contentHTML += _createModalListAndInputHTML(config.listId, config.textPKey, config.valPKey, config.hintKey, config.fmtKey, itemTypeNameForDisplay, false);
  2215. }
  2216.  
  2217. const modalContentElement = this.show(
  2218. config.modalTitleKey,
  2219. contentHTML,
  2220. (modalContent) => {
  2221. let newEnabledPredefs = null;
  2222. if (config.hasPredefinedToggles && config.predefinedSourceKey) {
  2223. newEnabledPredefs = Array.from(modalContent.querySelectorAll(`.predefined-options-list input[data-option-type="${config.predefinedSourceKey}"]:checked`)).map(cb => cb.value);
  2224. }
  2225. onModalCompleteCallback(tempItems, newEnabledPredefs, config.itemsArrayKey, config.predefinedSourceKey, config.customItemsMasterKey, config.isSortableMixed, manageType);
  2226. },
  2227. currentSettingsRef.theme
  2228. );
  2229.  
  2230. if (modalContentElement) {
  2231. if (mapping && mapping.populateFn) {
  2232. mapping.populateFn(config.listId, tempItems, modalContentElement);
  2233. }
  2234. _bindModalContentEventsInternal(modalContentElement, tempItems, config.listId);
  2235. }
  2236. },
  2237.  
  2238. /**
  2239. * Globally resets the edit state, useful when closing modals or switching tabs.
  2240. */
  2241. resetEditStateGlobally: function() { _resetEditStateInternal(_currentModalContent || document); },
  2242.  
  2243. /**
  2244. * Checks if a modal is currently open.
  2245. * @returns {boolean} True if a modal is open.
  2246. */
  2247. isModalOpen: function() { return !!_currentModal; }
  2248. };
  2249. })();
  2250.  
  2251. /**
  2252. * @module SettingsUIPaneGenerator
  2253. * A utility module responsible for generating the HTML content for each tab
  2254. * within the main settings window. It decouples the HTML structure from the
  2255. * core logic of the `SettingsManager`.
  2256. */
  2257. const SettingsUIPaneGenerator = (function() {
  2258. /**
  2259. * Creates the HTML content for the "General" settings tab.
  2260. * @returns {string} The HTML string for the pane.
  2261. */
  2262. function createGeneralPaneHTML() {
  2263. const langOpts = LocalizationService.getAvailableLocales().map(lc => {
  2264. let dn;
  2265. if (lc === 'auto') {
  2266. dn = _('settings_language_auto');
  2267. } else {
  2268. try {
  2269. dn = new Intl.DisplayNames([lc], { type: 'language' }).of(lc);
  2270. dn = dn.charAt(0).toUpperCase() + dn.slice(1);
  2271. } catch (e) {
  2272. dn = lc;
  2273. }
  2274. dn = `${dn} (${lc})`;
  2275. }
  2276. return `<option value="${lc}">${dn}</option>`;
  2277. }).join('');
  2278.  
  2279. const locationOptionsHTML = `
  2280. <option value="tools">${_('settings_location_tools')}</option>
  2281. <option value="topBlock">${_('settings_location_top')}</option>
  2282. <option value="header">${_('settings_location_header')}</option>
  2283. <option value="none">${_('settings_location_hide')}</option>`;
  2284.  
  2285. return `
  2286. <div class="${CSS.SETTING_ITEM}">
  2287. <label for="${IDS.SETTING_INTERFACE_LANGUAGE}">${_('settings_interface_language')}</label>
  2288. <select id="${IDS.SETTING_INTERFACE_LANGUAGE}">${langOpts}</select>
  2289. </div>
  2290. <div class="${CSS.SETTING_ITEM}">
  2291. <label for="${IDS.SETTING_SECTION_MODE}">${_('settings_section_mode')}</label>
  2292. <select id="${IDS.SETTING_SECTION_MODE}">
  2293. <option value="remember">${_('settings_section_mode_remember')}</option>
  2294. <option value="expandAll">${_('settings_section_mode_expand')}</option>
  2295. <option value="collapseAll">${_('settings_section_mode_collapse')}</option>
  2296. </select>
  2297. <div style="margin-top:0.6em;">
  2298. <input type="checkbox" id="${IDS.SETTING_ACCORDION}">
  2299. <label for="${IDS.SETTING_ACCORDION}" class="${CSS.SETTING_ITEM_LABEL_INLINE}">${_('settings_accordion_mode')}</label>
  2300. <div class="${CSS.SETTING_VALUE_HINT}" style="margin-top:0.3em; margin-left:1.7em; font-weight:normal;">${_('settings_accordion_mode_hint_desc')}</div>
  2301. </div>
  2302. </div>
  2303. <div class="${CSS.SETTING_ITEM}">
  2304. <input type="checkbox" id="${IDS.SETTING_DRAGGABLE}">
  2305. <label for="${IDS.SETTING_DRAGGABLE}" class="${CSS.SETTING_ITEM_LABEL_INLINE}">${_('settings_enable_drag')}</label>
  2306. </div>
  2307. <div class="${CSS.SETTING_ITEM}">
  2308. <label for="${IDS.SETTING_RESET_LOCATION}">${_('settings_reset_button_location')}</label>
  2309. <select id="${IDS.SETTING_RESET_LOCATION}">${locationOptionsHTML}</select>
  2310. </div>
  2311. <div class="${CSS.SETTING_ITEM}">
  2312. <label for="${IDS.SETTING_VERBATIM_LOCATION}">${_('settings_verbatim_button_location')}</label>
  2313. <select id="${IDS.SETTING_VERBATIM_LOCATION}">${locationOptionsHTML}</select>
  2314. </div>
  2315. <div class="${CSS.SETTING_ITEM}">
  2316. <label for="${IDS.SETTING_ADV_SEARCH_LOCATION}">${_('settings_adv_search_location')}</label>
  2317. <select id="${IDS.SETTING_ADV_SEARCH_LOCATION}">${locationOptionsHTML}</select>
  2318. </div>
  2319. <div class="${CSS.SETTING_ITEM}">
  2320. <label for="${IDS.SETTING_PERSONALIZE_LOCATION}">${_('settings_personalize_button_location')}</label>
  2321. <select id="${IDS.SETTING_PERSONALIZE_LOCATION}">${locationOptionsHTML}</select>
  2322. </div>
  2323. <div class="${CSS.SETTING_ITEM}">
  2324. <label for="${IDS.SETTING_SCHOLAR_LOCATION}">${_('settings_scholar_location')}</label>
  2325. <select id="${IDS.SETTING_SCHOLAR_LOCATION}">${locationOptionsHTML}</select>
  2326. </div>
  2327. <div class="${CSS.SETTING_ITEM}">
  2328. <label for="${IDS.SETTING_TRENDS_LOCATION}">${_('settings_trends_location')}</label>
  2329. <select id="${IDS.SETTING_TRENDS_LOCATION}">${locationOptionsHTML}</select>
  2330. </div>
  2331. <div class="${CSS.SETTING_ITEM}">
  2332. <label for="${IDS.SETTING_DATASET_SEARCH_LOCATION}">${_('settings_dataset_search_location')}</label>
  2333. <select id="${IDS.SETTING_DATASET_SEARCH_LOCATION}">${locationOptionsHTML}</select>
  2334. </div>`;
  2335. }
  2336.  
  2337. /**
  2338. * Creates the HTML content for the "Appearance" settings tab.
  2339. * @returns {string} The HTML string for the pane.
  2340. */
  2341. function createAppearancePaneHTML() {
  2342. const colorOptionsGrid = COLOR_MAPPINGS.map(map => {
  2343. const labelTextKey = `settings_color_${map.key.replace(/([A-Z])/g, '_$1').toLowerCase()}`;
  2344. return `<label for="${map.id}">${_(labelTextKey)}</label><input type="color" id="${map.id}">`;
  2345. }).join('');
  2346.  
  2347. return `
  2348. <div class="${CSS.SETTING_ITEM}">
  2349. <label for="${IDS.SETTING_WIDTH}">${_('settings_sidebar_width')}</label>
  2350. <span class="${CSS.SETTING_RANGE_HINT}">${_('settings_width_range_hint')}</span>
  2351. <input type="range" id="${IDS.SETTING_WIDTH}" min="90" max="270" step="5"><span class="${CSS.SETTING_RANGE_VALUE}"></span>
  2352. </div>
  2353. <div class="${CSS.SETTING_ITEM}">
  2354. <label for="${IDS.SETTING_HEIGHT}">${_('settings_sidebar_height')}</label>
  2355. <span class="${CSS.SETTING_RANGE_HINT}">${_('settings_height_range_hint')}</span>
  2356. <input type="range" id="${IDS.SETTING_HEIGHT}" min="25" max="100" step="5"><span class="${CSS.SETTING_RANGE_VALUE}"></span>
  2357. </div>
  2358. <div class="${CSS.SETTING_ITEM}">
  2359. <label for="${IDS.SETTING_FONT_SIZE}">${_('settings_font_size')}</label>
  2360. <span class="${CSS.SETTING_RANGE_HINT}">${_('settings_font_size_range_hint')}</span>
  2361. <input type="range" id="${IDS.SETTING_FONT_SIZE}" min="8" max="24" step="0.5"><span class="${CSS.SETTING_RANGE_VALUE}"></span>
  2362. </div>
  2363. <div class="${CSS.SETTING_ITEM}">
  2364. <label for="${IDS.SETTING_HEADER_ICON_SIZE}">${_('settings_header_icon_size')}</label>
  2365. <span class="${CSS.SETTING_RANGE_HINT}">${_('settings_header_icon_size_range_hint')}</span>
  2366. <input type="range" id="${IDS.SETTING_HEADER_ICON_SIZE}" min="8" max="32" step="0.5"><span class="${CSS.SETTING_RANGE_VALUE}"></span>
  2367. </div>
  2368. <div class="${CSS.SETTING_ITEM}">
  2369. <label for="${IDS.SETTING_VERTICAL_SPACING}">${_('settings_vertical_spacing')}</label>
  2370. <span class="${CSS.SETTING_RANGE_HINT}">${_('settings_vertical_spacing_range_hint')}</span>
  2371. <input type="range" id="${IDS.SETTING_VERTICAL_SPACING}" min="0.05" max="1.5" step="0.05"><span class="${CSS.SETTING_RANGE_VALUE}"></span>
  2372. </div>
  2373. <div class="${CSS.SETTING_ITEM}">
  2374. <label for="${IDS.SETTING_THEME}">${_('settings_theme')}</label>
  2375. <select id="${IDS.SETTING_THEME}">
  2376. <option value="system">${_('settings_theme_system')}</option>
  2377. <option value="light">${_('settings_theme_light')}</option>
  2378. <option value="dark">${_('settings_theme_dark')}</option>
  2379. <option value="minimal-light">${_('settings_theme_minimal_light')}</option>
  2380. <option value="minimal-dark">${_('settings_theme_minimal_dark')}</option>
  2381. </select>
  2382. </div>
  2383. <div class="${CSS.SETTING_ITEM}">
  2384. <input type="checkbox" id="${IDS.SETTING_HOVER}"><label for="${IDS.SETTING_HOVER}" class="${CSS.SETTING_ITEM_LABEL_INLINE}">${_('settings_hover_mode')}</label>
  2385. <div style="margin-top:0.8em;padding-left:1.5em;">
  2386. <label for="${IDS.SETTING_OPACITY}" style="display:block;margin-bottom:0.4em;font-weight:normal;">${_('settings_idle_opacity')}</label>
  2387. <span class="${CSS.SETTING_RANGE_HINT}" style="width:auto;display:inline-block;margin-right:1em;">${_('settings_opacity_range_hint')}</span>
  2388. <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;">
  2389. <span class="${CSS.SETTING_RANGE_VALUE}" style="display:inline-block;min-width:3em;text-align:right;vertical-align:middle;"></span>
  2390. </div>
  2391. </div>
  2392. <div class="${CSS.SETTING_ITEM}">
  2393. <label for="${IDS.SETTING_COUNTRY_DISPLAY_MODE}">${_('settings_country_display')}</label>
  2394. <select id="${IDS.SETTING_COUNTRY_DISPLAY_MODE}">
  2395. <option value="iconAndText">${_('settings_country_display_icontext')}</option>
  2396. <option value="textOnly">${_('settings_country_display_text')}</option>
  2397. <option value="iconOnly">${_('settings_country_display_icon')}</option>
  2398. </select>
  2399. </div>
  2400. <div class="${CSS.SETTING_ITEM}">
  2401. <label for="${IDS.SETTING_SCROLLBAR_POSITION}">${_('settings_scrollbar_position')}</label>
  2402. <select id="${IDS.SETTING_SCROLLBAR_POSITION}">
  2403. <option value="right">${_('settings_scrollbar_right')}</option>
  2404. <option value="left">${_('settings_scrollbar_left')}</option>
  2405. <option value="hidden">${_('settings_scrollbar_hidden')}</option>
  2406. </select>
  2407. </div>
  2408. <div class="${CSS.SETTING_ITEM}">
  2409. <input type="checkbox" id="${IDS.SETTING_HIDE_GOOGLE_LOGO}">
  2410. <label for="${IDS.SETTING_HIDE_GOOGLE_LOGO}" class="${CSS.SETTING_ITEM_LABEL_INLINE}">${_('settings_hide_google_logo')}</label>
  2411. <div class="${CSS.SETTING_VALUE_HINT}" style="margin-top:0.3em; margin-left:1.7em; font-weight:normal;">${_('settings_hide_google_logo_hint')}</div>
  2412. </div>
  2413. <hr style="margin:1.2em 0;">
  2414. <div id="${IDS.CUSTOM_COLORS_CONTAINER}" class="${CSS.SETTING_ITEM}">
  2415. <label style="font-weight:bold; width:100%; margin-bottom: 0.8em;">${_('settings_advanced_color_options')}</label>
  2416. <div style="display:grid; grid-template-columns: auto 1fr; gap: 0.8em 1em; align-items: center; width: 100%;">${colorOptionsGrid}</div>
  2417. <button id="${IDS.RESET_CUSTOM_COLORS_BTN}" style="margin-top: 1em;">${_('settings_reset_colors_button')}</button>
  2418. </div>`;
  2419. }
  2420.  
  2421. /**
  2422. * Creates the HTML content for the "Features" settings tab.
  2423. * @returns {string} The HTML string for the pane.
  2424. */
  2425. function createFeaturesPaneHTML() {
  2426. const visItemsHTML = ALL_SECTION_DEFINITIONS.map(def => {
  2427. const dn = _(def.titleKey) || def.id;
  2428. 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>`;
  2429. }).join('');
  2430.  
  2431. return `
  2432. <p>${_('settings_visible_sections')}</p>${visItemsHTML}
  2433. <div class="${CSS.SETTING_ITEM}">
  2434. <input type="checkbox" id="${IDS.SETTING_SITE_SEARCH_CHECKBOX_MODE}">
  2435. <label for="${IDS.SETTING_SITE_SEARCH_CHECKBOX_MODE}" class="${CSS.SETTING_ITEM_LABEL_INLINE}">${_('settings_enable_site_search_checkbox_mode')}</label>
  2436. <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>
  2437. </div>
  2438. <div class="${CSS.SETTING_ITEM}">
  2439. <input type="checkbox" id="${IDS.SETTING_SHOW_FAVICONS}">
  2440. <label for="${IDS.SETTING_SHOW_FAVICONS}" class="${CSS.SETTING_ITEM_LABEL_INLINE}">${_('settings_show_favicons')}</label>
  2441. <div class="${CSS.SETTING_VALUE_HINT}" style="margin-top:0.3em; margin-left:1.7em; font-weight:normal;">${_('settings_show_favicons_hint')}</div>
  2442. </div>
  2443. <div class="${CSS.SETTING_ITEM}">
  2444. <input type="checkbox" id="${IDS.SETTING_FILETYPE_SEARCH_CHECKBOX_MODE}">
  2445. <label for="${IDS.SETTING_FILETYPE_SEARCH_CHECKBOX_MODE}" class="${CSS.SETTING_ITEM_LABEL_INLINE}">${_('settings_enable_filetype_search_checkbox_mode')}</label>
  2446. <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>
  2447. </div>
  2448. <div class="${CSS.SETTING_ITEM}">
  2449. <input type="checkbox" id="${IDS.SETTING_SHOW_RESULT_STATS}">
  2450. <label for="${IDS.SETTING_SHOW_RESULT_STATS}" class="${CSS.SETTING_ITEM_LABEL_INLINE}">${_('settings_show_result_stats')}</label>
  2451. </div>
  2452. <hr style="margin:1.2em 0;">
  2453. <p style="font-weight:bold;margin-bottom:0.5em;">${_('settings_section_order')}</p>
  2454. <p class="${CSS.SETTING_VALUE_HINT}" style="font-size:0.9em;margin-top:-0.3em;margin-bottom:0.7em;">${_('settings_section_order_hint')}</p>
  2455. <ul id="${IDS.SIDEBAR_SECTION_ORDER_LIST}" class="${CSS.SECTION_ORDER_LIST}"></ul>`;
  2456. }
  2457.  
  2458. /**
  2459. * Creates the HTML content for the "Custom" settings tab.
  2460. * @returns {string} The HTML string for the pane.
  2461. */
  2462. function createCustomPaneHTML() {
  2463. return `
  2464. <div class="${CSS.SETTING_ITEM}">
  2465. <p>${_('settings_custom_intro')}</p>
  2466. <button class="${CSS.BUTTON_MANAGE_CUSTOM}" data-${DATA_ATTR.MANAGE_TYPE}="site">${_('settings_manage_sites_button')}</button>
  2467. </div>
  2468. <div class="${CSS.SETTING_ITEM}">
  2469. <button class="${CSS.BUTTON_MANAGE_CUSTOM}" data-${DATA_ATTR.MANAGE_TYPE}="language">${_('settings_manage_languages_button')}</button>
  2470. </div>
  2471. <div class="${CSS.SETTING_ITEM}">
  2472. <button class="${CSS.BUTTON_MANAGE_CUSTOM}" data-${DATA_ATTR.MANAGE_TYPE}="country">${_('settings_manage_countries_button')}</button>
  2473. </div>
  2474. <div class="${CSS.SETTING_ITEM}">
  2475. <button class="${CSS.BUTTON_MANAGE_CUSTOM}" data-${DATA_ATTR.MANAGE_TYPE}="time">${_('settings_manage_time_ranges_button')}</button>
  2476. </div>
  2477. <div class="${CSS.SETTING_ITEM}">
  2478. <button class="${CSS.BUTTON_MANAGE_CUSTOM}" data-${DATA_ATTR.MANAGE_TYPE}="filetype">${_('settings_manage_file_types_button')}</button>
  2479. </div>`;
  2480. }
  2481. return { createGeneralPaneHTML, createAppearancePaneHTML, createFeaturesPaneHTML, createCustomPaneHTML };
  2482. })();
  2483.  
  2484. /**
  2485. * @module SectionOrderDragHandler
  2486. * Manages the drag-and-drop functionality for reordering sections in the settings window.
  2487. * It is a self-contained module that handles all necessary drag events.
  2488. */
  2489. const SectionOrderDragHandler = (function() {
  2490. let _draggedItem = null; let _listElement = null; let _settingsRef = null; let _onOrderUpdateCallback = null;
  2491. 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; }
  2492. 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'); } }
  2493. 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); } } }
  2494. 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); }); } }
  2495. function handleDrop(event) {
  2496. event.preventDefault(); if (!_draggedItem || !_listElement || !_settingsRef || !_onOrderUpdateCallback) return;
  2497. const draggedSectionId = event.dataTransfer.getData('text/plain');
  2498. let currentVisibleOrder = _settingsRef.sidebarSectionOrder.filter(id => _settingsRef.visibleSections[id]);
  2499. const oldIndexInVisible = currentVisibleOrder.indexOf(draggedSectionId);
  2500. if (oldIndexInVisible > -1) { currentVisibleOrder.splice(oldIndexInVisible, 1); } else { handleDragEnd(); return; }
  2501. const afterElement = getDragAfterElement(_listElement, event.clientY);
  2502. if (afterElement) { const targetId = afterElement.dataset.sectionId; const newIndexInVisible = currentVisibleOrder.indexOf(targetId); if (newIndexInVisible > -1) { currentVisibleOrder.splice(newIndexInVisible, 0, draggedSectionId); } else { currentVisibleOrder.push(draggedSectionId); }
  2503. } else { currentVisibleOrder.push(draggedSectionId); }
  2504. const hiddenSectionOrder = _settingsRef.sidebarSectionOrder.filter(id => !_settingsRef.visibleSections[id]);
  2505. _settingsRef.sidebarSectionOrder = [...currentVisibleOrder, ...hiddenSectionOrder];
  2506. handleDragEnd(); _onOrderUpdateCallback();
  2507. }
  2508. 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 = ''; }); } }
  2509. 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'; } }
  2510. 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; }
  2511. return { initialize, destroy };
  2512. })();
  2513. /**
  2514. * @module SettingsManager
  2515. * The central controller for all script settings. It handles loading settings from storage,
  2516. * saving them, validating their integrity, and managing the entire settings UI,
  2517. * including its creation, population with data, and event handling.
  2518. */
  2519. const SettingsManager = (function() {
  2520. let _settingsWindow = null; let _settingsOverlay = null; let _currentSettings = {};
  2521. let _settingsBackup = {}; let _defaultSettingsRef = null; let _isInitialized = false;
  2522. let _applySettingsToSidebar_cb = ()=>{}; let _buildSidebarUI_cb = ()=>{};
  2523. let _applySectionCollapseStates_cb = ()=>{}; let _initMenuCommands_cb = ()=>{};
  2524. let _renderSectionOrderList_ext_cb = ()=>{};
  2525.  
  2526. /**
  2527. * A helper to populate a range slider and its associated value display in the settings UI.
  2528. * @private
  2529. * @param {HTMLElement} win - The settings window element.
  2530. * @param {string} id - The ID of the range input element.
  2531. * @param {number|string} value - The value to set.
  2532. * @param {Function} [formatFn=(val) => val] - An optional function to format the displayed value.
  2533. */
  2534. function _populateSliderSetting_internal(win, id, value, formatFn = (val) => val) {
  2535. const i = win.querySelector(`#${id}`);
  2536. if (i) {
  2537. i.value = value;
  2538. let vs = i.parentNode.querySelector(`.${CSS.SETTING_RANGE_VALUE}`);
  2539. if (vs) {
  2540. vs.textContent = formatFn(value);
  2541. }
  2542. }
  2543. }
  2544. /**
  2545. * Populates the "General" tab in the settings window with current setting values.
  2546. * @private
  2547. * @param {HTMLElement} win - The settings window element.
  2548. * @param {Object} s - The current settings object.
  2549. */
  2550. function _populateGeneralSettings_internal(win, s) {
  2551. const lS = win.querySelector(`#${IDS.SETTING_INTERFACE_LANGUAGE}`);
  2552. if (lS) lS.value = s.interfaceLanguage;
  2553. const sMS = win.querySelector(`#${IDS.SETTING_SECTION_MODE}`),
  2554. acC = win.querySelector(`#${IDS.SETTING_ACCORDION}`);
  2555. if (sMS && acC) {
  2556. sMS.value = s.sectionDisplayMode;
  2557. const iRM = s.sectionDisplayMode === 'remember';
  2558. acC.disabled = !iRM;
  2559. acC.checked = iRM ? s.accordionMode : false;
  2560. const accordionHint = acC.parentElement.querySelector(`.${CSS.SETTING_VALUE_HINT}`);
  2561. if (accordionHint) accordionHint.style.color = iRM ? '' : 'grey';
  2562. }
  2563. const dC = win.querySelector(`#${IDS.SETTING_DRAGGABLE}`);
  2564. if (dC) dC.checked = s.draggableHandleEnabled;
  2565. const rLS = win.querySelector(`#${IDS.SETTING_RESET_LOCATION}`);
  2566. if (rLS) rLS.value = s.resetButtonLocation;
  2567. const vLS = win.querySelector(`#${IDS.SETTING_VERBATIM_LOCATION}`);
  2568. if (vLS) vLS.value = s.verbatimButtonLocation;
  2569. const aSLS = win.querySelector(`#${IDS.SETTING_ADV_SEARCH_LOCATION}`);
  2570. if (aSLS) aSLS.value = s.advancedSearchLinkLocation;
  2571. const pznLS = win.querySelector(`#${IDS.SETTING_PERSONALIZE_LOCATION}`);
  2572. if (pznLS) pznLS.value = s.personalizationButtonLocation;
  2573. const schLS = win.querySelector(`#${IDS.SETTING_SCHOLAR_LOCATION}`);
  2574. if (schLS) schLS.value = s.googleScholarShortcutLocation;
  2575. const trnLS = win.querySelector(`#${IDS.SETTING_TRENDS_LOCATION}`);
  2576. if (trnLS) trnLS.value = s.googleTrendsShortcutLocation;
  2577. const dsLS = win.querySelector(`#${IDS.SETTING_DATASET_SEARCH_LOCATION}`);
  2578. if (dsLS) dsLS.value = s.googleDatasetSearchShortcutLocation;
  2579. }
  2580.  
  2581. /**
  2582. * Populates the "Appearance" tab in the settings window with current setting values.
  2583. * @private
  2584. * @param {HTMLElement} win - The settings window element.
  2585. * @param {Object} s - The current settings object.
  2586. */
  2587. function _populateAppearanceSettings_internal(win, s) {
  2588. _populateSliderSetting_internal(win, IDS.SETTING_WIDTH, s.sidebarWidth);
  2589. _populateSliderSetting_internal(win, IDS.SETTING_HEIGHT, s.sidebarHeight);
  2590. _populateSliderSetting_internal(win, IDS.SETTING_FONT_SIZE, s.fontSize, v => parseFloat(v).toFixed(1));
  2591. _populateSliderSetting_internal(win, IDS.SETTING_HEADER_ICON_SIZE, s.headerIconSize, v => parseFloat(v).toFixed(1));
  2592. _populateSliderSetting_internal(win, IDS.SETTING_VERTICAL_SPACING, s.verticalSpacingMultiplier, v => `x ${parseFloat(v).toFixed(2)}`);
  2593. _populateSliderSetting_internal(win, IDS.SETTING_OPACITY, s.idleOpacity, v => parseFloat(v).toFixed(2));
  2594. const tS = win.querySelector(`#${IDS.SETTING_THEME}`);
  2595. if (tS) tS.value = s.theme;
  2596. const cDS = win.querySelector(`#${IDS.SETTING_COUNTRY_DISPLAY_MODE}`);
  2597. if (cDS) cDS.value = s.countryDisplayMode;
  2598. const scrollbarPos = win.querySelector(`#${IDS.SETTING_SCROLLBAR_POSITION}`);
  2599. if (scrollbarPos) scrollbarPos.value = s.scrollbarPosition;
  2600. const hC = win.querySelector(`#${IDS.SETTING_HOVER}`),
  2601. oI = win.querySelector(`#${IDS.SETTING_OPACITY}`);
  2602. if (hC && oI) {
  2603. hC.checked = s.hoverMode;
  2604. const iHE = s.hoverMode;
  2605. oI.disabled = !iHE;
  2606. const oC = oI.closest('div');
  2607. if (oC) {
  2608. oC.style.opacity = iHE ? '1' : '0.6';
  2609. oC.style.pointerEvents = iHE ? 'auto' : 'none';
  2610. }
  2611. }
  2612. const hideLogoCb = win.querySelector(`#${IDS.SETTING_HIDE_GOOGLE_LOGO}`);
  2613. if (hideLogoCb) hideLogoCb.checked = s.hideGoogleLogoWhenExpanded;
  2614.  
  2615. const isDark = s.theme.includes('dark') || (s.theme === 'system' && systemThemeMediaQuery && systemThemeMediaQuery.matches);
  2616. const colorDefaults = {
  2617. bgColor: isDark ? '#202124' : '#ffffff',
  2618. textColor: isDark ? '#bdc1c6' : '#3c4043',
  2619. linkColor: isDark ? '#8ab4f8' : '#1a0dab',
  2620. selectedColor: isDark ? '#e8eaed' : '#000000',
  2621. inputTextColor: isDark ? '#e8eaed' : '#202124',
  2622. borderColor: isDark ? '#5f6368' : '#dadce0',
  2623. dividerColor: isDark ? '#3c4043' : '#eeeeee',
  2624. btnBgColor: isDark ? '#303134' : '#f8f9fa',
  2625. btnHoverBgColor: isDark ? '#3c4043' : '#e8eaed',
  2626. activeBgColor: isDark ? '#8ab4f8' : '#e8f0fe',
  2627. activeTextColor: isDark ? '#202124' : '#1967d2',
  2628. activeBorderColor: isDark ? '#8ab4f8' : '#aecbfa',
  2629. headerIconColor: isDark ? '#bdc1c6' : '#5f6368',
  2630. };
  2631. COLOR_MAPPINGS.forEach(map => {
  2632. const picker = win.querySelector(`#${map.id}`);
  2633. if (picker) {
  2634. picker.value = s.customColors[map.key] || colorDefaults[map.key];
  2635. }
  2636. });
  2637. }
  2638.  
  2639. /**
  2640. * Populates the "Features" tab in the settings window with current setting values.
  2641. * @private
  2642. * @param {HTMLElement} win - The settings window element.
  2643. * @param {Object} s - The current settings object.
  2644. * @param {Function} renderFn - A callback function to render the section order list.
  2645. */
  2646. function _populateFeatureSettings_internal(win, s, renderFn) {
  2647. win.querySelectorAll(`#${IDS.TAB_PANE_FEATURES} input[type="checkbox"][data-${DATA_ATTR.SECTION_ID}]`)?.forEach(cb => {
  2648. const sId = cb.getAttribute(`data-${DATA_ATTR.SECTION_ID}`);
  2649. if (sId && s.visibleSections.hasOwnProperty(sId)) {
  2650. cb.checked = s.visibleSections[sId];
  2651. } else if (sId && _defaultSettingsRef.visibleSections.hasOwnProperty(sId)) {
  2652. cb.checked = _defaultSettingsRef.visibleSections[sId] ?? false;
  2653. }
  2654. });
  2655. const siteSearchCheckboxModeEl = win.querySelector(`#${IDS.SETTING_SITE_SEARCH_CHECKBOX_MODE}`);
  2656. if (siteSearchCheckboxModeEl) siteSearchCheckboxModeEl.checked = s.enableSiteSearchCheckboxMode;
  2657. const showFaviconsEl = win.querySelector(`#${IDS.SETTING_SHOW_FAVICONS}`);
  2658. if (showFaviconsEl) showFaviconsEl.checked = s.showFaviconsForSiteSearch;
  2659. const filetypeSearchCheckboxModeEl = win.querySelector(`#${IDS.SETTING_FILETYPE_SEARCH_CHECKBOX_MODE}`);
  2660. if (filetypeSearchCheckboxModeEl) filetypeSearchCheckboxModeEl.checked = s.enableFiletypeCheckboxMode;
  2661. const showResultStatsEl = win.querySelector(`#${IDS.SETTING_SHOW_RESULT_STATS}`);
  2662. if (showResultStatsEl) showResultStatsEl.checked = s.showResultStats;
  2663. renderFn(s);
  2664. }
  2665.  
  2666. /**
  2667. * Ensures the correct settings tab is visible based on the last active tab or a default.
  2668. * @private
  2669. */
  2670. function _initializeActiveSettingsTab_internal() {
  2671. if (!_settingsWindow) return;
  2672. const tC = _settingsWindow.querySelector(`.${CSS.SETTINGS_TABS}`),
  2673. cC = _settingsWindow.querySelector(`.${CSS.SETTINGS_TAB_CONTENT}`);
  2674. if (!tC || !cC) return;
  2675. const aTB = tC.querySelector(`.${CSS.TAB_BUTTON}.${CSS.IS_ACTIVE}`);
  2676. const tT = (aTB && aTB.dataset[DATA_ATTR.TAB]) ? aTB.dataset[DATA_ATTR.TAB] : 'general';
  2677. tC.querySelectorAll(`.${CSS.TAB_BUTTON}`).forEach(b => b.classList.toggle(CSS.IS_ACTIVE, b.dataset[DATA_ATTR.TAB] === tT));
  2678. cC.querySelectorAll(`.${CSS.TAB_PANE}`).forEach(p => p.classList.toggle(CSS.IS_ACTIVE, p.dataset[DATA_ATTR.TAB] === tT));
  2679. }
  2680.  
  2681. /**
  2682. * Loads settings from storage using GM_getValue.
  2683. * @private
  2684. * @returns {Object} The parsed settings object from storage, or an empty object on error.
  2685. */
  2686. function _loadFromStorage() {
  2687. try {
  2688. const s = GM_getValue(STORAGE_KEY, '{}');
  2689. return JSON.parse(s || '{}');
  2690. } catch (e) {
  2691. console.error(`${LOG_PREFIX} Error loading/parsing settings:`, e);
  2692. return {};
  2693. }
  2694. }
  2695.  
  2696. /**
  2697. * Ensures the display arrays for languages and countries are valid and populated.
  2698. * For new users or corrupted settings where these arrays are missing or empty,
  2699. * this function populates them based on the script's default predefined options.
  2700. * This replaces a more complex legacy migration system.
  2701. * @private
  2702. * @param {Object} settings - The settings object to validate and potentially modify.
  2703. */
  2704. function _migrateToDisplayArraysIfNecessary(settings) {
  2705. const displayTypes = [{
  2706. displayKey: 'displayLanguages',
  2707. predefinedKey: 'language',
  2708. defaultEnabled: defaultSettings.enabledPredefinedOptions.language
  2709. }, {
  2710. displayKey: 'displayCountries',
  2711. predefinedKey: 'country',
  2712. defaultEnabled: defaultSettings.enabledPredefinedOptions.country
  2713. }];
  2714.  
  2715. displayTypes.forEach(typeInfo => {
  2716. // Check if the display array is missing, invalid, or empty.
  2717. if (!Array.isArray(settings[typeInfo.displayKey]) || settings[typeInfo.displayKey].length === 0) {
  2718. console.log(`${LOG_PREFIX} Initializing '${typeInfo.displayKey}' with default predefined options.`);
  2719. const newDisplayArray = [];
  2720. const addedValues = new Set();
  2721. const defaultOptionsToEnable = typeInfo.defaultEnabled || [];
  2722.  
  2723. defaultOptionsToEnable.forEach(val => {
  2724. const predefinedOpt = PREDEFINED_OPTIONS[typeInfo.predefinedKey]?.find(p => p.value === val);
  2725. if (predefinedOpt && !addedValues.has(predefinedOpt.value)) {
  2726. newDisplayArray.push({
  2727. id: predefinedOpt.value,
  2728. text: _(predefinedOpt.textKey), // Text is for reference, will be re-translated on UI build
  2729. value: predefinedOpt.value,
  2730. type: 'predefined',
  2731. originalKey: predefinedOpt.textKey
  2732. });
  2733. addedValues.add(predefinedOpt.value);
  2734. }
  2735. });
  2736. settings[typeInfo.displayKey] = newDisplayArray;
  2737. }
  2738. });
  2739. }
  2740.  
  2741. /**
  2742. * Validates and merges core settings like sidebar position and state.
  2743. * @private
  2744. */
  2745. function _validateAndMergeCoreSettings_internal(target, source, defaults) {
  2746. if (typeof target.sidebarPosition !== 'object' || target.sidebarPosition === null || Array.isArray(target.sidebarPosition)) {
  2747. target.sidebarPosition = JSON.parse(JSON.stringify(defaults.sidebarPosition));
  2748. }
  2749. target.sidebarPosition.left = parseInt(target.sidebarPosition.left, 10) || defaults.sidebarPosition.left;
  2750. target.sidebarPosition.top = parseInt(target.sidebarPosition.top, 10) || defaults.sidebarPosition.top;
  2751. if (typeof target.sectionStates !== 'object' || target.sectionStates === null || Array.isArray(target.sectionStates)) {
  2752. target.sectionStates = {};
  2753. }
  2754. target.sidebarCollapsed = !!target.sidebarCollapsed;
  2755. target.draggableHandleEnabled = typeof target.draggableHandleEnabled === 'boolean' ? target.draggableHandleEnabled : defaults.draggableHandleEnabled;
  2756. target.interfaceLanguage = typeof source.interfaceLanguage === 'string' ? source.interfaceLanguage : defaults.interfaceLanguage;
  2757. }
  2758.  
  2759. /**
  2760. * Validates and merges appearance-related settings like dimensions, fonts, and colors.
  2761. * @private
  2762. */
  2763. function _validateAndMergeAppearanceSettings_internal(target, source, defaults) {
  2764. target.sidebarWidth = Utils.clamp(parseInt(target.sidebarWidth, 10) || defaults.sidebarWidth, 90, 270);
  2765. target.sidebarHeight = Utils.clamp(parseInt(target.sidebarHeight, 10) || defaults.sidebarHeight, 25, 100);
  2766. target.fontSize = Utils.clamp(parseFloat(target.fontSize) || defaults.fontSize, 8, 24);
  2767. target.headerIconSize = Utils.clamp(parseFloat(target.headerIconSize) || defaults.headerIconSize, 8, 32);
  2768. target.verticalSpacingMultiplier = Utils.clamp(parseFloat(target.verticalSpacingMultiplier) || defaults.verticalSpacingMultiplier, 0.05, 1.5);
  2769. target.idleOpacity = Utils.clamp(parseFloat(target.idleOpacity) || defaults.idleOpacity, 0.1, 1.0);
  2770. target.hoverMode = !!target.hoverMode;
  2771. const validThemes = ['system', 'light', 'dark', 'minimal-light', 'minimal-dark'];
  2772. if (target.theme === 'minimal') target.theme = 'minimal-light';
  2773. else if (!validThemes.includes(target.theme)) target.theme = defaults.theme;
  2774. target.hideGoogleLogoWhenExpanded = typeof source.hideGoogleLogoWhenExpanded === 'boolean' ? source.hideGoogleLogoWhenExpanded : defaults.hideGoogleLogoWhenExpanded;
  2775.  
  2776. if (typeof source.customColors === 'object' && source.customColors !== null && !Array.isArray(source.customColors)) {
  2777. target.customColors = {};
  2778. const colorRegex = /^#[0-9a-fA-F]{6}$/;
  2779. COLOR_MAPPINGS.forEach(map => {
  2780. if (typeof source.customColors[map.key] === 'string' && (source.customColors[map.key] === '' || colorRegex.test(source.customColors[map.key]))) {
  2781. target.customColors[map.key] = source.customColors[map.key];
  2782. } else {
  2783. target.customColors[map.key] = '';
  2784. }
  2785. });
  2786. } else {
  2787. target.customColors = JSON.parse(JSON.stringify(defaults.customColors));
  2788. }
  2789. }
  2790.  
  2791. /**
  2792. * Validates and merges feature-related settings like section visibility and button locations.
  2793. * @private
  2794. */
  2795. function _validateAndMergeFeatureSettings_internal(target, source, defaults) {
  2796. if (typeof target.visibleSections !== 'object' || target.visibleSections === null || Array.isArray(target.visibleSections)) {
  2797. target.visibleSections = JSON.parse(JSON.stringify(defaults.visibleSections));
  2798. }
  2799. const validSectionIDs = new Set(ALL_SECTION_DEFINITIONS.map(def => def.id));
  2800. Object.keys(defaults.visibleSections).forEach(id => {
  2801. if (!validSectionIDs.has(id)) {
  2802. console.warn(`${LOG_PREFIX} Invalid section ID in defaultSettings.visibleSections: ${id}`);
  2803. } else if (typeof target.visibleSections[id] !== 'boolean') {
  2804. target.visibleSections[id] = defaults.visibleSections[id] ?? true;
  2805. }
  2806. });
  2807. const validSectionModes = ['remember', 'expandAll', 'collapseAll'];
  2808. if (!validSectionModes.includes(target.sectionDisplayMode)) target.sectionDisplayMode = defaults.sectionDisplayMode;
  2809. target.accordionMode = !!target.accordionMode;
  2810. target.enableSiteSearchCheckboxMode = typeof target.enableSiteSearchCheckboxMode === 'boolean' ? target.enableSiteSearchCheckboxMode : defaults.enableSiteSearchCheckboxMode;
  2811. target.showFaviconsForSiteSearch = typeof target.showFaviconsForSiteSearch === 'boolean' ? target.showFaviconsForSiteSearch : defaults.showFaviconsForSiteSearch;
  2812. target.enableFiletypeCheckboxMode = typeof target.enableFiletypeCheckboxMode === 'boolean' ? target.enableFiletypeCheckboxMode : defaults.enableFiletypeCheckboxMode;
  2813. const validButtonLocations = ['header', 'topBlock', 'tools', 'none'];
  2814. if (!validButtonLocations.includes(target.resetButtonLocation)) target.resetButtonLocation = defaults.resetButtonLocation;
  2815. if (!validButtonLocations.includes(target.verbatimButtonLocation)) target.verbatimButtonLocation = defaults.verbatimButtonLocation;
  2816. if (!validButtonLocations.includes(target.advancedSearchLinkLocation)) target.advancedSearchLinkLocation = defaults.advancedSearchLinkLocation;
  2817. if (!validButtonLocations.includes(target.personalizationButtonLocation)) {
  2818. target.personalizationButtonLocation = defaults.personalizationButtonLocation;
  2819. }
  2820. if (!validButtonLocations.includes(target.googleScholarShortcutLocation)) {
  2821. target.googleScholarShortcutLocation = defaults.googleScholarShortcutLocation;
  2822. }
  2823. if (!validButtonLocations.includes(target.googleTrendsShortcutLocation)) {
  2824. target.googleTrendsShortcutLocation = defaults.googleTrendsShortcutLocation;
  2825. }
  2826. if (!validButtonLocations.includes(target.googleDatasetSearchShortcutLocation)) {
  2827. target.googleDatasetSearchShortcutLocation = defaults.googleDatasetSearchShortcutLocation;
  2828. }
  2829. const validCountryDisplayModes = ['iconAndText', 'textOnly', 'iconOnly'];
  2830. if (!validCountryDisplayModes.includes(target.countryDisplayMode)) target.countryDisplayMode = defaults.countryDisplayMode;
  2831. const validScrollbarPositions = ['right', 'left', 'hidden'];
  2832. if (!validScrollbarPositions.includes(target.scrollbarPosition)) target.scrollbarPosition = defaults.scrollbarPosition;
  2833. target.showResultStats = typeof source.showResultStats === 'boolean' ? source.showResultStats : defaults.showResultStats;
  2834. }
  2835.  
  2836. /**
  2837. * Validates and merges user-defined custom lists (e.g., favorite sites).
  2838. * @private
  2839. */
  2840. function _validateAndMergeCustomLists_internal(target, source, defaults) {
  2841. const listKeys = ['favoriteSites', 'customLanguages', 'customTimeRanges', 'customFiletypes', 'customCountries'];
  2842. listKeys.forEach(key => {
  2843. 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]));
  2844. });
  2845. }
  2846.  
  2847. /**
  2848. * Validates and merges the settings for enabled predefined options.
  2849. * @private
  2850. */
  2851. function _validateAndMergePredefinedOptions_internal(target, source, defaults) {
  2852. target.enabledPredefinedOptions = target.enabledPredefinedOptions || {};
  2853. ['time', 'filetype'].forEach(type => {
  2854. if (!target.enabledPredefinedOptions[type] || !Array.isArray(target.enabledPredefinedOptions[type])) {
  2855. target.enabledPredefinedOptions[type] = JSON.parse(JSON.stringify(defaults.enabledPredefinedOptions[type] || []));
  2856. }
  2857. const savedTypeOptions = source.enabledPredefinedOptions?.[type];
  2858. if (PREDEFINED_OPTIONS[type] && Array.isArray(savedTypeOptions)) {
  2859. const validValues = new Set(PREDEFINED_OPTIONS[type].map(opt => opt.value));
  2860. target.enabledPredefinedOptions[type] = savedTypeOptions.filter(val => typeof val === 'string' && validValues.has(val));
  2861. } else if (!PREDEFINED_OPTIONS[type]) {
  2862. target.enabledPredefinedOptions[type] = [];
  2863. }
  2864. });
  2865. if (target.displayLanguages && target.enabledPredefinedOptions) target.enabledPredefinedOptions.language = [];
  2866. if (target.displayCountries && target.enabledPredefinedOptions) target.enabledPredefinedOptions.country = [];
  2867. }
  2868.  
  2869. /**
  2870. * Validates and finalizes the order of sidebar sections.
  2871. * @private
  2872. */
  2873. function _finalizeSectionOrder_internal(target, source, defaults) {
  2874. const finalOrder = [];
  2875. const currentVisibleOrderSet = new Set();
  2876. const validSectionIDs = new Set(ALL_SECTION_DEFINITIONS.map(def => def.id));
  2877. const orderSource = (Array.isArray(source.sidebarSectionOrder) && source.sidebarSectionOrder.length > 0) ? source.sidebarSectionOrder : defaults.sidebarSectionOrder;
  2878. orderSource.forEach(id => {
  2879. if (typeof id === 'string' && validSectionIDs.has(id) && target.visibleSections[id] === true && !currentVisibleOrderSet.has(id)) {
  2880. finalOrder.push(id);
  2881. currentVisibleOrderSet.add(id);
  2882. }
  2883. });
  2884. defaults.sidebarSectionOrder.forEach(id => {
  2885. if (typeof id === 'string' && validSectionIDs.has(id) && target.visibleSections[id] === true && !currentVisibleOrderSet.has(id)) {
  2886. finalOrder.push(id);
  2887. }
  2888. });
  2889. target.sidebarSectionOrder = finalOrder;
  2890. }
  2891.  
  2892. /**
  2893. * Takes a raw settings object (e.g., from storage) and merges it with default settings,
  2894. * performing validation and sanitation to ensure a clean, usable settings object.
  2895. * @private
  2896. * @param {Object} saved - The raw settings object loaded from storage.
  2897. * @returns {Object} The validated and merged settings object.
  2898. */
  2899. function _validateAndMergeSettings(saved) {
  2900. let newSettings = JSON.parse(JSON.stringify(_defaultSettingsRef));
  2901. newSettings = Utils.mergeDeep(newSettings, saved);
  2902.  
  2903. _validateAndMergeCoreSettings_internal(newSettings, saved, _defaultSettingsRef);
  2904. _validateAndMergeAppearanceSettings_internal(newSettings, saved, _defaultSettingsRef);
  2905. _validateAndMergeFeatureSettings_internal(newSettings, saved, _defaultSettingsRef);
  2906. _validateAndMergeCustomLists_internal(newSettings, saved, _defaultSettingsRef);
  2907. _migrateToDisplayArraysIfNecessary(newSettings);
  2908.  
  2909. ['displayLanguages', 'displayCountries'].forEach(displayKey => {
  2910. if (!Array.isArray(newSettings[displayKey])) {
  2911. newSettings[displayKey] = JSON.parse(JSON.stringify(_defaultSettingsRef[displayKey])) || [];
  2912. }
  2913. newSettings[displayKey] = newSettings[displayKey].filter(item =>
  2914. item && typeof item.id === 'string' &&
  2915. (item.type === 'predefined' ? (typeof item.text === 'string' && typeof item.originalKey === 'string') : typeof item.text === 'string') &&
  2916. typeof item.value === 'string' &&
  2917. (item.type === 'predefined' || item.type === 'custom')
  2918. );
  2919. });
  2920.  
  2921. _validateAndMergePredefinedOptions_internal(newSettings, saved, _defaultSettingsRef);
  2922. _finalizeSectionOrder_internal(newSettings, saved, _defaultSettingsRef);
  2923.  
  2924. return newSettings;
  2925. }
  2926.  
  2927. // A map of element IDs to their live update event handler functions.
  2928. const _sEH_internal = {
  2929. [IDS.SETTING_WIDTH]: (t, vS) => _hSLI(t, 'sidebarWidth', vS, 90, 270, 5),
  2930. [IDS.SETTING_HEIGHT]: (t, vS) => _hSLI(t, 'sidebarHeight', vS, 25, 100, 5),
  2931. [IDS.SETTING_FONT_SIZE]: (t, vS) => _hSLI(t, 'fontSize', vS, 8, 24, 0.5, v => parseFloat(v).toFixed(1)),
  2932. [IDS.SETTING_HEADER_ICON_SIZE]: (t, vS) => _hSLI(t, 'headerIconSize', vS, 8, 32, 0.5, v => parseFloat(v).toFixed(1)),
  2933. [IDS.SETTING_VERTICAL_SPACING]: (t, vS) => _hSLI(t, 'verticalSpacingMultiplier', vS, 0.05, 1.5, 0.05, v => `x ${parseFloat(v).toFixed(2)}`),
  2934. [IDS.SETTING_OPACITY]: (t, vS) => _hSLI(t, 'idleOpacity', vS, 0.1, 1.0, 0.05, v => parseFloat(v).toFixed(2)),
  2935. [IDS.SETTING_INTERFACE_LANGUAGE]: (t) => {
  2936. const nL = t.value;
  2937. if (_currentSettings.interfaceLanguage !== nL) {
  2938. _currentSettings.interfaceLanguage = nL;
  2939. LocalizationService.updateActiveLocale(_currentSettings);
  2940. _initMenuCommands_cb();
  2941. publicApi.populateWindow();
  2942. _buildSidebarUI_cb();
  2943. }
  2944. },
  2945. [IDS.SETTING_THEME]: (t) => {
  2946. _currentSettings.theme = t.value;
  2947. _populateAppearanceSettings_internal(_settingsWindow, _currentSettings);
  2948. _applySettingsToSidebar_cb(_currentSettings);
  2949. },
  2950. [IDS.SETTING_HOVER]: (t) => {
  2951. _currentSettings.hoverMode = t.checked;
  2952. const oI = _settingsWindow.querySelector(`#${IDS.SETTING_OPACITY}`);
  2953. if (oI) {
  2954. const iHE = _currentSettings.hoverMode;
  2955. oI.disabled = !iHE;
  2956. const oC = oI.closest('div');
  2957. if (oC) {
  2958. oC.style.opacity = iHE ? '1' : '0.6';
  2959. oC.style.pointerEvents = iHE ? 'auto' : 'none';
  2960. }
  2961. }
  2962. _applySettingsToSidebar_cb(_currentSettings);
  2963. },
  2964. [IDS.SETTING_DRAGGABLE]: (t) => {
  2965. _currentSettings.draggableHandleEnabled = t.checked;
  2966. _applySettingsToSidebar_cb(_currentSettings);
  2967. DragManager.setDraggable(t.checked, sidebar, sidebar?.querySelector(`.${CSS.DRAG_HANDLE}`), _currentSettings, debouncedSaveSettings);
  2968. },
  2969. [IDS.SETTING_ACCORDION]: (t) => {
  2970. const sMS = _settingsWindow.querySelector(`#${IDS.SETTING_SECTION_MODE}`);
  2971. if (sMS?.value === 'remember') _currentSettings.accordionMode = t.checked;
  2972. else {
  2973. t.checked = false;
  2974. _currentSettings.accordionMode = false;
  2975. }
  2976. _applySettingsToSidebar_cb(_currentSettings);
  2977. _applySectionCollapseStates_cb();
  2978. },
  2979. [IDS.SETTING_SECTION_MODE]: (t) => {
  2980. _currentSettings.sectionDisplayMode = t.value;
  2981. const aC = _settingsWindow.querySelector(`#${IDS.SETTING_ACCORDION}`);
  2982. if (aC) {
  2983. const iRM = t.value === 'remember';
  2984. aC.disabled = !iRM;
  2985. if (aC.parentElement.querySelector(`.${CSS.SETTING_VALUE_HINT}`)) aC.parentElement.querySelector(`.${CSS.SETTING_VALUE_HINT}`).style.color = iRM ? '' : 'grey';
  2986. if (!iRM) {
  2987. aC.checked = false;
  2988. _currentSettings.accordionMode = false;
  2989. } else {
  2990. aC.checked = _settingsBackup?.accordionMode ?? _currentSettings.accordionMode ?? _defaultSettingsRef.accordionMode;
  2991. _currentSettings.accordionMode = aC.checked;
  2992. }
  2993. }
  2994. _applySettingsToSidebar_cb(_currentSettings);
  2995. _applySectionCollapseStates_cb();
  2996. },
  2997. [IDS.SETTING_RESET_LOCATION]: (t) => {
  2998. _currentSettings.resetButtonLocation = t.value;
  2999. _buildSidebarUI_cb();
  3000. },
  3001. [IDS.SETTING_VERBATIM_LOCATION]: (t) => {
  3002. _currentSettings.verbatimButtonLocation = t.value;
  3003. _buildSidebarUI_cb();
  3004. },
  3005. [IDS.SETTING_ADV_SEARCH_LOCATION]: (t) => {
  3006. _currentSettings.advancedSearchLinkLocation = t.value;
  3007. _buildSidebarUI_cb();
  3008. },
  3009. [IDS.SETTING_PERSONALIZE_LOCATION]: (target) => {
  3010. _currentSettings.personalizationButtonLocation = target.value;
  3011. _buildSidebarUI_cb();
  3012. },
  3013. [IDS.SETTING_SCHOLAR_LOCATION]: (target) => {
  3014. _currentSettings.googleScholarShortcutLocation = target.value;
  3015. _buildSidebarUI_cb();
  3016. },
  3017. [IDS.SETTING_TRENDS_LOCATION]: (target) => {
  3018. _currentSettings.googleTrendsShortcutLocation = target.value;
  3019. _buildSidebarUI_cb();
  3020. },
  3021. [IDS.SETTING_DATASET_SEARCH_LOCATION]: (target) => {
  3022. _currentSettings.googleDatasetSearchShortcutLocation = target.value;
  3023. _buildSidebarUI_cb();
  3024. },
  3025. [IDS.SETTING_SITE_SEARCH_CHECKBOX_MODE]: (target) => {
  3026. _currentSettings.enableSiteSearchCheckboxMode = target.checked;
  3027. _buildSidebarUI_cb();
  3028. },
  3029. [IDS.SETTING_SHOW_FAVICONS]: (target) => {
  3030. _currentSettings.showFaviconsForSiteSearch = target.checked;
  3031. _buildSidebarUI_cb();
  3032. },
  3033. [IDS.SETTING_FILETYPE_SEARCH_CHECKBOX_MODE]: (target) => {
  3034. _currentSettings.enableFiletypeCheckboxMode = target.checked;
  3035. _buildSidebarUI_cb();
  3036. },
  3037. [IDS.SETTING_COUNTRY_DISPLAY_MODE]: (t) => {
  3038. _currentSettings.countryDisplayMode = t.value;
  3039. _buildSidebarUI_cb();
  3040. },
  3041. [IDS.SETTING_SCROLLBAR_POSITION]: (t) => {
  3042. _currentSettings.scrollbarPosition = t.value;
  3043. _applySettingsToSidebar_cb(_currentSettings);
  3044. },
  3045. [IDS.SETTING_SHOW_RESULT_STATS]: (t) => {
  3046. _currentSettings.showResultStats = t.checked;
  3047. ResultStatsManager.toggle(_currentSettings.showResultStats);
  3048. },
  3049. [IDS.SETTING_HIDE_GOOGLE_LOGO]: (t) => {
  3050. _currentSettings.hideGoogleLogoWhenExpanded = t.checked;
  3051. _applySettingsToSidebar_cb(_currentSettings);
  3052. },
  3053. [IDS.RESET_CUSTOM_COLORS_BTN]: () => {
  3054. _currentSettings.customColors = JSON.parse(JSON.stringify(_defaultSettingsRef.customColors));
  3055. _populateAppearanceSettings_internal(_settingsWindow, _currentSettings);
  3056. _applySettingsToSidebar_cb(_currentSettings);
  3057. }
  3058. };
  3059.  
  3060. /**
  3061. * Generic handler for range slider input events.
  3062. * @private
  3063. */
  3064. function _hSLI(t, sK, vS, min, max, step, fFn = v => v) {
  3065. const v = Utils.clamp((step === 1 || step === 5) ? parseInt(t.value, 10) : parseFloat(t.value), min, max);
  3066. if (isNaN(v)) _currentSettings[sK] = _defaultSettingsRef[sK];
  3067. else _currentSettings[sK] = v;
  3068. if (vS) vS.textContent = fFn(_currentSettings[sK]);
  3069. _applySettingsToSidebar_cb(_currentSettings);
  3070. }
  3071.  
  3072. /**
  3073. * The main event handler for live updates in the settings window.
  3074. * @private
  3075. * @param {Event} e - The input or change event.
  3076. */
  3077. function _lUH_internal(e) {
  3078. const t = e.target;
  3079. if (!t) return;
  3080. const sI = t.id;
  3081. const vS = (t.type === 'range') ? t.parentNode.querySelector(`.${CSS.SETTING_RANGE_VALUE}`) : null;
  3082.  
  3083. if (t.type === 'color') {
  3084. const colorMapping = COLOR_MAPPINGS.find(m => m.id === t.id);
  3085. if (colorMapping) {
  3086. if (!_currentSettings.customColors) _currentSettings.customColors = {};
  3087. _currentSettings.customColors[colorMapping.key] = t.value;
  3088. _applySettingsToSidebar_cb(_currentSettings);
  3089. return;
  3090. }
  3091. }
  3092.  
  3093. if (_sEH_internal[sI]) {
  3094. if (t.type === 'range') _sEH_internal[sI](t, vS);
  3095. else _sEH_internal[sI](t);
  3096. }
  3097. }
  3098. const publicApi = {
  3099. /**
  3100. * Initializes the SettingsManager.
  3101. * @param {Object} defaultSettingsObj - The default settings.
  3102. * @param {Function} applyCb - Callback to apply settings to the sidebar.
  3103. * @param {Function} buildCb - Callback to rebuild the sidebar UI.
  3104. * @param {Function} collapseCb - Callback to apply section collapse states.
  3105. * @param {Function} menuCb - Callback to initialize menu commands.
  3106. * @param {Function} renderOrderCb - Callback to render the section order list.
  3107. */
  3108. initialize: function(defaultSettingsObj, applyCb, buildCb, collapseCb, menuCb, renderOrderCb) {
  3109. if (_isInitialized) return;
  3110. _defaultSettingsRef = defaultSettingsObj;
  3111. _applySettingsToSidebar_cb = applyCb;
  3112. _buildSidebarUI_cb = buildCb;
  3113. _applySectionCollapseStates_cb = collapseCb;
  3114. _initMenuCommands_cb = menuCb;
  3115. _renderSectionOrderList_ext_cb = renderOrderCb;
  3116. this.load();
  3117. this.buildSkeleton();
  3118. _isInitialized = true;
  3119. },
  3120.  
  3121. /**
  3122. * Loads settings from storage and validates them.
  3123. */
  3124. load: function() {
  3125. const s = _loadFromStorage();
  3126. _currentSettings = _validateAndMergeSettings(s);
  3127. LocalizationService.updateActiveLocale(_currentSettings);
  3128. },
  3129.  
  3130. /**
  3131. * Saves the current settings to storage.
  3132. * @param {string} [logContext='SaveBtn'] - Context for logging the save action.
  3133. */
  3134. save: function(logContext = 'SaveBtn') {
  3135. try {
  3136. ['displayLanguages', 'displayCountries'].forEach(displayKey => {
  3137. const mapping = getListMapping(displayKey === 'displayLanguages' ? IDS.LANG_LIST : IDS.COUNTRIES_LIST);
  3138. if (mapping && mapping.customItemsMasterKey && _currentSettings[displayKey] && Array.isArray(_currentSettings[mapping.customItemsMasterKey])) {
  3139. const displayItems = _currentSettings[displayKey];
  3140. const currentDisplayCustomItems = displayItems.filter(item => item.type === 'custom');
  3141. const currentDisplayCustomItemValues = new Set(currentDisplayCustomItems.map(item => item.value));
  3142.  
  3143. const newMasterList = (_currentSettings[mapping.customItemsMasterKey] || [])
  3144. .filter(masterItem => currentDisplayCustomItemValues.has(masterItem.value))
  3145. .map(oldMasterItem => {
  3146. const correspondingDisplayItem = currentDisplayCustomItems.find(d => d.value === oldMasterItem.value);
  3147. return correspondingDisplayItem ? {
  3148. text: correspondingDisplayItem.text,
  3149. value: oldMasterItem.value
  3150. } : oldMasterItem;
  3151. });
  3152.  
  3153. currentDisplayCustomItems.forEach(dispItem => {
  3154. if (!newMasterList.find(mi => mi.value === dispItem.value)) {
  3155. newMasterList.push({
  3156. text: dispItem.text,
  3157. value: dispItem.value
  3158. });
  3159. }
  3160. });
  3161. _currentSettings[mapping.customItemsMasterKey] = newMasterList;
  3162. }
  3163. });
  3164.  
  3165. GM_setValue(STORAGE_KEY, JSON.stringify(_currentSettings));
  3166. console.log(`${LOG_PREFIX} Settings saved by SM${logContext ? ` (${logContext})` : ''}.`);
  3167. _settingsBackup = JSON.parse(JSON.stringify(_currentSettings));
  3168. } catch (e) {
  3169. console.error(`${LOG_PREFIX} SM save error:`, e);
  3170. NotificationManager.show('alert_generic_error', {
  3171. context: 'saving settings'
  3172. }, 'error', 5000);
  3173. }
  3174. },
  3175.  
  3176. /**
  3177. * Resets the current settings to the script's defaults.
  3178. */
  3179. reset: function() {
  3180. if (confirm(_('confirm_reset_settings'))) {
  3181. _currentSettings = JSON.parse(JSON.stringify(_defaultSettingsRef));
  3182. _migrateToDisplayArraysIfNecessary(_currentSettings);
  3183. if (!_currentSettings.sidebarSectionOrder || _currentSettings.sidebarSectionOrder.length === 0) {
  3184. _currentSettings.sidebarSectionOrder = [..._defaultSettingsRef.sidebarSectionOrder];
  3185. }
  3186. LocalizationService.updateActiveLocale(_currentSettings);
  3187. this.populateWindow();
  3188. _applySettingsToSidebar_cb(_currentSettings);
  3189. _buildSidebarUI_cb();
  3190. _initMenuCommands_cb();
  3191. _showGlobalMessage('alert_settings_reset_success', {}, 'success', 4000);
  3192. }
  3193. },
  3194.  
  3195. /**
  3196. * Resets all settings from a menu command, requiring a page refresh.
  3197. */
  3198. resetAllFromMenu: function() {
  3199. if (confirm(_('confirm_reset_all_menu'))) {
  3200. try {
  3201. GM_setValue(STORAGE_KEY, JSON.stringify(_defaultSettingsRef));
  3202. alert(_('alert_reset_all_menu_success'));
  3203. } catch (e) {
  3204. _showGlobalMessage('alert_reset_all_menu_fail', {}, 'error', 0);
  3205. }
  3206. }
  3207. },
  3208.  
  3209. /**
  3210. * Returns the current settings object.
  3211. * @returns {Object} The current settings.
  3212. */
  3213. getCurrentSettings: function() {
  3214. return _currentSettings;
  3215. },
  3216.  
  3217. /**
  3218. * Builds the skeleton HTML for the settings window and overlay.
  3219. */
  3220. buildSkeleton: function() {
  3221. if (_settingsWindow) return;
  3222. _settingsOverlay = document.createElement('div');
  3223. _settingsOverlay.id = IDS.SETTINGS_OVERLAY;
  3224. _settingsWindow = document.createElement('div');
  3225. _settingsWindow.id = IDS.SETTINGS_WINDOW;
  3226.  
  3227. _settingsWindow.innerHTML = `
  3228. <div class="${CSS.SETTINGS_HEADER}">
  3229. <h3>${_('settingsTitle')}</h3>
  3230. <button class="${CSS.SETTINGS_CLOSE_BTN}" title="${_('settings_close_button_title')}">${SVG_ICONS.close}</button>
  3231. </div>
  3232. <div id="${IDS.SETTINGS_MESSAGE_BAR}" class="${CSS.MESSAGE_BAR}" style="display: none;"></div>
  3233. <div class="${CSS.SETTINGS_TABS}">
  3234. <button class="${CSS.TAB_BUTTON} ${CSS.IS_ACTIVE}" data-${DATA_ATTR.TAB}="general">${_('settings_tab_general')}</button>
  3235. <button class="${CSS.TAB_BUTTON}" data-${DATA_ATTR.TAB}="appearance">${_('settings_tab_appearance')}</button>
  3236. <button class="${CSS.TAB_BUTTON}" data-${DATA_ATTR.TAB}="features">${_('settings_tab_features')}</button>
  3237. <button class="${CSS.TAB_BUTTON}" data-${DATA_ATTR.TAB}="custom">${_('settings_tab_custom')}</button>
  3238. </div>
  3239. <div class="${CSS.SETTINGS_TAB_CONTENT}">
  3240. <div class="${CSS.TAB_PANE} ${CSS.IS_ACTIVE}" data-${DATA_ATTR.TAB}="general" id="${IDS.TAB_PANE_GENERAL}"></div>
  3241. <div class="${CSS.TAB_PANE}" data-${DATA_ATTR.TAB}="appearance" id="${IDS.TAB_PANE_APPEARANCE}"></div>
  3242. <div class="${CSS.TAB_PANE}" data-${DATA_ATTR.TAB}="features" id="${IDS.TAB_PANE_FEATURES}"></div>
  3243. <div class="${CSS.TAB_PANE}" data-${DATA_ATTR.TAB}="custom" id="${IDS.TAB_PANE_CUSTOM}"></div>
  3244. </div>
  3245. <div class="${CSS.SETTINGS_FOOTER}">
  3246. <button class="${CSS.BUTTON_RESET}">${_('settings_reset_all_button')}</button>
  3247. <button class="${CSS.BUTTON_CANCEL}">${_('settings_cancel_button')}</button>
  3248. <button class="${CSS.BUTTON_SAVE}">${_('settings_save_button')}</button>
  3249. </div>`;
  3250.  
  3251. _settingsOverlay.appendChild(_settingsWindow);
  3252. document.body.appendChild(_settingsOverlay);
  3253. this.bindEvents();
  3254. },
  3255.  
  3256. /**
  3257. * Populates the settings window with content and current values.
  3258. */
  3259. populateWindow: function() {
  3260. if (!_settingsWindow) return;
  3261. try {
  3262. // Update translatable texts
  3263. _settingsWindow.querySelector(`.${CSS.SETTINGS_HEADER} h3`).textContent = _('settingsTitle');
  3264. _settingsWindow.querySelector(`.${CSS.SETTINGS_CLOSE_BTN}`).title = _('settings_close_button_title');
  3265. _settingsWindow.querySelector(`button[data-${DATA_ATTR.TAB}="general"]`).textContent = _('settings_tab_general');
  3266. _settingsWindow.querySelector(`button[data-${DATA_ATTR.TAB}="appearance"]`).textContent = _('settings_tab_appearance');
  3267. _settingsWindow.querySelector(`button[data-${DATA_ATTR.TAB}="features"]`).textContent = _('settings_tab_features');
  3268. _settingsWindow.querySelector(`button[data-${DATA_ATTR.TAB}="custom"]`).textContent = _('settings_tab_custom');
  3269. _settingsWindow.querySelector(`.${CSS.BUTTON_RESET}`).textContent = _('settings_reset_all_button');
  3270. _settingsWindow.querySelector(`.${CSS.BUTTON_CANCEL}`).textContent = _('settings_cancel_button');
  3271. _settingsWindow.querySelector(`.${CSS.BUTTON_SAVE}`).textContent = _('settings_save_button');
  3272.  
  3273. // Re-generate tab content
  3274. const paneGeneral = _settingsWindow.querySelector(`#${IDS.TAB_PANE_GENERAL}`);
  3275. if (paneGeneral) paneGeneral.innerHTML = SettingsUIPaneGenerator.createGeneralPaneHTML();
  3276. const paneAppearance = _settingsWindow.querySelector(`#${IDS.TAB_PANE_APPEARANCE}`);
  3277. if (paneAppearance) paneAppearance.innerHTML = SettingsUIPaneGenerator.createAppearancePaneHTML();
  3278. const paneFeatures = _settingsWindow.querySelector(`#${IDS.TAB_PANE_FEATURES}`);
  3279. if (paneFeatures) paneFeatures.innerHTML = SettingsUIPaneGenerator.createFeaturesPaneHTML();
  3280. const paneCustom = _settingsWindow.querySelector(`#${IDS.TAB_PANE_CUSTOM}`);
  3281. if (paneCustom) paneCustom.innerHTML = SettingsUIPaneGenerator.createCustomPaneHTML();
  3282.  
  3283. // Populate values
  3284. _populateGeneralSettings_internal(_settingsWindow, _currentSettings);
  3285. _populateAppearanceSettings_internal(_settingsWindow, _currentSettings);
  3286. _populateFeatureSettings_internal(_settingsWindow, _currentSettings, _renderSectionOrderList_ext_cb);
  3287. ModalManager.resetEditStateGlobally();
  3288. _initializeActiveSettingsTab_internal();
  3289. this.bindLiveUpdateEvents();
  3290. this.bindFeaturesTabEvents();
  3291. } catch (e) {
  3292. _showGlobalMessage('alert_init_fail', {
  3293. scriptName: SCRIPT_INTERNAL_NAME,
  3294. error: "Settings UI pop err"
  3295. }, 'error', 0);
  3296. }
  3297. },
  3298.  
  3299. /**
  3300. * Shows the settings window.
  3301. */
  3302. show: function() {
  3303. if (!_settingsOverlay || !_settingsWindow) return;
  3304. _settingsBackup = JSON.parse(JSON.stringify(_currentSettings));
  3305. LocalizationService.updateActiveLocale(_currentSettings);
  3306. this.populateWindow();
  3307. applyThemeToElement(_settingsWindow, _currentSettings.theme);
  3308. applyThemeToElement(_settingsOverlay, _currentSettings.theme);
  3309. _settingsOverlay.style.display = 'flex';
  3310. },
  3311.  
  3312. /**
  3313. * Hides the settings window.
  3314. * @param {boolean} [isCancel=false] - If true, reverts any live changes to their pre-opening state.
  3315. */
  3316. hide: function(isCancel = false) {
  3317. if (!_settingsOverlay) return;
  3318. ModalManager.resetEditStateGlobally();
  3319. if (ModalManager.isModalOpen()) ModalManager.hide(true);
  3320. _settingsOverlay.style.display = 'none';
  3321. const messageBar = document.getElementById(IDS.SETTINGS_MESSAGE_BAR);
  3322. if (messageBar) messageBar.style.display = 'none';
  3323. if (isCancel && _settingsBackup && Object.keys(_settingsBackup).length > 0) {
  3324. _currentSettings = JSON.parse(JSON.stringify(_settingsBackup));
  3325. LocalizationService.updateActiveLocale(_currentSettings);
  3326. this.populateWindow();
  3327. _applySettingsToSidebar_cb(_currentSettings);
  3328. _buildSidebarUI_cb();
  3329. _initMenuCommands_cb();
  3330. } else if (isCancel) {
  3331. console.warn(`${LOG_PREFIX} SM: Cancelled, no backup to restore or backup was identical.`);
  3332. }
  3333. },
  3334.  
  3335. /**
  3336. * Binds the main, one-time events for the settings window using event delegation.
  3337. */
  3338. bindEvents: function() {
  3339. if (!_settingsWindow || _settingsWindow.dataset.eventsBound === 'true') return;
  3340.  
  3341. _settingsWindow.addEventListener('click', (e) => {
  3342. const target = e.target;
  3343. // Close, Cancel, Save, Reset buttons
  3344. if (target.closest(`.${CSS.SETTINGS_CLOSE_BTN}`)) this.hide(true);
  3345. else if (target.closest(`.${CSS.BUTTON_CANCEL}`)) this.hide(true);
  3346. else if (target.closest(`.${CSS.BUTTON_SAVE}`)) {
  3347. this.save();
  3348. LocalizationService.updateActiveLocale(_currentSettings);
  3349. _initMenuCommands_cb();
  3350. _buildSidebarUI_cb();
  3351. this.hide(false);
  3352. } else if (target.closest(`.${CSS.BUTTON_RESET}`)) this.reset();
  3353. // Tab navigation
  3354. else if (target.closest(`.${CSS.TAB_BUTTON}`) && !target.closest(`.${CSS.IS_ACTIVE}`)) {
  3355. ModalManager.resetEditStateGlobally();
  3356. const tabToActivate = target.closest(`.${CSS.TAB_BUTTON}`).dataset[DATA_ATTR.TAB];
  3357. if (!tabToActivate) return;
  3358. _settingsWindow.querySelectorAll(`.${CSS.TAB_BUTTON}`).forEach(b => b.classList.remove(CSS.IS_ACTIVE));
  3359. target.closest(`.${CSS.TAB_BUTTON}`).classList.add(CSS.IS_ACTIVE);
  3360. _settingsWindow.querySelector(`.${CSS.SETTINGS_TAB_CONTENT}`)?.querySelectorAll(`.${CSS.TAB_PANE}`)?.forEach(p => p.classList.remove(CSS.IS_ACTIVE));
  3361. _settingsWindow.querySelector(`.${CSS.SETTINGS_TAB_CONTENT} .${CSS.TAB_PANE}[data-${DATA_ATTR.TAB}="${tabToActivate}"]`)?.classList.add(CSS.IS_ACTIVE);
  3362. }
  3363. // Manage custom buttons
  3364. else if (target.closest(`.${CSS.BUTTON_MANAGE_CUSTOM}`)) {
  3365. const manageType = target.closest(`.${CSS.BUTTON_MANAGE_CUSTOM}`).dataset[DATA_ATTR.MANAGE_TYPE];
  3366. if (manageType) {
  3367. ModalManager.openManageCustomOptions(manageType, _currentSettings, PREDEFINED_OPTIONS,
  3368. (updatedItemsArray, newEnabledPredefs, itemsArrayKey, predefinedOptKey) => {
  3369. if (itemsArrayKey) _currentSettings[itemsArrayKey] = updatedItemsArray;
  3370. if (predefinedOptKey && newEnabledPredefs) {
  3371. if (!_currentSettings.enabledPredefinedOptions) _currentSettings.enabledPredefinedOptions = {};
  3372. _currentSettings.enabledPredefinedOptions[predefinedOptKey] = newEnabledPredefs;
  3373. }
  3374. _buildSidebarUI_cb();
  3375. }
  3376. );
  3377. }
  3378. }
  3379. });
  3380.  
  3381. _settingsWindow.dataset.eventsBound = 'true';
  3382. },
  3383.  
  3384. /**
  3385. * Binds events for controls that provide a live preview of changes (e.g., sliders, color pickers).
  3386. */
  3387. bindLiveUpdateEvents: function() {
  3388. if (!_settingsWindow) return;
  3389. const liveUpdateHandler = (e) => {
  3390. const target = e.target;
  3391. if (target && _sEH_internal[target.id]) {
  3392. const rangeValueSpan = (target.type === 'range') ? target.parentNode.querySelector(`.${CSS.SETTING_RANGE_VALUE}`) : null;
  3393. _sEH_internal[target.id](target, rangeValueSpan);
  3394. } else if (target && target.type === 'color') {
  3395. _lUH_internal(e);
  3396. }
  3397. };
  3398.  
  3399. _settingsWindow.removeEventListener('input', _lUH_internal);
  3400. _settingsWindow.removeEventListener('change', _lUH_internal);
  3401.  
  3402. _settingsWindow.addEventListener('input', liveUpdateHandler);
  3403. _settingsWindow.addEventListener('change', liveUpdateHandler);
  3404.  
  3405. const resetColorsBtn = _settingsWindow.querySelector(`#${IDS.RESET_CUSTOM_COLORS_BTN}`);
  3406. if (resetColorsBtn) {
  3407. resetColorsBtn.removeEventListener('click', _sEH_internal[IDS.RESET_CUSTOM_COLORS_BTN]);
  3408. resetColorsBtn.addEventListener('click', _sEH_internal[IDS.RESET_CUSTOM_COLORS_BTN]);
  3409. }
  3410. },
  3411.  
  3412. /**
  3413. * Binds events specific to the "Features" tab, such as section visibility checkboxes.
  3414. */
  3415. bindFeaturesTabEvents: function() {
  3416. const featuresPane = _settingsWindow?.querySelector(`#${IDS.TAB_PANE_FEATURES}`);
  3417. if (!featuresPane) return;
  3418. featuresPane.querySelectorAll(`input[type="checkbox"][data-${DATA_ATTR.SECTION_ID}]`).forEach(checkbox => {
  3419. checkbox.removeEventListener('change', this._handleVisibleSectionChange);
  3420. checkbox.addEventListener('change', this._handleVisibleSectionChange.bind(this));
  3421. });
  3422.  
  3423. const orderListElement = featuresPane.querySelector(`#${IDS.SIDEBAR_SECTION_ORDER_LIST}`);
  3424. if (orderListElement) {
  3425. SectionOrderDragHandler.initialize(orderListElement, _currentSettings, () => {
  3426. _renderSectionOrderList_ext_cb(_currentSettings);
  3427. _buildSidebarUI_cb();
  3428. });
  3429. }
  3430. },
  3431.  
  3432. /**
  3433. * Handles changes to the visibility of a section.
  3434. * @private
  3435. * @param {Event} e - The change event from a visibility checkbox.
  3436. */
  3437. _handleVisibleSectionChange: function(e) {
  3438. const target = e.target;
  3439. const sectionId = target.getAttribute(`data-${DATA_ATTR.SECTION_ID}`);
  3440. if (sectionId && _currentSettings.visibleSections.hasOwnProperty(sectionId)) {
  3441. _currentSettings.visibleSections[sectionId] = target.checked;
  3442. _finalizeSectionOrder_internal(_currentSettings, _currentSettings, _defaultSettingsRef);
  3443. _renderSectionOrderList_ext_cb(_currentSettings);
  3444. _buildSidebarUI_cb();
  3445. }
  3446. },
  3447. };
  3448. return publicApi;
  3449. })();
  3450.  
  3451. /**
  3452. * @module DragManager
  3453. * Manages the dragging functionality for the main sidebar, allowing the user
  3454. * to reposition it on the screen.
  3455. */
  3456. const DragManager = (function() {
  3457. let _isDragging = false; let _dragStartX, _dragStartY, _sidebarStartX, _sidebarStartY;
  3458. let _sidebarElement, _handleElement; let _settingsManagerRef, _saveCallbackRef;
  3459.  
  3460. /**
  3461. * Safely extracts client coordinates (x, y) from a mouse or touch event.
  3462. * This utility function handles both desktop (mousedown, mousemove) and mobile (touchstart, touchmove) events.
  3463. * @param {MouseEvent|TouchEvent} e - The browser event object.
  3464. * @returns {{x: number, y: number}} An object containing the x and y coordinates.
  3465. */
  3466. 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 }; }
  3467.  
  3468. /**
  3469. * Initiates a drag operation.
  3470. * @private
  3471. * @param {MouseEvent|TouchEvent} e - The start event.
  3472. */
  3473. function _startDrag(e) {
  3474. const currentSettings = _settingsManagerRef.getCurrentSettings();
  3475. if (!currentSettings.draggableHandleEnabled || currentSettings.sidebarCollapsed || (e.type === 'mousedown' && e.button !== 0)) {
  3476. return;
  3477. }
  3478. e.preventDefault();
  3479. _isDragging = true;
  3480. const coords = _getEventCoordinates(e);
  3481. _dragStartX = coords.x;
  3482. _dragStartY = coords.y;
  3483. _sidebarStartX = _sidebarElement.offsetLeft;
  3484. _sidebarStartY = _sidebarElement.offsetTop;
  3485. _sidebarElement.style.cursor = 'grabbing';
  3486. _sidebarElement.style.userSelect = 'none';
  3487. document.body.style.cursor = 'grabbing';
  3488. }
  3489.  
  3490. /**
  3491. * Handles the dragging movement.
  3492. * @private
  3493. * @param {MouseEvent|TouchEvent} e - The move event.
  3494. */
  3495. function _drag(e) {
  3496. if (!_isDragging) return;
  3497. e.preventDefault();
  3498. const coords = _getEventCoordinates(e);
  3499. const dx = coords.x - _dragStartX;
  3500. const dy = coords.y - _dragStartY;
  3501. let newLeft = _sidebarStartX + dx;
  3502. let newTop = _sidebarStartY + dy;
  3503.  
  3504. const maxLeft = window.innerWidth - (_sidebarElement?.offsetWidth ?? 0);
  3505. const maxTop = window.innerHeight - (_sidebarElement?.offsetHeight ?? 0);
  3506. newLeft = Utils.clamp(newLeft, 0, maxLeft);
  3507. newTop = Utils.clamp(newTop, MIN_SIDEBAR_TOP_POSITION, maxTop);
  3508.  
  3509. if (_sidebarElement) {
  3510. _sidebarElement.style.left = `${newLeft}px`;
  3511. _sidebarElement.style.top = `${newTop}px`;
  3512. }
  3513. }
  3514.  
  3515. /**
  3516. * Stops the drag operation and saves the new position.
  3517. * @private
  3518. */
  3519. function _stopDrag() {
  3520. if (_isDragging) {
  3521. _isDragging = false;
  3522. if (_sidebarElement) {
  3523. _sidebarElement.style.cursor = 'default';
  3524. _sidebarElement.style.userSelect = '';
  3525. }
  3526. document.body.style.cursor = '';
  3527.  
  3528. const currentSettings = _settingsManagerRef.getCurrentSettings();
  3529. if (!currentSettings.sidebarPosition) currentSettings.sidebarPosition = {};
  3530. currentSettings.sidebarPosition.left = _sidebarElement.offsetLeft;
  3531. currentSettings.sidebarPosition.top = _sidebarElement.offsetTop;
  3532.  
  3533. if (typeof _saveCallbackRef === 'function') {
  3534. _saveCallbackRef('Drag Stop');
  3535. }
  3536. }
  3537. }
  3538. return {
  3539. /**
  3540. * Initializes the DragManager with necessary elements and callbacks.
  3541. * @param {HTMLElement} sidebarEl - The main sidebar element.
  3542. * @param {HTMLElement} handleEl - The specific drag handle element.
  3543. * @param {Object} settingsMgr - A reference to the SettingsManager.
  3544. * @param {Function} saveCb - The debounced save settings callback.
  3545. */
  3546. init: function(sidebarEl, handleEl, settingsMgr, saveCb) {
  3547. _sidebarElement = sidebarEl;
  3548. _handleElement = handleEl;
  3549. _settingsManagerRef = settingsMgr;
  3550. _saveCallbackRef = saveCb;
  3551.  
  3552. if (_handleElement) {
  3553. _handleElement.addEventListener('mousedown', _startDrag);
  3554. _handleElement.addEventListener('touchstart', _startDrag, { passive: false });
  3555. }
  3556. document.addEventListener('mousemove', _drag);
  3557. document.addEventListener('touchmove', _drag, { passive: false });
  3558. document.addEventListener('mouseup', _stopDrag);
  3559. document.addEventListener('touchend', _stopDrag);
  3560. document.addEventListener('touchcancel', _stopDrag);
  3561. },
  3562.  
  3563. /**
  3564. * Enables or disables the dragging functionality.
  3565. * @param {boolean} isEnabled - Whether dragging should be enabled.
  3566. * @param {HTMLElement} sidebarEl - The main sidebar element.
  3567. * @param {HTMLElement} handleEl - The drag handle element.
  3568. */
  3569. setDraggable: function(isEnabled, sidebarEl, handleEl) {
  3570. _sidebarElement = sidebarEl;
  3571. _handleElement = handleEl;
  3572. if (_handleElement) {
  3573. _handleElement.style.display = isEnabled ? 'block' : 'none';
  3574. }
  3575. }
  3576. };
  3577. })();
  3578.  
  3579. /**
  3580. * @module ResultStatsManager
  3581. * Manages the display of search result statistics (count and time) in the sidebar.
  3582. * It uses a robust combination of polling and MutationObserver to reliably find
  3583. * the statistics element on the Google search results page, which can load asynchronously.
  3584. */
  3585. const ResultStatsManager = (function() {
  3586. let _container = null;
  3587. let _observer = null;
  3588. let _isUpdating = false;
  3589. let _pollingInterval = null;
  3590. let _pollCount = 0;
  3591. const MAX_POLLS = 16;
  3592. const POLLING_INTERVAL_MS = 250;
  3593. let _statsDisplayed = false;
  3594.  
  3595. /**
  3596. * Parses the raw statistics string from Google's result-stats element
  3597. * and formats it into a more compact "count (time)" string.
  3598. * @private
  3599. * @param {string} text - The raw text content from the #result-stats element.
  3600. * @returns {string} The formatted statistics string, or an empty string if parsing fails.
  3601. */
  3602. function _parseAndFormatStats(text) {
  3603. if (!text) return '';
  3604.  
  3605. const timeRegex = /[(\(]([\d,.]+)\s*\S+[)\)]/i;
  3606. const timeMatch = text.match(timeRegex);
  3607.  
  3608. if (!timeMatch) {
  3609. const fallbackTimeRegex = /[(\(]([\d,.]+)\s*s[)\)]/i;
  3610. const fallbackTimeMatch = text.match(fallbackTimeRegex);
  3611. if (!fallbackTimeMatch) {
  3612. return '';
  3613. }
  3614. timeMatch = fallbackTimeMatch;
  3615. }
  3616.  
  3617. const timeStr = timeMatch[1].replace(',', '.');
  3618. let textWithoutTime = text.replace(timeMatch[0], '');
  3619.  
  3620. const numberRegex = /[\d.,\s ]+/g;
  3621. let allNumbers = textWithoutTime.match(numberRegex) || [];
  3622.  
  3623. if (allNumbers.length === 0) {
  3624. return '';
  3625. }
  3626.  
  3627. let largestNumber = 0;
  3628. allNumbers.forEach(numStr => {
  3629. const cleanNumStr = numStr.trim().replace(/[.,\s ]/g, '');
  3630. if (cleanNumStr) {
  3631. const num = parseInt(cleanNumStr, 10);
  3632. if (!isNaN(num) && num > largestNumber) {
  3633. largestNumber = num;
  3634. }
  3635. }
  3636. });
  3637.  
  3638. if (largestNumber === 0) {
  3639. if (!/\s0\s/.test(textWithoutTime)) {
  3640. return '';
  3641. }
  3642. }
  3643.  
  3644. const formattedCount = new Intl.NumberFormat('en-US').format(largestNumber);
  3645. return `${formattedCount} (${timeStr}s)`;
  3646. }
  3647.  
  3648. /**
  3649. * Updates the displayed statistics in the sidebar if the source element is found.
  3650. * @private
  3651. * @returns {boolean} True if the stats were successfully displayed, false otherwise.
  3652. */
  3653. function _updateDisplay() {
  3654. if (_isUpdating) return false;
  3655.  
  3656. if (!SettingsManager.getCurrentSettings().showResultStats) {
  3657. _clearDisplay();
  3658. return true;
  3659. }
  3660.  
  3661. const sourceEl = document.getElementById('result-stats');
  3662. if (!sourceEl || !_container) {
  3663. _clearDisplay();
  3664. return false;
  3665. }
  3666.  
  3667. const formattedText = _parseAndFormatStats(sourceEl.textContent);
  3668. const displayEl = _container.querySelector('#gscs-result-stats-display');
  3669.  
  3670. if (formattedText) {
  3671. _isUpdating = true;
  3672. if (displayEl && displayEl.textContent !== formattedText) {
  3673. displayEl.textContent = formattedText;
  3674. }
  3675. if (_container.style.display === 'none') {
  3676. _container.style.display = '';
  3677. }
  3678. _statsDisplayed = true;
  3679. _isUpdating = false;
  3680. return true;
  3681. } else {
  3682. _clearDisplay();
  3683. return false;
  3684. }
  3685. }
  3686.  
  3687. /**
  3688. * Clears the displayed statistics from the sidebar.
  3689. * @private
  3690. */
  3691. function _clearDisplay() {
  3692. if (_container) {
  3693. _isUpdating = true;
  3694. const displayEl = _container.querySelector('#gscs-result-stats-display');
  3695. if (displayEl && displayEl.textContent !== '') {
  3696. displayEl.textContent = '';
  3697. }
  3698. if (_container.style.display !== 'none') {
  3699. _container.style.display = 'none';
  3700. }
  3701. _isUpdating = false;
  3702. }
  3703. }
  3704.  
  3705. /**
  3706. * Stops the polling interval used to find the stats element.
  3707. * @private
  3708. */
  3709. function _stopPolling() {
  3710. if (_pollingInterval) {
  3711. clearInterval(_pollingInterval);
  3712. _pollingInterval = null;
  3713. }
  3714. }
  3715.  
  3716. /**
  3717. * Starts a robust check for the statistics element, using both polling and a MutationObserver.
  3718. * @private
  3719. */
  3720. function _startRobustCheck() {
  3721. if (_updateDisplay()) {
  3722. return;
  3723. }
  3724.  
  3725. _stopPolling();
  3726. _pollCount = 0;
  3727. _pollingInterval = setInterval(() => {
  3728. _pollCount++;
  3729. if (_updateDisplay() || _pollCount >= MAX_POLLS) {
  3730. _stopPolling();
  3731. }
  3732. }, POLLING_INTERVAL_MS);
  3733.  
  3734. if (_observer) _observer.disconnect();
  3735. const targetNode = document.body;
  3736. if (!targetNode) return;
  3737.  
  3738. _observer = new MutationObserver(() => {
  3739. if (document.getElementById('result-stats')) {
  3740. _updateDisplay();
  3741. _stopPolling();
  3742. _observer.disconnect();
  3743. }
  3744. });
  3745. _observer.observe(targetNode, { childList: true, subtree: true });
  3746. }
  3747.  
  3748. window.addEventListener('load', () => {
  3749. setTimeout(() => {
  3750. if (!_statsDisplayed) {
  3751. _updateDisplay();
  3752. }
  3753. }, 500);
  3754. });
  3755.  
  3756. return {
  3757. /**
  3758. * Initializes the manager, starting the search for the stats element.
  3759. */
  3760. init: function() {
  3761. _statsDisplayed = false;
  3762. _startRobustCheck();
  3763. },
  3764.  
  3765. /**
  3766. * Creates the container element for the stats display.
  3767. * @param {DocumentFragment} parentFragment - The fragment to append the container to.
  3768. */
  3769. createContainer: function(parentFragment) {
  3770. _container = document.createElement('div');
  3771. _container.id = IDS.RESULT_STATS_CONTAINER;
  3772. const display = document.createElement('div');
  3773. display.id = 'gscs-result-stats-display';
  3774. _container.appendChild(display);
  3775. parentFragment.appendChild(_container);
  3776. _container.style.display = 'none';
  3777. },
  3778.  
  3779. /**
  3780. * Public method to trigger a manual update of the stats display.
  3781. */
  3782. update: _updateDisplay,
  3783.  
  3784. /**
  3785. * Toggles the visibility of the stats display based on user settings.
  3786. * @param {boolean} show - Whether to show or hide the stats.
  3787. */
  3788. toggle: function(show) {
  3789. if (show) {
  3790. if (!_statsDisplayed) {
  3791. _startRobustCheck();
  3792. } else {
  3793. _updateDisplay();
  3794. }
  3795. } else {
  3796. _clearDisplay();
  3797. }
  3798. }
  3799. };
  3800. })();
  3801. /**
  3802. * @module URLActionManager
  3803. * The core action handler of the script. It is responsible for applying all filters
  3804. * by generating new Google search URLs with the appropriate parameters and then
  3805. * navigating the browser to them. It handles a variety of URL parameters, including
  3806. * the complex `tbs` parameter and query-modifying operators like `site:`.
  3807. */
  3808. const URLActionManager = (function() {
  3809. function _getURLObject() { try { return new URL(window.location.href); } catch (e) { console.error(`${LOG_PREFIX} Error creating URL object: `, e); return null; }}
  3810. function _navigateTo(url) { window.location.href = url.toString(); }
  3811. function _setSearchParam(urlObj, paramName, value) { urlObj.searchParams.set(paramName, value); }
  3812. function _deleteSearchParam(urlObj, paramName) { urlObj.searchParams.delete(paramName); }
  3813. function _getTbsParts(urlObj) { const tbs = urlObj.searchParams.get('tbs'); return tbs ? tbs.split(',').filter(p => p.trim() !== '') : []; }
  3814. function _setTbsParam(urlObj, tbsPartsArray) { const newTbsValue = tbsPartsArray.join(','); if (newTbsValue) { _setSearchParam(urlObj, 'tbs', newTbsValue); } else { _deleteSearchParam(urlObj, 'tbs'); }}
  3815.  
  3816. /**
  3817. * A generic function to generate a new URL based on a modification function.
  3818. * @param {Function} urlModifier - A function that takes a URL object and modifies it.
  3819. * @returns {string|null} The string representation of the modified URL, or null on error.
  3820. */
  3821. function generateURL(urlModifier) {
  3822. try {
  3823. const u = _getURLObject();
  3824. if (!u) return null;
  3825. urlModifier(u);
  3826. return u.toString();
  3827. } catch (e) {
  3828. console.error(`${LOG_PREFIX} Error generating URL:`, e);
  3829. return null;
  3830. }
  3831. }
  3832.  
  3833. const publicApi = {
  3834. generateURLObject: _getURLObject,
  3835. generateResetFiltersURL: function() {
  3836. return generateURL(u => {
  3837. const q = u.searchParams.get('q') || '';
  3838. const nP = new URLSearchParams();
  3839. // Clean both site and filetype operators
  3840. let cQ = Utils._cleanQueryByOperator(q, 'site');
  3841. cQ = Utils._cleanQueryByOperator(cQ, 'filetype');
  3842.  
  3843. if (cQ) { nP.set('q', cQ); }
  3844. u.search = nP.toString();
  3845. _deleteSearchParam(u, 'tbs'); _deleteSearchParam(u, 'lr'); _deleteSearchParam(u, 'cr');
  3846. _deleteSearchParam(u, 'as_filetype'); _deleteSearchParam(u, 'as_occt');
  3847. });
  3848. },
  3849. generateToggleVerbatimURL: function() {
  3850. return generateURL(u => {
  3851. let tP = _getTbsParts(u);
  3852. const vP = 'li:1';
  3853. const iCA = tP.includes(vP);
  3854. tP = tP.filter(p => p !== vP);
  3855. if (!iCA) { tP.push(vP); }
  3856. _setTbsParam(u, tP);
  3857. });
  3858. },
  3859. generateTogglePersonalizationURL: function() {
  3860. return generateURL(u => {
  3861. const isActive = publicApi.isPersonalizationActive();
  3862. if (isActive) { _setSearchParam(u, 'pws', '0'); }
  3863. else { _deleteSearchParam(u, 'pws'); }
  3864. });
  3865. },
  3866. generateFilterURL: function(type, value) {
  3867. return generateURL(u => {
  3868. let tbsParts = _getTbsParts(u);
  3869. const isTimeFilter = type === 'qdr';
  3870. const isStandaloneParam = ['lr', 'cr', 'as_occt'].includes(type);
  3871.  
  3872. if (isTimeFilter) {
  3873. let processedTbsParts = tbsParts.filter(p => !p.startsWith(`qdr:`) && !p.startsWith('cdr:') && !p.startsWith('cd_min:') && !p.startsWith('cd_max:'));
  3874. if (value !== '') processedTbsParts.push(`qdr:${value}`);
  3875. _setTbsParam(u, processedTbsParts);
  3876. } else if (isStandaloneParam) {
  3877. _deleteSearchParam(u, type);
  3878. if (value !== '' && !(type === 'as_occt' && value === 'any')) {
  3879. _setSearchParam(u, type, value);
  3880. }
  3881. } else if (type === 'as_filetype') {
  3882. let currentQuery = u.searchParams.get('q') || '';
  3883. // MODIFIED: Only clean 'filetype:' operators
  3884. currentQuery = Utils._cleanQueryByOperator(currentQuery, 'filetype');
  3885.  
  3886. if (value !== '') {
  3887. _setSearchParam(u, 'q', (currentQuery + ` filetype:${value}`).trim());
  3888. } else {
  3889. if (currentQuery) _setSearchParam(u, 'q', currentQuery);
  3890. else _deleteSearchParam(u, 'q');
  3891. }
  3892. _deleteSearchParam(u, 'as_filetype');
  3893. }
  3894. });
  3895. },
  3896. generateSiteSearchURL: function(siteCriteria) {
  3897. return generateURL(u => {
  3898. const sitesToSearch = Array.isArray(siteCriteria) ? siteCriteria.flatMap(sc => Utils.parseCombinedValue(sc)) : Utils.parseCombinedValue(siteCriteria);
  3899. const uniqueSites = [...new Set(sitesToSearch.map(s => s.toLowerCase()))];
  3900. if (uniqueSites.length === 0) return;
  3901. let q = u.searchParams.get('q') || '';
  3902. // MODIFIED: Only clean 'site:' operators
  3903. q = Utils._cleanQueryByOperator(q, 'site');
  3904. let siteQueryPart = uniqueSites.map(s => `site:${s}`).join(' OR ');
  3905. const nQ = `${q} ${siteQueryPart}`.trim();
  3906. _setSearchParam(u, 'q', nQ);
  3907. // Clear other filters that conflict with site search
  3908. _deleteSearchParam(u, 'cr'); _deleteSearchParam(u, 'as_filetype');
  3909. });
  3910. },
  3911. generateCombinedFiletypeSearchURL: function(filetypeCriteria) {
  3912. return generateURL(u => {
  3913. const filetypesToSearch = Array.isArray(filetypeCriteria) ? filetypeCriteria.flatMap(fc => Utils.parseCombinedValue(fc)) : Utils.parseCombinedValue(filetypeCriteria);
  3914. const uniqueFiletypes = [...new Set(filetypesToSearch.map(f => f.toLowerCase()))];
  3915. if (uniqueFiletypes.length === 0) return;
  3916. let q = u.searchParams.get('q') || '';
  3917. // MODIFIED: Only clean 'filetype:' operators
  3918. q = Utils._cleanQueryByOperator(q, 'filetype');
  3919. let filetypeQueryPart = uniqueFiletypes.map(ft => `filetype:${ft}`).join(' OR ');
  3920. const nQ = `${q} ${filetypeQueryPart}`.trim();
  3921. _setSearchParam(u, 'q', nQ);
  3922. _deleteSearchParam(u, 'as_filetype');
  3923. });
  3924. },
  3925. generateClearSiteSearchURL: function() {
  3926. return generateURL(u => {
  3927. const q = u.searchParams.get('q') || '';
  3928. // MODIFIED: Only clean 'site:' operators
  3929. let nQ = Utils._cleanQueryByOperator(q, 'site');
  3930. if (nQ) { _setSearchParam(u, 'q', nQ); } else { _deleteSearchParam(u, 'q'); }
  3931. });
  3932. },
  3933. generateClearFiletypeSearchURL: function() {
  3934. return generateURL(u => {
  3935. let q = u.searchParams.get('q') || '';
  3936. // MODIFIED: Only clean 'filetype:' operators
  3937. q = Utils._cleanQueryByOperator(q, 'filetype');
  3938. if (q) { _setSearchParam(u, 'q', q); } else { _deleteSearchParam(u, 'q'); }
  3939. _deleteSearchParam(u, 'as_filetype');
  3940. });
  3941. },
  3942. generateDateRangeURL: function(dateMinStr, dateMaxStr) {
  3943. return generateURL(u => {
  3944. let dateTbsPart = 'cdr:1';
  3945. if (dateMinStr) { const [y, m, d] = dateMinStr.split('-'); dateTbsPart += `,cd_min:${m}/${d}/${y}`; }
  3946. if (dateMaxStr) { const [y, m, d] = dateMaxStr.split('-'); dateTbsPart += `,cd_max:${m}/${d}/${y}`; }
  3947. let tbsParts = _getTbsParts(u);
  3948. let preservedTbsParts = tbsParts.filter(p => !p.startsWith('qdr:') && !p.startsWith('cdr:') && !p.startsWith('cd_min:') && !p.startsWith('cd_max:'));
  3949. let newTbsParts = [...preservedTbsParts, dateTbsPart];
  3950. _setTbsParam(u, newTbsParts);
  3951. });
  3952. },
  3953. triggerResetFilters: function() {
  3954. const url = publicApi.generateResetFiltersURL();
  3955. if(url) _navigateTo(url);
  3956. else NotificationManager.show('alert_error_resetting_filters', {}, 'error', 5000);
  3957. },
  3958. triggerToggleVerbatim: function() {
  3959. const url = publicApi.generateToggleVerbatimURL();
  3960. if(url) _navigateTo(url);
  3961. else NotificationManager.show('alert_error_toggling_verbatim', {}, 'error', 5000);
  3962. },
  3963. triggerTogglePersonalization: function() {
  3964. const url = publicApi.generateTogglePersonalizationURL();
  3965. if (url) _navigateTo(url);
  3966. else NotificationManager.show('alert_error_toggling_personalization', {}, 'error', 5000);
  3967. },
  3968. applyFilter: function(type, value) {
  3969. if (type === 'as_filetype' && Utils.parseCombinedValue(value).length > 1) {
  3970. publicApi.applyCombinedFiletypeSearch(value);
  3971. return;
  3972. }
  3973. const url = publicApi.generateFilterURL(type, value);
  3974. if (url) _navigateTo(url);
  3975. else NotificationManager.show('alert_error_applying_filter', { type, value }, 'error', 5000);
  3976. },
  3977. applySiteSearch: function(siteCriteria) {
  3978. const url = publicApi.generateSiteSearchURL(siteCriteria);
  3979. if (url) _navigateTo(url);
  3980. else {
  3981. const siteForError = Array.isArray(siteCriteria) ? siteCriteria.join(', ') : siteCriteria;
  3982. NotificationManager.show('alert_error_applying_site_search', { site: siteForError }, 'error', 5000);
  3983. }
  3984. },
  3985. applyCombinedFiletypeSearch: function(filetypeCriteria) {
  3986. const url = publicApi.generateCombinedFiletypeSearchURL(filetypeCriteria);
  3987. if (url) _navigateTo(url);
  3988. else {
  3989. const ftForError = Array.isArray(filetypeCriteria) ? filetypeCriteria.join(', ') : filetypeCriteria;
  3990. NotificationManager.show('alert_error_applying_filter', { type: 'filetype (combined)', value: ftForError }, 'error', 5000);
  3991. }
  3992. },
  3993. clearSiteSearch: function() {
  3994. const url = publicApi.generateClearSiteSearchURL();
  3995. if(url) _navigateTo(url);
  3996. else NotificationManager.show('alert_error_clearing_site_search', {}, 'error', 5000);
  3997. },
  3998. clearFiletypeSearch: function() {
  3999. const url = publicApi.generateClearFiletypeSearchURL();
  4000. if (url) _navigateTo(url);
  4001. else NotificationManager.show('alert_error_applying_filter', { type: 'filetype', value: '(clear)' }, 'error', 5000);
  4002. },
  4003. applyDateRange: function(dateMinStr, dateMaxStr) {
  4004. const url = publicApi.generateDateRangeURL(dateMinStr, dateMaxStr);
  4005. if(url) _navigateTo(url);
  4006. else NotificationManager.show('alert_error_applying_date', {}, 'error', 5000);
  4007. },
  4008. 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; }},
  4009. 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; }},
  4010. };
  4011. return publicApi;
  4012. })();
  4013.  
  4014. /**
  4015. * Injects the script's CSS styles into the page.
  4016. * It relies on the companion style script to populate `window.GSCS_Namespace.stylesText`.
  4017. */
  4018. 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!"; }`);} } }
  4019.  
  4020. /**
  4021. * Sets up a listener to detect changes in the system's color scheme (light/dark mode).
  4022. * This allows the sidebar to automatically update its theme when the user's system theme changes,
  4023. * provided the "Follow System" theme option is selected.
  4024. */
  4025. 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; } }
  4026.  
  4027. /**
  4028. * Builds the basic HTML structure (skeleton) for the sidebar and injects it into the page.
  4029. * This initial structure includes the header with collapse, drag, and settings buttons.
  4030. */
  4031. 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); }
  4032.  
  4033. /**
  4034. * Applies all visual settings (position, dimensions, theme, colors, etc.) to the sidebar element.
  4035. * This function is called whenever a relevant setting is changed.
  4036. * @param {Object} [settingsToApply] - The settings object to apply. If not provided, it uses the current settings.
  4037. */
  4038. function applySettings(settingsToApply) {
  4039. if (!sidebar) return;
  4040. const currentSettings = settingsToApply || SettingsManager.getCurrentSettings();
  4041. let targetTop = currentSettings.sidebarPosition.top;
  4042. targetTop = Math.max(MIN_SIDEBAR_TOP_POSITION, targetTop);
  4043. sidebar.style.left = `${currentSettings.sidebarPosition.left}px`;
  4044. sidebar.style.top = `${targetTop}px`;
  4045. sidebar.style.setProperty('--sidebar-font-base-size', `${currentSettings.fontSize}px`);
  4046. sidebar.style.setProperty('--sidebar-header-icon-base-size', `${currentSettings.headerIconSize}px`);
  4047. sidebar.style.setProperty('--sidebar-spacing-multiplier', currentSettings.verticalSpacingMultiplier);
  4048. sidebar.style.setProperty('--sidebar-max-height', `${currentSettings.sidebarHeight}vh`);
  4049. if (!currentSettings.sidebarCollapsed) { sidebar.style.width = `${currentSettings.sidebarWidth}px`; }
  4050. else { sidebar.style.width = '40px';}
  4051. applyThemeToElement(sidebar, currentSettings.theme);
  4052. if (sidebar._hoverListeners) { sidebar.removeEventListener('mouseenter', sidebar._hoverListeners.enter); sidebar.removeEventListener('mouseleave', sidebar._hoverListeners.leave); sidebar._hoverListeners = null; sidebar.style.opacity = '1';}
  4053. 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 }; }
  4054. else { sidebar.style.opacity = '1'; }
  4055. applySidebarCollapseVisuals(currentSettings.sidebarCollapsed);
  4056.  
  4057. const colors = currentSettings.customColors;
  4058. COLOR_MAPPINGS.forEach(map => {
  4059. map.cssVars.forEach(cssVar => {
  4060. if (colors[map.key]) {
  4061. sidebar.style.setProperty(cssVar, colors[map.key]);
  4062. } else {
  4063. sidebar.style.removeProperty(cssVar);
  4064. }
  4065. });
  4066. });
  4067.  
  4068. sidebar.classList.remove('scrollbar-left', 'scrollbar-hidden');
  4069. if (currentSettings.scrollbarPosition === 'left') {
  4070. sidebar.classList.add('scrollbar-left');
  4071. } else if (currentSettings.scrollbarPosition === 'hidden') {
  4072. sidebar.classList.add('scrollbar-hidden');
  4073. }
  4074.  
  4075. const googleLogo = document.querySelector('#logo');
  4076. if (googleLogo) {
  4077. if (currentSettings.hideGoogleLogoWhenExpanded && !currentSettings.sidebarCollapsed) {
  4078. googleLogo.style.visibility = 'hidden';
  4079. } else {
  4080. googleLogo.style.visibility = 'visible';
  4081. }
  4082. }
  4083.  
  4084. ResultStatsManager.toggle(currentSettings.showResultStats);
  4085. }
  4086. /**
  4087. * Parses a time filter value (e.g., 'h', 'd2', 'w') into a comparable number of minutes.
  4088. * This is used for sorting time filter options logically.
  4089. * @private
  4090. * @param {string} timeValue - The time value string.
  4091. * @returns {number} The equivalent number of minutes, or Infinity for invalid/any-time values.
  4092. */
  4093. 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; } }
  4094.  
  4095. /**
  4096. * Prepares and sorts the final list of filter options to be displayed in a section.
  4097. * It combines script-defined options, predefined user-enabled options, and custom user-added options.
  4098. * @private
  4099. * @param {string} sectionId - The ID of the section being prepared.
  4100. * @param {Object[]} scriptDefinedOptions - Options hardcoded in the script (e.g., "Any Time").
  4101. * @param {Object} currentSettings - The current settings object.
  4102. * @param {Object} predefinedOptionsSource - A reference to the PREDEFINED_OPTIONS object.
  4103. * @returns {Object[]} The final, sorted array of option objects to be rendered.
  4104. */
  4105. function _prepareFilterOptions(sectionId, scriptDefinedOptions, currentSettings, predefinedOptionsSource) {
  4106. const finalOptions = [];
  4107. const tempAddedValues = new Set();
  4108. const sectionDef = ALL_SECTION_DEFINITIONS.find(s => s.id === sectionId);
  4109. if (!sectionDef) return [];
  4110.  
  4111. const isSortableMixedType = sectionDef.displayItemsKey && Array.isArray(currentSettings[sectionDef.displayItemsKey]);
  4112. const isFiletypeCheckboxModeActive = sectionId === 'sidebar-section-filetype' && currentSettings.enableFiletypeCheckboxMode;
  4113. const isSiteCheckboxModeActive = sectionId === 'sidebar-section-site-search' && currentSettings.enableSiteSearchCheckboxMode;
  4114. const isOccurrenceSection = sectionId === 'sidebar-section-occurrence';
  4115.  
  4116. if (scriptDefinedOptions) {
  4117. scriptDefinedOptions.forEach(opt => {
  4118. if (opt && typeof opt.textKey === 'string' && typeof opt.v === 'string') {
  4119. if (isOccurrenceSection) {
  4120. const translatedText = _(opt.textKey);
  4121. finalOptions.push({ text: translatedText, value: opt.v, originalText: translatedText, isCustom: false, isAnyOption: (opt.v === '') });
  4122. tempAddedValues.add(opt.v);
  4123. } else if (opt.v === '') {
  4124. if (!((isFiletypeCheckboxModeActive && sectionId === 'sidebar-section-filetype') ||
  4125. (isSiteCheckboxModeActive && sectionId === 'sidebar-section-site-search'))) {
  4126. const translatedText = (sectionId === 'sidebar-section-site-search') ? _('filter_any_site') : _(opt.textKey);
  4127. finalOptions.push({ text: translatedText, value: opt.v, originalText: translatedText, isCustom: false, isAnyOption: true });
  4128. tempAddedValues.add(opt.v);
  4129. }
  4130. }
  4131. }
  4132. });
  4133. }
  4134. if (isOccurrenceSection) return finalOptions;
  4135.  
  4136. if (isSortableMixedType) {
  4137. const displayItems = currentSettings[sectionDef.displayItemsKey] || [];
  4138. displayItems.forEach(item => {
  4139. if (!tempAddedValues.has(item.value)) {
  4140. let displayText = item.text;
  4141. if (item.type === 'predefined' && item.originalKey) {
  4142. displayText = _(item.originalKey);
  4143. if (sectionId === 'sidebar-section-country') {
  4144. const parsed = Utils.parseIconAndText(displayText);
  4145. displayText = `${parsed.icon} ${parsed.text}`.trim();
  4146. }
  4147. }
  4148. finalOptions.push({ text: displayText, value: item.value, originalText: displayText, isCustom: item.type === 'custom' });
  4149. tempAddedValues.add(item.value);
  4150. }
  4151. });
  4152. } else {
  4153. const predefinedKey = sectionDef.predefinedOptionsKey;
  4154. const customKey = sectionDef.customItemsKey;
  4155. const predefinedOptsFromSource = predefinedOptionsSource && predefinedKey ? (predefinedOptionsSource[predefinedKey] || []) : [];
  4156. const customOptsFromSettings = customKey ? (currentSettings[customKey] || []) : [];
  4157. let enabledPredefinedSystemVals;
  4158.  
  4159. if (isFiletypeCheckboxModeActive && sectionId === 'sidebar-section-filetype') {
  4160. enabledPredefinedSystemVals = currentSettings.enabledPredefinedOptions[predefinedKey] || [];
  4161. } else {
  4162. enabledPredefinedSystemVals = predefinedKey ? (currentSettings.enabledPredefinedOptions[predefinedKey] || []) : [];
  4163. }
  4164.  
  4165. const itemsForThisSection = [];
  4166. const enabledSet = new Set(enabledPredefinedSystemVals);
  4167.  
  4168. if (Array.isArray(predefinedOptsFromSource)) {
  4169. predefinedOptsFromSource.forEach(opt => {
  4170. if (opt && typeof opt.textKey === 'string' && typeof opt.value === 'string' && enabledSet.has(opt.value) && !tempAddedValues.has(opt.value)) {
  4171. const translatedText = _(opt.textKey);
  4172. itemsForThisSection.push({ text: translatedText, value: opt.value, originalText: translatedText, isCustom: false });
  4173. }
  4174. });
  4175. }
  4176.  
  4177. const validCustomOptions = Array.isArray(customOptsFromSettings) ? customOptsFromSettings.filter(cOpt => cOpt && typeof cOpt.text === 'string' && typeof cOpt.value === 'string') : [];
  4178. validCustomOptions.forEach(opt => {
  4179. if (!tempAddedValues.has(opt.value)){
  4180. itemsForThisSection.push({ text: opt.text, value: opt.value, originalText: opt.text, isCustom: true });
  4181. }
  4182. });
  4183.  
  4184. itemsForThisSection.forEach(opt => {
  4185. if (!tempAddedValues.has(opt.value)){
  4186. finalOptions.push(opt);
  4187. tempAddedValues.add(opt.value);
  4188. }
  4189. });
  4190. }
  4191.  
  4192. if (scriptDefinedOptions && !isOccurrenceSection) {
  4193. scriptDefinedOptions.forEach(opt => {
  4194. if (opt && typeof opt.textKey === 'string' && typeof opt.v === 'string' && opt.v !== '' && !tempAddedValues.has(opt.v)) {
  4195. const translatedText = _(opt.textKey);
  4196. finalOptions.push({ text: translatedText, value: opt.v, originalText: translatedText, isCustom: false });
  4197. tempAddedValues.add(opt.v);
  4198. }
  4199. });
  4200. }
  4201.  
  4202. if (!isSortableMixedType && !isOccurrenceSection) {
  4203. let anyOptionToSortSeparately = null;
  4204. const anyOptionIdx = finalOptions.findIndex(opt => opt.isAnyOption === true);
  4205.  
  4206. if (anyOptionIdx !== -1) {
  4207. anyOptionToSortSeparately = finalOptions.splice(anyOptionIdx, 1)[0];
  4208. }
  4209.  
  4210. finalOptions.sort((a, b) => {
  4211. const isTimeSection = (sectionId === 'sidebar-section-time');
  4212. if (isTimeSection) {
  4213. const timeA = _parseTimeValueToMinutes(a.value);
  4214. const timeB = _parseTimeValueToMinutes(b.value);
  4215. if (timeA !== Infinity || timeB !== Infinity) {
  4216. if (timeA !== timeB) return timeA - timeB;
  4217. }
  4218. }
  4219. const sTA = a.originalText || a.text;
  4220. const sTB = b.originalText || b.text;
  4221. const sL = LocalizationService.getCurrentLocale() === 'en' ? undefined : LocalizationService.getCurrentLocale();
  4222. return sTA.localeCompare(sTB, sL, { numeric: true, sensitivity: 'base' });
  4223. });
  4224.  
  4225. if (anyOptionToSortSeparately) {
  4226. finalOptions.unshift(anyOptionToSortSeparately);
  4227. }
  4228. }
  4229. return finalOptions;
  4230. }
  4231.  
  4232. /**
  4233. * Creates a DOM element for a single filter option.
  4234. * @private
  4235. * @param {Object} optionData - The data for the option.
  4236. * @param {string} filterParam - The URL parameter this option controls.
  4237. * @param {boolean} isCountrySection - Flag indicating if this is for the country section (for special icon handling).
  4238. * @param {string} countryDisplayMode - The current display mode for countries.
  4239. * @returns {HTMLElement} The created option element.
  4240. */
  4241. 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; }
  4242.  
  4243. /**
  4244. * The main function for building or rebuilding the entire sidebar UI based on current settings.
  4245. * It clears the existing content and dynamically creates all visible sections and controls in the correct order.
  4246. */
  4247. 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(); }
  4248.  
  4249. /**
  4250. * Builds all the individual section elements for the sidebar.
  4251. * @private
  4252. * @returns {DocumentFragment} A document fragment containing all the generated section elements.
  4253. */
  4254. 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; }
  4255.  
  4256. /**
  4257. * Creates a filter section element with its options.
  4258. * @param {string} id - The section's ID.
  4259. * @param {string} titleKey - The localization key for the section title.
  4260. * @param {Object[]} scriptDefinedOptions - Hardcoded options for this section.
  4261. * @param {string} filterParam - The URL parameter this section controls.
  4262. * @param {Object} currentSettings - The current settings object.
  4263. * @param {Object} predefinedOptionsSource - The source of predefined options.
  4264. * @param {string} countryDisplayMode - The display mode for countries.
  4265. * @returns {HTMLElement|null} The created section element.
  4266. */
  4267. 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; }
  4268.  
  4269. /**
  4270. * Creates the "Site Search" section element.
  4271. * @private
  4272. * @param {string} sectionId - The section's ID.
  4273. * @param {string} titleKey - The localization key for the section title.
  4274. * @param {Object} currentSettings - The current settings object.
  4275. * @returns {HTMLElement} The created section element.
  4276. */
  4277. function _createSiteSearchSectionElement(sectionId, titleKey, currentSettings) {
  4278. const { section, sectionContent, sectionTitle } = _createSectionShell(sectionId, titleKey);
  4279. sectionTitle.textContent = _(titleKey);
  4280. populateSiteSearchList(sectionContent, currentSettings.favoriteSites, currentSettings.enableSiteSearchCheckboxMode, currentSettings.showFaviconsForSiteSearch);
  4281. return section;
  4282. }
  4283.  
  4284. /**
  4285. * Populates the content of the "Site Search" section. This function can be called
  4286. * to rebuild the list when settings change.
  4287. * @param {HTMLElement} sectionContentElement - The content element of the site search section.
  4288. * @param {Object[]} favoriteSitesArray - The array of favorite sites from settings.
  4289. * @param {boolean} checkboxModeEnabled - Whether checkbox mode is active.
  4290. * @param {boolean} showFaviconsEnabled - Whether to show favicons.
  4291. */
  4292. function populateSiteSearchList(sectionContentElement, favoriteSitesArray, checkboxModeEnabled, showFaviconsEnabled) {
  4293. if (!sectionContentElement) { console.error("Site search section content element missing"); return; }
  4294. sectionContentElement.innerHTML = '';
  4295.  
  4296. const sites = Array.isArray(favoriteSitesArray) ? favoriteSitesArray : [];
  4297. const listFragment = document.createDocumentFragment();
  4298.  
  4299. const clearOptDiv = document.createElement('div');
  4300. clearOptDiv.classList.add(CSS.FILTER_OPTION);
  4301. clearOptDiv.id = IDS.CLEAR_SITE_SEARCH_OPTION;
  4302. clearOptDiv.title = _('tooltip_clear_site_search');
  4303. clearOptDiv.textContent = _('filter_any_site');
  4304. clearOptDiv.dataset[DATA_ATTR.FILTER_TYPE] = 'site_clear';
  4305. sectionContentElement.appendChild(clearOptDiv);
  4306.  
  4307. const listElement = document.createElement('ul');
  4308. listElement.classList.add(CSS.CUSTOM_LIST);
  4309. if (checkboxModeEnabled) {
  4310. listElement.classList.add('checkbox-mode-enabled');
  4311. }
  4312.  
  4313. sites.forEach((site, index) => {
  4314. if (site?.text && site?.url) {
  4315. const li = document.createElement('li');
  4316. const siteValue = site.url;
  4317. const isGroup = siteValue.includes(' OR ');
  4318.  
  4319. if (checkboxModeEnabled) {
  4320. const uniqueId = `site-cb-${index}-${Date.now()}`;
  4321. const checkbox = document.createElement('input');
  4322. checkbox.id = uniqueId;
  4323. checkbox.type = 'checkbox';
  4324. checkbox.value = siteValue;
  4325. checkbox.classList.add(CSS.CHECKBOX_SITE);
  4326. checkbox.dataset[DATA_ATTR.SITE_URL] = siteValue;
  4327. li.appendChild(checkbox);
  4328.  
  4329. const label = document.createElement('label');
  4330. label.htmlFor = uniqueId;
  4331. if (showFaviconsEnabled && !isGroup) {
  4332. const favicon = document.createElement('img');
  4333. favicon.src = `https://www.google.com/s2/favicons?sz=32&domain_url=${siteValue}`;
  4334. favicon.classList.add(CSS.FAVICON);
  4335. favicon.loading = 'lazy';
  4336. label.appendChild(favicon);
  4337. }
  4338. label.appendChild(document.createTextNode(site.text));
  4339. label.dataset[DATA_ATTR.SITE_URL] = siteValue;
  4340. label.title = _('tooltip_site_search', { siteUrl: siteValue.replace(/\s+OR\s+/gi, ', ') });
  4341. li.appendChild(label);
  4342. } else {
  4343. const divOpt = document.createElement('div');
  4344. divOpt.classList.add(CSS.FILTER_OPTION);
  4345. if (showFaviconsEnabled && !isGroup) {
  4346. const favicon = document.createElement('img');
  4347. favicon.src = `https://www.google.com/s2/favicons?sz=32&domain_url=${siteValue}`;
  4348. favicon.classList.add(CSS.FAVICON);
  4349. favicon.loading = 'lazy';
  4350. divOpt.appendChild(favicon);
  4351. }
  4352. divOpt.appendChild(document.createTextNode(site.text));
  4353. divOpt.dataset[DATA_ATTR.SITE_URL] = siteValue;
  4354. divOpt.title = _('tooltip_site_search', { siteUrl: siteValue.replace(/\s+OR\s+/gi, ', ') });
  4355. li.appendChild(divOpt);
  4356. }
  4357.  
  4358. listFragment.appendChild(li);
  4359. }
  4360. });
  4361. listElement.appendChild(listFragment);
  4362. sectionContentElement.appendChild(listElement);
  4363.  
  4364. if (checkboxModeEnabled) {
  4365. let applyButton = sectionContentElement.querySelector(`#${IDS.APPLY_SELECTED_SITES_BUTTON}`);
  4366. if (!applyButton) {
  4367. applyButton = document.createElement('button');
  4368. applyButton.id = IDS.APPLY_SELECTED_SITES_BUTTON;
  4369. applyButton.classList.add(CSS.BUTTON, CSS.BUTTON_APPLY_SITES);
  4370. applyButton.textContent = _('tool_apply_selected_sites');
  4371. sectionContentElement.appendChild(applyButton);
  4372. }
  4373. applyButton.disabled = true;
  4374. applyButton.style.display = 'none';
  4375. }
  4376.  
  4377. // Use event delegation for site search interactions.
  4378. if (!sectionContentElement.dataset.siteSearchListenerAttached) {
  4379. sectionContentElement.dataset.siteSearchListenerAttached = 'true';
  4380. sectionContentElement.addEventListener('click', (event) => {
  4381. const target = event.target;
  4382. const currentSettings = SettingsManager.getCurrentSettings();
  4383. const isCheckboxMode = currentSettings.enableSiteSearchCheckboxMode;
  4384. const clearSiteOpt = target.closest(`#${IDS.CLEAR_SITE_SEARCH_OPTION}`);
  4385.  
  4386. if (clearSiteOpt) {
  4387. URLActionManager.clearSiteSearch();
  4388. sectionContentElement.querySelectorAll(`.${CSS.FILTER_OPTION}.${CSS.IS_SELECTED}, label.${CSS.IS_SELECTED}`).forEach(o => o.classList.remove(CSS.IS_SELECTED));
  4389. clearSiteOpt.classList.add(CSS.IS_SELECTED);
  4390. if (isCheckboxMode) {
  4391. sectionContentElement.querySelectorAll(`input[type="checkbox"].${CSS.CHECKBOX_SITE}`).forEach(cb => cb.checked = false);
  4392. _updateApplySitesButtonState(sectionContentElement);
  4393. }
  4394. } else if (isCheckboxMode) {
  4395. const labelElement = target.closest('label');
  4396. if (labelElement && labelElement.dataset[DATA_ATTR.SITE_URL]) {
  4397. event.preventDefault();
  4398. const siteUrlOrCombined = labelElement.dataset[DATA_ATTR.SITE_URL];
  4399. sectionContentElement.querySelectorAll(`input[type="checkbox"].${CSS.CHECKBOX_SITE}`).forEach(cb => {
  4400. const correspondingLabel = sectionContentElement.querySelector(`label[for="${cb.id}"]`);
  4401. cb.checked = (cb.value === siteUrlOrCombined);
  4402. if(correspondingLabel) correspondingLabel.classList.toggle(CSS.IS_SELECTED, cb.checked);
  4403. });
  4404. URLActionManager.applySiteSearch(siteUrlOrCombined);
  4405. _updateApplySitesButtonState(sectionContentElement);
  4406. sectionContentElement.querySelector(`#${IDS.CLEAR_SITE_SEARCH_OPTION}`)?.classList.remove(CSS.IS_SELECTED);
  4407. }
  4408. } else {
  4409. const siteOptionDiv = target.closest(`div.${CSS.FILTER_OPTION}:not(#${IDS.CLEAR_SITE_SEARCH_OPTION})`);
  4410. if (siteOptionDiv && siteOptionDiv.dataset[DATA_ATTR.SITE_URL]) {
  4411. const siteUrlOrCombined = siteOptionDiv.dataset[DATA_ATTR.SITE_URL];
  4412. sectionContentElement.querySelectorAll(`.${CSS.FILTER_OPTION}.${CSS.IS_SELECTED}`).forEach(o => o.classList.remove(CSS.IS_SELECTED));
  4413. URLActionManager.applySiteSearch(siteUrlOrCombined);
  4414. siteOptionDiv.classList.add(CSS.IS_SELECTED);
  4415. sectionContentElement.querySelector(`#${IDS.CLEAR_SITE_SEARCH_OPTION}`)?.classList.remove(CSS.IS_SELECTED);
  4416. }
  4417. }
  4418. });
  4419.  
  4420. sectionContentElement.addEventListener('change', (event) => {
  4421. if (event.target.matches(`input[type="checkbox"].${CSS.CHECKBOX_SITE}`)) {
  4422. _updateApplySitesButtonState(sectionContentElement);
  4423. const label = sectionContentElement.querySelector(`label[for="${event.target.id}"]`);
  4424. if (label) label.classList.toggle(CSS.IS_SELECTED, event.target.checked);
  4425. if (event.target.checked) {
  4426. sectionContentElement.querySelector(`#${IDS.CLEAR_SITE_SEARCH_OPTION}`)?.classList.remove(CSS.IS_SELECTED);
  4427. }
  4428. }
  4429. });
  4430.  
  4431. const applyBtn = sectionContentElement.querySelector(`#${IDS.APPLY_SELECTED_SITES_BUTTON}`);
  4432. if(applyBtn && !applyBtn.dataset[DATA_ATTR.LISTENER_ATTACHED]){
  4433. applyBtn.dataset[DATA_ATTR.LISTENER_ATTACHED] = 'true';
  4434. applyBtn.addEventListener('click', () => {
  4435. const selectedValues = Array.from(sectionContentElement.querySelectorAll(`input[type="checkbox"].${CSS.CHECKBOX_SITE}:checked`)).map(cb => cb.value);
  4436. if (selectedValues.length > 0) {
  4437. URLActionManager.applySiteSearch(selectedValues);
  4438. sectionContentElement.querySelector(`#${IDS.CLEAR_SITE_SEARCH_OPTION}`)?.classList.remove(CSS.IS_SELECTED);
  4439. }
  4440. });
  4441. }
  4442. }
  4443. }
  4444.  
  4445. /**
  4446. * Creates the "File Type" section element.
  4447. * @private
  4448. * @param {string} sectionId - The section's ID.
  4449. * @param {string} titleKey - The localization key for the section title.
  4450. * @param {Object[]} scriptDefinedOptions - Hardcoded options for this section.
  4451. * @param {string} filterParam - The URL parameter this section controls.
  4452. * @param {Object} currentSettings - The current settings object.
  4453. * @param {Object} predefinedOptionsSource - The source of predefined options.
  4454. * @returns {HTMLElement} The created section element.
  4455. */
  4456. function _createFiletypeSectionElement(sectionId, titleKey, scriptDefinedOptions, filterParam, currentSettings, predefinedOptionsSource) {
  4457. const { section, sectionContent, sectionTitle } = _createSectionShell(sectionId, titleKey);
  4458. sectionTitle.textContent = _(titleKey);
  4459. populateFiletypeList(sectionContent, scriptDefinedOptions, currentSettings, predefinedOptionsSource, filterParam);
  4460. return section;
  4461. }
  4462. /**
  4463. * Populates the content of the "File Type" section.
  4464. * @param {HTMLElement} sectionContentElement - The content element of the file type section.
  4465. * @param {Object[]} scriptDefinedOpts - Hardcoded options for this section.
  4466. * @param {Object} currentSettings - The current settings object.
  4467. * @param {Object} predefinedOptsSource - The source of predefined options.
  4468. * @param {string} filterParam - The URL parameter this section controls.
  4469. */
  4470. function populateFiletypeList(sectionContentElement, scriptDefinedOpts, currentSettings, predefinedOptsSource, filterParam) {
  4471. if (!sectionContentElement) { console.error("Filetype section content element missing"); return; }
  4472. sectionContentElement.innerHTML = '';
  4473.  
  4474. const checkboxModeEnabled = currentSettings.enableFiletypeCheckboxMode;
  4475. const combinedOptions = _prepareFilterOptions('sidebar-section-filetype', scriptDefinedOpts, currentSettings, predefinedOptsSource);
  4476. const listFragment = document.createDocumentFragment();
  4477.  
  4478. const clearOptDiv = document.createElement('div');
  4479. clearOptDiv.classList.add(CSS.FILTER_OPTION);
  4480. clearOptDiv.id = IDS.CLEAR_FILETYPE_SEARCH_OPTION;
  4481. clearOptDiv.title = _('filter_clear_tooltip_suffix');
  4482. clearOptDiv.textContent = _('filter_any_format');
  4483. clearOptDiv.dataset[DATA_ATTR.FILTER_TYPE] = 'filetype_clear';
  4484. sectionContentElement.appendChild(clearOptDiv);
  4485.  
  4486. const listElement = document.createElement('ul');
  4487. listElement.classList.add(CSS.CUSTOM_LIST);
  4488. if (checkboxModeEnabled) {
  4489. listElement.classList.add('checkbox-mode-enabled');
  4490. }
  4491.  
  4492. combinedOptions.forEach((option, index) => {
  4493. if (option.isAnyOption) return;
  4494.  
  4495. const li = document.createElement('li');
  4496. const filetypeValue = option.value;
  4497.  
  4498. if (checkboxModeEnabled) {
  4499. const checkbox = document.createElement('input');
  4500. const uniqueId = `ft-cb-${index}-${Date.now()}`;
  4501. checkbox.type = 'checkbox';
  4502. checkbox.id = uniqueId;
  4503. checkbox.value = filetypeValue;
  4504. checkbox.classList.add(CSS.CHECKBOX_FILETYPE);
  4505. checkbox.dataset[DATA_ATTR.FILETYPE_VALUE] = filetypeValue;
  4506. li.appendChild(checkbox);
  4507.  
  4508. const label = document.createElement('label');
  4509. label.htmlFor = checkbox.id;
  4510. label.dataset[DATA_ATTR.FILETYPE_VALUE] = filetypeValue;
  4511. label.title = `${option.text} (${filterParam}=${filetypeValue.replace(/\s+OR\s+/gi, ', ')})`;
  4512. label.textContent = option.text;
  4513. li.appendChild(label);
  4514. } else {
  4515. const divOpt = document.createElement('div');
  4516. divOpt.classList.add(CSS.FILTER_OPTION);
  4517. divOpt.dataset[DATA_ATTR.FILTER_TYPE] = filterParam;
  4518. divOpt.dataset[DATA_ATTR.FILTER_VALUE] = filetypeValue;
  4519. divOpt.title = `${option.text} (${filterParam}=${filetypeValue.replace(/\s+OR\s+/gi, ', ')})`;
  4520. divOpt.textContent = option.text;
  4521. li.appendChild(divOpt);
  4522. }
  4523. listFragment.appendChild(li);
  4524. });
  4525. listElement.appendChild(listFragment);
  4526. sectionContentElement.appendChild(listElement);
  4527.  
  4528. if (checkboxModeEnabled) {
  4529. let applyButton = sectionContentElement.querySelector(`#${IDS.APPLY_SELECTED_FILETYPES_BUTTON}`);
  4530. if(!applyButton) {
  4531. applyButton = document.createElement('button');
  4532. applyButton.id = IDS.APPLY_SELECTED_FILETYPES_BUTTON;
  4533. applyButton.classList.add(CSS.BUTTON, CSS.BUTTON_APPLY_FILETYPES);
  4534. applyButton.textContent = _('tool_apply_selected_filetypes');
  4535. sectionContentElement.appendChild(applyButton);
  4536. }
  4537. applyButton.disabled = true;
  4538. applyButton.style.display = 'none';
  4539. }
  4540.  
  4541. // Use event delegation for filetype search interactions.
  4542. if (!sectionContentElement.dataset.filetypeClickListenerAttached) {
  4543. sectionContentElement.dataset.filetypeClickListenerAttached = 'true';
  4544. sectionContentElement.addEventListener('click', (event) => {
  4545. const target = event.target;
  4546. const isCheckboxMode = SettingsManager.getCurrentSettings().enableFiletypeCheckboxMode;
  4547. const clearFiletypeOpt = target.closest(`#${IDS.CLEAR_FILETYPE_SEARCH_OPTION}`);
  4548.  
  4549. if (clearFiletypeOpt) {
  4550. URLActionManager.clearFiletypeSearch();
  4551. sectionContentElement.querySelectorAll(`.${CSS.FILTER_OPTION}.${CSS.IS_SELECTED}, label.${CSS.IS_SELECTED}`).forEach(o => o.classList.remove(CSS.IS_SELECTED));
  4552. clearFiletypeOpt.classList.add(CSS.IS_SELECTED);
  4553. if (isCheckboxMode) {
  4554. sectionContentElement.querySelectorAll(`input[type="checkbox"].${CSS.CHECKBOX_FILETYPE}`).forEach(cb => cb.checked = false);
  4555. _updateApplyFiletypesButtonState(sectionContentElement);
  4556. }
  4557. } else if (isCheckboxMode) {
  4558. const labelElement = target.closest('label');
  4559. if (labelElement && labelElement.dataset[DATA_ATTR.FILETYPE_VALUE]) {
  4560. event.preventDefault();
  4561. const filetypeValueOrCombined = labelElement.dataset[DATA_ATTR.FILETYPE_VALUE];
  4562. sectionContentElement.querySelectorAll(`input[type="checkbox"].${CSS.CHECKBOX_FILETYPE}`).forEach(cb => {
  4563. const correspondingLabel = sectionContentElement.querySelector(`label[for="${cb.id}"]`);
  4564. cb.checked = (cb.value === filetypeValueOrCombined);
  4565. if(correspondingLabel) correspondingLabel.classList.toggle(CSS.IS_SELECTED, cb.checked);
  4566. });
  4567. URLActionManager.applyCombinedFiletypeSearch(filetypeValueOrCombined);
  4568. _updateApplyFiletypesButtonState(sectionContentElement);
  4569. sectionContentElement.querySelector(`#${IDS.CLEAR_FILETYPE_SEARCH_OPTION}`)?.classList.remove(CSS.IS_SELECTED);
  4570. }
  4571. } else {
  4572. const optionDiv = target.closest(`div.${CSS.FILTER_OPTION}:not(#${IDS.CLEAR_FILETYPE_SEARCH_OPTION})`);
  4573. if (optionDiv && optionDiv.dataset[DATA_ATTR.FILTER_VALUE]) {
  4574. const clickedFilterType = optionDiv.dataset[DATA_ATTR.FILTER_TYPE];
  4575. const clickedFilterValueOrCombined = optionDiv.dataset[DATA_ATTR.FILTER_VALUE];
  4576. sectionContentElement.querySelectorAll(`.${CSS.FILTER_OPTION}.${CSS.IS_SELECTED}`).forEach(o => o.classList.remove(CSS.IS_SELECTED));
  4577. optionDiv.classList.add(CSS.IS_SELECTED);
  4578. URLActionManager.applyFilter(clickedFilterType, clickedFilterValueOrCombined);
  4579. sectionContentElement.querySelector(`#${IDS.CLEAR_FILETYPE_SEARCH_OPTION}`)?.classList.remove(CSS.IS_SELECTED);
  4580. }
  4581. }
  4582. });
  4583.  
  4584. sectionContentElement.addEventListener('change', (event) => {
  4585. if (event.target.matches(`input[type="checkbox"].${CSS.CHECKBOX_FILETYPE}`)) {
  4586. _updateApplyFiletypesButtonState(sectionContentElement);
  4587. const label = sectionContentElement.querySelector(`label[for="${event.target.id}"]`);
  4588. if (label) label.classList.toggle(CSS.IS_SELECTED, event.target.checked);
  4589. if (event.target.checked) {
  4590. sectionContentElement.querySelector(`#${IDS.CLEAR_FILETYPE_SEARCH_OPTION}`)?.classList.remove(CSS.IS_SELECTED);
  4591. }
  4592. }
  4593. });
  4594.  
  4595. const applyBtn = sectionContentElement.querySelector(`#${IDS.APPLY_SELECTED_FILETYPES_BUTTON}`);
  4596. if (applyBtn && !applyBtn.dataset[DATA_ATTR.LISTENER_ATTACHED]) {
  4597. applyBtn.dataset[DATA_ATTR.LISTENER_ATTACHED] = 'true';
  4598. applyBtn.addEventListener('click', () => {
  4599. const selectedValues = Array.from(sectionContentElement.querySelectorAll(`input[type="checkbox"].${CSS.CHECKBOX_FILETYPE}:checked`)).map(cb => cb.value);
  4600. if (selectedValues.length > 0) {
  4601. URLActionManager.applyCombinedFiletypeSearch(selectedValues);
  4602. sectionContentElement.querySelector(`#${IDS.CLEAR_FILETYPE_SEARCH_OPTION}`)?.classList.remove(CSS.IS_SELECTED);
  4603. }
  4604. });
  4605. }
  4606. }
  4607. }
  4608.  
  4609. /**
  4610. * Updates the state (enabled/disabled, visible/hidden) of the "Apply Selected" button
  4611. * in the site search section based on how many checkboxes are checked.
  4612. * @private
  4613. * @param {HTMLElement} sectionContentElement - The content element of the site search section.
  4614. */
  4615. function _updateApplySitesButtonState(sectionContentElement) {
  4616. if (!sectionContentElement) return;
  4617. const applyButton = sectionContentElement.querySelector(`#${IDS.APPLY_SELECTED_SITES_BUTTON}`);
  4618. if (!applyButton) return;
  4619. const checkedCount = sectionContentElement.querySelectorAll(`input[type="checkbox"].${CSS.CHECKBOX_SITE}:checked`).length;
  4620. applyButton.disabled = checkedCount === 0;
  4621. applyButton.style.display = checkedCount > 0 ? 'inline-flex' : 'none';
  4622. }
  4623.  
  4624. /**
  4625. * Updates the state of the "Apply Selected" button in the file type section.
  4626. * @private
  4627. * @param {HTMLElement} sectionContentElement - The content element of the file type section.
  4628. */
  4629. function _updateApplyFiletypesButtonState(sectionContentElement) {
  4630. if (!sectionContentElement) return;
  4631. const applyButton = sectionContentElement.querySelector(`#${IDS.APPLY_SELECTED_FILETYPES_BUTTON}`);
  4632. if (!applyButton) return;
  4633. const checkedCount = sectionContentElement.querySelectorAll(`input[type="checkbox"].${CSS.CHECKBOX_FILETYPE}:checked`).length;
  4634. applyButton.disabled = checkedCount === 0;
  4635. applyButton.style.display = checkedCount > 0 ? 'inline-flex' : 'none';
  4636. }
  4637. /**
  4638. * Renders the draggable list of sections in the "Features" tab of the settings window.
  4639. * @param {Object} settingsRef - A reference to the current settings object.
  4640. */
  4641. 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); }
  4642.  
  4643. /**
  4644. * Initializes the script's menu commands using `GM_registerMenuCommand`.
  4645. * This adds "Open Settings" and "Reset All Settings" options to the Tampermonkey menu.
  4646. */
  4647. 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)); } }
  4648.  
  4649. /**
  4650. * Creates the basic shell for a sidebar section (the section container, title, and content elements).
  4651. * @private
  4652. * @param {string} id - The ID for the main section element.
  4653. * @param {string} titleKey - The localization key for the section's title.
  4654. * @returns {{section: HTMLElement, sectionContent: HTMLElement, sectionTitle: HTMLElement}} An object containing the created elements.
  4655. */
  4656. 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 }; }
  4657.  
  4658. /**
  4659. * Creates the "Date Range" section element.
  4660. * @private
  4661. * @param {string} sectionId - The section's ID.
  4662. * @param {string} titleKey - The localization key for the section title.
  4663. * @returns {HTMLElement} The created section element.
  4664. */
  4665. 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; }
  4666.  
  4667. /**
  4668. * A factory function to create a standard button element used throughout the sidebar.
  4669. * @private
  4670. * @param {Object} options - The button's configuration.
  4671. * @param {string|null} [options.id=null] - The ID for the button.
  4672. * @param {string} options.className - The CSS class for the button.
  4673. * @param {string} options.svgIcon - The SVG icon string.
  4674. * @param {string|null} [options.textContent=null] - The text content for the button.
  4675. * @param {string} options.title - The title attribute (tooltip) for the button.
  4676. * @param {Function} options.clickHandler - The click event handler.
  4677. * @param {boolean} [options.isActive=false] - Whether the button should be in an active state.
  4678. * @returns {HTMLButtonElement} The created button element.
  4679. */
  4680. 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; }
  4681.  
  4682. /**
  4683. * Creates the Personalization toggle button.
  4684. * @private
  4685. * @param {('tools'|'header'|'topBlock')} [forLocation='tools'] - The location where the button will be placed.
  4686. * @returns {HTMLButtonElement} The created button element.
  4687. */
  4688. 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 }); }
  4689.  
  4690. /**
  4691. * Creates the "Advanced Search" link element.
  4692. * @private
  4693. * @param {boolean} [isButtonLike=false] - If true, styles the link to look like a button.
  4694. * @returns {HTMLAnchorElement} The created anchor element.
  4695. */
  4696. 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; }
  4697.  
  4698. /**
  4699. * Creates a generic shortcut button for a specified Google service.
  4700. * @private
  4701. * @param {string} serviceId - The key for the service in SERVICE_SHORTCUT_CONFIG (e.g., 'googleScholar').
  4702. * @param {('tools'|'header'|'topBlock')} [forLocation='tools'] - The location where the button will be placed.
  4703. * @returns {HTMLButtonElement|null} The created button element, or null if config is not found.
  4704. */
  4705. function _createServiceShortcutButton(serviceId, forLocation = 'tools') {
  4706. const config = SERVICE_SHORTCUT_CONFIG[serviceId];
  4707. if (!config) {
  4708. console.warn(`${LOG_PREFIX} No configuration found for service shortcut: ${serviceId}`);
  4709. return null;
  4710. }
  4711.  
  4712. const isIconOnlyLocation = (forLocation === 'header');
  4713. const displayText = !isIconOnlyLocation ? _(config.textKey) : '';
  4714.  
  4715. // The click handler logic is now generic and reusable
  4716. const clickHandler = () => {
  4717. try {
  4718. const currentUrl = Utils.getCurrentURL();
  4719. if (currentUrl) {
  4720. const query = currentUrl.searchParams.get('q');
  4721. if (query) {
  4722. const serviceUrl = `${config.baseUrl}?${config.queryParam}=${encodeURIComponent(query)}`;
  4723. // Use GM_openInTab for better control, falling back to window.open
  4724. if (typeof GM_openInTab === 'function') {
  4725. GM_openInTab(serviceUrl, { active: true, insert: true });
  4726. } else {
  4727. window.open(serviceUrl, '_blank');
  4728. }
  4729. } else {
  4730. window.open(config.homepage, '_blank');
  4731. NotificationManager.show('alert_no_keywords_for_shortcut', { service_name: _(config.serviceNameKey) }, 'info');
  4732. }
  4733. }
  4734. } catch (e) {
  4735. console.error(`${LOG_PREFIX} Error opening ${config.serviceNameKey}:`, e);
  4736. NotificationManager.show('alert_error_opening_link', { service_name: _(config.serviceNameKey) }, 'error');
  4737. }
  4738. };
  4739.  
  4740. return _createStandardButton({
  4741. id: config.id,
  4742. className: (forLocation === 'header') ? CSS.HEADER_BUTTON : CSS.BUTTON,
  4743. svgIcon: config.svgIcon || '',
  4744. textContent: displayText,
  4745. title: _(config.titleKey),
  4746. clickHandler: clickHandler
  4747. });
  4748. }
  4749.  
  4750. /**
  4751. * Builds and inserts the control buttons and links into the sidebar header.
  4752. * @private
  4753. */
  4754. 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); } }); }
  4755.  
  4756. /**
  4757. * Builds the container and controls for the "Top Block" area of the sidebar.
  4758. * @private
  4759. * @returns {HTMLElement|null} The created container element, or null if no controls are placed there.
  4760. */
  4761. 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; }
  4762.  
  4763. /**
  4764. * Creates the "Tools" section element.
  4765. * @private
  4766. * @returns {HTMLElement|null} The created section element, or null if no tools are configured to be in this section.
  4767. */
  4768. 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; }
  4769.  
  4770. /**
  4771. * Validates the date range input fields.
  4772. * @private
  4773. * @param {HTMLInputElement} minInput - The start date input.
  4774. * @param {HTMLInputElement} maxInput - The end date input.
  4775. * @param {HTMLElement} errorMsgElement - The element for displaying errors.
  4776. * @returns {boolean} True if the dates are valid.
  4777. */
  4778. 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; }
  4779.  
  4780. /**
  4781. * Adds event listeners to the date range input fields.
  4782. */
  4783. 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(); }
  4784.  
  4785. /**
  4786. * A helper function that calls other functions to initialize all sidebar states and listeners after the UI is built.
  4787. */
  4788. function _initializeSidebarEventListenersAndStates() { addDateRangeListener(); addToolButtonListeners(); initializeSelectedFilters(); applySectionCollapseStates(); ResultStatsManager.update(); }
  4789.  
  4790. /**
  4791. * Clears the text content and removes the visibility class from a message element.
  4792. * @private
  4793. */
  4794. function _clearElementMessage(element, visibleClass = CSS.IS_ERROR_VISIBLE) { if(!element)return; element.textContent=''; element.classList.remove(visibleClass);}
  4795.  
  4796. /**
  4797. * Shows a message in a specific DOM element.
  4798. * @private
  4799. */
  4800. function _showElementMessage(element, messageKey, messageArgs = {}, visibleClass = CSS.IS_ERROR_VISIBLE) { if(!element)return; element.textContent=_(messageKey,messageArgs); element.classList.add(visibleClass);}
  4801.  
  4802. /**
  4803. * Adds event listeners to tool buttons that might be in different locations.
  4804. */
  4805. 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'; }); }); }
  4806.  
  4807. /**
  4808. * Applies the visual styles for the sidebar's collapsed or expanded state.
  4809. * @param {boolean} isCollapsed - Whether the sidebar is currently collapsed.
  4810. */
  4811. 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');}} }
  4812.  
  4813. /**
  4814. * Applies the collapsed/expanded state to all sections based on the current settings.
  4815. */
  4816. 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; } } }); }
  4817. /**
  4818. * Initializes the visual state of all filter options in the sidebar to reflect the
  4819. * current URL's search parameters upon page load.
  4820. */
  4821. function initializeSelectedFilters() {
  4822. if (!sidebar) return;
  4823. try {
  4824. const currentUrl = URLActionManager.generateURLObject();
  4825. if (!currentUrl) return;
  4826. const params = currentUrl.searchParams;
  4827. const currentTbs = params.get('tbs') || '';
  4828. const currentQuery = params.get('q') || '';
  4829.  
  4830. ALL_SECTION_DEFINITIONS.forEach(sectionDef => {
  4831. if (sectionDef.type === 'filter' && sectionDef.param && sectionDef.id !== 'sidebar-section-filetype' && sectionDef.id !== 'sidebar-section-site-search') {
  4832. _initializeStandaloneFilterState(params, sectionDef.id, sectionDef.param);
  4833. }
  4834. });
  4835. _initializeTimeFilterState(currentTbs);
  4836. _initializeVerbatimState();
  4837. _initializePersonalizationState();
  4838. _initializeDateRangeInputs(currentTbs);
  4839. _initializeSiteSearchState(currentQuery);
  4840. _initializeFiletypeSearchState(currentQuery, params.get('as_filetype'));
  4841. } catch (e) {
  4842. console.error(`${LOG_PREFIX} Error initializing filter highlights:`, e);
  4843. }
  4844. }
  4845.  
  4846. /**
  4847. * Sets the selected state for options in a standard filter section based on URL parameters.
  4848. * @private
  4849. * @param {URLSearchParams} params - The current URL search parameters.
  4850. * @param {string} sectionId - The ID of the section to initialize.
  4851. * @param {string} paramToGetFromURL - The URL parameter key to check.
  4852. */
  4853. function _initializeStandaloneFilterState(params, sectionId, paramToGetFromURL) {
  4854. const sectionElement = sidebar?.querySelector(`#${sectionId}`);
  4855. if (!sectionElement) return;
  4856. const urlValue = params.get(paramToGetFromURL);
  4857. const options = sectionElement.querySelectorAll(`.${CSS.FILTER_OPTION}`);
  4858. let anOptionWasSelectedBasedOnUrl = false;
  4859.  
  4860. options.forEach(opt => {
  4861. const optionValue = opt.dataset[DATA_ATTR.FILTER_VALUE];
  4862. const isSelected = (urlValue !== null && urlValue === optionValue);
  4863. opt.classList.toggle(CSS.IS_SELECTED, isSelected);
  4864. if (isSelected) anOptionWasSelectedBasedOnUrl = true;
  4865. });
  4866.  
  4867. if (!anOptionWasSelectedBasedOnUrl) {
  4868. const defaultOptionQuery = (paramToGetFromURL === 'as_occt')
  4869. ? `.${CSS.FILTER_OPTION}[data-${DATA_ATTR.FILTER_VALUE}="any"]`
  4870. : `.${CSS.FILTER_OPTION}[data-${DATA_ATTR.FILTER_VALUE}=""]`;
  4871. const defaultOpt = sectionElement.querySelector(defaultOptionQuery);
  4872. if (defaultOpt) {
  4873. defaultOpt.classList.add(CSS.IS_SELECTED);
  4874. }
  4875. }
  4876. }
  4877.  
  4878. /**
  4879. * Sets the selected state for the time filter based on the 'tbs' URL parameter.
  4880. * @private
  4881. * @param {string} currentTbs - The current value of the 'tbs' URL parameter.
  4882. */
  4883. 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); }); }
  4884.  
  4885. /**
  4886. * Sets the active state of the Verbatim search button.
  4887. * @private
  4888. */
  4889. function _initializeVerbatimState(){ const isVerbatimActiveNow = URLActionManager.isVerbatimActive(); sidebar?.querySelectorAll(`#${IDS.TOOL_VERBATIM}`).forEach(b=>b.classList.toggle(CSS.IS_ACTIVE, isVerbatimActiveNow)); }
  4890.  
  4891. /**
  4892. * Sets the active state of the Personalization toggle button.
  4893. * @private
  4894. */
  4895. 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(); }); }
  4896.  
  4897. /**
  4898. * Sets the values of the date range inputs based on the 'tbs' URL parameter.
  4899. * @private
  4900. * @param {string} currentTbs - The current value of the 'tbs' URL parameter.
  4901. */
  4902. 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; } }
  4903.  
  4904. /**
  4905. * Sets the selected state for the site search options based on 'site:' operators in the query.
  4906. * @private
  4907. * @param {string} currentQuery - The current search query string.
  4908. */
  4909. function _initializeSiteSearchState(currentQuery){
  4910. const siteSearchSectionContent = sidebar?.querySelector('#sidebar-section-site-search .'+CSS.SECTION_CONTENT);
  4911. if (!siteSearchSectionContent) return;
  4912.  
  4913. const clearSiteOptDiv = siteSearchSectionContent.querySelector(`#${IDS.CLEAR_SITE_SEARCH_OPTION}`);
  4914. const listElement = siteSearchSectionContent.querySelector('ul.' + CSS.CUSTOM_LIST);
  4915. const currentSettings = SettingsManager.getCurrentSettings();
  4916. const checkboxModeEnabled = currentSettings.enableSiteSearchCheckboxMode;
  4917.  
  4918. siteSearchSectionContent.querySelectorAll(`.${CSS.FILTER_OPTION}.${CSS.IS_SELECTED}, label.${CSS.IS_SELECTED}`).forEach(opt => opt.classList.remove(CSS.IS_SELECTED));
  4919. if (checkboxModeEnabled && listElement) {
  4920. listElement.querySelectorAll(`input[type="checkbox"].${CSS.CHECKBOX_SITE}`).forEach(cb => cb.checked = false);
  4921. }
  4922.  
  4923. const siteMatches = [...currentQuery.matchAll(/(?<!\S)site:([\w.:\/~%?#=&+-]+)(?!\S)/gi)];
  4924. let activeSiteUrlsFromQuery = siteMatches.map(match => match[1].toLowerCase());
  4925. activeSiteUrlsFromQuery.sort();
  4926.  
  4927. if (activeSiteUrlsFromQuery.length > 0) {
  4928. if(clearSiteOptDiv) clearSiteOptDiv.classList.remove(CSS.IS_SELECTED);
  4929. let customOptionFullyMatched = false;
  4930.  
  4931. if (listElement) {
  4932. const customSiteOptions = Array.from(listElement.querySelectorAll(checkboxModeEnabled ? 'label' : `div.${CSS.FILTER_OPTION}`));
  4933. for (const optElement of customSiteOptions) {
  4934. const customSiteValue = optElement.dataset[DATA_ATTR.SITE_URL];
  4935. if (!customSiteValue) continue;
  4936.  
  4937. const definedCustomSites = Utils.parseCombinedValue(customSiteValue).map(s => s.toLowerCase()).sort();
  4938. if (definedCustomSites.length > 0 && definedCustomSites.length === activeSiteUrlsFromQuery.length && definedCustomSites.every((val, index) => val === activeSiteUrlsFromQuery[index])) {
  4939. if (checkboxModeEnabled) {
  4940. const checkbox = listElement.querySelector(`input[type="checkbox"][value="${customSiteValue}"]`);
  4941. if (checkbox) checkbox.checked = true;
  4942. }
  4943. optElement.classList.add(CSS.IS_SELECTED);
  4944. customOptionFullyMatched = true;
  4945. break;
  4946. }
  4947. }
  4948. }
  4949.  
  4950. if (!customOptionFullyMatched && listElement && checkboxModeEnabled) {
  4951. activeSiteUrlsFromQuery.forEach(url => {
  4952. const checkbox = listElement.querySelector(`input[type="checkbox"].${CSS.CHECKBOX_SITE}[value="${url}"]`);
  4953. if (checkbox) {
  4954. checkbox.checked = true;
  4955. const label = listElement.querySelector(`label[for="${checkbox.id}"]`);
  4956. if(label) label.classList.add(CSS.IS_SELECTED);
  4957. }
  4958. });
  4959. } else if (!customOptionFullyMatched && listElement && !checkboxModeEnabled && activeSiteUrlsFromQuery.length === 1) {
  4960. const singleSiteInQuery = activeSiteUrlsFromQuery[0];
  4961. const optionDiv = listElement.querySelector(`div.${CSS.FILTER_OPTION}[data-${DATA_ATTR.SITE_URL}="${singleSiteInQuery}"]`);
  4962. if (optionDiv) optionDiv.classList.add(CSS.IS_SELECTED);
  4963. }
  4964. } else {
  4965. if (clearSiteOptDiv) clearSiteOptDiv.classList.add(CSS.IS_SELECTED);
  4966. }
  4967.  
  4968. if (checkboxModeEnabled) {
  4969. _updateApplySitesButtonState(siteSearchSectionContent);
  4970. }
  4971. }
  4972.  
  4973. /**
  4974. * Sets the selected state for the file type options based on 'filetype:' operators or the 'as_filetype' parameter.
  4975. * @private
  4976. * @param {string} currentQuery - The current search query string.
  4977. * @param {string|null} asFiletypeParam - The value of the 'as_filetype' URL parameter.
  4978. */
  4979. function _initializeFiletypeSearchState(currentQuery, asFiletypeParam) {
  4980. const filetypeSectionContent = sidebar?.querySelector('#sidebar-section-filetype .'+CSS.SECTION_CONTENT);
  4981. if (!filetypeSectionContent) return;
  4982.  
  4983. const clearFiletypeOptDiv = filetypeSectionContent.querySelector(`#${IDS.CLEAR_FILETYPE_SEARCH_OPTION}`);
  4984. const listElement = filetypeSectionContent.querySelector('ul.' + CSS.CUSTOM_LIST);
  4985. const currentSettings = SettingsManager.getCurrentSettings();
  4986. const checkboxModeEnabled = currentSettings.enableFiletypeCheckboxMode;
  4987.  
  4988. filetypeSectionContent.querySelectorAll(`.${CSS.FILTER_OPTION}.${CSS.IS_SELECTED}, label.${CSS.IS_SELECTED}`).forEach(opt => opt.classList.remove(CSS.IS_SELECTED));
  4989. if (checkboxModeEnabled && listElement) {
  4990. listElement.querySelectorAll(`input[type="checkbox"].${CSS.CHECKBOX_FILETYPE}`).forEach(cb => cb.checked = false);
  4991. }
  4992.  
  4993. let activeFiletypesFromQuery = [];
  4994. const filetypeMatches = [...currentQuery.matchAll(/(?<!\S)filetype:([\w.:\/~%?#=&+-]+)(?!\S)/gi)];
  4995.  
  4996. if (filetypeMatches.length > 0) {
  4997. activeFiletypesFromQuery = filetypeMatches.map(match => match[1].toLowerCase());
  4998. } else if (asFiletypeParam && !currentQuery.includes('filetype:')) {
  4999. activeFiletypesFromQuery = Utils.parseCombinedValue(asFiletypeParam).map(ft => ft.toLowerCase());
  5000. }
  5001. activeFiletypesFromQuery.sort();
  5002.  
  5003. if (activeFiletypesFromQuery.length > 0) {
  5004. if(clearFiletypeOptDiv) clearFiletypeOptDiv.classList.remove(CSS.IS_SELECTED);
  5005. let customOptionFullyMatched = false;
  5006.  
  5007. if (listElement) {
  5008. const customFiletypeOptions = Array.from(listElement.querySelectorAll(checkboxModeEnabled ? 'label' : `div.${CSS.FILTER_OPTION}`));
  5009. for (const optElement of customFiletypeOptions) {
  5010. const customFtValueAttr = checkboxModeEnabled ? optElement.dataset[DATA_ATTR.FILETYPE_VALUE] : optElement.dataset[DATA_ATTR.FILTER_VALUE];
  5011. if (!customFtValueAttr) continue;
  5012.  
  5013. const definedCustomFiletypes = Utils.parseCombinedValue(customFtValueAttr).map(s => s.toLowerCase()).sort();
  5014. if (definedCustomFiletypes.length > 0 && definedCustomFiletypes.length === activeFiletypesFromQuery.length && definedCustomFiletypes.every((val, index) => val === activeFiletypesFromQuery[index])) {
  5015. if (checkboxModeEnabled) {
  5016. const checkbox = listElement.querySelector(`input[type="checkbox"][value="${customFtValueAttr}"]`);
  5017. if (checkbox) checkbox.checked = true;
  5018. }
  5019. optElement.classList.add(CSS.IS_SELECTED);
  5020. customOptionFullyMatched = true;
  5021. break;
  5022. }
  5023. }
  5024. }
  5025.  
  5026. if (!customOptionFullyMatched && listElement && checkboxModeEnabled) {
  5027. activeFiletypesFromQuery.forEach(ft => {
  5028. const checkbox = listElement.querySelector(`input[type="checkbox"].${CSS.CHECKBOX_FILETYPE}[value="${ft}"]`);
  5029. if (checkbox) {
  5030. checkbox.checked = true;
  5031. const label = listElement.querySelector(`label[for="${checkbox.id}"]`);
  5032. if(label) label.classList.add(CSS.IS_SELECTED);
  5033. }
  5034. });
  5035. } else if (!customOptionFullyMatched && listElement && !checkboxModeEnabled && activeFiletypesFromQuery.length === 1) {
  5036. const singleFtInQuery = activeFiletypesFromQuery[0];
  5037. const optionDiv = listElement.querySelector(`div.${CSS.FILTER_OPTION}[data-${DATA_ATTR.FILTER_VALUE}="${singleFtInQuery}"]`);
  5038. if (optionDiv) optionDiv.classList.add(CSS.IS_SELECTED);
  5039. }
  5040. } else {
  5041. if (clearFiletypeOptDiv) clearFiletypeOptDiv.classList.add(CSS.IS_SELECTED);
  5042. }
  5043. if (checkboxModeEnabled) {
  5044. _updateApplyFiletypesButtonState(filetypeSectionContent);
  5045. }
  5046. }
  5047.  
  5048. /**
  5049. * Binds the main event listeners for the sidebar itself, primarily using event delegation.
  5050. * This includes clicks on the settings and collapse buttons, as well as section titles.
  5051. */
  5052. function bindSidebarEvents() {
  5053. if (!sidebar) return;
  5054. const collapseButton = sidebar.querySelector(`#${IDS.COLLAPSE_BUTTON}`);
  5055. const settingsButton = sidebar.querySelector(`#${IDS.SETTINGS_BUTTON}`);
  5056. if (collapseButton) collapseButton.title = _('sidebar_collapse_title');
  5057. if (settingsButton) settingsButton.title = _('sidebar_settings_title');
  5058.  
  5059. // Use a single delegated event listener for the entire sidebar for efficiency.
  5060. sidebar.addEventListener('click', (e) => {
  5061. const settingsBtnTarget = e.target.closest(`#${IDS.SETTINGS_BUTTON}`);
  5062. if (settingsBtnTarget) { SettingsManager.show(); return; }
  5063. const collapseBtnTarget = e.target.closest(`#${IDS.COLLAPSE_BUTTON}`);
  5064. if (collapseBtnTarget) { toggleSidebarCollapse(); return; }
  5065. const sectionTitleTarget = e.target.closest(`.${CSS.SIDEBAR_CONTENT_WRAPPER} .${CSS.SECTION_TITLE}`);
  5066. if (sectionTitleTarget && !sidebar.classList.contains(CSS.IS_SIDEBAR_COLLAPSED)) { handleSectionCollapse(e); return; }
  5067. });
  5068.  
  5069. sidebar.addEventListener('mousedown', (event) => {
  5070. if (event.button !== 1) { return; } // Middle-click only
  5071. const target = event.target.closest(`.${CSS.FILTER_OPTION}, label[data-site-url], label[data-filetype-value]`);
  5072. if (!target) { return; }
  5073. event.preventDefault();
  5074.  
  5075. let targetUrl = null;
  5076. const dataset = target.dataset;
  5077.  
  5078. if (target.id === IDS.CLEAR_SITE_SEARCH_OPTION) { targetUrl = URLActionManager.generateClearSiteSearchURL(); }
  5079. else if (target.id === IDS.CLEAR_FILETYPE_SEARCH_OPTION) { targetUrl = URLActionManager.generateClearFiletypeSearchURL(); }
  5080. else if (dataset.siteUrl) { targetUrl = URLActionManager.generateSiteSearchURL(dataset.siteUrl); }
  5081. else if (dataset.filetypeValue) { targetUrl = URLActionManager.generateCombinedFiletypeSearchURL(dataset.filetypeValue); }
  5082. else if (dataset.filterType && typeof dataset.filterValue !== 'undefined') { targetUrl = URLActionManager.generateFilterURL(dataset.filterType, dataset.filterValue); }
  5083.  
  5084. if (targetUrl && typeof GM_openInTab === 'function') {
  5085. GM_openInTab(targetUrl, { active: false, insert: true });
  5086. }
  5087. });
  5088. }
  5089.  
  5090. /**
  5091. * Toggles the collapsed/expanded state of the entire sidebar and saves the new state.
  5092. */
  5093. function toggleSidebarCollapse() { const cs = SettingsManager.getCurrentSettings(); cs.sidebarCollapsed = !cs.sidebarCollapsed; applySettings(cs); SettingsManager.save('Sidebar Collapse');}
  5094.  
  5095. /**
  5096. * Handles a click on a section title to expand or collapse it.
  5097. * It also manages the accordion effect if enabled.
  5098. * @param {Event} event - The click event.
  5099. */
  5100. 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'); } }
  5101.  
  5102. /**
  5103. * Collapses all other sections when one is expanded in accordion mode.
  5104. * @private
  5105. * @returns {boolean} True if any section's state was changed.
  5106. */
  5107. 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; }
  5108.  
  5109. /**
  5110. * Toggles the visual state of a single section and updates its state in the settings if necessary.
  5111. * @private
  5112. * @returns {boolean} True if the section's state was changed.
  5113. */
  5114. 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; }
  5115.  
  5116. /**
  5117. * The main entry point for the script. It orchestrates the initialization process,
  5118. * including loading dependencies, settings, and building the UI.
  5119. */
  5120. function initializeScript() {
  5121. console.log(LOG_PREFIX + " Initializing script...");
  5122. debouncedSaveSettings = Utils.debounce(() => SettingsManager.save('Debounced Save'), 800);
  5123. try {
  5124. addGlobalStyles(); NotificationManager.init(); LocalizationService.initializeBaseLocale();
  5125. SettingsManager.initialize( defaultSettings, applySettings, buildSidebarUI, applySectionCollapseStates, _initMenuCommands, renderSectionOrderList );
  5126. setupSystemThemeListener(); buildSidebarSkeleton();
  5127. DragManager.init( sidebar, sidebar.querySelector(`.${CSS.DRAG_HANDLE}`), SettingsManager, debouncedSaveSettings );
  5128. const initialSettings = SettingsManager.getCurrentSettings();
  5129. DragManager.setDraggable(initialSettings.draggableHandleEnabled, sidebar, sidebar.querySelector(`.${CSS.DRAG_HANDLE}`));
  5130. applySettings(initialSettings); buildSidebarUI(); bindSidebarEvents(); _initMenuCommands();
  5131. ResultStatsManager.init();
  5132. console.log(`${LOG_PREFIX} Script initialization complete. Final effective locale: ${LocalizationService.getCurrentLocale()}`);
  5133. } catch (error) {
  5134. console.error(`${LOG_PREFIX} [initializeScript] CRITICAL ERROR DURING INITIALIZATION:`, error, error.stack);
  5135. const scriptNameForAlert = (typeof _ === 'function' && _('scriptName') && !(_('scriptName').startsWith('[ERR:'))) ? _('scriptName') : SCRIPT_INTERNAL_NAME;
  5136. if (typeof NotificationManager !== 'undefined' && NotificationManager.show) { NotificationManager.show('alert_init_fail', { scriptName: scriptNameForAlert, error: error.message }, 'error', 0); }
  5137. else { _showGlobalMessage('alert_init_fail', { scriptName: scriptNameForAlert, error: error.message }, 'error', 0); }
  5138. if(sidebar && sidebar.remove) sidebar.remove(); const settingsOverlayEl = document.getElementById(IDS.SETTINGS_OVERLAY); if(settingsOverlayEl) settingsOverlayEl.remove(); ModalManager.hide();
  5139. }
  5140. }
  5141.  
  5142. /**
  5143. * This section handles the loading of external dependencies (styles and i18n).
  5144. * It waits for custom events dispatched by the companion scripts before initializing the main script.
  5145. * A timeout is included as a fallback in case the events do not fire.
  5146. */
  5147. if (document.getElementById(IDS.SIDEBAR)) { console.warn(`${LOG_PREFIX} Sidebar with ID "${IDS.SIDEBAR}" already exists. Skipping initialization.`); return; }
  5148. const dependenciesReady = { styles: false, i18n: false }; let initializationAttempted = false; let timeoutFallback;
  5149. 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 }); } } }
  5150. document.addEventListener('gscsStylesLoaded', function stylesLoadedHandler() { console.log(`${LOG_PREFIX} Event "gscsStylesLoaded" received.`); dependenciesReady.styles = true; checkDependenciesAndInitialize(); }, { once: true });
  5151. document.addEventListener('gscsi18nLoaded', function i18nLoadedHandler() { console.log(`${LOG_PREFIX} Event "gscsi18nLoaded" received.`); dependenciesReady.i18n = true; checkDependenciesAndInitialize(); }, { once: true });
  5152. 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);
  5153. if (document.readyState === 'complete' || document.readyState === 'interactive' || document.readyState === 'loaded') {
  5154. if (typeof window.GSCS_Namespace !== 'undefined') {
  5155. if (typeof window.GSCS_Namespace.stylesText === 'string' && window.GSCS_Namespace.stylesText.trim() !== '' && !dependenciesReady.styles) {
  5156. dependenciesReady.styles = true;
  5157. }
  5158. if (typeof window.GSCS_Namespace.i18nPack === 'object' && Object.keys(window.GSCS_Namespace.i18nPack.translations || {}).length > 0 && !dependenciesReady.i18n) {
  5159. dependenciesReady.i18n = true;
  5160. }
  5161. }
  5162. if (dependenciesReady.styles && dependenciesReady.i18n && !initializationAttempted) {
  5163. checkDependenciesAndInitialize();
  5164. }
  5165. }
  5166. })();

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址