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