5 Commits
v0.1 ... v0.5

3 changed files with 261 additions and 62 deletions

View File

@ -1,7 +1,7 @@
#Sat, 27 Apr 2013 23:11:31 -0500
#Wed, 01 May 2013 09:43:41 -0500
lib.local=true
name=jdb-gtd
version=0.1
version=0.5
nailgun.classpath.dir=/home/jdbernard/programs/nailgun/classpath
build.number=24
build.number=1

View File

@ -2,22 +2,24 @@ package com.jdblabs.gtd.cli
import com.jdbernard.util.LightOptionParser
import com.martiansoftware.nailgun.NGContext
import java.security.MessageDigest
import org.joda.time.DateMidnight
import org.joda.time.DateTime
import org.slf4j.Logger
import org.slf4j.LoggerFactory
//import org.slf4j.Logger
//import org.slf4j.LoggerFactory
public class GTDCLI {
public static final String VERSION = "0.1"
public static final String VERSION = "0.5"
private static String EOL = System.getProperty("line.separator")
private static GTDCLI nailgunInst
private MessageDigest md5 = MessageDigest.getInstance("MD5")
private int terminalWidth
private Scanner stdin
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) {
GTDCLI inst = new GTDCLI(new File(System.getProperty("user.home"),
@ -79,53 +81,35 @@ public class GTDCLI {
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()) {
def command = parsedArgs.poll()
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 ~/list-copies/: listCopies(parsedArgs); break
default:
parsedArgs.addFirst(command)
process(parsedArgs)
println "Unrecognized command: ${command}"
break } } }
protected void process(LinkedList args) {
def rootDir = workingDir
def path = args.poll()
if (path) {
givenDir = new File(path)
if (givenDir.exists() && givenDir.isDirectory()) rootDir = givenDir
else { 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
def givenDir = new File(path)
if (!(gtdDirs = findGtdRootDir(givenDir))) {
println "'$path' is not a valid directory."; return }}
// Start processing items
inDir.listFiles().collect { new Item(it) }.each { item ->
gtdDirs.in.listFiles().collect { new Item(it) }.each { item ->
println ""
def response
@ -165,7 +149,7 @@ public class GTDCLI {
print "> " }
def oldFile = item.file
item.file = new File(incubateDir, item.file.name)
item.file = new File(gtdDirs.incubate, item.file.name)
item.save()
oldFile.delete() }
@ -180,7 +164,7 @@ public class GTDCLI {
def date = new DateMidnight().toString("YYYY-MM-dd")
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()
oldFile.delete()
return }
@ -205,7 +189,7 @@ public class GTDCLI {
// Needs to be a project
if (response ==~ /yes|y/) {
def oldFile = item.file
item.file = new File(projectsDir,
item.file = new File(gtdDirs.projects,
stringToFilename(item.outcome))
item.save()
oldFile.delete()
@ -223,12 +207,12 @@ public class GTDCLI {
"Next action (who needs to do what).", ""])
def oldFile = item.file
item.file = new File(waitingDir,
item.file = new File(gtdDirs.waiting,
stringToFilename(item.action))
item.save()
oldFile.delete()
println "Moved to ${waitingDir.name} folder." }
println "Moved to ${gtdDirs.waiting.name} folder." }
// Defer
@ -236,12 +220,12 @@ public class GTDCLI {
item.action = prompt(["Next action.", ""])
def oldFile = item.file
item.file = new File(actionsDir,
item.file = new File(gtdDirs["next-actions"],
stringToFilename(item.action))
item.save()
oldFile.delete()
println "Moved to the ${actionsDir.name} folder."
println "Moved to the ${gtdDirs['next-actions'].name} folder."
}
// Tickle
@ -252,12 +236,114 @@ public class GTDCLI {
"(YYYY-MM-DD)"])
def oldFile = item.file
item.file = new File(ticklerDir,
item.file = new File(gtdDirs.tickler,
stringToFilename(item.action))
item.save()
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.
findAllCopies(oldFile, gtdDrs."next-actions").each { file ->
println "Deleting duplicate entry from the " +
"${file.parentFile.name} context."
file.delete() }
// Delete any copies of this item in the waiting folder.
findAllCopies(oldFile, gtdDirs.waiting).each { file ->
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.
findAllCopies(oldFile, gtdDirs.projects).each { file ->
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) {
if (currentDate != null) println ""
println itemDay.toString("EEE, MM/dd")
println "----------"
currentDate = itemDay }
println " $item" } }
protected void listCopies(LinkedList args) {
args.each { filePath ->
def file = new File(filePath)
if (!file.isAbsolute()) file = new File(workingDir, filePath)
if (!file.isFile()) {
println "${file.canonicalPath} is not a regular file."
return }
String originalRelativePath = getRelativePath(gtdDirs.root, file)
println "Copies of $originalRelativePath:"
println ""
findAllCopies(file, gtdDirs.root).each { copy ->
if (copy.canonicalPath != file.canonicalPath) {
String relativePath = getRelativePath(gtdDirs.root, copy)
println " $relativePath" }} }
args.clear() }
protected void printUsage(LinkedList args) {
if (!args) {
@ -272,23 +358,136 @@ public class GTDCLI {
println ""
println "top-leve commands:"
println ""
println " process Process inbox items systematically."
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 {
def command = args.poll()
// TODO
//switch(command.toLowerCase()) {
// case ~/process/:
switch(command.toLowerCase()) {
case ~/process/: println """\
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 List<File> findAllCopies(File original, File inDir) {
def copies = []
def originalMD5 = md5.digest(original.bytes)
inDir.eachFileRecurse { file ->
if (file.isFile() && md5.digest(file.bytes) == originalMD5)
copies << file }
return copies }
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 String getRelativePath(File parent, File child) {
def parentPath = parent.canonicalPath.split("/")
def childPath = child.canonicalPath.split("/")
if (parentPath.length > childPath.length) return ""
int b = 0
while (b < parentPath.length && parentPath[b] == childPath[b] ) b++;
if (b != parentPath.length) return ""
return (['.'] + childPath[b..<childPath.length]).join('/') }
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) {
return f.name.replaceAll(/[-_]/, " ").capitalize() }
static String stringToFilename(String s) {
return s.replaceAll(/\s/, '-').
replaceAll(/[';:]/, '').
replaceAll(/[';:(\.$)]/, '').
toLowerCase() }
}

View File

@ -3,28 +3,28 @@ package com.jdblabs.gtd.cli
public class Item {
public File file
public Map properties = [:]
public Map gtdProperties = [:]
public Item(File f) {
this.file = f
def javaProps = new Properties()
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() {
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, "") } }
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() {
if (properties.action) return properties.action
if (properties.outcome) return properties.outcome
if (properties.title) return properties.title
if (gtdProperties.action) return gtdProperties.action
if (gtdProperties.outcome) return gtdProperties.outcome
if (gtdProperties.title) return gtdProperties.title
return file.name.replaceAll(/[-_]/, " ").capitalize() }
}