rtm-clj

0.1.0-SNAPSHOT


A command line interface for Remember the Milk.

dependencies

org.clojure/clojure
1.2.1
clj-http
0.1.3
swank-clojure/swank-clojure
1.3.2
clojure-contrib
1.2.0
clansi
1.0.0

dev dependencies

lein-marginalia
0.6.0



(this space intentionally left almost blank)
 
(ns rtm-clj.xml
  (:require [rtm-clj.utils :as utils]
   [clojure.xml :as xml]
            [clojure.zip :as zip]
            [clojure.contrib.zip-filter :as zf]
            [clojure.contrib.zip-filter.xml :as zfx])
  (:import [java.io ByteArrayInputStream]))

Convert the string to xml

The responses that come back from RTM are xml. This converts into an xml structure so that we can parse it.

(defn to-xml
  [s]
  (let [input-stream (ByteArrayInputStream. (.getBytes s))]
    (xml/parse input-stream)))

Simple function that parses the response xml string and returns the value of the tag

(defn parse-response
  [response tag-name]
  (when response
    (for [x (xml-seq (to-xml response)) :when (= tag-name (:tag x))]
      (first (:content x)))))

Takes a map m, zip location loc, an attribute name att, and more attributes more. Associates the attribute into the map using the attribute from the xml as the value.

Quick way to convert xml structure to a flatter map structure.

(defn- assoc-attributes
  [m loc att & more]
  (let [new-m (assoc m att (zfx/xml1-> loc (zfx/attr att)))]
    (if (seq more)
      (recur new-m loc (first more) (rest more))
      new-m)))
(defn- create-note-map
  [loc]
  (assoc (assoc-attributes {} loc :id :created :modified :title) :text (zfx/text loc)))

Assocs the notes into the map.

(defn- extract-notes
  [m task-series-loc]
  (let [notes (map create-note-map (zfx/xml-> task-series-loc :notes :note))]
    (assoc m :notes notes)))
(defn- create-tag-map
  [loc]
  (zfx/text loc))
(defn- extract-tags
  [m task-series-loc]
  (let [tags (map create-tag-map (zfx/xml-> task-series-loc :tags :tag))]
    (assoc m :tags tags)))

Creates a flat map of the key attributes from the xml, representing a task.

(defn- create-task-map
  [task-loc]
  (let [task-series-loc (zip/up task-loc)]
    ;; create task-series-id to avoid clash with task id, that way can have flat map
    (let [task-map
          (-> (assoc-attributes {:task-series-id (zfx/xml1-> task-series-loc (zfx/attr :id))}
                                task-series-loc :created :modified :name :source :url :location_id)
              (assoc-attributes task-loc :id :due :has_due_time :added :completed
                                :deleted :priority :postponed :estimate)
              (extract-notes task-series-loc)
              (extract-tags task-series-loc)
              (assoc :list-id (zfx/xml1-> (zip/up task-series-loc) (zfx/attr :id))))]
      (utils/debug (str "task-map: " task-map))
      task-map)))

Creates a flat map of the key task series data. Path specifies the zip-filter path to the task tag

(defn parse-task-series-response
  ([xml]
     (parse-task-series-response xml :tasks :list :taskseries :task))
  ([xml & path]
     (utils/debug (str "xml: " xml))
     (let [zipped (zip/xml-zip (to-xml xml))]
       (map create-task-map (apply zfx/xml-> zipped path)))))
(defn parse-add-task-response
  [xml]
  (parse-task-series-response xml :list :taskseries :task))

example of a task-series-response

(comment
  {:notes ({:title "http://www.rememberthemilk.com/services/api/", :modified "2011-06-04T05:07:52Z", :created "2011-06-04T05:07:52Z", :id "22967071"}), :list-id "11361634", :estimate "", :name "Remember The Milk - Services / API", :postponed "0", :has_due_time "0", :location_id "", :added "2011-06-04T05:07:51Z", :task-series-id "119659454", :url "", :created "2011-06-04T05:07:51Z", :completed "", :modified "2011-07-03T10:58:35Z", :due "", :source "email", :id "183302555", :deleted "", :priority "1"})

Returns a map of the error code or nil if there was no error

(defn parse-error
  [xml]
  (let [zipped (zip/xml-zip (to-xml xml))
        stat (zfx/xml1-> zipped (zfx/attr :stat))]
    (if (= "fail" stat)
      (let [err-loc (zfx/xml1-> zipped :err)]
        (assoc-attributes {} err-loc :code :msg))
      nil)))

Returns a map with the details for the undo, or nil if not undoable.

(defn parse-undoable
  [xml timeline]
  (if-let [transaction (zfx/xml1-> (zip/xml-zip (to-xml xml)) :transaction)]
    (let [undoable (zfx/xml1-> transaction (zfx/attr :undoable))]
      (if (= "1" undoable)
        {:transaction-id (zfx/xml1-> transaction (zfx/attr :id)),
         :timeline timeline}
        nil))))
 

This contains the functions that actually call the Remember the Milk API. The API is REST based, so it uses clj-http.

(ns rtm-clj.api
  (:require
   [rtm-clj.utils :as utils]
   [rtm-clj.xml :as xml]
   [clj-http.client :as http]
   [clojure.string :as str])
  (:import
   [java.security MessageDigest]
   [java.net URLEncoder]))

Constants

(def *api-url* "http://api.rememberthemilk.com/services/rest/?")
(def *auth-url-base* "http://www.rememberthemilk.com/services/auth/?")

URL Building

Accepts a map, and converts it into key value pairs for the url

(defn- build-params
  ([request-params]
     (build-params request-params "=" "&" true))
  ([request-params key-val-separator param-separator encode?]
     (let [encode (if (true? encode?) #(URLEncoder/encode (str %) "UTF-8") #(str %))
           coded (for [[n v] request-params] (str (encode n) key-val-separator (encode v)))]
       (apply str (interpose param-separator coded)))))
(defn- md5sum
  [s]
  (format "%1$032x" (BigInteger. 1 (.digest (MessageDigest/getInstance "MD5") (.getBytes s)))))

Building up the vocabulary...

(defn shared-secret-set?
  [state]
  (not (empty? (:shared-secret state))))
(defn api-key-set?
  [state]
  (not (empty? (:api-key state))))

See RTM Authentication documentation.

(defn- sign-params
  "This does the signing that RTM api requires"
  [state param-map]
  (if (shared-secret-set? state)
    (let [sorted-map (sort param-map)
          sig-string (str (:shared-secret state) (build-params sorted-map   false))]
      (utils/debug (str "Signature string: " sig-string))
      (md5sum sig-string))))

Builds the url, with the api and signature parameters correctly applied.

(defn build-rtm-url
  "Builds the url to hit the rest service"
  ([state param-map]
     (build-rtm-url state param-map *api-url*))
  ([state param-map base-url]
     (utils/debug (str "Building url: " param-map base-url))
     (let [all-params (assoc param-map "api_key" (:api-key state))
           api-sig (sign-params state all-params)
           url (str base-url (build-params all-params) "&api_sig=" api-sig)]
       (utils/debug url)
       url)))

This should probably work differently. Doesn't really make sense for a general api call, only in context of the command line.

(defn- check-for-error
  "A bit ugly, but it at least logs an error if the api call failed."
  [response-xml]
  (if-let [error (xml/parse-error response-xml)]
    (do
      (println (str "Error: " (:msg error)))
      nil)
    response-xml))

Abstracts out the the REST call. method is the name of the RTM method. The param-map contains the key/value pairs for the http request params.

(defn- call-api
  [state method param-map]
  (if (and (shared-secret-set? state) (api-key-set? state))
    (let [param-map-with-method (assoc param-map "method" method)
          url (str (build-rtm-url state param-map-with-method))]
      (check-for-error (:body (http/get url))))
    (println "Shared secret and / or api key not set")))
(defn- call-api-with-token
  ([state method]
     (call-api-with-token state method {}))
  ([state method param-map]
     (call-api state method (assoc param-map "auth_token" (:token state)))))

The Actual RTM Methods

These are the top-level api functions, corresponding (mostly) to the methods defined here

The echo method just echos back the request params. Good for testing basic connectivity. Returns the full response map from clj-http.

(defn rtm-test-echo
  "Calls the rtm.test.echo method with the specified params"
  ([state]
     (rtm-test-echo state {"dummy" "value"}))
  ([state param-map]
     (call-api state "rtm.test.echo" param-map)))

Returns a frob which is required in the call to authenticate the user.

(defn rtm-auth-getFrob
  "Calls the rtm.auth.getFrob method"
  [state]
  (call-api state "rtm.auth.getFrob" {}))

Puts in the call to the api for an auth token, which will be available if the user has authorized access

(defn rtm-auth-getToken
  [state frob]
  (call-api state "rtm.auth.getToken" {"frob" frob}))

If the token is valid, then it is returned, otherwise nil

(defn rtm-auth-checkToken
  "Checks if the token is still valid. Returns the token if it is valid, otherwise
returns nil"
  [state token]
  (call-api state "rtm.auth.checkToken" {"auth_token" token}))

Returns the lists for the user as a sequence in the following format: ({:id "listid" :name "listname"} {:id "another_list" :name "another list name"} etc)

(defn rtm-lists-getList
  "Returns the lists for the user"
  [state]
  (call-api-with-token state "rtm.lists.getList"))

Returns all the tasks, or the tasks for a particular list. Supports the RTM search filters. By default it uses status:incomplete to only return incomplete tasks NB: it only returns a sub-set of the data currently. I may add more in as I go along...

(defn rtm-tasks-getList
  "Gets all the tasks for a particular list, or all tasks if not list-id provided"
  ([state list-id]
     (rtm-tasks-getList state list-id "status:incomplete"))
  ([state list-id list-filter]
     (call-api-with-token state "rtm.tasks.getList" {"list_id" list-id, "filter" list-filter})))
(defn rtm-search
  [state & search-filter]
  (call-api-with-token state "rtm.tasks.getList" {"filter" (str/join " " search-filter)}))

Timeline is required for any write tasks

(defn rtm-timelines-create
  [state]
  (call-api-with-token state "rtm.timelines.create"))

Add a task

(defn rtm-tasks-add
  "Create and add a new task using smart add. The task is added to the inbox."
  [state name]
  (call-api-with-token state "rtm.tasks.add"
    {"timeline" (:timeline state), "parse" "1", "name" name}))
(defn- make-params-map
  [& args]
  (utils/debug (str "Make params map for: " args))
  (if (and (seq args) (even? (count args)))
    (apply assoc {} args)
    {}))
(defn- call-task-method  
  [method-name state list-id task-series-id task-id & args]
  (utils/debug (str "call-task-method: args = " args))
  (let [additional-params (apply make-params-map args)]
    (utils/debug (str method-name " params: list-id = " list-id ", additional" additional-params))
    (call-api-with-token state method-name
      (into  {"timeline" (:timeline state), "list_id" list-id, "taskseries_id" task-series-id, "task_id" task-id} additional-params))))

Delete a task

(defn rtm-tasks-delete
  [state list-id task-series-id task-id]
  (call-task-method "rtm.tasks.delete" state list-id task-series-id task-id))
(defn rtm-transactions-undo
  [state timeline transaction-id]
  (call-api-with-token state "rtm.transactions.undo"
    {"timeline" timeline, "transaction_id" transaction-id}))

mark as task as complete

(defn rtm-tasks-complete
  [state list-id task-series-id task-id]
  (call-task-method "rtm.tasks.complete" state list-id task-series-id task-id))

postpone a task

(defn rtm-tasks-postpone
  [state list-id task-series-id task-id]
  (call-task-method "rtm.tasks.postpone" state list-id task-series-id task-id))

Create a new list

(defn rtm-lists-add
  [state name]
  (call-api-with-token state "rtm.lists.add"
    {"timeline" (:timeline state), "name" name}))

Move task to list

(defn rtm-tasks-moveTo
  [state from-list-id to-list-id task-series-id task-id]
  (call-api-with-token state "rtm.tasks.moveTo"
    {"timeline" (:timeline state), "from_list_id" from-list-id, "to_list_id" to-list-id,
     "taskseries_id" task-series-id, "task_id" task-id}))

set the priority of a task Note the order is slightly different here, with priority first, since this will be called with partial

(defn rtm-tasks-setPriority
  [priority state list-id task-series-id task-id]
  (call-task-method "rtm.tasks.setPriority" state list-id task-series-id task-id
                    "priority" priority))
(defn rtm-tasks-setName
  [name state list-id task-series-id task-id]
  (call-task-method "rtm.tasks.setName" state list-id task-series-id task-id
                    "name" name))
(defn rtm-tasks-setDueDate
  [due state list-id task-series-id task-id]
  (call-task-method "rtm.tasks.setDueDate" state list-id task-series-id task-id
                    "due" due "has_due_time" 1 "parse" 1))
(defn rtm-tasks-addTags
  "Add the tags to the task"
  [tag-coll state list-id task-series-id task-id]
  (call-task-method "rtm.tasks.addTags" state list-id task-series-id task-id
                    "tags" (str/join "," tag-coll)))
(defn rtm-tasks-removeTags
  "Remove the tags from the task"
  [tag-coll state list-id task-series-id task-id]
  (call-task-method "rtm.tasks.removeTags" state list-id task-series-id task-id
                    "tags" (str/join "," tag-coll)))
 

Contains the mutable stuff, and also the non-mutable state (if that makes sense)

(ns rtm-clj.state
  (:require [rtm-clj.utils :as utils]))

At some point I may make this configurable. This is where the state is stored.

(def *state-file* (str (System/getenv "HOME") "/.rtm-clj"))

This stores all the undoable tasks

(def *undoables* (atom ()))

This is the cache for the session

(def *cache* (atom {}))

Used to store data for the session

(defn cache-put
  [key data]
  (utils/debug (str "Storing " key " --> " data))
  (swap! *cache* assoc key data)
  data)
(defn cache-get
  [key]
  (@*cache* key))

Creates a new state map

is now stored in a map which is passed around rather than having a global

(defn new-state
  []
  {:api-key nil :shared-secret nil :token nil :timeline nil})
(defn set-token
  [state token]
  (assoc state :token token))

Sets the key for the session

(defn set-api-key
  [state key]
  (assoc state :api-key key))

Sets the shared secret for the session

(defn set-shared-secret
  [state secret]
  (assoc state :shared-secret secret))

Save the api key and shared secret to a file

Persistence

Store the state to a file, using the default location.

(defn save-state!
  ([state]
     (save-state! *state-file* state))
  ([f state]
     (spit f (dissoc state :timeline))
     state))

Tries to set up the api key and shared secret from a file

Load the state up again. This is called on startup. Note the exclamation mark, due to the fact that it overrides the current state.

(defn load-state
  ([]
     (load-state *state-file*))
  ([f]
     (try
       (read-string (slurp f))       
       (catch Exception e
         (new-state)))))

stores it away for future reference

(defn store-undoable
  [m]
  (swap! *undoables* conj m))
(defn remove-undoable
  [idx]
  (utils/debug (str "Removing undoable: " idx))
  (swap! *undoables* #(apply list (vals (dissoc  (utils/indexify %) idx)))))
(defn undoables [] @*undoables*)

Retrieves the sort order for the list from the state or nil if none

(defn get-list-sort-order
  [state list-id]
  (if-let [sort-order-map (:sort-order state)]
    (sort-order-map list-id)
    nil))

Helper method to store the list number away as hidden.

(defn hide-list
  [state list-num]
  (let [hidden (if (:hidden state) (:hidden state) #{})]    
    (assoc state :hidden (conj hidden list-num))))

Determine if the list is hidden currently.

(defn list-hidden?
  [state list-num]
  (if (:hidden state)
    ((:hidden state) list-num)))

Opposite of hide

(defn unhide-list
  [state list-num]
  (if (:hidden state)
    (assoc state :hidden (disj (:hidden state) list-num))))
 
(ns rtm-clj.utils
  (:require [clojure.string :as str]))

utility functions

(defmulti as-int class)
(defmethod as-int Number [v]
  (int v))
(defmethod as-int String [s]
  (try
    (Integer/parseInt s)
    (catch Exception e nil)))
(defmethod as-int :default [x] nil)

Creates a map using the supplied collection, where each key is a number starting at i and incrementing

used twice, so factored out

(defn indexify
  ([coll]
     (indexify coll 1))
  ([coll i]
     (apply array-map (interleave (iterate inc i) coll))))

Displays the prompt for the user and reads input for stdin. Returns a vector of the individual words that were entered.

This function displays the prompt, reads input, and returns the full line as a String. Note that it is parameterized so that it can be used to request specific input from the user It would probably be useful to add a validation function in here as well to make it more general.

(defn prompt!
  ([]
     (prompt! "rtm> "))
  ([s]
     (prompt! s str/blank?))
  ([s vf]
     (print s)
     (flush)
     (let [line (str/trim (read-line))]
       (if (vf line)
         (recur s vf)
         line))))
(def *debug-on* (atom false))
(defn switch-debug-on! [bool]
  (reset! *debug-on* bool))
(defn debug [s]
  (when @*debug-on*
    (do
      (println)
      (println s))))

Makes a comparator that can compare values determined by the keys. adaptor is applied to the result of the comparator, should be + for ascending or - for descending (or something else if you want to be funky!)

(defn make-map-comparator
  ([f & keys]
     (let [selector (apply comp (reverse keys))]    
       (fn [map-a map-b]
         (f (compare (selector map-a) (selector map-b)))))))

Makes a comparator that strings all the provided comparators together. Enables to do multi-level sorting.

(defn make-combined-comparator
  [comparator & comparators]
  (fn [x y]
    (loop [comp-fn comparator
           remaining comparators]
      (let [result (comp-fn x y)]
        (if (= 0 result)
          (if (seq remaining)
            (recur (first remaining) (rest remaining))
            result)
          result)))))

Makes a combined comparator that will sort by each of the keys in order to differentiate.

(defn make-combined-key-comparator
  [f & keys]
  (let [comparators (map #(make-map-comparator f %) keys)]
    (apply make-combined-comparator comparators)))
 

This layer sits on top of the api layer and provides the higher level commands for the app.

(ns rtm-clj.command
  (:require [rtm-clj.api :as api]
            [rtm-clj.state :as state]
            [rtm-clj.utils :as utils]
            [rtm-clj.xml :as xml]
            [clojure.string :as str]
            [swank.swank :as swank]
            [clansi :as clansi])
  (:import [java.net URI]))
(defn- create-id-map
  "Creates the map that is used to output the lists, tasks etc"
  [items]
  (for [item items :let [id-map {:id (:id item), :name (:name item), :data item}]]
    id-map))
(defn- divider
  []
  (println "============================================================"))
(defn- title
  [t]
  (println)
  (divider)
  (println t)
  (divider))
(def priority-formats
  {"1" :red, "2" :blue, "3" :cyan})
(defn- colorize
  [str priority]
  (if-let [color-key (priority-formats priority)]
    (clansi/style str color-key :bright)
    str))
(defn- format-task
  "Formats the task for display"
  [name-key item-map]
  (let [item (val item-map)
        priority (:priority (:data item))]    
    (colorize (str (format "%5s" (key item-map)) " - " (name-key item)) priority)))

Display the results. The contract is that the map must be in the following format: {0, {:id 123 :name "Inbox"}, 1 {:id 1234 :name "Sent"}}

(defn- display-id-map
  "Requires map in format: id, {:id id :name name}"
  ([heading id-map]
     (display-id-map heading :name id-map))
  ([heading name-key id-map]
     (if (not (zero? (count id-map)))
       (do
         (title heading)
         (doseq [item id-map]
           (println (format-task name-key item)))
         (divider)
         (println)))
     id-map))

The map is cached using the provided key for future lookup. That is the mechanism for storing data in the session.

(defn- cache-id-map
  "Requires map in format: id, {:id id :name name}"
  [cache-id id-map]
  (state/cache-put cache-id id-map)
  (state/cache-put :last cache-id)
  id-map)
(defn- print-if-not-empty [text data]
  (if-not (or (nil? data) (empty? data))
    (println (str text data))))
(defn- display-task
  [task-data]
  (utils/debug (str "Task data:" task-data))
  (title (str "Task: " (:name task-data)))
  (print-if-not-empty "Due: " (:due task-data))
  (print-if-not-empty "URL: " (:url task-data))
  (doseq [note (flatten (:notes task-data))]
    (println (str "Note: " (:title note)))
    (println (:text note)))
  (if-not (empty? (:tags task-data))
    (do
      (print "Tags: ")
      (doseq [tag (:tags task-data)]
        (print (str tag " ")))
      (println)))
  (divider)
  (println))
(defn get-lists
  "Calls the rtm api to retrieve lists, returning the attributes from the xml"
  [state]
  (if-let [list-xml (xml/to-xml (api/rtm-lists-getList state))]
    (for [x (xml-seq list-xml) :when (= :list (:tag x))]
      (:attrs x))))

Generates the url that the user needs to access in order to grant access for the client to access their account, and launches the browser. Returns the frob.

(defn request-authorization
  "See http://www.rememberthemilk.com/services/api/authentication.rtm"
  [state frob]
  (if frob
    (do
      (utils/debug (str "Requesting auth with frob '" frob "'"))
      (if-let [url (api/build-rtm-url state {"perms" "delete", "frob" frob} api/*auth-url-base*)]
        (.browse (java.awt.Desktop/getDesktop) (URI. url)))
      frob)))
(defn get-frob
  "Calls rtm-auth-getFrob and parses the response to extract the frob value."
  [state]
  (first (xml/parse-response (api/rtm-auth-getFrob state) :frob)))
(defn get-token
  "Calls the api to get the token"
  [state frob]
  (first (xml/parse-response (api/rtm-auth-getToken state frob) :token)))
(defn validate-token
  "Calls the api to check the validity of the token"
  ([state]
     (if (:token state)
       (validate-token state (:token state))
       nil))
  ([state token]
     (first (xml/parse-response (api/rtm-auth-checkToken state token) :token))))

Checks to see if we have a valid token. If not then launches the browser for authorization. Returns the state.

(defn login
  "This is a helper method that pulls the whole auth process together"
  ([state]
     (if-not (validate-token state)
       (if-let [frob (request-authorization state (get-frob state))]
         (do
           (utils/prompt! "Authorise application in browser and <RETURN> to continue..."
                    (fn [x] nil))
           (if-let [new-token (get-token state frob)]
             (if-let [valid-token (validate-token state new-token)]
               (state/save-state! (state/set-token state valid-token))))))
       state)))
(defn init-api-key
  [state]
  (if-not (api/api-key-set? state)
    (state/set-api-key state (utils/prompt! "Enter api key: "))
    state))
(defn init-shared-secret
  [state]
  (if-not (api/shared-secret-set? state)
    (state/set-shared-secret state (utils/prompt! "Enter shared secret: "))
    state))
(defn init-timeline
  [state]
  (if-let [timeline (first (xml/parse-response (api/rtm-timelines-create state) :timeline))]
    (assoc state :timeline timeline)))
(defn init-state
  "Sets up the state for the session"
  ([]
     (init-state (state/load-state)))
  ([state]
     (->> state
          init-api-key
          init-shared-secret          
          state/save-state!)))

Now we have the section of the file which defines the commands. These are just Clojure functions. However, due to a limitation in the current implementation, the commands should only use explict parameters or & args, otherwise the arity check will fail. Note that for the function to be picked up as a command, it must have the :cmd metadata, defining the name

(defn ^{:cmd "exit" :also ["quit"]} exit
  "Exits the application"
  [state]
  (state/save-state!  state)
  (println "Good-bye")
  (System/exit 1))

Start a repl to connect to from Slime

(defn ^{:cmd "swank" :also ["repl"]} start-swank
  "Start a swank server on the specified port. Defaults to 4005."
  ([state]
     (swank/start-repl 4005))
  ([state port]
     (swank/start-repl (utils/as-int port))))
(defn ^{:cmd "echo" :also ["say"]} echo
  "Echos out the command: echo [text]"
  [state & args]
  (apply println args))
(defn ^{:cmd "state"} state
  "Echos out the current state"
  [state]
  (println state))
(defn- apply-sort-order
  [state list-id the-list]
  (if-let [sort-keys (state/get-list-sort-order state list-id)]
    (sort (apply utils/make-combined-key-comparator + sort-keys) the-list)
    the-list))
(defn- handle-get-list-response
  "Caches the response and displays the tasks"
  [state tasks title sort-key]
  (->> (apply-sort-order state sort-key tasks)
       (create-id-map)
       (utils/indexify)
       (display-id-map (str "List: " title))
       (cache-id-map :tasks)))
(defn ^{:cmd "search"} search
  "Search using the RTM search queries"
  [state & query-params]
  (if (seq query-params)
    (if-let [tasks (xml/parse-task-series-response (apply api/rtm-search state query-params))]
      (handle-get-list-response state tasks (str/join " " query-params) nil))
    (println "Nothing to search for.")))
(defn- do-display-lists
  [state title pred]
  (if-let [lists (get-lists state)]
         (->> (utils/indexify (create-id-map (filter pred lists)))
              (display-id-map title)
              (cache-id-map :lists))))

Not only displays the lists, but also stores them away for reference, so user can do list 0 to display all the tasks in list 0

(defn ^{:cmd "list", :also ["ls" "l"], :cache-id :lists} display-lists
  "Displays all the lists or all the tasks for the selected list"
  ([state]
     (letfn [(not-hidden? [l]
               (not (state/list-hidden? state (:id l))))]
       (do-display-lists state "Lists" not-hidden?)))
  ([state i]
     (let [idx (utils/as-int i)]
       (if-let [cached-lists (state/cache-get :lists)]
         (if-let [the-list (cached-lists idx)]
           (if-let [tasks (xml/parse-task-series-response (api/rtm-tasks-getList state (:id the-list)))]
             (do
               (state/cache-put :last-list-num idx)
               (handle-get-list-response state tasks (:name the-list) (:id the-list)))))))))
(defn- display-last-list
  [state]
  (if-let [last-list-num (state/cache-get :last-list-num)]
    (display-lists state last-list-num)))

Command for viewing a particular task

(defn ^{:cmd "task", :also ["t"], :cache-id :tasks} view-task
  "Displays the details of a particular task from the last displayed list."
  [state i]
  (if-let [task ((state/cache-get :tasks) (utils/as-int i))]
    (let [task-data (:data task)]
      (state/cache-put :last-task task-data)
      (display-task task-data))))

Command for adding a task

(defn ^{:cmd "new", :also ["add"]} add-task
  "Creates a new task using smart-add"
  [state & args]
  (if-let [new-task (xml/parse-add-task-response (api/rtm-tasks-add state (str/join " " args)))]
    (do
      (utils/debug (str "new-task: " new-task))
      (display-task (first new-task)))
    (println "Failed to add task")))

Command to enable debug

(defn ^{:cmd "debug"} switch-debug-on!
  [state]
  (utils/switch-debug-on! true))
(defn ^{:cmd "nodebug"} switch-debug-off!
  [state]
  (utils/switch-debug-on! false))
(defn- cache-if-undoable
  "Takes the raw xml response, and caches the details if it is undoable. The msg is what is displayed in the history."
  [state msg xml-response]
  (if-let [undoable (xml/parse-undoable xml-response (:timeline state))]
    (state/store-undoable (assoc undoable :message msg)))
  xml-response)

Helper function which abstracts out some common functionality when acting on a list of tasks (e.g. rm, mark as complete etc)

(defn- task-command
  [f undo-msg state tasknum & others]
  (utils/debug (str "task-command: tasknum=" tasknum ", others=" others))
  (if-let [task (:data ((state/cache-get :tasks) (utils/as-int tasknum)))]
    (do
      (utils/debug (str "task: " task))
      (utils/debug (cache-if-undoable state (str undo-msg " task \ (:name task) "\)
                                      (f state (:list-id task) (:task-series-id task) (:id task))))
      (if (seq others)
        (recur f undo-msg state (first others) (rest others))
        (display-last-list state)))))
(defn ^{:cmd "rm", :also ["delete"]} delete-task
  "Delete one or more tasks (by index)."
  [state tasknum & others]
  (apply task-command api/rtm-tasks-delete "Deleted" state tasknum others))
(defn ^{:cmd "complete", :also ["c"]} complete-task
  "Mark one or more tasks as complete (by index)."
  [state tasknum & others]
  (apply task-command api/rtm-tasks-complete "Completed" state tasknum others))
(defn ^{:cmd "postpone", :also ["pp"]} postpone-task
  "Mark one or more tasks as postponed"
  [state tasknum & others]
  (apply task-command api/rtm-tasks-postpone "Postponed" state tasknum others))
(defn ^{:cmd "due"} set-due-date
  [state tasknum & date]
  (if (seq date)
    (task-command (partial api/rtm-tasks-setDueDate (str/join " " date)) "Changed due date on" state tasknum)
    (println "You must provide a date.")))
(defn ^{:cmd "tag"} add-tags
  [state tasknum & tags]
  (if (seq tags)
    (task-command (partial api/rtm-tasks-addTags tags) "Added tags to" state tasknum)
    (println "You must specify tags.")))
(defn ^{:cmd "rmtag"} remove-tags
  [state tasknum & tags]
  (if (seq tags)
    (task-command (partial api/rtm-tasks-removeTags tags) "Removed tags to" state tasknum)
    (println "You must specify tags.")))

These are ripe for converting to macros

(defn- set-priority
  [priority state tasknum & others]
  (apply task-command (partial api/rtm-tasks-setPriority priority) (str "Set priority" priority) state tasknum others))
(defn ^{:cmd "p0"} set-priority-0
  "Sets the priority of a task to 0"
  [state tasknum & others]
  (apply set-priority "0" state tasknum others))
(defn ^{:cmd "p1"} set-priority-1
  "Sets the priority of a task to 1"
  [state tasknum & others]
  (apply set-priority "1" state tasknum others))
(defn ^{:cmd "p2"} set-priority-2
  "Sets the priority of a task to 2"
  [state tasknum & others]
  (apply set-priority "2" state tasknum others))
(defn ^{:cmd "p3"} set-priority-3
  "Sets the priority of a task to 3"
  [state tasknum & others]
  (apply set-priority "3" state tasknum others))
(defn ^{:cmd "rename", :also ["ren"]} rename-task
  "Rename a task. rename [id] new-name"
  [state tasknum & name]
  (if name
    (let [new-name (str/join " " name)]
      (task-command (partial api/rtm-tasks-setName new-name)
                    (str "Renamed to \ new-name "\) state tasknum))
    (do
      (println "You must provide a name")
      (display-last-list state))))
(defn ^{:cmd "undo"} undo
  "Displays undoable actions and allows to undo"
  ([state]
     (->> (utils/indexify (state/undoables))
                  (display-id-map "Undoable Tasks" :message)
                  (cache-id-map :undos)))
  ([state idx]
     (if-let [undo-map (state/cache-get :undos)]
       (if-let [um (undo-map (utils/as-int idx))]
         (if (api/rtm-transactions-undo state (:timeline um) (:transaction-id um))
           (state/remove-undoable (utils/as-int idx)))
         (println "Error: Nothing found to undo")))))
(defn ^{:cmd "newlist", :also ["nl"]} create-new-list
  "Creates a new list"
  [state & name]
  (api/rtm-lists-add state (str/join " " name)))
(defn- get-list
  "Gets the list from the cache"
  [id]
  ((state/cache-get :lists) (utils/as-int id)))
(defn ^{:cmd "move", :also ["mv"]} move-task
  "Move tasks by id to a list, which will be prompted for."
  [state task-idx & more]
  (display-lists state)
  (if-let [to-list (get-list (utils/prompt! "Move task(s) to which list? " (complement utils/as-int)))]
    (loop [tasknum task-idx
           others more]        
      (if-let [task (:data ((state/cache-get :tasks) (utils/as-int tasknum)))]
        (do          
          (utils/debug (cache-if-undoable state (str "Moved task \ (:name task) "\" to " (:name to-list))
                                          (api/rtm-tasks-moveTo state (:list-id task) (:id to-list) (:task-series-id task) (:id task))))
          (if (seq others)
            (recur (first others) (rest others))))))))
(defn- set-sort-order
  [state list-num the-list & keys]
  (let [state (assoc-in state [:sort-order (:id the-list)] keys)]
    (state/cache-put :state state)
    (state/save-state! state)
    (display-lists state list-num)))
(defn ^{:cmd "sort", :also ["s"]} sort-list
  "sort [listnum] [d|p]"
  [state list-num & keys]
  (if-let [the-list (get-list list-num)]
    (let [sort-keys (map keyword (filter #{"due" "priority" "name"} keys))]
      (apply set-sort-order state list-num the-list sort-keys))))
(defn- do-hide-action
  [state f listnum & more-listnums]
  (if-let [the-list (get-list listnum)]
    (let [new-state (state/save-state! (state/cache-put :state (f state (:id the-list))))]
      (if (seq more-listnums)
        (recur new-state f (first more-listnums) (rest more-listnums))))))

I use this pattern in a few different places. Could abstract out?

(defn ^{:cmd "hide", :also ["hidden"]} hide-list
  "Hides a list, by list number"
  ([state]
     (letfn [(hidden? [l] (state/list-hidden? state (:id l)))]
       (do-display-lists state "Hidden Lists" hidden?)))
  ([state listnum & more-listnums]
     (apply do-hide-action state state/hide-list listnum more-listnums)))
(defn ^{:cmd "unhide"} unhide-list
  [state listnum & more-listnums]
  (apply do-hide-action state state/unhide-list listnum more-listnums))
 

The entry point for the application. This contains the main entry for the command line application. The idea is that it displays a prompt, you enter a command, which is then executed. It's essentially a kind of REPL or shell.

(ns rtm-clj.core
  (:require [rtm-clj.command :as cmd]
            [rtm-clj.state :as state]
            [rtm-clj.utils :as utils]
            [clojure.string :as str])
  (:gen-class :main true))

The map that contains all the commands.

(def *commands* (atom {}))

And some aliases

(def *command-aliases* (atom {}))

The entry point for putting the commands into the map. This associates a Clojure function with a String, which is the command name. When a command is executed, the correct function is looked up from here, and all the arguments are passed to it.

(defn- duplicate-alias?
  [al cmd]
  (if-let [existing (@*command-aliases* al)]
    (not (= cmd existing))
    false))
(defn register-command-alias
  [al cmd]
  (if-not (duplicate-alias? al cmd)
    (swap! *command-aliases* assoc al cmd)))
(defn register-command
  "Registers the command for future use"
  [f name]
  (swap! *commands* assoc name f)
  (doseq [also (:also (meta f))]
    (register-command-alias also name))
  (if-let [cache-id (:cache-id (meta f))]
    (register-command-alias cache-id name)))
(declare lookup-command)

Ideally this would go into the command namespace, but it relies on looking up other commands so really, it needs to live at the top level

(defn ^{:cmd "help" :also ["?", "h"]} help
  "Displays all the available commands, or provides help for a particular command"
  ([state]
     (apply println (sort (keys @*commands*))))
  ([state cmd]
     (if-let [f (lookup-command state cmd)]
       (println (str cmd " "(:also (meta f)) ": " (:doc (meta f))))
       (println (str cmd ": command not found")))))

Dispatching Commands

This section of the code is the part that parses the input from the user, and works out which command to execute.

(defn- lookup-alias
  [cmd]
  (if (@*command-aliases* cmd)
    (@*command-aliases* cmd)
    cmd))
(defn- command-exists?
  [cmd]
  (if (@*commands* cmd) true false))

Supports looking at the menu and typing a number for the displayed menu

(defn- lookup-index-command [state i]
  "Used to directly execute an item from the displayed index."
  (if-let [last-id (state/cache-get :last)]
    (if-let [f (lookup-command state last-id)]
      ;; construct a function that takes state as the  arg, and pass it the index
      (with-meta
        (fn [f-state] (f f-state i)) {:arglists '([s])}))))
(defn lookup-command 
  "Looks up the command by name, also checking for aliases. Returns the function."
  [state cmd]
  (let [cmd-name (lookup-alias cmd)]
    (if (command-exists? cmd-name)
      (@*commands* cmd-name)
      (if-let [i (utils/as-int cmd)]
          (lookup-index-command state i)))))

I love this bit of code. There's probably an easier way to do it, but then I didn't have much experience of Clojure when I wrote it. This takes a single arglist for a function (i.e. the bit in square brackets, as defined in defn), and returns a function which takes one argument (a number) and returns true if that number is compatible with the arity of the arg list. For example:

  • [] - the only valid number is 0
  • [x] - the only valid number is 1
  • [& args] - any number >= 0
  • [x & args] - any number >= 1

This is only a simple check. It doesn't handle arglists that have a destructuring form, hence the restriction noted above in the commands section, about using explicit args or & args.

(defn arity-check
  "Returns a function that evaluates to true if the arity matches the count"
  [arglist]
  ;; special case - if arglist is of zero length then no need to check for & args
  (if (= 0 (count arglist))
    #(= % 0)
    (let [arg-map (apply assoc {}
                         (interleave arglist (range 0 (count arglist))))]
      ;; if & args found then number of args is >= the position of the &
      ;; otherwise it's just a simple size comparison
      (if ('& arg-map)
        #(>= % ('& arg-map))
        #(= % (count arglist))))))

This takes a function, f, and the args that it is proposed will be passed to the function. The arity of the args must match the arity of at least one of the function's arglists. Hence this calls arity-check for each of the arglists, then calls each of the functions returned with the number of arguments. The result is a set of booleans containing the result of the function calls.

(defn arity-matches-args
  "Returns true if the args match up to the function"
  [f num-args]
  (let [arity-check-fns (map arity-check (:arglists (meta f)))]
    ;; Check that at least one of the arglists can accept all the args.
    ((set (map #(% num-args) arity-check-fns)) true)))

Looks up the registered commands to find a function that is mapped to the command the user entered. If it is found, and the arity matches, then the function is called, passing the rest of the args.

(defn- call-cmd
  "Destructures the command entered by the user, looking up the function that
implements the command using the first element. If found, the function is called
with the rest of the args"
  [state cmd & args]
  (if-let [f (lookup-command state cmd)]
    ;; arity match needs to include the state arg
    (if (arity-matches-args f (inc (count args)))
      (try
        (apply f state args)
        (catch Exception e
          (println (str "Exception: " (.toString e)))
          (.printStackTrace e)))
      (do
        (println (str cmd ": wrong number of args"))
        (help cmd)))
    (println (str cmd ": command not found"))))

This works with the full command string that the user entered e.g. help echo Splits the command into a sequence of words, and calls call-cmd to execute the function.

(defn call
  "Pass the raw command string in here as read from the prompt. Parses it and
delegate to the call-cmd"
  [state cmd-str]
  (apply call-cmd state (str/split cmd-str #" ")))

This is the repl. Just repeatedly prompts for input and executes the commmand.

(defn cmd-loop
  "This is repl, if you like, for rtm. Read a command, evaluate it, print the result, loop"
  [state]
  (call state (utils/prompt!))
  (recur (state/cache-get :state)))

Dynamically discover commands

(defn- discover-commands
  ([]
     (discover-commands 'rtm-clj.command)
     (discover-commands 'rtm-clj.core))
  ([namespace]
     (doseq [f (vals (ns-publics namespace)) :when (:cmd (meta f)) :let [name (:cmd (meta f))]]
       (register-command f name))))

Main Control Loop

The main method, the entry point for running from Java. It tries to load a previous state from the file in the home directory, to retrieve the api key and shared secret, which are needed to interact with the Remember the Milk API. Once it is all set up, it just calls the cmd-loop.

(defn -main [& args]
  (discover-commands)
  (if-let [state (cmd/login (cmd/init-state))]
    (let [state-with-timeline (state/cache-put :state (cmd/init-timeline state))]
      (cmd/display-lists state-with-timeline)
      (cmd-loop state-with-timeline)
      (do
        (println "Login failed")
        (cmd/exit)))))