v45 第 45 批:日程「重复到」换最新控件 + CalDAV 双向同步
用户 2026-10-12 两条:
1. 新建日程里「重复到」的那个日历选择框,改成最新控件。
- templates/calendar/_event_fields.php:裸 <input type=date> 换成
.dt-input.dt-antd-single[data-antd-date=1](AntD 纯日期 DatePicker),
复用既有 mountAntdDate,不新写挂载逻辑。
- 值载体仍是 name=rep_until 的原生框(包在 .dt-native 里),
服务端契约与 Repeat::build($_POST) 一字不改。
2. CalDAV 从「只读」改成「双向同步」。
用户给了一台可用的参照服务器(群晖 DAViCal)要求「完全按参照返回」;
抓参照 + 复刻 Thunderbird / SyncCalendar 的真实请求序列后确认:
参照是可写的,而只读会让两个客户端都判「不可用」。
- dav.php:放行 PUT(新建 201+ETag / 更新 204+ETag / If-Match 不符 412)、
DELETE(204)、MKCALENDAR(201+Location),仍逐本判权;
OPTIONS 的 Allow 与 DAV 头照参照补齐(DAV 头两行,注意 PHP header 追加);
current-user-privilege-set 按实际可写性给,并排除系统派生本(农历等)。
- app/CalDav.php:资源名统一用 UID(原自定义本用 id、派生本用 uid 两种混着,
一开写就表现为「客户端 PUT 到 UID 名、服务端 href 是 id 名 ⇒ 写进去读不回」);
补 owner / getctag / getcontenttype / calendar-description;新增 ctagSeedOf。
- app/Ics.php:修一个一直存在的真 bug —— 导出 RRULE 漏了 `RRULE:` 前缀
(库里存的是去前缀的值),导致所有导出/同步出去的重复日程都变成单次。
- templates/home.php:首页红线补 data-now-min(给探针做零容差判据用)。
验证:本地 e2e 1510/0、TB 探针 35/0、DAV 诊断 28/0、
v43 真机 33/0 + 反向 9/0、v44 真机 41/0 + API 63/0 + 反向 19/0、
v45 重复到 10/0 + 反向 2/0、v45 DAV 双向 24/0 + 反向 5/0;
全流水线 PUSH_RC=0(含部署静态断言第 45 批形态)。
This commit is contained in:
+55
-9
@@ -221,17 +221,26 @@ final class CalDav
|
||||
/**
|
||||
* 资源名(href 的最后一段,不含 .ics)。
|
||||
*
|
||||
* - 自定义日程:用 calendar_events.id,稳定且与 oneEvent() 的入参一一对应;
|
||||
* - 派生条目:id 为 0,用 uid 的净化形式(同书注释:"派生本没有 calendar_events.id")。
|
||||
* 🔴🔴 v45(用户第 2 条,照参照 DAViCal 对齐):**一律用 UID 的净化形式**。
|
||||
* 原来自定义日程用 `calendar_events.id`、派生条目用 UID —— 两种混着。
|
||||
* 这在"只读"时还凑合(href 是我们自己生成的,客户端只跟着走),
|
||||
* 但一开**写**就立刻露馅:
|
||||
* · 客户端 PUT 到 `/dav/1.ics/<UID>.ics`(它按 UID 命名资源,参照也是),
|
||||
* 服务端却按 id 生成 href ⇒ 它随后 GET/REPORT 自己刚写的那条
|
||||
* `/dav/1.ics/<UID>.ics` **取不回来**(404)⇒ 同步看起来"写进去了又没了"。
|
||||
* · 参照 DAViCal 的资源名就是 UID(`syncal-1758000003001-1d8b60.ics`)。
|
||||
* ⇒ 统一成 UID:客户端写什么名、就能读回什么名。
|
||||
* ⚠️ UID 为空的行(老数据)回退用 id,保证总有名字。
|
||||
*/
|
||||
public static function segmentOf(array $r): string
|
||||
{
|
||||
$rid = (int)($r['id'] ?? 0);
|
||||
if ($rid > 0) {
|
||||
return (string)$rid;
|
||||
}
|
||||
$uid = preg_replace('/[^A-Za-z0-9._-]/', '_', (string)($r['uid'] ?? ''));
|
||||
return $uid === null ? '' : $uid;
|
||||
$uid = $uid === null ? '' : trim($uid);
|
||||
if ($uid !== '') {
|
||||
return $uid;
|
||||
}
|
||||
$rid = (int)($r['id'] ?? 0);
|
||||
return $rid > 0 ? (string)$rid : '';
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -251,6 +260,28 @@ final class CalDav
|
||||
return ['ics' => $ics, 'etag' => '"' . md5($ics) . '"'];
|
||||
}
|
||||
|
||||
/**
|
||||
* 日历本的 ctag 种子 —— 客户端拿 ctag 判「这本整体变没变」。
|
||||
*
|
||||
* 🔴 v45(用户第 2 条,照参照 DAViCal 补齐属性时加的)。
|
||||
* ⚠️ 用**这一本里所有资源 ETag 的摘要**当种子,而不是"现在时间"——
|
||||
* 否则每请求一次 ctag 都变 ⇒ 客户端认为"本子一直在变" ⇒ 每轮全量重拉
|
||||
* (与 ETag 潮汐是同一个坑,见 resourceOf 的说明)。
|
||||
* ⚠️ 这里**不扫全表**:只取 calendar_events 的 (id, updated_at, sequence) 与
|
||||
* 该本条目数 —— 这几样任一变化都意味着"这本变了",够用且便宜。
|
||||
*/
|
||||
public static function ctagSeedOf(array $cal): string
|
||||
{
|
||||
$st = Database::get()->prepare(
|
||||
'SELECT COUNT(*) AS n, COALESCE(MAX(updated_at), \'\') AS mx,
|
||||
COALESCE(SUM(sequence), 0) AS sq
|
||||
FROM calendar_events WHERE calendar_id = ?'
|
||||
);
|
||||
$st->execute([(int)$cal['id']]);
|
||||
$r = $st->fetch() ?: [];
|
||||
return (string)($r['n'] ?? 0) . '|' . (string)($r['mx'] ?? '') . '|' . (string)($r['sq'] ?? 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* 按**资源名**找一个条目(供单条资源的 GET / REPORT 用)。
|
||||
*
|
||||
@@ -272,14 +303,29 @@ final class CalDav
|
||||
if ($seg === '') {
|
||||
return null;
|
||||
}
|
||||
// 数字资源名 = calendar_events.id(自定义本)
|
||||
// 🔴🔴 v45:资源名现在**统一是 UID 的净化形式**(见 segmentOf),
|
||||
// 所以先按 UID 直接查库 —— 这是最快也最准的一条。
|
||||
// ⚠️ 为什么不能只靠"遍历 resources() 比对":一本几千条时每条都要
|
||||
// 全量派生一遍(含 130 年农历换算),代价太大。
|
||||
$st = Database::get()->prepare(
|
||||
'SELECT e.* FROM calendar_events e
|
||||
JOIN calendars c ON c.id = e.calendar_id
|
||||
WHERE e.calendar_id = ? AND c.family_id = ? AND e.uid = ? LIMIT 1'
|
||||
);
|
||||
$st->execute([(int)$cal['id'], $familyId, $seg]);
|
||||
$row = $st->fetch();
|
||||
if ($row) {
|
||||
// 能看见这本就能看见里面的条目(共享只读也算)
|
||||
return Calendar::mineOne($familyId, $me, (int)$cal['id']) ? $row : null;
|
||||
}
|
||||
// 兼容老式客户端 / 老链接:资源名是**纯数字 id**(v45 之前我们生成的形态)
|
||||
if (ctype_digit($seg)) {
|
||||
$one = self::oneEvent((int)$cal['id'], (int)$seg, [$familyId, $me]);
|
||||
if ($one) {
|
||||
return $one;
|
||||
}
|
||||
}
|
||||
// 派生资源名:从派生列表里按净化后的 uid 找
|
||||
// 派生资源名:从派生列表里按净化后的 uid 找(派生条目不在 calendar_events 里)
|
||||
foreach (self::resources($cal, $familyId, $me) as $r) {
|
||||
if (self::segmentOf($r) === $seg) {
|
||||
return $r;
|
||||
|
||||
+12
-1
@@ -90,7 +90,18 @@ final class Ics
|
||||
$out[] = 'DESCRIPTION:' . self::escapeText((string)$e['note']);
|
||||
}
|
||||
if (($e['rrule'] ?? '') !== '') {
|
||||
$out[] = (string)$e['rrule']; // 原样带过(导入时存的就是原文)
|
||||
/* 🔴🔴 v45(2026-10-12)修的真 bug:这里原来**漏了 `RRULE:` 前缀**,
|
||||
直接吐 `$e['rrule']`(库里存的是**去掉前缀**的值,见 parse 的
|
||||
`'rrule' => self::nullOr($p['RRULE'])`)⇒ 导出的 ICS 里那一行是
|
||||
`FREQ=WEEKLY;UNTIL=20261231T235959Z` —— 一个**没有属性名**的裸行,
|
||||
任何客户端都不会把它当成重复规则(RFC 5545 要求 `RRULE:...`)。
|
||||
后果:所有导出/CalDAV 同步出去的重复日程,在别人那里**都变成单次**。
|
||||
为什么以前没被发现:夹具与线上导出的都是"不带重复"的日程,
|
||||
这条分支一直没被走到(本轮为了验 PUT 重复日程才第一次踩到)。 */
|
||||
/* ⚠️ 值类型是 **RECUR**,不是 TEXT —— 里面的逗号是**结构分隔符**
|
||||
(如 `BYDAY=MO,TU,WE,TH,FR`),**绝不能**用 escapeText 转义,
|
||||
否则客户端会把它当成一整块而解析失败。原样拼接即可。 */
|
||||
$out[] = 'RRULE:' . (string)$e['rrule'];
|
||||
}
|
||||
$out[] = 'STATUS:' . strtoupper((string)($e['status'] ?: 'confirmed'));
|
||||
$out[] = 'TRANSP:' . (empty($e['transp']) ? 'OPAQUE' : 'TRANSPARENT');
|
||||
|
||||
Reference in New Issue
Block a user