From 31c1357fbb09948354447701105c32b5bc7e731f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=84min=20Baumeler?= Date: Thu, 3 Jul 2025 13:15:44 +0200 Subject: [PATCH] feat: inline attachment support --- README.md | 13 +++- src/awk/attach.awk | 35 +++++++++ src/awk/attachdd.awk | 8 ++ src/awk/attachls.awk | 41 ++++++++++ src/awk/attachrm.awk | 13 ++++ src/awk/list.awk | 12 ++- src/main.sh | 9 ++- src/sh/attachment.sh | 178 +++++++++++++++++++++++++++++++++++++++++++ src/sh/awkscripts.sh | 28 +++++++ src/sh/config.sh | 2 + src/sh/theme.sh | 1 + 11 files changed, 337 insertions(+), 3 deletions(-) create mode 100644 src/awk/attach.awk create mode 100644 src/awk/attachdd.awk create mode 100644 src/awk/attachls.awk create mode 100644 src/awk/attachrm.awk create mode 100644 src/sh/attachment.sh diff --git a/README.md b/README.md index 0725823..653561c 100644 --- a/README.md +++ b/README.md @@ -86,6 +86,7 @@ In addition, there are the following keybindings: | `ctrl-x` | Toggle task completion | | `alt-up` | Increase task priority | | `alt-down` | Decrease task priority | +| `ctrl-a` | Open attachments view | | `alt-v` | View bare iCalendar file | | `alt-0` | Default view: Journal, notes, and _open_ tasks | | `alt-1` | Display journal entries | @@ -94,6 +95,16 @@ In addition, there are the following keybindings: You may also invoke the script with `--help` to see further command-line options. +In the attachment view, you may use the following keys: +| Key | Action | +| --- | ------ | +| `enter` | Open attachment | +| `j` | Down | +| `k` | Up | +| `w` | Toggle line wrap | +| `ctrl-a` | Add attachment | +| `ctrl-alt-d` | Delete attachment | + Git support ----------- You can track your entries with `git` by simply running `fzf-vjour --git-init`. @@ -123,7 +134,7 @@ Here is a list of some currently present limitations. - Timezone agnostic: Timezone specifications are ignored. - Time agnostic: We use the date portion only of date-time specifications. - No alarms or notifications -- No attachments +- Inline attachments only - No recurrences License diff --git a/src/awk/attach.awk b/src/awk/attach.awk new file mode 100644 index 0000000..410bb3e --- /dev/null +++ b/src/awk/attach.awk @@ -0,0 +1,35 @@ +## src/awk/attach.awk +## Prepend attachment to iCalendar file. +## +## @assign file: Path to base64-encoded content +## @assign mime: Mime +## @assign filename: Original filename + +# Functions + +# Write attachment +# +# @local variables: line, aline +function write_attachment( line, aline, fl) { + line = "ATTACH;ENCODING=BASE64;VALUE=BINARY;FMTTYPE="mime";FILENAME="filename":" + fl = 1 + while (getline aline = 73) { + print substr(line, 1, 73) + line = substr(line, 74) + fl = 0 + } + while (length(line) >= 72) { + print " "substr(line, 1, 72) + line = substr(line, 73) + } + } + if (line) + print " "line +} + +# AWK program + +/^END:(VTODO|VJOURNAL)$/ { write_attachment() } +{ print } diff --git a/src/awk/attachdd.awk b/src/awk/attachdd.awk new file mode 100644 index 0000000..f6fc79f --- /dev/null +++ b/src/awk/attachdd.awk @@ -0,0 +1,8 @@ +BEGIN { FS="[:;]" } +/^END:(VTODO|VJOURNAL)$/ { ins = 0; exit } +/^[^ ]/ && a { a = 0 } +/^ / && a && p { print substr($0, 2); } +/^ / && a && !p { if (index($0, ":")) { p = 1; print substr($0, index($0, ":")+1) } } +/^ATTACH/ && ins { i++; } +/^ATTACH/ && ins && i == id { a = 1; if (index($0, ":")) { p = 1; print substr($0, index($0, ":")+1) } } +/^BEGIN:(VTODO|VJOURNAL)$/ { ins = 1 } diff --git a/src/awk/attachls.awk b/src/awk/attachls.awk new file mode 100644 index 0000000..d1e5b19 --- /dev/null +++ b/src/awk/attachls.awk @@ -0,0 +1,41 @@ +# Decide if we need to read more to get all properties +# +# @input str: strin read so far +# @return: 1 if we need more data, 0 otherwise +function cont_reading(str) { + return index(str, ":") ? 0 : 1 +} + +# Get information about attachment +# +# @input i: Attachment index +# @input str: Attachment string (at least up to content separator `:`) +# @return: informative string +function att_info(i, str, cnt, k, info) { + str = substr(str, 1, index(str, ":") - 1) + cnt = split(str, props) + if (cnt > 1) { + for (k=2; k<=cnt; k++) { + pname = substr(props[k], 1, index(props[k], "=") - 1) + pvalu = substr(props[k], index(props[k], "=") + 1) + if (pname == "ENCODING" && pvalu = "BASE64") + enc = "base64" + if (pname == "FILENAME") + fin = pvalu + if (pname == "VALUE") + val = pvalu + if (pname == "FMTTYPE") + type = pvalu + } + if (enc) + info = "inline" + } + print i, fin, type, enc, info +} + +BEGIN { FS="[:;]"; OFS="\t" } +/^END:(VTODO|VJOURNAL)$/ { ins = 0; exit } +l && !r { att_info(i, l); l = "" } +/^ / && r { l = l substr($0, 2); r = cont_reading($0) } +/^ATTACH/ && ins { i++; l = $0; r = cont_reading($0) } +/^BEGIN:(VTODO|VJOURNAL)$/ { ins = 1 } diff --git a/src/awk/attachrm.awk b/src/awk/attachrm.awk new file mode 100644 index 0000000..2a8659e --- /dev/null +++ b/src/awk/attachrm.awk @@ -0,0 +1,13 @@ +## src/awk/attachrm.awk +## Remove attachment from iCalendar file. +## +## @assign id: Attachment number to remove + +BEGIN { FS="[:;]" } +/^END:(VTODO|VJOURNAL)$/ { ins = 0 } +/^[^ ]/ && a { a = 0 } +/^ / && a { next } +/^ATTACH/ && ins { i++; } +/^ATTACH/ && ins && i == id { a = 1; next } +/^BEGIN:(VTODO|VJOURNAL)$/ { ins = 1 } +{ print } diff --git a/src/awk/list.awk b/src/awk/list.awk index 1f770d1..c9fc505 100644 --- a/src/awk/list.awk +++ b/src/awk/list.awk @@ -50,6 +50,7 @@ BEGIN { # flag_journal: symbol for journal entries # flag_note: symbol for note entries # flag_priority symbol for prior. task + # flag_attachment symbol for attachment # style_collection # style_date # style_summary @@ -76,6 +77,7 @@ BEGIN { BEGINFILE { type = ""; prop = ""; + att = ""; delete c; } @@ -92,6 +94,11 @@ BEGINFILE { c[prop] = $0; next; } +/^ATTACH/ { + prop = "" + att = 1; + next; +} /^[^ ]/ && prop { prop = ""; next; @@ -193,6 +200,9 @@ ENDFILE { # categories categories = cat ? cat : " " + # attachments + att = att ? flag_attachment " " : "" + # filename # FILENAME @@ -203,6 +213,6 @@ ENDFILE { collection, datecolor d OFF, flag, - priotext summarycolor summary OFF, + priotext att summarycolor summary OFF, style_category categories OFF; } diff --git a/src/main.sh b/src/main.sh index 35ab168..3859cfc 100644 --- a/src/main.sh +++ b/src/main.sh @@ -23,6 +23,7 @@ __lines() { -v flag_journal="$FLAG_JOURNAL" \ -v flag_note="$FLAG_NOTE" \ -v flag_priority="$FLAG_PRIORITY" \ + -v flag_attachment="$FLAG_ATTACHMENT" \ -v style_collection="$STYLE_COLLECTION" \ -v style_date="$STYLE_DATE" \ -v style_summary="$STYLE_SUMMARY" \ @@ -66,6 +67,9 @@ fi # Command line arguments: Interal use . "sh/cli.sh" +# Attachment handling +. "sh/attachment.sh" + while true; do query=$(stripws "$query") selection=$( @@ -77,7 +81,7 @@ while true; do --print-query \ --accept-nth=4 \ --preview="$0 --preview {4}" \ - --expect="ctrl-n,ctrl-alt-d,alt-v" \ + --expect="ctrl-n,ctrl-alt-d,alt-v,ctrl-a" \ --bind="ctrl-r:reload($0 --reload)" \ --bind="ctrl-x:reload($0 --reload --toggle-completed {4})" \ --bind="alt-up:reload($0 --reload --change-priority '+1' {4})" \ @@ -116,6 +120,9 @@ while true; do "alt-v") $EDITOR "$file" ;; + "ctrl-a") + __attachment_view "$file" + ;; *) __edit "$file" ;; diff --git a/src/sh/attachment.sh b/src/sh/attachment.sh new file mode 100644 index 0000000..7f07639 --- /dev/null +++ b/src/sh/attachment.sh @@ -0,0 +1,178 @@ +# Add attachment to iCalendar file +# +# @input $1: Path to iCalendar file +__add_attachment() { + file="$1" + shift + sel=$( + $FZF --prompt="Select attachment> " \ + --walker="file,hidden" \ + --walker-root="$HOME" \ + --expect="ctrl-c,ctrl-g,ctrl-q,esc" + ) + key=$(echo "$sel" | head -1) + f=$(echo "$sel" | tail -1) + if [ -n "$key" ]; then + f="" + fi + if [ -z "$f" ] || [ ! -f "$f" ]; then + return + fi + filename=$(basename "$f") + mime=$(file -b -i "$f" | cut -d ';' -f 1) + if [ -z "$mime" ]; then + mime="application/octet-stream" + fi + fenc=$(mktemp) + base64 "$f" >"$fenc" + filetmp=$(mktemp) + awk -v file="$fenc" -v mime="$mime" -v filename="$filename" "$AWK_ATTACH" "$file" >"$filetmp" + mv "$filetmp" "$file" + if [ -n "${GIT:-}" ]; then + $GIT add "$file" + $GIT commit -q -m "Added attachment" -- "$file" + fi + rm "$fenc" +} + +# Open attachment from iCalendar file +# +# @input $1: Attachment id +# @input $2: Attachment name +# @input $3: Attachment format +# @input $4: Attachment encoding +# @input $5: Path to iCalendar file +__open_attachment() { + attid="$1" + shift + attname="$1" + shift + attfmt="$1" + shift + attenc="$1" + shift + file="$1" + shift + if [ "$attenc" != "base64" ]; then + err "Unsupported attachment encoding: $attenc. Press to continue." + read -r tmp + return + fi + if [ -n "$attname" ]; then + tmpdir=$(mktemp -d) + attpath="$tmpdir/$attname" + elif [ -n "$attfmt" ]; then + attext=$(echo "$attfmt" | cut -d "/" -f 2) + attpath=$(mktemp --suffix="$attext") + else + attpath=$(mktemp) + fi + # Get file and decode + awk -v id="$attid" "$AWK_ATTACHDD" "$file" | base64 -d >"$attpath" + fn=$(file "$attpath") + while true; do + printf "Are you sure you want to open \"%s\"? (yes/no): " "$fn" >/dev/tty + read -r yn + case $yn in + "yes") + $OPEN "$attpath" + printf "Press to continue." >/dev/tty + read -r tmp + break + ;; + "no") + break + ;; + *) + echo "Please answer \"yes\" or \"no\"." >/dev/tty + ;; + esac + done + # Clean up + rm -f "$attpath" + if [ -n "${tmpdir:-}" ] && [ -d "${tmpdir:-}" ]; then + rm -rf "$tmpdir" + fi +} + +# Delete attachment from iCalendar file +# +# @input $1: Attachment id +# @input $2: Path to iCalendar File +__del_attachment() { + attid="$1" + shift + file="$1" + shift + while true; do + printf "Are you sure you want to delete attachment \"%s\"? (yes/no): " "$attid" >/dev/tty + read -r yn + case $yn in + "yes") + filetmp=$(mktemp) + awk -v id="$attid" "$AWK_ATTACHRM" "$file" >"$filetmp" + mv "$filetmp" "$file" + if [ -n "${GIT:-}" ]; then + $GIT add "$file" + $GIT commit -q -m "Deleted attachment" -- "$file" + fi + break + ;; + "no") + break + ;; + *) + echo "Please answer \"yes\" or \"no\"." >/dev/tty + ;; + esac + done +} + +# Show attachment window +# +# @input $1: Path to iCalendar file +__attachment_view() { + file="$1" + shift + att=$( + awk "$AWK_ATTACHLS" "$file" | + $FZF \ + --delimiter="\t" \ + --accept-nth=1,2,3,4 \ + --with-nth="Attachment {1}: \"{2}\" {3} ({5})" \ + --no-sort \ + --tac \ + --margin="30%,30%" \ + --border=bold \ + --border-label="Attachment View Keys: open, delete, add" \ + --expect="ctrl-a" \ + --expect="ctrl-c,ctrl-g,ctrl-q,ctrl-d,esc,q,backspace" \ + --print-query \ + --bind="start:hide-input" \ + --bind="ctrl-alt-d:show-input+change-query(ctrl-alt-d)+accept" \ + --bind='load:transform:[ "$FZF_TOTAL_COUNT" -eq 0 ] && echo "unbind(enter)+unbind(ctrl-alt-d)"' \ + --bind="w:toggle-wrap" \ + --bind="j:down" \ + --bind="k:up" || + true + ) + key=$(echo "$att" | head -2 | xargs) + sel=$(echo "$att" | tail -1) + attid=$(echo "$sel" | cut -f 1) + attname=$(echo "$sel" | cut -f 2) + attfmt=$(echo "$sel" | cut -f 3) + attenc=$(echo "$sel" | cut -f 4) + case "$key" in + "ctrl-c" | "ctrl-g" | "ctrl-q" | "ctrl-d" | "esc" | "q" | "backspace") ;; + "ctrl-alt-d") + __del_attachment "$attid" "$file" + ;; + "ctrl-a") + __add_attachment "$file" + ;; + *) + __open_attachment "$attid" "$attname" "$attfmt" "$attenc" "$file" + ;; + esac + # +} diff --git a/src/sh/awkscripts.sh b/src/sh/awkscripts.sh index 60b31fe..144957d 100644 --- a/src/sh/awkscripts.sh +++ b/src/sh/awkscripts.sh @@ -39,3 +39,31 @@ AWK_UPDATE=$( EOF ) export AWK_UPDATE + +AWK_ATTACH=$( + cat <<'EOF' +@@include awk/attach.awk +EOF +) +export AWK_ATTACH + +AWK_ATTACHDD=$( + cat <<'EOF' +@@include awk/attachdd.awk +EOF +) +export AWK_ATTACHDD + +AWK_ATTACHLS=$( + cat <<'EOF' +@@include awk/attachls.awk +EOF +) +export AWK_ATTACHLS + +AWK_ATTACHRM=$( + cat <<'EOF' +@@include awk/attachrm.awk +EOF +) +export AWK_ATTACHRM diff --git a/src/sh/config.sh b/src/sh/config.sh index 704d33f..539d4f2 100644 --- a/src/sh/config.sh +++ b/src/sh/config.sh @@ -43,3 +43,5 @@ if command -v "git" >/dev/null && [ -d "$ROOT/.git" ]; then GIT="git -C $ROOT" export GIT fi + +export OPEN=${OPEN:-open} diff --git a/src/sh/theme.sh b/src/sh/theme.sh index 8fc7ece..ddc8dc6 100644 --- a/src/sh/theme.sh +++ b/src/sh/theme.sh @@ -11,6 +11,7 @@ export FLAG_COMPLETED="${FLAG_COMPLETED:-✅}" export FLAG_JOURNAL="${FLAG_JOURNAL:-📘}" export FLAG_NOTE="${FLAG_NOTE:-🗒️}" export FLAG_PRIORITY="${FLAG_PRIORITY:-❗}" +export FLAG_ATTACHMENT="${FLAG_ATTACHMENT:-🔗}" # Style export STYLE_COLLECTION="${STYLE_COLLECTION:-$FAINT$WHITE}"