- 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:
+2
-2
@@ -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>
|
||||
|
||||
@@ -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,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>
|
||||
|
||||
@@ -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
@@ -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}
|
||||
|
||||
@@ -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">×</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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
@@ -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: ?>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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', ''));
|
||||
|
||||
@@ -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: ?>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user