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:
KnugiHK
2025-12-14 23:00:48 +08:00
parent 43658a92c4
commit ddd0ac3143
2 changed files with 120 additions and 29 deletions

View File

@@ -38,14 +38,124 @@ class ContactsFromVCards:
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."""
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
bytes_val = quopri.decodestring(value)
return bytes_val.decode(charset, errors="replace")
def _parse_vcard_line(line: str) -> tuple[str, dict[str, str], str] | None:
"""
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):
contacts = []
@@ -58,27 +168,8 @@ def read_vcards_file(vcf_file_path, default_country_code: str):
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)
if contact := process_vcard_entry(vcard):
contacts.append(contact)
logger.info(f"Imported {len(contacts)} contacts/vcards{CLEAR_LINE}")
return map_number_to_name(contacts, default_country_code)

View File

@@ -23,7 +23,7 @@ def test_readVCardsFile():
# Test complex name
assert data[1][1] == "Yard Lawn Guy, Jose Lopez"
# Test name with emoji
assert data[2][1] == "John Butler 🌟"
assert data[2][1] == "John Butler 🌟💫🌟"
# Test note with multi-line encoding
assert data[3][1] == "Airline Contact #'s"
# Test address with multi-line encoding