full distribution over sparkline indicators
[barcat.git] / barcat
1 #!/usr/bin/perl -CA
2 use 5.014;
3 use warnings;
4 use utf8;
5 use List::Util qw( min max sum );
6 use open qw( :std :utf8 );
7 use re '/msx';
8
9 our $VERSION = '1.08';
10
11 my %opt;
12 if (@ARGV) {
13 require Getopt::Long;
14 Getopt::Long->import('2.33', qw( :config gnu_getopt ));
15 GetOptions(\%opt,
16         'ascii|a!',
17         'color|c!',
18         'C' => sub { $opt{color} = 0 },
19         'field|f=s' => sub {
20                 eval {
21                         local $_ = $_[1];
22                         $opt{anchor} = /\A[0-9]+\z/ ? qr/(?:\S*\h+){$_}\K/ : qr/$_/;
23                 } or die $@ =~ s/(?:\ at\ \N+)?\Z/ for option $_[0]/r;
24         },
25         'human-readable|H!',
26         'interval|t:i',
27         'trim|length|l=s' => sub {
28                 my ($optname, $optval) = @_;
29                 $optval =~ s/%$// and $opt{trimpct}++;
30                 $optval =~ m/\A-?[0-9]+\z/ or die(
31                         "Value \"$optval\" invalid for option $optname",
32                         " (number or percentage expected)\n"
33                 );
34                 $opt{trim} = $optval;
35         },
36         'value-length=i',
37         'hidemin=i',
38         'hidemax=i',
39         'minval=f',
40         'maxval=f',
41         'limit|L:s' => sub {
42                 my ($optname, $optval) = @_;
43                 $optval ||= 0;
44                 $optval =~ /\A-[0-9]+\z/ and $optval .= '-';  # tail shorthand
45                 ($opt{hidemin}, $opt{hidemax}) =
46                 $optval =~ m/\A (?: (-? [0-9]+)? - )? ([0-9]+)? \z/ or die(
47                         "Value \"$optval\" invalid for option limit",
48                         " (range expected)\n"
49                 );
50         },
51         'header!',
52         'markers|m=s',
53         'graph-format=s' => sub {
54                 $opt{'graph-format'} = substr $_[1], 0, 1;
55         },
56         'spark:s' => sub {
57                 $opt{spark} = [split //,
58                         $_[1] || ($opt{ascii} ? ' .oO' : ' ▁▂▃▄▅▆▇█')
59                 ];
60         },
61         'palette=s' => sub {
62                 $opt{palette} = {
63                         fire   => [qw( 90 31 91 33 93 97 96 )],
64                         fire88 => [map {"38;5;$_"} qw(
65                                 80  32 48 64  68 72 76  77 78 79  47
66                         )],
67                         fire256=> [map {"38;5;$_"} qw(
68                                 235  52 88 124 160 196
69                                 202 208 214 220 226  227 228 229 230 231  159
70                         )],
71                         ramp88 => [map {"38;5;$_"} qw(
72                                 64 65 66 67 51 35 39 23 22 26 25 28
73                         )],
74                         whites => [qw( 1;30 0;37 1;37 )],
75                         greys  => [map {"38;5;$_"} 52, 235..255, 47],
76                 }->{$_[1]} // [ split /[^0-9;]/, $_[1] ];
77         },
78         'stat|s!',
79         'signal-stat=s',
80         'unmodified|u!',
81         'width|w=i',
82         'version|V' => sub {
83                 my $mascot = $opt{ascii} ? '=^,^=' : 'ฅ^•ﻌ•^ฅ';
84                 say "barcat $mascot version $VERSION";
85                 exit;
86         },
87         'usage|h' => sub {
88                 /^=/ ? last : print for readline *DATA;  # text between __END__ and pod
89                 exit;
90         },
91         'help|?'  => sub {
92                 require Pod::Usage;
93                 Pod::Usage::pod2usage(
94                         -exitval => 0, -perldocopt => '-oman', -verbose => 2,
95                 );
96         },
97 ) or exit 64;  # EX_USAGE
98 }
99
100 $opt{width} ||= $ENV{COLUMNS} || qx(tput cols) || 80 unless $opt{spark};
101 $opt{color} //= -t *STDOUT;  # enable on tty
102 $opt{'graph-format'} //= '-';
103 $opt{trim}   *= $opt{width} / 100 if $opt{trimpct};
104 $opt{units}   = [split //, ' kMGTPEZYyzafpn'.($opt{ascii} ? 'u' : 'μ').'m']
105         if $opt{'human-readable'};
106 $opt{anchor} //= qr/\A/;
107 $opt{'value-length'} = 6 if $opt{units};
108 $opt{'value-length'} = 1 if $opt{unmodified};
109 $opt{'signal-stat'} //= exists $SIG{INFO} ? 'INFO' : 'QUIT';
110 $opt{markers} //= '=avg >31.73v <68.27v +50v |0';
111 $opt{palette} //= $opt{color} && [31, 90, 32];
112 $opt{hidemin} = ($opt{hidemin} || 1) - 1;
113 $opt{input} = (@ARGV && $ARGV[0] =~ m/\A[-0-9]/) ? \@ARGV : undef
114         and undef $opt{interval};
115
116 $opt{'sum-format'} = sub { sprintf '%.8g', $_[0] };
117 $opt{'calc-format'} = sub { sprintf '%*.*f', 0, 2, $_[0] };
118 $opt{'value-format'} = $opt{units} && sub {
119         my $unit = (
120                 log(abs $_[0] || 1) / log(10)
121                 - 3 * (abs($_[0]) < .9995)   # shift to smaller unit if below 1
122                 + 1e-15  # float imprecision
123         );
124         my $decimal = ($unit % 3) == ($unit < 0);
125         $unit -= log($decimal ? .995 : .9995) / log(10);  # rounded
126         $decimal = ($unit % 3) == ($unit < 0);
127         $decimal &&= $_[0] !~ /^-?0*[0-9]{1,3}$/;  # integer 0..999
128         sprintf('%*.*f%1s',
129                 3 + ($_[0] < 0), # digits plus optional negative sign
130                 $decimal,  # tenths
131                 $_[0] / 1000 ** int($unit/3),  # number
132                 $#{$opt{units}} * 1.5 < abs $unit ? sprintf('e%d', $unit) :
133                         $opt{units}->[$unit/3]  # suffix
134         );
135 };
136
137
138 my (@lines, @values, @order);
139
140 $SIG{$_} = \&show_stat for $opt{'signal-stat'} || ();
141 $SIG{ALRM} = sub {
142         show_lines();
143         alarm $opt{interval} if defined $opt{interval} and $opt{interval} > 0;
144 };
145 $SIG{INT} = \&show_exit;
146
147 if (defined $opt{interval}) {
148         $opt{interval} ||= 1;
149         alarm $opt{interval} if $opt{interval} > 0;
150
151         eval {
152                 require Tie::Array::Sorted;
153                 tie @order, 'Tie::Array::Sorted', sub { $_[1] <=> $_[0] };
154         } or warn $@, "Expect slowdown with large datasets!\n";
155 }
156
157 my $valmatch = qr<
158         $opt{anchor} ( \h* -? [0-9]* [.]? [0-9]+ (?: e[+-]?[0-9]+ )? |)
159 >x;
160 while (defined ($_ = $opt{input} ? shift @{ $opt{input} } : readline)) {
161         s/\r?\n\z//;
162         s/\A\h*// unless $opt{unmodified};
163         my $valnum = s/$valmatch/\n/ && $1;
164         push @values, $valnum;
165         push @order, $valnum if length $valnum;
166         if (defined $opt{trim} and defined $valnum) {
167                 my $trimpos = abs $opt{trim};
168                 $trimpos -= length $valnum if $opt{unmodified};
169                 if ($trimpos <= 1) {
170                         $_ = substr $_, 0, 2;
171                 }
172                 elsif (length > $trimpos) {
173                         # cut and replace (intentional lvalue for speed, contrary to PBP)
174                         substr($_, $trimpos - 1) = $opt{ascii} ? '>' : '…';
175                 }
176         }
177         push @lines, $_;
178         show_lines() if defined $opt{interval} and $opt{interval} < 0
179                 and $. % $opt{interval} == 0;
180 }
181
182 if ($opt{'zero-missing'}) {
183         push @values, (0) x 10;
184 }
185
186 $SIG{INT} = 'DEFAULT';
187
188 sub color {
189         $opt{color} and defined $_[0] or return '';
190         return "\e[$_[0]m" if defined wantarray;
191         $_ = color(@_) . $_ . color(0) if defined;
192 }
193
194 sub show_lines {
195
196 state $nr =
197         $opt{hidemin} < 0 ? @lines + $opt{hidemin} + 1 :
198         $opt{hidemin};
199 @lines > $nr or return;
200
201 my $limit = $#lines;
202 if (defined $opt{hidemax}) {
203         if ($opt{hidemin} and $opt{hidemin} < 0) {
204                 $limit -= $opt{hidemax} - 1;
205         }
206         else {
207                 $limit = $opt{hidemax} - 1;
208         }
209 }
210
211 @order = sort { $b <=> $a } @order unless tied @order;
212 my $maxval = $opt{maxval} // (
213         $opt{hidemax} ? max grep { length } @values[$nr .. $limit] :
214         $order[0]
215 ) // 0;
216 my $minval = $opt{minval} // min $order[-1] // (), 0;
217 my $range = $maxval - $minval;
218 my $lenval = $opt{'value-length'} // max map { length } @order;
219 my $len    = defined $opt{trim} && $opt{trim} <= 0 ? -$opt{trim} + 1 :
220         max map { length $values[$_] && length $lines[$_] }
221                 0 .. min $#lines, $opt{hidemax} || ();  # left padding
222 my $size   = defined $opt{width} && $range &&
223         ($opt{width} - $lenval - $len) / $range;  # bar multiplication
224
225 my @barmark;
226 if ($opt{markers} and $size > 0) {
227         for my $markspec (split /\h/, $opt{markers}) {
228                 my ($char, $func) = split //, $markspec, 2;
229                 my $pos = eval {
230                         if ($func eq 'avg') {
231                                 return sum(@order) / @order;
232                         }
233                         elsif ($func =~ /\A([0-9.]+)v\z/) {
234                                 die "Invalid marker $char: percentile $1 out of bounds\n" if $1 > 100;
235                                 my $index = $#order * $1 / 100;
236                                 return ($order[$index] + $order[$index + .5]) / 2;
237                         }
238                         elsif ($func =~ /\A-?[0-9.]+\z/) {
239                                 return $func;
240                         }
241                         else {
242                                 die "Unknown marker $char: $func\n";
243                         }
244                 };
245                 defined $pos or do {
246                         warn $@ if $@;
247                         next;
248                 };
249                 $pos -= $minval;
250                 $pos >= 0 or next;
251                 color(36) for $barmark[$pos * $size] = $char;
252         }
253
254         state $lastmax = $maxval;
255         if ($maxval > $lastmax) {
256                 print ' ' x ($lenval + $len);
257                 printf color(90);
258                 printf '%-*s',
259                         ($lastmax - $minval) * $size + .5,
260                         '-' x (($values[$nr - 1] - $minval) * $size);
261                 print color(92);
262                 say '+' x (($range - $lastmax) * $size + .5);
263                 print color(0);
264                 $lastmax = $maxval;
265         }
266 }
267
268 say(
269         color(31), sprintf('%*s', $lenval, $minval),
270         color(90), '-', color(36), '+',
271         color(32), sprintf('%*s', $size * $range - 3, $maxval),
272         color(90), '-', color(36), '+',
273         color(0),
274 ) if $opt{header};
275
276 while ($nr <= $limit) {
277         my $val = $values[$nr];
278         my $rel = length $val && $range && ($val - $minval) / $range;
279         my $color = !length $val || !$opt{palette} ? undef :
280                 $val == $order[0] ? $opt{palette}->[-1] : # max
281                 $val == $order[-1] ? $opt{palette}->[0] : # min
282                 $opt{palette}->[ $rel * ($#{$opt{palette}} - 1) + 1 ];
283
284         if ($opt{spark}) {
285                 say '' if $opt{width} and $nr and $nr % $opt{width} == 0;
286                 print color($color), $opt{spark}->[
287                         !$val || !$#{$opt{spark}} ? 0 : # blank
288                         $#{$opt{spark}} < 2 ? 1 :
289                         $val >= $order[0] ? -1 :
290                         $rel * ($#{$opt{spark}} - 1e-14) + 1
291                 ];
292                 next;
293         }
294
295         if (length $val) {
296                 $val = $opt{'value-format'} ? $opt{'value-format'}->($val) :
297                         sprintf "%*s", $lenval, $val;
298                 color($color) for $val;
299         }
300         my $line = $lines[$nr] =~ s/\n/$val/r;
301         if (not length $val) {
302                 say $line;
303                 next;
304         }
305         printf '%-*s', $len + length($val), $line;
306         print $barmark[$_] // $opt{'graph-format'}
307                 for 1 .. $size && (($values[$nr] || 0) - $minval) * $size + .5;
308         say '';
309 }
310 continue {
311         $nr++;
312 }
313 say $opt{palette} ? color(0) : '' if $opt{spark};
314
315         return $nr;
316 }
317
318 sub show_stat {
319         if ($opt{hidemin} or $opt{hidemax}) {
320                 my $linemin = $opt{hidemin};
321                 my $linemax = ($opt{hidemax} || @lines) - 1;
322                 if ($linemin < 0) {
323                         $linemin += @lines;
324                         $linemax = @lines - $linemax;
325                 }
326                 printf '%.8g of ', $opt{'sum-format'}->(
327                         sum(grep {length} @values[$linemin .. $linemax]) // 0
328                 );
329         }
330         if (@order) {
331                 my $total = sum @order;
332                 printf '%s total', color(1) . $opt{'sum-format'}->($total) . color(0);
333                 printf ' in %d values', scalar @order;
334                 printf ' over %d lines', scalar @lines if @order != @lines;
335                 printf(' (%s min, %s avg, %s max)',
336                         color(31) . ($opt{'value-format'} || sub {$_[0]})->($order[-1]) . color(0),
337                         color(36) . ($opt{'value-format'} || $opt{'calc-format'})->($total / @order) . color(0),
338                         color(32) . ($opt{'value-format'} || sub {$_[0]})->($order[0]) . color(0),
339                 );
340         }
341         say '';
342         return 1;
343 }
344
345 sub show_exit {
346         show_lines();
347         show_stat() if $opt{stat};
348         exit 130 if @_;  # 0x80+signo
349         exit;
350 }
351
352 show_exit();
353
354 __END__
355 Usage:                                               /\_/\
356   barcat [OPTIONS] [FILES|NUMBERS]                  (=•.•=)
357                                                     (u   u)
358 Options:
359   -a, --[no-]ascii         Restrict user interface to ASCII characters
360   -c, --[no-]color         Force colored output of values and bar markers
361   -f, --field=(N|REGEXP)   Compare values after a given number of whitespace
362                            separators
363       --header             Prepend a chart axis with minimum and maximum
364                            values labeled
365   -H, --human-readable     Format values using SI unit prefixes
366   -t, --interval[=(N|-LINES)]
367                            Output partial progress every given number of
368                            seconds or input lines
369   -l, --length=[-]SIZE[%]  Trim line contents (between number and bars)
370   -L, --limit[=(N|-LAST|START-[END])]
371                            Stop output after a number of lines
372       --graph-format=CHAR  Glyph to repeat for the graph line
373   -m, --markers=FORMAT     Statistical positions to indicate on bars
374       --min=N, --max=N     Bars extend from 0 or the minimum value if lower
375       --palette=(PRESET|COLORS)
376                            Override colors of parsed numbers
377       --spark[=CHARS]      Replace lines by sparklines
378   -s, --stat               Total statistics after all data
379   -u, --unmodified         Do not reformat values, keeping leading whitespace
380       --value-length=SIZE  Reserved space for numbers
381   -w, --width=COLUMNS      Override the maximum number of columns to use
382   -h, --usage              Overview of available options
383       --help               Full pod documentation
384   -V, --version            Version information
385
386 =encoding utf8
387
388 =head1 NAME
389
390 barcat - concatenate texts with graph to visualize values
391
392 =head1 SYNOPSIS
393
394 B<barcat> [<options>] [<file>... | <numbers>]
395
396 =head1 DESCRIPTION
397
398 Visualizes relative sizes of values read from input
399 (parameters, file(s) or STDIN).
400 Contents are concatenated similar to I<cat>,
401 but numbers are reformatted and a bar graph is appended to each line.
402
403 Don't worry, barcat does not drink and divide.
404 It can has various options for input and output (re)formatting,
405 but remains limited to one-dimensional charts.
406 For more complex graphing needs
407 you'll need a larger animal like I<gnuplot>.
408
409 =head1 OPTIONS
410
411 =over
412
413 =item -a, --[no-]ascii
414
415 Restrict user interface to ASCII characters,
416 replacing default UTF-8 by their closest approximation.
417 Input is always interpreted as UTF-8 and shown as is.
418
419 =item -c, --[no-]color
420
421 Force colored output of values and bar markers.
422 Defaults on if output is a tty,
423 disabled otherwise such as when piped or redirected.
424
425 =item -f, --field=(<number> | <regexp>)
426
427 Compare values after a given number of whitespace separators,
428 or matching a regular expression.
429
430 Unspecified or I<-f0> means values are at the start of each line.
431 With I<-f1> the second word is taken instead.
432 A string can indicate the starting position of a value
433 (such as I<-f:> if preceded by colons),
434 or capture the numbers itself,
435 for example I<-f'(\d+)'> for the first digits anywhere.
436
437 =item --header
438
439 Prepend a chart axis with minimum and maximum values labeled.
440
441 =item -H, --human-readable
442
443 Format values using SI unit prefixes,
444 turning long numbers like I<12356789> into I<12.4M>.
445 Also changes an exponent I<1.602176634e-19> to I<160.2z>.
446 Short integers are aligned but kept without decimal point.
447
448 =item -t, --interval[=(<seconds> | -<lines>)]
449
450 Output partial progress every given number of seconds or input lines.
451 An update can also be forced by sending a I<SIGALRM> alarm signal.
452
453 =item -l, --length=[-]<size>[%]
454
455 Trim line contents (between number and bars)
456 to a maximum number of characters.
457 The exceeding part is replaced by an abbreviation sign,
458 unless C<--length=0>.
459
460 Prepend a dash (i.e. make negative) to enforce padding
461 regardless of encountered contents.
462
463 =item -L, --limit[=(<count> | -<last> | <start>-[<end>])]
464
465 Stop output after a number of lines.
466 A single value indicates the last line number (like C<head>),
467 or first line counting from the bottom if negative (like C<tail>).
468 A specific range can be given by two values.
469
470 All input is still counted and analyzed for statistics,
471 but disregarded for padding and bar size.
472
473 =item --graph-format=<character>
474
475 Glyph to repeat for the graph line.
476 Defaults to a dash C<->.
477
478 =item -m, --markers=<format>
479
480 Statistical positions to indicate on bars.
481 A single indicator glyph precedes each position:
482
483 =over 2
484
485 =item <number>
486
487 Exact value to match on the axis.
488 A vertical bar at the zero crossing is displayed by I<|0>
489 for negative values.
490 For example I<:3.14> would show a colon at pi.
491
492 =item <percentage>I<v>
493
494 Ranked value at the given percentile.
495 The default shows I<+> at I<50v> for the mean or median;
496 the middle value or average between middle values.
497 One standard deviation right of the mean is at about I<68.3v>.
498 The default includes I<< >31.73v <68.27v >>
499 to encompass all I<normal> results, or 68% of all entries, by B<< <--> >>.
500
501 =item I<avg>
502
503 Matches the average;
504 the sum of all values divided by the number of counted lines.
505 Indicated by default as I<=>.
506
507 =back
508
509 =item --min=<number>, --max=<number>
510
511 Bars extend from 0 or the minimum value if lower,
512 to the largest value encountered.
513 These options can be set to customize this range.
514
515 =item --palette=(<preset> | <color>...)
516
517 Override colors of parsed numbers.
518 Can be any CSI escape, such as I<90> for default dark grey,
519 or alternatively I<1;30> for bright black.
520
521 In case of additional colors,
522 the last is used for values equal to the maximum, the first for minima.
523 If unspecified, these are green and red respectively (I<31 90 32>).
524 Multiple intermediate colors will be distributed
525 relative to the size of values.
526
527 Predefined color schemes are named I<whites> and I<fire>,
528 or I<greys> and I<fire256> for 256-color variants.
529
530 =item --spark[=<characters>]
531
532 Replace lines by I<sparklines>,
533 single characters corresponding to input values.
534 Of a specified sequence of unicode characters,
535 the first one will be used for non-values,
536 the remainder will be distributed over the range of values.
537 Unspecified, block fill glyphs U+2581-2588 will be used.
538
539 =item -s, --stat
540
541 Total statistics after all data.
542
543 =item -u, --unmodified
544
545 Do not reformat values, keeping leading whitespace.
546 Keep original value alignment, which may be significant in some programs.
547
548 =item --value-length=<size>
549
550 Reserved space for numbers.
551
552 =item -w, --width=<columns>
553
554 Override the maximum number of columns to use.
555 Appended graphics will extend to fill up the entire screen.
556
557 =item -h, --usage
558
559 Overview of available options.
560
561 =item --help
562
563 Full pod documentation
564 as rendered by perldoc.
565
566 =item -V, --version
567
568 Version information.
569
570 =back
571
572 =head1 EXAMPLES
573
574 Draw a sine wave:
575
576     seq 30 | awk '{print sin($1/10)}' | barcat
577
578 Compare file sizes (with human-readable numbers):
579
580     du -d0 -b * | barcat -H
581
582 Memory usage of user processes with long names truncated:
583
584     ps xo %mem,pid,cmd | barcat -l40
585
586 Monitor network latency from prefixed results:
587
588     ping google.com | barcat -f'time=\K' -t
589
590 Commonly used after counting, for example users on the current server:
591
592     users | tr ' ' '\n' | sort | uniq -c | barcat
593
594 Letter frequencies in text files:
595
596     cat /usr/share/games/fortunes/*.u8 |
597     perl -CS -nE 'say for grep length, split /\PL*/, uc' |
598     sort | uniq -c | barcat
599
600 Number of HTTP requests per day:
601
602     cat log/access.log | cut -d\  -f4 | cut -d: -f1 | uniq -c | barcat
603
604 Any kind of database query with counts, preserving returned alignment:
605
606     echo 'SELECT count(*),schemaname FROM pg_tables GROUP BY 2' |
607     psql -t | barcat -u
608
609 In PostgreSQL from within the client:
610
611         postgres=> SELECT sin(generate_series(0, 3, .1)) \g |barcat
612
613 Earthquakes worldwide magnitude 1+ in the last 24 hours:
614
615     curl https://earthquake.usgs.gov/earthquakes/feed/v1.0/summary/1.0_day.csv |
616     column -tns, | barcat -f4 -u -l80%
617
618 External datasets, like movies per year:
619
620     curl https://github.com/prust/wikipedia-movie-data/raw/master/movies.json -L |
621     perl -054 -nlE 'say if s/^"year"://' | uniq -c | barcat
622
623 But please get I<jq> to process JSON
624 and replace the manual selection by C<< jq '.[].year' >>.
625
626 Pokémon height comparison:
627
628     curl https://github.com/Biuni/PokemonGO-Pokedex/raw/master/pokedex.json -L |
629     jq -r '.pokemon[] | [.height,.num,.name] | join(" ")' | barcat
630
631 USD/EUR exchange rate from CSV provided by the ECB:
632
633     curl https://sdw.ecb.europa.eu/export.do \
634          -Gd 'node=SEARCHRESULTS&q=EXR.D.USD.EUR.SP00.A&exportType=csv' |
635     grep '^[12]' | barcat -f',\K' --value-length=7
636
637 Total population history in XML from the World Bank:
638
639     curl http://api.worldbank.org/v2/country/1W/indicator/SP.POP.TOTL -L |
640     xmllint --xpath '//*[local-name()="date" or local-name()="value"]' - |
641     sed -r 's,</wb:value>,\n,g; s,(<[^>]+>)+, ,g' | barcat -f1 -H
642
643 And of course various Git statistics, such commit count by year:
644
645     git log --pretty=%ci | cut -b-4 | uniq -c | barcat
646
647 Or the top 3 most frequent authors with statistics over all:
648
649     git shortlog -sn | barcat -L3 -s
650
651 Activity graph of the last days (substitute date C<-v-{}d> on BSD):
652
653     ( git log --pretty=%ci --since=30day | cut -b-10
654       seq 0 30 | xargs -i date +%F -d-{}day ) |
655     sort | uniq -c | awk '$1--' | barcat --spark
656
657 Sparkline graphics of simple input given as inline parameters:
658
659         barcat --spark= 3 1 4 1 5 0 9 2 4
660
661 =head1 AUTHOR
662
663 Mischa POSLAWSKY <perl@shiar.org>
664
665 =head1 LICENSE
666
667 GPL3+.