-
Notifications
You must be signed in to change notification settings - Fork 798
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Added contacts feature, documented in core/contacts/README.md. #1706
Merged
+421
−9
Merged
Changes from 2 commits
Commits
Show all changes
7 commits
Select commit
Hold shift + click to select a range
9b4ce89
Added contacts feature, documented in core/contacts/README.md.
wolfmanstout c920e49
Merge branch 'main' into contacts
nriley 19e9c35
Move contacts to "private" directory.
wolfmanstout 4114d15
Use "contacts json" for json file.
wolfmanstout a82312d
Updated contacts docs and improve code clarity.
wolfmanstout 10d3349
Update README.md
wolfmanstout 8452160
Update .gitignore
wolfmanstout File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,62 @@ | ||
# Contacts | ||
|
||
This directory provides a versatile `<user.prose_contact>` capture that can be | ||
used to insert names and email addresses using a suffix. The contact list may be | ||
provided through `contacts.json` in this directory, `contacts.csv` in the | ||
settings directory, or both. | ||
wolfmanstout marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
Here is an example contacts.json: | ||
|
||
```json | ||
[ | ||
{ | ||
"email": "[email protected]", | ||
"full_name": "Jonathan Doh: Jonathan Doe", | ||
"nicknames": ["Jon", "Jah Nee: Jonny"] | ||
} | ||
] | ||
``` | ||
|
||
Note that for either full_name or nicknames, pronunciation can be provided via | ||
the standard Talon list format of "[pronunciation]: [name]". Pronunciation for | ||
the first name is automatically extracted from pronunciation for the full name, | ||
if there are the same number of name parts in each. Pronunciation can be | ||
overridden for the first name by adding a nickname with matching written form. | ||
|
||
To refer to this contact, you could say: | ||
|
||
- Jonathan Doh email -> [email protected] | ||
- Jonathan email -> [email protected] | ||
- Jah Nee email -> [email protected] | ||
- Jah Nee name -> Jonny | ||
- Jonathan Doh name -> Jonathan Doe | ||
- Jon last name -> Doe | ||
- Jon full name -> Jonathan Doe | ||
- Jon names -> Jon's | ||
- Jon full names -> Jonathan Doe's | ||
|
||
The CSV format provides only email and full name functionality: | ||
|
||
```csv | ||
Name,Email | ||
John Doe,[email protected] | ||
Jane Doe,[email protected] | ||
``` | ||
|
||
The advantage of the CSV format is that it is easily exported. If both the CSV | ||
and JSON are present, they will be merged based on email addresses. This makes | ||
it easy to use an exported CSV and maintain nicknames in the JSON. For example, | ||
to export from Gmail, go to https://contacts.google.com/, then click "Frequently | ||
contacted", then "Export". Then run: | ||
|
||
```bash | ||
cat contacts.csv | python -c "import csv; import sys; w=csv.writer(sys.stdout); [w.writerow([row['First Name'] + ' ' + row['Last Name'], row['E-mail 1 - Value']]) for row in csv.DictReader(sys.stdin)]" | ||
``` | ||
|
||
In case of name conflicts (e.g. two people named John), the first instance will | ||
be preferred, with all JSON contacts taking precedence over CSV. If you wish to | ||
refer to both, use the pronunciation to differentiate, using a nickname to | ||
override the first name pronunciation if desired. For example, you might add | ||
"John S: John" and "John D: John" as nicknames to the two different Johns. This | ||
is also an effective way to handle name homophones such as John and Jon, which | ||
would otherwise be resolved arbitrarily by the speech engine. |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,293 @@ | ||
import json | ||
import logging | ||
from dataclasses import dataclass | ||
|
||
from talon import Context, Module | ||
|
||
from ..user_settings import track_csv_list, track_file | ||
|
||
mod = Module() | ||
ctx = Context() | ||
|
||
mod.list("contact_names", desc="Contact first names, full names, and nicknames.") | ||
mod.list("contact_emails", desc="Maps names to email addresses.") | ||
mod.list("contact_full_names", desc="Maps names to full names.") | ||
|
||
|
||
@dataclass | ||
class Contact: | ||
email: str | ||
full_name: str | ||
nicknames: list[str] | ||
pronunciations: dict[str, str] | ||
|
||
@classmethod | ||
def from_json(cls, contact): | ||
email = contact.get("email") | ||
if not email: | ||
logging.error(f"Skipping contact missing email: {contact}") | ||
return None | ||
|
||
# Handle full name with potential pronunciation | ||
full_name_raw = contact.get("full_name", "") | ||
pronunciations = {} | ||
if ":" in full_name_raw: | ||
pronunciation, full_name = [x.strip() for x in full_name_raw.split(":", 1)] | ||
if ( | ||
full_name in pronunciations | ||
and pronunciations[full_name] != pronunciation | ||
): | ||
logging.info( | ||
f"Multiple pronunciations found for '{full_name}'; using '{pronunciation}'" | ||
) | ||
pronunciations[full_name] = pronunciation | ||
|
||
# Add pronunciation for each component of the name. | ||
pron_parts = pronunciation.split() | ||
name_parts = full_name.split() | ||
if len(pron_parts) == len(name_parts): | ||
for pron, name in zip(pron_parts, name_parts): | ||
if name in pronunciations and pronunciations[name] != pron: | ||
logging.info( | ||
f"Multiple different pronunciations found for '{name}' in " | ||
f"{full_name_raw}; using '{pron}'" | ||
) | ||
pronunciations[name] = pron | ||
else: | ||
logging.info( | ||
f"Pronunciation parts don't match name parts for '{full_name_raw}; skipping them.'" | ||
) | ||
else: | ||
full_name = full_name_raw | ||
|
||
# Handle nicknames with potential pronunciations | ||
nicknames = [] | ||
for nickname_raw in contact.get("nicknames", []): | ||
if ":" in nickname_raw: | ||
pronunciation, nickname = [ | ||
x.strip() for x in nickname_raw.split(":", 1) | ||
] | ||
if ( | ||
nickname in pronunciations | ||
and pronunciations[nickname] != pronunciation | ||
): | ||
logging.info( | ||
f"Multiple different pronunciations found for '{nickname}' in " | ||
f"contact {email}; using '{pronunciation}'" | ||
) | ||
pronunciations[nickname] = pronunciation | ||
nicknames.append(nickname) | ||
else: | ||
nicknames.append(nickname_raw) | ||
|
||
return Contact( | ||
email=email, | ||
full_name=full_name, | ||
nicknames=nicknames, | ||
pronunciations=pronunciations, | ||
) | ||
|
||
|
||
csv_contacts: list[Contact] = [] | ||
json_contacts: list[Contact] = [] | ||
|
||
|
||
@track_csv_list("contacts.csv", headers=("Name", "Email"), default={}) | ||
def on_contacts_csv(values): | ||
global csv_contacts | ||
csv_contacts = [] | ||
for email, full_name in values.items(): | ||
if not email: | ||
logging.error(f"Skipping contact missing email: {full_name}") | ||
continue | ||
csv_contacts.append( | ||
Contact(email=email, full_name=full_name, nicknames=[], pronunciations={}) | ||
) | ||
reload_contacts() | ||
|
||
|
||
@track_file("contacts.json", default="[]") | ||
def on_contacts_json(f): | ||
global json_contacts | ||
try: | ||
contacts = json.load(f) | ||
except Exception: | ||
logging.exception("Error parsing contacts.json") | ||
return | ||
|
||
json_contacts = [] | ||
knausj85 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
for contact in contacts: | ||
try: | ||
parsed_contact = Contact.from_json(contact) | ||
if parsed_contact: | ||
json_contacts.append(parsed_contact) | ||
except Exception: | ||
logging.exception(f"Error parsing contact: {contact}") | ||
reload_contacts() | ||
|
||
|
||
def create_pronunciation_to_name_map(contact): | ||
result = {} | ||
if contact.full_name: | ||
result[contact.pronunciations.get(contact.full_name, contact.full_name)] = ( | ||
contact.full_name | ||
) | ||
# Add pronunciation mapping for first name only | ||
first_name = contact.full_name.split()[0] | ||
result[contact.pronunciations.get(first_name, first_name)] = first_name | ||
for nickname in contact.nicknames: | ||
result[contact.pronunciations.get(nickname, nickname)] = nickname | ||
return result | ||
|
||
|
||
def reload_contacts(): | ||
csv_by_email = {contact.email: contact for contact in csv_contacts} | ||
json_by_email = {contact.email: contact for contact in json_contacts} | ||
# Merge the CSV and JSON contacts. Maintain order of contacts with JSON first. | ||
merged_contacts = [] | ||
for email in json_by_email | csv_by_email: | ||
csv_contact = csv_by_email.get(email) | ||
json_contact = json_by_email.get(email) | ||
|
||
if csv_contact and json_contact: | ||
# Prefer JSON data but use CSV name if JSON name is empty | ||
full_name = json_contact.full_name or csv_contact.full_name | ||
merged_contacts.append( | ||
Contact( | ||
email=email, | ||
full_name=full_name, | ||
nicknames=json_contact.nicknames, | ||
pronunciations=json_contact.pronunciations, | ||
) | ||
) | ||
else: | ||
# Use whichever contact exists | ||
merged_contacts.append(json_contact or csv_contact) | ||
|
||
contact_names = {} | ||
contact_emails = {} | ||
contact_full_names = {} | ||
# Iterate in reverse so that the first contact with a name is used. | ||
for contact in reversed(merged_contacts): | ||
pronunciation_map = create_pronunciation_to_name_map(contact) | ||
for pronunciation, name in pronunciation_map.items(): | ||
contact_names[pronunciation] = name | ||
contact_emails[pronunciation] = contact.email | ||
if contact.full_name: | ||
contact_full_names[pronunciation] = contact.full_name | ||
|
||
ctx.lists["user.contact_names"] = contact_names | ||
ctx.lists["user.contact_emails"] = contact_emails | ||
ctx.lists["user.contact_full_names"] = contact_full_names | ||
|
||
|
||
def first_name_from_full_name(full_name: str): | ||
return full_name.split(" ")[0] | ||
|
||
|
||
def last_name_from_full_name(full_name: str): | ||
return full_name.split(" ")[-1] | ||
|
||
|
||
def username_from_email(email: str): | ||
return email.split("@")[0] | ||
|
||
|
||
def make_name_possessive(name: str): | ||
return f"{name}'s" | ||
|
||
|
||
@mod.capture( | ||
rule="{user.contact_names} name", | ||
) | ||
def prose_name(m) -> str: | ||
return m.contact_names | ||
|
||
|
||
@mod.capture( | ||
rule="{user.contact_names} names", | ||
) | ||
def prose_name_possessive(m) -> str: | ||
return make_name_possessive(m.contact_names) | ||
|
||
|
||
@mod.capture( | ||
rule="{user.contact_emails} email [address]", | ||
) | ||
def prose_email(m) -> str: | ||
return m.contact_emails | ||
|
||
|
||
@mod.capture( | ||
rule="{user.contact_emails} (username | L dap)", | ||
) | ||
def prose_username(m) -> str: | ||
return username_from_email(m.contact_emails) | ||
|
||
|
||
@mod.capture( | ||
rule="{user.contact_full_names} full name", | ||
) | ||
def prose_full_name(m) -> str: | ||
return m.contact_full_names | ||
|
||
|
||
@mod.capture( | ||
rule="{user.contact_full_names} full names", | ||
) | ||
def prose_full_name_possessive(m) -> str: | ||
return make_name_possessive(m.contact_full_names) | ||
|
||
|
||
@mod.capture( | ||
rule="{user.contact_full_names} first name", | ||
) | ||
def prose_first_name(m) -> str: | ||
return first_name_from_full_name(m.contact_full_names) | ||
|
||
|
||
@mod.capture( | ||
rule="{user.contact_full_names} first names", | ||
) | ||
def prose_first_name_possessive(m) -> str: | ||
return make_name_possessive(first_name_from_full_name(m.contact_full_names)) | ||
|
||
|
||
@mod.capture( | ||
rule="{user.contact_full_names} last name", | ||
) | ||
def prose_last_name(m) -> str: | ||
return last_name_from_full_name(m.contact_full_names) | ||
|
||
|
||
@mod.capture( | ||
rule="{user.contact_full_names} last names", | ||
) | ||
def prose_last_name_possessive(m) -> str: | ||
return make_name_possessive(last_name_from_full_name(m.contact_full_names)) | ||
|
||
|
||
@mod.capture( | ||
rule="(hi | high) {user.contact_names} [name]", | ||
) | ||
def prose_contact_snippet(m) -> str: | ||
return f"hi {m.contact_names}" | ||
|
||
|
||
@mod.capture( | ||
rule=( | ||
"<user.prose_name> " | ||
"| <user.prose_name_possessive> " | ||
"| <user.prose_email> " | ||
"| <user.prose_username> " | ||
"| <user.prose_full_name> " | ||
"| <user.prose_full_name_possessive> " | ||
"| <user.prose_first_name> " | ||
"| <user.prose_first_name_possessive> " | ||
"| <user.prose_last_name>" | ||
"| <user.prose_last_name_possessive>" | ||
"| <user.prose_contact_snippet>" | ||
), | ||
) | ||
def prose_contact(m) -> str: | ||
return m[0] |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If preferred, I could add this section directly to the top-level readme, or link to it from there.