Initial commit for Greenlens

This commit is contained in:
Timo Knuth
2026-03-16 21:31:46 +01:00
parent 307135671f
commit 05d4f6e78b
573 changed files with 54233 additions and 1891 deletions

4
server/.dockerignore Normal file
View File

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

12
server/Dockerfile Normal file
View File

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

View File

@@ -1,14 +1,30 @@
const fs = require('fs');
const path = require('path');
const dotenv = require('dotenv');
const express = require('express');
const cors = require('cors');
const Stripe = require('stripe');
// override: true ensures .env always wins over existing shell env vars
dotenv.config({ path: path.join(__dirname, '.env'), override: true });
dotenv.config({ path: path.join(__dirname, '.env.local'), override: true });
dotenv.config({ path: path.join(__dirname, '..', '.env') });
dotenv.config({ path: path.join(__dirname, '..', '.env.local') });
const loadEnvFiles = (filePaths) => {
const mergedFileEnv = {};
for (const filePath of filePaths) {
if (!fs.existsSync(filePath)) continue;
Object.assign(mergedFileEnv, dotenv.parse(fs.readFileSync(filePath)));
}
for (const [key, value] of Object.entries(mergedFileEnv)) {
if (process.env[key] === undefined) {
process.env[key] = value;
}
}
};
loadEnvFiles([
path.join(__dirname, '..', '.env'),
path.join(__dirname, '.env'),
path.join(__dirname, '..', '.env.local'),
path.join(__dirname, '.env.local'),
]);
const { closeDatabase, getDefaultDbPath, openDatabase, get, run } = require('./lib/sqlite');
const { ensureAuthSchema, signUp: authSignUp, login: authLogin, issueToken, verifyJwt } = require('./lib/auth');
@@ -39,9 +55,12 @@ const {
identifyPlant,
isConfigured: isOpenAiConfigured,
} = require('./lib/openai');
const { applyCatalogGrounding, normalizeText } = require('./lib/scanGrounding');
const { ensureStorageBucket, uploadImage, isStorageConfigured } = require('./lib/storage');
const app = express();
const port = Number(process.env.PORT || 3000);
const plantsPublicDir = path.join(__dirname, 'public', 'plants');
const stripeSecretKey = (process.env.STRIPE_SECRET_KEY || '').trim();
if (!stripeSecretKey) {
console.error('STRIPE_SECRET_KEY is not set. Payment endpoints will fail.');
@@ -112,15 +131,6 @@ const parseBoolean = (value, fallbackValue) => {
return fallbackValue;
};
const normalizeText = (value) => {
return String(value || '')
.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/\s+/g, ' ')
.trim();
};
const hashString = (value) => {
let hash = 0;
for (let i = 0; i < value.length; i += 1) {
@@ -151,6 +161,7 @@ const resolveUserId = (request) => {
const authHeader = request.header('authorization');
if (authHeader && authHeader.startsWith('Bearer ')) {
const token = authHeader.slice(7);
if (token === 'guest') return 'guest';
const payload = verifyJwt(token);
if (payload && payload.sub) return String(payload.sub);
}
@@ -198,59 +209,6 @@ const pickCatalogFallback = (entries, imageUri, preferHighConfidence = false) =>
return toPlantResult(entries[index], confidence);
};
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 applyCatalogGrounding = (aiResult, catalogEntries) => {
const matchedEntry = findCatalogMatch(aiResult, catalogEntries);
if (!matchedEntry) {
return { grounded: false, result: aiResult };
}
return {
grounded: true,
result: {
name: matchedEntry.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',
},
},
};
};
const toImportErrorPayload = (error) => {
if (error instanceof PlantImportValidationError) {
return {
@@ -331,6 +289,8 @@ const ensureRequestAuth = (request) => {
return userId;
};
const isGuest = (userId) => userId === 'guest';
const ensureNonEmptyString = (value, fieldName) => {
if (typeof value === 'string' && value.trim()) return value.trim();
const error = new Error(`${fieldName} is required.`);
@@ -350,6 +310,7 @@ const seedBootstrapCatalogIfNeeded = async () => {
};
app.use(cors());
app.use('/plants', express.static(plantsPublicDir));
// Webhook must be BEFORE express.json() to get the raw body
app.post('/api/webhook', express.raw({ type: 'application/json' }), (request, response) => {
@@ -399,6 +360,7 @@ app.get('/', (_request, response) => {
'POST /v1/health-check',
'POST /v1/billing/simulate-purchase',
'POST /v1/billing/simulate-webhook',
'POST /v1/upload/image',
],
});
});
@@ -512,9 +474,11 @@ app.post('/api/payment-sheet', async (request, response) => {
app.get('/v1/billing/summary', async (request, response) => {
try {
const userId = ensureRequestAuth(request);
const userExists = await get(db, 'SELECT id FROM auth_users WHERE id = ?', [userId]);
if (!userExists) {
return response.status(401).json({ code: 'UNAUTHORIZED', message: 'User not found.' });
if (userId !== 'guest') {
const userExists = await get(db, 'SELECT id FROM auth_users WHERE id = ?', [userId]);
if (!userExists) {
return response.status(401).json({ code: 'UNAUTHORIZED', message: 'User not found.' });
}
}
const summary = await getBillingSummary(db, userId);
response.status(200).json(summary);
@@ -543,23 +507,29 @@ app.post('/v1/scan', async (request, response) => {
const modelPath = [];
let modelUsed = null;
let modelFallbackCount = 0;
creditsCharged += await consumeCreditsWithIdempotency(
db,
userId,
chargeKey('scan-primary', userId, idempotencyKey),
SCAN_PRIMARY_COST,
);
if (!isGuest(userId)) {
creditsCharged += await consumeCreditsWithIdempotency(
db,
userId,
chargeKey('scan-primary', userId, idempotencyKey),
SCAN_PRIMARY_COST,
);
}
const accountSnapshot = await getAccountSnapshot(db, userId);
const scanPlan = accountSnapshot.plan === 'pro' ? 'pro' : 'free';
const catalogEntries = await getPlants(db, { limit: 500 });
let result = pickCatalogFallback(catalogEntries, imageUri, false);
let usedOpenAi = false;
if (isOpenAiConfigured()) {
console.log(`Starting OpenAI identification for user ${userId} using model ${getScanModel()}`);
console.log(`Starting OpenAI identification for user ${userId} using model ${getScanModel(scanPlan)} (plan: ${scanPlan})`);
const openAiPrimary = await identifyPlant({
imageUri,
language,
mode: 'primary',
plan: scanPlan,
});
modelFallbackCount = Math.max(
modelFallbackCount,
@@ -567,7 +537,7 @@ app.post('/v1/scan', async (request, response) => {
);
if (openAiPrimary?.result) {
console.log(`OpenAI primary identification successful for user ${userId}: ${openAiPrimary.result.name} (${openAiPrimary.result.confidence}) using ${openAiPrimary.modelUsed}`);
const grounded = applyCatalogGrounding(openAiPrimary.result, catalogEntries);
const grounded = applyCatalogGrounding(openAiPrimary.result, catalogEntries, language);
result = grounded.result;
if (!grounded.grounded) result = { ...result, confidence: clamp(Math.max(result.confidence || 0.6, 0.72), 0.05, 0.99) };
usedOpenAi = true;
@@ -592,22 +562,24 @@ app.post('/v1/scan', async (request, response) => {
}
const shouldReview = result.confidence < LOW_CONFIDENCE_REVIEW_THRESHOLD;
const accountSnapshot = await getAccountSnapshot(db, userId);
if (shouldReview && accountSnapshot.plan === 'pro') {
console.log(`Starting AI review for user ${userId} (confidence ${result.confidence} < ${LOW_CONFIDENCE_REVIEW_THRESHOLD})`);
try {
creditsCharged += await consumeCreditsWithIdempotency(
db,
userId,
chargeKey('scan-review', userId, idempotencyKey),
SCAN_REVIEW_COST,
);
if (!isGuest(userId)) {
creditsCharged += await consumeCreditsWithIdempotency(
db,
userId,
chargeKey('scan-review', userId, idempotencyKey),
SCAN_REVIEW_COST,
);
}
if (usedOpenAi) {
const openAiReview = await identifyPlant({
imageUri,
language,
mode: 'review',
plan: scanPlan,
});
modelFallbackCount = Math.max(
modelFallbackCount,
@@ -615,7 +587,7 @@ app.post('/v1/scan', async (request, response) => {
);
if (openAiReview?.result) {
console.log(`OpenAI review identification successful for user ${userId}: ${openAiReview.result.name} (${openAiReview.result.confidence}) using ${openAiReview.modelUsed}`);
const grounded = applyCatalogGrounding(openAiReview.result, catalogEntries);
const grounded = applyCatalogGrounding(openAiReview.result, catalogEntries, language);
result = grounded.result;
if (!grounded.grounded) result = { ...result, confidence: clamp(Math.max(result.confidence || 0.6, 0.72), 0.05, 0.99) };
modelUsed = openAiReview.modelUsed || modelUsed;
@@ -743,12 +715,15 @@ app.post('/v1/health-check', async (request, response) => {
throw error;
}
const creditsCharged = await consumeCreditsWithIdempotency(
db,
userId,
chargeKey('health-check', userId, idempotencyKey),
HEALTH_CHECK_COST,
);
let creditsCharged = 0;
if (!isGuest(userId)) {
creditsCharged = await consumeCreditsWithIdempotency(
db,
userId,
chargeKey('health-check', userId, idempotencyKey),
HEALTH_CHECK_COST,
);
}
const healthCheck = {
generatedAt: nowIso(),
@@ -803,6 +778,35 @@ app.post('/v1/billing/simulate-webhook', async (request, response) => {
}
});
// ─── Image Upload ──────────────────────────────────────────────────────────
app.post('/v1/upload/image', async (request, response) => {
try {
ensureRequestAuth(request);
if (!isStorageConfigured()) {
return response.status(503).json({
code: 'STORAGE_NOT_CONFIGURED',
message: 'Image storage is not configured.',
});
}
const { imageBase64, contentType = 'image/jpeg' } = request.body || {};
if (!imageBase64 || typeof imageBase64 !== 'string') {
return response.status(400).json({
code: 'BAD_REQUEST',
message: 'imageBase64 is required.',
});
}
const { url } = await uploadImage(imageBase64, contentType);
response.status(200).json({ url });
} catch (error) {
const payload = toApiErrorPayload(error);
response.status(payload.status).json(payload.body);
}
});
// ─── Auth endpoints ────────────────────────────────────────────────────────
app.post('/auth/signup', async (request, response) => {
@@ -843,6 +847,9 @@ const start = async () => {
await ensureBillingSchema(db);
await ensureAuthSchema(db);
await seedBootstrapCatalogIfNeeded();
if (isStorageConfigured()) {
await ensureStorageBucket().catch((err) => console.warn('MinIO bucket setup failed:', err.message));
}
const stripeMode = getStripeSecretMode();
const stripePublishableMode = getStripePublishableMode();
@@ -855,7 +862,7 @@ const start = async () => {
console.log(`Stripe Publishable Mode: ${stripePublishableMode} | Key: ${maskKey(process.env.STRIPE_PUBLISHABLE_KEY || process.env.EXPO_PUBLIC_STRIPE_PUBLISHABLE_KEY)}`);
const server = app.listen(port, () => {
console.log(`GreenLns server listening at http://localhost:${port}`);
console.log(`GreenLens server listening at http://localhost:${port}`);
});
const gracefulShutdown = async () => {
@@ -873,6 +880,6 @@ const start = async () => {
};
start().catch((error) => {
console.error('Failed to start GreenLns server', error);
console.error('Failed to start GreenLens server', error);
process.exit(1);
});

View File

@@ -1,17 +1,17 @@
const { get, run } = require('./sqlite');
const FREE_MONTHLY_CREDITS = 15;
const PRO_MONTHLY_CREDITS = 50;
const PRO_MONTHLY_CREDITS = 250;
const TOPUP_DEFAULT_CREDITS = 60;
const TOPUP_CREDITS_BY_PRODUCT = {
pro_monthly: 0,
topup_small: 50,
topup_small: 25,
topup_medium: 120,
topup_large: 300,
};
const AVAILABLE_PRODUCTS = ['pro_monthly', 'topup_small', 'topup_medium', 'topup_large'];
const AVAILABLE_PRODUCTS = ['monthly_pro', 'yearly_pro', 'topup_small', 'topup_medium', 'topup_large'];
const nowIso = () => new Date().toISOString();
@@ -68,7 +68,7 @@ const normalizeAccountRow = (row) => {
return {
userId: String(row.userId),
plan: row.plan === 'pro' ? 'pro' : 'free',
provider: typeof row.provider === 'string' && row.provider ? row.provider : 'stripe',
provider: typeof row.provider === 'string' && row.provider ? row.provider : 'revenuecat',
cycleStartedAt: String(row.cycleStartedAt),
cycleEndsAt: String(row.cycleEndsAt),
monthlyAllowance: Number(row.monthlyAllowance) || FREE_MONTHLY_CREDITS,
@@ -84,7 +84,7 @@ const buildDefaultAccount = (userId, now) => {
return {
userId,
plan: 'free',
provider: 'stripe',
provider: 'revenuecat',
cycleStartedAt: cycleStartedAt.toISOString(),
cycleEndsAt: cycleEndsAt.toISOString(),
monthlyAllowance: FREE_MONTHLY_CREDITS,
@@ -305,6 +305,20 @@ const consumeCreditsWithIdempotency = async (db, userId, key, cost) => {
};
const getBillingSummary = async (db, userId) => {
if (userId === 'guest') {
return {
entitlement: { plan: 'free', provider: 'mock', status: 'active', renewsAt: null },
credits: {
monthlyAllowance: 5,
usedThisCycle: 0,
topupBalance: 0,
available: 5,
cycleStartedAt: nowIso(),
cycleEndsAt: nowIso()
},
availableProducts: AVAILABLE_PRODUCTS,
};
}
return runInTransaction(db, async () => {
const account = await getOrCreateAccount(db, userId);
account.updatedAt = nowIso();
@@ -314,6 +328,20 @@ const getBillingSummary = async (db, userId) => {
};
const getAccountSnapshot = async (db, userId) => {
if (userId === 'guest') {
return {
userId: 'guest',
plan: 'free',
provider: 'mock',
cycleStartedAt: nowIso(),
cycleEndsAt: nowIso(),
monthlyAllowance: 5,
usedThisCycle: 0,
topupBalance: 0,
renewsAt: null,
updatedAt: nowIso(),
};
}
return runInTransaction(db, async () => {
const account = await getOrCreateAccount(db, userId);
account.updatedAt = nowIso();
@@ -342,11 +370,11 @@ const simulatePurchase = async (db, userId, idempotencyKey, productId) => {
const account = await getOrCreateAccount(db, userId);
if (productId === 'pro_monthly') {
if (productId === 'monthly_pro' || productId === 'yearly_pro') {
const now = new Date();
const { cycleStartedAt, cycleEndsAt } = getCycleBounds(now);
account.plan = 'pro';
account.provider = 'stripe';
account.provider = 'revenuecat';
account.monthlyAllowance = PRO_MONTHLY_CREDITS;
account.usedThisCycle = 0;
account.cycleStartedAt = cycleStartedAt.toISOString();
@@ -441,7 +469,7 @@ const ensureBillingSchema = async (db) => {
`CREATE TABLE IF NOT EXISTS billing_accounts (
userId TEXT PRIMARY KEY,
plan TEXT NOT NULL DEFAULT 'free',
provider TEXT NOT NULL DEFAULT 'stripe',
provider TEXT NOT NULL DEFAULT 'revenuecat',
cycleStartedAt TEXT NOT NULL,
cycleEndsAt TEXT NOT NULL,
monthlyAllowance INTEGER NOT NULL DEFAULT 15,

193
server/lib/hybridSearch.js Normal file
View File

@@ -0,0 +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,
};

View File

@@ -1,7 +1,9 @@
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').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-4o-mini').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 = (() => {
@@ -22,8 +24,13 @@ const parseModelChain = (primaryModel, fallbackModels) => {
};
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));
};
@@ -197,12 +204,17 @@ const buildIdentifyPrompt = (language, mode) => {
? '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:',
`- "name", "description", and "careInfo.light" must be written in ${getLanguageLabel(language)}.`,
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.',
@@ -324,10 +336,11 @@ const postChatCompletion = async ({ modelChain, messages, imageUri, temperature
return { payload: null, modelUsed: null, attemptedModels };
};
const identifyPlant = async ({ imageUri, language, mode = 'primary' }) => {
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: OPENAI_SCAN_MODEL_CHAIN,
modelChain,
imageUri,
messages: [
{
@@ -355,7 +368,7 @@ const identifyPlant = async ({ imageUri, language, mode = 'primary' }) => {
const content = extractMessageContent(completion.payload);
if (!content) {
console.warn('OpenAI identify returned empty content.', {
model: completion.modelUsed || OPENAI_SCAN_MODEL_CHAIN[0],
model: completion.modelUsed || modelChain[0],
mode,
image: summarizeImageUri(imageUri),
});
@@ -365,7 +378,7 @@ const identifyPlant = async ({ imageUri, language, mode = 'primary' }) => {
const parsed = parseContentToJson(content);
if (!parsed) {
console.warn('OpenAI identify returned non-JSON content.', {
model: completion.modelUsed || OPENAI_SCAN_MODEL_CHAIN[0],
model: completion.modelUsed || modelChain[0],
mode,
preview: content.slice(0, 220),
});
@@ -375,7 +388,7 @@ const identifyPlant = async ({ imageUri, language, mode = 'primary' }) => {
const normalized = normalizeIdentifyResult(parsed, language);
if (!normalized) {
console.warn('OpenAI identify JSON did not match schema.', {
model: completion.modelUsed || OPENAI_SCAN_MODEL_CHAIN[0],
model: completion.modelUsed || modelChain[0],
mode,
keys: Object.keys(parsed),
});
@@ -439,8 +452,10 @@ const analyzePlantHealth = async ({ imageUri, language, plantContext }) => {
module.exports = {
analyzePlantHealth,
buildIdentifyPrompt,
getHealthModel: () => OPENAI_HEALTH_MODEL_CHAIN[0],
getScanModel: () => OPENAI_SCAN_MODEL_CHAIN[0],
getScanModel: (plan = 'free') => getScanModelChain(plan)[0],
identifyPlant,
isConfigured: () => Boolean(OPENAI_API_KEY),
normalizeIdentifyResult,
};

View File

@@ -1,11 +1,15 @@
const crypto = require('crypto');
const { all, get, run } = require('./sqlite');
const { normalizeSearchText, rankHybridEntries } = require('./hybridSearch');
const DEFAULT_LIMIT = 60;
const MAX_LIMIT = 500;
const MAX_AUDIT_DETAILS = 80;
const WIKIMEDIA_FILEPATH_SEGMENT = 'Special:FilePath/';
const WIKIMEDIA_REDIRECT_BASE = 'https://commons.wikimedia.org/wiki/Special:FilePath/';
const WIKIMEDIA_SEARCH_PREFIX = 'wikimedia-search:';
const LOCAL_PLANT_IMAGE_PREFIX = '/plants/';
const LOCAL_PLANT_IMAGE_PATH_PATTERN = /^\/plants\/[A-Za-z0-9/_-]+\.[A-Za-z0-9]+$/;
class PlantImportValidationError extends Error {
constructor(message, details) {
@@ -19,12 +23,7 @@ const normalizeWhitespace = (value) => {
return value.trim().replace(/\s+/g, ' ');
};
const normalizeKey = (value) => {
return normalizeWhitespace(value)
.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '');
};
const normalizeKey = (value) => normalizeSearchText(normalizeWhitespace(value));
const unwrapMarkdownLink = (value) => {
const markdownMatch = value.match(/^\[[^\]]+]\((https?:\/\/[^)]+)\)(.*)$/i);
@@ -41,6 +40,16 @@ const tryDecode = (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 convertWikimediaFilePathUrl = (value) => {
const segmentIndex = value.indexOf(WIKIMEDIA_FILEPATH_SEGMENT);
if (segmentIndex < 0) return null;
@@ -55,12 +64,75 @@ const convertWikimediaFilePathUrl = (value) => {
return `${WIKIMEDIA_REDIRECT_BASE}${encodedFileName}`;
};
const toWikimediaFilePathUrl = (value) => {
if (typeof value !== 'string' || !value.includes('upload.wikimedia.org/wikipedia/commons/')) {
return null;
}
const cleanUrl = value.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 `${WIKIMEDIA_REDIRECT_BASE}${encodeURIComponent(decoded)}`;
};
const normalizeLocalImagePath = (value) => {
if (typeof value !== 'string') return null;
const trimmed = value.trim();
if (!trimmed) return null;
const withoutQuery = trimmed.split(/[?#]/)[0].replace(/\\/g, '/');
const withLeadingSlash = withoutQuery.startsWith('/') ? withoutQuery : `/${withoutQuery}`;
if (!withLeadingSlash.startsWith(LOCAL_PLANT_IMAGE_PREFIX)) return null;
if (withLeadingSlash.includes('..')) return null;
if (!LOCAL_PLANT_IMAGE_PATH_PATTERN.test(withLeadingSlash)) return null;
return withLeadingSlash;
};
const normalizeWikimediaSearchUri = (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;
const normalizedQuery = normalizeWhitespace(decodeRepeatedly(rawQuery));
if (!normalizedQuery) return null;
return `${WIKIMEDIA_SEARCH_PREFIX}${encodeURIComponent(normalizedQuery)}`;
};
const normalizeImageUri = (rawUri) => {
if (typeof rawUri !== 'string') return null;
const trimmed = rawUri.trim();
if (!trimmed) return null;
const localPath = normalizeLocalImagePath(trimmed);
if (localPath) return localPath;
const wikimediaSearchUri = normalizeWikimediaSearchUri(trimmed);
if (wikimediaSearchUri) return wikimediaSearchUri;
const normalized = unwrapMarkdownLink(trimmed);
const converted = convertWikimediaFilePathUrl(normalized);
const candidate = (converted || normalized).replace(/^http:\/\//i, 'https://');
@@ -142,10 +214,11 @@ const prepareEntry = (rawEntry, index, existingIdMap, preserveExistingIds) => {
errors.push({
index,
field: 'imageUri',
message: 'imageUri is missing or invalid. A valid http(s) URL is required.',
message: 'imageUri is missing or invalid. Use a valid http(s) URL, a local /plants/... path, or wikimedia-search:<query>.',
value: rawEntry?.imageUri ?? null,
});
}
const imageStatus = imageUri && imageUri.startsWith(WIKIMEDIA_SEARCH_PREFIX) ? 'pending' : 'ok';
const categories = toArrayOfStrings(rawEntry?.categories);
const confidence = parseNumber(rawEntry?.confidence, 1);
@@ -168,7 +241,7 @@ const prepareEntry = (rawEntry, index, existingIdMap, preserveExistingIds) => {
name,
botanicalName,
imageUri,
imageStatus: 'ok',
imageStatus,
description,
categories,
careInfo,
@@ -335,11 +408,12 @@ const parseJsonObject = (value) => {
const toApiPlant = (row) => {
const categories = parseJsonArray(row.categories);
const careInfo = parseJsonObject(row.careInfo);
const imageUri = toWikimediaFilePathUrl(row.imageUri) || row.imageUri;
return {
id: row.id,
name: row.name,
botanicalName: row.botanicalName,
imageUri: row.imageUri,
imageUri,
imageStatus: row.imageStatus || 'ok',
description: row.description || '',
categories,
@@ -349,7 +423,7 @@ const toApiPlant = (row) => {
};
const getPlants = async (db, options = {}) => {
const query = typeof options.query === 'string' ? options.query.trim().toLowerCase() : '';
const query = typeof options.query === 'string' ? options.query.trim() : '';
const category = typeof options.category === 'string' ? options.category.trim() : '';
const limitRaw = Number(options.limit);
const limit = Number.isFinite(limitRaw)
@@ -368,15 +442,6 @@ const getPlants = async (db, options = {}) => {
confidence
FROM plants`;
const params = [];
if (query) {
sql += ` WHERE (
LOWER(name) LIKE ?
OR LOWER(botanicalName) LIKE ?
OR LOWER(COALESCE(description, '')) LIKE ?
)`;
const likePattern = `%${query}%`;
params.push(likePattern, likePattern, likePattern);
}
sql += ' ORDER BY name COLLATE NOCASE ASC';
const rows = await all(db, sql, params);
@@ -386,7 +451,12 @@ const getPlants = async (db, options = {}) => {
results = results.filter((plant) => plant.categories.includes(category));
}
return results.slice(0, limit);
if (!query) {
return results.slice(0, limit);
}
return rankHybridEntries(results, query, limit)
.map((candidate) => candidate.entry);
};
const getPlantDiagnostics = async (db) => {
@@ -565,7 +635,7 @@ const rebuildPlantsCatalog = async (db, rawEntries, options = {}) => {
entry.name,
entry.botanicalName,
entry.imageUri,
'ok',
entry.imageStatus,
entry.description,
JSON.stringify(entry.categories),
JSON.stringify(entry.careInfo),
@@ -647,6 +717,8 @@ module.exports = {
ensurePlantSchema,
getPlantDiagnostics,
getPlants,
normalizeKey,
normalizeImageUri,
toWikimediaFilePathUrl,
rebuildPlantsCatalog,
};

131
server/lib/scanGrounding.js Normal file
View File

@@ -0,0 +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,
};

View File

@@ -0,0 +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,
};

72
server/lib/storage.js Normal file
View File

@@ -0,0 +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,
};

776
server/package-lock.json generated
View File

@@ -12,10 +12,22 @@
"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"
}
},
"node_modules/@emnapi/runtime": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz",
"integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==",
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@gar/promisify": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@gar/promisify/-/promisify-1.1.3.tgz",
@@ -23,6 +35,471 @@
"license": "MIT",
"optional": true
},
"node_modules/@img/colour": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
"integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/@img/sharp-darwin-arm64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz",
"integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==",
"cpu": [
"arm64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-arm64": "1.2.4"
}
},
"node_modules/@img/sharp-darwin-x64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz",
"integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==",
"cpu": [
"x64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-x64": "1.2.4"
}
},
"node_modules/@img/sharp-libvips-darwin-arm64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz",
"integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==",
"cpu": [
"arm64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"darwin"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-darwin-x64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz",
"integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==",
"cpu": [
"x64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"darwin"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-arm": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz",
"integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==",
"cpu": [
"arm"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-arm64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz",
"integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==",
"cpu": [
"arm64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-ppc64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz",
"integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==",
"cpu": [
"ppc64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-riscv64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz",
"integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==",
"cpu": [
"riscv64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-s390x": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz",
"integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==",
"cpu": [
"s390x"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-x64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz",
"integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==",
"cpu": [
"x64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz",
"integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==",
"cpu": [
"arm64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linuxmusl-x64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz",
"integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==",
"cpu": [
"x64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-linux-arm": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz",
"integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==",
"cpu": [
"arm"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm": "1.2.4"
}
},
"node_modules/@img/sharp-linux-arm64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz",
"integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==",
"cpu": [
"arm64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm64": "1.2.4"
}
},
"node_modules/@img/sharp-linux-ppc64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz",
"integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==",
"cpu": [
"ppc64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-ppc64": "1.2.4"
}
},
"node_modules/@img/sharp-linux-riscv64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz",
"integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==",
"cpu": [
"riscv64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-riscv64": "1.2.4"
}
},
"node_modules/@img/sharp-linux-s390x": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz",
"integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==",
"cpu": [
"s390x"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-s390x": "1.2.4"
}
},
"node_modules/@img/sharp-linux-x64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz",
"integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==",
"cpu": [
"x64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-x64": "1.2.4"
}
},
"node_modules/@img/sharp-linuxmusl-arm64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz",
"integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==",
"cpu": [
"arm64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-arm64": "1.2.4"
}
},
"node_modules/@img/sharp-linuxmusl-x64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz",
"integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==",
"cpu": [
"x64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-x64": "1.2.4"
}
},
"node_modules/@img/sharp-wasm32": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz",
"integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==",
"cpu": [
"wasm32"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
"optional": true,
"dependencies": {
"@emnapi/runtime": "^1.7.0"
},
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-arm64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz",
"integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==",
"cpu": [
"arm64"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-ia32": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz",
"integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==",
"cpu": [
"ia32"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-x64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz",
"integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==",
"cpu": [
"x64"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@npmcli/fs": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-1.1.1.tgz",
@@ -151,6 +628,12 @@
"node": "^12.13.0 || ^14.15.0 || >=16.0.0"
}
},
"node_modules/async": {
"version": "3.2.6",
"resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz",
"integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==",
"license": "MIT"
},
"node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
@@ -198,6 +681,15 @@
"readable-stream": "^3.4.0"
}
},
"node_modules/block-stream2": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/block-stream2/-/block-stream2-2.1.0.tgz",
"integrity": "sha512-suhjmLI57Ewpmq00qaygS8UgEq2ly2PCItenIyhMqVjo4t4pGzqMvfgJuX8iWTeSDdfSSqS6j38fL4ToNL7Pfg==",
"license": "MIT",
"dependencies": {
"readable-stream": "^3.4.0"
}
},
"node_modules/body-parser": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz",
@@ -233,6 +725,12 @@
"concat-map": "0.0.1"
}
},
"node_modules/browser-or-node": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/browser-or-node/-/browser-or-node-2.1.1.tgz",
"integrity": "sha512-8CVjaLJGuSKMVTxJ2DpBl5XnlNDiT4cQFeuCJJrvJmts9YrTZDizTX7PjC2s6W4x+MBGZeEY6dGMrF04/6Hgqg==",
"license": "MIT"
},
"node_modules/buffer": {
"version": "5.7.1",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
@@ -257,6 +755,15 @@
"ieee754": "^1.1.13"
}
},
"node_modules/buffer-crc32": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-1.0.0.tgz",
"integrity": "sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==",
"license": "MIT",
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
@@ -442,6 +949,15 @@
}
}
},
"node_modules/decode-uri-component": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.2.tgz",
"integrity": "sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ==",
"license": "MIT",
"engines": {
"node": ">=0.10"
}
},
"node_modules/decompress-response": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
@@ -633,6 +1149,12 @@
"node": ">= 0.6"
}
},
"node_modules/eventemitter3": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
"license": "MIT"
},
"node_modules/expand-template": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
@@ -685,12 +1207,52 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/fast-xml-builder": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.0.0.tgz",
"integrity": "sha512-fpZuDogrAgnyt9oDDz+5DBz0zgPdPZz6D4IR7iESxRXElrlGTRkHJ9eEt+SACRJwT0FNFrt71DFQIUFBJfX/uQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT"
},
"node_modules/fast-xml-parser": {
"version": "5.4.2",
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.4.2.tgz",
"integrity": "sha512-pw/6pIl4k0CSpElPEJhDppLzaixDEuWui2CUQQBH/ECDf7+y6YwA4Gf7Tyb0Rfe4DIMuZipYj4AEL0nACKglvQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT",
"dependencies": {
"fast-xml-builder": "^1.0.0",
"strnum": "^2.1.2"
},
"bin": {
"fxparser": "src/cli/cli.js"
}
},
"node_modules/file-uri-to-path": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
"integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",
"license": "MIT"
},
"node_modules/filter-obj": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/filter-obj/-/filter-obj-1.1.0.tgz",
"integrity": "sha512-8rXg1ZnX7xzy2NGDVkBVaAy+lSlPNwad13BtgSlLuxfIslyt5Vg64U7tFcCt4WS1R0hvtnQybT/IyCkGZ3DpXQ==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/finalhandler": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz",
@@ -1102,6 +1664,12 @@
"license": "ISC",
"optional": true
},
"node_modules/lodash": {
"version": "4.17.23",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
"license": "MIT"
},
"node_modules/lru-cache": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
@@ -1242,6 +1810,60 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/minio": {
"version": "8.0.7",
"resolved": "https://registry.npmjs.org/minio/-/minio-8.0.7.tgz",
"integrity": "sha512-E737MgufW8CeQAsTAtnEMrxZ9scMSf29kkhZoXzDTKj/Jszzo2SfeZUH9wbDQH2Rsq6TCtl/yQL0+XdVKZansQ==",
"license": "Apache-2.0",
"dependencies": {
"async": "^3.2.4",
"block-stream2": "^2.1.0",
"browser-or-node": "^2.1.1",
"buffer-crc32": "^1.0.0",
"eventemitter3": "^5.0.1",
"fast-xml-parser": "^5.3.4",
"ipaddr.js": "^2.0.1",
"lodash": "^4.17.21",
"mime-types": "^2.1.35",
"query-string": "^7.1.3",
"stream-json": "^1.8.0",
"through2": "^4.0.2",
"xml2js": "^0.5.0 || ^0.6.2"
},
"engines": {
"node": "^16 || ^18 || >=20"
}
},
"node_modules/minio/node_modules/ipaddr.js": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.3.0.tgz",
"integrity": "sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg==",
"license": "MIT",
"engines": {
"node": ">= 10"
}
},
"node_modules/minio/node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/minio/node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/minipass": {
"version": "3.3.6",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz",
@@ -1625,6 +2247,24 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/query-string": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/query-string/-/query-string-7.1.3.tgz",
"integrity": "sha512-hh2WYhq4fi8+b+/2Kg9CEge4fDPvHS534aOOvOZeQ3+Vf2mCFsaFBYj0i+iXcAq6I9Vzp5fjMFBlONvayDC1qg==",
"license": "MIT",
"dependencies": {
"decode-uri-component": "^0.2.2",
"filter-obj": "^1.1.0",
"split-on-first": "^1.0.0",
"strict-uri-encode": "^2.0.0"
},
"engines": {
"node": ">=6"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/range-parser": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
@@ -1747,6 +2387,15 @@
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
"node_modules/sax": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/sax/-/sax-1.5.0.tgz",
"integrity": "sha512-21IYA3Q5cQf089Z6tgaUTr7lDAyzoTPx5HRtbhsME8Udispad8dC/+sziTNugOEx54ilvatQ9YCzl4KQLPcRHA==",
"license": "BlueOak-1.0.0",
"engines": {
"node": ">=11.0.0"
}
},
"node_modules/semver": {
"version": "7.7.4",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
@@ -1817,6 +2466,50 @@
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
"license": "ISC"
},
"node_modules/sharp": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz",
"integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==",
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
"@img/colour": "^1.0.0",
"detect-libc": "^2.1.2",
"semver": "^7.7.3"
},
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-darwin-arm64": "0.34.5",
"@img/sharp-darwin-x64": "0.34.5",
"@img/sharp-libvips-darwin-arm64": "1.2.4",
"@img/sharp-libvips-darwin-x64": "1.2.4",
"@img/sharp-libvips-linux-arm": "1.2.4",
"@img/sharp-libvips-linux-arm64": "1.2.4",
"@img/sharp-libvips-linux-ppc64": "1.2.4",
"@img/sharp-libvips-linux-riscv64": "1.2.4",
"@img/sharp-libvips-linux-s390x": "1.2.4",
"@img/sharp-libvips-linux-x64": "1.2.4",
"@img/sharp-libvips-linuxmusl-arm64": "1.2.4",
"@img/sharp-libvips-linuxmusl-x64": "1.2.4",
"@img/sharp-linux-arm": "0.34.5",
"@img/sharp-linux-arm64": "0.34.5",
"@img/sharp-linux-ppc64": "0.34.5",
"@img/sharp-linux-riscv64": "0.34.5",
"@img/sharp-linux-s390x": "0.34.5",
"@img/sharp-linux-x64": "0.34.5",
"@img/sharp-linuxmusl-arm64": "0.34.5",
"@img/sharp-linuxmusl-x64": "0.34.5",
"@img/sharp-wasm32": "0.34.5",
"@img/sharp-win32-arm64": "0.34.5",
"@img/sharp-win32-ia32": "0.34.5",
"@img/sharp-win32-x64": "0.34.5"
}
},
"node_modules/side-channel": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
@@ -1982,6 +2675,15 @@
"node": ">= 10"
}
},
"node_modules/split-on-first": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/split-on-first/-/split-on-first-1.1.0.tgz",
"integrity": "sha512-43ZssAJaMusuKWL8sKUBQXHWOpq8d6CfN/u1p4gUzfJkM05C8rxTmYrkIPTXapZpORA6LkkzcUulJ8FqA7Uudw==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/sqlite3": {
"version": "5.1.7",
"resolved": "https://registry.npmjs.org/sqlite3/-/sqlite3-5.1.7.tgz",
@@ -2028,6 +2730,30 @@
"node": ">= 0.8"
}
},
"node_modules/stream-chain": {
"version": "2.2.5",
"resolved": "https://registry.npmjs.org/stream-chain/-/stream-chain-2.2.5.tgz",
"integrity": "sha512-1TJmBx6aSWqZ4tx7aTpBDXK0/e2hhcNSTV8+CbFJtDjbb+I1mZ8lHit0Grw9GRT+6JbIrrDd8esncgBi8aBXGA==",
"license": "BSD-3-Clause"
},
"node_modules/stream-json": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/stream-json/-/stream-json-1.9.1.tgz",
"integrity": "sha512-uWkjJ+2Nt/LO9Z/JyKZbMusL8Dkh97uUBTv3AJQ74y07lVahLY4eEFsPsE97pxYBwr8nnjMAIch5eqI0gPShyw==",
"license": "BSD-3-Clause",
"dependencies": {
"stream-chain": "^2.2.5"
}
},
"node_modules/strict-uri-encode": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/strict-uri-encode/-/strict-uri-encode-2.0.0.tgz",
"integrity": "sha512-QwiXZgpRcKkhTj2Scnn++4PKtWsH0kpzZ62L2R6c/LUVYv7hVnZqcg2+sMuT6R7Jusu1vviK/MFsu6kNJfWlEQ==",
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/string_decoder": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
@@ -2091,6 +2817,18 @@
}
}
},
"node_modules/strnum": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.0.tgz",
"integrity": "sha512-Y7Bj8XyJxnPAORMZj/xltsfo55uOiyHcU2tnAVzHUnSJR/KsEX+9RoDeXEnsXtl/CX4fAcrt64gZ13aGaWPeBg==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT"
},
"node_modules/tar": {
"version": "6.2.1",
"resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz",
@@ -2152,6 +2890,15 @@
"node": ">=8"
}
},
"node_modules/through2": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/through2/-/through2-4.0.2.tgz",
"integrity": "sha512-iOqSav00cVxEEICeD7TjLB1sueEL+81Wpzp2bY17uZjZN0pWZPuo4suZ/61VujxmqSGFfgOcNuTZ85QJwNZQpw==",
"license": "MIT",
"dependencies": {
"readable-stream": "3"
}
},
"node_modules/toidentifier": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
@@ -2161,6 +2908,13 @@
"node": ">=0.6"
}
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD",
"optional": true
},
"node_modules/tunnel-agent": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
@@ -2263,6 +3017,28 @@
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC"
},
"node_modules/xml2js": {
"version": "0.6.2",
"resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.6.2.tgz",
"integrity": "sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==",
"license": "MIT",
"dependencies": {
"sax": ">=0.6.0",
"xmlbuilder": "~11.0.0"
},
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/xmlbuilder": {
"version": "11.0.1",
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz",
"integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==",
"license": "MIT",
"engines": {
"node": ">=4.0"
}
},
"node_modules/yallist": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",

View File

@@ -7,6 +7,7 @@
"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": [],
@@ -16,6 +17,8 @@
"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

@@ -0,0 +1 @@

Binary file not shown.

After

Width:  |  Height:  |  Size: 465 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 210 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 163 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 163 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 195 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 230 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 431 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 162 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 360 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 159 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 151 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 165 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 182 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 310 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 155 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 174 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 461 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 315 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 193 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 314 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 232 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 395 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 208 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 405 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 344 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 158 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 367 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 363 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 248 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 248 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 345 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 382 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 178 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 189 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 369 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 301 KiB

Some files were not shown because too many files have changed in this diff Show More