diff --git a/src/renderer/components/sidebar/FilterInput.css b/src/renderer/components/sidebar/FilterInput.css index 21f6f95..9613de6 100644 --- a/src/renderer/components/sidebar/FilterInput.css +++ b/src/renderer/components/sidebar/FilterInput.css @@ -1,11 +1,54 @@ .fe6e-filter-container { + display: flex; + align-items: center; border: 0.07rem solid var(--color-border); border-radius: 4px; box-shadow: rgba(99, 99, 99, 0.2) 0px 2px 8px 0px; - padding: 8px 0 8px 8px; + padding: 8px; } .fe6e-filter { font-size: 100%; - width: 100%; + flex: 1; +} + +.fe6e-filter-clear { + display: flex; + align-items: center; + cursor: pointer; + color: grey; + margin-left: 4px; +} + +.fe6e-filter-clear:hover { + color: black; +} + +.fe6e-taglist { + display: flex; + flex-wrap: wrap; + gap: 4px; + margin-top: 6px; + padding: 8px; + border: 0.07rem solid var(--color-border); + border-radius: 4px; + box-shadow: rgba(99, 99, 99, 0.2) 0px 2px 8px 0px; + max-height: 7em; + overflow-y: auto; +} + +.fe6e-taglist-tag { + font-size: 0.75em; + padding: 2px 6px; + border-radius: 2px; + border: 1px solid var(--color-secondary-accent); + white-space: nowrap; + cursor: pointer; + text-transform: uppercase; +} + +.fe6e-taglist-tag:hover { + background-color: var(--color-tag-hover); + border-color: var(--color-tag-hover); + color: black; } \ No newline at end of file diff --git a/src/renderer/components/sidebar/FilterInput.js b/src/renderer/components/sidebar/FilterInput.js index 3581db3..a8ecf79 100644 --- a/src/renderer/components/sidebar/FilterInput.js +++ b/src/renderer/components/sidebar/FilterInput.js @@ -1,6 +1,8 @@ /* eslint-disable react/prop-types */ import React from 'react' -import { useMemento } from '../hooks' +import Icon from '@mdi/react' +import { mdiClose } from '@mdi/js' +import { useServices, useMemento } from '../hooks' import { defaultSearch } from './state' import { matcher, stopPropagation } from '../events' import { cmdOrCtrl } from '../../platform' @@ -12,8 +14,10 @@ import './FilterInput.css' * */ export const FilterInput = props => { + const { searchIndex } = useServices() const [search, setSearch] = useMemento('ui.sidebar.search', defaultSearch) const [cursor, setCursor] = React.useState(null) + const [userTags, setUserTags] = React.useState([]) const ref = React.useRef() React.useEffect(() => { @@ -25,12 +29,39 @@ export const FilterInput = props => { if (input) input.setSelectionRange(position, position) }, [ref, cursor, search.filter]) + // Fetch user tags and listen for index updates + React.useEffect(() => { + const fetchTags = async () => { + const tags = await searchIndex.userTags() + setUserTags(tags) + } + + fetchTags() + searchIndex.on('index/updated', fetchTags) + return () => searchIndex.off('index/updated', fetchTags) + }, [searchIndex]) + const handleChange = event => { const { target } = event setCursor(target.selectionStart) setSearch({ ...search, filter: target.value }) } + const handleTagClick = tag => { + const tagFilter = `#${tag}` + const newFilter = search.filter + ? `${search.filter} ${tagFilter}` + : tagFilter + setCursor(null) + setSearch({ ...search, filter: newFilter, force: true }) + } + + const handleClear = event => { + event.stopPropagation() + setCursor(null) + setSearch({ ...search, filter: '' }) + ref.current?.focus() + } const handleKeyDown = event => { matcher([ @@ -59,18 +90,38 @@ export const FilterInput = props => { } return ( -
- -
+ <> +
+ + {search.filter && ( + + + + )} +
+ {userTags.length > 0 && ( +
+ {userTags.map(tag => ( + handleTagClick(tag)} + > + {tag.toUpperCase()} + + ))} +
+ )} + ) } diff --git a/src/renderer/store/MiniSearch.js b/src/renderer/store/MiniSearch.js index cda3fbe..9be22bb 100644 --- a/src/renderer/store/MiniSearch.js +++ b/src/renderer/store/MiniSearch.js @@ -40,11 +40,12 @@ 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.tags.push(token.substring(1)) + } 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.ids.push(token.substring(1)) else if (token.startsWith('&')) { /* ignore */ } else if (token) acc.text.push(token) return acc - }, { scope: [], text: [], tags: [], ids }) + }, { scope: [], text: [], tags: [], excludeTags: [], ids }) const query = { combineWith: 'AND', queries: [] } @@ -58,11 +59,15 @@ export const parseQuery = (terms, ids = []) => { add('text', 'AND', true) add('tags', 'AND', true) - const filter = parts.ids && parts.ids.length + const filter = parts.ids.length ? result => parts.ids.some(id => result.id.startsWith(id)) : null - return filter - ? [query, { filter }] + const options = {} + if (filter) options.filter = filter + if (parts.excludeTags.length) options.excludeTags = parts.excludeTags + + return Object.keys(options).length + ? [query, options] : [query] } diff --git a/src/renderer/store/SearchIndex.js b/src/renderer/store/SearchIndex.js index f96e39f..95343c1 100644 --- a/src/renderer/store/SearchIndex.js +++ b/src/renderer/store/SearchIndex.js @@ -201,7 +201,7 @@ SearchIndex.prototype.searchField = function (field, tokens) { SearchIndex.prototype.search = async function (terms, options) { if (terms.includes('@place') && options.force) { const query = terms - .replace(/([@#!&]\S+)/gi, '') + .replace(/([@#!&-]\S+)/gi, '') .trim() .replace(/[ ]+/g, '+') @@ -221,11 +221,25 @@ SearchIndex.prototype.search = async function (terms, options) { )(terms) const [query, searchOptions] = parseQuery(terms, ids) - const matches = searchOptions - ? this.index.search(query, searchOptions) + + // Only pass filter option to MiniSearch, handle excludeTags separately + const miniSearchOptions = searchOptions?.filter ? { filter: searchOptions.filter } : null + const matches = miniSearchOptions + ? this.index.search(query, miniSearchOptions) : this.index.search(query) - const keys = matches.map(R.prop('id')) + // Filter out excluded tags using cached documents + const excludeTags = searchOptions?.excludeTags || [] + const filteredMatches = excludeTags.length + ? 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)) + }) + : matches + + const keys = filteredMatches.map(R.prop('id')) const option = id => { @@ -267,3 +281,14 @@ SearchIndex.prototype.createQuery = function (terms, callback, options) { disposable.on(this.sessionStore, 'put', refresh) return disposable } + + +/** + * Get all unique user-defined tags from the database. + * Returns sorted array of tag names. + */ +SearchIndex.prototype.userTags = async function () { + const tagArrays = await L.values(this.jsonDB, ID.TAGS_PREFIX) + const tags = tagArrays.flatMap(arr => Array.isArray(arr) ? arr : []) + return [...new Set(tags)].sort() +}