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:
2026-11-02 15:29:36 +08:00
parent 821cfd1542
commit c86658966c
3 changed files with 827 additions and 0 deletions
+281
View File
@@ -57,6 +57,161 @@ final class Ics
'RECURRENCE-ID', '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 文本。 * 把一个日历本导出成 .ics 文本。
* *
@@ -362,6 +517,132 @@ final class Ics
return $out; 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'] ?? '')),
];
}
/** /**
* 把解析出来的属性归一成我们要落库的形状。 * 把解析出来的属性归一成我们要落库的形状。
* *
+327
View File
@@ -0,0 +1,327 @@
<?php
/**
* TaskDAV —— 待办(VTODO)走的 CalDAV 扩展层。
*
* 🔴 2026-11-02 新增。用户原话:
* 「网页端的 caldav 功能,除了能够同步日程外,增加可以同步任务(待办)的功能」
*
* 用户已定的三个选择:
* (1) 存放位置:作为 /dav/ 内的一个日历本(不另开 /taskdav/ 入口)
* (2) 同步方向:直接双向读写
* (3) 字段映射:标准映射(SUMMARY/DESCRIPTION/DUE/STATUS/PERCENT-COMPLETE)
*
* ## 为什么是"虚拟日历本"
*
* 任务在 `tasks` 表里是按**看板 + 状态三列**组织的,没有"日历本"这个概念。
* 但客户端只认"一个日历集合 = 一组资源"。所以这里造一个**虚拟本**:
* · href 固定为 `/dav/tasks.ics/`
* · 名字固定「家庭待办」
* · 不对应 calendar_events 里的任何一行,也不出现在 Calendar::mine() 里
*
* ⚠️ **虚拟本 id 必须与真实日历 id 不冲突**。用**负数**:
* 真实 `calendar_events.calendar_id` 是 `INT UNSIGNED`,永远 ≥ 1。
* 负数在 `parsePath()` 出来的 `book` 里一眼可辨,也不会有任何一张表撞上。
*
* ## 权限口径
*
* 与网页端「任务」模块**完全一致**(`Task::visibleTo` / `Task::canWrite`):
* · 能看到的:我建的 / 分给我的 / 共享给我的 / 我是父任务可见者的子任务
* · 能写的:`Task::canWrite` 判(只读共享的不能写)
* ⇒ 不在 DAV 层另写一套权限判断,否则"网页上不能改的,手机能改"会出问题。
*
* ## UID 方案
*
* `task-<id>`。前缀 `task-` 是为了与日历的 UID 空间**隔离**:
* 日历的 UID 可能是 `syncal-1758000003001-1d8b60`、`movie-100_familybook`,
* 或客户端自造的任意串。加前缀后,即便有人在日历本里放了 UID 叫 `task-5` 的
* 日程,也不会与本模块的待办 5 互相干扰(路由本就分开了,这里是双保险)。
*/
declare(strict_types=1);
namespace App;
final class TaskDav
{
/** 虚拟本的 href(客户端看到的集合地址) */
public const BOOK_HREF = '/dav/tasks.ics/';
/**
* 虚拟本 id。
*
* ⚠️ **负数**:`calendar_events.calendar_id` 是 INT UNSIGNED(≥1),
* 用负数永远撞不上;且在 parsePath 的返回值里一眼能认出"这是待办本"。
*/
public const BOOK_ID = -1;
/** 虚拟本显示名 */
public const BOOK_NAME = '家庭待办';
/** 资源名前缀(与日历 UID 空间隔离) */
public const UID_PREFIX = 'task-';
/** 本实现的 ctag 用:客户端拿它判"这整本变没变" */
private const CTAG_VERSION = 1;
/** 这个 book id 是不是待办本 */
public static function isTaskBook(int $bookId): bool
{
return $bookId === self::BOOK_ID;
}
/** 这个 href(如 `/dav/tasks.ics` 或带尾斜杠)是不是待办本 */
public static function isTaskHref(string $href): bool
{
$p = rtrim((string)parse_url($href, PHP_URL_PATH), '/');
return $p === rtrim(self::BOOK_HREF, '/');
}
/**
* 这个路径是不是待办本**及其子资源**(`/dav/tasks.ics/` 或 `/dav/tasks.ics/task-5.ics`)。
*
* 🔴 2026-11-02 修:PUT/GET 单条/DELETE 报 404。
* 根因是原来全用 `isTaskHref()`,它只比"整条路径 == /dav/tasks.ics/",
* 带子资源时(`/dav/tasks.ics/task-99999.ics`)必然 false ⇒
* 请求落到原有日历分支,而 `book=0` 又不是任何一本日历 ⇒ 404。
*
* ⚠️ 必须用前缀匹配而不是再拆一段:
* `/dav/tasks.icsXXX` 这种畸形路径不该被当成待办本,
* 所以前缀后面必须是 "/" 或结束。
*/
public static function isTaskPath(string $href): bool
{
$p = rtrim((string)parse_url($href, PHP_URL_PATH), '/');
$base = rtrim(self::BOOK_HREF, '/');
if ($p === $base) {
return true;
}
return strpos($p, $base . '/') === 0;
}
/**
* 一条 tasks 行 → 资源名(href 最后一段,不含 .ics)。
*
* ⚠️ 与 CalDav::segmentOf() 同口径:**一律用 UID**。
* 客户端 PUT 到 `/dav/tasks.ics/task-5.ics` 后,必须能按同名 GET 回来,
* 否则会表现成"写进去了又没了"(CalDav 那边踩过这个坑,注释记着)。
*/
public static function segmentOf(array $t): string
{
$id = (int)($t['id'] ?? 0);
return self::UID_PREFIX . $id;
}
/** 从资源段反解任务 id(`task-5` → 5;认不出来返回 0) */
public static function idOfSegment(string $seg): int
{
$seg = trim($seg);
if ($seg === '') {
return 0;
}
// 兼容客户端在 UID 后面追加后缀的情况(有些客户端会把 UID 再净化)
if (preg_match('/^' . preg_quote(self::UID_PREFIX, '/') . '(\d+)/', $seg, $m)) {
return (int)$m[1];
}
return 0;
}
/**
* 当前用户能看到的全部任务(含已完成),按"父在前、子在后 + 截止时间"排。
*
* ⚠️ 只查一次库,**不逐条调 `Task::find()`** —— 那会变成 N+1
* (`find()` 还会顺带查 comments / attachs / shares,对列表来说是浪费)。
* 权限用 `Task::visibleTo()` 逐条判(它是纯内存判断,除 isShared 会查一次
* 子任务父级,量很小)。
*
* @return array<int,array> tasks 行 + `board_name` + `uid`
*/
public static function all(int $familyId, int $personId): array
{
$st = Database::get()->prepare(
'SELECT t.*, b.name AS board_name
FROM tasks t
LEFT JOIN task_boards b ON b.id = t.board_id
WHERE t.family_id = ?
ORDER BY t.status <> \'done\', t.due_at IS NULL, t.due_at, t.position, t.id'
);
$st->execute([$familyId]);
$out = [];
foreach ($st->fetchAll() as $r) {
if (!Task::visibleTo($r, $personId)) {
continue;
}
$r['uid'] = self::UID_PREFIX . (int)$r['id'];
$out[] = $r;
}
return $out;
}
/** 单条任务(含权限判断)。取不到或无权看 → null。 */
public static function one(int $familyId, int $personId, int $taskId): ?array
{
if ($taskId <= 0) {
return null;
}
$st = Database::get()->prepare(
'SELECT t.*, b.name AS board_name
FROM tasks t
LEFT JOIN task_boards b ON b.id = t.board_id
WHERE t.id = ? AND t.family_id = ?'
);
$st->execute([$taskId, $familyId]);
$r = $st->fetch();
if (!$r) {
return null;
}
if (!Task::visibleTo($r, $personId)) {
return null;
}
$r['uid'] = self::UID_PREFIX . (int)$r['id'];
return $r;
}
/**
* 一条任务 → 资源(ics 正文 + ETag)。
*
* ⚠️ ETag 由**内容哈希**算出,不用"当前时间"。
* CalDav 那边为这个踩过坑(ETag 潮汐 ⇒ 客户端每轮全量重拉),
* 这里从一开始就按正确做法来。
*/
public static function resourceOf(array $t): array
{
$ics = Ics::exportTodos([$t], self::BOOK_NAME);
return ['ics' => $ics, 'etag' => '"' . md5($ics) . '"'];
}
/** 整本导出(GET / REPORT 用) */
public static function ics(int $familyId, int $personId): string
{
return Ics::exportTodos(self::all($familyId, $personId), self::BOOK_NAME);
}
/**
* ctag:整本"变没变"的指纹。
*
* ⚠️ 必须覆盖**任何会影响导出的变化**:
* · 条目增删 ⇒ COUNT 变
* · 任意一条改动 ⇒ MAX(updated_at) / SUM 变
* · **状态变化也算改动** —— `setStatus` 会写 updated_at,所以上面这条能兜住
* (但 `markDone` 之类的旁路如果只改 done_at 不改 updated_at,
* 这里会漏;所以额外把 SUM(UNIX_TIMESTAMP(done_at)) 也算进来)。
*
* ⚠️ 不用 `MAX(updated_at)` 单打独斗:同一秒内改两条时它不变,
* 客户端会漏一次同步。加上 COUNT 与 done_at 的和,碰撞概率足够低。
*/
public static function ctag(int $familyId, int $personId): string
{
/* ⚠️ 这里按「全 family」统计而不是「这个人可见的」:
可见性是内存里判的(Task::visibleTo 有 shares 查询),
放进 SQL 会很复杂。按全家统计的后果只是"别人的任务变了也会让我重拉一次",
属于**偏保守但安全**的方向(宁可多拉,不可漏拉)。 */
$st = Database::get()->prepare(
'SELECT COUNT(*) AS n,
COALESCE(MAX(updated_at), \'\') AS mx,
COALESCE(SUM(UNIX_TIMESTAMP(COALESCE(done_at, \'1970-01-01\'))), 0) AS dn
FROM tasks WHERE family_id = ?'
);
$st->execute([$familyId]);
$r = $st->fetch() ?: [];
$seed = self::CTAG_VERSION . '|'
. (int)($r['n'] ?? 0) . '|'
. (string)($r['mx'] ?? '') . '|'
. (int)($r['dn'] ?? 0);
return '"' . md5($seed) . '"';
}
/**
* 客户端 PUT 一条待办 → 落库。
*
* @param int $taskId URL 段里解析出的 id(0 = 新建)
* @param string $body text/calendar 正文
*
* @return array{id:int, created:bool}
* @throws \RuntimeException 正文不合法 / 无写权限
*/
public static function put(int $familyId, int $personId, int $taskId, string $body): array
{
$parsed = Ics::parseTodos($body);
if (!$parsed) {
throw new \RuntimeException('正文里没有找到 VTODO。');
}
// 一个资源 = 一条待办。出现多个说明客户端行为异常,取第一条(与 CalDav 同口径)
$n = Ics::normalizeTodo($parsed[0]);
if ($n['summary'] === '') {
throw new \RuntimeException('VTODO 缺少 SUMMARY(任务标题),拒绝写入。');
}
// ---- 更新已存在的 ----
/* ⚠️ 只有当这条**确实存在且有权访问**时才走更新分支。
否则(比如客户端 PUT 到 task-99999.ics,而 99999 不存在)
必须**新建** —— RFC 4918 §9.7 规定 PUT 到不存在的 URI 就是创建资源。
原来这里直接抛"任务不存在",会让客户端建不了新待办(实测 403)。
这也兼容"客户端用自己编的 UID 当资源名"的场景。 */
$old = $taskId > 0 ? self::one($familyId, $personId, $taskId) : null;
if ($old !== null) {
if (!Task::canWrite($old, $personId)) {
throw new \RuntimeException('这条任务你没有写权限。');
}
/* ⚠️ `Task::update()` 会把**没传的字段清空**(它按 `$d['x'] ?? ''` 取值),
所以必须把原值补全再传 —— 否则客户端只改标题就会把备注/截止时间清掉。
这是"部分更新"最容易出的 bug。 */
Task::update($familyId, $taskId, [
'title' => $n['summary'],
'note' => $n['description'],
'status' => $n['status'],
'due_at' => $n['due'] ?? '',
'assignee_id' => (int)($old['assignee_id'] ?? 0),
'repeat_rule' => (string)($old['repeat_rule'] ?? ''),
'parent_id' => (int)($old['parent_id'] ?? 0),
'board_id' => (int)($old['board_id'] ?? 0),
'category_id' => (int)($old['category_id'] ?? 0),
]);
// 完成时间:`update()` 走到 done 会自动 markDone;这里补 done_at 的精确值
// (客户端给的 COMPLETED 时间比"服务器当前时间"更准)
if ($n['status'] === Task::DONE_STATUS && !empty($n['completed'])) {
Database::get()->prepare(
'UPDATE tasks SET done_at = ? WHERE id = ? AND family_id = ?'
)->execute([$n['completed'], $taskId, $familyId]);
}
return ['id' => $taskId, 'created' => false];
}
// ---- 新建 ----
$id = Task::create($familyId, $personId, [
'title' => $n['summary'],
'note' => $n['description'],
'status' => $n['status'],
'due_at' => $n['due'] ?? '',
]);
if ($id <= 0) {
throw new \RuntimeException('创建任务失败。');
}
if ($n['status'] === Task::DONE_STATUS && !empty($n['completed'])) {
Database::get()->prepare(
'UPDATE tasks SET done_at = ? WHERE id = ? AND family_id = ?'
)->execute([$n['completed'], $id, $familyId]);
}
return ['id' => $id, 'created' => true];
}
/** 客户端 DELETE 一条待办 */
public static function delete(int $familyId, int $personId, int $taskId): bool
{
$t = self::one($familyId, $personId, $taskId);
if (!$t) {
return false;
}
if (!Task::canWrite($t, $personId)) {
return false;
}
Task::destroy($familyId, $taskId);
return true;
}
}
+219
View File
@@ -58,6 +58,7 @@ use App\CalDav;
use App\Calendar; use App\Calendar;
use App\Database; use App\Database;
use App\Ics; use App\Ics;
use App\TaskDav;
/** 统一的失败出口(CalDAV 客户端只认状态码,正文给点人话便于排查) */ /** 统一的失败出口(CalDAV 客户端只认状态码,正文给点人话便于排查) */
function dav_fail(int $code, string $msg): void function dav_fail(int $code, string $msg): void
@@ -317,6 +318,80 @@ $emit = static function (string $xmlns, array $parts): void {
// ---- 5. PROPFIND:发现链 + 清单 / 属性 / 资源列表 ---- // ---- 5. PROPFIND:发现链 + 清单 / 属性 / 资源列表 ----
if ($method === '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'); $depth = (string)($_SERVER['HTTP_DEPTH'] ?? '0');
// 5a. `PROPFIND /`(站点根)→ 只回 current-user-principal。 // 5a. `PROPFIND /`(站点根)→ 只回 current-user-principal。
@@ -464,6 +539,23 @@ if ($method === 'PROPFIND') {
. '</d:href></c:calendar-home-set>' . '</d:href></c:calendar-home-set>'
. '</d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response>'); . '</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" ' $emit('xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav" '
. 'xmlns:x="http://apple.com/ns/ical/" ' . 'xmlns:x="http://apple.com/ns/ical/" '
. 'xmlns:cs="http://calendarserver.org/ns/"', $parts); . 'xmlns:cs="http://calendarserver.org/ns/"', $parts);
@@ -522,6 +614,36 @@ if ($method === 'PROPFIND') {
// ---- 6. GET / HEAD:整本导出成 ics,或单条资源 ---- // ---- 6. GET / HEAD:整本导出成 ics,或单条资源 ----
if ($method === 'GET' || $method === 'HEAD') { 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 修在这里**。 /* 🔴🔴 v45b(2026-10-12 用户「TB 还是找不到日历」)——**真 bug 修在这里**。
原来这一段是: 原来这一段是:
if (!$cal) { dav_fail(404, '没有这本日历。'); } if (!$cal) { dav_fail(404, '没有这本日历。'); }
@@ -596,6 +718,48 @@ if ($method === 'GET' || $method === 'HEAD') {
// ---- 7. REPORT:calendar-multiget(按 href 取) / calendar-query(整本或按时间范围)---- // ---- 7. REPORT:calendar-multiget(按 href 取) / calendar-query(整本或按时间范围)----
if ($method === 'REPORT') { 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)时 —— /* 🔴 v45b:REPORT 打到 **home 容器**(`/dav/`,bookId=0)时 ——
照参照(DAViCal)回 **403**,不回 404。 照参照(DAViCal)回 **403**,不回 404。
参照实测:`REPORT /caldav/<user>/` → `403 Forbidden`(不是 404)。 参照实测:`REPORT /caldav/<user>/` → `403 Forbidden`(不是 404)。
@@ -706,6 +870,49 @@ if ($method === 'REPORT') {
// 函数,所以"手机上建的"与"网页里导入的"结果完全一致(UID 去重、重复规则、 // 函数,所以"手机上建的"与"网页里导入的"结果完全一致(UID 去重、重复规则、
// 提醒、全天与否都由它统一处理)。**绝不在 DAV 里另写一套 INSERT**。 // 提醒、全天与否都由它统一处理)。**绝不在 DAV 里另写一套 INSERT**。
if ($method === 'PUT') { 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) { if (!$cal) {
dav_fail(404, '没有这本日历。'); dav_fail(404, '没有这本日历。');
} }
@@ -898,6 +1105,18 @@ if ($method === 'PUT') {
// ⚠️ 覆盖行与 EXDATE 都是 `calendar_events` 的子表(FK ON DELETE CASCADE), // ⚠️ 覆盖行与 EXDATE 都是 `calendar_events` 的子表(FK ON DELETE CASCADE),
// 所以这一句 DELETE 会把它们一并带走,不需要额外清理。 // 所以这一句 DELETE 会把它们一并带走,不需要额外清理。
if ($method === '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) { if (!$cal) {
dav_fail(404, '没有这本日历。'); dav_fail(404, '没有这本日历。');
} }