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