calDAV v5
Deploy HouseHub / deploy (push) Successful in 2s

This commit is contained in:
Cedric
2026-05-13 18:26:09 +02:00
parent d9d9587ded
commit a2e0061dde
4 changed files with 412 additions and 4 deletions
+8
View File
@@ -20,6 +20,14 @@ require __DIR__ . '/header.php';
</div>
</div>
<p class="ios-agenda-intro">Les événements viennent de HouseHub après synchro iCloud. <strong>Synchroniser</strong> met à jour tous les calendriers du compte ; les changements locaux partent au prochain envoi.</p>
<details class="ios-cal-prefs-details" id="ios-cal-prefs-details">
<summary class="ios-cal-prefs-summary">Calendriers affichés et couleurs</summary>
<p class="ios-cal-prefs-help">Décoche un calendrier pour le masquer dans lagenda. La couleur est libre (repère la teinte iOS sur ton téléphone puis copie-la avec le sélecteur).</p>
<div id="ios-calendar-prefs-rows" class="ios-cal-prefs-rows"></div>
<p id="ios-calendar-prefs-msg" class="ios-cal-prefs-msg" role="status"></p>
<button type="button" class="pf-btn" id="ios-calendar-prefs-save">Enregistrer affichage &amp; couleurs</button>
</details>
</section>
<section class="pf-panel-card ios-agenda-card">
+175 -1
View File
@@ -28,6 +28,12 @@ CREATE TABLE IF NOT EXISTS user_calendar_integrations (
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uq_user_provider (user_id, provider)
)");
try {
$meta_pdo->exec('ALTER TABLE user_calendar_integrations ADD COLUMN calendar_prefs_json TEXT NULL');
} catch (Throwable $e) {
// colonne déjà présente
}
$pdo->exec("
CREATE TABLE IF NOT EXISTS pf_calendar_events (
id INT AUTO_INCREMENT PRIMARY KEY,
@@ -90,11 +96,113 @@ function ios_normalize_datetime(?string $s): ?string
return $s;
}
function ios_calendar_url_key(?string $url): string
{
if ($url === null || $url === '') {
return '';
}
return rtrim(trim($url), '/');
}
/** @return array<string, array{visible:bool, color:?string}> */
function ios_calendar_prefs_decode(?string $json): array
{
if ($json === null || $json === '') {
return [];
}
$d = json_decode($json, true);
if (!is_array($d)) {
return [];
}
$out = [];
foreach ($d as $k => $v) {
if (!is_string($k) || $k === '' || !is_array($v)) {
continue;
}
$key = ios_calendar_url_key($k);
if ($key === '') {
continue;
}
$out[$key] = [
'visible' => !array_key_exists('visible', $v) ? true : (bool) $v['visible'],
'color' => (isset($v['color']) && is_string($v['color']) && preg_match('/^#[0-9A-Fa-f]{6}$/', $v['color'])) ? $v['color'] : null,
];
}
return $out;
}
/**
* @return list<array{url:string,url_key:string,display:string,visible:bool,color:?string}>
*/
function ios_list_calendar_sources_for_ui(array $integration, string $password, PDO $pdo): array
{
$prefs = ios_calendar_prefs_decode($integration['calendar_prefs_json'] ?? null);
$urlsMeta = [];
$primary = $integration['calendar_url'] ?? '';
if (hh_caldav_url_is_icloud($primary)) {
try {
foreach (hh_icloud_discover_calendar_entries($integration['username'], $password) as $e) {
$k = ios_calendar_url_key($e['url']);
if ($k === '') {
continue;
}
$disp = trim((string) ($e['display'] ?? ''));
if ($disp === '') {
$disp = basename(parse_url($e['url'], PHP_URL_PATH) ?: '') ?: $k;
}
$urlsMeta[$k] = ['url' => rtrim($e['url'], '/') . '/', 'display' => $disp];
}
} catch (Throwable $e) {
}
} else {
$k = ios_calendar_url_key($primary);
if ($k !== '') {
$urlsMeta[$k] = ['url' => rtrim($primary, '/') . '/', 'display' => 'Calendrier'];
}
}
$stmt = $pdo->query("
SELECT DISTINCT l.calendar_url AS u
FROM pf_calendar_event_links l
INNER JOIN pf_calendar_events e ON e.id = l.calendar_event_id
WHERE e.deleted_at IS NULL AND l.calendar_url IS NOT NULL AND TRIM(l.calendar_url) != ''
");
foreach ($stmt->fetchAll(PDO::FETCH_COLUMN) as $u) {
$k = ios_calendar_url_key((string) $u);
if ($k === '' || isset($urlsMeta[$k])) {
continue;
}
$urlsMeta[$k] = [
'url' => rtrim((string) $u, '/') . '/',
'display' => basename(parse_url((string) $u, PHP_URL_PATH) ?: '') ?: $k,
];
}
$out = [];
foreach ($urlsMeta as $key => $meta) {
$p = $prefs[$key] ?? [];
$visible = !array_key_exists('visible', $p) ? true : (bool) $p['visible'];
$color = (isset($p['color']) && is_string($p['color']) && preg_match('/^#[0-9A-Fa-f]{6}$/', $p['color'])) ? $p['color'] : null;
$out[] = [
'url' => $meta['url'],
'url_key' => $key,
'display' => $meta['display'],
'visible' => $visible,
'color' => $color,
];
}
usort($out, static fn ($a, $b) => strcasecmp($a['display'], $b['display']));
return $out;
}
$integrationStmt = $meta_pdo->prepare("SELECT * FROM user_calendar_integrations WHERE user_id = ? AND provider='icloud_caldav'");
$integrationStmt->execute([$userId]);
$integration = $integrationStmt->fetch();
if ($action === 'events' && $method === 'GET') {
$prefs = [];
if ($integration) {
$prefs = ios_calendar_prefs_decode($integration['calendar_prefs_json'] ?? null);
}
$rows = $pdo->query("
SELECT e.*, l.calendar_url AS calendar_source_url
FROM pf_calendar_events e
@@ -102,7 +210,73 @@ if ($action === 'events' && $method === 'GET') {
WHERE e.deleted_at IS NULL
ORDER BY e.start_at ASC
")->fetchAll();
ios_ok($rows);
$out = [];
foreach ($rows as $r) {
$key = ios_calendar_url_key($r['calendar_source_url'] ?? '');
if ($key !== '' && isset($prefs[$key]['visible']) && $prefs[$key]['visible'] === false) {
continue;
}
$r['display_color'] = null;
if ($key !== '' && !empty($prefs[$key]['color']) && is_string($prefs[$key]['color']) && preg_match('/^#[0-9A-Fa-f]{6}$/', $prefs[$key]['color'])) {
$r['display_color'] = $prefs[$key]['color'];
}
$out[] = $r;
}
ios_ok($out);
}
if ($action === 'calendar_sources' && $method === 'GET') {
if (!$integration) {
ios_ok(['calendars' => []]);
}
try {
$ctx = ios_caldav_prepare($integration, $meta_pdo);
} catch (Throwable $e) {
ios_err('CalDAV: ' . $e->getMessage(), 400);
}
$integrationStmt->execute([$userId]);
$integrationFresh = $integrationStmt->fetch();
if (!$integrationFresh) {
ios_ok(['calendars' => []]);
}
ios_ok(['calendars' => ios_list_calendar_sources_for_ui($integrationFresh, $ctx['password'], $pdo)]);
}
if ($action === 'calendar_prefs' && $method === 'POST') {
ios_require_csrf();
if (!$integration) {
ios_err('Connexion iCloud non configurée.', 400);
}
$body = ios_body();
$raw = $body['prefs'] ?? null;
if (!is_array($raw)) {
ios_err('Format prefs invalide', 400);
}
$clean = [];
foreach ($raw as $key => $p) {
if (!is_array($p)) {
continue;
}
$urlKey = ios_calendar_url_key(is_string($key) ? $key : '');
if ($urlKey === '') {
continue;
}
$visible = array_key_exists('visible', $p) ? (bool) $p['visible'] : true;
$col = $p['color'] ?? null;
if ($col !== null && $col !== '' && is_string($col) && preg_match('/^#[0-9A-Fa-f]{6}$/', $col)) {
$col = '#' . strtolower(substr($col, 1));
} else {
$col = null;
}
$clean[$urlKey] = ['visible' => $visible, 'color' => $col];
}
$existing = ios_calendar_prefs_decode($integration['calendar_prefs_json'] ?? null);
foreach ($clean as $urlKey => $v) {
$existing[$urlKey] = $v;
}
$json = json_encode($existing, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
$meta_pdo->prepare('UPDATE user_calendar_integrations SET calendar_prefs_json = ? WHERE id = ?')->execute([$json, $integration['id']]);
ios_ok(['saved' => true]);
}
if ($action === 'events' && $method === 'POST') {
+100 -2
View File
@@ -24,6 +24,102 @@
line-height: 1.45;
}
.ios-cal-prefs-details {
margin-top: 14px;
border: 1px solid var(--pf-border, #e2e8f0);
border-radius: 10px;
padding: 10px 14px;
background: var(--pf-bg-page, #fff);
}
.ios-cal-prefs-summary {
cursor: pointer;
font-weight: 600;
font-size: 0.95rem;
}
.ios-cal-prefs-help {
margin: 10px 0 12px;
font-size: 0.85rem;
color: var(--pf-text-muted, #64748b);
line-height: 1.45;
}
.ios-cal-prefs-rows {
display: flex;
flex-direction: column;
gap: 10px;
margin-bottom: 12px;
}
.ios-cal-pref-row {
display: grid;
grid-template-columns: auto 1fr auto auto;
align-items: center;
gap: 10px 12px;
padding: 8px 10px;
border: 1px solid var(--pf-border, #e2e8f0);
border-radius: 8px;
background: var(--pf-bg-lighter, #f8fafc);
}
.ios-cal-pref-row input[type="checkbox"] {
width: 18px;
height: 18px;
}
.ios-cal-pref-label {
font-size: 0.88rem;
min-width: 0;
word-break: break-word;
}
.ios-cal-pref-color {
width: 44px;
height: 36px;
padding: 2px;
border: 1px solid var(--pf-border, #e2e8f0);
border-radius: 8px;
cursor: pointer;
background: var(--pf-bg-page, #fff);
}
.ios-cal-pref-reset {
border: none;
background: transparent;
color: var(--pf-text-muted, #64748b);
font-size: 0.78rem;
text-decoration: underline;
cursor: pointer;
padding: 4px;
font-family: inherit;
white-space: nowrap;
}
.ios-cal-pref-reset:hover {
color: var(--pf-text-main, #0f172a);
}
.ios-cal-prefs-msg {
min-height: 1.25em;
font-size: 0.85rem;
margin: 0 0 10px;
}
@media (max-width: 640px) {
.ios-cal-pref-row {
grid-template-columns: auto 1fr;
grid-template-rows: auto auto;
}
.ios-cal-pref-color {
grid-column: 2;
}
.ios-cal-pref-reset {
grid-column: 2;
justify-self: start;
}
}
.ios-form-grid {
display: grid;
grid-template-columns: 1fr 1fr;
@@ -349,8 +445,10 @@
box-shadow: inset 3px 0 0 rgba(255, 255, 255, 0.35);
}
.ios-event-block:hover {
filter: brightness(1.08);
.ios-event-block--custom {
border: none !important;
color: #fff !important;
box-shadow: inset 3px 0 0 rgba(255, 255, 255, 0.35) !important;
}
.ios-event-block-title {
+129 -1
View File
@@ -26,6 +26,8 @@ const PALETTE = [
"ios-cal-c5",
];
const SUGGEST_HEX = ["#22c55e", "#3b82f6", "#8b5cf6", "#eab308", "#14b8a6", "#f43f5e"];
function escHtml(s) {
const d = document.createElement("div");
d.textContent = s ?? "";
@@ -50,6 +52,21 @@ function parseEvtDate(iso) {
return Number.isNaN(d.getTime()) ? null : d;
}
function darkenHex(hex, amount) {
const n = parseInt(hex.slice(1), 16);
const r = Math.max(0, (n >> 16) - amount);
const g = Math.max(0, ((n >> 8) & 0xff) - amount);
const b = Math.max(0, (n & 0xff) - amount);
return `#${[r, g, b].map((x) => x.toString(16).padStart(2, "0")).join("")}`;
}
function suggestedColorForKey(key) {
const k = String(key || "");
let h = 0;
for (let i = 0; i < k.length; i++) h = (h * 31 + k.charCodeAt(i)) >>> 0;
return SUGGEST_HEX[h % SUGGEST_HEX.length];
}
function colorClassForEvent(evt) {
const key = String(evt.calendar_source_url || evt.external_uid || evt.id || "");
let h = 0;
@@ -57,6 +74,22 @@ function colorClassForEvent(evt) {
return PALETTE[h % PALETTE.length];
}
/** Applique couleur perso (API) ou palette par défaut */
function styleEventBlock(el, evt) {
el.className = "ios-event-block";
el.style.background = "";
el.style.boxShadow = "";
const hex = evt.display_color;
if (hex && /^#[0-9A-Fa-f]{6}$/i.test(hex)) {
const c = hex.startsWith("#") ? hex : `#${hex.slice(-6)}`;
el.classList.add("ios-event-block--custom");
el.style.background = `linear-gradient(135deg, ${darkenHex(c, 40)} 0%, ${c} 100%)`;
el.style.boxShadow = "inset 3px 0 0 rgba(255,255,255,0.35)";
} else {
el.classList.add(colorClassForEvent(evt));
}
}
function mondayOf(date) {
const d = new Date(date.getFullYear(), date.getMonth(), date.getDate());
const dow = d.getDay();
@@ -243,7 +276,7 @@ function buildDayColumn(dayMidnight) {
const left = (100 * seg._lane) / seg._laneCount;
const blk = document.createElement("button");
blk.type = "button";
blk.className = `ios-event-block ${colorClassForEvent(seg.evt)}`;
styleEventBlock(blk, seg.evt);
blk.style.top = `${top}px`;
blk.style.height = `${h}px`;
blk.style.left = `calc(${left}% + 2px)`;
@@ -442,6 +475,9 @@ async function loadEvents() {
events.forEach((evt) => {
const card = document.createElement("div");
card.className = "ios-event-card";
if (evt.display_color && /^#[0-9A-Fa-f]{6}$/i.test(evt.display_color)) {
card.style.borderLeft = `4px solid ${evt.display_color}`;
}
card.innerHTML = `
<div class="ios-event-card-head">
<div>
@@ -476,6 +512,94 @@ async function loadSyncStatus() {
iosSyncStatus.textContent = status.message;
}
async function loadCalendarPrefs() {
const wrap = document.getElementById("ios-calendar-prefs-rows");
const msg = document.getElementById("ios-calendar-prefs-msg");
if (!wrap) return;
if (msg) msg.textContent = "";
wrap.innerHTML = "<span class=\"ios-agenda-intro\">Chargement des calendriers…</span>";
try {
const data = await iosApi("calendar_sources");
const rows = data.calendars || [];
wrap.innerHTML = "";
if (!rows.length) {
wrap.innerHTML =
"<span class=\"ios-agenda-intro\">Aucun calendrier détecté. Configure iCloud dans Paramètres puis synchronise.</span>";
return;
}
rows.forEach((row, idx) => {
const id = `ios-cal-cb-${idx}`;
const rowEl = document.createElement("div");
rowEl.className = "ios-cal-pref-row";
rowEl.dataset.urlKey = row.url_key;
const cb = document.createElement("input");
cb.type = "checkbox";
cb.id = id;
cb.checked = row.visible !== false;
const lab = document.createElement("label");
lab.className = "ios-cal-pref-label";
lab.htmlFor = id;
lab.textContent = row.display || row.url_key;
const colInp = document.createElement("input");
colInp.type = "color";
colInp.className = "ios-cal-pref-color";
colInp.value = row.color || suggestedColorForKey(row.url_key);
colInp.title = "Couleur dans lagenda";
const autoBtn = document.createElement("button");
autoBtn.type = "button";
autoBtn.className = "ios-cal-pref-reset";
autoBtn.textContent = "Palette auto";
autoBtn.title = "Couleurs HouseHub par défaut (sans teinte iOS)";
autoBtn.addEventListener("click", () => {
rowEl.dataset.paletteAuto = "1";
colInp.disabled = true;
colInp.style.opacity = "0.35";
});
rowEl.appendChild(cb);
rowEl.appendChild(lab);
rowEl.appendChild(colInp);
rowEl.appendChild(autoBtn);
wrap.appendChild(rowEl);
});
} catch (e) {
wrap.innerHTML = `<span class="ios-agenda-intro">${escHtml(e.message)}</span>`;
}
}
async function saveCalendarPrefs() {
const msg = document.getElementById("ios-calendar-prefs-msg");
const wrap = document.getElementById("ios-calendar-prefs-rows");
if (!wrap) return;
const prefs = {};
wrap.querySelectorAll(".ios-cal-pref-row").forEach((rowEl) => {
const key = rowEl.dataset.urlKey;
if (!key) return;
const vis = rowEl.querySelector('input[type="checkbox"]')?.checked === true;
const colInp = rowEl.querySelector(".ios-cal-pref-color");
let color = null;
if (!rowEl.dataset.paletteAuto && colInp && !colInp.disabled) {
const v = colInp.value;
if (v && /^#[0-9A-Fa-f]{6}$/i.test(v)) {
color = v.startsWith("#") ? v.toLowerCase() : `#${v}`.toLowerCase();
}
}
prefs[key] = { visible: vis, color };
});
try {
await iosApi("calendar_prefs", { method: "POST", body: { prefs } });
if (msg) msg.textContent = "Préférences enregistrées.";
await loadEvents();
await loadCalendarPrefs();
} catch (e) {
if (msg) msg.textContent = e.message;
}
}
iosForm.addEventListener("submit", async (e) => {
e.preventDefault();
const idStr = document.getElementById("ios-event-id").value.trim();
@@ -505,6 +629,7 @@ document.getElementById("ios-sync-btn")?.addEventListener("click", async () => {
const data = await iosApi("sync", { method: "POST", body: {} });
if (iosSyncStatus) iosSyncStatus.textContent = data.message;
await loadEvents();
await loadCalendarPrefs();
} finally {
btn.disabled = false;
}
@@ -558,10 +683,13 @@ document.getElementById("ios-agenda-add")?.addEventListener("click", () => {
document.getElementById("ios-title")?.focus();
});
document.getElementById("ios-calendar-prefs-save")?.addEventListener("click", () => saveCalendarPrefs());
window.addEventListener("DOMContentLoaded", async () => {
try {
iosCursor = new Date();
await loadEvents();
await loadCalendarPrefs();
await loadSyncStatus();
showListSection(false);
startNowTimer();