From 948335bc2a1d459252d678789012596756222662 Mon Sep 17 00:00:00 2001 From: Thomas Halwax Date: Mon, 2 Feb 2026 10:57:23 +0100 Subject: [PATCH 1/5] exact match for tag search allow one letter tags --- src/renderer/store/MiniSearch.js | 6 +++--- test/renderer/store/MiniSearch-test.js | 28 ++++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/src/renderer/store/MiniSearch.js b/src/renderer/store/MiniSearch.js index 9be22bb8..a345f7a8 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).toLowerCase()) + 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/test/renderer/store/MiniSearch-test.js b/test/renderer/store/MiniSearch-test.js index 8efd717b..0b9630b1 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']) + }) }) }) From 207eae96ec68ed952d757fa7dcf8d22dce32d18a Mon Sep 17 00:00:00 2001 From: Thomas Halwax Date: Mon, 2 Feb 2026 11:01:13 +0100 Subject: [PATCH 2/5] consistent usage of casing --- src/renderer/store/MiniSearch.js | 2 +- src/renderer/store/SearchIndex.js | 2 +- test/renderer/store/MiniSearch-test.js | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/renderer/store/MiniSearch.js b/src/renderer/store/MiniSearch.js index a345f7a8..c9b845d2 100644 --- a/src/renderer/store/MiniSearch.js +++ b/src/renderer/store/MiniSearch.js @@ -40,7 +40,7 @@ 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 > 2 && acc.excludeTags.push(token.substring(2).toLowerCase()) + } 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) diff --git a/src/renderer/store/SearchIndex.js b/src/renderer/store/SearchIndex.js index 95343c19..20ceb152 100644 --- a/src/renderer/store/SearchIndex.js +++ b/src/renderer/store/SearchIndex.js @@ -235,7 +235,7 @@ SearchIndex.prototype.search = async function (terms, options) { 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)) + return !excludeTags.some(tag => docTags.includes(tag.toLowerCase())) }) : matches diff --git a/test/renderer/store/MiniSearch-test.js b/test/renderer/store/MiniSearch-test.js index 0b9630b1..6106090f 100644 --- a/test/renderer/store/MiniSearch-test.js +++ b/test/renderer/store/MiniSearch-test.js @@ -77,10 +77,10 @@ describe('MiniSearch', function () { 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']) + assert.deepStrictEqual(options.excludeTags, ['A']) const [, options2] = parseQuery('-#B -#C') - assert.deepStrictEqual(options2.excludeTags, ['b', 'c']) + assert.deepStrictEqual(options2.excludeTags, ['B', 'C']) }) }) }) From e386bb5182b4406cb43a716d89281eb7b189fee7 Mon Sep 17 00:00:00 2001 From: Thomas Halwax Date: Mon, 2 Feb 2026 11:14:41 +0100 Subject: [PATCH 3/5] tagList entries works like toggle buttons --- src/renderer/components/sidebar/FilterInput.js | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/renderer/components/sidebar/FilterInput.js b/src/renderer/components/sidebar/FilterInput.js index a8ecf793..8d247837 100644 --- a/src/renderer/components/sidebar/FilterInput.js +++ b/src/renderer/components/sidebar/FilterInput.js @@ -49,9 +49,13 @@ export const FilterInput = props => { const handleTagClick = tag => { const tagFilter = `#${tag}` - const newFilter = search.filter - ? `${search.filter} ${tagFilter}` - : tagFilter + const tokens = search.filter.split(' ').filter(Boolean) + const tagIndex = tokens.findIndex(t => t.toLowerCase() === tagFilter.toLowerCase()) + + const newFilter = tagIndex >= 0 + ? tokens.filter((_, i) => i !== tagIndex).join(' ') + : search.filter ? `${search.filter} ${tagFilter}` : tagFilter + setCursor(null) setSearch({ ...search, filter: newFilter, force: true }) } From 0b037a3e9362d34a2597855eb4c92220ab28c138 Mon Sep 17 00:00:00 2001 From: Thomas Halwax Date: Mon, 2 Feb 2026 11:21:40 +0100 Subject: [PATCH 4/5] inserting tags in upper case --- src/renderer/store/SearchIndex.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/renderer/store/SearchIndex.js b/src/renderer/store/SearchIndex.js index 20ceb152..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.toLowerCase())) + const docTags = doc.tags.filter(Boolean).map(t => t.toUpperCase()) + return !excludeTags.some(tag => docTags.includes(tag.toUpperCase())) }) : matches From 5344116abfb5e3150cc349ebe3d3a35a31917f06 Mon Sep 17 00:00:00 2001 From: Thomas Halwax Date: Mon, 2 Feb 2026 11:22:58 +0100 Subject: [PATCH 5/5] consistently using the same casing --- src/renderer/components/sidebar/FilterInput.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/renderer/components/sidebar/FilterInput.js b/src/renderer/components/sidebar/FilterInput.js index 8d247837..5bc95c60 100644 --- a/src/renderer/components/sidebar/FilterInput.js +++ b/src/renderer/components/sidebar/FilterInput.js @@ -48,9 +48,9 @@ export const FilterInput = props => { } const handleTagClick = tag => { - const tagFilter = `#${tag}` + const tagFilter = `#${tag.toUpperCase()}` const tokens = search.filter.split(' ').filter(Boolean) - const tagIndex = tokens.findIndex(t => t.toLowerCase() === tagFilter.toLowerCase()) + const tagIndex = tokens.findIndex(t => t.toUpperCase() === tagFilter) const newFilter = tagIndex >= 0 ? tokens.filter((_, i) => i !== tagIndex).join(' ')