From 46de1ed0ad62c95f067553bf79a775dd016a5980 Mon Sep 17 00:00:00 2001 From: Michael Hoffman Date: Fri, 15 Nov 2024 17:31:27 -0500 Subject: [PATCH] Add source and docs --- Makefile | 13 ++++++ README.md | 39 +++++++++++++++++ bin/gcal-tab2xlsx | 95 ++++++++++++++++++++++++++++++++++++++++++ bin/gcal-xlsx2tab | 94 +++++++++++++++++++++++++++++++++++++++++ bin/gcaledit | 90 +++++++++++++++++++++++++++++++++++++++ bin/gcaledit-deadlines | 23 ++++++++++ bin/gcaledit-meals | 19 +++++++++ bin/gcaledit-zoom | 22 ++++++++++ setup.cfg | 6 +++ 9 files changed, 401 insertions(+) create mode 100644 Makefile create mode 100644 README.md create mode 100755 bin/gcal-tab2xlsx create mode 100755 bin/gcal-xlsx2tab create mode 100755 bin/gcaledit create mode 100755 bin/gcaledit-deadlines create mode 100755 bin/gcaledit-meals create mode 100755 bin/gcaledit-zoom create mode 100644 setup.cfg diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..82df50a --- /dev/null +++ b/Makefile @@ -0,0 +1,13 @@ +## Copyright 2024 Michael M. Hoffman + +bindir = $(HOME)/.local/bin + +scripts = bin/gcaledit bin/gcaledit-deadlines bin/gcaledit-meals bin/gcaledit-zoom bin/gcal-tab2xlsx bin/gcal-xlsx2tab + +install: + install --target-directory=$(bindir) $(scripts) + +install-prereqs: + pip install gcalcli pandas XlsxWriter + +.PHONY: install install-prereqs diff --git a/README.md b/README.md new file mode 100644 index 0000000..e3d204a --- /dev/null +++ b/README.md @@ -0,0 +1,39 @@ +# gcaledit + +Bash and Python scripts to demonstrate the gcalcli `agendaupdate` command. + +## Installation + +`make install-prereqs` to install the Python prerequisites needed. + +`make install` to install the scripts. Two of them are Python scripts but it's easier to deal with it this way than to make a proper package that could be installed with pip. + +## Scripts + +### gcaledit + +`gcaledit` is the main script. It uses `gcalcli agenda --tsv` to get event info from your Google Calendar and formats it as a table with one row per event and a column for each event detail. Then, it converts the table to XLSX spreadsheet format and uses an `open` command to open the file in Excel or your spreadsheet application of choice. You will need to supply the `open` command. + +Then, `gcaledit` uses `inotifywait` to watch the file for saves. Any save will cause the changes to be written back to your Google Calendar through `gcalcli agendaupdate --tsv`. + +### gcaledit workflows + +`gcaledit-deadlines`: edit events on a particular calendar where you store all of your deadlines + +`gcaledit-meals`: meal planning: edit events on your family calendar that match `Breakfast|Lunch|Dinner|Brunch|Cook` + +`gcaledit-zoom`: edit events that match `.zoom.us`. I use this to bulk eliminate extra cruft that goes into these events by default + +### helper scripts + +`gcal-tab2xlsx`: convert output of `gcalcli agenda --tsv` to XLSX + +`gcal-xlsx2csv`: convert XLSX created by `gcal-tab2xlsx` to be a TSV suitable as input to `gcalcli agendaupdate` + +## Support + +There is no support guaranteed! This may not work at all on any system other than my own. This is mainly here to provide examples. Pull requests might be considered. + +## License + +MIT License diff --git a/bin/gcal-tab2xlsx b/bin/gcal-tab2xlsx new file mode 100755 index 0000000..5d07752 --- /dev/null +++ b/bin/gcal-tab2xlsx @@ -0,0 +1,95 @@ +#!/usr/bin/env python +"""gcaltab2xlsx: save gcal TSV as XLSX.""" + +__version__ = "0.1" + +# Copyright 2021, 2023, 2024 Michael M. Hoffman + +from argparse import Namespace +from os import EX_OK +from pathlib import Path +import sys +from typing import TextIO + +from pandas import read_csv, to_timedelta +from xlsxwriter import Workbook + + +WORKSHEET_NAME = "Schedule" +INFILE_SEP = "\t" + +DTYPE = {"location": "string"} + +COL_WIDTHS = {"id": 44, + "start_date": 10, + "end_date": 10, + "title": 40} + +FORMAT_HEADER = {"bold": True} +FORMAT_LENGTH = {"num_format": "h:mm"} + + +def gcal_save_xlsx(infile: TextIO, outfilepath: Path) -> int: + """Save gcal from standard input as XLSX.""" + dataframe = read_csv(infile, sep=INFILE_SEP, header=0, dtype=DTYPE, + na_filter=False) + columns = dataframe.columns + + if "length" in columns: + dataframe.length = to_timedelta(dataframe.length) + + with Workbook(outfilepath) as workbook: + format_header = workbook.add_format(FORMAT_HEADER) + format_length = workbook.add_format(FORMAT_LENGTH) + + worksheet = workbook.add_worksheet(WORKSHEET_NAME) + + worksheet.write_row(0, 0, columns, format_header) + + for col_index, (col_name, column) \ + in enumerate(dataframe.items()): + worksheet.write_column(1, col_index, column) + + width = COL_WIDTHS.get(col_name) + if col_name == "length": + col_format = format_length + else: + col_format = None + + worksheet.set_column(col_index, col_index, width, col_format) + + return EX_OK + + +def parse_args(args: list[str]) -> Namespace: + """Parse arguments.""" + from argparse import (ArgumentDefaultsHelpFormatter, ArgumentParser, + FileType) + + description = __doc__.splitlines()[0].partition(": ")[2] + parser = ArgumentParser(description=description, + formatter_class=ArgumentDefaultsHelpFormatter) + + version = f"%(prog)s {__version__}" + parser.add_argument("--version", action="version", version=version) + + parser.add_argument("infile", nargs="?", metavar="TSV", type=FileType(), + default="-", + help="path of input TSV table ('-' means stdin)") + + parser.add_argument("outfile", metavar="XLSX", type=Path, + help="path of output XLSX workbook") + + return parser.parse_args(args) + + +def main(argv: list[str] = sys.argv[1:]) -> int: + """Provide command-line interface.""" + args = parse_args(argv) + + with args.infile as infile: + return gcal_save_xlsx(infile, args.outfile) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/bin/gcal-xlsx2tab b/bin/gcal-xlsx2tab new file mode 100755 index 0000000..ab304d3 --- /dev/null +++ b/bin/gcal-xlsx2tab @@ -0,0 +1,94 @@ +#!/usr/bin/env python +"""xlsx2gcaltab: Convert a workbook to tab-separated values for gcalcli.""" + +__version__ = "0.1" + +# Copyright 2021, 2023, 2024 Michael M. Hoffman + +from argparse import Namespace +from os import EX_OK +from pathlib import Path +import sys + +from pandas import DataFrame, NaT, read_excel, to_datetime + + +CSV_SEP = "\t" + +DATE_FORMAT_DATE = "ISO8601" + +# https://docs.python.org/3/library/datetime.html#strftime-and-strptime-behavior +# hour (24-hour clock, zero padded):minute (zero-padded) +DATE_FORMAT_TIME = "%H:%M" + +# XXX: instead of hardcoding the two date columns, infer this from columns with +# names ending in _date +DATE_FORMAT: dict[str, str] = {"start_date": DATE_FORMAT_DATE, + "end_date": DATE_FORMAT_DATE} +PARSE_DATES: list[str] = list(map(str, DATE_FORMAT)) + +EXCEL_DTYPE = {"start_date": str, "start_time": str, + "end_date": str, "end_time": str} + + +def update_datetime(dataframe: DataFrame, colname: str) -> None: + colname_type = colname.rpartition("_")[2] + + match colname_type: + case "date" | "time": + pass + case _: + return + + col = to_datetime(dataframe[colname], format="mixed") + dataframe[colname] = getattr(col.dt, colname_type) + + +def xlsx2tab(filepath: Path) -> int: + """Convert a workbook to tab-separated values.""" + dataframe = read_excel(filepath, dtype=EXCEL_DTYPE) + + for colname in dataframe.columns: + update_datetime(dataframe, colname) + + try: + # XXX: add this back in when `gcalcli agendaupdate` can handle it + del dataframe["length"] + except KeyError: + # gcalcli 4.5.0 doesn't support `--details length` + # https://github.com/insanum/gcalcli/issues/791 + pass + + dataframe.replace(NaT, "") + + dataframe.to_csv(sys.stdout, sep=CSV_SEP, index=False) + + return EX_OK + + +def parse_args(args: list[str]) -> Namespace: + """Parse arguments.""" + from argparse import ArgumentDefaultsHelpFormatter, ArgumentParser + + description = __doc__.splitlines()[0].partition(": ")[2] + parser = ArgumentParser(description=description, + formatter_class=ArgumentDefaultsHelpFormatter) + + version = f"%(prog)s {__version__}" + parser.add_argument("--version", action="version", version=version) + + parser.add_argument("infile", metavar="XLSX", type=Path, + help="path of input XLSX workbook") + + return parser.parse_args(args) + + +def main(argv: list[str] = sys.argv[1:]) -> int: + """Provide command-line interface.""" + args = parse_args(argv) + + return xlsx2tab(args.infile) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/bin/gcaledit b/bin/gcaledit new file mode 100755 index 0000000..6a0739e --- /dev/null +++ b/bin/gcaledit @@ -0,0 +1,90 @@ +#!/usr/bin/env bash + +## gcaledit: edit Google Calendar in spreadsheet + +## Copyright 2020, 2021, 2023, 2024 Michael M. Hoffman + +set -o nounset -o pipefail -o errexit + +if [[ $# -lt 4 || "${1:-}" == "--help" || "${1:-}" == "-h" ]]; then + printf >&2 \ + "usage: %s --calendar EDITCAL [--calendar OTHERCAL]... START END [GREPARGS...]" \ + "$0" + exit 2 +fi + +calendar_args=() +while [[ $# -gt 0 ]]; do + case "$1" in + --calendar) + if [[ -n "$2" ]]; then + calendar_args+=("--calendar" "$2") + shift 2 + else + echo >&2 "error: --calendar requires argument" + exit 1 + fi + ;; + *) + break + ;; + esac +done + +if [[ $# -lt 2 ]]; then + echo >&2 "error: missing START and END" + exit 1 +fi + +start="$1" +end="$2" + +shift 2 + +grepargs=("$@") + +if [[ ${#grepargs[@]} -eq 0 ]]; then + # default grepargs of nothing + grepargs+=("") +fi + +file="$(mktemp --tmpdir "$(basename "$0").XXXXXXXXXX.xlsx")" + +on_exit () +{ + rm -rf "$file" +} + +trap on_exit EXIT + +# https://unix.stackexchange.com/questions/11856/sort-but-keep-header-line-at-the-top +body() { + IFS= read -r header + printf '%s\n' "$header" + "$@" +} + +# XXX: add more details, especially notes +# XXX: grep with exit status 1 should not break the pipe +# add a || true in there + +# XXX: this won't actually work for >1 calendars until agendaupdate can +# write or ignore the calendar detail + +gcalcli "${calendar_args[@]}" agenda \ + --details id --details length --details action --details location \ + --details description --tsv "$start" "$end" \ + | body grep --perl-regexp "${grepargs[@]}" \ + | gcal-tab2xlsx "$file" + +open "$file" + +echo "Press enter to finish after next save." >&2 +while inotifywait -e close_write -e modify "$file"; do + # only supply the first calendar argument + gcal-xlsx2tab "$file" | gcalcli "${calendar_args[@]:0:2}" agendaupdate + if read -t 0; then + read # eat up whatever was provided + break + fi +done diff --git a/bin/gcaledit-deadlines b/bin/gcaledit-deadlines new file mode 100755 index 0000000..fc33c50 --- /dev/null +++ b/bin/gcaledit-deadlines @@ -0,0 +1,23 @@ +#!/usr/bin/env bash + +## deadlines: put deadlines into spreadsheet + +## Copyright 2020-2021, 2023-2024 Michael M. Hoffman + +set -o nounset -o pipefail -o errexit + +if [[ "${1:-}" == "--help" || "${1:-}" == "-h" ]]; then + echo usage: "$0" [START] [END] [GREPARGS...] + exit 2 +fi + +calendar="${GCALEDIT_DEADLINES_CALENDAR:-Deadlines}" + +start="${1:-3 days ago}" # suggest 3 days ago +end="${2:-1 year}" # suggest 1 year + +shift 2 + +# grepargs: suggest . + +exec gcaledit --calendar "$calendar" "$start 12am" "$end 11:59pm" "$@" diff --git a/bin/gcaledit-meals b/bin/gcaledit-meals new file mode 100755 index 0000000..13ad476 --- /dev/null +++ b/bin/gcaledit-meals @@ -0,0 +1,19 @@ +#!/usr/bin/env bash + +## meals: put meals into spreadsheet + +## Copyright 2020-2021, 2023-2024 Michael M. Hoffman + +set -o nounset -o pipefail -o errexit + +if [[ $# -gt 1 || "${1:-}" == "--help" || "${1:-}" == "-h" ]]; then + echo usage: "$0" [NUMDAYS] + exit 2 +fi + +num_days="${1:-9}" +calendar="${GCALEDIT_MEALS_CALENDAR:-Family}" + +exec gcaledit --calendar "$calendar" \ + "today 12am" "$num_days days 11:59pm" \ + -i -e $'(\t|; )(Breakfast|Lunch|Dinner|Brunch|Cook)' diff --git a/bin/gcaledit-zoom b/bin/gcaledit-zoom new file mode 100755 index 0000000..8716c9b --- /dev/null +++ b/bin/gcaledit-zoom @@ -0,0 +1,22 @@ +#!/usr/bin/env bash + +## gcaledit-zoom.sh: edit upcoming zoom meetings + +## Copyright 2023, 2024 Michael M. Hoffman + +set -o nounset -o pipefail -o errexit + +if [[ $# -gt 3 || "${1:-}" == "--help" || "${1:-}" == "-h" ]]; then + printf "usage: %s [START] [END] [CALENDAR]" "$0" + exit 2 +fi + +start="${1:-today}" +end="${2:-2 months}" +calendar="${3:-${GCALEDIT_ZOOM_CALENDAR:-defaultcalendarname}}" + +# XXX: can't patch events I don't own, shouldn't I be able to delete these at least? +# +# googleapiclient.errors.HttpError: + +exec gcaledit --calendar "$calendar" "$start" "$end" '\.zoom\.us' diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..a4580e5 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,6 @@ +[flake8] +inline-quotes = double +import-order-style = google + +[mypy] +ignore_missing_imports = True