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