class Todo

Constants

COLORS
COLOR_CODES
CONTEXT_TAG_PATTERN
DATE_FORMAT
DUE_DATE_DAYS_SIMPLE
DUE_DATE_TAG_PATTERN
ORDER
PRIORITY_FLAG
STATES
TODO_FILE

Public Instance Methods

execute(arguments) click to toggle source
# File bin/todo.rb, line 77
def execute(arguments)
  begin
    setup
    action = arguments.first
    args = arguments.drop(1)
    case action
    when 'add'
      raise action + ' command requires at least one parameter' if args.empty?
      add(args.join(' '))
    when 'start'
      args.length > 0 ? change_state(args.first.to_i, 'started', args.drop(1).join(' ')) : list(nil, [':started'])
    when 'done'
      args.length > 0 ? change_state(args.first.to_i, 'done', args.drop(1).join(' ')) : list(nil, [':done'])
    when 'block'
      args.length > 0 ? change_state(args.first.to_i, 'blocked', args.drop(1).join(' ')) : list(nil, [':blocked'])
    when 'wait'
      args.length > 0 ? change_state(args.first.to_i, 'waiting', args.drop(1).join(' ')) : list(nil, [':waiting'])
    when 'reset'
      args.length > 0 ? change_state(args.first.to_i, 'new', args.drop(1).join(' ')) : list(nil, [':new'])
    when 'prio'
      raise action + ' command requires at least one parameter' if args.length < 1
      set_priority(args.first.to_i, args.drop(1).join(' '))
    when 'due'
      raise action + ' command requires at least one parameter' if args.length < 1
      due_date(args.first.to_i, args.drop(1).join(' '))
    when 'append'
      raise action + ' command requires at least two parameters' if args.length < 2
      append(args.first.to_i, args.drop(1).join(' '))
    when 'rename'
      raise action + ' command requires at least two parameters' if args.length < 2
      rename(args.first.to_i, args.drop(1).join(' '))
    when 'del'
      raise action + ' command requires exactly one parameter' if args.length != 1
      delete(args.first.to_i)
    when 'note'
      raise action + ' command requires at least two parameters' if args.length < 2
      add_note(args.first.to_i, args.drop(1).join(' '))
    when 'delnote'
      raise action + ' command requires one or two parameters' if args.length < 1 || args.length > 2
      delete_note(args.first.to_i, args[1])
    when 'list'
      list(nil, args)
    when 'show'
      raise action + ' command requires exactly one parameter' if args.length != 1
      show(args.first.to_i)
    when 'help'
      raise action + ' command has no parameters' if args.length > 0
      puts usage
    when 'repl'
      raise action + ' command has no parameters' if args.length > 0
      start_repl
    when 'cleanup'
      raise action + ' command requires at least one parameter' if args.empty?
      cleanup(args)
    else
      list(nil, arguments)
    end
  rescue StandardError => error
    puts "#{colorize('ERROR:', :red)} #{error}"
  end
  self
end

Private Instance Methods

add(text) click to toggle source
# File bin/todo.rb, line 237
def add(text)
  task = { state: 'new', title: text, modified: @today.strftime(DATE_FORMAT) }
  postprocess_tags(task)
  File.open(TODO_FILE, 'a:UTF-8') { |file| file.write(JSON.generate(task) + "\n") }
  list
end
add_note(item, text) click to toggle source
# File bin/todo.rb, line 341
def add_note(item, text)
  update_task(item, :show, lambda do |task|
    task[:note] ||= []
    task[:note].push(text)
  end)
end
append(item, text) click to toggle source
# File bin/todo.rb, line 255
def append(item, text)
  update_task(item, :list, lambda do |task|
    task[:title] = [task[:title], text].join(' ')
    postprocess_tags(task)
  end)
end
change_state(item, state, note = nil) click to toggle source
# File bin/todo.rb, line 276
def change_state(item, state, note = nil)
  update_task(item, :list, lambda do |task|
    task[:state] = state
    if !note.nil? && !note.empty?
      task[:note] ||= []
      task[:note].push(note)
    end
  end)
end
cleanup(patterns) click to toggle source
# File bin/todo.rb, line 383
def cleanup(patterns)
  tasks = load_tasks
  patterns = [':done'] + patterns.to_a
  items = filter_tasks(tasks, patterns)
  items.each_key { |num| tasks.delete(num) }
  write_tasks(tasks)
  puts "Deleted #{items.size} todo(s)"
end
colorize(text, color) click to toggle source
# File bin/todo.rb, line 401
def colorize(text, color)
  "\e[#{COLOR_CODES[color] || 37}m#{text}\e[0m"
end
convert_due_date(date) click to toggle source
# File bin/todo.rb, line 405
def convert_due_date(date)
  day_index = @due_date_days.index(date.to_s.downcase) ||
    DUE_DATE_DAYS_SIMPLE.index(date.to_s.downcase) ||
    @due_date_days.map { |day| day[0..2] }.index(date.to_s.downcase)
  return (@today + day_index).strftime(DATE_FORMAT) if day_index
  date.nil? || date.empty? ? nil : Date.strptime(date, DATE_FORMAT).strftime(DATE_FORMAT)
end
delete(item) click to toggle source
# File bin/todo.rb, line 269
def delete(item)
  tasks = load_tasks(item)
  tasks.delete(item)
  write_tasks(tasks)
  list
end
delete_note(item, num = nil) click to toggle source
# File bin/todo.rb, line 348
def delete_note(item, num = nil)
  update_task(item, :show, lambda do |task|
    if num.to_s.empty?
      task.delete(:note)
    else
      raise "#{num.to_i}: Note does not exist" if num.to_i <= 0 || task[:note].to_a.size < num.to_i
      task[:note].delete_at(num.to_i - 1)
      task.delete(:note) if task[:note].empty?
    end
  end)
end
due_date(item, date = '') click to toggle source
# File bin/todo.rb, line 297
def due_date(item, date = '')
  update_task(item, :list, lambda do |task|
    task[:due] = convert_due_date(date)
    task.delete(:due) if task[:due].nil?
  end)
end
filter_tasks(tasks, patterns) click to toggle source
# File bin/todo.rb, line 392
def filter_tasks(tasks, patterns)
  patterns = patterns.uniq
  tasks.select do |num, task|
    patterns.all? do |pattern|
      @queries[pattern] ? @queries[pattern].call(task) : /#{pattern}/ix.match(task[:title])
    end
  end
end
list(tasks = nil, patterns = nil) click to toggle source
# File bin/todo.rb, line 304
def list(tasks = nil, patterns = nil)
  tasks ||= load_tasks
  task_indent = [tasks.keys.max.to_s.size, 4].max
  patterns ||= []
  patterns += [':active'] if (patterns & [':active', ':done', ':blocked', ':started', ':new', ':all', ':waiting']).empty?
  items = filter_tasks(tasks, patterns).sort_by do |num, task|
    [
      task[:priority] && task[:state] != 'done' ? 0 : 1,
      ORDER[task[:state] || 'default'] || ORDER['default'],
      task[:state] != 'done' ? task[:due] || 'n/a' : task[:modified],
      num
    ]
  end
  items.each do |num, task|
    state = task[:state] || 'default'
    display_state = colorize(STATES[state], COLORS[state])
    title = task[:title].gsub(CONTEXT_TAG_PATTERN) do |tag|
      (tag.start_with?(' ') ? ' ' : '') + colorize(tag.strip, :cyan)
    end
    priority_flag = task[:priority] && state != 'done' ? colorize(PRIORITY_FLAG, :red) : ' '
    due_date = ''
    if task[:due] && state != 'done'
      date_diff = (Date.strptime(task[:due], DATE_FORMAT) - @today).to_i
      if date_diff < 0
        due_date = colorize("(#{date_diff.abs}d overdue)", :red)
      elsif date_diff == 0 || date_diff == 1
        due_date = colorize("(#{DUE_DATE_DAYS_SIMPLE[date_diff]})", :yellow)
      else
        due_date = colorize("(#{@due_date_days[date_diff] || task[:due]})", :magenta) if date_diff > 1
      end
      due_date = ' ' + due_date
    end
    puts "#{num.to_s.rjust(task_indent)}:#{priority_flag}#{display_state} #{title}#{due_date}"
  end
  puts 'No todos found' if items.empty?
end
load_tasks(item_to_check = nil) click to toggle source
# File bin/todo.rb, line 206
def load_tasks(item_to_check = nil)
  count = 0
  tasks = {}
  if File.exist?(TODO_FILE)
    File.open(TODO_FILE, 'r:UTF-8') do |file|
      file.each_line do |line|
        next if line.strip == ''
        count += 1
        tasks[count] = JSON.parse(line.chomp, :symbolize_names => true)
      end
    end
  end
  raise "#{item_to_check}: No such todo" if item_to_check && !tasks.has_key?(item_to_check)
  tasks
end
postprocess_tags(task) click to toggle source
# File bin/todo.rb, line 228
def postprocess_tags(task)
  match_data = task[:title].match(DUE_DATE_TAG_PATTERN)
  if match_data
    task[:title] = task[:title].gsub(DUE_DATE_TAG_PATTERN, '')
    task[:due] = convert_due_date(match_data[2])
  end
  raise 'title must not be empty' if task[:title].empty?
end
rename(item, text) click to toggle source
# File bin/todo.rb, line 262
def rename(item, text)
  update_task(item, :list, lambda do |task|
    task[:title] = text
    postprocess_tags(task)
  end)
end
set_priority(item, note = nil) click to toggle source
# File bin/todo.rb, line 286
def set_priority(item, note = nil)
  update_task(item, :list, lambda do |task|
    task[:priority] = !task[:priority]
    task.delete(:priority) if !task[:priority]
    if !note.nil? && !note.empty?
      task[:note] ||= []
      task[:note].push(note)
    end
  end)
end
setup() click to toggle source
# File bin/todo.rb, line 181
def setup
  @today = Date.today
  next_7_days = (0..6).map { |day| @today + day }
  @due_date_days = next_7_days.map { |day| day.strftime('%A').downcase }
  due_dates_for_queries = next_7_days.map { |day| day.strftime(DATE_FORMAT) }
  recent_date = (@today - 7).strftime(DATE_FORMAT)
  @queries = {
    ':active'    => lambda { |task| /(new|started|blocked|waiting)/.match(task[:state]) },
    ':done'      => lambda { |task| 'done' == task[:state] },
    ':blocked'   => lambda { |task| 'blocked' == task[:state] },
    ':waiting'   => lambda { |task| 'waiting' == task[:state] },
    ':started'   => lambda { |task| 'started' == task[:state] },
    ':new'       => lambda { |task| 'new' == task[:state] },
    ':all'       => lambda { |task| /\w+/.match(task[:state]) },
    ':priority'  => lambda { |task| task[:priority] },
    ':note'      => lambda { |task| task[:note] && !task[:note].empty? },
    ':today'     => lambda { |task| due_dates_for_queries[0] == task[:due] },
    ':tomorrow'  => lambda { |task| due_dates_for_queries[1] == task[:due] },
    ':next7days' => lambda { |task| /(#{due_dates_for_queries.join('|')})/.match(task[:due]) },
    ':overdue'   => lambda { |task| task[:due] && task[:due] < due_dates_for_queries[0] },
    ':due'       => lambda { |task| task[:due] },
    ':recent'    => lambda { |task| recent_date <= task[:modified] }
  }
end
show(item, tasks = nil) click to toggle source
# File bin/todo.rb, line 360
def show(item, tasks = nil)
  tasks ||= load_tasks(item)
  tasks[item].each do |k, v|
    v = "\n" + v.each_with_index.
      map { |n, i| v.size > 1 ? "#{(i + 1).to_s.rjust(v.size.to_s.size)}: #{n}" : n }.
      join("\n") if v.is_a?(Array)
    puts "#{colorize(k.to_s.rjust(10) + ':', :cyan)} #{v}"
  end
end
start_repl() click to toggle source
# File bin/todo.rb, line 370
def start_repl
  command = ''
  while !['exit', 'quit'].include?(command)
    if ['clear', 'cls'].include?(command)
      print "\e[H\e[2J"
    else
      execute(command == 'repl' ? [] : command.split(/\s+/))
    end
    print "\ntodo> "
    command = STDIN.gets.chomp.strip
  end
end
update_task(item, post_action, update_function) click to toggle source
# File bin/todo.rb, line 244
def update_task(item, post_action, update_function)
  tasks = load_tasks(item)
  update_function.call(tasks[item])
  tasks[item][:modified] = @today.strftime(DATE_FORMAT)
  write_tasks(tasks)
  case post_action
  when :show then show(item, tasks)
  when :list then list(tasks)
  end
end
usage() click to toggle source
# File bin/todo.rb, line 142
  def usage
    <<~USAGE
      Usage: todo <command> <arguments>

      Commands:
      * add <text>                     add new task
      * start <tasknumber> [text]      mark task as started, with optional note
      * done <tasknumber> [text]       mark task as completed, with optional note
      * block <tasknumber> [text]      mark task as blocked, with optional note
      * wait <tasknumber> [text]       mark task as waiting, with optional note
      * reset <tasknumber> [text]      reset task to new state, with optional note
      * prio <tasknumber> [text]       toggle high priority flag, with optional note
      * due <tasknumber> [date]        set/unset due date (in YYYY-MM-DD format)

      * append <tasknumber> <text>     append text to task title
      * rename <tasknumber> <text>     rename task
      * del <tasknumber>               delete task
      * note <tasknumber> <text>       add note to task
      * delnote <tasknumber> [number]  delete a specific or all notes from task

      * list <regex> [regex...]        list tasks (only active tasks by default)
      * show <tasknumber>              show all task details
      * repl                           enter read-eval-print loop mode
      * cleanup <regex> [regex...]     cleanup completed tasks by regex
      * help                           this help screen

      With list command the following pre-defined queries can be also used:
      #{@queries.keys.each_with_index.map { |k, i| (i == 8 ? "\n" : '') + k }.join(', ')}

      Due dates can be also added via tags in task title: "due:YYYY-MM-DD"
      In addition to formatted dates, you can use date synonyms:
      "due:today", "due:tomorrow", and day names e.g. "due:monday" or "due:tue"

      Legend: #{STATES.select { |k, v| k != 'default' }.map { |k, v| "#{k} #{v}" }.join(', ') }, priority #{PRIORITY_FLAG}

      Todo file: #{TODO_FILE}
    USAGE
  end
write_tasks(tasks) click to toggle source
# File bin/todo.rb, line 222
def write_tasks(tasks)
  File.open(TODO_FILE, 'w:UTF-8') do |file|
    tasks.keys.sort.each { |key| file.write(JSON.generate(tasks[key]) + "\n") }
  end
end