Files
papa-kind-treff.info/src/App/Search.php
2026-01-03 02:01:26 +01:00

242 lines
11 KiB
PHP

<?php
declare(strict_types=1);
namespace App;
final class Search
{
public function __construct(private ?\PDO $pdo) {}
public function searchEvents(string $query, int $limit = 100, ?array $geo = null): array
{
if (!$this->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 [];
}
}
}