mirror of
https://github.com/KnugiHK/WhatsApp-Chat-Exporter.git
synced 2026-02-10 19:22:24 +00:00
Refactor vCard parsing to improve decoding and structure
Replaces regex-based vCard parsing with dedicated functions for parsing lines, handling quoted-printable encoding, and extracting fields. Adds support for CHARSET and ENCODING parameters, improves handling of multi-line and encoded values, and centralizes vCard entry processing for better maintainability and accuracy.
This commit is contained in:
@@ -38,14 +38,124 @@ class ContactsFromVCards:
|
|||||||
chats.add_chat(number + "@s.whatsapp.net", ChatStore(Device.ANDROID, name))
|
chats.add_chat(number + "@s.whatsapp.net", ChatStore(Device.ANDROID, name))
|
||||||
|
|
||||||
|
|
||||||
def decode_vcard_value(value: str) -> str:
|
def decode_quoted_printable(value: str, charset: str) -> str:
|
||||||
"""Decode a vCard value that may be quoted-printable UTF-8."""
|
"""Decode a vCard value that may be quoted-printable UTF-8."""
|
||||||
try:
|
bytes_val = quopri.decodestring(value)
|
||||||
value = value.replace("=\n", "") # remove soft line breaks
|
return bytes_val.decode(charset, errors="replace")
|
||||||
bytes_val = quopri.decodestring(value)
|
|
||||||
return bytes_val.decode("utf-8", errors="replace")
|
def _parse_vcard_line(line: str) -> tuple[str, dict[str, str], str] | None:
|
||||||
except Exception:
|
"""
|
||||||
return value
|
Parses a single vCard property line into its components:
|
||||||
|
Property Name, Parameters (as a dict), and Value.
|
||||||
|
|
||||||
|
Example: 'FN;CHARSET=UTF-8:John Doe' -> ('FN', {'CHARSET': 'UTF-8'}, 'John Doe')
|
||||||
|
"""
|
||||||
|
# Find the first colon, which separates the property/parameters from the value.
|
||||||
|
colon_index = line.find(':')
|
||||||
|
if colon_index == -1:
|
||||||
|
return None # Invalid vCard line format
|
||||||
|
|
||||||
|
prop_and_params = line[:colon_index].strip()
|
||||||
|
value = line[colon_index + 1:].strip()
|
||||||
|
|
||||||
|
# Split property name from parameters
|
||||||
|
parts = prop_and_params.split(';')
|
||||||
|
property_name = parts[0].upper()
|
||||||
|
|
||||||
|
parameters = {}
|
||||||
|
for part in parts[1:]:
|
||||||
|
if '=' in part:
|
||||||
|
key, val = part.split('=', 1)
|
||||||
|
parameters[key.upper()] = val.strip('"') # Remove potential quotes from value
|
||||||
|
|
||||||
|
return property_name, parameters, value
|
||||||
|
|
||||||
|
|
||||||
|
def get_vcard_value(entry: str, field_name: str) -> list[str]:
|
||||||
|
"""
|
||||||
|
Scans the vCard entry for lines starting with the specific field_name
|
||||||
|
and returns a list of its decoded values, handling parameters like
|
||||||
|
ENCODING and CHARSET.
|
||||||
|
"""
|
||||||
|
target_name = field_name.upper()
|
||||||
|
cached_line = ""
|
||||||
|
charset = "utf-8"
|
||||||
|
values = []
|
||||||
|
|
||||||
|
for line in entry.splitlines():
|
||||||
|
line = line.strip()
|
||||||
|
if cached_line:
|
||||||
|
if line.endswith('='):
|
||||||
|
cached_line += line[:-1]
|
||||||
|
continue # Wait for the next line to complete the value
|
||||||
|
values.append(decode_quoted_printable(cached_line + line, charset))
|
||||||
|
cached_line = ""
|
||||||
|
else:
|
||||||
|
# Skip empty lines or lines that don't start with the target field (after stripping)
|
||||||
|
if not line or not line.upper().startswith(target_name):
|
||||||
|
continue
|
||||||
|
|
||||||
|
parsed = _parse_vcard_line(line)
|
||||||
|
if parsed is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
prop_name, params, raw_value = parsed
|
||||||
|
|
||||||
|
if prop_name != target_name:
|
||||||
|
continue
|
||||||
|
|
||||||
|
encoding = params.get('ENCODING')
|
||||||
|
charset = params.get('CHARSET', 'utf-8')
|
||||||
|
|
||||||
|
# Apply decoding if ENCODING parameter is present
|
||||||
|
if encoding == 'QUOTED-PRINTABLE':
|
||||||
|
if raw_value.endswith('='):
|
||||||
|
# Handle soft line breaks in quoted-printable and cache the line
|
||||||
|
cached_line += raw_value[:-1]
|
||||||
|
continue # Wait for the next line to complete the value
|
||||||
|
values.append(decode_quoted_printable(raw_value, charset))
|
||||||
|
elif encoding:
|
||||||
|
raise NotImplementedError(f"Encoding '{encoding}' not supported yet.")
|
||||||
|
else:
|
||||||
|
values.append(raw_value)
|
||||||
|
return values
|
||||||
|
|
||||||
|
|
||||||
|
def process_vcard_entry(entry: str) -> dict | bool:
|
||||||
|
"""
|
||||||
|
Process a vCard entry using pure string manipulation
|
||||||
|
|
||||||
|
Args:
|
||||||
|
entry: A string containing a single vCard block.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A dictionary of the extracted data or False if required fields are missing.
|
||||||
|
"""
|
||||||
|
|
||||||
|
name = None
|
||||||
|
|
||||||
|
# Extract name in priority: FN -> N -> ORG
|
||||||
|
for field in ("FN", "N", "ORG"):
|
||||||
|
if name_values := get_vcard_value(entry, field):
|
||||||
|
name = name_values[0].replace(';', ' ') # Simple cleanup for structured name
|
||||||
|
break
|
||||||
|
|
||||||
|
if not name:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# 2. Extract phone numbers
|
||||||
|
numbers = get_vcard_value(entry, "TEL")
|
||||||
|
|
||||||
|
# Ensure at least one number was found
|
||||||
|
if not numbers:
|
||||||
|
return False
|
||||||
|
|
||||||
|
return {
|
||||||
|
"full_name": name,
|
||||||
|
# Remove duplications
|
||||||
|
"numbers": set(numbers),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def read_vcards_file(vcf_file_path, default_country_code: str):
|
def read_vcards_file(vcf_file_path, default_country_code: str):
|
||||||
contacts = []
|
contacts = []
|
||||||
@@ -58,27 +168,8 @@ def read_vcards_file(vcf_file_path, default_country_code: str):
|
|||||||
if "END:VCARD" not in vcard:
|
if "END:VCARD" not in vcard:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Extract name in priority: FN -> N -> ORG
|
if contact := process_vcard_entry(vcard):
|
||||||
name = None
|
contacts.append(contact)
|
||||||
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}")
|
logger.info(f"Imported {len(contacts)} contacts/vcards{CLEAR_LINE}")
|
||||||
return map_number_to_name(contacts, default_country_code)
|
return map_number_to_name(contacts, default_country_code)
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ def test_readVCardsFile():
|
|||||||
# Test complex name
|
# Test complex name
|
||||||
assert data[1][1] == "Yard Lawn Guy, Jose Lopez"
|
assert data[1][1] == "Yard Lawn Guy, Jose Lopez"
|
||||||
# Test name with emoji
|
# Test name with emoji
|
||||||
assert data[2][1] == "John Butler 🌟"
|
assert data[2][1] == "John Butler 🌟💫🌟"
|
||||||
# Test note with multi-line encoding
|
# Test note with multi-line encoding
|
||||||
assert data[3][1] == "Airline Contact #'s"
|
assert data[3][1] == "Airline Contact #'s"
|
||||||
# Test address with multi-line encoding
|
# Test address with multi-line encoding
|
||||||
|
|||||||
Reference in New Issue
Block a user