summaryrefslogtreecommitdiff
path: root/jrnl
blob: ff8e9a94ed85fa0b8254900c92cc10710ad17d7a (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
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