用户 2 条原话:
①「名册,增加cardav同步功能。」
②「caldav和cardav,都增加一个创建口令的功能,这样,就使用用户名和口令来同步,
避免在第三方客户端进行同步时自己的密码泄露。」
用户拍板的两个岔路(AskUserQuestion):
· 口令范围 = **一把通用 DAV 口令**(日历 + 通讯录共用),不是两个 ——
手机上填一次就都通;两把会让用户要在两个页面各配一遍、还容易只配一半。
· 同步范围 = 与**名册页完全一致**(Person::roster(kind='member')),不另发明判据。
════ 一、 口令统一:caldav_credentials → dav_credentials(Schema v23)════
表**改名**(不是新建一张 carddav_credentials):
· 老站 RENAME → 已配好的口令一字不动,**所有设备不用重配**;
· 新装时两表都不在 → 跳过(直接 RENAME 一张不存在的表会抛异常、把升级打断);
· 两表并存(上次升级跑一半 / 有人手工建过)→ 搬行(INSERT IGNORE)+ DROP 旧表。
⚠️ 迁移**必须**是 RENAME + 存在性守卫(新增 Schema::hasTable),
只改建表语句里的名字的话,老库会**再建一张空表**、旧表留在原地没人读 →
用户所有已配好的同步设备立刻全部 401(而且改密码也修不好:新口令写进了新表)。
Auth:caldavIssue/Revoke/Auth/hasCalDav → davIssue/davRevoke/davAuth/hasDav,
新增 davLastUsed()(UI 上显示"最近使用")。
口令管理入口搬到**个人设置 → DAV 同步**(它管的是"我的凭据",同时服务日历与通讯录;
放在日历页会让只想同步通讯录的人在日程里找半天)。
· 旧路由 calendar/caldav_token|caldav_revoke 保留为 302 → 老书签不 404;
· 日历本页只留地址 + 一个指路按钮(密码类的东西只该有一个管理入口,
两处各放一套按钮迟早对不上:这页重置了、那页还显示"已配置")。
════ 二、 CardDAV(全新)════
· app/Vcard.php —— 名册 → vCard **3.0**(iOS/DAVx⁵ 推荐;4.0 在 iOS 上会被降级丢字段)。
FN 必须有(缺了 iOS 会**整张卡丢掉**)、UID 用 person id(稳定,不随改名变)、
标签能对上固定词就走 TYPE(手机上显示成"手机/工作"),对不上就原样放进
X-ABLabel(**不猜**成 HOME —— 猜错了用户看到"家"其实是"奶奶家")。
· app/CardDav.php —— books/rows/one/cardOf/etag/parsePath;ROOT='/carddav/'。
· carddav.php —— OPTIONS / PROPFIND(0,1) / GET / REPORT;**写方法一律 403**。
只读是与 CalDAV 同样的取舍:与其"半支持"(客户端以为改成功了、服务端却没落库),
不如明确拒绝,用户至少知道"手机上加的联系人不会同步回来"。
· **地址空间分离、鉴权同一套**:/carddav/ 与 /dav/ 各自独立(资源类型与命名空间
完全不同,混在一棵树里要靠猜客户端意图,猜错的表现是"连上了但一本都看不到");
但两者调**同一个** Auth::davAuth、读**同一张** dav_credentials 表 ——
这正是"一把口令管两种同步"的落点,也保证不会出现"日历能连、通讯录连不上"。
· 路由三处:router.php(本地 php -S)+ .htaccess(书面来源)+ 部署脚本 5c 段。
⚠️ 线上用**独立标记块** `# >>> FamilyBook CardDAV`:老站已经有
`# >>> FamilyBook CalDAV`,把新规则并进那一段会被"标记在不在"的幂等判断
**整段跳过** → /carddav/ 永远 404,而本地(走 router.php)完全正常。
· 打包白名单加 carddav.php(站点根下的独立入口最容易漏 —— dav.php / upload_stage.php
都漏过一次,症状都是"线上那个功能 404、本地一切正常")。
════ 三、 🔴 三个真 bug(全是"本地校验全绿"型,靠 vCard 往返测试抓出来)════
把 App\Vcard(导出)与 App\ContactImport(导入)各自单测都是绿的,
但**导出→导入跑一遍**立刻翻出三个:
① Vcard::fold **漏了"续行补前导空格"**(照抄 Ics::fold 时抄漏了最后一步)。
后果:折出来的那几段变成**独立的属性行** → 客户端把半个名字/半个地址当垃圾丢掉。
而"每行 ≤75 字节""确实折行了"全是绿的 —— 只有「**折行可无损还原**」这条断言能抓。
② ContactImport 用 `explode(';')` 给 ADR / N 分段 —— PHP 的 explode **不认识转义**,
值里写了 `\;`(字面分号)照样在那里拆开 → 地址「青岛市,市南区;某某路1号」被
从中间切断,后半截(含省名)跑到别的段上;导进来的人地址只剩前半句、
而且看着像"省没填"。手机通讯录里带分号的地址("XX路1号;3单元")不算罕见。
→ 新增 `splitVcardSegments()`:**按未转义的分号**切,切完再逐段 unescape。
⚠️ "先 unescape 再分段"是同一个错的另一种写法(本轮第一版就这么改,往返照样红)。
③ Vcard::adrField 的**分号数量对、但位置错一位**(省落到了"邮编"格)。
第一版 `;;` + 街道 + `;;;` + 省 + `;` —— 总数也是 6,可多出来的那个挤在前面 →
解析器读段 4 拿到空串。**段数对、总数对、只有位置错一位**,
所以"数分号个数"的检查发现不了它。
════ 四、 顺带修的一个 ETag 一致性 bug ════
同一张卡在 PROPFIND(清单)与 REPORT(取正文)里原本算出**两个不同的 ETag**
(一个用不带结尾 CRLF 的正文、一个用带 CRLF 的)→ 客户端认为这张卡一直在变、
**反复重拉**(同步永远不收敛),而服务端看不出任何异常(两边都合法)。
→ 新增 `CardDav::cardOf()`,把**正文与 ETag 成对返回**,结尾 CRLF 只加在正文上、
不参与 ETag。修法不是"两处改成一样",而是**让它们没有机会不一样**。
════ 五、 UI ════
· 个人设置页「DAV 同步」卡:两个地址(/dav/ 与 /carddav/)+ 用户名预填 + 一次性明文
(只显示这一次,走一次性会话槽位)+ 生成/重置/撤销 + "最近使用"。
· 名册页 CardDAV 卡:地址 + "这 N 个人"(人数与名册页一致,用户当场能对上)+
指路口令的按钮。**不放**生成表单。
· 日历本页改口径:只留 /dav/ 地址 + 指路链接。
════ 六、 验证 ════
- e2e **1106/0**(本批新增约 45 条:CardDAV 全协议、越权 404、只读 403、
一把口令开两把锁、撤销后两处**同时**失效、口令一次性/不落库、ETag 一致性)
- **vCard 规格体检 36/36**(新工具 `_fb_b22_vcf_spec.php`:转义/折行/多值/标签/
空名兜底 + **导出→导入往返一致**)
- **真机探针 28/0**(新工具 `_fb_b22_ui.py`:两块 UI 渲染、**在浏览器里点按钮**生成
口令、刷新后明文消失但状态留下、名册页人数对得上、日历本页改口径、
撤销后两处 401、全程零 JS 报错)
- 桌面几何 428/0 · 移动端几何(360/375/390/414)684/0 · 结构升级 149/0
- 部署静态断言空跑 **307/0** · 线上部署 134 项自检 0 失败
- **反向验证 14/14**(`_fb_b22_falsify.py`):迁移不干活 / 读旧表 / 断入口 /
去掉 addressbook 声明 / 只读守卫失效 / 不校验口令 / 越权不再查名册 /
折行不补空格 / 分段改回 explode / ADR 错位 / UI 卡拿掉 / 明文不再一次性 /
撤销不删凭据 —— 逐条改回旧行为,对应断言都变红,还原后复绿。
════ 七、 🔴🔴 线上实测挖出的**最重要**的一条:DAV 鉴权在 Apache 上一直是坏的 ════
部署后在线上以 **Web 用户(www)** 身份做端到端验收时,DAV 全部 401 ——
而本地 e2e 全绿。追下去发现两层原因:
① **测试假象**:我把 DAV 请求放进了一个**临时探针文件**里,用 `curl` 打它。
但 Apache 请求 `.php` 文件时**不套用站点根 .htaccess 的 URL 重写** ——
所以探针里的请求根本没被转给 dav.php/carddav.php,拿到的是**探针自己的 401**,
不是 DAV 的。第一轮"全 401"有一半是探针自己造的假象。
② **真问题**:Apache 默认**不把 `Authorization` 头交给 PHP-FPM** ——
`$_SERVER['PHP_AUTH_USER']` 与 `HTTP_AUTHORIZATION` **全是 null**。
实测(这台服务器 Apache 2.4.53,直接监听 80/443,**没有 nginx**):
基线 : {"PHP_AUTH_USER":null,"HTTP_AUTHORIZATION":null}
`CGIPassAuth On`: {"PHP_AUTH_USER":"abc","HTTP_AUTHORIZATION":"(有)"}
⇒ **线上 CalDAV 从来没被真实客户端连上过**(服务端代码是对的、环境少了这一行)。
本轮 e2e 用 `php -S`,那里 `PHP_AUTH_USER` 是**自己填好的**,所以一路全绿 ——
又一个"本地好好的、线上不行"。
修法:部署脚本 5c 段追加**独立标记块** `# >>> FamilyBook DAV auth`,内容
`CGIPassAuth On`(Apache **2.4.13+ 的核心指令**,不需要额外模块、**不用动宝塔面板**)。
仓库 `.htaccess` 同步写上(它是那段规则唯一的书面来源)。
修完后线上端到端**全部通过**(以 www 身份、真实 HTTP 服务、真实数据):
· CalDAV:`PROPFIND → 207`、**8 本日历本**、`PUT → 403`
· CardDAV:根 `207` + `<card:addressbook/>`;清单 `207`、**1089 张卡**、1089 个 ETag;
整本 vcf `200`、1089 张、全是 3.0、`text/vcard`;**最长行 75 字节**(真实数据上
折行真的发生了);`REPORT → 207` 带 `address-data`;`PUT → 403`;
`OPTIONS → 200 DAV: 1, 2, 3, addressbook`
· 导出的卡数 **1089 == 名册人数 1089**(口径一致)
· 口令错 → `401`
临时探针用完即删并复验 404;临时插入的口令用完即清(不动用户已配的凭据)。
════ 八、 关于 CalDAV「本来是好的」这件事 ════
严格说:**服务端实现一直是对的**,缺的是 Apache 那一行 `CGIPassAuth`。
也就是说这个仓库里的 CalDAV 之前**上线即不可用**(只是没人用真实客户端试过)。
本批顺手把它一起修好了 —— 现在 /dav/ 与 /carddav/ 都能被 iOS / DAVx⁵ 正常连上。
393 lines
18 KiB
PHP
393 lines
18 KiB
PHP
<?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
|
||
{
|
||
$name = trim((string)($p['name'] ?? ''));
|
||
if ($name === '') {
|
||
$name = '(未命名)'; // FN 必须非空,否则 iOS 会整张丢掉
|
||
}
|
||
$lines = [];
|
||
$lines[] = 'BEGIN:VCARD';
|
||
$lines[] = 'VERSION:3.0';
|
||
|
||
// ---- 姓名 ----------------------------------------------------
|
||
// ⚠️ UID 必须**稳定**:客户端靠它判断"这是新卡还是同一张卡改了"。
|
||
// 用 `person-<id>@<域>`(不能用"姓名"——同名的人会互相覆盖,
|
||
// 而且改名之后客户端会当成"删一张加一张")。
|
||
$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;
|
||
}
|
||
} |