1#!/usr/local/bin/bash
2
3# === HEAVY LIFTING ===
4shopt -s extglob extquote
5
6# NOTE:  Todo.sh requires the .todo/config configuration file to run.
7# Place the .todo/config file in your home directory or use the -d option for a custom location.
8
9[ -f VERSION-FILE ] && . VERSION-FILE || VERSION="2.12.0"
10version() {
11    cat <<-EndVersion
12		TODO.TXT Command Line Interface v$VERSION
13
14		Homepage: http://todotxt.org
15		Code repository: https://github.com/todotxt/todo.txt-cli/
16		Contributors: https://github.com/todotxt/todo.txt-cli/graphs/contributors
17		License: https://github.com/todotxt/todo.txt-cli/blob/master/LICENSE
18	EndVersion
19    exit 1
20}
21
22# Set script name and full path early.
23TODO_SH=$(basename "$0")
24TODO_FULL_SH="$0"
25export TODO_SH TODO_FULL_SH
26
27oneline_usage="$TODO_SH [-fhpantvV] [-d todo_config] action [task_number] [task_description]"
28
29usage()
30{
31    cat <<-EndUsage
32		Usage: $oneline_usage
33		Try '$TODO_SH -h' for more information.
34	EndUsage
35    exit 1
36}
37
38shorthelp()
39{
40    cat <<-EndHelp
41		  Usage: $oneline_usage
42
43		  Actions:
44		    add|a "THING I NEED TO DO +project @context"
45		    addm "THINGS I NEED TO DO
46		          MORE THINGS I NEED TO DO"
47		    addto DEST "TEXT TO ADD"
48		    append|app ITEM# "TEXT TO APPEND"
49		    archive
50		    command [ACTIONS]
51		    deduplicate
52		    del|rm ITEM# [TERM]
53		    depri|dp ITEM#[, ITEM#, ITEM#, ...]
54		    done|do ITEM#[, ITEM#, ITEM#, ...]
55		    help [ACTION...]
56		    list|ls [TERM...]
57		    listall|lsa [TERM...]
58		    listaddons
59		    listcon|lsc [TERM...]
60		    listfile|lf [SRC [TERM...]]
61		    listpri|lsp [PRIORITIES] [TERM...]
62		    listproj|lsprj [TERM...]
63		    move|mv ITEM# DEST [SRC]
64		    prepend|prep ITEM# "TEXT TO PREPEND"
65		    pri|p ITEM# PRIORITY
66		    replace ITEM# "UPDATED TODO"
67		    report
68		    shorthelp
69
70		  Actions can be added and overridden using scripts in the actions
71		  directory.
72	EndHelp
73
74    # Only list the one-line usage from the add-on actions. This assumes that
75    # add-ons use the same usage indentation structure as todo.sh.
76    addonHelp | grep -e '^  Add-on Actions:' -e '^    [[:alpha:]]'
77
78    cat <<-EndHelpFooter
79
80		  See "help" for more details.
81	EndHelpFooter
82}
83
84help()
85{
86    cat <<-EndOptionsHelp
87		  Usage: $oneline_usage
88
89		  Options:
90		    -@
91		        Hide context names in list output.  Use twice to show context
92		        names (default).
93		    -+
94		        Hide project names in list output.  Use twice to show project
95		        names (default).
96		    -c
97		        Color mode
98		    -d CONFIG_FILE
99		        Use a configuration file other than the default ~/.todo/config
100		    -f
101		        Forces actions without confirmation or interactive input
102		    -h
103		        Display a short help message; same as action "shorthelp"
104		    -p
105		        Plain mode turns off colors
106		    -P
107		        Hide priority labels in list output.  Use twice to show
108		        priority labels (default).
109		    -a
110		        Don't auto-archive tasks automatically on completion
111		    -A
112		        Auto-archive tasks automatically on completion
113		    -n
114		        Don't preserve line numbers; automatically remove blank lines
115		        on task deletion
116		    -N
117		        Preserve line numbers
118		    -t
119		        Prepend the current date to a task automatically
120		        when it's added.
121		    -T
122		        Do not prepend the current date to a task automatically
123		        when it's added.
124		    -v
125		        Verbose mode turns on confirmation messages
126		    -vv
127		        Extra verbose mode prints some debugging information and
128		        additional help text
129		    -V
130		        Displays version, license and credits
131		    -x
132		        Disables TODOTXT_FINAL_FILTER
133
134
135	EndOptionsHelp
136
137    [ "$TODOTXT_VERBOSE" -gt 1 ] && cat <<-'EndVerboseHelp'
138		  Environment variables:
139		    TODOTXT_AUTO_ARCHIVE            is same as option -a (0)/-A (1)
140		    TODOTXT_CFG_FILE=CONFIG_FILE    is same as option -d CONFIG_FILE
141		    TODOTXT_FORCE=1                 is same as option -f
142		    TODOTXT_PRESERVE_LINE_NUMBERS   is same as option -n (0)/-N (1)
143		    TODOTXT_PLAIN                   is same as option -p (1)/-c (0)
144		    TODOTXT_DATE_ON_ADD             is same as option -t (1)/-T (0)
145		    TODOTXT_PRIORITY_ON_ADD=pri     default priority A-Z
146		    TODOTXT_VERBOSE=1               is same as option -v
147		    TODOTXT_DISABLE_FILTER=1        is same as option -x
148		    TODOTXT_DEFAULT_ACTION=""       run this when called with no arguments
149		    TODOTXT_SORT_COMMAND="sort ..." customize list output
150		    TODOTXT_FINAL_FILTER="sed ..."  customize list after color, P@+ hiding
151		    TODOTXT_SOURCEVAR=\$DONE_FILE   use another source for listcon, listproj
152		    TODOTXT_SIGIL_BEFORE_PATTERN="" optionally allow chars preceding +p / @c
153		    TODOTXT_SIGIL_VALID_PATTERN=.*  tweak the allowed chars for +p and @c
154		    TODOTXT_SIGIL_AFTER_PATTERN=""  optionally allow chars after +p / @c
155
156
157	EndVerboseHelp
158        actionsHelp
159        addonHelp
160}
161
162actionsHelp()
163{
164    cat <<-EndActionsHelp
165		  Built-in Actions:
166		    add "THING I NEED TO DO +project @context"
167		    a "THING I NEED TO DO +project @context"
168		      Adds THING I NEED TO DO to your todo.txt file on its own line.
169		      Project and context notation optional.
170		      Quotes optional.
171
172		    addm "FIRST THING I NEED TO DO +project1 @context
173		    SECOND THING I NEED TO DO +project2 @context"
174		      Adds FIRST THING I NEED TO DO to your todo.txt on its own line and
175		      Adds SECOND THING I NEED TO DO to you todo.txt on its own line.
176		      Project and context notation optional.
177
178		    addto DEST "TEXT TO ADD"
179		      Adds a line of text to any file located in the todo.txt directory.
180		      For example, addto inbox.txt "decide about vacation"
181
182		    append ITEM# "TEXT TO APPEND"
183		    app ITEM# "TEXT TO APPEND"
184		      Adds TEXT TO APPEND to the end of the task on line ITEM#.
185		      Quotes optional.
186
187		    archive
188		      Moves all done tasks from todo.txt to done.txt and removes blank lines.
189
190		    command [ACTIONS]
191		      Runs the remaining arguments using only todo.sh builtins.
192		      Will not call any .todo.actions.d scripts.
193
194		    deduplicate
195		      Removes duplicate lines from todo.txt.
196
197		    del ITEM# [TERM]
198		    rm ITEM# [TERM]
199		      Deletes the task on line ITEM# in todo.txt.
200		      If TERM specified, deletes only TERM from the task.
201
202		    depri ITEM#[, ITEM#, ITEM#, ...]
203		    dp ITEM#[, ITEM#, ITEM#, ...]
204		      Deprioritizes (removes the priority) from the task(s)
205		      on line ITEM# in todo.txt.
206
207		    done ITEM#[, ITEM#, ITEM#, ...]
208		    do ITEM#[, ITEM#, ITEM#, ...]
209		      Marks task(s) on line ITEM# as done in todo.txt.
210
211		    help [ACTION...]
212		      Display help about usage, options, built-in and add-on actions,
213		      or just the usage help for the passed ACTION(s).
214
215		    list [TERM...]
216		    ls [TERM...]
217		      Displays all tasks that contain TERM(s) sorted by priority with line
218		      numbers.  Each task must match all TERM(s) (logical AND); to display
219		      tasks that contain any TERM (logical OR), use
220		      "TERM1\|TERM2\|..." (with quotes), or TERM1\\\|TERM2 (unquoted).
221		      Hides all tasks that contain TERM(s) preceded by a
222		      minus sign (i.e. -TERM). If no TERM specified, lists entire todo.txt.
223
224		    listall [TERM...]
225		    lsa [TERM...]
226		      Displays all the lines in todo.txt AND done.txt that contain TERM(s)
227		      sorted by priority with line  numbers.  Hides all tasks that
228		      contain TERM(s) preceded by a minus sign (i.e. -TERM).  If no
229		      TERM specified, lists entire todo.txt AND done.txt
230		      concatenated and sorted.
231
232		    listaddons
233		      Lists all added and overridden actions in the actions directory.
234
235		    listcon [TERM...]
236		    lsc [TERM...]
237		      Lists all the task contexts that start with the @ sign in todo.txt.
238		      If TERM specified, considers only tasks that contain TERM(s).
239
240		    listfile [SRC [TERM...]]
241		    lf [SRC [TERM...]]
242		      Displays all the lines in SRC file located in the todo.txt directory,
243		      sorted by priority with line  numbers.  If TERM specified, lists
244		      all lines that contain TERM(s) in SRC file.  Hides all tasks that
245		      contain TERM(s) preceded by a minus sign (i.e. -TERM).
246		      Without any arguments, the names of all text files in the todo.txt
247		      directory are listed.
248
249		    listpri [PRIORITIES] [TERM...]
250		    lsp [PRIORITIES] [TERM...]
251		      Displays all tasks prioritized PRIORITIES.
252		      PRIORITIES can be a single one (A) or a range (A-C).
253		      If no PRIORITIES specified, lists all prioritized tasks.
254		      If TERM specified, lists only prioritized tasks that contain TERM(s).
255		      Hides all tasks that contain TERM(s) preceded by a minus sign
256		      (i.e. -TERM).
257
258		    listproj [TERM...]
259		    lsprj [TERM...]
260		      Lists all the projects (terms that start with a + sign) in
261		      todo.txt.
262		      If TERM specified, considers only tasks that contain TERM(s).
263
264		    move ITEM# DEST [SRC]
265		    mv ITEM# DEST [SRC]
266		      Moves a line from source text file (SRC) to destination text file (DEST).
267		      Both source and destination file must be located in the directory defined
268		      in the configuration directory.  When SRC is not defined
269		      it's by default todo.txt.
270
271		    prepend ITEM# "TEXT TO PREPEND"
272		    prep ITEM# "TEXT TO PREPEND"
273		      Adds TEXT TO PREPEND to the beginning of the task on line ITEM#.
274		      Quotes optional.
275
276		    pri ITEM# PRIORITY
277		    p ITEM# PRIORITY
278		      Adds PRIORITY to task on line ITEM#.  If the task is already
279		      prioritized, replaces current priority with new PRIORITY.
280		      PRIORITY must be a letter between A and Z.
281
282		    replace ITEM# "UPDATED TODO"
283		      Replaces task on line ITEM# with UPDATED TODO.
284
285		    report
286		      Adds the number of open tasks and done tasks to report.txt.
287
288		    shorthelp
289		      List the one-line usage of all built-in and add-on actions.
290
291	EndActionsHelp
292}
293
294addonHelp()
295{
296    if [ -d "$TODO_ACTIONS_DIR" ]; then
297        didPrintAddonActionsHeader=
298        for action in "$TODO_ACTIONS_DIR"/*
299        do
300            if [ -f "$action" ] && [ -x "$action" ]; then
301                if [ ! "$didPrintAddonActionsHeader" ]; then
302                    cat <<-EndAddonActionsHeader
303		  Add-on Actions:
304	EndAddonActionsHeader
305                    didPrintAddonActionsHeader=1
306                fi
307                "$action" usage
308            elif [ -d "$action" ] && [ -x "$action"/"$(basename "$action")" ]; then
309                if [ ! "$didPrintAddonActionsHeader" ]; then
310                    cat <<-EndAddonActionsHeader
311		  Add-on Actions:
312	EndAddonActionsHeader
313                    didPrintAddonActionsHeader=1
314                fi
315                "$action"/"$(basename "$action")" usage
316            fi
317        done
318    fi
319}
320
321actionUsage()
322{
323    for actionName
324    do
325        action="${TODO_ACTIONS_DIR}/${actionName}"
326        if [ -f "$action" ] && [ -x "$action" ]; then
327            "$action" usage
328        elif [ -d "$action" ] && [ -x "$action"/"$(basename "$action")" ]; then
329            "$action"/"$(basename "$action")" usage
330        else
331            builtinActionUsage=$(actionsHelp | sed -n -e "/^    ${actionName//\//\\/} /,/^\$/p" -e "/^    ${actionName//\//\\/}$/,/^\$/p")
332            if [ "$builtinActionUsage" ]; then
333                echo "$builtinActionUsage"
334                echo
335            else
336                die "TODO: No action \"${actionName}\" exists."
337            fi
338        fi
339    done
340}
341
342dieWithHelp()
343{
344    case "$1" in
345        help)       help;;
346        shorthelp)  shorthelp;;
347    esac
348    shift
349
350    die "$@"
351}
352die()
353{
354    echo "$*"
355    exit 1
356}
357
358cleaninput()
359{
360    # Parameters:    When $1 = "for sed", performs additional escaping for use
361    #                in sed substitution with "|" separators.
362    # Precondition:  $input contains text to be cleaned.
363    # Postcondition: Modifies $input.
364
365    # Replace CR and LF with space; tasks always comprise a single line.
366    input=${input//$'\r'/ }
367    input=${input//$'\n'/ }
368
369    if [ "$1" = "for sed" ]; then
370        # This action uses sed with "|" as the substitution separator, and & as
371        # the matched string; these must be escaped.
372        # Backslashes must be escaped, too, and before the other stuff.
373        input=${input//\\/\\\\}
374        input=${input//|/\\|}
375        input=${input//&/\\&}
376    fi
377}
378
379getPrefix()
380{
381    # Parameters:    $1: todo file; empty means $TODO_FILE.
382    # Returns:       Uppercase FILE prefix to be used in place of "TODO:" where
383    #                a different todo file can be specified.
384    local base
385    base=$(basename "${1:-$TODO_FILE}")
386    echo "${base%%.[^.]*}" | tr '[:lower:]' '[:upper:]'
387}
388
389getTodo()
390{
391    # Parameters:    $1: task number
392    #                $2: Optional todo file
393    # Precondition:  $errmsg contains usage message.
394    # Postcondition: $todo contains task text.
395
396    local item=$1
397    [ -z "$item" ] && die "$errmsg"
398    [ "${item//[0-9]/}" ] && die "$errmsg"
399
400    todo=$(sed "$item!d" "${2:-$TODO_FILE}")
401    [ -z "$todo" ] && die "$(getPrefix "$2"): No task $item."
402}
403getNewtodo()
404{
405    # Parameters:    $1: task number
406    #                $2: Optional todo file
407    # Precondition:  None.
408    # Postcondition: $newtodo contains task text.
409
410    local item=$1
411    [ -z "$item" ] && die "Programming error: $item should exist."
412    [ "${item//[0-9]/}" ] && die "Programming error: $item should be numeric."
413
414    newtodo=$(sed "$item!d" "${2:-$TODO_FILE}")
415    [ -z "$newtodo" ] && die "$(getPrefix "$2"): No updated task $item."
416}
417
418replaceOrPrepend()
419{
420  action=$1; shift
421  case "$action" in
422    replace)
423      backref=
424      querytext="Replacement: "
425      ;;
426    prepend)
427      backref=' &'
428      querytext="Prepend: "
429      ;;
430  esac
431  shift; item=$1; shift
432  getTodo "$item"
433
434  if [[ -z "$1" && $TODOTXT_FORCE = 0 ]]; then
435    echo -n "$querytext"
436    read -r -i "$todo" -e input
437  else
438    input=$*
439  fi
440
441  # Retrieve existing priority and prepended date
442  local -r priAndDateExpr='^\((.) \)\{0,1\}\([0-9]\{2,4\}-[0-9]\{2\}-[0-9]\{2\} \)\{0,1\}'
443  priority=$(sed -e "$item!d" -e "${item}s/${priAndDateExpr}.*/\\1/" "$TODO_FILE")
444  prepdate=$(sed -e "$item!d" -e "${item}s/${priAndDateExpr}.*/\\2/" "$TODO_FILE")
445
446  if [ "$prepdate" ] && [ "$action" = "replace" ] && [ "$(echo "$input"|sed -e "s/${priAndDateExpr}.*/\\1\\2/")" ]; then
447      # If the replaced text starts with a [priority +] date, it will replace
448      # the existing date, too.
449    prepdate=
450  fi
451
452  # Temporarily remove any existing priority and prepended date, perform the
453  # change (replace/prepend) and re-insert the existing priority and prepended
454  # date again.
455  cleaninput "for sed"
456  sed -i.bak -e "$item s/^${priority}${prepdate}//" -e "$item s|^.*|${priority}${prepdate}${input}${backref}|" "$TODO_FILE"
457  if [ "$TODOTXT_VERBOSE" -gt 0 ]; then
458    getNewtodo "$item"
459    case "$action" in
460      replace)
461        echo "$item $todo"
462        echo "TODO: Replaced task with:"
463        echo "$item $newtodo"
464        ;;
465      prepend)
466        echo "$item $newtodo"
467        ;;
468    esac
469  fi
470}
471
472fixMissingEndOfLine()
473{
474    # Parameters:    $1: todo file; empty means $TODO_FILE.
475    sed -i.bak -e '$a\' "${1:-$TODO_FILE}"
476}
477
478uppercasePriority()
479{
480    # Precondition:  $input contains task text for which to uppercase priority.
481    # Postcondition: Modifies $input.
482    lower=( {a..z} )
483    upper=( {A..Z} )
484    for ((i=0; i<26; i++))
485    do
486        upperPriority="${upperPriority};s/^[(]${lower[i]}[)]/(${upper[i]})/"
487    done
488    input=$(echo "$input" | sed "$upperPriority")
489}
490
491#Preserving environment variables so they don't get clobbered by the config file
492OVR_TODOTXT_AUTO_ARCHIVE="$TODOTXT_AUTO_ARCHIVE"
493OVR_TODOTXT_FORCE="$TODOTXT_FORCE"
494OVR_TODOTXT_PRESERVE_LINE_NUMBERS="$TODOTXT_PRESERVE_LINE_NUMBERS"
495OVR_TODOTXT_PLAIN="$TODOTXT_PLAIN"
496OVR_TODOTXT_DATE_ON_ADD="$TODOTXT_DATE_ON_ADD"
497OVR_TODOTXT_PRIORITY_ON_ADD="$TODOTXT_PRIORITY_ON_ADD"
498OVR_TODOTXT_DISABLE_FILTER="$TODOTXT_DISABLE_FILTER"
499OVR_TODOTXT_VERBOSE="$TODOTXT_VERBOSE"
500OVR_TODOTXT_DEFAULT_ACTION="$TODOTXT_DEFAULT_ACTION"
501OVR_TODOTXT_SORT_COMMAND="$TODOTXT_SORT_COMMAND"
502OVR_TODOTXT_FINAL_FILTER="$TODOTXT_FINAL_FILTER"
503
504# Prevent GREP_OPTIONS from malforming grep's output
505export GREP_OPTIONS=""
506
507# == PROCESS OPTIONS ==
508while getopts ":fhpcnNaAtTvVx+@Pd:" Option
509do
510  case $Option in
511    '@')
512        ## HIDE_CONTEXT_NAMES starts at zero (false); increment it to one
513        ##   (true) the first time this flag is seen. Each time the flag
514        ##   is seen after that, increment it again so that an even
515        ##   number shows context names and an odd number hides context
516        ##   names.
517        : $(( HIDE_CONTEXT_NAMES++ ))
518        if [ $(( HIDE_CONTEXT_NAMES % 2 )) -eq 0 ]
519        then
520            ## Zero or even value -- show context names
521            unset HIDE_CONTEXTS_SUBSTITUTION
522        else
523            ## One or odd value -- hide context names
524            export HIDE_CONTEXTS_SUBSTITUTION='[[:space:]]@[[:graph:]]\{1,\}'
525        fi
526        ;;
527    '+')
528        ## HIDE_PROJECT_NAMES starts at zero (false); increment it to one
529        ##   (true) the first time this flag is seen. Each time the flag
530        ##   is seen after that, increment it again so that an even
531        ##   number shows project names and an odd number hides project
532        ##   names.
533        : $(( HIDE_PROJECT_NAMES++ ))
534        if [ $(( HIDE_PROJECT_NAMES % 2 )) -eq 0 ]
535        then
536            ## Zero or even value -- show project names
537            unset HIDE_PROJECTS_SUBSTITUTION
538        else
539            ## One or odd value -- hide project names
540            export HIDE_PROJECTS_SUBSTITUTION='[[:space:]][+][[:graph:]]\{1,\}'
541        fi
542        ;;
543    a)
544        OVR_TODOTXT_AUTO_ARCHIVE=0
545        ;;
546    A)
547        OVR_TODOTXT_AUTO_ARCHIVE=1
548        ;;
549    c)
550        OVR_TODOTXT_PLAIN=0
551        ;;
552    d)
553        TODOTXT_CFG_FILE=$OPTARG
554        ;;
555    f)
556        OVR_TODOTXT_FORCE=1
557        ;;
558    h)
559        # Short-circuit option parsing and forward to the action.
560        # Cannot just invoke shorthelp() because we need the configuration
561        # processed to locate the add-on actions directory.
562        set -- '-h' 'shorthelp'
563        OPTIND=2
564        ;;
565    n)
566        OVR_TODOTXT_PRESERVE_LINE_NUMBERS=0
567        ;;
568    N)
569        OVR_TODOTXT_PRESERVE_LINE_NUMBERS=1
570        ;;
571    p)
572        OVR_TODOTXT_PLAIN=1
573        ;;
574    P)
575        ## HIDE_PRIORITY_LABELS starts at zero (false); increment it to one
576        ##   (true) the first time this flag is seen. Each time the flag
577        ##   is seen after that, increment it again so that an even
578        ##   number shows priority labels and an odd number hides priority
579        ##   labels.
580        : $(( HIDE_PRIORITY_LABELS++ ))
581        if [ $(( HIDE_PRIORITY_LABELS % 2 )) -eq 0 ]
582        then
583            ## Zero or even value -- show priority labels
584            unset HIDE_PRIORITY_SUBSTITUTION
585        else
586            ## One or odd value -- hide priority labels
587            export HIDE_PRIORITY_SUBSTITUTION="([A-Z])[[:space:]]"
588        fi
589        ;;
590    t)
591        OVR_TODOTXT_DATE_ON_ADD=1
592        ;;
593    T)
594        OVR_TODOTXT_DATE_ON_ADD=0
595        ;;
596    v)
597        : $(( TODOTXT_VERBOSE++ ))
598        ;;
599    V)
600        version
601        ;;
602    x)
603        OVR_TODOTXT_DISABLE_FILTER=1
604        ;;
605    *)
606        usage
607        ;;
608  esac
609done
610shift $((OPTIND - 1))
611
612# defaults if not yet defined
613TODOTXT_VERBOSE=${TODOTXT_VERBOSE:-1}
614TODOTXT_PLAIN=${TODOTXT_PLAIN:-0}
615TODOTXT_CFG_FILE=${TODOTXT_CFG_FILE:-$HOME/.todo/config}
616TODOTXT_FORCE=${TODOTXT_FORCE:-0}
617TODOTXT_PRESERVE_LINE_NUMBERS=${TODOTXT_PRESERVE_LINE_NUMBERS:-1}
618TODOTXT_AUTO_ARCHIVE=${TODOTXT_AUTO_ARCHIVE:-1}
619TODOTXT_DATE_ON_ADD=${TODOTXT_DATE_ON_ADD:-0}
620TODOTXT_PRIORITY_ON_ADD=${TODOTXT_PRIORITY_ON_ADD:-}
621TODOTXT_DEFAULT_ACTION=${TODOTXT_DEFAULT_ACTION:-}
622TODOTXT_SORT_COMMAND=${TODOTXT_SORT_COMMAND:-env LC_COLLATE=C sort -f -k2}
623TODOTXT_DISABLE_FILTER=${TODOTXT_DISABLE_FILTER:-}
624TODOTXT_FINAL_FILTER=${TODOTXT_FINAL_FILTER:-cat}
625TODOTXT_GLOBAL_CFG_FILE=${TODOTXT_GLOBAL_CFG_FILE:-/usr/local/etc/todo.cfg}
626TODOTXT_SIGIL_BEFORE_PATTERN=${TODOTXT_SIGIL_BEFORE_PATTERN:-}	# Allow any other non-whitespace entity before +project and @context; should be an optional match; example: \(w:\)\{0,1\} to allow w:@context.
627TODOTXT_SIGIL_VALID_PATTERN=${TODOTXT_SIGIL_VALID_PATTERN:-.*}	# Limit the valid characters (from the default any non-whitespace sequence) for +project and @context; example: [a-zA-Z]\{3,\} to only allow alphabetic ones that are at least three characters long.
628TODOTXT_SIGIL_AFTER_PATTERN=${TODOTXT_SIGIL_AFTER_PATTERN:-}	# Allow any other non-whitespace entity after +project and @context; should be an optional match; example: )\{0,1\} to allow (with the corresponding TODOTXT_SIGIL_BEFORE_PATTERN) enclosing in parentheses.
629
630# Export all TODOTXT_* variables
631export "${!TODOTXT_@}"
632
633# Default color map
634export NONE=''
635export BLACK='\\033[0;30m'
636export RED='\\033[0;31m'
637export GREEN='\\033[0;32m'
638export BROWN='\\033[0;33m'
639export BLUE='\\033[0;34m'
640export PURPLE='\\033[0;35m'
641export CYAN='\\033[0;36m'
642export LIGHT_GREY='\\033[0;37m'
643export DARK_GREY='\\033[1;30m'
644export LIGHT_RED='\\033[1;31m'
645export LIGHT_GREEN='\\033[1;32m'
646export YELLOW='\\033[1;33m'
647export LIGHT_BLUE='\\033[1;34m'
648export LIGHT_PURPLE='\\033[1;35m'
649export LIGHT_CYAN='\\033[1;36m'
650export WHITE='\\033[1;37m'
651export DEFAULT='\\033[0m'
652
653# Default priority->color map.
654export PRI_A=$YELLOW        # color for A priority
655export PRI_B=$GREEN         # color for B priority
656export PRI_C=$LIGHT_BLUE    # color for C priority
657export PRI_X=$WHITE         # color unless explicitly defined
658
659# Default project, context, date, item number, and metadata key:value pairs colors.
660export COLOR_PROJECT=$NONE
661export COLOR_CONTEXT=$NONE
662export COLOR_DATE=$NONE
663export COLOR_NUMBER=$NONE
664export COLOR_META=$NONE
665
666# Default highlight colors.
667export COLOR_DONE=$LIGHT_GREY   # color for done (but not yet archived) tasks
668
669# Default sentence delimiters for todo.sh append.
670# If the text to be appended to the task begins with one of these characters, no
671# whitespace is inserted in between. This makes appending to an enumeration
672# (todo.sh add 42 ", foo") syntactically correct.
673export SENTENCE_DELIMITERS=',.:;'
674
675[ -e "$TODOTXT_CFG_FILE" ] || {
676    CFG_FILE_ALT="$HOME/todo.cfg"
677
678    if [ -e "$CFG_FILE_ALT" ]
679    then
680        TODOTXT_CFG_FILE="$CFG_FILE_ALT"
681    fi
682}
683
684[ -e "$TODOTXT_CFG_FILE" ] || {
685    CFG_FILE_ALT="$HOME/.todo.cfg"
686
687    if [ -e "$CFG_FILE_ALT" ]
688    then
689        TODOTXT_CFG_FILE="$CFG_FILE_ALT"
690    fi
691}
692
693[ -e "$TODOTXT_CFG_FILE" ] || {
694    CFG_FILE_ALT="${XDG_CONFIG_HOME:-$HOME/.config}/todo/config"
695
696    if [ -e "$CFG_FILE_ALT" ]
697    then
698        TODOTXT_CFG_FILE="$CFG_FILE_ALT"
699    fi
700}
701
702[ -e "$TODOTXT_CFG_FILE" ] || {
703    CFG_FILE_ALT=$(dirname "$0")"/todo.cfg"
704
705    if [ -e "$CFG_FILE_ALT" ]
706    then
707        TODOTXT_CFG_FILE="$CFG_FILE_ALT"
708    fi
709}
710
711[ -e "$TODOTXT_CFG_FILE" ] || {
712    CFG_FILE_ALT="$TODOTXT_GLOBAL_CFG_FILE"
713
714    if [ -e "$CFG_FILE_ALT" ]
715    then
716        TODOTXT_CFG_FILE="$CFG_FILE_ALT"
717    fi
718}
719
720
721if [ -z "$TODO_ACTIONS_DIR" ] || [ ! -d "$TODO_ACTIONS_DIR" ]
722then
723    TODO_ACTIONS_DIR="$HOME/.todo/actions"
724    export TODO_ACTIONS_DIR
725fi
726
727[ -d "$TODO_ACTIONS_DIR" ] || {
728    TODO_ACTIONS_DIR_ALT="$HOME/.todo.actions.d"
729
730    if [ -d "$TODO_ACTIONS_DIR_ALT" ]
731    then
732        TODO_ACTIONS_DIR="$TODO_ACTIONS_DIR_ALT"
733    fi
734}
735
736[ -d "$TODO_ACTIONS_DIR" ] || {
737    TODO_ACTIONS_DIR_ALT="${XDG_CONFIG_HOME:-$HOME/.config}/todo/actions"
738
739    if [ -d "$TODO_ACTIONS_DIR_ALT" ]
740    then
741        TODO_ACTIONS_DIR="$TODO_ACTIONS_DIR_ALT"
742    fi
743}
744
745# === SANITY CHECKS (thanks Karl!) ===
746[ -r "$TODOTXT_CFG_FILE" ] || dieWithHelp "$1" "Fatal Error: Cannot read configuration file $TODOTXT_CFG_FILE"
747
748. "$TODOTXT_CFG_FILE"
749
750# === APPLY OVERRIDES
751if [ -n "$OVR_TODOTXT_AUTO_ARCHIVE" ] ; then
752  TODOTXT_AUTO_ARCHIVE="$OVR_TODOTXT_AUTO_ARCHIVE"
753fi
754if [ -n "$OVR_TODOTXT_FORCE" ] ; then
755  TODOTXT_FORCE="$OVR_TODOTXT_FORCE"
756fi
757if [ -n "$OVR_TODOTXT_PRESERVE_LINE_NUMBERS" ] ; then
758  TODOTXT_PRESERVE_LINE_NUMBERS="$OVR_TODOTXT_PRESERVE_LINE_NUMBERS"
759fi
760if [ -n "$OVR_TODOTXT_PLAIN" ] ; then
761  TODOTXT_PLAIN="$OVR_TODOTXT_PLAIN"
762fi
763if [ -n "$OVR_TODOTXT_DATE_ON_ADD" ] ; then
764  TODOTXT_DATE_ON_ADD="$OVR_TODOTXT_DATE_ON_ADD"
765fi
766if [ -n "$OVR_TODOTXT_PRIORITY_ON_ADD" ] ; then
767    TODOTXT_PRIORITY_ON_ADD="$OVR_TODOTXT_PRIORITY_ON_ADD"
768fi
769if [ -n "$OVR_TODOTXT_DISABLE_FILTER" ] ; then
770  TODOTXT_DISABLE_FILTER="$OVR_TODOTXT_DISABLE_FILTER"
771fi
772if [ -n "$OVR_TODOTXT_VERBOSE" ] ; then
773  TODOTXT_VERBOSE="$OVR_TODOTXT_VERBOSE"
774fi
775if [ -n "$OVR_TODOTXT_DEFAULT_ACTION" ] ; then
776  TODOTXT_DEFAULT_ACTION="$OVR_TODOTXT_DEFAULT_ACTION"
777fi
778if [ -n "$OVR_TODOTXT_SORT_COMMAND" ] ; then
779  TODOTXT_SORT_COMMAND="$OVR_TODOTXT_SORT_COMMAND"
780fi
781if [ -n "$OVR_TODOTXT_FINAL_FILTER" ] ; then
782  TODOTXT_FINAL_FILTER="$OVR_TODOTXT_FINAL_FILTER"
783fi
784
785ACTION=${1:-$TODOTXT_DEFAULT_ACTION}
786
787[ -z "$ACTION" ]    && usage
788[ -d "$TODO_DIR" ]  || mkdir -p "$TODO_DIR" 2> /dev/null || dieWithHelp "$1" "Fatal Error: $TODO_DIR is not a directory"
789( cd "$TODO_DIR" )  || dieWithHelp "$1" "Fatal Error: Unable to cd to $TODO_DIR"
790[ -z "$TODOTXT_PRIORITY_ON_ADD" ] \
791    || echo "$TODOTXT_PRIORITY_ON_ADD" | grep -q "^[A-Z]$" \
792    || die "TODOTXT_PRIORITY_ON_ADD should be a capital letter from A to Z (it is now \"$TODOTXT_PRIORITY_ON_ADD\")."
793
794[ -z "$TODO_FILE" ] && TODO_FILE="$TODO_DIR/todo.txt"
795[ -z "$DONE_FILE" ] && DONE_FILE="$TODO_DIR/done.txt"
796[ -z "$REPORT_FILE" ] && REPORT_FILE="$TODO_DIR/report.txt"
797
798[ -f "$TODO_FILE" ] || [ -c "$TODO_FILE" ] || > "$TODO_FILE"
799[ -f "$DONE_FILE" ] || [ -c "$DONE_FILE" ] || > "$DONE_FILE"
800[ -f "$REPORT_FILE" ] || [ -c "$REPORT_FILE" ] || > "$REPORT_FILE"
801
802if [ $TODOTXT_PLAIN = 1 ]; then
803    for clr in ${!PRI_@}; do
804        export "$clr"=$NONE
805    done
806    PRI_X=$NONE
807    DEFAULT=$NONE
808    COLOR_DONE=$NONE
809    COLOR_PROJECT=$NONE
810    COLOR_CONTEXT=$NONE
811    COLOR_DATE=$NONE
812    COLOR_NUMBER=$NONE
813    COLOR_META=$NONE
814fi
815
816[[ "$HIDE_PROJECTS_SUBSTITUTION" ]] && COLOR_PROJECT="$NONE"
817[[ "$HIDE_CONTEXTS_SUBSTITUTION" ]] && COLOR_CONTEXT="$NONE"
818
819_addto() {
820    file="$1"
821    input="$2"
822    cleaninput
823    uppercasePriority
824
825    if [[ "$TODOTXT_DATE_ON_ADD" -eq 1 ]]; then
826        now=$(date '+%Y-%m-%d')
827        input=$(echo "$input" | sed -e 's/^\(([A-Z]) \)\{0,1\}/\1'"$now /")
828    fi
829    if [[ -n "$TODOTXT_PRIORITY_ON_ADD" ]]; then
830        if ! echo "$input" | grep -q '^([A-Z])'; then
831            input=$(echo -n "($TODOTXT_PRIORITY_ON_ADD) " ; echo "$input")
832        fi
833    fi
834    fixMissingEndOfLine "$file"
835    echo "$input" >> "$file"
836    if [ "$TODOTXT_VERBOSE" -gt 0 ]; then
837        TASKNUM=$(sed -n '$ =' "$file")
838        echo "$TASKNUM $input"
839        echo "$(getPrefix "$file"): $TASKNUM added."
840    fi
841}
842
843shellquote()
844{
845    typeset -r qq=\'; printf %s\\n "'${1//\'/${qq}\\${qq}${qq}}'";
846}
847
848filtercommand()
849{
850    filter=${1:-}
851    shift
852    post_filter=${1:-}
853    shift
854
855    for search_term
856    do
857        ## See if the first character of $search_term is a dash
858        if [ "${search_term:0:1}" != '-' ]
859        then
860            ## First character isn't a dash: hide lines that don't match
861            ## this $search_term
862            filter="${filter:-}${filter:+ | }grep -i $(shellquote "$search_term")"
863        else
864            ## First character is a dash: hide lines that match this
865            ## $search_term
866            #
867            ## Remove the first character (-) before adding to our filter command
868            filter="${filter:-}${filter:+ | }grep -v -i $(shellquote "${search_term:1}")"
869        fi
870    done
871
872    [ -n "$post_filter" ] && {
873        filter="${filter:-}${filter:+ | }${post_filter:-}"
874    }
875
876    printf %s "$filter"
877}
878
879_list() {
880    local FILE="$1"
881    ## If the file starts with a "/" use absolute path. Otherwise,
882    ## try to find it in either $TODO_DIR or using a relative path
883    if [ "${1:0:1}" == / ]; then
884        ## Absolute path
885        src="$FILE"
886    elif [ -f "$TODO_DIR/$FILE" ]; then
887        ## Path relative to todo.sh directory
888        src="$TODO_DIR/$FILE"
889    elif [ -f "$FILE" ]; then
890        ## Path relative to current working directory
891        src="$FILE"
892    elif [ -f "$TODO_DIR/${FILE}.txt" ]; then
893        ## Path relative to todo.sh directory, missing file extension
894        src="$TODO_DIR/${FILE}.txt"
895    else
896        die "TODO: File $FILE does not exist."
897    fi
898
899    ## Get our search arguments, if any
900    shift ## was file name, new $1 is first search term
901
902    _format "$src" '' "$@"
903
904    if [ "$TODOTXT_VERBOSE" -gt 0 ]; then
905        echo "--"
906        echo "$(getPrefix "$src"): ${NUMTASKS:-0} of ${TOTALTASKS:-0} tasks shown"
907    fi
908}
909getPadding()
910{
911    ## We need one level of padding for each power of 10 $LINES uses.
912    LINES=$(sed -n '$ =' "${1:-$TODO_FILE}")
913    printf %s ${#LINES}
914}
915_format()
916{
917    # Parameters:    $1: todo input file; when empty formats stdin
918    #                $2: ITEM# number width; if empty auto-detects from $1 / $TODO_FILE.
919    # Precondition:  None
920    # Postcondition: $NUMTASKS and $TOTALTASKS contain statistics (unless $TODOTXT_VERBOSE=0).
921
922    FILE=$1
923    shift
924
925    ## Figure out how much padding we need to use, unless this was passed to us.
926    PADDING=${1:-$(getPadding "$FILE")}
927    shift
928
929    ## Number the file, then run the filter command,
930    ## then sort and mangle output some more
931    if [[ $TODOTXT_DISABLE_FILTER = 1 ]]; then
932        TODOTXT_FINAL_FILTER="cat"
933    fi
934    items=$(
935        if [ "$FILE" ]; then
936            sed = "$FILE"
937        else
938            sed =
939        fi                                                      \
940        | sed -e '''
941            N
942            s/^/     /
943            s/ *\([ 0-9]\{'"$PADDING"',\}\)\n/\1 /
944            /^[ 0-9]\{1,\} *$/d
945         '''
946    )
947
948    ## Build and apply the filter.
949    filter_command=$(filtercommand "${pre_filter_command:-}" "${post_filter_command:-}" "$@")
950    if [ "${filter_command}" ]; then
951        filtered_items=$(echo -n "$items" | eval "${filter_command}")
952    else
953        filtered_items=$items
954    fi
955    filtered_items=$(
956        echo -n "$filtered_items"                              \
957        | sed '''
958            s/^     /00000/;
959            s/^    /0000/;
960            s/^   /000/;
961            s/^  /00/;
962            s/^ /0/;
963          ''' \
964        | eval "${TODOTXT_SORT_COMMAND}" \
965        | awk '''
966            function highlight(colorVar,      color) {
967                color = ENVIRON[colorVar]
968                gsub(/\\+033/, "\033", color)
969                return color
970            }
971            {
972                clr = ""
973                if (match($0, /^[0-9]+ x /)) {
974                    clr = highlight("COLOR_DONE")
975                } else if (match($0, /^[0-9]+ \([A-Z]\) /)) {
976                    clr = highlight("PRI_" substr($0, RSTART + RLENGTH - 3, 1))
977                    clr = (clr ? clr : highlight("PRI_X"))
978                    if (ENVIRON["HIDE_PRIORITY_SUBSTITUTION"] != "") {
979                        $0 = substr($0, 1, RLENGTH - 4) substr($0, RSTART + RLENGTH)
980                    }
981                }
982                end_clr = (clr ? highlight("DEFAULT") : "")
983
984                prj_beg = highlight("COLOR_PROJECT")
985                prj_end = (prj_beg ? (highlight("DEFAULT") clr) : "")
986
987                ctx_beg = highlight("COLOR_CONTEXT")
988                ctx_end = (ctx_beg ? (highlight("DEFAULT") clr) : "")
989
990                dat_beg = highlight("COLOR_DATE")
991                dat_end = (dat_beg ? (highlight("DEFAULT") clr) : "")
992
993                num_beg = highlight("COLOR_NUMBER")
994                num_end = (num_beg ? (highlight("DEFAULT") clr) : "")
995
996                met_beg = highlight("COLOR_META")
997                met_end = (met_beg ? (highlight("DEFAULT") clr) : "")
998
999                gsub(/[ \t][ \t]*/, "\n&\n")
1000                len = split($0, words, /\n/)
1001
1002                printf "%s", clr
1003                for (i = 1; i <= len; ++i) {
1004                    if (i == 1 && words[i] ~ /^[0-9]+$/ ) {
1005                        printf "%s", num_beg words[i] num_end
1006                    } else if (words[i] ~ /^[+].*[A-Za-z0-9_]$/) {
1007                        printf "%s", prj_beg words[i] prj_end
1008                    } else if (words[i] ~ /^[@].*[A-Za-z0-9_]$/) {
1009                        printf "%s", ctx_beg words[i] ctx_end
1010                    } else if (words[i] ~ /^(19|20)[0-9]{2}-(0[1-9]|1[012])-(0[1-9]|[12][0-9]|3[01])$/) {
1011                        printf "%s", dat_beg words[i] dat_end
1012                    } else if (words[i] ~ /^[[:alnum:]]+:[^ ]+$/) {
1013                        printf "%s", met_beg words[i] met_end
1014                    } else {
1015                        printf "%s", words[i]
1016                    }
1017                }
1018                printf "%s\n", end_clr
1019            }
1020          '''  \
1021        | sed '''
1022            s/'"${HIDE_PROJECTS_SUBSTITUTION:-^}"'//g
1023            s/'"${HIDE_CONTEXTS_SUBSTITUTION:-^}"'//g
1024            s/'"${HIDE_CUSTOM_SUBSTITUTION:-^}"'//g
1025          '''                                                   \
1026        | eval ${TODOTXT_FINAL_FILTER}                          \
1027    )
1028    [ "$filtered_items" ] && echo "$filtered_items"
1029
1030    if [ "$TODOTXT_VERBOSE" -gt 0 ]; then
1031        NUMTASKS=$( echo -n "$filtered_items" | sed -n '$ =' )
1032        TOTALTASKS=$( echo -n "$items" | sed -n '$ =' )
1033    fi
1034    if [ "$TODOTXT_VERBOSE" -gt 1 ]; then
1035        echo "TODO DEBUG: Filter Command was: ${filter_command:-cat}"
1036    fi
1037}
1038
1039listWordsWithSigil()
1040{
1041    sigil=$1
1042    shift
1043
1044    FILE=$TODO_FILE
1045    [ "$TODOTXT_SOURCEVAR" ] && eval "FILE=$TODOTXT_SOURCEVAR"
1046	eval "$(filtercommand 'cat "${FILE[@]}"' '' "$@")" \
1047		| grep -o "[^ ]*${sigil}[^ ]\\+" \
1048		| sed -n \
1049			-e "s#^${TODOTXT_SIGIL_BEFORE_PATTERN//#/\\#}##" \
1050			-e "s#${TODOTXT_SIGIL_AFTER_PATTERN//#/\\#}\$##" \
1051			-e "/^${sigil}${TODOTXT_SIGIL_VALID_PATTERN//\//\\/}$/p" \
1052		| sort -u
1053}
1054
1055export -f cleaninput getPrefix getTodo getNewtodo shellquote filtercommand _list listWordsWithSigil getPadding _format die
1056
1057# == HANDLE ACTION ==
1058action=$( printf "%s\n" "$ACTION" | tr '[:upper:]' '[:lower:]' )
1059
1060## If the first argument is "command", run the rest of the arguments
1061## using todo.sh builtins.
1062## Else, run a actions script with the name of the command if it exists
1063## or fallback to using a builtin
1064if [ "$action" == command ]
1065then
1066    ## Get rid of "command" from arguments list
1067    shift
1068    ## Reset action to new first argument
1069    action=$( printf "%s\n" "$1" | tr '[:upper:]' '[:lower:]' )
1070elif [ -d "$TODO_ACTIONS_DIR/$action" ] && [ -x "$TODO_ACTIONS_DIR/$action/$action" ]
1071then
1072    "$TODO_ACTIONS_DIR/$action/$action" "$@"
1073    exit $?
1074elif [ -d "$TODO_ACTIONS_DIR" ] && [ -x "$TODO_ACTIONS_DIR/$action" ]
1075then
1076    "$TODO_ACTIONS_DIR/$action" "$@"
1077    exit $?
1078fi
1079
1080## Only run if $action isn't found in .todo.actions.d
1081case $action in
1082"add" | "a")
1083    if [[ -z "$2" && $TODOTXT_FORCE = 0 ]]; then
1084        echo -n "Add: "
1085        read -e -r input
1086    else
1087        [ -z "$2" ] && die "usage: $TODO_SH add \"TODO ITEM\""
1088        shift
1089        input=$*
1090    fi
1091    _addto "$TODO_FILE" "$input"
1092    ;;
1093
1094"addm")
1095    if [[ -z "$2" && $TODOTXT_FORCE = 0 ]]; then
1096        echo -n "Add: "
1097        read -e -r input
1098    else
1099        [ -z "$2" ] && die "usage: $TODO_SH addm \"TODO ITEM\""
1100        shift
1101        input=$*
1102    fi
1103
1104    # Set Internal Field Seperator as newline so we can
1105    # loop across multiple lines
1106    SAVEIFS=$IFS
1107    IFS=$'\n'
1108
1109    # Treat each line seperately
1110    for line in $input ; do
1111        _addto "$TODO_FILE" "$line"
1112    done
1113    IFS=$SAVEIFS
1114    ;;
1115
1116"addto" )
1117    [ -z "$2" ] && die "usage: $TODO_SH addto DEST \"TODO ITEM\""
1118    dest="$TODO_DIR/$2"
1119    [ -z "$3" ] && die "usage: $TODO_SH addto DEST \"TODO ITEM\""
1120    shift
1121    shift
1122    input=$*
1123
1124    if [ -f "$dest" ]; then
1125        _addto "$dest" "$input"
1126    else
1127        die "TODO: Destination file $dest does not exist."
1128    fi
1129    ;;
1130
1131"append" | "app" )
1132    errmsg="usage: $TODO_SH append ITEM# \"TEXT TO APPEND\""
1133    shift; item=$1; shift
1134    getTodo "$item"
1135
1136    if [[ -z "$1" && $TODOTXT_FORCE = 0 ]]; then
1137        echo -n "Append: "
1138        read -e -r input
1139    else
1140        input=$*
1141    fi
1142    case "$input" in
1143      [$SENTENCE_DELIMITERS]*)  appendspace=;;
1144      *)                        appendspace=" ";;
1145    esac
1146    cleaninput "for sed"
1147
1148    if sed -i.bak "${item} s|^.*|&${appendspace}${input}|" "$TODO_FILE"; then
1149        if [ "$TODOTXT_VERBOSE" -gt 0 ]; then
1150            getNewtodo "$item"
1151            echo "$item $newtodo"
1152	fi
1153    else
1154        die "TODO: Error appending task $item."
1155    fi
1156    ;;
1157
1158"archive" )
1159    # defragment blank lines
1160    sed -i.bak -e '/./!d' "$TODO_FILE"
1161    [ "$TODOTXT_VERBOSE" -gt 0 ] && grep "^x " "$TODO_FILE"
1162    grep "^x " "$TODO_FILE" >> "$DONE_FILE"
1163    sed -i.bak '/^x /d' "$TODO_FILE"
1164    if [ "$TODOTXT_VERBOSE" -gt 0 ]; then
1165	echo "TODO: $TODO_FILE archived."
1166    fi
1167    ;;
1168
1169"del" | "rm" )
1170    # replace deleted line with a blank line when TODOTXT_PRESERVE_LINE_NUMBERS is 1
1171    errmsg="usage: $TODO_SH del ITEM# [TERM]"
1172    item=$2
1173    getTodo "$item"
1174
1175    if [ -z "$3" ]; then
1176        if  [ $TODOTXT_FORCE = 0 ]; then
1177            echo "Delete '$todo'?  (y/n)"
1178            read -e -r ANSWER
1179        else
1180            ANSWER="y"
1181        fi
1182        if [ "$ANSWER" = "y" ]; then
1183            if [ $TODOTXT_PRESERVE_LINE_NUMBERS = 0 ]; then
1184                # delete line (changes line numbers)
1185                sed -i.bak -e "${item}s/^.*//" -e '/./!d' "$TODO_FILE"
1186            else
1187                # leave blank line behind (preserves line numbers)
1188                sed -i.bak -e "${item}s/^.*//" "$TODO_FILE"
1189            fi
1190            if [ "$TODOTXT_VERBOSE" -gt 0 ]; then
1191                echo "$item $todo"
1192                echo "TODO: $item deleted."
1193            fi
1194        else
1195            echo "TODO: No tasks were deleted."
1196        fi
1197    else
1198        sed -i.bak \
1199            -e "${item}s/^\((.) \)\{0,1\} *$3 */\1/g" \
1200            -e "${item}s/ *$3 *\$//g" \
1201            -e "${item}s/  *$3 */ /g" \
1202            -e "${item}s/ *$3  */ /g" \
1203            -e "${item}s/$3//g" \
1204            "$TODO_FILE"
1205        getNewtodo "$item"
1206        if [ "$todo" = "$newtodo" ]; then
1207            [ "$TODOTXT_VERBOSE" -gt 0 ] && echo "$item $todo"
1208            die "TODO: '$3' not found; no removal done."
1209        fi
1210        if [ "$TODOTXT_VERBOSE" -gt 0 ]; then
1211            echo "$item $todo"
1212            echo "TODO: Removed '$3' from task."
1213            echo "$item $newtodo"
1214        fi
1215    fi
1216    ;;
1217
1218"depri" | "dp" )
1219    errmsg="usage: $TODO_SH depri ITEM#[, ITEM#, ITEM#, ...]"
1220    shift;
1221    [ $# -eq 0 ] && die "$errmsg"
1222
1223    # Split multiple depri's, if comma separated change to whitespace separated
1224    # Loop the 'depri' function for each item
1225    for item in ${*//,/ }; do
1226        getTodo "$item"
1227
1228	if [[ "$todo" = \(?\)\ * ]]; then
1229	    sed -i.bak -e "${item}s/^(.) //" "$TODO_FILE"
1230	    if [ "$TODOTXT_VERBOSE" -gt 0 ]; then
1231		getNewtodo "$item"
1232		echo "$item $newtodo"
1233		echo "TODO: $item deprioritized."
1234	    fi
1235	else
1236	    echo "TODO: $item is not prioritized."
1237	fi
1238    done
1239    ;;
1240
1241"do" | "done" )
1242    errmsg="usage: $TODO_SH do ITEM#[, ITEM#, ITEM#, ...]"
1243    # shift so we get arguments to the do request
1244    shift;
1245    [ "$#" -eq 0 ] && die "$errmsg"
1246
1247    # Split multiple do's, if comma separated change to whitespace separated
1248    # Loop the 'do' function for each item
1249    for item in ${*//,/ }; do
1250        getTodo "$item"
1251
1252        # Check if this item has already been done
1253        if [ "${todo:0:2}" != "x " ]; then
1254            now=$(date '+%Y-%m-%d')
1255            # remove priority once item is done
1256            sed -i.bak "${item}s/^(.) //" "$TODO_FILE"
1257            sed -i.bak "${item}s|^|x $now |" "$TODO_FILE"
1258            if [ "$TODOTXT_VERBOSE" -gt 0 ]; then
1259                getNewtodo "$item"
1260                echo "$item $newtodo"
1261                echo "TODO: $item marked as done."
1262	    fi
1263        else
1264            echo "TODO: $item is already marked done."
1265        fi
1266    done
1267
1268    if [ $TODOTXT_AUTO_ARCHIVE = 1 ]; then
1269        # Recursively invoke the script to allow overriding of the archive
1270        # action.
1271        "$TODO_FULL_SH" archive
1272    fi
1273    ;;
1274
1275"help" )
1276    shift  ## Was help; new $1 is first help topic / action name
1277    if [ $# -gt 0 ]; then
1278        # Don't use PAGER here; we don't expect much usage output from one / few actions.
1279        actionUsage "$@"
1280    else
1281        if [ -t 1 ] ; then # STDOUT is a TTY
1282            if which "${PAGER:-less}" >/dev/null 2>&1; then
1283                # we have a working PAGER (or less as a default)
1284                help | "${PAGER:-less}" && exit 0
1285            fi
1286        fi
1287        help # just in case something failed above, we go ahead and just spew to STDOUT
1288    fi
1289    ;;
1290
1291"shorthelp" )
1292    if [ -t 1 ] ; then # STDOUT is a TTY
1293        if which "${PAGER:-less}" >/dev/null 2>&1; then
1294            # we have a working PAGER (or less as a default)
1295            shorthelp | "${PAGER:-less}" && exit 0
1296        fi
1297    fi
1298    shorthelp # just in case something failed above, we go ahead and just spew to STDOUT
1299    ;;
1300
1301"list" | "ls" )
1302    shift  ## Was ls; new $1 is first search term
1303    _list "$TODO_FILE" "$@"
1304    ;;
1305
1306"listall" | "lsa" )
1307    shift  ## Was lsa; new $1 is first search term
1308
1309    TOTAL=$( sed -n '$ =' "$TODO_FILE" )
1310    PADDING=${#TOTAL}
1311
1312    post_filter_command="${post_filter_command:-}${post_filter_command:+ | }awk -v TOTAL=$TOTAL -v PADDING=$PADDING '{ \$1 = sprintf(\"%\" PADDING \"d\", (\$1 > TOTAL ? 0 : \$1)); print }' "
1313    cat "$TODO_FILE" "$DONE_FILE" | TODOTXT_VERBOSE=0 _format '' "$PADDING" "$@"
1314
1315    if [ "$TODOTXT_VERBOSE" -gt 0 ]; then
1316        TDONE=$( sed -n '$ =' "$DONE_FILE" )
1317        TASKNUM=$(TODOTXT_PLAIN=1 TODOTXT_VERBOSE=0 _format "$TODO_FILE" 1 "$@" | sed -n '$ =')
1318        DONENUM=$(TODOTXT_PLAIN=1 TODOTXT_VERBOSE=0 _format "$DONE_FILE" 1 "$@" | sed -n '$ =')
1319        echo "--"
1320        echo "$(getPrefix "$TODO_FILE"): ${TASKNUM:-0} of ${TOTAL:-0} tasks shown"
1321        echo "$(getPrefix "$DONE_FILE"): ${DONENUM:-0} of ${TDONE:-0} tasks shown"
1322        echo "total $((TASKNUM + DONENUM)) of $((TOTAL + TDONE)) tasks shown"
1323    fi
1324    ;;
1325
1326"listfile" | "lf" )
1327    shift  ## Was listfile, next $1 is file name
1328    if [ $# -eq 0 ]; then
1329        [ "$TODOTXT_VERBOSE" -gt 0 ] && echo "Files in the todo.txt directory:"
1330        cd "$TODO_DIR" && ls -1 -- *.txt
1331    else
1332        FILE="$1"
1333        shift  ## Was filename; next $1 is first search term
1334
1335        _list "$FILE" "$@"
1336    fi
1337    ;;
1338
1339"listcon" | "lsc" )
1340    shift
1341    listWordsWithSigil '@' "$@"
1342    ;;
1343
1344"listproj" | "lsprj" )
1345    shift
1346    listWordsWithSigil '+' "$@"
1347    ;;
1348
1349"listpri" | "lsp" )
1350    shift ## was "listpri", new $1 is priority to list or first TERM
1351
1352    pri=$(printf "%s\n" "$1" | tr '[:lower:]' '[:upper:]' | grep -e '^[A-Z]$' -e '^[A-Z]-[A-Z]$') && shift || pri="A-Z"
1353    post_filter_command="${post_filter_command:-}${post_filter_command:+ | }grep '^ *[0-9]\+ ([${pri}]) '"
1354    _list "$TODO_FILE" "$@"
1355    ;;
1356
1357"move" | "mv" )
1358    # replace moved line with a blank line when TODOTXT_PRESERVE_LINE_NUMBERS is 1
1359    errmsg="usage: $TODO_SH mv ITEM# DEST [SRC]"
1360    item=$2
1361    dest="$TODO_DIR/$3"
1362    src="$TODO_DIR/$4"
1363
1364    [ -z "$4" ] && src="$TODO_FILE"
1365    [ -z "$dest" ] && die "$errmsg"
1366
1367    [ -f "$src" ] || die "TODO: Source file $src does not exist."
1368    [ -f "$dest" ] || die "TODO: Destination file $dest does not exist."
1369
1370    getTodo "$item" "$src"
1371    [ -z "$todo" ] && die "$item: No such item in $src."
1372    if  [ $TODOTXT_FORCE = 0 ]; then
1373        echo "Move '$todo' from $src to $dest? (y/n)"
1374        read -e -r ANSWER
1375    else
1376        ANSWER="y"
1377    fi
1378    if [ "$ANSWER" = "y" ]; then
1379        if [ $TODOTXT_PRESERVE_LINE_NUMBERS = 0 ]; then
1380            # delete line (changes line numbers)
1381            sed -i.bak -e "${item}s/^.*//" -e '/./!d' "$src"
1382        else
1383            # leave blank line behind (preserves line numbers)
1384            sed -i.bak -e "${item}s/^.*//" "$src"
1385        fi
1386        fixMissingEndOfLine "$dest"
1387        echo "$todo" >> "$dest"
1388
1389        if [ "$TODOTXT_VERBOSE" -gt 0 ]; then
1390            echo "$item $todo"
1391            echo "TODO: $item moved from '$src' to '$dest'."
1392        fi
1393    else
1394        echo "TODO: No tasks moved."
1395    fi
1396    ;;
1397
1398"prepend" | "prep" )
1399    errmsg="usage: $TODO_SH prepend ITEM# \"TEXT TO PREPEND\""
1400    replaceOrPrepend 'prepend' "$@"
1401    ;;
1402
1403"pri" | "p" )
1404    item=$2
1405    newpri=$( printf "%s\n" "$3" | tr '[:lower:]' '[:upper:]' )
1406
1407    errmsg="usage: $TODO_SH pri ITEM# PRIORITY
1408note: PRIORITY must be anywhere from A to Z."
1409
1410    [ "$#" -ne 3 ] && die "$errmsg"
1411    [[ "$newpri" = @([A-Z]) ]] || die "$errmsg"
1412    getTodo "$item"
1413
1414    oldpri=
1415    if [[ "$todo" = \(?\)\ * ]]; then
1416        oldpri=${todo:1:1}
1417    fi
1418
1419    if [ "$oldpri" != "$newpri" ]; then
1420        sed -i.bak -e "${item}s/^(.) //" -e "${item}s/^/($newpri) /" "$TODO_FILE"
1421    fi
1422    if [ "$TODOTXT_VERBOSE" -gt 0 ]; then
1423        getNewtodo "$item"
1424        echo "$item $newtodo"
1425        if [ "$oldpri" != "$newpri" ]; then
1426            if [ "$oldpri" ]; then
1427                echo "TODO: $item re-prioritized from ($oldpri) to ($newpri)."
1428            else
1429                echo "TODO: $item prioritized ($newpri)."
1430            fi
1431        fi
1432    fi
1433    if [ "$oldpri" = "$newpri" ]; then
1434        echo "TODO: $item already prioritized ($newpri)."
1435    fi
1436    ;;
1437
1438"replace" )
1439    errmsg="usage: $TODO_SH replace ITEM# \"UPDATED ITEM\""
1440    replaceOrPrepend 'replace' "$@"
1441    ;;
1442
1443"report" )
1444    # archive first
1445    # Recursively invoke the script to allow overriding of the archive
1446    # action.
1447    "$TODO_FULL_SH" archive
1448
1449    TOTAL=$( sed -n '$ =' "$TODO_FILE" )
1450    TDONE=$( sed -n '$ =' "$DONE_FILE" )
1451    NEWDATA="${TOTAL:-0} ${TDONE:-0}"
1452    LASTREPORT=$(sed -ne '$p' "$REPORT_FILE")
1453    LASTDATA=${LASTREPORT#* }   # Strip timestamp.
1454    if [ "$LASTDATA" = "$NEWDATA" ]; then
1455        echo "$LASTREPORT"
1456        [ "$TODOTXT_VERBOSE" -gt 0 ] && echo "TODO: Report file is up-to-date."
1457    else
1458        NEWREPORT="$(date +%Y-%m-%dT%T) ${NEWDATA}"
1459        echo "${NEWREPORT}" >> "$REPORT_FILE"
1460        echo "${NEWREPORT}"
1461        [ "$TODOTXT_VERBOSE" -gt 0 ] && echo "TODO: Report file updated."
1462    fi
1463    ;;
1464
1465"deduplicate" )
1466    if [ $TODOTXT_PRESERVE_LINE_NUMBERS = 0 ]; then
1467        deduplicateSedCommand='d'
1468    else
1469        deduplicateSedCommand='s/^.*//; p'
1470    fi
1471
1472    # To determine the difference when deduplicated lines are preserved, only
1473    # non-empty lines must be counted.
1474    originalTaskNum=$( sed -e '/./!d' "$TODO_FILE" | sed -n '$ =' )
1475
1476    # Look for duplicate lines and discard the second occurrence.
1477    # We start with an empty hold space on the first line.  For each line:
1478    #   G - appends newline + hold space to the pattern space
1479    #   s/\n/&&/; - double up the first new line so we catch adjacent dups
1480    #   /^\([^\n]*\n\).*\n\1/b dedup
1481    #       If the first line of the hold space shows up again later as an
1482    #       entire line, it's a duplicate. Jump to the "dedup" label, where
1483    #       either of the following is executed, depending on whether empty
1484    #       lines should be preserved:
1485    #       d           - Delete the current pattern space, quit this line and
1486    #                     move on to the next, or:
1487    #       s/^.*//; p  - Clear the task text, print this line and move on to
1488    #                     the next.
1489    #   s/\n//;   - else (no duplicate), drop the doubled newline
1490    #   h;        - replace the hold space with the expanded pattern space
1491    #   P;        - print up to the first newline (that is, the input line)
1492    #   b         - end processing of the current line
1493    sed -i.bak -n \
1494        -e 'G; s/\n/&&/; /^\([^\n]*\n\).*\n\1/b dedup' \
1495        -e 's/\n//; h; P; b' \
1496        -e ':dedup' \
1497        -e "$deduplicateSedCommand" \
1498        "$TODO_FILE"
1499
1500    newTaskNum=$( sed -e '/./!d' "$TODO_FILE" | sed -n '$ =' )
1501    deduplicateNum=$(( originalTaskNum - newTaskNum ))
1502    if [ $deduplicateNum -eq 0 ]; then
1503        echo "TODO: No duplicate tasks found"
1504    else
1505        echo "TODO: $deduplicateNum duplicate task(s) removed"
1506    fi
1507    ;;
1508
1509"listaddons" )
1510    if [ -d "$TODO_ACTIONS_DIR" ]; then
1511        cd "$TODO_ACTIONS_DIR" || exit $?
1512        for action in *
1513        do
1514            if [ -f "$action" ] && [ -x "$action" ]; then
1515                echo "$action"
1516            elif [ -d "$action" ] && [ -x "$action/$action" ]; then
1517                echo "$action"
1518            fi
1519        done
1520    fi
1521    ;;
1522
1523* )
1524    usage;;
1525esac
1526