diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 9bcf3a80..e7ed173f 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -1253,8 +1253,8 @@ "description": "Text for button to apply the selected action" }, "bulkActionsTooltip": { - "message": "Click to open the filter, search and bulk actions panel", - "description": "Text for button to apply the selected action" + "message": "Bulk actions can be applied to selected styles in this column", + "description": "Select style for bulk action header tooltip" }, "bulkActionsError": { "message": "Choose at least one style", diff --git a/background/search-db.js b/background/search-db.js index 75318304..4f9c9a2d 100644 --- a/background/search-db.js +++ b/background/search-db.js @@ -4,13 +4,108 @@ (() => { // toLocaleLowerCase cache, autocleared after 1 minute const cache = new Map(); - // top-level style properties to be searched - const PARTS = { - name: searchText, - url: searchText, - sourceCode: searchText, - sections: searchSections, - }; + + // Creates an array of intermediate words (2 letter minimum) + // 'usercss' => ["us", "use", "user", "userc", "usercs", "usercss"] + // this makes it so the user can type partial queries and not have the search + // constantly switching between using & ignoring the filter + const createPartials = id => id.split('').reduce((acc, _, index) => { + if (index > 0) { + acc.push(id.substring(0, index + 1)); + } + return acc; + }, []); + + const searchWithin = [{ + id: 'code', + labels: createPartials('code'), + get: style => style.sections.map(section => section.code).join(' ') + }, { + id: 'usercss', + labels: [...createPartials('usercss'), ...createPartials('meta')], + get: style => JSON.stringify(style.usercssData || {}) + // remove JSON structure; restore urls + .replace(/[[\]{},":]/g, ' ').replace(/\s\/\//g, '://') + }, { + id: 'name', // default + labels: createPartials('name'), + get: style => style.name + }]; + + const styleProps = [{ + id: 'enabled', + labels: ['on', ...createPartials('enabled')], + check: style => style.enabled + }, { + id: 'disabled', + labels: ['off', ...createPartials('disabled')], + check: style => !style.enabled + }, { + id: 'local', + labels: createPartials('local'), + check: style => !style.updateUrl + }, { + id: 'external', + labels: createPartials('external'), + check: style => style.updateUrl + }, { + id: 'usercss', + labels: createPartials('usercss'), + check: style => style.usercssData + }, { + id: 'non usercss', + labels: ['original', ...createPartials('nonusercss')], + check: style => !style.usercssData + }]; + + const matchers = [{ + id: 'url', + test: query => /url:\w+/i.test(query), + matches: query => { + const matchUrl = query.match(/url:([/.-_\w]+)/); + const result = matchUrl && matchUrl[1] + ? styleManager.getStylesByUrl(matchUrl[1]) + .then(result => result.map(r => r.data.id)) + : []; + return {result}; + }, + }, { + id: 'regex', + test: query => { + const x = query.includes('/') && !query.includes('//') && + /^\/(.+?)\/([gimsuy]*)$/.test(query); + // console.log('regex match?', query, x); + return x; + }, + matches: () => ({regex: tryRegExp(RegExp.$1, RegExp.$2)}) + }, { + id: 'props', + test: query => /is:/.test(query), + matches: query => { + const label = /is:(\w+)/g.exec(query); + return label && label[1] + ? {prop: styleProps.find(p => p.labels.includes(label[1]))} + : {}; + } + }, { + id: 'within', + test: query => /in:/.test(query), + matches: query => { + const label = /in:(\w+)/g.exec(query); + return label && label[1] + ? {within: searchWithin.find(s => s.labels.includes(label[1]))} + : {}; + } + }, { + id: 'default', + test: () => true, + matches: query => { + const word = query.startsWith('"') && query.endsWith('"') + ? query.slice(1, -1) + : query; + return {word: word || query}; + } + }]; /** * @param params @@ -19,76 +114,93 @@ * @returns {number[]} - array of matched styles ids */ API_METHODS.searchDB = ({query, ids}) => { - let rx, words, icase, matchUrl; - query = query.trim(); + const parts = query.trim().split(/(".*?")|\s+/).filter(Boolean); - if (/^url:/i.test(query)) { - matchUrl = query.slice(query.indexOf(':') + 1).trim(); - if (matchUrl) { - return styleManager.getStylesByUrl(matchUrl) - .then(results => results.map(r => r.data.id)); + const searchFilters = { + words: [], + regex: null, // only last regex expression is used + results: [], + props: [], + within: [], + }; + + const searchText = (text, searchFilters) => { + if (searchFilters.regex) return searchFilters.regex.test(text); + for (let pass = 1; pass <= (searchFilters.icase ? 2 : 1); pass++) { + if (searchFilters.words.every(w => text.includes(w))) return true; + text = lower(text); } + }; + + const searchProps = (style, searchFilters) => { + const x = searchFilters.props.every(prop => { + const y = prop.check(style) + // if (y) console.log('found prop', prop.id, style.id) + return y; + }); + // if (x) console.log('found prop', style.id) + return x; + }; + + parts.forEach(part => { + matchers.some(matcher => { + if (matcher.test(part)) { + const {result, regex, word, prop, within} = matcher.matches(part || ''); + if (result) searchFilters.results.push(result); + if (regex) searchFilters.regex = regex; // limited to a single regexp + if (word) searchFilters.words.push(word); + if (prop) searchFilters.props.push(prop); + if (within) searchFilters.within.push(within); + return true; + } + }); + }); + if (!searchFilters.within.length) { + searchFilters.within.push(...searchWithin.slice(-1)); } - if (query.startsWith('/') && /^\/(.+?)\/([gimsuy]*)$/.test(query)) { - rx = tryRegExp(RegExp.$1, RegExp.$2); - } - if (!rx) { - words = query - .split(/(".*?")|\s+/) - .filter(Boolean) - .map(w => w.startsWith('"') && w.endsWith('"') - ? w.slice(1, -1) - : w) - .filter(w => w.length > 1); - words = words.length ? words : [query]; - icase = words.some(w => w === lower(w)); + + // console.log('matchers', searchFilters); + // url matches + if (searchFilters.results.length) { + return searchFilters.results; } + searchFilters.icase = searchFilters.words.some(w => w === lower(w)); + query = parts.join(' ').trim(); return styleManager.getAllStyles().then(styles => { if (ids) { const idSet = new Set(ids); styles = styles.filter(s => idSet.has(s.id)); } + const results = []; + const propResults = []; + const hasProps = searchFilters.props.length > 0; + const noWords = searchFilters.words.length === 0; for (const style of styles) { const id = style.id; - if (!query || words && !words.length) { + if (noWords) { + // no query or only filters are matching -> show all styles results.push(id); - continue; - } - for (const part in PARTS) { - const text = style[part]; - if (text && PARTS[part](text, rx, words, icase)) { + } else { + const text = searchFilters.within.map(within => within.get(style)).join(' '); + if (searchText(text, searchFilters)) { results.push(id); - break; } } - } - if (cache.size) debounce(clearCache, 60e3); - return results; - }); - }; - - function searchText(text, rx, words, icase) { - if (rx) return rx.test(text); - for (let pass = 1; pass <= (icase ? 2 : 1); pass++) { - if (words.every(w => text.includes(w))) return true; - text = lower(text); - } - } - - function searchSections(sections, rx, words, icase) { - for (const section of sections) { - for (const prop in section) { - const value = section[prop]; - if (typeof value === 'string') { - if (searchText(value, rx, words, icase)) return true; - } else if (Array.isArray(value)) { - if (value.some(str => searchText(str, rx, words, icase))) return true; + if (hasProps && searchProps(style, searchFilters) && results.includes(id)) { + propResults.push(id); } } - } - } + // results AND propResults + const finalResults = hasProps + ? propResults.filter(id => results.includes(id)) + : results; + if (cache.size) debounce(clearCache, 60e3); + // console.log('final', finalResults) + return finalResults; + }); + }; function lower(text) { let result = cache.get(text); diff --git a/manage.html b/manage.html index 9dcb964d..a977d808 100644 --- a/manage.html +++ b/manage.html @@ -221,10 +221,7 @@ - + @@ -256,12 +253,6 @@ - - - -