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/__init__.py
ADDED
jodie/cli/__doc__.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# jodie/cli/__doc__.py
|
|
3
|
+
"""jodie - Manage macOS Contacts.app from command line!
|
|
4
|
+
|
|
5
|
+
Usage:
|
|
6
|
+
jodie new TEXT... [options]
|
|
7
|
+
jodie new [options]
|
|
8
|
+
jodie new --auto TEXT... [options]
|
|
9
|
+
jodie new --paste [options]
|
|
10
|
+
jodie new --stdin [options]
|
|
11
|
+
jodie new --explicit EMAIL NAME [COMPANY] [TITLE] [NOTE...]
|
|
12
|
+
jodie parse [options] TEXT
|
|
13
|
+
|
|
14
|
+
Arguments:
|
|
15
|
+
TEXT Text for jodie to parse intelligently (default mode).
|
|
16
|
+
EMAIL Email address (used with --explicit).
|
|
17
|
+
NAME Full name (used with --explicit).
|
|
18
|
+
COMPANY Company name (used with --explicit).
|
|
19
|
+
TITLE Job title (used with --explicit).
|
|
20
|
+
NOTE Note text (used with --explicit).
|
|
21
|
+
|
|
22
|
+
Options:
|
|
23
|
+
--explicit Use strict positional parsing (EMAIL NAME COMPANY TITLE NOTE).
|
|
24
|
+
-A --auto Smart parsing mode (default, kept for backward compatibility).
|
|
25
|
+
-C COMPANY --company=COMPANY Company name.
|
|
26
|
+
-E EMAIL --email=EMAIL Email.
|
|
27
|
+
-F FIRST --first=FIRST First name.
|
|
28
|
+
--first-name=FIRST First name (alias for --first).
|
|
29
|
+
--firstname=FIRST First name (alias for --first).
|
|
30
|
+
-L LAST --last=LAST Last name.
|
|
31
|
+
--last-name=LAST Last name (alias for --last).
|
|
32
|
+
--lastname=LAST Last name (alias for --last).
|
|
33
|
+
-U NAME --full-name=NAME Full name.
|
|
34
|
+
--name=NAME Full name (alias for --full-name).
|
|
35
|
+
-N NOTE --note=NOTE Any text you want to save in the `Note` field in Contacts.app.
|
|
36
|
+
--notes=NOTE Note text (alias for --note).
|
|
37
|
+
-P PHONE --phone=PHONE Phone.
|
|
38
|
+
-T TITLE --title=TITLE Job title.
|
|
39
|
+
-X TEXT --text=TEXT Text for jodie to try her best to parse semi-intelligently if she can.
|
|
40
|
+
-W WEBSITES --websites=WEBSITES Comma-separated list of websites/URLs.
|
|
41
|
+
--website=WEBSITES Website URLs (alias for --websites).
|
|
42
|
+
--linkedin=URL LinkedIn profile URL (auto-labeled as LinkedIn).
|
|
43
|
+
-D --dry-run Preview parsed fields without saving.
|
|
44
|
+
-H --help Show this screen.
|
|
45
|
+
-V --version Show version.
|
|
46
|
+
--paste Read input from clipboard (pbpaste).
|
|
47
|
+
--stdin Read input from stdin (pipe/heredoc).
|
|
48
|
+
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
__version__ = '0.1.0'
|
|
52
|
+
__title__ = "jodie"
|
|
53
|
+
__license__ = "MIT"
|
|
54
|
+
__description__ = "Jodie lets you add contacts to Contacts.app on macOS from command line"
|
|
55
|
+
__keywords__ = "macOS Contacts.app Contact management Contacts command line tool CLI"
|
|
56
|
+
__author__ = "austin"
|
|
57
|
+
__email__ = "tips@cia.lol"
|
|
58
|
+
__url__ = "https://github.com/austinogilvie/jodie"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
__all__ = ['__version__', '__description__', '__url__', '__doc__']
|
jodie/cli/__init__.py
ADDED
jodie/cli/__main__.py
ADDED
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# jodie/cli/__main__.py
|
|
3
|
+
import sys
|
|
4
|
+
from docopt import docopt
|
|
5
|
+
from nameparser import HumanName
|
|
6
|
+
import jodie
|
|
7
|
+
from jodie.cli.__doc__ import __version__, __description__, __url__, __doc__
|
|
8
|
+
from jodie.constants import WEBMAIL_DOMAINS
|
|
9
|
+
|
|
10
|
+
COMMANDS = ('new', 'parse',)
|
|
11
|
+
UTILITY_FLAGS = ('--auto', '--explicit', '--help', '--version', '--dry-run', '--paste', '--stdin')
|
|
12
|
+
|
|
13
|
+
def detect_argument_mode(args):
|
|
14
|
+
"""
|
|
15
|
+
Mode priority:
|
|
16
|
+
1. --explicit -> positional (strict order)
|
|
17
|
+
2. Named flags -> named mode
|
|
18
|
+
3. TEXT arguments -> auto (default)
|
|
19
|
+
"""
|
|
20
|
+
# --explicit forces positional mode
|
|
21
|
+
if args.get('--explicit'):
|
|
22
|
+
return "positional"
|
|
23
|
+
|
|
24
|
+
# Check for named options (excluding utility flags)
|
|
25
|
+
named_options = {k: v for k, v in args.items()
|
|
26
|
+
if k.startswith('--') and k not in UTILITY_FLAGS}
|
|
27
|
+
|
|
28
|
+
if any(named_options.values()):
|
|
29
|
+
return "named"
|
|
30
|
+
|
|
31
|
+
# Default: bare args -> auto-parse
|
|
32
|
+
if args.get('TEXT'):
|
|
33
|
+
return "auto"
|
|
34
|
+
|
|
35
|
+
return "positional"
|
|
36
|
+
|
|
37
|
+
def parse_auto(arguments):
|
|
38
|
+
detected_fields = {
|
|
39
|
+
"first_name": None,
|
|
40
|
+
"last_name": None,
|
|
41
|
+
"email": None,
|
|
42
|
+
"phone": None,
|
|
43
|
+
"job_title": None,
|
|
44
|
+
"company": None,
|
|
45
|
+
"websites": [],
|
|
46
|
+
"note": None
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
# Track which arguments were consumed (by original text)
|
|
50
|
+
consumed_args = set()
|
|
51
|
+
|
|
52
|
+
# First pass: identify all fields that can be unambiguously determined
|
|
53
|
+
for arg in arguments:
|
|
54
|
+
# 1. Email Address - Strong, unambiguous signal
|
|
55
|
+
if not detected_fields["email"]:
|
|
56
|
+
email = jodie.parsers.EmailParser.parse(arg)
|
|
57
|
+
if email:
|
|
58
|
+
detected_fields["email"] = email
|
|
59
|
+
consumed_args.add(arg)
|
|
60
|
+
# Infer name from mailbox format if name is not already set
|
|
61
|
+
if not detected_fields["first_name"]:
|
|
62
|
+
first_name, last_name = jodie.parsers.NameParser.parse(arg)
|
|
63
|
+
if first_name or last_name:
|
|
64
|
+
detected_fields["first_name"] = first_name
|
|
65
|
+
detected_fields["last_name"] = last_name
|
|
66
|
+
continue
|
|
67
|
+
|
|
68
|
+
# 2. Website URL - High-confidence markers
|
|
69
|
+
website = jodie.parsers.WebsiteParser.parse(arg)
|
|
70
|
+
if website:
|
|
71
|
+
if not detected_fields["websites"]:
|
|
72
|
+
detected_fields["websites"] = []
|
|
73
|
+
detected_fields["websites"].append(website)
|
|
74
|
+
consumed_args.add(arg)
|
|
75
|
+
continue
|
|
76
|
+
|
|
77
|
+
# 3. Job Title - Common patterns, after ruling out email/URL
|
|
78
|
+
if not detected_fields["job_title"]:
|
|
79
|
+
title = jodie.parsers.TitleParser.parse(arg)
|
|
80
|
+
if title:
|
|
81
|
+
detected_fields["job_title"] = title
|
|
82
|
+
consumed_args.add(arg)
|
|
83
|
+
continue
|
|
84
|
+
|
|
85
|
+
# 4. Phone Number - Look for phone patterns
|
|
86
|
+
if not detected_fields["phone"]:
|
|
87
|
+
phone = jodie.parsers.PhoneParser.parse(arg)
|
|
88
|
+
if phone:
|
|
89
|
+
detected_fields["phone"] = phone
|
|
90
|
+
consumed_args.add(arg)
|
|
91
|
+
continue
|
|
92
|
+
|
|
93
|
+
# 5. Person Name - Often ambiguous without context
|
|
94
|
+
if not detected_fields["first_name"]:
|
|
95
|
+
first_name, last_name = jodie.parsers.NameParser.parse(arg)
|
|
96
|
+
if first_name or last_name:
|
|
97
|
+
detected_fields["first_name"] = first_name
|
|
98
|
+
detected_fields["last_name"] = last_name
|
|
99
|
+
consumed_args.add(arg)
|
|
100
|
+
continue
|
|
101
|
+
|
|
102
|
+
# Second pass: handle company name and any remaining fields
|
|
103
|
+
for arg in arguments:
|
|
104
|
+
# Skip if this argument was already consumed
|
|
105
|
+
if arg in consumed_args:
|
|
106
|
+
continue
|
|
107
|
+
|
|
108
|
+
# 6. Company Name - Most ambiguous, use as fallback
|
|
109
|
+
if not detected_fields["company"]:
|
|
110
|
+
# Check for business-related terms
|
|
111
|
+
if any(term in arg.lower() for term in ["inc", "llc", "ltd", "corp", "co"]):
|
|
112
|
+
detected_fields["company"] = arg.strip()
|
|
113
|
+
continue
|
|
114
|
+
|
|
115
|
+
# Check if this matches any of the collected website domains
|
|
116
|
+
if detected_fields["websites"]:
|
|
117
|
+
for url in detected_fields["websites"]:
|
|
118
|
+
domain = url.split("//")[-1].split("/")[0].lower()
|
|
119
|
+
if arg.lower() in domain or domain in arg.lower():
|
|
120
|
+
detected_fields["company"] = arg.strip()
|
|
121
|
+
break
|
|
122
|
+
if detected_fields["company"]:
|
|
123
|
+
continue
|
|
124
|
+
|
|
125
|
+
# If we get here and still don't have a company, this might be the company name
|
|
126
|
+
if not detected_fields["company"]:
|
|
127
|
+
detected_fields["company"] = arg.strip()
|
|
128
|
+
|
|
129
|
+
# Infer company from email domain if not found
|
|
130
|
+
if not detected_fields["company"] and detected_fields["email"]:
|
|
131
|
+
email = detected_fields["email"]
|
|
132
|
+
if "@" in email:
|
|
133
|
+
domain = email.split("@")[1].lower()
|
|
134
|
+
if domain not in WEBMAIL_DOMAINS:
|
|
135
|
+
# Extract company name from domain (e.g., "thirdprime.vc" -> "Thirdprime")
|
|
136
|
+
company_name = domain.split(".")[0].title()
|
|
137
|
+
detected_fields["company"] = company_name
|
|
138
|
+
|
|
139
|
+
return detected_fields
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def main():
|
|
143
|
+
first, last, email, phone, title, company, websites, note = (None,) * 8
|
|
144
|
+
|
|
145
|
+
args = docopt(__doc__, version=__version__)
|
|
146
|
+
|
|
147
|
+
# Handle --paste: read from clipboard and parse
|
|
148
|
+
if args.get('--paste'):
|
|
149
|
+
from jodie.input import read_clipboard, SignaturePreprocessor
|
|
150
|
+
text = read_clipboard()
|
|
151
|
+
preprocessed = SignaturePreprocessor.preprocess(text)
|
|
152
|
+
fields = parse_auto(preprocessed)
|
|
153
|
+
if fields:
|
|
154
|
+
if fields.get('first_name'):
|
|
155
|
+
human_name = HumanName(f"{fields['first_name']} {fields['last_name']}".strip())
|
|
156
|
+
if human_name:
|
|
157
|
+
first, last = human_name.first, human_name.last
|
|
158
|
+
email = fields.get('email')
|
|
159
|
+
phone = fields.get('phone')
|
|
160
|
+
title = fields.get('job_title')
|
|
161
|
+
company = fields.get('company')
|
|
162
|
+
websites = fields.get('websites')
|
|
163
|
+
# Apply explicit overrides from command line
|
|
164
|
+
# TODO: Notes disabled pending AppleScript refactor (ISS-000012)
|
|
165
|
+
# note = args.get('--note') or args.get('--notes')
|
|
166
|
+
if args.get('--company'):
|
|
167
|
+
company = args.get('--company')
|
|
168
|
+
|
|
169
|
+
# Handle --stdin: read from stdin and parse
|
|
170
|
+
elif args.get('--stdin'):
|
|
171
|
+
from jodie.input import read_stdin, SignaturePreprocessor
|
|
172
|
+
text = read_stdin()
|
|
173
|
+
preprocessed = SignaturePreprocessor.preprocess(text)
|
|
174
|
+
fields = parse_auto(preprocessed)
|
|
175
|
+
if fields:
|
|
176
|
+
if fields.get('first_name'):
|
|
177
|
+
human_name = HumanName(f"{fields['first_name']} {fields['last_name']}".strip())
|
|
178
|
+
if human_name:
|
|
179
|
+
first, last = human_name.first, human_name.last
|
|
180
|
+
email = fields.get('email')
|
|
181
|
+
phone = fields.get('phone')
|
|
182
|
+
title = fields.get('job_title')
|
|
183
|
+
company = fields.get('company')
|
|
184
|
+
websites = fields.get('websites')
|
|
185
|
+
# Apply explicit overrides from command line
|
|
186
|
+
# TODO: Notes disabled pending AppleScript refactor (ISS-000012)
|
|
187
|
+
# note = args.get('--note') or args.get('--notes')
|
|
188
|
+
if args.get('--company'):
|
|
189
|
+
company = args.get('--company')
|
|
190
|
+
|
|
191
|
+
else:
|
|
192
|
+
# Standard mode detection for non-paste/stdin
|
|
193
|
+
mode = detect_argument_mode(args)
|
|
194
|
+
|
|
195
|
+
if mode == "auto":
|
|
196
|
+
fields = parse_auto(args['TEXT'])
|
|
197
|
+
if fields:
|
|
198
|
+
if fields.get('first_name'):
|
|
199
|
+
human_name = HumanName(f"{fields['first_name']} {fields['last_name']}".strip())
|
|
200
|
+
if human_name:
|
|
201
|
+
first, last = human_name.first, human_name.last
|
|
202
|
+
|
|
203
|
+
email = fields.get('email')
|
|
204
|
+
phone = fields.get('phone')
|
|
205
|
+
title = fields.get('job_title')
|
|
206
|
+
company = fields.get('company')
|
|
207
|
+
websites = fields.get('websites')
|
|
208
|
+
# TODO: Notes disabled pending AppleScript refactor (ISS-000012)
|
|
209
|
+
# note = fields.get('note')
|
|
210
|
+
|
|
211
|
+
elif mode == "positional":
|
|
212
|
+
try:
|
|
213
|
+
full_name = args['NAME']
|
|
214
|
+
first, last = jodie.parsers.NameParser.parse(full_name)
|
|
215
|
+
email = jodie.parsers.EmailParser.parse(args['EMAIL'])
|
|
216
|
+
except Exception as e:
|
|
217
|
+
sys.stderr.write(
|
|
218
|
+
f"Error processing positional arguments: {str(e)}\n")
|
|
219
|
+
sys.exit(1)
|
|
220
|
+
company = args.get('COMPANY')
|
|
221
|
+
title = args.get('TITLE')
|
|
222
|
+
# TODO: Notes disabled pending AppleScript refactor (ISS-000012)
|
|
223
|
+
# note = args.get('NOTE')
|
|
224
|
+
|
|
225
|
+
elif mode == "named":
|
|
226
|
+
try:
|
|
227
|
+
# Handle first name aliases: --first, --first-name, --firstname
|
|
228
|
+
first = args.get('--first') or args.get('--first-name') or args.get('--firstname')
|
|
229
|
+
# Handle last name aliases: --last, --last-name, --lastname
|
|
230
|
+
last = args.get('--last') or args.get('--last-name') or args.get('--lastname')
|
|
231
|
+
# Handle full name aliases: --full-name, --name
|
|
232
|
+
full = args.get('--full-name') or args.get('--name')
|
|
233
|
+
if full:
|
|
234
|
+
parts = full.split()
|
|
235
|
+
if first is None:
|
|
236
|
+
first = parts[0].strip()
|
|
237
|
+
if last is None:
|
|
238
|
+
last = ' '.join(parts[1:]).strip()
|
|
239
|
+
email = args.get('--email')
|
|
240
|
+
phone = args.get('--phone')
|
|
241
|
+
title = args.get('--title')
|
|
242
|
+
company = args.get('--company')
|
|
243
|
+
# Handle websites aliases: --websites, --website
|
|
244
|
+
websites = args.get('--websites') or args.get('--website')
|
|
245
|
+
if websites and isinstance(websites, str):
|
|
246
|
+
# Only split if it's a string (from command line)
|
|
247
|
+
websites = [url.strip() for url in websites.split(',')]
|
|
248
|
+
# Handle --linkedin specially (adds with LinkedIn label)
|
|
249
|
+
linkedin_url = args.get('--linkedin')
|
|
250
|
+
if linkedin_url:
|
|
251
|
+
if websites is None:
|
|
252
|
+
websites = []
|
|
253
|
+
elif isinstance(websites, str):
|
|
254
|
+
websites = [websites]
|
|
255
|
+
websites.append({'url': linkedin_url, 'label': 'LinkedIn'})
|
|
256
|
+
# Handle note aliases: --note, --notes
|
|
257
|
+
# TODO: Notes disabled pending AppleScript refactor (ISS-000012)
|
|
258
|
+
# note = args.get('--note') or args.get('--notes')
|
|
259
|
+
|
|
260
|
+
except Exception as e:
|
|
261
|
+
sys.stderr.write(f"Error processing named arguments: {str(e)}\n")
|
|
262
|
+
sys.exit(1)
|
|
263
|
+
|
|
264
|
+
# Handle dry-run preview
|
|
265
|
+
if args.get('--dry-run'):
|
|
266
|
+
from jodie.cli.preview import format_preview
|
|
267
|
+
fields = {
|
|
268
|
+
'first_name': first,
|
|
269
|
+
'last_name': last,
|
|
270
|
+
'email': email,
|
|
271
|
+
'phone': phone,
|
|
272
|
+
'job_title': title,
|
|
273
|
+
'company': company,
|
|
274
|
+
'websites': websites,
|
|
275
|
+
'note': note
|
|
276
|
+
}
|
|
277
|
+
preview = format_preview(fields)
|
|
278
|
+
sys.stdout.write(preview + "\n")
|
|
279
|
+
sys.exit(0)
|
|
280
|
+
|
|
281
|
+
c = jodie.contact.Contact(
|
|
282
|
+
first_name=first,
|
|
283
|
+
last_name=last,
|
|
284
|
+
email=email,
|
|
285
|
+
phone=phone,
|
|
286
|
+
job_title=title,
|
|
287
|
+
company=company,
|
|
288
|
+
websites=websites,
|
|
289
|
+
note=note
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
sys.stdout.write(f'Saving...\n{c}\n')
|
|
293
|
+
status = 0 if c.save() else 1
|
|
294
|
+
sys.exit(status)
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
if __name__ == "__main__":
|
|
298
|
+
main()
|
jodie/cli/preview.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# jodie/cli/preview.py
|
|
3
|
+
"""Dry-run preview formatting for contact fields."""
|
|
4
|
+
|
|
5
|
+
def format_preview(fields: dict) -> str:
|
|
6
|
+
"""Format parsed fields as a preview table.
|
|
7
|
+
|
|
8
|
+
Args:
|
|
9
|
+
fields: Dict with keys like 'first_name', 'last_name', 'email', etc.
|
|
10
|
+
Values can be strings or dicts with 'value', 'source', 'confidence'
|
|
11
|
+
|
|
12
|
+
Returns:
|
|
13
|
+
Formatted string table
|
|
14
|
+
"""
|
|
15
|
+
# Normalize fields to consistent format
|
|
16
|
+
normalized = {}
|
|
17
|
+
field_labels = {
|
|
18
|
+
'first_name': 'First Name',
|
|
19
|
+
'last_name': 'Last Name',
|
|
20
|
+
'email': 'Email',
|
|
21
|
+
'phone': 'Phone',
|
|
22
|
+
'job_title': 'Title',
|
|
23
|
+
'company': 'Company',
|
|
24
|
+
'websites': 'Websites',
|
|
25
|
+
'note': 'Note'
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
for key, value in fields.items():
|
|
29
|
+
if value is None:
|
|
30
|
+
continue
|
|
31
|
+
label = field_labels.get(key, key.replace('_', ' ').title())
|
|
32
|
+
if isinstance(value, dict):
|
|
33
|
+
normalized[label] = value
|
|
34
|
+
elif isinstance(value, list):
|
|
35
|
+
# Handle websites list
|
|
36
|
+
if value:
|
|
37
|
+
normalized[label] = {'value': ', '.join(str(v) for v in value), 'source': 'parsed', 'confidence': 1.0}
|
|
38
|
+
else:
|
|
39
|
+
normalized[label] = {'value': str(value), 'source': 'parsed', 'confidence': 1.0}
|
|
40
|
+
|
|
41
|
+
if not normalized:
|
|
42
|
+
return "No fields detected."
|
|
43
|
+
|
|
44
|
+
lines = []
|
|
45
|
+
lines.append("┌" + "─" * 57 + "┐")
|
|
46
|
+
lines.append("│" + "Contact Preview".center(57) + "│")
|
|
47
|
+
lines.append("├" + "─" * 14 + "┬" + "─" * 22 + "┬" + "─" * 19 + "┤")
|
|
48
|
+
lines.append("│ Field │ Value │ Source │")
|
|
49
|
+
lines.append("├" + "─" * 14 + "┼" + "─" * 22 + "┼" + "─" * 19 + "┤")
|
|
50
|
+
|
|
51
|
+
for field_name, field_data in normalized.items():
|
|
52
|
+
value = str(field_data.get('value', ''))[:20]
|
|
53
|
+
source = field_data.get('source', 'unknown')
|
|
54
|
+
confidence = field_data.get('confidence', 0)
|
|
55
|
+
if confidence > 0:
|
|
56
|
+
source_str = f"{source} ({int(confidence*100)}%)"
|
|
57
|
+
else:
|
|
58
|
+
source_str = source
|
|
59
|
+
lines.append(f"│ {field_name:<12} │ {value:<20} │ {source_str:<17} │")
|
|
60
|
+
|
|
61
|
+
lines.append("└" + "─" * 14 + "┴" + "─" * 22 + "┴" + "─" * 19 + "┘")
|
|
62
|
+
lines.append("")
|
|
63
|
+
lines.append("Run without --dry-run to save this contact.")
|
|
64
|
+
|
|
65
|
+
return "\n".join(lines)
|
jodie/config.py
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# jodie/config.py
|
|
3
|
+
"""Configuration file support for jodie.
|
|
4
|
+
|
|
5
|
+
Supports .jodierc files in TOML format, checked in order:
|
|
6
|
+
1. Current directory (./.jodierc)
|
|
7
|
+
2. Home directory (~/.jodierc)
|
|
8
|
+
|
|
9
|
+
Command-line flags override config values.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import os
|
|
13
|
+
import sys
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Optional, Dict, Any
|
|
16
|
+
|
|
17
|
+
# Try Python 3.11+ tomllib, fall back to tomli
|
|
18
|
+
try:
|
|
19
|
+
import tomllib
|
|
20
|
+
except ImportError:
|
|
21
|
+
try:
|
|
22
|
+
import tomli as tomllib
|
|
23
|
+
except ImportError:
|
|
24
|
+
tomllib = None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
DEFAULT_CONFIG: Dict[str, Any] = {
|
|
28
|
+
"defaults": {
|
|
29
|
+
"company": None,
|
|
30
|
+
"note_prefix": None,
|
|
31
|
+
"websites": [],
|
|
32
|
+
"tags": [],
|
|
33
|
+
},
|
|
34
|
+
"parsers": {
|
|
35
|
+
"email": True,
|
|
36
|
+
"phone": True,
|
|
37
|
+
"website": True,
|
|
38
|
+
"title": True,
|
|
39
|
+
"name": True,
|
|
40
|
+
"company": True,
|
|
41
|
+
},
|
|
42
|
+
"behavior": {
|
|
43
|
+
"auto_infer_company": True,
|
|
44
|
+
"strip_pronouns": True,
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def find_config_file() -> Optional[Path]:
|
|
50
|
+
"""Find .jodierc in current dir or home dir.
|
|
51
|
+
|
|
52
|
+
Returns:
|
|
53
|
+
Path to config file if found, None otherwise
|
|
54
|
+
"""
|
|
55
|
+
candidates = [
|
|
56
|
+
Path.cwd() / ".jodierc",
|
|
57
|
+
Path.home() / ".jodierc",
|
|
58
|
+
]
|
|
59
|
+
for path in candidates:
|
|
60
|
+
if path.exists():
|
|
61
|
+
return path
|
|
62
|
+
return None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _deep_merge(base: dict, override: dict) -> dict:
|
|
66
|
+
"""Recursively merge override into base.
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
base: Base dictionary
|
|
70
|
+
override: Dictionary to merge on top
|
|
71
|
+
|
|
72
|
+
Returns:
|
|
73
|
+
Merged dictionary
|
|
74
|
+
"""
|
|
75
|
+
result = base.copy()
|
|
76
|
+
for key, value in override.items():
|
|
77
|
+
if key in result and isinstance(result[key], dict) and isinstance(value, dict):
|
|
78
|
+
result[key] = _deep_merge(result[key], value)
|
|
79
|
+
else:
|
|
80
|
+
result[key] = value
|
|
81
|
+
return result
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def load_config() -> Dict[str, Any]:
|
|
85
|
+
"""Load config from file, merged with defaults.
|
|
86
|
+
|
|
87
|
+
Returns:
|
|
88
|
+
Configuration dictionary with defaults and any user overrides
|
|
89
|
+
"""
|
|
90
|
+
config = DEFAULT_CONFIG.copy()
|
|
91
|
+
config = {k: v.copy() if isinstance(v, dict) else v for k, v in config.items()}
|
|
92
|
+
|
|
93
|
+
if tomllib is None:
|
|
94
|
+
# No TOML parser available, return defaults
|
|
95
|
+
return config
|
|
96
|
+
|
|
97
|
+
config_file = find_config_file()
|
|
98
|
+
if config_file:
|
|
99
|
+
try:
|
|
100
|
+
with open(config_file, "rb") as f:
|
|
101
|
+
user_config = tomllib.load(f)
|
|
102
|
+
config = _deep_merge(config, user_config)
|
|
103
|
+
except Exception as e:
|
|
104
|
+
sys.stderr.write(f"Warning: Error loading {config_file}: {e}\n")
|
|
105
|
+
|
|
106
|
+
return config
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def get_default(config: Dict[str, Any], key: str, cli_value: Optional[str] = None) -> Optional[str]:
|
|
110
|
+
"""Get a value, preferring CLI over config.
|
|
111
|
+
|
|
112
|
+
Args:
|
|
113
|
+
config: Configuration dictionary
|
|
114
|
+
key: Key to look up in defaults section
|
|
115
|
+
cli_value: Value from command line (takes precedence)
|
|
116
|
+
|
|
117
|
+
Returns:
|
|
118
|
+
CLI value if provided, otherwise config default, or None
|
|
119
|
+
"""
|
|
120
|
+
if cli_value is not None:
|
|
121
|
+
return cli_value
|
|
122
|
+
return config.get("defaults", {}).get(key)
|
jodie/constants.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# jodie/constants.py
|
|
3
|
+
"""Shared constants used across jodie modules."""
|
|
4
|
+
|
|
5
|
+
from typing import FrozenSet
|
|
6
|
+
|
|
7
|
+
# Common webmail/personal email providers
|
|
8
|
+
# Used to distinguish work emails from personal emails
|
|
9
|
+
# and to skip company inference from these domains
|
|
10
|
+
WEBMAIL_DOMAINS: FrozenSet[str] = frozenset({
|
|
11
|
+
# Google
|
|
12
|
+
"gmail.com",
|
|
13
|
+
"googlemail.com",
|
|
14
|
+
# Microsoft
|
|
15
|
+
"hotmail.com",
|
|
16
|
+
"hotmail.co.uk",
|
|
17
|
+
"hotmail.de",
|
|
18
|
+
"hotmail.es",
|
|
19
|
+
"hotmail.fr",
|
|
20
|
+
"hotmail.it",
|
|
21
|
+
"outlook.com",
|
|
22
|
+
"live.com",
|
|
23
|
+
# Yahoo
|
|
24
|
+
"yahoo.com",
|
|
25
|
+
"ymail.com",
|
|
26
|
+
# Apple
|
|
27
|
+
"icloud.com",
|
|
28
|
+
"mac.com",
|
|
29
|
+
"me.com",
|
|
30
|
+
# AOL/Verizon
|
|
31
|
+
"aol.com",
|
|
32
|
+
"verizon.net",
|
|
33
|
+
# Privacy-focused
|
|
34
|
+
"protonmail.com",
|
|
35
|
+
"proton.me",
|
|
36
|
+
"tutanota.com",
|
|
37
|
+
"tuta.com",
|
|
38
|
+
"hushmail.com",
|
|
39
|
+
# Other
|
|
40
|
+
"hey.com",
|
|
41
|
+
"qq.com",
|
|
42
|
+
"zoho.com",
|
|
43
|
+
"fastmail.com",
|
|
44
|
+
})
|