Index: modules/aggregator/aggregator.admin.inc
===================================================================
RCS file: /cvs/drupal/drupal/modules/aggregator/aggregator.admin.inc,v
retrieving revision 1.10
diff -u -p -r1.10 aggregator.admin.inc
--- modules/aggregator/aggregator.admin.inc	15 May 2008 21:27:32 -0000	1.10
+++ modules/aggregator/aggregator.admin.inc	1 Jul 2008 08:55:28 -0000
@@ -214,6 +214,169 @@ function aggregator_admin_remove_feed_su
 }
 
 /**
+ * Form builder; Generate a form to import feeds from OPML.
+ *
+ * @ingroup forms
+ * @see aggregator_form_opml_submit()
+ */
+function aggregator_form_opml(&$form_state) {
+  $period = drupal_map_assoc(array(900, 1800, 3600, 7200, 10800, 21600, 32400, 43200, 64800, 86400, 172800, 259200, 604800, 1209600, 2419200), 'format_interval');
+
+  $form['#attributes'] = array('enctype' => "multipart/form-data");
+
+  $form['upload'] = array(
+    '#type' => 'file',
+    '#title' => t('OPML File'),
+    '#description' => t('Upload an OPML file containing a list of feeds to be imported.'),
+  );
+
+  $form['or'] = array(
+    '#type' => 'item',
+    '#value' => t('<strong>or</strong>'),
+  );
+
+  $form['remote'] = array(
+    '#type' => 'textfield',
+    '#title' => t('OPML Remote URL'),
+    '#description' => t('Enter the URL of an OPML file. This file will be downloaded and processed only once on submission of the form.'),
+  );
+
+  $form['refresh'] = array(
+    '#type' => 'select',
+    '#title' => t('Update interval'),
+    '#default_value' => 3600,
+    '#options' => $period,
+    '#description' => t('The length of time between feed updates. (Requires a correctly configured <a href="@cron">cron maintenance task</a>.)', array('@cron' => url('admin/reports/status'))),
+  );
+
+  // Handling of categories:
+  $options = array();
+  $categories = db_query('SELECT cid, title FROM {aggregator_category} ORDER BY title');
+  while ($category = db_fetch_object($categories)) {
+    $options[$category->cid] = check_plain($category->title);
+  }
+  if ($options) {
+    $form['category'] = array(
+      '#type' => 'checkboxes',
+      '#title' => t('Categorize news items'),
+      '#options' => $options,
+      '#description' => t('New feed items are automatically filed in the checked categories.'),
+    );
+  }
+  else {
+    $form['category'] = array(
+      '#type' => 'item',
+      '#value' => t('No <a href="@category">feed categories</a> exist. You may want to create one now, because this is the only time all new feeds can be categorized at once.', array('@category' => url('admin/content/aggregator/add/category'))),
+    );
+  }
+  $form['submit'] = array('#type' => 'submit', '#value' => t('Import'));
+
+  return $form;
+}
+
+/**
+ * Validate aggregator_form_opml form submissions.
+ */
+function aggregator_form_opml_validate($form, &$form_state) {
+  // If both fields are empty or filled, cancel.
+  if (empty($form_state['values']['remote']) == empty($_FILES['files']['name']['upload'])) {
+    form_set_error('remote', t('You must <em>either</em> upload a file or enter a URL.'));
+  }
+
+  // Validate the URL, if one was entered.
+  if (!empty($form_state['values']['remote']) && !valid_url($form_state['values']['remote'], TRUE)) {
+    form_set_error('remote', t('This URL is not valid.'));
+  }
+}
+
+/**
+ * Process aggregator_form_opml form submissions.
+ */
+function aggregator_form_opml_submit($form, &$form_state) {
+  if ($file = file_save_upload('upload')) {
+    $data = file_get_contents($file->filepath);
+  }
+  else {
+    $response = drupal_http_request($form_state['values']['remote']);
+    $data = $response->data;
+  }
+
+  $feeds = _aggregator_parse_opml_feeds($data);
+
+  if (!empty($feeds)) {
+    drupal_set_message(t('This OPML outline contains %num feeds.', array('%num' => count($feeds))), 'status');
+  }
+  else if (is_array($feeds)) {
+    drupal_set_message(t('The OPML outline is valid, but does not contain any feeds.'), 'error');
+    return;
+  }
+  else {
+    drupal_set_message(t('The OPML outline is invalid and could not be parsed.'), 'error');
+    return;
+  }
+
+  $form_state['values']['op'] = t('Save');
+
+  foreach ($feeds as $feed) {
+    $duplicate = FALSE;
+    $result = db_query("SELECT title, url FROM {aggregator_feed} WHERE title = '%s' OR url='%s'", $feed['title'], $feed['url']);
+    while (!$duplicate && $old = db_fetch_object($result)) {
+      if (strcasecmp($old->title, $feed['title']) == 0) {
+        drupal_set_message(t('A feed named %title already exists and was not added.', array('%title' => $old->title)), 'warning');
+        $duplicate = TRUE;
+      }
+      if (strcasecmp($old->url, $feed['url']) == 0) {
+        drupal_set_message(t('A feed with the URL %url already exists and was not added.', array('%url' => $old->url)), 'warning');
+        $duplicate = TRUE;
+      }
+    }
+    if (!$duplicate) {
+      $form_state['values']['title'] = $feed['title'];
+      $form_state['values']['url'] = $feed['url'];
+      drupal_execute('aggregator_form_feed', $form_state);
+    }
+  }
+}
+
+/**
+ * Parses an OPML file, extracting feeds from it. Feeds are
+ * recognized as <outline> elements with the attributes <em>text</em>
+ * and <em>xmlurl</em> set.
+ *
+ * @param $opml
+ *   The complete contents of an OPML document.
+ *
+ * @return
+ *   An array of feeds, each an associative array with a <em>title</em> and 
+ *   a <em>url</em> element, or NULL if the OPML document failed to be parsed. 
+ *   An empty array will be returned if the document is valid but 
+ *   contains no feeds, as some OPML documents do.
+ */
+function _aggregator_parse_opml_feeds($opml) {
+  $items = array();
+  $feeds = array();
+
+  $parser = drupal_xml_parser_create($opml);
+
+  if (xml_parse_into_struct($parser, $opml, $vals, $index)) {
+    foreach ($vals as $entry) {
+      if ($entry['tag'] == 'OUTLINE' && isset($entry['attributes'])) {
+        $items[] = $entry['attributes'];
+      }
+    }
+    foreach ($items as $n => $item) {
+      if (!empty($item['XMLURL'])) {
+        $feed = array('title' => $item['TEXT'], 'url' => $item['XMLURL']);
+        $feeds[] = $feed;
+      }
+    }
+  }
+  else return NULL;
+
+  return $feeds;
+}
+
+/**
  * Menu callback; refreshes a feed, then redirects to the overview page.
  *
  * @param $feed
Index: modules/aggregator/aggregator.module
===================================================================
RCS file: /cvs/drupal/drupal/modules/aggregator/aggregator.module,v
retrieving revision 1.381
diff -u -p -r1.381 aggregator.module
--- modules/aggregator/aggregator.module	18 Jun 2008 03:36:23 -0000	1.381
+++ modules/aggregator/aggregator.module	1 Jul 2008 08:55:28 -0000
@@ -24,6 +24,8 @@ function aggregator_help($path, $arg) {
       return '<p>' . t('Add a feed in RSS, RDF or Atom format. A feed may only have one entry.') . '</p>';
     case 'admin/content/aggregator/add/category':
       return '<p>' . t('Categories allow feed items from different feeds to be grouped together. For example, several sport-related feeds may belong to a category named <em>Sports</em>. Feed items may be grouped automatically (by selecting a category when creating or editing a feed) or manually (via the <em>Categorize</em> page available from feed item listings). Each category provides its own feed page and block.') . '</p>';
+    case 'admin/content/aggregator/add/opml':
+      return '<p>' . t('<acronym title="Outline Processor Markup Language">OPML</acronym> is an XML format used to exchange multiple feeds between aggregators. A single OPML document may contain a collection of many feeds. Drupal can parse such a file and import all feeds at once, saving you the effort of adding them manually. You may either upload a local file from your computer or enter a URL where Drupal can download it.') . '</p>';
   }
 }
 
@@ -101,6 +103,15 @@ function aggregator_menu() {
     'type' => MENU_LOCAL_TASK,
     'parent' => 'admin/content/aggregator',
   );
+  $items['admin/content/aggregator/add/opml'] = array(
+    'title' => 'Import OPML',
+    'page callback' => 'drupal_get_form',
+    'page arguments' => array('aggregator_form_opml'),
+    'access arguments' => array('administer news feeds'),
+    'type' => MENU_LOCAL_TASK,
+    'parent' => 'admin/content/aggregator',
+    'file' => 'aggregator.admin.inc',
+  );
   $items['admin/content/aggregator/remove/%aggregator_feed'] = array(
     'title' => 'Remove items',
     'page callback' => 'drupal_get_form',
Index: modules/aggregator/aggregator.test
===================================================================
RCS file: /cvs/drupal/drupal/modules/aggregator/aggregator.test,v
retrieving revision 1.3
diff -u -p -r1.3 aggregator.test
--- modules/aggregator/aggregator.test	30 May 2008 07:30:49 -0000	1.3
+++ modules/aggregator/aggregator.test	1 Jul 2008 08:55:28 -0000
@@ -312,3 +312,197 @@ class CategorizeFeedItemTestCase extends
     $this->deleteFeed($feed);
   }
 }
+
+class ImportOPMLTestCase extends AggregatorTestCase {
+  private static $prefix = 'simpletest_aggregator_';
+  /**
+   * Implementation of getInfo().
+   */
+  function getInfo() {
+    return array(
+      'name' => t('Import feeds from OPML'),
+      'description' => t('Uploads and parses an OPML document to add feeds contained in it.'),
+      'group' => t('Aggregator'),
+    );
+  }
+
+  /**
+   * Implementation of setUp().
+   */
+  function setUp() {
+    parent::setUp();
+
+    $badOpml = <<<EOF
+<opml>
+  <invalid>
+</opml>
+EOF;
+
+    $emptyOpml = <<<EOF
+<?xml version="1.0" encoding="utf-8"?>
+<opml version="1.0"> 
+  <head></head>
+  <body>
+    <outline text="Sample text" />
+    <outline text="Sample text" url="Sample URL" />
+  </body>
+</opml>
+EOF;
+  $feeds[0] = $this->getFeedEditArray();
+  $feeds[1] = $this->getFeedEditArray();
+  $feeds[2] = $this->getFeedEditArray();
+
+    /* One file has an XML declaration, the other does not - 
+     * both must pass the parser.
+     */
+    $goodOpml = <<<EOF
+<opml version="1.0"> 
+  <head></head>
+  <body>
+    <!-- first feed to be imported -->
+    <outline text="$feeds[0][title]" xmlurl="$feeds[0][url]" />
+    
+    <!-- second feed tests string delimiation and attribute order -->
+    <outline xmlurl='$feeds[1][url]' text='$feeds[1][title]'/>
+    
+    <!-- testing for duplicate url and title -->
+    <outline xmlurl="$feeds[0][url]" text="Duplicate URL"/>
+    <outline xmlurl="http://duplicate.title" text="$feeds[1][title]"/>
+    
+    <!-- testing that feeds are only added with required attributes -->
+    <outline text="$feeds[2][title]" />
+    <outline xmlurl="$feeds[2][url]" />
+  </body>
+</opml>
+EOF;
+    
+    $this->path = file_directory_path() . '/opml';
+    file_check_directory($this->path);
+    
+    file_save_data($badOpml, $this->path . '/badopml.xml');
+    file_save_data($emptyOpml, $this->path . '/emptyopml.xml');
+    file_save_data($goodOpml, $this->path . '/goodopml.xml');
+    
+    $this->feeds = $feeds;
+  }
+
+  /**
+   * Open the form.
+   */
+  function testFormOpen() {
+    db_query('TRUNCATE {aggregator_category}');
+    $this->drupalGet('admin/content/aggregator/add/opml');
+    $this->assertText('A single OPML document may contain a collection of many feeds.', t('Looking for help text.'));
+    $this->assertFieldByName('remote', '', t('Looking for remote URL field.'));
+    $this->assertText('You may want to create one now', t('Warning if no categories exist.'));
+
+    $this->cat_title = $this->randomName(10, self::$prefix);
+
+    db_query("INSERT INTO {aggregator_category} (cid, title, description) VALUES (%d, '%s', '%s')", 1, $this->cat_title, "Test Category");
+    $this->drupalGet('admin/content/aggregator/add/opml');
+    $this->assertFieldByName('category[1]', $cat_title, t('Category field if categories exist.'));
+  }  
+
+  /**
+   * Try to import a submit an invalid form.
+   */
+  function testFormBadFields() {
+    $before = db_result(db_query('SELECT COUNT(*) FROM {aggregator_feed}'));
+    $badfields = t('You must <em>either</em> upload a file or enter a URL.');
+    $badurl    = t('This URL is not valid.');
+
+    $form = array(
+      'files[upload]' => '',
+      'remote'        => '',
+    );
+
+    $this->drupalPost('admin/content/aggregator/add/opml', $form, t('Import'));
+    $this->assertRaw($badfields, t('Error if no fields are filled.'));
+
+    $form['files[upload]'] = $this->path . '/goodopml.xml';
+    $form['remote'] = file_create_url($this->path . '/goodopml.xml');
+
+    $this->drupalPost('admin/content/aggregator/add/opml', $form, t('Import'));
+    $this->assertRaw($badfields, t('Error if both fields are filled.'));
+    
+    $form = array();
+    $form['remote'] = "invalidUrl://empty";
+    $this->drupalPost('admin/content/aggregator/add/opml', $form, t('Import'));
+    $this->assertText($badurl, t('Error if the URL is bad.'));
+    $after = db_result(db_query('SELECT COUNT(*) FROM {aggregator_feed}'));
+    $this->assertEqual($before, $after, t('No feeds were added during the three last form submissions.'));
+  }
+  
+  /**
+   * Try to import a submit an invalid XML file
+   */
+  function testFormBadXML() {
+    $badxml = t('The OPML outline is invalid and could not be parsed.');
+    $emptyxml = t('The OPML outline is valid, but does not contain any feeds.');
+    
+    $before = db_result(db_query('SELECT COUNT(*) FROM {aggregator_feed}'));
+    $form['files[upload]'] = $this->path . '/badopml.xml';
+
+    $this->drupalPost('admin/content/aggregator/add/opml', $form, t('Import'));
+    $this->assertText($badxml, t('Attempting to upload invalid XML.'));
+    
+    $form = array();
+
+    $form['remote']    = file_create_url($this->path . '/emptyopml.xml');
+    $this->drupalPost('admin/content/aggregator/add/opml', $form, t('Import'));
+    $this->assertText($emptyxml, t('Attempting to load XML that contains no feeds by remote URL.'));
+    $after = db_result(db_query('SELECT COUNT(*) FROM {aggregator_feed}'));
+    
+    $this->assertEqual($before, $after, t('No feeds were added during the four last form submissions.'));
+  }
+  
+  /**
+   * Try to import feeds.
+   */
+  function testFormImport() {
+    $form = array(
+      'files[upload]' => $this->path . '/goodopml.xml',
+      'refresh'       => '900',
+      'category[1]'   => '1',
+    );
+    $this->_testFormImport($form);
+    
+    $form = array(
+      'remote' => file_create_url($this->path . '/goodopml.xml'),
+      'refresh'       => '900',
+      'category[1]'   => '1',
+    );
+  }
+   
+  function _testFormImport($form) {
+    $feedcount = t('This OPML outline contains %num feeds.', array('%num' => 4));
+    $duplicate[0] = t('A feed with the URL %url already exists and was not added.', array('%url' => $this->feeds[0]['url']));
+    $duplicate[1] = t('A feed named %title already exists and was not added.', array('%title' => $this->feeds[1]['title']));
+    
+    db_query('TRUNCATE {aggregator_feed}');
+    db_query('TRUNCATE {aggregator_category_feed}');
+
+    $this->drupalPost('admin/content/aggregator/add/opml', $form, t('Import'));
+
+    $this->assertRaw($feedcount, t('Verifying the number of parsed feeds.'));
+    $this->assertRaw($duplicate[0], t('Verifying that a duplicate URL was identified'));
+    $this->assertRaw($duplicate[1], t('Verifying that a duplicate title was identified'));
+
+    $after = db_result(db_query('SELECT COUNT(*) FROM {aggregator_feed}'));
+    $this->assertEqual($after, 2, t('Verifying that two distinct feeds were added.'));
+    
+    $res = db_query("SELECT url, title, cid FROM {aggregator_feed} NATURAL JOIN {aggregator_feed_category}");
+    $refresh = $cat = TRUE;
+    while ($row = db_fetch_array($res)) {
+      $title[$row['url']] = $row['title'];
+      $url[$row['title']] = $row['url'];
+      $refresh = $refresh && $row['refresh'] == 900;
+      $cat = $cat && $row['cid'] == 1;
+    }
+    
+    $this->assertEqual($title[$this->feeds[0]['url']], $this->feeds[0]['title'], t('First feed was added correctly.'));
+    $this->assertEqual($url[$this->feeds[1]['title']], $this->feeds[1]['url'], t('Second feed was added correctly.'));
+    $this->assertTrue($refresh, t('Refresh times are correct.'));
+    $this->assertTrue($cat, t('Categories are correct.'));
+  }
+}
