Files
FamilyBook/app/TaskDav.php
T
laoyang 610d524e68 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 没有「看板列」概念。
分类则双向(由「在哪一本」决定)。
2026-11-02 15:41:54 +08:00

427 lines
16 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 功能,除了能够同步日程外,增加可以同步任务(待办)的功能」
*
* 2026-11-02 重构(第二版):
* 用户给了参照服务器 nas.yangyq.net/caldav/synccalendartest(DAViCal 1.1.9),
* 并指出「应该可以直接显示那个家庭的待办分类」。
* 参照实测:待办按分类拆成多个独立 calendar 集合,各自 supported-calendar-component-set
* 只含 VTODO。客户端里显示成多个并列的待办列表。
* ⇒ 本版从「一个家庭待办本」改为「每个 task_categories 一行 = 一个 VTODO 本」,
* 外加一个 Inbox 本收未分类任务(与参照的 home_todo 同构)。
*
* ## 本的组织(用户已定)
*
* /dav/tasks-<categoryId>.ics/ 每个分类一本,名字就是分类名
* /dav/tasks-inbox.ics/ 未分类(category_id 为空)的任务
*
* ⚠️ 为什么用 `tasks-<id>` 而不是裸 id:
* 真实日历本是 `/dav/<id>.ics/`,用裸 id 会与它们撞号
* (calendar_events 的 id 与 task_categories 的 id 是两套独立序列)。
* 加 `tasks-` 前缀后永远不会冲突,也不需要负数那种 trick。
*
* ## 权限口径
*
* 与网页端「任务」模块完全一致(User 可见 = Task::visibleTo)。
* 不在 DAV 层另写一套权限判断,否则"网页上不能改的,手机能改"会出问题。
*
* ## UID 方案
*
* `task-<id>`。前缀 `task-` 与日历的 UID 空间隔离(日历 UID 可能是
* `syncal-...`、`movie-100_familybook`,或客户端自造的任意串)。
*/
declare(strict_types=1);
namespace App;
final class TaskDav
{
/**
* 本 href 前缀:`/dav/tasks-<id>.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<int,array{seg:string, id:int, name:string, href:string, color:string}>
* 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 或路径解析出待办本的段(`<id>` 或 `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;
}
}