#!/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);