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
|