CalDAV 待办改为按分类拆成多本(对齐参照 DAViCal)
用户提供参照服务器 nas.yangyq.net/caldav/synccalendartest(DAViCal 1.1.9)并指出
「应该可以直接显示那个家庭的待办分类」。
## 参照实测结果
那台服务器的待办本结构:
/caldav/<user>/tfxnz/ 家庭计划 VTODO
/caldav/<user>/zlqlzm/ 工作任务 VTODO
/caldav/<user>/tdotbsnl/ 个人成长 VTODO
/caldav/<user>/home_todo/ Inbox VTODO
⇒ 每个分类是**独立的 calendar 集合**,各自 supported-calendar-component-set
只含 VTODO,客户端里显示成并列的多个待办列表。
VTODO 正文还带 CREATED / LAST-MOD / DTSTAMP / SEQUENCE / PRIORITY /
PERCENT-COMPLETE / STATUS / DESCRIPTION / LOCATION / TRANSP。
## 上一版的问题
第一版做成「一个「家庭待办」本装全部任务」,所有分类混在一起,
客户端里只有一个列表 —— 用户要的是分类可见。
## 这一版
### 本的组织
/dav/tasks-<categoryId>.ics/ 每个 task_categories 一行 = 一本
/dav/tasks-inbox.ics/ category_id 为空的任务(Inbox)
⚠️ 用 `tasks-<id>` 而不是裸 id:真实日历本是 `/dav/<id>.ics/`,
裸 id 会与它们撞号(calendar_events 与 task_categories 是两套独立序列)。
### 分类跟随「本」
客户端把资源 PUT 进哪一本,任务就归哪个分类。
这是「按分类拆本」的自然语义,也让「在客户端里移动任务到另一本」等价于改分类。
### 跨本隔离
GET/DELETE 单条时会校验「这条任务确实属于这个本」,
否则改一下 href 就能用 A 本读到 B 本的任务。
## 验证(39 项全过)
真实数据(5 个分类 / 32 条任务):
| 本 | 条数 |
|---|---|
| 家庭事务 | 6 |
| 工作相关 | 19 |
| 个人事务 | 2 |
| 党支部工作 | 1 |
| 轻舟相关 | 1 |
| 收件箱 | 3 |
每本 supported-calendar-component-set 均只含 VTODO,无 VEVENT。
PUT 到 tasks-1.ics 后 Location 返回 /dav/tasks-1.ics/task-40.ics,
GET 读回、更新、完成、删除全部正常。
回归:原有日历本(观影等)PROPFIND/GET 正常,组件类型仍是 VEVENT。
鉴权:无口令 401、不存在的分类本 404、非法段 404。
测试数据已清理,tasks 仍为 32 条。
## 文件
- app/TaskDav.php 重写为多本模型
- app/Ics.php VTODO 导出/解析(上一版已加,本版未改)
- dav.php 五处拦截改多本
## 已知限制
看板名(正在进行/等待反馈/等待结果)通过 CATEGORIES 输出给客户端,
但客户端改它不会反向改看板 —— 标准 VTODO 没有「看板列」概念。
分类则双向(由「在哪一本」决定)。
This commit is contained in:
+243
-143
@@ -1,40 +1,37 @@
|
||||
<?php
|
||||
/**
|
||||
* TaskDAV —— 待办(VTODO)走的 CalDAV 扩展层。
|
||||
* TaskDAV —— 待办(VTODO)走的 CalDAV 扩展层(多本模型)。
|
||||
*
|
||||
* 🔴 2026-11-02 新增。用户原话:
|
||||
* 2026-11-02 新增。用户原话:
|
||||
* 「网页端的 caldav 功能,除了能够同步日程外,增加可以同步任务(待办)的功能」
|
||||
*
|
||||
* 用户已定的三个选择:
|
||||
* (1) 存放位置:作为 /dav/ 内的一个日历本(不另开 /taskdav/ 入口)
|
||||
* (2) 同步方向:直接双向读写
|
||||
* (3) 字段映射:标准映射(SUMMARY/DESCRIPTION/DUE/STATUS/PERCENT-COMPLETE)
|
||||
* 2026-11-02 重构(第二版):
|
||||
* 用户给了参照服务器 nas.yangyq.net/caldav/synccalendartest(DAViCal 1.1.9),
|
||||
* 并指出「应该可以直接显示那个家庭的待办分类」。
|
||||
* 参照实测:待办按分类拆成多个独立 calendar 集合,各自 supported-calendar-component-set
|
||||
* 只含 VTODO。客户端里显示成多个并列的待办列表。
|
||||
* ⇒ 本版从「一个家庭待办本」改为「每个 task_categories 一行 = 一个 VTODO 本」,
|
||||
* 外加一个 Inbox 本收未分类任务(与参照的 home_todo 同构)。
|
||||
*
|
||||
* ## 为什么是"虚拟日历本"
|
||||
* ## 本的组织(用户已定)
|
||||
*
|
||||
* 任务在 `tasks` 表里是按**看板 + 状态三列**组织的,没有"日历本"这个概念。
|
||||
* 但客户端只认"一个日历集合 = 一组资源"。所以这里造一个**虚拟本**:
|
||||
* · href 固定为 `/dav/tasks.ics/`
|
||||
* · 名字固定「家庭待办」
|
||||
* · 不对应 calendar_events 里的任何一行,也不出现在 Calendar::mine() 里
|
||||
* /dav/tasks-<categoryId>.ics/ 每个分类一本,名字就是分类名
|
||||
* /dav/tasks-inbox.ics/ 未分类(category_id 为空)的任务
|
||||
*
|
||||
* ⚠️ **虚拟本 id 必须与真实日历 id 不冲突**。用**负数**:
|
||||
* 真实 `calendar_events.calendar_id` 是 `INT UNSIGNED`,永远 ≥ 1。
|
||||
* 负数在 `parsePath()` 出来的 `book` 里一眼可辨,也不会有任何一张表撞上。
|
||||
* ⚠️ 为什么用 `tasks-<id>` 而不是裸 id:
|
||||
* 真实日历本是 `/dav/<id>.ics/`,用裸 id 会与它们撞号
|
||||
* (calendar_events 的 id 与 task_categories 的 id 是两套独立序列)。
|
||||
* 加 `tasks-` 前缀后永远不会冲突,也不需要负数那种 trick。
|
||||
*
|
||||
* ## 权限口径
|
||||
*
|
||||
* 与网页端「任务」模块**完全一致**(`Task::visibleTo` / `Task::canWrite`):
|
||||
* · 能看到的:我建的 / 分给我的 / 共享给我的 / 我是父任务可见者的子任务
|
||||
* · 能写的:`Task::canWrite` 判(只读共享的不能写)
|
||||
* ⇒ 不在 DAV 层另写一套权限判断,否则"网页上不能改的,手机能改"会出问题。
|
||||
* 与网页端「任务」模块完全一致(User 可见 = Task::visibleTo)。
|
||||
* 不在 DAV 层另写一套权限判断,否则"网页上不能改的,手机能改"会出问题。
|
||||
*
|
||||
* ## UID 方案
|
||||
*
|
||||
* `task-<id>`。前缀 `task-` 是为了与日历的 UID 空间**隔离**:
|
||||
* 日历的 UID 可能是 `syncal-1758000003001-1d8b60`、`movie-100_familybook`,
|
||||
* 或客户端自造的任意串。加前缀后,即便有人在日历本里放了 UID 叫 `task-5` 的
|
||||
* 日程,也不会与本模块的待办 5 互相干扰(路由本就分开了,这里是双保险)。
|
||||
* `task-<id>`。前缀 `task-` 与日历的 UID 空间隔离(日历 UID 可能是
|
||||
* `syncal-...`、`movie-100_familybook`,或客户端自造的任意串)。
|
||||
*/
|
||||
declare(strict_types=1);
|
||||
|
||||
@@ -42,108 +39,201 @@ namespace App;
|
||||
|
||||
final class TaskDav
|
||||
{
|
||||
/** 虚拟本的 href(客户端看到的集合地址) */
|
||||
public const BOOK_HREF = '/dav/tasks.ics/';
|
||||
|
||||
/**
|
||||
* 虚拟本 id。
|
||||
* 本 href 前缀:`/dav/tasks-<id>.ics/`。
|
||||
*
|
||||
* ⚠️ **负数**:`calendar_events.calendar_id` 是 INT UNSIGNED(≥1),
|
||||
* 用负数永远撞不上;且在 parsePath 的返回值里一眼能认出"这是待办本"。
|
||||
* Inbox 本用 `tasks-inbox.ics`(id 段是字符串 `inbox` 而不是数字)。
|
||||
*/
|
||||
public const BOOK_ID = -1;
|
||||
public const HREF_PREFIX = '/dav/tasks-';
|
||||
|
||||
/** 虚拟本显示名 */
|
||||
public const BOOK_NAME = '家庭待办';
|
||||
/** Inbox(未分类)本的 id 段 */
|
||||
public const INBOX_SEG = 'inbox';
|
||||
|
||||
/** Inbox 本的显示名(与参照服务器的 home_todo 一个意思) */
|
||||
public const INBOX_NAME = '收件箱';
|
||||
|
||||
/** 资源名前缀(与日历 UID 空间隔离) */
|
||||
public const UID_PREFIX = 'task-';
|
||||
|
||||
/** 本实现的 ctag 用:客户端拿它判"这整本变没变" */
|
||||
private const CTAG_VERSION = 1;
|
||||
/** ctag 版本,改了导出格式就 +1,让客户端重拉 */
|
||||
private const CTAG_VERSION = 2;
|
||||
|
||||
/** 这个 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
|
||||
/**
|
||||
* 列出「这个人能看到的待办本」。
|
||||
*
|
||||
* @return array<int,array{seg:string, id:int, name:string, href:string, color:string}>
|
||||
* id 为 -1 表示 Inbox
|
||||
*/
|
||||
public static function books(int $familyId, int $personId): array
|
||||
{
|
||||
$p = rtrim((string)parse_url($href, PHP_URL_PATH), '/');
|
||||
return $p === rtrim(self::BOOK_HREF, '/');
|
||||
$out = [];
|
||||
|
||||
// ---- 各分类本 ----
|
||||
/* ⚠️ 不直接用 Task::categories():它带 used_count 子查询,
|
||||
对 DAV 每次发现请求都算一遍是浪费。这里只要 id/name/position。 */
|
||||
$st = Database::get()->prepare(
|
||||
'SELECT id, name FROM task_categories WHERE family_id = ? ORDER BY position, id'
|
||||
);
|
||||
$st->execute([$familyId]);
|
||||
foreach ($st->fetchAll() as $c) {
|
||||
$cid = (int)$c['id'];
|
||||
$out[] = [
|
||||
'seg' => (string)$cid,
|
||||
'id' => $cid,
|
||||
'name' => (string)$c['name'],
|
||||
'href' => self::HREF_PREFIX . $cid . '.ics/',
|
||||
'color' => self::colorOf($cid, (string)$c['name']),
|
||||
];
|
||||
}
|
||||
|
||||
// ---- Inbox(未分类)----
|
||||
/* ⚠️ 即使用户当前没有未分类任务,也要列出来 ——
|
||||
否则客户端建不出"没选分类"的待办,且新任务会无处可去。
|
||||
参照服务器也是恒定有 home_todo/Inbox 的。 */
|
||||
$out[] = [
|
||||
'seg' => self::INBOX_SEG,
|
||||
'id' => -1,
|
||||
'name' => self::INBOX_NAME,
|
||||
'href' => self::HREF_PREFIX . self::INBOX_SEG . '.ics/',
|
||||
'color' => '#8A8A8A',
|
||||
];
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 这个路径是不是待办本**及其子资源**(`/dav/tasks.ics/` 或 `/dav/tasks.ics/task-5.ics`)。
|
||||
* 从一个 href 或路径解析出待办本的段(`<id>` 或 `inbox`)。
|
||||
*
|
||||
* 🔴 2026-11-02 修:PUT/GET 单条/DELETE 报 404。
|
||||
* 根因是原来全用 `isTaskHref()`,它只比"整条路径 == /dav/tasks.ics/",
|
||||
* 带子资源时(`/dav/tasks.ics/task-99999.ics`)必然 false ⇒
|
||||
* 请求落到原有日历分支,而 `book=0` 又不是任何一本日历 ⇒ 404。
|
||||
*
|
||||
* ⚠️ 必须用前缀匹配而不是再拆一段:
|
||||
* `/dav/tasks.icsXXX` 这种畸形路径不该被当成待办本,
|
||||
* 所以前缀后面必须是 "/" 或结束。
|
||||
* @return array{seg:string, sub:string}|null
|
||||
* seg = 本段(如 `3` 或 `inbox`),sub = 资源段(如 `task-12`),
|
||||
* 不是待办本则返回 null。
|
||||
*/
|
||||
public static function isTaskPath(string $href): bool
|
||||
public static function parse(string $href): ?array
|
||||
{
|
||||
$p = rtrim((string)parse_url($href, PHP_URL_PATH), '/');
|
||||
$base = rtrim(self::BOOK_HREF, '/');
|
||||
if ($p === $base) {
|
||||
return true;
|
||||
$prefix = rtrim(self::HREF_PREFIX, '/'); // /dav/tasks-
|
||||
if (strpos($p, $prefix) !== 0) {
|
||||
return null;
|
||||
}
|
||||
return strpos($p, $base . '/') === 0;
|
||||
$rest = substr($p, strlen($prefix)); // 如 `3.ics` 或 `3.ics/task-12.ics`
|
||||
if ($rest === '') {
|
||||
return null;
|
||||
}
|
||||
$parts = explode('/', $rest);
|
||||
$bookSeg = (string)preg_replace('/\.ics$/', '', $parts[0]);
|
||||
if ($bookSeg === '') {
|
||||
return null;
|
||||
}
|
||||
$sub = '';
|
||||
if (isset($parts[1]) && $parts[1] !== '') {
|
||||
$sub = (string)preg_replace('/\.ics$/', '', $parts[1]);
|
||||
}
|
||||
return ['seg' => $bookSeg, 'sub' => $sub];
|
||||
}
|
||||
|
||||
/** 这个路径是不是待办本(含子资源) */
|
||||
public static function isTaskPath(string $href): bool
|
||||
{
|
||||
return self::parse($href) !== null;
|
||||
}
|
||||
|
||||
/** 段 → 分类 id(Inbox 返回 -1;认不出返回 -2 表示非法) */
|
||||
public static function categoryIdOfSeg(string $seg): int
|
||||
{
|
||||
if ($seg === self::INBOX_SEG) {
|
||||
return -1;
|
||||
}
|
||||
if (preg_match('/^\d+$/', $seg) === 1) {
|
||||
return (int)$seg;
|
||||
}
|
||||
return -2;
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验这个段确实是**这个人能访问的**待办本。
|
||||
*
|
||||
* ⚠️ 必须校验:否则改一下 URL(`/dav/tasks-999.ics/`)就能探到
|
||||
* 别的家庭的分类名,属于越权。
|
||||
*/
|
||||
public static function resolveBook(int $familyId, int $personId, string $seg): ?array
|
||||
{
|
||||
foreach (self::books($familyId, $personId) as $b) {
|
||||
if ($b['seg'] === $seg) {
|
||||
return $b;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 本名配色(稳定:同一个分类每次都是同一色) */
|
||||
private static function colorOf(int $id, string $name): string
|
||||
{
|
||||
$palette = ['#B0763A', '#7A4E8C', '#3A7AB0', '#4E8C5A', '#B05A5A', '#8C7A4E', '#5A6B8C'];
|
||||
return $palette[$id % count($palette)];
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 资源名
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* 一条 tasks 行 → 资源名(href 最后一段,不含 .ics)。
|
||||
*
|
||||
* ⚠️ 与 CalDav::segmentOf() 同口径:**一律用 UID**。
|
||||
* 客户端 PUT 到 `/dav/tasks.ics/task-5.ics` 后,必须能按同名 GET 回来,
|
||||
* 否则会表现成"写进去了又没了"(CalDav 那边踩过这个坑,注释记着)。
|
||||
* ⚠️ 与 CalDav::segmentOf() 同口径:一律用 UID。
|
||||
* 客户端 PUT 到 `/dav/tasks-3.ics/task-5.ics` 后必须能按同名 GET 回来,
|
||||
* 否则会表现成"写进去了又没了"(CalDav 那边踩过)。
|
||||
*/
|
||||
public static function segmentOf(array $t): string
|
||||
{
|
||||
$id = (int)($t['id'] ?? 0);
|
||||
return self::UID_PREFIX . $id;
|
||||
return self::UID_PREFIX . (int)($t['id'] ?? 0);
|
||||
}
|
||||
|
||||
/** 从资源段反解任务 id(`task-5` → 5;认不出来返回 0) */
|
||||
/** 从资源段反解任务 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`
|
||||
* @param int $catId 分类 id;-1 表示 Inbox(未分类)
|
||||
*/
|
||||
public static function all(int $familyId, int $personId): array
|
||||
public static function tasksIn(int $familyId, int $personId, int $catId): 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]);
|
||||
$sql = 'SELECT t.*, b.name AS board_name, c.name AS cat_name
|
||||
FROM tasks t
|
||||
LEFT JOIN task_boards b ON b.id = t.board_id
|
||||
LEFT JOIN task_categories c ON c.id = t.category_id
|
||||
WHERE t.family_id = ?';
|
||||
$params = [$familyId];
|
||||
|
||||
if ($catId === -1) {
|
||||
$sql .= ' AND t.category_id IS NULL';
|
||||
} else {
|
||||
$sql .= ' AND t.category_id = ?';
|
||||
$params[] = $catId;
|
||||
}
|
||||
// 未完成的在前,然后按截止时间、看板顺序
|
||||
$sql .= ' ORDER BY t.status <> \'done\', t.due_at IS NULL, t.due_at, t.position, t.id';
|
||||
|
||||
$st = Database::get()->prepare($sql);
|
||||
$st->execute($params);
|
||||
|
||||
$out = [];
|
||||
foreach ($st->fetchAll() as $r) {
|
||||
@@ -156,16 +246,17 @@ final class TaskDav
|
||||
return $out;
|
||||
}
|
||||
|
||||
/** 单条任务(含权限判断)。取不到或无权看 → null。 */
|
||||
/** 单条任务(含权限)。取不到或无权看 → 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
|
||||
'SELECT t.*, b.name AS board_name, c.name AS cat_name
|
||||
FROM tasks t
|
||||
LEFT JOIN task_boards b ON b.id = t.board_id
|
||||
LEFT JOIN task_categories c ON c.id = t.category_id
|
||||
WHERE t.id = ? AND t.family_id = ?'
|
||||
);
|
||||
$st->execute([$taskId, $familyId]);
|
||||
@@ -180,95 +271,97 @@ final class TaskDav
|
||||
return $r;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 导出
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* 一条任务 → 资源(ics 正文 + ETag)。
|
||||
*
|
||||
* ⚠️ ETag 由**内容哈希**算出,不用"当前时间"。
|
||||
* CalDav 那边为这个踩过坑(ETag 潮汐 ⇒ 客户端每轮全量重拉),
|
||||
* 这里从一开始就按正确做法来。
|
||||
* ⚠️ ETag 由内容哈希算出,不用"当前时间"。
|
||||
* CalDav 那边为 ETag 潮汐踩过坑(客户端每轮全量重拉),这里从一开始就用正确做法。
|
||||
*/
|
||||
public static function resourceOf(array $t): array
|
||||
public static function resourceOf(array $t, string $calName = '待办'): array
|
||||
{
|
||||
$ics = Ics::exportTodos([$t], self::BOOK_NAME);
|
||||
$ics = Ics::exportTodos([$t], $calName);
|
||||
return ['ics' => $ics, 'etag' => '"' . md5($ics) . '"'];
|
||||
}
|
||||
|
||||
/** 整本导出(GET / REPORT 用) */
|
||||
public static function ics(int $familyId, int $personId): string
|
||||
/** 整本导出 */
|
||||
public static function ics(int $familyId, int $personId, int $catId, string $calName): string
|
||||
{
|
||||
return Ics::exportTodos(self::all($familyId, $personId), self::BOOK_NAME);
|
||||
return Ics::exportTodos(self::tasksIn($familyId, $personId, $catId), $calName);
|
||||
}
|
||||
|
||||
/**
|
||||
* ctag:整本"变没变"的指纹。
|
||||
* 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 的和,碰撞概率足够低。
|
||||
* ⚠️ 必须覆盖任何会影响导出的变化,且不能随时间变。
|
||||
* 用 COUNT + MAX(updated_at) + SUM(done_at) 三个一起,
|
||||
* 单用 MAX(updated_at) 在同一秒改两条时会漏。
|
||||
*/
|
||||
public static function ctag(int $familyId, int $personId): string
|
||||
public static function ctag(int $familyId, int $personId, int $catId): 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]);
|
||||
$sql = '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 = ?';
|
||||
$params = [$familyId];
|
||||
if ($catId === -1) {
|
||||
$sql .= ' AND category_id IS NULL';
|
||||
} else {
|
||||
$sql .= ' AND category_id = ?';
|
||||
$params[] = $catId;
|
||||
}
|
||||
$st = Database::get()->prepare($sql);
|
||||
$st->execute($params);
|
||||
$r = $st->fetch() ?: [];
|
||||
|
||||
$seed = self::CTAG_VERSION . '|'
|
||||
$seed = self::CTAG_VERSION . '|' . $catId . '|'
|
||||
. (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 正文
|
||||
* @param int $catId 本对应的分类 id(-1 = Inbox)
|
||||
* @param int $taskId URL 段解析出的 id(0 = 新建)
|
||||
* @param string $body text/calendar 正文
|
||||
*
|
||||
* @return array{id:int, created:bool}
|
||||
* @throws \RuntimeException 正文不合法 / 无写权限
|
||||
* @throws \RuntimeException
|
||||
*/
|
||||
public static function put(int $familyId, int $personId, int $taskId, string $body): array
|
||||
public static function put(int $familyId, int $personId, int $catId,
|
||||
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)。
|
||||
/* 只有"确实存在且有权访问"时才走更新分支。
|
||||
否则(客户端 PUT 到 task-99999.ics 而它不存在)必须**新建** ——
|
||||
RFC 4918 §9.7 规定 PUT 到不存在的 URI 就是创建资源。
|
||||
这也兼容"客户端用自己编的 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'] ?? ''` 取值),
|
||||
/* ⚠️ Task::update() 会把没传的字段清空(它按 `$d['x'] ?? ''` 取值),
|
||||
所以必须把原值补全再传 —— 否则客户端只改标题就会把备注/截止时间清掉。
|
||||
这是"部分更新"最容易出的 bug。 */
|
||||
Task::update($familyId, $taskId, [
|
||||
@@ -280,37 +373,44 @@ final class TaskDav
|
||||
'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),
|
||||
// 分类跟随**本**:客户端把资源放进了哪一本,就归哪个分类
|
||||
'category_id' => $catId === -1 ? 0 : $catId,
|
||||
]);
|
||||
|
||||
// 完成时间:`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]);
|
||||
}
|
||||
self::fixDoneAt($familyId, $taskId, $n);
|
||||
return ['id' => $taskId, 'created' => false];
|
||||
}
|
||||
|
||||
// ---- 新建 ----
|
||||
$id = Task::create($familyId, $personId, [
|
||||
'title' => $n['summary'],
|
||||
'note' => $n['description'],
|
||||
'status' => $n['status'],
|
||||
'due_at' => $n['due'] ?? '',
|
||||
'title' => $n['summary'],
|
||||
'note' => $n['description'],
|
||||
'status' => $n['status'],
|
||||
'due_at' => $n['due'] ?? '',
|
||||
'category_id' => $catId === -1 ? 0 : $catId,
|
||||
]);
|
||||
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]);
|
||||
}
|
||||
self::fixDoneAt($familyId, $id, $n);
|
||||
return ['id' => $id, 'created' => true];
|
||||
}
|
||||
|
||||
/**
|
||||
* 用客户端给的 COMPLETED 时间覆盖 done_at。
|
||||
*
|
||||
* `Task::update()` 走到 done 时会自动 markDone(用"服务器当前时间"),
|
||||
* 但客户端给的 COMPLETED 更准(它可能补录昨天完成的事)。
|
||||
*/
|
||||
private static function fixDoneAt(int $familyId, int $taskId, array $n): void
|
||||
{
|
||||
if ($n['status'] !== Task::DONE_STATUS || empty($n['completed'])) {
|
||||
return;
|
||||
}
|
||||
Database::get()->prepare(
|
||||
'UPDATE tasks SET done_at = ? WHERE id = ? AND family_id = ?'
|
||||
)->execute([$n['completed'], $taskId, $familyId]);
|
||||
}
|
||||
|
||||
/** 客户端 DELETE 一条待办 */
|
||||
public static function delete(int $familyId, int $personId, int $taskId): bool
|
||||
{
|
||||
|
||||
@@ -324,8 +324,26 @@ if ($method === 'PROPFIND') {
|
||||
* /dav/tasks.ics/ 是本模块的虚拟本,不对应 calendar_events 的任何一行。
|
||||
* 在这里前置拦截,原有日历分支一个字都不用改(零回归风险)。
|
||||
*/
|
||||
if (TaskDav::isTaskHref($path)) {
|
||||
if (TaskDav::isTaskPath($path)) {
|
||||
/* ============================================================
|
||||
* 待办本(VTODO)—— 按分类多本,与参照 DAViCal 同构
|
||||
* ============================================================
|
||||
* 用户 2026-11-02 给了参照服务器并指出
|
||||
* 「应该可以直接显示那个家庭的待办分类」。
|
||||
* 参照实测:每个分类是独立的 calendar 集合,各自 comp 只含 VTODO。
|
||||
*
|
||||
* ⚠️ supported-calendar-component-set 必须是 VTODO:
|
||||
* 给 VEVENT 的话客户端会按日程解析,VTODO 会被过滤掉不显示。
|
||||
*/
|
||||
$tHit = TaskDav::parse($path);
|
||||
$tSeg = $tHit === null ? '' : (string)$tHit['seg'];
|
||||
$tBook = TaskDav::resolveBook($familyId, $me, $tSeg);
|
||||
if ($tBook === null) {
|
||||
dav_fail(404, '没有这本待办。');
|
||||
}
|
||||
$tCatId = (int)$tBook['id'];
|
||||
$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);
|
||||
@@ -337,22 +355,20 @@ if ($method === 'PROPFIND') {
|
||||
};
|
||||
|
||||
if ($tDepth !== '1') {
|
||||
/* Depth:0 —— 这一本自身的属性。
|
||||
⚠️ supported-calendar-component-set 必须是 VTODO:
|
||||
给 VEVENT 的话客户端会按日程解析,VTODO 会被过滤掉不显示。 */
|
||||
$ctag = TaskDav::ctag($familyId, $me);
|
||||
// Depth:0 —— 这一本自身的属性
|
||||
$ctag = TaskDav::ctag($familyId, $me, $tCatId);
|
||||
$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:response><d:href>' . CalDav::x($tBook['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:displayname>' . CalDav::x($tBook['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:calendar-description>' . CalDav::x('岁月家 · 待办 · ' . $tBook['name']) . '</c:calendar-description>'
|
||||
. '<x:calendar-color xmlns:x="http://apple.com/ns/ical/">' . CalDav::x($tBook['color']) . '</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>'
|
||||
@@ -368,21 +384,21 @@ if ($method === 'PROPFIND') {
|
||||
⚠️ href 必须与 GET/REPORT 用同一个 segmentOf 算,
|
||||
三处不一致的话客户端会反复重拉(日历那边踩过)。 */
|
||||
$items = [
|
||||
'<d:response><d:href>' . CalDav::x(TaskDav::BOOK_HREF) . '</d:href>'
|
||||
'<d:response><d:href>' . CalDav::x($tBook['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:displayname>' . CalDav::x($tBook['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) {
|
||||
foreach (TaskDav::tasksIn($familyId, $me, $tCatId) as $t) {
|
||||
$seg = TaskDav::segmentOf($t);
|
||||
if ($seg === '') {
|
||||
continue;
|
||||
}
|
||||
$etag = TaskDav::resourceOf($t)['etag'];
|
||||
$etag = TaskDav::resourceOf($t, $tBook['name'])['etag'];
|
||||
$items[] = '<d:response><d:href>'
|
||||
. CalDav::x(TaskDav::BOOK_HREF . $seg . '.ics') . '</d:href>'
|
||||
. CalDav::x($tBook['href'] . $seg . '.ics') . '</d:href>'
|
||||
. '<d:propstat><d:prop>'
|
||||
. '<d:getetag>' . CalDav::x($etag) . '</d:getetag>'
|
||||
. '<d:resourcetype/>'
|
||||
@@ -539,22 +555,26 @@ 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>';
|
||||
/* 待办本:**按分类拆成多本**(与参照 DAViCal 同构)。
|
||||
用户 2026-11-02:「应该可以直接显示那个家庭的待办分类」。
|
||||
参照实测:每个分类是独立 calendar 集合,客户端里并列显示为多个待办列表。
|
||||
没有这一整批,客户端 PROPFIND /dav/ 时根本不知道有待办,也就不会去拉。 */
|
||||
foreach (TaskDav::books($familyId, $me) as $tb) {
|
||||
$tCtag = TaskDav::ctag($familyId, $me, (int)$tb['id']);
|
||||
$parts[] = '<d:response><d:href>' . CalDav::x($tb['href']) . '</d:href>'
|
||||
. '<d:propstat><d:prop>'
|
||||
. '<d:resourcetype><d:collection/><c:calendar/></d:resourcetype>'
|
||||
. '<d:displayname>' . CalDav::x($tb['name']) . '</d:displayname>'
|
||||
. '<d:getcontenttype>httpd/unix-directory</d:getcontenttype>'
|
||||
. '<c:calendar-description>' . CalDav::x('岁月家 · 待办 · ' . $tb['name']) . '</c:calendar-description>'
|
||||
. '<x:calendar-color xmlns:x="http://apple.com/ns/ical/">' . CalDav::x($tb['color']) . '</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/" '
|
||||
@@ -615,32 +635,44 @@ if ($method === 'PROPFIND') {
|
||||
// ---- 6. GET / HEAD:整本导出成 ics,或单条资源 ----
|
||||
if ($method === 'GET' || $method === 'HEAD') {
|
||||
/* 待办本:整本导出,或单条 VTODO。
|
||||
⚠️ 单条时必须校验资源名属于本用户可见的任务,
|
||||
否则改一下 URL 就能读到别人的任务(越权)。 */
|
||||
⚠️ 单条必须校验资源属于**这一本**且本用户可见,
|
||||
否则改 URL 就能读到别人的任务(越权)。 */
|
||||
if (TaskDav::isTaskPath($path)) {
|
||||
$tParsed = TaskDav::idOfSegment($evSeg);
|
||||
if ($tParsed > 0) {
|
||||
$tOne = TaskDav::one($familyId, $me, $tParsed);
|
||||
if (!$tOne) {
|
||||
$tHit = TaskDav::parse($path);
|
||||
$tBook = TaskDav::resolveBook($familyId, $me, (string)($tHit['seg'] ?? ''));
|
||||
if ($tBook === null) {
|
||||
dav_fail(404, '没有这本待办。');
|
||||
}
|
||||
$tCatId = (int)$tBook['id'];
|
||||
$tSub = (string)($tHit['sub'] ?? '');
|
||||
|
||||
if ($tSub !== '') {
|
||||
$tParsed = TaskDav::idOfSegment($tSub);
|
||||
$tOne = $tParsed > 0 ? TaskDav::one($familyId, $me, $tParsed) : null;
|
||||
// ⚠️ 还要确认这条**确实属于这一本**,否则可用 A 本的 href 读到 B 本的任务
|
||||
if ($tOne === null) {
|
||||
dav_fail(404, '没有这条待办。');
|
||||
}
|
||||
$tRes = TaskDav::resourceOf($tOne);
|
||||
$tOwnCat = (int)($tOne['category_id'] ?? 0);
|
||||
$tExpectCat = $tCatId === -1 ? 0 : $tCatId;
|
||||
if ($tOwnCat !== $tExpectCat) {
|
||||
dav_fail(404, '这条待办不在这本里。');
|
||||
}
|
||||
$tRes = TaskDav::resourceOf($tOne, $tBook['name']);
|
||||
header('Content-Type: text/calendar; charset=utf-8');
|
||||
header('ETag: ' . $tRes['etag']);
|
||||
if ($method === 'HEAD') {
|
||||
exit;
|
||||
if ($method !== 'HEAD') {
|
||||
echo $tRes['ics'];
|
||||
}
|
||||
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;
|
||||
header('ETag: ' . TaskDav::ctag($familyId, $me, $tCatId));
|
||||
if ($method !== 'HEAD') {
|
||||
echo TaskDav::ics($familyId, $me, $tCatId, $tBook['name']);
|
||||
}
|
||||
echo $tIcs;
|
||||
exit;
|
||||
}
|
||||
|
||||
@@ -719,9 +751,14 @@ if ($method === 'GET' || $method === 'HEAD') {
|
||||
// ---- 7. REPORT:calendar-multiget(按 href 取) / calendar-query(整本或按时间范围)----
|
||||
if ($method === 'REPORT') {
|
||||
/* 待办本:calendar-query / calendar-multiget 都返回 VTODO。
|
||||
⚠️ 支持 multiget 按 href 过滤 —— 整本回虽然"客户端也能用",
|
||||
但一本 30 条时无感、将来几百条时就是纯浪费(日历那边为此改过)。 */
|
||||
⚠️ 支持 multiget 按 href 过滤 —— 整本回在条目多时是纯浪费。 */
|
||||
if (TaskDav::isTaskPath($path)) {
|
||||
$tHit = TaskDav::parse($path);
|
||||
$tBook = TaskDav::resolveBook($familyId, $me, (string)($tHit['seg'] ?? ''));
|
||||
if ($tBook === null) {
|
||||
dav_fail(404, '没有这本待办。');
|
||||
}
|
||||
$tCatId = (int)$tBook['id'];
|
||||
$tBody = (string)file_get_contents('php://input');
|
||||
$tWantsMulti = stripos($tBody, 'calendar-multiget') !== false;
|
||||
|
||||
@@ -737,14 +774,14 @@ if ($method === 'REPORT') {
|
||||
}
|
||||
|
||||
$tItems = [];
|
||||
foreach (TaskDav::all($familyId, $me) as $t) {
|
||||
foreach (TaskDav::tasksIn($familyId, $me, $tCatId) as $t) {
|
||||
$seg = TaskDav::segmentOf($t);
|
||||
if ($seg === '' || ($tWantsMulti && !isset($tWant[$seg]))) {
|
||||
continue;
|
||||
}
|
||||
$res = TaskDav::resourceOf($t);
|
||||
$res = TaskDav::resourceOf($t, $tBook['name']);
|
||||
$tItems[] = '<d:response><d:href>'
|
||||
. CalDav::x(TaskDav::BOOK_HREF . $seg . '.ics') . '</d:href>'
|
||||
. CalDav::x($tBook['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>'
|
||||
@@ -871,9 +908,17 @@ if ($method === 'REPORT') {
|
||||
// 提醒、全天与否都由它统一处理)。**绝不在 DAV 里另写一套 INSERT**。
|
||||
if ($method === 'PUT') {
|
||||
/* 待办本:新建 / 更新一条 VTODO。
|
||||
⚠️ 路径里有段 = 更新那一条;没有段 = 新建(客户端会给新资源自己编个名字,
|
||||
所以"没有段"很少见,但按标准要支持)。 */
|
||||
⚠️ 路径里有段 = 更新那一条;没有段 = 新建(客户端会给新资源自己编名字)。
|
||||
⚠️ PUT 到不存在的资源名是**创建**(RFC 4918 §9.7),
|
||||
由 TaskDav::put 内部判断,这里不预先拒绝。 */
|
||||
if (TaskDav::isTaskPath($path)) {
|
||||
$tHit = TaskDav::parse($path);
|
||||
$tBook = TaskDav::resolveBook($familyId, $me, (string)($tHit['seg'] ?? ''));
|
||||
if ($tBook === null) {
|
||||
dav_fail(404, '没有这本待办。');
|
||||
}
|
||||
$tCatId = (int)$tBook['id'];
|
||||
|
||||
$tBody = (string)file_get_contents('php://input');
|
||||
if (trim($tBody) === '') {
|
||||
dav_fail(400, '正文是空的。');
|
||||
@@ -884,15 +929,10 @@ if ($method === 'PUT') {
|
||||
dav_fail(415, '只接受 text/calendar 正文。');
|
||||
}
|
||||
|
||||
$evSeg2 = (string)preg_replace('/\.ics$/', '', $evSeg);
|
||||
$tTaskId = TaskDav::idOfSegment($evSeg2);
|
||||
$tTaskId = TaskDav::idOfSegment((string)($tHit['sub'] ?? ''));
|
||||
|
||||
/* ⚠️ 「段里有字但解析不出 id」= 客户端用了自己编的 UID(新建场景常见)。
|
||||
这种情况按**新建**处理,而不是报错 —— 那才是它的真实意图。
|
||||
但如果段是 `task-123` 形式却查不到这条,说明已被删,应按新建处理
|
||||
(有些客户端会重推已删的资源,回 404 会让它反复重试)。 */
|
||||
try {
|
||||
$tRes2 = TaskDav::put($familyId, $me, $tTaskId, $tBody);
|
||||
$tRes2 = TaskDav::put($familyId, $me, $tCatId, $tTaskId, $tBody);
|
||||
} catch (RuntimeException $e) {
|
||||
dav_fail(403, $e->getMessage());
|
||||
}
|
||||
@@ -901,10 +941,9 @@ if ($method === 'PUT') {
|
||||
if (!$tSaved) {
|
||||
dav_fail(500, '写入后读取失败。');
|
||||
}
|
||||
$tRes3 = TaskDav::resourceOf($tSaved);
|
||||
$tNewHref = TaskDav::BOOK_HREF . TaskDav::segmentOf($tSaved) . '.ics';
|
||||
$tRes3 = TaskDav::resourceOf($tSaved, $tBook['name']);
|
||||
$tNewHref = $tBook['href'] . TaskDav::segmentOf($tSaved) . '.ics';
|
||||
|
||||
// 新建 → 201 Created + Location;更新 → 200/204
|
||||
http_response_code($tRes2['created'] ? 201 : 204);
|
||||
header('ETag: ' . $tRes3['etag']);
|
||||
if ($tRes2['created']) {
|
||||
@@ -1106,9 +1145,14 @@ if ($method === 'PUT') {
|
||||
// 所以这一句 DELETE 会把它们一并带走,不需要额外清理。
|
||||
if ($method === 'DELETE') {
|
||||
if (TaskDav::isTaskPath($path)) {
|
||||
$tTaskId = TaskDav::idOfSegment((string)preg_replace('/\.ics$/', '', $evSeg));
|
||||
$tHit = TaskDav::parse($path);
|
||||
$tBook = TaskDav::resolveBook($familyId, $me, (string)($tHit['seg'] ?? ''));
|
||||
if ($tBook === null) {
|
||||
dav_fail(404, '没有这本待办。');
|
||||
}
|
||||
$tTaskId = TaskDav::idOfSegment((string)($tHit['sub'] ?? ''));
|
||||
if ($tTaskId <= 0) {
|
||||
dav_fail(400, '要指定是哪一条待办(如 /dav/tasks.ics/task-5.ics)。');
|
||||
dav_fail(400, '要指定是哪一条待办(如 /dav/tasks-3.ics/task-5.ics)。');
|
||||
}
|
||||
if (!TaskDav::delete($familyId, $me, $tTaskId)) {
|
||||
dav_fail(404, '没有这条待办,或你没有删除权限。');
|
||||
|
||||
Reference in New Issue
Block a user