diff --git a/packages/api/test/services/library_item.test.ts b/packages/api/test/services/library_item.test.ts new file mode 100644 index 000000000..c567db29d --- /dev/null +++ b/packages/api/test/services/library_item.test.ts @@ -0,0 +1,140 @@ +import { expect } from 'chai' +import 'mocha' +import { filterItemEvents } from '../../src/services/library_item' +import { parseSearchQuery } from '../../src/utils/search' + +describe('filterItemEvents', () => { + it('returns events if there are quotation marks in the subscription name', () => { + const query = 'subscription:"Best \\\"Omnivore\\\""' + const ast = parseSearchQuery(query) + const events = [ + { + subscription: 'Best "Omnivore"', + }, + ] + const result = filterItemEvents(ast, events) + expect(result).to.eql(events) + }) + + it('returns events if subscription name equals ignore case', () => { + const query = 'subscription:substack' + const ast = parseSearchQuery(query) + const events = [ + { + subscription: 'Substack', + }, + ] + const result = filterItemEvents(ast, events) + expect(result).to.eql(events) + }) + + it('returns events if site name equals ignore case', () => { + const query = 'site:youtube' + const ast = parseSearchQuery(query) + const events = [ + { + siteName: 'YouTube', + }, + ] + const result = filterItemEvents(ast, events) + expect(result).to.eql(events) + }) + + it('returns events if site name contains the search query', () => { + const query = 'site:standard' + const ast = parseSearchQuery(query) + const events = [ + { + siteName: 'Der Standard', + }, + ] + const result = filterItemEvents(ast, events) + expect(result).to.eql(events) + }) + + it('returns events if domain name contains the search query', () => { + const query = 'site:stackoverflow.com' + const ast = parseSearchQuery(query) + const events = [ + { + siteName: 'Stack Overflow', + originalUrl: 'https://stackoverflow.com/questions/123', + }, + ] + const result = filterItemEvents(ast, events) + expect(result).to.eql(events) + }) + + it('returns events if top level domain matches', () => { + const query = 'site:".com"' + const ast = parseSearchQuery(query) + const events = [ + { + siteName: 'Stack Overflow', + originalUrl: 'https://stackoverflow.com/questions/123', + }, + ] + const result = filterItemEvents(ast, events) + expect(result).to.eql(events) + }) + + it('returns events if labels match the search query', () => { + const query = 'label:foo' + const ast = parseSearchQuery(query) + const events = [ + { + labelNames: ['foo'], + }, + ] + const result = filterItemEvents(ast, events) + expect(result).to.eql(events) + }) + + it('returns events if labels contain quotation marks', () => { + const query = 'label:"foo \\\"bar\\\""' + const ast = parseSearchQuery(query) + const events = [ + { + labelNames: ['foo "bar"'], + }, + ] + const result = filterItemEvents(ast, events) + expect(result).to.eql(events) + }) + + it('returns events if labels contain space', () => { + const query = 'label:"foo bar"' + const ast = parseSearchQuery(query) + const events = [ + { + labelNames: ['foo bar'], + }, + ] + const result = filterItemEvents(ast, events) + expect(result).to.eql(events) + }) + + it('returns events if labels match the search query ignore case', () => { + const query = 'label:Foo' + const ast = parseSearchQuery(query) + const events = [ + { + labelNames: ['foo'], + }, + ] + const result = filterItemEvents(ast, events) + expect(result).to.eql(events) + }) + + it('returns events if labels match the search query with multiple labels', () => { + const query = 'label:foo,bar' + const ast = parseSearchQuery(query) + const events = [ + { + labelNames: ['foo', 'bar'], + }, + ] + const result = filterItemEvents(ast, events) + expect(result).to.eql(events) + }) +}) diff --git a/packages/web/components/templates/navMenu/LibraryLegacyMenu.tsx b/packages/web/components/templates/navMenu/LibraryLegacyMenu.tsx index 7fa8dcf8a..8590c3578 100644 --- a/packages/web/components/templates/navMenu/LibraryLegacyMenu.tsx +++ b/packages/web/components/templates/navMenu/LibraryLegacyMenu.tsx @@ -20,6 +20,7 @@ import { ToggleCaretDownIcon } from '../../elements/icons/ToggleCaretDownIcon' import Link from 'next/link' import { ToggleCaretRightIcon } from '../../elements/icons/ToggleCaretRightIcon' import { NavMenuFooter } from './Footer' +import { escapeQuotes } from '../../../utils/helper' export const LIBRARY_LEFT_MENU_WIDTH = '275px' @@ -255,7 +256,7 @@ function Subscriptions( name: name, keywords: '*' + name, perform: () => { - props.applySearchQuery(`subscription:\"${name}\"`) + props.applySearchQuery(`subscription:\"${escapeQuotes(name)}\"`) }, } }), @@ -291,7 +292,9 @@ function Subscriptions( return ( @@ -507,7 +510,7 @@ function LabelButton(props: LabelButtonProps): JSX.Element { const checkboxRef = useRef(null) const state = useMemo(() => { const term = props.searchTerm ?? '' - if (term.indexOf(`label:\"${props.label.name}\"`) >= 0) { + if (term.indexOf(`label:\"${escapeQuotes(props.label.name)}\"`) >= 0) { return 'on' } return 'off' @@ -557,7 +560,7 @@ function LabelButton(props: LabelButtonProps): JSX.Element { props.applySearchQuery(query.trim()) } else { props.applySearchQuery( - `${query.trim()} label:\"${props.label.name}\"` + `${query.trim()} label:\"${escapeQuotes(props.label.name)}\"` ) } }} @@ -576,16 +579,14 @@ function LabelButton(props: LabelButtonProps): JSX.Element { type="checkbox" checked={state === 'on'} onChange={(e) => { + const escapedName = escapeQuotes(props.label.name) if (e.target.checked) { props.applySearchQuery( - `${props.searchTerm ?? ''} label:\"${props.label.name}\"` + `${props.searchTerm ?? ''} label:\"${escapedName}\"` ) } else { const query = - props.searchTerm?.replace( - `label:\"${props.label.name}\"`, - '' - ) ?? '' + props.searchTerm?.replace(`label:\"${escapedName}\"`, '') ?? '' props.applySearchQuery(query) } }} diff --git a/packages/web/components/templates/navMenu/LibraryMenu.tsx b/packages/web/components/templates/navMenu/LibraryMenu.tsx index 7a48e621b..a2c5cbb05 100644 --- a/packages/web/components/templates/navMenu/LibraryMenu.tsx +++ b/packages/web/components/templates/navMenu/LibraryMenu.tsx @@ -31,6 +31,7 @@ import { NewsletterIcon } from '../../elements/icons/NewsletterIcon' import { Dropdown, DropdownOption } from '../../elements/DropdownElements' import { useRouter } from 'next/router' import { DiscoverIcon } from "../../elements/icons/DiscoverIcon" +import { escapeQuotes } from "../../../utils/helper" export const LIBRARY_LEFT_MENU_WIDTH = '275px' @@ -221,7 +222,7 @@ const LibraryNav = (props: LibraryFilterMenuProps): JSX.Element => { } /> @@ -545,7 +546,7 @@ function Subscriptions( name: name, keywords: '*' + name, perform: () => { - props.applySearchQuery(`subscription:\"${name}\"`) + props.applySearchQuery(`subscription:\"${escapeQuotes(name)}\"`) }, } }), @@ -581,7 +582,9 @@ function Subscriptions( return ( @@ -735,7 +738,7 @@ type NavButtonRedirectProps = { } function NavRedirectButton(props: NavButtonRedirectProps): JSX.Element { - const [selected, setSelected] = useState(false); + const [selected, setSelected] = useState(false) const router = useRouter() useEffect(() => { @@ -932,7 +935,7 @@ function LabelButton(props: LabelButtonProps): JSX.Element { const checkboxRef = useRef(null) const state = useMemo(() => { const term = props.searchTerm ?? '' - if (term.indexOf(`label:\"${props.label.name}\"`) >= 0) { + if (term.indexOf(`label:\"${escapeQuotes(props.label.name)}\"`) >= 0) { return 'on' } return 'off' @@ -982,7 +985,7 @@ function LabelButton(props: LabelButtonProps): JSX.Element { props.applySearchQuery(query.trim()) } else { props.applySearchQuery( - `${query.trim()} label:\"${props.label.name}\"` + `${query.trim()} label:\"${escapeQuotes(props.label.name)}\"` ) } }} @@ -1001,14 +1004,15 @@ function LabelButton(props: LabelButtonProps): JSX.Element { type="checkbox" checked={state === 'on'} onChange={(e) => { + const escapedLabelName = escapeQuotes(props.label.name) if (e.target.checked) { props.applySearchQuery( - `${props.searchTerm ?? ''} label:\"${props.label.name}\"` + `${props.searchTerm ?? ''} label:\"${escapedLabelName}\"` ) } else { const query = props.searchTerm?.replace( - `label:\"${props.label.name}\"`, + `label:\"${escapedLabelName}\"`, '' ) ?? '' props.applySearchQuery(query) diff --git a/packages/web/pages/settings/pinned-searches.tsx b/packages/web/pages/settings/pinned-searches.tsx index b06f8cce9..784062d14 100644 --- a/packages/web/pages/settings/pinned-searches.tsx +++ b/packages/web/pages/settings/pinned-searches.tsx @@ -21,6 +21,7 @@ import { Label } from '../../lib/networking/fragments/labelFragment' import { CheckSquare, Circle, Square } from 'phosphor-react' import { SavedSearch } from '../../lib/networking/fragments/savedSearchFragment' import { usePersistedState } from '../../lib/hooks/usePersistedState' +import { escapeQuotes } from '../../utils/helper' export type PinnedSearch = { type: 'saved-search' | 'label' @@ -282,7 +283,7 @@ function LabelButton(props: LabelButtonProps): JSX.Element { type: 'label', itemId: props.label.id, name: props.label.name, - search: `label:\"${props.label.name}\"`, + search: `label:\"${escapeQuotes(props.label.name)}\"`, }} listAction={props.listAction} > diff --git a/packages/web/pages/settings/shortcuts.tsx b/packages/web/pages/settings/shortcuts.tsx index 1ee7d39f0..f9fddfec2 100644 --- a/packages/web/pages/settings/shortcuts.tsx +++ b/packages/web/pages/settings/shortcuts.tsx @@ -34,7 +34,7 @@ import { CheckSquare, Square } from 'phosphor-react' import { Button } from '../../components/elements/Button' import { styled } from '@stitches/react' import { SavedSearch } from '../../lib/networking/fragments/savedSearchFragment' - +import { escapeQuotes } from '../../utils/helper' type ListAction = 'RESET' | 'ADD_ITEM' | 'REMOVE_ITEM' const SHORTCUTS_KEY = 'library-shortcuts' @@ -365,7 +365,7 @@ const AvailableItems = (props: ListProps): JSX.Element => { type: 'label', label: label, name: label.name, - filter: `label:\"${label.name}\"`, + filter: `label:\"${escapeQuotes(label.name)}\"`, } props.dispatchList({ item, @@ -416,7 +416,7 @@ const AvailableItems = (props: ListProps): JSX.Element => { : 'feed', filter: subscription.type == SubscriptionType.NEWSLETTER - ? `subscription:\"${subscription.name}\"` + ? `subscription:\"${escapeQuotes(subscription.name)}\"` : `rss:\"${subscription.url}\"`, } props.dispatchList({ diff --git a/packages/web/utils/helper.ts b/packages/web/utils/helper.ts new file mode 100644 index 000000000..831148eed --- /dev/null +++ b/packages/web/utils/helper.ts @@ -0,0 +1 @@ +export const escapeQuotes = (str: string) => str.replace(/"/g, '\\"')