xref: /plugin/calendar/sync_outlook.php (revision 2866e8271e4daef3b32eacb3a9082d02159b592b)
1#!/usr/bin/env php
2<?php
3/**
4 * DokuWiki Calendar → Outlook Sync (Delta Mode)
5 *
6 * Syncs calendar events from DokuWiki to Office 365/Outlook via Microsoft Graph API.
7 * Uses hash-based change tracking to only sync new, modified, or deleted events.
8 * Unchanged events are skipped entirely (zero API calls).
9 *
10 * Usage:
11 *   php sync_outlook.php                       # Delta sync (only changes)
12 *   php sync_outlook.php --dry-run             # Show what would change
13 *   php sync_outlook.php --namespace=work      # Sync only specific namespace
14 *   php sync_outlook.php --force               # Force re-sync ALL events
15 *   php sync_outlook.php --clean-duplicates    # Remove duplicate events
16 *   php sync_outlook.php --reset               # Reset sync state, rebuild from scratch
17 *
18 * First run after upgrade: existing sync_state.json will be auto-migrated
19 * to v2 format with hash tracking. All events will re-sync once to populate hashes.
20 * Subsequent runs will only touch changed events.
21 *
22 * Setup:
23 *   1. Edit sync_config.php with your Azure credentials
24 *   2. Run: php sync_outlook.php --dry-run
25 *   3. If looks good: php sync_outlook.php
26 *   4. Add to cron (see documentation for cron syntax)
27 */
28
29// Parse command line options
30$options = getopt('', ['dry-run', 'namespace:', 'force', 'verbose', 'clean-duplicates', 'reset']);
31$dryRun = isset($options['dry-run']);
32$forceSync = isset($options['force']);
33$verbose = isset($options['verbose']) || $dryRun;
34$cleanDuplicates = isset($options['clean-duplicates']);
35$reset = isset($options['reset']);
36$filterNamespace = isset($options['namespace']) ? $options['namespace'] : null;
37
38// Determine script directory
39$scriptDir = __DIR__;
40$dokuwikiRoot = dirname(dirname(dirname($scriptDir))); // Go up to dokuwiki root
41
42// Determine meta directory
43// Parse DokuWiki's local.php for custom metadir/savedir (farm-safe, no include)
44$metaDir = $dokuwikiRoot . '/data/meta';
45$localConf = $dokuwikiRoot . '/conf/local.php';
46if (file_exists($localConf)) {
47    $localContent = file_get_contents($localConf);
48    // Look for $conf['metadir'] = '...';
49    if (preg_match("/\\$conf\['metadir'\]\s*=\s*'([^']+)'/", $localContent, $m)) {
50        $metaDir = rtrim($m[1], '/');
51    } elseif (preg_match("/\\$conf\['savedir'\]\s*=\s*'([^']+)'/", $localContent, $m)) {
52        $candidateMetaDir = rtrim($m[1], '/') . '/meta';
53        if (is_dir($candidateMetaDir)) {
54            $metaDir = $candidateMetaDir;
55        }
56    }
57}
58
59// Load sync configuration
60// Priority: plugin directory first (original/working location), per-wiki override second
61$configFile = $scriptDir . '/sync_config.php';
62$perWikiConfig = $metaDir . '/calendar/sync_config.php';
63// Per-wiki config only takes priority if it exists (explicit opt-in for farm setups)
64if (file_exists($perWikiConfig)) {
65    $configFile = $perWikiConfig;
66}
67if (!file_exists($configFile)) {
68    die("ERROR: Configuration file not found: $configFile\n" .
69        "Please copy sync_config.php and add your credentials.\n");
70}
71
72// Debug: show which config file is being used
73if ($verbose) {
74    echo "[CONFIG] Loading: $configFile\n";
75}
76
77$config = require $configFile;
78
79// Validate configuration
80if (empty($config['tenant_id']) || strpos($config['tenant_id'], 'YOUR_') !== false) {
81    die("ERROR: Please configure your Azure credentials in sync_config.php\n");
82}
83
84// Files - store in DokuWiki data directory (writable), not plugin directory
85$dataDir = $metaDir . '/calendar/';
86if (!is_dir($dataDir)) {
87    mkdir($dataDir, 0755, true);
88}
89$stateFile = $dataDir . 'sync_state.json';
90$logFile = $dataDir . 'sync.log';
91
92// Initialize
93$stats = [
94    'scanned' => 0,
95    'created' => 0,
96    'updated' => 0,
97    'deleted' => 0,
98    'recreated' => 0,
99    'skipped' => 0,
100    'errors' => 0
101];
102
103// Logging
104function logMessage($message, $level = 'INFO') {
105    global $logFile, $verbose, $config;
106
107    // Use timezone from config, fallback to America/Los_Angeles
108    $timezone = isset($config['timezone']) ? $config['timezone'] : 'America/Los_Angeles';
109    $tz = new DateTimeZone($timezone);
110    $now = new DateTime('now', $tz);
111    $timestamp = $now->format('Y-m-d H:i:s');
112
113    $logLine = "[$timestamp] [$level] $message\n";
114
115    if ($verbose || $level === 'ERROR') {
116        echo $logLine;
117    }
118
119    file_put_contents($logFile, $logLine, FILE_APPEND);
120}
121
122logMessage("=== DokuWiki → Outlook Sync Started ===");
123if ($dryRun) logMessage("DRY RUN MODE - No changes will be made");
124if ($filterNamespace) logMessage("Filtering namespace: $filterNamespace");
125if ($reset) logMessage("RESET MODE - Will rebuild sync state from scratch");
126if ($cleanDuplicates) logMessage("CLEAN DUPLICATES MODE - Will remove all duplicate events");
127
128// =============================================================================
129// MICROSOFT GRAPH API CLIENT
130// =============================================================================
131
132class MicrosoftGraphClient {
133    private $config;
134    private $accessToken = null;
135    private $tokenExpiry = 0;
136
137    public function __construct($config) {
138        $this->config = $config;
139    }
140
141    public function getAccessToken() {
142        // Check if we have a valid cached token
143        if ($this->accessToken && time() < $this->tokenExpiry) {
144            return $this->accessToken;
145        }
146
147        // Request new token
148        $tokenUrl = "https://login.microsoftonline.com/{$this->config['tenant_id']}/oauth2/v2.0/token";
149
150        $data = [
151            'grant_type' => 'client_credentials',
152            'client_id' => $this->config['client_id'],
153            'client_secret' => $this->config['client_secret'],
154            'scope' => 'https://graph.microsoft.com/.default'
155        ];
156
157        $ch = curl_init($tokenUrl);
158        curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
159        curl_setopt($ch, CURLOPT_POST, true);
160        curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($data));
161        curl_setopt($ch, CURLOPT_TIMEOUT, $this->config['api_timeout']);
162
163        $response = curl_exec($ch);
164        $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
165        curl_close($ch);
166
167        if ($httpCode !== 200) {
168            throw new Exception("Failed to get access token: HTTP $httpCode - $response");
169        }
170
171        $result = json_decode($response, true);
172        if (!isset($result['access_token'])) {
173            throw new Exception("No access token in response: $response");
174        }
175
176        $this->accessToken = $result['access_token'];
177        $this->tokenExpiry = time() + ($result['expires_in'] - 300); // Refresh 5min early
178
179        return $this->accessToken;
180    }
181
182    public function apiRequest($method, $endpoint, $data = null) {
183        $token = $this->getAccessToken();
184        $url = "https://graph.microsoft.com/v1.0" . $endpoint;
185
186        $ch = curl_init($url);
187        curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
188        curl_setopt($ch, CURLOPT_TIMEOUT, $this->config['api_timeout']);
189        curl_setopt($ch, CURLOPT_HTTPHEADER, [
190            'Authorization: Bearer ' . $token,
191            'Content-Type: application/json',
192            'Prefer: outlook.timezone="' . $this->config['timezone'] . '"'
193        ]);
194
195        if ($method !== 'GET') {
196            curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method);
197        }
198
199        if ($data !== null) {
200            $jsonData = json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
201            if ($jsonData === false) {
202                throw new Exception("Failed to encode JSON: " . json_last_error_msg());
203            }
204            curl_setopt($ch, CURLOPT_POSTFIELDS, $jsonData);
205        }
206
207        $response = curl_exec($ch);
208        $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
209        curl_close($ch);
210
211        if ($httpCode >= 400) {
212            throw new Exception("API request failed: $method $endpoint - HTTP $httpCode - $response");
213        }
214
215        return json_decode($response, true);
216    }
217
218    public function createEvent($userEmail, $eventData) {
219        return $this->apiRequest('POST', "/users/$userEmail/events", $eventData);
220    }
221
222    public function updateEvent($userEmail, $outlookId, $eventData) {
223        return $this->apiRequest('PATCH', "/users/$userEmail/events/$outlookId", $eventData);
224    }
225
226    public function deleteEvent($userEmail, $outlookId) {
227        return $this->apiRequest('DELETE', "/users/$userEmail/events/$outlookId");
228    }
229
230    public function getEvent($userEmail, $outlookId) {
231        try {
232            return $this->apiRequest('GET', "/users/$userEmail/events/$outlookId");
233        } catch (Exception $e) {
234            return null; // Event not found
235        }
236    }
237
238    public function findEventByDokuWikiId($userEmail, $dokuwikiId) {
239        // Search for events with our custom extended property
240        $filter = rawurlencode("singleValueExtendedProperties/Any(ep: ep/id eq 'String {66f5a359-4659-4830-9070-00047ec6ac6e} Name DokuWikiId' and ep/value eq '$dokuwikiId')");
241
242        try {
243            $result = $this->apiRequest('GET', "/users/$userEmail/events?\$filter=$filter&\$select=id,subject");
244            return isset($result['value']) ? $result['value'] : [];
245        } catch (Exception $e) {
246            logMessage("ERROR searching for event: " . $e->getMessage(), 'ERROR');
247            return [];
248        }
249    }
250
251    public function deleteAllDuplicates($userEmail, $dokuwikiId) {
252        $events = $this->findEventByDokuWikiId($userEmail, $dokuwikiId);
253
254        if (count($events) <= 1) {
255            return 0; // No duplicates
256        }
257
258        // Keep the first one, delete the rest
259        $deleted = 0;
260        for ($i = 1; $i < count($events); $i++) {
261            try {
262                $this->deleteEvent($userEmail, $events[$i]['id']);
263                $deleted++;
264                logMessage("Deleted duplicate: {$events[$i]['subject']}", 'DEBUG');
265            } catch (Exception $e) {
266                logMessage("ERROR deleting duplicate: " . $e->getMessage(), 'ERROR');
267            }
268        }
269
270        return $deleted;
271    }
272}
273
274// =============================================================================
275// DOKUWIKI CALENDAR READER
276// =============================================================================
277
278function loadDokuWikiEvents($dokuwikiRoot, $filterNamespace = null) {
279    // Use the global $metaDir set at script startup (respects conf overrides)
280    global $metaDir;
281    $allEvents = [];
282
283    if (!is_dir($metaDir)) {
284        logMessage("ERROR: Meta directory not found: $metaDir", 'ERROR');
285        return [];
286    }
287
288    scanCalendarDirs($metaDir, '', $allEvents, $filterNamespace);
289
290    return $allEvents;
291}
292
293function scanCalendarDirs($dir, $namespace, &$allEvents, $filterNamespace) {
294    $items = @scandir($dir);
295    if (!$items) return;
296
297    foreach ($items as $item) {
298        if ($item === '.' || $item === '..') continue;
299
300        $path = $dir . '/' . $item;
301
302        if (is_dir($path)) {
303            if ($item === 'calendar') {
304                // Found a calendar directory
305                $currentNamespace = trim($namespace, ':');
306
307                // Check filter
308                if ($filterNamespace !== null && $currentNamespace !== $filterNamespace) {
309                    continue;
310                }
311
312                logMessage("Scanning calendar: $currentNamespace", 'DEBUG');
313                loadCalendarFiles($path, $currentNamespace, $allEvents);
314            } else {
315                // Recurse into subdirectory
316                $newNamespace = $namespace ? $namespace . ':' . $item : $item;
317                scanCalendarDirs($path, $newNamespace, $allEvents, $filterNamespace);
318            }
319        }
320    }
321}
322
323function loadCalendarFiles($calendarDir, $namespace, &$allEvents) {
324    global $stats;
325
326    $files = glob($calendarDir . '/*.json');
327
328    foreach ($files as $file) {
329        $contents = file_get_contents($file);
330
331        // Skip empty files
332        if (trim($contents) === '' || trim($contents) === '{}' || trim($contents) === '[]') {
333            continue;
334        }
335
336        $data = json_decode($contents, true);
337
338        // Check for JSON errors
339        if (json_last_error() !== JSON_ERROR_NONE) {
340            logMessage("ERROR: Invalid JSON in $file: " . json_last_error_msg(), 'ERROR');
341            continue;
342        }
343
344        if (!is_array($data)) continue;
345        if (empty($data)) continue;
346
347        // MATCH DOKUWIKI LOGIC: Load everything from the file, no filtering
348        foreach ($data as $dateKey => $events) {
349            if (!is_array($events)) continue;
350
351            foreach ($events as $event) {
352                if (!isset($event['id'])) continue;
353
354                $stats['scanned']++;
355
356                // Get event's namespace field
357                $eventNamespace = isset($event['namespace']) ? $event['namespace'] : '';
358
359                // Create unique ID based on event's namespace field
360                // Empty namespace = root namespace
361                if ($eventNamespace === '') {
362                    $uniqueId = ':' . $event['id'];
363                } else {
364                    $uniqueId = $eventNamespace . ':' . $event['id'];
365                }
366
367                // Store file location for reference
368                $event['_fileNamespace'] = $namespace;
369                $event['_dateKey'] = $dateKey;
370
371                // Add to collection - just like DokuWiki does
372                $allEvents[$uniqueId] = $event;
373            }
374        }
375    }
376}
377
378// =============================================================================
379// EVENT CONVERSION
380// =============================================================================
381
382function convertToOutlookEvent($dwEvent, $config) {
383    $timezone = $config['timezone'];
384
385    // Parse date and time
386    $dateKey = $dwEvent['_dateKey'];
387    $startDate = $dateKey;
388    $endDate = isset($dwEvent['endDate']) && $dwEvent['endDate'] ? $dwEvent['endDate'] : $dateKey;
389
390    // Handle time
391    $isAllDay = empty($dwEvent['time']);
392
393    if ($isAllDay) {
394        // All-day events: Use just the date, and end date is next day
395        $startDateTime = $startDate;
396
397        // For all-day events, end date must be the day AFTER the last day
398        $endDateObj = new DateTime($endDate);
399        $endDateObj->modify('+1 day');
400        $endDateTime = $endDateObj->format('Y-m-d');
401    } else {
402        // Timed events: Add time to date
403        $startDateTime = $startDate . 'T' . $dwEvent['time'] . ':00';
404
405        // End time: if no end date, add 1 hour to start time
406        if ($endDate === $dateKey) {
407            $dt = new DateTime($startDateTime, new DateTimeZone($timezone));
408            $dt->modify('+1 hour');
409            $endDateTime = $dt->format('Y-m-d\TH:i:s');
410        } else {
411            $endDateTime = $endDate . 'T23:59:59';
412        }
413    }
414
415    // Determine category based on namespace FIRST (takes precedence)
416    $namespace = isset($dwEvent['namespace']) ? $dwEvent['namespace'] : '';
417    $category = null;
418
419    // Priority 1: Namespace mapping
420    if (!empty($namespace) && isset($config['category_mapping'][$namespace])) {
421        $category = $config['category_mapping'][$namespace];
422    }
423
424    // Priority 2: Color mapping (fallback if no namespace or namespace not mapped)
425    if ($category === null && isset($dwEvent['color'])) {
426        $colorToCategoryMap = [
427            '#3498db' => 'Blue Category',      // Blue
428            '#2ecc71' => 'Green Category',     // Green
429            '#f39c12' => 'Orange Category',    // Orange
430            '#e74c3c' => 'Red Category',       // Red
431            '#f1c40f' => 'Yellow Category',    // Yellow
432            '#9b59b6' => 'Purple Category',    // Purple
433        ];
434
435        $eventColor = strtolower($dwEvent['color']);
436        foreach ($colorToCategoryMap as $color => $cat) {
437            if (strtolower($color) === $eventColor) {
438                $category = $cat;
439                break;
440            }
441        }
442    }
443
444    // Priority 3: Default category
445    if ($category === null) {
446        $category = $config['default_category'];
447    }
448
449    // Clean and sanitize text fields
450    $title = isset($dwEvent['title']) ? trim($dwEvent['title']) : 'Untitled Event';
451    $description = isset($dwEvent['description']) ? trim($dwEvent['description']) : '';
452
453    // Remove any null bytes and control characters that can break JSON
454    $title = preg_replace('/[\x00-\x1F\x7F]/u', '', $title);
455    $description = preg_replace('/[\x00-\x1F\x7F]/u', '', $description);
456
457    // Ensure proper UTF-8 encoding
458    if (!mb_check_encoding($title, 'UTF-8')) {
459        $title = mb_convert_encoding($title, 'UTF-8', 'UTF-8');
460    }
461    if (!mb_check_encoding($description, 'UTF-8')) {
462        $description = mb_convert_encoding($description, 'UTF-8', 'UTF-8');
463    }
464
465    // Build Outlook event structure
466    if ($isAllDay) {
467        // All-day events use different format (no time component, no timezone)
468        $outlookEvent = [
469            'subject' => $title,
470            'body' => [
471                'contentType' => 'text',
472                'content' => $description
473            ],
474            'start' => [
475                'dateTime' => $startDateTime,
476                'timeZone' => 'UTC'  // All-day events should use UTC
477            ],
478            'end' => [
479                'dateTime' => $endDateTime,
480                'timeZone' => 'UTC'
481            ],
482            'isAllDay' => true,
483            'categories' => [$category],
484            'isReminderOn' => false,  // All-day events typically don't need reminders
485            'singleValueExtendedProperties' => [
486                [
487                    'id' => 'String {66f5a359-4659-4830-9070-00047ec6ac6e} Name DokuWikiId',
488                    'value' => $namespace . ':' . $dwEvent['id']
489                ]
490            ]
491        ];
492    } else {
493        // Timed events
494        $outlookEvent = [
495            'subject' => $title,
496            'body' => [
497                'contentType' => 'text',
498                'content' => $description
499            ],
500            'start' => [
501                'dateTime' => $startDateTime,
502                'timeZone' => $timezone
503            ],
504            'end' => [
505                'dateTime' => $endDateTime,
506                'timeZone' => $timezone
507            ],
508            'isAllDay' => false,
509            'categories' => [$category],
510            'isReminderOn' => true,
511            'reminderMinutesBeforeStart' => $config['reminder_minutes'],
512            'singleValueExtendedProperties' => [
513                [
514                    'id' => 'String {66f5a359-4659-4830-9070-00047ec6ac6e} Name DokuWikiId',
515                    'value' => $namespace . ':' . $dwEvent['id']
516                ]
517            ]
518        ];
519    }
520
521    return $outlookEvent;
522}
523
524// =============================================================================
525// SYNC STATE MANAGEMENT (with hash-based change tracking)
526// =============================================================================
527
528/**
529 * Compute a hash of all sync-relevant event fields.
530 * If any of these fields change, the event will be re-synced to Outlook.
531 */
532function computeEventHash($dwEvent) {
533    $fields = [
534        'title'       => isset($dwEvent['title']) ? trim($dwEvent['title']) : '',
535        'description' => isset($dwEvent['description']) ? trim($dwEvent['description']) : '',
536        'time'        => isset($dwEvent['time']) ? $dwEvent['time'] : '',
537        'endTime'     => isset($dwEvent['endTime']) ? $dwEvent['endTime'] : '',
538        'endDate'     => isset($dwEvent['endDate']) ? $dwEvent['endDate'] : '',
539        'color'       => isset($dwEvent['color']) ? $dwEvent['color'] : '',
540        'namespace'   => isset($dwEvent['namespace']) ? $dwEvent['namespace'] : '',
541        'isTask'      => !empty($dwEvent['isTask']) ? '1' : '0',
542        'completed'   => !empty($dwEvent['completed']) ? '1' : '0',
543        'dateKey'     => isset($dwEvent['_dateKey']) ? $dwEvent['_dateKey'] : '',
544    ];
545    return md5(json_encode($fields));
546}
547
548function loadSyncState($stateFile) {
549    if (!file_exists($stateFile)) {
550        return ['mapping' => [], 'last_sync' => 0, 'version' => 2];
551    }
552
553    $data = json_decode(file_get_contents($stateFile), true);
554    if (!$data) {
555        return ['mapping' => [], 'last_sync' => 0, 'version' => 2];
556    }
557
558    // Migrate v1 state (mapping was dwId => outlookId string)
559    // to v2 state (mapping is dwId => {outlookId, hash})
560    if (!isset($data['version']) || $data['version'] < 2) {
561        logMessage("Migrating sync state from v1 to v2 (adding hash tracking)...");
562        $newMapping = [];
563        foreach ($data['mapping'] as $dwId => $value) {
564            if (is_string($value)) {
565                // v1 format: dwId => outlookId
566                $newMapping[$dwId] = ['outlookId' => $value, 'hash' => ''];
567            } else {
568                // Already v2
569                $newMapping[$dwId] = $value;
570            }
571        }
572        $data['mapping'] = $newMapping;
573        $data['version'] = 2;
574        logMessage("Migration complete - " . count($newMapping) . " entries migrated (will re-sync all on first run)");
575    }
576
577    return $data;
578}
579
580function saveSyncState($stateFile, $state) {
581    $state['last_sync'] = time();
582    $state['version'] = 2;
583    file_put_contents($stateFile, json_encode($state, JSON_PRETTY_PRINT));
584}
585
586// =============================================================================
587// MAIN SYNC LOGIC
588// =============================================================================
589
590try {
591    // Initialize API client
592    $client = new MicrosoftGraphClient($config);
593    logMessage("Authenticating with Microsoft Graph API...");
594    $client->getAccessToken();
595    logMessage("Authentication successful");
596
597    // Load sync state
598    $state = loadSyncState($stateFile);
599    $mapping = $state['mapping']; // dwId => {outlookId, hash}
600
601    // Reset mode - clear the mapping
602    if ($reset) {
603        logMessage("Resetting sync state...");
604        $mapping = [];
605    }
606
607    // Load DokuWiki events
608    logMessage("Loading DokuWiki calendar events...");
609    $dwEvents = loadDokuWikiEvents($dokuwikiRoot, $filterNamespace);
610    logMessage("Found " . count($dwEvents) . " events in DokuWiki");
611
612    // Clean duplicates mode
613    if ($cleanDuplicates) {
614        logMessage("=== Cleaning Duplicates ===");
615        $duplicatesFound = 0;
616        $duplicatesDeleted = 0;
617
618        foreach ($dwEvents as $dwId => $dwEvent) {
619            $existingEvents = $client->findEventByDokuWikiId($config['user_email'], $dwId);
620
621            if (count($existingEvents) > 1) {
622                $duplicatesFound += count($existingEvents) - 1;
623                logMessage("Found " . count($existingEvents) . " copies of: {$dwEvent['title']}");
624
625                if (!$dryRun) {
626                    $deleted = $client->deleteAllDuplicates($config['user_email'], $dwId);
627                    $duplicatesDeleted += $deleted;
628
629                    // Update mapping with the remaining event
630                    $remaining = $client->findEventByDokuWikiId($config['user_email'], $dwId);
631                    if (count($remaining) == 1) {
632                        $hash = computeEventHash($dwEvent);
633                        $mapping[$dwId] = ['outlookId' => $remaining[0]['id'], 'hash' => $hash];
634                    }
635                }
636            }
637        }
638
639        logMessage("=== Duplicate Cleanup Complete ===");
640        logMessage("Duplicates found: $duplicatesFound");
641        logMessage("Duplicates deleted: $duplicatesDeleted");
642
643        if (!$dryRun) {
644            $state['mapping'] = $mapping;
645            saveSyncState($stateFile, $state);
646        }
647
648        exit(0);
649    }
650
651    // =========================================================================
652    // DELTA DETECTION - classify events as new, modified, unchanged, or deleted
653    // =========================================================================
654
655    $newEvents = [];       // In DokuWiki but not in mapping
656    $modifiedEvents = [];  // In both but hash changed
657    $unchangedEvents = []; // In both and hash matches
658    $deletedIds = [];      // In mapping but not in DokuWiki
659
660    // Classify current DokuWiki events
661    foreach ($dwEvents as $dwId => $dwEvent) {
662        $currentHash = computeEventHash($dwEvent);
663
664        if (!isset($mapping[$dwId])) {
665            $newEvents[$dwId] = $dwEvent;
666        } elseif ($forceSync || $mapping[$dwId]['hash'] !== $currentHash) {
667            $modifiedEvents[$dwId] = $dwEvent;
668        } else {
669            $unchangedEvents[$dwId] = $dwEvent;
670        }
671    }
672
673    // Find deleted events (in mapping but no longer in DokuWiki)
674    foreach ($mapping as $dwId => $entry) {
675        if (!isset($dwEvents[$dwId])) {
676            $deletedIds[] = $dwId;
677        }
678    }
679
680    logMessage("=== Delta Analysis ===");
681    logMessage("  New:       " . count($newEvents));
682    logMessage("  Modified:  " . count($modifiedEvents));
683    logMessage("  Unchanged: " . count($unchangedEvents) . " (skipping)");
684    logMessage("  Deleted:   " . count($deletedIds));
685    $totalApiCalls = count($newEvents) + count($modifiedEvents) + count($deletedIds);
686    logMessage("  API calls: ~$totalApiCalls (vs " . count($dwEvents) . " full sync)");
687
688    if ($totalApiCalls === 0) {
689        logMessage("Nothing to sync - calendar is up to date!");
690    }
691
692    // =========================================================================
693    // SYNC NEW EVENTS
694    // =========================================================================
695
696    foreach ($newEvents as $dwId => $dwEvent) {
697        // Check for abort flag
698        if (file_exists(__DIR__ . '/.sync_abort')) {
699            logMessage("=== SYNC ABORTED BY USER ===", 'WARN');
700            @unlink(__DIR__ . '/.sync_abort');
701            break;
702        }
703
704        // Skip completed tasks if configured
705        if (!$config['sync_completed_tasks'] &&
706            !empty($dwEvent['isTask']) &&
707            !empty($dwEvent['completed'])) {
708            $stats['skipped']++;
709            continue;
710        }
711
712        $outlookEvent = convertToOutlookEvent($dwEvent, $config);
713        $eventNamespace = isset($dwEvent['namespace']) ? $dwEvent['namespace'] : '';
714        $hash = computeEventHash($dwEvent);
715
716        try {
717            // Check if event already exists in Outlook (unmapped from previous sync)
718            $existingEvents = $client->findEventByDokuWikiId($config['user_email'], $dwId);
719
720            if (count($existingEvents) >= 1) {
721                // Already exists - update and map it
722                $outlookId = $existingEvents[0]['id'];
723
724                if (!$dryRun) {
725                    $client->updateEvent($config['user_email'], $outlookId, $outlookEvent);
726                    $mapping[$dwId] = ['outlookId' => $outlookId, 'hash' => $hash];
727
728                    // Clean any duplicates
729                    if (count($existingEvents) > 1) {
730                        $client->deleteAllDuplicates($config['user_email'], $dwId);
731                        logMessage("  Cleaned " . (count($existingEvents) - 1) . " duplicate(s)");
732                    }
733                }
734                $stats['updated']++;
735                logMessage("Mapped & updated: {$dwEvent['title']} [$eventNamespace]");
736            } else {
737                // Truly new - create in Outlook
738                if (!$dryRun) {
739                    $result = $client->createEvent($config['user_email'], $outlookEvent);
740                    $mapping[$dwId] = ['outlookId' => $result['id'], 'hash' => $hash];
741                    logMessage("Created: {$dwEvent['title']} [$eventNamespace]");
742                } else {
743                    logMessage("Would create: {$dwEvent['title']} [$eventNamespace]");
744                }
745                $stats['created']++;
746            }
747        } catch (Exception $e) {
748            $stats['errors']++;
749            logMessage("ERROR creating {$dwEvent['title']}: " . $e->getMessage(), 'ERROR');
750        }
751    }
752
753    // =========================================================================
754    // SYNC MODIFIED EVENTS
755    // =========================================================================
756
757    foreach ($modifiedEvents as $dwId => $dwEvent) {
758        if (file_exists(__DIR__ . '/.sync_abort')) {
759            logMessage("=== SYNC ABORTED BY USER ===", 'WARN');
760            @unlink(__DIR__ . '/.sync_abort');
761            break;
762        }
763
764        if (!$config['sync_completed_tasks'] &&
765            !empty($dwEvent['isTask']) &&
766            !empty($dwEvent['completed'])) {
767            $stats['skipped']++;
768            continue;
769        }
770
771        $outlookEvent = convertToOutlookEvent($dwEvent, $config);
772        $eventNamespace = isset($dwEvent['namespace']) ? $dwEvent['namespace'] : '';
773        $hash = computeEventHash($dwEvent);
774        $outlookId = $mapping[$dwId]['outlookId'];
775
776        try {
777            if (!$dryRun) {
778                try {
779                    $client->updateEvent($config['user_email'], $outlookId, $outlookEvent);
780                    $mapping[$dwId] = ['outlookId' => $outlookId, 'hash' => $hash];
781                    $stats['updated']++;
782                    logMessage("Updated: {$dwEvent['title']} [$eventNamespace]");
783                } catch (Exception $e) {
784                    // 404 = event was deleted from Outlook, recreate it
785                    if (strpos($e->getMessage(), 'HTTP 404') !== false ||
786                        strpos($e->getMessage(), 'ErrorItemNotFound') !== false) {
787
788                        logMessage("Event deleted from Outlook, recreating: {$dwEvent['title']}", 'WARN');
789                        $result = $client->createEvent($config['user_email'], $outlookEvent);
790                        $mapping[$dwId] = ['outlookId' => $result['id'], 'hash' => $hash];
791                        $stats['recreated']++;
792                        logMessage("Recreated: {$dwEvent['title']} [$eventNamespace]");
793                    } else {
794                        throw $e;
795                    }
796                }
797            } else {
798                $stats['updated']++;
799                logMessage("Would update: {$dwEvent['title']} [$eventNamespace]");
800            }
801        } catch (Exception $e) {
802            $stats['errors']++;
803            logMessage("ERROR updating {$dwEvent['title']}: " . $e->getMessage(), 'ERROR');
804        }
805    }
806
807    // =========================================================================
808    // DELETE REMOVED EVENTS
809    // =========================================================================
810
811    if ($config['delete_outlook_events'] && !empty($deletedIds)) {
812        logMessage("=== Deleting " . count($deletedIds) . " removed events ===");
813
814        foreach ($deletedIds as $dwId) {
815            $outlookId = $mapping[$dwId]['outlookId'];
816
817            try {
818                if (!$dryRun) {
819                    $client->deleteEvent($config['user_email'], $outlookId);
820                    logMessage("Deleted: $dwId");
821                } else {
822                    logMessage("Would delete: $dwId");
823                }
824                unset($mapping[$dwId]);
825                $stats['deleted']++;
826            } catch (Exception $e) {
827                if (strpos($e->getMessage(), 'HTTP 404') !== false ||
828                    strpos($e->getMessage(), 'ErrorItemNotFound') !== false) {
829                    logMessage("Already gone from Outlook: $dwId", 'DEBUG');
830                    unset($mapping[$dwId]);
831                    $stats['deleted']++;
832                } else {
833                    logMessage("ERROR deleting $dwId: " . $e->getMessage(), 'ERROR');
834                    $stats['errors']++;
835                }
836            }
837        }
838    }
839
840    // Save state after every sync (checkpoint)
841    if (!$dryRun) {
842        $state['mapping'] = $mapping;
843        saveSyncState($stateFile, $state);
844    }
845
846    // Count unchanged as skipped for stats
847    $stats['skipped'] += count($unchangedEvents);
848
849    // Summary
850    logMessage("=== Sync Complete ===");
851    logMessage("New:       {$stats['created']}");
852    logMessage("Updated:   {$stats['updated']}");
853    logMessage("Recreated: {$stats['recreated']}");
854    logMessage("Deleted:   {$stats['deleted']}");
855    logMessage("Unchanged: " . count($unchangedEvents));
856    logMessage("Skipped:   {$stats['skipped']}");
857    logMessage("Errors:    {$stats['errors']}");
858
859    logMessage("");
860    if ($dryRun) {
861        logMessage("DRY RUN - No changes were made");
862    } else {
863        logMessage("Sync completed successfully!");
864    }
865
866    exit($stats['errors'] > 0 ? 1 : 0);
867
868} catch (Exception $e) {
869    logMessage("FATAL ERROR: " . $e->getMessage(), 'ERROR');
870    exit(1);
871}
872