feat: 完成循环1审查补齐 - RFC 6350 vCard引擎、全量自动化单测集成与根目录gitignore规范

这个提交包含在:
wikiboss
2026-09-20 17:22:09 +08:00
父节点 9cc49aa329
当前提交 3221ce77c4
共有 4 个文件被更改,包括 9978 次插入 和 0 次删除

查看文件

@@ -0,0 +1,419 @@
/**
* OmniCard AI (CRMmp) - RFC 6350 国际通用标准 vCard 3.0/4.0 编解码与生成引擎
* 遵循 RFC 6350 (vCard 4.0) 与 RFC 2426 (vCard 3.0) 国际标准
* 零外部依赖,纯 TypeScript 高效实现
*/
export interface VCardName {
familyName?: string;
givenName?: string;
additionalNames?: string;
honorificPrefixes?: string;
honorificSuffixes?: string;
}
export interface VCardAddress {
postOfficeBox?: string;
extendedAddress?: string;
street?: string;
locality?: string;
region?: string;
postalCode?: string;
countryName?: string;
}
export interface VCardTelephone {
number: string;
type?: 'WORK' | 'CELL' | 'HOME' | 'MAIN' | 'OTHER' | string;
}
export interface VCardEmail {
address: string;
type?: 'WORK' | 'HOME' | 'OTHER' | string;
}
export interface VCardSocialProfile {
type: 'linkedin' | 'whatsapp' | 'telegram' | 'twitter' | 'wechat' | string;
url: string;
}
export interface VCardPhoto {
url?: string;
base64?: string;
mediaType?: string;
}
export interface VCardData {
fn: string;
name?: VCardName;
title?: string;
org?: string | string[];
tel?: string | VCardTelephone | (string | VCardTelephone)[];
email?: string | VCardEmail | (string | VCardEmail)[];
url?: string | string[];
adr?: VCardAddress | VCardAddress[];
note?: string;
socialProfiles?: VCardSocialProfile[];
photo?: string | VCardPhoto;
}
export type VCardVersion = '3.0' | '4.0';
/**
* 转义 RFC 6350 TEXT 字段值中的保留特殊字符
* 字符顺序:先转义反斜杠,再转义分号、逗号和换行
*/
export function escapeText(text: string): string {
if (!text) return '';
return text
.replace(/\\/g, '\\\\')
.replace(/;/g, '\\;')
.replace(/,/g, '\\,')
.replace(/\r\n|\r|\n/g, '\\n');
}
/**
* 反转义 RFC 6350 TEXT 字段值
*/
export function unescapeText(text: string): string {
if (!text) return '';
return text
.replace(/\\n/gi, '\n')
.replace(/\\,/g, ',')
.replace(/\\;/g, ';')
.replace(/\\\\/g, '\\');
}
/**
* 严格执行 RFC 6350 第 3.2 节长行折叠 (Line Folding) 规范
* 每一物理行不超过 75 字符,超长在 75 处插入 CRLF 及一个前置空格进行折叠
*/
export function foldLine(line: string, maxLen: number = 75): string {
if (line.length <= maxLen) return line;
let result = line.slice(0, maxLen);
let remaining = line.slice(maxLen);
while (remaining.length > 0) {
// 后续行因为开头有 1 个空格作为折叠标记,故内容取 maxLen - 1 字符
result += '\r\n ' + remaining.slice(0, maxLen - 1);
remaining = remaining.slice(maxLen - 1);
}
return result;
}
/**
* RFC 6350 反折叠 (Line Unfolding):移除所有 CRLF 或 LF 后紧跟的空格/制表符
*/
export function unfoldLines(vcardString: string): string {
return vcardString.replace(/\r?\n[ \t]/g, '');
}
/**
* 生成 RFC 6350 / RFC 2426 合规的 vCard 字符串
* @param data 电子名片结构化数据
* @param version 版本,默认 '4.0'
*/
export function generateVCard(data: VCardData, version: VCardVersion = '4.0'): string {
const lines: string[] = [];
// 1. 头部标记
lines.push('BEGIN:VCARD');
lines.push(`VERSION:${version}`);
// 2. 全名 (FN) - RFC 6350 强制要求
lines.push(`FN:${escapeText(data.fn)}`);
// 3. 结构化姓名 (N:Family;Given;Additional;Prefix;Suffix)
if (data.name) {
const nParts = [
escapeText(data.name.familyName || ''),
escapeText(data.name.givenName || ''),
escapeText(data.name.additionalNames || ''),
escapeText(data.name.honorificPrefixes || ''),
escapeText(data.name.honorificSuffixes || ''),
];
lines.push(`N:${nParts.join(';')}`);
}
// 4. 头衔 (TITLE)
if (data.title) {
lines.push(`TITLE:${escapeText(data.title)}`);
}
// 5. 组织机构 (ORG)
if (data.org) {
if (Array.isArray(data.org)) {
lines.push(`ORG:${data.org.map((o) => escapeText(o)).join(';')}`);
} else {
lines.push(`ORG:${escapeText(data.org)}`);
}
}
// 6. 电话 (TEL)
if (data.tel) {
const tels = Array.isArray(data.tel) ? data.tel : [data.tel];
for (const t of tels) {
const num = typeof t === 'string' ? t : t.number;
const rawType = typeof t === 'string' ? 'CELL' : t.type || 'CELL';
const type = version === '4.0' ? rawType.toUpperCase() : rawType.toUpperCase();
lines.push(`TEL;TYPE=${type}:${num}`);
}
}
// 7. 邮箱 (EMAIL)
if (data.email) {
const emails = Array.isArray(data.email) ? data.email : [data.email];
for (const e of emails) {
const addr = typeof e === 'string' ? e : e.address;
const rawType = typeof e === 'string' ? 'WORK' : e.type || 'WORK';
const type = version === '3.0' ? `INTERNET,${rawType.toUpperCase()}` : rawType.toUpperCase();
lines.push(`EMAIL;TYPE=${type}:${addr}`);
}
}
// 8. 网址 / 移动微官网 (URL)
if (data.url) {
const urls = Array.isArray(data.url) ? data.url : [data.url];
for (const u of urls) {
lines.push(`URL:${u}`);
}
}
// 9. 备注 (NOTE)
if (data.note) {
lines.push(`NOTE:${escapeText(data.note)}`);
}
// 10. 地址 (ADR)
if (data.adr) {
const adrs = Array.isArray(data.adr) ? data.adr : [data.adr];
for (const a of adrs) {
const adrParts = [
escapeText(a.postOfficeBox || ''),
escapeText(a.extendedAddress || ''),
escapeText(a.street || ''),
escapeText(a.locality || ''),
escapeText(a.region || ''),
escapeText(a.postalCode || ''),
escapeText(a.countryName || ''),
];
lines.push(`ADR;TYPE=WORK:${adrParts.join(';')}`);
}
}
// 11. 社交网络主页 (X-SOCIALPROFILE)
if (data.socialProfiles && data.socialProfiles.length > 0) {
for (const sp of data.socialProfiles) {
lines.push(`X-SOCIALPROFILE;TYPE=${sp.type.toLowerCase()}:${sp.url}`);
}
}
// 12. 头像照片 (PHOTO)
if (data.photo) {
let photoUrl: string | undefined;
let photoBase64: string | undefined;
let mediaType = 'image/jpeg';
if (typeof data.photo === 'string') {
if (data.photo.startsWith('http://') || data.photo.startsWith('https://')) {
photoUrl = data.photo;
} else if (data.photo.startsWith('data:')) {
const match = data.photo.match(/^data:([^;]+);base64,(.+)$/i);
if (match) {
mediaType = match[1];
photoBase64 = match[2];
} else {
photoBase64 = data.photo;
}
} else {
photoBase64 = data.photo;
}
} else {
photoUrl = data.photo.url;
photoBase64 = data.photo.base64;
if (data.photo.mediaType) {
mediaType = data.photo.mediaType;
}
}
if (photoUrl) {
if (version === '3.0') {
lines.push(`PHOTO;VALUE=URI:${photoUrl}`);
} else {
lines.push(`PHOTO:${photoUrl}`);
}
} else if (photoBase64) {
// 清洗 Base64 内部可能包含的空白换行
const cleanBase64 = photoBase64.replace(/\s+/g, '');
const typeShort = mediaType.split('/')[1]?.toUpperCase() || 'JPEG';
if (version === '3.0') {
lines.push(`PHOTO;ENCODING=b;TYPE=${typeShort}:${cleanBase64}`);
} else {
lines.push(`PHOTO:data:${mediaType};base64,${cleanBase64}`);
}
}
}
// 13. 尾部标记
lines.push('END:VCARD');
// 对每一行应用 RFC 6350 行折叠,以 CRLF 拼接并以 CRLF 结尾
return lines.map((l) => foldLine(l)).join('\r\n') + '\r\n';
}
/**
* 校验并解析 vCard 字符串为 VCardData 结构
* @param vcardString 待解析的原始 vCard 字符串
*/
export function parseVCard(vcardString: string): Partial<VCardData> {
if (!vcardString || !vcardString.includes('BEGIN:VCARD') || !vcardString.includes('END:VCARD')) {
throw new Error('Invalid vCard: missing BEGIN:VCARD or END:VCARD');
}
// 第一步:反折叠
const unfolded = unfoldLines(vcardString);
const rawLines = unfolded.split(/\r?\n/);
const result: Partial<VCardData> = {};
const tels: VCardTelephone[] = [];
const emails: VCardEmail[] = [];
const urls: string[] = [];
const socialProfiles: VCardSocialProfile[] = [];
for (const rawLine of rawLines) {
const line = rawLine.trim();
if (!line || line === 'BEGIN:VCARD' || line === 'END:VCARD' || line.startsWith('VERSION:')) {
continue;
}
const colonIdx = line.indexOf(':');
if (colonIdx === -1) continue;
const head = line.slice(0, colonIdx);
const value = line.slice(colonIdx + 1);
const headParts = head.split(';');
const tag = headParts[0].toUpperCase();
const params = headParts.slice(1);
// 辅助解析 TYPE 参数
let typeParam = '';
for (const p of params) {
const m = p.match(/^TYPE=(.+)$/i);
if (m) {
// 过滤 INTERNET 等前缀
const types = m[1].split(',').map((t) => t.trim().replace(/^"|"$/g, ''));
const nonInternet = types.filter((t) => t.toUpperCase() !== 'INTERNET');
typeParam = nonInternet.length > 0 ? nonInternet[0] : types[0];
}
}
switch (tag) {
case 'FN': {
result.fn = unescapeText(value);
break;
}
case 'N': {
const parts = value.split(';');
const name: VCardName = {};
if (parts[0]) name.familyName = unescapeText(parts[0]);
if (parts[1]) name.givenName = unescapeText(parts[1]);
if (parts[2]) name.additionalNames = unescapeText(parts[2]);
if (parts[3]) name.honorificPrefixes = unescapeText(parts[3]);
if (parts[4]) name.honorificSuffixes = unescapeText(parts[4]);
result.name = name;
break;
}
case 'TITLE': {
result.title = unescapeText(value);
break;
}
case 'ORG': {
const orgParts = value.split(';').map((p) => unescapeText(p));
result.org = orgParts.length > 1 ? orgParts : orgParts[0];
break;
}
case 'TEL': {
tels.push({
number: value,
type: typeParam ? typeParam.toUpperCase() : 'CELL',
});
break;
}
case 'EMAIL': {
emails.push({
address: value,
type: typeParam ? typeParam.toUpperCase() : 'WORK',
});
break;
}
case 'URL': {
urls.push(value);
break;
}
case 'NOTE': {
result.note = unescapeText(value);
break;
}
case 'ADR': {
const parts = value.split(';').map((p) => unescapeText(p));
// RFC 6350: [PO Box, Extended, Street, Locality, Region, PostalCode, Country]
const adr: VCardAddress = {};
if (parts[0]) adr.postOfficeBox = parts[0];
if (parts[1]) adr.extendedAddress = parts[1];
if (parts[2]) adr.street = parts[2];
if (parts[3]) adr.locality = parts[3];
if (parts[4]) adr.region = parts[4];
if (parts[5]) adr.postalCode = parts[5];
if (parts[6]) adr.countryName = parts[6];
result.adr = adr;
break;
}
case 'X-SOCIALPROFILE': {
socialProfiles.push({
type: typeParam.toLowerCase() || 'other',
url: value,
});
break;
}
case 'PHOTO': {
// 判断是 Data URI 还是普通 URL 还是 3.0 Base64
if (value.startsWith('data:')) {
const m = value.match(/^data:([^;]+);base64,(.+)$/i);
if (m) {
result.photo = {
mediaType: m[1],
base64: m[2],
};
} else {
result.photo = { url: value };
}
} else if (params.some((p) => /ENCODING=b/i.test(p) || /BASE64/i.test(p))) {
let mediaType = 'image/jpeg';
for (const p of params) {
const tm = p.match(/TYPE=([^;]+)/i);
if (tm) {
mediaType = `image/${tm[1].toLowerCase()}`;
}
}
result.photo = {
mediaType,
base64: value,
};
} else {
result.photo = { url: value };
}
break;
}
}
}
if (tels.length > 0) result.tel = tels;
if (emails.length > 0) result.email = emails;
if (urls.length > 0) result.url = urls.length === 1 ? urls[0] : urls;
if (socialProfiles.length > 0) result.socialProfiles = socialProfiles;
return result;
}