1<?php
2/**
3 * Calendar Plugin - Event Cache
4 *
5 * Provides caching for calendar events to avoid reloading JSON files
6 * on every page view. Uses file-based caching with TTL.
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
15class CalendarEventCache {
16
17    /** @var int Default cache TTL in seconds (5 minutes) */
18    private const DEFAULT_TTL = 300;
19
20    /** @var string Cache directory relative to DokuWiki data */
21    private const CACHE_DIR = 'cache/calendar/';
22
23    /** @var array In-memory cache for current request */
24    private static $memoryCache = [];
25
26    /** @var string|null Base cache directory path */
27    private static $cacheDir = null;
28
29    /**
30     * Get the cache directory path
31     *
32     * @return string Cache directory path
33     */
34    private static function getCacheDir() {
35        if (self::$cacheDir === null) {
36            self::$cacheDir = DOKU_INC . 'data/' . self::CACHE_DIR;
37            if (!is_dir(self::$cacheDir)) {
38                @mkdir(self::$cacheDir, 0755, true);
39            }
40        }
41        return self::$cacheDir;
42    }
43
44    /**
45     * Generate cache key for a month's events
46     *
47     * @param string $namespace Namespace filter
48     * @param int $year Year
49     * @param int $month Month
50     * @return string Cache key
51     */
52    private static function getMonthCacheKey($namespace, $year, $month) {
53        $ns = preg_replace('/[^a-zA-Z0-9_-]/', '_', $namespace ?: 'default');
54        return sprintf('events_%s_%04d_%02d', $ns, $year, $month);
55    }
56
57    /**
58     * Get cache file path
59     *
60     * @param string $key Cache key
61     * @return string File path
62     */
63    private static function getCacheFile($key) {
64        return self::getCacheDir() . $key . '.cache';
65    }
66
67    /**
68     * Get cached events for a month
69     *
70     * @param string $namespace Namespace filter
71     * @param int $year Year
72     * @param int $month Month
73     * @param int $ttl TTL in seconds (default 5 minutes)
74     * @return array|null Cached events or null if cache miss/expired
75     */
76    public static function getMonthEvents($namespace, $year, $month, $ttl = self::DEFAULT_TTL) {
77        $key = self::getMonthCacheKey($namespace, $year, $month);
78
79        // Check memory cache first
80        if (isset(self::$memoryCache[$key])) {
81            return self::$memoryCache[$key];
82        }
83
84        $cacheFile = self::getCacheFile($key);
85
86        if (!file_exists($cacheFile)) {
87            return null;
88        }
89
90        // Check if cache is expired
91        $mtime = filemtime($cacheFile);
92        if ($mtime === false || (time() - $mtime) > $ttl) {
93            @unlink($cacheFile);
94            return null;
95        }
96
97        $contents = @file_get_contents($cacheFile);
98        if ($contents === false) {
99            return null;
100        }
101
102        $data = @unserialize($contents);
103        if ($data === false) {
104            @unlink($cacheFile);
105            return null;
106        }
107
108        // Store in memory cache
109        self::$memoryCache[$key] = $data;
110
111        return $data;
112    }
113
114    /**
115     * Set cached events for a month
116     *
117     * @param string $namespace Namespace filter
118     * @param int $year Year
119     * @param int $month Month
120     * @param array $events Events data
121     * @return bool Success status
122     */
123    public static function setMonthEvents($namespace, $year, $month, array $events) {
124        $key = self::getMonthCacheKey($namespace, $year, $month);
125
126        // Store in memory cache
127        self::$memoryCache[$key] = $events;
128
129        $cacheFile = self::getCacheFile($key);
130        $serialized = serialize($events);
131
132        // Use temp file for atomic write
133        $tempFile = $cacheFile . '.tmp';
134        if (@file_put_contents($tempFile, $serialized) === false) {
135            return false;
136        }
137
138        if (!@rename($tempFile, $cacheFile)) {
139            @unlink($tempFile);
140            return false;
141        }
142
143        return true;
144    }
145
146    /**
147     * Invalidate cache for a specific month
148     *
149     * @param string $namespace Namespace filter
150     * @param int $year Year
151     * @param int $month Month
152     */
153    public static function invalidateMonth($namespace, $year, $month) {
154        $key = self::getMonthCacheKey($namespace, $year, $month);
155
156        // Clear memory cache
157        unset(self::$memoryCache[$key]);
158
159        // Delete cache file
160        $cacheFile = self::getCacheFile($key);
161        if (file_exists($cacheFile)) {
162            @unlink($cacheFile);
163        }
164    }
165
166    /**
167     * Invalidate cache for a namespace (all months)
168     *
169     * @param string $namespace Namespace to invalidate
170     */
171    public static function invalidateNamespace($namespace) {
172        $ns = preg_replace('/[^a-zA-Z0-9_-]/', '_', $namespace ?: 'default');
173        $pattern = self::getCacheDir() . "events_{$ns}_*.cache";
174
175        foreach (glob($pattern) as $file) {
176            @unlink($file);
177        }
178
179        // Clear matching memory cache entries
180        $prefix = "events_{$ns}_";
181        foreach (array_keys(self::$memoryCache) as $key) {
182            if (strpos($key, $prefix) === 0) {
183                unset(self::$memoryCache[$key]);
184            }
185        }
186    }
187
188    /**
189     * Invalidate all event caches
190     */
191    public static function invalidateAll() {
192        $pattern = self::getCacheDir() . "events_*.cache";
193
194        foreach (glob($pattern) as $file) {
195            @unlink($file);
196        }
197
198        // Clear memory cache
199        self::$memoryCache = [];
200    }
201
202    /**
203     * Get cache statistics
204     *
205     * @return array Cache stats
206     */
207    public static function getStats() {
208        $cacheDir = self::getCacheDir();
209        $files = glob($cacheDir . "*.cache");
210
211        $stats = [
212            'files' => count($files),
213            'size' => 0,
214            'oldest' => null,
215            'newest' => null,
216            'memory_entries' => count(self::$memoryCache)
217        ];
218
219        foreach ($files as $file) {
220            $size = filesize($file);
221            $mtime = filemtime($file);
222
223            $stats['size'] += $size;
224
225            if ($stats['oldest'] === null || $mtime < $stats['oldest']) {
226                $stats['oldest'] = $mtime;
227            }
228            if ($stats['newest'] === null || $mtime > $stats['newest']) {
229                $stats['newest'] = $mtime;
230            }
231        }
232
233        return $stats;
234    }
235
236    /**
237     * Clean up expired cache files
238     *
239     * @param int $ttl TTL in seconds
240     * @return int Number of files cleaned
241     */
242    public static function cleanup($ttl = self::DEFAULT_TTL) {
243        $cacheDir = self::getCacheDir();
244        $files = glob($cacheDir . "*.cache");
245        $cleaned = 0;
246        $now = time();
247
248        foreach ($files as $file) {
249            $mtime = filemtime($file);
250            if ($mtime !== false && ($now - $mtime) > $ttl) {
251                if (@unlink($file)) {
252                    $cleaned++;
253                }
254            }
255        }
256
257        return $cleaned;
258    }
259}
260