Compare commits
17 Commits
Author | SHA1 | Date | |
---|---|---|---|
b4e01b6098 | |||
415c0e622f | |||
a9ba9d94f8 | |||
085b8d1d14 | |||
9fee96cb25 | |||
1e0a3b4063 | |||
0776889bc5 | |||
ab80b3a1b9 | |||
aee6e442ee | |||
8fe3ef015d | |||
58026c83ab | |||
a2f8b7b7a6 | |||
4339a7db2a | |||
1c4e526833 | |||
e893da72b6 | |||
62e62404c1 | |||
7a04d46853 |
69
README.md
Normal file
69
README.md
Normal 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.xml
27
build.xml
@ -40,4 +40,31 @@
|
|||||||
|
|
||||||
<exec executable="ng-start" os="Linux"/>
|
<exec executable="ng-start" os="Linux"/>
|
||||||
</target>
|
</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>
|
</project>
|
||||||
|
@ -1,7 +1,8 @@
|
|||||||
#Wed, 01 May 2013 09:43:41 -0500
|
#Wed, 30 Oct 2013 10:46:52 -0500
|
||||||
lib.local=true
|
lib.local=true
|
||||||
name=jdb-gtd
|
name=jdb-gtd
|
||||||
version=0.5
|
version=1.6
|
||||||
nailgun.classpath.dir=/home/jdbernard/programs/nailgun/classpath
|
nailgun.classpath.dir=/home/jdbernard/programs/nailgun/classpath
|
||||||
|
executable.jar=true
|
||||||
build.number=1
|
main.class=com.jdblabs.gtd.cli.GTDCLI
|
||||||
|
build.number=0
|
||||||
|
21
resources/WEB-INF/web.xml
Normal file
21
resources/WEB-INF/web.xml
Normal 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>
|
78
src/main/com/jdblabs/gtd/Item.groovy
Normal file
78
src/main/com/jdblabs/gtd/Item.groovy
Normal 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() }
|
||||||
|
}
|
141
src/main/com/jdblabs/gtd/PropertyHelp.groovy
Normal file
141
src/main/com/jdblabs/gtd/PropertyHelp.groovy
Normal 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() }
|
||||||
|
}
|
124
src/main/com/jdblabs/gtd/Util.groovy
Normal file
124
src/main/com/jdblabs/gtd/Util.groovy
Normal 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 [:] }
|
||||||
|
}
|
@ -1,5 +1,12 @@
|
|||||||
|
/**
|
||||||
|
* # GTDCLI
|
||||||
|
* @author Jonathan Bernard (jdb@jdb-labs.com)
|
||||||
|
* @copyright 2013 [JDB Labs LLC](http://jdb-labs.com)
|
||||||
|
*/
|
||||||
package com.jdblabs.gtd.cli
|
package com.jdblabs.gtd.cli
|
||||||
|
|
||||||
|
import com.jdblabs.gtd.Item
|
||||||
|
import com.jdblabs.gtd.PropertyHelp
|
||||||
import com.jdbernard.util.LightOptionParser
|
import com.jdbernard.util.LightOptionParser
|
||||||
import com.martiansoftware.nailgun.NGContext
|
import com.martiansoftware.nailgun.NGContext
|
||||||
import java.security.MessageDigest
|
import java.security.MessageDigest
|
||||||
@ -8,67 +15,112 @@ import org.joda.time.DateTime
|
|||||||
//import org.slf4j.Logger
|
//import org.slf4j.Logger
|
||||||
//import org.slf4j.LoggerFactory
|
//import org.slf4j.LoggerFactory
|
||||||
|
|
||||||
|
import static com.jdblabs.gtd.Util.*
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Command-line helper for working with this implementation of the Getting
|
||||||
|
* Things Done method.
|
||||||
|
* @org gtd.jdb-labs.com/cli/GTDCLI */
|
||||||
public class GTDCLI {
|
public class GTDCLI {
|
||||||
|
|
||||||
public static final String VERSION = "0.5"
|
public static final String VERSION = "1.6"
|
||||||
private static String EOL = System.getProperty("line.separator")
|
private static String EOL = System.getProperty("line.separator")
|
||||||
|
|
||||||
|
/// We have a persistent instance when we are in the context of a Nailgun
|
||||||
|
/// setup.
|
||||||
private static GTDCLI nailgunInst
|
private static GTDCLI nailgunInst
|
||||||
|
|
||||||
private MessageDigest md5 = MessageDigest.getInstance("MD5")
|
/// Used to wrap lines intelligently.
|
||||||
private int terminalWidth
|
private int terminalWidth
|
||||||
|
|
||||||
private Scanner stdin
|
private Scanner stdin
|
||||||
private File workingDir
|
private File workingDir
|
||||||
|
|
||||||
|
/// The [GTD Root Directory map][root-map] for our repository.
|
||||||
|
///
|
||||||
|
/// [root-map]: jlp://gtd.jdb-labs.com/notes/root-directory-map
|
||||||
private Map<String, File> gtdDirs
|
private Map<String, File> gtdDirs
|
||||||
|
|
||||||
//private Logger log = LoggerFactory.getLogger(getClass())
|
//private Logger log = LoggerFactory.getLogger(getClass())
|
||||||
|
|
||||||
|
/** #### `main`
|
||||||
|
* Main entry point for a normal GTD CLI process. */
|
||||||
public static void main(String[] args) {
|
public static void main(String[] args) {
|
||||||
|
/// Instantiate our GTDCLI instance using the configuration file at
|
||||||
|
/// `$HOME/.gtdclirc`.
|
||||||
GTDCLI inst = new GTDCLI(new File(System.getProperty("user.home"),
|
GTDCLI inst = new GTDCLI(new File(System.getProperty("user.home"),
|
||||||
".gtdclirc"))
|
".gtdclirc"))
|
||||||
|
|
||||||
|
/// Actual processing is done by the
|
||||||
|
/// [`run`](jlp://gtd.jdb-labs.com/cli/GTDCLI/run) method
|
||||||
if (args.length > 0) args[-1] = args[-1].trim()
|
if (args.length > 0) args[-1] = args[-1].trim()
|
||||||
inst.run(args) }
|
inst.run(args) }
|
||||||
|
|
||||||
|
/** #### `nailMain`
|
||||||
|
* Entry point for a GTD CLI process under [Nailgun][ng].
|
||||||
|
* [ng]: http://www.martiansoftware.com/nailgun/ */
|
||||||
public static void nailMain(NGContext context) {
|
public static void nailMain(NGContext context) {
|
||||||
if (nailgunInst == null)
|
if (nailgunInst == null)
|
||||||
nailgunInst = new GTDCLI(new File(
|
nailgunInst = new GTDCLI(new File(
|
||||||
System.getProperty("user.home"), ".gtdclirc"))
|
System.getProperty("user.home"), ".gtdclirc"))
|
||||||
else nailgunInst.stdin = new Scanner(context.in)
|
else nailgunInst.stdin = new Scanner(context.in)
|
||||||
|
|
||||||
// trim the last argument, not all cli's are well-behaved
|
/// Trim the last argument; not all cli's are well-behaved
|
||||||
if (context.args.length > 0) context.args[-1] = context.args[-1].trim()
|
if (context.args.length > 0) context.args[-1] = context.args[-1].trim()
|
||||||
|
|
||||||
nailgunInst.run(context.args) }
|
nailgunInst.run(context.args) }
|
||||||
|
|
||||||
|
/** #### `reconfigure`
|
||||||
|
* This method reloads the configuration before invoking the run function,
|
||||||
|
* allowing a long-lived instance to react to configuration changes. */
|
||||||
public static void reconfigure(String[] args) {
|
public static void reconfigure(String[] args) {
|
||||||
|
/// If we do not have a long-running Nailgun instance we just call
|
||||||
|
/// main.
|
||||||
if (nailgunInst == null) main(args)
|
if (nailgunInst == null) main(args)
|
||||||
else {
|
else {
|
||||||
|
/// Discard our old instance and instantiate a new one in order to
|
||||||
|
/// read afresh the configuration file.
|
||||||
nailgunInst = null
|
nailgunInst = null
|
||||||
nailgunInst = new GTDCLI(new File(
|
nailgunInst = new GTDCLI(new File(
|
||||||
System.getProperty("user.home"), ".gritterrc"))
|
System.getProperty("user.home"), ".gritterrc"))
|
||||||
|
|
||||||
nailgunInst.run(args) } }
|
nailgunInst.run(args) } }
|
||||||
|
|
||||||
|
/** #### `constructor`
|
||||||
|
* Create a new GTDCLI instance, using the given configuration file. */
|
||||||
public GTDCLI(File configFile) {
|
public GTDCLI(File configFile) {
|
||||||
|
|
||||||
// parse the config file
|
/// Parse the groovy config file
|
||||||
def config = [:]
|
def config = [:]
|
||||||
if (configFile.exists())
|
if (configFile.exists())
|
||||||
config = new ConfigSlurper().parse(configFile.toURL())
|
config = new ConfigSlurper().parse(configFile.toURL())
|
||||||
|
|
||||||
// configure the terminal width
|
/// Configure the terminal width
|
||||||
terminalWidth = (System.getenv().COLUMNS ?: config.terminalWidth ?: 79) as int
|
terminalWidth = (System.getenv().COLUMNS ?: config.terminalWidth ?: 79) as int
|
||||||
|
|
||||||
|
/// Configure our default working directory.
|
||||||
workingDir = config.defaultDirectory ?
|
workingDir = config.defaultDirectory ?
|
||||||
new File(config.defaultDirectory) :
|
new File(config.defaultDirectory) :
|
||||||
new File('.')
|
new File('.')
|
||||||
|
|
||||||
stdin = new Scanner(System.in) }
|
stdin = new Scanner(System.in) }
|
||||||
|
|
||||||
|
/** #### `run`
|
||||||
|
* This method does the work of processing the user input and taking the
|
||||||
|
* appropriate action.
|
||||||
|
* @org gtd.jdb-labs.com/cli/GTDCLI/run */
|
||||||
protected void run(String[] args) {
|
protected void run(String[] args) {
|
||||||
|
|
||||||
|
/// Simple CLI options:
|
||||||
def cliDefinition = [
|
def cliDefinition = [
|
||||||
|
/// -h, --help
|
||||||
|
/// : Show the usage information.
|
||||||
h: [longName: 'help'],
|
h: [longName: 'help'],
|
||||||
|
/// -d, --directory
|
||||||
|
/// : Set the working directory for the CLI.
|
||||||
d: [longName: 'directory', arguments: 1],
|
d: [longName: 'directory', arguments: 1],
|
||||||
|
/// -v, --version
|
||||||
|
/// : Print version information.
|
||||||
v: [longName: 'version']]
|
v: [longName: 'version']]
|
||||||
|
|
||||||
def opts = LightOptionParser.parseOptions(cliDefinition, args as List)
|
def opts = LightOptionParser.parseOptions(cliDefinition, args as List)
|
||||||
@ -77,10 +129,17 @@ public class GTDCLI {
|
|||||||
if (opts.v) { println "GTD CLI v$VERSION"; return }
|
if (opts.v) { println "GTD CLI v$VERSION"; return }
|
||||||
if (opts.d) workingDir = new File(opts.d)
|
if (opts.d) workingDir = new File(opts.d)
|
||||||
|
|
||||||
|
/// View the arguments as a [`LinkedList`][1] so we can use [`peek`][2]
|
||||||
|
/// and [`poll`][3].
|
||||||
|
///
|
||||||
|
/// [1]: http://docs.oracle.com/javase/6/docs/api/java/util/LinkedList.html
|
||||||
|
/// [2]: http://docs.oracle.com/javase/6/docs/api/java/util/LinkedList.html#peek()
|
||||||
|
/// [3]: http://docs.oracle.com/javase/6/docs/api/java/util/LinkedList.html#poll()
|
||||||
def parsedArgs = (opts.args as List) as LinkedList
|
def parsedArgs = (opts.args as List) as LinkedList
|
||||||
|
|
||||||
if (parsedArgs.size() < 1) printUsage()
|
if (parsedArgs.size() < 1) printUsage()
|
||||||
|
|
||||||
|
/// Make sure we are in a GTD directory.
|
||||||
gtdDirs = findGtdRootDir(workingDir)
|
gtdDirs = findGtdRootDir(workingDir)
|
||||||
if (!gtdDirs) {
|
if (!gtdDirs) {
|
||||||
println "fatal: '${workingDir.canonicalPath}'"
|
println "fatal: '${workingDir.canonicalPath}'"
|
||||||
@ -88,18 +147,29 @@ public class GTDCLI {
|
|||||||
return }
|
return }
|
||||||
|
|
||||||
while (parsedArgs.peek()) {
|
while (parsedArgs.peek()) {
|
||||||
|
/// Pull off the first argument.
|
||||||
def command = parsedArgs.poll()
|
def command = parsedArgs.poll()
|
||||||
|
|
||||||
|
/// Match the first argument and invoke the proper command method.
|
||||||
switch (command.toLowerCase()) {
|
switch (command.toLowerCase()) {
|
||||||
case ~/help/: printUsage(parsedArgs); break
|
case ~/help/: printUsage(parsedArgs); break
|
||||||
case ~/done/: done(parsedArgs); break
|
case ~/done/: done(parsedArgs); break
|
||||||
case ~/cal|calendar/: calendar(parsedArgs); break
|
case ~/cal|calendar/: calendar(parsedArgs); break
|
||||||
case ~/process/: process(parsedArgs); break
|
case ~/process/: process(parsedArgs); break
|
||||||
case ~/list-copies/: listCopies(parsedArgs); break
|
case ~/list-copies/: listCopies(parsedArgs); break
|
||||||
|
case ~/new/: newAction(parsedArgs); break
|
||||||
|
case ~/tickler/: tickler(parsedArgs); break
|
||||||
|
case ~/ls|list/: ls(parsedArgs); break;
|
||||||
default:
|
default:
|
||||||
println "Unrecognized command: ${command}"
|
println "Unrecognized command: ${command}"
|
||||||
break } } }
|
break } } }
|
||||||
|
|
||||||
|
/** #### `process`
|
||||||
|
* Implement the *process* step of the GTD method. For details, see the
|
||||||
|
* [online help][help-process] included by running `gtd help process`
|
||||||
|
*
|
||||||
|
* [help-process]: jlp://gtd.jdb-labs.com/cli/GTDCLI/help/process
|
||||||
|
*/
|
||||||
protected void process(LinkedList args) {
|
protected void process(LinkedList args) {
|
||||||
|
|
||||||
def path = args.poll()
|
def path = args.poll()
|
||||||
@ -108,34 +178,26 @@ public class GTDCLI {
|
|||||||
if (!(gtdDirs = findGtdRootDir(givenDir))) {
|
if (!(gtdDirs = findGtdRootDir(givenDir))) {
|
||||||
println "'$path' is not a valid directory."; return }}
|
println "'$path' is not a valid directory."; return }}
|
||||||
|
|
||||||
// Start processing items
|
/// Start processing items
|
||||||
gtdDirs.in.listFiles().collect { new Item(it) }.each { item ->
|
gtdDirs.in.listFiles().collect { new Item(it) }.each { item ->
|
||||||
|
|
||||||
println ""
|
println ""
|
||||||
def response
|
def response
|
||||||
def readline = {stdin.nextLine().trim()}
|
def readline = {stdin.nextLine().trim()}
|
||||||
def prompt = { msg ->
|
def oldFile = item.file
|
||||||
if (msg instanceof List) msg = msg.join(EOL)
|
|
||||||
msg += "> "
|
|
||||||
print msg
|
|
||||||
def line
|
|
||||||
|
|
||||||
while(!(line = readline())) print msg
|
|
||||||
|
|
||||||
return line }
|
|
||||||
|
|
||||||
// 1. Is it actionable?
|
/// 1. Is it actionable?
|
||||||
if (!item.title) item.title = filenameToString(item.file)
|
if (!item.title) item.title = filenameToString(item.file)
|
||||||
response = prompt([">> $item", "Is it actionable?"]).toLowerCase()
|
response = prompt([">> $item", "Is it actionable?"]).toLowerCase()
|
||||||
|
|
||||||
// Not actionable
|
/// Not actionable, should we incubate this or trash it?
|
||||||
if (!(response ==~ /yes|y/)) {
|
if (!(response ==~ /yes|y/)) {
|
||||||
response = prompt("Incubate or trash?").toLowerCase()
|
response = prompt("Incubate or trash?").toLowerCase()
|
||||||
|
|
||||||
// Trash
|
/// Trash
|
||||||
if ("trash" =~ response) item.file.delete()
|
if ("trash" =~ response) item.file.delete()
|
||||||
|
|
||||||
// Incubate
|
/// Incubate
|
||||||
else {
|
else {
|
||||||
println "Enter extra info. One 'key: value' pair per line."
|
println "Enter extra info. One 'key: value' pair per line."
|
||||||
println "(ex: date: YYYY-MM-DD, details)"
|
println "(ex: date: YYYY-MM-DD, details)"
|
||||||
@ -148,28 +210,26 @@ public class GTDCLI {
|
|||||||
PropertyHelp.parse(parts[1].trim())
|
PropertyHelp.parse(parts[1].trim())
|
||||||
print "> " }
|
print "> " }
|
||||||
|
|
||||||
def oldFile = item.file
|
|
||||||
item.file = new File(gtdDirs.incubate, item.file.name)
|
item.file = new File(gtdDirs.incubate, item.file.name)
|
||||||
item.save()
|
item.save()
|
||||||
oldFile.delete() }
|
oldFile.delete() }
|
||||||
|
|
||||||
// Actionable
|
/// It is actionable. Can we do it now in less than 2 minutes?
|
||||||
} else {
|
} else {
|
||||||
response = prompt("Will it take less than 2 minutes?").toLowerCase()
|
response = prompt("Will it take less than 2 minutes?").toLowerCase()
|
||||||
|
|
||||||
// Do it now
|
/// Yes, so do it now.
|
||||||
if (response ==~ /yes|y/) {
|
if (response ==~ /yes|y/) {
|
||||||
println "Do it now."; print "> "
|
println "Do it now."; print "> "
|
||||||
readline();
|
readline();
|
||||||
|
|
||||||
def date = new DateMidnight().toString("YYYY-MM-dd")
|
def date = new DateMidnight().toString("YYYY-MM-dd")
|
||||||
def oldFile = item.file
|
|
||||||
item.file = new File(gtdDirs.done, "$date-${item.file.name}")
|
item.file = new File(gtdDirs.done, "$date-${item.file.name}")
|
||||||
item.save()
|
item.save()
|
||||||
oldFile.delete()
|
oldFile.delete()
|
||||||
return }
|
return }
|
||||||
|
|
||||||
// > 2 minutes
|
/// It will take more than 2 minutes. Track it in our system.
|
||||||
item.outcome = prompt("What is the desired outcome?")
|
item.outcome = prompt("What is the desired outcome?")
|
||||||
|
|
||||||
println "Enter extra info. One 'key: value' pair per line."
|
println "Enter extra info. One 'key: value' pair per line."
|
||||||
@ -184,127 +244,160 @@ public class GTDCLI {
|
|||||||
PropertyHelp.parse(parts[1].trim())
|
PropertyHelp.parse(parts[1].trim())
|
||||||
print "> " }
|
print "> " }
|
||||||
|
|
||||||
|
/// Does this need to be a separate project?
|
||||||
response = prompt("Too big for one action?").toLowerCase()
|
response = prompt("Too big for one action?").toLowerCase()
|
||||||
|
|
||||||
// Needs to be a project
|
/// Yes, this deserves it's own project folder.
|
||||||
if (response ==~ /yes|y/) {
|
if (response ==~ /yes|y/) {
|
||||||
def oldFile = item.file
|
|
||||||
item.file = new File(gtdDirs.projects,
|
item.file = new File(gtdDirs.projects,
|
||||||
stringToFilename(item.outcome))
|
stringToFilename(item.outcome))
|
||||||
item.save()
|
item.save()
|
||||||
oldFile.delete()
|
oldFile.delete()
|
||||||
println "Moved to projects." }
|
println "Moved to projects." }
|
||||||
|
|
||||||
// Is a single action
|
/// No, we can track this in one item. Is this something we
|
||||||
|
/// need someone else to do, should we defer it to our
|
||||||
|
/// next-actions list, or should we forget about it until a
|
||||||
|
/// future date?
|
||||||
else {
|
else {
|
||||||
response = prompt("Delegate, defer, or tickler?").
|
response = prompt("Delegate, defer, or tickler?").
|
||||||
toLowerCase()
|
toLowerCase()
|
||||||
|
|
||||||
// Delegate
|
/// Delegate, move to the *waiting* folder.
|
||||||
if (response =~ /del/) {
|
if (response =~ /del/) {
|
||||||
|
|
||||||
item.action = prompt([
|
item.action = prompt([
|
||||||
"Next action (who needs to do what).", ""])
|
"Next action (who needs to do what).", ""])
|
||||||
|
|
||||||
def oldFile = item.file
|
item.file = new File(promptContext(gtdDirs.waiting),
|
||||||
item.file = new File(gtdDirs.waiting,
|
stringToFilename(item.action)) }
|
||||||
stringToFilename(item.action))
|
|
||||||
item.save()
|
|
||||||
oldFile.delete()
|
|
||||||
|
|
||||||
println "Moved to ${gtdDirs.waiting.name} folder." }
|
|
||||||
|
|
||||||
|
|
||||||
// Defer
|
/// Defer, move to the *next-actions* folder.
|
||||||
else if (response =~ /def/) {
|
else if (response =~ /def/) {
|
||||||
item.action = prompt(["Next action.", ""])
|
item.action = prompt(["Next action.", ""])
|
||||||
|
|
||||||
def oldFile = item.file
|
item.file = new File(promptContext(gtdDirs["next-actions"]),
|
||||||
item.file = new File(gtdDirs["next-actions"],
|
stringToFilename(item.action)) }
|
||||||
stringToFilename(item.action))
|
|
||||||
item.save()
|
|
||||||
oldFile.delete()
|
|
||||||
|
|
||||||
println "Moved to the ${gtdDirs['next-actions'].name} folder."
|
/// Forget for now, move it to the *tickler* folder.
|
||||||
}
|
|
||||||
|
|
||||||
// Tickle
|
|
||||||
else {
|
else {
|
||||||
item.action = prompt(["Next action.", ""])
|
item.action = prompt(["Next action.", ""])
|
||||||
item.tickle = prompt([
|
item.tickle = prompt([
|
||||||
"When do you want it to become active?",
|
"When do you want it to become active?",
|
||||||
"(YYYY-MM-DD)"])
|
"(YYYY-MM-DD)"])
|
||||||
|
|
||||||
def oldFile = item.file
|
|
||||||
item.file = new File(gtdDirs.tickler,
|
item.file = new File(gtdDirs.tickler,
|
||||||
|
stringToFilename(item.action)) }
|
||||||
|
|
||||||
|
item.save()
|
||||||
|
oldFile.delete()
|
||||||
|
|
||||||
|
println "Moved to " +
|
||||||
|
getRelativePath(gtdDirs.root, item.file.parentFile)
|
||||||
|
|
||||||
|
/// If we have a project property, and a corresponding
|
||||||
|
/// project folder exists, copy the item there.
|
||||||
|
def projectDir = new File(gtdDirs.projects,
|
||||||
|
item.project ?: '')
|
||||||
|
if (item.project && projectDir.exists() &&
|
||||||
|
projectDir.isDirectory()) {
|
||||||
|
item.file = new File(projectDir,
|
||||||
stringToFilename(item.action))
|
stringToFilename(item.action))
|
||||||
item.save()
|
item.save()
|
||||||
oldFile.delete()
|
println "Copied to " +
|
||||||
println "Moved to the ${gtdDirs.tickler.name} folder." } } } } }
|
getRelativePath(gtdDirs.root, item.file.parentFile) } } } } }
|
||||||
|
|
||||||
|
/** #### `done`
|
||||||
|
* Implement the `done` command to mark items as completed. For detailed
|
||||||
|
* information see the [online help][help-done] by running
|
||||||
|
* `gtd help done`.
|
||||||
|
*
|
||||||
|
* [help-done]: jlp://gtd.jdb-labs.com/cli/GTDCLI/help/done
|
||||||
|
*/
|
||||||
protected void done(LinkedList args) {
|
protected void done(LinkedList args) {
|
||||||
|
|
||||||
def selectedFilePath = args.poll()
|
def selectedFilePath = args.poll()
|
||||||
def selectedFile = new File(selectedFilePath)
|
|
||||||
|
|
||||||
if (!selectedFile) {
|
if (!selectedFilePath) {
|
||||||
println "gtd done command requires a <action-file> parameter."
|
println "gtd done command requires a <action-file> parameter."
|
||||||
return }
|
return }
|
||||||
|
|
||||||
def item
|
while (selectedFilePath) {
|
||||||
if (selectedFile.isAbsolute()) item = new Item(selectedFile)
|
def item
|
||||||
else item = new Item(new File(workingDir, selectedFilePath))
|
def selectedFile = new File(selectedFilePath)
|
||||||
|
|
||||||
def itemMd5 = md5.digest(item.file.bytes)
|
if (!selectedFile.exists() || !selectedFile.isFile()) {
|
||||||
|
println "File does not exist or is a directory:"
|
||||||
|
println "\t" + selectedFile.canonicalPath
|
||||||
|
continue }
|
||||||
|
|
||||||
// Move to the done folder.
|
if (selectedFile.isAbsolute()) item = new Item(selectedFile)
|
||||||
def oldFile = item.file
|
else item = new Item(new File(workingDir, selectedFilePath))
|
||||||
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.
|
/// Move to the done folder.
|
||||||
if (inPath(gtdDirs.projects, oldFile)) {
|
def oldFile = item.file
|
||||||
|
def date = new DateMidnight().toString("YYYY-MM-dd")
|
||||||
|
item.file = new File(gtdDirs.done, "$date-${item.file.name}")
|
||||||
|
item.save()
|
||||||
|
|
||||||
// Delete any copies of this item in the next actions folder.
|
/// Check if this item was in a project folder.
|
||||||
findAllCopies(oldFile, gtdDrs."next-actions").each { file ->
|
if (inPath(gtdDirs.projects, oldFile)) {
|
||||||
println "Deleting duplicate entry from the " +
|
|
||||||
"${file.parentFile.name} context."
|
|
||||||
file.delete() }
|
|
||||||
|
|
||||||
// Delete any copies of this item in the waiting folder.
|
/// Delete any copies of this item from the next actions folder.
|
||||||
findAllCopies(oldFile, gtdDirs.waiting).each { file ->
|
findAllCopies(oldFile, gtdDirs."next-actions").each { file ->
|
||||||
println "Deleting duplicate entry from the " +
|
println "Deleting duplicate entry from the " +
|
||||||
"${file.parentFile.name} waiting context."
|
"${file.parentFile.name} context."
|
||||||
file.delete() }}
|
file.delete() }
|
||||||
|
|
||||||
// Check if this item was in the next-action or waiting folder.
|
/// Delete any copies of this item from the waiting folder.
|
||||||
if (inPath(gtdDirs["next-actions"], oldFile) ||
|
findAllCopies(oldFile, gtdDirs.waiting).each { file ->
|
||||||
inPath(gtdDirs.waiting, oldFile)) {
|
println "Deleting duplicate entry from the " +
|
||||||
|
"${file.parentFile.name} waiting context."
|
||||||
|
file.delete() }}
|
||||||
|
|
||||||
// Delete any copies of this item in the projects folder.
|
/// Check if this item was in the next-action or waiting folder.
|
||||||
findAllCopies(oldFile, gtdDirs.projects).each { file ->
|
if (inPath(gtdDirs["next-actions"], oldFile) ||
|
||||||
println "Deleting duplicate entry from the " +
|
inPath(gtdDirs.waiting, oldFile)) {
|
||||||
"${file.parentFile.name} project."
|
|
||||||
file.delete() }}
|
|
||||||
|
|
||||||
// Delete the original
|
/// Delete any copies of this item from the projects folder.
|
||||||
oldFile.delete()
|
findAllCopies(oldFile, gtdDirs.projects).each { file ->
|
||||||
|
println "Deleting duplicate entry from the " +
|
||||||
|
"${file.parentFile.name} project."
|
||||||
|
file.delete() }}
|
||||||
|
|
||||||
println "'$item' marked as done." }
|
/// Delete the original
|
||||||
|
oldFile.delete()
|
||||||
|
|
||||||
|
selectedFilePath = args.poll()
|
||||||
|
println "'$item' marked as done." } }
|
||||||
|
|
||||||
|
/** #### `calendar`
|
||||||
|
* Implement the `calendar` command to show all the items which are
|
||||||
|
* scheduled on the calendar. For detailed information see the
|
||||||
|
* [online help][help-calendar] by running `gtd help calendar`.
|
||||||
|
*
|
||||||
|
* [help-calendar]: jlp://gtd.jdb-labs.com/cli/GTDCLI/help/calendar
|
||||||
|
*/
|
||||||
protected void calendar(LinkedList args) {
|
protected void calendar(LinkedList args) {
|
||||||
def itemsOnCalendar = []
|
def itemsOnCalendar = []
|
||||||
|
|
||||||
|
MessageDigest md5 = MessageDigest.getInstance("MD5")
|
||||||
|
|
||||||
|
/// Temporary helper function to add GTD item files that have the
|
||||||
|
/// `date` property defined.
|
||||||
def addCalendarItems = { file ->
|
def addCalendarItems = { file ->
|
||||||
if (!file.isFile()) return
|
if (!file.isFile()) return
|
||||||
def item = new Item(file)
|
def item = new Item(file)
|
||||||
if (item.date) itemsOnCalendar << item }
|
if (item.date) itemsOnCalendar << item }
|
||||||
|
|
||||||
|
/// Look through each of the `next-actions`, `waiting`, and `projects`
|
||||||
|
/// folders for items which should be on the calendar
|
||||||
gtdDirs."next-actions".eachFileRecurse(addCalendarItems)
|
gtdDirs."next-actions".eachFileRecurse(addCalendarItems)
|
||||||
gtdDirs.waiting.eachFileRecurse(addCalendarItems)
|
gtdDirs.waiting.eachFileRecurse(addCalendarItems)
|
||||||
gtdDirs.projects.eachFileRecurse(addCalendarItems)
|
gtdDirs.projects.eachFileRecurse(addCalendarItems)
|
||||||
|
|
||||||
|
/// De-duplicate the list.
|
||||||
itemsOnCalendar = itemsOnCalendar.unique { md5.digest(it.file.bytes) }.
|
itemsOnCalendar = itemsOnCalendar.unique { md5.digest(it.file.bytes) }.
|
||||||
sort { it.date }
|
sort { it.date }
|
||||||
|
|
||||||
@ -312,6 +405,7 @@ public class GTDCLI {
|
|||||||
|
|
||||||
def currentDate = null
|
def currentDate = null
|
||||||
|
|
||||||
|
/// Print each day of items.
|
||||||
itemsOnCalendar.each { item ->
|
itemsOnCalendar.each { item ->
|
||||||
def itemDay = new DateMidnight(item.date)
|
def itemDay = new DateMidnight(item.date)
|
||||||
if (itemDay != currentDate) {
|
if (itemDay != currentDate) {
|
||||||
@ -322,9 +416,17 @@ public class GTDCLI {
|
|||||||
|
|
||||||
println " $item" } }
|
println " $item" } }
|
||||||
|
|
||||||
|
/** #### `listCopies`
|
||||||
|
* Implement the `list-copies` command to show all the copies of a given
|
||||||
|
* item in the repository. For detailed information see the
|
||||||
|
* [online help][help-list-copies] by running `gtd help list-copies`.
|
||||||
|
*
|
||||||
|
* [help-list-copies]: jlp://gtd.jdb-labs.com/cli/GTDCLI/help/list-copies
|
||||||
|
*/
|
||||||
protected void listCopies(LinkedList args) {
|
protected void listCopies(LinkedList args) {
|
||||||
|
|
||||||
args.each { filePath ->
|
args.each { filePath ->
|
||||||
|
/// First find the file they have named.
|
||||||
def file = new File(filePath)
|
def file = new File(filePath)
|
||||||
|
|
||||||
if (!file.isAbsolute()) file = new File(workingDir, filePath)
|
if (!file.isAbsolute()) file = new File(workingDir, filePath)
|
||||||
@ -337,6 +439,9 @@ public class GTDCLI {
|
|||||||
println "Copies of $originalRelativePath:"
|
println "Copies of $originalRelativePath:"
|
||||||
println ""
|
println ""
|
||||||
|
|
||||||
|
/// Find all copies using [`Util.findAllCopies`][1] and print their
|
||||||
|
/// relative paths.
|
||||||
|
/// [1]: jlp://gtd.jdb-labs.com/Util/findAllCopies
|
||||||
findAllCopies(file, gtdDirs.root).each { copy ->
|
findAllCopies(file, gtdDirs.root).each { copy ->
|
||||||
if (copy.canonicalPath != file.canonicalPath) {
|
if (copy.canonicalPath != file.canonicalPath) {
|
||||||
String relativePath = getRelativePath(gtdDirs.root, copy)
|
String relativePath = getRelativePath(gtdDirs.root, copy)
|
||||||
@ -344,29 +449,159 @@ public class GTDCLI {
|
|||||||
|
|
||||||
args.clear() }
|
args.clear() }
|
||||||
|
|
||||||
|
/** #### `new`
|
||||||
|
* Implement the `new` command to create a new GTD item in the current
|
||||||
|
* directory. For detailed information see the [online help][help-new] by
|
||||||
|
* running `gtd help new`.
|
||||||
|
*
|
||||||
|
* [help-new]: jlp://gtd.jdb-labs.com/cli/GTDCLI/help/new
|
||||||
|
*/
|
||||||
|
protected void newAction(LinkedList args) {
|
||||||
|
|
||||||
|
/// Get the next action.
|
||||||
|
def response = prompt(["Next action?", ""])
|
||||||
|
def file = new File(workingDir, stringToFilename(response))
|
||||||
|
file.createNewFile()
|
||||||
|
def item = new Item(file)
|
||||||
|
|
||||||
|
item.action = response
|
||||||
|
|
||||||
|
println "Enter extra info. One 'key: value' pair per line."
|
||||||
|
println "(ex: date: YYYY-MM-DD, project=my-project)"
|
||||||
|
println "End with an empty line."
|
||||||
|
print "> "
|
||||||
|
|
||||||
|
/// Read in item properties.
|
||||||
|
while (response = stdin.nextLine().trim()) {
|
||||||
|
/// Skip lines that do not contain either `:` or `=` (the key-value
|
||||||
|
/// delimiters).
|
||||||
|
if (!(response =~ /[:=]/)) continue
|
||||||
|
|
||||||
|
/// Split the line into key and value and add this property to the
|
||||||
|
/// item.
|
||||||
|
def parts = response.split(/[:=]/)
|
||||||
|
item[parts[0].trim().toLowerCase()] =
|
||||||
|
PropertyHelp.parse(parts[1].trim())
|
||||||
|
print "> " }
|
||||||
|
|
||||||
|
item.save()
|
||||||
|
|
||||||
|
/// If we have a project property, and a corresponding project folder
|
||||||
|
/// exists, copy the item there.
|
||||||
|
def projectDir = new File(gtdDirs.projects, item.project ?: '')
|
||||||
|
if (item.project && projectDir.exists() && projectDir.isDirectory()) {
|
||||||
|
item.file = new File(projectDir, stringToFilename(item.action))
|
||||||
|
item.save()
|
||||||
|
println "Copied to " +
|
||||||
|
getRelativePath(gtdDirs.root, item.file.parentFile) } }
|
||||||
|
|
||||||
|
/** #### `tickler`
|
||||||
|
* Implement the `tickler` command to move items in the *tickler* folder to
|
||||||
|
* the *next-actions* folder if their time has come. For detailed
|
||||||
|
* information see the [online help][help-tickler] by running
|
||||||
|
* `gtd help tickler`.
|
||||||
|
*
|
||||||
|
* [help-tickler]: jlp://gtd.jdb-labs.com/cli/GTDCLI/help/tickler
|
||||||
|
*/
|
||||||
|
protected void tickler(LinkedList args) {
|
||||||
|
|
||||||
|
gtdDirs.tickler.eachFileRecurse { file ->
|
||||||
|
def item = new Item(file)
|
||||||
|
def today = new DateMidnight()
|
||||||
|
|
||||||
|
/// If the item is scheduled to be tickled today (or in the past)
|
||||||
|
/// then move it into the next-actions folder
|
||||||
|
if ((item.tickle as DateMidnight) <= today) {
|
||||||
|
println "Moving '${item.action}' out of the tickler."
|
||||||
|
def oldFile = item.file
|
||||||
|
item.file = new File(gtdDirs."next-actions",
|
||||||
|
stringToFilename(item.action))
|
||||||
|
item.gtdProperties.remove("tickle")
|
||||||
|
item.save()
|
||||||
|
oldFile.delete() }}}
|
||||||
|
|
||||||
|
/** #### `ls`
|
||||||
|
* Implement the `ls` command to pretty print all items in a context
|
||||||
|
* folder, a project folder, or the *next-action* folder. For detailed
|
||||||
|
* information see the [online help][help-ls] by running
|
||||||
|
* `gtd help ls`.
|
||||||
|
*
|
||||||
|
* [help-ls]: jlp://gtd.jdb-labs.com/cli/GTDCLI/help/ls
|
||||||
|
*/
|
||||||
|
protected void ls(LinkedList args) {
|
||||||
|
|
||||||
|
def target = args.poll()
|
||||||
|
|
||||||
|
/// Temporary helper function to print all the items in a given
|
||||||
|
/// directory.
|
||||||
|
def printItems = { dir ->
|
||||||
|
if (!dir.exists() || !dir.isDirectory()) return
|
||||||
|
println "-- ${getRelativePath(gtdDirs.root, dir)} --"
|
||||||
|
dir.eachFile { file ->
|
||||||
|
if (!file.exists() || !file.isFile() || file.isHidden() ||
|
||||||
|
file.name.startsWith('.'))
|
||||||
|
return
|
||||||
|
|
||||||
|
def item = new Item(file)
|
||||||
|
println item.action }
|
||||||
|
|
||||||
|
println "" }
|
||||||
|
|
||||||
|
/// If we have a named context or project, look for those items
|
||||||
|
/// specifically
|
||||||
|
if (target) {
|
||||||
|
|
||||||
|
printItems(new File(gtdDirs['next-actions'], target))
|
||||||
|
printItems(new File(gtdDirs.waiting, target))
|
||||||
|
printItems(new File(gtdDirs.projects, target)) }
|
||||||
|
|
||||||
|
/// Otherwise print all items in the *next-actions* and *waiting*
|
||||||
|
/// folders and all their subfolders.
|
||||||
|
else {
|
||||||
|
printItems(gtdDirs['next-actions'])
|
||||||
|
printItems(gtdDirs['waiting'])
|
||||||
|
gtdDirs['next-actions'].eachDir(printItems)
|
||||||
|
gtdDirs['waiting'].eachDir(printItems) } }
|
||||||
|
|
||||||
|
/** #### `help`
|
||||||
|
* Implement the `help` command which provides the online-help. Users can
|
||||||
|
* access the online help for a command by running `gtd help <command>`.*/
|
||||||
protected void printUsage(LinkedList args) {
|
protected void printUsage(LinkedList args) {
|
||||||
|
|
||||||
if (!args) {
|
if (!args) {
|
||||||
println "Jonathan Bernard's Getting Things Done CLI v$VERSION"
|
println """\
|
||||||
println "usage: gtd [option...] <command>..."
|
Jonathan Bernard's Getting Things Done CLI v$VERSION
|
||||||
println ""
|
usage: gtd [option...] <command>...
|
||||||
println "options are:"
|
|
||||||
println ""
|
options are:
|
||||||
println " -h, --help Print this usage information."
|
|
||||||
println " -d, --directory Set the GTD root directory."
|
-h, --help Print this usage information.
|
||||||
println " -v, --version Print the GTD CLI version."
|
-d, --directory Set the GTD root directory.
|
||||||
println ""
|
-v, --version Print the GTD CLI version.
|
||||||
println "top-leve commands:"
|
|
||||||
println ""
|
top-level commands:
|
||||||
println " help <command> Print detailed help about a command."
|
|
||||||
println " process Process inbox items systematically."
|
help <command> Print detailed help about a command.
|
||||||
println " done <action-file> Mark an action as done. This will automatically "
|
process Process inbox items systematically.
|
||||||
println " take care of duplicates of the action in project "
|
done <action-file> Mark an action as done. This will automatically
|
||||||
println " or next-actions sub-folders."
|
take care of duplicates of the action in project
|
||||||
|
or next-actions sub-folders.
|
||||||
|
calendar Show the tasks with specific days assigned to
|
||||||
|
them, sorted by date.
|
||||||
|
list-copies <action-file> Given an action item, list all the other places
|
||||||
|
there the same item is filed (cross-reference
|
||||||
|
with a project folder, for example).
|
||||||
|
new Interactively create a new action item in the
|
||||||
|
current folder.
|
||||||
|
tickler Search the tickler file for items that need to be
|
||||||
|
delivered and move them to the *next-actions*
|
||||||
|
folder."""
|
||||||
} else {
|
} else {
|
||||||
def command = args.poll()
|
def command = args.poll()
|
||||||
|
|
||||||
switch(command.toLowerCase()) {
|
switch(command.toLowerCase()) {
|
||||||
|
/// Online help for the `process` command.
|
||||||
|
/// @org gtd.jdb-labs.com/cli/GTDCLI/help/process
|
||||||
case ~/process/: println """\
|
case ~/process/: println """\
|
||||||
usage: gtd process
|
usage: gtd process
|
||||||
|
|
||||||
@ -397,6 +632,8 @@ and guides you through the *process* step of the GTD method as follows:
|
|||||||
directory directory."""
|
directory directory."""
|
||||||
break
|
break
|
||||||
|
|
||||||
|
/// Online help for the `done` command.
|
||||||
|
/// @org gtd.jdb-labs.com/cli/GTDCLI/help/done
|
||||||
case ~/done/: println """\
|
case ~/done/: println """\
|
||||||
usage: gtd done <action-file>
|
usage: gtd done <action-file>
|
||||||
|
|
||||||
@ -415,79 +652,112 @@ 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
|
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
|
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
|
one place and deal with them all in one fell swoop. Duplicates are determined by
|
||||||
exact file contents (MD5 has of the file contents)."""
|
exact file contents (MD5 hash of the file contents)."""
|
||||||
break
|
break
|
||||||
|
|
||||||
|
/// Online help for the `calendar` command.
|
||||||
|
/// @org gtd.jdb-labs.com/cli/GTDCLI/help/calendar
|
||||||
|
case ~/calendar/: println """\
|
||||||
|
usage: gtd calendar
|
||||||
|
|
||||||
|
Print an agenda of all the actions that are on the calendar, sorted by date.
|
||||||
|
This prints a date heading first, then all of the actions assogned to that day.
|
||||||
|
Remember that in the GTD calendar items are supposed to be hard dates, IE.
|
||||||
|
things that *must* be done on the assigned date."""
|
||||||
|
break
|
||||||
|
|
||||||
|
/// Online help for the `list-copies` command.
|
||||||
|
/// @org gtd.jdb-labs.com/cli/GTDCLI/help/list-copies
|
||||||
|
case ~/list-copies/: println """\
|
||||||
|
usage: gtd list-copies <action-file>
|
||||||
|
|
||||||
|
Where <action-file> is expected to be the path (absolute or relative) to an
|
||||||
|
action item file.
|
||||||
|
|
||||||
|
This command searched through the current GTD repository for any items that are
|
||||||
|
duplicates of this item."""
|
||||||
|
break
|
||||||
|
|
||||||
|
/// Online help for the `new` command.
|
||||||
|
/// @org gtd.jdb-labs.com/cli/GTDCLI/help/new
|
||||||
|
case ~/new/: println """\
|
||||||
|
usage: gtd new
|
||||||
|
|
||||||
|
This command is interactive (maybe allow it to take interactive prompts in the
|
||||||
|
future?). It prompts the user for the next action and any extended properties
|
||||||
|
that should be associated with it, then creates the action file in the current
|
||||||
|
directory."""
|
||||||
|
break
|
||||||
|
|
||||||
|
/// Online help for the `tickler` command.
|
||||||
|
/// @org gtd.jdb-labs.com/cli/GTDCLI/help/tickler
|
||||||
|
case ~/tickler/: println """\
|
||||||
|
usage: gtd tickler
|
||||||
|
|
||||||
|
This command should be scheduled for execution once a day. It checks the tickler
|
||||||
|
file for any items that should become active (based on their <tickle> property)
|
||||||
|
and moves them out of the tickler file and into the next-actions file."""
|
||||||
|
break
|
||||||
|
|
||||||
|
/// Online help for the `ls`/`list-context` command.
|
||||||
|
/// @org gtd.jdb-labs.com/cli/GTDCLI/help/ls
|
||||||
|
case ~/ls|list-context/: println """\
|
||||||
|
usage gtd ls [<context> ...]
|
||||||
|
|
||||||
|
This command lists all the tasks for a given context or project. The purpose is
|
||||||
|
to list in one place items that are sitting in the next-actions folder or the
|
||||||
|
waiting folder for a specific context or list items for a given project. If no
|
||||||
|
context or project is named, all contexts are listed."""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
protected List<File> findAllCopies(File original, File inDir) {
|
/** #### `prompt`
|
||||||
def copies = []
|
* Prompt the user for an answer to a question. This is a helper to loop
|
||||||
def originalMD5 = md5.digest(original.bytes)
|
* until the user has entered an actual response. */
|
||||||
|
protected String prompt(def msg) {
|
||||||
inDir.eachFileRecurse { file ->
|
if (msg instanceof List) msg = msg.join(EOL)
|
||||||
if (file.isFile() && md5.digest(file.bytes) == originalMD5)
|
msg += "> "
|
||||||
copies << file }
|
print msg
|
||||||
|
def line
|
||||||
|
|
||||||
return copies }
|
while(!(line = stdin.nextLine().trim())) print msg
|
||||||
|
|
||||||
|
return line }
|
||||||
|
|
||||||
protected boolean inPath(File parent, File child) {
|
/** #### `promptContext`
|
||||||
def parentPath = parent.canonicalPath.split("/")
|
* Prompt the user to choose a context (subdirectory of the given
|
||||||
def childPath = child.canonicalPath.split("/")
|
* directory). */
|
||||||
|
protected File promptContext(File baseDir) {
|
||||||
|
print "Context?> "
|
||||||
|
def line
|
||||||
|
def contextFile
|
||||||
|
|
||||||
// If the parent path is longer than the child path it cannot contain
|
line = stdin.nextLine().trim()
|
||||||
// the child path.
|
contextFile = line ? new File(baseDir, line) : baseDir
|
||||||
if (parentPath.length > childPath.length) return false;
|
|
||||||
|
|
||||||
// If the parent and child paths do not match at any point, the parent
|
while (!contextFile.exists() || !contextFile.isDirectory()) {
|
||||||
// path does not contain the child path.
|
println "Available contexts:"
|
||||||
for (int i = 0; i < parentPath.length; i++)
|
baseDir.eachDir { print "\t${it.name}"}
|
||||||
if (childPath[i] != parentPath[i])
|
println ""
|
||||||
return false;
|
|
||||||
|
|
||||||
// The parent path is at least as long as the child path, and the child
|
print "Context?> "
|
||||||
// path matches the parent path (up until the end of the parent path).
|
line = stdin.nextLine().trim()
|
||||||
// The child path either is the parent path or is contained by the
|
contextFile = line ? new File(baseDir, line) : baseDir }
|
||||||
// parent path.
|
|
||||||
return true }
|
return contextFile }
|
||||||
|
|
||||||
protected String getRelativePath(File parent, File child) {
|
/** #### `filenameToString`
|
||||||
def parentPath = parent.canonicalPath.split("/")
|
* The default pretty-print conversion for filenames. */
|
||||||
def childPath = child.canonicalPath.split("/")
|
public static String filenameToString(File f) {
|
||||||
|
|
||||||
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() }
|
return f.name.replaceAll(/[-_]/, " ").capitalize() }
|
||||||
|
|
||||||
static String stringToFilename(String s) {
|
/** #### `stringToFilename`
|
||||||
|
* Helper method to convert a user-entered string into something more
|
||||||
|
* palatable for a filename. */
|
||||||
|
public static String stringToFilename(String s) {
|
||||||
return s.replaceAll(/\s/, '-').
|
return s.replaceAll(/\s/, '-').
|
||||||
replaceAll(/[';:(\.$)]/, '').
|
replaceAll(/[';:(\.$\/)]/, '').
|
||||||
toLowerCase() }
|
toLowerCase() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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() }
|
|
||||||
}
|
|
@ -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() }
|
|
||||||
}
|
|
416
src/main/com/jdblabs/gtd/servlet/GTDServlet.groovy
Normal file
416
src/main/com/jdblabs/gtd/servlet/GTDServlet.groovy
Normal 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) }
|
||||||
|
}
|
Reference in New Issue
Block a user