v51b DAV 例外:时区换算 + RECURRENCE-ID/EXDATE/多 VEVENT 不再被丢

根因三处(此前一个资源只能有一个 VEVENT、只认前 9 个字段):
 · Ics::normalizeVevent() 固定 9 字段,没有 recurrence_id/exdate;
 · dav.php 只取 $list[0](覆盖行全丢);
 · calendar_events 没有对应列。

修法:
 · 时区 —— 带 Z / TZID 的值按 zone 换算成本地墙钟,浮动值不动;
   UNTIL 一起归一(必须与 DTSTART 同值类型/同基准);DTSTAMP/CREATED/
   LAST-MODIFIED 仍按 RFC 保留 UTC。
 · 例外 —— 新增 calendar_event_overrides 卫星表(uk(event_id,recurrence_id),
   FK CASCADE)存覆盖行;EXDATE 落既有的 calendar_event_skips(与网页端
   「只删这一次」共用);PUT 的例外语义 = 权威替换。
 · 未建模属性(X-* 等)落 calendar_events.extra 原样回吐,
   表单保存不认识时不动它(防网页编辑抹掉客户端的 X-*)。
 · 「RECURRENCE-ID + STATUS:CANCELLED」折算成 EXDATE(RFC 5545 §3.8.4.4
   与 EXDATE 等价),并保留"正文里只有覆盖行"的退化路径。
 · ETag / ctag 必须含例外,否则只改「某一次」别的设备永远拉不到。
 · 网页月历 month() 认覆盖行(按新时间画、原时间不留影、不被 EXDATE 那天画)。

Schema 33 → 34(VERSION 人读 + fingerprint() 才是真正判据)。

验证:Ics 离线单测 30/30;端到端 DAV 例外探针 56/56;v45 既有回归 24/24。
反证:退回旧行为 → 6 条断言变红(网页上"已取消"的那次仍被画出来)。
This commit is contained in:
2026-10-15 20:19:09 +08:00
parent 4fbac180e1
commit ef574d82a4
5 changed files with 864 additions and 50 deletions
+90 -4
View File
@@ -731,14 +731,85 @@ if ($method === 'PUT') {
if (!$list) {
dav_fail(415, '正文里没有找到 VEVENT。');
}
$v = $list[0]; // 一个资源 = 一个 VEVENT(重复系列整份在一个 VEVENT 里)
if (empty($v['starts_at'])) {
/* 🔴🔴 v52b(2026-10-15):「一个资源里可以有**多个 VEVENT**」。
重复系列的标准形态 = 主事件(无 `RECURRENCE-ID`)+ 若干**覆盖行**
(带 `RECURRENCE-ID` = 被替换的那次**原发生时刻**,各自己的 `DTSTART` = 改到的新时间)。
见 RFC 4791 §4.1 与 RFC 5545 §3.8.4.4;Google / 苹果 / 雷鸟 / SyncCalendar
表达"仅这一次改到别的时间"用的就是它。
⚠️ 原来这里写死 `$list[0]` ⇒ 覆盖行**全被丢掉**,于是:
· 用户在手机上"把周三那次改到周五"→ 同步过去只剩原系列 ⇒ 改动**丢失**;
· 而且因为"那一次"没被排除,下一轮拉取又把它画回原时间(幽灵日程)。
修法:主事件与覆盖行分开收,主事件走原来的 upsert,覆盖行走 replaceOverrides。 */
$v = null; // 主事件(应当是**没有** RECURRENCE-ID 的那条)
$ovrs = []; // 覆盖行
/* 「取消某一次」在真实客户端里有**两种**表达,语义完全等价(RFC 5545 §3.8.4.4):
· `EXDATE` —— 记进 `skip_dates`(SynCalendar / 苹果 / 雷鸟走的都是这条);
· `RECURRENCE-ID` + `STATUS:CANCELLED` 的覆盖行 —— 也被本服务器按"已取消"收留。
🔴 形态二以前是被**当成普通覆盖行**存下来的 ⇒ 网页日历在系列里那次照样画一条
(只是标着已取消),而且 `Repeat::occurrences()` 里那次**根本没被排除**。
这里统一折算成 skip 日期,两种流派就都对了。 */
$cancelled = []; // 形态二的覆盖行(只在"正文里只有它"时要退化成一条普通日程)
$cancelDays = []; // 它们等价排除了哪几天(`Y-m-d`)
foreach ($list as $one) {
$ridRaw = trim((string)($one['recurrence_id'] ?? ''));
if ($ridRaw === '') {
if ($v === null) {
$v = $one;
}
// 同一份正文里出现**多个**无 RECURRENCE-ID 的 VEVENT:取第一个,
// 其余无法表达(一个资源只能有一个主事件),宁可少存不可乱存。
continue;
}
if (strtolower((string)($one['status'] ?? '')) === 'cancelled') {
$cancelled[] = $one;
$cd = Calendar::ridDay($ridRaw);
if ($cd !== '') {
$cancelDays[$cd] = true;
}
continue;
}
$ovrs[] = $one;
}
if ($v === null) {
// 极端形态:正文里只有覆盖行(某些客户端"单独移动/取消这一次"会这么发)。
// 这时**必须**把它当普通日程存下来 —— 直接 415 会让这条日程整个消失。
$from = $ovrs ? 'ovr' : ($cancelled ? 'cx' : '');
if ($from === 'ovr') {
$v = array_shift($ovrs);
} elseif ($from === 'cx') {
$v = array_shift($cancelled);
}
if ($from !== '') {
// ⚠️ 它自己就是这条日程,别把自己的发生日当成"被排除的一天"排除掉,
// 否则这条日程会在网页上整个消失(只有"取消",连"是什么"都看不到)。
$sd = Calendar::ridDay(trim((string)($v['recurrence_id'] ?? '')));
if ($sd !== '') {
unset($cancelDays[$sd]);
}
unset($v['recurrence_id']);
}
}
if ($v === null || empty($v['starts_at'])) {
dav_fail(422, 'VEVENT 缺 DTSTART。');
}
if (trim((string)($v['title'] ?? '')) === '') {
$v['title'] = '(无标题日程)';
}
$uidIn = (string)($v['uid'] ?? '');
/* 这次要落的「例外」。⚠️ 语义是**权威替换**(PUT 的正文就是这条资源的全部真相,
见 RFC 4791 §5.3.2):本次没出现的覆盖行/EXDATE 一律删掉 ——
只增不减的话,用户在客户端"把某次改回来",服务器上那条记录永远删不掉。
⚠️ EXDATE 通常写在主事件上,但 RFC 允许写在任意一个 VEVENT 里 ⇒ 全部并起来。 */
$skipDates = (array)($v['skip_dates'] ?? []);
foreach ($ovrs as $one) {
$skipDates = array_merge($skipDates, (array)($one['skip_dates'] ?? []));
}
// 「RECURRENCE-ID + STATUS:CANCELLED」折算出来的排除日(见上面分组处)
foreach (array_keys($cancelDays) as $cd) {
$skipDates[] = $cd;
}
$skipDates = array_values(array_unique($skipDates));
// 是不是"改已有的":**优先按资源名(uid)找**(客户端 PUT 到 <uid>.ics),
// 再按正文里的 UID 找,最后兼容老式数字-id 资源名。
@@ -776,6 +847,12 @@ if ($method === 'PUT') {
Database::get()->prepare('UPDATE calendar_events SET uid = ? WHERE id = ?')
->execute([CalDav::segmentOf($cur ?: ['id' => $existingId]), $existingId]);
}
/* 重复系列的「例外」(v52b):`EXDATE` = 哪几次被取消、
`RECURRENCE-ID` 覆盖行 = 哪几次被改到别的时间。
⚠️ 必须在算 ETag **之前**落库 —— ETag 由最终正文算出,
否则客户端拿到的 ETag 对应的还是旧正文,下轮 If-Match 会莫名 412。 */
Calendar::setSkips($existingId, $skipDates);
Calendar::replaceOverrides($existingId, $ovrs);
$after = CalDav::oneEvent((int)$cal['id'], $existingId, [$familyId, $me]);
$et = CalDav::resourceOf($after ?: $cur, (string)$cal['name'])['etag'];
http_response_code(204);
@@ -794,6 +871,12 @@ if ($method === 'PUT') {
$rid = (int)$st->fetchColumn();
$row = $rid > 0 ? CalDav::oneEvent((int)$cal['id'], $rid, [$familyId, $me]) : null;
}
/* 重复系列的「例外」:落库后再算 ETag(原因见上面更新那一段的注释)。 */
$exId = $row ? (int)$row['id'] : $newId;
if ($exId > 0) {
Calendar::setSkips($exId, $skipDates);
Calendar::replaceOverrides($exId, $ovrs);
}
$et = $row ? CalDav::resourceOf($row, (string)$cal['name'])['etag'] : '';
http_response_code(201);
if ($et !== '') {
@@ -809,8 +892,11 @@ if ($method === 'PUT') {
//
// 照参照与 RFC:成功 **204 No Content**;不存在 **404**;本不可写 **403**。
// ⚠️ 删除的是**整条资源**(含重复系列的全部实例)—— CalDAV 里重复系列就是
// 一个资源,客户端要"只删某一次"会先 PUT 改 RRULE + EXDATE(那是另一条路径,
// 我们不能再替他做决定)。这样语义最简单,也与参照一致。
// 一个资源,客户端要"只删某一次"会先 PUT 改 RRULE + EXDATE(那条路径我们已经
// 支持了,见 7b 的 `skip_dates` / 覆盖行),我们不能再替他做决定。
// 这样语义最简单,也与参照一致。
// ⚠️ 覆盖行与 EXDATE 都是 `calendar_events` 的子表(FK ON DELETE CASCADE),
// 所以这一句 DELETE 会把它们一并带走,不需要额外清理。
if ($method === 'DELETE') {
if (!$cal) {
dav_fail(404, '没有这本日历。');