mirror of
https://github.com/KnugiHK/WhatsApp-Chat-Exporter.git
synced 2026-01-28 21:30:43 +00:00
Changed the contact import message from a print statement to a logger.info call for better logging consistency.
117 lines
3.8 KiB
Python
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
|