19 Commits
v0.5 ... v1.9

Author SHA1 Message Date
acaf58f456 Added delegate command to delegate existing actions.
* Created `delegate`. This will move an action from a next actions context
  folder to the waiting folder. Any duplicate items in project folders will be
  renamed and updated to reflect any action change.
* Updated `ls` to take multiple named contexts or projects and list them all.
2013-11-05 08:46:06 -06:00
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
15 changed files with 1546 additions and 291 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 @@
#Wed, 01 May 2013 09:43:41 -0500
#Tue, 05 Nov 2013 08:44:25 -0600
lib.local=true
name=jdb-gtd
version=0.5
version=1.9
nailgun.classpath.dir=/home/jdbernard/programs/nailgun/classpath
build.number=1
executable.jar=true
main.class=com.jdblabs.gtd.cli.GTDCLI
build.number=5

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 [:] }
}

File diff suppressed because it is too large Load Diff

View File

@ -1,30 +0,0 @@
package com.jdblabs.gtd.cli
public class Item {
public File file
public Map gtdProperties = [:]
public Item(File f) {
this.file = f
def javaProps = new Properties()
f.withReader { reader -> javaProps.load(reader) }
javaProps.each { k, v -> gtdProperties[k] = PropertyHelp.parse(v) } }
public void save() {
def javaProps = new Properties()
gtdProperties.each { k, v -> javaProps[k] = PropertyHelp.format(v) }
file.withOutputStream { os -> javaProps.store(os, "") } }
public def propertyMissing(String name, def value) {
gtdProperties[name] = value }
public def propertyMissing(String name) { return gtdProperties[name] }
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

@ -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) }
}