Files
FamilyBook/app/Ics.php
T

1076 lines
47 KiB
PHP
Raw Normal View History

<?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',
];
2026-11-02 15:29:36 +08:00
/**
* 把一个待办集合导出成 .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']);
}
/* ---- 标题 ----
🔴 2026-11-02 改:原来给子任务硬加「└ 」前缀。那会**污染用户数据** ——
客户端把标题写回来(PUT)时,前缀会被存进数据库,
用户的任务标题就凭空多了一坨。
层级改由 RELATED-TO 表达(标准属性),
另给 X-FAMILYBOOK-PARENT 做展示用的补充,不碰 SUMMARY。 */
2026-11-02 15:29:36 +08:00
$title = (string)($t['title'] ?? '');
$out[] = 'SUMMARY:' . self::escapeText($title);
$parentId = (int)($t['parent_id'] ?? 0);
$note = (string)($t['note'] ?? '');
/* 跨本父子:客户端会看到 RELATED-TO 指向一个本里不存在的 UID,
部分客户端会**直接不显示这条**(当成孤立子项)。
补一句人话说明,让用户知道这是"父在别的分类里",不是数据损坏。 */
if (!empty($t['parent_in_other_book']) && ($t['parent_title'] ?? '') !== '') {
$hint = '(此任务的上级「' . (string)$t['parent_title']
. '」在另一个分类里,网页端可以看到两级的完整关系)';
$note = $note === '' ? $hint : $note . "\n\n" . $hint;
}
if ($note !== '') {
$out[] = 'DESCRIPTION:' . self::escapeText($note);
2026-11-02 15:29:36 +08:00
}
/* ---- 截止时间 ----
库里 `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);
}
/* ---- 父子关系 ----
`RELATED-TO` 是 RFC 5545 §3.8.4.5 的标准属性,值是**父资源的 UID**。
本模块的 UID 规范是 `task-<id>`,所以这里拼 `task-<parentId>`
正好对上同一本里那个资源的 UID。
⚠️ 各客户端的支持度:
· DAVx⁵ + Tasks.org:认,显示成缩进的子项
· 苹果提醒事项:认自己的 parent 语义,对 RELATED-TO 支持有限
· Thunderbird:完全不显示层级,两条并列
这是客户端能力差异,服务端无法改变 —— 数据是正确的。 */
2026-11-02 15:29:36 +08:00
if ($parentId > 0) {
$out[] = 'RELATED-TO:task-' . $parentId;
/* 展示用的补充属性:值 = 父标题。
支持的客户端能直接显示"上级是谁",不支持的无视。
用 X- 前缀是因为这是本服务特有的,不进标准语义。 */
if (($t['parent_title'] ?? '') !== '') {
$out[] = 'X-FAMILYBOOK-PARENT:' . self::escapeText((string)$t['parent_title']);
}
2026-11-02 15:29:36 +08:00
}
$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;
}
2026-11-02 15:29:36 +08:00
/**
* 解析 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;
}
}