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:
@@ -58,6 +58,7 @@ use App\CalDav;
|
||||
use App\Calendar;
|
||||
use App\Database;
|
||||
use App\Ics;
|
||||
use App\TaskDav;
|
||||
|
||||
/** 统一的失败出口(CalDAV 客户端只认状态码,正文给点人话便于排查) */
|
||||
function dav_fail(int $code, string $msg): void
|
||||
@@ -317,6 +318,80 @@ $emit = static function (string $xmlns, array $parts): void {
|
||||
|
||||
// ---- 5. PROPFIND:发现链 + 清单 / 属性 / 资源列表 ----
|
||||
if ($method === 'PROPFIND') {
|
||||
/* ============================================================
|
||||
* 待办本(VTODO)—— 必须在原有日历逻辑之前拦下
|
||||
* ============================================================
|
||||
* /dav/tasks.ics/ 是本模块的虚拟本,不对应 calendar_events 的任何一行。
|
||||
* 在这里前置拦截,原有日历分支一个字都不用改(零回归风险)。
|
||||
*/
|
||||
if (TaskDav::isTaskHref($path)) {
|
||||
$tDepth = (string)($_SERVER['HTTP_DEPTH'] ?? '0');
|
||||
$taskEmit = static function (string $xmlns, array $items): void {
|
||||
header('Content-Type: application/xml; charset=utf-8');
|
||||
http_response_code(207);
|
||||
echo '<?xml version="1.0" encoding="utf-8"?>' . "\n"
|
||||
. '<d:multistatus ' . $xmlns . '>' . "\n"
|
||||
. implode("\n", $items) . "\n"
|
||||
. '</d:multistatus>';
|
||||
exit;
|
||||
};
|
||||
|
||||
if ($tDepth !== '1') {
|
||||
/* Depth:0 —— 这一本自身的属性。
|
||||
⚠️ supported-calendar-component-set 必须是 VTODO:
|
||||
给 VEVENT 的话客户端会按日程解析,VTODO 会被过滤掉不显示。 */
|
||||
$ctag = TaskDav::ctag($familyId, $me);
|
||||
$taskEmit(
|
||||
'xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav" '
|
||||
. 'xmlns:cs="http://calendarserver.org/ns/" '
|
||||
. 'xmlns:x="http://apple.com/ns/ical/"',
|
||||
[
|
||||
'<d:response><d:href>' . CalDav::x(TaskDav::BOOK_HREF) . '</d:href>'
|
||||
. '<d:propstat><d:prop>'
|
||||
. '<d:resourcetype><d:collection/><c:calendar/></d:resourcetype>'
|
||||
. '<d:displayname>' . CalDav::x(TaskDav::BOOK_NAME) . '</d:displayname>'
|
||||
. '<d:getcontenttype>httpd/unix-directory</d:getcontenttype>'
|
||||
. '<c:calendar-description>' . CalDav::x('岁月家 · 待办(任务看板同步)') . '</c:calendar-description>'
|
||||
. '<x:calendar-color xmlns:x="http://apple.com/ns/ical/">#7C6A5B</x:calendar-color>'
|
||||
. '<c:supported-calendar-component-set><c:comp name="VTODO"/></c:supported-calendar-component-set>'
|
||||
. '<cs:getctag>' . CalDav::x($ctag) . '</cs:getctag>'
|
||||
. '<d:getctag>' . CalDav::x($ctag) . '</d:getctag>'
|
||||
. '<d:current-user-principal><d:href>' . CalDav::x(CalDav::PRINCIPAL_PATH) . '</d:href></d:current-user-principal>'
|
||||
. '<d:owner><d:href>' . CalDav::x(CalDav::PRINCIPAL_PATH) . '</d:href></d:owner>'
|
||||
. '<d:current-user-privilege-set><d:privilege><d:all/></d:privilege></d:current-user-privilege-set>'
|
||||
. '</d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response>',
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/* Depth:1 —— 本 + 里面的资源清单。
|
||||
⚠️ href 必须与 GET/REPORT 用同一个 segmentOf 算,
|
||||
三处不一致的话客户端会反复重拉(日历那边踩过)。 */
|
||||
$items = [
|
||||
'<d:response><d:href>' . CalDav::x(TaskDav::BOOK_HREF) . '</d:href>'
|
||||
. '<d:propstat><d:prop>'
|
||||
. '<d:resourcetype><d:collection/><c:calendar/></d:resourcetype>'
|
||||
. '<d:displayname>' . CalDav::x(TaskDav::BOOK_NAME) . '</d:displayname>'
|
||||
. '<c:supported-calendar-component-set><c:comp name="VTODO"/></c:supported-calendar-component-set>'
|
||||
. '</d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response>',
|
||||
];
|
||||
foreach (TaskDav::all($familyId, $me) as $t) {
|
||||
$seg = TaskDav::segmentOf($t);
|
||||
if ($seg === '') {
|
||||
continue;
|
||||
}
|
||||
$etag = TaskDav::resourceOf($t)['etag'];
|
||||
$items[] = '<d:response><d:href>'
|
||||
. CalDav::x(TaskDav::BOOK_HREF . $seg . '.ics') . '</d:href>'
|
||||
. '<d:propstat><d:prop>'
|
||||
. '<d:getetag>' . CalDav::x($etag) . '</d:getetag>'
|
||||
. '<d:resourcetype/>'
|
||||
. '<d:getcontenttype>text/calendar; charset=utf-8</d:getcontenttype>'
|
||||
. '</d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response>';
|
||||
}
|
||||
$taskEmit('xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav"', $items);
|
||||
}
|
||||
|
||||
$depth = (string)($_SERVER['HTTP_DEPTH'] ?? '0');
|
||||
|
||||
// 5a. `PROPFIND /`(站点根)→ 只回 current-user-principal。
|
||||
@@ -464,6 +539,23 @@ if ($method === 'PROPFIND') {
|
||||
. '</d:href></c:calendar-home-set>'
|
||||
. '</d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response>');
|
||||
}
|
||||
/* 待办本:作为「/dav/ 下的一个日历本」挂进来(用户选定的方案)。
|
||||
没有这条,客户端 PROPFIND /dav/ 时根本不知道有这一本,也就不会去拉。 */
|
||||
$tCtag = TaskDav::ctag($familyId, $me);
|
||||
$parts[] = '<d:response><d:href>' . CalDav::x(TaskDav::BOOK_HREF) . '</d:href>'
|
||||
. '<d:propstat><d:prop>'
|
||||
. '<d:resourcetype><d:collection/><c:calendar/></d:resourcetype>'
|
||||
. '<d:displayname>' . CalDav::x(TaskDav::BOOK_NAME) . '</d:displayname>'
|
||||
. '<d:getcontenttype>httpd/unix-directory</d:getcontenttype>'
|
||||
. '<c:calendar-description>' . CalDav::x('岁月家 · 待办(任务看板同步)') . '</c:calendar-description>'
|
||||
. '<x:calendar-color xmlns:x="http://apple.com/ns/ical/">#7C6A5B</x:calendar-color>'
|
||||
. '<c:supported-calendar-component-set><c:comp name="VTODO"/></c:supported-calendar-component-set>'
|
||||
. '<cs:getctag>' . CalDav::x($tCtag) . '</cs:getctag>'
|
||||
. '<d:getctag>' . CalDav::x($tCtag) . '</d:getctag>'
|
||||
. '<d:current-user-principal><d:href>' . CalDav::x(CalDav::PRINCIPAL_PATH) . '</d:href></d:current-user-principal>'
|
||||
. '<d:owner><d:href>' . CalDav::x(CalDav::PRINCIPAL_PATH) . '</d:href></d:owner>'
|
||||
. '</d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response>';
|
||||
|
||||
$emit('xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav" '
|
||||
. 'xmlns:x="http://apple.com/ns/ical/" '
|
||||
. 'xmlns:cs="http://calendarserver.org/ns/"', $parts);
|
||||
@@ -522,6 +614,36 @@ if ($method === 'PROPFIND') {
|
||||
|
||||
// ---- 6. GET / HEAD:整本导出成 ics,或单条资源 ----
|
||||
if ($method === 'GET' || $method === 'HEAD') {
|
||||
/* 待办本:整本导出,或单条 VTODO。
|
||||
⚠️ 单条时必须校验资源名属于本用户可见的任务,
|
||||
否则改一下 URL 就能读到别人的任务(越权)。 */
|
||||
if (TaskDav::isTaskPath($path)) {
|
||||
$tParsed = TaskDav::idOfSegment($evSeg);
|
||||
if ($tParsed > 0) {
|
||||
$tOne = TaskDav::one($familyId, $me, $tParsed);
|
||||
if (!$tOne) {
|
||||
dav_fail(404, '没有这条待办。');
|
||||
}
|
||||
$tRes = TaskDav::resourceOf($tOne);
|
||||
header('Content-Type: text/calendar; charset=utf-8');
|
||||
header('ETag: ' . $tRes['etag']);
|
||||
if ($method === 'HEAD') {
|
||||
exit;
|
||||
}
|
||||
echo $tRes['ics'];
|
||||
exit;
|
||||
}
|
||||
// 整本
|
||||
$tIcs = TaskDav::ics($familyId, $me);
|
||||
header('Content-Type: text/calendar; charset=utf-8');
|
||||
header('ETag: ' . TaskDav::ctag($familyId, $me));
|
||||
if ($method === 'HEAD') {
|
||||
exit;
|
||||
}
|
||||
echo $tIcs;
|
||||
exit;
|
||||
}
|
||||
|
||||
/* 🔴🔴 v45b(2026-10-12 用户「TB 还是找不到日历」)——**真 bug 修在这里**。
|
||||
原来这一段是:
|
||||
if (!$cal) { dav_fail(404, '没有这本日历。'); }
|
||||
@@ -596,6 +718,48 @@ if ($method === 'GET' || $method === 'HEAD') {
|
||||
|
||||
// ---- 7. REPORT:calendar-multiget(按 href 取) / calendar-query(整本或按时间范围)----
|
||||
if ($method === 'REPORT') {
|
||||
/* 待办本:calendar-query / calendar-multiget 都返回 VTODO。
|
||||
⚠️ 支持 multiget 按 href 过滤 —— 整本回虽然"客户端也能用",
|
||||
但一本 30 条时无感、将来几百条时就是纯浪费(日历那边为此改过)。 */
|
||||
if (TaskDav::isTaskPath($path)) {
|
||||
$tBody = (string)file_get_contents('php://input');
|
||||
$tWantsMulti = stripos($tBody, 'calendar-multiget') !== false;
|
||||
|
||||
$tWant = [];
|
||||
if ($tWantsMulti && preg_match_all('#<[^>]*href[^>]*>([^<]+)</[^>]*href>#i', $tBody, $mm)) {
|
||||
foreach ($mm[1] as $h) {
|
||||
$h = trim(html_entity_decode($h, ENT_QUOTES | ENT_XML1, 'UTF-8'));
|
||||
$seg = preg_replace('/\.ics$/', '', (string)basename(rtrim($h, '/')));
|
||||
if ($seg !== '') {
|
||||
$tWant[$seg] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$tItems = [];
|
||||
foreach (TaskDav::all($familyId, $me) as $t) {
|
||||
$seg = TaskDav::segmentOf($t);
|
||||
if ($seg === '' || ($tWantsMulti && !isset($tWant[$seg]))) {
|
||||
continue;
|
||||
}
|
||||
$res = TaskDav::resourceOf($t);
|
||||
$tItems[] = '<d:response><d:href>'
|
||||
. CalDav::x(TaskDav::BOOK_HREF . $seg . '.ics') . '</d:href>'
|
||||
. '<d:propstat><d:prop>'
|
||||
. '<d:getetag>' . CalDav::x($res['etag']) . '</d:getetag>'
|
||||
. '<c:calendar-data>' . CalDav::x($res['ics']) . '</c:calendar-data>'
|
||||
. '</d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response>';
|
||||
}
|
||||
|
||||
header('Content-Type: application/xml; charset=utf-8');
|
||||
http_response_code(207);
|
||||
echo '<?xml version="1.0" encoding="utf-8"?>' . "\n"
|
||||
. '<d:multistatus xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">' . "\n"
|
||||
. implode("\n", $tItems) . "\n"
|
||||
. '</d:multistatus>';
|
||||
exit;
|
||||
}
|
||||
|
||||
/* 🔴 v45b:REPORT 打到 **home 容器**(`/dav/`,bookId=0)时 ——
|
||||
照参照(DAViCal)回 **403**,不回 404。
|
||||
参照实测:`REPORT /caldav/<user>/` → `403 Forbidden`(不是 404)。
|
||||
@@ -706,6 +870,49 @@ if ($method === 'REPORT') {
|
||||
// 函数,所以"手机上建的"与"网页里导入的"结果完全一致(UID 去重、重复规则、
|
||||
// 提醒、全天与否都由它统一处理)。**绝不在 DAV 里另写一套 INSERT**。
|
||||
if ($method === 'PUT') {
|
||||
/* 待办本:新建 / 更新一条 VTODO。
|
||||
⚠️ 路径里有段 = 更新那一条;没有段 = 新建(客户端会给新资源自己编个名字,
|
||||
所以"没有段"很少见,但按标准要支持)。 */
|
||||
if (TaskDav::isTaskPath($path)) {
|
||||
$tBody = (string)file_get_contents('php://input');
|
||||
if (trim($tBody) === '') {
|
||||
dav_fail(400, '正文是空的。');
|
||||
}
|
||||
$tCtype = (string)($_SERVER['CONTENT_TYPE'] ?? '');
|
||||
if ($tCtype !== '' && stripos($tCtype, 'calendar') === false
|
||||
&& stripos($tCtype, 'text/plain') === false) {
|
||||
dav_fail(415, '只接受 text/calendar 正文。');
|
||||
}
|
||||
|
||||
$evSeg2 = (string)preg_replace('/\.ics$/', '', $evSeg);
|
||||
$tTaskId = TaskDav::idOfSegment($evSeg2);
|
||||
|
||||
/* ⚠️ 「段里有字但解析不出 id」= 客户端用了自己编的 UID(新建场景常见)。
|
||||
这种情况按**新建**处理,而不是报错 —— 那才是它的真实意图。
|
||||
但如果段是 `task-123` 形式却查不到这条,说明已被删,应按新建处理
|
||||
(有些客户端会重推已删的资源,回 404 会让它反复重试)。 */
|
||||
try {
|
||||
$tRes2 = TaskDav::put($familyId, $me, $tTaskId, $tBody);
|
||||
} catch (RuntimeException $e) {
|
||||
dav_fail(403, $e->getMessage());
|
||||
}
|
||||
|
||||
$tSaved = TaskDav::one($familyId, $me, (int)$tRes2['id']);
|
||||
if (!$tSaved) {
|
||||
dav_fail(500, '写入后读取失败。');
|
||||
}
|
||||
$tRes3 = TaskDav::resourceOf($tSaved);
|
||||
$tNewHref = TaskDav::BOOK_HREF . TaskDav::segmentOf($tSaved) . '.ics';
|
||||
|
||||
// 新建 → 201 Created + Location;更新 → 200/204
|
||||
http_response_code($tRes2['created'] ? 201 : 204);
|
||||
header('ETag: ' . $tRes3['etag']);
|
||||
if ($tRes2['created']) {
|
||||
header('Location: ' . $tNewHref);
|
||||
}
|
||||
exit;
|
||||
}
|
||||
|
||||
if (!$cal) {
|
||||
dav_fail(404, '没有这本日历。');
|
||||
}
|
||||
@@ -898,6 +1105,18 @@ if ($method === 'PUT') {
|
||||
// ⚠️ 覆盖行与 EXDATE 都是 `calendar_events` 的子表(FK ON DELETE CASCADE),
|
||||
// 所以这一句 DELETE 会把它们一并带走,不需要额外清理。
|
||||
if ($method === 'DELETE') {
|
||||
if (TaskDav::isTaskPath($path)) {
|
||||
$tTaskId = TaskDav::idOfSegment((string)preg_replace('/\.ics$/', '', $evSeg));
|
||||
if ($tTaskId <= 0) {
|
||||
dav_fail(400, '要指定是哪一条待办(如 /dav/tasks.ics/task-5.ics)。');
|
||||
}
|
||||
if (!TaskDav::delete($familyId, $me, $tTaskId)) {
|
||||
dav_fail(404, '没有这条待办,或你没有删除权限。');
|
||||
}
|
||||
http_response_code(204);
|
||||
exit;
|
||||
}
|
||||
|
||||
if (!$cal) {
|
||||
dav_fail(404, '没有这本日历。');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user