Skip to content

Commit 1d7bdda

Browse files
Merge pull request #189 from CodeForPhilly/feat/zbl-day6-updates
Feat/zbl day6 updates
2 parents 662cea6 + 55128b3 commit 1d7bdda

13 files changed

Lines changed: 2393 additions & 383 deletions

lib/server.js

Lines changed: 206 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,202 @@ module.exports = async function ({ plants, nurseries }) {
147147

148148
app.use(express.json());
149149

150+
// Resolve a pasted block of text into plant IDs by exact match on:
151+
// - _id
152+
// - Common Name
153+
// - Scientific Name
154+
//
155+
// Notes:
156+
// - Matching is case-insensitive.
157+
// - Input may contain extra text; we split into chunks and also extract binomial
158+
// scientific names (e.g. "Acer rubrum") from longer lines.
159+
app.post("/api/v1/plants/resolve-names", async (req, res) => {
160+
try {
161+
const text = typeof req.body?.text === "string" ? req.body.text : "";
162+
if (!text.trim()) {
163+
return res.status(400).json({ error: "text is required" });
164+
}
165+
166+
const MAX_TOKENS = 1000;
167+
const HEADER_TOKENS = new Set(
168+
[
169+
"common name",
170+
"scientific name",
171+
"bloom",
172+
"bloom time",
173+
"sun",
174+
"soil",
175+
"life",
176+
"life cycle",
177+
"form",
178+
"height",
179+
"spread",
180+
"notes",
181+
"additional details",
182+
].map((s) => s.toLowerCase())
183+
);
184+
185+
const normalize = (s) =>
186+
String(s || "")
187+
.trim()
188+
.replace(/\s+/g, " ")
189+
.replace(/^[*\-\u2022]+/g, "")
190+
.replace(/^\(?\s*(?:plant|common name|scientific name)\s*[:\-]\s*/i, "")
191+
.replace(/^["']|["']$/g, "")
192+
.trim()
193+
.replace(/\s+/g, " ");
194+
195+
// Split into chunks by common separators.
196+
const chunks = text
197+
.split(/[\n\r\t,;]+/g)
198+
.map(normalize)
199+
.filter((s) => s.length > 0);
200+
201+
// Extract scientific binomials from longer chunks (helps when extra text surrounds names).
202+
/** @type {string[]} */
203+
const extractedBinomials = [];
204+
for (const chunk of chunks) {
205+
// Match "Genus species" (species can include hyphen).
206+
const re = /\b([A-Z][a-z]+)\s+([a-z][a-z-]{1,})\b/g;
207+
let m;
208+
while ((m = re.exec(chunk)) !== null) {
209+
extractedBinomials.push(`${m[1]} ${m[2]}`);
210+
}
211+
}
212+
213+
// De-dupe while preserving original casing for UI, but match on normalized form.
214+
const normalizedToOriginal = new Map();
215+
for (const raw of [...chunks, ...extractedBinomials]) {
216+
if (normalizedToOriginal.size >= MAX_TOKENS) break;
217+
const n = normalize(raw).toLowerCase();
218+
if (!n) continue;
219+
if (HEADER_TOKENS.has(n)) continue;
220+
if (!normalizedToOriginal.has(n)) {
221+
normalizedToOriginal.set(n, normalize(raw));
222+
}
223+
}
224+
225+
const tokens = Array.from(normalizedToOriginal.values());
226+
const tokensLower = Array.from(normalizedToOriginal.keys());
227+
228+
if (!tokens.length) {
229+
return res.json({
230+
tokensUsed: 0,
231+
matchedIds: [],
232+
matched: [],
233+
notFound: [],
234+
});
235+
}
236+
237+
// Query candidates in one go.
238+
const docs = await plants
239+
.find(
240+
{
241+
$or: [
242+
{ _id: { $in: tokens } },
243+
{ "Scientific Name": { $in: tokens } },
244+
{ "Common Name": { $in: tokens } },
245+
],
246+
},
247+
{
248+
projection: { _id: 1, "Common Name": 1, "Scientific Name": 1 },
249+
}
250+
)
251+
.collation({ locale: "en", strength: 2 })
252+
.toArray();
253+
254+
// Build lookup maps (normalized -> docs).
255+
const byId = new Map();
256+
const bySci = new Map();
257+
const byCommon = new Map();
258+
259+
const pushMap = (map, key, doc) => {
260+
if (!key) return;
261+
const k = normalize(key).toLowerCase();
262+
if (!k) return;
263+
const arr = map.get(k) || [];
264+
arr.push(doc);
265+
map.set(k, arr);
266+
};
267+
268+
for (const d of docs) {
269+
pushMap(byId, d._id, d);
270+
pushMap(bySci, d["Scientific Name"], d);
271+
pushMap(byCommon, d["Common Name"], d);
272+
}
273+
274+
/** @type {Set<string>} */
275+
const matchedIdsSet = new Set();
276+
/** @type {{_id: string, commonName: string, scientificName: string}[]} */
277+
const matched = [];
278+
/** @type {string[]} */
279+
const notFound = [];
280+
/** @type {Record<string, string[]>} */
281+
const ambiguous = {};
282+
283+
const chooseDeterministic = (arr) => {
284+
if (!arr || !arr.length) return null;
285+
// Stable deterministic selection: lowest _id lexicographically
286+
const sorted = [...arr].sort((a, b) => String(a._id).localeCompare(String(b._id)));
287+
return sorted[0];
288+
};
289+
290+
for (let i = 0; i < tokensLower.length; i++) {
291+
const tokenKey = tokensLower[i];
292+
const tokenDisplay = tokens[i];
293+
294+
const idMatches = byId.get(tokenKey) || [];
295+
const sciMatches = bySci.get(tokenKey) || [];
296+
const commonMatches = byCommon.get(tokenKey) || [];
297+
298+
const candidates = idMatches.length ? idMatches : sciMatches.length ? sciMatches : commonMatches;
299+
if (!candidates || !candidates.length) {
300+
notFound.push(tokenDisplay);
301+
continue;
302+
}
303+
304+
if (candidates.length > 1) {
305+
ambiguous[tokenDisplay] = candidates.map((d) => String(d._id));
306+
}
307+
308+
const chosen = chooseDeterministic(candidates);
309+
if (!chosen) {
310+
notFound.push(tokenDisplay);
311+
continue;
312+
}
313+
314+
const id = String(chosen._id);
315+
if (!matchedIdsSet.has(id)) {
316+
matchedIdsSet.add(id);
317+
matched.push({
318+
_id: id,
319+
commonName: chosen["Common Name"] || "",
320+
scientificName: chosen["Scientific Name"] || "",
321+
});
322+
}
323+
}
324+
325+
const response = {
326+
tokensUsed: tokens.length,
327+
matchedIds: Array.from(matchedIdsSet),
328+
matched,
329+
notFound,
330+
};
331+
332+
if (Object.keys(ambiguous).length) {
333+
response.ambiguous = ambiguous;
334+
}
335+
336+
return res.json(response);
337+
} catch (e) {
338+
console.error("error in /api/v1/plants/resolve-names:", e);
339+
return res.status(500).json({
340+
error: e.message || "Internal server error",
341+
details: process.env.NODE_ENV === "development" ? e.stack : undefined,
342+
});
343+
}
344+
});
345+
150346
app.post("/get-vendors", async (req, res) => {
151347
try {
152348
if (!req.body.zipCode) {
@@ -731,12 +927,18 @@ module.exports = async function ({ plants, nurseries }) {
731927
});
732928
});
733929
}
734-
if (Array.isArray(req.query.favorites)) {
930+
// Favorites can arrive as a string when there's only one (?favorites=ID),
931+
// or as an array when repeated (?favorites=ID1&favorites=ID2).
932+
const favoritesParam = req.query.favorites;
933+
const favoritesList = Array.isArray(favoritesParam)
934+
? favoritesParam
935+
: typeof favoritesParam === "string"
936+
? [favoritesParam]
937+
: [];
938+
if (favoritesList.length) {
735939
$and.push({
736940
_id: {
737-
$in: req.query.favorites.map((v) =>
738-
typeof v == "string" ? v : ""
739-
),
941+
$in: favoritesList.map((v) => (typeof v == "string" ? v : "")).filter(Boolean),
740942
},
741943
});
742944
}

0 commit comments

Comments
 (0)