]> git.imager.perl.org - bse.git/blobdiff - site/cgi-bin/modules/BSE/Edit/Article.pm
allow editing image tags on the big image tool page
[bse.git] / site / cgi-bin / modules / BSE / Edit / Article.pm
index 762250977afc5e3e3acfd3ad85338e828f6a9a1d..63d60fd46569e73daeb7898381fdc308dce7c73d 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.057";
 
 =head1 NAME
 
@@ -68,7 +73,8 @@ sub article_dispatch {
   my $action;
   my %actions = $self->article_actions;
   for my $check (keys %actions) {
-    if ($cgi->param($check) || $cgi->param("$check.x")) {
+    if ($cgi->param($check) || $cgi->param("$check.x")
+       || $cgi->param("a_$check") || $cgi->param("a_$check.x")) {
       $action = $check;
       last;
     }
@@ -126,6 +132,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',
@@ -243,7 +250,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 {
@@ -254,7 +261,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
@@ -290,7 +297,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");
        }
       }
     }
@@ -360,7 +367,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}) {
@@ -408,8 +415,8 @@ sub tag_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);
   }
   my %labels =
     (
@@ -535,6 +542,12 @@ sub extra_templates {
   @templates;
 }
 
+sub categories {
+  my ($self, $articles) = @_;
+
+  return $articles->categories;
+}
+
 sub edit_parent {
   my ($article) = @_;
 
@@ -554,7 +567,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;
 }
@@ -660,7 +674,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 {
@@ -692,7 +707,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')) {
@@ -801,7 +816,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');
@@ -824,6 +839,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;
@@ -1074,6 +1102,7 @@ sub tag_thumbimage {
     (
      geo => $args,
      cfg => $cfg,
+     nolink => 1,
     );
 }
 
@@ -1119,14 +1148,151 @@ 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;
+}
+
+=back
+
+=head1 Common Edit Page Tags
+
+Variables:
+
+=over
+
+=item *
+
+C<article> - the article being edited.  This is a dummy article when a
+new article is being created.
+
+=item *
+
+C<isnew> - true if a new article is being created.
+
+=item *
+
+C<custom> - describes custom tags.
+
+=item *
+
+C<errors> - errors from the last submission of the page.
+
+=item *
+
+C<image_stores> - a function returning an array of possible image
+storages.
+
+=item *
+
+C<thumbs> - for the image list, whether thumbs should be displayed
+instead of full size images.
+
+=item *
+
+C<can_thumbs> - true if thumbnails are available.
+
+=back
+
+=cut
+
 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) {
@@ -1139,6 +1305,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;
@@ -1162,13 +1330,29 @@ 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);
+  $request->set_variable(thumbs => defined $thumbs_obj);
+  $request->set_variable(can_thumbs => defined $thumbs_obj_real);
+  $request->set_variable(image_stores =>
+                        sub {
+                          $self->iter_image_stores;
+                        });
+
   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 ],
@@ -1185,9 +1369,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 ],
@@ -1197,8 +1386,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, 
@@ -1212,9 +1407,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, 
@@ -1228,8 +1428,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
@@ -1239,6 +1445,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'),
@@ -1248,7 +1460,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 ],
@@ -1263,6 +1475,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 ],
+     ),
     );
 }
 
@@ -1303,6 +1522,9 @@ sub iter_groups {
 sub tag_ifGroupRequired {
   my ($article, $rgroup) = @_;
 
+  $article->{id}
+    or return 0;
+
   $$rgroup or return 0;
 
   $article->is_accessible_to($$rgroup);
@@ -1372,7 +1594,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;
@@ -1387,7 +1609,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 {
@@ -1396,7 +1625,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) = @_;
@@ -1432,6 +1661,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 {
@@ -1483,6 +1728,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);
 
@@ -1514,21 +1788,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) = @_;
 
@@ -1538,7 +1847,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) 
@@ -1555,6 +1864,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
@@ -1648,7 +1971,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);
   }
@@ -1675,8 +1998,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
 
@@ -1688,12 +2021,20 @@ sub save_new {
 
   my ($after_id) = $cgi->param("_after");
   if (defined $after_id) {
-    Articles->reorder_child($article->{parentid}, $article->{id}, $after_id);
+    BSE::TB::Articles->reorder_child($article->{parentid}, $article->{id}, $after_id);
     # reload, the displayOrder probably changed
     $article = $articles->getByPkey($article->{id});
   }
 
-  use Util 'generate_article';
+  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) {
@@ -1724,11 +2065,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);
 
@@ -1748,10 +2116,26 @@ sub _article_data {
     [
      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
+}
+
+=over
+
 =item save
 
 Error codes:
@@ -1788,7 +2172,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);
@@ -1818,10 +2204,26 @@ 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
@@ -1874,7 +2276,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")) {
@@ -1882,10 +2283,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')
@@ -1910,14 +2308,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);
@@ -1933,20 +2341,21 @@ sub save {
 
   my ($after_id) = $cgi->param("_after");
   if (defined $after_id) {
-    Articles->reorder_child($article->{parentid}, $article->{id}, $after_id);
+    BSE::TB::Articles->reorder_child($article->{parentid}, $article->{id}, $after_id);
     # reload, the displayOrder probably changed
     $article = $articles->getByPkey($article->{id});
   }
 
-  use Util 'generate_article';
   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
       (
@@ -1963,8 +2372,6 @@ sub save {
 sub can_reparent_to {
   my ($self, $article, $newparent, $parent_editor, $articles, $rmsg) = @_;
 
-  $DB::single = 1;
-
   my @child_types = $parent_editor->child_types;
   if (!grep $_ eq ref $self, @child_types) {
     my ($child_type) = (ref $self) =~ /(\w+)$/;
@@ -2138,11 +2545,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;
@@ -2167,12 +2575,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;
   }
 }
@@ -2277,10 +2686,9 @@ sub add_stepkid {
 
   my $after_id = $cgi->param("_after");
   if (defined $after_id) {
-    Articles->reorder_child($article->id, $child->id, $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;
 
   if ($req->is_ajax) {
@@ -2361,7 +2769,6 @@ sub del_stepkid {
   if ($@) {
     return $self->_service_error($req, $article, $articles, $@, {}, "DELETE");
   }
-  use Util 'generate_article';
   generate_article($articles, $article) if $Constants::AUTO_GENERATE;
 
   if ($req->is_ajax) {
@@ -2384,7 +2791,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) {
@@ -2411,7 +2818,6 @@ 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');
@@ -2484,24 +2890,24 @@ sub req_restepkid {
 
   # first, identify the stepkid link
   my $cgi = $req->cgi;
-  require OtherParents;
+  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) = OtherParents->getBy(parentId => $parentid, childId => $article->id)
+  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 = Articles->getByPkey($newparentid)
+    my $new_parent = BSE::TB::Articles->getByPkey($newparentid)
       or return $self->_service_error($req, $article, $articles, "Unknown new parent id", {}, "UNKNOWNNEWPARENT");
     my $existing = 
-      OtherParents->getBy(parentId=>$newparentid, childId=>$article->id)
+      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;
@@ -2510,7 +2916,7 @@ sub req_restepkid {
 
   my $after_id = $cgi->param("_after");
   if (defined $after_id) {
-    Articles->reorder_child($step->{parentId}, $article->id, $after_id);
+    BSE::TB::Articles->reorder_child($step->{parentId}, $article->id, $after_id);
   }
 
   if ($req->is_ajax) {
@@ -2565,7 +2971,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');
@@ -2602,7 +3007,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');
@@ -2620,7 +3024,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) {
@@ -2648,7 +3052,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', 
@@ -2722,7 +3125,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;
@@ -2753,12 +3156,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;
@@ -2781,48 +3179,72 @@ sub save_image_changes {
        if length $image->{name};
     }
 
+    if ($cgi->param("_save_image_tags$image->{id}")) {
+      my @tags = $cgi->param("tags$image->{id}");
+      my @errors;
+      my $index = 0;
+      for my $tag (@tags) {
+       my $error;
+       if ($tag =~ /\S/
+           && !BSE::TB::Tags->valid_name($tag, \$error)) {
+         $errors[$index] = "msg:bse/admin/edit/tags/invalid/$error";
+         $errors{"tags$image->{id}"} = \@errors;
+       }
+       ++$index;
+      }
+      unless (@errors) {
+       $changes{$id}{tags} = [ grep /\S/, @tags ];
+      }
+    }
+
     my $filename = $cgi->param("image$id");
     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
@@ -2856,8 +3278,13 @@ sub save_image_changes {
     if ($changes{$id}) {
       my $changes = $changes{$id};
       ++$changes_found;
-      
+
       for my $field (keys %$changes) {
+       my $tags = delete $changes->{$field};
+       if ($tags) {
+         my $error;
+         $image->set_tags($tags, \$error);
+       }
        $image->{$field} = $changes->{$field};
       }
       $image->save;
@@ -2890,7 +3317,6 @@ sub save_image_changes {
   }
   
   if ($changes_found) {
-    use Util 'generate_article';
     generate_article($articles, $article) if $Constants::AUTO_GENERATE;
   }
     
@@ -2923,14 +3349,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
       };
   }
 
@@ -2973,14 +3399,15 @@ sub _service_error {
     my $json_result = $req->json_content($result);
 
     if (!exists $ENV{HTTP_X_REQUESTED_WITH}
-       && $ENV{HTTP_X_REQUESTED_WITH} !~ /XMLHttpRequest/) {
+       || $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);
   }
 }
 
@@ -2998,16 +3425,63 @@ 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/A-Za-z0-9_./-/cs;
+
+  require BSE::ImageSize;
+
+  my ($width,$height, $type) = BSE::ImageSize::imgsize($fh);
+
+  unless (defined $width) {
+    $$error = "Unknown image file type";
+    return;
   }
 
-  return "img";
+  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 ($width, $height, $type);
 }
 
+my $last_display_order = 0;
+
 sub do_add_image {
   my ($self, $cfg, $article, $image, %opts) = @_;
 
@@ -3039,56 +3513,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 = '';
@@ -3102,10 +3570,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;
@@ -3154,28 +3622,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')) {
@@ -3206,9 +3688,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
@@ -3219,14 +3700,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);
@@ -3234,12 +3722,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');
 }
 
@@ -3264,7 +3757,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');
@@ -3291,7 +3783,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');
@@ -3313,7 +3804,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
@@ -3343,8 +3834,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> };
@@ -3367,6 +3858,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) = @_;
 
@@ -3381,6 +3896,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 =
     (
@@ -3393,6 +3910,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) = @_;
   
@@ -3404,11 +3949,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);
 
@@ -3435,62 +3980,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') {
@@ -3520,9 +4079,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) = @_;
 
@@ -3582,7 +4196,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;
@@ -3603,7 +4217,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';
   }
@@ -3619,9 +4234,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";
   }
@@ -3633,10 +4245,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;
@@ -3651,110 +4263,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 {
@@ -3785,7 +4337,6 @@ sub fileswap {
     }
   }
 
-  use Util 'generate_article';
   generate_article($articles, $article) if $Constants::AUTO_GENERATE;
 
   $self->refresh($article, $req->cgi, undef, 'File moved');
@@ -3817,7 +4368,6 @@ sub filedel {
     }
   }
 
-  use Util 'generate_article';
   generate_article($articles, $article) if $Constants::AUTO_GENERATE;
 
   $self->_refresh_filelist($req, $article, 'File deleted');
@@ -3842,9 +4392,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");
@@ -3867,9 +4419,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
@@ -3915,7 +4464,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;
            }
@@ -3935,6 +4484,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) {
@@ -3949,7 +4507,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;
@@ -3994,7 +4557,6 @@ sub filesave {
     $file->save;
   }
 
-  use Util 'generate_article';
   generate_article($articles, $article) if $Constants::AUTO_GENERATE;
 
   $self->_refresh_filelist($req, $article);
@@ -4175,6 +4737,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;
@@ -4182,7 +4747,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 ],
@@ -4247,6 +4812,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) {
@@ -4259,76 +4825,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");
@@ -4370,7 +4870,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';
        }
@@ -4398,7 +4898,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');
@@ -4420,12 +4920,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) {
@@ -4433,7 +4928,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);
@@ -4453,7 +4947,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;
@@ -4508,7 +5003,7 @@ sub remove {
     return $self->_service_error($req, $article, $articles, $why_not, {}, $code);
   }
 
-  my $id = $article->id;
+  my $data = $article->data_only;
 
   my $parentid = $article->{parentid};
   $article->remove($req->cfg);
@@ -4517,16 +5012,17 @@ sub remove {
     return $req->json_content
       (
        success => 1,
-       article_id => $id,
+       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});
 }
 
@@ -4541,7 +5037,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');
@@ -4558,7 +5053,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');
@@ -4584,6 +5078,7 @@ my %defaults =
    linkAlias => '',
    author => '',
    summary => '',
+   category => '',
   );
 
 sub default_value {
@@ -4609,7 +5104,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};
     
@@ -4618,7 +5113,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};
     
@@ -4733,10 +5228,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});
      }
    }
  
@@ -4831,10 +5326,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});
      }
    }
  
@@ -4852,13 +5347,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
@@ -4881,7 +5376,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;
@@ -4917,7 +5412,7 @@ sub _article_kid_summary {
   if (--$depth > 0) {
     for my $kid (@kids) {
       $kid->{children} = [ _article_kid_summary($kid->{id}, $depth) ];
-      $kid->{allkids} = [ Articles->allkid_summary($kid->{id}) ];
+      $kid->{allkids} = [ BSE::TB::Articles->allkid_summary($kid->{id}) ];
     }
   }
 
@@ -4954,7 +5449,7 @@ sub req_tree {
      ],
      allkids =>
      [
-      Articles->allkid_summary($article->id)
+      BSE::TB::Articles->allkid_summary($article->id)
      ],
     );
 }