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 条断言变红(网页上"已取消"的那次仍被画出来)。
This commit is contained in:
+348
-14
@@ -15,6 +15,17 @@
|
||||
*
|
||||
* 时区:系统里存的一律是**本地时间**(不带时区),导出也不带 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);
|
||||
@@ -26,6 +37,26 @@ 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 文本。
|
||||
*
|
||||
@@ -44,6 +75,15 @@ final class Ics
|
||||
];
|
||||
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)
|
||||
@@ -100,9 +140,42 @@ final class Ics
|
||||
这条分支一直没被走到(本轮为了验 PUT 重复日程才第一次踩到)。 */
|
||||
/* ⚠️ 值类型是 **RECUR**,不是 TEXT —— 里面的逗号是**结构分隔符**
|
||||
(如 `BYDAY=MO,TU,WE,TH,FR`),**绝不能**用 escapeText 转义,
|
||||
否则客户端会把它当成一整块而解析失败。原样拼接即可。 */
|
||||
$out[] = 'RRULE:' . (string)$e['rrule'];
|
||||
否则客户端会把它当成一整块而解析失败。原样拼接即可。
|
||||
⚠️ `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);
|
||||
@@ -131,6 +204,86 @@ final class Ics
|
||||
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。
|
||||
*
|
||||
@@ -184,6 +337,20 @@ final class Ics
|
||||
}
|
||||
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;
|
||||
@@ -195,7 +362,17 @@ final class Ics
|
||||
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'] ?? '');
|
||||
@@ -205,9 +382,11 @@ final class Ics
|
||||
$allDay = stripos($params, 'VALUE=DATE') !== false
|
||||
|| preg_match('/^\d{8}$/', $startRaw) === 1;
|
||||
|
||||
$starts = $allDay ? self::fromDateValue($startRaw) : self::fromDateTimeValue($startRaw);
|
||||
$starts = $allDay ? self::fromDateValue($startRaw) : self::fromDateTimeValue($startRaw, $params);
|
||||
$ends = $endRaw !== ''
|
||||
? ($allDay ? self::fromDateValue($endRaw) : self::fromDateTimeValue($endRaw))
|
||||
? ($allDay
|
||||
? self::fromDateValue($endRaw)
|
||||
: self::fromDateTimeValue($endRaw, (string)($p['DTEND_PARAMS'] ?? '')))
|
||||
: null;
|
||||
// 全天日程的 DTEND 是**排他**的第二天,减回一天才对得上"到哪天为止"
|
||||
if ($allDay && $ends !== null && $starts !== null) {
|
||||
@@ -215,6 +394,13 @@ final class Ics
|
||||
$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'] ?? '')),
|
||||
@@ -223,12 +409,158 @@ final class Ics
|
||||
'all_day' => $allDay,
|
||||
'location' => self::nullOr(self::unescapeText((string)($p['LOCATION'] ?? ''))),
|
||||
'note' => self::nullOr(self::unescapeText((string)($p['DESCRIPTION'] ?? ''))),
|
||||
'rrule' => self::nullOr((string)($p['RRULE'] ?? '')),
|
||||
// ⚠️ 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
|
||||
{
|
||||
@@ -289,15 +621,17 @@ final class Ics
|
||||
return date('Ymd', $t);
|
||||
}
|
||||
|
||||
/** '20261016T143000' / '2026-10-16 14:30:00' → '2026-10-16 14:30:00' */
|
||||
private static function fromDateTimeValue(string $v): ?string
|
||||
/**
|
||||
* '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
|
||||
{
|
||||
$v = trim($v);
|
||||
if (preg_match('/^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})Z?$/', $v, $m)) {
|
||||
return sprintf('%s-%s-%s %s:%s:%s', $m[1], $m[2], $m[3], $m[4], $m[5], $m[6]);
|
||||
}
|
||||
$t = strtotime($v);
|
||||
return $t === false ? null : date('Y-m-d H:i:s', $t);
|
||||
$p = self::localParts($v, $params);
|
||||
return $p === null ? null : $p[0] . ' ' . $p[1];
|
||||
}
|
||||
|
||||
/** '20261016' → '2026-10-16 00:00:00' */
|
||||
|
||||
Reference in New Issue
Block a user