Files
CRM_Montzar/apps/web/src/lib/phone-contacts.ts
T

226 lines
6.2 KiB
TypeScript
Raw Normal View History

/** مخاطب خامِ آماده‌ی ورود به CRM */
export type PhoneContactDraft = {
key: string;
firstName: string;
lastName: string;
fullName: string;
mobile?: string;
phone?: string;
email?: string;
};
type ContactInfoLike = {
name?: string[];
email?: string[];
tel?: string[];
};
type ContactsManagerLike = {
select: (
properties: string[],
options?: { multiple?: boolean },
) => Promise<ContactInfoLike[]>;
};
function contactsApi(): ContactsManagerLike | null {
const nav = navigator as Navigator & { contacts?: ContactsManagerLike };
if (!nav.contacts || typeof nav.contacts.select !== 'function') return null;
return nav.contacts;
}
export function isContactPickerSupported() {
return Boolean(contactsApi());
}
function splitName(full: string): { firstName: string; lastName: string } {
const parts = full.trim().split(/\s+/).filter(Boolean);
if (!parts.length) return { firstName: 'بدون‌نام', lastName: '' };
if (parts.length === 1) return { firstName: parts[0]!, lastName: '' };
return { firstName: parts[0]!, lastName: parts.slice(1).join(' ') };
}
function cleanPhone(raw: string): string {
return raw.replace(/[^\d+]/g, '').trim();
}
function looksMobile(raw: string): boolean {
const digits = raw.replace(/\D/g, '');
if (/^09\d{9}$/.test(digits)) return true;
if (/^989\d{9}$/.test(digits)) return true;
if (/^9\d{9}$/.test(digits)) return true;
return /mobile|cell|iphone|موبایل|همراه/i.test(raw);
}
function pickPhones(tels: string[]): { mobile?: string; phone?: string } {
const cleaned = tels.map((t) => cleanPhone(t)).filter(Boolean);
if (!cleaned.length) return {};
const mobile = cleaned.find((t) => looksMobile(t));
if (mobile) {
const phone = cleaned.find((t) => t !== mobile);
return { mobile, phone };
}
return { mobile: cleaned[0], phone: cleaned[1] };
}
function draftFromParts(input: {
fullName: string;
firstName?: string;
lastName?: string;
tels?: string[];
emails?: string[];
}): PhoneContactDraft | null {
let firstName = (input.firstName ?? '').trim();
let lastName = (input.lastName ?? '').trim();
const fullName = (input.fullName || `${firstName} ${lastName}`).trim();
if (!firstName && !lastName) {
const split = splitName(fullName || 'بدون‌نام');
firstName = split.firstName;
lastName = split.lastName;
}
const phones = pickPhones(input.tels ?? []);
const email = (input.emails ?? []).map((e) => e.trim()).find(Boolean);
if (!phones.mobile && !phones.phone && !email && !fullName) return null;
const key = [
firstName,
lastName,
phones.mobile ?? '',
phones.phone ?? '',
email ?? '',
].join('|');
return {
key,
firstName: firstName || 'بدون‌نام',
lastName,
fullName: fullName || `${firstName} ${lastName}`.trim(),
mobile: phones.mobile,
phone: phones.phone,
email,
};
}
export async function pickPhoneContacts(): Promise<PhoneContactDraft[]> {
const api = contactsApi();
if (!api) {
throw new Error('CONTACT_PICKER_UNSUPPORTED');
}
const rows = await api.select(['name', 'email', 'tel'], { multiple: true });
const drafts: PhoneContactDraft[] = [];
const seen = new Set<string>();
for (const row of rows) {
const fullName = (row.name ?? []).map((n) => n.trim()).filter(Boolean).join(' ');
const draft = draftFromParts({
fullName,
tels: row.tel ?? [],
emails: row.email ?? [],
});
if (!draft || seen.has(draft.key)) continue;
seen.add(draft.key);
drafts.push(draft);
}
return drafts;
}
function unfoldVcard(text: string): string[] {
const raw = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n').split('\n');
const lines: string[] = [];
for (const line of raw) {
if (!line) continue;
if (/^[ \t]/.test(line) && lines.length) {
lines[lines.length - 1] += line.slice(1);
} else {
lines.push(line);
}
}
return lines;
}
function parseVcardProp(line: string): { name: string; params: string; value: string } | null {
const colon = line.indexOf(':');
if (colon < 0) return null;
const left = line.slice(0, colon);
const value = line.slice(colon + 1);
const semi = left.indexOf(';');
const name = (semi >= 0 ? left.slice(0, semi) : left).toUpperCase();
const params = semi >= 0 ? left.slice(semi + 1) : '';
return { name, params, value };
}
/** پارس سادهٔ فایل‌های `.vcf` / vCard 2.1–4.0 */
export function parseVCard(text: string): PhoneContactDraft[] {
const lines = unfoldVcard(text);
const drafts: PhoneContactDraft[] = [];
const seen = new Set<string>();
let inCard = false;
let fn = '';
let firstName = '';
let lastName = '';
const tels: string[] = [];
const emails: string[] = [];
function flush() {
const draft = draftFromParts({
fullName: fn,
firstName,
lastName,
tels,
emails,
});
if (draft && !seen.has(draft.key)) {
seen.add(draft.key);
drafts.push(draft);
}
fn = '';
firstName = '';
lastName = '';
tels.length = 0;
emails.length = 0;
}
for (const line of lines) {
const upper = line.toUpperCase();
if (upper === 'BEGIN:VCARD') {
if (inCard) flush();
inCard = true;
continue;
}
if (upper === 'END:VCARD') {
if (inCard) flush();
inCard = false;
continue;
}
if (!inCard) continue;
const prop = parseVcardProp(line);
if (!prop) continue;
if (prop.name === 'FN') {
fn = prop.value.trim();
} else if (prop.name === 'N') {
const parts = prop.value.split(';');
lastName = (parts[0] ?? '').trim();
firstName = (parts[1] ?? '').trim();
} else if (prop.name === 'TEL') {
const tel = cleanPhone(prop.value);
if (!tel) continue;
if (/CELL|MOBILE|IPHONE|VOICE/i.test(prop.params) || looksMobile(prop.value)) {
tels.unshift(tel);
} else {
tels.push(tel);
}
} else if (prop.name === 'EMAIL') {
const email = prop.value.trim();
if (email) emails.push(email);
}
}
if (inCard) flush();
return drafts;
}
export async function parseVCardFile(file: File): Promise<PhoneContactDraft[]> {
const text = await file.text();
return parseVCard(text);
}