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