xref: /plugin/calendar/classes/EventManager.php (revision 2866e8271e4daef3b32eacb3a9082d02159b592b)
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