Files
FamilyBook/app/Ics.php
T
laoyang ef574d82a4 v51b DAV 例外:时区换算 + RECURRENCE-ID/EXDATE/多 VEVENT 不再被丢
根因三处(此前一个资源只能有一个 VEVENT、只认前 9 个字段):
 · Ics::normalizeVevent() 固定 9 字段,没有 recurrence_id/exdate;
 · dav.php 只取 $list[0](覆盖行全丢);
 · calendar_events 没有对应列。

修法:
 · 时区 —— 带 Z / TZID 的值按 zone 换算成本地墙钟,浮动值不动;
   UNTIL 一起归一(必须与 DTSTART 同值类型/同基准);DTSTAMP/CREATED/
   LAST-MODIFIED 仍按 RFC 保留 UTC。
 · 例外 —— 新增 calendar_event_overrides 卫星表(uk(event_id,recurrence_id),
   FK CASCADE)存覆盖行;EXDATE 落既有的 calendar_event_skips(与网页端
   「只删这一次」共用);PUT 的例外语义 = 权威替换。
 · 未建模属性(X-* 等)落 calendar_events.extra 原样回吐,
   表单保存不认识时不动它(防网页编辑抹掉客户端的 X-*)。
 · 「RECURRENCE-ID + STATUS:CANCELLED」折算成 EXDATE(RFC 5545 §3.8.4.4
   与 EXDATE 等价),并保留"正文里只有覆盖行"的退化路径。
 · ETag / ctag 必须含例外,否则只改「某一次」别的设备永远拉不到。
 · 网页月历 month() 认覆盖行(按新时间画、原时间不留影、不被 EXDATE 那天画)。

Schema 33 → 34(VERSION 人读 + fingerprint() 才是真正判据)。

验证:Ics 离线单测 30/30;端到端 DAV 例外探针 56/56;v45 既有回归 24/24。
反证:退回旧行为 → 6 条断言变红(网页上"已取消"的那次仍被画出来)。
2026-10-15 20:19:09 +08:00

769 lines
34 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 文本。
*
* @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;
}
/**
* 把解析出来的属性归一成我们要落库的形状。
*
* 🔴 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;
}
}