1 package FS::msg_template;
4 use base qw( FS::Record );
6 use FS::Misc qw( generate_email send_email do_print );
8 use FS::Record qw( qsearch qsearchs );
13 use FS::template_content;
15 use Date::Format qw( time2str );
16 use HTML::Entities qw( decode_entities encode_entities ) ;
18 use HTML::TreeBuilder;
23 use vars qw( $DEBUG $conf );
25 FS::UID->install_callback( sub { $conf = new FS::Conf; } );
31 FS::msg_template - Object methods for msg_template records
37 $record = new FS::msg_template \%hash;
38 $record = new FS::msg_template { 'column' => 'value' };
40 $error = $record->insert;
42 $error = $new_record->replace($old_record);
44 $error = $record->delete;
46 $error = $record->check;
50 An FS::msg_template object represents a customer message template.
51 FS::msg_template inherits from FS::Record. The following fields are currently
56 =item msgnum - primary key
58 =item msgname - Name of the template. This will appear in the user interface;
59 if it needs to be localized for some users, add it to the message catalog.
61 =item agentnum - Agent associated with this template. Can be NULL for a
64 =item mime_type - MIME type. Defaults to text/html.
66 =item from_addr - Source email address.
68 =item disabled - disabled ('Y' or NULL).
78 Creates a new template. To add the template to the database, see L<"insert">.
80 Note that this stores the hash reference, not a distinct copy of the hash it
81 points to. You can ask the object for a copy with the I<hash> method.
85 # the new method can be inherited from FS::Record, if a table method is defined
87 sub table { 'msg_template'; }
89 =item insert [ CONTENT ]
91 Adds this record to the database. If there is an error, returns the error,
92 otherwise returns false.
94 A default (no locale) L<FS::template_content> object will be created. CONTENT
95 is an optional hash containing 'subject' and 'body' for this object.
103 my $oldAutoCommit = $FS::UID::AutoCommit;
104 local $FS::UID::AutoCommit = 0;
107 my $error = $self->SUPER::insert;
109 $content{'msgnum'} = $self->msgnum;
110 $content{'subject'} ||= '';
111 $content{'body'} ||= '';
112 my $template_content = new FS::template_content (\%content);
113 $error = $template_content->insert;
117 $dbh->rollback if $oldAutoCommit;
121 $dbh->commit if $oldAutoCommit;
127 Delete this record from the database.
131 # the delete method can be inherited from FS::Record
133 =item replace [ OLD_RECORD ] [ CONTENT ]
135 Replaces the OLD_RECORD with this one in the database. If there is an error,
136 returns the error, otherwise returns false.
138 CONTENT is an optional hash containing 'subject', 'body', and 'locale'. If
139 supplied, an L<FS::template_content> object will be created (or modified, if
140 one already exists for this locale).
146 my $old = ( ref($_[0]) and $_[0]->isa('FS::Record') )
148 : $self->replace_old;
151 my $oldAutoCommit = $FS::UID::AutoCommit;
152 local $FS::UID::AutoCommit = 0;
155 my $error = $self->SUPER::replace($old);
157 if ( !$error and %content ) {
158 $content{'locale'} ||= '';
159 my $new_content = qsearchs('template_content', {
160 'msgnum' => $self->msgnum,
161 'locale' => $content{'locale'},
163 if ( $new_content ) {
164 $new_content->subject($content{'subject'});
165 $new_content->body($content{'body'});
166 $error = $new_content->replace;
169 $content{'msgnum'} = $self->msgnum;
170 $new_content = new FS::template_content \%content;
171 $error = $new_content->insert;
176 $dbh->rollback if $oldAutoCommit;
180 warn "committing FS::msg_template->replace\n" if $DEBUG and $oldAutoCommit;
181 $dbh->commit if $oldAutoCommit;
189 Checks all fields to make sure this is a valid template. If there is
190 an error, returns the error, otherwise returns false. Called by the insert
195 # the check method should currently be supplied - FS::Record contains some
196 # data checking routines
202 $self->ut_numbern('msgnum')
203 || $self->ut_text('msgname')
204 || $self->ut_foreign_keyn('agentnum', 'agent', 'agentnum')
205 || $self->ut_textn('mime_type')
206 || $self->ut_enum('disabled', [ '', 'Y' ] )
207 || $self->ut_textn('from_addr')
209 return $error if $error;
211 $self->mime_type('text/html') unless $self->mime_type;
216 =item content_locales
218 Returns a hashref of the L<FS::template_content> objects attached to
219 this template, with the locale as key.
223 sub content_locales {
225 return $self->{'_content_locales'} ||= +{
226 map { $_->locale , $_ }
227 qsearch('template_content', { 'msgnum' => $self->msgnum })
231 =item prepare OPTION => VALUE
233 Fills in the template and returns a hash of the 'from' address, 'to'
234 addresses, subject line, and body.
236 Options are passed as a list of name/value pairs:
242 Customer object (optional)
246 Additional context object (currently, can be a cust_main, cust_pkg,
247 cust_bill, cust_pay, cust_pay_pending, or svc_(acct, phone, broadband,
248 domain) ). If the object is a svc_*, its cust_pkg will be fetched and
249 used for substitution.
251 As a special case, this may be an arrayref of two objects. Both
252 objects will be available for substitution, with their field names
253 prefixed with 'new_' and 'old_' respectively. This is used in the
254 rt_ticket export when exporting "replace" events.
258 Configuration option to use as the source address, based on the customer's
259 agentnum. If unspecified (or the named option is empty), 'invoice_from'
262 The I<from_addr> field in the template takes precedence over this.
266 Destination address. The default is to use the customer's
267 invoicing_list addresses. Multiple addresses may be comma-separated.
271 A hash reference of additional substitutions
278 my( $self, %opt ) = @_;
280 my $cust_main = $opt{'cust_main'}; # or die 'cust_main required';
281 my $object = $opt{'object'}; # or die 'object required';
283 my $locale = $cust_main ? $cust_main->locale : '';
285 warn "no locale for cust#".$cust_main->custnum."; using default content\n"
286 if $DEBUG and $cust_main and !$locale;
288 my $content = $self->content( $locale );
291 "preparing template '%s' to cust#%s\n",
293 $cust_main ? $cust_main->custnum : 'none'
296 my $subs = $self->substitutions;
299 # create substitution table
302 my ( @objects, @prefixes );
304 @objects = ( $cust_main );
309 if( ref($object) eq 'ARRAY' ) {
310 # [new, old], for provisioning tickets
311 push @objects, $object->[0], $object->[1];
312 push @prefixes, 'new_', 'old_';
313 $svc = $object->[0] if $object->[0]->isa('FS::svc_Common');
316 push @objects, $object;
318 $svc = $object if $object->isa('FS::svc_Common');
322 push @objects, $svc->cust_svc->cust_pkg;
326 foreach my $obj (@objects) {
327 my $prefix = shift @prefixes;
328 foreach my $name (@{ $subs->{$obj->table} }) {
331 $hash{$prefix.$name} = $obj->$name();
333 elsif( ref($name) eq 'ARRAY' ) {
334 # [ foo => sub { ... } ]
335 $hash{$prefix.($name->[0])} = $name->[1]->($obj);
338 warn "bad msg_template substitution: '$name'\n";
344 if ( $opt{substitutions} ) {
345 $hash{$_} = $opt{substitutions}->{$_} foreach keys %{$opt{substitutions}};
348 $_ = encode_entities($_ || '') foreach values(%hash);
353 my $subject_tmpl = new Text::Template (
355 SOURCE => $content->subject,
357 my $subject = $subject_tmpl->fill_in( HASH => \%hash );
359 my $body = $content->body;
360 my ($skin, $guts) = eviscerate($body);
362 $_ = decode_entities($_); # turn all punctuation back into itself
363 s/\r//gs; # remove \r's
364 s/<br[^>]*>/\n/gsi; # and <br /> tags
365 s/<p>/\n/gsi; # and <p>
366 s/<\/p>//gsi; # and </p>
367 s/\240/ /gs; # and
371 $body = '{ use Date::Format qw(time2str); "" }';
372 while(@$skin || @$guts) {
373 $body .= shift(@$skin) || '';
374 $body .= shift(@$guts) || '';
381 my $body_tmpl = new Text::Template (
386 $body = $body_tmpl->fill_in( HASH => \%hash );
393 if ( exists($opt{'to'}) ) {
395 @to = split(/\s*,\s*/, $opt{'to'});
397 } elsif ( $cust_main ) {
400 if ( $opt{'to_contact_classnum'} ) {
401 my $classnum = $opt{'to_contact_classnum'};
402 @classes = ref($classnum) ? @$classnum : split(',', $classnum);
405 @classes = ( 'invoice' );
407 @to = $cust_main->contact_list_email(@classes);
411 die 'no To: address or cust_main object specified';
415 my $from_addr = $self->from_addr;
418 my @agentnum = ( $cust_main->agentnum ) if $cust_main;
419 if ( $opt{'from_config'} ) {
420 $from_addr = scalar( $conf->config( $opt{'from_config'}, @agentnum ));
422 $from_addr ||= $conf->invoice_from_full( @agentnum );
425 # if ( $conf->exists('log_sent_mail') and !$opt{'preview'} ) {
426 # my $cust_msg = FS::cust_msg->new({
427 # 'custnum' => $cust_main->custnum,
428 # 'msgnum' => $self->msgnum,
429 # 'status' => 'prepared',
432 # @cust_msg = ('cust_msg' => $cust_msg);
435 my $text_body = encode('UTF-8',
436 HTML::FormatText->new(leftmargin => 0, rightmargin => 70)
437 ->format( HTML::TreeBuilder->new_from_content($body) )
440 'custnum' => $cust_main ? $cust_main->custnum : undef,
441 'msgnum' => $self->msgnum,
442 'from' => $from_addr,
444 'bcc' => $self->bcc_addr || undef,
445 'subject' => $subject,
446 'html_body' => $body,
447 'text_body' => $text_body,
452 =item send OPTION => VALUE
454 Fills in the template and sends it to the customer. Options are as for
455 'prepare', plus 'attach', a L<MIME::Entity> (or arrayref of them) to attach
460 # broken out from prepare() in case we want to queue the sending,
466 my %email = generate_email($self->prepare(%opt));
467 if ( $opt{'attach'} ) {
469 if (ref($opt{'attach'}) eq 'ARRAY') {
470 @attach = @{ $opt{'attach'} };
472 @attach = $opt{'attach'};
474 push @{ $email{mimeparts} }, @attach;
480 =item render OPTION => VALUE ...
482 Fills in the template and renders it to a PDF document. Returns the
483 name of the PDF file.
485 Options are as for 'prepare', but 'from' and 'to' are meaningless.
489 # will also have options to set paper size, margins, etc.
493 eval "use PDF::WebKit";
496 my %hash = $self->prepare(%opt);
497 my $html = $hash{'html_body'};
499 # Graphics/stylesheets should probably go in /var/www on the Freeside
501 my $script_path = `/usr/bin/which freeside-wkhtmltopdf`;
503 my $kit = PDF::WebKit->new(\$html); #%options
504 # hack to use our wrapper script
505 $kit->configure(sub { shift->wkhtmltopdf($script_path) });
512 Render a PDF and send it to the printer. OPTIONS are as for 'render'.
517 my( $self, %opt ) = @_;
518 do_print( [ $self->render(%opt) ], agentnum=>$opt{cust_main}->agentnum );
521 # helper sub for package dates
522 my $ymd = sub { $_[0] ? time2str('%Y-%m-%d', $_[0]) : '' };
524 # helper sub for money amounts
525 my $money = sub { ($conf->money_char || '$') . sprintf('%.2f', $_[0] || 0) };
527 # helper sub for usage-related messages
528 my $usage_warning = sub {
530 foreach my $col (qw(seconds upbytes downbytes totalbytes)) {
531 my $amount = $svc->$col; next if $amount eq '';
532 my $method = $col.'_threshold';
533 my $threshold = $svc->$method; next if $threshold eq '';
534 return [$col, $amount, $threshold] if $amount <= $threshold;
535 # this only returns the first one that's below threshold, if there are
541 #my $conf = new FS::Conf;
543 #return contexts and fill-in values
544 # If you add anything, be sure to add a description in
545 # httemplate/edit/msg_template.html.
547 my $payinfo_sub = sub {
549 ($obj->payby eq 'CARD' || $obj->payby eq 'CHEK')
551 : $obj->decrypt($obj->payinfo)
553 my $payinfo_end = sub {
555 my $payinfo = &$payinfo_sub($obj);
556 substr($payinfo, -4);
558 { 'cust_main' => [qw(
559 display_custnum agentnum agent_name
562 name name_short contact contact_firstlast
563 address1 address2 city county state zip
565 daytime night mobile fax
568 ship_name ship_name_short ship_contact ship_contact_firstlast
569 ship_address1 ship_address2 ship_city ship_county ship_state ship_zip
572 paymask payname paytype payip
573 num_cancelled_pkgs num_ncancelled_pkgs num_pkgs
574 classname categoryname
577 invoicing_list_emailonly
578 cust_status ucfirst_cust_status cust_statuscolor
583 [ invoicing_email => sub { shift->invoicing_list_emailonly_scalar } ],
584 #compatibility: obsolete ship_ fields - use the non-ship versions
587 [ "ship_$field" => sub { shift->$field } ]
589 qw( last first company daytime night fax )
591 # ship_name, ship_name_short, ship_contact, ship_contact_firstlast
593 [ expdate => sub { shift->paydate_epoch } ], #compatibility
594 [ signupdate_ymd => sub { $ymd->(shift->signupdate) } ],
595 [ dundate_ymd => sub { $ymd->(shift->dundate) } ],
596 [ paydate_my => sub { sprintf('%02d/%04d', shift->paydate_monthyear) } ],
597 [ otaker_first => sub { shift->access_user->first } ],
598 [ otaker_last => sub { shift->access_user->last } ],
599 [ payby => sub { FS::payby->shortname(shift->payby) } ],
600 [ company_name => sub {
601 $conf->config('company_name', shift->agentnum)
603 [ company_address => sub {
604 $conf->config('company_address', shift->agentnum)
606 [ company_phonenum => sub {
607 $conf->config('company_phonenum', shift->agentnum)
609 [ selfservice_server_base_url => sub {
610 $conf->config('selfservice_server-base_url') #, shift->agentnum)
615 pkgnum pkg_label pkg_label_long
619 start_date setup bill last_bill
623 [ pkg => sub { shift->part_pkg->pkg } ],
624 [ pkg_category => sub { shift->part_pkg->categoryname } ],
625 [ pkg_class => sub { shift->part_pkg->classname } ],
626 [ cancel => sub { shift->getfield('cancel') } ], # grrr...
627 [ start_ymd => sub { $ymd->(shift->getfield('start_date')) } ],
628 [ setup_ymd => sub { $ymd->(shift->getfield('setup')) } ],
629 [ next_bill_ymd => sub { $ymd->(shift->getfield('bill')) } ],
630 [ last_bill_ymd => sub { $ymd->(shift->getfield('last_bill')) } ],
631 [ adjourn_ymd => sub { $ymd->(shift->getfield('adjourn')) } ],
632 [ susp_ymd => sub { $ymd->(shift->getfield('susp')) } ],
633 [ expire_ymd => sub { $ymd->(shift->getfield('expire')) } ],
634 [ cancel_ymd => sub { $ymd->(shift->getfield('cancel')) } ],
636 # not necessarily correct for non-flat packages
637 [ setup_fee => sub { shift->part_pkg->option('setup_fee') } ],
638 [ recur_fee => sub { shift->part_pkg->option('recur_fee') } ],
640 [ freq_pretty => sub { shift->part_pkg->freq_pretty } ],
649 [ due_date2str => sub { shift->due_date2str('short') } ],
651 #XXX not really thinking about cust_bill substitutions quite yet
653 # for welcome and limit warning messages
659 [ password => sub { shift->getfield('_password') } ],
660 [ column => sub { &$usage_warning(shift)->[0] } ],
661 [ amount => sub { &$usage_warning(shift)->[1] } ],
662 [ threshold => sub { &$usage_warning(shift)->[2] } ],
669 my $registrar = qsearchs('registrar',
670 { registrarnum => shift->registrarnum} );
671 $registrar ? $registrar->registrarname : ''
675 my $svc_acct = qsearchs('svc_acct', { svcnum => shift->catchall });
676 $svc_acct ? $svc_acct->email : ''
687 'svc_broadband' => [qw(
695 # for payment receipts
700 [ paid => sub { sprintf("%.2f", shift->paid) } ],
701 # overrides the one in cust_main in cases where a cust_pay is passed
702 [ payby => sub { FS::payby->shortname(shift->payby) } ],
703 [ date => sub { time2str("%a %B %o, %Y", shift->_date) } ],
704 [ 'payinfo' => $payinfo_sub ],
705 [ 'payinfo_end' => $payinfo_end ],
707 # for refund receipts
710 [ refund => sub { sprintf("%.2f", shift->refund) } ],
711 [ payby => sub { FS::payby->shortname(shift->payby) } ],
712 [ date => sub { time2str("%a %B %o, %Y", shift->_date) } ],
713 [ 'payinfo' => $payinfo_sub ],
714 [ 'payinfo_end' => $payinfo_end ],
716 # for payment decline messages
717 # try to support all cust_pay fields
718 # 'error' is a special case, it contains the raw error from the gateway
719 'cust_pay_pending' => [qw(
723 [ paid => sub { sprintf("%.2f", shift->paid) } ],
724 [ payby => sub { FS::payby->shortname(shift->payby) } ],
725 [ date => sub { time2str("%a %B %o, %Y", shift->_date) } ],
726 [ 'payinfo' => $payinfo_sub ],
727 [ 'payinfo_end' => $payinfo_end ],
734 Returns the L<FS::template_content> object appropriate to LOCALE, if there
735 is one. If not, returns the one with a NULL locale.
742 qsearchs('template_content',
743 { 'msgnum' => $self->msgnum, 'locale' => $locale }) ||
744 qsearchs('template_content',
745 { 'msgnum' => $self->msgnum, 'locale' => '' });
750 Returns the L<FS::agent> object for this template.
755 qsearchs('agent', { 'agentnum' => $_[0]->agentnum });
759 my ($self, %opts) = @_;
762 # First move any historical templates in config to real message templates
766 [ 'alerter_msgnum', 'alerter_template', '', '', '' ],
767 [ 'cancel_msgnum', 'cancelmessage', 'cancelsubject', '', '' ],
768 [ 'decline_msgnum', 'declinetemplate', '', '', '' ],
769 [ 'impending_recur_msgnum', 'impending_recur_template', '', '', 'impending_recur_bcc' ],
770 [ 'payment_receipt_msgnum', 'payment_receipt_email', '', '', '' ],
771 [ 'welcome_msgnum', 'welcome_email', 'welcome_email-subject', 'welcome_email-from', '' ],
772 [ 'warning_msgnum', 'warning_email', 'warning_email-subject', 'warning_email-from', '' ],
775 my @agentnums = ('', map {$_->agentnum} qsearch('agent', {}));
776 foreach my $agentnum (@agentnums) {
778 my ($newname, $oldname, $subject, $from, $bcc) = @$_;
779 if ($conf->exists($oldname, $agentnum)) {
780 my $new = new FS::msg_template({
781 'msgname' => $oldname,
782 'agentnum' => $agentnum,
783 'from_addr' => ($from && $conf->config($from, $agentnum)) || '',
784 'bcc_addr' => ($bcc && $conf->config($from, $agentnum)) || '',
785 'subject' => ($subject && $conf->config($subject, $agentnum)) || '',
786 'mime_type' => 'text/html',
787 'body' => join('<BR>',$conf->config($oldname, $agentnum)),
789 my $error = $new->insert;
790 die $error if $error;
791 $conf->set($newname, $new->msgnum, $agentnum);
792 $conf->delete($oldname, $agentnum);
793 $conf->delete($from, $agentnum) if $from;
794 $conf->delete($subject, $agentnum) if $subject;
798 if ( $conf->exists('alert_expiration', $agentnum) ) {
799 my $msgnum = $conf->exists('alerter_msgnum', $agentnum);
800 my $template = FS::msg_template->by_key($msgnum) if $msgnum;
802 warn "template for alerter_msgnum $msgnum not found\n";
805 # this is now a set of billing events
806 foreach my $days (30, 15, 5) {
807 my $event = FS::part_event->new({
808 'agentnum' => $agentnum,
809 'event' => "Card expiration warning - $days days",
810 'eventtable' => 'cust_main',
811 'check_freq' => '1d',
812 'action' => 'notice',
813 'disabled' => 'Y', #initialize first
815 my $error = $event->insert( 'msgnum' => $msgnum );
817 warn "error creating expiration alert event:\n$error\n\n";
820 # make it work like before:
821 # only send each warning once before the card expires,
822 # only warn active customers,
823 # only warn customers with CARD/DCRD,
824 # only warn customers who get email invoices
826 'once_every' => { 'run_delay' => '30d' },
827 'cust_paydate_within' => { 'within' => $days.'d' },
828 'cust_status' => { 'status' => { 'active' => 1 } },
829 'payby' => { 'payby' => { 'CARD' => 1,
832 'message_email' => {},
834 foreach (keys %conds) {
835 my $condition = FS::part_event_condition->new({
836 'conditionname' => $_,
837 'eventpart' => $event->eventpart,
839 $error = $condition->insert( %{ $conds{$_} });
841 warn "error creating expiration alert event:\n$error\n\n";
845 $error = $event->initialize;
847 warn "expiration alert event was created, but not initialized:\n$error\n\n";
850 $conf->delete('alerter_msgnum', $agentnum);
851 $conf->delete('alert_expiration', $agentnum);
853 } # if alerter_msgnum
858 # Move subject and body from msg_template to template_content
861 foreach my $msg_template ( qsearch('msg_template', {}) ) {
862 if ( $msg_template->subject || $msg_template->body ) {
863 # create new default content
865 $content{subject} = $msg_template->subject;
866 $msg_template->set('subject', '');
868 # work around obscure Pg/DBD bug
869 # https://rt.cpan.org/Public/Bug/Display.html?id=60200
870 # (though the right fix is to upgrade DBD)
871 my $body = $msg_template->body;
872 if ( $body =~ /^x([0-9a-f]+)$/ ) {
873 # there should be no real message templates that look like that
874 warn "converting template body to TEXT\n";
875 $body = pack('H*', $1);
877 $content{body} = $body;
878 $msg_template->set('body', '');
880 my $error = $msg_template->replace(%content);
881 die $error if $error;
886 # Add new-style default templates if missing
888 $self->_populate_initial_data;
890 ### Fix dump-email_to (needs to happen after _populate_initial_data)
891 if ($conf->config('dump-email_to')) {
892 # anyone who still uses dump-email_to should have just had this created
893 my ($msg_template) = qsearch('msg_template',{ msgname => 'System log' });
895 eval "use FS::log_email;";
897 my $log_email = new FS::log_email {
898 'context' => 'Cron::backup',
900 'msgnum' => $msg_template->msgnum,
901 'to_addr' => $conf->config('dump-email_to'),
903 my $error = $log_email->insert;
904 die $error if $error;
905 $conf->delete('dump-email_to');
911 sub _populate_initial_data { #class method
912 #my($class, %opts) = @_;
915 eval "use FS::msg_template::InitialData;";
917 eval "use FS::upgrade_journal;";
920 my $initial_data = FS::msg_template::InitialData->_initial_data;
922 foreach my $hash ( @$initial_data ) {
924 next if $hash->{_conf} && $conf->config( $hash->{_conf} );
925 next if $hash->{_upgrade_journal} && FS::upgrade_journal->is_done( $hash->{_upgrade_journal} );
927 my $msg_template = new FS::msg_template($hash);
928 my $error = $msg_template->insert( @{ $hash->{_insert_args} || [] } );
929 die $error if $error;
931 $conf->set( $hash->{_conf}, $msg_template->msgnum ) if $hash->{_conf};
932 FS::upgrade_journal->set_done( $hash->{_upgrade_journal} )
933 if $hash->{_upgrade_journal};
940 # Every bit as pleasant as it sounds.
942 # We do this because Text::Template::Preprocess doesn't
943 # actually work. It runs the entire template through
944 # the preprocessor, instead of the code segments. Which
945 # is a shame, because Text::Template already contains
946 # the code to do this operation.
948 my (@outside, @inside);
951 while($body || $chunk) {
952 my ($first, $delim, $rest);
953 # put all leading non-delimiters into $first
955 ($body =~ /^((?:\\[{}]|[^{}])*)(.*)$/s);
957 # put a leading delimiter into $delim if there is one
959 ($rest =~ /^([{}]?)(.*)$/s);
961 if( $delim eq '{' ) {
964 push @outside, $chunk;
969 elsif( $delim eq '}' ) {
972 push @inside, $chunk;
980 push @outside, $chunk . $rest;
981 } # else ? something wrong
986 (\@outside, \@inside);
995 L<FS::Record>, schema.html from the base documentation.