From 0fa9adfb1e69ffa042a24ebe1436ed1e1f2146ca Mon Sep 17 00:00:00 2001 From: Zach Leahan <54847203+ZacharyLeahan@users.noreply.github.com> Date: Tue, 27 May 2025 13:07:24 -0400 Subject: [PATCH] Link query rules to filters --- lib/queryrules.ini | 28 +++++++++ lib/server.js | 45 +++++++++++++ src/components/Explorer.vue | 122 ++++++++++++++++++++++++++++++++---- 3 files changed, 184 insertions(+), 11 deletions(-) diff --git a/lib/queryrules.ini b/lib/queryrules.ini index 4c52a79..dc76ee7 100644 --- a/lib/queryrules.ini +++ b/lib/queryrules.ini @@ -16,16 +16,19 @@ Query = { "States": { "$in": ["PA", "NJ", "DE", "MD", "NY", "WV", "VA", "OH"] } Chip = Perennial Keywords = Perennial Query = { "Life Cycle Flags": { "$in": ["Perennial"] } } +Filters = { "Life Cycle Flags": ["Perennial"] } [duration_Annual] Chip = Annual Keywords = Annual Query = { "Life Cycle Flags": { "$in": ["Annual"] } } +Filters = { "Life Cycle Flags": ["Annual"] } [duration_Biennial] Chip = Biennial Keywords = Biennial Query = { "Life Cycle Flags": { "$in": ["Biennial"] } } +Filters = { "Life Cycle Flags": ["Biennial"] } [MonarchsCheck] Chip = Supports Monarchs @@ -36,16 +39,19 @@ Query = { "Pollinator Flags": { "$in": ["Monarch Butterfly"] } } Chip = Part Shade Keywords = partial, Part Shade, Partial Shade, Filtered Light, Partial Sun, Part Sun Query = { "Sun Exposure Flags": { "$in": ["Part Shade"] } } +Filters = { "Sun Exposure Flags": ["Part Shade"] } [SunExposure_FullSun] Chip = Full Sun Keywords = Full Sun, All day sun, sun Query = { "Sun Exposure Flags": { "$in": ["Full Sun"] } } +Filters = { "Sun Exposure Flags": ["Full Sun"] } [SunExposure_Shade] Chip = Shade Keywords = Full Shade, Deep Shade, Shade, Shady location, Shade Tolerant Query = { "Sun Exposure Flags": { "$in": ["Shade"] } } +Filters = { "Sun Exposure Flags": ["Shade"] } [FallBlooming] Chip = Fall Blooming @@ -61,16 +67,19 @@ Query = { "Flowering Months": { "$regex": "mar|apr|may", "$options": "i" } } Chip = Wet Soil Keywords = Wet, Rain Garden, Puddles, Wet Feet, Wet Soil, Wet Dirt Query = { "Soil Moisture Flags": { "$in": ["Wet"] } } +Filters = { "Soil Moisture Flags": ["Wet"] } [SoilMoisture_Dry] Chip = Dry Soil Keywords = Dry, Cracks in Soil, Dry Soil, Dry Dirt Query = { "Soil Moisture Flags": { "$in": ["Dry"] } } +Filters = { "Soil Moisture Flags": ["Dry"] } [SoilMoisture_Moist] Chip = Moist Soil Keywords = Moist, Slightly Moist, Moist Soil, Slightly Wet Query = { "Soil Moisture Flags": { "$in": ["Moist"] } } +Filters = { "Soil Moisture Flags": ["Moist"] } [DroughtTolerance] Chip = Drought Tolerant @@ -81,96 +90,115 @@ Query = { "Plant Type Flags": { "$in": ["Drought Tolerant"] } } Chip = Evergreen Plants Keywords = Evergreen, Evergreen Trees, Trees that are evergreen Query = { "Plant Type Flags": { "$in": ["Evergreen"] } } +Filters = { "Plant Type Flags": ["Evergreen"] } [Tree] Chip = Tree Keywords = Tree Query = { "Plant Type Flags": { "$in": ["Tree"] } } +Filters = { "Plant Type Flags": ["Tree"] } [Shrub] Chip = Shrub Keywords = Shrub, Bush Query = { "Plant Type Flags": { "$in": ["Shrub"] } } +Filters = { "Plant Type Flags": ["Shrub"] } [Vine] Chip = Vine Keywords = Vine, Climbing, Climber Query = { "Plant Type Flags": { "$in": ["Vine"] } } +Filters = { "Plant Type Flags": ["Vine"] } [Fern] Chip = Fern Keywords = Fern Query = { "Plant Type Flags": { "$in": ["Fern"] } } +Filters = { "Plant Type Flags": ["Fern"] } [Grass] Chip = Grass Keywords = Grass, Grasses, Sedge, Sedges, Rush, Rushes Query = { "Plant Type Flags": { "$in": ["Grass", "Sedge", "Rush"] } } +Filters = { "Plant Type Flags": ["Grass", "Sedge", "Rush"] } [Hummingbirds] Chip = Attracts Hummingbirds Keywords = Hummingbird, Hummingbirds, Hummingbird-attracting Query = { "Pollinator Flags": { "$in": ["Hummingbirds"] } } +Filters = { "Pollinator Flags": ["Hummingbirds"] } [Butterflies] Chip = Attracts Butterflies Keywords = Butterfly, Butterflies Query = { "Pollinator Flags": { "$in": ["Butterflies"] } } +Filters = { "Pollinator Flags": ["Butterflies"] } [Bees] Chip = Attracts Bees Keywords = Bee, Bees, Bumblebee, Bumblebees, Honey Bee Query = { "Pollinator Flags": { "$in": ["Bees"] } } +Filters = { "Pollinator Flags": ["Bees"] } [RedFlower] Chip = Red Flowers Keywords = Red, Red Flower, Red Flowers, Red Blooms Query = { "Flower Color Flags": { "$in": ["Red"] } } +Filters = { "Flower Color Flags": ["Red"] } [BlueFlower] Chip = Blue Flowers Keywords = Blue, Blue Flower, Blue Flowers, Blue Blooms Query = { "Flower Color Flags": { "$in": ["Blue"] } } +Filters = { "Flower Color Flags": ["Blue"] } [YellowFlower] Chip = Yellow Flowers Keywords = Yellow, Yellow Flower, Yellow Flowers, Yellow Blooms Query = { "Flower Color Flags": { "$in": ["Yellow"] } } +Filters = { "Flower Color Flags": ["Yellow"] } [PurpleFlower] Chip = Purple Flowers Keywords = Purple, Purple Flower, Purple Flowers, Purple Blooms, Violet Query = { "Flower Color Flags": { "$in": ["Purple"] } } +Filters = { "Flower Color Flags": ["Purple"] } [WhiteFlower] Chip = White Flowers Keywords = White, White Flower, White Flowers, White Blooms Query = { "Flower Color Flags": { "$in": ["White"] } } +Filters = { "Flower Color Flags": ["White"] } [PinkFlower] Chip = Pink Flowers Keywords = Pink, Pink Flower, Pink Flowers, Pink Blooms Query = { "Flower Color Flags": { "$in": ["Pink"] } } +Filters = { "Flower Color Flags": ["Pink"] } [OrangeFlower] Chip = Orange Flowers Keywords = Orange, Orange Flower, Orange Flowers, Orange Blooms Query = { "Flower Color Flags": { "$in": ["Orange"] } } +Filters = { "Flower Color Flags": ["Orange"] } [GreenFlower] Chip = Green Flowers Keywords = Green, Green Flower, Green Flowers, Green Blooms Query = { "Flower Color Flags": { "$in": ["Green"] } } +Filters = { "Flower Color Flags": ["Green"] } [Superplant] Chip = Super Plant Keywords = Super, Super Plant, Superplant Query = { "Superplant": true } +Filters = { "Superplant": ["Super Plant"] } [Showy] Chip = Showy Keywords = Showy, Showy Flowers, Showy Blooms Query = { "Showy": true } +Filters = { "Showy": ["Showy"] } [FallColor] Chip = Fall Color diff --git a/lib/server.js b/lib/server.js index 5cd040f..be4b0f1 100644 --- a/lib/server.js +++ b/lib/server.js @@ -12,6 +12,28 @@ 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), + filters: rule.Filters ? JSON.parse(rule.Filters) : undefined, + }; + } 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 +81,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 +479,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..944eb77 100644 --- a/src/components/Explorer.vue +++ b/src/components/Explorer.vue @@ -862,6 +862,11 @@ export default { total: 0, q: "", activeSearch: "", + queryRules: {}, + appliedRules: [], + ruleChips: [], + ruleFilterValues: {}, + applyingRules: false, sort: "Sort by Recommendation Score", filters, componentKey: 0, // Add a key for forcing re-renders @@ -913,7 +918,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); @@ -990,6 +1006,9 @@ export default { }, filterValues: { async handler() { + if (this.applyingRules) { + return; + } if (this.isDesktop()) { this.submit(); } else { @@ -1024,6 +1043,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 +1204,59 @@ 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; + }, + applyRuleFilters(names) { + this.applyingRules = true; + // Remove previous rule-applied filter values + for (const [filterName, values] of Object.entries(this.ruleFilterValues)) { + const def = this.filters.find((f) => f.name === filterName); + if (!def) continue; + if (def.array) { + this.filterValues[filterName] = this.filterValues[filterName].filter( + (v) => !values.includes(v) + ); + } else { + this.filterValues[filterName] = def.default; + } + } + this.ruleFilterValues = {}; + // Apply filters from current rules + for (const name of names) { + const rule = this.queryRules[name]; + if (!rule || !rule.filters) continue; + for (const [filterName, val] of Object.entries(rule.filters)) { + const def = this.filters.find((f) => f.name === filterName); + if (!def) continue; + const values = Array.isArray(val) ? val : [val]; + if (def.array) { + for (const v of values) { + if (!this.filterValues[filterName].includes(v)) { + this.filterValues[filterName].push(v); + } + } + } else { + this.filterValues[filterName] = values[0]; + } + if (!this.ruleFilterValues[filterName]) this.ruleFilterValues[filterName] = []; + this.ruleFilterValues[filterName].push(...values); + } + } + this.applyingRules = false; + }, async getVendors() { if (!this.selected) return []; const data = { @@ -1330,6 +1411,13 @@ export default { clearTimeout(this.submitTimeout); this.submitTimeout = null; } + const detected = this.detectRules(); + if (detected.length) { + this.appliedRules = detected; + } else { + this.appliedRules = []; + } + this.applyRuleFilters(this.appliedRules); this.submitTimeout = setTimeout(submit.bind(this), 50); // Reduced timeout for faster response function submit() { @@ -1370,19 +1458,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 +1504,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 +1572,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 +1591,7 @@ export default { this.filterValues[filter.name] = filter.default; } this.q = ""; + this.appliedRules = []; this.submit(); }, toggleSort() {