2026-10-03 08:18:05 +08:00
|
|
|
|
<?php
|
|
|
|
|
|
/**
|
|
|
|
|
|
* CalDAV 服务端(v24 用户第 7 条后半)
|
|
|
|
|
|
*
|
|
|
|
|
|
* 用户原话:「再增加caldav功能,就是可以把属于我的日历本,通过caldav协议分享给别人。」
|
|
|
|
|
|
*
|
|
|
|
|
|
* 实现的是 RFC 4791 的一个**只读子集** —— 够 iOS / 安卓 / Thunderbird / DAVx⁵
|
|
|
|
|
|
* 把家书的日历本订阅进来;本阶段不做 PUT/DELETE(在手机上改日程)。
|
|
|
|
|
|
* 少做这一半的代价是"要处理冲突与 ETag 回写",而收益(随时改)并不急。
|
|
|
|
|
|
*
|
|
|
|
|
|
* 客户端会依次发这些请求(这是**必须**按顺序实现的最小集):
|
|
|
|
|
|
*
|
|
|
|
|
|
* OPTIONS /dav/ → 声明支持 DAV: 1, calendar-access
|
|
|
|
|
|
* PROPFIND /dav/ (Depth: 0) → 当前用户可见的日历本清单
|
|
|
|
|
|
* PROPFIND /dav/<book>.ics/ (0) → 某一本的属性(displayname / color / 组件集)
|
|
|
|
|
|
* PROPFIND /dav/<book>.ics/ (1) → 这本里所有日程的资源清单(getetag)
|
|
|
|
|
|
* REPORT /dav/<book>.ics/ → calendar-query / calendar-multiget,取正文
|
|
|
|
|
|
* GET /dav/<book>.ics/ → 整本导出成一个 ics(很多客户端走这条)
|
|
|
|
|
|
*
|
|
|
|
|
|
* 鉴权用 **HTTP Basic**(DAVx⁵ / iOS 都原生支持),口令不是登录口令:
|
2026-10-06 17:33:48 +08:00
|
|
|
|
* 见 Auth::davAuth() —— 登录口令绝不该在客户端里被反复存着。
|
|
|
|
|
|
*
|
|
|
|
|
|
* ⚠️ 口令是**日历与通讯录共用**的一把(用户 2026-10-06 第 22 批):
|
|
|
|
|
|
* 同一个用户名 + 同一串口令,既连 /dav/(本文件)也连 /carddav/(App\CardDav)。
|
|
|
|
|
|
* 两个入口各自独立鉴权、互不依赖 —— 「日历能连、通讯录连不上」这种半通状态
|
|
|
|
|
|
* 在协议层就不可能出现(同一套 davAuth、同一张 dav_credentials 表)。
|
2026-10-03 08:18:05 +08:00
|
|
|
|
*
|
|
|
|
|
|
* ⚠️ 只读:所有写方法(PUT/DELETE/PROPPATCH)一律 403。
|
|
|
|
|
|
* 与其"半支持"(写成功但客户端状态与服务端不一致),不如明确拒绝。
|
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
|
|
declare(strict_types=1);
|
|
|
|
|
|
|
|
|
|
|
|
namespace App;
|
|
|
|
|
|
|
|
|
|
|
|
final class CalDav
|
|
|
|
|
|
{
|
|
|
|
|
|
/** 资源前缀(URL 里的路径) */
|
|
|
|
|
|
public const ROOT = '/dav/';
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 当前用户可见的日历本。与「日程」模块里看到的是同一批
|
|
|
|
|
|
* (我建的 + 共享给我的),口径必须一致,否则手机上少一本、网页上多一本。
|
|
|
|
|
|
*/
|
|
|
|
|
|
public static function books(int $familyId, int $personId): array
|
|
|
|
|
|
{
|
|
|
|
|
|
return Calendar::mine($familyId, $personId);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 日历本 → URL 段。用 id 而不是名字:名字里有中文/空格/斜杠时,
|
|
|
|
|
|
* 客户端与服务端对"怎么编码"的理解常常不一致(实测 DAVx⁵ 会二次编码)。
|
|
|
|
|
|
*/
|
|
|
|
|
|
public static function hrefOf(array $cal): string
|
|
|
|
|
|
{
|
|
|
|
|
|
return self::ROOT . (int)$cal['id'] . '.ics/';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
public static function bookHref(int $calId): string
|
|
|
|
|
|
{
|
|
|
|
|
|
return self::ROOT . $calId . '.ics/';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-08 20:05:01 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 从 URL 段(如 /dav/3.ics/)解出日历本 id 与"是不是要单个日程"。
|
|
|
|
|
|
*
|
|
|
|
|
|
* 🔴 v31 修的真 bug:`$event` 原来是 `(int)$evSeg` —— 于是**派生条目**的
|
|
|
|
|
|
* 资源名(`movie-100_familybook`、`lunar-2026-10-01_familybook`)
|
|
|
|
|
|
* `(int)` 之后全是 **0**,被当成"没指定单条资源" → 整本导出。
|
|
|
|
|
|
* 实测:`GET /dav/4.ics/lunar-2025-01-01_familybook.ics` 回了 **400 条 VEVENT**。
|
|
|
|
|
|
*
|
|
|
|
|
|
* 这也正是**为什么不能只认数字 id**:资源名有两种(自定义日程用 id、
|
|
|
|
|
|
* 派生条目用净化后的 uid),`(int)` 会把后者静默压成 0。
|
|
|
|
|
|
* ⇒ 改为按 `$seg` 是否为空判断;`$event` 仍给**数字**那份(自定义日程用),
|
|
|
|
|
|
* 同时把原始段原样带出去(`seg`),让调用方按资源名精确匹配。
|
|
|
|
|
|
*
|
|
|
|
|
|
* @return array{book:int, event:int, seg:string}
|
|
|
|
|
|
*/
|
2026-10-03 08:18:05 +08:00
|
|
|
|
public static function parsePath(string $path): array
|
|
|
|
|
|
{
|
|
|
|
|
|
$p = (string)parse_url($path, PHP_URL_PATH);
|
|
|
|
|
|
if (strpos($p, self::ROOT) !== 0) {
|
2026-10-08 20:05:01 +08:00
|
|
|
|
return ['book' => 0, 'event' => 0, 'seg' => ''];
|
2026-10-03 08:18:05 +08:00
|
|
|
|
}
|
|
|
|
|
|
$rest = substr($p, strlen(self::ROOT));
|
|
|
|
|
|
$rest = trim($rest, '/');
|
|
|
|
|
|
if ($rest === '') {
|
2026-10-08 20:05:01 +08:00
|
|
|
|
return ['book' => 0, 'event' => 0, 'seg' => ''];
|
2026-10-03 08:18:05 +08:00
|
|
|
|
}
|
2026-10-08 20:05:01 +08:00
|
|
|
|
// 形如 3.ics 或 3.ics/<seg>.ics
|
2026-10-03 08:18:05 +08:00
|
|
|
|
$parts = explode('/', $rest);
|
|
|
|
|
|
$bookSeg = $parts[0];
|
|
|
|
|
|
$book = (int)preg_replace('/\.ics$/', '', $bookSeg);
|
2026-10-08 20:05:01 +08:00
|
|
|
|
$seg = '';
|
2026-10-03 08:18:05 +08:00
|
|
|
|
if (isset($parts[1]) && $parts[1] !== '') {
|
2026-10-08 20:05:01 +08:00
|
|
|
|
// 单条资源:<seg>.ics —— 段里可能含点/短横,所以只去掉结尾的 .ics
|
|
|
|
|
|
$seg = (string)preg_replace('/\.ics$/', '', $parts[1]);
|
2026-10-03 08:18:05 +08:00
|
|
|
|
}
|
2026-10-08 20:05:01 +08:00
|
|
|
|
return ['book' => $book, 'event' => (int)$seg, 'seg' => $seg];
|
2026-10-03 08:18:05 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-08 20:05:01 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 客户端**发现链**用的固定路径(RFC 6764 / 6352)。
|
|
|
|
|
|
*
|
|
|
|
|
|
* 🔴 v31 修的真 bug:原来这两个入口**完全没实现**,客户端的发现链第一句就断。
|
|
|
|
|
|
* 实测(DAVx⁵ / iOS 每次添加账号都这么做):
|
|
|
|
|
|
* PROPFIND / → 问 current-user-principal → 服务器根本没有 PROPFIND 处理,
|
|
|
|
|
|
* 落到 index.php 的 ?r= 路由 → 返回登录页 HTML(客户端解析失败)
|
|
|
|
|
|
* PROPFIND /dav/ 问 current-user-principal → 我们回的是"日历本清单",
|
|
|
|
|
|
* 里面**一个 principal 都没有** → 客户端判定"这不是个 DAV 服务器"
|
|
|
|
|
|
* 症状正是用户报的「同步不出任何数据」—— 连账号都建不起来,谈何同步。
|
|
|
|
|
|
*
|
|
|
|
|
|
* ⚠️ principal / home-set 不能"就地"写成 `/dav/` 自己:
|
|
|
|
|
|
* 客户端会拿 home-set 当**集合根**再往下 PROPFIND Depth:1。
|
|
|
|
|
|
* 直接回 /dav/ 的话,Depth:1 会要求列出 /dav/ 下所有日历本的**资源**,
|
|
|
|
|
|
* 语义错位且体积巨大。这里给独立路径,行为与"标准 DAV 服务器"一致。
|
|
|
|
|
|
*/
|
|
|
|
|
|
public const PRINCIPAL_PATH = '/dav/principal/';
|
|
|
|
|
|
|
2026-10-11 10:12:11 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 🔴🔴 v41(用户 2026-10-10 第 11 条:「caldav 和 carddav 功能还是不好用,
|
|
|
|
|
|
* thunderbird 返回的结果是,找不到联系人,找不到日历本,再仔细检查这个功能,
|
|
|
|
|
|
* 这次要务必彻底解决。」)
|
|
|
|
|
|
*
|
|
|
|
|
|
* 真根因(线上实测出来的,不是猜的):
|
|
|
|
|
|
* 原来 `HOME_PATH = '/dav/calendars/'`,而**日历本自己的 href 是 `/dav/1.ics/`**
|
|
|
|
|
|
* —— 也就是说 **日历本并不在 home-set 之下**。
|
|
|
|
|
|
* Thunderbird(严格按 RFC 4791/6352):拿到 `calendar-home-set` 后,
|
|
|
|
|
|
* PROPFIND 它、**只看它的成员**当作日历本 ⇒ 那里一个都没有
|
|
|
|
|
|
* ⇒ 报「找不到日历本」。CardDAV 侧一模一样(`/carddav/addressbooks/`
|
|
|
|
|
|
* vs 通讯录 `/carddav/1.vcf/`)⇒ 「找不到联系人」。
|
|
|
|
|
|
*
|
|
|
|
|
|
* ⚠️ 为什么 v33 那批没抓出来:那批的探针**只验证"发现链每一步都回 207"**,
|
|
|
|
|
|
* 而每一步确实都回了 207、principal 与 home-set 也都拿到了 ——
|
|
|
|
|
|
* **层级关系错了这件事,单个请求的返回码看不出来**。
|
|
|
|
|
|
* ⇒ 新探针必须断言"**home-set 的成员里必须有日历本/通讯录**"。
|
|
|
|
|
|
*
|
|
|
|
|
|
* 修法:让 home-set 就指向**本协议自己的根**(`/dav/`、`/carddav/`)——
|
|
|
|
|
|
* ① 成员关系立刻成立(`/dav/1.ics/` 确实在 `/dav/` 之下);
|
|
|
|
|
|
* ② **所有既有 URL 一字不变**(客户端里配的 `/dav/`、`/dav/1.ics/` 继续有效,
|
|
|
|
|
|
* 不需要用户重新配置,也不会让已同步的客户端全部失效);
|
|
|
|
|
|
* ③ 少一层跳转。
|
|
|
|
|
|
* ⚠️ 别改成"把日历本挪到 /dav/calendars/ 下面"——那会让**每一个用户**的
|
|
|
|
|
|
* 客户端配置作废(已订阅的日历会 404),代价大得多。
|
|
|
|
|
|
*/
|
|
|
|
|
|
public const HOME_PATH = '/dav/';
|
2026-10-08 20:05:01 +08:00
|
|
|
|
|
|
|
|
|
|
/** 通讯录那边同义(见 App\CardDav,两边刻意保持一致) */
|
|
|
|
|
|
public const CARD_PRINCIPAL_PATH = '/carddav/principal/';
|
|
|
|
|
|
|
2026-10-11 10:12:11 +08:00
|
|
|
|
public const CARD_HOME_PATH = '/carddav/';
|
2026-10-08 20:05:01 +08:00
|
|
|
|
|
2026-10-03 08:18:05 +08:00
|
|
|
|
// ---------------- 应答 ----------------
|
|
|
|
|
|
|
|
|
|
|
|
public static function xmlHeader(): void
|
|
|
|
|
|
{
|
2026-10-13 00:29:55 +08:00
|
|
|
|
/* 🔴🔴 v45b(2026-10-12 用户「TB 还是找不到日历」)——**关键对齐**。
|
|
|
|
|
|
抓参照(DAViCal)发现:它**每一个响应**(不只 OPTIONS)都带两行 `DAV:` 头:
|
|
|
|
|
|
DAV: 1, 2, 3, access-control, calendar-access, calendar-schedule
|
|
|
|
|
|
DAV: extended-mkcol, bind, addressbook, calendar-auto-schedule
|
|
|
|
|
|
而我们原来**只有 OPTIONS 带**。Thunderbird 的 CalDAV 探测里会读
|
|
|
|
|
|
`DAV:` 头来确认"这确实是个 DAV 服务"(它把 `calendar-access` 当作
|
|
|
|
|
|
"支持 CalDAV" 的判据)。PROPFIND/REPORT 的响应缺这个头时,
|
|
|
|
|
|
部分 TB 版本会认为"服务器没声明 CalDAV 能力"。
|
|
|
|
|
|
⇒ 照参照,在这个**所有 XML 响应都必经**的地方补上。
|
|
|
|
|
|
|
|
|
|
|
|
⚠️ Content-Type 也用**参照的字面形态** `text/xml`(原来 `application/xml`)。
|
|
|
|
|
|
两者都能被解析,但参照是 `text/xml`,用户要求「完全按参照返回」;
|
|
|
|
|
|
且个别老解析器对 `application/xml` 的处理路径不同,对齐更稳。
|
|
|
|
|
|
⚠️ 第二个 `DAV:` 头必须 `false` 追加 —— PHP 同名 header 默认**覆盖**。 */
|
|
|
|
|
|
header('Content-Type: text/xml; charset="utf-8"');
|
|
|
|
|
|
header('DAV: 1, 2, 3, calendar-access, calendar-schedule', false);
|
|
|
|
|
|
header('DAV: extended-mkcol', false);
|
2026-10-03 08:18:05 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** 多状态应答(207)—— PROPFIND / REPORT 都用它 */
|
|
|
|
|
|
public static function multis(int $code = 207): void
|
|
|
|
|
|
{
|
|
|
|
|
|
http_response_code($code);
|
|
|
|
|
|
self::xmlHeader();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** 把一个 xml 文本片段做最省事的转义(我们只回显自己库里的字符串) */
|
|
|
|
|
|
public static function x(string $s): string
|
|
|
|
|
|
{
|
|
|
|
|
|
return htmlspecialchars($s, ENT_XML1 | ENT_QUOTES, 'UTF-8');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 某个日历本里"能列出来"的日程。
|
|
|
|
|
|
*
|
|
|
|
|
|
* ⚠️ 与网页日历同源:导出/订阅的内容必须与页面上看到的一致。
|
|
|
|
|
|
* 派生本(观影 / 旅程)走 Calendar::exportRows,自定义本走 calendar_events。
|
|
|
|
|
|
*/
|
|
|
|
|
|
public static function rows(array $cal, int $familyId, int $me): array
|
|
|
|
|
|
{
|
|
|
|
|
|
return Calendar::exportRows($cal, $familyId, $me);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-08 20:05:01 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 一个日历本里,"**资源**"意义上的条目(每个 href 一条)。
|
|
|
|
|
|
*
|
|
|
|
|
|
* 🔴 v31 修的真 bug:原来直接把 rows() 当成资源列表用,而
|
|
|
|
|
|
* `Calendar::exportRows()` 对**派生本**(观影 / 旅程)是按"日历视图"取数的 ——
|
|
|
|
|
|
* 重复日程会展开成 N 次发生、跨天日程逐天出一条、纪念日/农历/节假日逐日派生。
|
|
|
|
|
|
* 实测线上 `/dav/1.ics/`(观影本):**2981 个 `<d:response>`,只有 196 个不同 href**
|
|
|
|
|
|
* (其中一条 `anniv-0_familybook.ics` 自己重复了 2399 次)。
|
|
|
|
|
|
* CalDAV 的资源是**一个 .ics 文件**,同名重复出现会让客户端:
|
|
|
|
|
|
* · 把同一条日程反复入库(删除/去重逻辑整天在跑);
|
|
|
|
|
|
* · 或直接判定"服务器状态异常"而中止本轮同步。
|
|
|
|
|
|
*
|
|
|
|
|
|
* ⇒ 这里按 **UID** 去重(CalDAV 的资源名就是 UID)。同一个 UID 的多条谱系
|
|
|
|
|
|
* (重复日程的每次发生)在**一个资源**里由 RRULE 自己表达,本就该合并。
|
|
|
|
|
|
*
|
|
|
|
|
|
* ⚠️ 保留**第一次**出现的那条:rows() 按时间升序,第一条就是系列里最早的那次,
|
|
|
|
|
|
* 它的 title / 时长信息最完整(跨天条目里也只有第一天带 time)。
|
|
|
|
|
|
*/
|
|
|
|
|
|
public static function resources(array $cal, int $familyId, int $me): array
|
|
|
|
|
|
{
|
|
|
|
|
|
$seen = [];
|
|
|
|
|
|
$out = [];
|
|
|
|
|
|
foreach (self::rows($cal, $familyId, $me) as $r) {
|
|
|
|
|
|
$uid = (string)($r['uid'] ?? '');
|
|
|
|
|
|
if ($uid === '') {
|
|
|
|
|
|
$out[] = $r; // 没有 UID 的(理论上不该有)不参与去重,照样列出
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (isset($seen[$uid])) {
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
$seen[$uid] = true;
|
|
|
|
|
|
$out[] = $r;
|
|
|
|
|
|
}
|
|
|
|
|
|
return $out;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 资源名(href 的最后一段,不含 .ics)。
|
|
|
|
|
|
*
|
2026-10-12 21:40:53 +08:00
|
|
|
|
* 🔴🔴 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,保证总有名字。
|
2026-10-08 20:05:01 +08:00
|
|
|
|
*/
|
|
|
|
|
|
public static function segmentOf(array $r): string
|
|
|
|
|
|
{
|
|
|
|
|
|
$uid = preg_replace('/[^A-Za-z0-9._-]/', '_', (string)($r['uid'] ?? ''));
|
2026-10-12 21:40:53 +08:00
|
|
|
|
$uid = $uid === null ? '' : trim($uid);
|
|
|
|
|
|
if ($uid !== '') {
|
|
|
|
|
|
return $uid;
|
|
|
|
|
|
}
|
|
|
|
|
|
$rid = (int)($r['id'] ?? 0);
|
|
|
|
|
|
return $rid > 0 ? (string)$rid : '';
|
2026-10-08 20:05:01 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 一个**资源**的 ICS 正文与 ETag —— 与 CardDav::cardOf 同一个套路:
|
|
|
|
|
|
* 正文与 ETag **成对**算出来,不给"两处各算一次"留机会。
|
|
|
|
|
|
*
|
|
|
|
|
|
* 🔴 v31:ETag 必须由**内容**算出。原先 REPORT 里用 md5($evIcs),
|
|
|
|
|
|
* 而 $evIcs 里含每次都在变的 DTSTAMP(见 Ics::stampOf)⇒ ETag 潮汐。
|
|
|
|
|
|
* 现在 DTSTAMP 已确定,但**判据仍钉在"内容哈希"上** —— 这样以后
|
|
|
|
|
|
* ICS 生成器再加什么时间性字段,也不会又变成"每轮都变"。
|
|
|
|
|
|
*
|
|
|
|
|
|
* @return array{ics:string, etag:string}
|
|
|
|
|
|
*/
|
|
|
|
|
|
public static function resourceOf(array $r, string $calName): array
|
|
|
|
|
|
{
|
2026-10-15 20:08:05 +08:00
|
|
|
|
$ics = Ics::export([self::withExceptions($r)], $calName);
|
2026-10-08 20:05:01 +08:00
|
|
|
|
return ['ics' => $ics, 'etag' => '"' . md5($ics) . '"'];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-15 20:08:05 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 给一条日程行补上它的「重复系列例外」——
|
|
|
|
|
|
* `_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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-12 21:40:53 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 日历本的 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() ?: [];
|
2026-10-15 20:08:05 +08:00
|
|
|
|
/* 🔴 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'] ?? '');
|
2026-10-12 21:40:53 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-08 20:05:01 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 按**资源名**找一个条目(供单条资源的 GET / REPORT 用)。
|
|
|
|
|
|
*
|
|
|
|
|
|
* ⚠️ 认两种资源名(与 segmentOf() 一一对应):
|
|
|
|
|
|
* · 纯数字 = calendar_events.id(自定义本)
|
|
|
|
|
|
* · 其它 = uid 净化形式(派生本:movie-100_familybook、lunar-2026-10-01_familybook)
|
|
|
|
|
|
* v31 之前只认数字,于是派生本的单条资源请求会被当成"整本"处理
|
|
|
|
|
|
* (实测 `GET /dav/4.ics/lunar-2025-01-01_familybook.ics` 回了 400 条 VEVENT)。
|
|
|
|
|
|
*
|
|
|
|
|
|
* ⚠️ 数字那一支**直接查库**(`oneEvent`),不走 resources():
|
|
|
|
|
|
* 理论上"合法资源名"必然出现在 resources() 里(href 就是从它生成的),
|
|
|
|
|
|
* 但"必须能取到"和"必须扫全表才能取到"是两件事 —— 一本几千条时
|
|
|
|
|
|
* 每取一条都全量派生一遍(含 130 年农历换算)代价太大。
|
|
|
|
|
|
* 派生本没有 id,只能从列表里找;量小(几十到几百条),可接受。
|
|
|
|
|
|
*/
|
|
|
|
|
|
public static function oneResource(array $cal, int $familyId, int $me, string $seg): ?array
|
|
|
|
|
|
{
|
|
|
|
|
|
$seg = trim($seg);
|
|
|
|
|
|
if ($seg === '') {
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
2026-10-12 21:40:53 +08:00
|
|
|
|
// 🔴🔴 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 之前我们生成的形态)
|
2026-10-08 20:05:01 +08:00
|
|
|
|
if (ctype_digit($seg)) {
|
|
|
|
|
|
$one = self::oneEvent((int)$cal['id'], (int)$seg, [$familyId, $me]);
|
|
|
|
|
|
if ($one) {
|
|
|
|
|
|
return $one;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-10-12 21:40:53 +08:00
|
|
|
|
// 派生资源名:从派生列表里按净化后的 uid 找(派生条目不在 calendar_events 里)
|
2026-10-08 20:05:01 +08:00
|
|
|
|
foreach (self::resources($cal, $familyId, $me) as $r) {
|
|
|
|
|
|
if (self::segmentOf($r) === $seg) {
|
|
|
|
|
|
return $r;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-03 08:18:05 +08:00
|
|
|
|
/** 单条日程(按 calendar_events.id)—— 只允许自定义本,派生本没有"单条资源" */
|
|
|
|
|
|
public static function oneEvent(int $calId, int $eventId, array $familyIdAndMe): ?array
|
|
|
|
|
|
{
|
|
|
|
|
|
[$familyId, $me] = $familyIdAndMe;
|
|
|
|
|
|
$st = Database::get()->prepare(
|
|
|
|
|
|
'SELECT e.* FROM calendar_events e
|
|
|
|
|
|
JOIN calendars c ON c.id = e.calendar_id
|
|
|
|
|
|
WHERE e.id = ? AND e.calendar_id = ? AND c.family_id = ?'
|
|
|
|
|
|
);
|
|
|
|
|
|
$st->execute([$eventId, $calId, $familyId]);
|
|
|
|
|
|
$r = $st->fetch();
|
|
|
|
|
|
if (!$r) {
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
// 共享只读的人也要能取到(能看见这本就能看见里面的条目)
|
|
|
|
|
|
$cal = Calendar::mineOne($familyId, $calId, $me);
|
|
|
|
|
|
if (!$cal) {
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
return $r;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-08 20:05:01 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 一整本导出成 ics 文本(GET 与 REPORT 都用它)。
|
|
|
|
|
|
*
|
|
|
|
|
|
* ⚠️ 走 resources()(按 UID 去重)而不是 rows():日历视图里同一条日程会被
|
|
|
|
|
|
* 重复日程/跨天/派生规则展开成很多条,整本导出时那些都是**同一个资源**。
|
|
|
|
|
|
*/
|
2026-10-03 08:18:05 +08:00
|
|
|
|
public static function ics(array $cal, int $familyId, int $me): string
|
|
|
|
|
|
{
|
2026-10-08 20:05:01 +08:00
|
|
|
|
return Ics::export(self::resources($cal, $familyId, $me), (string)$cal['name']);
|
2026-10-03 08:18:05 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|