diff --git a/DESCRIPTION.md b/DESCRIPTION.md index 8620ece..47178c7 100644 --- a/DESCRIPTION.md +++ b/DESCRIPTION.md @@ -7,6 +7,14 @@ Create vCards from a spreadsheet of contacts - useful for business cards, QR cod ## Features - **vCard 3.0 and 4.0 support** - Generate either format +- **Custom CSV mapping** - Map any CSV column names to vCard fields +- **Batch processing** - Convert entire directories of CSV files +- **Single-file output** - Combine all contacts into one .vcf file +- **File splitting** - Split output by size (`--max-vcard-file-size`) or contact count (`--max-vcards-per-file`) +- **Multi-type fields** - Multiple phones (`phone_cell`, `phone_home`, `phone_work`, `phone_fax`), emails (`email_home`, `email_work`), and addresses (work + home) +- **Media embedding** - Embed photos, logos, and keys (base64 or URL) +- **Accent stripping** - Remove diacritics for compatibility (`--strip-accents`) +- **Auto-detect encoding** - Handles various file encodings - **Command-line interface** - Convert files directly from terminal - **Library API** - Use programmatically in your Python code - **Type hints** - Full typing support for IDE autocomplete @@ -21,6 +29,12 @@ pip install csv2vcard # With CLI support pip install csv2vcard[cli] + +# With encoding detection +pip install csv2vcard[encoding] + +# Full installation +pip install csv2vcard[all] ``` ## Quick Start @@ -34,8 +48,26 @@ csv2vcard convert contacts.csv # Specify output directory and vCard version csv2vcard convert contacts.csv -o ./vcards -V 4.0 -# Use semicolon delimiter -csv2vcard convert contacts.csv -d ";" +# Convert all CSVs in a directory +csv2vcard convert ./csv_folder/ + +# Export all contacts to a single file +csv2vcard convert contacts.csv --single-vcard + +# Split output into multiple files (max 100 contacts per file) +csv2vcard convert contacts.csv --max-vcards-per-file 100 + +# Split output by file size (max 1MB per file) +csv2vcard convert contacts.csv --max-vcard-file-size 1048576 + +# Strip accents for compatibility +csv2vcard convert contacts.csv --strip-accents + +# Use custom column mapping +csv2vcard convert data.csv -m mapping.json + +# Show example mapping file +csv2vcard mapping # Create a test vCard (Forrest Gump) csv2vcard test @@ -57,45 +89,115 @@ csv2vcard( ",", output_dir="./vcards", version=VCardVersion.V4_0, + single_file=True, # All contacts in one file + mapping_file="mapping.json", # Custom column names + strip_accents=True, # Remove diacritics + max_vcards_per_file=100, # Split into multiple files ) +# Convert entire directory +csv2vcard("./csv_folder/", ",", output_dir="./vcards") + # Test with sample contact test_csv2vcard() ``` ## CSV Format -Your CSV file should have these column headers: +Your CSV file should have column headers that match vCard fields. Use the default names or create a custom mapping. + +### Default Column Names + +**Required:** `last_name`, `first_name` + +**Basic fields:** +``` +last_name, first_name, middle_name, name_prefix, name_suffix, nickname, gender, birthday, anniversary, org, title, role, note +``` + +**Contact fields (single):** +``` +phone, email, website +``` + +**Multi-type phone:** +``` +phone_cell, phone_home, phone_work, phone_fax +``` + +**Multi-type email:** +``` +email_home, email_work +``` +**Work address:** ``` -last_name,first_name,title,org,phone,email,website,street,city,p_code,country +street, city, region, p_code, country ``` -**Required columns:** `last_name`, `first_name` +**Home address:** +``` +home_street, home_city, home_region, home_p_code, home_country +``` -**Optional columns:** `title`, `org`, `phone`, `email`, `website`, `street`, `city`, `p_code`, `country` +**Media:** +``` +photo, logo, key +``` + +**Additional fields:** +``` +categories, geo, tz +``` ### Example CSV ```csv -last_name,first_name,title,org,phone,email,website,street,city,p_code,country -Gump,Forrest,Shrimp Man,Bubba Gump Shrimp Co.,+1234567890,forrest@example.com,https://example.com,42 Plantation St.,Baytown,30314,USA -Doe,Jane,Developer,Tech Corp,+0987654321,jane@example.com,https://jane.dev,123 Main St.,New York,10001,USA +last_name,first_name,title,org,phone,email,street,city,p_code,country,birthday,note +Gump,Forrest,Shrimp Man,Bubba Gump Shrimp Co.,+1234567890,forrest@example.com,42 Plantation St.,Baytown,30314,USA,1944-06-06,Life is like a box of chocolates +Doe,Jane,Developer,Tech Corp,+0987654321,jane@example.com,123 Main St.,New York,10001,USA,, +``` + +### Custom Column Mapping + +Create a JSON file to map your CSV column names to vCard fields: + +```json +{ + "first_name": ["Given Name", "FirstName", "First"], + "last_name": ["Surname", "FamilyName", "Last"], + "email": ["Email Address", "E-Mail"], + "phone": ["Phone Number", "Mobile", "Tel"] +} +``` + +Then use it: +```bash +csv2vcard convert data.csv -m mapping.json ``` ## CLI Reference ``` -csv2vcard convert [OPTIONS] CSV_FILE +csv2vcard convert [OPTIONS] SOURCE + +Arguments: + SOURCE Path to CSV file or directory containing CSV files Options: - -d, --delimiter TEXT CSV field delimiter (default: ",") - -o, --output PATH Output directory (default: ./export/) - -V, --vcard-version TEXT vCard version: 3.0 or 4.0 (default: 3.0) - --strict Exit on validation errors - -v, --verbose Enable verbose output - --version Show version and exit - --help Show help message + -d, --delimiter TEXT CSV field delimiter (default: ",") + -o, --output PATH Output directory (default: ./export/) + -V, --vcard-version TEXT vCard version: 3.0 or 4.0 (default: 3.0) + -1, --single-vcard Export all contacts to a single .vcf file + -m, --mapping PATH Path to JSON mapping file + -e, --encoding TEXT CSV file encoding (auto-detected if not set) + -a, --strip-accents Remove accents/diacritics from contact fields + --max-vcard-file-size INT Split output by file size (bytes) + --max-vcards-per-file INT Split output by contact count + --strict Exit on validation errors + -v, --verbose Enable verbose output + --version Show version and exit + --help Show help message ``` ## API Reference @@ -108,11 +210,17 @@ from csv2vcard.models import VCardVersion # Convert CSV to vCards files = csv2vcard( - csv_filename, # Path to CSV file + csv_filename, # Path to CSV file or directory csv_delimiter=",", # Field delimiter output_dir=None, # Output directory (default: ./export/) version=VCardVersion.V3_0, # vCard version strict=False, # Raise on validation errors + single_file=False, # Combine all contacts into one file + encoding=None, # File encoding (auto-detected) + mapping_file=None, # Path to JSON mapping file + strip_accents=False, # Remove diacritics + max_file_size=None, # Split by file size (bytes) + max_vcards_per_file=None, # Split by contact count ) # Returns: List[Path] of created vCard files @@ -132,7 +240,11 @@ from csv2vcard.models import Contact, VCardVersion, VCardOutput contact = Contact( last_name="Doe", first_name="John", + middle_name="William", email="john@example.com", + phone="+1234567890", + birthday="1990-01-15", + nickname="Johnny", ) # Or from a dictionary @@ -147,6 +259,7 @@ VCardVersion.V4_0 # vCard 4.0 (RFC 6350) - Python 3.9 or higher - For CLI: `typer` (installed with `csv2vcard[cli]`) +- For encoding detection: `charset-normalizer` (installed with `csv2vcard[encoding]`) ## License diff --git a/README.md b/README.md index e66a031..50a36be 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,10 @@ Create vCards from a spreadsheet of contacts - useful for business cards, QR cod - **Custom CSV mapping** - Map any CSV column names to vCard fields - **Batch processing** - Convert entire directories of CSV files - **Single-file output** - Combine all contacts into one .vcf file +- **File splitting** - Split output by size or contact count +- **Multi-type fields** - Multiple phone numbers, emails, and addresses per contact +- **Media embedding** - Embed photos, logos, and keys (base64 or URL) +- **Accent stripping** - Remove diacritics for compatibility - **Auto-detect encoding** - Handles various file encodings - **Command-line interface** - Convert files directly from terminal - **Library API** - Use programmatically in your Python code @@ -63,6 +67,15 @@ csv2vcard convert ./csv_folder/ # Export all contacts to a single file csv2vcard convert contacts.csv --single-vcard +# Split output into multiple files (max 100 contacts per file) +csv2vcard convert contacts.csv --max-vcards-per-file 100 + +# Split output by file size (max 1MB per file) +csv2vcard convert contacts.csv --max-vcard-file-size 1048576 + +# Strip accents for compatibility (é→e, ü→u) +csv2vcard convert contacts.csv --strip-accents + # Use custom column mapping csv2vcard convert data.csv -m mapping.json @@ -91,6 +104,8 @@ csv2vcard( version=VCardVersion.V4_0, single_file=True, # All contacts in one file mapping_file="mapping.json", # Custom column names + strip_accents=True, # Remove diacritics + max_vcards_per_file=100, # Split into multiple files ) # Convert entire directory @@ -106,13 +121,47 @@ Your CSV file should have column headers that match vCard fields. Use the defaul ### Default Column Names +**Required:** `last_name`, `first_name` + +**Basic fields:** ``` -last_name,first_name,middle_name,name_prefix,name_suffix,nickname,gender,birthday,anniversary,phone,email,website,org,title,role,street,city,region,p_code,country,note +last_name, first_name, middle_name, name_prefix, name_suffix, nickname, gender, birthday, anniversary, org, title, role, note ``` -**Required:** `last_name`, `first_name` +**Contact fields (single):** +``` +phone, email, website +``` + +**Multi-type phone:** +``` +phone_cell, phone_home, phone_work, phone_fax +``` + +**Multi-type email:** +``` +email_home, email_work +``` + +**Work address:** +``` +street, city, region, p_code, country +``` + +**Home address:** +``` +home_street, home_city, home_region, home_p_code, home_country +``` -**Optional:** All other fields +**Media:** +``` +photo, logo, key +``` + +**Additional fields:** +``` +categories, geo, tz +``` ### Example CSV @@ -149,16 +198,19 @@ Arguments: SOURCE Path to CSV file or directory containing CSV files Options: - -d, --delimiter TEXT CSV field delimiter (default: ",") - -o, --output PATH Output directory (default: ./export/) - -V, --vcard-version TEXT vCard version: 3.0 or 4.0 (default: 3.0) - -1, --single-vcard Export all contacts to a single .vcf file - -m, --mapping PATH Path to JSON mapping file - -e, --encoding TEXT CSV file encoding (auto-detected if not set) - --strict Exit on validation errors - -v, --verbose Enable verbose output - --version Show version and exit - --help Show help message + -d, --delimiter TEXT CSV field delimiter (default: ",") + -o, --output PATH Output directory (default: ./export/) + -V, --vcard-version TEXT vCard version: 3.0 or 4.0 (default: 3.0) + -1, --single-vcard Export all contacts to a single .vcf file + -m, --mapping PATH Path to JSON mapping file + -e, --encoding TEXT CSV file encoding (auto-detected if not set) + -a, --strip-accents Remove accents/diacritics from contact fields + --max-vcard-file-size INT Split output by file size (bytes) + --max-vcards-per-file INT Split output by contact count + --strict Exit on validation errors + -v, --verbose Enable verbose output + --version Show version and exit + --help Show help message ``` ## API Reference @@ -179,6 +231,9 @@ files = csv2vcard( single_file=False, # Combine all contacts into one file encoding=None, # File encoding (auto-detected) mapping_file=None, # Path to JSON mapping file + strip_accents=False, # Remove diacritics (é→e, ü→u) + max_file_size=None, # Split by file size (bytes) + max_vcards_per_file=None, # Split by contact count ) # Returns: List[Path] of created vCard files @@ -215,10 +270,12 @@ VCardVersion.V4_0 # vCard 4.0 (RFC 6350) ## Supported vCard Fields +### Name & Basic Info + | Field | Description | Example | |-------|-------------|---------| -| `last_name` | Family name | Doe | -| `first_name` | Given name | John | +| `last_name` | Family name (required) | Doe | +| `first_name` | Given name (required) | John | | `middle_name` | Middle name | William | | `name_prefix` | Honorific prefix | Dr. | | `name_suffix` | Honorific suffix | Jr. | @@ -226,19 +283,56 @@ VCardVersion.V4_0 # vCard 4.0 (RFC 6350) | `gender` | Gender (M/F/O/N/U) | M | | `birthday` | Birth date (YYYY-MM-DD) | 1990-01-15 | | `anniversary` | Anniversary date | 2015-06-20 | -| `phone` | Phone number | +1234567890 | -| `email` | Email address | john@example.com | -| `website` | Website URL | https://example.com | | `org` | Organization | Acme Corp | | `title` | Job title | Developer | | `role` | Role/function | Team Lead | -| `street` | Street address | 123 Main St | -| `city` | City | New York | -| `region` | State/province | NY | -| `p_code` | Postal code | 10001 | -| `country` | Country | USA | | `note` | Notes | Any additional info | +### Contact Fields + +| Field | Description | vCard Type | +|-------|-------------|------------| +| `phone` | Default phone | TEL;TYPE=WORK | +| `phone_cell` | Mobile phone | TEL;TYPE=CELL | +| `phone_home` | Home phone | TEL;TYPE=HOME | +| `phone_work` | Work phone | TEL;TYPE=WORK | +| `phone_fax` | Fax number | TEL;TYPE=FAX | +| `email` | Default email | EMAIL;TYPE=WORK | +| `email_home` | Personal email | EMAIL;TYPE=HOME | +| `email_work` | Work email | EMAIL;TYPE=WORK | +| `website` | Website URL | URL | + +### Address Fields + +| Field | Description | vCard Type | +|-------|-------------|------------| +| `street` | Work street address | ADR;TYPE=WORK | +| `city` | Work city | ADR;TYPE=WORK | +| `region` | Work state/province | ADR;TYPE=WORK | +| `p_code` | Work postal code | ADR;TYPE=WORK | +| `country` | Work country | ADR;TYPE=WORK | +| `home_street` | Home street address | ADR;TYPE=HOME | +| `home_city` | Home city | ADR;TYPE=HOME | +| `home_region` | Home state/province | ADR;TYPE=HOME | +| `home_p_code` | Home postal code | ADR;TYPE=HOME | +| `home_country` | Home country | ADR;TYPE=HOME | + +### Media Fields + +| Field | Description | Format | +|-------|-------------|--------| +| `photo` | Contact photo | URL or base64 | +| `logo` | Company logo | URL or base64 | +| `key` | Public key | URL or base64 | + +### Additional Fields + +| Field | Description | Example | +|-------|-------------|---------| +| `categories` | Tags/groups (comma-separated) | Work,Friends | +| `geo` | Geographic coordinates | 37.386,-122.082 | +| `tz` | Timezone | America/New_York | + ## Requirements - Python 3.9 or higher diff --git a/csv2vcard/__init__.py b/csv2vcard/__init__.py index a578320..ac83b85 100644 --- a/csv2vcard/__init__.py +++ b/csv2vcard/__init__.py @@ -1,6 +1,6 @@ """csv2vcard - Convert CSV files to vCard format (3.0 and 4.0).""" -__version__ = "0.4.0" +__version__ = "0.5.0" # For backwards compatibility, users can still do: # from csv2vcard import csv2vcard diff --git a/csv2vcard/cli.py b/csv2vcard/cli.py index 3816ea1..64b1ff2 100644 --- a/csv2vcard/cli.py +++ b/csv2vcard/cli.py @@ -100,6 +100,28 @@ def convert( help="CSV file encoding (auto-detected if not specified)", ), ] = None, + strip_accents_opt: Annotated[ + bool, + typer.Option( + "--strip-accents", + "-a", + help="Remove accents/diacritics from contact fields", + ), + ] = False, + max_file_size: Annotated[ + Optional[int], + typer.Option( + "--max-vcard-file-size", + help="Maximum file size in bytes for split output files", + ), + ] = None, + max_vcards_per_file: Annotated[ + Optional[int], + typer.Option( + "--max-vcards-per-file", + help="Maximum number of vCards per output file", + ), + ] = None, strict: Annotated[ bool, typer.Option( @@ -137,6 +159,10 @@ def convert( csv2vcard convert ./csv_folder/ --single-vcard -o ./output csv2vcard convert data.csv -m mapping.json + + csv2vcard convert data.csv --strip-accents + + csv2vcard convert data.csv --max-vcards-per-file 100 """ # Configure logging log_level = logging.DEBUG if verbose else logging.INFO @@ -162,6 +188,9 @@ def convert( single_file=single_file, encoding=encoding, mapping_file=mapping_file, + strip_accents=strip_accents_opt, + max_file_size=max_file_size, + max_vcards_per_file=max_vcards_per_file, ) if files: typer.echo(f"Successfully created {len(files)} vCard file(s).") diff --git a/csv2vcard/create_vcard.py b/csv2vcard/create_vcard.py index d6156db..ba30a70 100644 --- a/csv2vcard/create_vcard.py +++ b/csv2vcard/create_vcard.py @@ -143,16 +143,34 @@ def _create_vcard_3(contact: Contact) -> str: if contact.org: lines.append(f"ORG;CHARSET=UTF-8:{_escape_vcard_value(contact.org)}") + # Phone numbers - backwards compatible single phone if contact.phone: lines.append(f"TEL;TYPE=WORK,VOICE:{contact.phone}") + # Multi-type phone numbers (v0.5.0) + if contact.phone_cell: + lines.append(f"TEL;TYPE=CELL:{contact.phone_cell}") + if contact.phone_home: + lines.append(f"TEL;TYPE=HOME,VOICE:{contact.phone_home}") + if contact.phone_work: + lines.append(f"TEL;TYPE=WORK,VOICE:{contact.phone_work}") + if contact.phone_fax: + lines.append(f"TEL;TYPE=FAX:{contact.phone_fax}") + + # Email - backwards compatible single email if contact.email: lines.append(f"EMAIL;TYPE=WORK:{contact.email}") + # Multi-type email (v0.5.0) + if contact.email_home: + lines.append(f"EMAIL;TYPE=HOME:{contact.email_home}") + if contact.email_work: + lines.append(f"EMAIL;TYPE=WORK:{contact.email_work}") + if contact.website: lines.append(f"URL;TYPE=WORK:{contact.website}") - # Address - only if at least one component is present + # Address (default/work) - only if at least one component is present if any([contact.street, contact.city, contact.region, contact.p_code, contact.country]): # ADR format: PO Box;Extended;Street;City;Region;PostalCode;Country adr_parts = [ @@ -166,6 +184,41 @@ def _create_vcard_3(contact: Contact) -> str: ] lines.append(f"ADR;TYPE=WORK;CHARSET=UTF-8:{';'.join(adr_parts)}") + # Home address (v0.5.0) + if any([contact.home_street, contact.home_city, contact.home_region, + contact.home_p_code, contact.home_country]): + adr_parts = [ + "", # PO Box + "", # Extended address + _escape_vcard_value(contact.home_street), + _escape_vcard_value(contact.home_city), + _escape_vcard_value(contact.home_region), + _escape_vcard_value(contact.home_p_code), + _escape_vcard_value(contact.home_country), + ] + lines.append(f"ADR;TYPE=HOME;CHARSET=UTF-8:{';'.join(adr_parts)}") + + # Media fields (v0.5.0) + if contact.photo: + lines.append(_format_media_field_v3("PHOTO", contact.photo)) + if contact.logo: + lines.append(_format_media_field_v3("LOGO", contact.logo)) + + # New vCard fields (v0.5.0) + if contact.categories: + lines.append(f"CATEGORIES;CHARSET=UTF-8:{_escape_vcard_value(contact.categories)}") + + if contact.geo: + # vCard 3.0 GEO format: lat;lon + geo = contact.geo.replace(",", ";") + lines.append(f"GEO:{geo}") + + if contact.tz: + lines.append(f"TZ:{_escape_vcard_value(contact.tz)}") + + if contact.key: + lines.append(_format_key_field_v3(contact.key)) + if contact.note: lines.append(f"NOTE;CHARSET=UTF-8:{_escape_vcard_value(contact.note)}") @@ -179,6 +232,33 @@ def _create_vcard_3(contact: Contact) -> str: return "\n".join(lines) + "\n" +def _format_media_field_v3(field_name: str, value: str) -> str: + """Format PHOTO or LOGO field for vCard 3.0.""" + if value.startswith(("http://", "https://")): + return f"{field_name};VALUE=URI:{value}" + else: + # Assume base64 encoded data + # Try to detect image type from data or default to JPEG + if value.startswith("/9j/"): + media_type = "JPEG" + elif value.startswith("iVBOR"): + media_type = "PNG" + elif value.startswith("R0lGOD"): + media_type = "GIF" + else: + media_type = "JPEG" + return f"{field_name};ENCODING=b;TYPE={media_type}:{value}" + + +def _format_key_field_v3(value: str) -> str: + """Format KEY field for vCard 3.0.""" + if value.startswith(("http://", "https://")): + return f"KEY;VALUE=URI:{value}" + else: + # Assume base64 encoded key data + return f"KEY;ENCODING=b:{value}" + + def _create_vcard_4(contact: Contact) -> str: """ Generate vCard 4.0 format (RFC 6350). @@ -240,16 +320,34 @@ def _create_vcard_4(contact: Contact) -> str: if contact.org: lines.append(f"ORG:{_escape_vcard_value(contact.org)}") + # Phone numbers - backwards compatible single phone if contact.phone: lines.append(f"TEL;TYPE=work,voice;VALUE=uri:tel:{contact.phone}") + # Multi-type phone numbers (v0.5.0) + if contact.phone_cell: + lines.append(f"TEL;TYPE=cell;VALUE=uri:tel:{contact.phone_cell}") + if contact.phone_home: + lines.append(f"TEL;TYPE=home,voice;VALUE=uri:tel:{contact.phone_home}") + if contact.phone_work: + lines.append(f"TEL;TYPE=work,voice;VALUE=uri:tel:{contact.phone_work}") + if contact.phone_fax: + lines.append(f"TEL;TYPE=fax;VALUE=uri:tel:{contact.phone_fax}") + + # Email - backwards compatible single email if contact.email: lines.append(f"EMAIL;TYPE=work:{contact.email}") + # Multi-type email (v0.5.0) + if contact.email_home: + lines.append(f"EMAIL;TYPE=home:{contact.email_home}") + if contact.email_work: + lines.append(f"EMAIL;TYPE=work:{contact.email_work}") + if contact.website: lines.append(f"URL;TYPE=work:{contact.website}") - # Address + # Address (default/work) if any([contact.street, contact.city, contact.region, contact.p_code, contact.country]): adr_parts = [ "", # PO Box @@ -262,6 +360,40 @@ def _create_vcard_4(contact: Contact) -> str: ] lines.append(f"ADR;TYPE=work:{';'.join(adr_parts)}") + # Home address (v0.5.0) + if any([contact.home_street, contact.home_city, contact.home_region, + contact.home_p_code, contact.home_country]): + adr_parts = [ + "", # PO Box + "", # Extended address + _escape_vcard_value(contact.home_street), + _escape_vcard_value(contact.home_city), + _escape_vcard_value(contact.home_region), + _escape_vcard_value(contact.home_p_code), + _escape_vcard_value(contact.home_country), + ] + lines.append(f"ADR;TYPE=home:{';'.join(adr_parts)}") + + # Media fields (v0.5.0) + if contact.photo: + lines.append(_format_media_field_v4("PHOTO", contact.photo)) + if contact.logo: + lines.append(_format_media_field_v4("LOGO", contact.logo)) + + # New vCard fields (v0.5.0) + if contact.categories: + lines.append(f"CATEGORIES:{_escape_vcard_value(contact.categories)}") + + if contact.geo: + # vCard 4.0 GEO format: geo:lat,lon + lines.append(f"GEO:geo:{contact.geo}") + + if contact.tz: + lines.append(f"TZ:{_escape_vcard_value(contact.tz)}") + + if contact.key: + lines.append(_format_key_field_v4(contact.key)) + if contact.note: lines.append(f"NOTE:{_escape_vcard_value(contact.note)}") @@ -273,3 +405,30 @@ def _create_vcard_4(contact: Contact) -> str: lines.append("END:VCARD") return "\n".join(lines) + "\n" + + +def _format_media_field_v4(field_name: str, value: str) -> str: + """Format PHOTO or LOGO field for vCard 4.0.""" + if value.startswith(("http://", "https://")): + return f"{field_name}:{value}" + else: + # Assume base64 encoded data + # Try to detect media type from data or default to JPEG + if value.startswith("/9j/"): + media_type = "image/jpeg" + elif value.startswith("iVBOR"): + media_type = "image/png" + elif value.startswith("R0lGOD"): + media_type = "image/gif" + else: + media_type = "image/jpeg" + return f"{field_name};ENCODING=b;MEDIATYPE={media_type}:{value}" + + +def _format_key_field_v4(value: str) -> str: + """Format KEY field for vCard 4.0.""" + if value.startswith(("http://", "https://")): + return f"KEY:{value}" + else: + # Assume base64 encoded key data (PGP or similar) + return f"KEY;MEDIATYPE=application/pgp-keys:{value}" diff --git a/csv2vcard/csv2vcard.py b/csv2vcard/csv2vcard.py index ab19fdf..e26f052 100644 --- a/csv2vcard/csv2vcard.py +++ b/csv2vcard/csv2vcard.py @@ -7,10 +7,16 @@ from pathlib import Path from csv2vcard.create_vcard import create_vcard -from csv2vcard.export_vcard import ensure_export_dir, export_vcard, export_vcards_combined +from csv2vcard.export_vcard import ( + ensure_export_dir, + export_vcard, + export_vcards_combined, + export_vcards_split, +) from csv2vcard.mapping import load_mapping from csv2vcard.models import VCardVersion from csv2vcard.parse_csv import find_csv_files, parse_csv +from csv2vcard.utils import strip_accents_from_contact logger = logging.getLogger(__name__) @@ -25,6 +31,9 @@ def csv2vcard( single_file: bool = False, encoding: str | None = None, mapping_file: str | Path | None = None, + strip_accents: bool = False, + max_file_size: int | None = None, + max_vcards_per_file: int | None = None, csv_delimeter: str | None = None, # Legacy parameter name (deprecated) ) -> list[Path]: """ @@ -39,6 +48,9 @@ def csv2vcard( single_file: Export all contacts to a single .vcf file (default: False) encoding: File encoding (auto-detected if None) mapping_file: Path to JSON mapping file (uses default if None) + strip_accents: Remove accents from contact fields (default: False) + max_file_size: Maximum file size in bytes for split files (v0.5.0) + max_vcards_per_file: Maximum vCards per file for split files (v0.5.0) csv_delimeter: DEPRECATED - use csv_delimiter instead Returns: @@ -87,6 +99,10 @@ def csv2vcard( mapping=mapping, ) for contact in contacts: + # Apply accent stripping if requested (v0.5.0) + if strip_accents: + contact = strip_accents_from_contact(contact) + vcard = create_vcard(contact, version=version) all_vcards.append(vcard) @@ -97,7 +113,15 @@ def csv2vcard( # Export vCards created_files: list[Path] = [] - if single_file: + # File splitting mode (v0.5.0) + if max_file_size is not None or max_vcards_per_file is not None: + created_files = export_vcards_split( + all_vcards, + output_path, + max_file_size=max_file_size, + max_vcards_per_file=max_vcards_per_file, + ) + elif single_file: # Export all to single file combined_filename = "contacts.vcf" combined_path = output_path / combined_filename diff --git a/csv2vcard/export_vcard.py b/csv2vcard/export_vcard.py index edbe6c0..cc20a0a 100644 --- a/csv2vcard/export_vcard.py +++ b/csv2vcard/export_vcard.py @@ -141,6 +141,97 @@ def export_vcards_combined( raise ExportError(f"Failed to export combined vCard: {e}") from e +def export_vcards_split( + vcards: list[dict[str, str] | VCardOutput], + output_dir: str | Path, + base_filename: str = "contacts", + max_file_size: int | None = None, + max_vcards_per_file: int | None = None, +) -> list[Path]: + """ + Export vCards to multiple files, splitting by size or count. + + Args: + vcards: List of vCard data (dicts or VCardOutput objects) + output_dir: Output directory + base_filename: Base name for output files (without extension) + max_file_size: Maximum file size in bytes (approximate) + max_vcards_per_file: Maximum number of vCards per file + + Returns: + List of paths to created files + + Raises: + ExportError: If export fails + ValueError: If neither max_file_size nor max_vcards_per_file specified + """ + if max_file_size is None and max_vcards_per_file is None: + raise ValueError("Either max_file_size or max_vcards_per_file must be specified") + + output_path = Path(output_dir) + ensure_export_dir(output_path) + + # Extract vCard outputs + outputs: list[str] = [] + for vcard in vcards: + if isinstance(vcard, VCardOutput): + outputs.append(vcard.output) + else: + outputs.append(vcard["output"]) + + created_files: list[Path] = [] + current_chunk: list[str] = [] + current_size = 0 + file_index = 1 + + def write_chunk() -> None: + nonlocal current_chunk, current_size, file_index + if not current_chunk: + return + + filename = f"{base_filename}_{file_index:03d}.vcf" + output_file = output_path / filename + combined = "".join(current_chunk) + + try: + output_file.write_text(combined, encoding="utf-8") + created_files.append(output_file) + logger.info(f"Created split vCard with {len(current_chunk)} contacts: {output_file}") + except OSError as e: + raise ExportError(f"Failed to write vCard file: {e}") from e + + current_chunk = [] + current_size = 0 + file_index += 1 + + for output in outputs: + vcard_size = len(output.encode("utf-8")) + + # Check if we need to start a new file + should_split = False + + if max_vcards_per_file is not None and len(current_chunk) >= max_vcards_per_file: + should_split = True + + if (max_file_size is not None + and current_size + vcard_size > max_file_size + and current_chunk): + # Only split if we have at least one vCard in the chunk + should_split = True + + if should_split: + write_chunk() + + current_chunk.append(output) + current_size += vcard_size + + # Write any remaining vCards + write_chunk() + + logger.info(f"Created {len(created_files)} split vCard file(s)") + return created_files + + # Legacy function for backwards compatibility def check_export() -> None: """ diff --git a/csv2vcard/mapping.py b/csv2vcard/mapping.py index 34a1902..87040bb 100644 --- a/csv2vcard/mapping.py +++ b/csv2vcard/mapping.py @@ -23,20 +23,49 @@ "gender": ["gender", "sex"], "birthday": ["birthday", "birthdate", "birth_date", "dob", "date_of_birth", "bday"], "anniversary": ["anniversary", "wedding_anniversary", "wedding_date"], - # Contact + # Contact - single (backwards compatible) "phone": ["phone", "telephone", "tel", "mobile", "cell", "cellphone", "phone_number"], "email": ["email", "e-mail", "email_address", "mail"], "website": ["website", "url", "web", "homepage", "webpage", "site"], + # Contact - multi-type phone (v0.5.0) + "phone_cell": ["phone_cell", "cell_phone", "mobile_phone", "mobile"], + "phone_home": ["phone_home", "home_phone", "personal_phone"], + "phone_work": ["phone_work", "work_phone", "business_phone", "office_phone"], + "phone_fax": ["phone_fax", "fax", "fax_number"], + # Contact - multi-type email (v0.5.0) + "email_home": ["email_home", "home_email", "personal_email"], + "email_work": ["email_work", "work_email", "business_email", "office_email"], # Organization "org": ["org", "organization", "organisation", "company", "employer", "business"], "title": ["title", "job_title", "jobtitle", "position"], "role": ["role", "job_role", "function", "occupation"], - # Address - "street": ["street", "street_address", "address", "address1", "street1"], - "city": ["city", "locality", "town"], - "region": ["region", "state", "province", "county", "state_province"], + # Address (default/work) + "street": ["street", "street_address", "address", "address1", "street1", "work_street"], + "city": ["city", "locality", "town", "work_city"], + "region": ["region", "state", "province", "county", "state_province", "work_state"], "p_code": ["p_code", "postal_code", "postalcode", "zip", "zipcode", "zip_code", "postcode"], - "country": ["country", "country_name", "nation"], + "country": ["country", "country_name", "nation", "work_country"], + # Address - home (v0.5.0) + # Support both "home_street" and "street_home" naming conventions + "home_street": [ + "home_street", "street_home", "home_address", "personal_street", "home_street_address", + ], + "home_city": ["home_city", "city_home", "personal_city"], + "home_region": [ + "home_region", "region_home", "home_state", "state_home", "home_province", "personal_state", + ], + "home_p_code": [ + "home_p_code", "p_code_home", "home_postal_code", "home_zip", "zip_home", "personal_zip", + ], + "home_country": ["home_country", "country_home", "personal_country"], + # Media (v0.5.0) + "photo": ["photo", "picture", "image", "avatar", "photo_url"], + "logo": ["logo", "company_logo", "org_logo", "logo_url"], + # New vCard fields (v0.5.0) + "categories": ["categories", "category", "tags", "groups", "labels"], + "geo": ["geo", "coordinates", "location", "lat_lon", "gps"], + "tz": ["tz", "timezone", "time_zone"], + "key": ["key", "public_key", "pgp_key", "gpg_key"], # Other "note": ["note", "notes", "comment", "comments", "remarks", "description"], } diff --git a/csv2vcard/models.py b/csv2vcard/models.py index b1ba201..842e895 100644 --- a/csv2vcard/models.py +++ b/csv2vcard/models.py @@ -19,7 +19,7 @@ class VCardVersion(Enum): # Required fields that must be present for a valid contact REQUIRED_FIELDS: frozenset[str] = frozenset({"last_name", "first_name"}) -# All supported contact fields (v0.4.0 expanded) +# All supported contact fields (v0.5.0 expanded) ALL_FIELDS: frozenset[str] = frozenset({ # Name components "last_name", @@ -32,20 +32,42 @@ class VCardVersion(Enum): "gender", "birthday", "anniversary", - # Contact + # Contact - single (backwards compatible) "phone", "email", "website", + # Contact - multi-type phone (v0.5.0) + "phone_cell", + "phone_home", + "phone_work", + "phone_fax", + # Contact - multi-type email (v0.5.0) + "email_home", + "email_work", # Organization "org", "title", "role", - # Address + # Address (default/work) "street", "city", "region", "p_code", "country", + # Address - home (v0.5.0) + "home_street", + "home_city", + "home_region", + "home_p_code", + "home_country", + # Media (v0.5.0) + "photo", # URL or base64-encoded image + "logo", # URL or base64-encoded image + # New vCard fields (v0.5.0) + "categories", # Comma-separated list + "geo", # latitude,longitude + "tz", # Timezone + "key", # Public key URL or base64 # Other "note", }) @@ -68,23 +90,50 @@ class Contact: birthday: str = "" # YYYY-MM-DD or YYYYMMDD anniversary: str = "" # YYYY-MM-DD or YYYYMMDD - # Contact + # Contact - single (backwards compatible) phone: str = "" email: str = "" website: str = "" + # Contact - multi-type phone (v0.5.0) + phone_cell: str = "" + phone_home: str = "" + phone_work: str = "" + phone_fax: str = "" + + # Contact - multi-type email (v0.5.0) + email_home: str = "" + email_work: str = "" + # Organization org: str = "" title: str = "" role: str = "" - # Address (ADR field) + # Address (default/work ADR field) street: str = "" city: str = "" region: str = "" # state/province p_code: str = "" country: str = "" + # Address - home (v0.5.0) + home_street: str = "" + home_city: str = "" + home_region: str = "" + home_p_code: str = "" + home_country: str = "" + + # Media (v0.5.0) + photo: str = "" # URL or base64-encoded image + logo: str = "" # URL or base64-encoded image + + # New vCard fields (v0.5.0) + categories: str = "" # Comma-separated list + geo: str = "" # latitude,longitude (e.g., "37.386013,-122.082932") + tz: str = "" # Timezone (e.g., "-05:00" or "America/New_York") + key: str = "" # Public key URL or base64 + # Other note: str = "" @@ -119,20 +168,42 @@ def from_dict(cls, data: dict[str, str]) -> Contact: gender=data.get("gender", ""), birthday=data.get("birthday", ""), anniversary=data.get("anniversary", ""), - # Contact + # Contact - single phone=data.get("phone", ""), email=data.get("email", ""), website=data.get("website", ""), + # Contact - multi-type phone (v0.5.0) + phone_cell=data.get("phone_cell", ""), + phone_home=data.get("phone_home", ""), + phone_work=data.get("phone_work", ""), + phone_fax=data.get("phone_fax", ""), + # Contact - multi-type email (v0.5.0) + email_home=data.get("email_home", ""), + email_work=data.get("email_work", ""), # Organization org=data.get("org", ""), title=data.get("title", ""), role=data.get("role", ""), - # Address + # Address (default/work) street=data.get("street", ""), city=data.get("city", ""), region=data.get("region", ""), p_code=data.get("p_code", ""), country=data.get("country", ""), + # Address - home (v0.5.0) + home_street=data.get("home_street", ""), + home_city=data.get("home_city", ""), + home_region=data.get("home_region", ""), + home_p_code=data.get("home_p_code", ""), + home_country=data.get("home_country", ""), + # Media (v0.5.0) + photo=data.get("photo", ""), + logo=data.get("logo", ""), + # New vCard fields (v0.5.0) + categories=data.get("categories", ""), + geo=data.get("geo", ""), + tz=data.get("tz", ""), + key=data.get("key", ""), # Other note=data.get("note", ""), ) @@ -156,20 +227,42 @@ def to_dict(self) -> dict[str, str]: "gender": self.gender, "birthday": self.birthday, "anniversary": self.anniversary, - # Contact + # Contact - single "phone": self.phone, "email": self.email, "website": self.website, + # Contact - multi-type phone (v0.5.0) + "phone_cell": self.phone_cell, + "phone_home": self.phone_home, + "phone_work": self.phone_work, + "phone_fax": self.phone_fax, + # Contact - multi-type email (v0.5.0) + "email_home": self.email_home, + "email_work": self.email_work, # Organization "org": self.org, "title": self.title, "role": self.role, - # Address + # Address (default/work) "street": self.street, "city": self.city, "region": self.region, "p_code": self.p_code, "country": self.country, + # Address - home (v0.5.0) + "home_street": self.home_street, + "home_city": self.home_city, + "home_region": self.home_region, + "home_p_code": self.home_p_code, + "home_country": self.home_country, + # Media (v0.5.0) + "photo": self.photo, + "logo": self.logo, + # New vCard fields (v0.5.0) + "categories": self.categories, + "geo": self.geo, + "tz": self.tz, + "key": self.key, # Other "note": self.note, } diff --git a/csv2vcard/utils.py b/csv2vcard/utils.py new file mode 100644 index 0000000..e51def8 --- /dev/null +++ b/csv2vcard/utils.py @@ -0,0 +1,60 @@ +"""Utility functions for csv2vcard.""" + +from __future__ import annotations + +import unicodedata + + +def strip_accents(text: str) -> str: + """ + Remove accents/diacritics from text. + + Uses Unicode normalization to decompose characters and then + removes combining diacritical marks. + + Args: + text: Input string with potential accents + + Returns: + String with accents removed + + Examples: + >>> strip_accents("café") + 'cafe' + >>> strip_accents("naïve") + 'naive' + >>> strip_accents("Müller") + 'Muller' + """ + # Normalize to decomposed form (NFD) + # This separates base characters from combining diacritical marks + normalized = unicodedata.normalize("NFD", text) + + # Remove combining diacritical marks (category "Mn") + stripped = "".join( + char for char in normalized + if unicodedata.category(char) != "Mn" + ) + + return stripped + + +def strip_accents_from_contact(contact: dict[str, str]) -> dict[str, str]: + """ + Remove accents from all string values in a contact dictionary. + + Args: + contact: Contact dictionary with field names as keys + + Returns: + New dictionary with accents stripped from all string values + + Example: + >>> contact = {"first_name": "José", "last_name": "García"} + >>> strip_accents_from_contact(contact) + {'first_name': 'Jose', 'last_name': 'Garcia'} + """ + return { + key: strip_accents(value) if isinstance(value, str) else value + for key, value in contact.items() + } diff --git a/csv2vcard/validators.py b/csv2vcard/validators.py index d907e2f..82e23ca 100644 --- a/csv2vcard/validators.py +++ b/csv2vcard/validators.py @@ -11,6 +11,12 @@ logger = logging.getLogger(__name__) +# Valid vCard 4.0 gender values (single character) +VALID_GENDER_VALUES = frozenset({"M", "F", "O", "N", "U"}) + +# Email validation regex (RFC 5322 simplified) +EMAIL_REGEX = re.compile(r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$") + def validate_contact(contact: dict[str, str], strict: bool = False) -> list[str]: """ @@ -46,12 +52,139 @@ def validate_contact(contact: dict[str, str], strict: bool = False) -> list[str] # Validate email format if provided email = contact.get("email", "").strip() - if email and not re.match(r"^[^@]+@[^@]+\.[^@]+$", email): + if email and not validate_email(email): warnings.append(f"Invalid email format: {email}") + # Validate multi-type emails (v0.5.0) + for email_field in ("email_home", "email_work"): + email_val = contact.get(email_field, "").strip() + if email_val and not validate_email(email_val): + warnings.append(f"Invalid email format in {email_field}: {email_val}") + + # Validate gender (v0.5.0) + gender = contact.get("gender", "").strip() + if gender and not validate_gender(gender): + warnings.append(f"Invalid gender value: {gender}") + + # Validate geo coordinates (v0.5.0) + geo = contact.get("geo", "").strip() + if geo and not validate_geo(geo): + warnings.append(f"Invalid geo coordinates: {geo}") + return warnings +def validate_email(email: str) -> bool: + """ + Validate email address format. + + Args: + email: Email address string + + Returns: + True if valid, False otherwise + + Examples: + >>> validate_email("user@example.com") + True + >>> validate_email("invalid-email") + False + """ + if not email: + return False + return bool(EMAIL_REGEX.match(email)) + + +def validate_gender(gender: str) -> bool: + """ + Validate vCard 4.0 gender value. + + Valid values per RFC 6350: + - M: Male + - F: Female + - O: Other + - N: None/not applicable + - U: Unknown + + Also accepts full words for user convenience. + + Args: + gender: Gender string + + Returns: + True if valid, False otherwise + + Examples: + >>> validate_gender("M") + True + >>> validate_gender("Female") + True + >>> validate_gender("invalid") + False + """ + if not gender: + return False + + upper = gender.upper() + + # Accept single letter codes + if upper in VALID_GENDER_VALUES: + return True + + # Accept common full words + valid_words = { + "MALE": True, + "FEMALE": True, + "OTHER": True, + "NONE": True, + "UNKNOWN": True, + } + return upper in valid_words + + +def validate_geo(geo: str) -> bool: + """ + Validate geographic coordinates. + + Expected format: "latitude,longitude" where: + - latitude: -90.0 to 90.0 + - longitude: -180.0 to 180.0 + + Args: + geo: Coordinate string (e.g., "37.386013,-122.082932") + + Returns: + True if valid, False otherwise + + Examples: + >>> validate_geo("37.386013,-122.082932") + True + >>> validate_geo("91.0,0.0") + False + >>> validate_geo("invalid") + False + """ + if not geo: + return False + + # Allow semicolon separator (vCard 3.0 format) or comma (common format) + parts = geo.replace(";", ",").split(",") + + if len(parts) != 2: + return False + + try: + lat = float(parts[0].strip()) + lon = float(parts[1].strip()) + except ValueError: + return False + + # Check valid ranges + if not (-90.0 <= lat <= 90.0): + return False + return -180.0 <= lon <= 180.0 + + def validate_csv_file(filepath: Path, strict: bool = False) -> None: """ Validate that a CSV file exists and is readable. diff --git a/pyproject.toml b/pyproject.toml index ec8c371..aa8be6b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "csv2vcard" -version = "0.4.0" +version = "0.5.0" description = "A library for converting CSVs to vCards (vCard 3.0 and 4.0)" readme = "DESCRIPTION.md" license = "MIT" diff --git a/tests/test_create_vcard.py b/tests/test_create_vcard.py index a24f22c..8089210 100644 --- a/tests/test_create_vcard.py +++ b/tests/test_create_vcard.py @@ -203,3 +203,320 @@ def test_unicode_names(self) -> None: result = create_vcard(contact) assert result["filename"] == "muller_hans.vcf" + + +class TestMultiTypePhones: + """Test multi-type phone field generation.""" + + def test_phone_cell_v3(self) -> None: + """Test cell phone in vCard 3.0.""" + contact = {"last_name": "Doe", "first_name": "John", "phone_cell": "+1234567890"} + result = create_vcard(contact, version=VCardVersion.V3_0) + + assert "TEL;TYPE=CELL:" in result["output"] + assert "+1234567890" in result["output"] + + def test_phone_cell_v4(self) -> None: + """Test cell phone in vCard 4.0.""" + contact = {"last_name": "Doe", "first_name": "John", "phone_cell": "+1234567890"} + result = create_vcard(contact, version=VCardVersion.V4_0) + + assert "TEL;TYPE=cell;VALUE=uri:tel:+1234567890" in result["output"] + + def test_phone_home_v3(self) -> None: + """Test home phone in vCard 3.0.""" + contact = {"last_name": "Doe", "first_name": "John", "phone_home": "+1111111111"} + result = create_vcard(contact, version=VCardVersion.V3_0) + + assert "TEL;TYPE=HOME,VOICE:" in result["output"] + assert "+1111111111" in result["output"] + + def test_phone_work_v3(self) -> None: + """Test work phone in vCard 3.0.""" + contact = {"last_name": "Doe", "first_name": "John", "phone_work": "+2222222222"} + result = create_vcard(contact, version=VCardVersion.V3_0) + + assert "TEL;TYPE=WORK,VOICE:" in result["output"] + assert "+2222222222" in result["output"] + + def test_phone_fax_v3(self) -> None: + """Test fax number in vCard 3.0.""" + contact = {"last_name": "Doe", "first_name": "John", "phone_fax": "+3333333333"} + result = create_vcard(contact, version=VCardVersion.V3_0) + + assert "TEL;TYPE=FAX:" in result["output"] + assert "+3333333333" in result["output"] + + def test_multiple_phones_v3(self) -> None: + """Test multiple phone types in same contact.""" + contact = { + "last_name": "Doe", + "first_name": "John", + "phone_cell": "+1111111111", + "phone_home": "+2222222222", + "phone_work": "+3333333333", + } + result = create_vcard(contact, version=VCardVersion.V3_0) + + assert "TEL;TYPE=CELL:" in result["output"] + assert "TEL;TYPE=HOME,VOICE:" in result["output"] + assert "TEL;TYPE=WORK,VOICE:" in result["output"] + + +class TestMultiTypeEmails: + """Test multi-type email field generation.""" + + def test_email_home_v3(self) -> None: + """Test home email in vCard 3.0.""" + contact = { + "last_name": "Doe", + "first_name": "John", + "email_home": "john@personal.com", + } + result = create_vcard(contact, version=VCardVersion.V3_0) + + assert "EMAIL;TYPE=HOME:" in result["output"] + assert "john@personal.com" in result["output"] + + def test_email_work_v3(self) -> None: + """Test work email in vCard 3.0.""" + contact = { + "last_name": "Doe", + "first_name": "John", + "email_work": "john@company.com", + } + result = create_vcard(contact, version=VCardVersion.V3_0) + + assert "EMAIL;TYPE=WORK:" in result["output"] + assert "john@company.com" in result["output"] + + def test_email_home_v4(self) -> None: + """Test home email in vCard 4.0.""" + contact = { + "last_name": "Doe", + "first_name": "John", + "email_home": "john@personal.com", + } + result = create_vcard(contact, version=VCardVersion.V4_0) + + assert "EMAIL;TYPE=home:" in result["output"] + assert "john@personal.com" in result["output"] + + def test_multiple_emails(self) -> None: + """Test multiple email types in same contact.""" + contact = { + "last_name": "Doe", + "first_name": "John", + "email": "john@default.com", + "email_home": "john@personal.com", + "email_work": "john@company.com", + } + result = create_vcard(contact, version=VCardVersion.V3_0) + + assert result["output"].count("EMAIL;") == 3 + + +class TestHomeAddress: + """Test home address field generation.""" + + def test_home_address_v3(self) -> None: + """Test home address in vCard 3.0.""" + contact = { + "last_name": "Doe", + "first_name": "John", + "home_street": "123 Home St", + "home_city": "Hometown", + "home_region": "HT", + "home_p_code": "12345", + "home_country": "USA", + } + result = create_vcard(contact, version=VCardVersion.V3_0) + + assert "ADR;TYPE=HOME" in result["output"] + assert "123 Home St" in result["output"] + assert "Hometown" in result["output"] + + def test_home_address_v4(self) -> None: + """Test home address in vCard 4.0.""" + contact = { + "last_name": "Doe", + "first_name": "John", + "home_street": "123 Home St", + "home_city": "Hometown", + } + result = create_vcard(contact, version=VCardVersion.V4_0) + + assert "ADR;TYPE=home:" in result["output"] + + def test_both_addresses(self) -> None: + """Test work and home addresses in same contact.""" + contact = { + "last_name": "Doe", + "first_name": "John", + "street": "456 Work Ave", + "city": "Worktown", + "home_street": "123 Home St", + "home_city": "Hometown", + } + result = create_vcard(contact, version=VCardVersion.V3_0) + + assert "ADR;TYPE=WORK" in result["output"] + assert "ADR;TYPE=HOME" in result["output"] + + +class TestMediaFields: + """Test media field generation (photo, logo, key).""" + + def test_photo_url_v3(self) -> None: + """Test photo URL in vCard 3.0.""" + contact = { + "last_name": "Doe", + "first_name": "John", + "photo": "https://example.com/photo.jpg", + } + result = create_vcard(contact, version=VCardVersion.V3_0) + + assert "PHOTO;" in result["output"] + assert "https://example.com/photo.jpg" in result["output"] + + def test_photo_url_v4(self) -> None: + """Test photo URL in vCard 4.0.""" + contact = { + "last_name": "Doe", + "first_name": "John", + "photo": "https://example.com/photo.jpg", + } + result = create_vcard(contact, version=VCardVersion.V4_0) + + assert "PHOTO:" in result["output"] + assert "https://example.com/photo.jpg" in result["output"] + + def test_photo_base64_v3(self) -> None: + """Test photo base64 in vCard 3.0.""" + contact = { + "last_name": "Doe", + "first_name": "John", + "photo": "data:image/jpeg;base64,/9j/4AAQSkZJRg==", + } + result = create_vcard(contact, version=VCardVersion.V3_0) + + assert "PHOTO;ENCODING=b;TYPE=JPEG:" in result["output"] + + def test_logo_url_v3(self) -> None: + """Test logo URL in vCard 3.0.""" + contact = { + "last_name": "Doe", + "first_name": "John", + "logo": "https://example.com/logo.png", + } + result = create_vcard(contact, version=VCardVersion.V3_0) + + assert "LOGO;" in result["output"] + assert "https://example.com/logo.png" in result["output"] + + def test_logo_v4(self) -> None: + """Test logo in vCard 4.0.""" + contact = { + "last_name": "Doe", + "first_name": "John", + "logo": "https://example.com/logo.png", + } + result = create_vcard(contact, version=VCardVersion.V4_0) + + assert "LOGO:" in result["output"] + + def test_key_url_v3(self) -> None: + """Test public key URL in vCard 3.0.""" + contact = { + "last_name": "Doe", + "first_name": "John", + "key": "https://example.com/key.pgp", + } + result = create_vcard(contact, version=VCardVersion.V3_0) + + assert "KEY;" in result["output"] + assert "https://example.com/key.pgp" in result["output"] + + def test_key_v4(self) -> None: + """Test public key in vCard 4.0.""" + contact = { + "last_name": "Doe", + "first_name": "John", + "key": "https://example.com/key.pgp", + } + result = create_vcard(contact, version=VCardVersion.V4_0) + + assert "KEY:" in result["output"] + + +class TestAdditionalFields: + """Test additional vCard fields (categories, geo, tz).""" + + def test_categories_v3(self) -> None: + """Test categories in vCard 3.0.""" + contact = { + "last_name": "Doe", + "first_name": "John", + "categories": "Work,Friends,VIP", + } + result = create_vcard(contact, version=VCardVersion.V3_0) + + assert "CATEGORIES;CHARSET=UTF-8:" in result["output"] + # Commas are escaped in vCard format + assert "Work" in result["output"] + + def test_categories_v4(self) -> None: + """Test categories in vCard 4.0.""" + contact = { + "last_name": "Doe", + "first_name": "John", + "categories": "Family", + } + result = create_vcard(contact, version=VCardVersion.V4_0) + + assert "CATEGORIES:" in result["output"] + + def test_geo_v3(self) -> None: + """Test geo coordinates in vCard 3.0.""" + contact = { + "last_name": "Doe", + "first_name": "John", + "geo": "37.386,-122.082", + } + result = create_vcard(contact, version=VCardVersion.V3_0) + + assert "GEO:" in result["output"] + + def test_geo_v4(self) -> None: + """Test geo coordinates in vCard 4.0.""" + contact = { + "last_name": "Doe", + "first_name": "John", + "geo": "37.386,-122.082", + } + result = create_vcard(contact, version=VCardVersion.V4_0) + + assert "GEO:" in result["output"] + + def test_timezone_v3(self) -> None: + """Test timezone in vCard 3.0.""" + contact = { + "last_name": "Doe", + "first_name": "John", + "tz": "America/New_York", + } + result = create_vcard(contact, version=VCardVersion.V3_0) + + assert "TZ:" in result["output"] + assert "America/New_York" in result["output"] + + def test_timezone_v4(self) -> None: + """Test timezone in vCard 4.0.""" + contact = { + "last_name": "Doe", + "first_name": "John", + "tz": "-05:00", + } + result = create_vcard(contact, version=VCardVersion.V4_0) + + assert "TZ:" in result["output"] diff --git a/tests/test_export_vcard.py b/tests/test_export_vcard.py index 8886e2f..a99a0a7 100644 --- a/tests/test_export_vcard.py +++ b/tests/test_export_vcard.py @@ -7,7 +7,12 @@ import pytest from csv2vcard.exceptions import ExportError -from csv2vcard.export_vcard import ensure_export_dir, export_vcard +from csv2vcard.export_vcard import ( + ensure_export_dir, + export_vcard, + export_vcards_combined, + export_vcards_split, +) from csv2vcard.models import VCardOutput, VCardVersion @@ -200,3 +205,172 @@ def test_check_export_warns( check_export() assert (temp_dir / "export").exists() + + +class TestExportVCardsCombined: + """Test combined vCard export.""" + + def test_combines_multiple_vcards(self, temp_dir: Path) -> None: + """Test that multiple vCards are combined into one file.""" + vcards = [ + {"filename": "a.vcf", "output": "BEGIN:VCARD\nFN:Alice\nEND:VCARD\n"}, + {"filename": "b.vcf", "output": "BEGIN:VCARD\nFN:Bob\nEND:VCARD\n"}, + {"filename": "c.vcf", "output": "BEGIN:VCARD\nFN:Charlie\nEND:VCARD\n"}, + ] + output_path = temp_dir / "combined.vcf" + + result = export_vcards_combined(vcards, output_path) + + assert result.exists() + content = result.read_text() + assert content.count("BEGIN:VCARD") == 3 + assert "FN:Alice" in content + assert "FN:Bob" in content + assert "FN:Charlie" in content + + def test_accepts_vcard_output_objects(self, temp_dir: Path) -> None: + """Test that VCardOutput objects are accepted.""" + vcards = [ + VCardOutput( + filename="a.vcf", + output="BEGIN:VCARD\nFN:Alice\nEND:VCARD\n", + name="Alice", + version=VCardVersion.V3_0, + ), + VCardOutput( + filename="b.vcf", + output="BEGIN:VCARD\nFN:Bob\nEND:VCARD\n", + name="Bob", + version=VCardVersion.V3_0, + ), + ] + output_path = temp_dir / "combined.vcf" + + result = export_vcards_combined(vcards, output_path) + + assert result.exists() + content = result.read_text() + assert content.count("BEGIN:VCARD") == 2 + + def test_creates_parent_directory(self, temp_dir: Path) -> None: + """Test that parent directory is created if needed.""" + vcards = [{"filename": "a.vcf", "output": "BEGIN:VCARD\nEND:VCARD\n"}] + output_path = temp_dir / "subdir" / "combined.vcf" + + result = export_vcards_combined(vcards, output_path) + + assert result.exists() + assert result.parent.exists() + + def test_empty_list_creates_empty_file(self, temp_dir: Path) -> None: + """Test that empty list creates empty file.""" + output_path = temp_dir / "empty.vcf" + + result = export_vcards_combined([], output_path) + + assert result.exists() + assert result.read_text() == "" + + +class TestExportVCardsSplit: + """Test split vCard export.""" + + def test_split_by_count(self, temp_dir: Path) -> None: + """Test splitting vCards by count.""" + vcards = [ + {"filename": f"{i}.vcf", "output": f"BEGIN:VCARD\nFN:Contact{i}\nEND:VCARD\n"} + for i in range(5) + ] + + result = export_vcards_split( + vcards, temp_dir, base_filename="contacts", max_vcards_per_file=2 + ) + + assert len(result) == 3 # 2 + 2 + 1 + assert all(f.exists() for f in result) + # First file should have 2 contacts + assert result[0].read_text().count("BEGIN:VCARD") == 2 + # Last file should have 1 contact + assert result[2].read_text().count("BEGIN:VCARD") == 1 + + def test_split_by_size(self, temp_dir: Path) -> None: + """Test splitting vCards by file size.""" + # Each vCard is about 40 bytes + vcards = [ + {"filename": f"{i}.vcf", "output": f"BEGIN:VCARD\nFN:Contact{i}\nEND:VCARD\n"} + for i in range(5) + ] + + result = export_vcards_split( + vcards, temp_dir, base_filename="contacts", max_file_size=100 + ) + + # Should create multiple files + assert len(result) >= 2 + assert all(f.exists() for f in result) + + def test_split_filename_format(self, temp_dir: Path) -> None: + """Test that split files have correct naming format.""" + vcards = [ + {"filename": f"{i}.vcf", "output": f"BEGIN:VCARD\nFN:Contact{i}\nEND:VCARD\n"} + for i in range(3) + ] + + result = export_vcards_split( + vcards, temp_dir, base_filename="export", max_vcards_per_file=1 + ) + + assert result[0].name == "export_001.vcf" + assert result[1].name == "export_002.vcf" + assert result[2].name == "export_003.vcf" + + def test_split_requires_parameter(self, temp_dir: Path) -> None: + """Test that at least one split parameter is required.""" + vcards = [{"filename": "a.vcf", "output": "BEGIN:VCARD\nEND:VCARD\n"}] + + with pytest.raises(ValueError, match="must be specified"): + export_vcards_split(vcards, temp_dir) + + def test_split_accepts_vcard_output_objects(self, temp_dir: Path) -> None: + """Test that VCardOutput objects are accepted.""" + vcards = [ + VCardOutput( + filename=f"{i}.vcf", + output=f"BEGIN:VCARD\nFN:Contact{i}\nEND:VCARD\n", + name=f"Contact{i}", + version=VCardVersion.V3_0, + ) + for i in range(4) + ] + + result = export_vcards_split( + vcards, temp_dir, base_filename="contacts", max_vcards_per_file=2 + ) + + assert len(result) == 2 + assert all(f.exists() for f in result) + + def test_split_creates_directory(self, temp_dir: Path) -> None: + """Test that output directory is created if needed.""" + output_dir = temp_dir / "new_subdir" + vcards = [{"filename": "a.vcf", "output": "BEGIN:VCARD\nEND:VCARD\n"}] + + result = export_vcards_split( + vcards, output_dir, base_filename="contacts", max_vcards_per_file=10 + ) + + assert output_dir.exists() + assert len(result) == 1 + + def test_split_single_large_vcard(self, temp_dir: Path) -> None: + """Test that a single vCard larger than max_file_size still gets written.""" + large_output = "BEGIN:VCARD\n" + "NOTE:" + "x" * 200 + "\nEND:VCARD\n" + vcards = [{"filename": "large.vcf", "output": large_output}] + + result = export_vcards_split( + vcards, temp_dir, base_filename="contacts", max_file_size=50 + ) + + # Should still create the file even though it's larger than max + assert len(result) == 1 + assert result[0].exists() diff --git a/tests/test_models.py b/tests/test_models.py index 680f951..7595b9b 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -147,12 +147,22 @@ def test_all_fields(self) -> None: "last_name", "first_name", "middle_name", "name_prefix", "name_suffix", # Basic info "nickname", "gender", "birthday", "anniversary", - # Contact + # Contact - single (backwards compatible) "phone", "email", "website", + # Contact - multi-type phone (v0.5.0) + "phone_cell", "phone_home", "phone_work", "phone_fax", + # Contact - multi-type email (v0.5.0) + "email_home", "email_work", # Organization "org", "title", "role", - # Address + # Address (default/work) "street", "city", "region", "p_code", "country", + # Address - home (v0.5.0) + "home_street", "home_city", "home_region", "home_p_code", "home_country", + # Media (v0.5.0) + "photo", "logo", + # New vCard fields (v0.5.0) + "categories", "geo", "tz", "key", # Other "note", } diff --git a/tests/test_utils.py b/tests/test_utils.py new file mode 100644 index 0000000..9d8e7a9 --- /dev/null +++ b/tests/test_utils.py @@ -0,0 +1,92 @@ +"""Tests for utility functions (v0.5.0).""" + +from __future__ import annotations + +from csv2vcard.utils import strip_accents, strip_accents_from_contact + + +class TestStripAccents: + """Test suite for strip_accents function.""" + + def test_simple_accents(self) -> None: + """Test removal of simple accents.""" + assert strip_accents("café") == "cafe" + assert strip_accents("naïve") == "naive" + assert strip_accents("résumé") == "resume" + + def test_german_umlauts(self) -> None: + """Test removal of German umlauts.""" + assert strip_accents("Müller") == "Muller" + assert strip_accents("Köln") == "Koln" + assert strip_accents("Größe") == "Große" # ß is preserved (not a diacritic) + + def test_spanish_accents(self) -> None: + """Test removal of Spanish accents.""" + assert strip_accents("José") == "Jose" + assert strip_accents("García") == "Garcia" + assert strip_accents("señor") == "senor" + + def test_french_accents(self) -> None: + """Test removal of French accents.""" + assert strip_accents("français") == "francais" + assert strip_accents("être") == "etre" + assert strip_accents("garçon") == "garcon" + + def test_no_accents(self) -> None: + """Test string without accents.""" + assert strip_accents("hello") == "hello" + assert strip_accents("John Doe") == "John Doe" + + def test_empty_string(self) -> None: + """Test empty string.""" + assert strip_accents("") == "" + + def test_mixed_content(self) -> None: + """Test string with mixed ASCII and accented characters.""" + assert strip_accents("Hello, José!") == "Hello, Jose!" + assert strip_accents("123 café street") == "123 cafe street" + + +class TestStripAccentsFromContact: + """Test suite for strip_accents_from_contact function.""" + + def test_contact_with_accents(self) -> None: + """Test stripping accents from contact dictionary.""" + contact = { + "first_name": "José", + "last_name": "García", + "city": "München", + } + result = strip_accents_from_contact(contact) + + assert result["first_name"] == "Jose" + assert result["last_name"] == "Garcia" + assert result["city"] == "Munchen" + + def test_contact_without_accents(self) -> None: + """Test contact without accents.""" + contact = { + "first_name": "John", + "last_name": "Doe", + "email": "john@example.com", + } + result = strip_accents_from_contact(contact) + + assert result == contact + + def test_empty_contact(self) -> None: + """Test empty contact dictionary.""" + result = strip_accents_from_contact({}) + assert result == {} + + def test_preserves_all_keys(self) -> None: + """Test that all keys are preserved.""" + contact = { + "first_name": "José", + "last_name": "García", + "email": "jose@example.com", + "phone": "+1234567890", + } + result = strip_accents_from_contact(contact) + + assert set(result.keys()) == set(contact.keys()) diff --git a/tests/test_validators.py b/tests/test_validators.py index d5f40a8..85467c4 100644 --- a/tests/test_validators.py +++ b/tests/test_validators.py @@ -11,6 +11,9 @@ sanitize_filename, validate_contact, validate_csv_file, + validate_email, + validate_gender, + validate_geo, validate_output_directory, ) @@ -189,3 +192,135 @@ def test_underscore_allowed(self) -> None: """Test that underscores are allowed.""" result = sanitize_filename("John_Doe") assert result == "john_doe" + + +class TestValidateEmail: + """Test suite for validate_email function (v0.5.0).""" + + def test_valid_email_simple(self) -> None: + """Test simple valid email.""" + assert validate_email("user@example.com") is True + + def test_valid_email_with_plus(self) -> None: + """Test email with plus sign.""" + assert validate_email("user+tag@example.com") is True + + def test_valid_email_subdomain(self) -> None: + """Test email with subdomain.""" + assert validate_email("user@mail.example.com") is True + + def test_invalid_email_no_at(self) -> None: + """Test email without @ sign.""" + assert validate_email("userexample.com") is False + + def test_invalid_email_no_domain(self) -> None: + """Test email without domain.""" + assert validate_email("user@") is False + + def test_invalid_email_no_tld(self) -> None: + """Test email without TLD.""" + assert validate_email("user@example") is False + + def test_empty_email(self) -> None: + """Test empty email.""" + assert validate_email("") is False + + +class TestValidateGender: + """Test suite for validate_gender function (v0.5.0).""" + + def test_valid_male_letter(self) -> None: + """Test valid M gender code.""" + assert validate_gender("M") is True + + def test_valid_female_letter(self) -> None: + """Test valid F gender code.""" + assert validate_gender("F") is True + + def test_valid_other_letter(self) -> None: + """Test valid O gender code.""" + assert validate_gender("O") is True + + def test_valid_none_letter(self) -> None: + """Test valid N gender code.""" + assert validate_gender("N") is True + + def test_valid_unknown_letter(self) -> None: + """Test valid U gender code.""" + assert validate_gender("U") is True + + def test_valid_male_word(self) -> None: + """Test 'Male' as valid gender.""" + assert validate_gender("Male") is True + + def test_valid_female_word(self) -> None: + """Test 'Female' as valid gender.""" + assert validate_gender("Female") is True + + def test_case_insensitive(self) -> None: + """Test case insensitivity.""" + assert validate_gender("m") is True + assert validate_gender("FEMALE") is True + + def test_invalid_gender(self) -> None: + """Test invalid gender value.""" + assert validate_gender("X") is False + assert validate_gender("invalid") is False + + def test_empty_gender(self) -> None: + """Test empty gender.""" + assert validate_gender("") is False + + +class TestValidateGeo: + """Test suite for validate_geo function (v0.5.0).""" + + def test_valid_coordinates_comma(self) -> None: + """Test valid coordinates with comma separator.""" + assert validate_geo("37.386013,-122.082932") is True + + def test_valid_coordinates_semicolon(self) -> None: + """Test valid coordinates with semicolon separator (vCard 3.0).""" + assert validate_geo("37.386013;-122.082932") is True + + def test_valid_coordinates_with_spaces(self) -> None: + """Test coordinates with spaces.""" + assert validate_geo("37.386013, -122.082932") is True + + def test_valid_coordinates_at_boundaries(self) -> None: + """Test coordinates at valid boundaries.""" + assert validate_geo("90.0,180.0") is True + assert validate_geo("-90.0,-180.0") is True + assert validate_geo("0,0") is True + + def test_invalid_latitude_too_high(self) -> None: + """Test latitude exceeding 90.""" + assert validate_geo("91.0,0.0") is False + + def test_invalid_latitude_too_low(self) -> None: + """Test latitude below -90.""" + assert validate_geo("-91.0,0.0") is False + + def test_invalid_longitude_too_high(self) -> None: + """Test longitude exceeding 180.""" + assert validate_geo("0.0,181.0") is False + + def test_invalid_longitude_too_low(self) -> None: + """Test longitude below -180.""" + assert validate_geo("0.0,-181.0") is False + + def test_invalid_format_not_numbers(self) -> None: + """Test non-numeric coordinates.""" + assert validate_geo("abc,def") is False + + def test_invalid_format_single_value(self) -> None: + """Test single value instead of pair.""" + assert validate_geo("37.386013") is False + + def test_invalid_format_three_values(self) -> None: + """Test three values.""" + assert validate_geo("37.386013,-122.082932,100") is False + + def test_empty_geo(self) -> None: + """Test empty geo string.""" + assert validate_geo("") is False