#!/usr/bin/env ruby # notyet [-ace] [-f FILE] [FILTERS...] - a todo tracker # # notyet is in the public domain. # To the extent possible under law, Leah Neukirchen # has waived all copyright and related or neighboring rights to this work. # http://creativecommons.org/publicdomain/zero/1.0/ require 'optionparser' require 'date' require 'time' TODAY = Date.today NOW = Time.now DEFER_DAYS = 14 class Entry < Struct.new(:depth, :state, :desc, :file, :line, :children) def reindent(change) self.depth += change children.each { |c| c.reindent change } self end def dump unless $show_all return if "xX".index(state) return if count("xX") == count end if depth < 0 puts "#{desc.strip} [#{stat}]".lstrip unless $edit else stat = children.empty? ? "" : " [#{self.stat}]" print "#{file}:#{line}\t" if $edit && depth >= 0 print "#{" " * depth}#{state} #{desc}#{stat}\n" if depth >= 0 end children.each { |c| c.dump } end def stat children.each { |c| c.stat } @stat ||= "#{count("xX")}/#{count("-xX")}" end def count(s=nil) if children.empty? && (s.nil? || s.index(state)) return 1 else children.map { |c| c.count(s) }.sum end end def sort children.each { |c| c.sort } children.sort_by! { |c| [c.state, c.dateorder, c.desc] } end def dateorder if desc =~ /^(?:\(.*?\)\s*)?\[(\d\d\d\d-\d\d-\d\d)( \d\d:\d\d)?\]([+!~-]?)/ d = Time.parse("#{$1}#{$2}") case $3 when "+", "" # todo (default) = earliest first d - NOW when "-" # reminder = sort by difference to now (d - NOW).abs when "!" # deadline, drop after the time if NOW < d d - NOW else Float::INFINITY end when "~" # defer, sink and raise periodically if NOW < d d - NOW else # sawtooth function (DEFER_DAYS - (((TODAY - d.to_date) % (2*DEFER_DAYS)) - DEFER_DAYS).abs) * 24*60*60 end end else Float::INFINITY end end def propagate unless children.empty? children.each { |c| c.propagate } if children.all? { |c| "X".index(c.state) } self.state = "X" elsif children.all? { |c| "xX".index(c.state) } self.state = "x" elsif children.all? { |c| "?xX".index(c.state) } self.state = "?" end force(state) if "?xX".index(state) if desc =~ /^(?:\(.*?\)\s*)?\[(\d\d\d\d-\d\d-\d\d)( \d\d:\d\d)?\]/ diff = "%+d" % (Date.parse($1) - TODAY) self.desc = "[#{$1}#{$2}] {#{diff}}#{$'}" else soonest = children.map { |c| "-?".index(c.state) && c.desc[/^(?:\(.*?\)\s*)?\[(\d\d\d\d-\d\d-\d\d)( \d\d:\d\d)?\]/, 1] }.compact.min if soonest diff = "%+d" % (Date.parse(soonest) - TODAY) self.desc = "[#{soonest}] {#{diff}} #{desc}" end end end end def force(state) self.state = state unless "xX?*".index(self.state) children.each { |c| c.force(state) } end # remove two layers of parents def splat self.children = children.map { |c| c.splat if c.desc == "#splat" if c.children.empty? [] else sub = c.children.map { |cc| cc.children }.flatten rel = sub.first.depth - c.depth sub.map { |cc| cc.reindent(-rel) } end else [c] end }.flatten(1) end def match1(word) desc =~ %r{(^|\W)#{Regexp.quote word.to_s}($|\W)} end def match(word) # need to match all non-@ words, and one of the mentioned @-words (if any) tags, words = word.partition { |w| w =~ /^@/ } (tags.empty? || tags.any? { |w| match1(w) }) && words.all? { |w| match1(w) } end def rmatch(word) match(word) || children.any? { |c| c.rmatch(word) } end def filter(word) if !match(word) children.reject! { |c| !c.rmatch(word) } children.each { |c| c.filter(word) } end end end def parse(io, filename=nil) todos = [Entry.new(-1, "/", "", "", 0, [])] filter = (io.stat.ftype != "file") while line = io.gets if filter && line =~ /^(.*):(\d+)\t/ fname, lineno, line = $1, $2.to_i, $' else fname, lineno = filename, io.lineno end if line =~ /\A(?:\s*(?:#|\/\/)\s)?(\s*)([-xX?*])\s+(.*)/ i, state, desc = $1.size, $2, $3 while i <= todos.last.depth todos.pop end children = [] if desc =~ /^#exec\s+(.+)/ Dir.chdir(File.dirname(fname)) { begin IO.popen($1) { |f| sub = parse(f, $1) sub.reindent(i+2) children = sub.children } rescue SystemCallError => e children = [Entry.new(i+2, "?", "ERROR: #{e}", fname, lineno, [])] end } next if children.empty? elsif desc =~ /^#include\s+(\S+)/ n = File.expand_path($1, File.dirname(fname)) begin File.open(n) { |f| sub = parse(f, $1) sub.reindent(i+2) children = sub.children } rescue SystemCallError => e children = [Entry.new(i+2, "?", "ERROR: #{e}", fname, lineno, [])] end next if children.empty? elsif desc =~ /^#includeall\s+(\S+)/ Dir.glob(File.expand_path($1, File.dirname(fname))) { |file| File.open(file) { |f| sub = parse(f, $1) sub.reindent(i+4) children << Entry.new(i+2, state, "#include #{file}", fname, lineno, sub.children) } } next if children.empty? end e = Entry.new(i, state, desc, fname, lineno, children) todos.last.children << e todos << e end end todos.first end files = [] filter = [] $show_all = $edit = false count_only = false opt_parser = OptionParser.new { |opts| opts.banner = "Usage: notyet [-ace] [-f FILE] [FILTERS...]" opts.summary_width = 9 opts.on("-a", "show all tasks (default: open tasks only)") { $show_all = true } opts.on("-c", "only show total count") { count_only = true } opts.on("-f FILE", "read tasks from FILE (default ~/.notyet)") { |f| files << f } opts.on("-e", "edit tasks in vim") { $edit = true } }.parse! ARGV files = [File.expand_path("~/.notyet")] if files.empty? t = nil if files.size > 1 t = Entry.new(-1, "/", "", "", 0, []) end files.each { |file| file = "/dev/stdin" if file == "-" File.open(file) { |f| d = parse(f, file) if t d.state = "-" d.desc = file d.reindent(2) d.depth = 0 t.children << d else t = d end } } t.splat t.propagate t.sort t.stat t.filter(ARGV) if count_only puts "#{t.count("xX")}/#{t.count("-xX*")}" exit end if t.children.empty? && !ARGV.empty? exit 1 end if $edit && STDOUT.tty? rd, wr = IO.pipe $stdout = wr Process.spawn('vim', '+nmap ^gF', '+nmap ', '+nnoremap o ^gFo- |' \ 'nnoremap x ^gF^rx | nnoremap X ^gF^rX |' \ 'nnoremap - ^gF^r- | nnoremap ? ^gF^r?', '+setl nomod noma nowrap conceallevel=3 concealcursor=nc', '+syn match Conceal /^.\{-}\t/ conceal', '+syn match Comment /\t\s*[xX].*/lc=1', '+hi Comment cterm=NONE ctermfg=darkgrey ctermbg=NONE guifg=#777777 guibg=NONE', '-', 0 => rd) t.dump $stdout.close Process.wait exit $?.exitstatus else t.dump end