diff --git css/export-ui-list.css css/export-ui-list.css
new file mode 100644
index 0000000..3a778ab
--- /dev/null
+++ css/export-ui-list.css
@@ -0,0 +1,28 @@
+/* $Id: panels-item.css,v 1.1.2.1 2010/02/17 01:09:46 merlinofchaos Exp $ */
+body form#ctools-export-ui-list-form {
+  margin: 0 0 20px 0;
+}
+
+#ctools-export-ui-list-form .form-item {
+  padding-right: 1em; /* LTR */
+  float: left; /* LTR */
+  margin-top: 0;
+  margin-bottom: 0;
+}
+
+#ctools-export-ui-list-items {
+  width: 100%;
+}
+
+#edit-order-wrapper {
+  clear: left; /* LTR */
+}
+
+#ctools-export-ui-list-form .form-submit {
+  margin-top: 1.65em;
+  float: left; /* LTR */
+}
+
+tr.ctools-export-ui-disabled {
+  color: #999;
+}
diff --git ctools.module ctools.module
index ad3cb4e..a703d73 100644
--- ctools.module
+++ ctools.module
@@ -284,7 +284,7 @@ function _ctools_passthrough(&$items, $type = 'theme') {
     require_once './' . $file->filename;
     list($tool) = explode('.', $file->name, 2);
 
-    $function = 'ctools_' . $tool . '_' . $type;
+    $function = 'ctools_' . str_replace ('-', '_', $tool) . '_' . $type;
     if (function_exists($function)) {
       $function($items);
     }
@@ -760,3 +760,38 @@ function ctools_file_check_directory(&$directory, $mode = 0, $form_item = NULL)
 
   return TRUE;
 }
+
+/**
+ * Menu loader; Load exportables when used with export-ui.
+ */
+function ctools_export_ui_load($item_name, $plugin_name) {
+  $return = &ctools_static(__FUNCTION__, FALSE);
+
+  if (!$return) {
+    ctools_include('export-ui');
+    $plugin = ctools_get_export_ui($plugin_name);
+
+    if ($plugin) {
+      // Get the load callback.
+      return ctools_export_crud_load($plugin['schema'], $item_name);
+    }
+  }
+
+  return $return;
+}
+
+/**
+ * Menu access callback for various tasks of export-ui.
+ */
+function ctools_export_ui_task_access($plugin_name, $op, $item = NULL) {
+  ctools_include('export-ui');
+  $plugin = ctools_get_export_ui($plugin_name);
+  $handler = ctools_export_ui_get_handler($plugin);
+
+  if ($handler) {
+    return $handler->access($op, $item);
+  }
+
+  // Deny access if the handler cannot be found.
+  return FALSE;
+}
diff --git includes/export-ui.inc includes/export-ui.inc
new file mode 100644
index 0000000..3227df3
--- /dev/null
+++ includes/export-ui.inc
@@ -0,0 +1,407 @@
+<?php
+// $Id: $
+
+/**
+ * Implementation of hook_ctools_plugin_*.
+ *
+ * Give information to CTools about the content types plugin.
+ */
+function ctools_ctools_plugin_export_ui() {
+  return array(
+    'defaults' => 'ctools_export_ui_defaults',
+  );
+}
+
+/**
+ * Provide defaults for an export-ui plugin.
+ */
+function ctools_export_ui_defaults($info, &$plugin) {
+  ctools_include('export');
+
+  $plugin += array(
+    'has menu' => TRUE,
+    'title' => $plugin['name'],
+    'export' => array(),
+    'allowed operations' => array(),
+    'menu' => array(),
+    'form' => array(),
+    'list' => NULL,
+    'access' => 'administer site configuration',
+    'handler' => array(
+      'class' => 'ctools_export_ui',
+    ),
+  );
+
+  if (empty($plugin['schema'])) {
+    if ($plugin['has menu']) {
+      // We need to issue a warning as schema is a required key.
+      drupal_set_message(t('The plugin definition of @plugin is missing the "schema" key.', array('@plugin' => $plugin['name'])), 'error');
+    }
+  }
+  else {
+    $schema = ctools_export_get_schema($plugin['schema']);
+
+    $plugin['export'] += array(
+      // Add the identifier key from the schema so we don't have to call
+      // ctools_export_get_schema() just for that.
+      'key' => $schema['export']['key'],
+    );
+
+    // Add some default fields that appear often in exports
+    // If these use different keys they can easily be specified in the
+    // $plugin.
+
+    if (empty($plugin['export']['admin_title']) && !empty($schema['fields']['admin_title'])) {
+      $plugin['export']['admin_title'] = 'admin_title';
+    }
+    if (empty($plugin['export']['admin_description']) && !empty($schema['fields']['admin_description'])) {
+      $plugin['export']['admin_description'] = 'admin_description';
+    }
+  }
+
+  // Define allowed operations, and the name of the operations.
+  $plugin['allowed operations'] += array(
+    'edit'    => array('title' => t('Edit')),
+    'enable'  => array('title' => t('Enable'), 'ajax' => TRUE, 'token' => TRUE),
+    'disable' => array('title' => t('Disable'), 'ajax' => TRUE, 'token' => TRUE),
+    'revert'  => array('title' => t('Revert')),
+    'delete'  => array('title' => t('Delete')),
+    'clone'   => array('title' => t('Clone')),
+    'import'  => array('title' => t('Import')),
+    'export'  => array('title' => t('Export')),
+  );
+
+  if ($plugin['has menu']) {
+    $plugin['menu'] += array(
+      'menu item' => str_replace(' ', '-', $plugin['name']),
+      'menu prefix' => 'admin/build',
+    );
+
+    $prefix_count = count(explode('/', $plugin['menu']['menu prefix']));
+
+    $plugin['menu'] += array(
+      // Default menu items that should be declared.
+      'items' => array(),
+    );
+
+    $plugin['menu']['items'] += array(
+      'list callback' => array(
+        'path' => '',
+        // Menu items are translated by the menu system.
+        // TODO: We need more flexibility in title. The title of the admin page
+        // is not necessarily the title of the object, plus we need
+        // plural, singular, proper, not proper, etc.
+        'title' => $plugin['title'],
+        'description' => 'List ' . $plugin['title'],
+        'page callback' => 'ctools_export_ui_switcher_page',
+        'page arguments' => array($plugin['name'], 'list'),
+        'access callback' => 'ctools_export_ui_task_access',
+        'access arguments' => array($plugin['name'], 'list'),
+        'type' => MENU_NORMAL_ITEM,
+      ),
+      'list' => array(
+        'path' => 'list',
+        'title' => 'List',
+        'description' => 'List ' . $plugin['title'],
+        'page callback' => 'ctools_export_ui_switcher_page',
+        'page arguments' => array($plugin['name'], 'list'),
+        'access callback' => 'ctools_export_ui_task_access',
+        'access arguments' => array($plugin['name'], 'list'),
+        'type' => MENU_DEFAULT_LOCAL_TASK,
+      ),
+      'add' => array(
+        'path' => 'add',
+        'title' => 'Add',
+        'description' => 'Add a new ' . $plugin['title'],
+        'page callback' => 'ctools_export_ui_switcher_page',
+        'page arguments' => array($plugin['name'], 'add'),
+        'access callback' => 'ctools_export_ui_task_access',
+        'access arguments' => array($plugin['name'], 'add'),
+        'type' => MENU_LOCAL_TASK,
+      ),
+      'edit callback' => array(
+        'path' => 'list/%ctools_export_ui',
+        'page callback' => 'ctools_export_ui_switcher_page',
+        'page arguments' => array($plugin['name'], 'edit', $prefix_count + 2),
+        'load arguments' => array($plugin['name']),
+        'access callback' => 'ctools_export_ui_task_access',
+        'access arguments' => array($plugin['name'], 'edit', $prefix_count + 2),
+        'type' => MENU_CALLBACK,
+      ),
+      'edit' => array(
+        'path' => 'list/%ctools_export_ui/edit',
+        'title' => 'Edit',
+        'page callback' => 'ctools_export_ui_switcher_page',
+        'page arguments' => array($plugin['name'], 'edit', $prefix_count + 2),
+        'load arguments' => array($plugin['name']),
+        'access callback' => 'ctools_export_ui_task_access',
+        'access arguments' => array($plugin['name'], 'edit', $prefix_count + 2),
+        'type' => MENU_DEFAULT_LOCAL_TASK,
+      ),
+    );
+
+    if ($plugin['allowed operations']['import']) {
+      $plugin['menu']['items'] += array(
+        'import' => array(
+          'path' => 'import',
+          'title' => 'Import',
+          'description' => 'Import ' . $plugin['title'] . ' to your site.',
+          // We allow permissions to import only to users that are allowed to
+          // execute php.
+          'page callback' => 'ctools_export_ui_switcher_page',
+          'page arguments' => array($plugin['name'], 'import'),
+          'access callback' => 'ctools_export_ui_task_access',
+          'access arguments' => array($plugin['name'], 'import'),
+          'type' => MENU_LOCAL_TASK,
+        ),
+      );
+    }
+
+    if ($plugin['allowed operations']['export']) {
+      $plugin['menu']['items'] += array(
+        'export' => array(
+          'path' => 'list/%ctools_export_ui/export',
+          'title' => 'Export',
+          'description' => 'Export ' . $plugin['title'],
+          'page callback' => 'ctools_export_ui_switcher_page',
+          'page arguments' => array($plugin['name'], 'export', $prefix_count + 2),
+          'load arguments' => array($plugin['name']),
+          'access callback' => 'ctools_export_ui_task_access',
+          'access arguments' => array($plugin['name'], 'export', $prefix_count + 2),
+          'type' => MENU_LOCAL_TASK,
+        ),
+      );
+    }
+
+    if ($plugin['allowed operations']['revert']) {
+      $plugin['menu']['items'] += array(
+        'revert' => array(
+          'path' => 'list/%ctools_export_ui/revert',
+          'title' => 'Revert',
+          'description' => 'Revert ' . $plugin['title'],
+          'page callback' => 'ctools_export_ui_switcher_page',
+          // Note: Yes, 'delete' op is correct.
+          'page arguments' => array($plugin['name'], 'delete', $prefix_count + 2),
+          'load arguments' => array($plugin['name']),
+          'access callback' => 'ctools_export_ui_task_access',
+          'access arguments' => array($plugin['name'], 'revert', $prefix_count + 2),
+          'type' => MENU_CALLBACK,
+        ),
+      );
+    }
+
+    if ($plugin['allowed operations']['delete']) {
+      $plugin['menu']['items'] += array(
+        'delete' => array(
+          'path' => 'list/%ctools_export_ui/delete',
+          'title' => 'Delete',
+          'description' => 'Delete ' . $plugin['title'],
+          'page callback' => 'ctools_export_ui_switcher_page',
+          'page arguments' => array($plugin['name'], 'delete', $prefix_count + 2),
+          'load arguments' => array($plugin['name']),
+          'access callback' => 'ctools_export_ui_task_access',
+          'access arguments' => array($plugin['name'], 'delete', $prefix_count + 2),
+          'type' => MENU_CALLBACK,
+        ),
+      );
+    }
+
+    if ($plugin['allowed operations']['clone']) {
+      $plugin['menu']['items'] += array(
+        'clone' => array(
+          'path' => 'list/%ctools_export_ui/clone',
+          'title' => 'Clone',
+          'description' => 'Revert ' . $plugin['title'],
+          'page callback' => 'ctools_export_ui_switcher_page',
+          'page arguments' => array($plugin['name'], 'clone', $prefix_count + 2),
+          'load arguments' => array($plugin['name']),
+          'access callback' => 'ctools_export_ui_task_access',
+          'access arguments' => array($plugin['name'], 'clone', $prefix_count + 2),
+          'type' => MENU_CALLBACK,
+        ),
+      );
+    }
+
+    if ($plugin['allowed operations']['enable']) {
+      $plugin['menu']['items'] += array(
+        'enable' => array(
+          'path' => 'list/%ctools_export_ui/enable',
+          'title' => 'Enable',
+          'description' => 'Enable ' . $plugin['title'],
+          'page callback' => 'ctools_export_ui_switcher_page',
+          'page arguments' => array($plugin['name'], 'enable', $prefix_count + 2),
+          'load arguments' => array($plugin['name']),
+          'access callback' => 'ctools_export_ui_task_access',
+          'access arguments' => array($plugin['name'], 'enable', $prefix_count + 2),
+          'type' => MENU_CALLBACK,
+        ),
+      );
+    }
+
+    if ($plugin['allowed operations']['disable']) {
+      $plugin['menu']['items'] += array(
+        'disable' => array(
+          'path' => 'list/%ctools_export_ui/disable',
+          'title' => 'Disable',
+          'description' => 'Disable ' . $plugin['title'],
+          'page callback' => 'ctools_export_ui_switcher_page',
+          'page arguments' => array($plugin['name'], 'disable', $prefix_count + 2),
+          'load arguments' => array($plugin['name']),
+          'access callback' => 'ctools_export_ui_task_access',
+          'access arguments' => array($plugin['name'], 'disable', $prefix_count + 2),
+          'type' => MENU_CALLBACK,
+        ),
+      );
+    }
+  }
+
+  // Define form elements.
+  $plugin['form'] += array(
+    'settings' => function_exists($plugin['name'] . '_form') ? $plugin['name'] . '_form' : '',
+    'string' => array(
+      // Strings used in drupal_set_title().
+      'title' => array(
+        'add' => t('Add a new @plugin', array('@plugin' => $plugin['title'])),
+        // The "%title" will be replaced in ctools_export_ui_form(), as in this
+        // stage we dont have the specific exportable object.
+        'edit' => t('Editing @plugin %title', array('@plugin' => $plugin['title'])),
+        'view' => t('Viewing @plugin %title', array('@plugin' => $plugin['title'])),
+        'clone' => t('Cloning @plugin %title', array('@plugin' => $plugin['title'])),
+
+        'import' => t('Import @plugin', array('@plugin' => $plugin['title'])),
+        'export' => t('Export @plugin', array('@plugin' => $plugin['title'])),
+      ),
+      // Strings used in confirmation pages.
+      'confirmation' => array(
+         // The "!action" and "%title" will be replaced in
+         // ctools_export_ui_form().
+        'question' => t('Are you sure you want to !action the @plugin %title?', array('@plugin' => $plugin['title'])),
+        'revert' => t('This action will permanently remove any customizations made to this item.'),
+        'delete' => t('This action will remove this item permanently from your site.'),
+      ),
+      // Strings used in $forms.
+      'help' => array(
+        'import' => t('You can import an exported definition by pasting the exported object code into the field below.'),
+      ),
+      // Strings used in drupal_set_message().
+      'message' => array(
+        'enable' => t('@plugin %title was enabled.', array('@plugin' => $plugin['title'])),
+        'disable' => t('@plugin %title was disabled.', array('@plugin' => $plugin['title'])),
+      ),
+    ),
+  );
+}
+
+/**
+ * Get the class to handle creating a list of exportable items.
+ *
+ * @return
+ *   Either the lister class or FALSE if one could not be had.
+ */
+function ctools_export_ui_get_handler($plugin) {
+  $cache = &ctools_static(__FUNCTION__, array());
+  if (empty($cache[$plugin['name']])) {
+    // If a list class is not specified by the plugin, fall back to the
+    // default ctools_export_ui plugin instead.
+    if (empty($plugin['list'])) {
+      $default = ctools_get_export_ui('ctools_export_ui');
+      $class = ctools_plugin_get_class($default, 'handler');
+    }
+    else {
+      $class = ctools_plugin_get_class($plugin, 'handler');
+    }
+
+    if ($class) {
+      $cache[$plugin['name']] = new $class();
+      $cache[$plugin['name']]->init($plugin);
+    }
+  }
+  return !empty($cache[$plugin['name']]) ? $cache[$plugin['name']] : FALSE;
+}
+
+/**
+ * Get the base path from a plugin.
+ *
+ * @param $plugin
+ *   The plugin.
+ *
+ * @return
+ *   The menu path to the plugin's list.
+ */
+function ctools_export_ui_plugin_base_path($plugin) {
+  return $plugin['menu']['menu prefix'] . '/' . $plugin['menu']['menu item'];
+}
+
+/**
+ * Get the path to a specific menu item from a plugin.
+ *
+ * @param $plugin
+ *   The plugin name.
+ * @param $item_id
+ *   The id in the menu items from the plugin.
+ * @param $export_key
+ *   The export key of the item being edited, if it exists.
+ * @return
+ *   The menu path to the plugin's list.
+ */
+function ctools_export_ui_plugin_menu_path($plugin, $item_id, $export_key = NULL) {
+  $path = $plugin['menu']['items'][$item_id]['path'];
+  if ($export_key) {
+    $path = str_replace('%ctools_export_ui', $export_key, $path);
+  }
+  return ctools_export_ui_plugin_base_path($plugin) . '/' . $path;
+}
+
+/**
+ * Helper function to include CTools plugins and get an export-ui exportable.
+ *
+ * @param $plugin_name
+ *   The plugin that should be laoded.
+ */
+function ctools_get_export_ui($plugin_name) {
+  ctools_include('plugins');
+  return ctools_get_plugins('ctools', 'export_ui', $plugin_name);
+
+}
+
+/**
+ * Helper function to include CTools plugins and get all export-ui exportables.
+ */
+function ctools_get_export_uis() {
+  ctools_include('plugins');
+  return ctools_get_plugins('ctools', 'export_ui');
+}
+
+/**
+ * Main page callback to manipulate exportables.
+ *
+ * This simply loads the object defined in the plugin and hands it off to
+ * a method based upon the name of the operation in use. This can easily
+ * be used to add more ops.
+ */
+function ctools_export_ui_switcher_page($plugin_name, $op) {
+  $args = func_get_args();
+  $js = !empty($_REQUEST['ctools_ajax']);
+
+  // Load the $plugin information
+  $plugin = ctools_get_export_ui($plugin_name);
+
+  // If we need to do a token test, do it here.
+  if (!empty($plugin['allowed operations'][$op]['token']) && (!isset($_GET['token']) || !drupal_valid_token($_GET['token'], $op))) {
+    return MENU_ACCESS_DENIED;
+  }
+
+  $handler = ctools_export_ui_get_handler($plugin);
+  if ($handler) {
+    $method = $op . '_page';
+    if (method_exists($handler, $method)) {
+      // replace the first two arguments:
+      $args[0] = $js;
+      $args[1] = $_POST;
+      return call_user_func_array(array($handler, $method), $args);
+    }
+  }
+  else {
+    return t('Configuration error. No handler found.');
+  }
+}
diff --git includes/export-ui.menu.inc includes/export-ui.menu.inc
new file mode 100644
index 0000000..fdeb2aa
--- /dev/null
+++ includes/export-ui.menu.inc
@@ -0,0 +1,16 @@
+<?php
+// $Id: $
+
+/**
+ * Delegated implementation of hook_menu().
+ */
+function ctools_export_ui_menu(&$items) {
+  ctools_include('export-ui');
+
+  foreach (ctools_get_export_uis() as $plugin) {
+    if ($plugin['has menu']) {
+      $handler = ctools_export_ui_get_handler($plugin);
+      $handler->hook_menu($items);
+    }
+  }
+}
diff --git includes/export.inc includes/export.inc
index b465d38..2a5a803 100644
--- includes/export.inc
+++ includes/export.inc
@@ -19,6 +19,246 @@ define('EXPORT_IN_DATABASE', 0x01);
 define('EXPORT_IN_CODE', 0x02);
 
 /**
+ * @defgroup export_crud CRUD functions for export.
+ * @{
+ * export.inc supports a small number of CRUD functions that should always
+ * work for every exportable object, no matter how complicated. These
+ * functions allow complex objects to provide their own callbacks, but
+ * in most cases, the default callbacks will be used.
+ *
+ * Note that defaults are NOT set in the $schema because it is presumed
+ * that a module's personalized CRUD functions will already know which
+ * $table to use and not want to clutter up the arguments with it.
+ */
+
+/**
+ * Create a new object for the given $table.
+ *
+ * @param $table
+ *   The name of the table to use to retrieve $schema values. This table
+ *   must have an 'export' section containing data or this function
+ *   will fail.
+ * @param $set_defaults
+ *   If TRUE, which is the default, then default values will be retrieved
+ *   from schema fields and set on the object.
+ *
+ * @return
+ *   The loaded object.
+ */
+function ctools_export_crud_new($table, $set_defaults = TRUE) {
+  $schema = ctools_export_get_schema($table);
+  $export = $schema['export'];
+
+  if (!empty($export['create callback']) && function_exists($export['create callback'])) {
+    return $export['create callback']($set_defaults);
+  }
+  else {
+    return ctools_export_new_object($table, $set_defaults);
+  }
+}
+
+/**
+ * Load a single exportable object.
+ *
+ * @param $table
+ *   The name of the table to use to retrieve $schema values. This table
+ *   must have an 'export' section containing data or this function
+ *   will fail.
+ * @param $name
+ *   The unique ID to load. The field for this ID will be specified by
+ *   the export key, which normally defaults to 'name'.
+ *
+ * @return
+ *   The loaded object.
+ */
+function ctools_export_crud_load($table, $name) {
+  $schema = ctools_export_get_schema($table);
+  $export = $schema['export'];
+
+  if (!empty($export['load callback']) && function_exists($export['load callback'])) {
+    return $export['load callback']($name);
+  }
+  else {
+    $result = ctools_export_load_object($table, 'names', array($name));
+    if (isset($result[$name])) {
+      return $result[$name];
+    }
+  }
+}
+
+/**
+ * Load all exportable objects of a given type.
+ *
+ * @param $table
+ *   The name of the table to use to retrieve $schema values. This table
+ *   must have an 'export' section containing data or this function
+ *   will fail.
+ * @param $reset
+ *   If TRUE, the static cache of all objects will be flushed prior to
+ *   loading all. This can be important on listing pages where items
+ *   might have changed on the page load.
+ * @return
+ *   An array of all loaded objects, keyed by the unique IDs of the export key.
+ */
+function ctools_export_crud_load_all($table, $reset = FALSE) {
+  $schema = ctools_export_get_schema($table);
+  $export = $schema['export'];
+
+  if ($reset) {
+    ctools_export_load_object_reset($table);
+  }
+
+  if (!empty($export['load all callback']) && function_exists($export['load all callback'])) {
+    return $export['load all callback']($reset);
+  }
+  else {
+    return ctools_export_load_object($table, 'all');
+  }
+}
+
+/**
+ * Save a single exportable object.
+ *
+ * @param $table
+ *   The name of the table to use to retrieve $schema values. This table
+ *   must have an 'export' section containing data or this function
+ *   will fail.
+ * @param $object
+ *   The fully populated object to save.
+ *
+ * @return
+ *   Failure to write a record will return FALSE. Otherwise SAVED_NEW or
+ *   SAVED_UPDATED is returned depending on the operation performed. The
+ *   $object parameter contains values for any serial fields defined by the $table
+ */
+function ctools_export_crud_save($table, &$object) {
+  $schema = ctools_export_get_schema($table);
+  $export = $schema['export'];
+
+  if (!empty($export['save callback']) && function_exists($export['save callback'])) {
+    return $export['save callback']($object);
+  }
+  else {
+    // Objects should have a serial primary key. If not, simply fail to write.
+    if (empty($export['primary key'])) {
+      return FALSE;
+    }
+
+    $key = $export['primary key'];
+    $update = (isset($object->{$key})) ? array($key) : array();
+    return drupal_write_record($table, $object, $update);
+  }
+}
+
+/**
+ * Delete a single exportable object.
+ *
+ * This only deletes from the database, which means that if an item is in
+ * code, then this is actually a revert.
+ *
+ * @param $table
+ *   The name of the table to use to retrieve $schema values. This table
+ *   must have an 'export' section containing data or this function
+ *   will fail.
+ * @param $object
+ *   The fully populated object to delete, or the export key.
+ */
+function ctools_export_crud_delete($table, $object) {
+  $schema = ctools_export_get_schema($table);
+  $export = $schema['export'];
+
+  if (!empty($export['delete callback']) && function_exists($export['delete callback'])) {
+    return $export['delete callback']($object);
+  }
+  else {
+    // If we were sent an object, get the export key from it. Otherwise
+    // assume we were sent the export key.
+    $value = is_object($object) ? $object->{$export['key']} : $object;
+    db_query("DELETE FROM {$table} WHERE " . $export['key'] . " = '%s'", $value);
+  }
+}
+
+/**
+ * Get the exported code of a single exportable object.
+ *
+ * @param $table
+ *   The name of the table to use to retrieve $schema values. This table
+ *   must have an 'export' section containing data or this function
+ *   will fail.
+ * @param $object
+ *   The fully populated object to delete, or the export key.
+ * @param $indent
+ *   Any indentation to apply to the code, in case this object is embedded
+ *   into another, for example.
+ *
+ * @return
+ *   A string containing the executable export of the object.
+ */
+function ctools_export_crud_export($table, $object, $indent = '') {
+  $schema = ctools_export_get_schema($table);
+  $export = $schema['export'];
+
+  if (!empty($export['export callback']) && function_exists($export['export callback'])) {
+    return $export['export callback']($object, $indent);
+  }
+  else {
+    return ctools_export_object($table, $object, $indent);
+  }
+}
+
+/**
+ * Turn exported code into an object.
+ *
+ * Note: If the code is poorly formed, this could crash and there is no
+ * way to prevent this.
+ *
+ * @param $table
+ *   The name of the table to use to retrieve $schema values. This table
+ *   must have an 'export' section containing data or this function
+ *   will fail.
+ * @param $code
+ *   The code to eval to create the object.
+ *
+ * @return
+ *   An object created from the export. This object will NOT have been saved
+ *   to the database. In the case of failure, a string containing all errors
+ *   that the system was able to determine.
+ */
+function ctools_export_crud_import($table, $code) {
+  $schema = ctools_export_get_schema($table);
+  $export = $schema['export'];
+
+  if (!empty($export['import callback']) && function_exists($export['import callback'])) {
+    return $export['import callback']($object, $indent);
+  }
+  else {
+    ob_start();
+    eval($code);
+    ob_end_clean();
+
+    if (empty(${$export['identifier']})) {
+      $errors = ob_get_contents();
+      if (empty($errors)) {
+        $errors = t('No item found.');
+      }
+      return $errors;
+    }
+
+    $item = ${$export['identifier']};
+
+    // Set these defaults just the same way that ctools_export_new_object sets them.
+    $item->export_type = EXPORT_IN_DATABASE;
+    $item->type = t('Local');
+
+    return $item;
+  }
+}
+
+/**
+ * @}
+ */
+
+/**
  * Load some number of exportable objects.
  *
  * This function will cache the objects, load subsidiary objects if necessary,
@@ -442,27 +682,48 @@ function ctools_export_object($table, $object, $indent = '', $identifier = NULL,
  * that it's easily available.
  */
 function ctools_export_get_schema($table) {
-  $schema = drupal_get_schema($table);
-
-  if (!isset($schema['export'])) {
-    $schema['export'] = array();
-  }
-
-  // Add some defaults
-  $schema['export'] += array(
-    'key' => 'name',
-    'object' => 'stdClass',
-    'status' => 'default_' . $table,
-    'default hook' => 'default_' . $table,
-    'can disable' => TRUE,
-    'identifier' => $table,
-    'bulk export' => TRUE,
-    'export callback' => "$schema[module]_export_{$table}",
-    'list callback' => "$schema[module]_{$table}_list",
-    'to hook code callback' => "$schema[module]_{$table}_to_hook_code",
-  );
+  $cache = &ctools_static(__FUNCTION__);
+  if (empty($cache[$table])) {
+    $schema = drupal_get_schema($table);
+
+    if (!isset($schema['export'])) {
+      $schema['export'] = array();
+    }
 
-  return $schema;
+    // Add some defaults
+    $schema['export'] += array(
+      'key' => 'name',
+      'key name' => t('Name'),
+      'object' => 'stdClass',
+      'status' => 'default_' . $table,
+      'default hook' => 'default_' . $table,
+      'can disable' => TRUE,
+      'identifier' => $table,
+      'bulk export' => TRUE,
+      'list callback' => "$schema[module]_{$table}_list",
+      'to hook code callback' => "$schema[module]_{$table}_to_hook_code",
+    );
+
+    // Notes:
+    // The following callbacks may be defined to override default behavior
+    // when using CRUD functions:
+    //
+    // create callback
+    // load callback
+    // load all callback
+    // save callback
+    // delete callback
+    // export callback
+    // import callback
+    //
+    // See the appropriate ctools_export_crud function for details on what
+    // arguments these callbacks should accept. Please do not call these
+    // directly, always use the ctools_export_crud_* wrappers to ensure
+    // that default implementations are honored.
+    $cache[$table] = $schema;
+  }
+
+  return $cache[$table];
 }
 
 /**
@@ -609,7 +870,7 @@ function ctools_export_to_hook_code(&$code, $table, $names = array(), $name = 'f
       $output .= "function " . $name . "_{$export['default hook']}() {\n";
       $output .= "  \${$export['identifier']}s = array();\n\n";
       foreach ($objects as $object) {
-        $output .= $export['export callback']($object, '  '); // if this function does not exist, better to error out than fail silently
+        $output .= ctools_export_crud_export($table, $object, '  ');
         $output .= "  \${$export['identifier']}s['" . check_plain($object->$export['key']) . "'] = \${$export['identifier']};\n\n";
       }
       $output .= "  return \${$export['identifier']}s;\n";
diff --git includes/form.inc includes/form.inc
index 3d6f7d5..2c4500b 100644
--- includes/form.inc
+++ includes/form.inc
@@ -303,6 +303,10 @@ function ctools_validate_form($form_id, $form, &$form_state) {
     }
   }
 
+  if (!empty($form_state['clicked_button']['#skip validation'])) {
+    return;
+  }
+
   _form_validate($form, $form_state, $form_id);
   $validated_forms[$form_id] = TRUE;
 }
diff --git includes/wizard.inc includes/wizard.inc
index c5b1130..c3ec6b9 100644
--- includes/wizard.inc
+++ includes/wizard.inc
@@ -232,6 +232,7 @@ function ctools_wizard_wrapper(&$form, &$form_state) {
         '#next' => $form_state['previous'],
         '#wizard type' => 'next',
         '#weight' => -2000,
+        '#skip validation' => TRUE,
         // hardcode the submit so that it doesn't try to save data.
         '#submit' => array('ctools_wizard_submit'),
         '#attributes' => $button_attributes,
@@ -320,14 +321,14 @@ function ctools_wizard_wrapper(&$form, &$form_state) {
     if (count($params) > 1) {
       $url = array_shift($params);
       $options = array();
-      
+
       $keys = array(0 => 'query', 1 => 'fragment');
       foreach ($params as $key => $value) {
         if (isset($keys[$key]) && isset($value)) {
           $options[$keys[$key]] = $value;
         }
       }
-      
+
       $params = array($url, $options);
     }
     $form['#action'] =  call_user_func_array('url', $params);
@@ -379,7 +380,7 @@ function ctools_wizard_get_path($form_info, $step) {
   if (is_array($form_info['path'])) {
     foreach ($form_info['path'] as $id => $part) {
       $form_info['path'][$id] = str_replace('%step', $step, $form_info['path'][$id]);
-    }    
+    }
     return $form_info['path'];
   }
   else {
diff --git js/auto-submit.js js/auto-submit.js
new file mode 100644
index 0000000..fa35bfb
--- /dev/null
+++ js/auto-submit.js
@@ -0,0 +1,66 @@
+// $Id: auto-submit.js,v 1.1.2.1 2010/02/17 01:09:46 merlinofchaos Exp $
+
+/**
+ * To make a form auto submit, all you have to do is 3 things:
+ *
+ * ctools_add_js('auto-submit');
+ *
+ * On gadgets you want to auto-submit when changed, add the ctools-auto-submit
+ * class. With FAPI, add:
+ * @code
+ *  '#attributes' => array('class' => 'ctools-auto-submit'),
+ * @endcode
+ *
+ * Finally, you have to identify which button you want clicked for autosubmit.
+ * The behavior of this button will be honored if it's ajaxy or not:
+ * @code
+ *  '#attributes' => array('class' => 'ctools-use-ajax ctools-auto-submit-click'),
+ * @endcode
+ *
+ * Currently only 'select' and 'textfield' types are supported. We probably
+ * could use additional support for radios and checkboxes.
+ */
+
+Drupal.behaviors.CToolsAutoSubmit = function() {
+  var timeoutID = 0;
+
+  // Bind to any select widgets that will be auto submitted.
+  $('select.ctools-auto-submit:not(.ctools-auto-submit-processed)')
+    .addClass('.ctools-auto-submit-processed')
+    .change(function() {
+      $(this.form).find('.ctools-auto-submit-click').click();
+    });
+
+  // Bind to any textfield widgets that will be auto submitted.
+  $('input[type=text].ctools-auto-submit:not(.ctools-auto-submit-processed)')
+    .addClass('.ctools-auto-submit-processed')
+    .keyup(function(e) {
+      var form = this.form;
+      switch (e.keyCode) {
+        case 16: // shift
+        case 17: // ctrl
+        case 18: // alt
+        case 20: // caps lock
+        case 33: // page up
+        case 34: // page down
+        case 35: // end
+        case 36: // home
+        case 37: // left arrow
+        case 38: // up arrow
+        case 39: // right arrow
+        case 40: // down arrow
+        case 9:  // tab
+        case 13: // enter
+        case 27: // esc
+          return false;
+        default:
+          if (!$(form).hasClass('ctools-ajaxing')) {
+            if ((timeoutID)) {
+              clearTimeout(timeoutID);
+            }
+
+            timeoutID = setTimeout(function() { $(form).find('.ctools-auto-submit-click').click(); }, 300);
+        }
+      }
+    });
+}
diff --git plugins/export_ui/ctools_export_ui.class.php plugins/export_ui/ctools_export_ui.class.php
new file mode 100644
index 0000000..4a6ec28
--- /dev/null
+++ plugins/export_ui/ctools_export_ui.class.php
@@ -0,0 +1,1092 @@
+<?php
+// $Id:$
+
+/**
+ * Base class for export UI.
+ */
+class ctools_export_ui {
+  var $plugin;
+  var $name;
+  var $options = array();
+
+  /**
+   * Fake constructor -- this is easier to deal with than the real
+   * constructor because we are retaining PHP4 compatibility, which
+   * would require all child classes to implement their own constructor.
+   */
+  function init($plugin) {
+    ctools_include('export');
+
+    $this->plugin = $plugin;
+  }
+
+  // ------------------------------------------------------------------------
+  // Menu item manipulation
+
+  /**
+   * hook_menu() entry point.
+   *
+   * Child implementations that need to add or modify menu items should
+   * probably call parent::hook_menu($items) and then modify as needed.
+   */
+  function hook_menu(&$items) {
+    $prefix = ctools_export_ui_plugin_base_path($this->plugin);
+
+    $my_items = array();
+    foreach ($this->plugin['menu']['items'] as $item) {
+      // Add menu item defaults.
+      $item += array(
+        'file' => 'export-ui.inc',
+        'file path' => drupal_get_path('module', 'ctools') . '/includes',
+      );
+
+      $path = !empty($item['path']) ? $prefix . '/' . $item['path'] : $prefix;
+      unset($item['path']);
+      $my_items[$path] = $item;
+    }
+
+    $items += $my_items;
+  }
+
+  /**
+   * Menu callback to determine if an operation is accessible.
+   *
+   * This function enforces a basic access check on the configured perm
+   * string, and then additional checks as needed.
+   *
+   * @param $op
+   *   The 'op' of the menu item, which is defined by 'allowed operations'
+   *   and embedded into the arguments in the menu item.
+   * @param $item
+   *   If an op that works on an item, then the item object, otherwise NULL.
+   *
+   * @return
+   *   TRUE if the current user has access, FALSE if not.
+   */
+  function access($op, $item) {
+    if (!user_access($this->plugin['access'])) {
+      return FALSE;
+    }
+
+    switch ($op) {
+      case 'import':
+        return user_access('use PHP for block visibility');
+      case 'revert':
+        return ($item->export_type & EXPORT_IN_DATABASE) && ($item->export_type & EXPORT_IN_CODE);
+      case 'delete':
+        return ($item->export_type & EXPORT_IN_DATABASE) && !($item->export_type & EXPORT_IN_CODE);
+      case 'disable':
+        return empty($item->disabled);
+      case 'enable':
+        return !empty($item->disabled);
+      default:
+        return TRUE;
+    }
+  }
+
+  // ------------------------------------------------------------------------
+  // These methods are the API for generating the list of exportable items.
+
+  /**
+   * Master entry point for handling a list.
+   *
+   * It is unlikely that a child object will need to override this method,
+   * unless the listing mechanism is going to be highly specialized.
+   */
+  function list_page($js, $input) {
+    $this->items = ctools_export_crud_load_all($this->plugin['schema'], !empty($input['js']));
+
+    // Respond to a reset command by clearing session and doing a drupal goto
+    // back to the base URL.
+    if (isset($input['op']) && $input['op'] == t('Reset')) {
+      unset($_SESSION['ctools_export_ui'][$this->plugin['name']]);
+      if (!$js) {
+        return drupal_goto($_GET['q']);
+      }
+      // clear everything but form id, form build id and form token:
+      $keys = array_keys($input);
+      foreach ($keys as $id) {
+        if (!in_array($id, array('form_id', 'form_build_id', 'form_token'))) {
+          unset($input[$id]);
+        }
+      }
+      $replace_form = TRUE;
+    }
+
+    // If there is no input, check to see if we have stored input in the
+    // session.
+    if (!isset($input['form_id'])) {
+      if (isset($_SESSION['ctools_export_ui'][$this->plugin['name']]) && is_array($_SESSION['ctools_export_ui'][$this->plugin['name']])) {
+        $input  = $_SESSION['ctools_export_ui'][$this->plugin['name']];
+      }
+    }
+    else {
+      $_SESSION['ctools_export_ui'][$this->plugin['name']] = $input;
+      unset($_SESSION['ctools_export_ui'][$this->plugin['name']]['q']);
+    }
+
+    // This is where the form will put the output.
+    $this->rows = array();
+    $this->sorts = array();
+
+    $form_state = array(
+      'plugin' => $this->plugin,
+      'input' => $input,
+      'rerender' => TRUE,
+      'no_redirect' => TRUE,
+      'object' => &$this,
+    );
+
+    ctools_include('form');
+    $form = ctools_build_form('ctools_export_ui_list_form', $form_state);
+
+    $output = $this->list_header($form_state) . $this->list_render($form_state) . $this->list_footer($form_state);
+
+    if (!$js) {
+      $this->list_css();
+      return $form . $output;
+    }
+
+    ctools_include('ajax');
+    $commands = array();
+    $commands[] = ctools_ajax_command_replace('#ctools-export-ui-list-items', $output);
+    if (!empty($replace_form)) {
+      $commands[] = ctools_ajax_command_replace('#ctools-export-ui-list-form', $form);
+    }
+    ctools_ajax_render($commands);
+  }
+
+  /**
+   * Create the filter/sort form at the top of a list of exports.
+   *
+   * This handles the very default conditions, and most lists are expected
+   * to override this and call through to parent::list_form() in order to
+   * get the base form and then modify it as necessary to add search
+   * gadgets for custom fields.
+   */
+  function list_form(&$form, &$form_state) {
+    // This forces the form to *always* treat as submitted which is
+    // necessary to make it work.
+    $form['#token'] = FALSE;
+    if (empty($form_state['input'])) {
+      $form["#post"] = TRUE;
+    }
+
+    // Add the 'q' in if we are not using clean URLs or it can get lost when
+    // using this kind of form.
+    if (!variable_get('clean_url', FALSE)) {
+      $form['q'] = array(
+        '#type' => 'hidden',
+        '#value' => $_GET['q'],
+      );
+    }
+
+    $all = array('all' => t('- All -'));
+
+    $form['top row'] = array(
+      '#prefix' => '<div class="ctools-export-ui-row ctools-export-ui-top-row clear-block">',
+      '#suffix' => '</div>',
+    );
+
+    $form['bottom row'] = array(
+      '#prefix' => '<div class="ctools-export-ui-row ctools-export-ui-bottom-row clear-block">',
+      '#suffix' => '</div>',
+    );
+
+    $form['top row']['storage'] = array(
+      '#type' => 'select',
+      '#title' => t('Storage'),
+      '#options' => $all + array(
+        t('Normal') => t('Normal'),
+        t('Default') => t('Default'),
+        t('Overridden') => t('Overridden'),
+      ),
+      '#default_value' => 'all',
+      '#attributes' => array('class' => 'ctools-auto-submit'),
+    );
+
+    $form['top row']['disabled'] = array(
+      '#type' => 'select',
+      '#title' => t('Enabled'),
+      '#options' => $all + array(
+        '0' => t('Enabled'),
+        '1' => t('Disabled')
+      ),
+      '#default_value' => 'all',
+      '#attributes' => array('class' => 'ctools-auto-submit'),
+    );
+
+    $form['top row']['search'] = array(
+      '#type' => 'textfield',
+      '#title' => t('Search'),
+      '#attributes' => array('class' => 'ctools-auto-submit'),
+    );
+
+    $form['bottom row']['order'] = array(
+      '#type' => 'select',
+      '#title' => t('Sort by'),
+      '#options' => $this->list_sort_options(),
+      '#default_value' => 'disabled',
+      '#attributes' => array('class' => 'ctools-auto-submit'),
+    );
+
+    $form['bottom row']['sort'] = array(
+      '#type' => 'select',
+      '#title' => t('Order'),
+      '#options' => array(
+        'asc' => t('Up'),
+        'desc' => t('Down'),
+      ),
+      '#default_value' => 'asc',
+      '#attributes' => array('class' => 'ctools-auto-submit'),
+    );
+
+    $form['bottom row']['submit'] = array(
+      '#type' => 'submit',
+      '#id' => 'ctools-export-ui-list-items-apply',
+      '#value' => t('Apply'),
+      '#attributes' => array('class' => 'ctools-use-ajax ctools-auto-submit-click'),
+    );
+
+    $form['bottom row']['reset'] = array(
+      '#type' => 'submit',
+      '#id' => 'ctools-export-ui-list-items-apply',
+      '#value' => t('Reset'),
+      '#attributes' => array('class' => 'ctools-use-ajax'),
+    );
+
+    ctools_add_js('ajax-responder');
+    ctools_add_js('auto-submit');
+    drupal_add_js('misc/jquery.form.js');
+    ctools_add_js('export-ui-list.js');
+
+    $form['#prefix'] = '<div class="clear-block">';
+    $form['#suffix'] = '</div>';
+  }
+
+  /**
+   * Validate the filter/sort form.
+   *
+   * It is very rare that a filter form needs validation, but if it is
+   * needed, override this.
+   */
+  function list_form_validate(&$form, &$form_state) { }
+
+  /**
+   * Submit the filter/sort form.
+   *
+   * This submit handler is actually responsible for building up all of the
+   * rows that will later be rendered, since it is doing the filtering and
+   * sorting.
+   *
+   * For the most part, you should not need to override this method, as the
+   * fiddly bits call through to other functions.
+   */
+  function list_form_submit(&$form, &$form_state) {
+    // Filter and re-sort the pages.
+    $plugin = $this->plugin;
+
+    $prefix = ctools_export_ui_plugin_base_path($plugin);
+
+    foreach ($this->items as $name => $item) {
+      // Call through to the filter and see if we're going to render this
+      // row. If it returns TRUE, then this row is filtered out.
+      if ($this->list_filter($form_state, $item)) {
+        continue;
+      }
+
+      // Note: Creating this list seems a little clumsy, but can't think of
+      // better ways to do this.
+      $allowed_operations = drupal_map_assoc(array_keys($plugin['allowed operations']));
+      $not_allowed_operations = array('import');
+
+      if ($item->type == t('Normal')) {
+        $not_allowed_operations[] = 'revert';
+      }
+      elseif ($item->type == t('Overridden')) {
+        $not_allowed_operations[] = 'delete';
+      }
+      else {
+        $not_allowed_operations[] = 'revert';
+        $not_allowed_operations[] = 'delete';
+      }
+
+      $not_allowed_operations[] = empty($item->disabled) ? 'enable' : 'disable';
+
+      foreach ($not_allowed_operations as $op) {
+        // Remove the operations that are not allowed for the specific
+        // exportable.
+        unset($allowed_operations[$op]);
+      }
+
+      $operations = array();
+
+      foreach ($allowed_operations as $op) {
+        $operations[$op] = array(
+          'title' => $plugin['allowed operations'][$op]['title'],
+          'href' => ctools_export_ui_plugin_menu_path($plugin, $op, $name),
+        );
+        if (!empty($plugin['allowed operations'][$op]['ajax'])) {
+          $operations[$op]['attributes'] = array('class' => 'ctools-use-ajax');
+        }
+        if (!empty($plugin['allowed operations'][$op]['token'])) {
+          $operations[$op]['query'] = array('token' => drupal_get_token($op));
+        }
+      }
+
+      $this->list_build_row($item, $form_state, $operations);
+    }
+
+    // Now actually sort
+    if ($form_state['values']['sort'] == 'desc') {
+      arsort($this->sorts);
+    }
+    else {
+      asort($this->sorts);
+    }
+
+    // Nuke the original.
+    $rows = $this->rows;
+    $this->rows = array();
+    // And restore.
+    foreach ($this->sorts as $name => $title) {
+      $this->rows[$name] = $rows[$name];
+    }
+  }
+
+  /**
+   * Determine if a row should be filtered out.
+   *
+   * This handles the default filters for the export UI list form. If you
+   * added additional filters in list_form() then this is where you should
+   * handle them.
+   *
+   * @return
+   *   TRUE if the item should be excluded.
+   */
+  function list_filter($form_state, $item) {
+    if ($form_state['values']['storage'] != 'all' && $form_state['values']['storage'] != $item->type) {
+      return TRUE;
+    }
+
+    if ($form_state['values']['disabled'] != 'all' && $form_state['values']['disabled'] != !empty($item->disabled)) {
+      return TRUE;
+    }
+
+    if ($form_state['values']['search']) {
+      $search = strtolower($form_state['values']['search']);
+      foreach ($this->list_search_fields() as $field) {
+        if (strpos(strtolower($item->$field), $search) !== FALSE) {
+          $hit = TRUE;
+          break;
+        }
+      }
+      if (empty($hit)) {
+        return TRUE;
+      }
+    }
+  }
+
+  /**
+   * Provide a list of fields to test against for the default "search" widget.
+   *
+   * This widget will search against whatever fields are configured here. By
+   * default it will attempt to search against the name, title and description fields.
+   */
+  function list_search_fields() {
+    $fields = array(
+      $this->plugin['export']['key'],
+    );
+
+    if (!empty($this->plugin['export']['admin_title'])) {
+      $fields[] = $this->plugin['export']['admin_title'];
+    }
+    if (!empty($this->plugin['export']['admin_description'])) {
+      $fields[] = $this->plugin['export']['admin_description'];
+    }
+
+    return $fields;
+  }
+
+  /**
+   * Provide a list of sort options.
+   *
+   * Override this if you wish to provide more or change how these work.
+   * The actual handling of the sorting will happen in build_row().
+   */
+  function list_sort_options() {
+    if (!empty($this->plugin['export']['admin_title'])) {
+      $options = array(
+        'disabled' => t('Enabled, title'),
+        $this->plugin['export']['admin_title'] => t('Title'),
+      );
+    }
+    else {
+      $options = array(
+        'disabled' => t('Enabled, name'),
+      );
+    }
+
+    $options += array(
+      'name' => t('Name'),
+      'storage' => t('Storage'),
+    );
+
+    return $options;
+  }
+
+  /**
+   * Add listing CSS to the page.
+   *
+   * Override this if you need custom CSS for your list.
+   */
+  function list_css() {
+    ctools_add_css('export-ui-list');
+  }
+
+  /**
+   * Build a row based on the item.
+   *
+   * By default all of the rows are placed into a table by the render
+   * method, so this is building up a row suitable for theme('table').
+   * This doesn't have to be true if you override both.
+   */
+  function list_build_row($item, &$form_state, $operations) {
+    // Set up sorting
+    $name = $item->{$this->plugin['export']['key']};
+
+    // Note: $item->type should have already been set up by export.inc so
+    // we can use it safely.
+    switch ($form_state['values']['order']) {
+      case 'disabled':
+        $this->sorts[$name] = empty($item->disabled) . $name;
+        break;
+      case 'title':
+        $this->sorts[$name] = $item->{$this->plugin['export']['admin_title']};
+        break;
+      case 'name':
+        $this->sorts[$name] = $name;
+        break;
+      case 'storage':
+        $this->sorts[$name] = $item->type . $name;
+        break;
+    }
+
+    $this->rows[$name]['data'] = array();
+    $this->rows[$name]['class'] = !empty($item->disabled) ? 'ctools-export-ui-disabled' : 'ctools-export-ui-enabled';
+
+    // If we have an admin title, make it the first row.
+    if ($this->plugin['export']['admin_title']) {
+      $this->rows[$name]['data'][] = array('data' => check_plain($item->{$this->plugin['export']['admin_title']}), 'class' => 'ctools-export-ui-title');
+    }
+    $this->rows[$name]['data'][] = array('data' => check_plain($name), 'class' => 'ctools-export-ui-name');
+    $this->rows[$name]['data'][] = array('data' => check_plain($item->type), 'class' => 'ctools-export-ui-storage');
+    $this->rows[$name]['data'][] = array('data' => theme('links', $operations), 'class' => 'ctools-export-ui-operations');
+
+    // Add an automatic mouseover of the description if one exists.
+    if (!empty($this->plugin['export']['admin_description'])) {
+      $this->rows[$name]['title'] = $item->{$this->plugin['export']['admin_description']};
+    }
+  }
+
+  /**
+   * Provide the table header.
+   *
+   * If you've added columns via list_build_row() but are still using a
+   * table, override this method to set up the table header.
+   */
+  function list_table_header() {
+    $header = array();
+    if ($this->plugin['export']['admin_title']) {
+      $header[] = array('data' => t('Title'), 'class' => 'ctools-export-ui-title');
+    }
+
+    $header[] = array('data' => t('Name'), 'class' => 'ctools-export-ui-name');
+    $header[] = array('data' => t('Storage'), 'class' => 'ctools-export-ui-storage');
+    $header[] = array('data' => t('Operations'), 'class' => 'ctools-export-ui-operations');
+
+    return $header;
+  }
+
+  /**
+   * Render all of the rows together.
+   *
+   * By default we place all of the rows in a table, and this should be the
+   * way most lists will go.
+   *
+   * Whatever you do if this method is overridden, the ID is important for AJAX
+   * so be sure it exists.
+   */
+  function list_render(&$form_state) {
+    return theme('table', $this->list_table_header(), $this->rows, array('id' => 'ctools-export-ui-list-items'));
+  }
+
+  /**
+   * Render a header to go before the list.
+   *
+   * This will appear after the filter/sort widgets.
+   */
+  function list_header($form_state) { }
+
+  /**
+   * Render a footer to go after thie list.
+   *
+   * This is a good place to add additional links.
+   */
+  function list_footer($form_state) { }
+
+  // ------------------------------------------------------------------------
+  // These methods are the API for adding/editing exportable items
+
+  function add_page($js, $input) {
+    $form_state = array(
+      'plugin' => $this->plugin,
+      'object' => &$this,
+      'ajax' => $js,
+      'item' => ctools_export_crud_new($this->plugin['schema']),
+      'op' => 'add',
+      'rerender' => TRUE,
+      'no_redirect' => TRUE,
+    );
+
+    return $this->edit_execute_form($form_state);
+  }
+
+  /**
+   * Main entry point to edit an item.
+   *
+   * The default implementation simply uses a form, so this should be
+   * overridden for more complex implentations that need more than to display
+   * a simple form (like a view or a page manager page).
+   */
+  function edit_page($js, $input, $item) {
+    $form_state = array(
+      'plugin' => $this->plugin,
+      'object' => &$this,
+      'ajax' => $js,
+      'item' => $item,
+      'op' => 'edit',
+      'rerender' => TRUE,
+      'no_redirect' => TRUE,
+    );
+
+    return $this->edit_execute_form($form_state);
+  }
+
+  function clone_page($js, $input, $item) {
+    // To make a clone of an item, we first export it and then re-import it.
+    // Export the handler, which is a fantastic way to clean database IDs out of it.
+    $export = ctools_export_crud_export($this->plugin['schema'], $item);
+    $item = ctools_export_crud_import($this->plugin['schema'], $export);
+    $item->{$this->plugin['export']['key']} = 'clone_of_' . $item->name;
+
+    $form_state = array(
+      'plugin' => $this->plugin,
+      'object' => &$this,
+      'ajax' => $js,
+      'item' => $item,
+      'op' => 'add',
+      'rerender' => TRUE,
+      'no_redirect' => TRUE,
+    );
+
+    return $this->edit_execute_form($form_state);
+  }
+
+  /**
+   * Execute the form.
+   *
+   * Add and Edit both funnel into this, but they have a few different
+   * settings.
+   */
+  function edit_execute_form($form_state) {
+    ctools_include('form');
+    $output = ctools_build_form('ctools_export_ui_edit_item_form', $form_state);
+    if (!empty($form_state['executed'])) {
+      $this->edit_save_form($form_state);
+    }
+
+    return $output;
+  }
+
+  /**
+   * Called to save the final product from the edit form.
+   */
+  function edit_save_form($form_state) {
+    $item = &$form_state['item'];
+    $export_key = $this->plugin['export']['key'];
+
+    $result = ctools_export_crud_save($this->plugin['schema'], $item);
+
+    if ($result) {
+      drupal_set_message(t('Saved @plugin %title.', array('@plugin' => $this->plugin['title'], '%title' =>  $item->{$export_key})));
+    }
+    else {
+      drupal_set_message(t('Could not save @plugin %title.', array('@plugin' => $this->plugin['title'], '%title' =>  $item->{$export_key})), 'error');
+    }
+  }
+
+  /**
+   * Provide the actual editing form.
+   */
+  function edit_form(&$form, &$form_state) {
+    $export_key = $this->plugin['export']['key'];
+    $item = $form_state['item'];
+    $schema = ctools_export_get_schema($this->plugin['schema']);
+
+    // Set title.
+    if ($form_state['op'] == 'edit') {
+      if ($item->export_type & EXPORT_IN_DATABASE) {
+        $title_op = $form_state['op'];
+      }
+      else {
+        $title_op = 'view';
+      }
+    }
+    else {
+      $title_op = $form_state['op'];
+    }
+
+    // Replace %title that might be there with the exportable title.
+    drupal_set_title(str_replace('%title', $item->{$export_key}, $this->plugin['form']['string']['title'][$title_op]));
+
+    // TODO: Drupal 7 has a nifty method of auto guessing names from
+    // titles that is standard. We should integrate that here as a
+    // nice standard.
+    // Guess at a couple of our standard fields.
+    if (!empty($this->plugin['export']['admin_title'])) {
+      $form['info'][$this->plugin['export']['admin_title']] = array(
+        '#type' => 'textfield',
+        '#title' => t('Administrative title'),
+        '#description' => t('This will appear in the administrative interface to easily identify it.'),
+        '#default_value' => $item->{$this->plugin['export']['admin_title']},
+      );
+    }
+
+    $form['info'][$export_key] = array(
+      '#title' => $schema['export']['key name'],
+      '#type' => 'textfield',
+      '#default_value' => $item->{$export_key},
+      '#description' => t('The unique ID for this @export', array('@export' => $this->plugin['title'])),
+      '#required' => TRUE,
+      '#maxlength' => 255,
+    );
+
+    if ($form_state['op'] === 'edit') {
+      $form['info'][$export_key]['#disabled'] = TRUE;
+      $form['info'][$export_key]['#value'] = $item->{$export_key};
+    }
+    else {
+      $form['info'][$export_key]['#element_validate'] = array('ctools_export_ui_edit_name_validate');
+    }
+
+    if (!empty($this->plugin['export']['admin_description'])) {
+      $form['info'][$this->plugin['export']['admin_description']] = array(
+        '#type' => 'textarea',
+        '#title' => t('Administrative description'),
+        '#default_value' => $item->{$this->plugin['export']['admin_description']},
+      );
+    }
+
+    // Add plugin's form definitions.
+    if (!empty($this->plugin['form']['settings'])) {
+      // Pass $form by reference.
+      $this->plugin['form']['settings']($form, $this->plugin, $form_state['op'], $item);
+    }
+
+    // Add the buttons if the wizard is not in use.
+    if (empty($form_state['form_info'])) {
+      // Make sure that whatever happens, the buttons go to the bottom.
+      $form['buttons']['#weight'] = 100;
+
+      // Add buttons.
+      $form['buttons']['submit'] = array(
+        '#type' => 'submit',
+        '#value' => t('Save'),
+      );
+
+      $form['buttons']['delete'] = array(
+        '#type' => 'submit',
+        '#value' => $item->export_type & EXPORT_IN_CODE ? t('Revert') : t('Delete'),
+        '#access' => $form_state['op'] === 'edit' && $item->export_type & EXPORT_IN_DATABASE,
+        '#submit' => 'ctools_export_ui_edit_name_validate',
+      );
+    }
+  }
+
+  function edit_form_validate(&$form, &$form_state) { }
+
+  /**
+   * Handle the submission of the edit form.
+   *
+   * At this point, submission is successful. Our only responsibility is
+   * to copy anything out of values onto the item that we are able to edit.
+   *
+   * If the keys all match up to the schema, this method will not need to be
+   * overridden.
+   */
+  function edit_form_submit(&$form, &$form_state) {
+    $plugin = $form_state['plugin'];
+    $export_key = $plugin['export']['key'];
+    $item = $form_state['item'];
+
+    $schema = ctools_export_get_schema($plugin['schema']);
+    foreach (array_keys($schema['fields']) as $key) {
+      if(isset($form_state['values'][$key])) {
+        $item->{$key} = $form_state['values'][$key];
+      }
+    }
+  }
+
+  // ------------------------------------------------------------------------
+  // These methods are the API for 'other' stuff with exportables such as
+  // enable, disable, import, export
+
+  /**
+   * Callback to enable a page.
+   */
+  function enable_page($js, $input, $item) {
+    return $this->set_item_state(FALSE, $js, $input, $item);
+  }
+
+  /**
+   * Callback to disable a page.
+   */
+  function disable_page($js, $input, $item) {
+    return $this->set_item_state(TRUE, $js, $input, $item);
+  }
+
+  /**
+   * Set an item's state to enabled or disabled and output to user.
+   *
+   * If javascript is in use, this will rebuild the list and send that back
+   * as though the filter form had been executed.
+   */
+  function set_item_state($state, $js, $input, $item) {
+    ctools_export_set_object_status($item, $state);
+
+    if (!$js) {
+      drupal_goto(ctools_export_ui_plugin_base_path($this->plugin));
+    }
+    else {
+      return $this->list_page($js, $input);
+    }
+  }
+
+  /**
+   * Page callback to delete an exportable item.
+   */
+  function delete_page($js, $input, $item) {
+    $form_state = array(
+      'plugin' => $this->plugin,
+      'object' => &$this,
+      'ajax' => $js,
+      'item' => $item,
+      'op' => $item->export_type & EXPORT_IN_CODE ? 'revert' : 'delete',
+      'rerender' => TRUE,
+      'no_redirect' => TRUE,
+    );
+
+    ctools_include('form');
+
+    $output = ctools_build_form('ctools_export_ui_delete_confirm_form', $form_state);
+    if (!empty($form_state['executed'])) {
+      ctools_export_crud_delete($this->plugin['schema'], $item);
+      // TODO: set up the right drupal_set_message here.
+      drupal_goto(ctools_export_ui_plugin_base_path($this->plugin));
+    }
+
+    return $output;
+  }
+
+  /**
+   * Page callback to display export information for an exportable item.
+   */
+  function export_page($js, $input, $item) {
+    drupal_set_title(str_replace('%title', $this->plugin['title'], $this->plugin['form']['string']['title']['export']));
+    return drupal_get_form('ctools_export_form', ctools_export_crud_export($this->plugin['schema'], $item), t('Export'));
+  }
+
+  /**
+   * Page callback to import information for an exportable item.
+   */
+  function import_page($js, $input, $step = 'begin') {
+    // Import is basically a multi step wizard form, so let's go ahead and
+    // use CTools' wizard.inc for it.
+
+    $form_info = array(
+      'id' => 'ctools_export_ui_import',
+      'path' => ctools_export_ui_plugin_base_path($this->plugin) . '/' . $this->plugin['menu']['items']['import']['path'] . '/%step',
+      'return path' => ctools_export_ui_plugin_base_path($this->plugin),
+      'show trail' => TRUE,
+      'show back' => TRUE,
+      'show return' => FALSE,
+      'finish callback' => 'ctools_export_ui_import_finish',
+      'cancel callback' => 'ctools_export_ui_import_cancel',
+      'order' => array(
+        'code' => t('Import code'),
+        'edit' => t('Edit'),
+      ),
+      'forms' => array(
+        'code' => array(
+          'form id' => 'ctools_export_ui_import_code'
+        ),
+        'edit' => array(
+          'form id' => 'ctools_export_ui_import_edit'
+        ),
+      ),
+    );
+
+    $form_state = array(
+      'plugin' => $this->plugin,
+      'input' => $input,
+      'rerender' => TRUE,
+      'no_redirect' => TRUE,
+      'object' => &$this,
+      'export' => '',
+      'overwrite' => FALSE,
+    );
+
+    if ($step == 'code') {
+      // This is only used if the BACK button was hit.
+      if (!empty($_SESSION['ctools_export_ui_import'][$this->plugin['name']])) {
+        $form_state['item'] = $_SESSION['ctools_export_ui_import'][$this->plugin['name']];
+        $form_state['export'] = $form_state['item']->export_ui_code;
+        $form_state['overwrite'] = $form_state['item']->export_ui_allow_overwrite;
+      }
+    }
+    else if ($step == 'begin') {
+      $step = 'code';
+      if (!empty($_SESSION['ctools_export_ui_import'][$this->plugin['name']])) {
+        unset($_SESSION['ctools_export_ui_import'][$this->plugin['name']]);
+      }
+    }
+    else if ($step != 'code') {
+      $form_state['item'] = $_SESSION['ctools_export_ui_import'][$this->plugin['name']];
+      $form_state['op'] = 'add';
+      if (!empty($form_state['item']->export_ui_allow_overwrite)) {
+        // if allow overwrite was enabled, set this to 'edit' only if the key already existed.
+        $export_key = $this->plugin['export']['key'];
+
+        if (ctools_export_crud_load($this->plugin['schema'], $form_state['item']->{$export_key})) {
+          $form_state['op'] = 'edit';
+        }
+      }
+    }
+
+    ctools_include('wizard');
+    return ctools_wizard_multistep_form($form_info, $step, $form_state);
+  }
+
+}
+
+// -----------------------------------------------------------------------
+// Forms to be used with this class.
+//
+// Since Drupal's forms are completely procedural, these forms will
+// mostly just be pass-throughs back to the object.
+
+/**
+ * Form callback to handle the filter/sort form when listing items.
+ *
+ * This simply loads the object defined in the plugin and hands it off.
+ */
+function ctools_export_ui_list_form(&$form_state) {
+  $form = array();
+  $form_state['object']->list_form($form, $form_state);
+  return $form;
+}
+
+/**
+ * Validate handler for ctools_export_ui_list_form.
+ */
+function ctools_export_ui_list_form_validate(&$form, &$form_state) {
+  $form_state['object']->list_form_validate($form, $form_state);
+}
+
+/**
+ * Submit handler for ctools_export_ui_list_form.
+ */
+function ctools_export_ui_list_form_submit(&$form, &$form_state) {
+  $form_state['object']->list_form_submit($form, $form_state);
+}
+
+/**
+ * Form callback to edit an exportable item.
+ *
+ * This simply loads the object defined in the plugin and hands it off.
+ */
+function ctools_export_ui_edit_item_form(&$form_state) {
+  $form = array();
+  $form_state['object']->edit_form($form, $form_state);
+  return $form;
+}
+
+/**
+ * Validate handler for ctools_export_ui_edit_item_form.
+ */
+function ctools_export_ui_edit_item_form_validate(&$form, &$form_state) {
+  $form_state['object']->edit_form_validate($form, $form_state);
+}
+
+/**
+ * Submit handler for ctools_export_ui_edit_item_form.
+ */
+function ctools_export_ui_edit_item_form_submit(&$form, &$form_state) {
+  $form_state['object']->edit_form_submit($form, $form_state);
+}
+
+/**
+ * Submit handler to delete for ctools_export_ui_edit_item_form
+ */
+function ctools_export_ui_edit_item_form_delete(&$form, &$form_state) {
+  $plugin = $form_state['plugin'];
+  $export_key = $plugin['export']['key'];
+  $item = $form_state['item'];
+
+  $menu_prefix = ctools_export_ui_plugin_base_path($plugin);
+  $form_state['redirect'] = ctools_export_ui_plugin_menu_path($plugin, 'delete', $item->{$export_key});
+}
+
+/**
+ * Validate that an export item name is acceptable and unique during add.
+ */
+function ctools_export_ui_edit_name_validate($element, &$form_state) {
+  $plugin = $form_state['plugin'];
+  // Check for string identifier sanity
+  if (!preg_match('!^[a-z0-9_]+$!', $element['#value'])) {
+    form_error($element, t('The export id can only consist of lowercase letters, underscores, and numbers.'));
+    return;
+  }
+
+  // Check for name collision
+  if ($exists = ctools_export_crud_load($plugin['schema'], $element['#value'])) {
+    form_error($element, t('A @plugin with this name already exists. Please choose another name or delete the existing export before creating a new one.', array('@plugin' => $plugin['title'])));
+  }
+}
+
+/**
+ * Delete/Revert confirm form.
+ */
+function ctools_export_ui_delete_confirm_form(&$form_state) {
+  $plugin = $form_state['plugin'];
+  $item = $form_state['item'];
+
+  $form = array();
+
+  $export_key = $plugin['export']['key'];
+  $question = str_replace('!action', $plugin['allowed operations'][$form_state['op']]['title'], $plugin['form']['string']['confirmation']['question']);
+  $question = str_replace('%title', $item->{$export_key}, $question);
+
+  $form = confirm_form($form,
+    $question,
+    ctools_export_ui_plugin_base_path($plugin),
+    $plugin['form']['string']['confirmation'][$form_state['op']],
+    drupal_ucfirst($plugin['allowed operations'][$form_state['op']]['title']), t('Cancel')
+  );
+  return $form;
+}
+
+/**
+ * Import form. Provides simple helptext instructions and textarea for
+ * pasting a export definition.
+ *
+ * This is a wizard form so its input is slightly different.
+ */
+function ctools_export_ui_import_code(&$form, &$form_state) {
+  $plugin = $form_state['plugin'];
+
+  $form['help'] = array(
+    '#type' => 'item',
+    '#value' => $plugin['form']['string']['help']['import'],
+  );
+
+  $form['import'] = array(
+    '#title' => t('@plugin object', array('@plugin' => $plugin['title'])),
+    '#type' => 'textarea',
+    '#rows' => 10,
+    '#required' => TRUE,
+    '#default_value' => $form_state['export'],
+  );
+
+  $form['overwrite'] = array(
+    '#title' => t('Allow import to overwrite an existing record.'),
+    '#type' => 'checkbox',
+    '#default_value' => $form_state['overwrite'],
+  );
+}
+
+/**
+ * Import edit form
+ *
+ * This is a wizard form so its input is slightly different. But it just
+ * passes through to the normal edit form.
+ */
+function ctools_export_ui_import_edit(&$form, &$form_state) {
+  $form_state['object']->edit_form($form, $form_state);
+}
+
+/**
+ * Validate handler for ctools_export_ui_import_edit.
+ */
+function ctools_export_ui_import_edit_validate(&$form, &$form_state) {
+  $form_state['object']->edit_form_validate($form, $form_state);
+}
+
+/**
+ * Submit handler for ctools_export_ui_import_edit.
+ */
+function ctools_export_ui_import_edit_submit(&$form, &$form_state) {
+  $form_state['object']->edit_form_submit($form, $form_state);
+}
+
+/**
+ * Import form validate handler.
+ *
+ * Evaluates code and make sure it creates an object before we continue.
+ */
+function ctools_export_ui_import_code_validate($form, &$form_state) {
+  $plugin = $form_state['plugin'];
+  $item = ctools_export_crud_import($plugin['schema'], $form_state['values']['import']);
+  if (is_string($item)) {
+    form_error($form['import'], t('Unable to get an import from the code. Errors reported: @errors', array('@errors' => $item)));
+    return;
+  }
+
+  $form_state['item'] = $item;
+  $form_state['item']->export_ui_allow_overwrite = $form_state['values']['overwrite'];
+  $form_state['item']->export_ui_code = $form_state['values']['import'];
+}
+
+/**
+ * Submit callback for import form.
+ *
+ * Stores the item in the session.
+ */
+function ctools_export_ui_import_code_submit($form, &$form_state) {
+  $_SESSION['ctools_export_ui_import'][$form_state['plugin']['name']] = $form_state['item'];
+}
+
+/**
+ * Wizard finish callback for import of exportable item.
+ */
+function ctools_export_ui_import_finish(&$form_state) {
+  // This indicates that overwrite was allowed, so we should delete the
+  // original item.
+  if ($form_state['op'] == 'edit') {
+    ctools_export_crud_delete($this->plugin['schema'], $form_state['item']);
+  }
+
+  $form_state['object']->edit_save_form($form_state);
+
+  // Clear temporary data from session.
+  unset($_SESSION['ctools_export_ui_import'][$form_state['plugin']['name']]);
+}
+
+/**
+ * Wizard cancel callback for import of exportable item.
+ */
+function ctools_export_ui_import_cancel(&$form_state) {
+  // Clear temporary data from session.
+  unset($_SESSION['ctools_export_ui_import'][$form_state['plugin']['name']]);
+}
diff --git plugins/export_ui/ctools_export_ui.inc plugins/export_ui/ctools_export_ui.inc
new file mode 100644
index 0000000..5602e6d
--- /dev/null
+++ plugins/export_ui/ctools_export_ui.inc
@@ -0,0 +1,16 @@
+<?php
+// $Id:$
+
+/**
+ * TODO: Document plugin keys:
+ * - name: The name of the plugin. This should be the same as the file name.
+ * - has menu: Deterimne if hook_menu() should declare items of this plugin.
+ *   Defaults to TRUE?
+ * - menu item: Optional; The menu item that should be used. For example if the
+ *   value is set to 'foo', then the URL will admin/build/foo. If no value is
+ *   defined then the plugin name will be used.
+ */
+$plugin = array(
+  // As this is the base class plugin, it shouldn't declare any menu items.
+  'has menu' => FALSE,
+);
