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)。
|
* 资源名(href 的最后一段,不含 .ics)。
|
||||||
*
|
*
|
||||||
* - 自定义日程:用 calendar_events.id,稳定且与 oneEvent() 的入参一一对应;
|
* 🔴🔴 v45(用户第 2 条,照参照 DAViCal 对齐):**一律用 UID 的净化形式**。
|
||||||
* - 派生条目:id 为 0,用 uid 的净化形式(同书注释:"派生本没有 calendar_events.id")。
|
* 原来自定义日程用 `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
|
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'] ?? ''));
|
$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) . '"'];
|
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 用)。
|
* 按**资源名**找一个条目(供单条资源的 GET / REPORT 用)。
|
||||||
*
|
*
|
||||||
@@ -272,14 +303,29 @@ final class CalDav
|
|||||||
if ($seg === '') {
|
if ($seg === '') {
|
||||||
return null;
|
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)) {
|
if (ctype_digit($seg)) {
|
||||||
$one = self::oneEvent((int)$cal['id'], (int)$seg, [$familyId, $me]);
|
$one = self::oneEvent((int)$cal['id'], (int)$seg, [$familyId, $me]);
|
||||||
if ($one) {
|
if ($one) {
|
||||||
return $one;
|
return $one;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 派生资源名:从派生列表里按净化后的 uid 找
|
// 派生资源名:从派生列表里按净化后的 uid 找(派生条目不在 calendar_events 里)
|
||||||
foreach (self::resources($cal, $familyId, $me) as $r) {
|
foreach (self::resources($cal, $familyId, $me) as $r) {
|
||||||
if (self::segmentOf($r) === $seg) {
|
if (self::segmentOf($r) === $seg) {
|
||||||
return $r;
|
return $r;
|
||||||
|
|||||||
+12
-1
@@ -90,7 +90,18 @@ final class Ics
|
|||||||
$out[] = 'DESCRIPTION:' . self::escapeText((string)$e['note']);
|
$out[] = 'DESCRIPTION:' . self::escapeText((string)$e['note']);
|
||||||
}
|
}
|
||||||
if (($e['rrule'] ?? '') !== '') {
|
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[] = 'STATUS:' . strtoupper((string)($e['status'] ?: 'confirmed'));
|
||||||
$out[] = 'TRANSP:' . (empty($e['transp']) ? 'OPAQUE' : 'TRANSPARENT');
|
$out[] = 'TRANSP:' . (empty($e['transp']) ? 'OPAQUE' : 'TRANSPARENT');
|
||||||
|
|||||||
@@ -16,8 +16,14 @@
|
|||||||
* index.php 那套 `?r=xxx` 路由是 GET/POST 世界里的东西,
|
* index.php 那套 `?r=xxx` 路由是 GET/POST 世界里的东西,
|
||||||
* 混在一起会让两边都变难懂。这里只做"按方法分派",业务在 App\CalDav 里。
|
* 混在一起会让两边都变难懂。这里只做"按方法分派",业务在 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 好像都不好用,没有任何数据被同步出来」。
|
* 🔴 v31(2026-10-08):用户报「carddav 和 caldav 好像都不好用,没有任何数据被同步出来」。
|
||||||
@@ -50,6 +56,8 @@ require __DIR__ . '/app/bootstrap.php';
|
|||||||
use App\Auth;
|
use App\Auth;
|
||||||
use App\CalDav;
|
use App\CalDav;
|
||||||
use App\Calendar;
|
use App\Calendar;
|
||||||
|
use App\Database;
|
||||||
|
use App\Ics;
|
||||||
|
|
||||||
/** 统一的失败出口(CalDAV 客户端只认状态码,正文给点人话便于排查) */
|
/** 统一的失败出口(CalDAV 客户端只认状态码,正文给点人话便于排查) */
|
||||||
function dav_fail(int $code, string $msg): void
|
function dav_fail(int $code, string $msg): void
|
||||||
@@ -199,16 +207,44 @@ $isRoot = ($path === '/' || $path === '');
|
|||||||
$isPrincipal = ($norm === CalDav::PRINCIPAL_PATH);
|
$isPrincipal = ($norm === CalDav::PRINCIPAL_PATH);
|
||||||
$isHome = ($norm === CalDav::HOME_PATH);
|
$isHome = ($norm === CalDav::HOME_PATH);
|
||||||
|
|
||||||
// ---- 2. 只读:写方法直接拒(明确拒比"写一半"好)----
|
// ---- 2. 写方法:**逐本判权**(v45 起支持双向同步)----
|
||||||
if (in_array($method, ['PUT', 'DELETE', 'PROPPATCH', 'MKCALENDAR', 'POST', 'PATCH'], true)) {
|
//
|
||||||
dav_fail(403, '家书的 CalDAV 目前是只读订阅:请在本站里修改日程。');
|
// 🔴🔴 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:客户端第一句就问"你支持什么" ----
|
// ---- 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') {
|
if ($method === 'OPTIONS') {
|
||||||
http_response_code(200);
|
http_response_code(200);
|
||||||
header('DAV: 1, 2, 3, calendar-access');
|
// ⚠️ 第二个参数 `false` = **追加而不是替换**。参照 DAViCal 发的是**两行** DAV 头;
|
||||||
header('Allow: OPTIONS, GET, HEAD, PROPFIND, REPORT');
|
// 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');
|
header('MS-Author-Via: DAV');
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
@@ -231,9 +267,42 @@ $dispName = static function (array $c) use ($me): string {
|
|||||||
return $nm;
|
return $nm;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** 一个 `d:propstat` 里塞当前用户的读权限(只读实现,所以恒为 read) */
|
/** 一个 `d:propstat` 里的权限集。
|
||||||
$readOnlyPriv = '<d:current-user-privilege-set><d:privilege><d:read/></d:privilege>'
|
*
|
||||||
. '</d:current-user-privilege-set>';
|
* 🔴 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 头)。
|
* 输出一段 multistatus(207 + XML 头)。
|
||||||
@@ -339,14 +408,30 @@ if ($method === 'PROPFIND') {
|
|||||||
|
|
||||||
foreach (CalDav::books($familyId, $me) as $c) {
|
foreach (CalDav::books($familyId, $me) as $c) {
|
||||||
$href = CalDav::hrefOf($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>'
|
$parts[] = '<d:response><d:href>' . CalDav::x($href) . '</d:href>'
|
||||||
. '<d:propstat><d:prop>'
|
. '<d:propstat><d:prop>'
|
||||||
. '<d:resourcetype><d:collection/><c:calendar/></d:resourcetype>'
|
. '<d:resourcetype><d:collection/><c:calendar/></d:resourcetype>'
|
||||||
. '<d:displayname>' . CalDav::x($dispName($c)) . '</d:displayname>'
|
. '<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>'
|
. '<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>'
|
. '<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>'
|
. '<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>';
|
. '</d:prop></d:propstat></d:response>';
|
||||||
}
|
}
|
||||||
// 🔴 v33:**始终**先输出 home 集合自身。
|
// 🔴 v33:**始终**先输出 home 集合自身。
|
||||||
@@ -379,7 +464,9 @@ if ($method === 'PROPFIND') {
|
|||||||
. '</d:href></c:calendar-home-set>'
|
. '</d:href></c:calendar-home-set>'
|
||||||
. '</d:prop></d:propstat></d:response>');
|
. '</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;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -409,13 +496,25 @@ if ($method === 'PROPFIND') {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Depth 0:这一天历本自身的属性
|
// 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:response><d:href>' . CalDav::x(CalDav::bookHref($bookId)) . '</d:href>'
|
||||||
. '<d:propstat><d:prop>'
|
. '<d:propstat><d:prop>'
|
||||||
. '<d:resourcetype><d:collection/><c:calendar/></d:resourcetype>'
|
. '<d:resourcetype><d:collection/><c:calendar/></d:resourcetype>'
|
||||||
. '<d:displayname>' . CalDav::x($dispName($cal)) . '</d:displayname>'
|
. '<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>'
|
. '<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>',
|
. '</d:prop></d:propstat></d:response>',
|
||||||
]);
|
]);
|
||||||
exit;
|
exit;
|
||||||
@@ -561,6 +660,180 @@ if ($method === 'REPORT') {
|
|||||||
exit;
|
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(并告知支持哪些)----
|
// ---- 8. 其它方法一律 405(并告知支持哪些)----
|
||||||
header('Allow: OPTIONS, GET, HEAD, PROPFIND, REPORT');
|
header('Allow: OPTIONS, GET, HEAD, PROPFIND, REPORT, PUT, DELETE, MKCALENDAR');
|
||||||
dav_fail(405, '不支持这个请求方法。');
|
dav_fail(405, '不支持这个请求方法。');
|
||||||
@@ -213,8 +213,25 @@ $evLab = static function (string $text, string $for = '', bool $star = false) us
|
|||||||
<?php echo $evRowOpen('', !$evRepeating || (string)$evRp['until'] === ''); ?>
|
<?php echo $evRowOpen('', !$evRepeating || (string)$evRp['until'] === ''); ?>
|
||||||
<?= $evLab('重复到', 'ev-rep-until') ?>
|
<?= $evLab('重复到', 'ev-rep-until') ?>
|
||||||
<div id="ev-rep-until-box"<?= ($evRepeating && (string)$evRp['until'] !== '') ? '' : ' hidden' ?>>
|
<div id="ev-rep-until-box"<?= ($evRepeating && (string)$evRp['until'] !== '') ? '' : ' hidden' ?>>
|
||||||
<input class="input" type="date" id="ev-rep-until" name="rep_until"
|
<?php /* 🔴 v45(用户 2026-10-12 第 1 条):「添加日程这里,当选择了重复,
|
||||||
value="<?= e((string)($evRp['until'] ?? '')) ?>">
|
并且选择了重复到某天为止,这时候重复到会出现,有一个日历选择框,
|
||||||
|
这个日历选择框也改为我们最新的控件。」
|
||||||
|
⇒ 把原来那个原生 `<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
+10
-1
@@ -322,7 +322,16 @@ unset($evRef);
|
|||||||
⚠️ CSS 里那条 `.ag-now-dot` 也一并删(留在那儿是死规则,
|
⚠️ CSS 里那条 `.ag-now-dot` 也一并删(留在那儿是死规则,
|
||||||
还会让"红线只有一根"这件事在样式表里看不出结论)。 */ ?>
|
还会让"红线只有一根"这件事在样式表里看不出结论)。 */ ?>
|
||||||
<?php if ($showNow): ?>
|
<?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 endif; ?>
|
||||||
<?php /* 日程块:按"相对第一个刻度的小时数"定位(与日视图同一套比例)---- */ ?>
|
<?php /* 日程块:按"相对第一个刻度的小时数"定位(与日视图同一套比例)---- */ ?>
|
||||||
<?php
|
<?php
|
||||||
|
|||||||
Reference in New Issue
Block a user