diff --git a/src/renderer/components/sidebar/FilterInput.js b/src/renderer/components/sidebar/FilterInput.js index a8ecf793..5bc95c60 100644 --- a/src/renderer/components/sidebar/FilterInput.js +++ b/src/renderer/components/sidebar/FilterInput.js @@ -48,10 +48,14 @@ export const FilterInput = props => { } const handleTagClick = tag => { - const tagFilter = `#${tag}` - const newFilter = search.filter - ? `${search.filter} ${tagFilter}` - : tagFilter + const tagFilter = `#${tag.toUpperCase()}` + const tokens = search.filter.split(' ').filter(Boolean) + const tagIndex = tokens.findIndex(t => t.toUpperCase() === tagFilter) + + const newFilter = tagIndex >= 0 + ? tokens.filter((_, i) => i !== tagIndex).join(' ') + : search.filter ? `${search.filter} ${tagFilter}` : tagFilter + setCursor(null) setSearch({ ...search, filter: newFilter, force: true }) } diff --git a/src/renderer/store/MiniSearch.js b/src/renderer/store/MiniSearch.js index 9be22bb8..c9b845d2 100644 --- a/src/renderer/store/MiniSearch.js +++ b/src/renderer/store/MiniSearch.js @@ -40,8 +40,8 @@ export const parseQuery = (terms, ids = []) => { // Remove hyphens to match the extractField transformation for scope const scopeValue = token.substring(1).replace(/-/g, '') scopeValue.length > 1 && acc.scope.push(scopeValue) - } else if (token.startsWith('-#')) token.length > 3 && acc.excludeTags.push(token.substring(2).toLowerCase()) - else if (token.startsWith('#')) token.length > 2 && acc.tags.push(token.substring(1)) + } else if (token.startsWith('-#')) token.length > 2 && acc.excludeTags.push(token.substring(2)) + else if (token.startsWith('#')) token.length > 1 && acc.tags.push(token.substring(1)) else if (token.startsWith('!')) token.length > 2 && acc.ids.push(token.substring(1)) else if (token.startsWith('&')) { /* ignore */ } else if (token) acc.text.push(token) return acc @@ -57,7 +57,7 @@ export const parseQuery = (terms, ids = []) => { add('scope', 'OR') add('text', 'AND', true) - add('tags', 'AND', true) + add('tags', 'AND') const filter = parts.ids.length ? result => parts.ids.some(id => result.id.startsWith(id)) diff --git a/src/renderer/store/SearchIndex.js b/src/renderer/store/SearchIndex.js index 95343c19..45367d12 100644 --- a/src/renderer/store/SearchIndex.js +++ b/src/renderer/store/SearchIndex.js @@ -234,8 +234,8 @@ SearchIndex.prototype.search = async function (terms, options) { ? matches.filter(match => { const doc = this.cachedDocuments[match.id] if (!doc || !doc.tags) return true - const docTags = doc.tags.filter(Boolean).map(t => t.toLowerCase()) - return !excludeTags.some(tag => docTags.includes(tag)) + const docTags = doc.tags.filter(Boolean).map(t => t.toUpperCase()) + return !excludeTags.some(tag => docTags.includes(tag.toUpperCase())) }) : matches diff --git a/test/renderer/store/MiniSearch-test.js b/test/renderer/store/MiniSearch-test.js index 8efd717b..6106090f 100644 --- a/test/renderer/store/MiniSearch-test.js +++ b/test/renderer/store/MiniSearch-test.js @@ -54,5 +54,33 @@ describe('MiniSearch', function () { const actual = index.search(query) assert.strictEqual(actual.length, _3OSC.docs.length - 1) // minus one installation }) + + it('tag search uses exact matching, not prefix matching', function () { + const index = createIndex() + index.addAll([ + { id: 'feature:1', text: 'First feature', tags: ['A'] }, + { id: 'feature:2', text: 'Second feature', tags: ['AA'] }, + { id: 'feature:3', text: 'Third feature', tags: ['AAA'] } + ]) + + const searchTag = (tag) => { + const [query] = parseQuery(tag) + return index.search(query).map(({ id }) => id).sort() + } + + // Each tag search should only return exact matches + assert.deepStrictEqual(searchTag('#A'), ['feature:1']) + assert.deepStrictEqual(searchTag('#AA'), ['feature:2']) + assert.deepStrictEqual(searchTag('#AAA'), ['feature:3']) + }) + + it('single-letter exclude tags are parsed correctly', function () { + // Verify parseQuery handles single-letter exclude tags + const [, options] = parseQuery('-#A') + assert.deepStrictEqual(options.excludeTags, ['A']) + + const [, options2] = parseQuery('-#B -#C') + assert.deepStrictEqual(options2.excludeTags, ['B', 'C']) + }) }) })