From ee99869343953472410a5aaaa53838c6e751daf7 Mon Sep 17 00:00:00 2001 From: James Stout Date: Sat, 8 Feb 2025 17:20:06 -0800 Subject: [PATCH 1/2] 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 Co-authored-by: Jeff Knaus --- .gitignore | 3 + README.md | 2 +- core/README.md | 1 + core/contacts/README.md | 61 ++++ core/contacts/contacts.py | 293 ++++++++++++++++++ .../edit_text_file_list.talon-list | 2 + core/text/text_and_dictation.py | 35 ++- core/user_settings.py | 33 +- 8 files changed, 421 insertions(+), 9 deletions(-) create mode 100644 core/contacts/README.md create mode 100644 core/contacts/contacts.py diff --git a/.gitignore b/.gitignore index 86c8e8dc0b..4a6f5b9325 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,9 @@ __pycache__ .idea/ # Locally generated /settings +# we highly recommended against removing the private folder from the gitignore file +# the directory is used to store sensitive information such as contacts +/private .vscode/settings.json .DS_Store *.bak diff --git a/README.md b/README.md index b03ab280ba..2f352988e2 100644 --- a/README.md +++ b/README.md @@ -282,7 +282,7 @@ Most lists of words are provided as Talon list files, with an extension of `.tal Some lists with multiple spoken forms/alternatives are instead provided as CSV files. Some are in the `settings` folder and are not created until you launch Talon with `community` installed. -You can customize common Talon list and CSV files with voice commands: say the word `customize` followed by `abbreviations`, `additional words`, `alphabet`, `homophones`, `search engines`, `Unix utilities`, `websites` or `words to replace`. These open the file in a text editor and move the insertion point to the bottom of the file so you can add to it. +You can customize common Talon list and CSV files with voice commands: say the word `customize` followed by `abbreviations`, `additional words`, `alphabet`, `homophones`, `search engines`, `Unix utilities`, `websites`, `words to replace`, `contacts json` or `contacts csv`. These open the file in a text editor and move the insertion point to the bottom of the file so you can add to it. You can also add words to the vocabulary or replacements (words_to_replace) by using the commands in [edit_vocabulary.talon](core/vocabulary/edit_vocabulary.talon). diff --git a/core/README.md b/core/README.md index 83edbcea0d..d84aff4a1f 100644 --- a/core/README.md +++ b/core/README.md @@ -4,6 +4,7 @@ This folder contains `edit_settings.talon`, which has a command to open various - `abbreviate` has a command for the use of abbreviations - `app_switcher` does not have commands but has the implementation of functions that allow for switching between applications +- `contacts` has captures for inserting contact information including name and email. - `edit` has commands for navigating and editing text with copy, paste, etc., as well as commands for zooming in and out - `file_extension` has a command for simpler spoken forms of file and website name extensions - `help` has commands to open various help menus, as described in the top level [README](https://github.com/talonhub/community?tab=readme-ov-file#getting-started-with-talon) diff --git a/core/contacts/README.md b/core/contacts/README.md new file mode 100644 index 0000000000..14b1decb06 --- /dev/null +++ b/core/contacts/README.md @@ -0,0 +1,61 @@ +# Contacts + +This directory provides a versatile `` 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. diff --git a/core/contacts/contacts.py b/core/contacts/contacts.py new file mode 100644 index 0000000000..a85257815d --- /dev/null +++ b/core/contacts/contacts.py @@ -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=( + " " + "| " + "| " + "| " + "| " + "| " + "| " + "| " + "| " + "| " + "| " + ), +) +def prose_contact(m) -> str: + return m[0] diff --git a/core/edit_text_file/edit_text_file_list.talon-list b/core/edit_text_file/edit_text_file_list.talon-list index 1af4e871b8..de8e1f223c 100644 --- a/core/edit_text_file/edit_text_file_list.talon-list +++ b/core/edit_text_file/edit_text_file_list.talon-list @@ -13,3 +13,5 @@ unix utilities: tags/terminal/unix_utility.talon-list abbreviations: settings/abbreviations.csv file extensions: settings/file_extensions.csv words to replace: settings/words_to_replace.csv +contacts json: settings/contacts.json +contacts csv: settings/contacts.csv diff --git a/core/text/text_and_dictation.py b/core/text/text_and_dictation.py index ea465e300f..6fde975978 100644 --- a/core/text/text_and_dictation.py +++ b/core/text/text_and_dictation.py @@ -120,14 +120,28 @@ def word(m) -> str: ) -@mod.capture(rule="({user.vocabulary} | )+") +@mod.capture(rule="({user.vocabulary} | | )+") def text(m) -> str: """A sequence of words, including user-defined vocabulary.""" return format_phrase(m) @mod.capture( - rule="({user.vocabulary} | {user.punctuation} | {user.prose_snippets} | | | | | | | )+" + rule=( + "(" + "{user.vocabulary}" + "| {user.punctuation}" + "| {user.prose_snippets}" + "| " + "| " + "| " + "| " + "| " + "| " + "| " + "| " + ")+" + ) ) def prose(m) -> str: """Mixed words and punctuation, auto-spaced & capitalized.""" @@ -136,14 +150,27 @@ def prose(m) -> str: @mod.capture( - rule="({user.vocabulary} | {user.punctuation} | {user.prose_snippets} | | | | | | )+" + rule=( + "(" + "{user.vocabulary}" + "| {user.punctuation}" + "| {user.prose_snippets}" + "| " + "| " + "| " + "| " + "| " + "| " + "| " + ")+" + ) ) def raw_prose(m) -> str: """Mixed words and punctuation, auto-spaced & capitalized, without quote straightening and commands (for use in dictation mode).""" return apply_formatting(m) -# For dragon, omit support for abbreviations +# For dragon, omit support for abbreviations and contacts @ctx_dragon.capture("user.text", rule="({user.vocabulary} | )+") def text_dragon(m) -> str: """A sequence of words, including user-defined vocabulary.""" diff --git a/core/user_settings.py b/core/user_settings.py index b1369c820f..4cfb970485 100644 --- a/core/user_settings.py +++ b/core/user_settings.py @@ -1,5 +1,4 @@ import csv -import os from pathlib import Path from typing import IO, Callable @@ -9,6 +8,8 @@ # community folder. SETTINGS_DIR = Path(__file__).parents[1] / "settings" SETTINGS_DIR.mkdir(exist_ok=True) +PRIVATE_DIR = Path(__file__).parents[1] / "private" +PRIVATE_DIR.mkdir(exist_ok=True) CallbackT = Callable[[dict[str, str]], None] DecoratorT = Callable[[CallbackT], CallbackT] @@ -76,9 +77,10 @@ def track_csv_list( headers: tuple[str, str], default: dict[str, str] = None, is_spoken_form_first: bool = False, + private: bool = False, ) -> DecoratorT: assert filename.endswith(".csv") - path = SETTINGS_DIR / filename + path = (PRIVATE_DIR / filename) if private else (SETTINGS_DIR / filename) write_csv_defaults(path, headers, default, is_spoken_form_first) def decorator(fn: CallbackT) -> CallbackT: @@ -90,8 +92,8 @@ def on_update(f): return decorator -def append_to_csv(filename: str, rows: dict[str, str]): - path = SETTINGS_DIR / filename +def append_to_csv(filename: str, rows: dict[str, str], private: bool = False): + path = (PRIVATE_DIR / filename) if private else (SETTINGS_DIR / filename) assert filename.endswith(".csv") with open(str(path)) as file: @@ -105,3 +107,26 @@ def append_to_csv(filename: str, rows: dict[str, str]): writer.writerow([]) for key, value in rows.items(): writer.writerow([key] if key == value else [value, key]) + + +WatchCallbackType = Callable[[IO], None] +WatchDecoratorType = Callable[[WatchCallbackType], WatchCallbackType] + + +def track_file( + filename: str, + default: str = "", + private: bool = False, +) -> WatchDecoratorType: + path = (PRIVATE_DIR / filename) if private else (SETTINGS_DIR / filename) + if not path.is_file(): + path.write_text(default) + + def decorator(fn: WatchCallbackType) -> WatchCallbackType: + @resource.watch(path) + def on_update(f): + fn(f) + + return on_update + + return decorator From 6d15eb3f3ea2afd546d2c1dd340b3a3ff24cf55e Mon Sep 17 00:00:00 2001 From: Adrien Fallou Date: Sun, 9 Feb 2025 02:45:11 +0100 Subject: [PATCH 2/2] Add snippet for Python context manager (#1743) ## Why? Context managers are fairly frequently used in Python; they're also something of a Python-specfic construct. For example: ``` with open(filepath) as f: file_lines = f.readlines() ``` ## What? Add context manager statement, used with `snip with` and `with wrap`. `snip context` is a possible alternative name. --- core/snippets/snippets/withStatement.snippet | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 core/snippets/snippets/withStatement.snippet diff --git a/core/snippets/snippets/withStatement.snippet b/core/snippets/snippets/withStatement.snippet new file mode 100644 index 0000000000..27aa50ec3b --- /dev/null +++ b/core/snippets/snippets/withStatement.snippet @@ -0,0 +1,13 @@ +name: withStatement +phrase: with +insertionScope: statement + +$0.wrapperPhrase: with +$0.wrapperScope: statement +--- + +language: python +- +with $1: + $0 +---