+1
-1
@@ -19,7 +19,7 @@ require __DIR__ . '/header.php';
|
|||||||
<button class="pf-btn btn-secondary" id="ios-sync-btn">Synchroniser</button>
|
<button class="pf-btn btn-secondary" id="ios-sync-btn">Synchroniser</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p class="pf-muted-note">Créez vos événements ici, puis synchronisez-les avec iCloud CalDAV.</p>
|
<p class="pf-muted-note">Les événements affichés viennent de HouseHub après import. Cliquez sur <strong>Synchroniser</strong> 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).</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="pf-panel-card">
|
<section class="pf-panel-card">
|
||||||
|
|||||||
@@ -135,57 +135,59 @@ if ($action === 'sync' && $method === 'POST') {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
$ctx = ios_caldav_prepare($integration, $meta_pdo);
|
$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) {
|
} catch (Throwable $e) {
|
||||||
ios_err('CalDAV: ' . $e->getMessage(), 400);
|
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);
|
ios_err('Action inconnue', 404);
|
||||||
|
|||||||
@@ -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
|
function ios_caldav_request(string $url, string $username, string $password, string $method = 'GET', ?string $body = null, array $headers = []): array
|
||||||
{
|
{
|
||||||
$ch = curl_init($url);
|
$ch = curl_init($url);
|
||||||
|
$defaultCt = ($method === 'REPORT' || $method === 'PROPFIND')
|
||||||
|
? 'application/xml; charset=utf-8'
|
||||||
|
: 'text/calendar; charset=utf-8';
|
||||||
$requestHeaders = array_merge([
|
$requestHeaders = array_merge([
|
||||||
'Content-Type: text/calendar; charset=utf-8',
|
'Content-Type: ' . $defaultCt,
|
||||||
], $headers);
|
], $headers);
|
||||||
|
$timeout = ($method === 'REPORT' || $method === 'PROPFIND') ? 60 : 20;
|
||||||
curl_setopt_array($ch, [
|
curl_setopt_array($ch, [
|
||||||
CURLOPT_RETURNTRANSFER => true,
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
CURLOPT_TIMEOUT => 20,
|
CURLOPT_TIMEOUT => $timeout,
|
||||||
CURLOPT_CUSTOMREQUEST => $method,
|
CURLOPT_CUSTOMREQUEST => $method,
|
||||||
CURLOPT_HTTPAUTH => CURLAUTH_BASIC,
|
CURLOPT_HTTPAUTH => CURLAUTH_BASIC,
|
||||||
CURLOPT_USERPWD => $username . ':' . $password,
|
CURLOPT_USERPWD => $username . ':' . $password,
|
||||||
CURLOPT_HTTPHEADER => $requestHeaders,
|
CURLOPT_HTTPHEADER => $requestHeaders,
|
||||||
CURLOPT_HEADER => true,
|
CURLOPT_HEADER => true,
|
||||||
|
CURLOPT_FOLLOWLOCATION => true,
|
||||||
|
CURLOPT_MAXREDIRS => 10,
|
||||||
]);
|
]);
|
||||||
if ($body !== null) {
|
if ($body !== null) {
|
||||||
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
|
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']);
|
$ics = str_replace("\r\n", "\n", $ics);
|
||||||
if ($res['code'] < 200 || $res['code'] >= 400) {
|
return preg_replace("/\n[ \t]/", '', $ics);
|
||||||
throw new RuntimeException('Lecture CalDAV impossible (HTTP ' . $res['code'] . ')');
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extrait les blocs texte <calendar-data> d’une réponse 207 REPORT (CalDAV).
|
||||||
|
*
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
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<array{external_uid:string,title:string,description:string,location:string,start_at:string,end_at:string}>
|
||||||
|
*/
|
||||||
|
function ios_parse_vevents_from_ics(string $ics): array
|
||||||
|
{
|
||||||
|
$ics = ios_ics_unfold($ics);
|
||||||
$events = [];
|
$events = [];
|
||||||
foreach ($blocks as $chunk) {
|
if (!preg_match_all('/BEGIN:VEVENT\s*(.*?)\s*END:VEVENT/s', $ics, $matches)) {
|
||||||
if (strpos($chunk, 'UID:') === false) {
|
return [];
|
||||||
|
}
|
||||||
|
foreach ($matches[1] as $chunk) {
|
||||||
|
if (!preg_match('/^UID:([^\r\n]+)/m', $chunk, $uidM)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
preg_match('/UID:(.+)\R/', $chunk, $uidM);
|
$uid = trim($uidM[1]);
|
||||||
preg_match('/SUMMARY:(.+)\R/', $chunk, $sumM);
|
$summary = '';
|
||||||
preg_match('/DESCRIPTION:(.+)\R/', $chunk, $descM);
|
if (preg_match('/^SUMMARY:([^\r\n]*)/m', $chunk, $m)) {
|
||||||
preg_match('/LOCATION:(.+)\R/', $chunk, $locM);
|
$summary = str_replace('\\,', ',', str_replace('\\;', ';', trim($m[1])));
|
||||||
preg_match('/DTSTART(?:;VALUE=DATE)?:(.+)\R/', $chunk, $startM);
|
}
|
||||||
preg_match('/DTEND(?:;VALUE=DATE)?:(.+)\R/', $chunk, $endM);
|
$description = '';
|
||||||
if (empty($uidM[1]) || empty($startM[1])) {
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
$start = date('Y-m-d H:i:s', strtotime(trim($startM[1])));
|
$endRaw = null;
|
||||||
$end = !empty($endM[1]) ? date('Y-m-d H:i:s', strtotime(trim($endM[1]))) : $start;
|
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[] = [
|
$events[] = [
|
||||||
'external_uid' => trim($uidM[1]),
|
'external_uid' => $uid,
|
||||||
'title' => trim($sumM[1] ?? ''),
|
'title' => $summary,
|
||||||
'description' => trim($descM[1] ?? ''),
|
'description' => $description,
|
||||||
'location' => trim($locM[1] ?? ''),
|
'location' => $location,
|
||||||
'start_at' => $start,
|
'start_at' => date('Y-m-d H:i:s', $startTs),
|
||||||
'end_at' => $end,
|
'end_at' => date('Y-m-d H:i:s', $endTs),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
return $events;
|
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 = '<?xml version="1.0" encoding="UTF-8"?>'
|
||||||
|
. '<C:calendar-query xmlns:C="urn:ietf:params:xml:ns:caldav" xmlns:D="DAV:">'
|
||||||
|
. '<D:prop><C:calendar-data/></D:prop>'
|
||||||
|
. '<C:filter>'
|
||||||
|
. '<C:comp-filter name="VCALENDAR">'
|
||||||
|
. '<C:comp-filter name="VEVENT">'
|
||||||
|
. '<C:time-range start="' . htmlspecialchars($tStart, ENT_XML1 | ENT_QUOTES, 'UTF-8') . '" end="' . htmlspecialchars($tEnd, ENT_XML1 | ENT_QUOTES, 'UTF-8') . '"/>'
|
||||||
|
. '</C:comp-filter></C:comp-filter></C:filter>'
|
||||||
|
. '</C:calendar-query>';
|
||||||
|
|
||||||
|
$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'], '<multistatus') === false && strpos($res['body'], 'multistatus') === false) {
|
||||||
|
$get = ios_caldav_request($calUrl, $integration['username'], $password, 'GET', null, ['Accept: text/calendar']);
|
||||||
|
if ($get['code'] >= 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
|
function ios_push_event_to_remote(array $integration, array $event, string $password): array
|
||||||
{
|
{
|
||||||
$uid = $event['external_uid'] ?: ('hh-' . $event['id'] . '@househub');
|
$uid = $event['external_uid'] ?: ('hh-' . $event['id'] . '@househub');
|
||||||
|
|||||||
Reference in New Issue
Block a user