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

用户问:「任务是带有子任务功能的,能否通过 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
+34 -8
View File
@@ -131,15 +131,27 @@ final class Ics
$out[] = 'LAST-MODIFIED:' . self::dateTimeValue((string)$t['updated_at']); $out[] = 'LAST-MODIFIED:' . self::dateTimeValue((string)$t['updated_at']);
} }
// ---- 标题(子任务加视觉前缀,纯展示)---- /* ---- 标题 ----
🔴 2026-11-02 改:原来给子任务硬加「└ 」前缀。那会**污染用户数据** ——
客户端把标题写回来(PUT)时,前缀会被存进数据库,
用户的任务标题就凭空多了一坨。
层级改由 RELATED-TO 表达(标准属性),
另给 X-FAMILYBOOK-PARENT 做展示用的补充,不碰 SUMMARY。 */
$title = (string)($t['title'] ?? ''); $title = (string)($t['title'] ?? '');
if ((int)($t['parent_id'] ?? 0) > 0) {
$title = '└ ' . $title;
}
$out[] = 'SUMMARY:' . self::escapeText($title); $out[] = 'SUMMARY:' . self::escapeText($title);
if (($t['note'] ?? '') !== '') { $parentId = (int)($t['parent_id'] ?? 0);
$out[] = 'DESCRIPTION:' . self::escapeText((string)$t['note']); $note = (string)($t['note'] ?? '');
/* 跨本父子:客户端会看到 RELATED-TO 指向一个本里不存在的 UID,
部分客户端会**直接不显示这条**(当成孤立子项)。
补一句人话说明,让用户知道这是"父在别的分类里",不是数据损坏。 */
if (!empty($t['parent_in_other_book']) && ($t['parent_title'] ?? '') !== '') {
$hint = '(此任务的上级「' . (string)$t['parent_title']
. '」在另一个分类里,网页端可以看到两级的完整关系)';
$note = $note === '' ? $hint : $note . "\n\n" . $hint;
}
if ($note !== '') {
$out[] = 'DESCRIPTION:' . self::escapeText($note);
} }
/* ---- 截止时间 ---- /* ---- 截止时间 ----
@@ -168,10 +180,24 @@ final class Ics
$out[] = 'CATEGORIES:' . self::escapeText($board); $out[] = 'CATEGORIES:' . self::escapeText($board);
} }
// ---- 父子关系(标准表达,但多数客户端不显示;不影响语义)---- /* ---- 父子关系 ----
$parentId = (int)($t['parent_id'] ?? 0); `RELATED-TO` 是 RFC 5545 §3.8.4.5 的标准属性,值是**父资源的 UID**。
本模块的 UID 规范是 `task-<id>`,所以这里拼 `task-<parentId>`
正好对上同一本里那个资源的 UID。
⚠️ 各客户端的支持度:
· DAVx⁵ + Tasks.org:认,显示成缩进的子项
· 苹果提醒事项:认自己的 parent 语义,对 RELATED-TO 支持有限
· Thunderbird:完全不显示层级,两条并列
这是客户端能力差异,服务端无法改变 —— 数据是正确的。 */
if ($parentId > 0) { if ($parentId > 0) {
$out[] = 'RELATED-TO:task-' . $parentId; $out[] = 'RELATED-TO:task-' . $parentId;
/* 展示用的补充属性:值 = 父标题。
支持的客户端能直接显示"上级是谁",不支持的无视。
用 X- 前缀是因为这是本服务特有的,不进标准语义。 */
if (($t['parent_title'] ?? '') !== '') {
$out[] = 'X-FAMILYBOOK-PARENT:' . self::escapeText((string)$t['parent_title']);
}
} }
$out[] = 'END:VTODO'; $out[] = 'END:VTODO';
+86 -13
View File
@@ -105,6 +105,47 @@ final class TaskDav
return $out; 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`)。 * 从一个 href 或路径解析出待办本的段(`<id>` 或 `inbox`)。
* *
@@ -216,36 +257,68 @@ final class TaskDav
*/ */
public static function tasksIn(int $familyId, int $personId, int $catId): array 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 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 LEFT JOIN task_categories c ON c.id = t.category_id
WHERE t.family_id = ?'; LEFT JOIN tasks p ON p.id = t.parent_id
$params = [$familyId]; LEFT JOIN task_categories pc ON pc.id = p.category_id
WHERE t.family_id = ?
if ($catId === -1) { ORDER BY t.status <> \'done\', t.due_at IS NULL, t.due_at, t.position, t.id';
$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 = Database::get()->prepare($sql);
$st->execute($params); $st->execute([$familyId]);
$out = []; $out = [];
foreach ($st->fetchAll() as $r) { foreach ($st->fetchAll() as $r) {
// 先按"有效分类"判它属不属于这一本
if (self::effectiveCat($r) !== $catId) {
continue;
}
if (!Task::visibleTo($r, $personId)) { if (!Task::visibleTo($r, $personId)) {
continue; continue;
} }
$r['uid'] = self::UID_PREFIX . (int)$r['id']; $r['uid'] = self::UID_PREFIX . (int)$r['id'];
/* ⚠️ 跨本父任务的提示:子任务跟自己的分类、而父在别的本时,
客户端会看到一条"有 RELATED-TO 但找不到那个父"的条目。
多给一个说明性字段,导出时写进 DESCRIPTION 尾部,
让用户至少知道"它的父在别的分类里",而不是以为数据坏了。 */
$r['parent_in_other_book'] = self::isParentInOtherBook($r);
$out[] = $r; $out[] = $r;
} }
return $out; 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。 */ /** 单条任务(含权限)。取不到或无权看 → null。 */
public static function one(int $familyId, int $personId, int $taskId): ?array public static function one(int $familyId, int $personId, int $taskId): ?array
{ {