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, '\\"')