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:
@@ -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, '没有这本日历。');
|
||||
|
||||
Reference in New Issue
Block a user