Initial commit for Greenlens
This commit is contained in:
42
utils/backendUrl.ts
Normal file
42
utils/backendUrl.ts
Normal file
@@ -0,0 +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';
|
||||
}
|
||||
};
|
||||
213
utils/hybridSearch.ts
Normal file
213
utils/hybridSearch.ts
Normal file
@@ -0,0 +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);
|
||||
};
|
||||
17
utils/idempotency.ts
Normal file
17
utils/idempotency.ts
Normal file
@@ -0,0 +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}`;
|
||||
};
|
||||
217
utils/imageUri.ts
Normal file
217
utils/imageUri.ts
Normal file
@@ -0,0 +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;
|
||||
};
|
||||
@@ -16,9 +16,23 @@ export const translations = {
|
||||
// Settings
|
||||
darkMode: 'Dark Mode',
|
||||
language: 'Sprache',
|
||||
appearance: 'Design',
|
||||
appearanceMode: 'Modus',
|
||||
colorPalette: 'Farbpalette',
|
||||
themeSystem: 'System',
|
||||
themeLight: 'Hell',
|
||||
themeDark: 'Dunkel',
|
||||
paletteForest: 'Forest',
|
||||
paletteOcean: 'Ocean',
|
||||
paletteSunset: 'Sunset',
|
||||
paletteMono: 'Mono',
|
||||
|
||||
// Empty States / Info
|
||||
// Info
|
||||
noPlants: 'Noch keine Pflanzen.',
|
||||
nextStepsTitle: 'Deine nächsten Schritte',
|
||||
stepScan: 'Erste Pflanze scannen',
|
||||
stepLexicon: 'Pflanzenlexikon erkunden',
|
||||
stepTheme: 'Design anpassen',
|
||||
|
||||
// Filters
|
||||
allGood: 'Alles gut',
|
||||
@@ -29,6 +43,17 @@ export const translations = {
|
||||
categories: 'Kategorien entdecken',
|
||||
resultsInPlants: 'Ergebnisse in "Meine Pflanzen"',
|
||||
noResults: 'Keine Pflanzen gefunden.',
|
||||
searchMyPlants: 'Meine Pflanzen',
|
||||
searchLexicon: 'Lexikon',
|
||||
searchAiSection: 'AI Deep Search',
|
||||
searchDeepAction: 'Deep Search (AI)',
|
||||
searchNoLocalResults: 'Keine Treffer in deiner Sammlung.',
|
||||
searchNoLexiconResults: 'Keine Treffer im Lexikon.',
|
||||
searchAiLoading: 'AI durchsucht den Katalog...',
|
||||
searchAiNoResults: 'AI hat keine passenden Pflanzen gefunden.',
|
||||
searchAiUnavailable: 'AI-Suche ist aktuell nicht verfügbar.',
|
||||
searchHistory: 'Suchverlauf',
|
||||
clearHistory: 'Verlauf löschen',
|
||||
|
||||
// Categories
|
||||
catCareEasy: "Pflegeleicht",
|
||||
@@ -37,6 +62,14 @@ export const translations = {
|
||||
catPetFriendly: "Tierfreundlich",
|
||||
catAirPurifier: "Luftreiniger",
|
||||
catFlowering: "Blühend",
|
||||
catBrightLight: "Helles Licht",
|
||||
catSun: "Sonnig",
|
||||
catHighHumidity: "Hohe Luftfeuchte",
|
||||
catHanging: "Hängend",
|
||||
catPatterned: "Gemustert",
|
||||
catTree: "Bäume",
|
||||
catLarge: "Groß",
|
||||
catMedicinal: "Heilpflanzen",
|
||||
|
||||
// Dictionary
|
||||
lexiconTitle: "Pflanzen-Lexikon",
|
||||
@@ -52,12 +85,40 @@ export const translations = {
|
||||
scanner: 'Scanner',
|
||||
analyzing: 'Pflanze wird analysiert...',
|
||||
localProcessing: "Lokale Verarbeitung",
|
||||
registerToSave: "Registrieren zum Speichern",
|
||||
|
||||
// Scan Stages
|
||||
scanStage1: "Bildqualität wird geprüft...",
|
||||
scanStage2: "Blattstrukturen werden analysiert...",
|
||||
scanStage3: "Abgleich mit Pflanzendatenbank...",
|
||||
|
||||
// Dashboard
|
||||
greetingMorning: 'Guten Morgen,',
|
||||
greetingAfternoon: 'Guten Tag,',
|
||||
greetingEvening: 'Guten Abend,',
|
||||
creditsLabel: 'Credits',
|
||||
needsWaterToday: 'Heute gießen',
|
||||
viewSchedule: 'Plan anzeigen',
|
||||
all: 'Alle',
|
||||
today: 'Heute',
|
||||
week: 'Woche',
|
||||
healthy: 'Gesund',
|
||||
dormant: 'Ruhe',
|
||||
thirsty: 'Durstig',
|
||||
healthyStatus: 'Gesund',
|
||||
nextWaterLabel: 'Gießen',
|
||||
noneInFilter: 'Keine Pflanzen in diesem Filter.',
|
||||
reminderTitle: 'Erinnerungen',
|
||||
reminderNone: 'Heute ist keine Pflanze fällig.',
|
||||
reminderDue: '{0} Pflanzen brauchen heute Wasser.',
|
||||
plantsThirsty: '{0} deiner Pflanzen brauchen heute Wasser!',
|
||||
collectionCount: '{0} Pflanzen',
|
||||
more: 'weitere',
|
||||
collectionTitle: 'Sammlung',
|
||||
emptyCollectionTitle: 'Deine Sammlung ist noch leer',
|
||||
emptyCollectionHint: 'Scanne deine erste Pflanze und starte deine digitale Sammlung.',
|
||||
scanFirstPlant: 'Erste Pflanze scannen',
|
||||
|
||||
// Plant Card / Detail / Result
|
||||
result: "Ergebnis",
|
||||
match: "Übereinstimmung",
|
||||
@@ -116,6 +177,37 @@ export const translations = {
|
||||
reminderOn: "Aktiviert",
|
||||
reminderOff: "Deaktiviert",
|
||||
reminderPermissionNeeded: "Berechtigung für Benachrichtigungen erforderlich.",
|
||||
|
||||
// Gallery
|
||||
galleryTitle: "Galerie",
|
||||
addPhoto: "Foto hinzufügen",
|
||||
noPhotos: "Noch keine Fotos",
|
||||
|
||||
// Tour
|
||||
tourFabTitle: "📷 Pflanze scannen",
|
||||
tourFabDesc: "Tippe hier um eine Pflanze zu fotografieren — die KI erkennt sie sofort.",
|
||||
tourSearchTitle: "🔍 Pflanzenlexikon",
|
||||
tourSearchDesc: "Durchsuche tausende Pflanzen oder lass die KI nach der perfekten suchen.",
|
||||
tourProfileTitle: "👤 Dein Profil",
|
||||
tourProfileDesc: "Passe Design, Sprache und Benachrichtigungen ganz nach deinem Geschmack an.",
|
||||
|
||||
// Onboarding
|
||||
onboardingTitle1: "Pflanzen-Scanner",
|
||||
onboardingTitle2: "Pflanzenpflege",
|
||||
onboardingTitle3: "Dein Garten",
|
||||
onboardingDesc1: "Scanne jede Pflanze und identifiziere sie sofort mit KI",
|
||||
onboardingDesc2: "Erhalte personalisierte Pflegetipps und Gießerinnerungen",
|
||||
onboardingDesc3: "Baue deine digitale Pflanzensammlung auf",
|
||||
onboardingNext: "Weiter",
|
||||
onboardingStart: "Los geht's",
|
||||
onboardingTagline: "Deine Pflanzen. Deine Welt.",
|
||||
onboardingFeatureScan: "Pflanzen scannen & erkennen",
|
||||
onboardingFeatureReminder: "Gießerinnerungen & Pflege",
|
||||
onboardingFeatureLexicon: "Digitales Pflanzen-Lexikon",
|
||||
onboardingScanBtn: "Pflanze scannen",
|
||||
onboardingRegister: "Registrieren",
|
||||
onboardingLogin: "Anmelden",
|
||||
onboardingDisclaimer: "Deine Daten bleiben privat und lokal auf deinem Gerät.",
|
||||
},
|
||||
en: {
|
||||
tabPlants: 'Plants',
|
||||
@@ -126,13 +218,38 @@ export const translations = {
|
||||
settingsTitle: 'Settings',
|
||||
darkMode: 'Dark Mode',
|
||||
language: 'Language',
|
||||
appearance: 'Appearance',
|
||||
appearanceMode: 'Mode',
|
||||
colorPalette: 'Color Palette',
|
||||
themeSystem: 'System',
|
||||
themeLight: 'Light',
|
||||
themeDark: 'Dark',
|
||||
paletteForest: 'Forest',
|
||||
paletteOcean: 'Ocean',
|
||||
paletteSunset: 'Sunset',
|
||||
paletteMono: 'Mono',
|
||||
noPlants: 'No plants yet.',
|
||||
nextStepsTitle: 'Your next steps',
|
||||
stepScan: 'Scan first plant',
|
||||
stepLexicon: 'Explore plant lexicon',
|
||||
stepTheme: 'Customize design',
|
||||
allGood: 'All good',
|
||||
toWater: 'To water',
|
||||
searchPlaceholder: 'Search plants...',
|
||||
categories: 'Discover Categories',
|
||||
resultsInPlants: 'Results in "My Plants"',
|
||||
noResults: 'No plants found.',
|
||||
searchMyPlants: 'My Plants',
|
||||
searchLexicon: 'Lexicon',
|
||||
searchAiSection: 'AI Deep Search',
|
||||
searchDeepAction: 'Deep Search (AI)',
|
||||
searchNoLocalResults: 'No matches in your collection.',
|
||||
searchNoLexiconResults: 'No matches in the lexicon.',
|
||||
searchAiLoading: 'AI is searching the catalog...',
|
||||
searchAiNoResults: 'AI found no matching plants.',
|
||||
searchAiUnavailable: 'AI search is currently unavailable.',
|
||||
searchHistory: 'Search history',
|
||||
clearHistory: 'Clear history',
|
||||
|
||||
catCareEasy: "Easy Care",
|
||||
catSucculents: "Succulents",
|
||||
@@ -140,25 +257,61 @@ export const translations = {
|
||||
catPetFriendly: "Pet Friendly",
|
||||
catAirPurifier: "Air Purifier",
|
||||
catFlowering: "Flowering",
|
||||
catBrightLight: "Bright Light",
|
||||
catSun: "Sunny",
|
||||
catHighHumidity: "High Humidity",
|
||||
catHanging: "Hanging",
|
||||
catPatterned: "Patterned",
|
||||
catTree: "Trees",
|
||||
catLarge: "Large",
|
||||
catMedicinal: "Medicinal",
|
||||
|
||||
lexiconTitle: "Plant Encyclopedia",
|
||||
lexiconDesc: "Browse our database and find the perfect addition for your home.",
|
||||
lexiconSearchPlaceholder: "Search encyclopedia...",
|
||||
browseLexicon: "Browse Encyclopedia",
|
||||
backToSearch: "Back to Search",
|
||||
|
||||
comingSoon: 'Coming Soon',
|
||||
gallery: 'Gallery',
|
||||
help: 'Help',
|
||||
scanner: 'Scanner',
|
||||
analyzing: 'Analyzing plant...',
|
||||
localProcessing: "Local Processing",
|
||||
// Misc
|
||||
comingSoon: 'Coming Soon',
|
||||
gallery: 'Gallery',
|
||||
help: 'Help',
|
||||
scanner: 'Scanner',
|
||||
analyzing: 'Analyzing plant...',
|
||||
localProcessing: "Local Processing",
|
||||
registerToSave: "Sign up to save",
|
||||
|
||||
// Scan Stages
|
||||
scanStage1: "Checking image quality...",
|
||||
scanStage2: "Analyzing leaf structures...",
|
||||
scanStage3: "Matching with plant database...",
|
||||
|
||||
// Dashboard
|
||||
greetingMorning: 'Good morning,',
|
||||
greetingAfternoon: 'Good afternoon,',
|
||||
greetingEvening: 'Good evening,',
|
||||
creditsLabel: 'Credits',
|
||||
needsWaterToday: 'Needs water today',
|
||||
viewSchedule: 'View Schedule',
|
||||
all: 'All',
|
||||
today: 'Today',
|
||||
week: 'Week',
|
||||
healthy: 'Healthy',
|
||||
dormant: 'Dormant',
|
||||
thirsty: 'Thirsty',
|
||||
healthyStatus: 'Healthy',
|
||||
nextWaterLabel: 'Water',
|
||||
noneInFilter: 'No plants in this filter.',
|
||||
reminderTitle: 'Reminders',
|
||||
reminderNone: 'No plants are due today.',
|
||||
reminderDue: '{0} plants need water today.',
|
||||
plantsThirsty: '{0} of your plants are feeling thirsty!',
|
||||
collectionCount: '{0} plants',
|
||||
more: 'more',
|
||||
collectionTitle: 'Collection',
|
||||
emptyCollectionTitle: 'Your collection is still empty',
|
||||
emptyCollectionHint: 'Scan your first plant to start building your digital garden.',
|
||||
scanFirstPlant: 'Scan first plant',
|
||||
|
||||
result: "Result",
|
||||
match: "Match",
|
||||
careCheck: "Care Check",
|
||||
@@ -212,6 +365,37 @@ export const translations = {
|
||||
reminderOn: "Enabled",
|
||||
reminderOff: "Disabled",
|
||||
reminderPermissionNeeded: "Notification permission required.",
|
||||
|
||||
// Gallery
|
||||
galleryTitle: "Gallery",
|
||||
addPhoto: "Add Photo",
|
||||
noPhotos: "No photos yet",
|
||||
|
||||
// Tour
|
||||
tourFabTitle: "📷 Scan Plant",
|
||||
tourFabDesc: "Tap here to photograph a plant — the AI recognizes it instantly.",
|
||||
tourSearchTitle: "🔍 Plant Encyclopedia",
|
||||
tourSearchDesc: "Search thousands of plants or let the AI find the perfect one.",
|
||||
tourProfileTitle: "👤 Your Profile",
|
||||
tourProfileDesc: "Customize design, language, and notifications to your liking.",
|
||||
|
||||
// Onboarding
|
||||
onboardingTitle1: "Plant Scanner",
|
||||
onboardingTitle2: "Plant Care",
|
||||
onboardingTitle3: "Your Garden",
|
||||
onboardingDesc1: "Scan any plant and identify it instantly with AI",
|
||||
onboardingDesc2: "Get personalized care tips and watering reminders",
|
||||
onboardingDesc3: "Build your digital plant collection",
|
||||
onboardingNext: "Next",
|
||||
onboardingStart: "Get Started",
|
||||
onboardingTagline: "Your plants. Your world.",
|
||||
onboardingFeatureScan: "Scan & identify plants",
|
||||
onboardingFeatureReminder: "Watering reminders & care",
|
||||
onboardingFeatureLexicon: "Digital plant encyclopedia",
|
||||
onboardingScanBtn: "Scan Plant",
|
||||
onboardingRegister: "Sign Up",
|
||||
onboardingLogin: "Log In",
|
||||
onboardingDisclaimer: "Your data stays private and local on your device.",
|
||||
},
|
||||
es: {
|
||||
tabPlants: 'Plantas',
|
||||
@@ -222,13 +406,38 @@ export const translations = {
|
||||
settingsTitle: 'Ajustes',
|
||||
darkMode: 'Modo Oscuro',
|
||||
language: 'Idioma',
|
||||
appearance: 'Apariencia',
|
||||
appearanceMode: 'Modo',
|
||||
colorPalette: 'Paleta',
|
||||
themeSystem: 'Sistema',
|
||||
themeLight: 'Claro',
|
||||
themeDark: 'Oscuro',
|
||||
paletteForest: 'Forest',
|
||||
paletteOcean: 'Ocean',
|
||||
paletteSunset: 'Sunset',
|
||||
paletteMono: 'Mono',
|
||||
noPlants: 'Aún no hay plantas.',
|
||||
nextStepsTitle: 'Tus próximos pasos',
|
||||
stepScan: 'Escanear primera planta',
|
||||
stepLexicon: 'Explorar enciclopedia',
|
||||
stepTheme: 'Personalizar diseño',
|
||||
allGood: 'Todo bien',
|
||||
toWater: 'Regar',
|
||||
searchPlaceholder: 'Buscar plantas...',
|
||||
categories: 'Descubrir Categorías',
|
||||
resultsInPlants: 'Resultados en "Mis Plantas"',
|
||||
noResults: 'No se encontraron plantas.',
|
||||
searchMyPlants: 'Mis Plantas',
|
||||
searchLexicon: 'Enciclopedia',
|
||||
searchAiSection: 'Busqueda profunda AI',
|
||||
searchDeepAction: 'Busqueda profunda (AI)',
|
||||
searchNoLocalResults: 'No hay coincidencias en tu coleccion.',
|
||||
searchNoLexiconResults: 'No hay coincidencias en la enciclopedia.',
|
||||
searchAiLoading: 'AI esta buscando en el catalogo...',
|
||||
searchAiNoResults: 'AI no encontro plantas compatibles.',
|
||||
searchAiUnavailable: 'La busqueda AI no esta disponible ahora.',
|
||||
searchHistory: 'Busquedas recientes',
|
||||
clearHistory: 'Borrar historial',
|
||||
|
||||
catCareEasy: "Fácil Cuidado",
|
||||
catSucculents: "Suculentas",
|
||||
@@ -236,25 +445,61 @@ export const translations = {
|
||||
catPetFriendly: "Pet Friendly",
|
||||
catAirPurifier: "Purificador",
|
||||
catFlowering: "Con Flores",
|
||||
catBrightLight: "Luz Brillante",
|
||||
catSun: "Sol",
|
||||
catHighHumidity: "Alta Humedad",
|
||||
catHanging: "Colgante",
|
||||
catPatterned: "Con Patrón",
|
||||
catTree: "Árboles",
|
||||
catLarge: "Grande",
|
||||
catMedicinal: "Medicinal",
|
||||
|
||||
lexiconTitle: "Enciclopedia",
|
||||
lexiconDesc: "Explora nuestra base de datos y encuentra la adición perfecta para tu hogar.",
|
||||
lexiconSearchPlaceholder: "Buscar en enciclopedia...",
|
||||
browseLexicon: "Explorar Enciclopedia",
|
||||
backToSearch: "Volver a Buscar",
|
||||
|
||||
comingSoon: 'Próximamente',
|
||||
gallery: 'Galería',
|
||||
help: 'Ayuda',
|
||||
scanner: 'Escáner',
|
||||
analyzing: 'Analizando planta...',
|
||||
localProcessing: "Procesamiento Local",
|
||||
// Misc
|
||||
comingSoon: 'Próximamente',
|
||||
gallery: 'Galería',
|
||||
help: 'Ayuda',
|
||||
scanner: 'Escáner',
|
||||
analyzing: 'Analizando planta...',
|
||||
localProcessing: "Procesamiento Local",
|
||||
registerToSave: "Regístrate para guardar",
|
||||
|
||||
// Scan Stages
|
||||
scanStage1: "Verificando calidad de imagen...",
|
||||
scanStage2: "Analizando estructuras...",
|
||||
scanStage3: "Comparando con base de datos...",
|
||||
|
||||
// Dashboard
|
||||
greetingMorning: 'Buenos dias,',
|
||||
greetingAfternoon: 'Buenas tardes,',
|
||||
greetingEvening: 'Buenas noches,',
|
||||
creditsLabel: 'Creditos',
|
||||
needsWaterToday: 'Necesitan agua hoy',
|
||||
viewSchedule: 'Ver horario',
|
||||
all: 'Todas',
|
||||
today: 'Hoy',
|
||||
week: 'Semana',
|
||||
healthy: 'Saludables',
|
||||
dormant: 'Dormantes',
|
||||
thirsty: 'Sedienta',
|
||||
healthyStatus: 'Saludable',
|
||||
nextWaterLabel: 'Riego',
|
||||
noneInFilter: 'No hay plantas en este filtro.',
|
||||
reminderTitle: 'Recordatorios',
|
||||
reminderNone: 'No hay plantas pendientes para hoy.',
|
||||
reminderDue: '{0} plantas necesitan agua hoy.',
|
||||
plantsThirsty: '¡{0} de tus plantas tienen sed hoy!',
|
||||
collectionCount: '{0} plantas',
|
||||
more: 'más',
|
||||
collectionTitle: 'Coleccion',
|
||||
emptyCollectionTitle: 'Tu coleccion esta vacia',
|
||||
emptyCollectionHint: 'Escanea tu primera planta para empezar tu coleccion digital.',
|
||||
scanFirstPlant: 'Escanear primera planta',
|
||||
|
||||
result: "Resultado",
|
||||
match: "Coincidencia",
|
||||
careCheck: "Chequeo de Cuidados",
|
||||
@@ -308,6 +553,37 @@ export const translations = {
|
||||
reminderOn: "Activado",
|
||||
reminderOff: "Desactivado",
|
||||
reminderPermissionNeeded: "Permiso de notificación requerido.",
|
||||
|
||||
// Gallery
|
||||
galleryTitle: "Galería",
|
||||
addPhoto: "Añadir Foto",
|
||||
noPhotos: "Sin fotos aún",
|
||||
|
||||
// Tour
|
||||
tourFabTitle: "📷 Escanear Planta",
|
||||
tourFabDesc: "Toca aquí para fotografiar una planta — la IA la reconoce al instante.",
|
||||
tourSearchTitle: "🔍 Enciclopedia",
|
||||
tourSearchDesc: "Busca en miles de plantas o deja que la IA encuentre la perfecta.",
|
||||
tourProfileTitle: "👤 Tu Perfil",
|
||||
tourProfileDesc: "Personaliza diseño, idioma y notificaciones a tu gusto.",
|
||||
|
||||
// Onboarding
|
||||
onboardingTitle1: "Escáner de Plantas",
|
||||
onboardingTitle2: "Cuidado de Plantas",
|
||||
onboardingTitle3: "Tu Jardín",
|
||||
onboardingDesc1: "Escanea cualquier planta e identifícala al instante con IA",
|
||||
onboardingDesc2: "Obtén consejos de cuidado personalizados y recordatorios de riego",
|
||||
onboardingDesc3: "Construye tu colección digital de plantas",
|
||||
onboardingNext: "Siguiente",
|
||||
onboardingStart: "Empezar",
|
||||
onboardingTagline: "Tus plantas. Tu mundo.",
|
||||
onboardingFeatureScan: "Escanea e identifica plantas",
|
||||
onboardingFeatureReminder: "Recordatorios de riego y cuidado",
|
||||
onboardingFeatureLexicon: "Enciclopedia digital de plantas",
|
||||
onboardingScanBtn: "Escanear Planta",
|
||||
onboardingRegister: "Registrarse",
|
||||
onboardingLogin: "Iniciar sesión",
|
||||
onboardingDisclaimer: "Tus datos permanecen privados y locales en tu dispositivo.",
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user