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