#!/usr/bin/guile !# ;; Copyright (c) 2014 Nikolay Puzanov ;; Permission is hereby granted, free of charge, to any person obtaining a copy ;; of this software and associated documentation files (the "Software"), to deal ;; in the Software without restriction, including without limitation the rights ;; to use, copy, modify, merge, publish, distribute, sublicense, and/or sell ;; copies of the Software, and to permit persons to whom the Software is ;; furnished to do so, subject to the following conditions: ;; The above copyright notice and this permission notice shall be included in ;; all copies or substantial portions of the Software. ;; THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR ;; IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, ;; FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE ;; AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER ;; LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, ;; OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN ;; THE SOFTWARE. ;; -*- geiser-scheme-implementation: guile -*- (import (rnrs io ports (6)) (srfi srfi-1) (srfi srfi-19) (srfi srfi-11)) ;;; Set locale according to environment settings (setlocale LC_ALL "") ;;; Use srfi-48 in other scheme implementation (use-modules (ice-9 format)) ;;; Record format: ;;; PROJECT/TASK/SUBTASK/ETC: [START_TIME] - [STOP_TIME] - DURATION ;;; Hardcoded timesheet filename (define ts-file (string-append (getenv "HOME") "/.timesheet")) (define date-time-format "~Y-~m-~d ~H:~M:~S") (define date-format "~Y-~m-~d") ;;; ========================= COMMON HELPER FUNCTIONS ========================== ;;; Find substring separated by ch-start and ch-end. ;;; (substring/find str ch-start ch-end [begin end]) ;;; If ch-start is #f, select substring from beginning of string. ;;; If ch-end is #f, select substring from ch-start to end string. (define (substring/find str ch-start ch-end . args) (let ((start (if (null? args) 0 (car args))) (end (if (< (length args) 2) (string-length str) (cadr args)))) (let loop ((strings '()) (start start)) (let ((ce (if ch-end (string-index str ch-end start end) end))) (if (not ce) (reverse strings) (let ((cs (if ch-start (string-index str ch-start start ce) (- start 1)))) (if (not cs) (reverse strings) (loop (cons (substring str (1+ cs) ce) strings) (1+ ce))))))))) ;;; Remove duplicates in list (define (remove-dup list) (reverse (fold (lambda (x l) (if (or (null? l) (not (equal? (car l) x))) (cons x l) l)) '() list))) ;;; Convert time to string "hh:mm:ss" (define (time->string td) (let ((sec (time-second td))) (let* ((h (quotient sec 3600)) (m (quotient (- sec (* h 3600)) 60)) (s (- sec (* h 3600) (* m 60)))) (string-append (format #f "~2,'0d" h) ":" (format #f "~2,'0d" m) ":" (format #f "~2,'0d" s))))) ;;; Convert string "hh:mm:ss" to time (define (string->time str) (let ((splitted (string-split str #\:))) (if (not (= (length splitted) 3)) (throw 'bad-time-string "Bad time format (expected hh:mm:ss)" str) (let ((h (string->number (car splitted))) (m (string->number (cadr splitted))) (s (string->number (caddr splitted)))) (if (and (and h m s) (< m 60) (< s 60)) (make-time 'time-duration 0 (+ (* h 3600) (* m 60) s)) (throw 'bad-time-string "Bad time string" str)))))) ;;; Convert string to date or time ;;; Time is "hh:mm:ss", date is date-format (define (string->date/time str) (if (string-any #\: str) (string->time str) (string->date str date-format))) ;;; Convert date or time to string (define (date/time->string time) (if (date? time) (date->string time date-format) (time->string time))) ;;; Convert path to string (define (path->string path) (let loop ((path path) (str "")) (if (null? path) str (loop (cdr path) (string-append str (car path) (if (null? (cdr path)) "" "/")))))) ;;; Split path to separate elements ;;; (path-split str [start end]) (define (path-split str . args) (let ((start (if (null? args) 0 (car args))) (end (if (or (null? args) (null? (cdr args))) (string-length str) (cadr args)))) (let loop ((path '()) (path-start start)) (let ((item-end (string-index str #\/ path-start end))) (if (not item-end) (reverse (cons (substring str path-start end) path)) (loop (cons (substring str path-start item-end) path) (1+ item-end))))))) ;;; Compare paths (define (pathtime-utc a)) (tb (date->time-utc b))) (timetime-utc d1) (date->time-utc d2))) ;;; Dates is in same day? (define (same-month? d1 d2) (and (= (date-month d1) (date-month d2)) (= (date-year d1) (date-year d2)))) ;;; Dates is in same month? (define (same-day? d1 d2) (and (same-month? d1 d2) (= (date-day d1) (date-day d2)))) ;;; Check date for occurence between d-past and d-future (define (date-in-range? d d-past d-future) (let ((dt (date->time-utc d))) (and (time>=? dt (date->time-utc d-past)) (time<=? dt (date->time-utc d-future))))) ;;; Truncate hours, minutes and seconds (define (date-round-day date) (make-date 0 0 0 0 (date-day date) (date-month date) (date-year date) (date-zone-offset date))) ;;; Returns remainder plus fractional part of ;;; truncating x. ;;; For example: (remainder-and-rest 5.1 3) -> 2.1 (define (remainder-and-rest x y) (let ((xi (truncate x))) (+ (- x xi) (remainder xi y)))) ;;; Returns monday of current week (define (monday-of-week date) (date-round-day (julian-day->date (let ((jd (date->julian-day date))) (- jd (remainder-and-rest jd 7)))))) ;;; Returns monday of next week (define (monday-of-next-week date) (date-round-day (julian-day->date (let ((jd (date->julian-day date))) (+ jd (- 7 (remainder-and-rest jd 7))))))) ;;; Returns n-th element of list l, or NUL if list is shorter than n (define (nth-maybe n l) (if (null? l) '() (if (zero? n) (car l) (nth-maybe (- n 1) (cdr l))))) ;;; ========================= PROJECT SPECIFIC HELPERS ========================= ;;; Find task or deadline by path (define (find-by-path sheet path) (find (lambda (x) (equal? (car x) path)) sheet)) ;;; Compare deadlines by path (define (deadlinestring (car x))) (apply append (cons records more))) stringdate (car dates) date-time-format))) (let-values (((date-end duration) (if (null? (cdr dates)) (values #f #f) (let* ((date-end (string->date (cadr dates) date-time-format)) (duration (time-difference (date->time-utc date-end) (date->time-utc date-start)))) (values date-end duration))))) (list path date-start date-end duration)))))))) ;;; Deadline file format ;;; PROJECT/TASK/ETC: DATE|TIME ;;; Returns list '((list of path elements) date|time) (define (parse-deadline-string str) (let ((colon (string-index str #\:))) (if (not colon) #f (let ((path (path-split (string-trim (substring str 0 colon)))) (time (string-trim-both (substring str (1+ colon) (string-length str))))) (if (string-null? time) #f (let ((time (string->date/time time))) (list path time))))))) ;;; Read timesheet and deadlines (define (read-timesheet filename) (if (file-exists? filename) (call-with-input-file filename (lambda (port) (let loop ((record-type 'unknown) (timerecords '()) (deadlines '())) (let ((line (get-line port))) (if (eof-object? line) (values (remove-dup (sort timerecords timerecordstring (car dline)) (date/time->string (cadr dline)))) ;;; Print timesheet record (define (print-timerecord task . now) (let ((path (car task)) (sdate (cadr task)) (edate (caddr task)) (duration (cadddr task)) (now (if (null? now) #f (car now)))) (let-values (((edate duration) (if edate (values edate duration) (if now (values now (date-difference now sdate)) (values #f #f))))) (format #t "~a: [~a]" (path->string path) (date->string sdate date-time-format)) (if edate (format #t " - [~a] - ~a\n" (date->string edate date-time-format) (time->string duration)) (newline))))) ;;; Print timerecords (define (print-timerecords timesheet) (for-each print-timerecord (sort timesheet timerecordstring (cadr tree)) (let ((deadline (caddr tree))) (cond ;; deadline is date ((date? deadline) (string-append " -- deadline at " (date/time->string deadline) (if (datestring deadline) (if (timestring (time-difference deadline (cadr tree))) ")")))) ;; no deadline (else "")))) (for-each (lambda (l) (walk l (+ level 2))) (cdddr tree))))) ;;; Filter sheet by qualis and range. ;;; Qualis is string "day", "week", "month" or task name. ;;; Range is the date string. Both qualis and range may be nil. ;;; ;;; Function returns values of filtered sheet and string ;;; with description of filtering range. (define (filter-sheet sheet qualis range) (if (null? qualis) (values sheet '()) (let ((date (catch #t (lambda () (string->date range date-format)) (lambda (key . args) (current-date))))) (let-values (((description filter-lambda) (cond ;; Filter records by day ((string-ci= qualis "day") (values (format #f "DAY ~a" (date->string date "~Y-~m-~d")) (lambda (x) (same-day? date (cadr x))))) ;; Filter records by month ((string-ci= qualis "month") (values (format #f "MONTH ~a" (date->string date "~Y-~m")) (lambda (x) (same-month? date (cadr x))))) ;; Filter records by week ((string-ci= qualis "week") (let ((beg (monday-of-week date)) (end (monday-of-next-week date))) (values (format #f "WEEK [~a - ~a)" (date->string beg date-format) (date->string end date-format)) (lambda (x) (date-in-range? (cadr x) beg end))))) ;; Filter records by path (else (values (format #f "PROJECT ~a" qualis) (let ((rep-path (path-split qualis))) (lambda (x) (let loop ((path (car x)) (rep-path rep-path)) (if (or (null? path) (null? rep-path)) #t (if (string-ci= (car path) (car rep-path)) (loop (cdr path) (cdr rep-path)) #f)))))))))) (values (filter filter-lambda sheet) description))))) ;;; ================================ COMMANDS ================================== ;;; Start new task. Returns new sheet with started task or #f if nothing started. (define (cmd-start-task sheet deadlines . params) (let* ((last (last-task sheet)) (path (if (null? params) (if last (car last) #f) (path-split (car params)))) (now (current-date))) (if (not path) (begin (format (current-error-port) "Not specified task path. No tasks in the sheet.\n") (values #f #f)) (begin (stop-task last) (let-values (((sheet task) (new-task sheet path))) (format #t "--- NEW TASK RUN\n") (print-timerecord task) (values sheet deadlines)))))) ;;; Stop a running task. Returns new sheet or #f if nothing to stop. (define (cmd-stop-task sheet deadlines . params) (let ((last (last-task sheet))) (if (stop-task last) (begin (format #t "--- STOP TASK\n") (print-timerecord last) (values sheet deadlines)) (begin (format (current-error-port) "Nothing to stop\n") (values #f #f))))) ;;; Print report (define (cmd-report sheet deadlines . params) (format #t "--- REPORT") (let ((qualis (nth-maybe 0 params)) (range (nth-maybe 1 params))) (let-values (((sheet description) (filter-sheet sheet qualis range))) (when (not (null? description)) (display ". ") (display description)) (newline) (print-report (add-deadlines-to-report! (make-report sheet) deadlines)))) (let ((last (last-task sheet))) (when last (format #t "\n--- ~a TASK\n" (if (caddr last) "LAST STOPPED" "RUNNING")) (print-timerecord last (current-date)))) (values #f #f)) ;;; Print all tasks (define (cmd-tasklist sheet deadlines . unused) (format #t "~{~a ~}\n" (record-path-list sheet deadlines)) (values #f #f)) ;;; Print deadlines (define (cmd-deadlist sheet deadlines . unused) (format #t "~{~a ~}\n" (record-name-list deadlines)) (values #f #f)) ;;; Deadlines (define (cmd-deadline sheet deadlines . args) (let ((deadlines (let* ((arg0 (if (null? args) #f (car args))) (arg1 (if (and arg0 (not (null? (cdr args)))) (cadr args) #f)) (arg2 (if (and arg1 (not (null? (cddr args)))) (caddr args) #f)) (last (last-task sheet))) (cond ;; Add deadline ((equal? arg0 "set") (if (not arg1) (begin (format #t "Not specified date/time of deadline\n") #f) (let-values (((task time) (if arg2 (values (path-split arg1) (string->date/time arg2)) (values (if last (car last) #f) (string->date/time arg1))))) (if (not task) (begin (format #t "Not specified task path.\n") #f) (let ((dl (find-by-path deadlines task))) (if dl (begin (set-car! (cdr dl) time) deadlines) (append deadlines (list (list task time))))))))) ;; Delete deadline ((equal? arg0 "clear") (call/cc (lambda (break-del) (let ((task (if arg1 (path-split arg1) (if (null? last) (begin (format #t "Not specified task path.\n") (break-del #f)) (car last))))) (fold-right (lambda (x l) (if (equal? (car x) task) (begin (format #t "Deleted ~a: ~a\n" (path->string (car x)) (date/time->string (cadr x))) l) (cons x l))) '() deadlines))))) ;; Show all deadlines ((equal? arg0 "all") (print-deadlines deadlines) #f) ;; Show deadline for task (else (let ((task (if arg0 (path-split arg0) (if (null? last) (begin (format #t "--- ALL DEADLINES\n") `()) (car last))))) (for-each (lambda (dl) (when (let loop ((p1 task) (p2 (car dl))) (if (null? p1) #t (if (null? p2) #f (if (not (string=? (car p1) (car p2))) #f (loop (cdr p1) (cdr p2)))))) (print-deadline dl))) deadlines)) #f))))) (if deadlines (values sheet deadlines) (values #f #f)))) ;;; Events (define (cmd-timesheet sheet deadlines . params) (format #t "--- TIMESHEET") (let ((qualis (nth-maybe 0 params)) (range (nth-maybe 1 params))) (let-values (((sheet description) (filter-sheet sheet qualis range))) (when (not (null? description)) (display ". ") (display description)) (newline) (print-timerecords sheet))) (values #f #f)) ;;; ================================ MAIN FUNCTION ================================== (define (main cmdl) (let ((command (cdr cmdl))) (let-values (((sheet deadlines) (read-timesheet ts-file))) (if (null? command) ;; Show running task (let ((last (last-task sheet))) (if (and last (not (caddr last))) (let* ((path (car last)) (timer (date-difference (current-date) (cadr last)))) (format #t "~a: ~a ~a\n" (path->string path) (time->string timer) ;; Print deadline (let ((deadline (find-by-path deadlines path))) (if (and deadline (cadr deadline)) (let ((deadtime (cadr deadline))) (string-append "(" (if (or (and (date? deadtime) (datestring deadtime)) ")")) "")))) (format #t "NO TASKS\n"))) ;; Else run command (let ((param (cdr command)) (command (car command))) (let-values (((sheet' deadlines') (apply (cond ((string= command "start") cmd-start-task) ((string= command "stop") cmd-stop-task) ((string= command "report") cmd-report) ((string= command "refresh") (lambda (s d . p) (values s d))) ((string= command "deadline") cmd-deadline) ((string= command "timesheet") cmd-timesheet) ;; Service commands ((string= command "tasklist") cmd-tasklist) ((string= command "deadlist") cmd-deadlist) ;; ----------------------- Show usage ------------------------- ;; (else (with-output-to-port (current-error-port) (lambda () (format #t "Usage: ~a [command]\n" (car cmdl)) (format #t "Commands:\n") (format #t " start [TASK] Start new task. If no task, use last runned task\n") (format #t " stop Stop task\n") (format #t " report Show report\n") (format #t " report day [DATE] Show report for today or DATE\n") (format #t " report week [DATE] Show report for current week or week of DATE\n") (format #t " report month [DATE] Show report for current month or month of DATE\n") (format #t " report TASK Show report for project\n") (format #t " deadline set [TASK] DATE|TIME Add deadline for project (or for last task)\n") (format #t " deadline clear [TASK] Remove deadline for project (or for last task)\n") (format #t " deadline [TASK] Show deadline for project\n") (format #t " deadline all Show all deadlines\n") (format #t " timesheet Show all raw events\n") (format #t " timesheet day [DATE] Show raw events for today or DATE\n") (format #t " timesheet week [DATE] Show raw events for current week or week of DATE\n") (format #t " timesheet month [DATE] Show raw events for current month or month of DATE\n") (format #t " timesheet TASK Show raw events\n") (format #t " refresh Refresh worksheet file after manual edit\n") (format #t " (no command) Show running task and timer\n\n") (format #t "DATE format: YYYY-mm-dd\n") (format #t "TIME format: HH:MM:SS\n") (newline))) (lambda (s d . p) (values #f #f)))) (cons* sheet deadlines param)))) ;; ----------------------- Save new sheet ------------------------- ;; (when (and (list? sheet') (not (null? sheet'))) (with-output-to-file ts-file (lambda () (print-timesheet sheet' deadlines')))))))))) ;;; JUST DO IT! (main (command-line))