output asked headers in the order they were asked; avoid header name spoofing by...
[hband-tools.git] / user-tools / hlcal
blob0ab009b3a7e30e1cc7e0eca194268056b2529ab9
1 #!/usr/bin/env perl
3 =pod
5 =head1 NAME
7 hlcal - Highlight BSD cal(1) output
9 hlncal - Highlight BSD ncal(1) output
11 =head1 SYNOPSIS
13 hlcal [OPTIONS] [CAL-OPTIONS]
15 hlncal [OPTIONS] [NCAL-OPTIONS]
17 =head1 DESCRIPTION
19 Wrap cal(1), ncal(1) around and highlight specific days.
21 =head1 OPTIONS
23 =over 4
25 =item I<DOW>=I<COLOR>
27 =item I<DATE>=I<COLOR>
29 =item I<START-DATE>...I<END-DATE>[,I<DOW>[,I<DOW>[,...]]]=I<COLOR>
31 =back
33 Where I<DOW> is a day-of-week name (3 letters),
34 I<COLOR> is a space- or hyphen-delimited list of ANSI color or other
35 formatting style name,
36 I<DATE> (and I<START-DATE>, I<END-DATE>) is in I<[[YYYY-]MM-]DD> format,
37 ie. year and month are optional,
38 and lack of them interpreted as "every year" and "every month"
39 respectively.
41 In single date definition, I<DATE>, may enter an asterisk C<*> as month
42 to select a given date in every month in the given year, or in every
43 year if you leave out the year as well.
44 Example: C<1917-*-15>
46 In the interval definition, may add several I<DOW> days which makes only
47 those days highlighted in the specified interval.
48 Examples:
49 C<04-01...06-30,WED> means every Wednesday in the second quarter.
50 C<1...7,FRI> means the first Friday in every month.
52 =head1 SUPPORTED ANSI COLORS AND STYLES
54 Colors: black, red, green, yellow, blue, magenta, cyan, white, default.
56 May be preceded by C<bright>, eg: C<bright red>.
57 May be followed by C<bg> to set the background color instead of the
58 foreground, eg: C<yellow-bg>.
60 Styles: bold, faint, italic, underline,
61 blink_slow, blink_rapid, inverse, conceal, crossed,
63 Note, not all styles are supported by all terminal emulators.
65 =head1 EXAMPLE
67 hlncal today=inverse `ncal -e`=yellow_bg-red SUN=bright-red SAT=red -bM3
69 =cut
72 use Data::Dumper;
73 use Getopt::Long qw/:config no_ignore_case bundling no_getopt_compat pass_through/;
74 use feature qw/switch/;
75 no if ($] >= 5.018), 'warnings' => 'experimental::smartmatch';
76 use Pod::Usage;
77 use Carp qw/croak/;
78 use List::MoreUtils qw/all any none/;
80 use POSIX qw/strftime setlocale LC_TIME/;
81 use DateTime;
82 use DateTime::Duration;
83 use DateTime::Format::Strptime;
84 use DateTime::Locale;
85 use locale;
88 # take the basename of the command as procname.
89 $0 =~ s/.*\/([^\/]+)$/$1/;
91 use constant {
92 VERTICAL => 'v',
93 HORIZONTAL => 'h',
96 use constant {
97 MON => 0,
98 TUE => 1,
99 WED => 2,
100 THU => 3,
101 FRI => 4,
102 SAT => 5,
103 SUN => 6,
106 my %Months = (
107 jan=>1,
108 feb=>2,
109 mar=>3,
110 apr=>4,
111 may=>5,
112 jun=>6,
113 jul=>7,
114 aug=>8,
115 sep=>9,
116 'oct'=>10,
117 nov=>11,
118 dec=>12,
120 my $re_months = join '|', keys %Months;
121 my $re_mon = qq/(0?[1-9]|1[012]|$re_months)/;
122 my $re_dom = q/(0[1-9]|[12]\d|3[01])/;
123 my $re_dow = 'MON|TUE|WED|THU|FRI|SAT|SUN';
126 my %ANSIcolor = (
127 black=>30, red=>31, green=>32, yellow=>33, blue=>34, magenta=>35, cyan=>36, white=>37,
129 my %ANSIcode = (
130 'reset'=>0, bold=>1, faint=>2, italic=>3, underline=>4,
131 blink_slow=>5, blink_rapid=>6, inverse=>7, conceal=>8, crossed=>9,
132 normal=>22,
133 noitalic=>23, nounderline=>24, noblink=>25, noinverse=>27, noconceal=>28, nocrossed=>29,
134 %ANSIcolor,
135 default=>39,
137 sub get_ansi_codes
139 my $str = shift;
140 my @codes = ();
141 $str =~ s/[- ]bg/_bg/g;
142 $str =~ s/bright[- ]/bright_/g;
143 for my $word (split /[\s-]+/, $str)
145 my $code = 0;
146 if($word =~ /^(?'BRIGHT'bright_|)(?'COLOR'.+?)(?'BG'_bg|)$/ and any {$_ eq $+{'COLOR'}} (keys %ANSIcolor))
148 $code += 10 if $+{'BG'};
149 $code += 60 if $+{'BRIGHT'};
150 $word = $+{'COLOR'};
152 if(exists $ANSIcode{$word}) { $code += $ANSIcode{$word}; }
153 else { $code = ''; }
154 push @codes, $code;
156 return @codes;
158 sub ansicode
160 my @codes = grep {length} get_ansi_codes join " ", @_;
161 return '' if not @codes;
162 return sprintf "\x1B[%sm", join(';', @codes);
163 #return join '', map { sprintf "\x1B[%sm", $_ } @codes;
166 sub verify_color
168 my $color = shift;
169 warn "$0: no ANSI code: $color\n" unless all {length} get_ansi_codes $color;
173 $OptNoHighlightToday = 0;
174 # weeks starts on Sunday by default in cal
175 $week_start_on = SUN;
176 $week_start_day_explicite = 0;
177 $calcmd = 'cal';
178 $calcmd = 'ncal' if $0 =~ /ncal/;
181 while(@ARGV)
183 my $arg = shift @ARGV;
185 # TODO: get highlight parameters from config files
186 # TODO: mix dow/date/interval definitions under @Highlight list
188 $arg =~ s{(today|yesterday|tomorrow)(?=.*=)}{
189 my $date = $1;
190 $date = DateTime->now(time_zone=>'local')->strftime('%F') if $date eq 'today';
191 $date = DateTime->now(time_zone=>'local')->subtract(days=>1)->strftime('%F') if $date eq 'yesterday';
192 $date = DateTime->now(time_zone=>'local')->add(days=>1)->strftime('%F') if $date eq 'tomorrow';
193 $date
194 }ige;
196 if(my ($day, $color) = $arg =~ /^($re_dow)=(.+)$/i)
198 $Highlight->{'days-of-week'}->{eval $day} = $color;
199 verify_color $color;
201 elsif($arg =~ /^(((?'YEAR'\d\d\d\d)-|)(?'MON'$re_mon|\*)-|)(?'DOM'$re_dom)=(?'COLOR'.+)$/i)
203 my $year = $+{'YEAR'} || '*';
204 my $mon = $+{'MON'} || '*';
205 my $dom = $+{'DOM'};
206 my $color = $+{'COLOR'};
207 $mon =~ s/^0//;
208 $dom =~ s/^0//;
209 $mon = $Months{$mon} if exists $Months{$mon};
210 $Highlight->{'date'}->{$year}->{$mon}->{$dom} = $color;
211 verify_color $color;
213 elsif($arg =~ /^(((?'Y1'\d\d\d\d)-|)((?'M1'$re_mon)-)|)(?'D1'$re_dom)\.\.\.(?('M1')(?('Y1')((?'Y2'\d\d\d\d)-|))((?'M2'$re_mon)-|))(?'D2'$re_dom)(?'DOW'(,($re_dow))*)=(?'COLOR'.+)$/i)
215 push @{$Highlight->{'interval'}}, {
216 'start' => {'y'=>$+{'Y1'}, 'm'=>$Months{lc$+{'M1'}}||$+{'M1'}, 'd'=>$+{'D1'}},
217 'end' => {'y'=>$+{'Y2'}, 'm'=>$Months{lc$+{'M2'}}||$+{'M2'}, 'd'=>$+{'D2'}},
218 'dow' => [map {eval} grep {$_} split /,/, $+{'DOW'}],
219 'color' => $+{'COLOR'},
221 verify_color $+{'COLOR'};
223 elsif($arg =~ /=/)
225 pod2usage(-exitval=>2, -verbose=>99);
227 else
229 # unknown parameter.
230 # give it back to cal/ncal.
231 unshift @ARGV, $arg;
232 last;
236 #warn Dumper $Highlight; # DEBUG
239 @cal_args = @ARGV;
241 GetOptions(
242 'M' => sub { $week_start_on = MON; $week_start_day_explicite = 1; },
243 'S' => sub { $week_start_on = SUN; $week_start_day_explicite = 1; },
244 'C' => sub { $calcmd = 'cal'; },
245 'N' => sub { $calcmd = 'ncal'; },
246 'b' => sub { $layout = HORIZONTAL; },
247 'h' => \$OptNoHighlightToday, # TODO
248 'help|?' => sub {
249 pod2usage(-exitval=>'NOEXIT', -verbose=>99);
250 system("sh", "-c", "cal --help 2>&1");
251 exit 0;
253 ) or pod2usage(-exitval=>2, -verbose=>99);
256 if(not defined $layout)
258 $layout = HORIZONTAL;
259 $layout = VERTICAL if $calcmd eq 'ncal';
262 if(!$week_start_day_explicite and $calcmd eq 'ncal')
264 # ncal takes locale settings for the week's first day
265 ($first_weekday) = `locale -k first_weekday` =~ /=(\d+)/;
266 if(defined $first_weekday)
268 $week_start_on = ($first_weekday+5) % 7;
274 my $month_parser = new DateTime::Format::Strptime(pattern=>'%Y %b', on_error=>'croak', locale=>setlocale(LC_TIME));
278 # if this bsdcal version supports -h flag,
279 if(system("$calcmd -h >/dev/null 2>&1") == 0)
281 # prevent cal to highlight today. we will do it.
282 unshift @cal_args, "-h";
285 open my $pipe, '-|:utf8', $calcmd, @cal_args or croak "$0: popen: $calcmd: $!";
286 binmode STDOUT, ':utf8';
288 $title_passed = 0;
290 while(<$pipe>)
292 my $is_week_row = 0;
294 if(/(\d\d\d\d)/)
296 $year = $1;
299 my @words = /([^\d\s]+)/g;
300 if(scalar @words >= 1 and scalar @words <= 3 and (not /\d/ or /\d\d\d\d/))
302 # seems to be 1, 2, or 3 month names
303 @month_column = ();
304 for my $word (@words)
306 push @month_column, int $month_parser->parse_datetime("$year $word")->strftime('%m');
310 my @years = /(\d\d\d\d)/g;
311 if(@years)
313 @year_column = @years;
316 if(/([^\d\s]+\s+){7}/)
318 # at least 7 groups of letters (not space or digit),
319 # it's probably day of week names;
320 # assuming horizontal layout.
322 # TODO: any locale setting in which month names have space?
324 $layout = HORIZONTAL;
325 $title_passed = 1;
326 $week_of_month = 0;
327 $is_week_row = 1;
329 %dow_bgn = ();
330 %dow_end = ();
331 my $day_spacing = 1;
332 for my $dow ($week_start_on, MON..SUN)
334 my $repeats = ($dow - $week_start_on) % 7 + 1;
335 /^(\s*[^\d\s]+){$repeats}/;
336 $dow_end{$dow} = length $&;
337 $dow_bgn{$dow} = $dow eq $week_start_on ? 0 : $dow_end{($dow-1)%7} + $day_spacing;
341 if(/^\s*$/)
343 $title_passed = 0;
346 if($layout eq HORIZONTAL)
348 if(/\d/ and $title_passed)
350 # at least 1 number -> a row of a week
351 $week_of_month++;
352 $is_week_row = 1;
355 if($is_week_row)
357 my $month_spacing = 2;
358 my $where_last_dow_column_ends = $dow_end{($week_start_on-1)%7};
359 my $how_many_month_columns = int(length($_) / $where_last_dow_column_ends);
360 my $accum_shift = 0;
362 for my $month_column (0..$how_many_month_columns-1)
364 my $week_start_offset = $month_column * ($where_last_dow_column_ends + $month_spacing);
366 my %hldow = map {$_=>$Highlight->{'days-of-week'}->{$_}} keys %{$Highlight->{'days-of-week'}};
367 my $cur_year = $year_column[$month_column] || $year;
368 my $cur_month = $month_column[$month_column];
370 for my $dow ( $week_start_on .. SUN, MON .. ($week_start_on-1) )
372 my $dom = int substr($_, $dow_bgn{$dow} + $week_start_offset + $accum_shift, $dow_end{$dow}-$dow_bgn{$dow});
373 next unless $dom;
374 my $this_date = DateTime->new(year=>$cur_year, month=>$cur_month, day=>$dom, time_zone=>'local');
376 for my $ival (@{$Highlight->{'interval'}})
378 next if @{$ival->{'dow'}} and not $dow ~~ @{$ival->{'dow'}};
380 my $s = $ival->{'start'};
381 my $e = $ival->{'end'};
383 my $sy = $s->{'y'}||$cur_year;
384 my $sm = $s->{'m'}||$cur_month;
385 my $start = DateTime->new(year=>$sy, month=>$sm, day=>$s->{'d'}, time_zone=>'local');
386 my $end = DateTime->new(year=>$e->{'y'}||$sy, month=>$e->{'m'}||$sm, day=>$e->{'d'}, time_zone=>'local');
388 if($end < $start)
390 if (! $e->{'m'}) { $end->add(months=>1); }
391 elsif(! $e->{'y'}) { $end->add(years=>1); }
393 if($this_date < $start)
395 if (! $s->{'m'}) { $_->subtract(months=>1) for $start, $end; }
396 elsif(! $s->{'y'}) { $_->subtract(years=>1) for $start, $end; }
399 #warn $this_date->ymd, ": ", $start->ymd, "...", $end->ymd, "\n"; # DEBUG
401 if($this_date >= $start and $this_date <= $end)
403 $hldow{$dow} .= " " . $ival->{'color'};
408 my %dates = ();
409 $dates{$d} = $c while ($d, $c) = each %{$Highlight->{'date'}->{'*'}->{'*'}};
410 $dates{$d} = $c while ($d, $c) = each %{$Highlight->{'date'}->{'*'}->{$cur_month}};
411 $dates{$d} = $c while ($d, $c) = each %{$Highlight->{'date'}->{$cur_year}->{$cur_month}};
412 for my $date (keys %dates)
414 my $dow = int strftime('%u', 0, 0, 0, $date, $cur_month-1, $cur_year-1900) - 1;
415 my $date_at_dow_location = substr($_, $dow_bgn{$dow} + $week_start_offset + $accum_shift, $dow_end{$dow}-$dow_bgn{$dow});
416 $hldow{$dow} .= " " . $dates{$date} if $date == $date_at_dow_location;
419 for my $dow ( $week_start_on .. SUN, MON .. ($week_start_on-1) )
421 if(exists $hldow{$dow})
423 my $color = $hldow{$dow};
424 my $insert1 = ansicode($color);
425 my $insert2 = ansicode('reset');
426 my $bgn = $dow_bgn{$dow} + $week_start_offset + $accum_shift;
427 my $end = $dow_end{$dow} + $week_start_offset + $accum_shift;
429 $_ = substr($_, 0, $bgn) . $insert1 . substr($_, $bgn, $end-$bgn) . $insert2 . substr($_, $end);
430 $accum_shift += length($insert1) + length($insert2);
437 else # $layout eq VERTICAL
439 if(not /\b\d\d?\b/)
441 # no 1 or 2 digit numbers, assuming month name(s) header.
442 # TODO: any locale setting in which month names have numbers?
444 $day_of_week = $week_start_on - 1;
445 $is_date_row = 0;
448 if(/(\d+\s+){4}/)
450 # at least 4 numbers -> it's a new row of days
451 $day_of_week = ($day_of_week + 1) % 7;
452 $is_date_row = 1;
455 if($is_date_row)
457 # TODO: hilight dates
459 while (my ($hldow, $color) = each %{$Highlight->{'days-of-week'}})
461 if($day_of_week == $hldow)
463 s/^/ansicode($color)/e;
464 s/$/ansicode('reset')/e;
470 print;
473 close $pipe;