1*1d05cddcSAtari911#!/usr/bin/env php 2*1d05cddcSAtari911<?php 3*1d05cddcSAtari911/** 4*1d05cddcSAtari911 * DokuWiki Calendar → Outlook Sync 5*1d05cddcSAtari911 * 6*1d05cddcSAtari911 * Syncs calendar events from DokuWiki to Office 365/Outlook via Microsoft Graph API 7*1d05cddcSAtari911 * 8*1d05cddcSAtari911 * Usage: 9*1d05cddcSAtari911 * php sync_outlook.php # Full sync 10*1d05cddcSAtari911 * php sync_outlook.php --dry-run # Show changes without applying 11*1d05cddcSAtari911 * php sync_outlook.php --namespace=work # Sync only specific namespace 12*1d05cddcSAtari911 * php sync_outlook.php --clean-duplicates # Remove duplicate events 13*1d05cddcSAtari911 * php sync_outlook.php --reset # Reset sync state, rebuild from scratch 14*1d05cddcSAtari911 * php sync_outlook.php --force # Force re-sync all events 15*1d05cddcSAtari911 * 16*1d05cddcSAtari911 * Setup: 17*1d05cddcSAtari911 * 1. Edit sync_config.php with your Azure credentials 18*1d05cddcSAtari911 * 2. Run: php sync_outlook.php --dry-run 19*1d05cddcSAtari911 * 3. If looks good: php sync_outlook.php 20*1d05cddcSAtari911 * 4. Add to cron (see documentation for cron syntax) 21*1d05cddcSAtari911 */ 22*1d05cddcSAtari911 23*1d05cddcSAtari911// Parse command line options 24*1d05cddcSAtari911$options = getopt('', ['dry-run', 'namespace:', 'force', 'verbose', 'clean-duplicates', 'reset']); 25*1d05cddcSAtari911$dryRun = isset($options['dry-run']); 26*1d05cddcSAtari911$forceSync = isset($options['force']); 27*1d05cddcSAtari911$verbose = isset($options['verbose']) || $dryRun; 28*1d05cddcSAtari911$cleanDuplicates = isset($options['clean-duplicates']); 29*1d05cddcSAtari911$reset = isset($options['reset']); 30*1d05cddcSAtari911$filterNamespace = isset($options['namespace']) ? $options['namespace'] : null; 31*1d05cddcSAtari911 32*1d05cddcSAtari911// Determine script directory 33*1d05cddcSAtari911$scriptDir = __DIR__; 34*1d05cddcSAtari911$dokuwikiRoot = dirname(dirname(dirname($scriptDir))); // Go up to dokuwiki root 35*1d05cddcSAtari911 36*1d05cddcSAtari911// Load configuration 37*1d05cddcSAtari911$configFile = $scriptDir . '/sync_config.php'; 38*1d05cddcSAtari911if (!file_exists($configFile)) { 39*1d05cddcSAtari911 die("ERROR: Configuration file not found: $configFile\n" . 40*1d05cddcSAtari911 "Please copy sync_config.php and add your credentials.\n"); 41*1d05cddcSAtari911} 42*1d05cddcSAtari911 43*1d05cddcSAtari911$config = require $configFile; 44*1d05cddcSAtari911 45*1d05cddcSAtari911// Validate configuration 46*1d05cddcSAtari911if (empty($config['tenant_id']) || strpos($config['tenant_id'], 'YOUR_') !== false) { 47*1d05cddcSAtari911 die("ERROR: Please configure your Azure credentials in sync_config.php\n"); 48*1d05cddcSAtari911} 49*1d05cddcSAtari911 50*1d05cddcSAtari911// Files 51*1d05cddcSAtari911$stateFile = $scriptDir . '/sync_state.json'; 52*1d05cddcSAtari911$logFile = $scriptDir . '/sync.log'; 53*1d05cddcSAtari911 54*1d05cddcSAtari911// Initialize 55*1d05cddcSAtari911$stats = [ 56*1d05cddcSAtari911 'scanned' => 0, 57*1d05cddcSAtari911 'created' => 0, 58*1d05cddcSAtari911 'updated' => 0, 59*1d05cddcSAtari911 'deleted' => 0, 60*1d05cddcSAtari911 'recreated' => 0, 61*1d05cddcSAtari911 'skipped' => 0, 62*1d05cddcSAtari911 'errors' => 0 63*1d05cddcSAtari911]; 64*1d05cddcSAtari911 65*1d05cddcSAtari911// Logging 66*1d05cddcSAtari911function logMessage($message, $level = 'INFO') { 67*1d05cddcSAtari911 global $logFile, $verbose; 68*1d05cddcSAtari911 69*1d05cddcSAtari911 // Set timezone to Los Angeles 70*1d05cddcSAtari911 $tz = new DateTimeZone('America/Los_Angeles'); 71*1d05cddcSAtari911 $now = new DateTime('now', $tz); 72*1d05cddcSAtari911 $timestamp = $now->format('Y-m-d H:i:s'); 73*1d05cddcSAtari911 74*1d05cddcSAtari911 $logLine = "[$timestamp] [$level] $message\n"; 75*1d05cddcSAtari911 76*1d05cddcSAtari911 if ($verbose || $level === 'ERROR') { 77*1d05cddcSAtari911 echo $logLine; 78*1d05cddcSAtari911 } 79*1d05cddcSAtari911 80*1d05cddcSAtari911 file_put_contents($logFile, $logLine, FILE_APPEND); 81*1d05cddcSAtari911} 82*1d05cddcSAtari911 83*1d05cddcSAtari911logMessage("=== DokuWiki → Outlook Sync Started ==="); 84*1d05cddcSAtari911if ($dryRun) logMessage("DRY RUN MODE - No changes will be made"); 85*1d05cddcSAtari911if ($filterNamespace) logMessage("Filtering namespace: $filterNamespace"); 86*1d05cddcSAtari911if ($reset) logMessage("RESET MODE - Will rebuild sync state from scratch"); 87*1d05cddcSAtari911if ($cleanDuplicates) logMessage("CLEAN DUPLICATES MODE - Will remove all duplicate events"); 88*1d05cddcSAtari911 89*1d05cddcSAtari911// ============================================================================= 90*1d05cddcSAtari911// MICROSOFT GRAPH API CLIENT 91*1d05cddcSAtari911// ============================================================================= 92*1d05cddcSAtari911 93*1d05cddcSAtari911class MicrosoftGraphClient { 94*1d05cddcSAtari911 private $config; 95*1d05cddcSAtari911 private $accessToken = null; 96*1d05cddcSAtari911 private $tokenExpiry = 0; 97*1d05cddcSAtari911 98*1d05cddcSAtari911 public function __construct($config) { 99*1d05cddcSAtari911 $this->config = $config; 100*1d05cddcSAtari911 } 101*1d05cddcSAtari911 102*1d05cddcSAtari911 public function getAccessToken() { 103*1d05cddcSAtari911 // Check if we have a valid cached token 104*1d05cddcSAtari911 if ($this->accessToken && time() < $this->tokenExpiry) { 105*1d05cddcSAtari911 return $this->accessToken; 106*1d05cddcSAtari911 } 107*1d05cddcSAtari911 108*1d05cddcSAtari911 // Request new token 109*1d05cddcSAtari911 $tokenUrl = "https://login.microsoftonline.com/{$this->config['tenant_id']}/oauth2/v2.0/token"; 110*1d05cddcSAtari911 111*1d05cddcSAtari911 $data = [ 112*1d05cddcSAtari911 'grant_type' => 'client_credentials', 113*1d05cddcSAtari911 'client_id' => $this->config['client_id'], 114*1d05cddcSAtari911 'client_secret' => $this->config['client_secret'], 115*1d05cddcSAtari911 'scope' => 'https://graph.microsoft.com/.default' 116*1d05cddcSAtari911 ]; 117*1d05cddcSAtari911 118*1d05cddcSAtari911 $ch = curl_init($tokenUrl); 119*1d05cddcSAtari911 curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); 120*1d05cddcSAtari911 curl_setopt($ch, CURLOPT_POST, true); 121*1d05cddcSAtari911 curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($data)); 122*1d05cddcSAtari911 curl_setopt($ch, CURLOPT_TIMEOUT, $this->config['api_timeout']); 123*1d05cddcSAtari911 124*1d05cddcSAtari911 $response = curl_exec($ch); 125*1d05cddcSAtari911 $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); 126*1d05cddcSAtari911 curl_close($ch); 127*1d05cddcSAtari911 128*1d05cddcSAtari911 if ($httpCode !== 200) { 129*1d05cddcSAtari911 throw new Exception("Failed to get access token: HTTP $httpCode - $response"); 130*1d05cddcSAtari911 } 131*1d05cddcSAtari911 132*1d05cddcSAtari911 $result = json_decode($response, true); 133*1d05cddcSAtari911 if (!isset($result['access_token'])) { 134*1d05cddcSAtari911 throw new Exception("No access token in response: $response"); 135*1d05cddcSAtari911 } 136*1d05cddcSAtari911 137*1d05cddcSAtari911 $this->accessToken = $result['access_token']; 138*1d05cddcSAtari911 $this->tokenExpiry = time() + ($result['expires_in'] - 300); // Refresh 5min early 139*1d05cddcSAtari911 140*1d05cddcSAtari911 return $this->accessToken; 141*1d05cddcSAtari911 } 142*1d05cddcSAtari911 143*1d05cddcSAtari911 public function apiRequest($method, $endpoint, $data = null) { 144*1d05cddcSAtari911 $token = $this->getAccessToken(); 145*1d05cddcSAtari911 $url = "https://graph.microsoft.com/v1.0" . $endpoint; 146*1d05cddcSAtari911 147*1d05cddcSAtari911 $ch = curl_init($url); 148*1d05cddcSAtari911 curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); 149*1d05cddcSAtari911 curl_setopt($ch, CURLOPT_TIMEOUT, $this->config['api_timeout']); 150*1d05cddcSAtari911 curl_setopt($ch, CURLOPT_HTTPHEADER, [ 151*1d05cddcSAtari911 'Authorization: Bearer ' . $token, 152*1d05cddcSAtari911 'Content-Type: application/json', 153*1d05cddcSAtari911 'Prefer: outlook.timezone="' . $this->config['timezone'] . '"' 154*1d05cddcSAtari911 ]); 155*1d05cddcSAtari911 156*1d05cddcSAtari911 if ($method !== 'GET') { 157*1d05cddcSAtari911 curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method); 158*1d05cddcSAtari911 } 159*1d05cddcSAtari911 160*1d05cddcSAtari911 if ($data !== null) { 161*1d05cddcSAtari911 $jsonData = json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); 162*1d05cddcSAtari911 if ($jsonData === false) { 163*1d05cddcSAtari911 throw new Exception("Failed to encode JSON: " . json_last_error_msg()); 164*1d05cddcSAtari911 } 165*1d05cddcSAtari911 curl_setopt($ch, CURLOPT_POSTFIELDS, $jsonData); 166*1d05cddcSAtari911 } 167*1d05cddcSAtari911 168*1d05cddcSAtari911 $response = curl_exec($ch); 169*1d05cddcSAtari911 $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); 170*1d05cddcSAtari911 curl_close($ch); 171*1d05cddcSAtari911 172*1d05cddcSAtari911 if ($httpCode >= 400) { 173*1d05cddcSAtari911 throw new Exception("API request failed: $method $endpoint - HTTP $httpCode - $response"); 174*1d05cddcSAtari911 } 175*1d05cddcSAtari911 176*1d05cddcSAtari911 return json_decode($response, true); 177*1d05cddcSAtari911 } 178*1d05cddcSAtari911 179*1d05cddcSAtari911 public function createEvent($userEmail, $eventData) { 180*1d05cddcSAtari911 return $this->apiRequest('POST', "/users/$userEmail/events", $eventData); 181*1d05cddcSAtari911 } 182*1d05cddcSAtari911 183*1d05cddcSAtari911 public function updateEvent($userEmail, $outlookId, $eventData) { 184*1d05cddcSAtari911 return $this->apiRequest('PATCH', "/users/$userEmail/events/$outlookId", $eventData); 185*1d05cddcSAtari911 } 186*1d05cddcSAtari911 187*1d05cddcSAtari911 public function deleteEvent($userEmail, $outlookId) { 188*1d05cddcSAtari911 return $this->apiRequest('DELETE', "/users/$userEmail/events/$outlookId"); 189*1d05cddcSAtari911 } 190*1d05cddcSAtari911 191*1d05cddcSAtari911 public function getEvent($userEmail, $outlookId) { 192*1d05cddcSAtari911 try { 193*1d05cddcSAtari911 return $this->apiRequest('GET', "/users/$userEmail/events/$outlookId"); 194*1d05cddcSAtari911 } catch (Exception $e) { 195*1d05cddcSAtari911 return null; // Event not found 196*1d05cddcSAtari911 } 197*1d05cddcSAtari911 } 198*1d05cddcSAtari911 199*1d05cddcSAtari911 public function findEventByDokuWikiId($userEmail, $dokuwikiId) { 200*1d05cddcSAtari911 // Search for events with our custom extended property 201*1d05cddcSAtari911 $filter = rawurlencode("singleValueExtendedProperties/Any(ep: ep/id eq 'String {66f5a359-4659-4830-9070-00047ec6ac6e} Name DokuWikiId' and ep/value eq '$dokuwikiId')"); 202*1d05cddcSAtari911 203*1d05cddcSAtari911 try { 204*1d05cddcSAtari911 $result = $this->apiRequest('GET', "/users/$userEmail/events?\$filter=$filter&\$select=id,subject"); 205*1d05cddcSAtari911 return isset($result['value']) ? $result['value'] : []; 206*1d05cddcSAtari911 } catch (Exception $e) { 207*1d05cddcSAtari911 logMessage("ERROR searching for event: " . $e->getMessage(), 'ERROR'); 208*1d05cddcSAtari911 return []; 209*1d05cddcSAtari911 } 210*1d05cddcSAtari911 } 211*1d05cddcSAtari911 212*1d05cddcSAtari911 public function deleteAllDuplicates($userEmail, $dokuwikiId) { 213*1d05cddcSAtari911 $events = $this->findEventByDokuWikiId($userEmail, $dokuwikiId); 214*1d05cddcSAtari911 215*1d05cddcSAtari911 if (count($events) <= 1) { 216*1d05cddcSAtari911 return 0; // No duplicates 217*1d05cddcSAtari911 } 218*1d05cddcSAtari911 219*1d05cddcSAtari911 // Keep the first one, delete the rest 220*1d05cddcSAtari911 $deleted = 0; 221*1d05cddcSAtari911 for ($i = 1; $i < count($events); $i++) { 222*1d05cddcSAtari911 try { 223*1d05cddcSAtari911 $this->deleteEvent($userEmail, $events[$i]['id']); 224*1d05cddcSAtari911 $deleted++; 225*1d05cddcSAtari911 logMessage("Deleted duplicate: {$events[$i]['subject']}", 'DEBUG'); 226*1d05cddcSAtari911 } catch (Exception $e) { 227*1d05cddcSAtari911 logMessage("ERROR deleting duplicate: " . $e->getMessage(), 'ERROR'); 228*1d05cddcSAtari911 } 229*1d05cddcSAtari911 } 230*1d05cddcSAtari911 231*1d05cddcSAtari911 return $deleted; 232*1d05cddcSAtari911 } 233*1d05cddcSAtari911} 234*1d05cddcSAtari911 235*1d05cddcSAtari911// ============================================================================= 236*1d05cddcSAtari911// DOKUWIKI CALENDAR READER 237*1d05cddcSAtari911// ============================================================================= 238*1d05cddcSAtari911 239*1d05cddcSAtari911function loadDokuWikiEvents($dokuwikiRoot, $filterNamespace = null) { 240*1d05cddcSAtari911 $metaDir = $dokuwikiRoot . '/data/meta'; 241*1d05cddcSAtari911 $allEvents = []; 242*1d05cddcSAtari911 243*1d05cddcSAtari911 if (!is_dir($metaDir)) { 244*1d05cddcSAtari911 logMessage("ERROR: Meta directory not found: $metaDir", 'ERROR'); 245*1d05cddcSAtari911 return []; 246*1d05cddcSAtari911 } 247*1d05cddcSAtari911 248*1d05cddcSAtari911 scanCalendarDirs($metaDir, '', $allEvents, $filterNamespace); 249*1d05cddcSAtari911 250*1d05cddcSAtari911 return $allEvents; 251*1d05cddcSAtari911} 252*1d05cddcSAtari911 253*1d05cddcSAtari911function scanCalendarDirs($dir, $namespace, &$allEvents, $filterNamespace) { 254*1d05cddcSAtari911 $items = @scandir($dir); 255*1d05cddcSAtari911 if (!$items) return; 256*1d05cddcSAtari911 257*1d05cddcSAtari911 foreach ($items as $item) { 258*1d05cddcSAtari911 if ($item === '.' || $item === '..') continue; 259*1d05cddcSAtari911 260*1d05cddcSAtari911 $path = $dir . '/' . $item; 261*1d05cddcSAtari911 262*1d05cddcSAtari911 if (is_dir($path)) { 263*1d05cddcSAtari911 if ($item === 'calendar') { 264*1d05cddcSAtari911 // Found a calendar directory 265*1d05cddcSAtari911 $currentNamespace = trim($namespace, ':'); 266*1d05cddcSAtari911 267*1d05cddcSAtari911 // Check filter 268*1d05cddcSAtari911 if ($filterNamespace !== null && $currentNamespace !== $filterNamespace) { 269*1d05cddcSAtari911 continue; 270*1d05cddcSAtari911 } 271*1d05cddcSAtari911 272*1d05cddcSAtari911 logMessage("Scanning calendar: $currentNamespace", 'DEBUG'); 273*1d05cddcSAtari911 loadCalendarFiles($path, $currentNamespace, $allEvents); 274*1d05cddcSAtari911 } else { 275*1d05cddcSAtari911 // Recurse into subdirectory 276*1d05cddcSAtari911 $newNamespace = $namespace ? $namespace . ':' . $item : $item; 277*1d05cddcSAtari911 scanCalendarDirs($path, $newNamespace, $allEvents, $filterNamespace); 278*1d05cddcSAtari911 } 279*1d05cddcSAtari911 } 280*1d05cddcSAtari911 } 281*1d05cddcSAtari911} 282*1d05cddcSAtari911 283*1d05cddcSAtari911function loadCalendarFiles($calendarDir, $namespace, &$allEvents) { 284*1d05cddcSAtari911 global $stats; 285*1d05cddcSAtari911 286*1d05cddcSAtari911 $files = glob($calendarDir . '/*.json'); 287*1d05cddcSAtari911 288*1d05cddcSAtari911 foreach ($files as $file) { 289*1d05cddcSAtari911 $contents = file_get_contents($file); 290*1d05cddcSAtari911 291*1d05cddcSAtari911 // Skip empty files 292*1d05cddcSAtari911 if (trim($contents) === '' || trim($contents) === '{}' || trim($contents) === '[]') { 293*1d05cddcSAtari911 continue; 294*1d05cddcSAtari911 } 295*1d05cddcSAtari911 296*1d05cddcSAtari911 $data = json_decode($contents, true); 297*1d05cddcSAtari911 298*1d05cddcSAtari911 // Check for JSON errors 299*1d05cddcSAtari911 if (json_last_error() !== JSON_ERROR_NONE) { 300*1d05cddcSAtari911 logMessage("ERROR: Invalid JSON in $file: " . json_last_error_msg(), 'ERROR'); 301*1d05cddcSAtari911 continue; 302*1d05cddcSAtari911 } 303*1d05cddcSAtari911 304*1d05cddcSAtari911 if (!is_array($data)) continue; 305*1d05cddcSAtari911 if (empty($data)) continue; 306*1d05cddcSAtari911 307*1d05cddcSAtari911 // MATCH DOKUWIKI LOGIC: Load everything from the file, no filtering 308*1d05cddcSAtari911 foreach ($data as $dateKey => $events) { 309*1d05cddcSAtari911 if (!is_array($events)) continue; 310*1d05cddcSAtari911 311*1d05cddcSAtari911 foreach ($events as $event) { 312*1d05cddcSAtari911 if (!isset($event['id'])) continue; 313*1d05cddcSAtari911 314*1d05cddcSAtari911 $stats['scanned']++; 315*1d05cddcSAtari911 316*1d05cddcSAtari911 // Get event's namespace field 317*1d05cddcSAtari911 $eventNamespace = isset($event['namespace']) ? $event['namespace'] : ''; 318*1d05cddcSAtari911 319*1d05cddcSAtari911 // Create unique ID based on event's namespace field 320*1d05cddcSAtari911 // Empty namespace = root namespace 321*1d05cddcSAtari911 if ($eventNamespace === '') { 322*1d05cddcSAtari911 $uniqueId = ':' . $event['id']; 323*1d05cddcSAtari911 } else { 324*1d05cddcSAtari911 $uniqueId = $eventNamespace . ':' . $event['id']; 325*1d05cddcSAtari911 } 326*1d05cddcSAtari911 327*1d05cddcSAtari911 // Store file location for reference 328*1d05cddcSAtari911 $event['_fileNamespace'] = $namespace; 329*1d05cddcSAtari911 $event['_dateKey'] = $dateKey; 330*1d05cddcSAtari911 331*1d05cddcSAtari911 // Add to collection - just like DokuWiki does 332*1d05cddcSAtari911 $allEvents[$uniqueId] = $event; 333*1d05cddcSAtari911 } 334*1d05cddcSAtari911 } 335*1d05cddcSAtari911 } 336*1d05cddcSAtari911} 337*1d05cddcSAtari911 338*1d05cddcSAtari911// ============================================================================= 339*1d05cddcSAtari911// EVENT CONVERSION 340*1d05cddcSAtari911// ============================================================================= 341*1d05cddcSAtari911 342*1d05cddcSAtari911function convertToOutlookEvent($dwEvent, $config) { 343*1d05cddcSAtari911 $timezone = $config['timezone']; 344*1d05cddcSAtari911 345*1d05cddcSAtari911 // Parse date and time 346*1d05cddcSAtari911 $dateKey = $dwEvent['_dateKey']; 347*1d05cddcSAtari911 $startDate = $dateKey; 348*1d05cddcSAtari911 $endDate = isset($dwEvent['endDate']) && $dwEvent['endDate'] ? $dwEvent['endDate'] : $dateKey; 349*1d05cddcSAtari911 350*1d05cddcSAtari911 // Handle time 351*1d05cddcSAtari911 $isAllDay = empty($dwEvent['time']); 352*1d05cddcSAtari911 353*1d05cddcSAtari911 if ($isAllDay) { 354*1d05cddcSAtari911 // All-day events: Use just the date, and end date is next day 355*1d05cddcSAtari911 $startDateTime = $startDate; 356*1d05cddcSAtari911 357*1d05cddcSAtari911 // For all-day events, end date must be the day AFTER the last day 358*1d05cddcSAtari911 $endDateObj = new DateTime($endDate); 359*1d05cddcSAtari911 $endDateObj->modify('+1 day'); 360*1d05cddcSAtari911 $endDateTime = $endDateObj->format('Y-m-d'); 361*1d05cddcSAtari911 } else { 362*1d05cddcSAtari911 // Timed events: Add time to date 363*1d05cddcSAtari911 $startDateTime = $startDate . 'T' . $dwEvent['time'] . ':00'; 364*1d05cddcSAtari911 365*1d05cddcSAtari911 // End time: if no end date, add 1 hour to start time 366*1d05cddcSAtari911 if ($endDate === $dateKey) { 367*1d05cddcSAtari911 $dt = new DateTime($startDateTime, new DateTimeZone($timezone)); 368*1d05cddcSAtari911 $dt->modify('+1 hour'); 369*1d05cddcSAtari911 $endDateTime = $dt->format('Y-m-d\TH:i:s'); 370*1d05cddcSAtari911 } else { 371*1d05cddcSAtari911 $endDateTime = $endDate . 'T23:59:59'; 372*1d05cddcSAtari911 } 373*1d05cddcSAtari911 } 374*1d05cddcSAtari911 375*1d05cddcSAtari911 // Determine category based on namespace FIRST (takes precedence) 376*1d05cddcSAtari911 $namespace = isset($dwEvent['namespace']) ? $dwEvent['namespace'] : ''; 377*1d05cddcSAtari911 $category = null; 378*1d05cddcSAtari911 379*1d05cddcSAtari911 // Priority 1: Namespace mapping 380*1d05cddcSAtari911 if (!empty($namespace) && isset($config['category_mapping'][$namespace])) { 381*1d05cddcSAtari911 $category = $config['category_mapping'][$namespace]; 382*1d05cddcSAtari911 } 383*1d05cddcSAtari911 384*1d05cddcSAtari911 // Priority 2: Color mapping (fallback if no namespace or namespace not mapped) 385*1d05cddcSAtari911 if ($category === null && isset($dwEvent['color'])) { 386*1d05cddcSAtari911 $colorToCategoryMap = [ 387*1d05cddcSAtari911 '#3498db' => 'Blue Category', // Blue 388*1d05cddcSAtari911 '#2ecc71' => 'Green Category', // Green 389*1d05cddcSAtari911 '#f39c12' => 'Orange Category', // Orange 390*1d05cddcSAtari911 '#e74c3c' => 'Red Category', // Red 391*1d05cddcSAtari911 '#f1c40f' => 'Yellow Category', // Yellow 392*1d05cddcSAtari911 '#9b59b6' => 'Purple Category', // Purple 393*1d05cddcSAtari911 ]; 394*1d05cddcSAtari911 395*1d05cddcSAtari911 $eventColor = strtolower($dwEvent['color']); 396*1d05cddcSAtari911 foreach ($colorToCategoryMap as $color => $cat) { 397*1d05cddcSAtari911 if (strtolower($color) === $eventColor) { 398*1d05cddcSAtari911 $category = $cat; 399*1d05cddcSAtari911 break; 400*1d05cddcSAtari911 } 401*1d05cddcSAtari911 } 402*1d05cddcSAtari911 } 403*1d05cddcSAtari911 404*1d05cddcSAtari911 // Priority 3: Default category 405*1d05cddcSAtari911 if ($category === null) { 406*1d05cddcSAtari911 $category = $config['default_category']; 407*1d05cddcSAtari911 } 408*1d05cddcSAtari911 409*1d05cddcSAtari911 // Clean and sanitize text fields 410*1d05cddcSAtari911 $title = isset($dwEvent['title']) ? trim($dwEvent['title']) : 'Untitled Event'; 411*1d05cddcSAtari911 $description = isset($dwEvent['description']) ? trim($dwEvent['description']) : ''; 412*1d05cddcSAtari911 413*1d05cddcSAtari911 // Remove any null bytes and control characters that can break JSON 414*1d05cddcSAtari911 $title = preg_replace('/[\x00-\x1F\x7F]/u', '', $title); 415*1d05cddcSAtari911 $description = preg_replace('/[\x00-\x1F\x7F]/u', '', $description); 416*1d05cddcSAtari911 417*1d05cddcSAtari911 // Ensure proper UTF-8 encoding 418*1d05cddcSAtari911 if (!mb_check_encoding($title, 'UTF-8')) { 419*1d05cddcSAtari911 $title = mb_convert_encoding($title, 'UTF-8', 'UTF-8'); 420*1d05cddcSAtari911 } 421*1d05cddcSAtari911 if (!mb_check_encoding($description, 'UTF-8')) { 422*1d05cddcSAtari911 $description = mb_convert_encoding($description, 'UTF-8', 'UTF-8'); 423*1d05cddcSAtari911 } 424*1d05cddcSAtari911 425*1d05cddcSAtari911 // Build Outlook event structure 426*1d05cddcSAtari911 if ($isAllDay) { 427*1d05cddcSAtari911 // All-day events use different format (no time component, no timezone) 428*1d05cddcSAtari911 $outlookEvent = [ 429*1d05cddcSAtari911 'subject' => $title, 430*1d05cddcSAtari911 'body' => [ 431*1d05cddcSAtari911 'contentType' => 'text', 432*1d05cddcSAtari911 'content' => $description 433*1d05cddcSAtari911 ], 434*1d05cddcSAtari911 'start' => [ 435*1d05cddcSAtari911 'dateTime' => $startDateTime, 436*1d05cddcSAtari911 'timeZone' => 'UTC' // All-day events should use UTC 437*1d05cddcSAtari911 ], 438*1d05cddcSAtari911 'end' => [ 439*1d05cddcSAtari911 'dateTime' => $endDateTime, 440*1d05cddcSAtari911 'timeZone' => 'UTC' 441*1d05cddcSAtari911 ], 442*1d05cddcSAtari911 'isAllDay' => true, 443*1d05cddcSAtari911 'categories' => [$category], 444*1d05cddcSAtari911 'isReminderOn' => false, // All-day events typically don't need reminders 445*1d05cddcSAtari911 'singleValueExtendedProperties' => [ 446*1d05cddcSAtari911 [ 447*1d05cddcSAtari911 'id' => 'String {66f5a359-4659-4830-9070-00047ec6ac6e} Name DokuWikiId', 448*1d05cddcSAtari911 'value' => $namespace . ':' . $dwEvent['id'] 449*1d05cddcSAtari911 ] 450*1d05cddcSAtari911 ] 451*1d05cddcSAtari911 ]; 452*1d05cddcSAtari911 } else { 453*1d05cddcSAtari911 // Timed events 454*1d05cddcSAtari911 $outlookEvent = [ 455*1d05cddcSAtari911 'subject' => $title, 456*1d05cddcSAtari911 'body' => [ 457*1d05cddcSAtari911 'contentType' => 'text', 458*1d05cddcSAtari911 'content' => $description 459*1d05cddcSAtari911 ], 460*1d05cddcSAtari911 'start' => [ 461*1d05cddcSAtari911 'dateTime' => $startDateTime, 462*1d05cddcSAtari911 'timeZone' => $timezone 463*1d05cddcSAtari911 ], 464*1d05cddcSAtari911 'end' => [ 465*1d05cddcSAtari911 'dateTime' => $endDateTime, 466*1d05cddcSAtari911 'timeZone' => $timezone 467*1d05cddcSAtari911 ], 468*1d05cddcSAtari911 'isAllDay' => false, 469*1d05cddcSAtari911 'categories' => [$category], 470*1d05cddcSAtari911 'isReminderOn' => true, 471*1d05cddcSAtari911 'reminderMinutesBeforeStart' => $config['reminder_minutes'], 472*1d05cddcSAtari911 'singleValueExtendedProperties' => [ 473*1d05cddcSAtari911 [ 474*1d05cddcSAtari911 'id' => 'String {66f5a359-4659-4830-9070-00047ec6ac6e} Name DokuWikiId', 475*1d05cddcSAtari911 'value' => $namespace . ':' . $dwEvent['id'] 476*1d05cddcSAtari911 ] 477*1d05cddcSAtari911 ] 478*1d05cddcSAtari911 ]; 479*1d05cddcSAtari911 } 480*1d05cddcSAtari911 481*1d05cddcSAtari911 return $outlookEvent; 482*1d05cddcSAtari911} 483*1d05cddcSAtari911 484*1d05cddcSAtari911// ============================================================================= 485*1d05cddcSAtari911// SYNC STATE MANAGEMENT 486*1d05cddcSAtari911// ============================================================================= 487*1d05cddcSAtari911 488*1d05cddcSAtari911function loadSyncState($stateFile) { 489*1d05cddcSAtari911 if (!file_exists($stateFile)) { 490*1d05cddcSAtari911 return ['mapping' => [], 'last_sync' => 0]; 491*1d05cddcSAtari911 } 492*1d05cddcSAtari911 493*1d05cddcSAtari911 $data = json_decode(file_get_contents($stateFile), true); 494*1d05cddcSAtari911 return $data ?: ['mapping' => [], 'last_sync' => 0]; 495*1d05cddcSAtari911} 496*1d05cddcSAtari911 497*1d05cddcSAtari911function saveSyncState($stateFile, $state) { 498*1d05cddcSAtari911 $state['last_sync'] = time(); 499*1d05cddcSAtari911 file_put_contents($stateFile, json_encode($state, JSON_PRETTY_PRINT)); 500*1d05cddcSAtari911} 501*1d05cddcSAtari911 502*1d05cddcSAtari911// ============================================================================= 503*1d05cddcSAtari911// MAIN SYNC LOGIC 504*1d05cddcSAtari911// ============================================================================= 505*1d05cddcSAtari911 506*1d05cddcSAtari911try { 507*1d05cddcSAtari911 // Initialize API client 508*1d05cddcSAtari911 $client = new MicrosoftGraphClient($config); 509*1d05cddcSAtari911 logMessage("Authenticating with Microsoft Graph API..."); 510*1d05cddcSAtari911 $client->getAccessToken(); 511*1d05cddcSAtari911 logMessage("Authentication successful"); 512*1d05cddcSAtari911 513*1d05cddcSAtari911 // Load sync state 514*1d05cddcSAtari911 $state = loadSyncState($stateFile); 515*1d05cddcSAtari911 $mapping = $state['mapping']; // dwId => outlookId 516*1d05cddcSAtari911 517*1d05cddcSAtari911 // Reset mode - clear the mapping 518*1d05cddcSAtari911 if ($reset) { 519*1d05cddcSAtari911 logMessage("Resetting sync state..."); 520*1d05cddcSAtari911 $mapping = []; 521*1d05cddcSAtari911 } 522*1d05cddcSAtari911 523*1d05cddcSAtari911 // Load DokuWiki events 524*1d05cddcSAtari911 logMessage("Loading DokuWiki calendar events..."); 525*1d05cddcSAtari911 $dwEvents = loadDokuWikiEvents($dokuwikiRoot, $filterNamespace); 526*1d05cddcSAtari911 logMessage("Found " . count($dwEvents) . " events in DokuWiki"); 527*1d05cddcSAtari911 528*1d05cddcSAtari911 // Clean duplicates mode 529*1d05cddcSAtari911 if ($cleanDuplicates) { 530*1d05cddcSAtari911 logMessage("=== Cleaning Duplicates ==="); 531*1d05cddcSAtari911 $duplicatesFound = 0; 532*1d05cddcSAtari911 $duplicatesDeleted = 0; 533*1d05cddcSAtari911 534*1d05cddcSAtari911 foreach ($dwEvents as $dwId => $dwEvent) { 535*1d05cddcSAtari911 $existingEvents = $client->findEventByDokuWikiId($config['user_email'], $dwId); 536*1d05cddcSAtari911 537*1d05cddcSAtari911 if (count($existingEvents) > 1) { 538*1d05cddcSAtari911 $duplicatesFound += count($existingEvents) - 1; 539*1d05cddcSAtari911 logMessage("Found " . count($existingEvents) . " copies of: {$dwEvent['title']}"); 540*1d05cddcSAtari911 541*1d05cddcSAtari911 if (!$dryRun) { 542*1d05cddcSAtari911 $deleted = $client->deleteAllDuplicates($config['user_email'], $dwId); 543*1d05cddcSAtari911 $duplicatesDeleted += $deleted; 544*1d05cddcSAtari911 545*1d05cddcSAtari911 // Update mapping with the remaining event 546*1d05cddcSAtari911 $remaining = $client->findEventByDokuWikiId($config['user_email'], $dwId); 547*1d05cddcSAtari911 if (count($remaining) == 1) { 548*1d05cddcSAtari911 $mapping[$dwId] = $remaining[0]['id']; 549*1d05cddcSAtari911 } 550*1d05cddcSAtari911 } 551*1d05cddcSAtari911 } 552*1d05cddcSAtari911 } 553*1d05cddcSAtari911 554*1d05cddcSAtari911 logMessage("=== Duplicate Cleanup Complete ==="); 555*1d05cddcSAtari911 logMessage("Duplicates found: $duplicatesFound"); 556*1d05cddcSAtari911 logMessage("Duplicates deleted: $duplicatesDeleted"); 557*1d05cddcSAtari911 558*1d05cddcSAtari911 if (!$dryRun) { 559*1d05cddcSAtari911 $state['mapping'] = $mapping; 560*1d05cddcSAtari911 saveSyncState($stateFile, $state); 561*1d05cddcSAtari911 } 562*1d05cddcSAtari911 563*1d05cddcSAtari911 exit(0); 564*1d05cddcSAtari911 } 565*1d05cddcSAtari911 566*1d05cddcSAtari911 // Track which Outlook events we've seen (to detect deletions) 567*1d05cddcSAtari911 $seenOutlookIds = []; 568*1d05cddcSAtari911 569*1d05cddcSAtari911 // Sync each DokuWiki event 570*1d05cddcSAtari911 foreach ($dwEvents as $dwId => $dwEvent) { 571*1d05cddcSAtari911 // Check for abort flag 572*1d05cddcSAtari911 $abortFile = __DIR__ . '/.sync_abort'; 573*1d05cddcSAtari911 if (file_exists($abortFile)) { 574*1d05cddcSAtari911 logMessage("=== SYNC ABORTED BY USER ===", 'WARN'); 575*1d05cddcSAtari911 logMessage("Partial sync completed. Some events may not be synced.", 'WARN'); 576*1d05cddcSAtari911 @unlink($abortFile); // Clean up abort flag 577*1d05cddcSAtari911 break; // Exit the loop 578*1d05cddcSAtari911 } 579*1d05cddcSAtari911 580*1d05cddcSAtari911 // Skip completed tasks if configured 581*1d05cddcSAtari911 if (!$config['sync_completed_tasks'] && 582*1d05cddcSAtari911 !empty($dwEvent['isTask']) && 583*1d05cddcSAtari911 !empty($dwEvent['completed'])) { 584*1d05cddcSAtari911 $stats['skipped']++; 585*1d05cddcSAtari911 continue; 586*1d05cddcSAtari911 } 587*1d05cddcSAtari911 588*1d05cddcSAtari911 $outlookEvent = convertToOutlookEvent($dwEvent, $config); 589*1d05cddcSAtari911 590*1d05cddcSAtari911 try { 591*1d05cddcSAtari911 // Check if we have this event mapped 592*1d05cddcSAtari911 $outlookId = isset($mapping[$dwId]) ? $mapping[$dwId] : null; 593*1d05cddcSAtari911 594*1d05cddcSAtari911 // If not mapped, search Outlook for it (might exist from previous sync) 595*1d05cddcSAtari911 if (!$outlookId) { 596*1d05cddcSAtari911 $existingEvents = $client->findEventByDokuWikiId($config['user_email'], $dwId); 597*1d05cddcSAtari911 598*1d05cddcSAtari911 if (count($existingEvents) > 1) { 599*1d05cddcSAtari911 // Found duplicates! Clean them up 600*1d05cddcSAtari911 logMessage("WARN: Found " . count($existingEvents) . " duplicates for: {$dwEvent['title']}", 'WARN'); 601*1d05cddcSAtari911 602*1d05cddcSAtari911 if (!$dryRun) { 603*1d05cddcSAtari911 $deleted = $client->deleteAllDuplicates($config['user_email'], $dwId); 604*1d05cddcSAtari911 logMessage("Deleted $deleted duplicate(s)", 'INFO'); 605*1d05cddcSAtari911 606*1d05cddcSAtari911 // Re-search to get the remaining one 607*1d05cddcSAtari911 $existingEvents = $client->findEventByDokuWikiId($config['user_email'], $dwId); 608*1d05cddcSAtari911 } 609*1d05cddcSAtari911 } 610*1d05cddcSAtari911 611*1d05cddcSAtari911 if (count($existingEvents) == 1) { 612*1d05cddcSAtari911 // Found existing event 613*1d05cddcSAtari911 $outlookId = $existingEvents[0]['id']; 614*1d05cddcSAtari911 $mapping[$dwId] = $outlookId; 615*1d05cddcSAtari911 logMessage("Mapped existing event: {$dwEvent['title']} (ID: $outlookId)", 'DEBUG'); 616*1d05cddcSAtari911 } elseif (count($existingEvents) > 1 && !$dryRun) { 617*1d05cddcSAtari911 // Still duplicates after cleanup? Just pick the first one 618*1d05cddcSAtari911 $outlookId = $existingEvents[0]['id']; 619*1d05cddcSAtari911 $mapping[$dwId] = $outlookId; 620*1d05cddcSAtari911 logMessage("WARN: Multiple versions remain, using first: {$dwEvent['title']}", 'WARN'); 621*1d05cddcSAtari911 } 622*1d05cddcSAtari911 } 623*1d05cddcSAtari911 624*1d05cddcSAtari911 if ($outlookId) { 625*1d05cddcSAtari911 // Event exists in mapping - try to update it 626*1d05cddcSAtari911 $seenOutlookIds[] = $outlookId; 627*1d05cddcSAtari911 628*1d05cddcSAtari911 if (!$dryRun) { 629*1d05cddcSAtari911 try { 630*1d05cddcSAtari911 $client->updateEvent($config['user_email'], $outlookId, $outlookEvent); 631*1d05cddcSAtari911 $stats['updated']++; 632*1d05cddcSAtari911 $eventNamespace = isset($dwEvent['namespace']) ? $dwEvent['namespace'] : ''; 633*1d05cddcSAtari911 logMessage("Updated: {$dwEvent['title']} [$eventNamespace]"); 634*1d05cddcSAtari911 } catch (Exception $e) { 635*1d05cddcSAtari911 // Check if it's a 404 (event was deleted from Outlook) 636*1d05cddcSAtari911 if (strpos($e->getMessage(), 'HTTP 404') !== false || 637*1d05cddcSAtari911 strpos($e->getMessage(), 'ErrorItemNotFound') !== false) { 638*1d05cddcSAtari911 639*1d05cddcSAtari911 logMessage("Event deleted from Outlook, recreating: {$dwEvent['title']}", 'WARN'); 640*1d05cddcSAtari911 641*1d05cddcSAtari911 // Remove from mapping and recreate 642*1d05cddcSAtari911 unset($mapping[$dwId]); 643*1d05cddcSAtari911 $result = $client->createEvent($config['user_email'], $outlookEvent); 644*1d05cddcSAtari911 $mapping[$dwId] = $result['id']; 645*1d05cddcSAtari911 $seenOutlookIds[] = $result['id']; 646*1d05cddcSAtari911 $stats['recreated']++; 647*1d05cddcSAtari911 $eventNamespace = isset($dwEvent['namespace']) ? $dwEvent['namespace'] : ''; 648*1d05cddcSAtari911 logMessage("Recreated: {$dwEvent['title']} [$eventNamespace] (new ID: {$result['id']})", 'INFO'); 649*1d05cddcSAtari911 } else { 650*1d05cddcSAtari911 // Different error - rethrow 651*1d05cddcSAtari911 throw $e; 652*1d05cddcSAtari911 } 653*1d05cddcSAtari911 } 654*1d05cddcSAtari911 } else { 655*1d05cddcSAtari911 $stats['updated']++; 656*1d05cddcSAtari911 $eventNamespace = isset($dwEvent['namespace']) ? $dwEvent['namespace'] : ''; 657*1d05cddcSAtari911 logMessage("Would update: {$dwEvent['title']} [$eventNamespace]"); 658*1d05cddcSAtari911 } 659*1d05cddcSAtari911 660*1d05cddcSAtari911 } else { 661*1d05cddcSAtari911 // New event - create in Outlook 662*1d05cddcSAtari911 if (!$dryRun) { 663*1d05cddcSAtari911 $result = $client->createEvent($config['user_email'], $outlookEvent); 664*1d05cddcSAtari911 $mapping[$dwId] = $result['id']; 665*1d05cddcSAtari911 $seenOutlookIds[] = $result['id']; 666*1d05cddcSAtari911 $eventNamespace = isset($dwEvent['namespace']) ? $dwEvent['namespace'] : ''; 667*1d05cddcSAtari911 logMessage("Created: {$dwEvent['title']} [$eventNamespace] (ID: {$result['id']})", 'DEBUG'); 668*1d05cddcSAtari911 } else { 669*1d05cddcSAtari911 $eventNamespace = isset($dwEvent['namespace']) ? $dwEvent['namespace'] : ''; 670*1d05cddcSAtari911 logMessage("Would create: {$dwEvent['title']} [$eventNamespace]"); 671*1d05cddcSAtari911 } 672*1d05cddcSAtari911 $stats['created']++; 673*1d05cddcSAtari911 } 674*1d05cddcSAtari911 675*1d05cddcSAtari911 // Save state periodically (every 50 events) 676*1d05cddcSAtari911 if (!$dryRun && (count($seenOutlookIds) % 50 == 0)) { 677*1d05cddcSAtari911 $state['mapping'] = $mapping; 678*1d05cddcSAtari911 saveSyncState($stateFile, $state); 679*1d05cddcSAtari911 logMessage("State saved (checkpoint)", 'DEBUG'); 680*1d05cddcSAtari911 } 681*1d05cddcSAtari911 682*1d05cddcSAtari911 } catch (Exception $e) { 683*1d05cddcSAtari911 $stats['errors']++; 684*1d05cddcSAtari911 logMessage("ERROR syncing {$dwEvent['title']}: " . $e->getMessage(), 'ERROR'); 685*1d05cddcSAtari911 } 686*1d05cddcSAtari911 } 687*1d05cddcSAtari911 688*1d05cddcSAtari911 // Delete events that were removed from DokuWiki 689*1d05cddcSAtari911 if ($config['delete_outlook_events']) { 690*1d05cddcSAtari911 logMessage("=== Checking for deleted events ===", 'DEBUG'); 691*1d05cddcSAtari911 logMessage("Total in mapping: " . count($mapping), 'DEBUG'); 692*1d05cddcSAtari911 logMessage("Total seen (should exist): " . count($seenOutlookIds), 'DEBUG'); 693*1d05cddcSAtari911 694*1d05cddcSAtari911 $deletedCount = 0; 695*1d05cddcSAtari911 foreach ($mapping as $dwId => $outlookId) { 696*1d05cddcSAtari911 if (!in_array($outlookId, $seenOutlookIds)) { 697*1d05cddcSAtari911 // Event was deleted in DokuWiki 698*1d05cddcSAtari911 logMessage("Event $dwId not in DokuWiki anymore (Outlook ID: $outlookId)", 'DEBUG'); 699*1d05cddcSAtari911 try { 700*1d05cddcSAtari911 if (!$dryRun) { 701*1d05cddcSAtari911 $client->deleteEvent($config['user_email'], $outlookId); 702*1d05cddcSAtari911 unset($mapping[$dwId]); 703*1d05cddcSAtari911 logMessage("Deleted from Outlook: $dwId", 'INFO'); 704*1d05cddcSAtari911 } else { 705*1d05cddcSAtari911 logMessage("Would delete: $dwId", 'INFO'); 706*1d05cddcSAtari911 } 707*1d05cddcSAtari911 $stats['deleted']++; 708*1d05cddcSAtari911 $deletedCount++; 709*1d05cddcSAtari911 } catch (Exception $e) { 710*1d05cddcSAtari911 // If it's already gone (404), just remove from mapping 711*1d05cddcSAtari911 if (strpos($e->getMessage(), 'HTTP 404') !== false || 712*1d05cddcSAtari911 strpos($e->getMessage(), 'ErrorItemNotFound') !== false) { 713*1d05cddcSAtari911 logMessage("Event $dwId already gone from Outlook, removing from mapping", 'DEBUG'); 714*1d05cddcSAtari911 unset($mapping[$dwId]); 715*1d05cddcSAtari911 $stats['deleted']++; 716*1d05cddcSAtari911 $deletedCount++; 717*1d05cddcSAtari911 } else { 718*1d05cddcSAtari911 logMessage("ERROR deleting $dwId: " . $e->getMessage(), 'ERROR'); 719*1d05cddcSAtari911 } 720*1d05cddcSAtari911 } 721*1d05cddcSAtari911 } 722*1d05cddcSAtari911 } 723*1d05cddcSAtari911 logMessage("Deleted $deletedCount events from Outlook", 'DEBUG'); 724*1d05cddcSAtari911 } 725*1d05cddcSAtari911 726*1d05cddcSAtari911 // Save state 727*1d05cddcSAtari911 if (!$dryRun) { 728*1d05cddcSAtari911 $state['mapping'] = $mapping; 729*1d05cddcSAtari911 saveSyncState($stateFile, $state); 730*1d05cddcSAtari911 } 731*1d05cddcSAtari911 732*1d05cddcSAtari911 // Summary 733*1d05cddcSAtari911 logMessage("=== Sync Complete ==="); 734*1d05cddcSAtari911 logMessage("Scanned: {$stats['scanned']} events"); 735*1d05cddcSAtari911 logMessage("Created: {$stats['created']}"); 736*1d05cddcSAtari911 logMessage("Updated: {$stats['updated']}"); 737*1d05cddcSAtari911 logMessage("Recreated: {$stats['recreated']} (deleted from Outlook)"); 738*1d05cddcSAtari911 logMessage("Deleted: {$stats['deleted']}"); 739*1d05cddcSAtari911 logMessage("Skipped: {$stats['skipped']}"); 740*1d05cddcSAtari911 logMessage("Errors: {$stats['errors']}"); 741*1d05cddcSAtari911 742*1d05cddcSAtari911 // ============================================================================= 743*1d05cddcSAtari911 // FINAL STEP: Check for and remove any duplicates in Outlook 744*1d05cddcSAtari911 // ============================================================================= 745*1d05cddcSAtari911 746*1d05cddcSAtari911 if (!$dryRun) { 747*1d05cddcSAtari911 logMessage(""); 748*1d05cddcSAtari911 logMessage("=== Final Duplicate Check ==="); 749*1d05cddcSAtari911 750*1d05cddcSAtari911 $duplicatesFound = 0; 751*1d05cddcSAtari911 $duplicatesRemoved = 0; 752*1d05cddcSAtari911 753*1d05cddcSAtari911 // Check each event we synced for duplicates 754*1d05cddcSAtari911 foreach ($dwEvents as $dwId => $dwEvent) { 755*1d05cddcSAtari911 try { 756*1d05cddcSAtari911 // Search Outlook for this DokuWiki ID 757*1d05cddcSAtari911 $outlookEvents = $client->findEventByDokuWikiId($config['user_email'], $dwId); 758*1d05cddcSAtari911 759*1d05cddcSAtari911 if (count($outlookEvents) > 1) { 760*1d05cddcSAtari911 $duplicatesFound += count($outlookEvents) - 1; 761*1d05cddcSAtari911 logMessage("DUPLICATE: Found " . count($outlookEvents) . " copies of: {$dwEvent['title']} [$dwId]", 'WARN'); 762*1d05cddcSAtari911 763*1d05cddcSAtari911 // Keep the first one, delete the rest 764*1d05cddcSAtari911 $kept = array_shift($outlookEvents); // Remove first from array 765*1d05cddcSAtari911 $mapping[$dwId] = $kept['id']; // Update mapping to first one 766*1d05cddcSAtari911 767*1d05cddcSAtari911 foreach ($outlookEvents as $duplicate) { 768*1d05cddcSAtari911 try { 769*1d05cddcSAtari911 $client->deleteEvent($config['user_email'], $duplicate['id']); 770*1d05cddcSAtari911 $duplicatesRemoved++; 771*1d05cddcSAtari911 logMessage(" Removed duplicate: " . $duplicate['id'], 'DEBUG'); 772*1d05cddcSAtari911 } catch (Exception $e) { 773*1d05cddcSAtari911 logMessage(" ERROR removing duplicate: " . $e->getMessage(), 'ERROR'); 774*1d05cddcSAtari911 } 775*1d05cddcSAtari911 } 776*1d05cddcSAtari911 } 777*1d05cddcSAtari911 } catch (Exception $e) { 778*1d05cddcSAtari911 // Continue checking other events even if one fails 779*1d05cddcSAtari911 logMessage("ERROR checking duplicates for {$dwEvent['title']}: " . $e->getMessage(), 'ERROR'); 780*1d05cddcSAtari911 } 781*1d05cddcSAtari911 } 782*1d05cddcSAtari911 783*1d05cddcSAtari911 if ($duplicatesFound > 0) { 784*1d05cddcSAtari911 logMessage(""); 785*1d05cddcSAtari911 logMessage("Duplicates found: $duplicatesFound"); 786*1d05cddcSAtari911 logMessage("Duplicates removed: $duplicatesRemoved"); 787*1d05cddcSAtari911 788*1d05cddcSAtari911 // Save updated mapping 789*1d05cddcSAtari911 $state['mapping'] = $mapping; 790*1d05cddcSAtari911 saveSyncState($stateFile, $state); 791*1d05cddcSAtari911 logMessage("Mapping updated after duplicate cleanup"); 792*1d05cddcSAtari911 } else { 793*1d05cddcSAtari911 logMessage("No duplicates found - Outlook is clean!"); 794*1d05cddcSAtari911 } 795*1d05cddcSAtari911 } 796*1d05cddcSAtari911 797*1d05cddcSAtari911 logMessage(""); 798*1d05cddcSAtari911 if ($dryRun) { 799*1d05cddcSAtari911 logMessage("DRY RUN - No changes were made"); 800*1d05cddcSAtari911 } else { 801*1d05cddcSAtari911 logMessage("Sync completed successfully!"); 802*1d05cddcSAtari911 } 803*1d05cddcSAtari911 804*1d05cddcSAtari911 exit($stats['errors'] > 0 ? 1 : 0); 805*1d05cddcSAtari911 806*1d05cddcSAtari911} catch (Exception $e) { 807*1d05cddcSAtari911 logMessage("FATAL ERROR: " . $e->getMessage(), 'ERROR'); 808*1d05cddcSAtari911 exit(1); 809*1d05cddcSAtari911} 810