Compare commits

...

25 Commits

Author SHA1 Message Date
6f268c05cd use whitespace free paths only 2025-06-05 00:17:08 +02:00
5144465792 export variables and use them in subprocesses 2025-06-04 23:46:32 +02:00
83061bd54b uuidgen 2025-06-02 23:28:42 +02:00
b613c25f98 minor improvements 2025-06-02 23:26:42 +02:00
374d7e08c8 gh readme 2025-06-02 10:37:47 +02:00
7df51a6980 removed sha1sum depependency, batcat dependency, auto bat vs batcat vs cat, display sync output 2025-06-02 10:31:28 +02:00
4079a53c2b updated readme 2025-06-02 09:36:19 +02:00
0c742eccd4 removed tracking of fzf-vjour 2025-06-02 09:24:32 +02:00
095a2e3810 ext 2025-06-02 09:22:45 +02:00
498c7371b7 externalize awk scripts 2025-06-02 09:19:28 +02:00
8d5223343b fix:repeated STATUS 2025-05-28 16:09:39 +02:00
f7d4a54a3d fix:wrong config path 2025-05-28 14:24:17 +02:00
3db94cf627 readme update 2025-05-28 14:22:11 +02:00
50c438e656 awk internalized 2025-05-28 14:14:58 +02:00
a72c2c73d2 awk based, fast 2025-05-28 14:02:20 +02:00
23fdb4c3d2 awk based 2025-05-27 21:24:21 +02:00
9545410139 updated readme 2025-05-23 17:01:03 +02:00
c2342b52a6 readme update 2025-05-23 16:58:27 +02:00
7aa85ce5af impreved fzf 2025-05-23 15:55:14 +02:00
5b292f6971 improved keys 2025-05-23 15:47:33 +02:00
74a75f1b4b more flags 2025-05-23 15:43:06 +02:00
ee8e6994f3 one temp file less 2025-05-23 14:57:03 +02:00
2df8fcf1bc improved view, fixes 2025-05-23 14:46:56 +02:00
9a5aaea387 Tons of changes 2025-05-23 14:04:18 +02:00
5bbde62142 fix:remove temporary files 2025-05-22 13:30:46 +02:00
11 changed files with 853 additions and 411 deletions

1
.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
fzf-vjour

View File

@@ -1,49 +1,45 @@
A [fzf](https://github.com/junegunn/fzf)-based **journaling and notes** application with CalDav support. A [fzf](https://github.com/junegunn/fzf)-based **journaling, notes, and tasks** application with CalDav support.
Description and Use Case Description and Use Case
------------------------ ------------------------
This application allows for a keyboard-controlled maneuvering of your notes and journal entries. This application allows for a keyboard-controlled maneuvering of your notes, journal entries, and tasks.
A journal entry is nothing more than a note associated with a specific date. These entries are stored as [iCalendar](https://datatracker.ietf.org/doc/html/rfc5545) files of the type `VJOURNAL` and `VTODO`.
These entries are stored as [iCalendar](https://datatracker.ietf.org/doc/html/rfc5545) files of the type `VJOURNAL`.
For instance, you could use this application as a minimalistic terminal-based counterpart of [jtx Board](https://jtx.techbee.at/) in a setup For instance, you could use this application as a terminal-based counterpart of [jtx Board](https://jtx.techbee.at/) in a setup
with a CalDav server, such as [Radicale](https://radicale.org/), and a synchronization tool like [vdirsyncer](http://vdirsyncer.pimutils.org/). with a CalDav server, such as [Radicale](https://radicale.org/), and a synchronization tool like [vdirsyncer](http://vdirsyncer.pimutils.org/).
Installation Installation
------------ ------------
Just copy the file to your preferred location, e.g., `~/.local/bin`, and make it executable. Download the file `fzf-vjour` from the [latest release](https://github.com/baumea/fzf-vjour/releases/latest), or run `./scripts/build.sh`, then
copy `fzf-vjour` to your preferred location, e.g., `~/.local/bin`, and make it executable.
### Requirements ### Requirements
This is a POSIX script with inline `python3` elements. This is a POSIX script with inline `awk` elements.
Make sure you have [fzf](https://github.com/junegunn/fzf), [batcat](https://github.com/sharkdp/bat), and [yq](https://github.com/mikefarah/yq) installed. Make sure you have [fzf](https://github.com/junegunn/fzf) installed.
For the `python3` code, we also require [icalendar](https://pypi.org/project/icalendar/). I also suggest to install [batcat](https://github.com/sharkdp/bat) for colorful previews.
Configuration Configuration
-------------- --------------
This application is configured with a YAML file located at `$HOME/.config/fzf-vjour/config.yaml`. This application is configured with a file located at `$HOME/.config/fzf-vjour/config`.
The entry `datadir` specifies the root directory of your journal and note entries. The entry `ROOT` specifies the root directory of your journal and note entries.
This directory may contain several subfolders, called _collections_. This directory may contain several subfolders, called _collections_.
The entry `collections` is a list, where each item specifies a subfolder, given by `name`, and a label, given by `label` (any string). The entry `COLLECTION_LABELS` is a `;`-delimited list, where each item specifies a subfolder and a label (see example below).
In the application, the user sees the collection labels instead of the collection names. In the application, the user sees the collection labels instead of the collection names.
This is particularly useful, because some servers use randomly generated names. This is particularly useful, because some servers use randomly generated names.
Finally, a third entry `sync_cmd` specifies the command to be executed for synchronizing. Finally, a third entry `SYNC_CMD` specifies the command to be executed for synchronizing.
Consider the following example: Consider the following example:
```yaml ```sh
datadir: ~/.journal ROOT=~/.journal/
sync_cmd: vdirsyncer sync journals COLLECTION_LABELS="745ae7a0-d723-4cd8-80c4-75f52f5b7d90=shared 👫🏼;12cacb18-d3e1-4ad4-a1d0-e5b209012e85=work 💼;"
collections: SYNC_CMD="vdirsyncer sync journals"
- name: 12cacb18-d3e1-4ad4-a1d0-e5b209012e85
label: 💼
- name: 745ae7a0-d723-4cd8-80c4-75f52f5b7d90
label: 🏡
``` ```
Here the files are stored in Here the files are stored in
`~/.journal/12cacb18-d3e1-4ad4-a1d0-e5b209012e85` (work-related entries) `~/.journal/12cacb18-d3e1-4ad4-a1d0-e5b209012e85` (work-related entries)
and and
`~/.journal/745ae7a0-d723-4cd8-80c4-75f52f5b7d90` (personal collection). `~/.journal/745ae7a0-d723-4cd8-80c4-75f52f5b7d90` (shared collection).
This configuration will work well with a `vdirsyncer` configuration such as This configuration will work well with a `vdirsyncer` configuration such as
```confini ```confini
@@ -59,20 +55,30 @@ path = "~/.journal"
[storage remote] [storage remote]
type = "caldav" type = "caldav"
item_types = ["VJOURNAL"] item_types = ["VJOURNAL", "VTODO"]
... ...
``` ```
Usage Usage
----- -----
Use the default `fzf` keys to navigate your notes. In addition, there are the following keybindings: Use the default `fzf` keys to navigate your notes, e.g., `ctrl-j` and `ctrl-k` for going down/up in the list.
In addition, there are the following keybindings:
| Key | Action | | Key | Action |
| --- | ------ | | --- | ------ |
| enter | Open note/journal in your `$EDITOR` | | `enter` | Open note/journal/task in your `$EDITOR` |
| ctrl-d | Delete the seleted entry | | `ctrl-alt-d` | Delete the seleted entry |
| ctrl-n | Make a new entry | | `ctrl-n` | Make a new entry |
| ctrl-r | Refresh the view | | `ctrl-r` | Refresh the view |
| ctrl-s | Run the synchronization command | | `ctrl-s` | Run the synchronization command |
| `ctrl-x` | Toggle task completion |
| `alt-up` | Increase task priority |
| `alt-down` | Decrease task priority |
| `alt-0` | Default view: Journal, notes, and _open_ tasks |
| `alt-1` | Display journal entries |
| `alt-2` | Display notes |
| `alt-3` | Display all tasks |
You may also invoke the script with `--help` to see further command-line options.
License License
------- -------

382
fzf-vjour
View File

@@ -1,382 +0,0 @@
#!/bin/sh
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")
ROOT=$(eval "echo $ROOT")
if [ ! -d "$ROOT" ]; then
echo "Root directory not set or wrongly set"
exit 1
fi
SED_COLLECTIONNAMES_TO_LABELS=$(
printf "sed "
yq '.collections[] | "s|/*" + .name + "/*|:" + .label + "\ |"' <"$CONFIGFILE" |
xargs printf "-e \"%s\" "
)
SED_COLLECTIONLABELS_TO_NAMES=$(
printf "sed "
yq '.collections[] | "s|\ *" + .label + "\ *|/" + .name + "/|"' <"$CONFIGFILE" |
xargs printf "-e \"%s\" "
)
COLLECTION_LABEL_MAX_LEN=$(yq '[.collections[].label | length] | max' <"$CONFIGFILE")
LABLES=$(yq '.collections[].label' <"$CONFIGFILE")
SYNC_CMD=$(yq '.sync_cmd' <"$CONFIGFILE")
__vjournalnew() {
python3 -c '
import sys
from datetime import date, datetime
from icalendar.cal import Calendar, Journal
if not len(sys.argv) == 2:
print("Pass uid as first argument!", file=sys.stderr)
sys.exit(1)
UID = sys.argv[1]
ical = Calendar()
j = Journal()
isnote = True
line = sys.stdin.readline().strip()
if line[:4] == "::: ":
isnote = False
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()
# Create ical
j["SUMMARY"] = summary
j.categories = categories
j["DESCRIPTION"] = description
j.LAST_MODIFIED = datetime.utcnow()
j.DTSTAMP = datetime.utcnow()
j["UID"] = UID
j["SEQUENCE"] = 1
j["STATUS"] = "FINAL"
if not isnote:
j.DTSTART = date.today()
ical.add_component(j)
ical["VERSION"] = "2.0"
ical["PRODID"] = "vjournew/basic"
# Print
print(ical.to_ical().decode().replace("\r\n", "\n"))' "$@"
}
__vjournalupdate() {
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() {
filepath="$1"
maxlen="$2"
totallen=$((maxlen + ${#ROOT}))
filepathpad=$(printf "%-${totallen}s" "$filepath")
# Color support
GREEN=$(printf '\033[1;32m')
WHITE=$(printf '\033[1;97m')
FAINT=$(printf '\033[2m')
OFF=$(printf '\033[m')
# Parse file
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
date_target=$(date -d "$dtstart" +%s)
date_today=$(date +%s)
date_delta=$(((date_target - date_today) / 86400))
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 "$dtstamp $filepath $WHITE$date_expr$OFF $date_emoji $GREEN$summary$OFF $FAINT$categories$OFF"
}
__lines() {
# Collect all files
FILES_TMP=$(mktemp)
trap 'rm -f "$FILES_TMP"' EXIT
find "$ROOT" -type f -name '*.ics' >"$FILES_TMP"
# Compute max length of basenames
maxlen=0
while IFS= read -r file; do
name=$(basename "$file")
[ ${#name} -gt "$maxlen" ] && maxlen=${#name}
done <"$FILES_TMP"
lines=$(while IFS= read -r file; do
__filepath_to_searchline "$file" "$maxlen"
done <"$FILES_TMP")
# Decorate
lines=$(echo "$lines" | eval "$SED_COLLECTIONNAMES_TO_LABELS")
# Sort and cut off irreleant part
lines=$(echo "$lines" | sort -g -r | cut -d ':' -f 2-)
echo "$lines"
}
__filepath_from_selection() {
selection=$(echo "$1" | cut -d " " -f 1,2 | eval "$SED_COLLECTIONLABELS_TO_NAMES" | sed "s|^|$ROOT/|")
echo "$selection"
}
# Program starts here
# Command line arguments to be self-contained
# Generate preview of file from selection
if [ "${1:-}" = "--preview" ]; then
vjfile=$(__filepath_from_selection "$2")
__vjournal2json <"$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 -v "$vjfile"
fi
# Generate new entry
if [ "${1:-}" = "--new" ]; then
label_new=$(echo "$LABLES" | fzf \
--margin 20% \
--prompt="Select collection> ")
uuid_new=$(uuidgen)
vjfile_new=$(__filepath_from_selection "$label_new $uuid_new.ics")
if [ -f "$vjfile_new" ]; then
echo "Bad luck..."
return 1
fi
#
TMPFILE="$(mktemp).md"
SHAFILE="$TMPFILE.sha"
trap 'rm -f "$TMPFILE" "$SHAFILE"' EXIT
{
echo "::: Keep this line if you want to add a **JOURNAL** entry (associated to today), else we will add a **NOTE**"
echo "# <write summary here>"
echo "> <comma-separated list of categories>"
echo ""
} >"$TMPFILE"
sha1sum "$TMPFILE" >"$SHAFILE"
# Open in editor
$EDITOR "$TMPFILE" >/dev/tty
# Update if changes are detected
if ! sha1sum -c "$SHAFILE" >/dev/null 2>&1; then
vjfile_tmp="$TMPFILE.ics"
trap 'rm -f "$vjfile_tmp"' EXIT
__vjournalnew "$uuid_new" <"$TMPFILE" >"$vjfile_tmp" && mv "$vjfile_tmp" "$vjfile_new"
fi
fi
if [ "${1:-}" = "--reload" ]; then
__lines
exit
fi
selection=$(
__lines | fzf --ansi \
--preview="$0 --preview {}" \
--bind="ctrl-d:become($0 --delete {})" \
--bind="ctrl-n:become($0 --new)" \
--bind="ctrl-s:execute($SYNC_CMD)" \
--bind="ctrl-r:reload-sync($0 --reload)"
)
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
__vjournal2json <"$VJ_FILE" >"$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"
echo "> $CATEGORIES"
echo ""
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!"
vj_file_new="$TMPFILE.ics"
trap 'rm -f "$vj_file_new"' EXIT
__vjournalupdate "$VJ_FILE" <"$TMPFILE" >"$vj_file_new" && mv "$vj_file_new" "$VJ_FILE"
fi
exec "$0"

11
scripts/build.sh Executable file
View File

@@ -0,0 +1,11 @@
#!/bin/sh
BOLD="\033[1m"
GREEN="\033[0;32m"
OFF="\033[m"
NAME="fzf-vjour"
SRC="./src/main.sh"
echo "🐔 ${GREEN}Building${OFF} ${BOLD}$NAME${OFF}"
sed -E 's|@@include (.+)$|cat \1|e' "$SRC" >"$NAME"
chmod +x "$NAME"
echo "🥚 ${GREEN}Done${OFF}"

41
src/awk/altertodo.awk Normal file
View File

@@ -0,0 +1,41 @@
# Increase/decrease priority, or toggle completed status
#
# If `delta` is specified using `-v`, then the priority value is increased by
# `delta.` If `delta` is unspecified (or equal to 0), then the completeness
# status is toggled.
BEGIN {
FS=":";
zulu = strftime("%Y%m%dT%H%M%SZ", systime(), 1);
delta = delta + 0; # cast as integer
}
/^END:VTODO/ && inside {
# Print sequence and last-modified, if not yet printed
if (!seq) print "SEQUENCE:1";
if (!lm) print "LAST-MODIFIED:" zulu;
# Print priority
prio = prio ? prio + delta : 0 + delta;
prio = prio < 0 ? 0 : prio;
prio = prio > 9 ? 9 : prio;
print "PRIORITY:" prio;
# Print status (toggle if needed)
bit_status = status == "COMPLETED" ? 1 : 0;
bit_toggle = delta ? 0 : 1;
percent = xor(bit_status, bit_toggle) ? 100 : 0;
status = xor(bit_status, bit_toggle) ? "COMPLETED" : "NEEDS-ACTION";
print "STATUS:" status
print "PERCENT-COMPLETE:" percent
# print rest
inside = "";
print $0;
next
}
/^BEGIN:VTODO/ { inside = 1; print; next }
/^SEQUENCE/ && inside { seq = 1; print "SEQUENCE:" $2+1; next }
/^LAST-MODIFIED/ && inside { lm = 1; print "LAST-MODIFIED:" zulu; next }
/^PRIORITY:/ && inside { prio = $2; next }
/^STATUS/ && inside { status = $2; next }
/^PERCENT-COMPLETE/ && inside { next } # ignore, we take STATUS:COMPLETED as reference
{ print }

39
src/awk/export.awk Normal file
View File

@@ -0,0 +1,39 @@
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", "\n", c[prop]);
gsub("\\\\N", "\n", c[prop]);
gsub("\\\\,", ",", c[prop]);
gsub("\\\\;", ";", c[prop]);
gsub("\\\\\\\\", "\\", c[prop]);
}
BEGIN { FS = "[:;]"; }
/^BEGIN:(VJOURNAL|VTODO)/ { type = $2 }
/^END:/ && $2 == type { exit }
/^(CATEGORIES|DESCRIPTION|SUMMARY|DUE)/ { prop = $1; content_line[prop] = $0; next; }
/^[^ ]/ && prop { prop = ""; next; }
/^ / && prop { content_line[prop] = content_line[prop] substr($0, 2); next; }
END {
if (!type) {
exit
}
# Process content lines
storetext_line(content_line, c, "CATEGORIES" );
storetext_line(content_line, c, "DESCRIPTION");
storetext_line(content_line, c, "SUMMARY" );
storetext_line(content_line, c, "DUE" );
# Print
if (c["DUE"])
print "::: <| " substr(c["DUE"], 1, 4) "-" substr(c["DUE"], 5, 2) "-" substr(c["DUE"], 7, 2);
print "# " c["SUMMARY"];
print "> " c["CATEGORIES"];
print "";
print c["DESCRIPTION"];
}

18
src/awk/get.awk Normal file
View File

@@ -0,0 +1,18 @@
# print content of field `field`
BEGIN { FS = ":"; regex = "^" field; }
/^BEGIN:(VJOURNAL|VTODO)/ { type = $2 }
/^END:/ && $2 == type { exit }
$0 ~ field { content = $0; next; }
/^ / && content { content = content substr($0, 2); next; }
/^[^ ]/ && content { exit }
END {
if (!type) { exit }
# Process content line
content = substr(content, index(content, ":") + 1);
gsub("\\\\n", "\n", content);
gsub("\\\\N", "\n", content);
gsub("\\\\,", ",", content);
gsub("\\\\;", ";", content);
gsub("\\\\\\\\", "\\", content);
print content;
}

225
src/awk/list.awk Normal file
View File

@@ -0,0 +1,225 @@
# 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-dos
# flag_completed: symbol for completed to-dos
# 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
}
# Construct path, and check for validity
depth = split(FILENAME, path, "/");
fpath = path[depth-1] "/" path[depth]
if (index(fpath, " "))
{
print 10,
"-",
"-",
RED "ERROR: file '" fpath "' contains whitespaces!" OFF
exit
}
# Collection name
collection = path[depth-1]
collection = collection in collection2label ? collection2label[collection] : collection;
# 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
priotext = "";
prio = 0;
if (c["PRIORITY"] > 0)
{
priotext = "❗(" c["PRIORITY"] ") ";
prio = 10 - c["PRIORITY"];
}
# 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"];
# 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,
fpath,
collection,
datecolor d OFF,
flag,
priotext summarycolor summary OFF,
WHITE categories OFF;
}

96
src/awk/new.awk Normal file
View File

@@ -0,0 +1,96 @@
function escape_categories(str)
{
gsub("\\\\", "\\\\", str);
gsub(";", "\\\\;", str);
}
function escape(str)
{
escape_categories(str)
gsub(",", "\\\\,", str);
}
function print_fold(nameparam, content, i, s)
{
i = 74 - length(nameparam);
s = substr(content, 1, i);
print nameparam s;
s = substr(content, i+1, 73);
i = i + 73;
while (s)
{
print " " s;
s = substr(content, i+1, 73);
i = i + 73;
}
}
BEGIN {
FS=":";
type = "VJOURNAL";
zulu = strftime("%Y%m%dT%H%M%SZ", systime(), 1);
}
desc { desc = desc "\\n" $0; next; }
{
if (substr($0, 1, 6) == "::: |>")
{
start = substr(zulu, 1, 8);
getline;
}
if (substr($0, 1, 6) == "::: <|")
{
type = "VTODO"
due = substr($0, 8);
getline;
}
summary = substr($0, 1, 2) != "# " ? "" : substr($0, 3);
getline;
categories = substr($0, 1, 1) != ">" ? "" : substr($0, 3);
getline; # This line should be empty
getline; # First line of description
desc = $0;
next;
}
END {
# Sanitize input
if (due) {
# Use command line `date` for parsing
cmd = "date -d \"" due "\" +\"%Y%m%d\"";
cmd | getline res
due = res ? res : ""
}
escape(summary);
escape(desc);
escape_categories(categories);
# print ical
print "BEGIN:VCALENDAR";
print "VERSION:2.0";
print "CALSCALE:GREGORIAN";
print "PRODID:-//fab//awk//EN";
print "BEGIN:" type;
print "DTSTAMP:" zulu;
print "UID:" uid;
print "CLASS:PRIVATE";
print "CREATED:" zulu;
print "SEQUENCE:1";
print "LAST-MODIFIED:" zulu;
if (type == "VTODO")
{
print "STATUS:NEEDS-ACTION";
print "PERCENT-COMPLETE:0";
if (due)
print "DUE;VALUE=DATE:" due;
}
else
{
print "STATUS:FINAL";
if (start)
print "DTSTART;VALUE=DATE:" start;
}
if (summary) print_fold("SUMMARY:", summary, i, s);
if (categories) print_fold("CATEGORIES:", categories, i, s);
if (desc) print_fold("DESCRIPTION:", desc, i, s);
print "END:" type;
print "END:VCALENDAR"
}

85
src/awk/update.awk Normal file
View File

@@ -0,0 +1,85 @@
function getcontent(content_line, prop)
{
return substr(content_line[prop], index(content_line[prop], ":") + 1);
}
function escape_categories(str)
{
gsub("\\\\", "\\\\", str);
gsub(";", "\\\\;", str);
}
function escape(str)
{
escape_categories(str)
gsub(",", "\\\\,", str);
}
function print_fold(nameparam, content, i, s)
{
i = 74 - length(nameparam);
s = substr(content, 1, i);
print nameparam s;
s = substr(content, i+1, 73);
i = i + 73;
while (s)
{
print " " s;
s = substr(content, i+1, 73);
i = i + 73;
}
}
BEGIN {
FS=":";
zulu = strftime("%Y%m%dT%H%M%SZ", systime(), 1);
}
ENDFILE {
if (NR == FNR)
{
# Sanitize input
if (due) {
# Use command line `date` for parsing
cmd = "date -d \"" due "\" +\"%Y%m%d\"";
cmd | getline res
due = res ? res : ""
}
escape(summary);
escape(desc);
escape_categories(categories);
}
}
NR == FNR && desc { desc = desc "\\n" $0; next; }
NR == FNR {
if (substr($0, 1, 6) == "::: <|")
{
due = substr($0, 8);
getline;
}
summary = substr($0, 1, 2) != "# " ? "" : substr($0, 3);
getline;
categories = substr($0, 1, 1) != ">" ? "" : substr($0, 3);
getline; # This line should be empty
getline; # First line of description
desc = $0;
next;
}
/^BEGIN:(VJOURNAL|VTODO)/ { type = $2; print; next }
/^X-ALT-DESC/ && type { next } # drop this alternative description
/^ / && type { next } # drop this folded line (the only content with folded lines will be updated)
/^(DUE|SUMMARY|CATEGORIES|DESCRIPTION|LAST-MODIFIED)/ && type { next } # skip for now, we will write updated fields at the end
/^SEQUENCE/ && type { seq = $2; next } # store sequence number and skip
/^END:/ && type == $2 {
seq = seq ? seq + 1 : 1;
print "SEQUENCE:" seq;
print "LAST-MODIFIED:" zulu;
if (due) print "DUE;VALUE=DATE:" due;
print_fold("SUMMARY:", summary, i, s);
print_fold("CATEGORIES:", categories, i, s);
print_fold("DESCRIPTION:", desc, i, s);
type = "";
}
{ print }

302
src/main.sh Normal file
View File

@@ -0,0 +1,302 @@
#!/bin/sh
set -eu
err() {
echo "$1" >/dev/tty
}
if [ -z "${FZF_VJOUR_USE_EXPORTED:-}" ]; then
# Read configuration
CONFIGFILE="$HOME/.config/fzf-vjour/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
# Tools
if command -v "fzf" >/dev/null; then
FZF="fzf"
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_ALTERTODO=$(
cat <<'EOF'
@@include src/awk/altertodo.awk
EOF
)
export AWK_ALTERTODO
AWK_EXPORT=$(
cat <<'EOF'
@@include src/awk/export.awk
EOF
)
export AWK_EXPORT
AWK_GET=$(
cat <<'EOF'
@@include src/awk/get.awk
EOF
)
export AWK_GET
AWK_LIST=$(
cat <<'EOF'
@@include src/awk/list.awk
EOF
)
export AWK_LIST
AWK_NEW=$(
cat <<'EOF'
@@include src/awk/new.awk
EOF
)
export AWK_NEW
AWK_UPDATE=$(
cat <<'EOF'
@@include src/awk/update.awk
EOF
)
export AWK_UPDATE
### END OF AWK SCRIPTS
FZF_VJOUR_USE_EXPORTED="yes"
export FZF_VJOUR_USE_EXPORTED
fi
__lines() {
find "$ROOT" -type f -name '*.ics' -print0 | xargs -0 -P 0 \
awk \
-v collection_labels="$COLLECTION_LABELS" \
-v flag_open="🔲" \
-v flag_completed="✅" \
-v flag_journal="📘" \
-v flag_note="🗒️" \
"$AWK_LIST" |
sort -g -r
}
# 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 <selection> Generate preview"
echo " --delete <selection> Delete selected entry"
echo " --decrease-priority <selection> Decrease priority of selected task"
echo " --increase-priority <selection> Increase priority of selected task"
echo " --toggle-completed <selection> Toggle completion flag of task"
exit
fi
# Command line arguments to be self-contained
# Generate preview of file from selection
if [ "${1:-}" = "--preview" ]; then
name=$(echo "$2" | cut -d ' ' -f 3)
file="$ROOT/$name"
awk -v field="DESCRIPTION" "$AWK_GET" "$file" |
$CAT
exit
fi
# Delete file from selection
if [ "${1:-}" = "--delete" ]; then
name=$(echo "$2" | cut -d ' ' -f 3)
file="$ROOT/$name"
summary=$(awk -v field="SUMMARY" "$AWK_GET" "$file")
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 "$file"
break
;;
"no")
break
;;
*)
echo "Please answer \"yes\" or \"no\"." >/dev/tty
;;
esac
done
fi
# Generate new entry
if [ "${1:-}" = "--new" ]; then
collection=$(echo "$COLLECTION_LABELS" | tr ';' '\n' | fzf --delimiter='=' --with-nth=2 --accept-nth=1)
file=""
while [ -f "$file" ] || [ -z "$file" ]; do
uuid=$($UUIDGEN)
file="$ROOT/$collection/$uuid.ics"
done
tmpmd=$(mktemp --suffix='.md')
{
echo "::: |> <!-- keep this line to associate the entry to _today_ -->"
echo "::: <| <!-- specify the due date for to-dos, can be empty, a date string, or even \"next Sunday\" -->"
echo "# <!-- write summary here -->"
echo "> <!-- comma-separated list of categories -->"
echo ""
} >"$tmpmd"
checksum=$(cksum "$tmpmd")
# Open in editor
$EDITOR "$tmpmd" >/dev/tty
# Update if changes are detected
if [ "$checksum" != "$(cksum "$tmpmd")" ]; then
tmpfile="$tmpmd.ics"
awk -v uid="$uuid" "$AWK_NEW" "$tmpmd" >"$tmpfile"
mv "$tmpfile" "$file"
fi
rm "$tmpmd"
fi
# Toggle completed flag
if [ "${1:-}" = "--toggle-completed" ]; then
name=$(echo "$2" | cut -d ' ' -f 3)
file="$ROOT/$name"
tmpfile=$(mktemp)
awk "$AWK_ALTERTODO" "$file" >"$tmpfile"
mv "$tmpfile" "$file"
fi
# Increase priority
if [ "${1:-}" = "--increase-priority" ]; then
name=$(echo "$2" | cut -d ' ' -f 3)
file="$ROOT/$name"
tmpfile=$(mktemp)
awk -v delta="1" "$AWK_ALTERTODO" "$file" >"$tmpfile"
mv "$tmpfile" "$file"
fi
# Decrease priority
if [ "${1:-}" = "--decrease-priority" ]; then
name=$(echo "$2" | cut -d ' ' -f 3)
file="$ROOT/$name"
tmpfile=$(mktemp)
awk -v delta="-1" "$AWK_ALTERTODO" "$file" >"$tmpfile"
mv "$tmpfile" "$file"
fi
if [ "${1:-}" = "--reload" ]; then
__lines
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
query=${query:-!✅}
query=$(echo "$query" | xargs)
selection=$(
__lines | $FZF --ansi \
--query="$query " \
--no-sort \
--no-hscroll \
--ellipsis='' \
--with-nth=4.. \
--accept-nth=3 \
--preview="$0 --preview {}" \
--bind="ctrl-r:reload-sync($0 --reload)" \
--bind="ctrl-alt-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 ; printf 'Press <enter> to continue.'; read -r tmp)"
)
if [ -z "$selection" ]; then
return 0
fi
file="$ROOT/$selection"
if [ ! -f "$file" ]; then
echo "ERROR: File '$file' does not exist!" >/dev/tty
return 1
fi
# Prepare file to be edited
filetmp=$(mktemp --suffix='.md')
awk "$AWK_EXPORT" "$file" >"$filetmp"
checksum=$(cksum "$filetmp")
# Open in editor
$EDITOR "$filetmp" >/dev/tty
# Update only if changes are detected
if [ "$checksum" != "$(cksum "$filetmp")" ]; then
file_new="$filetmp.ics"
awk "$AWK_UPDATE" "$filetmp" "$file" >"$file_new"
mv "$file_new" "$file"
fi
rm "$filetmp"
exec "$0"