dotfiles/talon/user/community/migration_helpers/migration_helpers.py

271 lines
8.5 KiB
Python

import csv
import os
import re
from dataclasses import dataclass
from pathlib import Path
from typing import Union
from talon import Module, actions, app
mod = Module()
@dataclass
class CSVData:
"""Class to track CSV-related data necessary for conversion to .talon-list"""
# name of the list
name: str
# Path to the CSV file
path: str
# path to the generated .talon-list
newpath: Union[str, callable] = None
# Indicates whether the first line of the CSV file is a header
# that should be ignored
is_first_line_header: bool = True
# Indicates whether the spoken form or value is first in the CSV file
is_spoken_form_first: bool = False
# An optional callable for generating a custom header for
# generated .talon-list
custom_header: callable = None
# An optional callable for custom processing of the value for
# generated .talon-list
custom_value_converter: callable = None
# Note: homophones, emacs_commands, file_extensions, words_to_replace, abbreviations, and app name overrides
# are intentionally omitted, as their use cases are not compatible with .talon-list conversions
supported_csv_files = [
CSVData(
"user.git_argument",
os.path.join("apps", "git", "git_arguments.csv"),
os.path.join("apps", "git", "git_argument.talon-list"),
),
CSVData(
"user.git_command",
os.path.join("apps", "git", "git_commands.csv"),
os.path.join("apps", "git", "git_command.talon-list"),
),
CSVData(
"user.vocabulary",
os.path.join("settings", "additional_words.csv"),
os.path.join("core", "vocabulary", "vocabulary.talon-list"),
),
CSVData(
"user.letter",
os.path.join("settings", "alphabet.csv"),
os.path.join("core", "keys", "letter.talon-list"),
),
CSVData(
"user.system_paths",
os.path.join("settings", "system_paths.csv"),
lambda: os.path.join(
"core", f"system_paths-{actions.user.talon_get_hostname()}.talon-list"
),
custom_header=(lambda: f"hostname: {actions.user.talon_get_hostname()}"),
),
CSVData(
"user.search_engine",
os.path.join("settings", "search_engines.csv"),
os.path.join("core", "websites_and_search_engines", "search_engine.talon-list"),
),
CSVData(
"user.unix_utility",
os.path.join("settings", "unix_utilities.csv"),
os.path.join("tags", "terminal", "unix_utility.talon-list"),
),
CSVData(
"user.website",
os.path.join("settings", "websites.csv"),
os.path.join("core", "websites_and_search_engines", "website.talon-list"),
),
CSVData(
"user.emoji",
os.path.join("tags", "emoji", "emoji.csv"),
os.path.join("tags", "emoji", "emoji.talon-list"),
is_first_line_header=False,
is_spoken_form_first=True,
),
CSVData(
"user.emoticon",
os.path.join("tags", "emoji", "emoticon.csv"),
os.path.join("tags", "emoji", "emoticon.talon-list"),
is_first_line_header=False,
is_spoken_form_first=True,
),
CSVData(
"user.kaomoji",
os.path.join("tags", "emoji", "kaomoji.csv"),
os.path.join("tags", "emoji", "kaomoji.talon-list"),
is_first_line_header=False,
is_spoken_form_first=True,
),
]
def convert_csv_to_talonlist(input_csv: csv.reader, config: CSVData):
"""
Convert a 1 or 2 column CSV into a talon list.
Empty lines, lines containing only whitespace or starting with a # are skipped.
Args:
- input_csv: A csv.reader instance
- config: A CSVData instance
Returns:
- str: The contents of a talon list file
Raises:
- ValueError: If any line in the input CSV contains more than 2 columns.
"""
rows = list(input_csv)
is_spoken_form_first = config.is_spoken_form_first
is_first_line_header = config.is_first_line_header
start_index = 1 if is_first_line_header else 0
output = []
output.append(f"list: {config.name}")
if config.custom_header and callable(config.custom_header):
output.append(config.custom_header())
output.append("-")
for row in rows[start_index:]:
# Remove trailing whitespace for each cell
row = [col.rstrip() for col in row]
cols = len(row)
# Check columns
if cols > 2:
raise ValueError("Expected only 1 or 2 columns, got {cols}:", row)
# Exclude empty or comment rows
if cols == 0 or (cols == 1 and row[0] == "") or row[0].startswith("#"):
continue
if cols == 2:
if is_spoken_form_first:
spoken_form, value = row
else:
value, spoken_form = row
if config.custom_value_converter:
value = config.custom_value_converter(value)
else:
spoken_form = value = row[0]
if spoken_form != value:
if not str.isprintable(value) or "'" in value or '"' in value:
value = repr(value)
output.append(f"{spoken_form}: {value}")
else:
output.append(f"{spoken_form}")
# Terminate file in newline
output.append("")
return "\n".join(output)
def convert_files(csv_files_list):
known_csv_files = {str(item.path): item for item in csv_files_list}
conversion_count = 0
base_path = Path(__file__).resolve().parent.parent
for csv_path in base_path.rglob("*.csv"):
csv_relative_path = csv_path.relative_to(base_path)
migrated_csv_path = csv_path.with_suffix(".csv-converted-to-talon-list")
config = known_csv_files.get(str(csv_relative_path))
if not config:
continue
if callable(config.newpath):
talonlist_relative_path = config.newpath()
else:
talonlist_relative_path = config.newpath
talonlist_path = base_path / talonlist_relative_path
if talonlist_path.is_file() and not csv_path.is_file():
print(f"Skipping existing Talon list file {talonlist_relative_path}")
continue
if migrated_csv_path.is_file():
print(f"Skipping existing renamed CSV {migrated_csv_path}")
continue
print(
f"Converting CSV {csv_relative_path} to Talon list {talonlist_relative_path}"
)
conversion_count += 1
with open(csv_path, newline="") as csv_file:
csv_reader = csv.reader(csv_file, skipinitialspace=True)
talonlist_content = convert_csv_to_talonlist(csv_reader, config)
print(
f"Renaming converted CSV to {migrated_csv_path.name}. This file may be deleted if no longer needed; it's preserved in case there's an issue with conversion."
)
if talonlist_path.is_file():
backup_path = talonlist_path.with_suffix(".bak")
print(
f"Migration target {talonlist_relative_path} already exists; backing up to {backup_path}"
)
talonlist_path.rename(backup_path)
with open(talonlist_path, "w") as talonlist_file:
talonlist_file.write(talonlist_content)
csv_path.rename(migrated_csv_path)
return conversion_count
@mod.action_class
class Actions:
def migrate_known_csv_files():
"""Migrate known CSV files to .talon-list"""
conversion_count = convert_files(supported_csv_files)
if conversion_count > 0:
notification_text = f"migration_helpers.py converted {conversion_count} CSVs. See Talon log for more details.\n"
print(notification_text)
actions.app.notify(notification_text)
def migrate_custom_csv(
path: str,
new_path: str,
list_name: str,
is_first_line_header: bool,
spoken_form_first: bool,
):
"""Migrate a custom CSV file"""
csv_file = CSVData(
list_name,
path,
new_path,
is_first_line_header,
spoken_form_first,
None,
None,
)
convert_files([csv_file])
def on_ready():
try:
actions.user.migrate_known_csv_files()
except KeyError:
# Due to a core Talon bug, the above action may not be available when a ready callback is invoked.
# (see https://github.com/talonhub/community/pull/1268#issuecomment-2325721706)
notification = (
"Unable to migrate CSVs to Talon lists.",
"Please quit and restart Talon.",
)
app.notify(*notification)
print(*notification)
app.register("ready", on_ready)