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