diff --git a/calendar-ios.php b/calendar-ios.php index f6d4984..332c469 100644 --- a/calendar-ios.php +++ b/calendar-ios.php @@ -19,7 +19,7 @@ require __DIR__ . '/header.php'; -

Créez vos événements ici, puis synchronisez-les avec iCloud CalDAV.

+

Les événements affichés viennent de HouseHub après import. Cliquez sur Synchroniser pour récupérer ceux qui sont déjà dans ton calendrier iCloud (fenêtre d’environ trois ans en arrière à quatre ans en avant).

diff --git a/modules/calendar-ios/api.php b/modules/calendar-ios/api.php index 5223adb..987b8d4 100644 --- a/modules/calendar-ios/api.php +++ b/modules/calendar-ios/api.php @@ -135,57 +135,59 @@ if ($action === 'sync' && $method === 'POST') { try { $ctx = ios_caldav_prepare($integration, $meta_pdo); + $integration = $ctx['integration']; + $davPassword = $ctx['password']; + $remoteEvents = ios_fetch_remote_events($integration, $davPassword); + $remoteByUid = []; + foreach ($remoteEvents as $re) { + $remoteByUid[$re['external_uid']] = $re; + } + + $localStmt = $pdo->query("SELECT * FROM pf_calendar_events WHERE deleted_at IS NULL"); + $localEvents = $localStmt->fetchAll(); + + foreach ($localEvents as $evt) { + if ($evt['sync_state'] === 'pending_push' || empty($evt['external_uid'])) { + $push = ios_push_event_to_remote($integration, $evt, $davPassword); + if ($push['code'] >= 200 && $push['code'] < 300) { + $uid = $evt['external_uid'] ?: ('hh-' . $evt['id'] . '@househub'); + $pdo->prepare("UPDATE pf_calendar_events SET external_uid=?, sync_state='synced', updated_at=NOW() WHERE id=?")->execute([$uid, $evt['id']]); + $pdo->prepare("INSERT INTO pf_calendar_event_links (calendar_event_id, external_uid, calendar_url, external_etag) VALUES (?,?,?,?) ON DUPLICATE KEY UPDATE external_etag=VALUES(external_etag), calendar_url=VALUES(calendar_url), updated_at=NOW()") + ->execute([$evt['id'], $uid, $integration['calendar_url'], null]); + } + } + } + + $pendingDelete = $pdo->query("SELECT id, external_uid FROM pf_calendar_events WHERE deleted_at IS NOT NULL AND sync_state='pending_delete'")->fetchAll(); + foreach ($pendingDelete as $evt) { + if (!empty($evt['external_uid'])) { + ios_delete_remote_event($integration, $evt['external_uid'], $davPassword); + } + $pdo->prepare("DELETE FROM pf_calendar_event_links WHERE calendar_event_id=?")->execute([$evt['id']]); + $pdo->prepare("DELETE FROM pf_calendar_events WHERE id=?")->execute([$evt['id']]); + } + + foreach ($remoteEvents as $remote) { + $existing = $pdo->prepare("SELECT id, updated_at FROM pf_calendar_events WHERE external_uid=? LIMIT 1"); + $existing->execute([$remote['external_uid']]); + $row = $existing->fetch(); + if (!$row) { + $pdo->prepare(" + INSERT INTO pf_calendar_events (family_id, created_by_user_id, title, description, location, start_at, end_at, timezone, external_uid, sync_state) + VALUES (?, ?, ?, ?, ?, ?, ?, 'Europe/Paris', ?, 'synced') + ")->execute([$familyId, $userId, $remote['title'], $remote['description'], $remote['location'], $remote['start_at'], $remote['end_at'], $remote['external_uid']]); + $newId = (int)$pdo->lastInsertId(); + $pdo->prepare("INSERT INTO pf_calendar_event_links (calendar_event_id, external_uid, calendar_url, external_etag) VALUES (?,?,?,?) ON DUPLICATE KEY UPDATE updated_at=NOW()") + ->execute([$newId, $remote['external_uid'], $integration['calendar_url'], null]); + } + } + + $meta_pdo->prepare("UPDATE user_calendar_integrations SET last_sync_at=NOW(), status='connected' WHERE id=?")->execute([$integration['id']]); + $n = count($remoteEvents); + ios_ok(['message' => 'Synchronisation terminée. ' . $n . ' événement(s) lu(s) depuis iCloud.']); } catch (Throwable $e) { ios_err('CalDAV: ' . $e->getMessage(), 400); } - $integration = $ctx['integration']; - $davPassword = $ctx['password']; - - $remoteEvents = ios_fetch_remote_events($integration, $davPassword); - $remoteByUid = []; - foreach ($remoteEvents as $re) $remoteByUid[$re['external_uid']] = $re; - - $localStmt = $pdo->query("SELECT * FROM pf_calendar_events WHERE deleted_at IS NULL"); - $localEvents = $localStmt->fetchAll(); - - foreach ($localEvents as $evt) { - if ($evt['sync_state'] === 'pending_push' || empty($evt['external_uid'])) { - $push = ios_push_event_to_remote($integration, $evt, $davPassword); - if ($push['code'] >= 200 && $push['code'] < 300) { - $uid = $evt['external_uid'] ?: ('hh-' . $evt['id'] . '@househub'); - $pdo->prepare("UPDATE pf_calendar_events SET external_uid=?, sync_state='synced', updated_at=NOW() WHERE id=?")->execute([$uid, $evt['id']]); - $pdo->prepare("INSERT INTO pf_calendar_event_links (calendar_event_id, external_uid, calendar_url, external_etag) VALUES (?,?,?,?) ON DUPLICATE KEY UPDATE external_etag=VALUES(external_etag), calendar_url=VALUES(calendar_url), updated_at=NOW()") - ->execute([$evt['id'], $uid, $integration['calendar_url'], null]); - } - } - } - - $pendingDelete = $pdo->query("SELECT id, external_uid FROM pf_calendar_events WHERE deleted_at IS NOT NULL AND sync_state='pending_delete'")->fetchAll(); - foreach ($pendingDelete as $evt) { - if (!empty($evt['external_uid'])) { - ios_delete_remote_event($integration, $evt['external_uid'], $davPassword); - } - $pdo->prepare("DELETE FROM pf_calendar_event_links WHERE calendar_event_id=?")->execute([$evt['id']]); - $pdo->prepare("DELETE FROM pf_calendar_events WHERE id=?")->execute([$evt['id']]); - } - - foreach ($remoteEvents as $remote) { - $existing = $pdo->prepare("SELECT id, updated_at FROM pf_calendar_events WHERE external_uid=? LIMIT 1"); - $existing->execute([$remote['external_uid']]); - $row = $existing->fetch(); - if (!$row) { - $pdo->prepare(" - INSERT INTO pf_calendar_events (family_id, created_by_user_id, title, description, location, start_at, end_at, timezone, external_uid, sync_state) - VALUES (?, ?, ?, ?, ?, ?, ?, 'Europe/Paris', ?, 'synced') - ")->execute([$familyId, $userId, $remote['title'], $remote['description'], $remote['location'], $remote['start_at'], $remote['end_at'], $remote['external_uid']]); - $newId = (int)$pdo->lastInsertId(); - $pdo->prepare("INSERT INTO pf_calendar_event_links (calendar_event_id, external_uid, calendar_url, external_etag) VALUES (?,?,?,?) ON DUPLICATE KEY UPDATE updated_at=NOW()") - ->execute([$newId, $remote['external_uid'], $integration['calendar_url'], null]); - } - } - - $meta_pdo->prepare("UPDATE user_calendar_integrations SET last_sync_at=NOW(), status='connected' WHERE id=?")->execute([$integration['id']]); - ios_ok(['message' => 'Synchronisation terminée.']); } ios_err('Action inconnue', 404); diff --git a/modules/calendar-ios/caldav_sync.php b/modules/calendar-ios/caldav_sync.php index ab3d0dc..5e48126 100644 --- a/modules/calendar-ios/caldav_sync.php +++ b/modules/calendar-ios/caldav_sync.php @@ -37,17 +37,23 @@ function ios_make_ics(array $event): string function ios_caldav_request(string $url, string $username, string $password, string $method = 'GET', ?string $body = null, array $headers = []): array { $ch = curl_init($url); + $defaultCt = ($method === 'REPORT' || $method === 'PROPFIND') + ? 'application/xml; charset=utf-8' + : 'text/calendar; charset=utf-8'; $requestHeaders = array_merge([ - 'Content-Type: text/calendar; charset=utf-8', + 'Content-Type: ' . $defaultCt, ], $headers); + $timeout = ($method === 'REPORT' || $method === 'PROPFIND') ? 60 : 20; curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, - CURLOPT_TIMEOUT => 20, + CURLOPT_TIMEOUT => $timeout, CURLOPT_CUSTOMREQUEST => $method, CURLOPT_HTTPAUTH => CURLAUTH_BASIC, CURLOPT_USERPWD => $username . ':' . $password, CURLOPT_HTTPHEADER => $requestHeaders, CURLOPT_HEADER => true, + CURLOPT_FOLLOWLOCATION => true, + CURLOPT_MAXREDIRS => 10, ]); if ($body !== null) { curl_setopt($ch, CURLOPT_POSTFIELDS, $body); @@ -66,41 +72,156 @@ function ios_caldav_request(string $url, string $username, string $password, str ]; } -function ios_fetch_remote_events(array $integration, string $password): array +/** + * Déplie les lignes ICS pliées (RFC 5545). + */ +function ios_ics_unfold(string $ics): string { - $res = ios_caldav_request($integration['calendar_url'], $integration['username'], $password, 'GET', null, ['Accept: text/calendar']); - if ($res['code'] < 200 || $res['code'] >= 400) { - throw new RuntimeException('Lecture CalDAV impossible (HTTP ' . $res['code'] . ')'); + $ics = str_replace("\r\n", "\n", $ics); + return preg_replace("/\n[ \t]/", '', $ics); +} + +/** + * Extrait les blocs texte d’une réponse 207 REPORT (CalDAV). + * + * @return list + */ +function ios_caldav_extract_calendar_data_bodies(string $xmlBody): array +{ + $dom = new DOMDocument(); + if (!@$dom->loadXML($xmlBody)) { + return []; } - $blocks = preg_split('/BEGIN:VEVENT|END:VEVENT/', $res['body']); + $xpath = new DOMXPath($dom); + $nodes = $xpath->query("//*[local-name()='calendar-data']"); + $out = []; + foreach ($nodes as $node) { + $t = trim($node->textContent); + if ($t !== '') { + $out[] = $t; + } + } + return $out; +} + +/** + * @return list + */ +function ios_parse_vevents_from_ics(string $ics): array +{ + $ics = ios_ics_unfold($ics); $events = []; - foreach ($blocks as $chunk) { - if (strpos($chunk, 'UID:') === false) { + if (!preg_match_all('/BEGIN:VEVENT\s*(.*?)\s*END:VEVENT/s', $ics, $matches)) { + return []; + } + foreach ($matches[1] as $chunk) { + if (!preg_match('/^UID:([^\r\n]+)/m', $chunk, $uidM)) { continue; } - preg_match('/UID:(.+)\R/', $chunk, $uidM); - preg_match('/SUMMARY:(.+)\R/', $chunk, $sumM); - preg_match('/DESCRIPTION:(.+)\R/', $chunk, $descM); - preg_match('/LOCATION:(.+)\R/', $chunk, $locM); - preg_match('/DTSTART(?:;VALUE=DATE)?:(.+)\R/', $chunk, $startM); - preg_match('/DTEND(?:;VALUE=DATE)?:(.+)\R/', $chunk, $endM); - if (empty($uidM[1]) || empty($startM[1])) { + $uid = trim($uidM[1]); + $summary = ''; + if (preg_match('/^SUMMARY:([^\r\n]*)/m', $chunk, $m)) { + $summary = str_replace('\\,', ',', str_replace('\\;', ';', trim($m[1]))); + } + $description = ''; + if (preg_match('/^DESCRIPTION:([^\r\n]*)/m', $chunk, $m)) { + $description = str_replace('\\,', ',', str_replace('\\;', ';', str_replace('\\n', "\n", trim($m[1])))); + } + $location = ''; + if (preg_match('/^LOCATION:([^\r\n]*)/m', $chunk, $m)) { + $location = str_replace('\\,', ',', str_replace('\\;', ';', trim($m[1]))); + } + $startRaw = null; + if (preg_match('/^DTSTART[^:]*:([^\r\n]+)/m', $chunk, $m)) { + $startRaw = trim($m[1]); + } + if ($startRaw === null || $startRaw === '') { continue; } - $start = date('Y-m-d H:i:s', strtotime(trim($startM[1]))); - $end = !empty($endM[1]) ? date('Y-m-d H:i:s', strtotime(trim($endM[1]))) : $start; + $endRaw = null; + if (preg_match('/^DTEND[^:]*:([^\r\n]+)/m', $chunk, $m)) { + $endRaw = trim($m[1]); + } + if (preg_match('/^\d{8}$/', $startRaw)) { + $startTs = strtotime($startRaw . 'T000000 UTC'); + } else { + $norm = preg_replace('/^(\d{8})T(\d{6})Z?$/', '$1T$2 UTC', $startRaw); + $startTs = strtotime($norm ?: $startRaw); + if ($startTs === false) { + $startTs = strtotime($startRaw); + } + } + if ($startTs === false) { + continue; + } + if ($endRaw !== null && $endRaw !== '') { + if (preg_match('/^\d{8}$/', $endRaw)) { + $endTs = strtotime($endRaw . 'T000000 UTC'); + } else { + $normE = preg_replace('/^(\d{8})T(\d{6})Z?$/', '$1T$2 UTC', $endRaw); + $endTs = strtotime($normE ?: $endRaw); + if ($endTs === false) { + $endTs = strtotime($endRaw); + } + } + } else { + $endTs = $startTs; + } + if ($endTs === false) { + $endTs = $startTs; + } $events[] = [ - 'external_uid' => trim($uidM[1]), - 'title' => trim($sumM[1] ?? ''), - 'description' => trim($descM[1] ?? ''), - 'location' => trim($locM[1] ?? ''), - 'start_at' => $start, - 'end_at' => $end, + 'external_uid' => $uid, + 'title' => $summary, + 'description' => $description, + 'location' => $location, + 'start_at' => date('Y-m-d H:i:s', $startTs), + 'end_at' => date('Y-m-d H:i:s', $endTs), ]; } return $events; } +function ios_fetch_remote_events(array $integration, string $password): array +{ + $calUrl = rtrim($integration['calendar_url'], '/') . '/'; + $tStart = gmdate('Ymd\THis\Z', strtotime('-3 years')); + $tEnd = gmdate('Ymd\THis\Z', strtotime('+4 years')); + $reportXml = '' + . '' + . '' + . '' + . '' + . '' + . '' + . '' + . ''; + + $res = ios_caldav_request($calUrl, $integration['username'], $password, 'REPORT', $reportXml, [ + 'Accept: application/xml, text/xml', + 'Depth: 1', + ]); + if (!in_array($res['code'], [200, 207], true)) { + throw new RuntimeException('Lecture CalDAV (REPORT) impossible (HTTP ' . $res['code'] . ').'); + } + $bodies = ios_caldav_extract_calendar_data_bodies($res['body']); + $byUid = []; + foreach ($bodies as $fragment) { + foreach (ios_parse_vevents_from_ics($fragment) as $ev) { + $byUid[$ev['external_uid']] = $ev; + } + } + if ($byUid === [] && strpos($res['body'], '= 200 && $get['code'] < 400 && str_contains($get['body'], 'BEGIN:VEVENT')) { + foreach (ios_parse_vevents_from_ics($get['body']) as $ev) { + $byUid[$ev['external_uid']] = $ev; + } + } + } + return array_values($byUid); +} + function ios_push_event_to_remote(array $integration, array $event, string $password): array { $uid = $event['external_uid'] ?: ('hh-' . $event['id'] . '@househub');