*/ 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; } // 同名属性出现多次时保留第一个(RFC 里多数属性都是单值的) if (!array_key_exists($name, $cur)) { $cur[$name] = $value; } if (!array_key_exists($name . '_PARAMS', $cur)) { $cur[$name . '_PARAMS'] = $params; } } return $out; } /** 把解析出来的属性归一成我们要落库的形状 */ 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); $ends = $endRaw !== '' ? ($allDay ? self::fromDateValue($endRaw) : self::fromDateTimeValue($endRaw)) : null; // 全天日程的 DTEND 是**排他**的第二天,减回一天才对得上"到哪天为止" if ($allDay && $ends !== null && $starts !== null) { $d = strtotime($ends . ' -1 day'); $ends = $d ? date('Y-m-d', $d) . ' 00:00:00' : null; } 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' => self::nullOr((string)($p['RRULE'] ?? '')), 'status' => strtolower((string)($p['STATUS'] ?? '')) ?: 'confirmed', 'reminder_minutes' => self::triggerMinutes((string)($p['_trigger'] ?? '')), ]; } /** 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' */ private static function fromDateTimeValue(string $v): ?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); } /** '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) */ private static function stampOf($dt): string { $t = strtotime((string)$dt); if ($t === false) { $t = time(); } 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; } }