From 892d8e3063b2455a6ef3d82ff2d6a835635d5df1 Mon Sep 17 00:00:00 2001 From: Zach Leahan <54847203+ZacharyLeahan@users.noreply.github.com> Date: Mon, 26 May 2025 20:51:14 -0400 Subject: [PATCH] feat: add keyword rule search --- lib/server.js | 44 +++++++++++++++++++++ src/components/Explorer.vue | 78 +++++++++++++++++++++++++++++++------ 2 files changed, 111 insertions(+), 11 deletions(-) diff --git a/lib/server.js b/lib/server.js index 5cd040f..d39bd44 100644 --- a/lib/server.js +++ b/lib/server.js @@ -12,6 +12,27 @@ const version = fs.existsSync(versionFile) ? fs.readFileSync(versionFile, "utf8") : "0"; const axios = require("axios"); +const ini = require("ini"); + +// Load query rules from queryrules.ini at startup +const queryRulesPath = path.join(__dirname, "queryrules.ini"); +let queryRules = {}; +if (fs.existsSync(queryRulesPath)) { + const parsed = ini.parse(fs.readFileSync(queryRulesPath, "utf8")); + for (const [name, rule] of Object.entries(parsed)) { + try { + queryRules[name] = { + chip: rule.Chip, + keywords: String(rule.Keywords || "") + .split(/,\s*/) + .filter(Boolean), + query: JSON.parse(rule.Query), + }; + } catch (e) { + console.error(`Failed to parse query rule ${name}:`, e); + } + } +} // Disabled for now because it causes confusion when we update the data // const cache = {}; @@ -59,11 +80,31 @@ module.exports = async function ({ plants, nurseries }) { }); return res.send(response.data); }); + + // Provide query rules to the frontend + app.get("/api/v1/queryrules", (req, res) => { + res.json(queryRules); + }); app.get("/api/v1/plants", async (req, res) => { try { const fetchResults = req.query.results !== "0"; const fetchTotal = req.query.total !== "0"; const query = {}; + const ruleChips = []; + const rulesParam = req.query.rules; + if (rulesParam) { + const names = Array.isArray(rulesParam) + ? rulesParam + : String(rulesParam).split(/,/); + for (const name of names) { + const rule = queryRules[name]; + if (rule) { + if (!query.$and) query.$and = []; + query.$and.push(rule.query); + ruleChips.push({ name, chip: rule.chip }); + } + } + } const sorts = { "Sort by Common Name (A-Z)": { "Common Name": 1, @@ -437,6 +478,9 @@ module.exports = async function ({ plants, nurseries }) { ); } } + if (ruleChips.length) { + response.ruleChips = ruleChips; + } // setCache(req, response); return res.send(response); } catch (e) { diff --git a/src/components/Explorer.vue b/src/components/Explorer.vue index da6e025..336dbb1 100644 --- a/src/components/Explorer.vue +++ b/src/components/Explorer.vue @@ -862,6 +862,9 @@ export default { total: 0, q: "", activeSearch: "", + queryRules: {}, + appliedRules: [], + ruleChips: [], sort: "Sort by Recommendation Score", filters, componentKey: 0, // Add a key for forcing re-renders @@ -913,7 +916,18 @@ export default { return extras; }, chips() { - return this.getChips(true); + const chips = this.getChips(true); + if (this.ruleChips.length) { + for (const rc of this.ruleChips) { + chips.push({ + name: rc.name, + label: rc.chip, + key: `rule:${rc.name}`, + svg: 'Search', + }); + } + } + return chips; }, flags() { return this.getChips(false); @@ -1024,6 +1038,15 @@ export default { // Pick a random hero image after hydration to avoid SSR hydration mismatch this.twoUpIndex = Math.floor(Math.random() * twoUpImageCredits.length); + // Fetch query rules used for keyword searches + try { + const resp = await fetch('/api/v1/queryrules'); + this.queryRules = await resp.json(); + } catch (e) { + console.error('Failed to fetch query rules', e); + this.queryRules = {}; + } + this.displayLocation = localStorage.getItem("displayLocation") || ""; this.zipCode = localStorage.getItem("zipCode") || ""; this.manualZip = localStorage.getItem("manualZip") === "true"; @@ -1176,6 +1199,21 @@ export default { this.manualZip = true; localStorage.setItem("manualZip", "true") }, + + detectRules() { + const matches = []; + if (!this.q || !this.queryRules) return matches; + const qLower = this.q.toLowerCase(); + for (const [name, rule] of Object.entries(this.queryRules)) { + for (const kw of rule.keywords) { + if (qLower.includes(kw.toLowerCase())) { + matches.push(name); + break; + } + } + } + return matches; + }, async getVendors() { if (!this.selected) return []; const data = { @@ -1330,6 +1368,12 @@ export default { clearTimeout(this.submitTimeout); this.submitTimeout = null; } + const detected = this.detectRules(); + if (detected.length) { + this.appliedRules = detected; + } else { + this.appliedRules = []; + } this.submitTimeout = setTimeout(submit.bind(this), 50); // Reduced timeout for faster response function submit() { @@ -1370,19 +1414,23 @@ export default { this.updatingCounts = true; const doUpdate = async () => { try { - const params = { - ...this.filterValues, - q: this.q, - sort: this.sort, - }; + const params = { + ...this.filterValues, + ...(this.appliedRules.length ? {} : { q: this.q }), + sort: this.sort, + }; if (this.initializing) { resolve(); return; } + if (this.appliedRules.length) { + params.rules = this.appliedRules; + } const response = await fetch("/api/v1/plants?" + qs.stringify(params)); const data = await response.json(); this.filterCounts = data.counts; - this.activeSearch = this.q; + this.activeSearch = this.appliedRules.length ? "" : this.q; + this.ruleChips = data.ruleChips || []; } finally { this.updatingCounts = false; resolve(); @@ -1412,17 +1460,21 @@ export default { } : { ...this.filterValues, - q: this.q, + ...(this.appliedRules.length ? {} : { q: this.q }), sort: this.sort, page: this.page, }; - this.activeSearch = this.q; + if (this.appliedRules.length) { + params.rules = this.appliedRules; + } + this.activeSearch = this.appliedRules.length ? "" : this.q; if (this.initializing) { // Don't send a bogus query for min 0 max 0 delete params["Height (feet)"]; } const response = await fetch("/api/v1/plants?" + qs.stringify(params)); const data = await response.json(); + this.ruleChips = data.ruleChips || []; if (!this.favorites) { this.filterCounts = data.counts; for (const filter of this.filters) { @@ -1476,12 +1528,15 @@ export default { if (chip.name === "Search") { this.q = ""; } else { + if (chip.key && chip.key.startsWith('rule:')) { + this.appliedRules = this.appliedRules.filter((n) => n !== chip.name); + } const filter = this.filters.find((filter) => filter.name === chip.name); - if (filter.array) { + if (filter && filter.array) { this.filterValues[chip.name] = this.filterValues[chip.name].filter( (value) => value !== chip.label ); - } else { + } else if (filter) { this.filterValues[chip.name] = filter.default; } } @@ -1492,6 +1547,7 @@ export default { this.filterValues[filter.name] = filter.default; } this.q = ""; + this.appliedRules = []; this.submit(); }, toggleSort() {