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