2 Commits
v0.1 ... v0.3

3 changed files with 217 additions and 60 deletions

View File

@ -1,7 +1,7 @@
#Sat, 27 Apr 2013 23:11:31 -0500 #Mon, 29 Apr 2013 11:30:52 -0500
lib.local=true lib.local=true
name=jdb-gtd name=jdb-gtd
version=0.1 version=0.3
nailgun.classpath.dir=/home/jdbernard/programs/nailgun/classpath nailgun.classpath.dir=/home/jdbernard/programs/nailgun/classpath
build.number=24 build.number=6

View File

@ -2,22 +2,24 @@ package com.jdblabs.gtd.cli
import com.jdbernard.util.LightOptionParser import com.jdbernard.util.LightOptionParser
import com.martiansoftware.nailgun.NGContext import com.martiansoftware.nailgun.NGContext
import java.security.MessageDigest
import org.joda.time.DateMidnight import org.joda.time.DateMidnight
import org.joda.time.DateTime import org.joda.time.DateTime
import org.slf4j.Logger //import org.slf4j.Logger
import org.slf4j.LoggerFactory //import org.slf4j.LoggerFactory
public class GTDCLI { public class GTDCLI {
public static final String VERSION = "0.1" public static final String VERSION = "0.3"
private static String EOL = System.getProperty("line.separator") private static String EOL = System.getProperty("line.separator")
private static GTDCLI nailgunInst private static GTDCLI nailgunInst
private MessageDigest md5 = MessageDigest.getInstance("MD5")
private int terminalWidth private int terminalWidth
private Scanner stdin private Scanner stdin
private File workingDir private File workingDir
private Logger log = LoggerFactory.getLogger(getClass()) private Map<String, File> gtdDirs
//private Logger log = LoggerFactory.getLogger(getClass())
public static void main(String[] args) { public static void main(String[] args) {
GTDCLI inst = new GTDCLI(new File(System.getProperty("user.home"), GTDCLI inst = new GTDCLI(new File(System.getProperty("user.home"),
@ -79,13 +81,19 @@ public class GTDCLI {
if (parsedArgs.size() < 1) printUsage() if (parsedArgs.size() < 1) printUsage()
log.debug("argument list: {}", parsedArgs) gtdDirs = findGtdRootDir(workingDir)
if (!gtdDirs) {
println "fatal: '${workingDir.canonicalPath}'"
println " is not a GTD repository (or any of the parent directories)."
return }
while (parsedArgs.peek()) { while (parsedArgs.peek()) {
def command = parsedArgs.poll() def command = parsedArgs.poll()
switch (command.toLowerCase()) { switch (command.toLowerCase()) {
case ~/help/: printUsafe(parsedArgs); break case ~/help/: printUsage(parsedArgs); break
case ~/done/: done(parsedArgs); break
case ~/cal|calendar/: calendar(parsedArgs); break
case ~/process/: process(parsedArgs); break case ~/process/: process(parsedArgs); break
default: default:
parsedArgs.addFirst(command) parsedArgs.addFirst(command)
@ -93,39 +101,15 @@ public class GTDCLI {
break } } } break } } }
protected void process(LinkedList args) { protected void process(LinkedList args) {
def rootDir = workingDir
def path = args.poll() def path = args.poll()
if (path) { if (path) {
givenDir = new File(path) def givenDir = new File(path)
if (givenDir.exists() && givenDir.isDirectory()) rootDir = givenDir if (!(gtdDirs = findGtdRootDir(givenPath))) {
else { println "'$path' is not a valid directory."; return }} println "'$path' is not a valid directory."; return }}
def findGtdDir = { dirName ->
def dir = new File(rootDir, dirName)
if (!dir.exists() || !dir.isDirectory()) {
println "'${rootDir.canonicalPath}' is not a valid GTD " +
"directory (missing the '$dirName' folder)."
return null }
else return dir }
// check to see if this is the parent GTD folder, in which case it
// should contain `in`, `incubate`, `next-actions`, `projects`,
// `tickler`, and `waiting` folders
def inDir, incubateDir, actionsDir, projectsDir, ticklerDir,
waitingDir, doneDir
if (!(inDir = findGtdDir("in")) ||
!(incubateDir = findGtdDir("incubate")) ||
!(doneDir = findGtdDir("done")) ||
!(actionsDir = findGtdDir("next-actions")) ||
!(projectsDir = findGtdDir("projects")) ||
!(ticklerDir = findGtdDir("tickler")) ||
!(waitingDir = findGtdDir("waiting")))
return
// Start processing items // Start processing items
inDir.listFiles().collect { new Item(it) }.each { item -> gtdDirs.in.listFiles().collect { new Item(it) }.each { item ->
println "" println ""
def response def response
@ -165,7 +149,7 @@ public class GTDCLI {
print "> " } print "> " }
def oldFile = item.file def oldFile = item.file
item.file = new File(incubateDir, item.file.name) item.file = new File(gtdDirs.incubate, item.file.name)
item.save() item.save()
oldFile.delete() } oldFile.delete() }
@ -180,7 +164,7 @@ public class GTDCLI {
def date = new DateMidnight().toString("YYYY-MM-dd") def date = new DateMidnight().toString("YYYY-MM-dd")
def oldFile = item.file def oldFile = item.file
item.file = new File(doneDir, "$date-${item.file.name}") item.file = new File(gtdDirs.done, "$date-${item.file.name}")
item.save() item.save()
oldFile.delete() oldFile.delete()
return } return }
@ -205,7 +189,7 @@ public class GTDCLI {
// Needs to be a project // Needs to be a project
if (response ==~ /yes|y/) { if (response ==~ /yes|y/) {
def oldFile = item.file def oldFile = item.file
item.file = new File(projectsDir, item.file = new File(gtdDirs.projects,
stringToFilename(item.outcome)) stringToFilename(item.outcome))
item.save() item.save()
oldFile.delete() oldFile.delete()
@ -223,12 +207,12 @@ public class GTDCLI {
"Next action (who needs to do what).", ""]) "Next action (who needs to do what).", ""])
def oldFile = item.file def oldFile = item.file
item.file = new File(waitingDir, item.file = new File(gtdDirs.waiting,
stringToFilename(item.action)) stringToFilename(item.action))
item.save() item.save()
oldFile.delete() oldFile.delete()
println "Moved to ${waitingDir.name} folder." } println "Moved to ${gtdDirs.waiting.name} folder." }
// Defer // Defer
@ -236,12 +220,12 @@ public class GTDCLI {
item.action = prompt(["Next action.", ""]) item.action = prompt(["Next action.", ""])
def oldFile = item.file def oldFile = item.file
item.file = new File(actionsDir, item.file = new File(gtdDirs["next-actions"],
stringToFilename(item.action)) stringToFilename(item.action))
item.save() item.save()
oldFile.delete() oldFile.delete()
println "Moved to the ${actionsDir.name} folder." println "Moved to the ${gtdDirs['next-actions'].name} folder."
} }
// Tickle // Tickle
@ -252,11 +236,93 @@ public class GTDCLI {
"(YYYY-MM-DD)"]) "(YYYY-MM-DD)"])
def oldFile = item.file def oldFile = item.file
item.file = new File(ticklerDir, item.file = new File(gtdDirs.tickler,
stringToFilename(item.action)) stringToFilename(item.action))
item.save() item.save()
oldFile.delete() oldFile.delete()
println "Moved to the ${ticklerDir.name} folder." } } } } } println "Moved to the ${gtdDirs.tickler.name} folder." } } } } }
protected void done(LinkedList args) {
def selectedFilePath = args.poll()
def selectedFile = new File(selectedFilePath)
if (!selectedFile) {
println "gtd done command requires a <action-file> parameter."
return }
def item
if (selectedFile.isAbsolute()) item = new Item(selectedFile)
else item = new Item(new File(workingDir, selectedFilePath))
def itemMd5 = md5.digest(item.file.bytes)
// Move to the done folder.
def oldFile = item.file
def date = new DateMidnight().toString("YYYY-MM-dd")
item.file = new File(gtdDirs.done, "$date-${item.file.name}")
item.save()
// Check if this item was in a project folder.
if (inPath(gtdDirs.projects, oldFile)) {
// Delete any copies of this item in the next actions folder.
gtdDirs["next-actions"].eachFileRecurse({ file ->
if (file.isFile() && md5.digest(file.bytes) == itemMd5) {
println "Deleting duplicate entry from the " +
"${file.parentFile.name} context."
file.delete() }})
// Delete any copies of this item in the waiting folder.
gtdDirs.waiting.eachFileRecurse({ file ->
if (file.isFile() && md5.digest(file.bytes) == itemMd5) {
println "Deleting duplicate entry from the " +
"${file.parentFile.name} waiting context."
file.delete() }})}
// Check if this item was in the next-action or waiting folder.
if (inPath(gtdDirs["next-actions"], oldFile) ||
inPath(gtdDirs.waiting, oldFile)) {
// Delete any copies of this item in the projects folder.
gtdDirs.projects.eachFileRecurse({ file ->
if (file.isFile() && md5.digest(file.bytes) == itemMd5) {
println "Deleting duplicate entry from the " +
"${file.parentFile.name} project."
file.delete() }})}
// Delete the original
oldFile.delete()
println "'$item' marked as done." }
protected void calendar(LinkedList args) {
def itemsOnCalendar = []
def addCalendarItems = { file ->
if (!file.isFile()) return
def item = new Item(file)
if (item.date) itemsOnCalendar << item }
gtdDirs."next-actions".eachFileRecurse(addCalendarItems)
gtdDirs.waiting.eachFileRecurse(addCalendarItems)
gtdDirs.projects.eachFileRecurse(addCalendarItems)
itemsOnCalendar = itemsOnCalendar.unique { md5.digest(it.file.bytes) }.
sort { it.date }
if (!itemsOnCalendar) println "No items on the calendar."
def currentDate = null
itemsOnCalendar.each { item ->
def itemDay = new DateMidnight(item.date)
if (itemDay != currentDate) {
println itemDay.toString("EEE, MM/dd")
println "----------"
currentDate = itemDay }
println " $item" } }
protected void printUsage(LinkedList args) { protected void printUsage(LinkedList args) {
@ -272,23 +338,114 @@ public class GTDCLI {
println "" println ""
println "top-leve commands:" println "top-leve commands:"
println "" println ""
println " process Process inbox items systematically."
println " help <command> Print detailed help about a command." println " help <command> Print detailed help about a command."
println " process Process inbox items systematically."
println " done <action-file> Mark an action as done. This will automatically "
println " take care of duplicates of the action in project "
println " or next-actions sub-folders."
} else { } else {
def command = args.poll() def command = args.poll()
// TODO switch(command.toLowerCase()) {
//switch(command.toLowerCase()) { case ~/process/: println """\
// case ~/process/: usage: gtd process
This is an interactive command.
GTD CLI goes through all the items in the "in" folder for this GTD repository
and guides you through the *process* step of the GTD method as follows:
Is the item actionable?
V
+---------------------------> No
| / \\
Yes Incubate Trash
| (Someday/Maybe)
V
Yes <--Too big for one action? --> No
| |
V |
Move to projects V
(still needs organization) What is the next action?
/
/
Defer, delegate, or tickler?
/ | \\
/ Move to the Set a date for this
Move to the waiting to become active again.
next-actions directory Move to the tickler
directory directory."""
break
case ~/done/: println """\
usage: gtd done <action-file>
Where <action-file> is expected to be the path (absolute or relative) to an
action item file. The action item file is expected to be in the *projects*
folder, the *next-actions* folder, the *waiting* folder, or a subfolder of one of
the aforementioned folders. The item is prepended with the current date and
moved to the *done* folder. If the item was in a project folder, the
*next-actions* and *waiting* folders are scanned recursively for duplicates of
the item, which are removed if found. Similarly, if the action was in a
*next-actions* or *waiting* folder the *projects* folder is scanned recursively
for duplicates.
The intention of the duplicate removal is to allow you to copy actions from
project folders into next action or waiting contexts, so you can keep a view of
the item organized by the project or in your next actions list. The GTD CLI tool
is smart enough to recognize that these are the same items filed in more than
one place and deal with them all in one fell swoop. Duplicates are determined by
exact file contents (MD5 has of the file contents)."""
break
}
} }
} }
protected boolean inPath(File parent, File child) {
def parentPath = parent.canonicalPath.split("/")
def childPath = child.canonicalPath.split("/")
// If the parent path is longer than the child path it cannot contain
// the child path.
if (parentPath.length > childPath.length) return false;
// If the parent and child paths do not match at any point, the parent
// path does not contain the child path.
for (int i = 0; i < parentPath.length; i++)
if (childPath[i] != parentPath[i])
return false;
// The parent path is at least as long as the child path, and the child
// path matches the parent path (up until the end of the parent path).
// The child path either is the parent path or is contained by the
// parent path.
return true }
protected Map findGtdRootDir(File givenDir) {
def gtdDirs = [:]
File currentDir = givenDir
while (currentDir != null) {
gtdDirs = ["in", "incubate", "done", "next-actions", "projects",
"tickler", "waiting"].
collectEntries { [it, new File(currentDir, it)] }
if (gtdDirs.values().every { dir -> dir.exists() && dir.isDirectory() }) {
gtdDirs.root = currentDir
return gtdDirs }
currentDir = currentDir.parentFile }
return [:] }
static String filenameToString(File f) { static String filenameToString(File f) {
return f.name.replaceAll(/[-_]/, " ").capitalize() } return f.name.replaceAll(/[-_]/, " ").capitalize() }
static String stringToFilename(String s) { static String stringToFilename(String s) {
return s.replaceAll(/\s/, '-'). return s.replaceAll(/\s/, '-').
replaceAll(/[';:]/, ''). replaceAll(/[';:(\.$)]/, '').
toLowerCase() } toLowerCase() }
} }

View File

@ -3,28 +3,28 @@ package com.jdblabs.gtd.cli
public class Item { public class Item {
public File file public File file
public Map properties = [:] public Map gtdProperties = [:]
public Item(File f) { public Item(File f) {
this.file = f this.file = f
def javaProps = new Properties() def javaProps = new Properties()
f.withReader { reader -> javaProps.load(reader) } f.withReader { reader -> javaProps.load(reader) }
javaProps.each { k, v -> properties[k] = PropertyHelp.parse(v) } } javaProps.each { k, v -> gtdProperties[k] = PropertyHelp.parse(v) } }
public void save() { public void save() {
def javaProps = new Properties() def javaProps = new Properties()
properties.each { k, v -> javaProps[k] = PropertyHelp.format(v) } gtdProperties.each { k, v -> javaProps[k] = PropertyHelp.format(v) }
file.withOutputStream { os -> javaProps.store(os, "") } } file.withOutputStream { os -> javaProps.store(os, "") } }
public def propertyMissing(String name, def value) { public def propertyMissing(String name, def value) {
properties[name] = value } gtdProperties[name] = value }
public def propertyMissing(String name) { return properties[name] } public def propertyMissing(String name) { return gtdProperties[name] }
public String toString() { public String toString() {
if (properties.action) return properties.action if (gtdProperties.action) return gtdProperties.action
if (properties.outcome) return properties.outcome if (gtdProperties.outcome) return gtdProperties.outcome
if (properties.title) return properties.title if (gtdProperties.title) return gtdProperties.title
return file.name.replaceAll(/[-_]/, " ").capitalize() } return file.name.replaceAll(/[-_]/, " ").capitalize() }
} }