Files
FamilyBook/app/TaskDav.php
T
laoyang c86658966c CalDAV 增加待办(VTODO)双向同步
用户原话:「网页端的 caldav 功能,除了能够同步日程外,增加可以同步任务(待办)的功能」

## 三个设计选择(用户已定)

1. 存放位置:作为 /dav/ 内的一个日历本,不另开 /taskdav/ 入口
2. 同步方向:直接双向读写
3. 字段映射:标准映射

## 字段映射

| VTODO | tasks 列 | 说明 |
|---|---|---|
| UID | 派生 task-<id> | 与日历 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 再验
(本次踩过:改了没上传,拿旧代码验出假结果)。
2026-11-02 15:29:36 +08:00

327 lines
13 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* TaskDAV —— 待办(VTODO)走的 CalDAV 扩展层。
*
* 🔴 2026-11-02 新增。用户原话:
* 「网页端的 caldav 功能,除了能够同步日程外,增加可以同步任务(待办)的功能」
*
* 用户已定的三个选择:
* (1) 存放位置:作为 /dav/ 内的一个日历本(不另开 /taskdav/ 入口)
* (2) 同步方向:直接双向读写
* (3) 字段映射:标准映射(SUMMARY/DESCRIPTION/DUE/STATUS/PERCENT-COMPLETE)
*
* ## 为什么是"虚拟日历本"
*
* 任务在 `tasks` 表里是按**看板 + 状态三列**组织的,没有"日历本"这个概念。
* 但客户端只认"一个日历集合 = 一组资源"。所以这里造一个**虚拟本**:
* · href 固定为 `/dav/tasks.ics/`
* · 名字固定「家庭待办」
* · 不对应 calendar_events 里的任何一行,也不出现在 Calendar::mine() 里
*
* ⚠️ **虚拟本 id 必须与真实日历 id 不冲突**。用**负数**:
* 真实 `calendar_events.calendar_id` 是 `INT UNSIGNED`,永远 ≥ 1。
* 负数在 `parsePath()` 出来的 `book` 里一眼可辨,也不会有任何一张表撞上。
*
* ## 权限口径
*
* 与网页端「任务」模块**完全一致**(`Task::visibleTo` / `Task::canWrite`):
* · 能看到的:我建的 / 分给我的 / 共享给我的 / 我是父任务可见者的子任务
* · 能写的:`Task::canWrite` 判(只读共享的不能写)
* ⇒ 不在 DAV 层另写一套权限判断,否则"网页上不能改的,手机能改"会出问题。
*
* ## UID 方案
*
* `task-<id>`。前缀 `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<int,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;
}
}