xref: /plugin/calendar/sync_outlook.php (revision 7e8ea635dd19058d6f7c428adbbe02d9702096d7)
11d05cddcSAtari911#!/usr/bin/env php
21d05cddcSAtari911<?php
31d05cddcSAtari911/**
49ccd446eSAtari911 * DokuWiki Calendar → Outlook Sync (Delta Mode)
51d05cddcSAtari911 *
69ccd446eSAtari911 * Syncs calendar events from DokuWiki to Office 365/Outlook via Microsoft Graph API.
79ccd446eSAtari911 * Uses hash-based change tracking to only sync new, modified, or deleted events.
89ccd446eSAtari911 * Unchanged events are skipped entirely (zero API calls).
91d05cddcSAtari911 *
101d05cddcSAtari911 * Usage:
119ccd446eSAtari911 *   php sync_outlook.php                       # Delta sync (only changes)
129ccd446eSAtari911 *   php sync_outlook.php --dry-run             # Show what would change
131d05cddcSAtari911 *   php sync_outlook.php --namespace=work      # Sync only specific namespace
149ccd446eSAtari911 *   php sync_outlook.php --force               # Force re-sync ALL events
151d05cddcSAtari911 *   php sync_outlook.php --clean-duplicates    # Remove duplicate events
161d05cddcSAtari911 *   php sync_outlook.php --reset               # Reset sync state, rebuild from scratch
179ccd446eSAtari911 *
189ccd446eSAtari911 * First run after upgrade: existing sync_state.json will be auto-migrated
199ccd446eSAtari911 * to v2 format with hash tracking. All events will re-sync once to populate hashes.
209ccd446eSAtari911 * Subsequent runs will only touch changed events.
211d05cddcSAtari911 *
221d05cddcSAtari911 * Setup:
231d05cddcSAtari911 *   1. Edit sync_config.php with your Azure credentials
241d05cddcSAtari911 *   2. Run: php sync_outlook.php --dry-run
251d05cddcSAtari911 *   3. If looks good: php sync_outlook.php
261d05cddcSAtari911 *   4. Add to cron (see documentation for cron syntax)
271d05cddcSAtari911 */
281d05cddcSAtari911
291d05cddcSAtari911// Parse command line options
301d05cddcSAtari911$options = getopt('', ['dry-run', 'namespace:', 'force', 'verbose', 'clean-duplicates', 'reset']);
311d05cddcSAtari911$dryRun = isset($options['dry-run']);
321d05cddcSAtari911$forceSync = isset($options['force']);
331d05cddcSAtari911$verbose = isset($options['verbose']) || $dryRun;
341d05cddcSAtari911$cleanDuplicates = isset($options['clean-duplicates']);
351d05cddcSAtari911$reset = isset($options['reset']);
361d05cddcSAtari911$filterNamespace = isset($options['namespace']) ? $options['namespace'] : null;
371d05cddcSAtari911
381d05cddcSAtari911// Determine script directory
391d05cddcSAtari911$scriptDir = __DIR__;
401d05cddcSAtari911$dokuwikiRoot = dirname(dirname(dirname($scriptDir))); // Go up to dokuwiki root
411d05cddcSAtari911
421d05cddcSAtari911// Load configuration
431d05cddcSAtari911$configFile = $scriptDir . '/sync_config.php';
441d05cddcSAtari911if (!file_exists($configFile)) {
451d05cddcSAtari911    die("ERROR: Configuration file not found: $configFile\n" .
461d05cddcSAtari911        "Please copy sync_config.php and add your credentials.\n");
471d05cddcSAtari911}
481d05cddcSAtari911
491d05cddcSAtari911$config = require $configFile;
501d05cddcSAtari911
511d05cddcSAtari911// Validate configuration
521d05cddcSAtari911if (empty($config['tenant_id']) || strpos($config['tenant_id'], 'YOUR_') !== false) {
531d05cddcSAtari911    die("ERROR: Please configure your Azure credentials in sync_config.php\n");
541d05cddcSAtari911}
551d05cddcSAtari911
561d05cddcSAtari911// Files
571d05cddcSAtari911$stateFile = $scriptDir . '/sync_state.json';
581d05cddcSAtari911$logFile = $scriptDir . '/sync.log';
591d05cddcSAtari911
601d05cddcSAtari911// Initialize
611d05cddcSAtari911$stats = [
621d05cddcSAtari911    'scanned' => 0,
631d05cddcSAtari911    'created' => 0,
641d05cddcSAtari911    'updated' => 0,
651d05cddcSAtari911    'deleted' => 0,
661d05cddcSAtari911    'recreated' => 0,
671d05cddcSAtari911    'skipped' => 0,
681d05cddcSAtari911    'errors' => 0
691d05cddcSAtari911];
701d05cddcSAtari911
711d05cddcSAtari911// Logging
721d05cddcSAtari911function logMessage($message, $level = 'INFO') {
73*7e8ea635SAtari911    global $logFile, $verbose, $config;
741d05cddcSAtari911
75*7e8ea635SAtari911    // Use timezone from config, fallback to America/Los_Angeles
76*7e8ea635SAtari911    $timezone = isset($config['timezone']) ? $config['timezone'] : 'America/Los_Angeles';
77*7e8ea635SAtari911    $tz = new DateTimeZone($timezone);
781d05cddcSAtari911    $now = new DateTime('now', $tz);
791d05cddcSAtari911    $timestamp = $now->format('Y-m-d H:i:s');
801d05cddcSAtari911
811d05cddcSAtari911    $logLine = "[$timestamp] [$level] $message\n";
821d05cddcSAtari911
831d05cddcSAtari911    if ($verbose || $level === 'ERROR') {
841d05cddcSAtari911        echo $logLine;
851d05cddcSAtari911    }
861d05cddcSAtari911
871d05cddcSAtari911    file_put_contents($logFile, $logLine, FILE_APPEND);
881d05cddcSAtari911}
891d05cddcSAtari911
901d05cddcSAtari911logMessage("=== DokuWiki → Outlook Sync Started ===");
911d05cddcSAtari911if ($dryRun) logMessage("DRY RUN MODE - No changes will be made");
921d05cddcSAtari911if ($filterNamespace) logMessage("Filtering namespace: $filterNamespace");
931d05cddcSAtari911if ($reset) logMessage("RESET MODE - Will rebuild sync state from scratch");
941d05cddcSAtari911if ($cleanDuplicates) logMessage("CLEAN DUPLICATES MODE - Will remove all duplicate events");
951d05cddcSAtari911
961d05cddcSAtari911// =============================================================================
971d05cddcSAtari911// MICROSOFT GRAPH API CLIENT
981d05cddcSAtari911// =============================================================================
991d05cddcSAtari911
1001d05cddcSAtari911class MicrosoftGraphClient {
1011d05cddcSAtari911    private $config;
1021d05cddcSAtari911    private $accessToken = null;
1031d05cddcSAtari911    private $tokenExpiry = 0;
1041d05cddcSAtari911
1051d05cddcSAtari911    public function __construct($config) {
1061d05cddcSAtari911        $this->config = $config;
1071d05cddcSAtari911    }
1081d05cddcSAtari911
1091d05cddcSAtari911    public function getAccessToken() {
1101d05cddcSAtari911        // Check if we have a valid cached token
1111d05cddcSAtari911        if ($this->accessToken && time() < $this->tokenExpiry) {
1121d05cddcSAtari911            return $this->accessToken;
1131d05cddcSAtari911        }
1141d05cddcSAtari911
1151d05cddcSAtari911        // Request new token
1161d05cddcSAtari911        $tokenUrl = "https://login.microsoftonline.com/{$this->config['tenant_id']}/oauth2/v2.0/token";
1171d05cddcSAtari911
1181d05cddcSAtari911        $data = [
1191d05cddcSAtari911            'grant_type' => 'client_credentials',
1201d05cddcSAtari911            'client_id' => $this->config['client_id'],
1211d05cddcSAtari911            'client_secret' => $this->config['client_secret'],
1221d05cddcSAtari911            'scope' => 'https://graph.microsoft.com/.default'
1231d05cddcSAtari911        ];
1241d05cddcSAtari911
1251d05cddcSAtari911        $ch = curl_init($tokenUrl);
1261d05cddcSAtari911        curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
1271d05cddcSAtari911        curl_setopt($ch, CURLOPT_POST, true);
1281d05cddcSAtari911        curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($data));
1291d05cddcSAtari911        curl_setopt($ch, CURLOPT_TIMEOUT, $this->config['api_timeout']);
1301d05cddcSAtari911
1311d05cddcSAtari911        $response = curl_exec($ch);
1321d05cddcSAtari911        $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
1331d05cddcSAtari911        curl_close($ch);
1341d05cddcSAtari911
1351d05cddcSAtari911        if ($httpCode !== 200) {
1361d05cddcSAtari911            throw new Exception("Failed to get access token: HTTP $httpCode - $response");
1371d05cddcSAtari911        }
1381d05cddcSAtari911
1391d05cddcSAtari911        $result = json_decode($response, true);
1401d05cddcSAtari911        if (!isset($result['access_token'])) {
1411d05cddcSAtari911            throw new Exception("No access token in response: $response");
1421d05cddcSAtari911        }
1431d05cddcSAtari911
1441d05cddcSAtari911        $this->accessToken = $result['access_token'];
1451d05cddcSAtari911        $this->tokenExpiry = time() + ($result['expires_in'] - 300); // Refresh 5min early
1461d05cddcSAtari911
1471d05cddcSAtari911        return $this->accessToken;
1481d05cddcSAtari911    }
1491d05cddcSAtari911
1501d05cddcSAtari911    public function apiRequest($method, $endpoint, $data = null) {
1511d05cddcSAtari911        $token = $this->getAccessToken();
1521d05cddcSAtari911        $url = "https://graph.microsoft.com/v1.0" . $endpoint;
1531d05cddcSAtari911
1541d05cddcSAtari911        $ch = curl_init($url);
1551d05cddcSAtari911        curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
1561d05cddcSAtari911        curl_setopt($ch, CURLOPT_TIMEOUT, $this->config['api_timeout']);
1571d05cddcSAtari911        curl_setopt($ch, CURLOPT_HTTPHEADER, [
1581d05cddcSAtari911            'Authorization: Bearer ' . $token,
1591d05cddcSAtari911            'Content-Type: application/json',
1601d05cddcSAtari911            'Prefer: outlook.timezone="' . $this->config['timezone'] . '"'
1611d05cddcSAtari911        ]);
1621d05cddcSAtari911
1631d05cddcSAtari911        if ($method !== 'GET') {
1641d05cddcSAtari911            curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method);
1651d05cddcSAtari911        }
1661d05cddcSAtari911
1671d05cddcSAtari911        if ($data !== null) {
1681d05cddcSAtari911            $jsonData = json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
1691d05cddcSAtari911            if ($jsonData === false) {
1701d05cddcSAtari911                throw new Exception("Failed to encode JSON: " . json_last_error_msg());
1711d05cddcSAtari911            }
1721d05cddcSAtari911            curl_setopt($ch, CURLOPT_POSTFIELDS, $jsonData);
1731d05cddcSAtari911        }
1741d05cddcSAtari911
1751d05cddcSAtari911        $response = curl_exec($ch);
1761d05cddcSAtari911        $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
1771d05cddcSAtari911        curl_close($ch);
1781d05cddcSAtari911
1791d05cddcSAtari911        if ($httpCode >= 400) {
1801d05cddcSAtari911            throw new Exception("API request failed: $method $endpoint - HTTP $httpCode - $response");
1811d05cddcSAtari911        }
1821d05cddcSAtari911
1831d05cddcSAtari911        return json_decode($response, true);
1841d05cddcSAtari911    }
1851d05cddcSAtari911
1861d05cddcSAtari911    public function createEvent($userEmail, $eventData) {
1871d05cddcSAtari911        return $this->apiRequest('POST', "/users/$userEmail/events", $eventData);
1881d05cddcSAtari911    }
1891d05cddcSAtari911
1901d05cddcSAtari911    public function updateEvent($userEmail, $outlookId, $eventData) {
1911d05cddcSAtari911        return $this->apiRequest('PATCH', "/users/$userEmail/events/$outlookId", $eventData);
1921d05cddcSAtari911    }
1931d05cddcSAtari911
1941d05cddcSAtari911    public function deleteEvent($userEmail, $outlookId) {
1951d05cddcSAtari911        return $this->apiRequest('DELETE', "/users/$userEmail/events/$outlookId");
1961d05cddcSAtari911    }
1971d05cddcSAtari911
1981d05cddcSAtari911    public function getEvent($userEmail, $outlookId) {
1991d05cddcSAtari911        try {
2001d05cddcSAtari911            return $this->apiRequest('GET', "/users/$userEmail/events/$outlookId");
2011d05cddcSAtari911        } catch (Exception $e) {
2021d05cddcSAtari911            return null; // Event not found
2031d05cddcSAtari911        }
2041d05cddcSAtari911    }
2051d05cddcSAtari911
2061d05cddcSAtari911    public function findEventByDokuWikiId($userEmail, $dokuwikiId) {
2071d05cddcSAtari911        // Search for events with our custom extended property
2081d05cddcSAtari911        $filter = rawurlencode("singleValueExtendedProperties/Any(ep: ep/id eq 'String {66f5a359-4659-4830-9070-00047ec6ac6e} Name DokuWikiId' and ep/value eq '$dokuwikiId')");
2091d05cddcSAtari911
2101d05cddcSAtari911        try {
2111d05cddcSAtari911            $result = $this->apiRequest('GET', "/users/$userEmail/events?\$filter=$filter&\$select=id,subject");
2121d05cddcSAtari911            return isset($result['value']) ? $result['value'] : [];
2131d05cddcSAtari911        } catch (Exception $e) {
2141d05cddcSAtari911            logMessage("ERROR searching for event: " . $e->getMessage(), 'ERROR');
2151d05cddcSAtari911            return [];
2161d05cddcSAtari911        }
2171d05cddcSAtari911    }
2181d05cddcSAtari911
2191d05cddcSAtari911    public function deleteAllDuplicates($userEmail, $dokuwikiId) {
2201d05cddcSAtari911        $events = $this->findEventByDokuWikiId($userEmail, $dokuwikiId);
2211d05cddcSAtari911
2221d05cddcSAtari911        if (count($events) <= 1) {
2231d05cddcSAtari911            return 0; // No duplicates
2241d05cddcSAtari911        }
2251d05cddcSAtari911
2261d05cddcSAtari911        // Keep the first one, delete the rest
2271d05cddcSAtari911        $deleted = 0;
2281d05cddcSAtari911        for ($i = 1; $i < count($events); $i++) {
2291d05cddcSAtari911            try {
2301d05cddcSAtari911                $this->deleteEvent($userEmail, $events[$i]['id']);
2311d05cddcSAtari911                $deleted++;
2321d05cddcSAtari911                logMessage("Deleted duplicate: {$events[$i]['subject']}", 'DEBUG');
2331d05cddcSAtari911            } catch (Exception $e) {
2341d05cddcSAtari911                logMessage("ERROR deleting duplicate: " . $e->getMessage(), 'ERROR');
2351d05cddcSAtari911            }
2361d05cddcSAtari911        }
2371d05cddcSAtari911
2381d05cddcSAtari911        return $deleted;
2391d05cddcSAtari911    }
2401d05cddcSAtari911}
2411d05cddcSAtari911
2421d05cddcSAtari911// =============================================================================
2431d05cddcSAtari911// DOKUWIKI CALENDAR READER
2441d05cddcSAtari911// =============================================================================
2451d05cddcSAtari911
2461d05cddcSAtari911function loadDokuWikiEvents($dokuwikiRoot, $filterNamespace = null) {
2471d05cddcSAtari911    $metaDir = $dokuwikiRoot . '/data/meta';
2481d05cddcSAtari911    $allEvents = [];
2491d05cddcSAtari911
2501d05cddcSAtari911    if (!is_dir($metaDir)) {
2511d05cddcSAtari911        logMessage("ERROR: Meta directory not found: $metaDir", 'ERROR');
2521d05cddcSAtari911        return [];
2531d05cddcSAtari911    }
2541d05cddcSAtari911
2551d05cddcSAtari911    scanCalendarDirs($metaDir, '', $allEvents, $filterNamespace);
2561d05cddcSAtari911
2571d05cddcSAtari911    return $allEvents;
2581d05cddcSAtari911}
2591d05cddcSAtari911
2601d05cddcSAtari911function scanCalendarDirs($dir, $namespace, &$allEvents, $filterNamespace) {
2611d05cddcSAtari911    $items = @scandir($dir);
2621d05cddcSAtari911    if (!$items) return;
2631d05cddcSAtari911
2641d05cddcSAtari911    foreach ($items as $item) {
2651d05cddcSAtari911        if ($item === '.' || $item === '..') continue;
2661d05cddcSAtari911
2671d05cddcSAtari911        $path = $dir . '/' . $item;
2681d05cddcSAtari911
2691d05cddcSAtari911        if (is_dir($path)) {
2701d05cddcSAtari911            if ($item === 'calendar') {
2711d05cddcSAtari911                // Found a calendar directory
2721d05cddcSAtari911                $currentNamespace = trim($namespace, ':');
2731d05cddcSAtari911
2741d05cddcSAtari911                // Check filter
2751d05cddcSAtari911                if ($filterNamespace !== null && $currentNamespace !== $filterNamespace) {
2761d05cddcSAtari911                    continue;
2771d05cddcSAtari911                }
2781d05cddcSAtari911
2791d05cddcSAtari911                logMessage("Scanning calendar: $currentNamespace", 'DEBUG');
2801d05cddcSAtari911                loadCalendarFiles($path, $currentNamespace, $allEvents);
2811d05cddcSAtari911            } else {
2821d05cddcSAtari911                // Recurse into subdirectory
2831d05cddcSAtari911                $newNamespace = $namespace ? $namespace . ':' . $item : $item;
2841d05cddcSAtari911                scanCalendarDirs($path, $newNamespace, $allEvents, $filterNamespace);
2851d05cddcSAtari911            }
2861d05cddcSAtari911        }
2871d05cddcSAtari911    }
2881d05cddcSAtari911}
2891d05cddcSAtari911
2901d05cddcSAtari911function loadCalendarFiles($calendarDir, $namespace, &$allEvents) {
2911d05cddcSAtari911    global $stats;
2921d05cddcSAtari911
2931d05cddcSAtari911    $files = glob($calendarDir . '/*.json');
2941d05cddcSAtari911
2951d05cddcSAtari911    foreach ($files as $file) {
2961d05cddcSAtari911        $contents = file_get_contents($file);
2971d05cddcSAtari911
2981d05cddcSAtari911        // Skip empty files
2991d05cddcSAtari911        if (trim($contents) === '' || trim($contents) === '{}' || trim($contents) === '[]') {
3001d05cddcSAtari911            continue;
3011d05cddcSAtari911        }
3021d05cddcSAtari911
3031d05cddcSAtari911        $data = json_decode($contents, true);
3041d05cddcSAtari911
3051d05cddcSAtari911        // Check for JSON errors
3061d05cddcSAtari911        if (json_last_error() !== JSON_ERROR_NONE) {
3071d05cddcSAtari911            logMessage("ERROR: Invalid JSON in $file: " . json_last_error_msg(), 'ERROR');
3081d05cddcSAtari911            continue;
3091d05cddcSAtari911        }
3101d05cddcSAtari911
3111d05cddcSAtari911        if (!is_array($data)) continue;
3121d05cddcSAtari911        if (empty($data)) continue;
3131d05cddcSAtari911
3141d05cddcSAtari911        // MATCH DOKUWIKI LOGIC: Load everything from the file, no filtering
3151d05cddcSAtari911        foreach ($data as $dateKey => $events) {
3161d05cddcSAtari911            if (!is_array($events)) continue;
3171d05cddcSAtari911
3181d05cddcSAtari911            foreach ($events as $event) {
3191d05cddcSAtari911                if (!isset($event['id'])) continue;
3201d05cddcSAtari911
3211d05cddcSAtari911                $stats['scanned']++;
3221d05cddcSAtari911
3231d05cddcSAtari911                // Get event's namespace field
3241d05cddcSAtari911                $eventNamespace = isset($event['namespace']) ? $event['namespace'] : '';
3251d05cddcSAtari911
3261d05cddcSAtari911                // Create unique ID based on event's namespace field
3271d05cddcSAtari911                // Empty namespace = root namespace
3281d05cddcSAtari911                if ($eventNamespace === '') {
3291d05cddcSAtari911                    $uniqueId = ':' . $event['id'];
3301d05cddcSAtari911                } else {
3311d05cddcSAtari911                    $uniqueId = $eventNamespace . ':' . $event['id'];
3321d05cddcSAtari911                }
3331d05cddcSAtari911
3341d05cddcSAtari911                // Store file location for reference
3351d05cddcSAtari911                $event['_fileNamespace'] = $namespace;
3361d05cddcSAtari911                $event['_dateKey'] = $dateKey;
3371d05cddcSAtari911
3381d05cddcSAtari911                // Add to collection - just like DokuWiki does
3391d05cddcSAtari911                $allEvents[$uniqueId] = $event;
3401d05cddcSAtari911            }
3411d05cddcSAtari911        }
3421d05cddcSAtari911    }
3431d05cddcSAtari911}
3441d05cddcSAtari911
3451d05cddcSAtari911// =============================================================================
3461d05cddcSAtari911// EVENT CONVERSION
3471d05cddcSAtari911// =============================================================================
3481d05cddcSAtari911
3491d05cddcSAtari911function convertToOutlookEvent($dwEvent, $config) {
3501d05cddcSAtari911    $timezone = $config['timezone'];
3511d05cddcSAtari911
3521d05cddcSAtari911    // Parse date and time
3531d05cddcSAtari911    $dateKey = $dwEvent['_dateKey'];
3541d05cddcSAtari911    $startDate = $dateKey;
3551d05cddcSAtari911    $endDate = isset($dwEvent['endDate']) && $dwEvent['endDate'] ? $dwEvent['endDate'] : $dateKey;
3561d05cddcSAtari911
3571d05cddcSAtari911    // Handle time
3581d05cddcSAtari911    $isAllDay = empty($dwEvent['time']);
3591d05cddcSAtari911
3601d05cddcSAtari911    if ($isAllDay) {
3611d05cddcSAtari911        // All-day events: Use just the date, and end date is next day
3621d05cddcSAtari911        $startDateTime = $startDate;
3631d05cddcSAtari911
3641d05cddcSAtari911        // For all-day events, end date must be the day AFTER the last day
3651d05cddcSAtari911        $endDateObj = new DateTime($endDate);
3661d05cddcSAtari911        $endDateObj->modify('+1 day');
3671d05cddcSAtari911        $endDateTime = $endDateObj->format('Y-m-d');
3681d05cddcSAtari911    } else {
3691d05cddcSAtari911        // Timed events: Add time to date
3701d05cddcSAtari911        $startDateTime = $startDate . 'T' . $dwEvent['time'] . ':00';
3711d05cddcSAtari911
3721d05cddcSAtari911        // End time: if no end date, add 1 hour to start time
3731d05cddcSAtari911        if ($endDate === $dateKey) {
3741d05cddcSAtari911            $dt = new DateTime($startDateTime, new DateTimeZone($timezone));
3751d05cddcSAtari911            $dt->modify('+1 hour');
3761d05cddcSAtari911            $endDateTime = $dt->format('Y-m-d\TH:i:s');
3771d05cddcSAtari911        } else {
3781d05cddcSAtari911            $endDateTime = $endDate . 'T23:59:59';
3791d05cddcSAtari911        }
3801d05cddcSAtari911    }
3811d05cddcSAtari911
3821d05cddcSAtari911    // Determine category based on namespace FIRST (takes precedence)
3831d05cddcSAtari911    $namespace = isset($dwEvent['namespace']) ? $dwEvent['namespace'] : '';
3841d05cddcSAtari911    $category = null;
3851d05cddcSAtari911
3861d05cddcSAtari911    // Priority 1: Namespace mapping
3871d05cddcSAtari911    if (!empty($namespace) && isset($config['category_mapping'][$namespace])) {
3881d05cddcSAtari911        $category = $config['category_mapping'][$namespace];
3891d05cddcSAtari911    }
3901d05cddcSAtari911
3911d05cddcSAtari911    // Priority 2: Color mapping (fallback if no namespace or namespace not mapped)
3921d05cddcSAtari911    if ($category === null && isset($dwEvent['color'])) {
3931d05cddcSAtari911        $colorToCategoryMap = [
3941d05cddcSAtari911            '#3498db' => 'Blue Category',      // Blue
3951d05cddcSAtari911            '#2ecc71' => 'Green Category',     // Green
3961d05cddcSAtari911            '#f39c12' => 'Orange Category',    // Orange
3971d05cddcSAtari911            '#e74c3c' => 'Red Category',       // Red
3981d05cddcSAtari911            '#f1c40f' => 'Yellow Category',    // Yellow
3991d05cddcSAtari911            '#9b59b6' => 'Purple Category',    // Purple
4001d05cddcSAtari911        ];
4011d05cddcSAtari911
4021d05cddcSAtari911        $eventColor = strtolower($dwEvent['color']);
4031d05cddcSAtari911        foreach ($colorToCategoryMap as $color => $cat) {
4041d05cddcSAtari911            if (strtolower($color) === $eventColor) {
4051d05cddcSAtari911                $category = $cat;
4061d05cddcSAtari911                break;
4071d05cddcSAtari911            }
4081d05cddcSAtari911        }
4091d05cddcSAtari911    }
4101d05cddcSAtari911
4111d05cddcSAtari911    // Priority 3: Default category
4121d05cddcSAtari911    if ($category === null) {
4131d05cddcSAtari911        $category = $config['default_category'];
4141d05cddcSAtari911    }
4151d05cddcSAtari911
4161d05cddcSAtari911    // Clean and sanitize text fields
4171d05cddcSAtari911    $title = isset($dwEvent['title']) ? trim($dwEvent['title']) : 'Untitled Event';
4181d05cddcSAtari911    $description = isset($dwEvent['description']) ? trim($dwEvent['description']) : '';
4191d05cddcSAtari911
4201d05cddcSAtari911    // Remove any null bytes and control characters that can break JSON
4211d05cddcSAtari911    $title = preg_replace('/[\x00-\x1F\x7F]/u', '', $title);
4221d05cddcSAtari911    $description = preg_replace('/[\x00-\x1F\x7F]/u', '', $description);
4231d05cddcSAtari911
4241d05cddcSAtari911    // Ensure proper UTF-8 encoding
4251d05cddcSAtari911    if (!mb_check_encoding($title, 'UTF-8')) {
4261d05cddcSAtari911        $title = mb_convert_encoding($title, 'UTF-8', 'UTF-8');
4271d05cddcSAtari911    }
4281d05cddcSAtari911    if (!mb_check_encoding($description, 'UTF-8')) {
4291d05cddcSAtari911        $description = mb_convert_encoding($description, 'UTF-8', 'UTF-8');
4301d05cddcSAtari911    }
4311d05cddcSAtari911
4321d05cddcSAtari911    // Build Outlook event structure
4331d05cddcSAtari911    if ($isAllDay) {
4341d05cddcSAtari911        // All-day events use different format (no time component, no timezone)
4351d05cddcSAtari911        $outlookEvent = [
4361d05cddcSAtari911            'subject' => $title,
4371d05cddcSAtari911            'body' => [
4381d05cddcSAtari911                'contentType' => 'text',
4391d05cddcSAtari911                'content' => $description
4401d05cddcSAtari911            ],
4411d05cddcSAtari911            'start' => [
4421d05cddcSAtari911                'dateTime' => $startDateTime,
4431d05cddcSAtari911                'timeZone' => 'UTC'  // All-day events should use UTC
4441d05cddcSAtari911            ],
4451d05cddcSAtari911            'end' => [
4461d05cddcSAtari911                'dateTime' => $endDateTime,
4471d05cddcSAtari911                'timeZone' => 'UTC'
4481d05cddcSAtari911            ],
4491d05cddcSAtari911            'isAllDay' => true,
4501d05cddcSAtari911            'categories' => [$category],
4511d05cddcSAtari911            'isReminderOn' => false,  // All-day events typically don't need reminders
4521d05cddcSAtari911            'singleValueExtendedProperties' => [
4531d05cddcSAtari911                [
4541d05cddcSAtari911                    'id' => 'String {66f5a359-4659-4830-9070-00047ec6ac6e} Name DokuWikiId',
4551d05cddcSAtari911                    'value' => $namespace . ':' . $dwEvent['id']
4561d05cddcSAtari911                ]
4571d05cddcSAtari911            ]
4581d05cddcSAtari911        ];
4591d05cddcSAtari911    } else {
4601d05cddcSAtari911        // Timed events
4611d05cddcSAtari911        $outlookEvent = [
4621d05cddcSAtari911            'subject' => $title,
4631d05cddcSAtari911            'body' => [
4641d05cddcSAtari911                'contentType' => 'text',
4651d05cddcSAtari911                'content' => $description
4661d05cddcSAtari911            ],
4671d05cddcSAtari911            'start' => [
4681d05cddcSAtari911                'dateTime' => $startDateTime,
4691d05cddcSAtari911                'timeZone' => $timezone
4701d05cddcSAtari911            ],
4711d05cddcSAtari911            'end' => [
4721d05cddcSAtari911                'dateTime' => $endDateTime,
4731d05cddcSAtari911                'timeZone' => $timezone
4741d05cddcSAtari911            ],
4751d05cddcSAtari911            'isAllDay' => false,
4761d05cddcSAtari911            'categories' => [$category],
4771d05cddcSAtari911            'isReminderOn' => true,
4781d05cddcSAtari911            'reminderMinutesBeforeStart' => $config['reminder_minutes'],
4791d05cddcSAtari911            'singleValueExtendedProperties' => [
4801d05cddcSAtari911                [
4811d05cddcSAtari911                    'id' => 'String {66f5a359-4659-4830-9070-00047ec6ac6e} Name DokuWikiId',
4821d05cddcSAtari911                    'value' => $namespace . ':' . $dwEvent['id']
4831d05cddcSAtari911                ]
4841d05cddcSAtari911            ]
4851d05cddcSAtari911        ];
4861d05cddcSAtari911    }
4871d05cddcSAtari911
4881d05cddcSAtari911    return $outlookEvent;
4891d05cddcSAtari911}
4901d05cddcSAtari911
4911d05cddcSAtari911// =============================================================================
4929ccd446eSAtari911// SYNC STATE MANAGEMENT (with hash-based change tracking)
4931d05cddcSAtari911// =============================================================================
4941d05cddcSAtari911
4959ccd446eSAtari911/**
4969ccd446eSAtari911 * Compute a hash of all sync-relevant event fields.
4979ccd446eSAtari911 * If any of these fields change, the event will be re-synced to Outlook.
4989ccd446eSAtari911 */
4999ccd446eSAtari911function computeEventHash($dwEvent) {
5009ccd446eSAtari911    $fields = [
5019ccd446eSAtari911        'title'       => isset($dwEvent['title']) ? trim($dwEvent['title']) : '',
5029ccd446eSAtari911        'description' => isset($dwEvent['description']) ? trim($dwEvent['description']) : '',
5039ccd446eSAtari911        'time'        => isset($dwEvent['time']) ? $dwEvent['time'] : '',
5049ccd446eSAtari911        'endTime'     => isset($dwEvent['endTime']) ? $dwEvent['endTime'] : '',
5059ccd446eSAtari911        'endDate'     => isset($dwEvent['endDate']) ? $dwEvent['endDate'] : '',
5069ccd446eSAtari911        'color'       => isset($dwEvent['color']) ? $dwEvent['color'] : '',
5079ccd446eSAtari911        'namespace'   => isset($dwEvent['namespace']) ? $dwEvent['namespace'] : '',
5089ccd446eSAtari911        'isTask'      => !empty($dwEvent['isTask']) ? '1' : '0',
5099ccd446eSAtari911        'completed'   => !empty($dwEvent['completed']) ? '1' : '0',
5109ccd446eSAtari911        'dateKey'     => isset($dwEvent['_dateKey']) ? $dwEvent['_dateKey'] : '',
5119ccd446eSAtari911    ];
5129ccd446eSAtari911    return md5(json_encode($fields));
5139ccd446eSAtari911}
5149ccd446eSAtari911
5151d05cddcSAtari911function loadSyncState($stateFile) {
5161d05cddcSAtari911    if (!file_exists($stateFile)) {
5179ccd446eSAtari911        return ['mapping' => [], 'last_sync' => 0, 'version' => 2];
5181d05cddcSAtari911    }
5191d05cddcSAtari911
5201d05cddcSAtari911    $data = json_decode(file_get_contents($stateFile), true);
5219ccd446eSAtari911    if (!$data) {
5229ccd446eSAtari911        return ['mapping' => [], 'last_sync' => 0, 'version' => 2];
5239ccd446eSAtari911    }
5249ccd446eSAtari911
5259ccd446eSAtari911    // Migrate v1 state (mapping was dwId => outlookId string)
5269ccd446eSAtari911    // to v2 state (mapping is dwId => {outlookId, hash})
5279ccd446eSAtari911    if (!isset($data['version']) || $data['version'] < 2) {
5289ccd446eSAtari911        logMessage("Migrating sync state from v1 to v2 (adding hash tracking)...");
5299ccd446eSAtari911        $newMapping = [];
5309ccd446eSAtari911        foreach ($data['mapping'] as $dwId => $value) {
5319ccd446eSAtari911            if (is_string($value)) {
5329ccd446eSAtari911                // v1 format: dwId => outlookId
5339ccd446eSAtari911                $newMapping[$dwId] = ['outlookId' => $value, 'hash' => ''];
5349ccd446eSAtari911            } else {
5359ccd446eSAtari911                // Already v2
5369ccd446eSAtari911                $newMapping[$dwId] = $value;
5379ccd446eSAtari911            }
5389ccd446eSAtari911        }
5399ccd446eSAtari911        $data['mapping'] = $newMapping;
5409ccd446eSAtari911        $data['version'] = 2;
5419ccd446eSAtari911        logMessage("Migration complete - " . count($newMapping) . " entries migrated (will re-sync all on first run)");
5429ccd446eSAtari911    }
5439ccd446eSAtari911
5449ccd446eSAtari911    return $data;
5451d05cddcSAtari911}
5461d05cddcSAtari911
5471d05cddcSAtari911function saveSyncState($stateFile, $state) {
5481d05cddcSAtari911    $state['last_sync'] = time();
5499ccd446eSAtari911    $state['version'] = 2;
5501d05cddcSAtari911    file_put_contents($stateFile, json_encode($state, JSON_PRETTY_PRINT));
5511d05cddcSAtari911}
5521d05cddcSAtari911
5531d05cddcSAtari911// =============================================================================
5541d05cddcSAtari911// MAIN SYNC LOGIC
5551d05cddcSAtari911// =============================================================================
5561d05cddcSAtari911
5571d05cddcSAtari911try {
5581d05cddcSAtari911    // Initialize API client
5591d05cddcSAtari911    $client = new MicrosoftGraphClient($config);
5601d05cddcSAtari911    logMessage("Authenticating with Microsoft Graph API...");
5611d05cddcSAtari911    $client->getAccessToken();
5621d05cddcSAtari911    logMessage("Authentication successful");
5631d05cddcSAtari911
5641d05cddcSAtari911    // Load sync state
5651d05cddcSAtari911    $state = loadSyncState($stateFile);
5669ccd446eSAtari911    $mapping = $state['mapping']; // dwId => {outlookId, hash}
5671d05cddcSAtari911
5681d05cddcSAtari911    // Reset mode - clear the mapping
5691d05cddcSAtari911    if ($reset) {
5701d05cddcSAtari911        logMessage("Resetting sync state...");
5711d05cddcSAtari911        $mapping = [];
5721d05cddcSAtari911    }
5731d05cddcSAtari911
5741d05cddcSAtari911    // Load DokuWiki events
5751d05cddcSAtari911    logMessage("Loading DokuWiki calendar events...");
5761d05cddcSAtari911    $dwEvents = loadDokuWikiEvents($dokuwikiRoot, $filterNamespace);
5771d05cddcSAtari911    logMessage("Found " . count($dwEvents) . " events in DokuWiki");
5781d05cddcSAtari911
5791d05cddcSAtari911    // Clean duplicates mode
5801d05cddcSAtari911    if ($cleanDuplicates) {
5811d05cddcSAtari911        logMessage("=== Cleaning Duplicates ===");
5821d05cddcSAtari911        $duplicatesFound = 0;
5831d05cddcSAtari911        $duplicatesDeleted = 0;
5841d05cddcSAtari911
5851d05cddcSAtari911        foreach ($dwEvents as $dwId => $dwEvent) {
5861d05cddcSAtari911            $existingEvents = $client->findEventByDokuWikiId($config['user_email'], $dwId);
5871d05cddcSAtari911
5881d05cddcSAtari911            if (count($existingEvents) > 1) {
5891d05cddcSAtari911                $duplicatesFound += count($existingEvents) - 1;
5901d05cddcSAtari911                logMessage("Found " . count($existingEvents) . " copies of: {$dwEvent['title']}");
5911d05cddcSAtari911
5921d05cddcSAtari911                if (!$dryRun) {
5931d05cddcSAtari911                    $deleted = $client->deleteAllDuplicates($config['user_email'], $dwId);
5941d05cddcSAtari911                    $duplicatesDeleted += $deleted;
5951d05cddcSAtari911
5961d05cddcSAtari911                    // Update mapping with the remaining event
5971d05cddcSAtari911                    $remaining = $client->findEventByDokuWikiId($config['user_email'], $dwId);
5981d05cddcSAtari911                    if (count($remaining) == 1) {
5999ccd446eSAtari911                        $hash = computeEventHash($dwEvent);
6009ccd446eSAtari911                        $mapping[$dwId] = ['outlookId' => $remaining[0]['id'], 'hash' => $hash];
6011d05cddcSAtari911                    }
6021d05cddcSAtari911                }
6031d05cddcSAtari911            }
6041d05cddcSAtari911        }
6051d05cddcSAtari911
6061d05cddcSAtari911        logMessage("=== Duplicate Cleanup Complete ===");
6071d05cddcSAtari911        logMessage("Duplicates found: $duplicatesFound");
6081d05cddcSAtari911        logMessage("Duplicates deleted: $duplicatesDeleted");
6091d05cddcSAtari911
6101d05cddcSAtari911        if (!$dryRun) {
6111d05cddcSAtari911            $state['mapping'] = $mapping;
6121d05cddcSAtari911            saveSyncState($stateFile, $state);
6131d05cddcSAtari911        }
6141d05cddcSAtari911
6151d05cddcSAtari911        exit(0);
6161d05cddcSAtari911    }
6171d05cddcSAtari911
6189ccd446eSAtari911    // =========================================================================
6199ccd446eSAtari911    // DELTA DETECTION - classify events as new, modified, unchanged, or deleted
6209ccd446eSAtari911    // =========================================================================
6211d05cddcSAtari911
6229ccd446eSAtari911    $newEvents = [];       // In DokuWiki but not in mapping
6239ccd446eSAtari911    $modifiedEvents = [];  // In both but hash changed
6249ccd446eSAtari911    $unchangedEvents = []; // In both and hash matches
6259ccd446eSAtari911    $deletedIds = [];      // In mapping but not in DokuWiki
6269ccd446eSAtari911
6279ccd446eSAtari911    // Classify current DokuWiki events
6281d05cddcSAtari911    foreach ($dwEvents as $dwId => $dwEvent) {
6299ccd446eSAtari911        $currentHash = computeEventHash($dwEvent);
6309ccd446eSAtari911
6319ccd446eSAtari911        if (!isset($mapping[$dwId])) {
6329ccd446eSAtari911            $newEvents[$dwId] = $dwEvent;
6339ccd446eSAtari911        } elseif ($forceSync || $mapping[$dwId]['hash'] !== $currentHash) {
6349ccd446eSAtari911            $modifiedEvents[$dwId] = $dwEvent;
6359ccd446eSAtari911        } else {
6369ccd446eSAtari911            $unchangedEvents[$dwId] = $dwEvent;
6379ccd446eSAtari911        }
6389ccd446eSAtari911    }
6399ccd446eSAtari911
6409ccd446eSAtari911    // Find deleted events (in mapping but no longer in DokuWiki)
6419ccd446eSAtari911    foreach ($mapping as $dwId => $entry) {
6429ccd446eSAtari911        if (!isset($dwEvents[$dwId])) {
6439ccd446eSAtari911            $deletedIds[] = $dwId;
6449ccd446eSAtari911        }
6459ccd446eSAtari911    }
6469ccd446eSAtari911
6479ccd446eSAtari911    logMessage("=== Delta Analysis ===");
6489ccd446eSAtari911    logMessage("  New:       " . count($newEvents));
6499ccd446eSAtari911    logMessage("  Modified:  " . count($modifiedEvents));
6509ccd446eSAtari911    logMessage("  Unchanged: " . count($unchangedEvents) . " (skipping)");
6519ccd446eSAtari911    logMessage("  Deleted:   " . count($deletedIds));
6529ccd446eSAtari911    $totalApiCalls = count($newEvents) + count($modifiedEvents) + count($deletedIds);
6539ccd446eSAtari911    logMessage("  API calls: ~$totalApiCalls (vs " . count($dwEvents) . " full sync)");
6549ccd446eSAtari911
6559ccd446eSAtari911    if ($totalApiCalls === 0) {
6569ccd446eSAtari911        logMessage("Nothing to sync - calendar is up to date!");
6579ccd446eSAtari911    }
6589ccd446eSAtari911
6599ccd446eSAtari911    // =========================================================================
6609ccd446eSAtari911    // SYNC NEW EVENTS
6619ccd446eSAtari911    // =========================================================================
6629ccd446eSAtari911
6639ccd446eSAtari911    foreach ($newEvents as $dwId => $dwEvent) {
6641d05cddcSAtari911        // Check for abort flag
6659ccd446eSAtari911        if (file_exists(__DIR__ . '/.sync_abort')) {
6661d05cddcSAtari911            logMessage("=== SYNC ABORTED BY USER ===", 'WARN');
6679ccd446eSAtari911            @unlink(__DIR__ . '/.sync_abort');
6689ccd446eSAtari911            break;
6691d05cddcSAtari911        }
6701d05cddcSAtari911
6711d05cddcSAtari911        // Skip completed tasks if configured
6721d05cddcSAtari911        if (!$config['sync_completed_tasks'] &&
6731d05cddcSAtari911            !empty($dwEvent['isTask']) &&
6741d05cddcSAtari911            !empty($dwEvent['completed'])) {
6751d05cddcSAtari911            $stats['skipped']++;
6761d05cddcSAtari911            continue;
6771d05cddcSAtari911        }
6781d05cddcSAtari911
6791d05cddcSAtari911        $outlookEvent = convertToOutlookEvent($dwEvent, $config);
6809ccd446eSAtari911        $eventNamespace = isset($dwEvent['namespace']) ? $dwEvent['namespace'] : '';
6819ccd446eSAtari911        $hash = computeEventHash($dwEvent);
6821d05cddcSAtari911
6831d05cddcSAtari911        try {
6849ccd446eSAtari911            // Check if event already exists in Outlook (unmapped from previous sync)
6851d05cddcSAtari911            $existingEvents = $client->findEventByDokuWikiId($config['user_email'], $dwId);
6861d05cddcSAtari911
6879ccd446eSAtari911            if (count($existingEvents) >= 1) {
6889ccd446eSAtari911                // Already exists - update and map it
6899ccd446eSAtari911                $outlookId = $existingEvents[0]['id'];
6901d05cddcSAtari911
6911d05cddcSAtari911                if (!$dryRun) {
6929ccd446eSAtari911                    $client->updateEvent($config['user_email'], $outlookId, $outlookEvent);
6939ccd446eSAtari911                    $mapping[$dwId] = ['outlookId' => $outlookId, 'hash' => $hash];
6941d05cddcSAtari911
6959ccd446eSAtari911                    // Clean any duplicates
6969ccd446eSAtari911                    if (count($existingEvents) > 1) {
6979ccd446eSAtari911                        $client->deleteAllDuplicates($config['user_email'], $dwId);
6989ccd446eSAtari911                        logMessage("  Cleaned " . (count($existingEvents) - 1) . " duplicate(s)");
6999ccd446eSAtari911                    }
7009ccd446eSAtari911                }
7019ccd446eSAtari911                $stats['updated']++;
7029ccd446eSAtari911                logMessage("Mapped & updated: {$dwEvent['title']} [$eventNamespace]");
7039ccd446eSAtari911            } else {
7049ccd446eSAtari911                // Truly new - create in Outlook
7059ccd446eSAtari911                if (!$dryRun) {
7069ccd446eSAtari911                    $result = $client->createEvent($config['user_email'], $outlookEvent);
7079ccd446eSAtari911                    $mapping[$dwId] = ['outlookId' => $result['id'], 'hash' => $hash];
7089ccd446eSAtari911                    logMessage("Created: {$dwEvent['title']} [$eventNamespace]");
7099ccd446eSAtari911                } else {
7109ccd446eSAtari911                    logMessage("Would create: {$dwEvent['title']} [$eventNamespace]");
7119ccd446eSAtari911                }
7129ccd446eSAtari911                $stats['created']++;
7139ccd446eSAtari911            }
7149ccd446eSAtari911        } catch (Exception $e) {
7159ccd446eSAtari911            $stats['errors']++;
7169ccd446eSAtari911            logMessage("ERROR creating {$dwEvent['title']}: " . $e->getMessage(), 'ERROR');
7171d05cddcSAtari911        }
7181d05cddcSAtari911    }
7191d05cddcSAtari911
7209ccd446eSAtari911    // =========================================================================
7219ccd446eSAtari911    // SYNC MODIFIED EVENTS
7229ccd446eSAtari911    // =========================================================================
7239ccd446eSAtari911
7249ccd446eSAtari911    foreach ($modifiedEvents as $dwId => $dwEvent) {
7259ccd446eSAtari911        if (file_exists(__DIR__ . '/.sync_abort')) {
7269ccd446eSAtari911            logMessage("=== SYNC ABORTED BY USER ===", 'WARN');
7279ccd446eSAtari911            @unlink(__DIR__ . '/.sync_abort');
7289ccd446eSAtari911            break;
7291d05cddcSAtari911        }
7301d05cddcSAtari911
7319ccd446eSAtari911        if (!$config['sync_completed_tasks'] &&
7329ccd446eSAtari911            !empty($dwEvent['isTask']) &&
7339ccd446eSAtari911            !empty($dwEvent['completed'])) {
7349ccd446eSAtari911            $stats['skipped']++;
7359ccd446eSAtari911            continue;
7369ccd446eSAtari911        }
7371d05cddcSAtari911
7389ccd446eSAtari911        $outlookEvent = convertToOutlookEvent($dwEvent, $config);
7399ccd446eSAtari911        $eventNamespace = isset($dwEvent['namespace']) ? $dwEvent['namespace'] : '';
7409ccd446eSAtari911        $hash = computeEventHash($dwEvent);
7419ccd446eSAtari911        $outlookId = $mapping[$dwId]['outlookId'];
7429ccd446eSAtari911
7439ccd446eSAtari911        try {
7441d05cddcSAtari911            if (!$dryRun) {
7451d05cddcSAtari911                try {
7461d05cddcSAtari911                    $client->updateEvent($config['user_email'], $outlookId, $outlookEvent);
7479ccd446eSAtari911                    $mapping[$dwId] = ['outlookId' => $outlookId, 'hash' => $hash];
7481d05cddcSAtari911                    $stats['updated']++;
7491d05cddcSAtari911                    logMessage("Updated: {$dwEvent['title']} [$eventNamespace]");
7501d05cddcSAtari911                } catch (Exception $e) {
7519ccd446eSAtari911                    // 404 = event was deleted from Outlook, recreate it
7521d05cddcSAtari911                    if (strpos($e->getMessage(), 'HTTP 404') !== false ||
7531d05cddcSAtari911                        strpos($e->getMessage(), 'ErrorItemNotFound') !== false) {
7541d05cddcSAtari911
7551d05cddcSAtari911                        logMessage("Event deleted from Outlook, recreating: {$dwEvent['title']}", 'WARN');
7561d05cddcSAtari911                        $result = $client->createEvent($config['user_email'], $outlookEvent);
7579ccd446eSAtari911                        $mapping[$dwId] = ['outlookId' => $result['id'], 'hash' => $hash];
7581d05cddcSAtari911                        $stats['recreated']++;
7599ccd446eSAtari911                        logMessage("Recreated: {$dwEvent['title']} [$eventNamespace]");
7601d05cddcSAtari911                    } else {
7611d05cddcSAtari911                        throw $e;
7621d05cddcSAtari911                    }
7631d05cddcSAtari911                }
7641d05cddcSAtari911            } else {
7651d05cddcSAtari911                $stats['updated']++;
7661d05cddcSAtari911                logMessage("Would update: {$dwEvent['title']} [$eventNamespace]");
7671d05cddcSAtari911            }
7681d05cddcSAtari911        } catch (Exception $e) {
7691d05cddcSAtari911            $stats['errors']++;
7709ccd446eSAtari911            logMessage("ERROR updating {$dwEvent['title']}: " . $e->getMessage(), 'ERROR');
7711d05cddcSAtari911        }
7721d05cddcSAtari911    }
7731d05cddcSAtari911
7749ccd446eSAtari911    // =========================================================================
7759ccd446eSAtari911    // DELETE REMOVED EVENTS
7769ccd446eSAtari911    // =========================================================================
7771d05cddcSAtari911
7789ccd446eSAtari911    if ($config['delete_outlook_events'] && !empty($deletedIds)) {
7799ccd446eSAtari911        logMessage("=== Deleting " . count($deletedIds) . " removed events ===");
7809ccd446eSAtari911
7819ccd446eSAtari911        foreach ($deletedIds as $dwId) {
7829ccd446eSAtari911            $outlookId = $mapping[$dwId]['outlookId'];
7839ccd446eSAtari911
7841d05cddcSAtari911            try {
7851d05cddcSAtari911                if (!$dryRun) {
7861d05cddcSAtari911                    $client->deleteEvent($config['user_email'], $outlookId);
7879ccd446eSAtari911                    logMessage("Deleted: $dwId");
7881d05cddcSAtari911                } else {
7899ccd446eSAtari911                    logMessage("Would delete: $dwId");
7901d05cddcSAtari911                }
7919ccd446eSAtari911                unset($mapping[$dwId]);
7921d05cddcSAtari911                $stats['deleted']++;
7931d05cddcSAtari911            } catch (Exception $e) {
7941d05cddcSAtari911                if (strpos($e->getMessage(), 'HTTP 404') !== false ||
7951d05cddcSAtari911                    strpos($e->getMessage(), 'ErrorItemNotFound') !== false) {
7969ccd446eSAtari911                    logMessage("Already gone from Outlook: $dwId", 'DEBUG');
7971d05cddcSAtari911                    unset($mapping[$dwId]);
7981d05cddcSAtari911                    $stats['deleted']++;
7991d05cddcSAtari911                } else {
8001d05cddcSAtari911                    logMessage("ERROR deleting $dwId: " . $e->getMessage(), 'ERROR');
8019ccd446eSAtari911                    $stats['errors']++;
8021d05cddcSAtari911                }
8031d05cddcSAtari911            }
8041d05cddcSAtari911        }
8051d05cddcSAtari911    }
8061d05cddcSAtari911
8079ccd446eSAtari911    // Save state after every sync (checkpoint)
8081d05cddcSAtari911    if (!$dryRun) {
8091d05cddcSAtari911        $state['mapping'] = $mapping;
8101d05cddcSAtari911        saveSyncState($stateFile, $state);
8111d05cddcSAtari911    }
8121d05cddcSAtari911
8139ccd446eSAtari911    // Count unchanged as skipped for stats
8149ccd446eSAtari911    $stats['skipped'] += count($unchangedEvents);
8159ccd446eSAtari911
8161d05cddcSAtari911    // Summary
8171d05cddcSAtari911    logMessage("=== Sync Complete ===");
8189ccd446eSAtari911    logMessage("New:       {$stats['created']}");
8191d05cddcSAtari911    logMessage("Updated:   {$stats['updated']}");
8209ccd446eSAtari911    logMessage("Recreated: {$stats['recreated']}");
8211d05cddcSAtari911    logMessage("Deleted:   {$stats['deleted']}");
8229ccd446eSAtari911    logMessage("Unchanged: " . count($unchangedEvents));
8231d05cddcSAtari911    logMessage("Skipped:   {$stats['skipped']}");
8241d05cddcSAtari911    logMessage("Errors:    {$stats['errors']}");
8251d05cddcSAtari911
8261d05cddcSAtari911    logMessage("");
8271d05cddcSAtari911    if ($dryRun) {
8281d05cddcSAtari911        logMessage("DRY RUN - No changes were made");
8291d05cddcSAtari911    } else {
8301d05cddcSAtari911        logMessage("Sync completed successfully!");
8311d05cddcSAtari911    }
8321d05cddcSAtari911
8331d05cddcSAtari911    exit($stats['errors'] > 0 ? 1 : 0);
8341d05cddcSAtari911
8351d05cddcSAtari911} catch (Exception $e) {
8361d05cddcSAtari911    logMessage("FATAL ERROR: " . $e->getMessage(), 'ERROR');
8371d05cddcSAtari911    exit(1);
8381d05cddcSAtari911}
839