1<?php 2/** 3 * Calendar Plugin - Event Manager 4 * 5 * Consolidates event CRUD operations with proper file locking and caching. 6 * This class is the single point of entry for all event data operations. 7 * 8 * @license GPL 2 http://www.gnu.org/licenses/gpl-2.0.html 9 * @author DokuWiki Community 10 * @version 7.0.8 11 */ 12 13if (!defined('DOKU_INC')) die(); 14 15// Require dependencies 16require_once __DIR__ . '/FileHandler.php'; 17require_once __DIR__ . '/EventCache.php'; 18 19class CalendarEventManager { 20 21 /** @var string Base data directory */ 22 private static $baseDir = null; 23 24 /** 25 * Get the base calendar data directory 26 * 27 * @return string Base directory path 28 */ 29 private static function getBaseDir() { 30 if (self::$baseDir === null) { 31 self::$baseDir = DOKU_INC . 'data/meta/'; 32 } 33 return self::$baseDir; 34 } 35 36 /** 37 * Get the data directory for a namespace 38 * 39 * @param string $namespace Namespace (empty for default) 40 * @return string Directory path 41 */ 42 private static function getNamespaceDir($namespace = '') { 43 $dir = self::getBaseDir(); 44 if ($namespace) { 45 $dir .= str_replace(':', '/', $namespace) . '/'; 46 } 47 $dir .= 'calendar/'; 48 return $dir; 49 } 50 51 /** 52 * Get the event file path for a specific month 53 * 54 * @param string $namespace Namespace 55 * @param int $year Year 56 * @param int $month Month 57 * @return string File path 58 */ 59 private static function getEventFile($namespace, $year, $month) { 60 $dir = self::getNamespaceDir($namespace); 61 return $dir . sprintf('%04d-%02d.json', $year, $month); 62 } 63 64 /** 65 * Load events for a specific month 66 * 67 * @param string $namespace Namespace filter 68 * @param int $year Year 69 * @param int $month Month 70 * @param bool $useCache Whether to use caching 71 * @return array Events indexed by date 72 */ 73 public static function loadMonth($namespace, $year, $month, $useCache = true) { 74 // Check cache first 75 if ($useCache) { 76 $cached = CalendarEventCache::getMonthEvents($namespace, $year, $month); 77 if ($cached !== null) { 78 return $cached; 79 } 80 } 81 82 $events = []; 83 84 // Handle wildcards and multiple namespaces 85 if (strpos($namespace, '*') !== false || strpos($namespace, ';') !== false) { 86 $events = self::loadMonthMultiNamespace($namespace, $year, $month); 87 } else { 88 $eventFile = self::getEventFile($namespace, $year, $month); 89 $events = CalendarFileHandler::readJson($eventFile); 90 } 91 92 // Store in cache 93 if ($useCache) { 94 CalendarEventCache::setMonthEvents($namespace, $year, $month, $events); 95 } 96 97 return $events; 98 } 99 100 /** 101 * Load events from multiple namespaces 102 * 103 * @param string $namespacePattern Namespace pattern (with * or ;) 104 * @param int $year Year 105 * @param int $month Month 106 * @return array Merged events indexed by date 107 */ 108 private static function loadMonthMultiNamespace($namespacePattern, $year, $month) { 109 $allEvents = []; 110 $namespaces = self::expandNamespacePattern($namespacePattern); 111 112 foreach ($namespaces as $ns) { 113 $eventFile = self::getEventFile($ns, $year, $month); 114 $events = CalendarFileHandler::readJson($eventFile); 115 116 foreach ($events as $date => $dateEvents) { 117 if (!isset($allEvents[$date])) { 118 $allEvents[$date] = []; 119 } 120 foreach ($dateEvents as $event) { 121 // Ensure namespace is set 122 if (!isset($event['namespace'])) { 123 $event['namespace'] = $ns; 124 } 125 $allEvents[$date][] = $event; 126 } 127 } 128 } 129 130 return $allEvents; 131 } 132 133 /** 134 * Expand namespace pattern to list of namespaces 135 * 136 * @param string $pattern Namespace pattern 137 * @return array List of namespace paths 138 */ 139 private static function expandNamespacePattern($pattern) { 140 $namespaces = []; 141 142 // Handle semicolon-separated namespaces 143 if (strpos($pattern, ';') !== false) { 144 $parts = explode(';', $pattern); 145 foreach ($parts as $part) { 146 $expanded = self::expandNamespacePattern(trim($part)); 147 $namespaces = array_merge($namespaces, $expanded); 148 } 149 return array_unique($namespaces); 150 } 151 152 // Handle wildcard 153 if (strpos($pattern, '*') !== false) { 154 // Get base directory 155 $basePattern = str_replace('*', '', $pattern); 156 $basePattern = rtrim($basePattern, ':'); 157 158 $searchDir = self::getBaseDir(); 159 if ($basePattern) { 160 $searchDir .= str_replace(':', '/', $basePattern) . '/'; 161 } 162 163 // Always include the base namespace 164 $namespaces[] = $basePattern; 165 166 // Find subdirectories with calendar data 167 if (is_dir($searchDir)) { 168 $iterator = new RecursiveIteratorIterator( 169 new RecursiveDirectoryIterator($searchDir, RecursiveDirectoryIterator::SKIP_DOTS), 170 RecursiveIteratorIterator::SELF_FIRST 171 ); 172 173 foreach ($iterator as $file) { 174 if ($file->isDir() && $file->getFilename() === 'calendar') { 175 // Extract namespace from path 176 $path = dirname($file->getPathname()); 177 $relPath = str_replace(self::getBaseDir(), '', $path); 178 $ns = str_replace('/', ':', trim($relPath, '/')); 179 if ($ns && !in_array($ns, $namespaces)) { 180 $namespaces[] = $ns; 181 } 182 } 183 } 184 } 185 186 return $namespaces; 187 } 188 189 // Simple namespace 190 return [$pattern]; 191 } 192 193 /** 194 * Save an event 195 * 196 * @param array $eventData Event data 197 * @param string|null $oldDate Previous date (for moves) 198 * @param string|null $oldNamespace Previous namespace (for moves) 199 * @return array Result with success status and event data 200 */ 201 public static function saveEvent(array $eventData, $oldDate = null, $oldNamespace = null) { 202 // Validate required fields 203 if (empty($eventData['date']) || empty($eventData['title'])) { 204 return ['success' => false, 'error' => 'Missing required fields']; 205 } 206 207 $date = $eventData['date']; 208 $namespace = $eventData['namespace'] ?? ''; 209 $eventId = $eventData['id'] ?? uniqid(); 210 211 // Parse date 212 if (!preg_match('/^(\d{4})-(\d{2})-(\d{2})$/', $date, $matches)) { 213 return ['success' => false, 'error' => 'Invalid date format']; 214 } 215 list(, $year, $month, $day) = $matches; 216 $year = (int)$year; 217 $month = (int)$month; 218 219 // Ensure ID is set 220 $eventData['id'] = $eventId; 221 222 // Set created timestamp if new 223 if (!isset($eventData['created'])) { 224 $eventData['created'] = date('Y-m-d H:i:s'); 225 } 226 227 // Handle event move (different date or namespace) 228 $dateChanged = $oldDate && $oldDate !== $date; 229 $namespaceChanged = $oldNamespace !== null && $oldNamespace !== $namespace; 230 231 if ($dateChanged || $namespaceChanged) { 232 // Delete from old location 233 if (!preg_match('/^(\d{4})-(\d{2})-(\d{2})$/', $oldDate ?: $date, $oldMatches)) { 234 return ['success' => false, 'error' => 'Invalid old date format']; 235 } 236 list(, $oldYear, $oldMonth, $oldDay) = $oldMatches; 237 238 $oldEventFile = self::getEventFile($oldNamespace ?? $namespace, (int)$oldYear, (int)$oldMonth); 239 $oldEvents = CalendarFileHandler::readJson($oldEventFile); 240 241 $deleteDate = $oldDate ?: $date; 242 if (isset($oldEvents[$deleteDate])) { 243 $oldEvents[$deleteDate] = array_values(array_filter( 244 $oldEvents[$deleteDate], 245 function($evt) use ($eventId) { 246 return $evt['id'] !== $eventId; 247 } 248 )); 249 250 if (empty($oldEvents[$deleteDate])) { 251 unset($oldEvents[$deleteDate]); 252 } 253 254 CalendarFileHandler::writeJson($oldEventFile, $oldEvents); 255 256 // Invalidate old location cache 257 CalendarEventCache::invalidateMonth($oldNamespace ?? $namespace, (int)$oldYear, (int)$oldMonth); 258 } 259 } 260 261 // Load current events 262 $eventFile = self::getEventFile($namespace, $year, $month); 263 $events = CalendarFileHandler::readJson($eventFile); 264 265 // Ensure date array exists 266 if (!isset($events[$date]) || !is_array($events[$date])) { 267 $events[$date] = []; 268 } 269 270 // Update or add event 271 $found = false; 272 foreach ($events[$date] as $key => $evt) { 273 if ($evt['id'] === $eventId) { 274 $events[$date][$key] = $eventData; 275 $found = true; 276 break; 277 } 278 } 279 280 if (!$found) { 281 $events[$date][] = $eventData; 282 } 283 284 // Save with atomic write 285 if (!CalendarFileHandler::writeJson($eventFile, $events)) { 286 return ['success' => false, 'error' => 'Failed to save event']; 287 } 288 289 // Invalidate cache 290 CalendarEventCache::invalidateMonth($namespace, $year, $month); 291 292 return ['success' => true, 'event' => $eventData]; 293 } 294 295 /** 296 * Delete an event 297 * 298 * @param string $eventId Event ID 299 * @param string $date Event date 300 * @param string $namespace Namespace 301 * @return array Result with success status 302 */ 303 public static function deleteEvent($eventId, $date, $namespace = '') { 304 if (!preg_match('/^(\d{4})-(\d{2})-(\d{2})$/', $date, $matches)) { 305 return ['success' => false, 'error' => 'Invalid date format']; 306 } 307 list(, $year, $month, $day) = $matches; 308 $year = (int)$year; 309 $month = (int)$month; 310 311 $eventFile = self::getEventFile($namespace, $year, $month); 312 $events = CalendarFileHandler::readJson($eventFile); 313 314 if (!isset($events[$date])) { 315 return ['success' => false, 'error' => 'Event not found']; 316 } 317 318 $originalCount = count($events[$date]); 319 $events[$date] = array_values(array_filter( 320 $events[$date], 321 function($evt) use ($eventId) { 322 return $evt['id'] !== $eventId; 323 } 324 )); 325 326 if (count($events[$date]) === $originalCount) { 327 return ['success' => false, 'error' => 'Event not found']; 328 } 329 330 if (empty($events[$date])) { 331 unset($events[$date]); 332 } 333 334 if (!CalendarFileHandler::writeJson($eventFile, $events)) { 335 return ['success' => false, 'error' => 'Failed to delete event']; 336 } 337 338 // Invalidate cache 339 CalendarEventCache::invalidateMonth($namespace, $year, $month); 340 341 return ['success' => true]; 342 } 343 344 /** 345 * Get a single event by ID 346 * 347 * @param string $eventId Event ID 348 * @param string $date Event date 349 * @param string $namespace Namespace (use * for all) 350 * @return array|null Event data or null if not found 351 */ 352 public static function getEvent($eventId, $date, $namespace = '') { 353 if (!preg_match('/^(\d{4})-(\d{2})-(\d{2})$/', $date, $matches)) { 354 return null; 355 } 356 list(, $year, $month, $day) = $matches; 357 358 $events = self::loadMonth($namespace, (int)$year, (int)$month); 359 360 if (!isset($events[$date])) { 361 return null; 362 } 363 364 foreach ($events[$date] as $event) { 365 if ($event['id'] === $eventId) { 366 return $event; 367 } 368 } 369 370 return null; 371 } 372 373 /** 374 * Find which namespace an event is in 375 * 376 * @param string $eventId Event ID 377 * @param string $date Event date 378 * @return string|null Namespace or null if not found 379 */ 380 public static function findEventNamespace($eventId, $date) { 381 if (!preg_match('/^(\d{4})-(\d{2})-(\d{2})$/', $date, $matches)) { 382 return null; 383 } 384 list(, $year, $month, $day) = $matches; 385 386 // Load all namespaces 387 $events = self::loadMonth('*', (int)$year, (int)$month, false); 388 389 if (!isset($events[$date])) { 390 return null; 391 } 392 393 foreach ($events[$date] as $event) { 394 if ($event['id'] === $eventId) { 395 return $event['namespace'] ?? ''; 396 } 397 } 398 399 return null; 400 } 401 402 /** 403 * Search events across all namespaces 404 * 405 * @param string $query Search query 406 * @param array $options Search options (dateFrom, dateTo, namespace) 407 * @return array Matching events 408 */ 409 public static function searchEvents($query, array $options = []) { 410 $results = []; 411 $query = strtolower(trim($query)); 412 413 $namespace = $options['namespace'] ?? '*'; 414 $dateFrom = $options['dateFrom'] ?? date('Y-m-01'); 415 $dateTo = $options['dateTo'] ?? date('Y-m-d', strtotime('+1 year')); 416 417 // Parse date range 418 $startDate = new DateTime($dateFrom); 419 $endDate = new DateTime($dateTo); 420 421 // Iterate through months 422 $current = clone $startDate; 423 $current->modify('first day of this month'); 424 425 while ($current <= $endDate) { 426 $year = (int)$current->format('Y'); 427 $month = (int)$current->format('m'); 428 429 $events = self::loadMonth($namespace, $year, $month); 430 431 foreach ($events as $date => $dateEvents) { 432 if ($date < $dateFrom || $date > $dateTo) { 433 continue; 434 } 435 436 foreach ($dateEvents as $event) { 437 $titleMatch = stripos($event['title'] ?? '', $query) !== false; 438 $descMatch = stripos($event['description'] ?? '', $query) !== false; 439 440 if ($titleMatch || $descMatch) { 441 $event['_date'] = $date; 442 $results[] = $event; 443 } 444 } 445 } 446 447 $current->modify('+1 month'); 448 } 449 450 // Sort by date 451 usort($results, function($a, $b) { 452 return strcmp($a['_date'], $b['_date']); 453 }); 454 455 return $results; 456 } 457 458 /** 459 * Get all namespaces that have calendar data 460 * 461 * @return array List of namespaces 462 */ 463 public static function getNamespaces() { 464 return self::expandNamespacePattern('*'); 465 } 466 467 /** 468 * Debug log helper 469 * 470 * @param string $message Message to log 471 */ 472 private static function log($message) { 473 if (defined('CALENDAR_DEBUG') && CALENDAR_DEBUG) { 474 error_log("[Calendar EventManager] $message"); 475 } 476 } 477} 478