# Copyright 2002-2008 Josh Clark and Global Moxie, LLC. This code cannot be
# redistributed without permission from globalmoxie.com.  For more
# information, consult your Big Medium license.
#
# $Id: Prompt.pm 3233 2008-08-21 12:47:26Z josh $

package BigMed::App::Web::Prompt;
use strict;
use warnings;
use utf8;
use Carp;
$Carp::Verbose = 1;
use BigMed::App;
use BigMed::Filter;
use BigMed::Plugin;
use BigMed::DiskUtil qw(bm_file_path);

#all prompt routines receive:
#$_[0]: app object
#$_[1]: field
#$_[2]: hash ref with options

use constant RICH_TEXT_HEIGHT       => 300;
use constant RICH_TEXT_WIDTH        => '99%';
use constant RICH_TEXT_BRIEF_HEIGHT => 100;
use constant RICH_TEXT_BRIEF_WIDTH  => '68%';

sub register_all {
    my $app_class = shift;
    $app_class->register_prompter(

        #default and custom types
        _default => sub { _default_prompt( @_, 'text' ) },
        submit   => sub { _default_prompt( @_, 'submit' ) },
        hidden   => sub { _default_prompt( @_, 'hidden' ) },
        button   => sub { _default_prompt( @_, 'button' ) },
        module_objects  => \&_module_objects,
        priority_slider => \&_priority_slider,

        #BigMed::Elements types
        body_position      => \&_body_position,
        boolean            => sub { _default_prompt( @_, 'checkbox' ) },
        css_box_dimensions => \&_css_box_dimensions,
        css_font_size      => \&_css_font_size,
        css_length         => \&_css_length,
        dir_path           => sub { _default_prompt( @_, 'text' ) },
        dir_url            => sub { _default_prompt( @_, 'text' ) },
        document           => \&_document,
        email              => sub { _default_prompt( @_, 'text' ) },
        icon               => \&_icon,
        id                 => sub { _default_prompt( @_, 'hidden' ) },
        image_file         => \&_image_file,
        kilobytes          => \&_kilobytes,
        key_boolean        => sub { _default_prompt( @_, 'checkbox' ) },
        navigation_style   => \&_navigation_style,
        password           => sub { _default_prompt( @_, 'password' ) },
        radio_toggle       => \&_radio_toggle,
        raw_text           => sub { _default_prompt( @_, 'textarea' ) },
        rich_text          => \&_rich_text,
        rich_text_brief    => \&_rich_text_brief,
        rich_text_inline   => \&_rich_text_inline,
        rich_text_public   => \&_rich_text_public,
        select_section     => \&_select_section,
        simple_text        => sub { _default_prompt( @_, 'text' ) },
        sort_order         => \&_sort_order,
        system_time        => \&_system_time,
        tags               => \&_tags,
        tritoggle          => \&_tritoggle,
        url                => \&_url,
        username           => sub { _default_prompt( @_, 'text' ) },
        value_buttons      => \&_value_buttons,
        value_freeform     => \&_value_freeform,
        value_list         => sub { _default_prompt( @_, 'select' ) },
        value_several      => \&_value_several,
        time_offset        => \&_time_offset,
        url_safe           => sub { _default_prompt( @_, 'text' ) },
    );
}

sub _default_prompt {
    my ( $app, $field, $roptions, $field_type ) = @_;
    $roptions ||= {};
    _field_tmpl_hash( $app, id => $field, type => $field_type, %$roptions );
}

sub _field_tmpl_hash {
    my $app        = shift;
    my %param      = @_;
    my $field_type = lc $param{type};
    my $query      = $param{query}; #presence of query means use utf8
    if ( $query && defined( my $qvalue = $app->utf8_param( $param{id} ) ) ) {
        if ( $field_type eq 'checkbox' ) {    #format for boolean
            $param{value} = { map { $_ => 1 } $app->utf8_param( $param{id} ) };
        }
        else {
            $param{value} = $app->escape($qvalue);
        }
    }

    #process field type and template
    my ( $field_template, $field_options );
    my $style = '';                           #for now, it's just for textarea
    if (   $field_type eq 'text'
        || $field_type eq 'hidden'
        || $field_type eq 'password'
        || $field_type eq 'submit'
        || $field_type eq 'button' )
    {
        $param{value} =
          _stringify_array_ref( $param{value}, $param{multiple} );
        $field_template = 'wi_prompt_input.tmpl';
        $param{value} = '' if $field_type eq 'password';
        $param{css_class} = 'bm_Submit'
          if ( $field_type eq 'submit' || $field_type eq 'button' )
          && !$param{css_class};

    }
    elsif ( $field_type eq 'textarea' ) {
        $field_template = 'wi_prompt_textarea.tmpl';
        if ( $param{width} ) {
            $param{width} .= 'px'
              if substr( $param{width}, -1, 1 ) !~ /\D|\%/;
            $style = 'width:' . $param{width} . ';';
        }
        if ( $param{height} ) {
            $param{height} .= 'px'
              if substr( $param{height}, -1, 1 ) !~ /\D|\%/;
            $style .= 'height:' . $param{height};
        }
    }
    elsif ( $field_type eq 'select' ) {
        $field_template = 'wi_prompt_select.tmpl';
        $field_options  = _select_options(%param);
    }
    elsif ( $field_type eq 'checkbox' ) {
        $field_template       = 'wi_prompt_checkbox.tmpl';
        $param{label_as_head} = 1;
        $field_options        = _checkbox_options( $app, %param );
    }

    my $field_html = $app->html_template(
        $field_template,
        INPUT_TYPE  => $field_type,
        FIELD_ID    => $param{id},
        FIELD_VALUE => $param{value},
        FIELD_CLASS => $param{css_class},

        #applies only to select and checkbox fields...
        FIELD_OPTIONS  => $field_options,
        FIELD_MULTIPLE => $param{multiple},

        #applies only to textarea
        FIELD_STYLE => $style,
    );

    if (   $field_type eq 'hidden'
        || $field_type eq 'submit'
        || $field_type eq 'button' )
    {
        $param{hide_label}  = 1;
        $param{hide_status} = 1;
    }
    else {
        _register_javascript_info( $app, %param );
    }
    return _field_wrapper_hash_ref( $app, $param{id}, $field_html, \%param );
}

sub _body_position {
    my ( $app, $id, $rparam ) = @_;
    my %param = %$rparam;
    $param{value} ||= 'block:1';
    my ( $position, $paragraph ) = split( /:/, $param{value} );
    $paragraph ||= 1;

    my $field_html = $app->html_template(
        'wi_prompt_body_position.tmpl',
        FIELD_ID      => $id,
        FIELD_OPTIONS => _select_options(
            value   => $position,
            options => $param{options},
            labels  => $param{labels},
        ),
        PARAGRAPH => $paragraph,
    );
    _register_javascript_info( $app, %param, id => $id );
    return _field_wrapper_hash_ref( $app, $id, $field_html, \%param );
}

sub _css_font_size {
    my ( $app, $id, $rparam ) = @_;
    my %param = %$rparam;
    my $value = lc( $param{value} || q{} );
    $value =~ s/\A\s+//g;
    $value =~ s/\s+\z//g;
    my $keyword =
      (      $value eq "small"
          || $value eq "x-small"
          || $value eq "medium"
          || $value eq "large"
          || $value eq "x-large"
          || $value eq "xx-large"
          || $value eq "xx-small" ) ? $value : q{};
    my $size = $keyword ? q{} : $value;
    my ( $num, $unit );

    if ( $size =~ /^\s*(\-?[\d.]*)\s*(\D*)\s*$/ ) {
        ( $num, $unit ) = ( $1, $2 );
    }
    $num  = q{}  if !defined $value;
    $unit = 'px' if !$unit;
    my $field_html = $app->html_template(
        'wi_prompt_css_font_size.tmpl',
        FIELD_ID    => $id,
        KEYWORD_ID  => "${id}__KEYWORD",
        LENGTH_ID   => "${id}__SIZE",
        $keyword    => ' selected="selected"',
        keyword     => $keyword,
        NUM_VALUE   => $num,
        UNITS_VALUE => $unit,
        LENGTH_CSS  => $param{css_class},
    );
    $app->js_add_behavior( "\#${id}___RSIZE", 'click',
        "Element.show('${id}___SIZETOG');Element.hide('${id}___KEYWORDTOG');"
    );
    $app->js_add_behavior( "\#${id}___RKEY", 'click',
        "Element.hide('${id}___SIZETOG');Element.show('${id}___KEYWORDTOG');"
    );
    _register_javascript_info( $app, %param, id => $id );
    return _field_wrapper_hash_ref( $app, $id, $field_html, \%param );
}

sub _css_length {
    my ( $app, $id, $rparam ) = @_;
    my $field_html = _css_length_field( $app, $id, $rparam );
    return _field_wrapper_hash_ref( $app, $id, $field_html, $rparam );
}

sub _css_length_field {    #guts for css_length and _css_box_dimensions
    my ( $app, $id, $rparam ) = @_;
    my %param = ref $rparam ? %{ $rparam } : {};
    $param{value} = q{} if !defined $param{value};
    $param{value} =~ s/\A\s+//g;
    $param{value} =~ s/\s+\z//g;
    my ( $value, $unit );
    if ( $param{value} =~ /^\s*(\-?[\d.]*)\s*(\D*)\s*$/ ) {
        ( $value, $unit ) = ( $1, $2 );
    }
    $value = q{}  if !defined $value;
    $unit  = 'px' if !$unit;
    my $field_html = $app->html_template(
        'wi_prompt_css_length.tmpl',
        LENGTH_ID   => $id,
        NUM_VALUE   => $value,
        UNITS_VALUE => $unit,
        LENGTH_CSS  => $param{css_class},
    );
    _register_javascript_info( $app, %param, id => $id );
    return $field_html;
}

sub _css_box_dimensions {
    my ( $app, $id, $rparam ) = @_;
    my %param    = ref $rparam ? %{ $rparam } : ();
    my %dirfield;
    foreach my $dir ( qw(top right bottom left) ) {
        my $rdir_param = $param{$dir} || {};
        $dirfield{$dir} = _css_length_field( $app, "$id-$dir", $rdir_param );
    }
    my $field_html =
      $app->html_template( 'wi_prompt_css_box_dimensions.tmpl', %dirfield );
    $param{label_as_head} = 1;
    return _field_wrapper_hash_ref( $app, $id, $field_html, \%param );
}

sub _document {
    require BigMed::DiskUtil;
    my ( $app, $id, $rparam ) = @_;
    my ( $filename, $file_url, $doc_class );
    if ( $filename = $rparam->{value} ) {    #pre-existing file
        my $url_dir = $rparam->{url_directory}
          || ($app->can('current_site')
            ? $app->current_site->doc_url
            : croak 'document prompt requires url_directory parameter' );
        $file_url  = "$url_dir/$filename";
        $doc_class =
          ( $filename =~ /[.]([a-zA-Z0-9]+)$/ ) ? 'docIconSm_' . lc $1 : '';
    }

    my $field_html = $app->html_template(
        'wi_prompt_document.tmpl',
        FIELD_ID  => $id,
        FILE_URL  => $file_url,
        DOC_CLASS => $doc_class,
        FILENAME  => $filename,
    );
    $app->js_add_script( $app->env('BMADMINURL') . '/js/bm-editor.js' );
    _register_javascript_info( $app, %{ $rparam }, id => $id );
    return _field_wrapper_hash_ref( $app, $id, $field_html, $rparam );
}

sub _kilobytes {
    my ( $app, $id, $rparam ) = @_;
    my $value = $rparam->{value} || 0;
    
    #presence of query means to use existing value, but pull from utf8_param
    if ( $rparam->{query} && defined $app->utf8_param($id) ) {
        $value = $app->utf8_param($id);
        my $units = $app->utf8_param("${id}___UNITS");
        $value *= 1024 if $units && $units eq 'MB';
    }
    my $unit;
    if ( $value / 1024 >= 1 || $value / 1024 <= 1 ) {
        $unit  = 'MB';
        $value =
          $value % 1024 ? sprintf( "%.1f", $value / 1024 ) : $value / 1024;
    }
    else {
        $unit = 'KB';
    }

    my $unit_options = _select_options(
        options => ['KB', 'MB'],
        labels  => {
            'KB' => $app->language('PROMPT_Kilobytes'),
            'MB' => $app->language('PROMPT_Megabytes'),
        },
        value => $unit,
    );
    my $units_field = $app->html_template(
        'wi_prompt_select.tmpl',
        FIELD_ID      => "${id}___UNITS",
        FIELD_CLASS   => 'inline',
        FIELD_OPTIONS => $unit_options,
    );
    my $value_field = $app->html_template(
        'wi_prompt_input.tmpl',
        FIELD_ID    => $id,
        FIELD_VALUE => $value,
        INPUT_TYPE  => 'text',
        FIELD_CLASS => 'inline',
    );
    _register_javascript_info( $app, %$rparam, id => $id );
    return _field_wrapper_hash_ref( $app, $id, $value_field . $units_field,
        $rparam );
}

sub _icon {
    my ( $app, $id, $rparam ) = @_;
    my $site = $rparam->{site} || $app->current_site;
    my $asset_dir = bm_file_path( $site->html_dir, 'bm.assets' );
    my $prefix = ( $rparam->{icon_type} || '' ) . 'icon';
    my $ASSETS;
    my @files;
    opendir( $ASSETS, $asset_dir )
      or $app->set_io_error( $ASSETS, 'opendir', $asset_dir, $! )
      or $app->error_stop;
    while ( defined( my $icon = readdir($ASSETS) ) ) {
        push @files, $icon if index( $icon, $prefix ) == 0;
    }
    closedir($ASSETS);

    my @icons;
    my $asset_url = $site->html_url . '/bm.assets';
    my $value     = $rparam->{value};
    my $selected;
    foreach my $file ( sort @files ) {
        my $id = "${id}___" . scalar @icons;
        $selected = $id if $file eq $value;
        push @icons,
          { src       => "$asset_url/$file",
            icon_id   => $id,
            icon_name => $app->escape($file),
          };
    }
    my $field_html = $app->html_template(
        'wi_prompt_icon.tmpl',
        ICONS    => \@icons,
        FIELD_ID => $id,
        VALUE    => $value,
    );
    _register_javascript_info( $app, %$rparam, id => $id );
    $rparam->{label_as_head} = 1;
    $app->js_add_onload("BM.FormWidget.updateIconButtons(\$('$selected'));")
      if $selected;
    return _field_wrapper_hash_ref( $app, $id, $field_html, $rparam );
}

sub _image_file {
    my ( $app, $id, $rparam ) = @_;
    my $site =
      (      $rparam->{site}
          || ( $app->can('current_site') ? $app->current_site : undef ) )
      or croak 'image_file prompt requires a current_site object';
    require BigMed::Media::Image;

    my $value = $rparam->{value} || {};
    my @loaded;
    my @formats;
    my $img_dir = $site->image_url;
    
    my $preview_img;
    foreach my $rformat ( ['original','orig'], BigMed::Media::Image->image_formats($site) ) {
        my ($sname, $size) = @{ $rformat };
        my $img = $value->{$size};
        my $is_file = !$img || ( index($img, 'url:') != 0 );
        my $url_field;
        if ($is_file) {
            $url_field = 'http://';
            $img = "$img_dir/$img" if $img;
        }
        else {
            $img =~ s/^url://;
            $url_field = $img ||'http://';
        }
        $preview_img = $img
          if $img
          && !$preview_img
          && ( $sname eq 'thumb'
            || $sname eq 'xsmall'
            || $sname eq 'small'
            || $sname eq 'medium' );

        my ($label, $pre_label);
        if ( $sname !~ /^\d/ ) {
            $label = $app->language("PREFS_IMAGE_$size");
            $pre_label = $app->language("IMAGE_$sname");
        }
        else {
            $label = $pre_label = $sname;
        }

        push @loaded, {
            url => $img,
            label => $pre_label,
        } if $img;
        push @formats, {
            FORMAT_ID => $id . $size,
            IS_FILE => $is_file,
            FDISPLAY => ( $is_file ? 'inline' : 'none' ),
            UDISPLAY => ( $is_file ? 'none' : 'inline' ),
            URL => $url_field,
            LABEL => $label,
        };
    }
    
    $app->js_add_script( $app->env('BMADMINURL') . '/js/bm-editor.js' );
    _register_javascript_info( $app, %{$rparam}, id => $id );
    my $field_html = $app->html_template(
        'wi_prompt_image_file.tmpl',
        FIELD_ID => $id,
        CAN_THUMBNAIL => BigMed::Media::Image->can_thumbnail,
        LOADED => \@loaded,
        FORMATS => \@formats,
        PREVIEW_IMG => $preview_img,  
    );
    return _field_wrapper_hash_ref( $app, $id, $field_html, $rparam );
}

sub _module_objects {
    my ( $app, $id, $roptions ) = @_;
    my @objects = ref $roptions->{objects} ? @{ $roptions->{objects} } : ();
    my $relation = $roptions->{relation_name}
      or croak "Module objects prompt requires relation_name parameter";

    #fetch data labels for this module's object types
    my $rel_class = $roptions->{relation_class} or return {};
    my @data_classes = ref $rel_class eq 'ARRAY' ? @$rel_class : ($rel_class);
    my @data_labels = map { $_->data_label } @data_classes;
    my @add_links   = map {
        {   LINK_TEXT =>
              $app->language( ['BM_Add_object', $app->language($_)] ),
            RELATION_TYPE => $relation . '-' . $_,
        }
    } @data_labels;

    foreach my $c (@data_classes) {
        my $pre_preview = $c->pre_preview;
        $pre_preview->( $app, \@objects ) if $pre_preview;
    }

    #get json objects and add to javascript header
    require BigMed::JSON;
    import BigMed::JSON;
    my %json = json_object_info(
        app           => $app,
        classes       => \@data_classes,
        objects       => \@objects,
        relation_info => $roptions->{relation_info},
    );
    $app->js_add_script( $app->env('BMADMINURL') . '/js/bm-editor.js' );
    foreach (qw(schema relation iframe depend)) {
        $app->js_init_object("BM.Mini.$_");
    }
    $app->js_add_code("Object.extend(BM.Mini.schema, $json{schema})");
    $app->js_add_code( "BM.Mini.relation['"
          . $app->js_escape($relation)
          . "']=$json{relation};" );
    foreach my $label ( @{ $json{labels} } ) {
        $app->js_init_object("BM.Mini.obj['$label']");
        $app->js_add_code( "Object.extend(BM.Mini.obj['$label'],"
              . $json{objects}->{$label}
              . ');' )
          if $json{objects}->{$label};
        $app->js_add_code(
            "BM.Mini.depend['$label']=" . $json{dependency}->{$label} . ';' );
        $app->js_add_code("BM.Mini.iframe['$label']=1")
          if $json{iframe}->{$label};
    }
    _get_rich_text_filters($app) if $json{rich_text};    #init richtext js

    #build the preview html for each object
    my @preview;
    my $sortable = $roptions->{sortable};
    $app->js_make_lists_sortable("BM_MINI_$relation") if $sortable;

    my $rpreview      = $roptions->{relation_info}->{preview};
    my $meta_callback = $rpreview->{html} if $rpreview;
    my $points        = $roptions->{relation_info}->{points};
    my $del_text      = $points ? 'BM_Remove' : 'BM_Delete';
    $del_text = $app->language($del_text);
    foreach my $item (@objects) {
        my ( $pointer, $obj, $relation_id );
        if ( ref $item eq 'ARRAY' ) {    # pointer/object pair
            my $pointer;
            ( $pointer, $obj ) = @$item;
            $relation_id =
              $relation . '-' . $obj->data_label . '-' . $pointer->id;
        }
        else {                           #plain object for "has" relationship
            $obj         = $item;
            $relation_id =
              $relation . '-' . $obj->data_label . '-' . $obj->id;
        }
        my %preview = $obj->preview();
        if ( $preview{html} ) {
            my %obj_preview = $preview{html}->( $app, $obj );
            my %meta_preview = $meta_callback->( $app, $item, \%obj_preview )
              if $meta_callback;
            push(
                @preview,
                {   %obj_preview,
                    %meta_preview,
                    RELATION_ID => $relation_id,
                    SORTABLE    => $sortable,
                    DELETE      => $del_text,
                }
            );
        }
    }
    my $limit = $roptions->{relation_info}->{limit_num};
    my $html  = $app->html_template(
        'wi_minicontent_module.tmpl',
        RELATION       => $relation,
        ADD_LINKS      => \@add_links,
        AT_LIMIT       => $limit && @objects >= $limit,
        MODULE_OBJECTS => \@preview,
        SORTABLE       => $sortable,
    );
    { FIELD_HTML => $html };
}

sub _navigation_style {
    my ( $app, $id, $rparam ) = @_;
    my $site   = $rparam->{site} || $app->current_site;
    my $prefix = $rparam->{prefix};
    if (   $prefix ne 'vnav'
        && $prefix ne 'hnav'
        && $prefix ne 'vsub'
        && $prefix ne 'hsub' )
    {
        croak "Prefix argument required: vnav, hnav, vsub or hsub";
    }
    my $custom = bm_file_path( $app->env('MOXIEDATA'),
        'templates_custom', 'site_templates', '_navcss' );
    my $default = bm_file_path( $app->env('MOXIEDATA'),
        'templates', 'site_templates', '_navcss' );
    my %style;
    foreach my $dir ( $custom, $default ) {
        my $NAV;
        next if !-e $dir;
        opendir( $NAV, $dir )
          or $app->set_io_error( $NAV, 'opendir', $dir, $! )
          or $app->error_stop;
        while ( defined( my $file = readdir($NAV) ) ) {
            if ( $file =~ s/^${prefix}_([a-zA-Z0-9\-_]+)[.]css$/$1/ ) {
                $style{$1} = 1;
            }
        }
        closedir($NAV);
    }
    $rparam->{options} = ['none', ( sort { lc $a cmp lc $b } keys %style )];
    my $rlabels = $rparam->{labels} || {};
    $rparam->{labels} = {
        %$rlabels,
        dropdown => $app->language('PROMPT_NAVIGATION_Dropdown Menu'),
        toggle   => $app->language('PROMPT_NAVIGATION_Toggle Menu'),
        'none'   => $app->language('PROMPT_NAVIGATION_No Style'),
    };
    _default_prompt( $app, $id, $rparam, 'select' );
}

sub _priority_slider {
    my ( $app, $id, $roptions ) = @_;
    my $value = defined $roptions->{value} ? $roptions->{value} : 500;
    my ( $knob, $track, $status ) = map { "${id}__$_" } qw(knob track disp);
    my $field_html = $app->html_template(
        'wi_prompt_slider.tmpl',
        FIELD_ID    => $id,
        KNOB_ID     => $knob,
        TRACK_ID    => $track,
        STATUS_ID   => $status,
        FIELD_VALUE => $value,
    );
    $app->js_add_onload("BM.Slider.init('$id');");
    _register_javascript_info( $app, %$roptions );
    return _field_wrapper_hash_ref( $app, $id, $field_html, $roptions );
}

sub _radio_toggle {
    my ( $app, $id, $roptions ) = @_;
    my %param = $roptions ? %$roptions : ();
    ($param{id} = $id)
      or croak 'radio toggle prompt must have field name/ID for the group';
    my @tabs =
      $param{value} && ref $param{value} eq 'ARRAY' ? @{ $param{value} } : ();
    my $query = $param{query};
    my $checked;

    #presence of query means to use existing value, but pull from utf8_param
    if ( $query && defined( my $qvalue = $app->utf8_param($id) ) ) {
        $checked = $qvalue;
    }
    my %addl_opts;
    $addl_opts{field_msg} = $param{field_msg} if $param{field_msg};
    $addl_opts{query}     = $query            if $query;

    my @fields;
    my @radios;
    my $have_checked;
    foreach my $tab (@tabs) {
        my $tab_id = $tab->{id} or croak 'all radio_toggle tabs require id';
        if ($checked) {
            $tab->{checked} = ( $checked eq $tab->{value} ) ? 'checked' : '';
        }
        elsif ( $tab->{checked} ) {
            $tab->{checked} = 'checked';
        }
        my $label =
            $tab->{label}
          ? $app->language( $tab->{label} )
          : ( $tab->{value} || $tab_id );
        push @radios,
          { FIELD_ID      => $tab_id,
            FIELD_NAME    => $id,
            FIELD_LABEL   => $label,
            FIELD_VALUE   => $tab->{value} || '',
            FIELD_CHECKED => $tab->{checked},
          };

        #do the individual fields
        my $display_state;
        if ( $tab->{checked} ) {
            $have_checked = 1 if $tab->{checked};
            $display_state = 'block';
        }
        else {
            $display_state = 'none';
        }
        my @field_ref =
          ref $tab->{field} eq 'ARRAY'
          ? @{ $tab->{field} }
          : croak 'all radio_toggle tabs require key/value pair w/field ref';
        my $rtab_fields;
        if ( ref $field_ref[0] eq 'CODE' ) {    #custom field routine
            my $rfield_opts = $field_ref[2] || {};
            $field_ref[2] = { %{$rfield_opts}, %addl_opts };
            my $field_html =
              $field_ref[0]->( $app, $field_ref[1], $rfield_opts );
            next if !$field_html;

            my $rfield_hash =
              _field_wrapper_hash_ref( $app, $field_ref[1], $field_html,
                $rfield_opts );
            $rtab_fields = [$rfield_hash];
        }
        elsif ( ref $field_ref[0] eq 'ARRAY' ) {    #multiple field prompts
            my @tab_fields;
            foreach my $ref (@field_ref) {
                my $rfield_opts = $ref->[2] || {};
                $ref->[2] = { %{$rfield_opts}, %addl_opts };
                push @tab_fields, $app->prompt( @{$ref} );
            }
            $rtab_fields = \@tab_fields;
        }
        else {                                      #regular field prompt
            my $rfield_opts = $field_ref[2] || {};
            $field_ref[2] = { %{$rfield_opts}, %addl_opts };
            $rtab_fields = [$app->prompt(@field_ref)];
        }

        push @fields,
          { DIV_ID        => $tab_id . 'TogField',
            FIELDS        => $rtab_fields,
            DISPLAY_STATE => $display_state,
          };

    }
    $radios[0]->{FIELD_CHECKED} = 'checked' if !$have_checked && @radios;
    my $field_html = $app->html_template(
        'wi_prompt_radio_toggle.tmpl',
        TABS        => \@radios,
        FIELD_DIVS  => \@fields,
        GROUP_ID    => $id,
        GROUP_CLASS => $param{css_class},
    );
    _register_javascript_info( $app, %param );
    $app->js_init_radio_tabs();
    $param{hide_label} = 1 if scalar @tabs == 0;
    $param{label_as_head} = 1;
    return _field_wrapper_hash_ref( $app, $param{id}, $field_html, \%param );
}

sub _rich_text {
    my ( $app, $id, $roptions ) = @_;
    $id or croak 'No ID provided for rich_text prompt';
    $roptions ||= {};
    my %editor_options = %$roptions;
    if ( $editor_options{force_right} ) {
        $editor_options{hide_label}  = undef;
        $editor_options{label}       = '&nbsp;<br />&nbsp;';
        $editor_options{description} = undef;
    }
    else {
        $editor_options{hide_label} = 1;
    }
    $editor_options{width}  ||= RICH_TEXT_WIDTH;
    $editor_options{height} ||= RICH_TEXT_HEIGHT;
    $editor_options{value}  ||= '';

    my ( $text, $current_filter ) =
      BigMed::Filter->preprompt_text_and_filter( $app,
        $editor_options{value} );

    #build the fields
    my @fields;
    foreach my $filter ( _get_rich_text_filters($app) ) {
        my $handler =
          BigMed::Filter->prompt_coderef( $filter,
            'BigMed::App::Web::Prompt' )
          || 'raw_text';
        my $checked = ( $filter eq $current_filter );
        my %field_options = %editor_options;
        $field_options{value} = $checked ? $text : '';
        delete @field_options{ qw(required validate_as)
          };    #handled at tabset level
        push @fields,
          { id      => $filter . 'Tab-' . $id,
            label   => "$filter",
            value   => $app->escape($filter),
            field   => [$handler, "$filter$id", \%field_options],
            checked => $checked,
          };
    }
    my %tabset_options = %$roptions;
    $tabset_options{value} = \@fields;
    $app->prompt( 'radio_toggle', $id, \%tabset_options );
}

sub _rich_text_brief {
    my ( $app, $id, $roptions ) = @_;
    my %editor_options = %$roptions;
    $editor_options{width}   ||= RICH_TEXT_BRIEF_WIDTH;
    $editor_options{height}  ||= RICH_TEXT_BRIEF_HEIGHT;
    $editor_options{value}   ||= '';
    $editor_options{toolbar} ||= 'bm-brief';
    $editor_options{force_right} = 1;
    _rich_text( $app, $id, \%editor_options );
}

sub _get_rich_text_filters {
    my $app = shift;
    return @{$app->param('PROMPT_RT_FILTERS')}
      if $app->param('PROMPT_RT_FILTERS');
    my @rich_text_filters =
      grep { BigMed::Filter->browser_supports( $app, $_ ) }
      ( BigMed::Filter->all_filters );
    require JSON;
    $app->js_add_code( 'BM.RichText.filters = '
          . JSON::objToJson( \@rich_text_filters )
          . ";\nBM.RichText.height = '"
          . RICH_TEXT_BRIEF_HEIGHT
          . "';\nBM.RichText.width = '"
          . RICH_TEXT_BRIEF_WIDTH
          . "';" );
    $app->param('PROMPT_RT_FILTERS',\@rich_text_filters);
    return @rich_text_filters;
}

sub _rich_text_inline {
    my ( $app, $id, $roptions ) = @_;
    my $value = $roptions->{value};
    if ($value) {
        require BigMed::Filter::HTML2Markdown;

        package BigMed::Filter::HTML2Markdown;
        $value             = _BackslashItalicsAndBold($value);
        $value             = _UndoItalicsAndBold($value);
        $roptions->{value} = $value;
    }
    _default_prompt( $app, $id, $roptions, 'text' );
}

sub _rich_text_public {
    my ( $app, $id, $roptions ) = @_;
    my %options = %{ $roptions };

    my $filter;
    ( $options{value}, $filter ) =
      BigMed::Filter->preprompt_text_and_filter( $app, $options{value} );
    my $handler =  BigMed::Filter->prompt_coderef( $filter,
            'BigMed::App::Web::Prompt' );
    
    $options{force_right} = 1;
    $options{width} = RICH_TEXT_BRIEF_WIDTH;
    $options{height} = '300';
    if ($handler) {
        my $field_html = $handler->( $app, $id, \%options );
        return _field_wrapper_hash_ref( $app, $id, $field_html, \%options );
    }
    return $app->prompt('raw_text', $id, \%options);
}

sub _select_section {
    my ( $app, $id, $roptions ) = @_;

    #collect the site or bail out
    my %options = %$roptions;
    my $site    = $options{site};
    if ( !$site && $app->can('current_site') ) {
        $site = $app->current_site;
    }
    elsif ( !$site ) {
        require BigMed::Site;
        my $site_id = $app->path_site();
        defined( $site = BigMed::Site->fetch($site_id) )
          or $app->error_stop();
    }
    if ( !$site ) {
        $app->set_error(
            head => 'BM_HEAD_Unknown site',
            text => 'BM_TEXT_Unknown site'
        );
        $app->error_stop;
    }

    #gather the sections where the user has permission, if applicable;
    #if no user, give all sections
    my @sections;
    my $user = $options{user}
      || ( $app->can('current_user') ? $app->current_user : undef );
    if ($user) {
        my %allowed = $user->allowed_section_hash($site);
        @sections = grep { $allowed{$_} } $site->all_descendants_ids();
    }
    else {
        @sections = $site->all_descendants_ids();
    }

    #build the labels
    my %labels;
    foreach my $section_id (@sections) {
        my $section_obj = $site->section_obj_by_id($section_id)
          or $app->error_stop;
        my $preset =
          join( '', ('&rarr; ') x ( scalar( $section_obj->parents ) - 1 ) );
        $labels{$section_id} = $preset . $section_obj->name;  #already escaped
    }
    unshift @sections, q{}
      if ( !$options{required} && !$options{multiple} )
      || $options{'all_sections_option'};
    $labels{q{}} = $app->language('PROMPT_SELECT_All sections')
      if $options{'all_sections_option'};
    if ($options{homepage}) {
        my $home = $site->homepage_obj or $app->error_stop;
        unshift @sections, $home->id;
        $labels{ $home->id } = $app->language('BM_Homepage');
    }
    $options{options} = \@sections;
    $options{labels}  = \%labels;
    return $app->prompt( 'value_list', $id, \%options );
}

sub _select_options {
    my %param = @_;
    my @select;
    if ( $param{optgroups} ) {
        foreach my $rgroup ( @{ $param{optgroups} } ) {
            push @select,
              { group   => $rgroup->{name},
                options =>
                  _select_options( %{$rgroup}, value => $param{value} ),
              };
        }
        return \@select;
    }

    my %labels  = ref $param{labels}  eq 'HASH'  ? %{ $param{labels} }  : ();
    my @options = ref $param{options} eq 'ARRAY' ? @{ $param{options} } : ();
    my @value   =
        !defined $param{value} ? ()
      : ref $param{value} eq 'ARRAY' ? @{ $param{value} }
      : ( $param{value} );
    my %selected;
    @selected{@value} = ();
    foreach my $opt (@options) {
        push @select,
          { option   => $opt,
            label    => $labels{$opt} || $opt,
            selected => exists $selected{$opt},
          };
    }
    @select = ( { option => '' } ) if @select == 0;
    \@select;
}

sub _sort_order {
    my ( $app, $id, $roptions ) = @_;
    my $value = $roptions->{value} || '';
    my ( $col_string, $order_string ) = split( /\|/, $value );
    my @order = split( /:/, $order_string );
    my @columns =
      map {
        ( ( shift @order eq 'a' ) && $_ eq 'pub_time' ) ? 'chron_time' : $_;
      }
      split( /:/, $col_string );
    $roptions->{value} = \@columns;
    _value_several( $app, $id, $roptions );
}

sub _tags {
    my ( $app, $id, $roptions ) = @_;

    #custom prompt for tag relationship; receives same params as
    #module_object
    my %option = %{$roptions};
    my @values = map { $_->[1]->name } @{ $option{objects} };
    my $value = join( ', ', @values );

    my $field_html = $app->html_template(
        'wi_prompt_tags.tmpl',
        INPUT_TYPE  => 'text',
        FIELD_ID    => $id,
        FIELD_VALUE => $value,
    );

    #add the autocomplete mechanism
    require BigMed::JSON;
    import BigMed::JSON;
    #unescape tags; autocomplete won't pick up entities, and json
    #will make it all safe.
    my @tags =
      map { $app->unescape($_) } BigMed::Tag->all_tags( $option{site} );
    my $tags = objToJson( \@tags );

    $app->js_add_onload( "new Autocompleter.Local('$id', '${id}___tags', $tags,"
          . "{'tokens':[',',';']});" );


    my $raw_label = BigMed::Tag->data_label;
    my $label  = $app->language( "RELATION_$raw_label" );
    my $desc  = $app->language( $raw_label . '_DESC' );
    my %param = (label => $label, description => $desc);
    _register_javascript_info( $app, %param );
    return _field_wrapper_hash_ref( $app, $id, $field_html, \%param, );
}

sub _tritoggle {
    my ( $app, $id, $roptions ) = @_;
    my %param = %{$roptions};
    if ( ref $param{buttons} ne 'ARRAY' ) {
        $param{buttons} = [
            id          => $param{id},
            value       => $param{value},
            states      => $param{states},
            button_html => $param{button_html},
            css_class   => $param{css_class},
        ];
    }
    else {
        $param{label_as_head} = 1;
    }

    my @buttons;
    foreach my $rbutton ( @{ $param{buttons} } ) {
        my $rstates = $rbutton->{states}
          or croak 'tritoggle buttons require states parameter';
        my %states = %{ $rbutton->{states} };
        croak 'tritoggle requires on and off values for states parameter'
          if ( !defined $states{on} || !defined $states{off} );
        $states{default} = q{} if !defined $states{default};

        my $value =
          defined $rbutton->{value} ? $rbutton->{value} : $states{default};
        my $toggle_state;
        foreach my $state ( keys %states ) {
            $toggle_state = "\u$state", last if $states{$state} eq $value;
        }
        if ( !defined $toggle_state ) {
            $value        = $states{default};
            $toggle_state = 'Default';
        }

        my $this_id = $rbutton->{id} or croak 'tritoggle buttons require id';
        push @buttons,
          { FIELD_ID     => $this_id,
            FIELD_VALUE  => $value,
            BUTTON_HTML  => $rbutton->{button_html},
            TOGGLE_STATE => $toggle_state,
            CSS_CLASS    => $rbutton->{css_class},
          };
        _register_javascript_info( $app, %{$rbutton} );
        $app->js_add_onload( qq|BM.Tritoggle.initButton("$this_id",{|
              . qq|"Default":"$states{default}","On":"$states{on}",|
              . qq|"Off":"$states{off}"});| );

    }
    my $field_html =
      $app->html_template( 'wi_prompt_tritoggle.tmpl', BUTTONS => \@buttons );

    return _field_wrapper_hash_ref( $app, $id, $field_html, \%param );
}

sub _value_buttons {
    my ( $app, $id, $roptions ) = @_;
    my %param = %$roptions;
    my @options = ref $param{options} eq 'ARRAY' ? @{ $param{options} } : ();
    my @buttons;
    foreach my $opt (@options) {
        my $value = defined $opt->{value} ? $opt->{value} : q{};
        push @buttons,
          { opt_id    => "${id}___$value",
            opt_value => $value,
            opt_label => $opt->{label} || $value,
            opt_class => $opt->{css_class} || "bmcp_list_button_$value",
          };
    }
    my $value = defined $param{value} ? $param{value} : q{};
    my $field_html = $app->html_template(
        'wi_prompt_value_buttons.tmpl',
        FIELD_ID => $id,
        BUTTONS  => \@buttons,
        VALUE    => $value,
    );
    $app->js_add_onload("BM.FormWidget.setImageButtons('$id','$value', '');");
    _register_javascript_info( $app, %param, id => $id );
    $param{label_as_head} = 1;
    return _field_wrapper_hash_ref( $app, $id, $field_html, \%param );
}

sub _value_freeform {
    my ( $app, $id, $roptions ) = @_;
    my $rvalue = $roptions->{value} || [];
    $roptions->{value} = join( "\n", @$rvalue );
    _default_prompt( $app, $id, $roptions, 'textarea' );
}

sub _value_several {
    my ( $app, $id, $roptions ) = @_;
    my %param     = %$roptions;
    my $numfields = $param{numfields} || 5;
    my $rvalue    = $param{value} || [];
    my @value     = @$rvalue;
    $numfields = @value if @value > $numfields;
    my @fields;
    my $query = $roptions->{query};

    foreach my $i ( 1 .. $numfields ) {
        my $qvalue;
        $qvalue = $app->utf8_param("${id}___$i") if $query;
        $value[$i - 1] = $qvalue if defined $qvalue;
        push @fields,
          { FIELD_ID      => "${id}___$i",
            FIELD_OPTIONS => _select_options(
                value     => $value[$i - 1],
                optgroups => $roptions->{optgroups},
                options   => $roptions->{options},
                labels    => $roptions->{labels},
            ),
            FIELD_CLASS => $param{css_class},
          };
    }
    my $field_html = $app->html_template(
        'wi_prompt_value_several.tmpl',
        GROUP_ID       => $id,
        NUMFIELDS      => $numfields,
        FIELDS         => \@fields,
        OPTIONAL_LABEL => $param{optional_label},
        OPTION_TRUE    => $param{option_true},
    );
    _register_javascript_info( $app, %param, id => $id );
    $param{label_as_head} = 1;
    return _field_wrapper_hash_ref( $app, $id, $field_html, \%param );
}

sub _system_time {
    my ( $app, $id, $roptions ) = @_;
    my %param    = %$roptions;
    my $time_obj = BigMed->time_obj(
        bigmed_time => $param{value},
        offset      => $param{offset},
    );
    my $dformat = $param{date_format} || '%b %e %Y';
    my $tformat = $param{time_format} || '%I %M %p';
    $tformat =~ s/%r/%I %M %p/g;    #ampm time
    $tformat =~ s/%[RT]/%H %M/g;    #24-hour times
    $dformat =~ s/%[Bmn]/%b/g;      #months to abbreviated names
    my @date = $dformat =~ /%([bBdehmnyY])/g;
    my @time = $tformat =~ /%([HIMp])/g;
    @time = qw(I M p) if !@time;
    @date = qw(b e Y) if !@time;

    my @mabbr = qw (Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec);
    my @mfull = (
        'January',   'February', 'March',    'April',
        'May',       'June',     'July',     'August',
        'September', 'October',  'November', 'December'
    );
    my $start_year = $param{start_year} || $time_obj->year - 20;
    my $end_year   = $param{end_year}   || ( localtime(time) )[5] + 1905;

    my @date_fields;
    foreach my $dfield (@date) {
        my %select = ( FIELD_CLASS => $param{css_class} || 'inline' );
        my $field_id;
        if ( $dfield eq 'b' || $dfield eq 'h' ) {
            $select{FIELD_ID}      = "${id}___month";
            $select{FIELD_OPTIONS} = _select_options(
                value   => $time_obj->month,
                options => [( 1 .. 12 )],
                labels  => {
                    map { $_ => $app->language( $mabbr[$_ - 1] ) } ( 1 .. 12 )
                },
            );
        }
        elsif ( $dfield eq 'e' || $dfield eq 'd' ) {
            $select{FIELD_ID}      = "${id}___day";
            $select{FIELD_OPTIONS} = _select_options(
                value   => $time_obj->day,
                options => [( 1 .. 31 )],
            );
        }
        elsif ( $dfield eq 'y' || $dfield eq 'Y' ) {
            $select{FIELD_ID}      = "${id}___year";
            $select{FIELD_OPTIONS} = _select_options(
                value   => $time_obj->year,
                options => [( $start_year .. $end_year )],
            );
        }
        else {
            next;    #whazzat?
        }
        push @date_fields, \%select;

    }

    my @time_fields;
    foreach my $tfield (@time) {
        my %select = ( FIELD_CLASS => $param{css_class} || 'inline' );
        my $field_id;
        if ( $tfield eq 'H' ) {
            $select{FIELD_ID}      = "${id}___hour";
            $select{FIELD_OPTIONS} = _select_options(
                value   => $time_obj->hour,
                options => [( 0 .. 23 )],
                labels => { map { $_ => sprintf( '%02d', $_ ) } ( 0 .. 23 ) },
            );
        }
        elsif ( $tfield eq 'I' ) {
            my $h = $time_obj->hour;
            $h =
                $h > 12 ? $h - 12
              : $h == 0 ? 12
              : $h;
            $select{FIELD_ID}      = "${id}___hour";
            $select{FIELD_OPTIONS} = _select_options(
                value   => $h,
                options => [( 1 .. 12 )],
            );
        }
        elsif ( $tfield eq 'M' ) {
            $select{FIELD_ID}      = "${id}___minute";
            $select{FIELD_OPTIONS} = _select_options(
                value => $time_obj->minute - ( $time_obj->minute % 5 ),
                options => [qw(00 05 10 15 20 25 30 35 40 45 50 55)],
            );
        }
        elsif ( $tfield eq 'p' ) {
            $select{FIELD_ID}      = "${id}___ampm";
            $select{FIELD_OPTIONS} = _select_options(
                value => ( $time_obj->hour >= 12 ? 'pm' : 'am' ),
                options => ['am', 'pm'],
            );
        }
        else {
            next;    #hmm....
        }
        push @time_fields, \%select;
    }

    $param{option_label} &&= $app->language( $param{option_label} );
    my $field_html = $app->html_template(
        'wi_prompt_system_time.tmpl',
        DATE_FIELDS    => \@date_fields,
        TIME_FIELDS    => \@time_fields,
        OPTIONAL_LABEL => $param{option_label},
        OPTION_TRUE    => $param{optional_time},
        GROUP_ID       => $id,
        OFFSET         => $param{offset} || '',
    );
    _register_javascript_info( $app, %param, id => $id );
    $param{label_as_head} = 1;
    return _field_wrapper_hash_ref( $app, $id, $field_html, \%param );

}

sub _time_offset {
    my ( $app, $id, $roptions ) = @_;
    my %param = $roptions ? %$roptions : ();
    $id or croak 'timezone offset prompt must have field name/ID';

    my ( $sign, $hour, $minute );
    my $value;
    my $query = $param{query};
    if (   $query
        && defined( $sign   = $app->utf8_param( $id . '-sign' ) )
        && defined( $hour   = $app->utf8_param( $id . '-hour' ) )
        && defined( $minute = $app->utf8_param( $id . '-minute' ) ) )
    {
        $value = "$sign$hour:$minute";
    }
    else {
        $value = $param{value} || '';
    }

    if ( $value =~ /^([\+\-])(\d\d?)?:(\d\d)(?::(\d\d))?$/ ) {
        ( $sign, $hour, $minute ) = ( $1, $2, $3 );
    }
    else {
        ( $sign, $hour, $minute ) = ( '+', '0', '00' );
    }
    my @fields = (
        {   FIELD_ID      => $id . '-sign',
            FIELD_OPTIONS => _select_options(
                value   => $sign,
                options => ['+', '-'],
            ),
            FIELD_CLASS => $param{css_class} || 'inline',
        },
        {   FIELD_ID      => $id . '-hour',
            FIELD_OPTIONS => _select_options(
                value   => $hour,
                options => [
                    '0',  '1',  '2',  '3',  '4',  '5',  '6',  '7',
                    '8',  '9',  '10', '11', '12', '13', '14', '15',
                    '16', '17', '18', '19', '20', '21', '22', '23'
                ],
            ),
            FIELD_CLASS => $param{css_class} || 'inline',
        },
        {   FIELD_ID      => $id . '-minute',
            FIELD_OPTIONS => _select_options(
                value   => $minute,
                options => [qw(00 15 30 45)],
            ),
            FIELD_CLASS => $param{css_class} || 'inline',
        },
    );
    my $field_html = $app->html_template(
        'wi_prompt_time_offset.tmpl',
        ORIG_VALUE => $value,
        FIELDS     => \@fields,
        GROUP_ID   => $id,
    );
    _register_javascript_info( $app, %param, id => $id );
    $app->js_show_gmt_help($id);
    $param{label_as_head} = 1;

    return _field_wrapper_hash_ref( $app, $id, $field_html, \%param );
}

sub _url {
    my ( $app, $id, $roptions ) = @_;
    $roptions->{value} &&= $app->escape( $roptions->{value} );
    _default_prompt( $app, $id, $roptions, 'text' );
}

sub _checkbox_options {
    my ( $app, %param ) = @_;
    my @options =
      ref $param{options} eq 'ARRAY' ? @{ $param{options} } : ('1');
    my %labels =
      ref $param{labels} eq 'HASH' ? %{ $param{labels} }
      : $param{option_label}
      ? ( '1' => $app->language( $param{option_label} ) )
      : ( '1' => $param{id} );
    my @checkboxes;
    my %checked =
        ref $param{value} eq 'HASH' ? %{ $param{value} }
      : defined $param{value} ? ( $param{value} => 1 )
      : ();
    foreach my $value (@options) {
        push @checkboxes,
          { 'check_value' => $value,
            'group_name'  => $param{id},
            'checked'     => $checked{$value},
            'check_label' => $labels{$value},
          };
    }
    \@checkboxes;
}

sub _harvest_field_message {
    my ( $app, $rfield_msg, $id, $label ) = @_;
    return $rfield_msg if !$rfield_msg || ref $rfield_msg ne 'HASH';
    my $msg;
    return undef unless $id && ( $msg = $rfield_msg->{$id} );
    $msg = $label ? "$label: " . $app->language($msg) : $app->language($msg);
    push @{ $rfield_msg->{_ERR_LIST} }, $msg;
    $app->js_focus_field($id)
      if $rfield_msg->{_FIRST_ERR} && $rfield_msg->{_FIRST_ERR} eq $id;
    $msg;
}

sub _register_javascript_info {
    my $app   = shift;
    my %param = @_;
    $app->js_add_validation(
        id       => $param{id},
        required => $param{required},
        type     => $param{validate_as},
        label    => $param{label},
    );
    $app->js_focus_field( $param{id} ) if $param{focus};
}

my $required_text;

sub _field_wrapper_hash_ref {
    my ( $app, $id, $field_html, $rparam ) = @_;
    if ( $rparam->{ajax_status} && !$id ) {
        croak 'field must have id to include ajax_status';
    }
    if ( $rparam->{required} && !$required_text ) {
        $required_text = $app->language('BM_Required field');
    }
    my $field_msg =
      _harvest_field_message( $app, $rparam->{field_msg}, $id,
        $rparam->{label} );
    {   FIELD_ID           => $id,
        FIELD_HTML         => $field_html,
        FIELD_STATUS       => $field_msg,
        FIELD_AJAX_STATUS  => $rparam->{ajax_status},
        FIELD_LABEL        => $rparam->{label},
        DESCRIPTION        => $rparam->{description},
        SHOW_LABEL         => !$rparam->{hide_label} && $rparam->{label},
        LABEL_AS_HEAD      => $rparam->{label_as_head},
        SHOW_STATUS        => !$rparam->{hide_status},
        FIELD_CONTAINER_ID => $rparam->{container_id},
        FIELD_HIDDEN       => $rparam->{hidden},
        REQUIRED           => $rparam->{required},
        REQUIRED_TEXT      => $required_text,
        CONTAINER_CLASS    => $rparam->{container_class},
    };
}

sub _stringify_array_ref {
    return $_[0] if ref $_[0] ne 'ARRAY';
    return $_[1] ? join( ", ", @{ $_[0] } ) : @{ $_[0] }[0];
}

1;

__END__

=head1 NAME

BigMed::App::Web::Prompt - Big Medium field/prompt generator

=head1 DESCRIPTION

In BigMed::App::Web, the C<prompt> method is used to generate hash references
representing fields to be given to C<make_fieldset_ref> to build form
fieldsets. BigMed::App::Web::Prompt provides the routines that do this.

This documentation describes the available prompt types and the options that
they accept via BigMed::App::Web's C<prompt> method (or C<make_field_ref>).

=head1 USAGE

=head2 Registering C<prompt> routines

BigMed::App::Web registers the BigMed::App::Web::Prompt's prompt
routines via the C<register_all> method:

    BigMed::App::Web::Prompt->register_all();

=head2 Accessing C<prompt> routines

Access to these routines is via the BigMed::App::Web C<prompt> method:

    $app->prompt('field type', 'field name', \%options);

=head2 Option parameters

The C<prompt> method allows you to include a reference to an options
hash. Many field types accept their own custom options, but all fields
accept these key/value pairs:

=over 4

=item * id => $field_name

The xhtml id to use for the field or, for prompts that return multiple
input/select/textarea elements, the base id for those IDs.

=item * value => $value

The (escaped, localized) value to use for the field. See the specific field
type below for details on the expected format for the value.

=item * query => $query_obj

A CGI.pm object to use to supply the field value. If supplied and there's
a defined value for the field in the query object, that value is used
in preference to the value parameter.

=item * label => $label

The (escaped, localized) user-friendly name for this field, which creates
a bold title for the prompt.

=item * description => $description

The (escaped, localized) description/caption for this field.

=item * field_msg => $status_message

The (escaped, localized) status/error message to display with the field.

=item * container_id => $container_id

The id to use, if any, for the <div> containing the field.

=item * ajax_status => 1

Boolean indicating whether the field should include a status div to receive
and display status messages from ajax calls.

=item * hide_label => 1

A boolean value indicating whether the label and description should be
suppressed in the form xhtml.

=item * hide_status => 1

A boolean value indicating whether the status div (used to display the
status/error message from the field_msg value) should be suppressed in the
form xhtml.

=item * hidden => 1

A boolean value indicating whether the <div> containing the field entry
should be hidden via display:none. (Default is undef, so that fields will
be displayed unless you specify that they should not be).

=item * required => 1

A boolean value whether client-side validation should require this field.

=item * validate_as => $field_type

The type of client-side validation to enforce, if any.

=back

=head2 Return value

The prompt methods return a reference to a hash of parameters
suitable for passing to BigMed::App::Web's C<prompt_fieldset_ref> method
for creating a form fieldset.

Specifically, the returned hash reference is composed of these key/value
pairs:

=over 4

=item * FIELD_ID => $id

The xhtml id to use for the field or, for prompts that return multiple
input/select/textarea elements, the base id for those IDs.

=item * FIELD_HTML => $html

The xhtml for the actual input/select/textarea elements. (Does not include
the <label>, fieldStatus and other fieldset wrapper info that gets added
by the "wi_web_fieldset.tmpl" template.)

=item * FIELD_STATUS => $message

A (escaped, localized) status/error message to include along with the
field.

=item * FIELD_AJAX_STATUS => 1

If true, indicates that a <div> should be included with the field to receive
and display status messages for ajax calls.

=item * FIELD_LABEL => $label

A (escaped, localized) user-friendly name for the field.

=item * DESCRIPTION => $description

A (escaped, localized) description/caption for the field.

=item * FIELD_CONTAINER_ID => $container_id

The id to use, if any, for the <div> containing the field.

=item * FIELD_HIDDEN => 1

A boolean value indicating whether the <div> containing the field entry
should be hidden via display:none.

=item * SHOW_LABEL => 1

A boolean value indicating whether the label and description should be
included in the form xhtml.

=item * LABEL_AS_HEAD => 1

A boolean value indicating whether the label should be wrapped in a <h4>
tag instead of a <label> tag. This is used by fields that generate more
than one html <input> field so that it's not possible to associate a
<label> tag with just one field.

=item * SHOW_STATUS => 1

A boolean value indicating whether the C<FIELD_STATUS> message should
be included in the form xhtml.

=back

=head1 The Prompt Types

These are the available prompt types. The prompt type should be included
as the first argument in a call to C<prompt> and corresponds to one of
the BigMed::Elements element types.

If an unknown prompt type is requested, a text input field will be returned.

=head2 C<body_position>

Creates a select field and a hidden text input field to prompt for a
body position, useful for related objects like images, pullquotes, etc that
can be embedded in body text. The text input field becomes visible when
the "block" value is chosen in the select field ("Align with paragraph:")
to allow the user to enter a paragraph number.

In addition to the standard parameters for prompt types, body_position also
accepts the options and labels parameters described in the C<value_list>
prompt type. These are used to generate the options and labels in the
select field. These options and labels may be left undefined, however,
and the standard 'above', 'below', 'hidden' and 'block' values will be used
instead.

=head2 C<boolean>

Creates a check box that returns a value of 1 if checked. Setting the value
parameter to 1 makes the check box checked on page load. This prompt type
does no client-side validation.

The boolean prompt type accepts these additional parameter:

=over 4

=item * option_label => 'text to display with checkbox'

The text to display next to the checkbox. This should be the unlocalized
lexicon key for the text to display.

=back

Also, the css_class parameter is not used by this prompt type.

=head2 C<css_box_dimensions>

Creates four css_length fields for the top, bottom, left, right
values for CSS box settings. These fields are named the same as
the id passed to the prompt routine, with "-top", "-bottom", "-left"
and "-right" appended.

The specific parameters for each field shoudl be supplied in the
parameter hash reference with keys for the specific direction:

    $app->prompt(
        'css_box_dimensions',
        'margin',
        { top    => { value=> '10px' },
          right  => { value=> '3px' },
          bottom => { value=> '0px' },
          left   => { value=> '3px' },
        }
    );

=head2 C<css_font_size>

Creates a radio button pair for keyword vs units and, depending on which
is selected, displays a select menu with keyword values, or a css_length
prompt: an input field for unit value and a select menu for units.

=head2 C<css_length>

Creates an input field for unit value and a select menu for units
(px, %, em, ex, pi, pt, in, cm, etc).

=head2 C<dir_path>

Creates a text input field. (By default, C<dir_path> prompts generated
with C<prompt_field_ref> parameters do client-side clean-up and validation
to make sure that the entry doesn't contain double slashes, trailing or
leading whitespace, or trailing slashes. Client-side validation also
checks if it's formatted as an absolute path.

=head2 C<dir_url>

Creates a text input field. (By default, C<email> prompts generated
with C<prompt_field_ref> parameters do client-side clean-up to eliminate
trailing/leading spaces, trailing slashes, and line breaks.

=head2 C<document>

Creates a document upload input field.

If a filename value is specified, that filename is displayed along with
a link to the file, with a "replace this file" link that
toggles display of the upload file. The file with this filename at the
target directory will be replaced by the parser routine if a file
is uploaded.

In addition to the usual parameters, the document prompt accepts one
optional custom parameter:

=over 4

=item * url_directory => $url

If specified, this url will be used in the link to the current file, if any.
If unspecified, the default will be the current site's bm.doc directory.

=back

=head2 C<email>

Creates a text input field. (By default, C<email> prompts generated
with C<prompt_field_ref> parameters do client-side validation to check that
the value is a valid e-mail address).

=head2 C<hidden>

Creates a hidden input field. Hidden fields always return false values for
C<SHOW_LABEL> and C<SHOW_STATUS>.

=head2 C<icon>

Displays an assortment of icons from the bm.assets directory; clicking the
icon highlights and selects it.

In addition to the usual parameters:

=over 4

=item * prefix => 'email'

This required parameter indicates which icons to display from the bm.assets
directory. Any files with names starting with the prefix followed by 'icon'
will be displayed.

=back

=head2 C<id>

Creates a hidden input field for object IDs. Hidden fields always return
false values for C<SHOW_LABEL> and C<SHOW_STATUS>.

=head2 C<image_file>

Creates a set of input fields to load a new BigMed::Media::Image image set.
For installations with thumbnailing enabled, the view defaults to a single
file input to upload the original image, with an option to load each
image size manually. For installations without thumbnailing, the only
option is to load each image size manually.

The prompt requires the site object for the site. This object can be
supplied in the C<site> option parameter or, for application objects
that support the C<current_site> method, the prompt will use that method
to collect the current site's object.

=head2 C<key_boolean>

Creates a list of checkboxes. Accepts a key_boolean element (a hash reference)
as the value and any of the true key/value pairs in the hash are displayed
as checked.

The prompt accepts the following option parameters:

=over 4

=item * options => \@keys

The names of the keys/flags to allow the user to check on or off, in the
order to display them.

=item * labels => \%labels

The labels to display next to the check boxes. The keys are the options
and the values are the unlocalized strings to display.

=back

=head2 C<kilobytes>

Creates an input field to enter a number, followed by a select menu with
values KB and MB to allow user to enter either kilobyte or megabyte
value. Client-side validation requires number zero or higher.

=back

=head2 C<module_objects>

Generates the html and JSON data for displaying related objects and their
associated edit fields for any BigMed::Content relationship.

The module_objects prompt accepts these custom parameters:

=over 4

=item * relation_name => $relation

The object relationship name, defined in the BigMed::Content class or
subclass.

=item * relation_class => $class

The class of the related object being displayed.

=item * relation_points => 1

A true value indicates that this is a "points_to" relationship, false
indicates a "has" relationship.

=item * relation_info => \%relation_info

A reference to the relationship_info hash for the relationship (same
one that you get when you call $content_class->relationship_info($name) ).

=item * objects => \@objects

The data objects, if any, to display for this relationship.

=item * sortable => 1

If true, the data objects should be displayed as sortable, allowing the user
to drag/drop into a new order.

=back

=head2 C<navigation_style>

Creates a value_list pulldown menu presenting all of the available css styles
for the indicated navigation prefix. The prefix parameter must be one of
four values:

    prefix => 'vnav|hnav|vsub|hsub'

=head2 C<password>

Creates a password input field.

=head2 C<priority_slider>

Creates a slider widget for the user to provide a value between 0 and 1000.
If the value parameter submitted to the method is undefined, a default
value of 500 is used. Th value parameter is the only parameter used by
this prompt.

=head2 C<radio_toggle>

Creates a set of radio buttons, rendered as tabs, which when clicked
hide/display the appropriate related field. Each tab is a container for
a single field prompt. It's useful for offering a set of options for a
setting and then to display the appropriate field for entering details about
that option.

    my $radio_set = $app->prompt(
        'radio_toggle',
        'radioGroupName',
        {   label => 'heading for the whole button group',
            value => [
                {   id      => 'radio1',
                    label   => 'text label first button',
                    value   => 'value for first radio button',
                    checked => 1,
                    field   => ['simple_text', 'field1', \%options1],
                },
                {   id    => 'radio2',
                    label => 'text label for second button',
                    value => 'value for second radio button',
                    field => ['value_list', 'field2', \%options2]
                },
            ],
        },
    );

As with all prompt calls, the first two arguments are the field type
and the field ID. In this case, the field ID is the "name" attribute
for all of the radio buttons, the common name that groups the buttons
together. The radio_toggle C<prompt> option will throw an exception
if no field ID is provided.

Most of the information about the tabs and their associated fields
is held in the C<value> parameter option. The C<value> parameter holds
a reference to an array of hash references, each representing one of the
radio tabs. Each hash reference contains the following parameters:

=over 4

=item * id => 'tabID',

The unique ID of the specific tab option. This is required; if not provided,
the method will throw an exception.

=item * label => 'text label for the button/tab'

This text will be displayed as the tab text. If no label is provided, the tab
value will be used instead and, failing that, the tab id.

=item * value => 'value for the button'

This is the value returned to the server for the radio group when this tab/button
is selected.

=item * field => $prompt_field_ref

The field info for the field associated with the tab. This should be either:

=over 4

=item 1. A reference to an array of arguments appropriate for passing to
the C<prompt> method (as usual, the easiest way to generate this with
BigMed::App's C<prompt_field_ref> method.

    field => [ 'value_list', 'fieldname', \%options ]

=item 2. A reference to an array of field references, if you want multiple
fields to be displayed below the tab:

    field => [
        [ 'simple_text', 'field1', \%options1],
        [ 'value_list', 'field2', \%options ],
        ...
    ]

=item * force_right => 1

If true, the tabs will be nudged to the right, aligning with
other fields with label text. If false, the tabs will start at the left
edge of the available space.

=back
 
=head2 C<raw_text>

Creates a textarea field. Accepts two custom options in addition to the default
set:

=over 4

=item * height => 100 or height => '100%'

Determines the height of the element to display. The value may be
a number or a percentage. If a number, that will be the height in pixels.
If unspecifed, the browser or CSS default will be used.

=item * width => 500 or width => '100%'

Determines the width of the element to display. The value may be
a number or a percentage. If a number, that will be the height in pixels.
If unspecifed, the browser or CSS default will be used.

=back

=head2 C<rich_text>

Creates a radio/tab set, with a tab/field for each registered rich_text
filter. If the RichText filter is registered, that filter is always
displayed first. If the RawHTML filter is registered, that filter
is always displayed last.

The rich_text prompt has these custom options available in addition to
the default set:

=over 4

=item * height => 100 or height => '100%'

Determines the height of the element to display. The value may be
a number or a percentage. If a number, that will be the height in pixels.
If unspecifed, the default value of 300 will be used.

=item * width => 500 or width => '100%'

Determines the width of the element to display. The value may be
a number or a percentage. If a number, that will be the height in pixels.
If unspecifed, the default value of '99%' will be used.

=item * toolbar => 'toolbar name'

Determines the toolbar elements to be displayed. 'bm-full' is the
default value and will display the full tool bar. 'bm-brief' will
display an abbreviated tool bar.

=item * force_right => 1

If true, the rich text field will be nudged to the right, aligning with
other fields with label text. If false, the rich text field will extend
to 100% of the width of the space.

=back

=head2 C<rich_text_brief>

Similar to rich_text but with defaults better suited to small fields
(while rich_text's defaults are better suited to big edit areas).

Creates a radio/tab set, with a tab/field for each registered rich_text
filter. If the RichText filter is registered, that filter is always
displayed first. If the RawHTML filter is registered, that filter
is always displayed last.

The rich_text_brief prompt has the same custom options as rich_text:

=over 4

=item * height => 100 or height => '100%'

Determines the height of the element to display. If unspecifed, the
default value of 100 will be used.

=item * width => 500 or width => '100%'

Determines the width of the element to display. If unspecifed, the
default value of '68%' will be used.

=item * toolbar => 'toolbar name'

Determines the toolbar elements to be displayed. 'bm-brief' is the
default value and will display an abbreviated tool bar. 'bm-full'
will display the full tool bar.

=item * force_right => 1

If true, the rich text field will be nudged to the right, aligning with
other fields with label text. If false, the rich text field will extend
to 100% of the width of the space.

=back

=head2 C<rich_text_inline>

Creates a text input field.

=head2 C<select_section>

Creates a value_list prompt using the sections specified for the current
site and user.

The select_section prompt accepts two custom options in addition to the
default set:

=over 4

=item * site => $site_object

The site object whose sections you want to use. If no site is specified,
the site is retrieved from current_site if that method is available
in the current application (for BigMed::App::Web::Login subclasses, for
example). If no site can be found, an error will be displayed.

=item * user => $user_object

The user whose privileges you want to use to display the sections. Only
sections to which the user has privileges will be shown. If no user
is specified, the user is retrieved from current_user is available
in the current application. If no user can be found, all of the site's
sections will be displayed.

=item * multiple => $number_of_rows

If set to a positive integer, will allow multiple selections; the list will
be displayed as a list box instead of a pop-up menu, with the number of
rows specified in the multiple parameter.

=item * all_sections_option => 1

If set to a true value, will include 'all sections' as the first option
(value ''). If both all_sections_option and homepage are selected, homepage
comes first.

=item * homepage => 1

If set to a true value, inclues "Homepage" as the first option (with
value set to the homepage's section id). If both homepage and
all_sections_option are selected, homepage comes first.

=back

=head2 C<simple_text>

Creates a text input field. (By default, C<simple_text> prompts generated
with C<prompt_field_ref> parameters do client-side validation to check that
the text field does not include line feeds).

=head2 C<system_time>

Creates a date/time field. The submitted value should be a Big Medium-formatted
time string (or defaults to the current time). Accepts these parameters:

=over 4

=item * date_format => $date_format

The date_format string, used to determine the year/month/day order
of the fields. The site or user date preference string may be used.
Default value is '%b %e %Y' (month day year).

=item * time_format => $time_format

The time_format string, used to determine 24-hour versus am/pm time
for the display. As with date_format above, the site or user time preference
may be used. Default value is '%I %M %p' (hour minute ampm).

=item * offset => $time_offset

The Big Medium-formatted time offset to use to display local time. The offset
represents the difference from GMT or UTC time. See BigMed::Time for details.

=item * option_label => $optional_time_string

If supplied, the time will be presented as an optional field with a checkbox
that toggles display of the date/time fields. This setting also determines
the label to display with the checkbox; it should be the unlocalized string
to be submitted to BigMed::App's language method.

=item * optional_time => $boolean

If true (and if the option_label setting is provided above), the optional
time checkbox will be checked and the date/time fields will be displayed.
If false, the checkbox will not be checked and the date/time fields will
be hidden until displayed.

=back

=head2 C<sort_order>

Builds a <value_several>-style list for selecting the sort order of articles.

=head2 C<time_offset>

Creates three select pulldown menus to represent a timezone offset
from GMT/UTC time. The first pulldown holds the +/- value, the second
holds the hour, and the third holds the minutes.

By default, no client-side validation is done. However, this prompt type
does add some JavaScript to display the user's current time zone offset
as well as to show what the current setting's resulting local time is
(updates automatically when you change a value).

=head2 C<submit>

Creates a submit input field. Unless you specify a different class in the
C<css_class> option, the class gets set to "bm_Submit".

=head2 C<tritoggle>

Creates one or more html buttons that toggle between three states, each
with an associated value. The current value for the button is submitted
with the form.

This prompt has three custom parameters:

=over 4

=item * states => { default => 'val1', on => 'val2', off => 'val3' }

A hash reference specifying the values for each of the three states default,
on and off. The button is initialized with the state whose value is
in the value parameter. If no value is supplied, the default value is used.

=item * button_html => '<img src="foo.gif" />'

The html to include as the button contents.

=item * buttons => \@button_parameters

Allows you to specify multiple buttons. This is an array reference of the
parameters to use for the buttons:

    buttons => [
        { id          => 'button1',
          value       => 'default',
          states      => \%button1_states,
          button_html => 'first button',
        },
        \%button2,
        \%button3,
    ]
=back

=head2 C<url>

Creates a text input field.

=head2 C<username>

Creates a text input field. (By default, C<username> prompts generated
with C<prompt_field_ref> parameters do client-side validation to check that
the text field does not include line feeds).

=head2 C<value_buttons>

Basically a graphical replacement for radio buttons, powered by javascript
and visualized with css. Each button is a css-styled link which
populates a hidden field when clicked. The opacity of inactive buttons
is reduced and the active button is set to 100%.

The details for each button are supplied in the options parameter:

    options => [
        { value     => 'button_value',
          opt_label => 'localized text', #defaults to value
          opt_class => 'classname', #default: "bmcp_list_button_$value"
        }
    ]

=head2 C<value_freeform>

Presents a textarea prompt where the user enters one list item per line.

=head2 C<value_list>

Creates a C<< <select> >> pulldown menu of options specified in the C<options>
parameter (see below). The value in the C<value> parameter will be selected.
In addition to the standard C<prompt> parameter options, this prompt type
also accepts the following:

=over 4

=item * options => \@options

Reference to array of option values to include in the list. The options will
be listed in the same order as listed in the array (the C<optgroups> parameter
overrides this parameter if provided).

=item * labels => { 'option' => 'display label', ... }

Reference to hash of display labels to show with each option. These should
be localized and ready for display to the user. If an option does not
have a corresponding label, the option itself will be used.

=item * optgroups => \@groups

    optgroups => [
        { name => 'localized group name',
          options => {
              options => \@options,
              labels  => \%labels,
        },
        { name => 'another localized name',
          options => {
              options => \@options2,
              labels  => \%labels2,
        },
    ]

If you want to group list options within optgroup tags, this parameter
offers a replacement for the options and labels tags. It's an array
reference of two-element hash references. Name is the localized, escaped
label attribute for the optgroup, and options is a hash reference containing
the options and labels parameters for the options to display.

=item * multiple => $number_of_rows

If set to a positive integer, will allow multiple selections; the list will
be displayed as a list box instead of a pop-up menu, with the number of
rows specified in the multiple parameter.

=item * option_label => $optional_field_string

If supplied, the fields will be presented as optional with a checkbox
that toggles display of the fields. This setting also determines
the label to display with the checkbox; it should be a localized string.

=item * option_true => $boolean

If true (and if the option_label setting is provided above), the optional
field checkbox will be checked and the fields will be displayed.
If false, the checkbox will not be checked and the ields will
be hidden until displayed.

=back

=head2 C<value_several>

Creates a series of pulldown menus so that the user can submit a list of
ordered values from a set of pre-defined option values. Accepts the same
parameters as C<value_list> above, plus:

=over 4

=item * numfields => $n

Number of pulldown menus to display. Default is 5.

=back

=head1 SEE ALSO

BigMed::App::Web

=head1 AUTHOR & COPYRIGHTS

This module and all Big Medium modules are copyright Josh Clark
and Global Moxie. All rights reserved.

Use of this module and the Big Medium content
management system are governed by Global Moxie's software licenses
and may not be used outside of the terms and conditions outlined
there.

For more information, visit the Global Moxie website at
L<http://globalmoxie.com/>.

Big Medium and Global Moxie are service marks of Global Moxie
and Josh Clark. All rights reserved.

=cut

