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