jodie 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,659 @@
1
+ #!/usr/bin/env python3
2
+ # jodie/contact/contact.py
3
+ from datetime import datetime
4
+ from typing import Optional, List, Any, Union, Dict
5
+ import subprocess
6
+ import objc
7
+ from Contacts import (CNMutableContact, CNContactStore, CNSaveRequest, CNLabeledValue,
8
+ CNPhoneNumber, CNLabelURLAddressHomePage)
9
+ from Foundation import NSCalendar, NSDateComponents
10
+
11
+ from jodie.constants import WEBMAIL_DOMAINS
12
+
13
+
14
+ def get_label_for_email(email: str) -> str:
15
+ """
16
+ Determine the label for an email address based on its domain.
17
+
18
+ Args:
19
+ email (str): The email address to analyze.
20
+
21
+ Returns:
22
+ str: "work" if the email domain is neither a common webmail provider nor an educational institution (.edu),
23
+ otherwise "home".
24
+ """
25
+ # Extract the email's domain
26
+ email_domain: str = email.split('@')[-1].lower() if email else ""
27
+
28
+ # Check domain against webmail providers and education domains (.edu)
29
+ if email_domain not in WEBMAIL_DOMAINS and not email_domain.endswith('.edu'):
30
+ return "work"
31
+ else:
32
+ return "home"
33
+
34
+
35
+ def get_human_friendly_label(label: str) -> str:
36
+ """
37
+ Convert Apple Contacts framework label constants to human-friendly names.
38
+
39
+ Args:
40
+ label (str): The label constant from Apple Contacts framework
41
+
42
+ Returns:
43
+ str: A human-friendly label name
44
+ """
45
+ # Map Apple constants to friendly names
46
+ label_map = {
47
+ CNLabelURLAddressHomePage: "Website",
48
+ "work": "Work",
49
+ "home": "Home",
50
+ "LinkedIn": "LinkedIn",
51
+ "GitHub": "GitHub",
52
+ "Twitter": "Twitter",
53
+ "Instagram": "Instagram",
54
+ "Facebook": "Facebook",
55
+ "Calendar": "Calendar"
56
+ }
57
+
58
+ return label_map.get(label, label)
59
+
60
+
61
+ def get_label_for_website(url: str, email: Optional[str] = None, company: Optional[str] = None) -> str:
62
+ """
63
+ Determine the appropriate label for a website URL based on its domain, email, and company information.
64
+
65
+ Args:
66
+ url (str): The website URL to analyze.
67
+ email (str, optional): The contact's email address to check for domain matching.
68
+ company (str, optional): The contact's company name to check for domain matching.
69
+
70
+ Returns:
71
+ str: A label constant from Contacts framework (CNLabelURLAddress*) or a custom label string.
72
+ """
73
+ # Common professional/social networks
74
+ professional_domains = {
75
+ 'linkedin.com': 'LinkedIn',
76
+ 'github.com': 'GitHub',
77
+ 'twitter.com': 'Twitter',
78
+ 'instagram.com': 'Instagram',
79
+ 'facebook.com': 'Facebook',
80
+ }
81
+
82
+ # Common calendar/scheduling domains
83
+ calendar_domains = {
84
+ 'calendly.com': 'Calendar',
85
+ 'meet.google.com': 'Calendar',
86
+ 'zoom.us': 'Calendar',
87
+ }
88
+
89
+ # Extract the domain from the URL
90
+ try:
91
+ # Remove protocol and www if present
92
+ domain = url.lower().replace('https://', '').replace('http://', '').replace('www.', '')
93
+ # Get the domain part
94
+ domain = domain.split('/')[0]
95
+ except:
96
+ # If URL parsing fails, default to homepage
97
+ return CNLabelURLAddressHomePage
98
+
99
+ # Rule 1: Check against known professional/social networks
100
+ for known_domain, label in professional_domains.items():
101
+ if known_domain in domain:
102
+ return label
103
+
104
+ # Rule 2: Check against known calendar domains
105
+ for known_domain, label in calendar_domains.items():
106
+ if known_domain in domain:
107
+ return label
108
+
109
+ # Rule 3: If email is work and domains match, set website to work
110
+ if email:
111
+ email_domain = email.split('@')[-1].lower()
112
+ if get_label_for_email(email) == "work" and email_domain in domain:
113
+ return "Work"
114
+
115
+ # Rule 4: If email is home/webmail and company name exists, check for company name in domain
116
+ if email and company and get_label_for_email(email) == "home":
117
+ # Split company name into words and check if any appear in domain
118
+ company_words = company.lower().split()
119
+ if any(word in domain for word in company_words):
120
+ return "Work"
121
+
122
+ # Rule 5: Fallback to homepage
123
+ return CNLabelURLAddressHomePage
124
+
125
+
126
+ class WebsiteLabeledValue(CNLabeledValue):
127
+ """Wrapper for CNLabeledValue that provides a better string representation and additional functionality."""
128
+
129
+ def __repr__(self) -> str:
130
+ return f"Website(label={self.label()!r}, url={self.value()!r})"
131
+
132
+ def to_dict(self) -> Dict[str, str]:
133
+ """Convert to a dictionary representation."""
134
+ return {"label": self.label(), "url": self.value()}
135
+
136
+
137
+ class Contact:
138
+ """
139
+ Simple wrapper for Apple iOS / macOS Contact record.
140
+ Provides a Pythonic interface to interact with Apple's Contacts framework.
141
+ """
142
+
143
+ def __init__(
144
+ self,
145
+ first_name: Optional[str] = None,
146
+ last_name: Optional[str] = None,
147
+ email: Optional[str] = None,
148
+ phone: Optional[str] = None,
149
+ job_title: Optional[str] = None,
150
+ company: Optional[str] = None,
151
+ websites: Optional[Union[str, List[str], List[Dict[str, str]], List[CNLabeledValue]]] = None,
152
+ note: Optional[str] = None
153
+ ) -> None:
154
+ """
155
+ Initialize a Contact object with optional parameters for various contact fields.
156
+ Empty or whitespace-only values will be treated as None and not set.
157
+
158
+ Args:
159
+ first_name: First name of the contact
160
+ last_name: Last name of the contact
161
+ email: Email address of the contact
162
+ phone: Phone number of the contact
163
+ job_title: Job title of the contact
164
+ company: Company name of the contact
165
+ websites: Website URL(s) of the contact
166
+ note: Additional notes for the contact
167
+ """
168
+ self.contact: CNMutableContact = CNMutableContact.alloc().init()
169
+
170
+ # Convert empty strings to None
171
+ self.first_name = first_name if first_name and first_name.strip() else None
172
+ self.last_name = last_name if last_name and last_name.strip() else None
173
+ self.email = email
174
+ self.phone = phone
175
+ self.job_title = job_title if job_title and job_title.strip() else None
176
+ self.company = company
177
+ self.websites = websites
178
+ self.note = note
179
+
180
+ # Apple Contacts.app doesnt display created date by default
181
+ # save the created date in a custom field
182
+ self._created_date: datetime = datetime.now()
183
+ self._set_creation_date()
184
+
185
+ def _set_creation_date(self) -> None:
186
+ """
187
+ Set the creation date for the contact using the current date and store it as a custom date field.
188
+ """
189
+ dateComponents: NSDateComponents = NSDateComponents.alloc().init()
190
+ dateComponents.setYear_(self._created_date.year)
191
+ dateComponents.setMonth_(self._created_date.month)
192
+ dateComponents.setDay_(self._created_date.day)
193
+ customDateValue: CNLabeledValue = CNLabeledValue.alloc().initWithLabel_value_(
194
+ "created_date", dateComponents)
195
+ self.contact.setDates_([customDateValue])
196
+
197
+ def _set_note_via_applescript(self, note_text: str) -> bool:
198
+ """Set note field via AppleScript (bypasses entitlement requirement).
199
+
200
+ Args:
201
+ note_text: The note text to set on the contact
202
+
203
+ Returns:
204
+ bool: True if successful, False otherwise
205
+ """
206
+ if not note_text:
207
+ return True
208
+
209
+ # Escape for AppleScript string
210
+ escaped_note = note_text.replace('\\', '\\\\').replace('"', '\\"')
211
+
212
+ # Build query to find the contact we just saved
213
+ first = self.first_name or ""
214
+ last = self.last_name or ""
215
+
216
+ if first and last:
217
+ query = f'first name is "{first}" and last name is "{last}"'
218
+ elif first:
219
+ query = f'first name is "{first}"'
220
+ elif last:
221
+ query = f'last name is "{last}"'
222
+ else:
223
+ return False # Can't identify contact without name
224
+
225
+ applescript = f'''
226
+ tell application "Contacts"
227
+ set p to first person whose {query}
228
+ set note of p to "{escaped_note}"
229
+ save
230
+ end tell
231
+ '''
232
+
233
+ result = subprocess.run(
234
+ ['osascript', '-e', applescript],
235
+ capture_output=True,
236
+ text=True
237
+ )
238
+ return result.returncode == 0
239
+
240
+ def save(self) -> 'Contact':
241
+ """
242
+ Validate required fields and try to save to Contacts.app / Apple Address Book.
243
+
244
+ Returns:
245
+ Contact: The saved contact instance
246
+
247
+ Raises:
248
+ ValueError: If required fields (first name, last name, and either email or phone) are missing
249
+ Exception: If the save operation fails
250
+ """
251
+ # Check that we have first name, last name, and at least one contact method (email or phone)
252
+ has_first_name = bool(self.contact.givenName())
253
+ has_last_name = bool(self.contact.familyName())
254
+ has_email = bool(self.contact.emailAddresses())
255
+ has_phone = bool(self.contact.phoneNumbers())
256
+
257
+ if not (has_first_name and has_last_name and (has_email or has_phone)):
258
+ raise ValueError(
259
+ "Missing required fields. First name, last name, and at least one contact method (email or phone) are required.")
260
+
261
+ store: CNContactStore = CNContactStore.alloc().init()
262
+ request: CNSaveRequest = CNSaveRequest.alloc().init()
263
+ request.addContact_toContainerWithIdentifier_(self.contact, None)
264
+
265
+ error: Any = objc.nil
266
+ success, error = store.executeSaveRequest_error_(request, None)
267
+ if not success:
268
+ raise Exception(f"Failed to save contact: {error}")
269
+
270
+ # TODO: Notes disabled pending AppleScript refactor (ISS-000012)
271
+ # After successful PyObjC save, apply note via AppleScript if present
272
+ # note_text = self.contact.note()
273
+ # if note_text:
274
+ # self._set_note_via_applescript(note_text)
275
+
276
+ return self
277
+
278
+ def __str__(self) -> str:
279
+ """String representation of the contact, showing only set fields."""
280
+ fields = []
281
+
282
+ # Name
283
+ name_parts = []
284
+ if self.first_name:
285
+ name_parts.append(self.first_name)
286
+ if self.last_name:
287
+ name_parts.append(self.last_name)
288
+ fields.append(f"Contact: {' '.join(name_parts)}" if name_parts else "Contact: Unknown")
289
+
290
+ # Email
291
+ fields.append(f"Email: {self.email}" if self.email else "Email: None")
292
+
293
+ # Phone
294
+ fields.append(f"Phone: {self.phone}" if self.phone else "Phone: None")
295
+
296
+ # Job Title
297
+ fields.append(f"Job Title: {self.job_title}" if self.job_title else "Job Title: None")
298
+
299
+ # Company
300
+ fields.append(f"Company: {self.company}" if self.company else "Company: None")
301
+
302
+ # Websites
303
+ if self.websites:
304
+ website_str = ", ".join(f"{get_human_friendly_label(site.label())}: {site.value()}" for site in self.websites)
305
+ fields.append(f"Websites: {website_str}")
306
+ else:
307
+ fields.append("Websites: None")
308
+
309
+ return ", ".join(fields)
310
+
311
+ def __repr__(self) -> str:
312
+ websites = self.websites
313
+ if websites:
314
+ website_repr = f"[{', '.join(f'{get_human_friendly_label(site.label())}: {site.value()}' for site in websites)}]"
315
+ else:
316
+ website_repr = "None"
317
+ return (f"{self.__class__.__name__}(first_name={self.first_name!r}, "
318
+ f"last_name={self.last_name!r}, email={self.email!r}, "
319
+ f"phone={self.phone!r}, job_title={self.job_title!r}, "
320
+ f"company={self.company!r}, websites={website_repr})")
321
+
322
+ @property
323
+ def first_name(self) -> Optional[str]:
324
+ """Get the contact's first name."""
325
+ val = self.contact.givenName()
326
+ return val.strip() if val and val.strip() else None
327
+
328
+ @property
329
+ def last_name(self) -> Optional[str]:
330
+ """Get the contact's last name."""
331
+ val = self.contact.familyName()
332
+ return val.strip() if val and val.strip() else None
333
+
334
+ @property
335
+ def email(self) -> Optional[str]:
336
+ """Get the contact's primary email address."""
337
+ emailAddresses: List[CNLabeledValue] = self.contact.emailAddresses()
338
+ return emailAddresses[0].value() if emailAddresses else None
339
+
340
+ @email.setter
341
+ def email(self, value: Optional[str]) -> None:
342
+ if not value or not value.strip():
343
+ self.contact.setEmailAddresses_([])
344
+ return
345
+ email_label = get_label_for_email(value)
346
+ emailValue = CNLabeledValue.alloc().initWithLabel_value_(
347
+ email_label, value.strip().lower())
348
+ self.contact.setEmailAddresses_([emailValue])
349
+
350
+ @property
351
+ def phone(self) -> Optional[str]:
352
+ """Get the contact's primary phone number."""
353
+ phoneNumbers: List[CNLabeledValue] = self.contact.phoneNumbers()
354
+ return phoneNumbers[0].value().stringValue() if phoneNumbers else None
355
+
356
+ @phone.setter
357
+ def phone(self, value: Optional[str]) -> None:
358
+ if not value or not value.strip():
359
+ self.contact.setPhoneNumbers_([])
360
+ return
361
+ cleaned_number = ''.join(ch for ch in value if ch.isdigit() or ch == '+')
362
+ phone_number = CNPhoneNumber.phoneNumberWithStringValue_(cleaned_number)
363
+ phone_label_value = CNLabeledValue.alloc().initWithLabel_value_(
364
+ "mobile", phone_number)
365
+ self.contact.setPhoneNumbers_([phone_label_value])
366
+
367
+ @property
368
+ def job_title(self) -> Optional[str]:
369
+ """Get the contact's job title."""
370
+ val = self.contact.jobTitle()
371
+ return val.strip() if val and val.strip() else None
372
+
373
+ @property
374
+ def company(self) -> Optional[str]:
375
+ """Get the contact's company name."""
376
+ val = self.contact.organizationName()
377
+ return val.strip() if val and val.strip() else None
378
+
379
+ @company.setter
380
+ def company(self, value: Optional[str]) -> None:
381
+ if not value or not value.strip():
382
+ self.contact.setOrganizationName_(None)
383
+ return
384
+ self.contact.setOrganizationName_(value.strip())
385
+
386
+ @property
387
+ def websites(self) -> Optional[List[WebsiteLabeledValue]]:
388
+ """Get all websites as a list of WebsiteLabeledValue objects."""
389
+ urlAddresses: List[CNLabeledValue] = self.contact.urlAddresses()
390
+ if not urlAddresses:
391
+ return None
392
+ return [WebsiteLabeledValue.alloc().initWithLabel_value_(site.label(), site.value()) for site in urlAddresses]
393
+
394
+ @websites.setter
395
+ def websites(self, value: Union[str, List[str], List[Dict[str, str]], List[CNLabeledValue]]) -> None:
396
+ """Set websites. Can handle:
397
+ - Single URL string
398
+ - List of URL strings
399
+ - List of dicts with 'label' and 'url' keys
400
+ - List of CNLabeledValue objects
401
+ """
402
+ if not value:
403
+ self.contact.setUrlAddresses_([])
404
+ return
405
+
406
+ # Convert to list of WebsiteLabeledValue objects
407
+ if isinstance(value, str):
408
+ label = get_label_for_website(value, self.email, self.company)
409
+ value = [WebsiteLabeledValue.alloc().initWithLabel_value_(label, value.strip().lower())]
410
+ elif isinstance(value, list):
411
+ if not value:
412
+ self.contact.setUrlAddresses_([])
413
+ return
414
+
415
+ if isinstance(value[0], str):
416
+ # List of strings
417
+ url_values = []
418
+ for url in value:
419
+ label = get_label_for_website(url, self.email, self.company)
420
+ url_value = WebsiteLabeledValue.alloc().initWithLabel_value_(
421
+ label, url.strip().lower())
422
+ url_values.append(url_value)
423
+ value = url_values
424
+ elif isinstance(value[0], dict):
425
+ # List of dicts
426
+ url_values = []
427
+ for item in value:
428
+ url = item["url"]
429
+ label = item.get("label") or get_label_for_website(url, self.email, self.company)
430
+ url_value = WebsiteLabeledValue.alloc().initWithLabel_value_(
431
+ label, url.strip().lower())
432
+ url_values.append(url_value)
433
+ value = url_values
434
+ elif isinstance(value[0], CNLabeledValue):
435
+ # Convert existing CNLabeledValue objects to WebsiteLabeledValue
436
+ url_values = []
437
+ for site in value:
438
+ url_value = WebsiteLabeledValue.alloc().initWithLabel_value_(
439
+ site.label(), site.value())
440
+ url_values.append(url_value)
441
+ value = url_values
442
+
443
+ self.contact.setUrlAddresses_(value)
444
+
445
+ def add_website(self, url: str, label: Optional[str] = None) -> None:
446
+ """Add a single website with optional label."""
447
+ current = self.websites or []
448
+ if not label:
449
+ label = get_label_for_website(url, self.email, self.company)
450
+ websiteValue = WebsiteLabeledValue.alloc().initWithLabel_value_(
451
+ label, url.strip().lower())
452
+ current.append(websiteValue)
453
+ self.contact.setUrlAddresses_(current)
454
+
455
+ def get_website(self, label: str) -> Optional[str]:
456
+ """Get a specific website by its label."""
457
+ websites = self.websites
458
+ if not websites:
459
+ return None
460
+ for site in websites:
461
+ if site.label() == label:
462
+ return site.value()
463
+ return None
464
+
465
+ # Convenience properties for common website types
466
+ @property
467
+ def linkedin(self) -> Optional[str]:
468
+ return self.get_website("LinkedIn")
469
+
470
+ @property
471
+ def work_website(self) -> Optional[str]:
472
+ return self.get_website("Work")
473
+
474
+ @property
475
+ def home_website(self) -> Optional[str]:
476
+ return self.get_website(CNLabelURLAddressHomePage)
477
+
478
+ @property
479
+ def note(self) -> Optional[str]:
480
+ """Get the contact's notes.
481
+
482
+ Note:
483
+ This functionality is currently broken due to Apple entitlements requirements.
484
+ """
485
+ return self.contact.note()
486
+
487
+ @note.setter
488
+ def note(self, value: Optional[str]) -> None:
489
+ """Set the contact's notes.
490
+
491
+ Note:
492
+ This functionality is currently broken due to Apple entitlements requirements.
493
+ """
494
+ if not value or not value.strip():
495
+ self.contact.setNote_(None)
496
+ return
497
+ self.contact.setNote_(value.strip())
498
+
499
+ def __dict__(self) -> dict:
500
+ """
501
+ Return a dictionary representation of the contact.
502
+
503
+ Returns:
504
+ dict: A dictionary containing all contact fields and their values.
505
+ Empty fields are returned as None.
506
+ Created date is formatted as YYYY-MM-DD.
507
+ """
508
+ def get_value_or_none(value):
509
+ if value is None:
510
+ return None
511
+ if isinstance(value, str):
512
+ return value if value.strip() else None
513
+ if isinstance(value, list):
514
+ return [item.to_dict() for item in value] if value else None
515
+ return value
516
+
517
+ return {
518
+ 'first_name': get_value_or_none(self.first_name),
519
+ 'last_name': get_value_or_none(self.last_name),
520
+ 'email': get_value_or_none(self.email),
521
+ 'phone': get_value_or_none(self.phone),
522
+ 'job_title': get_value_or_none(self.job_title),
523
+ 'company': get_value_or_none(self.company),
524
+ 'websites': get_value_or_none(self.websites),
525
+ 'note': get_value_or_none(self.note),
526
+ 'created_date': self._created_date.strftime('%Y-%m-%d')
527
+ }
528
+
529
+ def tojson(self) -> dict:
530
+ """
531
+ Return a JSON-serializable dictionary representation of the contact.
532
+
533
+ Returns:
534
+ dict: A dictionary containing all contact fields and their values,
535
+ with datetime objects converted to ISO format strings.
536
+ """
537
+ return self.__dict__()
538
+
539
+ @first_name.setter
540
+ def first_name(self, value: Optional[str]) -> None:
541
+ """Set first name."""
542
+ if not value or not value.strip():
543
+ value = None
544
+ self.contact.setGivenName_(value)
545
+
546
+ @last_name.setter
547
+ def last_name(self, value: Optional[str]) -> None:
548
+ """Set last name."""
549
+ if not value or not value.strip():
550
+ value = None
551
+ self.contact.setFamilyName_(value)
552
+
553
+ @job_title.setter
554
+ def job_title(self, value: Optional[str]) -> None:
555
+ """Set job title."""
556
+ if not value or not value.strip():
557
+ value = None
558
+ self.contact.setJobTitle_(value)
559
+
560
+ def test_website_labels():
561
+ test_cases = [
562
+ # Social/Professional Networks
563
+ ("https://linkedin.com/in/johndoe", None, None, "LinkedIn"),
564
+ ("https://github.com/johndoe", None, None, "GitHub"),
565
+ ("https://twitter.com/johndoe", None, None, "Twitter"),
566
+
567
+ # Calendar Links
568
+ ("https://calendly.com/johndoe", None, None, "Calendar"),
569
+ ("https://meet.google.com/abc-xyz", None, None, "Calendar"),
570
+
571
+ # Work Email Domain Match
572
+ ("https://acme.com", "john@acme.com", None, "Work"), # work email
573
+ ("https://acme.com", "john@gmail.com", None, CNLabelURLAddressHomePage), # personal email
574
+
575
+ # Company Name Match
576
+ ("https://acme-corp.com", "john@gmail.com", "Acme Corp", "Work"), # company name in domain
577
+ ("https://acme.com", "john@gmail.com", "Acme Corp", "Work"), # company name in domain
578
+ ("https://other.com", "john@gmail.com", "Acme Corp", CNLabelURLAddressHomePage), # no match
579
+
580
+ # Multiple URLs for same contact
581
+ ("https://acme.com", "john@acme.com", "Acme Corp", "Work"), # work website
582
+ ("https://linkedin.com/in/johndoe", "john@acme.com", "Acme Corp", "LinkedIn"), # LinkedIn profile
583
+
584
+ # Edge Cases
585
+ ("invalid-url", None, None, CNLabelURLAddressHomePage), # invalid URL
586
+ ("", None, None, CNLabelURLAddressHomePage), # empty URL
587
+ (None, None, None, CNLabelURLAddressHomePage), # None URL
588
+ ]
589
+
590
+ print("Testing website label determination:")
591
+ print("-" * 80)
592
+
593
+ for url, email, company, expected in test_cases:
594
+ result = get_label_for_website(url, email, company)
595
+ status = "✓" if result == expected else "✗"
596
+ print(f"{status} URL: {url}")
597
+ print(f" Email: {email}")
598
+ print(f" Company: {company}")
599
+ print(f" Expected: {expected}")
600
+ print(f" Got: {result}")
601
+ print("-" * 80)
602
+
603
+ # Test __dict__ and tojson methods
604
+ print("\nTesting Contact.__dict__ and Contact.tojson methods:")
605
+ print("-" * 80)
606
+
607
+ # Test case 1: Complete contact information
608
+ contact1 = Contact(
609
+ first_name="John",
610
+ last_name="Doe",
611
+ email="john@acme.com",
612
+ phone="+1-555-123-4567",
613
+ job_title="Software Engineer",
614
+ company="Acme Corp",
615
+ websites="https://acme.com",
616
+ note="Met at conference"
617
+ )
618
+
619
+ # Test case 2: Minimal contact information
620
+ contact2 = Contact(
621
+ first_name="Jane",
622
+ last_name="Smith",
623
+ email="jane@gmail.com"
624
+ )
625
+
626
+ test_contacts = [
627
+ ("Complete Contact", contact1),
628
+ ("Minimal Contact", contact2)
629
+ ]
630
+
631
+ for name, contact in test_contacts:
632
+ print(f"\nTesting {name}:")
633
+ print("-" * 40)
634
+
635
+ # Test __dict__ method
636
+ contact_dict = contact.__dict__()
637
+ print("__dict__() result:")
638
+ for key, value in contact_dict.items():
639
+ print(f" {key}: {value}")
640
+
641
+ # Test tojson method
642
+ contact_json = contact.tojson()
643
+ print("\ntojson() result:")
644
+ for key, value in contact_json.items():
645
+ print(f" {key}: {value}")
646
+
647
+ # Verify both methods return the same data
648
+ assert contact_dict == contact_json, f"__dict__ and tojson returned different results for {name}"
649
+ print("\n✓ __dict__ and tojson results match")
650
+
651
+ # Verify all values are JSON-serializable
652
+ import json
653
+ try:
654
+ json.dumps(contact_json)
655
+ print("✓ JSON serialization successful")
656
+ except (TypeError, ValueError) as e:
657
+ print(f"✗ JSON serialization failed: {e}")
658
+
659
+ print("-" * 40)
@@ -0,0 +1,5 @@
1
+ from .clipboard import read_clipboard
2
+ from .stdin import read_stdin
3
+ from .signature import SignaturePreprocessor
4
+
5
+ __all__ = ['read_clipboard', 'read_stdin', 'SignaturePreprocessor']
@@ -0,0 +1,8 @@
1
+ import subprocess
2
+
3
+ def read_clipboard() -> str:
4
+ """Read text from macOS clipboard via pbpaste."""
5
+ result = subprocess.run(['pbpaste'], capture_output=True, text=True)
6
+ if result.returncode != 0:
7
+ raise RuntimeError("Failed to read clipboard")
8
+ return result.stdout