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
+299 -28
View File
@@ -415,11 +415,9 @@ final class Calendar
*/
public static function updateEvent(int $id, array $d): void
{
Database::get()->prepare(
'UPDATE calendar_events SET title=?, location=?, starts_at=?, ends_at=?, all_day=?,
reminder_minutes=?, status=?, transp=?, rrule=?, note=?, sequence = sequence + 1
WHERE id=?'
)->execute([
$sets = 'title=?, location=?, starts_at=?, ends_at=?, all_day=?,
reminder_minutes=?, status=?, transp=?, rrule=?, note=?';
$args = [
mb_substr(trim((string)$d['title']), 0, 200, 'UTF-8'),
self::textOrNull((string)($d['location'] ?? ''), 255),
$d['starts_at'],
@@ -430,8 +428,19 @@ final class Calendar
!empty($d['transp']) ? 1 : 0,
self::textOrNull((string)($d['rrule'] ?? ''), 255),
self::textOrNull($d['note'] ?? ''),
$id,
]);
];
/* ⚠️ `extra`(未建模的 VEVENT 属性)**只在调用方显式给了才写**。
网页的编辑表单不认识这个字段 —— 无条件写 NULL 的话,用户在网页上
改一次标题,CalDAV 客户端写进来的 `X-*` 就被悄悄抹掉了。
「没说」=「别动」,这条对往返保真很关键(与 reminder_minutes 不同:
那一个表单一定会给,所以照旧无条件写)。 */
if (array_key_exists('extra', $d)) {
$sets .= ', extra=?';
$args[] = self::textOrNull((string)$d['extra']);
}
$sets .= ', sequence = sequence + 1';
$args[] = $id;
Database::get()->prepare("UPDATE calendar_events SET {$sets} WHERE id=?")->execute($args);
}
public static function deleteEvent(int $id): void
@@ -504,6 +513,156 @@ final class Calendar
return array_map(static fn($r) => (string)$r['skip_date'], $st->fetchAll());
}
/**
* 把这条日程的「例外日」**整组替换**成给定的几天(CalDAV PUT 的权威语义)。
*
* ⚠️ 为什么是"整组替换"而不是"逐个增删":CalDAV 的 PUT 语义是
* **客户端推上来的整份资源就是这条日程的全部真相**(RFC 4791 §5.3.2)。
* 只增不减的话,用户在客户端把某次"恢复"回来,服务器上那条排除记录
* 永远删不掉 —— 表现为"取消了又改回来,可它还是不出现"。
* ⚠️ 传空数组 = 清空(把之前排除的全恢复)。
*/
public static function setSkips(int $eventId, array $datesYmd): void
{
$db = Database::get();
$clean = [];
foreach ($datesYmd as $d) {
$d = substr(trim((string)$d), 0, 10);
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $d)) {
$clean[$d] = true;
}
}
$db->beginTransaction();
try {
$db->prepare('DELETE FROM calendar_event_skips WHERE event_id = ?')->execute([$eventId]);
if ($clean) {
$ins = $db->prepare(
'INSERT INTO calendar_event_skips (event_id, skip_date) VALUES (?,?)'
);
foreach (array_keys($clean) as $d) {
$ins->execute([$eventId, $d]);
}
}
$db->commit();
} catch (\Throwable $e) {
$db->rollBack();
throw $e;
}
}
/**
* `RECURRENCE-ID`(ICS 形态:`20261021T090000` / `20261021`)→ `Y-m-d`。
*
* 用来回答"这个覆盖行替换的是哪一天的发生",也是网页日历把覆盖行
* 挪到新日期去画时的对齐依据。解析不出来就返回空串(调用方跳过)。
*
* ⚠️ 传进来的值必须已经是**本地浮动**形态(`Ics::parse()` 出来的
* `recurrence_id` 就是这样),否则带 `Z`/`TZID` 的值会被截到**前一天**。
* `public` 是因为 `dav.php` 也要用它把"取消某一次"折算成 EXDATE。
*/
public static function ridDay(string $rid): string
{
if (preg_match('/^(\d{4})(\d{2})(\d{2})/', trim($rid), $m)) {
return $m[1] . '-' . $m[2] . '-' . $m[3];
}
return '';
}
/**
* 一条日程的**覆盖行**(重复系列里被改到别的时间的某几次)。
*
* @return array<int,array> 按 recurrence_id 升序
*/
public static function overridesOf(int $eventId): array
{
$st = Database::get()->prepare(
'SELECT * FROM calendar_event_overrides WHERE event_id = ? ORDER BY recurrence_id'
);
$st->execute([$eventId]);
return $st->fetchAll();
}
/**
* 一批日程的覆盖行(`[event_id => [行…]]`)—— 导出/资源列表用。
*
* ⚠️ 单独提供一个批量入口是为了**避免 N+1**:一本日历几百条日程时,
* 逐条查一次覆盖行会让 REPORT 从"一次查询"变成"几百次"。
*
* @param array<int,int|string> $eventIds
*/
public static function overridesFor(array $eventIds): array
{
$ids = [];
foreach ($eventIds as $i) {
$i = (int)$i;
if ($i > 0) {
$ids[$i] = true;
}
}
if (!$ids) {
return [];
}
$in = implode(',', array_fill(0, count($ids), '?'));
$st = Database::get()->prepare(
"SELECT * FROM calendar_event_overrides WHERE event_id IN ($in) ORDER BY event_id, recurrence_id"
);
$st->execute(array_keys($ids));
$out = [];
foreach ($st->fetchAll() as $r) {
$out[(int)$r['event_id']][] = $r;
}
return $out;
}
/**
* 把一条日程的覆盖行**整组替换**(CalDAV PUT 的权威语义)。
*
* @param array<int,array> $rows 每行至少要有 recurrence_id / starts_at;
* title 为空时回落到"(无标题日程)"
*/
public static function replaceOverrides(int $eventId, array $rows): void
{
$db = Database::get();
$db->beginTransaction();
try {
$db->prepare('DELETE FROM calendar_event_overrides WHERE event_id = ?')->execute([$eventId]);
if ($rows) {
$ins = $db->prepare(
'INSERT INTO calendar_event_overrides
(event_id, recurrence_id, title, location, starts_at, ends_at, all_day,
note, status, sequence, extra)
VALUES (?,?,?,?,?,?,?,?,?,?,?)'
);
foreach ($rows as $r) {
$rid = trim((string)($r['recurrence_id'] ?? ''));
// 没有 RECURRENCE-ID 的行不是覆盖行(那应该是主事件)——跳过,
// 免得把一条普通日程塞成"替换第 0 次"这种说不通的东西。
if ($rid === '' || empty($r['starts_at'])) {
continue;
}
$title = trim((string)($r['title'] ?? ''));
$ins->execute([
$eventId,
mb_substr($rid, 0, 64, 'UTF-8'),
mb_substr($title !== '' ? $title : '(无标题日程)', 0, 200, 'UTF-8'),
self::textOrNull((string)($r['location'] ?? ''), 255),
(string)$r['starts_at'],
($r['ends_at'] ?? null) ?: null,
!empty($r['all_day']) ? 1 : 0,
self::textOrNull((string)($r['note'] ?? '')),
self::statusOf($r['status'] ?? null),
(int)($r['sequence'] ?? 0),
self::textOrNull((string)($r['extra'] ?? '')),
]);
}
}
$db->commit();
} catch (\Throwable $e) {
$db->rollBack();
throw $e;
}
}
/**
* 把"重复日程里被改掉的某一次"落成一条**独立的单次日程**。
*
@@ -567,7 +726,7 @@ final class Calendar
$from = '1970-01-01';
$to = '2100-01-01';
$out = [];
$seen = [];
$gens = []; // key => 同一个节点铺出来的各天(v52:要合成一条跨天行,见下)
$want = (int)$cal['id'];
foreach (self::month($familyId, [$cal], $me, $from, $to) as $e) {
// 🔴 v31 修的真 bug:`month()` 里**任务**与**纪念日**那两段是
@@ -580,18 +739,63 @@ final class Calendar
if ((int)($e['cal'] ?? 0) !== $want) {
continue;
}
$key = $e['kind'] . '-' . $e['ref_id'] . '-' . $e['date'];
if (isset($seen[$key])) {
continue; // 跨天日程会逐天出一条,导出时一条就够
/* ⚠️ 分组键**不能含日期**:跨天条目会被 `month()` 逐天铺出来,
含日期就各成一组(每天一条 VEVENT、UID 还相同)——实测那个 bug 的
前半段就是这样:跨天住宿出 3 条,被 resources() 按 UID 去重后
只留**第一天**那条,而第一天那条没有结束时刻 ⇒ 又变成 DTEND=DTSTART。
所以键必须是"这一个节点"的标识:kind + ref_id。 */
$key = $e['kind'] . '-' . (int)$e['ref_id'];
/* 🔴🔴 v52(用户 2026-10-15 第 1 条):「旅程里面的行程,通过 CalDAV 同步了之后,
时间是错的……它只有开始时间,**结束时间和开始时间是一样的**。」
真根因:这里对**派生条目**(观影 / 旅程)一律写死 `'ends_at' => null`,
而 `Ics::vevent()` 遇到空的结束时刻会退化成「`DTEND` = `DTSTART`」
(那行注释写的是"没有结束时刻就按开始时刻算,给一个等于开始的 DTEND 最稳")
⇒ 同步到手机 / 雷鸟上,每条行程的结束时间都等于开始时间。
⚠️ 修法要点:`month()` 把**跨天条目逐天**铺出来(一天一条),
每条各自带/不带 `end_time`(结束时刻**只在最后一天**给,
与自定义日程同一口径)。所以**不能**在这里"第一天直接用 end_time"——
那会把结束时刻贴到开始那天上。
⇒ 改为:**同一个节点(同 key)先收集它铺出来的所有天**,
再用"第一天的时间 + 最后一天的时间"拼成**一条**跨天行。
这样才得到真正的 `DTSTART → DTEND` 跨度。
⚠️ `uid` 仍是 `kind-ref_id@familybook`(不含日期)—— CalDAV 资源按 UID 去重,
UID 带日期会让一个节点变成 N 个资源(参见 CalDav::resources 的说明)。 */
if (!isset($gens[$key])) {
$gens[$key] = [];
}
$seen[$key] = true;
$day = (string)$e['date'];
$gens[$key][] = $e;
}
foreach ($gens as $key => $occ) {
// 按日期排序,取首末两天
usort($occ, static function (array $a, array $b): int {
return strcmp((string)$a['date'], (string)$b['date']);
});
$firstDay = (string)$occ[0]['date'];
$lastDay = (string)$occ[count($occ) - 1]['date'];
$firstT = (string)($occ[0]['time'] ?? ''); // 'HH:MM' 或 ''
$allDay = empty($firstT);
$starts = $firstDay . ' ' . ($allDay ? '00:00:00' : $firstT . ':00');
/* 结束时刻:从**最后一天**那一条里取 `end_time`(`month()` 只在末天给)。
⚠️ 末天那条若也没有 end_time(本来就没填结束)⇒ 留 null,
退回"DTEND = DTSTART"的旧行为(服务端渲染那边也是"没填结束")。
⚠️ 结束若不在开始之后(数据倒挂)⇒ 也留 null,别导出负时长。 */
$lastT = (string)($occ[count($occ) - 1]['end_time'] ?? '');
$ends = null;
if ($lastT !== '') {
$ends = $lastDay . ' ' . $lastT . ':00';
if ($ends <= $starts) {
$ends = null;
}
}
$e = $occ[0];
$out[] = [
'uid' => $e['kind'] . '-' . (int)$e['ref_id'] . '@familybook',
'title' => (string)$e['title'],
'starts_at' => $day . ' ' . ($e['time'] !== '' ? $e['time'] . ':00' : '00:00:00'),
'ends_at' => null,
'all_day' => empty($e['time']) ? 1 : 0,
'starts_at' => $starts,
'ends_at' => $ends,
'all_day' => $allDay ? 1 : 0,
'location' => null,
'note' => null,
'status' => 'confirmed',
@@ -654,7 +858,33 @@ final class Calendar
}
return $out;
}
return $st->fetchAll();
/* ---- 把重复系列的「例外」附着到对应主事件上(CalDAV 导出用)----
🔴 v52b(2026-10-15):`Ics::export()` 需要这两样才能把正文写对 ——
`_skip_dates`(EXDATE:哪几次被取消)与 `_overrides`(RECURRENCE-ID 覆盖行)。
两样都是**批量查**的(`overridesFor` / 一次 IN 查询),不能逐条查:
一本几百条日程时逐条查会让 REPORT 慢一个数量级。
⚠️ 只给自定义本挂:派生日历(农历/节假日/观影/旅程)没有 RRULE,
也就没有"某一次"可例外(Ics 那边也会因为没有 RRULE 而跳过 EXDATE)。 */
$rows = $st->fetchAll();
if ($rows) {
$ids = array_map('intval', array_column($rows, 'id'));
$in = implode(',', array_fill(0, count($ids), '?'));
$sk = Database::get()->prepare(
"SELECT event_id, skip_date FROM calendar_event_skips WHERE event_id IN ($in)"
);
$sk->execute($ids);
$skips = [];
foreach ($sk->fetchAll() as $r) {
$skips[(int)$r['event_id']][] = (string)$r['skip_date'];
}
$ovr = self::overridesFor($ids);
foreach ($rows as $i => $r) {
$eid = (int)$r['id'];
$rows[$i]['_skip_dates'] = $skips[$eid] ?? [];
$rows[$i]['_overrides'] = $ovr[$eid] ?? [];
}
}
return $rows;
}
/**
@@ -684,8 +914,8 @@ final class Calendar
$db->prepare(
'INSERT INTO calendar_events
(calendar_id, uid, title, location, starts_at, ends_at, all_day,
reminder_minutes, status, transp, rrule, note, created_by)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)'
reminder_minutes, status, transp, rrule, note, created_by, extra)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)'
)->execute([
$calendarId,
$uid,
@@ -700,6 +930,7 @@ final class Calendar
self::textOrNull((string)($v['rrule'] ?? ''), 255),
self::textOrNull((string)($v['note'] ?? '')),
$createdBy ?: null,
self::textOrNull((string)($v['extra'] ?? '')),
]);
return 'added';
}
@@ -921,6 +1152,7 @@ final class Calendar
// 一次把这批日程的「例外日期」取出来(N+1 会在重复日程多时明显变慢)
$skips = [];
$ovrs = [];
if ($rows) {
$eids = array_map('intval', array_column($rows, 'id'));
$in2 = implode(',', array_fill(0, count($eids), '?'));
@@ -931,6 +1163,20 @@ final class Calendar
foreach ($sk->fetchAll() as $r) {
$skips[(int)$r['event_id']][(string)$r['skip_date']] = true;
}
/* 🔴 v52b:「被**改到别的时间**的某一次」(CalDAV 的 RECURRENCE-ID 覆盖行)
与 skips 一次批量取出来 —— 网页日历也得认它,否则手机上把周三那次
改到周五,网页上周三**照样**画着(还在原时间),等于两边不一致。 */
foreach (self::overridesFor($eids) as $eid => $list) {
foreach ($list as $o) {
/* ⚠️ 键必须是 **`Y-m-d`**(与 `Repeat::occurrences()` 返回的日期
同形态),**不能**直接用 ICS 形态的 `recurrence_id` ——
那样永远匹配不上,覆盖行会被静默忽略(网页上照样画在原时间)。 */
$od = self::ridDay((string)$o['recurrence_id']);
if ($od !== '') {
$ovrs[(int)$eid][$od] = $o;
}
}
}
}
foreach ($rows as $e) {
@@ -953,29 +1199,54 @@ final class Calendar
(string)$e['starts_at'], $rr, $from, $to,
$skips[(int)$e['id']] ?? []
);
/* 🔴 v52b:覆盖行可能把某一次挪到**别的月份**去 ⇒ 只看"本窗口内的发生"
会漏掉它。反着也收一遍:原发生不在窗口里、但**新时间**落在窗口内的那几次。
⚠️ `$od` 已经是 `Y-m-d`(见上面建 `$ovrs` 时的 `ridDay`),别再转一次。 */
foreach (($ovrs[(int)$e['id']] ?? []) as $od => $o) {
$nd = substr((string)($o['starts_at'] ?? ''), 0, 10);
if ($nd >= $from && $nd <= $to && !in_array($od, $days, true)) {
$days[] = $od;
}
}
foreach ($days as $day) {
/* 这一次被改到别的时间了吗?有覆盖行就按覆盖行画。
⚠️ 三个"日期"要分清,混用会画出"整串都堆在系列第一天"的怪相:
· `$day` = 这一**次发生**在原系列里的日期(也是 `occ` 的值,
详情浮层与编辑链路靠它定位"改哪一次");
· `$base` = 这一次**画在哪一天**(有覆盖行 ⇒ 它改到的新日期);
· `$bsd/$bed` = 用来算**时长**的起止(有覆盖行用覆盖行自己的,
没给结束就别继承主事件的时长)。 */
$ov = $ovrs[(int)$e['id']][$day] ?? null;
$vd = $ov ?: $e;
$base = $ov ? substr((string)$ov['starts_at'], 0, 10) : $day;
$bsd = substr((string)$vd['starts_at'], 0, 10);
$bed = substr((string)($vd['ends_at'] ?: $vd['starts_at']), 0, 10);
$vtm = substr((string)$vd['starts_at'], 11, 5);
// 没给结束时刻就是"没有结束"(不继承主事件的时长,
// 否则"挪到下午茶"会被画成一个 9 小时的块)
$vetm = $vd['ends_at'] ? substr((string)$vd['ends_at'], 11, 5) : '';
// 跨天日程:从这次发生的当天起,按事件本身的长度铺开
$span = (int)floor((strtotime($ed) - strtotime($sd)) / 86400);
$span = (int)floor((strtotime($bed) - strtotime($bsd)) / 86400);
$first = true;
foreach (self::eachDay($day, date('Y-m-d', strtotime($day . ' +' . $span . ' day')), $from, $to) as $d2) {
$isLast = ($d2 === date('Y-m-d', strtotime($day . ' +' . $span . ' day')));
foreach (self::eachDay($base, date('Y-m-d', strtotime($base . ' +' . $span . ' day')), $from, $to) as $d2) {
$isLast = ($d2 === date('Y-m-d', strtotime($base . ' +' . $span . ' day')));
$out[] = [
'cal' => (int)$e['calendar_id'],
'color' => (string)($c['color'] ?? '#4A7C82'),
'calname' => (string)($c['name'] ?? ''),
'title' => (string)$e['title'],
'title' => (string)$vd['title'],
'date' => $d2,
// 跨天的第二、三天只显示"还在进行",不再重复挂一个开始时刻
'time' => ($first && empty($e['all_day'])) ? $tm : '',
'time' => ($first && empty($vd['all_day'])) ? $vtm : '',
// 结束时刻同理:只落在**最后一天**上(那才是它真正结束的那天)
'end_time'=> ($isLast && $etm !== '' && empty($e['all_day'])) ? $etm : '',
'all_day' => !empty($e['all_day']),
'end_time'=> ($isLast && $vetm !== '' && empty($vd['all_day'])) ? $vetm : '',
'all_day' => !empty($vd['all_day']),
'kind' => 'custom',
'ref_id' => (int)$e['id'],
// 🔴 v43(用户第 6 条):条目详情浮层要显示"更多信息",
// 所以地点/备注/状态也要带出去(原来模板只用得到 title/time)。
'location' => (string)($e['location'] ?? ''),
'note' => (string)($e['note'] ?? ''),
'location' => (string)($vd['location'] ?? ''),
'note' => (string)($vd['note'] ?? ''),
// 带上这一天的日期,重复日程点进去才能定位到"改哪一次"
'occ' => $day,
'repeat' => $rr,