Compare commits

...

31 Commits
v0.4 ... main

Author SHA1 Message Date
Jonathan Bernard
64e70add18 Alphabatized the depency list. 2015-02-05 00:48:42 -06:00
Jonathan Bernard
7b861f2318 Added the WAR plugin to the gradle build. 2015-02-04 23:58:56 -06:00
Jonathan Bernard
b1f2c9a875 Migrated to gradle build system. 2015-02-04 23:47:42 -06:00
Joanthan Bernard
4c5f514fb4 Bash completion script, list-projects, list-contexts commands.
* list-projects lists all the projects in the repo.
* list-contexts lists all the context in the repo.
* Created a bash completion script to allow auto-complete for GTD.
2014-12-16 14:04:58 -06:00
Joanthan Bernard
a780d972f1 List actions now sorts its output alphabetically. 2014-12-16 11:16:49 -06:00
Joanthan Bernard
43f0930cf2 Updated the version number (forgot and pushed the tags). 2014-12-01 12:37:20 -06:00
Joanthan Bernard
f95dc91707 New rename-project command.
The `rename-project` command will rename a project and update all of the action
items associated with that project.

* Fills in some missing information in the online help.
* Fixes a bug in the finGtdRootDir function. It was not properly handling
  relative paths.
* Fixes a bug where the reconfigure command was referring to the wrong
  configuration file.
2014-12-01 12:05:55 -06:00
Joanthan Bernard
2c8180d9b2 Upgraded to jdb-util-3.2 2014-11-19 12:52:03 -06:00
Jonathan Bernard
12f87afe63 Added jlp-docs and target to generate documentation. 2014-06-21 14:32:48 -05:00
Jonathan Bernard
3496e21af5 Added online help and documentation for delegate command. 2014-04-16 21:37:30 -05:00
Jonathan Bernard
40906eebf8 Updated web.xml for current deployed config. 2014-04-15 20:45:00 +00:00
Jonathan Bernard
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
Jonathan Bernard
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
Jonathan Bernard
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
Jonathan Bernard
415c0e622f Fixed documentation typos, error code typos. 2013-10-28 12:25:22 -05:00
Jonathan Bernard
a9ba9d94f8 Bumped version number. 2013-10-21 14:08:58 +00:00
Jonathan Bernard
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
Jonathan Bernard
9fee96cb25 Added support for Windows-style paths. 2013-10-08 14:30:14 -05:00
Jonathan Bernard
1e0a3b4063 Fixing links for GitHub-style README. 2013-09-23 23:24:11 -05:00
Jonathan Bernard
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
Jonathan Bernard
ab80b3a1b9 Started working on README. 2013-09-23 23:09:30 -05:00
Jonathan Bernard
aee6e442ee Fixing links in documentation. 2013-09-23 23:08:14 -05:00
Jonathan Bernard
8fe3ef015d Comprehensive documentation using JLP. 2013-09-23 12:42:25 -05:00
Jonathan Bernard
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
Jonathan Bernard
a2f8b7b7a6 ls command: include projects, list all if no specific context is named. 2013-08-05 10:21:45 -05:00
Jonathan Bernard
4339a7db2a Added ls command: list all tasks for a given context. 2013-07-02 21:56:05 -05:00
Jonathan Bernard
1c4e526833 Added ticker command. 2013-05-06 16:54:27 -05:00
Jonathan Bernard
e893da72b6 Bugfix: typo in variable name. 2013-05-06 16:14:25 -05:00
Jonathan Bernard
62e62404c1 Bugfixes for new command. 2013-05-01 15:02:50 -05:00
Jonathan Bernard
7a04d46853 Added new command, brought the on-line help up-to-date. 2013-05-01 14:38:29 -05:00
Jonathan Bernard
f3c8f575b7 Changed formatting of the calendar command. 2013-05-01 09:46:25 -05:00
27 changed files with 2051 additions and 901 deletions

1
.gitignore vendored
View File

@ -1,2 +1,3 @@
.gradle/
*.sw?
build/

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

27
build.gradle Normal file
View File

@ -0,0 +1,27 @@
apply plugin: "groovy"
apply plugin: "war"
apply plugin: "maven"
group = "com.jdblabs"
version = "1.14"
repositories {
mavenLocal()
mavenCentral() }
dependencies {
compile 'ch.qos.logback:logback-classic:1.1.2'
compile 'ch.qos.logback:logback-core:1.1.2'
compile 'com.jdbernard:jdb-util:3.4'
compile 'com.martiansoftware:nailgun-server:0.9.1'
compile 'joda-time:joda-time:2.7'
compile 'org.codehaus.groovy:groovy-all:2.3.6'
compile 'org.slf4j:slf4j-api:1.7.10'
providedCompile 'javax.servlet:javax.servlet-api:3.0.1'
testCompile 'junit:junit:4.12'
}
task jlpDocs(type:Exec) {
commandLine 'jlp', '--no-source', '--output-dir', 'doc', 'src', 'README.md'
}

View File

@ -1,43 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<project name="gtd-cli" basedir="." default="ng-deploy">
<property file="project.properties"/>
<import file="jdb-build-1.10.xml"/>
<target name="init">
<mkdir dir="${build.dir}/main/classes"/>
</target>
<target name="ng-deploy" depends="build">
<!-- Stop the Nailgun Server -->
<exec executable="cmd" os="Windows XP">
<arg value="/c"/>
<arg value="ng-stop"/>
</exec>
<exec executable="ng-stop" os="Linux"/>
<!-- delete old copies -->
<delete>
<fileset dir="${nailgun.classpath.dir}">
<include name="${name}*.jar"/>
</fileset>
</delete>
<!-- copy new build -->
<copy todir="${nailgun.classpath.dir}">
<fileset dir="${build.dir}/lib/runtime/jar"/>
<fileset dir="${build.dir}">
<include name="${name}-${version}.${build.number}.jar"/>
</fileset>
</copy>
<!-- start the NG server up again. -->
<exec executable="cmd" os="Windows XP">
<arg value="/c"/>
<arg value="ng-start"/>
</exec>
<exec executable="ng-start" os="Linux"/>
</target>
</project>

View File

@ -1,248 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<project name="Jonathan Bernard Build Common"
xmlns:ivy="antlib:org.apache.ivy.ant">
<property environment="env"/>
<!--======== INIT TARGETS ========-->
<target name="-init" depends="-common-init,init"/>
<target name="-common-init">
<!-- Set default values for some key properties. Since properties are
write once, any value set before this point takes precedence. -->
<property name="versioning.file" value="project.properties"/>
<property name="src.dir" value="${basedir}/src"/>
<property name="build.dir" value="${basedir}/build"/>
<property name="lib.dir" value="${basedir}/lib"/>
<property name="resources.dir" value="${basedir}/resources"/>
<property name="splash.image" value="splash.png"/>
<!--======== PATHS ========-->
<path id="groovy.classpath">
<fileset dir="${env.GROOVY_HOME}/lib">
<include name="*.jar"/>
</fileset>
</path>
<path id="groovy.embeddable">
<fileset dir="${env.GROOVY_HOME}/embeddable">
<include name="*.jar"/>
</fileset>
</path>
<path id="compile-libs">
<fileset dir="${build.dir}/lib/compile/jar">
<include name="*.jar"/>
</fileset>
</path>
<path id="runtime-libs">
<fileset dir="${build.dir}/lib/runtime/jar">
<include name="*.jar"/>
</fileset>
</path>
</target>
<target name="-init-groovy">
<taskdef name="groovyc" classpathref="groovy.classpath"
classname="org.codehaus.groovy.ant.Groovyc"/>
<taskdef name="groovy" classpathref="groovy.classpath"
classname="org.codehaus.groovy.ant.Groovy"/>
</target>
<target name="init"/>
<target name="clean" depends="-init">
<delete dir="${build.dir}"/>
</target>
<!--======== LIBRARY TARGETS ========-->
<target name="-lib" depends="-lib-local,-lib-ivy,lib"/>
<target name="lib"/>
<target name="-init-ivy">
<ivy:settings id="ivy.settings" file="ivysettings.xml"/>
</target>
<target name="-lib-ivy" depends="-init-ivy" unless="${lib.local}">
<ivy:retrieve settingsRef="ivy.settings"
pattern="${lib.dir}/[conf]/[type]/[artifact]-[revision].[ext]"
conf="compile,runtime"/>
</target>
<target name="-lib-groovy" if="${lib.local}">
<copy todir="${build.dir}/lib/runtime/jar">
<fileset dir="${env.GROOVY_HOME}/embeddable"/>
</copy>
</target>
<target name="-lib-local" if="${lib.local}">
<echo message="Resolving libraries locally."/>
<mkdir dir="${build.dir}/lib/compile/jar"/>
<mkdir dir="${build.dir}/lib/runtime/jar"/>
<copy todir="${build.dir}/lib/compile/jar" failonerror="false">
<fileset dir="${lib.dir}/compile/jar"/>
</copy>
<copy todir="${build.dir}/lib/runtime/jar" failonerror="false">
<fileset dir="${lib.dir}/runtime/jar"/>
</copy>
</target>
<!--======== VERSIONING TARGETS ========-->
<target name="increment-build-number" depends="-init">
<propertyfile file="${versioning.file}">
<entry key="build.number" default="0" type="int" value="1"
operation="+"/>
</propertyfile>
</target>
<target name="set-version" depends="-init">
<input
message="The current version is ${version}. Enter a new version: "
addproperty="new-version"/>
<propertyfile file="${versioning.file}">
<entry key="version" value="${new-version}" operation="="
type="string"/>
<entry key="build.number" value="0" type="int" operation="="/>
</propertyfile>
</target>
<!--======== COMPILATION TARGETS ========-->
<target name="-compile-groovy" depends="-init,-init-groovy,-lib,-lib-groovy">
<mkdir dir="${build.dir}/main/classes"/>
<groovyc srcdir="${src.dir}/main" destdir="${build.dir}/main/classes"
includeAntRuntime="false" fork="true">
<classpath>
<path refid="groovy.classpath"/>
<path refid="compile-libs"/>
</classpath>
<javac/>
</groovyc>
</target>
<target name="-compile-java" depends="-init,-lib">
<mkdir dir="${build.dir}/main/classes"/>
<javac srcdir="${src.dir}/main" destdir="${build.dir}/main/classes"
includeAntRuntime="false" classpathref="compile-libs"/>
</target>
<target name="compile" depends="-compile-groovy"/>
<!--======== JUNIT TARGETS ========-->
<target name="-compile-tests-groovy" depends="-init,compile">
<mkdir dir="${build.dir}/test/classes"/>
<groovyc srcdir="${src.dir}/test" destdir="${build.dir}/test/classes"
includeAntRuntime="false" fork="true">
<classpath>
<path refid="groovy.classpath"/>
<path refid="compile-libs"/>
<path location="${build.dir}/main/classes"/>
</classpath>
</groovyc>
</target>
<target name="-compile-tests-java" depends="-init,compile">
<mkdir dir="${build.dir}/test/classes"/>
<javac srcdir="${src.dir}/test" destdir="${build.dir}/test/classes"
includeAntRuntime="false">
<classpath>
<path refid="compile-libs"/>
<path location="${build.dir}/main/classes"/>
</classpath>
</javac>
</target>
<target name="compile-tests" depends="-compile-tests-groovy"/>
<target name="run-tests" depends="compile-tests,resources-test">
<junit printsummary="true">
<classpath>
<path refid="groovy.classpath"/>
<path refid="compile-libs"/>
<path location="${build.dir}/main/classes"/>
<path location="${build.dir}/test/classes"/>
</classpath>
<formatter type="plain" usefile="false"/>
<batchtest>
<fileset dir="${build.dir}/test/classes">
<include name="**/*"/>
</fileset>
</batchtest>
</junit>
</target>
<!--======== RESOURCES TARGETS ========-->
<target name="resources" depends="-init">
<mkdir dir="${build.dir}/main/classes"/>
<copy todir="${build.dir}/main/classes" failonerror="false">
<fileset dir="${resources.dir}/main/"/>
</copy>
</target>
<target name="resources-test" depends="-init">
<mkdir dir="${build.dir}/test/classes"/>
<copy todir="${build.dir}/test/classes" failonerror="false">
<fileset dir="${resources.dir}/test/"/>
</copy>
</target>
<!--======== BUILD TARGETS ========-->
<target name="-build-modular-lib" unless="executable.jar"
depends="compile,increment-build-number,resources">
<jar destfile="${build.dir}/${name}-${version}.${build.number}.jar"
basedir="${build.dir}/main/classes"/>
</target>
<target name="-build-modular-executable" if="executable.jar"
depends="compile,increment-build-number,resources">
<pathconvert property="jar.classpath" pathsep=" " refid="runtime-libs">
<mapper>
<chainedmapper>
<!-- remove absolute path -->
<flattenmapper />
<!-- add lib/ prefix -->
<globmapper from="*" to="lib/*" />
</chainedmapper>
</mapper>
</pathconvert>
<jar destfile="${build.dir}/${name}-${version}.${build.number}.jar"
basedir="${build.dir}/main/classes">
<manifest>
<attribute name="Main-Class" value="${main.class}"/>
<attribute name="Class-Path" value="${jar.classpath}"/>
<attribute name="SplashScreen-Image" value="${splash.image}"/>
</manifest>
</jar>
</target>
<target name="-build-modular"
depends="-build-modular-lib,-build-modular-executable"/>
<target name="-build-packed-libs"
depends="compile,increment-build-number,resources">
<unjar destdir="${build.dir}/main/classes">
<fileset dir="${build.dir}/lib/runtime/jar"/>
</unjar>
<jar destfile="${build.dir}/${name}-${version}.${build.number}.jar"
basedir="${build.dir}/main/classes"/>
</target>
<target name="build" depends="-build-modular"/>
</project>

View File

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -1,7 +0,0 @@
#Wed, 01 May 2013 09:40:28 -0500
lib.local=true
name=jdb-gtd
version=0.4
nailgun.classpath.dir=/home/jdbernard/programs/nailgun/classpath
build.number=5

View File

@ -0,0 +1,39 @@
_gtd()
{
local cur prev topOpts debugOpts logLevels
COMPREPLY=()
cur="${COMP_WORDS[COMP_CWORD]}"
prev="${COMP_WORDS[COMP_CWORD-1]}"
topOpts="help process done calendar list-copies new tickler list debug delegate rename-project list-projects list-contexts"
debugOpts="state loglevel"
logLevels="TRACE DEBUG INFO WARN ERROR"
case "${prev}" in
help)
COMPREPLY=( $(compgen -W "${topOpts}" -- ${cur}) )
return 0
;;
done|list-copies|delegate)
COMPREPLY=( $(compgen -f ${cur}) )
return 0
;;
ls|list)
COMPREPLY=( $(gtd list-projects) $(gtd list-contexts) )
return 0
;;
debug)
COMPREPLY=( $(compgen -W "${debugOpts}" -- ${cur}) )
return 0
;;
loglevel)
COMPREPLY=( $(compgen -W "${logLevels}" -- ${cur}) )
return 0
;;
*)
;;
esac
COMPREPLY=( $(compgen -W "${topOpts}" -- ${cur}) )
return 0
}
complete -F _gtd gtd

View File

@ -1,492 +0,0 @@
package com.jdblabs.gtd.cli
import com.jdbernard.util.LightOptionParser
import com.martiansoftware.nailgun.NGContext
import java.security.MessageDigest
import org.joda.time.DateMidnight
import org.joda.time.DateTime
//import org.slf4j.Logger
//import org.slf4j.LoggerFactory
public class GTDCLI {
public static final String VERSION = "0.4"
private static String EOL = System.getProperty("line.separator")
private static GTDCLI nailgunInst
private MessageDigest md5 = MessageDigest.getInstance("MD5")
private int terminalWidth
private Scanner stdin
private File workingDir
private Map<String, File> gtdDirs
//private Logger log = LoggerFactory.getLogger(getClass())
public static void main(String[] args) {
GTDCLI inst = new GTDCLI(new File(System.getProperty("user.home"),
".gtdclirc"))
if (args.length > 0) args[-1] = args[-1].trim()
inst.run(args) }
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 (context.args.length > 0) context.args[-1] = context.args[-1].trim()
nailgunInst.run(context.args) }
public static void reconfigure(String[] args) {
if (nailgunInst == null) main(args)
else {
nailgunInst = null
nailgunInst = new GTDCLI(new File(
System.getProperty("user.home"), ".gritterrc"))
nailgunInst.run(args) } }
public GTDCLI(File configFile) {
// parse the config file
def config = [:]
if (configFile.exists())
config = new ConfigSlurper().parse(configFile.toURL())
// configure the terminal width
terminalWidth = (System.getenv().COLUMNS ?: config.terminalWidth ?: 79) as int
workingDir = config.defaultDirectory ?
new File(config.defaultDirectory) :
new File('.')
stdin = new Scanner(System.in) }
protected void run(String[] args) {
def cliDefinition = [
h: [longName: 'help'],
d: [longName: 'directory', arguments: 1],
v: [longName: 'version']]
def opts = LightOptionParser.parseOptions(cliDefinition, args as List)
if (opts.h) { printUsage(null); return }
if (opts.v) { println "GTD CLI v$VERSION"; return }
if (opts.d) workingDir = new File(opts.d)
def parsedArgs = (opts.args as List) as LinkedList
if (parsedArgs.size() < 1) printUsage()
gtdDirs = findGtdRootDir(workingDir)
if (!gtdDirs) {
println "fatal: '${workingDir.canonicalPath}'"
println " is not a GTD repository (or any of the parent directories)."
return }
while (parsedArgs.peek()) {
def command = parsedArgs.poll()
switch (command.toLowerCase()) {
case ~/help/: printUsage(parsedArgs); break
case ~/done/: done(parsedArgs); break
case ~/cal|calendar/: calendar(parsedArgs); break
case ~/process/: process(parsedArgs); break
case ~/list-copies/: listCopies(parsedArgs); break
default:
println "Unrecognized command: ${command}"
break } } }
protected void process(LinkedList args) {
def path = args.poll()
if (path) {
def givenDir = new File(path)
if (!(gtdDirs = findGtdRootDir(givenDir))) {
println "'$path' is not a valid directory."; return }}
// 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
while(!(line = readline())) print msg
return line }
// 1. Is it actionable?
if (!item.title) item.title = filenameToString(item.file)
response = prompt([">> $item", "Is it actionable?"]).toLowerCase()
// Not actionable
if (!(response ==~ /yes|y/)) {
response = prompt("Incubate or trash?").toLowerCase()
// Trash
if ("trash" =~ response) item.file.delete()
// Incubate
else {
println "Enter extra info. One 'key: value' pair per line."
println "(ex: date: YYYY-MM-DD, details)"
println "End with an empty line."
print "> "
while (response = readline()) {
if (!response =~ /[:=]/) continue
def parts = response.split(/[:=]/)
item[parts[0].trim().toLowerCase()] =
PropertyHelp.parse(parts[1].trim())
print "> " }
def oldFile = item.file
item.file = new File(gtdDirs.incubate, item.file.name)
item.save()
oldFile.delete() }
// Actionable
} else {
response = prompt("Will it take less than 2 minutes?").toLowerCase()
// 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(gtdDirs.done, "$date-${item.file.name}")
item.save()
oldFile.delete()
return }
// > 2 minutes
item.outcome = prompt("What is the desired outcome?")
println "Enter extra info. One 'key: value' pair per line."
println "(ex: date: YYYY-MM-DD, details)"
println "End with an empty line."
print "> "
while (response = readline()) {
if (!(response =~ /[:=]/)) continue
def parts = response.split(/[:=]/)
item[parts[0].trim().toLowerCase()] =
PropertyHelp.parse(parts[1].trim())
print "> " }
response = prompt("Too big for one action?").toLowerCase()
// Needs to be a project
if (response ==~ /yes|y/) {
def oldFile = item.file
item.file = new File(gtdDirs.projects,
stringToFilename(item.outcome))
item.save()
oldFile.delete()
println "Moved to projects." }
// Is a single action
else {
response = prompt("Delegate, defer, or tickler?").
toLowerCase()
// Delegate
if (response =~ /del/) {
item.action = prompt([
"Next action (who needs to do what).", ""])
def oldFile = item.file
item.file = new File(gtdDirs.waiting,
stringToFilename(item.action))
item.save()
oldFile.delete()
println "Moved to ${gtdDirs.waiting.name} folder." }
// Defer
else if (response =~ /def/) {
item.action = prompt(["Next action.", ""])
def oldFile = item.file
item.file = new File(gtdDirs["next-actions"],
stringToFilename(item.action))
item.save()
oldFile.delete()
println "Moved to the ${gtdDirs['next-actions'].name} folder."
}
// Tickle
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(gtdDirs.tickler,
stringToFilename(item.action))
item.save()
oldFile.delete()
println "Moved to the ${gtdDirs.tickler.name} folder." } } } } }
protected void done(LinkedList args) {
def selectedFilePath = args.poll()
def selectedFile = new File(selectedFilePath)
if (!selectedFile) {
println "gtd done command requires a <action-file> parameter."
return }
def item
if (selectedFile.isAbsolute()) item = new Item(selectedFile)
else item = new Item(new File(workingDir, selectedFilePath))
def itemMd5 = md5.digest(item.file.bytes)
// Move to the done folder.
def oldFile = item.file
def date = new DateMidnight().toString("YYYY-MM-dd")
item.file = new File(gtdDirs.done, "$date-${item.file.name}")
item.save()
// Check if this item was in a project folder.
if (inPath(gtdDirs.projects, oldFile)) {
// Delete any copies of this item in the next actions folder.
findAllCopies(oldFile, gtdDrs."next-actions").each { file ->
println "Deleting duplicate entry from the " +
"${file.parentFile.name} context."
file.delete() }
// Delete any copies of this item in the waiting folder.
findAllCopies(oldFile, gtdDirs.waiting).each { file ->
println "Deleting duplicate entry from the " +
"${file.parentFile.name} waiting context."
file.delete() }}
// Check if this item was in the next-action or waiting folder.
if (inPath(gtdDirs["next-actions"], oldFile) ||
inPath(gtdDirs.waiting, oldFile)) {
// Delete any copies of this item in the projects folder.
findAllCopies(oldFile, gtdDirs.projects).each { file ->
println "Deleting duplicate entry from the " +
"${file.parentFile.name} project."
file.delete() }}
// Delete the original
oldFile.delete()
println "'$item' marked as done." }
protected void calendar(LinkedList args) {
def itemsOnCalendar = []
def addCalendarItems = { file ->
if (!file.isFile()) return
def item = new Item(file)
if (item.date) itemsOnCalendar << item }
gtdDirs."next-actions".eachFileRecurse(addCalendarItems)
gtdDirs.waiting.eachFileRecurse(addCalendarItems)
gtdDirs.projects.eachFileRecurse(addCalendarItems)
itemsOnCalendar = itemsOnCalendar.unique { md5.digest(it.file.bytes) }.
sort { it.date }
if (!itemsOnCalendar) println "No items on the calendar."
def currentDate = null
itemsOnCalendar.each { item ->
def itemDay = new DateMidnight(item.date)
if (itemDay != currentDate) {
println itemDay.toString("EEE, MM/dd")
println "----------"
currentDate = itemDay }
println " $item" } }
protected void listCopies(LinkedList args) {
args.each { filePath ->
def file = new File(filePath)
if (!file.isAbsolute()) file = new File(workingDir, filePath)
if (!file.isFile()) {
println "${file.canonicalPath} is not a regular file."
return }
String originalRelativePath = getRelativePath(gtdDirs.root, file)
println "Copies of $originalRelativePath:"
println ""
findAllCopies(file, gtdDirs.root).each { copy ->
if (copy.canonicalPath != file.canonicalPath) {
String relativePath = getRelativePath(gtdDirs.root, copy)
println " $relativePath" }} }
args.clear() }
protected void printUsage(LinkedList args) {
if (!args) {
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 " help <command> Print detailed help about a command."
println " process Process inbox items systematically."
println " done <action-file> Mark an action as done. This will automatically "
println " take care of duplicates of the action in project "
println " or next-actions sub-folders."
} else {
def command = args.poll()
switch(command.toLowerCase()) {
case ~/process/: println """\
usage: gtd process
This is an interactive command.
GTD CLI goes through all the items in the "in" folder for this GTD repository
and guides you through the *process* step of the GTD method as follows:
Is the item actionable?
V
+---------------------------> No
| / \\
Yes Incubate Trash
| (Someday/Maybe)
V
Yes <--Too big for one action? --> No
| |
V |
Move to projects V
(still needs organization) What is the next action?
/
/
Defer, delegate, or tickler?
/ | \\
/ Move to the Set a date for this
Move to the waiting to become active again.
next-actions directory Move to the tickler
directory directory."""
break
case ~/done/: println """\
usage: gtd done <action-file>
Where <action-file> is expected to be the path (absolute or relative) to an
action item file. The action item file is expected to be in the *projects*
folder, the *next-actions* folder, the *waiting* folder, or a subfolder of one of
the aforementioned folders. The item is prepended with the current date and
moved to the *done* folder. If the item was in a project folder, the
*next-actions* and *waiting* folders are scanned recursively for duplicates of
the item, which are removed if found. Similarly, if the action was in a
*next-actions* or *waiting* folder the *projects* folder is scanned recursively
for duplicates.
The intention of the duplicate removal is to allow you to copy actions from
project folders into next action or waiting contexts, so you can keep a view of
the item organized by the project or in your next actions list. The GTD CLI tool
is smart enough to recognize that these are the same items filed in more than
one place and deal with them all in one fell swoop. Duplicates are determined by
exact file contents (MD5 has of the file contents)."""
break
}
}
}
protected List<File> findAllCopies(File original, File inDir) {
def copies = []
def originalMD5 = md5.digest(original.bytes)
inDir.eachFileRecurse { file ->
if (file.isFile() && md5.digest(file.bytes) == originalMD5)
copies << file }
return copies }
protected boolean inPath(File parent, File child) {
def parentPath = parent.canonicalPath.split("/")
def childPath = child.canonicalPath.split("/")
// If the parent path is longer than the child path it cannot contain
// the child path.
if (parentPath.length > childPath.length) return false;
// If the parent and child paths do not match at any point, the parent
// path does not contain the child path.
for (int i = 0; i < parentPath.length; i++)
if (childPath[i] != parentPath[i])
return false;
// The parent path is at least as long as the child path, and the child
// path matches the parent path (up until the end of the parent path).
// The child path either is the parent path or is contained by the
// parent path.
return true }
protected String getRelativePath(File parent, File child) {
def parentPath = parent.canonicalPath.split("/")
def childPath = child.canonicalPath.split("/")
if (parentPath.length > childPath.length) return ""
int b = 0
while (b < parentPath.length && parentPath[b] == childPath[b] ) b++;
if (b != parentPath.length) return ""
return (['.'] + childPath[b..<childPath.length]).join('/') }
protected Map findGtdRootDir(File givenDir) {
def gtdDirs = [:]
File currentDir = givenDir
while (currentDir != null) {
gtdDirs = ["in", "incubate", "done", "next-actions", "projects",
"tickler", "waiting"].
collectEntries { [it, new File(currentDir, it)] }
if (gtdDirs.values().every { dir -> dir.exists() && dir.isDirectory() }) {
gtdDirs.root = currentDir
return gtdDirs }
currentDir = currentDir.parentFile }
return [:] }
static String filenameToString(File f) {
return f.name.replaceAll(/[-_]/, " ").capitalize() }
static String stringToFilename(String s) {
return s.replaceAll(/\s/, '-').
replaceAll(/[';:(\.$)]/, '').
toLowerCase() }
}

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,79 @@
/**
* # 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.
* * `project`: the name of the project with which this item is associated.
* @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.canonicalFile
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

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

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/gtd</param-value>
</init-param>
</servlet>
<servlet-mapping>
<servlet-name>GTDServlet</servlet-name>
<url-pattern>/</url-pattern>
</servlet-mapping>
</web-app>