23 Commits
v0.1 ... v1.8

Author SHA1 Message Date
5ac69157dc Fixed bug in done command. Replaced output system with logging system.
* Fixed the bug in `done` where it would incorrectly reference relative files.
* Moved to using SLF4J and Logback loggers for all output.
2013-11-03 00:42:58 -05:00
b4e01b6098 done supports a list of actions, bugfix.
* `done` command now accepts an unlimited list of tasks to mark as done.
* `GTDCLI.stringToFilename` now removes forward slashes.
2013-10-30 11:31:28 -05:00
415c0e622f Fixed documentation typos, error code typos. 2013-10-28 12:25:22 -05:00
a9ba9d94f8 Bumped version number. 2013-10-21 14:08:58 +00:00
085b8d1d14 Refactored new and process actions to ask the user for a context.
Also added logic to copy items to their correct project folder if a project is
given and the corresponding project folder exists.
2013-10-21 14:01:48 +00:00
9fee96cb25 Added support for Windows-style paths. 2013-10-08 14:30:14 -05:00
1e0a3b4063 Fixing links for GitHub-style README. 2013-09-23 23:24:11 -05:00
0776889bc5 Fixed links in README to work with GitHub's default Markdown rendering (as opposed to JLP's). 2013-09-23 23:15:55 -05:00
ab80b3a1b9 Started working on README. 2013-09-23 23:09:30 -05:00
aee6e442ee Fixing links in documentation. 2013-09-23 23:08:14 -05:00
8fe3ef015d Comprehensive documentation using JLP. 2013-09-23 12:42:25 -05:00
58026c83ab Created the GTDServlet to serve the GTD repository via a REST API.
* Moved Item, PropertyHelp to the com.jdblabs.gtd namespace.
* Broke out common functions from GTCLI to a new Util class.
* Created a GTDServlet class which responds to the following endpoints:

  * `/login` (POST): Expects JSON input in the request body in the form of
    `{"username": "joe_user", "password": "password1234" }`. The username and
    password are validated against the values listed in the GTD root directory
    .properties file.
  * `/contexts` (GET): Returns all the GTD contexts the current user has `read`
    access to.
  * `/contexts/<contextId>` (GET): Returns a single context. The ID is the GTD
    context directory name.
  * `/projects` (GET): Returns all the GTD projects the current user has `read`
    access to.
  * `/projects/<projectId>` (GET): Returns a single project. The ID is the GTD
    project directory name.
  * `/next-actions/<categoryNames>` (GET): Returns all the next actions for a
    list of contexts or projects. The categoryNames value is expected to be a
    comma-delimited list of project and context names.
2013-09-22 16:13:54 -05:00
a2f8b7b7a6 ls command: include projects, list all if no specific context is named. 2013-08-05 10:21:45 -05:00
4339a7db2a Added ls command: list all tasks for a given context. 2013-07-02 21:56:05 -05:00
1c4e526833 Added ticker command. 2013-05-06 16:54:27 -05:00
e893da72b6 Bugfix: typo in variable name. 2013-05-06 16:14:25 -05:00
62e62404c1 Bugfixes for new command. 2013-05-01 15:02:50 -05:00
7a04d46853 Added new command, brought the on-line help up-to-date. 2013-05-01 14:38:29 -05:00
f3c8f575b7 Changed formatting of the calendar command. 2013-05-01 09:46:25 -05:00
cabbdf7450 Added list-copies command. 2013-05-01 09:41:17 -05:00
7f39ab7de1 Bug fix (misnamed variable). 2013-05-01 09:29:15 -05:00
daf6bea67b v0.3: Added calendar command, done now works also for waiting items. 2013-04-29 11:31:41 -05:00
67f6ee4f91 Implemented gtd done command. 2013-04-29 02:23:41 -05:00
15 changed files with 1582 additions and 224 deletions

69
README.md Normal file
View File

@ -0,0 +1,69 @@
# Jonathan Bernard's Getting Things Done implementation.
This is my adaptation of the Getting Things Done system by David Allen. There
are a lot of tools that adapt his system for various digital platforms, but
most of them move away from the folder-based system he created. They try to
create new systems based on the core principles of the method outlined in
[*Getting Things Done*][book], but I was unable to find a system that followed
the details of his method. I do not think there is anything wrong with
reimagining the system based on the core principles. David Allen advocates that
in the book himself. Still, I was very attracted to the folder-based
implementation that he descibes; I only wanted to use digital folders and files
instead of physical folders and pages.
## History and Motivation
My method initially started as a simple collection of folders, intended to
mirror the physical system. As I used this I noticed some common use patterns
that would benefit from an automated tool. In particular, I wanted to have the
system walk my through the *process* phase. It was too easy for me to forget
some of the important principles of this phase: immediately doing anything that
could be done in 5 minutes or less, identifying the next action for an item,
and sorting the action correctly. Out of this the [command-line tool][cli] was
born.
As I started using the system for everything I started desiring to have some
way to publish my plans (or at least some contexts of my plans, like work).
This lead me to implement a [REST API][servlet] that interfaced with the
repository (still just files) so that I could easily embed this information
in a web page, or allow controlled access to the system from a client
application.
## How It Works
*TODO*
## Code Index
### com.jdblabs.gtd
[Item](http://jdbernard.github.io/gtd/doc/src/main/com/jdblabs/gtd/Item.groovy.html)
: One item in the GTD system (a *next action* for example). This class is a
wrapper around the File to make it easier to work programatically with GTD
items.
[PropertyHelp](http://jdbernard.github.io/gtd/doc/src/main/com/jdblabs/gtd/PropertyHelp.groovy.html)
: Simple serialization support for item properties. Used to read and write
properties from an item file.
[Util](http://jdbernard.github.io/gtd/doc/src/main/com/jdblabs/gtd/Util.groovy.html)
: Utility methods common to this implementation of the Getting Things Done
method.
### com.jdblabs.gtd.cli
[GTDCLI][cli]
: Command-line interface to the GTD repository. The repository organization
is intended to be simple enough that standard UNIX command-line tools are
sufficient, but it is useful to add some specific commands to walk you
through the processing phase or manage duplicated entries (when tracking an
item in a next-actions context and a project folder, for example).
### com.jdblabs.gts.servlet
[GTDServlet][servlet]
: Standard Java servlet to expose the repository via
[book]: https://secure.davidco.com/store/catalog/GETTING-THINGS-DONE-PAPERBACK-p-16175.php
[cli]: http://jdbernard.github.io/gtd/doc/src/main/com/jdblabs/gtd/cli/GTDCLI.groovy.html
[servlet]: http://jdbernard.github.io/gtd/doc/src/main/com/jdblabs/gtd/servlet/GTDServlet.groovy.html

View File

@ -40,4 +40,31 @@
<exec executable="ng-start" os="Linux"/>
</target>
<target name="servlet" depends="compile,increment-build-number">
<mkdir dir="${build.dir}/servlet/WEB-INF/classes"/>
<mkdir dir="${build.dir}/servlet/WEB-INF/lib"/>
<mkdir dir="${build.dir}/servlet/META-INF"/>
<copy todir="${build.dir}/servlet/WEB-INF/classes">
<fileset dir="${build.dir}/main/classes"/>
</copy>
<copy todir="${build.dir}/servlet/WEB-INF/lib">
<fileset dir="${build.dir}/lib/runtime/jar"/>
</copy>
<copy todir="${build.dir}/servlet/WEB-INF">
<fileset dir="${resources.dir}/WEB-INF"/>
</copy>
<copy todir="${build.dir}/servlet/META-INF">
<fileset dir="${resources.dir}/META-INF"/>
</copy>
<!--<jar
destfile="${build.dir}/${name}-servlet-${version}.${build.number}.war"
basedir="${build.dir}/servlet"/> -->
<jar destfile="${build.dir}/gtd.war" basedir="${build.dir}/servlet"/>
</target>
</project>

Binary file not shown.

Binary file not shown.

View File

View File

View File

@ -1,7 +1,8 @@
#Sat, 27 Apr 2013 23:11:31 -0500
#Sat, 02 Nov 2013 23:33:05 -0500
lib.local=true
name=jdb-gtd
version=0.1
version=1.8
nailgun.classpath.dir=/home/jdbernard/programs/nailgun/classpath
build.number=24
executable.jar=true
main.class=com.jdblabs.gtd.cli.GTDCLI
build.number=72

21
resources/WEB-INF/web.xml Normal file
View File

@ -0,0 +1,21 @@
<web-app xmlns="http://java.sun.com/xml/ns/javaee"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://java.sun.com/xml/ns/javaee http://java.sun.com/xml/ns/javaee/web-app_3_0.xsd"
version="3.0">
<servlet>
<servlet-name>GTDServlet</servlet-name>
<servlet-class>com.jdblabs.gtd.servlet.GTDServlet</servlet-class>
<init-param>
<param-name>gtdRootDir</param-name>
<param-value>/home/jdbernard/Dropbox/gtd</param-value>
</init-param>
</servlet>
<servlet-mapping>
<servlet-name>GTDServlet</servlet-name>
<url-pattern>/</url-pattern>
</servlet-mapping>
</web-app>

View File

@ -0,0 +1,78 @@
/**
* # 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
if (gtdProperties.title) return gtdProperties.title
return file.name.replaceAll(/[-_]/, " ").capitalize() }
}

View File

@ -0,0 +1,141 @@
/**
* # 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
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 {
/// **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`][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 ->
def df = dateFormat.clone()
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 });
String pattern;
Class klass;
def parseFun, formatFun;
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)
private static String NEVER_MATCH = /(?!x)x/;
/// #### constructor
public PropertyHelp(String pattern, Class klass, def parseFun,
def formatFun) {
this.pattern = pattern
this.klass = klass
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) {
/// 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

@ -0,0 +1,124 @@
/**
* # 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")
def copies = []
def originalMD5 = md5.digest(original.bytes)
inDir.eachFileRecurse { file ->
if (file.isFile() && md5.digest(file.bytes) == originalMD5)
copies << file }
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 (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 }
/** #### `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)] }
if (gtdDirs.values().every { dir -> dir.exists() && dir.isDirectory() }) {
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,72 +1,202 @@
/**
* # GTDCLI
* @author Jonathan Bernard (jdb@jdb-labs.com)
* @copyright 2013 [JDB Labs LLC](http://jdb-labs.com)
*/
package com.jdblabs.gtd.cli
import ch.qos.logback.classic.Level
import ch.qos.logback.classic.Logger
import ch.qos.logback.classic.LoggerContext
import ch.qos.logback.classic.encoder.PatternLayoutEncoder
import ch.qos.logback.classic.filter.LevelFilter
import ch.qos.logback.classic.filter.ThresholdFilter
import ch.qos.logback.core.OutputStreamAppender
import ch.qos.logback.core.spi.FilterReply
import com.jdblabs.gtd.Item
import com.jdblabs.gtd.PropertyHelp
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.Logger as SFL4JLogger
import org.slf4j.LoggerFactory
import static com.jdblabs.gtd.Util.*
/**
* Command-line helper for working with this implementation of the Getting
* Things Done method.
* @org gtd.jdb-labs.com/cli/GTDCLI */
public class GTDCLI {
public static final String VERSION = "0.1"
public static final String VERSION = "1.8"
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
private Logger log = LoggerFactory.getLogger(getClass())
/// 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
/// Logging objects
private Logger log
private OutputStreamAppender otherAppender
private OutputStreamAppender infoAppender
private ThresholdFilter thresholdFilter
private LevelFilter rejectInfo
private String loggingThreshold
public void setLoggingThreshold(String level) {
if (thresholdFilter) {
System.out.println "Changing logging level to $level"
thresholdFilter.stop()
thresholdFilter.level = level
thresholdFilter.start() }
this.loggingThreshold = level }
/** #### `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/cli/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
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
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
/// Setup logging
LoggerContext lc = (LoggerContext) LoggerFactory.getILoggerFactory()
lc.reset()
thresholdFilter = new ThresholdFilter()
loggingThreshold = config.defaultLoggingLevel ?: 'INFO'
thresholdFilter.level = loggingThreshold
infoAppender = new OutputStreamAppender()
otherAppender = new OutputStreamAppender()
PatternLayoutEncoder infoLayout = new PatternLayoutEncoder()
PatternLayoutEncoder otherLayout = new PatternLayoutEncoder()
LevelFilter acceptInfo = new LevelFilter()
rejectInfo = new LevelFilter()
[infoAppender, otherAppender, infoLayout, otherLayout, acceptInfo,
rejectInfo].each { it.context = lc }
// Setup filter and layout for INFO appender
infoLayout.context = lc
infoLayout.pattern = '%msg'
infoLayout.start()
acceptInfo.level = Level.INFO
acceptInfo.onMatch = FilterReply.ACCEPT
acceptInfo.onMismatch = FilterReply.DENY
acceptInfo.start()
infoAppender.encoder = infoLayout
infoAppender.outputStream = System.out
infoAppender.addFilter(acceptInfo)
infoAppender.start()
// Setup filters and layout for non-INFO appender
otherLayout.context = lc
otherLayout.pattern = '%level -- %msg%n'
otherLayout.start()
rejectInfo.level = Level.INFO
rejectInfo.onMatch = FilterReply.DENY
rejectInfo.start()
thresholdFilter.start()
otherAppender.encoder = otherLayout
otherAppender.outputStream = System.err
otherAppender.addFilter(rejectInfo)
otherAppender.addFilter(thresholdFilter)
otherAppender.start()
log = lc.getLogger(getClass())
log.addAppender(infoAppender)
log.addAppender(otherAppender)
/// 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/cli/GTDCLI/run */
protected void run(String[] args) {
log.debug("Args: $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)
@ -75,83 +205,81 @@ 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
log.debug("Parsed args: ${parsedArgs}")
if (parsedArgs.size() < 1) printUsage()
log.debug("argument list: {}", parsedArgs)
/// Make sure we are in a GTD directory.
gtdDirs = findGtdRootDir(workingDir)
log.debug("gtdDirs:$EOL\t${gtdDirs}")
if (!gtdDirs) {
log.error "fatal: '${workingDir.canonicalPath}'"
log.error " is not a GTD repository (or any of the parent directories)."
return }
while (parsedArgs.peek()) {
/// Pull off the first argument.
def command = parsedArgs.poll()
log.trace("Processing command: ${command}")
/// Match the first argument and invoke the proper command method.
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
case ~/new/: newAction(parsedArgs); break
case ~/tickler/: tickler(parsedArgs); break
case ~/ls|list/: ls(parsedArgs); break;
case ~/debug/: debug(parsedArgs); break;
default:
parsedArgs.addFirst(command)
process(parsedArgs)
log.error "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/cli/GTDCLI/help/process
*/
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 givenDir = new File(path)
if (!(gtdDirs = findGtdRootDir(givenDir))) {
log.error "'$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
inDir.listFiles().collect { new Item(it) }.each { item ->
/// Start processing items
gtdDirs.in.listFiles().collect { new Item(it) }.each { item ->
println ""
def response
def readline = {stdin.nextLine().trim()}
def prompt = { msg ->
if (msg instanceof List) msg = msg.join(EOL)
msg += "> "
print msg
def line
def oldFile = item.file
while(!(line = readline())) print msg
return line }
// 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)"
@ -164,28 +292,26 @@ public class GTDCLI {
PropertyHelp.parse(parts[1].trim())
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() }
// 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();
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 }
// > 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."
@ -200,95 +326,561 @@ 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(projectsDir,
item.file = new File(gtdDirs.projects,
stringToFilename(item.outcome))
item.save()
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([
"Next action (who needs to do what).", ""])
def oldFile = item.file
item.file = new File(waitingDir,
stringToFilename(item.action))
item.save()
oldFile.delete()
println "Moved to ${waitingDir.name} folder." }
item.file = new File(promptContext(gtdDirs.waiting),
stringToFilename(item.toString())) }
// Defer
/// Defer, move to the *next-actions* folder.
else if (response =~ /def/) {
item.action = prompt(["Next action.", ""])
def oldFile = item.file
item.file = new File(actionsDir,
stringToFilename(item.action))
item.save()
oldFile.delete()
item.file = new File(promptContext(gtdDirs["next-actions"]),
stringToFilename(item.toString())) }
println "Moved to the ${actionsDir.name} folder."
}
// Tickle
/// Forget for now, move it to the *tickler* folder.
else {
item.action = prompt(["Next action.", ""])
item.tickle = prompt([
"When do you want it to become active?",
"(YYYY-MM-DD)"])
def oldFile = item.file
item.file = new File(ticklerDir,
stringToFilename(item.action))
item.file = new File(gtdDirs.tickler,
stringToFilename(item.toString())) }
item.save()
oldFile.delete()
println "Moved to the ${ticklerDir.name} folder." } } } } }
println "Moved to " +
getRelativePath(gtdDirs.root, item.file.parentFile)
/// If we have a project property, and a corresponding
/// project folder exists, copy the item there.
def projectDir = new File(gtdDirs.projects,
item.project ?: '')
if (item.project && projectDir.exists() &&
projectDir.isDirectory()) {
item.file = new File(projectDir,
stringToFilename(item.toString()))
item.save()
println "Copied to " +
getRelativePath(gtdDirs.root, item.file.parentFile) } } } } }
/** #### `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/cli/GTDCLI/help/done
*/
protected void done(LinkedList args) {
def selectedFilePath = args.poll()
if (!selectedFilePath) {
log.error "gtd done command requires a <action-file> parameter."
return }
while (selectedFilePath) {
def item
def selectedFile = new File(selectedFilePath)
if (!selectedFile.isAbsolute())
selectedFile = new File(workingDir, selectedFilePath)
if (!selectedFile.exists() || !selectedFile.isFile()) {
log.error "File does not exist or is a directory:"
log.error "\t" + selectedFile.canonicalPath
continue }
item = new Item(selectedFile)
/// 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 from the next actions folder.
findAllCopies(oldFile, gtdDirs."next-actions").each { file ->
println "Deleting duplicate entry from the " +
"${file.parentFile.name} context."
if (file.exists()) file.delete() }
/// 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."
if (file.exists()) 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 from the projects folder.
findAllCopies(oldFile, gtdDirs.projects).each { file ->
println "Deleting duplicate entry from the " +
"${file.parentFile.name} project."
if (file.exists()) file.delete() }}
/// Delete the original
oldFile.delete()
selectedFilePath = args.poll()
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/cli/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 }
if (!itemsOnCalendar) println "No items on the calendar."
def currentDate = null
/// Print each day of items.
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" } }
/** #### `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/cli/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)
if (!file.isFile()) {
log.error "${file.canonicalPath} is not a regular file."
return }
String originalRelativePath = getRelativePath(gtdDirs.root, file)
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)
println " $relativePath" }} }
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/cli/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()
def item = new Item(file)
item.action = response
println "Enter extra info. One 'key: value' pair per line."
println "(ex: date: YYYY-MM-DD, project=my-project)"
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())
print "> " }
item.save()
/// If we have a project property, and a corresponding project folder
/// exists, copy the item there.
def projectDir = new File(gtdDirs.projects, item.project ?: '')
if (item.project && projectDir.exists() && projectDir.isDirectory()) {
item.file = new File(projectDir, stringToFilename(item.toString()))
item.save()
println "Copied to " +
getRelativePath(gtdDirs.root, item.file.parentFile) } }
/** #### `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/cli/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 ((item.tickle as DateMidnight) <= today) {
println "Moving '${item}' out of the tickler."
def oldFile = item.file
item.file = new File(gtdDirs."next-actions",
stringToFilename(item.toString()))
item.gtdProperties.remove("tickle")
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/cli/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)} --"
dir.eachFile { file ->
if (!file.exists() || !file.isFile() || file.isHidden() ||
file.name.startsWith('.'))
return
def item = new Item(file)
println item}
println "" }
/// 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) } }
/** #### `debug`
* Print out debug information. Currently this prints out the internal
* state of the CLI. I may add other subcommands if the need arises. */
protected void debug(LinkedList args) {
def command = args.poll()
if (!command || "state" == command) {
println "GTD CLI v${VERSION}"
println ""
println "-- General"
println " Running under nailgun? ${nailgunInst ? 'yes' : 'no'}"
println " Terminal width ${terminalWidth}"
println " Working directory ${workingDir.canonicalPath}"
println ""
println "-- GTD Directories"
gtdDirs.each { k, v -> println " ${k.padRight(12)} ${v.canonicalPath}" }
println ""
println "-- Logging"
println " Threshold ${loggingThreshold}"
log.trace " Message from TRACE"
log.debug " Message from DEBUG"
log.info " Message from INFO${EOL}"
log.warn " Message from WARN"
log.error " Message from ERROR" }
else if ("loglevel" == command) {
def level = args.poll()
if (!level)
log.error "debug loglevel command requires additional arguments."
else setLoggingThreshold(level) }
else log.error "Unrecognized debug command: '${command}'." }
private void print(String msg) { log.info(msg) }
private void println(String line) { log.info(line + EOL) }
/** #### `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) {
println "Jonathan Bernard's Getting Things Done CLI v$VERSION"
println "usage: gtd [option...] <command>..."
println ""
println "options are:"
println ""
println " -h, --help Print this usage information."
println " -d, --directory Set the GTD root directory."
println " -v, --version Print the GTD CLI version."
println ""
println "top-leve commands:"
println ""
println " process Process inbox items systematically."
println " help <command> Print detailed help about a command."
println """\
Jonathan Bernard's Getting Things Done CLI v$VERSION
usage: gtd [option...] <command>...
options are:
-h, --help Print this usage information.
-d, --directory Set the GTD root directory.
-v, --version Print the GTD CLI version.
top-level commands:
help <command> Print detailed help about a command.
process Process inbox items systematically.
done <action-file> Mark an action as done. This will automatically
take care of duplicates of the action in project
or next-actions sub-folders.
calendar Show the tasks with specific days assigned to
them, sorted by date.
list-copies <action-file> Given an action item, list all the other places
there the same item is filed (cross-reference
with a project folder, for example).
new Interactively create a new action item in the
current folder.
tickler Search the tickler file for items that need to be
delivered and move them to the *next-actions*
folder."""
} else {
def command = args.poll()
// TODO
//switch(command.toLowerCase()) {
// case ~/process/:
switch(command.toLowerCase()) {
/// Online help for the `process` command.
/// @org gtd.jdb-labs.com/cli/GTDCLI/help/process
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
/// Online help for the `done` command.
/// @org gtd.jdb-labs.com/cli/GTDCLI/help/done
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 hash of the file contents)."""
break
/// Online help for the `calendar` command.
/// @org gtd.jdb-labs.com/cli/GTDCLI/help/calendar
case ~/calendar/: println """\
usage: gtd calendar
Print an agenda of all the actions that are on the calendar, sorted by date.
This prints a date heading first, then all of the actions assogned to that day.
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/cli/GTDCLI/help/list-copies
case ~/list-copies/: println """\
usage: gtd list-copies <action-file>
Where <action-file> is expected to be the path (absolute or relative) to an
action item file.
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/cli/GTDCLI/help/new
case ~/new/: println """\
usage: gtd new
This command is interactive (maybe allow it to take interactive prompts in the
future?). It prompts the user for the next action and any extended properties
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/cli/GTDCLI/help/tickler
case ~/tickler/: println """\
usage: gtd tickler
This command should be scheduled for execution once a day. It checks the tickler
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/cli/GTDCLI/help/ls
case ~/ls|list-context/: println """\
usage gtd ls [<context> ...]
This command lists all the tasks for a given context or project. The purpose is
to list in one place items that are sitting in the next-actions folder or the
waiting folder for a specific context or list items for a given project. If no
context or project is named, all contexts are listed."""
}
}
}
static String filenameToString(File f) {
/** #### `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 += "> "
print msg
def line
while(!(line = stdin.nextLine().trim())) print msg
return line }
/** #### `promptContext`
* Prompt the user to choose a context (subdirectory of the given
* directory). */
protected File promptContext(File baseDir) {
print "Context?> "
def line
def contextFile
line = stdin.nextLine().trim()
contextFile = line ? new File(baseDir, line) : baseDir
while (!contextFile.exists() || !contextFile.isDirectory()) {
log.warn "'$line' is not a valid context."
println "Available contexts:"
baseDir.eachDir { print "\t${it.name}"}
println ""
print "Context?> "
line = stdin.nextLine().trim()
contextFile = line ? new File(baseDir, line) : baseDir }
return contextFile }
/** #### `filenameToString`
* The default pretty-print conversion for filenames. */
public static String filenameToString(File f) {
return f.name.replaceAll(/[-_]/, " ").capitalize() }
static String stringToFilename(String s) {
/** #### `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(/[';:]/, '').
replaceAll(/[';:(\.$\/)]/, '').
toLowerCase() }
}

View File

@ -1,30 +0,0 @@
package com.jdblabs.gtd.cli
public class Item {
public File file
public Map properties = [:]
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) } }
public void save() {
def javaProps = new Properties()
properties.each { k, v -> javaProps[k] = PropertyHelp.format(v) }
file.withOutputStream { os -> javaProps.store(os, "") } }
public def propertyMissing(String name, def value) {
properties[name] = value }
public def propertyMissing(String name) { return properties[name] }
public String toString() {
if (properties.action) return properties.action
if (properties.outcome) return properties.outcome
if (properties.title) return properties.title
return file.name.replaceAll(/[-_]/, " ").capitalize() }
}

View File

@ -1,81 +0,0 @@
package com.jdblabs.gtd.cli
import org.joda.time.DateMidnight
import org.joda.time.DateTime
import java.text.SimpleDateFormat
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.
DATE_MIDNIGHT(/^\d{4}-\d{2}-\d{2}$/, DateMidnight,
{ v -> DateMidnight.parse(v) },
{ d -> d.toString("YYYY-MM-dd") }),
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.
DATE(NEVER_MATCH, Date,
{ v -> v }, // never called
{ d -> dateFormat.format(d) }),
CALENDAR(NEVER_MATCH, Calendar,
{ v -> v }, // never called
{ c ->
def df = dateFormat.clone()
df.calendar = c
df.format(c.time) }),
INTEGER(NEVER_MATCH, Integer,
{ v -> v as Integer }, // never called
{ i -> i as String }),
LONG(/^\d+$/, Long,
{ v -> v as Long },
{ l -> l as String }),
FLOAT(NEVER_MATCH, Float,
{ v -> v as Float}, // never called
{ f -> f as String}),
DOUBLE(/^\d+\.\d+$/, Double,
{ v -> v as Double },
{ d -> d as String });
String pattern;
Class klass;
def parseFun, formatFun;
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).
private static String NEVER_MATCH = /(?!x)x/;
public PropertyHelp(String pattern, Class klass, def parseFun,
def formatFun) {
this.pattern = pattern
this.klass = klass
this.parseFun = parseFun
this.formatFun = formatFun }
public boolean matches(String prop) { return prop ==~ pattern }
public boolean matches(Class klass) { return this.klass == klass }
public static Object parse(String value) {
def propertyType = PropertyHelp.values().find {
it.matches(value) }
return propertyType ? propertyType.parseFun(value) : value }
public static String format(def object) {
def propertyType = PropertyHelp.values().find {
it.klass.isInstance(object) }
return propertyType ? propertyType.formatFun(object) : object.toString() }
}

View File

@ -0,0 +1,416 @@
/**
* # 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
import com.jdblabs.gtd.PropertyHelp
import com.jdblabs.gtd.Util
import com.jdbernard.util.SmartConfig
import groovy.json.JsonBuilder
import groovy.json.JsonException
import groovy.json.JsonSlurper
import java.util.regex.Matcher
import javax.servlet.ServletConfig
import javax.servlet.ServletException
import javax.servlet.http.HttpServlet
import javax.servlet.http.HttpServletRequest
import javax.servlet.http.HttpServletResponse
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.
* @org gtd.jdb-labs.com/servlet/GTDServlet
*/
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/servlet/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. " +
"Expected to find it at '${cfgFile.canonicalPath}'.")
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")
response.addHeader("Access-Control-Allow-Methods", "POST")
break
case ~'/contexts.*':
case ~'/projects.*':
case ~'/next-actions/.+':
response.addHeader("Allow", "GET")
response.addHeader("Access-Control-Allow-Methods", "GET")
break
default:
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 `/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.
def requestBody
try { requestBody = new JsonSlurper().parse(request.reader) }
catch (JsonException jsone) {
response.status = SC_BAD_REQUEST
return }
/// 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).
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.
if (!users.contains(requestBody.username) ||
requestBody.password != expectedPwd) {
response.status = SC_UNAUTHORIZED
response.writer.flush()
return }
response.status = SC_OK
session.setAttribute('authenticated', true)
session.setAttribute('username', requestBody.username)
writeJSON([status: "ok"], response)
return }
/// 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`
switch (request.servletPath) {
case ~/\/contexts.*/:
case ~/\/projects.*/:
response.status = SC_METHOD_NOT_ALLOWED
return
default:
response.status = SC_NOT_FOUND
return
}
}
/** #### `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 (!((boolean)session.getAttribute('authenticated'))) {
response.status = SC_UNAUTHORIZED
return }
def curData = new TempRequestData()
/// Read the username from the session object.
curData.username = session.getAttribute('username')
/// Determine the user's default permissions.
curData.defaultPermissions =
(config."account.${curData.username}.defaultPermissions" ?: "")
.split(/,/).collect { it.trim() }
switch(request.servletPath) {
/// If they are invoking `/logout` then invalidate their session
/// and return `200 OK`
case "/logout":
session.removeAttribute("authenticated")
session.invalidate()
break
/// ##### `/contexts`
/// Return the list of contexts that are readable by this user.
case "/contexts":
/// 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] }
writeJSON(returnData, response)
break
/// ##### `/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.
File ctxDir = new File(gtdDirs['next-actions'], contextId)
/// Check that they have read permission on this directory.
def filteredList = findAllowedDirs("read", curData, [ctxDir])
if (filteredList.size() == 0) {
response.status = SC_NOT_FOUND
writeJSON([status: "not found"], response)
break }
def entry = filteredList[0]
def returnData = [id: entry.dir.name, title: entry.props.title]
writeJSON(returnData, response)
break
/// ##### `/projects`
/// Return the list of projects that are readable for this user.
case "/projects":
/// Filter the project directories to find the ones that the
/// user has read access to.
def selectedProjects = findAllowedDirs("read", curData,
gtdDirs['projects'].listFiles())
def returnData = selectedProjects.collect { entry ->
[id: entry.dir.name, title: entry.props.title] }
writeJSON(returnData, response)
break
/// ##### `/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.
File projectDir = new File(gtdDirs['projects'], contextId)
/// 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
/// ##### `/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
List ids = Matcher.lastMatcher[0][1].split(/,/) as List
List searchDirs = []
/// 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
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.
def actualDirs = findAllowedDirs("read", curData, searchDirs)
/// Collect all the items.
def items = [], itemFiles = [], uniqueItemFiles = []
/// 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 [`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)
def dupes = Util.findAllCopies(item, gtdDirs.root)
/// Remove them from the source collection.
itemFiles.removeAll { f1 -> dupes.any { f2 ->
f1.canonicalPath == f2.canonicalPath }}
/// Add the first one to the destination collection.
uniqueItemFiles << item }
/// Create Item objects for each item.
items = uniqueItemFiles.collect { new Item(it) }
/// Return all the items.
def returnData = items.collect { item ->
def m = [id: item.file.name]
item.gtdProperties.each { k, v ->
m[k] = PropertyHelp.format(v) }
return m }
writeJSON(returnData, response)
break
/// Otherwise return a `404 Not Found`
default:
response.status = SC_NOT_FOUND
break
}
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/servlet/GTDServlet/TempRequestData */
protected Collection findAllowedDirs(String permission,
TempRequestData curData, def dirs) {
return findAllowedDirs([permission], curData, dirs) }
protected Collection findAllowedDirs(List requiredPermissions,
TempRequestData curData, def dirs) {
return dirs.collectMany { dir ->
/// Only directories can be contexts and projects.
if (!dir.exists() || !dir.isDirectory()) { return [] }
/// Check for a .properties file in this directory.
def propFile = new File(dir, '.properties')
/// 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.
/// *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 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 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) }
}