git.fiddlerwoaroof.com
Raw Blame History
(in-package #:tempores.cli)

(defparameter *interactive* nil)
(defparameter *version* "0:7")

(defun unroll-date (date-obj)
  (with-slots (year month day) date-obj
    (list day month year)))

(defun split-time (time)
  (let ((time-parts (split-sequence #\: time)))
    (destructuring-bind (hours minutes . optional-seconds) time-parts
      (let ((hours (parse-integer hours))
            (minutes (parse-integer minutes))
            (seconds (parse-integer (or (car optional-seconds) "0")))
            (extra (cdr optional-seconds)))
        (values hours minutes seconds extra)))))

(defun try-fix-time (failed-time)
  (handler-case
    (multiple-value-bind (hours minutes seconds extra) (split-time failed-time)
      (if (and (< hours 24) (< minutes 60) (< seconds 60) (null extra))
        (values (format nil "~2,'0d:~2,'0d:~2,'0d" hours minutes seconds) t)
        (values nil nil)))
    (parse-error (c) c (values nil nil))))

(defun call-with-prompt (stream prompt args cb)
  (apply #'format stream prompt args)
  (finish-output *query-io*)
  (funcall cb (read-line *query-io*)))

(defmacro with-prompt ((result-sym stream prompt &rest args) &body body)
  `(call-with-prompt ,stream ,prompt (list ,@args)
                     (lambda (,result-sym)
                       ,@body)))

(defun abort-with-message (stream message &rest args)
  (apply #'format stream message args)
  (abort))

(define-condition parse-time-error (parse-error)
  ((time-string :initarg :time-string :accessor time-string))
  (:report (lambda (condition stream)
             (format stream "Time input did not parse correctly: ~s" (time-string condition)))))

(defun call-with-prompt-for-time (stream prompt args cb)
  (call-with-prompt
    stream prompt args
    (lambda (time-string)
      (multiple-value-bind (hours minutes seconds extra) (split-time time-string)
        (funcall cb hours minutes seconds extra)))))

(defmacro with-prompt-for-time ((result-syms stream prompt &rest args) &body body)
  `(call-with-prompt-for-time ,stream ,prompt (list ,@args)
                              (lambda (,@result-syms)
                                ,@body)))

(define-message format-time (hours minutes &optional (seconds 0))
  (:decimal 2 '0) #\: (:decimal 2 '0) #\: (:decimal 2 '0))

(defun handle-invalid-time (c) c
  (let ((time (tempores.parser::failed-chunk c)))
    (multiple-value-bind (new-value success) (try-fix-time time)
      (when success
        (progn (warn 'tempores::autocorrect-warning
                     :old-value time
                     :new-value new-value)
               (smug:replace-invalid time new-value))))
    (if *interactive*
      (loop
        (handler-case
          (with-prompt-for-time ((hours minutes seconds &rest rest)
                                 *query-io* "Invalid time ~a, replacement? " time)
            (declare (ignore rest))
            (let ((replacement (format-time nil hours minutes seconds)))
              (format *query-io* "~&Replacing ~s with ~s.~%---~%" time replacement)
              (smug:replace-invalid time replacement)))
          (parse-error (c) c (format t "~&Invalid entry.~%"))))
      (abort-with-message t "~&Time ~a is invalid.~%" time))))

(defun handle-invalid-whitespace (ignore-whitespace-errors)
  (lambda (c) c
    (let ((extra-whitespace (tempores.parser::failed-chunk c)))
      (if (or ignore-whitespace-errors
              (when *interactive*
                (y-or-n-p "Invalid extra whitespace ~s, truncate?" extra-whitespace)))
        (smug:replace-invalid extra-whitespace "")
        (abort-with-message t "~&Whitespace errors~%")))))

(defun parse-file (&optional (file *default-time-sheet-file*) ignore-whitespace-errors)
  (flet ((parse-string (string)
           (handler-bind ((tempores.parser::invalid-whitespace
                            (handle-invalid-whitespace ignore-whitespace-errors))
                          (parse-error #'handle-invalid-time)
                          (tempores.parser::invalid-time #'handle-invalid-time) )
             (smug:parse (tempores.parser::.date-records) string))))
    (multiple-value-bind (parsed leftovers) (parse-string (read-file-into-string file))
      (if (or (null leftovers) (string= leftovers ""))
        parsed
        (cerror "Continue?" 'parsing-error :leftovers leftovers)))))

(defun pprint-results (results incompletes status)
  (print-entries results)

  (when incompletes
    (format t "~&~120,1,0,'-<~>~%Partial Entries:~%")
    (print-entries incompletes))

  (when status
    (print-status results)))

(defun sort-by-date (results)
  (stable-sort results #'local-time:timestamp<
               :key (alambda (apply #'local-time:encode-timestamp
                                    (list* 0 0 0 0 (unroll-date (date it)))))))

(defun maybe-nreverse (flag list)
  (if flag
    (nreverse list)
    list))

(define-modify-macro maybe-nreversef (flag)
                     (lambda (place flag)
                       (maybe-nreverse flag place)))

(defun list-without-nulls (&rest items)
  (loop for item in items
        when item collect item))

(defun pprint-log (args &key client reverse status ignore-whitespace interactive)
  (labels ((sort-func (client)
             (apply #'compose
                    (list-without-nulls
                      (when reverse #'nreverse)
                      (when client
                        (plambda (stable-sort :1 #'string-lessp :key #'client)))
                      #'sort-by-date)))
           (sort-results (results &optional (client client))
             (funcall (sort-func client) results))
           (get-logs (files)
             (loop for file in (ensure-list files)
                   append (tempores:get-log file ignore-whitespace)) ))

    (let ((*default-time-sheet-file* (or args *default-time-sheet-file*))
          (*interactive* interactive)
          (*print-pretty* t))
      (let-each (:be *)
        (get-logs *default-time-sheet-file*)
        (group-by-class *)
        (destructuring-bind (complete-ranges incomplete-ranges) *
          (let ((complete-results (sort-results complete-ranges client))
                (incomplete-results (sort-results incomplete-ranges t)))
            (pprint-results complete-results incomplete-results status)))))))

(defsynopsis (:postfix "TIMESHEETS ...")
  (text :contents "A program for managing logs of hours worked")
  (group (:header "Display options")
         (flag :short-name "s" :long-name "status"
               :description "Print a short summary of work status")
         (flag :short-name "W"
               :long-name "ignore-whitespace"
               :description "Ignore whitespace errors in input")
         (flag :short-name "i" :long-name "interactive"
               :description "Run interactively"))
  (group (:header "Sort options")
         (flag :short-name "r"
               :long-name "reverse"
               :description "Reverse the sort direction")
         (flag :short-name "c"
               :long-name "client"
               :description "Sort records by client"))
  (group (:header "Freshbooks")
         (flag :long-name "post-hours"
               :description "Post hours to freshbooks (requires manual setup of Freshbooks keys)"))
  (group (:header "Self-test options")
         (flag :long-name "dependencies"
               :description "Graph the dependencies of this project")
         (flag :long-name "run-tests"
               :description "Run the tests")
         (enum :long-name "output-style"
               :description "The kind of output to produce"
               :default-value :normal
               :enum '(:xunit :normal)))
  (group (:header "Reformat options")
         (flag :long-name "reformat-file"
               :short-name "f"
               :description "Read the current timesheet file and dump, correcting any whitespace or formatting errors"))
  (group (:header "Generic options")
         (flag :short-name "v" :long-name "version"
               :description "Show the program version")
         (flag :short-name "h" :long-name "help"
               :description "Show this help")))

(eval-when (:load-toplevel :compile-toplevel :execute)
  (define-message version-message (version)
    (:own-line () "tempores file parser, version " :str)))

(defun show-version ()
  (version-message t *version*))

(defun tests-main (&optional (output-style nil output-style-p))
  (declare (ignorable output-style-p))
  (let ((should-test:*verbose* t))
    (ecase output-style
      (:xunit (should-test:test-for-xunit *standard-output* :package :tempores.parser))
      (:normal (should-test:test :package :tempores.parser)))))

(defun reformat-main ()
  (with-tempores-configuration ()
    (format t "~{~a~}"
            (mapcar #'unparse
                    (parse-file *default-time-sheet-file*
                                t)))))

(defun pprint-log-main ()
  (make-context)
  (tagbody
    start
    (restart-case
      (cond
        ((getopt :long-name "help") (help))
        ((getopt :long-name "version") (show-version))
        ((getopt :long-name "dependencies") (format t (tempores.package-grapher::graph-tempores-packages)))
        ((getopt :long-name "post-hours") (let ((*print-pretty* nil))
                                            (loop for item in (tempores.freshbooks::post-time-entries-main)
                                                  do (format t "Posted an entry")
                                                  do (plump:serialize item)
                                                  finally (format t "Don't forget to archive time file."))))
        ((getopt :long-name "run-tests") (tests-main (getopt :long-name "output-style")))
        ((getopt :long-name "reformat-file") (reformat-main))
        (t (with-tempores-configuration ()
             (pprint-log
               (remainder)
               :client (getopt :long-name "client")
               :interactive (getopt :long-name "interactive")
               :ignore-whitespace (getopt :long-name "ignore-whitespace")
               :status (getopt :long-name "status")
               :reverse (getopt :long-name "reverse")))))
      (retry () (go start))
      (abort ()))))

(defun make-executable ()
  (dump "tempores" pprint-log-main
        :compression 8
        :purify t))