Browse code
Adding some missing files
fiddlerwoaroof authored on 28/04/2016 02:41:58
Showing 2 changed files
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 |
+ |