diff --git a/app/TaskDav.php b/app/TaskDav.php index 3b6e9b4..4923320 100644 --- a/app/TaskDav.php +++ b/app/TaskDav.php @@ -1,40 +1,37 @@ .ics/ 每个分类一本,名字就是分类名 + * /dav/tasks-inbox.ics/ 未分类(category_id 为空)的任务 * - * ⚠️ **虚拟本 id 必须与真实日历 id 不冲突**。用**负数**: - * 真实 `calendar_events.calendar_id` 是 `INT UNSIGNED`,永远 ≥ 1。 - * 负数在 `parsePath()` 出来的 `book` 里一眼可辨,也不会有任何一张表撞上。 + * ⚠️ 为什么用 `tasks-` 而不是裸 id: + * 真实日历本是 `/dav/.ics/`,用裸 id 会与它们撞号 + * (calendar_events 的 id 与 task_categories 的 id 是两套独立序列)。 + * 加 `tasks-` 前缀后永远不会冲突,也不需要负数那种 trick。 * * ## 权限口径 * - * 与网页端「任务」模块**完全一致**(`Task::visibleTo` / `Task::canWrite`): - * · 能看到的:我建的 / 分给我的 / 共享给我的 / 我是父任务可见者的子任务 - * · 能写的:`Task::canWrite` 判(只读共享的不能写) - * ⇒ 不在 DAV 层另写一套权限判断,否则"网页上不能改的,手机能改"会出问题。 + * 与网页端「任务」模块完全一致(User 可见 = Task::visibleTo)。 + * 不在 DAV 层另写一套权限判断,否则"网页上不能改的,手机能改"会出问题。 * * ## UID 方案 * - * `task-`。前缀 `task-` 是为了与日历的 UID 空间**隔离**: - * 日历的 UID 可能是 `syncal-1758000003001-1d8b60`、`movie-100_familybook`, - * 或客户端自造的任意串。加前缀后,即便有人在日历本里放了 UID 叫 `task-5` 的 - * 日程,也不会与本模块的待办 5 互相干扰(路由本就分开了,这里是双保险)。 + * `task-`。前缀 `task-` 与日历的 UID 空间隔离(日历 UID 可能是 + * `syncal-...`、`movie-100_familybook`,或客户端自造的任意串)。 */ declare(strict_types=1); @@ -42,108 +39,201 @@ namespace App; final class TaskDav { - /** 虚拟本的 href(客户端看到的集合地址) */ - public const BOOK_HREF = '/dav/tasks.ics/'; - /** - * 虚拟本 id。 + * 本 href 前缀:`/dav/tasks-.ics/`。 * - * ⚠️ **负数**:`calendar_events.calendar_id` 是 INT UNSIGNED(≥1), - * 用负数永远撞不上;且在 parsePath 的返回值里一眼能认出"这是待办本"。 + * Inbox 本用 `tasks-inbox.ics`(id 段是字符串 `inbox` 而不是数字)。 */ - public const BOOK_ID = -1; + public const HREF_PREFIX = '/dav/tasks-'; - /** 虚拟本显示名 */ - public const BOOK_NAME = '家庭待办'; + /** Inbox(未分类)本的 id 段 */ + public const INBOX_SEG = 'inbox'; + + /** Inbox 本的显示名(与参照服务器的 home_todo 一个意思) */ + public const INBOX_NAME = '收件箱'; /** 资源名前缀(与日历 UID 空间隔离) */ public const UID_PREFIX = 'task-'; - /** 本实现的 ctag 用:客户端拿它判"这整本变没变" */ - private const CTAG_VERSION = 1; + /** ctag 版本,改了导出格式就 +1,让客户端重拉 */ + private const CTAG_VERSION = 2; - /** 这个 book id 是不是待办本 */ - public static function isTaskBook(int $bookId): bool - { - return $bookId === self::BOOK_ID; - } + // ============================================================ + // 本的识别 + // ============================================================ - /** 这个 href(如 `/dav/tasks.ics` 或带尾斜杠)是不是待办本 */ - public static function isTaskHref(string $href): bool + /** + * 列出「这个人能看到的待办本」。 + * + * @return array + * id 为 -1 表示 Inbox + */ + public static function books(int $familyId, int $personId): array { - $p = rtrim((string)parse_url($href, PHP_URL_PATH), '/'); - return $p === rtrim(self::BOOK_HREF, '/'); + $out = []; + + // ---- 各分类本 ---- + /* ⚠️ 不直接用 Task::categories():它带 used_count 子查询, + 对 DAV 每次发现请求都算一遍是浪费。这里只要 id/name/position。 */ + $st = Database::get()->prepare( + 'SELECT id, name FROM task_categories WHERE family_id = ? ORDER BY position, id' + ); + $st->execute([$familyId]); + foreach ($st->fetchAll() as $c) { + $cid = (int)$c['id']; + $out[] = [ + 'seg' => (string)$cid, + 'id' => $cid, + 'name' => (string)$c['name'], + 'href' => self::HREF_PREFIX . $cid . '.ics/', + 'color' => self::colorOf($cid, (string)$c['name']), + ]; + } + + // ---- Inbox(未分类)---- + /* ⚠️ 即使用户当前没有未分类任务,也要列出来 —— + 否则客户端建不出"没选分类"的待办,且新任务会无处可去。 + 参照服务器也是恒定有 home_todo/Inbox 的。 */ + $out[] = [ + 'seg' => self::INBOX_SEG, + 'id' => -1, + 'name' => self::INBOX_NAME, + 'href' => self::HREF_PREFIX . self::INBOX_SEG . '.ics/', + 'color' => '#8A8A8A', + ]; + + return $out; } /** - * 这个路径是不是待办本**及其子资源**(`/dav/tasks.ics/` 或 `/dav/tasks.ics/task-5.ics`)。 + * 从一个 href 或路径解析出待办本的段(`` 或 `inbox`)。 * - * 🔴 2026-11-02 修:PUT/GET 单条/DELETE 报 404。 - * 根因是原来全用 `isTaskHref()`,它只比"整条路径 == /dav/tasks.ics/", - * 带子资源时(`/dav/tasks.ics/task-99999.ics`)必然 false ⇒ - * 请求落到原有日历分支,而 `book=0` 又不是任何一本日历 ⇒ 404。 - * - * ⚠️ 必须用前缀匹配而不是再拆一段: - * `/dav/tasks.icsXXX` 这种畸形路径不该被当成待办本, - * 所以前缀后面必须是 "/" 或结束。 + * @return array{seg:string, sub:string}|null + * seg = 本段(如 `3` 或 `inbox`),sub = 资源段(如 `task-12`), + * 不是待办本则返回 null。 */ - public static function isTaskPath(string $href): bool + public static function parse(string $href): ?array { $p = rtrim((string)parse_url($href, PHP_URL_PATH), '/'); - $base = rtrim(self::BOOK_HREF, '/'); - if ($p === $base) { - return true; + $prefix = rtrim(self::HREF_PREFIX, '/'); // /dav/tasks- + if (strpos($p, $prefix) !== 0) { + return null; } - return strpos($p, $base . '/') === 0; + $rest = substr($p, strlen($prefix)); // 如 `3.ics` 或 `3.ics/task-12.ics` + if ($rest === '') { + return null; + } + $parts = explode('/', $rest); + $bookSeg = (string)preg_replace('/\.ics$/', '', $parts[0]); + if ($bookSeg === '') { + return null; + } + $sub = ''; + if (isset($parts[1]) && $parts[1] !== '') { + $sub = (string)preg_replace('/\.ics$/', '', $parts[1]); + } + return ['seg' => $bookSeg, 'sub' => $sub]; } + /** 这个路径是不是待办本(含子资源) */ + public static function isTaskPath(string $href): bool + { + return self::parse($href) !== null; + } + + /** 段 → 分类 id(Inbox 返回 -1;认不出返回 -2 表示非法) */ + public static function categoryIdOfSeg(string $seg): int + { + if ($seg === self::INBOX_SEG) { + return -1; + } + if (preg_match('/^\d+$/', $seg) === 1) { + return (int)$seg; + } + return -2; + } + + /** + * 校验这个段确实是**这个人能访问的**待办本。 + * + * ⚠️ 必须校验:否则改一下 URL(`/dav/tasks-999.ics/`)就能探到 + * 别的家庭的分类名,属于越权。 + */ + public static function resolveBook(int $familyId, int $personId, string $seg): ?array + { + foreach (self::books($familyId, $personId) as $b) { + if ($b['seg'] === $seg) { + return $b; + } + } + return null; + } + + /** 本名配色(稳定:同一个分类每次都是同一色) */ + private static function colorOf(int $id, string $name): string + { + $palette = ['#B0763A', '#7A4E8C', '#3A7AB0', '#4E8C5A', '#B05A5A', '#8C7A4E', '#5A6B8C']; + return $palette[$id % count($palette)]; + } + + // ============================================================ + // 资源名 + // ============================================================ + /** * 一条 tasks 行 → 资源名(href 最后一段,不含 .ics)。 * - * ⚠️ 与 CalDav::segmentOf() 同口径:**一律用 UID**。 - * 客户端 PUT 到 `/dav/tasks.ics/task-5.ics` 后,必须能按同名 GET 回来, - * 否则会表现成"写进去了又没了"(CalDav 那边踩过这个坑,注释记着)。 + * ⚠️ 与 CalDav::segmentOf() 同口径:一律用 UID。 + * 客户端 PUT 到 `/dav/tasks-3.ics/task-5.ics` 后必须能按同名 GET 回来, + * 否则会表现成"写进去了又没了"(CalDav 那边踩过)。 */ public static function segmentOf(array $t): string { - $id = (int)($t['id'] ?? 0); - return self::UID_PREFIX . $id; + return self::UID_PREFIX . (int)($t['id'] ?? 0); } - /** 从资源段反解任务 id(`task-5` → 5;认不出来返回 0) */ + /** 从资源段反解任务 id(`task-5` → 5;认不出返回 0) */ public static function idOfSegment(string $seg): int { $seg = trim($seg); if ($seg === '') { return 0; } - // 兼容客户端在 UID 后面追加后缀的情况(有些客户端会把 UID 再净化) if (preg_match('/^' . preg_quote(self::UID_PREFIX, '/') . '(\d+)/', $seg, $m)) { return (int)$m[1]; } return 0; } + // ============================================================ + // 读取 + // ============================================================ + /** - * 当前用户能看到的全部任务(含已完成),按"父在前、子在后 + 截止时间"排。 + * 某一本里的任务。 * - * ⚠️ 只查一次库,**不逐条调 `Task::find()`** —— 那会变成 N+1 - * (`find()` 还会顺带查 comments / attachs / shares,对列表来说是浪费)。 - * 权限用 `Task::visibleTo()` 逐条判(它是纯内存判断,除 isShared 会查一次 - * 子任务父级,量很小)。 - * - * @return array tasks 行 + `board_name` + `uid` + * @param int $catId 分类 id;-1 表示 Inbox(未分类) */ - public static function all(int $familyId, int $personId): array + public static function tasksIn(int $familyId, int $personId, int $catId): array { - $st = Database::get()->prepare( - 'SELECT t.*, b.name AS board_name - FROM tasks t - LEFT JOIN task_boards b ON b.id = t.board_id - WHERE t.family_id = ? - ORDER BY t.status <> \'done\', t.due_at IS NULL, t.due_at, t.position, t.id' - ); - $st->execute([$familyId]); + $sql = 'SELECT t.*, b.name AS board_name, c.name AS cat_name + FROM tasks t + LEFT JOIN task_boards b ON b.id = t.board_id + LEFT JOIN task_categories c ON c.id = t.category_id + WHERE t.family_id = ?'; + $params = [$familyId]; + + if ($catId === -1) { + $sql .= ' AND t.category_id IS NULL'; + } else { + $sql .= ' AND t.category_id = ?'; + $params[] = $catId; + } + // 未完成的在前,然后按截止时间、看板顺序 + $sql .= ' ORDER BY t.status <> \'done\', t.due_at IS NULL, t.due_at, t.position, t.id'; + + $st = Database::get()->prepare($sql); + $st->execute($params); $out = []; foreach ($st->fetchAll() as $r) { @@ -156,16 +246,17 @@ final class TaskDav return $out; } - /** 单条任务(含权限判断)。取不到或无权看 → null。 */ + /** 单条任务(含权限)。取不到或无权看 → null。 */ public static function one(int $familyId, int $personId, int $taskId): ?array { if ($taskId <= 0) { return null; } $st = Database::get()->prepare( - 'SELECT t.*, b.name AS board_name + 'SELECT t.*, b.name AS board_name, c.name AS cat_name FROM tasks t LEFT JOIN task_boards b ON b.id = t.board_id + LEFT JOIN task_categories c ON c.id = t.category_id WHERE t.id = ? AND t.family_id = ?' ); $st->execute([$taskId, $familyId]); @@ -180,95 +271,97 @@ final class TaskDav return $r; } + // ============================================================ + // 导出 + // ============================================================ + /** * 一条任务 → 资源(ics 正文 + ETag)。 * - * ⚠️ ETag 由**内容哈希**算出,不用"当前时间"。 - * CalDav 那边为这个踩过坑(ETag 潮汐 ⇒ 客户端每轮全量重拉), - * 这里从一开始就按正确做法来。 + * ⚠️ ETag 由内容哈希算出,不用"当前时间"。 + * CalDav 那边为 ETag 潮汐踩过坑(客户端每轮全量重拉),这里从一开始就用正确做法。 */ - public static function resourceOf(array $t): array + public static function resourceOf(array $t, string $calName = '待办'): array { - $ics = Ics::exportTodos([$t], self::BOOK_NAME); + $ics = Ics::exportTodos([$t], $calName); return ['ics' => $ics, 'etag' => '"' . md5($ics) . '"']; } - /** 整本导出(GET / REPORT 用) */ - public static function ics(int $familyId, int $personId): string + /** 整本导出 */ + public static function ics(int $familyId, int $personId, int $catId, string $calName): string { - return Ics::exportTodos(self::all($familyId, $personId), self::BOOK_NAME); + return Ics::exportTodos(self::tasksIn($familyId, $personId, $catId), $calName); } /** - * ctag:整本"变没变"的指纹。 + * ctag:这一本"变没变"的指纹。 * - * ⚠️ 必须覆盖**任何会影响导出的变化**: - * · 条目增删 ⇒ COUNT 变 - * · 任意一条改动 ⇒ MAX(updated_at) / SUM 变 - * · **状态变化也算改动** —— `setStatus` 会写 updated_at,所以上面这条能兜住 - * (但 `markDone` 之类的旁路如果只改 done_at 不改 updated_at, - * 这里会漏;所以额外把 SUM(UNIX_TIMESTAMP(done_at)) 也算进来)。 - * - * ⚠️ 不用 `MAX(updated_at)` 单打独斗:同一秒内改两条时它不变, - * 客户端会漏一次同步。加上 COUNT 与 done_at 的和,碰撞概率足够低。 + * ⚠️ 必须覆盖任何会影响导出的变化,且不能随时间变。 + * 用 COUNT + MAX(updated_at) + SUM(done_at) 三个一起, + * 单用 MAX(updated_at) 在同一秒改两条时会漏。 */ - public static function ctag(int $familyId, int $personId): string + public static function ctag(int $familyId, int $personId, int $catId): string { - /* ⚠️ 这里按「全 family」统计而不是「这个人可见的」: - 可见性是内存里判的(Task::visibleTo 有 shares 查询), - 放进 SQL 会很复杂。按全家统计的后果只是"别人的任务变了也会让我重拉一次", - 属于**偏保守但安全**的方向(宁可多拉,不可漏拉)。 */ - $st = Database::get()->prepare( - 'SELECT COUNT(*) AS n, - COALESCE(MAX(updated_at), \'\') AS mx, - COALESCE(SUM(UNIX_TIMESTAMP(COALESCE(done_at, \'1970-01-01\'))), 0) AS dn - FROM tasks WHERE family_id = ?' - ); - $st->execute([$familyId]); + $sql = 'SELECT COUNT(*) AS n, + COALESCE(MAX(updated_at), \'\') AS mx, + COALESCE(SUM(UNIX_TIMESTAMP(COALESCE(done_at, \'1970-01-01\'))), 0) AS dn + FROM tasks WHERE family_id = ?'; + $params = [$familyId]; + if ($catId === -1) { + $sql .= ' AND category_id IS NULL'; + } else { + $sql .= ' AND category_id = ?'; + $params[] = $catId; + } + $st = Database::get()->prepare($sql); + $st->execute($params); $r = $st->fetch() ?: []; - $seed = self::CTAG_VERSION . '|' + $seed = self::CTAG_VERSION . '|' . $catId . '|' . (int)($r['n'] ?? 0) . '|' . (string)($r['mx'] ?? '') . '|' . (int)($r['dn'] ?? 0); return '"' . md5($seed) . '"'; } + // ============================================================ + // 写入 + // ============================================================ + /** * 客户端 PUT 一条待办 → 落库。 * - * @param int $taskId URL 段里解析出的 id(0 = 新建) - * @param string $body text/calendar 正文 + * @param int $catId 本对应的分类 id(-1 = Inbox) + * @param int $taskId URL 段解析出的 id(0 = 新建) + * @param string $body text/calendar 正文 * * @return array{id:int, created:bool} - * @throws \RuntimeException 正文不合法 / 无写权限 + * @throws \RuntimeException */ - public static function put(int $familyId, int $personId, int $taskId, string $body): array + public static function put(int $familyId, int $personId, int $catId, + int $taskId, string $body): array { $parsed = Ics::parseTodos($body); if (!$parsed) { throw new \RuntimeException('正文里没有找到 VTODO。'); } - // 一个资源 = 一条待办。出现多个说明客户端行为异常,取第一条(与 CalDav 同口径) $n = Ics::normalizeTodo($parsed[0]); if ($n['summary'] === '') { throw new \RuntimeException('VTODO 缺少 SUMMARY(任务标题),拒绝写入。'); } - // ---- 更新已存在的 ---- - /* ⚠️ 只有当这条**确实存在且有权访问**时才走更新分支。 - 否则(比如客户端 PUT 到 task-99999.ics,而 99999 不存在) - 必须**新建** —— RFC 4918 §9.7 规定 PUT 到不存在的 URI 就是创建资源。 - 原来这里直接抛"任务不存在",会让客户端建不了新待办(实测 403)。 + /* 只有"确实存在且有权访问"时才走更新分支。 + 否则(客户端 PUT 到 task-99999.ics 而它不存在)必须**新建** —— + RFC 4918 §9.7 规定 PUT 到不存在的 URI 就是创建资源。 这也兼容"客户端用自己编的 UID 当资源名"的场景。 */ $old = $taskId > 0 ? self::one($familyId, $personId, $taskId) : null; + if ($old !== null) { if (!Task::canWrite($old, $personId)) { throw new \RuntimeException('这条任务你没有写权限。'); } - - /* ⚠️ `Task::update()` 会把**没传的字段清空**(它按 `$d['x'] ?? ''` 取值), + /* ⚠️ Task::update() 会把没传的字段清空(它按 `$d['x'] ?? ''` 取值), 所以必须把原值补全再传 —— 否则客户端只改标题就会把备注/截止时间清掉。 这是"部分更新"最容易出的 bug。 */ Task::update($familyId, $taskId, [ @@ -280,37 +373,44 @@ final class TaskDav 'repeat_rule' => (string)($old['repeat_rule'] ?? ''), 'parent_id' => (int)($old['parent_id'] ?? 0), 'board_id' => (int)($old['board_id'] ?? 0), - 'category_id' => (int)($old['category_id'] ?? 0), + // 分类跟随**本**:客户端把资源放进了哪一本,就归哪个分类 + 'category_id' => $catId === -1 ? 0 : $catId, ]); - - // 完成时间:`update()` 走到 done 会自动 markDone;这里补 done_at 的精确值 - // (客户端给的 COMPLETED 时间比"服务器当前时间"更准) - if ($n['status'] === Task::DONE_STATUS && !empty($n['completed'])) { - Database::get()->prepare( - 'UPDATE tasks SET done_at = ? WHERE id = ? AND family_id = ?' - )->execute([$n['completed'], $taskId, $familyId]); - } + self::fixDoneAt($familyId, $taskId, $n); return ['id' => $taskId, 'created' => false]; } // ---- 新建 ---- $id = Task::create($familyId, $personId, [ - 'title' => $n['summary'], - 'note' => $n['description'], - 'status' => $n['status'], - 'due_at' => $n['due'] ?? '', + 'title' => $n['summary'], + 'note' => $n['description'], + 'status' => $n['status'], + 'due_at' => $n['due'] ?? '', + 'category_id' => $catId === -1 ? 0 : $catId, ]); if ($id <= 0) { throw new \RuntimeException('创建任务失败。'); } - if ($n['status'] === Task::DONE_STATUS && !empty($n['completed'])) { - Database::get()->prepare( - 'UPDATE tasks SET done_at = ? WHERE id = ? AND family_id = ?' - )->execute([$n['completed'], $id, $familyId]); - } + self::fixDoneAt($familyId, $id, $n); return ['id' => $id, 'created' => true]; } + /** + * 用客户端给的 COMPLETED 时间覆盖 done_at。 + * + * `Task::update()` 走到 done 时会自动 markDone(用"服务器当前时间"), + * 但客户端给的 COMPLETED 更准(它可能补录昨天完成的事)。 + */ + private static function fixDoneAt(int $familyId, int $taskId, array $n): void + { + if ($n['status'] !== Task::DONE_STATUS || empty($n['completed'])) { + return; + } + Database::get()->prepare( + 'UPDATE tasks SET done_at = ? WHERE id = ? AND family_id = ?' + )->execute([$n['completed'], $taskId, $familyId]); + } + /** 客户端 DELETE 一条待办 */ public static function delete(int $familyId, int $personId, int $taskId): bool { diff --git a/dav.php b/dav.php index 5280bf5..a39d93e 100644 --- a/dav.php +++ b/dav.php @@ -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/"', [ - '' . CalDav::x(TaskDav::BOOK_HREF) . '' + '' . CalDav::x($tBook['href']) . '' . '' . '' - . '' . CalDav::x(TaskDav::BOOK_NAME) . '' + . '' . CalDav::x($tBook['name']) . '' . 'httpd/unix-directory' - . '' . CalDav::x('岁月家 · 待办(任务看板同步)') . '' - . '#7C6A5B' + . '' . CalDav::x('岁月家 · 待办 · ' . $tBook['name']) . '' + . '' . CalDav::x($tBook['color']) . '' . '' . '' . CalDav::x($ctag) . '' . '' . CalDav::x($ctag) . '' @@ -368,21 +384,21 @@ if ($method === 'PROPFIND') { ⚠️ href 必须与 GET/REPORT 用同一个 segmentOf 算, 三处不一致的话客户端会反复重拉(日历那边踩过)。 */ $items = [ - '' . CalDav::x(TaskDav::BOOK_HREF) . '' + '' . CalDav::x($tBook['href']) . '' . '' . '' - . '' . CalDav::x(TaskDav::BOOK_NAME) . '' + . '' . CalDav::x($tBook['name']) . '' . '' . 'HTTP/1.1 200 OK', ]; - 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[] = '' - . CalDav::x(TaskDav::BOOK_HREF . $seg . '.ics') . '' + . CalDav::x($tBook['href'] . $seg . '.ics') . '' . '' . '' . CalDav::x($etag) . '' . '' @@ -539,22 +555,26 @@ if ($method === 'PROPFIND') { . '' . 'HTTP/1.1 200 OK'); } - /* 待办本:作为「/dav/ 下的一个日历本」挂进来(用户选定的方案)。 - 没有这条,客户端 PROPFIND /dav/ 时根本不知道有这一本,也就不会去拉。 */ - $tCtag = TaskDav::ctag($familyId, $me); - $parts[] = '' . CalDav::x(TaskDav::BOOK_HREF) . '' - . '' - . '' - . '' . CalDav::x(TaskDav::BOOK_NAME) . '' - . 'httpd/unix-directory' - . '' . CalDav::x('岁月家 · 待办(任务看板同步)') . '' - . '#7C6A5B' - . '' - . '' . CalDav::x($tCtag) . '' - . '' . CalDav::x($tCtag) . '' - . '' . CalDav::x(CalDav::PRINCIPAL_PATH) . '' - . '' . CalDav::x(CalDav::PRINCIPAL_PATH) . '' - . 'HTTP/1.1 200 OK'; + /* 待办本:**按分类拆成多本**(与参照 DAViCal 同构)。 + 用户 2026-11-02:「应该可以直接显示那个家庭的待办分类」。 + 参照实测:每个分类是独立 calendar 集合,客户端里并列显示为多个待办列表。 + 没有这一整批,客户端 PROPFIND /dav/ 时根本不知道有待办,也就不会去拉。 */ + foreach (TaskDav::books($familyId, $me) as $tb) { + $tCtag = TaskDav::ctag($familyId, $me, (int)$tb['id']); + $parts[] = '' . CalDav::x($tb['href']) . '' + . '' + . '' + . '' . CalDav::x($tb['name']) . '' + . 'httpd/unix-directory' + . '' . CalDav::x('岁月家 · 待办 · ' . $tb['name']) . '' + . '' . CalDav::x($tb['color']) . '' + . '' + . '' . CalDav::x($tCtag) . '' + . '' . CalDav::x($tCtag) . '' + . '' . CalDav::x(CalDav::PRINCIPAL_PATH) . '' + . '' . CalDav::x(CalDav::PRINCIPAL_PATH) . '' + . 'HTTP/1.1 200 OK'; + } $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[] = '' - . CalDav::x(TaskDav::BOOK_HREF . $seg . '.ics') . '' + . CalDav::x($tBook['href'] . $seg . '.ics') . '' . '' . '' . CalDav::x($res['etag']) . '' . '' . CalDav::x($res['ics']) . '' @@ -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, '没有这条待办,或你没有删除权限。');