327 lines
13 KiB
PHP
327 lines
13 KiB
PHP
<?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;
|
|||
|
|
}
|
|||
|
|
}
|