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:
2026-10-12 21:40:53 +08:00
parent 9f41e1622e
commit 4ccbcc3360
5 changed files with 384 additions and 28 deletions
+55 -9
View File
@@ -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
View File
@@ -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');
+288 -15
View File
@@ -16,8 +16,14 @@
* index.php 那套 `?r=xxx` 路由是 GET/POST 世界里的东西,
* 混在一起会让两边都变难懂。这里只做"按方法分派",业务在 App\CalDav 里。
*
* 本阶段是**只读**实现(RFC 4791 的子集):写方法一律 403。
* 与其"半支持"(客户端以为写成功了、服务端却没落库/没合冲突),不如明确拒绝。
* v24~v44 是**只读**实现(RFC 4791 的子集):写方法一律 403。
* 🔴 v45(用户 2026-10-12 第 2 条):**改成双向同步** —— 用户给了一个可用的
* 参照服务器(群晖 DAViCal)要求「完全按照这个来返回就行」。抓参照 + 复刻
* Thunderbird / SyncCalendar 的真实请求序列后确认:参照是**可写**的,
* 而只读会带来两个真问题(见下面 §7b 的长注释)。现在支持:
* PUT(新建/更新日程,按 UID 落库,带 ETag 与 If-Match 乐观锁)
* DELETE(删日程)· MKCALENDAR(建普通本)
* ⚠️ 仍**不支持** PROPPATCH / POST / PATCH,也**不声称** ACL —— 那些明确拒。
*
* ---------------------------------------------------------------------------
* 🔴 v31(2026-10-08):用户报「carddav 和 caldav 好像都不好用,没有任何数据被同步出来」。
@@ -50,6 +56,8 @@ require __DIR__ . '/app/bootstrap.php';
use App\Auth;
use App\CalDav;
use App\Calendar;
use App\Database;
use App\Ics;
/** 统一的失败出口(CalDAV 客户端只认状态码,正文给点人话便于排查) */
function dav_fail(int $code, string $msg): void
@@ -199,16 +207,44 @@ $isRoot = ($path === '/' || $path === '');
$isPrincipal = ($norm === CalDav::PRINCIPAL_PATH);
$isHome = ($norm === CalDav::HOME_PATH);
// ---- 2. 只读:写方法直接拒(明确拒比"写一半"好)----
if (in_array($method, ['PUT', 'DELETE', 'PROPPATCH', 'MKCALENDAR', 'POST', 'PATCH'], true)) {
dav_fail(403, '家书的 CalDAV 目前是只读订阅:请在本站里修改日程。');
// ---- 2. 写方法:**逐本判权**(v45 起支持双向同步)----
//
// 🔴🔴 v45(用户 2026-10-12 第 2 条):用户给了一个可用的参照服务器
// (群晖 DAViCal)要求「完全按照这个来返回就行」。抓参照 + 复刻 SyncCalendar
// 客户端的真实请求序列后,确认真根因是:
// **参照是双向可写的,而家书对写方法一律回 403、权限也只声明 read。**
// 后果分两层:
// ① SyncCalendar 用 `PUT .../synccalendar-probe.ics` **探测可写性**(见它
// DavClient.probeWritable)——收到 403 就把整本标成「只读」;
// ② Thunderbird 更直接:它是**双向同步**客户端,PUT/DELETE 被拒就报错,
// 且日历显示为只读,「在 TB 里改日程」完全不可用。
// ⇒ 本轮按参照实现 PUT / DELETE(MKCALENDAR 也一并给)。
//
// ⚠️ 但**不能**无条件放开:家书里日历本有「只读共享」这回事(别人共享给你的本
// 可能只有 read 权限)。所以先按本子判权,**能写才进写分支**,不能写仍 403。
// ⚠️ PROPPATCH / POST / PATCH 仍不支持(参照的 Allow 里也没有 PROPPATCH;
// 它支持 ACL/MKCALENDAR,我们不需要 ACL)—— 明确拒比"写一半"好。
if ($method === 'PROPPATCH' || $method === 'POST' || $method === 'PATCH') {
header('Allow: OPTIONS, GET, HEAD, PROPFIND, REPORT, PUT, DELETE, MKCALENDAR');
dav_fail(405, '家书的 DAV 不支持这个写方法(支持 PUT / DELETE)。');
}
// ---- 3. OPTIONS:客户端第一句就问"你支持什么" ----
// 🔴 v45:Allow / DAV 头**照参照 DAViCal 补齐** ——
// · Allow 里要有 PUT / DELETE / MKCALENDAR(否则客户端判定"不可写");
// · DAV 头补 `calendar-schedule` 与第二行 `extended-mkcol, ...`(参照有两行)。
// 参照:`DAV: 1, 2, 3, access-control, calendar-access, calendar-schedule`
// `DAV: extended-mkcol, bind, addressbook, calendar-auto-schedule`
// ⚠️ 我们**不**声称 access-control(没实现 ACL),也不声称 addressbook
// 这一层是 CardDAV 的(本文件是 CalDAV 入口;CardDAV 那边照旧)。
if ($method === 'OPTIONS') {
http_response_code(200);
header('DAV: 1, 2, 3, calendar-access');
header('Allow: OPTIONS, GET, HEAD, PROPFIND, REPORT');
// ⚠️ 第二个参数 `false` = **追加而不是替换**。参照 DAViCal 发的是**两行** DAV 头;
// PHP 的 `header()` 同名默认**覆盖** ⇒ 不给 false 的话第二行会把第一行顶掉,
// `calendar-access` 就没了(本探针 A3 抓到的正是这个)。
header('DAV: 1, 2, 3, calendar-access, calendar-schedule', false);
header('DAV: extended-mkcol', false);
header('Allow: OPTIONS, GET, HEAD, PROPFIND, REPORT, PUT, DELETE, MKCALENDAR');
header('MS-Author-Via: DAV');
exit;
}
@@ -231,9 +267,42 @@ $dispName = static function (array $c) use ($me): string {
return $nm;
};
/** 一个 `d:propstat` 里塞当前用户的读权限(只读实现,所以恒为 read) */
$readOnlyPriv = '<d:current-user-privilege-set><d:privilege><d:read/></d:privilege>'
. '</d:current-user-privilege-set>';
/** 一个 `d:propstat` 里的权限集。
*
* 🔴 v45(用户第 2 条「完全按参照返回」):**按实际可写性**给 ——
* 参照 DAViCal 对可写本给的是 `<privilege><all/>` + 一整串 write 相关权限;
* 原来我们**恒给 read**,于是 SyncCalendar 判「只读」(见它
* `entry.writable = /write/i.test(privSet)`),Thunderbird 也不让改。
* ⇒ 能写就声明 `<write/>`(`write-content` / `bind` / `unbind` 一并给,
* 它们正是"能建/能删资源"的语义,参照也是这么给的);
* 只读则只给 `<read/>`。 */
$privOf = static function (bool $writable): string {
if ($writable) {
return '<d:current-user-privilege-set>'
. '<d:privilege><d:read/></d:privilege>'
. '<d:privilege><d:write/></d:privilege>'
. '<d:privilege><d:write-properties/></d:privilege>'
. '<d:privilege><d:write-content/></d:privilege>'
. '<d:privilege><d:bind/></d:privilege>'
. '<d:privilege><d:unbind/></d:privilege>'
. '</d:current-user-privilege-set>';
}
return '<d:current-user-privilege-set><d:privilege><d:read/></d:privilege>'
. '</d:current-user-privilege-set>';
};
/** 某个本子我能不能写(照 Calendar::canWrite 的真规则)。
*
* ⚠️ 还要**排除系统派生本**(农历/节假日/传统节日/二十四节气)——
* 那些本我虽然是 owner(`canWrite` 恒真),但它们是系统生成的、不能往里写。
* 漏这一条会让权限集对派生本声明 `write` ⇒ 客户端显示"可编辑"、
* 用户改完才在 PUT 那一步吃 403(体验很差,也是本探针 B2 抓到的)。 */
$canWriteCal = static function ($cal) use ($me): bool {
return $cal !== null
&& Calendar::canWrite($cal, $me)
&& !Calendar::isReadonlyKind((string)($cal['kind'] ?? ''));
};
// 兼容旧调用点:home / principal 这类"容器"不是具体某一本,按"至少有一本可写"给
$readOnlyPriv = $privOf(false);
/**
* 输出一段 multistatus(207 + XML 头)。
@@ -339,14 +408,30 @@ if ($method === 'PROPFIND') {
foreach (CalDav::books($familyId, $me) as $c) {
$href = CalDav::hrefOf($c);
$w = $canWriteCal($c); // 🔴 v45:按本子给真权限(含派生本只读)
/* 🔴 v45:**照参照补齐属性**(用户「完全按参照返回」)——
参照 DAViCal 在日历本上给了:resourcetype / displayname /
getcontenttype / sync-token / getctag / supported-calendar-component-set /
current-user-privilege-set / owner,并把没实现的属性单独放一个
404 propstat(getctag / calendar-color / description / timezone)。
⚠️ 我们**照给** owner 与 getctag(客户端用 getctag 判"这本变没变"),
但不给 sync-token(我们没实现 sync-collection —— 谎报会让
客户端走 sync-collection 然后拿到 501)。
⚠️ calendar-color 我们**有**(模块色),所以放在 200 那一组里,
不像参照那样 404 —— 有就给,是更好的实现。 */
$ctag = '"' . md5($href . ':' . CalDav::ctagSeedOf($c)) . '"';
$parts[] = '<d:response><d:href>' . CalDav::x($href) . '</d:href>'
. '<d:propstat><d:prop>'
. '<d:resourcetype><d:collection/><c:calendar/></d:resourcetype>'
. '<d:displayname>' . CalDav::x($dispName($c)) . '</d:displayname>'
. '<d:getcontenttype>httpd/unix-directory</d:getcontenttype>'
. '<c:calendar-description>' . CalDav::x('家书 · ' . (string)$c['name']) . '</c:calendar-description>'
. '<x:calendar-color xmlns:x="http://apple.com/ns/ical/">' . CalDav::x((string)$c['color']) . '</x:calendar-color>'
. '<c:supported-calendar-component-set><c:comp name="VEVENT"/></c:supported-calendar-component-set>'
. $readOnlyPriv
. '<cs:getctag>' . CalDav::x($ctag) . '</cs:getctag>'
. '<d:getctag>' . CalDav::x($ctag) . '</d:getctag>'
. '<d:owner><d:href>' . CalDav::x(CalDav::PRINCIPAL_PATH) . '</d:href></d:owner>'
. $privOf($w)
. '</d:prop></d:propstat></d:response>';
}
// 🔴 v33:**始终**先输出 home 集合自身。
@@ -379,7 +464,9 @@ if ($method === 'PROPFIND') {
. '</d:href></c:calendar-home-set>'
. '</d:prop></d:propstat></d:response>');
}
$emit('xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav" xmlns:x="http://apple.com/ns/ical/"', $parts);
$emit('xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav" '
. 'xmlns:x="http://apple.com/ns/ical/" '
. 'xmlns:cs="http://calendarserver.org/ns/"', $parts);
exit;
}
@@ -409,13 +496,25 @@ if ($method === 'PROPFIND') {
}
// Depth 0:这一天历本自身的属性
$emit('xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav"', [
// 🔴 v45:权限按**这本实际可写性**给(原来恒 read);并按参照补
// getcontenttype / getctag / owner / calendar-description / 颜色。
$cw = $canWriteCal($cal);
$ct = '"' . md5(CalDav::bookHref($bookId) . ':' . CalDav::ctagSeedOf($cal)) . '"';
$emit('xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav" '
. 'xmlns:x="http://apple.com/ns/ical/" '
. 'xmlns:cs="http://calendarserver.org/ns/"', [
'<d:response><d:href>' . CalDav::x(CalDav::bookHref($bookId)) . '</d:href>'
. '<d:propstat><d:prop>'
. '<d:resourcetype><d:collection/><c:calendar/></d:resourcetype>'
. '<d:displayname>' . CalDav::x($dispName($cal)) . '</d:displayname>'
. '<d:getcontenttype>httpd/unix-directory</d:getcontenttype>'
. '<c:calendar-description>' . CalDav::x('家书 · ' . (string)$cal['name']) . '</c:calendar-description>'
. '<x:calendar-color xmlns:x="http://apple.com/ns/ical/">' . CalDav::x((string)$cal['color']) . '</x:calendar-color>'
. '<c:supported-calendar-component-set><c:comp name="VEVENT"/></c:supported-calendar-component-set>'
. $readOnlyPriv
. '<cs:getctag>' . CalDav::x($ct) . '</cs:getctag>'
. '<d:getctag>' . CalDav::x($ct) . '</d:getctag>'
. '<d:owner><d:href>' . CalDav::x(CalDav::PRINCIPAL_PATH) . '</d:href></d:owner>'
. $privOf($cw)
. '</d:prop></d:propstat></d:response>',
]);
exit;
@@ -561,6 +660,180 @@ if ($method === 'REPORT') {
exit;
}
// ---- 7b. PUT:新建 / 更新一条日程(RFC 4791 §5.3.2)----
//
// 🔴 v45(用户第 2 条「完全按参照返回」):这是「双向同步」的核心。
// Thunderbird / SyncCalendar 都靠 PUT 把本地的改动推上来。
//
// 契约(照参照与 RFC):
// · 请求体 = 一份完整 VCALENDAR(含一个 VEVENT,带 UID);
// · 资源名由客户端决定(`<UID>.ics` 或任意名)—— 我们**按 UID 去重落库**,
// 不依赖文件名,这样客户端改名/换名也不会重复建;
// · 新建成功 → **201 Created** + `ETag`(客户端记下来做 If-Match);
// · 更新成功 → **204 No Content** + 新 `ETag`(RFC:更新不带正文);
// · 带 `If-Match` 且与当前 ETag 不符 → **412 Precondition Failed**
// (这是防"两个设备同时改"的乐观锁,客户端会重拉后再推);
// · 目标本不可写 → **403**(只读共享的本子不能写,与网页端同一条规则);
// · 正文不是合法日历 → **415**(参照与我们都不接受非 text/calendar)。
//
// ⚠️ 落库一律走 `Calendar::importEvent()` —— 它与网页「导入 .ics」是**同一个**
// 函数,所以"手机上建的"与"网页里导入的"结果完全一致(UID 去重、重复规则、
// 提醒、全天与否都由它统一处理)。**绝不在 DAV 里另写一套 INSERT**。
if ($method === 'PUT') {
if (!$cal) {
dav_fail(404, '没有这本日历。');
}
if (!Calendar::canWrite($cal, $me)) {
dav_fail(403, '这本日历是只读共享,你不能往里写。');
}
// 派生本(观影/旅程/农历/节假日…)是**系统生成的只读本**,不能被客户端写。
if (Calendar::isReadonlyKind((string)$cal['kind'])) {
dav_fail(403, '这本是系统生成的只读日历,不能写入。');
}
$bodyRaw = (string)file_get_contents('php://input');
// 只收 text/calendar(客户端本来就会带;不带也宽容放行,但空正文一定拒)
$ctype = (string)($_SERVER['CONTENT_TYPE'] ?? '');
if ($ctype !== '' && stripos($ctype, 'calendar') === false && stripos($ctype, 'text/plain') === false) {
dav_fail(415, '只接受 text/calendar 正文。');
}
if (trim($bodyRaw) === '') {
dav_fail(400, '正文是空的。');
}
$list = Ics::parse($bodyRaw);
if (!$list) {
dav_fail(415, '正文里没有找到 VEVENT。');
}
$v = $list[0]; // 一个资源 = 一个 VEVENT(重复系列整份在一个 VEVENT 里)
if (empty($v['starts_at'])) {
dav_fail(422, 'VEVENT 缺 DTSTART。');
}
if (trim((string)($v['title'] ?? '')) === '') {
$v['title'] = '(无标题日程)';
}
$uidIn = (string)($v['uid'] ?? '');
// 是不是"改已有的":**优先按资源名(uid)找**(客户端 PUT 到 <uid>.ics),
// 再按正文里的 UID 找,最后兼容老式数字-id 资源名。
// ⚠️ 三种都要有,缺一种就会出现"客户端改完变成两条"或"改了但旧的那条还在"。
$existingId = 0;
if ($evSeg !== '') {
$bySeg = CalDav::oneResource($cal, $familyId, $me, $evSeg);
if ($bySeg && (int)($bySeg['id'] ?? 0) > 0) {
$existingId = (int)$bySeg['id'];
}
}
if ($existingId === 0 && $uidIn !== '') {
$st = Database::get()->prepare(
'SELECT id FROM calendar_events WHERE calendar_id = ? AND uid = ? LIMIT 1'
);
$st->execute([(int)$cal['id'], $uidIn]);
$existingId = (int)$st->fetchColumn();
}
// 乐观锁:If-Match 给了就必须与当前 ETag 相符(不符 = 别人改过了 → 412)
if ($existingId > 0) {
$cur = CalDav::oneEvent((int)$cal['id'], $existingId, [$familyId, $me]);
$curEt = $cur ? CalDav::resourceOf($cur, (string)$cal['name'])['etag'] : '';
$ifMatch = trim((string)($_SERVER['HTTP_IF_MATCH'] ?? ''));
if ($ifMatch !== '' && $ifMatch !== '*' && $curEt !== '' && $ifMatch !== $curEt) {
header('ETag: ' . $curEt);
dav_fail(412, '这条日程已经被改过了,请重新拉取后再试。');
}
// 归属校验:mime 前面已用 `Calendar::canWrite($cal, $me)` 判过"这本能不能写",
// 而 `$existingId` 这条日程必然是**这本里**的(上面按 calendar_id 查的),
// 所以无需再判一次 —— 只读共享的本在函数开头就被 403 拦掉了。
Calendar::updateEvent($existingId, $v);
// 保 UID 不被清空(老客户端可能不带 UID 就改:那就沿用原来的)
if ($uidIn === '') {
Database::get()->prepare('UPDATE calendar_events SET uid = ? WHERE id = ?')
->execute([CalDav::segmentOf($cur ?: ['id' => $existingId]), $existingId]);
}
$after = CalDav::oneEvent((int)$cal['id'], $existingId, [$familyId, $me]);
$et = CalDav::resourceOf($after ?: $cur, (string)$cal['name'])['etag'];
http_response_code(204);
header('ETag: ' . $et);
exit;
}
// 新建
$newId = Calendar::importEvent((int)$cal['id'], $v, $me) === 'added'
? (int)Database::get()->lastInsertId() : 0;
$row = $newId > 0 ? CalDav::oneEvent((int)$cal['id'], $newId, [$familyId, $me]) : null;
if (!$row && $uidIn !== '') {
$st = Database::get()->prepare(
'SELECT id FROM calendar_events WHERE calendar_id = ? AND uid = ? LIMIT 1');
$st->execute([(int)$cal['id'], $uidIn]);
$rid = (int)$st->fetchColumn();
$row = $rid > 0 ? CalDav::oneEvent((int)$cal['id'], $rid, [$familyId, $me]) : null;
}
$et = $row ? CalDav::resourceOf($row, (string)$cal['name'])['etag'] : '';
http_response_code(201);
if ($et !== '') {
header('ETag: ' . $et);
}
if ($row) {
header('Location: ' . CalDav::bookHref($bookId) . CalDav::segmentOf($row) . '.ics');
}
exit;
}
// ---- 7c. DELETE:删一条日程 ----
//
// 照参照与 RFC:成功 **204 No Content**;不存在 **404**;本不可写 **403**。
// ⚠️ 删除的是**整条资源**(含重复系列的全部实例)—— CalDAV 里重复系列就是
// 一个资源,客户端要"只删某一次"会先 PUT 改 RRULE + EXDATE(那是另一条路径,
// 我们不能再替他做决定)。这样语义最简单,也与参照一致。
if ($method === 'DELETE') {
if (!$cal) {
dav_fail(404, '没有这本日历。');
}
if (!Calendar::canWrite($cal, $me) || Calendar::isReadonlyKind((string)$cal['kind'])) {
dav_fail(403, '这本日历不能写入/删除。');
}
if ($evSeg === '') {
dav_fail(404, '要删哪条?没给出资源名。');
}
// 🔴 v45:资源名现在**是 UID**(见 CalDav::segmentOf)—— 用 oneResource 解析,
// 它同时兼容老的数字-id 资源名。
// ⚠️ 但**只能删"真的有实体"的那种**(派生条目是算出来的、删不掉),
// 所以解析出来的行必须带正的 calendar_events.id。
$one = CalDav::oneResource($cal, $familyId, $me, $evSeg);
if (!$one || (int)($one['id'] ?? 0) <= 0) {
dav_fail(404, '没有这条日程(或它是系统派生的只读条目,删不了)。');
}
Calendar::deleteEvent((int)$one['id']);
http_response_code(204);
exit;
}
// ---- 7d. MKCALENDAR:新建一个日历本(参照支持;Thunderbird 会用它)----
//
// ⚠️ 我们**只允许建普通本**(kind=custom)。理由:观影/旅程/农历那些是
// 系统派生的只读本,客户端建不出来也不该建。
if ($method === 'MKCALENDAR') {
$bodyRaw = (string)file_get_contents('php://input');
$name = '';
if (preg_match('#<[^>]*displayname[^>]*>([^<]*)<#i', $bodyRaw, $m)) {
$name = trim(html_entity_decode($m[1], ENT_QUOTES | ENT_XML1, 'UTF-8'));
}
if ($name === '') {
$name = '新日历本';
}
$color = '#4A7C82';
if (preg_match('#<[^>]*calendar-color[^>]*>([^<]*)<#i', $bodyRaw, $m)) {
$c = trim($m[1]);
if (preg_match('/^#[0-9A-Fa-f]{6}$/', substr($c, 0, 7))) {
$color = substr($c, 0, 7);
}
}
$newCal = Calendar::create($familyId, $me, mb_substr($name, 0, 60, 'UTF-8'), $color, 'custom');
http_response_code(201);
header('Location: ' . CalDav::bookHref($newCal));
exit;
}
// ---- 8. 其它方法一律 405(并告知支持哪些)----
header('Allow: OPTIONS, GET, HEAD, PROPFIND, REPORT');
header('Allow: OPTIONS, GET, HEAD, PROPFIND, REPORT, PUT, DELETE, MKCALENDAR');
dav_fail(405, '不支持这个请求方法。');
+19 -2
View File
@@ -213,8 +213,25 @@ $evLab = static function (string $text, string $for = '', bool $star = false) us
<?php echo $evRowOpen('', !$evRepeating || (string)$evRp['until'] === ''); ?>
<?= $evLab('重复到', 'ev-rep-until') ?>
<div id="ev-rep-until-box"<?= ($evRepeating && (string)$evRp['until'] !== '') ? '' : ' hidden' ?>>
<input class="input" type="date" id="ev-rep-until" name="rep_until"
value="<?= e((string)($evRp['until'] ?? '')) ?>">
<?php /* 🔴 v45(用户 2026-10-12 第 1 条):「添加日程这里,当选择了重复,
并且选择了重复到某天为止,这时候重复到会出现,有一个日历选择框,
这个日历选择框也改为我们最新的控件。」
⇒ 把原来那个原生 `<input type="date">` 换成 AntD **纯日期** DatePicker。
⚠️ 做法与「纪念日 → 日期」完全同一套(v42/v43 已验证):
· 外层 `.dt-input` + `data-antd-date="1"` → app.js 的 `mountAntdDate` 接管;
· 值载体仍是**同一个** `name="rep_until"` 的 `<input type="date">`,
只是被 `.dt-native` 包起来(否则那条"收起原生框"的 CSS 不命中 ⇒
会出现"多了一个控件",正是 v43 纪念日踩过的坑);
· 服务端契约**一字不改**:`Repeat::build($_POST)` 照旧读 `rep_until`。
⚠️ 千万别给这个 `.dt-input` 加 `data-dt="…"`:那是"年/月/日+时/分"那套
四字段契约的标记,加上去会被 `upgrade()` 抢走、反而绑错处理器。 */ ?>
<div class="dt-input dt-antd-single" data-antd-date="1" id="ev-rep-until-wrap">
<span class="dt-native">
<input type="date" class="dt-date" id="ev-rep-until" name="rep_until"
value="<?= e((string)($evRp['until'] ?? '')) ?>" aria-label="重复到">
</span>
</div>
</div>
</div>
+10 -1
View File
@@ -322,7 +322,16 @@ unset($evRef);
⚠️ CSS 里那条 `.ag-now-dot` 也一并删(留在那儿是死规则,
还会让"红线只有一根"这件事在样式表里看不出结论)。 */ ?>
<?php if ($showNow): ?>
<div class="ag-now" style="top: <?= number_format($nowPos * 100, 2) ?>%"></div>
<?php /* 🔴 v45:把**服务端算红线用的那个分钟数**也写到元素上(`data-now-min`)。
为什么:探针原来只能"拿服务端渲染出的 top% 去和浏览器此刻的时间比" ——
两者的容差被 0.3% 一道卡着,而"整小时 vs 分钟"的差只等于
`分钟/60/跨度`:当分钟数很小时(如第 2 分钟)占比不到 0.3%,
**注入旧行为也不会变红** ⇒ 反向验证当场判它"恒真摆设"。
写上 data-now-min 之后,探针可以做一个**零容差**的判据:
"服务端用的分钟数 == 浏览器此刻的分钟数",整小时注入必然对不上。
⚠️ 它只是给测试用的观测点,不影响任何渲染/行为。 */ ?>
<div class="ag-now" style="top: <?= number_format($nowPos * 100, 2) ?>%"
data-now-min="<?= (int)$nowMinutes ?>"></div>
<?php endif; ?>
<?php /* 日程块:按"相对第一个刻度的小时数"定位(与日视图同一套比例)---- */ ?>
<?php