<?php
/**
 * Database edit interface class  
 *
 * Uses PEAR MDB module for database access -- with peardb_wrapper.php
 *
 * @version  1.7.0
 * @author   Paul Bissex <pb@e-scribe.com>
 * @license  GPL
 *
 * A simple class for dynamically creating database-editing forms, and for processing
 * commands that add, edit, and delete records
 *
 * Usage:
 *
 * // Table setup
 * $foo = new DB_Edit ("mysql://user:pass@host/database", "table");
 * $foo->ID_col = "ID";
 * $foo->set_summary_cols ("date", "title");
 * $foo->view_command = "actions/view.php?story=";
 *
 * // Appearance setup
 * $foo->table_atts = "BGCOLOR='#FFEE99' CELLPADDING='8' CELLSPACING='0' BORDER='0'";
 * $foo->hed_bg = "#DDCC77";
 * $foo->cell_bg[0] = "#FFEE99";
 * $foo->cell_bg[1] = "#EEDD88";
 *
 * // Column setup
 * $foo->set_input_type ("description", "textarea");
 * $foo->set_input_size ("date", 15);
 * $foo->set_input_select ("state", array ("vt" => "Vermont", "ma" => "Massachusetts"));
 * $foo->add_help ("foofield", "Enter full name of foo");
 * $foo->add_html_tools ("body");
 *
 * // Super cool relational menu building
 * // Second param is SQL query returning two cols, index then label
 * $foo->set_input_relation ("users", "SELECT ID, username FROM users");
 *
 * // Execute -- catch commands and display list and/or edit form
 * $foo->catch_requests();
 *   OR
 * $html = $foo->catch_requests ("capture");
 *
 * TODO: Important: change "Delete" method from GET to POST to avoid problems  
 *       with Google browser prefetching and other similar spankings
 * TODO: flesh out usage examples
 * TODO: support all HTML form field types 
 * TODO: finish row-range support
 * TODO: support file uploads?
 * TODO: update to latest PEAR DB module
 */

require_once "MDB.php";   /// PEAR DB class ///
require_once "MDB/peardb_wrapper.php";

class 
DB_Edit
    
{
    
/**
     * Public variables
     */
    
var $table_atts;
    var 
$table_name;
    var 
$hed_bg;
    var 
$cell_bg;
    var 
$ID_col "ID";  /// primary key column name ///
    
var $view_command "";
    
    var 
$html "";   /// for "capture" mode
    
    /**
     * Private variables -- switches
     */
    
var $_use_ranging true;   /// allow for row ranges in list display
    
var $_range_size 25;
    var 
$_summary_cols;  /// column names for summary table ///
    
var $_default_cols 35;
    var 
$_order_by "";
    var 
$_textarea_cols 25;
    var 
$_textarea_rows 8;
    var 
$_input_spec = array();   /// specs for non-default input fields
    
var $_self_url;    /// Contains base address of script (no parameters)
    
var $_cols = array();  /// columns, in form  $name => $type ///
    
var $_col_help = array();  /// messages to print with columns; keyed by col name ///
    
var $_db_conn;   /// database connection (resource)
    
var $_table_name;
    var 
$_datasource;   /// PEAR DB-style datasource pseudo-URL
    
var $_form_name "DB_Edit_mainform";  /// some JS fns need a form name
    
var $_html_help_file "html_help.html";  /// TODO: should be empty by default
    
    /**
     * CONSTRUCTOR
     *
     * @param  $datasource string   PEAR DB DSN e.g. mysql://user:pass@host/db
     * @param  $table_name string   name of table which will be edited
     */    
    
function DB_Edit ($datasource$table_name)
        {
        
$this->_datasource $datasource;
        
$this->_table_name $table_name;
        
$this->_self_url $_SERVER['PHP_SELF'];    
        
$this->_store_col_info();
        }
            
                
    
/**
     * Associate help text with a particular column
     *
     * @param  $colname string   column name
     * @param  $text string   the help message
     */
    
function add_help ($colname$code)
        {
        if (isset (
$this->_col_help [$colname]))
            {
            
$this->_col_help [$colname] .= $code;
            }
        else
            {
            
$this->_col_help [$colname] = $code;
            }
        }



    
/**
     * Add HTML editing components to help area
     *
     */
    
function add_html_tools ($colname)
        {
        
/// NOTE: JS inclusion slightly hacky: code may occur multiple times on page ///
        
$this->add_help ($colname$this->_javascript_tool_functions());
        
$this->_add_tool ($colname"br""line break");
        
$this->_add_tool ($colname"italic""italic text");
        
$this->_add_tool ($colname"bold""bold text");
        
$this->_add_tool ($colname"alink""link");
        
$this->_add_tool ($colname"img""image");
        
$this->_add_tool ($colname"htmltidy""tidy HTML");
        
$this->add_html_help_link ($colname); 
        }
            
    
/**
     * Add link for HTML help popup
     *
     * TODO: consider integrating HTML help into classfile, using document.write to generate popup
     */
    
function add_html_help_link ($colname$label="Show HTML help window")
        {
        
$_popcode "onclick=\"window.open('{$this->_html_help_file}','_help','width=640,height=500,scrollbars=1');return false;\"";
        
$this->add_help ($colname"<br /><br /><a $_popcode href='{$this->_html_help_file}'>$label</A>\n");
        }
        
        
    
/**
     * Add javascript button to help area
     *
     * @param  $colname string   column name
     * @param  $js_fn_name string   javascript function name ()
     * @param  $button_label string   text for button
     */        
    
function _add_tool ($colname$js_fn_name$button_label="")
        {
        if (empty (
$button_label))
            {
            
$button_label $js_fn_name;
            }
        
/// field name in Javascript-ese (DOM) ///                
        
$field "document.{$this->_form_name}.$colname";
        
/// generate button code ///
        
$code =  "<input type='button' onclick='javascript:$js_fn_name($field);";
        
$code .= "{$field}.focus();' value=' $button_label ' /><br />\n ";
        
$this->add_help ($colname$code);        
        }


    
/**
     * Create ORDER BY clause from column name(s)
     *
     * @param (variable size array) column names
     */
    
function set_order()
        {
        if (
func_num_args() == 0)
            {
            
trigger_error ("No arguments passed to DB_Edit::set_order()");
            }
        else
            {
            
$args func_get_args();
            
$column_list implode (","$args);
            
$this->_order_by "ORDER BY $column_list";
            }
        }
        
        
    
/**
     * Set summary columns
     *
     * @param (variable) column names
     */        
    
function set_summary_cols()
        {
        if (
func_num_args() == 0)
            {
            
trigger_error ("No arguments passed to DB_Edit::set_summary_cols()");
            }
        else
            {
            
$this->_summary_cols func_get_args();
            }
        }
        
        
    
/**
     * Set input tag size for a particular column
     *
     * For now, stored with type info in $this->_input_spec
     */
    
function set_input_size ($col_name$spec)
        {
        
$this->_input_spec [$col_name] = $spec;
        }


    
/**
     * Set size for all textarea fields
     */
    
function set_textarea_size ($cols$rows)
        {
        
$this->_textarea_cols $cols;
        
$this->_textarea_rows $rows;
        }


    
/**
     * Set input tag type for a particular column
     *
     * For now, stored with size info in $this->_input_spec
     */
    
function set_input_type ($col_name$spec)
        {
        
$this->_input_spec [$col_name] = $spec;
        }


    
/**
     * Setup array for <select>
     *
     * @param  $col_name string
     * @param  $options array   arranged as value => label
     */
     
function set_input_select ($col_name$options)
         {
        
$this->_input_spec [$col_name] = "select";
        
$this->_input_choices [$col_name] = $options;
        }


    
/**
     * Build a menu based on SQL
     * Query should return two columns: key and label
     *
     * @param  $col_name string
     * @param  $sql string   code that fetches key and label data
     * @param  $additional_options array   any additional key/label pairs (e.g. "0" => "None")
     */
    
function set_input_relation ($col_name$sql$additional_options=array())
        {
        
$rows $this->_fetch_results ($sqlDB_FETCHMODE_DEFAULT);
        if (!
DB::iserror ($rows))
            {
            foreach (
$rows as $row)
                {
                
$options [$row [0]] = $row [1];
                }
            if (
count ($additional_options))
                {
                foreach (
$additional_options as $key => $option)
                    {
                    
$options [$key] = $option;
                    }
                }
            
$this->set_input_select ($col_name$options);
            }
        }



    
/**
     * Main function, more or less
     *
     * Determine command, check against prefs, execute, do http redir if needed
     */
    
function catch_requests ($mode="print")
        {
        
$this->html "";
        
        
$command = @$_REQUEST ['c'];
        
$ID = @$_REQUEST ['n'];
        
$first_row = @$_REQUEST ['r'];

        if (
$command == "edit")
            {
            
$this->_show_form ($mode$ID);
            }
        else if (
$command == "delete")
            {
            
$this->_delete_row ($ID);
            
$this->_reload();   /// TODO: how is mode retained??
            
}
        else if (
$command == "Add")
            {
            
$row_data $_REQUEST;
            unset (
$row_data ['c']);  /// don't try to insert button into table! ///
            
unset ($row_data ['MAX_FILE_SIZE']);  /// likewise that hidden field, if present ///
            
$this->_insert_row ($row_data);
            
$this->_show_list ($mode);
            
$this->_show_form ($mode);
            }
        else if (
$command == "Update")
            {
            
$row_data $_REQUEST;
            unset (
$row_data ['c']);  /// don't try to insert button into table! ///
            
unset ($row_data ['MAX_FILE_SIZE']);  /// likewise that hidden field ///
            
$this->_update_row ($row_data);
            
$this->_show_list ($mode);
            
$this->_show_form ($mode);
            }
        else
            {
            
/// Default behavior: show list of records and form for adding ///
            
$this->_show_list ($mode$first_row);
            
$this->_show_form ($mode);
            }
        }





    
/**
     * Perform SQL query
     *
     * @param  $query string  SQL to be executed on active connection
     * @return  query result resource
     */
    
function _do_query ($query)
        {
        
$d DB::connect ($this->_datasource);
        if (
DB::iserror ($d))
            {
            die (
"Error: " $d->getMessage());
            }
        return 
$d->query ($query);
        }


    
/**
     * Prepare fields for INSERT or UPDATE
     *
     * remove fields with names begining with "_"
     * and addslashes
     *
     * @param  $cols array   of form $colname => $colvalue
     * @return  array  same array, less columns (fields) beginning with "_"
     */
    
function _prep_data ($cols)
        {
        foreach (
$cols as $col_name => $col_value)
            {
            
/// Omit field names beginning with underscores ///
            
if (substr($col_name01) != "_")
                {
                
$_prepped [$col_name] = addslashes ($col_value);  
                }
            }
        return 
$_prepped;
        }
    
    
    
/**
     * Build INSERT query text from array
     * 
     * Factored for use by dump()
     *
     * @param  $data array   of form $colname => $colvalue
     * @return  string   SQL query string
     */
    
function _build_insert ($data)
        {
        
$table $this->_table_name;
        
$_prepped DB_Edit::_prep_data ($data);
        
$query "INSERT INTO $table (";
        
$query .= implode (", "array_keys($_prepped));
        
$query .= ") VALUES (\"";
        
$query .= implode ("\",\""array_values($_prepped));
        
$query .= "\")";
        return 
$query;
        }
    
    
    
/**
     * Perform INSERT query
     *
     * @param  $table string
     * @param  $data string[fieldname => value]
     */
    
function _insert_row ($data)
        {
        
$table $this->_table_name;
        
$query DB_Edit::_build_insert ($data);
        
$this->_do_query ($query);
        }
    
    
    
/** 
     * Perform UPDATE query
     *
     * @param  $table string
     * @param  $data string array [fieldname => value]
     * @param  $key string   name of key column; default "ID"
     */
    
function _update_row ($data$key "ID")
        {
        
$table $this->_table_name;
        
$data DB_Edit::_prep_data ($data);
        if (empty (
$data))
            {
            
$this->err "ERROR: empty array passed to _update_row()";
            return;
            }
        
$query "UPDATE $table SET ";
        foreach (
$data as $field_name => $field_value)
            {
            if (
$field_name != $key)
                
/// don't add key field to query /// 
                
{
                
$query .= ($field_name "=\"" $field_value "\", ");  
                }
            }
        
/// strip last comma and add WHERE clause ///
        
$query substr ($query0strlen ($query) - 2);
        
$query .= " WHERE $key=\"" $data [$key] . "\"";
        
$this->_do_query ($query);
        }
    
    
    
    function 
_delete_row ($row_ID)
        {
        
$this->_do_query ("DELETE FROM {$this->_table_name} WHERE {$this->ID_col}='$row_ID'");
        }


        
    
/**
     * Story column names => types in $this->_cols
     */
    
function _store_col_info()
        {
        
$columns = ($this->_fetch_results ("DESCRIBE {$this->_table_name}"DB_FETCHMODE_ORDERED));
        foreach (
$columns as $column)
            {
            
/// 0 is column name; 1 is column type
            
$this->_cols [$column [0]] = $column [1];
            }
        }
        
        
    function 
_fetch_results ($query$mode=DB_FETCHMODE_ASSOC)
        {
        
$d DB::connect ($this->_datasource);
        if (
DB::iserror ($d))
            {
            die (
"Error: " $d->getMessage());
            }
        return 
$d->getAll ($queryNULL$mode);
        }

    
    
/**
     * HTML link code with "OK?" query
     *
     * @param  $url string   target URL
     * @param  $label string   link text
     * @return  string   <a> tag with embedded javascript
     */
    
function _confirm_link ($url$label
        {
        
$html "<a href='$url' onclick=\"javascript:if(!confirm('Really do it?'))";
        
$html .= "return false;\">$label</a><br />";
        return 
$html;
        }


    
/**
     * Return rows for list
     *
     * @return  array   result rows
     *
     * TODO: complete ranging functionality.
     * Currently "?r=20" will produce a list of 25 (default) rows starting at row 20.
     * Needed: "show all" ability (simple link, shown if ranging was used)
     * Needed: paging: show link to r+default and r-default positions (error checking needed too)
     * Needed: display indicating which rows are being shown
     */
    
function _fetch_list_results ($first_row="")
        {
        
$cols implode (", "$this->_summary_cols) . ", " $this->ID_col;
        
$query "SELECT $cols FROM {$this->_table_name} {$this->_order_by}";
        if (
$this->_use_ranging && $first_row)
            {
            
$query .= " LIMIT " $first_row "," $this->_range_size;
            }
        return 
$this->_fetch_results ($query);
        }
        

    
/**
     * Display list of all rows in table
     * with options for edit, remove, view -- as per settings
     */
    
function _show_list ($mode="print"$first_row="")
        {
        
$html "";
        
$html .=  "<table {$this->table_atts}><tr>";
        foreach (
$this->_summary_cols as $col_hed)
            {
            
$col_hed ucwords ($col_hed);
            
$html .=  "<td bgcolor='{$this->hed_bg}'><b>$col_hed</b></td>";
            }
        
$html .=  "<td colspan='3' bgcolor='{$this->hed_bg}'>Controls</td></tr>\n";
        
$rows $this->_fetch_list_results ($first_row);
        foreach (
$rows as $row)
            {
            
$ID $row [$this->ID_col];
            
$html .=  "<tr>";
            foreach (
$this->_summary_cols as $col)
                {
                
$html .=  "<td bgcolor='{$this->cell_bg [0]}'>" $row [$col] . "</td>";
                }
            
/// show View link ///
            
$html .= "<td>";
            if (
$this->view_command)
                {
                
$html .= "<a href='{$this->view_command}$ID' target='_view'>View</a>";
                }
            
$html .= "</td>";
            
/// show Edit link ///
            
$html .=  "<td><a href='{$this->_self_url}?c=edit&n=$ID'>Edit</a></td>";
            
/// show Delete link ///
            
$html .=  "<td>";
            
$_del_link $this->_self_url "?c=delete&n=$ID";
            
$html .= $this->_confirm_link ($_del_link"Delete");
            
$html .=  "</td></tr>\n";
            }
        
$html .=  "</table><br /><br /><hr />";
        
        if (
$mode == "capture")
            {
            
$this->html .= $html;
            }
        else
            {
            print 
$html;
            }
        }    
    
    
    
/**
     * Display edit form, using _cols to determine field types
     * 
     * @param  $edit_ID string   (optional) row ID, for editing mode
     */
    
function _show_form ($mode="print"$edit_ID="")
        {
        
$html "";
        
$edrow = array();
        if (
$edit_ID)
            {
            
$query "SELECT * FROM {$this->_table_name} WHERE {$this->ID_col}='$edit_ID'";
            
$edrow $this->_fetch_results ($query);
            
/// TODO: check for failure (i.e. no matching record) ///
            
$edrow $edrow [0];  /// unpack ///
            
}
        
$html .=  "<form name='{$this->_form_name}' action='{$this->_self_url}' method='POST'>";
        
$html .=  "<table cellpadding='4'>";
        
$bg 0;  /// row background color index; alternates per row ///
        
foreach ($this->_cols as $col_name => $col_type)
            {            
            if (
$col_name != $this->ID_col)
                {
                
$col_spec = @$this->_input_spec [$col_name];
                
$help = @$this->_col_help [$col_name];
                
$col_label ucwords ($col_name);

                
$bg = ($bg);  /// flip between 0 and 1, for color alternation ///
                
$html .=  "<tr bgcolor='{$this->cell_bg [$bg]}'>";
                
$html .=  "<td align='right'>$col_label</td>";

                
$input_value htmlspecialchars (stripslashes (@$edrow [$col_name]), ENT_QUOTES);
                
$size $this->_default_cols;
                if (
is_int ($col_spec))
                    {
                    
$size $col_spec;
                    }
                
/// TODO: factor input tag rendering code ///
                
$html .= "<td>";
                if (
$col_spec == "textarea")
                    {
                    
$html .= $this->_form_field_textarea ($col_name$input_value);
                    }
                else if (
$col_spec == "select")
                    {
                    
$html .= $this->_form_field_select ($col_name$input_value$this->_input_choices [$col_name]);
                    }
                else
                    {
                    
$html .= $this->_form_field_text ($col_name$input_value$size);
                    }
                
/// TODO: add checkbox & radio button support! ///
                
$html .= "</td>";
                
$html .=  "<td><i>$help</i></td></tr>\n";
                }
            }
        if (
$edit_ID)
            {
            
$html .=  "<input type='hidden' name='{$this->ID_col}' value='$edit_ID'";    
            
$html .=  "<td></td><td><input type='submit'";
            
$html .=  " name='c' value='Update'></td><td></td>";
            }
        else
            {  
            
$html .=  "<td></td><td><input type='submit' name='c' value='Add'></td><td></td>";  
            }
        
/// TODO: reset button too?
        
$html .=  "</table>";
        
$html .=  "</form>";

        if (
$mode == "capture")
            {
            
$this->html .= $html;
            }
        else
            {
            print 
$html;
            }
        }
        
        
    function 
_reload()
        {
        
header ("Location: $this->_self_url");
        }


    
/** 
     * Generate HTML TEXT form field with optional data for editing 
     */
    
function _form_field_text ($name$value$size=0)
        {
        if (
$size == )
            {
            
$size $this->_default_cols;
            }
        
$html "";
        
$html .= " <input type='text' size='$size' name='$name' value='$value' />";
        return 
$html;
        }
    

    
/** 
     * Generate HTML textarea form field with optional data for editing 
     */
    
function _form_field_textarea ($name$value)
        {
        
$cols $this->_textarea_cols;
        
$rows $this->_textarea_rows;
        
$html "";
        
$html .= " <textarea cols='$cols' rows='$rows' name='$name'>\n";
        
$html .=  $value;
        
$html .=  "\n</textarea>\n";
        return 
$html;
        }
    

    
/** 
     * Generate HTML CHECKBOX form field with optional data for editing
     *
     * TODO: finish rewrite/clean on model of other html gen methods 
     */
    
function _form_field_checkbox ($label$value$name)
        {
        
$html .=  (" <input type='checkbox' name='$name' value='1'");
        if (
$value == 1
            {  
$html .=  (" checked ");  }
        
$html .=  (" />");
        return 
$html;
        }
    

    
/** 
     * Generate HTML SELECT form field 
     * Last param is associative array: realvalue => textlabel 
     * Default selection is via $value parameter 
     */
    
function _form_field_select ($name$value$options)
        {
        
$html "<select name='$name'>\n";
        if (
count ($options))
            {
            foreach (
$options as $opt_value => $opt_label)
                {
                
$html .=  "<option value='$opt_value' ";
                if (
$value == $opt_value)
                    {
                    
$html .= " selected";
                    }
                
$html .= " />$opt_label</option>\n";
                }
            }
        
$html .= "</select><br />\n";
        return 
$html;
        }
    

    
/**
     * TODO: would be nice to have generalized JS func for paired tags;
     * it would make it easier to expand add_html_tools()
     */
    
function _javascript_tool_functions()
        {
        
$html = <<<HEREDOC
<script language="javascript">
function bold(field) {
    var dark=prompt('Bold text','Text');
    if (dark)
        {  field.value+=('<b>'+dark+'</b>');  }
    }
function br(field) {
    field.value+=('<br />')
    }
function italic(field) {
    var ital=prompt('Italicized Text','Text');
    if (ital)
        {  field.value+=('<i>'+ital+'</i>');  }
    }
function alink(field) {
    var linkurl=prompt('URL for the link','http://');
    var linktext=prompt('Text for the link','Text');
    if (linktext) 
        {  field.value+=('<a href="'+linkurl+'">'+linktext+'</a>');     }
    }
function img(field) {
    var imgurl=prompt('URL of the image','');
    if (imgurl) 
        {  field.value+=('<img src="'+imgurl+'" />');  }
    }
function htmltidy(field) {
    var re = new RegExp ('>', 'gi');
    field.value = field.value.replace (re, '>\\n') ; 
    }
</script>
HEREDOC;
        return 
$html;
        }
        
    }
?>