#!/bin/sh set -eu # Color support GREEN=$(printf '\033[1;32m') RED=$(printf '\033[1;31m') WHITE=$(printf '\033[1;97m') FAINT=$(printf '\033[2m') OFF=$(printf '\033[m') # Read configuration # shellcheck source=/dev/null . "$HOME/.config/fzf-awk-board/config" if [ -z "$ROOT" ] || [ -z "$SYNC_CMD" ] || [ -z "$COLLECTION_LABELS" ]; then echo "Failed to get configuration." exit 1 fi __vtodopriority() { python3 -c ' import sys from datetime import datetime from icalendar.cal import Todo if not len(sys.argv) == 3: print("Pass ical file as first argument!", file=sys.stderr) sys.exit(1) increase = 1 if sys.argv[2] == "1" else -1 with open(sys.argv[1], "r") as f: try: ical = Todo.from_ical(f.read()) except Exception as e: print(f"Failed to read vjournal file: {e}", file=sys.stderr) sys.exit(1) tlist = [component for component in ical.walk("VTODO")] if len(tlist) == 0: print("ical file is not a VTODO", file=sys.stderr) sys.exit(1) t = tlist[0] # Update ical priority = t.pop("PRIORITY") priority = (int(priority) if priority else 0) + increase priority = 0 if priority < 0 else 9 if priority > 9 else priority t["PRIORITY"] = priority # Print print(ical.to_ical().decode().replace("\r\n", "\n")) ' "$@" } __vtodotogglecompleted() { python3 -c ' import sys from datetime import datetime from icalendar.cal import Todo from icalendar.prop import vDDDTypes if not len(sys.argv) == 2: print("Pass ical file as first argument!", file=sys.stderr) sys.exit(1) with open(sys.argv[1], "r") as f: try: ical = Todo.from_ical(f.read()) except Exception as e: print(f"Failed to read vjournal file: {e}", file=sys.stderr) sys.exit(1) tlist = [component for component in ical.walk("VTODO")] if len(tlist) == 0: print("ical file is not a VTODO", file=sys.stderr) sys.exit(1) t = tlist[0] # Update ical if t.has_key("STATUS") and t["STATUS"] == "COMPLETED": # Mark as not completed t["STATUS"] = "NEEDS-ACTION" t["PERCENT-COMPLETE"] = 0 if t.has_key("COMPLETED"): t.pop("COMPLETED") else: t["STATUS"] = "COMPLETED" t["PERCENT-COMPLETE"] = 100 t["COMPLETED"] = vDDDTypes(datetime.utcnow()) # Print print(ical.to_ical().decode().replace("\r\n", "\n")) ' "$@" } __vicalnew() { python3 -c ' import sys from datetime import date, datetime from icalendar.cal import Calendar, Journal, Todo from icalendar.prop import vDDDTypes if not len(sys.argv) == 2: print("Pass UID as first argument!", file=sys.stderr) sys.exit(1) UID = sys.argv[1] start = None due = None line = sys.stdin.readline().strip() if line[:6] == "::: |>": start = datetime.utcnow().date() line = sys.stdin.readline().strip() if line[:6] == "::: <|": lst = line.split(" ") due = True if len(lst) >= 3: try: duedate = datetime.strptime(lst[2], "%Y-%m-%d").date() due = duedate except Exception as e: pass line = sys.stdin.readline().strip() if not line[:2] == "# ": print("Error: Summary line is corrupt!", file=sys.stderr) sys.exit(1) summary = line[2:] line = sys.stdin.readline().strip() if not line[:2] == "> " and not line == ">": categories = [] else: categories = line[2:].split(",") line = sys.stdin.readline().strip() if not line == "": print("Error: Missing separating line!", file=sys.stderr) sys.exit(1) description = sys.stdin.read() # Create ical now = datetime.utcnow() ical = Calendar() if due: o = Todo() # The following are REQUIRED, but MUST NOT occur more than once. # dtstamp / uid / o.DTSTAMP = now o["UID"] = UID # The following are OPTIONAL, but MUST NOT occur more than once. # class / completed / created / description / dtstart / geo / last-mod / # location / organizer / percent / priority / recurid / seq / status / # summary / url / o["CLASS"] = "PRIVATE" o["CREATED"] = vDDDTypes(now) o["DESCRIPTION"] = description.strip() o.LAST_MODIFIED = now o["PRIORITY"] = 0 o["SEQUENCE"] = 0 o["STATUS"] = "NEEDS-ACTION" o["SUMMARY"] = summary # The following is OPTIONAL, but SHOULD NOT occur more than once. # rrule / # Either "due" or "duration" MAY appear in a "todoprop", but "due" and # "duration" MUST NOT occur in the same "todoprop". If "duration" appear in # a "todoprop", then "dtstart" MUST also appear in the same "todoprop". # due / duration / if isinstance(due, date): o.DUE = due # The following are OPTIONAL, and MAY occur more than once. # attach / attendee / categories / comment / contact / exdate / rstatus / # related / resources / rdate / x-prop / iana-prop o.categories = categories else: o = Journal() # The following are REQUIRED, but MUST NOT occur more than once. # dtstamp / uid / o.DTSTAMP = now o["UID"] = UID # The following are OPTIONAL, but MUST NOT occur more than once. # class / created / dtstart / last-mod / organizer / recurid / seq / status # / summary / url / o["CLASS"] = "PRIVATE" o["CREATED"] = vDDDTypes(now) o.DTSTART = start o.LAST_MODIFIED = now o["SEQUENCE"] = 0 o["STATUS"] = "FINAL" o["SUMMARY"] = summary # The following is OPTIONAL, but SHOULD NOT occur more than once. # rrule / # The following are OPTIONAL, and MAY occur more than once. # attach / attendee / categories / comment / contact / description / exdate # / related / rdate / rstatus / x-prop / iana-prop o.categories = categories o["DESCRIPTION"] = description.strip() ical.add_component(o) ical["PRODID"] = "fzf-vjour/basic" ical["VERSION"] = "2.0" # Print print(ical.to_ical().decode().replace("\r\n", "\n"))' "$@" } __icalupdate() { python3 -c ' import sys from datetime import date, datetime from icalendar.cal import Calendar, Todo if not len(sys.argv) == 2: print("Pass ical file as first argument!", file=sys.stderr) sys.exit(1) with open(sys.argv[1], "r") as f: try: ical = Calendar.from_ical(f.read()) except Exception as e: print(f"Failed to read ical file: {e}", file=sys.stderr) sys.exit(1) olist = [component for component in ical.walk(select=lambda c:c.name in ["VJOURNAL", "VTODO"])] if len(olist) == 0: sys.exit(0) o = olist[0] line = sys.stdin.readline().strip() due = None if isinstance(o, Todo): if not line[:6] == "::: <|": print("Error: Due date line is corrupt!", file=sys.stderr) sys.exit(1) lst = line.split(" ") due = True if len(lst) >= 3: try: duedate = datetime.strptime(lst[2], "%Y-%m-%d").date() due = duedate except Exception as e: pass line = sys.stdin.readline().strip() if not line[:2] == "# ": print("Error: Summary line is corrupt!", file=sys.stderr) sys.exit(1) summary = line[2:] line = sys.stdin.readline().strip() if not line[:2] == "> " and not line == ">": categories = [] else: categories = line[2:].split(",") line = sys.stdin.readline().strip() if not line == "": print("Error: Missing separating line!", file=sys.stderr) sys.exit(1) description = sys.stdin.read() # Update ical if due: if isinstance(due, date): o.DUE = due elif "DUE" in o.keys(): o.pop("DUE") o["SUMMARY"] = summary o.categories = categories o["DESCRIPTION"] = description.strip() o.LAST_MODIFIED = datetime.utcnow() seq = o["SEQUENCE"] if "SEQUENCE" in o.keys() else 0 o["SEQUENCE"] = seq + 1 # Print print(ical.to_ical().decode().replace("\r\n", "\n")) ' "$@" } __ical2json() { python3 -c ' import sys import json from datetime import datetime from zoneinfo import ZoneInfo from icalendar.cal import Calendar, Todo input_data = sys.stdin.read() try: ical = Calendar.from_ical(input_data) except Exception as e: print(f"Failed to read ical file: {e}", file=sys.stderr) sys.exit(1) olist = [component for component in ical.walk(select=lambda c:c.name in ["VJOURNAL", "VTODO"])] if len(olist) == 0: sys.exit(0) o = olist[0] local_tz = ZoneInfo("localtime") data = { "summary": o.get("SUMMARY"), "description": o.get("DESCRIPTION") if "DESCRIPTION" in o.keys() else "", "categories": o.categories, "class": o.get("CLASS"), "created": str(o.DTSTAMP.astimezone(local_tz)) if o.DTSTAMP else "", "last_modified": str(o.LAST_MODIFIED.astimezone(local_tz)) if o.LAST_MODIFIED else "", "start": str( o.DTSTART.astimezone(local_tz) if isinstance(o.DTSTART, datetime) else o.DTSTART or "" ), } if isinstance(o, Todo): data["due"] = str(o.DUE) if o.DUE else "" print(json.dumps(data))' } __lines() { find "$ROOT" -type f -name '*.ics' -print0 | xargs -0 -P 0 \ awk -f "./src/list.awk" \ -v collection_labels="$COLLECTION_LABELS" \ -v flag_open="🔲" \ -v flag_completed="✅" \ -v flag_journal="📘" \ -v flag_note="🗒️" | sort -g -r | cut -d ' ' -f 3- } __filepath_from_selection() { echo "$1" | grep -o ' \{50\}.*$' | xargs } # Program starts here 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 " --tasks Show tasks only" echo " --no-tasks Ignore tasks" echo " --notes Show notes only" echo " --no-notes Ignore notes" echo " --journal Show journal only" echo " --no-journal Ignore journal" echo " --completed Show completed tasks only" echo " --no-completed Ignore completed tasks" echo " --new Create new entry" echo "" echo "The following options are for internal use." echo " --reload Reload list" echo " --preview Generate preview" echo " --delete Delete selected entry" echo " --decrease-priority Decrease priority of selected task" echo " --increase-priority Increase priority of selected task" echo " --toggle-completed Toggle completion flag of task" exit fi # Command line arguments to be self-contained # Generate preview of file from selection if [ "${1:-}" = "--preview" ]; then vjfile=$(__filepath_from_selection "$2") __ical2json <"$vjfile" | jq -r ".description" | batcat --color=always --style=numbers --language=md exit fi # Delete file from selection if [ "${1:-}" = "--delete" ]; then vjfile=$(__filepath_from_selection "$2") rm -i "$vjfile" fi # Generate new entry if [ "${1:-}" = "--new" ]; then collection=$(echo "$LABLES" | fzf \ --margin 20% \ --prompt="Select collection> ") file="" while [ -f "$file" ] || [ -z "$file" ]; do uuid=$(uuidgen) file=$(__filepath_from_selection "$collection /$uuid.ics") done tmpmd=$(mktemp --suffix='.md') tmpsha="$tmpmd.sha" { echo "::: |> " echo "::: <| " echo "# " echo "> " echo "" } >"$tmpmd" sha1sum "$tmpmd" >"$tmpsha" # Open in editor $EDITOR "$tmpmd" >/dev/tty # Update if changes are detected if ! sha1sum -c "$tmpsha" >/dev/null 2>&1; then tmpfile="$tmpmd.ics" tmpferr="$tmpmd.err" if __vicalnew "$uuid" <"$tmpmd" >"$tmpfile" 2>"$tmpferr"; then mv "$tmpfile" "$file" else rm "$tmpfile" less "$tmpferr" fi rm "$tmpferr" fi rm "$tmpmd" "$tmpsha" fi # Toggle completed flag if [ "${1:-}" = "--toggle-completed" ]; then vtfile=$(__filepath_from_selection "$2") vtfile_tmp=$(mktemp) __vtodotogglecompleted "$vtfile" >"$vtfile_tmp" && mv "$vtfile_tmp" "$vtfile" || rm "$vtfile_tmp" fi # Increase priority if [ "${1:-}" = "--increase-priority" ]; then vtfile=$(__filepath_from_selection "$2") vtfile_tmp=$(mktemp) __vtodopriority "$vtfile" "1" >"$vtfile_tmp" && mv "$vtfile_tmp" "$vtfile" || rm "$vtfile_tmp" fi # Decrease priority if [ "${1:-}" = "--decrease-priority" ]; then vtfile=$(__filepath_from_selection "$2") vtfile_tmp=$(mktemp) __vtodopriority "$vtfile" "-1" >"$vtfile_tmp" && mv "$vtfile_tmp" "$vtfile" || rm "$vtfile_tmp" fi if [ "${1:-}" = "--reload" ]; then __lines exit fi if [ "${1:-}" = "--search-line" ]; then __filepath_to_searchline "$2" exit fi query="${FZF_QUERY:-}" if [ "${1:-}" = "--no-completed" ]; then query="!✅" fi if [ "${1:-}" = "--completed" ]; then query="✅" fi if [ "${1:-}" = "--tasks" ]; then query="✅ | 🔲" fi if [ "${1:-}" = "--no-tasks" ]; then query="!✅ !🔲" fi if [ "${1:-}" = "--notes" ]; then query="🗒️" fi if [ "${1:-}" = "--no-notes" ]; then query="!🗒️" fi if [ "${1:-}" = "--journal" ]; then query="📘" fi if [ "${1:-}" = "--no-journal" ]; then query="!📘" fi if [ -z "$query" ]; then query="!✅" fi query=$(echo "$query" | sed 's/ *$//g') selection=$( __lines | fzf --ansi \ --query="$query " \ --no-sort \ --no-hscroll \ --ellipsis='' \ --preview="$0 --preview {}" \ --bind="ctrl-r:reload-sync($0 --reload)" #--preview="$0 --preview {}" \ #--bind="ctrl-d:become($0 --delete {})" \ #--bind="ctrl-x:become($0 --toggle-completed {})" \ #--bind="alt-up:become($0 --increase-priority {})" \ #--bind="alt-down:become($0 --decrease-priority {})" \ #--bind="ctrl-n:become($0 --new)" \ #--bind="alt-0:change-query(!✅)" \ #--bind="alt-1:change-query(📘)" \ #--bind="alt-2:change-query(🗒️)" \ #--bind="alt-3:change-query(✅ | 🔲)" \ #--bind="ctrl-s:execute($SYNC_CMD)" \ ) echo "OK" if [ -z "$selection" ]; then return 0 fi file=$(__filepath_from_selection "$selection") if [ ! -f "$file" ]; then echo "ERROR: File '$file' does not exist!" return 1 fi # Parse vjournal file and save as json filejson=$(mktemp) __ical2json <"$file" >"$filejson" # Prepare file to be edited filetmp=$(mktemp --suffix='.md') filesha="$filetmp.sha" if jq -e '.due' "$filejson"; then due=$(jq -r '.due' "$filejson") echo "::: <| $due" >"$filetmp" fi summary=$(jq -r '.summary' "$filejson") categories=$(jq -r '.categories | join(",")' "$filejson") { echo "# $summary" echo "> $categories" echo "" jq -r '.description' "$filejson" } >>"$filetmp" rm "$filejson" sha1sum "$filetmp" >"$filesha" # Open in editor $EDITOR "$filetmp" # Update only if changes are detected if ! sha1sum -c "$filesha" >/dev/null 2>&1; then echo "Uh... chages detected!" vj_file_new="$filetmp.ics" __icalupdate "$file" <"$filetmp" >"$vj_file_new" && mv "$vj_file_new" "$file" || rm "$vj_file_new" fi rm "$filetmp" "$filesha" exec "$0"