CalDAV 待办改为按分类拆成多本(对齐参照 DAViCal)

用户提供参照服务器 nas.yangyq.net/caldav/synccalendartest(DAViCal 1.1.9)并指出
「应该可以直接显示那个家庭的待办分类」。

## 参照实测结果

那台服务器的待办本结构:

    /caldav/<user>/tfxnz/     家庭计划   VTODO
    /caldav/<user>/zlqlzm/    工作任务   VTODO
    /caldav/<user>/tdotbsnl/  个人成长   VTODO
    /caldav/<user>/home_todo/ Inbox     VTODO

⇒ 每个分类是**独立的 calendar 集合**,各自 supported-calendar-component-set
只含 VTODO,客户端里显示成并列的多个待办列表。

VTODO 正文还带 CREATED / LAST-MOD / DTSTAMP / SEQUENCE / PRIORITY /
PERCENT-COMPLETE / STATUS / DESCRIPTION / LOCATION / TRANSP。

## 上一版的问题

第一版做成「一个「家庭待办」本装全部任务」,所有分类混在一起,
客户端里只有一个列表 —— 用户要的是分类可见。

## 这一版

### 本的组织

    /dav/tasks-<categoryId>.ics/   每个 task_categories 一行 = 一本
    /dav/tasks-inbox.ics/          category_id 为空的任务(Inbox)

⚠️ 用 `tasks-<id>` 而不是裸 id:真实日历本是 `/dav/<id>.ics/`,
裸 id 会与它们撞号(calendar_events 与 task_categories 是两套独立序列)。

### 分类跟随「本」

客户端把资源 PUT 进哪一本,任务就归哪个分类。
这是「按分类拆本」的自然语义,也让「在客户端里移动任务到另一本」等价于改分类。

### 跨本隔离

GET/DELETE 单条时会校验「这条任务确实属于这个本」,
否则改一下 href 就能用 A 本读到 B 本的任务。

## 验证(39 项全过)

真实数据(5 个分类 / 32 条任务):

| 本 | 条数 |
|---|---|
| 家庭事务 | 6 |
| 工作相关 | 19 |
| 个人事务 | 2 |
| 党支部工作 | 1 |
| 轻舟相关 | 1 |
| 收件箱 | 3 |

每本 supported-calendar-component-set 均只含 VTODO,无 VEVENT。
PUT 到 tasks-1.ics 后 Location 返回 /dav/tasks-1.ics/task-40.ics,
GET 读回、更新、完成、删除全部正常。

回归:原有日历本(观影等)PROPFIND/GET 正常,组件类型仍是 VEVENT。
鉴权:无口令 401、不存在的分类本 404、非法段 404。

测试数据已清理,tasks 仍为 32 条。

## 文件

- app/TaskDav.php     重写为多本模型
- app/Ics.php         VTODO 导出/解析(上一版已加,本版未改)
- dav.php             五处拦截改多本

## 已知限制

看板名(正在进行/等待反馈/等待结果)通过 CATEGORIES 输出给客户端,
但客户端改它不会反向改看板 —— 标准 VTODO 没有「看板列」概念。
分类则双向(由「在哪一本」决定)。
This commit is contained in:
2026-11-02 15:41:54 +08:00
parent c86658966c
commit 610d524e68
2 changed files with 351 additions and 207 deletions
+108 -64
View File
@@ -324,8 +324,26 @@ if ($method === 'PROPFIND') {
* /dav/tasks.ics/ 是本模块的虚拟本,不对应 calendar_events 的任何一行。
* 在这里前置拦截,原有日历分支一个字都不用改(零回归风险)。
*/
if (TaskDav::isTaskHref($path)) {
if (TaskDav::isTaskPath($path)) {
/* ============================================================
* 待办本(VTODO)—— 按分类多本,与参照 DAViCal 同构
* ============================================================
* 用户 2026-11-02 给了参照服务器并指出
* 「应该可以直接显示那个家庭的待办分类」。
* 参照实测:每个分类是独立的 calendar 集合,各自 comp 只含 VTODO。
*
* ⚠️ supported-calendar-component-set 必须是 VTODO:
* 给 VEVENT 的话客户端会按日程解析,VTODO 会被过滤掉不显示。
*/
$tHit = TaskDav::parse($path);
$tSeg = $tHit === null ? '' : (string)$tHit['seg'];
$tBook = TaskDav::resolveBook($familyId, $me, $tSeg);
if ($tBook === null) {
dav_fail(404, '没有这本待办。');
}
$tCatId = (int)$tBook['id'];
$tDepth = (string)($_SERVER['HTTP_DEPTH'] ?? '0');
$taskEmit = static function (string $xmlns, array $items): void {
header('Content-Type: application/xml; charset=utf-8');
http_response_code(207);
@@ -337,22 +355,20 @@ if ($method === 'PROPFIND') {
};
if ($tDepth !== '1') {
/* Depth:0 —— 这一本自身的属性。
⚠️ supported-calendar-component-set 必须是 VTODO:
给 VEVENT 的话客户端会按日程解析,VTODO 会被过滤掉不显示。 */
$ctag = TaskDav::ctag($familyId, $me);
// Depth:0 —— 这一本自身的属性
$ctag = TaskDav::ctag($familyId, $me, $tCatId);
$taskEmit(
'xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav" '
. 'xmlns:cs="http://calendarserver.org/ns/" '
. 'xmlns:x="http://apple.com/ns/ical/"',
[
'<d:response><d:href>' . CalDav::x(TaskDav::BOOK_HREF) . '</d:href>'
'<d:response><d:href>' . CalDav::x($tBook['href']) . '</d:href>'
. '<d:propstat><d:prop>'
. '<d:resourcetype><d:collection/><c:calendar/></d:resourcetype>'
. '<d:displayname>' . CalDav::x(TaskDav::BOOK_NAME) . '</d:displayname>'
. '<d:displayname>' . CalDav::x($tBook['name']) . '</d:displayname>'
. '<d:getcontenttype>httpd/unix-directory</d:getcontenttype>'
. '<c:calendar-description>' . CalDav::x('岁月家 · 待办(任务看板同步)') . '</c:calendar-description>'
. '<x:calendar-color xmlns:x="http://apple.com/ns/ical/">#7C6A5B</x:calendar-color>'
. '<c:calendar-description>' . CalDav::x('岁月家 · 待办 · ' . $tBook['name']) . '</c:calendar-description>'
. '<x:calendar-color xmlns:x="http://apple.com/ns/ical/">' . CalDav::x($tBook['color']) . '</x:calendar-color>'
. '<c:supported-calendar-component-set><c:comp name="VTODO"/></c:supported-calendar-component-set>'
. '<cs:getctag>' . CalDav::x($ctag) . '</cs:getctag>'
. '<d:getctag>' . CalDav::x($ctag) . '</d:getctag>'
@@ -368,21 +384,21 @@ if ($method === 'PROPFIND') {
⚠️ href 必须与 GET/REPORT 用同一个 segmentOf 算,
三处不一致的话客户端会反复重拉(日历那边踩过)。 */
$items = [
'<d:response><d:href>' . CalDav::x(TaskDav::BOOK_HREF) . '</d:href>'
'<d:response><d:href>' . CalDav::x($tBook['href']) . '</d:href>'
. '<d:propstat><d:prop>'
. '<d:resourcetype><d:collection/><c:calendar/></d:resourcetype>'
. '<d:displayname>' . CalDav::x(TaskDav::BOOK_NAME) . '</d:displayname>'
. '<d:displayname>' . CalDav::x($tBook['name']) . '</d:displayname>'
. '<c:supported-calendar-component-set><c:comp name="VTODO"/></c:supported-calendar-component-set>'
. '</d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response>',
];
foreach (TaskDav::all($familyId, $me) as $t) {
foreach (TaskDav::tasksIn($familyId, $me, $tCatId) as $t) {
$seg = TaskDav::segmentOf($t);
if ($seg === '') {
continue;
}
$etag = TaskDav::resourceOf($t)['etag'];
$etag = TaskDav::resourceOf($t, $tBook['name'])['etag'];
$items[] = '<d:response><d:href>'
. CalDav::x(TaskDav::BOOK_HREF . $seg . '.ics') . '</d:href>'
. CalDav::x($tBook['href'] . $seg . '.ics') . '</d:href>'
. '<d:propstat><d:prop>'
. '<d:getetag>' . CalDav::x($etag) . '</d:getetag>'
. '<d:resourcetype/>'
@@ -539,22 +555,26 @@ if ($method === 'PROPFIND') {
. '</d:href></c:calendar-home-set>'
. '</d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response>');
}
/* 待办本:作为「/dav/ 下的一个日历本」挂进来(用户选定的方案)。
没有这条,客户端 PROPFIND /dav/ 时根本不知道有这一本,也就不会去拉。 */
$tCtag = TaskDav::ctag($familyId, $me);
$parts[] = '<d:response><d:href>' . CalDav::x(TaskDav::BOOK_HREF) . '</d:href>'
. '<d:propstat><d:prop>'
. '<d:resourcetype><d:collection/><c:calendar/></d:resourcetype>'
. '<d:displayname>' . CalDav::x(TaskDav::BOOK_NAME) . '</d:displayname>'
. '<d:getcontenttype>httpd/unix-directory</d:getcontenttype>'
. '<c:calendar-description>' . CalDav::x('岁月家 · 待办(任务看板同步)') . '</c:calendar-description>'
. '<x:calendar-color xmlns:x="http://apple.com/ns/ical/">#7C6A5B</x:calendar-color>'
. '<c:supported-calendar-component-set><c:comp name="VTODO"/></c:supported-calendar-component-set>'
. '<cs:getctag>' . CalDav::x($tCtag) . '</cs:getctag>'
. '<d:getctag>' . CalDav::x($tCtag) . '</d:getctag>'
. '<d:current-user-principal><d:href>' . CalDav::x(CalDav::PRINCIPAL_PATH) . '</d:href></d:current-user-principal>'
. '<d:owner><d:href>' . CalDav::x(CalDav::PRINCIPAL_PATH) . '</d:href></d:owner>'
. '</d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response>';
/* 待办本:**按分类拆成多本**(与参照 DAViCal 同构)。
用户 2026-11-02:「应该可以直接显示那个家庭的待办分类」。
参照实测:每个分类是独立 calendar 集合,客户端里并列显示为多个待办列表。
没有这一整批,客户端 PROPFIND /dav/ 时根本不知道有待办,也就不会去拉。 */
foreach (TaskDav::books($familyId, $me) as $tb) {
$tCtag = TaskDav::ctag($familyId, $me, (int)$tb['id']);
$parts[] = '<d:response><d:href>' . CalDav::x($tb['href']) . '</d:href>'
. '<d:propstat><d:prop>'
. '<d:resourcetype><d:collection/><c:calendar/></d:resourcetype>'
. '<d:displayname>' . CalDav::x($tb['name']) . '</d:displayname>'
. '<d:getcontenttype>httpd/unix-directory</d:getcontenttype>'
. '<c:calendar-description>' . CalDav::x('岁月家 · 待办 · ' . $tb['name']) . '</c:calendar-description>'
. '<x:calendar-color xmlns:x="http://apple.com/ns/ical/">' . CalDav::x($tb['color']) . '</x:calendar-color>'
. '<c:supported-calendar-component-set><c:comp name="VTODO"/></c:supported-calendar-component-set>'
. '<cs:getctag>' . CalDav::x($tCtag) . '</cs:getctag>'
. '<d:getctag>' . CalDav::x($tCtag) . '</d:getctag>'
. '<d:current-user-principal><d:href>' . CalDav::x(CalDav::PRINCIPAL_PATH) . '</d:href></d:current-user-principal>'
. '<d:owner><d:href>' . CalDav::x(CalDav::PRINCIPAL_PATH) . '</d:href></d:owner>'
. '</d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response>';
}
$emit('xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav" '
. 'xmlns:x="http://apple.com/ns/ical/" '
@@ -615,32 +635,44 @@ if ($method === 'PROPFIND') {
// ---- 6. GET / HEAD:整本导出成 ics,或单条资源 ----
if ($method === 'GET' || $method === 'HEAD') {
/* 待办本:整本导出,或单条 VTODO。
⚠️ 单条时必须校验资源名属于本用户可见的任务,
否则改一下 URL 就能读到别人的任务(越权)。 */
⚠️ 单条必须校验资源属于**这一本**且本用户可见,
否则改 URL 就能读到别人的任务(越权)。 */
if (TaskDav::isTaskPath($path)) {
$tParsed = TaskDav::idOfSegment($evSeg);
if ($tParsed > 0) {
$tOne = TaskDav::one($familyId, $me, $tParsed);
if (!$tOne) {
$tHit = TaskDav::parse($path);
$tBook = TaskDav::resolveBook($familyId, $me, (string)($tHit['seg'] ?? ''));
if ($tBook === null) {
dav_fail(404, '没有这本待办。');
}
$tCatId = (int)$tBook['id'];
$tSub = (string)($tHit['sub'] ?? '');
if ($tSub !== '') {
$tParsed = TaskDav::idOfSegment($tSub);
$tOne = $tParsed > 0 ? TaskDav::one($familyId, $me, $tParsed) : null;
// ⚠️ 还要确认这条**确实属于这一本**,否则可用 A 本的 href 读到 B 本的任务
if ($tOne === null) {
dav_fail(404, '没有这条待办。');
}
$tRes = TaskDav::resourceOf($tOne);
$tOwnCat = (int)($tOne['category_id'] ?? 0);
$tExpectCat = $tCatId === -1 ? 0 : $tCatId;
if ($tOwnCat !== $tExpectCat) {
dav_fail(404, '这条待办不在这本里。');
}
$tRes = TaskDav::resourceOf($tOne, $tBook['name']);
header('Content-Type: text/calendar; charset=utf-8');
header('ETag: ' . $tRes['etag']);
if ($method === 'HEAD') {
exit;
if ($method !== 'HEAD') {
echo $tRes['ics'];
}
echo $tRes['ics'];
exit;
}
// 整本
$tIcs = TaskDav::ics($familyId, $me);
header('Content-Type: text/calendar; charset=utf-8');
header('ETag: ' . TaskDav::ctag($familyId, $me));
if ($method === 'HEAD') {
exit;
header('ETag: ' . TaskDav::ctag($familyId, $me, $tCatId));
if ($method !== 'HEAD') {
echo TaskDav::ics($familyId, $me, $tCatId, $tBook['name']);
}
echo $tIcs;
exit;
}
@@ -719,9 +751,14 @@ if ($method === 'GET' || $method === 'HEAD') {
// ---- 7. REPORT:calendar-multiget(按 href 取) / calendar-query(整本或按时间范围)----
if ($method === 'REPORT') {
/* 待办本:calendar-query / calendar-multiget 都返回 VTODO。
⚠️ 支持 multiget 按 href 过滤 —— 整本回虽然"客户端也能用",
但一本 30 条时无感、将来几百条时就是纯浪费(日历那边为此改过)。 */
⚠️ 支持 multiget 按 href 过滤 —— 整本回在条目多时是纯浪费。 */
if (TaskDav::isTaskPath($path)) {
$tHit = TaskDav::parse($path);
$tBook = TaskDav::resolveBook($familyId, $me, (string)($tHit['seg'] ?? ''));
if ($tBook === null) {
dav_fail(404, '没有这本待办。');
}
$tCatId = (int)$tBook['id'];
$tBody = (string)file_get_contents('php://input');
$tWantsMulti = stripos($tBody, 'calendar-multiget') !== false;
@@ -737,14 +774,14 @@ if ($method === 'REPORT') {
}
$tItems = [];
foreach (TaskDav::all($familyId, $me) as $t) {
foreach (TaskDav::tasksIn($familyId, $me, $tCatId) as $t) {
$seg = TaskDav::segmentOf($t);
if ($seg === '' || ($tWantsMulti && !isset($tWant[$seg]))) {
continue;
}
$res = TaskDav::resourceOf($t);
$res = TaskDav::resourceOf($t, $tBook['name']);
$tItems[] = '<d:response><d:href>'
. CalDav::x(TaskDav::BOOK_HREF . $seg . '.ics') . '</d:href>'
. CalDav::x($tBook['href'] . $seg . '.ics') . '</d:href>'
. '<d:propstat><d:prop>'
. '<d:getetag>' . CalDav::x($res['etag']) . '</d:getetag>'
. '<c:calendar-data>' . CalDav::x($res['ics']) . '</c:calendar-data>'
@@ -871,9 +908,17 @@ if ($method === 'REPORT') {
// 提醒、全天与否都由它统一处理)。**绝不在 DAV 里另写一套 INSERT**。
if ($method === 'PUT') {
/* 待办本:新建 / 更新一条 VTODO。
⚠️ 路径里有段 = 更新那一条;没有段 = 新建(客户端会给新资源自己编个名字,
所以"没有段"很少见,但按标准要支持)。 */
⚠️ 路径里有段 = 更新那一条;没有段 = 新建(客户端会给新资源自己编名字)。
⚠️ PUT 到不存在的资源名是**创建**(RFC 4918 §9.7),
由 TaskDav::put 内部判断,这里不预先拒绝。 */
if (TaskDav::isTaskPath($path)) {
$tHit = TaskDav::parse($path);
$tBook = TaskDav::resolveBook($familyId, $me, (string)($tHit['seg'] ?? ''));
if ($tBook === null) {
dav_fail(404, '没有这本待办。');
}
$tCatId = (int)$tBook['id'];
$tBody = (string)file_get_contents('php://input');
if (trim($tBody) === '') {
dav_fail(400, '正文是空的。');
@@ -884,15 +929,10 @@ if ($method === 'PUT') {
dav_fail(415, '只接受 text/calendar 正文。');
}
$evSeg2 = (string)preg_replace('/\.ics$/', '', $evSeg);
$tTaskId = TaskDav::idOfSegment($evSeg2);
$tTaskId = TaskDav::idOfSegment((string)($tHit['sub'] ?? ''));
/* ⚠️ 「段里有字但解析不出 id」= 客户端用了自己编的 UID(新建场景常见)。
这种情况按**新建**处理,而不是报错 —— 那才是它的真实意图。
但如果段是 `task-123` 形式却查不到这条,说明已被删,应按新建处理
(有些客户端会重推已删的资源,回 404 会让它反复重试)。 */
try {
$tRes2 = TaskDav::put($familyId, $me, $tTaskId, $tBody);
$tRes2 = TaskDav::put($familyId, $me, $tCatId, $tTaskId, $tBody);
} catch (RuntimeException $e) {
dav_fail(403, $e->getMessage());
}
@@ -901,10 +941,9 @@ if ($method === 'PUT') {
if (!$tSaved) {
dav_fail(500, '写入后读取失败。');
}
$tRes3 = TaskDav::resourceOf($tSaved);
$tNewHref = TaskDav::BOOK_HREF . TaskDav::segmentOf($tSaved) . '.ics';
$tRes3 = TaskDav::resourceOf($tSaved, $tBook['name']);
$tNewHref = $tBook['href'] . TaskDav::segmentOf($tSaved) . '.ics';
// 新建 → 201 Created + Location;更新 → 200/204
http_response_code($tRes2['created'] ? 201 : 204);
header('ETag: ' . $tRes3['etag']);
if ($tRes2['created']) {
@@ -1106,9 +1145,14 @@ if ($method === 'PUT') {
// 所以这一句 DELETE 会把它们一并带走,不需要额外清理。
if ($method === 'DELETE') {
if (TaskDav::isTaskPath($path)) {
$tTaskId = TaskDav::idOfSegment((string)preg_replace('/\.ics$/', '', $evSeg));
$tHit = TaskDav::parse($path);
$tBook = TaskDav::resolveBook($familyId, $me, (string)($tHit['seg'] ?? ''));
if ($tBook === null) {
dav_fail(404, '没有这本待办。');
}
$tTaskId = TaskDav::idOfSegment((string)($tHit['sub'] ?? ''));
if ($tTaskId <= 0) {
dav_fail(400, '要指定是哪一条待办(如 /dav/tasks.ics/task-5.ics)。');
dav_fail(400, '要指定是哪一条待办(如 /dav/tasks-3.ics/task-5.ics)。');
}
if (!TaskDav::delete($familyId, $me, $tTaskId)) {
dav_fail(404, '没有这条待办,或你没有删除权限。');