535 lines
21 KiB
PHP
535 lines
21 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
|
|
require_once __DIR__ . '/db.php';
|
|
|
|
try {
|
|
match ($_SERVER['REQUEST_METHOD']) {
|
|
'GET' => handle_get(),
|
|
'POST' => handle_post(),
|
|
'PUT' => handle_put(),
|
|
'DELETE' => handle_delete(),
|
|
default => error_out('Method not allowed', 405),
|
|
};
|
|
} catch (PDOException $e) {
|
|
error_out('Database error: ' . $e->getMessage(), 500);
|
|
}
|
|
|
|
// ─── GET /api/attendees.php ───────────────────────────────────────────────────
|
|
// Returns all attendees with their registered workshops.
|
|
|
|
function handle_get(): never
|
|
{
|
|
$rows = db()->query(
|
|
"SELECT a.id, a.first_name, a.last_name, a.email, a.mobile_number,
|
|
GROUP_CONCAT(ws.workshop_name ORDER BY ws.workshop_time SEPARATOR ', ') AS workshops,
|
|
GROUP_CONCAT(ws.workshop_time ORDER BY ws.workshop_time SEPARATOR ',') AS workshop_times,
|
|
MIN(ws.workshop_time) AS workshop_time,
|
|
MIN(r.workshop_session_id) AS workshop_session_id
|
|
FROM attendees a
|
|
LEFT JOIN registrations r ON r.attendee_id = a.id
|
|
LEFT JOIN workshop_sessions ws ON ws.id = r.workshop_session_id
|
|
GROUP BY a.id
|
|
ORDER BY a.last_name, a.first_name"
|
|
)->fetchAll();
|
|
|
|
json_out(['attendees' => $rows, 'total' => count($rows)]);
|
|
}
|
|
|
|
// ─── POST /api/attendees.php ──────────────────────────────────────────────────
|
|
// Accepts a CSV file upload (field name: "file").
|
|
// Replaces all existing attendees with the CSV contents.
|
|
// Required columns: first_name, last_name, mobile_number
|
|
// Optional columns: email
|
|
|
|
function handle_post(): never
|
|
{
|
|
if (empty($_FILES['file'])) {
|
|
error_out('No file uploaded');
|
|
}
|
|
|
|
$file = $_FILES['file'];
|
|
|
|
if ($file['error'] !== UPLOAD_ERR_OK) {
|
|
error_out('Upload error (code ' . $file['error'] . ')');
|
|
}
|
|
|
|
$ext = strtolower(pathinfo((string) $file['name'], PATHINFO_EXTENSION));
|
|
if ($ext !== 'csv') {
|
|
error_out('File must be a .csv');
|
|
}
|
|
|
|
$handle = fopen($file['tmp_name'], 'r');
|
|
if ($handle === false) {
|
|
error_out('Could not read uploaded file');
|
|
}
|
|
|
|
// Auto-detect delimiter from the first line (tab, semicolon, or comma)
|
|
// Strip UTF-8 BOM if present so it doesn't interfere with delimiter counting or fgetcsv
|
|
$rawFirstLine = (string) fgets($handle);
|
|
$hasBom = str_starts_with($rawFirstLine, "\xEF\xBB\xBF");
|
|
$firstLine = $hasBom ? substr($rawFirstLine, 3) : $rawFirstLine;
|
|
rewind($handle);
|
|
if ($hasBom) {
|
|
fseek($handle, 3); // seek past BOM so fgetcsv sees the opening quote of the first field
|
|
}
|
|
$tabCount = substr_count($firstLine, "\t");
|
|
$semiCount = substr_count($firstLine, ';');
|
|
$commaCount = substr_count($firstLine, ',');
|
|
if ($tabCount >= $semiCount && $tabCount >= $commaCount) {
|
|
$delim = "\t";
|
|
} elseif ($semiCount >= $commaCount) {
|
|
$delim = ';';
|
|
} else {
|
|
$delim = ',';
|
|
}
|
|
|
|
$headers = fgetcsv($handle, 0, $delim);
|
|
if ($headers === false) {
|
|
error_out('CSV file is empty');
|
|
}
|
|
$headers = array_map(fn($h) => strtolower(trim((string) $h, " \t\n\r\0\x0B\"")), $headers);
|
|
|
|
$col = [
|
|
'first_name' => find_col($headers, ['first name', 'first_name', 'firstname', 'first']),
|
|
'last_name' => find_col($headers, ['last name', 'last_name', 'lastname', 'last', 'surname']),
|
|
'email' => find_col($headers, ['email', 'email address', 'email_address']),
|
|
'mobile_number' => find_col($headers, ['custom: mobile number', 'mobile_number', 'mobile number', 'mobile', 'phone', 'telephone', 'cell']),
|
|
'status' => find_col($headers, ['status', 'rsvp status', 'rsvp_status']),
|
|
// Workshop columns — both are optional; time may also be embedded in the name
|
|
'workshop_name' => find_col($headers, [
|
|
'custom: please pick one workshop you\'d like to book into.',
|
|
'custom: please pick one workshop you\'d like to book into',
|
|
'workshop_name', 'workshop name', 'workshop', 'session', 'activity',
|
|
]),
|
|
'workshop_time' => find_col($headers, ['workshop_time', 'workshop time', 'session_time', 'time', 'start_time']),
|
|
];
|
|
|
|
if ($col['first_name'] === null || $col['last_name'] === null || $col['mobile_number'] === null) {
|
|
fclose($handle);
|
|
$missing = [];
|
|
if ($col['first_name'] === null) $missing[] = 'first_name (e.g. "First Name")';
|
|
if ($col['last_name'] === null) $missing[] = 'last_name (e.g. "Last Name")';
|
|
if ($col['mobile_number'] === null) $missing[] = 'mobile_number (e.g. "Custom: Mobile number")';
|
|
error_out('Missing required columns: ' . implode(', ', $missing) . '. Headers detected: ' . implode(' | ', $headers));
|
|
}
|
|
|
|
$hasWorkshops = $col['workshop_name'] !== null;
|
|
|
|
$mode = trim((string) ($_POST['mode'] ?? 'clear'));
|
|
if (!in_array($mode, ['clear', 'overwrite'], true)) {
|
|
$mode = 'clear';
|
|
}
|
|
|
|
// Resolutions map: mobile_number => chosen group index (0-based)
|
|
// Sent on the second submission after the user resolves duplicates in the UI.
|
|
$resolutions = [];
|
|
if (!empty($_POST['resolutions'])) {
|
|
$dec = json_decode((string) $_POST['resolutions'], true);
|
|
if (is_array($dec)) {
|
|
$resolutions = $dec;
|
|
}
|
|
}
|
|
|
|
// ─── Phase 1: Parse all valid rows into memory ────────────────────────────
|
|
|
|
$validRows = [];
|
|
$skipped = 0;
|
|
$skipReasons = ['not_attending' => 0, 'no_mobile' => 0];
|
|
$rejected = [];
|
|
|
|
while (($row = fgetcsv($handle, 0, $delim)) !== false) {
|
|
$row = array_map(fn($v) => trim((string) $v, " \t\n\r\0\x0B\""), $row);
|
|
|
|
$firstName = trim((string) ($row[$col['first_name']] ?? ''));
|
|
$lastName = trim((string) ($row[$col['last_name']] ?? ''));
|
|
$email = trim((string) ($row[$col['email']] ?? ''));
|
|
$rawMobile = trim((string) ($row[$col['mobile_number']] ?? ''), " \t\n\r\0\x0B\"'");
|
|
$rawStatus = $col['status'] !== null ? trim((string) ($row[$col['status']] ?? '')) : '';
|
|
|
|
if ($col['status'] !== null) {
|
|
$status = strtolower($rawStatus);
|
|
if ($status !== '' && $status !== 'attending') {
|
|
$skipped++;
|
|
$skipReasons['not_attending']++;
|
|
$rejected[] = [
|
|
'first_name' => $firstName, 'last_name' => $lastName,
|
|
'email' => $email, 'raw_mobile' => $rawMobile,
|
|
'status' => $rawStatus, 'reason' => 'not_attending',
|
|
];
|
|
continue;
|
|
}
|
|
}
|
|
|
|
$mobile = sanitise_mobile($rawMobile);
|
|
if ($mobile === '') {
|
|
$skipped++;
|
|
$skipReasons['no_mobile']++;
|
|
$rejected[] = [
|
|
'first_name' => $firstName, 'last_name' => $lastName,
|
|
'email' => $email, 'raw_mobile' => $rawMobile,
|
|
'status' => $rawStatus, 'reason' => classify_mobile_rejection($rawMobile),
|
|
];
|
|
continue;
|
|
}
|
|
|
|
// Parse workshop columns up-front (needed for duplicate display and import)
|
|
$wsName = $hasWorkshops ? trim((string) ($row[$col['workshop_name']] ?? '')) : '';
|
|
$wsTime = ($hasWorkshops && $col['workshop_time'] !== null)
|
|
? trim((string) ($row[$col['workshop_time']] ?? ''))
|
|
: '';
|
|
$dt = null;
|
|
if ($hasWorkshops && $wsName !== '') {
|
|
if ($wsTime === '') {
|
|
$wsTime = extract_time_from_string($wsName) ?? '';
|
|
$wsName = trim_time_from_string($wsName);
|
|
}
|
|
if ($wsTime !== '') {
|
|
$parsed = strtotime($wsTime);
|
|
$dt = $parsed !== false ? date('Y-m-d H:i:s', $parsed) : null;
|
|
}
|
|
}
|
|
|
|
$validRows[] = [
|
|
'first_name' => $firstName,
|
|
'last_name' => $lastName,
|
|
'email' => $email,
|
|
'mobile_number' => $mobile,
|
|
'workshop_name' => $wsName,
|
|
'workshop_time' => $dt,
|
|
];
|
|
}
|
|
fclose($handle);
|
|
|
|
// ─── Phase 2: Detect intra-CSV duplicate mobile numbers ───────────────────
|
|
|
|
$byMobile = [];
|
|
foreach ($validRows as $i => $vr) {
|
|
$byMobile[$vr['mobile_number']][] = $i;
|
|
}
|
|
|
|
$duplicateGroups = [];
|
|
foreach ($byMobile as $mobile => $indices) {
|
|
if (count($indices) > 1) {
|
|
$duplicateGroups[] = [
|
|
'mobile' => $mobile,
|
|
'rows' => array_values(array_map(fn($i) => $validRows[$i], $indices)),
|
|
];
|
|
}
|
|
}
|
|
|
|
// If duplicates exist and the user hasn't resolved them yet, pause and ask.
|
|
if (!empty($duplicateGroups) && empty($resolutions)) {
|
|
json_out(['status' => 'needs_resolution', 'duplicates' => $duplicateGroups]);
|
|
}
|
|
|
|
// Apply resolutions — keep only the chosen row for each duplicate mobile.
|
|
if (!empty($resolutions)) {
|
|
$indicesToRemove = [];
|
|
foreach ($byMobile as $mobile => $indices) {
|
|
if (!isset($resolutions[$mobile]) || count($indices) < 2) {
|
|
continue;
|
|
}
|
|
$chosenGroupPos = (int) $resolutions[$mobile];
|
|
foreach ($indices as $groupPos => $rowIdx) {
|
|
if ($groupPos !== $chosenGroupPos) {
|
|
$indicesToRemove[$rowIdx] = true;
|
|
}
|
|
}
|
|
}
|
|
$validRows = array_values(
|
|
array_filter($validRows, fn($_, $i) => !isset($indicesToRemove[$i]), ARRAY_FILTER_USE_BOTH)
|
|
);
|
|
}
|
|
|
|
// ─── Phase 3: Import ──────────────────────────────────────────────────────
|
|
|
|
$db = db();
|
|
$db->beginTransaction();
|
|
|
|
if ($mode === 'clear') {
|
|
$db->exec('DELETE FROM registrations');
|
|
$db->exec('DELETE FROM attendees');
|
|
if ($hasWorkshops) {
|
|
$db->exec('DELETE FROM workshop_sessions');
|
|
}
|
|
}
|
|
|
|
// Pre-populate workshop cache from existing sessions (for overwrite mode)
|
|
$workshopCache = [];
|
|
if ($hasWorkshops && $mode !== 'clear') {
|
|
foreach ($db->query('SELECT id, workshop_name, workshop_time FROM workshop_sessions')->fetchAll() as $ws) {
|
|
$workshopCache[$ws['workshop_name'] . '|' . ($ws['workshop_time'] ?? '')] = (int) $ws['id'];
|
|
}
|
|
}
|
|
|
|
$workshopStmt = $db->prepare(
|
|
'INSERT INTO workshop_sessions (workshop_name, workshop_time) VALUES (:name, :time)'
|
|
);
|
|
$regStmt = $db->prepare(
|
|
'INSERT INTO registrations (attendee_id, workshop_session_id) VALUES (:attendee_id, :ws_id)'
|
|
);
|
|
$insertStmt = $db->prepare(
|
|
'INSERT INTO attendees (first_name, last_name, email, mobile_number)
|
|
VALUES (:first_name, :last_name, :email, :mobile_number)
|
|
ON DUPLICATE KEY UPDATE
|
|
first_name = VALUES(first_name),
|
|
last_name = VALUES(last_name),
|
|
email = VALUES(email),
|
|
id = LAST_INSERT_ID(id)'
|
|
);
|
|
$findByEmailStmt = $db->prepare(
|
|
"SELECT id FROM attendees WHERE email = :email AND email != ''"
|
|
);
|
|
$updateStmt = $db->prepare(
|
|
'UPDATE attendees SET first_name=:first_name, last_name=:last_name, email=:email, mobile_number=:mobile_number WHERE id=:id'
|
|
);
|
|
$deleteRegsStmt = $db->prepare(
|
|
'DELETE FROM registrations WHERE attendee_id = :attendee_id'
|
|
);
|
|
|
|
$imported = 0;
|
|
$updated = 0;
|
|
|
|
foreach ($validRows as $vr) {
|
|
$firstName = $vr['first_name'];
|
|
$lastName = $vr['last_name'];
|
|
$email = $vr['email'];
|
|
$mobile = $vr['mobile_number'];
|
|
|
|
if ($mode === 'clear') {
|
|
$insertStmt->execute([
|
|
':first_name' => $firstName,
|
|
':last_name' => $lastName,
|
|
':email' => $email,
|
|
':mobile_number' => $mobile,
|
|
]);
|
|
$attendeeId = (int) $db->lastInsertId();
|
|
// rowCount: 1 = new insert, 2 = ON DUPLICATE KEY UPDATE fired
|
|
if ($insertStmt->rowCount() >= 2) { $updated++; } else { $imported++; }
|
|
} else {
|
|
$matches = [];
|
|
if ($email !== '') {
|
|
$findByEmailStmt->execute([':email' => $email]);
|
|
$matches = $findByEmailStmt->fetchAll();
|
|
}
|
|
|
|
if (count($matches) === 0) {
|
|
$insertStmt->execute([
|
|
':first_name' => $firstName,
|
|
':last_name' => $lastName,
|
|
':email' => $email,
|
|
':mobile_number' => $mobile,
|
|
]);
|
|
$attendeeId = (int) $db->lastInsertId();
|
|
$imported++;
|
|
} else {
|
|
$attendeeId = (int) $matches[0]['id'];
|
|
$updateStmt->execute([
|
|
':first_name' => $firstName,
|
|
':last_name' => $lastName,
|
|
':email' => $email,
|
|
':mobile_number' => $mobile,
|
|
':id' => $attendeeId,
|
|
]);
|
|
$deleteRegsStmt->execute([':attendee_id' => $attendeeId]);
|
|
if (count($matches) > 1) {
|
|
$extraIds = array_column(array_slice($matches, 1), 'id');
|
|
$placeholders = implode(',', array_fill(0, count($extraIds), '?'));
|
|
$db->prepare("DELETE FROM attendees WHERE id IN ($placeholders)")
|
|
->execute($extraIds);
|
|
}
|
|
$updated++;
|
|
}
|
|
}
|
|
|
|
if ($hasWorkshops && $vr['workshop_name'] !== '') {
|
|
$cacheKey = $vr['workshop_name'] . '|' . ($vr['workshop_time'] ?? '');
|
|
if (!isset($workshopCache[$cacheKey])) {
|
|
$workshopStmt->execute([':name' => $vr['workshop_name'], ':time' => $vr['workshop_time']]);
|
|
$workshopCache[$cacheKey] = (int) $db->lastInsertId();
|
|
}
|
|
$regStmt->execute([':attendee_id' => $attendeeId, ':ws_id' => $workshopCache[$cacheKey]]);
|
|
}
|
|
}
|
|
|
|
$db->commit();
|
|
|
|
json_out(['imported' => $imported, 'updated' => $updated, 'skipped' => $skipped, 'skip_reasons' => $skipReasons, 'workshops_imported' => $hasWorkshops, 'rejected' => $rejected]);
|
|
}
|
|
|
|
// ─── PUT /api/attendees.php ───────────────────────────────────────────────────
|
|
// Body: { id, first_name, last_name, email, mobile_number,
|
|
// workshop_name, workshop_time (ISO datetime or empty) }
|
|
// Updates a single attendee and replaces their workshop registration.
|
|
|
|
function handle_put(): never
|
|
{
|
|
$body = json_decode((string) file_get_contents('php://input'), true);
|
|
|
|
$id = (int) ($body['id'] ?? 0);
|
|
$firstName = trim((string) ($body['first_name'] ?? ''));
|
|
$lastName = trim((string) ($body['last_name'] ?? ''));
|
|
$email = trim((string) ($body['email'] ?? ''));
|
|
$rawMobile = trim((string) ($body['mobile_number'] ?? ''));
|
|
$workshopName = trim((string) ($body['workshop_name'] ?? ''));
|
|
$workshopTime = trim((string) ($body['workshop_time'] ?? ''));
|
|
|
|
if (!$firstName) error_out('first_name is required');
|
|
if (!$lastName) error_out('last_name is required');
|
|
|
|
$mobile = sanitise_mobile($rawMobile);
|
|
if ($mobile === '') error_out('A valid mobile_number is required');
|
|
|
|
$db = db();
|
|
$db->beginTransaction();
|
|
|
|
if ($id <= 0) {
|
|
// Insert new attendee (used when manually adding a previously-rejected row)
|
|
$db->prepare(
|
|
'INSERT INTO attendees (first_name, last_name, email, mobile_number) VALUES (:fn, :ln, :email, :mobile)'
|
|
)->execute([':fn' => $firstName, ':ln' => $lastName, ':email' => $email, ':mobile' => $mobile]);
|
|
$id = (int) $db->lastInsertId();
|
|
} else {
|
|
$check = $db->prepare('SELECT id FROM attendees WHERE id = :id');
|
|
$check->execute([':id' => $id]);
|
|
if (!$check->fetch()) { $db->rollBack(); error_out('Attendee not found', 404); }
|
|
|
|
$db->prepare(
|
|
'UPDATE attendees SET first_name=:fn, last_name=:ln, email=:email, mobile_number=:mobile WHERE id=:id'
|
|
)->execute([':fn' => $firstName, ':ln' => $lastName, ':email' => $email, ':mobile' => $mobile, ':id' => $id]);
|
|
|
|
$db->prepare('DELETE FROM registrations WHERE attendee_id = :id')->execute([':id' => $id]);
|
|
}
|
|
|
|
$workshopSessionId = isset($body['workshop_session_id']) ? (int) $body['workshop_session_id'] : null;
|
|
|
|
if ($workshopSessionId !== null && $workshopSessionId > 0) {
|
|
$verify = $db->prepare('SELECT id FROM workshop_sessions WHERE id = :id');
|
|
$verify->execute([':id' => $workshopSessionId]);
|
|
if (!$verify->fetch()) error_out('Workshop session not found', 404);
|
|
$db->prepare('INSERT INTO registrations (attendee_id, workshop_session_id) VALUES (:aid, :wsid)')
|
|
->execute([':aid' => $id, ':wsid' => $workshopSessionId]);
|
|
} elseif ($workshopName !== '') {
|
|
$dt = null;
|
|
if ($workshopTime !== '') {
|
|
$parsed = strtotime($workshopTime);
|
|
$dt = $parsed !== false ? date('Y-m-d H:i:s', $parsed) : null;
|
|
}
|
|
|
|
$find = $db->prepare(
|
|
'SELECT id FROM workshop_sessions
|
|
WHERE workshop_name = :name
|
|
AND ((:time IS NULL AND workshop_time IS NULL) OR workshop_time = :time)'
|
|
);
|
|
$find->execute([':name' => $workshopName, ':time' => $dt]);
|
|
$ws = $find->fetch();
|
|
|
|
if ($ws) {
|
|
$wsId = (int) $ws['id'];
|
|
} else {
|
|
$db->prepare('INSERT INTO workshop_sessions (workshop_name, workshop_time) VALUES (:name, :time)')
|
|
->execute([':name' => $workshopName, ':time' => $dt]);
|
|
$wsId = (int) $db->lastInsertId();
|
|
}
|
|
|
|
$db->prepare('INSERT INTO registrations (attendee_id, workshop_session_id) VALUES (:aid, :wsid)')
|
|
->execute([':aid' => $id, ':wsid' => $wsId]);
|
|
}
|
|
|
|
$db->commit();
|
|
json_out(['updated' => true]);
|
|
}
|
|
|
|
// ─── DELETE /api/attendees.php ────────────────────────────────────────────────
|
|
// Removes all attendees (and their registrations via CASCADE).
|
|
|
|
function handle_delete(): never
|
|
{
|
|
$db = db();
|
|
$db->exec('DELETE FROM registrations');
|
|
$db->exec('DELETE FROM attendees');
|
|
$db->exec('DELETE FROM workshop_sessions');
|
|
json_out(['deleted' => true]);
|
|
}
|
|
|
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
|
|
function find_col(array $headers, array $candidates): ?int
|
|
{
|
|
foreach ($candidates as $candidate) {
|
|
$idx = array_search($candidate, $headers, true);
|
|
if ($idx !== false) {
|
|
return (int) $idx;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function sanitise_mobile(string $number): string
|
|
{
|
|
$stripped = trim($number, " \t\n\r\0\x0B\"'");
|
|
$prefix = str_starts_with($stripped, '+') ? '+' : '';
|
|
$digits = (string) preg_replace('/\D/', '', $stripped);
|
|
$normalised = $prefix . $digits;
|
|
|
|
// Accept UK mobile numbers only: +447XXXXXXXXX, 07XXXXXXXXX, or 7XXXXXXXXX (9 digits after the prefix)
|
|
if (!preg_match('/^(\+447|07|7)\d{9}$/', $normalised)) {
|
|
return '';
|
|
}
|
|
|
|
// Normalise all variants to international format: +447XXXXXXXXX
|
|
if (str_starts_with($normalised, '07')) {
|
|
return '+44' . substr($normalised, 1);
|
|
}
|
|
if (str_starts_with($normalised, '7')) {
|
|
return '+447' . substr($normalised, 1);
|
|
}
|
|
return $normalised; // already +447…
|
|
}
|
|
|
|
/**
|
|
* Returns a rejection reason string for a mobile number that failed sanitise_mobile.
|
|
* Reasons: 'no_mobile' | 'foreign_country_code' | 'invalid_number'
|
|
*/
|
|
function classify_mobile_rejection(string $number): string
|
|
{
|
|
$stripped = trim($number, " \t\n\r\0\x0B\"'");
|
|
$prefix = str_starts_with($stripped, '+') ? '+' : '';
|
|
$digits = (string) preg_replace('/\D/', '', $stripped);
|
|
$normalised = $prefix . $digits;
|
|
|
|
if ($normalised === '' || strlen($digits) < 3) {
|
|
return 'no_mobile';
|
|
}
|
|
if (str_starts_with($normalised, '+') && !str_starts_with($normalised, '+44')) {
|
|
return 'foreign_country_code';
|
|
}
|
|
return 'invalid_number';
|
|
}
|
|
|
|
/**
|
|
* Extracts the first recognisable time token from a string.
|
|
* e.g. "Magic / Illusion Workshop 1pm" → "1pm"
|
|
* "Axe Throwing 13:30" → "13:30"
|
|
*/
|
|
function extract_time_from_string(string $str): ?string
|
|
{
|
|
if (preg_match('/\b(\d{1,2}:\d{2}\s*(?:am|pm)?|\d{1,2}\s*(?:am|pm))\b/i', $str, $m)) {
|
|
return trim($m[1]);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Removes the first recognisable time token (and any surrounding separators/spaces)
|
|
* from a string, returning the cleaned name.
|
|
* e.g. "Magic / Illusion Workshop 1pm" → "Magic / Illusion Workshop"
|
|
*/
|
|
function trim_time_from_string(string $str): string
|
|
{
|
|
$cleaned = preg_replace('/[\s\-–—_,]+\b\d{1,2}:\d{2}\s*(?:am|pm)?\b/i', '', $str);
|
|
$cleaned = preg_replace('/[\s\-–—_,]+\b\d{1,2}\s*(?:am|pm)\b/i', '', (string) $cleaned);
|
|
return trim((string) $cleaned);
|
|
}
|