pdo) return []; $q = trim($query); $hasGeo = isset($geo['lat'], $geo['lng']) && is_numeric($geo['lat']) && is_numeric($geo['lng']); if ($q === '' && !$hasGeo) return []; $tokens = array_filter(preg_split('/\s+/', $q) ?: [], fn($t) => $t !== ''); if (!$tokens) { $tokens = [$q]; } // Nur Tokens ab 3 Zeichen für fuzzy/LIKE berücksichtigen $tokens = array_values(array_filter($tokens, fn($t) => mb_strlen($t) >= 3)); if (!$tokens && !$hasGeo) return []; $conditions = []; $bindTokens = []; $i = 0; foreach ($tokens as $tok) { $tok = trim($tok); if ($tok === '') continue; // LIKE + phonetic (SOUNDEX) to allow partial and typo-tolerant matches $conditions[] = "(title LIKE CONCAT('%', ?, '%') OR teaser_public LIKE CONCAT('%', ?, '%') OR description LIKE CONCAT('%', ?, '%') OR city LIKE CONCAT('%', ?, '%') OR region LIKE CONCAT('%', ?, '%') OR zip LIKE CONCAT('%', ?, '%') OR SOUNDEX(title)=SOUNDEX(?) OR SOUNDEX(teaser_public)=SOUNDEX(?) OR SOUNDEX(description)=SOUNDEX(?) OR SOUNDEX(city)=SOUNDEX(?) OR SOUNDEX(region)=SOUNDEX(?))"; // LIKE bindings $bindTokens[] = $tok; $bindTokens[] = $tok; $bindTokens[] = $tok; $bindTokens[] = $tok; $bindTokens[] = $tok; $bindTokens[] = $tok; // SOUNDEX bindings $bindTokens[] = $tok; $bindTokens[] = $tok; $bindTokens[] = $tok; $bindTokens[] = $tok; $bindTokens[] = $tok; $i++; } $whereParts = [ "starts_at >= NOW()", "status != 'cancelled'", ]; if ($conditions) { // "OR" so that partial matches across tokens are allowed $whereParts[] = '(' . implode(' OR ', $conditions) . ')'; } $distanceFiltering = false; $bind = []; if ($hasGeo) { $sql = "SELECT id, title, teaser_public, description, city, region, zip, starts_at, visibility, allow_kids, location_label, lat, lng, (6371 * ACOS(LEAST(1, COS(RADIANS(?)) * COS(RADIANS(lat)) * COS(RADIANS(lng) - RADIANS(?)) + SIN(RADIANS(?)) * SIN(RADIANS(lat)) ))) AS distance_km"; $lat = (float)$geo['lat']; $lng = (float)$geo['lng']; $radius = isset($geo['radius']) && is_numeric($geo['radius']) ? max(0.1, (float)$geo['radius']) : 5.0; $distanceFiltering = true; $latRange = $radius / 111.0; $lngRange = $radius / (111.0 * max(0.1, cos($lat * M_PI / 180))); $whereParts[] = "(lat IS NOT NULL AND lng IS NOT NULL)"; $whereParts[] = "(lat BETWEEN ? AND ?)"; $whereParts[] = "(lng BETWEEN ? AND ?)"; // Haversine params (order must match SQL): first three $bind[] = $lat; // COS(RADIANS(?)) $bind[] = $lng; // COS(RADIANS(lng) - RADIANS(?)) $bind[] = $lat; // SIN(RADIANS(?)) // THEN token binds $bind = array_merge($bind, $bindTokens); // Bounding box $bind[] = $lat - $latRange; $bind[] = $lat + $latRange; $bind[] = $lng - $lngRange; $bind[] = $lng + $lngRange; // Radius for HAVING $bind[] = $radius; } else { $sql = "SELECT id, title, teaser_public, description, city, region, zip, starts_at, visibility, allow_kids, location_label, lat, lng, NULL AS distance_km"; $bind = $bindTokens; } $where = $whereParts ? ('WHERE ' . implode(' AND ', $whereParts)) : ''; $sql .= " FROM events $where"; if ($distanceFiltering) { $sql .= " HAVING distance_km <= ?"; $sql .= " ORDER BY distance_km ASC, starts_at ASC"; } else { $sql .= " ORDER BY starts_at ASC"; } $limit = (int)$limit; $sql .= " LIMIT {$limit}"; $stmt = $this->pdo->prepare($sql); try { $stmt->execute($bind); $rows = $stmt->fetchAll(\PDO::FETCH_ASSOC) ?: []; if (!$hasGeo) { foreach ($rows as &$r) { unset($r['distance_km']); } unset($r); } // Fuzzy filter: allow slight typos (Levenshtein <= 1 or 2) if ($tokens) { $rows = array_values(array_filter($rows, function ($row) use ($tokens) { $haystack = strtolower( ($row['title'] ?? '') . ' ' . ($row['teaser_public'] ?? '') . ' ' . ($row['description'] ?? '') . ' ' . ($row['city'] ?? '') . ' ' . ($row['region'] ?? '') ); $words = preg_split('/[^a-z0-9äöüß]+/i', $haystack) ?: []; foreach ($tokens as $tok) { $t = strtolower($tok); if ($t === '') continue; if (str_contains($haystack, $t)) { return true; } foreach ($words as $w) { if ($w === '') continue; $dist = levenshtein($t, $w); if ($dist <= 1 || ($dist <= 2 && max(strlen($t), strlen($w)) > 4)) { return true; } } } return false; })); } // Fallback: wenn keine Treffer, erneut ohne Token-Filter laden und nur fuzzy filtern if (!$rows && $tokens) { $wherePartsFallback = [ "starts_at >= NOW()", "status != 'cancelled'", ]; $bindFb = []; $sqlFb = ''; if ($hasGeo) { $sqlFb = "SELECT id, title, teaser_public, description, city, region, zip, starts_at, visibility, allow_kids, location_label, lat, lng, (6371 * ACOS(LEAST(1, COS(RADIANS(?)) * COS(RADIANS(lat)) * COS(RADIANS(lng) - RADIANS(?)) + SIN(RADIANS(?)) * SIN(RADIANS(lat)) ))) AS distance_km"; $lat = (float)$geo['lat']; $lng = (float)$geo['lng']; $radius = isset($geo['radius']) && is_numeric($geo['radius']) ? max(0.1, (float)$geo['radius']) : 5.0; $latRange = $radius / 111.0; $lngRange = $radius / (111.0 * max(0.1, cos($lat * M_PI / 180))); $wherePartsFallback[] = "(lat IS NOT NULL AND lng IS NOT NULL)"; $wherePartsFallback[] = "(lat BETWEEN ? AND ?)"; $wherePartsFallback[] = "(lng BETWEEN ? AND ?)"; $bindFb[] = $lat; $bindFb[] = $lng; $bindFb[] = $lat; $bindFb[] = $lat - $latRange; $bindFb[] = $lat + $latRange; $bindFb[] = $lng - $lngRange; $bindFb[] = $lng + $lngRange; $bindFb[] = $radius; $havingFb = true; } else { $sqlFb = "SELECT id, title, teaser_public, description, city, region, zip, starts_at, visibility, allow_kids, location_label, lat, lng, 1 AS distance_km"; $havingFb = false; } $whereFb = $wherePartsFallback ? ('WHERE ' . implode(' AND ', $wherePartsFallback)) : ''; $sqlFb .= " FROM events $whereFb"; if ($havingFb) { $sqlFb .= " HAVING distance_km <= ?"; $sqlFb .= " ORDER BY distance_km ASC, starts_at ASC"; } else { $sqlFb .= " ORDER BY starts_at ASC"; } $sqlFb .= " LIMIT {$limit}"; $stmtFb = $this->pdo->prepare($sqlFb); $stmtFb->execute($bindFb); $rowsFb = $stmtFb->fetchAll(\PDO::FETCH_ASSOC) ?: []; if ($rowsFb) { $rows = array_values(array_filter($rowsFb, function ($row) use ($tokens) { $haystack = strtolower( ($row['title'] ?? '') . ' ' . ($row['teaser_public'] ?? '') . ' ' . ($row['description'] ?? '') . ' ' . ($row['city'] ?? '') . ' ' . ($row['region'] ?? '') ); $words = preg_split('/[^a-z0-9äöüß]+/i', $haystack) ?: []; foreach ($tokens as $tok) { $t = strtolower($tok); if ($t === '') continue; if (str_contains($haystack, $t)) { return true; } foreach ($words as $w) { if ($w === '') continue; $dist = levenshtein($t, $w); if ($dist <= 1 || ($dist <= 2 && max(strlen($t), strlen($w)) > 4)) { return true; } } } return false; })); } } if (defined('APP_ENV') && APP_ENV === 'staging') { $logOk = [ 'status' => 'ok', 'sql' => $sql, 'bind' => $bind, 'count' => count($rows), 'fallback' => ($rows ? 'primary' : 'fallback'), ]; @file_put_contents(__DIR__ . '/../../debug/search_debug.log', print_r($logOk, true)); } return $rows; } catch (\PDOException $e) { // Log into /debug/search_debug.log and continue with empty results $logErr = [ 'error' => $e->getMessage(), 'sql' => $sql, 'bind' => $bind, ]; @file_put_contents(__DIR__ . '/../../debug/search_debug.log', print_r($logErr, true)); return []; } } }