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