Initial commit for Greenlens
4
server/.dockerignore
Normal file
@@ -0,0 +1,4 @@
|
||||
node_modules
|
||||
.env
|
||||
.env.*
|
||||
npm-debug.log
|
||||
12
server/Dockerfile
Normal 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"]
|
||||
195
server/index.js
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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
@@ -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,
|
||||
};
|
||||
402
server/lib/searchIntentConfig.js
Normal 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
@@ -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
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
1
server/public/plants/.gitkeep
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
BIN
server/public/plants/acer-palmatum--faecherahorn--388b6858.webp
Normal file
|
After Width: | Height: | Size: 465 KiB |
BIN
server/public/plants/acer-platanoides--spitzahorn--dc2fbf6a.webp
Normal file
|
After Width: | Height: | Size: 210 KiB |
|
After Width: | Height: | Size: 80 KiB |
BIN
server/public/plants/adenium-obesum--wuestenrose--18b1b800.webp
Normal file
|
After Width: | Height: | Size: 81 KiB |
|
After Width: | Height: | Size: 163 KiB |
|
After Width: | Height: | Size: 125 KiB |
|
After Width: | Height: | Size: 154 KiB |
BIN
server/public/plants/aechmea-fasciata--silbervase--04efba01.webp
Normal file
|
After Width: | Height: | Size: 102 KiB |
|
After Width: | Height: | Size: 163 KiB |
|
After Width: | Height: | Size: 113 KiB |
BIN
server/public/plants/agave-americana--agave--3b197a68.webp
Normal file
|
After Width: | Height: | Size: 195 KiB |
|
After Width: | Height: | Size: 230 KiB |
BIN
server/public/plants/alcea-rosea--stockrose--4b6ec327.webp
Normal file
|
After Width: | Height: | Size: 431 KiB |
|
After Width: | Height: | Size: 170 KiB |
|
After Width: | Height: | Size: 132 KiB |
|
After Width: | Height: | Size: 118 KiB |
BIN
server/public/plants/alocasia-zebrina--525caa90.webp
Normal file
|
After Width: | Height: | Size: 90 KiB |
BIN
server/public/plants/aloe-arborescens--kap-aloe--2c4f60ef.webp
Normal file
|
After Width: | Height: | Size: 162 KiB |
BIN
server/public/plants/aloe-ferox--kap-aloe-ferox--e78c3f65.webp
Normal file
|
After Width: | Height: | Size: 80 KiB |
BIN
server/public/plants/aloe-vera--206a00f6.webp
Normal file
|
After Width: | Height: | Size: 8.3 KiB |
|
After Width: | Height: | Size: 148 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 120 KiB |
BIN
server/public/plants/anemone-coronaria--anemone--8a7fda1c.webp
Normal file
|
After Width: | Height: | Size: 139 KiB |
BIN
server/public/plants/anethum-graveolens--dill--b93ab119.webp
Normal file
|
After Width: | Height: | Size: 360 KiB |
|
After Width: | Height: | Size: 159 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 151 KiB |
|
After Width: | Height: | Size: 104 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 165 KiB |
BIN
server/public/plants/arnica-montana--arnika--285e6ea8.webp
Normal file
|
After Width: | Height: | Size: 182 KiB |
BIN
server/public/plants/artemisia-absinthium--wermut--26709c63.webp
Normal file
|
After Width: | Height: | Size: 310 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 76 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 155 KiB |
|
After Width: | Height: | Size: 174 KiB |
|
After Width: | Height: | Size: 71 KiB |
BIN
server/public/plants/bambusa-vulgaris--bambusrohr--620e47a2.webp
Normal file
|
After Width: | Height: | Size: 461 KiB |
|
After Width: | Height: | Size: 315 KiB |
|
After Width: | Height: | Size: 46 KiB |
BIN
server/public/plants/begonia-rex--koenigsbegonie--b6083f44.webp
Normal file
|
After Width: | Height: | Size: 193 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 154 KiB |
|
After Width: | Height: | Size: 109 KiB |
BIN
server/public/plants/beta-vulgaris--mangold--7fb88006.webp
Normal file
|
After Width: | Height: | Size: 34 KiB |
BIN
server/public/plants/betula-pendula--hange-birke--aff0bb68.webp
Normal file
|
After Width: | Height: | Size: 114 KiB |
|
After Width: | Height: | Size: 314 KiB |
|
After Width: | Height: | Size: 73 KiB |
|
After Width: | Height: | Size: 232 KiB |
|
After Width: | Height: | Size: 119 KiB |
|
After Width: | Height: | Size: 94 KiB |
BIN
server/public/plants/caladium-bicolor--buntblatt--d052df1e.webp
Normal file
|
After Width: | Height: | Size: 395 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 86 KiB |
|
After Width: | Height: | Size: 208 KiB |
BIN
server/public/plants/callisia-repens--callisia--65401a5e.webp
Normal file
|
After Width: | Height: | Size: 81 KiB |
BIN
server/public/plants/camellia-japonica--kamelie--6a23eb3f.webp
Normal file
|
After Width: | Height: | Size: 405 KiB |
|
After Width: | Height: | Size: 93 KiB |
|
After Width: | Height: | Size: 97 KiB |
|
After Width: | Height: | Size: 113 KiB |
BIN
server/public/plants/capsicum-annuum--chili--dabf0f0e.webp
Normal file
|
After Width: | Height: | Size: 140 KiB |
BIN
server/public/plants/carica-papaya--papaya--240dc331.webp
Normal file
|
After Width: | Height: | Size: 67 KiB |
|
After Width: | Height: | Size: 344 KiB |
|
After Width: | Height: | Size: 56 KiB |
BIN
server/public/plants/cedrus-libani--zeder--6af4a3dd.webp
Normal file
|
After Width: | Height: | Size: 158 KiB |
BIN
server/public/plants/centaurea-cyanus--kornblume--ff8efded.webp
Normal file
|
After Width: | Height: | Size: 106 KiB |
|
After Width: | Height: | Size: 21 KiB |
BIN
server/public/plants/ceropegia-woodii--herzkette--b51fb231.webp
Normal file
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 367 KiB |
|
After Width: | Height: | Size: 363 KiB |
BIN
server/public/plants/chamomilla-recutita--kamille--0bffdb72.webp
Normal file
|
After Width: | Height: | Size: 105 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 248 KiB |
BIN
server/public/plants/citrus-limon--zitronenbaum--100d9901.webp
Normal file
|
After Width: | Height: | Size: 78 KiB |
BIN
server/public/plants/citrus-sinensis--orangenbaum--294e1722.webp
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
server/public/plants/clematis-viticella--clematis--7c06c3b1.webp
Normal file
|
After Width: | Height: | Size: 248 KiB |
BIN
server/public/plants/clivia-miniata--riemenblatt--29bc76f5.webp
Normal file
|
After Width: | Height: | Size: 54 KiB |
BIN
server/public/plants/codiaeum-variegatum--kroton--fceb7491.webp
Normal file
|
After Width: | Height: | Size: 345 KiB |
|
After Width: | Height: | Size: 131 KiB |
|
After Width: | Height: | Size: 382 KiB |
BIN
server/public/plants/colocasia-esculenta--taro--d23c3d35.webp
Normal file
|
After Width: | Height: | Size: 178 KiB |
BIN
server/public/plants/columnea-gloriosa--columnea--3fd247d6.webp
Normal file
|
After Width: | Height: | Size: 189 KiB |
|
After Width: | Height: | Size: 93 KiB |
|
After Width: | Height: | Size: 369 KiB |
|
After Width: | Height: | Size: 301 KiB |