diff --git a/wordpress_migrate.pages.inc b/wordpress_migrate.pages.inc
index b014da8..d2e18a0 100644
--- a/wordpress_migrate.pages.inc
+++ b/wordpress_migrate.pages.inc
@@ -18,25 +18,94 @@ function wordpress_migrate_import() {
  * Form for specifying where to obtain the WordPress content.
  */
 function wordpress_migrate_import_form($form, &$form_state) {
+  // If an uploaded file exceeds post_max_size, the validate and submit functions
+  // never get called (as they will if upload_max_filesize is exceeded but
+  // post_max_size isn't). We can detect this case by the presence of an error.
+  if ($error = error_get_last()) {
+    drupal_set_message(t('File upload failed: !error. Please make sure the file
+      you\'re trying to upload is not larger than !limit',
+      array('!error' => $error['message'], '!limit' => format_size(file_upload_max_size()))));
+  }
+
+  $post_type = variable_get('wordpress_migrate_post_type', '');
+  if (!$post_type) {
+    if (user_access(WORDPRESS_MIGRATE_ACCESS_CONFIGURE)) {
+      $message = t('Wordpress migration must be <a href="@config">configured</a> before use',
+        array('@config' => url('admin/content/wordpress/configure')));
+    }
+    else {
+      $message = t('WordPress migration is not properly configured - please contact
+        a site administrator');
+    }
+    $form['unconfigured'] = array(
+      '#prefix' => '<div>',
+      '#markup' => $message,
+      '#suffix' => '</div>',
+    );
+    return $form;
+  }
+
   $form['overview'] = array(
     '#prefix' => '<div>',
-    '#markup' => "TBD: Describe the migration process. Make note of size restrictions
-      for selecting local file (query PHP max values for guidance).",
+    '#markup' => t('WordPress blogs can be imported into Drupal using this form.
+      You may either provide the necessary credentials for Drupal to retrieve
+      your WordPress blog data directly, or you may export the blog yourself
+      and upload the exported WXR file.'),
     '#suffix' => '</div>',
   );
 
+  if (module_exists('media') && !module_exists('migrate_extras')) {
+    $form['need_extras'] = array(
+      '#prefix' => '<div>',
+      '#markup' => t('You have the <a href="@media">Media module</a> enabled - to
+        take advantage of Media features, you need to also install and enable the
+        <a href="@extras">Migrate Extras module</a>.',
+        array('@media' => url('http://drupal.org/project/media'),
+          '@extras' => url('http://drupal.org/project/migrate_extras'))),
+      '#suffix' => '</div>',
+    );
+  }
+
+  $form['credentials'] = array(
+    '#type' => 'fieldset',
+    '#title' => t('WordPress blog credentials'),
+    '#collapsible' => TRUE,
+    '#collapsed' => FALSE,
+  );
+
+  $form['credentials']['description'] = array(
+    '#prefix' => '<div>',
+    '#markup' => t('To import your blog directly from WordPress, enter your credentials here.'),
+    '#suffix' => '</div>',
+  );
+
+  $form['credentials']['domain'] = array(
+    '#type' => 'textfield',
+    '#title' => t('Domain of your blog'),
+    '#description' => t('Enter the domain of the blog to import (e.g., example.wordpress.com)'),
+  );
+
+  $form['credentials']['username'] = array(
+    '#type' => 'textfield',
+    '#title' => t('Username of your WordPress account'),
+    '#description' => t(''),
+  );
+
+  $form['credentials']['password'] = array(
+    '#type' => 'password',
+    '#title' => t('Password to your WordPress account'),
+    '#description' => t(''),
+  );
+
   $form['#attributes'] = array('enctype' => 'multipart/form-data');
   $form['wxr_file'] = array(
     '#type' => 'file',
     '#title' => t('WordPress exported (WXR) file to import into Drupal'),
+    '#description' => t('If you have exported your WordPress blog to your local filesystem,
+      choose the downloaded file here. The largest file size you can import is !size',
+      array('!size' => format_size(file_upload_max_size()))),
   );
 
-  // TODO: Select destination node type(s)
-
-  // TODO: Select user to own blog
-
-  // TODO: Select vocabulary for categories
-
   $form['submit'] = array(
     '#type' => 'submit',
     '#value' => t('Import WordPress blog'),
@@ -49,37 +118,574 @@ function wordpress_migrate_import_form($form, &$form_state) {
  * Submit callback for the WordPress import form.
  */
 function wordpress_migrate_import_form_submit($form, &$form_state) {
-  // TODO: Use configured file scheme, defaulting to private://
-  $tmpfile = $_FILES['files']['tmp_name']['wxr_file'];
-  $destination = '../private/wordpress/' . $_FILES['files']['name']['wxr_file'];
-  $moved = move_uploaded_file($tmpfile, $destination);
-  if ($moved) {
-    // Get the full path where the file lives
-    $destination = realpath($destination);
-
-    // Extract the blog title, which will be used to construct migration machine names
-    $xml = simplexml_load_file($destination);
-    $title = (string)$xml->channel->title;
-    // Keep only alphabetic characters
-    // TODO: error if nothing's left
-    $title = preg_replace('/[^A-Za-z]/', '', $title);
-
-    // Write to the table.
-    // TODO: What to do if this has previously been imported?
-    db_merge('wordpress_migrate')
-      ->key(array('filename' => $destination))
-      ->fields(array('title' => $title))
-      ->execute();
-
-    // Instantiate each WP migration, passing the filename
-    foreach (WordPressMigration::migrationList() as $class_name) {
-      $migration = new $class_name(array('filename' => $destination));
-      $migration->processImport();
-    }
-    drupal_set_message(t('File %filename successfully uploaded and imported',
-      array('%filename' => $_FILES['files']['name']['wxr_file'])));
+  // prepare the destination directory for the locally uploaded or remotely downloaded file
+  $directory = 'wordpress://';
+  if (!file_prepare_directory($directory, FILE_CREATE_DIRECTORY | FILE_MODIFY_PERMISSIONS)) {
+    form_set_error('wxr_file', t('Could not prepare directory %directory',
+      array('%directory' => $directory)));
+    return;
+  }
+
+  // if the user has entered a filename, take precedence over the credentials section
+  if ($_FILES['files']['name']['wxr_file']) {
+    if ($_FILES['files']['name']['wxr_file'] && $_FILES['files']['error']['wxr_file']) {
+      form_set_error('wxr_file', t('The file could not be uploaded, most likely
+        because the file size exceeds the configured limit of !filesize',
+        array('!filesize' => format_size(file_upload_max_size()))));
+      return;
+    }
+
+    $tmpfile = $_FILES['files']['tmp_name']['wxr_file'];
+    if ($tmpfile) {
+      // Handle uploaded file
+      $filename = $_FILES['files']['name']['wxr_file'];
+      $destination = $directory . str_replace(' ', '%20', $filename);
+      $saved = file_unmanaged_move($tmpfile, $destination, FILE_EXISTS_REPLACE);
+      if (!$saved) {
+        form_set_error('wxr_file', t('Failed to save file to %filename', array('%filename' => $destination)));
+      }
+    }
   }
   else {
-    drupal_set_message(t('Failed to move file %filename', array('%filename' => $filename)));
+    // Export the WordPress blog directly
+    // the instructions omit http here, but the user may use it anyway
+    // also must strip any space or trailing / characters
+    $parts = parse_url($form_state['values']['domain']);
+    $domain = trim(isset($parts['host']) ? $parts['host'] : array_shift(explode('/', $parts['path'], 2)));
+
+    // get a temp file for cookie content
+    $cookie_file = file_directory_temp() . '/wpimport-cookie.txt';
+
+    // Login to the WordPress site
+    $wordpress_version = 3;
+    $login_url = 'http://' . $domain . '/wp-login.php';
+    if (!($handle = fopen($login_url, 'r'))) {
+      $wordpress_version = 2;
+      $login_url = 'http://' . $domain . '/blog/wp-login.php';
+      $handle = fopen($login_url, 'r');
+    }
+    if (!$handle) {
+      form_set_error('credentials][domain', t('Could not find login page for !domain',
+          array('!domain' => $domain)));
+    }
+    fclose($handle);
+    $username = $form_state['values']['username'];
+    $password = $form_state['values']['password'];
+    $ch = curl_init();
+    curl_setopt($ch, CURLOPT_URL, $login_url);
+    curl_setopt($ch, CURLOPT_HEADER, 1);
+    curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
+    curl_setopt($ch, CURLOPT_USERAGENT, 'Importer');
+    curl_setopt($ch, CURLOPT_COOKIESESSION, 1);
+    curl_setopt($ch, CURLOPT_COOKIEJAR, $cookie_file);
+    curl_setopt($ch, CURLOPT_COOKIEFILE, $cookie_file);
+    curl_setopt($ch, CURLOPT_POST, 1);
+    curl_setopt($ch, CURLOPT_POSTFIELDS, "log=$username&pwd=$password&testcookie=1");
+    $login_result = curl_exec($ch);
+    curl_close($ch);
+
+    // Login successful? Grab the export
+    if (strpos($login_result, 'Set-Cookie: wordpress_logged_in')) {
+      $filename = $domain . '.xml';
+      $destination = $directory . '/' . $filename;
+      $export_url = 'http://' . $domain;
+      if ($wordpress_version == 2) {
+        $export_url .= '/blog';
+      }
+      $export_url .= '/wp-admin/export.php?mm_start=all&mm_end=all&author=all' .
+        '&export_taxonomy[category]=0&export_taxonomy[post_tag]=0&export_post_type=all' .
+        '&export_post_status=all&download=true';
+
+      $ch = curl_init();
+      curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
+      curl_setopt($ch, CURLOPT_COOKIEFILE, $cookie_file);
+      curl_setopt($ch, CURLOPT_URL, $export_url);
+      $data = curl_exec($ch);
+      dpm($data, 'data');
+      curl_close($ch);
+
+      file_unmanaged_save_data($data, $destination, FILE_EXISTS_REPLACE);
+      $saved = TRUE;
+    }
+    else {
+      form_set_error('credentials][domain', t('Could not login at !login_url',
+        array('!login_url' => $login_url)));
+      $saved = FALSE;
+    }
   }
+
+  if ($saved) {
+    // The excerpt namespace is sometimes omitted, stuff it in if necessary
+    $wxr_string = file_get_contents($destination);
+    $excerpt_ns = 'xmlns:excerpt="http://wordpress.org/export/1.0/excerpt/"';
+    $excerpt_signature = 'xmlns:excerpt="http://wordpress.org/export/';
+    $content_ns = 'xmlns:content="http://purl.org/rss/1.0/modules/content/"';
+    if (!strpos($wxr_string, $excerpt_signature)) {
+      $wxr_string = str_replace($content_ns, $excerpt_ns . "\n\t" . $content_ns, $wxr_string);
+    }
+    // Add the Atom namespace, in case it's referenced
+    $atom_ns = 'xmlns:atom="http://www.w3.org/2005/Atom"';
+    $wxr_string = str_replace($content_ns, $atom_ns . "\n\t" . $content_ns, $wxr_string);
+
+    // Fix unencoded ampersands
+    $wxr_string = str_replace('&amp;', '&', $wxr_string);
+    $wxr_string = str_replace('&', '&amp;', $wxr_string);
+    file_put_contents($destination, $wxr_string);
+    try {
+      $blog = wordpress_migrate_blog($destination);
+    }
+    catch (Exception $e) {
+      form_set_error('wxr_file', $e->getMessage());
+      file_unmanaged_delete($destination);
+      return;
+    }
+
+    // Import each migration in order, until done or time is running out
+    $spawned = FALSE;
+    foreach ($blog->migrations() as $migration) {
+      $result = $migration->processImport();
+      if ($result == MigrationBase::RESULT_INCOMPLETE) {
+        $drush = variable_get('wordpress_migrate_drush', '');
+        if (!$drush) {
+          if (user_access(WORDPRESS_MIGRATE_ACCESS_CONFIGURE)) {
+            $message = t('The blog was too large to import through the browser - please
+                <a href="@config">configure drush</a> so the import process may be
+                run in the background.',
+              array('@config' => url('admin/content/wordpress/configure')));
+          }
+          else {
+            $message = t('The blog was too large to import through the browser - please
+              contact a site administrator to properly configure the site for
+              background imports.');
+          }
+          form_set_error('wxr_file', $message);
+          break;
+        }
+        drupal_set_message(t('The blog is too large to completely import immediately -
+          the rest of the import will be run in the background and you will receive an email
+          when it is complete'));
+        $uri = 'http://' . $_SERVER['HTTP_HOST'];
+        $log_file = '/tmp/' . $filename . '.import.log';
+        $command = "$drush --uri=$uri wordpress-migrate-import $destination >$log_file 2>&1 &";
+        exec($command);
+        $spawned = TRUE;
+        break;
+      }
+    }
+    if (!$spawned) {
+      drupal_set_message(t('File %filename successfully uploaded and imported',
+        array('%filename' => $filename)));
+    }
+  }
+}
+
+/**
+ * Menu callback: Returns a page for reviewing WordPress migrations.
+ */
+function wordpress_migrate_review() {
+  drupal_set_title(t('WordPress migrations'));
+  return drupal_get_form('wordpress_migrate_review_form');
+}
+
+/**
+ * Form for reviewing WordPress migrations.
+ */
+function wordpress_migrate_review_form($form, &$form_state) {
+  if (isset($form_state['values']['operation']) &&
+      ($form_state['values']['operation'] == 'rollback' || $form_state['values']['operation'] == 'clear')) {
+    return wordpress_migrate_rollback_confirm($form, $form_state, array_filter($form_state['values']['blogs']));
+  }
+  $form['overview'] = array(
+    '#prefix' => '<p>',
+    '#markup' => t('These are the WordPress blogs which you have imported into your
+      Drupal site. For each component of the blog, the number of imported items is
+      displayed.'),
+    '#suffix' => '</p>',
+  );
+
+  $header = array(
+    'blog_url' => array('data' => t('Blog URL')),
+    'status' => array('data' => t('Status')),
+    'WordPressCategory' => array('data' => t('Categories')),
+    'WordPressTag' => array('data' => t('Tags')),
+    'WordPressBlogEntry' => array('data' => t('Posts')),
+    'WordPressPage' => array('data' => t('Pages')),
+    'WordPressAttachment' => array('data' => t('Attachments')),
+    'WordPressComment' => array('data' => t('Comments')),
+  );
+  $rows = array();
+  $blogs = call_user_func(array(wordpress_migrate_blog_class(), 'blogs'));
+  foreach ($blogs as $blog) {
+    $classes = array_flip($blog->migrationClasses());
+    $row['blog_url'] = $blog->getBlogUrl();
+    $row['status'] = t('Complete');
+    foreach ($blog->migrations() as $migration) {
+      $class = $classes[get_class($migration)];
+      $row[$class] = $migration->importedCount();
+      $status = $migration->getStatus();
+      if ($status == MigrationBase::STATUS_IMPORTING) {
+        $row['status'] = t('Importing');
+      }
+      elseif ($status == MigrationBase::STATUS_ROLLING_BACK) {
+        $row['status'] = t('Deleting');
+      }
+    }
+    $rows[$blog->getBlogUrl()] = $row;
+  }
+
+  $form['blogs'] = array(
+    '#type' => 'tableselect',
+    '#header' => $header,
+    '#options' => $rows,
+    '#empty' => t('No WordPress blogs have been migrated into your Drupal site'),
+  );
+
+  // Build the 'Update options' form.
+  $form['options'] = array(
+    '#type' => 'fieldset',
+    '#title' => t('Update options'),
+    '#attributes' => array('class' => array('container-inline')),
+  );
+  $options = array(
+    'rollback' => t('Remove imported content'),
+    'clear' => t('Remove migration bookkeeping'),
+  );
+  $form['options']['description'] = array(
+    '#prefix' => '<div>',
+    '#markup' => t('The WordPress migration process does considerable bookkeeping,
+      tracking how the original WordPress content maps to the imported Drupal content.
+      This bookkeeping allows you to easily back out the migration, restoring your
+      Drupal site to its initial state; once you are satisfied with the imported
+      content, you may remove the bookkeeping overhead.
+      <ul><li><strong>Remove imported content</strong> will restore
+      your Drupal site to its state before the selected WordPress blogs were
+      imported, deleting all imported content as well as the bookkeeping overhead.</li>
+      <li><strong>Remove bookkeeping only</strong> will remove the bookkeeping
+      overhead, retaining all imported content.</li></ul>'),
+    '#postfix' => '</div>',
+  );
+  $form['options']['operation'] = array(
+    '#type' => 'select',
+    '#title' => t('Operation'),
+    '#title_display' => 'invisible',
+    '#options' => $options,
+  );
+  $form['options']['submit'] = array(
+    '#type' => 'submit',
+    '#value' => t('Update'),
+    '#validate' => array('wordpress_migrate_review_validate'),
+    '#submit' => array('wordpress_migrate_review_submit'),
+  );
+
+  return $form;
+}
+
+/**
+ * Validate callback for the WordPress review form.
+ */
+function wordpress_migrate_review_validate($form, &$form_state) {
+  // Error if there are no items to select.
+  if (!is_array($form_state['values']['blogs']) || !count(array_filter($form_state['values']['blogs']))) {
+    form_set_error('', t('No items selected.'));
+  }
+}
+
+/**
+ * Submit callback for the WordPress review form.
+ */
+function wordpress_migrate_review_submit($form, &$form_state) {
+  // We need to rebuild the form to go to a second step (confirm blog rollback)
+  $form_state['rebuild'] = TRUE;
+}
+
+function wordpress_migrate_rollback_confirm($form, &$form_state, $blogs) {
+  $operation = $form_state['values']['operation'];
+
+  $form['blogs'] = array('#prefix' => '<ul>', '#suffix' => '</ul>', '#tree' => TRUE);
+  // array_filter returns only elements with TRUE values
+  foreach ($blogs as $blog_url) {
+    $form['blogs'][$blog_url] = array(
+      '#type' => 'hidden',
+      '#value' => $blog_url,
+      '#prefix' => '<li>',
+      '#suffix' => check_plain($blog_url) . "</li>\n",
+    );
+  }
+  $form['operation'] = array('#type' => 'hidden', '#value' => $operation);
+  $form['#submit'][] = 'wordpress_migrate_rollback_confirm_submit';
+  if ($operation == 'rollback') {
+    $confirm_question = format_plural(count($blogs),
+                                    'Are you sure you want to remove all imported content for this blog?',
+                                    'Are you sure you want to remove all imported content for these blogs?');
+  }
+  else {
+    $confirm_question = format_plural(count($blogs),
+                                    'Are you sure you want to remove all migration bookkeeping for this blog?',
+                                    'Are you sure you want to remove all migration bookkeeping for these blogs?');
+  }
+  return confirm_form($form,
+                    $confirm_question,
+                    'admin/content/wordpress/review', t('This action cannot be undone.'),
+                    t('Delete'), t('Cancel'));
+}
+
+function wordpress_migrate_rollback_confirm_submit($form, &$form_state) {
+  if ($form_state['values']['confirm']) {
+    $blogs = array_keys($form_state['values']['blogs']);
+    foreach ($blogs as $blog_url) {
+      // TODO: Batch API
+      // Rollback migrations for this domain
+      $filename = db_select('wordpress_migrate', 'wp')
+                   ->fields('wp', array('filename'))
+                   ->condition('blog_url', $blog_url)
+                   ->execute()
+                   ->fetchField();
+      $blog = wordpress_migrate_blog($filename);
+      $migrations = array_reverse($blog->migrations());
+      $success = TRUE;
+      foreach ($migrations as $migration) {
+        // Only rollback content for the rollback operation
+        if ($form_state['values']['operation'] == 'clear') {
+          // Remove map/message tables, and migrate_status table entry
+          Migration::deregisterMigration($migration->getMachineName());
+          $success = TRUE;
+        }
+        else {
+          // If not currently idle, stop it before attempting rollback
+          if ($migration->getStatus() != MigrationBase::STATUS_IDLE) {
+            $migration->stopProcess();
+            // Give it a little time to react to the stop request
+            $count = 5;
+            while ($count && $migration->getStatus() == MigrationBase::STATUS_STOPPING) {
+              sleep(2);
+              $count--;
+            }
+            if ($migration->getStatus() == MigrationBase::STATUS_STOPPING) {
+              // At this point, assume it's stuck and reset the status so we can continue
+              $migration->resetStatus();
+            }
+          }
+          $result = $migration->processRollback();
+          if ($result == MigrationBase::RESULT_INCOMPLETE) {
+            $drush = variable_get('wordpress_migrate_drush', '');
+            if (!$drush) {
+              if (user_access(WORDPRESS_MIGRATE_ACCESS_CONFIGURE)) {
+                $message = t('The blog was too large to delete through the browser - please
+                      <a href="@config">configure drush</a> so the deletion process may be
+                      run in the background.',
+                array('@config' => url('admin/content/wordpress/configure')));
+              }
+              else {
+                $message = t('The blog was too large to delete through the browser - please
+                    contact a site administrator to properly configure the site for
+                    background deletion.');
+              }
+              drupal_set_message($message);
+              break;
+            }
+            drupal_set_message(t('The blog is too large to completely delete immediately -
+                the rest of the deletion will be run in the background.'));
+            $uri = 'http://' . $_SERVER['HTTP_HOST'];
+            $log_file = '/tmp/' . basename($blog->getFilename()) . '.rollback.log';
+            $destination = $blog->getFilename();
+            $command = "$drush --uri=$uri wordpress-migrate-rollback $destination >$log_file 2>&1 &";
+            exec($command);
+            $success = FALSE;
+            break;
+          }
+          elseif ($result == MigrationBase::RESULT_COMPLETED) {
+            // Remove map/message tables, and migrate_status table entry
+            Migration::deregisterMigration($migration->getMachineName());
+          }
+          else {
+            drupal_set_message(t('Failed to complete rollback, status=!status',
+              array('!status' => $result)));
+            $success = FALSE;
+            break;
+          }
+        }
+      }
+
+      if ($success) {
+        // Clear wordpress_migrate table entry
+        db_delete('wordpress_migrate')
+          ->condition('blog_url', $blog_url)
+          ->execute();
+
+        // Delete WXR file
+        file_unmanaged_delete($filename);
+
+        // Delete photo gallery
+        if (module_exists('media_gallery')) {
+          global $user;
+          $blog_title = t("@name's blog", array('@name' => format_username($user)));
+          $gallery_nid = db_select('node', 'n')
+            ->fields('n', array('nid'))
+            ->condition('type', 'media_gallery')
+            ->condition('title', $blog_title)
+            ->execute()
+            ->fetchField();
+          if ($gallery_nid) {
+            node_delete($gallery_nid);
+          }
+        }
+      }
+    }
+    if ($success) {
+      $count = count($form_state['values']['blogs']);
+      watchdog('content', 'Deleted @count WordPress blogs.', array('@count' => $count));
+      drupal_set_message(format_plural($count, 'Deleted 1 WordPress blog.',
+        'Deleted @count WordPress blogs.'));
+    }
+  }
+  $form_state['redirect'] = 'admin/content/wordpress/review';
+}
+
+/**
+ * Menu callback: Returns a page for configuring WordPress migrations.
+ */
+function wordpress_migrate_configure() {
+  drupal_set_title(t('WordPress configuration'));
+  return drupal_get_form('wordpress_migrate_configure_form');
+}
+
+/**
+ * Form for configuring WordPress migrations.
+ */
+function wordpress_migrate_configure_form($form, &$form_state) {
+  $form['wordpress_migrate_drush'] = array(
+    '#type' => 'textfield',
+    '#title' => t('Location of drush command on server'),
+    '#default_value' => variable_get('wordpress_migrate_drush', ''),
+    '#description' => t('Larger blogs need to be imported by spawning a drush
+      command. Please enter the full path of the drush command on the server
+      to enable this functionality.'),
+  );
+
+  // Select destination node type(s)
+  $node_types = node_type_get_types();
+  $options = array();
+  foreach ($node_types as $node_type => $info) {
+    $options[$node_type] = $info->name;
+  }
+
+  if (isset($options['blog'])) {
+    $default = 'blog';
+  }
+  else {
+    $default = '';
+  }
+
+  $form['wordpress_migrate_post_type'] = array(
+    '#type' => 'select',
+    '#title' => t('Type of node to hold WordPress posts'),
+    '#default_value' => variable_get('wordpress_migrate_post_type', $default),
+    '#options' => $options,
+    '#description' => t(''),
+  );
+
+  if (isset($options['page'])) {
+    $default = 'page';
+  }
+  else {
+    $default = '';
+  }
+
+  $form['wordpress_migrate_page_type'] = array(
+    '#type' => 'select',
+    '#title' => t('Type of node to hold WordPress pages'),
+    '#default_value' => variable_get('wordpress_migrate_page_type', $default),
+    '#options' => $options,
+    '#description' => t(''),
+  );
+
+  // Select default text format for bodies etc.
+  $options = array();
+  foreach (filter_formats() as $format_id => $format) {
+    $options[$format_id] = $format->name;
+  }
+  $form['wordpress_migrate_text_format'] = array(
+    '#type' => 'select',
+    '#title' => t('Format for text fields'),
+    '#default_value' => variable_get('wordpress_migrate_text_format', 'filtered_html'),
+    '#options' => $options,
+    '#description' => t(''),
+  );
+
+  // TODO: Select user to own blog
+
+  // Select vocabularies for categories and tags
+  // TODO: Get only those attached to destination content types
+  $vocabs = taxonomy_vocabulary_get_names();
+  $options = array('' => t('Do not import'));
+  foreach ($vocabs as $machine_name => $vocab) {
+    $options[$machine_name] = $vocab->name;
+  }
+
+  $form['wordpress_migrate_tag_vocabulary'] = array(
+    '#type' => 'select',
+    '#title' => t('Vocabulary for WordPress tags'),
+    '#default_value' => variable_get('wordpress_migrate_tag_vocabulary', ''),
+    '#options' => $options,
+    '#description' => t('Choose the vocabulary to hold WordPress tags.'),
+  );
+
+  $form['wordpress_migrate_category_vocabulary'] = array(
+    '#type' => 'select',
+    '#title' => t('Vocabulary for WordPress categories'),
+    '#default_value' => variable_get('wordpress_migrate_category_vocabulary', ''),
+    '#options' => $options,
+    '#description' => t('Choose the vocabulary to hold WordPress categories.'),
+  );
+
+  // TODO: Select mechanism for running import (Batch API, cron, spawned process...)
+
+  $form['wordpress_migrate_notification_subject'] = array(
+    '#type' => 'textfield',
+    '#title' => t('Subject for email notifications'),
+    '#default_value' => variable_get('wordpress_migrate_notification_subject',
+      t('Wordpress import status')),
+    '#description' => t(''),
+  );
+
+  $form['wordpress_migrate_notification_body'] = array(
+    '#type' => 'textarea',
+    '#title' => t('Body for email notifications of import success'),
+    '#default_value' => variable_get('wordpress_migrate_notification_body',
+      t("Your WordPress import is complete! Any messages generated are below.\n\n!output")),
+  );
+
+  $form['wordpress_migrate_notification_failure_body'] = array(
+    '#type' => 'textarea',
+    '#title' => t('Body for email notifications of import failure'),
+    '#default_value' => variable_get('wordpress_migrate_notification_failure_body',
+      t('Your WordPress import failed. Any messages generated are below.\n\n!output')),
+  );
+
+  // TODO: For most of the above, indicate whether they can be overridden per import
+
+  $form['submit'] = array(
+    '#type' => 'submit',
+    '#value' => t('Save configuration changes'),
+  );
+
+  return $form;
+}
+
+/**
+ * Submit callback for the WordPress configure form.
+ */
+function wordpress_migrate_configure_form_submit($form, &$form_state) {
+  // TODO: Verify drush command file exists
+  variable_set('wordpress_migrate_drush', $form_state['values']['wordpress_migrate_drush']);
+
+  variable_set('wordpress_migrate_post_type', $form_state['values']['wordpress_migrate_post_type']);
+  variable_set('wordpress_migrate_text_format', $form_state['values']['wordpress_migrate_text_format']);
+  variable_set('wordpress_migrate_page_type', $form_state['values']['wordpress_migrate_page_type']);
+  variable_set('wordpress_migrate_tag_vocabulary', $form_state['values']['wordpress_migrate_tag_vocabulary']);
+  variable_set('wordpress_migrate_category_vocabulary', $form_state['values']['wordpress_migrate_category_vocabulary']);
+  variable_set('wordpress_migrate_notification_subject', $form_state['values']['wordpress_migrate_notification_subject']);
+  variable_set('wordpress_migrate_notification_body', $form_state['values']['wordpress_migrate_notification_body']);
+  variable_set('wordpress_migrate_notification_failure_body', $form_state['values']['wordpress_migrate_notification_failure_body']);
+  drupal_set_message(t('WordPress configuration changes saved.'));
 }
