+21
-4
@@ -19,10 +19,27 @@ 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">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>
|
<p class="pf-muted-note">Les événements affichés viennent de HouseHub après import. <strong>Synchroniser</strong> interroge <strong>tous tes calendriers iCloud</strong> (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.</p>
|
||||||
|
<div class="ios-view-toggle" style="display:flex;gap:8px;margin-top:10px;flex-wrap:wrap">
|
||||||
|
<button type="button" class="pf-btn btn-secondary ios-view-btn ios-view-btn--active" id="ios-view-month">Vue mois</button>
|
||||||
|
<button type="button" class="pf-btn btn-secondary ios-view-btn" id="ios-view-list">Liste</button>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="pf-panel-card">
|
<section class="pf-panel-card" id="ios-month-section">
|
||||||
|
<div class="ios-cal-toolbar">
|
||||||
|
<button type="button" class="pf-btn btn-secondary" id="ios-month-prev" aria-label="Mois précédent">←</button>
|
||||||
|
<span id="ios-month-label" class="ios-cal-month-label"></span>
|
||||||
|
<button type="button" class="pf-btn btn-secondary" id="ios-month-next" aria-label="Mois suivant">→</button>
|
||||||
|
</div>
|
||||||
|
<div class="ios-cal-weekdays" aria-hidden="true">
|
||||||
|
<span>Lun</span><span>Mar</span><span>Mer</span><span>Jeu</span><span>Ven</span><span>Sam</span><span>Dim</span>
|
||||||
|
</div>
|
||||||
|
<div id="ios-cal-grid" class="ios-cal-grid"></div>
|
||||||
|
<div id="ios-day-detail" class="ios-day-detail"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="pf-panel-card" id="ios-edit-panel">
|
||||||
<h2 class="pf-card-h2">Ajouter / Modifier un événement</h2>
|
<h2 class="pf-card-h2">Ajouter / Modifier un événement</h2>
|
||||||
<form id="ios-event-form" class="ios-form-grid">
|
<form id="ios-event-form" class="ios-form-grid">
|
||||||
<input type="hidden" id="ios-event-id">
|
<input type="hidden" id="ios-event-id">
|
||||||
@@ -53,8 +70,8 @@ require __DIR__ . '/header.php';
|
|||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="pf-panel-card">
|
<section class="pf-panel-card" id="ios-list-section">
|
||||||
<h2 class="pf-card-h2">Événements</h2>
|
<h2 class="pf-card-h2">Événements (liste)</h2>
|
||||||
<div id="ios-sync-status" class="pf-muted-note"></div>
|
<div id="ios-sync-status" class="pf-muted-note"></div>
|
||||||
<div id="ios-events-list" class="ios-events-list"></div>
|
<div id="ios-events-list" class="ios-events-list"></div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
+30
-12
@@ -8,6 +8,12 @@ function hh_normalize_apple_app_password(string $password): string
|
|||||||
return preg_replace('/\s+/', '', trim($password));
|
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
|
function hh_caldav_is_icloud_well_known_root(string $url): bool
|
||||||
{
|
{
|
||||||
$parts = parse_url(rtrim(trim($url), '/'));
|
$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<array{display:string, url:string}>
|
||||||
*
|
*
|
||||||
* @throws RuntimeException
|
* @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);
|
$password = hh_normalize_apple_app_password($password);
|
||||||
$root = 'https://caldav.icloud.com/';
|
$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)) {
|
if (!in_array($r3['code'], [200, 207], true)) {
|
||||||
throw new RuntimeException('Liste des calendriers impossible (HTTP ' . $r3['code'] . ').');
|
throw new RuntimeException('Liste des calendriers impossible (HTTP ' . $r3['code'] . ').');
|
||||||
}
|
}
|
||||||
$entries = hh_caldav_parse_calendar_collections($r3['body']);
|
$raw = hh_caldav_parse_calendar_collections($r3['body']);
|
||||||
if ($entries === []) {
|
if ($raw === []) {
|
||||||
throw new RuntimeException('Aucun calendrier trouvé sur ce compte iCloud.');
|
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'];
|
$preferred = ['calendar', 'calendrier', 'home', 'par défaut', 'default'];
|
||||||
$chosenHref = null;
|
|
||||||
foreach ($entries as $e) {
|
foreach ($entries as $e) {
|
||||||
$lower = mb_strtolower($e['display']);
|
$lower = mb_strtolower($e['display']);
|
||||||
foreach ($preferred as $p) {
|
foreach ($preferred as $p) {
|
||||||
if ($lower !== '' && str_contains($lower, $p)) {
|
if ($lower !== '' && str_contains($lower, $p)) {
|
||||||
$chosenHref = $e['href'];
|
return $e['url'];
|
||||||
break 2;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if ($chosenHref === null) {
|
return $entries[0]['url'];
|
||||||
$chosenHref = $entries[0]['href'];
|
|
||||||
}
|
|
||||||
|
|
||||||
return rtrim(hh_caldav_resolve_href($homeUrl, $chosenHref), '/') . '/';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -56,11 +56,17 @@ CREATE TABLE IF NOT EXISTS pf_calendar_event_links (
|
|||||||
external_uid VARCHAR(255) NOT NULL,
|
external_uid VARCHAR(255) NOT NULL,
|
||||||
external_etag VARCHAR(255) DEFAULT NULL,
|
external_etag VARCHAR(255) DEFAULT NULL,
|
||||||
calendar_url VARCHAR(1024) DEFAULT NULL,
|
calendar_url VARCHAR(1024) DEFAULT NULL,
|
||||||
|
external_href VARCHAR(2048) DEFAULT NULL,
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
UNIQUE KEY uq_calendar_event (calendar_event_id),
|
UNIQUE KEY uq_calendar_event (calendar_event_id),
|
||||||
UNIQUE KEY uq_external_link (external_uid)
|
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_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; }
|
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 = $meta_pdo->prepare("SELECT * FROM user_calendar_integrations WHERE user_id = ? AND provider='icloud_caldav'");
|
||||||
$integrationStmt->execute([$userId]);
|
$integrationStmt->execute([$userId]);
|
||||||
$integration = $integrationStmt->fetch();
|
$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)
|
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')
|
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()]);
|
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'
|
SET title=?, description=?, location=?, start_at=?, end_at=?, updated_at=NOW(), sync_state='pending_push'
|
||||||
WHERE id=?
|
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]);
|
ios_ok(['updated' => true]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,23 +161,30 @@ if ($action === 'sync' && $method === 'POST') {
|
|||||||
$ctx = ios_caldav_prepare($integration, $meta_pdo);
|
$ctx = ios_caldav_prepare($integration, $meta_pdo);
|
||||||
$integration = $ctx['integration'];
|
$integration = $ctx['integration'];
|
||||||
$davPassword = $ctx['password'];
|
$davPassword = $ctx['password'];
|
||||||
$remoteEvents = ios_fetch_remote_events($integration, $davPassword);
|
$syncUrls = ios_sync_calendar_collection_urls($integration, $davPassword);
|
||||||
$remoteByUid = [];
|
$remoteEvents = ios_fetch_remote_events_all_calendars($integration, $davPassword);
|
||||||
foreach ($remoteEvents as $re) {
|
|
||||||
$remoteByUid[$re['external_uid']] = $re;
|
|
||||||
}
|
|
||||||
|
|
||||||
$localStmt = $pdo->query("SELECT * FROM pf_calendar_events WHERE deleted_at IS NULL");
|
$localStmt = $pdo->query("SELECT * FROM pf_calendar_events WHERE deleted_at IS NULL");
|
||||||
$localEvents = $localStmt->fetchAll();
|
$localEvents = $localStmt->fetchAll();
|
||||||
|
|
||||||
|
$linkStmt = $pdo->prepare('SELECT external_href, calendar_url FROM pf_calendar_event_links WHERE calendar_event_id = ?');
|
||||||
|
|
||||||
foreach ($localEvents as $evt) {
|
foreach ($localEvents as $evt) {
|
||||||
if ($evt['sync_state'] === 'pending_push' || empty($evt['external_uid'])) {
|
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) {
|
if ($push['code'] >= 200 && $push['code'] < 300) {
|
||||||
$uid = $evt['external_uid'] ?: ('hh-' . $evt['id'] . '@househub');
|
$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("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()")
|
$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]);
|
->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();
|
$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) {
|
foreach ($pendingDelete as $evt) {
|
||||||
if (!empty($evt['external_uid'])) {
|
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_event_links WHERE calendar_event_id=?")->execute([$evt['id']]);
|
||||||
$pdo->prepare("DELETE FROM pf_calendar_events WHERE id=?")->execute([$evt['id']]);
|
$pdo->prepare("DELETE FROM pf_calendar_events WHERE id=?")->execute([$evt['id']]);
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach ($remoteEvents as $remote) {
|
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 = $pdo->prepare("SELECT id, updated_at FROM pf_calendar_events WHERE external_uid=? LIMIT 1");
|
||||||
$existing->execute([$remote['external_uid']]);
|
$existing->execute([$remote['external_uid']]);
|
||||||
$row = $existing->fetch();
|
$row = $existing->fetch();
|
||||||
@@ -177,14 +218,18 @@ if ($action === 'sync' && $method === 'POST') {
|
|||||||
VALUES (?, ?, ?, ?, ?, ?, ?, 'Europe/Paris', ?, 'synced')
|
VALUES (?, ?, ?, ?, ?, ?, ?, 'Europe/Paris', ?, 'synced')
|
||||||
")->execute([$familyId, $userId, $remote['title'], $remote['description'], $remote['location'], $remote['start_at'], $remote['end_at'], $remote['external_uid']]);
|
")->execute([$familyId, $userId, $remote['title'], $remote['description'], $remote['location'], $remote['start_at'], $remote['end_at'], $remote['external_uid']]);
|
||||||
$newId = (int)$pdo->lastInsertId();
|
$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()")
|
$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'], $integration['calendar_url'], null]);
|
->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']]);
|
$meta_pdo->prepare("UPDATE user_calendar_integrations SET last_sync_at=NOW(), status='connected' WHERE id=?")->execute([$integration['id']]);
|
||||||
$n = count($remoteEvents);
|
$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) {
|
} catch (Throwable $e) {
|
||||||
ios_err('CalDAV: ' . $e->getMessage(), 400);
|
ios_err('CalDAV: ' . $e->getMessage(), 400);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,6 +64,130 @@
|
|||||||
gap: 8px;
|
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) {
|
@media (max-width: 768px) {
|
||||||
.ios-form-grid {
|
.ios-form-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
@@ -76,4 +200,8 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
}
|
}
|
||||||
|
.ios-cal-cell {
|
||||||
|
min-height: 40px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,40 @@
|
|||||||
const iosEventsList = document.getElementById("ios-events-list");
|
const iosEventsList = document.getElementById("ios-events-list");
|
||||||
const iosSyncStatus = document.getElementById("ios-sync-status");
|
const iosSyncStatus = document.getElementById("ios-sync-status");
|
||||||
const iosForm = document.getElementById("ios-event-form");
|
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 = {}) {
|
async function iosApi(action, options = {}) {
|
||||||
const method = options.method || "GET";
|
const method = options.method || "GET";
|
||||||
const headers = {
|
const headers = {
|
||||||
"Accept": "application/json",
|
Accept: "application/json",
|
||||||
"X-Requested-With": "XMLHttpRequest",
|
"X-Requested-With": "XMLHttpRequest",
|
||||||
...(options.headers || {}),
|
...(options.headers || {}),
|
||||||
};
|
};
|
||||||
@@ -25,8 +54,8 @@ async function iosApi(action, options = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function formatDate(iso) {
|
function formatDate(iso) {
|
||||||
const d = new Date(iso);
|
const d = new Date(String(iso).replace(" ", "T"));
|
||||||
return d.toLocaleString();
|
return Number.isNaN(d.getTime()) ? iso : d.toLocaleString();
|
||||||
}
|
}
|
||||||
|
|
||||||
function fillForm(evt) {
|
function fillForm(evt) {
|
||||||
@@ -34,8 +63,8 @@ function fillForm(evt) {
|
|||||||
document.getElementById("ios-title").value = evt.title || "";
|
document.getElementById("ios-title").value = evt.title || "";
|
||||||
document.getElementById("ios-location").value = evt.location || "";
|
document.getElementById("ios-location").value = evt.location || "";
|
||||||
document.getElementById("ios-description").value = evt.description || "";
|
document.getElementById("ios-description").value = evt.description || "";
|
||||||
document.getElementById("ios-start").value = (evt.start_at || "").slice(0, 16);
|
document.getElementById("ios-start").value = sqlToDatetimeLocal(evt.start_at);
|
||||||
document.getElementById("ios-end").value = (evt.end_at || "").slice(0, 16);
|
document.getElementById("ios-end").value = sqlToDatetimeLocal(evt.end_at);
|
||||||
}
|
}
|
||||||
|
|
||||||
function resetForm() {
|
function resetForm() {
|
||||||
@@ -43,37 +72,145 @@ function resetForm() {
|
|||||||
document.getElementById("ios-event-id").value = "";
|
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 = `<span class="ios-cal-daynum">${d}</span>`;
|
||||||
|
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 = `<span class="pf-muted-note">Aucun événement le ${d}/${mo + 1}/${y}.</span>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const label = new Date(y, mo, d).toLocaleDateString("fr-FR", { weekday: "long", day: "numeric", month: "long" });
|
||||||
|
iosDayDetail.innerHTML = `<div class="ios-day-detail-title">${escHtml(label)}</div>` + list.map((evt) => `
|
||||||
|
<div class="ios-day-row">
|
||||||
|
<span class="ios-day-row-time">${escHtml(String(evt.start_at).slice(11, 16) || "")}</span>
|
||||||
|
<span class="ios-day-row-title">${escHtml(evt.title || "(Sans titre)")}</span>
|
||||||
|
<span class="ios-day-row-actions">
|
||||||
|
<button type="button" class="pf-btn btn-secondary btn-sm" data-ed="${evt.id}">Modifier</button>
|
||||||
|
<button type="button" class="pf-btn btn-secondary btn-sm" data-del="${evt.id}">Supprimer</button>
|
||||||
|
</span>
|
||||||
|
</div>`).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() {
|
async function loadEvents() {
|
||||||
const events = await iosApi("events");
|
const events = await iosApi("events");
|
||||||
|
iosEventsCache = events;
|
||||||
iosEventsList.innerHTML = "";
|
iosEventsList.innerHTML = "";
|
||||||
if (!events.length) {
|
if (!events.length) {
|
||||||
iosEventsList.innerHTML = "<div class='pf-muted-note'>Aucun événement.</div>";
|
iosEventsList.innerHTML = "<div class='pf-muted-note'>Aucun événement.</div>";
|
||||||
return;
|
} else {
|
||||||
}
|
events.forEach((evt) => {
|
||||||
events.forEach((evt) => {
|
const card = document.createElement("div");
|
||||||
const card = document.createElement("div");
|
card.className = "ios-event-card";
|
||||||
card.className = "ios-event-card";
|
card.innerHTML = `
|
||||||
card.innerHTML = `
|
|
||||||
<div class="ios-event-card-head">
|
<div class="ios-event-card-head">
|
||||||
<div>
|
<div>
|
||||||
<div class="ios-event-card-title">${evt.title || "(Sans titre)"}</div>
|
<div class="ios-event-card-title">${escHtml(evt.title || "(Sans titre)")}</div>
|
||||||
<div class="ios-event-card-meta">${formatDate(evt.start_at)} → ${formatDate(evt.end_at)}</div>
|
<div class="ios-event-card-meta">${escHtml(formatDate(evt.start_at))} → ${escHtml(formatDate(evt.end_at))}</div>
|
||||||
<div class="ios-event-card-meta">${evt.location || ""}</div>
|
<div class="ios-event-card-meta">${escHtml(evt.location || "")}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="ios-event-card-actions">
|
<div class="ios-event-card-actions">
|
||||||
<button class="pf-btn btn-secondary" data-edit="${evt.id}">Modifier</button>
|
<button type="button" class="pf-btn btn-secondary" data-edit="${evt.id}">Modifier</button>
|
||||||
<button class="pf-btn btn-secondary" data-delete="${evt.id}">Supprimer</button>
|
<button type="button" class="pf-btn btn-secondary" data-delete="${evt.id}">Supprimer</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
card.querySelector("[data-edit]").addEventListener("click", () => fillForm(evt));
|
card.querySelector("[data-edit]").addEventListener("click", () => fillForm(evt));
|
||||||
card.querySelector("[data-delete]").addEventListener("click", async () => {
|
card.querySelector("[data-delete]").addEventListener("click", async () => {
|
||||||
if (!confirm("Supprimer cet événement ?")) return;
|
if (!confirm("Supprimer cet événement ?")) return;
|
||||||
await iosApi("events", { method: "DELETE", body: { id: evt.id } });
|
await iosApi("events", { method: "DELETE", body: { id: evt.id } });
|
||||||
await loadEvents();
|
await loadEvents();
|
||||||
|
});
|
||||||
|
iosEventsList.appendChild(card);
|
||||||
});
|
});
|
||||||
iosEventsList.appendChild(card);
|
}
|
||||||
});
|
if (iosView === "month") renderMonthGrid();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadSyncStatus() {
|
async function loadSyncStatus() {
|
||||||
@@ -83,36 +220,55 @@ async function loadSyncStatus() {
|
|||||||
|
|
||||||
iosForm.addEventListener("submit", async (e) => {
|
iosForm.addEventListener("submit", async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const id = document.getElementById("ios-event-id").value;
|
const idStr = document.getElementById("ios-event-id").value.trim();
|
||||||
const payload = {
|
const payload = {
|
||||||
id: id ? parseInt(id, 10) : null,
|
|
||||||
title: document.getElementById("ios-title").value.trim(),
|
title: document.getElementById("ios-title").value.trim(),
|
||||||
location: document.getElementById("ios-location").value.trim(),
|
location: document.getElementById("ios-location").value.trim(),
|
||||||
description: document.getElementById("ios-description").value.trim(),
|
description: document.getElementById("ios-description").value.trim(),
|
||||||
start_at: document.getElementById("ios-start").value,
|
start_at: localDatetimeToSql(document.getElementById("ios-start").value),
|
||||||
end_at: document.getElementById("ios-end").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();
|
resetForm();
|
||||||
await loadEvents();
|
await loadEvents();
|
||||||
});
|
});
|
||||||
|
|
||||||
document.getElementById("ios-form-reset").addEventListener("click", resetForm);
|
document.getElementById("ios-form-reset")?.addEventListener("click", resetForm);
|
||||||
document.getElementById("ios-sync-btn").addEventListener("click", async () => {
|
document.getElementById("ios-sync-btn")?.addEventListener("click", async () => {
|
||||||
document.getElementById("ios-sync-btn").disabled = true;
|
const btn = document.getElementById("ios-sync-btn");
|
||||||
|
btn.disabled = true;
|
||||||
try {
|
try {
|
||||||
const data = await iosApi("sync", { method: "POST", body: {} });
|
const data = await iosApi("sync", { method: "POST", body: {} });
|
||||||
iosSyncStatus.textContent = data.message;
|
iosSyncStatus.textContent = data.message;
|
||||||
await loadEvents();
|
await loadEvents();
|
||||||
} finally {
|
} 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 () => {
|
window.addEventListener("DOMContentLoaded", async () => {
|
||||||
try {
|
try {
|
||||||
await loadEvents();
|
await loadEvents();
|
||||||
await loadSyncStatus();
|
await loadSyncStatus();
|
||||||
|
setView("month");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
iosSyncStatus.textContent = e.message;
|
iosSyncStatus.textContent = e.message;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -81,29 +81,6 @@ function ios_ics_unfold(string $ics): string
|
|||||||
return preg_replace("/\n[ \t]/", '', $ics);
|
return preg_replace("/\n[ \t]/", '', $ics);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 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 [];
|
|
||||||
}
|
|
||||||
$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}>
|
* @return list<array{external_uid:string,title:string,description:string,location:string,start_at:string,end_at:string}>
|
||||||
*/
|
*/
|
||||||
@@ -182,6 +159,70 @@ function ios_parse_vevents_from_ics(string $ics): array
|
|||||||
return $events;
|
return $events;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Associe chaque fragment iCalendar du REPORT à l’URL de ressource CalDAV (nécessaire pour PUT/DELETE sur iCloud).
|
||||||
|
*
|
||||||
|
* @return list<array{resource_url:string,ical:string}>
|
||||||
|
*/
|
||||||
|
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<string>
|
||||||
|
*/
|
||||||
|
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<array<string,mixed>> champs événement + _resource_url + _calendar_collection_url
|
||||||
|
*/
|
||||||
function ios_fetch_remote_events(array $integration, string $password): array
|
function ios_fetch_remote_events(array $integration, string $password): array
|
||||||
{
|
{
|
||||||
$calUrl = rtrim($integration['calendar_url'], '/') . '/';
|
$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)) {
|
if (!in_array($res['code'], [200, 207], true)) {
|
||||||
throw new RuntimeException('Lecture CalDAV (REPORT) impossible (HTTP ' . $res['code'] . ').');
|
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 = [];
|
$byUid = [];
|
||||||
foreach ($bodies as $fragment) {
|
foreach ($fragments as $frag) {
|
||||||
foreach (ios_parse_vevents_from_ics($fragment) as $ev) {
|
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;
|
$byUid[$ev['external_uid']] = $ev;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if ($byUid === [] && strpos($res['body'], '<multistatus') === false && strpos($res['body'], 'multistatus') === false) {
|
if ($byUid === [] && strpos($res['body'], 'multistatus') === false) {
|
||||||
$get = ios_caldav_request($calUrl, $integration['username'], $password, 'GET', null, ['Accept: text/calendar']);
|
$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')) {
|
if ($get['code'] >= 200 && $get['code'] < 400 && str_contains($get['body'], 'BEGIN:VEVENT')) {
|
||||||
foreach (ios_parse_vevents_from_ics($get['body']) as $ev) {
|
foreach (ios_parse_vevents_from_ics($get['body']) as $ev) {
|
||||||
|
$ev['_resource_url'] = null;
|
||||||
|
$ev['_calendar_collection_url'] = $calUrl;
|
||||||
$byUid[$ev['external_uid']] = $ev;
|
$byUid[$ev['external_uid']] = $ev;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -222,16 +267,37 @@ function ios_fetch_remote_events(array $integration, string $password): array
|
|||||||
return array_values($byUid);
|
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<array<string,mixed>>
|
||||||
|
*/
|
||||||
|
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');
|
$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);
|
$ics = ios_make_ics($event);
|
||||||
return ios_caldav_request($url, $integration['username'], $password, 'PUT', $ics);
|
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: */*']);
|
return ios_caldav_request($url, $integration['username'], $password, 'DELETE', null, ['Accept: */*']);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user