3 Commits
Author SHA1 Message Date
laoyang f92a98a3a3 子任务跟随父分类,修复父子跨本导致客户端无法显示层级
用户问:「任务是带有子任务功能的,能否通过 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 | 不显示层级,两条并列 |

数据是正确的,显示成什么样取决于客户端能力。
想在所有客户端都看到层级,只能靠前端自己渲染 —— 那是网页端的事。
2026-11-02 16:07:45 +08:00
laoyang 610d524e68 CalDAV 待办改为按分类拆成多本(对齐参照 DAViCal)
用户提供参照服务器 nas.yangyq.net/caldav/synccalendartest(DAViCal 1.1.9)并指出
「应该可以直接显示那个家庭的待办分类」。

## 参照实测结果

那台服务器的待办本结构:

    /caldav/<user>/tfxnz/     家庭计划   VTODO
    /caldav/<user>/zlqlzm/    工作任务   VTODO
    /caldav/<user>/tdotbsnl/  个人成长   VTODO
    /caldav/<user>/home_todo/ Inbox     VTODO

⇒ 每个分类是**独立的 calendar 集合**,各自 supported-calendar-component-set
只含 VTODO,客户端里显示成并列的多个待办列表。

VTODO 正文还带 CREATED / LAST-MOD / DTSTAMP / SEQUENCE / PRIORITY /
PERCENT-COMPLETE / STATUS / DESCRIPTION / LOCATION / TRANSP。

## 上一版的问题

第一版做成「一个「家庭待办」本装全部任务」,所有分类混在一起,
客户端里只有一个列表 —— 用户要的是分类可见。

## 这一版

### 本的组织

    /dav/tasks-<categoryId>.ics/   每个 task_categories 一行 = 一本
    /dav/tasks-inbox.ics/          category_id 为空的任务(Inbox)

⚠️ 用 `tasks-<id>` 而不是裸 id:真实日历本是 `/dav/<id>.ics/`,
裸 id 会与它们撞号(calendar_events 与 task_categories 是两套独立序列)。

### 分类跟随「本」

客户端把资源 PUT 进哪一本,任务就归哪个分类。
这是「按分类拆本」的自然语义,也让「在客户端里移动任务到另一本」等价于改分类。

### 跨本隔离

GET/DELETE 单条时会校验「这条任务确实属于这个本」,
否则改一下 href 就能用 A 本读到 B 本的任务。

## 验证(39 项全过)

真实数据(5 个分类 / 32 条任务):

| 本 | 条数 |
|---|---|
| 家庭事务 | 6 |
| 工作相关 | 19 |
| 个人事务 | 2 |
| 党支部工作 | 1 |
| 轻舟相关 | 1 |
| 收件箱 | 3 |

每本 supported-calendar-component-set 均只含 VTODO,无 VEVENT。
PUT 到 tasks-1.ics 后 Location 返回 /dav/tasks-1.ics/task-40.ics,
GET 读回、更新、完成、删除全部正常。

回归:原有日历本(观影等)PROPFIND/GET 正常,组件类型仍是 VEVENT。
鉴权:无口令 401、不存在的分类本 404、非法段 404。

测试数据已清理,tasks 仍为 32 条。

## 文件

- app/TaskDav.php     重写为多本模型
- app/Ics.php         VTODO 导出/解析(上一版已加,本版未改)
- dav.php             五处拦截改多本

## 已知限制

看板名(正在进行/等待反馈/等待结果)通过 CATEGORIES 输出给客户端,
但客户端改它不会反向改看板 —— 标准 VTODO 没有「看板列」概念。
分类则双向(由「在哪一本」决定)。
2026-11-02 15:41:54 +08:00
laoyang c86658966c CalDAV 增加待办(VTODO)双向同步
用户原话:「网页端的 caldav 功能,除了能够同步日程外,增加可以同步任务(待办)的功能」

## 三个设计选择(用户已定)

1. 存放位置:作为 /dav/ 内的一个日历本,不另开 /taskdav/ 入口
2. 同步方向:直接双向读写
3. 字段映射:标准映射

## 字段映射

| VTODO | tasks 列 | 说明 |
|---|---|---|
| UID | 派生 task-<id> | 与日历 UID 空间隔离 |
| SUMMARY | title | 子任务加「└ 」前缀(纯展示) |
| DESCRIPTION | note | |
| DUE | due_at | 零点视为整天,用 VALUE=DATE |
| STATUS | status | todo→NEEDS-ACTION,doing→IN-PROCESS,done→COMPLETED |
| COMPLETED | done_at | 仅 done 时输出 |
| PERCENT-COMPLETE | status | doing=50,done=100 |
| CATEGORIES | 看板名 | 让客户端能归类 |
| RELATED-TO | parent_id | 标准父子表达 |

## 改动

### app/Ics.php:并列增加 VTODO 能力

不改 parse()/export() 的任何行为(VEVENT 路径注释里记着多轮踩坑史),
新增 exportTodos() / vtodo() / parseTodos() / normalizeTodo(),复用底层工具。

### app/TaskDav.php(新建,约 380 行)

虚拟日历本:href 固定 /dav/tasks.ics/,BOOK_ID = -1
(calendar_events.calendar_id 是 INT UNSIGNED 恒 ≥1,负数永不撞)。

权限口径与网页端完全一致,复用 Task::visibleTo / Task::canWrite /
Task::create / Task::update / Task::destroy,不另写一套。

### dav.php:前置拦截,原有日历逻辑零改动

PROPFIND / REPORT / GET / PUT / DELETE 各分支开头判断是否待办本,
是则走 TaskDav 并 exit。另外在 /dav/ 的本清单里追加待办本
(否则客户端根本不知道有这一本)。

## 过程中修掉的 4 个真 bug

1. **Class "TaskDav" not found** → 缺 use App\TaskDav;
   autoload 要求带上命名空间前缀,漏了会让整个 dav.php 全线 500。

2. **PUT/GET单条/DELETE 全 404** → 原来全用 isTaskHref(),
   它只比「整条路径 == /dav/tasks.ics/」,带子资源时必然 false。
   新增 isTaskPath() 做前缀匹配(且要求前缀后必须是 / 或结束,
   避免 /dav/tasks.icsXXX 被误判)。

3. **PUT 到不存在的资源名返回 403** → 原实现直接抛「任务不存在」。
   按 RFC 4918 §9.7,PUT 到不存在的 URI 就是创建资源。
   改为:只有确实存在且有权访问时才走更新分支,否则新建。

4. **注释里的 markdown 加粗导致 PHP 语法错**
   `作为 **/dav/ 内的一个日历本**` 里 `**/` 形成 `*/`,提前闭合块注释。
   PHP 注释里不要写 markdown 加粗。

## 验证

### Ics 层(34 项全过)
生成 → 解析 → 归一往返,含三种客户端完成态表达
(STATUS:COMPLETED / 只有 COMPLETED 时间戳 / PERCENT-COMPLETE:100)。

### TaskDav 层(45 项全过,真实 32 条任务)
读写、ETag 稳定性、权限、事务回滚。
写操作全在事务里做并回滚,不污染真实数据。

### HTTP 端到端(37 项全过)
OPTIONS → PROPFIND 发现 → REPORT → GET 整本 → PUT 新建
→ GET 读回 → PUT 更新 → DELETE,另含回归与越权检查:
- 原有日历本 PROPFIND/GET 仍正常,组件类型仍是 VEVENT
- 无口令 401、错口令 401、不存在的任务 404

测试按 RFC 跟随 Location(新建时服务端分配 id 并告知真实 href),
这也是真实客户端的行为。

### 数据完整性
测试前后 tasks 均为 32 条,测试数据已清理。
测试用 DAV 口令已 revoke(不影响用户原有口令)。

## 部署

app/Ics.php、app/TaskDav.php、dav.php 三个文件已上传并验证。

⚠️ 服务端文件不在客户端仓库,以后改这些要先比对服务器 md5 再验
(本次踩过:改了没上传,拿旧代码验出假结果)。
2026-11-02 15:29:36 +08:00