Files
laoyang f92a98a3a3 子任务跟随父分类,修复父子跨本导致客户端无法显示层级
用户问:「任务是带有子任务功能的,能否通过 caldav,把任务的子任务也同步给客户端?」

## 答案:能,但实测发现父子被分到了不同本

改前数据:

    父 #12「轻舟的幼儿园发票」  category_id = 1(家庭事务)→ tasks-1.ics
    子 #29「送财务报销」        category_id = NULL(收件箱) → tasks-inbox.ics

客户端渲染「家庭事务」时看不到子任务,渲染「收件箱」时又找不到父任务,
⇒ 不可能显示成层级,只能两条并列。

## 改法

### 1. 子任务跟随父分类(TaskDav::effectiveCat)

    有效分类 = 自己的 category_id
              ↓ 为空且是子任务时
              父任务的 category_id

⚠️ **不改数据库**,只在视图层算。
   改库等于篡改用户数据,而且单向不可逆。

边界:
| 情况 | 有效分类 |
|---|---|
| 自己无分类 + 父有分类 | 跟父(父子同本) |
| 自己无分类 + 父也无分类 | 都进收件箱(父子同本) |
| 自己有分类 | 用自己的(尊重用户明确设定) |

`tasksIn()` 改为 SQL 联查 `p.category_id`(一次到位,避免 N+1),
再在内存里按有效分类过滤。

### 2. SUMMARY 去掉硬加的「└ 」前缀

原来给子任务 SUMMARY 加「└ 」。这会**污染用户数据** ——
客户端把标题写回来(PUT)时前缀被存进库,用户的标题就多了一坨。

层级改由标准属性表达:
- `RELATED-TO:task-12` —— RFC 5545 §3.8.4.5 标准父子
- `X-FAMILYBOOK-PARENT:轻舟的幼儿园发票` —— 展示用补充,不支持的客户端无视

### 3. 跨本时的降级提示

用户明确给子任务设了别的分类时(尊重其选择),父子仍会跨本。
那种情况在 DESCRIPTION 尾部补一句人话说明,避免客户端把
「有 RELATED-TO 但找不到父」当成数据损坏而不显示这条。

## 验证

改后分布:

| 本 | 总数 | 父 | 子 |
|---|---|---|---|
| 家庭事务 | 7 | 6 | 1(#29) |
| 工作相关 | 19 | 19 | 0 |
| 个人事务 | 2 | 2 | 0 |
| 党支部工作 | 1 | 1 | 0 |
| 轻舟相关 | 1 | 1 | 0 |
| 收件箱 | 2 | 2 | 0 |

- 总数仍 32 条,没丢没重
- **所有父子都在同一本里**
- HTTP 端到端 39 项全过(含跨本隔离、鉴权、日历本回归)

## 客户端支持度说明(服务端无法改变)

| 客户端 | 层级显示 |
|---|---|
| DAVx⁵ + Tasks.org | 认 RELATED-TO,显示缩进子项 |
| 苹果提醒事项 | 有原生子任务 UI,对 RELATED-TO 支持有限 |
| Thunderbird | 不显示层级,两条并列 |

数据是正确的,显示成什么样取决于客户端能力。
想在所有客户端都看到层级,只能靠前端自己渲染 —— 那是网页端的事。
2026-11-02 16:07:45 +08:00

500 lines
19 KiB
PHP
Raw Permalink 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;
}
/**
* 一条任务的**有效分类**(决定它出现在哪个本里)。
*
* 2026-11-02 新增。用户问「任务的子任务能否同步给客户端」,
* 实测发现父子跨本(父分类=1,子分类=NULL)⇒ 客户端无法显示层级。
* 用户已定:**子任务跟随父的分类**。
*
* 规则:
* · 自己有分类 → 用自己的(尊重用户明确设定)
* · 自己无分类 + 有父 → 跟父的分类(保证父子同本,层级才可显示)
* · 都没有 → 收件箱(-1)
*
* ⚠️ **不改数据库**:这是视图层算出来的。改库就等于篡改用户数据,
* 而且单向不可逆(以后想让子任务独立分类就回不去了)。
*
* @param array $row tasks 行(可用 `parent_cat_id` 预联查好的父分类)
*/
public static function effectiveCat(array $row): int
{
$own = (int)($row['category_id'] ?? 0);
if ($own > 0) {
return $own;
}
$pid = (int)($row['parent_id'] ?? 0);
if ($pid > 0) {
// 优先用预联查的父分类(避免 N+1)
if (array_key_exists('parent_cat_id', $row)) {
$pc = (int)($row['parent_cat_id'] ?? 0);
return $pc > 0 ? $pc : -1;
}
$st = Database::get()->prepare(
'SELECT category_id FROM tasks WHERE id = ? LIMIT 1'
);
$st->execute([$pid]);
$v = $st->fetchColumn();
$pc = $v === false || $v === null ? 0 : (int)$v;
return $pc > 0 ? $pc : -1;
}
return -1;
}
/**
* 从一个 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 直接按 `t.category_id` 过滤:
子任务要**跟随父的分类**(见 effectiveCat),
父分类在另一张行上,必须联查出来再在内存里判。
`parent_cat_id` 一次联查解决,避免逐条查父任务(N+1)。 */
$sql = 'SELECT t.*, b.name AS board_name, c.name AS cat_name,
p.category_id AS parent_cat_id,
pc.name AS parent_cat_name,
p.title AS parent_title
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
LEFT JOIN tasks p ON p.id = t.parent_id
LEFT JOIN task_categories pc ON pc.id = p.category_id
WHERE t.family_id = ?
ORDER BY t.status <> \'done\', t.due_at IS NULL, t.due_at, t.position, t.id';
$st = Database::get()->prepare($sql);
$st->execute([$familyId]);
$out = [];
foreach ($st->fetchAll() as $r) {
// 先按"有效分类"判它属不属于这一本
if (self::effectiveCat($r) !== $catId) {
continue;
}
if (!Task::visibleTo($r, $personId)) {
continue;
}
$r['uid'] = self::UID_PREFIX . (int)$r['id'];
/* ⚠️ 跨本父任务的提示:子任务跟自己的分类、而父在别的本时,
客户端会看到一条"有 RELATED-TO 但找不到那个父"的条目。
多给一个说明性字段,导出时写进 DESCRIPTION 尾部,
让用户至少知道"它的父在别的分类里",而不是以为数据坏了。 */
$r['parent_in_other_book'] = self::isParentInOtherBook($r);
$out[] = $r;
}
return $out;
}
/**
* 这条任务的父是否在**别的本**里(跨本父子)。
*
* 用于导出时加一句提示。只在"子任务自己有分类、父分类不同"时成立 ——
* 那种情况是用户明确给子任务设了别的分类,我们尊重它,但要让客户端可见。
*/
private static function isParentInOtherBook(array $r): bool
{
$pid = (int)($r['parent_id'] ?? 0);
if ($pid <= 0) {
return false;
}
$own = (int)($r['category_id'] ?? 0);
if ($own <= 0) {
return false; // 自己没分类 → 已跟随父,必定同本
}
$pc = array_key_exists('parent_cat_id', $r)
? (int)($r['parent_cat_id'] ?? 0)
: 0;
$pcNorm = $pc > 0 ? $pc : -1;
return $pcNorm !== $own;
}
/** 单条任务(含权限)。取不到或无权看 → 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;
}
}