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 {