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

View File

@@ -1,42 +1,42 @@
const DEFAULT_API_BASE_URL = 'http://localhost:3000/api';
const normalizeHttpUrl = (value?: string | null): string | null => {
const trimmed = String(value || '').trim();
if (!trimmed) return null;
try {
const parsed = new URL(trimmed);
return `${parsed.origin}${parsed.pathname}`.replace(/\/+$/, '');
} catch {
return null;
}
};
export const getConfiguredApiBaseUrl = (): string => {
const explicitApiUrl = normalizeHttpUrl(process.env.EXPO_PUBLIC_API_URL);
if (explicitApiUrl) return explicitApiUrl;
const backendBaseUrl = normalizeHttpUrl(
process.env.EXPO_PUBLIC_BACKEND_URL || process.env.EXPO_PUBLIC_PAYMENT_SERVER_URL,
);
if (backendBaseUrl) {
return backendBaseUrl.endsWith('/api') ? backendBaseUrl : `${backendBaseUrl}/api`;
}
return DEFAULT_API_BASE_URL;
};
export const getConfiguredAssetBaseUrl = (): string => {
const apiBaseUrl = getConfiguredApiBaseUrl();
try {
const parsed = new URL(apiBaseUrl);
const pathname = parsed.pathname.replace(/\/+$/, '');
const assetPath = pathname.endsWith('/api')
? pathname.slice(0, -4)
: pathname;
return `${parsed.origin}${assetPath}`.replace(/\/+$/, '');
} catch {
return 'http://localhost:3000';
}
};
const DEFAULT_API_BASE_URL = 'http://localhost:3000/api';
const normalizeHttpUrl = (value?: string | null): string | null => {
const trimmed = String(value || '').trim();
if (!trimmed) return null;
try {
const parsed = new URL(trimmed);
return `${parsed.origin}${parsed.pathname}`.replace(/\/+$/, '');
} catch {
return null;
}
};
export const getConfiguredApiBaseUrl = (): string => {
const explicitApiUrl = normalizeHttpUrl(process.env.EXPO_PUBLIC_API_URL);
if (explicitApiUrl) return explicitApiUrl;
const backendBaseUrl = normalizeHttpUrl(
process.env.EXPO_PUBLIC_BACKEND_URL || process.env.EXPO_PUBLIC_PAYMENT_SERVER_URL,
);
if (backendBaseUrl) {
return backendBaseUrl.endsWith('/api') ? backendBaseUrl : `${backendBaseUrl}/api`;
}
return DEFAULT_API_BASE_URL;
};
export const getConfiguredAssetBaseUrl = (): string => {
const apiBaseUrl = getConfiguredApiBaseUrl();
try {
const parsed = new URL(apiBaseUrl);
const pathname = parsed.pathname.replace(/\/+$/, '');
const assetPath = pathname.endsWith('/api')
? pathname.slice(0, -4)
: pathname;
return `${parsed.origin}${assetPath}`.replace(/\/+$/, '');
} catch {
return 'http://localhost:3000';
}
};

View File

@@ -1,213 +1,213 @@
import { CareInfo } from '../types';
const { SEARCH_INTENT_CONFIG } = require('../constants/searchIntentConfig');
type SearchIntentConfig = {
aliases?: string[];
entryHints?: string[];
lightHints?: string[];
};
export interface HybridSearchEntryLike {
name: string;
botanicalName?: string;
description?: string;
categories?: string[];
careInfo?: Partial<CareInfo> | null;
}
interface RankedEntry<T> {
entry: T;
score: number;
}
const normalizeArray = (values: string[]): string[] => {
return [...new Set(values.map((value) => normalizeSearchText(value)).filter(Boolean))];
};
export const normalizeSearchText = (value: string): string => {
return value
.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.trim()
.replace(/[^a-z0-9\s_-]+/g, ' ')
.replace(/[_-]+/g, ' ')
.replace(/\s+/g, ' ');
};
const tokenize = (normalizedValue: string): string[] => {
return normalizedValue.split(' ').filter(Boolean);
};
const tokenSetFromQuery = (normalizedQuery: string): Set<string> => {
const rawTokens = tokenize(normalizedQuery);
const noise = new Set<string>(SEARCH_INTENT_CONFIG.noiseTokens.map((token: string) => normalizeSearchText(token)));
return new Set(rawTokens.filter((token) => !noise.has(token)));
};
const includesPhrase = (normalizedQuery: string, normalizedAlias: string, queryTokens: Set<string>): boolean => {
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: string): string[] => {
const queryTokens = tokenSetFromQuery(normalizedQuery);
const intents = (Object.entries(SEARCH_INTENT_CONFIG.intents) as Array<[string, SearchIntentConfig]>)
.filter(([, value]) =>
(value.aliases || []).some((alias) => includesPhrase(normalizedQuery, normalizeSearchText(alias), queryTokens)))
.map(([intentId]) => intentId);
return intents;
};
const getLevenshteinDistance = (left: string, right: string): number => {
const rows = left.length + 1;
const cols = right.length + 1;
const matrix: number[][] = 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: string, candidates: string[]): number => {
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: string, normalizedTarget: string, exact: number, prefix: number, contains: number): number => {
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: HybridSearchEntryLike): string[] => {
const normalizedDescription = normalizeSearchText(entry.description || '');
const normalizedLight = normalizeSearchText(entry.careInfo?.light || '');
const derivedSignals = new Set<string>();
const normalizedCategories = (entry.categories || []).map((category) => normalizeSearchText(category));
normalizedCategories.forEach((category) => derivedSignals.add(category));
(Object.entries(SEARCH_INTENT_CONFIG.intents) as Array<[string, SearchIntentConfig]>).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];
};
export const scoreHybridEntry = (entry: HybridSearchEntryLike, query: string): number => {
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 && queryTokens.length > 0) {
score += 16;
}
}
return score;
};
export const rankHybridEntries = <T extends HybridSearchEntryLike>(
entries: T[],
query: string,
limit = 30,
): RankedEntry<T>[] => {
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);
};
import { CareInfo } from '../types';
const { SEARCH_INTENT_CONFIG } = require('../constants/searchIntentConfig');
type SearchIntentConfig = {
aliases?: string[];
entryHints?: string[];
lightHints?: string[];
};
export interface HybridSearchEntryLike {
name: string;
botanicalName?: string;
description?: string;
categories?: string[];
careInfo?: Partial<CareInfo> | null;
}
interface RankedEntry<T> {
entry: T;
score: number;
}
const normalizeArray = (values: string[]): string[] => {
return [...new Set(values.map((value) => normalizeSearchText(value)).filter(Boolean))];
};
export const normalizeSearchText = (value: string): string => {
return value
.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.trim()
.replace(/[^a-z0-9\s_-]+/g, ' ')
.replace(/[_-]+/g, ' ')
.replace(/\s+/g, ' ');
};
const tokenize = (normalizedValue: string): string[] => {
return normalizedValue.split(' ').filter(Boolean);
};
const tokenSetFromQuery = (normalizedQuery: string): Set<string> => {
const rawTokens = tokenize(normalizedQuery);
const noise = new Set<string>(SEARCH_INTENT_CONFIG.noiseTokens.map((token: string) => normalizeSearchText(token)));
return new Set(rawTokens.filter((token) => !noise.has(token)));
};
const includesPhrase = (normalizedQuery: string, normalizedAlias: string, queryTokens: Set<string>): boolean => {
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: string): string[] => {
const queryTokens = tokenSetFromQuery(normalizedQuery);
const intents = (Object.entries(SEARCH_INTENT_CONFIG.intents) as Array<[string, SearchIntentConfig]>)
.filter(([, value]) =>
(value.aliases || []).some((alias) => includesPhrase(normalizedQuery, normalizeSearchText(alias), queryTokens)))
.map(([intentId]) => intentId);
return intents;
};
const getLevenshteinDistance = (left: string, right: string): number => {
const rows = left.length + 1;
const cols = right.length + 1;
const matrix: number[][] = 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: string, candidates: string[]): number => {
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: string, normalizedTarget: string, exact: number, prefix: number, contains: number): number => {
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: HybridSearchEntryLike): string[] => {
const normalizedDescription = normalizeSearchText(entry.description || '');
const normalizedLight = normalizeSearchText(entry.careInfo?.light || '');
const derivedSignals = new Set<string>();
const normalizedCategories = (entry.categories || []).map((category) => normalizeSearchText(category));
normalizedCategories.forEach((category) => derivedSignals.add(category));
(Object.entries(SEARCH_INTENT_CONFIG.intents) as Array<[string, SearchIntentConfig]>).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];
};
export const scoreHybridEntry = (entry: HybridSearchEntryLike, query: string): number => {
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 && queryTokens.length > 0) {
score += 16;
}
}
return score;
};
export const rankHybridEntries = <T extends HybridSearchEntryLike>(
entries: T[],
query: string,
limit = 30,
): RankedEntry<T>[] => {
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);
};

View File

@@ -1,17 +1,17 @@
const sanitizeSeed = (value: string): string => {
return value
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 36);
};
export const createIdempotencyKey = (scope: string, seed?: string): string => {
const base = sanitizeSeed(scope || 'action') || 'action';
const seedPart = seed ? sanitizeSeed(seed) : '';
const timestamp = Date.now().toString(36);
const random = Math.random().toString(36).slice(2, 10);
return seedPart
? `${base}-${seedPart}-${timestamp}-${random}`
: `${base}-${timestamp}-${random}`;
};
const sanitizeSeed = (value: string): string => {
return value
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 36);
};
export const createIdempotencyKey = (scope: string, seed?: string): string => {
const base = sanitizeSeed(scope || 'action') || 'action';
const seedPart = seed ? sanitizeSeed(seed) : '';
const timestamp = Date.now().toString(36);
const random = Math.random().toString(36).slice(2, 10);
return seedPart
? `${base}-${seedPart}-${timestamp}-${random}`
: `${base}-${timestamp}-${random}`;
};

View File

@@ -1,217 +1,217 @@
import { getConfiguredAssetBaseUrl } from './backendUrl';
const WIKIMEDIA_FILEPATH_SEGMENT = 'Special:FilePath/';
const WIKIMEDIA_REDIRECT_BASE = 'https://commons.wikimedia.org/wiki/Special:FilePath/';
export const DEFAULT_PLANT_IMAGE_URI =
'https://upload.wikimedia.org/wikipedia/commons/thumb/2/2e/Monstera_deliciosa2.jpg/330px-Monstera_deliciosa2.jpg';
// Verified working fallback images per category (from main database URLs)
const CATEGORY_FALLBACK_IMAGES: Record<string, string> = {
succulent: 'https://upload.wikimedia.org/wikipedia/commons/thumb/0/05/Aloe_Vera_houseplant.jpg/330px-Aloe_Vera_houseplant.jpg',
flowering: 'https://upload.wikimedia.org/wikipedia/commons/thumb/8/8b/AnthuriumAndraenum.jpg/330px-AnthuriumAndraenum.jpg',
medicinal: 'https://upload.wikimedia.org/wikipedia/commons/thumb/4/40/Lavandula_angustifolia_-_K%C3%B6hler%E2%80%93s_Medizinal-Pflanzen-087.jpg/330px-Lavandula_angustifolia_-_K%C3%B6hler%E2%80%93s_Medizinal-Pflanzen-087.jpg',
tree: 'https://upload.wikimedia.org/wikipedia/commons/thumb/2/25/Ficus_benjamina.jpg/330px-Ficus_benjamina.jpg',
hanging: 'https://upload.wikimedia.org/wikipedia/commons/thumb/6/62/Money_Plant_%28Epipremnum_aureum%29_4.jpg/330px-Money_Plant_%28Epipremnum_aureum%29_4.jpg',
patterned: 'https://upload.wikimedia.org/wikipedia/commons/thumb/e/e7/Starr_080716-9470_Calathea_crotalifera.jpg/330px-Starr_080716-9470_Calathea_crotalifera.jpg',
pet_friendly: 'https://upload.wikimedia.org/wikipedia/commons/thumb/6/6c/Chlorophytum_comosum_01.jpg/330px-Chlorophytum_comosum_01.jpg',
high_humidity: 'https://upload.wikimedia.org/wikipedia/commons/thumb/b/bd/Spathiphyllum_cochlearispathum_RTBG.jpg/330px-Spathiphyllum_cochlearispathum_RTBG.jpg',
air_purifier: 'https://upload.wikimedia.org/wikipedia/commons/thumb/f/fb/Snake_Plant_%28Sansevieria_trifasciata_%27Laurentii%27%29.jpg/330px-Snake_Plant_%28Sansevieria_trifasciata_%27Laurentii%27%29.jpg',
sun: 'https://upload.wikimedia.org/wikipedia/commons/thumb/8/85/Echeveria_elegans_-_1.jpg/330px-Echeveria_elegans_-_1.jpg',
low_light: 'https://upload.wikimedia.org/wikipedia/commons/thumb/c/cf/Zamioculcas_zamiifolia_1.jpg/330px-Zamioculcas_zamiifolia_1.jpg',
easy: 'https://upload.wikimedia.org/wikipedia/commons/thumb/6/6d/Pilea_peperomioides_Chinese_money_plant.jpg/330px-Pilea_peperomioides_Chinese_money_plant.jpg',
large: 'https://upload.wikimedia.org/wikipedia/commons/thumb/b/ba/Strelitzia_nicolai_3.jpg/330px-Strelitzia_nicolai_3.jpg',
};
const CATEGORY_PRIORITY = [
'succulent', 'flowering', 'medicinal', 'tree', 'hanging',
'patterned', 'pet_friendly', 'high_humidity', 'air_purifier',
'sun', 'low_light', 'easy', 'large',
];
export const getCategoryFallbackImage = (categories: string[]): string => {
for (const cat of CATEGORY_PRIORITY) {
if (categories.includes(cat) && CATEGORY_FALLBACK_IMAGES[cat]) {
return CATEGORY_FALLBACK_IMAGES[cat];
}
}
return DEFAULT_PLANT_IMAGE_URI;
};
const tryDecode = (value: string): string => {
try {
return decodeURIComponent(value);
} catch {
return value;
}
};
const decodeRepeatedly = (value: string, rounds = 3): string => {
let current = value;
for (let index = 0; index < rounds; index += 1) {
const decoded = tryDecode(current);
if (decoded === current) break;
current = decoded;
}
return current;
};
type PlantManifestItem = {
localImageUri?: string;
sourceUri?: string;
};
let manifestSourceByLocalUriCache: Map<string, string> | null = null;
let wikimediaSearchCache: Record<string, string | null> | null = null;
const normalizePlantAssetPath = (value?: string | null): string | null => {
const trimmed = String(value || '').trim();
if (!trimmed) return null;
const withoutQuery = trimmed.split(/[?#]/)[0].replace(/\\/g, '/');
const normalizedPath = withoutQuery.startsWith('/') ? withoutQuery : `/${withoutQuery}`;
if (!normalizedPath.startsWith('/plants/')) return null;
if (normalizedPath.includes('..')) return null;
return normalizedPath;
};
const loadManifestSourceByLocalUri = (): Map<string, string> => {
if (manifestSourceByLocalUriCache) return manifestSourceByLocalUriCache;
const nextCache = new Map<string, string>();
try {
const manifest = require('../server/public/plants/manifest.json') as { items?: PlantManifestItem[] };
const items = Array.isArray(manifest?.items) ? manifest.items : [];
for (const item of items) {
const localImageUri = normalizePlantAssetPath(item?.localImageUri);
const sourceUri = String(item?.sourceUri || '').trim();
if (!localImageUri || !sourceUri) continue;
nextCache.set(localImageUri, sourceUri);
}
} catch {
// Keep empty cache when manifest is unavailable.
}
manifestSourceByLocalUriCache = nextCache;
return nextCache;
};
const loadWikimediaSearchCache = (): Record<string, string | null> => {
if (wikimediaSearchCache) return wikimediaSearchCache;
try {
wikimediaSearchCache = require('../server/public/plants/wikimedia-search-cache.json') as Record<string, string | null>;
} catch {
wikimediaSearchCache = {};
}
return wikimediaSearchCache;
};
const resolveServerAssetPath = (rawPath: string): string | null => {
const normalizedPath = normalizePlantAssetPath(rawPath);
if (!normalizedPath) return null;
return `${getConfiguredAssetBaseUrl()}${normalizedPath}`;
};
const unwrapMarkdownLink = (value: string): string => {
const markdownLink = value.match(/^\[[^\]]+]\((https?:\/\/[^)]+)\)(.*)$/i);
if (!markdownLink) return value;
const [, url, suffix] = markdownLink;
return `${url}${suffix || ''}`;
};
const convertWikimediaFilePathUrl = (value: string): string | null => {
const segmentIndex = value.indexOf(WIKIMEDIA_FILEPATH_SEGMENT);
if (segmentIndex < 0) return null;
const fileNameStart = segmentIndex + WIKIMEDIA_FILEPATH_SEGMENT.length;
const rawFileName = value.slice(fileNameStart).split(/[?#]/)[0].trim();
if (!rawFileName) return null;
const decodedFileName = tryDecode(rawFileName).replace(/\s+/g, ' ').trim();
const encodedFileName = encodeURIComponent(decodedFileName).replace(/%2F/g, '/');
return `${WIKIMEDIA_REDIRECT_BASE}${encodedFileName}`;
};
export const getWikimediaFilePathFromThumbnailUrl = (url: string): string | null => {
if (!url.includes('upload.wikimedia.org/wikipedia/commons/thumb/')) return null;
// Example: https://upload.wikimedia.org/wikipedia/commons/thumb/2/2e/Monstera_deliciosa2.jpg/330px-Monstera_deliciosa2.jpg
// The filename is the segment between the second-to-last and last slash (in many cases)
// or use a more robust regex
const parts = url.split('/');
// Filter out empty parts from trailing slashes
const filtered = parts.filter(p => !!p);
if (filtered.length < 5) return null;
// For thumb URLs, the filename is typically the part before the last part (which is the px-filename)
// but let's be careful.
const lastName = filtered[filtered.length - 1];
const secondLastName = filtered[filtered.length - 2];
// In thumb URLs, the last part is usually "330px-Filename.jpg"
// The second to last is "Filename.jpg"
if (lastName.includes('px-') && lastName.endsWith(secondLastName)) {
try {
return decodeURIComponent(secondLastName);
} catch {
return secondLastName;
}
}
return null;
};
export const getPlantImageSourceFallbackUri = (rawUri?: string | null): string | null => {
const localImageUri = normalizePlantAssetPath(rawUri);
if (!localImageUri) return null;
const sourceUri = loadManifestSourceByLocalUri().get(localImageUri);
if (!sourceUri) return null;
if (/^https?:\/\//i.test(sourceUri)) {
return tryResolveImageUri(sourceUri);
}
if (!sourceUri.startsWith('wikimedia-search:')) {
return null;
}
const rawQuery = sourceUri.slice('wikimedia-search:'.length).trim();
const decodedQuery = decodeRepeatedly(rawQuery);
if (!decodedQuery) return null;
const searchCache = loadWikimediaSearchCache();
const cachedUrl = searchCache[decodedQuery]
|| searchCache[rawQuery]
|| searchCache[encodeURIComponent(decodedQuery)]
|| null;
return cachedUrl ? tryResolveImageUri(cachedUrl) : null;
};
export const tryResolveImageUri = (rawUri?: string | null): string | null => {
if (!rawUri) return null;
const trimmed = rawUri.trim();
if (!trimmed) return null;
const localAssetUri = resolveServerAssetPath(trimmed);
if (localAssetUri) return localAssetUri;
const normalized = unwrapMarkdownLink(trimmed);
const converted = convertWikimediaFilePathUrl(normalized);
const candidate = (converted || normalized).replace(/^http:\/\//i, 'https://');
if (!/^(https?:\/\/|file:\/\/|content:\/\/|data:image\/|blob:)/i.test(candidate)) {
return null;
}
return candidate;
};
export const resolveImageUri = (rawUri?: string | null): string => {
return tryResolveImageUri(rawUri) || DEFAULT_PLANT_IMAGE_URI;
};
import { getConfiguredAssetBaseUrl } from './backendUrl';
const WIKIMEDIA_FILEPATH_SEGMENT = 'Special:FilePath/';
const WIKIMEDIA_REDIRECT_BASE = 'https://commons.wikimedia.org/wiki/Special:FilePath/';
export const DEFAULT_PLANT_IMAGE_URI =
'https://upload.wikimedia.org/wikipedia/commons/thumb/2/2e/Monstera_deliciosa2.jpg/330px-Monstera_deliciosa2.jpg';
// Verified working fallback images per category (from main database URLs)
const CATEGORY_FALLBACK_IMAGES: Record<string, string> = {
succulent: 'https://upload.wikimedia.org/wikipedia/commons/thumb/0/05/Aloe_Vera_houseplant.jpg/330px-Aloe_Vera_houseplant.jpg',
flowering: 'https://upload.wikimedia.org/wikipedia/commons/thumb/8/8b/AnthuriumAndraenum.jpg/330px-AnthuriumAndraenum.jpg',
medicinal: 'https://upload.wikimedia.org/wikipedia/commons/thumb/4/40/Lavandula_angustifolia_-_K%C3%B6hler%E2%80%93s_Medizinal-Pflanzen-087.jpg/330px-Lavandula_angustifolia_-_K%C3%B6hler%E2%80%93s_Medizinal-Pflanzen-087.jpg',
tree: 'https://upload.wikimedia.org/wikipedia/commons/thumb/2/25/Ficus_benjamina.jpg/330px-Ficus_benjamina.jpg',
hanging: 'https://upload.wikimedia.org/wikipedia/commons/thumb/6/62/Money_Plant_%28Epipremnum_aureum%29_4.jpg/330px-Money_Plant_%28Epipremnum_aureum%29_4.jpg',
patterned: 'https://upload.wikimedia.org/wikipedia/commons/thumb/e/e7/Starr_080716-9470_Calathea_crotalifera.jpg/330px-Starr_080716-9470_Calathea_crotalifera.jpg',
pet_friendly: 'https://upload.wikimedia.org/wikipedia/commons/thumb/6/6c/Chlorophytum_comosum_01.jpg/330px-Chlorophytum_comosum_01.jpg',
high_humidity: 'https://upload.wikimedia.org/wikipedia/commons/thumb/b/bd/Spathiphyllum_cochlearispathum_RTBG.jpg/330px-Spathiphyllum_cochlearispathum_RTBG.jpg',
air_purifier: 'https://upload.wikimedia.org/wikipedia/commons/thumb/f/fb/Snake_Plant_%28Sansevieria_trifasciata_%27Laurentii%27%29.jpg/330px-Snake_Plant_%28Sansevieria_trifasciata_%27Laurentii%27%29.jpg',
sun: 'https://upload.wikimedia.org/wikipedia/commons/thumb/8/85/Echeveria_elegans_-_1.jpg/330px-Echeveria_elegans_-_1.jpg',
low_light: 'https://upload.wikimedia.org/wikipedia/commons/thumb/c/cf/Zamioculcas_zamiifolia_1.jpg/330px-Zamioculcas_zamiifolia_1.jpg',
easy: 'https://upload.wikimedia.org/wikipedia/commons/thumb/6/6d/Pilea_peperomioides_Chinese_money_plant.jpg/330px-Pilea_peperomioides_Chinese_money_plant.jpg',
large: 'https://upload.wikimedia.org/wikipedia/commons/thumb/b/ba/Strelitzia_nicolai_3.jpg/330px-Strelitzia_nicolai_3.jpg',
};
const CATEGORY_PRIORITY = [
'succulent', 'flowering', 'medicinal', 'tree', 'hanging',
'patterned', 'pet_friendly', 'high_humidity', 'air_purifier',
'sun', 'low_light', 'easy', 'large',
];
export const getCategoryFallbackImage = (categories: string[]): string => {
for (const cat of CATEGORY_PRIORITY) {
if (categories.includes(cat) && CATEGORY_FALLBACK_IMAGES[cat]) {
return CATEGORY_FALLBACK_IMAGES[cat];
}
}
return DEFAULT_PLANT_IMAGE_URI;
};
const tryDecode = (value: string): string => {
try {
return decodeURIComponent(value);
} catch {
return value;
}
};
const decodeRepeatedly = (value: string, rounds = 3): string => {
let current = value;
for (let index = 0; index < rounds; index += 1) {
const decoded = tryDecode(current);
if (decoded === current) break;
current = decoded;
}
return current;
};
type PlantManifestItem = {
localImageUri?: string;
sourceUri?: string;
};
let manifestSourceByLocalUriCache: Map<string, string> | null = null;
let wikimediaSearchCache: Record<string, string | null> | null = null;
const normalizePlantAssetPath = (value?: string | null): string | null => {
const trimmed = String(value || '').trim();
if (!trimmed) return null;
const withoutQuery = trimmed.split(/[?#]/)[0].replace(/\\/g, '/');
const normalizedPath = withoutQuery.startsWith('/') ? withoutQuery : `/${withoutQuery}`;
if (!normalizedPath.startsWith('/plants/')) return null;
if (normalizedPath.includes('..')) return null;
return normalizedPath;
};
const loadManifestSourceByLocalUri = (): Map<string, string> => {
if (manifestSourceByLocalUriCache) return manifestSourceByLocalUriCache;
const nextCache = new Map<string, string>();
try {
const manifest = require('../server/public/plants/manifest.json') as { items?: PlantManifestItem[] };
const items = Array.isArray(manifest?.items) ? manifest.items : [];
for (const item of items) {
const localImageUri = normalizePlantAssetPath(item?.localImageUri);
const sourceUri = String(item?.sourceUri || '').trim();
if (!localImageUri || !sourceUri) continue;
nextCache.set(localImageUri, sourceUri);
}
} catch {
// Keep empty cache when manifest is unavailable.
}
manifestSourceByLocalUriCache = nextCache;
return nextCache;
};
const loadWikimediaSearchCache = (): Record<string, string | null> => {
if (wikimediaSearchCache) return wikimediaSearchCache;
try {
wikimediaSearchCache = require('../server/public/plants/wikimedia-search-cache.json') as Record<string, string | null>;
} catch {
wikimediaSearchCache = {};
}
return wikimediaSearchCache;
};
const resolveServerAssetPath = (rawPath: string): string | null => {
const normalizedPath = normalizePlantAssetPath(rawPath);
if (!normalizedPath) return null;
return `${getConfiguredAssetBaseUrl()}${normalizedPath}`;
};
const unwrapMarkdownLink = (value: string): string => {
const markdownLink = value.match(/^\[[^\]]+]\((https?:\/\/[^)]+)\)(.*)$/i);
if (!markdownLink) return value;
const [, url, suffix] = markdownLink;
return `${url}${suffix || ''}`;
};
const convertWikimediaFilePathUrl = (value: string): string | null => {
const segmentIndex = value.indexOf(WIKIMEDIA_FILEPATH_SEGMENT);
if (segmentIndex < 0) return null;
const fileNameStart = segmentIndex + WIKIMEDIA_FILEPATH_SEGMENT.length;
const rawFileName = value.slice(fileNameStart).split(/[?#]/)[0].trim();
if (!rawFileName) return null;
const decodedFileName = tryDecode(rawFileName).replace(/\s+/g, ' ').trim();
const encodedFileName = encodeURIComponent(decodedFileName).replace(/%2F/g, '/');
return `${WIKIMEDIA_REDIRECT_BASE}${encodedFileName}`;
};
export const getWikimediaFilePathFromThumbnailUrl = (url: string): string | null => {
if (!url.includes('upload.wikimedia.org/wikipedia/commons/thumb/')) return null;
// Example: https://upload.wikimedia.org/wikipedia/commons/thumb/2/2e/Monstera_deliciosa2.jpg/330px-Monstera_deliciosa2.jpg
// The filename is the segment between the second-to-last and last slash (in many cases)
// or use a more robust regex
const parts = url.split('/');
// Filter out empty parts from trailing slashes
const filtered = parts.filter(p => !!p);
if (filtered.length < 5) return null;
// For thumb URLs, the filename is typically the part before the last part (which is the px-filename)
// but let's be careful.
const lastName = filtered[filtered.length - 1];
const secondLastName = filtered[filtered.length - 2];
// In thumb URLs, the last part is usually "330px-Filename.jpg"
// The second to last is "Filename.jpg"
if (lastName.includes('px-') && lastName.endsWith(secondLastName)) {
try {
return decodeURIComponent(secondLastName);
} catch {
return secondLastName;
}
}
return null;
};
export const getPlantImageSourceFallbackUri = (rawUri?: string | null): string | null => {
const localImageUri = normalizePlantAssetPath(rawUri);
if (!localImageUri) return null;
const sourceUri = loadManifestSourceByLocalUri().get(localImageUri);
if (!sourceUri) return null;
if (/^https?:\/\//i.test(sourceUri)) {
return tryResolveImageUri(sourceUri);
}
if (!sourceUri.startsWith('wikimedia-search:')) {
return null;
}
const rawQuery = sourceUri.slice('wikimedia-search:'.length).trim();
const decodedQuery = decodeRepeatedly(rawQuery);
if (!decodedQuery) return null;
const searchCache = loadWikimediaSearchCache();
const cachedUrl = searchCache[decodedQuery]
|| searchCache[rawQuery]
|| searchCache[encodeURIComponent(decodedQuery)]
|| null;
return cachedUrl ? tryResolveImageUri(cachedUrl) : null;
};
export const tryResolveImageUri = (rawUri?: string | null): string | null => {
if (!rawUri) return null;
const trimmed = rawUri.trim();
if (!trimmed) return null;
const localAssetUri = resolveServerAssetPath(trimmed);
if (localAssetUri) return localAssetUri;
const normalized = unwrapMarkdownLink(trimmed);
const converted = convertWikimediaFilePathUrl(normalized);
const candidate = (converted || normalized).replace(/^http:\/\//i, 'https://');
if (!/^(https?:\/\/|file:\/\/|content:\/\/|data:image\/|blob:)/i.test(candidate)) {
return null;
}
return candidate;
};
export const resolveImageUri = (rawUri?: string | null): string => {
return tryResolveImageUri(rawUri) || DEFAULT_PLANT_IMAGE_URI;
};

File diff suppressed because it is too large Load Diff