- Rebuilt the CLI importer to use the official Facebook Graph API instead of HTML scraping.

Main changes:
Replaced 
[scripts/facebook-events-import.php](/Users/tyemeclifford/Documents/GH/MyKeyser/scripts/facebook-events-import.php) 
with a Graph API client.
It now supports --page-id, --pages-file, --event-id, --events-file, and 
optional area discovery via --discover-pages --center=LAT,LNG 
--query=....
Uses FACEBOOK_GRAPH_ACCESS_TOKEN or --access-token.
Pulls Graph fields for title, description, start/end time, 
place/location, ticket URI, and cover.source for the main photo.
Keeps the existing staged review/import workflow in 
/admin/event_imports.php.

- Implemented event detail pages and clickable event cards.
What changed:
Added [event.php](/Users/tyemeclifford/Documents/GH/MyKeyser/event.php) 
for individual event pages.
Made event cards clickable from 
[events.php](/Users/tyemeclifford/Documents/GH/MyKeyser/events.php), 
[index.php](/Users/tyemeclifford/Documents/GH/MyKeyser/index.php), and 
legacy 
[2index.php](/Users/tyemeclifford/Documents/GH/MyKeyser/2index.php).
Added large event photo display plus lightbox enlargement in 
[assets/css/style.css](/Users/tyemeclifford/Documents/GH/MyKeyser/assets/css/style.css) 
and 
[assets/js/app.js](/Users/tyemeclifford/Documents/GH/MyKeyser/assets/js/app.js).
Added eventUrl() and externalHref() helpers in 
[includes/functions.php](/Users/tyemeclifford/Documents/GH/MyKeyser/includes/functions.php).
Added copy-event-link support on event detail pages.
Updated README to list the new route.
This commit is contained in:
Ty Clifford
2026-06-19 18:07:45 -04:00
parent d83b62b79c
commit 354bb4255d
13 changed files with 1455 additions and 9 deletions
+2 -2
View File
@@ -136,7 +136,7 @@ include __DIR__.'/includes/header.php';
<div class="sec-rule"></div>
</div>
<?php foreach($upevts as $ev): ?>
<div class="evt-card">
<a href="<?=e(eventUrl($ev))?>" class="evt-card evt-link-card">
<div class="evt-date-box">
<div class="evt-mon"><?=strtoupper(date('M',strtotime($ev['event_date'])))?></div>
<div class="evt-day"><?=date('j',strtotime($ev['event_date']))?></div>
@@ -151,7 +151,7 @@ include __DIR__.'/includes/header.php';
</div>
<div class="evt-desc"><?=e(substr($ev['description'],0,180)).(strlen($ev['description'])>180?'…':'')?></div>
</div>
</div>
</a>
<?php endforeach; ?>
<div style="text-align:center;margin-top:1.5rem">
<a href="/events.php" class="btn btn-outline">All Events</a>
+18
View File
@@ -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
+7
View File
@@ -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'");
?>
<div class="admin-wrap">
<aside class="admin-side">
@@ -27,6 +28,12 @@ $_pendingClaims = (int)qval("SELECT COUNT(*) FROM business_claim_requests WHERE
<div class="admin-side-title" style="margin-top:1rem">COMMUNITY</div>
<a class="asl" href="/admin/forum.php">💬 Forum</a>
<a class="asl" href="/admin/events.php">📅 Events</a>
<a class="asl" href="/admin/event_imports.php" style="display:flex;align-items:center;justify-content:space-between">
<span>Event Imports</span>
<?php if ($_pendingEventImports): ?>
<span style="background:var(--gold);color:var(--bg0);font-family:'Oswald',sans-serif;font-size:.65rem;padding:.1rem .42rem;border-radius:10px;font-weight:600"><?=$_pendingEventImports?></span>
<?php endif; ?>
</a>
<a class="asl" href="/admin/attractions.php">🗺️ Attractions</a>
<a class="asl" href="/admin/reports.php">🚩 Reports</a>
<div class="admin-side-title" style="margin-top:1rem">USERS</div>
+246
View File
@@ -0,0 +1,246 @@
<?php
require_once dirname(__DIR__).'/includes/boot.php';
require_once dirname(__DIR__).'/includes/facebook_event_importer.php';
requireAdmin();
$adminTitle = 'Event Imports';
function eventImportRowsFromPost(): array {
$rows = p('rows', []);
return is_array($rows) ? $rows : [];
}
function eventImportSelectedIds(): array {
$ids = p('ids', []);
if (!is_array($ids)) $ids = [];
return array_values(array_unique(array_filter(array_map('intval', $ids))));
}
function eventImportSavePostedRows(array $onlyIds = []): void {
$wanted = array_fill_keys($onlyIds, true);
foreach (eventImportRowsFromPost() as $id => $row) {
$id = (int)$id;
if ($id <= 0) continue;
if ($onlyIds && !isset($wanted[$id])) continue;
if (!is_array($row)) continue;
facebookEventSaveCandidateFields($id, $row);
}
}
$filter = gs('filter') ?: p('filter', 'staged');
if (!in_array($filter, ['staged', 'imported', 'skipped', 'all'], true)) $filter = 'staged';
if (isPost()) {
csrfCheck();
$eventStatus = in_array(p('event_status', 'pending'), ['pending', 'approved'], true) ? p('event_status', 'pending') : 'pending';
$allowDuplicate = p('allow_duplicates', '0') === '1';
if (($saveId = (int)p('save_id', 0)) > 0) {
eventImportSavePostedRows([$saveId]);
flash('Import candidate saved.', 'success');
go('/admin/event_imports.php?filter='.$filter);
}
if (($importId = (int)p('import_id', 0)) > 0) {
eventImportSavePostedRows([$importId]);
$result = facebookEventImportCandidate($importId, uid(), $eventStatus, $allowDuplicate);
flash($result['ok'] ? 'Event imported.'.(!empty($result['warning']) ? ' '.$result['warning'] : '') : $result['error'], $result['ok'] ? 'success' : 'error');
go('/admin/event_imports.php?filter='.$filter);
}
if (($skipId = (int)p('skip_id', 0)) > 0) {
qrun("UPDATE event_imports SET status='skipped',updated_at=datetime('now') WHERE id=? AND status!='imported'", [$skipId]);
flash('Import candidate skipped.', 'success');
go('/admin/event_imports.php?filter='.$filter);
}
if (($restoreId = (int)p('restore_id', 0)) > 0) {
qrun("UPDATE event_imports SET status='staged',updated_at=datetime('now') WHERE id=? AND status='skipped'", [$restoreId]);
flash('Import candidate restored.', 'success');
go('/admin/event_imports.php?filter=staged');
}
if (($deleteId = (int)p('delete_id', 0)) > 0) {
qrun("DELETE FROM event_imports WHERE id=? AND status!='imported'", [$deleteId]);
flash('Import candidate deleted.', 'success');
go('/admin/event_imports.php?filter='.$filter);
}
$act = p('_act', '');
if ($act === 'save_all') {
eventImportSavePostedRows();
flash('Import candidates saved.', 'success');
go('/admin/event_imports.php?filter='.$filter);
}
if ($act === 'bulk_import') {
$ids = eventImportSelectedIds();
eventImportSavePostedRows($ids);
$ok = 0; $errors = [];
foreach ($ids as $id) {
$result = facebookEventImportCandidate($id, uid(), $eventStatus, $allowDuplicate);
if ($result['ok']) $ok++;
else $errors[] = '#'.$id.': '.$result['error'];
}
if ($ok) flash($ok.' event'.($ok === 1 ? '' : 's').' imported.', 'success');
if ($errors) flash(implode(' ', $errors), 'error');
if (!$ids) flash('Select at least one staged import first.', 'warning');
go('/admin/event_imports.php?filter='.$filter);
}
if ($act === 'bulk_skip') {
$ids = eventImportSelectedIds();
foreach ($ids as $id) qrun("UPDATE event_imports SET status='skipped',updated_at=datetime('now') WHERE id=? AND status!='imported'", [$id]);
flash($ids ? count($ids).' import candidate'.(count($ids) === 1 ? '' : 's').' skipped.' : 'Select at least one staged import first.', $ids ? 'success' : 'warning');
go('/admin/event_imports.php?filter='.$filter);
}
if ($act === 'bulk_delete') {
$ids = eventImportSelectedIds();
foreach ($ids as $id) qrun("DELETE FROM event_imports WHERE id=? AND status!='imported'", [$id]);
flash($ids ? count($ids).' import candidate'.(count($ids) === 1 ? '' : 's').' deleted.' : 'Select at least one staged import first.', $ids ? 'success' : 'warning');
go('/admin/event_imports.php?filter='.$filter);
}
}
$where = match ($filter) {
'staged' => "WHERE status='staged'",
'imported' => "WHERE status='imported'",
'skipped' => "WHERE status='skipped'",
default => '',
};
$imports = qall("SELECT * FROM event_imports $where ORDER BY CASE status WHEN 'staged' THEN 0 WHEN 'skipped' THEN 1 ELSE 2 END, created_at DESC LIMIT 200");
$counts = [
'staged' => (int)qval("SELECT COUNT(*) FROM event_imports WHERE status='staged'"),
'imported' => (int)qval("SELECT COUNT(*) FROM event_imports WHERE status='imported'"),
'skipped' => (int)qval("SELECT COUNT(*) FROM event_imports WHERE status='skipped'"),
'all' => (int)qval("SELECT COUNT(*) FROM event_imports"),
];
include __DIR__.'/admin_header.php';
?>
<?php adminTitle('Facebook Event Imports', 'Review staged Facebook event candidates, correct any fields, then import selected rows into the normal My Keyser events workflow.') ?>
<div class="ibox" style="border-color:var(--gold-d)">
<div class="ibox-title">CLI STAGING</div>
<p style="color:var(--text-m);font-size:.9rem;line-height:1.65;margin-bottom:.8rem">
Stage Facebook event candidates through the official Graph API, then return here to verify titles, dates, locations, duplicates, and photos before publishing or sending them to pending review.
</p>
<code style="display:block;white-space:normal;background:var(--bg2);border:1px solid var(--bdr);border-radius:var(--r);padding:.8rem;color:var(--text-m)">FACEBOOK_GRAPH_ACCESS_TOKEN=... php scripts/facebook-events-import.php --area="Keyser, WV" --page-id=123456789 --limit=20</code>
</div>
<div style="display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:.5rem;margin-bottom:1rem">
<div class="flex-row" style="gap:.4rem">
<?php foreach(['staged'=>'Staged','imported'=>'Imported','skipped'=>'Skipped','all'=>'All'] as $key => $label): ?>
<a href="/admin/event_imports.php?filter=<?=$key?>" class="btn btn-xs<?=$filter===$key?' btn-primary':' btn-outline'?>"><?=$label?> (<?=$counts[$key]?>)</a>
<?php endforeach; ?>
</div>
<a href="/admin/events.php" class="btn btn-xs btn-outline">Manage Events</a>
</div>
<?php if (!$imports): ?>
<div class="empty"><div class="empty-ico">+</div><h3>No event imports here</h3><p style="margin-top:.5rem">Run the Graph API CLI importer to stage Facebook event candidates for review.</p></div>
<?php else: ?>
<form method="POST">
<?=csrfField()?>
<input type="hidden" name="filter" value="<?=e($filter)?>">
<div class="ibox" style="position:sticky;top:76px;z-index:2">
<div class="flex-row" style="justify-content:space-between;align-items:flex-end">
<div class="flex-row">
<div class="fg" style="margin-bottom:0;min-width:180px">
<label class="flabel">IMPORT AS</label>
<select name="event_status" class="fselect">
<option value="pending">Pending review</option>
<option value="approved">Approved public event</option>
</select>
</div>
<label style="display:flex;align-items:center;gap:.45rem;color:var(--text-m);font-size:.86rem;margin-top:1.25rem">
<input type="checkbox" name="allow_duplicates" value="1"> Allow duplicate title/date
</label>
</div>
<div class="flex-row" style="gap:.4rem">
<button type="submit" name="_act" value="save_all" class="btn btn-xs btn-outline">Save Edits</button>
<button type="submit" name="_act" value="bulk_import" class="btn btn-xs btn-success">Import Selected</button>
<button type="submit" name="_act" value="bulk_skip" class="btn btn-xs btn-secondary">Skip Selected</button>
<button type="submit" name="_act" value="bulk_delete" class="btn btn-xs btn-danger" data-confirm="Delete selected import candidates?">Delete Selected</button>
</div>
</div>
</div>
<div style="display:grid;gap:1rem">
<?php foreach ($imports as $imp): ?>
<?php
$id = (int)$imp['id'];
$issues = facebookEventCandidateIssues($imp);
$duplicateId = facebookEventDuplicateId($imp);
$statusClass = ['staged' => 'badge-gold', 'imported' => 'badge-green', 'skipped' => 'badge-gray'][$imp['status']] ?? 'badge-gray';
$preview = $imp['image_path'] ?: $imp['image_url'];
$canEdit = $imp['status'] !== 'imported';
?>
<div class="ibox" style="margin-bottom:0">
<div class="event-import-grid">
<div>
<?php if ($imp['status'] === 'staged'): ?>
<label style="display:flex;align-items:center;gap:.45rem;color:var(--text-m);font-size:.86rem;margin-bottom:.55rem">
<input type="checkbox" name="ids[]" value="<?=$id?>"> Select
</label>
<?php endif; ?>
<div class="event-import-preview">
<?php if ($preview): ?>
<img src="<?=e($preview)?>" alt="<?=e($imp['title'] ?: 'Event image')?>">
<?php else: ?>
No image
<?php endif; ?>
</div>
<div class="flex-row" style="gap:.35rem;margin-top:.65rem">
<span class="badge <?=$statusClass?>"><?=strtoupper(e($imp['status']))?></span>
<?php if (!$issues && !$duplicateId && $imp['status'] === 'staged'): ?><span class="badge badge-green">READY</span><?php endif; ?>
<?php if ($issues): ?><span class="badge badge-red">NEEDS FIX</span><?php endif; ?>
<?php if ($duplicateId && $imp['status'] === 'staged'): ?><span class="badge badge-gold">DUPLICATE?</span><?php endif; ?>
</div>
<?php if ($imp['source_url']): ?><p style="font-size:.78rem;margin-top:.6rem"><a href="<?=e($imp['source_url'])?>" target="_blank">View source</a></p><?php endif; ?>
<?php if ($imp['imported_event_id']): ?><p style="font-size:.78rem;margin-top:.25rem"><a href="/admin/events.php?edit=<?=(int)$imp['imported_event_id']?>">Edit imported event #<?=(int)$imp['imported_event_id']?></a></p><?php endif; ?>
</div>
<div style="min-width:0">
<div class="form-row">
<div class="fg"><label class="flabel">TITLE *</label><input type="text" name="rows[<?=$id?>][title]" class="finput" value="<?=e($imp['title'])?>" <?=$canEdit?'':'disabled'?>></div>
<div class="fg"><label class="flabel">SOURCE AREA</label><input type="text" class="finput" value="<?=e($imp['source_area'])?>" disabled></div>
</div>
<div class="fg"><label class="flabel">DESCRIPTION</label><textarea name="rows[<?=$id?>][description]" class="ftextarea" style="min-height:76px" <?=$canEdit?'':'disabled'?>><?=e($imp['description'])?></textarea></div>
<div class="form-row">
<div class="fg"><label class="flabel">VENUE</label><input type="text" name="rows[<?=$id?>][venue]" class="finput" value="<?=e($imp['venue'])?>" <?=$canEdit?'':'disabled'?>></div>
<div class="fg"><label class="flabel">ADDRESS</label><input type="text" name="rows[<?=$id?>][address]" class="finput" value="<?=e($imp['address'])?>" <?=$canEdit?'':'disabled'?>></div>
</div>
<div class="form-row">
<div class="fg"><label class="flabel">START DATE *</label><input type="date" name="rows[<?=$id?>][event_date]" class="finput" value="<?=e($imp['event_date'])?>" <?=$canEdit?'':'disabled'?>></div>
<div class="fg"><label class="flabel">START TIME</label><input type="text" name="rows[<?=$id?>][event_time]" class="finput" value="<?=e($imp['event_time'])?>" placeholder="e.g. 7:00 PM" <?=$canEdit?'':'disabled'?>></div>
</div>
<div class="form-row">
<div class="fg"><label class="flabel">END DATE</label><input type="date" name="rows[<?=$id?>][end_date]" class="finput" value="<?=e($imp['end_date'])?>" <?=$canEdit?'':'disabled'?>></div>
<div class="fg"><label class="flabel">END TIME</label><input type="text" name="rows[<?=$id?>][end_time]" class="finput" value="<?=e($imp['end_time'])?>" <?=$canEdit?'':'disabled'?>></div>
</div>
<div class="form-row">
<div class="fg"><label class="flabel">COST</label><input type="text" name="rows[<?=$id?>][cost]" class="finput" value="<?=e($imp['cost'] ?: 'Free')?>" <?=$canEdit?'':'disabled'?>></div>
<div class="fg"><label class="flabel">WEBSITE</label><input type="text" name="rows[<?=$id?>][website]" class="finput" value="<?=e($imp['website'])?>" <?=$canEdit?'':'disabled'?>></div>
</div>
<input type="hidden" name="rows[<?=$id?>][image_path]" value="<?=e($imp['image_path'])?>">
<?php if ($issues || $duplicateId || $imp['status_note']): ?>
<div style="background:rgba(201,168,76,.08);border:1px solid rgba(201,168,76,.2);border-radius:var(--r);padding:.75rem;margin:.4rem 0 1rem;color:var(--text-m);font-size:.84rem;line-height:1.55">
<?php if ($issues): ?><div><strong>Needs review:</strong> <?=e(implode(', ', $issues))?></div><?php endif; ?>
<?php if ($duplicateId && $imp['status'] === 'staged'): ?><div><strong>Possible duplicate:</strong> existing event #<?=$duplicateId?> has the same title and start date.</div><?php endif; ?>
<?php if ($imp['status_note']): ?><div><strong>Note:</strong> <?=e($imp['status_note'])?></div><?php endif; ?>
</div>
<?php endif; ?>
<div class="flex-row" style="gap:.4rem">
<?php if ($canEdit): ?><button type="submit" name="save_id" value="<?=$id?>" class="btn btn-xs btn-outline">Save</button><?php endif; ?>
<?php if ($imp['status'] === 'staged'): ?><button type="submit" name="import_id" value="<?=$id?>" class="btn btn-xs btn-success">Import</button><button type="submit" name="skip_id" value="<?=$id?>" class="btn btn-xs btn-secondary">Skip</button><?php endif; ?>
<?php if ($imp['status'] === 'skipped'): ?><button type="submit" name="restore_id" value="<?=$id?>" class="btn btn-xs btn-primary">Restore</button><?php endif; ?>
<?php if ($imp['status'] !== 'imported'): ?><button type="submit" name="delete_id" value="<?=$id?>" class="btn btn-xs btn-danger" data-confirm="Delete this import candidate?">Delete</button><?php endif; ?>
</div>
</div>
</div>
</div>
<?php endforeach; ?>
</div>
</form>
<?php endif; ?>
<?php include __DIR__.'/admin_footer.php'; ?>
+15 -1
View File
@@ -346,6 +346,8 @@ address{font-style:normal}
/* ── Events ─────────────────────────────────────────────── */
.evt-card{background:var(--bg3);border:1px solid var(--bdr);border-radius:var(--r);padding:1.5rem;display:flex;gap:1.5rem;transition:all .22s;margin-bottom:1.1rem}
.evt-card:hover{border-color:var(--gold-d);box-shadow:var(--sh-sm)}
.evt-link-card{color:inherit;text-decoration:none}
.evt-link-card:hover{color:inherit;transform:translateY(-1px)}
.evt-date-box{flex-shrink:0;text-align:center;background:var(--bg4);border:1px solid var(--bdr-l);border-radius:var(--r);padding:.65rem .85rem;min-width:64px}
.evt-mon{font-family:'Oswald',sans-serif;font-size:.68rem;letter-spacing:.18em;color:var(--gold)}
.evt-day{font-family:'Playfair Display',serif;font-size:2rem;color:var(--text);line-height:1}
@@ -358,11 +360,23 @@ address{font-style:normal}
.evt-actions{margin-top:.75rem;display:flex;gap:.5rem;flex-wrap:wrap}
.evt-image-wrap{width:180px;min-height:126px;align-self:stretch;flex:0 0 180px;overflow:hidden;border-radius:var(--r);border:1px solid rgba(38,244,255,.2);background:var(--bg4)}
.evt-image{width:100%;height:100%;object-fit:cover}
.event-hero-photo{padding:0;overflow:hidden}
.event-photo-btn{display:block;position:relative;width:100%;border:0;background:var(--bg4);padding:0;color:var(--text);text-align:left}
.event-photo-btn img{width:100%;max-height:560px;object-fit:contain;background:#000}
.event-photo-btn span{position:absolute;right:1rem;bottom:1rem;background:rgba(3,5,11,.82);border:1px solid rgba(255,209,102,.36);border-radius:var(--r);padding:.45rem .75rem;font-family:'Oswald',sans-serif;font-size:.74rem;letter-spacing:.1em;color:var(--gold)}
.event-detail-date{display:flex;gap:.85rem;align-items:center;padding-bottom:.85rem;margin-bottom:.5rem;border-bottom:1px solid var(--bdr)}
.lightbox{position:fixed;inset:0;z-index:5000;display:none;align-items:center;justify-content:center;background:rgba(0,0,0,.88);padding:1.5rem}
.lightbox.open{display:flex}
.lightbox img{max-width:min(1200px,96vw);max-height:88vh;object-fit:contain;border-radius:var(--r);box-shadow:var(--sh)}
.lightbox-close{position:absolute;right:1rem;top:1rem;width:42px;height:42px;border:1px solid rgba(255,255,255,.25);border-radius:999px;background:rgba(3,5,11,.75);color:#fff;font-size:1.6rem;line-height:1}
.event-strip .evt-card{display:grid;grid-template-columns:auto 1fr;align-items:start}
.event-strip .evt-image-wrap{grid-column:1/-1;width:100%;height:150px;min-height:150px}
.event-admin-image,.menu-admin-image{display:flex;align-items:center;gap:.85rem;margin-bottom:.65rem;flex-wrap:wrap}
.event-admin-image img{width:150px;height:88px;object-fit:cover;border-radius:var(--r);border:1px solid var(--bdr-l)}
.menu-admin-image img,.admin-menu-thumb{width:72px;height:72px;object-fit:cover;border-radius:var(--r);border:1px solid var(--bdr-l)}
.event-import-grid{display:grid;grid-template-columns:180px minmax(0,1fr);gap:1rem;align-items:start}
.event-import-preview{width:180px;height:108px;background:var(--bg2);border:1px solid var(--bdr);border-radius:var(--r);overflow:hidden;display:flex;align-items:center;justify-content:center;color:var(--text-d);font-size:.8rem}
.event-import-preview img{width:100%;height:100%;object-fit:cover}
/* ── About / Facts ──────────────────────────────────────── */
.fact-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(210px,1fr));gap:1.1rem}
@@ -458,7 +472,7 @@ address{font-style:normal}
/* ── Responsive ─────────────────────────────────────────── */
@media(max-width:1100px){.footer-grid{grid-template-columns:1fr 1fr}.home-hero-grid{grid-template-columns:1fr}.hero-signal{grid-template-columns:1fr 1fr}.signal-card-main{min-height:auto}.about-split{grid-template-columns:1fr}}
@media(max-width:900px){.detail-body{grid-template-columns:1fr}.admin-wrap{grid-template-columns:1fr}.admin-side{position:static;max-height:none;border-right:none;border-bottom:1px solid var(--bdr)}.form-row{grid-template-columns:1fr}.section-head{align-items:flex-start;flex-direction:column}.random-list{grid-template-columns:1fr}.signup-grid,.tier-grid{grid-template-columns:1fr}.home-facts{grid-template-columns:1fr}.forum-layout{grid-template-columns:1fr}.evt-image-wrap{width:140px;flex-basis:140px}}
@media(max-width:900px){.detail-body{grid-template-columns:1fr}.admin-wrap{grid-template-columns:1fr}.admin-side{position:static;max-height:none;border-right:none;border-bottom:1px solid var(--bdr)}.form-row{grid-template-columns:1fr}.section-head{align-items:flex-start;flex-direction:column}.random-list{grid-template-columns:1fr}.signup-grid,.tier-grid{grid-template-columns:1fr}.home-facts{grid-template-columns:1fr}.forum-layout{grid-template-columns:1fr}.evt-image-wrap{width:140px;flex-basis:140px}.event-import-grid{grid-template-columns:1fr}.event-import-preview{width:100%;height:180px}}
@media(max-width:768px){
.nav-links{display:none;position:absolute;top:64px;left:0;right:0;background:var(--bg0);border-bottom:1px solid var(--bdr-l);flex-direction:column;padding:.75rem 1rem;gap:.1rem;box-shadow:var(--sh)}
.nav-links.open{display:flex}.hamburger{display:flex}
+39
View File
@@ -29,3 +29,42 @@ document.querySelectorAll('.asl').forEach(l=>{
l.classList.add('on');
if(location.pathname===href)l.classList.add('on');
});
// Event photo lightbox
const lightboxTriggers=document.querySelectorAll('[data-lightbox-src]');
let lightbox;
const closeLightbox=()=>{
lightbox?.classList.remove('open');
document.body.style.overflow='';
};
if(lightboxTriggers.length){
lightbox=document.createElement('div');
lightbox.className='lightbox';
lightbox.innerHTML='<button type="button" class="lightbox-close" aria-label="Close image">&times;</button><img alt="">';
document.body.appendChild(lightbox);
const img=lightbox.querySelector('img');
lightboxTriggers.forEach(btn=>{
btn.addEventListener('click',()=>{
img.src=btn.dataset.lightboxSrc;
img.alt=btn.dataset.lightboxAlt||'Event photo';
lightbox.classList.add('open');
document.body.style.overflow='hidden';
});
});
lightbox.addEventListener('click',e=>{if(e.target===lightbox||e.target.closest('.lightbox-close'))closeLightbox();});
document.addEventListener('keydown',e=>{if(e.key==='Escape')closeLightbox();});
}
// Copy share links
document.querySelectorAll('[data-copy-url]').forEach(btn=>{
btn.addEventListener('click',async()=>{
const label=btn.textContent;
try{
await navigator.clipboard.writeText(btn.dataset.copyUrl);
btn.textContent='Copied';
setTimeout(()=>btn.textContent=label,1400);
}catch{
prompt('Copy this link:',btn.dataset.copyUrl);
}
});
});
+128
View File
@@ -0,0 +1,128 @@
<?php
require_once __DIR__.'/includes/boot.php';
$id = iget('id');
$event = qone("
SELECT e.*, u.username AS submitter
FROM events e
LEFT JOIN users u ON u.id = e.submitted_by
WHERE e.id = ? AND (e.status = 'approved' OR ? = 1)
", [$id, isAdmin() ? 1 : 0]);
if (!$event) {
flash('Event not found.', 'error');
go('/events.php');
}
$activeNav = 'events';
$pageTitle = e($event['title']).' - Events in Keyser, WV';
$dateLabel = fdate($event['event_date'], 'l, F j, Y');
$endDateLabel = !empty($event['end_date']) ? fdate($event['end_date'], 'l, F j, Y') : '';
$timeLabel = trim((string)($event['event_time'] ?? ''));
$endTimeLabel = trim((string)($event['end_time'] ?? ''));
$dateRange = $dateLabel;
if ($endDateLabel && $endDateLabel !== $dateLabel) $dateRange .= ' through '.$endDateLabel;
if ($timeLabel !== '') $dateRange .= ' at '.$timeLabel;
if ($endTimeLabel !== '') $dateRange .= ' - '.$endTimeLabel;
include __DIR__.'/includes/header.php';
?>
<div class="detail-hdr">
<div class="detail-hdr-inner">
<div class="breadcrumb">
<a href="/index.php">Home</a><span></span>
<a href="/events.php">Events</a><span></span>
<?=e($event['title'])?>
</div>
<div class="cat-badge">COMMUNITY EVENT</div>
<h1 style="font-size:clamp(1.9rem,4vw,2.9rem);margin:.4rem 0"><?=e($event['title'])?></h1>
<div class="evt-meta" style="font-size:.9rem;margin-top:.65rem">
<span><?=e($dateRange)?></span>
<?php if (!empty($event['venue'])): ?><span><?=e($event['venue'])?></span><?php endif; ?>
<?php if ($event['is_featured']): ?><span class="featured-badge">FEATURED</span><?php endif; ?>
<?php if (isAdmin() && $event['status'] !== 'approved'): ?><span class="badge badge-gold"><?=strtoupper(e($event['status']))?></span><?php endif; ?>
</div>
</div>
</div>
<div class="detail-body">
<div>
<?php if (!empty($event['image_path'])): ?>
<div class="event-hero-photo ibox">
<button type="button" class="event-photo-btn" data-lightbox-src="<?=e($event['image_path'])?>" data-lightbox-alt="<?=e($event['title'])?>">
<img src="<?=e($event['image_path'])?>" alt="<?=e($event['title'])?>">
<span>View full image</span>
</button>
</div>
<?php endif; ?>
<div class="ibox">
<div class="ibox-title">ABOUT THIS EVENT</div>
<?php if (!empty($event['description'])): ?>
<p style="color:var(--text-m);line-height:1.85;white-space:pre-line"><?=e($event['description'])?></p>
<?php else: ?>
<p style="color:var(--text-d)">No description has been added yet.</p>
<?php endif; ?>
</div>
<?php if (!empty($event['address'])): ?>
<div class="ibox">
<div class="ibox-title">LOCATION</div>
<div style="background:var(--bg4);border:1px dashed var(--bdr);border-radius:4px;padding:1.75rem;text-align:center">
<?php if (!empty($event['venue'])): ?><h3 style="font-size:1.2rem;margin-bottom:.35rem"><?=e($event['venue'])?></h3><?php endif; ?>
<p style="font-size:.9rem;color:var(--text-m);margin-bottom:.9rem"><?=e($event['address'])?></p>
<a href="https://maps.google.com/?q=<?=urlencode(trim(($event['venue'] ? $event['venue'].' ' : '').$event['address']))?>" target="_blank" rel="noopener" class="btn btn-outline btn-sm">Open in Google Maps</a>
</div>
</div>
<?php endif; ?>
<div class="flex-row">
<a href="/events.php" class="btn btn-secondary">Back to Events</a>
<?php if (isAdmin()): ?><a href="/admin/events.php?edit=<?=(int)$event['id']?>" class="btn btn-outline">Edit Event</a><?php endif; ?>
</div>
</div>
<div>
<div class="ibox">
<div class="ibox-title">EVENT DETAILS</div>
<div class="event-detail-date">
<div class="evt-date-box">
<div class="evt-mon"><?=strtoupper(date('M', strtotime($event['event_date'])))?></div>
<div class="evt-day"><?=date('j', strtotime($event['event_date']))?></div>
<div class="evt-yr"><?=date('Y', strtotime($event['event_date']))?></div>
</div>
<div>
<div style="font-weight:700;color:var(--text)"><?=e($dateLabel)?></div>
<?php if ($timeLabel !== ''): ?><div style="color:var(--text-m);font-size:.9rem"><?=e($timeLabel)?><?= $endTimeLabel !== '' ? ' - '.e($endTimeLabel) : '' ?></div><?php endif; ?>
<?php if ($endDateLabel && $endDateLabel !== $dateLabel): ?><div style="color:var(--text-d);font-size:.82rem">Through <?=e($endDateLabel)?></div><?php endif; ?>
</div>
</div>
<?php if (!empty($event['venue'])): ?><div class="irow"><span class="irow-i">📍</span><span class="irow-v"><?=e($event['venue'])?></span></div><?php endif; ?>
<?php if (!empty($event['address'])): ?><div class="irow"><span class="irow-i">⌖</span><span class="irow-v"><?=e($event['address'])?></span></div><?php endif; ?>
<div class="irow"><span class="irow-i">$</span><span class="irow-v"><?=e($event['cost'] ?: 'Free')?></span></div>
<?php if (!empty($event['website'])): ?>
<div class="irow"><span class="irow-i"></span><a href="<?=e(externalHref($event['website']))?>" target="_blank" rel="noopener" class="irow-v" style="color:var(--gold);word-break:break-all"><?=e($event['website'])?></a></div>
<?php endif; ?>
</div>
<?php if (!empty($event['contact_name']) || !empty($event['contact_email']) || !empty($event['contact_phone'])): ?>
<div class="ibox">
<div class="ibox-title">CONTACT</div>
<?php if (!empty($event['contact_name'])): ?><div class="irow"><span class="irow-i">◇</span><span class="irow-v"><?=e($event['contact_name'])?></span></div><?php endif; ?>
<?php if (!empty($event['contact_phone'])): ?><div class="irow"><span class="irow-i">☎</span><a href="tel:<?=e($event['contact_phone'])?>" class="irow-v" style="color:var(--gold)"><?=e($event['contact_phone'])?></a></div><?php endif; ?>
<?php if (!empty($event['contact_email'])): ?><div class="irow"><span class="irow-i">@</span><a href="mailto:<?=e($event['contact_email'])?>" class="irow-v" style="color:var(--gold);word-break:break-all"><?=e($event['contact_email'])?></a></div><?php endif; ?>
</div>
<?php endif; ?>
<div class="ibox">
<div class="ibox-title">SHARE</div>
<p style="color:var(--text-m);font-size:.88rem;margin-bottom:.8rem">Send this event page to someone who should know about it.</p>
<button type="button" class="btn btn-outline btn-sm" data-copy-url="<?=e(absoluteUrl(eventUrl($event)))?>">Copy Event Link</button>
</div>
</div>
</div>
<?php include __DIR__.'/includes/footer.php'; ?>
+3 -3
View File
@@ -108,7 +108,7 @@ include __DIR__.'/includes/header.php';
<div class="section">
<?php if($events): ?>
<?php foreach($events as $ev): ?>
<div class="evt-card">
<a href="<?=e(eventUrl($ev))?>" class="evt-card evt-link-card">
<?php if(!empty($ev['image_path'])): ?>
<div class="evt-image-wrap"><img src="<?=e($ev['image_path'])?>" alt="<?=e($ev['title'])?>" class="evt-image"></div>
<?php endif; ?>
@@ -127,9 +127,9 @@ include __DIR__.'/includes/header.php';
<?php if($ev['contact_phone']): ?><span>📞 <?=e($ev['contact_phone'])?></span><?php endif; ?>
</div>
<div class="evt-desc"><?=e($ev['description'])?></div>
<?php if($ev['website']): ?><div class="evt-actions"><a href="https://<?=e($ev['website'])?>" target="_blank" class="btn btn-outline btn-sm">More Info ↗</a></div><?php endif; ?>
<div class="evt-actions"><span class="btn btn-outline btn-sm">View Event</span></div>
</div>
</div>
</a>
<?php endforeach; ?>
<?php if($pages>1): ?><div style="display:flex;justify-content:center;gap:.5rem;margin-top:2rem;flex-wrap:wrap"><?php for($i=1;$i<=$pages;$i++): ?><a href="?page=<?=$i?>" class="btn btn-sm<?=$i===$page?' btn-primary':' btn-outline'?>"><?=$i?></a><?php endfor; ?></div><?php endif; ?>
<?php else: ?>
+53
View File
@@ -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,
+601
View File
@@ -0,0 +1,601 @@
<?php
if (!function_exists('qrun')) require_once __DIR__.'/boot.php';
function facebookEventImporterUserAgent(): string {
return 'MyKeyserEventImporter/1.0 (+https://mykeyser.local)';
}
function facebookEventText(mixed $value): string {
if (is_array($value)) $value = implode(' ', array_filter(array_map('facebookEventText', $value)));
$value = html_entity_decode(strip_tags((string)($value ?? '')), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
return trim(preg_replace('/\s+/', ' ', $value));
}
function facebookEventAllowedImageHost(string $url): bool {
$host = strtolower((string)(parse_url($url, PHP_URL_HOST) ?? ''));
if ($host === '') return false;
foreach (['facebook.com', 'fbcdn.net', 'fbsbx.com'] as $allowed) {
if ($host === $allowed || str_ends_with($host, '.'.$allowed)) return true;
}
return false;
}
function facebookEventNormalizeUrl(string $url, string $baseUrl = 'https://www.facebook.com'): string {
$url = trim(html_entity_decode($url, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'));
$url = str_replace(['\\/', '\\u002F', '\\u003A', '\\u0026'], ['/', '/', ':', '&'], $url);
if ($url === '') return '';
if (str_starts_with($url, '//')) $url = 'https:'.$url;
if (str_starts_with($url, '/')) {
$base = parse_url($baseUrl);
$scheme = $base['scheme'] ?? 'https';
$host = $base['host'] ?? 'www.facebook.com';
$url = $scheme.'://'.$host.$url;
}
$parts = parse_url($url);
if (!$parts || empty($parts['scheme']) || empty($parts['host'])) return '';
if (!in_array(strtolower($parts['scheme']), ['http', 'https'], true)) return '';
$host = strtolower($parts['host']);
$path = $parts['path'] ?? '';
if (($host === 'l.facebook.com' || str_ends_with($host, '.l.facebook.com')) && !empty($parts['query'])) {
parse_str($parts['query'], $query);
if (!empty($query['u'])) return facebookEventNormalizeUrl((string)$query['u'], $baseUrl);
}
$normalized = strtolower($parts['scheme']).'://'.$host.$path;
if (!empty($parts['query'])) $normalized .= '?'.$parts['query'];
return $normalized;
}
function facebookEventNormalizeEventUrl(string $url, string $baseUrl = 'https://www.facebook.com'): string {
$url = facebookEventNormalizeUrl($url, $baseUrl);
if ($url === '') return '';
$parts = parse_url($url);
$host = strtolower($parts['host'] ?? '');
if ($host !== 'facebook.com' && $host !== 'www.facebook.com' && !str_ends_with($host, '.facebook.com')) return '';
$path = preg_replace('~/+~', '/', (string)($parts['path'] ?? ''));
if (!preg_match('~^/(events|share)/~i', $path)) return '';
return 'https://www.facebook.com'.rtrim($path, '/');
}
function facebookEventExternalId(string $url): string {
$path = (string)(parse_url($url, PHP_URL_PATH) ?? '');
if (preg_match_all('~\d{6,}~', $path, $matches) && !empty($matches[0])) {
return end($matches[0]);
}
return '';
}
function facebookEventFetchUrl(string $url): array {
$url = facebookEventNormalizeUrl($url);
if ($url === '') return ['ok' => 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('<?xml encoding="utf-8" ?>'.$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];
}
+12 -1
View File
@@ -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', ''));
+2 -2
View File
@@ -99,7 +99,7 @@ include __DIR__.'/includes/header.php';
<?php if($upevts): ?>
<div class="event-strip">
<?php foreach($upevts as $ev): ?>
<div class="evt-card">
<a href="<?=e(eventUrl($ev))?>" class="evt-card evt-link-card">
<?php if(!empty($ev['image_path'])): ?>
<div class="evt-image-wrap"><img src="<?=e($ev['image_path'])?>" alt="<?=e($ev['title'])?>" class="evt-image"></div>
<?php endif; ?>
@@ -117,7 +117,7 @@ include __DIR__.'/includes/header.php';
</div>
<div class="evt-desc"><?=e(substr($ev['description'],0,160)).(strlen($ev['description'])>160?'...':'')?></div>
</div>
</div>
</a>
<?php endforeach; ?>
</div>
<?php else: ?>
+329
View File
@@ -0,0 +1,329 @@
#!/usr/bin/env php
<?php
require_once dirname(__DIR__).'/includes/boot.php';
require_once dirname(__DIR__).'/includes/facebook_event_importer.php';
if (PHP_SAPI !== 'cli') {
fwrite(STDERR, "This script must be run from the command line.\n");
exit(1);
}
const FB_GRAPH_DEFAULT_VERSION = 'v23.0';
const FB_GRAPH_EVENT_FIELDS = 'id,name,description,start_time,end_time,place{id,name,location},cover{source},ticket_uri';
const FB_GRAPH_PLACE_FIELDS = 'id,name,location';
function fbCliUsage(): void {
echo "Facebook Graph API event importer for My Keyser\n\n";
echo "Usage:\n";
echo " FACEBOOK_GRAPH_ACCESS_TOKEN=... php scripts/facebook-events-import.php --area=\"Keyser, WV\" --page-id=123456789\n";
echo " php scripts/facebook-events-import.php --access-token=... --event-id=123456789012345\n";
echo " php scripts/facebook-events-import.php --access-token=... --pages-file=/path/to/page-ids.txt\n";
echo " php scripts/facebook-events-import.php --access-token=... --area=\"Keyser, WV\" --center=39.4364,-78.9762 --distance=25000 --query=\"Keyser\" --discover-pages\n\n";
echo "Required:\n";
echo " --access-token=TOKEN Graph API access token, or set FACEBOOK_GRAPH_ACCESS_TOKEN.\n\n";
echo "Sources:\n";
echo " --page-id=ID[,ID] Facebook Page IDs to read through /{page-id}/events.\n";
echo " --pages-file=FILE Plain-text Page ID file, one ID per line.\n";
echo " --event-id=ID[,ID] Specific Facebook Event IDs to read through /{event-id}.\n";
echo " --events-file=FILE Plain-text Event ID file, one ID per line.\n";
echo " --discover-pages Use Graph place search for --center/--distance/--query, then read events for matching Page/place IDs.\n\n";
echo "Options:\n";
echo " --area=TEXT Label shown in the admin import review queue.\n";
echo " --center=LAT,LNG Center point for Graph place search.\n";
echo " --distance=METERS Radius for Graph place search. Default: 25000.\n";
echo " --query=TEXT Place search query. Defaults to --area.\n";
echo " --place-limit=N Maximum places to discover. Default: 50.\n";
echo " --limit=N Maximum events to stage total. Default: 100.\n";
echo " --since=YYYY-MM-DD Start date for Page event reads. Default: today.\n";
echo " --until=YYYY-MM-DD End date for Page event reads. Default: +1 year.\n";
echo " --graph-version=vNN.0 Graph API version. Default: ".FB_GRAPH_DEFAULT_VERSION." or FACEBOOK_GRAPH_VERSION.\n";
echo " --no-images Stage Graph cover URLs but skip downloading cover photos.\n";
echo " --dry-run Fetch and print candidates without writing to the database.\n";
echo " --help Show this help.\n\n";
echo "Notes:\n";
echo " This script uses only official Graph API endpoints. It does not scrape HTML, log in,\n";
echo " solve challenges, or bypass Facebook access controls. Your Meta app/token must have\n";
echo " permission to read the requested Page or Event data.\n";
}
function fbCliOptions(array $argv): array {
$options = [];
for ($i = 1; $i < count($argv); $i++) {
$arg = $argv[$i];
if (!str_starts_with($arg, '--')) continue;
$arg = substr($arg, 2);
if (str_contains($arg, '=')) {
[$key, $value] = explode('=', $arg, 2);
} else {
$key = $arg;
$value = true;
if ($i + 1 < count($argv) && !str_starts_with($argv[$i + 1], '--')) $value = $argv[++$i];
}
if (isset($options[$key])) {
if (!is_array($options[$key])) $options[$key] = [$options[$key]];
$options[$key][] = $value;
} else {
$options[$key] = $value;
}
}
return $options;
}
function fbCliValues(array $options, string $key): array {
if (!isset($options[$key])) return [];
$value = $options[$key];
if (!is_array($value)) $value = [$value];
$out = [];
foreach ($value as $item) {
foreach (explode(',', (string)$item) as $part) {
$part = trim($part);
if ($part !== '') $out[] = $part;
}
}
return $out;
}
function fbCliFileValues(array $options, string $key): array {
$out = [];
foreach (fbCliValues($options, $key) as $file) {
if (!is_file($file) || !is_readable($file)) {
throw new RuntimeException("File is not readable: ".$file);
}
foreach (file($file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [] as $line) {
$line = trim(preg_replace('/#.*/', '', $line));
if ($line !== '') $out[] = $line;
}
}
return $out;
}
function fbCliLine(string $message = ''): void {
echo $message."\n";
}
function fbCliGraphVersion(array $options): string {
$version = trim((string)($options['graph-version'] ?? getenv('FACEBOOK_GRAPH_VERSION') ?: FB_GRAPH_DEFAULT_VERSION));
if ($version === '') $version = FB_GRAPH_DEFAULT_VERSION;
return str_starts_with($version, 'v') ? $version : 'v'.$version;
}
function fbCliGraphBase(string $version): string {
return 'https://graph.facebook.com/'.rawurlencode($version).'/';
}
function fbCliGraphRequest(string $path, array $params, string $token, string $version): array {
$path = ltrim($path, '/');
$url = fbCliGraphBase($version).$path;
if ($params) $url .= '?'.http_build_query($params);
if (function_exists('curl_init')) {
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => 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);
}