Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions packages/models/src/Domain/Runtime/Display/DisplayOptions.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<NotesAndFilesDisplayOptions>
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<NotesAndFilesDisplayOptions>
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<NotesAndFilesDisplayOptions>
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<NotesAndFilesDisplayOptions>
const collection = collectionWithNotes(['one', 'two', 'three'])
expect(notesAndFilesMatchingOptions(options, collection.all() as SNNote[], collection)).toHaveLength(3)
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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 (
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,11 @@ export function doesItemMatchSearchQuery(
item: DecryptedItemInterface<ItemContent>,
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
}
Loading