From 8cd4dedb0348bcd34da40a31e0cea6c8749edc61 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=84min=20Baumeler?= Date: Wed, 21 May 2025 16:55:48 +0200 Subject: [PATCH] First working version to _edit_ VJOURNAL files Uses inline python3 code. --- fzf-vjour | 277 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 277 insertions(+) create mode 100755 fzf-vjour diff --git a/fzf-vjour b/fzf-vjour new file mode 100755 index 0000000..4052226 --- /dev/null +++ b/fzf-vjour @@ -0,0 +1,277 @@ +#!/usr/bin/env zsh + +set -eu + +# Read configuration +CONFIGFILE="$HOME/.config/fzf-vjour/config.yaml" +if [[ ! -e "$CONFIGFILE" ]] +then + echo "Config file '$CONFIGFILE' not found" + exit 1 +fi +ROOT=$(yq '.datadir' < "$CONFIGFILE") +if [[ ! -d "$ROOT" ]] +then + echo "Root directory not set or wrongly set" + exit 1 +fi +COLLECTION_NAMES=($(yq '.collections [].name' "$CONFIGFILE")) +COLLECTION_LABELS=($(yq '.collections [].label' "$CONFIGFILE")) +COLLECTION_LABEL_MAX_LEN=0 +for label in "${COLLECTION_LABELS[@]}"; do + [[ ${#label} -gt $COLLECTION_LABEL_MAX_LEN ]] && COLLECTION_LABEL_MAX_LEN=${#label} +done + +__vjourupdate() { + python3 -c ' +import sys +from datetime import datetime +from icalendar.cal import Journal + +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 = Journal.from_ical(f.read()) + except Exception as e: + print(f"Failed to read vjournal file: {e}", file=sys.stderr) + sys.exit(1) + +jlist = [component for component in ical.walk("VJOURNAL")] +if len(jlist) == 0: + print("ical file is not a VJOURNAL", file=sys.stderr) + sys.exit(1) +j = jlist[0] + +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 == ">": + print("Error: Categories line is corrupt!", file=sys.stderr) + sys.exit(1) +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 +j["SUMMARY"] = summary +j.categories = categories +j["DESCRIPTION"] = description +j.LAST_MODIFIED = datetime.utcnow() + +# Print +print(ical.to_ical().decode().replace("\r\n", "\n")) +' "$@" +} + +__vjournal2json() { + python3 -c ' +import sys +import json +from datetime import datetime +from zoneinfo import ZoneInfo +from icalendar.cal import Journal + +input_data = sys.stdin.read() +try: + ical = Journal.from_ical(input_data) +except Exception as e: + print(f"Failed to read vjournal file: {e}", file=sys.stderr) + sys.exit(1) + +jlist = [component for component in ical.walk("VJOURNAL")] +if len(jlist) == 0: + sys.exit(0) + +j = jlist[0] + +local_tz = ZoneInfo("localtime") +data = { + "summary": j.get("SUMMARY"), + "description": j.get("DESCRIPTION"), + "categories": j.categories, + "class": j.get("CLASS"), + "created": str(j.DTSTAMP.astimezone(local_tz)), + "last_modified": str(j.LAST_MODIFIED.astimezone(local_tz)), + "start": str( + j.DTSTART.astimezone(local_tz) + if isinstance(j.DTSTART, datetime) + else j.DTSTART or "" + ), +} + +print(json.dumps(data))' +} +# Process each file +# This function takes two arguments: +# +# @param string: Path to ics file +# @param string: Maximum length of filenames (for padding purposes) +__filepath_to_searchline() { + local filepath="$1" + local maxlen="$2" + local totallen=$((maxlen + ${#ROOT})) + local filepathpad=$(printf "%-${totallen}s" "$filepath") + + # Color support + local GREEN=$'\033[1;32m' + local WHITE=$'\033[1;97m' + local FAINT=$'\033[2m' + local OFF=$'\033[m'; + + # Parse file + local summary categories dtstamp dtstart + + while IFS= read -r line; do + case "$line" in + SUMMARY:*) + summary="${line#SUMMARY:}" + ;; + CATEGORIES:*) + categories="${line#CATEGORIES:}" + ;; + DTSTAMP:*) + dtstamp="${line#DTSTAMP:}" + ;; + DTSTART*:[0-9]*) + dtstart=$(echo "$line" | grep -oE '[0-9]{8}') + ;; + esac + if [[ -n $summary && -n $categories && -n $dtstamp && -n $dtstart ]]; then + break + fi + done < "$filepath" + + # Parse date + if [[ -n $dtstart ]]; then + local date_target=$(date -d "$dtstart" +%s) + local date_today=$(date +%s) + local date_delta=$(( (date_target - date_today) / 86400 )) + local date_expr=$date_delta + if [[ $date_delta -eq 0 ]] + then + date_expr="today" + elif [[ $date_delta -eq -1 ]] + then + date_expr="yesterday" + elif [[ $date_delta -eq 1 ]] + then + date_expr="tomorrow" + elif [[ $date_delta -lt -1 && $date_delta -ge -7 ]] + then + date_expr="last $(date -d $dtstart +%A)" + elif [[ $date_delta -gt 1 && $date_delta -le 7 ]] + then + date_expr="next $(date -d $dtstart +%A)" + else + date_expr=$(date -d $dtstart +%x) + fi + date_emoji="📘" + else + date_emoji="🗒️" + date_expr="" + fi + date_expr=$(printf "%12s" "$date_expr") + + # Print line + echo -e "$dtstamp $filepath $WHITE$date_expr$OFF $date_emoji $GREEN$summary$OFF $FAINT$categories$OFF" +} + +__lines() { + # Collect all files + local files=() + while IFS= read -r -d '' file; do + files+=("$file") + done < <(find "$ROOT" -type f -name '*.ics' -print0) + + # Compute max length of basenames + local maxlen=0 + for file in "${files[@]}"; do + local name=$(basename "$file") + [[ ${#name} -gt $maxlen ]] && maxlen=${#name} + done + + local lines=$(for file in "${files[@]}"; do + __filepath_to_searchline "$file" "$maxlen" + done) + + # Decorate + for ((i = 1; i <= ${#COLLECTION_NAMES[@]}; i++)); do + #local label=$(printf "%${COLLECTION_LABEL_MAX_LEN}s" "${COLLECTION_LABELS[$i]}") + local label="${COLLECTION_LABELS[$i]}" + lines=$(echo "$lines" | sed "s|/*${COLLECTION_NAMES[$i]}/*|:$label |") + done + + # Sort and cut off irreleant part + lines=$(echo "$lines" | sort -g -r | cut -d ':' -f 2-) + + echo -e "$lines" +} + +__filepath_from_selection() { + local selection=$(echo "$1" | cut -d " " -f 1,2) + for ((i = 1; i <= ${#COLLECTION_NAMES[@]}; i++)); do + selection=$(echo "$selection" | sed "s|^.*${COLLECTION_LABELS[$i]} *|$ROOT/${COLLECTION_NAMES[$i]}/|") + done + echo -e "$selection" +} + +__open_vj_file() { + local file="$1" + local tmpfile="$(mktemp).md" + echo "$file" | __vjournal2json +} + +selection=$(__lines | fzf --ansi) +if [[ -z "$selection" ]]; then + return 0; +fi + +VJ_FILE=$(__filepath_from_selection "$selection") + +if [[ ! -f "$VJ_FILE" ]]; then + echo "ERROR: File '$VJ_FILE' does not exist!" + return 1; +fi + +# Parse vjournal file and save as json +TMPJSON="$(mktemp).json" +trap "rm -f $TMPJSON" EXIT +cat "$VJ_FILE" | __vjournal2json > "$TMPJSON" + +# Prepare file to be edited +TMPFILE="$(mktemp).md" +SHAFILE="$TMPFILE.sha" +trap "rm -f $TMPFILE $SHAFILE" EXIT +SUMMARY=$(jq -r '.summary' "$TMPJSON") +CATEGORIES=$(jq -r '.categories | join(",")' "$TMPJSON") +echo "# $SUMMARY" > "$TMPFILE" +echo "> $CATEGORIES" >> "$TMPFILE" +echo >> "$TMPFILE" +jq -r '.description' "$TMPJSON" >> "$TMPFILE" +sha1sum "$TMPFILE" > "$SHAFILE" + +# Open in editor +$EDITOR "$TMPFILE" + +# Update only if changes are detected +if ! sha1sum -c "$SHAFILE" > /dev/null 2>&1 +then + echo "Uh... chages detected!" + local vj_file_new="$TMPFILE.ics" + trap "rm -f $vj_file_new" EXIT + __vjourupdate "$VJ_FILE" < "$TMPFILE" > "$vj_file_new" && mv "$vj_file_new" "$VJ_FILE" +fi + +exec "$0"