#!/bin/bash # (c) 2019-2020 Vitaly Parnas # 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 + tag as the entire output is piped to echo #alias jrnl_select_tags="jrnl --tags | awk '{print \$1}' | dmenu | xargs echo" function usage { cat <, -ls|--list: list all available journals -n|--max-entries : limit query to the last n entries, -s|--search : search for records containing . Can combine for a conjunctive search. -S|--search-all : search all journals for records containing . 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