|
|
|
@ -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.3.1"
|
|
|
|
|
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,13 +81,19 @@ 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
|
|
|
|
|
default:
|
|
|
|
|
parsedArgs.addFirst(command)
|
|
|
|
@ -93,39 +101,15 @@ public class GTDCLI {
|
|
|
|
|
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,11 +236,93 @@ 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.
|
|
|
|
|
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) {
|
|
|
|
|
|
|
|
|
@ -272,23 +338,114 @@ 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 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) {
|
|
|
|
|
return f.name.replaceAll(/[-_]/, " ").capitalize() }
|
|
|
|
|
|
|
|
|
|
static String stringToFilename(String s) {
|
|
|
|
|
return s.replaceAll(/\s/, '-').
|
|
|
|
|
replaceAll(/[';:]/, '').
|
|
|
|
|
replaceAll(/[';:(\.$)]/, '').
|
|
|
|
|
toLowerCase() }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|