diff options
Diffstat (limited to 'jrnl')
-rwxr-xr-x | jrnl | 262 |
1 files changed, 262 insertions, 0 deletions
@@ -0,0 +1,262 @@ +#!/bin/bash +# (c) 2019-2020 Vitaly Parnas <vp330@parnas.me> +# See LICENSE for licensing information. + +#TAG_COLOR="00;36" +TIMEFORMAT="%Y-%m-%d %H:%M" +RECORD_START_PAT='[0-9]{4}-[0-9]{2}-[0-9]{2}\s[0-9]{2}:[0-9]{2}' + +# The 'tac' utility doesn't support perl-compatible regex +TAC_RECORD_START_PAT='[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]\s[0-9][0-9]:[0-9][0-9]' + +declare -A JOURNALS=( + ['default']="$HOME/journals/journal.txt" +) + +[ -f "$JRNL2_CFG" ] && CFG_FILE="$JRNL2_CFG" || CFG_FILE="$HOME/.jrnl2.rc" +[ -f "$CFG_FILE" ] && . "$CFG_FILE" + +[ -z $DEFAULT_JRNL ] && DEFAULT_JRNL='default' +JOURNAL="${JOURNALS[$DEFAULT_JRNL]}" +[ -z "$JOURNAL" ] && echo "Journal '$DEFAULT_JRNL' not found" && exit 1 +[ ! -f "$JOURNAL" ] && echo "Cannot open $JOURNAL" && exit 1 + +# Pass disjunctive arguments in regex (ie 'pat1|pat2' or '@(tag1|tag2)' +# Pass conjunctive arguments as separate search -s operations, which are passed as separate arguments here + # awk '/foo/ && /bar/' + # grep -P '(?=.*?word1)(?=.*?word2)(?=.*?word3)^.*$' +function jrnl_entry_search +{ + short_srch="$1"; shift + reverse="$1"; shift + max_entries="$1"; shift + tags_only="$1"; shift + case_sens="$1"; shift + + $short_srch && instr='P' || instr='p' + $case_sens && mod='' || mod='I' + # case-insensitive match + search_cmd='sed -nr "/^'$RECORD_START_PAT'/{x;/./$instr;d; \${x;/./$instr;d}}; \${x;G;/./$instr}; {H}"' + while [ -n "$1" ]; do + pattern="$1" + search_cmd=$(sed -r 's/(\$instr)/{\/'"$pattern"'\/\$mod\1}/g' <<< "$search_cmd") + shift + done + if $tags_only; then + cmd_suffix+=" | list_tags" + else + $reverse && + cmd_suffix+=" | tac -s '^$TAC_RECORD_START_PAT' --before --regex" + [ -n "$TAG_COLOR" ] && + cmd_suffix+=" | GREP_COLORS='mt=$TAG_COLOR' grep --color -e '' -e '@\w\+'" + fi + if [ $max_entries -gt 0 ]; then + # Output entries in --short (1-line) mode to establish the start entry for the search space + prev_instr=$instr + instr='P' + start_line=$(eval "$search_cmd $JOURNAL | tail -$max_entries | head -1") + instr=$prev_instr + [ -z "$start_line" ] && return + # Use line number as start search space to avoid unescaped char issues. If completely duplicate headers exist, will choose the last as start space. + line_num=$(grep -nF "$start_line" $JOURNAL | awk -F':' '{print $1}' | tail -1) + sed -n "$line_num,\$p" "$JOURNAL" | eval "$search_cmd $cmd_suffix" + else + eval "$search_cmd $JOURNAL $cmd_suffix" + fi +} + +function jrnl_entry_edit +{ + max_entries="$1"; shift + orig=$(jrnl_entry_search false false $max_entries false $case_sens "$@") + [ -z "$orig" ] && echo "Nothing found by that search" && return + tmpfile=$(mktemp --tmpdir jrnl-edit.XXXXXX) + trap 'rm "$tmpfile"' 0 1 15 + echo "$orig" > "$tmpfile" + sed -i -r 's/^'"$RECORD_START_PAT"'.*/== MODIFY\/DELETE RECORD BELOW. DO NOT DELETE THIS LINE. \0 ==\n\n\0/' $tmpfile + $EDITOR $tmpfile + echo "$orig" | diff -q -BZ -I '==.*==' - $tmpfile \ + >/dev/null && echo "No changes" && return + num_edited=0 + num_deleted=0 + num_intact=0 + headers=$(sed -nr "s/^==.*($RECORD_START_PAT.*) ==$/\1/p" $tmpfile) + while read -r header; do + # Escape a bunch of characters conflicting with sed + header=$(sed -r 's/([?*+-/(){}|^$\\]|\[|\])/\\\1/g' <<< "$header") + old_record=$(sed -rn "/^$header/,/^$RECORD_START_PAT/{//!p;/^$header/p}" <<< "$orig") + new_record=$(sed -rn "/^==.*$header/,/^==/{//!{/^$RECORD_START_PAT/,\$p}}" "$tmpfile") + #echo -e "old:\n$old_record" + #echo -e "new:\n$new_record" + if [ "$old_record" = "$new_record" ]; then + (( num_intact++ )) + continue + fi + if [ -n "$new_record" ]; then + tmpjrnl=$(mktemp --tmpdir jrnl-temp.XXXXXX) + ( # Combine the section above entry + entry + section below + sed -rn '/^'"$header"'/,$!p' $JOURNAL + echo -e "$new_record""\n" + sed -r "1,/^$header/d" $JOURNAL | + sed -rn "/^$RECORD_START_PAT"'/,$p' + ) > "$tmpjrnl" + mv "$tmpjrnl" "$JOURNAL" + (( num_edited++ )) + else # delete old record + sed -i -nr "/^$header/,/$RECORD_START_PAT/{//!d;/^$header/d};p" $JOURNAL + (( num_deleted++ )) + fi + done <<< "$headers" + [[ $num_edited -gt 0 ]] && echo "Edited $num_edited entries" + [[ $num_deleted -gt 0 ]] && echo "Deleted $num_deleted entries" + [[ $num_intact -gt 0 ]] && echo "Intact $num_intact entries" +} + +# Adds entry in preference of +# 1. stdin +# 2. argument +# 3. Edited in $EDITOR +function jrnl_entry_add +{ + # First, try stdin + entry=$( + while read -rt .01 stdin; do + echo "$stdin" + done + ) + if [[ -z "$entry" ]]; then + if [[ -n $1 ]]; then + entry="${@:1}" + else + tmpfile=$(mktemp --tmpdir jrnl-new.XXXXXX) + trap 'rm "$tmpfile"' 0 1 15 + $EDITOR $tmpfile + entry=$(cat $tmpfile) + #rm $tmpfile + fi + fi + if [[ -n "$entry" ]]; then + filter='1s/\.\s\+/.\n/' # Separate the first sentence from rest by a NL. + sed -rn "1{/^$RECORD_START_PAT/q0};q1" <<< "$entry" && + prefix="" || prefix="$(date "+$TIMEFORMAT") " + echo -e "$prefix""$(sed "$filter" <<< "$entry")\n" >> "$JOURNAL" + echo "1 new entry written" + else + echo "Journal unmodified" + fi +} + +# List all tags within provided journal file or otherwise stdin +function list_tags { + [[ -n $1 ]] && input="$1" || input="-" + grep -E -o '(^|\s)@[_[:alnum:]\-]+' "$input" | sed 's/^\s*//' | sort -fib | uniq -ci | sort -nr +} + +# +## The stdout isn't immediately visible with each selected <ctrl>+<enter> tag as the entire output is piped to echo +#alias jrnl_select_tags="jrnl --tags | awk '{print \$1}' | dmenu | xargs echo" + +function usage { + cat <<EOF +${0##*/}: + -h|--help: help text, + -j|--journal <alternate journal (use -ls to list)>, + -ls|--list: list all available journals + -n|--max-entries <n>: limit query to the last n entries, + -s|--search <pattern>: search for records containing <pattern>. Can combine for a conjunctive search. + -S|--search-all <pattern>: search all journals for records containing <pattern>. Cannot combine with --edit. + -c|--case: case-sensitive search (insensitive by default) + -e|--edit: edit/delete the searched entries + -t|--tags: list of existing tags + -r|--short: view entry headings only + -R|--reverse: view entries in reverse +EOF +} + +function assert_args() { + cmd="$1"; shift + [[ $# -lt 1 || $1 =~ ^\- ]] && echo "$cmd needs an argument" && exit 128 +} + +short_srch=false +max_entries=0 +tags_only=false +case_sens=false +edit=false +search_all=false +reverse=false +search_args=() +options_passed=false + +while [[ -n $1 ]]; do + case $1 in + -j|--journal) + shift + assert_args "journal" "$@" + if [ -n "${JOURNALS[$1]}" ]; then + JOURNAL="${JOURNALS[$1]}" + else + echo "Journal $1 undefined." + exit 1 + fi + ;; + -t|--tags) + options_passed=true + tags_only=true;; + -c|--case) + options_passed=true + case_sens=true;; + -ls|--ls|--list) + for j in "${!JOURNALS[@]}"; do echo "$j"; done + exit 0;; + -r|--short) + options_passed=true + short_srch=true;; + -R|--reverse) + options_passed=true + reverse=true;; + -s|--search) + options_passed=true + shift + assert_args "search" "$@" + search_args+=("$1") + ;; + -S|--search-all) + options_passed=true + shift + assert_args "search-all" "$@" + search_args+=("$1") + search_all=true + ;; + -e|--edit) + options_passed=true + edit=true;; + -n|--max-entries) + options_passed=true + shift + assert_args "max_entries" "$@" + max_entries=$1 + ;; + -h|--help|-*) + usage + exit 0;; + *) break;; + esac + shift +done + +if ! $options_passed; then + jrnl_entry_add "$@" +elif $search_all; then + for jrnl_label in "${!JOURNALS[@]}"; do + JOURNAL=${JOURNALS[$jrnl_label]} + echo "=== $jrnl_label ===" + jrnl_entry_search $short_srch $reverse $max_entries $tags_only $case_sens "${search_args[@]}" + done +elif $edit; then + jrnl_entry_edit $max_entries "${search_args[@]}" +elif $tags_only && [ -z "$search_args" ] && [ $max_entries == 0 ]; then + list_tags "$JOURNAL" +else + jrnl_entry_search $short_srch $reverse $max_entries $tags_only $case_sens "${search_args[@]}" +fi |