-
Notifications
You must be signed in to change notification settings - Fork 3
/
reaper.el
840 lines (736 loc) · 33.4 KB
/
reaper.el
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
;;; reaper.el --- Interact with Harvest time tracking app -*- lexical-binding: t; -*-
;; Copyright (C) 2019 Thomas Fini Hansen
;; Author: Thomas Fini Hansen <xen@xen.dk>
;; Created: August 11, 2019
;; Version: 1.0.0
;; Package-Requires: ((emacs "26.2"))
;; Keywords: tools
;; Url: https://github.com/xendk/reaper
;; This program is free software; you can redistribute it and/or modify
;; it under the terms of the GNU General Public License as published by
;; the Free Software Foundation, either version 3 of the License, or
;; (at your option) any later version.
;; This program is distributed in the hope that it will be useful,
;; but WITHOUT ANY WARRANTY; without even the implied warranty of
;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
;; GNU General Public License for more details.
;; You should have received a copy of the GNU General Public License
;; along with this program. If not, see <https://www.gnu.org/licenses/>.
;;; Commentary:
;; Interactive tool for tracking time with Harvest.
(require 'cl-lib)
(require 'json)
(require 'url)
;; Declare what we're using from elsewhere.
(declare-function ivy-read "ivy")
(defvar calc-eval-error)
(defgroup reaper nil
"Reaper configuration."
:prefix "reaper-"
:group 'communication)
(defcustom reaper-api-key ""
"API key used to authenticate with Harvest."
:type 'string
:group 'reaper)
(defcustom reaper-account-id ""
"Account id for Harvest."
:type 'string
:group 'reaper)
(defconst reaper--list-format
[("Project" 32 nil)
("Task" 20 nil)
("Time" 5 nil)
("Note" 40 nil)
]
"Reaper list format.")
(defconst reaper--time-regexp
(rx (optional (group-n 1 (optional digit)) ":") (group-n 2 (one-or-more digit))))
;;; Code:
(defvar reaper-buffer-name " *Reaper*"
"Name for Reaper buffer.")
(defvar reaper-total-hours 0
"Total hours tracked on the currently selected day in the reaper buffer.")
(defvar reaper-running-hours 0
"Hours tracked on the currently running timer.")
(defvar reaper-autofile-functions nil
"Functions to automatically categorize entries from notes.
Each function is called with one argument NOTE which is the user
supplied note. If one of these functions returns a cons
of (project_id . task_id), those are used and the rest are not
called.")
(defvar-local reaper-user-id nil
"Cached id of user.")
(defvar-local reaper-timeentries nil
"Cache of Harvest time entries.")
(defvar-local reaper-timeentries-loading nil
"Whether we're currently fetching time entries.")
(defvar-local reaper-project-tasks nil
"Cache of projects and tasks.")
(defvar-local reaper-last-marked-entry nil
"ID of the time entry point was on before last fetch. Used to
relocate point to the same entry after redisplaying.")
(defvar-local reaper-running-timer nil
"Currently running timer.")
(defvar-local reaper-fetch-time nil
"Timestamp of last fetch.")
(defvar-local reaper-date nil
"Date displayed in Reaper buffer.")
(defvar-local reaper-update-timer nil
"Timer for updating buffer.")
(defvar reaper-mode-map
(let ((map (make-sparse-keymap)))
(define-key map (kbd "r") #'reaper-refresh-buffer)
(define-key map (kbd "g") #'reaper-refresh)
(define-key map (kbd "d") #'reaper-goto-date)
(define-key map (kbd "f") #'reaper-goto-date+1)
(define-key map (kbd "b") #'reaper-goto-date-1)
(define-key map (kbd "SPC") #'reaper-start-timer)
(define-key map (kbd "RET") #'reaper-start-timer-and-quit-window)
(define-key map (kbd "c") #'reaper-start-new-timer)
(define-key map (kbd "s") #'reaper-stop-timer)
(define-key map (kbd "k") #'reaper-delete-entry)
(define-key map (kbd "e e") #'reaper-edit-entry)
(define-key map (kbd "e p") #'reaper-edit-entry-project)
(define-key map (kbd "e t") #'reaper-edit-entry-task)
(define-key map (kbd "e d") #'reaper-edit-entry-description)
(define-key map (kbd "t") #'reaper-edit-entry-time)
(define-key map (kbd "DEL") #'reaper-delete-entry)
(define-key map (kbd "Q") #'reaper-kill-buffer)
(define-key map (kbd "!") #'reaper-clear-project-tasks)
map)
"Keymap for Harvest mode.")
(defmacro reaper-get-entry (id)
"Get the time entry with ID."
`(cdr (assoc ,id reaper-timeentries)))
(defmacro reaper-entry-id (entry)
"Get id of time ENTRY."
`(cdr (assoc :id ,entry)))
(defmacro reaper-entry-project-id (entry)
"Get project id of time ENTRY."
`(cdr (assoc :project_id ,entry)))
(defmacro reaper-entry-project (entry)
"Get project name of time ENTRY."
`(cdr (assoc :project ,entry)))
(defmacro reaper-entry-task-id (entry)
"Get task id of time ENTRY."
`(cdr (assoc :task_id ,entry)))
(defmacro reaper-entry-task (entry)
"Get task name of time ENTRY."
`(cdr (assoc :task ,entry)))
(defmacro reaper-entry-is-running (entry)
"Get whether time ENTRY is running."
`(cdr (assoc :is_running ,entry)))
(defmacro reaper-entry-hours (entry)
"Get hours of time ENTRY."
`(cdr (assoc :hours ,entry)))
(defmacro reaper-entry-notes (entry)
"Get notes of time ENTRY."
`(cdr (assoc :notes ,entry)))
(defmacro reaper-with-selected-entry (&rest body)
"Run BODY with the time entry at point as ENTRY."
`(let ((entry (reaper-get-entry (tabulated-list-get-id))))
(when entry
,@body)))
(defmacro reaper-get-project (id)
"Get the project with ID."
`(cdr (assoc ,id reaper-project-tasks)))
(defmacro reaper-get-head-project ()
"Get the \"head\" project, that's either the the last used, or the first."
`(cdr (car reaper-project-tasks)))
(defmacro reaper-project-id (project)
"Get id of PROJECT."
`(cdr (assoc :id ,project)))
(defmacro reaper-project-code (project)
"Get code of PROJECT."
`(cdr (assoc :code ,project)))
(defmacro reaper-project-name (project)
"Get name of PROJECT."
`(cdr (assoc :name ,project)))
(defmacro reaper-project-tasks (project)
"Get tasks of PROJECT."
`(cdr (assoc :tasks ,project)))
(define-derived-mode reaper-mode tabulated-list-mode "Reaper"
"Major mode for Reaper buffer.
\\<reaper-mode-map>
"
:group 'reaper
:syntax-table nil
:abbrev-table nil
(buffer-disable-undo)
(kill-all-local-variables)
(use-local-map reaper-mode-map)
(setq buffer-read-only t
truncate-lines t
;; Start with current date.
reaper-date (format-time-string "%Y-%m-%d")
;; In special-mode, these get set up automatically, but in
;; tabulated-list-mode we have to do it ourselves.
mode-name "Reaper"
major-mode 'reaper-mode
tabulated-list-format reaper--list-format
tabulated-list-entries #'reaper--list-entries
tabulated-list-padding 3)
(reaper-refresh-buffer)
;; Start a timer to update the running timer.
(setq reaper-update-timer (run-at-time t 60 #'reaper--update-timer))
(add-hook 'kill-buffer-hook #'reaper-kill-buffer-hook))
(defmacro reaper-with-buffer (&rest body)
"Run BODY with the Reaper buffer as current."
`(with-current-buffer (reaper--buffer)
,@body))
;;;###autoload
(defun reaper ()
"Open Reaper buffer."
(interactive)
(reaper--check-credentials)
(reaper-with-buffer
(pop-to-buffer (current-buffer))))
(defun reaper-get-running-timer-note ()
"Return the note (description) of the current running timer.
If no timer is running, return nil."
(reaper-with-buffer
(when reaper-running-timer
(let ((entry (reaper-get-entry reaper-running-timer)))
(when entry
(reaper-entry-notes entry))))))
(defun reaper-insert-project-id ()
"Prompt for a project and insert the id of the selected project into the current buffer."
(interactive)
(reaper-ensure-project-tasks)
(let ((project (reaper-with-buffer (reaper-read-project (reaper-project-id (reaper-get-head-project))))))
(when project
(insert (number-to-string (reaper-project-id project))))))
(defun reaper-insert-task-id ()
"Prompt for a project and task, and insert the id of the selected task of project into the current buffer."
(interactive)
(reaper-ensure-project-tasks)
(let* ((project (reaper-with-buffer (reaper-read-project (reaper-project-id (reaper-get-head-project)))))
(task-id (reaper-read-task project (car (car (reaper-project-tasks project))))))
(when task-id
(insert (number-to-string task-id)))))
(defun reaper-kill-buffer-hook ()
"Cancel running timers when the buffer gets killed."
(when reaper-update-timer
(cancel-timer reaper-update-timer)
(setq reaper-update-timer nil)))
(defun reaper-refresh ()
"Refresh data from Harvest and update buffer."
(interactive)
(reaper-with-buffer
(setq reaper-timeentries nil)
(reaper-refresh-buffer)))
(defun reaper-refresh-entries ()
"Fetch time-entries from Harvest."
(reaper--check-credentials)
(unless reaper-timeentries-loading
(reaper-with-buffer
;; Remember which entry point was on.
(setq reaper-last-marked-entry (tabulated-list-get-id))
(setq reaper-timeentries-loading t)
(unless reaper-user-id
(setq reaper-user-id (reaper--get-user-id)))
(reaper-api-async "GET"
(format "time_entries?from=%s&to=%s&user_id=%s" reaper-date reaper-date reaper-user-id)
nil
'reaper--update-entries))))
(defun reaper--update-entries (data)
"Update `reaper--timeentries' with DATA."
(let ((request-time (current-time))
(response-entries (reaper-alist-get '(time_entries) data)))
(reaper-with-buffer
(setq reaper-running-timer nil)
(setq reaper-timeentries
(mapcar
(lambda (entry)
(when (reaper-alist-get '(is_running) entry)
(setq reaper-running-timer (reaper-alist-get '(id) entry)))
(cons (reaper-alist-get '(id) entry)
(list
(cons :id (reaper-alist-get '(id) entry))
(cons :project_id (reaper-alist-get '(project id) entry))
(cons :project (reaper-alist-get '(project name) entry))
(cons :task_id (reaper-alist-get '(task id) entry))
(cons :task (reaper-alist-get '(task name) entry))
(cons :is_running (reaper-alist-get '(is_running) entry))
(cons :hours (reaper-alist-get '(hours) entry))
(cons :notes (let ((notes (reaper-alist-get '(notes) entry)))
(if notes (decode-coding-string notes 'utf-8) ""))))))
;; API returns newest entry first. Simply reverse the list.
(reverse response-entries)))
(setq reaper-fetch-time request-time)
(setq reaper-timeentries-loading nil)
(reaper-update-buffer))))
(defun reaper-clear-project-tasks ()
"Clear cached projects and tasks."
(interactive)
(setq reaper-project-tasks nil))
(defun reaper-ensure-project-tasks ()
"Ensure that we have project and tasks fetched."
(unless (bound-and-true-p reaper-project-tasks)
(reaper-refresh-project-tasks)))
(defun reaper-refresh-project-tasks ()
"Fetch projects and tasks from Harvest."
(interactive)
(reaper-with-buffer
(unless reaper-project-tasks
(message "Refreshing projects and tasks from Harvest, please hold.")
(let ((reaper-project-tasks-response
(reaper-alist-get '(project_assignments)
(reaper-api "GET"
"users/me/project_assignments"
nil
"Refreshed projects and tasks."))))
(setq reaper-project-tasks
(mapcar
(lambda (entry)
(cons (reaper-alist-get '(project id) entry)
(list
(cons :id (reaper-alist-get '(project id) entry))
(cons :name (reaper-alist-get '(project name) entry))
(cons :code (reaper-alist-get '(project code) entry))
(cons :client (reaper-alist-get '(client name) entry))
(cons :tasks (mapcar
(lambda (task)
(cons (reaper-alist-get '(task id) task) (reaper-alist-get '(task name) task)))
(reaper-alist-get '(task_assignments) entry))))))
reaper-project-tasks-response))))))
(defun reaper-refresh-buffer ()
"Refresh Reaper buffer."
(interactive)
(unless (and (bound-and-true-p reaper-timeentries) (not reaper-timeentries-loading))
(reaper-refresh-entries))
(reaper-update-buffer))
(defun reaper-update-buffer ()
"Update Reaper buffer."
(interactive)
(tabulated-list-init-header)
(tabulated-list-print t)
(reaper--highlight-running-and-move-point))
(defun reaper--goto-date (date-string)
"Go to a date.
DATE-STRING can be given in ISO like format, year-month-day, with
- or . as a seperator.
Year and month can be left out and are assumed to be current,
unless the day number is equal or greater than todays date, in
which case the month is the previous.
Alternatively +<days>/-<days> can be used to move X days
forward/back from reaper-date."
(let* ((date (reaper--parse-date-string date-string)))
(if date
(progn
(setq reaper-date date)
(reaper-refresh))
(user-error "Invalid date %s" date-string))))
(defun reaper-goto-date ()
"Go to new date.
Dates are given in ISO like format, year-month-day, with - or .
as a seperator.
Year and month can be left out and are assumed to be current,
unless the day number is equal or greater than todays date, in
which case the month is the previous.
Alternatively +<days>/-<days> can be used to move X days
forward/back from reaper-date."
(interactive)
(reaper--goto-date (read-string "Goto date: ")))
(defun reaper-goto-date+1 ()
"Go a day forward."
(interactive)
(reaper--goto-date "+1"))
(defun reaper-goto-date-1 ()
"Go a day back."
(interactive)
(reaper--goto-date "-1"))
(defun reaper-start-timer ()
"Start the timer at point.
Stops any previously running timers."
(interactive)
(when (tabulated-list-get-id)
(reaper-api "PATCH" (format "time_entries/%s/restart" (tabulated-list-get-id)) nil "Started timer")
(reaper-refresh)))
(defun reaper-stop-timer ()
"Stop running timer."
(interactive)
(when reaper-running-timer
(reaper-api "PATCH" (format "time_entries/%s/stop" reaper-running-timer) nil "Stopped timer")
(reaper-refresh)))
(defun reaper-start-timer-and-quit-window ()
"Start timer at point and close window."
(interactive)
(reaper-start-timer)
(quit-window))
(defun reaper-start-new-timer ()
"Create a new running timer."
(interactive)
(reaper-ensure-project-tasks)
(let* ((notes (read-string "Description: "))
(autofile (run-hook-with-args-until-success 'reaper-autofile-functions notes))
(project
(or (and (consp autofile) (reaper-get-project (car autofile)))
(reaper-read-project (reaper-project-id (reaper-get-head-project)))))
(task-id (or (and (consp autofile) (when (assoc (cdr autofile) (reaper-project-tasks project))
(cdr autofile)))
(reaper-read-task project (car (car (reaper-project-tasks project))))))
(harvest-payload (make-hash-table :test 'equal)))
(puthash "project_id" (reaper-project-id project) harvest-payload)
(puthash "task_id" task-id harvest-payload)
(puthash "spent_date" (format-time-string "%Y-%m-%d") harvest-payload)
(puthash "notes" notes harvest-payload)
(reaper-api "POST" "time_entries" harvest-payload "Started timer")
(reaper--last-used project task-id)
(reaper-refresh)))
(defun reaper-delete-entry ()
"Delete time entry at point."
(interactive)
(reaper-with-selected-entry
(when (yes-or-no-p (format "Are you sure you want to delete \"%s - %s: %s"
(reaper-entry-project entry)
(reaper-entry-task entry)
(reaper-entry-notes entry)))
;; Go forward a line, so tabulated-list-mode has an entry to
;; stick to.
(forward-line 1)
(unless (tabulated-list-get-id)
;; If there's no entry on the following line, go back to the
;; previous instead.
(forward-line -2))
(reaper-api "DELETE" (format "time_entries/%s" (reaper-entry-id entry)) nil "Deleted entry")
(reaper-refresh))))
(defun reaper-kill-buffer ()
"Kill reaper buffer. Will remove timers and cached data."
(interactive)
(kill-buffer reaper-buffer-name))
(defun reaper-edit-entry ()
"Edit entry at point."
(interactive)
(reaper-ensure-project-tasks)
(reaper-with-selected-entry
(let* ((project (reaper-read-project (reaper-entry-project-id entry)))
(task-id (reaper-read-task project (reaper-entry-task-id entry)))
(notes (read-string "Description: " (reaper-entry-notes entry)))
(harvest-payload (make-hash-table :test 'equal)))
(puthash "project_id" (cdr (assoc :id project)) harvest-payload)
(puthash "task_id" task-id harvest-payload)
(puthash "notes" notes harvest-payload)
(reaper-api "PATCH" (format "time_entries/%s" (reaper-entry-id entry)) harvest-payload "Updated entry")
(reaper-refresh))))
(defun reaper-edit-entry-project ()
"Edit project of entry at point."
(interactive)
(reaper-ensure-project-tasks)
(reaper-with-selected-entry
(let* ((project (reaper-read-project (reaper-entry-project-id entry)))
;; When changing project, the possible tasks change too.
;; So if the new project doesn't have the current task,
;; we need to ask for it.
(current-task-id (reaper-entry-task-id entry))
(task-id
(if (assoc current-task-id (reaper-project-tasks project))
current-task-id
(reaper-read-task project (reaper-entry-task-id entry))))
(harvest-payload (make-hash-table :test 'equal)))
(puthash "project_id" (reaper-project-id project) harvest-payload)
(puthash "task_id" task-id harvest-payload)
(reaper-api "PATCH" (format "time_entries/%s" (reaper-entry-id entry)) harvest-payload "Updated entry")
(reaper-refresh))))
(defun reaper-edit-entry-task ()
"Edit task of entry at point."
(interactive)
(reaper-ensure-project-tasks)
(reaper-with-selected-entry
(let* ((project (reaper-get-project (reaper-entry-project-id entry)))
(task-id (reaper-read-task project (reaper-entry-task-id entry)))
(harvest-payload (make-hash-table :test 'equal)))
(puthash "task_id" task-id harvest-payload)
(reaper-api "PATCH" (format "time_entries/%s" (reaper-entry-id entry)) harvest-payload "Updated entry")
(reaper-refresh))))
(defun reaper-edit-entry-description ()
"Edit description of entry at point."
(interactive)
(reaper-ensure-project-tasks)
(reaper-with-selected-entry
(let* ((notes (read-string "Description: " (reaper-entry-notes entry)))
(harvest-payload (make-hash-table :test 'equal)))
(puthash "notes" notes harvest-payload)
(reaper-api "PATCH" (format "time_entries/%s" (reaper-entry-id entry)) harvest-payload "Updated entry")
(reaper-refresh))))
(defun reaper-edit-entry-time ()
"Edit time of entry at point."
(interactive)
(reaper-with-selected-entry
;; If the timer is running add the time since the data was fetched.
(let* ((time (reaper--hours-to-time (reaper--hours-accounting-for-running-timer entry)))
(new-time (reaper--time-to-hours-calculation (read-string "New time: " time)))
(harvest-payload (make-hash-table :test 'equal)))
(puthash "hours" new-time harvest-payload)
(reaper-api "PATCH" (format "time_entries/%s" (reaper-entry-id entry)) harvest-payload "Updated entry")
(reaper-refresh))))
(defun reaper-read-project (&optional default)
"Read a project from the user. Default to DEFAULT."
(let* ((projects (mapcar (lambda (project)
;; Really, we should take the cdr of
;; project here, but reaper-project-*
;; doesn't care as they're using assoc.
(cons (concat "[" (reaper-project-code project) "] " (reaper-project-name project)) (cdr project)))
reaper-project-tasks))
(default (reaper-get-project default))
(default-option (when default (concat "[" (reaper-project-code default) "] " (reaper-project-name default))))
(project (cdr (assoc (reaper--completing-read "Project: " projects default-option) projects))))
project))
(defun reaper-read-task (project &optional default)
"Read a task for PROJECT from the user. Default to DEFAULT.
Returns task id."
(let*
((tasks (mapcar (lambda (task) (cons (cdr task) (car task))) (reaper-project-tasks project)))
(default (when default (cdr (assoc default (reaper-project-tasks project)))))
(task-id (cdr (assoc (reaper--completing-read "Task: " tasks default) tasks))))
task-id))
(defun reaper-api (method path payload completion-message)
"Make an METHOD call to PATH with PAYLOAD and COMPLETION-MESSAGE."
(reaper--check-credentials)
(let* ((url-request-method method)
;;(url-set-mime-charset-string)
(url-mime-language-string nil)
(url-mime-encoding-string nil)
(url-mime-accept-string "application/json")
(url-personal-mail-address nil)
(url-request-data (if (or (string-equal method "POST")
(string-equal method "PATCH"))
(encode-coding-string (json-encode payload) 'utf-8)
nil))
(request-url (format "https://api.harvestapp.com/v2/%s" path))
(url-request-extra-headers
`(("Content-Type" . "application/json")
("Authorization" . ,(concat "Bearer " reaper-api-key))
("Harvest-Account-Id" . ,reaper-account-id)
("User-Agent" . "Xen's Emacs client (fini@reload.dk)"))))
(with-temp-buffer
(reaper-url-insert-file-contents request-url)
(goto-char (point-min))
(message "%s" completion-message)
;; Ensure JSON false values is nil.
(defvar json-false)
(let ((json-false nil))
(json-read)))))
(defun reaper-url-insert-file-contents (url &optional visit beg end replace)
"Quiet version of `url-insert-file-contents'.
URL, VISIT, BEG, END and REPLACE is the same as for
`url-insert-file-contents'."
(let ((buffer (url-retrieve-synchronously url t)))
(unless buffer (signal 'file-error (list url "No Data")))
(when (fboundp 'url-http--insert-file-helper)
;; XXX: This is HTTP/S specific and should be moved to url-http
;; instead. See bug#17549.
(url-http--insert-file-helper buffer url visit))
(url-insert-buffer-contents buffer url visit beg end replace)))
(defun reaper-api-async (method path payload callback)
"Make an asynchronous METHOD call to PATH with PAYLOAD and call CALLBACK on completion."
(reaper--check-credentials)
(let* ((url-request-method method)
;;(url-set-mime-charset-string)
(url-mime-language-string nil)
(url-mime-encoding-string nil)
(url-mime-accept-string "application/json")
(url-personal-mail-address nil)
(url-request-data (if (or (string-equal method "POST")
(string-equal method "PATCH"))
(encode-coding-string (json-encode payload) 'utf-8)
nil))
(request-url (format "https://api.harvestapp.com/v2/%s" path))
(url-request-extra-headers
`(("Content-Type" . "application/json")
("Authorization" . ,(concat "Bearer " reaper-api-key))
("Harvest-Account-Id" . ,reaper-account-id)
("User-Agent" . "Xen's Emacs client (fini@reload.dk)"))))
(url-retrieve request-url
#'(lambda (&rest ignored)
(let ((async-buffer (current-buffer)))
(with-temp-buffer
(when (fboundp 'url-http--insert-file-helper)
;; XXX: This is HTTP/S specific and should be moved to url-http
;; instead. See bug#17549.
(url-http--insert-file-helper async-buffer request-url))
(url-insert-buffer-contents async-buffer request-url)
(goto-char (point-min))
;; Ensure JSON false values is nil.
(defvar json-false)
(let ((json-false nil))
(funcall callback (json-read))))))
nil t)))
(defun reaper-alist-get (symbols alist)
"Look up the value for the chain of SYMBOLS in ALIST."
(if symbols
(reaper-alist-get (cdr symbols)
(assoc (car symbols) alist))
(cdr alist)))
(defun reaper--update-timer ()
"Update running timers in reaper buffer. Called by `run-at-time'."
(when (get-buffer reaper-buffer-name)
(reaper-with-buffer
(reaper-refresh-buffer))))
(defun reaper--check-credentials ()
"Check if Harvest credetials are set, or trigger an user error."
(when (or (string= "" reaper-api-key) (string= "" reaper-account-id))
(user-error "Please customize reaper-api-key and reaper-account-id")))
(defun reaper--buffer ()
"Return reaper buffer.
Will create it if it doesn't exist yet."
(or
(get-buffer reaper-buffer-name)
(with-current-buffer (get-buffer-create reaper-buffer-name)
(reaper-mode)
;; Start out on the last entry.
;; (goto-char (point-max))
;; (forward-line -1)
(current-buffer))))
(defun reaper--get-user-id ()
"Return the Harvest user id of the current user."
(or
(reaper-alist-get '(id) (reaper-api "GET"
"users/me"
nil
"Fetched user information"))
(error "Could not fetch user id")))
(defun reaper--list-entries ()
"Return list of entries for `tabulated-list-mode'."
(reaper-with-buffer
(if reaper-timeentries-loading
(list
(list nil (vector "" "" "" ""))
(list nil (vector "Loading, please wait." "" "" "")))
(setq reaper-total-hours 0)
(let
((entries (cl-loop for (_id . entry) in reaper-timeentries
collect (list
(reaper-entry-id entry)
(vector
(reaper-entry-project entry)
(reaper-entry-task entry)
(let ((hours (reaper--hours-accounting-for-running-timer entry)))
(setq reaper-total-hours (+ hours reaper-total-hours))
(reaper--hours-to-time hours))
;; For running timer, use time since timer_started_at.
;; Replace newlines as they mess with tabulated-list-mode.
(replace-regexp-in-string "\n" "\\\\n" (reaper-entry-notes entry)))))))
(append
entries
(list
(list nil (vconcat [] (mapcar (lambda (x) (make-string (elt x 1) ?-)) reaper--list-format)))
(list nil (vector "Total" "" (reaper--hours-to-time reaper-total-hours) ""))))))))
(defun reaper--highlight-running-and-move-point ()
"Highlight the currently running timer, and move point to the last selected entry."
(let ((move-to))
(save-excursion
(goto-char (point-min))
(while (not (eobp))
(let ((id (tabulated-list-get-id)))
(when (and reaper-running-timer (eq id reaper-running-timer))
(tabulated-list-put-tag "->"))
(when (and reaper-last-marked-entry (eq reaper-last-marked-entry id))
(setq move-to (point)))
(forward-line 1))))
;; Move point to the entry we stored, if found.
(when move-to
(goto-char move-to))))
(defun reaper--hours-to-time (hours)
"Convert Harvest HOURS to a time string."
(format "%d:%02d" (truncate hours) (floor (* 60 (- hours (truncate hours))))))
(defun reaper--time-to-hours-calculation (string)
"Convert a time calculation STRING to hours.
A calculation is a number of time strings (as parsed by
`reaper--time-to-hours') separated by + or -."
;; Error out if string contains other characters than numbers,
;; colon, plus and minus. Calc might handle it alright, but due to
;; the time parsing even simple things like 2*10 produce unexpected
;; results, so let's disallow it.
(unless (string-match (rx bos (1+ (any ?: ?+ ?- num)) eos) string)
(user-error "Invalid hours calculation string"))
;; Let calc do the heavy lifting.
(let ((calc-eval-error t))
(string-to-number (calc-eval (replace-regexp-in-string
reaper--time-regexp
(lambda (num) (number-to-string (reaper--time-to-hours num)))
string)))))
(defun reaper--time-to-hours (time)
"Convert TIME to hours.
TIME is in HH:MM or MM format. Returns a float."
(when (string-match (rx bos (regexp reaper--time-regexp) eos) time)
(let ((hours (match-string-no-properties 1 time))
(minutes (string-to-number (match-string-no-properties 2 time))))
(+ (if hours (string-to-number hours) 0) (/ (float minutes) 60)))))
(defun reaper--parse-date-string (date-string)
"Pass DATE-STRING into canonical Y-m-d format.
Dates are given in ISO like format, year-month-day, with - or .
as a seperator.
Year and month can be left out and are assumed to be current,
unless that would put the date in the future, in which case it
goes back a month or year.
If date is a number prefixed with -/+, it goes back/forward that
many days from reaper-date."
(let ((current-date (decode-time (current-time)))
(current-reaper-date (split-string reaper-date "[-]")))
(if (string-match (rx bos (group-n 1 (any ?+ ?-) (one-or-more digit)) eos) date-string)
(let* ((days-offset (string-to-number (match-string-no-properties 1 date-string))))
(format-time-string "%Y-%m-%d" (encode-time 59 59 23
(+ (string-to-number (nth 2 current-reaper-date)) days-offset)
(string-to-number (nth 1 current-reaper-date))
(string-to-number (nth 0 current-reaper-date)))))
(let* ((parts (reverse (split-string date-string "[-\.]")))
(day (string-to-number (nth 0 parts)))
(month (and (nth 1 parts) (string-to-number (nth 1 parts))))
(year (and (nth 2 parts) (string-to-number (nth 2 parts))))
(target-time (encode-time 59 59 23
day
(or month (nth 4 current-date))
(or year (nth 5 current-date))))
(target-time-month-ago (encode-time 59 59 23
day
(- (or month (nth 4 current-date)) 1)
(or year (nth 5 current-date))))
(target-time-year-ago (encode-time 59 59 23
day
(or month (nth 4 current-date))
(- (or year (nth 5 current-date)) 1)))
(time (cond
;; On empty input day is still parsed as 0
;; (string-to-number "") returns 0.
((< day 1) (current-time))
((time-less-p target-time (current-time)) target-time)
((and (not (nth 1 parts))
(time-less-p target-time-month-ago (current-time)))
target-time-month-ago)
((and (not (nth 2 parts))
(time-less-p target-time-year-ago (current-time)))
target-time-year-ago)
(t nil))))
(and time (format-time-string "%Y-%m-%d" time))))))
(defun reaper--hours-accounting-for-running-timer (entry)
"Return hours from ENTRY, adding in time since request if the timer is running."
(+ (reaper-entry-hours entry)
(if (reaper-entry-is-running entry)
(+ (/ (/ (time-to-seconds (time-subtract (current-time)
reaper-fetch-time)) 60) 60))
0)))
(defun reaper--completing-read (prompt options default)
"Complete with PROMPT, with OPTIONS and having DEFAULT.
Wraps `completing-read', or `ivy-read', in order to sort options
in last used order when using ivy."
(if (eq completing-read-function 'ivy-completing-read)
(ivy-read
prompt options
:require-match t
:preselect default
:def default)
(completing-read prompt options nil t nil nil default)))
(defun reaper--last-used (project task-id)
"Save PROJECT and TASK-ID as last used."
;; Simply move the last used project to the top of the list, and the
;; last used task to the top of the list of tasks on the project.
(setq reaper-project-tasks (assoc-delete-all (reaper-project-id project) reaper-project-tasks))
(let* ((tasks (reaper-project-tasks project))
(task (assoc task-id tasks)))
(cons task (delq task tasks))
(setf (reaper-project-tasks project) (cons task (delq task tasks))))
;; Create a proper alist.
(push (cons (reaper-project-id project) project) reaper-project-tasks))
(provide 'reaper)
;;; reaper.el ends here