$people 每人一行(含 contacts 附件信息,见 rosterCards()) */ public static function export(array $people, string $bookName = '家庭名册'): string { $out = []; foreach ($people as $p) { $out[] = self::card($p); } // ⚠️ CRLF 行尾是规范要求(RFC 2426 3.3 明确写了 lines 以 CRLF 分隔)。 // 用 \n 的话,iOS 大多能忍,但 Outlook 那一路会整张卡读不出来。 return implode("\r\n", $out) . "\r\n"; } /** 一张卡(一个 BEGIN:VCARD … END:VCARD 块),已折行、已转义 */ public static function card(array $p): string { $name = trim((string)($p['name'] ?? '')); if ($name === '') { $name = '(未命名)'; // FN 必须非空,否则 iOS 会整张丢掉 } $lines = []; $lines[] = 'BEGIN:VCARD'; $lines[] = 'VERSION:3.0'; // ---- 姓名 ---------------------------------------------------- // ⚠️ UID 必须**稳定**:客户端靠它判断"这是新卡还是同一张卡改了"。 // 用 `person-@<域>`(不能用"姓名"——同名的人会互相覆盖, // 而且改名之后客户端会当成"删一张加一张")。 $lines[] = 'UID:' . self::esc(self::uid($p)); $lines[] = 'FN:' . self::esc($name); $lines[] = 'N:' . self::nField($name); if (!empty($p['nickname'])) { $lines[] = 'NICKNAME:' . self::esc((string)$p['nickname']); } // ---- 电话 ---------------------------------------------------- // ⚠️ 主手机号在 persons.phone_encrypted 里是**密文**(AES), // 调用方(CardDav::rows)已经解好放在 $p['phone'],这里只管写。 // 为什么不在这一层解密:导出类是"文本生成器",不该碰密钥; // 而且一处解密、两处用(vcf 与页面)才不会出现"页面看得到、同步看不到"。 $mainPhone = trim((string)($p['phone'] ?? '')); if ($mainPhone !== '') { $lines[] = 'TEL;TYPE=CELL:' . self::esc($mainPhone); } foreach (self::contactRows($p, 'phone') as $row) { $t = trim((string)($row['value'] ?? '')); if ($t === '' || $t === $mainPhone) { continue; // 与主手机号重复就不再写一遍(客户端会显示两条一样的) } $lines[] = self::telLine($t, (string)($row['label'] ?? '')); } // ---- 邮箱 ---------------------------------------------------- $mainMail = trim((string)($p['email'] ?? '')); if ($mainMail !== '') { $lines[] = 'EMAIL;TYPE=INTERNET:' . self::esc($mainMail); } foreach (self::contactRows($p, 'email') as $row) { $t = trim((string)($row['value'] ?? '')); if ($t === '' || $t === $mainMail) { continue; } $lines[] = self::mailLine($t, (string)($row['label'] ?? '')); } // ---- 网址 ---------------------------------------------------- foreach (self::contactRows($p, 'url') as $row) { $t = trim((string)($row['value'] ?? '')); if ($t !== '') { $lines[] = 'URL:' . self::esc($t); } } // ---- 地址 ---------------------------------------------------- // ⚠️ 主地址有两个来源:persons.address(自由文本,可能是"XX小区3号楼"这种 // 纯街道)与 person_contacts 里 kind=address 的行。两者都写会重复, // 所以:contacts 里没有 address 行时才写 persons.address。 $addrRows = self::contactRows($p, 'address'); foreach ($addrRows as $row) { $t = trim((string)($row['value'] ?? '')); if ($t !== '') { $lines[] = 'ADR;TYPE=HOME:' . self::adrField($t, (string)($p['province'] ?? '')); } } if (!$addrRows && trim((string)($p['address'] ?? '')) !== '') { $lines[] = 'ADR;TYPE=HOME:' . self::adrField((string)$p['address'], (string)($p['province'] ?? '')); } // ---- 生日 ---------------------------------------------------- // ⚠️ vCard 3.0 的 BDAY 允许 `YYYY-MM-DD`,但**不允许只有月日**(那要写 `--MM-DD`)。 // 没年份的人(只知道月日)按 3.0 的写法给 `--MM-DD`; // 两者都没有就整条不写 —— 写个空 BDAY: 反而会让 iOS 认成一个"无效日期"。 $bday = self::bday((string)($p['birthday'] ?? '')); if ($bday !== '') { $lines[] = 'BDAY:' . $bday; } // ---- 单位 / 职务 -------------------------------------------- if (trim((string)($p['org'] ?? '')) !== '') { $lines[] = 'ORG:' . self::esc((string)$p['org']); } if (trim((string)($p['title'] ?? '')) !== '') { $lines[] = 'TITLE:' . self::esc((string)$p['title']); } // ---- 与我的关系 / 备注 -------------------------------------- // ⚠️ 关系写进 CATEGORIES 而不是自己造一个 X- 字段: // iOS 通讯录会把 CATEGORIES 当成"群组/标签"显示,用户一眼能看出谁是家人。 // 写 X-FAMILY-RELATION 那种私有字段的话,手机上什么都看不到 // (导出了但等于没导出)。 $rel = trim((string)($p['relation_word'] ?? '')); if ($rel !== '') { $lines[] = 'CATEGORIES:' . self::esc($rel); } if (trim((string)($p['note'] ?? '')) !== '') { $lines[] = 'NOTE:' . self::esc((string)$p['note']); } // REV:客户端的"这条改过没有"判据。用 updated_at(拿不到就用现在) $lines[] = 'REV:' . self::stamp((string)($p['updated_at'] ?? '')); $lines[] = 'END:VCARD'; // 逐行折行(折行要在**转义之后**:先折再转义会把续行前导空格也转义掉) $flat = []; foreach ($lines as $ln) { foreach (self::fold($ln) as $piece) { $flat[] = $piece; } } return implode("\r\n", $flat); } /** UID:稳定且跨系统唯一(改名、改电话都不该让它变) */ public static function uid(array $p): string { $id = (int)($p['id'] ?? 0); if ($id > 0) { return 'person-' . $id . '@familybook'; } // 兜底:没有 id 的行(理论上不会)用一个姓名哈希,至少内部一致 return 'person-' . substr(sha1((string)($p['name'] ?? '')), 0, 12) . '@familybook'; } /** 从 contacts 数组里取某一类(kind)的行 */ private static function contactRows(array $p, string $kind): array { $all = $p['contacts'] ?? []; if (!is_array($all)) { return []; } $rows = $all[$kind] ?? []; return is_array($rows) ? $rows : []; } /** * 电话行:尽量带上"手机/住宅/工作"。 * * ⚠️ label 是我们自己的中文标注(「奶奶家」「工作」),vCard 3.0 的 TYPE * 只认一组固定词(CELL/HOME/WORK/VOICE/FAX…)。做法: * · label 能对上固定词(手机/座机/家/单位…)→ 写成 TYPE, * 这样手机上会显示成"手机"/"工作",是用户期待的样子; * · 对不上 → 不猜,原样放进 X-ABLabel(苹果认这个私有字段, * 会把它当自定义标签显示出来)。猜错成 HOME 比不写更糟: * 用户会看到"家"却其实是"奶奶家"。 */ private static function telLine(string $phone, string $label): string { $map = [ '手机' => 'CELL', '移动' => 'CELL', 'cell' => 'CELL', 'mobile' => 'CELL', '家' => 'HOME', '住宅' => 'HOME', 'home' => 'HOME', '工作' => 'WORK', '单位' => 'WORK', '公司' => 'WORK', 'work' => 'WORK', '传真' => 'FAX', 'fax' => 'FAX', ]; $l = trim($label); $type = $map[mb_strtolower($l, 'UTF-8')] ?? ($map[$l] ?? ''); if ($type !== '') { return 'TEL;TYPE=' . $type . ':' . self::esc($phone); } if ($l !== '') { return 'TEL;TYPE=VOICE;X-ABLabel=' . self::escParam($l) . ':' . self::esc($phone); } return 'TEL:' . self::esc($phone); } /** 邮箱行(与电话同样的思路:能对上固定词就用 TYPE,否则原样当标签) */ private static function mailLine(string $mail, string $label): string { $map = ['家' => 'HOME', '住宅' => 'HOME', 'home' => 'HOME', '工作' => 'WORK', '单位' => 'WORK', '公司' => 'WORK', 'work' => 'WORK']; $l = trim($label); $type = $map[mb_strtolower($l, 'UTF-8')] ?? ($map[$l] ?? ''); if ($type !== '') { return 'EMAIL;TYPE=INTERNET,' . $type . ':' . self::esc($mail); } if ($l !== '') { return 'EMAIL;TYPE=INTERNET;X-ABLabel=' . self::escParam($l) . ':' . self::esc($mail); } return 'EMAIL;TYPE=INTERNET:' . self::esc($mail); } /** * N 字段(结构化姓名)。 * * ⚠️ 中文名不能按西文拆:"杨永全" 拆成 姓=杨 / 名=永全 是**猜**, * 猜错(复姓"欧阳"拆成"欧"+"阳")比不拆更糟。 * 所以:纯中文名字**整体放"姓"那一格、名留空** —— 显示出来仍是"杨永全", * 排序也按拼音走得通(iOS 用 FN 显示、用 N 排序)。 * ⚠️ 空格分得开的西文名("John Smith")才按空格拆成 名 / 姓。 */ private static function nField(string $name): string { $name = trim($name); if ($name === '') { return ';;;;'; } $hasCjk = (bool)preg_match('/[\x{4e00}-\x{9fff}]/u', $name); if ($hasCjk) { // 姓=整名,名=空。分号是分段符,末尾四个空段按 3.0 补齐。 return self::esc($name) . ';;;;'; } $parts = preg_split('/\s+/u', $name) ?: []; if (count($parts) >= 2) { $family = array_pop($parts); // 最后一段当姓("John Smith" → Smith) $given = implode(' ', $parts); return self::esc($family) . ';' . self::esc($given) . ';;;'; } return self::esc($name) . ';;;;'; } /** * ADR 字段:PO盒;扩展;街道;城市;省;邮编;国家(3.0 共 7 段)。 * * ⚠️ 我们**没有**可靠的城市/省/邮编拆分(persons.address 是自由文本)。 * 所以整串塞进"街道"那一格,其余留空。 * 为什么不去猜:把"青岛市市南区XX路"硬拆成市/区×邮编,猜错了手机上 * 会显示成一张地址错乱的卡(而整串在街道格子里至少读起来是对的)。 */ private static function adrField(string $addr, string $province): string { /* ADR 有 **7 段**(PO;扩展;街道;城市;省;邮编;国家),所以需要 **6 个分号**。 要落位的是:段2=街道、段4=省、其余留空 → '' ; '' ; 街道 ; '' ; 省 ; '' ; '' 即 `;;` + 街道 + `;;` + 省 + `;;`。 🔴 这里第一版写成了 `;;` + 街道 + `;;;` + 省 + `;` —— 分号总数也是 6, 但**多出来的那一个挤在前面**,于是省落到了"邮编"那一段 (解析器读段4 拿到空串 → 省看起来"没填")。 症状非常隐蔽:段数对、总数对、只有**位置**错一位。 这是靠"导出→导入往返一致"那条断言抓出来的(见 _fb_b22_vcf_spec.php 第 6 节): 只数分号个数的检查发现不了它。 */ return ';;' . self::esc($addr) . ';;' . self::esc(trim($province)) . ';;'; } /** * BDAY:优先真实阳历生日(有年份),否则只给月日(3.0 的 `--MM-DD`)。 * * ⚠️ 为什么不能直接把 "2026-03-05" 写进 BDAY 就完事: * 本项目的 birthday 是 **DATETIME**(精确到时分秒)。写全会被某些客户端 * 当成"生日有具体时刻",iOS 会显示成 00:00 —— 无害但多余,所以只取日期段。 */ private static function bday(string $birthday): string { $birthday = trim($birthday); // ⚠️ 本项目的 birthday 是 **DATETIME**(精确到时分秒),所以只取日期段: // 写全会被客户端当成"生日有具体时刻",iOS 上显示成 00:00(无害但多余)。 if ($birthday !== '' && preg_match('/^(\d{4})-(\d{2})-(\d{2})/', $birthday, $m)) { return $m[1] . '-' . $m[2] . '-' . $m[3]; } // 只有月日(`--MM-DD`)是 3.0 的合法写法,但**本项目不产出**: // 库里存的阳历生日一定有年份(没有年份是"农历"那一路的事, // 而 vCard 没有农历字段 —— 硬塞会让手机上多一句看不懂的话,见 CardDav::rows)。 // 这里留着分支只为"万一将来某条数据真的只有月日"时不至于写出非法 BDAY。 if (preg_match('/^(\d{2})-(\d{2})$/', $birthday, $m)) { return '--' . $m[1] . '-' . $m[2]; } return ''; } /** REV 用的时间戳(UTC,3.0 要求 ISO8601 基本型) */ private static function stamp(string $dt): string { $dt = trim($dt); $t = $dt === '' ? time() : (strtotime($dt) ?: time()); return gmdate('Ymd\THis\Z', $t); } /** 文本值转义(RFC 2426 3.4):`\` `;` `,` 与换行 */ public static function esc(string $v): string { $v = str_replace('\\', '\\\\', $v); $v = str_replace(';', '\;', $v); $v = str_replace(',', '\,', $v); return str_replace(["\r\n", "\r", "\n"], '\n', $v); } /** * 参数值转义(RFC 2426:参数里的 `,` `;` `:` 要加反斜杠或用引号)。 * 用双引号包起来最简单,也不会踩"中文标点"的坑。 */ private static function escParam(string $v): string { return '"' . str_replace(['"', '\\'], ['', '\\\\'], $v) . '"'; } /** * 折行:一行最多 75 **字节**,续行以一个空格开头,且不切在多字节字符中间。 * * ⚠️ 与 App\Ics::fold 是同一套算法。**刻意复制而不是共用**: * Ics 的 fold 是 private、而且它按"iCalendar 不折参数行"的经验调过; * 把两个协议的折行强行抽成一个公共函数,将来任一方按自己的规范微调 * 都会波及另一边(vCard 与 iCalendar 的折行规则虽然像,但不是同一段规范)。 * 复制一份二十行,换两边互不牵连。 */ private static function fold(string $line): array { if (strlen($line) <= 75) { return [$line]; } $out = []; $cur = ''; $len = 0; $bytes = strlen($line); $i = 0; while ($i < $bytes) { $o = ord($line[$i]); $n = 1; if ($o >= 0xF0) { $n = 4; } elseif ($o >= 0xE0) { $n = 3; } elseif ($o >= 0xC0) { $n = 2; } $tok = substr($line, $i, $n); $i += $n; $room = $out === [] ? 75 : 74; // 续行要多一个前导空格 if ($len + $n > $room) { $out[] = $cur; $cur = ''; $len = 0; } $cur .= $tok; $len += $n; } if ($cur !== '') { $out[] = $cur; } // 🔴 续行**必须**补前导空格(RFC 2426 3.2:换行后以一个空格开头表示"这是上一行的延续")。 // ⚠️ 漏掉这一步的后果极隐蔽:折行切出来的那几段会变成**独立的属性行**, // 客户端读到的就是半个名字 / 半个地址("青岛市\,市南区\;某某路1号 这是一个特别特" // 后面那两段被当成垃圾属性丢掉),而且服务端看不出任何异常 —— // 本地校验"每行 ≤75 字节""折行确实发生了"全是绿的。 // 这是本轮唯一一处靠"折行能否无损还原"这条断言才抓出来的 bug // (见 _fb_b22_vcf.py 的"折行可无损还原"),所以那条断言必须留着。 $res = []; foreach ($out as $k => $v) { $res[] = $k === 0 ? $v : ' ' . $v; } return $res; } }