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'] ?? '')),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 把解析出来的属性归一成我们要落库的形状。
|
||||
*
|
||||
|
||||
+327
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user