xref: /plugin/calendar/classes/GoogleCalendarSync.php (revision 2866e8271e4daef3b32eacb3a9082d02159b592b)
1815440faSAtari911<?php
2815440faSAtari911/**
3815440faSAtari911 * Calendar Plugin - Google Calendar Sync
4815440faSAtari911 *
5815440faSAtari911 * Provides two-way synchronization with Google Calendar using OAuth 2.0.
6815440faSAtari911 *
7815440faSAtari911 * Setup:
8815440faSAtari911 * 1. Create a project in Google Cloud Console
9815440faSAtari911 * 2. Enable Google Calendar API
10815440faSAtari911 * 3. Create OAuth 2.0 credentials (Web application)
11815440faSAtari911 * 4. Add redirect URI: https://yoursite.com/lib/exe/ajax.php
12815440faSAtari911 * 5. Enter Client ID and Client Secret in plugin admin
13815440faSAtari911 *
14815440faSAtari911 * @license GPL 2 http://www.gnu.org/licenses/gpl-2.0.html
15815440faSAtari911 * @author  DokuWiki Community
16*2866e827SAtari911 * @version 7.2.6
17815440faSAtari911 */
18815440faSAtari911
19815440faSAtari911if (!defined('DOKU_INC')) die();
20815440faSAtari911
21815440faSAtari911class GoogleCalendarSync {
22815440faSAtari911
23815440faSAtari911    /** @var string Google OAuth endpoints */
24815440faSAtari911    const AUTH_URL = 'https://accounts.google.com/o/oauth2/v2/auth';
25815440faSAtari911    const TOKEN_URL = 'https://oauth2.googleapis.com/token';
26815440faSAtari911    const CALENDAR_API = 'https://www.googleapis.com/calendar/v3';
27815440faSAtari911
28815440faSAtari911    /** @var string Required OAuth scopes */
29815440faSAtari911    const SCOPES = 'https://www.googleapis.com/auth/calendar.readonly https://www.googleapis.com/auth/calendar.events';
30815440faSAtari911
31815440faSAtari911    /** @var string Path to config and token storage */
32815440faSAtari911    private $configDir;
33815440faSAtari911    private $configFile;
34815440faSAtari911    private $tokenFile;
35815440faSAtari911
36815440faSAtari911    /** @var array Configuration */
37815440faSAtari911    private $config = [];
38815440faSAtari911
39815440faSAtari911    /** @var CalendarAuditLogger */
40815440faSAtari911    private $auditLogger;
41815440faSAtari911
42815440faSAtari911    /**
43815440faSAtari911     * Constructor
44815440faSAtari911     */
45815440faSAtari911    public function __construct() {
46815440faSAtari911        global $conf;
47815440faSAtari911        $this->configDir = $conf['metadir'] . '/calendar/';
48815440faSAtari911        $this->configFile = $this->configDir . 'google_config.json';
49815440faSAtari911        $this->tokenFile = $this->configDir . 'google_token.json';
50815440faSAtari911
51815440faSAtari911        if (!is_dir($this->configDir)) {
52815440faSAtari911            @mkdir($this->configDir, 0775, true);
53815440faSAtari911        }
54815440faSAtari911
55815440faSAtari911        $this->loadConfig();
56815440faSAtari911
57815440faSAtari911        // Load audit logger if available
58815440faSAtari911        if (class_exists('CalendarAuditLogger')) {
59815440faSAtari911            $this->auditLogger = new CalendarAuditLogger();
60815440faSAtari911        }
61815440faSAtari911    }
62815440faSAtari911
63815440faSAtari911    /**
64815440faSAtari911     * Load configuration from file
65815440faSAtari911     */
66815440faSAtari911    private function loadConfig() {
67815440faSAtari911        if (file_exists($this->configFile)) {
68815440faSAtari911            $data = file_get_contents($this->configFile);
69815440faSAtari911            $this->config = json_decode($data, true) ?: [];
70815440faSAtari911        }
71815440faSAtari911    }
72815440faSAtari911
73815440faSAtari911    /**
74815440faSAtari911     * Save configuration to file
75815440faSAtari911     */
76815440faSAtari911    public function saveConfig($clientId, $clientSecret, $calendarId = 'primary') {
77815440faSAtari911        $this->config = [
78815440faSAtari911            'client_id' => $clientId,
79815440faSAtari911            'client_secret' => $clientSecret,
80815440faSAtari911            'calendar_id' => $calendarId,
81815440faSAtari911            'updated' => date('Y-m-d H:i:s')
82815440faSAtari911        ];
83815440faSAtari911
84815440faSAtari911        file_put_contents($this->configFile, json_encode($this->config, JSON_PRETTY_PRINT));
85815440faSAtari911
86815440faSAtari911        // Secure the file
87815440faSAtari911        @chmod($this->configFile, 0600);
88815440faSAtari911
89815440faSAtari911        return true;
90815440faSAtari911    }
91815440faSAtari911
92815440faSAtari911    /**
93815440faSAtari911     * Check if Google sync is configured
94815440faSAtari911     */
95815440faSAtari911    public function isConfigured() {
96815440faSAtari911        return !empty($this->config['client_id']) && !empty($this->config['client_secret']);
97815440faSAtari911    }
98815440faSAtari911
99815440faSAtari911    /**
100815440faSAtari911     * Check if we have a valid access token
101815440faSAtari911     */
102815440faSAtari911    public function isAuthenticated() {
103815440faSAtari911        if (!file_exists($this->tokenFile)) {
104815440faSAtari911            return false;
105815440faSAtari911        }
106815440faSAtari911
107815440faSAtari911        $token = $this->getToken();
108815440faSAtari911        if (!$token || empty($token['access_token'])) {
109815440faSAtari911            return false;
110815440faSAtari911        }
111815440faSAtari911
112815440faSAtari911        // Check if token is expired
113815440faSAtari911        if (isset($token['expires_at']) && time() >= $token['expires_at']) {
114815440faSAtari911            // Try to refresh
115815440faSAtari911            if (!empty($token['refresh_token'])) {
116815440faSAtari911                return $this->refreshToken($token['refresh_token']);
117815440faSAtari911            }
118815440faSAtari911            return false;
119815440faSAtari911        }
120815440faSAtari911
121815440faSAtari911        return true;
122815440faSAtari911    }
123815440faSAtari911
124815440faSAtari911    /**
125815440faSAtari911     * Get the OAuth authorization URL
126815440faSAtari911     */
127815440faSAtari911    public function getAuthUrl($redirectUri) {
128815440faSAtari911        if (!$this->isConfigured()) {
129815440faSAtari911            return null;
130815440faSAtari911        }
131815440faSAtari911
132815440faSAtari911        $state = bin2hex(random_bytes(16));
133815440faSAtari911        $this->saveState($state);
134815440faSAtari911
135815440faSAtari911        $params = [
136815440faSAtari911            'client_id' => $this->config['client_id'],
137815440faSAtari911            'redirect_uri' => $redirectUri,
138815440faSAtari911            'response_type' => 'code',
139815440faSAtari911            'scope' => self::SCOPES,
140815440faSAtari911            'access_type' => 'offline',
141815440faSAtari911            'prompt' => 'consent',
142815440faSAtari911            'state' => $state
143815440faSAtari911        ];
144815440faSAtari911
145815440faSAtari911        return self::AUTH_URL . '?' . http_build_query($params);
146815440faSAtari911    }
147815440faSAtari911
148815440faSAtari911    /**
149815440faSAtari911     * Save OAuth state for CSRF protection
150815440faSAtari911     */
151815440faSAtari911    private function saveState($state) {
152815440faSAtari911        $stateFile = $this->configDir . 'google_state.json';
153815440faSAtari911        file_put_contents($stateFile, json_encode([
154815440faSAtari911            'state' => $state,
155815440faSAtari911            'created' => time()
156815440faSAtari911        ]));
157815440faSAtari911    }
158815440faSAtari911
159815440faSAtari911    /**
160815440faSAtari911     * Verify OAuth state
161815440faSAtari911     */
162815440faSAtari911    public function verifyState($state) {
163815440faSAtari911        $stateFile = $this->configDir . 'google_state.json';
164815440faSAtari911        if (!file_exists($stateFile)) {
165815440faSAtari911            return false;
166815440faSAtari911        }
167815440faSAtari911
168815440faSAtari911        $data = json_decode(file_get_contents($stateFile), true);
169815440faSAtari911        @unlink($stateFile); // One-time use
170815440faSAtari911
171815440faSAtari911        // Check state matches and is not too old (10 minutes)
172815440faSAtari911        if ($data['state'] === $state && (time() - $data['created']) < 600) {
173815440faSAtari911            return true;
174815440faSAtari911        }
175815440faSAtari911
176815440faSAtari911        return false;
177815440faSAtari911    }
178815440faSAtari911
179815440faSAtari911    /**
180815440faSAtari911     * Exchange authorization code for tokens
181815440faSAtari911     */
182815440faSAtari911    public function handleCallback($code, $redirectUri) {
183815440faSAtari911        if (!$this->isConfigured()) {
184815440faSAtari911            return ['success' => false, 'error' => 'Google sync not configured'];
185815440faSAtari911        }
186815440faSAtari911
187815440faSAtari911        $params = [
188815440faSAtari911            'client_id' => $this->config['client_id'],
189815440faSAtari911            'client_secret' => $this->config['client_secret'],
190815440faSAtari911            'code' => $code,
191815440faSAtari911            'grant_type' => 'authorization_code',
192815440faSAtari911            'redirect_uri' => $redirectUri
193815440faSAtari911        ];
194815440faSAtari911
195815440faSAtari911        $response = $this->httpPost(self::TOKEN_URL, $params);
196815440faSAtari911
197815440faSAtari911        if (!$response || isset($response['error'])) {
198815440faSAtari911            return [
199815440faSAtari911                'success' => false,
200815440faSAtari911                'error' => $response['error_description'] ?? $response['error'] ?? 'Token exchange failed'
201815440faSAtari911            ];
202815440faSAtari911        }
203815440faSAtari911
204815440faSAtari911        // Save token with expiry time
205815440faSAtari911        $token = [
206815440faSAtari911            'access_token' => $response['access_token'],
207815440faSAtari911            'refresh_token' => $response['refresh_token'] ?? null,
208815440faSAtari911            'token_type' => $response['token_type'] ?? 'Bearer',
209815440faSAtari911            'expires_at' => time() + ($response['expires_in'] ?? 3600),
210815440faSAtari911            'created' => date('Y-m-d H:i:s')
211815440faSAtari911        ];
212815440faSAtari911
213815440faSAtari911        $this->saveToken($token);
214815440faSAtari911
215815440faSAtari911        if ($this->auditLogger) {
216815440faSAtari911            $this->auditLogger->log('google_auth', ['action' => 'connected']);
217815440faSAtari911        }
218815440faSAtari911
219815440faSAtari911        return ['success' => true];
220815440faSAtari911    }
221815440faSAtari911
222815440faSAtari911    /**
223815440faSAtari911     * Refresh the access token
224815440faSAtari911     */
225815440faSAtari911    private function refreshToken($refreshToken) {
226815440faSAtari911        $params = [
227815440faSAtari911            'client_id' => $this->config['client_id'],
228815440faSAtari911            'client_secret' => $this->config['client_secret'],
229815440faSAtari911            'refresh_token' => $refreshToken,
230815440faSAtari911            'grant_type' => 'refresh_token'
231815440faSAtari911        ];
232815440faSAtari911
233815440faSAtari911        $response = $this->httpPost(self::TOKEN_URL, $params);
234815440faSAtari911
235815440faSAtari911        if (!$response || isset($response['error'])) {
236815440faSAtari911            return false;
237815440faSAtari911        }
238815440faSAtari911
239815440faSAtari911        // Update token
240815440faSAtari911        $token = $this->getToken();
241815440faSAtari911        $token['access_token'] = $response['access_token'];
242815440faSAtari911        $token['expires_at'] = time() + ($response['expires_in'] ?? 3600);
243815440faSAtari911
244815440faSAtari911        // Preserve refresh token if not returned
245815440faSAtari911        if (isset($response['refresh_token'])) {
246815440faSAtari911            $token['refresh_token'] = $response['refresh_token'];
247815440faSAtari911        }
248815440faSAtari911
249815440faSAtari911        $this->saveToken($token);
250815440faSAtari911
251815440faSAtari911        return true;
252815440faSAtari911    }
253815440faSAtari911
254815440faSAtari911    /**
255815440faSAtari911     * Save token to file
256815440faSAtari911     */
257815440faSAtari911    private function saveToken($token) {
258815440faSAtari911        file_put_contents($this->tokenFile, json_encode($token, JSON_PRETTY_PRINT));
259815440faSAtari911        @chmod($this->tokenFile, 0600);
260815440faSAtari911    }
261815440faSAtari911
262815440faSAtari911    /**
263815440faSAtari911     * Get current token
264815440faSAtari911     */
265815440faSAtari911    private function getToken() {
266815440faSAtari911        if (!file_exists($this->tokenFile)) {
267815440faSAtari911            return null;
268815440faSAtari911        }
269815440faSAtari911        return json_decode(file_get_contents($this->tokenFile), true);
270815440faSAtari911    }
271815440faSAtari911
272815440faSAtari911    /**
273815440faSAtari911     * Disconnect from Google Calendar
274815440faSAtari911     */
275815440faSAtari911    public function disconnect() {
276815440faSAtari911        if (file_exists($this->tokenFile)) {
277815440faSAtari911            @unlink($this->tokenFile);
278815440faSAtari911        }
279815440faSAtari911
280815440faSAtari911        if ($this->auditLogger) {
281815440faSAtari911            $this->auditLogger->log('google_auth', ['action' => 'disconnected']);
282815440faSAtari911        }
283815440faSAtari911
284815440faSAtari911        return true;
285815440faSAtari911    }
286815440faSAtari911
287815440faSAtari911    /**
288815440faSAtari911     * Get list of user's calendars
289815440faSAtari911     */
290815440faSAtari911    public function getCalendars() {
291815440faSAtari911        if (!$this->isAuthenticated()) {
292815440faSAtari911            return ['success' => false, 'error' => 'Not authenticated'];
293815440faSAtari911        }
294815440faSAtari911
295815440faSAtari911        $token = $this->getToken();
296815440faSAtari911        $url = self::CALENDAR_API . '/users/me/calendarList';
297815440faSAtari911
298815440faSAtari911        $response = $this->httpGet($url, $token['access_token']);
299815440faSAtari911
300815440faSAtari911        if (!$response || isset($response['error'])) {
301815440faSAtari911            return [
302815440faSAtari911                'success' => false,
303815440faSAtari911                'error' => $response['error']['message'] ?? 'Failed to get calendars'
304815440faSAtari911            ];
305815440faSAtari911        }
306815440faSAtari911
307815440faSAtari911        $calendars = [];
308815440faSAtari911        foreach ($response['items'] ?? [] as $cal) {
309815440faSAtari911            $calendars[] = [
310815440faSAtari911                'id' => $cal['id'],
311815440faSAtari911                'summary' => $cal['summary'],
312815440faSAtari911                'primary' => $cal['primary'] ?? false,
313815440faSAtari911                'accessRole' => $cal['accessRole']
314815440faSAtari911            ];
315815440faSAtari911        }
316815440faSAtari911
317815440faSAtari911        return ['success' => true, 'calendars' => $calendars];
318815440faSAtari911    }
319815440faSAtari911
320815440faSAtari911    /**
321815440faSAtari911     * Import events from Google Calendar
322815440faSAtari911     *
323815440faSAtari911     * @param string $namespace DokuWiki namespace to import into
324815440faSAtari911     * @param string $startDate Start date (Y-m-d)
325815440faSAtari911     * @param string $endDate End date (Y-m-d)
326815440faSAtari911     * @return array Result with imported count
327815440faSAtari911     */
328815440faSAtari911    public function importEvents($namespace = '', $startDate = null, $endDate = null) {
329815440faSAtari911        if (!$this->isAuthenticated()) {
330815440faSAtari911            return ['success' => false, 'error' => 'Not authenticated'];
331815440faSAtari911        }
332815440faSAtari911
333815440faSAtari911        // Default date range: 3 months past to 12 months future
334815440faSAtari911        if (!$startDate) {
335815440faSAtari911            $startDate = date('Y-m-d', strtotime('-3 months'));
336815440faSAtari911        }
337815440faSAtari911        if (!$endDate) {
338815440faSAtari911            $endDate = date('Y-m-d', strtotime('+12 months'));
339815440faSAtari911        }
340815440faSAtari911
341815440faSAtari911        $token = $this->getToken();
342815440faSAtari911        $calendarId = $this->config['calendar_id'] ?? 'primary';
343815440faSAtari911
344815440faSAtari911        // Build API URL
345815440faSAtari911        $url = self::CALENDAR_API . '/calendars/' . urlencode($calendarId) . '/events';
346815440faSAtari911        $params = [
347815440faSAtari911            'timeMin' => $startDate . 'T00:00:00Z',
348815440faSAtari911            'timeMax' => $endDate . 'T23:59:59Z',
349815440faSAtari911            'singleEvents' => 'true',  // Expand recurring events
350815440faSAtari911            'orderBy' => 'startTime',
351815440faSAtari911            'maxResults' => 2500
352815440faSAtari911        ];
353815440faSAtari911
354815440faSAtari911        $response = $this->httpGet($url . '?' . http_build_query($params), $token['access_token']);
355815440faSAtari911
356815440faSAtari911        if (!$response || isset($response['error'])) {
357815440faSAtari911            return [
358815440faSAtari911                'success' => false,
359815440faSAtari911                'error' => $response['error']['message'] ?? 'Failed to fetch events'
360815440faSAtari911            ];
361815440faSAtari911        }
362815440faSAtari911
363815440faSAtari911        // Process and save events
364815440faSAtari911        $imported = 0;
365815440faSAtari911        $skipped = 0;
366815440faSAtari911        $errors = [];
367815440faSAtari911
368815440faSAtari911        foreach ($response['items'] ?? [] as $gEvent) {
369815440faSAtari911            $result = $this->importSingleEvent($gEvent, $namespace);
370815440faSAtari911            if ($result['success']) {
371815440faSAtari911                $imported++;
372815440faSAtari911            } elseif ($result['skipped']) {
373815440faSAtari911                $skipped++;
374815440faSAtari911            } else {
375815440faSAtari911                $errors[] = $result['error'];
376815440faSAtari911            }
377815440faSAtari911        }
378815440faSAtari911
379815440faSAtari911        if ($this->auditLogger) {
380815440faSAtari911            $this->auditLogger->log('google_import', [
381815440faSAtari911                'namespace' => $namespace,
382815440faSAtari911                'imported' => $imported,
383815440faSAtari911                'skipped' => $skipped,
384815440faSAtari911                'date_range' => "$startDate to $endDate"
385815440faSAtari911            ]);
386815440faSAtari911        }
387815440faSAtari911
388815440faSAtari911        return [
389815440faSAtari911            'success' => true,
390815440faSAtari911            'imported' => $imported,
391815440faSAtari911            'skipped' => $skipped,
392815440faSAtari911            'errors' => $errors
393815440faSAtari911        ];
394815440faSAtari911    }
395815440faSAtari911
396815440faSAtari911    /**
397815440faSAtari911     * Import a single Google event
398815440faSAtari911     */
399815440faSAtari911    private function importSingleEvent($gEvent, $namespace) {
400815440faSAtari911        // Skip cancelled events
401815440faSAtari911        if (($gEvent['status'] ?? '') === 'cancelled') {
402815440faSAtari911            return ['success' => false, 'skipped' => true];
403815440faSAtari911        }
404815440faSAtari911
405815440faSAtari911        // Parse date/time
406815440faSAtari911        $startDateTime = $gEvent['start']['dateTime'] ?? $gEvent['start']['date'] ?? null;
407815440faSAtari911        $endDateTime = $gEvent['end']['dateTime'] ?? $gEvent['end']['date'] ?? null;
408815440faSAtari911
409815440faSAtari911        if (!$startDateTime) {
410815440faSAtari911            return ['success' => false, 'skipped' => true, 'error' => 'No start date'];
411815440faSAtari911        }
412815440faSAtari911
413815440faSAtari911        // Determine if all-day event
414815440faSAtari911        $isAllDay = isset($gEvent['start']['date']) && !isset($gEvent['start']['dateTime']);
415815440faSAtari911
416815440faSAtari911        // Parse dates
417815440faSAtari911        if ($isAllDay) {
418815440faSAtari911            $date = $gEvent['start']['date'];
419815440faSAtari911            $endDate = $gEvent['end']['date'];
420815440faSAtari911            // Google all-day events end on the next day
421815440faSAtari911            $endDate = date('Y-m-d', strtotime($endDate . ' -1 day'));
422815440faSAtari911            $time = '';
423815440faSAtari911            $endTime = '';
424815440faSAtari911        } else {
425815440faSAtari911            $startObj = new DateTime($startDateTime);
426815440faSAtari911            $endObj = new DateTime($endDateTime);
427815440faSAtari911
428815440faSAtari911            $date = $startObj->format('Y-m-d');
429815440faSAtari911            $endDate = $endObj->format('Y-m-d');
430815440faSAtari911            $time = $startObj->format('H:i');
431815440faSAtari911            $endTime = $endObj->format('H:i');
432815440faSAtari911
433815440faSAtari911            // If same day, don't set endDate
434815440faSAtari911            if ($date === $endDate) {
435815440faSAtari911                $endDate = '';
436815440faSAtari911            }
437815440faSAtari911        }
438815440faSAtari911
439815440faSAtari911        // Build event data
440815440faSAtari911        $eventId = 'g_' . substr(md5($gEvent['id']), 0, 8) . '_' . time();
441815440faSAtari911
442815440faSAtari911        $eventData = [
443815440faSAtari911            'id' => $eventId,
444815440faSAtari911            'title' => $gEvent['summary'] ?? 'Untitled',
445815440faSAtari911            'time' => $time,
446815440faSAtari911            'endTime' => $endTime,
447815440faSAtari911            'description' => $gEvent['description'] ?? '',
448815440faSAtari911            'color' => $this->colorFromGoogle($gEvent['colorId'] ?? null),
449815440faSAtari911            'isTask' => false,
450815440faSAtari911            'completed' => false,
451815440faSAtari911            'endDate' => $endDate,
452815440faSAtari911            'namespace' => $namespace,
453815440faSAtari911            'googleId' => $gEvent['id'],
454815440faSAtari911            'created' => date('Y-m-d H:i:s'),
455815440faSAtari911            'imported' => true
456815440faSAtari911        ];
457815440faSAtari911
458815440faSAtari911        // Save to calendar file
459815440faSAtari911        return $this->saveImportedEvent($namespace, $date, $eventData);
460815440faSAtari911    }
461815440faSAtari911
462815440faSAtari911    /**
463815440faSAtari911     * Save an imported event to the calendar JSON file
464815440faSAtari911     */
465815440faSAtari911    private function saveImportedEvent($namespace, $date, $eventData) {
466*2866e827SAtari911        global $conf;
467815440faSAtari911        list($year, $month, $day) = explode('-', $date);
468815440faSAtari911
469*2866e827SAtari911        $dataDir = $conf['metadir'] . '/';
470815440faSAtari911        if ($namespace) {
471815440faSAtari911            $dataDir .= str_replace(':', '/', $namespace) . '/';
472815440faSAtari911        }
473815440faSAtari911        $dataDir .= 'calendar/';
474815440faSAtari911
475815440faSAtari911        if (!is_dir($dataDir)) {
476815440faSAtari911            @mkdir($dataDir, 0755, true);
477815440faSAtari911        }
478815440faSAtari911
479815440faSAtari911        $eventFile = $dataDir . sprintf('%04d-%02d.json', $year, $month);
480815440faSAtari911
481815440faSAtari911        // Load existing events
482815440faSAtari911        $events = [];
483815440faSAtari911        if (file_exists($eventFile)) {
484815440faSAtari911            $events = json_decode(file_get_contents($eventFile), true) ?: [];
485815440faSAtari911        }
486815440faSAtari911
487815440faSAtari911        // Check if this Google event already exists (by googleId)
488815440faSAtari911        if (isset($events[$date])) {
489815440faSAtari911            foreach ($events[$date] as $existing) {
490815440faSAtari911                if (isset($existing['googleId']) && $existing['googleId'] === $eventData['googleId']) {
491815440faSAtari911                    return ['success' => false, 'skipped' => true]; // Already imported
492815440faSAtari911                }
493815440faSAtari911            }
494815440faSAtari911        }
495815440faSAtari911
496815440faSAtari911        // Add event
497815440faSAtari911        if (!isset($events[$date])) {
498815440faSAtari911            $events[$date] = [];
499815440faSAtari911        }
500815440faSAtari911        $events[$date][] = $eventData;
501815440faSAtari911
502815440faSAtari911        // Save using file handler if available
503815440faSAtari911        if (class_exists('CalendarFileHandler')) {
504815440faSAtari911            CalendarFileHandler::writeJson($eventFile, $events);
505815440faSAtari911        } else {
506815440faSAtari911            file_put_contents($eventFile, json_encode($events, JSON_PRETTY_PRINT));
507815440faSAtari911        }
508815440faSAtari911
509815440faSAtari911        return ['success' => true];
510815440faSAtari911    }
511815440faSAtari911
512815440faSAtari911    /**
513815440faSAtari911     * Export events to Google Calendar
514815440faSAtari911     *
515815440faSAtari911     * @param string $namespace DokuWiki namespace to export from
516815440faSAtari911     * @param string $startDate Start date (Y-m-d)
517815440faSAtari911     * @param string $endDate End date (Y-m-d)
518815440faSAtari911     * @return array Result with exported count
519815440faSAtari911     */
520815440faSAtari911    public function exportEvents($namespace = '', $startDate = null, $endDate = null) {
521815440faSAtari911        if (!$this->isAuthenticated()) {
522815440faSAtari911            return ['success' => false, 'error' => 'Not authenticated'];
523815440faSAtari911        }
524815440faSAtari911
525815440faSAtari911        // Default date range
526815440faSAtari911        if (!$startDate) {
527815440faSAtari911            $startDate = date('Y-m-d');
528815440faSAtari911        }
529815440faSAtari911        if (!$endDate) {
530815440faSAtari911            $endDate = date('Y-m-d', strtotime('+12 months'));
531815440faSAtari911        }
532815440faSAtari911
533815440faSAtari911        $token = $this->getToken();
534815440faSAtari911        $calendarId = $this->config['calendar_id'] ?? 'primary';
535815440faSAtari911
536815440faSAtari911        // Find events in date range
537815440faSAtari911        $events = $this->getLocalEvents($namespace, $startDate, $endDate);
538815440faSAtari911
539815440faSAtari911        $exported = 0;
540815440faSAtari911        $skipped = 0;
541815440faSAtari911        $errors = [];
542815440faSAtari911
543815440faSAtari911        foreach ($events as $event) {
544815440faSAtari911            // Skip already-imported events (came from Google)
545815440faSAtari911            if (!empty($event['imported']) || !empty($event['googleId'])) {
546815440faSAtari911                $skipped++;
547815440faSAtari911                continue;
548815440faSAtari911            }
549815440faSAtari911
550815440faSAtari911            $result = $this->exportSingleEvent($event, $calendarId, $token['access_token']);
551815440faSAtari911            if ($result['success']) {
552815440faSAtari911                $exported++;
553815440faSAtari911            } else {
554815440faSAtari911                $errors[] = $result['error'];
555815440faSAtari911            }
556815440faSAtari911        }
557815440faSAtari911
558815440faSAtari911        if ($this->auditLogger) {
559815440faSAtari911            $this->auditLogger->log('google_export', [
560815440faSAtari911                'namespace' => $namespace,
561815440faSAtari911                'exported' => $exported,
562815440faSAtari911                'skipped' => $skipped,
563815440faSAtari911                'date_range' => "$startDate to $endDate"
564815440faSAtari911            ]);
565815440faSAtari911        }
566815440faSAtari911
567815440faSAtari911        return [
568815440faSAtari911            'success' => true,
569815440faSAtari911            'exported' => $exported,
570815440faSAtari911            'skipped' => $skipped,
571815440faSAtari911            'errors' => $errors
572815440faSAtari911        ];
573815440faSAtari911    }
574815440faSAtari911
575815440faSAtari911    /**
576815440faSAtari911     * Export a single event to Google
577815440faSAtari911     */
578815440faSAtari911    private function exportSingleEvent($event, $calendarId, $accessToken) {
579815440faSAtari911        $date = $event['date'];
580815440faSAtari911        $endDate = $event['endDate'] ?? $date;
581815440faSAtari911
582815440faSAtari911        // Build Google event
583815440faSAtari911        if (empty($event['time'])) {
584815440faSAtari911            // All-day event
585815440faSAtari911            $gEvent = [
586815440faSAtari911                'summary' => $event['title'],
587815440faSAtari911                'description' => $event['description'] ?? '',
588815440faSAtari911                'start' => ['date' => $date],
589815440faSAtari911                'end' => ['date' => date('Y-m-d', strtotime($endDate . ' +1 day'))] // Google expects exclusive end
590815440faSAtari911            ];
591815440faSAtari911        } else {
592815440faSAtari911            // Timed event
593815440faSAtari911            $startTime = $date . 'T' . $event['time'] . ':00';
594815440faSAtari911            $endTime = ($endDate ?: $date) . 'T' . ($event['endTime'] ?: $event['time']) . ':00';
595815440faSAtari911
596815440faSAtari911            $gEvent = [
597815440faSAtari911                'summary' => $event['title'],
598815440faSAtari911                'description' => $event['description'] ?? '',
599815440faSAtari911                'start' => ['dateTime' => $startTime, 'timeZone' => date_default_timezone_get()],
600815440faSAtari911                'end' => ['dateTime' => $endTime, 'timeZone' => date_default_timezone_get()]
601815440faSAtari911            ];
602815440faSAtari911        }
603815440faSAtari911
604815440faSAtari911        // Set color if available
605815440faSAtari911        $colorId = $this->colorToGoogle($event['color'] ?? null);
606815440faSAtari911        if ($colorId) {
607815440faSAtari911            $gEvent['colorId'] = $colorId;
608815440faSAtari911        }
609815440faSAtari911
610815440faSAtari911        // Create event via API
611815440faSAtari911        $url = self::CALENDAR_API . '/calendars/' . urlencode($calendarId) . '/events';
612815440faSAtari911        $response = $this->httpPost($url, $gEvent, $accessToken, true);
613815440faSAtari911
614815440faSAtari911        if (!$response || isset($response['error'])) {
615815440faSAtari911            return [
616815440faSAtari911                'success' => false,
617815440faSAtari911                'error' => ($event['title'] ?? 'Event') . ': ' . ($response['error']['message'] ?? 'Failed to create')
618815440faSAtari911            ];
619815440faSAtari911        }
620815440faSAtari911
621815440faSAtari911        return ['success' => true, 'googleId' => $response['id']];
622815440faSAtari911    }
623815440faSAtari911
624815440faSAtari911    /**
625815440faSAtari911     * Get local calendar events
626815440faSAtari911     */
627815440faSAtari911    private function getLocalEvents($namespace, $startDate, $endDate) {
628*2866e827SAtari911        global $conf;
629815440faSAtari911        $events = [];
630815440faSAtari911
631*2866e827SAtari911        $dataDir = $conf['metadir'] . '/';
632815440faSAtari911        if ($namespace) {
633815440faSAtari911            $dataDir .= str_replace(':', '/', $namespace) . '/';
634815440faSAtari911        }
635815440faSAtari911        $dataDir .= 'calendar/';
636815440faSAtari911
637815440faSAtari911        if (!is_dir($dataDir)) {
638815440faSAtari911            return $events;
639815440faSAtari911        }
640815440faSAtari911
641815440faSAtari911        // Parse date range
642815440faSAtari911        $startObj = new DateTime($startDate);
643815440faSAtari911        $endObj = new DateTime($endDate);
644815440faSAtari911
645815440faSAtari911        // Iterate through month files
646815440faSAtari911        $current = clone $startObj;
647815440faSAtari911        $current->modify('first day of this month');
648815440faSAtari911
649815440faSAtari911        while ($current <= $endObj) {
650815440faSAtari911            $file = $dataDir . $current->format('Y-m') . '.json';
651815440faSAtari911
652815440faSAtari911            if (file_exists($file)) {
653815440faSAtari911                $data = json_decode(file_get_contents($file), true) ?: [];
654815440faSAtari911
655815440faSAtari911                foreach ($data as $date => $dayEvents) {
656815440faSAtari911                    if ($date >= $startDate && $date <= $endDate) {
657815440faSAtari911                        foreach ($dayEvents as $event) {
658815440faSAtari911                            $event['date'] = $date;
659815440faSAtari911                            $events[] = $event;
660815440faSAtari911                        }
661815440faSAtari911                    }
662815440faSAtari911                }
663815440faSAtari911            }
664815440faSAtari911
665815440faSAtari911            $current->modify('+1 month');
666815440faSAtari911        }
667815440faSAtari911
668815440faSAtari911        return $events;
669815440faSAtari911    }
670815440faSAtari911
671815440faSAtari911    /**
672815440faSAtari911     * Convert Google color ID to hex
673815440faSAtari911     */
674815440faSAtari911    private function colorFromGoogle($colorId) {
675815440faSAtari911        $colors = [
676815440faSAtari911            '1' => '#7986cb',  // Lavender
677815440faSAtari911            '2' => '#33b679',  // Sage
678815440faSAtari911            '3' => '#8e24aa',  // Grape
679815440faSAtari911            '4' => '#e67c73',  // Flamingo
680815440faSAtari911            '5' => '#f6c026',  // Banana
681815440faSAtari911            '6' => '#f5511d',  // Tangerine
682815440faSAtari911            '7' => '#039be5',  // Peacock
683815440faSAtari911            '8' => '#616161',  // Graphite
684815440faSAtari911            '9' => '#3f51b5',  // Blueberry
685815440faSAtari911            '10' => '#0b8043', // Basil
686815440faSAtari911            '11' => '#d60000', // Tomato
687815440faSAtari911        ];
688815440faSAtari911
689815440faSAtari911        return $colors[$colorId] ?? '#3498db';
690815440faSAtari911    }
691815440faSAtari911
692815440faSAtari911    /**
693815440faSAtari911     * Convert hex color to Google color ID
694815440faSAtari911     */
695815440faSAtari911    private function colorToGoogle($hex) {
696815440faSAtari911        if (!$hex) return null;
697815440faSAtari911
698815440faSAtari911        $hex = strtolower($hex);
699815440faSAtari911
700815440faSAtari911        // Map common colors to Google IDs
701815440faSAtari911        $map = [
702815440faSAtari911            '#7986cb' => '1', '#33b679' => '2', '#8e24aa' => '3',
703815440faSAtari911            '#e67c73' => '4', '#f6c026' => '5', '#f5511d' => '6',
704815440faSAtari911            '#039be5' => '7', '#616161' => '8', '#3f51b5' => '9',
705815440faSAtari911            '#0b8043' => '10', '#d60000' => '11',
706815440faSAtari911            // Common defaults
707815440faSAtari911            '#3498db' => '7', // Blue -> Peacock
708815440faSAtari911            '#e74c3c' => '11', // Red -> Tomato
709815440faSAtari911            '#2ecc71' => '2', // Green -> Sage
710815440faSAtari911            '#9b59b6' => '3', // Purple -> Grape
711815440faSAtari911            '#f39c12' => '5', // Orange -> Banana
712815440faSAtari911        ];
713815440faSAtari911
714815440faSAtari911        return $map[$hex] ?? null;
715815440faSAtari911    }
716815440faSAtari911
717815440faSAtari911    /**
718815440faSAtari911     * HTTP GET request
719815440faSAtari911     */
720815440faSAtari911    private function httpGet($url, $accessToken = null) {
721815440faSAtari911        $headers = ['Accept: application/json'];
722815440faSAtari911
723815440faSAtari911        if ($accessToken) {
724815440faSAtari911            $headers[] = 'Authorization: Bearer ' . $accessToken;
725815440faSAtari911        }
726815440faSAtari911
727815440faSAtari911        $ch = curl_init();
728815440faSAtari911        curl_setopt_array($ch, [
729815440faSAtari911            CURLOPT_URL => $url,
730815440faSAtari911            CURLOPT_RETURNTRANSFER => true,
731815440faSAtari911            CURLOPT_HTTPHEADER => $headers,
732815440faSAtari911            CURLOPT_TIMEOUT => 30
733815440faSAtari911        ]);
734815440faSAtari911
735815440faSAtari911        $response = curl_exec($ch);
736815440faSAtari911        curl_close($ch);
737815440faSAtari911
738815440faSAtari911        return json_decode($response, true);
739815440faSAtari911    }
740815440faSAtari911
741815440faSAtari911    /**
742815440faSAtari911     * HTTP POST request
743815440faSAtari911     */
744815440faSAtari911    private function httpPost($url, $data, $accessToken = null, $json = false) {
745815440faSAtari911        $headers = ['Accept: application/json'];
746815440faSAtari911
747815440faSAtari911        if ($accessToken) {
748815440faSAtari911            $headers[] = 'Authorization: Bearer ' . $accessToken;
749815440faSAtari911        }
750815440faSAtari911
751815440faSAtari911        if ($json) {
752815440faSAtari911            $headers[] = 'Content-Type: application/json';
753815440faSAtari911            $postData = json_encode($data);
754815440faSAtari911        } else {
755815440faSAtari911            $headers[] = 'Content-Type: application/x-www-form-urlencoded';
756815440faSAtari911            $postData = http_build_query($data);
757815440faSAtari911        }
758815440faSAtari911
759815440faSAtari911        $ch = curl_init();
760815440faSAtari911        curl_setopt_array($ch, [
761815440faSAtari911            CURLOPT_URL => $url,
762815440faSAtari911            CURLOPT_RETURNTRANSFER => true,
763815440faSAtari911            CURLOPT_POST => true,
764815440faSAtari911            CURLOPT_POSTFIELDS => $postData,
765815440faSAtari911            CURLOPT_HTTPHEADER => $headers,
766815440faSAtari911            CURLOPT_TIMEOUT => 30
767815440faSAtari911        ]);
768815440faSAtari911
769815440faSAtari911        $response = curl_exec($ch);
770815440faSAtari911        curl_close($ch);
771815440faSAtari911
772815440faSAtari911        return json_decode($response, true);
773815440faSAtari911    }
774815440faSAtari911
775815440faSAtari911    /**
776815440faSAtari911     * Get sync status information
777815440faSAtari911     */
778815440faSAtari911    public function getStatus() {
779815440faSAtari911        return [
780815440faSAtari911            'configured' => $this->isConfigured(),
781815440faSAtari911            'authenticated' => $this->isAuthenticated(),
782815440faSAtari911            'calendar_id' => $this->config['calendar_id'] ?? 'primary',
783815440faSAtari911            'has_client_id' => !empty($this->config['client_id']),
784815440faSAtari911            'config_date' => $this->config['updated'] ?? null
785815440faSAtari911        ];
786815440faSAtari911    }
787815440faSAtari911
788815440faSAtari911    /**
789815440faSAtari911     * Get the configured calendar ID
790815440faSAtari911     */
791815440faSAtari911    public function getCalendarId() {
792815440faSAtari911        return $this->config['calendar_id'] ?? 'primary';
793815440faSAtari911    }
794815440faSAtari911
795815440faSAtari911    /**
796815440faSAtari911     * Set the calendar ID to sync with
797815440faSAtari911     */
798815440faSAtari911    public function setCalendarId($calendarId) {
799815440faSAtari911        $this->config['calendar_id'] = $calendarId;
800815440faSAtari911        file_put_contents($this->configFile, json_encode($this->config, JSON_PRETTY_PRINT));
801815440faSAtari911        return true;
802815440faSAtari911    }
803815440faSAtari911}
804