Files
FamilyBook/app/Ics.php
T
laoyang c86658966c 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 再验
(本次踩过:改了没上传,拿旧代码验出假结果)。
2026-11-02 15:29:36 +08:00

1050 lines
45 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* iCalendar(RFC 5545)的读与写 —— 日程的 ics 导入 / 导出(用户 2026-10-02 第 7 条)。
*
* 为什么自己写而不引一个库:
* 这个应用是「零依赖」的(连 Composer 都没有),为两个函数拖进一个依赖不划算;
* 而且我们只需要 VEVENT 的一个常用子集,自己写反而能把"哪些字段往返不丢"说清楚。
*
* 三条必须遵守的 RFC 规则(踩过就懂):
* ① **行要折**:一行超过 75 个**字节**(不是字符!中文一个字 3 字节)就必须折,
* 折行用 CRLF + 一个空格开头。不折的话 iOS 日历会整条拒收。
* ② **文本要转义**:\ ; , 和换行各有转义写法,不转义会把一行正文切成好几条属性。
* ③ **全天日程的 DTSTART 是 DATE 值**(20261015),不是 DATETIME ——
* 写成 DATETIME 的话,客户端会当成"这一天的 00:00",看着一样但跨时区会错。
*
* 时区:系统里存的一律是**本地时间**(不带时区),导出也不带 Z、不写 VTIMEZONE。
* 家庭应用里"几点开会"就是墙上那个钟,硬套 UTC 反而会整体偏移。
*
* 🔴🔴 v52b(2026-10-15)修的真 bug:**入库方向原来只"删 Z",不做换算**。
* `fromDateTimeValue()` 的正则写作 `/…Z?$/`,`Z` 被吃掉之后数值原样落库 ⇒
* 客户端按 RFC 发的 `DTSTART:20261020T060000Z`(UTC 06:00)被当成**本地 06:00**
* 存下来,阅读端就少 8 小时(SyncCalendar 报的正是这个)。
* ⇒ 现在:带 `Z` 的按 UTC 换算到本地;带 `;TZID=` 的按那个时区换算;
* 两者都没有的**仍按本地原样**(那才是"墙上那个钟",不能动)。
* ⚠️ 同理,`RRULE` 里的 `UNTIL=…Z` 也必须换算成本地并去掉 `Z` ——
* RFC 5545 §3.3.10 要求 `UNTIL` 与 `DTSTART` 是**同一个值类型/同一套时间基准**,
* 一个 UTC 一个本地会整体错几个小时(表现是"重复提前结束/多一次")。
* 自研客户端 SyncCalendar 0.0.8 起已改发浮动本地时间,与这里口径一致。
*/
declare(strict_types=1);
namespace App;
final class Ics
{
/** 导出的产品标识(RFC 5545 PRODID,客户端会拿它判断"是谁生成的") */
private const PRODID = '-//FamilyBook//FamilyBook Calendar//CN';
/**
* 我们**建模**(有对应数据库列)的 VEVENT 属性。
*
* 🔴 v52b(2026-10-15 用户:「既然发现了就直接全部修了」):此前的 parser 只认这几个,
* **其余属性一律丢** —— 实测客户端 PUT 一份带 `X-` 自定义属性的 ICS,
* 读回来一个都不剩。虽然我们不需要理解它们,但**丢掉就是丢数据**:
* · `RDATE`(额外发生日)丢 = 系列少几次;
* · 别人加的 `X-`(如 `X-APPLE-STRUCTURED-LOCATION`)丢 = 位置信息没了;
* · 第三方客户端下次拉回来发现"我写的东西不见了",有些会改判定为冲突。
* ⇒ 未被建模的属性**原样收进 `extra`**(见 parse),导出时原样吐回。
*
* ⚠️ `EXDATE` / `RECURRENCE-ID` 单独处理,不在这条"原样带过"的路里
* (它们要落成 skips 表与覆盖行,见 §重复日程的例外)。
*/
private const MODELED = [
'UID', 'DTSTAMP', 'DTSTART', 'DTEND', 'SUMMARY', 'LOCATION', 'DESCRIPTION',
'RRULE', 'STATUS', 'TRANSP', 'SEQUENCE', 'CREATED', 'LAST-MODIFIED',
'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 文本。
*
* @param array $events calendar_events 的行
* @param string $calName 日历本名字(X-WR-CALNAME,客户端用它给订阅起标题)
*/
public static function export(array $events, string $calName = '日程'): string
{
$out = [
'BEGIN:VCALENDAR',
'VERSION:2.0',
'PRODID:' . self::PRODID,
'CALSCALE:GREGORIAN',
'METHOD:PUBLISH',
'X-WR-CALNAME:' . self::escapeText($calName),
];
foreach ($events as $e) {
$out = array_merge($out, self::vevent($e));
/* 🔴🔴 v52b:「重复系列里被改掉的某一次」必须与主事件写在**同一个资源**里
(CalDAV 里"一个重复系列 = 一个 .ics 文件",RFC 4791 §4.1)。
原来这里只吐主事件 ⇒ 客户端推上来的覆盖行**读不回去**,
手机上"把周三那次改到周五"在下次同步后凭空消失。
⚠️ 顺序:主事件在前、覆盖行在后(个别客户端按出现顺序建索引)。
⚠️ 覆盖行的 UID 与主事件**必须相同** —— 那是它们属于同一系列的凭据。 */
foreach ((array)($e['_overrides'] ?? []) as $o) {
$out = array_merge($out, self::overrideVevent($e, $o));
}
}
$out[] = 'END:VCALENDAR';
// 折行 + 统一 CRLF(RFC 5545 要求行尾是 CRLF,虽然多数客户端也收 LF)
$lines = [];
foreach ($out as $line) {
foreach (self::fold($line) as $l) {
$lines[] = $l;
}
}
return implode("\r\n", $lines) . "\r\n";
}
/** 一条日程 → VEVENT 的若干行 */
private static function vevent(array $e): array
{
$allDay = !empty($e['all_day']);
$starts = (string)($e['starts_at'] ?? '');
$ends = (string)($e['ends_at'] ?? '');
$uid = (string)($e['uid'] ?? '');
if ($uid === '') {
$uid = Calendar::newUid();
}
$stamp = self::stampOf($e['updated_at'] ?? $e['created_at'] ?? null);
$out = ['BEGIN:VEVENT', 'UID:' . $uid, 'DTSTAMP:' . $stamp];
if ($allDay) {
// 全天:VALUE=DATE,且 DTEND 是**第二天**(RFC:DTEND 是排他边界)
$out[] = 'DTSTART;VALUE=DATE:' . self::dateValue($starts);
$d = self::dateValue($ends !== '' ? $ends : $starts);
$out[] = 'DTEND;VALUE=DATE:' . ($ends !== '' ? self::nextDay($d) : self::nextDay($d));
} else {
$out[] = 'DTSTART:' . self::dateTimeValue($starts);
// 没有结束时刻就按开始时刻算(RFC 允许只给 DTSTART + DURATION,
// 但很多客户端对 DURATION 支持得更差,给一个等于开始的 DTEND 最稳)
$out[] = 'DTEND:' . self::dateTimeValue($ends !== '' ? $ends : $starts);
}
$out[] = 'SUMMARY:' . self::escapeText((string)($e['title'] ?? ''));
if (($e['location'] ?? '') !== '') {
$out[] = 'LOCATION:' . self::escapeText((string)$e['location']);
}
if (($e['note'] ?? '') !== '') {
$out[] = 'DESCRIPTION:' . self::escapeText((string)$e['note']);
}
if (($e['rrule'] ?? '') !== '') {
/* 🔴🔴 v45(2026-10-12)修的真 bug:这里原来**漏了 `RRULE:` 前缀**,
直接吐 `$e['rrule']`(库里存的是**去掉前缀**的值,见 parse 的
`'rrule' => self::nullOr($p['RRULE'])`)⇒ 导出的 ICS 里那一行是
`FREQ=WEEKLY;UNTIL=20261231T235959Z` —— 一个**没有属性名**的裸行,
任何客户端都不会把它当成重复规则(RFC 5545 要求 `RRULE:...`)。
后果:所有导出/CalDAV 同步出去的重复日程,在别人那里**都变成单次**。
为什么以前没被发现:夹具与线上导出的都是"不带重复"的日程,
这条分支一直没被走到(本轮为了验 PUT 重复日程才第一次踩到)。 */
/* ⚠️ 值类型是 **RECUR**,不是 TEXT —— 里面的逗号是**结构分隔符**
(如 `BYDAY=MO,TU,WE,TH,FR`),**绝不能**用 escapeText 转义,
否则客户端会把它当成一整块而解析失败。原样拼接即可。
⚠️ `rruleLocal()` 只动 `UNTIL=` 那一小段(把它掰成本地形态),
其余一字不改;对已经是对的规则是**幂等**的。 */
$out[] = 'RRULE:' . self::rruleLocal((string)$e['rrule'], $allDay);
}
/* ---- 重复系列的"例外日"(RFC 5545 EXDATE)----
服务器把"这一次被取消 / 被挪走"存在 `calendar_event_skips`(与网页端
"只删这一次"共用同一张表),导出时必须还原成 EXDATE —— 否则客户端
下次拉回来会把那一次**又画上**(用户看到的怪相:"我在手机上删了它,
过一会儿它自己回来了",SyncCalendar 那边叫幽灵日程)。
⚠️ 值必须与 DTSTART **同形态**:全天系列给 `EXDATE;VALUE=DATE:20261022`,
计时系列给 `EXDATE:20261022T090000`(时刻取主事件的,因为"哪一次"
是按时钟定的)。形态不一对,客户端会静默忽略整行。 */
$skipDates = (array)($e['_skip_dates'] ?? []);
if ($skipDates && trim((string)($e['rrule'] ?? '')) !== '') {
$vals = [];
foreach ($skipDates as $sd) {
$one = self::exdateValue((string)$sd, (string)$starts, $allDay);
if ($one !== '') {
$vals[] = $one;
}
}
if ($vals) {
$out[] = 'EXDATE' . ($allDay ? ';VALUE=DATE' : '') . ':' . implode(',', $vals);
}
}
/* ---- 未建模的属性原样带回(X-* / RDATE / CATEGORIES…)----
我们**不解释**它们,但必须让它们活过一轮 PUT→GET —— 否则第三方客户端
会发现"我写上去的东西不见了",轻则丢信息(如苹果的结构化位置),
重则判定同步冲突。 */
foreach (self::extraLines($e) as $l) {
$out[] = $l;
}
$out[] = 'STATUS:' . strtoupper((string)($e['status'] ?: 'confirmed'));
$out[] = 'TRANSP:' . (empty($e['transp']) ? 'OPAQUE' : 'TRANSPARENT');
$out[] = 'SEQUENCE:' . (int)($e['sequence'] ?? 0);
if (!empty($e['created_at'])) {
$out[] = 'CREATED:' . self::stampOf($e['created_at']);
}
if (!empty($e['updated_at'])) {
$out[] = 'LAST-MODIFIED:' . self::stampOf($e['updated_at']);
}
// 提醒 = VALARM(TRIGGER 用**负**持续时间表示"提前多久")。
// ⚠️ v24 第 20 批 第 8 条:一个日程可以有多档提醒 → **导出多个 VALARM**。
// RFC 5545 允许一个 VEVENT 带任意多个 VALARM,客户端会各响一次;
// 所以这里按 calendar_events.reminder_minutes 里的逗号串逐个吐。
// 老数据(只存了一个数字)走同一条路,导出的还是那个单一 VALARM。
foreach (\App\Calendar::reminderList($e['reminder_minutes'] ?? null) as $rvMin) {
if ($rvMin <= 0) { continue; }
$out = array_merge($out, [
'BEGIN:VALARM',
'ACTION:DISPLAY',
'DESCRIPTION:' . self::escapeText((string)($e['title'] ?? '提醒')),
'TRIGGER:-' . self::duration($rvMin),
'END:VALARM',
]);
}
$out[] = 'END:VEVENT';
return $out;
}
/**
* 一条**覆盖行**(重复系列里被改写的那一次)→ VEVENT 的若干行。
*
* RFC 5545 §3.8.4.4 / RFC 4791 §4.1:覆盖行 = 与主事件**同 UID** 的第二个 VEVENT,
* 用 `RECURRENCE-ID`(= **原来的**那次发生时刻)指明它替换的是哪一次,
* 而它自己的 `DTSTART` 是**改到的新时间**。这就是客户端"仅这一次改到别的时间"
* 在协议上的标准表达(Google / 苹果 / 雷鸟都在用)。
*
* 🔴🔴 v52b(2026-10-15)新增。在这之前服务器把覆盖行当普通日程吞掉,
* 于是"改某一次"永远存不下来。
*
* ⚠️ 三个"必须":
* · `UID` 与主事件**相同**(不同 UID = 变成两条不相干的日程);
* · `RECURRENCE-ID` 值与主事件的 `DTSTART` **同形态**(全天给 `VALUE=DATE`);
* · **不写 `RRULE`** —— 重复规则只属于主事件,覆盖行带 RRULE 会让
* 部分客户端把它展开成一整个新系列("改一次"变成"多出一整串")。
*/
private static function overrideVevent(array $master, array $o): array
{
$uid = (string)($master['uid'] ?? '');
if ($uid === '') {
$uid = Calendar::newUid();
}
$rid = (string)($o['recurrence_id'] ?? '');
$allDay = !empty($o['all_day']);
$starts = (string)($o['starts_at'] ?? '');
$ends = (string)($o['ends_at'] ?? '');
$stamp = self::stampOf($o['updated_at'] ?? $o['created_at'] ?? $master['updated_at'] ?? null);
$out = ['BEGIN:VEVENT', 'UID:' . $uid, 'DTSTAMP:' . $stamp];
if ($rid !== '') {
$out[] = 'RECURRENCE-ID' . ($allDay ? ';VALUE=DATE' : '') . ':' . $rid;
}
if ($allDay) {
$d = self::dateValue($starts);
$out[] = 'DTSTART;VALUE=DATE:' . $d;
$out[] = 'DTEND;VALUE=DATE:' . self::nextDay($d);
} else {
$out[] = 'DTSTART:' . self::dateTimeValue($starts);
$out[] = 'DTEND:' . self::dateTimeValue($ends !== '' ? $ends : $starts);
}
$out[] = 'SUMMARY:' . self::escapeText((string)($o['title'] ?? ''));
if (($o['location'] ?? '') !== '') {
$out[] = 'LOCATION:' . self::escapeText((string)$o['location']);
}
if (($o['note'] ?? '') !== '') {
$out[] = 'DESCRIPTION:' . self::escapeText((string)$o['note']);
}
foreach (self::extraLines($o) as $l) {
$out[] = $l;
}
$out[] = 'STATUS:' . strtoupper((string)($o['status'] ?: 'confirmed'));
$out[] = 'TRANSP:OPAQUE';
$out[] = 'SEQUENCE:' . (int)($o['sequence'] ?? 0);
$out[] = 'END:VEVENT';
return $out;
}
/**
* 被排除的那一天 → `EXDATE` 的值(与 `DTSTART` 同形态)。
*
* @param string $dateYmd '2026-10-22'
* @param string $masterStarts 主事件的 starts_at('Y-m-d H:i:s',供取时刻)
*/
private static function exdateValue(string $dateYmd, string $masterStarts, bool $allDay): string
{
$d = str_replace('-', '', substr(trim($dateYmd), 0, 10));
if (strlen($d) !== 8) {
return '';
}
if ($allDay) {
return $d;
}
$t = substr($masterStarts, 11, 8);
if (strlen($t) !== 8) {
$t = '00:00:00';
}
return $d . 'T' . str_replace(':', '', $t);
}
/**
* 解析一个 .ics 文本,返回里面的 VEVENT。
*
* @return array<int, array{uid:string,title:string,starts_at:string,ends_at:string,
* all_day:bool,location:?string,note:?string,
* reminder_minutes:?int,status:string,rrule:?string}>
*/
public static function parse(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;
$inAlarm = false;
foreach ($lines as $raw) {
$line = trim($raw);
if ($line === '') {
continue;
}
if ($line === 'BEGIN:VEVENT') {
$cur = [];
$inAlarm = false;
continue;
}
if ($line === 'END:VEVENT') {
if ($cur !== null) {
$out[] = self::normalizeVevent($cur);
}
$cur = null;
$inAlarm = false;
continue;
}
if ($cur === null) {
continue; // VCALENDAR / VTIMEZONE 里的东西一律跳过
}
if ($line === 'BEGIN:VALARM') { $inAlarm = true; continue; }
if ($line === 'END:VALARM') { $inAlarm = false; continue; }
[$name, $params, $value] = self::splitProp($line);
if ($name === '') {
continue;
}
// VALARM 里只认 TRIGGER(提前多久);DESCRIPTION 那些是给闹钟弹窗用的,
// 混进来会覆盖日程本身的备注(实测:iOS 导出的 VALARM 里也带 DESCRIPTION)。
if ($inAlarm) {
if ($name === 'TRIGGER') {
$cur['_trigger'] = $value;
}
continue;
}
/* 「重复日程的例外」必须**逐个**收,不能走下面"同名只留第一个"那条路:
· `EXDATE` 可以写**多行**、每行还可以**逗号分隔**多个值
(Google / 苹果导出的就是多行)。只留第一个 = 少排除好几次。
· 未被建模的属性同理,同一个名字可能出现多次(如两个 `X-`)。 */
if ($name === 'EXDATE') {
$cur['_exdate'][] = ['v' => $value, 'p' => $params];
continue;
}
if (!in_array($name, self::MODELED, true)) {
// ⚠️ 存**拆折行之后**的单行(parse 开头已经反折行过)——
// 导出时 export() 会统一重新折行,这里再折一次会折出双倍缩进。
$cur['_extra'][] = $line;
continue;
}
// 同名属性出现多次时保留第一个(RFC 里多数属性都是单值的)
if (!array_key_exists($name, $cur)) {
$cur[$name] = $value;
}
if (!array_key_exists($name . '_PARAMS', $cur)) {
$cur[$name . '_PARAMS'] = $params;
}
}
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'] ?? '')),
];
}
/**
* 把解析出来的属性归一成我们要落库的形状。
*
* 🔴 v52b(2026-10-15):补三样原来**根本没有**的东西 ——
* `recurrence_id`(这一条是重复系列里的哪一次)、
* `skip_dates`(这一条自己排除了哪些天)、
* `extra`(未建模属性原样带过)。
* 缺 `recurrence_id` 的后果最重:客户端把"被改到别的时间的某一次"作为
* **第二个 VEVENT** 推上来时,它会被当成一条**普通日程**,
* 与主事件撞同一个 UID ⇒ 要么被 UID 判重吃掉、要么变成两条(幽灵日程)。
*/
private static function normalizeVevent(array $p): array
{
$startRaw = (string)($p['DTSTART'] ?? '');
$endRaw = (string)($p['DTEND'] ?? '');
$params = (string)($p['DTSTART_PARAMS'] ?? '');
// 全天:VALUE=DATE,或者值本身就是 8 位的 YYYYMMDD
$allDay = stripos($params, 'VALUE=DATE') !== false
|| preg_match('/^\d{8}$/', $startRaw) === 1;
$starts = $allDay ? self::fromDateValue($startRaw) : self::fromDateTimeValue($startRaw, $params);
$ends = $endRaw !== ''
? ($allDay
? self::fromDateValue($endRaw)
: self::fromDateTimeValue($endRaw, (string)($p['DTEND_PARAMS'] ?? '')))
: null;
// 全天日程的 DTEND 是**排他**的第二天,减回一天才对得上"到哪天为止"
if ($allDay && $ends !== null && $starts !== null) {
$d = strtotime($ends . ' -1 day');
$ends = $d ? date('Y-m-d', $d) . ' 00:00:00' : null;
}
// 重复系列里的"哪一次"(RFC 5545 RECURRENCE-ID)。统一转成**本地浮动**形态,
// 与 DTSTART 同基准 —— 否则客户端下轮拿它去比对会发生"对不上"。
$ridRaw = trim((string)($p['RECURRENCE-ID'] ?? ''));
$rid = $ridRaw === ''
? ''
: self::canonicalLocalIcs($ridRaw, (string)($p['RECURRENCE-ID_PARAMS'] ?? ''), $allDay);
return [
'uid' => (string)($p['UID'] ?? ''),
'title' => self::unescapeText((string)($p['SUMMARY'] ?? '')),
'starts_at' => $starts,
'ends_at' => $ends,
'all_day' => $allDay,
'location' => self::nullOr(self::unescapeText((string)($p['LOCATION'] ?? ''))),
'note' => self::nullOr(self::unescapeText((string)($p['DESCRIPTION'] ?? ''))),
// ⚠️ RRULE 存**去掉前缀**的原文(导出侧补 `RRULE:`),
// 但 `UNTIL` 要换算成本地并与 DTSTART 同形态,见 rruleLocal()。
'rrule' => self::nullOr(self::rruleLocal((string)($p['RRULE'] ?? ''), $allDay)),
'status' => strtolower((string)($p['STATUS'] ?? '')) ?: 'confirmed',
'reminder_minutes' => self::triggerMinutes((string)($p['_trigger'] ?? '')),
'recurrence_id' => $rid,
'skip_dates' => self::exdateDates($p['_exdate'] ?? []),
'extra' => implode("\n", (array)($p['_extra'] ?? [])),
];
}
/**
* EXDATE 的值 → 被排除的那几天(`Y-m-d`)。
*
* ⚠️ **只取日期粒度**:服务器把"某一次例外"存在 `calendar_event_skips.skip_date`
* (DATE 列,与网页端"只删这一次"共用同一张表),所以落库时天以下是多余精度。
* 重复日程在家庭场景一天最多发生一次(没有 HOURLY 规则),够用。
* ⚠️ 带 `TZID` / 结尾 `Z` 的值要**先换算到本地再取日期**:UTC 23:00 的排除
* 在本地可能是**第二天**,直接截前 8 位会排错一天。
*/
private static function exdateDates(array $list): array
{
$out = [];
foreach ($list as $one) {
$params = (string)($one['p'] ?? '');
foreach (explode(',', (string)($one['v'] ?? '')) as $v) {
$v = trim($v);
if ($v === '') {
continue;
}
$parts = self::localParts($v, $params);
$day = $parts !== null ? $parts[0] : '';
if ($day !== '') {
$out[$day] = true;
}
}
}
return array_keys($out);
}
/**
* ICS 时间值 → **本地墙钟**的 [日期, 时刻]。
*
* 🔴🔴 v52b(2026-10-15)修的就是这里:原来"看到 `Z` 就当没看见",
* 直接把数值原样落库 ⇒ 客户端发 UTC 06:00、服务器存成本地 06:00,
* 阅读端少 8 小时。现在三种情况分开处理:
* · 结尾 `Z` → 按 UTC 换算到本地;
* · 有 `TZID=xxx` → 按该时区换算到本地(时区名不认识则退回原样);
* · 两者都没有 → **原样**(浮动时间=本地墙钟,这是本系统的约定,不能动)。
*
* @param string $v ICS 原始值(`20261020T060000Z` / `20261020` / `20261020T160000`)
* @param string $params 该属性的参数串(可能含 `TZID=Asia/Shanghai`)
* @return array{0:string,1:string}|null [Y-m-d, H:i:s];无法解析时 null
*/
private static function localParts(string $v, string $params = ''): ?array
{
$v = trim($v);
if ($v === '') {
return null;
}
// 日期值(全天):没有时刻,也没有时区可言
if (preg_match('/^(\d{4})(\d{2})(\d{2})$/', $v, $m)) {
return [$m[1] . '-' . $m[2] . '-' . $m[3], '00:00:00'];
}
if (!preg_match('/^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})(Z?)$/', $v, $m)) {
// 不是紧凑形态(极少数客户端写 `2026-10-20 06:00:00`)→ 交给 strtotime
$t = strtotime($v);
return $t === false ? null : [date('Y-m-d', $t), date('H:i:s', $t)];
}
$raw = $m[1] . $m[2] . $m[3] . 'T' . $m[4] . $m[5] . $m[6];
$zone = null;
if ($m[7] === 'Z') {
$zone = new \DateTimeZone('UTC');
} elseif (preg_match('/(?:^|;)TZID=([^;:]+)/i', $params, $tz)) {
try {
$zone = new \DateTimeZone(str_replace('"', '', $tz[1]));
} catch (\Throwable $e) {
// 时区名不认识(拼错 / 山寨 VTIMEZONE)→ 当浮动时间,别让整条日程解析失败
$zone = null;
}
}
$dt = \DateTime::createFromFormat('Ymd\THis', $raw, $zone ?: null);
if ($dt === false) {
return null;
}
if ($zone !== null) {
$dt->setTimezone(new \DateTimeZone(date_default_timezone_get()));
}
return [$dt->format('Y-m-d'), $dt->format('H:i:s')];
}
/**
* ICS 时间值 → **本地浮动**的紧凑 ICS 形态(`20261020T160000` / `20261020`)。
* 用于 `RECURRENCE-ID`:它必须与 `DTSTART` 同一套基准,客户端才比对得上。
*/
private static function canonicalLocalIcs(string $v, string $params, bool $allDay): string
{
$p = self::localParts($v, $params);
if ($p === null) {
return '';
}
$ymd = str_replace('-', '', $p[0]);
return $allDay ? $ymd : $ymd . 'T' . str_replace(':', '', $p[1]);
}
/**
* `RRULE` 的 `UNTIL` 归一:换算成本地、去掉 `Z`,并与 `DTSTART` 同形态。
*
* RFC 5545 §3.3.10:`UNTIL` 的值类型必须与 `DTSTART` 一致 ——
* `DTSTART` 是本地浮动时,`UNTIL=…Z`(UTC)会让系列整体错几个小时
* (表现是"重复提前几天结束"或"多出一次")。服务器既然统一用本地墙钟,
* 就必须把进来的 UTC `UNTIL` 也掰成本地。
*
* ⚠️ 其余部分(`FREQ`/`COUNT`/`BYDAY`…)**一字不动**:那是 RECUR 值,
* 里面逗号是**结构分隔符**,绝不能当文本转义或重排。
* ⚠️ 幂等:`UNTIL` 已经不带 `Z` 时,走一遍结果不变。
*/
private static function rruleLocal(string $rrule, bool $allDay): string
{
$rrule = trim($rrule);
if ($rrule === '' || stripos($rrule, 'UNTIL=') === false) {
return $rrule;
}
return (string)preg_replace_callback(
'/UNTIL=([0-9]{8}(?:T[0-9]{6}Z?)?)/i',
static function (array $m) use ($allDay): string {
$ics = self::canonicalLocalIcs($m[1], '', $allDay);
return 'UNTIL=' . ($ics !== '' ? $ics : $m[1]);
},
$rrule
);
}
/** `extra` 字段(多行文本)→ 要原样写回的行数组 */
private static function extraLines(array $row): array
{
$raw = (string)($row['extra'] ?? '');
if (trim($raw) === '') {
return [];
}
$out = [];
foreach (preg_split('/\r\n|\r|\n/', $raw) ?: [] as $l) {
$l = trim($l);
if ($l !== '') {
$out[] = $l;
}
}
return $out;
}
/** TRIGGER:-PT15M / TRIGGER:-P1D / TRIGGER;VALUE=DATE-TIME:... → 分钟数 */
private static function triggerMinutes(string $v): ?int
{
$v = trim($v);
if ($v === '') {
return null;
}
// 绝大多数客户端导出的是"提前多久"的持续时间(负号开头)
if (preg_match('/^-?P(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?$/', $v, $m)) {
$mins = (int)($m[1] ?? 0) * 1440;
$mins += (int)($m[2] ?? 0) * 60;
$mins += (int)($m[3] ?? 0);
$mins += (int)($m[4] ?? 0) > 0 ? 1 : 0; // 有秒就进位到 1 分钟,别归零
return $mins > 0 ? $mins : null;
}
return null;
}
/** 分钟 → RFC 5545 的持续时间,如 90 → PT1H30M */
private static function duration(int $mins): string
{
$d = intdiv($mins, 1440);
$h = intdiv($mins % 1440, 60);
$m = $mins % 60;
$s = 'P';
if ($d > 0) {
$s .= $d . 'D';
}
if ($h > 0 || $m > 0) {
$s .= 'T';
if ($h > 0) {
$s .= $h . 'H';
}
if ($m > 0) {
$s .= $m . 'M';
}
}
return $s === 'P' ? 'PT0S' : $s;
}
/** '2026-10-16 14:30:00' → '20261016T143000' */
private static function dateTimeValue(string $dt): string
{
$t = strtotime($dt);
if ($t === false) {
return '19700101T000000';
}
return date('Ymd\THis', $t);
}
/** '2026-10-16 14:30:00' → '20261016' */
private static function dateValue(string $dt): string
{
$t = strtotime($dt);
if ($t === false) {
return '19700101';
}
return date('Ymd', $t);
}
/**
* '20261016T143000' / '2026-10-16 14:30:00' → '2026-10-16 14:30:00'(本地墙钟)。
*
* ⚠️ 第二参数(该属性的参数串)**必须传**:`;TZID=…` 就在里面。
* 不传的后果与"删 Z"是同一类静默错误 —— 带 TZID 的值会被当成浮动时间,
* 整条日程偏几个小时。见 localParts() 的说明。
*/
private static function fromDateTimeValue(string $v, string $params = ''): ?string
{
$p = self::localParts($v, $params);
return $p === null ? null : $p[0] . ' ' . $p[1];
}
/** '20261016' → '2026-10-16 00:00:00' */
private static function fromDateValue(string $v): ?string
{
if (preg_match('/^(\d{4})(\d{2})(\d{2})$/', trim($v), $m)) {
return sprintf('%s-%s-%s 00:00:00', $m[1], $m[2], $m[3]);
}
$t = strtotime($v);
return $t === false ? null : date('Y-m-d 00:00:00', $t);
}
/** '20261016' → '20261017' */
private static function nextDay(string $ymd): string
{
$t = strtotime(substr($ymd, 0, 4) . '-' . substr($ymd, 4, 2) . '-' . substr($ymd, 6, 2) . ' +1 day');
return $t === false ? $ymd : date('Ymd', $t);
}
/** 属性行 → [名, 参数串, 值](只在**第一个**未加引号的冒号处切) */
private static function splitProp(string $line): array
{
$pos = strpos($line, ':');
if ($pos === false) {
return ['', '', ''];
}
$left = substr($line, 0, $pos);
$value = substr($line, $pos + 1);
if (strpos($left, ';') === false) {
return [strtoupper($left), '', $value];
}
[$name, $params] = explode(';', $left, 2);
return [strtoupper($name), $params, $value];
}
/** DTSTAMP / CREATED 一律写成 UTC(RFC 要求带 Z) */
/**
* DTSTAMP / CREATED / LAST-MODIFIED 的时间戳。
*
* 🔴 v31 修的真 bug:原来"没给时间就取 time()"。而**派生条目**
* (观影 / 行程节点 / 纪念日 / 农历 / 节假日 / 节气)没有 updated_at、created_at,
* 于是每生成一次 ICS,文本里就写进一个新的"现在" ⇒ 正文变了 ⇒ ETag 跟着变。
* 实测线上同一本连续两次 REPORT,**每一条的 ETag 都不一样** ——
* 客户端的"增量同步"因此退化成"每轮全量重拉",而且永远不收敛
* (拉回来的 ETag 与它记下的对不上 → 认为服务器一直在变)。
*
* ⇒ 缺时间就写**固定值**:确定性优先。一个诚实的"不知道",
* 远比一个每次都变的"现在"对同步友好。
* ⚠️ 别改成"用 starts_at 当 DTSTAMP"——DTSTAMP 的语义是"这一条什么时候
* 被创建/最后修改",不是"这件事什么时候发生";改了会让客户端排序错乱。
*/
private static function stampOf($dt): string
{
$s = trim((string)$dt);
$t = $s === '' ? 0 : strtotime($s);
if ($t === false || $t <= 0) {
return '19700101T000000Z';
}
return gmdate('Ymd\THis\Z', $t);
}
/** 文本转义(RFC 5545 3.3.11) */
private static function escapeText(string $v): string
{
$v = str_replace('\\', '\\\\', $v);
$v = str_replace(';', '\;', $v);
$v = str_replace(',', '\,', $v);
$v = str_replace(["\r\n", "\r", "\n"], '\n', $v);
return $v;
}
private static function unescapeText(string $v): string
{
$v = str_replace('\n', "\n", $v);
$v = str_replace('\N', "\n", $v);
$v = str_replace('\,', ',', $v);
$v = str_replace('\;', ';', $v);
return str_replace('\\\\', '\\', $v);
}
private static function nullOr(string $v): ?string
{
$v = trim($v);
return $v === '' ? null : $v;
}
/**
* 折行:一行最多 75 **字节**,且不能在多字节字符中间切断。
*
* ⚠️ 按字节而不是按字符数:中文一个字 3 字节,按字符数切会切出半个 UTF-8 字,
* 客户端解出来就是乱码。续行以**一个空格**开头。
*/
private static function fold(string $line): array
{
if (strlen($line) <= 75) {
return [$line];
}
$out = [];
$cur = '';
$len = 0;
$limit = 75;
$bytes = strlen($line);
$i = 0;
while ($i < $bytes) {
// 一次取一个完整 UTF-8 字符
$ch = $line[$i];
$n = 1;
$o = ord($ch);
if ($o >= 0xF0) { $n = 4; }
elseif ($o >= 0xE0) { $n = 3; }
elseif ($o >= 0xC0) { $n = 2; }
$tok = substr($line, $i, $n);
$i += $n;
// 续行要以空格开头,所以后续每行实际只能放 74 字节
$room = $out === [] ? $limit : $limit - 1;
if ($len + $n > $room) {
$out[] = $cur;
$cur = '';
$len = 0;
}
$cur .= $tok;
$len += $n;
}
if ($cur !== '') {
$out[] = $cur;
}
// 续行补前导空格
$res = [];
foreach ($out as $k => $v) {
$res[] = $k === 0 ? $v : ' ' . $v;
}
return $res;
}
}