diff --git a/CHANGELOG.txt b/CHANGELOG.txt index b38b95a575d63eff290dd77253b84215da627587..a9ef6c9b8be4528e35b002a90debff0eac57953c 100644 --- a/CHANGELOG.txt +++ b/CHANGELOG.txt @@ -19,6 +19,8 @@ Drupal 7.0, xxxx-xx-xx (development version) slimmed down install profile for developers. * Image toolkits are added by modules instead of being copied to the includes directory. +- News aggregator: + * Added OPML import functionality for RSS feeds. - Search: * Added support for language-aware searches. - Testing: diff --git a/modules/aggregator/aggregator.admin.inc b/modules/aggregator/aggregator.admin.inc index abbe9ca19a075c7a196aee0d50ea9800d909f551..18b003fa3368914a8dc244da1149eb746d1aa65e 100644 --- a/modules/aggregator/aggregator.admin.inc +++ b/modules/aggregator/aggregator.admin.inc @@ -213,6 +213,151 @@ function aggregator_admin_remove_feed_submit($form, &$form_state) { $form_state['redirect'] = 'admin/content/aggregator'; } +/** + * Form builder; Generate a form to import feeds from OPML. + * + * @ingroup forms + * @see aggregator_form_opml_validate() + * @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['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 cron maintenance task.)', 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.'), + ); + } + $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 either 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) { + $data = ''; + if ($file = file_save_upload('upload')) { + $data = file_get_contents($file->filepath); + } + else { + $response = drupal_http_request($form_state['values']['remote']); + if (!isset($response->error)) { + $data = $response->data; + } + } + + $feeds = _aggregator_parse_opml($data); + if (empty($feeds)) { + drupal_set_message(t('No new feed has been added.')); + return; + } + + $form_state['values']['op'] = t('Save'); + + foreach ($feeds as $feed) { + $result = db_query("SELECT title, url FROM {aggregator_feed} WHERE title = '%s' OR url = '%s'", $feed['title'], $feed['url']); + $duplicate = FALSE; + while ($old = db_fetch_object($result)) { + if (strcasecmp($old->title, $feed['title']) == 0) { + drupal_set_message(t('A feed named %title already exists.', array('%title' => $old->title)), 'warning'); + $duplicate = TRUE; + continue; + } + if (strcasecmp($old->url, $feed['url']) == 0) { + drupal_set_message(t('A feed with the URL %url already exists.', array('%url' => $old->url)), 'warning'); + $duplicate = TRUE; + continue; + } + } + if (!$duplicate) { + $form_state['values']['title'] = $feed['title']; + $form_state['values']['url'] = $feed['url']; + drupal_execute('aggregator_form_feed', $form_state); + } + } +} + +/** + * Parse an OPML file. + * + * Feeds are recognized as elements with the attributes + * text and xmlurl set. + * + * @param $opml + * The complete contents of an OPML document. + * @return + * An array of feeds, each an associative array with a title and + * a url 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($opml) { + $feeds = array(); + $xml_parser = drupal_xml_parser_create($opml); + if (xml_parse_into_struct($xml_parser, $opml, $values)) { + foreach ($values as $entry) { + if ($entry['tag'] == 'OUTLINE' && isset($entry['attributes'])) { + $item = $entry['attributes']; + if (!empty($item['XMLURL'])) { + $feeds[] = array('title' => $item['TEXT'], 'url' => $item['XMLURL']); + } + } + } + } + xml_parser_free($xml_parser); + + return $feeds; +} + /** * Menu callback; refreshes a feed, then redirects to the overview page. * diff --git a/modules/aggregator/aggregator.module b/modules/aggregator/aggregator.module index f2c0df679609ce2c8bbcdbae9119270e618115ba..fcc6c34aad8bf768b3004d1eb2e974eda9d37b73 100644 --- a/modules/aggregator/aggregator.module +++ b/modules/aggregator/aggregator.module @@ -24,6 +24,8 @@ function aggregator_help($path, $arg) { return '

' . t('Add a feed in RSS, RDF or Atom format. A feed may only have one entry.') . '

'; case 'admin/content/aggregator/add/category': return '

' . t('Categories allow feed items from different feeds to be grouped together. For example, several sport-related feeds may belong to a category named Sports. Feed items may be grouped automatically (by selecting a category when creating or editing a feed) or manually (via the Categorize page available from feed item listings). Each category provides its own feed page and block.') . '

'; + case 'admin/content/aggregator/add/opml': + return '

' . t('OPML 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.') . '

'; } } @@ -101,6 +103,14 @@ 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', + ); $items['admin/content/aggregator/remove/%aggregator_feed'] = array( 'title' => 'Remove items', 'page callback' => 'drupal_get_form', diff --git a/modules/aggregator/aggregator.test b/modules/aggregator/aggregator.test index eadaf8ec4d3c7caf9c3dfcb8f0cdbe7f2daa4ae9..497f5652581a1190daf17ecf6af73ef94a054f57 100644 --- a/modules/aggregator/aggregator.test +++ b/modules/aggregator/aggregator.test @@ -116,6 +116,85 @@ class AggregatorTestCase extends DrupalWebTestCase { $result = db_result(db_query("SELECT count(*) FROM {aggregator_feed} WHERE title = '%s' AND url='%s'", $feed_name, $feed_url)); return (1 == $result); } + + /** + * Create a valid OPML file from an array of feeds. + * + * @param $feeds + * An array of feeds. + * @return + * Path to valid OPML file. + */ + function getValidOpml($feeds) { + /** + * Does not have an XML declaration, must pass the parser. + */ + $opml = << + + + + + + + + + + + + + + + + + +EOF; + + $path = file_directory_path() . '/valid-opml.xml'; + file_save_data($opml, $path); + return $path; + } + + /** + * Create an invalid OPML file. + * + * @return + * Path to invalid OPML file. + */ + function getInvalidOpml() { + $opml = << + + +EOF; + + $path = file_directory_path() . '/invalid-opml.xml'; + file_save_data($opml, $path); + return $path; + } + + /** + * Create a valid but empty OPML file. + * + * @return + * Path to empty OPML file. + */ + function getEmptyOpml() { + $opml = << + + + + + + + +EOF; + + $path = file_directory_path() . '/empty-opml.xml'; + file_save_data($opml, $path); + return $path; + } } class AddFeedTestCase extends AggregatorTestCase { @@ -312,3 +391,121 @@ class CategorizeFeedItemTestCase extends AggregatorTestCase { $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 functionality'), + 'description' => t('Test OPML import.'), + 'group' => t('Aggregator'), + ); + } + + /** + * Open OPML import form. + */ + function openImportForm() { + db_query('TRUNCATE {aggregator_category}'); + + $category = $this->randomName(10, self::$prefix); + db_query("INSERT INTO {aggregator_category} (cid, title, description) VALUES (%d, '%s', '%s')", 1, $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('files[upload]', '', t('Looking for file upload field.')); + $this->assertFieldByName('remote', '', t('Looking for remote URL field.')); + $this->assertFieldByName('refresh', '', t('Looking for refresh field.')); + $this->assertFieldByName('category[1]', '1', t('Looking for category field.')); + } + + /** + * Submit form filled with invalid fields. + */ + function validateImportFormFields() { + $before = db_result(db_query('SELECT COUNT(*) FROM {aggregator_feed}')); + + $form = array(); + $this->drupalPost('admin/content/aggregator/add/opml', $form, t('Import')); + $this->assertRaw(t('You must either upload a file or enter a URL.'), t('Error if no fields are filled.')); + + $path = $this->getEmptyOpml(); + $form = array( + 'files[upload]' => $path, + 'remote' => file_create_url($path), + ); + $this->drupalPost('admin/content/aggregator/add/opml', $form, t('Import')); + $this->assertRaw(t('You must either upload a file or enter a URL.'), t('Error if both fields are filled.')); + + $form = array('remote' => 'invalidUrl://empty'); + $this->drupalPost('admin/content/aggregator/add/opml', $form, t('Import')); + $this->assertText(t('This URL is not valid.'), t('Error if the URL is invalid.')); + + $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.')); + } + + /** + * Submit form with invalid, empty and valid OPML files. + */ + function submitImportForm() { + $before = db_result(db_query('SELECT COUNT(*) FROM {aggregator_feed}')); + + $form['files[upload]'] = $this->getInvalidOpml(); + $this->drupalPost('admin/content/aggregator/add/opml', $form, t('Import')); + $this->assertText(t('No new feed has been added.'), t('Attempting to upload invalid XML.')); + + $form = array('remote' => file_create_url($this->getEmptyOpml())); + $this->drupalPost('admin/content/aggregator/add/opml', $form, t('Import')); + $this->assertText(t('No new feed has been added.'), t('Attempting to load empty OPML from remote URL.')); + + $after = db_result(db_query('SELECT COUNT(*) FROM {aggregator_feed}')); + $this->assertEqual($before, $after, t('No feeds were added during the two last form submissions.')); + + db_query('TRUNCATE {aggregator_feed}'); + db_query('TRUNCATE {aggregator_category}'); + db_query('TRUNCATE {aggregator_category_feed}'); + + $category = $this->randomName(10, self::$prefix); + db_query("INSERT INTO {aggregator_category} (cid, title, description) VALUES (%d, '%s', '%s')", 1, $category, ''); + + $feeds[0] = $this->getFeedEditArray(); + $feeds[1] = $this->getFeedEditArray(); + $feeds[2] = $this->getFeedEditArray(); + $form = array( + 'files[upload]' => $this->getValidOpml($feeds), + 'refresh' => '900', + 'category[1]' => $category, + ); + $this->drupalPost('admin/content/aggregator/add/opml', $form, t('Import')); + $this->assertRaw(t('A feed with the URL %url already exists.', array('%url' => $feeds[0]['url'])), t('Verifying that a duplicate URL was identified')); + $this->assertRaw(t('A feed named %title already exists.', array('%title' => $feeds[1]['title'])), 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.')); + + $feeds_from_db = db_query("SELECT f.title, f.url, f.refresh, cf.cid FROM {aggregator_feed} f LEFT JOIN {aggregator_category_feed} cf ON f.fid = cf.fid"); + $refresh = $category = TRUE; + while ($feed = db_fetch_array($feeds_from_db)) { + $title[$feed['url']] = $feed['title']; + $url[$feed['title']] = $feed['url']; + $category = $category && $feed['cid'] == 1; + $refresh = $refresh && $feed['refresh'] == 900; + } + + $this->assertEqual($title[$feeds[0]['url']], $feeds[0]['title'], t('First feed was added correctly.')); + $this->assertEqual($url[$feeds[1]['title']], $feeds[1]['url'], t('Second feed was added correctly.')); + $this->assertTrue($refresh, t('Refresh times are correct.')); + $this->assertTrue($category, t('Categories are correct.')); + } + + function testOPMLImport() { + $this->openImportForm(); + $this->validateImportFormFields(); + $this->submitImportForm(); + } +}