diff --git a/app/CalDav.php b/app/CalDav.php index 4b8131b..ac829ec 100644 --- a/app/CalDav.php +++ b/app/CalDav.php @@ -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'] ?? ''); } /** diff --git a/app/Calendar.php b/app/Calendar.php index 865229a..2f81417 100644 --- a/app/Calendar.php +++ b/app/Calendar.php @@ -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 按 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 $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 $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, diff --git a/app/Ics.php b/app/Ics.php index 9c8b6d9..05e4441 100644 --- a/app/Ics.php +++ b/app/Ics.php @@ -15,6 +15,17 @@ * * 时区:系统里存的一律是**本地时间**(不带时区),导出也不带 Z、不写 VTIMEZONE。 * 家庭应用里"几点开会"就是墙上那个钟,硬套 UTC 反而会整体偏移。 + * + * 🔴🔴 v52b(2026-10-15)修的真 bug:**入库方向原来只"删 Z",不做换算**。 + * `fromDateTimeValue()` 的正则写作 `/…Z?$/`,`Z` 被吃掉之后数值原样落库 ⇒ + * 客户端按 RFC 发的 `DTSTART:20261020T060000Z`(UTC 06:00)被当成**本地 06:00** + * 存下来,阅读端就少 8 小时(SyncCalendar 报的正是这个)。 + * ⇒ 现在:带 `Z` 的按 UTC 换算到本地;带 `;TZID=` 的按那个时区换算; + * 两者都没有的**仍按本地原样**(那才是"墙上那个钟",不能动)。 + * ⚠️ 同理,`RRULE` 里的 `UNTIL=…Z` 也必须换算成本地并去掉 `Z` —— + * RFC 5545 §3.3.10 要求 `UNTIL` 与 `DTSTART` 是**同一个值类型/同一套时间基准**, + * 一个 UTC 一个本地会整体错几个小时(表现是"重复提前结束/多一次")。 + * 自研客户端 SyncCalendar 0.0.8 起已改发浮动本地时间,与这里口径一致。 */ declare(strict_types=1); @@ -26,6 +37,26 @@ final class Ics /** 导出的产品标识(RFC 5545 PRODID,客户端会拿它判断"是谁生成的") */ private const PRODID = '-//FamilyBook//FamilyBook Calendar//CN'; + /** + * 我们**建模**(有对应数据库列)的 VEVENT 属性。 + * + * 🔴 v52b(2026-10-15 用户:「既然发现了就直接全部修了」):此前的 parser 只认这几个, + * **其余属性一律丢** —— 实测客户端 PUT 一份带 `X-` 自定义属性的 ICS, + * 读回来一个都不剩。虽然我们不需要理解它们,但**丢掉就是丢数据**: + * · `RDATE`(额外发生日)丢 = 系列少几次; + * · 别人加的 `X-`(如 `X-APPLE-STRUCTURED-LOCATION`)丢 = 位置信息没了; + * · 第三方客户端下次拉回来发现"我写的东西不见了",有些会改判定为冲突。 + * ⇒ 未被建模的属性**原样收进 `extra`**(见 parse),导出时原样吐回。 + * + * ⚠️ `EXDATE` / `RECURRENCE-ID` 单独处理,不在这条"原样带过"的路里 + * (它们要落成 skips 表与覆盖行,见 §重复日程的例外)。 + */ + private const MODELED = [ + 'UID', 'DTSTAMP', 'DTSTART', 'DTEND', 'SUMMARY', 'LOCATION', 'DESCRIPTION', + 'RRULE', 'STATUS', 'TRANSP', 'SEQUENCE', 'CREATED', 'LAST-MODIFIED', + 'RECURRENCE-ID', + ]; + /** * 把一个日历本导出成 .ics 文本。 * @@ -44,6 +75,15 @@ final class Ics ]; foreach ($events as $e) { $out = array_merge($out, self::vevent($e)); + /* 🔴🔴 v52b:「重复系列里被改掉的某一次」必须与主事件写在**同一个资源**里 + (CalDAV 里"一个重复系列 = 一个 .ics 文件",RFC 4791 §4.1)。 + 原来这里只吐主事件 ⇒ 客户端推上来的覆盖行**读不回去**, + 手机上"把周三那次改到周五"在下次同步后凭空消失。 + ⚠️ 顺序:主事件在前、覆盖行在后(个别客户端按出现顺序建索引)。 + ⚠️ 覆盖行的 UID 与主事件**必须相同** —— 那是它们属于同一系列的凭据。 */ + foreach ((array)($e['_overrides'] ?? []) as $o) { + $out = array_merge($out, self::overrideVevent($e, $o)); + } } $out[] = 'END:VCALENDAR'; // 折行 + 统一 CRLF(RFC 5545 要求行尾是 CRLF,虽然多数客户端也收 LF) @@ -100,9 +140,42 @@ final class Ics 这条分支一直没被走到(本轮为了验 PUT 重复日程才第一次踩到)。 */ /* ⚠️ 值类型是 **RECUR**,不是 TEXT —— 里面的逗号是**结构分隔符** (如 `BYDAY=MO,TU,WE,TH,FR`),**绝不能**用 escapeText 转义, - 否则客户端会把它当成一整块而解析失败。原样拼接即可。 */ - $out[] = 'RRULE:' . (string)$e['rrule']; + 否则客户端会把它当成一整块而解析失败。原样拼接即可。 + ⚠️ `rruleLocal()` 只动 `UNTIL=` 那一小段(把它掰成本地形态), + 其余一字不改;对已经是对的规则是**幂等**的。 */ + $out[] = 'RRULE:' . self::rruleLocal((string)$e['rrule'], $allDay); } + + /* ---- 重复系列的"例外日"(RFC 5545 EXDATE)---- + 服务器把"这一次被取消 / 被挪走"存在 `calendar_event_skips`(与网页端 + "只删这一次"共用同一张表),导出时必须还原成 EXDATE —— 否则客户端 + 下次拉回来会把那一次**又画上**(用户看到的怪相:"我在手机上删了它, + 过一会儿它自己回来了",SyncCalendar 那边叫幽灵日程)。 + ⚠️ 值必须与 DTSTART **同形态**:全天系列给 `EXDATE;VALUE=DATE:20261022`, + 计时系列给 `EXDATE:20261022T090000`(时刻取主事件的,因为"哪一次" + 是按时钟定的)。形态不一对,客户端会静默忽略整行。 */ + $skipDates = (array)($e['_skip_dates'] ?? []); + if ($skipDates && trim((string)($e['rrule'] ?? '')) !== '') { + $vals = []; + foreach ($skipDates as $sd) { + $one = self::exdateValue((string)$sd, (string)$starts, $allDay); + if ($one !== '') { + $vals[] = $one; + } + } + if ($vals) { + $out[] = 'EXDATE' . ($allDay ? ';VALUE=DATE' : '') . ':' . implode(',', $vals); + } + } + + /* ---- 未建模的属性原样带回(X-* / RDATE / CATEGORIES…)---- + 我们**不解释**它们,但必须让它们活过一轮 PUT→GET —— 否则第三方客户端 + 会发现"我写上去的东西不见了",轻则丢信息(如苹果的结构化位置), + 重则判定同步冲突。 */ + foreach (self::extraLines($e) as $l) { + $out[] = $l; + } + $out[] = 'STATUS:' . strtoupper((string)($e['status'] ?: 'confirmed')); $out[] = 'TRANSP:' . (empty($e['transp']) ? 'OPAQUE' : 'TRANSPARENT'); $out[] = 'SEQUENCE:' . (int)($e['sequence'] ?? 0); @@ -131,6 +204,86 @@ final class Ics return $out; } + /** + * 一条**覆盖行**(重复系列里被改写的那一次)→ VEVENT 的若干行。 + * + * RFC 5545 §3.8.4.4 / RFC 4791 §4.1:覆盖行 = 与主事件**同 UID** 的第二个 VEVENT, + * 用 `RECURRENCE-ID`(= **原来的**那次发生时刻)指明它替换的是哪一次, + * 而它自己的 `DTSTART` 是**改到的新时间**。这就是客户端"仅这一次改到别的时间" + * 在协议上的标准表达(Google / 苹果 / 雷鸟都在用)。 + * + * 🔴🔴 v52b(2026-10-15)新增。在这之前服务器把覆盖行当普通日程吞掉, + * 于是"改某一次"永远存不下来。 + * + * ⚠️ 三个"必须": + * · `UID` 与主事件**相同**(不同 UID = 变成两条不相干的日程); + * · `RECURRENCE-ID` 值与主事件的 `DTSTART` **同形态**(全天给 `VALUE=DATE`); + * · **不写 `RRULE`** —— 重复规则只属于主事件,覆盖行带 RRULE 会让 + * 部分客户端把它展开成一整个新系列("改一次"变成"多出一整串")。 + */ + private static function overrideVevent(array $master, array $o): array + { + $uid = (string)($master['uid'] ?? ''); + if ($uid === '') { + $uid = Calendar::newUid(); + } + $rid = (string)($o['recurrence_id'] ?? ''); + $allDay = !empty($o['all_day']); + $starts = (string)($o['starts_at'] ?? ''); + $ends = (string)($o['ends_at'] ?? ''); + $stamp = self::stampOf($o['updated_at'] ?? $o['created_at'] ?? $master['updated_at'] ?? null); + + $out = ['BEGIN:VEVENT', 'UID:' . $uid, 'DTSTAMP:' . $stamp]; + if ($rid !== '') { + $out[] = 'RECURRENCE-ID' . ($allDay ? ';VALUE=DATE' : '') . ':' . $rid; + } + if ($allDay) { + $d = self::dateValue($starts); + $out[] = 'DTSTART;VALUE=DATE:' . $d; + $out[] = 'DTEND;VALUE=DATE:' . self::nextDay($d); + } else { + $out[] = 'DTSTART:' . self::dateTimeValue($starts); + $out[] = 'DTEND:' . self::dateTimeValue($ends !== '' ? $ends : $starts); + } + $out[] = 'SUMMARY:' . self::escapeText((string)($o['title'] ?? '')); + if (($o['location'] ?? '') !== '') { + $out[] = 'LOCATION:' . self::escapeText((string)$o['location']); + } + if (($o['note'] ?? '') !== '') { + $out[] = 'DESCRIPTION:' . self::escapeText((string)$o['note']); + } + foreach (self::extraLines($o) as $l) { + $out[] = $l; + } + $out[] = 'STATUS:' . strtoupper((string)($o['status'] ?: 'confirmed')); + $out[] = 'TRANSP:OPAQUE'; + $out[] = 'SEQUENCE:' . (int)($o['sequence'] ?? 0); + $out[] = 'END:VEVENT'; + return $out; + } + + /** + * 被排除的那一天 → `EXDATE` 的值(与 `DTSTART` 同形态)。 + * + * @param string $dateYmd '2026-10-22' + * @param string $masterStarts 主事件的 starts_at('Y-m-d H:i:s',供取时刻) + */ + private static function exdateValue(string $dateYmd, string $masterStarts, bool $allDay): string + { + $d = str_replace('-', '', substr(trim($dateYmd), 0, 10)); + if (strlen($d) !== 8) { + return ''; + } + if ($allDay) { + return $d; + } + $t = substr($masterStarts, 11, 8); + if (strlen($t) !== 8) { + $t = '00:00:00'; + } + return $d . 'T' . str_replace(':', '', $t); + } + /** * 解析一个 .ics 文本,返回里面的 VEVENT。 * @@ -184,6 +337,20 @@ final class Ics } continue; } + /* 「重复日程的例外」必须**逐个**收,不能走下面"同名只留第一个"那条路: + · `EXDATE` 可以写**多行**、每行还可以**逗号分隔**多个值 + (Google / 苹果导出的就是多行)。只留第一个 = 少排除好几次。 + · 未被建模的属性同理,同一个名字可能出现多次(如两个 `X-`)。 */ + if ($name === 'EXDATE') { + $cur['_exdate'][] = ['v' => $value, 'p' => $params]; + continue; + } + if (!in_array($name, self::MODELED, true)) { + // ⚠️ 存**拆折行之后**的单行(parse 开头已经反折行过)—— + // 导出时 export() 会统一重新折行,这里再折一次会折出双倍缩进。 + $cur['_extra'][] = $line; + continue; + } // 同名属性出现多次时保留第一个(RFC 里多数属性都是单值的) if (!array_key_exists($name, $cur)) { $cur[$name] = $value; @@ -195,7 +362,17 @@ final class Ics return $out; } - /** 把解析出来的属性归一成我们要落库的形状 */ + /** + * 把解析出来的属性归一成我们要落库的形状。 + * + * 🔴 v52b(2026-10-15):补三样原来**根本没有**的东西 —— + * `recurrence_id`(这一条是重复系列里的哪一次)、 + * `skip_dates`(这一条自己排除了哪些天)、 + * `extra`(未建模属性原样带过)。 + * 缺 `recurrence_id` 的后果最重:客户端把"被改到别的时间的某一次"作为 + * **第二个 VEVENT** 推上来时,它会被当成一条**普通日程**, + * 与主事件撞同一个 UID ⇒ 要么被 UID 判重吃掉、要么变成两条(幽灵日程)。 + */ private static function normalizeVevent(array $p): array { $startRaw = (string)($p['DTSTART'] ?? ''); @@ -205,9 +382,11 @@ final class Ics $allDay = stripos($params, 'VALUE=DATE') !== false || preg_match('/^\d{8}$/', $startRaw) === 1; - $starts = $allDay ? self::fromDateValue($startRaw) : self::fromDateTimeValue($startRaw); + $starts = $allDay ? self::fromDateValue($startRaw) : self::fromDateTimeValue($startRaw, $params); $ends = $endRaw !== '' - ? ($allDay ? self::fromDateValue($endRaw) : self::fromDateTimeValue($endRaw)) + ? ($allDay + ? self::fromDateValue($endRaw) + : self::fromDateTimeValue($endRaw, (string)($p['DTEND_PARAMS'] ?? ''))) : null; // 全天日程的 DTEND 是**排他**的第二天,减回一天才对得上"到哪天为止" if ($allDay && $ends !== null && $starts !== null) { @@ -215,6 +394,13 @@ final class Ics $ends = $d ? date('Y-m-d', $d) . ' 00:00:00' : null; } + // 重复系列里的"哪一次"(RFC 5545 RECURRENCE-ID)。统一转成**本地浮动**形态, + // 与 DTSTART 同基准 —— 否则客户端下轮拿它去比对会发生"对不上"。 + $ridRaw = trim((string)($p['RECURRENCE-ID'] ?? '')); + $rid = $ridRaw === '' + ? '' + : self::canonicalLocalIcs($ridRaw, (string)($p['RECURRENCE-ID_PARAMS'] ?? ''), $allDay); + return [ 'uid' => (string)($p['UID'] ?? ''), 'title' => self::unescapeText((string)($p['SUMMARY'] ?? '')), @@ -223,12 +409,158 @@ final class Ics 'all_day' => $allDay, 'location' => self::nullOr(self::unescapeText((string)($p['LOCATION'] ?? ''))), 'note' => self::nullOr(self::unescapeText((string)($p['DESCRIPTION'] ?? ''))), - 'rrule' => self::nullOr((string)($p['RRULE'] ?? '')), + // ⚠️ RRULE 存**去掉前缀**的原文(导出侧补 `RRULE:`), + // 但 `UNTIL` 要换算成本地并与 DTSTART 同形态,见 rruleLocal()。 + 'rrule' => self::nullOr(self::rruleLocal((string)($p['RRULE'] ?? ''), $allDay)), 'status' => strtolower((string)($p['STATUS'] ?? '')) ?: 'confirmed', 'reminder_minutes' => self::triggerMinutes((string)($p['_trigger'] ?? '')), + 'recurrence_id' => $rid, + 'skip_dates' => self::exdateDates($p['_exdate'] ?? []), + 'extra' => implode("\n", (array)($p['_extra'] ?? [])), ]; } + /** + * EXDATE 的值 → 被排除的那几天(`Y-m-d`)。 + * + * ⚠️ **只取日期粒度**:服务器把"某一次例外"存在 `calendar_event_skips.skip_date` + * (DATE 列,与网页端"只删这一次"共用同一张表),所以落库时天以下是多余精度。 + * 重复日程在家庭场景一天最多发生一次(没有 HOURLY 规则),够用。 + * ⚠️ 带 `TZID` / 结尾 `Z` 的值要**先换算到本地再取日期**:UTC 23:00 的排除 + * 在本地可能是**第二天**,直接截前 8 位会排错一天。 + */ + private static function exdateDates(array $list): array + { + $out = []; + foreach ($list as $one) { + $params = (string)($one['p'] ?? ''); + foreach (explode(',', (string)($one['v'] ?? '')) as $v) { + $v = trim($v); + if ($v === '') { + continue; + } + $parts = self::localParts($v, $params); + $day = $parts !== null ? $parts[0] : ''; + if ($day !== '') { + $out[$day] = true; + } + } + } + return array_keys($out); + } + + /** + * ICS 时间值 → **本地墙钟**的 [日期, 时刻]。 + * + * 🔴🔴 v52b(2026-10-15)修的就是这里:原来"看到 `Z` 就当没看见", + * 直接把数值原样落库 ⇒ 客户端发 UTC 06:00、服务器存成本地 06:00, + * 阅读端少 8 小时。现在三种情况分开处理: + * · 结尾 `Z` → 按 UTC 换算到本地; + * · 有 `TZID=xxx` → 按该时区换算到本地(时区名不认识则退回原样); + * · 两者都没有 → **原样**(浮动时间=本地墙钟,这是本系统的约定,不能动)。 + * + * @param string $v ICS 原始值(`20261020T060000Z` / `20261020` / `20261020T160000`) + * @param string $params 该属性的参数串(可能含 `TZID=Asia/Shanghai`) + * @return array{0:string,1:string}|null [Y-m-d, H:i:s];无法解析时 null + */ + private static function localParts(string $v, string $params = ''): ?array + { + $v = trim($v); + if ($v === '') { + return null; + } + // 日期值(全天):没有时刻,也没有时区可言 + if (preg_match('/^(\d{4})(\d{2})(\d{2})$/', $v, $m)) { + return [$m[1] . '-' . $m[2] . '-' . $m[3], '00:00:00']; + } + if (!preg_match('/^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})(Z?)$/', $v, $m)) { + // 不是紧凑形态(极少数客户端写 `2026-10-20 06:00:00`)→ 交给 strtotime + $t = strtotime($v); + return $t === false ? null : [date('Y-m-d', $t), date('H:i:s', $t)]; + } + + $raw = $m[1] . $m[2] . $m[3] . 'T' . $m[4] . $m[5] . $m[6]; + $zone = null; + if ($m[7] === 'Z') { + $zone = new \DateTimeZone('UTC'); + } elseif (preg_match('/(?:^|;)TZID=([^;:]+)/i', $params, $tz)) { + try { + $zone = new \DateTimeZone(str_replace('"', '', $tz[1])); + } catch (\Throwable $e) { + // 时区名不认识(拼错 / 山寨 VTIMEZONE)→ 当浮动时间,别让整条日程解析失败 + $zone = null; + } + } + + $dt = \DateTime::createFromFormat('Ymd\THis', $raw, $zone ?: null); + if ($dt === false) { + return null; + } + if ($zone !== null) { + $dt->setTimezone(new \DateTimeZone(date_default_timezone_get())); + } + return [$dt->format('Y-m-d'), $dt->format('H:i:s')]; + } + + /** + * ICS 时间值 → **本地浮动**的紧凑 ICS 形态(`20261020T160000` / `20261020`)。 + * 用于 `RECURRENCE-ID`:它必须与 `DTSTART` 同一套基准,客户端才比对得上。 + */ + private static function canonicalLocalIcs(string $v, string $params, bool $allDay): string + { + $p = self::localParts($v, $params); + if ($p === null) { + return ''; + } + $ymd = str_replace('-', '', $p[0]); + return $allDay ? $ymd : $ymd . 'T' . str_replace(':', '', $p[1]); + } + + /** + * `RRULE` 的 `UNTIL` 归一:换算成本地、去掉 `Z`,并与 `DTSTART` 同形态。 + * + * RFC 5545 §3.3.10:`UNTIL` 的值类型必须与 `DTSTART` 一致 —— + * `DTSTART` 是本地浮动时,`UNTIL=…Z`(UTC)会让系列整体错几个小时 + * (表现是"重复提前几天结束"或"多出一次")。服务器既然统一用本地墙钟, + * 就必须把进来的 UTC `UNTIL` 也掰成本地。 + * + * ⚠️ 其余部分(`FREQ`/`COUNT`/`BYDAY`…)**一字不动**:那是 RECUR 值, + * 里面逗号是**结构分隔符**,绝不能当文本转义或重排。 + * ⚠️ 幂等:`UNTIL` 已经不带 `Z` 时,走一遍结果不变。 + */ + private static function rruleLocal(string $rrule, bool $allDay): string + { + $rrule = trim($rrule); + if ($rrule === '' || stripos($rrule, 'UNTIL=') === false) { + return $rrule; + } + return (string)preg_replace_callback( + '/UNTIL=([0-9]{8}(?:T[0-9]{6}Z?)?)/i', + static function (array $m) use ($allDay): string { + $ics = self::canonicalLocalIcs($m[1], '', $allDay); + return 'UNTIL=' . ($ics !== '' ? $ics : $m[1]); + }, + $rrule + ); + } + + /** `extra` 字段(多行文本)→ 要原样写回的行数组 */ + private static function extraLines(array $row): array + { + $raw = (string)($row['extra'] ?? ''); + if (trim($raw) === '') { + return []; + } + $out = []; + foreach (preg_split('/\r\n|\r|\n/', $raw) ?: [] as $l) { + $l = trim($l); + if ($l !== '') { + $out[] = $l; + } + } + return $out; + } + /** TRIGGER:-PT15M / TRIGGER:-P1D / TRIGGER;VALUE=DATE-TIME:... → 分钟数 */ private static function triggerMinutes(string $v): ?int { @@ -289,15 +621,17 @@ final class Ics return date('Ymd', $t); } - /** '20261016T143000' / '2026-10-16 14:30:00' → '2026-10-16 14:30:00' */ - private static function fromDateTimeValue(string $v): ?string + /** + * '20261016T143000' / '2026-10-16 14:30:00' → '2026-10-16 14:30:00'(本地墙钟)。 + * + * ⚠️ 第二参数(该属性的参数串)**必须传**:`;TZID=…` 就在里面。 + * 不传的后果与"删 Z"是同一类静默错误 —— 带 TZID 的值会被当成浮动时间, + * 整条日程偏几个小时。见 localParts() 的说明。 + */ + private static function fromDateTimeValue(string $v, string $params = ''): ?string { - $v = trim($v); - if (preg_match('/^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})Z?$/', $v, $m)) { - return sprintf('%s-%s-%s %s:%s:%s', $m[1], $m[2], $m[3], $m[4], $m[5], $m[6]); - } - $t = strtotime($v); - return $t === false ? null : date('Y-m-d H:i:s', $t); + $p = self::localParts($v, $params); + return $p === null ? null : $p[0] . ' ' . $p[1]; } /** '20261016' → '2026-10-16 00:00:00' */ diff --git a/app/Schema.php b/app/Schema.php index ec063ae..a9203fc 100644 --- a/app/Schema.php +++ b/app/Schema.php @@ -146,6 +146,26 @@ final class Schema * 「哪些行被删了」(没有 deleted_at 就只能硬删除 ⇒ 客户端永远看不到墓碑 * ⇒ 一直显示幽灵数据)。⛔ 凭据/字典/本地偏好等 8 张表**故意不加** —— * 同步白名单应当显式而简短。 + * 34 → **CalDAV 的重复日程例外(2026-10-15 用户:「既然发现了就直接全部修了」)**: + * ① `calendar_events` 加 `extra` —— **未建模的 VEVENT 属性原样保留** + * (`X-*` / `RDATE` / `CATEGORIES`…)。原来 parser 只认固定 9 个属性, + * 其余一律丢,客户端写上去的东西**读不回来**(实测 PUT 带 `X-` 属性, + * 回来一个都不剩)。 + * ② 新增 `calendar_event_overrides` —— 重复系列里**被改到别的时间的某一次**。 + * 动机(实测出来的真 bug):CalDAV 客户端把"仅这一次改到周五"写成 + * **同一个 .ics 里的第二个 VEVENT**(同 UID + `RECURRENCE-ID`), + * 而原来的 `dav.php` PUT 只取 `$list[0]` ⇒ 覆盖行直接进垃圾桶, + * 于是"改某一次"永远同步不过去,下轮还会把被排除那次又拉回来(幽灵日程)。 + * ⚠️ 为什么**不**把覆盖行塞进 `calendar_events`(加一个 `recurrence_id` 列): + * 那一列会被所有既有查询看到 —— `exportRows()` 的 + * `SELECT * FROM calendar_events WHERE calendar_id = ?`、日历视图的展开、 + * 提醒扫描…**每一处都得多判一次"这行是不是主事件"**,漏一处就是 + * "同一次日程出现两条"。而"被改掉的某一次"本来就不是"日程本体", + * 与 `calendar_event_skips` 是同一类东西 ⇒ 并列放一张卫星表, + * 既有查询**一行都不用改**。 + * ⚠️ 与 `calendar_event_skips`(= `EXDATE`,只取消)的分工: + * 被**取消**的那次 → skips;被**挪到别的时间**的那次 → overrides + * (后者自带新时间,语义上是"替换那一次",RFC 5545 §3.8.4.4)。 */ /** * 结构版本(**人读**用:日志、设置页上显示"库结构是哪一版")。 @@ -153,7 +173,7 @@ final class Schema * ⚠️ 它**不再**作为"要不要跑升级"的判据 —— 见 fingerprint() 的说明。 * 它仍然要手工维护,因为它给人看(排查时一眼知道代码到哪一版了)。 */ - public const VERSION = '33'; + public const VERSION = '34'; /** * 「源码指纹」:拿**本文件自身的内容**算一个哈希,作为"要不要跑升级"的判据。 @@ -1742,6 +1762,54 @@ CREATE TABLE IF NOT EXISTS `calendar_event_skips` ( COMMENT='重复日程里被单独取消/挪走的某一次(RFC 5545 的 EXDATE)' SQL); + // ---- CalDAV 的重复日程例外(版本 34,2026-10-15)---- + /* ① 「被改到别的时间的某一次」= 覆盖行(RFC 5545 §3.8.4.4 / RFC 4791 §4.1)。 + CalDAV 客户端表达"把第 3 次改到周五"用的是**同一个 .ics 里的第二个 VEVENT** + (同 UID + `RECURRENCE-ID: <原发生时刻>` + 自己的 `DTSTART` = 新时间)。 + 原来 dav.php 的 PUT 只取 `$list[0]` ⇒ 覆盖行被丢掉 ⇒ 手机上改的那一次 + **永远同步不过去**,而且下一轮拉取还会把被排除的那次又画回来。 + + ⚠️ 为什么不加在 `calendar_events` 里(加 `recurrence_id` 列): + 那样所有既有查询都会看到这些行,**每一处都得补一句"排除覆盖行"** + (`exportRows` 的 `SELECT *`、日历视图展开、提醒扫描、API…), + 漏一处就是"同一次日程显示两条"。放卫星表则既有查询**一行都不用改** —— + 与 `calendar_event_skips` 同一个道理。 + ⚠️ `UNIQUE KEY (event_id, recurrence_id)` 是幂等的基石:客户端每轮都把这 + 整个系列推一遍,没有它就每同步一次多一条覆盖行。 + ⚠️ 与 skips 的分工:**取消**这一次 → skips;**挪走**这一次 → 本表。 + 两者都可能同时存在(一个系列里既有取消的、又有挪走的)。 */ + $db->exec(<<<'SQL' +CREATE TABLE IF NOT EXISTS `calendar_event_overrides` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `event_id` INT UNSIGNED NOT NULL COMMENT '哪条日程的主事件(calendar_events.id)', + `recurrence_id` VARCHAR(64) NOT NULL COMMENT 'RFC 5545 RECURRENCE-ID:被替换的那次**原发生时刻**(本地浮动 ICS 形态,如 20261021T090000 / 20261021)', + `title` VARCHAR(200) NOT NULL, + `location` VARCHAR(255) DEFAULT NULL, + `starts_at` DATETIME NOT NULL COMMENT '改到的新开始时刻', + `ends_at` DATETIME DEFAULT NULL, + `all_day` TINYINT(1) NOT NULL DEFAULT 0, + `note` TEXT DEFAULT NULL, + `status` ENUM('confirmed','tentative','cancelled') NOT NULL DEFAULT 'confirmed', + `sequence` INT NOT NULL DEFAULT 0, + `extra` TEXT DEFAULT NULL COMMENT '该 VEVENT 里未被建模的属性原文(X-* 等),往返带过', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ceo` (`event_id`, `recurrence_id`), + CONSTRAINT `fk_ceo_event` FOREIGN KEY (`event_id`) REFERENCES `calendar_events` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + COMMENT='重复日程里被改到别的时间的某一次(RFC 5545 的 RECURRENCE-ID 覆盖行)' +SQL); + + /* ② `calendar_events.extra`:未被建模的 VEVENT 属性原样保留。 + parser 原来只认固定 9 个属性,其余一律丢 —— 实测 PUT 一份带 `X-` 自定义 + 属性的 ICS,读回来一个都不剩。虽然我们不需要理解它们,但**丢掉就是丢数据**: + `RDATE`(额外发生日)丢 = 系列少几次;别人的 `X-APPLE-STRUCTURED-LOCATION` + 丢 = 位置没了;第三方客户端发现"我写的没了"还可能改判为冲突。 + ⇒ 原样存、原样吐回,成本只有一列。 */ + self::addColumn($db, 'calendar_events', 'extra', + "ADD COLUMN `extra` TEXT DEFAULT NULL COMMENT '未被建模的 VEVENT 属性原文(X-* 等,按行存),CalDAV 导出时原样带回' AFTER `sequence`"); + // ---- 旅程的活动「是否公开」(版本 13,用户 2026-10-03 第 4 条)---- // 用户原话:「旅程的活动中,只显示 is_public=1 的活动」。 // ⚠️ 放在 **trip_details**(1:1 的卫星表)而不是 events: @@ -2138,7 +2206,7 @@ SQL); 'person_tags', 'note_shares', 'notifications', // ---- ensure() 版本迁移段里建的 ---- 'trip_node_revisions', 'task_boards', 'task_categories', - 'calendar_event_skips', 'anniversaries', 'anniversary_people', + 'calendar_event_skips', 'calendar_event_overrides', 'anniversaries', 'anniversary_people', 'ledgers', 'ledger_entries', 'ledger_shares', 'ledger_entry_snapshots', 'person_jobs', 'person_notes', ]; diff --git a/dav.php b/dav.php index c97d0d1..029e1cc 100644 --- a/dav.php +++ b/dav.php @@ -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 到 .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, '没有这本日历。');