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 ADDED
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env python3
2
+ # jodie/__init__.py
3
+
4
+ from jodie import contact, parsers
5
+ from jodie.cli.__doc__ import __version__, __description__, __url__, __doc__
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
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env python3
2
+ # jodie/cli/__init__.py
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
+ })
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env python3
2
+ # jodie/contact/__init__.py
3
+ from jodie.contact.contact import Contact
4
+
5
+ __all__ = ("Contact", )