From c86658966c927705602d75c97401480e6453a315 Mon Sep 17 00:00:00 2001 From: laoyang Date: Mon, 2 Nov 2026 15:29:36 +0800 Subject: [PATCH] =?UTF-8?q?CalDAV=20=E5=A2=9E=E5=8A=A0=E5=BE=85=E5=8A=9E?= =?UTF-8?q?=EF=BC=88VTODO=EF=BC=89=E5=8F=8C=E5=90=91=E5=90=8C=E6=AD=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户原话:「网页端的 caldav 功能,除了能够同步日程外,增加可以同步任务(待办)的功能」 ## 三个设计选择(用户已定) 1. 存放位置:作为 /dav/ 内的一个日历本,不另开 /taskdav/ 入口 2. 同步方向:直接双向读写 3. 字段映射:标准映射 ## 字段映射 | VTODO | tasks 列 | 说明 | |---|---|---| | UID | 派生 task- | 与日历 UID 空间隔离 | | SUMMARY | title | 子任务加「└ 」前缀(纯展示) | | DESCRIPTION | note | | | DUE | due_at | 零点视为整天,用 VALUE=DATE | | STATUS | status | todo→NEEDS-ACTION,doing→IN-PROCESS,done→COMPLETED | | COMPLETED | done_at | 仅 done 时输出 | | PERCENT-COMPLETE | status | doing=50,done=100 | | CATEGORIES | 看板名 | 让客户端能归类 | | RELATED-TO | parent_id | 标准父子表达 | ## 改动 ### app/Ics.php:并列增加 VTODO 能力 不改 parse()/export() 的任何行为(VEVENT 路径注释里记着多轮踩坑史), 新增 exportTodos() / vtodo() / parseTodos() / normalizeTodo(),复用底层工具。 ### app/TaskDav.php(新建,约 380 行) 虚拟日历本:href 固定 /dav/tasks.ics/,BOOK_ID = -1 (calendar_events.calendar_id 是 INT UNSIGNED 恒 ≥1,负数永不撞)。 权限口径与网页端完全一致,复用 Task::visibleTo / Task::canWrite / Task::create / Task::update / Task::destroy,不另写一套。 ### dav.php:前置拦截,原有日历逻辑零改动 PROPFIND / REPORT / GET / PUT / DELETE 各分支开头判断是否待办本, 是则走 TaskDav 并 exit。另外在 /dav/ 的本清单里追加待办本 (否则客户端根本不知道有这一本)。 ## 过程中修掉的 4 个真 bug 1. **Class "TaskDav" not found** → 缺 use App\TaskDav; autoload 要求带上命名空间前缀,漏了会让整个 dav.php 全线 500。 2. **PUT/GET单条/DELETE 全 404** → 原来全用 isTaskHref(), 它只比「整条路径 == /dav/tasks.ics/」,带子资源时必然 false。 新增 isTaskPath() 做前缀匹配(且要求前缀后必须是 / 或结束, 避免 /dav/tasks.icsXXX 被误判)。 3. **PUT 到不存在的资源名返回 403** → 原实现直接抛「任务不存在」。 按 RFC 4918 §9.7,PUT 到不存在的 URI 就是创建资源。 改为:只有确实存在且有权访问时才走更新分支,否则新建。 4. **注释里的 markdown 加粗导致 PHP 语法错** `作为 **/dav/ 内的一个日历本**` 里 `**/` 形成 `*/`,提前闭合块注释。 PHP 注释里不要写 markdown 加粗。 ## 验证 ### Ics 层(34 项全过) 生成 → 解析 → 归一往返,含三种客户端完成态表达 (STATUS:COMPLETED / 只有 COMPLETED 时间戳 / PERCENT-COMPLETE:100)。 ### TaskDav 层(45 项全过,真实 32 条任务) 读写、ETag 稳定性、权限、事务回滚。 写操作全在事务里做并回滚,不污染真实数据。 ### HTTP 端到端(37 项全过) OPTIONS → PROPFIND 发现 → REPORT → GET 整本 → PUT 新建 → GET 读回 → PUT 更新 → DELETE,另含回归与越权检查: - 原有日历本 PROPFIND/GET 仍正常,组件类型仍是 VEVENT - 无口令 401、错口令 401、不存在的任务 404 测试按 RFC 跟随 Location(新建时服务端分配 id 并告知真实 href), 这也是真实客户端的行为。 ### 数据完整性 测试前后 tasks 均为 32 条,测试数据已清理。 测试用 DAV 口令已 revoke(不影响用户原有口令)。 ## 部署 app/Ics.php、app/TaskDav.php、dav.php 三个文件已上传并验证。 ⚠️ 服务端文件不在客户端仓库,以后改这些要先比对服务器 md5 再验 (本次踩过:改了没上传,拿旧代码验出假结果)。 --- app/Ics.php | 281 +++++++++++++++++++++++++++++++++++++++++ app/TaskDav.php | 327 ++++++++++++++++++++++++++++++++++++++++++++++++ dav.php | 219 ++++++++++++++++++++++++++++++++ 3 files changed, 827 insertions(+) create mode 100644 app/TaskDav.php diff --git a/app/Ics.php b/app/Ics.php index 05e4441..05c0402 100644 --- a/app/Ics.php +++ b/app/Ics.php @@ -57,6 +57,161 @@ final class Ics 'RECURRENCE-ID', ]; + /** + * 把一个待办集合导出成 .ics 文本(VTODO 版本)。 + * + * 🔴 2026-11-02 新增:用户「网页端 caldav 除了同步日程,增加同步任务(待办)」。 + * + * ⚠️ 为什么不复用 `export()`:`export()` 里每条都走 `vevent()`, + * 而 VEVENT 与 VTODO 是**两种组件**(RFC 5545 §3.6.1 / §3.6.2): + * VEVENT 必须有 DTSTART、VTODO 不能有 DTEND。混用会让客户端解析失败。 + * ⇒ 单独一个入口,但**共用** fold/escape 等底层工具,风格保持一致。 + * + * @param array $tasks tasks 表的行(已补好 uid / board_name) + * @param string $calName X-WR-CALNAME 用的名字 + */ + public static function exportTodos(array $tasks, string $calName = '待办'): string + { + $out = [ + 'BEGIN:VCALENDAR', + 'VERSION:2.0', + 'PRODID:' . self::PRODID, + 'CALSCALE:GREGORIAN', + 'METHOD:PUBLISH', + 'X-WR-CALNAME:' . self::escapeText($calName), + /* VTODO 是 CalDAV 的扩展能力。这两个 non-standard 头是 + iOS / 苹果提醒事项认「这是个待办本」的依据之一; + 不认识它们的客户端会忽略,不影响 VEVENT 本。 */ + 'X-WR-RELCALID:familybook-tasks', + 'X-PUBLISHED-TTL:PT1H', + ]; + foreach ($tasks as $t) { + $out = array_merge($out, self::vtodo($t)); + } + $out[] = 'END:VCALENDAR'; + + $lines = []; + foreach ($out as $line) { + foreach (self::fold($line) as $l) { + $lines[] = $l; + } + } + return implode("\r\n", $lines) . "\r\n"; + } + + /** + * 一条任务 → VTODO 的若干行。 + * + * ⚠️ VTODO **不能有 DTSTART 之外的时间边界**:`DTEND` 是 VEVENT 的属性, + * 用在 VTODO 上会被严格客户端判为非法而整条丢弃。 + * 截止时间只走 `DUE`。 + */ + private static function vtodo(array $t): array + { + $uid = (string)($t['uid'] ?? ''); + if ($uid === '') { + $uid = 'task-' . (int)($t['id'] ?? 0); + } + $stamp = self::stampOf($t['updated_at'] ?? $t['created_at'] ?? null); + $status = (string)($t['status'] ?? 'todo'); + + $out = ['BEGIN:VTODO', 'UID:' . $uid, 'DTSTAMP:' . $stamp]; + + /* ---- 完成时间:必须在 STATUS:COMPLETED 之前给, + 苹果提醒事项要求 COMPLETED 与 STATUS 同时出现才认"已完成" ---- */ + $doneAt = (string)($t['done_at'] ?? ''); + if ($status === self::DONE_STATUS && $doneAt !== '') { + $out[] = 'COMPLETED:' . self::dateTimeValue($doneAt); + } + + if (($t['created_at'] ?? '') !== '') { + $out[] = 'CREATED:' . self::dateTimeValue((string)$t['created_at']); + } + if (($t['updated_at'] ?? '') !== '') { + $out[] = 'LAST-MODIFIED:' . self::dateTimeValue((string)$t['updated_at']); + } + + // ---- 标题(子任务加视觉前缀,纯展示)---- + $title = (string)($t['title'] ?? ''); + if ((int)($t['parent_id'] ?? 0) > 0) { + $title = '└ ' . $title; + } + $out[] = 'SUMMARY:' . self::escapeText($title); + + if (($t['note'] ?? '') !== '') { + $out[] = 'DESCRIPTION:' . self::escapeText((string)$t['note']); + } + + /* ---- 截止时间 ---- + 库里 `due_at` 是 DATETIME。判断"是否整天"的方式:时分秒全 00:00:00 + 就按 DATE 输出(用户在网页上选"某天截止"通常就是这个形态), + 否则按 DATE-TIME 输出。这样客户端显示的粒度与网页端一致。 */ + $due = (string)($t['due_at'] ?? ''); + if ($due !== '') { + if (self::isMidnight($due)) { + $out[] = 'DUE;VALUE=DATE:' . self::dateValue($due); + } else { + $out[] = 'DUE:' . self::dateTimeValue($due); + } + } + + // ---- 状态 ---- + $out[] = 'STATUS:' . self::statusOf($status); + $out[] = 'PERCENT-COMPLETE:' . self::percentOf($status); + + /* ---- 看板名进 CATEGORIES ---- + 客户端大多支持按 CATEGORIES 分组/打标签,把看板名放这里 + 是"让客户端也能看出这条属于哪个看板"的最低成本方式。 + ⚠️ 派生只读,不参与反向写回(PUT 时不从 CATEGORIES 改看板)。 */ + $board = (string)($t['board_name'] ?? ''); + if ($board !== '') { + $out[] = 'CATEGORIES:' . self::escapeText($board); + } + + // ---- 父子关系(标准表达,但多数客户端不显示;不影响语义)---- + $parentId = (int)($t['parent_id'] ?? 0); + if ($parentId > 0) { + $out[] = 'RELATED-TO:task-' . $parentId; + } + + $out[] = 'END:VTODO'; + return $out; + } + + /** 任务的三种状态 → VTODO 的 STATUS 值 */ + private static function statusOf(string $st): string + { + if ($st === self::DONE_STATUS) { + return 'COMPLETED'; + } + if ($st === 'doing') { + return 'IN-PROCESS'; + } + return 'NEEDS-ACTION'; + } + + /** STATUS 的通用信号:不认 IN-PROCESS 的客户端也能看出进度 */ + private static function percentOf(string $st): int + { + if ($st === self::DONE_STATUS) { + return 100; + } + return $st === 'doing' ? 50 : 0; + } + + /** 这个 DATETIME 是不是"零点"(= 用户表达的是"某天"而不是"某时刻") */ + private static function isMidnight(string $dt): bool + { + $t = strtotime($dt); + if ($t === false) { + return false; + } + return date('H:i:s', $t) === '00:00:00'; + } + + /** 任务完成状态常量(与 App\\Task::DONE_STATUS 一致,这里独立一份避免循环依赖) */ + private const DONE_STATUS = 'done'; + /** * 把一个日历本导出成 .ics 文本。 * @@ -362,6 +517,132 @@ final class Ics return $out; } + /** + * 解析 VTODO(客户端 PUT 上来的待办)。 + * + * 🔴 2026-11-02 新增。**独立于** `parse()`: + * `parse()` 只收 VEVENT,改它会波及日历写路径(那里注释记了多轮踩坑史)。 + * + * @return array 每条的字段: + * uid / summary / description / due / status / percent / completed / + * priority / categories / parent_uid / extra + * + * ⚠️ 只取**第一个** VTODO:一个 CalDAV 资源对应一个待办, + * 正文里出现多个说明客户端行为异常,宁可少存不可乱存 + * (与 PUT 里 VEVENT 的处理口径一致)。 + */ + public static function parseTodos(string $text): array + { + $text = str_replace(["\r\n", "\r"], "\n", $text); + $text = preg_replace("/\n[ \t]/", '', $text) ?? $text; + $lines = explode("\n", $text); + + $out = []; + $cur = null; + foreach ($lines as $raw) { + $line = trim($raw); + if ($line === '') { + continue; + } + if ($line === 'BEGIN:VTODO') { + $cur = []; + continue; + } + if ($line === 'END:VTODO') { + if ($cur !== null) { + $out[] = $cur; + } + $cur = null; + continue; + } + if ($cur === null) { + continue; // VCALENDAR / VALARM / VEVENT 里的东西跳过 + } + [$name, $params, $value] = self::splitProp($line); + if ($name === '') { + continue; + } + // 同名只留第一个(VTODO 里这些属性都是单值的) + if (!array_key_exists($name, $cur)) { + $cur[$name] = $value; + $cur[$name . '_PARAMS'] = $params; + } + } + return $out; + } + + /** + * 把解析出来的 VTODO 属性归一成能落库的形状。 + * + * 与 `normalizeVevent()` 并列,互不影响。 + * + * @return array{uid:string, summary:string, description:string, + * due:?string, status:string, completed:?string, + * percent:?int, priority:?int, categories:string, + * parent_uid:string} + */ + public static function normalizeTodo(array $p): array + { + $summary = self::unescapeText((string)($p['SUMMARY'] ?? '')); + $desc = self::unescapeText((string)($p['DESCRIPTION'] ?? '')); + + // ---- DUE:可能是 DATE 也可能 DATE-TIME ---- + $due = null; + $dueRaw = (string)($p['DUE'] ?? ''); + if ($dueRaw !== '') { + $dueParams = (string)($p['DUE_PARAMS'] ?? ''); + if (stripos($dueParams, 'VALUE=DATE') !== false && stripos($dueParams, 'DATE-TIME') === false) { + // 整天:`fromDateValue()` 返回的已经是 `Y-m-d 00:00:00` 完整形态, + // ⚠️ 不要再拼 ` 00:00:00` —— 会变成 `... 00:00:00 00:00:00`(踩过) + $due = self::fromDateValue($dueRaw); + } else { + $due = self::fromDateTimeValue($dueRaw, $dueParams); + } + } + + // ---- STATUS / COMPLETED ---- + /* 完成态的判定优先级: + ① `STATUS:COMPLETED` —— 最明确 + ② 有 `COMPLETED` 时间戳 —— 苹果提醒事项勾选时只加这个 + ③ `PERCENT-COMPLETE:100` —— 少数客户端只用这个 + 三者任一成立就算已完成,否则再看 IN-PROCESS。 */ + $stRaw = strtoupper(trim((string)($p['STATUS'] ?? ''))); + $compRaw = (string)($p['COMPLETED'] ?? ''); + $pctRaw = (string)($p['PERCENT-COMPLETE'] ?? ''); + $completed = $compRaw !== '' + ? self::fromDateTimeValue($compRaw, (string)($p['COMPLETED_PARAMS'] ?? '')) + : null; + + $isDone = $stRaw === 'COMPLETED' + || $completed !== null + || (trim($pctRaw) !== '' && (int)$pctRaw >= 100); + + if ($isDone) { + $status = self::DONE_STATUS; + } elseif ($stRaw === 'IN-PROCESS' || $stRaw === 'IN_PROGRESS') { + $status = 'doing'; + } elseif (trim($pctRaw) !== '' && (int)$pctRaw > 0 && (int)$pctRaw < 100) { + $status = 'doing'; + } else { + $status = 'todo'; + } + + $prio = trim((string)($p['PRIORITY'] ?? '')); + + return [ + 'uid' => trim((string)($p['UID'] ?? '')), + 'summary' => $summary, + 'description' => $desc, + 'due' => $due, + 'status' => $status, + 'completed' => $completed, + 'percent' => $pctRaw === '' ? null : (int)$pctRaw, + 'priority' => $prio === '' ? null : (int)$prio, + 'categories' => self::unescapeText((string)($p['CATEGORIES'] ?? '')), + 'parent_uid' => trim((string)($p['RELATED-TO'] ?? '')), + ]; + } + /** * 把解析出来的属性归一成我们要落库的形状。 * diff --git a/app/TaskDav.php b/app/TaskDav.php new file mode 100644 index 0000000..3b6e9b4 --- /dev/null +++ b/app/TaskDav.php @@ -0,0 +1,327 @@ +`。前缀 `task-` 是为了与日历的 UID 空间**隔离**: + * 日历的 UID 可能是 `syncal-1758000003001-1d8b60`、`movie-100_familybook`, + * 或客户端自造的任意串。加前缀后,即便有人在日历本里放了 UID 叫 `task-5` 的 + * 日程,也不会与本模块的待办 5 互相干扰(路由本就分开了,这里是双保险)。 + */ +declare(strict_types=1); + +namespace App; + +final class TaskDav +{ + /** 虚拟本的 href(客户端看到的集合地址) */ + public const BOOK_HREF = '/dav/tasks.ics/'; + + /** + * 虚拟本 id。 + * + * ⚠️ **负数**:`calendar_events.calendar_id` 是 INT UNSIGNED(≥1), + * 用负数永远撞不上;且在 parsePath 的返回值里一眼能认出"这是待办本"。 + */ + public const BOOK_ID = -1; + + /** 虚拟本显示名 */ + public const BOOK_NAME = '家庭待办'; + + /** 资源名前缀(与日历 UID 空间隔离) */ + public const UID_PREFIX = 'task-'; + + /** 本实现的 ctag 用:客户端拿它判"这整本变没变" */ + private const CTAG_VERSION = 1; + + /** 这个 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 + { + $p = rtrim((string)parse_url($href, PHP_URL_PATH), '/'); + return $p === rtrim(self::BOOK_HREF, '/'); + } + + /** + * 这个路径是不是待办本**及其子资源**(`/dav/tasks.ics/` 或 `/dav/tasks.ics/task-5.ics`)。 + * + * 🔴 2026-11-02 修:PUT/GET 单条/DELETE 报 404。 + * 根因是原来全用 `isTaskHref()`,它只比"整条路径 == /dav/tasks.ics/", + * 带子资源时(`/dav/tasks.ics/task-99999.ics`)必然 false ⇒ + * 请求落到原有日历分支,而 `book=0` 又不是任何一本日历 ⇒ 404。 + * + * ⚠️ 必须用前缀匹配而不是再拆一段: + * `/dav/tasks.icsXXX` 这种畸形路径不该被当成待办本, + * 所以前缀后面必须是 "/" 或结束。 + */ + public static function isTaskPath(string $href): bool + { + $p = rtrim((string)parse_url($href, PHP_URL_PATH), '/'); + $base = rtrim(self::BOOK_HREF, '/'); + if ($p === $base) { + return true; + } + return strpos($p, $base . '/') === 0; + } + + /** + * 一条 tasks 行 → 资源名(href 最后一段,不含 .ics)。 + * + * ⚠️ 与 CalDav::segmentOf() 同口径:**一律用 UID**。 + * 客户端 PUT 到 `/dav/tasks.ics/task-5.ics` 后,必须能按同名 GET 回来, + * 否则会表现成"写进去了又没了"(CalDav 那边踩过这个坑,注释记着)。 + */ + public static function segmentOf(array $t): string + { + $id = (int)($t['id'] ?? 0); + return self::UID_PREFIX . $id; + } + + /** 从资源段反解任务 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` + */ + public static function all(int $familyId, int $personId): 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]); + + $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 + FROM tasks t + LEFT JOIN task_boards b ON b.id = t.board_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): array + { + $ics = Ics::exportTodos([$t], self::BOOK_NAME); + return ['ics' => $ics, 'etag' => '"' . md5($ics) . '"']; + } + + /** 整本导出(GET / REPORT 用) */ + public static function ics(int $familyId, int $personId): string + { + return Ics::exportTodos(self::all($familyId, $personId), self::BOOK_NAME); + } + + /** + * 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 的和,碰撞概率足够低。 + */ + public static function ctag(int $familyId, int $personId): 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]); + $r = $st->fetch() ?: []; + + $seed = self::CTAG_VERSION . '|' + . (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 正文 + * + * @return array{id:int, created:bool} + * @throws \RuntimeException 正文不合法 / 无写权限 + */ + public static function put(int $familyId, int $personId, 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)。 + 这也兼容"客户端用自己编的 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' => (int)($old['category_id'] ?? 0), + ]); + + // 完成时间:`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]); + } + return ['id' => $taskId, 'created' => false]; + } + + // ---- 新建 ---- + $id = Task::create($familyId, $personId, [ + 'title' => $n['summary'], + 'note' => $n['description'], + 'status' => $n['status'], + 'due_at' => $n['due'] ?? '', + ]); + 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]); + } + return ['id' => $id, 'created' => true]; + } + + /** 客户端 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; + } +} \ No newline at end of file diff --git a/dav.php b/dav.php index 029e1cc..5280bf5 100644 --- a/dav.php +++ b/dav.php @@ -58,6 +58,7 @@ use App\CalDav; use App\Calendar; use App\Database; use App\Ics; +use App\TaskDav; /** 统一的失败出口(CalDAV 客户端只认状态码,正文给点人话便于排查) */ function dav_fail(int $code, string $msg): void @@ -317,6 +318,80 @@ $emit = static function (string $xmlns, array $parts): void { // ---- 5. PROPFIND:发现链 + 清单 / 属性 / 资源列表 ---- if ($method === 'PROPFIND') { + /* ============================================================ + * 待办本(VTODO)—— 必须在原有日历逻辑之前拦下 + * ============================================================ + * /dav/tasks.ics/ 是本模块的虚拟本,不对应 calendar_events 的任何一行。 + * 在这里前置拦截,原有日历分支一个字都不用改(零回归风险)。 + */ + if (TaskDav::isTaskHref($path)) { + $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); + echo '' . "\n" + . '' . "\n" + . implode("\n", $items) . "\n" + . ''; + exit; + }; + + if ($tDepth !== '1') { + /* Depth:0 —— 这一本自身的属性。 + ⚠️ supported-calendar-component-set 必须是 VTODO: + 给 VEVENT 的话客户端会按日程解析,VTODO 会被过滤掉不显示。 */ + $ctag = TaskDav::ctag($familyId, $me); + $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(TaskDav::BOOK_NAME) . '' + . 'httpd/unix-directory' + . '' . CalDav::x('岁月家 · 待办(任务看板同步)') . '' + . '#7C6A5B' + . '' + . '' . CalDav::x($ctag) . '' + . '' . CalDav::x($ctag) . '' + . '' . CalDav::x(CalDav::PRINCIPAL_PATH) . '' + . '' . CalDav::x(CalDav::PRINCIPAL_PATH) . '' + . '' + . 'HTTP/1.1 200 OK', + ] + ); + } + + /* Depth:1 —— 本 + 里面的资源清单。 + ⚠️ href 必须与 GET/REPORT 用同一个 segmentOf 算, + 三处不一致的话客户端会反复重拉(日历那边踩过)。 */ + $items = [ + '' . CalDav::x(TaskDav::BOOK_HREF) . '' + . '' + . '' + . '' . CalDav::x(TaskDav::BOOK_NAME) . '' + . '' + . 'HTTP/1.1 200 OK', + ]; + foreach (TaskDav::all($familyId, $me) as $t) { + $seg = TaskDav::segmentOf($t); + if ($seg === '') { + continue; + } + $etag = TaskDav::resourceOf($t)['etag']; + $items[] = '' + . CalDav::x(TaskDav::BOOK_HREF . $seg . '.ics') . '' + . '' + . '' . CalDav::x($etag) . '' + . '' + . 'text/calendar; charset=utf-8' + . 'HTTP/1.1 200 OK'; + } + $taskEmit('xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav"', $items); + } + $depth = (string)($_SERVER['HTTP_DEPTH'] ?? '0'); // 5a. `PROPFIND /`(站点根)→ 只回 current-user-principal。 @@ -464,6 +539,23 @@ 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'; + $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); @@ -522,6 +614,36 @@ if ($method === 'PROPFIND') { // ---- 6. GET / HEAD:整本导出成 ics,或单条资源 ---- if ($method === 'GET' || $method === 'HEAD') { + /* 待办本:整本导出,或单条 VTODO。 + ⚠️ 单条时必须校验资源名属于本用户可见的任务, + 否则改一下 URL 就能读到别人的任务(越权)。 */ + if (TaskDav::isTaskPath($path)) { + $tParsed = TaskDav::idOfSegment($evSeg); + if ($tParsed > 0) { + $tOne = TaskDav::one($familyId, $me, $tParsed); + if (!$tOne) { + dav_fail(404, '没有这条待办。'); + } + $tRes = TaskDav::resourceOf($tOne); + header('Content-Type: text/calendar; charset=utf-8'); + header('ETag: ' . $tRes['etag']); + if ($method === 'HEAD') { + exit; + } + 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; + } + echo $tIcs; + exit; + } + /* 🔴🔴 v45b(2026-10-12 用户「TB 还是找不到日历」)——**真 bug 修在这里**。 原来这一段是: if (!$cal) { dav_fail(404, '没有这本日历。'); } @@ -596,6 +718,48 @@ if ($method === 'GET' || $method === 'HEAD') { // ---- 7. REPORT:calendar-multiget(按 href 取) / calendar-query(整本或按时间范围)---- if ($method === 'REPORT') { + /* 待办本:calendar-query / calendar-multiget 都返回 VTODO。 + ⚠️ 支持 multiget 按 href 过滤 —— 整本回虽然"客户端也能用", + 但一本 30 条时无感、将来几百条时就是纯浪费(日历那边为此改过)。 */ + if (TaskDav::isTaskPath($path)) { + $tBody = (string)file_get_contents('php://input'); + $tWantsMulti = stripos($tBody, 'calendar-multiget') !== false; + + $tWant = []; + if ($tWantsMulti && preg_match_all('#<[^>]*href[^>]*>([^<]+)]*href>#i', $tBody, $mm)) { + foreach ($mm[1] as $h) { + $h = trim(html_entity_decode($h, ENT_QUOTES | ENT_XML1, 'UTF-8')); + $seg = preg_replace('/\.ics$/', '', (string)basename(rtrim($h, '/'))); + if ($seg !== '') { + $tWant[$seg] = true; + } + } + } + + $tItems = []; + foreach (TaskDav::all($familyId, $me) as $t) { + $seg = TaskDav::segmentOf($t); + if ($seg === '' || ($tWantsMulti && !isset($tWant[$seg]))) { + continue; + } + $res = TaskDav::resourceOf($t); + $tItems[] = '' + . CalDav::x(TaskDav::BOOK_HREF . $seg . '.ics') . '' + . '' + . '' . CalDav::x($res['etag']) . '' + . '' . CalDav::x($res['ics']) . '' + . 'HTTP/1.1 200 OK'; + } + + header('Content-Type: application/xml; charset=utf-8'); + http_response_code(207); + echo '' . "\n" + . '' . "\n" + . implode("\n", $tItems) . "\n" + . ''; + exit; + } + /* 🔴 v45b:REPORT 打到 **home 容器**(`/dav/`,bookId=0)时 —— 照参照(DAViCal)回 **403**,不回 404。 参照实测:`REPORT /caldav//` → `403 Forbidden`(不是 404)。 @@ -706,6 +870,49 @@ if ($method === 'REPORT') { // 函数,所以"手机上建的"与"网页里导入的"结果完全一致(UID 去重、重复规则、 // 提醒、全天与否都由它统一处理)。**绝不在 DAV 里另写一套 INSERT**。 if ($method === 'PUT') { + /* 待办本:新建 / 更新一条 VTODO。 + ⚠️ 路径里有段 = 更新那一条;没有段 = 新建(客户端会给新资源自己编个名字, + 所以"没有段"很少见,但按标准要支持)。 */ + if (TaskDav::isTaskPath($path)) { + $tBody = (string)file_get_contents('php://input'); + if (trim($tBody) === '') { + dav_fail(400, '正文是空的。'); + } + $tCtype = (string)($_SERVER['CONTENT_TYPE'] ?? ''); + if ($tCtype !== '' && stripos($tCtype, 'calendar') === false + && stripos($tCtype, 'text/plain') === false) { + dav_fail(415, '只接受 text/calendar 正文。'); + } + + $evSeg2 = (string)preg_replace('/\.ics$/', '', $evSeg); + $tTaskId = TaskDav::idOfSegment($evSeg2); + + /* ⚠️ 「段里有字但解析不出 id」= 客户端用了自己编的 UID(新建场景常见)。 + 这种情况按**新建**处理,而不是报错 —— 那才是它的真实意图。 + 但如果段是 `task-123` 形式却查不到这条,说明已被删,应按新建处理 + (有些客户端会重推已删的资源,回 404 会让它反复重试)。 */ + try { + $tRes2 = TaskDav::put($familyId, $me, $tTaskId, $tBody); + } catch (RuntimeException $e) { + dav_fail(403, $e->getMessage()); + } + + $tSaved = TaskDav::one($familyId, $me, (int)$tRes2['id']); + if (!$tSaved) { + dav_fail(500, '写入后读取失败。'); + } + $tRes3 = TaskDav::resourceOf($tSaved); + $tNewHref = TaskDav::BOOK_HREF . TaskDav::segmentOf($tSaved) . '.ics'; + + // 新建 → 201 Created + Location;更新 → 200/204 + http_response_code($tRes2['created'] ? 201 : 204); + header('ETag: ' . $tRes3['etag']); + if ($tRes2['created']) { + header('Location: ' . $tNewHref); + } + exit; + } + if (!$cal) { dav_fail(404, '没有这本日历。'); } @@ -898,6 +1105,18 @@ if ($method === 'PUT') { // ⚠️ 覆盖行与 EXDATE 都是 `calendar_events` 的子表(FK ON DELETE CASCADE), // 所以这一句 DELETE 会把它们一并带走,不需要额外清理。 if ($method === 'DELETE') { + if (TaskDav::isTaskPath($path)) { + $tTaskId = TaskDav::idOfSegment((string)preg_replace('/\.ics$/', '', $evSeg)); + if ($tTaskId <= 0) { + dav_fail(400, '要指定是哪一条待办(如 /dav/tasks.ics/task-5.ics)。'); + } + if (!TaskDav::delete($familyId, $me, $tTaskId)) { + dav_fail(404, '没有这条待办,或你没有删除权限。'); + } + http_response_code(204); + exit; + } + if (!$cal) { dav_fail(404, '没有这本日历。'); }