diff --git a/calendar-ios.php b/calendar-ios.php index 332c469..5ffd80e 100644 --- a/calendar-ios.php +++ b/calendar-ios.php @@ -19,10 +19,27 @@ require __DIR__ . '/header.php'; -

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).

+

Les événements affichés viennent de HouseHub après import. Synchroniser interroge tous tes calendriers iCloud (même compte) sur une fenêtre d’environ trois ans en arrière à quatre ans en avant. Modification et suppression sont renvoyées vers iCloud à la synchro suivante.

+
+ + +
-
+
+
+ + + +
+ +
+
+
+ +

Ajouter / Modifier un événement

@@ -53,8 +70,8 @@ require __DIR__ . '/header.php';
-
-

Événements

+
+

Événements (liste)

diff --git a/includes/icloud_caldav.php b/includes/icloud_caldav.php index 47aa51f..a189c52 100644 --- a/includes/icloud_caldav.php +++ b/includes/icloud_caldav.php @@ -8,6 +8,12 @@ function hh_normalize_apple_app_password(string $password): string return preg_replace('/\s+/', '', trim($password)); } +function hh_caldav_url_is_icloud(string $url): bool +{ + $h = strtolower((string) (parse_url(trim($url), PHP_URL_HOST) ?: '')); + return str_contains($h, 'icloud.com'); +} + function hh_caldav_is_icloud_well_known_root(string $url): bool { $parts = parse_url(rtrim(trim($url), '/')); @@ -118,11 +124,13 @@ function hh_caldav_parse_calendar_collections(string $xml): array } /** - * Depuis la racine iCloud (https://caldav.icloud.com), découvre l’URL d’un calendrier (par défaut « Calendar » / « Calendrier », sinon le premier). + * Liste les collections calendrier iCloud (URL complète avec / final + nom affiché). + * + * @return list * * @throws RuntimeException */ -function hh_icloud_discover_default_calendar_url(string $username, string $password): string +function hh_icloud_discover_calendar_entries(string $username, string $password): array { $password = hh_normalize_apple_app_password($password); $root = 'https://caldav.icloud.com/'; @@ -166,27 +174,37 @@ function hh_icloud_discover_default_calendar_url(string $username, string $passw if (!in_array($r3['code'], [200, 207], true)) { throw new RuntimeException('Liste des calendriers impossible (HTTP ' . $r3['code'] . ').'); } - $entries = hh_caldav_parse_calendar_collections($r3['body']); - if ($entries === []) { + $raw = hh_caldav_parse_calendar_collections($r3['body']); + if ($raw === []) { throw new RuntimeException('Aucun calendrier trouvé sur ce compte iCloud.'); } + $out = []; + foreach ($raw as $e) { + $url = rtrim(hh_caldav_resolve_href($homeUrl, $e['href']), '/') . '/'; + $out[] = ['display' => $e['display'], 'url' => $url]; + } + return $out; +} + +/** + * Depuis la racine iCloud (https://caldav.icloud.com), découvre l’URL d’un calendrier (par défaut « Calendar » / « Calendrier », sinon le premier). + * + * @throws RuntimeException + */ +function hh_icloud_discover_default_calendar_url(string $username, string $password): string +{ + $entries = hh_icloud_discover_calendar_entries($username, $password); $preferred = ['calendar', 'calendrier', 'home', 'par défaut', 'default']; - $chosenHref = null; foreach ($entries as $e) { $lower = mb_strtolower($e['display']); foreach ($preferred as $p) { if ($lower !== '' && str_contains($lower, $p)) { - $chosenHref = $e['href']; - break 2; + return $e['url']; } } } - if ($chosenHref === null) { - $chosenHref = $entries[0]['href']; - } - - return rtrim(hh_caldav_resolve_href($homeUrl, $chosenHref), '/') . '/'; + return $entries[0]['url']; } /** diff --git a/modules/calendar-ios/api.php b/modules/calendar-ios/api.php index 987b8d4..a1441e8 100644 --- a/modules/calendar-ios/api.php +++ b/modules/calendar-ios/api.php @@ -56,11 +56,17 @@ CREATE TABLE IF NOT EXISTS pf_calendar_event_links ( external_uid VARCHAR(255) NOT NULL, external_etag VARCHAR(255) DEFAULT NULL, calendar_url VARCHAR(1024) DEFAULT NULL, + external_href VARCHAR(2048) DEFAULT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uq_calendar_event (calendar_event_id), UNIQUE KEY uq_external_link (external_uid) )"); +try { + $pdo->exec('ALTER TABLE pf_calendar_event_links ADD COLUMN external_href VARCHAR(2048) DEFAULT NULL'); +} catch (Throwable $e) { + // colonne déjà présente +} function ios_ok($data): void { echo json_encode(['ok' => true, 'data' => $data], JSON_UNESCAPED_UNICODE); exit; } function ios_err(string $message, int $status = 400): void { http_response_code($status); echo json_encode(['ok' => false, 'error' => $message]); exit; } @@ -72,6 +78,18 @@ function ios_require_csrf(): void { } } +function ios_normalize_datetime(?string $s): ?string +{ + if ($s === null || $s === '') { + return null; + } + $s = str_replace('T', ' ', trim($s)); + if (preg_match('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/', $s)) { + $s .= ':00'; + } + return $s; +} + $integrationStmt = $meta_pdo->prepare("SELECT * FROM user_calendar_integrations WHERE user_id = ? AND provider='icloud_caldav'"); $integrationStmt->execute([$userId]); $integration = $integrationStmt->fetch(); @@ -91,7 +109,10 @@ if ($action === 'events' && $method === 'POST') { INSERT INTO pf_calendar_events (family_id, created_by_user_id, title, description, location, start_at, end_at, timezone, sync_state) VALUES (?, ?, ?, ?, ?, ?, ?, 'Europe/Paris', 'pending_push') "); - $stmt->execute([$familyId, $userId, trim($d['title']), $d['description'] ?? null, $d['location'] ?? null, $d['start_at'], $d['end_at']]); + $stmt->execute([ + $familyId, $userId, trim($d['title']), $d['description'] ?? null, $d['location'] ?? null, + ios_normalize_datetime($d['start_at'] ?? null), ios_normalize_datetime($d['end_at'] ?? null), + ]); ios_ok(['id' => (int)$pdo->lastInsertId()]); } @@ -105,7 +126,10 @@ if ($action === 'events' && $method === 'PUT') { SET title=?, description=?, location=?, start_at=?, end_at=?, updated_at=NOW(), sync_state='pending_push' WHERE id=? "); - $stmt->execute([trim($d['title'] ?? ''), $d['description'] ?? null, $d['location'] ?? null, $d['start_at'] ?? null, $d['end_at'] ?? null, $id]); + $stmt->execute([ + trim($d['title'] ?? ''), $d['description'] ?? null, $d['location'] ?? null, + ios_normalize_datetime($d['start_at'] ?? null), ios_normalize_datetime($d['end_at'] ?? null), $id, + ]); ios_ok(['updated' => true]); } @@ -137,23 +161,30 @@ if ($action === 'sync' && $method === 'POST') { $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; - } + $syncUrls = ios_sync_calendar_collection_urls($integration, $davPassword); + $remoteEvents = ios_fetch_remote_events_all_calendars($integration, $davPassword); $localStmt = $pdo->query("SELECT * FROM pf_calendar_events WHERE deleted_at IS NULL"); $localEvents = $localStmt->fetchAll(); + $linkStmt = $pdo->prepare('SELECT external_href, calendar_url FROM pf_calendar_event_links WHERE calendar_event_id = ?'); + foreach ($localEvents as $evt) { if ($evt['sync_state'] === 'pending_push' || empty($evt['external_uid'])) { - $push = ios_push_event_to_remote($integration, $evt, $davPassword); + $linkStmt->execute([$evt['id']]); + $lr = $linkStmt->fetch() ?: []; + $push = ios_push_event_to_remote( + $integration, + $evt, + $davPassword, + $lr['external_href'] ?? null, + $lr['calendar_url'] ?? null + ); 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]); + $pdo->prepare("INSERT INTO pf_calendar_event_links (calendar_event_id, external_uid, calendar_url, external_etag, external_href) VALUES (?,?,?,?,?) ON DUPLICATE KEY UPDATE external_etag=VALUES(external_etag), calendar_url=VALUES(calendar_url), external_href=COALESCE(VALUES(external_href), external_href), updated_at=NOW()") + ->execute([$evt['id'], $uid, $integration['calendar_url'], null, $lr['external_href'] ?? null]); } } } @@ -161,13 +192,23 @@ if ($action === 'sync' && $method === 'POST') { $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); + $linkStmt->execute([$evt['id']]); + $lr = $linkStmt->fetch() ?: []; + ios_delete_remote_event( + $integration, + $evt['external_uid'], + $davPassword, + $lr['external_href'] ?? null, + $lr['calendar_url'] ?? null + ); } $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) { + $href = $remote['_resource_url'] ?? null; + $colUrl = $remote['_calendar_collection_url'] ?? $integration['calendar_url']; $existing = $pdo->prepare("SELECT id, updated_at FROM pf_calendar_events WHERE external_uid=? LIMIT 1"); $existing->execute([$remote['external_uid']]); $row = $existing->fetch(); @@ -177,14 +218,18 @@ if ($action === 'sync' && $method === 'POST') { 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]); + $pdo->prepare("INSERT INTO pf_calendar_event_links (calendar_event_id, external_uid, calendar_url, external_etag, external_href) VALUES (?,?,?,?,?) ON DUPLICATE KEY UPDATE calendar_url=VALUES(calendar_url), external_href=COALESCE(VALUES(external_href), external_href), updated_at=NOW()") + ->execute([$newId, $remote['external_uid'], $colUrl, null, $href]); + } elseif ($href) { + $pdo->prepare('UPDATE pf_calendar_event_links SET external_href = COALESCE(external_href, ?), calendar_url = COALESCE(calendar_url, ?) WHERE calendar_event_id = ?') + ->execute([$href, $colUrl, $row['id']]); } } $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.']); + $nc = count($syncUrls); + ios_ok(['message' => 'Synchronisation terminée. ' . $n . ' événement(s) depuis ' . $nc . ' calendrier(s) distant(s).']); } catch (Throwable $e) { ios_err('CalDAV: ' . $e->getMessage(), 400); } diff --git a/modules/calendar-ios/assets/calendar-ios.css b/modules/calendar-ios/assets/calendar-ios.css index ed4e946..38c6660 100644 --- a/modules/calendar-ios/assets/calendar-ios.css +++ b/modules/calendar-ios/assets/calendar-ios.css @@ -64,6 +64,130 @@ gap: 8px; } +.ios-view-btn--active { + border-color: var(--pf-accent, #3b82f6); + background: color-mix(in srgb, var(--pf-accent, #3b82f6) 12%, transparent); +} + +.ios-cal-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 10px; +} + +.ios-cal-month-label { + font-weight: 700; + text-transform: capitalize; +} + +.ios-cal-weekdays { + display: grid; + grid-template-columns: repeat(7, 1fr); + gap: 4px; + margin-bottom: 6px; + font-size: 0.72rem; + color: var(--pf-text-muted, #64748b); + text-align: center; +} + +.ios-cal-grid { + display: grid; + grid-template-columns: repeat(7, 1fr); + gap: 4px; +} + +.ios-cal-cell { + aspect-ratio: 1; + min-height: 44px; + border: 1px solid var(--pf-border, #e2e8f0); + border-radius: 8px; + background: var(--pf-bg-page, #fff); + cursor: pointer; + display: flex; + flex-direction: column; + align-items: center; + justify-content: flex-start; + padding: 4px 2px; + font: inherit; + color: inherit; +} + +.ios-cal-cell--pad { + visibility: hidden; + pointer-events: none; + border: none; + background: transparent; +} + +.ios-cal-cell--today { + border-color: var(--pf-accent, #3b82f6); + box-shadow: 0 0 0 1px color-mix(in srgb, var(--pf-accent, #3b82f6) 35%, transparent); +} + +.ios-cal-cell--has { + background: color-mix(in srgb, var(--pf-accent, #3b82f6) 6%, var(--pf-bg-page, #fff)); +} + +.ios-cal-daynum { + font-size: 0.85rem; + font-weight: 600; +} + +.ios-cal-dots { + display: flex; + gap: 2px; + flex-wrap: wrap; + justify-content: center; + margin-top: 2px; +} + +.ios-cal-dot { + width: 5px; + height: 5px; + border-radius: 50%; + background: var(--pf-accent, #3b82f6); +} + +.ios-day-detail { + margin-top: 14px; + padding-top: 12px; + border-top: 1px solid var(--pf-border, #e2e8f0); +} + +.ios-day-detail-title { + font-weight: 700; + margin-bottom: 8px; + text-transform: capitalize; +} + +.ios-day-row { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + padding: 6px 0; + border-bottom: 1px solid var(--pf-border, #e2e8f0); + font-size: 0.9rem; +} + +.ios-day-row-time { + font-variant-numeric: tabular-nums; + color: var(--pf-text-muted, #64748b); + min-width: 40px; +} + +.ios-day-row-title { + flex: 1; + min-width: 120px; +} + +.ios-day-row-actions { + display: flex; + gap: 6px; +} + @media (max-width: 768px) { .ios-form-grid { grid-template-columns: 1fr; @@ -76,4 +200,8 @@ flex-direction: column; align-items: flex-start; } + .ios-cal-cell { + min-height: 40px; + font-size: 0.8rem; + } } diff --git a/modules/calendar-ios/assets/calendar-ios.js b/modules/calendar-ios/assets/calendar-ios.js index 60dd522..94e54de 100644 --- a/modules/calendar-ios/assets/calendar-ios.js +++ b/modules/calendar-ios/assets/calendar-ios.js @@ -1,11 +1,40 @@ const iosEventsList = document.getElementById("ios-events-list"); const iosSyncStatus = document.getElementById("ios-sync-status"); const iosForm = document.getElementById("ios-event-form"); +const iosCalGrid = document.getElementById("ios-cal-grid"); +const iosMonthLabel = document.getElementById("ios-month-label"); +const iosDayDetail = document.getElementById("ios-day-detail"); + +let iosEventsCache = []; +/** @type {Date} mois affiché (jour ignoré) */ +let iosMonthCursor = new Date(); +let iosView = "month"; + +function escHtml(s) { + const d = document.createElement("div"); + d.textContent = s ?? ""; + return d.innerHTML; +} + +/** SQL datetime → valeur input datetime-local */ +function sqlToDatetimeLocal(s) { + if (!s) return ""; + const m = /^(\d{4})-(\d{2})-(\d{2})[\sT](\d{2}):(\d{2})/.exec(String(s).trim()); + if (!m) return ""; + return `${m[1]}-${m[2]}-${m[3]}T${m[4]}:${m[5]}`; +} + +/** datetime-local → SQL pour l’API */ +function localDatetimeToSql(s) { + if (!s) return ""; + const t = s.includes("T") ? s.replace("T", " ") : s; + return t.length === 16 ? `${t}:00` : t; +} async function iosApi(action, options = {}) { const method = options.method || "GET"; const headers = { - "Accept": "application/json", + Accept: "application/json", "X-Requested-With": "XMLHttpRequest", ...(options.headers || {}), }; @@ -25,8 +54,8 @@ async function iosApi(action, options = {}) { } function formatDate(iso) { - const d = new Date(iso); - return d.toLocaleString(); + const d = new Date(String(iso).replace(" ", "T")); + return Number.isNaN(d.getTime()) ? iso : d.toLocaleString(); } function fillForm(evt) { @@ -34,8 +63,8 @@ function fillForm(evt) { document.getElementById("ios-title").value = evt.title || ""; document.getElementById("ios-location").value = evt.location || ""; document.getElementById("ios-description").value = evt.description || ""; - document.getElementById("ios-start").value = (evt.start_at || "").slice(0, 16); - document.getElementById("ios-end").value = (evt.end_at || "").slice(0, 16); + document.getElementById("ios-start").value = sqlToDatetimeLocal(evt.start_at); + document.getElementById("ios-end").value = sqlToDatetimeLocal(evt.end_at); } function resetForm() { @@ -43,37 +72,145 @@ function resetForm() { document.getElementById("ios-event-id").value = ""; } +function eventsForLocalDay(y, m, d) { + return iosEventsCache.filter((evt) => { + const t = new Date(String(evt.start_at).replace(" ", "T")); + return !Number.isNaN(t.getTime()) && t.getFullYear() === y && t.getMonth() === m && t.getDate() === d; + }); +} + +function renderMonthGrid() { + if (!iosCalGrid) return; + const y = iosMonthCursor.getFullYear(); + const m = iosMonthCursor.getMonth(); + if (iosMonthLabel) { + iosMonthLabel.textContent = new Date(y, m, 1).toLocaleDateString("fr-FR", { month: "long", year: "numeric" }); + } + + const first = new Date(y, m, 1); + const last = new Date(y, m + 1, 0); + let startWeekday = first.getDay() - 1; + if (startWeekday < 0) startWeekday = 6; + + const frag = document.createDocumentFragment(); + for (let i = 0; i < startWeekday; i++) { + const c = document.createElement("div"); + c.className = "ios-cal-cell ios-cal-cell--pad"; + frag.appendChild(c); + } + + for (let d = 1; d <= last.getDate(); d++) { + const cell = document.createElement("button"); + cell.type = "button"; + cell.className = "ios-cal-cell"; + const dayEvents = eventsForLocalDay(y, m, d); + if (dayEvents.length) cell.classList.add("ios-cal-cell--has"); + const today = new Date(); + if (y === today.getFullYear() && m === today.getMonth() && d === today.getDate()) { + cell.classList.add("ios-cal-cell--today"); + } + cell.innerHTML = `${d}`; + if (dayEvents.length) { + const dots = document.createElement("div"); + dots.className = "ios-cal-dots"; + dayEvents.slice(0, 4).forEach(() => { + const dot = document.createElement("span"); + dot.className = "ios-cal-dot"; + dots.appendChild(dot); + }); + cell.appendChild(dots); + } + cell.addEventListener("click", () => showDayDetail(y, m, d)); + frag.appendChild(cell); + } + iosCalGrid.innerHTML = ""; + iosCalGrid.appendChild(frag); +} + +function showDayDetail(y, mo, d) { + if (!iosDayDetail) return; + const list = eventsForLocalDay(y, mo, d); + if (!list.length) { + iosDayDetail.innerHTML = `Aucun événement le ${d}/${mo + 1}/${y}.`; + return; + } + const label = new Date(y, mo, d).toLocaleDateString("fr-FR", { weekday: "long", day: "numeric", month: "long" }); + iosDayDetail.innerHTML = `
${escHtml(label)}
` + list.map((evt) => ` +
+ ${escHtml(String(evt.start_at).slice(11, 16) || "")} + ${escHtml(evt.title || "(Sans titre)")} + + + + +
`).join(""); + iosDayDetail.querySelectorAll("[data-ed]").forEach((btn) => { + btn.addEventListener("click", () => { + const id = parseInt(btn.getAttribute("data-ed"), 10); + const evt = iosEventsCache.find((e) => e.id === id); + if (evt) { + fillForm(evt); + document.getElementById("ios-edit-panel")?.scrollIntoView({ behavior: "smooth", block: "start" }); + } + }); + }); + iosDayDetail.querySelectorAll("[data-del]").forEach((btn) => { + btn.addEventListener("click", async () => { + const id = parseInt(btn.getAttribute("data-del"), 10); + if (!confirm("Supprimer cet événement ?")) return; + await iosApi("events", { method: "DELETE", body: { id } }); + await loadEvents(); + showDayDetail(y, mo, d); + }); + }); +} + +function setView(mode) { + iosView = mode; + const mEl = document.getElementById("ios-view-month"); + const lEl = document.getElementById("ios-view-list"); + const wrap = document.getElementById("ios-month-section"); + const listSec = document.getElementById("ios-list-section"); + mEl?.classList.toggle("ios-view-btn--active", mode === "month"); + lEl?.classList.toggle("ios-view-btn--active", mode === "list"); + if (wrap) wrap.style.display = mode === "month" ? "block" : "none"; + if (listSec) listSec.style.display = mode === "list" ? "block" : "none"; + if (mode === "month") renderMonthGrid(); +} + async function loadEvents() { const events = await iosApi("events"); + iosEventsCache = events; iosEventsList.innerHTML = ""; if (!events.length) { iosEventsList.innerHTML = "
Aucun événement.
"; - return; - } - events.forEach((evt) => { - const card = document.createElement("div"); - card.className = "ios-event-card"; - card.innerHTML = ` + } else { + events.forEach((evt) => { + const card = document.createElement("div"); + card.className = "ios-event-card"; + card.innerHTML = `
-
${evt.title || "(Sans titre)"}
-
${formatDate(evt.start_at)} → ${formatDate(evt.end_at)}
-
${evt.location || ""}
+
${escHtml(evt.title || "(Sans titre)")}
+
${escHtml(formatDate(evt.start_at))} → ${escHtml(formatDate(evt.end_at))}
+
${escHtml(evt.location || "")}
- - + +
`; - card.querySelector("[data-edit]").addEventListener("click", () => fillForm(evt)); - card.querySelector("[data-delete]").addEventListener("click", async () => { - if (!confirm("Supprimer cet événement ?")) return; - await iosApi("events", { method: "DELETE", body: { id: evt.id } }); - await loadEvents(); + card.querySelector("[data-edit]").addEventListener("click", () => fillForm(evt)); + card.querySelector("[data-delete]").addEventListener("click", async () => { + if (!confirm("Supprimer cet événement ?")) return; + await iosApi("events", { method: "DELETE", body: { id: evt.id } }); + await loadEvents(); + }); + iosEventsList.appendChild(card); }); - iosEventsList.appendChild(card); - }); + } + if (iosView === "month") renderMonthGrid(); } async function loadSyncStatus() { @@ -83,36 +220,55 @@ async function loadSyncStatus() { iosForm.addEventListener("submit", async (e) => { e.preventDefault(); - const id = document.getElementById("ios-event-id").value; + const idStr = document.getElementById("ios-event-id").value.trim(); const payload = { - id: id ? parseInt(id, 10) : null, title: document.getElementById("ios-title").value.trim(), location: document.getElementById("ios-location").value.trim(), description: document.getElementById("ios-description").value.trim(), - start_at: document.getElementById("ios-start").value, - end_at: document.getElementById("ios-end").value, + start_at: localDatetimeToSql(document.getElementById("ios-start").value), + end_at: localDatetimeToSql(document.getElementById("ios-end").value), }; - await iosApi("events", { method: id ? "PUT" : "POST", body: payload }); + if (idStr) { + payload.id = parseInt(idStr, 10); + await iosApi("events", { method: "PUT", body: payload }); + } else { + await iosApi("events", { method: "POST", body: payload }); + } resetForm(); await loadEvents(); }); -document.getElementById("ios-form-reset").addEventListener("click", resetForm); -document.getElementById("ios-sync-btn").addEventListener("click", async () => { - document.getElementById("ios-sync-btn").disabled = true; +document.getElementById("ios-form-reset")?.addEventListener("click", resetForm); +document.getElementById("ios-sync-btn")?.addEventListener("click", async () => { + const btn = document.getElementById("ios-sync-btn"); + btn.disabled = true; try { const data = await iosApi("sync", { method: "POST", body: {} }); iosSyncStatus.textContent = data.message; await loadEvents(); } finally { - document.getElementById("ios-sync-btn").disabled = false; + btn.disabled = false; } }); +document.getElementById("ios-month-prev")?.addEventListener("click", () => { + iosMonthCursor = new Date(iosMonthCursor.getFullYear(), iosMonthCursor.getMonth() - 1, 1); + renderMonthGrid(); +}); + +document.getElementById("ios-month-next")?.addEventListener("click", () => { + iosMonthCursor = new Date(iosMonthCursor.getFullYear(), iosMonthCursor.getMonth() + 1, 1); + renderMonthGrid(); +}); + +document.getElementById("ios-view-month")?.addEventListener("click", () => setView("month")); +document.getElementById("ios-view-list")?.addEventListener("click", () => setView("list")); + window.addEventListener("DOMContentLoaded", async () => { try { await loadEvents(); await loadSyncStatus(); + setView("month"); } catch (e) { iosSyncStatus.textContent = e.message; } diff --git a/modules/calendar-ios/caldav_sync.php b/modules/calendar-ios/caldav_sync.php index 5e48126..7d0b5b4 100644 --- a/modules/calendar-ios/caldav_sync.php +++ b/modules/calendar-ios/caldav_sync.php @@ -81,29 +81,6 @@ function ios_ics_unfold(string $ics): string 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 []; - } - $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 */ @@ -182,6 +159,70 @@ function ios_parse_vevents_from_ics(string $ics): array return $events; } +/** + * Associe chaque fragment iCalendar du REPORT à l’URL de ressource CalDAV (nécessaire pour PUT/DELETE sur iCloud). + * + * @return list + */ +function ios_caldav_parse_report_calendar_fragments(string $xmlBody, string $calendarBaseUrl): array +{ + $dom = new DOMDocument(); + if (!@$dom->loadXML($xmlBody)) { + return []; + } + $xpath = new DOMXPath($dom); + $responses = $xpath->query("//*[local-name()='response']"); + $out = []; + foreach ($responses as $resp) { + $hrefNodes = $xpath->query(".//*[local-name()='href']", $resp); + if ($hrefNodes->length === 0) { + continue; + } + $href = trim($hrefNodes->item(0)->textContent); + if ($href === '') { + continue; + } + $cd = $xpath->query(".//*[local-name()='calendar-data']", $resp); + if ($cd->length === 0) { + continue; + } + $ical = trim($cd->item(0)->textContent); + if ($ical === '' || stripos($ical, 'BEGIN:VEVENT') === false) { + continue; + } + $resourceUrl = (str_starts_with($href, 'http://') || str_starts_with($href, 'https://')) + ? $href + : hh_caldav_resolve_href(rtrim($calendarBaseUrl, '/') . '/', $href); + $out[] = ['resource_url' => $resourceUrl, 'ical' => $ical]; + } + return $out; +} + +/** + * URLs des collections calendrier à interroger (tous les calendriers iCloud du compte, sinon l’URL configurée seule). + * + * @return list + */ +function ios_sync_calendar_collection_urls(array $integration, string $password): array +{ + $primary = rtrim($integration['calendar_url'] ?? '', '/') . '/'; + if (hh_caldav_url_is_icloud($primary)) { + try { + $entries = hh_icloud_discover_calendar_entries($integration['username'], $password); + + return array_values(array_unique(array_map(static function (array $e): string { + return rtrim($e['url'], '/') . '/'; + }, $entries))); + } catch (Throwable $e) { + // compte partiellement lisible : au moins le calendrier principal + } + } + return [$primary]; +} + +/** + * @return list> champs événement + _resource_url + _calendar_collection_url + */ function ios_fetch_remote_events(array $integration, string $password): array { $calUrl = rtrim($integration['calendar_url'], '/') . '/'; @@ -204,17 +245,21 @@ function ios_fetch_remote_events(array $integration, string $password): array 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']); + $fragments = ios_caldav_parse_report_calendar_fragments($res['body'], $calUrl); $byUid = []; - foreach ($bodies as $fragment) { - foreach (ios_parse_vevents_from_ics($fragment) as $ev) { + foreach ($fragments as $frag) { + foreach (ios_parse_vevents_from_ics($frag['ical']) as $ev) { + $ev['_resource_url'] = $frag['resource_url']; + $ev['_calendar_collection_url'] = $calUrl; $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) { + $ev['_resource_url'] = null; + $ev['_calendar_collection_url'] = $calUrl; $byUid[$ev['external_uid']] = $ev; } } @@ -222,16 +267,37 @@ function ios_fetch_remote_events(array $integration, string $password): array return array_values($byUid); } -function ios_push_event_to_remote(array $integration, array $event, string $password): array +/** + * Fusionne tous les calendriers du compte (iCloud) ou une seule collection. + * + * @return list> + */ +function ios_fetch_remote_events_all_calendars(array $integration, string $password): array +{ + $urls = ios_sync_calendar_collection_urls($integration, $password); + $merged = []; + foreach ($urls as $url) { + $sub = $integration; + $sub['calendar_url'] = $url; + foreach (ios_fetch_remote_events($sub, $password) as $ev) { + $merged[$ev['external_uid']] = $ev; + } + } + return array_values($merged); +} + +function ios_push_event_to_remote(array $integration, array $event, string $password, ?string $resourceUrl = null, ?string $collectionUrlOverride = null): array { $uid = $event['external_uid'] ?: ('hh-' . $event['id'] . '@househub'); - $url = rtrim($integration['calendar_url'], '/') . '/' . rawurlencode($uid) . '.ics'; + $collection = rtrim($collectionUrlOverride ?? $integration['calendar_url'], '/'); + $url = $resourceUrl ?: ($collection . '/' . rawurlencode($uid) . '.ics'); $ics = ios_make_ics($event); return ios_caldav_request($url, $integration['username'], $password, 'PUT', $ics); } -function ios_delete_remote_event(array $integration, string $externalUid, string $password): array +function ios_delete_remote_event(array $integration, string $externalUid, string $password, ?string $resourceUrl = null, ?string $collectionUrlOverride = null): array { - $url = rtrim($integration['calendar_url'], '/') . '/' . rawurlencode($externalUid) . '.ics'; + $collection = rtrim($collectionUrlOverride ?? $integration['calendar_url'], '/'); + $url = $resourceUrl ?: ($collection . '/' . rawurlencode($externalUid) . '.ics'); return ios_caldav_request($url, $integration['username'], $password, 'DELETE', null, ['Accept: */*']); }