Skip to content

Commit 1aac38b

Browse files
Merge pull request #186 from CodeForPhilly/feat/zbl-3d-planner
Feat/zbl 3d planner
2 parents 373d381 + 83c616f commit 1aac38b

19 files changed

Lines changed: 3575 additions & 248 deletions

.env.example

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,16 @@
1111
# - Local Mode: Use "localhost" (MongoDB running on your machine)
1212
DB_HOST=mongodb
1313

14+
# DB_PORT: MongoDB port used by *local scripts* (download/massage/dev checks) when running on your host.
15+
# - Local Mode (recommended): 27017
16+
# - If you want to connect from your host to the Docker Mongo container, set:
17+
# DB_HOST=localhost
18+
# DB_PORT=7017 (or whatever you set MONGODB_LOCAL_PORT to)
19+
DB_PORT=27017
20+
21+
# DB_NAME: MongoDB database name (used by local scripts and the server when run outside Docker)
22+
DB_NAME=pa-wildflower-selector
23+
1424
MONGODB_USER=root
1525
MONGODB_PASSWORD=123456
1626
MONGODB_DATABASE=pa-wildflower-selector

download.js

Lines changed: 35 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,12 @@ async function downloadMain() {
131131
clean.Superplant = sp;
132132
clean.Showy = clean.Showy === 'Yes';
133133

134+
// Convert Recommendation Score to an integer and ALWAYS populate.
135+
// DB schema requires this field to exist and be an integer.
136+
const scoreRaw = clean['Recommendation Score'];
137+
const scoreNum = parseInt(scoreRaw, 10);
138+
clean['Recommendation Score'] = Number.isFinite(scoreNum) ? scoreNum : 0;
139+
134140
// Convert height and spread to numbers and ALWAYS populate.
135141
// DB schema requires these fields to exist and be numeric.
136142
const heightNum = parseFloat(clean['Height (feet)']);
@@ -154,8 +160,17 @@ async function downloadMain() {
154160
}
155161
}
156162

163+
// IMPORTANT:
164+
// - `massage.js` / `optimize-plants-db` add computed fields that our DB schema requires
165+
// (e.g. Flags arrays, Flowering Months By Number, States, Genus, Family, etc).
166+
// - If we blindly replace documents using only the CSV fields, we will both:
167+
// (a) fail schema validation, and (b) lose those computed fields.
168+
//
169+
// So when a record already exists, merge it and only overwrite with the latest CSV fields.
170+
const merged = existing ? { ...existing, ...clean } : clean;
171+
157172
// Update the plant in the database
158-
await update(plants, clean);
173+
await update(plants, merged);
159174
}
160175

161176
// Update nurseries and online stores
@@ -225,11 +240,25 @@ async function updateOnlineStores() {
225240
}
226241

227242
async function update(plants, clean) {
228-
await plants.replaceOne({
229-
_id: clean._id
230-
}, clean, {
231-
upsert: true
232-
});
243+
try {
244+
await plants.replaceOne({
245+
_id: clean._id
246+
}, clean, {
247+
upsert: true
248+
});
249+
} catch (e) {
250+
// When MongoDB schema validation fails, expose details (paths/types) to help debug quickly.
251+
// Example: e.errInfo.details.schemaRulesNotSatisfied
252+
if (e && e.code === 121 && e.errInfo && e.errInfo.details) {
253+
console.error('MongoDB schema validation failed for plant:', clean && clean._id);
254+
try {
255+
console.error(JSON.stringify(e.errInfo.details, null, 2));
256+
} catch (jsonErr) {
257+
console.error(e.errInfo.details);
258+
}
259+
}
260+
throw e;
261+
}
233262
}
234263

235264
async function get(url, type = 'text', tries = 1) {

massage.js

Lines changed: 124 additions & 114 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,53 @@ const fs = require('fs');
44
const path = require('path');
55
const { match } = require('assert');
66

7+
function isFiniteNumber(n) {
8+
return typeof n === 'number' && Number.isFinite(n);
9+
}
10+
11+
function toNumberOrZero(v) {
12+
if (isFiniteNumber(v)) return v;
13+
const n = parseFloat(String(v ?? '').trim());
14+
return Number.isFinite(n) ? n : 0;
15+
}
16+
17+
function toIntOrZero(v) {
18+
if (isFiniteNumber(v)) return Math.trunc(v);
19+
const n = parseInt(String(v ?? '').trim(), 10);
20+
return Number.isFinite(n) ? n : 0;
21+
}
22+
23+
function toBool(v) {
24+
if (typeof v === 'boolean') return v;
25+
const s = String(v ?? '').trim().toLowerCase();
26+
if (s === 'yes' || s === 'true' || s === '1') return true;
27+
return false;
28+
}
29+
30+
function splitFlags(v, regex, mapper = capitalize) {
31+
const s = String(v ?? '');
32+
if (!s.trim()) return [];
33+
return s.split(regex).map(mapper).map(x => x.trim()).filter(flag => flag.length > 0);
34+
}
35+
36+
function computeFloweringMonthsByNumber(monthStr) {
37+
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
38+
const s = String(monthStr ?? '').trim();
39+
if (!s) return [];
40+
// Support en dash, em dash, and hyphen ranges (e.g. "May–Jun", "May-Jun", "May—Jun")
41+
const parts = s.split(/[-]/).map(p => p.trim()).filter(Boolean);
42+
if (parts.length === 1) {
43+
const idx = months.indexOf(parts[0]);
44+
return idx > -1 ? [idx] : [];
45+
}
46+
const from = months.indexOf(parts[0]);
47+
const to = months.indexOf(parts[1]);
48+
if (from === -1 || to === -1) return [];
49+
const out = [];
50+
for (let i = from; i <= to; i++) out.push(i);
51+
return out;
52+
}
53+
754
// Work around lack of top level await while still reporting errors properly
855
go().then(() => {
956
// All done
@@ -15,6 +62,25 @@ go().then(() => {
1562

1663
async function go() {
1764
const { plants, close } = await db();
65+
66+
// Helpful when schema validation is enabled: print details so failures are actionable.
67+
const originalUpdateOne = plants.updateOne.bind(plants);
68+
plants.updateOne = async (...args) => {
69+
try {
70+
return await originalUpdateOne(...args);
71+
} catch (e) {
72+
if (e && e.code === 121 && e.errInfo && e.errInfo.details) {
73+
console.error('MongoDB schema validation failed during massage.updateOne');
74+
try {
75+
console.error(JSON.stringify(e.errInfo.details, null, 2));
76+
} catch (jsonErr) {
77+
console.error(e.errInfo.details);
78+
}
79+
}
80+
throw e;
81+
}
82+
};
83+
1884
let values = await plants.find().toArray();
1985

2086
// Fix a small set of plants that already had duplicate records
@@ -33,6 +99,57 @@ async function go() {
3399
for (let i = 0; (i < values.length); i++) {
34100
let plant = values[i];
35101

102+
// Ensure required fields exist and have valid types BEFORE any other updates.
103+
// This allows us to repair legacy/invalid documents even when validation is strict.
104+
const scientificName = plant['Scientific Name'] || plant._id;
105+
const genus = (String(scientificName ?? '').split(/\s+/)[0] || '').trim();
106+
const family = plant['Plant Family'] || plant['Family'] || '';
107+
108+
// Build required flags (empty arrays are acceptable to the schema).
109+
const plantType = plant['Plant Type'] || '';
110+
let plantTypeFlags = [];
111+
let lifeCycleFlags = [];
112+
const matches = String(plantType).match(/^(.*?)(\(.*?\))?$/);
113+
if (matches) {
114+
plantTypeFlags = matches[1].split(/, /);
115+
if (matches[2]) {
116+
plantTypeFlags = [...plantTypeFlags, ...matches[2].split(/ or /)];
117+
}
118+
plantTypeFlags = plantTypeFlags.map(flag => flag.trim().replace('(', '').replace(')', '')).map(capitalize).filter(Boolean);
119+
const lifeCycles = ['Annual', 'Biennial', 'Perennial'];
120+
lifeCycleFlags = plantTypeFlags.filter(pt => lifeCycles.includes(pt));
121+
plantTypeFlags = plantTypeFlags.filter(pt => !lifeCycles.includes(pt));
122+
}
123+
124+
const sunExposureFlags = splitFlags(plant['Sun Exposure'], /,\s*/, capitalize);
125+
const soilMoistureFlags = splitFlags(plant['Soil Moisture'], /,\s*/, capitalize);
126+
const pollinatorFlags = splitFlags(plant['Pollinators'], /\s*(?:,|;)+\s*/, capitalize);
127+
const flowerColorFlags = splitFlags(plant['Flower Color'], /\s*[-,]\s*/, capitalize);
128+
const availabilityFlags = [];
129+
if (String(plant['Online Flag'] ?? '') === '1') availabilityFlags.push('Online');
130+
if (String(plant['Local Flag'] ?? '') === '1') availabilityFlags.push('Local');
131+
132+
const requiredFixes = {
133+
'Height (feet)': toNumberOrZero(plant['Height (feet)']),
134+
'Spread (feet)': toNumberOrZero(plant['Spread (feet)']),
135+
'Recommendation Score': toIntOrZero(plant['Recommendation Score']),
136+
'Showy': toBool(plant['Showy']),
137+
'Superplant': toBool(plant['Superplant']),
138+
'States': splitFlags(plant['Distribution in USA'], /,\s*/, s => String(s).trim()).filter(Boolean),
139+
'Genus': genus,
140+
'Family': String(family ?? ''),
141+
'Sun Exposure Flags': sunExposureFlags,
142+
'Soil Moisture Flags': soilMoistureFlags,
143+
'Plant Type Flags': plantTypeFlags,
144+
'Life Cycle Flags': lifeCycleFlags,
145+
'Pollinator Flags': pollinatorFlags,
146+
'Flower Color Flags': flowerColorFlags,
147+
'Availability Flags': availabilityFlags,
148+
'Flowering Months By Number': computeFloweringMonthsByNumber(plant['Flowering Months']),
149+
};
150+
151+
await plants.updateOne({ _id: plant._id }, { $set: requiredFixes });
152+
36153
// Check if image files exist and set hasImage and hasPreview flags
37154
const fullImagePath = `${__dirname}/images/${plant._id}.jpg`;
38155
const previewImagePath = `${__dirname}/images/${plant._id}.preview.jpg`;
@@ -79,31 +196,9 @@ async function go() {
79196
}
80197

81198
// Process Flowering Months into an array of flags
82-
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
83-
let [from, to] = plant['Flowering Months'].split('–');
84-
from = months.indexOf(from);
85-
to = months.indexOf(to);
86-
if ((from > -1) && (to > -1)) {
87-
const matching = [];
88-
for (let i = from; (i <= to); i++) {
89-
matching.push(i);
90-
}
91-
await plants.updateOne({
92-
_id: plant._id
93-
}, {
94-
$set: {
95-
'Flowering Months By Number': matching
96-
}
97-
});
98-
} else {
99-
await plants.updateOne({
100-
_id: plant._id
101-
}, {
102-
$unset: {
103-
'Flowering Months By Number': 1
104-
}
105-
});
106-
}
199+
// NOTE: This field is required by strict DB schema. Never $unset it.
200+
const floweringMonthsByNumber = computeFloweringMonthsByNumber(plant['Flowering Months']);
201+
await plants.updateOne({ _id: plant._id }, { $set: { 'Flowering Months By Number': floweringMonthsByNumber } });
107202
var states = plant['Distribution in USA'].split(', ')
108203
await plants.updateOne({
109204
_id: plant._id
@@ -148,12 +243,12 @@ async function go() {
148243
const score = plant['Recommendation Score'];
149244
if (typeof score === 'string' || score instanceof String) {
150245
const numScore = parseFloat(score);
151-
if (numScore != NaN) {
246+
if (!isNaN(numScore)) {
152247
await plants.updateOne({
153248
_id: plant._id
154249
}, {
155250
$set: {
156-
'Recommendation Score': numScore
251+
'Recommendation Score': Math.trunc(numScore)
157252
}
158253
});
159254
} else {
@@ -201,93 +296,8 @@ async function go() {
201296
});
202297
}
203298
}
204-
let plantTypeFlags = [];
205-
const plantType = plant['Plant Type'];
206-
const matches = plantType.match(/^(.*?)(\(.*?\))?$/);
207-
if (!matches) {
208-
console.error(`Don't know how to handle ${plantType}`);
209-
} else {
210-
plantTypeFlags = matches[1].split(/, /);
211-
if (matches[2]) {
212-
plantTypeFlags = [...plantTypeFlags, ...matches[2].split(/ or /)];
213-
}
214-
plantTypeFlags = plantTypeFlags.map(flag => flag.trim().replace('(', '').replace(')', '')).map(capitalize);
215-
const lifeCycles = ['Annual', 'Biennial', 'Perennial'];
216-
const lifeCycleFlags = plantTypeFlags.filter(plantType => lifeCycles.includes(plantType));
217-
plantTypeFlags = plantTypeFlags.filter(plantType => !lifeCycles.includes(plantType));
218-
await plants.updateOne({
219-
_id: plant._id
220-
}, {
221-
$set: {
222-
'Plant Type Flags': plantTypeFlags,
223-
'Life Cycle Flags': lifeCycleFlags
224-
}
225-
});
226-
}
227-
const sunExposureFlags = plant['Sun Exposure'].split(', ').map(capitalize).filter(flag => flag.length > 0);
228-
await plants.updateOne({
229-
_id: plant._id
230-
}, {
231-
$set: {
232-
'Sun Exposure Flags': sunExposureFlags
233-
}
234-
});
235-
const soilMoistureFlags = plant['Soil Moisture'].split(/,\s*/).map(capitalize).filter(flag => flag.length > 0);
236-
if (soilMoistureFlags.length) {
237-
await plants.updateOne({
238-
_id: plant._id
239-
}, {
240-
$set: {
241-
'Soil Moisture Flags': soilMoistureFlags
242-
}
243-
});
244-
} else {
245-
await plants.updateOne({
246-
_id: plant._id
247-
}, {
248-
$unset: {
249-
'Soil Moisture Flags': 1
250-
}
251-
});
252-
}
253-
const pollinatorFlags = plant['Pollinators'].split(/\s*(?:,|;)+\s*/).map(capitalize).filter(flag => flag.length > 0);
254-
await plants.updateOne({
255-
_id: plant._id
256-
}, {
257-
$set: {
258-
'Pollinator Flags': pollinatorFlags
259-
}
260-
});
261-
const propagationFlags = plant['Propagation'].split(/\s*(?:,|;)+\s*/).map(capitalize).filter(flag => flag.length > 0);
262-
await plants.updateOne({
263-
_id: plant._id
264-
}, {
265-
$set: {
266-
'Propagation Flags': propagationFlags
267-
}
268-
});
269-
const flowerColorFlags = (plant['Flower Color'] || '').split(/\s*[-,]\s*/).map(capitalize).filter(flag => flag.length > 0);
270-
await plants.updateOne({
271-
_id: plant._id
272-
}, {
273-
$set: {
274-
'Flower Color Flags': flowerColorFlags
275-
}
276-
});
277-
const availabilityFlags = [];
278-
if (plant['Online Flag'] === '1') {
279-
availabilityFlags.push('Online');
280-
}
281-
if (plant['Local Flag'] === '1') {
282-
availabilityFlags.push('Local');
283-
}
284-
await plants.updateOne({
285-
_id: plant._id
286-
}, {
287-
$set: {
288-
'Availability Flags': availabilityFlags
289-
}
290-
});
299+
// NOTE: "Flags" fields + required fields are computed and fixed at the top of the loop
300+
// in a single `$set` to keep the document schema-valid under strict validation.
291301
}
292302

293303
await close();

0 commit comments

Comments
 (0)