Browse code
Aditional changes of various kinds.
fiddlerwoaroof authored on 24/04/2016 05:37:26
Showing 3 changed files
Showing 3 changed files
... | ... |
@@ -28,10 +28,17 @@ |
28 | 28 |
(defpackage #:timesheet |
29 | 29 |
(:use #:cl #:anaphora #:alexandria #:serapeum #:fwoar.lisputils |
30 | 30 |
#:timesheet.parser) |
31 |
- (:import-from #:format-string-builder #:define-message)) |
|
31 |
+ (:import-from #:format-string-builder #:define-message) |
|
32 |
+ (:export #:with-timesheet-configuration #:pprint-log #:get-log #:timesheet |
|
33 |
+ #:*default-time-sheet-file* #:*rate* #:group-by-class #:print-status |
|
34 |
+ #:print-entries #:autocorrect-warning)) |
|
32 | 35 |
|
33 | 36 |
(defpackage #:timesheet.cli |
34 | 37 |
(:use #:cl #:anaphora #:alexandria #:serapeum #:fwoar.lisputils |
35 | 38 |
#:timesheet.parser #:timesheet #:net.didierverna.clon) |
36 | 39 |
(:import-from #:format-string-builder #:define-message)) |
37 | 40 |
|
41 |
+(in-package #:timesheet) |
|
42 |
+ |
|
43 |
+(defvar *default-time-sheet-file*) |
|
44 |
+(defvar *rate*) |
... | ... |
@@ -1,41 +1,191 @@ |
1 |
- |
|
2 | 1 |
(in-package #:timesheet.cli) |
3 | 2 |
|
3 |
+(defparameter *interactive* nil) |
|
4 |
+(defparameter *version* "0:5") |
|
5 |
+ |
|
4 | 6 |
(defsynopsis (:postfix "TIMESHEETS ...") |
5 | 7 |
(text :contents "A program for managing logs of hours worked") |
6 |
- (group (:header "Main actions") |
|
7 |
- (flag :short-name "c" |
|
8 |
- :long-name "client" |
|
9 |
- :description "Sort records by client") |
|
10 |
- (flag :short-name "r" |
|
11 |
- :long-name "reverse" |
|
12 |
- :description "Reverse the sort direction") |
|
8 |
+ (group (:header "Display options") |
|
9 |
+ (flag :short-name "s" :long-name "status" |
|
10 |
+ :description "Print a short summary of work status") |
|
13 | 11 |
(flag :short-name "W" |
14 | 12 |
:long-name "ignore-whitespace" |
15 | 13 |
:description "Ignore whitespace errors in input") |
16 | 14 |
(flag :short-name "i" :long-name "interactive" |
17 |
- :description "Run interactively") |
|
18 |
- (flag :short-name "s" :long-name "status" |
|
19 |
- :description "Print a short summary of work status")) |
|
20 |
- (group (:header "Other options") |
|
15 |
+ :description "Run interactively")) |
|
16 |
+ (group (:header "Sort options") |
|
17 |
+ (flag :short-name "r" |
|
18 |
+ :long-name "reverse" |
|
19 |
+ :description "Reverse the sort direction") |
|
20 |
+ (flag :short-name "c" |
|
21 |
+ :long-name "client" |
|
22 |
+ :description "Sort records by client")) |
|
23 |
+ (group (:header "Generic options") |
|
21 | 24 |
(flag :short-name "v" :long-name "version" |
22 |
- :description "Show the program version") |
|
25 |
+ :description "Show the program version") |
|
23 | 26 |
(flag :short-name "h" :long-name "help" |
24 | 27 |
:description "Show this help"))) |
25 | 28 |
|
29 |
+(define-message version-message (version) |
|
30 |
+ (:own-line () "timesheet file parser, version " :str)) |
|
31 |
+ |
|
32 |
+(defun unroll-date (date-obj) |
|
33 |
+ (with-slots (year month day) date-obj |
|
34 |
+ (list day month year))) |
|
35 |
+ |
|
36 |
+(defun show-version () |
|
37 |
+ (version-message t *version*)) |
|
38 |
+ |
|
39 |
+(defun split-time (time) |
|
40 |
+ (let ((time-parts (split-sequence #\: time))) |
|
41 |
+ (destructuring-bind (hours minutes . optional-seconds) time-parts |
|
42 |
+ (let ((hours (parse-integer hours)) |
|
43 |
+ (minutes (parse-integer minutes)) |
|
44 |
+ (seconds (parse-integer (or (car optional-seconds) "0"))) |
|
45 |
+ (extra (cdr optional-seconds))) |
|
46 |
+ (values hours minutes seconds extra))))) |
|
47 |
+ |
|
48 |
+(defun try-fix-time (failed-time) |
|
49 |
+ (handler-case |
|
50 |
+ (multiple-value-bind (hours minutes seconds extra) (split-time failed-time) |
|
51 |
+ (if (and (< hours 24) (< minutes 60) (< seconds 60) (null extra)) |
|
52 |
+ (values (format nil "~2,'0d:~2,'0d:~2,'0d" hours minutes seconds) t) |
|
53 |
+ (values nil nil))) |
|
54 |
+ (parse-error (c) c (values nil nil)))) |
|
55 |
+ |
|
56 |
+(defun call-with-prompt (stream prompt args cb) |
|
57 |
+ (apply #'format stream prompt args) |
|
58 |
+ (finish-output *query-io*) |
|
59 |
+ (funcall cb (read-line *query-io*))) |
|
60 |
+ |
|
61 |
+(defmacro with-prompt ((result-sym stream prompt &rest args) &body body) |
|
62 |
+ `(call-with-prompt ,stream ,prompt (list ,@args) |
|
63 |
+ (lambda (,result-sym) |
|
64 |
+ ,@body))) |
|
65 |
+ |
|
66 |
+(defun abort-with-message (stream message &rest args) |
|
67 |
+ (apply #'format stream message args) |
|
68 |
+ (abort)) |
|
69 |
+ |
|
70 |
+(define-condition parse-time-error (parse-error) |
|
71 |
+ ((time-string :initarg :time-string :accessor time-string)) |
|
72 |
+ (:report (lambda (condition stream) |
|
73 |
+ (format stream "Time input did not parse correctly: ~s" (time-string condition))))) |
|
74 |
+ |
|
75 |
+(defun call-with-prompt-for-time (stream prompt args cb) |
|
76 |
+ (call-with-prompt |
|
77 |
+ stream prompt args |
|
78 |
+ (lambda (time-string) |
|
79 |
+ (multiple-value-bind (hours minutes seconds extra) (split-time time-string) |
|
80 |
+ (funcall cb hours minutes seconds extra))))) |
|
81 |
+ |
|
82 |
+(defmacro with-prompt-for-time ((result-syms stream prompt &rest args) &body body) |
|
83 |
+ `(call-with-prompt-for-time ,stream ,prompt (list ,@args) |
|
84 |
+ (lambda (,@result-syms) |
|
85 |
+ ,@body))) |
|
86 |
+ |
|
87 |
+(define-message format-time (hours minutes &optional (seconds 0)) |
|
88 |
+ (:decimal 2 '0) #\: (:decimal 2 '0) #\: (:decimal 2 '0)) |
|
89 |
+ |
|
90 |
+(defun handle-invalid-time (c) c |
|
91 |
+ (let ((time (timesheet.parser::failed-chunk c))) |
|
92 |
+ (multiple-value-bind (new-value success) (try-fix-time time) |
|
93 |
+ (when success |
|
94 |
+ (progn (warn 'timesheet::autocorrect-warning |
|
95 |
+ :old-value time |
|
96 |
+ :new-value new-value) |
|
97 |
+ (smug:replace-invalid time new-value)))) |
|
98 |
+ (if *interactive* |
|
99 |
+ (loop |
|
100 |
+ (handler-case |
|
101 |
+ (with-prompt-for-time ((hours minutes seconds &rest rest) |
|
102 |
+ *query-io* "Invalid time ~a, replacement? " time) |
|
103 |
+ (declare (ignore rest)) |
|
104 |
+ (let ((replacement (format-time nil hours minutes seconds))) |
|
105 |
+ (format *query-io* "~&Replacing ~s with ~s.~%---~%" time replacement) |
|
106 |
+ (smug:replace-invalid time replacement))) |
|
107 |
+ (parse-error (c) c (format t "~&Invalid entry.~%")))) |
|
108 |
+ (abort-with-message t "~&Time ~a is invalid.~%" time)))) |
|
109 |
+ |
|
110 |
+(defun handle-invalid-whitespace (ignore-whitespace-errors) |
|
111 |
+ (lambda (c) c |
|
112 |
+ (let ((extra-whitespace (timesheet.parser::failed-chunk c))) |
|
113 |
+ (if (or ignore-whitespace-errors |
|
114 |
+ (when *interactive* |
|
115 |
+ (y-or-n-p "Invalid extra whitespace ~s, truncate?" extra-whitespace))) |
|
116 |
+ (smug:replace-invalid extra-whitespace "") |
|
117 |
+ (abort-with-message t "~&Whitespace errors~%"))))) |
|
118 |
+ |
|
119 |
+(defun parse-file (&optional (file *default-time-sheet-file*) ignore-whitespace-errors) |
|
120 |
+ (flet ((parse-string (string) |
|
121 |
+ (handler-bind ((timesheet.parser::invalid-whitespace |
|
122 |
+ (handle-invalid-whitespace ignore-whitespace-errors)) |
|
123 |
+ (parse-error #'handle-invalid-time) |
|
124 |
+ (timesheet.parser::invalid-time #'handle-invalid-time) ) |
|
125 |
+ (smug:parse (timesheet.parser::.date-records) string)))) |
|
126 |
+ (multiple-value-bind (parsed leftovers) (parse-string (read-file-into-string file)) |
|
127 |
+ (if (or (null leftovers) (string= leftovers "")) |
|
128 |
+ parsed |
|
129 |
+ (cerror "Continue?" 'parsing-error :leftovers leftovers))))) |
|
130 |
+ |
|
131 |
+(defun pprint-results (results incompletes status) |
|
132 |
+ (print-entries results) |
|
133 |
+ |
|
134 |
+ (when incompletes |
|
135 |
+ (format t "~&~120,1,0,'-<~>~%Partial Entries:~%") |
|
136 |
+ (print-entries incompletes)) |
|
137 |
+ |
|
138 |
+ (when status |
|
139 |
+ (print-status results))) |
|
140 |
+ |
|
141 |
+(defun sort-by-date (results) |
|
142 |
+ (stable-sort results #'local-time:timestamp< |
|
143 |
+ :key (alambda (apply #'local-time:encode-timestamp |
|
144 |
+ (list* 0 0 0 0 (unroll-date (date it))))))) |
|
145 |
+ |
|
146 |
+(defun pprint-log (args &key client reverse status ignore-whitespace interactive) |
|
147 |
+ (flet ((sort-results (results &optional (client client)) |
|
148 |
+ (setf results (sort-by-date results)) |
|
149 |
+ (when client |
|
150 |
+ (setf results (stable-sort results #'string-lessp :key #'client))) |
|
151 |
+ (when reverse |
|
152 |
+ (setf results (nreverse results))) |
|
153 |
+ results) |
|
154 |
+ (get-logs (files) |
|
155 |
+ (loop for file in (ensure-list files) |
|
156 |
+ append (timesheet:get-log file ignore-whitespace)) )) |
|
157 |
+ |
|
158 |
+ (let ((*default-time-sheet-file* (or args *default-time-sheet-file*)) |
|
159 |
+ (*interactive* interactive) |
|
160 |
+ (*print-pretty* t)) |
|
161 |
+ (let-each (:be *) |
|
162 |
+ (get-logs *default-time-sheet-file*) |
|
163 |
+ (group-by-class *) |
|
164 |
+ (destructuring-bind (complete-ranges incomplete-ranges) * |
|
165 |
+ (let ((complete-results (sort-results complete-ranges client)) |
|
166 |
+ (incomplete-results (sort-results incomplete-ranges t))) |
|
167 |
+ (pprint-results complete-results incomplete-results status))))))) |
|
168 |
+ |
|
26 | 169 |
(defun pprint-log-main () |
27 | 170 |
(make-context) |
28 |
- (cond |
|
29 |
- ((getopt :long-name "help") (help)) |
|
30 |
- (t (timesheet::with-timesheet-configuration () |
|
31 |
- (timesheet::pprint-log |
|
32 |
- (remainder) |
|
33 |
- :client (getopt :long-name "client") |
|
34 |
- :interactive (getopt :long-name "interactive") |
|
35 |
- :ignore-whitespace (getopt :long-name "ignore-whitespace") |
|
36 |
- :version (getopt :long-name "version") |
|
37 |
- :status (getopt :long-name "status") |
|
38 |
- :reverse (getopt :long-name "reverse")))))) |
|
171 |
+ (tagbody |
|
172 |
+ start |
|
173 |
+ (restart-case |
|
174 |
+ (cond |
|
175 |
+ ((getopt :long-name "help") (help)) |
|
176 |
+ ((getopt :long-name "version") (show-version)) |
|
177 |
+ (t (with-timesheet-configuration () |
|
178 |
+ (pprint-log |
|
179 |
+ (remainder) |
|
180 |
+ :client (getopt :long-name "client") |
|
181 |
+ :interactive (getopt :long-name "interactive") |
|
182 |
+ :ignore-whitespace (getopt :long-name "ignore-whitespace") |
|
183 |
+ :status (getopt :long-name "status") |
|
184 |
+ :reverse (getopt :long-name "reverse"))))) |
|
185 |
+ (retry () (go start)) |
|
186 |
+ (abort ())))) |
|
39 | 187 |
|
40 | 188 |
(defun make-executable () |
41 |
- (dump "timesheet" pprint-log-main)) |
|
189 |
+ (dump "timesheet" pprint-log-main |
|
190 |
+ :compression 8 |
|
191 |
+ :purify t)) |
... | ... |
@@ -20,62 +20,6 @@ |
20 | 20 |
(status-lines :initform nil :accessor :status-lines) |
21 | 21 |
(entries :initform nil :accessor :entries))) |
22 | 22 |
|
23 |
-(defvar *default-time-sheet-file*) |
|
24 |
-(defvar *rate*) |
|
25 |
-(defparameter *interactive* nil) |
|
26 |
- |
|
27 |
-(defun try-fix-time (failed-time) |
|
28 |
- (let ((time-parts (split-sequence #\: failed-time))) |
|
29 |
- (destructuring-bind (hours minutes . optional-seconds) time-parts |
|
30 |
- (let ((hours (parse-integer hours)) |
|
31 |
- (minutes (parse-integer minutes)) |
|
32 |
- (seconds (parse-integer (or (car optional-seconds) "0")))) |
|
33 |
- (if (and (< hours 24) (< minutes 60) (< seconds 60)) |
|
34 |
- (values (format nil "~2,'0d:~2,'0d:~2,'0d" hours minutes seconds) t) |
|
35 |
- (values nil nil)))))) |
|
36 |
- |
|
37 |
-(defun parse-file (&optional (file *default-time-sheet-file*) ignore-whitespace-errors) |
|
38 |
- (flet ((parse-string (string) |
|
39 |
- (handler-bind ((timesheet.parser::invalid-whitespace |
|
40 |
- (lambda (c) c |
|
41 |
- (let ((extra-whitespace (timesheet.parser::failed-chunk c))) |
|
42 |
- (if (or ignore-whitespace-errors |
|
43 |
- (when *interactive* |
|
44 |
- (y-or-n-p "Invalid extra whitespace ~s, truncate?" extra-whitespace))) |
|
45 |
- (smug:replace-invalid extra-whitespace "") |
|
46 |
- (progn (format t "~&Whitespace errors~%") |
|
47 |
- (abort)))))) |
|
48 |
- (timesheet.parser::invalid-time |
|
49 |
- (lambda (c) c |
|
50 |
- (let ((time (timesheet.parser::failed-chunk c))) |
|
51 |
- (multiple-value-bind (new-value success) (try-fix-time time) |
|
52 |
- (when success |
|
53 |
- (progn (warn 'autocorrect-warning |
|
54 |
- :old-value time |
|
55 |
- :new-value new-value) |
|
56 |
- (smug:replace-invalid time new-value))) |
|
57 |
- (if *interactive* |
|
58 |
- (progn |
|
59 |
- (format *query-io* "Invalid time ~a, replacement? " time) |
|
60 |
- (finish-output *query-io*) |
|
61 |
- (let ((replacement (read-line))) |
|
62 |
- (format t "~&Replacing ~s with ~s.~%---~%" time replacement) |
|
63 |
- (smug:replace-invalid time replacement))) |
|
64 |
- (progn |
|
65 |
- (format t "~&Time ~a is invalid.~%" time) |
|
66 |
- (abort)))))))) |
|
67 |
- (smug:parse (timesheet.parser::.date-records) string)))) |
|
68 |
- (multiple-value-bind (parsed leftovers) (parse-string (read-file-into-string file)) |
|
69 |
- (loop |
|
70 |
- (if (or (null leftovers) (string= leftovers "")) |
|
71 |
- (return parsed) |
|
72 |
- (cerror "Continue?" 'parsing-error :leftovers leftovers))) |
|
73 |
- parsed))) |
|
74 |
- |
|
75 |
-(defun unroll-date (date-obj) |
|
76 |
- (with-slots (year month day) date-obj |
|
77 |
- (list day month year))) |
|
78 |
- |
|
79 | 23 |
(defun combine-date-time (time-obj day month year) |
80 | 24 |
(declare (optimize (debug 3))) |
81 | 25 |
(with-slots (second minute hour) time-obj |
... | ... |
@@ -97,15 +41,15 @@ |
97 | 41 |
(funcall #'local-time-duration:duration unit amount))))) |
98 | 42 |
(with-slots (year month day) date |
99 | 43 |
(loop with complete = nil |
100 |
- with partial = nil |
|
101 |
- for (start-obj end-obj mod) in ranges |
|
102 |
- for start = (combine-date-time start-obj day month year) |
|
103 |
- for end = (when end-obj (combine-date-time end-obj day month year)) |
|
104 |
- for time-mod = (when mod (make-mod mod)) |
|
105 |
- if end do (push (local-time-duration:timestamp-difference end start) complete) |
|
106 |
- else do (push start partial) |
|
107 |
- when time-mod do (push time-mod complete) |
|
108 |
- finally (return (values complete partial)))))) |
|
44 |
+ with partial = nil |
|
45 |
+ for (start-obj end-obj mod) in ranges |
|
46 |
+ for start = (combine-date-time start-obj day month year) |
|
47 |
+ for end = (when end-obj (combine-date-time end-obj day month year)) |
|
48 |
+ for time-mod = (when mod (make-mod mod)) |
|
49 |
+ if end do (push (local-time-duration:timestamp-difference end start) complete) |
|
50 |
+ else do (push start partial) |
|
51 |
+ when time-mod do (push time-mod complete) |
|
52 |
+ finally (return (values complete partial)))))) |
|
109 | 53 |
|
110 | 54 |
(defun calculate-duration-in-15mins (duration) |
111 | 55 |
(let ((duration-in-minutes (local-time-duration:duration-as duration :minute))) |
... | ... |
@@ -136,14 +80,9 @@ |
136 | 80 |
(mapcan #'make-entry *)))) |
137 | 81 |
|
138 | 82 |
(defun get-log (&optional (file *default-time-sheet-file*) ignore-whitespace) |
139 |
- (let* ((entries (parse-file file ignore-whitespace))) |
|
83 |
+ (let* ((entries (timesheet.cli::parse-file file ignore-whitespace))) |
|
140 | 84 |
(mapcan #'get-entry-ranges entries))) |
141 | 85 |
|
142 |
-(defun sort-by-date (results) |
|
143 |
- (stable-sort results #'local-time:timestamp< |
|
144 |
- :key (alambda (apply #'local-time:encode-timestamp |
|
145 |
- (list* 0 0 0 0 (unroll-date (date it))))))) |
|
146 |
- |
|
147 | 86 |
(defun group-by-client (incompletes) |
148 | 87 |
(let ((results (make-hash-table :test 'equalp))) |
149 | 88 |
(loop for incomplete in incompletes |
... | ... |
@@ -168,11 +107,12 @@ |
168 | 107 |
|
169 | 108 |
(define-message status-line-format (client duration rate cost) |
170 | 109 |
(:own-line () |
171 |
- (:titlecase () (:rjust (26) :str)) |
|
172 |
- ": " (:float 7 2) " hours @ " (:float 7 2) " $/hr = $" (:float 7 2))) |
|
110 |
+ (:titlecase () (:rjust (26) :str)) |
|
111 |
+ ": " (:float 7 2) " hours @ " (:float 7 2) " $/hr = $" (:float 7 2))) |
|
173 | 112 |
|
174 | 113 |
(defun print-status (results) |
175 |
- (let* ((status-calculator (calculate-results results))) |
|
114 |
+ (let* ((status-calculator (calculate-results results)) |
|
115 |
+ (client-totals (client-totals status-calculator))) |
|
176 | 116 |
(labels ((print-status-line (status-line) |
177 | 117 |
(with-slots (client duration) status-line |
178 | 118 |
(status-line-format t client duration |
... | ... |
@@ -180,24 +120,14 @@ |
180 | 120 |
(calculate-cost status-calculator status-line)))) |
181 | 121 |
(print-separator () |
182 | 122 |
(format t "~&~120,1,0,'-<~>~%"))) |
183 |
- (let ((client-totals (client-totals status-calculator))) |
|
123 |
+ (let-each (:be *) |
|
184 | 124 |
(print-separator) |
185 |
- (let-each (:be *) |
|
186 |
- (hash-table-keys client-totals) |
|
187 |
- (sort * #'string-lessp) |
|
188 |
- (dolist (client *) |
|
189 |
- (print-status-line (gethash client client-totals))))) |
|
190 |
- (format t (total-line status-calculator *rate*))))) |
|
191 |
- |
|
192 |
-(defun pprint-results (results incompletes status) |
|
193 |
- (print-entries results) |
|
125 |
+ (hash-table-keys client-totals) |
|
126 |
+ (sort * #'string-lessp) |
|
127 |
+ (dolist (client *) |
|
128 |
+ (print-status-line (gethash client client-totals))) |
|
129 |
+ (format t (total-line status-calculator *rate*)))))) |
|
194 | 130 |
|
195 |
- (when incompletes |
|
196 |
- (format t "~&~120,1,0,'-<~>~%Partial Entries:~%") |
|
197 |
- (print-entries incompletes)) |
|
198 |
- |
|
199 |
- (when status |
|
200 |
- (print-status results))) |
|
201 | 131 |
|
202 | 132 |
(defun group-by-class (list &optional accum1 accum2) |
203 | 133 |
(tagbody ; Let's do some TCO ... |
... | ... |
@@ -211,37 +141,6 @@ |
211 | 141 |
(setf list tail) ; Here we step towards the terminating condition |
212 | 142 |
(go start))))) ; Recurse |
213 | 143 |
|
214 |
-(defun pprint-log (args &key client reverse status help version ignore-whitespace interactive) |
|
215 |
- (when help |
|
216 |
- (show-help) |
|
217 |
- (return-from pprint-log)) |
|
218 |
- |
|
219 |
- (when version |
|
220 |
- (show-version) |
|
221 |
- (return-from pprint-log)) |
|
222 |
- |
|
223 |
- (flet ((sort-results (results &optional (client client)) |
|
224 |
- (setf results (sort-by-date results)) |
|
225 |
- (when client |
|
226 |
- (setf results (stable-sort results #'string-lessp :key #'client))) |
|
227 |
- (when reverse |
|
228 |
- (setf results (nreverse results))) |
|
229 |
- results) |
|
230 |
- (get-logs (files) |
|
231 |
- (loop for file in (ensure-list files) |
|
232 |
- append (get-log file ignore-whitespace)) )) |
|
233 |
- |
|
234 |
- (let ((*default-time-sheet-file* (or args *default-time-sheet-file*)) |
|
235 |
- (*interactive* interactive) |
|
236 |
- (*print-pretty* t)) |
|
237 |
- (let-each (:be *) |
|
238 |
- (get-logs *default-time-sheet-file*) |
|
239 |
- (group-by-class *) |
|
240 |
- (destructuring-bind (complete-ranges incomplete-ranges) * |
|
241 |
- (let ((complete-results (sort-results complete-ranges client)) |
|
242 |
- (incomplete-results (sort-results incomplete-ranges t))) |
|
243 |
- (pprint-results complete-results incomplete-results status))))))) |
|
244 |
- |
|
245 | 144 |
(defmacro with-timesheet-configuration (() &body body) |
246 | 145 |
`(progn |
247 | 146 |
(ubiquitous:restore 'timesheet) |