-
Notifications
You must be signed in to change notification settings - Fork 4
/
Copy pathdr
executable file
·1403 lines (1240 loc) · 47.9 KB
/
dr
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
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/usr/bin/env bash
# shellcheck disable=SC2086,SC1117,SC2119
# %%% dr - Dreamdir utility program
# Copyright (c) 2015-2022 Soren Bjornstad; see LICENSE for details.
##### NOTES ON SHELLCHECK DIRECTIVES #####
# (For some stupid reason there can't be other comments between the shebang and
# the disable directives.)
# 1117: As stated on the official wiki page, this warning has been disabled in
# the next release "due to being too pedantic".
# 2086: Turn off checks for variable quoting.
# It's really too bad this has to be turned off, because it catches places
# where variables really should have been quoted but were not. Unfortunately,
# it flags passing around command-line arguments and lists of files like
# "grep 'pattern' $DREAMGLOB" as errors, and as such produces a heap of
# irrelevant warnings in this script. We could switch to using arrays
# sometime in the future.
# 2119: Turn off checks for passing no arguments to a function that seems to
# take some. This was leading to false positives all over on my POSIX
# proxies, which pass through $@ to ensure correct behavior in the future.
##### CONSTANTS #####
# application version
declare -r MYVERSION="2.2.0"
# matching pattern for dream files
declare -r DREAMGLOB='[0-9][0-9][0-9][0-9][0-9].dre'
##### DOCUMENTATION / HELP MESSAGES #####
# usagemsg()
# Print the usage/help message and exit.
usagemsg() {
cat <<USAGEMSG
dr - Dreamdir utility program
Copyright (c) 2015-2019 Soren Bjornstad; see LICENSE for details.
Usage: $(basename "$0") <action> [<action arguments>]
There are two types of actions, searches and utilities.
SEARCHES
Searches display some information about a subset of dreams. They take an
implied search expression after their other arguments; an empty search
expression selects all dreams. For information about search expressions, type
'$(basename "$0") help search'.
cat [-f] :: print all content of matching dreams on stdout; if -f,
fold lines to a maximum length of 80 characters
dump-headers :: print headers of matching dreams
edit :: pass matching dreams as args to \$EDITOR
find [-t] :: show numbers of matching dreams; with -t add total dreams
filename-display :: print filenames of matching dreams
get-header <header> :: print values of <header> for matching dreams (no line is
printed for matching dreams that have no such <header>)
header-values
[-f] <header> :: show a list of all values used for <header>
in matching dreams (include frequency if -f specified)
list-headers [-f] :: list headers used in at least one matching dream
(include frequency if -f specified)
tabulate [-rtw]
<headers> :: show a table of matching dreams with columns for each
<header>; type '$(basename "$0") help tabulate' for info
word-count
[-o <opts>] :: call 'drwc' to show word count of the specified
dreams, not including the headers; '-o' passes through
options to 'drwc', pass '-o -h' for help on options
winnow :: read filenames from stdin and write only those matching
this expression to stdout (to create AND queries)
UTILITIES
Utilities perform some other action.
act <search action> :: take <search action> on the list of filenames given on
stdin; useful for integration with other systems or at
the end of a winnow pipeline
help :: show this usage message
header-replace :: regex search-and-replace on headers; type
'$(basename "$0") help header-replace' for info
new :: create a new dream with the next available ID
number and open it for editing; see 'dr help new'
regenerate-tags :: regenerate ctags file '.dreams.ctags' in the dreamdir
check-validity :: format sanity check: ensure all dreams have
IDs and dates and ID numbers are contiguous
version :: print version number of this 'dr'
An action can be abbreviated by its initials (e.g., help: h, list-headers: lh).
'dr' operates on the current directory if it is a dreamdir (i.e., it contains a
file called '.dreamdir', contents unimportant). If the current directory is not
a dreamdir but the environment variable DREAMDIR is defined, 'dr' operates on
its value.
USAGEMSG
exit 0
}
# usagemsg_edit()
# Print the help on searching and exit.
usagemsg_edit() {
cat <<USAGEMSG
DR - SEARCHING
$(basename "$0") <action> [<options>] <expr>
Select dreams matching search expression <expr> and take <action>
(available search actions are listed under 'dr help').
SEARCH EXPRESSIONS
A search expression is one or more of the following. If multiple search
expressions are given, each search expression is evaluated in turn and the
results are concatenated.
* a dream number (leading zeroes are allowed but not required)
* a range of numbers: 12-18 or -18 (i.e., 1-18) or 12- (i.e., 12-last dream);
leading zeroes are allowed but not required
* a Bash globbing pattern for some dream numbers (leading zeroes are required)
* a[ll] :: select all dreams
* b[ack] [<N>] :: select the dream N positions from the end, inclusive
* l[ast] [<N>] :: like 'back', but select all dreams between N and the end
* d[ate] <op> <date> :: select dreams by date; <op> is 'gt', 'ge', 'lt',
'le', 'eq', or 'ne' or the usual symbolic equivalents
(>, >=, <, <=, =, !=), and <date> is in YYYY-MM-DD format
[requires GNU-compatible 'date -d' option on your system]
* g[rep] <regex> :: select all dreams matching ERE <regex> anywhere in the
file (whether in headers, notes, or text)
* t[agged] [-f] <header> <hregex> :: select dreams with <header> matching
<hregex>; with -f ("full"), select based on a standard
ERE on the whole line rather than an hregex
* r[andom] [<N>] :: select N dreams at random, presenting those selected in
numerical order (add -s if random order is desired)
N defaults to 1 if not provided.
A search expression may be entirely blank; in this case all dreams are
selected. Options are still parsed (see below).
Globbing patterns used in search expressions should be quoted (e.g., to edit
all dreams, use "dr edit '*'" , not "dr edit *"). They will be expanded either
way, but 'dr' can parse a glob more quickly than a long argument list, so
the search will finish faster if you quote the pattern. Note also that
including '.dre' is optional -- that is, '0100[34]' expands to '01003.dre
01004.dre'.
HREGEXES
<hregex>es are Extended Regular Expressions (EREs) that match only within
comma-separated items. The ^ and $ anchors can be used to match the start and
end of a single item. For example, with header "Tags: foo, bar baz, foobar":
REGEX :: MATCHES
foo :: 'foo' & 'foobar'
bar :: 'bar' & 'foobar'
baz :: 'bar baz'
az :: 'bar baz'
.*baz :: 'bar baz'
^ba.*az :: 'bar baz'
^foobar$ :: 'foobar'
^baz$ :: -
o, ba :: -
foo.*baz :: -
Padding your expression with ^ and $ is often useful; otherwise 'cat' matches
'catalog' and 'duplicate'. If you want to avoid 'catalog' but still get 'cat
food', you can pad with '\\b' (word boundary).
With 'dr find tagged', a dream matches if any comma-separated item matches
the hregex.
With 'dr header-replace', only the specific comma-separated item that matches
is replaced.
OPTIONS
The following options may be used with any search expression. They are applied
to the results in this order if several are used:
-v :: invert match (show only dreams that do *not* match expression)
-r :: reverse the order of returned results
-s :: randomize (shuffle) the order of returned results
-l <N> :: show only the first N results (if N is negative, remove N results
from the end of the list)
EXAMPLES
dr edit 50 :: edit dream number 50
dr filename-display 006[12]? :: show the filenames of dreams #610-629
dr edit last 4 890 :: edit the last four dreams and #890
dr dump-headers 20 0[12]01* :: show headers for dreams #20, #1010-1019,
#2010-2019
dr get-header Tags '*' :: show the Tags lines of all your dreams
dr edit -s last 6 :: edit the last six dreams in a random order
dr cat -l 6 grep 'foo|bar' :: print the first six dreams with a line
containing the substring "foo" or "bar"
dr cat date ge 2018-01-01 :: print all dreams from 2018 or later
dr edit -r grep 'XX' back 20 :: edit the dream 20 from the end, then dreams
containing "XX" in reverse numerical order
dr filename-display random 5 :: list the filenames of five random dreams
dr find tagged Places XYZ :: show numbers of dreams taking place at XYZ
dr edit tagged Tags '^cat$' :: edit dreams tagged with 'cat', but not
'caterwaul' or 'dedicate'
dr filename-display 610-629 | :: see a list of all the places used in dreams
dr winnow t Tags 'dog' | #610-629 that have a tag containing the
dr act header-values Places substring 'dog'
dr word-count :: display a word count of all dreams
dr wc -sl 12 all :: display the word count of 12 random dreams
dr list-headers -f 500- | :: show what headers you've used more than 50
awk '\$1 > 50' times (e.g., 'Date', 'Id', 'Tags') in dreams
500 and newer
dr wc -o -lt 50-100 | :: calculate the ratio of lucid words to total
awk '/total/{ print \$1/\$2}' words in dreams 50-100
dr fd tagged People Maud | :: copy dreams Maud appears in into the
xargs cp -t maud-dreams 'maud-dreams' folder
USAGEMSG
exit 0
}
usagemsg_header_replace() {
cat <<USAGEMSG
DR - HEADER-REPLACE
$(basename "$0") header-replace [-f] <header> <find> <replace> [<search-expr>]
In dreams matching <search-expr>, replace instances of <find> in header
<header> with <replace>. <header> and <replace> are EREs; <find> is
specifically an hregex (see 'dr help search' for more information).
<find> will not match across commas, but it may match and change several tags
separately. For example, for the header 'Tags: bar, baz', we could find 'ba'
and replace with 'fo' and obtain 'Tags: for, foz', but a search for 'r, b' will
not match anything.
Each comma-separated value is treated as a separate line for matching purposes,
so we can search for '^foobar$' to match only 'foobar' and not 'foobarbaz' in
'Tags: foobar, foobarbaz'.
PREVIEWING
Without the -f option, dr produces a preview of the changes your search-and-
replace will make. Typical output looks like this:
$ dr header-replace Tags "foo" "quux"
=== Preview of changes to be applied ===
01223.dre:
-foo
+quux
=Tags: quux, bar, baz
Changes will affect 1 file.
To make these changes, rerun the command with the '-f' option.
One section will be produced for each dream affected. The first lines
underneath are a diff between the two sets of values. The last line shows the
new header line that will be substituted in if you accept the changes.
RUNNING
After checking over the preview, rerun the command with the -f option to
actually make the changes. Naturally, this can be quite dangerous with the
wrong regexes (e.g., a replacement of '.' with 'x' will completely trash all
your headers), so it's wise to make a backup first. You'll also be required to
confirm interactively that you've double-checked the preview.
(NOTE: If the replace command gets interrupted in the middle, it is possible,
though very unlikely, that one of your dreams will have been deleted; you can
check this by seeing if 'dr check-validity' fails with a missing ID number. If
this does occur you will find the content in the file 'repl.tmp' in your
dreamdir.)
USAGEMSG
}
usagemsg_new() {
cat <<USAGEMSG
DR - NEW
$(basename "$0") new
Create a new dream file using the next unused ID number and open it in \$EDITOR.
If the dream file is unchanged when you exit your editor, it will be deleted.
CONFIGURATION
The template by default contains Id, Date, and Tags headers, with Id and Date
autofilled. You can customize the template by creating a .dream_template file
in the root of your dreamdir. This file will be copied to create a new dream.
You can include the following variables in your template:
\$id :: will be replaced with the ID number of the new dream (the same as the
filename without the extension)
\$today :: today's date in YYYY-MM-DD format
USAGEMSG
}
usagemsg_tabulate() {
cat <<USAGEMSG
DR - TABULATE
$(basename "$0") tabulate [-rtw] <comma-separated-headers> [<search-expr>]
Print a table of matching dreams, with one row for each dream and one column for
each of the <comma-separated-headers> supplied.
OPTIONS
-r :: Raw mode: separate columns with hard tab characters, rather than
automatically sizing columns and filling with spaces. (This is
useful if you want to pipe the output to another program or copy it
into a spreadsheet.)
-t :: Truncate excessively long cells so that the table is no wider than your
terminal.
-w :: Wrap excessively long cells onto multiple lines so that the table is no
wider than your terminal.
Only one of these options makes sense at a time; if more than one is specified,
the one highest in the list above wins.
The options -t and -w are only supported if the 'column' utility is installed on
your system and knows how to perform the relevant formatting tasks (this is
not true on macOS's version, for instance).
$(if hash column 2>/dev/null && column --version >/dev/null 2>&1; then
echo -e "** This system $(tput setaf 2)SUPPORTS$(tput sgr0) the -t and -w options. **";
else
echo "** This system $(tput setaf 1)DOES NOT SUPPORT$(tput sgr0) the -t and -w options. **";
fi)
CONFIGURATION
By default, the "Id" and "Date" columns will not be truncated or wrapped when
-t or -w is used. You can customize this behavior by creating a
.unwrappable_headers file in your dreamdir containing a list of header names
that are not allowed to wrap, one per line.
EXAMPLES
dr tabulate Id,Date,Title l 100
:: Print a table of the ID numbers, dates, and titles of the last 100
dreams recorded.
dr tabulate -w Id,Title,People,Places t Tags travel
:: Print a table of the ID numbers, titles, people and places of dreams
tagged with 'travel', wrapping lines as needed.
dr tabulate -r Id,Date,Title,People,Places |
awk -F $'\t' 'patsplit(\$4, arr, /,/) == 1 { print \$0 }' |
column -ts $'\t'
:: Print a table of the ID numbers, dates, titles, people and places of
dreams which tag precisely two people (i.e., the People header contains
exactly one comma). Note the use of '-r', followed by manually
reformatting the table, so 'awk' can tell where the columns begin and
end.
USAGEMSG
}
##### UTILITY FUNCTIONS #####
# die()
# Print arguments to stderr and exit the shell with the last exit code
# (or 1 if the last exit code is 0).
#
# Arguments:
# $1 - Message to complain with.
die() {
ret=$?
if [ $ret -eq 0 ]; then
ret=1
fi
printf "%b\n" "$@" >&2
exit $ret
}
# trim()
# Remove leading and trailing whitespace from a variable.
# https://stackoverflow.com/a/3352015
#
# Arguments:
# $* - the string to trim
#
# Output:
# the trimmed string
trim() {
local str="$*"
# remove leading whitespace characters
str="${str#"${str%%[![:space:]]*}"}"
# remove trailing whitespace characters
str="${str%"${str##*[![:space:]]}"}"
echo -n "$str"
}
# numerize()
# Display some string in singular or plural as appropriate.
#
# Arguments:
# $1 - the number to condition the singular/plural status on
# $2 - the singular form
# $3 - the plural form (optional; if not provided, defaults to singular + 's')
#
# Example:
# $ echo "I think there $(numerize $n "is" "are") $n $(numerize $n "foo") here."
# I think there is 1 foo here.
# I think there are 2 foos here.
numerize() {
[ -n "$1" ] || die "Invalid arguments given to numerize()"
[ -n "$2" ] || die "Invalid arguments given to numerize()"
local num=$1
local singular=$2
if [ -z "$3" ]; then
local plural="$singular"s
else
local plural=$3
fi
if [ "$num" -ne 1 ]; then
echo "$plural"
else
echo "$singular"
fi
}
# defileify()
# Given some dream filenames, convert them to comma-separated numbers, with no
# leading zeroes, and print the result.
#
# Arguments:
# (variable number) - the files to process
defileify() {
sed -Ee 's/(^|[[:space:]])00*/\1/g' -e 's/\.dre/,/g' -e 's/,$//' <<<"$@"
}
# colorify()
# Given a string, print the string with "syntax highlighting" for dreamdir
# syntax.
#
# Argument:
# $1 - the string to color
colorify() {
awk -f <(cat - <<ENDSCRIPT
function pop_stack (stack, len, item) {
item = stack[len]
delete stack[len]
return item
}
function push_stack (stack, item, len) {
stack[len+1] = item
return len+1
}
BEGIN {
col_headers = "\033[0;32m"
col_lucid = "\033[0;34m"
col_notes = "\033[0;36m"
col_verbatim = "\033[0;31m"
col_clear = "\033[0m"
color_stack[0] = ""
stack_len = 0
cur_color = col_clear
in_backtick = 0
}
{ UNHANDLED = 1 }
/:\t/ {
split(\$0, arr, ":\t")
printf("%s%s%s:\t%s\n", col_headers, arr[1], col_clear, arr[2])
UNHANDLED = 0
}
UNHANDLED && /^[^\[\]{}\`]*$/ {
print \$0
UNHANDLED = 0
}
UNHANDLED {
mlen = length(\$0)
for (i = 1; i <= mlen; i++) {
char = substr(\$0, i, 1)
if (char == "[") {
stack_len = push_stack(color_stack, cur_color, stack_len)
cur_color = col_notes
printf("%s%s", cur_color, char)
} else if (char == "{") {
stack_len = push_stack(color_stack, cur_color, stack_len)
cur_color = col_lucid
printf("%s%s", cur_color, char)
} else if (char == "\`" && !in_backtick) {
stack_len = push_stack(color_stack, cur_color, stack_len)
cur_color = col_verbatim
printf("%s%s", cur_color, char)
in_backtick = 1
} else if (char == "]") {
printf("%s", char)
cur_color = pop_stack(color_stack, stack_len--)
printf("%s", cur_color)
} else if (char == "}") {
printf("%s", char)
cur_color = pop_stack(color_stack, stack_len--)
printf("%s", cur_color)
} else if (char == "\`" && in_backtick) {
printf("%s", char)
cur_color = pop_stack(color_stack, stack_len--)
printf("%s", cur_color)
in_backtick = 0
} else {
printf("%s", char)
}
}
printf("\n")
}
END {
printf("%s", col_clear)
}
ENDSCRIPT
) </dev/stdin
}
# getrange()
# Given a range of dreams in the form Range ::= Number{*}@Number{*}, print
# a string of the filenames of dreams in that range (inclusive). If one of the
# numbers is missing, the first or last dream in the dreamdir is implied; '@'
# as a range is equivalent to all dreams.
#
# The @ will probably be input as something more sensible like '-'; we just use
# it as an internal separator because it doesn't conflict with command-line
# options.
#
# Called in several places in dreamfind().
#
# Argument:
# $1 - the range to find
#
# Example:
# $ echo "Filename list: $(getrange 4-8)"
# 00004.dre 00005.dre 00006.dre 00007.dre 00008.dre
getrange() {
[ -n "$1" ] || die "Invalid arguments given to getrange()"
local startat; local endat
startat=${1%@*}
endat=${1#*@}
if [ -z "$startat" ]; then
dreamfind 1
startat=$(defileify $args)
fi
if [ -z "$endat" ]; then
dreamfind last
endat=$(defileify $args)
fi
[ -n "$startat" ] || die "No results."
[ -n "$endat" ] || die "No results."
# seq is marginally faster, but we can work around it if unavailable
if hash seq 2>/dev/null; then
seq -f '%05g.dre' "$startat" "$endat"
else
for ((i=startat; i<=endat; ++i)); do
printf "%05d.dre\n" $i
done
fi
}
# drwordcount()
# Call an appropriate program to return a word count.
#
# Arguments:
# $1 - string containing arguments to drwc
# $2 - space-separated list of dream filenames to pass to drwc
# shellcheck disable=2068
# :: we want to split everything here, as we're just breaking it out to drwc
drwordcount() {
hash 'drwc' 2>/dev/null || die "Please install drwc to use the word count function."
drwc $1 $2
}
##### COMPLICATED ROUTINES #####
# dreamfind()
# Parse a search expression. The filenames of matching dreams are placed in the
# variable $args.
#
# This function may be used either to parse user input or to select dreams
# matching some pattern from within the script.
#
# Arguments:
# (variable number) A search expression, as input at the command line.
dreamfind() {
# Before parsing optional arguments, we have to change the delimiter used
# for ranges, because getopts isn't smart enough to see that "-20" is a
# unidirectional range from 1-20, not two arguments "2" and "0".
# http://unix.stackexchange.com/questions/258512/how-to-remove-a-positional-parameter-from
for arg; do
shift
if [[ $arg =~ ^[0-9]*-[0-9]*$ ]]; then
newarg="${arg//-/@}"
else
newarg="$arg"
fi
set -- "$@" "$newarg"
done
# Parse optional arguments to edit.
df_doReverse=0 # global because a user that has to sort the results
df_doShuf=0 # for further processing has to reapply these filters
local limit=0
local invert=0
OPTIND=0 # if loop doesn't run at all, we need to make sure it's reset
while getopts :rsl:v opt; do
case $opt in
r)
df_doReverse=1 ;;
s)
df_doShuf=1 ;;
l)
limit="$OPTARG"
[[ $limit =~ ^[0-9]+$ ]] || die "'$limit' is not a valid number of dreams to limit your search to."
;;
v)
invert=1 ;;
*)
die "Invalid option (-rslv are valid; see 'dr help search')."
esac
done
shift $((OPTIND-1))
# No arguments is equivalent to "all".
if [ -z "$1" ]; then
# http://stackoverflow.com/questions/13762370/
# assigning-to-a-positional-parameter
set -- "all"
fi
# Now compile a list of all the dreams we've specified.
args=""
local newargs=""
local howMany=0
while [ -n "$1" ]; do
if [[ $1 =~ ^[0-9][0-9]*$ ]]; then
local num=$((10#$1))
printf -v newargs '%05d.dre' "$num"
elif [[ $1 =~ ^[0-9]*@[0-9]*$ ]]; then
newargs=$(getrange "$1")
else case "$1" in
"last"|"back"|"l"|"b")
local action=$1
if [[ -n "$2" && $2 =~ ^[0-9]+$ ]]; then
howMany="$2"
shift
else
howMany=1
fi
newargs=$(find $DREAMGLOB | tail -n "$howMany")
if [[ "$action" = "back" || "$action" = "b" ]]; then
newargs=$(head -n 1 <<<"$newargs")
fi
;;
"grep"|"g")
[ -n "$2" ] || die "'grep' needs a pattern. (See 'dr help search' for help.)"
newargs=$(grep -El "$2" $DREAMGLOB)
shift
;;
"random"|"r")
if [[ -n "$2" && $2 =~ ^[0-9]+$ ]]; then
howMany=$2
shift
else
howMany=1
fi
newargs=$(find $DREAMGLOB | shufw | head -n "$howMany" | sort)
;;
"all"|"a")
newargs=$DREAMGLOB
;;
"date"|"d")
[ -n "$2" ] || die "oops"
[ -n "$3" ] || die "oops"
operator=$2
dateExpr=$3
myDate=$(date '+%Y-%m-%d' -d "$dateExpr" 2>/dev/null) || die "Invalid date expression '$dateExpr', or no GNU 'date' on this system. If you have GNU 'date', try a YYYY-MM-DD format, or a phrase like 'today', 'last Monday', or 'June 7'."
case $operator in
'gt'|'>') awkop='>' ;;
'lt'|'<') awkop='<' ;;
'ge'|'>=') awkop='>=' ;;
'le'|'<=') awkop='<=' ;;
'eq'|'='|'==') awkop='==' ;;
'ne'|'!='|'<>') awkop='!=' ;;
*) die "Invalid operator!" ;;
esac
newargs=$(awk "/Date: / { if (\$2 $awkop \"$myDate\") { print FILENAME } }" $DREAMGLOB)
shift 2
;;
"tagged"|"t")
local usage="Usage: dr $action tagged [-f] <header regex> <value regex>"
[ -n "$2" ] || die "$usage"
[ -n "$3" ] || die "$usage"
shift # remove 'tagged' subcommand
local df_t_full=0
OPTIND=0
while getopts :f opt; do
case $opt in
f) df_t_full=1 ;;
*) die "Invalid option (-f is valid; see 'dr help search')."
esac
done
shift $((OPTIND-1))
local header=$1
local searchpat=$2
if [ $df_t_full -eq 1 ]; then
# Treat ^ and $ as the end of the header field. If these are
# not used, add .* to simulate the substring match you would
# get if the $header: constraint weren't out front.
local newsearchpat=$searchpat
if [ ${searchpat:0:1} = "^" ]
then newsearchpat="${searchpat:1}"
else newsearchpat='.*'"${searchpat}"
fi
if [ ${searchpat: -1} = "$" ]
then newsearchpat="${newsearchpat:0: -1}"
else newsearchpat="${newsearchpat}"'.*'
fi
newargs=$(grep -E "$header: $newsearchpat" $DREAMGLOB |
sed -e 's/\([0-9]\{5\}\.dre\):.*/\1/')
else
# Comma-separated match. The idea here is to replace the
# delimiters with '@@@@@' (which we can fairly safely assume
# will not show up in a tag), then change the user's regex to
# match '@@@@@' as BOL/EOL and not cross it with '.'.
local pattern
pattern=$(sed -e 's/[$^]/@@@@@/' -e 's/\$/[$@]/' -e 's/\./[^@]/' <<<"$searchpat")
newargs=$(grep "$header: " $DREAMGLOB |
sed -e "s/$header: /@@@@@/" -e 's/, /@@@@@/g' -e 's/$/@@@@@/' |
grep -E "[0-9]{5}\.dre:.*$pattern" |
sed -e 's/\([0-9]\{5\}\.dre\):.*/\1/')
fi
# we want to leave one argument since we already shifted away
# "tagged" and the main loop shifts one argument for the subcommand
shift
;;
*) # filename glob
if [[ $1 = *".dre" ]]; then
newargs="$1"
else
newargs="$1.dre"
fi
esac
fi # regex matches
# Add args parsed to the complete list and proceed. Note that if a type
# of expression has arguments, it's responsible for shifting those away
# before it finishes.
args="$args $newargs"
shift
done
# apply option flags
# shellcheck disable=2086,2116
# :: Yes, this looks dumb. It expands any globs the string contains.
args=$(echo $args)
if [ $invert -eq 1 ]; then
args=$(comm -3 <(find $DREAMGLOB | tr ' ' '\n' | sort) <(tr ' ' '\n' <<<"$args" | sort))
fi
[ $df_doReverse -eq 1 ] && args=$(tr ' ' '\n' <<<"$args" | tacw)
[ $df_doShuf -eq 1 ] && args=$(tr ' ' '\n' <<<"$args" | shufw)
[ $limit -ne 0 ] && args=$(tr ' ' '\n' <<<"$args" | head -n $limit)
# sanity check
[ -z "$args" ] && die "No results."
for i in $args; do
[ -f "$i" ] || die "Matched nonexistent file '$i'; please check your search terms and try again.\n(Type 'dr help search' for help.)"
done
}
# newdream()
# Create a new dream file with the next unused number and a default template
# and open it in the user's editor.
newdream() {
local today; local lastNum; local newNum
today=$(date '+%Y-%m-%d')
lastNum=$(find . -maxdepth 1 -name "$DREAMGLOB" | sort | tail -n 1 | cut -c 3-7)
if [ -z "$lastNum" ]; then
# initializing dreamdir
lastNum=0
elif [ ! -f "$lastNum.dre" ]; then
die "Unable to correctly find last dream number. Please ensure no non-dream files in the dreamdir end with '.dre'. (Pulled '$lastNum'.)"
fi
newNum=$((10#$lastNum + 1))
printf -v newNum '%05d' "$newNum"
if [ -f ".dream_template" ]; then
cp ".dream_template" "$newNum.dre"
else
# http://unix.stackexchange.com/questions/88490/how-do-you-use-output-redirection-in-combination-with-here-documents-and-cat
cat <<NEWDREAM >"$newNum.dre" 2>&1
Id: \$id
Date: \$today
Tags:
NEWDREAM
fi
sedi "s/\\\$id/$newNum/" "$newNum.dre"
sedi "s/\\\$today/$today/" "$newNum.dre"
cp "$newNum.dre" "/tmp/$newNum.dre"
if [ "$EDITOR" = "vim" ]; then
# use vim's option to place the cursor on the last line
vim "$newNum.dre" +
else
"$EDITOR" "$newNum.dre"
fi
if diff "/tmp/$newNum.dre" "$newNum.dre" > /dev/null 2>&1; then
echo "Deleting unmodified dream file."
rm "$newNum.dre"
else
chmod 600 "$newNum.dre" # dreams default to 600 for basic privacy
fi
rm "/tmp/$newNum.dre"
}
# header_replace()
# Perform a search-and-replace in headers or other tags. Accepts $@ as arguments.
header_replace() {
local usageMsg
usageMsg="Usage: $(basename "$0") header-replace [-f] <header> <search> <replace> <search-expr>\nType 'dr help header-replace' for details."
[[ -n "$1" && -n "$2" && -n "$3" ]] || die "$usageMsg"
[[ "$2" =~ @ || "$3" =~ @ ]] && die "Sorry, at-signs (@) are not valid in search/replace regexes."
if [ "$1" = "-f" ]; then
local doChange=1
shift
else
local doChange=0
fi
local header="$1"
local searchpat="$2"
local replpat="$3"
shift 3
# Since the replace loop is rather expensive, compile a list of dreams that
# both match our search-expr and include the <search> hregex, and look only
# at those.
dreamfind "$@"
local affectOnlyDreams="$args"
dreamfind tagged "$header" "$searchpat"
args=$(comm -12 <(tr ' ' '\n' <<<"$affectOnlyDreams") \
<(tr ' ' '\n' <<<"$args"))
# If we're actually making changes, confirm that we really want to.
local numToChange
if [ $doChange -eq 1 ]; then
numToChange=$(trim "$(wc -w <<<"$args")")
echo "You are about to apply a search-and-replace that will affect" \
"$numToChange $(numerize "$numToChange" dream)."
if [ $numToChange -ge 50 ]; then
echo -e "\e[1;31mThat looks like quite a few dreams.\e[0m"
fi
echo "If this doesn't sound right, please check the results without -f first!"
read -rp "Do you wish to continue (y/n)? " doCont
if [ "$doCont" != "y" ]; then
exit 0
fi
else
echo " === Preview of changes to be applied ==="
fi
local numAffected=0
for filename in $args; do
local headerline; local oldtags; local newtags; local diffs
headerline=$(grep "$header: " "$filename")
# shellcheck disable=SC1004
# :: we actually do want a break within the single-quoted string for sed
oldtags=$(sed -e "s/$header: //" -e 's/, /\
/g' <<<"$headerline")
newtags=$(sed -Ee "s@$searchpat@$replpat@g" <<<"$oldtags")
diffs=$(diff -u <(echo "$oldtags") <(echo "$newtags") | grep '^[+-]' | sed -e '1,2d' -e 's/^/ /')
if [ -z "$diffs" ]; then
continue
fi
local replwith
replwith="$header: $(tr '\n' ',' <<<"$newtags" | sed -e 's/,/, /g' -e 's/, $//')"
if [ $doChange -eq 1 ]; then
sed -e "s@$headerline@$replwith@" "$filename" > repl.tmp
rm "$filename"
mv repl.tmp "$filename"
echo "$filename modified"
else
echo -e "$filename:\n$diffs\n =$replwith\n"
fi
numAffected=$((numAffected+1))
done
if [ $doChange -eq 1 ]; then
echo "Changed $numAffected $(numerize $numAffected file)."
else
echo "Changes will affect $numAffected $(numerize $numAffected file)."
echo "To make these changes, rerun the command with the '-f' option."
fi
}
# validate_dreamdir()
# Check to see if our dreamdir's format is okay.
validate_dreamdir() {
# Define portable version of gawk's ENDFILE, adapted from the gawk manual.
read -r -d '' endfileCode <<AWKSCRIPT
FILENAME != _oldfilename {
if (_oldfilename != "")
endfile(_oldfilename)
_oldfilename = FILENAME
}
END { endfile(FILENAME) }
AWKSCRIPT
# Accumulate errors.
local errors=()
# Check for empty dreamfiles. Awk will completely pass over them, so it's
# important to ensure this is correct first.
mapfile errors < <(find $DREAMGLOB -size 0 | sed -e 's/$/: File is empty/')
# Check for missing ID field.
mapfile -O "${#errors[@]}" errors < <(awk -f - <<AWKSCRIPT $DREAMGLOB
$endfileCode
BEGIN {
FS = ": "
idFound = 0
}
/Id: [0-9]{5}/ {
# ID header doesn't match the filename
if (FILENAME != (\$2 ".dre")) {
print FILENAME ": ID header (" \$2 ") does not match filename"
}
idFound = 1
nextfile
}
function endfile(name) {
if (!idFound)
print name ": ID header is missing"
idFound = 0
}
AWKSCRIPT
)
# Check for obviously invalid or missing date field.
mapfile -O "${#errors[@]}" errors < <(awk -f - <<AWKSCRIPT $DREAMGLOB
$endfileCode
BEGIN {
FS = ": "
dateFound = 0
}
/^Date: [12][901][0-9]{2}-[01][0-9]-[0123][0-9]/ {
dateFound = 1
nextfile
}
/^Date: / {
dateFound = 1
print FILENAME ": Invalid date header " \$2 " (wanted YYYY-MM-DD format)"
}
function endfile(name) {
if (!dateFound)
print name ": Date header is missing"
dateFound = 0
}
AWKSCRIPT
)
# Check for gaps in ID numbering.
mapfile -O "${#errors[@]}" errors < <(find $DREAMGLOB |
awk 'BEGIN { FS = "."; num = 1 } { if (num != $1+0) { print $0 ": an ID number was skipped before this file" }; num=$1+1 }')
# Print out the results.
if [ "${#errors[@]}" -eq 0 ]; then
echo "OK"
exit 0
else
# Group by ID number but keep sort stable so issues are shown
# in the order defined above within each ID number.
printf "%s" "${errors[@]}" | sort -s -t':' -k1
exit 1
fi
}
# regenerate_tags()
# Generate a ctags file for dreams. All header values are included.
regenerate_tags() {
hash 'python3' 2>/dev/null || die "Please install 'python3' to use regenerate-tags."
python3 <<PYTHONSCRIPT
import os
DREAMDIR = os.environ['DREAMDIR']
OUTPUTFILE = os.path.join(DREAMDIR, '.dreams.ctags')
def get_headers():
dreams = {}
for dreamfile in (i for i in os.listdir(DREAMDIR)
if i.endswith('.dre')):
with open(os.path.join(DREAMDIR, dreamfile)) as f:
dream = {}
for line in f:
if not line.strip():