diff --git a/packages/models/src/Domain/Runtime/Display/DisplayOptions.spec.ts b/packages/models/src/Domain/Runtime/Display/DisplayOptions.spec.ts index 2b6d79e119e..493a7f4fc10 100644 --- a/packages/models/src/Domain/Runtime/Display/DisplayOptions.spec.ts +++ b/packages/models/src/Domain/Runtime/Display/DisplayOptions.spec.ts @@ -20,7 +20,7 @@ describe('item display options', () => { return collection } - it('string query title', () => { + it('matches notes whose title contains the search query', () => { const query = 'foo' const options: NotesAndFilesDisplayOptions = { @@ -30,7 +30,7 @@ describe('item display options', () => { expect(notesAndFilesMatchingOptions(options, collection.all() as SNNote[], collection)).toHaveLength(2) }) - it('string query text', async function () { + it('matches notes whose body contains the search query', () => { const query = 'foo' const options: NotesAndFilesDisplayOptions = { searchQuery: { query: query, includeProtectedNoteText: true }, @@ -42,7 +42,7 @@ describe('item display options', () => { expect(notesAndFilesMatchingOptions(options, collection.all() as SNNote[], collection)).toHaveLength(2) }) - it('string query title and text', async function () { + it('matches notes when the search query appears in either title or body', () => { const query = 'foo' const options: NotesAndFilesDisplayOptions = { searchQuery: { query: query, includeProtectedNoteText: true }, @@ -50,4 +50,33 @@ describe('item display options', () => { const collection = collectionWithNotes(['hello', 'foobar'], ['foo', 'fobar']) expect(notesAndFilesMatchingOptions(options, collection.all() as SNNote[], collection)).toHaveLength(2) }) + + describe('title-only search', () => { + it('matches notes when the query appears only in the title', () => { + const query = 'foo' + const options: NotesAndFilesDisplayOptions = { + searchQuery: { query, includeProtectedNoteText: true, noteTitleOnly: true }, + } as jest.Mocked + const collection = collectionWithNotes(['foo', 'hello', 'foobar'], ['bar', 'baz', 'qux']) + expect(notesAndFilesMatchingOptions(options, collection.all() as SNNote[], collection)).toHaveLength(2) + }) + + it('does not match notes when the query appears only in the body', () => { + const query = 'foo' + const options: NotesAndFilesDisplayOptions = { + searchQuery: { query, includeProtectedNoteText: true, noteTitleOnly: true }, + } as jest.Mocked + const collection = collectionWithNotes(['hello', 'world'], ['foo', 'foobar']) + expect(notesAndFilesMatchingOptions(options, collection.all() as SNNote[], collection)).toHaveLength(0) + }) + + it('matches notes with a matching title even when the query also appears in the body', () => { + const query = 'foo' + const options: NotesAndFilesDisplayOptions = { + searchQuery: { query, includeProtectedNoteText: true, noteTitleOnly: true }, + } as jest.Mocked + const collection = collectionWithNotes(['foo', 'hello', 'foobar'], ['bar', 'foo', 'foo']) + expect(notesAndFilesMatchingOptions(options, collection.all() as SNNote[], collection)).toHaveLength(2) + }) + }) }) diff --git a/packages/models/src/Domain/Runtime/Display/DisplayOptionsToFilters.ts b/packages/models/src/Domain/Runtime/Display/DisplayOptionsToFilters.ts index e8c3da9f07b..4dfb6d5c451 100644 --- a/packages/models/src/Domain/Runtime/Display/DisplayOptionsToFilters.ts +++ b/packages/models/src/Domain/Runtime/Display/DisplayOptionsToFilters.ts @@ -55,6 +55,16 @@ export function computeFiltersForDisplayOptions( } } + if (options.searchQuery) { + const query = options.searchQuery + filters.push((item) => itemMatchesQuery(item, query, collection)) + + if (query.tagFilters && query.tagFilters.length > 0) { + const tagFilters = query.tagFilters + filters.push((item) => tagFilters.some((tag) => tag.isReferencingItem(item))) + } + } + if (options.includePinned === false && !viewsPredicate?.keypathIncludesString('pinned')) { filters.push((item) => !item.pinned) } @@ -71,11 +81,6 @@ export function computeFiltersForDisplayOptions( filters.push((item) => !item.archived) } - if (options.searchQuery) { - const query = options.searchQuery - filters.push((item) => itemMatchesQuery(item, query, collection)) - } - if ( !viewsPredicate?.keypathIncludesString('conflict_of') && !options.views?.some((v) => v.uuid === SystemViewId.TrashedNotes) diff --git a/packages/models/src/Domain/Runtime/Display/Search/SearchUtilities.ts b/packages/models/src/Domain/Runtime/Display/Search/SearchUtilities.ts index 0f265ec8758..a23cc695adf 100644 --- a/packages/models/src/Domain/Runtime/Display/Search/SearchUtilities.ts +++ b/packages/models/src/Domain/Runtime/Display/Search/SearchUtilities.ts @@ -38,18 +38,33 @@ export function itemMatchesQuery( searchQuery: SearchQuery, collection: ReferenceLookupCollection, ): boolean { - const shouldCheckForSomeTagMatches = searchQuery.shouldCheckForSomeTagMatches ?? true - const itemTags = collection.elementsReferencingElement(itemToMatch, ContentType.TYPES.Tag) as SNTag[] - const someTagsMatches = - shouldCheckForSomeTagMatches && - itemTags.some((tag) => matchResultForStringQuery(tag, searchQuery.query) !== MatchResult.None) - - if (itemToMatch.protected && !searchQuery.includeProtectedNoteText) { - const match = matchResultForStringQuery(itemToMatch, searchQuery.query) - return match === MatchResult.Title || match === MatchResult.TitleAndText || someTagsMatches + const { query, includeProtectedNoteText, shouldCheckForSomeTagMatches = true, noteTitleOnly = false } = searchQuery + + if (query.length === 0) { + return true + } + + const itemMatch = matchResultForStringQuery(itemToMatch, query) + + if (noteTitleOnly) { + return itemMatch === MatchResult.Title || itemMatch === MatchResult.TitleAndText + } + + if (shouldCheckForSomeTagMatches) { + const itemTags = collection.elementsReferencingElement(itemToMatch, ContentType.TYPES.Tag) as SNTag[] + const tagMatches = itemTags.map((tag) => matchResultForStringQuery(tag, query)) + const someTagsMatches = tagMatches.some((match) => match !== MatchResult.None) + + if (someTagsMatches) { + return true + } + } + + if (itemToMatch.protected && !includeProtectedNoteText) { + return itemMatch === MatchResult.Title || itemMatch === MatchResult.TitleAndText } - return matchResultForStringQuery(itemToMatch, searchQuery.query) !== MatchResult.None || someTagsMatches + return itemMatch !== MatchResult.None } function matchResultForStringQuery(item: SearchableItem, searchString: string): MatchResult { diff --git a/packages/models/src/Domain/Runtime/Display/Search/Types.ts b/packages/models/src/Domain/Runtime/Display/Search/Types.ts index e574abcaae3..2e8bed72804 100644 --- a/packages/models/src/Domain/Runtime/Display/Search/Types.ts +++ b/packages/models/src/Domain/Runtime/Display/Search/Types.ts @@ -1,11 +1,14 @@ import { ItemCollection } from './../../Collection/Item/ItemCollection' import { DecryptedItemInterface } from '../../../Abstract/Item' +import { SNTag } from '../../../Syncable/Tag' import { SearchableItem } from './SearchableItem' export type SearchQuery = { query: string includeProtectedNoteText: boolean shouldCheckForSomeTagMatches?: boolean + noteTitleOnly?: boolean + tagFilters?: SNTag[] } export interface ReferenceLookupCollection { diff --git a/packages/snjs/lib/Services/Items/ItemManager.ts b/packages/snjs/lib/Services/Items/ItemManager.ts index 9ad4204f2eb..72430c9e529 100644 --- a/packages/snjs/lib/Services/Items/ItemManager.ts +++ b/packages/snjs/lib/Services/Items/ItemManager.ts @@ -170,6 +170,17 @@ export class ItemManager extends Services.AbstractService implements Services.It }) .filter((tag) => tag != undefined) + const mostRecentVersionOfSearchQuery = options.searchQuery + ? { + ...options.searchQuery, + tagFilters: options.searchQuery.tagFilters + ?.map((tag) => { + return this.collection.find(tag.uuid) as Models.SNTag + }) + .filter((tag) => tag != undefined), + } + : undefined + const mostRecentVersionOfViews = options.views ?.map((view) => { if (Models.isSystemView(view)) { @@ -184,6 +195,7 @@ export class ItemManager extends Services.AbstractService implements Services.It ...override, ...{ tags: mostRecentVersionOfTags, + searchQuery: mostRecentVersionOfSearchQuery, views: mostRecentVersionOfViews, hiddenContentTypes: [ContentType.TYPES.Tag], }, diff --git a/packages/web/src/javascripts/Components/Checkbox/Checkbox.tsx b/packages/web/src/javascripts/Components/Checkbox/Checkbox.tsx index e2ace706a64..daa7cbb1d83 100644 --- a/packages/web/src/javascripts/Components/Checkbox/Checkbox.tsx +++ b/packages/web/src/javascripts/Components/Checkbox/Checkbox.tsx @@ -1,3 +1,4 @@ +import { classNames } from '@standardnotes/snjs' import { ChangeEventHandler, FunctionComponent } from 'react' type CheckboxProps = { @@ -10,9 +11,15 @@ type CheckboxProps = { const Checkbox: FunctionComponent = ({ name, checked, onChange, disabled, label }) => { return ( -