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:
+57
-2
@@ -272,10 +272,39 @@ final class CalDav
|
||||
*/
|
||||
public static function resourceOf(array $r, string $calName): array
|
||||
{
|
||||
$ics = Ics::export([$r], $calName);
|
||||
$ics = Ics::export([self::withExceptions($r)], $calName);
|
||||
return ['ics' => $ics, 'etag' => '"' . md5($ics) . '"'];
|
||||
}
|
||||
|
||||
/**
|
||||
* 给一条日程行补上它的「重复系列例外」——
|
||||
* `_skip_dates`(= `EXDATE`,哪几次被取消)与 `_overrides`(= `RECURRENCE-ID` 覆盖行)。
|
||||
*
|
||||
* 🔴 v52b(2026-10-15):这两样必须进正文,也必须进 ETag。
|
||||
* 只进正文不进 ETag 的话:客户端推完覆盖行 → 服务器存下了 →
|
||||
* 但 ETag 没变 ⇒ 客户端拿 If-Match 再推一次时**判不出"已经是最新的"**,
|
||||
* 严重时会被判成"服务器没接受"而反复重推(甚至回滚自己的改动)。
|
||||
* 所以 ETag 由**最终正文**算(本函数产出的东西直接进 Ics::export)。
|
||||
*
|
||||
* ⚠️ `isset` 判据是**故意**的:`exportRows()` 出来的行已经带好了这两样
|
||||
* (批量查过,见 Calendar::exportRows),这里就别再逐条查 ——
|
||||
* 整本 REPORT 时那会变成 N+1。
|
||||
*/
|
||||
public static function withExceptions(array $r): array
|
||||
{
|
||||
$eid = (int)($r['id'] ?? 0);
|
||||
if ($eid <= 0) {
|
||||
return $r;
|
||||
}
|
||||
if (!isset($r['_skip_dates'])) {
|
||||
$r['_skip_dates'] = Calendar::skipsOf($eid);
|
||||
}
|
||||
if (!isset($r['_overrides'])) {
|
||||
$r['_overrides'] = Calendar::overridesOf($eid);
|
||||
}
|
||||
return $r;
|
||||
}
|
||||
|
||||
/**
|
||||
* 日历本的 ctag 种子 —— 客户端拿 ctag 判「这本整体变没变」。
|
||||
*
|
||||
@@ -295,7 +324,33 @@ final class CalDav
|
||||
);
|
||||
$st->execute([(int)$cal['id']]);
|
||||
$r = $st->fetch() ?: [];
|
||||
return (string)($r['n'] ?? 0) . '|' . (string)($r['mx'] ?? '') . '|' . (string)($r['sq'] ?? 0);
|
||||
/* 🔴 v52b:**例外也要算进 ctag** ——
|
||||
客户端拿 ctag 判"这本整体变没变",没变就跳过整轮拉取。
|
||||
"只改其中一次"(取消某次 / 把某次挪到别的时间)**不动 calendar_events 那一行**
|
||||
(`updateEvent` 都没被调),所以只按上面那个种子算的话 ctag 恒定不变 ⇒
|
||||
客户端以为没变、永远拉不到这次改动(表现:"我在手机上改的,另一台设备上看不见")。 */
|
||||
$seed = (string)($r['n'] ?? 0) . '|' . (string)($r['mx'] ?? '') . '|' . (string)($r['sq'] ?? 0);
|
||||
|
||||
$st2 = Database::get()->prepare(
|
||||
'SELECT COUNT(*) AS n, COALESCE(SUM(CAST(o.sequence AS SIGNED)), 0) AS sq,
|
||||
COALESCE(MAX(o.updated_at), \'\') AS mx
|
||||
FROM calendar_event_overrides o
|
||||
JOIN calendar_events e ON e.id = o.event_id
|
||||
WHERE e.calendar_id = ?'
|
||||
);
|
||||
$st2->execute([(int)$cal['id']]);
|
||||
$o = $st2->fetch() ?: [];
|
||||
$seed .= '|' . (string)($o['n'] ?? 0) . '|' . (string)($o['mx'] ?? '') . '|' . (string)($o['sq'] ?? 0);
|
||||
|
||||
$st3 = Database::get()->prepare(
|
||||
'SELECT COUNT(*) AS n, COALESCE(MAX(s.created_at), \'\') AS mx
|
||||
FROM calendar_event_skips s
|
||||
JOIN calendar_events e ON e.id = s.event_id
|
||||
WHERE e.calendar_id = ?'
|
||||
);
|
||||
$st3->execute([(int)$cal['id']]);
|
||||
$s = $st3->fetch() ?: [];
|
||||
return $seed . '|' . (string)($s['n'] ?? 0) . '|' . (string)($s['mx'] ?? '');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user