summaryrefslogblamecommitdiffstats
path: root/bin/update-addressbook
blob: 4683e8662391f18e2a2f92860594542638b3c67d (plain) (tree)


















































































































































































































































































                                                                           
            
#!/usr/bin/env perl
use strict;
use warnings;
use 5.020;
use feature 'signatures';
no warnings 'experimental::signatures';

use Config::INI::Reader;
use Config::INI::Writer;
use Email::Address;
use File::Find;

my @exclude_patterns = (
    # quoted-printable (needs special handling, punting for now)
    sub($address) { $address->format =~ /^=\?/ },
    # automated emails
    sub($address) { $address->address =~ /(mailer-daemon|noreply)/i },
);

# abook uses # for comments instead of ;
package Abook::Reader {
    use base 'Config::INI::Reader';

    sub preprocess_line($self, $line) {
        ${$line} =~ s/\s+#.*$//g;
    }

    sub can_ignore($self, $line, $) {
        return $line =~ /\A\s*(?:#|$)/ ? 1 : 0;
    }
}

# abook is super finicky about its input format
package Abook::Writer {
    use base 'Config::INI::Writer';

    sub write_handle($self, $input, $handle) {
        print $handle "# abook addressbook file\n\n";
        $self->SUPER::write_handle($input, $handle);
    }

    sub preprocess_input($self, $data) {
        my $ini_data = [
            format => {
                program => 'abook',
                version => '0.6.1',
            },
        ];
        my $i = 0;
        for my $name (sort { fc($a) cmp fc($b) } keys %$data) {
            my $person_data = $data->{$name}->as_hashref;
            delete $person_data->{name};
            push @$ini_data, (
                $i++ => [
                    name => $name,
                    %$person_data,
                ],
            );
        }

        $self->SUPER::preprocess_input($ini_data);
    }

    sub stringify_value_assignment($self, $name, $value) {
        return '' unless defined $value;
        return $name . '=' . $self->stringify_value($value) . "\n";
    }
}

package Person {
    sub from_hashref($class, $data) {
        return bless $data, $class;
    }

    sub from_email($class, $name, $addresses) {
        return $class->from_hashref({
            name => $name,
            email => join(',', map { $_->address } @$addresses),
        });
    }

    sub addresses($self) {
        return split(',', $self->{email} // '');
    }

    sub as_hashref($self) {
        return { %$self };
    }
}

sub existing_people($addressbook) {
    my $data = Abook::Reader->read_file($addressbook);
    delete $data->{format};

    my %people;
    for my $id (keys %$data) {
        my $person = $data->{$id};
        $people{$person->{name}} = Person->from_hashref($person);
    }

    return %people;
}

sub maildir_addresses($maildir) {
    my %addresses;

    find(sub() {
        open my $fh, '<', $_ or die "couldn't open $_: $!";
        while (<$fh>) {
            last if /^$/;
            next unless /^(?:From|Sender): /;
            for my $address (Email::Address->parse($_)) {
                $address = Email::Address->new(
                    $address->name,
                    $address->address,
                );

                my $name = $address->name;
                my $format = $address->format;

                next if $addresses{$name} &&
                    grep { $format eq $_ } @{ $addresses{$name} };
                next if grep { $_->($address) } @exclude_patterns;

                push @{ $addresses{$name} ||= [] }, $address;
            }
        }
        close $fh;
    }, $maildir);

    return %addresses;
}

sub merge_addresses($old, $new) {
    my %reverse_old = map {
        map { $_->address => $_->name } @$_
    } values %$old;
    my @new_addresses = map {
        map { $_->address } @$_
    } values %$new;

    my %seen_address;
    my %reverse_ret;
    for my $new_address (@new_addresses) {
        next if $seen_address{$new_address}++;

        my @related_addresses = ($new_address);
        my @related_names;
        while (1) {
            my @new_related_names = map {
                my $cur_address = $_;
                (
                    (grep {
                        grep {
                            fc($_->address) eq fc($cur_address)
                        } @{ $new->{$_} }
                    } keys %$new),
                    (grep {
                        grep {
                            fc($_->address) eq fc($cur_address)
                        } @{ $old->{$_} }
                    } keys %$old),
                )
            } @related_addresses;
            @new_related_names = keys(
                %{ { map { $_ => 1 } @new_related_names } }
            );

            my @new_related_addresses = map {
                $_->address
            } map {
                (
                    (exists $new->{$_}
                        ? (@{ $new->{$_} })
                        : ()),
                    (exists $old->{$_}
                        ? (@{ $old->{$_} })
                        : ()),
                )
            } @new_related_names;
            @new_related_addresses = keys(
                %{ { map { $_ => 1 } @new_related_addresses } }
            );

            last if @related_names == @new_related_names
                && @related_addresses == @new_related_addresses;

            @related_addresses = @new_related_addresses;
            @related_names = @new_related_names;
        }

        my ($name) = grep { exists $old->{$_} } @related_names;
        $name = (sort @related_names)[0] unless defined $name;

        for my $related_address (@related_addresses) {
            $seen_address{$related_address}++;
            $reverse_ret{$related_address} = $name;
        }
    }

    %reverse_ret = (%reverse_old, %reverse_ret);

    my %ret;
    for my $address (keys %reverse_ret) {
        my $name = $reverse_ret{$address};
        push @{ $ret{$name} ||= [] }, Email::Address->new($name, $address);
    }

    for my $name (keys %ret) {
        my %seen_name;
        for my $address (@{ $ret{$name} }) {
            if (defined $seen_name{lc($address->address)}) {
                if (defined $reverse_old{$address->address}) {
                    $seen_name{lc($address->address)} = $address;
                }
            }
            else {
                $seen_name{lc($address->address)} = $address;
            }
        }
        $ret{$name} = [ values %seen_name ];
    }

    return %ret;
}

sub merge_people($old, $new) {
    for my $name (keys %$new) {
        if (exists $old->{$name}) {
            my $old_person = $old->{$name};
            my @addresses = $old_person->addresses;
            for my $address ($new->{$name}->addresses) {
                push @addresses, $address
                    unless grep { $_ eq $address } @addresses;
            }

            $old_person->{email} = join(',', @addresses);
        }
        else {
            $old->{$name} = $new->{$name};
        }
    }

    return %$old;
}

sub main($addressbook, $maildir) {
    die "usage: $0 ADDRESSBOOK MAILDIR" unless @_ == 2;

    my %existing_people = existing_people($addressbook);
    my %existing_addresses = map {
        my $name = $_;
        $name => [
            map {
                Email::Address->new($name, $_)
            } $existing_people{$name}->addresses
        ]
    } keys %existing_people;

    my %maildir = maildir_addresses($maildir);

    my %new_addresses = merge_addresses(\%existing_addresses, \%maildir);
    my %new_people = map {
        $_ => Person->from_email($_, $new_addresses{$_})
    } keys %new_addresses;

    my %new_abook = merge_people(\%existing_people, \%new_people);

    rename $addressbook => "$addressbook.bak"
        or die "couldn't rename $addressbook: $!";

    my $writer = Abook::Writer->new;
    $writer->write_file(\%new_abook, $addressbook);
}

main(@ARGV);