@@ -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 * (?: p l a n t | c o m m o n n a m e | s c i e n t i f i c n a m e ) \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