-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathorg-clock-agg.el
1425 lines (1257 loc) · 55.1 KB
/
org-clock-agg.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
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
;;; org-clock-agg.el --- Tree-like reports for org-clock records -*- lexical-binding: t -*-
;; Copyright (C) 2023 Korytov Pavel
;; Author: Korytov Pavel <[email protected]>
;; Maintainer: Korytov Pavel <[email protected]>
;; Version: 0.1.0
;; Package-Requires: ((emacs "27.2") (compat "29.1.4.1") (org-ql "0.8-pre"))
;; Homepage: https://github.com/SqrtMinusOne/org-clock-agg
;; Published-At: 2023-12-17
;; This file is NOT part of GNU Emacs.
;; 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:
;; Aggregate org-clock records and display the results in an
;; interactive buffer. org-clock records are grouped by predicates
;; such as file name, their outline path in the file, etc. Each
;; record is placed in a tree structure; each node of the tree shows
;; the total time spent in that node and its children. The top-level
;; node shows the total time spent in all records found by the query.
;;
;; `org-clock-agg' is the main entrypoint. It can be run interactively
;; or from elisp code. See the docstring for details.
;;
;; See also the README at
;; <https://github.com/SqrtMinusOne/org-clock-agg> for more details.
;;; Code:
(require 'cl-lib)
(require 'font-lock)
(require 'outline)
(require 'org)
(require 'seq)
(require 'widget)
(require 'compat)
(require 'org-ql)
(eval-when-compile
(require 'org-ql-view))
;; XXX byte-compiler on 29.4 started to want this, don't know why
(defvar widget-push-button-prefix)
(defvar widget-push-button-suffix)
(defgroup org-clock-agg nil
"Aggregate org-clock statistics."
:group 'org-clock)
(defcustom org-clock-agg-duration-format "%h:%.2m"
"Format string for durations in `org-clock-agg' views.
Refer to `format-seconds' for the available format specifiers."
:type 'string
:group 'org-clock-agg)
(defcustom org-clock-agg-files-preset nil
"Presets for the \"files\" parameter in `org-clock-agg' views.
The variable is an alist with preset names as keys and lists of
file names as values."
:type '(alist :key-type string :value-type (repeat string))
:group 'org-clock-agg)
(defcustom org-clock-agg-day-format "%Y-%m-%d, %a"
"Format string for days in `org-clock-agg' views.
Refer to `format-time-string' for the available format
specifiers."
:type 'string
:group 'org-clock-agg)
(defcustom org-clock-agg-week-format "%Y-%W"
"Format string for weeks in `org-clock-agg' views.
Refer to `format-time-string' for the list of available format
specifiers."
:type 'string
:group 'org-clock-agg)
(defcustom org-clock-agg-month-format "%Y-%m"
"Format string for months in `org-clock-agg' views.
Refer to `format-time-string' for the list of available format
specifiers."
:type 'string
:group 'org-clock-agg)
(defcustom org-clock-agg-properties nil
"Org properties to include in `org-clock-agg' views.
The value of this variable should be a list of strings.
Set this interactively or manually reset `org-ql-cache' and
`org-ql-node-value-cache' after modification."
:type '(repeat string)
:group 'org-clock-agg
:set (lambda (&rest _)
(setq org-ql-cache (make-hash-table :weakness 'key))
(setq org-ql-node-value-cache (make-hash-table :weakness 'key))))
(defcustom org-clock-agg-node-title-width-delta 40
"How many characters to truncate from the node title.
Refer to `org-clock-agg-node-format' for instructions on configuring
this."
:type 'integer
:group 'org-clock-agg)
(defcustom org-clock-agg-node-format "%-%(+ title-width)t %20c %8z"
"Format string for the node title in `org-clock-agg' views.
The following format specifiers are available:
- %t - node title with the level prefix, truncated to `title-width'
characters (see below)
- %c - name of the grouping function that produced the node (the
`:readable-name' parameter)
- %z - time spent in the node according to
`org-clock-agg-duration-format'
- %S - time share of the node against the parent node
- %s - time share of the node against the top-level node
Refer to `format-spec' for available modifiers.
This format string also evaluates Elisp expressions in the %(...)
blocks. During evaluation, the following variables are bound:
- `title-width': `window-width' minus
`org-clock-agg-node-title-width-delta'.
This way, in the default configuration, the node title is truncated to
fit into the window, utilizing `org-clock-agg-node-title-width-delta'
as the remaining characters for the rest of the string."
:type 'string
:group 'org-clock-agg)
(defcustom org-clock-agg-elem-format "- [%s]--[%e] => %d : %t"
"Format string for elements in `org-clock-agg' views.
The following format specifiers are available:
- %s - start of the time range
- %e - end of the time range
- %d - duration of the time range
- %t - title of the record.
The formats of %s and %e are controlled by `org-time-stamp-formats'."
:type 'string
:group 'org-clock-agg)
(defconst org-clock-agg--extra-params-default
'(("Show records:" . (checkbox :extras-key :show-elems))
("Add \"Ungrouped\"" . (checkbox :extras-key :add-ungrouped)))
"Default set of extra parameters for `org-clock-agg' views.")
(defcustom org-clock-agg-extra-params nil
"Extra parameters for `org-clock-agg' views."
:type '(alist :key-type string :value-type sexp)
:group 'org-clock-agg)
(defface org-clock-agg-group-face
'((t :inherit font-lock-comment-face))
"Face for group names in `org-clock-agg' tree views."
:group 'org-clock-agg)
(defface org-clock-agg-share-face
'((t :inherit font-lock-comment-face))
"Face for node time share values in `org-clock-agg' tree views."
:group 'org-clock-agg)
(defface org-clock-agg-duration-face
'((t :inherit font-lock-constant-face))
"Face for durations in `org-clock-agg' tree views."
:group 'org-clock-agg)
(defface org-clock-agg-param-face
'((t :inherit font-lock-variable-name-face))
"Face for parameters in `org-clock-agg' tree views."
:group 'org-clock-agg)
(defface org-clock-agg-elem-face nil
"Face for elements in `org-clock-agg' tree views.
It's probably supposed to be nil because it overrides the default
element formatting."
:group 'org-clock-agg)
;; This function appears in Emacs 29 and isn't avaliable in `compat'
;; for some reason
(defun org-clock-agg--alist-to-plist (alist)
"Convert ALIST to a plist."
(let ((res '()))
(dolist (x alist)
(push (car x) res)
(push (cdr x) res))
(nreverse res)))
;;; Querying
(defun org-clock-agg--parse-clocks (headline)
"Extract org-clock clocks from HEADLINE.
Return a list of alists with the following keys:
- `:start' - start time in seconds since the epoch
- `:end' - end time in seconds since the epoch
- `:duration' - duration in seconds."
(let ((contents (buffer-substring-no-properties
;; contents-begin starts after the headline
(org-element-property :contents-begin headline)
(org-element-property :contents-end headline))))
(with-temp-buffer
(insert contents)
(let (res)
(org-element-map (org-element-parse-buffer) 'clock
(lambda (clock)
(let ((start (time-convert
(org-timestamp-to-time (org-element-property :value clock))
'integer))
(end (time-convert
(org-timestamp-to-time (org-element-property :value clock) t)
'integer)))
(push
`((:start . ,start)
(:end . ,end)
(:duration . ,(- end start)))
res)))
;; The last argument stops parsing after the first headline.
;; So only clocks in the first headline are parsed.
nil nil 'headline)
res))))
(defun org-clock-agg--properties-at-point ()
"Return a list of selected properties at point.
`org-clock-agg-properties' sets the list of properties to select. The
properties are inherited from the parent headlines and from the global
properties set at the beginning of the file."
(let ((global-props
(org-ql--value-at
1 (lambda ()
(cl-loop for res in (org-collect-keywords org-clock-agg-properties)
collect (cons (nth 0 res) (nth 1 res))))))
(local-props
(org-ql--value-at
(point)
(lambda ()
(cl-loop for key in org-clock-agg-properties
for val = (org-entry-get nil key t)
when val collect `(,key . ,val))))))
(seq-uniq
(append local-props global-props)
(lambda (a b)
(equal (car a) (car b))))))
(defun org-clock-agg--parse-headline ()
"Parse headline at point.
Return a list of alists with the following keys:
- `:start' - start time in seconds since the epoch
- `:end' - end time in seconds since the epoch
- `:duration' - duration in seconds
- `:headline' - instance of org-element for the headline
- `:tags' - list of tags
- `:file' - file name
- `:outline-path' - list of outline path, i.e. all headlines from the
root to the current headline
- `:properties' - list of properties, `org-clock-agg-properties' sets the
list of properties to select
- `:category' - category of the current headline."
(let* ((headline (org-element-headline-parser))
(tags-val (org-ql--tags-at (point)))
(tags (seq-filter
#'stringp ;; to filter out `org-ql-nil'
(append (unless (eq (car tags-val) 'org-ql-nil)
(car tags-val))
(unless (eq (cdr tags-val) 'org-ql-nil)
(cdr tags-val)))))
(file (buffer-file-name))
(outline-path (mapcar
#'substring-no-properties
(org-ql--outline-path)))
(properties (when org-clock-agg-properties
(org-clock-agg--properties-at-point)))
(category (org-get-category)))
(org-ql--add-markers headline)
(cl-loop for clock in (org-clock-agg--parse-clocks headline)
collect`(,@clock
(:headline . ,headline)
(:tags . ,tags)
(:file . ,file)
(:outline-path . ,outline-path)
(:properties . ,properties)
(:category . ,category)))))
(defun org-clock-agg--normalize-time-predicate (val kind)
"Normalize VAL to a time predicate.
VAL can be either:
- A number interpreted as a number of days from the current one.
- A string parseable by `parse-time-string', with or without the time
part.
KIND is either \"from\" or \"to\". If it's the latter, the time part
is set to 23:59:59 when possible; otherwise, it's set to 00:00:00.
The result is the number of seconds since the epoch."
(when-let (int-val
(and (stringp val) (ignore-errors (number-to-string val))))
(setq val int-val))
(cond ((numberp val)
;; Hmm, so that's why alpapapa loves ts, dash and whatnot...
(+
(time-convert
(encode-time
(append
(if (eq kind 'to) '(59 59 23) '(0 0 0))
(seq-drop (decode-time) 3)))
'integer)
(* val 24 60 60)))
((stringp val)
(let ((res (parse-time-string val)))
(setf (decoded-time-second res)
(or (decoded-time-second res) (if (eq kind 'to) 59 0))
(decoded-time-minute res)
(or (decoded-time-minute res) (if (eq kind 'to) 59 0))
(decoded-time-hour res)
(or (decoded-time-hour res) (if (eq kind 'to) 23 0)))
(time-convert
(encode-time res)
'integer)))
(t (user-error "Invalid time predicate: %s" val))))
(defun org-clock-agg--filter-elems (from to elems)
"Filter ELEMS by FROM and TO.
Refer to `org-clock-agg--normalize-time-predicate' for the possible
values of FROM and TO.
ELEMS is a list as described in `org-clock-agg--parse-headline'."
(let ((from-date (org-clock-agg--normalize-time-predicate from 'from))
(to-date (org-clock-agg--normalize-time-predicate to 'to)))
(cl-loop for elem in elems
for start = (or (alist-get :start elem) 0)
for end = (or (alist-get :end elem) (expt 2 32))
when (and (>= start from-date)
(<= end to-date))
collect elem)))
(defun org-clock-agg--query (from to files)
"Query org files in FILES for clocked entries from FROM to TO.
Refer to `org-clock-agg--normalize-time-predicate' for the possible
values of FROM and TO.
Return a list as described in `org-clock-agg--parse-headline'."
(if (org-clock-agg--drill-down-p)
org-clock-agg--elems
(thread-last
(cl-loop for res in (org-ql-query
:select #'org-clock-agg--parse-headline
:from files
:where `(clocked :from ,from :to ,to))
append res)
(org-clock-agg--filter-elems from to))))
;;; Aggregation
(defvar org-clock-agg-groupby-functions nil
"Group by functions for `org-clock-agg'.
This is an alist with function names as keys and alists containing the
following keys as values:
- `:function' - the grouping function itself
- `:hidden' - whether to hide the function in the UI
- `:readable-name' - name to display in the UI
- `:default-sort' - default sorting function to use for this group (a
key of `org-clock-agg-sort-functions')
See `org-clock-agg-defgroupby' on how to define new grouping
functions.")
(defvar org-clock-agg-sort-functions nil
"Sort functions for `org-clock-agg'.
This is an alist with function names as keys and alists with the
following keys as values:
- `:function' - the sorting function itself
- `:readable-name' - name to display in the UI.
See `org-clock-agg-defsort' on how to define new sorting
functions.")
;; XXX This looks like reinventing the wheel... IDK.
(defmacro org-clock-agg--extract-params (body &rest params)
"Extract parameters from BODY.
BODY is a list of expressions. PARAMS is a list of symbols starting
with \":\".
E.g., if BODY is (:foo 1 :bar 2 something something), the usage is as
follows:
\(let \(foo bar)
\(org-clock-agg--extract-params body :foo :bar)
;; do something with foo and bar
)"
`(let ((body-wo-docstring (if (stringp (car-safe ,body)) (cdr body) ,body))
(docstring (when (stringp (car-safe ,body)) (car-safe ,body))))
(while-let ((symbol (and
(member (car-safe body-wo-docstring) ',params)
(car-safe body-wo-docstring))))
,@(mapcar
(lambda (param)
`(when (eq symbol ,param)
(setq ,(intern (substring (symbol-name param) 1)) (cadr body-wo-docstring))))
params)
(setq body-wo-docstring (cddr body-wo-docstring)))
(if docstring
(setq ,body (cons docstring body-wo-docstring))
(setq ,body body-wo-docstring))))
(cl-defmacro org-clock-agg-defgroupby (name &body body)
"Define a grouping function for `org-clock-agg'.
NAME is the name of the function. BODY binds the following variables:
- `elem' - an alist as described in `org-clock-agg--parse-headline'
- `extra-params' - an alist with extra parameters. See
`org-clock-agg' on that.
The function must return a list of strings, which are the group names.
BODY can also include the following keyword arguments:
- `:readable-name' - function name for the UI. If not given, NAME is
used.
- `:hidden' - if non-nil, the function is not shown in the UI.
- `:default-sort' - if non-nil, the function is used as the default
sort function."
(declare (indent defun)
(doc-string 2))
(let ((func-name (intern (concat "org-clock-agg--groupby-" (symbol-name name))))
readable-name hidden default-sort)
;; Parse keyword arguments in BODY
(org-clock-agg--extract-params body :readable-name :hidden
:default-sort)
(unless readable-name
(setq readable-name (symbol-name name)))
`(progn
(defun ,func-name (elem extra-params)
;; XXX To silence the byte-compiler
(ignore elem extra-params)
,@body)
(setf (alist-get ',name org-clock-agg-groupby-functions)
'((:function . ,func-name)
(:hidden . ,hidden)
(:readable-name . ,readable-name)
(:default-sort . ,default-sort))))))
(cl-defmacro org-clock-agg-defsort (name &body body)
"Define a sorting function for `org-clock-agg'.
NAME is the name of the function. BODY binds a `nodes' variable,
which is a list of tree nodes as described in function
`org-clock-agg--groupby'.
BODY can also contain the following keyword arguments:
- `:readable-name' - function name for the UI. If not given, NAME is
used."
(declare (indent defun)
(doc-string 2))
(let ((func-name (intern (concat "org-clock-agg--sort-" (symbol-name name))))
readable-name)
(org-clock-agg--extract-params body :readable-name)
(unless readable-name
(setq readable-name (symbol-name name)))
`(progn
(defun ,func-name (nodes)
,@body)
(setf (alist-get ',name org-clock-agg-sort-functions)
'((:function . ,func-name)
(:readable-name . ,readable-name))))))
(org-clock-agg-defgroupby category
"Group org-clock entries by category."
:readable-name "Category"
:default-sort total
(list (alist-get :category elem)))
(org-clock-agg-defgroupby org-file
"Group org-clock entries by file in `org-directory'."
:readable-name "Org file"
:default-sort total
(list
(file-relative-name (alist-get :file elem)
(directory-file-name org-directory))))
(org-clock-agg-defgroupby outline-path
"Group org-clock entries by outline path."
:readable-name "Outline path"
:default-sort total
(alist-get :outline-path elem))
(org-clock-agg-defgroupby tags
"Group org-clock entries by tags."
:readable-name "Tags"
:default-sort total
(seq-sort
#'string-lessp
(alist-get :tags elem)))
(org-clock-agg-defgroupby headline
"Group org-clock entries by headline."
:readable-name "Headline"
:default-sort total
(list (substring-no-properties
(org-element-property :raw-value (alist-get :headline elem)))))
(org-clock-agg-defgroupby day
:readable-name "Day"
:default-sort start-time
(list (thread-last elem
(alist-get :start)
(seconds-to-time)
(format-time-string org-clock-agg-day-format))))
(org-clock-agg-defgroupby week
:readable-name "Week"
:default-sort start-time
(list (thread-last elem
(alist-get :start)
(seconds-to-time)
(format-time-string org-clock-agg-week-format))))
(org-clock-agg-defgroupby month
:readable-name "Month"
:default-sort start-time
(list (thread-last elem
(alist-get :start)
(seconds-to-time)
(format-time-string org-clock-agg-month-format))))
(org-clock-agg-defgroupby todo
:readable-name "TODO keyword"
:default-sort total
(list (substring-no-properties
(org-element-property :todo-keyword (alist-get :headline elem)))))
(org-clock-agg-defgroupby is-done
:readable-name "Is done"
:default-sort total
(list (if (eq (org-element-property :todo-type (alist-get :headline elem)) 'done)
"Done"
"Not done")))
(org-clock-agg-defgroupby day-of-week
:readable-name "Day of week"
:default-sort name
(list (thread-last elem
(alist-get :start)
(seconds-to-time)
(format-time-string "%u - %A"))))
(defun org-clock-agg--make-property-name-readable (name)
"Make an org property NAME more readable."
(thread-last
name
(replace-regexp-in-string (rx (or "_" "-")) " ")
(capitalize)))
(org-clock-agg-defgroupby selected-properties
:readable-name "Selected props"
:default-sort total
(cl-loop for (key . value) in (alist-get :properties elem)
if value
collect (format
"%s: %s"
(org-clock-agg--make-property-name-readable key)
value)))
(org-clock-agg-defgroupby root-group
"Return \"Root\". Used for the root group."
:readable-name "Root"
:default-sort total
:hidden t
(list "Results"))
(org-clock-agg-defsort name
"Sort by name."
:readable-name "Name"
(seq-sort-by (lambda (elem) (alist-get :name elem)) #'string-lessp nodes))
(org-clock-agg-defsort total
"Sort by total time spent."
:readable-name "Total time"
(seq-sort-by (lambda (elem) (alist-get :total elem)) #'> nodes))
(org-clock-agg-defsort start-time
"Sort by start time."
:readable-name "Start time"
(seq-sort-by
(lambda (elem)
(thread-last elem
(list)
(org-clock-agg--ungroup)
(mapcar (lambda (row-elem) (alist-get :start row-elem)))
(seq-min)))
#'> nodes))
(org-clock-agg-defsort end-time
"Sort by end time."
:readable-name "End time"
(seq-sort-by
(lambda (elem)
(thread-last elem
(list)
(org-clock-agg--ungroup)
(mapcar (lambda (row-elem) (alist-get :end row-elem)))
(seq-max)))
#'> nodes))
(defun org-clock-agg--groupby-apply (alist groups elem)
"Recursively perform grouping for `org-clock-agg'.
ALIST is the alist used to store the results. GROUPS is a list of
groups for ELEM. GROUPS is a list with the following structure for
one group:
- group name
- parameters of the grouping function (as in the variable
`org-clock-agg-groupby-functions')
- name of the sorting function (keys of the variable
`org-clock-agg-sort-functions')
- sort order (t to reverse).
Refer to the function `org-clock-agg--groupby' for a description of
the return value."
(let* ((group-params (car groups))
(key (nth 0 group-params))
(groupby (nth 1 group-params))
(sort (nth 2 group-params))
(sort-order (nth 3 group-params))
(rest (cdr groups))
(duration (alist-get :duration elem))
(prev-val (alist-get key alist nil nil #'equal)))
(when key
(setf (alist-get key alist nil nil #'equal)
`((:total . ,(+ duration (or (alist-get :total prev-val) 0)))
(:groupby . ,groupby)
(:children . ,(org-clock-agg--groupby-apply
(alist-get :children prev-val) rest elem))
(:sort-symbol . ,sort)
(:sort-order . ,sort-order)
(:parent-share . ,0)
(:total-share . ,0)
(:elems . ,(if rest
(alist-get :elems prev-val)
(cons elem (alist-get :elems prev-val))))))))
alist)
(defun org-clock-agg--add-ungrouped (tree)
"Add \"Ungrouped\" nodes to TREE.
This function adds an \"Ungrouped\" node to every node in TREE that
contains both `:elems' (ungrouped elements) and `:children'. This
can happen when only a portion of the elements of the node was grouped
by a grouping function.
The addition of the \"Ungrouped\" node with all the ungrouped elements
ensures that the total time spent in the node equals the sum of the
total time spent in its children.
TREE is a tree as returned by `org-clock-agg--groupby'."
(dolist (node tree)
(let ((children (alist-get :children (cdr node)))
(elems (alist-get :elems (cdr node))))
(org-clock-agg--add-ungrouped children)
(when (and children elems)
(let ((total (seq-reduce (lambda (acc val)
(+ acc (alist-get :duration val)))
elems 0)))
(setf (alist-get :children (cdr node))
(cons
`("Ungrouped"
(:total . ,total)
(:groupby . ((:readable-name . "Ungrouped")))
(:children)
(:sort-symbol . total)
(:sort-order)
(:parent-share . 0)
(:total-share . 0)
(:elems . ,elems))
(alist-get :children (cdr node)))))
(setf (alist-get :elems (cdr node)) nil))))
tree)
(defun org-clock-agg--groupby-postaggregate (tree &optional total-time parent-time)
"Perform final aggregation calculations on TREE.
Sets the following fields on each tree node:
- `:parent-share'
- `:total-share'
TOTAL-TIME and PARENT-TIME are recursive parameters."
(unless (and total-time parent-time)
(setq total-time (alist-get :total (cdar tree)))
(setq parent-time total-time))
(dolist (node tree)
(let ((total (float (alist-get :total (cdr node)))))
(setf (alist-get :parent-share (cdr node))
(if (< 0 parent-time)
(/ total (float parent-time))
0)
(alist-get :total-share (cdr node))
(if (< 0 total-time)
(/ total (float total-time))
0))
(org-clock-agg--groupby-postaggregate
(alist-get :children (cdr node))
total-time
total))))
(defun org-clock-agg--groupby (elems groupby-list sort-list sort-order-list extra-params)
"Group ELEMS for `org-clock-agg' into a tree.
ELEMS is a list as described in `org-clock-agg--parse-headline'.
GROUPBY-LIST is a list of keys of the variable
`org-clock-agg-groupby-functions'. SORT-LIST is a list of keys of
the variable `org-clock-agg-sort-functions'. SORT-ORDER-LIST is a
list of booleans indicating whether to reverse the sort order for the
corresponding key in SORT-LIST.
The root group is always added to the beginning of GROUPBY-LIST.
EXTRA-PARAMS is an alist of extra parameters for the grouping
functions. If `:add-ungrouped' is non-nil, \"Ungrouped\" nodes are
added to the tree. See `org-clock-agg' for details.
The return value is a tree of alists with the following keys:
- `:total' - total seconds spent in the group
- `:parent-share' - `:total' / time spent in the parent node
- `:total-share' - `:total' / time spent in the top-level node
- `:groupby' - grouping function (as in the variable
`org-clock-agg-groupby-functions')
- `:children' - list of children tree nodes
- `:sort-symbol' - key of the variable `org-clock-agg-sort-functions'
used for sorting
- `:sort-order' - if non-nil, the sort order is reversed
- `:elems' - list of elements in the group, in the same form as
ELEMS."
(let (tree)
(dolist (elem elems)
(let* ((group-symbols (cons 'root-group groupby-list))
(sort-symbols (cons 'total sort-list))
(sort-orders (cons nil sort-order-list))
(groups
(cl-loop for group-symbol in group-symbols
for sort-symbol in sort-symbols
for sort-order in sort-orders
for groupby = (alist-get group-symbol org-clock-agg-groupby-functions)
for group-values = (funcall (alist-get :function groupby) elem extra-params)
append
(mapcar
(lambda (group-value)
(list group-value groupby sort-symbol sort-order))
group-values))))
(setq tree (org-clock-agg--groupby-apply tree groups elem))))
(when (alist-get :add-ungrouped extra-params)
(setq tree (org-clock-agg--add-ungrouped tree)))
(org-clock-agg--groupby-postaggregate tree)
tree))
(defun org-clock-agg--ungroup (tree)
"Reverse grouping for TREE.
TREE is a tree of alists as described in `org-clock-agg--groupby'.
The return value is a list of elements as described in
`org-clock-agg--parse-headline'."
(cl-loop for node in tree
append (alist-get :elems node)
append (org-clock-agg--ungroup (alist-get :children node))))
(defun org-clock-agg--groupby-sort (tree)
"Sort the grouped TREE.
TREE is a tree of alists as described in `org-clock-agg--groupby'."
(let* ((sorted-nodes-by-group
(thread-last
tree
(mapcar (lambda (node) (cons (cons :name (car node)) (cdr node))))
(seq-group-by
(lambda (node)
(list (alist-get :symbol (alist-get :groupby node))
(alist-get :sort-symbol node)
(alist-get :sort-order node))))
(mapcar
(lambda (grouped)
(let (;; (group-symbol (nth 0 (car grouped)))
(sort-symbol (nth 1 (car grouped)))
(sort-order (nth 2 (car grouped))))
(setf (cdr grouped)
(funcall (thread-last org-clock-agg-sort-functions
(alist-get sort-symbol)
(alist-get :function))
(cdr grouped)))
(when sort-order
(setf (cdr grouped) (reverse (cdr grouped))))
grouped)))
(seq-sort-by
(lambda (grouped)
(thread-last org-clock-agg-groupby-functions
(alist-get (car (car grouped)))
(alist-get :readable-name)))
#'string-lessp)))
(tree (seq-reduce (lambda (acc grouped)
(append (cdr grouped) acc))
sorted-nodes-by-group nil)))
(dolist (node tree)
(let ((children (alist-get :children node))
(elems (alist-get :elems node)))
(when children
(setf (alist-get :children node)
(org-clock-agg--groupby-sort children)))
(when elems
(setf (alist-get :elems node)
(seq-sort-by (lambda (elem) (alist-get :start elem))
#'>
(alist-get :elems node))))))
(mapcar (lambda (node)
(cons (alist-get :name node)
node))
tree)))
;; View & manage results
(defvar-local org-clock-agg--params nil
"Parameters for the current `org-clock-agg' buffer.")
(defvar-local org-clock-agg--elems nil
"Elements for the current `org-clock-agg' buffer.")
(defvar-local org-clock-agg--tree nil
"Tree for the current `org-clock-agg' buffer.")
(defun org-clock-agg-quit ()
"Quit the current `org-clock-agg' buffer."
(interactive)
(quit-window t))
(defvar org-clock-agg-tree-mode-map
(let ((keymap (make-sparse-keymap)))
(define-key keymap (kbd "q") #'org-clock-agg-quit)
(define-key keymap (kbd "r") #'org-clock-agg-refresh)
(define-key keymap (kbd "e") #'org-clock-agg-view-elems-at-point)
(define-key keymap (kbd "d") #'org-clock-agg-drill-down-at-point)
(define-key keymap (kbd "<tab>") #'outline-toggle-children)
(when (fboundp 'evil-define-key*)
(evil-define-key* 'normal keymap
"q" #'org-clock-agg-quit
"gr" #'org-clock-agg-refresh
"e" #'org-clock-agg-view-elems-at-point
"d" #'org-clock-agg-drill-down-at-point
(kbd "<tab>") #'outline-toggle-children))
keymap))
(define-derived-mode org-clock-agg-tree-mode fundamental-mode "Org Clock Agg Tree"
"Major mode for viewing `org-clock-agg' results."
(outline-minor-mode 1))
(defun org-clock-agg--render-controls-files ()
"Render the file picker for the `org-clock-agg' buffer."
(apply
#'widget-create 'menu-choice
:tag "Files"
:value (alist-get :files org-clock-agg--params)
:notify (lambda (widget &rest _)
(setf (alist-get :files org-clock-agg--params)
(widget-value widget)))
'(item :tag "Org Agenda" :value org-agenda)
(append
(mapcar
(lambda (item)
`(item :tag ,(car item) :value ,(cdr item)))
org-clock-agg-files-preset)
'((editable-list :tag "File"
:entry-format "%i %d %v"
:menu-tag "Custom list"
:value nil
(editable-field :tag "File" :value ""))))))
(defun org-clock-agg--render-controls-date ()
"Render the date picker for the `org-clock-agg' buffer."
(widget-create 'editable-field
:size 20
:format (concat (propertize "Date from: " 'face 'widget-button)
"%v ")
:value (let ((val (alist-get :from org-clock-agg--params)))
(if (numberp val)
(number-to-string val)
val))
:notify (lambda (widget &rest _)
(let ((val (widget-value widget)))
(when (string-match-p (rx bos (? "-") (+ digit) eos) val)
(setq val (string-to-number val)))
(setf (alist-get :from org-clock-agg--params) val))))
(widget-create 'editable-field
:size 20
:format (concat (propertize "To: " 'face 'widget-button)
"%v ")
:value (let ((val (alist-get :to org-clock-agg--params)))
(if (numberp val)
(number-to-string val)
val))
:notify (lambda (widget &rest _)
(let ((val (widget-value widget)))
(when (string-match-p (rx bos (? "-") (+ digit) eos) val)
(setq val (string-to-number val)))
(setf (alist-get :to org-clock-agg--params) val)))))
(defun org-clock-agg--render-controls-groupby ()
"Render grouping controls for the `org-clock-agg' buffer."
(insert (propertize "Group by: " 'face 'widget-button) "\n")
(widget-create 'editable-list
:tag "Group by"
:entry-format "%i %d %v"
:value
(cl-loop for group-value in (alist-get :groupby org-clock-agg--params)
for sort-value in (alist-get :sort org-clock-agg--params)
for sort-order-value in
(alist-get :sort-order org-clock-agg--params)
collect (list group-value sort-value sort-order-value))
:notify
(lambda (widget _changed-widget &optional _event)
(let ((group-value (mapcar #'car (widget-value widget)))
(sort-value (mapcar #'cadr (widget-value widget)))
(sort-order-value (mapcar #'caddr (widget-value widget))))
(setf (alist-get :groupby org-clock-agg--params) group-value)
(setf (alist-get :sort org-clock-agg--params) sort-value)
(setf (alist-get :sort-order org-clock-agg--params)
sort-order-value)))
`(group
:value (outline-path total)
(menu-choice
:tag "Group"
:notify (lambda (widget _child &optional event)
(if-let* ((value (widget-value widget))
(default-sort
(alist-get
:default-sort
(alist-get value org-clock-agg-groupby-functions)))
(parent (widget-get widget :parent)))
(widget-value-set parent (list value default-sort)))
(widget-default-action widget event))
,@(thread-last
org-clock-agg-groupby-functions
(seq-filter (lambda (groupby)
(not (alist-get :hidden (cdr groupby)))))
(mapcar
(lambda (groupby)
(let ((name (car groupby))
(readable-name (alist-get :readable-name (cdr groupby))))
`(item :tag ,readable-name
:value ,name
:menu-tag ,readable-name))))))
(menu-choice
:tag "Order"
,@(mapcar
(lambda (sort)
(let ((name (car sort))
(readable-name (alist-get :readable-name (cdr sort))))
`(item :tag ,readable-name
:value ,name
:menu-tag ,readable-name)))
org-clock-agg-sort-functions))
(toggle :on "Reverse order" :off "Normal order"))))
(defun org-clock-agg--extras-notify (widget &rest _)
"Notify funciton for extra-params widgets.
WIDGET is the instance of the widget that was changed."
(let ((extras-key (widget-get widget :extras-key)))
(setf (alist-get extras-key
(alist-get :extra-params org-clock-agg--params))
(widget-value widget))))
(defun org-clock-agg--render-extra-params ()
"Render extra-params for the `org-clock-agg' buffer."
(pcase-dolist (`(,name . ,params) (append org-clock-agg--extra-params-default
org-clock-agg-extra-params))
(insert (propertize name 'face 'widget-button) " ")
(apply #'widget-create `(,@params :notify org-clock-agg--extras-notify))
(insert "\n")))