MAX_BACKUPS) { unlink(array_pop($backups)); } } /** * One locked read-modify-write of events.json. The callback edits the data by * reference and returns null to write, or an error message to abort. * * @return true|string|false true = written, string = callback error, false = storage failure */ function raijuEventsMutate(callable $change, bool $backup = false) { $error = null; $ok = raijuJsonUpdate(EVENTS_PATH, function (array &$data) use ($change, $backup, &$error) { if (!isset($data['events']) || !is_array($data['events'])) { $data['events'] = []; } $error = $change($data); if ($error !== null) { return false; } if ($backup) { raijuEventsBackup(); } return true; }, ['events' => []]); if ($error !== null) { return $error; } return $ok; } } switch ($action) { case 'load-events': if (!file_exists(EVENTS_PATH)) { jsonResponse(true, ['events' => [], 'lastModified' => null]); } $content = json_decode(file_get_contents(EVENTS_PATH), true); jsonResponse(true, $content); break; case 'save-event': if (!validateCsrfToken()) { jsonResponse(false, null, 'Invalid CSRF token'); } $eventData = $_POST['event'] ?? ''; $event = json_decode($eventData, true); if (!is_array($event)) { jsonResponse(false, null, 'Invalid JSON format'); } // Validate: date required, title in at least one language required $defaultLang = defined('SITE_LANG_DEFAULT') ? SITE_LANG_DEFAULT : 'en'; $hasTitle = false; if (!empty($event['title']) && is_array($event['title'])) { foreach ($event['title'] as $t) { if (!empty($t)) { $hasTitle = true; break; } } } if (empty($event['date']) || !$hasTitle) { jsonResponse(false, null, 'Date and title are required'); } if (empty($event['id'])) { // Use default language title for ID, fallback to first available $titleForId = $event['title'][$defaultLang] ?? reset($event['title']); $event['id'] = $event['date'] . '-' . preg_replace('/[^a-z0-9-]/', '', strtolower(str_replace(' ', '-', $titleForId))); } $found = false; $result = raijuEventsMutate(function (array &$data) use ($event, &$found) { foreach ($data['events'] as $index => $existing) { if (($existing['id'] ?? null) === $event['id']) { $data['events'][$index] = $event; $found = true; break; } } if (!$found) { $data['events'][] = $event; } $data['lastModified'] = date('c'); return null; }, true); if ($result !== true) { jsonResponse(false, null, 'Error saving'); } jsonResponse(true, ['id' => $event['id']], $found ? 'Event updated' : 'Event created'); break; case 'delete-event': if (!validateCsrfToken()) { jsonResponse(false, null, 'Invalid CSRF token'); } $eventId = $_POST['id'] ?? ''; if (empty($eventId)) { jsonResponse(false, null, 'Event ID missing'); } if (!file_exists(EVENTS_PATH)) { jsonResponse(false, null, 'No events found'); } $result = raijuEventsMutate(function (array &$data) use ($eventId) { if (!isset($data['trash']) || !is_array($data['trash'])) { $data['trash'] = []; } // Move event to trash array (instead of deleting) $movedEvent = null; foreach ($data['events'] as $idx => $existing) { if (($existing['id'] ?? null) === $eventId) { $movedEvent = $existing; array_splice($data['events'], $idx, 1); break; } } if ($movedEvent === null) { return 'Event not found'; } $data['trash'][] = [ 'event' => $movedEvent, 'deletedAt' => date('c'), ]; $data['lastModified'] = date('c'); return null; }, true); if ($result !== true) { jsonResponse(false, null, is_string($result) ? $result : 'Error moving event to trash'); } jsonResponse(true, null, 'Event moved to trash'); break; case 'list-events-trash': if (!file_exists(EVENTS_PATH)) { jsonResponse(true, []); } $data = json_decode(file_get_contents(EVENTS_PATH), true); $trash = $data['trash'] ?? []; // Sort newest first usort($trash, function($a, $b) { return strcmp($b['deletedAt'] ?? '', $a['deletedAt'] ?? ''); }); jsonResponse(true, $trash); break; case 'restore-event': if (!validateCsrfToken()) { jsonResponse(false, null, 'Invalid CSRF token'); } $eventId = $_POST['id'] ?? ''; if (empty($eventId)) { jsonResponse(false, null, 'Event ID missing'); } if (!file_exists(EVENTS_PATH)) { jsonResponse(false, null, 'No events found'); } $restored = null; $result = raijuEventsMutate(function (array &$data) use ($eventId, &$restored) { if (!isset($data['trash']) || !is_array($data['trash'])) { return 'Event not in trash'; } foreach ($data['trash'] as $idx => $item) { if (($item['event']['id'] ?? null) === $eventId) { $restored = $item['event']; array_splice($data['trash'], $idx, 1); break; } } if ($restored === null) { return 'Event not found in trash'; } // Avoid id collision: if restored id already exists, append a suffix $existingIds = array_map(function($e) { return $e['id'] ?? ''; }, $data['events']); if (in_array($restored['id'], $existingIds, true)) { $restored['id'] = $restored['id'] . '-restored-' . time(); } $data['events'][] = $restored; $data['lastModified'] = date('c'); return null; }); if ($result !== true) { jsonResponse(false, null, is_string($result) ? $result : 'Error restoring event'); } jsonResponse(true, ['id' => $restored['id']], 'Event restored'); break; case 'delete-event-permanent': if (!validateCsrfToken()) { jsonResponse(false, null, 'Invalid CSRF token'); } $eventId = $_POST['id'] ?? ''; if (empty($eventId)) { jsonResponse(false, null, 'Event ID missing'); } if (!file_exists(EVENTS_PATH)) { jsonResponse(false, null, 'No events found'); } $result = raijuEventsMutate(function (array &$data) use ($eventId) { if (!isset($data['trash']) || !is_array($data['trash'])) { return 'Event not in trash'; } foreach ($data['trash'] as $idx => $item) { if (($item['event']['id'] ?? null) === $eventId) { array_splice($data['trash'], $idx, 1); return null; } } return 'Event not found in trash'; }); if ($result !== true) { jsonResponse(false, null, is_string($result) ? $result : 'Error deleting event permanently'); } jsonResponse(true, null, 'Event permanently deleted'); break; case 'empty-events-trash': if (!validateCsrfToken()) { jsonResponse(false, null, 'Invalid CSRF token'); } if (!file_exists(EVENTS_PATH)) { jsonResponse(true, null, 'Trash is empty'); } $result = raijuEventsMutate(function (array &$data) { $data['trash'] = []; return null; }); if ($result !== true) { jsonResponse(false, null, 'Error emptying trash'); } jsonResponse(true, null, 'Events trash emptied'); break; case 'toggle-event-visibility': if (!validateCsrfToken()) { jsonResponse(false, null, 'Invalid CSRF token'); } $eventId = $_POST['id'] ?? ''; if (empty($eventId)) { jsonResponse(false, null, 'Event ID missing'); } if (!file_exists(EVENTS_PATH)) { jsonResponse(false, null, 'No events found'); } $nowHidden = false; $result = raijuEventsMutate(function (array &$data) use ($eventId, &$nowHidden) { foreach ($data['events'] as $index => $existing) { if (($existing['id'] ?? null) === $eventId) { $nowHidden = empty($existing['hidden']); if ($nowHidden) { $data['events'][$index]['hidden'] = true; } else { unset($data['events'][$index]['hidden']); } $data['lastModified'] = date('c'); return null; } } return 'Event not found'; }); if ($result !== true) { jsonResponse(false, null, is_string($result) ? $result : 'Error saving'); } jsonResponse(true, ['hidden' => $nowHidden], $nowHidden ? 'Event hidden' : 'Event visible'); break; case 'load-event': $eventId = $_GET['id'] ?? ''; if (empty($eventId)) { jsonResponse(false, null, 'Event ID missing'); } if (!file_exists(EVENTS_PATH)) { jsonResponse(false, null, 'No events found'); } $data = json_decode(file_get_contents(EVENTS_PATH), true); foreach ($data['events'] as $event) { if ($event['id'] === $eventId) { jsonResponse(true, $event); } } jsonResponse(false, null, 'Event not found'); break; // ============================================================ // IMAGE MANAGEMENT // ============================================================ }