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