From f92a98a3a385d901ceac52e00a692ab9330954f4 Mon Sep 17 00:00:00 2001 From: laoyang Date: Mon, 2 Nov 2026 16:07:45 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AD=90=E4=BB=BB=E5=8A=A1=E8=B7=9F=E9=9A=8F?= =?UTF-8?q?=E7=88=B6=E5=88=86=E7=B1=BB=EF=BC=8C=E4=BF=AE=E5=A4=8D=E7=88=B6?= =?UTF-8?q?=E5=AD=90=E8=B7=A8=E6=9C=AC=E5=AF=BC=E8=87=B4=E5=AE=A2=E6=88=B7?= =?UTF-8?q?=E7=AB=AF=E6=97=A0=E6=B3=95=E6=98=BE=E7=A4=BA=E5=B1=82=E7=BA=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户问:「任务是带有子任务功能的,能否通过 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 | 不显示层级,两条并列 | 数据是正确的,显示成什么样取决于客户端能力。 想在所有客户端都看到层级,只能靠前端自己渲染 —— 那是网页端的事。 --- app/Ics.php | 42 ++++++++++++++++---- app/TaskDav.php | 101 +++++++++++++++++++++++++++++++++++++++++------- 2 files changed, 121 insertions(+), 22 deletions(-) diff --git a/app/Ics.php b/app/Ics.php index 05c0402..9bbf60b 100644 --- a/app/Ics.php +++ b/app/Ics.php @@ -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-`,所以这里拼 `task-` + 正好对上同一本里那个资源的 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'; diff --git a/app/TaskDav.php b/app/TaskDav.php index 4923320..ae9b0a8 100644 --- a/app/TaskDav.php +++ b/app/TaskDav.php @@ -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 或路径解析出待办本的段(`` 或 `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 {