2026-10-06 17:33:48 +08:00
|
|
|
|
<?php
|
|
|
|
|
|
/**
|
|
|
|
|
|
* vCard 3.0 导出(RFC 2426)—— 名册 → 通讯录卡片。
|
|
|
|
|
|
*
|
|
|
|
|
|
* 用户原话(2026-10-06 第 22 批):「名册,增加cardav同步功能。」
|
|
|
|
|
|
* 手机通讯录(iOS 通讯录 / DAVx⁵ / Thunderbird / 群晖 Contacts)拉到的就是这里的文本。
|
|
|
|
|
|
*
|
|
|
|
|
|
* ⚠️ 为什么导出 **3.0** 而不是 4.0:
|
|
|
|
|
|
* iOS 与 DAVx⁵ 都推荐 3.0(4.0 在 iOS 上会被"降级兼容",个别字段干脆丢),
|
|
|
|
|
|
* 而且 3.0 的 TEL/EMAIL 参数写法最简单 —— 少写兼容分支就少一处出错。
|
|
|
|
|
|
* 真正要紧的两条 3.0 规矩:
|
|
|
|
|
|
* · `FN` **必须**有(3.0 里 FN 是必需属性,没有它 iOS 会整张卡丢掉);
|
|
|
|
|
|
* · `N` 也要有(缺了 iOS 通讯录会按"无名"排序,找不着人)。
|
|
|
|
|
|
*
|
|
|
|
|
|
* ⚠️ 与「导入」是同一份字段表(App\ContactImport::parseVcard 的反向):
|
|
|
|
|
|
* 导进来的卡再导出去必须还是同一个人(往返不丢字段)。
|
|
|
|
|
|
* 那边的解析分支就是这边的书写清单,改动时要**两边一起看**。
|
|
|
|
|
|
*
|
|
|
|
|
|
* ⚠️ 三件必须做对、否则客户端会静默丢数据的事:
|
|
|
|
|
|
* ① **转义**:文本值里的 `\` `;` `,` 换行都要转(RFC 2426 3.4)——
|
|
|
|
|
|
* "青岛市,市南区"这种地址不转义,客户端会把逗号当**分段符**,地址后半截跑到别的字段;
|
|
|
|
|
|
* ② **折行**:一行最多 75 **字节**、续行以一个空格开头。中文一个字 3 字节,
|
|
|
|
|
|
* 按字符数切会切出半个 UTF-8 字 → 客户端解出来是乱码。这里直接复用 Ics 的实现思路。
|
|
|
|
|
|
* ③ **CSV 式的逗号列表**:N 与 ADR 是"用分号连接的分段",而每段内部还要再转义。
|
|
|
|
|
|
* 顺序不能反(先转义后拼分号,否则真分段符也会被转义掉)。
|
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
|
|
declare(strict_types=1);
|
|
|
|
|
|
|
|
|
|
|
|
namespace App;
|
|
|
|
|
|
|
|
|
|
|
|
final class Vcard
|
|
|
|
|
|
{
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 把一批"人"导出成一张 .vcf 文本(多张卡拼在一起,这是 CardDAV 资源的通行形态:
|
|
|
|
|
|
* 一本通讯录 = 一个 vcf 文件,里面 N 张卡)。
|
|
|
|
|
|
*
|
|
|
|
|
|
* @param array<int,array> $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
|
|
|
|
|
|
{
|
2026-10-13 11:53:58 +08:00
|
|
|
|
// 真名与显示名是**两个**,别再合成一个变量:
|
|
|
|
|
|
// · $real —— `persons.name`,写进 N(结构化姓名,客户端排序/去重用);
|
|
|
|
|
|
// · $display —— **我叫他什么**(`nicknames` 表,`CardDav::rows` 已算好),写进 FN。
|
|
|
|
|
|
// 🔴 v46(用户 2026-10-13)原话:
|
|
|
|
|
|
// 「同步通讯录的时候,名字不要用他的真实姓名,而是用我们在系统里给其他名册里的人
|
|
|
|
|
|
// 起的称呼……同时把原名、我跟他之间的关系,包括我给他写的备注,都放在备注里。」
|
|
|
|
|
|
// ⇒ FN = 称呼(手机上看到的就是我平常叫他的那两个字);
|
|
|
|
|
|
// 真名 / 关系 / 私人备注 / 工作经历全部进 NOTE(见 buildNote())。
|
|
|
|
|
|
$real = trim((string)($p['name'] ?? ''));
|
|
|
|
|
|
if ($real === '') {
|
|
|
|
|
|
$real = '(未命名)'; // FN 必须非空,否则 iOS 会整张丢掉
|
2026-10-06 17:33:48 +08:00
|
|
|
|
}
|
2026-10-13 11:53:58 +08:00
|
|
|
|
$display = trim((string)($p['display_name'] ?? ''));
|
|
|
|
|
|
if ($display === '') {
|
|
|
|
|
|
$display = $real; // 没给他起过称呼 → 退回真名(老行为,不能变成空名字)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-06 17:33:48 +08:00
|
|
|
|
$lines = [];
|
|
|
|
|
|
$lines[] = 'BEGIN:VCARD';
|
|
|
|
|
|
$lines[] = 'VERSION:3.0';
|
|
|
|
|
|
|
|
|
|
|
|
// ---- 姓名 ----------------------------------------------------
|
|
|
|
|
|
// ⚠️ UID 必须**稳定**:客户端靠它判断"这是新卡还是同一张卡改了"。
|
|
|
|
|
|
// 用 `person-<id>@<域>`(不能用"姓名"——同名的人会互相覆盖,
|
|
|
|
|
|
// 而且改名之后客户端会当成"删一张加一张")。
|
|
|
|
|
|
$lines[] = 'UID:' . self::esc(self::uid($p));
|
2026-10-13 11:53:58 +08:00
|
|
|
|
// FN:**显示名** —— 走 CardDAV 同步时它就是"我叫他什么"(见上)。
|
|
|
|
|
|
$lines[] = 'FN:' . self::esc($display);
|
|
|
|
|
|
// N:**真名**。缺了它 iOS 会把这卡按"无名"排序、搜真名也搜不到。
|
|
|
|
|
|
$lines[] = 'N:' . self::nField($real);
|
|
|
|
|
|
// NICKNAME:只在"显示名不等于小名"时才写。
|
|
|
|
|
|
// ⚠️ 这条判断是必要的,不是可有可无:走 CardDAV 时 `display` 就是小名,
|
|
|
|
|
|
// 再写一行 NICKNAME 会让手机上出现**两行一模一样的名字**(纯噪音);
|
|
|
|
|
|
// 而"用真名当显示名"的场合(往返导入 / 备份导出)里,NICKNAME 才是
|
|
|
|
|
|
// 唯一能保住小名的地方 —— 少了它小名就丢了。
|
|
|
|
|
|
$nick = trim((string)($p['nickname'] ?? ''));
|
|
|
|
|
|
if ($nick !== '' && $nick !== $display) {
|
|
|
|
|
|
$lines[] = 'NICKNAME:' . self::esc($nick);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---- 头像 ----------------------------------------------------
|
|
|
|
|
|
foreach (self::photoLine($p) as $ln) {
|
|
|
|
|
|
$lines[] = $ln;
|
2026-10-06 17:33:48 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---- 电话 ----------------------------------------------------
|
|
|
|
|
|
// ⚠️ 主手机号在 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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---- 单位 / 职务 --------------------------------------------
|
2026-10-13 11:53:58 +08:00
|
|
|
|
// 🔴 v46:ORG 只写**当前**单位(`persons.org` 是 `PersonJob::syncCurrent()`
|
|
|
|
|
|
// 从最新那段经历回写的当前快照);历史与职称明细都进 NOTE。
|
2026-10-06 17:33:48 +08:00
|
|
|
|
if (trim((string)($p['org'] ?? '')) !== '') {
|
|
|
|
|
|
$lines[] = 'ORG:' . self::esc((string)$p['org']);
|
|
|
|
|
|
}
|
|
|
|
|
|
if (trim((string)($p['title'] ?? '')) !== '') {
|
|
|
|
|
|
$lines[] = 'TITLE:' . self::esc((string)$p['title']);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-13 11:53:58 +08:00
|
|
|
|
// ---- 工作地址 ------------------------------------------------
|
|
|
|
|
|
// 🔴 v46(用户 2026-10-13)原话:
|
|
|
|
|
|
// 「把现在的工作单位放在他现在工作单位的地址栏里。」
|
|
|
|
|
|
// 用户同时明确「就是工作单位就行,不需要工作单位的地址」—— 也就是说
|
|
|
|
|
|
// 这个"工作地址"栏里放的是**单位名称**,不是门牌。
|
|
|
|
|
|
// 花钱买这一条的理由(实测 TB 157 · `modules/VCardUtils.sys.mjs` 的 typeMap):
|
|
|
|
|
|
// `adr.work` 在映射表里(工作地址是 TB 通讯录**会显示**的字段之一),
|
|
|
|
|
|
// 而 `org`/`title` 只映射到 Company/JobTitle 两个内置行;
|
|
|
|
|
|
// 把单位再放进 ADR;TYPE=WORK 能让它在详情里多一个明确的位置。
|
|
|
|
|
|
// ⚠️ 分段位置必须与 `adrField()` 一致(段2=街道…):单位名当"街道"那段,
|
|
|
|
|
|
// 城市/省/邮编/国家全留空 —— 写错段位会像 v24 那次一样"看着有、显示不出来"。
|
|
|
|
|
|
// ⚠️ 同样**只在同步模式**下写:备份导出里凭空多一条 ADR 会让
|
|
|
|
|
|
// `_fb_b22_vcf_spec.php` 的"补充地址只有 1 条"断言变红(那是往返一致性的守卫)。
|
|
|
|
|
|
if ($display !== $real && trim((string)($p['org'] ?? '')) !== '') {
|
|
|
|
|
|
$lines[] = 'ADR;TYPE=WORK:' . self::adrField((string)$p['org'], '');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-06 17:33:48 +08:00
|
|
|
|
// ---- 与我的关系 / 备注 --------------------------------------
|
|
|
|
|
|
// ⚠️ 关系写进 CATEGORIES 而不是自己造一个 X- 字段:
|
|
|
|
|
|
// iOS 通讯录会把 CATEGORIES 当成"群组/标签"显示,用户一眼能看出谁是家人。
|
|
|
|
|
|
// 写 X-FAMILY-RELATION 那种私有字段的话,手机上什么都看不到
|
|
|
|
|
|
// (导出了但等于没导出)。
|
|
|
|
|
|
$rel = trim((string)($p['relation_word'] ?? ''));
|
|
|
|
|
|
if ($rel !== '') {
|
|
|
|
|
|
$lines[] = 'CATEGORIES:' . self::esc($rel);
|
|
|
|
|
|
}
|
2026-10-13 11:53:58 +08:00
|
|
|
|
// NOTE:把"真名 / 关系 / 我的备注 / 他写的自我介绍 / 工作经历"全拼进去。
|
|
|
|
|
|
$note = self::buildNote($p, $real, $display);
|
|
|
|
|
|
if ($note !== '') {
|
|
|
|
|
|
$lines[] = 'NOTE:' . self::esc($note);
|
2026-10-06 17:33:48 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-13 11:53:58 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* NOTE 的内容:把"手机上这一个字段"能装的信息尽量装进去。
|
|
|
|
|
|
*
|
|
|
|
|
|
* 🔴 v46(用户 2026-10-13)原话:
|
|
|
|
|
|
* 「把他点进去之后,就能看到这个人原名叫什么、他跟我是什么关系,
|
|
|
|
|
|
* 以及我们是怎么认识的,还包括他的职称、工作经历之类的信息……
|
|
|
|
|
|
* 看看备注有没有字数限制,如果没有的话,就都放在这里面。
|
|
|
|
|
|
* 他的职称和工作单位,只写现在的。……把他的工作经历也放在他的备注里。」
|
|
|
|
|
|
*
|
|
|
|
|
|
* 🔴🔴 **只在"同步模式"下做富化**(`$display !== $real`,即显示名已被换成了
|
|
|
|
|
|
* 我叫他的称呼)。理由是一条硬约束:**导出必须与导入往返一致**。
|
|
|
|
|
|
* 备份导出那条路径(显示名就是真名)里,caller 没有算 `relation_word` /
|
|
|
|
|
|
* `jobs` / `my_note`,若这里也去拼 NOTE,会在往返导入后再叠一层 ——
|
|
|
|
|
|
* 每导一次多一截,且不可逆。所以那种场合 NOTE = 原有备注,与 v24 一字不差。
|
|
|
|
|
|
*
|
|
|
|
|
|
* ⚠️ 为什么用 `;` `|` 这类全角标点做分隔而不是换行:
|
|
|
|
|
|
* vCard 的换行是 `\n`,写进去在 iOS 上是**一个软换行**、在 TB 里可能被折掉;
|
|
|
|
|
|
* 而各个客户端对 NOTE 里的换行渲染差异很大(有的折成一行)。
|
|
|
|
|
|
* 用全角分隔符,无论折行还是单行显示,可读性都还在。
|
|
|
|
|
|
*
|
|
|
|
|
|
* ⚠️ 容量:vCard 3.0 对 NOTE 没有长度限制;本项目两个备注列都是 TEXT
|
|
|
|
|
|
* (65535 字节)。逐行折行由 fold() 负责,不会撑坏。
|
|
|
|
|
|
*
|
|
|
|
|
|
* @param array $p 一行人员数据(`CardDav::rows` 的输出)
|
|
|
|
|
|
* @param string $real 真名(persons.name)
|
|
|
|
|
|
* @param string $display 显示名(FN 用的那个)
|
|
|
|
|
|
*/
|
|
|
|
|
|
private static function buildNote(array $p, string $real, string $display): string
|
|
|
|
|
|
{
|
|
|
|
|
|
$theirs = trim((string)($p['note'] ?? ''));
|
|
|
|
|
|
|
|
|
|
|
|
// ── 备份导出模式:NOTE 就是原有备注(保持 v24 的往返语义)──
|
|
|
|
|
|
if ($display === $real) {
|
|
|
|
|
|
return $theirs;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ── 同步模式:富化 ──
|
|
|
|
|
|
$parts = [];
|
|
|
|
|
|
// ① 真名
|
|
|
|
|
|
$parts[] = '原名:' . $real;
|
|
|
|
|
|
// ② 与我的关系
|
|
|
|
|
|
$rel = trim((string)($p['relation_word'] ?? ''));
|
|
|
|
|
|
if ($rel !== '' && $rel !== '我') {
|
|
|
|
|
|
$parts[] = '与我的关系:' . $rel;
|
|
|
|
|
|
}
|
|
|
|
|
|
// ③ **我给他写的私人备注**(`person_notes`,只有写的人自己看得到)——
|
|
|
|
|
|
// 用户点名要的"我们是怎么认识的"就在这一条里。排在自我介绍前面,
|
|
|
|
|
|
// 因为它是"我的事",比履历更需要一眼看到。
|
|
|
|
|
|
$mine = trim((string)($p['my_note'] ?? ''));
|
|
|
|
|
|
if ($mine !== '') {
|
|
|
|
|
|
$parts[] = $mine;
|
|
|
|
|
|
}
|
|
|
|
|
|
// ④ 他自己写的自我介绍
|
|
|
|
|
|
if ($theirs !== '') {
|
|
|
|
|
|
$parts[] = '自我介绍:' . $theirs;
|
|
|
|
|
|
}
|
|
|
|
|
|
// ⑤ 当前单位 / 职称(只写现在的 —— 用户明确要求)
|
|
|
|
|
|
$org = trim((string)($p['org'] ?? ''));
|
|
|
|
|
|
if ($org !== '') {
|
|
|
|
|
|
$parts[] = '现单位:' . $org;
|
|
|
|
|
|
}
|
|
|
|
|
|
$title = trim((string)($p['title'] ?? ''));
|
|
|
|
|
|
if ($title !== '') {
|
|
|
|
|
|
$parts[] = '现职称:' . $title;
|
|
|
|
|
|
}
|
|
|
|
|
|
// ⑥ 工作经历(多段,从早到晚)—— 用户要的"三年前在哪、两年前在哪、现在在哪"
|
|
|
|
|
|
$jobs = self::jobLines($p['jobs'] ?? []);
|
|
|
|
|
|
if ($jobs !== '') {
|
|
|
|
|
|
$parts[] = '工作经历:' . $jobs;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return implode(';', array_filter($parts, static fn($s) => $s !== ''));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 工作经历 → 一段文字:`2020-01~2023-06 某大学(讲师)|2023-07~现在 另一大学(副教授)`
|
|
|
|
|
|
*
|
|
|
|
|
|
* ⚠️ `person_jobs` 的排序由 `PersonJob::list()` 保证:**没有结束年月的那段
|
|
|
|
|
|
* (=现在)排最前**,其余按开始时间倒序。这里照它的顺序写,
|
|
|
|
|
|
* 再反转成"从早到晚"读起来更顺("以前在哪、现在在哪")。
|
|
|
|
|
|
* 但**反转要稳**:只用 `array_reverse`,不重新排序,避免两处排序口径打架。
|
|
|
|
|
|
*/
|
|
|
|
|
|
private static function jobLines(array $jobs): string
|
|
|
|
|
|
{
|
|
|
|
|
|
$out = [];
|
|
|
|
|
|
foreach (array_reverse(array_values($jobs)) as $j) {
|
|
|
|
|
|
$org = trim((string)($j['org'] ?? ''));
|
|
|
|
|
|
if ($org === '') {
|
|
|
|
|
|
continue; // 没有单位的经历没法读,跳过(App\PersonJob 也允许这种空行)
|
|
|
|
|
|
}
|
|
|
|
|
|
$seg = '';
|
|
|
|
|
|
$start = trim((string)($j['start_ym'] ?? ''));
|
|
|
|
|
|
$end = trim((string)($j['end_ym'] ?? ''));
|
|
|
|
|
|
if ($start !== '' || $end !== '') {
|
|
|
|
|
|
// 结束为空 = 现在(与 App\PersonJob 列表里标「现在」同一口径)
|
|
|
|
|
|
$seg .= ($start !== '' ? $start : '?') . '~' . ($end !== '' ? $end : '现在') . ' ';
|
|
|
|
|
|
}
|
|
|
|
|
|
$seg .= $org;
|
|
|
|
|
|
// 职务与职称分开拼,两个都有就都写上
|
|
|
|
|
|
$extra = [];
|
|
|
|
|
|
$t = trim((string)($j['title'] ?? ''));
|
|
|
|
|
|
if ($t !== '') { $extra[] = $t; }
|
|
|
|
|
|
$rk = trim((string)($j['rank_name'] ?? ''));
|
|
|
|
|
|
if ($rk !== '' && $rk !== $t) { $extra[] = $rk; }
|
|
|
|
|
|
if ($extra) {
|
|
|
|
|
|
$seg .= '(' . implode('·', $extra) . ')';
|
|
|
|
|
|
}
|
|
|
|
|
|
$out[] = $seg;
|
|
|
|
|
|
}
|
|
|
|
|
|
return implode('|', $out);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* PHOTO 行(vCard 3.0 的 `ENCODING=b` 内嵌 base64)。
|
|
|
|
|
|
*
|
|
|
|
|
|
* 🔴 v46(用户 2026-10-13):「通讯录同步都没有头像,但咱们是有头像的……
|
|
|
|
|
|
* 我记得 CardDav 这种 ICS 同步的时候,可以把头像放在 ICS 文件里面。
|
|
|
|
|
|
* 虽然可能清晰度没那么高,但我们尽可能把清晰度调到最高。」
|
|
|
|
|
|
*
|
|
|
|
|
|
* ⚠️ 先泼一盆冷水(实测,别让用户白期待):**Thunderbird 看不到这张照片。**
|
|
|
|
|
|
* TB 157 的 vCard↔通讯录映射表(`modules/VCardUtils.sys.mjs` 的 `typeMap`)
|
|
|
|
|
|
* 里没有 `photo` 这一项 —— CardDAV 同步进来的联系人,TB 不会渲染头像。
|
|
|
|
|
|
* 但 **iOS 通讯录 / DAVx⁵ / 群晖 Contacts 都支持**,所以照写不白写。
|
|
|
|
|
|
*
|
|
|
|
|
|
* ⚠️ 清晰度:库里那张已经是 512×512、JPEG 质量 88 的成品(上传时就重采样过)。
|
|
|
|
|
|
* PHOTO 是**原字节内嵌**、不会再压一次 ⇒ 同步出去的就是库里那张,没有可再提的空间。
|
|
|
|
|
|
* 这里仍然"能多大给多大",且**超过 $maxBytes 时等比缩一档**守住体积:
|
|
|
|
|
|
* 一张卡几 MB 的情形容户端会整本拉得很慢(多张卡拼接成一本 vcf)。
|
|
|
|
|
|
*
|
|
|
|
|
|
* @return string[] 0 或 1 行
|
|
|
|
|
|
*/
|
|
|
|
|
|
private static function photoLine(array $p): array
|
|
|
|
|
|
{
|
|
|
|
|
|
$bin = (string)($p['photo_bin'] ?? '');
|
|
|
|
|
|
if ($bin === '') {
|
|
|
|
|
|
return [];
|
|
|
|
|
|
}
|
|
|
|
|
|
$type = 'JPEG';
|
|
|
|
|
|
if (strncmp($bin, "\x89PNG", 4) === 0) {
|
|
|
|
|
|
$type = 'PNG';
|
|
|
|
|
|
} elseif (strncmp($bin, 'GIF8', 4) === 0) {
|
|
|
|
|
|
$type = 'GIF';
|
|
|
|
|
|
} elseif (strncmp($bin, 'RIFF', 4) === 0 && substr($bin, 8, 4) === 'WEBP') {
|
|
|
|
|
|
$type = 'WEBP';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 太大就缩一档(只在真的超了才动它,且缩完还不合格就直接不发 —— 发个坏图更糟)
|
|
|
|
|
|
if (strlen($bin) > self::PHOTO_MAX_BYTES) {
|
|
|
|
|
|
$small = self::shrinkImage($bin, 384);
|
|
|
|
|
|
if ($small === '') {
|
|
|
|
|
|
return [];
|
|
|
|
|
|
}
|
|
|
|
|
|
$bin = $small;
|
|
|
|
|
|
$type = 'JPEG'; // shrinkImage 一律输出 JPEG
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// vCard 3.0:`PHOTO;ENCODING=b;TYPE=JPEG:<base64>`。
|
|
|
|
|
|
// ⚠️ base64 **不能折行**(折行是"行级"的事,由外层 fold() 统一做)——
|
|
|
|
|
|
// 在这里自己插入换行会把 ENCODING=b 的连续值切断,客户端解出坏图。
|
|
|
|
|
|
// 3.0 里 `ENCODING=b` 表示"base64 二进制",不要写成 4.0 的 `data:` URI
|
|
|
|
|
|
//(TB 与 iOS 对 3.0 里塞 data URI 的支持并不一致)。
|
|
|
|
|
|
return ['PHOTO;ENCODING=b;TYPE=' . $type . ':' . base64_encode($bin)];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 把图片二进制等比缩到最长边 $max,输出 JPEG(质量 88)。
|
|
|
|
|
|
*
|
|
|
|
|
|
* ⚠️ 与 `App\Upload::image` 的思路一致但**不复用**:那一个要处理裁剪区域、
|
|
|
|
|
|
* 槽位比例、落盘、主色提取,牵进 vCard 生成器会让依赖方向反过来
|
|
|
|
|
|
* (文本生成器不该依赖上传器)。这里只要"缩一下",二十行写清。
|
|
|
|
|
|
* ⚠️ 没有 GD 时返回 ''(调用方就当没有头像,绝不半途发一张坏图)。
|
|
|
|
|
|
*/
|
|
|
|
|
|
private static function shrinkImage(string $bin, int $max): string
|
|
|
|
|
|
{
|
|
|
|
|
|
if (!function_exists('imagecreatefromstring')) {
|
|
|
|
|
|
return '';
|
|
|
|
|
|
}
|
|
|
|
|
|
$src = @imagecreatefromstring($bin);
|
|
|
|
|
|
if (!$src) {
|
|
|
|
|
|
return '';
|
|
|
|
|
|
}
|
|
|
|
|
|
$w = imagesx($src);
|
|
|
|
|
|
$h = imagesy($src);
|
|
|
|
|
|
if ($w <= 0 || $h <= 0) {
|
|
|
|
|
|
imagedestroy($src);
|
|
|
|
|
|
return '';
|
|
|
|
|
|
}
|
|
|
|
|
|
$scale = $max / max($w, $h);
|
|
|
|
|
|
$nw = max(1, (int)round($w * $scale));
|
|
|
|
|
|
$nh = max(1, (int)round($h * $scale));
|
|
|
|
|
|
$dst = imagecreatetruecolor($nw, $nh);
|
|
|
|
|
|
if (!$dst) {
|
|
|
|
|
|
imagedestroy($src);
|
|
|
|
|
|
return '';
|
|
|
|
|
|
}
|
|
|
|
|
|
// JPEG 无 alpha:白底铺一层,否则 PNG 头像的透明区会变成黑块
|
|
|
|
|
|
imagefill($dst, 0, 0, imagecolorallocate($dst, 255, 255, 255));
|
|
|
|
|
|
imagecopyresampled($dst, $src, 0, 0, 0, 0, $nw, $nh, $w, $h);
|
|
|
|
|
|
ob_start();
|
|
|
|
|
|
$ok = imagejpeg($dst, null, 88);
|
|
|
|
|
|
$out = (string)ob_get_clean();
|
|
|
|
|
|
imagedestroy($dst);
|
|
|
|
|
|
imagedestroy($src);
|
|
|
|
|
|
return $ok && $out !== '' ? $out : '';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** PHOTO 的体积上限:超了就缩一档(一张头像到这个量级已经远超"够看") */
|
|
|
|
|
|
private const PHOTO_MAX_BYTES = 300 * 1024;
|
|
|
|
|
|
|
2026-10-06 17:33:48 +08:00
|
|
|
|
/** 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;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|