From 4ccbcc3360e1a6ded564f0d8e4f45625ef8c2081 Mon Sep 17 00:00:00 2001 From: laoyang Date: Mon, 12 Oct 2026 21:40:53 +0800 Subject: [PATCH] =?UTF-8?q?v45=20=E7=AC=AC=2045=20=E6=89=B9=EF=BC=9A?= =?UTF-8?q?=E6=97=A5=E7=A8=8B=E3=80=8C=E9=87=8D=E5=A4=8D=E5=88=B0=E3=80=8D?= =?UTF-8?q?=E6=8D=A2=E6=9C=80=E6=96=B0=E6=8E=A7=E4=BB=B6=20+=20CalDAV=20?= =?UTF-8?q?=E5=8F=8C=E5=90=91=E5=90=8C=E6=AD=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户 2026-10-12 两条: 1. 新建日程里「重复到」的那个日历选择框,改成最新控件。 - templates/calendar/_event_fields.php:裸 换成 .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 批形态)。 --- app/CalDav.php | 64 +++++- app/Ics.php | 13 +- dav.php | 303 +++++++++++++++++++++++++-- templates/calendar/_event_fields.php | 21 +- templates/home.php | 11 +- 5 files changed, 384 insertions(+), 28 deletions(-) diff --git a/app/CalDav.php b/app/CalDav.php index 1269989..2802a9c 100644 --- a/app/CalDav.php +++ b/app/CalDav.php @@ -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/.ics`(它按 UID 命名资源,参照也是), + * 服务端却按 id 生成 href ⇒ 它随后 GET/REPORT 自己刚写的那条 + * `/dav/1.ics/.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; diff --git a/app/Ics.php b/app/Ics.php index d786099..9c8b6d9 100644 --- a/app/Ics.php +++ b/app/Ics.php @@ -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'); diff --git a/dav.php b/dav.php index 1e5275b..a166383 100644 --- a/dav.php +++ b/dav.php @@ -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:propstat` 里的权限集。 + * + * 🔴 v45(用户第 2 条「完全按参照返回」):**按实际可写性**给 —— + * 参照 DAViCal 对可写本给的是 `` + 一整串 write 相关权限; + * 原来我们**恒给 read**,于是 SyncCalendar 判「只读」(见它 + * `entry.writable = /write/i.test(privSet)`),Thunderbird 也不让改。 + * ⇒ 能写就声明 ``(`write-content` / `bind` / `unbind` 一并给, + * 它们正是"能建/能删资源"的语义,参照也是这么给的); + * 只读则只给 ``。 */ +$privOf = static function (bool $writable): string { + if ($writable) { + return '' + . '' + . '' + . '' + . '' + . '' + . '' + . ''; + } + return '' + . ''; +}; +/** 某个本子我能不能写(照 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[] = '' . CalDav::x($href) . '' . '' . '' . '' . CalDav::x($dispName($c)) . '' + . 'httpd/unix-directory' . '' . CalDav::x('家书 · ' . (string)$c['name']) . '' . '' . CalDav::x((string)$c['color']) . '' . '' - . $readOnlyPriv + . '' . CalDav::x($ctag) . '' + . '' . CalDav::x($ctag) . '' + . '' . CalDav::x(CalDav::PRINCIPAL_PATH) . '' + . $privOf($w) . ''; } // 🔴 v33:**始终**先输出 home 集合自身。 @@ -379,7 +464,9 @@ if ($method === 'PROPFIND') { . '' . ''); } - $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/"', [ '' . CalDav::x(CalDav::bookHref($bookId)) . '' . '' . '' . '' . CalDav::x($dispName($cal)) . '' + . 'httpd/unix-directory' + . '' . CalDav::x('家书 · ' . (string)$cal['name']) . '' + . '' . CalDav::x((string)$cal['color']) . '' . '' - . $readOnlyPriv + . '' . CalDav::x($ct) . '' + . '' . CalDav::x($ct) . '' + . '' . CalDav::x(CalDav::PRINCIPAL_PATH) . '' + . $privOf($cw) . '', ]); 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); +// · 资源名由客户端决定(`.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 到 .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, '不支持这个请求方法。'); \ No newline at end of file diff --git a/templates/calendar/_event_fields.php b/templates/calendar/_event_fields.php index 1f510e0..a6fd7ce 100644 --- a/templates/calendar/_event_fields.php +++ b/templates/calendar/_event_fields.php @@ -213,8 +213,25 @@ $evLab = static function (string $text, string $for = '', bool $star = false) us
> - + ` 换成 AntD **纯日期** DatePicker。 + + ⚠️ 做法与「纪念日 → 日期」完全同一套(v42/v43 已验证): + · 外层 `.dt-input` + `data-antd-date="1"` → app.js 的 `mountAntdDate` 接管; + · 值载体仍是**同一个** `name="rep_until"` 的 ``, + 只是被 `.dt-native` 包起来(否则那条"收起原生框"的 CSS 不命中 ⇒ + 会出现"多了一个控件",正是 v43 纪念日踩过的坑); + · 服务端契约**一字不改**:`Repeat::build($_POST)` 照旧读 `rep_until`。 + ⚠️ 千万别给这个 `.dt-input` 加 `data-dt="…"`:那是"年/月/日+时/分"那套 + 四字段契约的标记,加上去会被 `upgrade()` 抢走、反而绑错处理器。 */ ?> +
+ + + +
diff --git a/templates/home.php b/templates/home.php index 3656095..8e0ac8f 100644 --- a/templates/home.php +++ b/templates/home.php @@ -322,7 +322,16 @@ unset($evRef); ⚠️ CSS 里那条 `.ag-now-dot` 也一并删(留在那儿是死规则, 还会让"红线只有一根"这件事在样式表里看不出结论)。 */ ?> -
+ +