]> diplodocus.org Git - minc/blob - minc
b4694265813442d8372a70ab56fa8629bbe15561
[minc] / minc
1 #! /usr/bin/env perl
2
3 # $Id$
4
5 =head1 NAME
6
7 B<minc> - incorporate mail from a maildir into mh folders
8
9 =head1 SYNOPSIS
10
11 B<minc> [B<-m> I<MAX>] [B<-n>]
12
13 B<minc> B<-d>
14
15 B<minc> B<-r>
16
17 =head1 DESCRIPTION
18
19 B<minc> incorporates mail from a maildir to a mh folder hierarchy. It
20 takes mail from a maildir folder (not a maildir folder hierarchy),
21 running each message through regular expression based filter and hook
22 functions to determine in which folder to store it or whether it's
23 spam. Post-processing hooks may be applied to each message.
24
25 As it processes each message, B<minc> prints a line for each message
26 similar to B<inc(1)> and B<scan(1)>. This line includes the folder
27 and message number in which the message was stored, the last 'From'
28 header, and the last 'Subject' header. These fields are truncated to
29 fit in the user's terminal (see COLUMNS in B<ENVIRONMENT> below) in
30 the following proportions: folder (0.1), message number (0.0625), from
31 header (0.175). Any of these may be overridden with $SCAN_P_FOLDER,
32 $SCAN_P_MESSAGE, or $SCAN_P_FROM. The subject always fills out the
33 rest of the line.
34
35 =cut
36
37 use strict;
38 use warnings;
39
40 use Data::Dumper;
41 use Errno;
42 use Fcntl qw(O_WRONLY O_EXCL O_CREAT);
43 use FileHandle;
44 use File::Temp qw(tempfile);
45 use File::stat;
46 use Getopt::Long qw(:config gnu_getopt no_ignore_case);
47 use POSIX qw(strftime WEXITSTATUS WIFEXITED);
48 use Pod::Usage;
49
50 our $VERSION = 1;
51
52 # If a filter set's header is $MAGIC_TO_TOKEN, that set is compared
53 # against headers matching this regex (taken from procmail).
54 my $MAGIC_TO_REGEX = '^((Original-)?(Resent-)?(To|Cc|Bcc)|(X-Envelope |Apparently(-Resent)?)-To)';
55 my $MAGIC_TO_TOKEN = ' TO';
56
57 # List of SPAM message numbers, scanned at the end so the user can
58 # check for false positives.
59 my @SPAM;
60
61 =head1 OPTIONS
62
63 =over 4
64
65 =item B<-d>
66
67 Dump (using Data::Dumper) the FILTERS list and exit. This is useful
68 for testing the syntax of .minc.
69
70 =item B<-h>
71
72 Show help.
73
74 =item B<-m> I<MAX>
75
76 Stop processing after I<MAX> messages.
77
78 =item B<-n>
79
80 Dry run; do not actually incorporate the mail, but log and report to
81 stdout/stderr as normal.
82
83 =item B<-r>
84
85 Rebuild `mhpath +`/.folders from scratch, processing no mail.
86
87 =back
88
89 =cut
90
91 my $dumpfilters;
92 my $help;
93 my $maxmsgs;
94 my $norun;
95 my $rebuild_dot_folders;
96
97 GetOptions(
98 'd' => \$dumpfilters,
99 'h|help' => \$help,
100 'm=i' => \$maxmsgs,
101 'n' => \$norun,
102 'r' => \$rebuild_dot_folders,
103 ) or pod2usage();
104 $help and pod2usage(-exitstatus=>0, -verbose=>1);
105 @ARGV == 0 or pod2usage();
106
107 our $run = !$norun;
108
109 =head1 ENVIRONMENT
110
111 =over 4
112
113 =item COLUMNS
114
115 How many columns the user's terminal can hold, used to print scan
116 lines for each processed message. Defaults to 80.
117
118 =item HOME
119
120 Where the configuration file (.minc) is found. Also,
121 $HOME/Maildir is used for the maildir if MAILDIR is not set.
122
123 =item MAILDIR
124
125 Where mail is delivered.
126
127 =back
128
129 =cut
130
131 use Env qw(COLUMNS HOME MAILDIR);
132
133 $COLUMNS ||= 80;
134
135 if (not $HOME) {
136 die("HOME environment variable must be set.\n");
137 }
138 if (not $MAILDIR) {
139 $MAILDIR = "$HOME/Maildir";
140 }
141
142 =head1 FILES
143
144 =over 4
145
146 =item $HOME/.minc
147
148 This file is Perl code (included via the 'require' directive) which
149 may define the FILTERS list, @start_hooks, @filter_hooks,
150 @post_store_hooks, and @stop_hooks.
151
152 =item `mhpath +`/.folders
153
154 B<minc> adds all folders it filters into to this file, which is used
155 by lukem's B<new(1)> (XXX need a link).
156
157 =item `mhpath +`/logs/minc.log
158
159 Where B<minc> logs what it does, unless in -n mode.
160
161 =item `mhpath +`/.minc.context
162
163 B<minc> uses this file for context (i.e. current folder) instead of
164 `mhpath +`/context. This allows you some amount of freedom to use mh
165 while B<minc> is running. For example, you can changed folders without
166 causing a message to be stored in the wrong folder. Care must still
167 be taken, however, about the unseen sequence; if you change it
168 (whether via show, or mark, or anything else) while in the same folder
169 as B<minc>, it is likely the new message B<minc> stores will not end up
170 marked unseen.
171
172 =back
173
174 =cut
175
176 our @FILTERS;
177 our (@start_hooks, @stop_hooks, @filter_hooks, @post_store_hooks);
178 our $SCAN_P_FOLDER = 0.1;
179 our $SCAN_P_MESSAGE = 0.0625;
180 our $SCAN_P_FROM = 0.175;
181 our @folder_sort_list = (qr/^inbox$/);
182 our $folder_sorter = sub { sort_by_list(@_, @folder_sort_list) };
183
184 my $mh;
185
186 $mh = `mhpath +`;
187 chomp($mh);
188
189 my $logfile = $mh . '/logs/minc.log';
190
191 $ENV{"MHCONTEXT"} = $mh . '/.minc.context';
192
193 \f
194 ###############################################################################
195 # Logging
196
197 sub mylog {
198 $run or return;
199
200 my $timestamp = strftime('%b %e %H:%M:%S', localtime);
201 my $msg;
202 foreach my $part (@_) {
203 if (defined($part)) {
204 $msg .= $part;
205 }
206 }
207 # no newlines in the log message, thanks
208 $msg =~ s/\n/ /gm;
209
210 open(LOG, ">>$logfile") or die("open(>>$logfile): $!");
211 print(LOG "$timestamp $msg\n") or die("print(>>$logfile): $!");
212 close(LOG) or die("close($logfile): $!");
213 }
214
215 sub logheader {
216 my ($text, @contents) = @_;
217 my $last;
218
219 if (@contents) {
220 $last = $contents[-1];
221 } else {
222 $last = '';
223 }
224
225 mylog('<< ', $text, $last);
226 }
227
228 sub log_headers {
229 my %headers = @_;
230
231 # For an explanation of the %headers structure, see the
232 # get_headers function below.
233 logheader('From: ', @{$headers{'return-path'}});
234 logheader('To: ', @{$headers{'to'}});
235 logheader('Subject: ', @{$headers{'subject'}});
236 logheader('Message-Id: ', @{$headers{'message-id'}});
237 }
238
239 \f
240 ###############################################################################
241 # Utility procedures
242
243 sub sort_by_list {
244 my $a = shift;
245 my $b = shift;
246
247 for my $i (@_) {
248 my $am = $a =~ $i;
249 my $bm = $b =~ $i;
250 if ($am) {
251 if ($bm) {
252 last;
253 }
254 return -1;
255 } elsif ($bm) {
256 return 1;
257 }
258 }
259
260 return $a cmp $b;
261 }
262
263 sub mkfolder {
264 my $folder = shift;
265 my $target;
266 my $component;
267
268 $target = $mh;
269 foreach $component (split('/', $folder)) {
270 $target = join('/', $target, $component);
271 -d $target or mkdir($target) or die("mkdir($target): $!");
272 }
273 }
274
275 sub getfiles {
276 my $dir = shift;
277 my @result;
278
279 if (not opendir(DIR, $dir)) {
280 die("opendir($dir): $!");
281 }
282
283 # Initialize $! to 0 (success) because stupid stupid Perl provides
284 # no way to distinguish an error from an empty directory; that
285 # means setting $! to 0 and testing it afterwards is the only way
286 # to detect an error. Real Programmers don't handle errors,
287 # right? >sigh<
288 $! = 0;
289 @result = readdir(DIR);
290 if ($! != 0) {
291 die("readdir($dir): $!");
292 }
293
294 if (@result <= 2) {
295 exit(0);
296 }
297
298 STDOUT->autoflush(1);
299 print(@result - 2, " messages...");
300
301 closedir(DIR);
302
303 return @result;
304 }
305
306 sub get_highest_msgnum {
307 my $mhfolder = shift;
308 my $dir;
309 my @list;
310 my $highest;
311 my $msgnum;
312
313 $dir = "$mh/$mhfolder";
314 if (not opendir(DIR, $dir)) {
315 die("opendir($dir): $!");
316 }
317
318 # Insert rant from getfiles here.
319 $! = 0;
320 @list = readdir(DIR);
321
322 if ($! != 0) {
323 die("readdir($dir): $!");
324 }
325
326 closedir(DIR);
327
328 $highest = 0;
329 foreach $msgnum (@list) {
330 # Look for integers.
331 if ($msgnum =~ /^[0-9]+$/) {
332 if ($msgnum > $highest) {
333 $highest = $msgnum;
334 }
335 }
336 }
337
338 return $highest;
339 }
340
341 sub store_message {
342 my $msg = shift;
343 my $mhfolder = shift;
344 my $msgnum;
345 my $try;
346 my $mhmsg;
347 my $status;
348
349 # We must do this even in -n mode because later steps fail without
350 # it. This should be harmless.
351 mkfolder($mhfolder);
352
353 # This loop is a modified version of the maildir delivery algorithm.
354 $msgnum = get_highest_msgnum($mhfolder);
355 for ($try = 0; ; $try++) {
356 $msgnum++;
357 $mhmsg = "$mh/$mhfolder/$msgnum";
358
359 if (not stat($mhmsg)) {
360 if ($!{ENOENT}) {
361 # Now we have a non-existent file, let's try to create
362 # it. We must create a zero-byte file first because a
363 # file my appear between our happy stat results and
364 # our later rename(2), which would clobber said file.
365 # So attempt to create a file with this name. If it
366 # succeeds, in just a bit here we'll knowingly clobber
367 # this file with the rename(2) call.
368
369 # Another way to do this is not to use rename(2), but
370 # use link(2) + unlink(2) instead. That's how the
371 # standard maildir algorithm does it. Each method has
372 # a disadvantage: the program may crash between the
373 # link(2) and unlink(2) calls. With the standard
374 # maildir algorithm, that means the message will end
375 # up duplicated. The advantage of creating an empty
376 # file followed by rename(2) is that an extra empty
377 # file is left behind as opposed to a duplicate
378 # message. This is more easily detected by the user.
379 if ($run) {
380 if (sysopen(MSG, $mhmsg,
381 O_WRONLY | O_EXCL | O_CREAT, 0600)) {
382 close(MSG);
383 last;
384 }
385 } else {
386 last;
387 }
388 }
389 }
390
391 # This algorithm is different from the maildir one; let's make
392 # 10 tries instead of 3.
393 if ($try == 9) {
394 die("Attempted filename $mhmsg exists.");
395 }
396
397 # This algorithm is different; i don't think we need to sleep.
398 #sleep(2);
399 }
400
401 mylog('+', $mhfolder);
402
403 if ($run) {
404 if (not rename($msg, $mhmsg)) {
405 die("rename($msg, $mhmsg): $!");
406 }
407
408 # Mark each message as soon as we store it and bomb if that
409 # fails. While it is slow, it is not safe to store multiple
410 # messages and then have a failure before marking some (or
411 # all).
412 if ($mhfolder eq 'SPAM') {
413 push(@SPAM, $msgnum);
414 } else {
415 $status = system('mark', "+$mhfolder", "$msgnum", '-sequence',
416 'unseen', '-add');
417 # XXX need to handle signalled and stopped, and print
418 # the exit code or signal number.
419 if (not WIFEXITED($status)) {
420 die("Failed to run mark");
421 } elsif (WEXITSTATUS($status) != 0) {
422 die("Failed to mark message unseen.");
423 }
424 }
425 }
426
427 return $msgnum;
428 }
429
430 # Parse a message file into a structure describing the headers. The
431 # structure is a hash of arrays. The hash keys are the names of the
432 # headers found in the message, made all lower-case. Each item in
433 # the hash is an array of header text. The array has one element
434 # per occurrence of the header. Most headers will only have a single
435 # element because they occur only once. The Received header is the
436 # most common header with multiple texts.
437 sub get_headers {
438 my $msg = shift;
439 my %headers;
440 my $current; # current header, used for unfolding lines
441 my $fieldname; # unmolested header name
442 my $contents; # contents of header
443
444 open(MSG, $msg) or die("open(MSG, $msg): $!");
445 while (<MSG>) {
446 chomp;
447 if (length == 0) {
448 last;
449 }
450
451 if (/^\s/) {
452 # folded header continuation
453
454 if (not defined($current)) {
455 warn('Malformed message, cannot parse headers.');
456 next;
457 }
458
459 @{$headers{$current}}[-1] .= $_;
460 } else {
461 ($fieldname) = split(/:/);
462 $current = lc($fieldname);
463 (undef, $contents) = split(/^\Q$fieldname\E:\s*/);
464
465 if (defined($headers{$current})) {
466 # multiple occurence of same header
467 push(@{$headers{$current}}, $contents);
468 } else {
469 # first occurence of a header
470 $headers{$current} = [$contents];
471 }
472 }
473 }
474 close(MSG);
475
476 return %headers;
477 }
478
479 \f
480 ###############################################################################
481 # Filtering
482
483 sub find_mh_folder {
484 my $msg = shift;
485 my %headers = @_;
486 my $filterref;
487 my @filter;
488 my $header;
489 my $contents;
490 my $pair;
491 my $regexp;
492 my $expression;
493 my $result;
494
495 if (not %headers) {
496 return 'malformed';
497 }
498
499 # Walk the list of filters. This structure is documented in
500 # pod at the end of the program.
501 foreach $filterref (@FILTERS) {
502 @filter = @$filterref;
503 $header = shift(@filter);
504
505 # Handle filters using the magic TO header specially.
506 if ($header eq $MAGIC_TO_TOKEN) {
507 foreach $header (keys(%headers)) {
508 if ($header =~ /$MAGIC_TO_REGEX/i) {
509 foreach $contents (@{$headers{lc($header)}}) {
510 foreach $pair (@filter) {
511 ($regexp, $expression) = @$pair;
512 if ($contents =~ /$regexp/i) {
513 if (eval "\$result = \"$expression\"") {
514 return $result;
515 }
516 }
517 }
518 }
519 }
520 }
521
522 # Now that it's been processed specially, skip normal handling.
523 next;
524 }
525
526 # Walk the list of message headers matching the filter's
527 # specified header.
528 foreach $contents (@{$headers{lc($header)}}) {
529 # Walk the filter's list of regexp/expression pairs.
530 foreach $pair (@filter) {
531 ($regexp, $expression) = @$pair;
532 if ($contents =~ /$regexp/i) {
533 if (eval "\$result = \"$expression\"") {
534 return $result;
535 }
536 }
537 }
538 }
539 }
540
541 return 'inbox';
542 }
543
544 sub filter_mail {
545 my $msgcount = @_ - 2; # don't count . and ..
546 my $len = length($msgcount);
547 my @baton;
548 my $msg;
549 my $mhfolder;
550 my $spam = 0;
551 my $saved = 0;
552 my $msgnum;
553 my %folders;
554
555 if (-f "$HOME/.minc") {
556 require "$HOME/.minc";
557 }
558
559 # XXX lame names
560 my $nf = int($COLUMNS * $SCAN_P_FOLDER);
561 my $nm = int($COLUMNS * $SCAN_P_MESSAGE);
562 my $nF = int($COLUMNS * $SCAN_P_FROM);
563 my $ns = $COLUMNS - $nf - $nm - $nF - 3;
564
565 my %batons;
566 for my $hook (@start_hooks) {
567 my ($handle, @baton) = $hook->();
568 if (defined($handle)) {
569 $batons{$handle} = [@baton];
570 }
571 }
572
573 for $msg (@_) {
574 ($msg eq '.' or $msg eq '..') and next;
575
576 my %headers = get_headers($msg);
577 log_headers(%headers);
578
579 undef($mhfolder);
580 for my $hook (@filter_hooks) {
581 my $result = $hook->(\%batons, \%headers, $msg);
582 defined($result) and ($mhfolder = $result);
583 }
584
585 defined($mhfolder) or ($mhfolder = find_mh_folder($msg, %headers));
586
587 $msgnum = store_message($msg, $mhfolder);
588 $folders{$mhfolder}++;
589
590 if ($mhfolder eq 'SPAM') {
591 $spam++;
592 } else {
593 $saved++;
594 my $from = '';
595 my $subject = '';
596 # Sometimes these headers are missing...
597 eval { $from = [@{$headers{'from'}}]->[-1] };
598 eval { $subject = [@{$headers{'subject'}}]->[-1] };
599 for ($from, $subject) {
600 tr/\x00-\x1f\x80-\xff/ /;
601 }
602 print("\r");
603 print(' ' x $COLUMNS);
604 printf("\r\%-${nf}s \%${nm}d \%-${nF}s \%s\n",
605 substr($mhfolder, 0, $nf), substr($msgnum, 0, $nm),
606 substr($from, 0, $nF),
607 substr($subject, 0, $ns));
608 }
609
610 for my $hook (@post_store_hooks) {
611 $hook->(\%batons, \%headers, $mhfolder, $msgnum);
612 }
613
614 print("\r");
615 printf(" \%${len}d SPAM \%${len}d saved \%${len}d/%1d",
616 $spam, $saved, $spam + $saved, $msgcount);
617
618 defined($maxmsgs) and ($spam + $saved < $maxmsgs or last);
619 }
620 print("\n");
621
622 for my $hook (@stop_hooks) {
623 $hook->(\%batons);
624 }
625
626 return %folders;
627 }
628
629 sub build_dot_folders {
630 my $folders = shift;
631 my $fh = shift;
632 my $fn;
633
634 if (defined($fh)) {
635 while (<$fh>) {
636 chomp;
637 $folders->{$_}++;
638 }
639 }
640
641 eval { ($fh, $fn) = tempfile("$mh/.folders.XXXXX") };
642 if ($@) {
643 warn("$@");
644 return;
645 }
646
647 for my $folder (sort { $folder_sorter->($a,$b) } keys(%$folders)) {
648 print($fh "$folder\n");
649 }
650
651 if (not close($fh)) {
652 warn("close($fn): $!");
653 unlink($fn) or warn("unlink($fn): $!");
654 return;
655 }
656
657 rename($fn, "$mh/.folders") or warn("rename($fn, $mh/.folders): $!");
658 }
659
660 sub create_dot_folders {
661 if (-f "$HOME/.minc") {
662 require "$HOME/.minc";
663 }
664
665 my %folders;
666 open(my $fh, '-|', 'folders', '-fast', '-recur')
667 or die("open(folders|): $!");
668 build_dot_folders(\%folders, $fh);
669 return 0;
670 }
671
672 sub update_dot_folders {
673 my $folders = shift;
674 my $fh;
675
676 if (not open($fh, "$mh/.folders") and not $!{ENOENT}) {
677 # For ENOENT, we go ahead and create it, else we error and
678 # don't clobber it.
679 warn("open($mh/.folders): $!");
680 return;
681 }
682
683 build_dot_folders($folders, $fh);
684 }
685
686 \f
687 MAIN: {
688 my $st;
689
690 if ($dumpfilters) {
691 require "$HOME/.minc";
692 $Data::Dumper::Indent = 1;
693 print(Dumper(\@FILTERS));
694 exit;
695 }
696
697 $rebuild_dot_folders and exit(create_dot_folders);
698
699 chdir("$MAILDIR/new") or die("chdir($MAILDIR/new): $!");
700 my %folders = filter_mail(map { $_->[1] }
701 sort { $a->[0] <=> $b->[0] }
702 map {
703 if (not ($st = stat($_))) {
704 die("stat($_): $!");
705 }
706 [$st->mtime, $_]
707 }
708 getfiles('.'));
709
710 $run and update_dot_folders(\%folders);
711
712 @SPAM and (exec('scan', '+SPAM', @SPAM) or die);
713 }
714
715 \f
716 __END__
717
718 =head1 THE FILTERS STRUCTURE
719
720 Each element of @FILTERS is a filter. A filter is itself an array.
721 The first element of a filter is a string, the name of the header this
722 filter acts upon. The header name is not case-sensitive. Each
723 subsequent element of a filter is a pair (i.e. a two-element array):
724 first, a regular expression B<minc> uses to determine whether this
725 filter matches or not, and second, an expression which B<minc>
726 evaluates to get the folder name.
727
728 B<minc> decides where to store a message by iterating over the
729 @FILTERS array. It tests each regexp of each filter against all
730 headers matching that filter's specified header. As soon as a match
731 is found, B<minc> evaluates the second part of the pair. This part
732 may contain positional parameters from the matched regexp ($1, $2,
733 etc.). The result of this expression is used as the folder name.
734
735 Multiple occurrences of the same header are preserved and tested
736 individually against the filters. That means, for example, that
737 filters can search all the Received headers for a certain string.
738
739 It is important to note that all the arrays of the @FILTERS structure
740 are traversed I<in order>. This means the filters can be arranged so
741 that some have priority over others. XXX: get Doug to write an
742 example of having the same header matched more than once.
743
744 Lastly, B<minc> supports a magic ' TO' header. Filters using this
745 header are matched against a collection of headers related to (and
746 including) the To. Which headers to use is determined by a regular
747 expression borrowed from procmail.
748
749 =head1 HOOKS
750
751 Filter hooks take a reference to a hash of batons, a reference to a
752 hash of headers, and the message filename as arguments. It returns
753 undef to decline filtering of this message (thus falling back to
754 subsequent filter hooks, and finally @FILTERS), or the name of the
755 folder to store this message into.
756
757 One of B<minc>'s global variables is available to the user-defined
758 hooks: $run. This boolean should be honored; hooks should only take
759 real action (i.e. removing or creating files, running external
760 programs, etc.) if $run is non-zero.
761
762 The baton hash is created simply from the start hooks; if the hook
763 returns at least one defined value, this value is used as the key and
764 all other return values are put into a list reference as the value.
765 This hash is then passed by reference to all filter, post-store, and
766 stop hooks.
767
768 Post store hooks take a reference to a hash of batons, a reference to
769 a hash of headers, the folder this message was stored in, and its new
770 message number.
771
772 XXX: need more details about the hook process; for now read the code.
773
774 =head1 EXAMPLES
775
776 @FILTERS = (
777
778 ['List-Id',
779 ['<([[:graph:]]+)-([[:graph:]]+)\.lists\.sourceforge\.net>', 'l/$1/$2'],
780 ['<([[:graph:]]+?)(-list)?\.freedesktop\.org>', 'l/freedesktop/$1'],
781 ['<swig\.cs\.uchicago\.edu>', 'l/swig'],
782 ],
783
784 ['List-Post',
785 ['<mailto:([[:graph:]]+)+@([[:graph:]]+)\.apache\.org>', 'l/apache/$2/$1'],
786 ],
787
788 ['To',
789 ['root', 'root'],
790 ],
791 );
792
793 The first List-Id filter is a surprisingly effective one which creates
794 appropriate folder names (such as l/htdig/updates and l/rox/devel) for
795 all Sourceforge lists to which i subscribe. Certainly there are lists
796 hosted at Sourceforge which do not work well with this, and this is
797 where it is important to remember that the first match is the one
798 uses. Simply put the more specific rules before this one.
799
800 The next List-Id example is simple. The swig example demonstrates
801 that the folder name does not have to use a portion of the matched
802 header; you can just hardcode the folder name.
803
804 The List-Post example is a nice one. Most ASF projects have their own
805 apache.org subdomain, with mailing lists hosted there. So, given a
806 list such as dev@httpd.apache.org, this filter will create the folder
807 name l/apache/httpd/dev.
808
809 XXX Need hook examples.
810
811 =head1 AUTHORS
812
813 Written by Eric Gillespie <epg@pretzelnet.org>. Design by Eric
814 Gillespie and Doug Porter <dsp@waterspout.com>.
815
816 This program is free software; you can redistribute it and/or modify
817 it under the same terms as Perl itself.
818
819 =cut
820
821 # Local variables:
822 # cperl-indent-level: 4
823 # perl-indent-level: 4
824 # indent-tabs-mode: nil
825 # End:
826
827 # vi: set tabstop=4 expandtab: