-
Notifications
You must be signed in to change notification settings - Fork 7
/
Copy pathorgtbl-aggregate.el
1783 lines (1647 loc) · 63.5 KB
/
orgtbl-aggregate.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
;;; orgtbl-aggregate.el --- Create an Org Mode aggregated table from another table -*- coding:utf-8; lexical-binding: t;-*-
;; Copyright (C) 2013-2025 Thierry Banel
;; Authors:
;; Thierry Banel tbanelwebmin at free dot fr
;; Michael Brand michael dot ch dot brand at gmail dot com
;; Contributors:
;; Eric Abrahamsen
;; Alejandro Erickson alejandro dot erickson at gmail dot com
;; Uwe Brauer
;; Peking Duck
;; Bill Hunker
;; Package-Requires: ((emacs "26.1"))
;; Version: 1.0
;; Keywords: data, extensions
;; URL: https://github.com/tbanel/orgaggregate/blob/master/README.org
;; orgtbl-aggregate 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.
;; orgtbl-aggregate 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 <http://www.gnu.org/licenses/>.
;;; Commentary:
;;
;; A new org-mode table is automatically updated,
;; based on another table acting as a data source
;; and user-given specifications for how to perform aggregation.
;;
;; Example:
;; Starting from a source table of activities and quantities
;; (whatever they are) over several days,
;;
;; #+TBLNAME: original
;; | Day | Color | Level | Quantity |
;; |-----------+-------+-------+----------|
;; | Monday | Red | 30 | 11 |
;; | Monday | Blue | 25 | 3 |
;; | Tuesday | Red | 51 | 12 |
;; | Tuesday | Red | 45 | 15 |
;; | Tuesday | Blue | 33 | 18 |
;; | Wednesday | Red | 27 | 23 |
;; | Wednesday | Blue | 12 | 16 |
;; | Wednesday | Blue | 15 | 15 |
;; | Thursday | Red | 39 | 24 |
;; | Thursday | Red | 41 | 29 |
;; | Thursday | Red | 49 | 30 |
;; | Friday | Blue | 7 | 5 |
;; | Friday | Blue | 6 | 8 |
;; | Friday | Blue | 11 | 9 |
;;
;; an aggregation is built for each day (because several rows
;; exist for each day), typing C-c C-c
;;
;; #+BEGIN: aggregate :table original :cols "Day mean(Level) sum(Quantity)"
;; | Day | mean(Level) | sum(Quantity) |
;; |-----------+-------------+---------------|
;; | Monday | 27.5 | 14 |
;; | Tuesday | 43 | 45 |
;; | Wednesday | 18 | 54 |
;; | Thursday | 43 | 83 |
;; | Friday | 8 | 22 |
;; #+END
;;
;; A wizard can be used:
;; C-c C-x x aggregate
;;
;; Full documentation here:
;; https://github.com/tbanel/orgaggregate/blob/master/README.org
;;; Requires:
(require 'calc-ext)
(require 'calc-aent)
(require 'calc-alg)
(require 'org)
(require 'org-table)
(eval-when-compile (require 'cl-lib))
(require 'rx)
(cl-proclaim '(optimize (speed 3) (safety 0)))
;;; Code:
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; creating long lists in the right order may be done
;; - by (nconc) but behavior is quadratic
;; - by (cons) (nreverse)
;; a third way involves keeping track of the last cons of the growing list
;; a cons at the head of the list is used for housekeeping
;; the actual list is (cdr ls)
(defsubst orgtbl-aggregate--list-create ()
"Create an appendable list."
(let ((x (cons nil nil)))
(setcar x x)))
(defmacro orgtbl-aggregate--list-append (ls value)
"Append VALUE at the end of LS in O(1) time."
`(setcar ,ls (setcdr (car ,ls) (cons ,value nil))))
(defmacro orgtbl-aggregate--list-get (ls)
"Return the regular Lisp list from LS."
`(cdr ,ls))
(defmacro orgtbl-aggregate--pop-simple (place)
"Like (pop PLACE), but without returning (car PLACE)."
`(setq ,place (cdr ,place)))
(defmacro orgtbl-aggregate--pop-leading-hline (table)
"Remove leading hlines from TABLE, if any."
`(while (not (listp (car ,table)))
(orgtbl-aggregate--pop-simple ,table)))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; The function (org-table-to-lisp) have been greatly enhanced
;; in Org Mode version 9.4
;; To benefit from this speedup in older versions of Org Mode,
;; this function is copied here with a slightly different name
;; It has also undergone near 3x speedup,
;; - by not using regexps
;; - achieving the shortest bytecode
;; Furthermore, this version avoids the
;; inhibit-changing-match-data and looking-at
;; incompatibilities between Emacs-27 and Emacs-30
(defun orgtbl-aggregate--table-to-lisp (&optional txt)
"Convert the table at point to a Lisp structure.
The structure will be a list. Each item is either the symbol `hline'
for a horizontal separator line, or a list of field values as strings.
The table is taken from the parameter TXT, or from the buffer at point."
(if txt
(with-temp-buffer
(buffer-disable-undo)
(insert txt)
(goto-char (point-min))
(orgtbl-aggregate--table-to-lisp))
(save-excursion
(goto-char (org-table-begin))
(let (table)
(while (progn (skip-chars-forward " \t")
(eq (following-char) ?|))
(forward-char)
(push
(if (eq (following-char) ?-)
'hline
(let (row)
(while (progn (skip-chars-forward " \t")
(not (eolp)))
(let ((q (point)))
(skip-chars-forward "^|\n")
(goto-char
(prog1
(let ((p (point)))
(unless (eolp) (setq p (1+ p)))
p)
(skip-chars-backward " \t" q)
(push
(buffer-substring-no-properties q (point))
row)))))
(nreverse row)))
table)
(forward-line))
(nreverse table)))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; Here is a bunch of useful utilities,
;; generic enough to be detached from the orgtbl-aggregate package.
;; For the time being, they are here.
(defun orgtbl-aggregate--list-local-tables ()
"Search for available tables in the current file."
(interactive)
(let ((tables))
(save-excursion
(goto-char (point-min))
(while (let ((case-fold-search t))
(re-search-forward
(rx bol
(* (any " \t")) "#+" (? "tbl") "name:"
(* (any " \t")) (group (* not-newline)))
nil t))
(push (match-string-no-properties 1) tables)))
tables))
(defun orgtbl-aggregate--get-distant-table (name-or-id)
"Find a table in the current buffer named NAME-OR-ID.
Return it as a Lisp list of lists.
An horizontal line is translated as the special symbol `hline'."
(unless (stringp name-or-id)
(setq name-or-id (format "%s" name-or-id)))
(let (buffer loc)
(save-excursion
(goto-char (point-min))
(if (let ((case-fold-search t))
(re-search-forward
;; This concat is automatically done by new versions of rx
;; using "literal". This appeared on june 26, 2019
;; For older versions of Emacs, we fallback to concat
(concat
(rx bol
(* (any " \t")) "#+" (? "tbl") "name:"
(* (any " \t")))
(regexp-quote name-or-id)
(rx (* (any " \t"))
eol))
nil t))
(setq buffer (current-buffer)
loc (match-beginning 0))
(let ((id-loc (org-id-find name-or-id 'marker)))
(unless (and id-loc (markerp id-loc))
(error "Can't find remote table \"%s\"" name-or-id))
(setq buffer (marker-buffer id-loc)
loc (marker-position id-loc))
(move-marker id-loc nil))))
(with-current-buffer buffer
(save-excursion
(goto-char loc)
(forward-char 1)
(unless (and (re-search-forward "^\\(\\*+ \\)\\|^[ \t]*|" nil t)
(not (match-beginning 1)))
(user-error "Cannot find a table at NAME or ID %s" name-or-id))
(orgtbl-aggregate--table-to-lisp)))))
(defun orgtbl-aggregate--remove-cookie-lines (table)
"Remove lines of TABLE which contain cookies.
But do not remove cookies in the header, if any.
The operation is destructive. But on the other hand,
if there are no cookies in TABLE, TABLE is returned
without any change.
A cookie is an alignment instruction like:
<l> left align cells in this column
<c> center cells
<r> right align
<15> make this column 15 characters wide."
(orgtbl-aggregate--pop-leading-hline table)
(cl-loop with hline = nil
for line on table
if (and hline
(cl-loop for cell in (car line)
thereis (string-match
(rx bol "<"
(? (any "lcr"))
(* (any "0-9"))
">" eol)
cell)))
do (setcar line t)
if (eq (car line) 'hline)
do (setq hline t))
(delq t table))
(defun orgtbl-aggregate--split-string-with-quotes (string)
"Like (split-string STRING), but with quote protection.
Single and double quotes protect space characters,
and also single quotes protect double quotes
and the other way around."
(let ((l (length string))
(start 0)
(result (orgtbl-aggregate--list-create)))
(save-match-data
(while (and (< start l)
(string-match
(rx
(* (any " \f\t\n\r\v"))
(group
(+ (or
(seq ?' (* (not (any ?'))) ?' )
(seq ?\" (* (not (any ?\"))) ?\")
(not (any " '\""))))))
string start))
(orgtbl-aggregate--list-append result (match-string 1 string))
(setq start (match-end 1))))
(orgtbl-aggregate--list-get result)))
(defun orgtbl-aggregate--colname-to-int (colname table &optional err)
"Convert the COLNAME into an integer.
COLNAME is a column name of TABLE.
The first column is numbered 1.
COLNAME may be:
- a dollar form, like $5 which is converted to 5
- an alphanumeric name which appears in the column header (if any)
- the special symbol `hline' which is converted into 0
If COLNAME is quoted (single or double quotes),
quotes are removed beforhand.
When COLNAME does not match any actual column,
an error is generated if ERR optional parameter is true
otherwise nil is returned."
(if (symbolp colname)
(setq colname (symbol-name colname)))
(if (string-match
(rx
bol
(or
(seq ?' (group-n 1 (* (not (any ?' )))) ?' )
(seq ?\" (group-n 1 (* (not (any ?\")))) ?\"))
eol)
colname)
(setq colname (match-string 1 colname)))
;; skip first hlines if any
(orgtbl-aggregate--pop-leading-hline table)
(cond ((equal colname "")
(and err (user-error "Empty column name")))
((equal colname "hline")
0)
((string-match (rx bol "$" (group (+ (any "0-9"))) eol) colname)
(let ((n (string-to-number (match-string 1 colname))))
(if (<= n (length (car table)))
n
(if err
(user-error "Column %s outside table" colname)))))
((and
(memq 'hline table)
(cl-loop
for h in (car table)
for i from 1
thereis (and (equal h colname) i))))
(err
(user-error "Column %s not found in table" colname))))
(defun orgtbl-aggregate--insert-make-spaces (n spaces-cache)
"Make a string of N spaces.
Caches results into SPACES-CACHE to avoid re-allocating
again and again the same string."
(if (< n (length spaces-cache))
(or (aref spaces-cache n)
(aset spaces-cache n (make-string n ? )))
(make-string n ? )))
(defun orgtbl-aggregate--insert-elisp-table (table)
"Insert TABLE in current buffer at point.
TABLE is a list of lists of cells. The list may contain the
special symbol `hline' to mean an horizontal line."
(let* ((nbcols (cl-loop
for row in table
maximize (if (listp row) (length row) 0)))
(maxwidths (make-list nbcols 1))
(numbers (make-list nbcols 0))
(non-empty (make-list nbcols 0))
(spaces-cache (make-vector 100 nil)))
;; compute maxwidths
(cl-loop for row in table
do
(cl-loop for cell on row
for mx on maxwidths
for nu on numbers
for ne on non-empty
for cellnp = (car cell)
do (cond ((not cellnp)
(setcar cell (setq cellnp "")))
((not (stringp cellnp))
(setcar cell (setq cellnp (format "%s" cellnp)))))
if (string-match-p org-table-number-regexp cellnp)
do (setcar nu (1+ (car nu)))
unless (equal cellnp "")
do (setcar ne (1+ (car ne)))
if (< (car mx) (string-width cellnp))
do (setcar mx (string-width cellnp))))
;; change meaning of numbers from quantity of cells with numbers
;; to flags saying whether alignment should be left (number alignment)
(cl-loop for nu on numbers
for ne in non-empty
do
(setcar nu (< (car nu) (* org-table-number-fraction ne))))
;; inactivating jit-lock-after-change boosts performance a lot
(cl-letf (((symbol-function 'jit-lock-after-change) (lambda (_a _b _c)) ))
;; insert well padded and aligned cells at current buffer position
(cl-loop for row in table
do
;; time optimization: surprisingly,
;; (insert (concat a b c)) is faster than
;; (insert a b c)
(insert
(mapconcat
#'identity
(nconc
(if (listp row)
(cl-loop for cell in row
for mx in maxwidths
for nu in numbers
for pad = (- mx (string-width cell))
collect "| "
;; no alignment
if (<= pad 0)
collect cell
;; left alignment
else if nu
collect cell and
collect (orgtbl-aggregate--insert-make-spaces pad spaces-cache)
;; right alignment
else
collect (orgtbl-aggregate--insert-make-spaces pad spaces-cache) and
collect cell
collect " ")
(cl-loop for bar = "|" then "+"
for mx in maxwidths
collect bar
collect (make-string (+ mx 2) ?-)))
(list "|\n"))
""))))))
(defun orgtbl-aggregate--get-header-table (table &optional asstring)
"Return the header of TABLE as a list of column names.
When ASSTRING is true, the result is a string which concatenates the
names of the columns. TABLE may be a Lisp list of rows, or the
name or id of a distant table. The function takes care of
possibly missing headers, and in this case returns a list
of $1, $2, $3... column names.
Actual column names which are not fully alphanumeric are quoted."
(unless (consp table)
(setq table (orgtbl-aggregate--get-distant-table table)))
(orgtbl-aggregate--pop-leading-hline table)
(let ((header
(if (memq 'hline table)
(cl-loop for x in (car table)
collect
(if (string-match "^[[:word:]_$.]+$" x)
x
(format "\"%s\"" x)))
(cl-loop for _x in (car table)
for i from 1
collect (format "$%s" i)))))
(if asstring
(mapconcat #'identity header " ")
header)))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; The venerable Calc is used thoroughly by the Aggregate package.
;; A few bugs were found.
;; They have been fixed in recent versions of Emacs
;; Uncomment the fixes if needed
;(defun math-max-list (a b)
; (if b
; (if (or (Math-anglep (car b)) (eq (caar b) 'date)
; (and (eq (car (car b)) 'intv) (math-intv-constp (car b)))
; (math-infinitep (car b)))
; (math-max-list (math-max a (car b)) (cdr b))
; (math-reject-arg (car b) 'anglep))
; a))
;
;(defun math-min-list (a b)
; (if b
; (if (or (Math-anglep (car b)) (eq (caar b) 'date)
; (and (eq (car (car b)) 'intv) (math-intv-constp (car b)))
; (math-infinitep (car b)))
; (math-min-list (math-min a (car b)) (cdr b))
; (math-reject-arg (car b) 'anglep))
; a))
;; End of Calc fixes
;; The *this* variable is accessible to the user.
;; It refers to the aggregated table before it is "printed"
;; into the buffer, so that it can be post-processed.
(defvar *this*)
(defun orgtbl-aggregate--post-process (table post)
"Post-process the aggregated TABLE according to the :post header.
POST might be:
- a reference to a babel-block, for example:
:post \"myprocessor(inputtable=*this*)\"
and somewhere else:
#+name: myprocessor
#+begin_src language :var inputtable=
...
#+end_src
- a Lisp lambda with one parameter, for example:
:post (lambda (table) (append table \\'(hline (\"total\" 123))))
- a Lisp function with one parameter, for example:
:post my-lisp-function
- a Lisp expression which will be evaluated
the *this* variable will contain the TABLE
In all those cases, the result must be a Lisp value compliant
with an Org Mode table."
(cond
((null post) table)
((functionp post)
(apply post table ()))
((stringp post)
(let ((*this* table))
(condition-case err
(org-babel-ref-resolve post)
(error
(message "error: %S" err)
(orgtbl-aggregate--post-process table (read post))))))
((listp post)
(let ((*this* table))
(eval post)))
(t (user-error ":post %S header could not be understood" post))))
(require 'calc-arith)
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; The Org Table Aggregation package really begins here
(defun orgtbl-aggregate--replace-colnames-nth (table expression)
"Replace occurrences of column names in Lisp EXPRESSION.
Replacements are forms like (nth N row),
N being the numbering of columns.
Doing so, EXPRESSION is ready to be computed against a TABLE row."
(cond
((listp expression)
(cons (car expression)
(cl-loop for x in (cdr expression)
collect
(orgtbl-aggregate--replace-colnames-nth table x))))
((numberp expression)
expression)
(t
(let ((n (orgtbl-aggregate--colname-to-int expression table)))
(if n
(list 'nth n 'orgtbl-aggregate--row)
expression)))))
;; dynamic binding
(defvar orgtbl-aggregate--var-keycols)
(cl-defstruct orgtbl-aggregate--outcol
formula ; user-entered formula to compute output cells
format ; user-entered formatter of output cell
sort ; user-entered sorting instruction for output column
invisible ; user-entered output column invisibility
name ; user-entered output column name
formula$ ; derived formula with $N instead of input column names
involved ; list of input columns numbers appearing in formula
formula-frux ; derived formula in Calc format with Frux(N) for input columns
key ; is this output column a key-column?
)
(defun orgtbl-aggregate--parse-col (col table)
"Parse COL specification into an ORGTBL-AGGREGATE--OUTCOL structure.
COL is a column specification. It is a string text:
\"formula;formatter;^sorting;<invisible>;'alternate_name'\"
If there is no formatter or sorting or other specifier,
nil is given in place. The other fields of orgtbl-aggregate--OUTCOL are
filled here too, and nowhere else.
TABLE is used to convert a column name
into the column number."
;; parse user specification
(unless (string-match
(rx
bol
(group-n 1
(* (or
(seq ?' (* (not (any ?'))) ?' )
(seq ?\" (* (not (any ?\"))) ?\")
(not (any ";'\"")))))
(*
";"
(or
(seq (group-n 2 (* (not (any "^;'\"<")))))
(seq "^" (group-n 3 (* (not (any "^;'\"<")))))
(seq "<" (group-n 4 (* (not (any "^;'\">")))) ">")
(seq "'" (group-n 5 (* (not (any "'")))) "'")))
eol)
col)
(user-error "Bad column specification: %S" col))
(let* ((formula (match-string 1 col))
(format (match-string 2 col))
(sort (match-string 3 col))
(invisible (match-string 4 col))
(name (match-string 5 col))
;; list the input column numbers which are involved
;; into formula
(involved nil)
;; create a derived formula in Calc format,
;; where names of input columns are replaced with
;; frux(N)
(frux
(replace-regexp-in-string
(rx
(or
(seq ?' (* (not (any ?' ))) ?')
(seq ?\" (* (not (any ?\"))) ?\")
(seq (+ (any word "_$."))))
(? (* space) "("))
(lambda (var)
(save-match-data ;; save because we are called within a replace-regexp
(if (string-match (rx (group (+ (not (any "(")))) (* space) "(") var)
(if (member
(match-string 1 var)
'("mean" "meane" "gmean" "hmean" "median" "sum"
"min" "max" "prod" "pvar" "sdev" "psdev"
"corr" "cov" "pcov" "count" "span" "var"))
;; aggregate functions with or without the leading "v"
;; for example, sum(X) and vsum(X) are equivalent
(format "v%s" var)
var)
;; replace VAR if it is a column name
(let ((i (orgtbl-aggregate--colname-to-int
var
table)))
(if i
(progn
(unless (member i involved)
(push i involved))
(format "Frux(%s)" i))
var)))))
formula))
;; create a derived formula where input column names
;; are replaced with $N
(formula$
(replace-regexp-in-string
(rx "Frux(" (+ (any "0-9")) ")")
(lambda (var)
(save-match-data
(string-match
(rx (group (+ (any "0-9"))))
var)
(format "$%s" (match-string 1 var))))
frux))
;; if a formula is just an input column name,
;; then it is a key-grouping-column
(key
(if (string-match
(rx
bol
(group
(or (seq "'" (* (not (any "'" ))) "'" )
(seq "\"" (* (not (any "\""))) "\"")
(+ (any word "_$."))))
eol)
formula)
(orgtbl-aggregate--colname-to-int formula table t))))
(if key (push key orgtbl-aggregate--var-keycols))
(make-orgtbl-aggregate--outcol
:formula formula
:format format
:sort sort
:invisible invisible
:name name
:formula-frux (math-read-expr frux)
:formula$ formula$
:involved involved
:key key)))
;; dynamic binding
(defvar orgtbl-aggregate--columns-sorting)
(cl-defstruct orgtbl-aggregate--sorting
strength
colnum
ascending
extract
compare)
(defun orgtbl-aggregate--prepare-sorting (aggcols)
"Create a list of columns to be sorted.
Columns are searched into AGGCOLS.
The resulting list will be used by
`orgtbl-aggregate--columns-sorting'.
The list contains sorting specifications as follows:
. sorting strength
. column number
. ascending descending
. extract function
. compare function
- sorting strength is a number telling what column should be
considered first:
. lower number are considered first
. nil are condirered last
- column number is as in the user specification
1 is the first user specified column
- ascending descending is nil for ascending, t for descending
- extract function converts the input cell (which is a string)
into a comparable value
- compare function compares two cells and answers nil if
the first cell must come before the second."
(cl-loop for col in aggcols
for sorting = (orgtbl-aggregate--outcol-sort col)
for colnum from 0
if sorting
do (progn
(unless (string-match
(rx bol (group (any "aAnNtTfF")) (group (* (any num))) eol)
sorting)
(user-error
"Bad sorting specification: ^%s, expecting a/A/n/N/t/T and an optional number"
sorting))
(orgtbl-aggregate--list-append
orgtbl-aggregate--columns-sorting
(let ((strength
(if (equal (match-string 2 sorting) "")
nil
(string-to-number (match-string 2 sorting)))))
(pcase (match-string 1 sorting)
("a" (record 'orgtbl-aggregate--sorting strength colnum nil #'identity #'string-lessp))
("A" (record 'orgtbl-aggregate--sorting strength colnum t #'identity #'string-lessp))
("n" (record 'orgtbl-aggregate--sorting strength colnum nil #'string-to-number #'<))
("N" (record 'orgtbl-aggregate--sorting strength colnum t #'string-to-number #'<))
("t" (record 'orgtbl-aggregate--sorting strength colnum nil #'orgtbl-aggregate--string-to-time #'<))
("T" (record 'orgtbl-aggregate--sorting strength colnum t #'orgtbl-aggregate--string-to-time #'<))
((or "f" "F") (user-error "f/F sorting specification not (yet) implemented"))
(_ (user-error "Bad sorting specification ^%s" sorting)))))))
;; major sorting columns must come before minor sorting columns
(setq orgtbl-aggregate--columns-sorting
(sort (orgtbl-aggregate--list-get orgtbl-aggregate--columns-sorting)
(lambda (a b)
(if (null (orgtbl-aggregate--sorting-strength a))
(and (null (orgtbl-aggregate--sorting-strength b))
(< (orgtbl-aggregate--sorting-colnum a)
(orgtbl-aggregate--sorting-colnum b)))
(or (null (orgtbl-aggregate--sorting-strength b))
(< (orgtbl-aggregate--sorting-strength a)
(orgtbl-aggregate--sorting-strength b))
(and (= (orgtbl-aggregate--sorting-strength a)
(orgtbl-aggregate--sorting-strength b))
(< (orgtbl-aggregate--sorting-colnum a)
(orgtbl-aggregate--sorting-colnum b)))))))))
;; escape lexical binding to eval user given
;; Lisp expression
(defvar orgtbl-aggregate--row)
(defun orgtbl-aggregate--table-add-group (groups hgroups row aggcond)
"Add the source ROW to the GROUPS of rows.
If ROW fits a group within GROUPS, then it is added at the end
of this group.
Otherwise a new group is added at the end of GROUPS,
containing this single ROW.
AGGCOND is a formula which is evaluated against ROW.
If nil, ROW is just discarded.
HGROUPS contains the same information as GROUPS, stored in
a hash-table, whereas GROUPS is a Lisp list."
(and (or (not aggcond)
(let ((orgtbl-aggregate--row row))
;; this eval need the variable 'orgtbl-aggregate--row
;; to have a value
(eval aggcond)))
(let ((gr (gethash row hgroups)))
(unless gr
(setq gr (orgtbl-aggregate--list-create))
(puthash row gr hgroups)
(orgtbl-aggregate--list-append groups gr))
(orgtbl-aggregate--list-append gr row))))
(defun orgtbl-aggregate--read-calc-expr (expr)
"Interpret EXPR (a string) as either an org date or a calc expression."
(cond
;; nil happens when a table is malformed
;; some columns are missing in some rows
((not expr) nil)
;; empty cell returned as nil,
;; to be processed later depending on modifier flags
((equal expr "") nil)
;; the purely numerical cell case arises very often
;; short-circuiting general functions boosts performance (a lot)
((and
(string-match
(rx bos
(? (any "+-")) (* (any "0-9"))
(? "." (* (any "0-9")))
(? "e" (? (any "+-")) (+ (any "0-9")))
eos)
expr)
(not (string-match (rx bos (* (any "+-.")) "e") expr)))
(math-read-number expr))
;; Convert an Org-mode date to Calc internal representation
((string-match org-ts-regexp0 expr)
(math-parse-date (replace-regexp-in-string " *[a-z]+[.]? *" " " expr)))
;; Convert a duration into a number of seconds
((string-match
(rx bos
(group (one-or-more (any "0-9")))
":"
(group (any "0-9") (any "0-9"))
(? ":" (group (any "0-9") (any "0-9")))
eos)
expr)
(+
(* 3600 (string-to-number (match-string 1 expr)))
(* 60 (string-to-number (match-string 2 expr)))
(if (match-string 3 expr) (string-to-number (match-string 3 expr)) 0)))
;; generic case: symbolic calc expression
(t
(math-simplify
(calcFunc-expand
(math-read-expr expr))))))
(defun orgtbl-aggregate--hash-test-equal (row1 row2)
"Are ROW1 & ROW2 equal regarding the key columns?"
(cl-loop for idx in orgtbl-aggregate--var-keycols
always (string= (nth idx row1) (nth idx row2))))
;; for hashes, try to stay within the 2^29 fixnums
;; see (info "(elisp) Integer Basics")
;; { prime_next 123 ==> 127 }
;; { prime_prev ((2^29 - 256) / 127 ) ==> 4227323 }
(defun orgtbl-aggregate--hash-test-hash (row)
"Compute a hash code for ROW from key columns."
(let ((h 45235))
(cl-loop for idx in orgtbl-aggregate--var-keycols
do
(cl-loop for c across (nth idx row)
do (setq h (% (* (+ h c) 127) 4227323))))
h))
(defun orgtbl-aggregate--create-table-aggregated (table params)
"Convert the source TABLE into an aggregated table.
The source TABLE is a list of lists of cells.
The resulting table follows the specifications,
found in PARAMS entry :cols, ignoring source rows
which do not pass the filter found in PARAMS entry :cond."
(orgtbl-aggregate--pop-leading-hline table)
(define-hash-table-test
'orgtbl-aggregate--hash-test-name
#'orgtbl-aggregate--hash-test-equal
#'orgtbl-aggregate--hash-test-hash)
(let ((groups (orgtbl-aggregate--list-create))
(hgroups (make-hash-table :test 'orgtbl-aggregate--hash-test-name))
(aggcols (plist-get params :cols))
(aggcond (plist-get params :cond))
(hline (plist-get params :hline))
;; a global variable, passed to the sort predicate
(orgtbl-aggregate--columns-sorting (orgtbl-aggregate--list-create))
;; another global variable
(orgtbl-aggregate--var-keycols))
(unless aggcols
(setq aggcols (orgtbl-aggregate--get-header-table table)))
(if (stringp aggcols)
(setq aggcols (orgtbl-aggregate--split-string-with-quotes aggcols)))
(cl-loop for col on aggcols
do (setcar col (orgtbl-aggregate--parse-col (car col) table)))
(when aggcond
(if (stringp aggcond)
(setq aggcond (read aggcond)))
(setq aggcond
(orgtbl-aggregate--replace-colnames-nth table aggcond)))
(setq hline
(cond ((null hline)
0)
((numberp hline)
hline)
((string-match-p (rx bol (or "yes" "t") eol) hline)
1)
((string-match-p (rx bol (or "no" "nil") eol) hline)
0)
((string-match-p "[0-9]+" hline)
(string-to-number hline))
(t
(user-error
":hline parameter should be 0, 1, 2, 3, ... or yes, t, no, nil, not %S"
hline))))
;; special case: no sorting column but :hline 1 required
;; then a hidden hline column is added
(if (and (> hline 0)
(cl-loop for col in aggcols
never (orgtbl-aggregate--outcol-sort col)))
(push
(orgtbl-aggregate--parse-col "hline;^n;<>" table)
aggcols))
(orgtbl-aggregate--prepare-sorting aggcols)
; split table into groups of rows
(cl-loop with b = 0
with bs = "0"
for row in
(or (cdr (memq 'hline table)) ;; skip header if any
table)
do
(cond ((eq row 'hline)
(setq b (1+ b)
bs (number-to-string b)))
((listp row)
(orgtbl-aggregate--table-add-group
groups
hgroups
(cons bs row)
aggcond))))
(let ((result ;; pre-allocate all resulting rows
(cl-loop for _x in (orgtbl-aggregate--list-get groups)
collect (orgtbl-aggregate--list-create)))
(all-$list
(cl-loop for _x in (orgtbl-aggregate--list-get groups)
collect (make-vector (length (car table)) nil))))
;; inactivating those two functions boosts performance
(cl-letf (((symbol-function 'math-read-preprocess-string) #'identity)
((symbol-function 'calc-input-angle-units) (lambda (_x) nil)))
;; do aggregation
(cl-loop for coldesc in aggcols
do
(orgtbl-aggregate--compute-sums-on-one-column
groups result coldesc all-$list)))
;; sort table according to columns described in
;; orgtbl-aggregate--columns-sorting
(if orgtbl-aggregate--columns-sorting ;; are there sorting instructions?
(setq result (sort result #'orgtbl-aggregate--sort-predicate)))
;; add hlines if requested
(if (> hline 0)
(orgtbl-aggregate--add-hlines result hline))
(push 'hline result)
;; add other lines of the original header, if any;
;; this is done only if the aggregated column refers to
;; a single source column (either a key column or within
;; an aggregated formula)
(orgtbl-aggregate--pop-leading-hline table)
(if (memq 'hline table)
(cl-loop
for i from (cl-loop
for i from -1
for x in table
until (eq x 'hline)
finally return i)
downto 1
do (push
(cons
nil
(cl-loop for column in aggcols
collect
(if (equal (length (orgtbl-aggregate--outcol-involved column)) 1)
(let ((n (1- (car (orgtbl-aggregate--outcol-involved column)))))
(if (>= n 0)
(nth n (nth i table))
""))
"")))
result)))
;; add the header to the resulting table with column names
;; as they appear in :cols but without decorations
(push
(cons
nil
(cl-loop for column in aggcols
collect (or
(orgtbl-aggregate--outcol-name column)
(orgtbl-aggregate--outcol-formula column))))
result)
;; remove invisible columns by modifying the table in-place
;; beware! it assumes that the actual list in orgtbl-aggregate--lists
;; is pointed to by the cdr of the orgtbl-aggregate--list
(if (cl-loop for col in aggcols
thereis (orgtbl-aggregate--outcol-invisible col))
(cl-loop for row in result
if (consp row)
do (cl-loop for col in aggcols
with cel = row
if (orgtbl-aggregate--outcol-invisible col)
do (setcdr cel (cddr cel))
else do (orgtbl-aggregate--pop-simple cel))))
;; change appendable-lists to regular lists
(cl-loop for row on result
if (consp (car row))
do (setcar row (orgtbl-aggregate--list-get (car row))))
result)))
(defun orgtbl-aggregate--sort-predicate (linea lineb)
"Compares LINEA & LINEB (which are Org Mode table rows)
according to orgtbl-aggregate--columns-sorting instructions.
Return nil if LINEA already comes before LINEB."
(setq linea (orgtbl-aggregate--list-get linea))
(setq lineb (orgtbl-aggregate--list-get lineb))
(cl-loop for col in orgtbl-aggregate--columns-sorting
for colnum = (orgtbl-aggregate--sorting-colnum col)
for desc = (orgtbl-aggregate--sorting-ascending col)
for extract = (orgtbl-aggregate--sorting-extract col)
for compare = (orgtbl-aggregate--sorting-compare col)
for cola = (funcall extract (nth colnum (if desc lineb linea)))
for colb = (funcall extract (nth colnum (if desc linea lineb)))
thereis (funcall compare cola colb)
until (funcall compare colb cola)))
(defun orgtbl-aggregate--string-to-time (f)
"Interprete the string F into a duration in minutes.
The code was borrowed from org-table.el."
(cond ((string-match org-ts-regexp-both f)
(float-time
(org-time-string-to-time (match-string 0 f))))
((org-duration-p f) (org-duration-to-minutes f))
((string-match "\\<[0-9]+:[0-9]\\{2\\}\\>" f)
(org-duration-to-minutes (match-string 0 f)))
(t 0)))