子任务跟随父分类,修复父子跨本导致客户端无法显示层级

用户问:「任务是带有子任务功能的,能否通过 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 | 不显示层级,两条并列 |

数据是正确的,显示成什么样取决于客户端能力。
想在所有客户端都看到层级,只能靠前端自己渲染 —— 那是网页端的事。
This commit is contained in:
2026-11-02 16:07:45 +08:00
parent 610d524e68
commit f92a98a3a3
2 changed files with 121 additions and 22 deletions
+87 -14
View File
@@ -105,6 +105,47 @@ final class TaskDav
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`)。
*
@@ -216,36 +257,68 @@ final class TaskDav
*/
public static function tasksIn(int $familyId, int $personId, int $catId): array
{
$sql = 'SELECT t.*, b.name AS board_name, c.name AS cat_name
/* ⚠️ 不能再用 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_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';
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($params);
$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
{