diff --git a/build.xml b/build.xml
index 3679eda..047886e 100644
--- a/build.xml
+++ b/build.xml
@@ -40,4 +40,31 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/project.properties b/project.properties
index c05993a..fedd082 100644
--- a/project.properties
+++ b/project.properties
@@ -1,8 +1,8 @@
-#Mon, 05 Aug 2013 10:16:09 -0500
+#Sun, 22 Sep 2013 16:05:26 -0500
lib.local=true
name=jdb-gtd
-version=1.1
+version=1.2
nailgun.classpath.dir=/home/jdbernard/programs/nailgun/classpath
executable.jar=true
main.class=com.jdblabs.gtd.cli.GTDCLI
-build.number=2
+build.number=49
diff --git a/resources/WEB-INF/web.xml b/resources/WEB-INF/web.xml
new file mode 100644
index 0000000..2f34835
--- /dev/null
+++ b/resources/WEB-INF/web.xml
@@ -0,0 +1,21 @@
+
+
+
+ GTDServlet
+ com.jdblabs.gtd.servlet.GTDServlet
+
+
+ gtdRootDir
+ /home/jdbernard/Dropbox/gtd
+
+
+
+
+ GTDServlet
+ /
+
+
+
diff --git a/src/main/com/jdblabs/gtd/cli/Item.groovy b/src/main/com/jdblabs/gtd/Item.groovy
similarity index 97%
rename from src/main/com/jdblabs/gtd/cli/Item.groovy
rename to src/main/com/jdblabs/gtd/Item.groovy
index c0e4132..4d44569 100644
--- a/src/main/com/jdblabs/gtd/cli/Item.groovy
+++ b/src/main/com/jdblabs/gtd/Item.groovy
@@ -1,4 +1,4 @@
-package com.jdblabs.gtd.cli
+package com.jdblabs.gtd
public class Item {
diff --git a/src/main/com/jdblabs/gtd/cli/PropertyHelp.groovy b/src/main/com/jdblabs/gtd/PropertyHelp.groovy
similarity index 98%
rename from src/main/com/jdblabs/gtd/cli/PropertyHelp.groovy
rename to src/main/com/jdblabs/gtd/PropertyHelp.groovy
index d1a80d8..33e94f4 100644
--- a/src/main/com/jdblabs/gtd/cli/PropertyHelp.groovy
+++ b/src/main/com/jdblabs/gtd/PropertyHelp.groovy
@@ -1,4 +1,4 @@
-package com.jdblabs.gtd.cli
+package com.jdblabs.gtd
import org.joda.time.DateMidnight
import org.joda.time.DateTime
diff --git a/src/main/com/jdblabs/gtd/Util.groovy b/src/main/com/jdblabs/gtd/Util.groovy
new file mode 100644
index 0000000..9740636
--- /dev/null
+++ b/src/main/com/jdblabs/gtd/Util.groovy
@@ -0,0 +1,68 @@
+package com.jdblabs.gtd
+
+import java.security.MessageDigest
+
+public class Util {
+
+ public static List 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 }
+
+ 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 }
+
+ public static String getRelativePath(File parent, File child) {
+ def parentPath = parent.canonicalPath.split("/")
+ def childPath = child.canonicalPath.split("/")
+
+ if (parentPath.length > childPath.length) return ""
+
+ int b = 0
+ while (b < parentPath.length && parentPath[b] == childPath[b] ) b++;
+
+ if (b != parentPath.length) return ""
+ return (['.'] + childPath[b.. dir.exists() && dir.isDirectory() }) {
+ gtdDirs.root = currentDir
+ return gtdDirs }
+
+ currentDir = currentDir.parentFile }
+
+ return [:] }
+}
diff --git a/src/main/com/jdblabs/gtd/cli/GTDCLI.groovy b/src/main/com/jdblabs/gtd/cli/GTDCLI.groovy
index 34e733c..3d799bb 100644
--- a/src/main/com/jdblabs/gtd/cli/GTDCLI.groovy
+++ b/src/main/com/jdblabs/gtd/cli/GTDCLI.groovy
@@ -1,5 +1,7 @@
package com.jdblabs.gtd.cli
+import com.jdblabs.gtd.Item
+import com.jdblabs.gtd.PropertyHelp
import com.jdbernard.util.LightOptionParser
import com.martiansoftware.nailgun.NGContext
import java.security.MessageDigest
@@ -8,13 +10,14 @@ import org.joda.time.DateTime
//import org.slf4j.Logger
//import org.slf4j.LoggerFactory
+import static com.jdblabs.gtd.Util.*
+
public class GTDCLI {
- public static final String VERSION = "1.1"
+ public static final String VERSION = "1.2"
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
@@ -249,8 +252,6 @@ public class GTDCLI {
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")
@@ -290,6 +291,8 @@ public class GTDCLI {
protected void calendar(LinkedList args) {
def itemsOnCalendar = []
+ MessageDigest md5 = MessageDigest.getInstance("MD5")
+
def addCalendarItems = { file ->
if (!file.isFile()) return
def item = new Item(file)
@@ -540,66 +543,6 @@ context or project is named, all contexts are listed."""
}
}
- protected List findAllCopies(File original, File inDir) {
- def copies = []
- def originalMD5 = md5.digest(original.bytes)
-
- inDir.eachFileRecurse { file ->
- if (file.isFile() && md5.digest(file.bytes) == originalMD5)
- copies << file }
-
- return copies }
-
- protected boolean inPath(File parent, File child) {
- def parentPath = parent.canonicalPath.split("/")
- def childPath = child.canonicalPath.split("/")
-
- // If the parent path is longer than the child path it cannot contain
- // the child path.
- if (parentPath.length > childPath.length) return false;
-
- // If the parent and child paths do not match at any point, the parent
- // path does not contain the child path.
- for (int i = 0; i < parentPath.length; i++)
- if (childPath[i] != parentPath[i])
- return false;
-
- // The parent path is at least as long as the child path, and the child
- // path matches the parent path (up until the end of the parent path).
- // The child path either is the parent path or is contained by the
- // parent path.
- return true }
-
- protected static String getRelativePath(File parent, File child) {
- def parentPath = parent.canonicalPath.split("/")
- def childPath = child.canonicalPath.split("/")
-
- if (parentPath.length > childPath.length) return ""
-
- int b = 0
- while (b < parentPath.length && parentPath[b] == childPath[b] ) b++;
-
- if (b != parentPath.length) return ""
- return (['.'] + childPath[b.. dir.exists() && dir.isDirectory() }) {
- gtdDirs.root = currentDir
- return gtdDirs }
-
- currentDir = currentDir.parentFile }
-
- return [:] }
-
protected String prompt(def msg) {
if (msg instanceof List) msg = msg.join(EOL)
msg += "> "
@@ -610,13 +553,12 @@ context or project is named, all contexts are listed."""
return line }
- static String filenameToString(File f) {
+ public static String filenameToString(File f) {
return f.name.replaceAll(/[-_]/, " ").capitalize() }
- static String stringToFilename(String s) {
+ public static String stringToFilename(String s) {
return s.replaceAll(/\s/, '-').
replaceAll(/[';:(\.$)]/, '').
toLowerCase() }
-
}
diff --git a/src/main/com/jdblabs/gtd/servlet/GTDServlet.groovy b/src/main/com/jdblabs/gtd/servlet/GTDServlet.groovy
new file mode 100644
index 0000000..ae93cb4
--- /dev/null
+++ b/src/main/com/jdblabs/gtd/servlet/GTDServlet.groovy
@@ -0,0 +1,336 @@
+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.*
+
+public class GTDServlet extends HttpServlet {
+
+ protected Map gtdDirs
+ private SmartConfig config
+
+ private class TempRequestData {
+ public String username
+ public def defaultPermissions
+ }
+
+ void init(ServletConfig config) {
+ 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}).")
+
+ 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) }
+
+ void doOptions(HttpServletRequest request, HttpServletResponse response) {
+ response.addHeader("Access-Control-Allow-Origin",
+ request.getHeader("Origin") ?: "*")
+ response.addHeader("Access-Control-Allow-Credentials", "true")
+ response.status = SC_OK
+
+ 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 }
+ }
+
+ void doPost(HttpServletRequest request, HttpServletResponse response) {
+ response.addHeader("Content-Type", "application/json")
+ 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 posting to /gtd/login then let's try to authenticate
+ // them.
+ 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 ~/\/gtd\/contexts.*/:
+ case ~/\/gtd\/projects.*/:
+ response.status = SC_METHOD_NOT_ALLOWED
+ return
+ default:
+ response.status = SC_NOT_FOUND
+ return
+ }
+ }
+
+ void doGet(HttpServletRequest request, HttpServletResponse response) {
+
+ response.status = SC_OK
+ response.addHeader("Content-Type", "application/json")
+ 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 /gtd/logout then invalidate their session
+ // and return 200 OK
+ case "/logout":
+ session.removeAttribute("authenticated")
+ session.invalidate()
+ break
+
+ // If they are GET'ing /gtd/contexts then return the list of
+ // contexts that are readable by this user.
+ case "/contexts":
+
+ // Filter the directories to find the ones that the user has
+ // read access to.
+ def selectedContexts = findAllowedDirs("read", curData,
+ gtdDirs['next-actions'].listFiles())
+
+ def returnData = selectedContexts.collect { entry ->
+ [id: entry.dir.name, title: entry.props.title] }
+
+ // Now format our response as JSON and write it to the response
+ writeJSON(returnData, response)
+ break
+
+ // If they are GET'ing /gtd/contexts/ then 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
+
+ // If they are GET'ing /gtd/projects then return the list of
+ // projects that are readable for this user.
+ case "/projects":
+ // Filter the 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
+
+ // If they are GET'ing /gtd/projects/ then return the
+ // list of projects that are 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 }
+
+ def entry = filteredList[0]
+ def returnData = [id: entry.dir.name, title: entry.props.title]
+ writeJSON(returnData, response)
+ break
+
+ 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 GTD findAllCopies utility
+ // 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.
+ while (itemFiles.size() > 0) {
+ def item = itemFiles.remove(0)
+
+ // Find all duplicates.
+ 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()
+ }
+
+ 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 ->
+
+ println ">> Considering ${dir.canonicalPath}"
+
+ // 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..Permissions property.
+ 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 show
+ // this context. If this is not the case (tested in the
+ // following conditional) we do not show 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 read it.
+ return [[ dir: dir, props: itemProps ]] } }
+
+ protected void writeJSON(def data, def response) {
+ new JsonBuilder(data).writeTo(response.writer) }
+}