-
Notifications
You must be signed in to change notification settings - Fork 797
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Added contacts feature, documented in core/contacts/README.md. (#1706)
I have been holding onto this one in my fork for a while, yet it's one of the most useful extensions to community I've written. Long ago I used to put contacts into vocabulary, but they end up conflicting with other words. By adding explicit suffixes and a predictable grammar, it is so much easier to work with them. As a bonus, you never have to remember email addresses again. --------- Co-authored-by: Nicholas Riley <com-github@sabi.net> Co-authored-by: Jeff Knaus <knaus.jeff@gmail.com>
- Loading branch information
1 parent
62c5e74
commit ee99869
Showing
8 changed files
with
421 additions
and
9 deletions.
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
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,61 @@ | ||
# 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 in the private directory via the `contacts.json` file, the `contacts.csv` file, or both. | ||
|
||
Here is an example contacts.json: | ||
|
||
```json | ||
[ | ||
{ | ||
"email": "john.doe@example.com", | ||
"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 -> john.doe@example.com | ||
- Jonathan email -> john.doe@example.com | ||
- Jah Nee email -> john.doe@example.com | ||
- 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,jon.doe@example.com | ||
Jane Doe,jane.doe@example.com | ||
``` | ||
|
||
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={}, private=True) | ||
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="[]", private=True) | ||
def on_contacts_json(f): | ||
global json_contacts | ||
try: | ||
contacts = json.load(f) | ||
except Exception: | ||
logging.exception("Error parsing contacts.json") | ||
return | ||
|
||
json_contacts = [] | ||
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.