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