11d05cddcSAtari911#!/usr/bin/env php 21d05cddcSAtari911<?php 31d05cddcSAtari911/** 4*9ccd446eSAtari911 * DokuWiki Calendar → Outlook Sync (Delta Mode) 51d05cddcSAtari911 * 6*9ccd446eSAtari911 * Syncs calendar events from DokuWiki to Office 365/Outlook via Microsoft Graph API. 7*9ccd446eSAtari911 * Uses hash-based change tracking to only sync new, modified, or deleted events. 8*9ccd446eSAtari911 * Unchanged events are skipped entirely (zero API calls). 91d05cddcSAtari911 * 101d05cddcSAtari911 * Usage: 11*9ccd446eSAtari911 * php sync_outlook.php # Delta sync (only changes) 12*9ccd446eSAtari911 * php sync_outlook.php --dry-run # Show what would change 131d05cddcSAtari911 * php sync_outlook.php --namespace=work # Sync only specific namespace 14*9ccd446eSAtari911 * 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 17*9ccd446eSAtari911 * 18*9ccd446eSAtari911 * First run after upgrade: existing sync_state.json will be auto-migrated 19*9ccd446eSAtari911 * to v2 format with hash tracking. All events will re-sync once to populate hashes. 20*9ccd446eSAtari911 * 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') { 731d05cddcSAtari911 global $logFile, $verbose; 741d05cddcSAtari911 751d05cddcSAtari911 // Set timezone to Los Angeles 761d05cddcSAtari911 $tz = new DateTimeZone('America/Los_Angeles'); 771d05cddcSAtari911 $now = new DateTime('now', $tz); 781d05cddcSAtari911 $timestamp = $now->format('Y-m-d H:i:s'); 791d05cddcSAtari911 801d05cddcSAtari911 $logLine = "[$timestamp] [$level] $message\n"; 811d05cddcSAtari911 821d05cddcSAtari911 if ($verbose || $level === 'ERROR') { 831d05cddcSAtari911 echo $logLine; 841d05cddcSAtari911 } 851d05cddcSAtari911 861d05cddcSAtari911 file_put_contents($logFile, $logLine, FILE_APPEND); 871d05cddcSAtari911} 881d05cddcSAtari911 891d05cddcSAtari911logMessage("=== DokuWiki → Outlook Sync Started ==="); 901d05cddcSAtari911if ($dryRun) logMessage("DRY RUN MODE - No changes will be made"); 911d05cddcSAtari911if ($filterNamespace) logMessage("Filtering namespace: $filterNamespace"); 921d05cddcSAtari911if ($reset) logMessage("RESET MODE - Will rebuild sync state from scratch"); 931d05cddcSAtari911if ($cleanDuplicates) logMessage("CLEAN DUPLICATES MODE - Will remove all duplicate events"); 941d05cddcSAtari911 951d05cddcSAtari911// ============================================================================= 961d05cddcSAtari911// MICROSOFT GRAPH API CLIENT 971d05cddcSAtari911// ============================================================================= 981d05cddcSAtari911 991d05cddcSAtari911class MicrosoftGraphClient { 1001d05cddcSAtari911 private $config; 1011d05cddcSAtari911 private $accessToken = null; 1021d05cddcSAtari911 private $tokenExpiry = 0; 1031d05cddcSAtari911 1041d05cddcSAtari911 public function __construct($config) { 1051d05cddcSAtari911 $this->config = $config; 1061d05cddcSAtari911 } 1071d05cddcSAtari911 1081d05cddcSAtari911 public function getAccessToken() { 1091d05cddcSAtari911 // Check if we have a valid cached token 1101d05cddcSAtari911 if ($this->accessToken && time() < $this->tokenExpiry) { 1111d05cddcSAtari911 return $this->accessToken; 1121d05cddcSAtari911 } 1131d05cddcSAtari911 1141d05cddcSAtari911 // Request new token 1151d05cddcSAtari911 $tokenUrl = "https://login.microsoftonline.com/{$this->config['tenant_id']}/oauth2/v2.0/token"; 1161d05cddcSAtari911 1171d05cddcSAtari911 $data = [ 1181d05cddcSAtari911 'grant_type' => 'client_credentials', 1191d05cddcSAtari911 'client_id' => $this->config['client_id'], 1201d05cddcSAtari911 'client_secret' => $this->config['client_secret'], 1211d05cddcSAtari911 'scope' => 'https://graph.microsoft.com/.default' 1221d05cddcSAtari911 ]; 1231d05cddcSAtari911 1241d05cddcSAtari911 $ch = curl_init($tokenUrl); 1251d05cddcSAtari911 curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); 1261d05cddcSAtari911 curl_setopt($ch, CURLOPT_POST, true); 1271d05cddcSAtari911 curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($data)); 1281d05cddcSAtari911 curl_setopt($ch, CURLOPT_TIMEOUT, $this->config['api_timeout']); 1291d05cddcSAtari911 1301d05cddcSAtari911 $response = curl_exec($ch); 1311d05cddcSAtari911 $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); 1321d05cddcSAtari911 curl_close($ch); 1331d05cddcSAtari911 1341d05cddcSAtari911 if ($httpCode !== 200) { 1351d05cddcSAtari911 throw new Exception("Failed to get access token: HTTP $httpCode - $response"); 1361d05cddcSAtari911 } 1371d05cddcSAtari911 1381d05cddcSAtari911 $result = json_decode($response, true); 1391d05cddcSAtari911 if (!isset($result['access_token'])) { 1401d05cddcSAtari911 throw new Exception("No access token in response: $response"); 1411d05cddcSAtari911 } 1421d05cddcSAtari911 1431d05cddcSAtari911 $this->accessToken = $result['access_token']; 1441d05cddcSAtari911 $this->tokenExpiry = time() + ($result['expires_in'] - 300); // Refresh 5min early 1451d05cddcSAtari911 1461d05cddcSAtari911 return $this->accessToken; 1471d05cddcSAtari911 } 1481d05cddcSAtari911 1491d05cddcSAtari911 public function apiRequest($method, $endpoint, $data = null) { 1501d05cddcSAtari911 $token = $this->getAccessToken(); 1511d05cddcSAtari911 $url = "https://graph.microsoft.com/v1.0" . $endpoint; 1521d05cddcSAtari911 1531d05cddcSAtari911 $ch = curl_init($url); 1541d05cddcSAtari911 curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); 1551d05cddcSAtari911 curl_setopt($ch, CURLOPT_TIMEOUT, $this->config['api_timeout']); 1561d05cddcSAtari911 curl_setopt($ch, CURLOPT_HTTPHEADER, [ 1571d05cddcSAtari911 'Authorization: Bearer ' . $token, 1581d05cddcSAtari911 'Content-Type: application/json', 1591d05cddcSAtari911 'Prefer: outlook.timezone="' . $this->config['timezone'] . '"' 1601d05cddcSAtari911 ]); 1611d05cddcSAtari911 1621d05cddcSAtari911 if ($method !== 'GET') { 1631d05cddcSAtari911 curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method); 1641d05cddcSAtari911 } 1651d05cddcSAtari911 1661d05cddcSAtari911 if ($data !== null) { 1671d05cddcSAtari911 $jsonData = json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); 1681d05cddcSAtari911 if ($jsonData === false) { 1691d05cddcSAtari911 throw new Exception("Failed to encode JSON: " . json_last_error_msg()); 1701d05cddcSAtari911 } 1711d05cddcSAtari911 curl_setopt($ch, CURLOPT_POSTFIELDS, $jsonData); 1721d05cddcSAtari911 } 1731d05cddcSAtari911 1741d05cddcSAtari911 $response = curl_exec($ch); 1751d05cddcSAtari911 $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); 1761d05cddcSAtari911 curl_close($ch); 1771d05cddcSAtari911 1781d05cddcSAtari911 if ($httpCode >= 400) { 1791d05cddcSAtari911 throw new Exception("API request failed: $method $endpoint - HTTP $httpCode - $response"); 1801d05cddcSAtari911 } 1811d05cddcSAtari911 1821d05cddcSAtari911 return json_decode($response, true); 1831d05cddcSAtari911 } 1841d05cddcSAtari911 1851d05cddcSAtari911 public function createEvent($userEmail, $eventData) { 1861d05cddcSAtari911 return $this->apiRequest('POST', "/users/$userEmail/events", $eventData); 1871d05cddcSAtari911 } 1881d05cddcSAtari911 1891d05cddcSAtari911 public function updateEvent($userEmail, $outlookId, $eventData) { 1901d05cddcSAtari911 return $this->apiRequest('PATCH', "/users/$userEmail/events/$outlookId", $eventData); 1911d05cddcSAtari911 } 1921d05cddcSAtari911 1931d05cddcSAtari911 public function deleteEvent($userEmail, $outlookId) { 1941d05cddcSAtari911 return $this->apiRequest('DELETE', "/users/$userEmail/events/$outlookId"); 1951d05cddcSAtari911 } 1961d05cddcSAtari911 1971d05cddcSAtari911 public function getEvent($userEmail, $outlookId) { 1981d05cddcSAtari911 try { 1991d05cddcSAtari911 return $this->apiRequest('GET', "/users/$userEmail/events/$outlookId"); 2001d05cddcSAtari911 } catch (Exception $e) { 2011d05cddcSAtari911 return null; // Event not found 2021d05cddcSAtari911 } 2031d05cddcSAtari911 } 2041d05cddcSAtari911 2051d05cddcSAtari911 public function findEventByDokuWikiId($userEmail, $dokuwikiId) { 2061d05cddcSAtari911 // Search for events with our custom extended property 2071d05cddcSAtari911 $filter = rawurlencode("singleValueExtendedProperties/Any(ep: ep/id eq 'String {66f5a359-4659-4830-9070-00047ec6ac6e} Name DokuWikiId' and ep/value eq '$dokuwikiId')"); 2081d05cddcSAtari911 2091d05cddcSAtari911 try { 2101d05cddcSAtari911 $result = $this->apiRequest('GET', "/users/$userEmail/events?\$filter=$filter&\$select=id,subject"); 2111d05cddcSAtari911 return isset($result['value']) ? $result['value'] : []; 2121d05cddcSAtari911 } catch (Exception $e) { 2131d05cddcSAtari911 logMessage("ERROR searching for event: " . $e->getMessage(), 'ERROR'); 2141d05cddcSAtari911 return []; 2151d05cddcSAtari911 } 2161d05cddcSAtari911 } 2171d05cddcSAtari911 2181d05cddcSAtari911 public function deleteAllDuplicates($userEmail, $dokuwikiId) { 2191d05cddcSAtari911 $events = $this->findEventByDokuWikiId($userEmail, $dokuwikiId); 2201d05cddcSAtari911 2211d05cddcSAtari911 if (count($events) <= 1) { 2221d05cddcSAtari911 return 0; // No duplicates 2231d05cddcSAtari911 } 2241d05cddcSAtari911 2251d05cddcSAtari911 // Keep the first one, delete the rest 2261d05cddcSAtari911 $deleted = 0; 2271d05cddcSAtari911 for ($i = 1; $i < count($events); $i++) { 2281d05cddcSAtari911 try { 2291d05cddcSAtari911 $this->deleteEvent($userEmail, $events[$i]['id']); 2301d05cddcSAtari911 $deleted++; 2311d05cddcSAtari911 logMessage("Deleted duplicate: {$events[$i]['subject']}", 'DEBUG'); 2321d05cddcSAtari911 } catch (Exception $e) { 2331d05cddcSAtari911 logMessage("ERROR deleting duplicate: " . $e->getMessage(), 'ERROR'); 2341d05cddcSAtari911 } 2351d05cddcSAtari911 } 2361d05cddcSAtari911 2371d05cddcSAtari911 return $deleted; 2381d05cddcSAtari911 } 2391d05cddcSAtari911} 2401d05cddcSAtari911 2411d05cddcSAtari911// ============================================================================= 2421d05cddcSAtari911// DOKUWIKI CALENDAR READER 2431d05cddcSAtari911// ============================================================================= 2441d05cddcSAtari911 2451d05cddcSAtari911function loadDokuWikiEvents($dokuwikiRoot, $filterNamespace = null) { 2461d05cddcSAtari911 $metaDir = $dokuwikiRoot . '/data/meta'; 2471d05cddcSAtari911 $allEvents = []; 2481d05cddcSAtari911 2491d05cddcSAtari911 if (!is_dir($metaDir)) { 2501d05cddcSAtari911 logMessage("ERROR: Meta directory not found: $metaDir", 'ERROR'); 2511d05cddcSAtari911 return []; 2521d05cddcSAtari911 } 2531d05cddcSAtari911 2541d05cddcSAtari911 scanCalendarDirs($metaDir, '', $allEvents, $filterNamespace); 2551d05cddcSAtari911 2561d05cddcSAtari911 return $allEvents; 2571d05cddcSAtari911} 2581d05cddcSAtari911 2591d05cddcSAtari911function scanCalendarDirs($dir, $namespace, &$allEvents, $filterNamespace) { 2601d05cddcSAtari911 $items = @scandir($dir); 2611d05cddcSAtari911 if (!$items) return; 2621d05cddcSAtari911 2631d05cddcSAtari911 foreach ($items as $item) { 2641d05cddcSAtari911 if ($item === '.' || $item === '..') continue; 2651d05cddcSAtari911 2661d05cddcSAtari911 $path = $dir . '/' . $item; 2671d05cddcSAtari911 2681d05cddcSAtari911 if (is_dir($path)) { 2691d05cddcSAtari911 if ($item === 'calendar') { 2701d05cddcSAtari911 // Found a calendar directory 2711d05cddcSAtari911 $currentNamespace = trim($namespace, ':'); 2721d05cddcSAtari911 2731d05cddcSAtari911 // Check filter 2741d05cddcSAtari911 if ($filterNamespace !== null && $currentNamespace !== $filterNamespace) { 2751d05cddcSAtari911 continue; 2761d05cddcSAtari911 } 2771d05cddcSAtari911 2781d05cddcSAtari911 logMessage("Scanning calendar: $currentNamespace", 'DEBUG'); 2791d05cddcSAtari911 loadCalendarFiles($path, $currentNamespace, $allEvents); 2801d05cddcSAtari911 } else { 2811d05cddcSAtari911 // Recurse into subdirectory 2821d05cddcSAtari911 $newNamespace = $namespace ? $namespace . ':' . $item : $item; 2831d05cddcSAtari911 scanCalendarDirs($path, $newNamespace, $allEvents, $filterNamespace); 2841d05cddcSAtari911 } 2851d05cddcSAtari911 } 2861d05cddcSAtari911 } 2871d05cddcSAtari911} 2881d05cddcSAtari911 2891d05cddcSAtari911function loadCalendarFiles($calendarDir, $namespace, &$allEvents) { 2901d05cddcSAtari911 global $stats; 2911d05cddcSAtari911 2921d05cddcSAtari911 $files = glob($calendarDir . '/*.json'); 2931d05cddcSAtari911 2941d05cddcSAtari911 foreach ($files as $file) { 2951d05cddcSAtari911 $contents = file_get_contents($file); 2961d05cddcSAtari911 2971d05cddcSAtari911 // Skip empty files 2981d05cddcSAtari911 if (trim($contents) === '' || trim($contents) === '{}' || trim($contents) === '[]') { 2991d05cddcSAtari911 continue; 3001d05cddcSAtari911 } 3011d05cddcSAtari911 3021d05cddcSAtari911 $data = json_decode($contents, true); 3031d05cddcSAtari911 3041d05cddcSAtari911 // Check for JSON errors 3051d05cddcSAtari911 if (json_last_error() !== JSON_ERROR_NONE) { 3061d05cddcSAtari911 logMessage("ERROR: Invalid JSON in $file: " . json_last_error_msg(), 'ERROR'); 3071d05cddcSAtari911 continue; 3081d05cddcSAtari911 } 3091d05cddcSAtari911 3101d05cddcSAtari911 if (!is_array($data)) continue; 3111d05cddcSAtari911 if (empty($data)) continue; 3121d05cddcSAtari911 3131d05cddcSAtari911 // MATCH DOKUWIKI LOGIC: Load everything from the file, no filtering 3141d05cddcSAtari911 foreach ($data as $dateKey => $events) { 3151d05cddcSAtari911 if (!is_array($events)) continue; 3161d05cddcSAtari911 3171d05cddcSAtari911 foreach ($events as $event) { 3181d05cddcSAtari911 if (!isset($event['id'])) continue; 3191d05cddcSAtari911 3201d05cddcSAtari911 $stats['scanned']++; 3211d05cddcSAtari911 3221d05cddcSAtari911 // Get event's namespace field 3231d05cddcSAtari911 $eventNamespace = isset($event['namespace']) ? $event['namespace'] : ''; 3241d05cddcSAtari911 3251d05cddcSAtari911 // Create unique ID based on event's namespace field 3261d05cddcSAtari911 // Empty namespace = root namespace 3271d05cddcSAtari911 if ($eventNamespace === '') { 3281d05cddcSAtari911 $uniqueId = ':' . $event['id']; 3291d05cddcSAtari911 } else { 3301d05cddcSAtari911 $uniqueId = $eventNamespace . ':' . $event['id']; 3311d05cddcSAtari911 } 3321d05cddcSAtari911 3331d05cddcSAtari911 // Store file location for reference 3341d05cddcSAtari911 $event['_fileNamespace'] = $namespace; 3351d05cddcSAtari911 $event['_dateKey'] = $dateKey; 3361d05cddcSAtari911 3371d05cddcSAtari911 // Add to collection - just like DokuWiki does 3381d05cddcSAtari911 $allEvents[$uniqueId] = $event; 3391d05cddcSAtari911 } 3401d05cddcSAtari911 } 3411d05cddcSAtari911 } 3421d05cddcSAtari911} 3431d05cddcSAtari911 3441d05cddcSAtari911// ============================================================================= 3451d05cddcSAtari911// EVENT CONVERSION 3461d05cddcSAtari911// ============================================================================= 3471d05cddcSAtari911 3481d05cddcSAtari911function convertToOutlookEvent($dwEvent, $config) { 3491d05cddcSAtari911 $timezone = $config['timezone']; 3501d05cddcSAtari911 3511d05cddcSAtari911 // Parse date and time 3521d05cddcSAtari911 $dateKey = $dwEvent['_dateKey']; 3531d05cddcSAtari911 $startDate = $dateKey; 3541d05cddcSAtari911 $endDate = isset($dwEvent['endDate']) && $dwEvent['endDate'] ? $dwEvent['endDate'] : $dateKey; 3551d05cddcSAtari911 3561d05cddcSAtari911 // Handle time 3571d05cddcSAtari911 $isAllDay = empty($dwEvent['time']); 3581d05cddcSAtari911 3591d05cddcSAtari911 if ($isAllDay) { 3601d05cddcSAtari911 // All-day events: Use just the date, and end date is next day 3611d05cddcSAtari911 $startDateTime = $startDate; 3621d05cddcSAtari911 3631d05cddcSAtari911 // For all-day events, end date must be the day AFTER the last day 3641d05cddcSAtari911 $endDateObj = new DateTime($endDate); 3651d05cddcSAtari911 $endDateObj->modify('+1 day'); 3661d05cddcSAtari911 $endDateTime = $endDateObj->format('Y-m-d'); 3671d05cddcSAtari911 } else { 3681d05cddcSAtari911 // Timed events: Add time to date 3691d05cddcSAtari911 $startDateTime = $startDate . 'T' . $dwEvent['time'] . ':00'; 3701d05cddcSAtari911 3711d05cddcSAtari911 // End time: if no end date, add 1 hour to start time 3721d05cddcSAtari911 if ($endDate === $dateKey) { 3731d05cddcSAtari911 $dt = new DateTime($startDateTime, new DateTimeZone($timezone)); 3741d05cddcSAtari911 $dt->modify('+1 hour'); 3751d05cddcSAtari911 $endDateTime = $dt->format('Y-m-d\TH:i:s'); 3761d05cddcSAtari911 } else { 3771d05cddcSAtari911 $endDateTime = $endDate . 'T23:59:59'; 3781d05cddcSAtari911 } 3791d05cddcSAtari911 } 3801d05cddcSAtari911 3811d05cddcSAtari911 // Determine category based on namespace FIRST (takes precedence) 3821d05cddcSAtari911 $namespace = isset($dwEvent['namespace']) ? $dwEvent['namespace'] : ''; 3831d05cddcSAtari911 $category = null; 3841d05cddcSAtari911 3851d05cddcSAtari911 // Priority 1: Namespace mapping 3861d05cddcSAtari911 if (!empty($namespace) && isset($config['category_mapping'][$namespace])) { 3871d05cddcSAtari911 $category = $config['category_mapping'][$namespace]; 3881d05cddcSAtari911 } 3891d05cddcSAtari911 3901d05cddcSAtari911 // Priority 2: Color mapping (fallback if no namespace or namespace not mapped) 3911d05cddcSAtari911 if ($category === null && isset($dwEvent['color'])) { 3921d05cddcSAtari911 $colorToCategoryMap = [ 3931d05cddcSAtari911 '#3498db' => 'Blue Category', // Blue 3941d05cddcSAtari911 '#2ecc71' => 'Green Category', // Green 3951d05cddcSAtari911 '#f39c12' => 'Orange Category', // Orange 3961d05cddcSAtari911 '#e74c3c' => 'Red Category', // Red 3971d05cddcSAtari911 '#f1c40f' => 'Yellow Category', // Yellow 3981d05cddcSAtari911 '#9b59b6' => 'Purple Category', // Purple 3991d05cddcSAtari911 ]; 4001d05cddcSAtari911 4011d05cddcSAtari911 $eventColor = strtolower($dwEvent['color']); 4021d05cddcSAtari911 foreach ($colorToCategoryMap as $color => $cat) { 4031d05cddcSAtari911 if (strtolower($color) === $eventColor) { 4041d05cddcSAtari911 $category = $cat; 4051d05cddcSAtari911 break; 4061d05cddcSAtari911 } 4071d05cddcSAtari911 } 4081d05cddcSAtari911 } 4091d05cddcSAtari911 4101d05cddcSAtari911 // Priority 3: Default category 4111d05cddcSAtari911 if ($category === null) { 4121d05cddcSAtari911 $category = $config['default_category']; 4131d05cddcSAtari911 } 4141d05cddcSAtari911 4151d05cddcSAtari911 // Clean and sanitize text fields 4161d05cddcSAtari911 $title = isset($dwEvent['title']) ? trim($dwEvent['title']) : 'Untitled Event'; 4171d05cddcSAtari911 $description = isset($dwEvent['description']) ? trim($dwEvent['description']) : ''; 4181d05cddcSAtari911 4191d05cddcSAtari911 // Remove any null bytes and control characters that can break JSON 4201d05cddcSAtari911 $title = preg_replace('/[\x00-\x1F\x7F]/u', '', $title); 4211d05cddcSAtari911 $description = preg_replace('/[\x00-\x1F\x7F]/u', '', $description); 4221d05cddcSAtari911 4231d05cddcSAtari911 // Ensure proper UTF-8 encoding 4241d05cddcSAtari911 if (!mb_check_encoding($title, 'UTF-8')) { 4251d05cddcSAtari911 $title = mb_convert_encoding($title, 'UTF-8', 'UTF-8'); 4261d05cddcSAtari911 } 4271d05cddcSAtari911 if (!mb_check_encoding($description, 'UTF-8')) { 4281d05cddcSAtari911 $description = mb_convert_encoding($description, 'UTF-8', 'UTF-8'); 4291d05cddcSAtari911 } 4301d05cddcSAtari911 4311d05cddcSAtari911 // Build Outlook event structure 4321d05cddcSAtari911 if ($isAllDay) { 4331d05cddcSAtari911 // All-day events use different format (no time component, no timezone) 4341d05cddcSAtari911 $outlookEvent = [ 4351d05cddcSAtari911 'subject' => $title, 4361d05cddcSAtari911 'body' => [ 4371d05cddcSAtari911 'contentType' => 'text', 4381d05cddcSAtari911 'content' => $description 4391d05cddcSAtari911 ], 4401d05cddcSAtari911 'start' => [ 4411d05cddcSAtari911 'dateTime' => $startDateTime, 4421d05cddcSAtari911 'timeZone' => 'UTC' // All-day events should use UTC 4431d05cddcSAtari911 ], 4441d05cddcSAtari911 'end' => [ 4451d05cddcSAtari911 'dateTime' => $endDateTime, 4461d05cddcSAtari911 'timeZone' => 'UTC' 4471d05cddcSAtari911 ], 4481d05cddcSAtari911 'isAllDay' => true, 4491d05cddcSAtari911 'categories' => [$category], 4501d05cddcSAtari911 'isReminderOn' => false, // All-day events typically don't need reminders 4511d05cddcSAtari911 'singleValueExtendedProperties' => [ 4521d05cddcSAtari911 [ 4531d05cddcSAtari911 'id' => 'String {66f5a359-4659-4830-9070-00047ec6ac6e} Name DokuWikiId', 4541d05cddcSAtari911 'value' => $namespace . ':' . $dwEvent['id'] 4551d05cddcSAtari911 ] 4561d05cddcSAtari911 ] 4571d05cddcSAtari911 ]; 4581d05cddcSAtari911 } else { 4591d05cddcSAtari911 // Timed events 4601d05cddcSAtari911 $outlookEvent = [ 4611d05cddcSAtari911 'subject' => $title, 4621d05cddcSAtari911 'body' => [ 4631d05cddcSAtari911 'contentType' => 'text', 4641d05cddcSAtari911 'content' => $description 4651d05cddcSAtari911 ], 4661d05cddcSAtari911 'start' => [ 4671d05cddcSAtari911 'dateTime' => $startDateTime, 4681d05cddcSAtari911 'timeZone' => $timezone 4691d05cddcSAtari911 ], 4701d05cddcSAtari911 'end' => [ 4711d05cddcSAtari911 'dateTime' => $endDateTime, 4721d05cddcSAtari911 'timeZone' => $timezone 4731d05cddcSAtari911 ], 4741d05cddcSAtari911 'isAllDay' => false, 4751d05cddcSAtari911 'categories' => [$category], 4761d05cddcSAtari911 'isReminderOn' => true, 4771d05cddcSAtari911 'reminderMinutesBeforeStart' => $config['reminder_minutes'], 4781d05cddcSAtari911 'singleValueExtendedProperties' => [ 4791d05cddcSAtari911 [ 4801d05cddcSAtari911 'id' => 'String {66f5a359-4659-4830-9070-00047ec6ac6e} Name DokuWikiId', 4811d05cddcSAtari911 'value' => $namespace . ':' . $dwEvent['id'] 4821d05cddcSAtari911 ] 4831d05cddcSAtari911 ] 4841d05cddcSAtari911 ]; 4851d05cddcSAtari911 } 4861d05cddcSAtari911 4871d05cddcSAtari911 return $outlookEvent; 4881d05cddcSAtari911} 4891d05cddcSAtari911 4901d05cddcSAtari911// ============================================================================= 491*9ccd446eSAtari911// SYNC STATE MANAGEMENT (with hash-based change tracking) 4921d05cddcSAtari911// ============================================================================= 4931d05cddcSAtari911 494*9ccd446eSAtari911/** 495*9ccd446eSAtari911 * Compute a hash of all sync-relevant event fields. 496*9ccd446eSAtari911 * If any of these fields change, the event will be re-synced to Outlook. 497*9ccd446eSAtari911 */ 498*9ccd446eSAtari911function computeEventHash($dwEvent) { 499*9ccd446eSAtari911 $fields = [ 500*9ccd446eSAtari911 'title' => isset($dwEvent['title']) ? trim($dwEvent['title']) : '', 501*9ccd446eSAtari911 'description' => isset($dwEvent['description']) ? trim($dwEvent['description']) : '', 502*9ccd446eSAtari911 'time' => isset($dwEvent['time']) ? $dwEvent['time'] : '', 503*9ccd446eSAtari911 'endTime' => isset($dwEvent['endTime']) ? $dwEvent['endTime'] : '', 504*9ccd446eSAtari911 'endDate' => isset($dwEvent['endDate']) ? $dwEvent['endDate'] : '', 505*9ccd446eSAtari911 'color' => isset($dwEvent['color']) ? $dwEvent['color'] : '', 506*9ccd446eSAtari911 'namespace' => isset($dwEvent['namespace']) ? $dwEvent['namespace'] : '', 507*9ccd446eSAtari911 'isTask' => !empty($dwEvent['isTask']) ? '1' : '0', 508*9ccd446eSAtari911 'completed' => !empty($dwEvent['completed']) ? '1' : '0', 509*9ccd446eSAtari911 'dateKey' => isset($dwEvent['_dateKey']) ? $dwEvent['_dateKey'] : '', 510*9ccd446eSAtari911 ]; 511*9ccd446eSAtari911 return md5(json_encode($fields)); 512*9ccd446eSAtari911} 513*9ccd446eSAtari911 5141d05cddcSAtari911function loadSyncState($stateFile) { 5151d05cddcSAtari911 if (!file_exists($stateFile)) { 516*9ccd446eSAtari911 return ['mapping' => [], 'last_sync' => 0, 'version' => 2]; 5171d05cddcSAtari911 } 5181d05cddcSAtari911 5191d05cddcSAtari911 $data = json_decode(file_get_contents($stateFile), true); 520*9ccd446eSAtari911 if (!$data) { 521*9ccd446eSAtari911 return ['mapping' => [], 'last_sync' => 0, 'version' => 2]; 522*9ccd446eSAtari911 } 523*9ccd446eSAtari911 524*9ccd446eSAtari911 // Migrate v1 state (mapping was dwId => outlookId string) 525*9ccd446eSAtari911 // to v2 state (mapping is dwId => {outlookId, hash}) 526*9ccd446eSAtari911 if (!isset($data['version']) || $data['version'] < 2) { 527*9ccd446eSAtari911 logMessage("Migrating sync state from v1 to v2 (adding hash tracking)..."); 528*9ccd446eSAtari911 $newMapping = []; 529*9ccd446eSAtari911 foreach ($data['mapping'] as $dwId => $value) { 530*9ccd446eSAtari911 if (is_string($value)) { 531*9ccd446eSAtari911 // v1 format: dwId => outlookId 532*9ccd446eSAtari911 $newMapping[$dwId] = ['outlookId' => $value, 'hash' => '']; 533*9ccd446eSAtari911 } else { 534*9ccd446eSAtari911 // Already v2 535*9ccd446eSAtari911 $newMapping[$dwId] = $value; 536*9ccd446eSAtari911 } 537*9ccd446eSAtari911 } 538*9ccd446eSAtari911 $data['mapping'] = $newMapping; 539*9ccd446eSAtari911 $data['version'] = 2; 540*9ccd446eSAtari911 logMessage("Migration complete - " . count($newMapping) . " entries migrated (will re-sync all on first run)"); 541*9ccd446eSAtari911 } 542*9ccd446eSAtari911 543*9ccd446eSAtari911 return $data; 5441d05cddcSAtari911} 5451d05cddcSAtari911 5461d05cddcSAtari911function saveSyncState($stateFile, $state) { 5471d05cddcSAtari911 $state['last_sync'] = time(); 548*9ccd446eSAtari911 $state['version'] = 2; 5491d05cddcSAtari911 file_put_contents($stateFile, json_encode($state, JSON_PRETTY_PRINT)); 5501d05cddcSAtari911} 5511d05cddcSAtari911 5521d05cddcSAtari911// ============================================================================= 5531d05cddcSAtari911// MAIN SYNC LOGIC 5541d05cddcSAtari911// ============================================================================= 5551d05cddcSAtari911 5561d05cddcSAtari911try { 5571d05cddcSAtari911 // Initialize API client 5581d05cddcSAtari911 $client = new MicrosoftGraphClient($config); 5591d05cddcSAtari911 logMessage("Authenticating with Microsoft Graph API..."); 5601d05cddcSAtari911 $client->getAccessToken(); 5611d05cddcSAtari911 logMessage("Authentication successful"); 5621d05cddcSAtari911 5631d05cddcSAtari911 // Load sync state 5641d05cddcSAtari911 $state = loadSyncState($stateFile); 565*9ccd446eSAtari911 $mapping = $state['mapping']; // dwId => {outlookId, hash} 5661d05cddcSAtari911 5671d05cddcSAtari911 // Reset mode - clear the mapping 5681d05cddcSAtari911 if ($reset) { 5691d05cddcSAtari911 logMessage("Resetting sync state..."); 5701d05cddcSAtari911 $mapping = []; 5711d05cddcSAtari911 } 5721d05cddcSAtari911 5731d05cddcSAtari911 // Load DokuWiki events 5741d05cddcSAtari911 logMessage("Loading DokuWiki calendar events..."); 5751d05cddcSAtari911 $dwEvents = loadDokuWikiEvents($dokuwikiRoot, $filterNamespace); 5761d05cddcSAtari911 logMessage("Found " . count($dwEvents) . " events in DokuWiki"); 5771d05cddcSAtari911 5781d05cddcSAtari911 // Clean duplicates mode 5791d05cddcSAtari911 if ($cleanDuplicates) { 5801d05cddcSAtari911 logMessage("=== Cleaning Duplicates ==="); 5811d05cddcSAtari911 $duplicatesFound = 0; 5821d05cddcSAtari911 $duplicatesDeleted = 0; 5831d05cddcSAtari911 5841d05cddcSAtari911 foreach ($dwEvents as $dwId => $dwEvent) { 5851d05cddcSAtari911 $existingEvents = $client->findEventByDokuWikiId($config['user_email'], $dwId); 5861d05cddcSAtari911 5871d05cddcSAtari911 if (count($existingEvents) > 1) { 5881d05cddcSAtari911 $duplicatesFound += count($existingEvents) - 1; 5891d05cddcSAtari911 logMessage("Found " . count($existingEvents) . " copies of: {$dwEvent['title']}"); 5901d05cddcSAtari911 5911d05cddcSAtari911 if (!$dryRun) { 5921d05cddcSAtari911 $deleted = $client->deleteAllDuplicates($config['user_email'], $dwId); 5931d05cddcSAtari911 $duplicatesDeleted += $deleted; 5941d05cddcSAtari911 5951d05cddcSAtari911 // Update mapping with the remaining event 5961d05cddcSAtari911 $remaining = $client->findEventByDokuWikiId($config['user_email'], $dwId); 5971d05cddcSAtari911 if (count($remaining) == 1) { 598*9ccd446eSAtari911 $hash = computeEventHash($dwEvent); 599*9ccd446eSAtari911 $mapping[$dwId] = ['outlookId' => $remaining[0]['id'], 'hash' => $hash]; 6001d05cddcSAtari911 } 6011d05cddcSAtari911 } 6021d05cddcSAtari911 } 6031d05cddcSAtari911 } 6041d05cddcSAtari911 6051d05cddcSAtari911 logMessage("=== Duplicate Cleanup Complete ==="); 6061d05cddcSAtari911 logMessage("Duplicates found: $duplicatesFound"); 6071d05cddcSAtari911 logMessage("Duplicates deleted: $duplicatesDeleted"); 6081d05cddcSAtari911 6091d05cddcSAtari911 if (!$dryRun) { 6101d05cddcSAtari911 $state['mapping'] = $mapping; 6111d05cddcSAtari911 saveSyncState($stateFile, $state); 6121d05cddcSAtari911 } 6131d05cddcSAtari911 6141d05cddcSAtari911 exit(0); 6151d05cddcSAtari911 } 6161d05cddcSAtari911 617*9ccd446eSAtari911 // ========================================================================= 618*9ccd446eSAtari911 // DELTA DETECTION - classify events as new, modified, unchanged, or deleted 619*9ccd446eSAtari911 // ========================================================================= 6201d05cddcSAtari911 621*9ccd446eSAtari911 $newEvents = []; // In DokuWiki but not in mapping 622*9ccd446eSAtari911 $modifiedEvents = []; // In both but hash changed 623*9ccd446eSAtari911 $unchangedEvents = []; // In both and hash matches 624*9ccd446eSAtari911 $deletedIds = []; // In mapping but not in DokuWiki 625*9ccd446eSAtari911 626*9ccd446eSAtari911 // Classify current DokuWiki events 6271d05cddcSAtari911 foreach ($dwEvents as $dwId => $dwEvent) { 628*9ccd446eSAtari911 $currentHash = computeEventHash($dwEvent); 629*9ccd446eSAtari911 630*9ccd446eSAtari911 if (!isset($mapping[$dwId])) { 631*9ccd446eSAtari911 $newEvents[$dwId] = $dwEvent; 632*9ccd446eSAtari911 } elseif ($forceSync || $mapping[$dwId]['hash'] !== $currentHash) { 633*9ccd446eSAtari911 $modifiedEvents[$dwId] = $dwEvent; 634*9ccd446eSAtari911 } else { 635*9ccd446eSAtari911 $unchangedEvents[$dwId] = $dwEvent; 636*9ccd446eSAtari911 } 637*9ccd446eSAtari911 } 638*9ccd446eSAtari911 639*9ccd446eSAtari911 // Find deleted events (in mapping but no longer in DokuWiki) 640*9ccd446eSAtari911 foreach ($mapping as $dwId => $entry) { 641*9ccd446eSAtari911 if (!isset($dwEvents[$dwId])) { 642*9ccd446eSAtari911 $deletedIds[] = $dwId; 643*9ccd446eSAtari911 } 644*9ccd446eSAtari911 } 645*9ccd446eSAtari911 646*9ccd446eSAtari911 logMessage("=== Delta Analysis ==="); 647*9ccd446eSAtari911 logMessage(" New: " . count($newEvents)); 648*9ccd446eSAtari911 logMessage(" Modified: " . count($modifiedEvents)); 649*9ccd446eSAtari911 logMessage(" Unchanged: " . count($unchangedEvents) . " (skipping)"); 650*9ccd446eSAtari911 logMessage(" Deleted: " . count($deletedIds)); 651*9ccd446eSAtari911 $totalApiCalls = count($newEvents) + count($modifiedEvents) + count($deletedIds); 652*9ccd446eSAtari911 logMessage(" API calls: ~$totalApiCalls (vs " . count($dwEvents) . " full sync)"); 653*9ccd446eSAtari911 654*9ccd446eSAtari911 if ($totalApiCalls === 0) { 655*9ccd446eSAtari911 logMessage("Nothing to sync - calendar is up to date!"); 656*9ccd446eSAtari911 } 657*9ccd446eSAtari911 658*9ccd446eSAtari911 // ========================================================================= 659*9ccd446eSAtari911 // SYNC NEW EVENTS 660*9ccd446eSAtari911 // ========================================================================= 661*9ccd446eSAtari911 662*9ccd446eSAtari911 foreach ($newEvents as $dwId => $dwEvent) { 6631d05cddcSAtari911 // Check for abort flag 664*9ccd446eSAtari911 if (file_exists(__DIR__ . '/.sync_abort')) { 6651d05cddcSAtari911 logMessage("=== SYNC ABORTED BY USER ===", 'WARN'); 666*9ccd446eSAtari911 @unlink(__DIR__ . '/.sync_abort'); 667*9ccd446eSAtari911 break; 6681d05cddcSAtari911 } 6691d05cddcSAtari911 6701d05cddcSAtari911 // Skip completed tasks if configured 6711d05cddcSAtari911 if (!$config['sync_completed_tasks'] && 6721d05cddcSAtari911 !empty($dwEvent['isTask']) && 6731d05cddcSAtari911 !empty($dwEvent['completed'])) { 6741d05cddcSAtari911 $stats['skipped']++; 6751d05cddcSAtari911 continue; 6761d05cddcSAtari911 } 6771d05cddcSAtari911 6781d05cddcSAtari911 $outlookEvent = convertToOutlookEvent($dwEvent, $config); 679*9ccd446eSAtari911 $eventNamespace = isset($dwEvent['namespace']) ? $dwEvent['namespace'] : ''; 680*9ccd446eSAtari911 $hash = computeEventHash($dwEvent); 6811d05cddcSAtari911 6821d05cddcSAtari911 try { 683*9ccd446eSAtari911 // Check if event already exists in Outlook (unmapped from previous sync) 6841d05cddcSAtari911 $existingEvents = $client->findEventByDokuWikiId($config['user_email'], $dwId); 6851d05cddcSAtari911 686*9ccd446eSAtari911 if (count($existingEvents) >= 1) { 687*9ccd446eSAtari911 // Already exists - update and map it 688*9ccd446eSAtari911 $outlookId = $existingEvents[0]['id']; 6891d05cddcSAtari911 6901d05cddcSAtari911 if (!$dryRun) { 691*9ccd446eSAtari911 $client->updateEvent($config['user_email'], $outlookId, $outlookEvent); 692*9ccd446eSAtari911 $mapping[$dwId] = ['outlookId' => $outlookId, 'hash' => $hash]; 6931d05cddcSAtari911 694*9ccd446eSAtari911 // Clean any duplicates 695*9ccd446eSAtari911 if (count($existingEvents) > 1) { 696*9ccd446eSAtari911 $client->deleteAllDuplicates($config['user_email'], $dwId); 697*9ccd446eSAtari911 logMessage(" Cleaned " . (count($existingEvents) - 1) . " duplicate(s)"); 698*9ccd446eSAtari911 } 699*9ccd446eSAtari911 } 700*9ccd446eSAtari911 $stats['updated']++; 701*9ccd446eSAtari911 logMessage("Mapped & updated: {$dwEvent['title']} [$eventNamespace]"); 702*9ccd446eSAtari911 } else { 703*9ccd446eSAtari911 // Truly new - create in Outlook 704*9ccd446eSAtari911 if (!$dryRun) { 705*9ccd446eSAtari911 $result = $client->createEvent($config['user_email'], $outlookEvent); 706*9ccd446eSAtari911 $mapping[$dwId] = ['outlookId' => $result['id'], 'hash' => $hash]; 707*9ccd446eSAtari911 logMessage("Created: {$dwEvent['title']} [$eventNamespace]"); 708*9ccd446eSAtari911 } else { 709*9ccd446eSAtari911 logMessage("Would create: {$dwEvent['title']} [$eventNamespace]"); 710*9ccd446eSAtari911 } 711*9ccd446eSAtari911 $stats['created']++; 712*9ccd446eSAtari911 } 713*9ccd446eSAtari911 } catch (Exception $e) { 714*9ccd446eSAtari911 $stats['errors']++; 715*9ccd446eSAtari911 logMessage("ERROR creating {$dwEvent['title']}: " . $e->getMessage(), 'ERROR'); 7161d05cddcSAtari911 } 7171d05cddcSAtari911 } 7181d05cddcSAtari911 719*9ccd446eSAtari911 // ========================================================================= 720*9ccd446eSAtari911 // SYNC MODIFIED EVENTS 721*9ccd446eSAtari911 // ========================================================================= 722*9ccd446eSAtari911 723*9ccd446eSAtari911 foreach ($modifiedEvents as $dwId => $dwEvent) { 724*9ccd446eSAtari911 if (file_exists(__DIR__ . '/.sync_abort')) { 725*9ccd446eSAtari911 logMessage("=== SYNC ABORTED BY USER ===", 'WARN'); 726*9ccd446eSAtari911 @unlink(__DIR__ . '/.sync_abort'); 727*9ccd446eSAtari911 break; 7281d05cddcSAtari911 } 7291d05cddcSAtari911 730*9ccd446eSAtari911 if (!$config['sync_completed_tasks'] && 731*9ccd446eSAtari911 !empty($dwEvent['isTask']) && 732*9ccd446eSAtari911 !empty($dwEvent['completed'])) { 733*9ccd446eSAtari911 $stats['skipped']++; 734*9ccd446eSAtari911 continue; 735*9ccd446eSAtari911 } 7361d05cddcSAtari911 737*9ccd446eSAtari911 $outlookEvent = convertToOutlookEvent($dwEvent, $config); 738*9ccd446eSAtari911 $eventNamespace = isset($dwEvent['namespace']) ? $dwEvent['namespace'] : ''; 739*9ccd446eSAtari911 $hash = computeEventHash($dwEvent); 740*9ccd446eSAtari911 $outlookId = $mapping[$dwId]['outlookId']; 741*9ccd446eSAtari911 742*9ccd446eSAtari911 try { 7431d05cddcSAtari911 if (!$dryRun) { 7441d05cddcSAtari911 try { 7451d05cddcSAtari911 $client->updateEvent($config['user_email'], $outlookId, $outlookEvent); 746*9ccd446eSAtari911 $mapping[$dwId] = ['outlookId' => $outlookId, 'hash' => $hash]; 7471d05cddcSAtari911 $stats['updated']++; 7481d05cddcSAtari911 logMessage("Updated: {$dwEvent['title']} [$eventNamespace]"); 7491d05cddcSAtari911 } catch (Exception $e) { 750*9ccd446eSAtari911 // 404 = event was deleted from Outlook, recreate it 7511d05cddcSAtari911 if (strpos($e->getMessage(), 'HTTP 404') !== false || 7521d05cddcSAtari911 strpos($e->getMessage(), 'ErrorItemNotFound') !== false) { 7531d05cddcSAtari911 7541d05cddcSAtari911 logMessage("Event deleted from Outlook, recreating: {$dwEvent['title']}", 'WARN'); 7551d05cddcSAtari911 $result = $client->createEvent($config['user_email'], $outlookEvent); 756*9ccd446eSAtari911 $mapping[$dwId] = ['outlookId' => $result['id'], 'hash' => $hash]; 7571d05cddcSAtari911 $stats['recreated']++; 758*9ccd446eSAtari911 logMessage("Recreated: {$dwEvent['title']} [$eventNamespace]"); 7591d05cddcSAtari911 } else { 7601d05cddcSAtari911 throw $e; 7611d05cddcSAtari911 } 7621d05cddcSAtari911 } 7631d05cddcSAtari911 } else { 7641d05cddcSAtari911 $stats['updated']++; 7651d05cddcSAtari911 logMessage("Would update: {$dwEvent['title']} [$eventNamespace]"); 7661d05cddcSAtari911 } 7671d05cddcSAtari911 } catch (Exception $e) { 7681d05cddcSAtari911 $stats['errors']++; 769*9ccd446eSAtari911 logMessage("ERROR updating {$dwEvent['title']}: " . $e->getMessage(), 'ERROR'); 7701d05cddcSAtari911 } 7711d05cddcSAtari911 } 7721d05cddcSAtari911 773*9ccd446eSAtari911 // ========================================================================= 774*9ccd446eSAtari911 // DELETE REMOVED EVENTS 775*9ccd446eSAtari911 // ========================================================================= 7761d05cddcSAtari911 777*9ccd446eSAtari911 if ($config['delete_outlook_events'] && !empty($deletedIds)) { 778*9ccd446eSAtari911 logMessage("=== Deleting " . count($deletedIds) . " removed events ==="); 779*9ccd446eSAtari911 780*9ccd446eSAtari911 foreach ($deletedIds as $dwId) { 781*9ccd446eSAtari911 $outlookId = $mapping[$dwId]['outlookId']; 782*9ccd446eSAtari911 7831d05cddcSAtari911 try { 7841d05cddcSAtari911 if (!$dryRun) { 7851d05cddcSAtari911 $client->deleteEvent($config['user_email'], $outlookId); 786*9ccd446eSAtari911 logMessage("Deleted: $dwId"); 7871d05cddcSAtari911 } else { 788*9ccd446eSAtari911 logMessage("Would delete: $dwId"); 7891d05cddcSAtari911 } 790*9ccd446eSAtari911 unset($mapping[$dwId]); 7911d05cddcSAtari911 $stats['deleted']++; 7921d05cddcSAtari911 } catch (Exception $e) { 7931d05cddcSAtari911 if (strpos($e->getMessage(), 'HTTP 404') !== false || 7941d05cddcSAtari911 strpos($e->getMessage(), 'ErrorItemNotFound') !== false) { 795*9ccd446eSAtari911 logMessage("Already gone from Outlook: $dwId", 'DEBUG'); 7961d05cddcSAtari911 unset($mapping[$dwId]); 7971d05cddcSAtari911 $stats['deleted']++; 7981d05cddcSAtari911 } else { 7991d05cddcSAtari911 logMessage("ERROR deleting $dwId: " . $e->getMessage(), 'ERROR'); 800*9ccd446eSAtari911 $stats['errors']++; 8011d05cddcSAtari911 } 8021d05cddcSAtari911 } 8031d05cddcSAtari911 } 8041d05cddcSAtari911 } 8051d05cddcSAtari911 806*9ccd446eSAtari911 // Save state after every sync (checkpoint) 8071d05cddcSAtari911 if (!$dryRun) { 8081d05cddcSAtari911 $state['mapping'] = $mapping; 8091d05cddcSAtari911 saveSyncState($stateFile, $state); 8101d05cddcSAtari911 } 8111d05cddcSAtari911 812*9ccd446eSAtari911 // Count unchanged as skipped for stats 813*9ccd446eSAtari911 $stats['skipped'] += count($unchangedEvents); 814*9ccd446eSAtari911 8151d05cddcSAtari911 // Summary 8161d05cddcSAtari911 logMessage("=== Sync Complete ==="); 817*9ccd446eSAtari911 logMessage("New: {$stats['created']}"); 8181d05cddcSAtari911 logMessage("Updated: {$stats['updated']}"); 819*9ccd446eSAtari911 logMessage("Recreated: {$stats['recreated']}"); 8201d05cddcSAtari911 logMessage("Deleted: {$stats['deleted']}"); 821*9ccd446eSAtari911 logMessage("Unchanged: " . count($unchangedEvents)); 8221d05cddcSAtari911 logMessage("Skipped: {$stats['skipped']}"); 8231d05cddcSAtari911 logMessage("Errors: {$stats['errors']}"); 8241d05cddcSAtari911 8251d05cddcSAtari911 logMessage(""); 8261d05cddcSAtari911 if ($dryRun) { 8271d05cddcSAtari911 logMessage("DRY RUN - No changes were made"); 8281d05cddcSAtari911 } else { 8291d05cddcSAtari911 logMessage("Sync completed successfully!"); 8301d05cddcSAtari911 } 8311d05cddcSAtari911 8321d05cddcSAtari911 exit($stats['errors'] > 0 ? 1 : 0); 8331d05cddcSAtari911 8341d05cddcSAtari911} catch (Exception $e) { 8351d05cddcSAtari911 logMessage("FATAL ERROR: " . $e->getMessage(), 'ERROR'); 8361d05cddcSAtari911 exit(1); 8371d05cddcSAtari911} 838