product options
authorTony Cook <tony@develop-help.com>
Fri, 17 Jul 2009 09:04:03 +0000 (09:04 +0000)
committertony <tony@45cb6cf1-00bc-42d2-bb5a-07f51df49f94>
Fri, 17 Jul 2009 09:04:03 +0000 (09:04 +0000)
39 files changed:
MANIFEST
schema/bse.sql
site/cgi-bin/modules/Article.pm
site/cgi-bin/modules/Articles.pm
site/cgi-bin/modules/BSE/Arrows.pm
site/cgi-bin/modules/BSE/DB.pm
site/cgi-bin/modules/BSE/DB/Mysql.pm
site/cgi-bin/modules/BSE/Edit/Article.pm
site/cgi-bin/modules/BSE/Edit/Product.pm
site/cgi-bin/modules/BSE/Request/Base.pm
site/cgi-bin/modules/BSE/Shop/Util.pm
site/cgi-bin/modules/BSE/TB/OrderItem.pm
site/cgi-bin/modules/BSE/TB/OrderItemOption.pm [new file with mode: 0644]
site/cgi-bin/modules/BSE/TB/OrderItemOptions.pm [new file with mode: 0644]
site/cgi-bin/modules/BSE/TB/ProductOption.pm [new file with mode: 0644]
site/cgi-bin/modules/BSE/TB/ProductOptionValue.pm [new file with mode: 0644]
site/cgi-bin/modules/BSE/TB/ProductOptionValues.pm [new file with mode: 0644]
site/cgi-bin/modules/BSE/TB/ProductOptions.pm [new file with mode: 0644]
site/cgi-bin/modules/BSE/UI/AdminShop.pm
site/cgi-bin/modules/BSE/UI/Shop.pm
site/cgi-bin/modules/BSE/Util/Tags.pm
site/cgi-bin/modules/Generate/Catalog.pm
site/cgi-bin/modules/Generate/Product.pm
site/cgi-bin/modules/Product.pm
site/cgi-bin/modules/Squirrel/Row.pm
site/cgi-bin/modules/Squirrel/Table.pm
site/htdocs/css/admin.css
site/htdocs/js/prototype.js
site/templates/admin/edit_1.tmpl
site/templates/admin/edit_prodopts.tmpl [new file with mode: 0644]
site/templates/admin/edit_product.tmpl
site/templates/admin/prodopt_delete.tmpl [new file with mode: 0644]
site/templates/admin/prodopt_edit.tmpl [new file with mode: 0644]
site/templates/admin/prodopt_value_delete.tmpl [new file with mode: 0644]
site/templates/admin/prodopt_value_edit.tmpl [new file with mode: 0644]
site/templates/admin/product_menu.tmpl [new file with mode: 0644]
site/templates/admin/xbase.tmpl
site/templates/catalog.tmpl
site/util/mysql.str

index e77e2afffc2b7ad9959bad6fa051e7c16b201784..eea3dae295282ac666ef5d37a6b2357f6fc45edb 100644 (file)
--- a/MANIFEST
+++ b/MANIFEST
@@ -132,9 +132,15 @@ site/cgi-bin/modules/BSE/TB/Locations.pm
 site/cgi-bin/modules/BSE/TB/Order.pm
 site/cgi-bin/modules/BSE/TB/Orders.pm
 site/cgi-bin/modules/BSE/TB/OrderItem.pm
+site/cgi-bin/modules/BSE/TB/OrderItemOption.pm
+site/cgi-bin/modules/BSE/TB/OrderItemOptions.pm
 site/cgi-bin/modules/BSE/TB/OrderItems.pm
 site/cgi-bin/modules/BSE/ProductImportXLS.pm
 site/cgi-bin/modules/BSE/TB/Seminar.pm
+site/cgi-bin/modules/BSE/TB/ProductOption.pm
+site/cgi-bin/modules/BSE/TB/ProductOptions.pm
+site/cgi-bin/modules/BSE/TB/ProductOptionValue.pm
+site/cgi-bin/modules/BSE/TB/ProductOptionValues.pm
 site/cgi-bin/modules/BSE/TB/SeminarBooking.pm
 site/cgi-bin/modules/BSE/TB/SeminarBookings.pm
 site/cgi-bin/modules/BSE/TB/SeminarSession.pm
@@ -350,6 +356,7 @@ site/templates/admin/edit_1.tmpl
 # site/templates/admin/edit_5.tmpl
 site/templates/admin/edit_catalog.tmpl
 site/templates/admin/edit_groups.tmpl
+site/templates/admin/edit_prodopts.tmpl
 site/templates/admin/edit_product.tmpl
 site/templates/admin/edit_seminar.tmpl
 site/templates/admin/edit_semsessions.tmpl
@@ -381,8 +388,13 @@ site/templates/admin/order_list_filled.tmpl
 site/templates/admin/order_list_incomplete.tmpl
 site/templates/admin/order_list_unfilled.tmpl
 # site/templates/admin/product_custom.tmpl
+site/templates/admin/prodopt_delete.tmpl
+site/templates/admin/prodopt_edit.tmpl
+site/templates/admin/prodopt_value_delete.tmpl
+site/templates/admin/prodopt_value_edit.tmpl
 site/templates/admin/product_detail.tmpl
 site/templates/admin/product_list.tmpl
+site/templates/admin/product_menu.tmpl
 site/templates/admin/regenerror.tmpl
 site/templates/admin/registeremail.tmpl
 site/templates/admin/reports/list.tmpl
index cf07794a6997b513e6d1c5e6b50b1ef8205fbb0f..1a60511c5dee5ccea41c447cb3043a95a254301d 100644 (file)
@@ -826,3 +826,37 @@ create table bse_wishlist (
   display_order integer not null,
   primary key(user_id, product_id)
 );
+
+drop table if exists bse_product_options;
+create table bse_product_options (
+  id integer not null auto_increment primary key,
+  product_id integer not null references product(productId),
+  name varchar(40) not null,
+  type varchar(10) not null,
+  global_ref integer null,
+  display_order integer not null,
+  enabled integer not null default 0,
+  default_value integer,
+  index product_order(product_id, display_order)
+) type=innodb;
+
+drop table if exists bse_product_option_values;
+create table bse_product_option_values (
+  id integer not null auto_increment primary key,
+  product_option_id integer not null references bse_product_options(id),
+  value varchar(40) not null,
+  display_order integer not null,
+  index option_order(product_option_id, display_order)
+) type=innodb;
+
+drop table if exists bse_order_item_options;
+create table bse_order_item_options (
+  id integer not null auto_increment primary key,
+  order_item_id integer not null references order_item(id),
+  original_id varchar(40) not null,
+  name varchar(40) not null,
+  value varchar(40) not null,
+  display varchar(80) not null,
+  display_order integer not null,
+  index item_order(order_item_id, display_order)
+) type=innodb;
\ No newline at end of file
index 834dd978b1a107bc79c4762a02bc95775bb801a8..12cc4cf9be20980810831d2cb71d210f45203c32 100644 (file)
@@ -18,6 +18,10 @@ sub columns {
     metaDescription metaKeywords summary menu titleAlias linkAlias/;
 }
 
+sub table {
+  'article';
+}
+
 sub numeric {
   qw(id listed parentid threshold summaryLength level 
      customInt1 customInt2 customInt3 customInt4 menu);
index 51610d814cd33b04ff6d3258a811a2a3295bb283..7cd87da54d2cb09d27721ba44033af64fff12a9a 100644 (file)
@@ -9,10 +9,6 @@ sub rowClass {
   return 'Article';
 }
 
-sub table {
-  'article';
-}
-
 # returns a list of articles which are sections
 sub sections {
   my ($self) = @_;
index 3ebaf11f56a45825ab81f51f326d90b3255aa801..c158626933e7e0f714c475ae9ea7996368ed6dce 100644 (file)
@@ -9,35 +9,58 @@ use vars qw(@EXPORT);
 @EXPORT = qw(make_arrows);
 
 sub make_arrows {
-  my ($cfg, $down_url, $up_url, $refresh, $prefix) = @_;
+  my ($cfg, $down_url, $up_url, $refresh, $type, %opts) = @_;
+
+  my $section = $type ? "$type arrows" : "arrows";
+  my $prefix = $cfg->entry($section, "prefix", $type);
 
   my $images_uri = $cfg->entry('uri', 'images', '/images');
   my $html = '';
   my $want_xhtml = $cfg->entryBool('basic', 'xhtml', 1);
-  my $align = $cfg->entry('arrows', 'align', $want_xhtml ? 'bottom' : 'absbottom');
-  my $nomove = qq'<img src="/images/trans_pixel.gif" width="17" height="13" border="0" align="$align" alt="" />';
+  my $extra_attr = "";
+  unless ($want_xhtml) {
+    my $align = $cfg->entry('arrows', 'align', $want_xhtml ? 'bottom' : 'absbottom');
+    $extra_attr = qq(border="0" align="$align" );
+  }
+  if ($cfg->entry($section, "set_size", 
+                 $cfg->entry("arrows", "set_size", 1))) {
+    my $image_width = $cfg->entry($section, "image_width", 17);
+    my $image_height = $cfg->entry($section, "image_height", 13);
+    $extra_attr .= qq(width="$image_width" height="$image_height" );
+  }
+
+  my $nomove = qq'<img src="/images/trans_pixel.gif" alt="" $extra_attr/>';
   if ($down_url) {
+    my $down_img = $cfg->entry($section, "downimg", "$images_uri/admin/${prefix}move_down.gif");
     $down_url .= "&r=".escape_uri($refresh) if $refresh;
     $down_url = escape_html($down_url);
-    my $alt = escape_html($cfg->entry('arrows', 'down_arrow_text', "Move Down"));
+    my $alt = escape_html($cfg->entry($section, "down_arrow_text", $cfg->entry('arrows', 'down_arrow_text', "Move Down")));
     $html .= qq!<a href="$down_url">!;
-    $html .= qq!<img src="$images_uri/admin/${prefix}move_down.gif" !
-      . qq!width="17" height="13" alt="$alt" border="0" align="$align" /></a>!;
+    $html .= qq!<img src="$down_img" alt="$alt" $extra_attr/></a>!;
   }
   else {
     $html .= $nomove;
   }
   if ($up_url) {
+    my $up_img = $cfg->entry($section, "upimg", "$images_uri/admin/${prefix}move_up.gif");
     $up_url .= "&r=".escape_uri($refresh) if $refresh;
     $up_url = escape_html($up_url);
-    my $alt = escape_html($cfg->entry('arrows', 'up_arrow_text', "Move Up"));
+    my $alt = escape_html($cfg->entry($section, "up_arrow_text", $cfg->entry('arrows', 'up_arrow_text', "Move Up")));
     $html .= qq!<a href="$up_url">!;
-    $html .= qq!<img src="$images_uri/admin/${prefix}move_up.gif" !
-      . qq!width="17" height="13" alt="$alt" border="0" align="$align" /></a>!;
+    $html .= qq!<img src="$up_img" alt="$alt" $extra_attr/></a>!;
   }
   else {
     $html .= $nomove;
   }
+  my $class = $cfg->entry($section, "class", $cfg->entry("arrows", "class", "bse_arrows"));
+
+  my $tag = $cfg->entry($section, "tag", $cfg->entry("arrows", "tag", "span"));
+  my $wrapper = qq(<$tag class="$class");
+  if ($opts{id}) {
+    my $id_prefix = $cfg->entry($section, "idprefix", $opts{id_prefix} || $prefix);
+    $wrapper .= qq( id="${id_prefix}$opts{id}");
+  }
+  $html = "$wrapper>$html</$tag>";
 
   $html;
 }
index 76e3bb9d64e34c9882ee4bfd391c3bec9d8ea5e4..77af91993f261f72c0d27b9ff164d95c7e16277d 100644 (file)
@@ -68,13 +68,12 @@ sub _query_expr {
 }
 
 sub generate_query {
-  my ($self, $table, $columns, $query) = @_;
+  my ($self, $row_class, $columns, $query) = @_;
 
-  my $row_class = $table->rowClass;
   my %trans;
   @trans{$row_class->columns} = $row_class->db_columns;
 
-  my $table_name = $table->table;
+  my $table_name = $row_class->table;
 
   my @out_columns = map 
     {; $trans{$_} or confess "No column '$_' in $table_name" } @$columns;
@@ -99,6 +98,46 @@ sub generate_query {
   return @rows;
 }
 
+sub insert_stmt {
+  my ($self, $table_name, $columns) = @_;
+
+  my $sql = "insert into $table_name(" . join(",", @$columns) . ")";
+  $sql .= " values(" . join(",", ("?") x @$columns) . ")";
+
+  my $sth = $self->{dbh}->prepare($sql);
+  $sth
+    or confess "Cannot prepare generated sql $sql: ", $self->{dbh}->errstr;
+
+  return $sth;
+}
+
+sub update_stmt {
+  my ($self, $table_name, $pkey, $cols) = @_;
+
+  my $sql = "update $table_name set\n  " .
+    join(",\n  ", map "$_ = ?", @$cols) .
+      "\n  where $pkey = ?";
+
+  my $sth = $self->{dbh}->prepare($sql);
+  $sth
+    or confess "Cannot prepare generated sql $sql: ", $self->{dbh}->errstr;
+
+  return $sth;
+}
+
+sub delete_stmt {
+  my ($self, $table_name, $pkeys) = @_;
+
+  my @where = map "$_ = ?", @$pkeys;
+  my $sql = "delete from $table_name where " . join " and ", @where;
+
+  my $sth = $self->{dbh}->prepare($sql);
+  $sth
+    or confess "Cannot prepare generated sql $sql: ", $self->{dbh}->errstr;
+
+  return $sth;
+}
+
 1;
 
 __END__
index e59c6d2e7adadcb2acf662437c09bb2db17ec6b3..6608ca8d8dce4a317586c69c4cc3c1fdb37ef763 100644 (file)
@@ -115,6 +115,11 @@ SQL
 select ar.*, pr.* from article ar, product pr, bse_wishlist wi
   where wi.user_id = ? and wi.product_id = ar.id and ar.id = pr.articleId
 order by wi.display_order desc
+SQL
+   getProductOptionByProduct_id => <<SQL,
+select *
+from bse_product_options
+where product_id = ?
 SQL
    'Products.visible_children_of' => <<SQL,
 select ar.*, pr.* from article ar, product pr
@@ -612,7 +617,7 @@ sub _single
 
 my $get_sql_by_name = 'select sql_statement from sql_statements where name=?';
 
-sub stmt {
+sub stmt_sql {
   my ($self, $name) = @_;
 
   $name =~ s/BSE.*:://;
@@ -625,10 +630,29 @@ sub stmt {
       #print STDERR "Found SQL '$sql'\n";
     }
     else {
-      print STDERR "SQL statment $name not found in sql_statements table\n";
+      #print STDERR "SQL statment $name not found in sql_statements table\n";
     }
   }
-  $sql or confess "Statement named '$name' not found";
+
+  return $sql;
+}
+
+sub stmt {
+  my ($self, $name) = @_;
+
+  my $sql = $self->stmt_sql($name)
+    or confess "Statement named '$name' not found";
+  my $sth = $self->{dbh}->prepare($sql)
+    or croak "Cannot prepare $name statment: ",$self->{dbh}->errstr;
+
+  $sth;
+}
+
+sub stmt_noerror {
+  my ($self, $name) = @_;
+
+  my $sql = $self->stmt_sql($name)
+    or return;
   my $sth = $self->{dbh}->prepare($sql)
     or croak "Cannot prepare $name statment: ",$self->{dbh}->errstr;
 
index de114cf16a35cd01ff89badfcc9ba1ec2c7fe723..1dbc309e729f41bb82f22a33d6d8f870ddb71fe8 100644 (file)
@@ -13,6 +13,22 @@ use BSE::Template;
 use BSE::Util::ContentType qw(content_type);
 use constant MAX_FILE_DISPLAYNAME_LENGTH => 255;
 
+=head1 NAME
+
+  BSE::Edit::Article - editing functionality for BSE articles
+
+=head1 DESCRIPTION
+
+Provides the base article editing functionality.
+
+This is badly organized and documented.
+
+=head1 METHODS
+
+=over
+
+=cut
+
 sub not_logged_on {
   my ($self, $req) = @_;
 
@@ -1068,9 +1084,7 @@ sub low_edit_tags {
   my $it = BSE::Util::Iterate->new;
   return
     (
-     BSE::Util::Tags->basic($acts, $cgi, $cfg),
-     BSE::Util::Tags->admin($acts, $cfg),
-     BSE::Util::Tags->secure($request),
+     $request->admin_tags,
      article => [ $tag_hash, $article ],
      old => [ \&tag_old, $article, $cgi ],
      default => [ \&tag_default, $self, $request, $article ],
@@ -2157,7 +2171,7 @@ sub save_stepparents {
                        'Stepparent information saved');
 }
 
-sub refresh {
+sub refresh_url {
   my ($self, $article, $cgi, $name, $message, $extras) = @_;
 
   my $url = $cgi->param('r');
@@ -2187,6 +2201,14 @@ sub refresh {
     $url .= "#$name" if $name;
   }
 
+  return $url;
+}
+
+sub refresh {
+  my ($self, $article, $cgi, $name, $message, $extras) = @_;
+
+  my $url = $self->refresh_url($article, $cgi, $name, $message, $extras);
+
   return BSE::Template->get_refresh($url, $self->{cfg});
 }
 
@@ -2386,6 +2408,31 @@ sub save_image_changes {
   return $self->refresh($article, $cgi);
 }
 
+=item _service_error
+
+This function is called on various errors.
+
+If a _service parameter was supplied, returns text like:
+
+=over
+
+Result: failure
+
+Field-Error: I<field-name1> - I<message1>
+
+Field-Error: I<field-name2> - I<message2>
+
+=back
+
+If the request is detected as an ajax request or a _ parameter is
+supplied, return JSON like:
+
+  { error: I<message> }
+
+Otherwise display the normal edit page with the error.
+
+=cut
+
 sub _service_error {
   my ($self, $req, $article, $articles, $msg, $error) = @_;
 
@@ -2402,6 +2449,9 @@ sub _service_error {
       $text =~ tr/\n/ /;
       $body .= "Error: $text\n";
     }
+    elsif ($msg) {
+      $body .= "Error: $msg\n";
+    }
     else {
       $body .= "Error: $error\n";
     }
@@ -2411,6 +2461,11 @@ sub _service_error {
        content => $body,
       };
   }
+  elsif ((() = $req->cgi->param('_')) ||
+        (defined $ENV{HTTP_X_REQUESTED_WITH}
+         && $ENV{HTTP_X_REQUESTED_WITH} =~ /XMLHttpRequest/)) {
+    return $req->json_content({ errors => $error });
+  }
   else {
     return $self->edit_form($req, $article, $articles, $msg, $error);
   }
@@ -3872,11 +3927,17 @@ sub req_ajax_set {
      };
 }
 
-1;
+sub csrf_error {
+  my ($self, $req, $article, $name, $description) = @_;
 
-=head1 NAME
+  my %errors;
+  $errors{_csrfp} = "Possible CSRF attempt on $name/$description: " . $req->csrf_error;
+  return $self->_service_error($req, $article, 'Articles', undef, \%errors);
+}
 
-  BSE::Edit::Article - editing functionality for BSE articles
+1;
+
+=back
 
 =head1 AUTHOR
 
index 8d594d247b7e36e072ff6faf609a8197045fdb3d..7ee1f4d4350de6b3a8d1825b9db7d8ed6d8543fb 100644 (file)
@@ -7,6 +7,21 @@ use BSE::Template;
 use BSE::Util::Iterate;
 use DevHelp::HTML;
 use BSE::CfgInfo 'product_options';
+use BSE::Util::Tags qw(tag_hash);
+
+=head1 NAME
+
+BSE::Edit::Product - tags and actions for editing BSE products
+
+=head1 SYNOPSIS
+
+  http://www.example.com/cgi-bin/admin/add.pl ...
+
+=head1 DESCRIPTION
+
+Article editor subclass for editing Products.
+
+=cut
 
 my %money_fields =
   (
@@ -53,6 +68,15 @@ sub iter_subs {
   BSE::TB::Subscriptions->all;
 }
 
+sub iter_option_values {
+  my ($self, $rcurrent_option, $args) = @_;
+
+  $$rcurrent_option
+    or return;
+
+  return $$rcurrent_option->values;
+}
+
 sub tag_hash_mbcs {
   my ($object, $args) = @_;
 
@@ -64,6 +88,103 @@ sub tag_hash_mbcs {
   escape_html($value, '<>&"');
 }
 
+sub tag_dboptionvalue_move {
+  my ($self, $req, $article, $rvalues, $rindex, $args) = @_;
+
+  $$rindex >= 0 && $$rindex < @$rvalues
+    or return "** dboptionvalue_move only in dboption_values iterator **";
+
+  my $my_id = $rvalues->[$$rindex]{id};
+  my $base_url = "$ENV{SCRIPT_NAME}?id=$article->{id}&value_id=$my_id&_csrfp=".$req->get_csrf_token("admin_move_option_value") . "&";
+
+  my $t = $req->cgi->param('_t');
+  $t && $t =~ /^\w+$/
+    and $base_url .= "_t=$t&";
+
+  my $up_url = '';
+  if ($$rindex > 0) {
+    $up_url = $base_url . "a_option_value_moveup=1";
+  }
+  my $down_url = '';
+  if ($$rindex < $#$rvalues) {
+    $down_url = $base_url . "a_option_value_movedown=1";
+  }
+
+  my $refresh = $self->refresh_url($article, $req->cgi);
+
+  require BSE::Arrows;
+  return BSE::Arrows::make_arrows($req->cfg, $down_url, $up_url, $refresh, $args, id => $my_id, id_prefix => "prodoptvaluemove");
+}
+
+sub tag_dboption_move {
+  my ($self, $req, $article, $roptions, $rindex, $args) = @_;
+
+  $$rindex >= 0 && $$rindex < @$roptions
+    or return "** dboption_move only in dboptions iterator **";
+
+  my $my_id = $roptions->[$$rindex]{id};
+  my $base_url = "$ENV{SCRIPT_NAME}?id=$article->{id}&option_id=$my_id&_csrfp=".$req->get_csrf_token("admin_move_option") . "&";
+
+  my $t = $req->cgi->param('_t');
+  $t && $t =~ /^\w+$/
+    and $base_url .= "_t=$t&";
+
+  my $up_url = '';
+  if ($$rindex > 0) {
+    $up_url = $base_url . "a_option_moveup=1";
+  }
+  my $down_url = '';
+  if ($$rindex < $#$roptions) {
+    $down_url = $base_url . "a_option_movedown=1";
+  }
+
+  my $refresh = $self->refresh_url($article, $req->cgi);
+
+  require BSE::Arrows;
+  return BSE::Arrows::make_arrows($req->cfg, $down_url, $up_url, $refresh, $args, id => $my_id, id_prefix => "prodoptmove");
+}
+
+=head1 Edit tags
+
+These a tags available on admin/edit_* pages specific to products.
+
+=over
+
+=item *
+
+product I<field> - display the given field from the product being edited.
+
+=item *
+
+iterator begin dboptions ... dboption I<field> ... iterator end dboptions
+
+- iterate over the existing database stored options for the product
+
+=item *
+
+dboption_move - display arrows to move the current dboption.  The span
+for the arrows is given an id of "prodoptmoveI<option-id>" by default.
+
+=item *
+
+iterator begin dboptionvalues ... dboptionvalue I<field> ... iterator end dboptionvalues
+
+- iterate over the values for the current dboption
+
+=item *
+
+dboptionvalue_move - display arrows to move the current dboption.  The
+span for the arrows is given an id of "prodoptvaluemoveI<value-id>"
+by default.
+
+=item *
+
+dboptionsjson - returns the product options as JSON.
+
+=back
+
+=cut
+
 sub low_edit_tags {
   my ($self, $acts, $req, $article, $articles, $msg, $errors) = @_;
 
@@ -72,6 +193,12 @@ sub low_edit_tags {
   my $cfg = $req->cfg;
   my $mbcs = $cfg->entry('html', 'mbcs', 0);
   my $tag_hash = $mbcs ? \&tag_hash_mbcs : \&hash_tag;
+  my $current_option;
+  my @dboptions;
+  my $dboption_index;
+  my @dboption_values;
+  my $dboption_value_index;
+  my $current_option_value;
   my $it = BSE::Util::Iterate->new;
   return 
     (
@@ -81,6 +208,36 @@ sub low_edit_tags {
      alloptions => join(",", sort keys %$product_opts),
      $it->make_iterator
      ([ \&iter_subs, $req ], 'subscription', 'subscriptions'),
+     $it->make
+     (
+      single => "dboption",
+      plural => "dboptions",
+      store => \$current_option,
+      data => \@dboptions,
+      index => \$dboption_index,
+      code => [ db_options => $article ],
+     ),
+     dboption_move =>
+     [
+      tag_dboption_move =>
+      $self, $req, $article, \@dboptions, \$dboption_index
+     ],
+     $it->make
+     (
+      single => "dboptionvalue",
+      plural => "dboptionvalues",
+      data => \@dboption_values,
+      index => \$dboption_value_index,
+      store => \$current_option_value,
+      code => [ iter_option_values => $self, \$current_option ],
+      nocache => 1,
+     ),
+     dboptionsjson => [ tag_dboptionsjson => $self, $article ],
+     dboptionvalue_move => 
+     [
+      tag_dboptionvalue_move =>
+      $self, $req, $article, \@dboption_values, \$dboption_value_index
+     ],
     );
 }
 
@@ -372,4 +529,1092 @@ sub type_default_value {
   return $self->SUPER::type_default_value($req, $col);
 }
 
+my %option_fields =
+  (
+   name =>
+   {
+    description => "Option name",
+    required => 1,
+    rules => "dh_one_line",
+   },
+   value1 =>
+   {
+    description => "Value 1",
+    rules => "dh_one_line",
+   },
+   value2 =>
+   {
+    description => "Value 2",
+    rules => "dh_one_line",
+   },
+   value3 =>
+   {
+    description => "Value 3",
+    rules => "dh_one_line",
+   },
+   value4 =>
+   {
+    description => "Value 4",
+    rules => "dh_one_line",
+   },
+   value5 =>
+   {
+    description => "Value 5",
+    rules => "dh_one_line",
+   },
+  );
+
+=head1 Targets
+
+Actions you can request from add.pl for products.
+
+=over
+
+=item a_add_option
+
+Add a new product option.
+
+On failure perform a service error.
+
+Requires _csrfp for admin_add_option
+
+For Ajax requests (or with a _ parameter) returns JSON like:
+
+  { 
+   success: 1,
+   option: { <option data> },
+   values: [ { value data }, { value data }, ... ]
+  }
+
+Parameters:
+
+=over
+
+=item *
+
+id - Article id
+
+=item *
+
+name - Name of the option (required)
+
+=item *
+
+value1 .. value5 - if any of these are non-blank they are added to the
+option as values.
+
+=back
+
+=cut
+
+sub req_add_option {
+  my ($self, $req, $article, $articles, $msg, $errors) = @_;
+
+  $req->check_csrf('admin_add_option')
+    or return $self->csrf_error($req, $article, "admin_add_option", "Add Product Option");
+
+  my %errors;
+  $req->validate(fields => \%option_fields,
+                errors => \%errors);
+  keys %errors
+    and return $self->_service_error($req, $article, $articles, undef, 
+                                    \%errors);
+
+  my $cgi = $req->cgi;
+  require BSE::TB::ProductOptions;
+  require BSE::TB::ProductOptionValues;
+  my $option = BSE::TB::ProductOptions->make
+    (
+     product_id => $article->{id},
+     name => scalar($cgi->param('name')),
+     display_order => time,
+    );
+
+  my $order = time;
+  my @values;
+  for my $value_key (sort grep /^value/, keys %option_fields) {
+    print STDERR "fetching $value_key\n";
+    my ($value) = $cgi->param($value_key);
+    if (defined $value && $value =~ /\S/) {
+      my $entry = BSE::TB::ProductOptionValues->make
+       (
+        product_option_id => $option->{id},
+        value => $value,
+        display_order => $order,
+       );
+      push @values, $entry;
+      ++$order;
+    }
+  }
+
+  $req->is_ajax
+    and return $req->json_content
+      (
+       success => 1,
+       option => $option->data_only,
+       values => [ map $_->data_only, @values ]
+      );
+
+  return $self->refresh($article, $cgi, undef, "Option added");
+}
+
+my %option_id =
+  (
+   option_id =>
+   {
+    rules => "required;positiveint",
+   },
+  );
+
+sub _get_option {
+  my ($self, $req, $article, $errors) = @_;
+
+  my $option;
+  my $cgi = $req->cgi;
+  $req->validate(fields => \%option_id,
+                errors => $errors);
+  unless ($errors->{option_id}) {
+    require BSE::TB::ProductOptions;
+    $option = BSE::TB::ProductOptions->getByPkey($cgi->param("option_id"));
+    $option
+      or $errors->{option_id} = "Unknown option id";
+  }
+  unless ($errors->{option_id}) {
+    $option->{product_id} = $article->{id}
+      or $errors->{option_id} = "Option doesn't belong to this product";
+  }
+  $errors->{option_id}
+    and return;
+
+  return $option;
+}
+
+sub _common_option {
+  my ($self, $template, $req, $article, $articles, $msg, $errors) = @_;
+
+  my %errors;
+  my $option = $self->_get_option($req, $article, \%errors);
+  keys %errors
+    and return $self->_service_error($req, $article, $articles, undef, \%errors);
+
+  my $it = BSE::Util::Iterate->new;
+  my %acts;
+  %acts =
+    (
+     $self->low_edit_tags(\%acts, $req, $article, $articles, $msg, $errors),
+     option => [ \&tag_hash, $option ],
+     $it->make
+     (
+      single => "dboptionvalue",
+      plural => "dboptionvalues",
+      code => [ iter_option_values => $self, \$option ],
+     ),
+    );
+
+  return $req->dyn_response($template, \%acts);
+}
+
+=item a_edit_option
+
+Produce a form to edit the given option.
+
+Parameters:
+
+=over
+
+=item *
+
+id - article id
+
+=item *
+
+option_id - option id.  This must belong to the product identified by
+id.
+
+=back
+
+Template: admin/prodopt_edit
+
+=cut
+
+sub req_edit_option {
+  my ($self, $req, $article, $articles, $msg, $errors) = @_;
+
+  return $self->_common_option('admin/prodopt_edit', $req, $article, 
+                              $articles, $msg, $errors);
+}
+
+my %option_name =
+  (
+   name =>
+   {
+    description => "Option name",
+    rules => "required;dh_one_line"
+   },
+   default_value =>
+   {
+    description => "Default Value",
+    rules => "integer"
+   }
+  );
+
+my %option_value =
+  (
+   description => "Value",
+   rules => "required;dh_one_line"
+  );
+
+=item a_save_option
+
+Saves changes to an option.
+
+On failure perform a service error.
+
+Requires _csrfp for admin_save_option
+
+For Ajax requests (or with a _ parameter), returns JSON like:
+
+  { 
+   success: 1,
+   option: { <option data> },
+   values: [ { value data, value data, ... } ]
+  }
+
+Parameters:
+
+=over
+
+=item *
+
+id - article id
+
+=item *
+
+option_id - id of the option to save, must belong to the product
+identified by id.
+
+=item *
+
+name - new value for the name field
+
+=item *
+
+default_value - id of the default value
+
+=item *
+
+save_enabled - if supplied and true, set enabled from the enabled
+parameter.
+
+=item *
+
+enabled - If supplied and true, enable the option, otherwise disable
+it.  Ignored unless save_enabled is true.
+
+=item *
+
+valueI<value-id> - set the displayed value for the value record
+identified by I<value-id>
+
+=back
+
+=cut
+
+sub req_save_option {
+  my ($self, $req, $article, $articles) = @_;
+
+  my $cgi = $req->cgi;
+
+  $req->check_csrf("admin_save_option")
+    or return $self->csrf_error($req, $article, "admin_save_option", "Save Product Option");
+
+  my %errors;
+  my $option = $self->_get_option($req, $article, \%errors);
+  keys %errors
+    and return $self->_service_error($req, $article, $articles, undef, \%errors);
+  $req->validate(rules => \%option_name,
+                errors => \%errors);
+  my @values = $option->values;
+  my %fields = map {; "value$_->{id}" => \%option_value } @values;
+  $req->validate(rules => \%fields,
+                errors => \%errors);
+  my $default_value = $cgi->param('default_value');
+  if (!$errors{default_value} && $default_value) {
+    grep $_->{id} == $default_value, @values
+      or $errors{default_value} = "Unknown value selected as default";
+  }
+  keys %errors
+    and return $self->req_edit_option($req, $article, $articles, undef, \%errors);
+
+  my $name = $cgi->param("name");
+  defined $name
+    and $option->set_name($name);
+  defined $default_value
+    and $option->set_default_value($default_value);
+  if ($cgi->param("save_enabled")) {
+    my $enabled = $cgi->param("enabled") ? 1 : 0;
+    $option->set_enabled($enabled);
+  }
+  $option->save;
+  for my $value (@values) {
+    my $new_value = $cgi->param("value$value->{id}");
+    if ($new_value ne $value->value) {
+      $value->set_value($new_value);
+      $value->save;
+    }
+  }
+
+  $req->is_ajax
+    and return $req->json_content
+      (
+       success => 1,
+       option => $option->data_only,
+       values => [ map $_->data_only, @values ],
+      );
+
+  return $self->refresh($article, $req->cgi, undef, "Option saved");
+}
+
+=item a_delconf_option
+
+Produce a form to confirm deletion of the given option.
+
+Parameters:
+
+=over
+
+=item *
+
+id - article id
+
+=item *
+
+option_id - option id.  This must belong to the product identified by
+id.
+
+=back
+
+Template: admin/prodopt_delete
+
+=cut
+
+sub req_delconf_option {
+  my ($self, $req, $article, $articles, $msg, $errors) = @_;
+
+  return $self->_common_option('admin/prodopt_delete', $req, $article, 
+                              $articles, $msg, $errors);
+}
+
+=item a_delete_option
+
+Delete the given option.
+
+On failure perform a service error.
+
+Requires _csrfp for admin_delete_option
+
+For Ajax requests (or with a _ parameter), returns JSON like:
+
+  { 
+   success: 1,
+  }
+
+=cut
+
+sub req_delete_option {
+  my ($self, $req, $article, $articles) = @_;
+
+  $req->check_csrf("admin_delete_option")
+    or return $self->csrf_error($req, $article, "admin_delete_option", "Delete Product Option");
+
+  my %errors;
+  my $option = $self->_get_option($req, $article, \%errors);
+  keys %errors
+    and return $self->_service_error($req, $article, $articles, undef, \%errors);
+  my @values = $option->values;
+
+  for my $value (@values) {
+    $value->remove;
+  }
+  $option->remove;
+
+  $req->is_ajax
+    and return $req->json_content
+      (
+       success => 1
+      );
+
+  return $self->refresh($article, $req->cgi, undef, "Option deleted");
+}
+
+
+my %add_option_value_fields =
+  (
+   option_id =>
+   {
+    description => "Option id",
+    rules => "required;positiveint",
+   },
+   value =>
+   {
+    description => "Value",
+    rules => "required",
+   },
+  );
+
+=item a_add_option_value
+
+Add a value to a product option.
+
+On failure perform a service error, see BSE::Edit::Article::_service_error.
+
+Requires _csrfp for admin_add_option_value
+
+For Ajax requests returns JSON like
+ { success: 1, value: (valueobject) }
+
+Standard redirect on success otherwise.
+
+Parameters:
+
+=over
+
+=item *
+
+id - article id
+
+=item *
+
+option_id - id of the option to add the value to
+
+=item *
+
+value - text of the value to add.
+
+=back
+
+=cut
+
+sub req_add_option_value {
+  my ($self, $req, $article, $articles, $msg, $errors) = @_;
+
+  $req->check_csrf("admin_add_option_value")
+    or return $self->csrf_error($req, $article, "admin_add_option_value", "Add Product Option Value");
+
+  my %errors;
+  $req->validate(fields => \%add_option_value_fields,
+                errors => \%errors);
+  my $option;
+  my $cgi = $req->cgi;
+  unless ($errors{option_id}) {
+    require BSE::TB::ProductOptions;
+    $option = BSE::TB::ProductOptions->getByPkey($cgi->param("option_id"));
+    defined $option && $option->{product_id}
+      or $errors{option_id} = "Bad option id - either unknown or for a different product";
+  }
+  keys %errors
+    and return $self->_service_error($req, $article, $articles, undef, \%errors);
+
+  my $value = $cgi->param("value");
+  require BSE::TB::ProductOptionValues;
+  my $entry = BSE::TB::ProductOptionValues->make
+    (
+     product_option_id => $option->{id},
+     value => $value,
+     display_order => time,
+    );
+
+  $req->is_ajax
+    and return $self->json_content
+      (
+       success => 1,
+       value => $entry->data_only
+      );
+
+  return $self->refresh($article, $cgi, undef, "Value added");
+}
+
+
+my %option_value_id =
+  (
+   value_id =>
+   {
+    rules => "required;positiveint",
+   },
+  );
+
+sub _get_option_value {
+  my ($self, $req, $article, $errors) = @_;
+
+  my $option_value;
+  my $cgi = $req->cgi;
+  $req->validate(fields => \%option_value_id,
+                errors => $errors);
+  unless ($errors->{value_id}) {
+    require BSE::TB::ProductOptionValues;
+    $option_value = BSE::TB::ProductOptionValues->getByPkey($cgi->param("value_id"));
+    $option_value
+      or $errors->{value_id} = "Unknown option value id";
+  }
+  my $option;
+  unless ($errors->{value_id}) {
+    $option = $option_value->option;
+    defined $option && $option->{product_id} == $article->{id}
+      or $errors->{value_id} = "Value has no option or doesn't belong to the product";
+  }
+
+  $errors->{value_id}
+    and return;
+
+  return wantarray ? ( $option_value, $option ) : $option_value ;
+}
+
+sub _common_option_value {
+  my ($self, $template, $req, $article, $articles, $msg, $errors) = @_;
+
+  my %errors;
+  my ($option_value, $option) = $self->_get_option_value($req, $article, \%errors);
+  keys %errors
+    and return $self->_service_error($req, $article, $articles, undef, \%errors);
+
+  my %acts;
+  %acts =
+    (
+     $self->low_edit_tags(\%acts, $req, $article, $articles, $msg, $errors),
+     option_value => [ \&tag_hash, $option_value ],
+     option => [ \&tag_hash, $option ],
+    );
+
+  return $req->dyn_response($template, \%acts);
+}
+
+=item a_edit_option_value
+
+Displays a form to edit the value for a given option.
+
+Parameters:
+
+=over
+
+=item *
+
+id - id of the product
+
+=item *
+
+value_id - id of he product option value to edit, must belong to the
+given product.
+
+=back
+
+Template: admin/prodopt_value_edit
+
+=cut
+
+sub req_edit_option_value {
+  my ($self, $req, $article, $articles, $msg, $errors) = @_;
+
+  return $self->_common_option_value('admin/prodopt_value_edit', $req,
+                                    $article, $articles, $msg, $errors);
+}
+
+my %save_option_value_fields =
+  (
+   value => 
+   {
+    rules => "required;dh_one_line",
+   },
+  );
+
+=item a_save_option_value
+
+Saves changes to an option.
+
+On failure perform a service error.
+
+Requires _csrfp for admin_save_option_value
+
+For Ajax requests (or with a _ parameter), returns JSON like:
+
+  { 
+   success: 1,
+   value: { value data }
+  }
+
+Parameters:
+
+=over
+
+=item *
+
+id - article id
+
+=item *
+
+value_id - id of the value to save, must belong to the product
+identified by id.
+
+=item *
+
+value - new displayed value for the option value.
+
+=back
+
+=cut
+
+sub req_save_option_value {
+  my ($self, $req, $article, $articles, $msg, $errors) = @_;
+
+  $req->check_csrf("admin_save_option_value")
+    or return $self->csrf_error($req, $article, "admin_save_option_value", "Save Product Option Value");
+
+  my %errors;
+  $req->validate(fields => \%save_option_value_fields,
+                errors => \%errors);
+  my $option_value = $self->_get_option_value($req, $article, \%errors);
+  keys %errors
+    and return $self->_service_error($req, $article, $articles, undef, \%errors);
+
+  my $cgi = $req->cgi;
+  $option_value->{value} = $cgi->param("value");
+  $option_value->save;
+
+  $req->is_ajax
+    and return $self->json_content
+      (
+       success => 1,
+       value => $option_value->data_only
+      );
+
+  return $self->refresh($article, $cgi, undef, "Value saved");
+}
+
+=item a_confdel_option_value
+
+Displays a page confirming deletion of a product option value.
+
+Parameters:
+
+=over
+
+=item *
+
+id - article id
+
+=item *
+
+value_id - option value id
+
+=back
+
+Template: admin/prodopt_value_delete
+
+=cut
+
+sub req_confdel_option_value {
+  my ($self, $req, $article, $articles, $msg, $errors) = @_;
+
+  return $self->_common_option_value('admin/prodopt_value_delete', $req,
+                                    $article, $articles, $msg, $errors);
+}
+
+=item a_delete_option_value
+
+Deletes a product option.
+
+On failure perform a service error.
+
+Requires _csrfp for admin_delete_option_value
+
+For Ajax requests (or with a _ parameter), returns JSON like:
+
+  { 
+   success: 1,
+  }
+
+Parameters:
+
+=over
+
+=item *
+
+id - article id
+
+=item *
+
+value_id - id of the value to delete, must belong to the product
+identified by id.
+
+=back
+
+=cut
+
+sub req_delete_option_value {
+  my ($self, $req, $article, $articles, $msg, $errors) = @_;
+
+  $req->check_csrf("admin_delete_option_value")
+    or return $self->csrf_error($req, $article, "admin_delete_option_value", "Delete Product Option Value");
+
+  my %errors;
+  my $option_value = $self->_get_option_value($req, $article, \%errors);
+  keys %errors
+    and return $self->_service_error($req, $article, $articles, undef, \%errors);
+
+  $option_value->remove;
+
+  $req->is_ajax
+    and return $self->json_content
+      (
+       success => 1
+      );
+
+  return $self->refresh($article, $req->cgi, undef, "Value removed");
+}
+
+sub tag_dboptionsjson {
+  my ($self, $article) = @_;
+
+  my @result;
+  my @options = $article->db_options;
+  my @opt_cols = BSE::TB::ProductOption->columns;
+  for my $option (@options) {
+    my $entry = $option->data_only;
+    $entry->{values} = [ map $_->data_only, $option->values ];
+    push @result, $entry;
+  }
+
+  require JSON;
+  my $json = JSON->new;
+  return $json->encode(\@result);
+}
+
+sub _option_move {
+  my ($self, $req, $article, $articles, $direction) = @_;
+
+  $req->check_csrf("admin_move_option")
+    or return $self->csrf_error($req, $article, "admin_move_option", "Move Product Option");
+
+  my %errors;
+  my $option = $self->_get_option($req, $article, \%errors);
+  keys %errors
+    and return $self->_service_error($req, $article, $articles, undef, \%errors);
+  my @options = $article->db_options;
+  my ($index) = grep $options[$_]{id} == $option->{id}, 0 .. $#options
+    or return $self->_service_error($req, $article, $articles, "Unknown option id");
+
+  $options[$index] = $option;
+
+  my $other_index = $index + $direction;
+  $other_index >= 0 && $other_index < @options
+    or return $self->_service_error($req, $article, $articles, "Can't move option beyond end");
+
+  my $other = $options[$other_index];
+
+  ($option->{display_order}, $other->{display_order}) =
+    ($other->{display_order}, $option->{display_order});
+  $option->save;
+  $other->save;
+
+  $req->is_ajax
+    and $req->json_content
+      (
+       success => 1,
+       order => [ map $_->{id}, @options ]
+      );
+
+  return $self->refresh($article, $req->cgi, undef, "Option moved");
+}
+
+=item a_option_move_up
+
+=item a_option_move_down
+
+Move a product option up/down through the options for a product.
+
+On failure perform a service error.
+
+Requires _csrfp for admin_move_option
+
+For Ajax requests (or with a _ parameter), returns JSON like:
+
+  {
+   success: 1,
+   order: [ list of option ids ]
+  }
+
+Parameters:
+
+=over
+
+=item *
+
+id - article id
+
+=item *
+
+option_id - option id.  This must belong to the product identified by
+id.
+
+=back
+
+=cut
+
+sub req_option_moveup {
+  my ($self, $req, $article, $articles) = @_;
+
+  return $self->_option_move($req, $article, $articles, -1);
+}
+
+sub req_option_movedown {
+  my ($self, $req, $article, $articles) = @_;
+
+  return $self->_option_move($req, $article, $articles, 1);
+}
+
+=item a_option_reorder
+
+Move a product option up/down through the options for a product.
+
+On failure perform a service error.
+
+Requires _csrfp for admin_move_option
+
+For Ajax requests (or with a _ parameter), returns JSON like:
+
+  {
+   success: 1,
+   order: [ list of option ids ]
+  }
+
+Parameters:
+
+=over
+
+=item *
+
+id - article id
+
+=item *
+
+option_ids - option ids separated by commas.  These must belong to the
+product identified by id.
+
+=back
+
+=cut
+
+sub req_option_reorder {
+  my ($self, $req, $article, $articles) = @_;
+
+  $req->check_csrf("admin_move_option")
+    or return $self->csrf_error($req, $article, "admin_move_option", "Move Product Option");
+
+  my @options = $article->db_options;
+  my @order = map { split ',' } $req->cgi->param('option_ids');
+  my %options = map { $_->{id} => $_ } @options;
+  my @new_options;
+  for my $id (@order) {
+    my $option = delete $options{$id}
+      or next;
+    push @new_options, $option;
+  }
+  push @new_options, sort { $a->{display_order} <=> $b->{display_order} } values %options;
+  my @display_order = map $_->{display_order}, @options;
+  for my $index (0 .. $#new_options) {
+    $new_options[$index]{display_order} = $display_order[$index];
+    $new_options[$index]->save;
+  }
+
+  $req->is_ajax
+    and return $req->json_content
+      (
+       success => 1,
+       order => [ map $_->{id}, @new_options ]
+      );
+
+  return $self->refresh($article, $req->cgi, undef, "Options reordered");
+}
+
+sub _option_value_move {
+  my ($self, $req, $article, $articles, $direction) = @_;
+
+  $req->check_csrf("admin_move_option_value")
+    or return $self->csrf_error($req, $article, "admin_move_option_value", "Move Product Option Value");
+
+  my %errors;
+  my ($option_value, $option) = $self->_get_option_value($req, $article, \%errors);
+  keys %errors
+    and return $self->_service_error($req, $article, $articles, undef, \%errors);
+  my @values = $option->values;
+  my ($index) = grep $values[$_]{id} == $option_value->{id}, 0 .. $#values
+    or return $self->_service_error($req, $article, $articles, "Unknown option value id");
+
+  $values[$index] = $option_value;
+
+  my $other_index = $index + $direction;
+  $other_index >= 0 && $other_index < @values
+    or return $self->_service_error($req, $article, $articles, "Can't move option value beyond end");
+
+  my $other = $values[$other_index];
+
+  ($option_value->{display_order}, $other->{display_order}) =
+    ($other->{display_order}, $option_value->{display_order});
+  $option_value->save;
+  $other->save;
+
+  $req->is_ajax
+    and $req->json_content
+      (
+       success => 1,
+       order => [ map $_->{id}, @values ]
+      );
+
+  return $self->refresh($article, $req->cgi, undef, "Value moved");
+}
+
+=item a_option_value_moveup
+
+=item a_option_value_movedown
+
+Move a product option value up/down through the values for a product
+option.
+
+On failure perform a service error.
+
+Requires _csrfp for admin_move_option_value
+
+For Ajax requests (or with a _ parameter), returns JSON like:
+
+  {
+   success: 1,
+   order: [ list of value ids ]
+  }
+
+Parameters:
+
+=over
+
+=item *
+
+id - article id
+
+=item *
+
+value_id - option id.  This must belong to the product identified by
+id.
+
+=back
+
+=cut
+
+sub req_option_value_moveup {
+  my ($self, $req, $article, $articles) = @_;
+
+  return $self->_option_value_move($req, $article, $articles, -1);
+}
+
+sub req_option_value_movedown {
+  my ($self, $req, $article, $articles) = @_;
+
+  return $self->_option_value_move($req, $article, $articles, 1);
+}
+
+=item a_option_value_reorder
+
+Specify a new order for the values belonging to a product option.
+
+On failure perform a service error.
+
+Requires _csrfp for admin_move_option_value
+
+For Ajax requests (or with a _ parameter), returns JSON like:
+
+  {
+   success: 1,
+   order: [ list of value ids ]
+  }
+
+Parameters:
+
+=over
+
+=item *
+
+id - article id
+
+=item *
+
+value_ids - new order for values specified as value ids separated by
+commas.
+
+=back
+
+=cut
+
+sub req_option_value_reorder {
+  my ($self, $req, $article, $articles) = @_;
+
+  $req->check_csrf("admin_move_option_value")
+    or return $self->csrf_error($req, $article, "admin_move_option_value", "Move Product Option Value");
+
+  my %errors;
+  my $option = $self->_get_option($req, $article, \%errors);
+  keys %errors
+    and return $self->_service_error($req, $article, $articles, undef, \%errors);
+  my @order = map { split ',' } $req->cgi->param('value_ids');
+  my @values = $option->values;
+  my %values = map { $_->{id} => $_ } @values;
+  my @new_values;
+  for my $id (@order) {
+    my $value = delete $values{$id}
+      or next;
+    push @new_values, $value;
+  }
+  push @new_values, sort { $a->{display_order} <=> $b->{display_order} } values %values;
+  my @display_order = map $_->{display_order}, @values;
+  for my $index (0 .. $#new_values) {
+    $new_values[$index]{display_order} = $display_order[$index];
+    $new_values[$index]->save;
+  }
+
+  $req->is_ajax
+    and return $req->json_content
+      (
+       success => 1,
+        option => $option->data_only,
+       order => [ map $_->{id}, @new_values ]
+      );
+
+  return $self->refresh($article, $req->cgi, undef, "Values reordered");
+}
+
+sub article_actions {
+  my $self = shift;
+
+  return
+    (
+     $self->SUPER::article_actions,
+     a_add_option => 'req_add_option',
+     a_confdel_option => 'req_confdel_option',
+     a_del_option => 'req_del_option',
+     a_edit_option => 'req_edit_option',
+     a_save_option => 'req_save_option',
+     a_delconf_option => 'req_delconf_option',
+     a_delete_option => 'req_delete_option',
+     a_get_option => 'req_get_option',
+     a_edit_option_value => 'req_edit_option_value',
+     a_save_option_value => 'req_save_option_value',
+     a_confdel_option_value => 'req_confdel_option_value',
+     a_delete_option_value => 'req_delete_option_value',
+     a_add_option_value => 'req_add_option_value',
+     a_option_value_moveup => 'req_option_value_moveup',
+     a_option_value_movedown => 'req_option_value_movedown',
+     a_option_value_reorder => 'req_option_value_reorder',
+     a_option_moveup => 'req_option_moveup',
+     a_option_movedown => 'req_option_movedown',
+     a_option_reorder => 'req_option_reorder',
+    );
+}
+
 1;
index 63b0ac99c141a41646c53dc6df8fe8c09293873e..4da8188e0f2af2e3072e16929b80caa084d3042e 100644 (file)
@@ -685,4 +685,131 @@ sub recaptcha_result {
   $_[0]{recaptcha_result};
 }
 
+=item json_content
+
+Generate a hash suitable for output_result() as JSON.
+
+=cut
+
+sub json_content {
+  my ($self, @values) = @_;
+
+  require JSON;
+
+  my $json = JSON->new;
+
+  my $value = @values > 1 ? +{ @values } : $values[0];
+
+  return
+    +{
+      type => "application/json",
+      content => $json->encode($value),
+     };
+}
+
+=item get_csrf_token($name)
+
+Generate a csrf token for the given name.
+
+=cut
+
+my $sequence = 0;
+
+sub get_csrf_token {
+  my ($req, $name) = @_;
+
+  my $cache = $req->session->{csrfp};
+  my $max_age = $req->cfg->entry('basic', 'csrfp_max_age', 3600);
+  my $now = time;
+  
+  my $entry = $cache->{$name};
+  if (!$entry || $entry->{time} + $max_age < $now) {
+    if ($entry) {
+      $entry->{oldtoken} = $entry->{token};
+      $entry->{oldtime} = $entry->{time};
+    }
+    else {
+      $entry = {};
+    }
+
+    # this doesn't need to be so perfectly secure that we drain the
+    # entropy pool and it'll be called fairly often
+    require Digest::MD5;
+    $entry->{token} =
+      Digest::MD5::md5_hex($now . $$ . rand() . $sequence++ . $name);
+    $entry->{time} = $now;
+  }
+  $cache->{$name} = $entry;
+  $req->session->{csrfp} = $cache;
+
+  return $entry->{token};
+}
+
+=item check_csrf($name)
+
+Check if the CSRF token supplied by the form is valid.
+
+$name should be the name supplied to the csrfp token.
+
+=cut
+
+sub check_csrf {
+  my ($self, $name) = @_;
+
+  defined $name
+    or confess "No CSRF token name supplied";
+
+  my $debug = $self->cfg->entry('debug', 'csrf', 0);
+
+  # the form might have multiple submit buttons, each initiating a
+  # different function, so the the form should supply tokens for every
+  # function for the form
+  my @tokens = $self->cgi->param('_csrfp');
+  unless (@tokens) {
+    $self->_csrf_error("No _csrfp token supplied");
+    return;
+  }
+
+  my $entry = $self->session->{csrfp}{$name};
+  unless ($entry) {
+    $self->_csrf_error("No token entry found for $name");
+    return;
+  }
+  
+  my $max_age = $self->cfg->entry('basic', 'csrfp_max_age', 3600);
+  my $now = time;
+  for my $token (@tokens) {
+    if ($entry->{token} 
+       && $entry->{token} eq $token
+       && $entry->{time} + 2*$max_age >= $now) {
+      $debug
+       and print STDERR "CSRF: match current token\n";
+      return 1;
+    }
+
+    if ($entry->{oldtoken}
+       && $entry->{oldtoken} eq $token
+       && $entry->{oldtime} + 2*$max_age >= $now) {
+      return 1;
+    }
+  }
+
+  $self->_csrf_error("No tokens matched the $name entry");
+  return;
+}
+
+sub _csrf_error {
+  my ($self, $message) = @_;
+
+  $self->cfg->entry('debug', 'csrf', 0)
+    and print STDERR "csrf error: $message\n";
+  $self->{csrf_error} = $message;
+
+  return;
+}
+
+sub csrf_error {
+  $_[0]{csrf_error};
+}
+
 1;
index 9716a31b07f91236bc93934ca63c6d9f2b9f42a4..c1bcdaa8c322f78be77155bda0e681030954d04b 100644 (file)
@@ -4,11 +4,11 @@ use vars qw(@ISA @EXPORT_OK);
 @ISA = qw/Exporter/;
 @EXPORT_OK = qw/shop_cart_tags cart_item_opts nice_options shop_nice_options
                 total shop_total load_order_fields basic_tags need_logon
-                get_siteuser payment_types/;
+                get_siteuser payment_types order_item_opts/;
 use Constants qw/:shop/;
 use BSE::Util::SQL qw(now_sqldate);
 use BSE::Util::Tags;
-use BSE::CfgInfo qw(custom_class product_options);
+use BSE::CfgInfo qw(custom_class);
 use Carp 'confess';
 use DevHelp::HTML qw(escape_html);
 
@@ -123,30 +123,51 @@ sub tag_location {
 sub cart_item_opts {
   my ($req, $cart_item, $product) = @_;
 
-  my $avail_options = product_options($req->cfg);
-
-  my @options = ();
-  my @values = split /,/, $cart_item->{options};
-  my @ids = split /,/, $product->{options};
-  for my $opt_index (0 .. $#ids) {
-    my $entry = $avail_options->{$ids[$opt_index]};
-    my $option = {
-                 id=>$ids[$opt_index],
-                 value=>$values[$opt_index],
-                 desc => $entry->{desc} || $ids[$opt_index],
-                };
-    if ($entry->{labels}) {
-      $option->{label} = $entry->{labels}{$values[$opt_index]};
-    }
-    else {
-      $option->{label} = $option->{value};
-    }
-    push(@options, $option);
-  }
+  my @option_descs = $product->option_descs($req->cfg, $cart_item->{options});
 
+  my @options;
+  my $index = 0;
+  for my $option (@option_descs) {
+    my $out_opt =
+      {
+       id => $option->{name},
+       value => $option->{value},
+       desc => $option->{desc},
+       label => $option->{display}
+      };
+
+    push @options, $out_opt;
+    ++$index;
+  }
+  
   return @options;
 }
 
+sub order_item_opts {
+  my ($req, $order_item, $product) = @_;
+  
+  if (length $order_item->{options}) {
+    my @values = split /,/, $order_item->options;
+    return map
+      +{
+       id => $_->{id},
+       value => $_->{value},
+       desc => $_->{desc},
+       label => $_->{display},
+       }, $product->option_descs($req->cfg, \@values);
+  }
+  else {
+    my @options = $order_item->option_list;
+    return map
+      +{
+       id => $_->original_id,
+       value => $_->value,
+       desc => $_->name,
+       label => $_->display
+       }, @options;
+  }
+}
+
 sub nice_options {
   my (@options) = @_;
 
index ce398b0aec267576715641de3c59d3ee57a4b551..716acbd127b4531f48baa0770b7a08fa0655f1ac 100644 (file)
@@ -12,4 +12,12 @@ sub columns {
             session_id product_code/;
 }
 
+sub option_list {
+  my ($self) = @_;
+
+  require BSE::TB::OrderItemOptions;
+  return sort { $a->{display_order} <=> $b->{display_order} }
+    BSE::TB::OrderItemOptions->getBy(order_item_id => $self->{id});
+}
+
 1;
diff --git a/site/cgi-bin/modules/BSE/TB/OrderItemOption.pm b/site/cgi-bin/modules/BSE/TB/OrderItemOption.pm
new file mode 100644 (file)
index 0000000..a30ba69
--- /dev/null
@@ -0,0 +1,13 @@
+package BSE::TB::OrderItemOption;
+use strict;
+use base 'Squirrel::Row';
+
+sub table {
+  "bse_order_item_options";
+}
+
+sub columns {
+  return qw/id order_item_id original_id name value display display_order/;
+}
+
+1;
diff --git a/site/cgi-bin/modules/BSE/TB/OrderItemOptions.pm b/site/cgi-bin/modules/BSE/TB/OrderItemOptions.pm
new file mode 100644 (file)
index 0000000..66b64a0
--- /dev/null
@@ -0,0 +1,10 @@
+package BSE::TB::OrderItemOptions;
+use strict;
+use base 'Squirrel::Table';
+use BSE::TB::OrderItemOption;
+
+sub rowClass {
+  'BSE::TB::OrderItemOption';
+}
+
+1;
diff --git a/site/cgi-bin/modules/BSE/TB/ProductOption.pm b/site/cgi-bin/modules/BSE/TB/ProductOption.pm
new file mode 100644 (file)
index 0000000..477bcea
--- /dev/null
@@ -0,0 +1,36 @@
+package BSE::TB::ProductOption;
+use strict;
+use base 'Squirrel::Row';
+
+sub columns {
+  return qw/id product_id name type global_ref display_order enabled default_value/;
+}
+
+sub table {
+  "bse_product_options";
+}
+
+sub defaults {
+  return
+    (
+     global_ref => undef,
+     enabled => 1,
+     type => "select",
+     default_value => 0,
+    );
+}
+
+sub values {
+  my ($self) = @_;
+
+  require BSE::TB::ProductOptionValues;
+  return sort { $a->{display_order} <=> $b->{display_order} }
+    BSE::TB::ProductOptionValues->getBy(product_option_id => $self->{id});
+}
+
+sub key {
+  my $self = shift;
+  return "prodopt_" . $self->id;
+}
+
+1;
diff --git a/site/cgi-bin/modules/BSE/TB/ProductOptionValue.pm b/site/cgi-bin/modules/BSE/TB/ProductOptionValue.pm
new file mode 100644 (file)
index 0000000..fde73a1
--- /dev/null
@@ -0,0 +1,20 @@
+package BSE::TB::ProductOptionValue;
+use strict;
+use base "Squirrel::Row";
+
+sub columns {
+  return qw/id product_option_id value display_order/;
+}
+
+sub table {
+  "bse_product_option_values";
+}
+
+sub option {
+  my ($self) = @_;
+
+  require BSE::TB::ProductOptions;
+  return BSE::TB::ProductOptions->getByPkey($self->{product_option_id});
+}
+
+1;
diff --git a/site/cgi-bin/modules/BSE/TB/ProductOptionValues.pm b/site/cgi-bin/modules/BSE/TB/ProductOptionValues.pm
new file mode 100644 (file)
index 0000000..51ba9ad
--- /dev/null
@@ -0,0 +1,10 @@
+package BSE::TB::ProductOptionValues;
+use strict;
+use base 'Squirrel::Table';
+use BSE::TB::ProductOptionValue;
+
+sub rowClass {
+  'BSE::TB::ProductOptionValue';
+}
+
+1;
diff --git a/site/cgi-bin/modules/BSE/TB/ProductOptions.pm b/site/cgi-bin/modules/BSE/TB/ProductOptions.pm
new file mode 100644 (file)
index 0000000..7c8abef
--- /dev/null
@@ -0,0 +1,10 @@
+package BSE::TB::ProductOptions;
+use strict;
+use base 'Squirrel::Table';
+use BSE::TB::ProductOption;
+
+sub rowClass {
+  'BSE::TB::ProductOption';
+}
+
+1;
index 883f18ece9c4693503c61cfc1e6e6869b3a7ff26..4c1f97f697fe6da063d5113f4219ff48d8e6d0da 100644 (file)
@@ -17,7 +17,7 @@ use BSE::Util::Iterate;
 use BSE::WebUtil 'refresh_to_admin';
 use DevHelp::HTML qw(:default popup_menu);
 use BSE::Arrows;
-use BSE::CfgInfo 'product_options';
+use BSE::Shop::Util qw(order_item_opts nice_options);
 
 my %actions =
   (
@@ -556,44 +556,6 @@ sub req_order_list_incomplete {
                        'Order list - Incomplete orders', @orders);
 }
 
-sub cart_item_opts {
-  my ($req, $cart_item, $product) = @_;
-
-  my $avail_options = product_options($req->cfg);
-
-  my @options = ();
-  my @values = split /,/, $cart_item->{options};
-  my @ids = split /,/, $product->{options};
-  for my $opt_index (0 .. $#ids) {
-    my $entry = $avail_options->{$ids[$opt_index]};
-    my $option = {
-                 id=>$ids[$opt_index],
-                 value=>$values[$opt_index],
-                 desc => $entry->{desc} || $ids[$opt_index],
-                };
-    if ($entry->{labels}) {
-      $option->{label} = $entry->{labels}{$values[$opt_index]};
-    }
-    else {
-      $option->{label} = $option->{value};
-    }
-    push(@options, $option);
-  }
-
-  return @options;
-}
-
-sub nice_options {
-  my (@options) = @_;
-
-  if (@options) {
-    return '('.join(", ", map("$_->{desc} $_->{label}", @options)).')';
-  }
-  else {
-    return '';
-  }
-}
-
 sub tag_siteuser {
   my ($order, $rsiteuser, $arg) = @_;
 
@@ -637,24 +599,14 @@ sub req_order_detail {
        sub { 
         if (++$line_index < @lines ) {
           $option_index = -1;
-          @options = cart_item_opts($req,
-                                    $lines[$line_index],
-                                    $products[$line_index]);
+          @options = order_item_opts($req,
+                                     $lines[$line_index],
+                                     $products[$line_index]);
           return 1;
         }
         return 0;
        },
        order => [ \&tag_hash, $order ],
-       #money => 
-       #sub { 
-#       my ($func, $args) = split ' ', $_[0], 2;
-#       return sprintf("%.2f", $acts{$func}->($args)/100.0)
-#       },
-#       date =>
-#       sub {
-#       my ($func, $args) = split ' ', $_[0], 2;
-#       return display_date($acts{$func}->($args));
-#       },
        extension =>
        sub {
         sprintf("%.2f", $lines[$line_index]{units} * $lines[$line_index]{$_[0]}/100.0)
index 9322d8dca3b6f4e1ef59f5ef44a07810d2aed7a5..b08979eed75238da6315b339ecd96e198fb7f79d 100644 (file)
@@ -4,7 +4,7 @@ use base 'BSE::UI::Dispatch';
 use DevHelp::HTML;
 use BSE::Util::SQL qw(now_sqldate now_sqldatetime);
 use BSE::Shop::Util qw(need_logon shop_cart_tags payment_types nice_options 
-                       cart_item_opts basic_tags);
+                       cart_item_opts basic_tags order_item_opts);
 use BSE::CfgInfo qw(custom_class credit_card_class);
 use BSE::TB::Orders;
 use BSE::TB::OrderItems;
@@ -158,7 +158,7 @@ sub req_add {
 
   my $found;
   for my $item (@cart) {
-    $item->{productId} eq $product->{id} && $item->{options} eq $options
+    $item->{productId} eq $product->{id} && _same_options($item->{options}, $options)
       or next;
 
     ++$found;
@@ -227,7 +227,7 @@ sub req_addsingle {
  
   my $found;
   for my $item (@cart) {
-    $item->{productId} eq $addid && $item->{options} eq $options
+    $item->{productId} eq $addid && _same_options($item->{options}, $options)
       or next;
 
     ++$found;
@@ -310,7 +310,7 @@ sub req_addmultiple {
     my @cart = @{$req->session->{cart}};
     $started_empty = @cart == 0;
     for my $item (@cart) {
-      $item->{options} eq '' or next;
+      @{$item->{options}} == 0 or next;
 
       my $addition = delete $additions{$item->{productId}}
        or next;
@@ -336,7 +336,7 @@ sub req_addmultiple {
         productId => $product->{id},
         units => $addition->{quantity}, 
         price=>$product->{retailPrice},
-        options=>'',
+        options=>[],
         %{$addition->{extras}},
        };
     }
@@ -735,17 +735,36 @@ sub req_payment {
   for my $row_num (0..$#items) {
     my $item = $items[$row_num];
     my $product = $products[$row_num];
-    $item->{orderId} = $order->{id};
-    $item->{max_lapsed} = 0;
+    my %item = %$item;
+    $item{orderId} = $order->{id};
+    $item{max_lapsed} = 0;
     if ($product->{subscription_id} != -1) {
       my $sub = $product->subscription;
-      $item->{max_lapsed} = $sub->{max_lapsed} if $sub;
+      $item{max_lapsed} = $sub->{max_lapsed} if $sub;
     }
-    defined $item->{session_id} or $item->{session_id} = 0;
-    my @data = @{$item}{@item_cols};
-    
+    defined $item{session_id} or $item{session_id} = 0;
+    $item{options} = ""; # not used for new orders
+    my @data = @item{@item_cols};
     shift @data;
-    push(@dbitems, BSE::TB::OrderItems->add(@data));
+    my $dbitem = BSE::TB::OrderItems->add(@data);
+    push @dbitems, $dbitem;
+
+    if ($item->{options} and @{$item->{options}}) {
+      require BSE::TB::OrderItemOptions;
+      my @option_descs = $product->option_descs($cfg, $item->{options});
+      my $display_order = 1;
+      for my $option (@option_descs) {
+       BSE::TB::OrderItemOptions->make
+           (
+            order_item_id => $dbitem->{id},
+            original_id => $option->{id},
+            name => $option->{desc},
+            value => $option->{value},
+            display => $option->{display},
+            display_order => $display_order++,
+           );
+      }
+    }
 
     my $sub = $product->subscription;
     if ($sub) {
@@ -756,10 +775,11 @@ sub req_payment {
       my $user = $req->siteuser;
       require BSE::TB::SeminarSessions;
       my $session = BSE::TB::SeminarSessions->getByPkey($item->{session_id});
+      my $options = join(",", @{$item->{options}});
       eval {
        $session->add_attendee($user, 
                               instructions => $order->{instructions},
-                              options => $item->{options});
+                              options => $options);
       };
     }
   }
@@ -895,9 +915,7 @@ sub req_orderdone {
      sub { 
        if (++$item_index < @items) {
         $option_index = -1;
-        @options = cart_item_opts($req, 
-                                  $items[$item_index], 
-                                  $products[$item_index]);
+        @options = order_item_opts($req, $items[$item_index]);
         undef $sem_session;
         undef $location;
         $item = $items[$item_index];
@@ -1082,9 +1100,9 @@ sub _send_order {
      sub { 
        if (++$item_index < @$items) {
         $option_index = -1;
-        @options = cart_item_opts($req,
-                                  $items->[$item_index], 
-                                  $products->[$item_index]);
+        @options = order_item_opts($req,
+                                   $items->[$item_index], 
+                                   $products->[$item_index]);
         return 1;
        }
        return 0;
@@ -1452,10 +1470,11 @@ sub _validate_add {
 
   # collect the product options
   my @options;
-  my @opt_names = split /,/, $product->{options};
+  my @option_descs =  $product->option_descs($req->cfg);
+  my @option_names = map $_->{name}, @option_descs;
   my @not_def;
   my $cgi = $req->cgi;
-  for my $name (@opt_names) {
+  for my $name (@option_names) {
     my $value = $cgi->param($name);
     push @options, $value;
     unless (defined $value) {
@@ -1466,7 +1485,6 @@ sub _validate_add {
     $$error = "Some product options (@not_def) not supplied";
     return;
   }
-  my $options = join(",", @options);
   
   # the product must be non-expired and listed
   (my $comp_release = $product->{release}) =~ s/ .*//;
@@ -1488,8 +1506,8 @@ sub _validate_add {
   # used to refresh if a logon is needed
   my $securlbase = $req->cfg->entryVar('site', 'secureurl');
   my $r = $securlbase . $ENV{SCRIPT_NAME} . "?add=1&id=$product->{id}";
-  for my $opt_index (0..$#opt_names) {
-    $r .= "&$opt_names[$opt_index]=".escape_uri($options[$opt_index]);
+  for my $opt_index (0..$#option_names) {
+    $r .= "&$option_names[$opt_index]=".escape_uri($options[$opt_index]);
   }
   
   my $user = $req->siteuser;
@@ -1582,7 +1600,7 @@ sub _validate_add {
     $extras{session_id} = $session_id;
   }
 
-  return ( $product, $options, \%extras );
+  return ( $product, \@options, \%extras );
 }
 
 sub _add_refresh {
@@ -1634,4 +1652,19 @@ sub _add_refresh {
   return BSE::Template->get_refresh($refresh, $cfg);
 }
 
+sub _same_options {
+  my ($left, $right) = @_;
+
+  for my $index (0 .. $#$left) {
+    my $left_value = $left->[$index];
+    my $right_value = $right->[$index];
+    defined $right_value
+      or return;
+    $left_value eq $right_value
+      or return;
+  }
+
+  return 1;
+}
+
 1;
index add858ac3eb1ebccccf2b07bba515f174401d438..5dbe55fa334fdec3f7ae07d119fe8b9fd813e14c 100644 (file)
@@ -482,6 +482,43 @@ sub basic {
     );
 }
 
+sub common {
+  my ($class, $req) = @_;
+
+  return
+    (
+     BSE::Util::Tags->basic(undef, $req->cgi, $req->cfg),
+     csrfp => [ \&tag_csrfp, $req ],
+    );
+}
+
+=item tag csrfp
+
+Generate a token that can be used to prevent cross-site request
+forgery.
+
+Takes a single argument, the action to be authenticated.
+
+=cut
+
+sub tag_csrfp {
+  my ($req, $args) = @_;
+
+  $args
+    or return "** missing required argument **";
+
+  my ($name, $type) = split ' ', $args;
+  defined $type
+    or $type = 'plain';
+
+  my $token = $req->get_csrf_token($name);
+
+  $type eq "plain" and return $token;
+  $type eq "hidden" and return
+    qq(<input type="hidden" name="_csrfp" value="$token" />);
+  return "** unknown csrfp type $type **";
+}
+
 sub make_iterator {
   my ($class, $array, $single, $plural, $saveto) = @_;
 
index 1d834e3aa1d3b6cfb6c2afa64d45d64ba95bdc84..4da908c92f159e09758ac88eb8d0bbfe2d71d0fd 100644 (file)
@@ -96,6 +96,21 @@ sub tag_moveallcat {
   return make_arrows($self->{cfg}, $down_url, $up_url, $refreshto, $img_prefix);
 }
 
+sub tag_ifAnyProductOptions {
+  my ($self, $lookup, $arg) = @_;
+
+  $arg ||= "product";
+
+  my $entry = $lookup->{$arg}
+    or die "** No such product $arg **";
+  my ($rindex, $rdata) = @$entry;
+  $$rindex >= 0 && $$rindex < @$rdata
+    or die "** not in an iterator for $arg **";
+  my @options = $rdata->[$$rindex]->option_descs($self->{cfg});
+
+  return scalar(@options);
+}
+
 sub generate_low {
   my ($self, $template, $article, $articles, $embedded) = @_;
 
@@ -130,6 +145,11 @@ sub generate_low {
   my $allprod_index;
   my $catalog_index = -1;
   my $allcat_index;
+  my %named_product_iterators =
+    (
+     product => [ \$product_index, \@products ],
+     allprod => [ \$allprod_index, \@allprods ],
+    );
   my $it = BSE::Util::Iterate->new;
   my $cfg = $self->{cfg};
   my $art_it = BSE::Util::Iterate::Article->new(cfg => $cfg);
@@ -202,6 +222,7 @@ HTML
         my $previd = $allprods[$allprod_index-1]{id};
         $up_url = "$CGI_URI/admin/move.pl?stepparent=$article->{id}&d=swap&id=$myid&other=$previd";
        }
+       
        return make_arrows($self->{cfg}, $down_url, $up_url, $refreshto, $img_prefix);
      },
      ifAnyProds => scalar(@allprods),
@@ -214,6 +235,8 @@ HTML
      $art_it->make_iterator(undef, 'allcat', 'allcats', \@allcats, \$allcat_index),
      moveallcat => 
      [ \&tag_moveallcat, $self, \@allcats, \$allcat_index, $article ],
+     ifAnyProductOptions =>
+     [ tag_ifAnyProductOptions => $self, \%named_product_iterators ],
     );
   my $oldurl = $acts{url};
   my $urlbase = $self->{cfg}->entryVar('site', 'url');
index 70acc062e521cd6412264dc9ef3628c3e24daf5c..70349a555ef56719697a2d8574fa32ec0f251036 100644 (file)
@@ -8,7 +8,6 @@ use Constants qw(:shop $CGI_URI $ADMIN_URI);
 use Carp qw(confess);
 use DevHelp::HTML;
 use BSE::Util::Tags qw(tag_article);
-use BSE::CfgInfo 'product_options';
 
 sub edit_link {
   my ($self, $id) = @_;
@@ -53,11 +52,7 @@ sub baseActs {
 
   my @stepcats = $product->step_parents();
   my $stepcat_index;
-  my $avail_options = product_options($self->{cfg});
-  my @options = 
-    map { +{ id=>$_, %{$avail_options->{$_}} } } 
-      grep $avail_options->{$_},
-      split /,/, $product->{options};
+  my @options = $product->option_descs($self->{cfg});
   my $option_index;
 
   return
index b6c7c712073f190742681a50025317f732d7d3c0..a80327003d2e8a98788643cd667a76572ccdd7bf 100644 (file)
@@ -53,24 +53,71 @@ sub is_start_sub_only {
   $self->{subscription_usage} == SUBUSAGE_START_ONLY;
 }
 
-sub _get_prod_options {
-  my ($product, $cfg, @values) = @_;
+sub _get_cfg_options {
+  my ($cfg) = @_;
 
   require BSE::CfgInfo;
   my $avail_options = BSE::CfgInfo::product_options($cfg);
+  my @options;
+  for my $name (keys %$avail_options) {
+    my $rawopt = $avail_options->{$name};
+    my %opt =
+      (
+       id => $name,
+       name => $rawopt->{desc},
+       default => $rawopt->{default} || '',
+      );
+    my @values;
+    for my $value (@{$rawopt->{values}}) {
+      my $label = $rawopt->{labels}{$value} || $value;
+      push @values,
+       bless
+         {
+          id => $value,
+          value => $label,
+         }, "BSE::CfgProductOptionValue";
+    }
+    $opt{values} = \@values;
+    push @options, bless \%opt, "BSE::CfgProductOption";
+  }
+
+  return @options;
+}
+
+sub _get_prod_options {
+  my ($product, $cfg, @values) = @_;
+
+  my %all_cfg_opts = map { $_->id => $_ } _get_cfg_options($cfg);
   my @opt_names = split /,/, $product->{options};
-  push @values, '' while @values < @opt_names;
-  my %values;
-  @values{@opt_names} = @values;
-
-  my @sem_options = map 
-    +{ 
-      id => $_, 
-      %{$avail_options->{$_}},
-      value => $values{$_},
-     }, @opt_names;
-  for my $option (@sem_options) {
-    $option->{display} = $option->{labels}{$option->{value}};
+
+  my @cfg_opts = map $all_cfg_opts{$_}, @opt_names;
+  my @db_opts = grep $_->enabled, $product->db_options;
+  my @all_options = ( @cfg_opts, @db_opts );
+
+  push @values, '' while @values < @all_options;
+
+  my $index = 0;
+  my @sem_options;
+  for my $opt (@all_options) {
+    my @opt_values = $opt->values;
+    my %opt_values = map { $_->id => $_->value } @opt_values;
+    my $result_opt = 
+      {
+       id => $opt->key,
+       name => $opt->key,
+       desc => $opt->name,
+       value => $values[$index],
+       type => $opt->type,
+       labels => \%opt_values,
+       default => $opt->default_value,
+      };
+    my $value = $values[$index];
+    if (defined $value) {
+      $result_opt->{values} = [ map $_->id, @opt_values ],
+      $result_opt->{display} = $opt_values{$values[$index]};
+    }
+    push @sem_options, $result_opt;
+    ++$index;
   }
 
   return @sem_options;
@@ -84,4 +131,35 @@ sub option_descs {
   return $self->_get_prod_options($cfg, @$rvalues);
 }
 
+sub db_options {
+  my ($self) = @_;
+
+  require BSE::TB::ProductOptions;
+  return BSE::TB::ProductOptions->getBy(product_id => $self->{id});
+}
+
+package BSE::CfgProductOption;
+use strict;
+
+sub id { $_[0]{id} }
+
+sub key {$_[0]{id} } # same as id for config options
+
+sub type { "select" }
+
+sub name { $_[0]{name} }
+
+sub values {
+  @{$_[0]{values}}
+}
+
+sub default_value { $_[0]{default} }
+
+package BSE::CfgProductOptionValue;
+use strict;
+
+sub id { $_[0]{id} }
+
+sub value { $_[0]{value} }
+
 1;
index 5f720352d3ed49284dffbc658978462bc80cda80..ce521d7eb77ea089f0cd3f0a45b3573c58e80871 100644 (file)
@@ -5,11 +5,16 @@ use strict;
 use Carp;
 use BSE::DB;
 
+my %methods_created;
+
 my $dh = BSE::DB->single;
 
 sub new {
   my ($class, @values) = @_;
 
+  $methods_created{$class}
+    or $class->_create_methods();
+
   my @primary = $class->primary;
   my @columns = $class->columns;
 
@@ -27,8 +32,8 @@ sub new {
       my @bases = $class->_get_bases;
       my $base_base = $bases[0];
       my $base_class = $base_base->[1];
-      my $sth = $dh->stmt("add$base_class")
-       or confess "No add$base_class member in DatabaseHandle";
+
+      my $sth = $dh->stmt("add$base_class");
 
       # extract the base class columns
       my @base_cols = $base_class->columns;
@@ -58,7 +63,10 @@ sub new {
       }
     }
     else {
-      my $sth = $dh->stmt("add$class")
+      my $sth = $dh->stmt_noerror("add$class");
+      my @cols = $self->db_columns;
+      shift @cols; # lose the pkey
+      $sth ||= $dh->insert_stmt($self->table, \@cols)
        or confess "No add$class member in DatabaseHandle";
       my $ret = $sth->execute(@values[1..$#values]);
       $ret && $ret != 0
@@ -88,6 +96,10 @@ sub bases {
   return {};
 }
 
+sub defaults {
+  return;
+}
+
 sub db_columns {
   my $class = shift;
   return $class->columns; # the same by default
@@ -124,9 +136,27 @@ sub save {
     }
   }
   else {
-    my $sth = $dh->stmt('replace'.ref $self)
+    my $member = 'replace'.ref $self;
+    my @exe_vals = @$self{$self->columns};
+    my $sth = $dh->stmt_noerror($member);
+    unless ($sth) {
+      my ($pkey_col) = $self->primary;
+      my @nonkey = grep $_ ne $pkey_col, $self->columns;
+      @exe_vals = @$self{@nonkey, $pkey_col};
+      $member = 'update'.ref $self;
+      $sth = $dh->stmt_noerror($member);
+
+      unless ($sth) {
+       # not strictly correct, but plenty of other code makes the
+       # same assumption
+       my @db_cols = $self->db_columns;
+       $pkey_col = shift @db_cols;
+       $sth = $dh->update_stmt($self->table, $pkey_col, \@db_cols);
+      }
+    }
+    $sth
       or confess "No replace",ref $self," member in DatabaseHandle";
-    $sth->execute(@$self{$self->columns})
+    $sth->execute(@exe_vals)
       or confess "Cannot save ",ref $self,":",$sth->errstr;
   }
 
@@ -136,19 +166,32 @@ sub save {
 sub remove {
   my $self = shift;
 
-  my $sth = $dh->stmt('delete'.ref($self));
   my $bases = $self->bases;
   my @primary = @$self{$self->primary};
-  $sth->execute(@primary);
-  while (keys %$bases) {
-    my ($col) = keys %$bases;
-    my $class = $bases->{$col}{class};
-    my $sth = $dh->stmt('delete'.$class);
+  if (keys %$bases) {
+    my $sth = $dh->stmt('delete'.ref($self));
     $sth->execute(@primary);
-    $bases = $class->bases;
+    while (keys %$bases) {
+      my ($col) = keys %$bases;
+      my $class = $bases->{$col}{class};
+      my $sth = $dh->stmt('delete'.$class);
+      $sth->execute(@primary);
+      $bases = $class->bases;
+    }
+    
+    # BUG: this should invalidate the cache
+  }
+  else {
+    my $member = 'delete'.ref($self);
+    my $sth = $dh->stmt_noerror($member);
+    unless ($sth) {
+      $sth = $dh->delete_stmt($self->table, [ $self->primary ]);
+    }
+    $sth
+      or confess "No $member member in DatabaseHandle";
+    $sth->execute(@primary)
+      or confess "Cannot delete ", ref $self, ":", $sth->errstr;
   }
-
-  # BUG: this should invalidate the cache
 }
 
 sub set {
@@ -198,10 +241,48 @@ sub _get_bases {
   @bases;
 }
 
+sub data_only {
+  my ($self) = @_;
+
+  my %result;
+  my @cols = $self->columns;
+  @result{@cols} = @{$self}{@cols};
+
+  return \%result;
+}
+
 # in case someone tries AUTOLOAD tricks
 sub DESTROY {
 }
 
+sub _create_methods {
+  my $class = shift;
+
+  $methods_created{$class} = 1;
+
+  my $bases = $class->bases;
+  my @bases = map $_->{class}, values %$bases;
+  my %all_cols = map { $_ => 1 } $class->columns;
+  for my $base (@bases) {
+    unless ($methods_created{$base}) {
+      $base->_create_methods();
+    }
+    delete @all_cols{$base->columns};
+  }
+  for my $col (keys %all_cols) {
+    unless ($class->can("set_$col")) {
+      no strict 'refs';
+      my $work_col = $col; # for closure
+      *{"${class}::set_$col"} = sub { $_[0]{$work_col} = $_[1] };
+    }
+    unless ($class->can($col)) {
+      no strict 'refs';
+      my $work_col = $col; # for closure
+      *{"${class}::$col"} = sub { $_[0]{$work_col} };
+    }
+  }
+}
+
 1;
 
 __END__
@@ -226,6 +307,13 @@ Class is some derived class.
 
 Create a new object of that class.
 
+Preferably, use the make method of the table class, See
+Squirrel::Table.
+
+=item $row->save
+
+Save the row to the database.
+
 =item $row->columns()
 
 Return a list of column names in the table.  No default.
@@ -267,6 +355,24 @@ Defaults to ('id').
 The older code returned column numbers, but was always looking up the
 column names in the columns array.
 
+=item $row->db_columns
+
+Columns as they are named in the database.  Defaults to calling columns().
+
+=item $row->remove
+
+Remove the row from the database.
+
+=item Class->defaults
+
+Returns defaults as name, value pairs suitable for assignment to hash.
+Used by make() in Squirrel::Table.
+
+=item $row->data_only
+
+Returns the data of the row as a hashref, with no extra housekeeping
+data.
+
 =back
 
 =NAME SEE ALSO
index a555b1e2d630df2bf42cb672f2d190e659222274..1c5318363d5dd1dad7883fc35c9127689d75692d 100644 (file)
@@ -95,8 +95,15 @@ sub getByPkey {
     (my $reqName = $rowClass) =~ s!::!/!g;
     require $reqName . ".pm";
     my $member = "get${rowClass}ByPkey";
-    my $sth = $dh->stmt($member)
-      or confess "No $member in DatabaseHandle";
+    my $sth = $dh->stmt_noerror($member);
+    unless ($sth) {
+      my @cols = ( $rowClass->primary );
+      my %vals;
+      @vals{@cols} = @values;
+      $sth ||= $self->_getBy_sth($member, \@cols, \%vals);
+    }
+    $sth
+      or confess "No $member in BSE::DB";
     $sth->execute(@values)
       or confess "Cannot execute $member handle from DatabaseHandle:", DBI->errstr;
     # should only be one row
@@ -191,7 +198,9 @@ sub getBy {
     (my $reqName = $rowClass) =~ s!::!/!g;
     require $reqName . ".pm";
     my $member = "get${rowClass}By".join("And", map "\u$_", @cols);
-    my $sth = $dh->stmt($member)
+    my $sth = $dh->stmt_noerror($member);
+    $sth ||= $self->_getBy_sth($member, \@cols, \%vals);
+    $sth
       or confess "No $member in BSE::DB";
     $sth->execute(@vals{@cols})
       or confess "Cannot execute $member from BSE::DB: ",DBI->errstr;
@@ -208,6 +217,36 @@ sub getBy {
   return wantarray ? @results : $results[0];
 }
 
+sub _getBy_sth {
+  my ($self, $name, $cols, $vals) = @_;
+
+  my $bases = $self->rowClass->bases;
+  keys %$bases
+    and confess "No statement $name found and cannot generate";
+
+  my @db_cols = $self->rowClass->db_columns;
+  my @code_cols = $self->rowClass->columns;
+  my %map;
+  @map{@code_cols} = @db_cols;
+  
+  my @conds;
+  for my $col (@$cols) {
+    my $db_col = $map{$col}
+      or confess "Cannot generate $name: unknown column $col";
+    # this doesn't handle null, but that should use a "special"
+    push @conds, "$db_col = ?";
+  }
+
+  my $sql = "select " . join(",", @db_cols) .
+    " from " . $self->rowClass->table .
+      " where " . join(" and ", @conds);
+
+  my $sth = $dh->{dbh}->prepare($sql)
+    or confess "Cannot prepare generated $sql: ", $dh->{dbh}->errstr;
+
+  return $sth;
+}
+
 sub getSpecial {
   my ($self, $name, @args) = @_;
 
@@ -271,6 +310,34 @@ sub query {
   $dh->generate_query($self, $columns, $query, $opts);
 }
 
+sub make {
+  my ($self, %values) = @_;
+
+  my @cols = $self->rowClass->columns;
+  my %defaults = $self->rowClass->defaults;
+  shift @cols; # presumably the generated private key
+  my @values;
+  for my $col (@cols) {
+    my $value;
+    # a defined test is inappropriate here, the caller might want to
+    # set a column to null.
+    if (exists $values{$col}) {
+      $value = delete $values{$col};
+    }
+    elsif (exists $defaults{$col}) {
+      $value = $defaults{$col};
+    }
+    else {
+      confess "No value or default supplied for $col";
+    }
+    push @values, $value;
+  }
+  keys %values
+    and confess "Extra values ", join(",", keys %values), " supplied to ${self}->make()";
+
+  return $self->add(@values);
+}
+
 1;
 
 __END__
index 0acf07dda32bfc25dbeff524c60b3c773176fb95..75b1b1f3112193255db4b0b4826d6e3adcd365ba 100644 (file)
@@ -21,4 +21,80 @@ h4 {  font-size: 12px}
 .version { font-size: 10px }
 input.inline { border-style: none; }
 tr.inline_form { background-color: #8080C0 }
\ No newline at end of file
+
+.bse_arrows img {
+  border: none;
+  vertical-align: bottom;
+  width: 17px;
+  height: 13px;
+}
+
+#productoptions .prodopt { 
+  border: 1px solid #000;
+  margin: 5px;
+}
+
+#productoptions .prodoptmenu {
+  font-size: 120%;
+  font-weight: bold;
+  background-color: #EEE;
+  padding: 3px;
+  border-bottom: 1px solid #000;
+}
+
+#productoptions .prodoptmenu a {
+  font-size: 82%;
+  float: right;
+  padding-left: 2px;
+}
+
+.editform {
+  border: 1px solid #000;
+  border-collapse: collapse;
+  vertical-align: baseline;
+  width: 100%;
+}
+
+table.editformsmall {
+  width: 50%;
+}
+
+table.editformtiny { 
+  width: auto;
+}
+
+table.editform td, table.editform th {
+  padding: 4px;
+  border: 1px solid #000;
+}
+
+table.editform th {
+  color: #FFF;
+  background-color: #999;
+  font-weight: bold;
+  text-align: left;
+  white-space: nowrap;
+}
+
+table.editform td {
+  color: #000;
+  background-color: #FFF;
+}
+
+table.editform td.help,
+table.editform td.check  {
+  white-space: nowrap;
+  width: 20px;
+}
+
+table.editform td.check { 
+  text-align: center;
+}
+
+table.editform td.buttons { 
+  text-align: right;
+}
+
+div.menu { 
+  margin-bottom: 1em;
+}
\ No newline at end of file
index 5d2100fac2dd69383a5d601d49181090cf106171..dfe8ab4e13652153903d481a1b006878df6a1f3b 100644 (file)
-/*  Prototype JavaScript framework, version 1.5.1
- *  (c) 2005-2007 Sam Stephenson
+/*  Prototype JavaScript framework, version 1.6.0.3
+ *  (c) 2005-2008 Sam Stephenson
  *
  *  Prototype is freely distributable under the terms of an MIT-style license.
  *  For details, see the Prototype web site: http://www.prototypejs.org/
  *
-/*--------------------------------------------------------------------------*/
+ *--------------------------------------------------------------------------*/
 
 var Prototype = {
-  Version: '1.5.1',
+  Version: '1.6.0.3',
 
   Browser: {
-    IE:     !!(window.attachEvent && !window.opera),
-    Opera:  !!window.opera,
+    IE:     !!(window.attachEvent &&
+      navigator.userAgent.indexOf('Opera') === -1),
+    Opera:  navigator.userAgent.indexOf('Opera') > -1,
     WebKit: navigator.userAgent.indexOf('AppleWebKit/') > -1,
-    Gecko:  navigator.userAgent.indexOf('Gecko') > -1 && navigator.userAgent.indexOf('KHTML') == -1
+    Gecko:  navigator.userAgent.indexOf('Gecko') > -1 &&
+      navigator.userAgent.indexOf('KHTML') === -1,
+    MobileSafari: !!navigator.userAgent.match(/Apple.*Mobile.*Safari/)
   },
 
   BrowserFeatures: {
     XPath: !!document.evaluate,
+    SelectorsAPI: !!document.querySelector,
     ElementExtensions: !!window.HTMLElement,
     SpecificElementExtensions:
-      (document.createElement('div').__proto__ !==
-       document.createElement('form').__proto__)
+      document.createElement('div')['__proto__'] &&
+      document.createElement('div')['__proto__'] !==
+        document.createElement('form')['__proto__']
   },
 
-  ScriptFragment: '<script[^>]*>([\u0001-\uFFFF]*?)</script>',
-  JSONFilter: /^\/\*-secure-\s*(.*)\s*\*\/\s*$/,
+  ScriptFragment: '<script[^>]*>([\\S\\s]*?)<\/script>',
+  JSONFilter: /^\/\*-secure-([\s\S]*)\*\/\s*$/,
 
   emptyFunction: function() { },
   K: function(x) { return x }
-}
+};
+
+if (Prototype.Browser.MobileSafari)
+  Prototype.BrowserFeatures.SpecificElementExtensions = false;
 
+
+/* Based on Alex Arnell's inheritance implementation. */
 var Class = {
   create: function() {
-    return function() {
+    var parent = null, properties = $A(arguments);
+    if (Object.isFunction(properties[0]))
+      parent = properties.shift();
+
+    function klass() {
       this.initialize.apply(this, arguments);
     }
+
+    Object.extend(klass, Class.Methods);
+    klass.superclass = parent;
+    klass.subclasses = [];
+
+    if (parent) {
+      var subclass = function() { };
+      subclass.prototype = parent.prototype;
+      klass.prototype = new subclass;
+      parent.subclasses.push(klass);
+    }
+
+    for (var i = 0; i < properties.length; i++)
+      klass.addMethods(properties[i]);
+
+    if (!klass.prototype.initialize)
+      klass.prototype.initialize = Prototype.emptyFunction;
+
+    klass.prototype.constructor = klass;
+
+    return klass;
   }
-}
+};
 
-var Abstract = new Object();
+Class.Methods = {
+  addMethods: function(source) {
+    var ancestor   = this.superclass && this.superclass.prototype;
+    var properties = Object.keys(source);
+
+    if (!Object.keys({ toString: true }).length)
+      properties.push("toString", "valueOf");
+
+    for (var i = 0, length = properties.length; i < length; i++) {
+      var property = properties[i], value = source[property];
+      if (ancestor && Object.isFunction(value) &&
+          value.argumentNames().first() == "$super") {
+        var method = value;
+        value = (function(m) {
+          return function() { return ancestor[m].apply(this, arguments) };
+        })(property).wrap(method);
+
+        value.valueOf = method.valueOf.bind(method);
+        value.toString = method.toString.bind(method);
+      }
+      this.prototype[property] = value;
+    }
+
+    return this;
+  }
+};
+
+var Abstract = { };
 
 Object.extend = function(destination, source) {
-  for (var property in source) {
+  for (var property in source)
     destination[property] = source[property];
-  }
   return destination;
-}
+};
 
 Object.extend(Object, {
   inspect: function(object) {
     try {
-      if (object === undefined) return 'undefined';
+      if (Object.isUndefined(object)) return 'undefined';
       if (object === null) return 'null';
-      return object.inspect ? object.inspect() : object.toString();
+      return object.inspect ? object.inspect() : String(object);
     } catch (e) {
       if (e instanceof RangeError) return '...';
       throw e;
@@ -62,24 +123,35 @@ Object.extend(Object, {
 
   toJSON: function(object) {
     var type = typeof object;
-    switch(type) {
+    switch (type) {
       case 'undefined':
       case 'function':
       case 'unknown': return;
       case 'boolean': return object.toString();
     }
+
     if (object === null) return 'null';
     if (object.toJSON) return object.toJSON();
-    if (object.ownerDocument === document) return;
+    if (Object.isElement(object)) return;
+
     var results = [];
     for (var property in object) {
       var value = Object.toJSON(object[property]);
-      if (value !== undefined)
+      if (!Object.isUndefined(value))
         results.push(property.toJSON() + ': ' + value);
     }
+
     return '{' + results.join(', ') + '}';
   },
 
+  toQueryString: function(object) {
+    return $H(object).toQueryString();
+  },
+
+  toHTML: function(object) {
+    return object && object.toHTML ? object.toHTML() : String.interpret(object);
+  },
+
   keys: function(object) {
     var keys = [];
     for (var property in object)
@@ -95,55 +167,104 @@ Object.extend(Object, {
   },
 
   clone: function(object) {
-    return Object.extend({}, object);
+    return Object.extend({ }, object);
+  },
+
+  isElement: function(object) {
+    return !!(object && object.nodeType == 1);
+  },
+
+  isArray: function(object) {
+    return object != null && typeof object == "object" &&
+      'splice' in object && 'join' in object;
+  },
+
+  isHash: function(object) {
+    return object instanceof Hash;
+  },
+
+  isFunction: function(object) {
+    return typeof object == "function";
+  },
+
+  isString: function(object) {
+    return typeof object == "string";
+  },
+
+  isNumber: function(object) {
+    return typeof object == "number";
+  },
+
+  isUndefined: function(object) {
+    return typeof object == "undefined";
   }
 });
 
-Function.prototype.bind = function() {
-  var __method = this, args = $A(arguments), object = args.shift();
-  return function() {
-    return __method.apply(object, args.concat($A(arguments)));
-  }
-}
+Object.extend(Function.prototype, {
+  argumentNames: function() {
+    var names = this.toString().match(/^[\s\(]*function[^(]*\(([^\)]*)\)/)[1]
+      .replace(/\s+/g, '').split(',');
+    return names.length == 1 && !names[0] ? [] : names;
+  },
 
-Function.prototype.bindAsEventListener = function(object) {
-  var __method = this, args = $A(arguments), object = args.shift();
-  return function(event) {
-    return __method.apply(object, [event || window.event].concat(args));
-  }
-}
+  bind: function() {
+    if (arguments.length < 2 && Object.isUndefined(arguments[0])) return this;
+    var __method = this, args = $A(arguments), object = args.shift();
+    return function() {
+      return __method.apply(object, args.concat($A(arguments)));
+    }
+  },
 
-Object.extend(Number.prototype, {
-  toColorPart: function() {
-    return this.toPaddedString(2, 16);
+  bindAsEventListener: function() {
+    var __method = this, args = $A(arguments), object = args.shift();
+    return function(event) {
+      return __method.apply(object, [event || window.event].concat(args));
+    }
   },
 
-  succ: function() {
-    return this + 1;
+  curry: function() {
+    if (!arguments.length) return this;
+    var __method = this, args = $A(arguments);
+    return function() {
+      return __method.apply(this, args.concat($A(arguments)));
+    }
   },
 
-  times: function(iterator) {
-    $R(0, this, true).each(iterator);
-    return this;
+  delay: function() {
+    var __method = this, args = $A(arguments), timeout = args.shift() * 1000;
+    return window.setTimeout(function() {
+      return __method.apply(__method, args);
+    }, timeout);
   },
 
-  toPaddedString: function(length, radix) {
-    var string = this.toString(radix || 10);
-    return '0'.times(length - string.length) + string;
+  defer: function() {
+    var args = [0.01].concat($A(arguments));
+    return this.delay.apply(this, args);
   },
 
-  toJSON: function() {
-    return isFinite(this) ? this.toString() : 'null';
+  wrap: function(wrapper) {
+    var __method = this;
+    return function() {
+      return wrapper.apply(this, [__method.bind(this)].concat($A(arguments)));
+    }
+  },
+
+  methodize: function() {
+    if (this._methodized) return this._methodized;
+    var __method = this;
+    return this._methodized = function() {
+      return __method.apply(null, [this].concat($A(arguments)));
+    };
   }
 });
 
 Date.prototype.toJSON = function() {
-  return '"' + this.getFullYear() + '-' +
-    (this.getMonth() + 1).toPaddedString(2) + '-' +
-    this.getDate().toPaddedString(2) + 'T' +
-    this.getHours().toPaddedString(2) + ':' +
-    this.getMinutes().toPaddedString(2) + ':' +
-    this.getSeconds().toPaddedString(2) + '"';
+  return '"' + this.getUTCFullYear() + '-' +
+    (this.getUTCMonth() + 1).toPaddedString(2) + '-' +
+    this.getUTCDate().toPaddedString(2) + 'T' +
+    this.getUTCHours().toPaddedString(2) + ':' +
+    this.getUTCMinutes().toPaddedString(2) + ':' +
+    this.getUTCSeconds().toPaddedString(2) + 'Z"';
 };
 
 var Try = {
@@ -155,17 +276,22 @@ var Try = {
       try {
         returnValue = lambda();
         break;
-      } catch (e) {}
+      } catch (e) { }
     }
 
     return returnValue;
   }
-}
+};
+
+RegExp.prototype.match = RegExp.prototype.test;
+
+RegExp.escape = function(str) {
+  return String(str).replace(/([.*+?^=!:${}()|[\]\/\\])/g, '\\$1');
+};
 
 /*--------------------------------------------------------------------------*/
 
-var PeriodicalExecuter = Class.create();
-PeriodicalExecuter.prototype = {
+var PeriodicalExecuter = Class.create({
   initialize: function(callback, frequency) {
     this.callback = callback;
     this.frequency = frequency;
@@ -178,6 +304,10 @@ PeriodicalExecuter.prototype = {
     this.timer = setInterval(this.onTimerEvent.bind(this), this.frequency * 1000);
   },
 
+  execute: function() {
+    this.callback(this);
+  },
+
   stop: function() {
     if (!this.timer) return;
     clearInterval(this.timer);
@@ -188,13 +318,13 @@ PeriodicalExecuter.prototype = {
     if (!this.currentlyExecuting) {
       try {
         this.currentlyExecuting = true;
-        this.callback(this);
+        this.execute();
       } finally {
         this.currentlyExecuting = false;
       }
     }
   }
-}
+});
 Object.extend(String, {
   interpret: function(value) {
     return value == null ? '' : String(value);
@@ -228,7 +358,7 @@ Object.extend(String.prototype, {
 
   sub: function(pattern, replacement, count) {
     replacement = this.gsub.prepareReplacement(replacement);
-    count = count === undefined ? 1 : count;
+    count = Object.isUndefined(count) ? 1 : count;
 
     return this.gsub(pattern, function(match) {
       if (--count < 0) return match[0];
@@ -238,14 +368,14 @@ Object.extend(String.prototype, {
 
   scan: function(pattern, iterator) {
     this.gsub(pattern, iterator);
-    return this;
+    return String(this);
   },
 
   truncate: function(length, truncation) {
     length = length || 30;
-    truncation = truncation === undefined ? '...' : truncation;
+    truncation = Object.isUndefined(truncation) ? '...' : truncation;
     return this.length > length ?
-      this.slice(0, length - truncation.length) + truncation : this;
+      this.slice(0, length - truncation.length) + truncation : String(this);
   },
 
   strip: function() {
@@ -279,7 +409,7 @@ Object.extend(String.prototype, {
   },
 
   unescapeHTML: function() {
-    var div = document.createElement('div');
+    var div = new Element('div');
     div.innerHTML = this.stripTags();
     return div.childNodes[0] ? (div.childNodes.length > 1 ?
       $A(div.childNodes).inject('', function(memo, node) { return memo+node.nodeValue }) :
@@ -288,16 +418,16 @@ Object.extend(String.prototype, {
 
   toQueryParams: function(separator) {
     var match = this.strip().match(/([^?#]*)(#.*)?$/);
-    if (!match) return {};
+    if (!match) return { };
 
-    return match[1].split(separator || '&').inject({}, function(hash, pair) {
+    return match[1].split(separator || '&').inject({ }, function(hash, pair) {
       if ((pair = pair.split('='))[0]) {
         var key = decodeURIComponent(pair.shift());
         var value = pair.length > 1 ? pair.join('=') : pair[0];
         if (value != undefined) value = decodeURIComponent(value);
 
         if (key in hash) {
-          if (hash[key].constructor != Array) hash[key] = [hash[key]];
+          if (!Object.isArray(hash[key])) hash[key] = [hash[key]];
           hash[key].push(value);
         }
         else hash[key] = value;
@@ -316,9 +446,7 @@ Object.extend(String.prototype, {
   },
 
   times: function(count) {
-    var result = '';
-    for (var i = 0; i < count; i++) result += this;
-    return result;
+    return count < 1 ? '' : new Array(count + 1).join(this);
   },
 
   camelize: function() {
@@ -364,11 +492,17 @@ Object.extend(String.prototype, {
     return this.sub(filter || Prototype.JSONFilter, '#{1}');
   },
 
+  isJSON: function() {
+    var str = this;
+    if (str.blank()) return false;
+    str = this.replace(/\\./g, '@').replace(/"[^"\\\n\r]*"/g, '');
+    return (/^[,:{}\[\]0-9.\-+Eaeflnr-u \n\r\t]*$/).test(str);
+  },
+
   evalJSON: function(sanitize) {
     var json = this.unfilterJSON();
     try {
-      if (!sanitize || (/^("(\\.|[^"\\\n\r])*?"|[,:{}\[\]0-9.\-+Eaeflnr-u \n\r\t])+?$/.test(json)))
-        return eval('(' + json + ')');
+      if (!sanitize || json.isJSON()) return eval('(' + json + ')');
     } catch (e) { }
     throw new SyntaxError('Badly formed JSON string: ' + this.inspect());
   },
@@ -392,6 +526,10 @@ Object.extend(String.prototype, {
 
   blank: function() {
     return /^\s*$/.test(this);
+  },
+
+  interpolate: function(object, pattern) {
+    return new Template(this, pattern).evaluate(object);
   }
 });
 
@@ -400,15 +538,15 @@ if (Prototype.Browser.WebKit || Prototype.Browser.IE) Object.extend(String.proto
     return this.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
   },
   unescapeHTML: function() {
-    return this.replace(/&amp;/g,'&').replace(/&lt;/g,'<').replace(/&gt;/g,'>');
+    return this.stripTags().replace(/&amp;/g,'&').replace(/&lt;/g,'<').replace(/&gt;/g,'>');
   }
 });
 
 String.prototype.gsub.prepareReplacement = function(replacement) {
-  if (typeof replacement == 'function') return replacement;
+  if (Object.isFunction(replacement)) return replacement;
   var template = new Template(replacement);
   return function(match) { return template.evaluate(match) };
-}
+};
 
 String.prototype.parseQuery = String.prototype.toQueryParams;
 
@@ -417,33 +555,51 @@ Object.extend(String.prototype.escapeHTML, {
   text: document.createTextNode('')
 });
 
-with (String.prototype.escapeHTML) div.appendChild(text);
+String.prototype.escapeHTML.div.appendChild(String.prototype.escapeHTML.text);
 
-var Template = Class.create();
-Template.Pattern = /(^|.|\r|\n)(#\{(.*?)\})/;
-Template.prototype = {
+var Template = Class.create({
   initialize: function(template, pattern) {
     this.template = template.toString();
-    this.pattern  = pattern || Template.Pattern;
+    this.pattern = pattern || Template.Pattern;
   },
 
   evaluate: function(object) {
+    if (Object.isFunction(object.toTemplateReplacements))
+      object = object.toTemplateReplacements();
+
     return this.template.gsub(this.pattern, function(match) {
-      var before = match[1];
+      if (object == null) return '';
+
+      var before = match[1] || '';
       if (before == '\\') return match[2];
-      return before + String.interpret(object[match[3]]);
+
+      var ctx = object, expr = match[3];
+      var pattern = /^([^.[]+|\[((?:.*?[^\\])?)\])(\.|\[|$)/;
+      match = pattern.exec(expr);
+      if (match == null) return before;
+
+      while (match != null) {
+        var comp = match[1].startsWith('[') ? match[2].gsub('\\\\]', ']') : match[1];
+        ctx = ctx[comp];
+        if (null == ctx || '' == match[3]) break;
+        expr = expr.substring('[' == match[3] ? match[1].length : match[0].length);
+        match = pattern.exec(expr);
+      }
+
+      return before + String.interpret(ctx);
     });
   }
-}
+});
+Template.Pattern = /(^|.|\r|\n)(#\{(.*?)\})/;
 
-var $break = {}, $continue = new Error('"throw $continue" is deprecated, use "return" instead');
+var $break = { };
 
 var Enumerable = {
-  each: function(iterator) {
+  each: function(iterator, context) {
     var index = 0;
     try {
       this._each(function(value) {
-        iterator(value, index++);
+        iterator.call(context, value, index++);
       });
     } catch (e) {
       if (e != $break) throw e;
@@ -451,43 +607,47 @@ var Enumerable = {
     return this;
   },
 
-  eachSlice: function(number, iterator) {
+  eachSlice: function(number, iterator, context) {
     var index = -number, slices = [], array = this.toArray();
+    if (number < 1) return array;
     while ((index += number) < array.length)
       slices.push(array.slice(index, index+number));
-    return slices.map(iterator);
+    return slices.collect(iterator, context);
   },
 
-  all: function(iterator) {
+  all: function(iterator, context) {
+    iterator = iterator || Prototype.K;
     var result = true;
     this.each(function(value, index) {
-      result = result && !!(iterator || Prototype.K)(value, index);
+      result = result && !!iterator.call(context, value, index);
       if (!result) throw $break;
     });
     return result;
   },
 
-  any: function(iterator) {
+  any: function(iterator, context) {
+    iterator = iterator || Prototype.K;
     var result = false;
     this.each(function(value, index) {
-      if (result = !!(iterator || Prototype.K)(value, index))
+      if (result = !!iterator.call(context, value, index))
         throw $break;
     });
     return result;
   },
 
-  collect: function(iterator) {
+  collect: function(iterator, context) {
+    iterator = iterator || Prototype.K;
     var results = [];
     this.each(function(value, index) {
-      results.push((iterator || Prototype.K)(value, index));
+      results.push(iterator.call(context, value, index));
     });
     return results;
   },
 
-  detect: function(iterator) {
+  detect: function(iterator, context) {
     var result;
     this.each(function(value, index) {
-      if (iterator(value, index)) {
+      if (iterator.call(context, value, index)) {
         result = value;
         throw $break;
       }
@@ -495,26 +655,33 @@ var Enumerable = {
     return result;
   },
 
-  findAll: function(iterator) {
+  findAll: function(iterator, context) {
     var results = [];
     this.each(function(value, index) {
-      if (iterator(value, index))
+      if (iterator.call(context, value, index))
         results.push(value);
     });
     return results;
   },
 
-  grep: function(pattern, iterator) {
+  grep: function(filter, iterator, context) {
+    iterator = iterator || Prototype.K;
     var results = [];
+
+    if (Object.isString(filter))
+      filter = new RegExp(filter);
+
     this.each(function(value, index) {
-      var stringValue = value.toString();
-      if (stringValue.match(pattern))
-        results.push((iterator || Prototype.K)(value, index));
-    })
+      if (filter.match(value))
+        results.push(iterator.call(context, value, index));
+    });
     return results;
   },
 
   include: function(object) {
+    if (Object.isFunction(this.indexOf))
+      if (this.indexOf(object) != -1) return true;
+
     var found = false;
     this.each(function(value) {
       if (value == object) {
@@ -526,16 +693,16 @@ var Enumerable = {
   },
 
   inGroupsOf: function(number, fillWith) {
-    fillWith = fillWith === undefined ? null : fillWith;
+    fillWith = Object.isUndefined(fillWith) ? null : fillWith;
     return this.eachSlice(number, function(slice) {
       while(slice.length < number) slice.push(fillWith);
       return slice;
     });
   },
 
-  inject: function(memo, iterator) {
+  inject: function(memo, iterator, context) {
     this.each(function(value, index) {
-      memo = iterator(memo, value, index);
+      memo = iterator.call(context, memo, value, index);
     });
     return memo;
   },
@@ -547,30 +714,33 @@ var Enumerable = {
     });
   },
 
-  max: function(iterator) {
+  max: function(iterator, context) {
+    iterator = iterator || Prototype.K;
     var result;
     this.each(function(value, index) {
-      value = (iterator || Prototype.K)(value, index);
-      if (result == undefined || value >= result)
+      value = iterator.call(context, value, index);
+      if (result == null || value >= result)
         result = value;
     });
     return result;
   },
 
-  min: function(iterator) {
+  min: function(iterator, context) {
+    iterator = iterator || Prototype.K;
     var result;
     this.each(function(value, index) {
-      value = (iterator || Prototype.K)(value, index);
-      if (result == undefined || value < result)
+      value = iterator.call(context, value, index);
+      if (result == null || value < result)
         result = value;
     });
     return result;
   },
 
-  partition: function(iterator) {
+  partition: function(iterator, context) {
+    iterator = iterator || Prototype.K;
     var trues = [], falses = [];
     this.each(function(value, index) {
-      ((iterator || Prototype.K)(value, index) ?
+      (iterator.call(context, value, index) ?
         trues : falses).push(value);
     });
     return [trues, falses];
@@ -578,24 +748,27 @@ var Enumerable = {
 
   pluck: function(property) {
     var results = [];
-    this.each(function(value, index) {
+    this.each(function(value) {
       results.push(value[property]);
     });
     return results;
   },
 
-  reject: function(iterator) {
+  reject: function(iterator, context) {
     var results = [];
     this.each(function(value, index) {
-      if (!iterator(value, index))
+      if (!iterator.call(context, value, index))
         results.push(value);
     });
     return results;
   },
 
-  sortBy: function(iterator) {
+  sortBy: function(iterator, context) {
     return this.map(function(value, index) {
-      return {value: value, criteria: iterator(value, index)};
+      return {
+        value: value,
+        criteria: iterator.call(context, value, index)
+      };
     }).sort(function(left, right) {
       var a = left.criteria, b = right.criteria;
       return a < b ? -1 : a > b ? 1 : 0;
@@ -608,7 +781,7 @@ var Enumerable = {
 
   zip: function() {
     var iterator = Prototype.K, args = $A(arguments);
-    if (typeof args.last() == 'function')
+    if (Object.isFunction(args.last()))
       iterator = args.pop();
 
     var collections = [this].concat(args).map($A);
@@ -624,46 +797,46 @@ var Enumerable = {
   inspect: function() {
     return '#<Enumerable:' + this.toArray().inspect() + '>';
   }
-}
+};
 
 Object.extend(Enumerable, {
   map:     Enumerable.collect,
   find:    Enumerable.detect,
   select:  Enumerable.findAll,
+  filter:  Enumerable.findAll,
   member:  Enumerable.include,
-  entries: Enumerable.toArray
+  entries: Enumerable.toArray,
+  every:   Enumerable.all,
+  some:    Enumerable.any
 });
-var $A = Array.from = function(iterable) {
+function $A(iterable) {
   if (!iterable) return [];
-  if (iterable.toArray) {
-    return iterable.toArray();
-  } else {
-    var results = [];
-    for (var i = 0, length = iterable.length; i < length; i++)
-      results.push(iterable[i]);
-    return results;
-  }
+  if (iterable.toArray) return iterable.toArray();
+  var length = iterable.length || 0, results = new Array(length);
+  while (length--) results[length] = iterable[length];
+  return results;
 }
 
 if (Prototype.Browser.WebKit) {
-  $A = Array.from = function(iterable) {
+  $A = function(iterable) {
     if (!iterable) return [];
-    if (!(typeof iterable == 'function' && iterable == '[object NodeList]') &&
-      iterable.toArray) {
+    // In Safari, only use the `toArray` method if it's not a NodeList.
+    // A NodeList is a function, has an function `item` property, and a numeric
+    // `length` property. Adapted from Google Doctype.
+    if (!(typeof iterable === 'function' && typeof iterable.length ===
+        'number' && typeof iterable.item === 'function') && iterable.toArray)
       return iterable.toArray();
-    } else {
-      var results = [];
-      for (var i = 0, length = iterable.length; i < length; i++)
-        results.push(iterable[i]);
-      return results;
-    }
-  }
+    var length = iterable.length || 0, results = new Array(length);
+    while (length--) results[length] = iterable[length];
+    return results;
+  };
 }
 
+Array.from = $A;
+
 Object.extend(Array.prototype, Enumerable);
 
-if (!Array.prototype._reverse)
-  Array.prototype._reverse = Array.prototype.reverse;
+if (!Array.prototype._reverse) Array.prototype._reverse = Array.prototype.reverse;
 
 Object.extend(Array.prototype, {
   _each: function(iterator) {
@@ -692,7 +865,7 @@ Object.extend(Array.prototype, {
 
   flatten: function() {
     return this.inject([], function(array, value) {
-      return array.concat(value && value.constructor == Array ?
+      return array.concat(Object.isArray(value) ?
         value.flatten() : [value]);
     });
   },
@@ -704,12 +877,6 @@ Object.extend(Array.prototype, {
     });
   },
 
-  indexOf: function(object) {
-    for (var i = 0, length = this.length; i < length; i++)
-      if (this[i] == object) return i;
-    return -1;
-  },
-
   reverse: function(inline) {
     return (inline !== false ? this : this.toArray())._reverse();
   },
@@ -726,6 +893,12 @@ Object.extend(Array.prototype, {
     });
   },
 
+  intersect: function(array) {
+    return this.uniq().findAll(function(item) {
+      return array.detect(function(value) { return item === value });
+    });
+  },
+
   clone: function() {
     return [].concat(this);
   },
@@ -742,15 +915,35 @@ Object.extend(Array.prototype, {
     var results = [];
     this.each(function(object) {
       var value = Object.toJSON(object);
-      if (value !== undefined) results.push(value);
+      if (!Object.isUndefined(value)) results.push(value);
     });
     return '[' + results.join(', ') + ']';
   }
 });
 
+// use native browser JS 1.6 implementation if available
+if (Object.isFunction(Array.prototype.forEach))
+  Array.prototype._each = Array.prototype.forEach;
+
+if (!Array.prototype.indexOf) Array.prototype.indexOf = function(item, i) {
+  i || (i = 0);
+  var length = this.length;
+  if (i < 0) i = length + i;
+  for (; i < length; i++)
+    if (this[i] === item) return i;
+  return -1;
+};
+
+if (!Array.prototype.lastIndexOf) Array.prototype.lastIndexOf = function(item, i) {
+  i = isNaN(i) ? this.length : (i < 0 ? this.length + i : i) + 1;
+  var n = this.slice(0, i).reverse().indexOf(item);
+  return (n < 0) ? n : i - n - 1;
+};
+
 Array.prototype.toArray = Array.prototype.clone;
 
 function $w(string) {
+  if (!Object.isString(string)) return [];
   string = string.strip();
   return string ? string.split(/\s+/) : [];
 }
@@ -760,7 +953,7 @@ if (Prototype.Browser.Opera){
     var array = [];
     for (var i = 0, length = this.length; i < length; i++) array.push(this[i]);
     for (var i = 0, length = arguments.length; i < length; i++) {
-      if (arguments[i].constructor == Array) {
+      if (Object.isArray(arguments[i])) {
         for (var j = 0, arrayLength = arguments[i].length; j < arrayLength; j++)
           array.push(arguments[i][j]);
       } else {
@@ -768,136 +961,137 @@ if (Prototype.Browser.Opera){
       }
     }
     return array;
-  }
+  };
 }
-var Hash = function(object) {
-  if (object instanceof Hash) this.merge(object);
-  else Object.extend(this, object || {});
-};
-
-Object.extend(Hash, {
-  toQueryString: function(obj) {
-    var parts = [];
-    parts.add = arguments.callee.addPair;
+Object.extend(Number.prototype, {
+  toColorPart: function() {
+    return this.toPaddedString(2, 16);
+  },
 
-    this.prototype._each.call(obj, function(pair) {
-      if (!pair.key) return;
-      var value = pair.value;
+  succ: function() {
+    return this + 1;
+  },
 
-      if (value && typeof value == 'object') {
-        if (value.constructor == Array) value.each(function(value) {
-          parts.add(pair.key, value);
-        });
-        return;
-      }
-      parts.add(pair.key, value);
-    });
+  times: function(iterator, context) {
+    $R(0, this, true).each(iterator, context);
+    return this;
+  },
 
-    return parts.join('&');
+  toPaddedString: function(length, radix) {
+    var string = this.toString(radix || 10);
+    return '0'.times(length - string.length) + string;
   },
 
-  toJSON: function(object) {
-    var results = [];
-    this.prototype._each.call(object, function(pair) {
-      var value = Object.toJSON(pair.value);
-      if (value !== undefined) results.push(pair.key.toJSON() + ': ' + value);
-    });
-    return '{' + results.join(', ') + '}';
+  toJSON: function() {
+    return isFinite(this) ? this.toString() : 'null';
   }
 });
 
-Hash.toQueryString.addPair = function(key, value, prefix) {
-  key = encodeURIComponent(key);
-  if (value === undefined) this.push(key);
-  else this.push(key + '=' + (value == null ? '' : encodeURIComponent(value)));
-}
+$w('abs round ceil floor').each(function(method){
+  Number.prototype[method] = Math[method].methodize();
+});
+function $H(object) {
+  return new Hash(object);
+};
 
-Object.extend(Hash.prototype, Enumerable);
-Object.extend(Hash.prototype, {
-  _each: function(iterator) {
-    for (var key in this) {
-      var value = this[key];
-      if (value && value == Hash.prototype[key]) continue;
+var Hash = Class.create(Enumerable, (function() {
 
-      var pair = [key, value];
-      pair.key = key;
-      pair.value = value;
-      iterator(pair);
-    }
-  },
+  function toQueryPair(key, value) {
+    if (Object.isUndefined(value)) return key;
+    return key + '=' + encodeURIComponent(String.interpret(value));
+  }
 
-  keys: function() {
-    return this.pluck('key');
-  },
+  return {
+    initialize: function(object) {
+      this._object = Object.isHash(object) ? object.toObject() : Object.clone(object);
+    },
 
-  values: function() {
-    return this.pluck('value');
-  },
+    _each: function(iterator) {
+      for (var key in this._object) {
+        var value = this._object[key], pair = [key, value];
+        pair.key = key;
+        pair.value = value;
+        iterator(pair);
+      }
+    },
 
-  merge: function(hash) {
-    return $H(hash).inject(this, function(mergedHash, pair) {
-      mergedHash[pair.key] = pair.value;
-      return mergedHash;
-    });
-  },
+    set: function(key, value) {
+      return this._object[key] = value;
+    },
 
-  remove: function() {
-    var result;
-    for(var i = 0, length = arguments.length; i < length; i++) {
-      var value = this[arguments[i]];
-      if (value !== undefined){
-        if (result === undefined) result = value;
-        else {
-          if (result.constructor != Array) result = [result];
-          result.push(value)
-        }
-      }
-      delete this[arguments[i]];
-    }
-    return result;
-  },
+    get: function(key) {
+      // simulating poorly supported hasOwnProperty
+      if (this._object[key] !== Object.prototype[key])
+        return this._object[key];
+    },
 
-  toQueryString: function() {
-    return Hash.toQueryString(this);
-  },
+    unset: function(key) {
+      var value = this._object[key];
+      delete this._object[key];
+      return value;
+    },
 
-  inspect: function() {
-    return '#<Hash:{' + this.map(function(pair) {
-      return pair.map(Object.inspect).join(': ');
-    }).join(', ') + '}>';
-  },
+    toObject: function() {
+      return Object.clone(this._object);
+    },
 
-  toJSON: function() {
-    return Hash.toJSON(this);
-  }
-});
+    keys: function() {
+      return this.pluck('key');
+    },
 
-function $H(object) {
-  if (object instanceof Hash) return object;
-  return new Hash(object);
-};
+    values: function() {
+      return this.pluck('value');
+    },
 
-// Safari iterates over shadowed properties
-if (function() {
-  var i = 0, Test = function(value) { this.key = value };
-  Test.prototype.key = 'foo';
-  for (var property in new Test('bar')) i++;
-  return i > 1;
-}()) Hash.prototype._each = function(iterator) {
-  var cache = [];
-  for (var key in this) {
-    var value = this[key];
-    if ((value && value == Hash.prototype[key]) || cache.include(key)) continue;
-    cache.push(key);
-    var pair = [key, value];
-    pair.key = key;
-    pair.value = value;
-    iterator(pair);
+    index: function(value) {
+      var match = this.detect(function(pair) {
+        return pair.value === value;
+      });
+      return match && match.key;
+    },
+
+    merge: function(object) {
+      return this.clone().update(object);
+    },
+
+    update: function(object) {
+      return new Hash(object).inject(this, function(result, pair) {
+        result.set(pair.key, pair.value);
+        return result;
+      });
+    },
+
+    toQueryString: function() {
+      return this.inject([], function(results, pair) {
+        var key = encodeURIComponent(pair.key), values = pair.value;
+
+        if (values && typeof values == 'object') {
+          if (Object.isArray(values))
+            return results.concat(values.map(toQueryPair.curry(key)));
+        } else results.push(toQueryPair(key, values));
+        return results;
+      }).join('&');
+    },
+
+    inspect: function() {
+      return '#<Hash:{' + this.map(function(pair) {
+        return pair.map(Object.inspect).join(': ');
+      }).join(', ') + '}>';
+    },
+
+    toJSON: function() {
+      return Object.toJSON(this.toObject());
+    },
+
+    clone: function() {
+      return new Hash(this);
+    }
   }
-};
-ObjectRange = Class.create();
-Object.extend(ObjectRange.prototype, Enumerable);
-Object.extend(ObjectRange.prototype, {
+})());
+
+Hash.prototype.toTemplateReplacements = Hash.prototype.toObject;
+Hash.from = $H;
+var ObjectRange = Class.create(Enumerable, {
   initialize: function(start, end, exclusive) {
     this.start = start;
     this.end = end;
@@ -923,7 +1117,7 @@ Object.extend(ObjectRange.prototype, {
 
 var $R = function(start, end, exclusive) {
   return new ObjectRange(start, end, exclusive);
-}
+};
 
 var Ajax = {
   getTransport: function() {
@@ -935,7 +1129,7 @@ var Ajax = {
   },
 
   activeRequestCount: 0
-}
+};
 
 Ajax.Responders = {
   responders: [],
@@ -955,10 +1149,10 @@ Ajax.Responders = {
 
   dispatch: function(callback, request, transport, json) {
     this.each(function(responder) {
-      if (typeof responder[callback] == 'function') {
+      if (Object.isFunction(responder[callback])) {
         try {
           responder[callback].apply(responder, [request, transport, json]);
-        } catch (e) {}
+        } catch (e) { }
       }
     });
   }
@@ -967,42 +1161,38 @@ Ajax.Responders = {
 Object.extend(Ajax.Responders, Enumerable);
 
 Ajax.Responders.register({
-  onCreate: function() {
-    Ajax.activeRequestCount++;
-  },
-  onComplete: function() {
-    Ajax.activeRequestCount--;
-  }
+  onCreate:   function() { Ajax.activeRequestCount++ },
+  onComplete: function() { Ajax.activeRequestCount-- }
 });
 
-Ajax.Base = function() {};
-Ajax.Base.prototype = {
-  setOptions: function(options) {
+Ajax.Base = Class.create({
+  initialize: function(options) {
     this.options = {
       method:       'post',
       asynchronous: true,
       contentType:  'application/x-www-form-urlencoded',
       encoding:     'UTF-8',
-      parameters:   ''
-    }
-    Object.extend(this.options, options || {});
+      parameters:   '',
+      evalJSON:     true,
+      evalJS:       true
+    };
+    Object.extend(this.options, options || { });
 
     this.options.method = this.options.method.toLowerCase();
-    if (typeof this.options.parameters == 'string')
+
+    if (Object.isString(this.options.parameters))
       this.options.parameters = this.options.parameters.toQueryParams();
+    else if (Object.isHash(this.options.parameters))
+      this.options.parameters = this.options.parameters.toObject();
   }
-}
-
-Ajax.Request = Class.create();
-Ajax.Request.Events =
-  ['Uninitialized', 'Loading', 'Loaded', 'Interactive', 'Complete'];
+});
 
-Ajax.Request.prototype = Object.extend(new Ajax.Base(), {
+Ajax.Request = Class.create(Ajax.Base, {
   _complete: false,
 
-  initialize: function(url, options) {
+  initialize: function($super, url, options) {
+    $super(options);
     this.transport = Ajax.getTransport();
-    this.setOptions(options);
     this.request(url);
   },
 
@@ -1019,7 +1209,7 @@ Ajax.Request.prototype = Object.extend(new Ajax.Base(), {
 
     this.parameters = params;
 
-    if (params = Hash.toQueryString(params)) {
+    if (params = Object.toQueryString(params)) {
       // when GET, append parameters to URL
       if (this.method == 'get')
         this.url += (this.url.include('?') ? '&' : '?') + params;
@@ -1028,14 +1218,14 @@ Ajax.Request.prototype = Object.extend(new Ajax.Base(), {
     }
 
     try {
-      if (this.options.onCreate) this.options.onCreate(this.transport);
-      Ajax.Responders.dispatch('onCreate', this, this.transport);
+      var response = new Ajax.Response(this);
+      if (this.options.onCreate) this.options.onCreate(response);
+      Ajax.Responders.dispatch('onCreate', this, response);
 
       this.transport.open(this.method.toUpperCase(), this.url,
         this.options.asynchronous);
 
-      if (this.options.asynchronous)
-        setTimeout(function() { this.respondToReadyState(1) }.bind(this), 10);
+      if (this.options.asynchronous) this.respondToReadyState.bind(this).defer(1);
 
       this.transport.onreadystatechange = this.onStateChange.bind(this);
       this.setRequestHeaders();
@@ -1083,7 +1273,7 @@ Ajax.Request.prototype = Object.extend(new Ajax.Base(), {
     if (typeof this.options.requestHeaders == 'object') {
       var extras = this.options.requestHeaders;
 
-      if (typeof extras.push == 'function')
+      if (Object.isFunction(extras.push))
         for (var i = 0, length = extras.length; i < length; i += 2)
           headers[extras[i]] = extras[i+1];
       else
@@ -1095,33 +1285,39 @@ Ajax.Request.prototype = Object.extend(new Ajax.Base(), {
   },
 
   success: function() {
-    return !this.transport.status
-        || (this.transport.status >= 200 && this.transport.status < 300);
+    var status = this.getStatus();
+    return !status || (status >= 200 && status < 300);
+  },
+
+  getStatus: function() {
+    try {
+      return this.transport.status || 0;
+    } catch (e) { return 0 }
   },
 
   respondToReadyState: function(readyState) {
-    var state = Ajax.Request.Events[readyState];
-    var transport = this.transport, json = this.evalJSON();
+    var state = Ajax.Request.Events[readyState], response = new Ajax.Response(this);
 
     if (state == 'Complete') {
       try {
         this._complete = true;
-        (this.options['on' + this.transport.status]
+        (this.options['on' + response.status]
          || this.options['on' + (this.success() ? 'Success' : 'Failure')]
-         || Prototype.emptyFunction)(transport, json);
+         || Prototype.emptyFunction)(response, response.headerJSON);
       } catch (e) {
         this.dispatchException(e);
       }
 
-      var contentType = this.getHeader('Content-type');
-      if (contentType && contentType.strip().
-        match(/^(text|application)\/(x-)?(java|ecma)script(;.*)?$/i))
-          this.evalResponse();
+      var contentType = response.getHeader('Content-type');
+      if (this.options.evalJS == 'force'
+          || (this.options.evalJS && this.isSameOrigin() && contentType
+          && contentType.match(/^\s*(text|application)\/(x-)?(java|ecma)script(;.*)?\s*$/i)))
+        this.evalResponse();
     }
 
     try {
-      (this.options['on' + state] || Prototype.emptyFunction)(transport, json);
-      Ajax.Responders.dispatch('on' + state, this, transport, json);
+      (this.options['on' + state] || Prototype.emptyFunction)(response, response.headerJSON);
+      Ajax.Responders.dispatch('on' + state, this, response, response.headerJSON);
     } catch (e) {
       this.dispatchException(e);
     }
@@ -1132,16 +1328,18 @@ Ajax.Request.prototype = Object.extend(new Ajax.Base(), {
     }
   },
 
-  getHeader: function(name) {
-    try {
-      return this.transport.getResponseHeader(name);
-    } catch (e) { return null }
+  isSameOrigin: function() {
+    var m = this.url.match(/^\s*https?:\/\/[^\/]*/);
+    return !m || (m[0] == '#{protocol}//#{domain}#{port}'.interpolate({
+      protocol: location.protocol,
+      domain: document.domain,
+      port: location.port ? ':' + location.port : ''
+    }));
   },
 
-  evalJSON: function() {
+  getHeader: function(name) {
     try {
-      var json = this.getHeader('X-JSON');
-      return json ? json.evalJSON() : null;
+      return this.transport.getResponseHeader(name) || null;
     } catch (e) { return null }
   },
 
@@ -1159,57 +1357,128 @@ Ajax.Request.prototype = Object.extend(new Ajax.Base(), {
   }
 });
 
-Ajax.Updater = Class.create();
+Ajax.Request.Events =
+  ['Uninitialized', 'Loading', 'Loaded', 'Interactive', 'Complete'];
 
-Object.extend(Object.extend(Ajax.Updater.prototype, Ajax.Request.prototype), {
-  initialize: function(container, url, options) {
-    this.container = {
-      success: (container.success || container),
-      failure: (container.failure || (container.success ? null : container))
+Ajax.Response = Class.create({
+  initialize: function(request){
+    this.request = request;
+    var transport  = this.transport  = request.transport,
+        readyState = this.readyState = transport.readyState;
+
+    if((readyState > 2 && !Prototype.Browser.IE) || readyState == 4) {
+      this.status       = this.getStatus();
+      this.statusText   = this.getStatusText();
+      this.responseText = String.interpret(transport.responseText);
+      this.headerJSON   = this._getHeaderJSON();
     }
 
-    this.transport = Ajax.getTransport();
-    this.setOptions(options);
+    if(readyState == 4) {
+      var xml = transport.responseXML;
+      this.responseXML  = Object.isUndefined(xml) ? null : xml;
+      this.responseJSON = this._getResponseJSON();
+    }
+  },
 
-    var onComplete = this.options.onComplete || Prototype.emptyFunction;
-    this.options.onComplete = (function(transport, param) {
-      this.updateContent();
-      onComplete(transport, param);
-    }).bind(this);
+  status:      0,
+  statusText: '',
 
-    this.request(url);
+  getStatus: Ajax.Request.prototype.getStatus,
+
+  getStatusText: function() {
+    try {
+      return this.transport.statusText || '';
+    } catch (e) { return '' }
   },
 
-  updateContent: function() {
-    var receiver = this.container[this.success() ? 'success' : 'failure'];
-    var response = this.transport.responseText;
+  getHeader: Ajax.Request.prototype.getHeader,
 
-    if (!this.options.evalScripts) response = response.stripScripts();
+  getAllHeaders: function() {
+    try {
+      return this.getAllResponseHeaders();
+    } catch (e) { return null }
+  },
 
-    if (receiver = $(receiver)) {
-      if (this.options.insertion)
-        new this.options.insertion(receiver, response);
-      else
-        receiver.update(response);
-    }
+  getResponseHeader: function(name) {
+    return this.transport.getResponseHeader(name);
+  },
 
-    if (this.success()) {
-      if (this.onComplete)
-        setTimeout(this.onComplete.bind(this), 10);
-    }
-  }
-});
+  getAllResponseHeaders: function() {
+    return this.transport.getAllResponseHeaders();
+  },
 
-Ajax.PeriodicalUpdater = Class.create();
-Ajax.PeriodicalUpdater.prototype = Object.extend(new Ajax.Base(), {
-  initialize: function(container, url, options) {
-    this.setOptions(options);
-    this.onComplete = this.options.onComplete;
+  _getHeaderJSON: function() {
+    var json = this.getHeader('X-JSON');
+    if (!json) return null;
+    json = decodeURIComponent(escape(json));
+    try {
+      return json.evalJSON(this.request.options.sanitizeJSON ||
+        !this.request.isSameOrigin());
+    } catch (e) {
+      this.request.dispatchException(e);
+    }
+  },
+
+  _getResponseJSON: function() {
+    var options = this.request.options;
+    if (!options.evalJSON || (options.evalJSON != 'force' &&
+      !(this.getHeader('Content-type') || '').include('application/json')) ||
+        this.responseText.blank())
+          return null;
+    try {
+      return this.responseText.evalJSON(options.sanitizeJSON ||
+        !this.request.isSameOrigin());
+    } catch (e) {
+      this.request.dispatchException(e);
+    }
+  }
+});
+
+Ajax.Updater = Class.create(Ajax.Request, {
+  initialize: function($super, container, url, options) {
+    this.container = {
+      success: (container.success || container),
+      failure: (container.failure || (container.success ? null : container))
+    };
+
+    options = Object.clone(options);
+    var onComplete = options.onComplete;
+    options.onComplete = (function(response, json) {
+      this.updateContent(response.responseText);
+      if (Object.isFunction(onComplete)) onComplete(response, json);
+    }).bind(this);
+
+    $super(url, options);
+  },
+
+  updateContent: function(responseText) {
+    var receiver = this.container[this.success() ? 'success' : 'failure'],
+        options = this.options;
+
+    if (!options.evalScripts) responseText = responseText.stripScripts();
+
+    if (receiver = $(receiver)) {
+      if (options.insertion) {
+        if (Object.isString(options.insertion)) {
+          var insertion = { }; insertion[options.insertion] = responseText;
+          receiver.insert(insertion);
+        }
+        else options.insertion(receiver, responseText);
+      }
+      else receiver.update(responseText);
+    }
+  }
+});
+
+Ajax.PeriodicalUpdater = Class.create(Ajax.Base, {
+  initialize: function($super, container, url, options) {
+    $super(options);
+    this.onComplete = this.options.onComplete;
 
     this.frequency = (this.options.frequency || 2);
     this.decay = (this.options.decay || 1);
 
-    this.updater = {};
+    this.updater = { };
     this.container = container;
     this.url = url;
 
@@ -1227,15 +1496,14 @@ Ajax.PeriodicalUpdater.prototype = Object.extend(new Ajax.Base(), {
     (this.onComplete || Prototype.emptyFunction).apply(this, arguments);
   },
 
-  updateComplete: function(request) {
+  updateComplete: function(response) {
     if (this.options.decay) {
-      this.decay = (request.responseText == this.lastText ?
+      this.decay = (response.responseText == this.lastText ?
         this.decay * this.options.decay : 1);
 
-      this.lastText = request.responseText;
+      this.lastText = response.responseText;
     }
-    this.timer = setTimeout(this.onTimerEvent.bind(this),
-      this.decay * this.frequency * 1000);
+    this.timer = this.onTimerEvent.bind(this).delay(this.decay * this.frequency);
   },
 
   onTimerEvent: function() {
@@ -1248,7 +1516,7 @@ function $(element) {
       elements.push($(arguments[i]));
     return elements;
   }
-  if (typeof element == 'string')
+  if (Object.isString(element))
     element = document.getElementById(element);
   return Element.extend(element);
 }
@@ -1259,65 +1527,52 @@ if (Prototype.BrowserFeatures.XPath) {
     var query = document.evaluate(expression, $(parentElement) || document,
       null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null);
     for (var i = 0, length = query.snapshotLength; i < length; i++)
-      results.push(query.snapshotItem(i));
+      results.push(Element.extend(query.snapshotItem(i)));
     return results;
   };
-
-  document.getElementsByClassName = function(className, parentElement) {
-    var q = ".//*[contains(concat(' ', @class, ' '), ' " + className + " ')]";
-    return document._getElementsByXPath(q, parentElement);
-  }
-
-} else document.getElementsByClassName = function(className, parentElement) {
-  var children = ($(parentElement) || document.body).getElementsByTagName('*');
-  var elements = [], child;
-  for (var i = 0, length = children.length; i < length; i++) {
-    child = children[i];
-    if (Element.hasClassName(child, className))
-      elements.push(Element.extend(child));
-  }
-  return elements;
-};
+}
 
 /*--------------------------------------------------------------------------*/
 
-if (!window.Element) var Element = {};
-
-Element.extend = function(element) {
-  var F = Prototype.BrowserFeatures;
-  if (!element || !element.tagName || element.nodeType == 3 ||
-   element._extended || F.SpecificElementExtensions || element == window)
-    return element;
-
-  var methods = {}, tagName = element.tagName, cache = Element.extend.cache,
-   T = Element.Methods.ByTag;
-
-  // extend methods for all tags (Safari doesn't need this)
-  if (!F.ElementExtensions) {
-    Object.extend(methods, Element.Methods),
-    Object.extend(methods, Element.Methods.Simulated);
-  }
-
-  // extend methods for specific tags
-  if (T[tagName]) Object.extend(methods, T[tagName]);
-
-  for (var property in methods) {
-    var value = methods[property];
-    if (typeof value == 'function' && !(property in element))
-      element[property] = cache.findOrStore(value);
-  }
-
-  element._extended = Prototype.emptyFunction;
-  return element;
-};
+if (!window.Node) var Node = { };
+
+if (!Node.ELEMENT_NODE) {
+  // DOM level 2 ECMAScript Language Binding
+  Object.extend(Node, {
+    ELEMENT_NODE: 1,
+    ATTRIBUTE_NODE: 2,
+    TEXT_NODE: 3,
+    CDATA_SECTION_NODE: 4,
+    ENTITY_REFERENCE_NODE: 5,
+    ENTITY_NODE: 6,
+    PROCESSING_INSTRUCTION_NODE: 7,
+    COMMENT_NODE: 8,
+    DOCUMENT_NODE: 9,
+    DOCUMENT_TYPE_NODE: 10,
+    DOCUMENT_FRAGMENT_NODE: 11,
+    NOTATION_NODE: 12
+  });
+}
 
-Element.extend.cache = {
-  findOrStore: function(value) {
-    return this[value] = this[value] || function() {
-      return value.apply(null, [this].concat($A(arguments)));
+(function() {
+  var element = this.Element;
+  this.Element = function(tagName, attributes) {
+    attributes = attributes || { };
+    tagName = tagName.toLowerCase();
+    var cache = Element.cache;
+    if (Prototype.Browser.IE && attributes.name) {
+      tagName = '<' + tagName + ' name="' + attributes.name + '">';
+      delete attributes.name;
+      return Element.writeAttribute(document.createElement(tagName), attributes);
     }
-  }
-};
+    if (!cache[tagName]) cache[tagName] = Element.extend(document.createElement(tagName));
+    return Element.writeAttribute(cache[tagName].cloneNode(false), attributes);
+  };
+  Object.extend(this.Element, element || { });
+  if (element) this.Element.prototype = element.prototype;
+}).call(window);
+
+Element.cache = { };
 
 Element.Methods = {
   visible: function(element) {
@@ -1331,12 +1586,14 @@ Element.Methods = {
   },
 
   hide: function(element) {
-    $(element).style.display = 'none';
+    element = $(element);
+    element.style.display = 'none';
     return element;
   },
 
   show: function(element) {
-    $(element).style.display = '';
+    element = $(element);
+    element.style.display = '';
     return element;
   },
 
@@ -1346,28 +1603,78 @@ Element.Methods = {
     return element;
   },
 
-  update: function(element, html) {
-    html = typeof html == 'undefined' ? '' : html.toString();
-    $(element).innerHTML = html.stripScripts();
-    setTimeout(function() {html.evalScripts()}, 10);
+  update: function(element, content) {
+    element = $(element);
+    if (content && content.toElement) content = content.toElement();
+    if (Object.isElement(content)) return element.update().insert(content);
+    content = Object.toHTML(content);
+    element.innerHTML = content.stripScripts();
+    content.evalScripts.bind(content).defer();
     return element;
   },
 
-  replace: function(element, html) {
+  replace: function(element, content) {
     element = $(element);
-    html = typeof html == 'undefined' ? '' : html.toString();
-    if (element.outerHTML) {
-      element.outerHTML = html.stripScripts();
-    } else {
+    if (content && content.toElement) content = content.toElement();
+    else if (!Object.isElement(content)) {
+      content = Object.toHTML(content);
       var range = element.ownerDocument.createRange();
-      range.selectNodeContents(element);
-      element.parentNode.replaceChild(
-        range.createContextualFragment(html.stripScripts()), element);
+      range.selectNode(element);
+      content.evalScripts.bind(content).defer();
+      content = range.createContextualFragment(content.stripScripts());
+    }
+    element.parentNode.replaceChild(content, element);
+    return element;
+  },
+
+  insert: function(element, insertions) {
+    element = $(element);
+
+    if (Object.isString(insertions) || Object.isNumber(insertions) ||
+        Object.isElement(insertions) || (insertions && (insertions.toElement || insertions.toHTML)))
+          insertions = {bottom:insertions};
+
+    var content, insert, tagName, childNodes;
+
+    for (var position in insertions) {
+      content  = insertions[position];
+      position = position.toLowerCase();
+      insert = Element._insertionTranslations[position];
+
+      if (content && content.toElement) content = content.toElement();
+      if (Object.isElement(content)) {
+        insert(element, content);
+        continue;
+      }
+
+      content = Object.toHTML(content);
+
+      tagName = ((position == 'before' || position == 'after')
+        ? element.parentNode : element).tagName.toUpperCase();
+
+      childNodes = Element._getContentFromAnonymousElement(tagName, content.stripScripts());
+
+      if (position == 'top' || position == 'after') childNodes.reverse();
+      childNodes.each(insert.curry(element));
+
+      content.evalScripts.bind(content).defer();
     }
-    setTimeout(function() {html.evalScripts()}, 10);
+
     return element;
   },
 
+  wrap: function(element, wrapper, attributes) {
+    element = $(element);
+    if (Object.isElement(wrapper))
+      $(wrapper).writeAttribute(attributes || { });
+    else if (Object.isString(wrapper)) wrapper = new Element(wrapper, attributes);
+    else wrapper = new Element('div', wrapper);
+    if (element.parentNode)
+      element.parentNode.replaceChild(wrapper, element);
+    wrapper.appendChild(element);
+    return wrapper;
+  },
+
   inspect: function(element) {
     element = $(element);
     var result = '<' + element.tagName.toLowerCase();
@@ -1393,7 +1700,7 @@ Element.Methods = {
   },
 
   descendants: function(element) {
-    return $A($(element).getElementsByTagName('*')).each(Element.extend);
+    return $(element).select("*");
   },
 
   firstDescendant: function(element) {
@@ -1423,7 +1730,7 @@ Element.Methods = {
   },
 
   match: function(element, selector) {
-    if (typeof selector == 'string')
+    if (Object.isString(selector))
       selector = new Selector(selector);
     return selector.match($(element));
   },
@@ -1432,56 +1739,86 @@ Element.Methods = {
     element = $(element);
     if (arguments.length == 1) return $(element.parentNode);
     var ancestors = element.ancestors();
-    return expression ? Selector.findElement(ancestors, expression, index) :
-      ancestors[index || 0];
+    return Object.isNumber(expression) ? ancestors[expression] :
+      Selector.findElement(ancestors, expression, index);
   },
 
   down: function(element, expression, index) {
     element = $(element);
     if (arguments.length == 1) return element.firstDescendant();
-    var descendants = element.descendants();
-    return expression ? Selector.findElement(descendants, expression, index) :
-      descendants[index || 0];
+    return Object.isNumber(expression) ? element.descendants()[expression] :
+      Element.select(element, expression)[index || 0];
   },
 
   previous: function(element, expression, index) {
     element = $(element);
     if (arguments.length == 1) return $(Selector.handlers.previousElementSibling(element));
     var previousSiblings = element.previousSiblings();
-    return expression ? Selector.findElement(previousSiblings, expression, index) :
-      previousSiblings[index || 0];
+    return Object.isNumber(expression) ? previousSiblings[expression] :
+      Selector.findElement(previousSiblings, expression, index);
   },
 
   next: function(element, expression, index) {
     element = $(element);
     if (arguments.length == 1) return $(Selector.handlers.nextElementSibling(element));
     var nextSiblings = element.nextSiblings();
-    return expression ? Selector.findElement(nextSiblings, expression, index) :
-      nextSiblings[index || 0];
+    return Object.isNumber(expression) ? nextSiblings[expression] :
+      Selector.findElement(nextSiblings, expression, index);
   },
 
-  getElementsBySelector: function() {
+  select: function() {
     var args = $A(arguments), element = $(args.shift());
     return Selector.findChildElements(element, args);
   },
 
-  getElementsByClassName: function(element, className) {
-    return document.getElementsByClassName(className, element);
+  adjacent: function() {
+    var args = $A(arguments), element = $(args.shift());
+    return Selector.findChildElements(element.parentNode, args).without(element);
+  },
+
+  identify: function(element) {
+    element = $(element);
+    var id = element.readAttribute('id'), self = arguments.callee;
+    if (id) return id;
+    do { id = 'anonymous_element_' + self.counter++ } while ($(id));
+    element.writeAttribute('id', id);
+    return id;
   },
 
   readAttribute: function(element, name) {
     element = $(element);
     if (Prototype.Browser.IE) {
-      if (!element.attributes) return null;
-      var t = Element._attributeTranslations;
+      var t = Element._attributeTranslations.read;
       if (t.values[name]) return t.values[name](element, name);
-      if (t.names[name])  name = t.names[name];
-      var attribute = element.attributes[name];
-      return attribute ? attribute.nodeValue : null;
+      if (t.names[name]) name = t.names[name];
+      if (name.include(':')) {
+        return (!element.attributes || !element.attributes[name]) ? null :
+         element.attributes[name].value;
+      }
     }
     return element.getAttribute(name);
   },
 
+  writeAttribute: function(element, name, value) {
+    element = $(element);
+    var attributes = { }, t = Element._attributeTranslations.write;
+
+    if (typeof name == 'object') attributes = name;
+    else attributes[name] = Object.isUndefined(value) ? true : value;
+
+    for (var attr in attributes) {
+      name = t.names[attr] || attr;
+      value = attributes[attr];
+      if (t.values[attr]) name = t.values[attr](element, value);
+      if (value === false || value === null)
+        element.removeAttribute(name);
+      else if (value === true)
+        element.setAttribute(name, name);
+      else element.setAttribute(name, value);
+    }
+    return element;
+  },
+
   getHeight: function(element) {
     return $(element).getDimensions().height;
   },
@@ -1497,39 +1834,28 @@ Element.Methods = {
   hasClassName: function(element, className) {
     if (!(element = $(element))) return;
     var elementClassName = element.className;
-    if (elementClassName.length == 0) return false;
-    if (elementClassName == className ||
-        elementClassName.match(new RegExp("(^|\\s)" + className + "(\\s|$)")))
-      return true;
-    return false;
+    return (elementClassName.length > 0 && (elementClassName == className ||
+      new RegExp("(^|\\s)" + className + "(\\s|$)").test(elementClassName)));
   },
 
   addClassName: function(element, className) {
     if (!(element = $(element))) return;
-    Element.classNames(element).add(className);
+    if (!element.hasClassName(className))
+      element.className += (element.className ? ' ' : '') + className;
     return element;
   },
 
   removeClassName: function(element, className) {
     if (!(element = $(element))) return;
-    Element.classNames(element).remove(className);
+    element.className = element.className.replace(
+      new RegExp("(^|\\s+)" + className + "(\\s+|$)"), ' ').strip();
     return element;
   },
 
   toggleClassName: function(element, className) {
     if (!(element = $(element))) return;
-    Element.classNames(element)[element.hasClassName(className) ? 'remove' : 'add'](className);
-    return element;
-  },
-
-  observe: function() {
-    Event.observe.apply(Event, arguments);
-    return $A(arguments).first();
-  },
-
-  stopObserving: function() {
-    Event.stopObserving.apply(Event, arguments);
-    return $A(arguments).first();
+    return element[element.hasClassName(className) ?
+      'removeClassName' : 'addClassName'](className);
   },
 
   // removes whitespace-only text node children
@@ -1551,14 +1877,22 @@ Element.Methods = {
 
   descendantOf: function(element, ancestor) {
     element = $(element), ancestor = $(ancestor);
+
+    if (element.compareDocumentPosition)
+      return (element.compareDocumentPosition(ancestor) & 8) === 8;
+
+    if (ancestor.contains)
+      return ancestor.contains(element) && ancestor !== element;
+
     while (element = element.parentNode)
       if (element == ancestor) return true;
+
     return false;
   },
 
   scrollTo: function(element) {
     element = $(element);
-    var pos = Position.cumulativeOffset(element);
+    var pos = element.cumulativeOffset();
     window.scrollTo(pos[0], pos[1]);
     return element;
   },
@@ -1567,7 +1901,7 @@ Element.Methods = {
     element = $(element);
     style = style == 'float' ? 'cssFloat' : style.camelize();
     var value = element.style[style];
-    if (!value) {
+    if (!value || value == 'auto') {
       var css = document.defaultView.getComputedStyle(element, null);
       value = css ? css[style] : null;
     }
@@ -1579,16 +1913,20 @@ Element.Methods = {
     return $(element).getStyle('opacity');
   },
 
-  setStyle: function(element, styles, camelized) {
+  setStyle: function(element, styles) {
     element = $(element);
-    var elementStyle = element.style;
-
+    var elementStyle = element.style, match;
+    if (Object.isString(styles)) {
+      element.style.cssText += ';' + styles;
+      return styles.include('opacity') ?
+        element.setOpacity(styles.match(/opacity:\s*(\d?\.?\d*)/)[1]) : element;
+    }
     for (var property in styles)
-      if (property == 'opacity') element.setOpacity(styles[property])
+      if (property == 'opacity') element.setOpacity(styles[property]);
       else
         elementStyle[(property == 'float' || property == 'cssFloat') ?
-          (elementStyle.styleFloat === undefined ? 'cssFloat' : 'styleFloat') :
-          (camelized ? property : property.camelize())] = styles[property];
+          (Object.isUndefined(elementStyle.styleFloat) ? 'cssFloat' : 'styleFloat') :
+            property] = styles[property];
 
     return element;
   },
@@ -1602,7 +1940,7 @@ Element.Methods = {
 
   getDimensions: function(element) {
     element = $(element);
-    var display = $(element).getStyle('display');
+    var display = element.getStyle('display');
     if (display != 'none' && display != null) // Safari bug
       return {width: element.offsetWidth, height: element.offsetHeight};
 
@@ -1631,7 +1969,7 @@ Element.Methods = {
       element.style.position = 'relative';
       // Opera returns the offset relative to the positioning context, when an
       // element is position relative but top and left have not been defined
-      if (window.opera) {
+      if (Prototype.Browser.Opera) {
         element.style.top = 0;
         element.style.left = 0;
       }
@@ -1655,8 +1993,8 @@ Element.Methods = {
   makeClipping: function(element) {
     element = $(element);
     if (element._overflow) return element;
-    element._overflow = element.style.overflow || 'auto';
-    if ((Element.getStyle(element, 'overflow') || 'visible') != 'hidden')
+    element._overflow = Element.getStyle(element, 'overflow') || 'auto';
+    if (element._overflow !== 'hidden')
       element.style.overflow = 'hidden';
     return element;
   },
@@ -1667,28 +2005,266 @@ Element.Methods = {
     element.style.overflow = element._overflow == 'auto' ? '' : element._overflow;
     element._overflow = null;
     return element;
+  },
+
+  cumulativeOffset: function(element) {
+    var valueT = 0, valueL = 0;
+    do {
+      valueT += element.offsetTop  || 0;
+      valueL += element.offsetLeft || 0;
+      element = element.offsetParent;
+    } while (element);
+    return Element._returnOffset(valueL, valueT);
+  },
+
+  positionedOffset: function(element) {
+    var valueT = 0, valueL = 0;
+    do {
+      valueT += element.offsetTop  || 0;
+      valueL += element.offsetLeft || 0;
+      element = element.offsetParent;
+      if (element) {
+        if (element.tagName.toUpperCase() == 'BODY') break;
+        var p = Element.getStyle(element, 'position');
+        if (p !== 'static') break;
+      }
+    } while (element);
+    return Element._returnOffset(valueL, valueT);
+  },
+
+  absolutize: function(element) {
+    element = $(element);
+    if (element.getStyle('position') == 'absolute') return element;
+    // Position.prepare(); // To be done manually by Scripty when it needs it.
+
+    var offsets = element.positionedOffset();
+    var top     = offsets[1];
+    var left    = offsets[0];
+    var width   = element.clientWidth;
+    var height  = element.clientHeight;
+
+    element._originalLeft   = left - parseFloat(element.style.left  || 0);
+    element._originalTop    = top  - parseFloat(element.style.top || 0);
+    element._originalWidth  = element.style.width;
+    element._originalHeight = element.style.height;
+
+    element.style.position = 'absolute';
+    element.style.top    = top + 'px';
+    element.style.left   = left + 'px';
+    element.style.width  = width + 'px';
+    element.style.height = height + 'px';
+    return element;
+  },
+
+  relativize: function(element) {
+    element = $(element);
+    if (element.getStyle('position') == 'relative') return element;
+    // Position.prepare(); // To be done manually by Scripty when it needs it.
+
+    element.style.position = 'relative';
+    var top  = parseFloat(element.style.top  || 0) - (element._originalTop || 0);
+    var left = parseFloat(element.style.left || 0) - (element._originalLeft || 0);
+
+    element.style.top    = top + 'px';
+    element.style.left   = left + 'px';
+    element.style.height = element._originalHeight;
+    element.style.width  = element._originalWidth;
+    return element;
+  },
+
+  cumulativeScrollOffset: function(element) {
+    var valueT = 0, valueL = 0;
+    do {
+      valueT += element.scrollTop  || 0;
+      valueL += element.scrollLeft || 0;
+      element = element.parentNode;
+    } while (element);
+    return Element._returnOffset(valueL, valueT);
+  },
+
+  getOffsetParent: function(element) {
+    if (element.offsetParent) return $(element.offsetParent);
+    if (element == document.body) return $(element);
+
+    while ((element = element.parentNode) && element != document.body)
+      if (Element.getStyle(element, 'position') != 'static')
+        return $(element);
+
+    return $(document.body);
+  },
+
+  viewportOffset: function(forElement) {
+    var valueT = 0, valueL = 0;
+
+    var element = forElement;
+    do {
+      valueT += element.offsetTop  || 0;
+      valueL += element.offsetLeft || 0;
+
+      // Safari fix
+      if (element.offsetParent == document.body &&
+        Element.getStyle(element, 'position') == 'absolute') break;
+
+    } while (element = element.offsetParent);
+
+    element = forElement;
+    do {
+      if (!Prototype.Browser.Opera || (element.tagName && (element.tagName.toUpperCase() == 'BODY'))) {
+        valueT -= element.scrollTop  || 0;
+        valueL -= element.scrollLeft || 0;
+      }
+    } while (element = element.parentNode);
+
+    return Element._returnOffset(valueL, valueT);
+  },
+
+  clonePosition: function(element, source) {
+    var options = Object.extend({
+      setLeft:    true,
+      setTop:     true,
+      setWidth:   true,
+      setHeight:  true,
+      offsetTop:  0,
+      offsetLeft: 0
+    }, arguments[2] || { });
+
+    // find page position of source
+    source = $(source);
+    var p = source.viewportOffset();
+
+    // find coordinate system to use
+    element = $(element);
+    var delta = [0, 0];
+    var parent = null;
+    // delta [0,0] will do fine with position: fixed elements,
+    // position:absolute needs offsetParent deltas
+    if (Element.getStyle(element, 'position') == 'absolute') {
+      parent = element.getOffsetParent();
+      delta = parent.viewportOffset();
+    }
+
+    // correct by body offsets (fixes Safari)
+    if (parent == document.body) {
+      delta[0] -= document.body.offsetLeft;
+      delta[1] -= document.body.offsetTop;
+    }
+
+    // set position
+    if (options.setLeft)   element.style.left  = (p[0] - delta[0] + options.offsetLeft) + 'px';
+    if (options.setTop)    element.style.top   = (p[1] - delta[1] + options.offsetTop) + 'px';
+    if (options.setWidth)  element.style.width = source.offsetWidth + 'px';
+    if (options.setHeight) element.style.height = source.offsetHeight + 'px';
+    return element;
   }
 };
 
+Element.Methods.identify.counter = 1;
+
 Object.extend(Element.Methods, {
-  childOf: Element.Methods.descendantOf,
+  getElementsBySelector: Element.Methods.select,
   childElements: Element.Methods.immediateDescendants
 });
 
+Element._attributeTranslations = {
+  write: {
+    names: {
+      className: 'class',
+      htmlFor:   'for'
+    },
+    values: { }
+  }
+};
+
 if (Prototype.Browser.Opera) {
-  Element.Methods._getStyle = Element.Methods.getStyle;
-  Element.Methods.getStyle = function(element, style) {
-    switch(style) {
-      case 'left':
-      case 'top':
-      case 'right':
-      case 'bottom':
-        if (Element._getStyle(element, 'position') == 'static') return null;
-      default: return Element._getStyle(element, style);
+  Element.Methods.getStyle = Element.Methods.getStyle.wrap(
+    function(proceed, element, style) {
+      switch (style) {
+        case 'left': case 'top': case 'right': case 'bottom':
+          if (proceed(element, 'position') === 'static') return null;
+        case 'height': case 'width':
+          // returns '0px' for hidden elements; we want it to return null
+          if (!Element.visible(element)) return null;
+
+          // returns the border-box dimensions rather than the content-box
+          // dimensions, so we subtract padding and borders from the value
+          var dim = parseInt(proceed(element, style), 10);
+
+          if (dim !== element['offset' + style.capitalize()])
+            return dim + 'px';
+
+          var properties;
+          if (style === 'height') {
+            properties = ['border-top-width', 'padding-top',
+             'padding-bottom', 'border-bottom-width'];
+          }
+          else {
+            properties = ['border-left-width', 'padding-left',
+             'padding-right', 'border-right-width'];
+          }
+          return properties.inject(dim, function(memo, property) {
+            var val = proceed(element, property);
+            return val === null ? memo : memo - parseInt(val, 10);
+          }) + 'px';
+        default: return proceed(element, style);
+      }
     }
-  };
+  );
+
+  Element.Methods.readAttribute = Element.Methods.readAttribute.wrap(
+    function(proceed, element, attribute) {
+      if (attribute === 'title') return element.title;
+      return proceed(element, attribute);
+    }
+  );
 }
+
 else if (Prototype.Browser.IE) {
+  // IE doesn't report offsets correctly for static elements, so we change them
+  // to "relative" to get the values, then change them back.
+  Element.Methods.getOffsetParent = Element.Methods.getOffsetParent.wrap(
+    function(proceed, element) {
+      element = $(element);
+      // IE throws an error if element is not in document
+      try { element.offsetParent }
+      catch(e) { return $(document.body) }
+      var position = element.getStyle('position');
+      if (position !== 'static') return proceed(element);
+      element.setStyle({ position: 'relative' });
+      var value = proceed(element);
+      element.setStyle({ position: position });
+      return value;
+    }
+  );
+
+  $w('positionedOffset viewportOffset').each(function(method) {
+    Element.Methods[method] = Element.Methods[method].wrap(
+      function(proceed, element) {
+        element = $(element);
+        try { element.offsetParent }
+        catch(e) { return Element._returnOffset(0,0) }
+        var position = element.getStyle('position');
+        if (position !== 'static') return proceed(element);
+        // Trigger hasLayout on the offset parent so that IE6 reports
+        // accurate offsetTop and offsetLeft values for position: fixed.
+        var offsetParent = element.getOffsetParent();
+        if (offsetParent && offsetParent.getStyle('position') === 'fixed')
+          offsetParent.setStyle({ zoom: 1 });
+        element.setStyle({ position: 'relative' });
+        var value = proceed(element);
+        element.setStyle({ position: position });
+        return value;
+      }
+    );
+  });
+
+  Element.Methods.cumulativeOffset = Element.Methods.cumulativeOffset.wrap(
+    function(proceed, element) {
+      try { element.offsetParent }
+      catch(e) { return Element._returnOffset(0,0) }
+      return proceed(element);
+    }
+  );
+
   Element.Methods.getStyle = function(element, style) {
     element = $(element);
     style = (style == 'float' || style == 'cssFloat') ? 'styleFloat' : style.camelize();
@@ -1703,126 +2279,321 @@ else if (Prototype.Browser.IE) {
 
     if (value == 'auto') {
       if ((style == 'width' || style == 'height') && (element.getStyle('display') != 'none'))
-        return element['offset'+style.capitalize()] + 'px';
+        return element['offset' + style.capitalize()] + 'px';
       return null;
     }
     return value;
   };
 
   Element.Methods.setOpacity = function(element, value) {
+    function stripAlpha(filter){
+      return filter.replace(/alpha\([^\)]*\)/gi,'');
+    }
     element = $(element);
+    var currentStyle = element.currentStyle;
+    if ((currentStyle && !currentStyle.hasLayout) ||
+      (!currentStyle && element.style.zoom == 'normal'))
+        element.style.zoom = 1;
+
     var filter = element.getStyle('filter'), style = element.style;
     if (value == 1 || value === '') {
-      style.filter = filter.replace(/alpha\([^\)]*\)/gi,'');
+      (filter = stripAlpha(filter)) ?
+        style.filter = filter : style.removeAttribute('filter');
       return element;
     } else if (value < 0.00001) value = 0;
-    style.filter = filter.replace(/alpha\([^\)]*\)/gi, '') +
+    style.filter = stripAlpha(filter) +
       'alpha(opacity=' + (value * 100) + ')';
     return element;
   };
 
-  // IE is missing .innerHTML support for TABLE-related elements
-  Element.Methods.update = function(element, html) {
-    element = $(element);
-    html = typeof html == 'undefined' ? '' : html.toString();
-    var tagName = element.tagName.toUpperCase();
-    if (['THEAD','TBODY','TR','TD'].include(tagName)) {
-      var div = document.createElement('div');
-      switch (tagName) {
-        case 'THEAD':
-        case 'TBODY':
-          div.innerHTML = '<table><tbody>' +  html.stripScripts() + '</tbody></table>';
-          depth = 2;
-          break;
-        case 'TR':
-          div.innerHTML = '<table><tbody><tr>' +  html.stripScripts() + '</tr></tbody></table>';
-          depth = 3;
-          break;
-        case 'TD':
-          div.innerHTML = '<table><tbody><tr><td>' +  html.stripScripts() + '</td></tr></tbody></table>';
-          depth = 4;
+  Element._attributeTranslations = {
+    read: {
+      names: {
+        'class': 'className',
+        'for':   'htmlFor'
+      },
+      values: {
+        _getAttr: function(element, attribute) {
+          return element.getAttribute(attribute, 2);
+        },
+        _getAttrNode: function(element, attribute) {
+          var node = element.getAttributeNode(attribute);
+          return node ? node.value : "";
+        },
+        _getEv: function(element, attribute) {
+          attribute = element.getAttribute(attribute);
+          return attribute ? attribute.toString().slice(23, -2) : null;
+        },
+        _flag: function(element, attribute) {
+          return $(element).hasAttribute(attribute) ? attribute : null;
+        },
+        style: function(element) {
+          return element.style.cssText.toLowerCase();
+        },
+        title: function(element) {
+          return element.title;
+        }
       }
-      $A(element.childNodes).each(function(node) { element.removeChild(node) });
-      depth.times(function() { div = div.firstChild });
-      $A(div.childNodes).each(function(node) { element.appendChild(node) });
-    } else {
-      element.innerHTML = html.stripScripts();
     }
-    setTimeout(function() { html.evalScripts() }, 10);
-    return element;
-  }
-}
-else if (Prototype.Browser.Gecko) {
-  Element.Methods.setOpacity = function(element, value) {
-    element = $(element);
-    element.style.opacity = (value == 1) ? 0.999999 :
-      (value === '') ? '' : (value < 0.00001) ? 0 : value;
+  };
+
+  Element._attributeTranslations.write = {
+    names: Object.extend({
+      cellpadding: 'cellPadding',
+      cellspacing: 'cellSpacing'
+    }, Element._attributeTranslations.read.names),
+    values: {
+      checked: function(element, value) {
+        element.checked = !!value;
+      },
+
+      style: function(element, value) {
+        element.style.cssText = value ? value : '';
+      }
+    }
+  };
+
+  Element._attributeTranslations.has = {};
+
+  $w('colSpan rowSpan vAlign dateTime accessKey tabIndex ' +
+      'encType maxLength readOnly longDesc frameBorder').each(function(attr) {
+    Element._attributeTranslations.write.names[attr.toLowerCase()] = attr;
+    Element._attributeTranslations.has[attr.toLowerCase()] = attr;
+  });
+
+  (function(v) {
+    Object.extend(v, {
+      href:        v._getAttr,
+      src:         v._getAttr,
+      type:        v._getAttr,
+      action:      v._getAttrNode,
+      disabled:    v._flag,
+      checked:     v._flag,
+      readonly:    v._flag,
+      multiple:    v._flag,
+      onload:      v._getEv,
+      onunload:    v._getEv,
+      onclick:     v._getEv,
+      ondblclick:  v._getEv,
+      onmousedown: v._getEv,
+      onmouseup:   v._getEv,
+      onmouseover: v._getEv,
+      onmousemove: v._getEv,
+      onmouseout:  v._getEv,
+      onfocus:     v._getEv,
+      onblur:      v._getEv,
+      onkeypress:  v._getEv,
+      onkeydown:   v._getEv,
+      onkeyup:     v._getEv,
+      onsubmit:    v._getEv,
+      onreset:     v._getEv,
+      onselect:    v._getEv,
+      onchange:    v._getEv
+    });
+  })(Element._attributeTranslations.read.values);
+}
+
+else if (Prototype.Browser.Gecko && /rv:1\.8\.0/.test(navigator.userAgent)) {
+  Element.Methods.setOpacity = function(element, value) {
+    element = $(element);
+    element.style.opacity = (value == 1) ? 0.999999 :
+      (value === '') ? '' : (value < 0.00001) ? 0 : value;
     return element;
   };
 }
 
-Element._attributeTranslations = {
-  names: {
-    colspan:   "colSpan",
-    rowspan:   "rowSpan",
-    valign:    "vAlign",
-    datetime:  "dateTime",
-    accesskey: "accessKey",
-    tabindex:  "tabIndex",
-    enctype:   "encType",
-    maxlength: "maxLength",
-    readonly:  "readOnly",
-    longdesc:  "longDesc"
-  },
-  values: {
-    _getAttr: function(element, attribute) {
-      return element.getAttribute(attribute, 2);
-    },
-    _flag: function(element, attribute) {
-      return $(element).hasAttribute(attribute) ? attribute : null;
-    },
-    style: function(element) {
-      return element.style.cssText.toLowerCase();
-    },
-    title: function(element) {
-      var node = element.getAttributeNode('title');
-      return node.specified ? node.nodeValue : null;
+else if (Prototype.Browser.WebKit) {
+  Element.Methods.setOpacity = function(element, value) {
+    element = $(element);
+    element.style.opacity = (value == 1 || value === '') ? '' :
+      (value < 0.00001) ? 0 : value;
+
+    if (value == 1)
+      if(element.tagName.toUpperCase() == 'IMG' && element.width) {
+        element.width++; element.width--;
+      } else try {
+        var n = document.createTextNode(' ');
+        element.appendChild(n);
+        element.removeChild(n);
+      } catch (e) { }
+
+    return element;
+  };
+
+  // Safari returns margins on body which is incorrect if the child is absolutely
+  // positioned.  For performance reasons, redefine Element#cumulativeOffset for
+  // KHTML/WebKit only.
+  Element.Methods.cumulativeOffset = function(element) {
+    var valueT = 0, valueL = 0;
+    do {
+      valueT += element.offsetTop  || 0;
+      valueL += element.offsetLeft || 0;
+      if (element.offsetParent == document.body)
+        if (Element.getStyle(element, 'position') == 'absolute') break;
+
+      element = element.offsetParent;
+    } while (element);
+
+    return Element._returnOffset(valueL, valueT);
+  };
+}
+
+if (Prototype.Browser.IE || Prototype.Browser.Opera) {
+  // IE and Opera are missing .innerHTML support for TABLE-related and SELECT elements
+  Element.Methods.update = function(element, content) {
+    element = $(element);
+
+    if (content && content.toElement) content = content.toElement();
+    if (Object.isElement(content)) return element.update().insert(content);
+
+    content = Object.toHTML(content);
+    var tagName = element.tagName.toUpperCase();
+
+    if (tagName in Element._insertionTranslations.tags) {
+      $A(element.childNodes).each(function(node) { element.removeChild(node) });
+      Element._getContentFromAnonymousElement(tagName, content.stripScripts())
+        .each(function(node) { element.appendChild(node) });
+    }
+    else element.innerHTML = content.stripScripts();
+
+    content.evalScripts.bind(content).defer();
+    return element;
+  };
+}
+
+if ('outerHTML' in document.createElement('div')) {
+  Element.Methods.replace = function(element, content) {
+    element = $(element);
+
+    if (content && content.toElement) content = content.toElement();
+    if (Object.isElement(content)) {
+      element.parentNode.replaceChild(content, element);
+      return element;
+    }
+
+    content = Object.toHTML(content);
+    var parent = element.parentNode, tagName = parent.tagName.toUpperCase();
+
+    if (Element._insertionTranslations.tags[tagName]) {
+      var nextSibling = element.next();
+      var fragments = Element._getContentFromAnonymousElement(tagName, content.stripScripts());
+      parent.removeChild(element);
+      if (nextSibling)
+        fragments.each(function(node) { parent.insertBefore(node, nextSibling) });
+      else
+        fragments.each(function(node) { parent.appendChild(node) });
     }
+    else element.outerHTML = content.stripScripts();
+
+    content.evalScripts.bind(content).defer();
+    return element;
+  };
+}
+
+Element._returnOffset = function(l, t) {
+  var result = [l, t];
+  result.left = l;
+  result.top = t;
+  return result;
+};
+
+Element._getContentFromAnonymousElement = function(tagName, html) {
+  var div = new Element('div'), t = Element._insertionTranslations.tags[tagName];
+  if (t) {
+    div.innerHTML = t[0] + html + t[1];
+    t[2].times(function() { div = div.firstChild });
+  } else div.innerHTML = html;
+  return $A(div.childNodes);
+};
+
+Element._insertionTranslations = {
+  before: function(element, node) {
+    element.parentNode.insertBefore(node, element);
+  },
+  top: function(element, node) {
+    element.insertBefore(node, element.firstChild);
+  },
+  bottom: function(element, node) {
+    element.appendChild(node);
+  },
+  after: function(element, node) {
+    element.parentNode.insertBefore(node, element.nextSibling);
+  },
+  tags: {
+    TABLE:  ['<table>',                '</table>',                   1],
+    TBODY:  ['<table><tbody>',         '</tbody></table>',           2],
+    TR:     ['<table><tbody><tr>',     '</tr></tbody></table>',      3],
+    TD:     ['<table><tbody><tr><td>', '</td></tr></tbody></table>', 4],
+    SELECT: ['<select>',               '</select>',                  1]
   }
 };
 
 (function() {
-  Object.extend(this, {
-    href: this._getAttr,
-    src:  this._getAttr,
-    type: this._getAttr,
-    disabled: this._flag,
-    checked:  this._flag,
-    readonly: this._flag,
-    multiple: this._flag
+  Object.extend(this.tags, {
+    THEAD: this.tags.TBODY,
+    TFOOT: this.tags.TBODY,
+    TH:    this.tags.TD
   });
-}).call(Element._attributeTranslations.values);
+}).call(Element._insertionTranslations);
 
 Element.Methods.Simulated = {
   hasAttribute: function(element, attribute) {
-    var t = Element._attributeTranslations, node;
-    attribute = t.names[attribute] || attribute;
-    node = $(element).getAttributeNode(attribute);
-    return node && node.specified;
+    attribute = Element._attributeTranslations.has[attribute] || attribute;
+    var node = $(element).getAttributeNode(attribute);
+    return !!(node && node.specified);
   }
 };
 
-Element.Methods.ByTag = {};
+Element.Methods.ByTag = { };
 
 Object.extend(Element, Element.Methods);
 
 if (!Prototype.BrowserFeatures.ElementExtensions &&
document.createElement('div').__proto__) {
-  window.HTMLElement = {};
-  window.HTMLElement.prototype = document.createElement('div').__proto__;
   document.createElement('div')['__proto__']) {
+  window.HTMLElement = { };
+  window.HTMLElement.prototype = document.createElement('div')['__proto__'];
   Prototype.BrowserFeatures.ElementExtensions = true;
 }
 
+Element.extend = (function() {
+  if (Prototype.BrowserFeatures.SpecificElementExtensions)
+    return Prototype.K;
+
+  var Methods = { }, ByTag = Element.Methods.ByTag;
+
+  var extend = Object.extend(function(element) {
+    if (!element || element._extendedByPrototype ||
+        element.nodeType != 1 || element == window) return element;
+
+    var methods = Object.clone(Methods),
+      tagName = element.tagName.toUpperCase(), property, value;
+
+    // extend methods for specific tags
+    if (ByTag[tagName]) Object.extend(methods, ByTag[tagName]);
+
+    for (property in methods) {
+      value = methods[property];
+      if (Object.isFunction(value) && !(property in element))
+        element[property] = value.methodize();
+    }
+
+    element._extendedByPrototype = Prototype.emptyFunction;
+    return element;
+
+  }, {
+    refresh: function() {
+      // extend methods for all tags (Safari doesn't need this)
+      if (!Prototype.BrowserFeatures.ElementExtensions) {
+        Object.extend(Methods, Element.Methods);
+        Object.extend(Methods, Element.Methods.Simulated);
+      }
+    }
+  });
+
+  extend.refresh();
+  return extend;
+})();
+
 Element.hasAttribute = function(element, attribute) {
   if (element.hasAttribute) return element.hasAttribute(attribute);
   return Element.Methods.Simulated.hasAttribute(element, attribute);
@@ -1847,26 +2618,26 @@ Element.addMethods = function(methods) {
     methods = arguments[1];
   }
 
-  if (!tagName) Object.extend(Element.Methods, methods || {});
+  if (!tagName) Object.extend(Element.Methods, methods || { });
   else {
-    if (tagName.constructor == Array) tagName.each(extend);
+    if (Object.isArray(tagName)) tagName.each(extend);
     else extend(tagName);
   }
 
   function extend(tagName) {
     tagName = tagName.toUpperCase();
     if (!Element.Methods.ByTag[tagName])
-      Element.Methods.ByTag[tagName] = {};
+      Element.Methods.ByTag[tagName] = { };
     Object.extend(Element.Methods.ByTag[tagName], methods);
   }
 
   function copy(methods, destination, onlyIfAbsent) {
     onlyIfAbsent = onlyIfAbsent || false;
-    var cache = Element.extend.cache;
     for (var property in methods) {
       var value = methods[property];
+      if (!Object.isFunction(value)) continue;
       if (!onlyIfAbsent || !(property in destination))
-        destination[property] = cache.findOrStore(value);
+        destination[property] = value.methodize();
     }
   }
 
@@ -1890,8 +2661,8 @@ Element.addMethods = function(methods) {
     klass = 'HTML' + tagName.capitalize() + 'Element';
     if (window[klass]) return window[klass];
 
-    window[klass] = {};
-    window[klass].prototype = document.createElement(tagName).__proto__;
+    window[klass] = { };
+    window[klass].prototype = document.createElement(tagName)['__proto__'];
     return window[klass];
   }
 
@@ -1903,169 +2674,113 @@ Element.addMethods = function(methods) {
   if (F.SpecificElementExtensions) {
     for (var tag in Element.Methods.ByTag) {
       var klass = findDOMClass(tag);
-      if (typeof klass == "undefined") continue;
+      if (Object.isUndefined(klass)) continue;
       copy(T[tag], klass.prototype);
     }
   }
 
   Object.extend(Element, Element.Methods);
   delete Element.ByTag;
-};
-
-var Toggle = { display: Element.toggle };
-
-/*--------------------------------------------------------------------------*/
 
-Abstract.Insertion = function(adjacency) {
-  this.adjacency = adjacency;
-}
-
-Abstract.Insertion.prototype = {
-  initialize: function(element, content) {
-    this.element = $(element);
-    this.content = content.stripScripts();
+  if (Element.extend.refresh) Element.extend.refresh();
+  Element.cache = { };
+};
 
-    if (this.adjacency && this.element.insertAdjacentHTML) {
-      try {
-        this.element.insertAdjacentHTML(this.adjacency, this.content);
-      } catch (e) {
-        var tagName = this.element.tagName.toUpperCase();
-        if (['TBODY', 'TR'].include(tagName)) {
-          this.insertContent(this.contentFromAnonymousTable());
-        } else {
-          throw e;
-        }
+document.viewport = {
+  getDimensions: function() {
+    var dimensions = { }, B = Prototype.Browser;
+    $w('width height').each(function(d) {
+      var D = d.capitalize();
+      if (B.WebKit && !document.evaluate) {
+        // Safari <3.0 needs self.innerWidth/Height
+        dimensions[d] = self['inner' + D];
+      } else if (B.Opera && parseFloat(window.opera.version()) < 9.5) {
+        // Opera <9.5 needs document.body.clientWidth/Height
+        dimensions[d] = document.body['client' + D]
+      } else {
+        dimensions[d] = document.documentElement['client' + D];
       }
-    } else {
-      this.range = this.element.ownerDocument.createRange();
-      if (this.initializeRange) this.initializeRange();
-      this.insertContent([this.range.createContextualFragment(this.content)]);
-    }
-
-    setTimeout(function() {content.evalScripts()}, 10);
+    });
+    return dimensions;
   },
 
-  contentFromAnonymousTable: function() {
-    var div = document.createElement('div');
-    div.innerHTML = '<table><tbody>' + this.content + '</tbody></table>';
-    return $A(div.childNodes[0].childNodes[0].childNodes);
-  }
-}
-
-var Insertion = new Object();
-
-Insertion.Before = Class.create();
-Insertion.Before.prototype = Object.extend(new Abstract.Insertion('beforeBegin'), {
-  initializeRange: function() {
-    this.range.setStartBefore(this.element);
+  getWidth: function() {
+    return this.getDimensions().width;
   },
 
-  insertContent: function(fragments) {
-    fragments.each((function(fragment) {
-      this.element.parentNode.insertBefore(fragment, this.element);
-    }).bind(this));
-  }
-});
-
-Insertion.Top = Class.create();
-Insertion.Top.prototype = Object.extend(new Abstract.Insertion('afterBegin'), {
-  initializeRange: function() {
-    this.range.selectNodeContents(this.element);
-    this.range.collapse(true);
+  getHeight: function() {
+    return this.getDimensions().height;
   },
 
-  insertContent: function(fragments) {
-    fragments.reverse(false).each((function(fragment) {
-      this.element.insertBefore(fragment, this.element.firstChild);
-    }).bind(this));
+  getScrollOffsets: function() {
+    return Element._returnOffset(
+      window.pageXOffset || document.documentElement.scrollLeft || document.body.scrollLeft,
+      window.pageYOffset || document.documentElement.scrollTop || document.body.scrollTop);
   }
-});
+};
+/* Portions of the Selector class are derived from Jack Slocum's DomQuery,
+ * part of YUI-Ext version 0.40, distributed under the terms of an MIT-style
+ * license.  Please see http://www.yui-ext.com/ for more information. */
 
-Insertion.Bottom = Class.create();
-Insertion.Bottom.prototype = Object.extend(new Abstract.Insertion('beforeEnd'), {
-  initializeRange: function() {
-    this.range.selectNodeContents(this.element);
-    this.range.collapse(this.element);
-  },
+var Selector = Class.create({
+  initialize: function(expression) {
+    this.expression = expression.strip();
 
-  insertContent: function(fragments) {
-    fragments.each((function(fragment) {
-      this.element.appendChild(fragment);
-    }).bind(this));
-  }
-});
+    if (this.shouldUseSelectorsAPI()) {
+      this.mode = 'selectorsAPI';
+    } else if (this.shouldUseXPath()) {
+      this.mode = 'xpath';
+      this.compileXPathMatcher();
+    } else {
+      this.mode = "normal";
+      this.compileMatcher();
+    }
 
-Insertion.After = Class.create();
-Insertion.After.prototype = Object.extend(new Abstract.Insertion('afterEnd'), {
-  initializeRange: function() {
-    this.range.setStartAfter(this.element);
   },
 
-  insertContent: function(fragments) {
-    fragments.each((function(fragment) {
-      this.element.parentNode.insertBefore(fragment,
-        this.element.nextSibling);
-    }).bind(this));
-  }
-});
-
-/*--------------------------------------------------------------------------*/
-
-Element.ClassNames = Class.create();
-Element.ClassNames.prototype = {
-  initialize: function(element) {
-    this.element = $(element);
-  },
+  shouldUseXPath: function() {
+    if (!Prototype.BrowserFeatures.XPath) return false;
 
-  _each: function(iterator) {
-    this.element.className.split(/\s+/).select(function(name) {
-      return name.length > 0;
-    })._each(iterator);
-  },
+    var e = this.expression;
 
-  set: function(className) {
-    this.element.className = className;
-  },
+    // Safari 3 chokes on :*-of-type and :empty
+    if (Prototype.Browser.WebKit &&
+     (e.include("-of-type") || e.include(":empty")))
+      return false;
 
-  add: function(classNameToAdd) {
-    if (this.include(classNameToAdd)) return;
-    this.set($A(this).concat(classNameToAdd).join(' '));
-  },
+    // XPath can't do namespaced attributes, nor can it read
+    // the "checked" property from DOM nodes
+    if ((/(\[[\w-]*?:|:checked)/).test(e))
+      return false;
 
-  remove: function(classNameToRemove) {
-    if (!this.include(classNameToRemove)) return;
-    this.set($A(this).without(classNameToRemove).join(' '));
+    return true;
   },
 
-  toString: function() {
-    return $A(this).join(' ');
-  }
-};
+  shouldUseSelectorsAPI: function() {
+    if (!Prototype.BrowserFeatures.SelectorsAPI) return false;
 
-Object.extend(Element.ClassNames.prototype, Enumerable);
-/* Portions of the Selector class are derived from Jack Slocum’s DomQuery,
- * part of YUI-Ext version 0.40, distributed under the terms of an MIT-style
- * license.  Please see http://www.yui-ext.com/ for more information. */
+    if (!Selector._div) Selector._div = new Element('div');
 
-var Selector = Class.create();
+    // Make sure the browser treats the selector as valid. Test on an
+    // isolated element to minimize cost of this check.
+    try {
+      Selector._div.querySelector(this.expression);
+    } catch(e) {
+      return false;
+    }
 
-Selector.prototype = {
-  initialize: function(expression) {
-    this.expression = expression.strip();
-    this.compileMatcher();
+    return true;
   },
 
   compileMatcher: function() {
-    // Selectors with namespaced attributes can't use the XPath version
-    if (Prototype.BrowserFeatures.XPath && !(/\[[\w-]*?:/).test(this.expression))
-      return this.compileXPathMatcher();
-
     var e = this.expression, ps = Selector.patterns, h = Selector.handlers,
         c = Selector.criteria, le, p, m;
 
     if (Selector._cache[e]) {
-      this.matcher = Selector._cache[e]; return;
+      this.matcher = Selector._cache[e];
+      return;
     }
+
     this.matcher = ["this.matcher = function(root) {",
                     "var r = root, h = Selector.handlers, c = false, n;"];
 
@@ -2074,8 +2789,8 @@ Selector.prototype = {
       for (var i in ps) {
         p = ps[i];
         if (m = e.match(p)) {
-          this.matcher.push(typeof c[i] == 'function' ? c[i](m) :
-             new Template(c[i]).evaluate(m));
+          this.matcher.push(Object.isFunction(c[i]) ? c[i](m) :
+            new Template(c[i]).evaluate(m));
           e = e.replace(m[0], '');
           break;
         }
@@ -2089,7 +2804,7 @@ Selector.prototype = {
 
   compileXPathMatcher: function() {
     var e = this.expression, ps = Selector.patterns,
-        x = Selector.xpath, le,  m;
+        x = Selector.xpath, le, m;
 
     if (Selector._cache[e]) {
       this.xpath = Selector._cache[e]; return;
@@ -2100,7 +2815,7 @@ Selector.prototype = {
       le = e;
       for (var i in ps) {
         if (m = e.match(ps[i])) {
-          this.matcher.push(typeof x[i] == 'function' ? x[i](m) :
+          this.matcher.push(Object.isFunction(x[i]) ? x[i](m) :
             new Template(x[i]).evaluate(m));
           e = e.replace(m[0], '');
           break;
@@ -2114,12 +2829,63 @@ Selector.prototype = {
 
   findElements: function(root) {
     root = root || document;
-    if (this.xpath) return document._getElementsByXPath(this.xpath, root);
-    return this.matcher(root);
+    var e = this.expression, results;
+
+    switch (this.mode) {
+      case 'selectorsAPI':
+        // querySelectorAll queries document-wide, then filters to descendants
+        // of the context element. That's not what we want.
+        // Add an explicit context to the selector if necessary.
+        if (root !== document) {
+          var oldId = root.id, id = $(root).identify();
+          e = "#" + id + " " + e;
+        }
+
+        results = $A(root.querySelectorAll(e)).map(Element.extend);
+        root.id = oldId;
+
+        return results;
+      case 'xpath':
+        return document._getElementsByXPath(this.xpath, root);
+      default:
+       return this.matcher(root);
+    }
   },
 
   match: function(element) {
-    return this.findElements(document).include(element);
+    this.tokens = [];
+
+    var e = this.expression, ps = Selector.patterns, as = Selector.assertions;
+    var le, p, m;
+
+    while (e && le !== e && (/\S/).test(e)) {
+      le = e;
+      for (var i in ps) {
+        p = ps[i];
+        if (m = e.match(p)) {
+          // use the Selector.assertions methods unless the selector
+          // is too complex.
+          if (as[i]) {
+            this.tokens.push([i, Object.clone(m)]);
+            e = e.replace(m[0], '');
+          } else {
+            // reluctantly do a document-wide search
+            // and look for a match in the array
+            return this.findElements(document).include(element);
+          }
+        }
+      }
+    }
+
+    var match = true, name, matches;
+    for (var i = 0, token; token = this.tokens[i]; i++) {
+      name = token[0], matches = token[1];
+      if (!Selector.assertions[name](element, matches)) {
+        match = false; break;
+      }
+    }
+
+    return match;
   },
 
   toString: function() {
@@ -2129,10 +2895,10 @@ Selector.prototype = {
   inspect: function() {
     return "#<Selector:" + this.expression.inspect() + ">";
   }
-};
+});
 
 Object.extend(Selector, {
-  _cache: {},
+  _cache: { },
 
   xpath: {
     descendant:   "//*",
@@ -2146,15 +2912,19 @@ Object.extend(Selector, {
     },
     className:    "[contains(concat(' ', @class, ' '), ' #{1} ')]",
     id:           "[@id='#{1}']",
-    attrPresence: "[@#{1}]",
+    attrPresence: function(m) {
+      m[1] = m[1].toLowerCase();
+      return new Template("[@#{1}]").evaluate(m);
+    },
     attr: function(m) {
+      m[1] = m[1].toLowerCase();
       m[3] = m[5] || m[6];
       return new Template(Selector.xpath.operators[m[2]]).evaluate(m);
     },
     pseudo: function(m) {
       var h = Selector.xpath.pseudos[m[1]];
       if (!h) return '';
-      if (typeof h === 'function') return h(m);
+      if (Object.isFunction(h)) return h(m);
       return new Template(Selector.xpath.pseudos[m[1]]).evaluate(m);
     },
     operators: {
@@ -2170,20 +2940,20 @@ Object.extend(Selector, {
       'first-child': '[not(preceding-sibling::*)]',
       'last-child':  '[not(following-sibling::*)]',
       'only-child':  '[not(preceding-sibling::* or following-sibling::*)]',
-      'empty':       "[count(*) = 0 and (count(text()) = 0 or translate(text(), ' \t\r\n', '') = '')]",
+      'empty':       "[count(*) = 0 and (count(text()) = 0)]",
       'checked':     "[@checked]",
-      'disabled':    "[@disabled]",
-      'enabled':     "[not(@disabled)]",
+      'disabled':    "[(@disabled) and (@type!='hidden')]",
+      'enabled':     "[not(@disabled) and (@type!='hidden')]",
       'not': function(m) {
         var e = m[6], p = Selector.patterns,
-            x = Selector.xpath, le, m, v;
+            x = Selector.xpath, le, v;
 
         var exclusion = [];
         while (e && le != e && (/\S/).test(e)) {
           le = e;
           for (var i in p) {
             if (m = e.match(p[i])) {
-              v = typeof x[i] == 'function' ? x[i](m) : new Template(x[i]).evaluate(m);
+              v = Object.isFunction(x[i]) ? x[i](m) : new Template(x[i]).evaluate(m);
               exclusion.push("(" + v.substring(1, v.length - 1) + ")");
               e = e.replace(m[0], '');
               break;
@@ -2233,15 +3003,15 @@ Object.extend(Selector, {
   },
 
   criteria: {
-    tagName:      'n = h.tagName(n, r, "#{1}", c);   c = false;',
-    className:    'n = h.className(n, r, "#{1}", c); c = false;',
-    id:           'n = h.id(n, r, "#{1}", c);        c = false;',
-    attrPresence: 'n = h.attrPresence(n, r, "#{1}"); c = false;',
+    tagName:      'n = h.tagName(n, r, "#{1}", c);      c = false;',
+    className:    'n = h.className(n, r, "#{1}", c);    c = false;',
+    id:           'n = h.id(n, r, "#{1}", c);           c = false;',
+    attrPresence: 'n = h.attrPresence(n, r, "#{1}", c); c = false;',
     attr: function(m) {
       m[3] = (m[5] || m[6]);
-      return new Template('n = h.attr(n, r, "#{1}", "#{3}", "#{2}"); c = false;').evaluate(m);
+      return new Template('n = h.attr(n, r, "#{1}", "#{3}", "#{2}", c); c = false;').evaluate(m);
     },
-    pseudo:       function(m) {
+    pseudo: function(m) {
       if (m[6]) m[6] = m[6].replace(/"/g, '\\"');
       return new Template('n = h.pseudo(n, "#{1}", "#{6}", r, c); c = false;').evaluate(m);
     },
@@ -2263,9 +3033,34 @@ Object.extend(Selector, {
     tagName:      /^\s*(\*|[\w\-]+)(\b|$)?/,
     id:           /^#([\w\-\*]+)(\b|$)/,
     className:    /^\.([\w\-\*]+)(\b|$)/,
-    pseudo:       /^:((first|last|nth|nth-last|only)(-child|-of-type)|empty|checked|(en|dis)abled|not)(\((.*?)\))?(\b|$|\s|(?=:))/,
-    attrPresence: /^\[([\w]+)\]/,
-    attr:         /\[((?:[\w-]*:)?[\w-]+)\s*(?:([!^$*~|]?=)\s*((['"])([^\]]*?)\4|([^'"][^\]]*?)))?\]/
+    pseudo:
+/^:((first|last|nth|nth-last|only)(-child|-of-type)|empty|checked|(en|dis)abled|not)(\((.*?)\))?(\b|$|(?=\s|[:+~>]))/,
+    attrPresence: /^\[((?:[\w]+:)?[\w]+)\]/,
+    attr:         /\[((?:[\w-]*:)?[\w-]+)\s*(?:([!^$*~|]?=)\s*((['"])([^\4]*?)\4|([^'"][^\]]*?)))?\]/
+  },
+
+  // for Selector.match and Element#match
+  assertions: {
+    tagName: function(element, matches) {
+      return matches[1].toUpperCase() == element.tagName.toUpperCase();
+    },
+
+    className: function(element, matches) {
+      return Element.hasClassName(element, matches[1]);
+    },
+
+    id: function(element, matches) {
+      return element.id === matches[1];
+    },
+
+    attrPresence: function(element, matches) {
+      return Element.hasAttribute(element, matches[1]);
+    },
+
+    attr: function(element, matches) {
+      var nodeValue = Element.readAttribute(element, matches[1]);
+      return nodeValue && Selector.operators[matches[2]](nodeValue, matches[5] || matches[6]);
+    }
   },
 
   handlers: {
@@ -2279,14 +3074,15 @@ Object.extend(Selector, {
 
     // marks an array of nodes for counting
     mark: function(nodes) {
+      var _true = Prototype.emptyFunction;
       for (var i = 0, node; node = nodes[i]; i++)
-        node._counted = true;
+        node._countedByPrototype = _true;
       return nodes;
     },
 
     unmark: function(nodes) {
       for (var i = 0, node; node = nodes[i]; i++)
-        node._counted = undefined;
+        node._countedByPrototype = undefined;
       return nodes;
     },
 
@@ -2294,15 +3090,15 @@ Object.extend(Selector, {
     // "ofType" flag indicates whether we're indexing for nth-of-type
     // rather than nth-child
     index: function(parentNode, reverse, ofType) {
-      parentNode._counted = true;
+      parentNode._countedByPrototype = Prototype.emptyFunction;
       if (reverse) {
         for (var nodes = parentNode.childNodes, i = nodes.length - 1, j = 1; i >= 0; i--) {
-          node = nodes[i];
-          if (node.nodeType == 1 && (!ofType || node._counted)) node.nodeIndex = j++;
+          var node = nodes[i];
+          if (node.nodeType == 1 && (!ofType || node._countedByPrototype)) node.nodeIndex = j++;
         }
       } else {
         for (var i = 0, j = 1, nodes = parentNode.childNodes; node = nodes[i]; i++)
-          if (node.nodeType == 1 && (!ofType || node._counted)) node.nodeIndex = j++;
+          if (node.nodeType == 1 && (!ofType || node._countedByPrototype)) node.nodeIndex = j++;
       }
     },
 
@@ -2311,8 +3107,8 @@ Object.extend(Selector, {
       if (nodes.length == 0) return nodes;
       var results = [], n;
       for (var i = 0, l = nodes.length; i < l; i++)
-        if (!(n = nodes[i])._counted) {
-          n._counted = true;
+        if (!(n = nodes[i])._countedByPrototype) {
+          n._countedByPrototype = Prototype.emptyFunction;
           results.push(Element.extend(n));
         }
       return Selector.handlers.unmark(results);
@@ -2329,7 +3125,7 @@ Object.extend(Selector, {
     child: function(nodes) {
       var h = Selector.handlers;
       for (var i = 0, results = [], node; node = nodes[i]; i++) {
-        for (var j = 0, children = [], child; child = node.childNodes[j]; j++)
+        for (var j = 0, child; child = node.childNodes[j]; j++)
           if (child.nodeType == 1 && child.tagName != '!') results.push(child);
       }
       return results;
@@ -2352,7 +3148,7 @@ Object.extend(Selector, {
 
     nextElementSibling: function(node) {
       while (node = node.nextSibling)
-             if (node.nodeType == 1) return node;
+        if (node.nodeType == 1) return node;
       return null;
     },
 
@@ -2364,7 +3160,7 @@ Object.extend(Selector, {
 
     // TOKEN FUNCTIONS
     tagName: function(nodes, root, tagName, combinator) {
-      tagName = tagName.toUpperCase();
+      var uTagName = tagName.toUpperCase();
       var results = [], h = Selector.handlers;
       if (nodes) {
         if (combinator) {
@@ -2377,14 +3173,15 @@ Object.extend(Selector, {
           if (tagName == "*") return nodes;
         }
         for (var i = 0, node; node = nodes[i]; i++)
-          if (node.tagName.toUpperCase() == tagName) results.push(node);
+          if (node.tagName.toUpperCase() === uTagName) results.push(node);
         return results;
       } else return root.getElementsByTagName(tagName);
     },
 
     id: function(nodes, root, id, combinator) {
       var targetNode = $(id), h = Selector.handlers;
-      if (!nodes && root == document) return targetNode ? [targetNode] : [];
+      if (!targetNode) return [];
+      if (!nodes && root == document) return [targetNode];
       if (nodes) {
         if (combinator) {
           if (combinator == 'child') {
@@ -2423,15 +3220,18 @@ Object.extend(Selector, {
       return results;
     },
 
-    attrPresence: function(nodes, root, attr) {
+    attrPresence: function(nodes, root, attr, combinator) {
+      if (!nodes) nodes = root.getElementsByTagName("*");
+      if (nodes && combinator) nodes = this[combinator](nodes);
       var results = [];
       for (var i = 0, node; node = nodes[i]; i++)
         if (Element.hasAttribute(node, attr)) results.push(node);
       return results;
     },
 
-    attr: function(nodes, root, attr, value, operator) {
+    attr: function(nodes, root, attr, value, operator, combinator) {
       if (!nodes) nodes = root.getElementsByTagName("*");
+      if (nodes && combinator) nodes = this[combinator](nodes);
       var handler = Selector.operators[operator], results = [];
       for (var i = 0, node; node = nodes[i]; i++) {
         var nodeValue = Element.readAttribute(node, attr);
@@ -2510,7 +3310,7 @@ Object.extend(Selector, {
       var h = Selector.handlers, results = [], indexed = [], m;
       h.mark(nodes);
       for (var i = 0, node; node = nodes[i]; i++) {
-        if (!node.parentNode._counted) {
+        if (!node.parentNode._countedByPrototype) {
           h.index(node.parentNode, reverse, ofType);
           indexed.push(node.parentNode);
         }
@@ -2537,7 +3337,7 @@ Object.extend(Selector, {
     'empty': function(nodes, value, root) {
       for (var i = 0, results = [], node; node = nodes[i]; i++) {
         // IE treats comments as element nodes
-        if (node.tagName == '!' || (node.firstChild && !node.innerHTML.match(/^\s*$/))) continue;
+        if (node.tagName == '!' || node.firstChild) continue;
         results.push(node);
       }
       return results;
@@ -2548,14 +3348,15 @@ Object.extend(Selector, {
       var exclusions = new Selector(selector).findElements(root);
       h.mark(exclusions);
       for (var i = 0, results = [], node; node = nodes[i]; i++)
-        if (!node._counted) results.push(node);
+        if (!node._countedByPrototype) results.push(node);
       h.unmark(exclusions);
       return results;
     },
 
     'enabled': function(nodes, value, root) {
       for (var i = 0, results = [], node; node = nodes[i]; i++)
-        if (!node.disabled) results.push(node);
+        if (!node.disabled && (!node.type || node.type !== 'hidden'))
+          results.push(node);
       return results;
     },
 
@@ -2575,34 +3376,42 @@ Object.extend(Selector, {
   operators: {
     '=':  function(nv, v) { return nv == v; },
     '!=': function(nv, v) { return nv != v; },
-    '^=': function(nv, v) { return nv.startsWith(v); },
+    '^=': function(nv, v) { return nv == v || nv && nv.startsWith(v); },
+    '$=': function(nv, v) { return nv == v || nv && nv.endsWith(v); },
+    '*=': function(nv, v) { return nv == v || nv && nv.include(v); },
     '$=': function(nv, v) { return nv.endsWith(v); },
     '*=': function(nv, v) { return nv.include(v); },
     '~=': function(nv, v) { return (' ' + nv + ' ').include(' ' + v + ' '); },
-    '|=': function(nv, v) { return ('-' + nv.toUpperCase() + '-').include('-' + v.toUpperCase() + '-'); }
+    '|=': function(nv, v) { return ('-' + (nv || "").toUpperCase() +
+     '-').include('-' + (v || "").toUpperCase() + '-'); }
+  },
+
+  split: function(expression) {
+    var expressions = [];
+    expression.scan(/(([\w#:.~>+()\s-]+|\*|\[.*?\])+)\s*(,|$)/, function(m) {
+      expressions.push(m[1].strip());
+    });
+    return expressions;
   },
 
   matchElements: function(elements, expression) {
-    var matches = new Selector(expression).findElements(), h = Selector.handlers;
+    var matches = $$(expression), h = Selector.handlers;
     h.mark(matches);
     for (var i = 0, results = [], element; element = elements[i]; i++)
-      if (element._counted) results.push(element);
+      if (element._countedByPrototype) results.push(element);
     h.unmark(matches);
     return results;
   },
 
   findElement: function(elements, expression, index) {
-    if (typeof expression == 'number') {
+    if (Object.isNumber(expression)) {
       index = expression; expression = false;
     }
     return Selector.matchElements(elements, expression || '*')[index || 0];
   },
 
   findChildElements: function(element, expressions) {
-    var exprs = expressions.join(','), expressions = [];
-    exprs.scan(/(([\w#:.~>+()\s-]+|\*|\[.*?\])+)\s*(,|$)/, function(m) {
-      expressions.push(m[1].strip());
-    });
+    expressions = Selector.split(expressions.join(','));
     var results = [], h = Selector.handlers;
     for (var i = 0, l = expressions.length, selector; i < l; i++) {
       selector = new Selector(expressions[i].strip());
@@ -2612,6 +3421,25 @@ Object.extend(Selector, {
   }
 });
 
+if (Prototype.Browser.IE) {
+  Object.extend(Selector.handlers, {
+    // IE returns comment nodes on getElementsByTagName("*").
+    // Filter them out.
+    concat: function(a, b) {
+      for (var i = 0, node; node = b[i]; i++)
+        if (node.tagName !== "!") a.push(node);
+      return a;
+    },
+
+    // IE improperly serializes _countedByPrototype in (inner|outer)HTML.
+    unmark: function(nodes) {
+      for (var i = 0, node; node = nodes[i]; i++)
+        node.removeAttribute('_countedByPrototype');
+      return nodes;
+    }
+  });
+}
+
 function $$() {
   return Selector.findChildElements(document, $A(arguments));
 }
@@ -2621,13 +3449,19 @@ var Form = {
     return form;
   },
 
-  serializeElements: function(elements, getHash) {
-    var data = elements.inject({}, function(result, element) {
+  serializeElements: function(elements, options) {
+    if (typeof options != 'object') options = { hash: !!options };
+    else if (Object.isUndefined(options.hash)) options.hash = true;
+    var key, value, submitted = false, submit = options.submit;
+
+    var data = elements.inject({ }, function(result, element) {
       if (!element.disabled && element.name) {
-        var key = element.name, value = $(element).getValue();
-        if (value != null) {
-               if (key in result) {
-            if (result[key].constructor != Array) result[key] = [result[key]];
+        key = element.name; value = $(element).getValue();
+        if (value != null && element.type != 'file' && (element.type != 'submit' || (!submitted &&
+            submit !== false && (!submit || key == submit) && (submitted = true)))) {
+          if (key in result) {
+            // a key is already present; construct an array of values
+            if (!Object.isArray(result[key])) result[key] = [result[key]];
             result[key].push(value);
           }
           else result[key] = value;
@@ -2636,13 +3470,13 @@ var Form = {
       return result;
     });
 
-    return getHash ? data : Hash.toQueryString(data);
+    return options.hash ? data : Object.toQueryString(data);
   }
 };
 
 Form.Methods = {
-  serialize: function(form, getHash) {
-    return Form.serializeElements(Form.getElements(form), getHash);
+  serialize: function(form, options) {
+    return Form.serializeElements(Form.getElements(form), options);
   },
 
   getElements: function(form) {
@@ -2684,9 +3518,15 @@ Form.Methods = {
   },
 
   findFirstElement: function(form) {
-    return $(form).getElements().find(function(element) {
-      return element.type != 'hidden' && !element.disabled &&
-        ['input', 'select', 'textarea'].include(element.tagName.toLowerCase());
+    var elements = $(form).getElements().findAll(function(element) {
+      return 'hidden' != element.type && !element.disabled;
+    });
+    var firstByIndex = elements.findAll(function(element) {
+      return element.hasAttribute('tabIndex') && element.tabIndex >= 0;
+    }).sortBy(function(element) { return element.tabIndex }).first();
+
+    return firstByIndex ? firstByIndex : elements.find(function(element) {
+      return ['input', 'select', 'textarea'].include(element.tagName.toLowerCase());
     });
   },
 
@@ -2697,22 +3537,23 @@ Form.Methods = {
   },
 
   request: function(form, options) {
-    form = $(form), options = Object.clone(options || {});
+    form = $(form), options = Object.clone(options || { });
 
-    var params = options.parameters;
+    var params = options.parameters, action = form.readAttribute('action') || '';
+    if (action.blank()) action = window.location.href;
     options.parameters = form.serialize(true);
 
     if (params) {
-      if (typeof params == 'string') params = params.toQueryParams();
+      if (Object.isString(params)) params = params.toQueryParams();
       Object.extend(options.parameters, params);
     }
 
     if (form.hasAttribute('method') && !options.method)
       options.method = form.method;
 
-    return new Ajax.Request(form.readAttribute('action'), options);
+    return new Ajax.Request(action, options);
   }
-}
+};
 
 /*--------------------------------------------------------------------------*/
 
@@ -2726,7 +3567,7 @@ Form.Element = {
     $(element).select();
     return element;
   }
-}
+};
 
 Form.Element.Methods = {
   serialize: function(element) {
@@ -2734,9 +3575,9 @@ Form.Element.Methods = {
     if (!element.disabled && element.name) {
       var value = element.getValue();
       if (value != undefined) {
-        var pair = {};
+        var pair = { };
         pair[element.name] = value;
-        return Hash.toQueryString(pair);
+        return Object.toQueryString(pair);
       }
     }
     return '';
@@ -2748,6 +3589,13 @@ Form.Element.Methods = {
     return Form.Element.Serializers[method](element);
   },
 
+  setValue: function(element, value) {
+    element = $(element);
+    var method = element.tagName.toLowerCase();
+    Form.Element.Serializers[method](element, value);
+    return element;
+  },
+
   clear: function(element) {
     $(element).value = '';
     return element;
@@ -2762,15 +3610,14 @@ Form.Element.Methods = {
     try {
       element.focus();
       if (element.select && (element.tagName.toLowerCase() != 'input' ||
-        !['button', 'reset', 'submit'].include(element.type)))
+          !['button', 'reset', 'submit'].include(element.type)))
         element.select();
-    } catch (e) {}
+    } catch (e) { }
     return element;
   },
 
   disable: function(element) {
     element = $(element);
-    element.blur();
     element.disabled = true;
     return element;
   },
@@ -2780,7 +3627,7 @@ Form.Element.Methods = {
     element.disabled = false;
     return element;
   }
-}
+};
 
 /*--------------------------------------------------------------------------*/
 
@@ -2790,27 +3637,44 @@ var $F = Form.Element.Methods.getValue;
 /*--------------------------------------------------------------------------*/
 
 Form.Element.Serializers = {
-  input: function(element) {
+  input: function(element, value) {
     switch (element.type.toLowerCase()) {
       case 'checkbox':
       case 'radio':
-        return Form.Element.Serializers.inputSelector(element);
+        return Form.Element.Serializers.inputSelector(element, value);
       default:
-        return Form.Element.Serializers.textarea(element);
+        return Form.Element.Serializers.textarea(element, value);
     }
   },
 
-  inputSelector: function(element) {
-    return element.checked ? element.value : null;
+  inputSelector: function(element, value) {
+    if (Object.isUndefined(value)) return element.checked ? element.value : null;
+    else element.checked = !!value;
   },
 
-  textarea: function(element) {
-    return element.value;
+  textarea: function(element, value) {
+    if (Object.isUndefined(value)) return element.value;
+    else element.value = value;
   },
 
-  select: function(element) {
-    return this[element.type == 'select-one' ?
-      'selectOne' : 'selectMany'](element);
+  select: function(element, value) {
+    if (Object.isUndefined(value))
+      return this[element.type == 'select-one' ?
+        'selectOne' : 'selectMany'](element);
+    else {
+      var opt, currentValue, single = !Object.isArray(value);
+      for (var i = 0, length = element.length; i < length; i++) {
+        opt = element.options[i];
+        currentValue = this.optionValue(opt);
+        if (single) {
+          if (currentValue == value) {
+            opt.selected = true;
+            return;
+          }
+        }
+        else opt.selected = value.include(currentValue);
+      }
+    }
   },
 
   selectOne: function(element) {
@@ -2833,45 +3697,34 @@ Form.Element.Serializers = {
     // extend element because hasAttribute may not be native
     return Element.extend(opt).hasAttribute('value') ? opt.value : opt.text;
   }
-}
+};
 
 /*--------------------------------------------------------------------------*/
 
-Abstract.TimedObserver = function() {}
-Abstract.TimedObserver.prototype = {
-  initialize: function(element, frequency, callback) {
-    this.frequency = frequency;
+Abstract.TimedObserver = Class.create(PeriodicalExecuter, {
+  initialize: function($super, element, frequency, callback) {
+    $super(callback, frequency);
     this.element   = $(element);
-    this.callback  = callback;
-
     this.lastValue = this.getValue();
-    this.registerCallback();
-  },
-
-  registerCallback: function() {
-    setInterval(this.onTimerEvent.bind(this), this.frequency * 1000);
   },
 
-  onTimerEvent: function() {
+  execute: function() {
     var value = this.getValue();
-    var changed = ('string' == typeof this.lastValue && 'string' == typeof value
-      ? this.lastValue != value : String(this.lastValue) != String(value));
-    if (changed) {
+    if (Object.isString(this.lastValue) && Object.isString(value) ?
+        this.lastValue != value : String(this.lastValue) != String(value)) {
       this.callback(this.element, value);
       this.lastValue = value;
     }
   }
-}
+});
 
-Form.Element.Observer = Class.create();
-Form.Element.Observer.prototype = Object.extend(new Abstract.TimedObserver(), {
+Form.Element.Observer = Class.create(Abstract.TimedObserver, {
   getValue: function() {
     return Form.Element.getValue(this.element);
   }
 });
 
-Form.Observer = Class.create();
-Form.Observer.prototype = Object.extend(new Abstract.TimedObserver(), {
+Form.Observer = Class.create(Abstract.TimedObserver, {
   getValue: function() {
     return Form.serialize(this.element);
   }
@@ -2879,8 +3732,7 @@ Form.Observer.prototype = Object.extend(new Abstract.TimedObserver(), {
 
 /*--------------------------------------------------------------------------*/
 
-Abstract.EventObserver = function() {}
-Abstract.EventObserver.prototype = {
+Abstract.EventObserver = Class.create({
   initialize: function(element, callback) {
     this.element  = $(element);
     this.callback = callback;
@@ -2901,7 +3753,7 @@ Abstract.EventObserver.prototype = {
   },
 
   registerFormCallbacks: function() {
-    Form.getElements(this.element).each(this.registerCallback.bind(this));
+    Form.getElements(this.element).each(this.registerCallback, this);
   },
 
   registerCallback: function(element) {
@@ -2917,24 +3769,20 @@ Abstract.EventObserver.prototype = {
       }
     }
   }
-}
+});
 
-Form.Element.EventObserver = Class.create();
-Form.Element.EventObserver.prototype = Object.extend(new Abstract.EventObserver(), {
+Form.Element.EventObserver = Class.create(Abstract.EventObserver, {
   getValue: function() {
     return Form.Element.getValue(this.element);
   }
 });
 
-Form.EventObserver = Class.create();
-Form.EventObserver.prototype = Object.extend(new Abstract.EventObserver(), {
+Form.EventObserver = Class.create(Abstract.EventObserver, {
   getValue: function() {
     return Form.serialize(this.element);
   }
 });
-if (!window.Event) {
-  var Event = new Object();
-}
+if (!window.Event) var Event = { };
 
 Object.extend(Event, {
   KEY_BACKSPACE: 8,
@@ -2950,100 +3798,368 @@ Object.extend(Event, {
   KEY_END:      35,
   KEY_PAGEUP:   33,
   KEY_PAGEDOWN: 34,
+  KEY_INSERT:   45,
 
-  element: function(event) {
-    return $(event.target || event.srcElement);
-  },
+  cache: { },
 
-  isLeftClick: function(event) {
-    return (((event.which) && (event.which == 1)) ||
-            ((event.button) && (event.button == 1)));
-  },
+  relatedTarget: function(event) {
+    var element;
+    switch(event.type) {
+      case 'mouseover': element = event.fromElement; break;
+      case 'mouseout':  element = event.toElement;   break;
+      default: return null;
+    }
+    return Element.extend(element);
+  }
+});
 
-  pointerX: function(event) {
-    return event.pageX || (event.clientX +
-      (document.documentElement.scrollLeft || document.body.scrollLeft));
-  },
+Event.Methods = (function() {
+  var isButton;
 
-  pointerY: function(event) {
-    return event.pageY || (event.clientY +
-      (document.documentElement.scrollTop || document.body.scrollTop));
-  },
+  if (Prototype.Browser.IE) {
+    var buttonMap = { 0: 1, 1: 4, 2: 2 };
+    isButton = function(event, code) {
+      return event.button == buttonMap[code];
+    };
+
+  } else if (Prototype.Browser.WebKit) {
+    isButton = function(event, code) {
+      switch (code) {
+        case 0: return event.which == 1 && !event.metaKey;
+        case 1: return event.which == 1 && event.metaKey;
+        default: return false;
+      }
+    };
+
+  } else {
+    isButton = function(event, code) {
+      return event.which ? (event.which === code + 1) : (event.button === code);
+    };
+  }
 
-  stop: function(event) {
-    if (event.preventDefault) {
+  return {
+    isLeftClick:   function(event) { return isButton(event, 0) },
+    isMiddleClick: function(event) { return isButton(event, 1) },
+    isRightClick:  function(event) { return isButton(event, 2) },
+
+    element: function(event) {
+      event = Event.extend(event);
+
+      var node          = event.target,
+          type          = event.type,
+          currentTarget = event.currentTarget;
+
+      if (currentTarget && currentTarget.tagName) {
+        // Firefox screws up the "click" event when moving between radio buttons
+        // via arrow keys. It also screws up the "load" and "error" events on images,
+        // reporting the document as the target instead of the original image.
+        if (type === 'load' || type === 'error' ||
+          (type === 'click' && currentTarget.tagName.toLowerCase() === 'input'
+            && currentTarget.type === 'radio'))
+              node = currentTarget;
+      }
+      if (node.nodeType == Node.TEXT_NODE) node = node.parentNode;
+      return Element.extend(node);
+    },
+
+    findElement: function(event, expression) {
+      var element = Event.element(event);
+      if (!expression) return element;
+      var elements = [element].concat(element.ancestors());
+      return Selector.findElement(elements, expression, 0);
+    },
+
+    pointer: function(event) {
+      var docElement = document.documentElement,
+      body = document.body || { scrollLeft: 0, scrollTop: 0 };
+      return {
+        x: event.pageX || (event.clientX +
+          (docElement.scrollLeft || body.scrollLeft) -
+          (docElement.clientLeft || 0)),
+        y: event.pageY || (event.clientY +
+          (docElement.scrollTop || body.scrollTop) -
+          (docElement.clientTop || 0))
+      };
+    },
+
+    pointerX: function(event) { return Event.pointer(event).x },
+    pointerY: function(event) { return Event.pointer(event).y },
+
+    stop: function(event) {
+      Event.extend(event);
       event.preventDefault();
       event.stopPropagation();
-    } else {
-      event.returnValue = false;
-      event.cancelBubble = true;
+      event.stopped = true;
     }
-  },
+  };
+})();
 
-  // find the first node with the given tagName, starting from the
-  // node the event was triggered on; traverses the DOM upwards
-  findElement: function(event, tagName) {
-    var element = Event.element(event);
-    while (element.parentNode && (!element.tagName ||
-        (element.tagName.toUpperCase() != tagName.toUpperCase())))
-      element = element.parentNode;
-    return element;
-  },
+Event.extend = (function() {
+  var methods = Object.keys(Event.Methods).inject({ }, function(m, name) {
+    m[name] = Event.Methods[name].methodize();
+    return m;
+  });
+
+  if (Prototype.Browser.IE) {
+    Object.extend(methods, {
+      stopPropagation: function() { this.cancelBubble = true },
+      preventDefault:  function() { this.returnValue = false },
+      inspect: function() { return "[object Event]" }
+    });
 
-  observers: false,
+    return function(event) {
+      if (!event) return false;
+      if (event._extendedByPrototype) return event;
+
+      event._extendedByPrototype = Prototype.emptyFunction;
+      var pointer = Event.pointer(event);
+      Object.extend(event, {
+        target: event.srcElement,
+        relatedTarget: Event.relatedTarget(event),
+        pageX:  pointer.x,
+        pageY:  pointer.y
+      });
+      return Object.extend(event, methods);
+    };
+
+  } else {
+    Event.prototype = Event.prototype || document.createEvent("HTMLEvents")['__proto__'];
+    Object.extend(Event.prototype, methods);
+    return Prototype.K;
+  }
+})();
+
+Object.extend(Event, (function() {
+  var cache = Event.cache;
+
+  function getEventID(element) {
+    if (element._prototypeEventID) return element._prototypeEventID[0];
+    arguments.callee.id = arguments.callee.id || 1;
+    return element._prototypeEventID = [++arguments.callee.id];
+  }
+
+  function getDOMEventName(eventName) {
+    if (eventName && eventName.include(':')) return "dataavailable";
+    return eventName;
+  }
+
+  function getCacheForID(id) {
+    return cache[id] = cache[id] || { };
+  }
+
+  function getWrappersForEventName(id, eventName) {
+    var c = getCacheForID(id);
+    return c[eventName] = c[eventName] || [];
+  }
+
+  function createWrapper(element, eventName, handler) {
+    var id = getEventID(element);
+    var c = getWrappersForEventName(id, eventName);
+    if (c.pluck("handler").include(handler)) return false;
+
+    var wrapper = function(event) {
+      if (!Event || !Event.extend ||
+        (event.eventName && event.eventName != eventName))
+          return false;
+
+      Event.extend(event);
+      handler.call(element, event);
+    };
+
+    wrapper.handler = handler;
+    c.push(wrapper);
+    return wrapper;
+  }
+
+  function findWrapper(id, eventName, handler) {
+    var c = getWrappersForEventName(id, eventName);
+    return c.find(function(wrapper) { return wrapper.handler == handler });
+  }
+
+  function destroyWrapper(id, eventName, handler) {
+    var c = getCacheForID(id);
+    if (!c[eventName]) return false;
+    c[eventName] = c[eventName].without(findWrapper(id, eventName, handler));
+  }
+
+  function destroyCache() {
+    for (var id in cache)
+      for (var eventName in cache[id])
+        cache[id][eventName] = null;
+  }
+
+
+  // Internet Explorer needs to remove event handlers on page unload
+  // in order to avoid memory leaks.
+  if (window.attachEvent) {
+    window.attachEvent("onunload", destroyCache);
+  }
+
+  // Safari has a dummy event handler on page unload so that it won't
+  // use its bfcache. Safari <= 3.1 has an issue with restoring the "document"
+  // object when page is returned to via the back button using its bfcache.
+  if (Prototype.Browser.WebKit) {
+    window.addEventListener('unload', Prototype.emptyFunction, false);
+  }
+
+  return {
+    observe: function(element, eventName, handler) {
+      element = $(element);
+      var name = getDOMEventName(eventName);
+
+      var wrapper = createWrapper(element, eventName, handler);
+      if (!wrapper) return element;
+
+      if (element.addEventListener) {
+        element.addEventListener(name, wrapper, false);
+      } else {
+        element.attachEvent("on" + name, wrapper);
+      }
 
-  _observeAndCache: function(element, name, observer, useCapture) {
-    if (!this.observers) this.observers = [];
-    if (element.addEventListener) {
-      this.observers.push([element, name, observer, useCapture]);
-      element.addEventListener(name, observer, useCapture);
-    } else if (element.attachEvent) {
-      this.observers.push([element, name, observer, useCapture]);
-      element.attachEvent('on' + name, observer);
+      return element;
+    },
+
+    stopObserving: function(element, eventName, handler) {
+      element = $(element);
+      var id = getEventID(element), name = getDOMEventName(eventName);
+
+      if (!handler && eventName) {
+        getWrappersForEventName(id, eventName).each(function(wrapper) {
+          element.stopObserving(eventName, wrapper.handler);
+        });
+        return element;
+
+      } else if (!eventName) {
+        Object.keys(getCacheForID(id)).each(function(eventName) {
+          element.stopObserving(eventName);
+        });
+        return element;
+      }
+
+      var wrapper = findWrapper(id, eventName, handler);
+      if (!wrapper) return element;
+
+      if (element.removeEventListener) {
+        element.removeEventListener(name, wrapper, false);
+      } else {
+        element.detachEvent("on" + name, wrapper);
+      }
+
+      destroyWrapper(id, eventName, handler);
+
+      return element;
+    },
+
+    fire: function(element, eventName, memo) {
+      element = $(element);
+      if (element == document && document.createEvent && !element.dispatchEvent)
+        element = document.documentElement;
+
+      var event;
+      if (document.createEvent) {
+        event = document.createEvent("HTMLEvents");
+        event.initEvent("dataavailable", true, true);
+      } else {
+        event = document.createEventObject();
+        event.eventType = "ondataavailable";
+      }
+
+      event.eventName = eventName;
+      event.memo = memo || { };
+
+      if (document.createEvent) {
+        element.dispatchEvent(event);
+      } else {
+        element.fireEvent(event.eventType, event);
+      }
+
+      return Event.extend(event);
     }
-  },
+  };
+})());
+
+Object.extend(Event, Event.Methods);
+
+Element.addMethods({
+  fire:          Event.fire,
+  observe:       Event.observe,
+  stopObserving: Event.stopObserving
+});
+
+Object.extend(document, {
+  fire:          Element.Methods.fire.methodize(),
+  observe:       Element.Methods.observe.methodize(),
+  stopObserving: Element.Methods.stopObserving.methodize(),
+  loaded:        false
+});
+
+(function() {
+  /* Support for the DOMContentLoaded event is based on work by Dan Webb,
+     Matthias Miller, Dean Edwards and John Resig. */
+
+  var timer;
+
+  function fireContentLoadedEvent() {
+    if (document.loaded) return;
+    if (timer) window.clearInterval(timer);
+    document.fire("dom:loaded");
+    document.loaded = true;
+  }
+
+  if (document.addEventListener) {
+    if (Prototype.Browser.WebKit) {
+      timer = window.setInterval(function() {
+        if (/loaded|complete/.test(document.readyState))
+          fireContentLoadedEvent();
+      }, 0);
 
-  unloadCache: function() {
-    if (!Event.observers) return;
-    for (var i = 0, length = Event.observers.length; i < length; i++) {
-      Event.stopObserving.apply(this, Event.observers[i]);
-      Event.observers[i][0] = null;
+      Event.observe(window, "load", fireContentLoadedEvent);
+
+    } else {
+      document.addEventListener("DOMContentLoaded",
+        fireContentLoadedEvent, false);
     }
-    Event.observers = false;
-  },
 
-  observe: function(element, name, observer, useCapture) {
-    element = $(element);
-    useCapture = useCapture || false;
+  } else {
+    document.write("<script id=__onDOMContentLoaded defer src=//:><\/script>");
+    $("__onDOMContentLoaded").onreadystatechange = function() {
+      if (this.readyState == "complete") {
+        this.onreadystatechange = null;
+        fireContentLoadedEvent();
+      }
+    };
+  }
+})();
+/*------------------------------- DEPRECATED -------------------------------*/
 
-    if (name == 'keypress' &&
-      (Prototype.Browser.WebKit || element.attachEvent))
-      name = 'keydown';
+Hash.toQueryString = Object.toQueryString;
 
-    Event._observeAndCache(element, name, observer, useCapture);
+var Toggle = { display: Element.toggle };
+
+Element.Methods.childOf = Element.Methods.descendantOf;
+
+var Insertion = {
+  Before: function(element, content) {
+    return Element.insert(element, {before:content});
   },
 
-  stopObserving: function(element, name, observer, useCapture) {
-    element = $(element);
-    useCapture = useCapture || false;
+  Top: function(element, content) {
+    return Element.insert(element, {top:content});
+  },
 
-    if (name == 'keypress' &&
-        (Prototype.Browser.WebKit || element.attachEvent))
-      name = 'keydown';
+  Bottom: function(element, content) {
+    return Element.insert(element, {bottom:content});
+  },
 
-    if (element.removeEventListener) {
-      element.removeEventListener(name, observer, useCapture);
-    } else if (element.detachEvent) {
-      try {
-        element.detachEvent('on' + name, observer);
-      } catch (e) {}
-    }
+  After: function(element, content) {
+    return Element.insert(element, {after:content});
   }
-});
+};
 
-/* prevent memory leaks in IE */
-if (Prototype.Browser.IE)
-  Event.observe(window, 'unload', Event.unloadCache, false);
+var $continue = new Error('"throw $continue" is deprecated, use "return" instead');
+
+// This should be moved to script.aculo.us; notice the deprecated methods
+// further below, that map to the newer Element methods.
 var Position = {
   // set to true if needed, warning: firefox performance problems
   // NOT neeeded for page scrolling, only if draggable contained in
@@ -3063,59 +4179,13 @@ var Position = {
                 || 0;
   },
 
-  realOffset: function(element) {
-    var valueT = 0, valueL = 0;
-    do {
-      valueT += element.scrollTop  || 0;
-      valueL += element.scrollLeft || 0;
-      element = element.parentNode;
-    } while (element);
-    return [valueL, valueT];
-  },
-
-  cumulativeOffset: function(element) {
-    var valueT = 0, valueL = 0;
-    do {
-      valueT += element.offsetTop  || 0;
-      valueL += element.offsetLeft || 0;
-      element = element.offsetParent;
-    } while (element);
-    return [valueL, valueT];
-  },
-
-  positionedOffset: function(element) {
-    var valueT = 0, valueL = 0;
-    do {
-      valueT += element.offsetTop  || 0;
-      valueL += element.offsetLeft || 0;
-      element = element.offsetParent;
-      if (element) {
-        if(element.tagName=='BODY') break;
-        var p = Element.getStyle(element, 'position');
-        if (p == 'relative' || p == 'absolute') break;
-      }
-    } while (element);
-    return [valueL, valueT];
-  },
-
-  offsetParent: function(element) {
-    if (element.offsetParent) return element.offsetParent;
-    if (element == document.body) return element;
-
-    while ((element = element.parentNode) && element != document.body)
-      if (Element.getStyle(element, 'position') != 'static')
-        return element;
-
-    return document.body;
-  },
-
   // caches x/y coordinate pair to use with overlap
   within: function(element, x, y) {
     if (this.includeScrollOffsets)
       return this.withinIncludingScrolloffsets(element, x, y);
     this.xcomp = x;
     this.ycomp = y;
-    this.offset = this.cumulativeOffset(element);
+    this.offset = Element.cumulativeOffset(element);
 
     return (y >= this.offset[1] &&
             y <  this.offset[1] + element.offsetHeight &&
@@ -3124,11 +4194,11 @@ var Position = {
   },
 
   withinIncludingScrolloffsets: function(element, x, y) {
-    var offsetcache = this.realOffset(element);
+    var offsetcache = Element.cumulativeScrollOffset(element);
 
     this.xcomp = x + offsetcache[0] - this.deltaX;
     this.ycomp = y + offsetcache[1] - this.deltaY;
-    this.offset = this.cumulativeOffset(element);
+    this.offset = Element.cumulativeOffset(element);
 
     return (this.ycomp >= this.offset[1] &&
             this.ycomp <  this.offset[1] + element.offsetHeight &&
@@ -3147,125 +4217,104 @@ var Position = {
         element.offsetWidth;
   },
 
-  page: function(forElement) {
-    var valueT = 0, valueL = 0;
-
-    var element = forElement;
-    do {
-      valueT += element.offsetTop  || 0;
-      valueL += element.offsetLeft || 0;
+  // Deprecation layer -- use newer Element methods now (1.5.2).
 
-      // Safari fix
-      if (element.offsetParent == document.body)
-        if (Element.getStyle(element,'position')=='absolute') break;
+  cumulativeOffset: Element.Methods.cumulativeOffset,
 
-    } while (element = element.offsetParent);
+  positionedOffset: Element.Methods.positionedOffset,
 
-    element = forElement;
-    do {
-      if (!window.opera || element.tagName=='BODY') {
-        valueT -= element.scrollTop  || 0;
-        valueL -= element.scrollLeft || 0;
-      }
-    } while (element = element.parentNode);
+  absolutize: function(element) {
+    Position.prepare();
+    return Element.absolutize(element);
+  },
 
-    return [valueL, valueT];
+  relativize: function(element) {
+    Position.prepare();
+    return Element.relativize(element);
   },
 
-  clone: function(source, target) {
-    var options = Object.extend({
-      setLeft:    true,
-      setTop:     true,
-      setWidth:   true,
-      setHeight:  true,
-      offsetTop:  0,
-      offsetLeft: 0
-    }, arguments[2] || {})
+  realOffset: Element.Methods.cumulativeScrollOffset,
 
-    // find page position of source
-    source = $(source);
-    var p = Position.page(source);
+  offsetParent: Element.Methods.getOffsetParent,
 
-    // find coordinate system to use
-    target = $(target);
-    var delta = [0, 0];
-    var parent = null;
-    // delta [0,0] will do fine with position: fixed elements,
-    // position:absolute needs offsetParent deltas
-    if (Element.getStyle(target,'position') == 'absolute') {
-      parent = Position.offsetParent(target);
-      delta = Position.page(parent);
-    }
+  page: Element.Methods.viewportOffset,
 
-    // correct by body offsets (fixes Safari)
-    if (parent == document.body) {
-      delta[0] -= document.body.offsetLeft;
-      delta[1] -= document.body.offsetTop;
+  clone: function(source, target, options) {
+    options = options || { };
+    return Element.clonePosition(target, source, options);
+  }
+};
+
+/*--------------------------------------------------------------------------*/
+
+if (!document.getElementsByClassName) document.getElementsByClassName = function(instanceMethods){
+  function iter(name) {
+    return name.blank() ? null : "[contains(concat(' ', @class, ' '), ' " + name + " ')]";
+  }
+
+  instanceMethods.getElementsByClassName = Prototype.BrowserFeatures.XPath ?
+  function(element, className) {
+    className = className.toString().strip();
+    var cond = /\s/.test(className) ? $w(className).map(iter).join('') : iter(className);
+    return cond ? document._getElementsByXPath('.//*' + cond, element) : [];
+  } : function(element, className) {
+    className = className.toString().strip();
+    var elements = [], classNames = (/\s/.test(className) ? $w(className) : null);
+    if (!classNames && !className) return elements;
+
+    var nodes = $(element).getElementsByTagName('*');
+    className = ' ' + className + ' ';
+
+    for (var i = 0, child, cn; child = nodes[i]; i++) {
+      if (child.className && (cn = ' ' + child.className + ' ') && (cn.include(className) ||
+          (classNames && classNames.all(function(name) {
+            return !name.toString().blank() && cn.include(' ' + name + ' ');
+          }))))
+        elements.push(Element.extend(child));
     }
+    return elements;
+  };
 
-    // set position
-    if(options.setLeft)   target.style.left  = (p[0] - delta[0] + options.offsetLeft) + 'px';
-    if(options.setTop)    target.style.top   = (p[1] - delta[1] + options.offsetTop) + 'px';
-    if(options.setWidth)  target.style.width = source.offsetWidth + 'px';
-    if(options.setHeight) target.style.height = source.offsetHeight + 'px';
-  },
+  return function(className, parentElement) {
+    return $(parentElement || document.body).getElementsByClassName(className);
+  };
+}(Element.Methods);
 
-  absolutize: function(element) {
-    element = $(element);
-    if (element.style.position == 'absolute') return;
-    Position.prepare();
+/*--------------------------------------------------------------------------*/
 
-    var offsets = Position.positionedOffset(element);
-    var top     = offsets[1];
-    var left    = offsets[0];
-    var width   = element.clientWidth;
-    var height  = element.clientHeight;
+Element.ClassNames = Class.create();
+Element.ClassNames.prototype = {
+  initialize: function(element) {
+    this.element = $(element);
+  },
 
-    element._originalLeft   = left - parseFloat(element.style.left  || 0);
-    element._originalTop    = top  - parseFloat(element.style.top || 0);
-    element._originalWidth  = element.style.width;
-    element._originalHeight = element.style.height;
+  _each: function(iterator) {
+    this.element.className.split(/\s+/).select(function(name) {
+      return name.length > 0;
+    })._each(iterator);
+  },
 
-    element.style.position = 'absolute';
-    element.style.top    = top + 'px';
-    element.style.left   = left + 'px';
-    element.style.width  = width + 'px';
-    element.style.height = height + 'px';
+  set: function(className) {
+    this.element.className = className;
   },
 
-  relativize: function(element) {
-    element = $(element);
-    if (element.style.position == 'relative') return;
-    Position.prepare();
+  add: function(classNameToAdd) {
+    if (this.include(classNameToAdd)) return;
+    this.set($A(this).concat(classNameToAdd).join(' '));
+  },
 
-    element.style.position = 'relative';
-    var top  = parseFloat(element.style.top  || 0) - (element._originalTop || 0);
-    var left = parseFloat(element.style.left || 0) - (element._originalLeft || 0);
+  remove: function(classNameToRemove) {
+    if (!this.include(classNameToRemove)) return;
+    this.set($A(this).without(classNameToRemove).join(' '));
+  },
 
-    element.style.top    = top + 'px';
-    element.style.left   = left + 'px';
-    element.style.height = element._originalHeight;
-    element.style.width  = element._originalWidth;
+  toString: function() {
+    return $A(this).join(' ');
   }
-}
-
-// Safari returns margins on body which is incorrect if the child is absolutely
-// positioned.  For performance reasons, redefine Position.cumulativeOffset for
-// KHTML/WebKit only.
-if (Prototype.Browser.WebKit) {
-  Position.cumulativeOffset = function(element) {
-    var valueT = 0, valueL = 0;
-    do {
-      valueT += element.offsetTop  || 0;
-      valueL += element.offsetLeft || 0;
-      if (element.offsetParent == document.body)
-        if (Element.getStyle(element, 'position') == 'absolute') break;
+};
 
-      element = element.offsetParent;
-    } while (element);
+Object.extend(Element.ClassNames.prototype, Enumerable);
 
-    return [valueL, valueT];
-  }
-}
+/*--------------------------------------------------------------------------*/
 
 Element.addMethods();
\ No newline at end of file
index 0a426121a90866f7a169d161b00eae87e4e2562e..0bd3285128b7db868fcfa488b393e33d29823b93 100644 (file)
 <:if Or [iadminuser_count] [iadmingroup_count]:>
       <form action="/cgi-bin/admin/adminusers.pl" name="access">
   <input type="hidden" name="id" value="<: article id:>" />
-  <table border="0" cellspacing="0" cellpadding="0" bgcolor="#000000" class="table">
-  <tr>
-    <td>
-        <table cellpadding="6" cellspacing="1" border="0" width="100%">
+        <table class="editform editformtiny">
           <tr>
-            <th nowrap="nowrap">
-
-        <font size="2">Manage access:</font>
-        </th>
-            <td bgcolor="#FFFFFF">
+            <th>Manage access: </th>
+            <td>
               <select name="adminid">
 <:iterator begin adminusers:>
 <option value="<:iadminuser id:>">User <:iadminuser logon:>
               </select>
               <input type="submit" name="a_showobjectart" value="Manage" />
       </td>
-            <td bgcolor="#FFFFFF"><:help access manage:>
+            <td class="help"><:help access manage:>
         </td>
           </tr>
         </table>
-    </td>
-   </tr>
-  </table>
       </form>
 <br>
 <:or Or:><:eif Or:>
     <input type="hidden" name="level" value="<: level :>" />
     <input type="hidden" name="id" value="<: article id :>" />
 
-  <table border="0" cellspacing="0" cellpadding="0" bgcolor="#000000" width="100%" class="table">
-    <tr>
-      <td>
-
-        <table cellpadding="6" border="0" cellspacing="1" width="100%">
+        <table class="editform">
           <tr> 
-            <th nowrap="nowrap" bgcolor="#FFFFFF" align="left">Parent:</th>
-            <td bgcolor="#FFFFFF" width="100%"
+            <th>Parent:</th>
+            <td> 
               <:if FieldPerm parentid:><select name="parentid">
                 <option value="">Please select a <:parentType:></option><: list:>
               </select><:or FieldPerm:><:parent title:> (<:parent id:>)<:eif FieldPerm:>
             </td>
-            <td nowrap="nowrap" bgcolor="#FFFFFF"><:help edit section:> <:error_img parentid:></td>
+            <td class="help"><:help edit section:> <:error_img parentid:></td>
           </tr>
           <tr> 
-            <th nowrap="nowrap" bgcolor="#FFFFFF" align="left">Title: 
-            </th>
-            <td bgcolor="#FFFFFF" width="100%"> 
+            <th>Title:</th>
+            <td> 
             <:ifFieldPerm title:><input type="text" name="title" maxlength="<:cfg fields title_size 255:>" size="64" value="<: old title default title :>" />
             <:or:><:default title:><:eif:></td>
-            <td nowrap="nowrap" bgcolor="#FFFFFF"><:help edit title:> <:error_img title:></td>
+            <td class="help"><:help edit title:> <:error_img title:></td>
           </tr>
           <:if Cfg "title alias" [concatenate level [article level]]:><tr>
-            <th nowrap="nowrap" bgcolor="#FFFFFF" align="left">Alias:</th>
-            <td bgcolor="#FFFFFF" width="100%">
+            <th>Alias:</th>
+            <td>
               <:if FieldPerm titleAlias:><input type="text" name="titleAlias" maxlength="<:cfg fields alias_size 60:>" size="60" value="<:old titleAlias article titleAlias:>" /><:or FieldPerm:><:article titleAlias:><:eif FieldPerm:>
             </td>
-            <td nowrap="nowrap" bgcolor="#FFFFFF"><:help edit titleAlias:> <:error_img titleAlias:></td>
+            <td class="help"><:help edit titleAlias:> <:error_img titleAlias:></td>
           </tr><:or Cfg:><:eif Cfg:>
           <tr> 
-            <th nowrap="nowrap" bgcolor="#FFFFFF" align="left">Summary: 
+            <th>Summary: 
             </th>
-            <td bgcolor="#FFFFFF" width="100%"
+            <td> 
             <:ifFieldPerm summary:><input type="text" name="summary" maxlength="<:cfg fields summary_size 255:>" size="64" value="<: old summary default summary :>" />
             <:or:><:default summary:><:eif:></td>
-            <td nowrap="nowrap" bgcolor="#FFFFFF"><:help edit summary:> <:error_img summary:></td>
+            <td class="help"><:help edit summary:> <:error_img summary:></td>
           </tr>
           <:if Cfg image title:><tr> 
-            <th nowrap="nowrap" bgcolor="#FFFFFF" align="left">Title image:</th>
-            <td bgcolor="#FFFFFF" width="100%"><:ifFieldPerm titleImage:><:titleImages:> (upload this to 
+            <th>Title image:</th>
+            <td><:ifFieldPerm titleImage:><:titleImages:> (upload this to 
               the /images/titles directory)<:or:><:article titleImage:><:eif:></td>
-            <td nowrap="nowrap" bgcolor="#FFFFFF"><:help edit titleImage:> <:error_img titleImage:></td>
+            <td class="help"><:help edit titleImage:> <:error_img titleImage:></td>
           </tr><:or Cfg:><:eif Cfg:>
           <tr> 
-            <th valign="top" nowrap="nowrap" bgcolor="#FFFFFF" align="left"> Body: </th>
-            <td bgcolor="#FFFFFF" width="100%"
+            <th> Body: </th>
+            <td> 
               <:if FieldPerm body:><textarea name="body" rows="10" cols="60" wrap="virtual"><: old body default body :></textarea>
               <:or FieldPerm:><:bodytext article body:><:eif FieldPerm:>
             </td>
-            <td valign="top" nowrap="nowrap" bgcolor="#FFFFFF"><:help body body:> <:error_img body:></td>
+            <td class="help"><:help body body:> <:error_img body:></td>
           </tr>
           <:if Cfgsection [concatenate level [article level] " menus"]:><tr> 
-            <th nowrap="nowrap" bgcolor="#FFFFFF" align="left">Menu:</th>
-            <td bgcolor="#FFFFFF" width="100%"
+            <th>Menu:</th>
+            <td> 
               <:if FieldPerm menu:><select name="menu" id="menu">
                 <:iterator begin cfgsection [concatenate level [article level] " menus"] sort=key:><option value="<:cfgentry key:>"<:if Eq [old menu] [cfgentry key]:> selected="selected"<:or Eq:><:eif Eq:>><:cfgentry value:></option><:iterator separator cfgsection:>
                 <:iterator end cfgsection:>
               </select><:or FieldPerm:><:article menu:><:eif FieldPerm:>
             </td>
-            <td nowrap="nowrap" bgcolor="#FFFFFF"><:help article menu:> <:error_img menu:></td>
+            <td class="help"><:help article menu:> <:error_img menu:></td>
           </tr><:or Cfgsection:><:eif Cfgsection:>
           <tr> 
-            <th nowrap="nowrap" bgcolor="#FFFFFF" align="left">Template:</th>
-            <td bgcolor="#FFFFFF" width="100%"><:ifFieldPerm template:> <:templates:><:or:><:article template:><:eif:> </td>
-            <td nowrap="nowrap" bgcolor="#FFFFFF"><:help edit template:> <:error_img template:></td>
+            <th>Template:</th>
+            <td><:ifFieldPerm template:> <:templates:><:or:><:article template:><:eif:> </td>
+            <td class="help"><:help edit template:> <:error_img template:></td>
           </tr>
                  <tr> 
-            <th nowrap="nowrap" bgcolor="#FFFFFF" align="left">List article:</th>
-            <td bgcolor="#FFFFFF" width="100%"> <:if FieldPerm listed:><:list listed:><:or FieldPerm:><:if Article listed:><:ifEq [article listed] "1":>Yes<:or:>In Sections, but not menu<:eif:><:or Article:>No<:eif Article:><:eif FieldPerm:> </td>
-            <td nowrap="nowrap" bgcolor="#FFFFFF"><:help edit listed:> <:error_img listed:></td>
+            <th>List article:</th>
+            <td> <:if FieldPerm listed:><:list listed:><:or FieldPerm:><:if Article listed:><:ifEq [article listed] "1":>Yes<:or:>In Sections, but not menu<:eif:><:or Article:>No<:eif Article:><:eif FieldPerm:> </td>
+            <td class="help"><:help edit listed:> <:error_img listed:></td>
           </tr>
           <tr> 
-            <th nowrap bgcolor="#FFFFFF" align="left">Link alias: 
-            </th>
-            <td bgcolor="#FFFFFF" width="100%"> 
+            <th>Link alias:</th>
+            <td> 
             <:ifFieldPerm linkAlias:><input type="text" name="linkAlias" maxlength="<:cfg fields linkAlias_size 255:>" size="40" value="<: old linkAlias article linkAlias :>">
             <:or:><:default linkAlias:><:eif:></td>
-            <td nowrap bgcolor="#FFFFFF"><:help edit linkAlias:> <:error_img linkAlias:></td>
+            <td class="help"><:help edit linkAlias:> <:error_img linkAlias:></td>
           </tr>
                  <tr>
-            <th valign="top" nowrap="nowrap" bgcolor="#FFFFFF" align="left">Flags:</th>
-            <td bgcolor="#FFFFFF" width="100%"><:iterator begin flags:><:if FieldPerm flags:>
+            <th>Flags:</th>
+            <td><:iterator begin flags:><:if FieldPerm flags:>
               <input type="checkbox" name="flags" value="<:flag id:>" <:ifFlagSet [flag id]:>checked<:or:>
               <:eif:> /><:or FieldPerm:><:ifFlagSet [flag id]:>Yes<:or:>No<:eif:>
               <:eif FieldPerm:><:flag desc:><:iterator separator flags:><br /><:iterator end flags:></td>
-            <td nowrap="nowrap" bgcolor="#FFFFFF"><:help edit flags:> <:error_img flags:></td>
+            <td class="help"><:help edit flags:> <:error_img flags:></td>
           </tr>
           <tr>
-            <th nowrap="nowrap" align="left" bgcolor="#FFFFFF">Page title:</th>
-            <td width="100%" bgcolor="#FFFFFF">
+            <th>Page title:</th>
+            <td>
               <:ifFieldPerm pageTitle:><input type="text" name="pageTitle" value='<:old pageTitle article pageTitle:>' size="60" maxlength="255" /> (alternate title)<:or:><:article pageTitle:><:eif:>
             </td>
-           <td nowrap="nowrap" bgcolor="#FFFFFF"><:help edit pageTitle:> <:error_img pageTitle:></td>
+           <td class="help"><:help edit pageTitle:> <:error_img pageTitle:></td>
           </tr>
           <tr>
-            <th nowrap="nowrap" align="left" bgcolor="#FFFFFF">Meta description:</th>
-            <td width="100%" bgcolor="#FFFFFF">
+            <th>Meta description:</th>
+            <td>
               <:ifFieldPerm metaDescription:><input type="text" name="metaDescription" value='<:old metaDescription article metaDescription:>' size="60" maxlength="255" /><:or:><:article metaDescription:><:eif:>
             </td>
-           <td nowrap="nowrap" bgcolor="#FFFFFF"><:help edit metaDescription:> <:error_img metaDescription:></td>
+           <td class="help"><:help edit metaDescription:> <:error_img metaDescription:></td>
           </tr>
           <tr>
-            <th nowrap="nowrap" align="left" bgcolor="#FFFFFF">Meta keywords:</th>
-            <td width="100%" bgcolor="#FFFFFF">
+            <th>Meta keywords:</th>
+            <td>
               <:ifFieldPerm metaKeywords:><input type="text" name="metaKeywords" value='<:old metaKeywords article metaKeywords:>' size="60" maxlength="255" /> (comma separated)<:or:><:article metaKeywords:><:eif:>
             </td>
-           <td nowrap="nowrap" bgcolor="#FFFFFF"><:help edit metaKeywords:> <:error_img metaKeywords:></td>
+           <td class="help"><:help edit metaKeywords:> <:error_img metaKeywords:></td>
           </tr>
                  <tr>
-            <th nowrap="nowrap" align="left" bgcolor="#FFFFFF">Author name:</th>
-            <td width="100%" bgcolor="#FFFFFF" >
+            <th>Author name:</th>
+            <td>
               <:if FieldPerm author:><input type="text" name="author" value='<:ifCfg editor auto_author:><:old author adminuser name:><:or:><:old author:><:eif:>' size="40" maxlength="255" /><:or FieldPerm:><:article author:><:eif FieldPerm:>
             </td>
-            <td nowrap="nowrap" bgcolor="#FFFFFF" ><:help edit author:> <:error_img author:></td>
+            <td class="help"><:help edit author:> <:error_img author:></td>
           </tr>
                  <tr> 
-            <th nowrap="nowrap" bgcolor="#FFFFFF" align="left">Release date:</th>
-            <td bgcolor="#FFFFFF" width="100%"
+            <th>Release date:</th>
+            <td> 
               <:if FieldPerm release:><input type="text" name="release" value="<: old release date "%d/%m/%Y" default release :>" size="10" maxlength="10" />
               (dd/mm/yyyy<: ifNew :> - default is today<: or :><: eif :>)<:or FieldPerm:><: date "%d/%m/%Y" article release :><:eif FieldPerm:></td>
-            <td nowrap="nowrap" bgcolor="#FFFFFF"><:help edit release:> <:error_img release:></td>
+            <td class="help"><:help edit release:> <:error_img release:></td>
           </tr>
           <tr> 
-            <th nowrap="nowrap" bgcolor="#FFFFFF" align="left">Expiry date:</th>
-            <td bgcolor="#FFFFFF" width="100%"
+            <th>Expiry date:</th>
+            <td> 
               <:if FieldPerm expire:><input type="text" name="expire" value="<: old expire date "%d/%m/%Y" default expire :>" size="10" maxlength="10" />
               (dd/mm/yyyy - <: ifNew :>default is never, <: or :><: eif :>blank 
               for never expires)<:or FieldPerm:><: date "%d/%m/%Y" article expire :><:eif FieldPerm:></td>
-            <td nowrap="nowrap" bgcolor="#FFFFFF"><:help edit expire:> <:error_img expire:></td>
+            <td class="help"><:help edit expire:> <:error_img expire:></td>
           </tr>
           <tr> 
-            <th nowrap="nowrap" bgcolor="#FFFFFF" align="left">Summary length:</th>
-            <td bgcolor="#FFFFFF" width="100%"
+            <th>Summary length:</th>
+            <td> 
               <:if FieldPerm summaryLength:><input type="text" name="summaryLength" size="10" maxlength="10" value="<: old summaryLength default summaryLength :>" />
               (in characters - <: ifNew :>default inherited from <:parentType:>, <: or :><: eif :> 
               zero for no summary)<:or FieldPerm:><: article summaryLength :><:eif FieldPerm:></td>
-            <td nowrap="nowrap" bgcolor="#FFFFFF"><:help edit summary:> <:error_img summaryLength:></td>
+            <td class="help"><:help edit summary:> <:error_img summaryLength:></td>
           </tr>
           <tr> 
-            <th nowrap="nowrap" bgcolor="#FFFFFF" align="left">Display threshold:</th>
-            <td bgcolor="#FFFFFF" width="100%"
+            <th>Display threshold:</th>
+            <td> 
               <:ifFieldPerm threshold:><input type="text" name="threshold" size="10" maxlength="10" value="<: old threshold default threshold :>" /><:or:><: article threshold :><:eif:>
             </td>
-            <td nowrap="nowrap" bgcolor="#FFFFFF"><:help edit threshold:> <:error_img
-            threshold:></td>
+            <td class="help"><:help edit threshold:> <:error_img threshold:></td>
           </tr>
           <tr> 
-            <th nowrap="nowrap" bgcolor="#FFFFFF" align="left">Keywords:</th>
-            <td bgcolor="#FFFFFF" width="100%"
+            <th>Keywords:</th>
+            <td> 
               <:ifFieldPerm keyword:><input type="text" name="keyword" maxlength="255" size="60" value="<: old keyword default keyword :>" />
               (comma separated)<:or:><: article threshold :><:eif:></td>
-            <td nowrap="nowrap" bgcolor="#FFFFFF"><:help edit keywords:> <:error_img keyword:></td>
+            <td class="help"><:help edit keywords:> <:error_img keyword:></td>
           </tr>
           <tr> 
-            <th nowrap="nowrap" bgcolor="#FFFFFF" align="left">Always Dynamic:</th>
-            <td bgcolor="#FFFFFF" width="100%"
+            <th>Always Dynamic:</th>
+            <td> 
               <:ifFieldPerm force_dynamic:><input type="hidden" name="save_force_dynamic" value="1" /><input type="checkbox" name="force_dynamic" value="1" <:if Article force_dynamic :>checked="checked"<:or Article:><:eif Article:> />
               <:or:><:ifArticle force_dynamic :>Yes<:or Article:>No<:eif Article:><:eif:></td>
-            <td nowrap="nowrap" bgcolor="#FFFFFF"><:help edit keywords:> <:error_img keyword:></td>
+            <td class="help"><:help edit keywords:> <:error_img keyword:></td>
           </tr>
 <:include admin/article_custom.tmpl optional:>
           <tr> 
-            <th nowrap="nowrap" bgcolor="#FFFFFF" align="left" valign="top">Thumbnail image:</th>
-            <td width="100%" valign="top" bgcolor="#FFFFFF"
+            <th>Thumbnail image:</th>
+            <td> 
               <:ifFieldPerm thumbImage:>
               <input type="file" name="thumbnail" /><:or:><:eif:>
               <:ifArticle thumbImage:><img src="/images/<:article thumbImage:>"> 
               <:if FieldPerm thumbImage:><input type="checkbox" name="remove_thumb" />
             Remove<:or FieldPerm:><:eif FieldPerm:><:or:><:eif:></td>
-            <td valign="top" nowrap="nowrap" bgcolor="#FFFFFF"><:help edit thumb:> <:error_img
+            <td class="help"><:help edit thumb:> <:error_img
             thumbImage:></td>
           </tr>
           <:if Article id:> 
           <tr> 
-            <th nowrap="nowrap" bgcolor="#FFFFFF" align="left" valign="top"><a name="files"></a>Files:</th>
-            <td nowrap="nowrap" bgcolor="#FFFFFF" width="100%"> <:if Files:> 
-              <table cellpadding="0" cellspacing="0" border="0" bgcolor="#333333">
-                <tr>
-                  <td> 
-                    <table cellpadding="5" cellspacing="1" border="0">
+            <th><a name="files"></a>Files:</th>
+            <td> <:if Files:> 
+                    <table class="editform">
                       <tr bgcolor="#FFFFFF"> 
                         <th>Filename</th>
                         <th>Size</th>
                       </tr>
                       <:iterator end files:> 
                     </table>
-                  </td>
-                </tr>
-              </table>
-              <p><a href="<:script:>?id=<:article id:>&amp;_t=file"><b>Manage Files</b></a>
-              </p>
+              <div><a href="<:script:>?id=<:article id:>&amp;_t=file"><b>Manage Files</b></a>
+              </div>
               <:or Files:>
-              <p>No files are attached to this article. <a href="<:script:>?id=<:article id:>&amp;_t=file"><b>Manage Files</b></a></p>
+              <div>No files are attached to this article. <a href="<:script:>?id=<:article id:>&amp;_t=file"><b>Manage Files</b></a></div>
               <:eif Files:>
             </td>
-            <td nowrap="nowrap" bgcolor="#FFFFFF" valign="top"><:help edit files:> <:error_img
+            <td class="help"><:help edit files:> <:error_img
             files:></td>
           </tr>
           <tr> 
-            <th valign="top" nowrap="nowrap" bgcolor="#FFFFFF" align="left">Images: 
+            <th>Images: 
             </th>
-            <td align="center" bgcolor="#FFFFFF" width="100%" name="images"><:if Images:> <:iterator begin 
+            <td><:if Images:> <:iterator begin 
               images:> <img src="/images/<: image image :>" alt="<:image alt :>" width= 
               <:image width:> height=<:image height:>> <:iterator separator images:> 
               <hr noshade size="1">
               <: iterator end images :>
               <p align="left"><a href="<:script:>?id=<:article id:>&amp;_t=img"><b>Manage Images</b></a></p>
               <:or Images:>
-              <p align="left">No images are attached to this article.  <a href="<:script:>?id=<:article id:>&amp;_t=img"><b>Manage Images</b></a></p>
+              No images are attached to this article.  <a href="<:script:>?id=<:article id:>&amp;_t=img"><b>Manage Images</b></a>
               <:eif Images:>
            </td>
-            <td valign="top" nowrap="nowrap" bgcolor="#FFFFFF"><:help edit images:> <:error_img
+            <td class="help"><:help edit images:> <:error_img
             images:></td>
           </tr>
           <:or Article:><:eif Article:> 
         </table>
-</td>
-</tr>
-</table>
 
   <p><: ifNew :>
     <input type="submit" name="save" value="Add New <: articleType:>" />
diff --git a/site/templates/admin/edit_prodopts.tmpl b/site/templates/admin/edit_prodopts.tmpl
new file mode 100644 (file)
index 0000000..a42bdb4
--- /dev/null
@@ -0,0 +1,260 @@
+<:wrap admin/xbase.tmpl title=>"[articleType] Options", showtitle=>1, menuitem=>prodopts:>
+<:ifMessage:><div id="message"><:message:></div><:or:><:eif:>
+<:include admin/product_menu.tmpl:>
+
+<:if Dboptions:>
+<h2>Product options</h2>
+<div id="prodoptmenu"></div>
+<div id="productoptions">
+<:iterator begin dboptions:>
+<div id="prodopt<:dboption id:>" class="prodopt">
+<div id="prodoptmenu<:dboption id:>" class="prodoptmenu">Option: <span id="prodoptname<:dboption id:>"><:dboption name:></span> <a href="<:script:>?id=<:article id:>&amp;a_edit_option=1&amp;option_id=<:dboption id:>">Edit</a> <a href="<:script:>?id=<:article id:>&amp;a_delconf_option=1&amp;option_id=<:dboption id:>">Delete</a> <:dboption_move:></div>
+<:if Dboptionvalues:>
+<ul id="vallist<:dboption id:>" class="prodoptvalues">
+<:iterator begin dboptionvalues:>
+<li id="valentry<:dboptionvalue id:>"><:dboptionvalue value:> <:ifEq [dboptionvalue id] [dboption default_value]:>(default)<:or:><:eif:> <a href="<:script:>?id=<:product id:>&amp;value_id=<:dboptionvalue id:>&amp;a_edit_option_value=1">Edit</a> <a href="<:script:>?id=<:product id:>&amp;value_id=<:dboptionvalue id:>&amp;a_confdel_option_value=1">Delete</a> <:dboptionvalue_move:></li>
+<:iterator end dboptionvalues:>
+</ul>  
+<form action="<:script:>" method="post" id="valform<:dboption id:>" /><input type="hidden" name="id" value="<:article id:>" /><input type="hidden" name="option_id" value="<:dboption id:>" /><input type="hidden" name="_t" value="prodopts" /><:csrfp admin_add_option_value hidden:><input type="text" name="value" /><input type="submit" name="a_add_option_value" value="Add Value" /></form>
+<:or Dboptionvalues:><:eif Dboptionvalues:>
+</div>
+<:iterator end dboptions:>
+</div>
+<:or Dboptions:><:eif Dboptions:>
+<div id="addoptionform">
+<form action="<:script:>" method="post">
+<:csrfp admin_add_option hidden:>
+<input type="hidden" name="_t" value="prodopts" />
+<input type="hidden" name="id" value="<:article id:>" />
+<table>
+  <tr>
+    <th>Name</th>
+    <td><input type="text" name="name" value="<:old name:>" size="40" maxlength="40" /><:error_img name:></td>
+  </tr>
+  <tr>
+    <th>Values</th>
+    <td><input type="text" name="value1" value="<:old value1:>" size="40" maxlength="40" /><:error_img value1:><br />
+<input type="text" name="value2" value="<:old value2:>" size="40" maxlength="40" /><:error_img value2:><br />
+<input type="text" name="value3" value="<:old value3:>" size="40" maxlength="40" /><:error_img value3:><br />
+<input type="text" name="value4" value="<:old value4:>" size="40" maxlength="40" /><:error_img value4:><br />
+<input type="text" name="value5" value="<:old value5:>" size="40" maxlength="40" /><:error_img value5:><br /></td>
+  </tr>
+  <tr>
+    <td colspan="2"><input type="submit" name="a_add_option" value="Add New Option" /></td>
+  </tr>
+</table>
+</form>
+</div>
+<div id="addoptionbutton" style="display: none">
+<a href="#" onclick="javascript: document.getElementById('addoptionform').style.display='block'; return false;">Add an option</a>
+</div>
+<:ifDboptions:>
+<script>
+
+// this really all belongs in a separate file
+
+$('addoptionform').style.display='none';
+$('addoptionbutton').style.display='block';
+var prodopts = <:dboptionsjson:>;
+var prodopts_by_id = new Object;
+var reorder_values_csrf = '<:csrfp admin_move_option_value:>';
+var reorder_options_csrf = '<:csrfp admin_move_option:>';
+var menu = $('prodoptmenu');
+if (menu) {
+  menu.appendChild(document.createTextNode("All Options: | "));
+  var sort_a = document.createElement("a");
+  sort_a.href = "javascript:sort_prodopts()";
+  sort_a.title = "Sort product options alphabetically";
+  sort_a.appendChild(document.createTextNode("Sort"));
+  menu.appendChild(sort_a);
+  menu.appendChild(document.createTextNode(" |"));
+}
+for (var i = 0; i < prodopts.length; ++i) {
+  var opt = prodopts[i];
+  prodopts_by_id[opt.id] = opt;
+  var opt_ele_id = 'prodoptmenu' + opt.id;
+  var opt_ele = $(opt_ele_id);
+  opt_ele.appendChild(document.createTextNode(" "));
+  var sort_a = document.createElement("a");
+  sort_a.href = "javascript:sort_prodopt_values('" + opt.id + "')";
+  sort_a.title = "Sort values alphabetically";
+  sort_a.appendChild(document.createTextNode("Sort"));
+  opt_ele.appendChild(sort_a);
+  for (var j = 0; j < opt.values.length; ++j) {
+    var val = opt.values[j];
+    var s = $('optval' + val.id);
+    var p = $('valentry' + val.id);
+    var f = $('editval' + val.id);
+
+    if (s && f && p) {
+      s.style.display = 'inline';
+      f.style.display = 'none';
+      var a = document.createElement("a");
+      a.onclick = make_edit_handler(f, a, s);
+      a.href="#";
+      var atext = document.createTextNode("Edit");
+      a.appendChild(atext);
+      p.appendChild(document.createTextNode(" "));
+      p.appendChild(a);
+    }
+  }
+}
+
+function reorder_option_values(id, order) {
+  var parent = $("vallist"+id);
+  var nodes = new Array;
+  var nodes_by_id = new Object;
+  for (var i = 0; i < parent.childNodes.length; ++i) {
+    var n = parent.childNodes[i];
+    if (n.id) {
+      var m = n.id.match(/^valentry(\d+)$/);
+      if (m) {
+        nodes_by_id[m[1]] = n;
+        nodes.push(n);
+      }
+    }
+  }
+  // remove our value nodes
+  for (var i = 0; i < nodes; ++i) {
+    parent.removeChild(nodes[i]);
+  }
+  // put them back in, in the new order
+  for (var i = 0; i < order.length; ++i) {
+    var n = nodes_by_id[order[i]];
+    if (n)
+      parent.appendChild(n);
+  }
+
+  // TODO: reorder the values in prodopts
+}
+
+function reorder_options(order) {
+  var parent = $("productoptions");
+  var nodes = new Array;
+  var nodes_by_id = new Object;
+  for (var i = 0; i < parent.childNodes.length; ++i) {
+    var n = parent.childNodes[i];
+    if (n.id) {
+      var m = n.id.match(/^prodopt(\d+)$/);
+      if (m) {
+        nodes_by_id[m[1]] = n;
+        nodes.push(n);
+      }
+    }
+  }
+  // remove our value nodes
+  for (var i = 0; i < nodes; ++i) {
+    parent.removeChild(nodes[i]);
+  }
+  // put them back in, in the new order
+  for (var i = 0; i < order.length; ++i) {
+    var n = nodes_by_id[order[i]];
+    if (n)
+      parent.appendChild(n);
+  }
+
+  // TODO: reorder the values in prodopts
+}
+
+var busy_img = $('busy_img');
+
+function set_busy(id) {
+  if (busy_img)
+    busy_img.style.display = 'inline';
+}
+function set_not_busy(id) {
+  if (busy_img)
+    busy_img.style.display = 'none';
+}
+
+function sort_prodopt_values(id) {
+  var opt = prodopts_by_id[id];
+
+  var ord = new Array;
+  // duplicate to sort it
+  for (var i = 0; i < opt.values.length; ++i) {
+    ord.push(opt.values[i]);
+  }
+  ord.sort(function(a, b) { 
+    var va = a.value.toLowerCase();
+    var vb = b.value.toLowerCase();
+    if (va < vb) return -1;
+    if (va > vb) return 1;
+    return 0;
+  });
+  var ids = new Array;
+  for (var i = 0; i < ord.length; ++i) {
+    ids.push(ord[i].id);
+  }
+  set_busy();
+  new Ajax.Request("<:script:>", {
+    method: "post",
+    parameters: {
+      a_option_value_reorder: 1,
+      id: "<:article id:>",
+      option_id: id,
+      value_ids: ids.join(","),
+      _csrfp: reorder_values_csrf,
+      _: 1
+    },
+    onSuccess: function(xport) {
+      var json = xport.responseJSON;
+      set_not_busy();
+      if (json.success) {
+        reorder_option_values(json.option.id, json.order);
+      }
+      else {
+        alert("Error sorting: " + json.error);
+      }
+    },
+    onFailure: function() {
+      alert("Error contacting server");
+      set_not_busy();
+    }
+    });
+}
+
+function sort_prodopts() {
+  var ord = new Array;
+  for (var i = 0; i < prodopts.length; ++i) {
+    ord.push(prodopts[i]);
+  }
+  ord.sort(function(a, b) {
+    var va = a.name.toLowerCase();
+    var vb = b.name.toLowerCase();
+    if (va < vb) return -1;
+    if (va > vb) return 1;
+    return 0;
+    });
+  var ids = new Array;
+  for (var i = 0; i < ord.length; ++i) {
+    ids.push(ord[i].id);
+  }
+  set_busy();
+  new Ajax.Request("<:script:>", {
+    method: "post",
+    parameters: {
+      a_option_reorder: 1,
+      id: "<:article id:>",
+      option_ids: ids.join(","),
+      _csrfp: reorder_options_csrf,
+      _: 1
+    },
+    onSuccess: function(xport) {
+      var json = xport.responseJSON;
+      set_not_busy();
+      if (json.success) {
+        reorder_options(json.order);
+      }
+      else {
+        alert("Error sorting: " + json.error);
+      }
+    },
+    onFailure: function() {
+      alert("Error contacting server");
+      set_not_busy();
+    }
+    });
+}
+</script>
+<:or:><:eif:>
\ No newline at end of file
index 2611f60121090a87b74459aab4e692a1d74e6b09..7909a4e0e6ae77c7c5bf1772d1856f6bc3553741 100644 (file)
@@ -1,37 +1,16 @@
-<html><head><title><:ifNew:>Add<:or:>Edit<:eif:> product - Shop administration</title>
-  <link rel="stylesheet" type="text/css" href="/css/admin.css">
-</head>
-<body>
-<h1>Shop Administration</h1>
+<:wrap admin/xbase.tmpl title => "Shop Administration", menuitem=>edit, showtitle=>1 :>
 <:ifMessage:>
 <p><b><:message:></b></p>
 <:or:><:eif:> 
-<p>| <a href="/cgi-bin/admin/menu.pl">Admin menu</a> | <:if New:><:or New:><a href="<:product admin:>">See
-     product</a> | <a href="/cgi-bin/admin/add.pl?id=<:product parentid:>">Edit
-     parent</a> 
-  |<:eif New:> <a href="/cgi-bin/admin/shopadmin.pl">Manage catalogs</a> |<:if
-  New:><:or New:><:if UserCan edit_save:product,edit_field_edit_listed:product
-   :> <a href="/cgi-bin/admin/add.pl?id=<:product id:>&_t=steps">Manage
-      step parents</a> | <:if Product listed:> <a href="<:script:>?id=<:product id:>&hide=1&r=<:script:>?id=<:product id:>">Hide
-      product</a> |<:or Product:> <a href="<:script:>?id=<:product id:>&unhide=1&r=<:script:>?id=<:product id:>">Show
-      product</a> |
-<a href="<:script:>?id=<:article id:>&amp;_t=groups">Manage access</a> |
-<:eif Product:><:or UserCan:><:eif UserCan:><:ifProduct listed:><:or:> Hidden<:eif:><:eif
-      New:></p>
+<:include admin/product_menu.tmpl:>
   <h2>Product Details</h2>
 <:ifNew:><:or:><:if Or [iadminuser_count] [iadmingroup_count]:>
       <form action="/cgi-bin/admin/adminusers.pl">
   <input type="hidden" name="id" value="<: article id:>" />
-  <table border="0" cellspacing="0" cellpadding="0" bgcolor="#000000" class="table">
-  <tr>
-    <td>
-        <table cellpadding="6" cellspacing="1" border="0" width="100%">
+        <table class="editform editformtiny">
           <tr>
-            <th nowrap="nowrap">
-
-        <font size="2">Manage access:</font>
-        </th>
-            <td bgcolor="#FFFFFF">
+            <th>Manage access:</th>
+            <td>
               <select name="adminid">
 <:iterator begin adminusers:>
 <option value="<:iadminuser id:>">User <:iadminuser logon:>
               </select>
               <input type="submit" name="a_showobjectart" value="Manage" />
       </td>
-            <td bgcolor="#FFFFFF"><:help access manage:>
+            <td class="help"><:help access manage:>
         </td>
           </tr>
         </table>
-    </td>
-   </tr>
-  </table>
       </form>
 <br>
 <:or Or:><:eif Or:><:eif:>
     <input type="hidden" name="lastModified" value="<: old lastModified article lastModified :>" />
     <input type="hidden" name="type" value="Product" />
     <input type="hidden" name="id" value="<:product id:>" />
-  <table border="0" cellspacing="0" cellpadding="0" bgcolor="#000000" class="table">
-    <tr>
-      <td>
-        <table border=0 cellpadding="6" cellspacing="1" width="100%">
+        <table class="editform">
                     <tr> 
-            <th align="left" bgcolor="#FFFFFF">Catalog:</th>
-            <td bgcolor="#FFFFFF">
+            <th>Catalog:</th>
+            <td>
 <:ifFieldPerm parentid:><select name="parentid"><:list:></select><:or:><:parent title:> (<:parent id:>)<:eif:></td>
             <td nowrap="nowrap" bgcolor="#FFFFFF"><:help product catalog:> <:error_img
               parentid:></td>
           </tr>
                  <tr> 
-            <th align="left" bgcolor="#FFFFFF">Title:</th>
-            <td bgcolor="#FFFFFF"><:ifFieldPerm title:><input type="text" name="title" value="<:old title default title:>" size="60"><:or:><:product title:><:eif:> </td>
-            <td nowrap="nowrap" bgcolor="#FFFFFF"><:help product title:> <:error_img title:></td>
+            <th>Title:</th>
+            <td><:ifFieldPerm title:><input type="text" name="title" value="<:old title default title:>" size="60"><:or:><:product title:><:eif:> </td>
+            <td class="help"><:help product title:> <:error_img title:></td>
           </tr>
           <:if Cfg "title alias" [concatenate level [article level]]:><tr>
-            <th nowrap="nowrap" bgcolor="#FFFFFF" align="left">Alias:</th>
-            <td bgcolor="#FFFFFF" width="100%">
+            <th>Alias:</th>
+            <td>
               <:if FieldPerm titleAlias:><input type="text" name="titleAlias" maxlength="<:cfg fields alias_size 60:>" size="60" value="<:old titleAlias article titleAlias:>" /><:or FieldPerm:><:article titleAlias:><:eif FieldPerm:>
             </td>
-            <td nowrap="nowrap" bgcolor="#FFFFFF"><:help edit titleAlias:> <:error_img titleAlias:></td>
+            <td class="help"><:help edit titleAlias:> <:error_img titleAlias:></td>
           </tr><:or Cfg:><:eif Cfg:>
           <tr> 
-            <th nowrap="nowrap" align="left" bgcolor="#FFFFFF">Summary:</th>
-            <td nowrap="nowrap" bgcolor="#FFFFFF"><:ifFieldPerm summary:><input type="text" name="summary" value="<:old summary default summary:>" size=60><:or:><:product summary:><:eif:> </td>
-            <td nowrap="nowrap" bgcolor="#FFFFFF"><:help product summary:> <:error_img
+            <th>Summary:</th>
+            <td><:ifFieldPerm summary:><input type="text" name="summary" value="<:old summary default summary:>" size=60><:or:><:product summary:><:eif:> </td>
+            <td class="edit"><:help product summary:> <:error_img
             summary:></td>
           </tr>
           <tr> 
-            <th nowrap="nowrap" align="left" bgcolor="#FFFFFF">Description:</th>
-            <td nowrap="nowrap" bgcolor="#FFFFFF"><:ifFieldPerm description:><input type="text" name="description" value="<:old description default description:>" size=60><:or:><:product description:><:eif:> </td>
-            <td nowrap="nowrap" bgcolor="#FFFFFF"><:help product description:> <:error_img
+            <th>Description:</th>
+            <td><:ifFieldPerm description:><input type="text" name="description" value="<:old description default description:>" size=60><:or:><:product description:><:eif:> </td>
+            <td class="help"><:help product description:> <:error_img
             description:></td>
           </tr>
           <tr> 
-            <th nowrap="nowrap" align="left" bgcolor="#FFFFFF">Product Code:</th>
-            <td nowrap="nowrap" bgcolor="#FFFFFF"><:ifFieldPerm product_code:><input type="text" name="product_code" value="<:old product_code default product_code:>" size=60><:or:><:product product_code:><:eif:> </td>
-            <td nowrap="nowrap" bgcolor="#FFFFFF"><:help product product_code:> <:error_img
+            <th>Product Code:</th>
+            <td><:ifFieldPerm product_code:><input type="text" name="product_code" value="<:old product_code default product_code:>" size=60><:or:><:product product_code:><:eif:> </td>
+            <td class="help"><:help product product_code:> <:error_img
             product_code:></td>
           </tr>
           <tr> 
-            <th align="left" bgcolor="#FFFFFF" valign="top"> Body:</th>
-            <td bgcolor="#FFFFFF"
+            <th> Body:</th>
+            <td> 
               <:ifFieldPerm body:><textarea name="body" rows="15" cols="60" wrap="virtual"><:old body default body:></textarea><:or:><:bodytext product body:><:eif:>
             </td>
-            <td nowrap="nowrap" bgcolor="#FFFFFF" valign="top"><:help body body:> <:error_img
+            <td class="help"><:help body body:> <:error_img
             body:></td>
           </tr>
           <:if Cfgsection [concatenate level [article level] " menus"]:><tr> 
-            <th nowrap="nowrap" bgcolor="#FFFFFF" align="left">Menu:</th>
-            <td bgcolor="#FFFFFF" width="100%"
+            <th>Menu:</th>
+            <td> 
               <:if FieldPerm menu:><select name="menu" id="menu">
                 <:iterator begin cfgsection [concatenate level [article level] " menus"] sort=key:><option value="<:cfgentry key:>"<:if Eq [old menu] [cfgentry key]:> selected="selected"<:or Eq:><:eif Eq:>><:cfgentry value:></option><:iterator separator cfgsection:>
                 <:iterator end cfgsection:>
               </select><:or FieldPerm:><:article menu:><:eif FieldPerm:>
             </td>
-            <td nowrap="nowrap" bgcolor="#FFFFFF"><:help article menu:> <:error_img menu:></td>
+            <td class="help"><:help article menu:> <:error_img menu:></td>
           </tr><:or Cfgsection:><:eif Cfgsection:>
                  <tr> 
-            <th nowrap="nowrap" align="left" bgcolor="#FFFFFF">Template:</th>
-            <td nowrap="nowrap" bgcolor="#FFFFFF"><:ifFieldPerm template:><:templates:><:or:><:product template:><:eif:></td>
-            <td nowrap="nowrap" bgcolor="#FFFFFF"><:help product template:> <:error_img
+            <th>Template:</th>
+            <td><:ifFieldPerm template:><:templates:><:or:><:product template:><:eif:></td>
+            <td class="help"><:help product template:> <:error_img
             template:></td>
           </tr>
                  <tr> 
-            <th nowrap="nowrap" bgcolor="#FFFFFF" align="left">List article:</th>
-            <td bgcolor="#FFFFFF" width="100%"> <:if FieldPerm listed:><:list listed:><:or FieldPerm:><:if Article listed:><:ifEq [article listed] "1":>Yes<:or:>In Sections, but not menu<:eif:><:or Article:>No<:eif Article:><:eif FieldPerm:> </td>
-            <td bgcolor="#FFFFFF"><:help edit listed:> <:error_img listed:></td>
+            <th>List article:</th>
+            <td> <:if FieldPerm listed:><:list listed:><:or FieldPerm:><:if Article listed:><:ifEq [article listed] "1":>Yes<:or:>In Sections, but not menu<:eif:><:or Article:>No<:eif Article:><:eif FieldPerm:> </td>
+            <td class="help"><:help edit listed:> <:error_img listed:></td>
           </tr>
           <tr> 
-            <th nowrap bgcolor="#FFFFFF" align="left">Link alias: 
-            </th>
-            <td bgcolor="#FFFFFF" width="100%"> 
+            <th>Link alias:</th>
+            <td> 
             <:ifFieldPerm linkAlias:><input type="text" name="linkAlias" maxlength="<:cfg fields linkAlias_size 255:>" size="40" value="<: old linkAlias article linkAlias :>">
             <:or:><:default linkAlias:><:eif:></td>
-            <td nowrap bgcolor="#FFFFFF"><:help edit linkAlias:> <:error_img linkAlias:></td>
+            <td class="help"><:help edit linkAlias:> <:error_img linkAlias:></td>
           </tr>
                  <tr>
-            <th nowrap="nowrap" bgcolor="#FFFFFF" align="left">Flags:</th>
-            <td bgcolor="#FFFFFF" width="100%"><:iterator begin flags:><:if FieldPerm flags:>
+            <th>Flags:</th>
+            <td><:iterator begin flags:><:if FieldPerm flags:>
               <input type="checkbox" name="flags" value="<:flag id:>" <:ifFlagSet [flag id]:>checked<:or:>
               <:eif:> /><:or FieldPerm:><:ifFlagSet [flag id]:>Yes<:or:>No<:eif:>
               <:eif FieldPerm:><:flag desc:><:iterator separator flags:><br /><:iterator end flags:></td>
-            <td bgcolor="#FFFFFF"><:help edit flags:> <:error_img flags:></td>
+            <td class="help"><:help edit flags:> <:error_img flags:></td>
           </tr>
           <tr>
-            <th nowrap="nowrap" align="left" bgcolor="#FFFFFF">Page title:</th>
-            <td width="100%" bgcolor="#FFFFFF">
+            <th>Page title:</th>
+            <td>
               <:ifFieldPerm pageTitle:><input type="text" name="pageTitle" value='<:old pageTitle article pageTitle:>' size="60" maxlength="255" /> (alternate title)<:or:><:article pageTitle:><:eif:>
             </td>
-           <td nowrap="nowrap" bgcolor="#FFFFFF"><:help edit pageTitle:> <:error_img pageTitle:></td>
+           <td class="help"><:help edit pageTitle:> <:error_img pageTitle:></td>
           </tr>
           <tr>
-            <th nowrap="nowrap" align="left" bgcolor="#FFFFFF">Meta description:</th>
-            <td width="100%" bgcolor="#FFFFFF">
+            <th>Meta description:</th>
+            <td>
               <:ifFieldPerm metaDescription:><input type="text" name="metaDescription" value='<:old metaDescription article metaDescription:>' size="60" maxlength="255" /><:or:><:article metaDescription:><:eif:>
             </td>
-           <td nowrap="nowrap" bgcolor="#FFFFFF"><:help edit metaDescription:> <:error_img metaDescription:></td>
+           <td class="help"><:help edit metaDescription:> <:error_img metaDescription:></td>
           </tr>
           <tr>
-            <th nowrap="nowrap" align="left" bgcolor="#FFFFFF">Meta keywords:</th>
-            <td width="100%" bgcolor="#FFFFFF">
+            <th>Meta keywords:</th>
+            <td>
               <:ifFieldPerm metaKeywords:><input type="text" name="metaKeywords" value='<:old metaKeywords article metaKeywords:>' size="60" maxlength="255" /> (comma separated)<:or:><:article metaKeywords:><:eif:>
             </td>
-           <td nowrap="nowrap" bgcolor="#FFFFFF"><:help edit metaKeywords:> <:error_img metaKeywords:></td>
+           <td class="help"><:help edit metaKeywords:> <:error_img metaKeywords:></td>
           </tr>
           <tr>
-            <th nowrap="nowrap" align="left" bgcolor="#FFFFFF">Author name:</th>
-            <td width="100%" bgcolor="#FFFFFF" >
+            <th>Author name:</th>
+            <td>
               <:if FieldPerm author:><input type="text" name="author" value='<:ifCfg editor auto_author:><:old author adminuser name:><:or:><:old author:><:eif:>' size="40" maxlength="255" /><:or FieldPerm:><:article author:><:eif FieldPerm:>
             </td>
-            <td nowrap="nowrap" bgcolor="#FFFFFF" ><:help edit author:> <:error_img author:></td>
+            <td class="help"><:help edit author:> <:error_img author:></td>
           </tr>
           <tr> 
-            <th nowrap="nowrap" align="left" bgcolor="#FFFFFF">Lead time:</th>
-            <td nowrap="nowrap" bgcolor="#FFFFFF"
+            <th>Lead time:</th>
+            <td> 
               <:ifFieldPerm leadTime:>
               <input type="text" name="leadTime" value="<:old leadTime default leadTime:>" size="5" /><:or:><:product leadTime:><:eif:>
               days</td>
-            <td nowrap="nowrap" bgcolor="#FFFFFF"><:help product leadtime:> <:error_img leadTime:></td>
+            <td class="help"><:help product leadtime:> <:error_img leadTime:></td>
           </tr>
           <tr> 
-            <th align="left" bgcolor="#FFFFFF"><:cfg "product fields" retailPrice "Retail price":>:</th>
-            <td bgcolor="#FFFFFF">$ 
+            <th><:cfg "product fields" retailPrice "Retail price":>:</th>
+            <td>$ 
               <:ifFieldPerm retailPrice:><input type="text" name="retailPrice" value="<:old retailPrice money default retailPrice:>" size="7" />
               (0.00)<:or:><:money product retailPrice:><:eif:> </td>
-            <td nowrap="nowrap" bgcolor="#FFFFFF"><:help product retail:> <:error_img retailPrice:></td>
+            <td class="help"><:help product retail:> <:error_img retailPrice:></td>
           </tr>
           <tr> 
-            <th align="left" bgcolor="#FFFFFF">Wholesale price:</th>
-            <td bgcolor="#FFFFFF">$ 
+            <th>Wholesale price:</th>
+            <td>$ 
               <:ifFieldPerm wholesalePrice:><input type="text" name="wholesalePrice" value="<:old wholesalePrice money default wholesalePrice:>" size="7" />
               (0.00)<:or:><:money product wholesalePrice:><:eif:></td>
-            <td nowrap="nowrap" bgcolor="#FFFFFF"><:help product wholesale:> <:error_img wholesalePrice:></td>
+            <td class="help"><:help product wholesale:> <:error_img wholesalePrice:></td>
           </tr>
           <tr> 
-            <th align="left" bgcolor="#FFFFFF">GST:</th>
-            <td bgcolor="#FFFFFF">$ 
+            <th>GST:</th>
+            <td>$ 
               <:ifFieldPerm gst:><input type="text" name="gst" value="<:old gst money default gst:>" size="7" />
               (0.00)<:or:><:money product gst:><:eif:></td>
-            <td nowrap="nowrap" bgcolor="#FFFFFF"><:help product gst:> <:error_img gst:></td>
+            <td class="help"><:help product gst:> <:error_img gst:></td>
           </tr>
           <tr> 
-            <th align="left" bgcolor="#FFFFFF">Release date:</th>
-            <td bgcolor="#FFFFFF"
+            <th>Release date:</th>
+            <td> 
               <:ifFieldPerm release:><input type="text" name="release" value="<:old release date "%d/%m/%Y" default release:>" size="11" />
               (dd/mm/yyyy)<:or:><:date "%d/%m/%Y" product release:><:eif:></td>
-            <td nowrap="nowrap" bgcolor="#FFFFFF"><:help product release:> <:error_img
+            <td class="help"><:help product release:> <:error_img
             release:></td>
           </tr>
           <tr> 
-            <th align="left" bgcolor="#FFFFFF">Expiry date:</th>
-            <td bgcolor="#FFFFFF"
+            <th>Expiry date:</th>
+            <td> 
               <:ifFieldPerm expire:><input type="text" name="expire" value="<:old expire date "%d/%m/%Y" default expire:>" size="11" />
               (dd/mm/yyyy)<:or:><:date "%d/%m/%Y" product expire:><:eif:></td>
-            <td nowrap="nowrap" bgcolor="#FFFFFF"><:help product expire:> <:error_img
+            <td class="help"><:help product expire:> <:error_img
             expire:></td>
           </tr>
           <tr> 
-            <th nowrap="nowrap" align="left" bgcolor="#FFFFFF">Summary length:</th>
-            <td nowrap="nowrap" bgcolor="#FFFFFF"
+            <th>Summary length:</th>
+            <td> 
               <:ifFieldPerm summaryLength:><input type="text" name="summaryLength" size="10" maxlength="10" value="<:old summaryLength default summaryLength:>" /><:or:><:product summaryLength:><:eif:>
             </td>
-            <td nowrap="nowrap" bgcolor="#FFFFFF"><:help product summary:> <:error_img summaryLength:></td>
+            <td class="help"><:help product summary:> <:error_img summaryLength:></td>
           </tr>
           <tr> 
-            <th nowrap="nowrap" align="left" bgcolor="#FFFFFF">Display threshold:</th>
-            <td nowrap="nowrap" bgcolor="#FFFFFF"
+            <th>Display threshold:</th>
+            <td> 
               <:ifFieldPerm threshold:><input type="text" name="threshold" size="10" maxlength="10" value="<:old threshold default threshold:>" /><:or:><:product threshold:><:eif:>
             </td>
-            <td nowrap="nowrap" bgcolor="#FFFFFF"><:help product threshold:> <:error_img threshold:></td>
+            <td class="help"><:help product threshold:> <:error_img threshold:></td>
           </tr>
           <tr> 
-            <th bgcolor="#FFFFFF" nowrap="nowrap" align="left"> Keywords: </th>
-            <td bgcolor="#FFFFFF" width="100%"
+            <th> Keywords: </th>
+            <td> 
               <:ifFieldPerm keyword:><input type="text" name="keyword" maxlength="255" size="60" value="<: old keyword default keyword :>" /><:or:><: article threshold :><:eif:>
               (comma separated) </td>
-            <td nowrap="nowrap" bgcolor="#FFFFFF"><:help catalog keywords:> <:error_img keyword:></td>
+            <td class="help"><:help catalog keywords:> <:error_img keyword:></td>
           </tr>
           <tr> 
-            <th nowrap="nowrap" bgcolor="#FFFFFF" align="left">Always Dynamic:</th>
-            <td bgcolor="#FFFFFF" width="100%"
+            <th>Always Dynamic:</th>
+            <td> 
               <:ifFieldPerm force_dynamic:><input type="hidden" name="save_force_dynamic" value="1" /><input type="checkbox" name="force_dynamic" value="1" <:if Article force_dynamic :>checked="checked"<:or Article:><:eif Article:> />
               <:or:><:ifArticle force_dynamic :>Yes<:or Article:>No<:eif Article:><:eif:></td>
-            <td nowrap="nowrap" bgcolor="#FFFFFF"><:help edit keywords:> <:error_img keyword:></td>
+            <td class="help"><:help edit keywords:> <:error_img keyword:></td>
           </tr>
           <tr> 
-            <th align="left" bgcolor="#FFFFFF">Options:</th>
-            <td bgcolor="#FFFFFF"
+            <th>Options:</th>
+            <td> 
               <:ifFieldPerm options:><input type="text" name="options" value="<:old options default options:>" size="30" />
               (<:alloptions:>)<:or:><:product options:><:eif:> </td>
-            <td bgcolor="#FFFFFF"><:help product options:> <:error_img options:></td>
+            <td class="help"><:help product options:> <:error_img options:></td>
           </tr>
 <:include admin/product_custom.tmpl optional:>
           <tr> 
-            <th nowrap="nowrap" align="left" bgcolor="#FFFFFF" valign="top">Thumbnail image:</th>
-            <td nowrap="nowrap" bgcolor="#FFFFFF"
+            <th>Thumbnail image:</th>
+            <td> 
               <:ifFieldPerm thumbImage:><input type="file" name="thumbnail" /><:or:><:eif:>
               <:ifProduct thumbImage:><img src="/images/<:product thumbImage:>"> 
               <:if FieldPerm thumbImage:><input type="checkbox" name="remove_thumb" />
               Remove<:or FieldPerm:><:eif FieldPerm:><:or:><:eif:> </td>
-            <td nowrap="nowrap" bgcolor="#FFFFFF" valign="top"><:help product thumb:> <:error_img
+            <td class="help"><:help product thumb:> <:error_img
             thumbImage:></td>
           </tr>
                  <:if Article id:>
           <tr> 
-            <th nowrap="nowrap" align="left" bgcolor="#FFFFFF" valign="top"><a name="files"></a>Files:</th>
-            <td nowrap="nowrap" bgcolor="#FFFFFF"> <:if Files:> 
-              <table cellpadding="0" cellspacing="0" border="0" bgcolor="#333333">
-                <tr> 
-                  <td> 
-                    <table cellpadding="5" cellspacing="1" border="0">
-                      <tr bgcolor="#FFFFFF"> 
+            <th><a name="files"></a>Files:</th>
+            <td> <:if Files:> 
+                    <table class="editform editformsmall">
+                      <tr> 
                         <th>Filename</th>
                         <th>Size</th>
                         <th>Type</th>
                         <th>User</th>
                       </tr>
                       <:iterator begin files:> 
-                      <tr bgcolor="#FFFFFF"
+                      <tr> 
                         <td><:file displayName:></td>
                         <td align="center"><:kb file sizeInBytes:></td>
                         <td><:file contentType:></td>
                       </tr>
                       <:iterator end files:> 
                     </table>
-                  </td>
-                </tr>
-              </table>
               <p><a href="<:script:>?filelist=1&id=<:article id:>"><b>Manage Files</b></a>
               </p>
               <:or Files:>
-              <p>No files are attached to this article. <a href="<:script:>?filelist=1&id=<:article id:>"><b>Manage Files</b></a></p><:eif Files:>
+              No files are attached to this article. <a href="<:script:>?filelist=1&id=<:article id:>"><b>Manage Files</b></a><:eif Files:>
             </td>
-            <td nowrap="nowrap" bgcolor="#FFFFFF" valign="top"><:help product 
+            <td class="help"><:help product 
               files:> <:error_img files:></td>
           </tr>
           <tr> 
-            <th valign="top" nowrap="nowrap" bgcolor="#FFFFFF" align="left"> Images: 
-            </th>
-            <td align="center" bgcolor="#FFFFFF"> <:if Images:> <:iterator begin 
+            <th>Images:</th>
+            <td> <:if Images:> <:iterator begin 
               images:> <img src="/images/<: image image :>" alt="<:image alt :>" width= 
               <:image width:> height=<:image height:>> <:iterator separator images:> 
               <hr noshade size="1">
               <: iterator end images :>
               <p align="left"><a href="<:script:>?id=<:article id:>&showimages=1"><b>Manage Images</b></a></p>
-             <:or Images:><p align="left">No images are attached to this article.  <a href="<:script:>?id=<:article id:>&showimages=1"><b>Manage Images</b></a></p>
+             <:or Images:>No images are attached to this article.  <a href="<:script:>?id=<:article id:>&showimages=1"><b>Manage Images</b></a>
              <:eif Images:>
             </td>
-            <td valign="top" bgcolor="#FFFFFF"><:help product images:> <:error_img
+            <td class="help"><:help product images:> <:error_img
             images:></td>
           </tr>
                  <:or Article:><:eif Article:>
           <tr> 
-            <th nowrap="nowrap" align="left" bgcolor="#FFFFFF">Purchase subscribes to:</th>
-            <td nowrap="nowrap" bgcolor="#FFFFFF"
+            <th>Purchase subscribes to:</th>
+            <td> 
              <select name="subscription_id">
                 <option value="-1"<:ifEq [old subscription_id] "-1":> selected="selected"<:or:><:eif:>>(nothing)</option>
 <:iterator begin subscriptions:>
 <:iterator end subscriptions:>
              </select> for <input type="text" name="subscription_period" value="<:ifEq [old subscription_period] "":><:default subscription_period:><:or:><:old subscription_period:><:eif:>" size="3" /><:error_img subscription_period:> months.
             </td>
-            <td nowrap="nowrap" bgcolor="#FFFFFF"><:help product subscription_id:></td>
+            <td class="help"><:help product subscription_id:></td>
           </tr>
           <tr> 
-            <th nowrap="nowrap" align="left" bgcolor="#FFFFFF">Can be used to:</th>
-            <td nowrap="nowrap" bgcolor="#FFFFFF"
+            <th>Can be used to:</th>
+            <td> 
              <select name="subscription_usage">
                 <option value="3"<:ifEq [old subscription_usage] "3":> selected="selected"<:or:><:eif:>>Start or renew a subscription</option>
                 <option value="1"<:ifEq [old subscription_usage] "1":> selected="selected"<:or:><:eif:>>Start a subscription only</option>
                 <option value="2"<:ifEq [old subscription_usage] "2":> selected="selected"<:or:><:eif:>>Renew a subscription only</option>
              </select>
             </td>
-            <td nowrap="nowrap" bgcolor="#FFFFFF"><:help product subscription_usage:></td>
+            <td class="help"><:help product subscription_usage:></td>
           </tr>
           <tr> 
-            <th nowrap="nowrap" align="left" bgcolor="#FFFFFF">User must be subscribed to:</th>
-            <td nowrap="nowrap" bgcolor="#FFFFFF"
+            <th>User must be subscribed to:</th>
+            <td> 
              <select name="subscription_required">
                 <option value="-1"<:ifEq [old subscription_required] "-1":> selected="selected"<:or:><:eif:>>(nothing)</option>
 <:iterator begin subscriptions:>
 <:iterator end subscriptions:>
              </select> to purchase this product
             </td>
-            <td nowrap="nowrap" bgcolor="#FFFFFF"><:help product subscription_id:></td>
+            <td class="help"><:help product subscription_id:></td>
           </tr>
         </table>
-      </td>
-    </tr>
-  </table>
   <p><font size="-1">*These fields cannot be modified once this product has been
       included in an order.</font></p>
   <:if UserCan edit_save:article:>
diff --git a/site/templates/admin/prodopt_delete.tmpl b/site/templates/admin/prodopt_delete.tmpl
new file mode 100644 (file)
index 0000000..1c46250
--- /dev/null
@@ -0,0 +1,20 @@
+<:wrap admin/xbase.tmpl title => "Delete Product Option", menuitem=>none :>
+<:include admin/product_menu.tmpl:>
+<form action="<:script:>" method="post">
+<input type="hidden" name="id" value="<:article id:>" />
+<input type="hidden" name="option_id" value="<:option id:>" />
+<input type="hidden" name="_t" value="prodopts" />
+<:csrfp admin_delete_option hidden:>
+<p>Option: <:option name:></p>
+<:if Dboptionvalues:>
+<p>Values:</p>
+<:iterator begin dboptionvalues:>
+<p>Value: <:dboptionvalue value:></p>
+<:iterator end dboptionvalues:>
+<:or Dboptionvalues:>
+<p>No values defined for this option</p>
+<:eif Dboptionvalues:>
+<p><input type="submit" name="a_delete_option" value="Delete" />
+<input type="submit" value="Return to product options" />
+</p>
+</form>
\ No newline at end of file
diff --git a/site/templates/admin/prodopt_edit.tmpl b/site/templates/admin/prodopt_edit.tmpl
new file mode 100644 (file)
index 0000000..71f3566
--- /dev/null
@@ -0,0 +1,45 @@
+<:wrap admin/xbase.tmpl title => "Edit Product Option", menuitem=>none, showtitle=>1 :>
+<:include admin/product_menu.tmpl:>
+<form action="<:script:>" method="post">
+<input type="hidden" name="id" value="<:article id:>" />
+<input type="hidden" name="option_id" value="<:option id:>" />
+<input type="hidden" name="_t" value="prodopts" />
+<:csrfp admin_save_option hidden:>
+<input type="hidden" name="save_enabled" value="1" />
+<table class="editform">
+<tr>
+  <th>Name:</th>
+  <td><input type="text" name="name" value="<:old name option name:>" /></td>
+  <td class="help"><:error_img name:></td>
+</tr>
+<tr>
+  <th>Enabled:</th>
+  <td><input type="checkbox" name="enabled" value="1" <:ifOld enabled option enabled:>checked="checked"<:or:><:eif:> /></td>
+  <td class="help"><:error_img name:></td>
+</tr>
+<tr>
+  <th>Values:</th>
+<td>
+<table class="editform">
+<tr>
+  <td colspan="2"></td>
+  <th>Default<:error_img default_value:></th>
+</tr>
+<:iterator begin dboptionvalues:>
+<tr>
+  <th>Value:</th>
+  <td><input type="text" name="value<:dboptionvalue id:>" value="<:oldi [concatenate value [dboptionvalue id]] 0 dboptionvalue value:>" /><:error_img [concatenate value [dboptionvalue id]]:></td>
+  <td class="check"><input type="radio" name="default_value" value="<:dboptionvalue id:>" <:ifEq [dboptionvalue id] [option default_value]:>checked="checked"<:or:><:eif:> /></td>
+</tr>
+<:iterator end dboptionvalues:>
+</table>
+</td>
+<td class="help"></td>
+</tr>
+<tr>
+  <td colspan="2" class="buttons"><input type="submit" name="a_save_option" value="Save" />
+<input type="submit" value="Return to product options" /></td>
+<td></td>
+</tr>
+</table>
+</form>
\ No newline at end of file
diff --git a/site/templates/admin/prodopt_value_delete.tmpl b/site/templates/admin/prodopt_value_delete.tmpl
new file mode 100644 (file)
index 0000000..5517ad9
--- /dev/null
@@ -0,0 +1,13 @@
+<:wrap admin/xbase.tmpl title => "Delete Product Option Value", menuitem=>"prodopt_delete" :>
+<:include admin/product_menu.tmpl:>
+<form action="<:script:>" method="post">
+<input type="hidden" name="id" value="<:article id:>" />
+<input type="hidden" name="value_id" value="<:option_value id:>" />
+<input type="hidden" name="_t" value="prodopts" />
+<:csrfp admin_delete_option_value hidden:>
+<p>Option: <:option name:></p>
+<p>Value: <:option_value value:></p>
+<p><input type="submit" name="a_delete_option_value" value="Delete" />
+<input type="submit" value="Return to product options" />
+</p>
+</form>
\ No newline at end of file
diff --git a/site/templates/admin/prodopt_value_edit.tmpl b/site/templates/admin/prodopt_value_edit.tmpl
new file mode 100644 (file)
index 0000000..2a6bace
--- /dev/null
@@ -0,0 +1,19 @@
+<:wrap admin/xbase.tmpl title => "Edit Product Option Value", menuitem=>none, showtitle=>1 :>
+<:include admin/product_menu.tmpl:>
+<form action="<:script:>" method="post">
+<input type="hidden" name="id" value="<:article id:>" />
+<input type="hidden" name="value_id" value="<:option_value id:>" />
+<input type="hidden" name="_t" value="prodopts" />
+<:csrfp admin_save_option_value hidden:>
+<table class="editform editformsmall">
+<tr>
+<th>Option:</th><td><:option name:></td></tr>
+<tr>
+<th>Value:</th>
+<td><input type="text" name="value" value="<:old value option_value value:>" /></td><td class="help"><:error_img value:></td></tr>
+<tr>
+  <td class="buttons" colspan="2"><input type="submit" name="a_save_option_value" value="Save" /><input type="submit" value="Return to product options" /></td>
+  <td></td>
+</tr>
+</table>
+</form>
\ No newline at end of file
diff --git a/site/templates/admin/product_menu.tmpl b/site/templates/admin/product_menu.tmpl
new file mode 100644 (file)
index 0000000..f434996
--- /dev/null
@@ -0,0 +1,60 @@
+<div class="menu">
+|
+<a href="/cgi-bin/admin/menu.pl">Admin Menu</a>
+|
+<:ifEq [param menuitem] edit:>
+<span>Edit product</span>
+<:or:>
+<a href="<:script:>?id=<:article id:>">Edit product</a>
+<:eif:>
+|
+<:if New:><:or New:>
+<:if Eq [param menuitem] "edit":>
+<a href="<:product admin:>">See product</a>
+|
+<a href="/cgi-bin/admin/add.pl?id=<:product parentid:>">Edit parent</a>
+|
+<a href="/cgi-bin/admin/shopadmin.pl">Manage catalogs</a>
+|
+<:if UserCan edit_save:product,edit_field_edit_listed:product
+   :>
+   <:if Product listed:>
+     <a href="<:script:>?id=<:product id:>&hide=1&r=<:script:>?id=<:product id:>">Hide product</a> |
+  <:or Product:>
+    <a href="<:script:>?id=<:product id:>&unhide=1&r=<:script:>?id=<:product id:>">Show product</a> |
+  <:eif Product:>
+<:or UserCan:><:eif UserCan:>
+<:or Eq:>
+<:eif Eq:>
+<:ifEq [param menuitem] images:>
+<span>Images</span>
+<:or:>
+<a href="<:script:>?id=<:article id:>&amp;showimages=1">Images</a>
+<:eif:>
+|
+<:ifEq [param menuitem] files:>
+<span>Files</span>
+<:or:>
+<a href="<:script:>?id=<:article id:>&amp;filelist=1">Files</a>
+<:eif:>
+|
+<:ifEq [param menuitem] steps:>
+<span>Manage step parents</span>
+<:or:>
+<a href="<:script:>?id=<:article id:>&amp;_t=steps">Manage step parents</a>
+<:eif:>
+|
+<:ifEq [param menuitem] access:>
+<span>Manage access</span>
+<:or:>
+<a href="<:script:>?id=<:article id:>&amp;_t=groups">Manage access</a>
+<:eif:>
+|
+<:ifEq [param menuitem] prodopts:>
+<span>Product options</span>
+<:or:>
+<a href="<:script:>?id=<:article id:>&amp;_t=prodopts">Product options</a>
+<:eif:>
+|
+<:eif New:>
+</div>
index 4dd0bfec1e4495b6b092a953c7953d13e59163ed..e3522f1f40e784b38e30aa27eba4f3428ad74f51 100644 (file)
@@ -6,6 +6,7 @@
 <:ajax includes:>
 </head>
 <body>
+<:ifParam showtitle:><h1><:param title:></h1><:or:><:eif:>
 <:wrap here:>
 <hr />
 <p class="version">BSE Release <:release:> - page generated <:today:></p>
index 1bf941327a1ff66c4477a6166239a130b35b41af..e93fa263fe92e8b3f1aec705961c38732947800e 100644 (file)
@@ -49,7 +49,7 @@
               description:></a></font> <:moveallprod:></td>
 <:if Allprod retailPrice:>
             <td align="right"> <font face="Verdana, Arial, Helvetica, sans-serif" size="-2"><b>$<:money allprod retailPrice:></b></font></td>
-<:ifAnd [cfg shop order_from_catalog] [ifEq [allprod options] ""]:>
+<:ifAnd [cfg shop order_from_catalog] [ifEq [ifAnyProductOptions allprod] "0"]:>
 <td nowrap="nowrap"><font face="Verdana, Arial, Helvetica, sans-serif" size="-2"><input type="text" name="qty<:allprod id:>" size="4" /><input type="image" src="/images/store/quick_add.gif" alt="+" name="a_addsingle<:allprod id:>" /></font></td>
 <:or:><:eif:>
 <:or Allprod:>
index 1249ce018038ab4aabdb5606c60a0b7f40c79dea..34b308ae16d429513f2112dd52d2c8c3c536d94b 100644 (file)
@@ -1,30 +1,30 @@
 Table admin_base
 Column id;int(11);NO;NULL;auto_increment
-Column type;char(1);NO;;
+Column type;char(1);NO;NULL;
 Index PRIMARY;1;[id]
 Table admin_groups
-Column base_id;int(11);NO;;
-Column name;varchar(80);NO;;
-Column description;varchar(255);NO;;
-Column perm_map;varchar(255);NO;;
+Column base_id;int(11);NO;NULL;
+Column name;varchar(80);NO;NULL;
+Column description;varchar(255);NO;NULL;
+Column perm_map;varchar(255);NO;NULL;
 Column template_set;varchar(80);NO;;
 Index PRIMARY;1;[base_id]
 Index name;1;[name]
 Table admin_membership
-Column user_id;int(11);NO;;
-Column group_id;int(11);NO;;
+Column user_id;int(11);NO;NULL;
+Column group_id;int(11);NO;NULL;
 Index PRIMARY;1;[user_id;group_id]
 Table admin_perms
-Column object_id;int(11);NO;;
-Column admin_id;int(11);NO;;
+Column object_id;int(11);NO;NULL;
+Column admin_id;int(11);NO;NULL;
 Column perm_map;varchar(255);YES;NULL;
 Index PRIMARY;1;[object_id;admin_id]
 Table admin_users
-Column base_id;int(11);NO;;
-Column logon;varchar(60);NO;;
-Column name;varchar(255);NO;;
-Column password;varchar(80);NO;;
-Column perm_map;varchar(255);NO;;
+Column base_id;int(11);NO;NULL;
+Column logon;varchar(60);NO;NULL;
+Column name;varchar(255);NO;NULL;
+Column password;varchar(80);NO;NULL;
+Column perm_map;varchar(255);NO;NULL;
 Index PRIMARY;1;[base_id]
 Index logon;1;[logon]
 Table article
@@ -32,24 +32,24 @@ Column id;int(11);NO;NULL;auto_increment
 Column parentid;int(11);NO;0;
 Column displayOrder;int(11);NO;0;
 Column title;varchar(255);NO;;
-Column titleImage;varchar(64);NO;;
-Column body;text;NO;;
+Column titleImage;varchar(64);NO;NULL;
+Column body;text;NO;NULL;
 Column thumbImage;varchar(64);NO;;
-Column thumbWidth;int(11);NO;;
-Column thumbHeight;int(11);NO;;
-Column imagePos;char(2);NO;;
+Column thumbWidth;int(11);NO;NULL;
+Column thumbHeight;int(11);NO;NULL;
+Column imagePos;char(2);NO;NULL;
 Column release;datetime;NO;1990-01-01 00:00:00;
 Column expire;datetime;NO;2999-12-31 23:59:59;
 Column keyword;varchar(255);NO;;
 Column template;varchar(127);NO;;
-Column link;varchar(255);NO;;
-Column admin;varchar(64);NO;;
+Column link;varchar(255);NO;NULL;
+Column admin;varchar(64);NO;NULL;
 Column threshold;int(11);NO;3;
 Column summaryLength;smallint(5) unsigned;NO;200;
 Column generator;varchar(40);NO;article;
-Column level;smallint(6);NO;;
+Column level;smallint(6);NO;NULL;
 Column listed;smallint(6);NO;1;
-Column lastModified;datetime;NO;;
+Column lastModified;datetime;NO;NULL;
 Column flags;varchar(80);NO;;
 Column customDate1;datetime;YES;NULL;
 Column customDate2;datetime;YES;NULL;
@@ -69,7 +69,7 @@ Column cached_dynamic;int(11);NO;0;
 Column inherit_siteuser_rights;int(11);NO;1;
 Column metaDescription;varchar(255);NO;;
 Column metaKeywords;varchar(255);NO;;
-Column summaryx;text;NO;;
+Column summaryx;text;NO;NULL;
 Column menu;smallint(5);NO;0;
 Column titleAlias;varchar(60);NO;;
 Column linkAlias;varchar(255);NO;;
@@ -81,121 +81,149 @@ Index article_level_index;0;[level;id]
 Index article_parentId_index;0;[parentid]
 Table article_files
 Column id;int(11);NO;NULL;auto_increment
-Column articleId;int(11);NO;;
+Column articleId;int(11);NO;NULL;
 Column displayName;varchar(255);NO;;
 Column filename;varchar(80);NO;;
-Column sizeInBytes;int(11);NO;;
+Column sizeInBytes;int(11);NO;NULL;
 Column description;varchar(255);NO;;
 Column contentType;varchar(80);NO;application/octet-stream;
-Column displayOrder;int(11);NO;;
+Column displayOrder;int(11);NO;NULL;
 Column forSale;int(11);NO;0;
 Column download;int(11);NO;0;
-Column whenUploaded;datetime;NO;;
+Column whenUploaded;datetime;NO;NULL;
 Column requireUser;int(11);NO;0;
-Column notes;text;NO;;
+Column notes;text;NO;NULL;
 Column name;varchar(80);NO;;
 Column hide_from_list;int(11);NO;0;
 Column storage;varchar(20);NO;local;
 Column src;varchar(255);NO;;
 Index PRIMARY;1;[id]
 Table bse_article_groups
-Column article_id;int(11);NO;;
-Column group_id;int(11);NO;;
+Column article_id;int(11);NO;NULL;
+Column group_id;int(11);NO;NULL;
 Index PRIMARY;1;[article_id;group_id]
 Table bse_locations
 Column id;int(11);NO;NULL;auto_increment
-Column description;varchar(255);NO;;
-Column room;varchar(40);NO;;
-Column street1;varchar(255);NO;;
-Column street2;varchar(255);NO;;
-Column suburb;varchar(255);NO;;
-Column state;varchar(80);NO;;
-Column country;varchar(80);NO;;
-Column postcode;varchar(40);NO;;
-Column public_notes;text;NO;;
-Column bookings_name;varchar(80);NO;;
-Column bookings_phone;varchar(80);NO;;
-Column bookings_fax;varchar(80);NO;;
-Column bookings_url;varchar(255);NO;;
-Column facilities_name;varchar(255);NO;;
-Column facilities_phone;varchar(80);NO;;
-Column admin_notes;text;NO;;
+Column description;varchar(255);NO;NULL;
+Column room;varchar(40);NO;NULL;
+Column street1;varchar(255);NO;NULL;
+Column street2;varchar(255);NO;NULL;
+Column suburb;varchar(255);NO;NULL;
+Column state;varchar(80);NO;NULL;
+Column country;varchar(80);NO;NULL;
+Column postcode;varchar(40);NO;NULL;
+Column public_notes;text;NO;NULL;
+Column bookings_name;varchar(80);NO;NULL;
+Column bookings_phone;varchar(80);NO;NULL;
+Column bookings_fax;varchar(80);NO;NULL;
+Column bookings_url;varchar(255);NO;NULL;
+Column facilities_name;varchar(255);NO;NULL;
+Column facilities_phone;varchar(80);NO;NULL;
+Column admin_notes;text;NO;NULL;
 Column disabled;int(11);NO;0;
 Index PRIMARY;1;[id]
+Table bse_order_item_options
+Column id;int(11);NO;NULL;auto_increment
+Column order_item_id;int(11);NO;NULL;
+Column original_id;varchar(40);NO;NULL;
+Column name;varchar(40);NO;NULL;
+Column value;varchar(40);NO;NULL;
+Column display;varchar(80);NO;NULL;
+Column display_order;int(11);NO;NULL;
+Index PRIMARY;1;[id]
+Index item_order;0;[order_item_id;display_order]
+Table bse_product_option_values
+Column id;int(11);NO;NULL;auto_increment
+Column product_option_id;int(11);NO;NULL;
+Column value;varchar(40);NO;NULL;
+Column display_order;int(11);NO;NULL;
+Index PRIMARY;1;[id]
+Index option_order;0;[product_option_id;display_order]
+Table bse_product_options
+Column id;int(11);NO;NULL;auto_increment
+Column product_id;int(11);NO;NULL;
+Column name;varchar(40);NO;NULL;
+Column type;varchar(10);NO;NULL;
+Column global_ref;int(11);YES;NULL;
+Column display_order;int(11);NO;NULL;
+Column enabled;int(11);NO;0;
+Column default_value;int(11);YES;NULL;
+Index PRIMARY;1;[id]
+Index product_order;0;[product_id;display_order]
 Table bse_seminar_bookings
 Column id;int(11);NO;NULL;auto_increment
-Column session_id;int(11);NO;;
-Column siteuser_id;int(11);NO;;
+Column session_id;int(11);NO;NULL;
+Column siteuser_id;int(11);NO;NULL;
 Column roll_present;int(11);NO;0;
 Column options;varchar(255);NO;;
-Column customer_instructions;text;NO;;
-Column support_notes;text;NO;;
+Column customer_instructions;text;NO;NULL;
+Column support_notes;text;NO;NULL;
 Index PRIMARY;1;[id]
 Index session_id;1;[session_id;siteuser_id]
 Index siteuser_id;0;[siteuser_id]
 Table bse_seminar_sessions
 Column id;int(11);NO;NULL;auto_increment
-Column seminar_id;int(11);NO;;
-Column location_id;int(11);NO;;
-Column when_at;datetime;NO;;
+Column seminar_id;int(11);NO;NULL;
+Column location_id;int(11);NO;NULL;
+Column when_at;datetime;NO;NULL;
 Column roll_taken;int(11);NO;0;
 Index PRIMARY;1;[id]
 Index location_id;0;[location_id]
 Index seminar_id;1;[seminar_id;location_id;when_at]
 Index seminar_id_2;0;[seminar_id]
 Table bse_seminars
-Column seminar_id;int(11);NO;;
-Column duration;int(11);NO;;
+Column seminar_id;int(11);NO;NULL;
+Column duration;int(11);NO;NULL;
 Index PRIMARY;1;[seminar_id]
 Table bse_siteuser_groups
 Column id;int(11);NO;NULL;auto_increment
-Column name;varchar(80);NO;;
+Column name;varchar(80);NO;NULL;
 Index PRIMARY;1;[id]
 Table bse_siteuser_images
-Column siteuser_id;int(11);NO;;
-Column image_id;varchar(20);NO;;
-Column filename;varchar(80);NO;;
-Column width;int(11);NO;;
-Column height;int(11);NO;;
-Column bytes;int(11);NO;;
-Column content_type;varchar(80);NO;;
-Column alt;varchar(255);NO;;
+Column siteuser_id;int(11);NO;NULL;
+Column image_id;varchar(20);NO;NULL;
+Column filename;varchar(80);NO;NULL;
+Column width;int(11);NO;NULL;
+Column height;int(11);NO;NULL;
+Column bytes;int(11);NO;NULL;
+Column content_type;varchar(80);NO;NULL;
+Column alt;varchar(255);NO;NULL;
 Index PRIMARY;1;[siteuser_id;image_id]
 Table bse_siteuser_membership
-Column group_id;int(11);NO;;
-Column siteuser_id;int(11);NO;;
+Column group_id;int(11);NO;NULL;
+Column siteuser_id;int(11);NO;NULL;
 Index PRIMARY;1;[group_id;siteuser_id]
 Index siteuser_id;0;[siteuser_id]
 Table bse_subscriptions
 Column subscription_id;int(11);NO;NULL;auto_increment
-Column text_id;varchar(20);NO;;
-Column title;varchar(255);NO;;
-Column description;text;NO;;
-Column max_lapsed;int(11);NO;;
+Column text_id;varchar(20);NO;NULL;
+Column title;varchar(255);NO;NULL;
+Column description;text;NO;NULL;
+Column max_lapsed;int(11);NO;NULL;
 Index PRIMARY;1;[subscription_id]
 Index text_id;1;[text_id]
 Table bse_user_subscribed
-Column subscription_id;int(11);NO;;
-Column siteuser_id;int(11);NO;;
-Column started_at;date;NO;;
-Column ends_at;date;NO;;
-Column max_lapsed;int(11);NO;;
+Column subscription_id;int(11);NO;NULL;
+Column siteuser_id;int(11);NO;NULL;
+Column started_at;date;NO;NULL;
+Column ends_at;date;NO;NULL;
+Column max_lapsed;int(11);NO;NULL;
 Index PRIMARY;1;[subscription_id;siteuser_id]
 Table bse_wishlist
-Column user_id;int(11);NO;;
-Column product_id;int(11);NO;;
-Column display_order;int(11);NO;;
+Column user_id;int(11);NO;NULL;
+Column product_id;int(11);NO;NULL;
+Column display_order;int(11);NO;NULL;
 Index PRIMARY;1;[user_id;product_id]
 Table email_blacklist
 Column id;int(11);NO;NULL;auto_increment
-Column email;varchar(127);NO;;
-Column why;varchar(80);NO;;
+Column email;varchar(127);NO;NULL;
+Column why;varchar(80);NO;NULL;
 Index PRIMARY;1;[id]
 Index email;1;[email]
 Table email_requests
 Column id;int(11);NO;NULL;auto_increment
-Column email;varchar(127);NO;;
-Column genEmail;varchar(127);NO;;
+Column email;varchar(127);NO;NULL;
+Column genEmail;varchar(127);NO;NULL;
 Column lastConfSent;datetime;NO;0000-00-00 00:00:00;
 Column unackedConfMsgs;int(11);NO;0;
 Index PRIMARY;1;[id]
@@ -203,7 +231,7 @@ Index email;1;[email]
 Index genEmail;1;[genEmail]
 Table image
 Column id;mediumint(8) unsigned;NO;NULL;auto_increment
-Column articleId;int(11);NO;;
+Column articleId;int(11);NO;NULL;
 Column image;varchar(64);NO;;
 Column alt;varchar(255);NO;[Image];
 Column width;smallint(5) unsigned;YES;NULL;
@@ -216,13 +244,13 @@ Column src;varchar(255);NO;;
 Index PRIMARY;1;[id]
 Table order_item
 Column id;int(11);NO;NULL;auto_increment
-Column productId;int(11);NO;;
-Column orderId;int(11);NO;;
-Column units;int(11);NO;;
-Column price;int(11);NO;;
-Column wholesalePrice;int(11);NO;;
-Column gst;int(11);NO;;
-Column options;varchar(255);NO;;
+Column productId;int(11);NO;NULL;
+Column orderId;int(11);NO;NULL;
+Column units;int(11);NO;NULL;
+Column price;int(11);NO;NULL;
+Column wholesalePrice;int(11);NO;NULL;
+Column gst;int(11);NO;NULL;
+Column options;varchar(255);NO;NULL;
 Column customInt1;int(11);YES;NULL;
 Column customInt2;int(11);YES;NULL;
 Column customInt3;int(11);YES;NULL;
@@ -257,14 +285,14 @@ Column billCountry;varchar(127);NO;Australia;
 Column telephone;varchar(80);NO;;
 Column facsimile;varchar(80);NO;;
 Column emailAddress;varchar(255);NO;;
-Column total;int(11);NO;;
+Column total;int(11);NO;NULL;
 Column wholesaleTotal;int(11);NO;0;
-Column gst;int(11);NO;;
-Column orderDate;datetime;NO;;
+Column gst;int(11);NO;NULL;
+Column orderDate;datetime;NO;NULL;
 Column ccNumberHash;varchar(127);NO;;
 Column ccName;varchar(127);NO;;
 Column ccExpiryHash;varchar(127);NO;;
-Column ccType;varchar(30);NO;;
+Column ccType;varchar(30);NO;NULL;
 Column filled;int(11);NO;0;
 Column whenFilled;datetime;YES;NULL;
 Column whoFilled;varchar(40);NO;;
@@ -272,7 +300,7 @@ Column paidFor;int(11);NO;0;
 Column paymentReceipt;varchar(40);YES;NULL;
 Column randomId;varchar(40);YES;NULL;
 Column cancelled;int(11);NO;0;
-Column userId;varchar(40);NO;;
+Column userId;varchar(40);NO;NULL;
 Column paymentType;int(11);NO;0;
 Column customInt1;int(11);YES;NULL;
 Column customInt2;int(11);YES;NULL;
@@ -284,7 +312,7 @@ Column customStr2;varchar(255);YES;NULL;
 Column customStr3;varchar(255);YES;NULL;
 Column customStr4;varchar(255);YES;NULL;
 Column customStr5;varchar(255);YES;NULL;
-Column instructions;text;NO;;
+Column instructions;text;NO;NULL;
 Column billTelephone;varchar(80);NO;;
 Column billFacsimile;varchar(80);NO;;
 Column billEmail;varchar(255);NO;;
@@ -311,28 +339,28 @@ Index order_cchash;0;[ccNumberHash]
 Index order_userId;0;[userId;orderDate]
 Table other_parents
 Column id;int(11);NO;NULL;auto_increment
-Column parentId;int(11);NO;;
-Column childId;int(11);NO;;
-Column parentDisplayOrder;int(11);NO;;
-Column childDisplayOrder;int(11);NO;;
+Column parentId;int(11);NO;NULL;
+Column childId;int(11);NO;NULL;
+Column parentDisplayOrder;int(11);NO;NULL;
+Column childDisplayOrder;int(11);NO;NULL;
 Column release;datetime;NO;0000-00-00 00:00:00;
 Column expire;datetime;NO;9999-12-31 23:59:59;
 Index PRIMARY;1;[id]
 Index childId;0;[childId;childDisplayOrder]
 Index parentId;1;[parentId;childId]
 Table product
-Column articleId;int(11);NO;;
-Column summary;varchar(255);NO;;
+Column articleId;int(11);NO;NULL;
+Column summary;varchar(255);NO;NULL;
 Column leadTime;int(11);NO;0;
-Column retailPrice;int(11);NO;;
+Column retailPrice;int(11);NO;NULL;
 Column wholesalePrice;int(11);YES;NULL;
-Column gst;int(11);NO;;
-Column options;varchar(255);NO;;
+Column gst;int(11);NO;NULL;
+Column options;varchar(255);NO;NULL;
 Column subscription_id;int(11);NO;-1;
 Column subscription_period;int(11);NO;0;
 Column subscription_usage;int(11);NO;3;
 Column subscription_required;int(11);NO;-1;
-Column product_code;varchar(80);NO;;
+Column product_code;varchar(80);NO;NULL;
 Index PRIMARY;1;[articleId]
 Table searchindex
 Column id;varbinary(200);NO;;
@@ -341,18 +369,18 @@ Column sectionIds;varchar(255);NO;;
 Column scores;varchar(255);NO;;
 Index PRIMARY;1;[id]
 Table sessions
-Column id;char(32);NO;;
+Column id;char(32);NO;NULL;
 Column a_session;text;YES;NULL;
 Column whenChanged;timestamp;NO;CURRENT_TIMESTAMP;
 Index PRIMARY;1;[id]
 Table site_users
 Column id;int(11);NO;NULL;auto_increment
-Column userId;varchar(40);NO;;
-Column password;varchar(40);NO;;
-Column email;varchar(255);NO;;
+Column userId;varchar(40);NO;NULL;
+Column password;varchar(40);NO;NULL;
+Column email;varchar(255);NO;NULL;
 Column keepAddress;int(11);NO;1;
-Column whenRegistered;datetime;NO;;
-Column lastLogon;datetime;NO;;
+Column whenRegistered;datetime;NO;NULL;
+Column lastLogon;datetime;NO;NULL;
 Column name1;varchar(127);YES;NULL;
 Column name2;varchar(127);YES;NULL;
 Column address;varchar(127);YES;NULL;
@@ -366,16 +394,16 @@ Column wantLetter;int(11);NO;0;
 Column confirmed;int(11);NO;0;
 Column confirmSecret;varchar(40);NO;;
 Column waitingForConfirmation;int(11);NO;0;
-Column textOnlyMail;int(11);NO;;
+Column textOnlyMail;int(11);NO;NULL;
 Column title;varchar(127);YES;NULL;
 Column organization;varchar(127);YES;NULL;
 Column referral;int(11);YES;NULL;
-Column otherReferral;varchar(127);NO;;
+Column otherReferral;varchar(127);NO;NULL;
 Column prompt;int(11);YES;NULL;
-Column otherPrompt;varchar(127);NO;;
-Column profession;int(11);NO;;
-Column otherProfession;varchar(127);NO;;
-Column previousLogon;datetime;NO;;
+Column otherPrompt;varchar(127);NO;NULL;
+Column profession;int(11);NO;NULL;
+Column otherProfession;varchar(127);NO;NULL;
+Column previousLogon;datetime;NO;NULL;
 Column billFirstName;varchar(127);NO;;
 Column billLastName;varchar(127);NO;;
 Column billStreet;varchar(127);NO;;
@@ -383,11 +411,11 @@ Column billSuburb;varchar(127);NO;;
 Column billState;varchar(40);NO;;
 Column billPostCode;varchar(40);NO;;
 Column billCountry;varchar(127);NO;;
-Column instructions;text;NO;;
+Column instructions;text;NO;NULL;
 Column billTelephone;varchar(80);NO;;
 Column billFacsimile;varchar(80);NO;;
 Column billEmail;varchar(255);NO;;
-Column adminNotes;text;NO;;
+Column adminNotes;text;NO;NULL;
 Column disabled;int(11);NO;0;
 Column flags;varchar(80);NO;;
 Column customText1;text;YES;NULL;
@@ -408,27 +436,27 @@ Index PRIMARY;1;[id]
 Index affiliate_name;0;[affiliate_name]
 Index userId;1;[userId]
 Table sql_statements
-Column name;varchar(80);NO;;
-Column sql_statement;text;NO;;
+Column name;varchar(80);NO;NULL;
+Column sql_statement;text;NO;NULL;
 Index PRIMARY;1;[name]
 Table subscribed_users
 Column id;int(11);NO;NULL;auto_increment
-Column subId;int(11);NO;;
-Column userId;int(11);NO;;
+Column subId;int(11);NO;NULL;
+Column userId;int(11);NO;NULL;
 Index PRIMARY;1;[id]
 Index subId;1;[subId;userId]
 Table subscription_types
 Column id;int(11);NO;NULL;auto_increment
-Column name;varchar(80);NO;;
-Column title;varchar(64);NO;;
-Column description;text;NO;;
-Column frequency;varchar(127);NO;;
-Column keyword;varchar(255);NO;;
+Column name;varchar(80);NO;NULL;
+Column title;varchar(64);NO;NULL;
+Column description;text;NO;NULL;
+Column frequency;varchar(127);NO;NULL;
+Column keyword;varchar(255);NO;NULL;
 Column archive;int(11);NO;1;
-Column article_template;varchar(127);NO;;
-Column html_template;varchar(127);NO;;
-Column text_template;varchar(127);NO;;
-Column parentId;int(11);NO;;
+Column article_template;varchar(127);NO;NULL;
+Column html_template;varchar(127);NO;NULL;
+Column text_template;varchar(127);NO;NULL;
+Column parentId;int(11);NO;NULL;
 Column lastSent;datetime;NO;0000-00-00 00:00:00;
 Column visible;int(11);NO;1;
 Index PRIMARY;1;[id]