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/ 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", diff --git a/src/background/requests.ts b/src/background/requests.ts index c4ef217..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. @@ -105,30 +129,66 @@ 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; + const enableMultiTrigger = opts.enableMultiTrigger ?? true; + const allowBracketGroups = opts.enableMultiTriggerBrackets ?? true; + + const triggersToCheck = [] as Array<{ + triggerStr: string; + isMulti: boolean; + }>; + + if ( + enableMultiTrigger && + typeof multiTrigger === "string" && + multiTrigger.trim() !== "" + ) { + triggersToCheck.push({ triggerStr: multiTrigger, isMulti: true }); + } - // 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, "\\$&"); + if (trigger.trim() !== "") { + triggersToCheck.push({ triggerStr: trigger, isMulti: false }); + } + + let keywordUsed = ""; + 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 + // 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( - `(^${escapedTrigger}\\S+\\s|\\s${escapedTrigger}\\S+|^${escapedTrigger}\\S+$)`, - ); + // 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 keywordUsed = ""; - queryText = queryText.replace(matchTrigger, (match) => { - keywordUsed = match.trim().replace(trigger, ""); - // Replace bang with zero len str - return ""; - }); + 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 +216,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 = splitMultiSearchTerms(queryText, allowBracketGroups); + 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..786e214 100644 --- a/src/configui/components/OptionsTabPanel.tsx +++ b/src/configui/components/OptionsTabPanel.tsx @@ -1,261 +1,344 @@ -import React, { - type ChangeEvent, - type Dispatch, - type SetStateAction, - useEffect, - 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 [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, - ); - - 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.storageMethod !== storageMethod || - initialOptions.ignoredSearchDomains.length !== - ignoredDomainsListAsArray().length || - initialOptions.ignoreBangCase !== ignoreBangCase, - ); - }, [ - initialOptions, - triggerText, - storageMethod, - ignoredDomainsList, - ignoreBangCase, - ]); - - const saveOptions = async () => { - 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.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); - 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 - - - - - - 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 [multiTriggerBrackets, setMultiTriggerBrackets] = useState( + initialOptions.enableMultiTriggerBrackets, + ); + + 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.enableMultiTriggerBrackets !== multiTriggerBrackets || + initialOptions.storageMethod !== storageMethod || + initialOptions.ignoredSearchDomains.length !== + ignoredDomainsListAsArray().length || + initialOptions.ignoreBangCase !== ignoreBangCase, + ); + }, [ + initialOptions, + triggerText, + multiTriggerText, + multiTriggerEnabled, + multiTriggerBrackets, + 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.enableMultiTriggerBrackets = multiTriggerBrackets; + 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); + setMultiTriggerBrackets(defaultCfg.options.enableMultiTriggerBrackets); + 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)} + /> + + + 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 + + )} + + + + 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 9d74177..3e131fb 100644 --- a/src/lib/config/config.ts +++ b/src/lib/config/config.ts @@ -10,6 +10,12 @@ 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; + // 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 0d9f149..9b708b3 100644 --- a/src/lib/config/default.ts +++ b/src/lib/config/default.ts @@ -46,6 +46,9 @@ const cfg: config.Config = { version: config.currentConfigVersion, options: { trigger: "!", + multiTrigger: "!!", + enableMultiTrigger: false, + enableMultiTriggerBrackets: true, storageMethod: "sync", ignoredSearchDomains: [], ignoreBangCase: false,