`,所以这里拼 `task-` 正好对上同一本里那个资源的 UID。 ⚠️ 各客户端的支持度: · DAVx⁵ + Tasks.org:认,显示成缩进的子项 · 苹果提醒事项:认自己的 parent 语义,对 RELATED-TO 支持有限 · Thunderbird:完全不显示层级,两条并列 这是客户端能力差异,服务端无法改变 —— 数据是正确的。 */ if ($parentId > 0) { $out[] = 'RELATED-TO:task-' . $parentId; /* 展示用的补充属性:值 = 父标题。 支持的客户端能直接显示"上级是谁",不支持的无视。 用 X- 前缀是因为这是本服务特有的,不进标准语义。 */ if (($t['parent_title'] ?? '') !== '') { $out[] = 'X-FAMILYBOOK-PARENT:' . self::escapeText((string)$t['parent_title']); } } $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 */ 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 每条的字段: * 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; } }