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