Skip to content

Instantly share code, notes, and snippets.

@ericgj
Created September 27, 2011 19:24
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save ericgj/1245973 to your computer and use it in GitHub Desktop.
Save ericgj/1245973 to your computer and use it in GitHub Desktop.
Todoer

My key requirements were

  • the syntax should be as close to simply writing a note to yourself as possible
  • it has to be easy to add or remove something from a todo list wherever you are in the filesystem
  • it should be easy to add or remove a task based on previous tasks added
  • it should not make changes to a file that you are editing manually
  • it should not rewrite the file each time a change is made, if possible

The commands to add and remove tasks are one-liner bash scripts to echo the command line to ~/.todo, basically. ++ is add, xx is remove. (You can name them whatever you like, of course.)

So

$ ++ personal, start the great american novel
$ xx freelance myproject, refactor the frobosh modules

results in two records added to ~/.todo :

+ [Tue Sep 20 12:10:13 EDT 2011] personal, start the great american novel
- [Tue Sep 20 12:10:50 EDT 2011] freelance myproject, refactor the frobosh modules

Anything before the first comma is treated as categories, anything after is the task itself.

So this gives you basically a log file. Then I wrote a little Ruby program to parse this and output to yaml (among other things).

I decided I wanted to use bash autocompletion to much the same effect as select menu autocompletion -- as you type it narrows down the choices to previously-entered tasks. Also, the ruby parser matches on the start of the line, so you can just type as much of the line as you need to make it unique and it will handle the rest.

Also I wrote another little bash tool called == for append-editing:

$ ++ personal, start the great american novel
$ == -a 'novel$' 'tomorrow'

This gives you a '-' and '+' record:

- [Tue Sep 20 14:15:38 EDT 2011] personal, start the great american novel
+ [Tue Sep 20 14:15:39 EDT 2011] personal, start the great american novel tomorrow

Of course for total flexibility you could just use sed to edit the file in-place, too.

So with these building blocks you could easily do something like

  • set a directory watcher on the .todo file and automatically redisplay a current todo list as it's edited (see example below)
  • serve up the .todo file through a web app and provide a GUI for adding tasks
  • stream it to loggly or PubSubHubbub it
  • parse it into commands to send to your arduino-controlled personal robot over IRC

etc....

It's not rocket science and I've taken ideas from others, but it could be useful. I'm especially happy about the autocompletion, which is such a timesaver. Also it gave me a chance to struggle with bash which I've been wanting to do.


Note that the ruby program below is just the basic idea. I have expanded it to include parsing out various things from the task description, using a markup format kinda similar to todo.txt, but simplified. I have person tags (@eric), other tags (=done), time estimates (~1h30m), and absolute or relative dates (due mon, started 1-Oct-2011), all of which can appear anywhere in the task description. (The only fixed thing is the categories which appear in the front.)

I've also split out the display methods into presenter classes and run them through Tilt templates. But all of this is a bit too much to put in a gist, so I left it simple, I'll put up the full code after CodeBrawl ends...

# Simple parser for ~/.todo file
# Format is like
#
# + [Tue Sep 20 12:10:13 EDT 2011] personal, walk the dog ~20m
# + [Tue Sep 20 12:10:50 EDT 2011] freelance general, take photo with @thomas
# - [Tue Sep 20 12:13:00 EDT 2011] freelance general, take photo
#
# + == added tasks
# - == finished tasks
#
# The description is split into two parts; anything before the first comma is
# treated as a hierarchy of categories, anything after is the task description
#
# More features are planned such as extracting @-tags (@thomas above)
# and time estimates (~15m) from the strings
#
# Note tasks are matched on the beginning of the description; so the finished
# task 'freelance general, take photo' matches the previous
# 'freelance general, take photo with @thomas'
#
# The ~/.todo file itself is the product of a few simple bash programs, see
# other gist files for details.
#
# See bottom of this file for usage examples.
#
require 'time'
require 'yaml'
require 'set'
class Todo
def self.parse(lines, &config)
new *lines.map {|line| LogEntry.parse(line) }.compact, &config
end
attr_reader :tasks
attr_accessor :mark_done
def initialize(*entries)
@tasks = []
yield self if block_given?
entries.sort_by(&:logtime).each do |e|
if e.add?; add e.task, e.categories; end
if e.sub?; sub e.task, e.categories; end
if e.change?; change e.task, e.categories; end
end
end
def add(task, categories=[])
@tasks << Task.new(task,categories)
end
def sub(task, categories=[], tag=self.mark_done)
task = Task.new(task,categories)
if tag then
@tasks.select {|t| task == t}.each do |t|
t.tag tag
end
else
@tasks.delete_if {|t| task == t}
end
end
def change(task, categories=[])
sub task, categories, nil
add task, categories
end
def aggregate(meth=nil)
tasks.inject({}) {|memo,task|
trav = memo
task.categories.each do |cat|
trav = ( trav[cat] ||= {} )
end
(trav['tasks'] ||= []) << (meth ? task.send(meth) : task)
memo
}
end
def flat_aggregate(meth=nil)
tasks.inject(Hash.new {|h,k| h[k]=[]}) {|memo, task|
memo[task.categories.join(' ')] << (meth ? task.send(meth) : task)
memo
}
end
def category(cat)
aggregate[cat]
end
alias [] category
# TODO move these to view class
def flat_to_yaml
YAML.dump(Hash[flat_aggregate(:name_with_tags).sort])
end
def to_yaml
YAML.dump(aggregate(:name_with_tags))
end
class Task
attr_reader :categories, :tags
attr_accessor :name
def initialize(task, categories=[])
@name, @categories = task, categories
@tags = Set.new
end
def recategorize(*cats)
@categories = cats
end
def categorize(cat)
@categories.pop
@categories << cat
end
def tag(t)
@tags << t
end
def name_with_tags
"#{self.name}" +
(self.tags.empty? ? "" : " (#{self.tags.to_a.join('; ')})")
end
def to_s
"#{self.categories.join(":")}: #{self.name}" +
(self.tags.empty? ? "" : ": (#{self.tags.to_a.join('; ')})")
end
def ==(other)
(self.categories == other.categories) and
(/^#{self.name}/ =~ other.name)
end
end
class LogEntry
attr_reader :action, :logtime, :task, :categories
def self.parse(line)
return unless /^(\+|\-|\*)\s\[(.*)\]\s([^,]+),\s*(.*)$/ =~ line
new $1, $2, $3, $4
end
def initialize(action,logtime,categories,task)
@action = action
@logtime = Time.parse(logtime)
@categories = categories.split(' ')
@task = task
end
def add?; @action == '+'; end
def sub?; @action == '-'; end
def change?; @action == '*'; end
end
end
if $0 == __FILE__
lines = []
File.open(File.expand_path('~/.todo')) {|f| lines = f.readlines}
# todo list where completed tasks are removed
t = Todo.parse(lines)
puts t.to_yaml
puts
puts t.flat_to_yaml
# todo list where completed tasks are tagged "done"
t2 = Todo.parse(lines) {|todo| todo.mark_done = "done"}
puts t2.to_yaml
puts
puts t2.flat_to_yaml
end
#! /usr/bin/env bash
echo "+" \[$(date)\] $@ >> ~/.todo
# saved as /usr/bin/++, chmod +x
#! /usr/bin/env bash
echo "-" \[$(date)\] $@ >> ~/.todo
# saved as /usr/bin/xx, chmod +x
_todo()
{
local cur words
COMPREPLY=()
words="${COMP_WORDS[@]:1}"
cur="${COMP_WORDS[COMP_CWORD]}"
local nexts=$( cat ~/.todo | grep -F "${words}" | grep '^\+' | cut -b34- | while read LINE; do elems=( ${LINE} ); echo "${elems[COMP_CWORD-1]}" ; done )
COMPREPLY=( $(compgen -W "${nexts}" -- "${cur}" ) )
return 0
}
complete -F _todo ++
complete -F _todo xx
# saved in /etc/bash_completion.d/todo
# make sure you source it to get the completion working
+ [Tue Sep 20 12:10:13 EDT 2011] personal, walk the dog ~20m
+ [Tue Sep 20 12:10:50 EDT 2011] freelance general, take photo with @thomas
- [Tue Sep 20 12:13:00 EDT 2011] freelance general, take photo
#! /usr/bin/env bash
# Delete + Add editing - a bit nicer than mid-stream editing
# Searches ~/.todo for the last '+' record matching $1,
# Adds a new '-' record for the task,
# Then adds a new '+' record changing $1 to $2, or appending $2 if -a option passed.
#
# Examples:
# ++ freelance general, take photo with @thomas
# >> + [Tue Sep 20 12:10:50 EDT 2011] freelance general, take photo with @thomas
# == "photo with @thomas" "photo with @elisa"
# >> - [Tue Sep 20 12:10:55 EDT 2011] freelance general, take photo with @thomas
# >> + [Tue Sep 20 12:10:55 EDT 2011] freelance general, take photo with @elisa
#
# ++ personal, start the great american novel
# >> + [Wed Sep 21 13:40:09 EDT 2011] personal, start the great american novel
# == -a 'great american novel' 'tomorrow'
# >> - [Wed Sep 21 13:45:32 EDT 2011] personal, start the great american novel
# >> + [Wed Sep 21 13:45:32 EDT 2011] personal, start the great american novel tomorrow
#
# saved as /usr/bin/==, chmod +x
# I chose '==' since that's the same key as '++' without the shift
APPEND=0
while getopts ":a" OPTION
do
case $OPTION in
a)
APPEND=1
shift
esac
done
pat="^\+\s\[.+\]\s(.*)($1)(.*)$"
xx $( cat ~/.todo | sed -r -n "s/${pat}/\1\2\3/1p" | tail -1 )
if [ "$APPEND" -eq "1" ]
then
++ $( cat ~/.todo | sed -r -n "s/${pat}/\1\2\3 $2/1p" | tail -1 )
else
++ $( cat ~/.todo | sed -r -n "s/${pat}/\1$2\3/1p" | tail -1 )
fi
#! /usr/bin/env ruby
# Using rb-inotify to refresh the todo list in the console, as it gets updated
require File.expand_path('../lib/todo',File.dirname(__FILE__)) # the todo.rb file above
require 'rb-inotify'
notif = INotify::Notifier.new
Signal.trap("INT") { notif.stop; exit }
modify_proc = lambda {
lines = []
File.open(File.expand_path('~/.todo')) {|f| lines = f.readlines}
t = Todo.parse(lines)
system('clear')
puts "TODO",
t.flat_to_yaml
}
watch_proc = lambda {
notif.watch(File.expand_path('~/.todo'), :close_write) do
modify_proc[]
watch_proc[]
end
}
modify_proc[]
watch_proc[]
notif.run
@Keoven
Copy link

Keoven commented Oct 3, 2011

Really liking the simplicity of this.. and of course the bash completion is a plus for me.. :D

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment