]> git.imager.perl.org - bse.git/blobdiff - site/cgi-bin/modules/BSE/Edit/Article.pm
modify the image editor page to use new style markup
[bse.git] / site / cgi-bin / modules / BSE / Edit / Article.pm
index d99e7d51a43272b5aa6fadc7a38a4b5951c085f4..a9db59ed28e701b0e69b8f51f67948dddf2e04be 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::Util::Valid qw/valid_date/;
 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.056";
 
 =head1 NAME
 
@@ -37,8 +42,10 @@ sub not_logged_on {
     return $req->json_content
       (
        {
+       success => 0,
        message => "Access forbidden: user not logged on",
        errors => {},
+       error_code => "LOGON",
        }
       );
   }
@@ -66,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;
     }
@@ -86,6 +94,14 @@ sub noarticle_dispatch {
   BSE::Permissions->check_logon($req)
     or return BSE::Template->get_refresh($req->url('logon'), $req->cfg);
 
+  my $mymsg;
+  my $article = $self->_dummy_article($req, $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);
+  }
+
   my $cgi = $req->cgi;
   my $action = 'add';
   my %actions = $self->noarticle_actions;
@@ -96,7 +112,7 @@ sub noarticle_dispatch {
     }
   }
   my $method = $actions{$action};
-  return $self->$method($req, $articles);
+  return $self->$method($req, $article, $articles);
 }
 
 sub article_actions {
@@ -116,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',
@@ -135,6 +152,12 @@ sub article_actions {
      a_ajax_get => 'req_ajax_get',
      a_ajax_save_body => 'req_ajax_save_body',
      a_ajax_set => 'req_ajax_set',
+     a_filemeta => 'req_filemeta',
+     a_csrfp => 'req_csrfp',
+     a_tree => 'req_tree',
+     a_article => 'req_article',
+     a_config => 'req_config',
+     a_restepkid => 'req_restepkid',
     );
 }
 
@@ -155,6 +178,8 @@ sub noarticle_actions {
     (
      add => 'add_form',
      save => 'save_new',
+     a_csrfp => 'req_csrfp',
+     a_config => 'req_config',
     );
 }
 
@@ -221,11 +246,11 @@ sub should_be_catalog {
     $parent = $articles->getByPkey($article->{id});
   }
 
-  my $shopid = $self->{cfg}->entryErr('articles', 'shop');
+  my $shopid = $self->cfg->entryErr('articles', 'shop');
 
   return $article->{parentid} && $parent &&
     ($article->{parentid} == $shopid || 
-     $parent->{generator} eq 'Generate::Catalog');
+     $parent->{generator} eq 'BSE::Generate::Catalog');
 }
 
 sub possible_parents {
@@ -234,9 +259,9 @@ sub possible_parents {
   my %labels;
   my @values;
 
-  my $shopid = $self->{cfg}->entryErr('articles', 'shop');
+  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
@@ -272,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");
        }
       }
     }
@@ -342,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}) {
@@ -384,26 +409,37 @@ 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 {
   my ($self, $article) = @_;
 
   my @title_images;
-  my $imagedir = cfg_image_dir($self->{cfg});
+  my $imagedir = cfg_image_dir($self->cfg);
   if (opendir TITLE_IMAGES, "$imagedir/titles") {
     @title_images = sort 
       grep -f "$imagedir/titles/$_" && /\.(gif|jpeg|jpg|png)$/i,
@@ -438,7 +474,7 @@ sub template_dirs {
   my @dirs = $self->base_template_dirs;
   if (my $parentid = $article->{parentid}) {
     my $section = "children of $parentid";
-    if (my $dirs = $self->{cfg}->entry($section, 'template_dirs')) {
+    if (my $dirs = $self->cfg->entry($section, 'template_dirs')) {
       push @dirs, split /,/, $dirs;
     }
   }
@@ -506,6 +542,12 @@ sub extra_templates {
   @templates;
 }
 
+sub categories {
+  my ($self, $articles) = @_;
+
+  return $articles->categories;
+}
+
 sub edit_parent {
   my ($article) = @_;
 
@@ -525,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;
 }
@@ -631,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 {
@@ -663,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')) {
@@ -772,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');
@@ -795,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;
@@ -837,13 +894,16 @@ sub tag_imgmove {
   $url .= $urladd;
 
   my $image = $images->[$$rindex];
-  my $down_url;
+  my $csrfp = $req->get_csrf_token("admin_move_image");
+  my $baseurl = "$ENV{SCRIPT_NAME}?id=$article->{id}&imageid=$image->{id}&";
+  $baseurl .= "_csrfp=$csrfp&";
+  my $down_url = "";
   if ($$rindex < $#$images) {
-    $down_url = "$ENV{SCRIPT_NAME}?id=$article->{id}&moveimgdown=1&imageid=$image->{id}";
+    $down_url = $baseurl . "moveimgdown=1";
   }
-  my $up_url = '';
+  my $up_url = "";
   if ($$rindex > 0) {
-    $up_url = "$ENV{SCRIPT_NAME}?id=$article->{id}&moveimgup=1&imageid=$image->{id}";
+    $up_url = $baseurl . "moveimgup=1";
   }
   return make_arrows($req->cfg, $down_url, $up_url, $url, $img_prefix);
 }
@@ -872,12 +932,15 @@ sub tag_movefiles {
   }
 
   my $down_url = "";
+  my $csrfp = $req->get_csrf_token("admin_move_file");
+  my $baseurl = "$ENV{SCRIPT_NAME}?fileswap=1&id=$article->{id}&";
+  $baseurl .= "_csrfp=$csrfp&";
   if ($$rindex < $#$files) {
-    $down_url = "$ENV{SCRIPT_NAME}?fileswap=1&amp;id=$article->{id}&amp;file1=$files->[$$rindex]{id}&amp;file2=$files->[$$rindex+1]{id}";
+    $down_url = $baseurl . "file1=$files->[$$rindex]{id}&file2=$files->[$$rindex+1]{id}";
   }
   my $up_url = "";
   if ($$rindex > 0) {
-    $up_url = "$ENV{SCRIPT_NAME}?fileswap=1&amp;id=$article->{id}&amp;file1=$files->[$$rindex]{id}&amp;file2=$files->[$$rindex-1]{id}";
+    $up_url = $baseurl . "file1=$files->[$$rindex]{id}&file2=$files->[$$rindex-1]{id}";
   }
 
   return make_arrows($req->cfg, $down_url, $up_url, $url, $img_prefix);
@@ -1031,29 +1094,205 @@ sub tag_thumbimage {
   my $filename = "$imagedir/$$current_image->{image}";
   -e $filename or return "** image file missing **";
 
-  my $geometry = $cfg->entry('thumb geometries', $args, 'scale(200x200)');
+  defined $args && $args =~ /\S/
+    or $args = "editor";
 
   my $image = $$current_image;
-  my ($width, $height) = $thumbs_obj->thumb_dimensions_sized
-    ($geometry, @$image{qw/width height/});
+  return $image->thumb
+    (
+     geo => $args,
+     cfg => $cfg,
+     nolink => 1,
+    );
+}
+
+sub tag_file_display {
+  my ($self, $files, $file_index) = @_;
+
+  $$file_index >= 0 && $$file_index < @$files
+    or return "* file_display only usable inside a files iterator *";
+  my $file = $files->[$$file_index];
+
+  my $disp_type = $self->cfg->entry("editor", "file_display", "");
+
+  return $file->inline
+    (
+     cfg => $self->cfg,
+     field => $disp_type,
+    );
+}
+
+sub tag_image {
+  my ($self, $cfg, $rcurrent, $args) = @_;
+
+  my $im = $$rcurrent
+    or return '';
+
+  my ($align, $rest) = split ' ', $args, 2;
+
+  if ($align && exists $im->{$align}) {
+    if ($align eq 'src') {
+      return escape_html($im->image_url($self->{cfg}));
+    }
+    else {
+      return escape_html($im->{$align});
+    }
+  }
+  else {
+    return $im->formatted
+      (
+       cfg => $cfg,
+       align => $align,
+       extras => $rest,
+      );
+  }
+}
+
+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 ($uri, $alt);
-  $alt = "thumbnail of ".$$current_image->{alt};
-  $uri = "$ENV{SCRIPT_NAME}?a_thumb=1&id=$$current_image->{articleId}&im=$$current_image->{id}&w=$width&h=$height";
+  my $fields = $self->custom_fields;
+  my %active;
+  for my $key (keys %$fields) {
+    $fields->{$key}{description}
+      and $active{$key} = $fields->{$key};
+  }
 
-  $alt = escape_html($alt);
-  $uri = escape_html($uri);
-  return qq!<img src="$uri" width="$width" height="$height" alt="$alt" border="0" />!;
+  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');
-  $msg ||= join "\n", map escape_html($_), $cgi->param('message'), $cgi->param('m');
-  $msg ||= $request->message($errors);
+  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);
   my $parent;
   if ($article->{id}) {
     if ($article->{parentid} > 0) {
@@ -1066,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;
@@ -1089,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 ],
@@ -1105,14 +1362,21 @@ sub low_edit_tags {
      $it->make_iterator
      ([ \&iter_get_images, $self, $article ], 'image', 'images', \@images, 
       \$image_index, undef, \$current_image),
+     image => [ tag_image => $self, $cfg, \$current_image ],
      thumbimage => [ \&tag_thumbimage, $cfg, $thumbs_obj, \$current_image ],
      ifThumbs => defined($thumbs_obj),
      ifCanThumbs => defined($thumbs_obj_real),
      imgmove => [ \&tag_imgmove, $request, $article, \$image_index, \@images ],
      message => $msg,
-     DevHelp::Tags->make_iterator2
-     ([ \&iter_get_kids, $article, $articles ], 
-      'child', 'children', \@children, \$child_index),
+     ifError => $if_error,
+     $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 ],
@@ -1122,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, 
@@ -1137,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, 
@@ -1153,10 +1428,30 @@ 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
+     (
+      code => [ iter_file_metas => $self, \@files, \$file_index ],
+      plural => "file_metas",
+      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'),
      DevHelp::Tags->make_iterator2
@@ -1165,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 ],
@@ -1180,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 ],
+     ),
     );
 }
 
@@ -1195,15 +1497,16 @@ sub iter_image_stores {
 sub _file_manager {
   my ($self) = @_;
 
-  require BSE::StorageMgr::Files;
+  require BSE::TB::ArticleFiles;
 
-  return BSE::StorageMgr::Files->new(cfg => $self->cfg);
+  return BSE::TB::ArticleFiles->file_manager($self->cfg);
 }
 
 sub iter_file_stores {
   my ($self) = @_;
 
-  my $mgr = $self->_file_manager;
+  require BSE::TB::ArticleFiles;
+  my $mgr = $self->_file_manager($self->cfg);
 
   return map +{ name => $_->name, description => $_->description },
     $mgr->all_stores;
@@ -1219,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);
@@ -1261,8 +1567,8 @@ sub edit_form {
   return $self->low_edit_form($request, $article, $articles, $msg, $errors);
 }
 
-sub add_form {
-  my ($self, $req, $articles, $msg, $errors) = @_;
+sub _dummy_article {
+  my ($self, $req, $articles, $rmsg) = @_;
 
   my $level;
   my $cgi = $req->cgi;
@@ -1288,7 +1594,7 @@ sub add_form {
   }
   
   my %article;
-  my @cols = Article->columns;
+  my @cols = BSE::TB::Article->columns;
   @article{@cols} = ('') x @cols;
   $article{id} = '';
   $article{parentid} = $parentid;
@@ -1299,16 +1605,27 @@ sub add_form {
 
   my ($values, $labels) = $self->possible_parents(\%article, $articles, $req);
   unless (@$values) {
-    require BSE::Edit::Site;
-    my $site = BSE::Edit::Site->new(cfg=>$req->cfg, db=> BSE::DB->single);
-    return $site->edit_sections($req, $articles, 
-                               "You can't add children to any article at that level");
+    $$rmsg = "You can't add children to any article at that level";
+    return;
   }
 
-  return $self->low_edit_form($req, \%article, $articles, $msg, $errors);
+  return $self->_make_dummy_article(\%article);
+}
+
+sub _make_dummy_article {
+  my ($self, $article) = @_;
+
+  require BSE::DummyArticle;
+  return bless $article, "BSE::DummyArticle";
+}
+
+sub add_form {
+  my ($self, $req, $article, $articles, $msg, $errors) = @_;
+
+  return $self->low_edit_form($req, $article, $articles, $msg, $errors);
 }
 
-sub generator { 'Generate::Article' }
+sub generator { 'BSE::Generate::Article' }
 
 sub typename {
   my ($self) = @_;
@@ -1344,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 {
@@ -1363,14 +1696,17 @@ sub validate {
 }
 
 sub validate_old {
-  my ($self, $article, $data, $articles, $errors) = @_;
+  my ($self, $article, $data, $articles, $errors, $ajax) = @_;
 
   $self->_validate_common($data, $articles, $errors, $article);
   custom_class($self->{cfg})
     ->article_validate($data, $article, $self->typename, $errors);
 
-  if (exists $data->{release} && !valid_date($data->{release})) {
-    $errors->{release} = "Invalid release date";
+  if (exists $data->{release}) {
+    if ($ajax && !dh_parse_sql_date($data->{release})
+       || !$ajax && !dh_parse_date($data->{release})) {
+      $errors->{release} = "Invalid release date";
+    }
   }
 
   if (!$errors->{linkAlias} 
@@ -1392,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);
 
@@ -1423,28 +1788,66 @@ 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, $articles) = @_;
+  my ($self, $req, $article, $articles) = @_;
+
+  $req->check_csrf("admin_add_article")
+    or return $self->csrf_error($req, undef, "admin_add_article", "Add Article");
   
   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) 
@@ -1460,36 +1863,86 @@ sub save_new {
   elsif ($data{parentid} !~ /^(?:-1|\d+)$/) {
     $errors{parentid} = "Invalid parent selection (template bug)";
   }
-  $self->validate(\%data, $articles, \%errors)
-    or return $self->add_form($req, $articles, $msg, \%errors);
+  $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
+       (
+        success => 0,
+        errors => \%errors,
+        error_code => "FIELD",
+        message => $req->message(\%errors),
+       );
+    }
+    else {
+      return $self->add_form($req, $article, $articles, $msg, \%errors);
+    }
+  }
 
   my $parent;
+  my $parent_msg;
+  my $parent_code;
   if ($data{parentid} > 0) {
     $parent = $articles->getByPkey($data{parentid}) or die;
-    $req->user_can('edit_add_child', $parent)
-      or return $self->add_form($req, $articles,
-                               "You cannot add a child to that article");
-    for my $name (@columns) {
-      if (exists $data{$name} && 
-         !$req->user_can("edit_add_field_$name", $parent)) {
-       delete $data{$name};
+    if ($req->user_can('edit_add_child', $parent)) {
+      for my $name (@columns) {
+       if (exists $data{$name} && 
+           !$req->user_can("edit_add_field_$name", $parent)) {
+         delete $data{$name};
+       }
       }
     }
+    else {
+      $parent_msg = "You cannot add a child to that article";
+      $parent_code = "ACCESS";
+    }
   }
   else {
-    $req->user_can('edit_add_child')
-      or return $self->add_form($req, $articles, 
-                               "You cannot create a top-level article");
-    for my $name (@columns) {
-      if (exists $data{$name} && 
-         !$req->user_can("edit_add_field_$name")) {
-       delete $data{$name};
+    if ($req->user_can('edit_add_child')) {
+      for my $name (@columns) {
+       if (exists $data{$name} && 
+           !$req->user_can("edit_add_field_$name")) {
+         delete $data{$name};
+       }
       }
     }
+    else {
+      $parent_msg = "You cannot create a top-level article";
+      $parent_code = "ACCESS";
+    }
+  }
+  if (!$parent_msg) {
+    $self->validate_parent(\%data, $articles, $parent, \$parent_msg)
+      or $parent_code = "PARENT";
+  }
+  if ($parent_msg) {
+    if ($req->is_ajax) {
+      return $req->json_content
+       (
+        success => 0,
+        message => $parent_msg,
+        error_code => $parent_code,
+        errors => {},
+       );
+    }
+    else {
+      return $self->add_form($req, $article, $articles, $parent_msg);
+    }
   }
-  
-  $self->validate_parent(\%data, $articles, $parent, \$msg)
-    or return $self->add_form($req, $articles, $msg);
 
   my $level = $parent ? $parent->{level}+1 : 1;
   $data{level} = $level;
@@ -1518,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)) {
+  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);
   }
@@ -1533,8 +1986,10 @@ sub save_new {
     }
   }
 
-  for my $col (qw(release expire)) {
-    $data{$col} = sql_date($data{$col});
+  unless ($req->is_ajax) {
+    for my $col (qw(release expire)) {
+      $data{$col} = sql_date($data{$col});
+    }
   }
 
   # these columns are handled a little differently
@@ -1543,8 +1998,18 @@ sub save_new {
       or $data{$col} = $self->default_value($req, \%data, $col);
   }
 
-  shift @columns;
-  my $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
 
@@ -1554,20 +2019,43 @@ 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) {
+    return $req->json_content
+      (
+       {
+       success => 1,
+       article => $self->_article_data($req, $article),
+       },
+      );
+  }
+
   my $r = $cgi->param('r');
   if ($r) {
     $r .= ($r =~ /\?/) ? '&' : '?';
     $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 {
@@ -1577,28 +2065,116 @@ 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);
 
   return 1;
 }
 
+sub _article_data {
+  my ($self, $req, $article) = @_;
+
+  my $article_data = $article->data_only;
+  $article_data->{link} = $article->link($req->cfg);
+  $article_data->{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
+}
+
+=over
+
+=item save
+
+Error codes:
+
+=over
+
+=item *
+
+ACCESS - user doesn't have access to this article.
+
+=item *
+
+LASTMOD - lastModified value doesn't match that in the article
+
+=item *
+
+PARENT - invalid parentid specified
+
+=back
+
+=cut
+
 sub save {
   my ($self, $req, $article, $articles) = @_;
 
+  $req->check_csrf("admin_save_article")
+    or return $self->csrf_error($req, $article, "admin_save_article", "Save Article");
+
   $req->user_can(edit_save => $article)
-    or return $self->edit_form($req, $article, $articles,
-                              "You don't have access to save this article");
+    or return $self->_service_error
+      ($req, $article, $articles, "You don't have access to save this article",
+       {}, "ACCESS");
 
   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);
@@ -1615,7 +2191,7 @@ sub save {
       }
       print STDERR "non-matching lastModified, article not saved\n";
       my $msg = "Article changes not saved, this article was modified $whoModified at $timeModified since this editor was loaded";
-         return $self->edit_form($req, $article, $articles, $msg);
+      return $self->_service_error($req, $article, $articles, $msg, {}, "LASTMOD");
     }
   }
 # end adrian
@@ -1624,70 +2200,82 @@ sub save {
   $data{flags} = join '', sort $cgi->param('flags')
     if $req->user_can("edit_field_edit_flags", $article);
   my %errors;
-  $self->validate_old($article, \%data, $articles, \%errors)
-    or return $self->edit_form($req, $article, $articles, undef, \%errors);
-  $self->save_thumbnail($cgi, $article, \%data)
-    if $req->user_can('edit_field_edit_thumbImage', $article);
-  $self->fill_old_data($req, $article, \%data);
   if (exists $article->{template} &&
       $article->{template} =~ m|\.\.|) {
-    my $msg = "Please only select templates from the list provided";
-    return $self->edit_form($req, $article, $articles, $msg);
+    $errors{template} = "Please only select templates from the list provided";
   }
-  
-  # reparenting
-  my $newparentid = $cgi->param('parentid');
-  if ($newparentid && $req->user_can('edit_field_edit_parentid', $article)) {
-    if ($newparentid == $article->{parentid}) {
-      # nothing to do
-    }
-    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->edit_form($req, $article, $articles, $msg);
-         }
-         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->edit_form($req, $article, $articles, $msg);
-         }
-         my $msg;
-         $self->reparent($article, $newparentid, $articles, \$msg)
-           or return $self->edit_form($req, $article, $articles, $msg);
-       }
-       else {
-         # stays at the same level, nothing special
-         $article->{parentid} = $newparentid;
-       }
-      }
-      # else ignore it
+
+  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)
+      && $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);
     }
     else {
-      # becoming a section
+      $newparent = $articles->getByPkey($newparentid);
+      ($parent_editor, $newparent) = $self->article_class($newparent, $articles, $req->cfg);
+    }
+    if ($newparent) {
       my $msg;
-      $self->reparent($article, -1, $articles, \$msg)
-       or return $self->edit_form($req, $article, $articles, $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 {
+      return $self->_service_error($req, $article, $articles, "No such parent article", {}, "PARENT");
     }
   }
 
   $article->{listed} = $cgi->param('listed')
    if defined $cgi->param('listed') && 
       $req->user_can('edit_field_edit_listed', $article);
-  $article->{release} = sql_date($cgi->param('release'))
-    if defined $cgi->param('release') && 
-      $req->user_can('edit_field_edit_release', $article);
-  
+
+  if ($req->user_can('edit_field_edit_release', $article)) {
+    my $release = $cgi->param("release");
+    if (defined $release && $release =~ /\S/) {
+      if ($req->is_ajax) {
+       $article->{release} = $release;
+      }
+      else {
+       $article->{release} = sql_date($release)
+      }
+    }
+  }
+
   $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")) {
@@ -1695,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')
@@ -1723,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);
@@ -1744,18 +2339,77 @@ 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
+      (
+       {
+       success => 1,
+       article => $self->_article_data($req, $article),
+       },
+      );
+  }
+
   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) = @_;
 
@@ -1891,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;
@@ -1920,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;
   }
 }
@@ -1940,89 +2596,202 @@ 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) = @_;
 
+  $req->check_csrf("admin_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 return $self->_service_error($req, $article, $articles, "You don't have access to add a stepparent to that article", {}, "ACCESS");
 
-    $req->user_can(edit_stepparent_add => $child)
-      or die "You don't have access to add a stepparent to that article\n";
+  my $new_entry;
+  eval {
     
-    use BSE::Util::Valid qw/valid_date/;
     my $release = $cgi->param('release');
-    valid_date($release) or $release = undef;
+    dh_parse_date($release) or $release = undef;
     my $expire = $cgi->param('expire');
-    valid_date($expire) or $expire = undef;
+    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');
+  }
 }
 
+=item del_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 {
   my ($self, $req, $article, $articles) = @_;
 
+  $req->check_csrf("admin_save_stepkids")
+    or return $self->csrf_error($req, $article, "admin_save_stepkids", "Save Stepkids");
+
   $req->user_can(edit_stepkid_save => $article)
     or return $self->edit_form($req, $article, $articles,
                               "No access to save stepkid data for this article");
 
   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) {
@@ -2034,7 +2803,7 @@ sub save_stepkids {
        if ($date eq '') {
          $date = $datedefs{$name};
        }
-       elsif (valid_date($date)) {
+       elsif (dh_parse_date($date)) {
          use BSE::Util::SQL qw/date_to_sql/;
          $date = date_to_sql($date);
        }
@@ -2049,15 +2818,125 @@ 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) = @_;
 
+  $req->check_csrf("admin_add_stepparent")
+    or return $self->csrf_error($req, $article, "admin_add_stepparent", "Add Stepparent");
+
   $req->user_can(edit_stepparent_add => $article)
     or return $self->edit_form($req, $article, $articles,
                               "You don't have access to add stepparents to this article");
@@ -2079,13 +2958,12 @@ sub add_stepparent {
     my $release = $cgi->param('release');
     defined $release
       or $release = "01/01/2000";
-    use BSE::Util::Valid qw/valid_date/;
-    $release eq '' or valid_date($release)
+    $release eq '' or dh_parse_date($release)
       or die "Invalid release date";
     my $expire = $cgi->param('expire');
     defined $expire
       or $expire = '31/12/2999';
-    $expire eq '' or valid_date($expire)
+    $expire eq '' or dh_parse_date($expire)
       or die "Invalid expire data";
   
     my $newentry = 
@@ -2093,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');
@@ -2102,6 +2979,9 @@ sub add_stepparent {
 sub del_stepparent {
   my ($self, $req, $article, $articles) = @_;
 
+  $req->check_csrf("admin_remove_stepparent")
+    or return $self->csrf_error($req, $article, "admin_del_stepparent", "Delete Stepparent");
+
   $req->user_can(edit_stepparent_delete => $article)
     or return $self->edit_form($req, $article, $articles,
                               "You cannot remove stepparents from that article");
@@ -2127,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');
@@ -2136,6 +3015,8 @@ sub del_stepparent {
 sub save_stepparents {
   my ($self, $req, $article, $articles) = @_;
 
+  $req->check_csrf("admin_save_stepparents")
+    or return $self->csrf_error($req, $article, "admin_save_stepparents", "Save Stepparents");
   $req->user_can(edit_stepparent_save => $article)
     or return $self->edit_form($req, $article, $articles,
                               "No access to save stepparent data for this artice");
@@ -2143,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) {
@@ -2155,7 +3036,7 @@ sub save_stepparents {
        if ($date eq '') {
          $date = $datedefs{$name};
        }
-       elsif (valid_date($date)) {
+       elsif (dh_parse_date($date)) {
          use BSE::Util::SQL qw/date_to_sql/;
          $date = date_to_sql($date);
        }
@@ -2171,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', 
@@ -2186,8 +3066,9 @@ sub refresh_url {
     if ($url !~ /[?&](m|message)=/ && $message) {
       # add in messages if none in the provided refresh
       my @msgs = ref $message ? @$message : $message;
+      my $sep = $url =~ /\?/ ? "&" : "?";
       for my $msg (@msgs) {
-       $url .= "&m=" . CGI::escape($msg);
+       $url .= $sep . "m=" . CGI::escape($msg);
       }
     }
   }
@@ -2232,6 +3113,9 @@ sub show_images {
 sub save_image_changes {
   my ($self, $req, $article, $articles) = @_;
 
+  $req->check_csrf("admin_save_images")
+    or return $self->csrf_error($req, $article, "admin_save_images", "Save Images");
+
   $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");
@@ -2241,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;
@@ -2272,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;
@@ -2304,43 +3183,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;
+       unless ($type) {
+         $errors{"image$id"} = $image_error;
+       }
 
-           $changes{$id}{image} = $image_name;
-           $changes{$id}{storage} = 'local';
-           $changes{$id}{src} = "/images/$image_name";
-           $changes{$id}{width} = $width;
-           $changes{$id}{height} = $height;
+       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
@@ -2408,7 +3294,6 @@ sub save_image_changes {
   }
   
   if ($changes_found) {
-    use Util 'generate_article';
     generate_article($articles, $article) if $Constants::AUTO_GENERATE;
   }
     
@@ -2441,7 +3326,16 @@ Otherwise display the normal edit page with the error.
 =cut
 
 sub _service_error {
-  my ($self, $req, $article, $articles, $msg, $error) = @_;
+  my ($self, $req, $article, $articles, $msg, $error, $code, $method) = @_;
+
+  unless ($article) {
+    my $mymsg;
+    $article = $self->_dummy_article($req, $articles, \$mymsg);
+    $article ||=
+      {
+       map $_ => '', BSE::TB::Article->columns
+      };
+  }
 
   if ($req->cgi->param('_service')) {
     my $body = '';
@@ -2469,15 +3363,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;
-    return $req->json_content($result);
+    $code and $result->{error_code} = $code;
+    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);
   }
 }
 
@@ -2495,6 +3402,63 @@ sub _service_success {
     };
 }
 
+# FIXME: eliminate this method and call get_ftype directly
+sub _image_ftype {
+  my ($self, $type) = @_;
+
+  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;
+  }
+
+  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) = @_;
 
@@ -2526,56 +3490,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) = 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 = '';
@@ -2589,15 +3547,16 @@ 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 Images;
-  my @cols = Image->columns;
+  require BSE::TB::Images;
+  my @cols = BSE::TB::Image->columns;
   shift @cols;
-  my $imageobj = Images->add(@image{@cols});
+  my $imageobj = BSE::TB::Images->add(@image{@cols});
 
   my $storage = $opts{storage};
   defined $storage or $storage = 'local';
@@ -2621,36 +3580,61 @@ 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) = @_;
 
+  $req->check_csrf("admin_add_image")
+    or return $self->csrf_error($req, $article, "admin_add_image", "Add Image");
   $req->user_can(edit_images_add => $article)
     or return $self->_service_error($req, $article, $articles,
                                    "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')) {
@@ -2661,6 +3645,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');
   }
@@ -2669,24 +3665,33 @@ 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
 sub remove_img {
   my ($self, $req, $article, $articles, $imageid) = @_;
 
+  $req->check_csrf("admin_remove_image")
+    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);
@@ -2694,18 +3699,25 @@ 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');
 }
 
 sub move_img_up {
   my ($self, $req, $article, $articles) = @_;
 
+  $req->check_csrf("admin_move_image")
+    or return $self->csrf_error($req, $article, "admin_move_image", "Move Image");
   $req->user_can(edit_images_reorder => $article)
     or return $self->edit_form($req, $article, $articles,
                                 "You don't have access to reorder images in this article");
@@ -2722,7 +3734,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');
@@ -2731,6 +3742,8 @@ sub move_img_up {
 sub move_img_down {
   my ($self, $req, $article, $articles) = @_;
 
+  $req->check_csrf("admin_move_image")
+    or return $self->csrf_error($req, $article, "admin_move_image", "Move Image");
   $req->user_can(edit_images_reorder => $article)
     or return $self->edit_form($req, $article, $articles,
                                 "You don't have access to reorder images in this article");
@@ -2747,7 +3760,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');
@@ -2769,11 +3781,15 @@ 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("$imagedir/$image->{image}", $geometry, \$error)
+    ($data, $type) = $thumb_obj->thumb_data
+      (
+       filename => "$imagedir/$image->{image}",
+       geometry => $geometry,
+       error => \$error
+      )
        or return 
          {
           type => 'text/plain',
@@ -2795,8 +3811,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> };
@@ -2819,6 +3835,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) = @_;
 
@@ -2833,6 +3873,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 =
     (
@@ -2845,20 +3887,50 @@ 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) = @_;
   
+  $req->check_csrf("admin_save_image")
+    or return $self->csrf_error($req, $article, "admin_save_image", "Save Image");
   my $cgi = $req->cgi;
 
   my $id = $cgi->param('image_id');
 
   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);
 
@@ -2885,61 +3957,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') {
@@ -2969,9 +4056,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) = @_;
 
@@ -3018,19 +4160,26 @@ my %file_fields =
     description => 'Identifier',
     maxlength => 80,
    },
+   category =>
+   {
+    description => "Category",
+    maxlength => 20,
+   },
   );
 
 sub fileadd {
   my ($self, $req, $article, $articles) = @_;
 
+  $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;
   my $cgi = $req->cgi;
-  require ArticleFile;
-  my @cols = ArticleFile->columns;
+  require BSE::TB::ArticleFiles;
+  my @cols = BSE::TB::ArticleFile->columns;
   shift @cols;
   for my $col (@cols) {
     if (defined $cgi->param($col)) {
@@ -3043,37 +4192,25 @@ sub fileadd {
   $req->validate(errors => \%errors,
                 fields => \%file_fields,
                 section => $article->{id} == -1 ? 'Global File Validation' : 'Article File Validation');
-
-  $file{forSale}       = 0 + exists $file{forSale};
-  $file{articleId}     = $article->{id};
-  $file{download}      = 0 + exists $file{download};
-  $file{requireUser}   = 0 + exists $file{requireUser};
-  $file{hide_from_list} = 0 + exists $file{hide_from_list};
-
-  my $downloadPath = $self->{cfg}->entryVar('paths', 'downloads');
-
+  
   # 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';
   }
   if ($file && -z $file) {
     $errors{file} = 'File is empty';
   }
-
-  unless ($file{contentType}) {
-    unless ($file =~ /\.([^.]+)$/) {
-      $file{contentType} = "application/octet-stream";
-    }
-    unless ($file{contentType}) {
-      $file{contentType} = content_type($self->cfg, $file);
-    }
-  }
+  
+  $file{forSale}       = 0 + exists $file{forSale};
+  $file{articleId}     = $article->{id};
+  $file{download}      = 0 + exists $file{download};
+  $file{requireUser}   = 0 + exists $file{requireUser};
+  $file{hide_from_list} = 0 + exists $file{hide_from_list};
+  $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";
   }
@@ -3085,94 +4222,76 @@ 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;
   $basename =~ tr/ /_/;
-
-  # 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} = $file;
 
-  require ArticleFiles;
-  my $fileobj = ArticleFiles->add(@file{@cols});
+  local $SIG{__DIE__};
+  my $fileobj = 
+    eval {
+      $article->add_file($self->cfg, %file);
+    };
 
-  $req->flash("New file added");
+  $fileobj
+    or return $self->_service_error($req, $article, $articles, $@);
 
-  my $storage = $cgi->param('storage');
-  defined $storage or $storage = 'local';
-  my $file_manager = $self->_file_manager($req->cfg);
+  unless ($req->is_ajax) {
+    $req->flash("New file added");
+  }
 
-  local $SIG{__DIE__};
+  my $json =
+    {
+     success => 1,
+     file => $fileobj->data_only,
+     warnings => [],
+    };
+  my $storage = $cgi->param("storage") || "";
   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;
+    my $msg;
+
+    $article->apply_storage($self->cfg, $fileobj, $storage, \$msg);
+
+    if ($msg) {
+      if ($req->is_ajax) {
+       push @{$json->{warnings}}, $msg;
+      }
+      else {
+       $req->flash_error($msg);
+      }
     }
   };
   if ($@) {
-    $req->flash($@);
+    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 {
   my ($self, $req, $article, $articles) = @_;
 
+  $req->check_csrf("admin_move_file")
+    or return $self->csrf_error($req, $article, "admin_move_file", "Move File");
+
   $req->user_can('edit_files_reorder', $article)
     or return $self->edit_form($req, $article, $articles,
                           "You don't have access to reorder files in this article");
@@ -3195,7 +4314,6 @@ sub fileswap {
     }
   }
 
-  use Util 'generate_article';
   generate_article($articles, $article) if $Constants::AUTO_GENERATE;
 
   $self->refresh($article, $req->cgi, undef, 'File moved');
@@ -3204,6 +4322,8 @@ sub fileswap {
 sub filedel {
   my ($self, $req, $article, $articles) = @_;
 
+  $req->check_csrf("admin_remove_file")
+    or return $self->csrf_error($req, $article, "admin_remove_file", "Delete File");
   $req->user_can('edit_files_delete', $article)
     or return $self->edit_form($req, $article, $articles,
                               "You don't have access to delete files from this article");
@@ -3217,7 +4337,7 @@ sub filedel {
 
     if ($file) {
       if ($file->{storage} ne 'local') {
-       my $mgr = $self->_file_manager;
+       my $mgr = $self->_file_manager($self->cfg);
        $mgr->unstore($file->{filename}, $file->{storage});
       }
 
@@ -3225,40 +4345,23 @@ sub filedel {
     }
   }
 
-  use Util 'generate_article';
   generate_article($articles, $article) if $Constants::AUTO_GENERATE;
 
   $self->_refresh_filelist($req, $article, 'File deleted');
 }
 
-# only some files can be stored remotely
-sub _select_filestore {
-  my ($self, $req, $mgr, $storage, $file) = @_;
-
-  my $store = $mgr->select_store($file->{filename}, $storage, $file);
-  if ($store ne 'local') {
-    if ($file->{forSale} || $file->{requireUser}) {
-      $store = 'local';
-      $req->flash("For sale or user required files can only be stored locally");
-    }
-    elsif ($file->{articleId} != -1 && $file->article->is_access_controlled) {
-      $store = 'local';
-      $req->flash("Files for access controlled articles can only be stored locally");
-    }
-  }
-
-  return $store;
-}
-
 sub filesave {
   my ($self, $req, $article, $articles) = @_;
 
+  $req->check_csrf("admin_save_files")
+    or return $self->csrf_error($req, $article, "admin_save_files", "Save Files");
+
   $req->user_can('edit_files_save', $article)
     or return $self->edit_form($req, $article, $articles,
                           "You don't have access to save file information for this article");
   my @files = $self->get_files($article);
 
-  my $download_path = $self->{cfg}->entryVar('paths', 'downloads');
+  my $download_path = BSE::TB::ArticleFiles->download_path($self->{cfg});
 
   my $cgi = $req->cgi;
   my %names;
@@ -3266,8 +4369,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");
@@ -3277,6 +4383,8 @@ sub filesave {
     }
     my $notes = $cgi->param("notes_$id");
     defined $notes and $file->{notes} = $notes;
+    my $category = $cgi->param("category_$id");
+    defined $category and $file->{category} = $category;
     my $name = $cgi->param("name_$id");
     if (defined $name) {
       $file->{name} = $name;
@@ -3288,9 +4396,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
@@ -3336,8 +4441,9 @@ 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;
            }
            else {
              $errors{"file_$id"} = $msg;
@@ -3355,6 +4461,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) {
@@ -3369,14 +4484,21 @@ sub filesave {
 
     return $self->edit_form($req, $article, $articles, undef, \%errors);
   }
-  $req->flash('File information saved');
-  my $mgr = $self->_file_manager;
+  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;
 
     my $storage = $cgi->param("storage_$file->{id}");
     defined $storage or $storage = 'local';
-    $storage = $self->_select_filestore($req, $mgr, $storage, $file);
+    my $msg;
+    $storage = $article->select_filestore($mgr, $file, $storage, \$msg);
+    $msg and $req->flash($msg);
     if ($storage ne $file->{storage} || $store_anyway{$file->{id}}) {
       my $old_storage = $file->{storage};
       eval {
@@ -3406,18 +4528,176 @@ sub filesave {
     unlink "$download_path/$filename";
   }
 
-  use Util 'generate_article';
+  # update file type metadatas
+  for my $file (@content_changed) {
+    $file->set_handler($self->{cfg});
+    $file->save;
+  }
+
   generate_article($articles, $article) if $Constants::AUTO_GENERATE;
 
   $self->_refresh_filelist($req, $article);
 }
 
+sub req_filemeta {
+  my ($self, $req, $article, $articles, $errors) = @_;
+
+  my $cgi = $req->cgi;
+
+  my $id = $cgi->param('file_id');
+
+  my ($file) = grep $_->{id} == $id, $self->get_files($article)
+    or return $self->edit_form($req, $article, $articles,
+                              "No such file");
+  $req->user_can(edit_files_save => $article)
+    or return $self->edit_form($req, $article, $articles,
+                              "You don't have access to save file information for this article");
+
+  my $name = $cgi->param('name');
+  $name && $name =~ /^\w+$/
+    or return $self->edit_form($req, $article, $articles,
+                              "Missing or invalid metadata name");
+
+  my $meta = $file->meta_by_name($name)
+    or return $self->edit_form($req, $article, $articles,
+                              "Metadata $name not defined for this file");
+
+  return
+    {
+     type => $meta->content_type,
+     content => $meta->value,
+    };
+}
+
 sub tag_old_checked {
   my ($errors, $cgi, $file, $key) = @_;
 
   return $errors ? $cgi->param($key) : $file->{$key};
 }
 
+sub tag_filemeta_value {
+  my ($file, $args, $acts, $funcname, $templater) = @_;
+
+  my ($name) = DevHelp::Tags->get_parms($args, $acts, $templater)
+    or return "* no meta name supplied *";
+
+  my $meta = $file->meta_by_name($name)
+    or return "";
+
+  $meta->content_type eq "text/plain"
+    or return "* $name has type " . $meta->content_type . " and cannot be displayed inline *";
+
+  return escape_html($meta->value);
+}
+
+sub tag_ifFilemeta_set {
+  my ($file, $args, $acts, $funcname, $templater) = @_;
+
+  my ($name) = DevHelp::Tags->get_parms($args, $acts, $templater)
+    or return "* no meta name supplied *";
+
+  my $meta = $file->meta_by_name($name)
+    or return 0;
+
+  return 1;
+}
+
+sub tag_filemeta_source {
+  my ($file, $args, $acts, $funcname, $templater) = @_;
+
+  my ($name) = DevHelp::Tags->get_parms($args, $acts, $templater)
+    or return "* no meta name supplied *";
+
+  return "$ENV{SCRIPT_NAME}?a_filemeta=1&amp;id=$file->{articleId}&amp;file_id=$file->{id}&amp;name=$name";
+}
+
+sub tag_filemeta_select {
+  my ($cgi, $allmeta, $rcurr_meta, $file, $args, $acts, $funcname, $templater) = @_;
+
+  my $meta;
+  if ($args =~ /\S/) {
+    my ($name) = DevHelp::Tags->get_parms($args, $acts, $templater)
+      or return "* cannot parse *";
+    ($meta) = grep $_->name eq $name, @$allmeta
+      or return "* cannot find meta field *";
+  }
+  elsif ($$rcurr_meta) {
+    $meta = $$rcurr_meta;
+  }
+  else {
+    return "* use in filemeta iterator or supply a name *";
+  }
+
+  $meta->type eq "enum"
+    or return "* can only use filemeta_select on enum metafields *";
+
+  my %labels;
+  my @values = $meta->values;
+  @labels{@values} = $meta->labels;
+
+  my $field_name = "meta_" . $meta->name;
+  my ($def) = $cgi->param($field_name);
+  unless (defined $def) {
+    my $value = $file->meta_by_name($meta->name);
+    if ($value && $value->is_text) {
+      $def = $value->value;
+    }
+  }
+  defined $def or $def = $values[0];
+
+  return popup_menu
+    (
+     -name => $field_name,
+     -values => \@values,
+     -labels => \%labels,
+     -default => $def,
+    );
+}
+
+sub tag_filemeta_select_label {
+  my ($allmeta, $rcurr_meta, $file, $args, $acts, $funcname, $templater) = @_;
+
+  my $meta;
+  if ($args =~ /\S/) {
+    my ($name) = DevHelp::Tags->get_parms($args, $acts, $templater)
+      or return "* cannot parse *";
+    ($meta) = grep $_->name eq $name, @$allmeta
+      or return "* cannot find meta field *";
+  }
+  elsif ($$rcurr_meta) {
+    $meta = $$rcurr_meta;
+  }
+  else {
+    return "* use in filemeta iterator or supply a name *";
+  }
+
+  $meta->type eq "enum"
+    or return "* can only use filemeta_select_label on enum metafields *";
+
+  my %labels;
+  my @values = $meta->values;
+  @labels{@values} = $meta->labels;
+
+  my $field_name = "meta_" . $meta->name;
+  my $value = $file->meta_by_name($meta->name);
+  if ($value) {
+    if ($value->is_text) {
+      if (exists $labels{$value->value}) {
+       return escape_html($labels{$value->value});
+      }
+      else {
+       return escape_html($value->value);
+      }
+    }
+    else {
+      return "* cannot display type " . $value->content_type . " inline *";
+    }
+  }
+  else {
+    return "* " . $meta->name . " not set *";
+  }
+}
+
 sub req_edit_file {
   my ($self, $req, $article, $articles, $errors) = @_;
 
@@ -3432,15 +4712,39 @@ sub req_edit_file {
     or return $self->edit_form($req, $article, $articles,
                               "You don't have access to save file information for this article");
 
+  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;
   %acts =
     (
      $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 ],
+     $it->make
+     (
+      plural => "filemetas",
+      single => "filemeta",
+      data => \@metafields,
+      store => \$current_meta,
+     ),
+     filemeta_value =>
+     [ \&tag_filemeta_value, $file ],
+     ifFilemeta_set =>
+     [ \&tag_ifFilemeta_set, $file ],
+     filemeta_source =>
+     [ \&tag_filemeta_source, $file ],
+     filemeta_select =>
+     [ \&tag_filemeta_select, $cgi, \@metafields, \$current_meta, $file ],
+     filemeta_select_label =>
+     [ \&tag_filemeta_select_label, \@metafields, \$current_meta, $file ],
     );
 
   return $req->response('admin/file_edit', \%acts);
@@ -3449,6 +4753,9 @@ sub req_edit_file {
 sub req_save_file {
   my ($self, $req, $article, $articles) = @_;
 
+  $req->check_csrf("admin_save_file")
+    or return $self->csrf_error($req, $article, "admin_save_file", "Save File");
+
   my $cgi = $req->cgi;
 
   my @files = $self->get_files($article);
@@ -3463,7 +4770,7 @@ sub req_save_file {
                               "You don't have access to save file information for this article");
   my @other_files = grep $_->{id} != $id, @files;
 
-  my $download_path = $self->{cfg}->entryVar('paths', 'downloads');
+  my $download_path = BSE::TB::ArticleFiles->download_path($self->{cfg});
 
   my %errors;
 
@@ -3482,6 +4789,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) {
@@ -3494,12 +4802,11 @@ 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";
-    }
   }
 
+  require BSE::FileMetaMeta;
+  my $meta = BSE::FileMetaMeta->retrieve($req, $file, \%errors);
+
   if ($cgi->param('save_file_flags')) {
     my $download = 0 + defined $cgi->param("download");
     if ($download ne $file->{download}) {
@@ -3540,7 +4847,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';
        }
@@ -3565,12 +4872,17 @@ sub req_save_file {
   }
   $file->save;
 
-  $req->flash('File information saved');
-  my $mgr = $self->_file_manager;
+  $file->set_handler($self->cfg);
+  $file->save;
+
+  $req->flash("msg:bse/admin/edit/file/save/success", [ $file->displayName ]);
+  my $mgr = $self->_file_manager($self->cfg);
 
   my $storage = $cgi->param('storage');
   defined $storage or $storage = $file->{storage};
-  $storage = $self->_select_filestore($req, $mgr, $storage, $file);
+  my $msg;
+  $storage = $article->select_filestore($mgr, $file, $storage, \$msg);
+  $msg and $req->flash($msg);
   if ($storage ne $file->{storage} || $store_anyway) {
     my $old_storage = $file->{storage};
     eval {
@@ -3585,71 +4897,123 @@ sub req_save_file {
       and $req->flash("Could not move $file->{displayName} to $storage: $@");
   }
 
+  BSE::FileMetaMeta->save($file, $meta);
+
   # remove the replaced files
   if (my ($old_name, $old_storage) = @old_file) {
     $mgr->unstore($old_name, $old_storage);
     unlink "$download_path/$old_name";
   }
 
-  use Util 'generate_article';
   generate_article($articles, $article) if $Constants::AUTO_GENERATE;
 
   $self->_refresh_filelist($req, $article);
 }
 
 sub can_remove {
-  my ($self, $req, $article, $articles, $rmsg) = @_;
+  my ($self, $req, $article, $articles, $rmsg, $rcode) = @_;
 
   unless ($req->user_can('edit_delete_article', $article, $rmsg)) {
     $$rmsg ||= "Access denied";
+    $$rcode = "ACCESS";
     return;
   }
 
   if ($articles->children($article->{id})) {
     $$rmsg = "This article has children.  You must delete the children first (or change their parents)";
+    $$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;
   }
   if ($article->{id} == $Constants::SHOPID) {
     $$rmsg = "Sorry, these pages are essential to the store - they cannot be deleted - you may want to hide the store instead.";
+    $$rcode = "SHOP";
     return;
   }
 
   return 1;
 }
 
+=item remove
+
+Error codes:
+
+=over
+
+=item *
+
+ACCESS - access denied
+
+=item *
+
+CHILDREN - the article has children
+
+=item *
+
+ESSENTIAL - the article is marked essential
+
+=item *
+
+SHOP - the article is an essential part of the shop (the shop article
+itself)
+
+=back
+
+JSON success response: { success: 1, article_id: I<id> }
+
+=cut
+
 sub remove {
   my ($self, $req, $article, $articles) = @_;
 
+  $req->check_csrf("admin_remove_article")
+    or return $self->csrf_error($req, $article, "admin_remove_article", "Remove Article");
+
   my $why_not;
-  unless ($self->can_remove($req, $article, $articles, \$why_not)) {
-    return $self->edit_form($req, $article, $articles, $why_not);
+  my $code;
+  unless ($self->can_remove($req, $article, $articles, \$why_not, \$code)) {
+    return $self->_service_error($req, $article, $articles, $why_not, {}, $code);
   }
 
+  my $data = $article->data_only;
+
   my $parentid = $article->{parentid};
   $article->remove($req->cfg);
 
+  if ($req->is_ajax) {
+    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});
 }
 
 sub unhide {
   my ($self, $req, $article, $articles) = @_;
 
+  $req->check_csrf("admin_save_article")
+    or return $self->csrf_error($req, $article, "admin_save_article", "Unhide article");
+
   if ($req->user_can(edit_field_edit_listed => $article)
       && $req->user_can(edit_save => $article)) {
     $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');
@@ -3658,12 +5022,14 @@ sub unhide {
 sub hide {
   my ($self, $req, $article, $articles) = @_;
 
+  $req->check_csrf("admin_save_article")
+    or return $self->csrf_error($req, $article, "admin_save_article", "Hide article");
+
   if ($req->user_can(edit_field_edit_listed => $article)
       && $req->user_can(edit_save => $article)) {
     $article->{listed} = 0;
     $article->save;
 
-    use Util 'generate_article';
     generate_article($articles, $article) if $Constants::AUTO_GENERATE;
   }
   my $r = $req->cgi->param('r');
@@ -3687,6 +5053,9 @@ my %defaults =
    menu => 0,
    titleAlias => '',
    linkAlias => '',
+   author => '',
+   summary => '',
+   category => '',
   );
 
 sub default_value {
@@ -3712,7 +5081,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};
     
@@ -3721,7 +5090,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};
     
@@ -3836,10 +5205,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});
      }
    }
  
@@ -3860,6 +5229,17 @@ sub req_ajax_save_body {
      };
 }
 
+sub iter_file_metas {
+  my ($self, $files, $rfile_index) = @_;
+
+  $$rfile_index < 0 || $$rfile_index >= @$files
+    and return;
+
+  my $file = $files->[$$rfile_index];
+
+  return $file->text_metadata;
+}
+
 my %settable_fields = qw(title keyword author pageTitle);
   
 
@@ -3923,10 +5303,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});
      }
    }
  
@@ -3943,7 +5323,256 @@ sub csrf_error {
   my %errors;
   my $msg = $req->csrf_error;
   $errors{_csrfp} = $msg;
-  return $self->_service_error($req, $article, 'Articles', $msg, \%errors);
+  my $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, 'BSE::TB::Articles', $mymsg);
+  }
+  return $self->_service_error($req, $article, 'BSE::TB::Articles', $msg, \%errors);
+}
+
+=item a_csrp
+
+Returns the csrf token for a given action.
+
+Must only be callable from Ajax requests.
+
+In general Ajax requests won't require a token, but some types of
+requests initiated by an Ajax based client might need a token, in
+particular: file uploads.
+
+=cut
+
+sub req_csrfp {
+  my ($self, $req, $article, $articles) = @_;
+
+  $req->is_ajax
+    or return $self->_service_error($req, $article, $articles,
+                                   "Only usable from Ajax", undef, "NOTAJAX");
+
+  $ENV{REQUEST_METHOD} eq 'POST'
+    or return $self->_service_error($req, $article, "BSE::TB::Articles",
+                                   "POST required for this action", {}, "NOTPOST");
+
+  my %errors;
+  my (@names) = $req->cgi->param("name");
+  @names or $errors{name} = "Missing parameter 'name'";
+  unless ($errors{name}) {
+    for my $name (@names) {
+      $name =~ /^\w+\z/
+       or $errors{name} = "Invalid name: must be an identifier";
+    }
+  }
+
+  keys %errors
+    and return $self->_service_error($req, $article, $articles,
+                                    "Invalid parameter", \%errors, "FIELD");
+
+  return $req->json_content
+    (
+     {
+      success => 1,
+      tokens =>
+      {
+       map { $_ => $req->get_csrf_token($_) } @names,
+      },
+     },
+    );
+}
+
+sub _article_kid_summary {
+  my ($article_id, $depth) = @_;
+
+  my @kids = BSE::DB->query(bseArticleKidSummary => $article_id);
+  if (--$depth > 0) {
+    for my $kid (@kids) {
+      $kid->{children} = [ _article_kid_summary($kid->{id}, $depth) ];
+      $kid->{allkids} = [ BSE::TB::Articles->allkid_summary($kid->{id}) ];
+    }
+  }
+
+  return @kids;
+}
+
+=item a_tree
+
+Returns a JSON tree of articles.
+
+Requires an article id (-1 to start from the root).
+
+Takes an optional tree depth.  1 only shows immediate children of the
+article.
+
+=cut
+
+sub req_tree {
+  my ($self, $req, $article, $articles) = @_;
+
+  my $depth = $req->cgi->param("depth");
+  defined $depth && $depth =~ /^\d+$/ and $depth >= 1
+    or $depth = 10000; # something large
+
+  $req->is_ajax
+    or return $self->_service_error($req, $article, $articles, "Only available to Ajax requests", {}, "NOTAJAX");
+
+  return $req->json_content
+    (
+     success => 1,
+     articles =>
+     [
+      _article_kid_summary($article->id, $depth),
+     ],
+     allkids =>
+     [
+      BSE::TB::Articles->allkid_summary($article->id)
+     ],
+    );
+}
+
+=item a_article
+
+Returns the article as JSON.
+
+Populates images with images and files with files.
+
+The article data is in the article member of the returned object.
+
+=cut
+
+sub req_article {
+  my ($self, $req, $article, $articles) = @_;
+
+  $req->is_ajax
+    or return $self->_service_error($req, $article, $articles, "Only available to Ajax requests", {}, "NOTAJAX");
+
+  return $req->json_content
+    (
+     success => 1,
+     article => $self->_article_data($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) = @_;
+
+  my $cfg = $req->cfg;
+  my %geos = $cfg->entries("thumb geometries");
+  my %defaults;
+  my @cols = $self->table_object($articles)->rowClass->columns;
+  shift @cols;
+  for my $col (@cols) {
+    my $def = $self->default_value($req, $article, $col);
+    defined $def and $defaults{$col} = $def;
+  }
+  my @templates = $self->templates($article);
+  $defaults{template} =
+    $self->default_template($article, $req->cfg, \@templates);
+
+  $conf->{templates} = [ $self->templates_long($article) ];
+  $conf->{thumb_geometries} =
+    [
+     map
+     {
+       +{
+        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
+
+Returns configuration information as JSON.
+
+Returns an object of the form:
+
+  {
+    success: 1,
+    templates:
+    [
+      "template.tmpl":
+      {
+        description: "template.tmpl", // or from [template descriptions]
+      },
+      ...
+    ],
+    thumb_geometries:
+    [
+      "geoid":
+      {
+        description: "geoid", // or from [thumb geometry id].description
+      },
+    ],
+    defaults:
+    {
+      field: value,
+      ...
+    },
+    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 {
+  my ($self, $req, $article, $articles) = @_;
+  
+  $req->is_ajax
+    or return $self->_service_error($req, $article, $articles, "Only available to Ajax requests", {}, "NOTAJAX");
+
+  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
+    );
 }
 
 1;