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 42*2866e827SAtari911// Determine meta directory 43*2866e827SAtari911// Parse DokuWiki's local.php for custom metadir/savedir (farm-safe, no include) 44*2866e827SAtari911$metaDir = $dokuwikiRoot . '/data/meta'; 45*2866e827SAtari911$localConf = $dokuwikiRoot . '/conf/local.php'; 46*2866e827SAtari911if (file_exists($localConf)) { 47*2866e827SAtari911 $localContent = file_get_contents($localConf); 48*2866e827SAtari911 // Look for $conf['metadir'] = '...'; 49*2866e827SAtari911 if (preg_match("/\\$conf\['metadir'\]\s*=\s*'([^']+)'/", $localContent, $m)) { 50*2866e827SAtari911 $metaDir = rtrim($m[1], '/'); 51*2866e827SAtari911 } elseif (preg_match("/\\$conf\['savedir'\]\s*=\s*'([^']+)'/", $localContent, $m)) { 52*2866e827SAtari911 $candidateMetaDir = rtrim($m[1], '/') . '/meta'; 53*2866e827SAtari911 if (is_dir($candidateMetaDir)) { 54*2866e827SAtari911 $metaDir = $candidateMetaDir; 55*2866e827SAtari911 } 56*2866e827SAtari911 } 57*2866e827SAtari911} 58*2866e827SAtari911 59*2866e827SAtari911// Load sync configuration 60*2866e827SAtari911// Priority: plugin directory first (original/working location), per-wiki override second 611d05cddcSAtari911$configFile = $scriptDir . '/sync_config.php'; 62*2866e827SAtari911$perWikiConfig = $metaDir . '/calendar/sync_config.php'; 63*2866e827SAtari911// Per-wiki config only takes priority if it exists (explicit opt-in for farm setups) 64*2866e827SAtari911if (file_exists($perWikiConfig)) { 65*2866e827SAtari911 $configFile = $perWikiConfig; 66*2866e827SAtari911} 671d05cddcSAtari911if (!file_exists($configFile)) { 681d05cddcSAtari911 die("ERROR: Configuration file not found: $configFile\n" . 691d05cddcSAtari911 "Please copy sync_config.php and add your credentials.\n"); 701d05cddcSAtari911} 711d05cddcSAtari911 72*2866e827SAtari911// Debug: show which config file is being used 73*2866e827SAtari911if ($verbose) { 74*2866e827SAtari911 echo "[CONFIG] Loading: $configFile\n"; 75*2866e827SAtari911} 76*2866e827SAtari911 771d05cddcSAtari911$config = require $configFile; 781d05cddcSAtari911 791d05cddcSAtari911// Validate configuration 801d05cddcSAtari911if (empty($config['tenant_id']) || strpos($config['tenant_id'], 'YOUR_') !== false) { 811d05cddcSAtari911 die("ERROR: Please configure your Azure credentials in sync_config.php\n"); 821d05cddcSAtari911} 831d05cddcSAtari911 8496df7d3eSAtari911// Files - store in DokuWiki data directory (writable), not plugin directory 85*2866e827SAtari911$dataDir = $metaDir . '/calendar/'; 8696df7d3eSAtari911if (!is_dir($dataDir)) { 8796df7d3eSAtari911 mkdir($dataDir, 0755, true); 8896df7d3eSAtari911} 8996df7d3eSAtari911$stateFile = $dataDir . 'sync_state.json'; 9096df7d3eSAtari911$logFile = $dataDir . 'sync.log'; 911d05cddcSAtari911 921d05cddcSAtari911// Initialize 931d05cddcSAtari911$stats = [ 941d05cddcSAtari911 'scanned' => 0, 951d05cddcSAtari911 'created' => 0, 961d05cddcSAtari911 'updated' => 0, 971d05cddcSAtari911 'deleted' => 0, 981d05cddcSAtari911 'recreated' => 0, 991d05cddcSAtari911 'skipped' => 0, 1001d05cddcSAtari911 'errors' => 0 1011d05cddcSAtari911]; 1021d05cddcSAtari911 1031d05cddcSAtari911// Logging 1041d05cddcSAtari911function logMessage($message, $level = 'INFO') { 1057e8ea635SAtari911 global $logFile, $verbose, $config; 1061d05cddcSAtari911 1077e8ea635SAtari911 // Use timezone from config, fallback to America/Los_Angeles 1087e8ea635SAtari911 $timezone = isset($config['timezone']) ? $config['timezone'] : 'America/Los_Angeles'; 1097e8ea635SAtari911 $tz = new DateTimeZone($timezone); 1101d05cddcSAtari911 $now = new DateTime('now', $tz); 1111d05cddcSAtari911 $timestamp = $now->format('Y-m-d H:i:s'); 1121d05cddcSAtari911 1131d05cddcSAtari911 $logLine = "[$timestamp] [$level] $message\n"; 1141d05cddcSAtari911 1151d05cddcSAtari911 if ($verbose || $level === 'ERROR') { 1161d05cddcSAtari911 echo $logLine; 1171d05cddcSAtari911 } 1181d05cddcSAtari911 1191d05cddcSAtari911 file_put_contents($logFile, $logLine, FILE_APPEND); 1201d05cddcSAtari911} 1211d05cddcSAtari911 1221d05cddcSAtari911logMessage("=== DokuWiki → Outlook Sync Started ==="); 1231d05cddcSAtari911if ($dryRun) logMessage("DRY RUN MODE - No changes will be made"); 1241d05cddcSAtari911if ($filterNamespace) logMessage("Filtering namespace: $filterNamespace"); 1251d05cddcSAtari911if ($reset) logMessage("RESET MODE - Will rebuild sync state from scratch"); 1261d05cddcSAtari911if ($cleanDuplicates) logMessage("CLEAN DUPLICATES MODE - Will remove all duplicate events"); 1271d05cddcSAtari911 1281d05cddcSAtari911// ============================================================================= 1291d05cddcSAtari911// MICROSOFT GRAPH API CLIENT 1301d05cddcSAtari911// ============================================================================= 1311d05cddcSAtari911 1321d05cddcSAtari911class MicrosoftGraphClient { 1331d05cddcSAtari911 private $config; 1341d05cddcSAtari911 private $accessToken = null; 1351d05cddcSAtari911 private $tokenExpiry = 0; 1361d05cddcSAtari911 1371d05cddcSAtari911 public function __construct($config) { 1381d05cddcSAtari911 $this->config = $config; 1391d05cddcSAtari911 } 1401d05cddcSAtari911 1411d05cddcSAtari911 public function getAccessToken() { 1421d05cddcSAtari911 // Check if we have a valid cached token 1431d05cddcSAtari911 if ($this->accessToken && time() < $this->tokenExpiry) { 1441d05cddcSAtari911 return $this->accessToken; 1451d05cddcSAtari911 } 1461d05cddcSAtari911 1471d05cddcSAtari911 // Request new token 1481d05cddcSAtari911 $tokenUrl = "https://login.microsoftonline.com/{$this->config['tenant_id']}/oauth2/v2.0/token"; 1491d05cddcSAtari911 1501d05cddcSAtari911 $data = [ 1511d05cddcSAtari911 'grant_type' => 'client_credentials', 1521d05cddcSAtari911 'client_id' => $this->config['client_id'], 1531d05cddcSAtari911 'client_secret' => $this->config['client_secret'], 1541d05cddcSAtari911 'scope' => 'https://graph.microsoft.com/.default' 1551d05cddcSAtari911 ]; 1561d05cddcSAtari911 1571d05cddcSAtari911 $ch = curl_init($tokenUrl); 1581d05cddcSAtari911 curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); 1591d05cddcSAtari911 curl_setopt($ch, CURLOPT_POST, true); 1601d05cddcSAtari911 curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($data)); 1611d05cddcSAtari911 curl_setopt($ch, CURLOPT_TIMEOUT, $this->config['api_timeout']); 1621d05cddcSAtari911 1631d05cddcSAtari911 $response = curl_exec($ch); 1641d05cddcSAtari911 $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); 1651d05cddcSAtari911 curl_close($ch); 1661d05cddcSAtari911 1671d05cddcSAtari911 if ($httpCode !== 200) { 1681d05cddcSAtari911 throw new Exception("Failed to get access token: HTTP $httpCode - $response"); 1691d05cddcSAtari911 } 1701d05cddcSAtari911 1711d05cddcSAtari911 $result = json_decode($response, true); 1721d05cddcSAtari911 if (!isset($result['access_token'])) { 1731d05cddcSAtari911 throw new Exception("No access token in response: $response"); 1741d05cddcSAtari911 } 1751d05cddcSAtari911 1761d05cddcSAtari911 $this->accessToken = $result['access_token']; 1771d05cddcSAtari911 $this->tokenExpiry = time() + ($result['expires_in'] - 300); // Refresh 5min early 1781d05cddcSAtari911 1791d05cddcSAtari911 return $this->accessToken; 1801d05cddcSAtari911 } 1811d05cddcSAtari911 1821d05cddcSAtari911 public function apiRequest($method, $endpoint, $data = null) { 1831d05cddcSAtari911 $token = $this->getAccessToken(); 1841d05cddcSAtari911 $url = "https://graph.microsoft.com/v1.0" . $endpoint; 1851d05cddcSAtari911 1861d05cddcSAtari911 $ch = curl_init($url); 1871d05cddcSAtari911 curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); 1881d05cddcSAtari911 curl_setopt($ch, CURLOPT_TIMEOUT, $this->config['api_timeout']); 1891d05cddcSAtari911 curl_setopt($ch, CURLOPT_HTTPHEADER, [ 1901d05cddcSAtari911 'Authorization: Bearer ' . $token, 1911d05cddcSAtari911 'Content-Type: application/json', 1921d05cddcSAtari911 'Prefer: outlook.timezone="' . $this->config['timezone'] . '"' 1931d05cddcSAtari911 ]); 1941d05cddcSAtari911 1951d05cddcSAtari911 if ($method !== 'GET') { 1961d05cddcSAtari911 curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method); 1971d05cddcSAtari911 } 1981d05cddcSAtari911 1991d05cddcSAtari911 if ($data !== null) { 2001d05cddcSAtari911 $jsonData = json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); 2011d05cddcSAtari911 if ($jsonData === false) { 2021d05cddcSAtari911 throw new Exception("Failed to encode JSON: " . json_last_error_msg()); 2031d05cddcSAtari911 } 2041d05cddcSAtari911 curl_setopt($ch, CURLOPT_POSTFIELDS, $jsonData); 2051d05cddcSAtari911 } 2061d05cddcSAtari911 2071d05cddcSAtari911 $response = curl_exec($ch); 2081d05cddcSAtari911 $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); 2091d05cddcSAtari911 curl_close($ch); 2101d05cddcSAtari911 2111d05cddcSAtari911 if ($httpCode >= 400) { 2121d05cddcSAtari911 throw new Exception("API request failed: $method $endpoint - HTTP $httpCode - $response"); 2131d05cddcSAtari911 } 2141d05cddcSAtari911 2151d05cddcSAtari911 return json_decode($response, true); 2161d05cddcSAtari911 } 2171d05cddcSAtari911 2181d05cddcSAtari911 public function createEvent($userEmail, $eventData) { 2191d05cddcSAtari911 return $this->apiRequest('POST', "/users/$userEmail/events", $eventData); 2201d05cddcSAtari911 } 2211d05cddcSAtari911 2221d05cddcSAtari911 public function updateEvent($userEmail, $outlookId, $eventData) { 2231d05cddcSAtari911 return $this->apiRequest('PATCH', "/users/$userEmail/events/$outlookId", $eventData); 2241d05cddcSAtari911 } 2251d05cddcSAtari911 2261d05cddcSAtari911 public function deleteEvent($userEmail, $outlookId) { 2271d05cddcSAtari911 return $this->apiRequest('DELETE', "/users/$userEmail/events/$outlookId"); 2281d05cddcSAtari911 } 2291d05cddcSAtari911 2301d05cddcSAtari911 public function getEvent($userEmail, $outlookId) { 2311d05cddcSAtari911 try { 2321d05cddcSAtari911 return $this->apiRequest('GET', "/users/$userEmail/events/$outlookId"); 2331d05cddcSAtari911 } catch (Exception $e) { 2341d05cddcSAtari911 return null; // Event not found 2351d05cddcSAtari911 } 2361d05cddcSAtari911 } 2371d05cddcSAtari911 2381d05cddcSAtari911 public function findEventByDokuWikiId($userEmail, $dokuwikiId) { 2391d05cddcSAtari911 // Search for events with our custom extended property 2401d05cddcSAtari911 $filter = rawurlencode("singleValueExtendedProperties/Any(ep: ep/id eq 'String {66f5a359-4659-4830-9070-00047ec6ac6e} Name DokuWikiId' and ep/value eq '$dokuwikiId')"); 2411d05cddcSAtari911 2421d05cddcSAtari911 try { 2431d05cddcSAtari911 $result = $this->apiRequest('GET', "/users/$userEmail/events?\$filter=$filter&\$select=id,subject"); 2441d05cddcSAtari911 return isset($result['value']) ? $result['value'] : []; 2451d05cddcSAtari911 } catch (Exception $e) { 2461d05cddcSAtari911 logMessage("ERROR searching for event: " . $e->getMessage(), 'ERROR'); 2471d05cddcSAtari911 return []; 2481d05cddcSAtari911 } 2491d05cddcSAtari911 } 2501d05cddcSAtari911 2511d05cddcSAtari911 public function deleteAllDuplicates($userEmail, $dokuwikiId) { 2521d05cddcSAtari911 $events = $this->findEventByDokuWikiId($userEmail, $dokuwikiId); 2531d05cddcSAtari911 2541d05cddcSAtari911 if (count($events) <= 1) { 2551d05cddcSAtari911 return 0; // No duplicates 2561d05cddcSAtari911 } 2571d05cddcSAtari911 2581d05cddcSAtari911 // Keep the first one, delete the rest 2591d05cddcSAtari911 $deleted = 0; 2601d05cddcSAtari911 for ($i = 1; $i < count($events); $i++) { 2611d05cddcSAtari911 try { 2621d05cddcSAtari911 $this->deleteEvent($userEmail, $events[$i]['id']); 2631d05cddcSAtari911 $deleted++; 2641d05cddcSAtari911 logMessage("Deleted duplicate: {$events[$i]['subject']}", 'DEBUG'); 2651d05cddcSAtari911 } catch (Exception $e) { 2661d05cddcSAtari911 logMessage("ERROR deleting duplicate: " . $e->getMessage(), 'ERROR'); 2671d05cddcSAtari911 } 2681d05cddcSAtari911 } 2691d05cddcSAtari911 2701d05cddcSAtari911 return $deleted; 2711d05cddcSAtari911 } 2721d05cddcSAtari911} 2731d05cddcSAtari911 2741d05cddcSAtari911// ============================================================================= 2751d05cddcSAtari911// DOKUWIKI CALENDAR READER 2761d05cddcSAtari911// ============================================================================= 2771d05cddcSAtari911 2781d05cddcSAtari911function loadDokuWikiEvents($dokuwikiRoot, $filterNamespace = null) { 279*2866e827SAtari911 // Use the global $metaDir set at script startup (respects conf overrides) 280*2866e827SAtari911 global $metaDir; 2811d05cddcSAtari911 $allEvents = []; 2821d05cddcSAtari911 2831d05cddcSAtari911 if (!is_dir($metaDir)) { 2841d05cddcSAtari911 logMessage("ERROR: Meta directory not found: $metaDir", 'ERROR'); 2851d05cddcSAtari911 return []; 2861d05cddcSAtari911 } 2871d05cddcSAtari911 2881d05cddcSAtari911 scanCalendarDirs($metaDir, '', $allEvents, $filterNamespace); 2891d05cddcSAtari911 2901d05cddcSAtari911 return $allEvents; 2911d05cddcSAtari911} 2921d05cddcSAtari911 2931d05cddcSAtari911function scanCalendarDirs($dir, $namespace, &$allEvents, $filterNamespace) { 2941d05cddcSAtari911 $items = @scandir($dir); 2951d05cddcSAtari911 if (!$items) return; 2961d05cddcSAtari911 2971d05cddcSAtari911 foreach ($items as $item) { 2981d05cddcSAtari911 if ($item === '.' || $item === '..') continue; 2991d05cddcSAtari911 3001d05cddcSAtari911 $path = $dir . '/' . $item; 3011d05cddcSAtari911 3021d05cddcSAtari911 if (is_dir($path)) { 3031d05cddcSAtari911 if ($item === 'calendar') { 3041d05cddcSAtari911 // Found a calendar directory 3051d05cddcSAtari911 $currentNamespace = trim($namespace, ':'); 3061d05cddcSAtari911 3071d05cddcSAtari911 // Check filter 3081d05cddcSAtari911 if ($filterNamespace !== null && $currentNamespace !== $filterNamespace) { 3091d05cddcSAtari911 continue; 3101d05cddcSAtari911 } 3111d05cddcSAtari911 3121d05cddcSAtari911 logMessage("Scanning calendar: $currentNamespace", 'DEBUG'); 3131d05cddcSAtari911 loadCalendarFiles($path, $currentNamespace, $allEvents); 3141d05cddcSAtari911 } else { 3151d05cddcSAtari911 // Recurse into subdirectory 3161d05cddcSAtari911 $newNamespace = $namespace ? $namespace . ':' . $item : $item; 3171d05cddcSAtari911 scanCalendarDirs($path, $newNamespace, $allEvents, $filterNamespace); 3181d05cddcSAtari911 } 3191d05cddcSAtari911 } 3201d05cddcSAtari911 } 3211d05cddcSAtari911} 3221d05cddcSAtari911 3231d05cddcSAtari911function loadCalendarFiles($calendarDir, $namespace, &$allEvents) { 3241d05cddcSAtari911 global $stats; 3251d05cddcSAtari911 3261d05cddcSAtari911 $files = glob($calendarDir . '/*.json'); 3271d05cddcSAtari911 3281d05cddcSAtari911 foreach ($files as $file) { 3291d05cddcSAtari911 $contents = file_get_contents($file); 3301d05cddcSAtari911 3311d05cddcSAtari911 // Skip empty files 3321d05cddcSAtari911 if (trim($contents) === '' || trim($contents) === '{}' || trim($contents) === '[]') { 3331d05cddcSAtari911 continue; 3341d05cddcSAtari911 } 3351d05cddcSAtari911 3361d05cddcSAtari911 $data = json_decode($contents, true); 3371d05cddcSAtari911 3381d05cddcSAtari911 // Check for JSON errors 3391d05cddcSAtari911 if (json_last_error() !== JSON_ERROR_NONE) { 3401d05cddcSAtari911 logMessage("ERROR: Invalid JSON in $file: " . json_last_error_msg(), 'ERROR'); 3411d05cddcSAtari911 continue; 3421d05cddcSAtari911 } 3431d05cddcSAtari911 3441d05cddcSAtari911 if (!is_array($data)) continue; 3451d05cddcSAtari911 if (empty($data)) continue; 3461d05cddcSAtari911 3471d05cddcSAtari911 // MATCH DOKUWIKI LOGIC: Load everything from the file, no filtering 3481d05cddcSAtari911 foreach ($data as $dateKey => $events) { 3491d05cddcSAtari911 if (!is_array($events)) continue; 3501d05cddcSAtari911 3511d05cddcSAtari911 foreach ($events as $event) { 3521d05cddcSAtari911 if (!isset($event['id'])) continue; 3531d05cddcSAtari911 3541d05cddcSAtari911 $stats['scanned']++; 3551d05cddcSAtari911 3561d05cddcSAtari911 // Get event's namespace field 3571d05cddcSAtari911 $eventNamespace = isset($event['namespace']) ? $event['namespace'] : ''; 3581d05cddcSAtari911 3591d05cddcSAtari911 // Create unique ID based on event's namespace field 3601d05cddcSAtari911 // Empty namespace = root namespace 3611d05cddcSAtari911 if ($eventNamespace === '') { 3621d05cddcSAtari911 $uniqueId = ':' . $event['id']; 3631d05cddcSAtari911 } else { 3641d05cddcSAtari911 $uniqueId = $eventNamespace . ':' . $event['id']; 3651d05cddcSAtari911 } 3661d05cddcSAtari911 3671d05cddcSAtari911 // Store file location for reference 3681d05cddcSAtari911 $event['_fileNamespace'] = $namespace; 3691d05cddcSAtari911 $event['_dateKey'] = $dateKey; 3701d05cddcSAtari911 3711d05cddcSAtari911 // Add to collection - just like DokuWiki does 3721d05cddcSAtari911 $allEvents[$uniqueId] = $event; 3731d05cddcSAtari911 } 3741d05cddcSAtari911 } 3751d05cddcSAtari911 } 3761d05cddcSAtari911} 3771d05cddcSAtari911 3781d05cddcSAtari911// ============================================================================= 3791d05cddcSAtari911// EVENT CONVERSION 3801d05cddcSAtari911// ============================================================================= 3811d05cddcSAtari911 3821d05cddcSAtari911function convertToOutlookEvent($dwEvent, $config) { 3831d05cddcSAtari911 $timezone = $config['timezone']; 3841d05cddcSAtari911 3851d05cddcSAtari911 // Parse date and time 3861d05cddcSAtari911 $dateKey = $dwEvent['_dateKey']; 3871d05cddcSAtari911 $startDate = $dateKey; 3881d05cddcSAtari911 $endDate = isset($dwEvent['endDate']) && $dwEvent['endDate'] ? $dwEvent['endDate'] : $dateKey; 3891d05cddcSAtari911 3901d05cddcSAtari911 // Handle time 3911d05cddcSAtari911 $isAllDay = empty($dwEvent['time']); 3921d05cddcSAtari911 3931d05cddcSAtari911 if ($isAllDay) { 3941d05cddcSAtari911 // All-day events: Use just the date, and end date is next day 3951d05cddcSAtari911 $startDateTime = $startDate; 3961d05cddcSAtari911 3971d05cddcSAtari911 // For all-day events, end date must be the day AFTER the last day 3981d05cddcSAtari911 $endDateObj = new DateTime($endDate); 3991d05cddcSAtari911 $endDateObj->modify('+1 day'); 4001d05cddcSAtari911 $endDateTime = $endDateObj->format('Y-m-d'); 4011d05cddcSAtari911 } else { 4021d05cddcSAtari911 // Timed events: Add time to date 4031d05cddcSAtari911 $startDateTime = $startDate . 'T' . $dwEvent['time'] . ':00'; 4041d05cddcSAtari911 4051d05cddcSAtari911 // End time: if no end date, add 1 hour to start time 4061d05cddcSAtari911 if ($endDate === $dateKey) { 4071d05cddcSAtari911 $dt = new DateTime($startDateTime, new DateTimeZone($timezone)); 4081d05cddcSAtari911 $dt->modify('+1 hour'); 4091d05cddcSAtari911 $endDateTime = $dt->format('Y-m-d\TH:i:s'); 4101d05cddcSAtari911 } else { 4111d05cddcSAtari911 $endDateTime = $endDate . 'T23:59:59'; 4121d05cddcSAtari911 } 4131d05cddcSAtari911 } 4141d05cddcSAtari911 4151d05cddcSAtari911 // Determine category based on namespace FIRST (takes precedence) 4161d05cddcSAtari911 $namespace = isset($dwEvent['namespace']) ? $dwEvent['namespace'] : ''; 4171d05cddcSAtari911 $category = null; 4181d05cddcSAtari911 4191d05cddcSAtari911 // Priority 1: Namespace mapping 4201d05cddcSAtari911 if (!empty($namespace) && isset($config['category_mapping'][$namespace])) { 4211d05cddcSAtari911 $category = $config['category_mapping'][$namespace]; 4221d05cddcSAtari911 } 4231d05cddcSAtari911 4241d05cddcSAtari911 // Priority 2: Color mapping (fallback if no namespace or namespace not mapped) 4251d05cddcSAtari911 if ($category === null && isset($dwEvent['color'])) { 4261d05cddcSAtari911 $colorToCategoryMap = [ 4271d05cddcSAtari911 '#3498db' => 'Blue Category', // Blue 4281d05cddcSAtari911 '#2ecc71' => 'Green Category', // Green 4291d05cddcSAtari911 '#f39c12' => 'Orange Category', // Orange 4301d05cddcSAtari911 '#e74c3c' => 'Red Category', // Red 4311d05cddcSAtari911 '#f1c40f' => 'Yellow Category', // Yellow 4321d05cddcSAtari911 '#9b59b6' => 'Purple Category', // Purple 4331d05cddcSAtari911 ]; 4341d05cddcSAtari911 4351d05cddcSAtari911 $eventColor = strtolower($dwEvent['color']); 4361d05cddcSAtari911 foreach ($colorToCategoryMap as $color => $cat) { 4371d05cddcSAtari911 if (strtolower($color) === $eventColor) { 4381d05cddcSAtari911 $category = $cat; 4391d05cddcSAtari911 break; 4401d05cddcSAtari911 } 4411d05cddcSAtari911 } 4421d05cddcSAtari911 } 4431d05cddcSAtari911 4441d05cddcSAtari911 // Priority 3: Default category 4451d05cddcSAtari911 if ($category === null) { 4461d05cddcSAtari911 $category = $config['default_category']; 4471d05cddcSAtari911 } 4481d05cddcSAtari911 4491d05cddcSAtari911 // Clean and sanitize text fields 4501d05cddcSAtari911 $title = isset($dwEvent['title']) ? trim($dwEvent['title']) : 'Untitled Event'; 4511d05cddcSAtari911 $description = isset($dwEvent['description']) ? trim($dwEvent['description']) : ''; 4521d05cddcSAtari911 4531d05cddcSAtari911 // Remove any null bytes and control characters that can break JSON 4541d05cddcSAtari911 $title = preg_replace('/[\x00-\x1F\x7F]/u', '', $title); 4551d05cddcSAtari911 $description = preg_replace('/[\x00-\x1F\x7F]/u', '', $description); 4561d05cddcSAtari911 4571d05cddcSAtari911 // Ensure proper UTF-8 encoding 4581d05cddcSAtari911 if (!mb_check_encoding($title, 'UTF-8')) { 4591d05cddcSAtari911 $title = mb_convert_encoding($title, 'UTF-8', 'UTF-8'); 4601d05cddcSAtari911 } 4611d05cddcSAtari911 if (!mb_check_encoding($description, 'UTF-8')) { 4621d05cddcSAtari911 $description = mb_convert_encoding($description, 'UTF-8', 'UTF-8'); 4631d05cddcSAtari911 } 4641d05cddcSAtari911 4651d05cddcSAtari911 // Build Outlook event structure 4661d05cddcSAtari911 if ($isAllDay) { 4671d05cddcSAtari911 // All-day events use different format (no time component, no timezone) 4681d05cddcSAtari911 $outlookEvent = [ 4691d05cddcSAtari911 'subject' => $title, 4701d05cddcSAtari911 'body' => [ 4711d05cddcSAtari911 'contentType' => 'text', 4721d05cddcSAtari911 'content' => $description 4731d05cddcSAtari911 ], 4741d05cddcSAtari911 'start' => [ 4751d05cddcSAtari911 'dateTime' => $startDateTime, 4761d05cddcSAtari911 'timeZone' => 'UTC' // All-day events should use UTC 4771d05cddcSAtari911 ], 4781d05cddcSAtari911 'end' => [ 4791d05cddcSAtari911 'dateTime' => $endDateTime, 4801d05cddcSAtari911 'timeZone' => 'UTC' 4811d05cddcSAtari911 ], 4821d05cddcSAtari911 'isAllDay' => true, 4831d05cddcSAtari911 'categories' => [$category], 4841d05cddcSAtari911 'isReminderOn' => false, // All-day events typically don't need reminders 4851d05cddcSAtari911 'singleValueExtendedProperties' => [ 4861d05cddcSAtari911 [ 4871d05cddcSAtari911 'id' => 'String {66f5a359-4659-4830-9070-00047ec6ac6e} Name DokuWikiId', 4881d05cddcSAtari911 'value' => $namespace . ':' . $dwEvent['id'] 4891d05cddcSAtari911 ] 4901d05cddcSAtari911 ] 4911d05cddcSAtari911 ]; 4921d05cddcSAtari911 } else { 4931d05cddcSAtari911 // Timed events 4941d05cddcSAtari911 $outlookEvent = [ 4951d05cddcSAtari911 'subject' => $title, 4961d05cddcSAtari911 'body' => [ 4971d05cddcSAtari911 'contentType' => 'text', 4981d05cddcSAtari911 'content' => $description 4991d05cddcSAtari911 ], 5001d05cddcSAtari911 'start' => [ 5011d05cddcSAtari911 'dateTime' => $startDateTime, 5021d05cddcSAtari911 'timeZone' => $timezone 5031d05cddcSAtari911 ], 5041d05cddcSAtari911 'end' => [ 5051d05cddcSAtari911 'dateTime' => $endDateTime, 5061d05cddcSAtari911 'timeZone' => $timezone 5071d05cddcSAtari911 ], 5081d05cddcSAtari911 'isAllDay' => false, 5091d05cddcSAtari911 'categories' => [$category], 5101d05cddcSAtari911 'isReminderOn' => true, 5111d05cddcSAtari911 'reminderMinutesBeforeStart' => $config['reminder_minutes'], 5121d05cddcSAtari911 'singleValueExtendedProperties' => [ 5131d05cddcSAtari911 [ 5141d05cddcSAtari911 'id' => 'String {66f5a359-4659-4830-9070-00047ec6ac6e} Name DokuWikiId', 5151d05cddcSAtari911 'value' => $namespace . ':' . $dwEvent['id'] 5161d05cddcSAtari911 ] 5171d05cddcSAtari911 ] 5181d05cddcSAtari911 ]; 5191d05cddcSAtari911 } 5201d05cddcSAtari911 5211d05cddcSAtari911 return $outlookEvent; 5221d05cddcSAtari911} 5231d05cddcSAtari911 5241d05cddcSAtari911// ============================================================================= 5259ccd446eSAtari911// SYNC STATE MANAGEMENT (with hash-based change tracking) 5261d05cddcSAtari911// ============================================================================= 5271d05cddcSAtari911 5289ccd446eSAtari911/** 5299ccd446eSAtari911 * Compute a hash of all sync-relevant event fields. 5309ccd446eSAtari911 * If any of these fields change, the event will be re-synced to Outlook. 5319ccd446eSAtari911 */ 5329ccd446eSAtari911function computeEventHash($dwEvent) { 5339ccd446eSAtari911 $fields = [ 5349ccd446eSAtari911 'title' => isset($dwEvent['title']) ? trim($dwEvent['title']) : '', 5359ccd446eSAtari911 'description' => isset($dwEvent['description']) ? trim($dwEvent['description']) : '', 5369ccd446eSAtari911 'time' => isset($dwEvent['time']) ? $dwEvent['time'] : '', 5379ccd446eSAtari911 'endTime' => isset($dwEvent['endTime']) ? $dwEvent['endTime'] : '', 5389ccd446eSAtari911 'endDate' => isset($dwEvent['endDate']) ? $dwEvent['endDate'] : '', 5399ccd446eSAtari911 'color' => isset($dwEvent['color']) ? $dwEvent['color'] : '', 5409ccd446eSAtari911 'namespace' => isset($dwEvent['namespace']) ? $dwEvent['namespace'] : '', 5419ccd446eSAtari911 'isTask' => !empty($dwEvent['isTask']) ? '1' : '0', 5429ccd446eSAtari911 'completed' => !empty($dwEvent['completed']) ? '1' : '0', 5439ccd446eSAtari911 'dateKey' => isset($dwEvent['_dateKey']) ? $dwEvent['_dateKey'] : '', 5449ccd446eSAtari911 ]; 5459ccd446eSAtari911 return md5(json_encode($fields)); 5469ccd446eSAtari911} 5479ccd446eSAtari911 5481d05cddcSAtari911function loadSyncState($stateFile) { 5491d05cddcSAtari911 if (!file_exists($stateFile)) { 5509ccd446eSAtari911 return ['mapping' => [], 'last_sync' => 0, 'version' => 2]; 5511d05cddcSAtari911 } 5521d05cddcSAtari911 5531d05cddcSAtari911 $data = json_decode(file_get_contents($stateFile), true); 5549ccd446eSAtari911 if (!$data) { 5559ccd446eSAtari911 return ['mapping' => [], 'last_sync' => 0, 'version' => 2]; 5569ccd446eSAtari911 } 5579ccd446eSAtari911 5589ccd446eSAtari911 // Migrate v1 state (mapping was dwId => outlookId string) 5599ccd446eSAtari911 // to v2 state (mapping is dwId => {outlookId, hash}) 5609ccd446eSAtari911 if (!isset($data['version']) || $data['version'] < 2) { 5619ccd446eSAtari911 logMessage("Migrating sync state from v1 to v2 (adding hash tracking)..."); 5629ccd446eSAtari911 $newMapping = []; 5639ccd446eSAtari911 foreach ($data['mapping'] as $dwId => $value) { 5649ccd446eSAtari911 if (is_string($value)) { 5659ccd446eSAtari911 // v1 format: dwId => outlookId 5669ccd446eSAtari911 $newMapping[$dwId] = ['outlookId' => $value, 'hash' => '']; 5679ccd446eSAtari911 } else { 5689ccd446eSAtari911 // Already v2 5699ccd446eSAtari911 $newMapping[$dwId] = $value; 5709ccd446eSAtari911 } 5719ccd446eSAtari911 } 5729ccd446eSAtari911 $data['mapping'] = $newMapping; 5739ccd446eSAtari911 $data['version'] = 2; 5749ccd446eSAtari911 logMessage("Migration complete - " . count($newMapping) . " entries migrated (will re-sync all on first run)"); 5759ccd446eSAtari911 } 5769ccd446eSAtari911 5779ccd446eSAtari911 return $data; 5781d05cddcSAtari911} 5791d05cddcSAtari911 5801d05cddcSAtari911function saveSyncState($stateFile, $state) { 5811d05cddcSAtari911 $state['last_sync'] = time(); 5829ccd446eSAtari911 $state['version'] = 2; 5831d05cddcSAtari911 file_put_contents($stateFile, json_encode($state, JSON_PRETTY_PRINT)); 5841d05cddcSAtari911} 5851d05cddcSAtari911 5861d05cddcSAtari911// ============================================================================= 5871d05cddcSAtari911// MAIN SYNC LOGIC 5881d05cddcSAtari911// ============================================================================= 5891d05cddcSAtari911 5901d05cddcSAtari911try { 5911d05cddcSAtari911 // Initialize API client 5921d05cddcSAtari911 $client = new MicrosoftGraphClient($config); 5931d05cddcSAtari911 logMessage("Authenticating with Microsoft Graph API..."); 5941d05cddcSAtari911 $client->getAccessToken(); 5951d05cddcSAtari911 logMessage("Authentication successful"); 5961d05cddcSAtari911 5971d05cddcSAtari911 // Load sync state 5981d05cddcSAtari911 $state = loadSyncState($stateFile); 5999ccd446eSAtari911 $mapping = $state['mapping']; // dwId => {outlookId, hash} 6001d05cddcSAtari911 6011d05cddcSAtari911 // Reset mode - clear the mapping 6021d05cddcSAtari911 if ($reset) { 6031d05cddcSAtari911 logMessage("Resetting sync state..."); 6041d05cddcSAtari911 $mapping = []; 6051d05cddcSAtari911 } 6061d05cddcSAtari911 6071d05cddcSAtari911 // Load DokuWiki events 6081d05cddcSAtari911 logMessage("Loading DokuWiki calendar events..."); 6091d05cddcSAtari911 $dwEvents = loadDokuWikiEvents($dokuwikiRoot, $filterNamespace); 6101d05cddcSAtari911 logMessage("Found " . count($dwEvents) . " events in DokuWiki"); 6111d05cddcSAtari911 6121d05cddcSAtari911 // Clean duplicates mode 6131d05cddcSAtari911 if ($cleanDuplicates) { 6141d05cddcSAtari911 logMessage("=== Cleaning Duplicates ==="); 6151d05cddcSAtari911 $duplicatesFound = 0; 6161d05cddcSAtari911 $duplicatesDeleted = 0; 6171d05cddcSAtari911 6181d05cddcSAtari911 foreach ($dwEvents as $dwId => $dwEvent) { 6191d05cddcSAtari911 $existingEvents = $client->findEventByDokuWikiId($config['user_email'], $dwId); 6201d05cddcSAtari911 6211d05cddcSAtari911 if (count($existingEvents) > 1) { 6221d05cddcSAtari911 $duplicatesFound += count($existingEvents) - 1; 6231d05cddcSAtari911 logMessage("Found " . count($existingEvents) . " copies of: {$dwEvent['title']}"); 6241d05cddcSAtari911 6251d05cddcSAtari911 if (!$dryRun) { 6261d05cddcSAtari911 $deleted = $client->deleteAllDuplicates($config['user_email'], $dwId); 6271d05cddcSAtari911 $duplicatesDeleted += $deleted; 6281d05cddcSAtari911 6291d05cddcSAtari911 // Update mapping with the remaining event 6301d05cddcSAtari911 $remaining = $client->findEventByDokuWikiId($config['user_email'], $dwId); 6311d05cddcSAtari911 if (count($remaining) == 1) { 6329ccd446eSAtari911 $hash = computeEventHash($dwEvent); 6339ccd446eSAtari911 $mapping[$dwId] = ['outlookId' => $remaining[0]['id'], 'hash' => $hash]; 6341d05cddcSAtari911 } 6351d05cddcSAtari911 } 6361d05cddcSAtari911 } 6371d05cddcSAtari911 } 6381d05cddcSAtari911 6391d05cddcSAtari911 logMessage("=== Duplicate Cleanup Complete ==="); 6401d05cddcSAtari911 logMessage("Duplicates found: $duplicatesFound"); 6411d05cddcSAtari911 logMessage("Duplicates deleted: $duplicatesDeleted"); 6421d05cddcSAtari911 6431d05cddcSAtari911 if (!$dryRun) { 6441d05cddcSAtari911 $state['mapping'] = $mapping; 6451d05cddcSAtari911 saveSyncState($stateFile, $state); 6461d05cddcSAtari911 } 6471d05cddcSAtari911 6481d05cddcSAtari911 exit(0); 6491d05cddcSAtari911 } 6501d05cddcSAtari911 6519ccd446eSAtari911 // ========================================================================= 6529ccd446eSAtari911 // DELTA DETECTION - classify events as new, modified, unchanged, or deleted 6539ccd446eSAtari911 // ========================================================================= 6541d05cddcSAtari911 6559ccd446eSAtari911 $newEvents = []; // In DokuWiki but not in mapping 6569ccd446eSAtari911 $modifiedEvents = []; // In both but hash changed 6579ccd446eSAtari911 $unchangedEvents = []; // In both and hash matches 6589ccd446eSAtari911 $deletedIds = []; // In mapping but not in DokuWiki 6599ccd446eSAtari911 6609ccd446eSAtari911 // Classify current DokuWiki events 6611d05cddcSAtari911 foreach ($dwEvents as $dwId => $dwEvent) { 6629ccd446eSAtari911 $currentHash = computeEventHash($dwEvent); 6639ccd446eSAtari911 6649ccd446eSAtari911 if (!isset($mapping[$dwId])) { 6659ccd446eSAtari911 $newEvents[$dwId] = $dwEvent; 6669ccd446eSAtari911 } elseif ($forceSync || $mapping[$dwId]['hash'] !== $currentHash) { 6679ccd446eSAtari911 $modifiedEvents[$dwId] = $dwEvent; 6689ccd446eSAtari911 } else { 6699ccd446eSAtari911 $unchangedEvents[$dwId] = $dwEvent; 6709ccd446eSAtari911 } 6719ccd446eSAtari911 } 6729ccd446eSAtari911 6739ccd446eSAtari911 // Find deleted events (in mapping but no longer in DokuWiki) 6749ccd446eSAtari911 foreach ($mapping as $dwId => $entry) { 6759ccd446eSAtari911 if (!isset($dwEvents[$dwId])) { 6769ccd446eSAtari911 $deletedIds[] = $dwId; 6779ccd446eSAtari911 } 6789ccd446eSAtari911 } 6799ccd446eSAtari911 6809ccd446eSAtari911 logMessage("=== Delta Analysis ==="); 6819ccd446eSAtari911 logMessage(" New: " . count($newEvents)); 6829ccd446eSAtari911 logMessage(" Modified: " . count($modifiedEvents)); 6839ccd446eSAtari911 logMessage(" Unchanged: " . count($unchangedEvents) . " (skipping)"); 6849ccd446eSAtari911 logMessage(" Deleted: " . count($deletedIds)); 6859ccd446eSAtari911 $totalApiCalls = count($newEvents) + count($modifiedEvents) + count($deletedIds); 6869ccd446eSAtari911 logMessage(" API calls: ~$totalApiCalls (vs " . count($dwEvents) . " full sync)"); 6879ccd446eSAtari911 6889ccd446eSAtari911 if ($totalApiCalls === 0) { 6899ccd446eSAtari911 logMessage("Nothing to sync - calendar is up to date!"); 6909ccd446eSAtari911 } 6919ccd446eSAtari911 6929ccd446eSAtari911 // ========================================================================= 6939ccd446eSAtari911 // SYNC NEW EVENTS 6949ccd446eSAtari911 // ========================================================================= 6959ccd446eSAtari911 6969ccd446eSAtari911 foreach ($newEvents as $dwId => $dwEvent) { 6971d05cddcSAtari911 // Check for abort flag 6989ccd446eSAtari911 if (file_exists(__DIR__ . '/.sync_abort')) { 6991d05cddcSAtari911 logMessage("=== SYNC ABORTED BY USER ===", 'WARN'); 7009ccd446eSAtari911 @unlink(__DIR__ . '/.sync_abort'); 7019ccd446eSAtari911 break; 7021d05cddcSAtari911 } 7031d05cddcSAtari911 7041d05cddcSAtari911 // Skip completed tasks if configured 7051d05cddcSAtari911 if (!$config['sync_completed_tasks'] && 7061d05cddcSAtari911 !empty($dwEvent['isTask']) && 7071d05cddcSAtari911 !empty($dwEvent['completed'])) { 7081d05cddcSAtari911 $stats['skipped']++; 7091d05cddcSAtari911 continue; 7101d05cddcSAtari911 } 7111d05cddcSAtari911 7121d05cddcSAtari911 $outlookEvent = convertToOutlookEvent($dwEvent, $config); 7139ccd446eSAtari911 $eventNamespace = isset($dwEvent['namespace']) ? $dwEvent['namespace'] : ''; 7149ccd446eSAtari911 $hash = computeEventHash($dwEvent); 7151d05cddcSAtari911 7161d05cddcSAtari911 try { 7179ccd446eSAtari911 // Check if event already exists in Outlook (unmapped from previous sync) 7181d05cddcSAtari911 $existingEvents = $client->findEventByDokuWikiId($config['user_email'], $dwId); 7191d05cddcSAtari911 7209ccd446eSAtari911 if (count($existingEvents) >= 1) { 7219ccd446eSAtari911 // Already exists - update and map it 7229ccd446eSAtari911 $outlookId = $existingEvents[0]['id']; 7231d05cddcSAtari911 7241d05cddcSAtari911 if (!$dryRun) { 7259ccd446eSAtari911 $client->updateEvent($config['user_email'], $outlookId, $outlookEvent); 7269ccd446eSAtari911 $mapping[$dwId] = ['outlookId' => $outlookId, 'hash' => $hash]; 7271d05cddcSAtari911 7289ccd446eSAtari911 // Clean any duplicates 7299ccd446eSAtari911 if (count($existingEvents) > 1) { 7309ccd446eSAtari911 $client->deleteAllDuplicates($config['user_email'], $dwId); 7319ccd446eSAtari911 logMessage(" Cleaned " . (count($existingEvents) - 1) . " duplicate(s)"); 7329ccd446eSAtari911 } 7339ccd446eSAtari911 } 7349ccd446eSAtari911 $stats['updated']++; 7359ccd446eSAtari911 logMessage("Mapped & updated: {$dwEvent['title']} [$eventNamespace]"); 7369ccd446eSAtari911 } else { 7379ccd446eSAtari911 // Truly new - create in Outlook 7389ccd446eSAtari911 if (!$dryRun) { 7399ccd446eSAtari911 $result = $client->createEvent($config['user_email'], $outlookEvent); 7409ccd446eSAtari911 $mapping[$dwId] = ['outlookId' => $result['id'], 'hash' => $hash]; 7419ccd446eSAtari911 logMessage("Created: {$dwEvent['title']} [$eventNamespace]"); 7429ccd446eSAtari911 } else { 7439ccd446eSAtari911 logMessage("Would create: {$dwEvent['title']} [$eventNamespace]"); 7449ccd446eSAtari911 } 7459ccd446eSAtari911 $stats['created']++; 7469ccd446eSAtari911 } 7479ccd446eSAtari911 } catch (Exception $e) { 7489ccd446eSAtari911 $stats['errors']++; 7499ccd446eSAtari911 logMessage("ERROR creating {$dwEvent['title']}: " . $e->getMessage(), 'ERROR'); 7501d05cddcSAtari911 } 7511d05cddcSAtari911 } 7521d05cddcSAtari911 7539ccd446eSAtari911 // ========================================================================= 7549ccd446eSAtari911 // SYNC MODIFIED EVENTS 7559ccd446eSAtari911 // ========================================================================= 7569ccd446eSAtari911 7579ccd446eSAtari911 foreach ($modifiedEvents as $dwId => $dwEvent) { 7589ccd446eSAtari911 if (file_exists(__DIR__ . '/.sync_abort')) { 7599ccd446eSAtari911 logMessage("=== SYNC ABORTED BY USER ===", 'WARN'); 7609ccd446eSAtari911 @unlink(__DIR__ . '/.sync_abort'); 7619ccd446eSAtari911 break; 7621d05cddcSAtari911 } 7631d05cddcSAtari911 7649ccd446eSAtari911 if (!$config['sync_completed_tasks'] && 7659ccd446eSAtari911 !empty($dwEvent['isTask']) && 7669ccd446eSAtari911 !empty($dwEvent['completed'])) { 7679ccd446eSAtari911 $stats['skipped']++; 7689ccd446eSAtari911 continue; 7699ccd446eSAtari911 } 7701d05cddcSAtari911 7719ccd446eSAtari911 $outlookEvent = convertToOutlookEvent($dwEvent, $config); 7729ccd446eSAtari911 $eventNamespace = isset($dwEvent['namespace']) ? $dwEvent['namespace'] : ''; 7739ccd446eSAtari911 $hash = computeEventHash($dwEvent); 7749ccd446eSAtari911 $outlookId = $mapping[$dwId]['outlookId']; 7759ccd446eSAtari911 7769ccd446eSAtari911 try { 7771d05cddcSAtari911 if (!$dryRun) { 7781d05cddcSAtari911 try { 7791d05cddcSAtari911 $client->updateEvent($config['user_email'], $outlookId, $outlookEvent); 7809ccd446eSAtari911 $mapping[$dwId] = ['outlookId' => $outlookId, 'hash' => $hash]; 7811d05cddcSAtari911 $stats['updated']++; 7821d05cddcSAtari911 logMessage("Updated: {$dwEvent['title']} [$eventNamespace]"); 7831d05cddcSAtari911 } catch (Exception $e) { 7849ccd446eSAtari911 // 404 = event was deleted from Outlook, recreate it 7851d05cddcSAtari911 if (strpos($e->getMessage(), 'HTTP 404') !== false || 7861d05cddcSAtari911 strpos($e->getMessage(), 'ErrorItemNotFound') !== false) { 7871d05cddcSAtari911 7881d05cddcSAtari911 logMessage("Event deleted from Outlook, recreating: {$dwEvent['title']}", 'WARN'); 7891d05cddcSAtari911 $result = $client->createEvent($config['user_email'], $outlookEvent); 7909ccd446eSAtari911 $mapping[$dwId] = ['outlookId' => $result['id'], 'hash' => $hash]; 7911d05cddcSAtari911 $stats['recreated']++; 7929ccd446eSAtari911 logMessage("Recreated: {$dwEvent['title']} [$eventNamespace]"); 7931d05cddcSAtari911 } else { 7941d05cddcSAtari911 throw $e; 7951d05cddcSAtari911 } 7961d05cddcSAtari911 } 7971d05cddcSAtari911 } else { 7981d05cddcSAtari911 $stats['updated']++; 7991d05cddcSAtari911 logMessage("Would update: {$dwEvent['title']} [$eventNamespace]"); 8001d05cddcSAtari911 } 8011d05cddcSAtari911 } catch (Exception $e) { 8021d05cddcSAtari911 $stats['errors']++; 8039ccd446eSAtari911 logMessage("ERROR updating {$dwEvent['title']}: " . $e->getMessage(), 'ERROR'); 8041d05cddcSAtari911 } 8051d05cddcSAtari911 } 8061d05cddcSAtari911 8079ccd446eSAtari911 // ========================================================================= 8089ccd446eSAtari911 // DELETE REMOVED EVENTS 8099ccd446eSAtari911 // ========================================================================= 8101d05cddcSAtari911 8119ccd446eSAtari911 if ($config['delete_outlook_events'] && !empty($deletedIds)) { 8129ccd446eSAtari911 logMessage("=== Deleting " . count($deletedIds) . " removed events ==="); 8139ccd446eSAtari911 8149ccd446eSAtari911 foreach ($deletedIds as $dwId) { 8159ccd446eSAtari911 $outlookId = $mapping[$dwId]['outlookId']; 8169ccd446eSAtari911 8171d05cddcSAtari911 try { 8181d05cddcSAtari911 if (!$dryRun) { 8191d05cddcSAtari911 $client->deleteEvent($config['user_email'], $outlookId); 8209ccd446eSAtari911 logMessage("Deleted: $dwId"); 8211d05cddcSAtari911 } else { 8229ccd446eSAtari911 logMessage("Would delete: $dwId"); 8231d05cddcSAtari911 } 8249ccd446eSAtari911 unset($mapping[$dwId]); 8251d05cddcSAtari911 $stats['deleted']++; 8261d05cddcSAtari911 } catch (Exception $e) { 8271d05cddcSAtari911 if (strpos($e->getMessage(), 'HTTP 404') !== false || 8281d05cddcSAtari911 strpos($e->getMessage(), 'ErrorItemNotFound') !== false) { 8299ccd446eSAtari911 logMessage("Already gone from Outlook: $dwId", 'DEBUG'); 8301d05cddcSAtari911 unset($mapping[$dwId]); 8311d05cddcSAtari911 $stats['deleted']++; 8321d05cddcSAtari911 } else { 8331d05cddcSAtari911 logMessage("ERROR deleting $dwId: " . $e->getMessage(), 'ERROR'); 8349ccd446eSAtari911 $stats['errors']++; 8351d05cddcSAtari911 } 8361d05cddcSAtari911 } 8371d05cddcSAtari911 } 8381d05cddcSAtari911 } 8391d05cddcSAtari911 8409ccd446eSAtari911 // Save state after every sync (checkpoint) 8411d05cddcSAtari911 if (!$dryRun) { 8421d05cddcSAtari911 $state['mapping'] = $mapping; 8431d05cddcSAtari911 saveSyncState($stateFile, $state); 8441d05cddcSAtari911 } 8451d05cddcSAtari911 8469ccd446eSAtari911 // Count unchanged as skipped for stats 8479ccd446eSAtari911 $stats['skipped'] += count($unchangedEvents); 8489ccd446eSAtari911 8491d05cddcSAtari911 // Summary 8501d05cddcSAtari911 logMessage("=== Sync Complete ==="); 8519ccd446eSAtari911 logMessage("New: {$stats['created']}"); 8521d05cddcSAtari911 logMessage("Updated: {$stats['updated']}"); 8539ccd446eSAtari911 logMessage("Recreated: {$stats['recreated']}"); 8541d05cddcSAtari911 logMessage("Deleted: {$stats['deleted']}"); 8559ccd446eSAtari911 logMessage("Unchanged: " . count($unchangedEvents)); 8561d05cddcSAtari911 logMessage("Skipped: {$stats['skipped']}"); 8571d05cddcSAtari911 logMessage("Errors: {$stats['errors']}"); 8581d05cddcSAtari911 8591d05cddcSAtari911 logMessage(""); 8601d05cddcSAtari911 if ($dryRun) { 8611d05cddcSAtari911 logMessage("DRY RUN - No changes were made"); 8621d05cddcSAtari911 } else { 8631d05cddcSAtari911 logMessage("Sync completed successfully!"); 8641d05cddcSAtari911 } 8651d05cddcSAtari911 8661d05cddcSAtari911 exit($stats['errors'] > 0 ? 1 : 0); 8671d05cddcSAtari911 8681d05cddcSAtari911} catch (Exception $e) { 8691d05cddcSAtari911 logMessage("FATAL ERROR: " . $e->getMessage(), 'ERROR'); 8701d05cddcSAtari911 exit(1); 8711d05cddcSAtari911} 872