This commit is contained in:
2026-03-29 10:26:38 -05:00
parent 05d4f6e78b
commit b1c99893a6
1628 changed files with 67782 additions and 60143 deletions

View File

@@ -1,4 +1,4 @@
node_modules
.env
.env.*
npm-debug.log
node_modules
.env
.env.*
npm-debug.log

View File

@@ -1,12 +1,12 @@
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
EXPOSE 3000
CMD ["node", "index.js"]
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
EXPOSE 3000
CMD ["node", "index.js"]

View File

@@ -1,9 +1,9 @@
{
"expo": {
"extra": {
"eas": {
"projectId": "4fb7372e-83ce-4f18-9b48-e735f00d7243"
}
}
}
}
{
"expo": {
"extra": {
"eas": {
"projectId": "4fb7372e-83ce-4f18-9b48-e735f00d7243"
}
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,107 +1,107 @@
const crypto = require('crypto');
const { get, run } = require('./sqlite');
const JWT_SECRET = process.env.JWT_SECRET || 'greenlens-dev-secret-change-in-prod';
const TOKEN_EXPIRY_SECONDS = 365 * 24 * 3600; // 1 year
// ─── Minimal JWT (HS256, no external deps) ─────────────────────────────────
const b64url = (input) => {
const str = typeof input === 'string' ? input : JSON.stringify(input);
return Buffer.from(str).toString('base64url');
};
const b64urlDecode = (str) => Buffer.from(str, 'base64url').toString();
const signJwt = (payload) => {
const header = b64url({ alg: 'HS256', typ: 'JWT' });
const body = b64url(payload);
const sig = crypto.createHmac('sha256', JWT_SECRET).update(`${header}.${body}`).digest('base64url');
return `${header}.${body}.${sig}`;
};
const verifyJwt = (token) => {
if (!token || typeof token !== 'string') return null;
const parts = token.split('.');
if (parts.length !== 3) return null;
const [header, body, sig] = parts;
const expected = crypto.createHmac('sha256', JWT_SECRET).update(`${header}.${body}`).digest('base64url');
if (sig !== expected) return null;
try {
const payload = JSON.parse(b64urlDecode(body));
if (payload.exp && Math.floor(Date.now() / 1000) > payload.exp) return null;
return payload;
} catch {
return null;
}
};
const issueToken = (userId, email, name) =>
signJwt({
sub: userId,
email,
name,
iat: Math.floor(Date.now() / 1000),
exp: Math.floor(Date.now() / 1000) + TOKEN_EXPIRY_SECONDS,
});
// ─── Password hashing ──────────────────────────────────────────────────────
const hashPassword = (password) =>
crypto.createHmac('sha256', JWT_SECRET).update(password).digest('hex');
// ─── Schema ────────────────────────────────────────────────────────────────
const ensureAuthSchema = async (db) => {
await run(
db,
`CREATE TABLE IF NOT EXISTS auth_users (
id TEXT PRIMARY KEY,
email TEXT NOT NULL UNIQUE COLLATE NOCASE,
name TEXT NOT NULL DEFAULT '',
password_hash TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
)`,
);
};
// ─── Operations ───────────────────────────────────────────────────────────
const signUp = async (db, email, name, password) => {
const normalizedEmail = email.trim().toLowerCase();
const existing = await get(db, 'SELECT id FROM auth_users WHERE email = ?', [normalizedEmail]);
if (existing) {
const err = new Error('Email already in use.');
err.code = 'EMAIL_TAKEN';
err.status = 409;
throw err;
}
const id = `usr_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`;
await run(db, 'INSERT INTO auth_users (id, email, name, password_hash) VALUES (?, ?, ?, ?)', [
id,
normalizedEmail,
name.trim(),
hashPassword(password),
]);
return { id, email: normalizedEmail, name: name.trim() };
};
const login = async (db, email, password) => {
const normalizedEmail = email.trim().toLowerCase();
const user = await get(db, 'SELECT id, email, name, password_hash FROM auth_users WHERE email = ?', [normalizedEmail]);
if (!user) {
const err = new Error('No account found for this email.');
err.code = 'USER_NOT_FOUND';
err.status = 401;
throw err;
}
if (user.password_hash !== hashPassword(password)) {
const err = new Error('Wrong password.');
err.code = 'WRONG_PASSWORD';
err.status = 401;
throw err;
}
return { id: user.id, email: user.email, name: user.name };
};
module.exports = { ensureAuthSchema, signUp, login, issueToken, verifyJwt };
const crypto = require('crypto');
const { get, run } = require('./sqlite');
const JWT_SECRET = process.env.JWT_SECRET || 'greenlens-dev-secret-change-in-prod';
const TOKEN_EXPIRY_SECONDS = 365 * 24 * 3600; // 1 year
// ─── Minimal JWT (HS256, no external deps) ─────────────────────────────────
const b64url = (input) => {
const str = typeof input === 'string' ? input : JSON.stringify(input);
return Buffer.from(str).toString('base64url');
};
const b64urlDecode = (str) => Buffer.from(str, 'base64url').toString();
const signJwt = (payload) => {
const header = b64url({ alg: 'HS256', typ: 'JWT' });
const body = b64url(payload);
const sig = crypto.createHmac('sha256', JWT_SECRET).update(`${header}.${body}`).digest('base64url');
return `${header}.${body}.${sig}`;
};
const verifyJwt = (token) => {
if (!token || typeof token !== 'string') return null;
const parts = token.split('.');
if (parts.length !== 3) return null;
const [header, body, sig] = parts;
const expected = crypto.createHmac('sha256', JWT_SECRET).update(`${header}.${body}`).digest('base64url');
if (sig !== expected) return null;
try {
const payload = JSON.parse(b64urlDecode(body));
if (payload.exp && Math.floor(Date.now() / 1000) > payload.exp) return null;
return payload;
} catch {
return null;
}
};
const issueToken = (userId, email, name) =>
signJwt({
sub: userId,
email,
name,
iat: Math.floor(Date.now() / 1000),
exp: Math.floor(Date.now() / 1000) + TOKEN_EXPIRY_SECONDS,
});
// ─── Password hashing ──────────────────────────────────────────────────────
const hashPassword = (password) =>
crypto.createHmac('sha256', JWT_SECRET).update(password).digest('hex');
// ─── Schema ────────────────────────────────────────────────────────────────
const ensureAuthSchema = async (db) => {
await run(
db,
`CREATE TABLE IF NOT EXISTS auth_users (
id TEXT PRIMARY KEY,
email TEXT NOT NULL UNIQUE COLLATE NOCASE,
name TEXT NOT NULL DEFAULT '',
password_hash TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
)`,
);
};
// ─── Operations ───────────────────────────────────────────────────────────
const signUp = async (db, email, name, password) => {
const normalizedEmail = email.trim().toLowerCase();
const existing = await get(db, 'SELECT id FROM auth_users WHERE email = ?', [normalizedEmail]);
if (existing) {
const err = new Error('Email already in use.');
err.code = 'EMAIL_TAKEN';
err.status = 409;
throw err;
}
const id = `usr_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`;
await run(db, 'INSERT INTO auth_users (id, email, name, password_hash) VALUES (?, ?, ?, ?)', [
id,
normalizedEmail,
name.trim(),
hashPassword(password),
]);
return { id, email: normalizedEmail, name: name.trim() };
};
const login = async (db, email, password) => {
const normalizedEmail = email.trim().toLowerCase();
const user = await get(db, 'SELECT id, email, name, password_hash FROM auth_users WHERE email = ?', [normalizedEmail]);
if (!user) {
const err = new Error('No account found for this email.');
err.code = 'USER_NOT_FOUND';
err.status = 401;
throw err;
}
if (user.password_hash !== hashPassword(password)) {
const err = new Error('Wrong password.');
err.code = 'WRONG_PASSWORD';
err.status = 401;
throw err;
}
return { id: user.id, email: user.email, name: user.name };
};
module.exports = { ensureAuthSchema, signUp, login, issueToken, verifyJwt };

File diff suppressed because it is too large Load Diff

View File

@@ -1,193 +1,193 @@
const { SEARCH_INTENT_CONFIG } = require('./searchIntentConfig');
const normalizeSearchText = (value) => {
return String(value || '')
.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.trim()
.replace(/[^a-z0-9\s_-]+/g, ' ')
.replace(/[_-]+/g, ' ')
.replace(/\s+/g, ' ');
};
const tokenize = (normalizedValue) => normalizedValue.split(' ').filter(Boolean);
const normalizeArray = (values) => {
return [...new Set((values || []).map((value) => normalizeSearchText(value)).filter(Boolean))];
};
const tokenSetFromQuery = (normalizedQuery) => {
const noise = new Set(SEARCH_INTENT_CONFIG.noiseTokens.map((token) => normalizeSearchText(token)));
return new Set(tokenize(normalizedQuery).filter((token) => !noise.has(token)));
};
const includesPhrase = (normalizedQuery, normalizedAlias, queryTokens) => {
if (!normalizedAlias) return false;
if (normalizedQuery.includes(normalizedAlias)) return true;
const aliasTokens = tokenize(normalizedAlias);
if (aliasTokens.length <= 1) return queryTokens.has(normalizedAlias);
return aliasTokens.every((token) => queryTokens.has(token));
};
const detectQueryIntents = (normalizedQuery) => {
const queryTokens = tokenSetFromQuery(normalizedQuery);
return Object.entries(SEARCH_INTENT_CONFIG.intents)
.filter(([, value]) =>
(value.aliases || []).some((alias) => includesPhrase(normalizedQuery, normalizeSearchText(alias), queryTokens)))
.map(([intentId]) => intentId);
};
const getLevenshteinDistance = (left, right) => {
const rows = left.length + 1;
const cols = right.length + 1;
const matrix = Array.from({ length: rows }, (_, rowIndex) => [rowIndex]);
for (let col = 0; col < cols; col += 1) {
matrix[0][col] = col;
}
for (let row = 1; row < rows; row += 1) {
for (let col = 1; col < cols; col += 1) {
const cost = left[row - 1] === right[col - 1] ? 0 : 1;
matrix[row][col] = Math.min(
matrix[row - 1][col] + 1,
matrix[row][col - 1] + 1,
matrix[row - 1][col - 1] + cost,
);
}
}
return matrix[left.length][right.length];
};
const fuzzyBonus = (normalizedQuery, candidates) => {
if (normalizedQuery.length < 3 || normalizedQuery.length > 32) return 0;
let best = Number.POSITIVE_INFINITY;
(candidates || []).forEach((candidate) => {
if (!candidate) return;
tokenize(candidate).forEach((token) => {
best = Math.min(best, getLevenshteinDistance(normalizedQuery, token));
});
best = Math.min(best, getLevenshteinDistance(normalizedQuery, candidate));
});
if (best === 1) return 14;
if (best === 2) return 8;
return 0;
};
const scoreTextMatch = (normalizedQuery, normalizedTarget, exact, prefix, contains) => {
if (!normalizedQuery || !normalizedTarget) return 0;
if (normalizedTarget === normalizedQuery) return exact;
if (normalizedTarget.startsWith(normalizedQuery)) return prefix;
if (normalizedTarget.includes(normalizedQuery)) return contains;
return 0;
};
const buildDerivedIntentSignals = (entry) => {
const normalizedDescription = normalizeSearchText(entry.description || '');
const normalizedLight = normalizeSearchText(entry.careInfo && entry.careInfo.light ? entry.careInfo.light : '');
const derivedSignals = new Set((entry.categories || []).map((category) => normalizeSearchText(category)));
Object.entries(SEARCH_INTENT_CONFIG.intents).forEach(([intentId, intentConfig]) => {
const entryHints = normalizeArray(intentConfig.entryHints || []);
if (entryHints.some((hint) => normalizedDescription.includes(hint))) {
derivedSignals.add(intentId);
}
const lightHints = normalizeArray(intentConfig.lightHints || []);
if (lightHints.some((hint) => normalizedLight.includes(hint))) {
derivedSignals.add(intentId);
}
});
return [...derivedSignals];
};
const scoreHybridEntry = (entry, query) => {
const normalizedQuery = normalizeSearchText(query);
if (!normalizedQuery) return 0;
const normalizedName = normalizeSearchText(entry.name || '');
const normalizedBotanical = normalizeSearchText(entry.botanicalName || '');
const normalizedDescription = normalizeSearchText(entry.description || '');
const normalizedCategories = (entry.categories || []).map((category) => normalizeSearchText(category));
const derivedSignals = buildDerivedIntentSignals(entry);
const requestedIntents = detectQueryIntents(normalizedQuery);
let score = 0;
score += Math.max(
scoreTextMatch(normalizedQuery, normalizedName, 140, 100, 64),
scoreTextMatch(normalizedQuery, normalizedBotanical, 130, 96, 58),
);
if (normalizedDescription.includes(normalizedQuery)) {
score += 24;
}
score += fuzzyBonus(normalizedQuery, [normalizedName, normalizedBotanical, ...normalizedCategories]);
let matchedIntentCount = 0;
requestedIntents.forEach((intentId) => {
const categoryHit = normalizedCategories.includes(intentId);
const derivedHit = derivedSignals.includes(intentId);
if (categoryHit) {
score += 92;
matchedIntentCount += 1;
return;
}
if (derivedHit) {
score += 56;
matchedIntentCount += 1;
}
});
if (matchedIntentCount >= 2) {
score += 38 * matchedIntentCount;
} else if (matchedIntentCount === 1) {
score += 10;
}
const queryTokens = [...tokenSetFromQuery(normalizedQuery)];
if (queryTokens.length > 1) {
const searchableText = [
normalizedName,
normalizedBotanical,
normalizedDescription,
...normalizedCategories,
...derivedSignals,
].join(' ');
const tokenHits = queryTokens.filter((token) => searchableText.includes(token)).length;
score += tokenHits * 8;
if (tokenHits === queryTokens.length) {
score += 16;
}
}
return score;
};
const rankHybridEntries = (entries, query, limit = 30) => {
const normalizedQuery = normalizeSearchText(query);
if (!normalizedQuery) {
return entries.slice(0, limit).map((entry) => ({ entry, score: 0 }));
}
return entries
.map((entry) => ({ entry, score: scoreHybridEntry(entry, normalizedQuery) }))
.filter((candidate) => candidate.score > 0)
.sort((left, right) =>
right.score - left.score ||
left.entry.name.length - right.entry.name.length ||
left.entry.name.localeCompare(right.entry.name))
.slice(0, limit);
};
module.exports = {
normalizeSearchText,
rankHybridEntries,
scoreHybridEntry,
};
const { SEARCH_INTENT_CONFIG } = require('./searchIntentConfig');
const normalizeSearchText = (value) => {
return String(value || '')
.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.trim()
.replace(/[^a-z0-9\s_-]+/g, ' ')
.replace(/[_-]+/g, ' ')
.replace(/\s+/g, ' ');
};
const tokenize = (normalizedValue) => normalizedValue.split(' ').filter(Boolean);
const normalizeArray = (values) => {
return [...new Set((values || []).map((value) => normalizeSearchText(value)).filter(Boolean))];
};
const tokenSetFromQuery = (normalizedQuery) => {
const noise = new Set(SEARCH_INTENT_CONFIG.noiseTokens.map((token) => normalizeSearchText(token)));
return new Set(tokenize(normalizedQuery).filter((token) => !noise.has(token)));
};
const includesPhrase = (normalizedQuery, normalizedAlias, queryTokens) => {
if (!normalizedAlias) return false;
if (normalizedQuery.includes(normalizedAlias)) return true;
const aliasTokens = tokenize(normalizedAlias);
if (aliasTokens.length <= 1) return queryTokens.has(normalizedAlias);
return aliasTokens.every((token) => queryTokens.has(token));
};
const detectQueryIntents = (normalizedQuery) => {
const queryTokens = tokenSetFromQuery(normalizedQuery);
return Object.entries(SEARCH_INTENT_CONFIG.intents)
.filter(([, value]) =>
(value.aliases || []).some((alias) => includesPhrase(normalizedQuery, normalizeSearchText(alias), queryTokens)))
.map(([intentId]) => intentId);
};
const getLevenshteinDistance = (left, right) => {
const rows = left.length + 1;
const cols = right.length + 1;
const matrix = Array.from({ length: rows }, (_, rowIndex) => [rowIndex]);
for (let col = 0; col < cols; col += 1) {
matrix[0][col] = col;
}
for (let row = 1; row < rows; row += 1) {
for (let col = 1; col < cols; col += 1) {
const cost = left[row - 1] === right[col - 1] ? 0 : 1;
matrix[row][col] = Math.min(
matrix[row - 1][col] + 1,
matrix[row][col - 1] + 1,
matrix[row - 1][col - 1] + cost,
);
}
}
return matrix[left.length][right.length];
};
const fuzzyBonus = (normalizedQuery, candidates) => {
if (normalizedQuery.length < 3 || normalizedQuery.length > 32) return 0;
let best = Number.POSITIVE_INFINITY;
(candidates || []).forEach((candidate) => {
if (!candidate) return;
tokenize(candidate).forEach((token) => {
best = Math.min(best, getLevenshteinDistance(normalizedQuery, token));
});
best = Math.min(best, getLevenshteinDistance(normalizedQuery, candidate));
});
if (best === 1) return 14;
if (best === 2) return 8;
return 0;
};
const scoreTextMatch = (normalizedQuery, normalizedTarget, exact, prefix, contains) => {
if (!normalizedQuery || !normalizedTarget) return 0;
if (normalizedTarget === normalizedQuery) return exact;
if (normalizedTarget.startsWith(normalizedQuery)) return prefix;
if (normalizedTarget.includes(normalizedQuery)) return contains;
return 0;
};
const buildDerivedIntentSignals = (entry) => {
const normalizedDescription = normalizeSearchText(entry.description || '');
const normalizedLight = normalizeSearchText(entry.careInfo && entry.careInfo.light ? entry.careInfo.light : '');
const derivedSignals = new Set((entry.categories || []).map((category) => normalizeSearchText(category)));
Object.entries(SEARCH_INTENT_CONFIG.intents).forEach(([intentId, intentConfig]) => {
const entryHints = normalizeArray(intentConfig.entryHints || []);
if (entryHints.some((hint) => normalizedDescription.includes(hint))) {
derivedSignals.add(intentId);
}
const lightHints = normalizeArray(intentConfig.lightHints || []);
if (lightHints.some((hint) => normalizedLight.includes(hint))) {
derivedSignals.add(intentId);
}
});
return [...derivedSignals];
};
const scoreHybridEntry = (entry, query) => {
const normalizedQuery = normalizeSearchText(query);
if (!normalizedQuery) return 0;
const normalizedName = normalizeSearchText(entry.name || '');
const normalizedBotanical = normalizeSearchText(entry.botanicalName || '');
const normalizedDescription = normalizeSearchText(entry.description || '');
const normalizedCategories = (entry.categories || []).map((category) => normalizeSearchText(category));
const derivedSignals = buildDerivedIntentSignals(entry);
const requestedIntents = detectQueryIntents(normalizedQuery);
let score = 0;
score += Math.max(
scoreTextMatch(normalizedQuery, normalizedName, 140, 100, 64),
scoreTextMatch(normalizedQuery, normalizedBotanical, 130, 96, 58),
);
if (normalizedDescription.includes(normalizedQuery)) {
score += 24;
}
score += fuzzyBonus(normalizedQuery, [normalizedName, normalizedBotanical, ...normalizedCategories]);
let matchedIntentCount = 0;
requestedIntents.forEach((intentId) => {
const categoryHit = normalizedCategories.includes(intentId);
const derivedHit = derivedSignals.includes(intentId);
if (categoryHit) {
score += 92;
matchedIntentCount += 1;
return;
}
if (derivedHit) {
score += 56;
matchedIntentCount += 1;
}
});
if (matchedIntentCount >= 2) {
score += 38 * matchedIntentCount;
} else if (matchedIntentCount === 1) {
score += 10;
}
const queryTokens = [...tokenSetFromQuery(normalizedQuery)];
if (queryTokens.length > 1) {
const searchableText = [
normalizedName,
normalizedBotanical,
normalizedDescription,
...normalizedCategories,
...derivedSignals,
].join(' ');
const tokenHits = queryTokens.filter((token) => searchableText.includes(token)).length;
score += tokenHits * 8;
if (tokenHits === queryTokens.length) {
score += 16;
}
}
return score;
};
const rankHybridEntries = (entries, query, limit = 30) => {
const normalizedQuery = normalizeSearchText(query);
if (!normalizedQuery) {
return entries.slice(0, limit).map((entry) => ({ entry, score: 0 }));
}
return entries
.map((entry) => ({ entry, score: scoreHybridEntry(entry, normalizedQuery) }))
.filter((candidate) => candidate.score > 0)
.sort((left, right) =>
right.score - left.score ||
left.entry.name.length - right.entry.name.length ||
left.entry.name.localeCompare(right.entry.name))
.slice(0, limit);
};
module.exports = {
normalizeSearchText,
rankHybridEntries,
scoreHybridEntry,
};

View File

@@ -1,461 +1,461 @@
const OPENAI_API_KEY = (process.env.OPENAI_API_KEY || process.env.EXPO_PUBLIC_OPENAI_API_KEY || '').trim();
const OPENAI_SCAN_MODEL = (process.env.OPENAI_SCAN_MODEL || process.env.EXPO_PUBLIC_OPENAI_SCAN_MODEL || 'gpt-5-mini').trim();
const OPENAI_SCAN_MODEL_PRO = (process.env.OPENAI_SCAN_MODEL_PRO || process.env.EXPO_PUBLIC_OPENAI_SCAN_MODEL_PRO || OPENAI_SCAN_MODEL).trim();
const OPENAI_HEALTH_MODEL = (process.env.OPENAI_HEALTH_MODEL || process.env.EXPO_PUBLIC_OPENAI_HEALTH_MODEL || OPENAI_SCAN_MODEL).trim();
const OPENAI_SCAN_FALLBACK_MODELS = (process.env.OPENAI_SCAN_FALLBACK_MODELS || process.env.EXPO_PUBLIC_OPENAI_SCAN_FALLBACK_MODELS || 'gpt-5-mini,gpt-4.1-mini').trim();
const OPENAI_SCAN_FALLBACK_MODELS_PRO = (process.env.OPENAI_SCAN_FALLBACK_MODELS_PRO || process.env.EXPO_PUBLIC_OPENAI_SCAN_FALLBACK_MODELS_PRO || OPENAI_SCAN_FALLBACK_MODELS).trim();
const OPENAI_HEALTH_FALLBACK_MODELS = (process.env.OPENAI_HEALTH_FALLBACK_MODELS || process.env.EXPO_PUBLIC_OPENAI_HEALTH_FALLBACK_MODELS || OPENAI_SCAN_FALLBACK_MODELS).trim();
const OPENAI_CHAT_COMPLETIONS_URL = (process.env.OPENAI_CHAT_COMPLETIONS_URL || 'https://api.openai.com/v1/chat/completions').trim();
const OPENAI_TIMEOUT_MS = (() => {
const raw = (process.env.OPENAI_TIMEOUT_MS || process.env.EXPO_PUBLIC_OPENAI_TIMEOUT_MS || '45000').trim();
const parsed = Number.parseInt(raw, 10);
if (Number.isFinite(parsed) && parsed >= 10000) return parsed;
return 45000;
})();
const parseModelChain = (primaryModel, fallbackModels) => {
const models = [primaryModel];
for (const model of String(fallbackModels || '').split(',')) {
const normalized = model.trim();
if (!normalized) continue;
models.push(normalized);
}
return [...new Set(models.filter(Boolean))];
};
const OPENAI_SCAN_MODEL_CHAIN = parseModelChain(OPENAI_SCAN_MODEL, OPENAI_SCAN_FALLBACK_MODELS);
const OPENAI_SCAN_MODEL_CHAIN_PRO = parseModelChain(OPENAI_SCAN_MODEL_PRO, OPENAI_SCAN_FALLBACK_MODELS_PRO);
const OPENAI_HEALTH_MODEL_CHAIN = parseModelChain(OPENAI_HEALTH_MODEL, OPENAI_HEALTH_FALLBACK_MODELS);
const getScanModelChain = (plan) => {
return plan === 'pro' ? OPENAI_SCAN_MODEL_CHAIN_PRO : OPENAI_SCAN_MODEL_CHAIN;
};
const clamp = (value, min, max) => {
return Math.min(max, Math.max(min, value));
};
const toErrorMessage = (error) => {
if (error instanceof Error) return error.message;
return String(error);
};
const summarizeImageUri = (imageUri) => {
const trimmed = typeof imageUri === 'string' ? imageUri.trim() : '';
if (!trimmed) return 'empty';
if (trimmed.startsWith('data:image')) return `data-uri(${Math.round(trimmed.length / 1024)}kb)`;
return trimmed.length > 120 ? `${trimmed.slice(0, 120)}...` : trimmed;
};
const toJsonString = (content) => {
const trimmed = typeof content === 'string' ? content.trim() : '';
if (!trimmed) return '';
const fenced = trimmed.match(/^```(?:json)?\s*([\s\S]*?)\s*```$/i);
if (fenced && fenced[1]) return fenced[1].trim();
return trimmed;
};
const parseContentToJson = (content) => {
try {
const parsed = JSON.parse(toJsonString(content));
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
return parsed;
}
return null;
} catch {
return null;
}
};
const getString = (value) => {
return typeof value === 'string' ? value.trim() : '';
};
const getNumber = (value) => {
if (typeof value === 'number' && Number.isFinite(value)) return value;
if (typeof value === 'string' && value.trim()) {
const parsed = Number(value);
if (Number.isFinite(parsed)) return parsed;
}
return null;
};
const getStringArray = (value) => {
if (!Array.isArray(value)) return [];
return value
.map((item) => (typeof item === 'string' ? item.trim() : ''))
.filter(Boolean);
};
const getLanguageLabel = (language) => {
if (language === 'de') return 'German';
if (language === 'es') return 'Spanish';
return 'English';
};
const normalizeIdentifyResult = (raw, language) => {
const name = getString(raw.name);
const botanicalName = getString(raw.botanicalName);
const description = getString(raw.description);
const confidenceRaw = getNumber(raw.confidence);
const careInfoRaw = raw.careInfo;
if (!name || !botanicalName || !careInfoRaw || typeof careInfoRaw !== 'object' || Array.isArray(careInfoRaw)) {
return null;
}
const waterIntervalRaw = getNumber(careInfoRaw.waterIntervalDays);
const light = getString(careInfoRaw.light);
const temp = getString(careInfoRaw.temp);
if (waterIntervalRaw == null || !light || !temp) {
return null;
}
const fallbackDescription = language === 'de'
? `${name} wurde per KI erkannt. Pflegehinweise sind unten aufgefuehrt.`
: language === 'es'
? `${name} se detecto con IA. Debajo veras recomendaciones de cuidado.`
: `${name} was identified with AI. Care guidance is shown below.`;
return {
name,
botanicalName,
confidence: clamp(confidenceRaw == null ? 0.72 : confidenceRaw, 0.05, 0.99),
description: description || fallbackDescription,
careInfo: {
waterIntervalDays: Math.round(clamp(waterIntervalRaw, 1, 45)),
light,
temp,
},
};
};
const normalizeHealthAnalysis = (raw, language) => {
const scoreRaw = getNumber(raw.overallHealthScore);
const statusRaw = getString(raw.status);
const issuesRaw = raw.likelyIssues;
const actionsNowRaw = getStringArray(raw.actionsNow).slice(0, 8);
const plan7DaysRaw = getStringArray(raw.plan7Days).slice(0, 10);
if (scoreRaw == null || !statusRaw || !Array.isArray(issuesRaw)) {
return null;
}
const status = statusRaw === 'healthy' || statusRaw === 'watch' || statusRaw === 'critical'
? statusRaw
: 'watch';
const likelyIssues = issuesRaw
.map((entry) => {
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return null;
const title = getString(entry.title);
const details = getString(entry.details);
const confidenceRaw = getNumber(entry.confidence);
if (!title || !details || confidenceRaw == null) return null;
return {
title,
details,
confidence: clamp(confidenceRaw, 0.05, 0.99),
};
})
.filter(Boolean)
.slice(0, 4);
if (likelyIssues.length === 0 || actionsNowRaw.length < 2 || plan7DaysRaw.length < 2) {
const fallbackIssue = language === 'de'
? 'Die KI konnte keine stabilen Gesundheitsmerkmale extrahieren.'
: language === 'es'
? 'La IA no pudo extraer senales de salud estables.'
: 'AI could not extract stable health signals.';
return {
overallHealthScore: Math.round(clamp(scoreRaw, 0, 100)),
status,
likelyIssues: [
{
title: language === 'de'
? 'Analyse unsicher'
: language === 'es'
? 'Analisis incierto'
: 'Uncertain analysis',
confidence: 0.35,
details: fallbackIssue,
},
],
actionsNow: actionsNowRaw.length > 0
? actionsNowRaw
: [language === 'de' ? 'Neues, schaerferes Foto aufnehmen.' : language === 'es' ? 'Tomar una foto nueva y mas nitida.' : 'Capture a new, sharper photo.'],
plan7Days: plan7DaysRaw.length > 0
? plan7DaysRaw
: [language === 'de' ? 'In 2 Tagen erneut pruefen.' : language === 'es' ? 'Volver a revisar en 2 dias.' : 'Re-check in 2 days.'],
};
}
return {
overallHealthScore: Math.round(clamp(scoreRaw, 0, 100)),
status,
likelyIssues,
actionsNow: actionsNowRaw,
plan7Days: plan7DaysRaw,
};
};
const buildIdentifyPrompt = (language, mode) => {
const reviewInstruction = mode === 'review'
? 'Re-check your first hypothesis with stricter botanical accuracy and correct any mismatch.'
: 'Identify the most likely houseplant species from this image with conservative confidence.';
const nameLanguageInstruction = language === 'en'
? '- "name" must be an English common name only. Never return a German or other non-English common name. If no reliable English common name is known, use "botanicalName" as "name" instead of inventing or translating.'
: `- "name" must be strictly written in ${getLanguageLabel(language)}. If a reliable common name in that language is not known, use "botanicalName" as "name" instead of inventing a localized name.`;
return [
`${reviewInstruction}`,
'Return strict JSON only in this shape:',
'{"name":"...","botanicalName":"...","confidence":0.0,"description":"...","careInfo":{"waterIntervalDays":7,"light":"...","temp":"..."}}',
'Rules:',
nameLanguageInstruction,
`- "description" and "careInfo.light" must be written in ${getLanguageLabel(language)}.`,
'- "botanicalName" must use accepted Latin scientific naming and must not be invented or misspelled.',
'- If species is uncertain, prefer genus-level naming (for example: "Calathea sp.").',
'- "confidence" must be between 0 and 1.',
'- Keep confidence <= 0.55 when the image is ambiguous, blurred, or partially visible.',
'- "waterIntervalDays" must be an integer between 1 and 45.',
'- Do not include markdown, explanations, or extra keys.',
].join('\n');
};
const buildHealthPrompt = (language, plantContext) => {
const contextLines = plantContext
? [
'Plant context:',
`- name: ${plantContext.name || 'n/a'}`,
`- botanicalName: ${plantContext.botanicalName || 'n/a'}`,
`- care.light: ${plantContext.careInfo?.light || 'n/a'}`,
`- care.temp: ${plantContext.careInfo?.temp || 'n/a'}`,
`- care.waterIntervalDays: ${plantContext.careInfo?.waterIntervalDays || 'n/a'}`,
`- description: ${plantContext.description || 'n/a'}`,
]
: ['Plant context: not provided'];
return [
`You are an expert botanist and plant health diagnostician. Carefully examine every visible detail of this plant photo and produce a thorough, professional health assessment written in ${getLanguageLabel(language)}.`,
'',
'Inspect the following in detail: leaf color (yellowing, browning, bleaching, dark spots, necrosis), leaf texture (wilting, crispy edges, curling, drooping), stem condition (rot, soft spots, discoloration), soil surface (dry cracks, mold, pests, waterlogging signs), visible pests (spider mites, fungus gnats, scale insects, aphids, mealybugs), root health (if visible), pot size and drainage.',
'',
'Return strict JSON only in this exact shape:',
'{"overallHealthScore":72,"status":"watch","likelyIssues":[{"title":"...","confidence":0.64,"details":"..."}],"actionsNow":["..."],"plan7Days":["..."]}',
'',
'Rules:',
'- "overallHealthScore": integer 0100. 100=perfect health, 8099=minor cosmetic only, 6079=noticeable issues needing attention, 4059=significant stress, below 40=severe/critical.',
'- "status": exactly one of "healthy" (score>=80, no active threats), "watch" (score 5079, needs monitoring), "critical" (score<50, urgent action needed).',
'- "likelyIssues": 2 to 4 items, sorted by confidence descending. Each item:',
' - "title": concise issue name (e.g. "Overwatering / Root Rot Risk")',
' - "confidence": float 0.050.99 reflecting visual certainty',
' - "details": 24 sentence detailed explanation of what you observe visually, what causes it, and what happens if untreated. Be specific — mention leaf color, location, pattern.',
`- "actionsNow": 5 to 8 specific, actionable steps for the next 2448 hours. Each step must be a complete sentence with concrete instructions (e.g. amounts, durations, techniques). Written in ${getLanguageLabel(language)}.`,
`- "plan7Days": 7 to 10 day-by-day or milestone care steps for the coming week. Each step should specify timing and expected outcome. Written in ${getLanguageLabel(language)}.`,
'- All text fields must be written in the specified language. No markdown, no extra keys.',
...contextLines,
].join('\n');
};
const extractMessageContent = (payload) => {
const content = payload?.choices?.[0]?.message?.content;
if (typeof content === 'string') return content;
if (Array.isArray(content)) {
return content
.map((chunk) => (chunk && chunk.type === 'text' ? chunk.text || '' : ''))
.join('')
.trim();
}
return '';
};
const postChatCompletion = async ({ modelChain, messages, imageUri, temperature }) => {
if (!OPENAI_API_KEY) return null;
if (typeof fetch !== 'function') {
throw new Error('Global fetch is not available in this Node runtime.');
}
const attemptedModels = [];
for (const model of modelChain) {
attemptedModels.push(model);
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), OPENAI_TIMEOUT_MS);
try {
const body = {
model,
response_format: { type: 'json_object' },
messages,
};
if (typeof temperature === 'number') body.temperature = temperature;
const response = await fetch(OPENAI_CHAT_COMPLETIONS_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${OPENAI_API_KEY}`,
},
body: JSON.stringify(body),
signal: controller.signal,
});
if (!response.ok) {
const body = await response.text();
console.warn('OpenAI request HTTP error.', {
status: response.status,
model,
endpoint: OPENAI_CHAT_COMPLETIONS_URL,
image: summarizeImageUri(imageUri),
bodyPreview: body.slice(0, 300),
});
continue;
}
const payload = await response.json();
return { payload, modelUsed: model, attemptedModels };
} catch (error) {
const isTimeoutAbort = error instanceof Error && error.name === 'AbortError';
console.warn('OpenAI request failed.', {
model,
endpoint: OPENAI_CHAT_COMPLETIONS_URL,
timeoutMs: OPENAI_TIMEOUT_MS,
aborted: isTimeoutAbort,
error: toErrorMessage(error),
image: summarizeImageUri(imageUri),
});
continue;
} finally {
clearTimeout(timeout);
}
}
return { payload: null, modelUsed: null, attemptedModels };
};
const identifyPlant = async ({ imageUri, language, mode = 'primary', plan = 'free' }) => {
if (!OPENAI_API_KEY) return { result: null, modelUsed: null, attemptedModels: [] };
const modelChain = getScanModelChain(plan);
const completion = await postChatCompletion({
modelChain,
imageUri,
messages: [
{
role: 'system',
content: 'You are a plant identification assistant. Return strict JSON only.',
},
{
role: 'user',
content: [
{ type: 'text', text: buildIdentifyPrompt(language, mode) },
{ type: 'image_url', image_url: { url: imageUri } },
],
},
],
});
if (!completion?.payload) {
return {
result: null,
modelUsed: completion?.modelUsed || null,
attemptedModels: completion?.attemptedModels || [],
};
}
const content = extractMessageContent(completion.payload);
if (!content) {
console.warn('OpenAI identify returned empty content.', {
model: completion.modelUsed || modelChain[0],
mode,
image: summarizeImageUri(imageUri),
});
return { result: null, modelUsed: completion.modelUsed, attemptedModels: completion.attemptedModels };
}
const parsed = parseContentToJson(content);
if (!parsed) {
console.warn('OpenAI identify returned non-JSON content.', {
model: completion.modelUsed || modelChain[0],
mode,
preview: content.slice(0, 220),
});
return { result: null, modelUsed: completion.modelUsed, attemptedModels: completion.attemptedModels };
}
const normalized = normalizeIdentifyResult(parsed, language);
if (!normalized) {
console.warn('OpenAI identify JSON did not match schema.', {
model: completion.modelUsed || modelChain[0],
mode,
keys: Object.keys(parsed),
});
}
return { result: normalized, modelUsed: completion.modelUsed, attemptedModels: completion.attemptedModels };
};
const analyzePlantHealth = async ({ imageUri, language, plantContext }) => {
if (!OPENAI_API_KEY) return { analysis: null, modelUsed: null, attemptedModels: [] };
const completion = await postChatCompletion({
modelChain: OPENAI_HEALTH_MODEL_CHAIN,
imageUri,
temperature: 0,
messages: [
{
role: 'system',
content: 'You are a plant health diagnosis assistant. Return strict JSON only.',
},
{
role: 'user',
content: [
{ type: 'text', text: buildHealthPrompt(language, plantContext) },
{ type: 'image_url', image_url: { url: imageUri } },
],
},
],
});
if (!completion?.payload) {
return {
analysis: null,
modelUsed: completion?.modelUsed || null,
attemptedModels: completion?.attemptedModels || [],
};
}
const content = extractMessageContent(completion.payload);
if (!content) {
console.warn('OpenAI health returned empty content.', {
model: completion.modelUsed || OPENAI_HEALTH_MODEL_CHAIN[0],
image: summarizeImageUri(imageUri),
});
return { analysis: null, modelUsed: completion.modelUsed, attemptedModels: completion.attemptedModels };
}
const parsed = parseContentToJson(content);
if (!parsed) {
console.warn('OpenAI health returned non-JSON content.', {
model: completion.modelUsed || OPENAI_HEALTH_MODEL_CHAIN[0],
preview: content.slice(0, 220),
});
return { analysis: null, modelUsed: completion.modelUsed, attemptedModels: completion.attemptedModels };
}
return {
analysis: normalizeHealthAnalysis(parsed, language),
modelUsed: completion.modelUsed,
attemptedModels: completion.attemptedModels,
};
};
module.exports = {
analyzePlantHealth,
buildIdentifyPrompt,
getHealthModel: () => OPENAI_HEALTH_MODEL_CHAIN[0],
getScanModel: (plan = 'free') => getScanModelChain(plan)[0],
identifyPlant,
isConfigured: () => Boolean(OPENAI_API_KEY),
normalizeIdentifyResult,
};
const OPENAI_API_KEY = (process.env.OPENAI_API_KEY || process.env.EXPO_PUBLIC_OPENAI_API_KEY || '').trim();
const OPENAI_SCAN_MODEL = (process.env.OPENAI_SCAN_MODEL || process.env.EXPO_PUBLIC_OPENAI_SCAN_MODEL || 'gpt-5-mini').trim();
const OPENAI_SCAN_MODEL_PRO = (process.env.OPENAI_SCAN_MODEL_PRO || process.env.EXPO_PUBLIC_OPENAI_SCAN_MODEL_PRO || OPENAI_SCAN_MODEL).trim();
const OPENAI_HEALTH_MODEL = (process.env.OPENAI_HEALTH_MODEL || process.env.EXPO_PUBLIC_OPENAI_HEALTH_MODEL || OPENAI_SCAN_MODEL).trim();
const OPENAI_SCAN_FALLBACK_MODELS = (process.env.OPENAI_SCAN_FALLBACK_MODELS || process.env.EXPO_PUBLIC_OPENAI_SCAN_FALLBACK_MODELS || 'gpt-5-mini,gpt-4.1-mini').trim();
const OPENAI_SCAN_FALLBACK_MODELS_PRO = (process.env.OPENAI_SCAN_FALLBACK_MODELS_PRO || process.env.EXPO_PUBLIC_OPENAI_SCAN_FALLBACK_MODELS_PRO || OPENAI_SCAN_FALLBACK_MODELS).trim();
const OPENAI_HEALTH_FALLBACK_MODELS = (process.env.OPENAI_HEALTH_FALLBACK_MODELS || process.env.EXPO_PUBLIC_OPENAI_HEALTH_FALLBACK_MODELS || OPENAI_SCAN_FALLBACK_MODELS).trim();
const OPENAI_CHAT_COMPLETIONS_URL = (process.env.OPENAI_CHAT_COMPLETIONS_URL || 'https://api.openai.com/v1/chat/completions').trim();
const OPENAI_TIMEOUT_MS = (() => {
const raw = (process.env.OPENAI_TIMEOUT_MS || process.env.EXPO_PUBLIC_OPENAI_TIMEOUT_MS || '45000').trim();
const parsed = Number.parseInt(raw, 10);
if (Number.isFinite(parsed) && parsed >= 10000) return parsed;
return 45000;
})();
const parseModelChain = (primaryModel, fallbackModels) => {
const models = [primaryModel];
for (const model of String(fallbackModels || '').split(',')) {
const normalized = model.trim();
if (!normalized) continue;
models.push(normalized);
}
return [...new Set(models.filter(Boolean))];
};
const OPENAI_SCAN_MODEL_CHAIN = parseModelChain(OPENAI_SCAN_MODEL, OPENAI_SCAN_FALLBACK_MODELS);
const OPENAI_SCAN_MODEL_CHAIN_PRO = parseModelChain(OPENAI_SCAN_MODEL_PRO, OPENAI_SCAN_FALLBACK_MODELS_PRO);
const OPENAI_HEALTH_MODEL_CHAIN = parseModelChain(OPENAI_HEALTH_MODEL, OPENAI_HEALTH_FALLBACK_MODELS);
const getScanModelChain = (plan) => {
return plan === 'pro' ? OPENAI_SCAN_MODEL_CHAIN_PRO : OPENAI_SCAN_MODEL_CHAIN;
};
const clamp = (value, min, max) => {
return Math.min(max, Math.max(min, value));
};
const toErrorMessage = (error) => {
if (error instanceof Error) return error.message;
return String(error);
};
const summarizeImageUri = (imageUri) => {
const trimmed = typeof imageUri === 'string' ? imageUri.trim() : '';
if (!trimmed) return 'empty';
if (trimmed.startsWith('data:image')) return `data-uri(${Math.round(trimmed.length / 1024)}kb)`;
return trimmed.length > 120 ? `${trimmed.slice(0, 120)}...` : trimmed;
};
const toJsonString = (content) => {
const trimmed = typeof content === 'string' ? content.trim() : '';
if (!trimmed) return '';
const fenced = trimmed.match(/^```(?:json)?\s*([\s\S]*?)\s*```$/i);
if (fenced && fenced[1]) return fenced[1].trim();
return trimmed;
};
const parseContentToJson = (content) => {
try {
const parsed = JSON.parse(toJsonString(content));
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
return parsed;
}
return null;
} catch {
return null;
}
};
const getString = (value) => {
return typeof value === 'string' ? value.trim() : '';
};
const getNumber = (value) => {
if (typeof value === 'number' && Number.isFinite(value)) return value;
if (typeof value === 'string' && value.trim()) {
const parsed = Number(value);
if (Number.isFinite(parsed)) return parsed;
}
return null;
};
const getStringArray = (value) => {
if (!Array.isArray(value)) return [];
return value
.map((item) => (typeof item === 'string' ? item.trim() : ''))
.filter(Boolean);
};
const getLanguageLabel = (language) => {
if (language === 'de') return 'German';
if (language === 'es') return 'Spanish';
return 'English';
};
const normalizeIdentifyResult = (raw, language) => {
const name = getString(raw.name);
const botanicalName = getString(raw.botanicalName);
const description = getString(raw.description);
const confidenceRaw = getNumber(raw.confidence);
const careInfoRaw = raw.careInfo;
if (!name || !botanicalName || !careInfoRaw || typeof careInfoRaw !== 'object' || Array.isArray(careInfoRaw)) {
return null;
}
const waterIntervalRaw = getNumber(careInfoRaw.waterIntervalDays);
const light = getString(careInfoRaw.light);
const temp = getString(careInfoRaw.temp);
if (waterIntervalRaw == null || !light || !temp) {
return null;
}
const fallbackDescription = language === 'de'
? `${name} wurde per KI erkannt. Pflegehinweise sind unten aufgefuehrt.`
: language === 'es'
? `${name} se detecto con IA. Debajo veras recomendaciones de cuidado.`
: `${name} was identified with AI. Care guidance is shown below.`;
return {
name,
botanicalName,
confidence: clamp(confidenceRaw == null ? 0.72 : confidenceRaw, 0.05, 0.99),
description: description || fallbackDescription,
careInfo: {
waterIntervalDays: Math.round(clamp(waterIntervalRaw, 1, 45)),
light,
temp,
},
};
};
const normalizeHealthAnalysis = (raw, language) => {
const scoreRaw = getNumber(raw.overallHealthScore);
const statusRaw = getString(raw.status);
const issuesRaw = raw.likelyIssues;
const actionsNowRaw = getStringArray(raw.actionsNow).slice(0, 8);
const plan7DaysRaw = getStringArray(raw.plan7Days).slice(0, 10);
if (scoreRaw == null || !statusRaw || !Array.isArray(issuesRaw)) {
return null;
}
const status = statusRaw === 'healthy' || statusRaw === 'watch' || statusRaw === 'critical'
? statusRaw
: 'watch';
const likelyIssues = issuesRaw
.map((entry) => {
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return null;
const title = getString(entry.title);
const details = getString(entry.details);
const confidenceRaw = getNumber(entry.confidence);
if (!title || !details || confidenceRaw == null) return null;
return {
title,
details,
confidence: clamp(confidenceRaw, 0.05, 0.99),
};
})
.filter(Boolean)
.slice(0, 4);
if (likelyIssues.length === 0 || actionsNowRaw.length < 2 || plan7DaysRaw.length < 2) {
const fallbackIssue = language === 'de'
? 'Die KI konnte keine stabilen Gesundheitsmerkmale extrahieren.'
: language === 'es'
? 'La IA no pudo extraer senales de salud estables.'
: 'AI could not extract stable health signals.';
return {
overallHealthScore: Math.round(clamp(scoreRaw, 0, 100)),
status,
likelyIssues: [
{
title: language === 'de'
? 'Analyse unsicher'
: language === 'es'
? 'Analisis incierto'
: 'Uncertain analysis',
confidence: 0.35,
details: fallbackIssue,
},
],
actionsNow: actionsNowRaw.length > 0
? actionsNowRaw
: [language === 'de' ? 'Neues, schaerferes Foto aufnehmen.' : language === 'es' ? 'Tomar una foto nueva y mas nitida.' : 'Capture a new, sharper photo.'],
plan7Days: plan7DaysRaw.length > 0
? plan7DaysRaw
: [language === 'de' ? 'In 2 Tagen erneut pruefen.' : language === 'es' ? 'Volver a revisar en 2 dias.' : 'Re-check in 2 days.'],
};
}
return {
overallHealthScore: Math.round(clamp(scoreRaw, 0, 100)),
status,
likelyIssues,
actionsNow: actionsNowRaw,
plan7Days: plan7DaysRaw,
};
};
const buildIdentifyPrompt = (language, mode) => {
const reviewInstruction = mode === 'review'
? 'Re-check your first hypothesis with stricter botanical accuracy and correct any mismatch.'
: 'Identify the most likely houseplant species from this image with conservative confidence.';
const nameLanguageInstruction = language === 'en'
? '- "name" must be an English common name only. Never return a German or other non-English common name. If no reliable English common name is known, use "botanicalName" as "name" instead of inventing or translating.'
: `- "name" must be strictly written in ${getLanguageLabel(language)}. If a reliable common name in that language is not known, use "botanicalName" as "name" instead of inventing a localized name.`;
return [
`${reviewInstruction}`,
'Return strict JSON only in this shape:',
'{"name":"...","botanicalName":"...","confidence":0.0,"description":"...","careInfo":{"waterIntervalDays":7,"light":"...","temp":"..."}}',
'Rules:',
nameLanguageInstruction,
`- "description" and "careInfo.light" must be written in ${getLanguageLabel(language)}.`,
'- "botanicalName" must use accepted Latin scientific naming and must not be invented or misspelled.',
'- If species is uncertain, prefer genus-level naming (for example: "Calathea sp.").',
'- "confidence" must be between 0 and 1.',
'- Keep confidence <= 0.55 when the image is ambiguous, blurred, or partially visible.',
'- "waterIntervalDays" must be an integer between 1 and 45.',
'- Do not include markdown, explanations, or extra keys.',
].join('\n');
};
const buildHealthPrompt = (language, plantContext) => {
const contextLines = plantContext
? [
'Plant context:',
`- name: ${plantContext.name || 'n/a'}`,
`- botanicalName: ${plantContext.botanicalName || 'n/a'}`,
`- care.light: ${plantContext.careInfo?.light || 'n/a'}`,
`- care.temp: ${plantContext.careInfo?.temp || 'n/a'}`,
`- care.waterIntervalDays: ${plantContext.careInfo?.waterIntervalDays || 'n/a'}`,
`- description: ${plantContext.description || 'n/a'}`,
]
: ['Plant context: not provided'];
return [
`You are an expert botanist and plant health diagnostician. Carefully examine every visible detail of this plant photo and produce a thorough, professional health assessment written in ${getLanguageLabel(language)}.`,
'',
'Inspect the following in detail: leaf color (yellowing, browning, bleaching, dark spots, necrosis), leaf texture (wilting, crispy edges, curling, drooping), stem condition (rot, soft spots, discoloration), soil surface (dry cracks, mold, pests, waterlogging signs), visible pests (spider mites, fungus gnats, scale insects, aphids, mealybugs), root health (if visible), pot size and drainage.',
'',
'Return strict JSON only in this exact shape:',
'{"overallHealthScore":72,"status":"watch","likelyIssues":[{"title":"...","confidence":0.64,"details":"..."}],"actionsNow":["..."],"plan7Days":["..."]}',
'',
'Rules:',
'- "overallHealthScore": integer 0100. 100=perfect health, 8099=minor cosmetic only, 6079=noticeable issues needing attention, 4059=significant stress, below 40=severe/critical.',
'- "status": exactly one of "healthy" (score>=80, no active threats), "watch" (score 5079, needs monitoring), "critical" (score<50, urgent action needed).',
'- "likelyIssues": 2 to 4 items, sorted by confidence descending. Each item:',
' - "title": concise issue name (e.g. "Overwatering / Root Rot Risk")',
' - "confidence": float 0.050.99 reflecting visual certainty',
' - "details": 24 sentence detailed explanation of what you observe visually, what causes it, and what happens if untreated. Be specific — mention leaf color, location, pattern.',
`- "actionsNow": 5 to 8 specific, actionable steps for the next 2448 hours. Each step must be a complete sentence with concrete instructions (e.g. amounts, durations, techniques). Written in ${getLanguageLabel(language)}.`,
`- "plan7Days": 7 to 10 day-by-day or milestone care steps for the coming week. Each step should specify timing and expected outcome. Written in ${getLanguageLabel(language)}.`,
'- All text fields must be written in the specified language. No markdown, no extra keys.',
...contextLines,
].join('\n');
};
const extractMessageContent = (payload) => {
const content = payload?.choices?.[0]?.message?.content;
if (typeof content === 'string') return content;
if (Array.isArray(content)) {
return content
.map((chunk) => (chunk && chunk.type === 'text' ? chunk.text || '' : ''))
.join('')
.trim();
}
return '';
};
const postChatCompletion = async ({ modelChain, messages, imageUri, temperature }) => {
if (!OPENAI_API_KEY) return null;
if (typeof fetch !== 'function') {
throw new Error('Global fetch is not available in this Node runtime.');
}
const attemptedModels = [];
for (const model of modelChain) {
attemptedModels.push(model);
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), OPENAI_TIMEOUT_MS);
try {
const body = {
model,
response_format: { type: 'json_object' },
messages,
};
if (typeof temperature === 'number') body.temperature = temperature;
const response = await fetch(OPENAI_CHAT_COMPLETIONS_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${OPENAI_API_KEY}`,
},
body: JSON.stringify(body),
signal: controller.signal,
});
if (!response.ok) {
const body = await response.text();
console.warn('OpenAI request HTTP error.', {
status: response.status,
model,
endpoint: OPENAI_CHAT_COMPLETIONS_URL,
image: summarizeImageUri(imageUri),
bodyPreview: body.slice(0, 300),
});
continue;
}
const payload = await response.json();
return { payload, modelUsed: model, attemptedModels };
} catch (error) {
const isTimeoutAbort = error instanceof Error && error.name === 'AbortError';
console.warn('OpenAI request failed.', {
model,
endpoint: OPENAI_CHAT_COMPLETIONS_URL,
timeoutMs: OPENAI_TIMEOUT_MS,
aborted: isTimeoutAbort,
error: toErrorMessage(error),
image: summarizeImageUri(imageUri),
});
continue;
} finally {
clearTimeout(timeout);
}
}
return { payload: null, modelUsed: null, attemptedModels };
};
const identifyPlant = async ({ imageUri, language, mode = 'primary', plan = 'free' }) => {
if (!OPENAI_API_KEY) return { result: null, modelUsed: null, attemptedModels: [] };
const modelChain = getScanModelChain(plan);
const completion = await postChatCompletion({
modelChain,
imageUri,
messages: [
{
role: 'system',
content: 'You are a plant identification assistant. Return strict JSON only.',
},
{
role: 'user',
content: [
{ type: 'text', text: buildIdentifyPrompt(language, mode) },
{ type: 'image_url', image_url: { url: imageUri } },
],
},
],
});
if (!completion?.payload) {
return {
result: null,
modelUsed: completion?.modelUsed || null,
attemptedModels: completion?.attemptedModels || [],
};
}
const content = extractMessageContent(completion.payload);
if (!content) {
console.warn('OpenAI identify returned empty content.', {
model: completion.modelUsed || modelChain[0],
mode,
image: summarizeImageUri(imageUri),
});
return { result: null, modelUsed: completion.modelUsed, attemptedModels: completion.attemptedModels };
}
const parsed = parseContentToJson(content);
if (!parsed) {
console.warn('OpenAI identify returned non-JSON content.', {
model: completion.modelUsed || modelChain[0],
mode,
preview: content.slice(0, 220),
});
return { result: null, modelUsed: completion.modelUsed, attemptedModels: completion.attemptedModels };
}
const normalized = normalizeIdentifyResult(parsed, language);
if (!normalized) {
console.warn('OpenAI identify JSON did not match schema.', {
model: completion.modelUsed || modelChain[0],
mode,
keys: Object.keys(parsed),
});
}
return { result: normalized, modelUsed: completion.modelUsed, attemptedModels: completion.attemptedModels };
};
const analyzePlantHealth = async ({ imageUri, language, plantContext }) => {
if (!OPENAI_API_KEY) return { analysis: null, modelUsed: null, attemptedModels: [] };
const completion = await postChatCompletion({
modelChain: OPENAI_HEALTH_MODEL_CHAIN,
imageUri,
temperature: 0,
messages: [
{
role: 'system',
content: 'You are a plant health diagnosis assistant. Return strict JSON only.',
},
{
role: 'user',
content: [
{ type: 'text', text: buildHealthPrompt(language, plantContext) },
{ type: 'image_url', image_url: { url: imageUri } },
],
},
],
});
if (!completion?.payload) {
return {
analysis: null,
modelUsed: completion?.modelUsed || null,
attemptedModels: completion?.attemptedModels || [],
};
}
const content = extractMessageContent(completion.payload);
if (!content) {
console.warn('OpenAI health returned empty content.', {
model: completion.modelUsed || OPENAI_HEALTH_MODEL_CHAIN[0],
image: summarizeImageUri(imageUri),
});
return { analysis: null, modelUsed: completion.modelUsed, attemptedModels: completion.attemptedModels };
}
const parsed = parseContentToJson(content);
if (!parsed) {
console.warn('OpenAI health returned non-JSON content.', {
model: completion.modelUsed || OPENAI_HEALTH_MODEL_CHAIN[0],
preview: content.slice(0, 220),
});
return { analysis: null, modelUsed: completion.modelUsed, attemptedModels: completion.attemptedModels };
}
return {
analysis: normalizeHealthAnalysis(parsed, language),
modelUsed: completion.modelUsed,
attemptedModels: completion.attemptedModels,
};
};
module.exports = {
analyzePlantHealth,
buildIdentifyPrompt,
getHealthModel: () => OPENAI_HEALTH_MODEL_CHAIN[0],
getScanModel: (plan = 'free') => getScanModelChain(plan)[0],
identifyPlant,
isConfigured: () => Boolean(OPENAI_API_KEY),
normalizeIdentifyResult,
};

File diff suppressed because it is too large Load Diff

View File

@@ -1,131 +1,131 @@
const clamp = (value, min, max) => {
return Math.min(max, Math.max(min, value));
};
const normalizeText = (value) => {
return String(value || '')
.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/\s+/g, ' ')
.trim();
};
const GERMAN_COMMON_NAME_HINTS = [
'weihnachtsstern',
'weinachtsstern',
'einblatt',
'fensterblatt',
'korbmarante',
'glucksfeder',
'gluecksfeder',
'efeutute',
'drachenbaum',
'gummibaum',
'geigenfeige',
'bogenhanf',
'yucca palme',
'gluckskastanie',
'glueckskastanie',
];
const isLikelyGermanCommonName = (value) => {
const raw = String(value || '').trim();
if (!raw) return false;
if (/[äöüß]/i.test(raw)) return true;
const normalized = normalizeText(raw).replace(/[^a-z0-9 ]+/g, ' ');
if (!normalized) return false;
if (/\b(der|die|das|ein|eine)\b/.test(normalized)) return true;
return GERMAN_COMMON_NAME_HINTS.some((hint) => normalized.includes(hint));
};
const isLikelyBotanicalName = (value, botanicalName) => {
const raw = String(value || '').trim();
const botanicalRaw = String(botanicalName || '').trim();
if (!raw) return false;
if (normalizeText(raw) === normalizeText(botanicalRaw)) return true;
return /^[A-Z][a-z-]+(?:\s[a-z.-]+){1,2}$/.test(raw);
};
const findCatalogMatch = (aiResult, entries) => {
if (!aiResult || !Array.isArray(entries) || entries.length === 0) return null;
const aiBotanical = normalizeText(aiResult.botanicalName);
const aiName = normalizeText(aiResult.name);
if (!aiBotanical && !aiName) return null;
const byExactBotanical = entries.find((entry) => normalizeText(entry.botanicalName) === aiBotanical);
if (byExactBotanical) return byExactBotanical;
const byExactName = entries.find((entry) => normalizeText(entry.name) === aiName);
if (byExactName) return byExactName;
if (aiBotanical) {
const aiGenus = aiBotanical.split(' ')[0];
if (aiGenus) {
const byGenus = entries.find((entry) => normalizeText(entry.botanicalName).startsWith(`${aiGenus} `));
if (byGenus) return byGenus;
}
}
const byContains = entries.find((entry) => {
const plantName = normalizeText(entry.name);
const botanical = normalizeText(entry.botanicalName);
return (aiName && (plantName.includes(aiName) || aiName.includes(plantName)))
|| (aiBotanical && (botanical.includes(aiBotanical) || aiBotanical.includes(botanical)));
});
if (byContains) return byContains;
return null;
};
const shouldUseCatalogNameOverride = ({ language, aiResult, matchedEntry }) => {
const catalogName = String(matchedEntry?.name || '').trim();
if (!catalogName) return false;
if (language !== 'en') return true;
if (isLikelyBotanicalName(catalogName, matchedEntry?.botanicalName || aiResult?.botanicalName)) {
return true;
}
if (isLikelyGermanCommonName(catalogName)) {
return false;
}
return true;
};
const applyCatalogGrounding = (aiResult, catalogEntries, language = 'en') => {
const matchedEntry = findCatalogMatch(aiResult, catalogEntries);
if (!matchedEntry) {
return { grounded: false, result: aiResult };
}
const useCatalogName = shouldUseCatalogNameOverride({ language, aiResult, matchedEntry });
return {
grounded: true,
result: {
name: useCatalogName ? matchedEntry.name || aiResult.name : aiResult.name,
botanicalName: matchedEntry.botanicalName || aiResult.botanicalName,
confidence: clamp(Math.max(aiResult.confidence || 0.6, 0.78), 0.05, 0.99),
description: aiResult.description || matchedEntry.description || '',
careInfo: {
waterIntervalDays: Math.max(1, Number(matchedEntry.careInfo?.waterIntervalDays) || Number(aiResult.careInfo?.waterIntervalDays) || 7),
light: matchedEntry.careInfo?.light || aiResult.careInfo?.light || 'Unknown',
temp: matchedEntry.careInfo?.temp || aiResult.careInfo?.temp || 'Unknown',
},
},
};
};
module.exports = {
applyCatalogGrounding,
findCatalogMatch,
isLikelyGermanCommonName,
normalizeText,
shouldUseCatalogNameOverride,
};
const clamp = (value, min, max) => {
return Math.min(max, Math.max(min, value));
};
const normalizeText = (value) => {
return String(value || '')
.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/\s+/g, ' ')
.trim();
};
const GERMAN_COMMON_NAME_HINTS = [
'weihnachtsstern',
'weinachtsstern',
'einblatt',
'fensterblatt',
'korbmarante',
'glucksfeder',
'gluecksfeder',
'efeutute',
'drachenbaum',
'gummibaum',
'geigenfeige',
'bogenhanf',
'yucca palme',
'gluckskastanie',
'glueckskastanie',
];
const isLikelyGermanCommonName = (value) => {
const raw = String(value || '').trim();
if (!raw) return false;
if (/[äöüß]/i.test(raw)) return true;
const normalized = normalizeText(raw).replace(/[^a-z0-9 ]+/g, ' ');
if (!normalized) return false;
if (/\b(der|die|das|ein|eine)\b/.test(normalized)) return true;
return GERMAN_COMMON_NAME_HINTS.some((hint) => normalized.includes(hint));
};
const isLikelyBotanicalName = (value, botanicalName) => {
const raw = String(value || '').trim();
const botanicalRaw = String(botanicalName || '').trim();
if (!raw) return false;
if (normalizeText(raw) === normalizeText(botanicalRaw)) return true;
return /^[A-Z][a-z-]+(?:\s[a-z.-]+){1,2}$/.test(raw);
};
const findCatalogMatch = (aiResult, entries) => {
if (!aiResult || !Array.isArray(entries) || entries.length === 0) return null;
const aiBotanical = normalizeText(aiResult.botanicalName);
const aiName = normalizeText(aiResult.name);
if (!aiBotanical && !aiName) return null;
const byExactBotanical = entries.find((entry) => normalizeText(entry.botanicalName) === aiBotanical);
if (byExactBotanical) return byExactBotanical;
const byExactName = entries.find((entry) => normalizeText(entry.name) === aiName);
if (byExactName) return byExactName;
if (aiBotanical) {
const aiGenus = aiBotanical.split(' ')[0];
if (aiGenus) {
const byGenus = entries.find((entry) => normalizeText(entry.botanicalName).startsWith(`${aiGenus} `));
if (byGenus) return byGenus;
}
}
const byContains = entries.find((entry) => {
const plantName = normalizeText(entry.name);
const botanical = normalizeText(entry.botanicalName);
return (aiName && (plantName.includes(aiName) || aiName.includes(plantName)))
|| (aiBotanical && (botanical.includes(aiBotanical) || aiBotanical.includes(botanical)));
});
if (byContains) return byContains;
return null;
};
const shouldUseCatalogNameOverride = ({ language, aiResult, matchedEntry }) => {
const catalogName = String(matchedEntry?.name || '').trim();
if (!catalogName) return false;
if (language !== 'en') return true;
if (isLikelyBotanicalName(catalogName, matchedEntry?.botanicalName || aiResult?.botanicalName)) {
return true;
}
if (isLikelyGermanCommonName(catalogName)) {
return false;
}
return true;
};
const applyCatalogGrounding = (aiResult, catalogEntries, language = 'en') => {
const matchedEntry = findCatalogMatch(aiResult, catalogEntries);
if (!matchedEntry) {
return { grounded: false, result: aiResult };
}
const useCatalogName = shouldUseCatalogNameOverride({ language, aiResult, matchedEntry });
return {
grounded: true,
result: {
name: useCatalogName ? matchedEntry.name || aiResult.name : aiResult.name,
botanicalName: matchedEntry.botanicalName || aiResult.botanicalName,
confidence: clamp(Math.max(aiResult.confidence || 0.6, 0.78), 0.05, 0.99),
description: aiResult.description || matchedEntry.description || '',
careInfo: {
waterIntervalDays: Math.max(1, Number(matchedEntry.careInfo?.waterIntervalDays) || Number(aiResult.careInfo?.waterIntervalDays) || 7),
light: matchedEntry.careInfo?.light || aiResult.careInfo?.light || 'Unknown',
temp: matchedEntry.careInfo?.temp || aiResult.careInfo?.temp || 'Unknown',
},
},
};
};
module.exports = {
applyCatalogGrounding,
findCatalogMatch,
isLikelyGermanCommonName,
normalizeText,
shouldUseCatalogNameOverride,
};

View File

@@ -1,402 +1,402 @@
const SEARCH_INTENT_CONFIG = {
intents: {
easy: {
aliases: [
'easy',
'easy care',
'easy plant',
'easy plants',
'easy to care',
'beginner',
'beginner plant',
'beginner plants',
'low maintenance',
'hard to kill',
'starter plant',
'starter plants',
'pflegearm',
'pflegeleicht',
'anfanger',
'anfangerpflanze',
'anfangerpflanzen',
'einfach',
'unkompliziert',
'facil cuidado',
'facil',
'principiante',
'planta facil',
'planta resistente',
],
entryHints: [
'easy',
'pflegeleicht',
'robust',
'resilient',
'hardy',
'low maintenance',
'beginner',
'facil',
'resistente',
'uncomplicated',
],
},
low_light: {
aliases: [
'low light',
'dark corner',
'dark room',
'office plant',
'office',
'windowless room',
'shade',
'shady',
'indirect light',
'little light',
'wenig licht',
'dunkle ecke',
'buero',
'buro',
'dunkel',
'schatten',
'halbschatten',
'poca luz',
'oficina',
'rincon oscuro',
'sombra',
],
lightHints: [
'low light',
'low to full light',
'shade',
'partial shade',
'indirect',
'indirect bright',
'bright indirect',
'wenig licht',
'schatten',
'halbschatten',
'indirekt',
'poca luz',
'sombra',
'luz indirecta',
],
},
pet_friendly: {
aliases: [
'pet friendly',
'pet-safe',
'pet safe',
'safe for cats',
'safe for dogs',
'cat safe',
'dog safe',
'non toxic',
'non-toxic',
'haustierfreundlich',
'tierfreundlich',
'katzensicher',
'hundefreundlich',
'mascota',
'pet friendly plant',
'segura para gatos',
'segura para perros',
'no toxica',
'no tóxica',
],
entryHints: [
'pet friendly',
'safe for pets',
'safe for cats',
'safe for dogs',
'tierfreundlich',
'haustierfreundlich',
'mascota',
],
},
air_purifier: {
aliases: [
'air purifier',
'air purifying',
'clean air',
'cleaner air',
'air cleaning',
'air freshening',
'luftreiniger',
'luftreinigend',
'reinigt luft',
'purificador',
'aire limpio',
'purifica aire',
],
entryHints: [
'air purifier',
'air purifying',
'clean air',
'luftreiniger',
'purificador',
],
},
flowering: {
aliases: [
'flowering',
'flowers',
'blooms',
'in bloom',
'bluhend',
'bluht',
'blumen',
'con flores',
'floracion',
],
entryHints: [
'flowering',
'blooms',
'flower',
'bluh',
'flor',
],
},
succulent: {
aliases: [
'succulent',
'succulents',
'cactus',
'cactus-like',
'drought tolerant',
'sukkulente',
'sukkulenten',
'trockenheitsvertraglich',
'trockenheitsvertraeglich',
'suculenta',
'suculentas',
],
entryHints: [
'succulent',
'cactus',
'drought tolerant',
'sukkulent',
'suculenta',
],
},
bright_light: {
aliases: [
'bright light',
'bright room',
'bright spot',
'east window',
'west window',
'sunny room',
'helles licht',
'hell',
'lichtreich',
'fensterplatz',
'mucha luz',
'luz brillante',
],
lightHints: [
'bright light',
'bright indirect',
'bright',
'helles licht',
'helles indirektes licht',
'luz brillante',
],
},
sun: {
aliases: [
'full sun',
'sun',
'sunny window',
'direct sun',
'south window',
'south facing window',
'volle sonne',
'sonnig',
'direkte sonne',
'fenster sud',
'fenster sued',
'fenster süd',
'ventana soleada',
'sol directo',
],
lightHints: [
'full sun',
'sunny',
'direct sun',
'volles sonnenlicht',
'sonnig',
'sol directo',
],
},
high_humidity: {
aliases: [
'high humidity',
'humid',
'bathroom plant',
'bathroom',
'shower room',
'humid room',
'tropical humidity',
'hohe luftfeuchtigkeit',
'feucht',
'badezimmer',
'dusche',
'luftfeucht',
'humedad alta',
'bano',
'baño',
],
entryHints: [
'high humidity',
'humidity',
'humid',
'hohe luftfeuchtigkeit',
'luftfeuchtigkeit',
'humedad alta',
],
},
hanging: {
aliases: [
'hanging',
'trailing',
'hanging basket',
'shelf plant',
'vine plant',
'cascading',
'hangend',
'ampel',
'rankend',
'colgante',
'planta colgante',
],
entryHints: [
'hanging',
'trailing',
'vine',
'hang',
'colgante',
],
},
patterned: {
aliases: [
'patterned',
'patterned leaves',
'striped',
'variegated',
'spotted',
'decorative leaves',
'fancy leaves',
'gemustert',
'muster',
'gestreift',
'bunt',
'variegada',
'rayada',
],
entryHints: [
'patterned',
'striped',
'variegated',
'spotted',
'gemustert',
'gestreift',
],
},
tree: {
aliases: [
'tree',
'indoor tree',
'small tree',
'floor tree',
'zimmerbaum',
'baum',
'arbol',
'árbol',
],
entryHints: [
'tree',
'baum',
'arbol',
],
},
large: {
aliases: [
'large',
'big plant',
'tall plant',
'statement plant',
'floor plant',
'oversized plant',
'gross',
'groß',
'grosse pflanze',
'hohe pflanze',
'planta grande',
'planta alta',
],
entryHints: [
'large',
'big',
'tall',
'gross',
'groß',
'grande',
],
},
medicinal: {
aliases: [
'medicinal',
'healing plant',
'herb',
'kitchen herb',
'tea herb',
'apothecary plant',
'heilpflanze',
'heilkraut',
'kraut',
'medicinal plant',
'medicinal herb',
'medicinales',
'hierba',
'hierba medicinal',
],
entryHints: [
'medicinal',
'herb',
'heil',
'kraut',
'hierba',
],
},
},
noiseTokens: [
'plant',
'plants',
'pflanze',
'pflanzen',
'planta',
'plantas',
'for',
'fur',
'fuer',
'para',
'mit',
'with',
'and',
'und',
'y',
'the',
'der',
'die',
'das',
'el',
'la',
'de',
'a',
'an',
],
};
module.exports = {
SEARCH_INTENT_CONFIG,
};
const SEARCH_INTENT_CONFIG = {
intents: {
easy: {
aliases: [
'easy',
'easy care',
'easy plant',
'easy plants',
'easy to care',
'beginner',
'beginner plant',
'beginner plants',
'low maintenance',
'hard to kill',
'starter plant',
'starter plants',
'pflegearm',
'pflegeleicht',
'anfanger',
'anfangerpflanze',
'anfangerpflanzen',
'einfach',
'unkompliziert',
'facil cuidado',
'facil',
'principiante',
'planta facil',
'planta resistente',
],
entryHints: [
'easy',
'pflegeleicht',
'robust',
'resilient',
'hardy',
'low maintenance',
'beginner',
'facil',
'resistente',
'uncomplicated',
],
},
low_light: {
aliases: [
'low light',
'dark corner',
'dark room',
'office plant',
'office',
'windowless room',
'shade',
'shady',
'indirect light',
'little light',
'wenig licht',
'dunkle ecke',
'buero',
'buro',
'dunkel',
'schatten',
'halbschatten',
'poca luz',
'oficina',
'rincon oscuro',
'sombra',
],
lightHints: [
'low light',
'low to full light',
'shade',
'partial shade',
'indirect',
'indirect bright',
'bright indirect',
'wenig licht',
'schatten',
'halbschatten',
'indirekt',
'poca luz',
'sombra',
'luz indirecta',
],
},
pet_friendly: {
aliases: [
'pet friendly',
'pet-safe',
'pet safe',
'safe for cats',
'safe for dogs',
'cat safe',
'dog safe',
'non toxic',
'non-toxic',
'haustierfreundlich',
'tierfreundlich',
'katzensicher',
'hundefreundlich',
'mascota',
'pet friendly plant',
'segura para gatos',
'segura para perros',
'no toxica',
'no tóxica',
],
entryHints: [
'pet friendly',
'safe for pets',
'safe for cats',
'safe for dogs',
'tierfreundlich',
'haustierfreundlich',
'mascota',
],
},
air_purifier: {
aliases: [
'air purifier',
'air purifying',
'clean air',
'cleaner air',
'air cleaning',
'air freshening',
'luftreiniger',
'luftreinigend',
'reinigt luft',
'purificador',
'aire limpio',
'purifica aire',
],
entryHints: [
'air purifier',
'air purifying',
'clean air',
'luftreiniger',
'purificador',
],
},
flowering: {
aliases: [
'flowering',
'flowers',
'blooms',
'in bloom',
'bluhend',
'bluht',
'blumen',
'con flores',
'floracion',
],
entryHints: [
'flowering',
'blooms',
'flower',
'bluh',
'flor',
],
},
succulent: {
aliases: [
'succulent',
'succulents',
'cactus',
'cactus-like',
'drought tolerant',
'sukkulente',
'sukkulenten',
'trockenheitsvertraglich',
'trockenheitsvertraeglich',
'suculenta',
'suculentas',
],
entryHints: [
'succulent',
'cactus',
'drought tolerant',
'sukkulent',
'suculenta',
],
},
bright_light: {
aliases: [
'bright light',
'bright room',
'bright spot',
'east window',
'west window',
'sunny room',
'helles licht',
'hell',
'lichtreich',
'fensterplatz',
'mucha luz',
'luz brillante',
],
lightHints: [
'bright light',
'bright indirect',
'bright',
'helles licht',
'helles indirektes licht',
'luz brillante',
],
},
sun: {
aliases: [
'full sun',
'sun',
'sunny window',
'direct sun',
'south window',
'south facing window',
'volle sonne',
'sonnig',
'direkte sonne',
'fenster sud',
'fenster sued',
'fenster süd',
'ventana soleada',
'sol directo',
],
lightHints: [
'full sun',
'sunny',
'direct sun',
'volles sonnenlicht',
'sonnig',
'sol directo',
],
},
high_humidity: {
aliases: [
'high humidity',
'humid',
'bathroom plant',
'bathroom',
'shower room',
'humid room',
'tropical humidity',
'hohe luftfeuchtigkeit',
'feucht',
'badezimmer',
'dusche',
'luftfeucht',
'humedad alta',
'bano',
'baño',
],
entryHints: [
'high humidity',
'humidity',
'humid',
'hohe luftfeuchtigkeit',
'luftfeuchtigkeit',
'humedad alta',
],
},
hanging: {
aliases: [
'hanging',
'trailing',
'hanging basket',
'shelf plant',
'vine plant',
'cascading',
'hangend',
'ampel',
'rankend',
'colgante',
'planta colgante',
],
entryHints: [
'hanging',
'trailing',
'vine',
'hang',
'colgante',
],
},
patterned: {
aliases: [
'patterned',
'patterned leaves',
'striped',
'variegated',
'spotted',
'decorative leaves',
'fancy leaves',
'gemustert',
'muster',
'gestreift',
'bunt',
'variegada',
'rayada',
],
entryHints: [
'patterned',
'striped',
'variegated',
'spotted',
'gemustert',
'gestreift',
],
},
tree: {
aliases: [
'tree',
'indoor tree',
'small tree',
'floor tree',
'zimmerbaum',
'baum',
'arbol',
'árbol',
],
entryHints: [
'tree',
'baum',
'arbol',
],
},
large: {
aliases: [
'large',
'big plant',
'tall plant',
'statement plant',
'floor plant',
'oversized plant',
'gross',
'groß',
'grosse pflanze',
'hohe pflanze',
'planta grande',
'planta alta',
],
entryHints: [
'large',
'big',
'tall',
'gross',
'groß',
'grande',
],
},
medicinal: {
aliases: [
'medicinal',
'healing plant',
'herb',
'kitchen herb',
'tea herb',
'apothecary plant',
'heilpflanze',
'heilkraut',
'kraut',
'medicinal plant',
'medicinal herb',
'medicinales',
'hierba',
'hierba medicinal',
],
entryHints: [
'medicinal',
'herb',
'heil',
'kraut',
'hierba',
],
},
},
noiseTokens: [
'plant',
'plants',
'pflanze',
'pflanzen',
'planta',
'plantas',
'for',
'fur',
'fuer',
'para',
'mit',
'with',
'and',
'und',
'y',
'the',
'der',
'die',
'das',
'el',
'la',
'de',
'a',
'an',
],
};
module.exports = {
SEARCH_INTENT_CONFIG,
};

View File

@@ -1,86 +1,86 @@
const fs = require('fs');
const path = require('path');
const sqlite3 = require('sqlite3').verbose();
const getDefaultDbPath = () => {
return process.env.PLANT_DB_PATH || path.join(__dirname, '..', 'data', 'greenlns.sqlite');
};
const ensureDbDirectory = (dbPath) => {
const directory = path.dirname(dbPath);
fs.mkdirSync(directory, { recursive: true });
};
const openDatabase = (dbPath = getDefaultDbPath()) => {
ensureDbDirectory(dbPath);
return new Promise((resolve, reject) => {
const db = new sqlite3.Database(dbPath, (error) => {
if (error) {
reject(error);
return;
}
resolve(db);
});
});
};
const closeDatabase = (db) => {
return new Promise((resolve, reject) => {
db.close((error) => {
if (error) {
reject(error);
return;
}
resolve();
});
});
};
const run = (db, sql, params = []) => {
return new Promise((resolve, reject) => {
db.run(sql, params, function onRun(error) {
if (error) {
reject(error);
return;
}
resolve({
lastId: this.lastID,
changes: this.changes,
});
});
});
};
const get = (db, sql, params = []) => {
return new Promise((resolve, reject) => {
db.get(sql, params, (error, row) => {
if (error) {
reject(error);
return;
}
resolve(row || null);
});
});
};
const all = (db, sql, params = []) => {
return new Promise((resolve, reject) => {
db.all(sql, params, (error, rows) => {
if (error) {
reject(error);
return;
}
resolve(rows || []);
});
});
};
module.exports = {
all,
closeDatabase,
get,
getDefaultDbPath,
openDatabase,
run,
};
const fs = require('fs');
const path = require('path');
const sqlite3 = require('sqlite3').verbose();
const getDefaultDbPath = () => {
return process.env.PLANT_DB_PATH || path.join(__dirname, '..', 'data', 'greenlns.sqlite');
};
const ensureDbDirectory = (dbPath) => {
const directory = path.dirname(dbPath);
fs.mkdirSync(directory, { recursive: true });
};
const openDatabase = (dbPath = getDefaultDbPath()) => {
ensureDbDirectory(dbPath);
return new Promise((resolve, reject) => {
const db = new sqlite3.Database(dbPath, (error) => {
if (error) {
reject(error);
return;
}
resolve(db);
});
});
};
const closeDatabase = (db) => {
return new Promise((resolve, reject) => {
db.close((error) => {
if (error) {
reject(error);
return;
}
resolve();
});
});
};
const run = (db, sql, params = []) => {
return new Promise((resolve, reject) => {
db.run(sql, params, function onRun(error) {
if (error) {
reject(error);
return;
}
resolve({
lastId: this.lastID,
changes: this.changes,
});
});
});
};
const get = (db, sql, params = []) => {
return new Promise((resolve, reject) => {
db.get(sql, params, (error, row) => {
if (error) {
reject(error);
return;
}
resolve(row || null);
});
});
};
const all = (db, sql, params = []) => {
return new Promise((resolve, reject) => {
db.all(sql, params, (error, rows) => {
if (error) {
reject(error);
return;
}
resolve(rows || []);
});
});
};
module.exports = {
all,
closeDatabase,
get,
getDefaultDbPath,
openDatabase,
run,
};

View File

@@ -1,72 +1,72 @@
const Minio = require('minio');
const crypto = require('crypto');
const getTrimmedEnv = (name, fallback = '') => String(process.env[name] ?? fallback).trim();
const MINIO_ENDPOINT = getTrimmedEnv('MINIO_ENDPOINT');
const MINIO_PORT = Number(process.env.MINIO_PORT || 9000);
const MINIO_USE_SSL = process.env.MINIO_USE_SSL === 'true';
const MINIO_ACCESS_KEY = getTrimmedEnv('MINIO_ACCESS_KEY');
const MINIO_SECRET_KEY = getTrimmedEnv('MINIO_SECRET_KEY');
const MINIO_BUCKET = getTrimmedEnv('MINIO_BUCKET', 'plant-images') || 'plant-images';
const isStorageConfigured = () => Boolean(MINIO_ENDPOINT && MINIO_ACCESS_KEY && MINIO_SECRET_KEY);
const getMinioPublicUrl = () =>
getTrimmedEnv('MINIO_PUBLIC_URL', `http://${MINIO_ENDPOINT}:${MINIO_PORT}`).replace(/\/$/, '');
const getClient = () => {
if (!isStorageConfigured()) {
throw new Error('Image storage is not configured.');
}
return new Minio.Client({
endPoint: MINIO_ENDPOINT,
port: MINIO_PORT,
useSSL: MINIO_USE_SSL,
accessKey: MINIO_ACCESS_KEY,
secretKey: MINIO_SECRET_KEY,
});
};
const ensureStorageBucket = async () => {
const client = getClient();
const exists = await client.bucketExists(MINIO_BUCKET);
if (!exists) {
await client.makeBucket(MINIO_BUCKET);
const policy = JSON.stringify({
Version: '2012-10-17',
Statement: [
{
Effect: 'Allow',
Principal: { AWS: ['*'] },
Action: ['s3:GetObject'],
Resource: [`arn:aws:s3:::${MINIO_BUCKET}/*`],
},
],
});
await client.setBucketPolicy(MINIO_BUCKET, policy);
console.log(`MinIO bucket '${MINIO_BUCKET}' created with public read policy.`);
}
};
const uploadImage = async (base64Data, contentType = 'image/jpeg') => {
const client = getClient();
const rawExtension = contentType.split('/')[1] || 'jpg';
const extension = rawExtension === 'jpeg' ? 'jpg' : rawExtension;
const filename = `${Date.now()}-${crypto.randomBytes(8).toString('hex')}.${extension}`;
const buffer = Buffer.from(base64Data, 'base64');
await client.putObject(MINIO_BUCKET, filename, buffer, buffer.length, {
'Content-Type': contentType,
});
const url = `${getMinioPublicUrl()}/${MINIO_BUCKET}/${filename}`;
return { url, filename };
};
module.exports = {
ensureStorageBucket,
uploadImage,
isStorageConfigured,
};
const Minio = require('minio');
const crypto = require('crypto');
const getTrimmedEnv = (name, fallback = '') => String(process.env[name] ?? fallback).trim();
const MINIO_ENDPOINT = getTrimmedEnv('MINIO_ENDPOINT');
const MINIO_PORT = Number(process.env.MINIO_PORT || 9000);
const MINIO_USE_SSL = process.env.MINIO_USE_SSL === 'true';
const MINIO_ACCESS_KEY = getTrimmedEnv('MINIO_ACCESS_KEY');
const MINIO_SECRET_KEY = getTrimmedEnv('MINIO_SECRET_KEY');
const MINIO_BUCKET = getTrimmedEnv('MINIO_BUCKET', 'plant-images') || 'plant-images';
const isStorageConfigured = () => Boolean(MINIO_ENDPOINT && MINIO_ACCESS_KEY && MINIO_SECRET_KEY);
const getMinioPublicUrl = () =>
getTrimmedEnv('MINIO_PUBLIC_URL', `http://${MINIO_ENDPOINT}:${MINIO_PORT}`).replace(/\/$/, '');
const getClient = () => {
if (!isStorageConfigured()) {
throw new Error('Image storage is not configured.');
}
return new Minio.Client({
endPoint: MINIO_ENDPOINT,
port: MINIO_PORT,
useSSL: MINIO_USE_SSL,
accessKey: MINIO_ACCESS_KEY,
secretKey: MINIO_SECRET_KEY,
});
};
const ensureStorageBucket = async () => {
const client = getClient();
const exists = await client.bucketExists(MINIO_BUCKET);
if (!exists) {
await client.makeBucket(MINIO_BUCKET);
const policy = JSON.stringify({
Version: '2012-10-17',
Statement: [
{
Effect: 'Allow',
Principal: { AWS: ['*'] },
Action: ['s3:GetObject'],
Resource: [`arn:aws:s3:::${MINIO_BUCKET}/*`],
},
],
});
await client.setBucketPolicy(MINIO_BUCKET, policy);
console.log(`MinIO bucket '${MINIO_BUCKET}' created with public read policy.`);
}
};
const uploadImage = async (base64Data, contentType = 'image/jpeg') => {
const client = getClient();
const rawExtension = contentType.split('/')[1] || 'jpg';
const extension = rawExtension === 'jpeg' ? 'jpg' : rawExtension;
const filename = `${Date.now()}-${crypto.randomBytes(8).toString('hex')}.${extension}`;
const buffer = Buffer.from(base64Data, 'base64');
await client.putObject(MINIO_BUCKET, filename, buffer, buffer.length, {
'Content-Type': contentType,
});
const url = `${getMinioPublicUrl()}/${MINIO_BUCKET}/${filename}`;
return { url, filename };
};
module.exports = {
ensureStorageBucket,
uploadImage,
isStorageConfigured,
};

6098
server/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,25 +1,25 @@
{
"name": "server",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"start": "node index.js",
"rebuild:batches": "node scripts/rebuild-from-batches.js",
"diagnostics": "node scripts/plant-diagnostics.js",
"images:download": "node scripts/download-plant-images.js",
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"cors": "^2.8.6",
"dotenv": "^17.3.1",
"express": "^5.2.1",
"minio": "^8.0.5",
"sharp": "^0.34.5",
"sqlite3": "^5.1.7",
"stripe": "^20.3.1"
}
}
{
"name": "server",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"start": "node index.js",
"rebuild:batches": "node scripts/rebuild-from-batches.js",
"diagnostics": "node scripts/plant-diagnostics.js",
"images:download": "node scripts/download-plant-images.js",
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"cors": "^2.8.6",
"dotenv": "^17.3.1",
"express": "^5.2.1",
"minio": "^8.0.5",
"sharp": "^0.34.5",
"sqlite3": "^5.1.7",
"stripe": "^20.3.1"
}
}

View File

@@ -1 +1 @@

File diff suppressed because it is too large Load Diff

View File

@@ -1,387 +1,387 @@
{
"Stapelia grandiflora": "https://upload.wikimedia.org/wikipedia/commons/6/67/Carrion_Plant_%28Stapelia_grandiflora%29_2.jpg",
"Saintpaulia ionantha": "https://upload.wikimedia.org/wikipedia/commons/thumb/1/19/2007-04-20Saintpaulia_ionantha04.jpg/1280px-2007-04-20Saintpaulia_ionantha04.jpg",
"Agave americana": "https://upload.wikimedia.org/wikipedia/commons/thumb/3/3b/Agave_July_2011-1.jpg/1280px-Agave_July_2011-1.jpg",
"Alocasia amazonica": "https://upload.wikimedia.org/wikipedia/commons/0/0e/Alocasia_x_amazonica_a2.jpg",
"Arnica montana": "https://upload.wikimedia.org/wikipedia/commons/thumb/1/17/Arnica_montana_-_K%C3%B6hler%E2%80%93s_Medizinal-Pflanzen-015.jpg/1280px-Arnica_montana_-_K%C3%B6hler%E2%80%93s_Medizinal-Pflanzen-015.jpg",
"Valeriana officinalis": "https://upload.wikimedia.org/wikipedia/commons/1/17/Valeriana_officinalis_-_K%C3%B6hler%E2%80%93s_Medizinal-Pflanzen-143.jpg",
"Bambusa vulgaris": "https://upload.wikimedia.org/wikipedia/commons/thumb/5/51/Bambusa_vulgaris_%28Dominica%29.jpg/1280px-Bambusa_vulgaris_%28Dominica%29.jpg",
"Musa acuminata": "https://upload.wikimedia.org/wikipedia/commons/thumb/2/2e/Musa_acuminata_kz01.jpg/1280px-Musa_acuminata_kz01.jpg",
"Chamaedorea elegans": "https://upload.wikimedia.org/wikipedia/commons/thumb/f/f5/Chamaedorea_elegans.jpg/1280px-Chamaedorea_elegans.jpg",
"Senecio serpens": "https://upload.wikimedia.org/wikipedia/commons/2/2b/Senecio_serpens.jpg",
"Phlebodium aureum": "https://upload.wikimedia.org/wikipedia/commons/thumb/2/2d/Starr_050107-2831_Phlebodium_aureum.jpg/1280px-Starr_050107-2831_Phlebodium_aureum.jpg",
"Canna indica": "https://upload.wikimedia.org/wikipedia/commons/9/99/Canna_indica_%28wild_species%29_flowers.JPG",
"Geranium sanguineum": "https://upload.wikimedia.org/wikipedia/commons/thumb/e/e2/Geranium_sanguineum_-_verev_kurereha.jpg/1280px-Geranium_sanguineum_-_verev_kurereha.jpg",
"Ficus retusa": "https://upload.wikimedia.org/wikipedia/commons/thumb/9/9d/Ficus_retusa_2zz.jpg/1280px-Ficus_retusa_2zz.jpg",
"Bougainvillea spectabilis": "https://upload.wikimedia.org/wikipedia/commons/thumb/f/f8/Starr_030418-0058_Bougainvillea_spectabilis.jpg/1280px-Starr_030418-0058_Bougainvillea_spectabilis.jpg",
"Caladium bicolor": "https://upload.wikimedia.org/wikipedia/commons/thumb/b/b8/Caladium_bicolor_%27Florida_Sweetheart%27_Plant_2220px.jpg/1280px-Caladium_bicolor_%27Florida_Sweetheart%27_Plant_2220px.jpg",
"Calibrachoa hybrida": "https://upload.wikimedia.org/wikipedia/commons/thumb/e/e2/Basket_of_Calibrachoa_parviflora_by_A_-_2020-06-18.jpg/1280px-Basket_of_Calibrachoa_parviflora_by_A_-_2020-06-18.jpg",
"Callisia repens": "https://upload.wikimedia.org/wikipedia/commons/thumb/e/e6/Turtle_Vine_%28Callisia_repens%29_1.jpg/1280px-Turtle_Vine_%28Callisia_repens%29_1.jpg",
"Cattleya labiata": "https://upload.wikimedia.org/wikipedia/commons/9/97/Cattleya_labiata_2.jpg",
"Capsicum annuum": "https://upload.wikimedia.org/wikipedia/commons/thumb/c/cc/Capsicum_annuum.JPG/1280px-Capsicum_annuum.JPG",
"Livistona chinensis": "https://upload.wikimedia.org/wikipedia/commons/f/f6/Livistona-chinensis.jpg",
"Rosa chinensis": "https://upload.wikimedia.org/wikipedia/commons/thumb/9/95/Rosa_chinensis_04-08-2012_01.jpg/1280px-Rosa_chinensis_04-08-2012_01.jpg",
"Columnea gloriosa": "https://upload.wikimedia.org/wikipedia/commons/thumb/6/6d/Columnea_gloriosa_137-8378.jpg/1280px-Columnea_gloriosa_137-8378.jpg",
"Dieffenbachia seguine": "https://upload.wikimedia.org/wikipedia/commons/8/80/Dieffenbachia_seguine1FKST.jpg",
"Dischidia ruscifolia": "https://upload.wikimedia.org/wikipedia/commons/thumb/e/ec/%E7%99%BE%E8%90%AC%E5%BF%83_Dischidia_ruscifolia_-%E9%A6%99%E6%B8%AF%E5%8B%95%E6%A4%8D%E7%89%A9%E5%85%AC%E5%9C%92_Hong_Kong_Botanical_Garden-_%289240152650%29.jpg/1280px-%E7%99%BE%E8%90%AC%E5%BF%83_Dischidia_ruscifolia_-%E9%A6%99%E6%B8%AF%E5%8B%95%E6%A4%8D%E7%89%A9%E5%85%AC%E5%9C%92_Hong_Kong_Botanical_Garden-_%289240152650%29.jpg",
"Dracaena marginata": "https://upload.wikimedia.org/wikipedia/commons/f/f9/Dracaena_marginata_IndoorPlant_0605k.jpg",
"Streptocarpus hybridus": "https://upload.wikimedia.org/wikipedia/commons/9/9f/2007-03-20Streptocarpus01.jpg",
"Dudleya brittonii": "https://upload.wikimedia.org/wikipedia/commons/thumb/1/10/Dudleya_Brittonii.jpg/1280px-Dudleya_Brittonii.jpg",
"Acer palmatum": "https://upload.wikimedia.org/wikipedia/commons/thumb/d/de/Portland_Japanese_Garden_maple.jpg/1280px-Portland_Japanese_Garden_maple.jpg",
"Fatsia japonica": "https://upload.wikimedia.org/wikipedia/commons/thumb/4/44/Fatsia_japonica.003_-_Zapateira.jpg/1280px-Fatsia_japonica.003_-_Zapateira.jpg",
"Vriesea splendens": "https://upload.wikimedia.org/wikipedia/commons/thumb/d/d6/Vriesea_splendens_flower.jpg/1280px-Vriesea_splendens_flower.jpg",
"Impatiens walleriana": "https://upload.wikimedia.org/wikipedia/commons/thumb/1/1e/Impatiens_walleriana_01.JPG/1280px-Impatiens_walleriana_01.JPG",
"Begonia maculata": "https://upload.wikimedia.org/wikipedia/commons/d/df/Begonia_maculata3073316230.jpg",
"Adiantum raddianum": "https://upload.wikimedia.org/wikipedia/commons/thumb/4/45/Starr_030807-0143_Adiantum_raddianum.jpg/1280px-Starr_030807-0143_Adiantum_raddianum.jpg",
"Fuchsia hybrida": "https://upload.wikimedia.org/wikipedia/commons/thumb/c/cc/Fuchsia_%C3%97_hybrida.jpg/1280px-Fuchsia_%C3%97_hybrida.jpg",
"Gasteria carinata": "https://upload.wikimedia.org/wikipedia/commons/thumb/1/12/Gasteria_carinata_hh.jpg/1280px-Gasteria_carinata_hh.jpg",
"Ficus lyrata": "https://upload.wikimedia.org/wikipedia/commons/thumb/8/84/Starr_031108-0130_Ficus_lyrata.jpg/1280px-Starr_031108-0130_Ficus_lyrata.jpg",
"Graptopetalum paraguayense": "https://upload.wikimedia.org/wikipedia/commons/thumb/5/59/%28MHNT%29_Graptopetalum_paraguayense_-_leaves.jpg/1280px-%28MHNT%29_Graptopetalum_paraguayense_-_leaves.jpg",
"Platycerium bifurcatum": "https://upload.wikimedia.org/wikipedia/commons/thumb/a/ab/Platycerium_bifurcatum_%28Corne_d%27%C3%A9lan%29_-_107.jpg/1280px-Platycerium_bifurcatum_%28Corne_d%27%C3%A9lan%29_-_107.jpg",
"Gloxinia speciosa": "https://upload.wikimedia.org/wikipedia/commons/8/87/Sinnningia_speciosa_im_Querbeet-Wintergarten.jpg",
"Echinocactus grusonii": "https://upload.wikimedia.org/wikipedia/commons/thumb/4/46/Echinocactus_grusonii_qtl1.jpg/1280px-Echinocactus_grusonii_qtl1.jpg",
"Phyllostachys aurea": "https://upload.wikimedia.org/wikipedia/commons/3/3b/Phyllostachys_aurea0.jpg",
"Punica granatum": "https://upload.wikimedia.org/wikipedia/commons/thumb/d/db/Illustration_Punica_granatum2.jpg/1280px-Illustration_Punica_granatum2.jpg",
"Schefflera actinophylla": "https://upload.wikimedia.org/wikipedia/commons/thumb/e/e6/Schefflera_actinophylla_02.JPG/1280px-Schefflera_actinophylla_02.JPG",
"Ficus elastica": "https://upload.wikimedia.org/wikipedia/commons/thumb/8/88/Ficus_November_2008-1.jpg/1280px-Ficus_November_2008-1.jpg",
"Cucumis sativus": "https://upload.wikimedia.org/wikipedia/commons/thumb/0/07/Cucumis_sativus_0001.JPG/1280px-Cucumis_sativus_0001.JPG",
"Betula pendula": "https://upload.wikimedia.org/wikipedia/commons/thumb/1/17/Betula_pendula_in_Sedovo_1.jpg/1280px-Betula_pendula_in_Sedovo_1.jpg",
"Philodendron hederaceum": "https://upload.wikimedia.org/wikipedia/commons/b/bb/Philodendron_scandens_subsp_oxycardium2.jpg",
"Ceropegia woodii": "https://upload.wikimedia.org/wikipedia/commons/5/55/Ceropegia_linearis_subsp_woodii.jpg",
"Hyacinthus orientalis": "https://upload.wikimedia.org/wikipedia/commons/thumb/a/ae/Garden_Hyacinth_Hyacinthus_orientalis_%27Blue_Jacket%27_Flower_2000px.jpg/1280px-Garden_Hyacinth_Hyacinthus_orientalis_%27Blue_Jacket%27_Flower_2000px.jpg",
"Azalea indica": "https://upload.wikimedia.org/wikipedia/commons/thumb/d/de/Rhododendron_indicum1.jpg/1280px-Rhododendron_indicum1.jpg",
"Zingiber officinale": "https://upload.wikimedia.org/wikipedia/commons/c/c1/Ginger_Plant_vs.jpg",
"Crassula ovata": "https://upload.wikimedia.org/wikipedia/commons/thumb/5/5d/Crassula_ovata_RTBG.jpg/1280px-Crassula_ovata_RTBG.jpg",
"Rhododendron simsii": "https://upload.wikimedia.org/wikipedia/commons/thumb/9/9b/Rhododendron_simsii_01.JPG/1280px-Rhododendron_simsii_01.JPG",
"Coffea arabica": "https://upload.wikimedia.org/wikipedia/commons/1/10/Coffea_arabica_2.jpg",
"Kalanchoe blossfeldiana": "https://upload.wikimedia.org/wikipedia/commons/thumb/f/fa/Kalanchoe_blossfeldiana_%28Florist_Kalanchoe%29.jpg/1280px-Kalanchoe_blossfeldiana_%28Florist_Kalanchoe%29.jpg",
"Camellia japonica": "https://upload.wikimedia.org/wikipedia/commons/thumb/6/6d/Camellia_japonica_var._decumbens_3.JPG/1280px-Camellia_japonica_var._decumbens_3.JPG",
"Chamomilla recutita": "https://upload.wikimedia.org/wikipedia/commons/thumb/a/a7/Chamomile%40original_size.jpg/1280px-Chamomile%40original_size.jpg",
"Aloe arborescens": "https://upload.wikimedia.org/wikipedia/commons/thumb/3/34/Aloe_arborescens_01.JPG/1280px-Aloe_arborescens_01.JPG",
"Aloe ferox": "https://upload.wikimedia.org/wikipedia/commons/9/9e/Aloe_Ferox_between_Cofimvaba_and_Ngcobo.jpg",
"Daucus carota": "https://upload.wikimedia.org/wikipedia/commons/thumb/2/23/Daucus_carota_May_2008-1_edit.jpg/1280px-Daucus_carota_May_2008-1_edit.jpg",
"Oxalis triangularis": "https://upload.wikimedia.org/wikipedia/commons/1/15/Oxalis_triangularis6.jpg",
"Hoya bella": "https://upload.wikimedia.org/wikipedia/commons/thumb/d/d1/%E7%BE%8E%E9%BA%97%E6%AF%AC%E8%98%AD_Hoya_bella_-%E9%A6%99%E6%B8%AF%E5%85%AC%E5%9C%92_Hong_Kong_Park-_%289240227808%29.jpg/1280px-%E7%BE%8E%E9%BA%97%E6%AF%AC%E8%98%AD_Hoya_bella_-%E9%A6%99%E6%B8%AF%E5%85%AC%E5%9C%92_Hong_Kong_Park-_%289240227808%29.jpg",
"Begonia tuberhybrida": "https://upload.wikimedia.org/wikipedia/commons/thumb/6/62/Begonia_x_tuberhybrida_1005Pink1.JPG/1280px-Begonia_x_tuberhybrida_1005Pink1.JPG",
"Epiphyllum oxypetalum": "https://upload.wikimedia.org/wikipedia/commons/thumb/5/59/Kakteen_IMG_4213.jpg/1280px-Kakteen_IMG_4213.jpg",
"Protea cynaroides": "https://upload.wikimedia.org/wikipedia/commons/thumb/3/3d/Protea_cynaroides_3.jpg/1280px-Protea_cynaroides_3.jpg",
"Conophytum calculus": "https://upload.wikimedia.org/wikipedia/commons/thumb/2/25/Conophytum_calculus_-_Namaqualand_-_South_Africa_5.jpg/1280px-Conophytum_calculus_-_Namaqualand_-_South_Africa_5.jpg",
"Rhipsalis baccifera": "https://upload.wikimedia.org/wikipedia/commons/b/ba/Rhipsalis_baccifera_01_ies.jpg",
"Cotyledon orbiculata": "https://upload.wikimedia.org/wikipedia/commons/thumb/b/bb/Cotyledon_orbiculata_2.jpg/1280px-Cotyledon_orbiculata_2.jpg",
"Lithops julii": "https://upload.wikimedia.org/wikipedia/commons/4/4d/Lithops_julii_Nicos_Farm_strain.jpg",
"Tradescantia pallida": "https://upload.wikimedia.org/wikipedia/commons/thumb/b/be/Tradescantia_pallida_C.jpg/1280px-Tradescantia_pallida_C.jpg",
"Aeschynanthus radicans": "https://upload.wikimedia.org/wikipedia/commons/5/52/Aeschynanthus_radicans.jpg",
"Tillandsia ionantha": "https://upload.wikimedia.org/wikipedia/commons/thumb/d/d9/Matthaei_Botanical_Gardens_-_IMG_8999.JPG/1280px-Matthaei_Botanical_Gardens_-_IMG_8999.JPG",
"Stephanotis floribunda": "https://upload.wikimedia.org/wikipedia/commons/9/9d/Stephanotis_floribunda.jpg",
"Dracaena fragrans": "https://upload.wikimedia.org/wikipedia/commons/6/64/Dracaena_fragrans_a1.jpg",
"Beta vulgaris": "https://upload.wikimedia.org/wikipedia/commons/f/ff/Beta_vulgaris_-_K%C3%B6hler%E2%80%93s_Medizinal-Pflanzen-167.jpg",
"Rhaphidophora tetrasperma": "https://upload.wikimedia.org/wikipedia/commons/d/db/Rhaphidophora_tetrasperma.jpg",
"Pachyphytum oviferum": "https://upload.wikimedia.org/wikipedia/commons/thumb/3/39/Pachyphytum_oviferum_1.jpg/1280px-Pachyphytum_oviferum_1.jpg",
"Monstera adansonii": "https://upload.wikimedia.org/wikipedia/commons/thumb/3/34/Monstera_adansonii_CBM.png/1280px-Monstera_adansonii_CBM.png",
"Crassula muscosa": "https://upload.wikimedia.org/wikipedia/commons/5/5e/Crassula_muscosa_Grubosz_2006-05-03_01.jpg",
"Epipremnum pinnatum Neon": "https://upload.wikimedia.org/wikipedia/commons/thumb/d/de/Epipremnum_pinnatum_%27Neon%27%2C_Longwood_Gardens_01.jpg/1280px-Epipremnum_pinnatum_%27Neon%27%2C_Longwood_Gardens_01.jpg",
"Lilium longiflorum": "https://upload.wikimedia.org/wikipedia/commons/thumb/d/dd/Lilium_longiflorum_%28Easter_Lily%29.JPG/1280px-Lilium_longiflorum_%28Easter_Lily%29.JPG",
"Senecio rowleyanus": "https://upload.wikimedia.org/wikipedia/commons/thumb/3/3b/Senecio_rowleyanus.jpg/1280px-Senecio_rowleyanus.jpg",
"Petroselinum crispum": "https://upload.wikimedia.org/wikipedia/commons/2/2d/Petroselinum_crispum_-_K%C3%B6hler%E2%80%93s_Medizinal-Pflanzen-103.jpg",
"Petunia hybrida": "https://upload.wikimedia.org/wikipedia/commons/thumb/a/af/Petunia_x_hybrida_a2.JPG/1280px-Petunia_x_hybrida_a2.JPG",
"Syngonium podophyllum": "https://upload.wikimedia.org/wikipedia/commons/thumb/0/07/Syngonium_podophyllum_Golden_0zz.jpg/1280px-Syngonium_podophyllum_Golden_0zz.jpg",
"Philodendron bipinnatifidum": "https://upload.wikimedia.org/wikipedia/commons/c/c1/Tree1.JPG",
"Philodendron gloriosum": "https://upload.wikimedia.org/wikipedia/commons/f/f1/Philodendron_gloriosum.jpg",
"Primula vulgaris": "https://upload.wikimedia.org/wikipedia/commons/thumb/7/76/Chipping_Sodbury_MMB_02_primula_vulgaris.jpg/1280px-Chipping_Sodbury_MMB_02_primula_vulgaris.jpg",
"Raphanus sativus": "https://upload.wikimedia.org/wikipedia/commons/thumb/7/73/Raphanus_sativus_of_Salem.jpg/1280px-Raphanus_sativus_of_Salem.jpg",
"Selaginella uncinata": "https://upload.wikimedia.org/wikipedia/commons/thumb/3/34/Selaginella_uncinata_-_Berlin_Botanical_Garden_-_IMG_8722.JPG/1280px-Selaginella_uncinata_-_Berlin_Botanical_Garden_-_IMG_8722.JPG",
"Peperomia caperata": "https://upload.wikimedia.org/wikipedia/commons/thumb/1/14/Peperomia_caperata_1-OB9.jpg/1280px-Peperomia_caperata_1-OB9.jpg",
"Hippeastrum hybrid": "https://upload.wikimedia.org/wikipedia/commons/thumb/f/fc/Hippeastrum_hybrid_1.jpg/1280px-Hippeastrum_hybrid_1.jpg",
"Digitalis purpurea": "https://upload.wikimedia.org/wikipedia/commons/thumb/b/ba/Digitalis_purpurea_LC0101.jpg/1280px-Digitalis_purpurea_LC0101.jpg",
"Lactuca sativa": "https://upload.wikimedia.org/wikipedia/commons/thumb/6/6b/Romaine_lettuce.jpg/1280px-Romaine_lettuce.jpg",
"Salvia officinalis": "https://upload.wikimedia.org/wikipedia/commons/5/5a/Salvia_officinalis0.jpg",
"Scindapsus pictus": "https://upload.wikimedia.org/wikipedia/commons/c/ca/Scindapsus_pictus_01.jpg",
"Mimosa pudica": "https://upload.wikimedia.org/wikipedia/commons/thumb/0/04/Touch_Me_not.jpg/1280px-Touch_Me_not.jpg",
"Phalaenopsis amabilis": "https://upload.wikimedia.org/wikipedia/commons/3/32/Phalaenopsis_amabilis_Orchi_03.jpg",
"Allium schoenoprasum": "https://upload.wikimedia.org/wikipedia/commons/thumb/7/74/Allium_schoenoprasum_J1.JPG/1280px-Allium_schoenoprasum_J1.JPG",
"Pandanus veitchii": "https://upload.wikimedia.org/wikipedia/commons/thumb/f/fb/Pandanus_tectorius.jpg/1280px-Pandanus_tectorius.jpg",
"Aeonium arboreum": "https://upload.wikimedia.org/wikipedia/commons/thumb/c/c4/Aeonium_arboreum_01.jpg/1280px-Aeonium_arboreum_01.jpg",
"Adenium socotranum": "https://upload.wikimedia.org/wikipedia/commons/thumb/f/fa/Adenium_seedling_2day_Uthandi_Aug21_D72_20609-21_ZP.jpg/1280px-Adenium_seedling_2day_Uthandi_Aug21_D72_20609-21_ZP.jpg",
"Echinacea purpurea": "https://upload.wikimedia.org/wikipedia/commons/thumb/b/b5/Monarch_Butterfly_Danaus_plexippus_on_Echinacea_purpurea_2800px.jpg/1280px-Monarch_Butterfly_Danaus_plexippus_on_Echinacea_purpurea_2800px.jpg",
"Tillandsia usneoides": "https://upload.wikimedia.org/wikipedia/commons/thumb/7/74/Tillandsia_usneoides_%28Bromeliaceae%29_%2825004066956%29.jpg/1280px-Tillandsia_usneoides_%28Bromeliaceae%29_%2825004066956%29.jpg",
"Rhapis excelsa": "https://upload.wikimedia.org/wikipedia/commons/7/7d/Rhapis_excelsa.jpg",
"Viola wittrockiana": "https://upload.wikimedia.org/wikipedia/commons/thumb/9/91/Pansy_viola_x_wittrockiana_blue.jpg/1280px-Pansy_viola_x_wittrockiana_blue.jpg",
"Schefflera arboricola": "https://upload.wikimedia.org/wikipedia/commons/thumb/8/82/Schefflera_arboricola%2C_vrugte%2C_a%2C_Pretoria.jpg/1280px-Schefflera_arboricola%2C_vrugte%2C_a%2C_Pretoria.jpg",
"Tagetes patula": "https://upload.wikimedia.org/wikipedia/commons/thumb/1/1f/French_marigold_garden_2009_G1.jpg/1280px-French_marigold_garden_2009_G1.jpg",
"Oncidium sphacelatum": "https://upload.wikimedia.org/wikipedia/commons/thumb/3/37/Oncidium_sphacelatum03.jpg/1280px-Oncidium_sphacelatum03.jpg",
"Colocasia esculenta": "https://upload.wikimedia.org/wikipedia/commons/thumb/8/83/Taro_leaf_underside%2C_backlit_by_sun.jpg/1280px-Taro_leaf_underside%2C_backlit_by_sun.jpg",
"Camellia sinensis": "https://upload.wikimedia.org/wikipedia/commons/thumb/e/e3/Camellia_sinensis_MHNT.BOT.2016.12.24.jpg/1280px-Camellia_sinensis_MHNT.BOT.2016.12.24.jpg",
"Plumeria rubra": "https://upload.wikimedia.org/wikipedia/commons/thumb/a/a2/White_Plumeria_from_Kannur_-_Kerala.jpg/1280px-White_Plumeria_from_Kannur_-_Kerala.jpg",
"Thymus vulgaris": "https://upload.wikimedia.org/wikipedia/commons/1/12/Thymus_vulgaris_-_K%C3%B6hler%E2%80%93s_Medizinal-Pflanzen-271.jpg",
"Cordyline fruticosa": "https://upload.wikimedia.org/wikipedia/commons/0/05/Ti_plant_%28Cordyline_fruticosa%29.jpg",
"Solanum lycopersicum": "https://upload.wikimedia.org/wikipedia/commons/thumb/8/89/Tomato_je.jpg/1280px-Tomato_je.jpg",
"Polypodium vulgare": "https://upload.wikimedia.org/wikipedia/commons/thumb/3/33/Polypodium_vulgare%2C_sores_%28Matthieu_Gauvain%29.JPG/1280px-Polypodium_vulgare%2C_sores_%28Matthieu_Gauvain%29.JPG",
"Microsorum punctatum": "https://upload.wikimedia.org/wikipedia/commons/thumb/9/9f/Microsorum_punctatum.jpg/1280px-Microsorum_punctatum.jpg",
"Tulipa gesneriana": "https://upload.wikimedia.org/wikipedia/commons/thumb/9/9e/%D7%A6%D7%91%D7%A2%D7%95%D7%A0%D7%99%D7%9D.JPG/1280px-%D7%A6%D7%91%D7%A2%D7%95%D7%A0%D7%99%D7%9D.JPG",
"Vanilla planifolia": "https://upload.wikimedia.org/wikipedia/commons/4/40/Vanilla_planifolia_1.jpg",
"Asplenium nidus": "https://upload.wikimedia.org/wikipedia/commons/thumb/7/73/Bird%27s-nest_Fern_Asplenium_nidus_Leaves_1.jpg/1280px-Bird%27s-nest_Fern_Asplenium_nidus_Leaves_1.jpg",
"Lantana camara": "https://upload.wikimedia.org/wikipedia/commons/thumb/7/71/Twin_lantana_camara_edit.jpg/1280px-Twin_lantana_camara_edit.jpg",
"Schlumbergera truncata": "https://upload.wikimedia.org/wikipedia/commons/thumb/6/61/Schlumbergera_truncata_flordemaio.jpg/1280px-Schlumbergera_truncata_flordemaio.jpg",
"Euphorbia pulcherrima": "https://upload.wikimedia.org/wikipedia/commons/thumb/5/5d/2008_05_17_-_Euphorbia_pulcherrima_05a.JPG/1280px-2008_05_17_-_Euphorbia_pulcherrima_05a.JPG",
"Tradescantia fluminensis": "https://upload.wikimedia.org/wikipedia/commons/thumb/0/05/Tradescantia_fluminensis_Flower_1.jpg/1280px-Tradescantia_fluminensis_Flower_1.jpg",
"Yucca elephantipes": "https://upload.wikimedia.org/wikipedia/commons/thumb/d/d1/Yucca_elephantipes_HRM2.JPG/1280px-Yucca_elephantipes_HRM2.JPG",
"Hamamelis mollis": "https://upload.wikimedia.org/wikipedia/commons/c/ce/Hamamelis_mollis0.jpg",
"Haworthia fasciata": "https://upload.wikimedia.org/wikipedia/commons/thumb/1/13/Haworthia_fasciata_%28Vermont%29.jpg/1280px-Haworthia_fasciata_%28Vermont%29.jpg",
"Tradescantia zebrina": "https://upload.wikimedia.org/wikipedia/commons/thumb/e/e6/Zebrina_pendula_20060521_2.JPG/1280px-Zebrina_pendula_20060521_2.JPG",
"Zinnia elegans": "https://upload.wikimedia.org/wikipedia/commons/thumb/d/d7/Zinnia_elegans_with_Bombus_01.JPG/1280px-Zinnia_elegans_with_Bombus_01.JPG",
"Citrus limon": "https://upload.wikimedia.org/wikipedia/commons/e/e4/Lemon.jpg",
"Melissa officinalis": "https://upload.wikimedia.org/wikipedia/commons/thumb/f/f9/Melissa_officinalis_1.jpg/1280px-Melissa_officinalis_1.jpg",
"Phoenix roebelenii": "https://upload.wikimedia.org/wikipedia/commons/thumb/1/1d/Starr_070124-3839_Phoenix_roebelenii.jpg/1280px-Starr_070124-3839_Phoenix_roebelenii.jpg",
"Cymbidium lowianum": "https://upload.wikimedia.org/wikipedia/commons/thumb/6/6b/Cymbidium_lowianum_var._concolor_3.jpg/1280px-Cymbidium_lowianum_var._concolor_3.jpg",
"Jasminum%20sambac": null,
"Pilea%20cadierei": null,
"Alocasia%20zebrina": null,
"Anemone%20coronaria": null,
"Jasminum sambac": "https://upload.wikimedia.org/wikipedia/commons/thumb/7/75/JasminumSambac.jpg/1280px-JasminumSambac.jpg",
"Anemone coronaria": "https://upload.wikimedia.org/wikipedia/commons/thumb/8/80/Blue_Anemone_coronaria.JPG/1280px-Blue_Anemone_coronaria.JPG",
"Pilea cadierei": "https://upload.wikimedia.org/wikipedia/commons/thumb/3/30/PileaCadierei.jpg/1280px-PileaCadierei.jpg",
"Alocasia zebrina": "https://upload.wikimedia.org/wikipedia/commons/4/43/Alocasia_zebrina_Reticulata_1zz.jpg",
"Iris%20germanica": null,
"Dianthus%20barbatus": null,
"Iris germanica": "https://upload.wikimedia.org/wikipedia/commons/thumb/2/29/Iris_germanica_001.jpg/1280px-Iris_germanica_001.jpg",
"Dianthus barbatus": "https://upload.wikimedia.org/wikipedia/commons/4/4f/Dianthus_barbatus_PID1688-4.jpg",
"Lavatera%20trimestris": null,
"Lavatera trimestris": "https://upload.wikimedia.org/wikipedia/commons/thumb/5/57/Flower_April_2010-1a.jpg/1280px-Flower_April_2010-1a.jpg",
"Satureja%20hortensis": null,
"Satureja hortensis": "https://upload.wikimedia.org/wikipedia/commons/thumb/5/5f/Satureja_hortensis_bgiu.jpg/1280px-Satureja_hortensis_bgiu.jpg",
"Borago%20officinalis": null,
"Borago officinalis": "https://upload.wikimedia.org/wikipedia/commons/thumb/5/54/Borago_officinalis_%28flower%29.jpg/1280px-Borago_officinalis_%28flower%29.jpg",
"Soleirolia%20soleirolii": null,
"Soleirolia soleirolii": "https://upload.wikimedia.org/wikipedia/commons/thumb/e/ed/Soleirolia_soleirolii_kz05.jpg/1280px-Soleirolia_soleirolii_kz05.jpg",
"Buxus%20sempervirens": null,
"Buxus sempervirens": "https://upload.wikimedia.org/wikipedia/commons/thumb/b/ba/Buisfleurs.jpg/1280px-Buisfleurs.jpg",
"Plectranthus%20scutellarioides": null,
"Plectranthus scutellarioides": "https://upload.wikimedia.org/wikipedia/commons/thumb/9/9c/Solenostemon_scutellarioides_%28Coleus_x_hybridus%29_%27Inky_Fingers%27_Leaf_Cluster_2730px.jpg/1280px-Solenostemon_scutellarioides_%28Coleus_x_hybridus%29_%27Inky_Fingers%27_Leaf_Cluster_2730px.jpg",
"Goeppertia%20insignis": null,
"Goeppertia insignis": "https://upload.wikimedia.org/wikipedia/commons/thumb/9/94/Goeppertia_insignis.jpg/1280px-Goeppertia_insignis.jpg",
"Goeppertia%20ornata": null,
"Goeppertia ornata": "https://upload.wikimedia.org/wikipedia/commons/thumb/4/42/CalatheaOrnataRoseo-Lineata.jpg/1280px-CalatheaOrnataRoseo-Lineata.jpg",
"Clematis%20viticella": null,
"Clematis viticella": "https://upload.wikimedia.org/wikipedia/commons/4/4f/Clematis_viticella3UME.jpg",
"Helichrysum%20italicum": null,
"Helichrysum italicum": "https://upload.wikimedia.org/wikipedia/commons/thumb/c/c5/Helichrysum_italicum_%28immortelle%29.JPG/1280px-Helichrysum_italicum_%28immortelle%29.JPG",
"Anethum%20graveolens": null,
"Anethum graveolens": "https://upload.wikimedia.org/wikipedia/commons/5/56/Anethum_graveolens_02.jpg",
"Lathyrus%20odoratus": null,
"Lathyrus odoratus": "https://upload.wikimedia.org/wikipedia/commons/thumb/7/72/Lathyrus_odoratus_Painted_Lady.jpg/1280px-Lathyrus_odoratus_Painted_Lady.jpg",
"Sorbus%20aucuparia": null,
"Sorbus aucuparia": "https://upload.wikimedia.org/wikipedia/commons/thumb/d/d4/2020_year._Herbarium._Sorbus_aucuparia._img-035.jpg/1280px-2020_year._Herbarium._Sorbus_aucuparia._img-035.jpg",
"Quercus%20robur": null,
"Quercus robur": "https://upload.wikimedia.org/wikipedia/commons/thumb/a/a9/Langaa_egeskov_rimfrost.jpg/1280px-Langaa_egeskov_rimfrost.jpg",
"Begonia%20semperflorens-cultorum": null,
"Begonia semperflorens-cultorum": "https://upload.wikimedia.org/wikipedia/commons/thumb/2/28/Begonia_semperflorens.jpg/1280px-Begonia_semperflorens.jpg",
"Verbena%20bonariensis": null,
"Verbena bonariensis": "https://upload.wikimedia.org/wikipedia/commons/thumb/e/e0/Verbena_bonariensis.jpg/1280px-Verbena_bonariensis.jpg",
"Artemisia%20dracunculus": null,
"Artemisia dracunculus": "https://upload.wikimedia.org/wikipedia/commons/6/6c/Estragon_1511.jpg",
"Eucalyptus%20globulus": null,
"Eucalyptus globulus": "https://upload.wikimedia.org/wikipedia/commons/7/74/Eucalyptus_globulus_globulus.jpg",
"Schefflera%20elegantissima": null,
"Schefflera elegantissima": "https://upload.wikimedia.org/wikipedia/commons/6/64/Schefflera_elegantissima.jpg",
"Salvia%20splendens": null,
"Salvia splendens": "https://upload.wikimedia.org/wikipedia/commons/thumb/0/0d/Salvia_splendens_%28357118788%29.jpg/1280px-Salvia_splendens_%28357118788%29.jpg",
"Picea%20abies": "https://upload.wikimedia.org/wikipedia/commons/thumb/1/1b/Picea_abies_%27Rubra_Spicata%27_5_2021_Norway-_%2851176180455%29.jpg/1280px-Picea_abies_%27Rubra_Spicata%27_5_2021_Norway-_%2851176180455%29.jpg",
"Ficus%20altissima": null,
"Ficus altissima": "https://upload.wikimedia.org/wikipedia/commons/thumb/6/60/Ficus_altissima1.jpg/1280px-Ficus_altissima1.jpg",
"Ficus%20microcarpa": null,
"Ficus microcarpa": "https://upload.wikimedia.org/wikipedia/commons/thumb/d/d6/Bonsai_Adam_and_Eve_%28PPL2-Enhanced%29_julesvernex2.jpg/1280px-Bonsai_Adam_and_Eve_%28PPL2-Enhanced%29_julesvernex2.jpg",
"Delonix%20regia": null,
"Delonix regia": "https://upload.wikimedia.org/wikipedia/commons/thumb/d/d6/Flame_tree_mali.jpg/1280px-Flame_tree_mali.jpg",
"Phlox%20paniculata": "https://upload.wikimedia.org/wikipedia/commons/thumb/e/e2/Phlox_paniculata_%27Opening_Act_Blush%27_6_2021_Garden_Phlox-_%2851242863119%29.jpg/1280px-Phlox_paniculata_%27Opening_Act_Blush%27_6_2021_Garden_Phlox-_%2851242863119%29.jpg",
"Syringa%20vulgaris": "https://upload.wikimedia.org/wikipedia/commons/2/2b/Syringa_vulgaris1.jpg",
"Forsythia%20x%20intermedia": null,
"Forsythia x intermedia": "https://upload.wikimedia.org/wikipedia/commons/b/b7/Forsythia-intermedia.JPG",
"Freesia%20refracta": null,
"Freesia refracta": "https://upload.wikimedia.org/wikipedia/commons/thumb/9/9e/FreesiaRefracta1.JPG/1280px-FreesiaRefracta1.JPG",
"Amaranthus%20caudatus": null,
"Amaranthus caudatus": "https://upload.wikimedia.org/wikipedia/commons/b/b6/Amaranthus_caudatus1.jpg",
"Bellis%20perennis": null,
"Bellis perennis": "https://upload.wikimedia.org/wikipedia/commons/thumb/1/1c/Pink_twinged_daisy_on_table_edit.jpg/1280px-Pink_twinged_daisy_on_table_edit.jpg",
"Impatiens%20balsamina": null,
"Impatiens balsamina": "https://upload.wikimedia.org/wikipedia/commons/f/f8/Impatiens_balsamina1.jpg",
"Hibiscus%20syriacus": null,
"Hibiscus syriacus": "https://upload.wikimedia.org/wikipedia/commons/thumb/7/70/Hibiscus-syriacus.jpg/1280px-Hibiscus-syriacus.jpg",
"Lonicera%20japonica": null,
"Lonicera japonica": "https://upload.wikimedia.org/wikipedia/commons/b/b9/Honeysuckle_2.jpg",
"Ginkgo%20biloba": null,
"Ginkgo biloba": "https://upload.wikimedia.org/wikipedia/commons/thumb/1/19/Ginkgo_biloba_MHNT.BOT.2010.13.1.jpg/1280px-Ginkgo_biloba_MHNT.BOT.2010.13.1.jpg",
"Gladiolus%20hortulanus": null,
"Gladiolus hortulanus": "https://upload.wikimedia.org/wikipedia/commons/thumb/2/25/Gartengladiole_apricot.JPG/1280px-Gartengladiole_apricot.JPG",
"Pachira%20aquatica": null,
"Pachira aquatica": "https://upload.wikimedia.org/wikipedia/commons/thumb/3/3a/Pachira_aquatica_%28inflorescense%29.jpg/1280px-Pachira_aquatica_%28inflorescense%29.jpg",
"Hyacinthoides%20non-scripta": null,
"Hyacinthoides non-scripta": "https://upload.wikimedia.org/wikipedia/commons/thumb/a/a8/Hyacinthoides_non-scripta_%28Common_Bluebell%29.jpg/1280px-Hyacinthoides_non-scripta_%28Common_Bluebell%29.jpg",
"Heliotropium%20arborescens": null,
"Heliotropium arborescens": "https://upload.wikimedia.org/wikipedia/commons/thumb/c/cb/Heliotropium_arborescens_%27Mini_Marine%27_Heliotrope_Flower_2500px.jpg/1280px-Heliotropium_arborescens_%27Mini_Marine%27_Heliotrope_Flower_2500px.jpg",
"Symphyotrichum%20novi-belgii": null,
"Symphyotrichum novi-belgii": "https://upload.wikimedia.org/wikipedia/commons/thumb/2/2f/Symphyotrichum_novi-belgii20090914_120.jpg/1280px-Symphyotrichum_novi-belgii20090914_120.jpg",
"Viola%20cornuta": null,
"Viola cornuta": "https://upload.wikimedia.org/wikipedia/commons/thumb/b/b6/Viola_cornuta_2008.jpg/1280px-Viola_cornuta_2008.jpg",
"Hydrangea%20macrophylla": null,
"Hydrangea macrophylla": "https://upload.wikimedia.org/wikipedia/commons/thumb/7/7f/Hydrangea_macrophylla_-_Hortensia_hydrangea.jpg/1280px-Hydrangea_macrophylla_-_Hortensia_hydrangea.jpg",
"Monarda%20didyma": "https://upload.wikimedia.org/wikipedia/commons/thumb/c/cd/Monarda_didyma_%27Colrain_Red%27_6_2021_Bee_Balm-_%2851273491889%29.jpg/1280px-Monarda_didyma_%27Colrain_Red%27_6_2021_Bee_Balm-_%2851273491889%29.jpg",
"Jacaranda%20mimosifolia": null,
"Jacaranda mimosifolia": "https://upload.wikimedia.org/wikipedia/commons/thumb/6/6c/Jacaranda_mimosifolia_5334.jpg/1280px-Jacaranda_mimosifolia_5334.jpg",
"Coffea%20arabica%20Nana": null,
"Coffea arabica Nana": "https://upload.wikimedia.org/wikipedia/commons/thumb/3/3e/Henry_G._Gilbert_Nursery_and_Seed_Trade_Catalog_Collection_%28IA_catalogueofgreen00dcla_0%29.pdf/page1-1016px-Henry_G._Gilbert_Nursery_and_Seed_Trade_Catalog_Collection_%28IA_catalogueofgreen00dcla_0%29.pdf.jpg",
"Eschscholzia%20californica": null,
"Eschscholzia californica": "https://upload.wikimedia.org/wikipedia/commons/thumb/d/da/Eschscholzia_californica_in_Sedovo_006.jpg/1280px-Eschscholzia_californica_in_Sedovo_006.jpg",
"Osteospermum%20ecklonis": null,
"Osteospermum ecklonis": "https://upload.wikimedia.org/wikipedia/commons/thumb/9/9e/Osteospermum_ecklonis1.JPG/1280px-Osteospermum_ecklonis1.JPG",
"Tropaeolum%20majus": null,
"Tropaeolum majus": "https://upload.wikimedia.org/wikipedia/commons/thumb/8/87/Tropaeolum_majus_2005_G1.jpg/1280px-Tropaeolum_majus_2005_G1.jpg",
"Nepeta%20cataria": null,
"Nepeta cataria": "https://upload.wikimedia.org/wikipedia/commons/1/12/Nepeta_cataria_Sturm24.jpg",
"Anthriscus%20cerefolium": null,
"Anthriscus cerefolium": "https://upload.wikimedia.org/wikipedia/commons/thumb/4/4b/Kerbel_%28Anthriscus_cerefolium%29_mit_Echter_Mehltau_%28Erysiphe_heraclei%29_Befall%2C_Schlaghecken.jpg/1280px-Kerbel_%28Anthriscus_cerefolium%29_mit_Echter_Mehltau_%28Erysiphe_heraclei%29_Befall%2C_Schlaghecken.jpg",
"Pinus%20sylvestris": null,
"Pinus sylvestris": "https://upload.wikimedia.org/wikipedia/commons/thumb/5/5b/Meenikunno_maastikukaiteala.jpg/1280px-Meenikunno_maastikukaiteala.jpg",
"Prunus%20laurocerasus": null,
"Prunus laurocerasus": "https://upload.wikimedia.org/wikipedia/commons/thumb/5/57/Fr%C3%BChling_bl%C3%BChender_Kirschenbaum.jpg/1280px-Fr%C3%BChling_bl%C3%BChender_Kirschenbaum.jpg",
"Verbascum%20thapsus": null,
"Verbascum thapsus": "https://upload.wikimedia.org/wikipedia/commons/thumb/3/33/Koningskaars_R01.jpg/1280px-Koningskaars_R01.jpg",
"Gaillardia%20aristata": null,
"Gaillardia aristata": "https://upload.wikimedia.org/wikipedia/commons/thumb/7/71/Gaillardia_July_2011-2.jpg/1280px-Gaillardia_July_2011-2.jpg",
"Coriandrum%20sativum": null,
"Coriandrum sativum": "https://upload.wikimedia.org/wikipedia/commons/1/13/Coriandrum_sativum_-_K%C3%B6hler%E2%80%93s_Medizinal-Pflanzen-193.jpg",
"Centaurea%20cyanus": null,
"Centaurea cyanus": "https://upload.wikimedia.org/wikipedia/commons/e/e3/CentaureaCyanus-bloem-kl.jpg",
"Cosmos%20bipinnatus": null,
"Cosmos bipinnatus": "https://upload.wikimedia.org/wikipedia/commons/thumb/a/a8/Cosmos_bipinnatus%2C_Burdwan%2C_West_Bengal%2C_India_10_01_2013.jpg/1280px-Cosmos_bipinnatus%2C_Burdwan%2C_West_Bengal%2C_India_10_01_2013.jpg",
"Anthurium%20crystallinum": null,
"Anthurium crystallinum": "https://upload.wikimedia.org/wikipedia/commons/6/61/Alismatales_-_Anthurium_crystallinum_2.jpg",
"Codiaeum%20variegatum": null,
"Codiaeum variegatum": null,
"Kroton": null,
"Helleborus%20orientalis": null,
"Helleborus orientalis": "https://upload.wikimedia.org/wikipedia/commons/thumb/9/9a/Helleborus_orientalis._Lenteroos_03.JPG/1280px-Helleborus_orientalis._Lenteroos_03.JPG",
"Levisticum%20officinale": null,
"Levisticum officinale": "https://upload.wikimedia.org/wikipedia/commons/thumb/4/4c/Liebst%C3%B6ckel_%28Levisticum_officinale%29_mit_einem_Echten_Mehltau_%28Erysiphe_heraclei%29_Befall.%2C_Schlaghecken.jpg/1280px-Liebst%C3%B6ckel_%28Levisticum_officinale%29_mit_einem_Echten_Mehltau_%28Erysiphe_heraclei%29_Befall.%2C_Schlaghecken.jpg",
"Antirrhinum%20majus": null,
"Antirrhinum majus": "https://upload.wikimedia.org/wikipedia/commons/thumb/6/64/Antirrhinum_majus_full_MichaD.jpg/1280px-Antirrhinum_majus_full_MichaD.jpg",
"Laurus%20nobilis": null,
"Laurus nobilis": "https://upload.wikimedia.org/wikipedia/commons/thumb/f/fe/Laurus_nobilis_MHNT_Fleurs.jpg/1280px-Laurus_nobilis_MHNT_Fleurs.jpg",
"Lupinus%20polyphyllus": null,
"Lupinus polyphyllus": "https://upload.wikimedia.org/wikipedia/commons/thumb/e/e7/Lupinus_polyphyllus_MHNT.BOT.2004.0.463.jpg/1280px-Lupinus_polyphyllus_MHNT.BOT.2004.0.463.jpg",
"Coreopsis%20tinctoria": null,
"Coreopsis tinctoria": "https://upload.wikimedia.org/wikipedia/commons/thumb/f/fe/Coreopsis_tinctoria_cultivar_Uptick_Cream_and_Red_4.JPG/1280px-Coreopsis_tinctoria_cultivar_Uptick_Cream_and_Red_4.JPG",
"Magnolia%20grandiflora": null,
"Magnolia grandiflora": "https://upload.wikimedia.org/wikipedia/commons/4/48/Magnolia_%C3%97_soulangeana_blossom.jpg",
"Convallaria%20majalis": null,
"Convallaria majalis": "https://upload.wikimedia.org/wikipedia/commons/thumb/a/a8/Beech%2C_ferns_and_lily_of_the_valley_in_Gullmarsskogen_1.jpg/1280px-Beech%2C_ferns_and_lily_of_the_valley_in_Gullmarsskogen_1.jpg",
"Origanum%20majorana": null,
"Origanum majorana": "https://upload.wikimedia.org/wikipedia/commons/thumb/d/d6/Origanum_majorana.jpg/1280px-Origanum_majorana.jpg",
"Leucanthemum%20vulgare": null,
"Leucanthemum vulgare": "https://upload.wikimedia.org/wikipedia/commons/thumb/e/e7/Leucanthemum_vulgare_%27Filigran%27_Flower_2200px.jpg/1280px-Leucanthemum_vulgare_%27Filigran%27_Flower_2200px.jpg",
"Salvia%20farinacea": null,
"Salvia farinacea": "https://upload.wikimedia.org/wikipedia/commons/6/64/Salvia_farinacea1.jpg",
"Gazania%20rigens": null,
"Gazania rigens": "https://upload.wikimedia.org/wikipedia/commons/thumb/3/3d/Gazania_rigens-1.jpg/1280px-Gazania_rigens-1.jpg",
"Papaver%20rhoeas": "https://upload.wikimedia.org/wikipedia/commons/thumb/0/04/Papaver_rhoeas_6_2021_Poppy-_%2851237437202%29.jpg/1280px-Papaver_rhoeas_6_2021_Poppy-_%2851237437202%29.jpg",
"Dianthus%20caryophyllus": null,
"Dianthus caryophyllus": "https://upload.wikimedia.org/wikipedia/commons/8/80/Gartennelke_1.jpg",
"Nemesia%20strumosa": null,
"Nemesia strumosa": "https://upload.wikimedia.org/wikipedia/commons/thumb/5/56/Nemesia_strumosa_two_colours.jpg/1280px-Nemesia_strumosa_two_colours.jpg",
"Fittonia%20albivenis": null,
"Fittonia albivenis": "https://upload.wikimedia.org/wikipedia/commons/thumb/7/72/Colpfl25.jpg/1280px-Colpfl25.jpg",
"Araucaria%20heterophylla": null,
"Araucaria heterophylla": "https://upload.wikimedia.org/wikipedia/commons/thumb/3/38/Araucaria_heterophylla_Norfolk_Island_0.jpg/1280px-Araucaria_heterophylla_Norfolk_Island_0.jpg",
"Cycas%20revoluta": "https://upload.wikimedia.org/wikipedia/commons/6/6d/Cycas_revoluta%2BFlorero.jpg",
"Paeonia%20lactiflora": null,
"Paeonia lactiflora": "https://upload.wikimedia.org/wikipedia/commons/thumb/b/bf/P%C3%B6rtschach_Winklern_10.-Oktober-Stra%C3%9Fe_67_Paeonia_lactiflora_24052014_2076.jpg/1280px-P%C3%B6rtschach_Winklern_10.-Oktober-Stra%C3%9Fe_67_Paeonia_lactiflora_24052014_2076.jpg",
"Philodendron%20hederaceum%20Brasil": null,
"Philodendron hederaceum Brasil": "https://upload.wikimedia.org/wikipedia/commons/thumb/4/44/Wikipedia_13._Fotoworkshop_Botanischer_Garten_Erlangen_2013_by-RaBoe_136.jpg/1280px-Wikipedia_13._Fotoworkshop_Botanischer_Garten_Erlangen_2013_by-RaBoe_136.jpg",
"Philodendron%20erubescens%20Pink%20Princess": null,
"Philodendron erubescens Pink Princess": "https://upload.wikimedia.org/wikipedia/commons/6/64/Arum_Family_-_Araceae_%283072475611%29.jpg",
"Thaumatophyllum%20xanadu": null,
"Thaumatophyllum xanadu": "https://upload.wikimedia.org/wikipedia/commons/thumb/1/17/Philodendron_xanadu_AK1.jpg/1280px-Philodendron_xanadu_AK1.jpg",
"Ipomoea%20purpurea": null,
"Ipomoea purpurea": "https://upload.wikimedia.org/wikipedia/commons/thumb/a/ab/Ipomoea_July_2011-4.jpg/1280px-Ipomoea_July_2011-4.jpg",
"Hypoestes%20phyllostachya": null,
"Hypoestes phyllostachya": "https://upload.wikimedia.org/wikipedia/commons/thumb/4/43/Starr_080117-1693_Hypoestes_phyllostachya.jpg/1280px-Starr_080117-1693_Hypoestes_phyllostachya.jpg",
"Peperomia%20polybotrya": null,
"Peperomia polybotrya": "https://upload.wikimedia.org/wikipedia/commons/thumb/8/87/Coin_Leaf_Peperomia_%28Peperomia_polybotrya_%27Jayde%27%29.jpg/1280px-Coin_Leaf_Peperomia_%28Peperomia_polybotrya_%27Jayde%27%29.jpg",
"Ranunculus%20asiaticus": null,
"Ranunculus asiaticus": "https://upload.wikimedia.org/wikipedia/commons/thumb/2/23/Persian_Buttercup_01.jpg/1280px-Persian_Buttercup_01.jpg",
"Rhododendron%20catawbiense": null,
"Rhododendron catawbiense": "https://upload.wikimedia.org/wikipedia/commons/thumb/6/6d/Rhododendron-catawbiense.jpg/1280px-Rhododendron-catawbiense.jpg",
"Calendula%20officinalis": "https://upload.wikimedia.org/wikipedia/commons/4/41/Calendula_officinalis_01.jpg",
"Delphinium%20elatum": null,
"Delphinium elatum": "https://upload.wikimedia.org/wikipedia/commons/5/54/Delphinium_elatum_a2.jpg",
"Robinia%20pseudoacacia": null,
"Robinia pseudoacacia": "https://upload.wikimedia.org/wikipedia/commons/thumb/4/4b/Robinia_pseudoacacia_fruits.jpg/1280px-Robinia_pseudoacacia_fruits.jpg",
"Chamaemelum%20nobile": null,
"Chamaemelum nobile": "https://upload.wikimedia.org/wikipedia/commons/thumb/a/a7/Chamomile%40original_size.jpg/1280px-Chamomile%40original_size.jpg",
"Rosa%20x%20hybrida": null,
"Rosa x hybrida": "https://upload.wikimedia.org/wikipedia/commons/d/db/Rosa_x_hybrida_iceberg_Reimer_Kordes_1958.JPG",
"Rudbeckia%20hirta": null,
"Rudbeckia hirta": "https://upload.wikimedia.org/wikipedia/commons/thumb/d/d1/Black_eyed_susan_20040717_110754_2.1474.jpg/1280px-Black_eyed_susan_20040717_110754_2.1474.jpg",
"Anthurium%20clarinervium": null,
"Anthurium clarinervium": "https://upload.wikimedia.org/wikipedia/commons/6/65/Anthurium_clarinervium.jpg",
"Rumex%20acetosa": null,
"Rumex acetosa": "https://upload.wikimedia.org/wikipedia/commons/thumb/e/ee/Rumex_acetosa_-_Hapu_oblikas.jpg/1280px-Rumex_acetosa_-_Hapu_oblikas.jpg",
"Buddleja%20davidii": null,
"Buddleja davidii": "https://upload.wikimedia.org/wikipedia/commons/thumb/0/0d/Distelfalter%2C_Vanessa_cardui_AUF_Schmetterlingsflieder%2C_Buddleja_davidii_1.JPG/1280px-Distelfalter%2C_Vanessa_cardui_AUF_Schmetterlingsflieder%2C_Buddleja_davidii_1.JPG",
"Allium%20tuberosum": null,
"Allium tuberosum": "https://upload.wikimedia.org/wikipedia/commons/a/a8/Allium_tuberosum1.jpg",
"Asclepias%20tuberosa": null,
"Asclepias tuberosa": "https://upload.wikimedia.org/wikipedia/commons/thumb/c/c9/Butterfly_Weed_Asclepias_tuberosa_Flower_Buds_3008px.jpg/1280px-Butterfly_Weed_Asclepias_tuberosa_Flower_Buds_3008px.jpg",
"Leucanthemum%20x%20superbum": null,
"Perilla%20frutescens": null,
"Leucanthemum x superbum": "https://upload.wikimedia.org/wikipedia/commons/thumb/f/f5/Leucanthemum_x_superbum_%27Becky%27_in_NH.jpg/1280px-Leucanthemum_x_superbum_%27Becky%27_in_NH.jpg",
"Perilla frutescens": "https://upload.wikimedia.org/wikipedia/commons/thumb/e/ee/Perilla_frutescens%27_flower.jpg/1280px-Perilla_frutescens%27_flower.jpg",
"Helianthus%20annuus": null,
"Spiraea%20japonica": "https://upload.wikimedia.org/wikipedia/commons/thumb/8/8a/Spiraea_japonica_%27Gold_Mound%27_6_2021_Japanese_Spirea-_%2851238159051%29.jpg/1280px-Spiraea_japonica_%27Gold_Mound%27_6_2021_Japanese_Spirea-_%2851238159051%29.jpg",
"Helianthus annuus": "https://upload.wikimedia.org/wikipedia/commons/6/66/Sunflower_l.jpg",
"Acer%20platanoides": null,
"Acer platanoides": "https://upload.wikimedia.org/wikipedia/commons/thumb/d/d1/2020_year._Herbarium._Acer_platanoides._img-012.jpg/1280px-2020_year._Herbarium._Acer_platanoides._img-012.jpg",
"Ilex%20aquifolium": null,
"Ilex aquifolium": "https://upload.wikimedia.org/wikipedia/commons/thumb/4/4f/Mahonia_aquifolium_qtl1.jpg/1280px-Mahonia_aquifolium_qtl1.jpg",
"Pelargonium%20zonale": "https://upload.wikimedia.org/wikipedia/commons/6/64/Normal_Pelargonium-zonale-376.jpg",
"Stevia%20rebaudiana": null,
"Stevia rebaudiana": "https://upload.wikimedia.org/wikipedia/commons/d/d9/Stevia-rebaudiana-total.JPG",
"Alcea%20rosea": "https://upload.wikimedia.org/wikipedia/commons/thumb/a/a8/Alcea_rosea_6_2021_Hollyhock-_%2851264042906%29.jpg/1280px-Alcea_rosea_6_2021_Hollyhock-_%2851264042906%29.jpg",
"Curio%20radicans": null,
"Curio radicans": "https://upload.wikimedia.org/wikipedia/commons/thumb/4/43/Senecio_radicans%2C_jard%C3%ADn_bot%C3%A1nico_de_Tallinn%2C_Estonia%2C_2012-08-13%2C_DD_01.JPG/1280px-Senecio_radicans%2C_jard%C3%ADn_bot%C3%A1nico_de_Tallinn%2C_Estonia%2C_2012-08-13%2C_DD_01.JPG",
"Curio%20x%20peregrinus": null,
"Curio x peregrinus": "https://upload.wikimedia.org/wikipedia/commons/thumb/9/9f/Getty_Research_Institute_%28IA_johannchristiank00kund%29.pdf/page1-725px-Getty_Research_Institute_%28IA_johannchristiank00kund%29.pdf.jpg",
"Hemerocallis%20fulva": "https://upload.wikimedia.org/wikipedia/commons/0/0e/Hemerocallis_fulva_-_flower_view_02.jpg",
"Lamprocapnos%20spectabilis": null,
"Lamprocapnos spectabilis": "https://upload.wikimedia.org/wikipedia/commons/thumb/d/df/Tr%C3%A4nendes_Herz_%28Dicentra_spectabilis%29.jpg/1280px-Tr%C3%A4nendes_Herz_%28Dicentra_spectabilis%29.jpg",
"Catalpa%20bignonioides": null,
"Catalpa bignonioides": "https://upload.wikimedia.org/wikipedia/commons/thumb/0/0b/Catalpa_bignonioides_Aurea_JPG1a.jpg/1280px-Catalpa_bignonioides_Aurea_JPG1a.jpg",
"Campsis%20radicans": null,
"Campsis radicans": "https://upload.wikimedia.org/wikipedia/commons/thumb/a/af/Campsis_radicans_0.4_R.jpg/1280px-Campsis_radicans_0.4_R.jpg",
"Myosotis%20sylvatica": null,
"Myosotis sylvatica": "https://upload.wikimedia.org/wikipedia/commons/thumb/4/4b/Myosotis_sylvatica_%28aka%29.jpg/1280px-Myosotis_sylvatica_%28aka%29.jpg",
"Eutrema%20japonicum": null,
"Eutrema japonicum": "https://upload.wikimedia.org/wikipedia/commons/thumb/5/5d/Wasabia_japonica_1.JPG/1280px-Wasabia_japonica_1.JPG",
"Peperomia%20argyreia": null,
"Peperomia argyreia": "https://upload.wikimedia.org/wikipedia/commons/thumb/a/ac/Peperomia_argyreia.jpg/1280px-Peperomia_argyreia.jpg",
"Weigela%20florida": "https://upload.wikimedia.org/wikipedia/commons/thumb/7/7e/Weigela_florida_%27Bokrashine%27_%27SHINING_SENSATION%27_6_2021_Weigela-_%2851239536602%29.jpg/1280px-Weigela_florida_%27Bokrashine%27_%27SHINING_SENSATION%27_6_2021_Weigela-_%2851239536602%29.jpg",
"Hyssopus%20officinalis": null,
"Hyssopus officinalis": "https://upload.wikimedia.org/wikipedia/commons/thumb/6/6d/Hyssopus-officinalis-habit.jpg/1280px-Hyssopus-officinalis-habit.jpg",
"Yucca%20aloifolia": null,
"Yucca aloifolia": "https://upload.wikimedia.org/wikipedia/commons/thumb/6/6c/Yucca_aloifolia_4.jpg/1280px-Yucca_aloifolia_4.jpg",
"Cedrus%20libani": null,
"Cedrus libani": "https://upload.wikimedia.org/wikipedia/commons/thumb/1/19/Cedrus_libani_foliage_PAN.JPG/1280px-Cedrus_libani_foliage_PAN.JPG",
"Nicotiana%20alata": null,
"Nicotiana alata": "https://upload.wikimedia.org/wikipedia/commons/thumb/4/45/Nicotiana_alata1By_Carl_E_Lewis.jpg/1280px-Nicotiana_alata1By_Carl_E_Lewis.jpg",
"Cymbopogon%20citratus": null,
"Cymbopogon citratus": "https://upload.wikimedia.org/wikipedia/commons/b/bd/YosriNov04Pokok_Serai.JPG",
"Aloysia%20citrodora": null,
"Aloysia citrodora": "https://upload.wikimedia.org/wikipedia/commons/a/a6/Aloysia_citriodora_002.jpg",
"Cupressus%20sempervirens": null,
"Cupressus sempervirens": "https://upload.wikimedia.org/wikipedia/commons/thumb/9/9c/Cypress_Halefka.JPG/1280px-Cypress_Halefka.JPG",
"Cycas revoluta": "https://upload.wikimedia.org/wikipedia/commons/f/f4/Cycas_revoluta1327A.JPG",
"Calendula officinalis": "https://upload.wikimedia.org/wikipedia/commons/thumb/0/0e/Calendula_in_Autumn.jpg/1280px-Calendula_in_Autumn.jpg",
"Pelargonium zonale": "https://upload.wikimedia.org/wikipedia/commons/thumb/a/a7/Pelargonium_zonale_%28Geraniaceae%29.jpg/1280px-Pelargonium_zonale_%28Geraniaceae%29.jpg",
"Hemerocallis fulva": "https://upload.wikimedia.org/wikipedia/commons/thumb/6/66/Daylily_%28Hemerocallis_fulva%29_v2.jpg/1280px-Daylily_%28Hemerocallis_fulva%29_v2.jpg",
"Garden croton": "https://upload.wikimedia.org/wikipedia/commons/thumb/b/bf/Colpfl05.jpg/1280px-Colpfl05.jpg",
"Senecio peregrinus": "https://upload.wikimedia.org/wikipedia/commons/thumb/4/43/Figure_2._Schematic_representation_of_homoploid_and_allopolyploid_hybrid_speciation._Updated.svg/1280px-Figure_2._Schematic_representation_of_homoploid_and_allopolyploid_hybrid_speciation._Updated.svg.png"
{
"Stapelia grandiflora": "https://upload.wikimedia.org/wikipedia/commons/6/67/Carrion_Plant_%28Stapelia_grandiflora%29_2.jpg",
"Saintpaulia ionantha": "https://upload.wikimedia.org/wikipedia/commons/thumb/1/19/2007-04-20Saintpaulia_ionantha04.jpg/1280px-2007-04-20Saintpaulia_ionantha04.jpg",
"Agave americana": "https://upload.wikimedia.org/wikipedia/commons/thumb/3/3b/Agave_July_2011-1.jpg/1280px-Agave_July_2011-1.jpg",
"Alocasia amazonica": "https://upload.wikimedia.org/wikipedia/commons/0/0e/Alocasia_x_amazonica_a2.jpg",
"Arnica montana": "https://upload.wikimedia.org/wikipedia/commons/thumb/1/17/Arnica_montana_-_K%C3%B6hler%E2%80%93s_Medizinal-Pflanzen-015.jpg/1280px-Arnica_montana_-_K%C3%B6hler%E2%80%93s_Medizinal-Pflanzen-015.jpg",
"Valeriana officinalis": "https://upload.wikimedia.org/wikipedia/commons/1/17/Valeriana_officinalis_-_K%C3%B6hler%E2%80%93s_Medizinal-Pflanzen-143.jpg",
"Bambusa vulgaris": "https://upload.wikimedia.org/wikipedia/commons/thumb/5/51/Bambusa_vulgaris_%28Dominica%29.jpg/1280px-Bambusa_vulgaris_%28Dominica%29.jpg",
"Musa acuminata": "https://upload.wikimedia.org/wikipedia/commons/thumb/2/2e/Musa_acuminata_kz01.jpg/1280px-Musa_acuminata_kz01.jpg",
"Chamaedorea elegans": "https://upload.wikimedia.org/wikipedia/commons/thumb/f/f5/Chamaedorea_elegans.jpg/1280px-Chamaedorea_elegans.jpg",
"Senecio serpens": "https://upload.wikimedia.org/wikipedia/commons/2/2b/Senecio_serpens.jpg",
"Phlebodium aureum": "https://upload.wikimedia.org/wikipedia/commons/thumb/2/2d/Starr_050107-2831_Phlebodium_aureum.jpg/1280px-Starr_050107-2831_Phlebodium_aureum.jpg",
"Canna indica": "https://upload.wikimedia.org/wikipedia/commons/9/99/Canna_indica_%28wild_species%29_flowers.JPG",
"Geranium sanguineum": "https://upload.wikimedia.org/wikipedia/commons/thumb/e/e2/Geranium_sanguineum_-_verev_kurereha.jpg/1280px-Geranium_sanguineum_-_verev_kurereha.jpg",
"Ficus retusa": "https://upload.wikimedia.org/wikipedia/commons/thumb/9/9d/Ficus_retusa_2zz.jpg/1280px-Ficus_retusa_2zz.jpg",
"Bougainvillea spectabilis": "https://upload.wikimedia.org/wikipedia/commons/thumb/f/f8/Starr_030418-0058_Bougainvillea_spectabilis.jpg/1280px-Starr_030418-0058_Bougainvillea_spectabilis.jpg",
"Caladium bicolor": "https://upload.wikimedia.org/wikipedia/commons/thumb/b/b8/Caladium_bicolor_%27Florida_Sweetheart%27_Plant_2220px.jpg/1280px-Caladium_bicolor_%27Florida_Sweetheart%27_Plant_2220px.jpg",
"Calibrachoa hybrida": "https://upload.wikimedia.org/wikipedia/commons/thumb/e/e2/Basket_of_Calibrachoa_parviflora_by_A_-_2020-06-18.jpg/1280px-Basket_of_Calibrachoa_parviflora_by_A_-_2020-06-18.jpg",
"Callisia repens": "https://upload.wikimedia.org/wikipedia/commons/thumb/e/e6/Turtle_Vine_%28Callisia_repens%29_1.jpg/1280px-Turtle_Vine_%28Callisia_repens%29_1.jpg",
"Cattleya labiata": "https://upload.wikimedia.org/wikipedia/commons/9/97/Cattleya_labiata_2.jpg",
"Capsicum annuum": "https://upload.wikimedia.org/wikipedia/commons/thumb/c/cc/Capsicum_annuum.JPG/1280px-Capsicum_annuum.JPG",
"Livistona chinensis": "https://upload.wikimedia.org/wikipedia/commons/f/f6/Livistona-chinensis.jpg",
"Rosa chinensis": "https://upload.wikimedia.org/wikipedia/commons/thumb/9/95/Rosa_chinensis_04-08-2012_01.jpg/1280px-Rosa_chinensis_04-08-2012_01.jpg",
"Columnea gloriosa": "https://upload.wikimedia.org/wikipedia/commons/thumb/6/6d/Columnea_gloriosa_137-8378.jpg/1280px-Columnea_gloriosa_137-8378.jpg",
"Dieffenbachia seguine": "https://upload.wikimedia.org/wikipedia/commons/8/80/Dieffenbachia_seguine1FKST.jpg",
"Dischidia ruscifolia": "https://upload.wikimedia.org/wikipedia/commons/thumb/e/ec/%E7%99%BE%E8%90%AC%E5%BF%83_Dischidia_ruscifolia_-%E9%A6%99%E6%B8%AF%E5%8B%95%E6%A4%8D%E7%89%A9%E5%85%AC%E5%9C%92_Hong_Kong_Botanical_Garden-_%289240152650%29.jpg/1280px-%E7%99%BE%E8%90%AC%E5%BF%83_Dischidia_ruscifolia_-%E9%A6%99%E6%B8%AF%E5%8B%95%E6%A4%8D%E7%89%A9%E5%85%AC%E5%9C%92_Hong_Kong_Botanical_Garden-_%289240152650%29.jpg",
"Dracaena marginata": "https://upload.wikimedia.org/wikipedia/commons/f/f9/Dracaena_marginata_IndoorPlant_0605k.jpg",
"Streptocarpus hybridus": "https://upload.wikimedia.org/wikipedia/commons/9/9f/2007-03-20Streptocarpus01.jpg",
"Dudleya brittonii": "https://upload.wikimedia.org/wikipedia/commons/thumb/1/10/Dudleya_Brittonii.jpg/1280px-Dudleya_Brittonii.jpg",
"Acer palmatum": "https://upload.wikimedia.org/wikipedia/commons/thumb/d/de/Portland_Japanese_Garden_maple.jpg/1280px-Portland_Japanese_Garden_maple.jpg",
"Fatsia japonica": "https://upload.wikimedia.org/wikipedia/commons/thumb/4/44/Fatsia_japonica.003_-_Zapateira.jpg/1280px-Fatsia_japonica.003_-_Zapateira.jpg",
"Vriesea splendens": "https://upload.wikimedia.org/wikipedia/commons/thumb/d/d6/Vriesea_splendens_flower.jpg/1280px-Vriesea_splendens_flower.jpg",
"Impatiens walleriana": "https://upload.wikimedia.org/wikipedia/commons/thumb/1/1e/Impatiens_walleriana_01.JPG/1280px-Impatiens_walleriana_01.JPG",
"Begonia maculata": "https://upload.wikimedia.org/wikipedia/commons/d/df/Begonia_maculata3073316230.jpg",
"Adiantum raddianum": "https://upload.wikimedia.org/wikipedia/commons/thumb/4/45/Starr_030807-0143_Adiantum_raddianum.jpg/1280px-Starr_030807-0143_Adiantum_raddianum.jpg",
"Fuchsia hybrida": "https://upload.wikimedia.org/wikipedia/commons/thumb/c/cc/Fuchsia_%C3%97_hybrida.jpg/1280px-Fuchsia_%C3%97_hybrida.jpg",
"Gasteria carinata": "https://upload.wikimedia.org/wikipedia/commons/thumb/1/12/Gasteria_carinata_hh.jpg/1280px-Gasteria_carinata_hh.jpg",
"Ficus lyrata": "https://upload.wikimedia.org/wikipedia/commons/thumb/8/84/Starr_031108-0130_Ficus_lyrata.jpg/1280px-Starr_031108-0130_Ficus_lyrata.jpg",
"Graptopetalum paraguayense": "https://upload.wikimedia.org/wikipedia/commons/thumb/5/59/%28MHNT%29_Graptopetalum_paraguayense_-_leaves.jpg/1280px-%28MHNT%29_Graptopetalum_paraguayense_-_leaves.jpg",
"Platycerium bifurcatum": "https://upload.wikimedia.org/wikipedia/commons/thumb/a/ab/Platycerium_bifurcatum_%28Corne_d%27%C3%A9lan%29_-_107.jpg/1280px-Platycerium_bifurcatum_%28Corne_d%27%C3%A9lan%29_-_107.jpg",
"Gloxinia speciosa": "https://upload.wikimedia.org/wikipedia/commons/8/87/Sinnningia_speciosa_im_Querbeet-Wintergarten.jpg",
"Echinocactus grusonii": "https://upload.wikimedia.org/wikipedia/commons/thumb/4/46/Echinocactus_grusonii_qtl1.jpg/1280px-Echinocactus_grusonii_qtl1.jpg",
"Phyllostachys aurea": "https://upload.wikimedia.org/wikipedia/commons/3/3b/Phyllostachys_aurea0.jpg",
"Punica granatum": "https://upload.wikimedia.org/wikipedia/commons/thumb/d/db/Illustration_Punica_granatum2.jpg/1280px-Illustration_Punica_granatum2.jpg",
"Schefflera actinophylla": "https://upload.wikimedia.org/wikipedia/commons/thumb/e/e6/Schefflera_actinophylla_02.JPG/1280px-Schefflera_actinophylla_02.JPG",
"Ficus elastica": "https://upload.wikimedia.org/wikipedia/commons/thumb/8/88/Ficus_November_2008-1.jpg/1280px-Ficus_November_2008-1.jpg",
"Cucumis sativus": "https://upload.wikimedia.org/wikipedia/commons/thumb/0/07/Cucumis_sativus_0001.JPG/1280px-Cucumis_sativus_0001.JPG",
"Betula pendula": "https://upload.wikimedia.org/wikipedia/commons/thumb/1/17/Betula_pendula_in_Sedovo_1.jpg/1280px-Betula_pendula_in_Sedovo_1.jpg",
"Philodendron hederaceum": "https://upload.wikimedia.org/wikipedia/commons/b/bb/Philodendron_scandens_subsp_oxycardium2.jpg",
"Ceropegia woodii": "https://upload.wikimedia.org/wikipedia/commons/5/55/Ceropegia_linearis_subsp_woodii.jpg",
"Hyacinthus orientalis": "https://upload.wikimedia.org/wikipedia/commons/thumb/a/ae/Garden_Hyacinth_Hyacinthus_orientalis_%27Blue_Jacket%27_Flower_2000px.jpg/1280px-Garden_Hyacinth_Hyacinthus_orientalis_%27Blue_Jacket%27_Flower_2000px.jpg",
"Azalea indica": "https://upload.wikimedia.org/wikipedia/commons/thumb/d/de/Rhododendron_indicum1.jpg/1280px-Rhododendron_indicum1.jpg",
"Zingiber officinale": "https://upload.wikimedia.org/wikipedia/commons/c/c1/Ginger_Plant_vs.jpg",
"Crassula ovata": "https://upload.wikimedia.org/wikipedia/commons/thumb/5/5d/Crassula_ovata_RTBG.jpg/1280px-Crassula_ovata_RTBG.jpg",
"Rhododendron simsii": "https://upload.wikimedia.org/wikipedia/commons/thumb/9/9b/Rhododendron_simsii_01.JPG/1280px-Rhododendron_simsii_01.JPG",
"Coffea arabica": "https://upload.wikimedia.org/wikipedia/commons/1/10/Coffea_arabica_2.jpg",
"Kalanchoe blossfeldiana": "https://upload.wikimedia.org/wikipedia/commons/thumb/f/fa/Kalanchoe_blossfeldiana_%28Florist_Kalanchoe%29.jpg/1280px-Kalanchoe_blossfeldiana_%28Florist_Kalanchoe%29.jpg",
"Camellia japonica": "https://upload.wikimedia.org/wikipedia/commons/thumb/6/6d/Camellia_japonica_var._decumbens_3.JPG/1280px-Camellia_japonica_var._decumbens_3.JPG",
"Chamomilla recutita": "https://upload.wikimedia.org/wikipedia/commons/thumb/a/a7/Chamomile%40original_size.jpg/1280px-Chamomile%40original_size.jpg",
"Aloe arborescens": "https://upload.wikimedia.org/wikipedia/commons/thumb/3/34/Aloe_arborescens_01.JPG/1280px-Aloe_arborescens_01.JPG",
"Aloe ferox": "https://upload.wikimedia.org/wikipedia/commons/9/9e/Aloe_Ferox_between_Cofimvaba_and_Ngcobo.jpg",
"Daucus carota": "https://upload.wikimedia.org/wikipedia/commons/thumb/2/23/Daucus_carota_May_2008-1_edit.jpg/1280px-Daucus_carota_May_2008-1_edit.jpg",
"Oxalis triangularis": "https://upload.wikimedia.org/wikipedia/commons/1/15/Oxalis_triangularis6.jpg",
"Hoya bella": "https://upload.wikimedia.org/wikipedia/commons/thumb/d/d1/%E7%BE%8E%E9%BA%97%E6%AF%AC%E8%98%AD_Hoya_bella_-%E9%A6%99%E6%B8%AF%E5%85%AC%E5%9C%92_Hong_Kong_Park-_%289240227808%29.jpg/1280px-%E7%BE%8E%E9%BA%97%E6%AF%AC%E8%98%AD_Hoya_bella_-%E9%A6%99%E6%B8%AF%E5%85%AC%E5%9C%92_Hong_Kong_Park-_%289240227808%29.jpg",
"Begonia tuberhybrida": "https://upload.wikimedia.org/wikipedia/commons/thumb/6/62/Begonia_x_tuberhybrida_1005Pink1.JPG/1280px-Begonia_x_tuberhybrida_1005Pink1.JPG",
"Epiphyllum oxypetalum": "https://upload.wikimedia.org/wikipedia/commons/thumb/5/59/Kakteen_IMG_4213.jpg/1280px-Kakteen_IMG_4213.jpg",
"Protea cynaroides": "https://upload.wikimedia.org/wikipedia/commons/thumb/3/3d/Protea_cynaroides_3.jpg/1280px-Protea_cynaroides_3.jpg",
"Conophytum calculus": "https://upload.wikimedia.org/wikipedia/commons/thumb/2/25/Conophytum_calculus_-_Namaqualand_-_South_Africa_5.jpg/1280px-Conophytum_calculus_-_Namaqualand_-_South_Africa_5.jpg",
"Rhipsalis baccifera": "https://upload.wikimedia.org/wikipedia/commons/b/ba/Rhipsalis_baccifera_01_ies.jpg",
"Cotyledon orbiculata": "https://upload.wikimedia.org/wikipedia/commons/thumb/b/bb/Cotyledon_orbiculata_2.jpg/1280px-Cotyledon_orbiculata_2.jpg",
"Lithops julii": "https://upload.wikimedia.org/wikipedia/commons/4/4d/Lithops_julii_Nicos_Farm_strain.jpg",
"Tradescantia pallida": "https://upload.wikimedia.org/wikipedia/commons/thumb/b/be/Tradescantia_pallida_C.jpg/1280px-Tradescantia_pallida_C.jpg",
"Aeschynanthus radicans": "https://upload.wikimedia.org/wikipedia/commons/5/52/Aeschynanthus_radicans.jpg",
"Tillandsia ionantha": "https://upload.wikimedia.org/wikipedia/commons/thumb/d/d9/Matthaei_Botanical_Gardens_-_IMG_8999.JPG/1280px-Matthaei_Botanical_Gardens_-_IMG_8999.JPG",
"Stephanotis floribunda": "https://upload.wikimedia.org/wikipedia/commons/9/9d/Stephanotis_floribunda.jpg",
"Dracaena fragrans": "https://upload.wikimedia.org/wikipedia/commons/6/64/Dracaena_fragrans_a1.jpg",
"Beta vulgaris": "https://upload.wikimedia.org/wikipedia/commons/f/ff/Beta_vulgaris_-_K%C3%B6hler%E2%80%93s_Medizinal-Pflanzen-167.jpg",
"Rhaphidophora tetrasperma": "https://upload.wikimedia.org/wikipedia/commons/d/db/Rhaphidophora_tetrasperma.jpg",
"Pachyphytum oviferum": "https://upload.wikimedia.org/wikipedia/commons/thumb/3/39/Pachyphytum_oviferum_1.jpg/1280px-Pachyphytum_oviferum_1.jpg",
"Monstera adansonii": "https://upload.wikimedia.org/wikipedia/commons/thumb/3/34/Monstera_adansonii_CBM.png/1280px-Monstera_adansonii_CBM.png",
"Crassula muscosa": "https://upload.wikimedia.org/wikipedia/commons/5/5e/Crassula_muscosa_Grubosz_2006-05-03_01.jpg",
"Epipremnum pinnatum Neon": "https://upload.wikimedia.org/wikipedia/commons/thumb/d/de/Epipremnum_pinnatum_%27Neon%27%2C_Longwood_Gardens_01.jpg/1280px-Epipremnum_pinnatum_%27Neon%27%2C_Longwood_Gardens_01.jpg",
"Lilium longiflorum": "https://upload.wikimedia.org/wikipedia/commons/thumb/d/dd/Lilium_longiflorum_%28Easter_Lily%29.JPG/1280px-Lilium_longiflorum_%28Easter_Lily%29.JPG",
"Senecio rowleyanus": "https://upload.wikimedia.org/wikipedia/commons/thumb/3/3b/Senecio_rowleyanus.jpg/1280px-Senecio_rowleyanus.jpg",
"Petroselinum crispum": "https://upload.wikimedia.org/wikipedia/commons/2/2d/Petroselinum_crispum_-_K%C3%B6hler%E2%80%93s_Medizinal-Pflanzen-103.jpg",
"Petunia hybrida": "https://upload.wikimedia.org/wikipedia/commons/thumb/a/af/Petunia_x_hybrida_a2.JPG/1280px-Petunia_x_hybrida_a2.JPG",
"Syngonium podophyllum": "https://upload.wikimedia.org/wikipedia/commons/thumb/0/07/Syngonium_podophyllum_Golden_0zz.jpg/1280px-Syngonium_podophyllum_Golden_0zz.jpg",
"Philodendron bipinnatifidum": "https://upload.wikimedia.org/wikipedia/commons/c/c1/Tree1.JPG",
"Philodendron gloriosum": "https://upload.wikimedia.org/wikipedia/commons/f/f1/Philodendron_gloriosum.jpg",
"Primula vulgaris": "https://upload.wikimedia.org/wikipedia/commons/thumb/7/76/Chipping_Sodbury_MMB_02_primula_vulgaris.jpg/1280px-Chipping_Sodbury_MMB_02_primula_vulgaris.jpg",
"Raphanus sativus": "https://upload.wikimedia.org/wikipedia/commons/thumb/7/73/Raphanus_sativus_of_Salem.jpg/1280px-Raphanus_sativus_of_Salem.jpg",
"Selaginella uncinata": "https://upload.wikimedia.org/wikipedia/commons/thumb/3/34/Selaginella_uncinata_-_Berlin_Botanical_Garden_-_IMG_8722.JPG/1280px-Selaginella_uncinata_-_Berlin_Botanical_Garden_-_IMG_8722.JPG",
"Peperomia caperata": "https://upload.wikimedia.org/wikipedia/commons/thumb/1/14/Peperomia_caperata_1-OB9.jpg/1280px-Peperomia_caperata_1-OB9.jpg",
"Hippeastrum hybrid": "https://upload.wikimedia.org/wikipedia/commons/thumb/f/fc/Hippeastrum_hybrid_1.jpg/1280px-Hippeastrum_hybrid_1.jpg",
"Digitalis purpurea": "https://upload.wikimedia.org/wikipedia/commons/thumb/b/ba/Digitalis_purpurea_LC0101.jpg/1280px-Digitalis_purpurea_LC0101.jpg",
"Lactuca sativa": "https://upload.wikimedia.org/wikipedia/commons/thumb/6/6b/Romaine_lettuce.jpg/1280px-Romaine_lettuce.jpg",
"Salvia officinalis": "https://upload.wikimedia.org/wikipedia/commons/5/5a/Salvia_officinalis0.jpg",
"Scindapsus pictus": "https://upload.wikimedia.org/wikipedia/commons/c/ca/Scindapsus_pictus_01.jpg",
"Mimosa pudica": "https://upload.wikimedia.org/wikipedia/commons/thumb/0/04/Touch_Me_not.jpg/1280px-Touch_Me_not.jpg",
"Phalaenopsis amabilis": "https://upload.wikimedia.org/wikipedia/commons/3/32/Phalaenopsis_amabilis_Orchi_03.jpg",
"Allium schoenoprasum": "https://upload.wikimedia.org/wikipedia/commons/thumb/7/74/Allium_schoenoprasum_J1.JPG/1280px-Allium_schoenoprasum_J1.JPG",
"Pandanus veitchii": "https://upload.wikimedia.org/wikipedia/commons/thumb/f/fb/Pandanus_tectorius.jpg/1280px-Pandanus_tectorius.jpg",
"Aeonium arboreum": "https://upload.wikimedia.org/wikipedia/commons/thumb/c/c4/Aeonium_arboreum_01.jpg/1280px-Aeonium_arboreum_01.jpg",
"Adenium socotranum": "https://upload.wikimedia.org/wikipedia/commons/thumb/f/fa/Adenium_seedling_2day_Uthandi_Aug21_D72_20609-21_ZP.jpg/1280px-Adenium_seedling_2day_Uthandi_Aug21_D72_20609-21_ZP.jpg",
"Echinacea purpurea": "https://upload.wikimedia.org/wikipedia/commons/thumb/b/b5/Monarch_Butterfly_Danaus_plexippus_on_Echinacea_purpurea_2800px.jpg/1280px-Monarch_Butterfly_Danaus_plexippus_on_Echinacea_purpurea_2800px.jpg",
"Tillandsia usneoides": "https://upload.wikimedia.org/wikipedia/commons/thumb/7/74/Tillandsia_usneoides_%28Bromeliaceae%29_%2825004066956%29.jpg/1280px-Tillandsia_usneoides_%28Bromeliaceae%29_%2825004066956%29.jpg",
"Rhapis excelsa": "https://upload.wikimedia.org/wikipedia/commons/7/7d/Rhapis_excelsa.jpg",
"Viola wittrockiana": "https://upload.wikimedia.org/wikipedia/commons/thumb/9/91/Pansy_viola_x_wittrockiana_blue.jpg/1280px-Pansy_viola_x_wittrockiana_blue.jpg",
"Schefflera arboricola": "https://upload.wikimedia.org/wikipedia/commons/thumb/8/82/Schefflera_arboricola%2C_vrugte%2C_a%2C_Pretoria.jpg/1280px-Schefflera_arboricola%2C_vrugte%2C_a%2C_Pretoria.jpg",
"Tagetes patula": "https://upload.wikimedia.org/wikipedia/commons/thumb/1/1f/French_marigold_garden_2009_G1.jpg/1280px-French_marigold_garden_2009_G1.jpg",
"Oncidium sphacelatum": "https://upload.wikimedia.org/wikipedia/commons/thumb/3/37/Oncidium_sphacelatum03.jpg/1280px-Oncidium_sphacelatum03.jpg",
"Colocasia esculenta": "https://upload.wikimedia.org/wikipedia/commons/thumb/8/83/Taro_leaf_underside%2C_backlit_by_sun.jpg/1280px-Taro_leaf_underside%2C_backlit_by_sun.jpg",
"Camellia sinensis": "https://upload.wikimedia.org/wikipedia/commons/thumb/e/e3/Camellia_sinensis_MHNT.BOT.2016.12.24.jpg/1280px-Camellia_sinensis_MHNT.BOT.2016.12.24.jpg",
"Plumeria rubra": "https://upload.wikimedia.org/wikipedia/commons/thumb/a/a2/White_Plumeria_from_Kannur_-_Kerala.jpg/1280px-White_Plumeria_from_Kannur_-_Kerala.jpg",
"Thymus vulgaris": "https://upload.wikimedia.org/wikipedia/commons/1/12/Thymus_vulgaris_-_K%C3%B6hler%E2%80%93s_Medizinal-Pflanzen-271.jpg",
"Cordyline fruticosa": "https://upload.wikimedia.org/wikipedia/commons/0/05/Ti_plant_%28Cordyline_fruticosa%29.jpg",
"Solanum lycopersicum": "https://upload.wikimedia.org/wikipedia/commons/thumb/8/89/Tomato_je.jpg/1280px-Tomato_je.jpg",
"Polypodium vulgare": "https://upload.wikimedia.org/wikipedia/commons/thumb/3/33/Polypodium_vulgare%2C_sores_%28Matthieu_Gauvain%29.JPG/1280px-Polypodium_vulgare%2C_sores_%28Matthieu_Gauvain%29.JPG",
"Microsorum punctatum": "https://upload.wikimedia.org/wikipedia/commons/thumb/9/9f/Microsorum_punctatum.jpg/1280px-Microsorum_punctatum.jpg",
"Tulipa gesneriana": "https://upload.wikimedia.org/wikipedia/commons/thumb/9/9e/%D7%A6%D7%91%D7%A2%D7%95%D7%A0%D7%99%D7%9D.JPG/1280px-%D7%A6%D7%91%D7%A2%D7%95%D7%A0%D7%99%D7%9D.JPG",
"Vanilla planifolia": "https://upload.wikimedia.org/wikipedia/commons/4/40/Vanilla_planifolia_1.jpg",
"Asplenium nidus": "https://upload.wikimedia.org/wikipedia/commons/thumb/7/73/Bird%27s-nest_Fern_Asplenium_nidus_Leaves_1.jpg/1280px-Bird%27s-nest_Fern_Asplenium_nidus_Leaves_1.jpg",
"Lantana camara": "https://upload.wikimedia.org/wikipedia/commons/thumb/7/71/Twin_lantana_camara_edit.jpg/1280px-Twin_lantana_camara_edit.jpg",
"Schlumbergera truncata": "https://upload.wikimedia.org/wikipedia/commons/thumb/6/61/Schlumbergera_truncata_flordemaio.jpg/1280px-Schlumbergera_truncata_flordemaio.jpg",
"Euphorbia pulcherrima": "https://upload.wikimedia.org/wikipedia/commons/thumb/5/5d/2008_05_17_-_Euphorbia_pulcherrima_05a.JPG/1280px-2008_05_17_-_Euphorbia_pulcherrima_05a.JPG",
"Tradescantia fluminensis": "https://upload.wikimedia.org/wikipedia/commons/thumb/0/05/Tradescantia_fluminensis_Flower_1.jpg/1280px-Tradescantia_fluminensis_Flower_1.jpg",
"Yucca elephantipes": "https://upload.wikimedia.org/wikipedia/commons/thumb/d/d1/Yucca_elephantipes_HRM2.JPG/1280px-Yucca_elephantipes_HRM2.JPG",
"Hamamelis mollis": "https://upload.wikimedia.org/wikipedia/commons/c/ce/Hamamelis_mollis0.jpg",
"Haworthia fasciata": "https://upload.wikimedia.org/wikipedia/commons/thumb/1/13/Haworthia_fasciata_%28Vermont%29.jpg/1280px-Haworthia_fasciata_%28Vermont%29.jpg",
"Tradescantia zebrina": "https://upload.wikimedia.org/wikipedia/commons/thumb/e/e6/Zebrina_pendula_20060521_2.JPG/1280px-Zebrina_pendula_20060521_2.JPG",
"Zinnia elegans": "https://upload.wikimedia.org/wikipedia/commons/thumb/d/d7/Zinnia_elegans_with_Bombus_01.JPG/1280px-Zinnia_elegans_with_Bombus_01.JPG",
"Citrus limon": "https://upload.wikimedia.org/wikipedia/commons/e/e4/Lemon.jpg",
"Melissa officinalis": "https://upload.wikimedia.org/wikipedia/commons/thumb/f/f9/Melissa_officinalis_1.jpg/1280px-Melissa_officinalis_1.jpg",
"Phoenix roebelenii": "https://upload.wikimedia.org/wikipedia/commons/thumb/1/1d/Starr_070124-3839_Phoenix_roebelenii.jpg/1280px-Starr_070124-3839_Phoenix_roebelenii.jpg",
"Cymbidium lowianum": "https://upload.wikimedia.org/wikipedia/commons/thumb/6/6b/Cymbidium_lowianum_var._concolor_3.jpg/1280px-Cymbidium_lowianum_var._concolor_3.jpg",
"Jasminum%20sambac": null,
"Pilea%20cadierei": null,
"Alocasia%20zebrina": null,
"Anemone%20coronaria": null,
"Jasminum sambac": "https://upload.wikimedia.org/wikipedia/commons/thumb/7/75/JasminumSambac.jpg/1280px-JasminumSambac.jpg",
"Anemone coronaria": "https://upload.wikimedia.org/wikipedia/commons/thumb/8/80/Blue_Anemone_coronaria.JPG/1280px-Blue_Anemone_coronaria.JPG",
"Pilea cadierei": "https://upload.wikimedia.org/wikipedia/commons/thumb/3/30/PileaCadierei.jpg/1280px-PileaCadierei.jpg",
"Alocasia zebrina": "https://upload.wikimedia.org/wikipedia/commons/4/43/Alocasia_zebrina_Reticulata_1zz.jpg",
"Iris%20germanica": null,
"Dianthus%20barbatus": null,
"Iris germanica": "https://upload.wikimedia.org/wikipedia/commons/thumb/2/29/Iris_germanica_001.jpg/1280px-Iris_germanica_001.jpg",
"Dianthus barbatus": "https://upload.wikimedia.org/wikipedia/commons/4/4f/Dianthus_barbatus_PID1688-4.jpg",
"Lavatera%20trimestris": null,
"Lavatera trimestris": "https://upload.wikimedia.org/wikipedia/commons/thumb/5/57/Flower_April_2010-1a.jpg/1280px-Flower_April_2010-1a.jpg",
"Satureja%20hortensis": null,
"Satureja hortensis": "https://upload.wikimedia.org/wikipedia/commons/thumb/5/5f/Satureja_hortensis_bgiu.jpg/1280px-Satureja_hortensis_bgiu.jpg",
"Borago%20officinalis": null,
"Borago officinalis": "https://upload.wikimedia.org/wikipedia/commons/thumb/5/54/Borago_officinalis_%28flower%29.jpg/1280px-Borago_officinalis_%28flower%29.jpg",
"Soleirolia%20soleirolii": null,
"Soleirolia soleirolii": "https://upload.wikimedia.org/wikipedia/commons/thumb/e/ed/Soleirolia_soleirolii_kz05.jpg/1280px-Soleirolia_soleirolii_kz05.jpg",
"Buxus%20sempervirens": null,
"Buxus sempervirens": "https://upload.wikimedia.org/wikipedia/commons/thumb/b/ba/Buisfleurs.jpg/1280px-Buisfleurs.jpg",
"Plectranthus%20scutellarioides": null,
"Plectranthus scutellarioides": "https://upload.wikimedia.org/wikipedia/commons/thumb/9/9c/Solenostemon_scutellarioides_%28Coleus_x_hybridus%29_%27Inky_Fingers%27_Leaf_Cluster_2730px.jpg/1280px-Solenostemon_scutellarioides_%28Coleus_x_hybridus%29_%27Inky_Fingers%27_Leaf_Cluster_2730px.jpg",
"Goeppertia%20insignis": null,
"Goeppertia insignis": "https://upload.wikimedia.org/wikipedia/commons/thumb/9/94/Goeppertia_insignis.jpg/1280px-Goeppertia_insignis.jpg",
"Goeppertia%20ornata": null,
"Goeppertia ornata": "https://upload.wikimedia.org/wikipedia/commons/thumb/4/42/CalatheaOrnataRoseo-Lineata.jpg/1280px-CalatheaOrnataRoseo-Lineata.jpg",
"Clematis%20viticella": null,
"Clematis viticella": "https://upload.wikimedia.org/wikipedia/commons/4/4f/Clematis_viticella3UME.jpg",
"Helichrysum%20italicum": null,
"Helichrysum italicum": "https://upload.wikimedia.org/wikipedia/commons/thumb/c/c5/Helichrysum_italicum_%28immortelle%29.JPG/1280px-Helichrysum_italicum_%28immortelle%29.JPG",
"Anethum%20graveolens": null,
"Anethum graveolens": "https://upload.wikimedia.org/wikipedia/commons/5/56/Anethum_graveolens_02.jpg",
"Lathyrus%20odoratus": null,
"Lathyrus odoratus": "https://upload.wikimedia.org/wikipedia/commons/thumb/7/72/Lathyrus_odoratus_Painted_Lady.jpg/1280px-Lathyrus_odoratus_Painted_Lady.jpg",
"Sorbus%20aucuparia": null,
"Sorbus aucuparia": "https://upload.wikimedia.org/wikipedia/commons/thumb/d/d4/2020_year._Herbarium._Sorbus_aucuparia._img-035.jpg/1280px-2020_year._Herbarium._Sorbus_aucuparia._img-035.jpg",
"Quercus%20robur": null,
"Quercus robur": "https://upload.wikimedia.org/wikipedia/commons/thumb/a/a9/Langaa_egeskov_rimfrost.jpg/1280px-Langaa_egeskov_rimfrost.jpg",
"Begonia%20semperflorens-cultorum": null,
"Begonia semperflorens-cultorum": "https://upload.wikimedia.org/wikipedia/commons/thumb/2/28/Begonia_semperflorens.jpg/1280px-Begonia_semperflorens.jpg",
"Verbena%20bonariensis": null,
"Verbena bonariensis": "https://upload.wikimedia.org/wikipedia/commons/thumb/e/e0/Verbena_bonariensis.jpg/1280px-Verbena_bonariensis.jpg",
"Artemisia%20dracunculus": null,
"Artemisia dracunculus": "https://upload.wikimedia.org/wikipedia/commons/6/6c/Estragon_1511.jpg",
"Eucalyptus%20globulus": null,
"Eucalyptus globulus": "https://upload.wikimedia.org/wikipedia/commons/7/74/Eucalyptus_globulus_globulus.jpg",
"Schefflera%20elegantissima": null,
"Schefflera elegantissima": "https://upload.wikimedia.org/wikipedia/commons/6/64/Schefflera_elegantissima.jpg",
"Salvia%20splendens": null,
"Salvia splendens": "https://upload.wikimedia.org/wikipedia/commons/thumb/0/0d/Salvia_splendens_%28357118788%29.jpg/1280px-Salvia_splendens_%28357118788%29.jpg",
"Picea%20abies": "https://upload.wikimedia.org/wikipedia/commons/thumb/1/1b/Picea_abies_%27Rubra_Spicata%27_5_2021_Norway-_%2851176180455%29.jpg/1280px-Picea_abies_%27Rubra_Spicata%27_5_2021_Norway-_%2851176180455%29.jpg",
"Ficus%20altissima": null,
"Ficus altissima": "https://upload.wikimedia.org/wikipedia/commons/thumb/6/60/Ficus_altissima1.jpg/1280px-Ficus_altissima1.jpg",
"Ficus%20microcarpa": null,
"Ficus microcarpa": "https://upload.wikimedia.org/wikipedia/commons/thumb/d/d6/Bonsai_Adam_and_Eve_%28PPL2-Enhanced%29_julesvernex2.jpg/1280px-Bonsai_Adam_and_Eve_%28PPL2-Enhanced%29_julesvernex2.jpg",
"Delonix%20regia": null,
"Delonix regia": "https://upload.wikimedia.org/wikipedia/commons/thumb/d/d6/Flame_tree_mali.jpg/1280px-Flame_tree_mali.jpg",
"Phlox%20paniculata": "https://upload.wikimedia.org/wikipedia/commons/thumb/e/e2/Phlox_paniculata_%27Opening_Act_Blush%27_6_2021_Garden_Phlox-_%2851242863119%29.jpg/1280px-Phlox_paniculata_%27Opening_Act_Blush%27_6_2021_Garden_Phlox-_%2851242863119%29.jpg",
"Syringa%20vulgaris": "https://upload.wikimedia.org/wikipedia/commons/2/2b/Syringa_vulgaris1.jpg",
"Forsythia%20x%20intermedia": null,
"Forsythia x intermedia": "https://upload.wikimedia.org/wikipedia/commons/b/b7/Forsythia-intermedia.JPG",
"Freesia%20refracta": null,
"Freesia refracta": "https://upload.wikimedia.org/wikipedia/commons/thumb/9/9e/FreesiaRefracta1.JPG/1280px-FreesiaRefracta1.JPG",
"Amaranthus%20caudatus": null,
"Amaranthus caudatus": "https://upload.wikimedia.org/wikipedia/commons/b/b6/Amaranthus_caudatus1.jpg",
"Bellis%20perennis": null,
"Bellis perennis": "https://upload.wikimedia.org/wikipedia/commons/thumb/1/1c/Pink_twinged_daisy_on_table_edit.jpg/1280px-Pink_twinged_daisy_on_table_edit.jpg",
"Impatiens%20balsamina": null,
"Impatiens balsamina": "https://upload.wikimedia.org/wikipedia/commons/f/f8/Impatiens_balsamina1.jpg",
"Hibiscus%20syriacus": null,
"Hibiscus syriacus": "https://upload.wikimedia.org/wikipedia/commons/thumb/7/70/Hibiscus-syriacus.jpg/1280px-Hibiscus-syriacus.jpg",
"Lonicera%20japonica": null,
"Lonicera japonica": "https://upload.wikimedia.org/wikipedia/commons/b/b9/Honeysuckle_2.jpg",
"Ginkgo%20biloba": null,
"Ginkgo biloba": "https://upload.wikimedia.org/wikipedia/commons/thumb/1/19/Ginkgo_biloba_MHNT.BOT.2010.13.1.jpg/1280px-Ginkgo_biloba_MHNT.BOT.2010.13.1.jpg",
"Gladiolus%20hortulanus": null,
"Gladiolus hortulanus": "https://upload.wikimedia.org/wikipedia/commons/thumb/2/25/Gartengladiole_apricot.JPG/1280px-Gartengladiole_apricot.JPG",
"Pachira%20aquatica": null,
"Pachira aquatica": "https://upload.wikimedia.org/wikipedia/commons/thumb/3/3a/Pachira_aquatica_%28inflorescense%29.jpg/1280px-Pachira_aquatica_%28inflorescense%29.jpg",
"Hyacinthoides%20non-scripta": null,
"Hyacinthoides non-scripta": "https://upload.wikimedia.org/wikipedia/commons/thumb/a/a8/Hyacinthoides_non-scripta_%28Common_Bluebell%29.jpg/1280px-Hyacinthoides_non-scripta_%28Common_Bluebell%29.jpg",
"Heliotropium%20arborescens": null,
"Heliotropium arborescens": "https://upload.wikimedia.org/wikipedia/commons/thumb/c/cb/Heliotropium_arborescens_%27Mini_Marine%27_Heliotrope_Flower_2500px.jpg/1280px-Heliotropium_arborescens_%27Mini_Marine%27_Heliotrope_Flower_2500px.jpg",
"Symphyotrichum%20novi-belgii": null,
"Symphyotrichum novi-belgii": "https://upload.wikimedia.org/wikipedia/commons/thumb/2/2f/Symphyotrichum_novi-belgii20090914_120.jpg/1280px-Symphyotrichum_novi-belgii20090914_120.jpg",
"Viola%20cornuta": null,
"Viola cornuta": "https://upload.wikimedia.org/wikipedia/commons/thumb/b/b6/Viola_cornuta_2008.jpg/1280px-Viola_cornuta_2008.jpg",
"Hydrangea%20macrophylla": null,
"Hydrangea macrophylla": "https://upload.wikimedia.org/wikipedia/commons/thumb/7/7f/Hydrangea_macrophylla_-_Hortensia_hydrangea.jpg/1280px-Hydrangea_macrophylla_-_Hortensia_hydrangea.jpg",
"Monarda%20didyma": "https://upload.wikimedia.org/wikipedia/commons/thumb/c/cd/Monarda_didyma_%27Colrain_Red%27_6_2021_Bee_Balm-_%2851273491889%29.jpg/1280px-Monarda_didyma_%27Colrain_Red%27_6_2021_Bee_Balm-_%2851273491889%29.jpg",
"Jacaranda%20mimosifolia": null,
"Jacaranda mimosifolia": "https://upload.wikimedia.org/wikipedia/commons/thumb/6/6c/Jacaranda_mimosifolia_5334.jpg/1280px-Jacaranda_mimosifolia_5334.jpg",
"Coffea%20arabica%20Nana": null,
"Coffea arabica Nana": "https://upload.wikimedia.org/wikipedia/commons/thumb/3/3e/Henry_G._Gilbert_Nursery_and_Seed_Trade_Catalog_Collection_%28IA_catalogueofgreen00dcla_0%29.pdf/page1-1016px-Henry_G._Gilbert_Nursery_and_Seed_Trade_Catalog_Collection_%28IA_catalogueofgreen00dcla_0%29.pdf.jpg",
"Eschscholzia%20californica": null,
"Eschscholzia californica": "https://upload.wikimedia.org/wikipedia/commons/thumb/d/da/Eschscholzia_californica_in_Sedovo_006.jpg/1280px-Eschscholzia_californica_in_Sedovo_006.jpg",
"Osteospermum%20ecklonis": null,
"Osteospermum ecklonis": "https://upload.wikimedia.org/wikipedia/commons/thumb/9/9e/Osteospermum_ecklonis1.JPG/1280px-Osteospermum_ecklonis1.JPG",
"Tropaeolum%20majus": null,
"Tropaeolum majus": "https://upload.wikimedia.org/wikipedia/commons/thumb/8/87/Tropaeolum_majus_2005_G1.jpg/1280px-Tropaeolum_majus_2005_G1.jpg",
"Nepeta%20cataria": null,
"Nepeta cataria": "https://upload.wikimedia.org/wikipedia/commons/1/12/Nepeta_cataria_Sturm24.jpg",
"Anthriscus%20cerefolium": null,
"Anthriscus cerefolium": "https://upload.wikimedia.org/wikipedia/commons/thumb/4/4b/Kerbel_%28Anthriscus_cerefolium%29_mit_Echter_Mehltau_%28Erysiphe_heraclei%29_Befall%2C_Schlaghecken.jpg/1280px-Kerbel_%28Anthriscus_cerefolium%29_mit_Echter_Mehltau_%28Erysiphe_heraclei%29_Befall%2C_Schlaghecken.jpg",
"Pinus%20sylvestris": null,
"Pinus sylvestris": "https://upload.wikimedia.org/wikipedia/commons/thumb/5/5b/Meenikunno_maastikukaiteala.jpg/1280px-Meenikunno_maastikukaiteala.jpg",
"Prunus%20laurocerasus": null,
"Prunus laurocerasus": "https://upload.wikimedia.org/wikipedia/commons/thumb/5/57/Fr%C3%BChling_bl%C3%BChender_Kirschenbaum.jpg/1280px-Fr%C3%BChling_bl%C3%BChender_Kirschenbaum.jpg",
"Verbascum%20thapsus": null,
"Verbascum thapsus": "https://upload.wikimedia.org/wikipedia/commons/thumb/3/33/Koningskaars_R01.jpg/1280px-Koningskaars_R01.jpg",
"Gaillardia%20aristata": null,
"Gaillardia aristata": "https://upload.wikimedia.org/wikipedia/commons/thumb/7/71/Gaillardia_July_2011-2.jpg/1280px-Gaillardia_July_2011-2.jpg",
"Coriandrum%20sativum": null,
"Coriandrum sativum": "https://upload.wikimedia.org/wikipedia/commons/1/13/Coriandrum_sativum_-_K%C3%B6hler%E2%80%93s_Medizinal-Pflanzen-193.jpg",
"Centaurea%20cyanus": null,
"Centaurea cyanus": "https://upload.wikimedia.org/wikipedia/commons/e/e3/CentaureaCyanus-bloem-kl.jpg",
"Cosmos%20bipinnatus": null,
"Cosmos bipinnatus": "https://upload.wikimedia.org/wikipedia/commons/thumb/a/a8/Cosmos_bipinnatus%2C_Burdwan%2C_West_Bengal%2C_India_10_01_2013.jpg/1280px-Cosmos_bipinnatus%2C_Burdwan%2C_West_Bengal%2C_India_10_01_2013.jpg",
"Anthurium%20crystallinum": null,
"Anthurium crystallinum": "https://upload.wikimedia.org/wikipedia/commons/6/61/Alismatales_-_Anthurium_crystallinum_2.jpg",
"Codiaeum%20variegatum": null,
"Codiaeum variegatum": null,
"Kroton": null,
"Helleborus%20orientalis": null,
"Helleborus orientalis": "https://upload.wikimedia.org/wikipedia/commons/thumb/9/9a/Helleborus_orientalis._Lenteroos_03.JPG/1280px-Helleborus_orientalis._Lenteroos_03.JPG",
"Levisticum%20officinale": null,
"Levisticum officinale": "https://upload.wikimedia.org/wikipedia/commons/thumb/4/4c/Liebst%C3%B6ckel_%28Levisticum_officinale%29_mit_einem_Echten_Mehltau_%28Erysiphe_heraclei%29_Befall.%2C_Schlaghecken.jpg/1280px-Liebst%C3%B6ckel_%28Levisticum_officinale%29_mit_einem_Echten_Mehltau_%28Erysiphe_heraclei%29_Befall.%2C_Schlaghecken.jpg",
"Antirrhinum%20majus": null,
"Antirrhinum majus": "https://upload.wikimedia.org/wikipedia/commons/thumb/6/64/Antirrhinum_majus_full_MichaD.jpg/1280px-Antirrhinum_majus_full_MichaD.jpg",
"Laurus%20nobilis": null,
"Laurus nobilis": "https://upload.wikimedia.org/wikipedia/commons/thumb/f/fe/Laurus_nobilis_MHNT_Fleurs.jpg/1280px-Laurus_nobilis_MHNT_Fleurs.jpg",
"Lupinus%20polyphyllus": null,
"Lupinus polyphyllus": "https://upload.wikimedia.org/wikipedia/commons/thumb/e/e7/Lupinus_polyphyllus_MHNT.BOT.2004.0.463.jpg/1280px-Lupinus_polyphyllus_MHNT.BOT.2004.0.463.jpg",
"Coreopsis%20tinctoria": null,
"Coreopsis tinctoria": "https://upload.wikimedia.org/wikipedia/commons/thumb/f/fe/Coreopsis_tinctoria_cultivar_Uptick_Cream_and_Red_4.JPG/1280px-Coreopsis_tinctoria_cultivar_Uptick_Cream_and_Red_4.JPG",
"Magnolia%20grandiflora": null,
"Magnolia grandiflora": "https://upload.wikimedia.org/wikipedia/commons/4/48/Magnolia_%C3%97_soulangeana_blossom.jpg",
"Convallaria%20majalis": null,
"Convallaria majalis": "https://upload.wikimedia.org/wikipedia/commons/thumb/a/a8/Beech%2C_ferns_and_lily_of_the_valley_in_Gullmarsskogen_1.jpg/1280px-Beech%2C_ferns_and_lily_of_the_valley_in_Gullmarsskogen_1.jpg",
"Origanum%20majorana": null,
"Origanum majorana": "https://upload.wikimedia.org/wikipedia/commons/thumb/d/d6/Origanum_majorana.jpg/1280px-Origanum_majorana.jpg",
"Leucanthemum%20vulgare": null,
"Leucanthemum vulgare": "https://upload.wikimedia.org/wikipedia/commons/thumb/e/e7/Leucanthemum_vulgare_%27Filigran%27_Flower_2200px.jpg/1280px-Leucanthemum_vulgare_%27Filigran%27_Flower_2200px.jpg",
"Salvia%20farinacea": null,
"Salvia farinacea": "https://upload.wikimedia.org/wikipedia/commons/6/64/Salvia_farinacea1.jpg",
"Gazania%20rigens": null,
"Gazania rigens": "https://upload.wikimedia.org/wikipedia/commons/thumb/3/3d/Gazania_rigens-1.jpg/1280px-Gazania_rigens-1.jpg",
"Papaver%20rhoeas": "https://upload.wikimedia.org/wikipedia/commons/thumb/0/04/Papaver_rhoeas_6_2021_Poppy-_%2851237437202%29.jpg/1280px-Papaver_rhoeas_6_2021_Poppy-_%2851237437202%29.jpg",
"Dianthus%20caryophyllus": null,
"Dianthus caryophyllus": "https://upload.wikimedia.org/wikipedia/commons/8/80/Gartennelke_1.jpg",
"Nemesia%20strumosa": null,
"Nemesia strumosa": "https://upload.wikimedia.org/wikipedia/commons/thumb/5/56/Nemesia_strumosa_two_colours.jpg/1280px-Nemesia_strumosa_two_colours.jpg",
"Fittonia%20albivenis": null,
"Fittonia albivenis": "https://upload.wikimedia.org/wikipedia/commons/thumb/7/72/Colpfl25.jpg/1280px-Colpfl25.jpg",
"Araucaria%20heterophylla": null,
"Araucaria heterophylla": "https://upload.wikimedia.org/wikipedia/commons/thumb/3/38/Araucaria_heterophylla_Norfolk_Island_0.jpg/1280px-Araucaria_heterophylla_Norfolk_Island_0.jpg",
"Cycas%20revoluta": "https://upload.wikimedia.org/wikipedia/commons/6/6d/Cycas_revoluta%2BFlorero.jpg",
"Paeonia%20lactiflora": null,
"Paeonia lactiflora": "https://upload.wikimedia.org/wikipedia/commons/thumb/b/bf/P%C3%B6rtschach_Winklern_10.-Oktober-Stra%C3%9Fe_67_Paeonia_lactiflora_24052014_2076.jpg/1280px-P%C3%B6rtschach_Winklern_10.-Oktober-Stra%C3%9Fe_67_Paeonia_lactiflora_24052014_2076.jpg",
"Philodendron%20hederaceum%20Brasil": null,
"Philodendron hederaceum Brasil": "https://upload.wikimedia.org/wikipedia/commons/thumb/4/44/Wikipedia_13._Fotoworkshop_Botanischer_Garten_Erlangen_2013_by-RaBoe_136.jpg/1280px-Wikipedia_13._Fotoworkshop_Botanischer_Garten_Erlangen_2013_by-RaBoe_136.jpg",
"Philodendron%20erubescens%20Pink%20Princess": null,
"Philodendron erubescens Pink Princess": "https://upload.wikimedia.org/wikipedia/commons/6/64/Arum_Family_-_Araceae_%283072475611%29.jpg",
"Thaumatophyllum%20xanadu": null,
"Thaumatophyllum xanadu": "https://upload.wikimedia.org/wikipedia/commons/thumb/1/17/Philodendron_xanadu_AK1.jpg/1280px-Philodendron_xanadu_AK1.jpg",
"Ipomoea%20purpurea": null,
"Ipomoea purpurea": "https://upload.wikimedia.org/wikipedia/commons/thumb/a/ab/Ipomoea_July_2011-4.jpg/1280px-Ipomoea_July_2011-4.jpg",
"Hypoestes%20phyllostachya": null,
"Hypoestes phyllostachya": "https://upload.wikimedia.org/wikipedia/commons/thumb/4/43/Starr_080117-1693_Hypoestes_phyllostachya.jpg/1280px-Starr_080117-1693_Hypoestes_phyllostachya.jpg",
"Peperomia%20polybotrya": null,
"Peperomia polybotrya": "https://upload.wikimedia.org/wikipedia/commons/thumb/8/87/Coin_Leaf_Peperomia_%28Peperomia_polybotrya_%27Jayde%27%29.jpg/1280px-Coin_Leaf_Peperomia_%28Peperomia_polybotrya_%27Jayde%27%29.jpg",
"Ranunculus%20asiaticus": null,
"Ranunculus asiaticus": "https://upload.wikimedia.org/wikipedia/commons/thumb/2/23/Persian_Buttercup_01.jpg/1280px-Persian_Buttercup_01.jpg",
"Rhododendron%20catawbiense": null,
"Rhododendron catawbiense": "https://upload.wikimedia.org/wikipedia/commons/thumb/6/6d/Rhododendron-catawbiense.jpg/1280px-Rhododendron-catawbiense.jpg",
"Calendula%20officinalis": "https://upload.wikimedia.org/wikipedia/commons/4/41/Calendula_officinalis_01.jpg",
"Delphinium%20elatum": null,
"Delphinium elatum": "https://upload.wikimedia.org/wikipedia/commons/5/54/Delphinium_elatum_a2.jpg",
"Robinia%20pseudoacacia": null,
"Robinia pseudoacacia": "https://upload.wikimedia.org/wikipedia/commons/thumb/4/4b/Robinia_pseudoacacia_fruits.jpg/1280px-Robinia_pseudoacacia_fruits.jpg",
"Chamaemelum%20nobile": null,
"Chamaemelum nobile": "https://upload.wikimedia.org/wikipedia/commons/thumb/a/a7/Chamomile%40original_size.jpg/1280px-Chamomile%40original_size.jpg",
"Rosa%20x%20hybrida": null,
"Rosa x hybrida": "https://upload.wikimedia.org/wikipedia/commons/d/db/Rosa_x_hybrida_iceberg_Reimer_Kordes_1958.JPG",
"Rudbeckia%20hirta": null,
"Rudbeckia hirta": "https://upload.wikimedia.org/wikipedia/commons/thumb/d/d1/Black_eyed_susan_20040717_110754_2.1474.jpg/1280px-Black_eyed_susan_20040717_110754_2.1474.jpg",
"Anthurium%20clarinervium": null,
"Anthurium clarinervium": "https://upload.wikimedia.org/wikipedia/commons/6/65/Anthurium_clarinervium.jpg",
"Rumex%20acetosa": null,
"Rumex acetosa": "https://upload.wikimedia.org/wikipedia/commons/thumb/e/ee/Rumex_acetosa_-_Hapu_oblikas.jpg/1280px-Rumex_acetosa_-_Hapu_oblikas.jpg",
"Buddleja%20davidii": null,
"Buddleja davidii": "https://upload.wikimedia.org/wikipedia/commons/thumb/0/0d/Distelfalter%2C_Vanessa_cardui_AUF_Schmetterlingsflieder%2C_Buddleja_davidii_1.JPG/1280px-Distelfalter%2C_Vanessa_cardui_AUF_Schmetterlingsflieder%2C_Buddleja_davidii_1.JPG",
"Allium%20tuberosum": null,
"Allium tuberosum": "https://upload.wikimedia.org/wikipedia/commons/a/a8/Allium_tuberosum1.jpg",
"Asclepias%20tuberosa": null,
"Asclepias tuberosa": "https://upload.wikimedia.org/wikipedia/commons/thumb/c/c9/Butterfly_Weed_Asclepias_tuberosa_Flower_Buds_3008px.jpg/1280px-Butterfly_Weed_Asclepias_tuberosa_Flower_Buds_3008px.jpg",
"Leucanthemum%20x%20superbum": null,
"Perilla%20frutescens": null,
"Leucanthemum x superbum": "https://upload.wikimedia.org/wikipedia/commons/thumb/f/f5/Leucanthemum_x_superbum_%27Becky%27_in_NH.jpg/1280px-Leucanthemum_x_superbum_%27Becky%27_in_NH.jpg",
"Perilla frutescens": "https://upload.wikimedia.org/wikipedia/commons/thumb/e/ee/Perilla_frutescens%27_flower.jpg/1280px-Perilla_frutescens%27_flower.jpg",
"Helianthus%20annuus": null,
"Spiraea%20japonica": "https://upload.wikimedia.org/wikipedia/commons/thumb/8/8a/Spiraea_japonica_%27Gold_Mound%27_6_2021_Japanese_Spirea-_%2851238159051%29.jpg/1280px-Spiraea_japonica_%27Gold_Mound%27_6_2021_Japanese_Spirea-_%2851238159051%29.jpg",
"Helianthus annuus": "https://upload.wikimedia.org/wikipedia/commons/6/66/Sunflower_l.jpg",
"Acer%20platanoides": null,
"Acer platanoides": "https://upload.wikimedia.org/wikipedia/commons/thumb/d/d1/2020_year._Herbarium._Acer_platanoides._img-012.jpg/1280px-2020_year._Herbarium._Acer_platanoides._img-012.jpg",
"Ilex%20aquifolium": null,
"Ilex aquifolium": "https://upload.wikimedia.org/wikipedia/commons/thumb/4/4f/Mahonia_aquifolium_qtl1.jpg/1280px-Mahonia_aquifolium_qtl1.jpg",
"Pelargonium%20zonale": "https://upload.wikimedia.org/wikipedia/commons/6/64/Normal_Pelargonium-zonale-376.jpg",
"Stevia%20rebaudiana": null,
"Stevia rebaudiana": "https://upload.wikimedia.org/wikipedia/commons/d/d9/Stevia-rebaudiana-total.JPG",
"Alcea%20rosea": "https://upload.wikimedia.org/wikipedia/commons/thumb/a/a8/Alcea_rosea_6_2021_Hollyhock-_%2851264042906%29.jpg/1280px-Alcea_rosea_6_2021_Hollyhock-_%2851264042906%29.jpg",
"Curio%20radicans": null,
"Curio radicans": "https://upload.wikimedia.org/wikipedia/commons/thumb/4/43/Senecio_radicans%2C_jard%C3%ADn_bot%C3%A1nico_de_Tallinn%2C_Estonia%2C_2012-08-13%2C_DD_01.JPG/1280px-Senecio_radicans%2C_jard%C3%ADn_bot%C3%A1nico_de_Tallinn%2C_Estonia%2C_2012-08-13%2C_DD_01.JPG",
"Curio%20x%20peregrinus": null,
"Curio x peregrinus": "https://upload.wikimedia.org/wikipedia/commons/thumb/9/9f/Getty_Research_Institute_%28IA_johannchristiank00kund%29.pdf/page1-725px-Getty_Research_Institute_%28IA_johannchristiank00kund%29.pdf.jpg",
"Hemerocallis%20fulva": "https://upload.wikimedia.org/wikipedia/commons/0/0e/Hemerocallis_fulva_-_flower_view_02.jpg",
"Lamprocapnos%20spectabilis": null,
"Lamprocapnos spectabilis": "https://upload.wikimedia.org/wikipedia/commons/thumb/d/df/Tr%C3%A4nendes_Herz_%28Dicentra_spectabilis%29.jpg/1280px-Tr%C3%A4nendes_Herz_%28Dicentra_spectabilis%29.jpg",
"Catalpa%20bignonioides": null,
"Catalpa bignonioides": "https://upload.wikimedia.org/wikipedia/commons/thumb/0/0b/Catalpa_bignonioides_Aurea_JPG1a.jpg/1280px-Catalpa_bignonioides_Aurea_JPG1a.jpg",
"Campsis%20radicans": null,
"Campsis radicans": "https://upload.wikimedia.org/wikipedia/commons/thumb/a/af/Campsis_radicans_0.4_R.jpg/1280px-Campsis_radicans_0.4_R.jpg",
"Myosotis%20sylvatica": null,
"Myosotis sylvatica": "https://upload.wikimedia.org/wikipedia/commons/thumb/4/4b/Myosotis_sylvatica_%28aka%29.jpg/1280px-Myosotis_sylvatica_%28aka%29.jpg",
"Eutrema%20japonicum": null,
"Eutrema japonicum": "https://upload.wikimedia.org/wikipedia/commons/thumb/5/5d/Wasabia_japonica_1.JPG/1280px-Wasabia_japonica_1.JPG",
"Peperomia%20argyreia": null,
"Peperomia argyreia": "https://upload.wikimedia.org/wikipedia/commons/thumb/a/ac/Peperomia_argyreia.jpg/1280px-Peperomia_argyreia.jpg",
"Weigela%20florida": "https://upload.wikimedia.org/wikipedia/commons/thumb/7/7e/Weigela_florida_%27Bokrashine%27_%27SHINING_SENSATION%27_6_2021_Weigela-_%2851239536602%29.jpg/1280px-Weigela_florida_%27Bokrashine%27_%27SHINING_SENSATION%27_6_2021_Weigela-_%2851239536602%29.jpg",
"Hyssopus%20officinalis": null,
"Hyssopus officinalis": "https://upload.wikimedia.org/wikipedia/commons/thumb/6/6d/Hyssopus-officinalis-habit.jpg/1280px-Hyssopus-officinalis-habit.jpg",
"Yucca%20aloifolia": null,
"Yucca aloifolia": "https://upload.wikimedia.org/wikipedia/commons/thumb/6/6c/Yucca_aloifolia_4.jpg/1280px-Yucca_aloifolia_4.jpg",
"Cedrus%20libani": null,
"Cedrus libani": "https://upload.wikimedia.org/wikipedia/commons/thumb/1/19/Cedrus_libani_foliage_PAN.JPG/1280px-Cedrus_libani_foliage_PAN.JPG",
"Nicotiana%20alata": null,
"Nicotiana alata": "https://upload.wikimedia.org/wikipedia/commons/thumb/4/45/Nicotiana_alata1By_Carl_E_Lewis.jpg/1280px-Nicotiana_alata1By_Carl_E_Lewis.jpg",
"Cymbopogon%20citratus": null,
"Cymbopogon citratus": "https://upload.wikimedia.org/wikipedia/commons/b/bd/YosriNov04Pokok_Serai.JPG",
"Aloysia%20citrodora": null,
"Aloysia citrodora": "https://upload.wikimedia.org/wikipedia/commons/a/a6/Aloysia_citriodora_002.jpg",
"Cupressus%20sempervirens": null,
"Cupressus sempervirens": "https://upload.wikimedia.org/wikipedia/commons/thumb/9/9c/Cypress_Halefka.JPG/1280px-Cypress_Halefka.JPG",
"Cycas revoluta": "https://upload.wikimedia.org/wikipedia/commons/f/f4/Cycas_revoluta1327A.JPG",
"Calendula officinalis": "https://upload.wikimedia.org/wikipedia/commons/thumb/0/0e/Calendula_in_Autumn.jpg/1280px-Calendula_in_Autumn.jpg",
"Pelargonium zonale": "https://upload.wikimedia.org/wikipedia/commons/thumb/a/a7/Pelargonium_zonale_%28Geraniaceae%29.jpg/1280px-Pelargonium_zonale_%28Geraniaceae%29.jpg",
"Hemerocallis fulva": "https://upload.wikimedia.org/wikipedia/commons/thumb/6/66/Daylily_%28Hemerocallis_fulva%29_v2.jpg/1280px-Daylily_%28Hemerocallis_fulva%29_v2.jpg",
"Garden croton": "https://upload.wikimedia.org/wikipedia/commons/thumb/b/bf/Colpfl05.jpg/1280px-Colpfl05.jpg",
"Senecio peregrinus": "https://upload.wikimedia.org/wikipedia/commons/thumb/4/43/Figure_2._Schematic_representation_of_homoploid_and_allopolyploid_hybrid_speciation._Updated.svg/1280px-Figure_2._Schematic_representation_of_homoploid_and_allopolyploid_hybrid_speciation._Updated.svg.png"
}

View File

@@ -1,486 +1,486 @@
#!/usr/bin/env node
/* eslint-disable no-console */
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
require('dotenv').config();
const sharp = require('sharp');
const { openDatabase, closeDatabase, all, run } = require('../lib/sqlite');
const { ensurePlantSchema } = require('../lib/plants');
const OUTPUT_DIR = path.join(__dirname, '..', 'public', 'plants');
const MANIFEST_PATH = path.join(OUTPUT_DIR, 'manifest.json');
const ROOT_DIR = path.join(__dirname, '..', '..');
const PLANTS_DUMP_PATH = path.join(ROOT_DIR, 'plants_dump_utf8.json');
const SEARCH_CACHE_PATH = path.join(OUTPUT_DIR, 'wikimedia-search-cache.json');
const MAX_CONCURRENCY = Number(process.env.PLANT_IMAGE_CONCURRENCY || 1);
const REQUEST_TIMEOUT_MS = 20000;
const MAX_FETCH_RETRIES = 5;
const WIKIMEDIA_SEARCH_PREFIX = 'wikimedia-search:';
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const slugify = (value) => {
const normalized = String(value || '')
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
return normalized || 'plant';
};
const buildFileBaseName = (plant) => {
const botanicalSlug = slugify(plant.botanicalName);
const nameSlug = slugify(plant.name);
const suffix = crypto
.createHash('sha1')
.update(`${plant.id}|${plant.botanicalName}|${plant.name}`)
.digest('hex')
.slice(0, 8);
if (nameSlug && nameSlug !== botanicalSlug) {
return `${botanicalSlug}--${nameSlug}--${suffix}`;
}
return `${botanicalSlug}--${suffix}`;
};
const ensureOutputDir = () => {
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
};
const loadRefreshMatchers = () => new Set(
String(process.env.PLANT_IMAGE_REFRESH || '')
.split(',')
.map((value) => value.trim().toLowerCase())
.filter(Boolean),
);
const loadManifest = () => {
try {
const raw = fs.readFileSync(MANIFEST_PATH, 'utf8');
return JSON.parse(raw);
} catch {
return { generatedAt: null, items: [] };
}
};
const saveManifest = (manifest) => {
fs.writeFileSync(MANIFEST_PATH, JSON.stringify(manifest, null, 2));
};
const loadSearchCache = () => {
try {
return JSON.parse(fs.readFileSync(SEARCH_CACHE_PATH, 'utf8'));
} catch {
return {};
}
};
const saveSearchCache = (cache) => {
fs.writeFileSync(SEARCH_CACHE_PATH, JSON.stringify(cache, null, 2));
};
const shouldRefreshPlantImage = (plant, refreshMatchers) => {
if (!refreshMatchers || refreshMatchers.size === 0) return false;
return [
plant.id,
plant.name,
plant.botanicalName,
].some((value) => refreshMatchers.has(String(value || '').trim().toLowerCase()));
};
const loadDumpFallbackMap = () => {
try {
const raw = fs.readFileSync(PLANTS_DUMP_PATH, 'utf8');
const entries = JSON.parse(raw);
if (!Array.isArray(entries)) return new Map();
const map = new Map();
for (const entry of entries) {
if (!entry || typeof entry.botanicalName !== 'string' || typeof entry.imageUri !== 'string') continue;
const key = entry.botanicalName.trim().toLowerCase();
if (!key || !/^https?:\/\//i.test(entry.imageUri)) continue;
if (!map.has(key)) map.set(key, entry.imageUri.trim());
}
return map;
} catch {
return new Map();
}
};
const getRetryDelayMs = (attempt, retryAfterHeader) => {
const retryAfterSeconds = Number(retryAfterHeader);
if (Number.isFinite(retryAfterSeconds) && retryAfterSeconds > 0) {
return retryAfterSeconds * 1000;
}
return Math.min(30000, 3000 * 2 ** attempt);
};
const tryDecode = (value) => {
try {
return decodeURIComponent(value);
} catch {
return value;
}
};
const decodeRepeatedly = (value, rounds = 3) => {
let current = value;
for (let index = 0; index < rounds; index += 1) {
const decoded = tryDecode(current);
if (decoded === current) break;
current = decoded;
}
return current;
};
const toWikimediaFilePathUrl = (rawUrl) => {
if (typeof rawUrl !== 'string' || !rawUrl.includes('upload.wikimedia.org/wikipedia/commons/')) {
return null;
}
const cleanUrl = rawUrl.split(/[?#]/)[0];
const parts = cleanUrl.split('/').filter(Boolean);
if (parts.length < 2) return null;
let fileName = null;
const thumbIndex = parts.indexOf('thumb');
if (thumbIndex >= 0 && parts.length >= thumbIndex + 5) {
fileName = parts[parts.length - 2];
} else {
fileName = parts[parts.length - 1];
}
if (!fileName) return null;
const decoded = tryDecode(fileName).trim();
if (!decoded) return null;
return `https://commons.wikimedia.org/wiki/Special:FilePath/${encodeURIComponent(decoded)}`;
};
const parseWikimediaSearchQuery = (value) => {
if (typeof value !== 'string') return null;
const trimmed = value.trim();
if (!trimmed.toLowerCase().startsWith(WIKIMEDIA_SEARCH_PREFIX)) return null;
const rawQuery = trimmed.slice(WIKIMEDIA_SEARCH_PREFIX.length).trim();
if (!rawQuery) return null;
return decodeRepeatedly(rawQuery);
};
const fetchImageBuffer = async (url, attempt = 0, redirectCount = 0) => {
if (redirectCount > 5) {
throw new Error('Too many redirects');
}
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
try {
const response = await fetch(url, {
headers: {
'User-Agent': 'GreenLens-PlantImageImporter/1.0',
'Accept': 'image/avif,image/webp,image/apng,image/*,*/*;q=0.8',
'Referer': 'https://commons.wikimedia.org/',
},
redirect: 'manual',
signal: controller.signal,
});
if ([301, 302, 303, 307, 308].includes(response.status)) {
const location = response.headers.get('location');
if (!location) throw new Error(`Redirect without location for ${url}`);
const nextUrl = new URL(location, url).toString();
return fetchImageBuffer(nextUrl, attempt, redirectCount + 1);
}
if ((response.status === 429 || response.status >= 500) && attempt < MAX_FETCH_RETRIES) {
const delayMs = getRetryDelayMs(attempt, response.headers.get('retry-after'));
await sleep(delayMs);
return fetchImageBuffer(url, attempt + 1, redirectCount);
}
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const arrayBuffer = await response.arrayBuffer();
return Buffer.from(arrayBuffer);
} finally {
clearTimeout(timeout);
}
};
const searchWikimediaImage = async (query, searchCache) => {
const normalizedQuery = String(query || '').trim();
if (!normalizedQuery) return null;
if (Object.prototype.hasOwnProperty.call(searchCache, normalizedQuery)) {
return searchCache[normalizedQuery] || null;
}
const apiUrl = `https://commons.wikimedia.org/w/api.php?action=query&generator=search&gsrnamespace=6&gsrsearch=${encodeURIComponent(normalizedQuery)}&gsrlimit=5&prop=imageinfo&iiprop=url&iiurlwidth=1200&format=json`;
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
const response = await fetch(apiUrl, {
headers: {
'User-Agent': 'GreenLens-PlantImageImporter/1.0',
'Accept': 'application/json',
},
signal: controller.signal,
});
clearTimeout(timeout);
if (!response.ok) {
searchCache[normalizedQuery] = null;
saveSearchCache(searchCache);
return null;
}
const data = await response.json();
const pages = data?.query?.pages ? Object.values(data.query.pages) : [];
for (const page of pages) {
const imageInfo = page?.imageinfo?.[0];
const candidate = imageInfo?.thumburl || imageInfo?.url || null;
if (candidate && /^https?:\/\//i.test(candidate)) {
searchCache[normalizedQuery] = candidate;
saveSearchCache(searchCache);
return candidate;
}
}
} catch {
// Ignore and cache as null below.
}
searchCache[normalizedQuery] = null;
saveSearchCache(searchCache);
return null;
};
const convertToWebp = async (inputBuffer, outputPath) => {
const tempPath = `${outputPath}.tmp-${process.pid}-${Date.now()}.webp`;
await sharp(inputBuffer)
.rotate()
.resize({
width: 1200,
height: 1200,
fit: 'inside',
withoutEnlargement: true,
})
.webp({ quality: 82 })
.toFile(tempPath);
fs.copyFileSync(tempPath, outputPath);
fs.unlinkSync(tempPath);
};
const updatePlantImageUri = async (db, plantId, localImageUri) => {
await run(
db,
'UPDATE plants SET imageUri = ?, imageStatus = ?, updatedAt = datetime(\'now\') WHERE id = ?',
[localImageUri, 'ok', plantId],
);
};
const processPlant = async (db, plant, manifestItems, dumpFallbackMap, searchCache, refreshMatchers) => {
const currentUri = String(plant.imageUri || '').trim();
const placeholderQuery = parseWikimediaSearchQuery(currentUri);
const fileBaseName = buildFileBaseName(plant);
const fileName = `${fileBaseName}.webp`;
const localImageUri = `/plants/${fileName}`;
const outputPath = path.join(OUTPUT_DIR, fileName);
const dumpFallbackUri = dumpFallbackMap.get(String(plant.botanicalName || '').trim().toLowerCase()) || null;
const shouldRefresh = shouldRefreshPlantImage(plant, refreshMatchers);
if (fs.existsSync(outputPath) && !shouldRefresh) {
await updatePlantImageUri(db, plant.id, localImageUri);
manifestItems.push({
id: plant.id,
botanicalName: plant.botanicalName,
name: plant.name,
sourceUri: currentUri,
localImageUri,
status: 'existing',
});
return { status: 'existing', plantId: plant.id, localImageUri };
}
if (!/^https?:\/\//i.test(currentUri) && !placeholderQuery) {
manifestItems.push({
id: plant.id,
botanicalName: plant.botanicalName,
name: plant.name,
sourceUri: currentUri,
localImageUri,
status: 'skipped',
reason: 'Current imageUri is not a remote URL and no local file exists yet.',
});
return { status: 'skipped', plantId: plant.id, localImageUri };
}
let lastError = null;
let sourceUsed = currentUri;
let buffer = null;
const searchedUri = await searchWikimediaImage(placeholderQuery, searchCache)
|| await searchWikimediaImage(plant.botanicalName, searchCache)
|| await searchWikimediaImage(plant.name, searchCache);
const candidateUris = [
/^https?:\/\//i.test(currentUri) ? currentUri : null,
/^https?:\/\//i.test(currentUri) ? toWikimediaFilePathUrl(currentUri) : null,
dumpFallbackUri,
toWikimediaFilePathUrl(dumpFallbackUri),
searchedUri,
toWikimediaFilePathUrl(searchedUri),
].filter(Boolean);
for (const candidateUri of [...new Set(candidateUris)]) {
try {
buffer = await fetchImageBuffer(candidateUri);
sourceUsed = candidateUri;
break;
} catch (error) {
lastError = error;
}
}
if (!buffer) {
throw lastError || new Error('Image download failed');
}
await convertToWebp(buffer, outputPath);
await updatePlantImageUri(db, plant.id, localImageUri);
manifestItems.push({
id: plant.id,
botanicalName: plant.botanicalName,
name: plant.name,
sourceUri: sourceUsed,
localImageUri,
status: 'downloaded',
});
await sleep(900);
return { status: 'downloaded', plantId: plant.id, localImageUri };
};
const runWithConcurrency = async (items, worker, concurrency) => {
const queue = [...items];
const results = [];
const runners = Array.from({ length: Math.min(concurrency, queue.length) }, async () => {
while (queue.length > 0) {
const item = queue.shift();
if (!item) return;
results.push(await worker(item));
}
});
await Promise.all(runners);
return results;
};
const main = async () => {
ensureOutputDir();
const manifest = loadManifest();
const manifestItems = [];
const dumpFallbackMap = loadDumpFallbackMap();
const searchCache = loadSearchCache();
const refreshMatchers = loadRefreshMatchers();
const db = await openDatabase();
try {
await ensurePlantSchema(db);
const plants = await all(
db,
`SELECT id, name, botanicalName, imageUri
FROM plants
ORDER BY name COLLATE NOCASE ASC`,
);
console.log(`Preparing ${plants.length} plant images...`);
const failures = [];
let completed = 0;
await runWithConcurrency(
plants,
async (plant) => {
try {
const result = await processPlant(db, plant, manifestItems, dumpFallbackMap, searchCache, refreshMatchers);
completed += 1;
console.log(`[${completed}/${plants.length}] ${plant.botanicalName} -> ${result.status}`);
return result;
} catch (error) {
completed += 1;
const message = error instanceof Error ? error.message : String(error);
console.error(`[${completed}/${plants.length}] ${plant.botanicalName} -> failed: ${message}`);
failures.push({
id: plant.id,
name: plant.name,
botanicalName: plant.botanicalName,
sourceUri: plant.imageUri,
error: message,
});
manifestItems.push({
id: plant.id,
botanicalName: plant.botanicalName,
name: plant.name,
sourceUri: plant.imageUri,
status: 'failed',
error: message,
});
return { status: 'failed', plantId: plant.id };
}
},
MAX_CONCURRENCY,
);
const downloadedCount = manifestItems.filter((item) => item.status === 'downloaded').length;
const existingCount = manifestItems.filter((item) => item.status === 'existing').length;
const skippedCount = manifestItems.filter((item) => item.status === 'skipped').length;
saveManifest({
generatedAt: new Date().toISOString(),
summary: {
totalPlants: plants.length,
downloadedCount,
existingCount,
skippedCount,
failureCount: failures.length,
},
failures,
items: manifestItems,
});
console.log('');
console.log(`Downloaded: ${downloadedCount}`);
console.log(`Already present: ${existingCount}`);
console.log(`Skipped: ${skippedCount}`);
console.log(`Failed: ${failures.length}`);
console.log(`Manifest: ${MANIFEST_PATH}`);
if (failures.length > 0) {
process.exitCode = 1;
}
} finally {
await closeDatabase(db);
}
};
main().catch((error) => {
console.error('Plant image import failed.');
console.error(error instanceof Error ? error.stack || error.message : String(error));
process.exit(1);
});
#!/usr/bin/env node
/* eslint-disable no-console */
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
require('dotenv').config();
const sharp = require('sharp');
const { openDatabase, closeDatabase, all, run } = require('../lib/sqlite');
const { ensurePlantSchema } = require('../lib/plants');
const OUTPUT_DIR = path.join(__dirname, '..', 'public', 'plants');
const MANIFEST_PATH = path.join(OUTPUT_DIR, 'manifest.json');
const ROOT_DIR = path.join(__dirname, '..', '..');
const PLANTS_DUMP_PATH = path.join(ROOT_DIR, 'plants_dump_utf8.json');
const SEARCH_CACHE_PATH = path.join(OUTPUT_DIR, 'wikimedia-search-cache.json');
const MAX_CONCURRENCY = Number(process.env.PLANT_IMAGE_CONCURRENCY || 1);
const REQUEST_TIMEOUT_MS = 20000;
const MAX_FETCH_RETRIES = 5;
const WIKIMEDIA_SEARCH_PREFIX = 'wikimedia-search:';
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const slugify = (value) => {
const normalized = String(value || '')
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
return normalized || 'plant';
};
const buildFileBaseName = (plant) => {
const botanicalSlug = slugify(plant.botanicalName);
const nameSlug = slugify(plant.name);
const suffix = crypto
.createHash('sha1')
.update(`${plant.id}|${plant.botanicalName}|${plant.name}`)
.digest('hex')
.slice(0, 8);
if (nameSlug && nameSlug !== botanicalSlug) {
return `${botanicalSlug}--${nameSlug}--${suffix}`;
}
return `${botanicalSlug}--${suffix}`;
};
const ensureOutputDir = () => {
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
};
const loadRefreshMatchers = () => new Set(
String(process.env.PLANT_IMAGE_REFRESH || '')
.split(',')
.map((value) => value.trim().toLowerCase())
.filter(Boolean),
);
const loadManifest = () => {
try {
const raw = fs.readFileSync(MANIFEST_PATH, 'utf8');
return JSON.parse(raw);
} catch {
return { generatedAt: null, items: [] };
}
};
const saveManifest = (manifest) => {
fs.writeFileSync(MANIFEST_PATH, JSON.stringify(manifest, null, 2));
};
const loadSearchCache = () => {
try {
return JSON.parse(fs.readFileSync(SEARCH_CACHE_PATH, 'utf8'));
} catch {
return {};
}
};
const saveSearchCache = (cache) => {
fs.writeFileSync(SEARCH_CACHE_PATH, JSON.stringify(cache, null, 2));
};
const shouldRefreshPlantImage = (plant, refreshMatchers) => {
if (!refreshMatchers || refreshMatchers.size === 0) return false;
return [
plant.id,
plant.name,
plant.botanicalName,
].some((value) => refreshMatchers.has(String(value || '').trim().toLowerCase()));
};
const loadDumpFallbackMap = () => {
try {
const raw = fs.readFileSync(PLANTS_DUMP_PATH, 'utf8');
const entries = JSON.parse(raw);
if (!Array.isArray(entries)) return new Map();
const map = new Map();
for (const entry of entries) {
if (!entry || typeof entry.botanicalName !== 'string' || typeof entry.imageUri !== 'string') continue;
const key = entry.botanicalName.trim().toLowerCase();
if (!key || !/^https?:\/\//i.test(entry.imageUri)) continue;
if (!map.has(key)) map.set(key, entry.imageUri.trim());
}
return map;
} catch {
return new Map();
}
};
const getRetryDelayMs = (attempt, retryAfterHeader) => {
const retryAfterSeconds = Number(retryAfterHeader);
if (Number.isFinite(retryAfterSeconds) && retryAfterSeconds > 0) {
return retryAfterSeconds * 1000;
}
return Math.min(30000, 3000 * 2 ** attempt);
};
const tryDecode = (value) => {
try {
return decodeURIComponent(value);
} catch {
return value;
}
};
const decodeRepeatedly = (value, rounds = 3) => {
let current = value;
for (let index = 0; index < rounds; index += 1) {
const decoded = tryDecode(current);
if (decoded === current) break;
current = decoded;
}
return current;
};
const toWikimediaFilePathUrl = (rawUrl) => {
if (typeof rawUrl !== 'string' || !rawUrl.includes('upload.wikimedia.org/wikipedia/commons/')) {
return null;
}
const cleanUrl = rawUrl.split(/[?#]/)[0];
const parts = cleanUrl.split('/').filter(Boolean);
if (parts.length < 2) return null;
let fileName = null;
const thumbIndex = parts.indexOf('thumb');
if (thumbIndex >= 0 && parts.length >= thumbIndex + 5) {
fileName = parts[parts.length - 2];
} else {
fileName = parts[parts.length - 1];
}
if (!fileName) return null;
const decoded = tryDecode(fileName).trim();
if (!decoded) return null;
return `https://commons.wikimedia.org/wiki/Special:FilePath/${encodeURIComponent(decoded)}`;
};
const parseWikimediaSearchQuery = (value) => {
if (typeof value !== 'string') return null;
const trimmed = value.trim();
if (!trimmed.toLowerCase().startsWith(WIKIMEDIA_SEARCH_PREFIX)) return null;
const rawQuery = trimmed.slice(WIKIMEDIA_SEARCH_PREFIX.length).trim();
if (!rawQuery) return null;
return decodeRepeatedly(rawQuery);
};
const fetchImageBuffer = async (url, attempt = 0, redirectCount = 0) => {
if (redirectCount > 5) {
throw new Error('Too many redirects');
}
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
try {
const response = await fetch(url, {
headers: {
'User-Agent': 'GreenLens-PlantImageImporter/1.0',
'Accept': 'image/avif,image/webp,image/apng,image/*,*/*;q=0.8',
'Referer': 'https://commons.wikimedia.org/',
},
redirect: 'manual',
signal: controller.signal,
});
if ([301, 302, 303, 307, 308].includes(response.status)) {
const location = response.headers.get('location');
if (!location) throw new Error(`Redirect without location for ${url}`);
const nextUrl = new URL(location, url).toString();
return fetchImageBuffer(nextUrl, attempt, redirectCount + 1);
}
if ((response.status === 429 || response.status >= 500) && attempt < MAX_FETCH_RETRIES) {
const delayMs = getRetryDelayMs(attempt, response.headers.get('retry-after'));
await sleep(delayMs);
return fetchImageBuffer(url, attempt + 1, redirectCount);
}
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const arrayBuffer = await response.arrayBuffer();
return Buffer.from(arrayBuffer);
} finally {
clearTimeout(timeout);
}
};
const searchWikimediaImage = async (query, searchCache) => {
const normalizedQuery = String(query || '').trim();
if (!normalizedQuery) return null;
if (Object.prototype.hasOwnProperty.call(searchCache, normalizedQuery)) {
return searchCache[normalizedQuery] || null;
}
const apiUrl = `https://commons.wikimedia.org/w/api.php?action=query&generator=search&gsrnamespace=6&gsrsearch=${encodeURIComponent(normalizedQuery)}&gsrlimit=5&prop=imageinfo&iiprop=url&iiurlwidth=1200&format=json`;
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
const response = await fetch(apiUrl, {
headers: {
'User-Agent': 'GreenLens-PlantImageImporter/1.0',
'Accept': 'application/json',
},
signal: controller.signal,
});
clearTimeout(timeout);
if (!response.ok) {
searchCache[normalizedQuery] = null;
saveSearchCache(searchCache);
return null;
}
const data = await response.json();
const pages = data?.query?.pages ? Object.values(data.query.pages) : [];
for (const page of pages) {
const imageInfo = page?.imageinfo?.[0];
const candidate = imageInfo?.thumburl || imageInfo?.url || null;
if (candidate && /^https?:\/\//i.test(candidate)) {
searchCache[normalizedQuery] = candidate;
saveSearchCache(searchCache);
return candidate;
}
}
} catch {
// Ignore and cache as null below.
}
searchCache[normalizedQuery] = null;
saveSearchCache(searchCache);
return null;
};
const convertToWebp = async (inputBuffer, outputPath) => {
const tempPath = `${outputPath}.tmp-${process.pid}-${Date.now()}.webp`;
await sharp(inputBuffer)
.rotate()
.resize({
width: 1200,
height: 1200,
fit: 'inside',
withoutEnlargement: true,
})
.webp({ quality: 82 })
.toFile(tempPath);
fs.copyFileSync(tempPath, outputPath);
fs.unlinkSync(tempPath);
};
const updatePlantImageUri = async (db, plantId, localImageUri) => {
await run(
db,
'UPDATE plants SET imageUri = ?, imageStatus = ?, updatedAt = datetime(\'now\') WHERE id = ?',
[localImageUri, 'ok', plantId],
);
};
const processPlant = async (db, plant, manifestItems, dumpFallbackMap, searchCache, refreshMatchers) => {
const currentUri = String(plant.imageUri || '').trim();
const placeholderQuery = parseWikimediaSearchQuery(currentUri);
const fileBaseName = buildFileBaseName(plant);
const fileName = `${fileBaseName}.webp`;
const localImageUri = `/plants/${fileName}`;
const outputPath = path.join(OUTPUT_DIR, fileName);
const dumpFallbackUri = dumpFallbackMap.get(String(plant.botanicalName || '').trim().toLowerCase()) || null;
const shouldRefresh = shouldRefreshPlantImage(plant, refreshMatchers);
if (fs.existsSync(outputPath) && !shouldRefresh) {
await updatePlantImageUri(db, plant.id, localImageUri);
manifestItems.push({
id: plant.id,
botanicalName: plant.botanicalName,
name: plant.name,
sourceUri: currentUri,
localImageUri,
status: 'existing',
});
return { status: 'existing', plantId: plant.id, localImageUri };
}
if (!/^https?:\/\//i.test(currentUri) && !placeholderQuery) {
manifestItems.push({
id: plant.id,
botanicalName: plant.botanicalName,
name: plant.name,
sourceUri: currentUri,
localImageUri,
status: 'skipped',
reason: 'Current imageUri is not a remote URL and no local file exists yet.',
});
return { status: 'skipped', plantId: plant.id, localImageUri };
}
let lastError = null;
let sourceUsed = currentUri;
let buffer = null;
const searchedUri = await searchWikimediaImage(placeholderQuery, searchCache)
|| await searchWikimediaImage(plant.botanicalName, searchCache)
|| await searchWikimediaImage(plant.name, searchCache);
const candidateUris = [
/^https?:\/\//i.test(currentUri) ? currentUri : null,
/^https?:\/\//i.test(currentUri) ? toWikimediaFilePathUrl(currentUri) : null,
dumpFallbackUri,
toWikimediaFilePathUrl(dumpFallbackUri),
searchedUri,
toWikimediaFilePathUrl(searchedUri),
].filter(Boolean);
for (const candidateUri of [...new Set(candidateUris)]) {
try {
buffer = await fetchImageBuffer(candidateUri);
sourceUsed = candidateUri;
break;
} catch (error) {
lastError = error;
}
}
if (!buffer) {
throw lastError || new Error('Image download failed');
}
await convertToWebp(buffer, outputPath);
await updatePlantImageUri(db, plant.id, localImageUri);
manifestItems.push({
id: plant.id,
botanicalName: plant.botanicalName,
name: plant.name,
sourceUri: sourceUsed,
localImageUri,
status: 'downloaded',
});
await sleep(900);
return { status: 'downloaded', plantId: plant.id, localImageUri };
};
const runWithConcurrency = async (items, worker, concurrency) => {
const queue = [...items];
const results = [];
const runners = Array.from({ length: Math.min(concurrency, queue.length) }, async () => {
while (queue.length > 0) {
const item = queue.shift();
if (!item) return;
results.push(await worker(item));
}
});
await Promise.all(runners);
return results;
};
const main = async () => {
ensureOutputDir();
const manifest = loadManifest();
const manifestItems = [];
const dumpFallbackMap = loadDumpFallbackMap();
const searchCache = loadSearchCache();
const refreshMatchers = loadRefreshMatchers();
const db = await openDatabase();
try {
await ensurePlantSchema(db);
const plants = await all(
db,
`SELECT id, name, botanicalName, imageUri
FROM plants
ORDER BY name COLLATE NOCASE ASC`,
);
console.log(`Preparing ${plants.length} plant images...`);
const failures = [];
let completed = 0;
await runWithConcurrency(
plants,
async (plant) => {
try {
const result = await processPlant(db, plant, manifestItems, dumpFallbackMap, searchCache, refreshMatchers);
completed += 1;
console.log(`[${completed}/${plants.length}] ${plant.botanicalName} -> ${result.status}`);
return result;
} catch (error) {
completed += 1;
const message = error instanceof Error ? error.message : String(error);
console.error(`[${completed}/${plants.length}] ${plant.botanicalName} -> failed: ${message}`);
failures.push({
id: plant.id,
name: plant.name,
botanicalName: plant.botanicalName,
sourceUri: plant.imageUri,
error: message,
});
manifestItems.push({
id: plant.id,
botanicalName: plant.botanicalName,
name: plant.name,
sourceUri: plant.imageUri,
status: 'failed',
error: message,
});
return { status: 'failed', plantId: plant.id };
}
},
MAX_CONCURRENCY,
);
const downloadedCount = manifestItems.filter((item) => item.status === 'downloaded').length;
const existingCount = manifestItems.filter((item) => item.status === 'existing').length;
const skippedCount = manifestItems.filter((item) => item.status === 'skipped').length;
saveManifest({
generatedAt: new Date().toISOString(),
summary: {
totalPlants: plants.length,
downloadedCount,
existingCount,
skippedCount,
failureCount: failures.length,
},
failures,
items: manifestItems,
});
console.log('');
console.log(`Downloaded: ${downloadedCount}`);
console.log(`Already present: ${existingCount}`);
console.log(`Skipped: ${skippedCount}`);
console.log(`Failed: ${failures.length}`);
console.log(`Manifest: ${MANIFEST_PATH}`);
if (failures.length > 0) {
process.exitCode = 1;
}
} finally {
await closeDatabase(db);
}
};
main().catch((error) => {
console.error('Plant image import failed.');
console.error(error instanceof Error ? error.stack || error.message : String(error));
process.exit(1);
});

View File

@@ -1,23 +1,23 @@
#!/usr/bin/env node
/* eslint-disable no-console */
require('dotenv').config();
const { closeDatabase, openDatabase } = require('../lib/sqlite');
const { ensurePlantSchema, getPlantDiagnostics } = require('../lib/plants');
const main = async () => {
const db = await openDatabase();
try {
await ensurePlantSchema(db);
const diagnostics = await getPlantDiagnostics(db);
console.log(JSON.stringify(diagnostics, null, 2));
} finally {
await closeDatabase(db);
}
};
main().catch((error) => {
console.error('Failed to read plant diagnostics.');
console.error(error instanceof Error ? error.stack || error.message : String(error));
process.exit(1);
});
#!/usr/bin/env node
/* eslint-disable no-console */
require('dotenv').config();
const { closeDatabase, openDatabase } = require('../lib/sqlite');
const { ensurePlantSchema, getPlantDiagnostics } = require('../lib/plants');
const main = async () => {
const db = await openDatabase();
try {
await ensurePlantSchema(db);
const diagnostics = await getPlantDiagnostics(db);
console.log(JSON.stringify(diagnostics, null, 2));
} finally {
await closeDatabase(db);
}
};
main().catch((error) => {
console.error('Failed to read plant diagnostics.');
console.error(error instanceof Error ? error.stack || error.message : String(error));
process.exit(1);
});

View File

@@ -1,123 +1,123 @@
#!/usr/bin/env node
/* eslint-disable no-console */
const fs = require('fs');
const path = require('path');
const vm = require('vm');
require('dotenv').config();
const { closeDatabase, openDatabase } = require('../lib/sqlite');
const { ensurePlantSchema, rebuildPlantsCatalog } = require('../lib/plants');
let ts;
try {
ts = require('typescript');
} catch (error) {
console.error('The rebuild script needs the "typescript" package in node_modules.');
console.error('Install dependencies in the repository root before running this script.');
process.exit(1);
}
const ROOT_DIR = path.resolve(__dirname, '..', '..');
const BATCH_1_PATH = path.join(ROOT_DIR, 'constants', 'lexiconBatch1.ts');
const BATCH_2_PATH = path.join(ROOT_DIR, 'constants', 'lexiconBatch2.ts');
const resolveTsFilePath = (fromFile, specifier) => {
if (!specifier.startsWith('.')) return null;
const fromDirectory = path.dirname(fromFile);
const absoluteBase = path.resolve(fromDirectory, specifier);
const candidates = [
absoluteBase,
`${absoluteBase}.ts`,
`${absoluteBase}.tsx`,
path.join(absoluteBase, 'index.ts'),
];
for (const candidate of candidates) {
if (fs.existsSync(candidate) && fs.statSync(candidate).isFile()) {
return candidate;
}
}
return null;
};
const loadTsModule = (absolutePath, cache = new Map()) => {
if (cache.has(absolutePath)) return cache.get(absolutePath);
const source = fs.readFileSync(absolutePath, 'utf8');
const transpiled = ts.transpileModule(source, {
compilerOptions: {
module: ts.ModuleKind.CommonJS,
target: ts.ScriptTarget.ES2020,
esModuleInterop: true,
jsx: ts.JsxEmit.ReactJSX,
},
fileName: absolutePath,
reportDiagnostics: false,
}).outputText;
const module = { exports: {} };
cache.set(absolutePath, module.exports);
const localRequire = (specifier) => {
const resolvedTsPath = resolveTsFilePath(absolutePath, specifier);
if (resolvedTsPath) {
return loadTsModule(resolvedTsPath, cache);
}
return require(specifier);
};
const sandbox = {
module,
exports: module.exports,
require: localRequire,
__dirname: path.dirname(absolutePath),
__filename: absolutePath,
console,
process,
Buffer,
setTimeout,
clearTimeout,
};
vm.runInNewContext(transpiled, sandbox, { filename: absolutePath });
cache.set(absolutePath, module.exports);
return module.exports;
};
const loadBatchEntries = () => {
const batch1Module = loadTsModule(BATCH_1_PATH);
const batch2Module = loadTsModule(BATCH_2_PATH);
const batch1Entries = batch1Module.LEXICON_BATCH_1_ENTRIES;
const batch2Entries = batch2Module.LEXICON_BATCH_2_ENTRIES;
if (!Array.isArray(batch1Entries) || !Array.isArray(batch2Entries)) {
throw new Error('Could not load lexicon batch entries from TypeScript constants.');
}
return [...batch1Entries, ...batch2Entries];
};
const main = async () => {
const entries = loadBatchEntries();
const db = await openDatabase();
try {
await ensurePlantSchema(db);
const summary = await rebuildPlantsCatalog(db, entries, {
source: 'local_batch_script',
preserveExistingIds: true,
enforceUniqueImages: true,
});
console.log('Rebuild finished successfully.');
console.log(JSON.stringify(summary, null, 2));
} finally {
await closeDatabase(db);
}
};
main().catch((error) => {
console.error('Failed to rebuild plants from local batches.');
console.error(error instanceof Error ? error.stack || error.message : String(error));
process.exit(1);
});
#!/usr/bin/env node
/* eslint-disable no-console */
const fs = require('fs');
const path = require('path');
const vm = require('vm');
require('dotenv').config();
const { closeDatabase, openDatabase } = require('../lib/sqlite');
const { ensurePlantSchema, rebuildPlantsCatalog } = require('../lib/plants');
let ts;
try {
ts = require('typescript');
} catch (error) {
console.error('The rebuild script needs the "typescript" package in node_modules.');
console.error('Install dependencies in the repository root before running this script.');
process.exit(1);
}
const ROOT_DIR = path.resolve(__dirname, '..', '..');
const BATCH_1_PATH = path.join(ROOT_DIR, 'constants', 'lexiconBatch1.ts');
const BATCH_2_PATH = path.join(ROOT_DIR, 'constants', 'lexiconBatch2.ts');
const resolveTsFilePath = (fromFile, specifier) => {
if (!specifier.startsWith('.')) return null;
const fromDirectory = path.dirname(fromFile);
const absoluteBase = path.resolve(fromDirectory, specifier);
const candidates = [
absoluteBase,
`${absoluteBase}.ts`,
`${absoluteBase}.tsx`,
path.join(absoluteBase, 'index.ts'),
];
for (const candidate of candidates) {
if (fs.existsSync(candidate) && fs.statSync(candidate).isFile()) {
return candidate;
}
}
return null;
};
const loadTsModule = (absolutePath, cache = new Map()) => {
if (cache.has(absolutePath)) return cache.get(absolutePath);
const source = fs.readFileSync(absolutePath, 'utf8');
const transpiled = ts.transpileModule(source, {
compilerOptions: {
module: ts.ModuleKind.CommonJS,
target: ts.ScriptTarget.ES2020,
esModuleInterop: true,
jsx: ts.JsxEmit.ReactJSX,
},
fileName: absolutePath,
reportDiagnostics: false,
}).outputText;
const module = { exports: {} };
cache.set(absolutePath, module.exports);
const localRequire = (specifier) => {
const resolvedTsPath = resolveTsFilePath(absolutePath, specifier);
if (resolvedTsPath) {
return loadTsModule(resolvedTsPath, cache);
}
return require(specifier);
};
const sandbox = {
module,
exports: module.exports,
require: localRequire,
__dirname: path.dirname(absolutePath),
__filename: absolutePath,
console,
process,
Buffer,
setTimeout,
clearTimeout,
};
vm.runInNewContext(transpiled, sandbox, { filename: absolutePath });
cache.set(absolutePath, module.exports);
return module.exports;
};
const loadBatchEntries = () => {
const batch1Module = loadTsModule(BATCH_1_PATH);
const batch2Module = loadTsModule(BATCH_2_PATH);
const batch1Entries = batch1Module.LEXICON_BATCH_1_ENTRIES;
const batch2Entries = batch2Module.LEXICON_BATCH_2_ENTRIES;
if (!Array.isArray(batch1Entries) || !Array.isArray(batch2Entries)) {
throw new Error('Could not load lexicon batch entries from TypeScript constants.');
}
return [...batch1Entries, ...batch2Entries];
};
const main = async () => {
const entries = loadBatchEntries();
const db = await openDatabase();
try {
await ensurePlantSchema(db);
const summary = await rebuildPlantsCatalog(db, entries, {
source: 'local_batch_script',
preserveExistingIds: true,
enforceUniqueImages: true,
});
console.log('Rebuild finished successfully.');
console.log(JSON.stringify(summary, null, 2));
} finally {
await closeDatabase(db);
}
};
main().catch((error) => {
console.error('Failed to rebuild plants from local batches.');
console.error(error instanceof Error ? error.stack || error.message : String(error));
process.exit(1);
});