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.
- jodie/__init__.py +5 -0
- jodie/cli/__doc__.py +61 -0
- jodie/cli/__init__.py +2 -0
- jodie/cli/__main__.py +298 -0
- jodie/cli/preview.py +65 -0
- jodie/config.py +122 -0
- jodie/constants.py +44 -0
- jodie/contact/__init__.py +5 -0
- jodie/contact/contact.py +659 -0
- jodie/input/__init__.py +5 -0
- jodie/input/clipboard.py +8 -0
- jodie/input/signature.py +57 -0
- jodie/input/stdin.py +7 -0
- jodie/parsers/__init__.py +23 -0
- jodie/parsers/base.py +69 -0
- jodie/parsers/parsers.py +243 -0
- jodie/parsers/pipeline.py +127 -0
- jodie-0.1.0.dist-info/METADATA +216 -0
- jodie-0.1.0.dist-info/RECORD +22 -0
- jodie-0.1.0.dist-info/WHEEL +5 -0
- jodie-0.1.0.dist-info/entry_points.txt +3 -0
- jodie-0.1.0.dist-info/top_level.txt +1 -0
jodie/contact/contact.py
ADDED
|
@@ -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)
|
jodie/input/__init__.py
ADDED
jodie/input/clipboard.py
ADDED
|
@@ -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
|