Content

Observations from a West Coast family

Puzzle #2: cal(1)

Wednesday 29 September 2004 - Filed under Pastime

(I’ve been fixing little smf(5) bugs, as well as revising our documentation, presentations and–most importantly–more block diagrams for this blog. But I bumped into an annoyance and thought I should share.)

As an young old-school Unix developer, I tend to live in terminal windows. One of my favourite commands is cal(1), which has a great default mode:

$ cal
September 2004
S  M Tu  W Th  F  S
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

But if you want to see an October calendar, you might get confused:

$ cal 10
10
Jan                    Feb                    Mar
S  M Tu  W Th  F  S    S  M Tu  W Th  F  S    S  M Tu  W Th  F  S
1  2  3  4                      1                      1
5  6  7  8  9 10 11    2  3  4  5  6  7  8    2  3  4  5  6  7  8
12 13 14 15 16 17 18    9 10 11 12 13 14 15    9 10 11 12 13 14 15
19 20 21 22 23 24 25   16 17 18 19 20 21 22   16 17 18 19 20 21 22
26 27 28 29 30 31      23 24 25 26 27 28      23 24 25 26 27 28 29
30 31
Apr                    May                    Jun
S  M Tu  W Th  F  S    S  M Tu  W Th  F  S    S  M Tu  W Th  F  S
1  2  3  4  5                1  2  3    1  2  3  4  5  6  7
6  7  8  9 10 11 12    4  5  6  7  8  9 10    8  9 10 11 12 13 14
13 14 15 16 17 18 19   11 12 13 14 15 16 17   15 16 17 18 19 20 21
20 21 22 23 24 25 26   18 19 20 21 22 23 24   22 23 24 25 26 27 28
27 28 29 30            25 26 27 28 29 30 31   29 30
Jul                    Aug                    Sep
S  M Tu  W Th  F  S    S  M Tu  W Th  F  S    S  M Tu  W Th  F  S
1  2  3  4  5                   1  2       1  2  3  4  5  6
6  7  8  9 10 11 12    3  4  5  6  7  8  9    7  8  9 10 11 12 13
13 14 15 16 17 18 19   10 11 12 13 14 15 16   14 15 16 17 18 19 20
20 21 22 23 24 25 26   17 18 19 20 21 22 23   21 22 23 24 25 26 27
27 28 29 30 31         24 25 26 27 28 29 30   28 29 30
31
Oct                    Nov                    Dec
S  M Tu  W Th  F  S    S  M Tu  W Th  F  S    S  M Tu  W Th  F  S
1  2  3  4                      1       1  2  3  4  5  6
5  6  7  8  9 10 11    2  3  4  5  6  7  8    7  8  9 10 11 12 13
12 13 14 15 16 17 18    9 10 11 12 13 14 15   14 15 16 17 18 19 20
19 20 21 22 23 24 25   16 17 18 19 20 21 22   21 22 23 24 25 26 27
26 27 28 29 30 31      23 24 25 26 27 28 29   28 29 30 31
30

It’s an interesting UI choice to assume that anyone would want the calendar for the year 10 C.E. Certainly I never do, and I’m pretty sure someone would have told me if UNIX systems were the professional historian’s first choice for computing…

So today’s puzzle is to make cal(1) more usable. If I enter cal month_num, give me the current month; If I enter “cal now” give me the 3-month window around the current month, like so:

$ cal now
August 2004             September 2004          October 2004
S  M Tu  W Th  F  S     S  M Tu  W Th  F  S     S  M Tu  W Th  F  S
1  2  3  4  5  6  7              1  2  3  4                    1  2
8  9 10 11 12 13 14     5  6  7  8  9 10 11     3  4  5  6  7  8  9
15 16 17 18 19 20 21    12 13 14 15 16 17 18    10 11 12 13 14 15 16
22 23 24 25 26 27 28    19 20 21 22 23 24 25    17 18 19 20 21 22 23
29 30 31                26 27 28 29 30          24 25 26 27 28 29 30
31

Other than that, all other standard invocations of cal(1) should work as usual.

My example solution is a couple dozen line ksh(1) shell function, and I’ll post it along with the best submissions. (Perl folks: no non-core modules, please.)

2004-09-29  »  Stephen

  • http://keramida.serverhive.com/weblog/ gkeramidas

    A really gross first attempt is something like this:

    $ cat cal-wrapper

    !/bin/sh

    if [ $# -eq 1 ]; then case $1 in [0-9]|[0-9][0-9]) cal $1 date '+%Y' ;; now) month=date '+%m' | sed 's/^0//' prev=$(( ( $month -1 – 1 ) % 12 + 1 )) [ $prev -lt 1 ] && prev=$(( $prev + 12 )) next=$(( ( $month -1 + 1 ) % 12 + 1 )) for lc in 1 2 3 4 5 6 7 8 ;do sh $0 $prev | head -$lc | tail -1 | awk ‘{printf “%-22s”,$0}’ sh $0 $month | head -$lc | tail -1 | awk ‘{printf “%-22s”,$0}’ sh $0 $next | head -$lc | tail -1 | awk ‘{printf “%-22s”,$0}’ echo ” done ;; *) cal “$1″ ;; esac else cal “$@” fi The single month trick was easy to do with date(1). I have to admit that the multiple invocations of cal(1) when the “now” mode is invoked though are ugly. I might think of something better tomorrow, after I get some more sleep :-)

  • DJ Gregor

    Here’s my entry. I started doing this in the Bourne shell, but halfway through I felt just too dirty writing it. So, I whipped this up in Perl, instead. Enjoy (and if you think this is too verbose, be happy that I didn’t “use strict;”…. at least I didn’t write it in lisp).

    !/usr/bin/perl -w

    $real_cal = “/usr/bin/cal”; if (@ARGV != 1 || $ARGV[0] ne “now”) { exec($real_cal, @ARGV) || die “could not exec $real_cal: $!\n”; }

    Set $months to be our current year * 12 + the current month of the year.

    Remember, $t[5] contains the number of years since 1900. Also, $t[4] is

    the month of the year starting at zero (Jan == 0, Dec == 11).

    @t = localtime(time()); $months = (($t[5] + 1900) * 12) + $t[4];

    @cal will store the merged output of three months’ output from cal(1).

    @cal = ();

    Iterate over the three months for which we want data.

    for $month ($months – 1, $months, $months + 1) {

    append_cal wants the first argument to be an array reference,

    the second argument to be the month, numbering from one, not zero,

    and the last to be the year.

    append_cal(\@cal, ($month % 12) + 1, int($month / 12)); }

    Print out our data, appending newlines.

    foreach $line (@cal) { $line =~ s/\s+$//; print $line, “\n”; } #

    append_cal – Append the calendar for the specified month and year to

    an array of lines.

    Parameters:

    $cal: an array reference into which to store the output from cal(1).

    $month: month of the year, suitable for passing directly to cal(1).

    Note that this starts at one (Jan == 1, Dec == 12).

    $year: year, numbering from zero (2004 == 2004).

    # sub append_cal { my ($cal) = shift(@); my ($month) = shift(@); my ($year) = shift(@_); my ($fh); my ($index); my ($line); open($fh, “$real_cal $month $year|”) || die “opening pipe from cal: $!\n”; $index = 0; while (defined($line = <$fh>)) { chomp($line); if (!defined($$cal[$index])) { $$cal[$index] = “”; } $$cal[$index] .= sprintf(“%-20s “, $line); $index++; } close($fh); }

  • DJ Gregor

    Ooops.. change that first <code>if</code> block to:

    if (@ARGV == 1 && $ARGV[0] =~ m/^\d{1,2}$/) {
    exec($real_cal, $ARGV[0], (localtime(time()))[5] + 1900) ||
    die "could not exec $real_cal: $!\n";
    } elsif (@ARGV != 1 || $ARGV[0] ne "now") {
    exec($real_cal, @ARGV) || die "could not exec $real_cal: $!\n";
    }
    

  • Danek Duvall

    Heh. I’ve been meaning to write this for years, but never got around to it. :)

    Here’s a version in zsh, modeled after gkeramidas’ solution, but gets the years wrapped properly, and uses two different constructs to get things side by side. I use zsh specifically for the =() construct, which sticks the contents of the output of the command inside in a temporary file, though once I’m using zsh, I take advantage of a few other features.

    For the “now” clause, I’ve two ways to print the calendars side by side. The first, using sdiff, forks fewer processes, but I believe is likely to be more fragile, given the column cutting. The second, using join, is probably more robust, but forks eleven processes to do the job. Choose your poison.

    !/bin/zsh -f

    PATH=/usr/bin if [[ $# -eq 1 ]]; then case $1 in (<0-12>) cal $1 $(date +%Y) ;; (“now”) this=( $(date +%m) $(date +%Y) ) next=( $(( this[1] % 12 + 1)) $this[2] ) prev=( $(( (this[1] – 2) % 12 + 1)) $this[2] ) (( next[1] == 1 )) && (( ++next[2] )) (( prev[1] == 0 )) && (( –prev[2], prev[1] = 12 )) sdiff =(cal $=prev) =(sdiff -w 60 =(cal $=this) =(cal $=next)) | \ cut -c 1-22,67-88,98- echo join -t$’\t’ -o 1.2,2.2 =(join -t$’\t’ -o 1.2,2.2 \ =(cal $=prev | cat -n) =(cal $=this | cat -n) | expand -t 23 | cat -n) \ =(cal $=next | cat -n) | expand -t 46 ;; (*) exec cal $0 ;; esac else exec cal $@ fi

  • http://keramida.serverhive.com/weblog/ Giorgos Keramidas

    After a bit of sleep I realized there was indeed a bug in the wrapping around of months to previous or next years. The correct solution in ksh(1) syntax would be something like this:

    year=$(date '+%Y')
    mon=$(date '+%m')
    pmon=$(( ( $mon - 2 ) % 12 + 1 ))
    [ $pmon -eq 0 ] && pmon=12
    pyear=$year
    [ $pmon -gt $mon ] && pyear=$(( $pyear - 1 ))
    nmon=$(( $mon % 12 + 1 ))
    nyear=$year
    [ $nmon -lt $mon ] && nyear=$(( $nyear + 1 ))
    
    There’s yet another bug in what I had posted. I was sitting on a FreeBSD workstation and the $(( expression )) syntax worked fine with /bin/sh. On Solaris this has to be done with backquoting if sh(1) is used, otherwise ksh should be used as the intepreter.

  • http://keramida.serverhive.com/weblog/ Giorgos Keramidas

    And voila, here’s a version that hopefully has no bugs, is written in ksh(1) to make use of the $(( expr )) syntax for brevity, and is faster:

    #!/bin/sh
    if [ $# -eq 1 ]; then
    case $1 in
    [0-9]|[0-9][0-9])
    cal $1 date '+%Y' ;;
    now)
    year=$(date '+%Y')
    mon=$(date '+%m')
    pmon=$(( ( $mon - 2 ) % 12 + 1 ))
    [ $pmon -eq 0 ] && pmon=12
    pyear=$year
    [ $pmon -gt $mon ] && pyear=$(( $pyear - 1 ))
    nmon=$(( $mon % 12 + 1 ))
    nyear=$year
    [ $nmon -lt $mon ] && nyear=$(( $nyear + 1 ))
    { cal "$pmon" "$pyear" | sed -e 's/^/a:/' ; \
    cal "$mon" "$year"   | sed -e 's/^/b:/' ; \
    cal "$nmon" "$nyear" | sed -e 's/^/c:/' ; } | \
    awk 'BEGIN { ja = jb = jc = 0;}
    /^a:/ {av[ja] = substr($0,3); ja++;}
    /^b:/ {bv[jb] = substr($0,3); jb++;}
    /^c:/ {cv[jc] = substr($0,3); jc++;}
    END {
    jmax = ja;
    if (jb > jmax) {jmax = jb;}
    if (jc > jmax) {jmax = jc;}
    for (ja = 0; ja < jmax; ja++) {
    printf "%-22s%-22s%-22s\n", av[ja],bv[ja],cv[ja];
    }
    }'
    ;;
    *)
    cal "$1" ;;
    esac
    else
    cal "$@"
    fi
    This one uses three string arrays in awk(1) to store the output of cal(1) as it comes in and saves a hell of a lot of process invocations from my first attempt. That’s probably why it is a lot faster too. That was fun playing with :-)

  • Alan Burlison

    !/bin/perl

    #

    You are not supposed to understand this, just be in awe of my perl-foo.

    # use strict; use warnings; my ($m, $y, @my) = (localtime())[4, 5]; $m += 1; $y += 1900; if (@ARGV == 1 && $ARGV[0] eq ‘now’) { $my[1] = [ $m, $y ]; } elsif (@ARGV == 1 && $ARGV[0] =~ /^\d+$/ && $ARGV[0] >= 1 && $ARGV[0] <= 12) { $my[1] = [ $ARGV[0], $y ]; } else { exec(‘/usr/bin/cal’, @ARGV) || die(“Can’t exec cal: $!\n”); } $_ = $my[1][0] – 1; @{$my[0]} = $_ == 0 ? ( 12, $my[1][1] – 1 ) : ( $, $my[1][1] ); $ = $my[1][0] + 1; @{$my[2]} = $_ == 13 ? ( 1, $my[1][1] + 1 ) : ( $, $my[1][1] ); my @cal = map([ map({ chomp($); $_ } qx{/usr/bin/cal $->[0] $->[1]}) ], @my); for ($_ = 0; $_ < 7; $++) { printf(“%-20s %-20s %-20s\n”, map(shift(@{$}) || ”, @cal)); } exit(0);

  • Alan Burlison

    Again, with pre tags, sigh.

    !/bin/perl

    #

    You are not supposed to understand this, just be in awe of my perl-foo.

    # use strict; use warnings; my ($m, $y, @my) = (localtime())[4, 5]; $m += 1; $y += 1900; if (@ARGV == 1 && $ARGV[0] eq ‘now’) { $my[1] = [ $m, $y ]; } elsif (@ARGV == 1 && $ARGV[0] =~ /^\d+$/ && $ARGV[0] >= 1 && $ARGV[0] <= 12) { $my[1] = [ $ARGV[0], $y ]; } else { exec(‘/usr/bin/cal’, @ARGV) || die(“Can’t exec cal: $!\n”); } $_ = $my[1][0] – 1; @{$my[0]} = $_ == 0 ? ( 12, $my[1][1] – 1 ) : ( $, $my[1][1] ); $ = $my[1][0] + 1; @{$my[2]} = $_ == 13 ? ( 1, $my[1][1] + 1 ) : ( $, $my[1][1] ); my @cal = map([ map({ chomp($); $_ } qx{/usr/bin/cal $->[0] $->[1]}) ], @my); for ($_ = 0; $_ < 7; $++) { printf(“%-20s %-20s %-20s\n”, map(shift(@{$}) || ”, @cal)); } exit(0);

  • Alan Burlison

    A further slight simplification is possible:

    my @cal = map([ map({ chomp($); $ } qx{/usr/bin/cal $->[0] $->[1]}) ], @my);
    
    can be replaced with:
    my @cal = map([ map({ chomp($); $ } qx{/usr/bin/cal $->[0] @{$}, @my);
    
    I’ve been able to reduce this to is 460 bytes (448 if you discount the #! line), any less and I feel I might risk compromising readibility:

    !/bin/perl

    ($m,$y)=(localtime())[4,5];$m+=1;$y+=1900;$n=@ARGV;$=$ARGV[0];if($n==1&&$ eq’now’){$a[1]=[$m,$y];}elsif($n==1&&$=~/^\d+$/&&$>=1&&$<=12){$a[1]=[$,$y];}else{exec(‘/usr/bin/cal’,@ARGV)||die($!);}$=$a[1][0]-1;@{$a[0]}=$==0?(12,$a[1][1]-1):($,$a[1][1]);$=$a[1][0]+1;@{$a[2]}=$==13?(1,$a[1][1]+1):($,$a[1][1]);@c=map([map({chomp($);$}qx{/usr/bin/cal @$})],@a);for($=0;$<7;$++){printf(“%-20s %-20s %-20s\n”,map(shift(@$_)||”,@c));} Note in particular how it takes care to still produce the correct output in a leap year when 1st Febuary falls on a Sunday – see that bit there on the second line?

  • Alan Burlison

    And if I win this competition I’d just like to remind you that you still haven’t given me my prize for winning your sort(1) competition – yes I was that anonymous poster.

  • http://keramida.serverhive.com/weblog/ gkeramidas

    Well, imho, readability is already out of the window if I have to scroll at the 400th column! But that’s probably just me and my archaic habit of fitting everything in 80 columns to avoid the “neck pendulum effect” ;-)

  • Alan Burlison

    Actually someone over on my blog has kindly fixed a bug in the version I posted here, and between us we’ve reduced it down to 393 bytes, or 380 bytes if you exclude the #! line and the trailing newline.

  • Gary

    <p/> I missed out on this last week, too busy travelling around. <p/> I think my solution is more verbose than most and it certainly isn’t most efficient (too much forking to run printf), but since I spent some time on it I thought I would post it anyway.

    !/bin/ksh

    if [ $# == 2 ] then /usr/bin/cal $1 $2 exit 0 elif [ $# == 1 ] then if [ $1 != now ] then /usr/bin/cal $1 $(date ‘+%Y’) exit 0 fi else /usr/bin/cal exit 0 fi integer year=$(date ‘+%Y’) integer now=$(date ‘+%m’) integer prev=$now-1 integer next=$now+1 integer i=0 integer j=0 store_cal() { export IFS=”\n” cal $1 $2 | while read line do out[$i]=$line i=$i+3 j=$j+1 done } store_cal $prev $year i=1 store_cal $now $year i=2 store_cal $next $year i=0 while (( i < j )) do printf “%-20s %-20s %-20s\n” \ “${out[$i]}” “${out[$i+1]}” “${out[$i+2]}” i=$i+3 done

  • Ant�nio Santos

    This is my version, done on 10 minutes:

    !/usr/bin/env perl


    my $arg = shift @ARGV;
    my @years = ( );
    my @months = ( );
    my %output = ( );
    my @lt = localtime; # see manual for localtime in perl but:

    index 5 is year minus 1900,

    index 4 is month minus 1


    if (not defined $arg or $arg eq ”) {
    system (‘/usr/bin/env cal’);
    exit (0);
    } elsif ($arg =~ m/^now$/i) {
    foreach (($lt [4] – 1)..($lt [4] + 1)) {
    if ($_ < 0) {
    push @years, $lt [5] + 1900 – 1;
    push @months, $_ + 11;
    } elsif ($_ > 11) {
    push @years, $lt [5] + 1900 + 1;
    push @months, $_ – 11;
    } else {
    push @years, $lt [5] + 1900;
    push @months, $_ + 1;
    }
    }
    } elsif ($arg =~ m/^[0-9]{0,2}$/ and $arg+0 >= 1 and $arg+0 <= 12) {
    push @years, $lt [5] + 1900;
    push @months, $arg+0;
    } else {
    die ‘usage: ‘ . $0 . ‘ [<month> | now]‘;
    }

    foreach my $i (0..$#years) {
    $output{$i} = [split ("\n", /usr/bin/env cal $months[$i] $years[$i])];
    }

    -- join output

    foreach my $row (0..7) { # max of each cal output are 8 rows
    print join (' 'x3, map { $_ . ' 'x(20-length ($)) } map { $output{$}->[$row] } (0..$#years) ) . "\n";
    }

    my email: Antonio.Santos at pt.jazztel.com


  • Antonio Santos

    !/usr/bin/env perl


    my $arg = shift @ARGV;
    my @years = ( );
    my @months = ( );
    my %output = ( );
    my @lt = localtime; # see manual for localtime in perl but:

    index 5 is year minus 1900,

    index 4 is month minus 1


    if (not defined $arg or $arg eq ”) {
    system (‘/usr/bin/env cal’);
    exit (0);
    } elsif ($arg =~ m/^now$/i) {
    foreach (($lt [4] – 1)..($lt [4] + 1)) {
    if ($_ < 0) {
    push @years, $lt [5] + 1900 – 1;
    push @months, $_ + 11;
    } elsif ($_ > 11) {
    push @years, $lt [5] + 1900 + 1;
    push @months, $_ – 11;
    } else {
    push @years, $lt [5] + 1900;
    push @months, $_ + 1;
    }
    }
    } elsif ($arg =~ m/^[0-9]{0,2}$/ and $arg+0 >= 1 and $arg+0 <= 12) {
    push @years, $lt [5] + 1900;
    push @months, $arg+0;
    } else {
    die ‘usage: ‘ . $0 . ‘ [<month> | now]‘;
    }

    foreach my $i (0..$#years) {
    $output{$i} = [split ("\n", /usr/bin/env cal $months[$i] $years[$i])];
    }

    -- join output

    foreach my $row (0..7) { # max of each cal output are 8 rows
    print join (' 'x3, map { $_ . ' 'x(20-length ($)+1) } map { $output{$}->[$row] } (0..$#years) ) . "\n";
    }

    Here it goes new version, was a +1 missing on the padding of spaces on output.

  • Dave Powell

    This ksh script weighs in at just 375 characters (including the #! line). Your turn, Alan.

    !/bin/ksh -p

    z=/usr/bin/cal c(){ $z $|sed ‘s/$/ /’|expand -t 23 } date “+%Y %m”|read y m d=0${1%%now} [[ $d -lt 13 && $d -gt 0 ]]&&exec $z $d ${2:-$y} [[ $1 != "now" ]]&&exec $z $ set -A a $y $y $y set -A b — $((m-1)) $m $((m+1)) f[1]=’a[0]=$((y-1));b[0]=12′ f[12]=’a[2]=$((y+1));b[2]=1′ eval ${f[$m]} eval paste for i in 0 1 2; do echo '&lt;(c '${b[$i]} ${a[$i]}')' done (note: the whitespace in the sed substitution is a single tab)

  • Dave Powell

    Follow-up: doing away with some unnecessary quoting saves 4 characters, leaving 371.

  • Dave Powell

    Even more shuffling produces a 365 character solution:

    !/bin/ksh -p

    z=/usr/bin/cal c(){ sed ‘s/$/ /’|expand -t23 } date +%Y\ %m|read y m d=0${1%%now} [[ $d -lt 13 && $d -gt 0 ]]&&exec $z $d ${2:-$y} [[ $1 = now ]]||exec $z $* set -A a $y $y $y set -A b — $((m-1)) $m $((m+1)) f[1]=’a[0]=$((y-1));b[0]=12′ f[12]=’a[2]=$((y+1));b[2]=1′ eval ${f[$m]} eval paste for i in 0 1 2; do echo "&lt;($z ${b[$i]} ${a[$i]}|c)" done

  • Dave Powell

    There’s a space after the ‘cal\’ (icky, but it saves a character). 310 bytes.

    !/bin/ksh -p

    z=/usr/bin/cal\ c(){ sed ‘s/$/ /’|expand -t23 } date +%Y\ %m|read y m d=0${1%%now} [[ $d -lt 13&&$d -gt 0 ]]&&exec $z$d ${2:-$y} [[ $1 = now ]]||exec $z$* set -A a 0 0 0 f[1]=’a[0]=-1′ f[12]=’a[2]=1′ eval ${f[m]} eval paste for i in 0 1 2; do echo "&lt;($z$(((m+i+10)%12+1)) $((y+a[i]))|c)" done

  • http://jroller.com/page/jaimec Jaime Cardoso

    Jesus. And they call me crazy, … Why not change z=/usr/bin/cal\ to z=/bin/cal If Solaris already has a symlink, you might use it after all, you can save 4 more characters :) )

  • Anonymous

    !/bin/ksh -p

    c() { cal $(($1%12+1)) $(($1/12)) | sed -e ‘s/$/ /’ | expand -21 } [[ $# -ne 1 ]] && exec cal “$@” integer y=$(date +%Y) [[ $1 != now ]] && { [[ $1 -lt 13 ]] && exec cal $1 $y || exec cal $1 } integer m=$(date +%m) integer d=$((y*12+(m-1))) exec paste <(c $((d-1))) <(c $((d))) <(c $((d+1)))

  • Anonymous

    !/bin/ksh -p

    c() { cal $(($1%12+1)) $(($1/12)) | sed -e ‘s/$/ /’ | expand -21 } [[ $# -ne 1 ]] && exec cal “$@” integer y=$(date +%Y) [[ $1 != now ]] && { [[ $1 -lt 13 ]] && exec cal $1 $y || exec cal $1 } integer m=$(date +%m) integer d=$((y*12+(m-1))) exec paste <(c $((d-1))) <(c $((d))) <(c $((d+1)))