diff --git a/packages/models/src/Domain/Runtime/Display/DisplayOptions.spec.ts b/packages/models/src/Domain/Runtime/Display/DisplayOptions.spec.ts index 2b6d79e119e..22f67491c32 100644 --- a/packages/models/src/Domain/Runtime/Display/DisplayOptions.spec.ts +++ b/packages/models/src/Domain/Runtime/Display/DisplayOptions.spec.ts @@ -50,4 +50,36 @@ describe('item display options', () => { const collection = collectionWithNotes(['hello', 'foobar'], ['foo', 'fobar']) expect(notesAndFilesMatchingOptions(options, collection.all() as SNNote[], collection)).toHaveLength(2) }) + + it('multi-word query matches when all words present', () => { + const options: NotesAndFilesDisplayOptions = { + searchQuery: { query: 'meeting notes', includeProtectedNoteText: true }, + } as jest.Mocked + const collection = collectionWithNotes(['Meeting Notes Monday', 'Notes', 'Meeting Agenda', 'notes from meeting']) + expect(notesAndFilesMatchingOptions(options, collection.all() as SNNote[], collection)).toHaveLength(2) + }) + + it('quoted query matches exact phrase only', () => { + const options: NotesAndFilesDisplayOptions = { + searchQuery: { query: '"foo bar"', includeProtectedNoteText: true }, + } as jest.Mocked + const collection = collectionWithNotes(['foo bar', 'bar foo', 'foo baz bar', 'foo bar baz']) + expect(notesAndFilesMatchingOptions(options, collection.all() as SNNote[], collection)).toHaveLength(2) + }) + + it('case insensitive matching', () => { + const options: NotesAndFilesDisplayOptions = { + searchQuery: { query: 'HELLO', includeProtectedNoteText: true }, + } as jest.Mocked + const collection = collectionWithNotes(['hello world', 'Hello', 'goodbye']) + expect(notesAndFilesMatchingOptions(options, collection.all() as SNNote[], collection)).toHaveLength(2) + }) + + it('empty query returns all items', () => { + const options: NotesAndFilesDisplayOptions = { + searchQuery: { query: '', includeProtectedNoteText: true }, + } as jest.Mocked + const collection = collectionWithNotes(['one', 'two', 'three']) + expect(notesAndFilesMatchingOptions(options, collection.all() as SNNote[], collection)).toHaveLength(3) + }) }) diff --git a/packages/models/src/Domain/Runtime/Display/DisplayOptionsToFilters.ts b/packages/models/src/Domain/Runtime/Display/DisplayOptionsToFilters.ts index e8c3da9f07b..6222394150b 100644 --- a/packages/models/src/Domain/Runtime/Display/DisplayOptionsToFilters.ts +++ b/packages/models/src/Domain/Runtime/Display/DisplayOptionsToFilters.ts @@ -2,7 +2,7 @@ import { DecryptedItem } from '../../Abstract/Item' import { SNTag } from '../../Syncable/Tag' import { CompoundPredicate } from '../Predicate/CompoundPredicate' import { ItemWithTags } from './Search/ItemWithTags' -import { itemMatchesQuery, itemPassesFilters } from './Search/SearchUtilities' +import { itemMatchesQueryPrepared, itemPassesFilters, prepareSearchQuery } from './Search/SearchUtilities' import { ItemFilter, ReferenceLookupCollection, SearchableDecryptedItem } from './Search/Types' import { NotesAndFilesDisplayOptions } from './DisplayOptions' import { SystemViewId } from '../../Syncable/SmartView' @@ -73,7 +73,8 @@ export function computeFiltersForDisplayOptions( if (options.searchQuery) { const query = options.searchQuery - filters.push((item) => itemMatchesQuery(item, query, collection)) + const prepared = prepareSearchQuery(query.query) + filters.push((item) => itemMatchesQueryPrepared(item, query, prepared, collection)) } if ( diff --git a/packages/models/src/Domain/Runtime/Display/Search/SearchUtilities.spec.ts b/packages/models/src/Domain/Runtime/Display/Search/SearchUtilities.spec.ts new file mode 100644 index 00000000000..d5eab1aefb3 --- /dev/null +++ b/packages/models/src/Domain/Runtime/Display/Search/SearchUtilities.spec.ts @@ -0,0 +1,154 @@ +import { prepareSearchQuery, itemMatchesQuery, itemMatchesQueryPrepared } from './SearchUtilities' +import { createNoteWithContent } from '../../../Utilities/Test/SpecUtils' +import { ItemCollection } from '../../Collection/Item/ItemCollection' +import { SNNote } from '../../../Syncable/Note/Note' +import { SearchQuery } from './Types' + +describe('prepareSearchQuery', () => { + it('lowercases the query', () => { + const prepared = prepareSearchQuery('Hello World') + expect(prepared.lowercase).toBe('hello world') + }) + + it('splits into words', () => { + const prepared = prepareSearchQuery('foo bar baz') + expect(prepared.words).toEqual(['foo', 'bar', 'baz']) + }) + + it('extracts quoted text', () => { + const prepared = prepareSearchQuery('"exact phrase"') + expect(prepared.quotedText).toBe('exact phrase') + }) + + it('returns null for quotedText when no quotes', () => { + const prepared = prepareSearchQuery('no quotes here') + expect(prepared.quotedText).toBeNull() + }) + + it('detects uuid strings', () => { + const prepared = prepareSearchQuery('a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11') + expect(prepared.isUuid).toBe(true) + }) + + it('does not flag non-uuid as uuid', () => { + const prepared = prepareSearchQuery('not-a-uuid') + expect(prepared.isUuid).toBe(false) + }) + + it('preserves raw string', () => { + const prepared = prepareSearchQuery('FooBar') + expect(prepared.raw).toBe('FooBar') + }) + + it('handles empty string', () => { + const prepared = prepareSearchQuery('') + expect(prepared.raw).toBe('') + expect(prepared.words).toEqual(['']) + expect(prepared.quotedText).toBeNull() + expect(prepared.isUuid).toBe(false) + }) +}) + +describe('itemMatchesQuery', () => { + const makeCollection = (notes: SNNote[]) => { + const collection = new ItemCollection() + collection.set(notes) + return collection + } + + it('matches title with multi-word query', () => { + const note = createNoteWithContent({ title: 'Meeting Notes for Monday', text: '' }) + const collection = makeCollection([note]) + const query: SearchQuery = { query: 'meeting monday', includeProtectedNoteText: true } + + expect(itemMatchesQuery(note, query, collection)).toBe(true) + }) + + it('does not match when only some words are present', () => { + const note = createNoteWithContent({ title: 'Meeting Notes', text: '' }) + const collection = makeCollection([note]) + const query: SearchQuery = { query: 'meeting friday', includeProtectedNoteText: true } + + expect(itemMatchesQuery(note, query, collection)).toBe(false) + }) + + it('matches body text', () => { + const note = createNoteWithContent({ title: '', text: 'some important content here' }) + const collection = makeCollection([note]) + const query: SearchQuery = { query: 'important', includeProtectedNoteText: true } + + expect(itemMatchesQuery(note, query, collection)).toBe(true) + }) + + it('matches quoted exact phrase', () => { + const note = createNoteWithContent({ title: 'hello world foo', text: '' }) + const collection = makeCollection([note]) + const query: SearchQuery = { query: '"world foo"', includeProtectedNoteText: true } + + expect(itemMatchesQuery(note, query, collection)).toBe(true) + }) + + it('does not match quoted phrase when words are not adjacent', () => { + const note = createNoteWithContent({ title: 'world bar foo', text: '' }) + const collection = makeCollection([note]) + const query: SearchQuery = { query: '"world foo"', includeProtectedNoteText: true } + + expect(itemMatchesQuery(note, query, collection)).toBe(false) + }) + + it('empty query matches everything', () => { + const note = createNoteWithContent({ title: 'anything', text: '' }) + const collection = makeCollection([note]) + const query: SearchQuery = { query: '', includeProtectedNoteText: true } + + expect(itemMatchesQuery(note, query, collection)).toBe(true) + }) + + it('is case insensitive', () => { + const note = createNoteWithContent({ title: 'UPPERCASE TITLE', text: '' }) + const collection = makeCollection([note]) + const query: SearchQuery = { query: 'uppercase', includeProtectedNoteText: true } + + expect(itemMatchesQuery(note, query, collection)).toBe(true) + }) +}) + +describe('itemMatchesQueryPrepared', () => { + const makeCollection = (notes: SNNote[]) => { + const collection = new ItemCollection() + collection.set(notes) + return collection + } + + // belt and suspenders - make sure prepared gives identical results + it('produces same results as itemMatchesQuery', () => { + const notes = [ + createNoteWithContent({ title: 'Meeting Notes', text: 'discuss budget' }), + createNoteWithContent({ title: 'Grocery List', text: 'milk eggs bread' }), + createNoteWithContent({ title: 'Random', text: 'meeting prep' }), + ] + const collection = makeCollection(notes) + const query: SearchQuery = { query: 'meeting', includeProtectedNoteText: true } + const prepared = prepareSearchQuery(query.query) + + for (const note of notes) { + expect(itemMatchesQueryPrepared(note, query, prepared, collection)).toBe( + itemMatchesQuery(note, query, collection), + ) + } + }) + + it('reuses prepared query across multiple items', () => { + const notes = [ + createNoteWithContent({ title: 'alpha', text: '' }), + createNoteWithContent({ title: 'beta', text: '' }), + createNoteWithContent({ title: 'alpha beta', text: '' }), + ] + const collection = makeCollection(notes) + const query: SearchQuery = { query: 'alpha', includeProtectedNoteText: true } + const prepared = prepareSearchQuery(query.query) + + const results = notes.filter((n) => itemMatchesQueryPrepared(n, query, prepared, collection)) + expect(results).toHaveLength(2) + }) +}) diff --git a/packages/models/src/Domain/Runtime/Display/Search/SearchUtilities.ts b/packages/models/src/Domain/Runtime/Display/Search/SearchUtilities.ts index 0f265ec8758..71949c0ca19 100644 --- a/packages/models/src/Domain/Runtime/Display/Search/SearchUtilities.ts +++ b/packages/models/src/Domain/Runtime/Display/Search/SearchUtilities.ts @@ -33,68 +33,108 @@ export function itemPassesFilters(item: SearchableDecryptedItem, filters: ItemFi return true } +/** do the expensive stuff once, not 10000 times */ +export interface PreparedQuery { + raw: string + lowercase: string + words: string[] + quotedText: string | null + isUuid: boolean +} + +export function prepareSearchQuery(searchString: string): PreparedQuery { + const lowercase = searchString.toLowerCase() + return { + raw: searchString, + lowercase, + words: lowercase.split(' '), + quotedText: stringBetweenQuotes(lowercase), + isUuid: stringIsUuid(lowercase), + } +} + export function itemMatchesQuery( itemToMatch: SearchableDecryptedItem, searchQuery: SearchQuery, collection: ReferenceLookupCollection, ): boolean { + const prepared = prepareSearchQuery(searchQuery.query) 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) + itemTags.some((tag) => matchResultForPreparedQuery(tag, prepared) !== MatchResult.None) if (itemToMatch.protected && !searchQuery.includeProtectedNoteText) { - const match = matchResultForStringQuery(itemToMatch, searchQuery.query) + const match = matchResultForPreparedQuery(itemToMatch, prepared) return match === MatchResult.Title || match === MatchResult.TitleAndText || someTagsMatches } - return matchResultForStringQuery(itemToMatch, searchQuery.query) !== MatchResult.None || someTagsMatches + return matchResultForPreparedQuery(itemToMatch, prepared) !== MatchResult.None || someTagsMatches } -function matchResultForStringQuery(item: SearchableItem, searchString: string): MatchResult { - if (searchString.length === 0) { +export function itemMatchesQueryPrepared( + itemToMatch: SearchableDecryptedItem, + searchQuery: SearchQuery, + prepared: PreparedQuery, + collection: ReferenceLookupCollection, +): boolean { + const shouldCheckForSomeTagMatches = searchQuery.shouldCheckForSomeTagMatches ?? true + const itemTags = collection.elementsReferencingElement(itemToMatch, ContentType.TYPES.Tag) as SNTag[] + const someTagsMatches = + shouldCheckForSomeTagMatches && + itemTags.some((tag) => matchResultForPreparedQuery(tag, prepared) !== MatchResult.None) + + if (itemToMatch.protected && !searchQuery.includeProtectedNoteText) { + const match = matchResultForPreparedQuery(itemToMatch, prepared) + return match === MatchResult.Title || match === MatchResult.TitleAndText || someTagsMatches + } + + return matchResultForPreparedQuery(itemToMatch, prepared) !== MatchResult.None || someTagsMatches +} + +function matchResultForPreparedQuery(item: SearchableItem, query: PreparedQuery): MatchResult { + if (query.raw.length === 0) { return MatchResult.TitleAndText } const title = item.title?.toLowerCase() const text = item.text?.toLowerCase() - const lowercaseText = searchString.toLowerCase() - const words = lowercaseText.split(' ') - const quotedText = stringBetweenQuotes(lowercaseText) - if (quotedText) { + if (query.quotedText) { return ( - (title?.includes(quotedText) ? MatchResult.Title : MatchResult.None) + - (text?.includes(quotedText) ? MatchResult.Text : MatchResult.None) + (title?.includes(query.quotedText) ? MatchResult.Title : MatchResult.None) + + (text?.includes(query.quotedText) ? MatchResult.Text : MatchResult.None) ) } - if (stringIsUuid(lowercaseText)) { - return item.uuid === lowercaseText ? MatchResult.Uuid : MatchResult.None + if (query.isUuid) { + return item.uuid === query.lowercase ? MatchResult.Uuid : MatchResult.None } const matchesTitle = title && - words.every((word) => { + query.words.every((word) => { return title.indexOf(word) >= 0 }) const matchesBody = text && - words.every((word) => { + query.words.every((word) => { return text.indexOf(word) >= 0 }) return (matchesTitle ? MatchResult.Title : 0) + (matchesBody ? MatchResult.Text : 0) } +const QUOTED_STRING_RE = /"(.*?)"/ +const UUID_RE = /\b[0-9a-f]{8}\b-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-\b[0-9a-f]{12}\b/ + function stringBetweenQuotes(text: string) { - const matches = text.match(/"(.*?)"/) + const matches = text.match(QUOTED_STRING_RE) return matches ? matches[1] : null } function stringIsUuid(text: string) { - const matches = text.match(/\b[0-9a-f]{8}\b-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-\b[0-9a-f]{12}\b/) - return matches ? true : false + return UUID_RE.test(text) } diff --git a/packages/web/src/javascripts/Utils/Items/Search/doesItemMatchSearchQuery.ts b/packages/web/src/javascripts/Utils/Items/Search/doesItemMatchSearchQuery.ts index 83eeb6e3cb4..84381bd68bc 100644 --- a/packages/web/src/javascripts/Utils/Items/Search/doesItemMatchSearchQuery.ts +++ b/packages/web/src/javascripts/Utils/Items/Search/doesItemMatchSearchQuery.ts @@ -23,11 +23,11 @@ export function doesItemMatchSearchQuery( item: DecryptedItemInterface, searchQuery: string, application: WebApplicationInterface, + lowercaseQuery?: string, ) { const title = getItemSearchableString(item, application).toLowerCase() - const matchesQuery = title.includes(searchQuery.toLowerCase()) + const matchesQuery = title.includes(lowercaseQuery ?? searchQuery.toLowerCase()) const isArchivedOrTrashed = item.archived || item.trashed - const isValidSearchResult = matchesQuery && !isArchivedOrTrashed - return isValidSearchResult + return matchesQuery && !isArchivedOrTrashed } diff --git a/packages/web/src/javascripts/Utils/Items/Search/getSearchResults.ts b/packages/web/src/javascripts/Utils/Items/Search/getSearchResults.ts index c847fd5f19d..9effba0d788 100644 --- a/packages/web/src/javascripts/Utils/Items/Search/getSearchResults.ts +++ b/packages/web/src/javascripts/Utils/Items/Search/getSearchResults.ts @@ -48,14 +48,14 @@ export function getLinkingSearchResults( return defaultReturnValue } - const searchableItems = naturalSort( - application.items.getItems([ContentType.TYPES.Note, ContentType.TYPES.File, ContentType.TYPES.Tag]), - 'title', - ) + const searchableItems = application.items.getItems([ContentType.TYPES.Note, ContentType.TYPES.File, ContentType.TYPES.Tag]) + const lowercaseQuery = searchQuery.toLowerCase() const unlinkedTags: LinkableItem[] = [] const unlinkedNotes: LinkableItem[] = [] const unlinkedFiles: LinkableItem[] = [] + const enforceResultLimit = options.contentType == null + const limitPerContentType = resultLimitForSearchQuery(searchQuery) for (let index = 0; index < searchableItems.length; index++) { const item = searchableItems[index] @@ -68,7 +68,7 @@ export function getLinkingSearchResults( continue } - if (searchQuery.length && !doesItemMatchSearchQuery(item, searchQuery, application)) { + if (searchQuery.length && !doesItemMatchSearchQuery(item, searchQuery, application, lowercaseQuery)) { continue } @@ -80,10 +80,6 @@ export function getLinkingSearchResults( continue } - const enforceResultLimit = options.contentType == null - - const limitPerContentType = resultLimitForSearchQuery(searchQuery) - if ( item.content_type === ContentType.TYPES.Tag && (!enforceResultLimit || @@ -108,9 +104,20 @@ export function getLinkingSearchResults( unlinkedFiles.push(item) continue } + + // at capacity, don't be greedy + if ( + enforceResultLimit && + unlinkedTags.length >= limitPerContentType && + unlinkedNotes.length >= limitPerContentType && + unlinkedFiles.length >= limitPerContentType && + linkedResults.length >= MaxLinkedResults + ) { + break + } } - unlinkedItems = [...unlinkedTags, ...unlinkedNotes, ...unlinkedFiles] + unlinkedItems = naturalSort([...unlinkedTags, ...unlinkedNotes, ...unlinkedFiles], 'title') shouldShowCreateTag = !linkedResults.find((link) => isSearchResultExistingTag(link.item, searchQuery)) &&