From 6d8520a0167bad3db2a79c7de68f22c302630cce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=84min=20Baumeler?= Date: Fri, 13 Jun 2025 12:33:26 +0200 Subject: [PATCH] non-recursive, clean --- src/awk/{lines.awk => approx.awk} | 0 src/main.sh | 1335 +++++++++++++++++------------ 2 files changed, 777 insertions(+), 558 deletions(-) rename src/awk/{lines.awk => approx.awk} (100%) diff --git a/src/awk/lines.awk b/src/awk/approx.awk similarity index 100% rename from src/awk/lines.awk rename to src/awk/approx.awk diff --git a/src/main.sh b/src/main.sh index be29b8f..63e522e 100755 --- a/src/main.sh +++ b/src/main.sh @@ -4,342 +4,16 @@ set -eu # TODO: Make sensitive to failures. I don't want to miss appointments! # TODO Ensure safe use of delimiters -err() { - echo "❌ $1" >/dev/tty -} - -if [ -z "${FZF_VCAL_USE_EXPORTED:-}" ]; then - # Read configuration - CONFIGFILE="$HOME/.config/fzf-vcal/config" - if [ ! -f "$CONFIGFILE" ]; then - err "Configuration '$CONFIGFILE' not found." - exit 1 - fi - # shellcheck source=/dev/null - . "$CONFIGFILE" - if [ -z "${ROOT:-}" ] || [ -z "${SYNC_CMD:-}" ] || [ -z "${COLLECTION_LABELS:-}" ]; then - err "Configuration is incomplete." - exit 1 - fi - export ROOT - export SYNC_CMD - export COLLECTION_LABELS - - DAY_START=${DAY_START:-8} - DAY_END=${DAY_END:-18} - export DAY_START - export DAY_END - - # Tools - if command -v "fzf" >/dev/null; then - FZF="fzf --black" - else - err "Did not find the command-line fuzzy finder fzf." - exit 1 - fi - export FZF - - if command -v "uuidgen" >/dev/null; then - UUIDGEN="uuidgen" - else - err "Did not find the uuidgen command." - exit 1 - fi - export UUIDGEN - - if command -v "bat" >/dev/null; then - CAT="bat" - elif command -v "batcat" >/dev/null; then - CAT="batcat" - fi - CAT=${CAT:+$CAT --color=always --style=numbers --language=md} - CAT=${CAT:-cat} - export CAT - - ### AWK SCRIPTS - AWK_LINES=$( - cat <<'EOF' -@@include src/awk/lines.awk -EOF - ) - export AWK_LINES - - AWK_MERGE=$( - cat <<'EOF' -@@include src/awk/merge.awk -EOF - ) - export AWK_MERGE - - AWK_PARSE=$( - cat <<'EOF' -@@include src/awk/parse.awk -EOF - ) - export AWK_PARSE - - AWK_WEEKVIEW=$( - cat <<'EOF' -@@include src/awk/weekview.awk -EOF - ) - export AWK_WEEKVIEW - - AWK_DAYVIEW=$( - cat <<'EOF' -@@include src/awk/dayview.awk -EOF - ) - export AWK_DAYVIEW - - AWK_GET=$( - cat <<'EOF' -@@include src/awk/get.awk -EOF - ) - export AWK_GET - - AWK_UPDATE=$( - cat <<'EOF' -@@include src/awk/update.awk -EOF - ) - export AWK_UPDATE - - AWK_NEW=$( - cat <<'EOF' -@@include src/awk/new.awk -EOF - ) - export AWK_NEW - - AWK_CAL=$( - cat <<'EOF' -@@include src/awk/cal.awk -EOF - ) - export AWK_CAL - ### END OF AWK SCRIPTS - - ## Colors - export GREEN="\033[1;32m" - export RED="\033[1;31m" - export WHITE="\033[1;97m" - export CYAN="\033[1;36m" - export ITALIC="\033[3m" - export FAINT="\033[2m" - export OFF="\033[m" - - export FZF_VJOUR_USE_EXPORTED="yes" -fi - -__load_approx_data() { - find "$ROOT" -type f -name '*.ics' -print0 | - xargs -0 -P0 \ - awk \ - -v collection_labels="$COLLECTION_LABELS" \ - "$AWK_LINES" -} - -__load_weeks() { - dates=$(awk -F'|' '{ print $2; print $3 }' "$APPROX_DATA_FILE") - file_dates=$(mktemp) - echo "$dates" | date --file="/dev/stdin" +"%G|%V" >"$file_dates" - awk "$AWK_MERGE" "$file_dates" "$APPROX_DATA_FILE" - rm "$file_dates" -} - -__show_day() { - weeknr=$(date -d "$DISPLAY_DATE" +"%G.%V") - files=$(grep "^$weeknr\ " "$WEEKLY_DATA_FILE" | cut -d " " -f 2-) - # Find relevant files in list of week files - sef=$({ - set -- $files - for file in "$@"; do - file="$ROOT/$file" - awk \ - -v collection_labels="$COLLECTION_LABELS" \ - "$AWK_PARSE" "$file" - done - }) - today=$(date -d "$DISPLAY_DATE" +"%D") - if [ -n "$sef" ]; then - sef=$(echo "$sef" | while IFS= read -r line; do - set -- $line - starttime="$1" - shift - endtime="$1" - shift - fpath="$(echo "$1" | sed 's/|/ /g')" # we will use | as delimiter (need to convert back!) - shift - description="$(echo "$*" | sed 's/|/:/g')" # we will use | as delimiter - # - daystart=$(date -d "$today 00:00:00" +"%s") - dayend=$(date -d "$today 23:59:59" +"%s") - line="" - if [ "$starttime" -gt "$daystart" ] && [ "$starttime" -lt "$dayend" ]; then - s=$(date -d "@$starttime" +"%R") - elif [ "$starttime" -le "$daystart" ] && [ "$endtime" -gt "$daystart" ]; then - s="00:00" - else - continue - fi - if [ "$endtime" -gt "$daystart" ] && [ "$endtime" -lt "$dayend" ]; then - e=$(date -d "@$endtime" +"%R") - elif [ "$endtime" -ge "$dayend" ] && [ "$starttime" -lt "$dayend" ]; then - e="00:00" - else - continue - fi - echo "$s|$e|$starttime|$endtime|$fpath|$description" - done) - fi - echo "$sef" | sort -n | awk -v today="$today" -v daystart="$DAY_START" -v dayend="$DAY_END" "$AWK_DAYVIEW" -} - -__list() { - weeknr=$(date -d "$DISPLAY_DATE" +"%G.%V") - files=$(grep "^$weeknr\ " "$WEEKLY_DATA_FILE" | cut -d " " -f 2-) - dayofweek=$(date -d "$DISPLAY_DATE" +"%u") - delta=$((1 - dayofweek)) - startofweek=$(date -d "$DISPLAY_DATE -$delta days" +"%D") - # loop over files - sef=$({ - set -- $files - for file in "$@"; do - file="$ROOT/$file" - awk \ - -v collection_labels="$COLLECTION_LABELS" \ - "$AWK_PARSE" "$file" - done - }) - if [ -n "$sef" ]; then - sef=$(echo "$sef" | while IFS= read -r line; do - set -- $line - starttime="$1" - shift - endtime="$1" - shift - #fpath="$1" - shift - description="$*" - for i in $(seq 0 7); do - daystart=$(date -d "$startofweek +$i days 00:00:00" +"%s") - dayend=$(date -d "$startofweek +$i days 23:59:59" +"%s") - if [ "$starttime" -gt "$daystart" ] && [ "$starttime" -lt "$dayend" ]; then - s=$(date -d "@$starttime" +"%H:%M") - s="$s -" - elif [ "$starttime" -le "$daystart" ] && [ "$endtime" -gt "$daystart" ]; then - s="00:00 -" - else - continue - fi - if [ "$endtime" -gt "$daystart" ] && [ "$endtime" -lt "$dayend" ]; then - e=$(date -d "@$endtime" +"%H:%M") - e="- $e" - elif [ "$endtime" -ge "$dayend" ] && [ "$starttime" -lt "$dayend" ]; then - e="- 00:00" - else - continue - fi - echo "$i $s$e >$description" - done - done) - fi - sef=$({ - echo "$sef" - seq 0 7 - } | sort -n) - echo "$sef" | awk -v startofweek="$startofweek" "$AWK_WEEKVIEW" - #seq -f "$startofweek +%g days" 0 6 | - # LC_ALL=c xargs -I {} date -d "{}" +"%a %e %b %Y" -} - -__canonical_datetime_hm() { - s="$1" - t=$(date -d "@$s" +"%R") - dfmt="%F" - if [ "$t" != "00:00" ]; then - dfmt="$dfmt %R" - fi - date -d "@$s" +"$dfmt" -} - -__canonical_datetime() { - s="$1" - shift - t=$(date -d "@$s" +"%R") - dfmt="$*%e %b %Y" - if [ "$t" != "00:00" ]; then - dfmt="$dfmt %R %Z" - fi - date -d "@$s" +"$dfmt" -} - -__edit() { - start=$(__canonical_datetime_hm "$1") - end=$(__canonical_datetime_hm "$2") - fpath="$3" - location=$(awk -v field="LOCATION" "$AWK_GET" "$fpath") - summary=$(awk -v field="SUMMARY" "$AWK_GET" "$fpath") - description=$(awk -v field="DESCRIPTION" "$AWK_GET" "$fpath") - filetmp=$(mktemp --suffix='.md') - ( - echo "::: |> $start" - echo "::: <| $end" - ) >"$filetmp" - if [ -n "$location" ]; then - echo "@ $location" >>"$filetmp" - fi - ( - echo "# $summary" - echo "" - echo "$description" - ) >>"$filetmp" - checksum=$(cksum "$filetmp") - $EDITOR "$filetmp" >/dev/tty - - # Update only if changes are detected - if [ "$checksum" != "$(cksum "$filetmp")" ]; then - filenew="$filetmp.ics" - if awk "$AWK_UPDATE" "$filetmp" "$fpath" >"$filenew"; then - mv "$filenew" "$fpath" - __refresh_data - else - rm -f "$filenew" - err "Failed to edit entry. Press to continue." - read -r tmp - fi - fi - rm "$filetmp" -} - -__refresh_data() { - if [ -n "${APPROX_DATA_FILE:-}" ]; then - rm -f "$APPROX_DATA_FILE" - fi - if [ -n "${WEEKLY_DATA_FILE:-}" ]; then - rm -f "$WEEKLY_DATA_FILE" - fi - APPROX_DATA_FILE=$(mktemp) - __load_approx_data >"$APPROX_DATA_FILE" - export APPROX_DATA_FILE - WEEKLY_DATA_FILE=$(mktemp) - __load_weeks >"$WEEKLY_DATA_FILE" - export WEEKLY_DATA_FILE -} - -## Start if [ "${1:-}" = "--help" ]; then echo "Usage: $0 [OPTION]" echo "" echo "You may specify at most one option." echo " --help Show this help and exit" - echo " --new Create new entry" echo " --today Show today's appointments" echo " --goto Interactively enter date to jump to" - echo " --day Show appointments of specified day" - echo " --date Show week of specified date" + echo " --new [date/date-time] Create new entry" + echo " --day [date] Show appointments of specified day" + echo " --week [date] Show week of specified date" echo "" echo "You may also start this program with setting locale and timezone" echo "information. For instance, to see and modify all of your calendar" @@ -350,143 +24,31 @@ if [ "${1:-}" = "--help" ]; then exit fi -if [ "${1:-}" = "--today" ]; then - exec $0 --day "today" -fi +### +### Helper functions +### err +### -if [ "${1:-}" = "--goto" ]; then - DISPLAY_DATE="" - while [ -z "$DISPLAY_DATE" ]; do - printf "Enter date you want to jump to, e.g., today + 1 month or 2024-1-14: " >/dev/tty - read -r tmp - if date -d "$tmp"; then - DISPLAY_DATE="$tmp" - fi - done -fi +# err() +# This is a helper function to print errors. +# +# @input $1: Error message +err() { + echo "❌ $1" >/dev/tty +} -if [ "${1:-}" = "--new" ]; then - collection=$(echo "$COLLECTION_LABELS" | tr ';' '\n' | awk '/./ {print}' | $FZF --margin="30%" --no-info --delimiter='=' --with-nth=2 --accept-nth=1) - fpath="" - while [ -f "$fpath" ] || [ -z "$fpath" ]; do - uuid=$($UUIDGEN) - fpath="$ROOT/$collection/$uuid.ics" - done - startsec=$(date -d "${2:-today 8:00}" +"%s") - endsec=$((startsec + 3600)) - start=$(__canonical_datetime_hm "$startsec") - end=$(__canonical_datetime_hm "$endsec") - filetmp=$(mktemp --suffix='.md') - ( - echo "::: |> $start" - echo "::: <| $end" - echo "@ " - echo "# " - echo "" - ) >"$filetmp" - checksum=$(cksum "$filetmp") - $EDITOR "$filetmp" >/dev/tty - - # Update only if changes are detected - if [ "$checksum" != "$(cksum "$filetmp")" ]; then - filenew="$filetmp.ics" - if awk -v uid="$uuid" "$AWK_NEW" "$filetmp" >"$filenew"; then - mv "$filenew" "$fpath" - __refresh_data - start=$(awk -v field="DTSTART" "$AWK_GET" "$fpath" | grep -o '[0-9]\{8\}') - else - rm -f "$filenew" - err "Failed to create new entry. Press to continue." - read -r tmp - fi - fi - rm "$filetmp" - set -- "--day" "$start" -fi - -if [ -z "${APPROX_DATA_FILE:-}" ]; then - __refresh_data -fi - -if [ "${1:-}" = "--day" ]; then - DISPLAY_DATE="${2:-today}" - export DISPLAY_DATE - selection=$( - __show_day | - $FZF \ - --reverse \ - --ansi \ - --no-sort \ - --no-input \ - --margin='20%,5%' \ - --border='double' \ - --color=label:bold:green \ - --border-label-pos=3 \ - --cycle \ - --delimiter='|' \ - --with-nth='{6}' \ - --accept-nth='2,3,4,5' \ - --preview="$0 --preview {}" \ - --expect="ctrl-n,esc,backspace,q" \ - --bind='load:transform(echo change-border-label:🗓️ $(date -d {1} +"%A %e %B %Y"))+transform(echo {} | grep \|\| || echo show-preview)' \ - --bind='start:hide-preview' \ - --bind="ctrl-r:reload:$0 --show-day {1}" \ - --bind='ctrl-j:down+hide-preview+transform:echo {} | grep \|\| || echo show-preview' \ - --bind='ctrl-k:up+hide-preview+transform:echo {} | grep \|\| || echo show-preview' \ - --bind="ctrl-l:hide-preview+reload:$0 --show-day {1} '+1 day'" \ - --bind="ctrl-h:hide-preview+reload:$0 --show-day {1} '-1 day'" \ - --bind="ctrl-s:execute($SYNC_CMD ; printf 'Press to continue.'; read -r tmp)" \ - --bind="ctrl-alt-d:become($0 --delete {})" \ - --bind="j:preview-down" \ - --bind="k:preview-down" \ - --bind="w:toggle-preview-wrap" - #--bind="ctrl-u:unbind(load)+reload:$0 --list {2} '-1 week'" \ - ) - key=$(echo "$selection" | head -1) - line=$(echo "$selection" | tail -1) - if [ "$line" = "$key" ]; then - line="" - fi - hour=$(echo "$line" | cut -d '|' -f 1) - start=$(echo "$line" | cut -d '|' -f 2) - end=$(echo "$line" | cut -d '|' -f 3) - fpath=$(echo "$line" | cut -d '|' -f 4 | sed "s/ /|/g") - if [ "$key" = "ctrl-n" ]; then - if echo "$hour" | grep ":"; then - hour="$DAY_START" - fi - exec $0 --new "$DISPLAY_DATE $hour:00" - elif [ -z "$key" ] && [ -n "$fpath" ]; then - fpath="$ROOT/$fpath" - __edit "$start" "$end" "$fpath" - exec $0 --day "$DISPLAY_DATE" - fi -fi - -if [ "${1:-}" = "--date" ]; then - DISPLAY_DATE="$2" -fi - -if [ "${1:-}" = "--preview" ]; then - hour=$(echo "$2" | cut -d '|' -f 2) - start=$(echo "$2" | cut -d '|' -f 3) - end=$(echo "$2" | cut -d '|' -f 4) - fpath=$(echo "$2" | cut -d '|' -f 5 | sed "s/ /|/g") - if [ -n "$hour" ] && [ -n "$fpath" ]; then - fpath="$ROOT/$fpath" - start=$(__canonical_datetime "$start" "%a ") - end=$(__canonical_datetime "$end" "%a ") - location=$(awk -v field="LOCATION" "$AWK_GET" "$fpath") - echo "📅 ${CYAN}$start${OFF} -> ${CYAN}$end${OFF}" - if [ -n "${location:-}" ]; then - echo "📍 ${CYAN}$location${OFF}" - fi - echo "" - awk -v field="DESCRIPTION" "$AWK_GET" "$fpath" | $CAT - fi - exit -fi +### +### preview helper functions +### month_previous +### month_next +### datetime_str +### +# month_previous() +# Print previous month of specified input month as . +# +# @input $1: Month +# @input $2: Year month_previous() { month=$(echo "$1" | sed 's/^0//') year=$(echo "$2" | sed 's/^0//') @@ -499,6 +61,11 @@ month_previous() { echo "$month $year" } +# month_next() +# Print next month of specified input month as . +# +# @input $1: Month +# @input $2: Year month_next() { month=$(echo "$1" | sed 's/^0//') year=$(echo "$2" | sed 's/^0//') @@ -511,6 +78,61 @@ month_next() { echo "$month $year" } +# datetime_str() +# Print date or datetime in a human readable form. +# +# @input $1: Seconds since epoch +# @input $2.. (optoinal): Prepend date format +datetime_str() { + s="$1" + shift + t=$(date -d "@$s" +"%R") + dfmt="$*%e %b %Y" + if [ "$t" != "00:00" ]; then + dfmt="$dfmt %R %Z" + fi + date -d "@$s" +"$dfmt" +} + +### +### Preview command-line options +### --preview-event +### --preview_week +### + +# --preview-event +# Print preview of event and exit. +# +# @input $1: Line from day view containing an event +# @req $ROOT: Path that contains the collections (see configuration) +# @req $AWK_GET: Awk script to extract fields from iCalendar file +# @req $CAT: Program to print +# @req colors +if [ "${1:-}" = "--preview-event" ]; then + hour=$(echo "$2" | cut -d '|' -f 2) + start=$(echo "$2" | cut -d '|' -f 3) + end=$(echo "$2" | cut -d '|' -f 4) + fpath=$(echo "$2" | cut -d '|' -f 5 | sed "s/ /|/g") + if [ -n "$hour" ] && [ -n "$fpath" ]; then + fpath="$ROOT/$fpath" + start=$(datetime_str "$start" "%a ") + end=$(datetime_str "$end" "%a ") + location=$(awk -v field="LOCATION" "$AWK_GET" "$fpath") + echo "📅 ${CYAN}$start${OFF} -> ${CYAN}$end${OFF}" + if [ -n "${location:-}" ]; then + echo "📍 ${CYAN}$location${OFF}" + fi + echo "" + awk -v field="DESCRIPTION" "$AWK_GET" "$fpath" | $CAT + fi + exit +fi + +# --preview-week +# Print preview of week. +# +# @input $2: Line from week view +# @req $AWK_CAL: Awk script to annotate calendar if [ "${1:-}" = "--preview-week" ]; then sign=$(echo "$2" | cut -d '|' -f 1) if [ "$sign" = "+" ]; then @@ -572,117 +194,714 @@ if [ "${1:-}" = "--preview-week" ]; then exit fi -if [ "${1:-}" = "--delete" ]; then - fpath=$(echo "$2" | cut -d '|' -f 5 | sed "s/ /|/g") - if [ -n "$fpath" ]; then - fpath="$ROOT/$fpath" - summary=$(awk -v field="SUMMARY" "$AWK_GET" "$fpath") - while true; do - printf "Do you want to delete the entry with the title \"%s\"? (yes/no): " "$summary" >/dev/tty - read -r yn - case $yn in - "yes") - rm -v "$fpath" - break - ;; - "no") - break - ;; - *) - echo "Please answer \"yes\" or \"no\"." >/dev/tty - ;; - esac +### +### View Functions +### __view_day +### __view_week +### __view_all +### + +# __view_day() +# This function prints the view for the day specified in `$DISPLAY_DATE`. +# +# @req $DISPLAY_DATE: Specification of the day to show +# @req $WEEKLY_DATA_FILE: Filename of weekly data (see `__refresh_data` and `__load_weeks`) +# @req $ROOT: Path that contains the collections (see configuration) +# @req $COLLECTION_LABELS: Mapping between collections and lables (see configuration) +# @req $AWK_PARSE: Parse awk script +# @req $DAY_START: Start time of the day (see configuration) +# @req $DAY_END: Start time of the day (see configuration) +# @req $AWK_DAYVIEW: Day-view awk script +__view_day() { + weeknr=$(date -d "$DISPLAY_DATE" +"%G.%V") + files=$(grep "^$weeknr\ " "$WEEKLY_DATA_FILE" | cut -d " " -f 2-) + # Find relevant files in list of week files + sef=$({ + set -- $files + for file in "$@"; do + file="$ROOT/$file" + awk \ + -v collection_labels="$COLLECTION_LABELS" \ + "$AWK_PARSE" "$file" done + }) + today=$(date -d "$DISPLAY_DATE" +"%D") + if [ -n "$sef" ]; then + sef=$(echo "$sef" | while IFS= read -r line; do + set -- $line + starttime="$1" + shift + endtime="$1" + shift + fpath="$(echo "$1" | sed 's/|/ /g')" # we will use | as delimiter (need to convert back!) + shift + description="$(echo "$*" | sed 's/|/:/g')" # we will use | as delimiter + # + daystart=$(date -d "$today 00:00:00" +"%s") + dayend=$(date -d "$today 23:59:59" +"%s") + line="" + if [ "$starttime" -gt "$daystart" ] && [ "$starttime" -lt "$dayend" ]; then + s=$(date -d "@$starttime" +"%R") + elif [ "$starttime" -le "$daystart" ] && [ "$endtime" -gt "$daystart" ]; then + s="00:00" + else + continue + fi + if [ "$endtime" -gt "$daystart" ] && [ "$endtime" -lt "$dayend" ]; then + e=$(date -d "@$endtime" +"%R") + elif [ "$endtime" -ge "$dayend" ] && [ "$starttime" -lt "$dayend" ]; then + e="00:00" + else + continue + fi + echo "$s|$e|$starttime|$endtime|$fpath|$description" + done) fi - __refresh_data - exec $0 --day "$DISPLAY_DATE" -fi + echo "$sef" | sort -n | awk -v today="$today" -v daystart="$DAY_START" -v dayend="$DAY_END" "$AWK_DAYVIEW" +} -if [ "${1:-}" = "--all" ]; then +# __view_week() +# This function prints the view for the week that contains the day specified in `$DISPLAY_DATE`. +# +# @req $DISPLAY_DATE: Specification of the day to show +# @req $WEEKLY_DATA_FILE: Filename of weekly data (see `__refresh_data` and `__load_weeks`) +# @req $ROOT: Path that contains the collections (see configuration) +# @req $COLLECTION_LABELS: Mapping between collections and lables (see configuration) +# @req $AWK_WEEKVIEW: Week-view awk script +__view_week() { + weeknr=$(date -d "$DISPLAY_DATE" +"%G.%V") + files=$(grep "^$weeknr\ " "$WEEKLY_DATA_FILE" | cut -d " " -f 2-) + dayofweek=$(date -d "$DISPLAY_DATE" +"%u") + delta=$((1 - dayofweek)) + startofweek=$(date -d "$DISPLAY_DATE -$delta days" +"%D") + # loop over files + sef=$({ + set -- $files + for file in "$@"; do + file="$ROOT/$file" + awk \ + -v collection_labels="$COLLECTION_LABELS" \ + "$AWK_PARSE" "$file" + done + }) + if [ -n "$sef" ]; then + sef=$(echo "$sef" | while IFS= read -r line; do + set -- $line + starttime="$1" + shift + endtime="$1" + shift + #fpath="$1" + shift + description="$*" + for i in $(seq 0 7); do + daystart=$(date -d "$startofweek +$i days 00:00:00" +"%s") + dayend=$(date -d "$startofweek +$i days 23:59:59" +"%s") + if [ "$starttime" -gt "$daystart" ] && [ "$starttime" -lt "$dayend" ]; then + s=$(date -d "@$starttime" +"%H:%M") + s="$s -" + elif [ "$starttime" -le "$daystart" ] && [ "$endtime" -gt "$daystart" ]; then + s="00:00 -" + else + continue + fi + if [ "$endtime" -gt "$daystart" ] && [ "$endtime" -lt "$dayend" ]; then + e=$(date -d "@$endtime" +"%H:%M") + e="- $e" + elif [ "$endtime" -ge "$dayend" ] && [ "$starttime" -lt "$dayend" ]; then + e="- 00:00" + else + continue + fi + echo "$i $s$e >$description" + done + done) + fi + sef=$({ + echo "$sef" + seq 0 7 + } | sort -n) + echo "$sef" | awk -v startofweek="$startofweek" "$AWK_WEEKVIEW" +} + +# __view_all() +# This function prints all entries. +# +# @req $APPROX_DATA_FILE: Filename of approximate data (see `__refresh_data` and `__load_approx_data`) +__view_all() { cat "$APPROX_DATA_FILE" +} + +### +### Command-line Arguments for reloading views +### --reload-day +### --reload-week +### --reload-all +### + +# --reload-day +# Reload view of specified day. +# +# @input $2.. (optional): Specification of day, defaults to `today` +if [ "${1:-}" = "--reload-day" ]; then + shift + DISPLAY_DATE=${*:-today} + __view_day exit fi -DISPLAY_DATE=${DISPLAY_DATE:-today} -DISPLAY_DATE=$(date -d "$DISPLAY_DATE" +"%D") -DISPLAY_POS=$((8 - $(date -d "$DISPLAY_DATE" +"%u"))) - -if [ "${1:-}" = "--show-day" ]; then +# --reload-week +# Reload view of the week containing the specified date. +# +# @input $2.. (optional): Specification of day, defaults to `today` +if [ "${1:-}" = "--reload-week" ]; then shift DISPLAY_DATE=${*:-today} DISPLAY_POS=$((8 - $(date -d "$DISPLAY_DATE" +"%u"))) - __show_day + __view_week exit fi -if [ "${1:-}" = "--list" ]; then - shift - DISPLAY_DATE=${*:-today} - DISPLAY_POS=$((8 - $(date -d "$DISPLAY_DATE" +"%u"))) - __list +# --reload-all +# Reload view of all entries. +if [ "${1:-}" = "--reload-all" ]; then + __view_all exit fi -selection=$( - __list | - $FZF \ - --tac \ - --no-sort \ - --no-hscroll \ - --ellipsis="" \ - --delimiter="|" \ - --with-nth="{4}" \ - --accept-nth=1,2 \ - --ansi \ - --gap 1 \ - --no-scrollbar \ - --info=right \ - --info-command="printf \"$(date +"%R %Z")\"" \ - --preview-window=up,7,border-bottom \ - --preview="$0 --preview-week {}" \ - --expect="ctrl-n" \ - --bind="ctrl-j:transform:([ {1} = \"+\" ] && [ \$FZF_POS -le 1 ]) && - echo unbind\(load\)+reload:$0 --list {2} '+1 day'|| - echo down" \ - --bind="ctrl-k:transform:([ {1} = \"+\" ] && [ \$FZF_POS -ge 7 ]) && - echo unbind\(load\)+reload:$0 --list {2} '-1 day'|| - echo up" \ - --bind="change:unbind(load)+reload($0 --all)+hide-preview" \ - --bind="backward-eof:rebind(load)+reload($0 --list)+show-preview" \ - --bind="load:pos($DISPLAY_POS)" \ - --bind="ctrl-u:unbind(load)+reload:$0 --list {2} '-1 week'" \ - --bind="ctrl-d:unbind(load)+reload:$0 --list {2} '+1 week'" \ - --bind="ctrl-alt-u:unbind(load)+reload:$0 --list {2} '-1 month'" \ - --bind="ctrl-alt-d:unbind(load)+reload:$0 --list {2} '+1 month'" \ - --bind="ctrl-s:execute($SYNC_CMD ; printf 'Press to continue.'; read -r tmp)" \ - --bind="ctrl-g:become($0 --goto)" \ - --bind="ctrl-r:rebind(load)+reload:$0 --list" +### +### Load Configuration +### ROOT: Directory containing the collections +### COLLECTION_LABELS: Mappings between collections and labels +### SYNC_CMD (optional): Synchronization command +### DAY_START (optional): Hour of start of the day (defaults to 8) +### DAY_END (optional): Hour of end of the day (defaults to 18) +### + +CONFIGFILE="$HOME/.config/fzf-vcal/config" +if [ ! -f "$CONFIGFILE" ]; then + err "Configuration '$CONFIGFILE' not found." + exit 1 +fi +# shellcheck source=/dev/null +. "$CONFIGFILE" +if [ -z "${ROOT:-}" ] || [ -z "${COLLECTION_LABELS:-}" ]; then + err "Configuration is incomplete." + exit 1 +fi +SYNC_CMD=${SYNC_CMD:-exit} +DAY_START=${DAY_START:-8} +DAY_END=${DAY_END:-18} + +### +### Check and load required tools +### FZF: Fuzzy finder `fzf`` +### UUIDGEN: Tool `uuidgen` to generate random uids +### CAT: `bat` or `batcat` or `cat` +### +### The presence of POSIX tools is not checked. +### + +if command -v "fzf" >/dev/null; then + FZF="fzf --black" +else + err "Did not find the command-line fuzzy finder fzf." + exit 1 +fi + +if command -v "uuidgen" >/dev/null; then + UUIDGEN="uuidgen" +else + err "Did not find the uuidgen command." + exit 1 +fi + +if command -v "bat" >/dev/null; then + CAT="bat" +elif command -v "batcat" >/dev/null; then + CAT="batcat" +fi +CAT=${CAT:+$CAT --color=always --style=numbers --language=md} +CAT=${CAT:-cat} + +### +### AWK scripts +### AWK_APPROX: Generate approximate data of all files +### AWK_CAL: Annotate output of `cal` +### AWK_DAYVIEW: Generate view of the day +### AWK_GET: Print field of iCalendar file +### AWK_MERGE: Generate list of weeks with associated iCalendar files +### AWK_NEW: Make new iCalendar file +### AWK_PARSE: Timezone aware parsing of iCalendar file for day view +### AWK_UPDATE: Update iCalendar file +### AWK_WEEKVIEW: Generate view of the week +### + +# TODO: Complete documentation +AWK_APPROX=$( + cat <<'EOF' +@@include src/awk/approx.awk +EOF ) -key=$(echo "$selection" | head -1) -line=$(echo "$selection" | tail -1) -if [ "$line" = "$key" ]; then - line="" -fi -sign=$(echo "$line" | cut -d '|' -f 1) -startdate=$(echo "$line" | cut -d '|' -f 2) -if [ "$key" = "ctrl-n" ]; then - # Add new - if [ "$sign" = "~" ]; then - startdate="" - fi - exec $0 --new "${startdate:-today} $DAY_START:00" -fi -if [ -z "$key" ] && [ -z "$line" ]; then - rm "$WEEKLY_DATA_FILE" "$APPROX_DATA_FILE" - return 0 -fi +AWK_MERGE=$( + cat <<'EOF' +@@include src/awk/merge.awk +EOF +) -if [ "$sign" = "~" ]; then - exec $0 --date "$startdate" -else - exec $0 --day "$startdate" -fi -echo "Going to end..." -echo "$selection" -echo "STOPPING NOW" +AWK_PARSE=$( + cat <<'EOF' +@@include src/awk/parse.awk +EOF +) + +AWK_WEEKVIEW=$( + cat <<'EOF' +@@include src/awk/weekview.awk +EOF +) + +AWK_DAYVIEW=$( + cat <<'EOF' +@@include src/awk/dayview.awk +EOF +) + +AWK_GET=$( + cat <<'EOF' +@@include src/awk/get.awk +EOF +) + +AWK_UPDATE=$( + cat <<'EOF' +@@include src/awk/update.awk +EOF +) + +AWK_NEW=$( + cat <<'EOF' +@@include src/awk/new.awk +EOF +) + +AWK_CAL=$( + cat <<'EOF' +@@include src/awk/cal.awk +EOF +) + +### +### Colors +### +#GREEN="\033[1;32m" +#RED="\033[1;31m" +#WHITE="\033[1;97m" +CYAN="\033[1;36m" +#ITALIC="\033[3m" +#FAINT="\033[2m" +OFF="\033[m" + +### +### Loading functions +### __load_approx_data +### __load_weeks +### __refresh_data +### + +# __load_approx_data() +# Print approximate data from iCalendar files in `$ROOT` +# TODO: Make safe and POSIX compliant +# +# @req $ROOT: Path that contains the collections (see configuration) +# @req $COLLECTION_LABELS: Mapping between collections and lables (see configuration) +# @req $AWK_APPROX: Awk script for approximation +__load_approx_data() { + find "$ROOT" -type f -name '*.ics' -print0 | + xargs -0 -P0 \ + awk \ + -v collection_labels="$COLLECTION_LABELS" \ + "$AWK_APPROX" +} + +# __load_weeks() +# For every relevant week, print associated iCalendar files +# +# @req $APPROX_DATA_FILE: Filename of approximate data (see `__refresh_data` and `__load_approx_data`) +# @req $AWK_MERGE: Merge awk script +__load_weeks() { + dates=$(awk -F'|' '{ print $2; print $3 }' "$APPROX_DATA_FILE") + file_dates=$(mktemp) + echo "$dates" | date --file="/dev/stdin" +"%G|%V" >"$file_dates" + awk "$AWK_MERGE" "$file_dates" "$APPROX_DATA_FILE" + rm "$file_dates" +} + +# __refresh_data() +# Refresh approximate data and per-week data. +# +# This functions stores the output of `__load_approx_data` in the temporary +# file `$APPROX_DATA_FILE` and the output of `__load_weeks` in the temporary +# file `@WEEKLY_DATA_FILE`. +__refresh_data() { + if [ -n "${APPROX_DATA_FILE:-}" ]; then + rm -f "$APPROX_DATA_FILE" + fi + if [ -n "${WEEKLY_DATA_FILE:-}" ]; then + rm -f "$WEEKLY_DATA_FILE" + fi + APPROX_DATA_FILE=$(mktemp) + __load_approx_data >"$APPROX_DATA_FILE" + WEEKLY_DATA_FILE=$(mktemp) + __load_weeks >"$WEEKLY_DATA_FILE" +} + +### +### UX helper functions +### __datetime_human_machine +### + +# __datetime_human_machine() +# Print date or datetime in a human and machine readable form. +# +# @input $1: Seconds since epoch +__datetime_human_machine() { + s="$1" + t=$(date -d "@$s" +"%R") + dfmt="%F" + if [ "$t" != "00:00" ]; then + dfmt="$dfmt %R" + fi + date -d "@$s" +"$dfmt" +} + +### +### iCalendar modification wrapper +### +### __edit +### __new +### __delete + +# __edit() +# Edit iCalendar file. +# +# @input $1: Start date/date-time +# @input $2: End date/date-time +# @input $3: Path to iCalendar file (relative to `$ROOT`) +# @req $AWK_GET: Awk script to extract fields from iCalendar file +# @req $AWK_UPDATE: Awk script to update iCalendar file +# @req $EDITOR: Environment variable of your favorite editor +__edit() { + start=$(__datetime_human_machine "$1") + end=$(__datetime_human_machine "$2") + fpath="$ROOT/$3" + location=$(awk -v field="LOCATION" "$AWK_GET" "$fpath") + summary=$(awk -v field="SUMMARY" "$AWK_GET" "$fpath") + description=$(awk -v field="DESCRIPTION" "$AWK_GET" "$fpath") + filetmp=$(mktemp --suffix='.md') + ( + echo "::: |> $start" + echo "::: <| $end" + ) >"$filetmp" + if [ -n "$location" ]; then + echo "@ $location" >>"$filetmp" + fi + ( + echo "# $summary" + echo "" + echo "$description" + ) >>"$filetmp" + checksum=$(cksum "$filetmp") + $EDITOR "$filetmp" >/dev/tty + + # Update only if changes are detected + if [ "$checksum" != "$(cksum "$filetmp")" ]; then + filenew="$filetmp.ics" + if awk "$AWK_UPDATE" "$filetmp" "$fpath" >"$filenew"; then + mv "$filenew" "$fpath" + __refresh_data + else + rm -f "$filenew" + err "Failed to edit entry. Press to continue." + read -r tmp + fi + fi + rm "$filetmp" +} + +# __new() +# Generate new iCalendar file +# +# This function also sets the `$start` variable to the start of the new entry. +# On failure, start will be empty. +# +# If some start has been specified and the nanoseconds are not 0, we assume +# that the user entered "tomorrow" or something like that, and did not +# specify the time. So, we will use the `$DAY_START` time of that date. +# If the user specified a malformed date/date-time, we fail. +# +# @input $1 (optional): Date or datetime, defaults to today. +# @req $COLLECTION_LABELS: Mapping between collections and lables (see configuration) +# @req $UUIDGEN: `uuidgen` command +# @req $ROOT: Path that contains the collections (see configuration) +# @req $EDITOR: Environment variable of your favorite editor +# @req $AWK_GET: Awk script to extract fields from iCalendar file +# @req $AWK_new: Awk script to generate iCalendar file +__new() { + collection=$(echo "$COLLECTION_LABELS" | tr ';' '\n' | awk '/./ {print}' | $FZF --margin="30%" --no-info --delimiter='=' --with-nth=2 --accept-nth=1) + fpath="" + while [ -f "$fpath" ] || [ -z "$fpath" ]; do + uuid=$($UUIDGEN) + fpath="$ROOT/$collection/$uuid.ics" + done + d="today $DAY_START" + if [ -n "${1:-}" ]; then + d="$1" + if [ "$(date -d "$1" +"%N")" -ne 0 ]; then + d="$d $DAY_START:00" + fi + fi + startsec=$(date -d "$d" +"%s") + endsec=$((startsec + 3600)) + start=$(__datetime_human_machine "$startsec") + end=$(__datetime_human_machine "$endsec") + filetmp=$(mktemp --suffix='.md') + ( + echo "::: |> $start" + echo "::: <| $end" + echo "@ " + echo "# " + echo "" + ) >"$filetmp" + checksum=$(cksum "$filetmp") + $EDITOR "$filetmp" >/dev/tty + + # Update only if changes are detected + if [ "$checksum" != "$(cksum "$filetmp")" ]; then + filenew="$filetmp.ics" + if awk -v uid="$uuid" "$AWK_NEW" "$filetmp" >"$filenew"; then + mv "$filenew" "$fpath" + start=$(awk -v field="DTSTART" "$AWK_GET" "$fpath" | grep -o '[0-9]\{8\}') + else + rm -f "$filenew" + start="" + err "Failed to create new entry. Press to continue." + read -r tmp + fi + fi + rm "$filetmp" +} + +# __delete() +# Delete iCalendar file +# +# @input $1: Path to iCalendar file, relative to `$ROOT` +# @req $ROOT: Path that contains the collections (see configuration) +# @req $AWK_GET: Awk script to extract fields from iCalendar file +__delete() { + fpath="$ROOT/$1" + summary=$(awk -v field="SUMMARY" "$AWK_GET" "$fpath") + while true; do + printf "Do you want to delete the entry with the title \"%s\"? (yes/no): " "$summary" >/dev/tty + read -r yn + case $yn in + "yes") + rm -v "$fpath" + break + ;; + "no") + break + ;; + *) + echo "Please answer \"yes\" or \"no\"." >/dev/tty + ;; + esac + done +} + +### Start +__refresh_data + +### Exports +# The preview calls run in subprocesses. These require the following variables: +export ROOT CAT AWK_GET AWK_CAL CYAN OFF +# The reload commands also run in subprocesses, and use in addition +export COLLECTION_LABELS DAY_START DAY_END AWK_DAYVIEW AWK_WEEKVIEW AWK_PARSE +# as well as the following variables that will be dynamically specified. So, we +# export them in the main loop using the following function. + +# __export() +# Re-export dynamical variables to subshells. +__export() { + export DISPLAY_DATE WEEKLY_DATA_FILE APPROX_DATA_FILE +} + +### +### Main loop with the command-line argument +### --today +### --goto +### --new +### --day +### --week +### +### The command-line argument defaults to "--week today". + +while true; do + export DISPLAY_DATE WEEKLY_DATA_FILE APPROX_DATA_FILE + if [ -z "${1:-}" ]; then + DISPLAY_DATE="today" + set -- "--week" "$DISPLAY_DATE" + fi + + if [ "$1" = "--today" ]; then + DISPLAY_DATE="today" + set -- "--day" "$DISPLAY_DATE" + fi + + if [ "$1" = "--goto" ]; then + DISPLAY_DATE="" + while [ -z "${DISPLAY_DATE:-}" ]; do + printf "Enter date you want to jump to, e.g., today + 1 month or 2024-1-14: " >/dev/tty + read -r tmp + if date -d "$tmp" >/dev/null; then + DISPLAY_DATE="$(date -d "$tmp" +"%D")" + fi + done + set -- "--day" "$DISPLAY_DATE" + fi + + if [ "${1:-}" = "--new" ]; then + __new "${2:-}" + if [ -n "$start" ]; then + DISPLAY_DATE="$start" + else + DISPLAY_DATE="${2:-}" + fi + __refresh_data + __export + set -- "--day" "$DISPLAY_DATE" + fi + + if [ "$1" = "--day" ]; then + DISPLAY_DATE="${2:-today}" + __export + selection=$( + __view_day | + $FZF \ + --reverse \ + --ansi \ + --no-sort \ + --no-input \ + --margin='20%,5%' \ + --border='double' \ + --color=label:bold:green \ + --border-label-pos=3 \ + --cycle \ + --delimiter='|' \ + --with-nth='{6}' \ + --accept-nth='1,2,3,4,5' \ + --preview="$0 --preview-event {}" \ + --expect="ctrl-n,ctrl-alt-d,esc,backspace,q" \ + --bind="load:pos(1)+transform(echo change-border-label:🗓️ \$(date -d {1} +\"%A %e %B %Y\"))+transform(echo {} | grep \|\| || echo show-preview)" \ + --bind="start:hide-preview" \ + --bind="ctrl-r:reload:$0 --reload-day {1}" \ + --bind="ctrl-j:down+hide-preview+transform:echo {} | grep \|\| || echo show-preview" \ + --bind="ctrl-k:up+hide-preview+transform:echo {} | grep \|\| || echo show-preview" \ + --bind="ctrl-l:hide-preview+reload:$0 --reload-day {1} '+1 day'" \ + --bind="ctrl-h:hide-preview+reload:$0 --reload-day {1} '-1 day'" \ + --bind="ctrl-s:execute($SYNC_CMD ; printf 'Press to continue.'; read -r tmp)" \ + --bind="j:preview-down" \ + --bind="k:preview-down" \ + --bind="w:toggle-preview-wrap" + ) + key=$(echo "$selection" | head -1) + line=$(echo "$selection" | tail -1) + if [ "$line" = "$key" ]; then + line="" + fi + DISPLAY_DATE=$(echo "$line" | cut -d '|' -f 1) + hour=$(echo "$line" | cut -d '|' -f 2) + start=$(echo "$line" | cut -d '|' -f 3) + end=$(echo "$line" | cut -d '|' -f 4) + fpath=$(echo "$line" | cut -d '|' -f 5 | sed "s/ /|/g") + if [ "$key" = "ctrl-n" ]; then + if echo "$hour" | grep ":"; then + hour="$DAY_START" + fi + set -- "--new" "$DISPLAY_DATE $hour:00" + elif [ "$key" = "ctrl-alt-d" ] && [ -n "$fpath" ]; then + __delete "$fpath" + __refresh_data + set -- "--day" "$DISPLAY_DATE" + elif [ -z "$key" ] && [ -n "$fpath" ]; then + __edit "$start" "$end" "$fpath" + set -- "--day" "$DISPLAY_DATE" + fi + __export + fi + + if [ "${1:-}" = "--week" ]; then + DISPLAY_DATE="${2:-today}" + DISPLAY_POS=$((8 - $(date -d "$DISPLAY_DATE" +"%u"))) + __export + selection=$( + __view_week | + $FZF \ + --tac \ + --no-sort \ + --no-hscroll \ + --ellipsis="" \ + --delimiter="|" \ + --with-nth="{4}" \ + --accept-nth=1,2 \ + --ansi \ + --gap 1 \ + --no-scrollbar \ + --info=right \ + --info-command="printf \"$(date +"%R %Z")\"" \ + --preview-window=up,7,border-bottom \ + --preview="$0 --preview-week {}" \ + --expect="ctrl-n,ctrl-g" \ + --bind="ctrl-j:transform:([ {1} = \"+\" ] && [ \$FZF_POS -le 1 ]) && + echo unbind\(load\)+reload:$0 --reload-week {2} '+1 day'|| + echo down" \ + --bind="ctrl-k:transform:([ {1} = \"+\" ] && [ \$FZF_POS -ge 7 ]) && + echo unbind\(load\)+reload:$0 --reload-week {2} '-1 day'|| + echo up" \ + --bind="change:unbind(load)+reload($0 --reload-all)+hide-preview" \ + --bind="load:pos($DISPLAY_POS)" \ + --bind="ctrl-u:unbind(load)+reload:$0 --reload-week {2} '-1 week'" \ + --bind="ctrl-d:unbind(load)+reload:$0 --reload-week {2} '+1 week'" \ + --bind="ctrl-alt-u:unbind(load)+reload:$0 --reload-week {2} '-1 month'" \ + --bind="ctrl-alt-d:unbind(load)+reload:$0 --reload-week {2} '+1 month'" \ + --bind="ctrl-s:execute($SYNC_CMD ; printf 'Press to continue.'; read -r tmp)" \ + --bind="backward-eof:rebind(load)+reload($0 --reload-week today)+show-preview" \ + --bind="ctrl-r:rebind(load)+reload($0 --reload-week today)+show-preview" + ) + + key=$(echo "$selection" | head -1) + line=$(echo "$selection" | tail -1) + if [ "$line" = "$key" ]; then + line="" + fi + sign=$(echo "$line" | cut -d '|' -f 1) + startdate=$(echo "$line" | cut -d '|' -f 2) + if [ "$key" = "ctrl-n" ]; then + if [ "$sign" = "~" ]; then + startdate="" + fi + set -- "--new" "${startdate:-today} $DAY_START:00" + elif [ "$key" = "ctrl-g" ]; then + set -- "--goto" + else + DISPLAY_DATE="$startdate" + if [ "$sign" = "~" ]; then + set -- "--week" "$DISPLAY_DATE" + else + set -- "--day" "$DISPLAY_DATE" + fi + fi + __export + fi +done