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()
+}