From 23fdb4c3d2b9ad9ca6a936e8140b94f3f0ae8c0a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=84min=20Baumeler?= Date: Tue, 27 May 2025 21:24:21 +0200 Subject: [PATCH] awk based --- fab | 550 +++++++++++++++++++++++++++++++++++++++++++++++++++ src/list.awk | 216 ++++++++++++++++++++ 2 files changed, 766 insertions(+) create mode 100755 fab create mode 100644 src/list.awk diff --git a/fab b/fab new file mode 100755 index 0000000..9a693cd --- /dev/null +++ b/fab @@ -0,0 +1,550 @@ +#!/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" diff --git a/src/list.awk b/src/list.awk new file mode 100644 index 0000000..b38d5da --- /dev/null +++ b/src/list.awk @@ -0,0 +1,216 @@ +# awk script to generate summary line for iCalendar VJOURNAL and VTODO entries +# +# See https://datatracker.ietf.org/doc/html/rfc5545 for the RFC 5545 that +# describes iCalendar, and its syntax + +function getcontent(content_line, prop) +{ + return substr(content_line[prop], index(content_line[prop], ":") + 1); +} + +function storetext_line(content_line, c, prop) +{ + c[prop] = getcontent(content_line, prop); + gsub("\\\\n", " ", c[prop]); + gsub("\\\\N", " ", c[prop]); + gsub("\\\\,", ",", c[prop]); + gsub("\\\\;", ";", c[prop]); + gsub("\\\\\\\\", "\\", c[prop]); + #gsub(" ", "_", c[prop]); +} + +function storeinteger(content_line, c, prop) +{ + c[prop] = getcontent(content_line, prop); + c[prop] = c[prop] ? c[prop] : 0; +} + +function storedatetime(content_line, c, prop) +{ + c[prop] = getcontent(content_line, prop); +} + +function storedate(content_line, c, prop) +{ + c[prop] = substr(getcontent(content_line, prop), 1, 8); +} + +function formatdate(date, today, todaystamp, ts, ts_y, ts_m, ts_d, delta) +{ + ts_y = substr(date, 1, 4); + ts_m = substr(date, 5, 2); + ts_d = substr(date, 7); + ts = mktime(ts_y " " ts_m " " ts_d " 00 00 00"); + delta = (ts - todaystamp) / 86400; + if (delta >= 0 && delta < 1) { + return "today"; + } + if (delta >= 1 && delta < 2) { + return "tomorrow"; + } + if (delta >= 2 && delta < 3) { + return "in two days"; + } + if (delta >= 3 && delta < 4) { + return "in three days"; + } + if (delta < 0 && delta >= -1) { + return "yesterday"; + } + if (delta < -1 && delta >= -2) { + return "two days ago"; + } + if (delta < -2 && delta >= -3) { + return "three days ago"; + } + return " " substr(date, 1, 4) "-" substr(date, 5, 2) "-" substr(date, 7); +} + +BEGIN { + # We require the following variables to be set using -v + # collection_lables: ;-delimited collection=label strings + # flag_open: symbol for open to-do's + # flag_completed: symbol for completed to-do's + # flag_journal: symbol for journal entries + # flag_note: symbol for note entries + + FS = "[:;]"; + # Collections + split(collection_labels, mapping, ";"); + for (map in mapping) + { + split(mapping[map], m, "="); + collection2label[m[1]] = m[2]; + } + # Colors + GREEN = "\033[1;32m"; + RED = "\033[1;31m"; + WHITE = "\033[1;97m"; + CYAN = "\033[1;36m"; + FAINT = "\033[2m"; + OFF = "\033[m"; + + # For date comparision + today = strftime("%Y%m%d"); + todaystamp = mktime(substr(today, 1, 4) " " substr(today, 5, 2) " " substr(today, 7) " 00 00 00"); +} + +# Reset variables +BEGINFILE { + type = ""; + prop = ""; + delete content_line; + delete c; + +} + +/^BEGIN:(VJOURNAL|VTODO)/ { + type = $2 +} + +/^END:/ && $2 == type { + nextfile +} + +/^(CATEGORIES|DESCRIPTION|PRIORITY|STATUS|SUMMARY|COMPLETED|DUE|DTSTART|DURATION|CREATED|DTSTAMP|LAST-MODIFIED)/ { + prop = $1; + content_line[prop] = $0; + next; +} +/^[^ ]/ && prop { + prop = ""; + next; +} +/^ / && prop { + content_line[prop] = content_line[prop] substr($0, 2); + next; +} + +ENDFILE { + if (!type) { + exit + } + # Process content lines + storetext_line(content_line, c, "CATEGORIES" ); + storetext_line(content_line, c, "DESCRIPTION" ); + storeinteger( content_line, c, "PRIORITY" ); + storetext_line(content_line, c, "STATUS" ); + storetext_line(content_line, c, "SUMMARY" ); + storedatetime( content_line, c, "COMPLETED" ); + storedate( content_line, c, "DUE" ); + storedate( content_line, c, "DTSTART" ); + storedatetime( content_line, c, "DURATION" ); + storedatetime( content_line, c, "CREATED" ); + storedatetime( content_line, c, "DTSTAMP" ); + storedatetime( content_line, c, "LAST-MODIFIED"); + + # Priority field, primarly used for sorting + prio = c["PRIORITY"] > 0 ? 10 - c["PRIORITY"] : "0"; + priotext = ""; + prio = 0; + if (c["PRIORITY"] > 0) + { + prio = 10 - c["PRIORITY"]; + priotext = "❗(" prio ") "; + } + + # Last modification/creation time stamp, used for sorting + # LAST-MODIFIED: Optional field for VTODO and VJOURNAL entries, date-time in + # UTC time format + # DTSTAMP: mandatory field in VTODO and VJOURNAL, date-time in UTC time + # format + mod = c["LAST-MODIFIED"] ? c["LAST-MODIFIED"] : c["DTSTAMP"]; + + # Collection name + depth = split(FILENAME, path, "/"); + collection = depth > 1 ? path[depth-1] : ""; + collection = collection in collection2label ? collection2label[collection] : collection; + + # Date field. For VTODO entries, we show the due date, for journal entries, + # the associated date. + datecolor = CYAN; + summarycolor = GREEN; + + if (type == "VTODO") + { + # Either DUE or DURATION may appear. If DURATION appears, then also DTSTART + d = c["DUE"] ? c["DUE"] : + (c["DURATION"] ? c["DTSTART"] " for " c["DURATION"] : ""); + if (d && d < today && c["STATUS"] != "COMPLETED") + { + datecolor = RED; + summarycolor = RED; + } + } else { + d = c["DTSTART"]; + } + d = d ? formatdate(d, today, todaystamp ts, ts_y, ts_m, ts_d, delta) : " "; + + # flag: - "journal" for VJOURNAL with DTSTART + # - "note" for VJOURNAL without DTSTART + # - "completed" for VTODO with c["STATUS"] == COMPLETED + # - "open" for VTODO with c["STATUS"] != COMPLETED + if (type == "VTODO") + flag = c["STATUS"] == "COMPLETED" ? flag_completed : flag_open; + else + flag = c["DTSTART"] ? flag_journal : flag_note; + + # summary + # c["SUMMARY"] + summary = c["SUMMARY"] ? c["SUMMARY"] : " " + + # categories + categories = c["CATEGORIES"] ? c["CATEGORIES"] : " " + + # filename + # FILENAME + + print prio, + mod, + collection, + datecolor d OFF, + flag, + priotext summarycolor summary OFF, + WHITE categories OFF, + " " FAINT FILENAME OFF; +}