]> git.imager.perl.org - bse.git/blobdiff - site/cgi-bin/modules/BSE/Edit/Article.pm
allow metadata to be defined for new products
[bse.git] / site / cgi-bin / modules / BSE / Edit / Article.pm
index 019a7ded4b6f339c3e71eab62210d62ddd9234a8..754412da4359cbd18dae798ae84720043c97047f 100644 (file)
@@ -1,17 +1,22 @@
 package BSE::Edit::Article;
 use strict;
 use base qw(BSE::Edit::Base);
-use BSE::Util::Tags qw(tag_error_img);
+use BSE::Util::Tags qw(tag_error_img tag_article tag_object);
 use BSE::Util::SQL qw(now_sqldate now_sqldatetime);
 use BSE::Permissions;
-use DevHelp::HTML qw(:default popup_menu);
+use BSE::Util::HTML qw(:default popup_menu);
 use BSE::Arrows;
-use BSE::CfgInfo qw(custom_class admin_base_url cfg_image_dir);
+use BSE::CfgInfo qw(custom_class admin_base_url cfg_image_dir cfg_dist_image_uri cfg_image_uri);
 use BSE::Util::Iterate;
 use BSE::Template;
 use BSE::Util::ContentType qw(content_type);
+use BSE::Regen 'generate_article';
 use DevHelp::Date qw(dh_parse_date dh_parse_sql_date);
+use List::Util qw(first);
 use constant MAX_FILE_DISPLAYNAME_LENGTH => 255;
+use constant ARTICLE_CUSTOM_FIELDS_CFG => "article custom fields";
+
+our $VERSION = "1.054";
 
 =head1 NAME
 
@@ -126,6 +131,7 @@ sub article_actions {
      addimg => 'add_image',
      a_edit_image => 'req_edit_image',
      a_save_image => 'req_save_image',
+     a_order_images => 'req_order_images',
      remove => 'remove',
      showimages => 'show_images',
      process => 'save_image_changes',
@@ -150,6 +156,7 @@ sub article_actions {
      a_tree => 'req_tree',
      a_article => 'req_article',
      a_config => 'req_config',
+     a_restepkid => 'req_restepkid',
     );
 }
 
@@ -242,7 +249,7 @@ sub should_be_catalog {
 
   return $article->{parentid} && $parent &&
     ($article->{parentid} == $shopid || 
-     $parent->{generator} eq 'Generate::Catalog');
+     $parent->{generator} eq 'BSE::Generate::Catalog');
 }
 
 sub possible_parents {
@@ -253,7 +260,7 @@ sub possible_parents {
 
   my $shopid = $self->cfg->entryErr('articles', 'shop');
   my @parents = $articles->getBy('level', $article->{level}-1);
-  @parents = grep { $_->{generator} eq 'Generate::Article' 
+  @parents = grep { $_->{generator} eq 'BSE::Generate::Article' 
                      && $_->{id} != $shopid } @parents;
 
   # user can only select parent they can add to
@@ -289,7 +296,7 @@ sub possible_parents {
       else {
        if ($req->user_can('edit_add_child')) {
          push @values, -1;
-         $labels{-1} = "-- move up a level -- become a section";
+         $labels{-1} = $req->catmsg("msg:bse/admin/edit/uplabelsect");
        }
       }
     }
@@ -359,7 +366,7 @@ sub iter_get_kids {
 
   my @children;
   $article->{id} or return;
-  if (UNIVERSAL::isa($article, 'Article')) {
+  if (UNIVERSAL::isa($article, 'BSE::TB::Article')) {
     @children = $article->children;
   }
   elsif ($article->{id}) {
@@ -401,19 +408,30 @@ sub default_template {
 sub tag_templates {
   my ($self, $article, $cfg, $cgi) = @_;
 
-  my @templates = sort $self->templates($article);
+  my @templates = sort { $a->{name} cmp $b->{name} } $self->templates_long($article);
   my $default;
-  if ($article->{template} && grep $_ eq $article->{template}, @templates) {
+  if ($article->{template} && grep $_->{name} eq $article->{template}, @templates) {
     $default = $article->{template};
   }
   else {
-    my @options;
-    $default = $self->default_template($article, $cfg, \@templates);
+    my @template_names = map $_->{name}, @templates;
+    $default = $self->default_template($article, $cfg, \@template_names);
   }
-  return popup_menu(-name=>'template',
-                   -values=>\@templates,
-                   -default=>$default,
-                   -override=>1);
+  my %labels =
+    (
+     map
+     { ;
+       $_->{name} => 
+       $_->{name} eq $_->{description}
+        ? $_->{name}
+          : "$_->{description} ($_->{name})"
+     } @templates
+    );
+  return popup_menu(-name => 'template',
+                   -values => [ map $_->{name}, @templates ],
+                   -labels => \%labels,
+                   -default => $default,
+                   -override => 1);
 }
 
 sub title_images {
@@ -523,6 +541,12 @@ sub extra_templates {
   @templates;
 }
 
+sub categories {
+  my ($self, $articles) = @_;
+
+  return $articles->categories;
+}
+
 sub edit_parent {
   my ($article) = @_;
 
@@ -542,7 +566,8 @@ sub iter_allkids {
 sub _load_step_kids {
   my ($article, $step_kids) = @_;
 
-  my @stepkids = OtherParents->getBy(parentId=>$article->{id}) if $article->{id};
+  require BSE::TB::OtherParents;
+  my @stepkids = BSE::TB::OtherParents->getBy(parentId=>$article->{id}) if $article->{id};
   %$step_kids = map { $_->{childId} => $_ } @stepkids;
   $step_kids->{loaded} = 1;
 }
@@ -648,7 +673,8 @@ sub iter_get_stepparents {
 
   return unless $article->{id} && $article->{id} > 0;
 
-  OtherParents->getBy(childId=>$article->{id});
+  require BSE::TB::OtherParents;
+  BSE::TB::OtherParents->getBy(childId=>$article->{id});
 }
 
 sub tag_ifStepParents {
@@ -680,7 +706,7 @@ sub tag_move_stepparent {
   $urladd = '' unless defined $urladd;
 
   my $cgi_uri = $self->{cfg}->entry('uri', 'cgi', '/cgi-bin');
-  my $images_uri = $self->{cfg}->entry('uri', 'images', '/images');
+  my $images_uri = cfg_dist_image_uri();
   my $html = '';
   my $url = $ENV{SCRIPT_NAME} . "?id=$article->{id}";
   if ($cgi->param('_t')) {
@@ -789,7 +815,7 @@ sub tag_movechild {
   $urladd = '' unless defined $urladd;
 
   my $cgi_uri = $self->{cfg}->entry('uri', 'cgi', '/cgi-bin');
-  my $images_uri = $self->{cfg}->entry('uri', 'images', '/images');
+  my $images_uri = cfg_dist_image_uri();
   my $urlbase = admin_base_url($req->cfg);
   my $refresh_url = "$urlbase$ENV{SCRIPT_NAME}?id=$article->{id}";
   my $t = $req->cgi->param('_t');
@@ -812,6 +838,19 @@ sub tag_movechild {
   return make_arrows($req->cfg, $down_url, $up_url, $refresh_url, $img_prefix);
 }
 
+sub tag_category {
+  my ($self, $articles, $article) = @_;
+
+  my @cats = $self->categories($articles);
+
+  my %labels = map { $_->{id}, $_->{name} } @cats;
+
+  return popup_menu(-name => 'category',
+                   -values => [ map $_->{id}, @cats ],
+                   -labels => \%labels,
+                   -default => $article->{category});
+}
+
 sub tag_edit_link {
   my ($cfg, $article, $args, $acts, $funcname, $templater) = @_;
   my ($which, $name) = split / /, $args, 2;
@@ -1062,6 +1101,7 @@ sub tag_thumbimage {
     (
      geo => $args,
      cfg => $cfg,
+     nolink => 1,
     );
 }
 
@@ -1107,14 +1147,108 @@ sub tag_image {
   }
 }
 
+sub iter_tags {
+  my ($self, $article) = @_;
+
+  $article->{id}
+    or return;
+
+  return $article->tag_objects;
+}
+
+my %base_custom_validation =
+  (
+   customDate1 =>
+   {
+    rules => "date",
+    htmltype => "text",
+    width => 10,
+    default => "",
+    type => "date",
+   },
+   customDate2 =>
+   {
+    rules => "date",
+    htmltype => "text",
+    width => 10,
+    default => "",
+    type => "date",
+   },
+   customStr1 =>
+   {
+    htmltype => "text",
+    default => "",
+   },
+   customStr2 =>
+   {
+    htmltype => "text",
+    default => "",
+   },
+   customInt1 =>
+   {
+    rules => "integer",
+    htmltype => "text",
+    width => 10,
+    default => "",
+   },
+   customInt2 =>
+   {
+    rules => "integer",
+    htmltype => "text",
+    width => 10,
+    default => "",
+   },
+   customInt3 =>
+   {
+    rules => "integer",
+    htmltype => "text",
+    width => 10,
+    default => "",
+   },
+   customInt4 =>
+   {
+    rules => "integer",
+    htmltype => "text",
+    width => 10,
+    default => "",
+   },
+  );
+
+sub custom_fields {
+  my $self = shift;
+
+  require DevHelp::Validate;
+  DevHelp::Validate->import;
+  return DevHelp::Validate::dh_configure_fields
+    (
+     \%base_custom_validation,
+     $self->cfg,
+     ARTICLE_CUSTOM_FIELDS_CFG,
+     BSE::DB->single->dbh,
+    );
+}
+
+sub _custom_fields {
+  my $self = shift;
+
+  my $fields = $self->custom_fields;
+  my %active;
+  for my $key (keys %$fields) {
+    $fields->{$key}{description}
+      and $active{$key} = $fields->{$key};
+  }
+
+  return \%active;
+}
+
 sub low_edit_tags {
   my ($self, $acts, $request, $article, $articles, $msg, $errors) = @_;
 
   my $cgi = $request->cgi;
   my $show_full = $cgi->param('f_showfull');
   my $if_error = $msg || ($errors && keys %$errors) || $request->cgi->param("_e");
-  $msg ||= join "\n", map escape_html($_), $cgi->param('message'), $cgi->param('m');
-  $msg ||= $request->message($errors);
+  #$msg ||= join "\n", map escape_html($_), $cgi->param('message'), $cgi->param('m');
+  $msg .= $request->message($errors);
   my $parent;
   if ($article->{id}) {
     if ($article->{parentid} > 0) {
@@ -1127,6 +1261,8 @@ sub low_edit_tags {
   else {
     $parent = { title=>"How did we get here?", id=>0 };
   }
+  $request->set_article(article => $article);
+  $request->set_variable(ifnew => !$article->{id});
   my $cfg = $self->{cfg};
   my $mbcs = $cfg->entry('html', 'mbcs', 0);
   my $tag_hash = $mbcs ? \&tag_hash_mbcs : \&tag_hash;
@@ -1150,13 +1286,23 @@ sub low_edit_tags {
   my @groups;
   my $current_group;
   my $it = BSE::Util::Iterate->new;
+  my $ito = BSE::Util::Iterate::Objects->new;
+  my $ita = BSE::Util::Iterate::Article->new(req => $request);
+
+  my $custom = $self->_custom_fields;
+  # only return the fields that are defined
+  $request->set_variable(custom => $custom);
+  $request->set_variable(errors => $errors || {});
+  my $article_type = $cfg->entry('level names', $article->{level}, 'Article');
+  $request->set_variable(article_type => $article_type);
+
   return
     (
      $request->admin_tags,
-     article => [ $tag_hash, $article ],
+     article => sub { tag_article($article, $cfg, $_[0]) },
      old => [ \&tag_old, $article, $cgi ],
      default => [ \&tag_default, $self, $request, $article ],
-     articleType => [ \&tag_art_type, $article->{level}, $cfg ],
+     articleType => escape_html($article_type),
      parentType => [ \&tag_art_type, $article->{level}-1, $cfg ],
      ifNew => [ \&tag_if_new, $article ],
      list => [ \&tag_list, $self, $article, $articles, $cgi, $request ],
@@ -1173,9 +1319,14 @@ sub low_edit_tags {
      imgmove => [ \&tag_imgmove, $request, $article, \$image_index, \@images ],
      message => $msg,
      ifError => $if_error,
-     DevHelp::Tags->make_iterator2
-     ([ \&iter_get_kids, $article, $articles ], 
-      'child', 'children', \@children, \$child_index),
+     $ita->make
+     (
+      code => [ \&iter_get_kids, $article, $articles ], 
+      single => 'child',
+      plural => 'children',
+      data => \@children,
+      index => \$child_index,
+     ),
      ifchildren => \&tag_if_children,
      childtype => [ \&tag_art_type, $article->{level}+1, $cfg ],
      ifHaveChildType => [ \&tag_if_have_child_type, $article->{level}, $cfg ],
@@ -1185,8 +1336,14 @@ sub low_edit_tags {
      templates => [ \&tag_templates, $self, $article, $cfg, $cgi ],
      titleImages => [ \&tag_title_images, $self, $article, $cfg, $cgi ],
      editParent => [ \&tag_edit_parent, $article ],
-     DevHelp::Tags->make_iterator2
-     ([ \&iter_allkids, $article ], 'kid', 'kids', \@allkids, \$allkid_index),
+     $ita->make
+     (
+      code => [ \&iter_allkids, $article ],
+      single => 'kid',
+      plural => 'kids',
+      data => \@allkids,
+      index => \$allkid_index,
+     ),
      ifStepKid => 
      [ \&tag_if_step_kid, $article, \@allkids, \$allkid_index, \%stepkids ],
      stepkid => [ \&tag_step_kid, $article, \@allkids, \$allkid_index, 
@@ -1200,9 +1357,14 @@ sub low_edit_tags {
      ifPossibles => 
      [ \&tag_if_possible_stepkids, \%stepkids, $request, $article, 
        \@possstepkids, $articles, $cgi ],
-     DevHelp::Tags->make_iterator2
-     ( [ \&iter_get_stepparents, $article ], 'stepparent', 'stepparents', 
-       \@stepparents, \$stepparent_index),
+     $ita->make
+     (
+      code => [ \&iter_get_stepparents, $article ],
+      single => 'stepparent',
+      plural => 'stepparents',
+      data => \@stepparents,
+      index => \$stepparent_index,
+     ),
      ifStepParents => \&tag_ifStepParents,
      stepparent_targ => 
      [ \&tag_stepparent_targ, $article, \@stepparent_targs, 
@@ -1216,8 +1378,14 @@ sub low_edit_tags {
      stepparent_possibles =>
      [ \&tag_stepparent_possibles, $cgi, $request, $article, $articles, 
        \@stepparent_targs, \@stepparentpossibles, ],
-     DevHelp::Tags->make_iterator2
-     ([ iter_files => $self, $article ], 'file', 'files', \@files, \$file_index ),
+     $ito->make
+     (
+      code => [ iter_files => $self, $article ],
+      single => 'file',
+      plural => 'files',
+      data => \@files,
+      index => \$file_index,
+     ),
      movefiles => 
      [ \&tag_movefiles, $self, $request, $article, \@files, \$file_index ],
      $it->make
@@ -1227,6 +1395,12 @@ sub low_edit_tags {
       single => "file_meta",
       nocache => 1,
      ),
+     ifFileExists => sub {
+       @files && $file_index >= 0 && $file_index < @files
+        or return 0;
+
+       return -f ($files[$file_index]->full_filename($cfg));
+     },
      file_display => [ tag_file_display => $self, \@files, \$file_index ],
      DevHelp::Tags->make_iterator2
      (\&iter_admin_users, 'iadminuser', 'adminusers'),
@@ -1236,7 +1410,7 @@ sub low_edit_tags {
      error => [ $tag_hash, $errors ],
      error_img => [ \&tag_error_img, $cfg, $errors ],
      ifFieldPerm => [ \&tag_if_field_perm, $request, $article ],
-     parent => [ $tag_hash, $parent ],
+     parent => [ \&tag_article, $parent, $cfg ],
      DevHelp::Tags->make_iterator2
      ([ \&iter_flags, $self ], 'flag', 'flags' ),
      ifFlagSet => [ \&tag_if_flag_set, $article ],
@@ -1251,6 +1425,13 @@ sub low_edit_tags {
      $it->make_iterator([ iter_file_stores => $self], 
                        'file_store', 'file_stores'),
      ifGroupRequired => [ \&tag_ifGroupRequired, $article, \$current_group ],
+     category => [ tag_category => $self, $articles, $article ],
+     $ito->make
+     (
+      single => "tag",
+      plural => "tags",
+      code => [ iter_tags => $self, $article ],
+     ),
     );
 }
 
@@ -1291,6 +1472,9 @@ sub iter_groups {
 sub tag_ifGroupRequired {
   my ($article, $rgroup) = @_;
 
+  $article->{id}
+    or return 0;
+
   $$rgroup or return 0;
 
   $article->is_accessible_to($$rgroup);
@@ -1360,7 +1544,7 @@ sub _dummy_article {
   }
   
   my %article;
-  my @cols = Article->columns;
+  my @cols = BSE::TB::Article->columns;
   @article{@cols} = ('') x @cols;
   $article{id} = '';
   $article{parentid} = $parentid;
@@ -1375,7 +1559,14 @@ sub _dummy_article {
     return;
   }
 
-  return \%article;
+  return $self->_make_dummy_article(\%article);
+}
+
+sub _make_dummy_article {
+  my ($self, $article) = @_;
+
+  require BSE::DummyArticle;
+  return bless $article, "BSE::DummyArticle";
 }
 
 sub add_form {
@@ -1384,7 +1575,7 @@ sub add_form {
   return $self->low_edit_form($req, $article, $articles, $msg, $errors);
 }
 
-sub generator { 'Generate::Article' }
+sub generator { 'BSE::Generate::Article' }
 
 sub typename {
   my ($self) = @_;
@@ -1420,6 +1611,22 @@ sub _validate_common {
       $errors->{linkAlias} = "Link alias must contain only alphanumerics and contain at least one letter";
     }
   }
+
+  if (defined $data->{category}) {
+    unless (first { $_->{id} eq $data->{category} } $self->categories($articles)) {
+      $errors->{category} = "msg:bse/admin/edit/category/unknown";
+    }
+  }
+
+  require DevHelp::Validate;
+  DevHelp::Validate->import('dh_validate_hash');
+  dh_validate_hash($data, $errors,
+                  {
+                   fields => $self->_custom_fields,
+                   optional => 1,
+                   dbh => BSE::DB->single->dbh,
+                  },
+                  $self->cfg, ARTICLE_CUSTOM_FIELDS_CFG);
 }
 
 sub validate {
@@ -1471,6 +1678,35 @@ sub validate_parent {
 sub fill_new_data {
   my ($self, $req, $data, $articles) = @_;
 
+  my $custom = $self->_custom_fields;
+  for my $key (keys %$custom) {
+    my ($value) = $req->cgi->param($key);
+    if (defined $value) {
+      if ($key =~ /^customDate/) {
+       require DevHelp::Date;
+       my $msg;
+       if (my ($year, $month, $day) =
+           DevHelp::Date::dh_parse_date($value, \$msg)) {
+         $data->{$key} = sprintf("%04d-%02d-%02d", $year, $month, $day);
+       }
+       else {
+         $data->{$key} = undef;
+       }
+      }
+      elsif ($key =~ /^customInt/) {
+       if ($value =~ /\S/) {
+         $data->{$key} = $value;
+       }
+       else {
+         $data->{$key} = undef;
+       }
+      }
+      else {
+       $data->{$key} = $value;
+      }
+    }
+  }
+
   custom_class($self->{cfg})
     ->article_fill_new($data, $self->typename);
 
@@ -1502,21 +1738,56 @@ sub default_link_path {
 sub make_link {
   my ($self, $article) = @_;
 
+  $article->is_linked
+    or return "";
+
+  my $title = $article->title;
   if ($article->is_dynamic) {
-    return "/cgi-bin/page.pl?page=$article->{id}&title=".escape_uri($article->{title});
+    (my $extra = $title) =~ tr/A-Za-z0-9/-/sc;
+    return "/cgi-bin/page.pl?page=$article->{id}&title=".escape_uri($extra);
   }
 
   my $article_uri = $self->link_path($article);
   my $link = "$article_uri/$article->{id}.html";
   my $link_titles = $self->{cfg}->entryBool('basic', 'link_titles', 0);
   if ($link_titles) {
-    (my $extra = lc $article->{title}) =~ tr/a-z0-9/_/sc;
+    (my $extra = $title) =~ tr/A-Za-z0-9/-/sc;
     $link .= "/" . $extra . "_html";
   }
 
   $link;
 }
 
+sub save_columns {
+  my ($self, $table_object) = @_;
+
+  my @columns = $table_object->rowClass->columns;
+  shift @columns;
+
+  return @columns;
+}
+
+sub _validate_tags {
+  my ($self, $tags, $errors) = @_;
+
+  my $fail = 0;
+  my @errors;
+  for my $tag (@$tags) {
+    my $error;
+    if ($tag =~ /\S/
+       && !BSE::TB::Tags->valid_name($tag, \$error)) {
+      push @errors, "msg:bse/admin/edit/tags/invalid/$error";
+      $errors->{tags} = \@errors;
+      ++$fail;
+    }
+    else {
+      push @errors, undef;
+    }
+  }
+
+  return $fail;
+}
+
 sub save_new {
   my ($self, $req, $article, $articles) = @_;
 
@@ -1526,7 +1797,7 @@ sub save_new {
   my $cgi = $req->cgi;
   my %data;
   my $table_object = $self->table_object($articles);
-  my @columns = $table_object->rowClass->columns;
+  my @columns = $self->save_columns($table_object);
   $self->save_thumbnail($cgi, undef, \%data);
   for my $name (@columns) {
     $data{$name} = $cgi->param($name) 
@@ -1543,6 +1814,20 @@ sub save_new {
     $errors{parentid} = "Invalid parent selection (template bug)";
   }
   $self->validate(\%data, $articles, \%errors);
+
+  my $save_tags = $cgi->param("_save_tags");
+  my @tags;
+  if ($save_tags) {
+    @tags = $cgi->param("tags");
+    $self->_validate_tags(\@tags, \%errors);
+  }
+
+  my $meta;
+  if ($cgi->param("_save_meta")) {
+    require BSE::ArticleMetaMeta;
+    $meta = BSE::ArticleMetaMeta->retrieve($req, $article, \%errors);
+  }
+
   if (keys %errors) {
     if ($req->is_ajax) {
       return $req->json_content
@@ -1636,7 +1921,7 @@ sub save_new {
 # end adrian
 
   $self->fill_new_data($req, \%data, $articles);
-  for my $col (qw(titleImage imagePos template keyword menu titleAlias linkAlias body author summary)) {
+  for my $col (qw(titleImage imagePos template keyword menu titleAlias linkAlias body author summary category)) {
     defined $data{$col} 
       or $data{$col} = $self->default_value($req, \%data, $col);
   }
@@ -1663,8 +1948,18 @@ sub save_new {
       or $data{$col} = $self->default_value($req, \%data, $col);
   }
 
-  shift @columns;
-  $article = $table_object->add(@data{@columns});
+  my @cols = $table_object->rowClass->columns;
+  shift @cols;
+
+  # fill out anything else from defaults
+  for my $col (@columns) {
+    exists $data{$col}
+      or $data{$col} = $self->default_value($req, \%data, $col);
+  }
+
+  $article = $table_object->add(@data{@cols});
+
+  $self->save_new_more($req, $article, \%data);
 
   # we now have an id - generate the links
 
@@ -1674,7 +1969,22 @@ sub save_new {
   $article->setLink($self->make_link($article));
   $article->save();
 
-  use Util 'generate_article';
+  my ($after_id) = $cgi->param("_after");
+  if (defined $after_id) {
+    BSE::TB::Articles->reorder_child($article->{parentid}, $article->{id}, $after_id);
+    # reload, the displayOrder probably changed
+    $article = $articles->getByPkey($article->{id});
+  }
+
+  if ($save_tags) {
+    my $error;
+    $article->set_tags([ grep /\S/, @tags ], \$error);
+  }
+
+  if ($meta) {
+    BSE::ArticleMetaMeta->save($article, $meta);
+  }
+
   generate_article($articles, $article) if $Constants::AUTO_GENERATE;
 
   if ($req->is_ajax) {
@@ -1693,11 +2003,9 @@ sub save_new {
     $r .= "id=$article->{id}";
   }
   else {
-    
     $r = admin_base_url($req->cfg) . $article->{admin};
   }
   return BSE::Template->get_refresh($r, $self->{cfg});
-
 }
 
 sub fill_old_data {
@@ -1707,11 +2015,38 @@ sub fill_old_data {
     $data->{body} =~ s/\x0D\x0A/\n/g;
     $data->{body} =~ tr/\r/\n/;
   }
-  for my $col (Article->columns) {
+  for my $col (BSE::TB::Article->columns) {
     next if $col =~ /^custom/;
     $article->{$col} = $data->{$col}
       if exists $data->{$col} && $col ne 'id' && $col ne 'parentid';
   }
+  my $custom = $self->_custom_fields;
+  for my $key (keys %$custom) {
+    if (exists $data->{$key}) {
+      if ($key =~ /^customDate/) {
+       require DevHelp::Date;
+       my $msg;
+       if (my ($year, $month, $day) =
+           DevHelp::Date::dh_parse_date($data->{$key}, \$msg)) {
+         $article->set($key, sprintf("%04d-%02d-%02d", $year, $month, $day));
+       }
+       else {
+         $article->set($key => undef);
+       }
+      }
+      elsif ($key =~ /^customInt/) {
+       if ($data->{$key} =~ /\S/) {
+         $article->set($key => $data->{$key});
+       }
+       else {
+         $article->set($key => undef);
+       }
+      }
+      else {
+       $article->set($key => $data->{$key});
+      }
+    }
+  }
   custom_class($self->{cfg})
     ->article_fill_old($article, $data, $self->typename);
 
@@ -1725,16 +2060,30 @@ sub _article_data {
   $article_data->{link} = $article->link($req->cfg);
   $article_data->{images} =
     [
-     map $_->data_only, $article->images
+     map $self->_image_data($req->cfg, $_), $article->images
     ];
   $article_data->{files} =
     [
      map $_->data_only, $article->files,
     ];
+  $article_data->{tags} =
+    [
+     $article->tags, # just the names
+    ];
 
   return $article_data;
 }
 
+sub save_more {
+  my ($self, $req, $article, $data) = @_;
+  # nothing to do here
+}
+
+sub save_new_more {
+  my ($self, $req, $article, $data) = @_;
+  # nothing to do here
+}
+
 =item save
 
 Error codes:
@@ -1771,7 +2120,9 @@ sub save {
   my $old_dynamic = $article->is_dynamic;
   my $cgi = $req->cgi;
   my %data;
-  for my $name ($article->columns) {
+  my $table_object = $self->table_object($articles);
+  my @save_cols = $self->save_columns($table_object);
+  for my $name (@save_cols) {
     $data{$name} = $cgi->param($name) 
       if defined($cgi->param($name)) and $name ne 'id' && $name ne 'parentid'
        && $req->user_can("edit_field_edit_$name", $article);
@@ -1801,51 +2152,56 @@ sub save {
       $article->{template} =~ m|\.\.|) {
     $errors{template} = "Please only select templates from the list provided";
   }
+
+  my $meta;
+  if ($cgi->param("_save_meta")) {
+    require BSE::ArticleMetaMeta;
+    $meta = BSE::ArticleMetaMeta->retrieve($req, $article, \%errors);
+  }
+
+  my $save_tags = $cgi->param("_save_tags");
+  my @tags;
+  if ($save_tags) {
+    @tags = $cgi->param("tags");
+    $self->_validate_tags(\@tags, \%errors);
+  }
   $self->validate_old($article, \%data, $articles, \%errors, scalar $req->is_ajax)
     or return $self->_service_error($req, $article, $articles, undef, \%errors, "FIELD");
   $self->save_thumbnail($cgi, $article, \%data)
     if $req->user_can('edit_field_edit_thumbImage', $article);
+  if (exists $data{flags} && $data{flags} =~ /D/) {
+    $article->remove_html;
+  }
   $self->fill_old_data($req, $article, \%data);
   
   # reparenting
   my $newparentid = $cgi->param('parentid');
-  if ($newparentid && $req->user_can('edit_field_edit_parentid', $article)) {
-    if ($newparentid == $article->{parentid}) {
-      # nothing to do
+  if ($newparentid
+      && $req->user_can('edit_field_edit_parentid', $article)
+      && $newparentid != $article->{parentid}) {
+    my $newparent;
+    my $parent_editor;
+    if ($newparentid == -1) {
+      require BSE::Edit::Site;
+      $newparent = BSE::TB::Site->new;
+      $parent_editor = BSE::Edit::Site->new(cfg => $req->cfg);
     }
-    elsif ($newparentid != -1) {
-      print STDERR "Reparenting...\n";
-      my $newparent = $articles->getByPkey($newparentid);
-      if ($newparent) {
-       if ($newparent->{level} != $article->{level}-1) {
-         # the article cannot become a child of itself or one of it's 
-         # children
-         if ($article->{id} == $newparentid 
-             || $self->is_descendant($article->{id}, $newparentid, $articles)) {
-           my $msg = "Cannot become a child of itself or of a descendant";
-           return $self->_service_error($req, $article, $articles, $msg, {}, "PARENT");
-         }
-         my $shopid = $self->{cfg}->entryErr('articles', 'shop');
-         if ($self->is_descendant($article->{id}, $shopid, $articles)) {
-           my $msg = "Cannot become a descendant of the shop";
-           return $self->_service_error($req, $article, $articles, $msg, {}, "PARENT");
-         }
-         my $msg;
-         $self->reparent($article, $newparentid, $articles, \$msg)
-           or return $self->_service_error($req, $article, $articles, $msg, {}, "PARENT");
-       }
-       else {
-         # stays at the same level, nothing special
-         $article->{parentid} = $newparentid;
-       }
+    else {
+      $newparent = $articles->getByPkey($newparentid);
+      ($parent_editor, $newparent) = $self->article_class($newparent, $articles, $req->cfg);
+    }
+    if ($newparent) {
+      my $msg;
+      if ($self->can_reparent_to($article, $newparent, $parent_editor, $articles, \$msg)
+        && $self->reparent($article, $newparentid, $articles, \$msg)) {
+       # nothing to do here
+      }
+      else {
+       return $self->_service_error($req, $article, $articles, $msg, {}, "PARENT");
       }
-      # else ignore it
     }
     else {
-      # becoming a section
-      my $msg;
-      $self->reparent($article, -1, $articles, \$msg)
-       or return $self->_service_error($req, $article, $articles, $msg, {}, "PARENT");
+      return $self->_service_error($req, $article, $articles, "No such parent article", {}, "PARENT");
     }
   }
 
@@ -1868,7 +2224,6 @@ sub save {
   $article->{expire} = sql_date($cgi->param('expire')) || $Constants::D_99
     if defined $cgi->param('expire') && 
       $req->user_can('edit_field_edit_expire', $article);
-  $article->{lastModified} =  now_sqldatetime();
   for my $col (qw/force_dynamic inherit_siteuser_rights/) {
     if ($req->user_can("edit_field_edit_$col", $article)
        && $cgi->param("save_$col")) {
@@ -1876,10 +2231,7 @@ sub save {
     }
   }
 
-# Added by adrian
-  my $user = $req->getuser;
-  $article->{lastModifiedBy} = $user ? $user->{logon} : '';
-# end adrian
+  $article->mark_modified(actor => $req->getuser || "U");
 
   my @save_group_ids = $cgi->param('save_group_id');
   if ($req->user_can('edit_field_edit_group_id')
@@ -1904,14 +2256,24 @@ sub save {
   my $old_link = $article->{link};
   # this need to go last
   $article->update_dynamic($self->{cfg});
-  if ($article->{link} && 
-      !$self->{cfg}->entry('protect link', $article->{id})) {
+  if (!$self->{cfg}->entry('protect link', $article->{id})) {
     my $article_uri = $self->make_link($article);
     $article->setLink($article_uri);
   }
 
   $article->save();
 
+  if ($save_tags) {
+    my $error;
+    $article->set_tags([ grep /\S/, @tags ], \$error);
+  }
+
+use Data::Dumper;
+print STDERR Dumper($meta);
+  if ($meta) {
+    BSE::ArticleMetaMeta->save($article, $meta);
+  }
+
   # fix the kids too
   my @extra_regen;
   @extra_regen = $self->update_child_dynamic($article, $articles, $req);
@@ -1925,15 +2287,23 @@ sub save {
     }
   }
 
-  use Util 'generate_article';
+  my ($after_id) = $cgi->param("_after");
+  if (defined $after_id) {
+    BSE::TB::Articles->reorder_child($article->{parentid}, $article->{id}, $after_id);
+    # reload, the displayOrder probably changed
+    $article = $articles->getByPkey($article->{id});
+  }
+
   if ($Constants::AUTO_GENERATE) {
     generate_article($articles, $article);
     for my $regen_id (@extra_regen) {
       my $regen = $articles->getByPkey($regen_id);
-      Util::generate_low($articles, $regen, $self->{cfg});
+      BSE::Regen::generate_low($articles, $regen, $self->{cfg});
     }
   }
 
+  $self->save_more($req, $article, \%data);
+
   if ($req->is_ajax) {
     return $req->json_content
       (
@@ -1947,6 +2317,47 @@ sub save {
   return $self->refresh($article, $cgi, undef, 'Article saved');
 }
 
+sub can_reparent_to {
+  my ($self, $article, $newparent, $parent_editor, $articles, $rmsg) = @_;
+
+  my @child_types = $parent_editor->child_types;
+  if (!grep $_ eq ref $self, @child_types) {
+    my ($child_type) = (ref $self) =~ /(\w+)$/;
+    my ($parent_type) = (ref $parent_editor) =~ /(\w+)$/;
+    
+    $$rmsg = "A $child_type cannot be a child of a $parent_type";
+    return;
+  }
+  
+  # the article cannot become a child of itself or one of it's 
+  # children
+  if ($article->{id} == $newparent->id
+      || $self->is_descendant($article->id, $newparent->id, $articles)) {
+    $$rmsg = "Cannot become a child of itself or of a descendant";
+    return;
+  }
+
+  my $shopid = $self->{cfg}->entryErr('articles', 'shop');
+  if ($self->shop_article) { # if this article belongs in the shop
+    unless ($newparent->id == $shopid
+           || $self->is_descendant($shopid, $newparent->{id}, $articles)) {
+      $$rmsg = "This article belongs in the shop";
+      return;
+    }
+  }
+  else {
+    if ($newparent->id == $shopid
+       || $self->is_descendant($shopid, $newparent->id, $articles)) {
+      $$rmsg = "This article doesn't belong in the shop";
+      return;
+    }
+  }
+
+  return 1;
+}
+
+sub shop_article { 0 }
+
 sub update_child_dynamic {
   my ($self, $article, $articles, $req) = @_;
 
@@ -2082,11 +2493,12 @@ sub save_thumbnail {
     unlink("$imagedir/$original->{thumbImage}");
     @$newdata{qw/thumbImage thumbWidth thumbHeight/} = ('', 0, 0);
   }
-  my $image = $cgi->param('thumbnail');
-  if ($image && -s $image) {
+  my $image_name = $cgi->param('thumbnail');
+  my $image = $cgi->upload('thumbnail');
+  if ($image_name && -s $image) {
     # where to put it...
     my $name = '';
-    $image =~ /([\w.-]+)$/ and $name = $1;
+    $image_name =~ /([\w.-]+)$/ and $name = $1;
     my $filename = time . "_" . $name;
 
     use Fcntl;
@@ -2111,12 +2523,13 @@ sub save_thumbnail {
     close OUTPUT
       or die "Could not close image output file: $!";
 
-    use Image::Size;
+    require BSE::ImageSize;
 
     if ($original && $original->{thumbImage}) {
       #unlink("$imagedir/$original->{thumbImage}");
     }
-    @$newdata{qw/thumbWidth thumbHeight/} = imgsize("$imagedir/$filename");
+    @$newdata{qw/thumbWidth thumbHeight/} =
+      BSE::ImageSize::imgsize("$imagedir/$filename");
     $newdata->{thumbImage} = $filename;
   }
 }
@@ -2131,6 +2544,46 @@ sub child_types {
   return ( 'BSE::Edit::Article' );
 }
 
+=item add_stepkid
+
+Add a step child to an article.
+
+Parameters:
+
+=over
+
+=item *
+
+id - parent article id (required)
+
+=item *
+
+stepkid - child article id (required)
+
+=item *
+
+_after - id of the allkid of id to position the stepkid after
+(optional)
+
+=back
+
+Returns a FIELD error for an invalid stepkid.
+
+Returns an ACCESS error for insufficient access.
+
+Return an ADD error for a general add failure.
+
+On success returns:
+
+  {
+   success: 1,
+   relationship: { childId: I<childid>, parentId: I<parentid> }
+  }
+
+=back
+
+=cut
+
 sub add_stepkid {
   my ($self, $req, $article, $articles) = @_;
 
@@ -2138,74 +2591,140 @@ sub add_stepkid {
     or return $self->csrf_error($req, $article, "admin_add_stepkid", "Add Stepkid");
 
   $req->user_can(edit_stepkid_add => $article)
-    or return $self->edit_form($req, $article, $articles,
-                              "You don't have access to add step children to this article");
+    or return $self->_service_error($req, $article, $articles,
+                              "You don't have access to add step children to this article", {}, "ACCESS");
 
   my $cgi = $req->cgi;
-  require 'BSE/Admin/StepParents.pm';
-  eval {
-    my $childId = $cgi->param('stepkid');
-    defined $childId
-      or die "No stepkid supplied to add_stepkid";
+  require BSE::Admin::StepParents;
+
+  my %errors;
+  my $childId = $cgi->param('stepkid');
+  defined $childId
+    or $errors{stepkid} = "No stepkid supplied to add_stepkid";
+  unless ($errors{stepkid}) {
     $childId =~ /^\d+$/
-      or die "Invalid stepkid supplied to add_stepkid";
-    my $child = $articles->getByPkey($childId)
-      or die "Article $childId not found";
+      or $errors{stepkid} = "Invalid stepkid supplied to add_stepkid";
+  }
+  my $child;
+  unless ($errors{stepkid}) {
+    $child = $articles->getByPkey($childId)
+      or $errors{stepkid} = "Article $childId not found";
+  }
+  keys %errors
+    and return $self->_service_error
+      ($req, $article, $articles, $errors{stepkid}, \%errors, "FIELD");
 
-    $req->user_can(edit_stepparent_add => $child)
-      or die "You don't have access to add a stepparent to that article\n";
+  $req->user_can(edit_stepparent_add => $child)
+    or return $self->_service_error($req, $article, $articles, "You don't have access to add a stepparent to that article", {}, "ACCESS");
+
+  my $new_entry;
+  eval {
     
     my $release = $cgi->param('release');
     dh_parse_date($release) or $release = undef;
     my $expire = $cgi->param('expire');
     dh_parse_date($expire) or $expire = undef;
   
-    my $newentry = 
+    $new_entry = 
       BSE::Admin::StepParents->add($article, $child, $release, $expire);
   };
   if ($@) {
-    return $self->edit_form($req, $article, $articles, $@);
+    return $self->_service_error($req, $article, $articles, $@, {}, "ADD");
+  }
+
+  my $after_id = $cgi->param("_after");
+  if (defined $after_id) {
+    BSE::TB::Articles->reorder_child($article->id, $child->id, $after_id);
   }
 
-  use Util 'generate_article';
   generate_article($articles, $article) if $Constants::AUTO_GENERATE;
 
-  return $self->refresh($article, $cgi, 'step', 'Stepchild added');
+  if ($req->is_ajax) {
+    return $req->json_content
+      (
+       success => 1,
+       relationship => $new_entry->data_only,
+      );
+  }
+  else {
+    $self->refresh($article, $cgi, 'step', 'Stepchild added');
+  }
 }
 
-sub del_stepkid {
-  my ($self, $req, $article, $articles) = @_;
+=item del_stepkid
 
-  $req->check_csrf("admin_remove_stepkid")
+Remove a stepkid relationship.
+
+Parameters:
+
+=over
+
+=item *
+
+id - parent article id (required)
+
+=item *
+
+stepkid - child article id (required)
+
+=back
+
+Returns a FIELD error for an invalid stepkid.
+
+Returns an ACCESS error for insufficient access.
+
+Return a DELETE error for a general delete failure.
+
+=cut
+
+sub del_stepkid {
+  my ($self, $req, $article, $articles) = @_;
+
+  $req->check_csrf("admin_remove_stepkid")
     or return $self->csrf_error($req, $article, "admin_del_stepkid", "Delete Stepkid");
   $req->user_can(edit_stepkid_delete => $article)
-    or return $self->edit_form($req, $article, $articles,
-                              "You don't have access to delete stepchildren from this article");
+    or return $self->_service_error($req, $article, $articles,
+                              "You don't have access to delete stepchildren from this article", {}, "ACCESS");
 
   my $cgi = $req->cgi;
-  require 'BSE/Admin/StepParents.pm';
-  eval {
-    my $childId = $cgi->param('stepkid');
-    defined $childId
-      or die "No stepkid supplied to add_stepkid";
+
+  my %errors;
+  my $childId = $cgi->param('stepkid');
+  defined $childId
+    or $errors{stepkid} = "No stepkid supplied to add_stepkid";
+  unless ($errors{stepkid}) {
     $childId =~ /^\d+$/
-      or die "Invalid stepkid supplied to add_stepkid";
-    my $child = $articles->getByPkey($childId)
-      or die "Article $childId not found";
+      or $errors{stepkid} = "Invalid stepkid supplied to add_stepkid";
+  }
+  my $child;
+  unless ($errors{stepkid}) {
+    $child = $articles->getByPkey($childId)
+      or $errors{stepkid} = "Article $childId not found";
+  }
+  keys %errors
+    and return $self->_service_error
+      ($req, $article, $articles, $errors{stepkid}, \%errors, "FIELD");
 
-    $req->user_can(edit_stepparent_delete => $child)
-      or die "You cannot remove stepparents from that article\n";
+  $req->user_can(edit_stepparent_delete => $child)
+    or return _service_error($req, $article, $article, "You cannot remove stepparents from that article", {}, "ACCESS");
     
+
+  require BSE::Admin::StepParents;
+  eval {
     BSE::Admin::StepParents->del($article, $child);
   };
   
   if ($@) {
-    return $self->edit_form($req, $article, $articles, $@);
+    return $self->_service_error($req, $article, $articles, $@, {}, "DELETE");
   }
-  use Util 'generate_article';
   generate_article($articles, $article) if $Constants::AUTO_GENERATE;
 
-  return $self->refresh($article, $cgi, 'step', 'Stepchild deleted');
+  if ($req->is_ajax) {
+    return $req->json_content(success => 1);
+  }
+  else {
+    return $self->refresh($article, $cgi, 'step', 'Stepchild deleted');
+  }
 }
 
 sub save_stepkids {
@@ -2220,7 +2739,7 @@ sub save_stepkids {
 
   my $cgi = $req->cgi;
   require 'BSE/Admin/StepParents.pm';
-  my @stepcats = OtherParents->getBy(parentId=>$article->{id});
+  my @stepcats = BSE::TB::OtherParents->getBy(parentId=>$article->{id});
   my %stepcats = map { $_->{parentId}, $_ } @stepcats;
   my %datedefs = ( release => '2000-01-01', expire=>'2999-12-31' );
   for my $stepcat (@stepcats) {
@@ -2247,12 +2766,119 @@ sub save_stepkids {
     };
     $@ and return $self->refresh($article, $cgi, '', $@);
   }
-  use Util 'generate_article';
   generate_article($articles, $article) if $Constants::AUTO_GENERATE;
 
   return $self->refresh($article, $cgi, 'step', 'Stepchild information saved');
 }
 
+=item a_restepkid
+
+Moves a stepkid from one parent to another, and sets the order within
+that new stepparent.
+
+Parameters:
+
+=over
+
+=item *
+
+id - id of the step kid to move (required)
+
+=item *
+
+parentid - id of the parent in the stepkid relationship (required)
+
+=item *
+
+newparentid - the new parent for the stepkid relationship (optional)
+
+=item *
+
+_after - id of the allkid under newparentid (or parentid if
+newparentid isn't supplied) to place the stepkid after (0 to place at
+the start)
+
+=back
+
+Errors:
+
+=over
+
+=item *
+
+NOPARENTID - parentid parameter not supplied
+
+=item *
+
+BADPARENTID - non-numeric parentid supplied
+
+=item *
+
+NOTFOUND - no stepkid relationship from parentid was found
+
+=item *
+
+BADNEWPARENT - newparentid is non-numeric
+
+=item *
+
+UNKNOWNNEWPARENT - no article id newparentid found
+
+=item *
+
+NEWPARENTDUP - there's already a stepkid relationship between
+newparentid and id.
+
+=back
+
+=cut
+
+sub req_restepkid {
+  my ($self, $req, $article, $articles) = @_;
+
+  # first, identify the stepkid link
+  my $cgi = $req->cgi;
+  require BSE::TB::OtherParents;
+  my $parentid = $cgi->param("parentid");
+  defined $parentid
+    or return $self->_service_error($req, $article, $articles, "Missing parentid", {}, "NOPARENTID");
+  $parentid =~ /^\d+$/
+    or return $self->_service_error($req, $article, $articles, "Invalid parentid", {}, "BADPARENTID");
+
+  my ($step) = BSE::TB::OtherParents->getBy(parentId => $parentid, childId => $article->id)
+    or return $self->_service_error($req, $article, $articles, "Unknown relationship", {}, "NOTFOUND");
+
+  my $newparentid = $cgi->param("newparentid");
+  if ($newparentid) {
+    $newparentid =~ /^\d+$/
+      or return $self->_service_error($req, $article, $articles, "Bad new parent id", {}, "BADNEWPARENT");
+    my $new_parent = BSE::TB::Articles->getByPkey($newparentid)
+      or return $self->_service_error($req, $article, $articles, "Unknown new parent id", {}, "UNKNOWNNEWPARENT");
+    my $existing = 
+      BSE::TB::OtherParents->getBy(parentId=>$newparentid, childId=>$article->id)
+       and return $self->_service_error($req, $article, $articles, "New parent is duplicate", {}, "NEWPARENTDUP");
+
+    $step->{parentId} = $newparentid;
+    $step->save;
+  }
+
+  my $after_id = $cgi->param("_after");
+  if (defined $after_id) {
+    BSE::TB::Articles->reorder_child($step->{parentId}, $article->id, $after_id);
+  }
+
+  if ($req->is_ajax) {
+    return $req->json_content
+      (
+       success => 1,
+       relationshop => $step->data_only,
+      );
+  }
+  else {
+    return $self->refresh($article, $cgi, 'step', "Stepchild moved");
+  }
+}
+
 sub add_stepparent {
   my ($self, $req, $article, $articles) = @_;
 
@@ -2293,7 +2919,6 @@ sub add_stepparent {
   };
   $@ and return $self->refresh($article, $cgi, 'step', $@);
 
-  use Util 'generate_article';
   generate_article($articles, $article) if $Constants::AUTO_GENERATE;
 
   return $self->refresh($article, $cgi, 'stepparents', 'Stepparent added');
@@ -2330,7 +2955,6 @@ sub del_stepparent {
   };
   $@ and return $self->refresh($article, $cgi, 'stepparents', $@);
 
-  use Util 'generate_article';
   generate_article($articles, $article) if $Constants::AUTO_GENERATE;
 
   return $self->refresh($article, $cgi, 'stepparents', 'Stepparent deleted');
@@ -2348,7 +2972,7 @@ sub save_stepparents {
   my $cgi = $req->cgi;
 
   require 'BSE/Admin/StepParents.pm';
-  my @stepparents = OtherParents->getBy(childId=>$article->{id});
+  my @stepparents = BSE::TB::OtherParents->getBy(childId=>$article->{id});
   my %stepparents = map { $_->{parentId}, $_ } @stepparents;
   my %datedefs = ( release => '2000-01-01', expire=>'2999-12-31' );
   for my $stepparent (@stepparents) {
@@ -2376,7 +3000,6 @@ sub save_stepparents {
     $@ and return $self->refresh($article, $cgi, '', $@);
   }
 
-  use Util 'generate_article';
   generate_article($articles, $article) if $Constants::AUTO_GENERATE;
 
   return $self->refresh($article, $cgi, 'stepparents', 
@@ -2450,7 +3073,7 @@ sub save_image_changes {
   my $cgi = $req->cgi;
   my $image_pos = $cgi->param('imagePos');
   if ($image_pos 
-      && $image_pos =~ /^(?:tl|tr|bl|br)$/
+      && $image_pos =~ /^(?:tl|tr|bl|br|xx)$/
       && $image_pos ne $article->{imagePos}) {
     $article->{imagePos} = $image_pos;
     $article->save;
@@ -2481,12 +3104,7 @@ sub save_image_changes {
     my $name = $cgi->param("name$id");
     if (defined $name && $name ne $image->{name}) {
       if ($name eq '') {
-       if ($article->{id} > 0) {
-         $changes{$id}{name} = '';
-       }
-       else {
-         $errors{"name$id"} = "Identifiers are required for global images";
-       }
+       $changes{$id}{name} = '';
       }
       elsif ($name =~ /^[a-z_]\w*$/i) {
        my $msg;
@@ -2513,44 +3131,50 @@ sub save_image_changes {
     if (defined $filename && length $filename) {
       my $in_fh = $cgi->upload("image$id");
       if ($in_fh) {
-       # work out where to put it
-       require DevHelp::FileUpload;
-       my $msg;
-       my ($image_name, $out_fh) = DevHelp::FileUpload->make_img_filename
-         ($image_dir, $filename . '', \$msg);
-       if ($image_name) {
-         local $/ = \8192;
-         my $data;
-         while ($data = <$in_fh>) {
-           print $out_fh $data;
-         }
-         close $out_fh;
+       my $basename;
+       my $image_error;
+       my ($width, $height, $type) = $self->_validate_image
+         ($filename, $in_fh, \$basename, \$image_error);
 
-         my $full_filename = "$image_dir/$image_name";
-         require Image::Size;
-         my ($width, $height, $type) = Image::Size::imgsize($full_filename);
-         if ($width) {
-           $old_images{$id} = 
-             { 
-              image => $image->{image}, 
-              storage => $image->{storage}
-             };
-           push @new_images, $image_name;
-
-           $changes{$id}{image} = $image_name;
-           $changes{$id}{storage} = 'local';
-           $changes{$id}{src} = "/images/$image_name";
-           $changes{$id}{width} = $width;
-           $changes{$id}{height} = $height;
-           $changes{$id}{ftype} = $self->_image_ftype($type);
+       unless ($type) {
+         $errors{"image$id"} = $image_error;
+       }
+
+       unless ($errors{"image$id"}) {
+         # work out where to put it
+         require DevHelp::FileUpload;
+         my $msg;
+         my ($image_name, $out_fh) = DevHelp::FileUpload->make_img_filename
+           ($image_dir, $basename, \$msg);
+         if ($image_name) {
+           local $/ = \8192;
+           my $data;
+           while ($data = <$in_fh>) {
+             print $out_fh $data;
+           }
+           close $out_fh;
+           
+           my $full_filename = "$image_dir/$image_name";
+           if ($width) {
+             $old_images{$id} = 
+               { 
+                image => $image->{image}, 
+                storage => $image->{storage}
+               };
+             push @new_images, $image_name;
+             
+             $changes{$id}{image} = $image_name;
+             $changes{$id}{storage} = 'local';
+             $changes{$id}{src} = cfg_image_uri() . "/" . $image_name;
+             $changes{$id}{width} = $width;
+             $changes{$id}{height} = $height;
+             $changes{$id}{ftype} = $self->_image_ftype($type);
+           }
          }
          else {
-           $errors{"image$id"} = $type;
+           $errors{"image$id"} = $msg;
          }
        }
-       else {
-         $errors{"image$id"} = $msg;
-       }
       }
       else {
        # problem uploading
@@ -2618,7 +3242,6 @@ sub save_image_changes {
   }
   
   if ($changes_found) {
-    use Util 'generate_article';
     generate_article($articles, $article) if $Constants::AUTO_GENERATE;
   }
     
@@ -2651,14 +3274,14 @@ Otherwise display the normal edit page with the error.
 =cut
 
 sub _service_error {
-  my ($self, $req, $article, $articles, $msg, $error, $code) = @_;
+  my ($self, $req, $article, $articles, $msg, $error, $code, $method) = @_;
 
   unless ($article) {
     my $mymsg;
     $article = $self->_dummy_article($req, $articles, \$mymsg);
     $article ||=
       {
-       map $_ => '', Article->columns
+       map $_ => '', BSE::TB::Article->columns
       };
   }
 
@@ -2688,16 +3311,28 @@ sub _service_error {
       };
   }
   elsif ((() = $req->cgi->param('_')) ||
-        (defined $ENV{HTTP_X_REQUESTED_WITH}
+        (exists $ENV{HTTP_X_REQUESTED_WITH}
          && $ENV{HTTP_X_REQUESTED_WITH} =~ /XMLHttpRequest/)) {
     $error ||= {};
-    my $result = { errors => $error };
+    my $result = 
+      {
+       errors => $error,
+       success => 0,
+      };
     $msg and $result->{message} = $msg;
     $code and $result->{error_code} = $code;
-    return $req->json_content($result);
+    my $json_result = $req->json_content($result);
+
+    if (!exists $ENV{HTTP_X_REQUESTED_WITH}
+       || $ENV{HTTP_X_REQUESTED_WITH} !~ /XMLHttpRequest/) {
+      $json_result->{type} = "text/plain";
+    }
+
+    return $json_result;
   }
   else {
-    return $self->edit_form($req, $article, $articles, $msg, $error);
+    $method ||= "edit_form";
+    return $self->$method($req, $article, $articles, $msg, $error);
   }
 }
 
@@ -2715,16 +3350,66 @@ sub _service_success {
     };
 }
 
+# FIXME: eliminate this method and call get_ftype directly
 sub _image_ftype {
   my ($self, $type) = @_;
 
-  if ($type eq 'CWS' || $type eq 'SWF') {
-    return "flash";
+  require BSE::TB::Images;
+  return BSE::TB::Images->get_ftype($type);
+}
+
+my %valid_exts =
+  (
+   tiff => "tiff,tif",
+   jpg => "jpeg,jpg",
+   pnm => "pbm,pgm,ppm",
+  );
+
+sub _validate_image {
+  my ($self, $filename, $fh, $rbasename, $error) = @_;
+
+  if ($fh) {
+    if (-z $fh) {
+      $$error = 'Image file is empty';
+      return;
+    }
+  }
+  else {
+    $$error = 'Please enter an image filename';
+    return;
+  }
+  my $imagename = $filename;
+  $imagename .= ''; # force it into a string
+  my $basename = '';
+  $imagename =~ tr/ //d;
+  $imagename =~ /([\w.-]+)$/ and $basename = $1;
+
+  # for OSs with special text line endings
+  require BSE::ImageSize;
+
+  my ($width,$height, $type) = BSE::ImageSize::imgsize($fh);
+
+  unless (defined $width) {
+    $$error = "Unknown image file type";
+    return;
+  }
+
+  my $lctype = lc $type;
+  my @valid_exts = split /,/, 
+    BSE::Cfg->single->entry("valid image extensions", $lctype,
+               $valid_exts{$lctype} || $lctype);
+
+  my ($ext) = $basename =~ /\.(\w+)\z/;
+  if (!$ext || !grep $_ eq lc $ext, @valid_exts) {
+    $basename .= ".$valid_exts[0]";
   }
+  $$rbasename = $basename;
 
-  return "img";
+  return ($width, $height, $type);
 }
 
+my $last_display_order = 0;
+
 sub do_add_image {
   my ($self, $cfg, $article, $image, %opts) = @_;
 
@@ -2756,56 +3441,50 @@ sub do_add_image {
       or $errors->{name} = $workmsg;
   }
 
-  if ($image) {
-    if (-z $image) {
-      $errors->{image} = 'Image file is empty';
-    }
-  }
-  else {
-    $errors->{image} = 'Please enter an image filename';
+  my $image_error;
+  my $basename;
+  my ($width, $height, $type) = 
+    $self->_validate_image($opts{filename} || $image, $image, \$basename,
+                          \$image_error);
+  unless ($width) {
+    $errors->{image} = $image_error;
   }
+
   keys %$errors
     and return;
 
-  my $imagename = $opts{filename} || $image;
-  $imagename .= ''; # force it into a string
-  my $basename = '';
-  $imagename =~ tr/ //d;
-  $imagename =~ /([\w.-]+)$/ and $basename = $1;
-
-  # create a filename that we hope is unique
-  my $filename = time. '_'. $basename;
-
   # for the sysopen() constants
   use Fcntl;
 
   my $imagedir = cfg_image_dir($cfg);
-  # loop until we have a unique filename
-  my $counter="";
-  $filename = time. '_' . $counter . '_' . $basename 
-    until sysopen( OUTPUT, "$imagedir/$filename", O_WRONLY| O_CREAT| O_EXCL)
-      || ++$counter > 100;
-
-  fileno(OUTPUT) or die "Could not open image file: $!";
 
-  # for OSs with special text line endings
-  binmode OUTPUT;
+  require DevHelp::FileUpload;
+  my $msg;
+  my ($filename, $fh) =
+    DevHelp::FileUpload->make_img_filename($imagedir, $basename, \$msg);
+  unless ($filename) {
+    $errors->{image} = $msg;
+    return;
+  }
 
   my $buffer;
 
+  binmode $fh;
+
   no strict 'refs';
 
   # read the image in from the browser and output it to our output filehandle
-  print OUTPUT $buffer while read $image, $buffer, 1024;
+  print $fh $buffer while read $image, $buffer, 1024;
 
   # close and flush
-  close OUTPUT
+  close $fh
     or die "Could not close image file $filename: $!";
 
-  use Image::Size;
-
-
-  my($width,$height, $type) = imgsize("$imagedir/$filename");
+  my $display_order = time;
+  if ($display_order <= $last_display_order) {
+    $display_order = $last_display_order + 1;
+  }
+  $last_display_order = $display_order;
 
   my $alt = $opts{alt};
   defined $alt or $alt = '';
@@ -2819,10 +3498,10 @@ sub do_add_image {
      width=>$width,
      height => $height,
      url => $url,
-     displayOrder=>time,
+     displayOrder => $display_order,
      name => $imageref,
      storage => 'local',
-     src => '/images/' . $filename,
+     src => cfg_image_uri() . '/' . $filename,
      ftype => $self->_image_ftype($type),
     );
   require BSE::TB::Images;
@@ -2852,6 +3531,15 @@ sub do_add_image {
   return $imageobj;
 }
 
+sub _image_data {
+  my ($self, $cfg, $image) = @_;
+
+  my $data = $image->data_only;
+  $data->{src} = $image->image_url($cfg);
+
+  return $data;
+}
+
 sub add_image {
   my ($self, $req, $article, $articles) = @_;
 
@@ -2862,28 +3550,42 @@ sub add_image {
                                    "You don't have access to add new images to this article");
 
   my $cgi = $req->cgi;
+
   my %errors;
+
+  my $save_tags = $cgi->param("_save_tags");
+  my @tags;
+  if ($save_tags) {
+    @tags = $cgi->param("tags");
+    $self->_validate_tags(\@tags, \%errors);
+  }
+
   my $imageobj =
     $self->do_add_image
       (
        $req->cfg,
        $article,
-       scalar($cgi->param('image')),
+       scalar($cgi->upload('image')),
        name => scalar($cgi->param('name')),
        alt => scalar($cgi->param('altIn')),
        url => scalar($cgi->param('url')),
        storage => scalar($cgi->param('storage')),
        errors => \%errors,
+       filename => scalar($cgi->param("image")),
       );
 
   $imageobj
     or return $self->_service_error($req, $article, $articles, undef, \%errors);
 
+  if ($save_tags) {
+    my $error;
+    $imageobj->set_tags([ grep /\S/, @tags ], \$error);
+  }
+
   # typically a soft failure from the storage
   $errors{flash}
     and $req->flash($errors{flash});
 
-  use Util 'generate_article';
   generate_article($articles, $article) if $Constants::AUTO_GENERATE;
 
   if ($cgi->param('_service')) {
@@ -2894,6 +3596,18 @@ sub add_image {
        },
       );
   }
+  elsif ($cgi->param("_") || $req->is_ajax) {
+    my $resp = $req->json_content
+      (
+       success => 1,
+       image => $self->_image_data($req->cfg, $imageobj),
+      );
+
+    # the browser handles this directly, tell it that it's text
+    $resp->{type} = "text/plain";
+
+    return $resp;
+  }
   else {
     return $self->refresh($article, $cgi, undef, 'New image added');
   }
@@ -2902,9 +3616,8 @@ sub add_image {
 sub _image_manager {
   my ($self) = @_;
 
-  require BSE::StorageMgr::Images;
-
-  return BSE::StorageMgr::Images->new(cfg => $self->cfg);
+  require BSE::TB::Images;
+  return BSE::TB::Images->storage_manager;
 }
 
 # remove an image
@@ -2915,14 +3628,21 @@ sub remove_img {
     or return $self->csrf_error($req, $article, "admin_remove_image", "Remove Image");
 
   $req->user_can(edit_images_delete => $article)
-    or return $self->edit_form($req, $article, $articles,
-                                "You don't have access to delete images from this article");
+    or return $self->_service_error($req, $article, $articles,
+                                "You don't have access to delete images from this article", {}, "ACCESS");
 
   $imageid or die;
 
   my @images = $self->get_images($article);
-  my ($image) = grep $_->{id} == $imageid, @images
-    or return $self->show_images($req, $article, $articles, "No such image");
+  my ($image) = grep $_->{id} == $imageid, @images;
+  unless ($image) {
+    if ($req->want_json_response) {
+      return $self->_service_error($req, $article, $articles, "No such image", {}, "NOTFOUND");
+    }
+    else {
+      return $self->show_images($req, $article, $articles, "No such image");
+    }
+  }
 
   if ($image->{storage} ne 'local') {
     my $mgr = $self->_image_manager($req->cfg);
@@ -2930,12 +3650,17 @@ sub remove_img {
   }
 
   my $imagedir = cfg_image_dir($req->cfg);
-  unlink "$imagedir$image->{image}";
   $image->remove;
 
-  use Util 'generate_article';
   generate_article($articles, $article) if $Constants::AUTO_GENERATE;
 
+  if ($req->want_json_response) {
+    return $req->json_content
+      (
+       success => 1,
+      );
+  }
+
   return $self->refresh($article, $req->cgi, undef, 'Image removed');
 }
 
@@ -2960,7 +3685,6 @@ sub move_img_up {
   $to->save;
   $from->save;
 
-  use Util 'generate_article';
   generate_article($articles, $article) if $Constants::AUTO_GENERATE;
 
   return $self->refresh($article, $req->cgi, undef, 'Image moved');
@@ -2987,7 +3711,6 @@ sub move_img_down {
   $to->save;
   $from->save;
 
-  use Util 'generate_article';
   generate_article($articles, $article) if $Constants::AUTO_GENERATE;
 
   return $self->refresh($article, $req->cgi, undef, 'Image moved');
@@ -3009,7 +3732,7 @@ sub req_thumb {
     my $geometry_id = $cgi->param('g');
     defined $geometry_id or $geometry_id = 'editor';
     my $geometry = $cfg->entry('thumb geometries', $geometry_id, 'scale(200x200)');
-    my $imagedir = $cfg->entry('paths', 'images', $Constants::IMAGEDIR);
+    my $imagedir = cfg_image_dir();
     
     my $error;
     ($data, $type) = $thumb_obj->thumb_data
@@ -3039,8 +3762,8 @@ sub req_thumb {
   }
   else {
     # grab the nothumb image
-    my $uri = $cfg->entry('editor', 'default_thumbnail', '/images/admin/nothumb.png');
-    my $filebase = $Constants::CONTENTBASE;
+    my $uri = $cfg->entry('editor', 'default_thumbnail', cfg_dist_image_uri() . '/admin/nothumb.png');
+    my $filebase = $cfg->content_base_path;
     if (open IMG, "<$filebase/$uri") {
       binmode IMG;
       my $data = do { local $/; <IMG> };
@@ -3063,6 +3786,30 @@ sub req_thumb {
   }
 }
 
+=item edit_image
+
+Display a form to allow editing an image.
+
+Tags:
+
+=over
+
+=item *
+
+eimage - the image being edited
+
+=item *
+
+normal article edit tags.
+
+=back
+
+Variables:
+
+eimage - the image being edited.
+
+=cut
+
 sub req_edit_image {
   my ($self, $req, $article, $articles, $errors) = @_;
 
@@ -3077,6 +3824,8 @@ sub req_edit_image {
     or return $self->edit_form($req, $article, $articles,
                               "You don't have access to save image information for this article");
 
+  $req->set_variable(eimage => $image);
+
   my %acts;
   %acts =
     (
@@ -3089,6 +3838,34 @@ sub req_edit_image {
   return $req->response('admin/image_edit', \%acts);
 }
 
+=item a_save_image
+
+Save changes to an image.
+
+Parameters:
+
+=over
+
+=item *
+
+id - article id
+
+=item *
+
+image_id - image id
+
+=item *
+
+alt, url, name - text fields to update
+
+=item *
+
+image - replacement image data (if any)
+
+=back
+
+=cut
+
 sub req_save_image {
   my ($self, $req, $article, $articles) = @_;
   
@@ -3100,11 +3877,11 @@ sub req_save_image {
 
   my @images = $self->get_images($article);
   my ($image) = grep $_->{id} == $id, @images
-    or return $self->edit_form($req, $article, $articles,
-                              "No such image");
+    or return $self->_service_error($req, $article, $articles, "No such image",
+                                   {}, "NOTFOUND");
   $req->user_can(edit_images_save => $article)
-    or return $self->edit_form($req, $article, $articles,
-                              "You don't have access to save image information for this article");
+    or return $self->_service_error($req, $article, $articles,
+                                   "You don't have access to save image information for this article", {}, "ACCESS");
 
   my $image_dir = cfg_image_dir($req->cfg);
 
@@ -3131,62 +3908,76 @@ sub req_save_image {
       }
     }
     else {
-      if ($article->{id} == -1) {
-       $errors{name} = "Identifiers are required for global images";
-      }
-      else {
-       $image->{name} = '';
-      }
+      $image->{name} = '';
     }
   }
   my $filename = $cgi->param('image');
   if (defined $filename && length $filename) {
     my $in_fh = $cgi->upload('image');
     if ($in_fh) {
-      require DevHelp::FileUpload;
-      my $msg;
-      my ($image_name, $out_fh) = DevHelp::FileUpload->make_img_filename
-       ($image_dir, $filename . '', \$msg);
-      if ($image_name) {
-       {
-         local $/ = \8192;
-         my $data;
-         while ($data = <$in_fh>) {
-           print $out_fh $data;
+      my $basename;
+      my $image_error;
+      my ($width, $height, $type) = $self->_validate_image
+       ($filename, $in_fh, \$basename, \$image_error);
+      if ($type) {
+       require DevHelp::FileUpload;
+       my $msg;
+       my ($image_name, $out_fh) = DevHelp::FileUpload->make_img_filename
+         ($image_dir, $basename, \$msg);
+       if ($image_name) {
+         {
+           local $/ = \8192;
+           my $data;
+           while ($data = <$in_fh>) {
+             print $out_fh $data;
+           }
+           close $out_fh;
          }
-         close $out_fh;
-       }
 
-       my $full_filename = "$image_dir/$image_name";
-       require Image::Size;
-       my ($width, $height, $type) = Image::Size::imgsize($full_filename);
-       if ($width) {
+         my $full_filename = "$image_dir/$image_name";
          $delete_file = $image->{image};
          $image->{image} = $image_name;
          $image->{width} = $width;
          $image->{height} = $height;
          $image->{storage} = 'local'; # not on the remote store yet
-         $image->{src} = '/images/' . $image_name;
+         $image->{src} = cfg_image_uri() . '/' . $image_name;
          $image->{ftype} = $self->_image_ftype($type);
        }
        else {
-         $errors{image} = $type;
+         $errors{image} = $msg;
        }
       }
       else {
-       $errors{image} = $msg;
+       $errors{image} = $image_error;
       }
     }
     else {
       $errors{image} = "No image file received";
     }
   }
-  keys %errors
-    and return $self->req_edit_image($req, $article, $articles, \%errors);
+  my $save_tags = $cgi->param("_save_tags");
+  my @tags;
+  if ($save_tags) {
+    @tags = $cgi->param("tags");
+    $self->_validate_tags(\@tags, \%errors);
+  }
+  if (keys %errors) {
+    if ($req->want_json_response) {
+      return $self->_service_error($req, $article, $articles, undef,
+                                  \%errors, "FIELD");
+    }
+    else {
+      return $self->req_edit_image($req, $article, $articles, \%errors);
+    }
+  }
 
   my $new_storage = $cgi->param('storage');
   defined $new_storage or $new_storage = $image->{storage};
   $image->save;
+  if ($save_tags) {
+    my $error;
+    $image->set_tags([ grep /\S/, @tags ], \$error);
+  }
   my $mgr = $self->_image_manager($req->cfg);
   if ($delete_file) {
     if ($old_storage ne 'local') {
@@ -3216,9 +4007,64 @@ sub req_save_image {
     $@ and $req->flash("There was a problem removing if from the old storage: $@");
   }
 
+  if ($req->want_json_response) {
+    return $req->json_content
+      (
+       success => 1,
+       image => $self->_image_data($req->cfg, $image),
+      );
+  }
+
   return $self->refresh($article, $cgi);
 }
 
+=item a_order_images
+
+Change the order of images for an article (or global images).
+
+Ajax only.
+
+=over
+
+=item *
+
+id - id of the article to change the image order for (-1 for global
+images)
+
+=item *
+
+order - comma-separated list of image ids in the new order.
+
+=back
+
+=cut
+
+sub req_order_images {
+  my ($self, $req, $article, $articles) = @_;
+
+  $req->is_ajax
+    or return $self->_service_error($req, $article, $articles, "The function only permitted from Ajax", {}, "AJAXONLY");
+
+  my $order = $req->cgi->param("order");
+  defined $order
+    or return $self->_service_error($req, $article, $articles, "order not supplied", {}, "NOORDER");
+  $order =~ /^\d+(,\d+)*$/
+    or return $self->_service_error($req, $article, $articles, "order not supplied", {}, "BADORDER");
+
+  my @order = split /,/, $order;
+
+  my @images = $article->set_image_order(\@order);
+
+  return $req->json_content
+    (
+     success => 1,
+     images =>
+     [
+      map $self->_image_data($req->cfg, $_), @images
+     ],
+    );
+}
+
 sub get_article {
   my ($self, $articles, $article) = @_;
 
@@ -3278,7 +4124,7 @@ sub fileadd {
   $req->check_csrf("admin_add_file")
     or return $self->csrf_error($req, $article, "admin_add_file", "Add File");
   $req->user_can(edit_files_add => $article)
-    or return $self->edit_form($req, $article, $articles,
+    or return $self->_service_error($req, $article, $articles,
                              "You don't have access to add files to this article");
 
   my %file;
@@ -3299,7 +4145,8 @@ sub fileadd {
                 section => $article->{id} == -1 ? 'Global File Validation' : 'Article File Validation');
   
   # build a filename
-  my $file = $cgi->param('file');
+  my $file = $cgi->upload('file');
+  my $filename = $cgi->param("file");
   unless ($file) {
     $errors{file} = 'Please enter a filename';
   }
@@ -3315,9 +4162,6 @@ sub fileadd {
   $file{category}       ||= '';
 
   defined $file{name} or $file{name} = '';
-  if ($article->{id} == -1 && $file{name} eq '') {
-    $errors{name} = 'Identifier is required for global files';
-  }
   if (!$errors{name} && length $file{name} && $file{name} !~/^\w+$/) {
     $errors{name} = "Identifier must be a single word";
   }
@@ -3329,10 +4173,10 @@ sub fileadd {
   }
 
   keys %errors
-    and return $self->edit_form($req, $article, $articles, undef, \%errors);
+    and return $self->_service_error($req, $article, $articles, undef, \%errors);
   
   my $basename = '';
-  my $workfile = $file;
+  my $workfile = $filename;
   $workfile =~ s![^\w.:/\\-]+!_!g;
   $workfile =~ tr/_/_/s;
   $workfile =~ /([ \w.-]+)$/ and $basename = $1;
@@ -3347,110 +4191,50 @@ sub fileadd {
     };
 
   $fileobj
-    or return $self->edit_form($req, $article, $articles, $@);
+    or return $self->_service_error($req, $article, $articles, $@);
 
-  $req->flash("New file added");
+  unless ($req->is_ajax) {
+    $req->flash("New file added");
+  }
 
+  my $json =
+    {
+     success => 1,
+     file => $fileobj->data_only,
+     warnings => [],
+    };
   my $storage = $cgi->param("storage") || "";
   eval {
     my $msg;
 
     $article->apply_storage($self->cfg, $fileobj, $storage, \$msg);
 
-    $msg and $req->flash($msg);
+    if ($msg) {
+      if ($req->is_ajax) {
+       push @{$json->{warnings}}, $msg;
+      }
+      else {
+       $req->flash_error($msg);
+      }
+    }
   };
-  $@
-    and $req->flash($@);
-
-#   my $downloadPath = $self->{cfg}->entryVar('paths', 'downloads');
-
-
-#   unless ($file{contentType}) {
-#     unless ($file =~ /\.([^.]+)$/) {
-#       $file{contentType} = "application/octet-stream";
-#     }
-#     unless ($file{contentType}) {
-#       $file{contentType} = content_type($self->cfg, $file);
-#     }
-#   }
-
-
-#   # if the user supplies a really long filename, it can overflow the 
-#   # filename field
-
-#   my $work_filename = $basename;
-#   if (length $work_filename > 60) {
-#     $work_filename = substr($work_filename, -60);
-#   }
-
-#   my $filename = time. '_'. $work_filename;
-
-#   # for the sysopen() constants
-#   use Fcntl;
-
-#   # loop until we have a unique filename
-#   my $counter="";
-#   $filename = time. '_' . $counter . '_' . $work_filename 
-#     until sysopen( OUTPUT, "$downloadPath/$filename", 
-#                 O_WRONLY| O_CREAT| O_EXCL)
-#       || ++$counter > 100;
-
-#   fileno(OUTPUT) or die "Could not open file: $!";
-
-#   # for OSs with special text line endings
-#   binmode OUTPUT;
-
-#   my $buffer;
-
-#   no strict 'refs';
-
-#   # read the image in from the browser and output it to our output filehandle
-#   print OUTPUT $buffer while read $file, $buffer, 8192;
-
-#   # close and flush
-#   close OUTPUT
-#     or die "Could not close file $filename: $!";
-
-#   use BSE::Util::SQL qw/now_datetime/;
-#   $file{filename} = $filename;
-#   $file{displayName} = $basename;
-#   $file{sizeInBytes} = -s $file;
-#   $file{displayOrder} = time;
-#   $file{whenUploaded} = now_datetime();
-#   $file{storage}        = 'local';
-#   $file{src}            = '';
-#   $file{file_handler}   = "";
-
-#   require BSE::TB::ArticleFiles;
-#   my $fileobj = BSE::TB::ArticleFiles->add(@file{@cols});
-
-#   my $storage = $cgi->param('storage');
-#   defined $storage or $storage = 'local';
-#   my $file_manager = $self->_file_manager($req->cfg);
-
-#   local $SIG{__DIE__};
-#   eval {
-#     my $src;
-#     $storage = $self->_select_filestore($req, $file_manager, $storage, $fileobj);
-#     $src = $file_manager->store($filename, $storage, $fileobj);
-
-#     if ($src) {
-#       $fileobj->{src} = $src;
-#       $fileobj->{storage} = $storage;
-#       $fileobj->save;
-#     }
-#   };
-#   if ($@) {
-#     $req->flash($@);
-#   }
-
-#   $fileobj->set_handler($req->cfg);
-#   $fileobj->save;
+  if ($@) {
+    if ($req->is_ajax) {
+      push @{$json->{warnings}}, $@;
+    }
+    else {
+      $req->flash_error($@);
+    }
+  }
 
-  use Util 'generate_article';
   generate_article($articles, $article) if $Constants::AUTO_GENERATE;
 
-  $self->_refresh_filelist($req, $article);
+  if ($req->is_ajax) {
+    return $req->json_content($json);
+  }
+  else {
+    $self->_refresh_filelist($req, $article);
+  }
 }
 
 sub fileswap {
@@ -3481,7 +4265,6 @@ sub fileswap {
     }
   }
 
-  use Util 'generate_article';
   generate_article($articles, $article) if $Constants::AUTO_GENERATE;
 
   $self->refresh($article, $req->cgi, undef, 'File moved');
@@ -3513,7 +4296,6 @@ sub filedel {
     }
   }
 
-  use Util 'generate_article';
   generate_article($articles, $article) if $Constants::AUTO_GENERATE;
 
   $self->_refresh_filelist($req, $article, 'File deleted');
@@ -3538,9 +4320,11 @@ sub filesave {
   my @old_files;
   my @new_files;
   my %store_anyway;
+  my $change_count = 0;
   my @content_changed;
   for my $file (@files) {
     my $id = $file->{id};
+    my $orig = $file->data_only;
     my $desc = $cgi->param("description_$id");
     defined $desc and $file->{description} = $desc;
     my $type = $cgi->param("contentType_$id");
@@ -3563,9 +4347,6 @@ sub filesave {
          $errors{"name_$id"} = "Invalid file identifier $name";
        }
       }
-      elsif ($article->{id} == -1) {
-       $errors{"name_$id"} = "Identifier is required for global files";
-      }
     }
     else {
       push @{$names{$file->{name}}}, $id
@@ -3611,7 +4392,7 @@ sub filesave {
              $file->{filename} = $file_name;
              $file->{storage} = 'local';
              $file->{sizeInBytes} = -s $full_name;
-             $file->{whenUploaded} = now_datetime();
+             $file->{whenUploaded} = now_sqldatetime();
              $file->{displayName} = $display_name;
              push @content_changed, $file;
            }
@@ -3631,6 +4412,15 @@ sub filesave {
        $errors{"file_$id"} = "Filename too long";
       }
     }
+
+    my $new = $file->data_only;
+  COLUMN:
+    for my $col ($file->columns) {
+      if ($new->{$col} ne $orig->{$col}) {
+       ++$change_count;
+       last COLUMN;
+      }
+    }
   }
   for my $name (keys %names) {
     if (@{$names{$name}} > 1) {
@@ -3645,7 +4435,12 @@ sub filesave {
 
     return $self->edit_form($req, $article, $articles, undef, \%errors);
   }
-  $req->flash('File information saved');
+  if ($change_count) {
+    $req->flash("msg:bse/admin/edit/file/save/success_count", [ $change_count ]);
+  }
+  else {
+    $req->flash("msg:bse/admin/edit/file/save/success_none");
+  }
   my $mgr = $self->_file_manager($self->cfg);
   for my $file (@files) {
     $file->save;
@@ -3690,7 +4485,6 @@ sub filesave {
     $file->save;
   }
 
-  use Util 'generate_article';
   generate_article($articles, $article) if $Constants::AUTO_GENERATE;
 
   $self->_refresh_filelist($req, $article);
@@ -3871,6 +4665,9 @@ sub req_edit_file {
 
   my @metafields = $file->metafields($self->cfg);
 
+  $req->set_variable(file => $file);
+  $req->set_variable(fields => { BSE::TB::ArticleFile->fields });
+
   my $it = BSE::Util::Iterate->new;
   my $current_meta;
   my %acts;
@@ -3878,7 +4675,7 @@ sub req_edit_file {
     (
      $self->low_edit_tags(\%acts, $req, $article, $articles, undef,
                          $errors),
-     efile => [ \&tag_hash, $file ],
+     efile => [ \&tag_object, $file ],
      error_img => [ \&tag_error_img, $req->cfg, $errors ],
      ifOldChecked =>
      [ \&tag_old_checked, $errors, $cgi, $file ],
@@ -3943,6 +4740,7 @@ sub req_save_file {
   my $notes = $cgi->param("notes");
   defined $notes and $file->{notes} = $notes;
   my $name = $cgi->param("name");
+  require BSE::ImageSize;
   if (defined $name) {
     $file->{name} = $name;
     if (length $name) {
@@ -3955,76 +4753,10 @@ sub req_save_file {
        $errors{name} = "Invalid file identifier $name";
       }
     }
-    if (!$errors{name} && $article->{id} == -1) {
-      length $name
-       or $errors{name} = "Identifier is required for global files";
-    }
   }
 
-  my @meta;
-  my @meta_delete;
-  my @metafields = grep !$_->ro, $file->metafields($self->cfg);
-  my %current_meta = map { $_ => 1 } $file->metanames;
-  for my $meta (@metafields) {
-    my $name = $meta->name;
-    my $cgi_name = "meta_$name";
-    if ($cgi->param("delete_$cgi_name")) {
-      for my $metaname ($meta->metanames) {
-       push @meta_delete, $metaname
-         if $current_meta{$metaname};
-      }
-    }
-    else {
-      my $new;
-      if ($meta->is_text) {
-       my ($value) = $cgi->param($cgi_name);
-       if (defined $value && 
-           ($value =~ /\S/ || $current_meta{$meta->name})) {
-         my $error;
-         if ($meta->validate(value => $value, error => \$error)) {
-           push @meta,
-             {
-              name => $name,
-              value => $value,
-             };
-         }
-         else {
-           $errors{$cgi_name} = $error;
-         }
-       }
-      }
-      else {
-       my $im = $cgi->param($cgi_name);
-       my $up = $cgi->upload($cgi_name);
-       if (defined $im && $up) {
-         my $data = do { local $/; <$up> };
-         my ($width, $height, $type) = imgsize(\$data);
-
-         if ($width && $height) {
-           push @meta,
-             (
-              {
-               name => $meta->data_name,
-               value => $data,
-               content_type => "image/\L$type",
-              },
-              {
-               name => $meta->width_name,
-               value => $width,
-              },
-              {
-               name => $meta->height_name,
-               value => $height,
-              },
-             );
-         }
-         else {
-           $errors{$cgi_name} = $type;
-         }
-       }
-      }
-    }
-  }
+  require BSE::FileMetaMeta;
+  my $meta = BSE::FileMetaMeta->retrieve($req, $file, \%errors);
 
   if ($cgi->param('save_file_flags')) {
     my $download = 0 + defined $cgi->param("download");
@@ -4066,7 +4798,7 @@ sub req_save_file {
          
          $file->{filename} = $file_name;
          $file->{sizeInBytes} = -s $full_name;
-         $file->{whenUploaded} = now_datetime();
+         $file->{whenUploaded} = now_sqldatetime();
          $file->{displayName} = $display_name;
          $file->{storage} = 'local';
        }
@@ -4094,7 +4826,7 @@ sub req_save_file {
   $file->set_handler($self->cfg);
   $file->save;
 
-  $req->flash('File information saved');
+  $req->flash("msg:bse/admin/edit/file/save/success", [ $file->displayName ]);
   my $mgr = $self->_file_manager($self->cfg);
 
   my $storage = $cgi->param('storage');
@@ -4116,12 +4848,7 @@ sub req_save_file {
       and $req->flash("Could not move $file->{displayName} to $storage: $@");
   }
 
-  for my $meta_delete (@meta_delete, map $_->{name}, @meta) {
-    $file->delete_meta_by_name($meta_delete);
-  }
-  for my $meta (@meta) {
-    $file->add_meta(%$meta, appdata => 1);
-  }
+  BSE::FileMetaMeta->save($file, $meta);
 
   # remove the replaced files
   if (my ($old_name, $old_storage) = @old_file) {
@@ -4129,7 +4856,6 @@ sub req_save_file {
     unlink "$download_path/$old_name";
   }
 
-  use Util 'generate_article';
   generate_article($articles, $article) if $Constants::AUTO_GENERATE;
 
   $self->_refresh_filelist($req, $article);
@@ -4149,7 +4875,8 @@ sub can_remove {
     $$rcode = "CHILDREN";
     return;
   }
-  if (grep $_ == $article->{id}, @Constants::NO_DELETE) {
+  if (grep($_ == $article->{id}, @Constants::NO_DELETE)
+     || $req->cfg->entry("undeletable articles", $article->{id})) {
     $$rmsg = "Sorry, these pages are essential to the site structure - they cannot be deleted";
     $$rcode = "ESSENTIAL";
     return;
@@ -4188,7 +4915,7 @@ itself)
 
 =back
 
-JSON success response: { success: 1 }
+JSON success response: { success: 1, article_id: I<id> }
 
 =cut
 
@@ -4204,6 +4931,8 @@ sub remove {
     return $self->_service_error($req, $article, $articles, $why_not, {}, $code);
   }
 
+  my $data = $article->data_only;
+
   my $parentid = $article->{parentid};
   $article->remove($req->cfg);
 
@@ -4211,15 +4940,17 @@ sub remove {
     return $req->json_content
       (
        success => 1,
+       article_id => $data->{id},
       );
   }
 
   my $url = $req->cgi->param('r');
   unless ($url) {
-    my $urlbase = admin_base_url($req->cfg);
-    $url = "$urlbase$ENV{SCRIPT_NAME}?id=$parentid";
-    $url .= "&message=Article+deleted";
+    $url = $req->cfg->admin_url("add", { id => $parentid });
   }
+
+  $req->flash_notice("msg:bse/admin/edit/remove", [ $data ]);
+
   return BSE::Template->get_refresh($url, $self->{cfg});
 }
 
@@ -4234,7 +4965,6 @@ sub unhide {
     $article->{listed} = 1;
     $article->save;
 
-    use Util 'generate_article';
     generate_article($articles, $article) if $Constants::AUTO_GENERATE;
   }
   return $self->refresh($article, $req->cgi, undef, 'Article unhidden');
@@ -4251,7 +4981,6 @@ sub hide {
     $article->{listed} = 0;
     $article->save;
 
-    use Util 'generate_article';
     generate_article($articles, $article) if $Constants::AUTO_GENERATE;
   }
   my $r = $req->cgi->param('r');
@@ -4277,6 +5006,7 @@ my %defaults =
    linkAlias => '',
    author => '',
    summary => '',
+   category => '',
   );
 
 sub default_value {
@@ -4302,7 +5032,7 @@ sub default_value {
 
   if ($col eq 'threshold') {
     my $parent = defined $article->{parentid} && $article->{parentid} != -1 
-      && Articles->getByPkey($article->{parentid}); 
+      && BSE::TB::Articles->getByPkey($article->{parentid}); 
 
     $parent and return $parent->{threshold};
     
@@ -4311,7 +5041,7 @@ sub default_value {
   
   if ($col eq 'summaryLength') {
     my $parent = defined $article->{parentid} && $article->{parentid} != -1 
-      && Articles->getByPkey($article->{parentid}); 
+      && BSE::TB::Articles->getByPkey($article->{parentid}); 
 
     $parent and return $parent->{summaryLength};
     
@@ -4426,10 +5156,10 @@ sub req_ajax_save_body {
 
    if ($Constants::AUTO_GENERATE) {
      require Util;
-     Util::generate_article($articles, $article);
+     generate_article($articles, $article);
      for my $regen_id (@extra_regen) {
        my $regen = $articles->getByPkey($regen_id);
-       Util::generate_low($articles, $regen, $self->{cfg});
+       BSE::Regen::generate_low($articles, $regen, $self->{cfg});
      }
    }
  
@@ -4524,10 +5254,10 @@ sub req_ajax_set {
 
    if ($Constants::AUTO_GENERATE) {
      require Util;
-     Util::generate_article($articles, $article);
+     generate_article($articles, $article);
      for my $regen_id (@extra_regen) {
        my $regen = $articles->getByPkey($regen_id);
-       Util::generate_low($articles, $regen, $self->{cfg});
+       BSE::Regen::generate_low($articles, $regen, $self->{cfg});
      }
    }
  
@@ -4545,13 +5275,13 @@ sub csrf_error {
   my $msg = $req->csrf_error;
   $errors{_csrfp} = $msg;
   my $mymsg;
-  $article ||= $self->_dummy_article($req, 'Articles', \$mymsg);
+  $article ||= $self->_dummy_article($req, 'BSE::TB::Articles', \$mymsg);
   unless ($article) {
     require BSE::Edit::Site;
     my $site = BSE::Edit::Site->new(cfg=>$req->cfg, db=> BSE::DB->single);
-    return $site->edit_sections($req, 'Articles', $mymsg);
+    return $site->edit_sections($req, 'BSE::TB::Articles', $mymsg);
   }
-  return $self->_service_error($req, $article, 'Articles', $msg, \%errors);
+  return $self->_service_error($req, $article, 'BSE::TB::Articles', $msg, \%errors);
 }
 
 =item a_csrp
@@ -4574,7 +5304,7 @@ sub req_csrfp {
                                    "Only usable from Ajax", undef, "NOTAJAX");
 
   $ENV{REQUEST_METHOD} eq 'POST'
-    or return $self->_service_error($req, $article, "Articles",
+    or return $self->_service_error($req, $article, "BSE::TB::Articles",
                                    "POST required for this action", {}, "NOTPOST");
 
   my %errors;
@@ -4610,6 +5340,7 @@ sub _article_kid_summary {
   if (--$depth > 0) {
     for my $kid (@kids) {
       $kid->{children} = [ _article_kid_summary($kid->{id}, $depth) ];
+      $kid->{allkids} = [ BSE::TB::Articles->allkid_summary($kid->{id}) ];
     }
   }
 
@@ -4644,6 +5375,10 @@ sub req_tree {
      [
       _article_kid_summary($article->id, $depth),
      ],
+     allkids =>
+     [
+      BSE::TB::Articles->allkid_summary($article->id)
+     ],
     );
 }
 
@@ -4670,6 +5405,19 @@ sub req_article {
     );
 }
 
+sub templates_long {
+  my ($self, $article) = @_;
+
+  my @templates = $self->templates($article);
+
+  my $cfg = $self->{cfg};
+  return map
+    +{
+      name => $_,
+      description => $cfg->entry("template descriptions", $_, $_),
+     }, @templates;
+}
+
 sub _populate_config {
   my ($self, $req, $article, $articles, $conf) = @_;
 
@@ -4686,30 +5434,23 @@ sub _populate_config {
   $defaults{template} =
     $self->default_template($article, $req->cfg, \@templates);
 
-  $conf->{templates} =
-    [
-     map
-      {
-       $_ =>
-         {
-          description => $cfg->entry("template description", $_, $_),
-         };
-      } @templates
-     ];
+  $conf->{templates} = [ $self->templates_long($article) ];
   $conf->{thumb_geometries} =
     [
      map
      {
-       $_ =>
-        {
-         description => $cfg->entry("thumb geometry $_", "description", $_),
-        };
+       +{
+        name => $_,
+        description => $cfg->entry("thumb geometry $_", "description", $_),
+       };
      } sort keys %geos
     ];
   $conf->{defaults} = \%defaults;
+  $conf->{upload_progress} = $req->_tracking_uploads;
   my @child_types = $self->child_types($article);
   s/^BSE::Edit::// for @child_types;
   $conf->{child_types} = \@child_types;
+  $conf->{flags} = [ $self->flags ];
 }
 
 =item a_config
@@ -4741,8 +5482,19 @@ Returns an object of the form:
       ...
     },
     child_types: [ "Article" ],
+    flags:
+    [
+      { id => "A", desc => "description" },
+      ...
+    ],
+    // possibible custom data
   }
 
+To define custom data add entries to the [extra a_config] section,
+keys become the keys in the returned structure pointing at hashes
+containing that section from the system configuration.  Custom keys
+may not conflict with system defined keys.
+
 =cut
 
 sub req_config {
@@ -4754,6 +5506,20 @@ sub req_config {
   my %conf;
   $self->_populate_config($req, $article, $articles, \%conf);
   $conf{success} = 1;
+
+  my $cfg = $req->cfg;
+  my %custom = $cfg->entries("extra a_config");
+  for my $key (keys %custom) {
+    exists $conf{$key} and next;
+
+    my $section = $custom{$key};
+    $section =~ s/\{(level|generator|parentid|template)\}/$article->{$1}/g;
+
+    $section eq "db" and die;
+
+    $conf{$key} = { $cfg->entries($section) };
+  }
+
   return $req->json_content
     (
      \%conf