git.fiddlerwoaroof.com
Browse code

Adding some missing files

fiddlerwoaroof authored on 28/04/2016 02:41:58
Showing 2 changed files
1 1
new file mode 100644
... ...
@@ -0,0 +1,229 @@
1
+(in-package #:tempores.cli)
2
+
3
+(defparameter *interactive* nil)
4
+(defparameter *version* "0:7")
5
+
6
+(defun unroll-date (date-obj)
7
+  (with-slots (year month day) date-obj
8
+    (list day month year)))
9
+
10
+(defun split-time (time)
11
+  (let ((time-parts (split-sequence #\: time)))
12
+    (destructuring-bind (hours minutes . optional-seconds) time-parts
13
+      (let ((hours (parse-integer hours))
14
+            (minutes (parse-integer minutes))
15
+            (seconds (parse-integer (or (car optional-seconds) "0")))
16
+            (extra (cdr optional-seconds)))
17
+        (values hours minutes seconds extra)))))
18
+
19
+(defun try-fix-time (failed-time)
20
+  (handler-case
21
+    (multiple-value-bind (hours minutes seconds extra) (split-time failed-time)
22
+      (if (and (< hours 24) (< minutes 60) (< seconds 60) (null extra))
23
+        (values (format nil "~2,'0d:~2,'0d:~2,'0d" hours minutes seconds) t)
24
+        (values nil nil)))
25
+    (parse-error (c) c (values nil nil))))
26
+
27
+(defun call-with-prompt (stream prompt args cb)
28
+  (apply #'format stream prompt args)
29
+  (finish-output *query-io*)
30
+  (funcall cb (read-line *query-io*)))
31
+
32
+(defmacro with-prompt ((result-sym stream prompt &rest args) &body body)
33
+  `(call-with-prompt ,stream ,prompt (list ,@args)
34
+                     (lambda (,result-sym)
35
+                       ,@body)))
36
+
37
+(defun abort-with-message (stream message &rest args)
38
+  (apply #'format stream message args)
39
+  (abort))
40
+
41
+(define-condition parse-time-error (parse-error)
42
+  ((time-string :initarg :time-string :accessor time-string))
43
+  (:report (lambda (condition stream)
44
+             (format stream "Time input did not parse correctly: ~s" (time-string condition)))))
45
+
46
+(defun call-with-prompt-for-time (stream prompt args cb)
47
+  (call-with-prompt
48
+    stream prompt args
49
+    (lambda (time-string)
50
+      (multiple-value-bind (hours minutes seconds extra) (split-time time-string)
51
+        (funcall cb hours minutes seconds extra)))))
52
+
53
+(defmacro with-prompt-for-time ((result-syms stream prompt &rest args) &body body)
54
+  `(call-with-prompt-for-time ,stream ,prompt (list ,@args)
55
+                              (lambda (,@result-syms)
56
+                                ,@body)))
57
+
58
+(define-message format-time (hours minutes &optional (seconds 0))
59
+  (:decimal 2 '0) #\: (:decimal 2 '0) #\: (:decimal 2 '0))
60
+
61
+(defun handle-invalid-time (c) c
62
+  (let ((time (tempores.parser::failed-chunk c)))
63
+    (multiple-value-bind (new-value success) (try-fix-time time)
64
+      (when success
65
+        (progn (warn 'tempores::autocorrect-warning
66
+                     :old-value time
67
+                     :new-value new-value)
68
+               (smug:replace-invalid time new-value))))
69
+    (if *interactive*
70
+      (loop
71
+        (handler-case
72
+          (with-prompt-for-time ((hours minutes seconds &rest rest)
73
+                                 *query-io* "Invalid time ~a, replacement? " time)
74
+            (declare (ignore rest))
75
+            (let ((replacement (format-time nil hours minutes seconds)))
76
+              (format *query-io* "~&Replacing ~s with ~s.~%---~%" time replacement)
77
+              (smug:replace-invalid time replacement)))
78
+          (parse-error (c) c (format t "~&Invalid entry.~%"))))
79
+      (abort-with-message t "~&Time ~a is invalid.~%" time))))
80
+
81
+(defun handle-invalid-whitespace (ignore-whitespace-errors)
82
+  (lambda (c) c
83
+    (let ((extra-whitespace (tempores.parser::failed-chunk c)))
84
+      (if (or ignore-whitespace-errors
85
+              (when *interactive*
86
+                (y-or-n-p "Invalid extra whitespace ~s, truncate?" extra-whitespace)))
87
+        (smug:replace-invalid extra-whitespace "")
88
+        (abort-with-message t "~&Whitespace errors~%")))))
89
+
90
+(defun parse-file (&optional (file *default-time-sheet-file*) ignore-whitespace-errors)
91
+  (flet ((parse-string (string)
92
+           (handler-bind ((tempores.parser::invalid-whitespace
93
+                            (handle-invalid-whitespace ignore-whitespace-errors))
94
+                          (parse-error #'handle-invalid-time)
95
+                          (tempores.parser::invalid-time #'handle-invalid-time) )
96
+             (smug:parse (tempores.parser::.date-records) string))))
97
+    (multiple-value-bind (parsed leftovers) (parse-string (read-file-into-string file))
98
+      (if (or (null leftovers) (string= leftovers ""))
99
+        parsed
100
+        (cerror "Continue?" 'parsing-error :leftovers leftovers)))))
101
+
102
+(defun pprint-results (results incompletes status)
103
+  (print-entries results)
104
+
105
+  (when incompletes
106
+    (format t "~&~120,1,0,'-<~>~%Partial Entries:~%")
107
+    (print-entries incompletes))
108
+
109
+  (when status
110
+    (print-status results)))
111
+
112
+(defun sort-by-date (results)
113
+  (stable-sort results #'local-time:timestamp<
114
+               :key (alambda (apply #'local-time:encode-timestamp
115
+                                    (list* 0 0 0 0 (unroll-date (date it)))))))
116
+
117
+(defun maybe-nreverse (flag list)
118
+  (if flag
119
+    (nreverse list)
120
+    list))
121
+
122
+(define-modify-macro maybe-nreversef (flag)
123
+                     (lambda (place flag)
124
+                       (maybe-nreverse flag place)))
125
+
126
+(defun list-without-nulls (&rest items)
127
+  (loop for item in items
128
+        when item collect item))
129
+
130
+(defun pprint-log (args &key client reverse status ignore-whitespace interactive)
131
+  (labels ((sort-func (client)
132
+             (apply #'compose
133
+                    (list-without-nulls
134
+                      (when reverse #'nreverse)
135
+                      (when client
136
+                        (plambda (stable-sort :1 #'string-lessp :key #'client)))
137
+                      #'sort-by-date)))
138
+           (sort-results (results &optional (client client))
139
+             (funcall (sort-func client) results))
140
+           (get-logs (files)
141
+             (loop for file in (ensure-list files)
142
+                   append (tempores:get-log file ignore-whitespace)) ))
143
+
144
+    (let ((*default-time-sheet-file* (or args *default-time-sheet-file*))
145
+          (*interactive* interactive)
146
+          (*print-pretty* t))
147
+      (let-each (:be *)
148
+        (get-logs *default-time-sheet-file*)
149
+        (group-by-class *)
150
+        (destructuring-bind (complete-ranges incomplete-ranges) *
151
+          (let ((complete-results (sort-results complete-ranges client))
152
+                (incomplete-results (sort-results incomplete-ranges t)))
153
+            (pprint-results complete-results incomplete-results status)))))))
154
+
155
+(defsynopsis (:postfix "TIMESHEETS ...")
156
+  (text :contents "A program for managing logs of hours worked")
157
+  (group (:header "Display options")
158
+         (flag :short-name "s" :long-name "status"
159
+               :description "Print a short summary of work status")
160
+         (flag :short-name "W"
161
+               :long-name "ignore-whitespace"
162
+               :description "Ignore whitespace errors in input")
163
+         (flag :short-name "i" :long-name "interactive"
164
+               :description "Run interactively"))
165
+  (group (:header "Sort options")
166
+         (flag :short-name "r"
167
+               :long-name "reverse"
168
+               :description "Reverse the sort direction")
169
+         (flag :short-name "c"
170
+               :long-name "client"
171
+               :description "Sort records by client"))
172
+  (group (:header "Freshbooks")
173
+         (flag :long-name "post-hours"
174
+               :description "Post hours to freshbooks (requires manual setup of Freshbooks keys)"))
175
+  (group (:header "Self-test options")
176
+         (flag :long-name "run-tests"
177
+               :description "Run the tests")
178
+         (enum :long-name "output-style"
179
+               :description "The kind of output to produce"
180
+               :default-value :normal
181
+               :enum '(:xunit :normal)))
182
+  (group (:header "Generic options")
183
+         (flag :short-name "v" :long-name "version"
184
+               :description "Show the program version")
185
+         (flag :short-name "h" :long-name "help"
186
+               :description "Show this help")))
187
+
188
+(eval-when (:load-toplevel :compile-toplevel :execute)
189
+  (define-message version-message (version)
190
+    (:own-line () "tempores file parser, version " :str)))
191
+
192
+(defun show-version ()
193
+  (version-message t *version*))
194
+
195
+(defun tests-main (&optional (output-style nil output-style-p))
196
+  (let ((should-test:*verbose* t))
197
+    (ecase output-style
198
+      (:xunit (should-test:test-for-xunit *standard-output* :package :tempores.parser))
199
+      (:normal (should-test:test :package :tempores.parser)))))
200
+
201
+(defun pprint-log-main ()
202
+  (make-context)
203
+  (tagbody
204
+    start
205
+    (restart-case
206
+      (cond
207
+        ((getopt :long-name "help") (help))
208
+        ((getopt :long-name "version") (show-version))
209
+        ((getopt :long-name "post-hours") (let ((*print-pretty* nil))
210
+                                            (loop for item in (tempores.freshbooks::post-time-entries-main)
211
+                                                  do (format t "Posted an entry")
212
+                                                  do (plump:serialize item)
213
+                                                  finally (format t "Don't forget to archive time file."))))
214
+        ((getopt :long-name "run-tests") (tests-main (getopt :long-name "output-style")))
215
+        (t (with-tempores-configuration ()
216
+             (pprint-log
217
+               (remainder)
218
+               :client (getopt :long-name "client")
219
+               :interactive (getopt :long-name "interactive")
220
+               :ignore-whitespace (getopt :long-name "ignore-whitespace")
221
+               :status (getopt :long-name "status")
222
+               :reverse (getopt :long-name "reverse")))))
223
+      (retry () (go start))
224
+      (abort ()))))
225
+
226
+(defun make-executable ()
227
+  (dump "tempores" pprint-log-main
228
+        :compression 8
229
+        :purify t))
0 230
new file mode 100644
... ...
@@ -0,0 +1,146 @@
1
+;; tempores.lisp
2
+
3
+(in-package #:tempores)
4
+
5
+;;; "tempores" goes here. Hacks and glory await!
6
+
7
+(defmacro maybe-list (test &optional val)
8
+  "If both arguments passed, when test is true, return a list containing val
9
+   or, when test is false, return nil.  If one argument passed, when test names
10
+   something that is not a list, return a list containing it, otherwise
11
+   return nil."
12
+  (once-only (test)
13
+    (let ((test (if val test `(not (listp ,test))))
14
+          (val (if val val test)))
15
+      `(when ,test
16
+         (list ,val)))))
17
+
18
+(defun combine-date-time (time-obj day month year)
19
+  (declare (optimize (debug 3)))
20
+  (with-slots (second minute hour) time-obj
21
+    (local-time:encode-timestamp 0 second minute hour
22
+                                 day month year)))
23
+
24
+(defun calculate-ranges (ranges date)
25
+  (declare (optimize (debug 3)))
26
+  (labels ((time-mod-unit-keyword (time-mod)
27
+             (make-keyword
28
+               (string-upcase
29
+                 (string-case (string-downcase (slot-value time-mod 'unit))
30
+                   ("mins" "minute")
31
+                   (t      "hour")))))
32
+           (make-mod (mod)
33
+             (when mod
34
+               (let ((unit (time-mod-unit-keyword mod))
35
+                     (amount (slot-value mod 'tempores.parser:amount)))
36
+                 (funcall #'local-time-duration:duration unit amount)))))
37
+    (with-slots (year month day) date
38
+      (loop with complete = nil
39
+        with partial = nil
40
+        for (start-obj end-obj mod) in ranges
41
+        for start = (combine-date-time start-obj day month year)
42
+        for end = (when end-obj (combine-date-time end-obj day month year))
43
+        for time-mod = (when mod (make-mod mod))
44
+        if end do (push (local-time-duration:timestamp-difference end start) complete)
45
+        else do (push start partial)
46
+        when time-mod do (push time-mod complete)
47
+        finally (return (values complete partial))))))
48
+
49
+(defun calculate-duration-in-15mins (duration)
50
+  (let ((duration-in-minutes (local-time-duration:duration-as duration :minute)))
51
+    (coerce (/ (round duration-in-minutes 15) 4)
52
+            'float)))
53
+
54
+(defun calculate-rounded-ranges (ranges)
55
+  (let-each (:be *)
56
+    (local-time-duration:duration)
57
+    (reduce #'local-time-duration:duration+ ranges :initial-value *)
58
+    (calculate-duration-in-15mins *)))
59
+
60
+(defclass log-entry ()
61
+  ((complete :initarg :complete)
62
+   (incomplete :initarg :incomplete)))
63
+
64
+(defun get-entry-ranges (entry)
65
+  (flet ((make-entry (record)
66
+           (let ((date (slot-value entry 'date)))
67
+             (with-slots (client memo ranges) record
68
+               (multiple-value-bind (complete partial) (calculate-ranges ranges date)
69
+                 (list*
70
+                   (make-complete-entry date client memo (calculate-rounded-ranges complete))
71
+                   (maybe-list partial
72
+                               (make-partial-entry date client memo partial))))))))
73
+    (let-each (:be *)
74
+      (slot-value entry 'records)
75
+      (mapcan #'make-entry *))))
76
+
77
+(defun get-log (&optional (file *default-time-sheet-file*) ignore-whitespace)
78
+  (let* ((entries (tempores.cli::parse-file file ignore-whitespace)))
79
+    (mapcan #'get-entry-ranges entries)))
80
+
81
+(defun group-by-client (incompletes)
82
+  (let ((results (make-hash-table :test 'equalp)))
83
+    (loop for incomplete in incompletes
84
+          for client = (client incomplete)
85
+          do (push incomplete (gethash client results)))
86
+    (hash-table-alist results)))
87
+
88
+(defun update-clients (status-calculator entry)
89
+  (flet ((ensure-client (client)
90
+           (ensure-gethash client
91
+                           (client-totals status-calculator)
92
+                           (make-instance 'status-line :client client))))
93
+    (with-accessors ((client client)) entry
94
+      (let ((client-hash-table (ensure-client client)))
95
+        (update client-hash-table entry)))))
96
+
97
+(defun calculate-results (results &optional (rate *rate*))
98
+  (let-first (:be status-calculator) (make-status-calculator rate)
99
+    (dolist (result results)
100
+      (update-clients status-calculator result)
101
+      (update status-calculator result))))
102
+
103
+(define-message status-line-format (client duration rate cost)
104
+  (:own-line ()
105
+   (:titlecase () (:rjust (26) :str))
106
+   ": " (:float 7 2) " hours @ " (:float 7 2) " $/hr = $" (:float 7 2)))
107
+
108
+(defun print-status (results)
109
+  (let* ((status-calculator (calculate-results results))
110
+         (client-totals (client-totals status-calculator)))
111
+    (labels ((print-status-line (status-line)
112
+               (with-slots (client duration) status-line
113
+                 (status-line-format t client duration
114
+                                     (rate status-calculator)
115
+                                     (calculate-cost status-calculator status-line))))
116
+             (print-separator ()
117
+               (format t "~&~120,1,0,'-<~>~%")))
118
+      (let-each (:be *)
119
+        (print-separator)
120
+        (hash-table-keys client-totals)
121
+        (sort * #'string-lessp)
122
+        (dolist (client *)
123
+          (print-status-line (gethash client client-totals)))
124
+        (format t (total-line status-calculator *rate*))))))
125
+
126
+
127
+(defun group-by-class (list &optional accum1 accum2)
128
+  (tagbody ; Let's do some TCO ...
129
+    start
130
+    (if (null list)
131
+      (return-from group-by-class (list accum1 accum2))
132
+      (destructuring-bind (head . tail) list
133
+        (etypecase head
134
+          (complete-entry (setf accum1 (cons head accum1))) ; Here we set the accumulators
135
+          (partial-entry (setf accum2 (cons head accum2)))) ;  to the appropriate values.
136
+        (setf list tail) ; Here we step towards the terminating condition
137
+        (go start))))) ; Recurse
138
+
139
+(defmacro with-tempores-configuration (() &body body)
140
+  `(progn
141
+     (ubiquitous:restore 'tempores)
142
+     (let ((*rate* (ubiquitous:defaulted-value 0 :rate))
143
+           (*default-time-sheet-file*
144
+             (ubiquitous:defaulted-value #p"~/bucket/time.md" :tempores :file)))
145
+       ,@body)))
146
+