# -*- Mode: perl -*- # # $Id: MxScreen.pm,v 0.1.1.1 2001/05/30 21:13:07 ram Exp $ # # Copyright (c) 1998-2001, Raphael Manfredi # Copyright (c) 2000-2001, Christophe Dehaudt # # You may redistribute only under the terms of the Artistic License, # as specified in the README file that comes with the distribution. # # HISTORY # $Log: MxScreen.pm,v $ # Revision 0.1.1.1 2001/05/30 21:13:07 ram # patch1: fixed HISTORY section # patch1: random cleanup in named argument docs # patch1: updated version number # # Revision 0.1 2001/04/22 17:57:03 ram # Baseline for first Alpha release. # # $EndLog$ # use strict; package CGI::MxScreen; use vars qw($VERSION $BIN_VERSION); $VERSION = '0.103'; $BIN_VERSION = '0.1'; use CGI::MxScreen::Constant; use CGI::MxScreen::Error; use Carp::Datum; use Log::Agent; use Getargs::Long; use Time::HiRes qw(time); require CGI; require CGI::MxScreen::Form::Field; require CGI::MxScreen::Form::Button; require CGI::MxScreen::Layout; require CGI::MxScreen::Session; my @managers; # For END {} # # ->make # # Creation routine. # sub make { DFEATURE my $f_; my $self = bless {}, shift; my ($tm_start, $tm_user, $tm_sys) = (time, times); # # Prevent anything to be written to STDOUT by tieing it to a package # that will log anything written there, without letting it go through. # require CGI::MxScreen::Tie::Stdout; tie *main::STDOUT, "CGI::MxScreen::Tie::Stdout"; # # Argument parsing. # ( $self->{_screen_list}, $self->{_initial_state}, $self->{_cgi_version}, $self->{_valid_time}, $self->{_bgcolor}, $self->{_layout}, ) = cxgetargs(@_, -screens => 'HASH', -initial => undef, -version => [undef, '1.0'], -timeout => ['i'], -bgcolor => [undef, '#bfbfbf'], -layout => ['CGI::MxScreen::Layout'], ); $self->{_start_times} = [$tm_start, $tm_user, $tm_sys]; $self->{_last_times} = [$tm_start, $tm_user, $tm_sys]; $self->{_layout} = CGI::MxScreen::Layout->make() unless defined $self->{_layout}; # # Perform default initialization if not already done via a call # to "use CGI::MxScreen::Config;". # require CGI::MxScreen::Config; CGI::MxScreen::Config::configure(); # Will return if already done if (defined $CGI::MxScreen::Config::LOG) { $self->{_log} = $CGI::MxScreen::Config::LOG; } else { use File::Spec; require Log::Agent::Logger; require Log::Agent::Channel::File; my $devnull = Log::Agent::Channel::File->make( -filename => File::Spec->devnull, -no_prefixing => 1, -no_ucfirst => 1, -no_newline => 1, ); $self->{_log} = Log::Agent::Logger->make(-channel => $devnull); } # # Now that logging is up, validate the creation routine parameters. # logcroak "-initial must be either a plain scalar or an ARRAY ref" if ref $self->initial_state && ref $self->initial_state ne "ARRAY"; my $state_name = ref $self->initial_state ? $self->initial_state->[0] : $self->initial_state; logcroak "initial state '$state_name' is not a known state" unless $self->is_valid_state($state_name); # # Initialize whole script context. # $self->trace_incoming; # XXX if logtrace_at("info") or logdebug_at("warn") my $session = $self->{_session} = CGI::MxScreen::Session->make( -serializer => $CGI::MxScreen::Config::SERIALIZER, -medium => $CGI::MxScreen::Config::MEDIUM, ); $self->{_context} = $session->restore; $self->check_validity(); # # Relink all serialized screens to the new manager. # my $ctx = $self->ctx; if (exists $ctx->{'screens'}) { foreach my $screen (values %{$ctx->{'screens'}}) { $screen->relink_to_manager($self); } } # # For session logging, maintain the following parameters in # the private CGI::MxScreen context. # # log_session unique session ID for logging (IP number-PID) # log_starttime time when session started # log_cnt counter incremented each time we're invoked # # unless (exists $ctx->{'log_session'}) { $ctx->{'log_session'} = CGI::remote_host() . "-" . $$; $ctx->{'log_starttime'} = int(time); $ctx->{'log_cnt'} = 0; } else { $ctx->{'log_cnt'}++; } # # From now on, all Log::Agent messages will bear the session ID. # require Log::Agent::Tag::String; use Log::Agent qw(logtags); my $tag = Log::Agent::Tag::String->make( -name => "session id", -value => "(" . $ctx->{'log_session'} . ")", ); my $log = $self->log; $log->tags->append($tag); logtags()->append($tag); $log->warning(""); $log->warning(\&log_session, $self); $log->info(\&log_agent, $self); $log->debug(\&log_inc_times, $self, "context restore + log init"); # # Process incoming parameters, trap all errors. # # Since we might be using CGI::Carp, we must cancel any trap hook by # localizing the __DIE__ and __WARN__ special handlers. # eval { local $SIG{__DIE__}; local $SIG{__WARN__}; $self->process_params; }; $log->debug(\&log_inc_times, $self, "parameter init"); $self->internal_error($@) if chomp $@; push(@managers, $self); return DVAL $self; } # # ->process_params # # Get CGI parameters, fill internal data structures. # sub process_params { DFEATURE my $f_; my $self = shift; # # Save params provided by CGI # # It is a quite big story because there are different possiblities # for the store location according to the way the fields have been # recorded, and also according to the storage indication settings. # # When the field has been recorded at display time (use of # record_field method), it might contain some storage indications # (see Form::Field). It may also contain an indication to not save # the value (useful for password). Anyway, when the incoming param # matches a recorded field from the last display, the store_value # method of the field is invoked to perform the task according to # the indication. Returning true indicates there is no need to # save the param value in the MxScreen repository (see # below). Actually, either the value has been save somewhere else # or there were some indication to not keep the value persistent. # # When there is no specific indication for the storage (either the # field has not been recorded but has been merely displayed, or # the store_value method returned false), the value is memorized # into the MxScreen repository for the orphan params. It is a # dedicated section of the context. Each orphan params is stored # in that section under the index of the screen name. All the orphan # params are replayed --put into the CGI param list-- to benefit # from their values when the field is once again displayed. # # NOTES: You have to know that a button press returns also a value # into the incoming CGI param list. The following code needs to # take care of that by filtering them before being considered as # orphan fields. For simple button, it is quite easy since the # param name must have been recorded into the Mxscreen Button # list, but for image button the returned param does not match # exactly the one recorded. For this latter button, the returned # param is in fact 2 params which indicates the click # location. Their name is composed by the param name (recorded in # the Mxscreen Button list) plus '.x' or '.y'. # # NOTES: CGI param list does not alway returned a value for all # displayed field of the screen. For some specific elements (for # instance checkbox group), no value is returned when the field is # cleared (no box checked in the previous example). This clear # value must however be saved into the storage location. To cope # this problem, all the known displayed fields (those in the # recorded list of fields, and those in the orphan repository of # the screen) are checked to validate the existence of a value # into the CGI param list. When no value is found, a clear value # ('') is enforced. # # load the package of the last screen where all needed classes # should have been defined. my $current_state = $self->initial_state; $current_state = $self->ctx->{'current_state'} if (defined $self->ctx->{'current_state'}); my ($screen_name) = $self->scatter($current_state); $self->load_screen_package($screen_name); # build a easy access way to recorded field and button: make a # hash from the array my $var_ctx = $self->context(PERSISTENT); my $field_hash = {}; for my $field (@{$self->context(SCREEN_FIELD)}) { DASSERT $field->isa('CGI::MxScreen::Form::Field'); $field_hash->{$field->name} = $field; } my $button_hash = {}; for my $button (@{$self->context(SCREEN_BUTTON)}) { DASSERT $button->isa('CGI::MxScreen::Form::Button'); $button_hash->{$button->name} = $button; } # # Patch the CGI param list for fields which are known to be # displayed but no value appears in the CGI list. # my $cgi_param = $self->context(CGI_PARAM); while (my ($k, $v) = each %{$cgi_param->{$screen_name}}) { CGI::param(-name => $k, -values => $v) unless defined CGI::param($k); } while (my ($k, $v) = each %$field_hash) { CGI::param(-name => $k, -values => $v->value) unless defined CGI::param($k); } # walkthrough the CGI param list to store values for my $param (CGI::param()) { DTRACE "storing incoming param $param"; my $field = $field_hash->{$param}; # return form CGI param might be either a single element or a # list of elements. To get all of them, an array context must # be used. Then, the value that will be stored is either the # array reference or the first and single element of the # array. my @value = CGI::param($param); my $value = $#value == 0 ? $value[0]: \@value; if (defined $field) { # # Patch the value (if needed) # Then store value according to the storage indication given in # the field (if any) # my ($patched, $nvalue) = $field->patch_value($value); if ($patched) { CGI::param(-name => $param, -values => $nvalue) if $patched; next if $field->store_value($var_ctx, $nvalue); } else { next if $field->store_value($var_ctx, $value); } } # no storage indication is present # # perhaps it was a button rather than a field # # image button press is embarrassing. In such a case, the # returned param is not 1 but 2 params which represents the # location of the click within the image if ($param =~ /(.*)\.([xy])$/) { if (defined (my $x = CGI::param("$1.x")) && defined (my $y = CGI::param("$1.y"))) { next if $2 eq "y"; # do the job only for x $param = $1; } } if (defined $button_hash->{$param}) { $self->internal_error( "invalid input form: buttons '" . $self->button_pressed->name . "' and '$param' were simultaneously pressed!") if defined $self->button_pressed; # Remember it as the button that was pressed $self->{_button_pressed} = $button_hash->{$param}; next; } # It is an orphan field that has not been saved. Keep it in # mind into the param repository. The param context is stored # under the name of the screen to build a kind of # hierachy. That allows the clean up functionality when # leaving a screen (on explicit request). $cgi_param->{$screen_name}->{$param} = $value; } # all orphan params will populate the CGI's param list if they are # not already present. That will allow to prefill fields when # redisplay and to give an access to their values with regular # CGI::param(). # # Information is organized in a hash table where the key is the # screen id (state name) and the value is another hash. The latter # contains the pair of data: symbol, value that must be restored. while (my ($screen , $hash) = each %$cgi_param) { while (my ($k, $v) = each %$hash) { CGI::param(-name => $k, -values => $v); } } return DVOID; } ######################################################################### # Internal Attribute Access: these methods are not intended to be used # # from the external world. # ######################################################################### sub screen_list { $_[0]->{'_screen_list'} } sub context_root { $_[0]->{'_context'} } sub screen { $_[0]->{'_screen'} } sub session { $_[0]->{'_session'} } sub cgi_version { $_[0]->{'_cgi_version'} } sub valid_time { $_[0]->{'_valid_time'} } sub initial_state { $_[0]->{'_initial_state'} } sub bgcolor { $_[0]->{'_bgcolor'} } sub layout { $_[0]->{'_layout'} } sub log { $_[0]->{'_log'} } sub start_times { $_[0]->{'_start_times'} } sub last_times { $_[0]->{'_last_times'} } sub button_pressed { $_[0]->{_button_pressed} } sub ctx { defined $_[0]->{'_context'} ? $_[0]->{'_context'}->[MXSCREEN] : {} } # # ->is_valid_state # # Check whether state is known # sub is_valid_state { DFEATURE my $f_; my $self = shift; my ($state) = @_; return DVAL exists $self->screen_list->{$state}; } # # ->load_screen_package # # Load source file for the class implementing the screen $name, unless # it is already present. # sub load_screen_package { DFEATURE my $f_; my $self = shift; my ($name) = @_; DREQUIRE $self->is_valid_state($name), "valid state '$name'"; my ($class_name) = cgetargs(@{$self->screen_list->{$name}}, {-strict => 0}, qw(class)); # # The following eval "" attempts to load the screen class by using # a require, assuming there is one class by file. However, we # check for the presence of an @ISA variable in the target package # before performing the require, since the application could have # already loaded all the screen classes. Given that all screens must # inherit from CGI::MxScreen::Screen, we know @ISA is defined if the # package is present. # eval "require $class_name unless defined \@${class_name}::ISA;"; if (chomp $@) { logerr "loading of $class_name failed: $@"; logdie "can't locate class \"$class_name\" for screen state \"$name\""; } return DVOID; } # # ->make_screen # # Create the screen for given state. # sub make_screen { DFEATURE my $f_; my $self = shift; my ($name) = @_; DREQUIRE $self->is_valid_state($name), "valid state '$name'"; $self->load_screen_package($name); my ($class_name, @remaining) = cgetargs(@{$self->screen_list->{$name}}, {-strict => 0, -extra => 1}, qw(class)); # # If the state has already been seen already, it has been serialized # in the context, but it needs to be relinked to the new manager instance. # # Otherwise, a new object is created and remembered in the context. # my $cxt = $self->ctx; # CGI::MxScreen own private context my $screen; if (exists $cxt->{'screens'}->{$name}) { $screen = $cxt->{'screens'}->{$name}; $screen->remake($self); } else { $screen = $class_name->make( -manager => $self, -name => $name, @remaining ); $cxt->{'screens'}->{$name} = $screen; } return DVAL $screen; } # # ->scatter # # Return: # either a list with a single element when incoming param is a # scalar value or a list with all element of the incoming list. # sub scatter { DFEATURE my $f_; my $self = shift; my ($id) = @_; return DARY @$id if ref $id eq 'ARRAY'; return DARY ($id); } # # ->obj_scatter # # Same as scatter(), but handles ($obj, $routine, @args) as well. # Supplies the screen if no blessed object is identified in the first # position of the list. # sub obj_scatter { DFEATURE my $f_; my $self = shift; my ($screen, $id) = @_; return DARY ($screen, $id) unless ref $id eq 'ARRAY'; if (ref $id->[0] && UNIVERSAL::isa($id->[0], "UNIVERSAL")) { $screen = $id->[0]; return DARY ($screen, @$id[1..$#$id]); } return DARY ($screen, @$id); } ######################################################################### # Class Feature: usable from the external world # ######################################################################### # # ->context # # return a reference of a given section withtin the overal context # area # # Arguments: # $index: index of the context section to returned # # Return: # a reference to the requested context section # sub context { DFEATURE my $f_; DREQUIRE $_[1] =~ /^\d+$/; DREQUIRE $_[1] >= 0 && $_[1] < CONTEXT_COUNT; return DVAL $_[0]->context_root->[$_[1]]; } # # ->spring_screen # ->previous_screen # ->current_screen # # Returns [state, display_args] # sub spring_screen { DFEATURE my $f_; return DVAL $_[0]->ctx->{'spring_state'}; # Last stable state(args) } sub previous_screen { DFEATURE my $f_; return DVAL $_[0]->ctx->{'previous_state'}; # Previous state(args) } sub current_screen { DFEATURE my $f_; return DVAL $_[0]->ctx->{'current_state'}; # Current state(args) } # # ->play # # Play the sequence of action necessary to display the new screen. # sub play { DFEATURE my $f_; my $self = shift; # coderef is a temporary arg until storable is able to select things to # store (storable::Hook) my ($coderef) = @_; my $log = $self->log; $log->debug(\&log_inc_times, $self, "outside CGI::MxScreen"); # # Compute target screen, trap all errors. # # Since we might be using CGI::Carp, we must cancel any trap hook by # localizing the __DIE__ and __WARN__ special handlers. # my ($screen, $args); eval { local $SIG{__DIE__}; local $SIG{__WARN__}; ($screen, $args) = $self->compute_screen; }; $log->debug(\&log_inc_times, $self, "screen computation"); $self->internal_error($@) if chomp $@; # # Emit CGI headers # From now on, output is safe and will not get us a server error. # untie *main::STDOUT; # Restore original STDOUT stream # # If they configured us to buffer all STDOUT until context is ready # to be emitted, then create object, print headers and mark the # output of headers as done: further output to STDOUT will be buffered # and printed only after the context. # # The reason for this is to have the context emitted before any other # form widget. That way, pressing a submit button before the whole form # is loaded in the browser won't matter as much, since we'll have at # least the context to propagate in the POST parameters. # my $stdout; if ($CGI::MxScreen::cf::mx_buffer_stdout) { require CGI::MxScreen::Tie::Buffered_Output; $stdout = tie *main::STDOUT, "CGI::MxScreen::Tie::Buffered_Output"; } # # Display screen, with proper "bounce" exception support. # Returns screen that was finally displayed. # $screen = $self->display($screen, $args, $stdout); $log->debug(\&log_inc_times, $self, "\"%s\" display", $screen->name); # # Snapshot current time and last modification date of the # scriptright before saving context. That fields can be used to # check for session validity. # $self->ctx->{'time'} = time; $self->ctx->{'script_date'} = (stat($0))[9]; # # Cleanup context to avoid saving transient data # &{$coderef}() if defined $coderef; # TBR for my $f (@{$self->context_root->[SCREEN_FIELD]}) { DASSERT $f->isa('CGI::MxScreen::Form::Field'); $f->cleanup(); } for my $b (@{$self->context_root->[SCREEN_BUTTON]}) { DASSERT $b->isa('CGI::MxScreen::Form::Button'); $b->cleanup(); } # # If STDOUT was bufferd, the context must be emitted explicitely # between the header of the form and the remaining data. # if (defined $stdout) { my $context = $self->session->save; $stdout->print_all($context); untie_stdout(); } else { print $self->session->save; } $log->debug(\&log_inc_times, $self, "context save"); # # Emit CGI trailers. # print CGI::endform; my $layout = $self->layout; $layout->postamble; $layout->end_HTML; return DVOID; } # # ->compute_screen # # Compute target screen, and run and enter/leave hooks if we change screens. # This routine does not display anything, but runs all the action callbacks. # # Returns new screen object, and a ref to the argument list. # sub compute_screen { DFEATURE my $f_; my $self = shift; my ($current_state, $previous_state, $new_state); my ($origin_name, $target_name, @arg_list); my $screen; my $errors = 0; my $ctx = $self->ctx; # get the current state from the context its format can be either # 'screen_name' or ['screen_name', @arg_list]. 'screen_name' is the # symbol key given to a screen name into the given screen list (at # make time) and @arg_list is a list of arg to pass to the display # routine of the screen. $current_state = $self->initial_state; $current_state = $ctx->{'current_state'} if (defined $ctx->{'current_state'}); $previous_state = $current_state; $new_state = $current_state; # # Compute the destination and process the associated actions when # a button has been detected as pressed (during the make method). # # If we could not identify a button that was pressed, we'll simply # remain in the current state and re-display the form unless there was # a default button recorded in the previous screen. # my $button_pressed = $self->button_pressed; if ($ctx->{'log_cnt'} && !defined $button_pressed) { # # Create the previous screen to lookup for a default button # ($origin_name) = $self->scatter($previous_state); $screen = $self->make_screen($origin_name); my $default = $screen->default_button; if (defined $default) { $button_pressed = $self->{_button_pressed} = $default; $self->log->warning("no button pressed, using default \"%s\"", $default->value); } else { $self->log->error( "no button pressed, no default, will stay in same state"); } } if (defined $button_pressed) { # # Create the previous screen to perform the actions # Screen could have been created above, during the default # button computation, hence the check. # unless (defined $screen) { ($origin_name) = $self->scatter($previous_state); $screen = $self->make_screen($origin_name); } # Those are not serialized DASSERT !defined $screen->error_env, "no callback error condition"; DASSERT !defined $screen->error, "no user error condition"; my $act_env; # Action environment if (defined $button_pressed->action) { DASSERT ref $button_pressed->action eq 'ARRAY'; use CGI::MxScreen::Error qw(is_mx_errcode); require CGI::MxScreen::Action_Env; $act_env = CGI::MxScreen::Action_Env->make(); for my $action (@{$button_pressed->action}) { my ($obj, $routine, @routine_arg) = $self->obj_scatter($screen, $action); my $errcode = $obj->$routine(@routine_arg, $act_env); # # Temporary safety net whilst migration of all callback # returned values is ongoing. # if ($errcode == 0 || $errcode == 1) { logwarn "callback %s->%s returned OLD boolean status", ref $obj, $routine; $errcode = $errcode ? CGI_MX_OK : CGI_MX_ABORT; } VERIFY is_mx_errcode($errcode), "callback ", ref($obj), "->$routine returns valid code", " -- returned $errcode"; next if $errcode == CGI_MX_OK; # # an error occurred, don't process the remaining # of actions if it is CGI_MX_ABORT. # # The screen is tagged with an error flag and the state # destination is resumed to the origin screen. # my $called = sprintf "%s->%s", ref($obj), $routine; my $binfo = sprintf "for button \"%s\"", $button_pressed->value; $binfo .= sprintf " (%s)", $button_pressed->name if $button_pressed->name ne $button_pressed->value; DTRACE "error in action callback: $called $binfo"; $self->log->error("action callback $called failed $binfo%s", $errcode == CGI_MX_ABORT ? ", aborting" : ""); $errors++; $screen->set_error_env($act_env); $act_env->add_error($obj, $routine, \@routine_arg); last if $errcode == CGI_MX_ABORT; } $new_state = $current_state if $errors; } # # Get the destination # # * when an error was found, we look at -on_error or -dyn_on_error, # and if one is found, we clear the error condition. # * when no error is raised, we look at -dyn_target or -target. # if ($errors) { # # Look for possible error trapping, which will force a move to # an alternate screen. The error condition is reset, therefore # the internal context of the screen will be cleared. # # For -dyn_on_error, we append the action environment. # DASSERT defined $act_env, "at least one action ran"; if ($button_pressed->has_error_trap) { my $dyn = $button_pressed->dyn_on_error; if (defined $dyn) { my ($routine, @args) = $self->scatter($dyn); DASSERT $screen->can($routine); $new_state = $screen->$routine(@args, $act_env); } else { $new_state = $button_pressed->on_error; } DASSERT defined $new_state; $errors = 0; # Moving to alternate screen } } else { # # No error found. # if ($button_pressed->is_computed_target) { my ($routine, @args) = $self->scatter($button_pressed->dyn_target); DASSERT $screen->can($routine); $new_state = $screen->$routine(@args); } else { $new_state = $button_pressed->target; } } } # clear context area dedicated to save field handles $self->context_root->[SCREEN_FIELD] = []; $self->context_root->[SCREEN_BUTTON] = []; # context might have been saved by the screen -> also clear the copy $screen->_clear_internal_context() if defined $screen && !$errors; # # update the MXSCREEN context # $ctx->{'current_state'} = $new_state; $ctx->{'previous_state'} = $previous_state unless $errors; $ctx->{'cgi_version'} = $self->cgi_version; $ctx->{'bin_version'} = $BIN_VERSION; $self->log->notice(\&log_state, $self, $previous_state, $new_state); # # Create the destination state (if needed) # Then call ->leave and ->enter hooks. # ($target_name, @arg_list) = $self->scatter($new_state); unless (defined $screen && $target_name eq $origin_name) { my $prev_screen = $screen; $screen = $self->make_screen($target_name); if (defined $prev_screen) { $prev_screen->leave($screen); $ctx->{'spring_state'} = $previous_state; # Where we came from } $screen->enter($prev_screen); } return DARY ($screen, \@arg_list); } # # ->display # # Display $screen, with args @$args, with proper support for screen "bounce". # # If $stdout is not undef, then it is a ref to a tied object, meaning STDOUT # is buffered. When bouncing with untied STDOUT, the layout and the headers # can only be emitted once, i.e. for the original screen. A warning is issued # if bouncing. # # Returns screen that was finally displayed. # sub display { DFEATURE my $f_; my $self = shift; my ($screen, $args, $stdout) = @_; for (my $i = 0; $i < 20; $i++) { # Max 20 bounces # # Can only emit the layout and the header each time when $stdout # is tied. We always emit the first time, naturally, since we # don't know whether we'll bounce at all. # if ($i == 0 || defined $stdout) { # # The layout object controls the following aspects: # # html headers # preamble #