diff --git a/2index.php b/2index.php
index a110c43..384fa43 100644
--- a/2index.php
+++ b/2index.php
@@ -136,7 +136,7 @@ include __DIR__.'/includes/header.php';
All Events
diff --git a/README.md b/README.md
index 4f8fdb0..1ff2e49 100644
--- a/README.md
+++ b/README.md
@@ -69,11 +69,24 @@ php -S localhost:8080
- **Alerts** — post/manage business announcements
- **Attractions** — full CRUD for attractions
- **Events** — approve/reject submissions, add events, feature events
+- **Event Imports** — review Facebook event candidates staged by the CLI importer
- **Users** — add/edit/delete users, reset passwords, suspend accounts
- **Assign Owners** — link users to businesses for self-management
- **Reports** — review and close user-submitted listing corrections
- **Settings** — toggle registration on/off, event approval, change admin password
+### Facebook Event Import Workflow
+
+Stage Facebook event candidates from the official Facebook Graph API, then review and import them from Admin -> Event Imports:
+
+```bash
+FACEBOOK_GRAPH_ACCESS_TOKEN=... php scripts/facebook-events-import.php --area="Keyser, WV" --page-id=123456789 --limit=20
+php scripts/facebook-events-import.php --access-token=... --event-id=123456789012345
+php scripts/facebook-events-import.php --access-token=... --area="Keyser, WV" --center=39.4364,-78.9762 --distance=25000 --query="Keyser" --discover-pages
+```
+
+The importer does not scrape Facebook, log in, or bypass access controls. Your Meta app/token must have permission to read the requested Page/Event data through Graph API.
+
---
## FILE STRUCTURE
@@ -86,6 +99,7 @@ keyser-wv/
├── menu.php # Owner menu manager
├── attractions.php # Attractions page
├── events.php # Events calendar + submission
+├── event.php # Event detail page
├── about.php # About Keyser WV history
├── login.php # Login
├── register.php # Registration
@@ -95,6 +109,7 @@ keyser-wv/
├── includes/
│ ├── boot.php # Bootstrap (session + DB init)
│ ├── db.php # Database, schema, seed data
+│ ├── facebook_event_importer.php # Facebook event parser/import helpers
│ ├── functions.php # Auth, helpers, CSRF, stars
│ ├── header.php # HTML header + navbar
│ └── footer.php # Footer + JS
@@ -107,10 +122,13 @@ keyser-wv/
│ ├── alerts.php # Manage alerts
│ ├── attractions.php # Manage attractions
│ ├── events.php # Manage events
+│ ├── event_imports.php # Review staged Facebook event imports
│ ├── users.php # Manage users
│ ├── owners.php # Assign owners to businesses
│ ├── reports.php # Handle listing reports
│ └── settings.php # Site settings + password
+├── scripts/
+│ └── facebook-events-import.php # CLI Facebook event stager
├── assets/
│ ├── css/style.css # Complete dark WV theme
│ └── js/app.js # Nav, dropdowns, confirm dialogs
diff --git a/admin/admin_header.php b/admin/admin_header.php
index a382137..4180433 100644
--- a/admin/admin_header.php
+++ b/admin/admin_header.php
@@ -7,6 +7,7 @@ include dirname(__DIR__).'/includes/header.php';
// Pending owner edit count for badge
$_pendingEdits = (int)qval("SELECT COUNT(*) FROM business_edit_requests WHERE status='pending'");
$_pendingClaims = (int)qval("SELECT COUNT(*) FROM business_claim_requests WHERE status='pending'");
+$_pendingEventImports = (int)qval("SELECT COUNT(*) FROM event_imports WHERE status='staged'");
?>
+
1): ?>
diff --git a/includes/db.php b/includes/db.php
index 21f53f1..f441d0c 100644
--- a/includes/db.php
+++ b/includes/db.php
@@ -95,6 +95,34 @@ function _ensureAppUpgrades(PDO $db): void {
created_at TEXT NOT NULL DEFAULT(datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_email_tokens_user ON email_verification_tokens(user_id, purpose, used_at);
+ CREATE TABLE IF NOT EXISTS event_imports (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ provider TEXT NOT NULL DEFAULT 'facebook',
+ source_area TEXT NOT NULL DEFAULT '',
+ source_url TEXT NOT NULL DEFAULT '',
+ external_id TEXT NOT NULL DEFAULT '',
+ title TEXT NOT NULL DEFAULT '',
+ description TEXT NOT NULL DEFAULT '',
+ venue TEXT NOT NULL DEFAULT '',
+ address TEXT NOT NULL DEFAULT '',
+ event_date TEXT NOT NULL DEFAULT '',
+ event_time TEXT NOT NULL DEFAULT '',
+ end_date TEXT,
+ end_time TEXT NOT NULL DEFAULT '',
+ cost TEXT NOT NULL DEFAULT 'Free',
+ website TEXT NOT NULL DEFAULT '',
+ image_url TEXT NOT NULL DEFAULT '',
+ image_path TEXT NOT NULL DEFAULT '',
+ raw_json TEXT NOT NULL DEFAULT '{}',
+ status TEXT NOT NULL DEFAULT 'staged',
+ status_note TEXT NOT NULL DEFAULT '',
+ imported_event_id INTEGER REFERENCES events(id) ON DELETE SET NULL,
+ created_at TEXT NOT NULL DEFAULT(datetime('now')),
+ updated_at TEXT NOT NULL DEFAULT(datetime('now'))
+ );
+ CREATE INDEX IF NOT EXISTS idx_event_imports_status ON event_imports(status, created_at);
+ CREATE INDEX IF NOT EXISTS idx_event_imports_external ON event_imports(provider, external_id);
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_event_imports_source ON event_imports(provider, source_url) WHERE source_url <> '';
CREATE TABLE IF NOT EXISTS forum_categories (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
@@ -329,6 +357,31 @@ function _schema(PDO $db): void {
is_featured INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT(datetime('now'))
);
+ CREATE TABLE IF NOT EXISTS event_imports(
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ provider TEXT NOT NULL DEFAULT 'facebook',
+ source_area TEXT NOT NULL DEFAULT '',
+ source_url TEXT NOT NULL DEFAULT '',
+ external_id TEXT NOT NULL DEFAULT '',
+ title TEXT NOT NULL DEFAULT '',
+ description TEXT NOT NULL DEFAULT '',
+ venue TEXT NOT NULL DEFAULT '',
+ address TEXT NOT NULL DEFAULT '',
+ event_date TEXT NOT NULL DEFAULT '',
+ event_time TEXT NOT NULL DEFAULT '',
+ end_date TEXT,
+ end_time TEXT NOT NULL DEFAULT '',
+ cost TEXT NOT NULL DEFAULT 'Free',
+ website TEXT NOT NULL DEFAULT '',
+ image_url TEXT NOT NULL DEFAULT '',
+ image_path TEXT NOT NULL DEFAULT '',
+ raw_json TEXT NOT NULL DEFAULT '{}',
+ status TEXT NOT NULL DEFAULT 'staged',
+ status_note TEXT NOT NULL DEFAULT '',
+ imported_event_id INTEGER REFERENCES events(id) ON DELETE SET NULL,
+ created_at TEXT NOT NULL DEFAULT(datetime('now')),
+ updated_at TEXT NOT NULL DEFAULT(datetime('now'))
+ );
CREATE TABLE IF NOT EXISTS reports(
id INTEGER PRIMARY KEY AUTOINCREMENT,
business_id INTEGER NOT NULL REFERENCES businesses(id) ON DELETE CASCADE,
diff --git a/includes/facebook_event_importer.php b/includes/facebook_event_importer.php
new file mode 100644
index 0000000..c4a953c
--- /dev/null
+++ b/includes/facebook_event_importer.php
@@ -0,0 +1,601 @@
+ false, 'error' => 'Invalid URL.', 'body' => '', 'url' => ''];
+ if (function_exists('curl_init')) {
+ $ch = curl_init($url);
+ curl_setopt_array($ch, [
+ CURLOPT_RETURNTRANSFER => true,
+ CURLOPT_FOLLOWLOCATION => true,
+ CURLOPT_MAXREDIRS => 4,
+ CURLOPT_TIMEOUT => 25,
+ CURLOPT_CONNECTTIMEOUT => 10,
+ CURLOPT_USERAGENT => facebookEventImporterUserAgent(),
+ CURLOPT_HTTPHEADER => ['Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8'],
+ ]);
+ $body = curl_exec($ch);
+ $errno = curl_errno($ch);
+ $error = curl_error($ch);
+ $status = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
+ $finalUrl = (string)curl_getinfo($ch, CURLINFO_EFFECTIVE_URL);
+ if (PHP_VERSION_ID < 80000) curl_close($ch);
+ if ($errno) return ['ok' => false, 'error' => $error ?: 'Request failed.', 'body' => '', 'url' => $url];
+ if ($status < 200 || $status >= 400) return ['ok' => false, 'error' => 'HTTP '.$status.' from Facebook.', 'body' => (string)$body, 'url' => $finalUrl ?: $url];
+ return ['ok' => true, 'error' => '', 'body' => (string)$body, 'url' => $finalUrl ?: $url];
+ }
+
+ $context = stream_context_create([
+ 'http' => [
+ 'method' => 'GET',
+ 'timeout' => 25,
+ 'ignore_errors' => true,
+ 'header' => "User-Agent: ".facebookEventImporterUserAgent()."\r\nAccept: text/html\r\n",
+ ],
+ ]);
+ $body = @file_get_contents($url, false, $context);
+ $statusLine = $http_response_header[0] ?? '';
+ if ($body === false || ($statusLine && !preg_match('/\s[23]\d\d\s/', $statusLine))) {
+ return ['ok' => false, 'error' => $statusLine ?: 'Request failed.', 'body' => (string)$body, 'url' => $url];
+ }
+ return ['ok' => true, 'error' => '', 'body' => (string)$body, 'url' => $url];
+}
+
+function facebookEventDom(string $html): ?DOMDocument {
+ if (!class_exists('DOMDocument')) return null;
+ $dom = new DOMDocument();
+ $old = libxml_use_internal_errors(true);
+ $loaded = $dom->loadHTML(''.$html);
+ libxml_clear_errors();
+ libxml_use_internal_errors($old);
+ return $loaded ? $dom : null;
+}
+
+function facebookEventMeta(string $html): array {
+ $dom = facebookEventDom($html);
+ if (!$dom) return [];
+ $out = [];
+ foreach ($dom->getElementsByTagName('meta') as $meta) {
+ $key = $meta->getAttribute('property') ?: $meta->getAttribute('name');
+ $content = $meta->getAttribute('content');
+ if ($key !== '' && $content !== '') $out[strtolower($key)] = $content;
+ }
+ foreach ($dom->getElementsByTagName('link') as $link) {
+ if (strtolower($link->getAttribute('rel')) === 'canonical' && $link->getAttribute('href') !== '') {
+ $out['canonical'] = $link->getAttribute('href');
+ }
+ }
+ return $out;
+}
+
+function facebookEventFindJsonLdEvent(mixed $node): ?array {
+ if (!is_array($node)) return null;
+ $type = $node['@type'] ?? '';
+ $types = is_array($type) ? $type : [$type];
+ foreach ($types as $candidateType) {
+ if (strtolower((string)$candidateType) === 'event') return $node;
+ }
+ foreach ($node as $child) {
+ $found = facebookEventFindJsonLdEvent($child);
+ if ($found) return $found;
+ }
+ return null;
+}
+
+function facebookEventJsonLd(string $html): ?array {
+ $dom = facebookEventDom($html);
+ if (!$dom) return null;
+ foreach ($dom->getElementsByTagName('script') as $script) {
+ $type = strtolower($script->getAttribute('type'));
+ if ($type !== 'application/ld+json') continue;
+ $json = trim($script->textContent ?? '');
+ if ($json === '') continue;
+ $decoded = json_decode($json, true);
+ $found = facebookEventFindJsonLdEvent($decoded);
+ if ($found) return $found;
+ }
+ return null;
+}
+
+function facebookEventImageFromValue(mixed $value): string {
+ if (is_string($value)) return $value;
+ if (is_array($value)) {
+ if (isset($value['url'])) return facebookEventImageFromValue($value['url']);
+ foreach ($value as $item) {
+ $image = facebookEventImageFromValue($item);
+ if ($image !== '') return $image;
+ }
+ }
+ return '';
+}
+
+function facebookEventAddressFromLocation(mixed $location): array {
+ $venue = '';
+ $address = '';
+ if (is_string($location)) return [$location, ''];
+ if (!is_array($location)) return ['', ''];
+
+ $venue = facebookEventText($location['name'] ?? '');
+ $addr = $location['address'] ?? '';
+ if (is_string($addr)) {
+ $address = facebookEventText($addr);
+ } elseif (is_array($addr)) {
+ $parts = [];
+ foreach (['streetAddress', 'addressLocality', 'addressRegion', 'postalCode'] as $key) {
+ if (!empty($addr[$key])) $parts[] = facebookEventText($addr[$key]);
+ }
+ $address = implode(', ', array_filter($parts));
+ }
+ return [$venue, $address];
+}
+
+function facebookEventSplitDateTime(string $value): array {
+ $value = facebookEventText(str_replace([' at ', ' AT '], ' ', $value));
+ if ($value === '') return ['', ''];
+ $hasTime = (bool)preg_match('/(?:T|\b\d{1,2}:\d{2}\b|\b\d{1,2}\s*(?:am|pm)\b)/i', $value);
+ try {
+ $dt = new DateTimeImmutable($value);
+ } catch (Throwable) {
+ return ['', ''];
+ }
+ return [$dt->format('Y-m-d'), $hasTime ? $dt->format('g:i A') : ''];
+}
+
+function facebookEventDateFromText(string $text): array {
+ $months = 'Jan(?:uary)?|Feb(?:ruary)?|Mar(?:ch)?|Apr(?:il)?|May|Jun(?:e)?|Jul(?:y)?|Aug(?:ust)?|Sep(?:t(?:ember)?)?|Oct(?:ober)?|Nov(?:ember)?|Dec(?:ember)?';
+ if (preg_match('/\b(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun)?(?:day)?[,]?\s*(?:'.$months.')\s+\d{1,2}(?:,\s*\d{4})?(?:\s+(?:at|@)\s+\d{1,2}(?::\d{2})?\s*(?:am|pm)?)?/i', $text, $match)) {
+ return facebookEventSplitDateTime($match[0]);
+ }
+ if (preg_match('/\b\d{4}-\d{2}-\d{2}(?:[T\s]\d{1,2}:\d{2}(?::\d{2})?)?/i', $text, $match)) {
+ return facebookEventSplitDateTime($match[0]);
+ }
+ return ['', ''];
+}
+
+function facebookEventCleanTitle(string $title): string {
+ $title = facebookEventText($title);
+ $title = preg_replace('/\s*\|\s*Facebook\s*$/i', '', $title);
+ $title = preg_replace('/\s*-\s*Facebook\s*$/i', '', $title);
+ return trim($title);
+}
+
+function facebookEventWebsiteForStorage(string $url): string {
+ $url = trim($url);
+ if ($url === '') return '';
+ $url = preg_replace('~^https?://~i', '', $url);
+ return preg_replace('~/+$~', '', $url);
+}
+
+function facebookEventExtractEventUrls(string $html, string $sourceUrl = 'https://www.facebook.com'): array {
+ $links = [];
+ $dom = facebookEventDom($html);
+ if ($dom) {
+ foreach ($dom->getElementsByTagName('a') as $a) {
+ $href = $a->getAttribute('href');
+ $url = facebookEventNormalizeEventUrl($href, $sourceUrl);
+ if ($url !== '') $links[$url] = $url;
+ }
+ }
+
+ $decoded = html_entity_decode($html, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
+ $decoded = str_replace(['\\/', '\\u002F', '\\u003A', '\\u0026'], ['/', '/', ':', '&'], $decoded);
+ if (preg_match_all('~https?://(?:www\.)?facebook\.com/(?:events|share)/[^\s"\'<>\\\\]+~i', $decoded, $matches)) {
+ foreach ($matches[0] as $raw) {
+ $url = facebookEventNormalizeEventUrl($raw, $sourceUrl);
+ if ($url !== '') $links[$url] = $url;
+ }
+ }
+ if (preg_match_all('~href=["\']([^"\']*(?:/events/|/share/)[^"\']*)["\']~i', $decoded, $matches)) {
+ foreach ($matches[1] as $raw) {
+ $url = facebookEventNormalizeEventUrl($raw, $sourceUrl);
+ if ($url !== '') $links[$url] = $url;
+ }
+ }
+ return array_values($links);
+}
+
+function facebookEventParseHtml(string $html, string $sourceUrl = '', string $area = ''): array {
+ $jsonLd = facebookEventJsonLd($html);
+ $meta = facebookEventMeta($html);
+ $sourceUrl = facebookEventNormalizeEventUrl($sourceUrl ?: ($meta['og:url'] ?? $meta['canonical'] ?? '')) ?: facebookEventNormalizeUrl($sourceUrl ?: ($meta['og:url'] ?? $meta['canonical'] ?? ''));
+
+ $candidate = [
+ 'provider' => 'facebook',
+ 'source_area' => $area,
+ 'source_url' => $sourceUrl,
+ 'external_id' => facebookEventExternalId($sourceUrl),
+ 'title' => '',
+ 'description' => '',
+ 'venue' => '',
+ 'address' => '',
+ 'event_date' => '',
+ 'event_time' => '',
+ 'end_date' => null,
+ 'end_time' => '',
+ 'cost' => 'Free',
+ 'website' => facebookEventWebsiteForStorage($sourceUrl),
+ 'image_url' => '',
+ 'image_path' => '',
+ 'raw_json' => ['json_ld' => $jsonLd, 'meta' => $meta],
+ ];
+
+ if ($jsonLd) {
+ $candidate['title'] = facebookEventCleanTitle((string)($jsonLd['name'] ?? ''));
+ $candidate['description'] = facebookEventText($jsonLd['description'] ?? '');
+ [$candidate['event_date'], $candidate['event_time']] = facebookEventSplitDateTime((string)($jsonLd['startDate'] ?? ''));
+ [$endDate, $endTime] = facebookEventSplitDateTime((string)($jsonLd['endDate'] ?? ''));
+ $candidate['end_date'] = $endDate ?: null;
+ $candidate['end_time'] = $endTime;
+ [$candidate['venue'], $candidate['address']] = facebookEventAddressFromLocation($jsonLd['location'] ?? null);
+ $candidate['image_url'] = facebookEventNormalizeUrl(facebookEventImageFromValue($jsonLd['image'] ?? ''), $sourceUrl ?: 'https://www.facebook.com');
+ if (!empty($jsonLd['url'])) {
+ $candidate['source_url'] = facebookEventNormalizeEventUrl((string)$jsonLd['url'], $sourceUrl ?: 'https://www.facebook.com') ?: $candidate['source_url'];
+ $candidate['website'] = facebookEventWebsiteForStorage($candidate['source_url']);
+ $candidate['external_id'] = facebookEventExternalId($candidate['source_url']);
+ }
+ if (!empty($jsonLd['offers']['price'])) $candidate['cost'] = facebookEventText($jsonLd['offers']['price']);
+ }
+
+ if ($candidate['title'] === '') $candidate['title'] = facebookEventCleanTitle($meta['og:title'] ?? $meta['twitter:title'] ?? '');
+ if ($candidate['description'] === '') $candidate['description'] = facebookEventText($meta['og:description'] ?? $meta['description'] ?? $meta['twitter:description'] ?? '');
+ if ($candidate['image_url'] === '') $candidate['image_url'] = facebookEventNormalizeUrl($meta['og:image'] ?? $meta['twitter:image'] ?? '', $sourceUrl ?: 'https://www.facebook.com');
+ if ($candidate['event_date'] === '') {
+ [$candidate['event_date'], $candidate['event_time']] = facebookEventDateFromText($candidate['title'].' '.$candidate['description']);
+ }
+ if ($candidate['source_url'] === '' && !empty($meta['og:url'])) {
+ $candidate['source_url'] = facebookEventNormalizeUrl($meta['og:url']);
+ $candidate['website'] = facebookEventWebsiteForStorage($candidate['source_url']);
+ $candidate['external_id'] = facebookEventExternalId($candidate['source_url']);
+ }
+ return $candidate;
+}
+
+function facebookGraphEventUrl(string $eventId): string {
+ $eventId = trim($eventId);
+ return $eventId !== '' ? 'https://www.facebook.com/events/'.rawurlencode($eventId) : '';
+}
+
+function facebookGraphPlaceToVenueAddress(mixed $place): array {
+ if (!is_array($place)) return ['', ''];
+ $venue = facebookEventText($place['name'] ?? '');
+ $location = $place['location'] ?? [];
+ if (!is_array($location)) return [$venue, ''];
+
+ $parts = [];
+ foreach (['street', 'city', 'state', 'zip', 'country'] as $key) {
+ if (!empty($location[$key])) $parts[] = facebookEventText($location[$key]);
+ }
+ return [$venue, implode(', ', array_filter($parts))];
+}
+
+function facebookGraphEventToCandidate(array $event, string $area = ''): array {
+ $eventId = trim((string)($event['id'] ?? ''));
+ $sourceUrl = facebookGraphEventUrl($eventId);
+ [$eventDate, $eventTime] = facebookEventSplitDateTime((string)($event['start_time'] ?? ''));
+ [$endDate, $endTime] = facebookEventSplitDateTime((string)($event['end_time'] ?? ''));
+ [$venue, $address] = facebookGraphPlaceToVenueAddress($event['place'] ?? null);
+ $ticketUrl = facebookEventNormalizeUrl((string)($event['ticket_uri'] ?? ''));
+ $website = $ticketUrl !== '' ? $ticketUrl : $sourceUrl;
+ $cover = $event['cover'] ?? [];
+ $imageUrl = is_array($cover) ? facebookEventNormalizeUrl((string)($cover['source'] ?? '')) : '';
+
+ return [
+ 'provider' => 'facebook',
+ 'source_area' => $area,
+ 'source_url' => $sourceUrl,
+ 'external_id' => $eventId,
+ 'title' => facebookEventCleanTitle((string)($event['name'] ?? '')),
+ 'description' => facebookEventText($event['description'] ?? ''),
+ 'venue' => $venue,
+ 'address' => $address,
+ 'event_date' => $eventDate,
+ 'event_time' => $eventTime,
+ 'end_date' => $endDate ?: null,
+ 'end_time' => $endTime,
+ 'cost' => 'Free',
+ 'website' => facebookEventWebsiteForStorage($website),
+ 'image_url' => $imageUrl,
+ 'image_path' => '',
+ 'raw_json' => ['graph_event' => $event],
+ ];
+}
+
+function facebookEventDownloadImage(string $imageUrl): array {
+ $imageUrl = facebookEventNormalizeUrl($imageUrl);
+ if ($imageUrl === '') return ['ok' => true, 'path' => '', 'error' => ''];
+ if (!facebookEventAllowedImageHost($imageUrl)) {
+ return ['ok' => false, 'path' => '', 'error' => 'Image host is not a Facebook image host.'];
+ }
+ $maxBytes = mbToBytes(setting('event_upload_max_mb', '5'), 5);
+ $tmp = tempnam(sys_get_temp_dir(), 'fb-event-img-');
+ if (!$tmp) return ['ok' => false, 'path' => '', 'error' => 'Could not create a temporary image file.'];
+ $mime = '';
+ $size = 0;
+
+ if (function_exists('curl_init')) {
+ $fh = fopen($tmp, 'wb');
+ if (!$fh) return ['ok' => false, 'path' => '', 'error' => 'Could not write a temporary image file.'];
+ $ch = curl_init($imageUrl);
+ curl_setopt_array($ch, [
+ CURLOPT_FILE => $fh,
+ CURLOPT_FOLLOWLOCATION => true,
+ CURLOPT_MAXREDIRS => 4,
+ CURLOPT_TIMEOUT => 25,
+ CURLOPT_CONNECTTIMEOUT => 10,
+ CURLOPT_USERAGENT => facebookEventImporterUserAgent(),
+ CURLOPT_WRITEFUNCTION => function ($ch, string $chunk) use ($fh, $maxBytes, &$size): int {
+ $size += strlen($chunk);
+ if ($size > $maxBytes) return 0;
+ $written = fwrite($fh, $chunk);
+ return $written === false ? 0 : $written;
+ },
+ ]);
+ $ok = curl_exec($ch);
+ $errno = curl_errno($ch);
+ $error = curl_error($ch);
+ $status = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
+ $mime = (string)curl_getinfo($ch, CURLINFO_CONTENT_TYPE);
+ if (PHP_VERSION_ID < 80000) curl_close($ch);
+ fclose($fh);
+ if (!$ok || $errno || $status < 200 || $status >= 400) {
+ @unlink($tmp);
+ $message = $size > $maxBytes ? 'Image exceeds the configured size limit.' : ($error ?: 'Image download failed.');
+ return ['ok' => false, 'path' => '', 'error' => $message];
+ }
+ } else {
+ $context = stream_context_create(['http' => ['timeout' => 25, 'ignore_errors' => true, 'header' => 'User-Agent: '.facebookEventImporterUserAgent()."\r\n"]]);
+ $body = @file_get_contents($imageUrl, false, $context);
+ if ($body === false || strlen($body) > $maxBytes) {
+ @unlink($tmp);
+ return ['ok' => false, 'path' => '', 'error' => $body === false ? 'Image download failed.' : 'Image exceeds the configured size limit.'];
+ }
+ file_put_contents($tmp, $body);
+ }
+
+ if (!function_exists('getimagesize') || !@getimagesize($tmp)) {
+ @unlink($tmp);
+ return ['ok' => false, 'path' => '', 'error' => 'Downloaded file is not a valid image.'];
+ }
+ if ($mime === '' && function_exists('finfo_open')) {
+ $finfo = finfo_open(FILEINFO_MIME_TYPE);
+ if ($finfo) {
+ $mime = (string)finfo_file($finfo, $tmp);
+ finfo_close($finfo);
+ }
+ }
+ $ext = match (strtolower(strtok($mime, ';') ?: '')) {
+ 'image/png' => 'png',
+ 'image/gif' => 'gif',
+ 'image/webp' => 'webp',
+ default => 'jpg',
+ };
+ $stored = storeUploadedFile(
+ ['name' => 'facebook-event.'.$ext, 'tmp_name' => $tmp, 'error' => UPLOAD_ERR_OK, 'size' => filesize($tmp) ?: 0],
+ 'events',
+ imageExts(),
+ $maxBytes,
+ [
+ 'image_only' => true,
+ 'resize_over_bytes' => mbToBytes(setting('forum_image_resize_threshold_mb', '1'), 1),
+ 'image_max_width' => (int)setting('forum_image_max_width', '1600'),
+ 'image_quality' => (int)setting('forum_image_quality', '82'),
+ ]
+ );
+ if (!$stored['ok']) @unlink($tmp);
+ return $stored['ok']
+ ? ['ok' => true, 'path' => (string)$stored['path'], 'error' => '']
+ : ['ok' => false, 'path' => '', 'error' => $stored['error'] ?? 'Image could not be saved.'];
+}
+
+function facebookEventCandidateIssues(array $candidate): array {
+ $issues = [];
+ if (trim((string)($candidate['title'] ?? '')) === '') $issues[] = 'Missing title';
+ $date = trim((string)($candidate['event_date'] ?? ''));
+ $dt = DateTimeImmutable::createFromFormat('!Y-m-d', $date);
+ if ($date === '' || !$dt || $dt->format('Y-m-d') !== $date) $issues[] = 'Missing valid start date';
+ return $issues;
+}
+
+function facebookEventDuplicateId(array $candidate): int {
+ $title = trim((string)($candidate['title'] ?? ''));
+ $date = trim((string)($candidate['event_date'] ?? ''));
+ if ($title === '' || $date === '') return 0;
+ return (int)qval("SELECT id FROM events WHERE lower(title)=lower(?) AND event_date=? LIMIT 1", [$title, $date]);
+}
+
+function facebookEventStageCandidate(array $candidate, bool $downloadImage = true): array {
+ $provider = 'facebook';
+ $sourceUrl = facebookEventNormalizeEventUrl((string)($candidate['source_url'] ?? '')) ?: facebookEventNormalizeUrl((string)($candidate['source_url'] ?? ''));
+ $candidate['source_url'] = $sourceUrl;
+ $candidate['external_id'] = trim((string)($candidate['external_id'] ?? facebookEventExternalId($sourceUrl)));
+ $candidate['website'] = facebookEventWebsiteForStorage((string)($candidate['website'] ?? $sourceUrl));
+ $candidate['cost'] = trim((string)($candidate['cost'] ?? '')) ?: 'Free';
+ $candidate['end_date'] = ($candidate['end_date'] ?? '') ?: null;
+ $candidate['raw_json'] = $candidate['raw_json'] ?? [];
+
+ if ($downloadImage && empty($candidate['image_path']) && !empty($candidate['image_url'])) {
+ $image = facebookEventDownloadImage((string)$candidate['image_url']);
+ if ($image['ok'] && !empty($image['path'])) {
+ $candidate['image_path'] = $image['path'];
+ } elseif (!$image['ok']) {
+ $candidate['status_note'] = 'Image not downloaded: '.$image['error'];
+ }
+ }
+
+ $existing = null;
+ if ($sourceUrl !== '') $existing = qone("SELECT * FROM event_imports WHERE provider=? AND source_url=? LIMIT 1", [$provider, $sourceUrl]);
+ if (!$existing && $candidate['external_id'] !== '') {
+ $existing = qone("SELECT * FROM event_imports WHERE provider=? AND external_id=? LIMIT 1", [$provider, $candidate['external_id']]);
+ }
+
+ $fields = [
+ 'provider' => $provider,
+ 'source_area' => facebookEventText($candidate['source_area'] ?? ''),
+ 'source_url' => $sourceUrl,
+ 'external_id' => $candidate['external_id'],
+ 'title' => facebookEventCleanTitle((string)($candidate['title'] ?? '')),
+ 'description' => facebookEventText($candidate['description'] ?? ''),
+ 'venue' => facebookEventText($candidate['venue'] ?? ''),
+ 'address' => facebookEventText($candidate['address'] ?? ''),
+ 'event_date' => trim((string)($candidate['event_date'] ?? '')),
+ 'event_time' => facebookEventText($candidate['event_time'] ?? ''),
+ 'end_date' => $candidate['end_date'],
+ 'end_time' => facebookEventText($candidate['end_time'] ?? ''),
+ 'cost' => facebookEventText($candidate['cost']),
+ 'website' => facebookEventWebsiteForStorage((string)$candidate['website']),
+ 'image_url' => facebookEventNormalizeUrl((string)($candidate['image_url'] ?? ''), $sourceUrl ?: 'https://www.facebook.com'),
+ 'image_path' => trim((string)($candidate['image_path'] ?? '')),
+ 'raw_json' => json_encode($candidate['raw_json'], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?: '{}',
+ 'status_note' => facebookEventText($candidate['status_note'] ?? ''),
+ ];
+
+ if ($existing) {
+ if (($existing['status'] ?? '') === 'imported') {
+ return ['ok' => true, 'id' => (int)$existing['id'], 'action' => 'already_imported'];
+ }
+ qrun(
+ "UPDATE event_imports SET source_area=?,source_url=?,external_id=?,title=?,description=?,venue=?,address=?,event_date=?,event_time=?,end_date=?,end_time=?,cost=?,website=?,image_url=?,image_path=?,raw_json=?,status='staged',status_note=?,updated_at=datetime('now') WHERE id=?",
+ [$fields['source_area'], $fields['source_url'], $fields['external_id'], $fields['title'], $fields['description'], $fields['venue'], $fields['address'], $fields['event_date'], $fields['event_time'], $fields['end_date'], $fields['end_time'], $fields['cost'], $fields['website'], $fields['image_url'], $fields['image_path'], $fields['raw_json'], $fields['status_note'], (int)$existing['id']]
+ );
+ return ['ok' => true, 'id' => (int)$existing['id'], 'action' => 'updated'];
+ }
+
+ $id = qrun(
+ "INSERT INTO event_imports(provider,source_area,source_url,external_id,title,description,venue,address,event_date,event_time,end_date,end_time,cost,website,image_url,image_path,raw_json,status,status_note)VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,'staged',?)",
+ [$fields['provider'], $fields['source_area'], $fields['source_url'], $fields['external_id'], $fields['title'], $fields['description'], $fields['venue'], $fields['address'], $fields['event_date'], $fields['event_time'], $fields['end_date'], $fields['end_time'], $fields['cost'], $fields['website'], $fields['image_url'], $fields['image_path'], $fields['raw_json'], $fields['status_note']]
+ );
+ return ['ok' => true, 'id' => $id, 'action' => 'created'];
+}
+
+function facebookEventSaveCandidateFields(int $id, array $row): void {
+ qrun(
+ "UPDATE event_imports SET title=?,description=?,venue=?,address=?,event_date=?,event_time=?,end_date=?,end_time=?,cost=?,website=?,image_path=?,status_note='',updated_at=datetime('now') WHERE id=? AND status!='imported'",
+ [
+ facebookEventCleanTitle((string)($row['title'] ?? '')),
+ facebookEventText($row['description'] ?? ''),
+ facebookEventText($row['venue'] ?? ''),
+ facebookEventText($row['address'] ?? ''),
+ trim((string)($row['event_date'] ?? '')),
+ facebookEventText($row['event_time'] ?? ''),
+ trim((string)($row['end_date'] ?? '')) ?: null,
+ facebookEventText($row['end_time'] ?? ''),
+ facebookEventText($row['cost'] ?? 'Free') ?: 'Free',
+ facebookEventWebsiteForStorage((string)($row['website'] ?? '')),
+ trim((string)($row['image_path'] ?? '')),
+ $id,
+ ]
+ );
+}
+
+function facebookEventImportCandidate(int $id, int $userId, string $eventStatus = 'pending', bool $allowDuplicate = false): array {
+ $candidate = qone("SELECT * FROM event_imports WHERE id=?", [$id]);
+ if (!$candidate) return ['ok' => false, 'error' => 'Import candidate not found.'];
+ if (($candidate['status'] ?? '') === 'imported') return ['ok' => false, 'error' => 'This candidate has already been imported.'];
+
+ $issues = facebookEventCandidateIssues($candidate);
+ if ($issues) return ['ok' => false, 'error' => implode('; ', $issues).'.'];
+
+ $duplicateId = facebookEventDuplicateId($candidate);
+ if ($duplicateId && !$allowDuplicate) return ['ok' => false, 'error' => 'Possible duplicate of event #'.$duplicateId.'.'];
+
+ $eventStatus = in_array($eventStatus, ['pending', 'approved'], true) ? $eventStatus : 'pending';
+ $imagePath = (string)($candidate['image_path'] ?? '');
+ $note = '';
+ if ($imagePath === '' && !empty($candidate['image_url'])) {
+ $image = facebookEventDownloadImage((string)$candidate['image_url']);
+ if ($image['ok'] && !empty($image['path'])) {
+ $imagePath = (string)$image['path'];
+ } elseif (!$image['ok']) {
+ $note = 'Imported without image: '.$image['error'];
+ }
+ }
+
+ $eventId = qrun(
+ "INSERT INTO events(title,description,venue,address,event_date,event_time,end_date,end_time,cost,website,contact_name,contact_email,contact_phone,image_path,submitted_by,status,is_featured)VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,0)",
+ [
+ $candidate['title'],
+ $candidate['description'],
+ $candidate['venue'],
+ $candidate['address'],
+ $candidate['event_date'],
+ $candidate['event_time'],
+ $candidate['end_date'] ?: null,
+ $candidate['end_time'],
+ $candidate['cost'] ?: 'Free',
+ facebookEventWebsiteForStorage((string)($candidate['website'] ?: $candidate['source_url'])),
+ '',
+ '',
+ '',
+ $imagePath,
+ $userId ?: null,
+ $eventStatus,
+ ]
+ );
+ qrun("UPDATE event_imports SET status='imported',imported_event_id=?,image_path=?,status_note=?,updated_at=datetime('now') WHERE id=?", [$eventId, $imagePath, $note, $id]);
+ return ['ok' => true, 'event_id' => $eventId, 'warning' => $note];
+}
diff --git a/includes/functions.php b/includes/functions.php
index a618e8e..a5f3e41 100644
--- a/includes/functions.php
+++ b/includes/functions.php
@@ -40,7 +40,7 @@ function g(string $k, mixed $d = ''): mixed { return $_GET[$k] ?? $d; }
function iget(string $k): int { return (int)($_POST[$k] ?? $_GET[$k] ?? 0); }
function ps(string $k): string { return trim((string)($_POST[$k] ?? '')); }
function gs(string $k): string { return trim((string)($_GET[$k] ?? '')); }
-function isPost(): bool { return strtoupper($_SERVER['REQUEST_METHOD']) === 'POST'; }
+function isPost(): bool { return strtoupper((string)($_SERVER['REQUEST_METHOD'] ?? 'GET')) === 'POST'; }
/* ── CSRF ─────────────────────────────────────────────── */
function csrf(): string {
@@ -325,6 +325,17 @@ function forumTopicUrl(array $topic): string {
return '/forum/'.rawurlencode((string)$topic['slug']).'/';
}
+function eventUrl(array $event): string {
+ return '/event.php?id='.(int)($event['id'] ?? 0);
+}
+
+function externalHref(string $url): string {
+ $url = trim($url);
+ if ($url === '') return '';
+ if (preg_match('~^https?://~i', $url)) return $url;
+ return 'https://'.$url;
+}
+
/* ── Email verification / Mailgun ─────────────────────── */
function requestBaseUrl(): string {
$configured = trim(setting('site_base_url', ''));
diff --git a/index.php b/index.php
index d24f911..16980a9 100644
--- a/index.php
+++ b/index.php
@@ -99,7 +99,7 @@ include __DIR__.'/includes/header.php';
-
=e(substr($ev['description'],0,160)).(strlen($ev['description'])>160?'...':'')?>
-
+
diff --git a/scripts/facebook-events-import.php b/scripts/facebook-events-import.php
new file mode 100644
index 0000000..104d4a9
--- /dev/null
+++ b/scripts/facebook-events-import.php
@@ -0,0 +1,329 @@
+#!/usr/bin/env php
+ true,
+ CURLOPT_FOLLOWLOCATION => true,
+ CURLOPT_MAXREDIRS => 4,
+ CURLOPT_TIMEOUT => 30,
+ CURLOPT_CONNECTTIMEOUT => 10,
+ CURLOPT_USERAGENT => facebookEventImporterUserAgent(),
+ CURLOPT_HTTPHEADER => [
+ 'Accept: application/json',
+ 'Authorization: Bearer '.$token,
+ ],
+ ]);
+ $body = curl_exec($ch);
+ $errno = curl_errno($ch);
+ $error = curl_error($ch);
+ $status = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
+ if (PHP_VERSION_ID < 80000) curl_close($ch);
+ if ($errno) throw new RuntimeException($error ?: 'Graph API request failed.');
+ } else {
+ $context = stream_context_create([
+ 'http' => [
+ 'method' => 'GET',
+ 'timeout' => 30,
+ 'ignore_errors' => true,
+ 'header' => "Accept: application/json\r\nAuthorization: Bearer ".$token."\r\nUser-Agent: ".facebookEventImporterUserAgent()."\r\n",
+ ],
+ ]);
+ $body = @file_get_contents($url, false, $context);
+ $statusLine = $http_response_header[0] ?? '';
+ $status = preg_match('/\s(\d{3})\s/', $statusLine, $match) ? (int)$match[1] : 0;
+ if ($body === false) throw new RuntimeException($statusLine ?: 'Graph API request failed.');
+ }
+
+ $json = json_decode((string)$body, true);
+ if (!is_array($json)) throw new RuntimeException('Graph API returned invalid JSON.');
+ if ($status < 200 || $status >= 300 || isset($json['error'])) {
+ $err = $json['error'] ?? [];
+ $message = is_array($err) ? ($err['message'] ?? 'Graph API error') : 'Graph API error';
+ $code = is_array($err) && isset($err['code']) ? ' (code '.$err['code'].')' : '';
+ throw new RuntimeException($message.$code);
+ }
+ return $json;
+}
+
+function fbCliDateToTimestamp(string $date, string $fallback): int {
+ $date = trim($date);
+ $ts = strtotime($date !== '' ? $date : $fallback);
+ if ($ts === false) throw new RuntimeException("Invalid date: ".$date);
+ return $ts;
+}
+
+function fbCliDiscoverPages(array $options, string $token, string $version): array {
+ if (empty($options['discover-pages'])) return [];
+ $center = trim((string)($options['center'] ?? ''));
+ if ($center === '' || !preg_match('/^-?\d+(?:\.\d+)?,-?\d+(?:\.\d+)?$/', $center)) {
+ throw new RuntimeException('--discover-pages requires --center=LAT,LNG.');
+ }
+ $area = trim((string)($options['area'] ?? ''));
+ $query = trim((string)($options['query'] ?? $area));
+ if ($query === '') throw new RuntimeException('--discover-pages requires --query or --area.');
+
+ $limit = max(1, min(100, (int)($options['place-limit'] ?? 50)));
+ $distance = max(1, (int)($options['distance'] ?? 25000));
+ $json = fbCliGraphRequest('search', [
+ 'type' => 'place',
+ 'q' => $query,
+ 'center' => $center,
+ 'distance' => $distance,
+ 'fields' => FB_GRAPH_PLACE_FIELDS,
+ 'limit' => $limit,
+ ], $token, $version);
+
+ $ids = [];
+ foreach (($json['data'] ?? []) as $place) {
+ if (!empty($place['id'])) $ids[(string)$place['id']] = (string)$place['id'];
+ }
+ fbCliLine('Discovered '.count($ids).' Page/place ID'.(count($ids) === 1 ? '' : 's').' from Graph place search.');
+ return array_values($ids);
+}
+
+function fbCliFetchPageEvents(string $pageId, string $token, string $version, int $since, int $until, int $remaining): array {
+ $events = [];
+ $after = '';
+ while ($remaining > count($events)) {
+ $params = [
+ 'fields' => FB_GRAPH_EVENT_FIELDS,
+ 'since' => $since,
+ 'until' => $until,
+ 'limit' => min(100, $remaining - count($events)),
+ ];
+ if ($after !== '') $params['after'] = $after;
+ $json = fbCliGraphRequest(rawurlencode($pageId).'/events', $params, $token, $version);
+ foreach (($json['data'] ?? []) as $event) {
+ if (is_array($event)) $events[] = $event + ['_source_page_id' => $pageId];
+ }
+ $after = (string)($json['paging']['cursors']['after'] ?? '');
+ if ($after === '' || empty($json['data'])) break;
+ }
+ return $events;
+}
+
+function fbCliFetchEvent(string $eventId, string $token, string $version): array {
+ $json = fbCliGraphRequest(rawurlencode($eventId), ['fields' => FB_GRAPH_EVENT_FIELDS], $token, $version);
+ return $json + ['_source_event_id' => $eventId];
+}
+
+function fbCliStageGraphEvent(array $event, string $area, bool $downloadImages, bool $dryRun, array &$summary): void {
+ $candidate = facebookGraphEventToCandidate($event, $area);
+ $title = $candidate['title'] ?: '(untitled)';
+ if ($dryRun) {
+ $summary['dry_run']++;
+ fbCliLine('DRY RUN: '.$title.' | '.($candidate['event_date'] ?: 'no date').' | '.($candidate['source_url'] ?: 'no source URL'));
+ return;
+ }
+
+ $result = facebookEventStageCandidate($candidate, $downloadImages);
+ if ($result['ok']) {
+ $summary[$result['action']] = ($summary[$result['action']] ?? 0) + 1;
+ fbCliLine(strtoupper(str_replace('_', ' ', $result['action'])).': #'.$result['id'].' '.$title);
+ } else {
+ $summary['failed']++;
+ fbCliLine('FAILED: '.$title.' - '.$result['error']);
+ }
+}
+
+$options = fbCliOptions($argv);
+if (isset($options['help']) || $options === []) {
+ fbCliUsage();
+ exit(0);
+}
+
+$token = trim((string)($options['access-token'] ?? getenv('FACEBOOK_GRAPH_ACCESS_TOKEN') ?: ''));
+if ($token === '') {
+ fwrite(STDERR, "Missing Graph API access token. Use --access-token or FACEBOOK_GRAPH_ACCESS_TOKEN.\n\n");
+ fbCliUsage();
+ exit(2);
+}
+
+try {
+ $version = fbCliGraphVersion($options);
+ $area = trim((string)($options['area'] ?? ''));
+ $limit = max(1, (int)($options['limit'] ?? 100));
+ $downloadImages = !isset($options['no-images']);
+ $dryRun = isset($options['dry-run']);
+ $summary = ['created' => 0, 'updated' => 0, 'already_imported' => 0, 'dry_run' => 0, 'failed' => 0, 'graph_failed' => 0];
+
+ $pageIds = array_values(array_unique(array_merge(
+ fbCliValues($options, 'page-id'),
+ fbCliFileValues($options, 'pages-file'),
+ fbCliDiscoverPages($options, $token, $version)
+ )));
+ $eventIds = array_values(array_unique(array_merge(
+ fbCliValues($options, 'event-id'),
+ fbCliFileValues($options, 'events-file')
+ )));
+
+ if (!$pageIds && !$eventIds) {
+ throw new RuntimeException('Provide at least one --page-id, --pages-file, --event-id, --events-file, or --discover-pages source.');
+ }
+
+ $since = fbCliDateToTimestamp((string)($options['since'] ?? ''), 'today');
+ $until = fbCliDateToTimestamp((string)($options['until'] ?? ''), '+1 year');
+ $staged = 0;
+
+ foreach ($eventIds as $eventId) {
+ if ($staged >= $limit) break;
+ fbCliLine('Fetching Graph event: '.$eventId);
+ try {
+ $event = fbCliFetchEvent($eventId, $token, $version);
+ fbCliStageGraphEvent($event, $area, $downloadImages, $dryRun, $summary);
+ $staged++;
+ } catch (Throwable $e) {
+ $summary['graph_failed']++;
+ fbCliLine('GRAPH FAILED: event '.$eventId.' - '.$e->getMessage());
+ }
+ }
+
+ foreach ($pageIds as $pageId) {
+ if ($staged >= $limit) break;
+ $remaining = $limit - $staged;
+ fbCliLine('Fetching Graph Page events: '.$pageId);
+ try {
+ $events = fbCliFetchPageEvents($pageId, $token, $version, $since, $until, $remaining);
+ } catch (Throwable $e) {
+ $summary['graph_failed']++;
+ fbCliLine('GRAPH FAILED: page '.$pageId.' - '.$e->getMessage());
+ continue;
+ }
+ foreach ($events as $event) {
+ if ($staged >= $limit) break;
+ fbCliStageGraphEvent($event, $area, $downloadImages, $dryRun, $summary);
+ $staged++;
+ }
+ }
+
+ fbCliLine();
+ fbCliLine('Summary:');
+ fbCliLine(' Created: '.$summary['created']);
+ fbCliLine(' Updated: '.$summary['updated']);
+ fbCliLine(' Already imported: '.$summary['already_imported']);
+ if ($dryRun) fbCliLine(' Dry-run candidates: '.$summary['dry_run']);
+ fbCliLine(' Stage failures: '.$summary['failed']);
+ fbCliLine(' Graph failures: '.$summary['graph_failed']);
+ fbCliLine();
+ fbCliLine('Review staged imports at: /admin/event_imports.php');
+} catch (Throwable $e) {
+ fwrite(STDERR, "Error: ".$e->getMessage()."\n");
+ exit(1);
+}