2 use base qw( FS::Password_Mixin
6 use vars qw( $skip_fuzzyfiles );
8 use Scalar::Util qw( blessed );
9 use FS::Record qw( qsearch qsearchs dbh );
11 use FS::contact_phone;
12 use FS::contact_email;
13 use FS::contact::Import;
15 use FS::phone_type; #for cgi_contact_fields
17 use FS::prospect_contact;
23 FS::contact - Object methods for contact records
29 $record = new FS::contact \%hash;
30 $record = new FS::contact { 'column' => 'value' };
32 $error = $record->insert;
34 $error = $new_record->replace($old_record);
36 $error = $record->delete;
38 $error = $record->check;
42 An FS::contact object represents an specific contact person for a prospect or
43 customer. FS::contact inherits from FS::Record. The following fields are
80 =item selfservice_access
86 =item _password_encoding
102 Creates a new contact. To add the contact to the database, see L<"insert">.
104 Note that this stores the hash reference, not a distinct copy of the hash it
105 points to. You can ask the object for a copy with the I<hash> method.
109 sub table { 'contact'; }
113 Adds this record to the database. If there is an error, returns the error,
114 otherwise returns false.
116 If the object has an C<emailaddress> field, L<FS::contact_email> records
117 will be created for each (comma-separated) email address in that field. If
118 any of these coincide with an existing email address, this contact will be
119 merged with the contact with that address.
121 Then, if the object has any fields named C<phonetypenumN> an
122 L<FS::contact_phone> record will be created for each of them. Those fields
123 should contain phone numbers of the appropriate types (where N is the key of
124 an L<FS::phone_type> record identifying the type of number: daytime, night,
127 After inserting the record, if the object has a 'custnum' or 'prospectnum'
128 field, an L<FS::cust_contact> or L<FS::prospect_contact> record will be
129 created to link the contact to the customer. The following fields will also
130 be included in that record, if they are set on the object:
141 local $SIG{INT} = 'IGNORE';
142 local $SIG{QUIT} = 'IGNORE';
143 local $SIG{TERM} = 'IGNORE';
144 local $SIG{TSTP} = 'IGNORE';
145 local $SIG{PIPE} = 'IGNORE';
147 my $oldAutoCommit = $FS::UID::AutoCommit;
148 local $FS::UID::AutoCommit = 0;
151 #save off and blank values that move to cust_contact / prospect_contact now
152 my $prospectnum = $self->prospectnum;
153 $self->prospectnum('');
154 my $custnum = $self->custnum;
158 for (qw( classnum comment selfservice_access invoice_dest )) {
159 $link_hash{$_} = $self->get($_);
163 #look for an existing contact with this email address
164 my $existing_contact = '';
165 if ( $self->get('emailaddress') =~ /\S/ ) {
167 my %existing_contact = ();
169 foreach my $email ( split(/\s*,\s*/, $self->get('emailaddress') ) ) {
171 my $contact_email = qsearchs('contact_email', { emailaddress=>$email } )
174 my $contact = $contact_email->contact;
175 $existing_contact{ $contact->contactnum } = $contact;
179 if ( scalar( keys %existing_contact ) > 1 ) {
180 $dbh->rollback if $oldAutoCommit;
181 return 'Multiple email addresses specified '.
182 ' that already belong to separate contacts';
183 } elsif ( scalar( keys %existing_contact ) ) {
184 ($existing_contact) = values %existing_contact;
190 if ( $existing_contact ) {
192 $self->$_($existing_contact->$_())
193 for qw( contactnum _password _password_encoding );
194 $error = $self->SUPER::replace($existing_contact);
198 $error = $self->SUPER::insert;
202 $error ||= $self->insert_password_history;
205 $dbh->rollback if $oldAutoCommit;
209 my $cust_contact = '';
210 # if $self->custnum was set, then the customer-specific properties
211 # (custnum, classnum, invoice_dest, selfservice_access, comment) are in
212 # pseudo-fields, and are now in %link_hash. otherwise, ignore all those
215 my %hash = ( 'contactnum' => $self->contactnum,
216 'custnum' => $custnum,
218 $cust_contact = qsearchs('cust_contact', \%hash )
219 || new FS::cust_contact { %hash, %link_hash };
220 my $error = $cust_contact->custcontactnum ? $cust_contact->replace
221 : $cust_contact->insert;
223 $dbh->rollback if $oldAutoCommit;
228 if ( $prospectnum ) {
229 my %hash = ( 'contactnum' => $self->contactnum,
230 'prospectnum' => $prospectnum,
232 my $prospect_contact = qsearchs('prospect_contact', \%hash )
233 || new FS::prospect_contact { %hash, %link_hash };
235 $prospect_contact->prospectcontactnum ? $prospect_contact->replace
236 : $prospect_contact->insert;
238 $dbh->rollback if $oldAutoCommit;
243 foreach my $pf ( grep { /^phonetypenum(\d+)$/ && $self->get($_) =~ /\S/ }
244 keys %{ $self->hashref } ) {
245 $pf =~ /^phonetypenum(\d+)$/ or die "wtf (daily, the)";
246 my $phonetypenum = $1;
248 my %hash = ( 'contactnum' => $self->contactnum,
249 'phonetypenum' => $phonetypenum,
252 qsearchs('contact_phone', \%hash)
253 || new FS::contact_phone { %hash, _parse_phonestring($self->get($pf)) };
254 my $error = $contact_phone->contactphonenum ? $contact_phone->replace
255 : $contact_phone->insert;
257 $dbh->rollback if $oldAutoCommit;
262 if ( $self->get('emailaddress') =~ /\S/ ) {
264 foreach my $email ( split(/\s*,\s*/, $self->get('emailaddress') ) ) {
266 'contactnum' => $self->contactnum,
267 'emailaddress' => $email,
269 unless ( qsearchs('contact_email', \%hash) ) {
270 my $contact_email = new FS::contact_email \%hash;
271 my $error = $contact_email->insert;
273 $dbh->rollback if $oldAutoCommit;
281 unless ( $skip_fuzzyfiles ) { #unless ( $import || $skip_fuzzyfiles ) {
282 #warn " queueing fuzzyfiles update\n"
284 my $error = $self->queue_fuzzyfiles_update;
286 $dbh->rollback if $oldAutoCommit;
287 return "updating fuzzy search cache: $error";
291 if ( $link_hash{'selfservice_access'} eq 'R'
292 or ( $link_hash{'selfservice_access'}
294 && ! length($self->_password)
298 my $error = $self->send_reset_email( queue=>1 );
300 $dbh->rollback if $oldAutoCommit;
305 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
313 Delete this record from the database.
320 local $SIG{HUP} = 'IGNORE';
321 local $SIG{INT} = 'IGNORE';
322 local $SIG{QUIT} = 'IGNORE';
323 local $SIG{TERM} = 'IGNORE';
324 local $SIG{TSTP} = 'IGNORE';
325 local $SIG{PIPE} = 'IGNORE';
327 my $oldAutoCommit = $FS::UID::AutoCommit;
328 local $FS::UID::AutoCommit = 0;
331 #got a prospetnum or custnum? delete the prospect_contact or cust_contact link
333 if ( $self->prospectnum ) {
334 my $prospect_contact = qsearchs('prospect_contact', {
335 'contactnum' => $self->contactnum,
336 'prospectnum' => $self->prospectnum,
338 my $error = $prospect_contact->delete;
340 $dbh->rollback if $oldAutoCommit;
345 # if $self->custnum was set, then we're removing the contact from this
347 if ( $self->custnum ) {
348 my $cust_contact = qsearchs('cust_contact', {
349 'contactnum' => $self->contactnum,
350 'custnum' => $self->custnum,
352 my $error = $cust_contact->delete;
354 $dbh->rollback if $oldAutoCommit;
359 # then, proceed with deletion only if the contact isn't attached to any other
360 # prospects or customers
362 #inefficient, but how many prospects/customers can a single contact be
363 # attached too? (and is removing them from one a common operation?)
364 if ( $self->prospect_contact || $self->cust_contact ) {
365 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
369 #proceed with deletion
371 foreach my $cust_pkg ( $self->cust_pkg ) {
372 $cust_pkg->contactnum('');
373 my $error = $cust_pkg->replace;
375 $dbh->rollback if $oldAutoCommit;
380 foreach my $object ( $self->contact_phone, $self->contact_email ) {
381 my $error = $object->delete;
383 $dbh->rollback if $oldAutoCommit;
388 my $error = $self->delete_password_history
389 || $self->SUPER::delete;
391 $dbh->rollback if $oldAutoCommit;
395 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
400 =item replace OLD_RECORD
402 Replaces the OLD_RECORD with this one in the database. If there is an error,
403 returns the error, otherwise returns false.
410 my $old = ( blessed($_[0]) && $_[0]->isa('FS::Record') )
412 : $self->replace_old;
414 $self->$_( $self->$_ || $old->$_ ) for qw( _password _password_encoding );
416 local $SIG{INT} = 'IGNORE';
417 local $SIG{QUIT} = 'IGNORE';
418 local $SIG{TERM} = 'IGNORE';
419 local $SIG{TSTP} = 'IGNORE';
420 local $SIG{PIPE} = 'IGNORE';
422 my $oldAutoCommit = $FS::UID::AutoCommit;
423 local $FS::UID::AutoCommit = 0;
426 #save off and blank values that move to cust_contact / prospect_contact now
427 my $prospectnum = $self->prospectnum;
428 $self->prospectnum('');
429 my $custnum = $self->custnum;
433 for (qw( classnum comment selfservice_access invoice_dest )) {
434 $link_hash{$_} = $self->get($_);
438 my $error = $self->SUPER::replace($old);
439 if ( $old->_password ne $self->_password ) {
440 $error ||= $self->insert_password_history;
443 $dbh->rollback if $oldAutoCommit;
447 my $cust_contact = '';
448 # if $self->custnum was set, then the customer-specific properties
449 # (custnum, classnum, invoice_dest, selfservice_access, comment) are in
450 # pseudo-fields, and are now in %link_hash. otherwise, ignore all those
453 my %hash = ( 'contactnum' => $self->contactnum,
454 'custnum' => $custnum,
457 if ( $cust_contact = qsearchs('cust_contact', \%hash ) ) {
458 $cust_contact->$_($link_hash{$_}) for keys %link_hash;
459 $error = $cust_contact->replace;
461 $cust_contact = new FS::cust_contact { %hash, %link_hash };
462 $error = $cust_contact->insert;
465 $dbh->rollback if $oldAutoCommit;
470 if ( $prospectnum ) {
471 my %hash = ( 'contactnum' => $self->contactnum,
472 'prospectnum' => $prospectnum,
475 if ( my $prospect_contact = qsearchs('prospect_contact', \%hash ) ) {
476 $prospect_contact->$_($link_hash{$_}) for keys %link_hash;
477 $error = $prospect_contact->replace;
479 my $prospect_contact = new FS::prospect_contact { %hash, %link_hash };
480 $error = $prospect_contact->insert;
483 $dbh->rollback if $oldAutoCommit;
488 foreach my $pf ( grep { /^phonetypenum(\d+)$/ }
489 keys %{ $self->hashref } ) {
490 $pf =~ /^phonetypenum(\d+)$/ or die "wtf (daily, the)";
491 my $phonetypenum = $1;
493 my %cp = ( 'contactnum' => $self->contactnum,
494 'phonetypenum' => $phonetypenum,
496 my $contact_phone = qsearchs('contact_phone', \%cp);
498 my $pv = $self->get($pf);
501 #if new value is empty, delete old entry
503 if ($contact_phone) {
504 $error = $contact_phone->delete;
506 $dbh->rollback if $oldAutoCommit;
513 $contact_phone ||= new FS::contact_phone \%cp;
515 my %cpd = _parse_phonestring( $pv );
516 $contact_phone->set( $_ => $cpd{$_} ) foreach keys %cpd;
518 my $method = $contact_phone->contactphonenum ? 'replace' : 'insert';
520 $error = $contact_phone->$method;
522 $dbh->rollback if $oldAutoCommit;
527 if ( defined($self->hashref->{'emailaddress'}) ) {
529 #ineffecient but whatever, how many email addresses can there be?
531 foreach my $contact_email ( $self->contact_email ) {
532 my $error = $contact_email->delete;
534 $dbh->rollback if $oldAutoCommit;
539 foreach my $email ( split(/\s*,\s*/, $self->get('emailaddress') ) ) {
541 my $contact_email = new FS::contact_email {
542 'contactnum' => $self->contactnum,
543 'emailaddress' => $email,
545 $error = $contact_email->insert;
547 $dbh->rollback if $oldAutoCommit;
555 unless ( $skip_fuzzyfiles ) { #unless ( $import || $skip_fuzzyfiles ) {
556 #warn " queueing fuzzyfiles update\n"
558 $error = $self->queue_fuzzyfiles_update;
560 $dbh->rollback if $oldAutoCommit;
561 return "updating fuzzy search cache: $error";
565 if ( $cust_contact and (
566 ( $cust_contact->selfservice_access eq ''
567 && $link_hash{selfservice_access}
568 && ! length($self->_password)
570 || $cust_contact->_resend()
574 my $error = $self->send_reset_email( queue=>1 );
576 $dbh->rollback if $oldAutoCommit;
581 if ( $self->get('password') ) {
582 my $error = $self->is_password_allowed($self->get('password'))
583 || $self->change_password($self->get('password'));
585 $dbh->rollback if $oldAutoCommit;
590 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
596 =item _parse_phonestring PHONENUMBER_STRING
598 Subroutine, takes a string and returns a list (suitable for assigning to a hash)
599 with keys 'countrycode', 'phonenum' and 'extension'
601 (Should probably be moved to contact_phone.pm, hence the initial underscore.)
605 sub _parse_phonestring {
608 my($countrycode, $extension) = ('1', '');
611 if ( $value =~ s/^\s*\+\s*(\d+)// ) {
617 if ( $value =~ s/\s*(ext|x)\s*(\d+)\s*$//i ) {
621 ( 'countrycode' => $countrycode,
622 'phonenum' => $value,
623 'extension' => $extension,
627 =item queue_fuzzyfiles_update
629 Used by insert & replace to update the fuzzy search cache
633 use FS::cust_main::Search;
634 sub queue_fuzzyfiles_update {
637 local $SIG{HUP} = 'IGNORE';
638 local $SIG{INT} = 'IGNORE';
639 local $SIG{QUIT} = 'IGNORE';
640 local $SIG{TERM} = 'IGNORE';
641 local $SIG{TSTP} = 'IGNORE';
642 local $SIG{PIPE} = 'IGNORE';
644 my $oldAutoCommit = $FS::UID::AutoCommit;
645 local $FS::UID::AutoCommit = 0;
648 foreach my $field ( 'first', 'last' ) {
649 my $queue = new FS::queue {
650 'job' => 'FS::cust_main::Search::append_fuzzyfiles_fuzzyfield'
652 my @args = "contact.$field", $self->get($field);
653 my $error = $queue->insert( @args );
655 $dbh->rollback if $oldAutoCommit;
656 return "queueing job (transaction rolled back): $error";
660 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
667 Checks all fields to make sure this is a valid contact. If there is
668 an error, returns the error, otherwise returns false. Called by the insert
676 if ( $self->selfservice_access eq 'R' || $self->selfservice_access eq 'P' ) {
677 $self->selfservice_access('Y');
682 $self->ut_numbern('contactnum')
683 || $self->ut_foreign_keyn('prospectnum', 'prospect_main', 'prospectnum')
684 || $self->ut_foreign_keyn('custnum', 'cust_main', 'custnum')
685 || $self->ut_foreign_keyn('locationnum', 'cust_location', 'locationnum')
686 || $self->ut_foreign_keyn('classnum', 'contact_class', 'classnum')
687 || $self->ut_namen('last')
688 || $self->ut_namen('first')
689 || $self->ut_textn('title')
690 || $self->ut_textn('comment')
691 || $self->ut_enum('selfservice_access', [ '', 'Y' ])
692 || $self->ut_textn('_password')
693 || $self->ut_enum('_password_encoding', [ '', 'bcrypt'])
694 || $self->ut_enum('disabled', [ '', 'Y' ])
696 return $error if $error;
698 return "Prospect and customer!" if $self->prospectnum && $self->custnum;
700 return "One of first name, last name, or title must have a value"
701 if ! grep $self->$_(), qw( first last title);
708 Returns a formatted string representing this contact, including name, title and
715 my $data = $self->first. ' '. $self->last;
716 $data .= ', '. $self->title
718 $data .= ' ('. $self->comment. ')'
725 Returns a formatted string representing this contact, with just the name.
731 $self->first . ' ' . $self->last;
734 #=item contact_classname PROSPECT_OBJ | CUST_MAIN_OBJ
736 #Returns the name of this contact's class for the specified prospect or
737 #customer (see L<FS::prospect_contact>, L<FS::cust_contact> and
738 #L<FS::contact_class>).
742 #sub contact_classname {
743 # my( $self, $prospect_or_cust ) = @_;
746 # if ( ref($prospect_or_cust) eq 'FS::prospect_main' ) {
747 # $link = qsearchs('prospect_contact', {
748 # 'contactnum' => $self->contactnum,
749 # 'prospectnum' => $prospect_or_cust->prospectnum,
751 # } elsif ( ref($prospect_or_cust) eq 'FS::cust_main' ) {
752 # $link = qsearchs('cust_contact', {
753 # 'contactnum' => $self->contactnum,
754 # 'custnum' => $prospect_or_cust->custnum,
757 # croak "$prospect_or_cust is not an FS::prospect_main or FS::cust_main object";
760 # my $contact_class = $link->contact_class or return '';
761 # $contact_class->classname;
764 #autoloaded by FK in 4.x, but not during the upgrade
767 qsearch('contact_email', { 'contactnum' => $self->contactnum } );
770 =item by_selfservice_email EMAILADDRESS
772 Alternate search constructor (class method). Given an email address, returns
773 the contact for that address. If that contact doesn't have selfservice access,
774 or there isn't one, returns the empty string.
778 sub by_selfservice_email {
779 my($class, $email) = @_;
781 my $contact_email = qsearchs({
782 'table' => 'contact_email',
783 'addl_from' => ' LEFT JOIN contact USING ( contactnum ) ',
784 'hashref' => { 'emailaddress' => $email, },
786 AND ( contact.disabled IS NULL )
787 AND EXISTS ( SELECT 1 FROM cust_contact
788 WHERE contact.contactnum = cust_contact.contactnum
789 AND cust_contact.selfservice_access = 'Y'
794 $contact_email->contact;
798 #these three functions are very much false laziness w/FS/FS/Auth/internal.pm
799 # and should maybe be libraried in some way for other password needs
801 use Crypt::Eksblowfish::Bcrypt qw( bcrypt_hash en_base64 de_base64);
803 sub authenticate_password {
804 my($self, $check_password) = @_;
806 if ( $self->_password_encoding eq 'bcrypt' ) {
808 my( $cost, $salt, $hash ) = split(',', $self->_password);
810 my $check_hash = en_base64( bcrypt_hash( { key_nul => 1,
812 salt => de_base64($salt),
818 $hash eq $check_hash;
822 return 0 if $self->_password eq '';
824 $self->_password eq $check_password;
830 =item change_password NEW_PASSWORD
832 Changes the contact's selfservice access password to NEW_PASSWORD. This does
833 not check password policy rules (see C<is_password_allowed>) and will return
834 an error only if editing the record fails for some reason.
836 If NEW_PASSWORD is the same as the existing password, this does nothing.
840 sub change_password {
841 my($self, $new_password) = @_;
843 # do nothing if the password is unchanged
844 return if $self->authenticate_password($new_password);
846 $self->change_password_fields( $new_password );
852 sub change_password_fields {
853 my($self, $new_password) = @_;
855 $self->_password_encoding('bcrypt');
859 my $salt = pack( 'C*', map int(rand(256)), 1..16 );
861 my $hash = bcrypt_hash( { key_nul => 1,
869 join(',', $cost, en_base64($salt), en_base64($hash) )
874 # end of false laziness w/FS/FS/Auth/internal.pm
877 #false laziness w/ClientAPI/MyAccount/reset_passwd
878 use Digest::SHA qw(sha512_hex);
880 use FS::ClientAPI_SessionCache;
881 sub send_reset_email {
882 my( $self, %opt ) = @_;
884 my @contact_email = $self->contact_email or return '';
886 my $reset_session = {
887 'contactnum' => $self->contactnum,
888 'svcnum' => $opt{'svcnum'},
892 my $conf = new FS::Conf;
894 ($conf->config('selfservice-password_reset_hours') || 24 ). ' hours';
896 my $reset_session_id;
898 $reset_session_id = sha512_hex(time(). {}. rand(). $$)
899 } until ( ! defined $self->myaccount_cache->get("reset_passwd_$reset_session_id") );
902 $self->myaccount_cache->set( "reset_passwd_$reset_session_id", $reset_session, $timeout );
907 my @cust_contact = grep $_->selfservice_access, $self->cust_contact;
908 $cust_main = $cust_contact[0]->cust_main if scalar(@cust_contact) == 1;
910 my $agentnum = $cust_main ? $cust_main->agentnum : '';
911 my $msgnum = $conf->config('selfservice-password_reset_msgnum', $agentnum);
912 #die "selfservice-password_reset_msgnum unset" unless $msgnum;
913 return "selfservice-password_reset_msgnum unset" unless $msgnum;
914 my $msg_template = qsearchs('msg_template', { msgnum => $msgnum } );
915 return "selfservice-password_reset_msgnum cannot be loaded" unless $msg_template;
917 'to' => join(',', map $_->emailaddress, @contact_email ),
918 'cust_main' => $cust_main,
920 'substitutions' => { 'session_id' => $reset_session_id }
923 if ( $opt{'queue'} ) { #or should queueing just be the default?
925 my $cust_msg = $msg_template->prepare( %msg_template );
926 my $error = $cust_msg->insert;
927 return $error if $error;
928 my $queue = new FS::queue {
929 'job' => 'FS::cust_msg::process_send',
930 'custnum' => $cust_main ? $cust_main->custnum : '',
932 $queue->insert( $cust_msg->custmsgnum );
936 $msg_template->send( %msg_template );
942 use vars qw( $myaccount_cache );
943 sub myaccount_cache {
945 $myaccount_cache ||= new FS::ClientAPI_SessionCache( {
946 'namespace' => 'FS::ClientAPI::MyAccount',
950 =item cgi_contact_fields
952 Returns a list reference containing the set of contact fields used in the web
953 interface for one-line editing (i.e. excluding contactnum, prospectnum, custnum
954 and locationnum, as well as password fields, but including fields for
955 contact_email and contact_phone records.)
959 sub cgi_contact_fields {
962 my @contact_fields = qw(
963 classnum first last title comment emailaddress selfservice_access
964 invoice_dest password
967 push @contact_fields, 'phonetypenum'. $_->phonetypenum
968 foreach qsearch({table=>'phone_type', order_by=>'weight'});
974 use FS::upgrade_journal;
975 sub _upgrade_data { #class method
976 my ($class, %opts) = @_;
978 # before anything else, migrate contact.custnum to cust_contact records
979 unless ( FS::upgrade_journal->is_done('contact_invoice_dest') ) {
981 local($skip_fuzzyfiles) = 1;
983 foreach my $contact (qsearch('contact', {})) {
984 my $error = $contact->replace;
985 die $error if $error;
988 FS::upgrade_journal->set_done('contact_invoice_dest');
992 # always migrate cust_main_invoice records over
993 local $FS::cust_main::import = 1; # override require_phone and such
994 my $search = FS::Cursor->new('cust_main_invoice', {});
996 while (my $cust_main_invoice = $search->fetch) {
997 my $custnum = $cust_main_invoice->custnum;
998 my $dest = $cust_main_invoice->dest;
999 my $cust_main = $cust_main_invoice->cust_main;
1001 if ( $dest =~ /^\d+$/ ) {
1002 my $svc_acct = FS::svc_acct->by_key($dest);
1003 die "custnum $custnum, invoice destination svcnum $svc_acct does not exist\n"
1005 $dest = $svc_acct->email;
1007 push @{ $custnum_dest{$custnum} ||= [] }, $dest;
1009 my $error = $cust_main_invoice->delete;
1011 die "custnum $custnum, cleaning up cust_main_invoice: $error\n";
1015 foreach my $custnum (keys %custnum_dest) {
1016 my $dests = $custnum_dest{$custnum};
1017 my $cust_main = FS::cust_main->by_key($custnum);
1018 my $error = $cust_main->replace( invoicing_list => $dests );
1020 die "custnum $custnum, creating contact: $error\n";
1032 L<FS::Record>, schema.html from the base documentation.