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

用户问:「任务是带有子任务功能的,能否通过 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']);
}
// ---- 标题(子任务加视觉前缀,纯展示)----
/* ---- 标题 ----
🔴 2026-11-02 改:原来给子任务硬加「└ 」前缀。那会**污染用户数据** ——
客户端把标题写回来(PUT)时,前缀会被存进数据库,
用户的任务标题就凭空多了一坨。
层级改由 RELATED-TO 表达(标准属性),
另给 X-FAMILYBOOK-PARENT 做展示用的补充,不碰 SUMMARY。 */
$title = (string)($t['title'] ?? '');
if ((int)($t['parent_id'] ?? 0) > 0) {
$title = '└ ' . $title;
}
$out[] = 'SUMMARY:' . self::escapeText($title);
if (($t['note'] ?? '') !== '') {
$out[] = 'DESCRIPTION:' . self::escapeText((string)$t['note']);
$parentId = (int)($t['parent_id'] ?? 0);
$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);
}
// ---- 父子关系(标准表达,但多数客户端不显示;不影响语义)----
$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) {
$out[] = 'RELATED-TO:task-' . $parentId;
/* 展示用的补充属性:值 = 父标题。
支持的客户端能直接显示"上级是谁",不支持的无视。
用 X- 前缀是因为这是本服务特有的,不进标准语义。 */
if (($t['parent_title'] ?? '') !== '') {
$out[] = 'X-FAMILYBOOK-PARENT:' . self::escapeText((string)$t['parent_title']);
}
}
$out[] = 'END:VTODO';