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