From 1fc19d34519a773325e259e4f589ffd07d6a480c Mon Sep 17 00:00:00 2001 From: Yevhenii Zhaivoronok Date: Wed, 8 Oct 2025 20:31:14 +0200 Subject: [PATCH 1/5] build: add biome lint:fix script --- package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/package.json b/package.json index 832d12a..c07b2b7 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "scripts": { "test": "", "lint": "npx biome lint ./src", + "lint:fix": "npx biome lint ./src --fix", "tsc-noemit": "npx tsc -noEmit", "build-firefox": "node ./bob.mjs -d -b firefox", "build-firefox-release": "node ./bob.mjs -z -s -b firefox", From 678316ca7b9f77694f4dec07ad1fddf4bbab74d3 Mon Sep 17 00:00:00 2001 From: Yevhenii Zhaivoronok Date: Wed, 8 Oct 2025 20:37:16 +0200 Subject: [PATCH 2/5] chore: add pnpm lock file to .gitignore --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 31bbe09..27d0404 100644 --- a/.gitignore +++ b/.gitignore @@ -55,6 +55,9 @@ build/Release node_modules/ jspm_packages/ +# pnpm +pnpm-lock.yaml + # Snowpack dependency directory (https://snowpack.dev/) web_modules/ From e3b92ca3eb2d3bbc8aeeb390c0945e20183e79e0 Mon Sep 17 00:00:00 2001 From: Yevhenii Zhaivoronok Date: Wed, 8 Oct 2025 21:29:40 +0200 Subject: [PATCH 3/5] feat: add multi-trigger functionality for search queries - Added a new `multiTrigger` option in the configuration to allow users to specify multiple search triggers. - Updated the `getRedirects` function to handle both single and multi-trigger scenarios. - Included OptionsTabPanel in UI for setting the multi-trigger, with validation to prevent conflicts with the standard trigger. --- src/background/requests.ts | 84 +++++++++++++++------ src/configui/components/OptionsTabPanel.tsx | 46 +++++++++++ src/lib/config/config.ts | 2 + src/lib/config/default.ts | 1 + 4 files changed, 111 insertions(+), 22 deletions(-) diff --git a/src/background/requests.ts b/src/background/requests.ts index c4ef217..3baeb76 100644 --- a/src/background/requests.ts +++ b/src/background/requests.ts @@ -105,30 +105,60 @@ export async function getRedirects( // Cut the first bang we can find from the query text, it can be anywhere in // the string - const { trigger } = opts; + const { trigger, multiTrigger } = opts; - // Escape regex special characters in the string (e.g. ")" or "."). This - // prevents these characters from being interpreted as regex syntax. Note: - // RegExp.escape() would be cleaner but isn't supported in all browsers yet - const escapedTrigger = trigger.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const triggersToCheck = [] as Array<{ + triggerStr: string; + isMulti: boolean; + }>; - // Build a regex pattern matching three cases: - // 1. Trigger at the start of a string followed by whitespace - // 2. Trigger after whitespace in the middle of a string - // 3. Trigger at the end of a string + if (typeof multiTrigger === "string" && multiTrigger.trim() !== "") { + triggersToCheck.push({ triggerStr: multiTrigger, isMulti: true }); + } - // To include variables we use a template string, annoyingly because regex - // uses lots of backslashes we have to escape them all 🤮 - const matchTrigger = new RegExp( - `(^${escapedTrigger}\\S+\\s|\\s${escapedTrigger}\\S+|^${escapedTrigger}\\S+$)`, - ); + if (trigger.trim() !== "") { + triggersToCheck.push({ triggerStr: trigger, isMulti: false }); + } let keywordUsed = ""; - queryText = queryText.replace(matchTrigger, (match) => { - keywordUsed = match.trim().replace(trigger, ""); - // Replace bang with zero len str - return ""; - }); + let isMulti = false; + + for (const candidate of triggersToCheck) { + // Escape regex special characters in the string (e.g. ")" or "."). This + // prevents these characters from being interpreted as regex syntax. Note: + // RegExp.escape() would be cleaner but isn't supported in all browsers yet + const escapedCandidate = candidate.triggerStr.replace( + /[.*+?^${}()|[\]\\]/g, + "\\$&", + ); + + // Build a regex pattern matching three cases: + // 1. Trigger at the start of a string followed by whitespace + // 2. Trigger after whitespace in the middle of a string + // 3. Trigger at the end of a string + + // To include variables we use a template string, annoyingly because regex + // uses lots of backslashes we have to escape them all 🤮 + const matchTrigger = new RegExp( + `(^${escapedCandidate}\\S+\\s|\\s${escapedCandidate}\\S+|^${escapedCandidate}\\S+$)`, + ); + + let localKeyword = ""; + const nextQueryText = queryText.replace(matchTrigger, (match) => { + localKeyword = match + .trim() + .slice(candidate.triggerStr.length) + .split(/\s+/)[0]; + return ""; + }); + + if (localKeyword.length > 0) { + keywordUsed = localKeyword; + queryText = nextQueryText.trim(); + isMulti = candidate.isMulti; + break; + } + } if (keywordUsed.length === 0) { return []; @@ -156,9 +186,19 @@ export async function getRedirects( } // Construct the URL(s) to redirect the user to. - const redirects: Array = []; - for (const bangInfo of redirectionBangInfos) { - redirects.push(...constructRedirects(bangInfo, queryText)); + const redirects = [] as Array; + + if (isMulti) { + const terms = queryText.split(/\s+/).filter((term) => term.length > 0); + for (const term of terms) { + for (const bangInfo of redirectionBangInfos) { + redirects.push(...constructRedirects(bangInfo, term)); + } + } + } else { + for (const bangInfo of redirectionBangInfos) { + redirects.push(...constructRedirects(bangInfo, queryText)); + } } return redirects; diff --git a/src/configui/components/OptionsTabPanel.tsx b/src/configui/components/OptionsTabPanel.tsx index 73e193d..833f502 100644 --- a/src/configui/components/OptionsTabPanel.tsx +++ b/src/configui/components/OptionsTabPanel.tsx @@ -3,6 +3,7 @@ import React, { type Dispatch, type SetStateAction, useEffect, + useMemo, useState, } from "react"; import { @@ -38,6 +39,9 @@ export default function BangsTabPanel(props: Props) { const [triggerText, setTriggerText] = useState( initialOptions.trigger, ); + const [multiTriggerText, setMultiTriggerText] = useState( + initialOptions.multiTrigger, + ); const [storageMethod, setStorageMethod] = useState(initialOptions.storageMethod); @@ -58,6 +62,12 @@ export default function BangsTabPanel(props: Props) { initialOptions.ignoreBangCase, ); + const triggersAreConflicting = useMemo(() => { + const trigger = triggerText.trim(); + const multiTrigger = multiTriggerText.trim(); + return trigger !== "" && multiTrigger !== "" && trigger === multiTrigger; + }, [multiTriggerText, triggerText]); + function ignoredDomainsListAsArray(): Array { return Object.keys(ignoredDomainsList).filter( (key) => ignoredDomainsList[key], @@ -70,6 +80,7 @@ export default function BangsTabPanel(props: Props) { useEffect(() => { setNeedToSave( initialOptions.trigger !== triggerText || + initialOptions.multiTrigger !== multiTriggerText || initialOptions.storageMethod !== storageMethod || initialOptions.ignoredSearchDomains.length !== ignoredDomainsListAsArray().length || @@ -78,12 +89,24 @@ export default function BangsTabPanel(props: Props) { }, [ initialOptions, triggerText, + multiTriggerText, storageMethod, ignoredDomainsList, ignoreBangCase, ]); const saveOptions = async () => { + if (triggersAreConflicting) { + notifications.show({ + title: "Failed to save options", + message: "Multi trigger must be different to the standard trigger", + autoClose: true, + icon: , + color: "red", + }); + return; + } + const notifId = notifications.show({ title: "Saving options...", message: "", @@ -97,6 +120,7 @@ export default function BangsTabPanel(props: Props) { cfg = await storage.getConfig(); cfg.options.trigger = triggerText; + cfg.options.multiTrigger = multiTriggerText; cfg.options.storageMethod = storageMethod; cfg.options.ignoredSearchDomains = ignoredDomainsListAsArray(); cfg.options.ignoreBangCase = ignoreBangCase; @@ -134,6 +158,7 @@ export default function BangsTabPanel(props: Props) { const resetToDefault = () => { const defaultCfg = defaultConfig(); setTriggerText(defaultCfg.options.trigger); + setMultiTriggerText(defaultCfg.options.multiTrigger); setStorageMethod(defaultCfg.options.storageMethod); setIgnoredDomainsList( Object.fromEntries( @@ -197,6 +222,27 @@ export default function BangsTabPanel(props: Props) { + + + Multi Trigger + setMultiTriggerText(e.target.value)} + placeholder="!!" + error={triggersAreConflicting} + /> + + + The character(s) to trigger multiple searches at once. Each remaining + word will be used as an individual query + + {triggersAreConflicting && ( + + Multi trigger must be different to the standard trigger + + )} + Storage Method diff --git a/src/lib/config/config.ts b/src/lib/config/config.ts index 9d74177..a071c75 100644 --- a/src/lib/config/config.ts +++ b/src/lib/config/config.ts @@ -10,6 +10,8 @@ export const allowedStorageMethodsAsArray: Array = export interface Options { // The character(s) to trigger the extension trigger: string; + // The character(s) to trigger multi-query searches + multiTrigger: string; // The storage method for config storageMethod: allowedStorageMethodsAsType; // Search engine domains to ignore, e.g. searx.tiekoetter.com diff --git a/src/lib/config/default.ts b/src/lib/config/default.ts index 0d9f149..511b589 100644 --- a/src/lib/config/default.ts +++ b/src/lib/config/default.ts @@ -46,6 +46,7 @@ const cfg: config.Config = { version: config.currentConfigVersion, options: { trigger: "!", + multiTrigger: "!!", storageMethod: "sync", ignoredSearchDomains: [], ignoreBangCase: false, From a9881ca36b54706b8cc99141f51d744f6b374199 Mon Sep 17 00:00:00 2001 From: Yevhenii Zhaivoronok Date: Wed, 8 Oct 2025 22:16:42 +0200 Subject: [PATCH 4/5] feat: added option to disable/enable Multi Trigger --- src/background/requests.ts | 7 +- src/configui/components/OptionsTabPanel.tsx | 632 ++++++++++---------- src/lib/config/config.ts | 2 + src/lib/config/default.ts | 1 + 4 files changed, 334 insertions(+), 308 deletions(-) diff --git a/src/background/requests.ts b/src/background/requests.ts index 3baeb76..b120454 100644 --- a/src/background/requests.ts +++ b/src/background/requests.ts @@ -106,13 +106,18 @@ export async function getRedirects( // Cut the first bang we can find from the query text, it can be anywhere in // the string const { trigger, multiTrigger } = opts; + const enableMultiTrigger = opts.enableMultiTrigger ?? true; const triggersToCheck = [] as Array<{ triggerStr: string; isMulti: boolean; }>; - if (typeof multiTrigger === "string" && multiTrigger.trim() !== "") { + if ( + enableMultiTrigger && + typeof multiTrigger === "string" && + multiTrigger.trim() !== "" + ) { triggersToCheck.push({ triggerStr: multiTrigger, isMulti: true }); } diff --git a/src/configui/components/OptionsTabPanel.tsx b/src/configui/components/OptionsTabPanel.tsx index 833f502..b27bcfb 100644 --- a/src/configui/components/OptionsTabPanel.tsx +++ b/src/configui/components/OptionsTabPanel.tsx @@ -1,307 +1,325 @@ -import React, { - type ChangeEvent, - type Dispatch, - type SetStateAction, - useEffect, - useMemo, - useState, -} from "react"; -import { - Stack, - Title, - Text, - Group, - Switch, - Input, - SegmentedControl, - Code, - Anchor, - Button, -} from "@mantine/core"; -import { Check, RotateCcw, Save, X } from "lucide-react"; -import { notifications } from "@mantine/notifications"; - -import * as config from "../../lib/config/config"; -import * as storage from "../../lib/config/storage/storage"; -import { hostPermissionUrls } from "../../lib/esbuilddefinitions"; -import defaultConfig from "../../lib/config/default"; - -interface Props { - initialOptions: config.Options; - setInitialConfig: Dispatch>; -} - -export default function BangsTabPanel(props: Props) { - const { initialOptions, setInitialConfig } = props; - - const [needToSave, setNeedToSave] = useState(false); - - const [triggerText, setTriggerText] = useState( - initialOptions.trigger, - ); - const [multiTriggerText, setMultiTriggerText] = useState( - initialOptions.multiTrigger, - ); - - const [storageMethod, setStorageMethod] = - useState(initialOptions.storageMethod); - - // Record - const [ignoredDomainsList, setIgnoredDomainsList] = useState< - Record - >( - Object.fromEntries( - hostPermissionUrls.map((url) => [ - url, - initialOptions.ignoredSearchDomains.includes(url), - ]), - ), - ); - - const [ignoreBangCase, setIgnoreBangCase] = useState( - initialOptions.ignoreBangCase, - ); - - const triggersAreConflicting = useMemo(() => { - const trigger = triggerText.trim(); - const multiTrigger = multiTriggerText.trim(); - return trigger !== "" && multiTrigger !== "" && trigger === multiTrigger; - }, [multiTriggerText, triggerText]); - - function ignoredDomainsListAsArray(): Array { - return Object.keys(ignoredDomainsList).filter( - (key) => ignoredDomainsList[key], - ); - } - - // The vaule of the variable ignoredDomainsListAsArray does not indicate if we - // have something to save or not, dont include in effect deps - // biome-ignore lint/correctness/useExhaustiveDependencies: - useEffect(() => { - setNeedToSave( - initialOptions.trigger !== triggerText || - initialOptions.multiTrigger !== multiTriggerText || - initialOptions.storageMethod !== storageMethod || - initialOptions.ignoredSearchDomains.length !== - ignoredDomainsListAsArray().length || - initialOptions.ignoreBangCase !== ignoreBangCase, - ); - }, [ - initialOptions, - triggerText, - multiTriggerText, - storageMethod, - ignoredDomainsList, - ignoreBangCase, - ]); - - const saveOptions = async () => { - if (triggersAreConflicting) { - notifications.show({ - title: "Failed to save options", - message: "Multi trigger must be different to the standard trigger", - autoClose: true, - icon: , - color: "red", - }); - return; - } - - const notifId = notifications.show({ - title: "Saving options...", - message: "", - loading: true, - autoClose: false, - withCloseButton: false, - }); - - let cfg: config.Config; - try { - cfg = await storage.getConfig(); - - cfg.options.trigger = triggerText; - cfg.options.multiTrigger = multiTriggerText; - cfg.options.storageMethod = storageMethod; - cfg.options.ignoredSearchDomains = ignoredDomainsListAsArray(); - cfg.options.ignoreBangCase = ignoreBangCase; - - await storage.updateStorageManagerMethod(storageMethod); - await storage.storeConfig(cfg); - await storage.clearUnusedStorageManagers(); - } catch (error) { - notifications.update({ - id: notifId, - title: "Failed to save options", - message: error instanceof Error ? error.message : "", - autoClose: true, - icon: , - color: "red", - loading: false, - }); - return; - } - - setNeedToSave(false); - setInitialConfig(cfg); - - notifications.update({ - id: notifId, - title: "Options saved", - message: "", - autoClose: true, - icon: , - color: "green", - loading: false, - }); - }; - - const resetToDefault = () => { - const defaultCfg = defaultConfig(); - setTriggerText(defaultCfg.options.trigger); - setMultiTriggerText(defaultCfg.options.multiTrigger); - setStorageMethod(defaultCfg.options.storageMethod); - setIgnoredDomainsList( - Object.fromEntries( - hostPermissionUrls.map((url) => [ - url, - defaultCfg.options.ignoredSearchDomains.includes(url), - ]), - ), - ); - setIgnoreBangCase(defaultCfg.options.ignoreBangCase); - }; - - const handleIgnoredSwitchChanged = - (label: string) => (event: ChangeEvent) => { - // We reverse the checked because the UI is showing "enabled", but the - // code is dealing with disabled - setIgnoredDomainsList((prev) => ({ - ...prev, - // NOTE: Not sure why currentTarget can't be used, but this works - [label]: !event.target.checked, - })); - }; - - return ( - - - - - - - - Trigger - setTriggerText(e.target.value)} - /> - - - The character(s) to trigger the extension, traditionally a{" "} - - bang - - - - - - Multi Trigger - setMultiTriggerText(e.target.value)} - placeholder="!!" - error={triggersAreConflicting} - /> - - - The character(s) to trigger multiple searches at once. Each remaining - word will be used as an individual query - - {triggersAreConflicting && ( - - Multi trigger must be different to the standard trigger - - )} - - - - Storage Method - - setStorageMethod(v as config.allowedStorageMethodsAsType) - } - data={config.allowedStorageMethodsAsArray} - /> - - - The storage method for config, sync uses your browsers{" "} - - sync storage - - , local uses{" "} - - local storage - - - - - - Case Insensitive Bangs - { - setIgnoreBangCase(e.currentTarget.checked); - }} - /> - - - For example, if active, the bangs a and A{" "} - will be equivalent - - - - - Enabled Domains - Search engine domains that this extension will trigger on - {hostPermissionUrls.map((label) => ( - - ))} - - - - ); -} +import React, { + type ChangeEvent, + type Dispatch, + type SetStateAction, + useEffect, + useMemo, + useState, +} from "react"; +import { + Stack, + Title, + Text, + Group, + Switch, + Input, + SegmentedControl, + Code, + Anchor, + Button, +} from "@mantine/core"; +import { Check, RotateCcw, Save, X } from "lucide-react"; +import { notifications } from "@mantine/notifications"; + +import * as config from "../../lib/config/config"; +import * as storage from "../../lib/config/storage/storage"; +import { hostPermissionUrls } from "../../lib/esbuilddefinitions"; +import defaultConfig from "../../lib/config/default"; + +interface Props { + initialOptions: config.Options; + setInitialConfig: Dispatch>; +} + +export default function BangsTabPanel(props: Props) { + const { initialOptions, setInitialConfig } = props; + + const [needToSave, setNeedToSave] = useState(false); + + const [triggerText, setTriggerText] = useState( + initialOptions.trigger, + ); + const [multiTriggerText, setMultiTriggerText] = useState( + initialOptions.multiTrigger, + ); + const [multiTriggerEnabled, setMultiTriggerEnabled] = useState( + initialOptions.enableMultiTrigger, + ); + + const [storageMethod, setStorageMethod] = + useState(initialOptions.storageMethod); + + // Record + const [ignoredDomainsList, setIgnoredDomainsList] = useState< + Record + >( + Object.fromEntries( + hostPermissionUrls.map((url) => [ + url, + initialOptions.ignoredSearchDomains.includes(url), + ]), + ), + ); + + const [ignoreBangCase, setIgnoreBangCase] = useState( + initialOptions.ignoreBangCase, + ); + + const triggersAreConflicting = useMemo(() => { + if (!multiTriggerEnabled) { + return false; + } + const trigger = triggerText.trim(); + const multiTrigger = multiTriggerText.trim(); + return trigger !== "" && multiTrigger !== "" && trigger === multiTrigger; + }, [multiTriggerEnabled, multiTriggerText, triggerText]); + + function ignoredDomainsListAsArray(): Array { + return Object.keys(ignoredDomainsList).filter( + (key) => ignoredDomainsList[key], + ); + } + + // The vaule of the variable ignoredDomainsListAsArray does not indicate if we + // have something to save or not, dont include in effect deps + // biome-ignore lint/correctness/useExhaustiveDependencies: + useEffect(() => { + setNeedToSave( + initialOptions.trigger !== triggerText || + initialOptions.multiTrigger !== multiTriggerText || + initialOptions.enableMultiTrigger !== multiTriggerEnabled || + initialOptions.storageMethod !== storageMethod || + initialOptions.ignoredSearchDomains.length !== + ignoredDomainsListAsArray().length || + initialOptions.ignoreBangCase !== ignoreBangCase, + ); + }, [ + initialOptions, + triggerText, + multiTriggerText, + multiTriggerEnabled, + storageMethod, + ignoredDomainsList, + ignoreBangCase, + ]); + + const saveOptions = async () => { + if (triggersAreConflicting) { + notifications.show({ + title: "Failed to save options", + message: "Multi trigger must be different to the standard trigger", + autoClose: true, + icon: , + color: "red", + }); + return; + } + + const notifId = notifications.show({ + title: "Saving options...", + message: "", + loading: true, + autoClose: false, + withCloseButton: false, + }); + + let cfg: config.Config; + try { + cfg = await storage.getConfig(); + + cfg.options.trigger = triggerText; + cfg.options.multiTrigger = multiTriggerText; + cfg.options.enableMultiTrigger = multiTriggerEnabled; + cfg.options.storageMethod = storageMethod; + cfg.options.ignoredSearchDomains = ignoredDomainsListAsArray(); + cfg.options.ignoreBangCase = ignoreBangCase; + + await storage.updateStorageManagerMethod(storageMethod); + await storage.storeConfig(cfg); + await storage.clearUnusedStorageManagers(); + } catch (error) { + notifications.update({ + id: notifId, + title: "Failed to save options", + message: error instanceof Error ? error.message : "", + autoClose: true, + icon: , + color: "red", + loading: false, + }); + return; + } + + setNeedToSave(false); + setInitialConfig(cfg); + + notifications.update({ + id: notifId, + title: "Options saved", + message: "", + autoClose: true, + icon: , + color: "green", + loading: false, + }); + }; + + const resetToDefault = () => { + const defaultCfg = defaultConfig(); + setTriggerText(defaultCfg.options.trigger); + setMultiTriggerText(defaultCfg.options.multiTrigger); + setMultiTriggerEnabled(defaultCfg.options.enableMultiTrigger); + setStorageMethod(defaultCfg.options.storageMethod); + setIgnoredDomainsList( + Object.fromEntries( + hostPermissionUrls.map((url) => [ + url, + defaultCfg.options.ignoredSearchDomains.includes(url), + ]), + ), + ); + setIgnoreBangCase(defaultCfg.options.ignoreBangCase); + }; + + const handleIgnoredSwitchChanged = + (label: string) => (event: ChangeEvent) => { + // We reverse the checked because the UI is showing "enabled", but the + // code is dealing with disabled + setIgnoredDomainsList((prev) => ({ + ...prev, + // NOTE: Not sure why currentTarget can't be used, but this works + [label]: !event.target.checked, + })); + }; + + return ( + + + + + + + + Trigger + setTriggerText(e.target.value)} + /> + + + The character(s) to trigger the extension, traditionally a{" "} + + bang + + + + + + Multi Trigger + setMultiTriggerText(e.target.value)} + placeholder="!!" + error={triggersAreConflicting} + disabled={!multiTriggerEnabled} + /> + + + The character(s) to trigger multiple searches at once. Each remaining + word will be used as an individual query + + + Enable Multi Trigger + setMultiTriggerEnabled(e.currentTarget.checked)} + /> + + {triggersAreConflicting && ( + + Multi trigger must be different to the standard trigger + + )} + + + + Storage Method + + setStorageMethod(v as config.allowedStorageMethodsAsType) + } + data={config.allowedStorageMethodsAsArray} + /> + + + The storage method for config, sync uses your browsers{" "} + + sync storage + + , local uses{" "} + + local storage + + + + + + Case Insensitive Bangs + { + setIgnoreBangCase(e.currentTarget.checked); + }} + /> + + + For example, if active, the bangs a and A{" "} + will be equivalent + + + + + Enabled Domains + Search engine domains that this extension will trigger on + {hostPermissionUrls.map((label) => ( + + ))} + + + + ); +} diff --git a/src/lib/config/config.ts b/src/lib/config/config.ts index a071c75..2fb3f3d 100644 --- a/src/lib/config/config.ts +++ b/src/lib/config/config.ts @@ -12,6 +12,8 @@ export interface Options { trigger: string; // The character(s) to trigger multi-query searches multiTrigger: string; + // Whether multi-trigger searches are enabled + enableMultiTrigger: boolean; // The storage method for config storageMethod: allowedStorageMethodsAsType; // Search engine domains to ignore, e.g. searx.tiekoetter.com diff --git a/src/lib/config/default.ts b/src/lib/config/default.ts index 511b589..1e686e6 100644 --- a/src/lib/config/default.ts +++ b/src/lib/config/default.ts @@ -47,6 +47,7 @@ const cfg: config.Config = { options: { trigger: "!", multiTrigger: "!!", + enableMultiTrigger: false, storageMethod: "sync", ignoredSearchDomains: [], ignoreBangCase: false, From 34c9e7a6415f70a47a89af4e405b3313492ca2ec Mon Sep 17 00:00:00 2001 From: Yevhenii Zhaivoronok Date: Wed, 8 Oct 2025 22:29:37 +0200 Subject: [PATCH 5/5] feat: add support for bracketed groups in multi-trigger searches --- src/background/requests.ts | 27 ++++++++++++++++++++- src/configui/components/OptionsTabPanel.tsx | 19 +++++++++++++++ src/lib/config/config.ts | 2 ++ src/lib/config/default.ts | 1 + 4 files changed, 48 insertions(+), 1 deletion(-) diff --git a/src/background/requests.ts b/src/background/requests.ts index b120454..e1351ec 100644 --- a/src/background/requests.ts +++ b/src/background/requests.ts @@ -70,6 +70,30 @@ function constructRedirects( return redirs; } +function splitMultiSearchTerms( + text: string, + allowBracketGroups: boolean, +): string[] { + if (!allowBracketGroups) { + return text.split(/\s+/).filter((term) => term.length > 0); + } + const terms: string[] = []; + const regex = /\[([^\]]+)\]|(\S+)/g; + let match: RegExpExecArray | null = regex.exec(text); + while (match !== null) { + if (match[1] !== undefined) { + const term = match[1].trim(); + if (term.length > 0) { + terms.push(term); + } + } else if (match[2] !== undefined) { + terms.push(match[2]); + } + match = regex.exec(text); + } + return terms; +} + /** * Given a URL, construct the associated redirects, if a bang exists in the query. * @param request The request details from a WebRequest event. @@ -107,6 +131,7 @@ export async function getRedirects( // the string const { trigger, multiTrigger } = opts; const enableMultiTrigger = opts.enableMultiTrigger ?? true; + const allowBracketGroups = opts.enableMultiTriggerBrackets ?? true; const triggersToCheck = [] as Array<{ triggerStr: string; @@ -194,7 +219,7 @@ export async function getRedirects( const redirects = [] as Array; if (isMulti) { - const terms = queryText.split(/\s+/).filter((term) => term.length > 0); + const terms = splitMultiSearchTerms(queryText, allowBracketGroups); for (const term of terms) { for (const bangInfo of redirectionBangInfos) { redirects.push(...constructRedirects(bangInfo, term)); diff --git a/src/configui/components/OptionsTabPanel.tsx b/src/configui/components/OptionsTabPanel.tsx index b27bcfb..786e214 100644 --- a/src/configui/components/OptionsTabPanel.tsx +++ b/src/configui/components/OptionsTabPanel.tsx @@ -45,6 +45,9 @@ export default function BangsTabPanel(props: Props) { const [multiTriggerEnabled, setMultiTriggerEnabled] = useState( initialOptions.enableMultiTrigger, ); + const [multiTriggerBrackets, setMultiTriggerBrackets] = useState( + initialOptions.enableMultiTriggerBrackets, + ); const [storageMethod, setStorageMethod] = useState(initialOptions.storageMethod); @@ -88,6 +91,7 @@ export default function BangsTabPanel(props: Props) { initialOptions.trigger !== triggerText || initialOptions.multiTrigger !== multiTriggerText || initialOptions.enableMultiTrigger !== multiTriggerEnabled || + initialOptions.enableMultiTriggerBrackets !== multiTriggerBrackets || initialOptions.storageMethod !== storageMethod || initialOptions.ignoredSearchDomains.length !== ignoredDomainsListAsArray().length || @@ -98,6 +102,7 @@ export default function BangsTabPanel(props: Props) { triggerText, multiTriggerText, multiTriggerEnabled, + multiTriggerBrackets, storageMethod, ignoredDomainsList, ignoreBangCase, @@ -130,6 +135,7 @@ export default function BangsTabPanel(props: Props) { cfg.options.trigger = triggerText; cfg.options.multiTrigger = multiTriggerText; cfg.options.enableMultiTrigger = multiTriggerEnabled; + cfg.options.enableMultiTriggerBrackets = multiTriggerBrackets; cfg.options.storageMethod = storageMethod; cfg.options.ignoredSearchDomains = ignoredDomainsListAsArray(); cfg.options.ignoreBangCase = ignoreBangCase; @@ -169,6 +175,7 @@ export default function BangsTabPanel(props: Props) { setTriggerText(defaultCfg.options.trigger); setMultiTriggerText(defaultCfg.options.multiTrigger); setMultiTriggerEnabled(defaultCfg.options.enableMultiTrigger); + setMultiTriggerBrackets(defaultCfg.options.enableMultiTriggerBrackets); setStorageMethod(defaultCfg.options.storageMethod); setIgnoredDomainsList( Object.fromEntries( @@ -255,6 +262,18 @@ export default function BangsTabPanel(props: Props) { onChange={(e) => setMultiTriggerEnabled(e.currentTarget.checked)} /> + + Words inside square brackets count as a single term. Example: + [php date format]. + + + Allow bracket grouping + setMultiTriggerBrackets(e.currentTarget.checked)} + disabled={!multiTriggerEnabled} + /> + {triggersAreConflicting && ( Multi trigger must be different to the standard trigger diff --git a/src/lib/config/config.ts b/src/lib/config/config.ts index 2fb3f3d..3e131fb 100644 --- a/src/lib/config/config.ts +++ b/src/lib/config/config.ts @@ -14,6 +14,8 @@ export interface Options { multiTrigger: string; // Whether multi-trigger searches are enabled enableMultiTrigger: boolean; + // Whether bracketed groups count as a single multi-trigger term + enableMultiTriggerBrackets: boolean; // The storage method for config storageMethod: allowedStorageMethodsAsType; // Search engine domains to ignore, e.g. searx.tiekoetter.com diff --git a/src/lib/config/default.ts b/src/lib/config/default.ts index 1e686e6..9b708b3 100644 --- a/src/lib/config/default.ts +++ b/src/lib/config/default.ts @@ -48,6 +48,7 @@ const cfg: config.Config = { trigger: "!", multiTrigger: "!!", enableMultiTrigger: false, + enableMultiTriggerBrackets: true, storageMethod: "sync", ignoredSearchDomains: [], ignoreBangCase: false,