.ics/ 每个分类一本,名字就是分类名 * /dav/tasks-inbox.ics/ 未分类(category_id 为空)的任务 * * ⚠️ 为什么用 `tasks-` 而不是裸 id: * 真实日历本是 `/dav/.ics/`,用裸 id 会与它们撞号 * (calendar_events 的 id 与 task_categories 的 id 是两套独立序列)。 * 加 `tasks-` 前缀后永远不会冲突,也不需要负数那种 trick。 * * ## 权限口径 * * 与网页端「任务」模块完全一致(User 可见 = Task::visibleTo)。 * 不在 DAV 层另写一套权限判断,否则"网页上不能改的,手机能改"会出问题。 * * ## UID 方案 * * `task-`。前缀 `task-` 与日历的 UID 空间隔离(日历 UID 可能是 * `syncal-...`、`movie-100_familybook`,或客户端自造的任意串)。 */ declare(strict_types=1); namespace App; final class TaskDav { /** * 本 href 前缀:`/dav/tasks-.ics/`。 * * Inbox 本用 `tasks-inbox.ics`(id 段是字符串 `inbox` 而不是数字)。 */ public const HREF_PREFIX = '/dav/tasks-'; /** Inbox(未分类)本的 id 段 */ public const INBOX_SEG = 'inbox'; /** Inbox 本的显示名(与参照服务器的 home_todo 一个意思) */ public const INBOX_NAME = '收件箱'; /** 资源名前缀(与日历 UID 空间隔离) */ public const UID_PREFIX = 'task-'; /** ctag 版本,改了导出格式就 +1,让客户端重拉 */ private const CTAG_VERSION = 2; // ============================================================ // 本的识别 // ============================================================ /** * 列出「这个人能看到的待办本」。 * * @return array * id 为 -1 表示 Inbox */ public static function books(int $familyId, int $personId): array { $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; } /** * 从一个 href 或路径解析出待办本的段(`` 或 `inbox`)。 * * @return array{seg:string, sub:string}|null * seg = 本段(如 `3` 或 `inbox`),sub = 资源段(如 `task-12`), * 不是待办本则返回 null。 */ public static function parse(string $href): ?array { $p = rtrim((string)parse_url($href, PHP_URL_PATH), '/'); $prefix = rtrim(self::HREF_PREFIX, '/'); // /dav/tasks- if (strpos($p, $prefix) !== 0) { return null; } $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-3.ics/task-5.ics` 后必须能按同名 GET 回来, * 否则会表现成"写进去了又没了"(CalDav 那边踩过)。 */ public static function segmentOf(array $t): string { return self::UID_PREFIX . (int)($t['id'] ?? 0); } /** 从资源段反解任务 id(`task-5` → 5;认不出返回 0) */ public static function idOfSegment(string $seg): int { $seg = trim($seg); if ($seg === '') { return 0; } if (preg_match('/^' . preg_quote(self::UID_PREFIX, '/') . '(\d+)/', $seg, $m)) { return (int)$m[1]; } return 0; } // ============================================================ // 读取 // ============================================================ /** * 某一本里的任务。 * * @param int $catId 分类 id;-1 表示 Inbox(未分类) */ public static function tasksIn(int $familyId, int $personId, int $catId): array { $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) { if (!Task::visibleTo($r, $personId)) { continue; } $r['uid'] = self::UID_PREFIX . (int)$r['id']; $out[] = $r; } return $out; } /** 单条任务(含权限)。取不到或无权看 → 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, 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]); $r = $st->fetch(); if (!$r) { return null; } if (!Task::visibleTo($r, $personId)) { return null; } $r['uid'] = self::UID_PREFIX . (int)$r['id']; return $r; } // ============================================================ // 导出 // ============================================================ /** * 一条任务 → 资源(ics 正文 + ETag)。 * * ⚠️ ETag 由内容哈希算出,不用"当前时间"。 * CalDav 那边为 ETag 潮汐踩过坑(客户端每轮全量重拉),这里从一开始就用正确做法。 */ public static function resourceOf(array $t, string $calName = '待办'): array { $ics = Ics::exportTodos([$t], $calName); return ['ics' => $ics, 'etag' => '"' . md5($ics) . '"']; } /** 整本导出 */ public static function ics(int $familyId, int $personId, int $catId, string $calName): string { return Ics::exportTodos(self::tasksIn($familyId, $personId, $catId), $calName); } /** * ctag:这一本"变没变"的指纹。 * * ⚠️ 必须覆盖任何会影响导出的变化,且不能随时间变。 * 用 COUNT + MAX(updated_at) + SUM(done_at) 三个一起, * 单用 MAX(updated_at) 在同一秒改两条时会漏。 */ public static function ctag(int $familyId, int $personId, int $catId): string { $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 . '|' . $catId . '|' . (int)($r['n'] ?? 0) . '|' . (string)($r['mx'] ?? '') . '|' . (int)($r['dn'] ?? 0); return '"' . md5($seed) . '"'; } // ============================================================ // 写入 // ============================================================ /** * 客户端 PUT 一条待办 → 落库。 * * @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 */ 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。'); } $n = Ics::normalizeTodo($parsed[0]); if ($n['summary'] === '') { throw new \RuntimeException('VTODO 缺少 SUMMARY(任务标题),拒绝写入。'); } /* 只有"确实存在且有权访问"时才走更新分支。 否则(客户端 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'] ?? ''` 取值), 所以必须把原值补全再传 —— 否则客户端只改标题就会把备注/截止时间清掉。 这是"部分更新"最容易出的 bug。 */ Task::update($familyId, $taskId, [ 'title' => $n['summary'], 'note' => $n['description'], 'status' => $n['status'], 'due_at' => $n['due'] ?? '', 'assignee_id' => (int)($old['assignee_id'] ?? 0), 'repeat_rule' => (string)($old['repeat_rule'] ?? ''), 'parent_id' => (int)($old['parent_id'] ?? 0), 'board_id' => (int)($old['board_id'] ?? 0), // 分类跟随**本**:客户端把资源放进了哪一本,就归哪个分类 'category_id' => $catId === -1 ? 0 : $catId, ]); 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'] ?? '', 'category_id' => $catId === -1 ? 0 : $catId, ]); if ($id <= 0) { throw new \RuntimeException('创建任务失败。'); } 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 { $t = self::one($familyId, $personId, $taskId); if (!$t) { return false; } if (!Task::canWrite($t, $personId)) { return false; } Task::destroy($familyId, $taskId); return true; } }