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