xref: /plugin/calendar/sync_outlook.php (revision 96df7d3e9a825dddf459ab1ee6077a9886837f17)
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
56*96df7d3eSAtari911// Files - store in DokuWiki data directory (writable), not plugin directory
57*96df7d3eSAtari911$dataDir = $dokuwikiRoot . '/data/meta/calendar/';
58*96df7d3eSAtari911if (!is_dir($dataDir)) {
59*96df7d3eSAtari911    mkdir($dataDir, 0755, true);
60*96df7d3eSAtari911}
61*96df7d3eSAtari911$stateFile = $dataDir . 'sync_state.json';
62*96df7d3eSAtari911$logFile = $dataDir . 'sync.log';
631d05cddcSAtari911
641d05cddcSAtari911// Initialize
651d05cddcSAtari911$stats = [
661d05cddcSAtari911    'scanned' => 0,
671d05cddcSAtari911    'created' => 0,
681d05cddcSAtari911    'updated' => 0,
691d05cddcSAtari911    'deleted' => 0,
701d05cddcSAtari911    'recreated' => 0,
711d05cddcSAtari911    'skipped' => 0,
721d05cddcSAtari911    'errors' => 0
731d05cddcSAtari911];
741d05cddcSAtari911
751d05cddcSAtari911// Logging
761d05cddcSAtari911function logMessage($message, $level = 'INFO') {
777e8ea635SAtari911    global $logFile, $verbose, $config;
781d05cddcSAtari911
797e8ea635SAtari911    // Use timezone from config, fallback to America/Los_Angeles
807e8ea635SAtari911    $timezone = isset($config['timezone']) ? $config['timezone'] : 'America/Los_Angeles';
817e8ea635SAtari911    $tz = new DateTimeZone($timezone);
821d05cddcSAtari911    $now = new DateTime('now', $tz);
831d05cddcSAtari911    $timestamp = $now->format('Y-m-d H:i:s');
841d05cddcSAtari911
851d05cddcSAtari911    $logLine = "[$timestamp] [$level] $message\n";
861d05cddcSAtari911
871d05cddcSAtari911    if ($verbose || $level === 'ERROR') {
881d05cddcSAtari911        echo $logLine;
891d05cddcSAtari911    }
901d05cddcSAtari911
911d05cddcSAtari911    file_put_contents($logFile, $logLine, FILE_APPEND);
921d05cddcSAtari911}
931d05cddcSAtari911
941d05cddcSAtari911logMessage("=== DokuWiki → Outlook Sync Started ===");
951d05cddcSAtari911if ($dryRun) logMessage("DRY RUN MODE - No changes will be made");
961d05cddcSAtari911if ($filterNamespace) logMessage("Filtering namespace: $filterNamespace");
971d05cddcSAtari911if ($reset) logMessage("RESET MODE - Will rebuild sync state from scratch");
981d05cddcSAtari911if ($cleanDuplicates) logMessage("CLEAN DUPLICATES MODE - Will remove all duplicate events");
991d05cddcSAtari911
1001d05cddcSAtari911// =============================================================================
1011d05cddcSAtari911// MICROSOFT GRAPH API CLIENT
1021d05cddcSAtari911// =============================================================================
1031d05cddcSAtari911
1041d05cddcSAtari911class MicrosoftGraphClient {
1051d05cddcSAtari911    private $config;
1061d05cddcSAtari911    private $accessToken = null;
1071d05cddcSAtari911    private $tokenExpiry = 0;
1081d05cddcSAtari911
1091d05cddcSAtari911    public function __construct($config) {
1101d05cddcSAtari911        $this->config = $config;
1111d05cddcSAtari911    }
1121d05cddcSAtari911
1131d05cddcSAtari911    public function getAccessToken() {
1141d05cddcSAtari911        // Check if we have a valid cached token
1151d05cddcSAtari911        if ($this->accessToken && time() < $this->tokenExpiry) {
1161d05cddcSAtari911            return $this->accessToken;
1171d05cddcSAtari911        }
1181d05cddcSAtari911
1191d05cddcSAtari911        // Request new token
1201d05cddcSAtari911        $tokenUrl = "https://login.microsoftonline.com/{$this->config['tenant_id']}/oauth2/v2.0/token";
1211d05cddcSAtari911
1221d05cddcSAtari911        $data = [
1231d05cddcSAtari911            'grant_type' => 'client_credentials',
1241d05cddcSAtari911            'client_id' => $this->config['client_id'],
1251d05cddcSAtari911            'client_secret' => $this->config['client_secret'],
1261d05cddcSAtari911            'scope' => 'https://graph.microsoft.com/.default'
1271d05cddcSAtari911        ];
1281d05cddcSAtari911
1291d05cddcSAtari911        $ch = curl_init($tokenUrl);
1301d05cddcSAtari911        curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
1311d05cddcSAtari911        curl_setopt($ch, CURLOPT_POST, true);
1321d05cddcSAtari911        curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($data));
1331d05cddcSAtari911        curl_setopt($ch, CURLOPT_TIMEOUT, $this->config['api_timeout']);
1341d05cddcSAtari911
1351d05cddcSAtari911        $response = curl_exec($ch);
1361d05cddcSAtari911        $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
1371d05cddcSAtari911        curl_close($ch);
1381d05cddcSAtari911
1391d05cddcSAtari911        if ($httpCode !== 200) {
1401d05cddcSAtari911            throw new Exception("Failed to get access token: HTTP $httpCode - $response");
1411d05cddcSAtari911        }
1421d05cddcSAtari911
1431d05cddcSAtari911        $result = json_decode($response, true);
1441d05cddcSAtari911        if (!isset($result['access_token'])) {
1451d05cddcSAtari911            throw new Exception("No access token in response: $response");
1461d05cddcSAtari911        }
1471d05cddcSAtari911
1481d05cddcSAtari911        $this->accessToken = $result['access_token'];
1491d05cddcSAtari911        $this->tokenExpiry = time() + ($result['expires_in'] - 300); // Refresh 5min early
1501d05cddcSAtari911
1511d05cddcSAtari911        return $this->accessToken;
1521d05cddcSAtari911    }
1531d05cddcSAtari911
1541d05cddcSAtari911    public function apiRequest($method, $endpoint, $data = null) {
1551d05cddcSAtari911        $token = $this->getAccessToken();
1561d05cddcSAtari911        $url = "https://graph.microsoft.com/v1.0" . $endpoint;
1571d05cddcSAtari911
1581d05cddcSAtari911        $ch = curl_init($url);
1591d05cddcSAtari911        curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
1601d05cddcSAtari911        curl_setopt($ch, CURLOPT_TIMEOUT, $this->config['api_timeout']);
1611d05cddcSAtari911        curl_setopt($ch, CURLOPT_HTTPHEADER, [
1621d05cddcSAtari911            'Authorization: Bearer ' . $token,
1631d05cddcSAtari911            'Content-Type: application/json',
1641d05cddcSAtari911            'Prefer: outlook.timezone="' . $this->config['timezone'] . '"'
1651d05cddcSAtari911        ]);
1661d05cddcSAtari911
1671d05cddcSAtari911        if ($method !== 'GET') {
1681d05cddcSAtari911            curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method);
1691d05cddcSAtari911        }
1701d05cddcSAtari911
1711d05cddcSAtari911        if ($data !== null) {
1721d05cddcSAtari911            $jsonData = json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
1731d05cddcSAtari911            if ($jsonData === false) {
1741d05cddcSAtari911                throw new Exception("Failed to encode JSON: " . json_last_error_msg());
1751d05cddcSAtari911            }
1761d05cddcSAtari911            curl_setopt($ch, CURLOPT_POSTFIELDS, $jsonData);
1771d05cddcSAtari911        }
1781d05cddcSAtari911
1791d05cddcSAtari911        $response = curl_exec($ch);
1801d05cddcSAtari911        $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
1811d05cddcSAtari911        curl_close($ch);
1821d05cddcSAtari911
1831d05cddcSAtari911        if ($httpCode >= 400) {
1841d05cddcSAtari911            throw new Exception("API request failed: $method $endpoint - HTTP $httpCode - $response");
1851d05cddcSAtari911        }
1861d05cddcSAtari911
1871d05cddcSAtari911        return json_decode($response, true);
1881d05cddcSAtari911    }
1891d05cddcSAtari911
1901d05cddcSAtari911    public function createEvent($userEmail, $eventData) {
1911d05cddcSAtari911        return $this->apiRequest('POST', "/users/$userEmail/events", $eventData);
1921d05cddcSAtari911    }
1931d05cddcSAtari911
1941d05cddcSAtari911    public function updateEvent($userEmail, $outlookId, $eventData) {
1951d05cddcSAtari911        return $this->apiRequest('PATCH', "/users/$userEmail/events/$outlookId", $eventData);
1961d05cddcSAtari911    }
1971d05cddcSAtari911
1981d05cddcSAtari911    public function deleteEvent($userEmail, $outlookId) {
1991d05cddcSAtari911        return $this->apiRequest('DELETE', "/users/$userEmail/events/$outlookId");
2001d05cddcSAtari911    }
2011d05cddcSAtari911
2021d05cddcSAtari911    public function getEvent($userEmail, $outlookId) {
2031d05cddcSAtari911        try {
2041d05cddcSAtari911            return $this->apiRequest('GET', "/users/$userEmail/events/$outlookId");
2051d05cddcSAtari911        } catch (Exception $e) {
2061d05cddcSAtari911            return null; // Event not found
2071d05cddcSAtari911        }
2081d05cddcSAtari911    }
2091d05cddcSAtari911
2101d05cddcSAtari911    public function findEventByDokuWikiId($userEmail, $dokuwikiId) {
2111d05cddcSAtari911        // Search for events with our custom extended property
2121d05cddcSAtari911        $filter = rawurlencode("singleValueExtendedProperties/Any(ep: ep/id eq 'String {66f5a359-4659-4830-9070-00047ec6ac6e} Name DokuWikiId' and ep/value eq '$dokuwikiId')");
2131d05cddcSAtari911
2141d05cddcSAtari911        try {
2151d05cddcSAtari911            $result = $this->apiRequest('GET', "/users/$userEmail/events?\$filter=$filter&\$select=id,subject");
2161d05cddcSAtari911            return isset($result['value']) ? $result['value'] : [];
2171d05cddcSAtari911        } catch (Exception $e) {
2181d05cddcSAtari911            logMessage("ERROR searching for event: " . $e->getMessage(), 'ERROR');
2191d05cddcSAtari911            return [];
2201d05cddcSAtari911        }
2211d05cddcSAtari911    }
2221d05cddcSAtari911
2231d05cddcSAtari911    public function deleteAllDuplicates($userEmail, $dokuwikiId) {
2241d05cddcSAtari911        $events = $this->findEventByDokuWikiId($userEmail, $dokuwikiId);
2251d05cddcSAtari911
2261d05cddcSAtari911        if (count($events) <= 1) {
2271d05cddcSAtari911            return 0; // No duplicates
2281d05cddcSAtari911        }
2291d05cddcSAtari911
2301d05cddcSAtari911        // Keep the first one, delete the rest
2311d05cddcSAtari911        $deleted = 0;
2321d05cddcSAtari911        for ($i = 1; $i < count($events); $i++) {
2331d05cddcSAtari911            try {
2341d05cddcSAtari911                $this->deleteEvent($userEmail, $events[$i]['id']);
2351d05cddcSAtari911                $deleted++;
2361d05cddcSAtari911                logMessage("Deleted duplicate: {$events[$i]['subject']}", 'DEBUG');
2371d05cddcSAtari911            } catch (Exception $e) {
2381d05cddcSAtari911                logMessage("ERROR deleting duplicate: " . $e->getMessage(), 'ERROR');
2391d05cddcSAtari911            }
2401d05cddcSAtari911        }
2411d05cddcSAtari911
2421d05cddcSAtari911        return $deleted;
2431d05cddcSAtari911    }
2441d05cddcSAtari911}
2451d05cddcSAtari911
2461d05cddcSAtari911// =============================================================================
2471d05cddcSAtari911// DOKUWIKI CALENDAR READER
2481d05cddcSAtari911// =============================================================================
2491d05cddcSAtari911
2501d05cddcSAtari911function loadDokuWikiEvents($dokuwikiRoot, $filterNamespace = null) {
2511d05cddcSAtari911    $metaDir = $dokuwikiRoot . '/data/meta';
2521d05cddcSAtari911    $allEvents = [];
2531d05cddcSAtari911
2541d05cddcSAtari911    if (!is_dir($metaDir)) {
2551d05cddcSAtari911        logMessage("ERROR: Meta directory not found: $metaDir", 'ERROR');
2561d05cddcSAtari911        return [];
2571d05cddcSAtari911    }
2581d05cddcSAtari911
2591d05cddcSAtari911    scanCalendarDirs($metaDir, '', $allEvents, $filterNamespace);
2601d05cddcSAtari911
2611d05cddcSAtari911    return $allEvents;
2621d05cddcSAtari911}
2631d05cddcSAtari911
2641d05cddcSAtari911function scanCalendarDirs($dir, $namespace, &$allEvents, $filterNamespace) {
2651d05cddcSAtari911    $items = @scandir($dir);
2661d05cddcSAtari911    if (!$items) return;
2671d05cddcSAtari911
2681d05cddcSAtari911    foreach ($items as $item) {
2691d05cddcSAtari911        if ($item === '.' || $item === '..') continue;
2701d05cddcSAtari911
2711d05cddcSAtari911        $path = $dir . '/' . $item;
2721d05cddcSAtari911
2731d05cddcSAtari911        if (is_dir($path)) {
2741d05cddcSAtari911            if ($item === 'calendar') {
2751d05cddcSAtari911                // Found a calendar directory
2761d05cddcSAtari911                $currentNamespace = trim($namespace, ':');
2771d05cddcSAtari911
2781d05cddcSAtari911                // Check filter
2791d05cddcSAtari911                if ($filterNamespace !== null && $currentNamespace !== $filterNamespace) {
2801d05cddcSAtari911                    continue;
2811d05cddcSAtari911                }
2821d05cddcSAtari911
2831d05cddcSAtari911                logMessage("Scanning calendar: $currentNamespace", 'DEBUG');
2841d05cddcSAtari911                loadCalendarFiles($path, $currentNamespace, $allEvents);
2851d05cddcSAtari911            } else {
2861d05cddcSAtari911                // Recurse into subdirectory
2871d05cddcSAtari911                $newNamespace = $namespace ? $namespace . ':' . $item : $item;
2881d05cddcSAtari911                scanCalendarDirs($path, $newNamespace, $allEvents, $filterNamespace);
2891d05cddcSAtari911            }
2901d05cddcSAtari911        }
2911d05cddcSAtari911    }
2921d05cddcSAtari911}
2931d05cddcSAtari911
2941d05cddcSAtari911function loadCalendarFiles($calendarDir, $namespace, &$allEvents) {
2951d05cddcSAtari911    global $stats;
2961d05cddcSAtari911
2971d05cddcSAtari911    $files = glob($calendarDir . '/*.json');
2981d05cddcSAtari911
2991d05cddcSAtari911    foreach ($files as $file) {
3001d05cddcSAtari911        $contents = file_get_contents($file);
3011d05cddcSAtari911
3021d05cddcSAtari911        // Skip empty files
3031d05cddcSAtari911        if (trim($contents) === '' || trim($contents) === '{}' || trim($contents) === '[]') {
3041d05cddcSAtari911            continue;
3051d05cddcSAtari911        }
3061d05cddcSAtari911
3071d05cddcSAtari911        $data = json_decode($contents, true);
3081d05cddcSAtari911
3091d05cddcSAtari911        // Check for JSON errors
3101d05cddcSAtari911        if (json_last_error() !== JSON_ERROR_NONE) {
3111d05cddcSAtari911            logMessage("ERROR: Invalid JSON in $file: " . json_last_error_msg(), 'ERROR');
3121d05cddcSAtari911            continue;
3131d05cddcSAtari911        }
3141d05cddcSAtari911
3151d05cddcSAtari911        if (!is_array($data)) continue;
3161d05cddcSAtari911        if (empty($data)) continue;
3171d05cddcSAtari911
3181d05cddcSAtari911        // MATCH DOKUWIKI LOGIC: Load everything from the file, no filtering
3191d05cddcSAtari911        foreach ($data as $dateKey => $events) {
3201d05cddcSAtari911            if (!is_array($events)) continue;
3211d05cddcSAtari911
3221d05cddcSAtari911            foreach ($events as $event) {
3231d05cddcSAtari911                if (!isset($event['id'])) continue;
3241d05cddcSAtari911
3251d05cddcSAtari911                $stats['scanned']++;
3261d05cddcSAtari911
3271d05cddcSAtari911                // Get event's namespace field
3281d05cddcSAtari911                $eventNamespace = isset($event['namespace']) ? $event['namespace'] : '';
3291d05cddcSAtari911
3301d05cddcSAtari911                // Create unique ID based on event's namespace field
3311d05cddcSAtari911                // Empty namespace = root namespace
3321d05cddcSAtari911                if ($eventNamespace === '') {
3331d05cddcSAtari911                    $uniqueId = ':' . $event['id'];
3341d05cddcSAtari911                } else {
3351d05cddcSAtari911                    $uniqueId = $eventNamespace . ':' . $event['id'];
3361d05cddcSAtari911                }
3371d05cddcSAtari911
3381d05cddcSAtari911                // Store file location for reference
3391d05cddcSAtari911                $event['_fileNamespace'] = $namespace;
3401d05cddcSAtari911                $event['_dateKey'] = $dateKey;
3411d05cddcSAtari911
3421d05cddcSAtari911                // Add to collection - just like DokuWiki does
3431d05cddcSAtari911                $allEvents[$uniqueId] = $event;
3441d05cddcSAtari911            }
3451d05cddcSAtari911        }
3461d05cddcSAtari911    }
3471d05cddcSAtari911}
3481d05cddcSAtari911
3491d05cddcSAtari911// =============================================================================
3501d05cddcSAtari911// EVENT CONVERSION
3511d05cddcSAtari911// =============================================================================
3521d05cddcSAtari911
3531d05cddcSAtari911function convertToOutlookEvent($dwEvent, $config) {
3541d05cddcSAtari911    $timezone = $config['timezone'];
3551d05cddcSAtari911
3561d05cddcSAtari911    // Parse date and time
3571d05cddcSAtari911    $dateKey = $dwEvent['_dateKey'];
3581d05cddcSAtari911    $startDate = $dateKey;
3591d05cddcSAtari911    $endDate = isset($dwEvent['endDate']) && $dwEvent['endDate'] ? $dwEvent['endDate'] : $dateKey;
3601d05cddcSAtari911
3611d05cddcSAtari911    // Handle time
3621d05cddcSAtari911    $isAllDay = empty($dwEvent['time']);
3631d05cddcSAtari911
3641d05cddcSAtari911    if ($isAllDay) {
3651d05cddcSAtari911        // All-day events: Use just the date, and end date is next day
3661d05cddcSAtari911        $startDateTime = $startDate;
3671d05cddcSAtari911
3681d05cddcSAtari911        // For all-day events, end date must be the day AFTER the last day
3691d05cddcSAtari911        $endDateObj = new DateTime($endDate);
3701d05cddcSAtari911        $endDateObj->modify('+1 day');
3711d05cddcSAtari911        $endDateTime = $endDateObj->format('Y-m-d');
3721d05cddcSAtari911    } else {
3731d05cddcSAtari911        // Timed events: Add time to date
3741d05cddcSAtari911        $startDateTime = $startDate . 'T' . $dwEvent['time'] . ':00';
3751d05cddcSAtari911
3761d05cddcSAtari911        // End time: if no end date, add 1 hour to start time
3771d05cddcSAtari911        if ($endDate === $dateKey) {
3781d05cddcSAtari911            $dt = new DateTime($startDateTime, new DateTimeZone($timezone));
3791d05cddcSAtari911            $dt->modify('+1 hour');
3801d05cddcSAtari911            $endDateTime = $dt->format('Y-m-d\TH:i:s');
3811d05cddcSAtari911        } else {
3821d05cddcSAtari911            $endDateTime = $endDate . 'T23:59:59';
3831d05cddcSAtari911        }
3841d05cddcSAtari911    }
3851d05cddcSAtari911
3861d05cddcSAtari911    // Determine category based on namespace FIRST (takes precedence)
3871d05cddcSAtari911    $namespace = isset($dwEvent['namespace']) ? $dwEvent['namespace'] : '';
3881d05cddcSAtari911    $category = null;
3891d05cddcSAtari911
3901d05cddcSAtari911    // Priority 1: Namespace mapping
3911d05cddcSAtari911    if (!empty($namespace) && isset($config['category_mapping'][$namespace])) {
3921d05cddcSAtari911        $category = $config['category_mapping'][$namespace];
3931d05cddcSAtari911    }
3941d05cddcSAtari911
3951d05cddcSAtari911    // Priority 2: Color mapping (fallback if no namespace or namespace not mapped)
3961d05cddcSAtari911    if ($category === null && isset($dwEvent['color'])) {
3971d05cddcSAtari911        $colorToCategoryMap = [
3981d05cddcSAtari911            '#3498db' => 'Blue Category',      // Blue
3991d05cddcSAtari911            '#2ecc71' => 'Green Category',     // Green
4001d05cddcSAtari911            '#f39c12' => 'Orange Category',    // Orange
4011d05cddcSAtari911            '#e74c3c' => 'Red Category',       // Red
4021d05cddcSAtari911            '#f1c40f' => 'Yellow Category',    // Yellow
4031d05cddcSAtari911            '#9b59b6' => 'Purple Category',    // Purple
4041d05cddcSAtari911        ];
4051d05cddcSAtari911
4061d05cddcSAtari911        $eventColor = strtolower($dwEvent['color']);
4071d05cddcSAtari911        foreach ($colorToCategoryMap as $color => $cat) {
4081d05cddcSAtari911            if (strtolower($color) === $eventColor) {
4091d05cddcSAtari911                $category = $cat;
4101d05cddcSAtari911                break;
4111d05cddcSAtari911            }
4121d05cddcSAtari911        }
4131d05cddcSAtari911    }
4141d05cddcSAtari911
4151d05cddcSAtari911    // Priority 3: Default category
4161d05cddcSAtari911    if ($category === null) {
4171d05cddcSAtari911        $category = $config['default_category'];
4181d05cddcSAtari911    }
4191d05cddcSAtari911
4201d05cddcSAtari911    // Clean and sanitize text fields
4211d05cddcSAtari911    $title = isset($dwEvent['title']) ? trim($dwEvent['title']) : 'Untitled Event';
4221d05cddcSAtari911    $description = isset($dwEvent['description']) ? trim($dwEvent['description']) : '';
4231d05cddcSAtari911
4241d05cddcSAtari911    // Remove any null bytes and control characters that can break JSON
4251d05cddcSAtari911    $title = preg_replace('/[\x00-\x1F\x7F]/u', '', $title);
4261d05cddcSAtari911    $description = preg_replace('/[\x00-\x1F\x7F]/u', '', $description);
4271d05cddcSAtari911
4281d05cddcSAtari911    // Ensure proper UTF-8 encoding
4291d05cddcSAtari911    if (!mb_check_encoding($title, 'UTF-8')) {
4301d05cddcSAtari911        $title = mb_convert_encoding($title, 'UTF-8', 'UTF-8');
4311d05cddcSAtari911    }
4321d05cddcSAtari911    if (!mb_check_encoding($description, 'UTF-8')) {
4331d05cddcSAtari911        $description = mb_convert_encoding($description, 'UTF-8', 'UTF-8');
4341d05cddcSAtari911    }
4351d05cddcSAtari911
4361d05cddcSAtari911    // Build Outlook event structure
4371d05cddcSAtari911    if ($isAllDay) {
4381d05cddcSAtari911        // All-day events use different format (no time component, no timezone)
4391d05cddcSAtari911        $outlookEvent = [
4401d05cddcSAtari911            'subject' => $title,
4411d05cddcSAtari911            'body' => [
4421d05cddcSAtari911                'contentType' => 'text',
4431d05cddcSAtari911                'content' => $description
4441d05cddcSAtari911            ],
4451d05cddcSAtari911            'start' => [
4461d05cddcSAtari911                'dateTime' => $startDateTime,
4471d05cddcSAtari911                'timeZone' => 'UTC'  // All-day events should use UTC
4481d05cddcSAtari911            ],
4491d05cddcSAtari911            'end' => [
4501d05cddcSAtari911                'dateTime' => $endDateTime,
4511d05cddcSAtari911                'timeZone' => 'UTC'
4521d05cddcSAtari911            ],
4531d05cddcSAtari911            'isAllDay' => true,
4541d05cddcSAtari911            'categories' => [$category],
4551d05cddcSAtari911            'isReminderOn' => false,  // All-day events typically don't need reminders
4561d05cddcSAtari911            'singleValueExtendedProperties' => [
4571d05cddcSAtari911                [
4581d05cddcSAtari911                    'id' => 'String {66f5a359-4659-4830-9070-00047ec6ac6e} Name DokuWikiId',
4591d05cddcSAtari911                    'value' => $namespace . ':' . $dwEvent['id']
4601d05cddcSAtari911                ]
4611d05cddcSAtari911            ]
4621d05cddcSAtari911        ];
4631d05cddcSAtari911    } else {
4641d05cddcSAtari911        // Timed events
4651d05cddcSAtari911        $outlookEvent = [
4661d05cddcSAtari911            'subject' => $title,
4671d05cddcSAtari911            'body' => [
4681d05cddcSAtari911                'contentType' => 'text',
4691d05cddcSAtari911                'content' => $description
4701d05cddcSAtari911            ],
4711d05cddcSAtari911            'start' => [
4721d05cddcSAtari911                'dateTime' => $startDateTime,
4731d05cddcSAtari911                'timeZone' => $timezone
4741d05cddcSAtari911            ],
4751d05cddcSAtari911            'end' => [
4761d05cddcSAtari911                'dateTime' => $endDateTime,
4771d05cddcSAtari911                'timeZone' => $timezone
4781d05cddcSAtari911            ],
4791d05cddcSAtari911            'isAllDay' => false,
4801d05cddcSAtari911            'categories' => [$category],
4811d05cddcSAtari911            'isReminderOn' => true,
4821d05cddcSAtari911            'reminderMinutesBeforeStart' => $config['reminder_minutes'],
4831d05cddcSAtari911            'singleValueExtendedProperties' => [
4841d05cddcSAtari911                [
4851d05cddcSAtari911                    'id' => 'String {66f5a359-4659-4830-9070-00047ec6ac6e} Name DokuWikiId',
4861d05cddcSAtari911                    'value' => $namespace . ':' . $dwEvent['id']
4871d05cddcSAtari911                ]
4881d05cddcSAtari911            ]
4891d05cddcSAtari911        ];
4901d05cddcSAtari911    }
4911d05cddcSAtari911
4921d05cddcSAtari911    return $outlookEvent;
4931d05cddcSAtari911}
4941d05cddcSAtari911
4951d05cddcSAtari911// =============================================================================
4969ccd446eSAtari911// SYNC STATE MANAGEMENT (with hash-based change tracking)
4971d05cddcSAtari911// =============================================================================
4981d05cddcSAtari911
4999ccd446eSAtari911/**
5009ccd446eSAtari911 * Compute a hash of all sync-relevant event fields.
5019ccd446eSAtari911 * If any of these fields change, the event will be re-synced to Outlook.
5029ccd446eSAtari911 */
5039ccd446eSAtari911function computeEventHash($dwEvent) {
5049ccd446eSAtari911    $fields = [
5059ccd446eSAtari911        'title'       => isset($dwEvent['title']) ? trim($dwEvent['title']) : '',
5069ccd446eSAtari911        'description' => isset($dwEvent['description']) ? trim($dwEvent['description']) : '',
5079ccd446eSAtari911        'time'        => isset($dwEvent['time']) ? $dwEvent['time'] : '',
5089ccd446eSAtari911        'endTime'     => isset($dwEvent['endTime']) ? $dwEvent['endTime'] : '',
5099ccd446eSAtari911        'endDate'     => isset($dwEvent['endDate']) ? $dwEvent['endDate'] : '',
5109ccd446eSAtari911        'color'       => isset($dwEvent['color']) ? $dwEvent['color'] : '',
5119ccd446eSAtari911        'namespace'   => isset($dwEvent['namespace']) ? $dwEvent['namespace'] : '',
5129ccd446eSAtari911        'isTask'      => !empty($dwEvent['isTask']) ? '1' : '0',
5139ccd446eSAtari911        'completed'   => !empty($dwEvent['completed']) ? '1' : '0',
5149ccd446eSAtari911        'dateKey'     => isset($dwEvent['_dateKey']) ? $dwEvent['_dateKey'] : '',
5159ccd446eSAtari911    ];
5169ccd446eSAtari911    return md5(json_encode($fields));
5179ccd446eSAtari911}
5189ccd446eSAtari911
5191d05cddcSAtari911function loadSyncState($stateFile) {
5201d05cddcSAtari911    if (!file_exists($stateFile)) {
5219ccd446eSAtari911        return ['mapping' => [], 'last_sync' => 0, 'version' => 2];
5221d05cddcSAtari911    }
5231d05cddcSAtari911
5241d05cddcSAtari911    $data = json_decode(file_get_contents($stateFile), true);
5259ccd446eSAtari911    if (!$data) {
5269ccd446eSAtari911        return ['mapping' => [], 'last_sync' => 0, 'version' => 2];
5279ccd446eSAtari911    }
5289ccd446eSAtari911
5299ccd446eSAtari911    // Migrate v1 state (mapping was dwId => outlookId string)
5309ccd446eSAtari911    // to v2 state (mapping is dwId => {outlookId, hash})
5319ccd446eSAtari911    if (!isset($data['version']) || $data['version'] < 2) {
5329ccd446eSAtari911        logMessage("Migrating sync state from v1 to v2 (adding hash tracking)...");
5339ccd446eSAtari911        $newMapping = [];
5349ccd446eSAtari911        foreach ($data['mapping'] as $dwId => $value) {
5359ccd446eSAtari911            if (is_string($value)) {
5369ccd446eSAtari911                // v1 format: dwId => outlookId
5379ccd446eSAtari911                $newMapping[$dwId] = ['outlookId' => $value, 'hash' => ''];
5389ccd446eSAtari911            } else {
5399ccd446eSAtari911                // Already v2
5409ccd446eSAtari911                $newMapping[$dwId] = $value;
5419ccd446eSAtari911            }
5429ccd446eSAtari911        }
5439ccd446eSAtari911        $data['mapping'] = $newMapping;
5449ccd446eSAtari911        $data['version'] = 2;
5459ccd446eSAtari911        logMessage("Migration complete - " . count($newMapping) . " entries migrated (will re-sync all on first run)");
5469ccd446eSAtari911    }
5479ccd446eSAtari911
5489ccd446eSAtari911    return $data;
5491d05cddcSAtari911}
5501d05cddcSAtari911
5511d05cddcSAtari911function saveSyncState($stateFile, $state) {
5521d05cddcSAtari911    $state['last_sync'] = time();
5539ccd446eSAtari911    $state['version'] = 2;
5541d05cddcSAtari911    file_put_contents($stateFile, json_encode($state, JSON_PRETTY_PRINT));
5551d05cddcSAtari911}
5561d05cddcSAtari911
5571d05cddcSAtari911// =============================================================================
5581d05cddcSAtari911// MAIN SYNC LOGIC
5591d05cddcSAtari911// =============================================================================
5601d05cddcSAtari911
5611d05cddcSAtari911try {
5621d05cddcSAtari911    // Initialize API client
5631d05cddcSAtari911    $client = new MicrosoftGraphClient($config);
5641d05cddcSAtari911    logMessage("Authenticating with Microsoft Graph API...");
5651d05cddcSAtari911    $client->getAccessToken();
5661d05cddcSAtari911    logMessage("Authentication successful");
5671d05cddcSAtari911
5681d05cddcSAtari911    // Load sync state
5691d05cddcSAtari911    $state = loadSyncState($stateFile);
5709ccd446eSAtari911    $mapping = $state['mapping']; // dwId => {outlookId, hash}
5711d05cddcSAtari911
5721d05cddcSAtari911    // Reset mode - clear the mapping
5731d05cddcSAtari911    if ($reset) {
5741d05cddcSAtari911        logMessage("Resetting sync state...");
5751d05cddcSAtari911        $mapping = [];
5761d05cddcSAtari911    }
5771d05cddcSAtari911
5781d05cddcSAtari911    // Load DokuWiki events
5791d05cddcSAtari911    logMessage("Loading DokuWiki calendar events...");
5801d05cddcSAtari911    $dwEvents = loadDokuWikiEvents($dokuwikiRoot, $filterNamespace);
5811d05cddcSAtari911    logMessage("Found " . count($dwEvents) . " events in DokuWiki");
5821d05cddcSAtari911
5831d05cddcSAtari911    // Clean duplicates mode
5841d05cddcSAtari911    if ($cleanDuplicates) {
5851d05cddcSAtari911        logMessage("=== Cleaning Duplicates ===");
5861d05cddcSAtari911        $duplicatesFound = 0;
5871d05cddcSAtari911        $duplicatesDeleted = 0;
5881d05cddcSAtari911
5891d05cddcSAtari911        foreach ($dwEvents as $dwId => $dwEvent) {
5901d05cddcSAtari911            $existingEvents = $client->findEventByDokuWikiId($config['user_email'], $dwId);
5911d05cddcSAtari911
5921d05cddcSAtari911            if (count($existingEvents) > 1) {
5931d05cddcSAtari911                $duplicatesFound += count($existingEvents) - 1;
5941d05cddcSAtari911                logMessage("Found " . count($existingEvents) . " copies of: {$dwEvent['title']}");
5951d05cddcSAtari911
5961d05cddcSAtari911                if (!$dryRun) {
5971d05cddcSAtari911                    $deleted = $client->deleteAllDuplicates($config['user_email'], $dwId);
5981d05cddcSAtari911                    $duplicatesDeleted += $deleted;
5991d05cddcSAtari911
6001d05cddcSAtari911                    // Update mapping with the remaining event
6011d05cddcSAtari911                    $remaining = $client->findEventByDokuWikiId($config['user_email'], $dwId);
6021d05cddcSAtari911                    if (count($remaining) == 1) {
6039ccd446eSAtari911                        $hash = computeEventHash($dwEvent);
6049ccd446eSAtari911                        $mapping[$dwId] = ['outlookId' => $remaining[0]['id'], 'hash' => $hash];
6051d05cddcSAtari911                    }
6061d05cddcSAtari911                }
6071d05cddcSAtari911            }
6081d05cddcSAtari911        }
6091d05cddcSAtari911
6101d05cddcSAtari911        logMessage("=== Duplicate Cleanup Complete ===");
6111d05cddcSAtari911        logMessage("Duplicates found: $duplicatesFound");
6121d05cddcSAtari911        logMessage("Duplicates deleted: $duplicatesDeleted");
6131d05cddcSAtari911
6141d05cddcSAtari911        if (!$dryRun) {
6151d05cddcSAtari911            $state['mapping'] = $mapping;
6161d05cddcSAtari911            saveSyncState($stateFile, $state);
6171d05cddcSAtari911        }
6181d05cddcSAtari911
6191d05cddcSAtari911        exit(0);
6201d05cddcSAtari911    }
6211d05cddcSAtari911
6229ccd446eSAtari911    // =========================================================================
6239ccd446eSAtari911    // DELTA DETECTION - classify events as new, modified, unchanged, or deleted
6249ccd446eSAtari911    // =========================================================================
6251d05cddcSAtari911
6269ccd446eSAtari911    $newEvents = [];       // In DokuWiki but not in mapping
6279ccd446eSAtari911    $modifiedEvents = [];  // In both but hash changed
6289ccd446eSAtari911    $unchangedEvents = []; // In both and hash matches
6299ccd446eSAtari911    $deletedIds = [];      // In mapping but not in DokuWiki
6309ccd446eSAtari911
6319ccd446eSAtari911    // Classify current DokuWiki events
6321d05cddcSAtari911    foreach ($dwEvents as $dwId => $dwEvent) {
6339ccd446eSAtari911        $currentHash = computeEventHash($dwEvent);
6349ccd446eSAtari911
6359ccd446eSAtari911        if (!isset($mapping[$dwId])) {
6369ccd446eSAtari911            $newEvents[$dwId] = $dwEvent;
6379ccd446eSAtari911        } elseif ($forceSync || $mapping[$dwId]['hash'] !== $currentHash) {
6389ccd446eSAtari911            $modifiedEvents[$dwId] = $dwEvent;
6399ccd446eSAtari911        } else {
6409ccd446eSAtari911            $unchangedEvents[$dwId] = $dwEvent;
6419ccd446eSAtari911        }
6429ccd446eSAtari911    }
6439ccd446eSAtari911
6449ccd446eSAtari911    // Find deleted events (in mapping but no longer in DokuWiki)
6459ccd446eSAtari911    foreach ($mapping as $dwId => $entry) {
6469ccd446eSAtari911        if (!isset($dwEvents[$dwId])) {
6479ccd446eSAtari911            $deletedIds[] = $dwId;
6489ccd446eSAtari911        }
6499ccd446eSAtari911    }
6509ccd446eSAtari911
6519ccd446eSAtari911    logMessage("=== Delta Analysis ===");
6529ccd446eSAtari911    logMessage("  New:       " . count($newEvents));
6539ccd446eSAtari911    logMessage("  Modified:  " . count($modifiedEvents));
6549ccd446eSAtari911    logMessage("  Unchanged: " . count($unchangedEvents) . " (skipping)");
6559ccd446eSAtari911    logMessage("  Deleted:   " . count($deletedIds));
6569ccd446eSAtari911    $totalApiCalls = count($newEvents) + count($modifiedEvents) + count($deletedIds);
6579ccd446eSAtari911    logMessage("  API calls: ~$totalApiCalls (vs " . count($dwEvents) . " full sync)");
6589ccd446eSAtari911
6599ccd446eSAtari911    if ($totalApiCalls === 0) {
6609ccd446eSAtari911        logMessage("Nothing to sync - calendar is up to date!");
6619ccd446eSAtari911    }
6629ccd446eSAtari911
6639ccd446eSAtari911    // =========================================================================
6649ccd446eSAtari911    // SYNC NEW EVENTS
6659ccd446eSAtari911    // =========================================================================
6669ccd446eSAtari911
6679ccd446eSAtari911    foreach ($newEvents as $dwId => $dwEvent) {
6681d05cddcSAtari911        // Check for abort flag
6699ccd446eSAtari911        if (file_exists(__DIR__ . '/.sync_abort')) {
6701d05cddcSAtari911            logMessage("=== SYNC ABORTED BY USER ===", 'WARN');
6719ccd446eSAtari911            @unlink(__DIR__ . '/.sync_abort');
6729ccd446eSAtari911            break;
6731d05cddcSAtari911        }
6741d05cddcSAtari911
6751d05cddcSAtari911        // Skip completed tasks if configured
6761d05cddcSAtari911        if (!$config['sync_completed_tasks'] &&
6771d05cddcSAtari911            !empty($dwEvent['isTask']) &&
6781d05cddcSAtari911            !empty($dwEvent['completed'])) {
6791d05cddcSAtari911            $stats['skipped']++;
6801d05cddcSAtari911            continue;
6811d05cddcSAtari911        }
6821d05cddcSAtari911
6831d05cddcSAtari911        $outlookEvent = convertToOutlookEvent($dwEvent, $config);
6849ccd446eSAtari911        $eventNamespace = isset($dwEvent['namespace']) ? $dwEvent['namespace'] : '';
6859ccd446eSAtari911        $hash = computeEventHash($dwEvent);
6861d05cddcSAtari911
6871d05cddcSAtari911        try {
6889ccd446eSAtari911            // Check if event already exists in Outlook (unmapped from previous sync)
6891d05cddcSAtari911            $existingEvents = $client->findEventByDokuWikiId($config['user_email'], $dwId);
6901d05cddcSAtari911
6919ccd446eSAtari911            if (count($existingEvents) >= 1) {
6929ccd446eSAtari911                // Already exists - update and map it
6939ccd446eSAtari911                $outlookId = $existingEvents[0]['id'];
6941d05cddcSAtari911
6951d05cddcSAtari911                if (!$dryRun) {
6969ccd446eSAtari911                    $client->updateEvent($config['user_email'], $outlookId, $outlookEvent);
6979ccd446eSAtari911                    $mapping[$dwId] = ['outlookId' => $outlookId, 'hash' => $hash];
6981d05cddcSAtari911
6999ccd446eSAtari911                    // Clean any duplicates
7009ccd446eSAtari911                    if (count($existingEvents) > 1) {
7019ccd446eSAtari911                        $client->deleteAllDuplicates($config['user_email'], $dwId);
7029ccd446eSAtari911                        logMessage("  Cleaned " . (count($existingEvents) - 1) . " duplicate(s)");
7039ccd446eSAtari911                    }
7049ccd446eSAtari911                }
7059ccd446eSAtari911                $stats['updated']++;
7069ccd446eSAtari911                logMessage("Mapped & updated: {$dwEvent['title']} [$eventNamespace]");
7079ccd446eSAtari911            } else {
7089ccd446eSAtari911                // Truly new - create in Outlook
7099ccd446eSAtari911                if (!$dryRun) {
7109ccd446eSAtari911                    $result = $client->createEvent($config['user_email'], $outlookEvent);
7119ccd446eSAtari911                    $mapping[$dwId] = ['outlookId' => $result['id'], 'hash' => $hash];
7129ccd446eSAtari911                    logMessage("Created: {$dwEvent['title']} [$eventNamespace]");
7139ccd446eSAtari911                } else {
7149ccd446eSAtari911                    logMessage("Would create: {$dwEvent['title']} [$eventNamespace]");
7159ccd446eSAtari911                }
7169ccd446eSAtari911                $stats['created']++;
7179ccd446eSAtari911            }
7189ccd446eSAtari911        } catch (Exception $e) {
7199ccd446eSAtari911            $stats['errors']++;
7209ccd446eSAtari911            logMessage("ERROR creating {$dwEvent['title']}: " . $e->getMessage(), 'ERROR');
7211d05cddcSAtari911        }
7221d05cddcSAtari911    }
7231d05cddcSAtari911
7249ccd446eSAtari911    // =========================================================================
7259ccd446eSAtari911    // SYNC MODIFIED EVENTS
7269ccd446eSAtari911    // =========================================================================
7279ccd446eSAtari911
7289ccd446eSAtari911    foreach ($modifiedEvents as $dwId => $dwEvent) {
7299ccd446eSAtari911        if (file_exists(__DIR__ . '/.sync_abort')) {
7309ccd446eSAtari911            logMessage("=== SYNC ABORTED BY USER ===", 'WARN');
7319ccd446eSAtari911            @unlink(__DIR__ . '/.sync_abort');
7329ccd446eSAtari911            break;
7331d05cddcSAtari911        }
7341d05cddcSAtari911
7359ccd446eSAtari911        if (!$config['sync_completed_tasks'] &&
7369ccd446eSAtari911            !empty($dwEvent['isTask']) &&
7379ccd446eSAtari911            !empty($dwEvent['completed'])) {
7389ccd446eSAtari911            $stats['skipped']++;
7399ccd446eSAtari911            continue;
7409ccd446eSAtari911        }
7411d05cddcSAtari911
7429ccd446eSAtari911        $outlookEvent = convertToOutlookEvent($dwEvent, $config);
7439ccd446eSAtari911        $eventNamespace = isset($dwEvent['namespace']) ? $dwEvent['namespace'] : '';
7449ccd446eSAtari911        $hash = computeEventHash($dwEvent);
7459ccd446eSAtari911        $outlookId = $mapping[$dwId]['outlookId'];
7469ccd446eSAtari911
7479ccd446eSAtari911        try {
7481d05cddcSAtari911            if (!$dryRun) {
7491d05cddcSAtari911                try {
7501d05cddcSAtari911                    $client->updateEvent($config['user_email'], $outlookId, $outlookEvent);
7519ccd446eSAtari911                    $mapping[$dwId] = ['outlookId' => $outlookId, 'hash' => $hash];
7521d05cddcSAtari911                    $stats['updated']++;
7531d05cddcSAtari911                    logMessage("Updated: {$dwEvent['title']} [$eventNamespace]");
7541d05cddcSAtari911                } catch (Exception $e) {
7559ccd446eSAtari911                    // 404 = event was deleted from Outlook, recreate it
7561d05cddcSAtari911                    if (strpos($e->getMessage(), 'HTTP 404') !== false ||
7571d05cddcSAtari911                        strpos($e->getMessage(), 'ErrorItemNotFound') !== false) {
7581d05cddcSAtari911
7591d05cddcSAtari911                        logMessage("Event deleted from Outlook, recreating: {$dwEvent['title']}", 'WARN');
7601d05cddcSAtari911                        $result = $client->createEvent($config['user_email'], $outlookEvent);
7619ccd446eSAtari911                        $mapping[$dwId] = ['outlookId' => $result['id'], 'hash' => $hash];
7621d05cddcSAtari911                        $stats['recreated']++;
7639ccd446eSAtari911                        logMessage("Recreated: {$dwEvent['title']} [$eventNamespace]");
7641d05cddcSAtari911                    } else {
7651d05cddcSAtari911                        throw $e;
7661d05cddcSAtari911                    }
7671d05cddcSAtari911                }
7681d05cddcSAtari911            } else {
7691d05cddcSAtari911                $stats['updated']++;
7701d05cddcSAtari911                logMessage("Would update: {$dwEvent['title']} [$eventNamespace]");
7711d05cddcSAtari911            }
7721d05cddcSAtari911        } catch (Exception $e) {
7731d05cddcSAtari911            $stats['errors']++;
7749ccd446eSAtari911            logMessage("ERROR updating {$dwEvent['title']}: " . $e->getMessage(), 'ERROR');
7751d05cddcSAtari911        }
7761d05cddcSAtari911    }
7771d05cddcSAtari911
7789ccd446eSAtari911    // =========================================================================
7799ccd446eSAtari911    // DELETE REMOVED EVENTS
7809ccd446eSAtari911    // =========================================================================
7811d05cddcSAtari911
7829ccd446eSAtari911    if ($config['delete_outlook_events'] && !empty($deletedIds)) {
7839ccd446eSAtari911        logMessage("=== Deleting " . count($deletedIds) . " removed events ===");
7849ccd446eSAtari911
7859ccd446eSAtari911        foreach ($deletedIds as $dwId) {
7869ccd446eSAtari911            $outlookId = $mapping[$dwId]['outlookId'];
7879ccd446eSAtari911
7881d05cddcSAtari911            try {
7891d05cddcSAtari911                if (!$dryRun) {
7901d05cddcSAtari911                    $client->deleteEvent($config['user_email'], $outlookId);
7919ccd446eSAtari911                    logMessage("Deleted: $dwId");
7921d05cddcSAtari911                } else {
7939ccd446eSAtari911                    logMessage("Would delete: $dwId");
7941d05cddcSAtari911                }
7959ccd446eSAtari911                unset($mapping[$dwId]);
7961d05cddcSAtari911                $stats['deleted']++;
7971d05cddcSAtari911            } catch (Exception $e) {
7981d05cddcSAtari911                if (strpos($e->getMessage(), 'HTTP 404') !== false ||
7991d05cddcSAtari911                    strpos($e->getMessage(), 'ErrorItemNotFound') !== false) {
8009ccd446eSAtari911                    logMessage("Already gone from Outlook: $dwId", 'DEBUG');
8011d05cddcSAtari911                    unset($mapping[$dwId]);
8021d05cddcSAtari911                    $stats['deleted']++;
8031d05cddcSAtari911                } else {
8041d05cddcSAtari911                    logMessage("ERROR deleting $dwId: " . $e->getMessage(), 'ERROR');
8059ccd446eSAtari911                    $stats['errors']++;
8061d05cddcSAtari911                }
8071d05cddcSAtari911            }
8081d05cddcSAtari911        }
8091d05cddcSAtari911    }
8101d05cddcSAtari911
8119ccd446eSAtari911    // Save state after every sync (checkpoint)
8121d05cddcSAtari911    if (!$dryRun) {
8131d05cddcSAtari911        $state['mapping'] = $mapping;
8141d05cddcSAtari911        saveSyncState($stateFile, $state);
8151d05cddcSAtari911    }
8161d05cddcSAtari911
8179ccd446eSAtari911    // Count unchanged as skipped for stats
8189ccd446eSAtari911    $stats['skipped'] += count($unchangedEvents);
8199ccd446eSAtari911
8201d05cddcSAtari911    // Summary
8211d05cddcSAtari911    logMessage("=== Sync Complete ===");
8229ccd446eSAtari911    logMessage("New:       {$stats['created']}");
8231d05cddcSAtari911    logMessage("Updated:   {$stats['updated']}");
8249ccd446eSAtari911    logMessage("Recreated: {$stats['recreated']}");
8251d05cddcSAtari911    logMessage("Deleted:   {$stats['deleted']}");
8269ccd446eSAtari911    logMessage("Unchanged: " . count($unchangedEvents));
8271d05cddcSAtari911    logMessage("Skipped:   {$stats['skipped']}");
8281d05cddcSAtari911    logMessage("Errors:    {$stats['errors']}");
8291d05cddcSAtari911
8301d05cddcSAtari911    logMessage("");
8311d05cddcSAtari911    if ($dryRun) {
8321d05cddcSAtari911        logMessage("DRY RUN - No changes were made");
8331d05cddcSAtari911    } else {
8341d05cddcSAtari911        logMessage("Sync completed successfully!");
8351d05cddcSAtari911    }
8361d05cddcSAtari911
8371d05cddcSAtari911    exit($stats['errors'] > 0 ? 1 : 0);
8381d05cddcSAtari911
8391d05cddcSAtari911} catch (Exception $e) {
8401d05cddcSAtari911    logMessage("FATAL ERROR: " . $e->getMessage(), 'ERROR');
8411d05cddcSAtari911    exit(1);
8421d05cddcSAtari911}
843