Files
WhatsApp-Chat-Exporter/Whatsapp_Chat_Exporter/vcards_contacts.py
KnugiHK 43658a92c4 Replace print with logger in read_vcards_file
Changed the contact import message from a print statement to a logger.info call for better logging consistency.
2025-12-14 21:57:17 +08:00

117 lines
3.8 KiB
Python

import logging
import re
import quopri
from typing import List, TypedDict
from Whatsapp_Chat_Exporter.data_model import ChatStore
from Whatsapp_Chat_Exporter.utility import CLEAR_LINE, Device
logger = logging.getLogger(__name__)
class ExportedContactNumbers(TypedDict):
full_name: str
numbers: List[str]
class ContactsFromVCards:
def __init__(self) -> None:
self.contact_mapping = []
def is_empty(self):
return self.contact_mapping == []
def load_vcf_file(self, vcf_file_path: str, default_country_code: str):
self.contact_mapping = read_vcards_file(vcf_file_path, default_country_code)
def enrich_from_vcards(self, chats):
for number, name in self.contact_mapping:
# short number must be a bad contact, lets skip it
if len(number) <= 5:
continue
chats_search = filter_chats_by_prefix(chats, number).values()
if chats_search:
for chat in chats_search:
if not hasattr(chat, 'name') or (hasattr(chat, 'name') and chat.name is None):
setattr(chat, 'name', name)
else:
chats.add_chat(number + "@s.whatsapp.net", ChatStore(Device.ANDROID, name))
def decode_vcard_value(value: str) -> str:
"""Decode a vCard value that may be quoted-printable UTF-8."""
try:
value = value.replace("=\n", "") # remove soft line breaks
bytes_val = quopri.decodestring(value)
return bytes_val.decode("utf-8", errors="replace")
except Exception:
return value
def read_vcards_file(vcf_file_path, default_country_code: str):
contacts = []
with open(vcf_file_path, "r", encoding="utf-8", errors="ignore") as f:
content = f.read()
# Split into individual vCards
vcards = content.split("BEGIN:VCARD")
for vcard in vcards:
if "END:VCARD" not in vcard:
continue
# Extract name in priority: FN -> N -> ORG
name = None
for field in ("FN", "N", "ORG"):
match = re.search(rf'^{field}(?:;[^:]*)?:(.*)', vcard, re.IGNORECASE | re.MULTILINE)
if match:
name = decode_vcard_value(match.group(1).strip())
break
if not name:
continue
# Extract phone numbers
numbers = re.findall(r'^\s*TEL(?:;[^:]*)?:(\+?\d+)', vcard, re.IGNORECASE | re.MULTILINE)
if not numbers:
continue
contact = {
"full_name": name,
"numbers": numbers,
}
contacts.append(contact)
logger.info(f"Imported {len(contacts)} contacts/vcards{CLEAR_LINE}")
return map_number_to_name(contacts, default_country_code)
def filter_chats_by_prefix(chats, prefix: str):
return {k: v for k, v in chats.items() if k.startswith(prefix)}
def map_number_to_name(contacts, default_country_code: str):
mapping = []
for contact in contacts:
for index, num in enumerate(contact['numbers']):
normalized = normalize_number(num, default_country_code)
if len(contact['numbers']) > 1:
name = f"{contact['full_name']} ({index+1})"
else:
name = contact['full_name']
mapping.append((normalized, name))
return mapping
def normalize_number(number: str, country_code: str):
# Clean the number
number = ''.join(c for c in number if c.isdigit() or c == "+")
# A number that starts with a + or 00 means it already have a country code
for starting_char in ('+', "00"):
if number.startswith(starting_char):
return number[len(starting_char):]
# leading zero should be removed
if number.startswith('0'):
number = number[1:]
return country_code + number # fall back