Compare commits
33 Commits
Author | SHA1 | Date | |
---|---|---|---|
|
64e70add18 | ||
|
7b861f2318 | ||
|
b1f2c9a875 | ||
|
4c5f514fb4 | ||
|
a780d972f1 | ||
|
43f0930cf2 | ||
|
f95dc91707 | ||
|
2c8180d9b2 | ||
|
12f87afe63 | ||
|
3496e21af5 | ||
|
40906eebf8 | ||
|
acaf58f456 | ||
|
5ac69157dc | ||
|
b4e01b6098 | ||
|
415c0e622f | ||
|
a9ba9d94f8 | ||
|
085b8d1d14 | ||
|
9fee96cb25 | ||
|
1e0a3b4063 | ||
|
0776889bc5 | ||
|
ab80b3a1b9 | ||
|
aee6e442ee | ||
|
8fe3ef015d | ||
|
58026c83ab | ||
|
a2f8b7b7a6 | ||
|
4339a7db2a | ||
|
1c4e526833 | ||
|
e893da72b6 | ||
|
62e62404c1 | ||
|
7a04d46853 | ||
|
f3c8f575b7 | ||
|
cabbdf7450 | ||
|
7f39ab7de1 |
1
.gitignore
vendored
1
.gitignore
vendored
@ -1,2 +1,3 @@
|
||||
.gradle/
|
||||
*.sw?
|
||||
build/
|
||||
|
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.gradle
Normal file
27
build.gradle
Normal 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'
|
||||
}
|
43
build.xml
43
build.xml
@ -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>
|
@ -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>
|
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.
Binary file not shown.
Binary file not shown.
@ -1,7 +0,0 @@
|
||||
#Mon, 29 Apr 2013 11:30:52 -0500
|
||||
lib.local=true
|
||||
name=jdb-gtd
|
||||
version=0.3
|
||||
nailgun.classpath.dir=/home/jdbernard/programs/nailgun/classpath
|
||||
|
||||
build.number=6
|
39
src/etc/bash_completion.d/gtd
Normal file
39
src/etc/bash_completion.d/gtd
Normal 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
|
@ -1,451 +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.3"
|
||||
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
|
||||
default:
|
||||
parsedArgs.addFirst(command)
|
||||
process(parsedArgs)
|
||||
break } } }
|
||||
|
||||
protected void process(LinkedList args) {
|
||||
|
||||
def path = args.poll()
|
||||
if (path) {
|
||||
def givenDir = new File(path)
|
||||
if (!(gtdDirs = findGtdRootDir(givenPath))) {
|
||||
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.
|
||||
gtdDirs["next-actions"].eachFileRecurse({ file ->
|
||||
if (file.isFile() && md5.digest(file.bytes) == itemMd5) {
|
||||
println "Deleting duplicate entry from the " +
|
||||
"${file.parentFile.name} context."
|
||||
file.delete() }})
|
||||
|
||||
// Delete any copies of this item in the waiting folder.
|
||||
gtdDirs.waiting.eachFileRecurse({ file ->
|
||||
if (file.isFile() && md5.digest(file.bytes) == itemMd5) {
|
||||
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.
|
||||
gtdDirs.projects.eachFileRecurse({ file ->
|
||||
if (file.isFile() && md5.digest(file.bytes) == itemMd5) {
|
||||
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 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 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 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() }
|
||||
}
|
||||
|
@ -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() }
|
||||
}
|
79
src/main/groovy/com/jdblabs/gtd/Item.groovy
Normal file
79
src/main/groovy/com/jdblabs/gtd/Item.groovy
Normal 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() }
|
||||
}
|
141
src/main/groovy/com/jdblabs/gtd/PropertyHelp.groovy
Normal file
141
src/main/groovy/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/groovy/com/jdblabs/gtd/Util.groovy
Normal file
124
src/main/groovy/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.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 [:] }
|
||||
}
|
1134
src/main/groovy/com/jdblabs/gtd/cli/GTDCLI.groovy
Normal file
1134
src/main/groovy/com/jdblabs/gtd/cli/GTDCLI.groovy
Normal file
File diff suppressed because it is too large
Load Diff
416
src/main/groovy/com/jdblabs/gtd/servlet/GTDServlet.groovy
Normal file
416
src/main/groovy/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) }
|
||||
}
|
21
src/main/webapp/WEB-INF/web.xml
Normal file
21
src/main/webapp/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/gtd</param-value>
|
||||
</init-param>
|
||||
</servlet>
|
||||
|
||||
<servlet-mapping>
|
||||
<servlet-name>GTDServlet</servlet-name>
|
||||
<url-pattern>/</url-pattern>
|
||||
</servlet-mapping>
|
||||
|
||||
</web-app>
|
Loading…
x
Reference in New Issue
Block a user