Comprehensive documentation using JLP.

This commit is contained in:
Jonathan Bernard 2013-09-23 12:42:25 -05:00
parent 58026c83ab
commit 8fe3ef015d
5 changed files with 517 additions and 117 deletions

View File

@ -1,27 +1,75 @@
/**
* # Item
* @author Jonathan Bernard (jdb@jdb-labs.com)
* @copyright 2013 [JDB Labs LLC](http://jdb-labs.com)
*/
package com.jdblabs.gtd
/**
* One Getting Things Done item (a page in David Allen's system). An item is
* represented by a file on the filesystem, organized into one of the GTD
* folders. The Item can have arbitrarily many properties, which are stored in
* the Item file as standard Java properties. By convention the `action`
* property is used as the item description (assuming this item is a next
* action item). Other important properties include:
*
* * `outcome`: describes the desired outcome.
* * `title`: the item title (optionally used if no `action` is defined).
* * `date`: the due date for this item. Remember that we should not use the
* calendar to "schedule" items, but only to represent items which must be
* done by or on that day.
* * `details`: more information related to this item.
* @org gtd.jdb-labs.com/Item
*/
public class Item {
public File file
public Map gtdProperties = [:]
/**
* #### constructor
* Load an item from a file. The typical pattern for creating new Items is
* to create the file first then pass that file to an Item constructor.
* Files with no contents are valid GTD items (the file name is used as a
* description in lieu of an `action` or `title` property). */
public Item(File f) {
this.file = f
/// Read and parse the item's properties from the file.
def javaProps = new Properties()
f.withReader { reader -> javaProps.load(reader) }
/// Properties are stored as plain text in the file. We use the
/// [PropertyHelp](jlp://gtd.jdb-labs.com/PropertyHelp) Enum to
/// serialize and deserialize the property objects.
javaProps.each { k, v -> gtdProperties[k] = PropertyHelp.parse(v) } }
/** #### `save`
* Persist the Item to it's file. */
public void save() {
def javaProps = new Properties()
gtdProperties.each { k, v -> javaProps[k] = PropertyHelp.format(v) }
file.withOutputStream { os -> javaProps.store(os, "") } }
/** #### `propertyMissing`
* Provide an implementation of the Groovy dynamic [propertyMissing][1]
* method to expose the gtdProperties map as properties on the item
* itself.
*
* [1]: http://groovy.codehaus.org/Using+methodMissing+and+propertyMissing
*/
public def propertyMissing(String name, def value) {
gtdProperties[name] = value }
public def propertyMissing(String name) { return gtdProperties[name] }
/** #### `toString`
* Provide a standard description of the item. This is used by the CLI
* interface, for example, to directly display GTD items.
*
* Look first for the `action` property, then `outcome`, then `title`.
* Failing to find any of these properties, pretty-print the filename
* as a description. */
public String toString() {
if (gtdProperties.action) return gtdProperties.action
if (gtdProperties.outcome) return gtdProperties.outcome

View File

@ -1,3 +1,8 @@
/**
* # PropertyHelp
* @author Jonathan Bernard (jdb@jdb-labs.com)
* @copyright 2013 [JDB Labs LLC](http://jdb-labs.com)
*/
package com.jdblabs.gtd
import org.joda.time.DateMidnight
@ -5,25 +10,51 @@ import org.joda.time.DateTime
import java.text.SimpleDateFormat
/**
* A poor man's serialization/deserialization library. Each Enum value
* represents a datatype that this class knows how to handle. The Enum entries
* consist of a regex to match data in textual form, a Class to match Java
* objects, a parse function to convert the textual form to a Java object, and
* a format function to convert a Java object to textual form.
* @org gtd.jdb-labs.com/PropertyHelp
*/
public enum PropertyHelp {
// Property types should be ordered here in order of decreasing specificity.
// That is, subclasses should come before the more general class so that
// objects are converted using the most specific class that
// PropertyHelp knows how to work with.
/// **Note:** Property types should be ordered here in order of decreasing
/// specificity. That is, subclasses should come before the more general
/// class so that objects are converted using the most specific class that
/// PropertyHelp knows how to work with.
/// #### `DATE_MIDNIGHT` ([`org.joda.time.DateMidnight`][joda-dm])
/// [joda-dm]: http://joda-time.sourceforge.net/apidocs/org/joda/time/DateMidnight.html
/// @example 2013-09-22
DATE_MIDNIGHT(/^\d{4}-\d{2}-\d{2}$/, DateMidnight,
{ v -> DateMidnight.parse(v) },
{ d -> d.toString("YYYY-MM-dd") }),
/// #### `DATETIME` ([`org.joda.time.DateTime`][joda-dt])
/// [joda-dt]: http://joda-time.sourceforge.net/apidocs/org/joda/time/DateTime.html
/// @example 2013-09-22T13:42:57
DATETIME(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/, DateTime,
{ v -> DateTime.parse(v) },
{ d -> d.toString("YYYY-MM-dd'T'HH:mm:ss") }),
// We never want to parse a value into a java.util.Date or
// java.util.Calendar object (we are using Joda Time instead of the
// standard Java Date and Calendar objects) but we do want to be able to
// handle if someone gives us a Date or Calendar object.
/// We never want to parse a value into a [`java.util.Date`][java-dt] or
/// [`java.util.Calendar`][java-cal] object (we are using Joda Time instead
/// of the standard Java Date and Calendar objects) but we do want to be
/// able to handle if someone gives us a Date or Calendar object.
///
/// [java-dt]: http://docs.oracle.com/javase/6/docs/api/java/util/Date.html
/// [java-cal]: http://docs.oracle.com/javase/6/docs/api/java/util/Calendar.html
/// #### `DATE` ([java.util.Date][java-dt])
/// [java-dt]: http://docs.oracle.com/javase/6/docs/api/java/util/Date.html
DATE(NEVER_MATCH, Date,
{ v -> v }, // never called
{ d -> dateFormat.format(d) }),
/// #### `CALENDAR` ([`java.util.Calendar`][java-cal])
/// [java-cal]: http://docs.oracle.com/javase/6/docs/api/java/util/Calendar.html
CALENDAR(NEVER_MATCH, Calendar,
{ v -> v }, // never called
{ c ->
@ -31,15 +62,32 @@ public enum PropertyHelp {
df.calendar = c
df.format(c.time) }),
/// Similarly, we always parse integers into [`Long`][java-long] objects,
/// and floating point values into [`Double`][java-double] objects.
///
/// [java-long]: http://docs.oracle.com/javase/6/docs/api/java/lang/Long.html
/// [java-double]: http://docs.oracle.com/javase/6/docs/api/java/lang/Double.html
/// #### `INTEGER` ([`java.lang.Integer`][java-int])
/// [java-int]: http://docs.oracle.com/javase/6/docs/api/java/lang/Integer.html
INTEGER(NEVER_MATCH, Integer,
{ v -> v as Integer }, // never called
{ i -> i as String }),
/// #### `LONG` ([`java.lang.Long`][java-long])
/// [java-long]: http://docs.oracle.com/javase/6/docs/api/java/lang/Long.html
LONG(/^\d+$/, Long,
{ v -> v as Long },
{ l -> l as String }),
/// #### `FLOAT` ([`java.lang.Float`][java-float])
/// [java-float]: http://docs.oracle.com/javase/6/docs/api/java/lang/Float.html
FLOAT(NEVER_MATCH, Float,
{ v -> v as Float}, // never called
{ f -> f as String}),
/// #### `DOUBLE` ([`java.lang.Double`][java-double])
/// [java-double]: http://docs.oracle.com/javase/6/docs/api/java/lang/Double.html
DOUBLE(/^\d+\.\d+$/, Double,
{ v -> v as Double },
{ d -> d as String });
@ -51,11 +99,11 @@ public enum PropertyHelp {
private static SimpleDateFormat dateFormat =
new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss");
// This pattern for can never match (is uses negative lookahead to
// contradict itself).
/// This pattern for can never match (is uses negative lookahead to
/// contradict itself)
private static String NEVER_MATCH = /(?!x)x/;
/// #### constructor
public PropertyHelp(String pattern, Class klass, def parseFun,
def formatFun) {
this.pattern = pattern
@ -63,19 +111,31 @@ public enum PropertyHelp {
this.parseFun = parseFun
this.formatFun = formatFun }
/// #### `matches`
/// Test if this Enum will match a given textual value.
public boolean matches(String prop) { return prop ==~ pattern }
/// Test if this Enum will match a given Java Class.
public boolean matches(Class klass) { return this.klass == klass }
/// #### `parse`
/// Try to parse a given textual value.
public static Object parse(String value) {
def propertyType = PropertyHelp.values().find {
it.matches(value) }
/// Try to find a matching converter.
def propertyType = PropertyHelp.values().find { it.matches(value) }
/// Use the converter to parse the value. If we did not find a
/// converter we assume this value is a plain string.
return propertyType ? propertyType.parseFun(value) : value }
/// #### `format`
/// Try to format a given Java Object as a String.
public static String format(def object) {
/// Try to find a converter that can handle this object type.
def propertyType = PropertyHelp.values().find {
it.klass.isInstance(object) }
/// Use the converter to format it as a string. If none was found we
/// use the object's `toString()` method.
return propertyType ? propertyType.formatFun(object) : object.toString() }
}

View File

@ -1,9 +1,28 @@
/**
* # Util
* @author Jonathan Bernard (jdb@jdb-labs.com)
* @copyright 2013 [JDB Labs LLC](http://jdb-labs.com)
*/
package com.jdblabs.gtd
import java.security.MessageDigest
/**
* Utility methods common to this implementation of the Getting Things Done
* method. These methods provide support for working with a GTD repository
* which follows the organization and convention described
* [here](jlp://gtd.jdb-labs.com/notes/organization-and-structure)
* @org gtd.jdb-labs.com/Util
*/
public class Util {
/** #### `findAllCopies`
* Given a GTD item file, find all files in the repository which are exact
* copies of this file (including thie file itself). This is useful when
* the same item exists in a project folder and in a next-action context
* folder.
*
* @org gtd.jdb-labs.com/Util/findAllCopies */
public static List<File> findAllCopies(File original, File inDir) {
MessageDigest md5 = MessageDigest.getInstance("MD5")
@ -16,44 +35,79 @@ public class Util {
return copies }
/** #### `inPath`
* Determine whether or not a given path is a subpath of a given parent
* path. This algorithm does not consider symlinks or hard links. It
* operates based on the textual path names. */
public static 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 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.
/// 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.
/// 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 }
/** #### `getRelativePath`
* Given a parent path and a child path, assuming the child path is
* contained within the parent path, return the relative path from the
* parent to the child. */
public static String getRelativePath(File parent, File child) {
def parentPath = parent.canonicalPath.split("/")
def childPath = child.canonicalPath.split("/")
/// If the parent path is longer it cannot contain the child path and
/// we cannot construct a relative path without backtracking.
if (parentPath.length > childPath.length) return ""
/// Compare the parent and child path up until the end of the parent
/// path.
int b = 0
while (b < parentPath.length && parentPath[b] == childPath[b] ) b++;
/// If we stopped before reaching the end of the parent path it must be
/// that the paths do not match. The parent cannot contain the child and
/// we cannot build a relative path without backtracking.
if (b != parentPath.length) return ""
return (['.'] + childPath[b..<childPath.length]).join('/') }
/** #### `findGtdRootDir`
* Starting from a give directory, walk upwards through the file system
* heirarchy looking for the GTD root directory. The use case that
* motivates this function is when you are currently down in the GTD
* folder structure, in a context or project subfolder for example, and
* you need to find the root directory of the GTD structure, somewhere
* above you.
*
* This function returns a GTD Root Directory map, which has keys
* representing each of the top-level GTD directories, including a `root`
* key which corresponds to the parent file of these top-level GTD
* directories. The values are the File objects representing the
* directories. For example, if the GTD root is at `/home/user/gtd` then
* the root map (call it `m`) will have `m.projects ==
* File('/home/user/gtd/projects')`, `m.root == File('/home/user/gtd')`,
* etc.
* @org gtd.jdb-labs.com/notes/root-directory-map */
public static Map findGtdRootDir(File givenDir) {
def gtdDirs = [:]
/// Start by considering the current directory as a candidate.
File currentDir = givenDir
while (currentDir != null) {
/// We recognize the GTD root directory when it contains all of the
/// GTD top-level directories.
gtdDirs = ["in", "incubate", "done", "next-actions", "projects",
"tickler", "waiting"].
collectEntries { [it, new File(currentDir, it)] }
@ -62,7 +116,9 @@ public class Util {
gtdDirs.root = currentDir
return gtdDirs }
/// If this was not the GTD root, let's try the parent.
currentDir = currentDir.parentFile }
/// If we never found the GTD root, we return an empty map.
return [:] }
}

View File

@ -1,3 +1,8 @@
/**
* # GTDCLI
* @author Jonathan Bernard (jdb@jdb-labs.com)
* @copyright 2013 [JDB Labs LLC](http://jdb-labs.com)
*/
package com.jdblabs.gtd.cli
import com.jdblabs.gtd.Item
@ -12,66 +17,109 @@ import org.joda.time.DateTime
import static com.jdblabs.gtd.Util.*
/**
* Command-line helper for working with this implementation of the Getting
* Things Done method. */
public class GTDCLI {
public static final String VERSION = "1.2"
private static String EOL = System.getProperty("line.separator")
/// We have a persistent instance when we are in the context of a Nailgun
/// setup.
private static GTDCLI nailgunInst
/// Used to wrap lines intelligently.
private int terminalWidth
private Scanner stdin
private File workingDir
/// The [GTD Root Directory map][root-map] for our repository.
///
/// [root-map]: jlp://gtd.jdb-labs.com/notes/root-directory-map
private Map<String, File> gtdDirs
//private Logger log = LoggerFactory.getLogger(getClass())
/** #### `main`
* Main entry point for a normal GTD CLI process. */
public static void main(String[] args) {
/// Instantiate our GTDCLI instance using the configuration file at
/// `$HOME/.gtdclirc`.
GTDCLI inst = new GTDCLI(new File(System.getProperty("user.home"),
".gtdclirc"))
/// Actual processing is done by the
/// [`run`](jlp://gtd.jdb-labs.com/GTDCLI/run) method
if (args.length > 0) args[-1] = args[-1].trim()
inst.run(args) }
/** #### `nailMain`
* Entry point for a GTD CLI process under [Nailgun][ng].
* [ng]: http://www.martiansoftware.com/nailgun/ */
public static void nailMain(NGContext context) {
if (nailgunInst == null)
nailgunInst = new GTDCLI(new File(
System.getProperty("user.home"), ".gtdclirc"))
else nailgunInst.stdin = new Scanner(context.in)
// trim the last argument, not all cli's are well-behaved
/// Trim the last argument; not all cli's are well-behaved
if (context.args.length > 0) context.args[-1] = context.args[-1].trim()
nailgunInst.run(context.args) }
/** #### `reconfigure`
* This method reloads the configuration before invoking the run function,
* allowing a long-lived instance to react to configuration changes. */
public static void reconfigure(String[] args) {
/// If we do not have a long-running Nailgun instance we just call
/// main.
if (nailgunInst == null) main(args)
else {
/// Discard our old instance and instantiate a new one in order to
/// read afresh the configuration file.
nailgunInst = null
nailgunInst = new GTDCLI(new File(
System.getProperty("user.home"), ".gritterrc"))
nailgunInst.run(args) } }
/** #### `constructor`
* Create a new GTDCLI instance, using the given configuration file. */
public GTDCLI(File configFile) {
// parse the config file
/// Parse the groovy config file
def config = [:]
if (configFile.exists())
config = new ConfigSlurper().parse(configFile.toURL())
// configure the terminal width
/// Configure the terminal width
terminalWidth = (System.getenv().COLUMNS ?: config.terminalWidth ?: 79) as int
/// Configure our default working directory.
workingDir = config.defaultDirectory ?
new File(config.defaultDirectory) :
new File('.')
stdin = new Scanner(System.in) }
/** #### `run`
* This method does the work of processing the user input and taking the
* appropriate action.
* @org gtd.jdb-labs.com/GTDCLI/run */
protected void run(String[] args) {
/// Simple CLI options:
def cliDefinition = [
/// -h, --help
/// : Show the usage information.
h: [longName: 'help'],
/// -d, --directory
/// : Set the working directory for the CLI.
d: [longName: 'directory', arguments: 1],
/// -v, --version
/// : Print version information.
v: [longName: 'version']]
def opts = LightOptionParser.parseOptions(cliDefinition, args as List)
@ -80,10 +128,17 @@ public class GTDCLI {
if (opts.v) { println "GTD CLI v$VERSION"; return }
if (opts.d) workingDir = new File(opts.d)
/// View the arguments as a [`LinkedList`][1] so we can use [`peek`][2]
/// and [`poll`][3].
///
/// [1]: http://docs.oracle.com/javase/6/docs/api/java/util/LinkedList.html
/// [2]: http://docs.oracle.com/javase/6/docs/api/java/util/LinkedList.html#peek()
/// [3]: http://docs.oracle.com/javase/6/docs/api/java/util/LinkedList.html#poll()
def parsedArgs = (opts.args as List) as LinkedList
if (parsedArgs.size() < 1) printUsage()
/// Make sure we are in a GTD directory.
gtdDirs = findGtdRootDir(workingDir)
if (!gtdDirs) {
println "fatal: '${workingDir.canonicalPath}'"
@ -91,8 +146,10 @@ public class GTDCLI {
return }
while (parsedArgs.peek()) {
/// Pull off the first argument.
def command = parsedArgs.poll()
/// Match the first argument and invoke the proper command method.
switch (command.toLowerCase()) {
case ~/help/: printUsage(parsedArgs); break
case ~/done/: done(parsedArgs); break
@ -106,6 +163,12 @@ public class GTDCLI {
println "Unrecognized command: ${command}"
break } } }
/** #### `process`
* Implement the *process* step of the GTD method. For details, see the
* [online help][help-process] included by running `gtd help process`
*
* [help-process]: jlp://gtd.jdb-labs.com/GTDCLI/help/process
*/
protected void process(LinkedList args) {
def path = args.poll()
@ -114,25 +177,25 @@ public class GTDCLI {
if (!(gtdDirs = findGtdRootDir(givenDir))) {
println "'$path' is not a valid directory."; return }}
// Start processing items
/// Start processing items
gtdDirs.in.listFiles().collect { new Item(it) }.each { item ->
println ""
def response
def readline = {stdin.nextLine().trim()}
// 1. Is it actionable?
/// 1. Is it actionable?
if (!item.title) item.title = filenameToString(item.file)
response = prompt([">> $item", "Is it actionable?"]).toLowerCase()
// Not actionable
/// Not actionable, should we incubate this or trash it?
if (!(response ==~ /yes|y/)) {
response = prompt("Incubate or trash?").toLowerCase()
// Trash
/// Trash
if ("trash" =~ response) item.file.delete()
// Incubate
/// Incubate
else {
println "Enter extra info. One 'key: value' pair per line."
println "(ex: date: YYYY-MM-DD, details)"
@ -150,11 +213,11 @@ public class GTDCLI {
item.save()
oldFile.delete() }
// Actionable
/// It is actionable. Can we do it now in less than 2 minutes?
} else {
response = prompt("Will it take less than 2 minutes?").toLowerCase()
// Do it now
/// Yes, so do it now.
if (response ==~ /yes|y/) {
println "Do it now."; print "> "
readline();
@ -166,7 +229,7 @@ public class GTDCLI {
oldFile.delete()
return }
// > 2 minutes
/// It will take more than 2 minutes. Track it in our system.
item.outcome = prompt("What is the desired outcome?")
println "Enter extra info. One 'key: value' pair per line."
@ -181,9 +244,10 @@ public class GTDCLI {
PropertyHelp.parse(parts[1].trim())
print "> " }
/// Does this need to be a separate project?
response = prompt("Too big for one action?").toLowerCase()
// Needs to be a project
/// Yes, this deserves it's own project folder.
if (response ==~ /yes|y/) {
def oldFile = item.file
item.file = new File(gtdDirs.projects,
@ -192,12 +256,15 @@ public class GTDCLI {
oldFile.delete()
println "Moved to projects." }
// Is a single action
/// No, we can track this in one item. Is this something we
/// need someone else to do, should we defer it to our
/// next-actions list, or should we forget about it until a
/// future date?
else {
response = prompt("Delegate, defer, or tickler?").
toLowerCase()
// Delegate
/// Delegate, move to the *waiting* folder.
if (response =~ /del/) {
item.action = prompt([
@ -212,7 +279,7 @@ public class GTDCLI {
println "Moved to ${gtdDirs.waiting.name} folder." }
// Defer
/// Defer, move to teh *next-actions* folder.
else if (response =~ /def/) {
item.action = prompt(["Next action.", ""])
@ -225,7 +292,7 @@ public class GTDCLI {
println "Moved to the ${gtdDirs['next-actions'].name} folder."
}
// Tickle
/// Forget for now, move it to the *tickler* folder.
else {
item.action = prompt(["Next action.", ""])
item.tickle = prompt([
@ -239,6 +306,13 @@ public class GTDCLI {
oldFile.delete()
println "Moved to the ${gtdDirs.tickler.name} folder." } } } } }
/** #### `done`
* Implement the `done` command to mark items as completed. For detailed
* information see the [online help][help-done] by running
* `gtd help done`.
*
* [help-done]: jlp://gtd.jdb-labs.com/GTDCLI/help/done
*/
protected void done(LinkedList args) {
def selectedFilePath = args.poll()
@ -252,56 +326,68 @@ public class GTDCLI {
if (selectedFile.isAbsolute()) item = new Item(selectedFile)
else item = new Item(new File(workingDir, selectedFilePath))
// Move to the done folder.
/// 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.
/// 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.
/// Delete any copies of this item from the next actions folder.
findAllCopies(oldFile, gtdDirs."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.
/// Delete any copies of this item from 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.
/// 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.
/// Delete any copies of this item from the projects folder.
findAllCopies(oldFile, gtdDirs.projects).each { file ->
println "Deleting duplicate entry from the " +
"${file.parentFile.name} project."
file.delete() }}
// Delete the original
/// Delete the original
oldFile.delete()
println "'$item' marked as done." }
/** #### `calendar`
* Implement the `calendar` command to show all the items which are
* scheduled on the calendar. For detailed information see the
* [online help][help-calendar] by running `gtd help calendar`.
*
* [help-calendar]: jlp://gtd.jdb-labs.com/GTDCLI/help/calendar
*/
protected void calendar(LinkedList args) {
def itemsOnCalendar = []
MessageDigest md5 = MessageDigest.getInstance("MD5")
/// Temporary helper function to add GTD item files that have the
/// `date` property defined.
def addCalendarItems = { file ->
if (!file.isFile()) return
def item = new Item(file)
if (item.date) itemsOnCalendar << item }
/// Look through each of the `next-actions`, `waiting`, and `projects`
/// folders for items which should be on the calendar
gtdDirs."next-actions".eachFileRecurse(addCalendarItems)
gtdDirs.waiting.eachFileRecurse(addCalendarItems)
gtdDirs.projects.eachFileRecurse(addCalendarItems)
/// De-duplicate the list.
itemsOnCalendar = itemsOnCalendar.unique { md5.digest(it.file.bytes) }.
sort { it.date }
@ -309,6 +395,7 @@ public class GTDCLI {
def currentDate = null
/// Print each day of items.
itemsOnCalendar.each { item ->
def itemDay = new DateMidnight(item.date)
if (itemDay != currentDate) {
@ -319,9 +406,17 @@ public class GTDCLI {
println " $item" } }
/** #### `listCopies`
* Implement the `list-copies` command to show all the copies of a given
* item in the repository. For detailed information see the
* [online help][help-list-copies] by running `gtd help list-copies`.
*
* [help-list-copies]: jlp://gtd.jdb-labs.com/GTDCLI/help/list-copies
*/
protected void listCopies(LinkedList args) {
args.each { filePath ->
/// First find the file they have named.
def file = new File(filePath)
if (!file.isAbsolute()) file = new File(workingDir, filePath)
@ -334,6 +429,9 @@ public class GTDCLI {
println "Copies of $originalRelativePath:"
println ""
/// Find all copies using [`Util.findAllCopies`][1] and print their
/// relative paths.
/// [1]: jlp://gtd.jdb-labs.com/Util/findAllCopies
findAllCopies(file, gtdDirs.root).each { copy ->
if (copy.canonicalPath != file.canonicalPath) {
String relativePath = getRelativePath(gtdDirs.root, copy)
@ -341,8 +439,16 @@ public class GTDCLI {
args.clear() }
/** #### `new`
* Implement the `new` command to create a new GTD item in the current
* directory. For detailed information see the [online help][help-new] by
* running `gtd help new`.
*
* [help-new]: jlp://gtd.jdb-labs.com/GTDCLI/help/new
*/
protected void newAction(LinkedList args) {
/// Get the next action.
def response = prompt(["Next action?", ""])
def file = new File(workingDir, stringToFilename(response))
file.createNewFile()
@ -355,8 +461,14 @@ public class GTDCLI {
println "End with an empty line."
print "> "
/// Read in item properties.
while (response = stdin.nextLine().trim()) {
/// Skip lines that do not contain either `:` or `=` (the key-value
/// delimiters).
if (!(response =~ /[:=]/)) continue
/// Split the line into key and value and add this property to the
/// item.
def parts = response.split(/[:=]/)
item[parts[0].trim().toLowerCase()] =
PropertyHelp.parse(parts[1].trim())
@ -364,14 +476,22 @@ public class GTDCLI {
item.save() }
/** #### `tickler`
* Implement the `tickler` command to move items in the *tickler* folder to
* the *next-actions* folder if their time has come. For detailed
* information see the [online help][help-tickler] by running
* `gtd help tickler`.
*
* [help-tickler]: jlp://gtd.jdb-labs.com/GTDCLI/help/tickler
*/
protected void tickler(LinkedList args) {
gtdDirs.tickler.eachFileRecurse { file ->
def item = new Item(file)
def today = new DateMidnight()
// If the item is scheduled to be tickled today (or in the past)
// then move it into the next-actions folder
/// If the item is scheduled to be tickled today (or in the past)
/// then move it into the next-actions folder
if ((item.tickle as DateMidnight) <= today) {
println "Moving '${item.action}' out of the tickler."
def oldFile = item.file
@ -381,10 +501,20 @@ public class GTDCLI {
item.save()
oldFile.delete() }}}
/** #### `ls`
* Implement the `ls` command to pretty print all items in a context
* folder, a project folder, or the *next-action* folder. For detailed
* information see the [online help][help-ls] by running
* `gtd help ls`.
*
* [help-ls]: jlp://gtd.jdb-labs.com/GTDCLI/help/ls
*/
protected void ls(LinkedList args) {
def target = args.poll()
/// Temporary helper function to print all the items in a given
/// directory.
def printItems = { dir ->
if (!dir.exists() || !dir.isDirectory()) return
println "-- ${getRelativePath(gtdDirs.root, dir)} --"
@ -397,20 +527,25 @@ public class GTDCLI {
println "" }
// If we have a named context or project, look for those items
// specifically
/// If we have a named context or project, look for those items
/// specifically
if (target) {
printItems(new File(gtdDirs['next-actions'], target))
printItems(new File(gtdDirs.waiting, target))
printItems(new File(gtdDirs.projects, target)) }
/// Otherwise print all items in the *next-actions* and *waiting*
/// folders and all their subfolders.
else {
printItems(gtdDirs['next-actions'])
printItems(gtdDirs['waiting'])
gtdDirs['next-actions'].eachDir(printItems)
gtdDirs['waiting'].eachDir(printItems) } }
/** #### `help`
* Implement the `help` command which provides the online-help. Users can
* access the online help for a command by running `gtd help <command>`.*/
protected void printUsage(LinkedList args) {
if (!args) {
@ -445,6 +580,8 @@ top-level commands:
def command = args.poll()
switch(command.toLowerCase()) {
/// Online help for the `process` command.
/// @org gtd.jdb-labs.com/GTDCLI/help/process
case ~/process/: println """\
usage: gtd process
@ -475,6 +612,8 @@ and guides you through the *process* step of the GTD method as follows:
directory directory."""
break
/// Online help for the `done` command.
/// @org gtd.jdb-labs.com/GTDCLI/help/done
case ~/done/: println """\
usage: gtd done <action-file>
@ -493,9 +632,11 @@ 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)."""
exact file contents (MD5 hash of the file contents)."""
break
/// Online help for the `calendar` command.
/// @org gtd.jdb-labs.com/GTDCLI/help/calendar
case ~/calendar/: println """\
usage: gtd calendar
@ -505,6 +646,8 @@ Remember that in the GTD calendar items are supposed to be hard dates, IE.
things that *must* be done on the assigned date."""
break
/// Online help for the `list-copies` command.
/// @org gtd.jdb-labs.com/GTDCLI/help/list-copies
case ~/list-copies/: println """\
usage: gtd list-copies <action-file>
@ -515,6 +658,8 @@ This command searched through the current GTD repository for any items that are
duplicates of this item."""
break
/// Online help for the `new` command.
/// @org gtd.jdb-labs.com/GTDCLI/help/new
case ~/new/: println """\
usage: gtd new
@ -524,6 +669,8 @@ that should be associated with it, then creates the action file in the current
directory."""
break
/// Online help for the `tickler` command.
/// @org gtd.jdb-labs.com/GTDCLI/help/tickler
case ~/tickler/: println """\
usage: gtd tickler
@ -532,6 +679,8 @@ file for any items that should become active (based on their <tickle> property)
and moves them out of the tickler file and into the next-actions file."""
break
/// Online help for the `ls`/`list-context` command.
/// @org gtd.jdb-labs.com/GTDCLI/help/ls
case ~/ls|list-context/: println """\
usage gtd ls [<context> ...]
@ -543,6 +692,9 @@ context or project is named, all contexts are listed."""
}
}
/** #### `prompt`
* Prompt the user for an answer to a question. This is a helper to loop
* until the user has entered an actual response. */
protected String prompt(def msg) {
if (msg instanceof List) msg = msg.join(EOL)
msg += "> "
@ -553,9 +705,14 @@ context or project is named, all contexts are listed."""
return line }
/** #### `filenameToString`
* The default pretty-print conversion for filenames. */
public static String filenameToString(File f) {
return f.name.replaceAll(/[-_]/, " ").capitalize() }
/** #### `stringToFilename`
* Helper method to convert a user-entered string into something more
* palatable for a filename. */
public static String stringToFilename(String s) {
return s.replaceAll(/\s/, '-').
replaceAll(/[';:(\.$)]/, '').

View File

@ -1,3 +1,8 @@
/**
* # GTDServlet
* @author Jonathan Bernard (jdb@jdb-labs.com)
* @copyright 2013 [JDB Labs LLC](http://jdb-labs.com)
*/
package com.jdblabs.gtd.servlet
import com.jdblabs.gtd.Item
@ -17,23 +22,44 @@ import javax.servlet.http.HttpSession
import static javax.servlet.http.HttpServletResponse.*
/**
* Servlet to expose a GTD file-based repository over HTTP via a REST API.
*/
public class GTDServlet extends HttpServlet {
protected Map gtdDirs
private SmartConfig config
/** ### TempRequestData
* Helper class to encapsulate data shared by several methods while
* fulfilling a single request.
*
* @org gtd.jdb-labs.com/GTDServlet/TempRequestData */
private class TempRequestData {
public String username
public def defaultPermissions
}
/** #### `init`
* Overrides [`GenericServlet.init(ServletConfig)`][1] to configure
* this servlet instance. Primarily we need to find our GTD root directory
* and read the `.properties` configuration file from the GTD root
* directory.
*
* [1]: http://docs.oracle.com/javaee/6/api/javax/servlet/GenericServlet.html#init(javax.servlet.ServletConfig) */
void init(ServletConfig config) {
/// We exepect the path to the GTD root directory to be supplied in the
/// servlet configuration: typically in the `web.xml` file.
String gtdDirName = config.getInitParameter("gtdRootDir")
this.gtdDirs = Util.findGtdRootDir(new File(gtdDirName))
if (!gtdDirs) throw new ServletException(
"Unable to initialize GTD servlet: no GTD root dir found in the " +
"configured path (${gtdDirName}).")
/// We expect to find a `.properties` file in the root directory that
/// we will use to configure the servlet for this repository (primarily
/// users and permissions).
def cfgFile = new File(gtdDirs.root, '.properties')
if (!cfgFile.isFile() || !cfgFile.exists()) throw new ServletException(
"Unable to find the GTD/.properties configuration file. " +
@ -41,12 +67,26 @@ public class GTDServlet extends HttpServlet {
this.config = new SmartConfig(cfgFile) }
/** #### `doOptions`
* Overrides [`HttpServlet.doOptions`][2] as we need to include
* [CORS headers][3] in response to a [CORS pre-flight request][4].
*
* [2]: http://docs.oracle.com/javaee/6/api/javax/servlet/http/HttpServlet.html#doOptions(javax.servlet.http.HttpServletRequest,%20javax.servlet.http.HttpServletResponse)
* [3]: https://developer.mozilla.org/en-US/docs/HTTP/Access_control_CORS#The_HTTP_response_headers
* [4]: https://developer.mozilla.org/en-US/docs/HTTP/Access_control_CORS#Preflighted_requests
*/
void doOptions(HttpServletRequest request, HttpServletResponse response) {
/// A browser will not send credentials like session cookies unless the
/// server responds with the exact Origin from the request header, so
/// we will use the Origin header unless the client did not send this
/// header.
response.addHeader("Access-Control-Allow-Origin",
request.getHeader("Origin") ?: "*")
response.addHeader("Access-Control-Allow-Credentials", "true")
response.status = SC_OK
/// We will set the `Access-Control-Allow-Methods` header based on the
/// endpoint that the client is trying to reach.
switch (request.servletPath) {
case '/login':
response.addHeader("Allow", "POST")
@ -62,34 +102,44 @@ public class GTDServlet extends HttpServlet {
response.status = SC_NOT_FOUND }
}
/** #### `doPost`
* Override the [`HttpServlet.doPost`][5] method to provide responses to
* `POST` requests.
*
* [5]: http://docs.oracle.com/javaee/6/api/javax/servlet/http/HttpServlet.html#doPost(javax.servlet.http.HttpServletRequest,%20javax.servlet.http.HttpServletResponse) */
void doPost(HttpServletRequest request, HttpServletResponse response) {
/// All our responses use JSON-formatted data.
response.addHeader("Content-Type", "application/json")
/// Add the CORS headers>
response.addHeader("Access-Control-Allow-Origin",
request.getHeader("Origin") ?: "*")
response.addHeader("Access-Control-Allow-Credentials", "true")
/// Get this user's session
HttpSession session = request.getSession(true);
// If the user is posting to /gtd/login then let's try to authenticate
// them.
/// If the user is posting to `/gtd/login` then let's try to
/// authenticate them. We don't care about the state of the existing
/// session.
if (request.servletPath == '/login') {
// Parse the username/password from the request.
/// Parse the username/password from the request.
def requestBody
try { requestBody = new JsonSlurper().parse(request.reader) }
catch (JsonException jsone) {
response.status = SC_BAD_REQUEST
return }
// Build our list of known users.
/// Build our list of known users.
def users = config.accountNames.split(/,/).collect { it.trim() }
// Lookup the user's password in the configuration (will be null if
// we are given an invalid username).
/// Lookup the user's password in the configuration (will be null if
/// we are given an invalid username).
String expectedPwd = config."account.${requestBody.username}.password"
// Reject the login request if the user is not defined by our
// configuration. Note: timing attack possible due to string
// comparison.
/// Reject the login request if the user is not defined by our
/// configuration. Note: timing attack possible due to string
/// comparison.
if (!users.contains(requestBody.username) ||
requestBody.password != expectedPwd) {
response.status = SC_UNAUTHORIZED
@ -102,13 +152,13 @@ public class GTDServlet extends HttpServlet {
writeJSON([status: "ok"], response)
return }
// If the user is not authenticated return a 401 Unauthorized.
/// If the user is not authenticated return a `401 Unauthorized`.
else if (!((boolean)session.getAttribute('authenticated'))) {
response.status = SC_UNAUTHORIZED
return }
// Right now there is no other endpoint that supports POST, so return
// 404 Not Found or 405 Method Not Allowed
/// Right now there is no other endpoint that supports `POST`, so return
/// `404 Not Found` or `405 Method Not Allowed`
switch (request.servletPath) {
case ~/\/gtd\/contexts.*/:
case ~/\/gtd\/projects.*/:
@ -120,66 +170,75 @@ public class GTDServlet extends HttpServlet {
}
}
/** #### `doGet`
* Overrides the [`HttpServlet.doGet`][6] method to provide reponses to
* `GET` requests.
*
* [6]: http://docs.oracle.com/javaee/6/api/javax/servlet/http/HttpServlet.html#doGet(javax.servlet.http.HttpServletRequest,%20javax.servlet.http.HttpServletResponse) */
void doGet(HttpServletRequest request, HttpServletResponse response) {
response.status = SC_OK
/// All of our responses have JSON formatted content.
response.addHeader("Content-Type", "application/json")
/// Add CORS headers.
response.addHeader("Access-Control-Allow-Origin",
request.getHeader("Origin") ?: "*")
response.addHeader("Access-Control-Allow-Credentials", "true")
HttpSession session = request.getSession(true);
// If the user is not authenticated return 401 Unauthorized.
/// If the user is not authenticated return `401 Unauthorized`.
if (!((boolean)session.getAttribute('authenticated'))) {
response.status = SC_UNAUTHORIZED
return }
def curData = new TempRequestData()
// Read the username from the session object.
/// Read the username from the session object.
curData.username = session.getAttribute('username')
// Determine the user's default permissions.
/// Determine the user's default permissions.
curData.defaultPermissions =
(config."account.${curData.username}.defaultPermissions" ?: "")
.split(/,/).collect { it.trim() }
switch(request.servletPath) {
// If they are invoking /gtd/logout then invalidate their session
// and return 200 OK
/// If they are invoking `/gtd/logout` then invalidate their session
/// and return `200 OK`
case "/logout":
session.removeAttribute("authenticated")
session.invalidate()
break
// If they are GET'ing /gtd/contexts then return the list of
// contexts that are readable by this user.
/// ##### `/gtd/contexts`
/// Return the list of contexts that are readable by this user.
case "/contexts":
// Filter the directories to find the ones that the user has
// read access to.
/// Filter all the context directories to find the ones that
/// the user has read access to.
def selectedContexts = findAllowedDirs("read", curData,
gtdDirs['next-actions'].listFiles())
/// Now format our response as JSON and write it to the response
def returnData = selectedContexts.collect { entry ->
[id: entry.dir.name, title: entry.props.title] }
// Now format our response as JSON and write it to the response
writeJSON(returnData, response)
break
// If they are GET'ing /gtd/contexts/<contextId> then return data
// for the requested context, assuming it is readable for this
// user.
/// ##### `/gtd/contexts/<contextId>`
/// Return data for the requested context, assuming it is
/// readable for this user.
case ~'/contexts/(.+)':
String contextId = Matcher.lastMatcher[0][1]
// Try to find the named context.
/// Try to find the named context.
File ctxDir = new File(gtdDirs['next-actions'], contextId)
// Check that they have read permission on this directory.
/// Check that they have read permission on this directory.
def filteredList = findAllowedDirs("read", curData, [ctxDir])
if (filteredList.size() == 0) {
response.status = SC_NOT_FOUND
@ -191,11 +250,11 @@ public class GTDServlet extends HttpServlet {
writeJSON(returnData, response)
break
// If they are GET'ing /gtd/projects then return the list of
// projects that are readable for this user.
/// ##### `/gtd/projects`
/// Return the list of projects that are readable for this user.
case "/projects":
// Filter the directories to find the ones that the user has
// read access to.
/// Filter the project directories to find the ones that the
/// user has read access to.
def selectedProjects = findAllowedDirs("read", curData,
gtdDirs['projects'].listFiles())
@ -204,74 +263,81 @@ public class GTDServlet extends HttpServlet {
writeJSON(returnData, response)
break
// If they are GET'ing /gtd/projects/<projectId> then return the
// list of projects that are readable for this user.
/// ##### `/gtd/projects/<projectId>`
/// Return data for the requested project, assuming it is readable
/// for this user.
case ~'/projects/(.+)':
String projectId = Matcher.lastMatcher[0][1]
// Try to find the named project.
/// Try to find the named project.
File projectDir = new File(gtdDirs['projects'], contextId)
// Check that they have read permission on this directory.
/// Check that they have read permission on this directory.
def filteredList = findAllowedDirs("read", curData, [projectDir])
if (filteredList.size() == 0) {
response.status = SC_NOT_FOUND
writeJSON([status: "not found"], response)
break }
/// Format as JSON and return.
def entry = filteredList[0]
def returnData = [id: entry.dir.name, title: entry.props.title]
writeJSON(returnData, response)
break
/// ##### `/gtd/next-actions/<contexts-and-projects>`
/// Return all of the items contained in the named contexts and
/// projects, assuming the user has access to them.
/// `<contexts-and-projects>` is expected to be a comma-delimited
/// list of context and project IDs.
case ~'/next-actions/(.+)':
// Parse out the list of contexts/projects
/// Parse out the list of contexts/projects
List ids = Matcher.lastMatcher[0][1].split(/,/) as List
List searchDirs = []
// Look for each id in our list of contexts
/// Look for each id in our list of contexts
searchDirs.addAll(ids.collect { id ->
new File(gtdDirs['next-actions'], id) })
// And look for each id in our list of projects
/// And look for each id in our list of projects
searchDirs.addAll(ids.collect { id ->
new File(gtdDirs['projects'], id) })
// Filter the directories to find the ones that exist and are
// readable by our user.
/// Filter the directories to find the ones that exist and are
/// readable by our user.
def actualDirs = findAllowedDirs("read", curData, searchDirs)
// Collect all the items.
/// Collect all the items.
def items = [], itemFiles = [], uniqueItemFiles = []
// Collect all the items across all the actual directories.
/// Collect all the items across all the actual directories.
itemFiles = actualDirs.collectMany { entry ->
entry.dir.listFiles({ f -> !f.isHidden() } as FileFilter) as List }
// De-duplicate the items using the GTD findAllCopies utility
// method to remove items that are listed in a chosen context
// and project. We are going to do this by identifying
// duplicate items, removing all of them from the itemFiles
// list and adding only the first to our new uniqueItemFiles
// list.
/// De-duplicate the items using the [`Util.findAllCopies`][8]
/// method to remove items that are listed in a chosen context
/// and project. We are going to do this by identifying
/// duplicate items, removing all of them from the itemFiles
/// list and adding only the first to our new uniqueItemFiles
/// list.
///
/// [8]: jlp://gtd.jdb-labs.com/Util/findAllCopies
while (itemFiles.size() > 0) {
def item = itemFiles.remove(0)
// Find all duplicates.
def dupes = Util.findAllCopies(item, gtdDirs.root)
// Remove them from the source collection.
/// Remove them from the source collection.
itemFiles.removeAll { f1 -> dupes.any { f2 ->
f1.canonicalPath == f2.canonicalPath }}
// Add the first one to the destination collection.
/// Add the first one to the destination collection.
uniqueItemFiles << item }
// Create Item objects for each item.
/// Create Item objects for each item.
items = uniqueItemFiles.collect { new Item(it) }
// Return all the items.
/// Return all the items.
def returnData = items.collect { item ->
def m = [id: item.file.name]
item.gtdProperties.each { k, v ->
@ -281,7 +347,7 @@ public class GTDServlet extends HttpServlet {
writeJSON(returnData, response)
break
// Otherwise return a 404 Not Found
/// Otherwise return a `404 Not Found`
default:
response.status = SC_NOT_FOUND
break
@ -290,6 +356,15 @@ public class GTDServlet extends HttpServlet {
response.writer.flush()
}
/** #### `findAllowedDirs`
* Helper method to take a permission or list of permissions, a list of
* File objects and return the subset of File objects which represent
* existing directories for which the current user has all of the
* requested permissions. This method also takes a [TempRequestData][7]
* object which provides access to the username and default permissions
* for the user making the request.
*
* [7]: jlp://gtd.jdb-labs.com/GTDServlet/TempRequestData */
protected Collection findAllowedDirs(String permission,
TempRequestData curData, def dirs) {
return findAllowedDirs([permission], curData, dirs) }
@ -298,39 +373,43 @@ public class GTDServlet extends HttpServlet {
TempRequestData curData, def dirs) {
return dirs.collectMany { dir ->
println ">> Considering ${dir.canonicalPath}"
// Only directories can be contexts and projects.
/// Only directories can be contexts and projects.
if (!dir.exists() || !dir.isDirectory()) { return [] }
// Check for a .properties file in this directory.
/// Check for a .properties file in this directory.
def propFile = new File(dir, '.properties')
// If it does not exist, defer to the defaults.
/// If it does not exist, defer to the defaults.
if (!propFile.exists() &&
!curData.defaultPermissions.containsAll(requiredPermissions)) {
return [] }
// Look for the account.<curData.username>.Permissions property.
/// Look for the `account.<curData.username>.permissions` property.
/// *Note* that the property access on `itemProps` will write the
/// default value to the properties file if it does not exist. This
/// may result in a new properties file being created.
def itemProps = new SmartConfig(propFile)
def actualPermissions = itemProps.getProperty(
"account.${curData.username}.permissions", "default").
split(/,/).collect { it.trim() }
// If the user has the correct permission on this context, or
// if this context inherits their default permissions, and
// they have the correct permission by default, then we show
// this context. If this is not the case (tested in the
// following conditional) we do not show this context.
/// If the user has the correct permission on this context, or
/// if this context inherits their default permissions, and
/// they have the correct permission by default, then we allow
/// this context. If this is not the case (tested in the
/// following conditional) we do not allow this context.
if (!actualPermissions.containsAll(requiredPermissions) &&
!(actualPermissions.containsAll('default') &&
curData.defaultPermissions.containsAll(requiredPermissions))) {
return [] }
// At this point we know the context exists, and the user
// has permission to read it.
/// At this point we know the context exists, and the user
/// has permission to access it.
return [[ dir: dir, props: itemProps ]] } }
/** #### `writeJSON`
* Helper method to write an object as JSON to the response. Mainly used
* to increase readability. */
protected void writeJSON(def data, def response) {
new JsonBuilder(data).writeTo(response.writer) }
}