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 再验
(本次踩过:改了没上传,拿旧代码验出假结果)。
This commit is contained in:
+281
@@ -57,6 +57,161 @@ final class Ics
|
||||
'RECURRENCE-ID',
|
||||
];
|
||||
|
||||
/**
|
||||
* 把一个待办集合导出成 .ics 文本(VTODO 版本)。
|
||||
*
|
||||
* 🔴 2026-11-02 新增:用户「网页端 caldav 除了同步日程,增加同步任务(待办)」。
|
||||
*
|
||||
* ⚠️ 为什么不复用 `export()`:`export()` 里每条都走 `vevent()`,
|
||||
* 而 VEVENT 与 VTODO 是**两种组件**(RFC 5545 §3.6.1 / §3.6.2):
|
||||
* VEVENT 必须有 DTSTART、VTODO 不能有 DTEND。混用会让客户端解析失败。
|
||||
* ⇒ 单独一个入口,但**共用** fold/escape 等底层工具,风格保持一致。
|
||||
*
|
||||
* @param array $tasks tasks 表的行(已补好 uid / board_name)
|
||||
* @param string $calName X-WR-CALNAME 用的名字
|
||||
*/
|
||||
public static function exportTodos(array $tasks, string $calName = '待办'): string
|
||||
{
|
||||
$out = [
|
||||
'BEGIN:VCALENDAR',
|
||||
'VERSION:2.0',
|
||||
'PRODID:' . self::PRODID,
|
||||
'CALSCALE:GREGORIAN',
|
||||
'METHOD:PUBLISH',
|
||||
'X-WR-CALNAME:' . self::escapeText($calName),
|
||||
/* VTODO 是 CalDAV 的扩展能力。这两个 non-standard 头是
|
||||
iOS / 苹果提醒事项认「这是个待办本」的依据之一;
|
||||
不认识它们的客户端会忽略,不影响 VEVENT 本。 */
|
||||
'X-WR-RELCALID:familybook-tasks',
|
||||
'X-PUBLISHED-TTL:PT1H',
|
||||
];
|
||||
foreach ($tasks as $t) {
|
||||
$out = array_merge($out, self::vtodo($t));
|
||||
}
|
||||
$out[] = 'END:VCALENDAR';
|
||||
|
||||
$lines = [];
|
||||
foreach ($out as $line) {
|
||||
foreach (self::fold($line) as $l) {
|
||||
$lines[] = $l;
|
||||
}
|
||||
}
|
||||
return implode("\r\n", $lines) . "\r\n";
|
||||
}
|
||||
|
||||
/**
|
||||
* 一条任务 → VTODO 的若干行。
|
||||
*
|
||||
* ⚠️ VTODO **不能有 DTSTART 之外的时间边界**:`DTEND` 是 VEVENT 的属性,
|
||||
* 用在 VTODO 上会被严格客户端判为非法而整条丢弃。
|
||||
* 截止时间只走 `DUE`。
|
||||
*/
|
||||
private static function vtodo(array $t): array
|
||||
{
|
||||
$uid = (string)($t['uid'] ?? '');
|
||||
if ($uid === '') {
|
||||
$uid = 'task-' . (int)($t['id'] ?? 0);
|
||||
}
|
||||
$stamp = self::stampOf($t['updated_at'] ?? $t['created_at'] ?? null);
|
||||
$status = (string)($t['status'] ?? 'todo');
|
||||
|
||||
$out = ['BEGIN:VTODO', 'UID:' . $uid, 'DTSTAMP:' . $stamp];
|
||||
|
||||
/* ---- 完成时间:必须在 STATUS:COMPLETED 之前给,
|
||||
苹果提醒事项要求 COMPLETED 与 STATUS 同时出现才认"已完成" ---- */
|
||||
$doneAt = (string)($t['done_at'] ?? '');
|
||||
if ($status === self::DONE_STATUS && $doneAt !== '') {
|
||||
$out[] = 'COMPLETED:' . self::dateTimeValue($doneAt);
|
||||
}
|
||||
|
||||
if (($t['created_at'] ?? '') !== '') {
|
||||
$out[] = 'CREATED:' . self::dateTimeValue((string)$t['created_at']);
|
||||
}
|
||||
if (($t['updated_at'] ?? '') !== '') {
|
||||
$out[] = 'LAST-MODIFIED:' . self::dateTimeValue((string)$t['updated_at']);
|
||||
}
|
||||
|
||||
// ---- 标题(子任务加视觉前缀,纯展示)----
|
||||
$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']);
|
||||
}
|
||||
|
||||
/* ---- 截止时间 ----
|
||||
库里 `due_at` 是 DATETIME。判断"是否整天"的方式:时分秒全 00:00:00
|
||||
就按 DATE 输出(用户在网页上选"某天截止"通常就是这个形态),
|
||||
否则按 DATE-TIME 输出。这样客户端显示的粒度与网页端一致。 */
|
||||
$due = (string)($t['due_at'] ?? '');
|
||||
if ($due !== '') {
|
||||
if (self::isMidnight($due)) {
|
||||
$out[] = 'DUE;VALUE=DATE:' . self::dateValue($due);
|
||||
} else {
|
||||
$out[] = 'DUE:' . self::dateTimeValue($due);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 状态 ----
|
||||
$out[] = 'STATUS:' . self::statusOf($status);
|
||||
$out[] = 'PERCENT-COMPLETE:' . self::percentOf($status);
|
||||
|
||||
/* ---- 看板名进 CATEGORIES ----
|
||||
客户端大多支持按 CATEGORIES 分组/打标签,把看板名放这里
|
||||
是"让客户端也能看出这条属于哪个看板"的最低成本方式。
|
||||
⚠️ 派生只读,不参与反向写回(PUT 时不从 CATEGORIES 改看板)。 */
|
||||
$board = (string)($t['board_name'] ?? '');
|
||||
if ($board !== '') {
|
||||
$out[] = 'CATEGORIES:' . self::escapeText($board);
|
||||
}
|
||||
|
||||
// ---- 父子关系(标准表达,但多数客户端不显示;不影响语义)----
|
||||
$parentId = (int)($t['parent_id'] ?? 0);
|
||||
if ($parentId > 0) {
|
||||
$out[] = 'RELATED-TO:task-' . $parentId;
|
||||
}
|
||||
|
||||
$out[] = 'END:VTODO';
|
||||
return $out;
|
||||
}
|
||||
|
||||
/** 任务的三种状态 → VTODO 的 STATUS 值 */
|
||||
private static function statusOf(string $st): string
|
||||
{
|
||||
if ($st === self::DONE_STATUS) {
|
||||
return 'COMPLETED';
|
||||
}
|
||||
if ($st === 'doing') {
|
||||
return 'IN-PROCESS';
|
||||
}
|
||||
return 'NEEDS-ACTION';
|
||||
}
|
||||
|
||||
/** STATUS 的通用信号:不认 IN-PROCESS 的客户端也能看出进度 */
|
||||
private static function percentOf(string $st): int
|
||||
{
|
||||
if ($st === self::DONE_STATUS) {
|
||||
return 100;
|
||||
}
|
||||
return $st === 'doing' ? 50 : 0;
|
||||
}
|
||||
|
||||
/** 这个 DATETIME 是不是"零点"(= 用户表达的是"某天"而不是"某时刻") */
|
||||
private static function isMidnight(string $dt): bool
|
||||
{
|
||||
$t = strtotime($dt);
|
||||
if ($t === false) {
|
||||
return false;
|
||||
}
|
||||
return date('H:i:s', $t) === '00:00:00';
|
||||
}
|
||||
|
||||
/** 任务完成状态常量(与 App\\Task::DONE_STATUS 一致,这里独立一份避免循环依赖) */
|
||||
private const DONE_STATUS = 'done';
|
||||
|
||||
/**
|
||||
* 把一个日历本导出成 .ics 文本。
|
||||
*
|
||||
@@ -362,6 +517,132 @@ final class Ics
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 VTODO(客户端 PUT 上来的待办)。
|
||||
*
|
||||
* 🔴 2026-11-02 新增。**独立于** `parse()`:
|
||||
* `parse()` 只收 VEVENT,改它会波及日历写路径(那里注释记了多轮踩坑史)。
|
||||
*
|
||||
* @return array<int,array> 每条的字段:
|
||||
* uid / summary / description / due / status / percent / completed /
|
||||
* priority / categories / parent_uid / extra
|
||||
*
|
||||
* ⚠️ 只取**第一个** VTODO:一个 CalDAV 资源对应一个待办,
|
||||
* 正文里出现多个说明客户端行为异常,宁可少存不可乱存
|
||||
* (与 PUT 里 VEVENT 的处理口径一致)。
|
||||
*/
|
||||
public static function parseTodos(string $text): array
|
||||
{
|
||||
$text = str_replace(["\r\n", "\r"], "\n", $text);
|
||||
$text = preg_replace("/\n[ \t]/", '', $text) ?? $text;
|
||||
$lines = explode("\n", $text);
|
||||
|
||||
$out = [];
|
||||
$cur = null;
|
||||
foreach ($lines as $raw) {
|
||||
$line = trim($raw);
|
||||
if ($line === '') {
|
||||
continue;
|
||||
}
|
||||
if ($line === 'BEGIN:VTODO') {
|
||||
$cur = [];
|
||||
continue;
|
||||
}
|
||||
if ($line === 'END:VTODO') {
|
||||
if ($cur !== null) {
|
||||
$out[] = $cur;
|
||||
}
|
||||
$cur = null;
|
||||
continue;
|
||||
}
|
||||
if ($cur === null) {
|
||||
continue; // VCALENDAR / VALARM / VEVENT 里的东西跳过
|
||||
}
|
||||
[$name, $params, $value] = self::splitProp($line);
|
||||
if ($name === '') {
|
||||
continue;
|
||||
}
|
||||
// 同名只留第一个(VTODO 里这些属性都是单值的)
|
||||
if (!array_key_exists($name, $cur)) {
|
||||
$cur[$name] = $value;
|
||||
$cur[$name . '_PARAMS'] = $params;
|
||||
}
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 把解析出来的 VTODO 属性归一成能落库的形状。
|
||||
*
|
||||
* 与 `normalizeVevent()` 并列,互不影响。
|
||||
*
|
||||
* @return array{uid:string, summary:string, description:string,
|
||||
* due:?string, status:string, completed:?string,
|
||||
* percent:?int, priority:?int, categories:string,
|
||||
* parent_uid:string}
|
||||
*/
|
||||
public static function normalizeTodo(array $p): array
|
||||
{
|
||||
$summary = self::unescapeText((string)($p['SUMMARY'] ?? ''));
|
||||
$desc = self::unescapeText((string)($p['DESCRIPTION'] ?? ''));
|
||||
|
||||
// ---- DUE:可能是 DATE 也可能 DATE-TIME ----
|
||||
$due = null;
|
||||
$dueRaw = (string)($p['DUE'] ?? '');
|
||||
if ($dueRaw !== '') {
|
||||
$dueParams = (string)($p['DUE_PARAMS'] ?? '');
|
||||
if (stripos($dueParams, 'VALUE=DATE') !== false && stripos($dueParams, 'DATE-TIME') === false) {
|
||||
// 整天:`fromDateValue()` 返回的已经是 `Y-m-d 00:00:00` 完整形态,
|
||||
// ⚠️ 不要再拼 ` 00:00:00` —— 会变成 `... 00:00:00 00:00:00`(踩过)
|
||||
$due = self::fromDateValue($dueRaw);
|
||||
} else {
|
||||
$due = self::fromDateTimeValue($dueRaw, $dueParams);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- STATUS / COMPLETED ----
|
||||
/* 完成态的判定优先级:
|
||||
① `STATUS:COMPLETED` —— 最明确
|
||||
② 有 `COMPLETED` 时间戳 —— 苹果提醒事项勾选时只加这个
|
||||
③ `PERCENT-COMPLETE:100` —— 少数客户端只用这个
|
||||
三者任一成立就算已完成,否则再看 IN-PROCESS。 */
|
||||
$stRaw = strtoupper(trim((string)($p['STATUS'] ?? '')));
|
||||
$compRaw = (string)($p['COMPLETED'] ?? '');
|
||||
$pctRaw = (string)($p['PERCENT-COMPLETE'] ?? '');
|
||||
$completed = $compRaw !== ''
|
||||
? self::fromDateTimeValue($compRaw, (string)($p['COMPLETED_PARAMS'] ?? ''))
|
||||
: null;
|
||||
|
||||
$isDone = $stRaw === 'COMPLETED'
|
||||
|| $completed !== null
|
||||
|| (trim($pctRaw) !== '' && (int)$pctRaw >= 100);
|
||||
|
||||
if ($isDone) {
|
||||
$status = self::DONE_STATUS;
|
||||
} elseif ($stRaw === 'IN-PROCESS' || $stRaw === 'IN_PROGRESS') {
|
||||
$status = 'doing';
|
||||
} elseif (trim($pctRaw) !== '' && (int)$pctRaw > 0 && (int)$pctRaw < 100) {
|
||||
$status = 'doing';
|
||||
} else {
|
||||
$status = 'todo';
|
||||
}
|
||||
|
||||
$prio = trim((string)($p['PRIORITY'] ?? ''));
|
||||
|
||||
return [
|
||||
'uid' => trim((string)($p['UID'] ?? '')),
|
||||
'summary' => $summary,
|
||||
'description' => $desc,
|
||||
'due' => $due,
|
||||
'status' => $status,
|
||||
'completed' => $completed,
|
||||
'percent' => $pctRaw === '' ? null : (int)$pctRaw,
|
||||
'priority' => $prio === '' ? null : (int)$prio,
|
||||
'categories' => self::unescapeText((string)($p['CATEGORIES'] ?? '')),
|
||||
'parent_uid' => trim((string)($p['RELATED-TO'] ?? '')),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 把解析出来的属性归一成我们要落库的形状。
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user