Issue #1930274 by rootatwc, Berdir: Convert aggregator processors and parsers to plugins.

8.0.x
webchick 2013-03-30 21:08:45 -07:00
parent 0b306f9385
commit 3cbb29b2d3
23 changed files with 1194 additions and 892 deletions

View File

@ -306,70 +306,50 @@ function aggregator_admin_form($form, $form_state) {
'#description' => t('A space-separated list of HTML tags allowed in the content of feed items. Disallowed tags are stripped from the content.'),
);
// Make sure configuration is sane.
aggregator_sanitize_configuration();
$config = config('aggregator.settings');
// Get all available fetchers.
$fetcher_manager = drupal_container()->get('plugin.manager.aggregator.fetcher');
$fetchers = array();
foreach ($fetcher_manager->getDefinitions() as $id => $definition) {
$label = $definition['title'] . ' <span class="description">' . $definition['description'] . '</span>';
$fetchers[$id] = $label;
// Get all available fetchers, parsers and processors.
foreach (array('fetcher', 'parser', 'processor') as $type) {
// Initialize definitions if not set.
$definitions[$type] = isset($definitions[$type]) ? $definitions[$type] : array();
$managers[$type] = Drupal::service("plugin.manager.aggregator.$type");
foreach ($managers[$type]->getDefinitions() as $id => $definition) {
$label = $definition['title'] . ' <span class="description">' . $definition['description'] . '</span>';
$definitions[$type][$id] = $label;
}
}
// Get all available parsers.
$parsers = module_implements('aggregator_parse');
foreach ($parsers as $k => $module) {
if ($info = module_invoke($module, 'aggregator_parse_info')) {
$label = $info['title'] . ' <span class="description">' . $info['description'] . '</span>';
}
else {
$label = $module;
}
unset($parsers[$k]);
$parsers[$module] = $label;
}
// Get all available processors.
$processors = module_implements('aggregator_process');
foreach ($processors as $k => $module) {
if ($info = module_invoke($module, 'aggregator_process_info')) {
$label = $info['title'] . ' <span class="description">' . $info['description'] . '</span>';
}
else {
$label = $module;
}
unset($processors[$k]);
$processors[$module] = $label;
}
// Store definitions and managers so we can access them later.
$form_state['definitions'] = $definitions;
$form_state['managers'] = $managers;
// Only show basic configuration if there are actually options.
$basic_conf = array();
if (count($fetchers) > 1) {
if (count($definitions['fetcher']) > 1) {
$basic_conf['aggregator_fetcher'] = array(
'#type' => 'radios',
'#title' => t('Fetcher'),
'#description' => t('Fetchers download data from an external source. Choose a fetcher suitable for the external source you would like to download from.'),
'#options' => $fetchers,
'#default_value' => config('aggregator.settings')->get('fetcher'),
'#options' => $definitions['fetcher'],
'#default_value' => $config->get('fetcher'),
);
}
if (count($parsers) > 1) {
if (count($definitions['parser']) > 1) {
$basic_conf['aggregator_parser'] = array(
'#type' => 'radios',
'#title' => t('Parser'),
'#description' => t('Parsers transform downloaded data into standard structures. Choose a parser suitable for the type of feeds you would like to aggregate.'),
'#options' => $parsers,
'#default_value' => config('aggregator.settings')->get('parser'),
'#options' => $definitions['parser'],
'#default_value' => $config->get('parser'),
);
}
if (count($processors) > 1) {
if (count($definitions['processor']) > 1) {
$basic_conf['aggregator_processors'] = array(
'#type' => 'checkboxes',
'#title' => t('Processors'),
'#description' => t('Processors act on parsed feed data, for example they store feed items. Choose the processors suitable for your task.'),
'#options' => $processors,
'#default_value' => config('aggregator.settings')->get('processors'),
'#options' => $definitions['processor'],
'#default_value' => $config->get('processors'),
);
}
if (count($basic_conf)) {
@ -382,9 +362,14 @@ function aggregator_admin_form($form, $form_state) {
$form['basic_conf'] += $basic_conf;
}
// Implementing modules will expect an array at $form['modules'].
$form['modules'] = array();
// Implementing processor plugins will expect an array at $form['processors'].
$form['processors'] = array();
// Call settingsForm() for each acrive processor.
foreach ($definitions['processor'] as $id => $definition) {
if (in_array($id, $config->get('processors'))) {
$form = $managers['processor']->createInstance($id)->settingsForm($form, $form_state);
}
}
return system_config_form($form, $form_state);
}
@ -393,13 +378,14 @@ function aggregator_admin_form($form, $form_state) {
*/
function aggregator_admin_form_submit($form, &$form_state) {
$config = config('aggregator.settings');
$config
->set('items.allowed_html', $form_state['values']['aggregator_allowed_html_tags'])
->set('items.expire', $form_state['values']['aggregator_clear'])
->set('items.teaser_length', $form_state['values']['aggregator_teaser_length'])
->set('source.list_max', $form_state['values']['aggregator_summary_items'])
->set('source.category_selector', $form_state['values']['aggregator_category_selector']);
// Let active processors save their settings.
foreach ($form_state['definitions']['processor'] as $id => $definition) {
if (in_array($id, $config->get('processors'))) {
$form_state['managers']['processor']->createInstance($id)->settingsSubmit($form, $form_state);
}
}
$config->set('items.allowed_html', $form_state['values']['aggregator_allowed_html_tags']);
if (isset($form_state['values']['aggregator_fetcher'])) {
$config->set('fetcher', $form_state['values']['aggregator_fetcher']);
}

View File

@ -1,192 +0,0 @@
<?php
/**
* @file
* Documentation for aggregator API.
*/
/**
* @addtogroup hooks
* @{
*/
/**
* Specify the class, title, and short description of your fetcher plugins.
*
* The title and the description provided are shown within the
* configuration page.
*
* @return
* An associative array whose keys define the fetcher id and whose values
* contain the fetcher definitions. Each fetcher definition is itself an
* associative array, with the following key-value pairs:
* - class: (required) The PHP class containing the fetcher implementation.
* - title: (required) A human readable name of the fetcher.
* - description: (required) A brief (40 to 80 characters) explanation of the
* fetcher's functionality.
*
* @ingroup aggregator
*/
function hook_aggregator_fetch_info() {
return array(
'aggregator' => array(
'class' => 'Drupal\aggregator\Plugin\aggregator\fetcher\DefaultFetcher',
'title' => t('Default fetcher'),
'description' => t('Downloads data from a URL using Drupal\'s HTTP request handler.'),
),
);
}
/**
* Create an alternative parser for aggregator module.
*
* A parser converts feed item data to a common format. The parser is called
* at the second of the three aggregation stages: first, data is downloaded
* by the active fetcher; second, it is converted to a common format by the
* active parser; and finally, it is passed to all active processors which
* manipulate or store the data.
*
* Modules that define this hook can be set as the active parser within the
* configuration page. Only one parser can be active at a time.
*
* @param $feed
* An object describing the resource to be parsed. $feed->source_string
* contains the raw feed data. The hook implementation should parse this data
* and add the following properties to the $feed object:
* - description: The human-readable description of the feed.
* - link: A full URL that directly relates to the feed.
* - image: An image URL used to display an image of the feed.
* - etag: An entity tag from the HTTP header used for cache validation to
* determine if the content has been changed.
* - modified: The UNIX timestamp when the feed was last modified.
* - items: An array of feed items. The common format for a single feed item
* is an associative array containing:
* - title: The human-readable title of the feed item.
* - description: The full body text of the item or a summary.
* - timestamp: The UNIX timestamp when the feed item was last published.
* - author: The author of the feed item.
* - guid: The global unique identifier (GUID) string that uniquely
* identifies the item. If not available, the link is used to identify
* the item.
* - link: A full URL to the individual feed item.
*
* @return
* TRUE if parsing was successful, FALSE otherwise.
*
* @see hook_aggregator_parse_info()
* @see hook_aggregator_fetch()
* @see hook_aggregator_process()
*
* @ingroup aggregator
*/
function hook_aggregator_parse($feed) {
if ($items = mymodule_parse($feed->source_string)) {
$feed->items = $items;
return TRUE;
}
return FALSE;
}
/**
* Specify the title and short description of your parser.
*
* The title and the description provided are shown within the configuration
* page. Use as title the human readable name of the parser and as description
* a brief (40 to 80 characters) explanation of the parser's functionality.
*
* This hook is only called if your module implements hook_aggregator_parse().
* If this hook is not implemented aggregator will use your module's file name
* as title and there will be no description.
*
* @return
* An associative array defining a title and a description string.
*
* @see hook_aggregator_parse()
*
* @ingroup aggregator
*/
function hook_aggregator_parse_info() {
return array(
'title' => t('Default parser'),
'description' => t('Default parser for RSS, Atom and RDF feeds.'),
);
}
/**
* Create a processor for aggregator.module.
*
* A processor acts on parsed feed data. Active processors are called at the
* third and last of the aggregation stages: first, data is downloaded by the
* active fetcher; second, it is converted to a common format by the active
* parser; and finally, it is passed to all active processors that manipulate or
* store the data.
*
* Modules that define this hook can be activated as a processor within the
* configuration page.
*
* @param $feed
* A feed object representing the resource to be processed. $feed->items
* contains an array of feed items downloaded and parsed at the parsing stage.
* See hook_aggregator_parse() for the basic format of a single item in the
* $feed->items array. For the exact format refer to the particular parser in
* use.
*
* @see hook_aggregator_process_info()
* @see hook_aggregator_fetch()
* @see hook_aggregator_parse()
*
* @ingroup aggregator
*/
function hook_aggregator_process($feed) {
foreach ($feed->items as $item) {
mymodule_save($item);
}
}
/**
* Specify the title and short description of your processor.
*
* The title and the description provided are shown within the configuration
* page. Use as title the natural name of the processor and as description a
* brief (40 to 80 characters) explanation of the functionality.
*
* This hook is only called if your module implements hook_aggregator_process().
* If this hook is not implemented aggregator will use your module's file name
* as title and there will be no description.
*
* @return
* An associative array defining a title and a description string.
*
* @see hook_aggregator_process()
*
* @ingroup aggregator
*/
function hook_aggregator_process_info($feed) {
return array(
'title' => t('Default processor'),
'description' => t('Creates lightweight records of feed items.'),
);
}
/**
* Remove stored feed data.
*
* Aggregator calls this hook if either a feed is deleted or a user clicks on
* "remove items".
*
* If your module stores feed items for example on hook_aggregator_process() it
* is recommended to implement this hook and to remove data related to $feed
* when called.
*
* @param $feed
* The $feed object whose items are being removed.
*
* @ingroup aggregator
*/
function hook_aggregator_remove($feed) {
mymodule_remove_items($feed->fid);
}
/**
* @} End of "addtogroup hooks".
*/

View File

@ -6,6 +6,7 @@
*/
use Drupal\aggregator\Plugin\Core\Entity\Feed;
use Drupal\Component\Plugin\Exception\PluginException;
/**
* Denotes that a feed's items should never expire.
@ -301,7 +302,7 @@ function aggregator_permission() {
* Queues news feeds for updates once their refresh interval has elapsed.
*/
function aggregator_cron() {
$result = db_query('SELECT fid FROM {aggregator_feed} WHERE queued = 0 AND checked + refresh < :time AND refresh <> :never', array(
$result = db_query('SELECT fid FROM {aggregator_feed} WHERE queued = 0 AND checked + refresh < :time AND refresh <> :never', array(
':time' => REQUEST_TIME,
':never' => AGGREGATOR_CLEAR_NEVER
));
@ -310,10 +311,8 @@ function aggregator_cron() {
$feed = aggregator_feed_load($fid);
if ($queue->createItem($feed)) {
// Add timestamp to avoid queueing item more than once.
db_update('aggregator_feed')
->fields(array('queued' => REQUEST_TIME))
->condition('fid', $feed->id())
->execute();
$feed->queued->value = REQUEST_TIME;
$feed->save();
}
}
@ -395,42 +394,18 @@ function aggregator_save_category($edit) {
* An object describing the feed to be cleared.
*/
function aggregator_remove(Feed $feed) {
_aggregator_get_variables();
// Call hook_aggregator_remove() on all modules.
module_invoke_all('aggregator_remove', $feed);
// Call \Drupal\aggregator\Plugin\ProcessorInterface::remove() on all
// processors.
$manager = Drupal::service('plugin.manager.aggregator.processor');
foreach ($manager->getDefinitions() as $id => $definition) {
$manager->createInstance($id)->remove($feed);
}
// Reset feed.
db_update('aggregator_feed')
->condition('fid', $feed->id())
->fields(array(
'checked' => 0,
'hash' => '',
'etag' => '',
'modified' => 0,
))
->execute();
}
/**
* Gets the fetcher, parser, and processors.
*
* @return
* An array containing the fetcher, parser, and processors.
*/
function _aggregator_get_variables() {
$config = config('aggregator.settings');
$fetcher = $config->get('fetcher');
$parser = $config->get('parser');
if ($parser == 'aggregator') {
include_once DRUPAL_ROOT . '/' . drupal_get_path('module', 'aggregator') . '/aggregator.parser.inc';
}
$processors = $config->get('processors');
if (in_array('aggregator', $processors)) {
include_once DRUPAL_ROOT . '/' . drupal_get_path('module', 'aggregator') . '/aggregator.processor.inc';
}
return array($fetcher, $parser, $processors);
$feed->checked->value = 0;
$feed->hash->value = '';
$feed->etag->value = '';
$feed->modified->value = 0;
$feed->save();
}
/**
@ -443,15 +418,28 @@ function aggregator_refresh(Feed $feed) {
// Store feed URL to track changes.
$feed_url = $feed->url->value;
list($fetcher, $parser, $processors) = _aggregator_get_variables();
$config = config('aggregator.settings');
// Fetch the feed.
$fetcher_manager = drupal_container()->get('plugin.manager.aggregator.fetcher');
$fetcher_manager = Drupal::service('plugin.manager.aggregator.fetcher');
try {
$success = $fetcher_manager->createInstance($fetcher)->fetch($feed);
$success = $fetcher_manager->createInstance($config->get('fetcher'))->fetch($feed);
}
catch (PluginException $e) {
$success = FALSE;
watchdog_exception('aggregator', $e);
}
// Retrieve processor manager now.
$processor_manager = Drupal::service('plugin.manager.aggregator.processor');
// Store instances in an array so we dont have to instantiate new objects.
$processor_instances = array();
foreach ($config->get('processors') as $processor) {
try {
$processor_instances[$processor] = $processor_manager->createInstance($processor);
}
catch (PluginException $e) {
watchdog_exception('aggregator', $e);
}
}
// We store the hash of feed data in the database. When refreshing a
@ -461,43 +449,48 @@ function aggregator_refresh(Feed $feed) {
if ($success && ($feed->hash->value != $hash)) {
// Parse the feed.
if (module_invoke($parser, 'aggregator_parse', $feed)) {
if (empty($feed->link->value)) {
$feed->link->value = $feed->url->value;
}
$feed->hash->value = $hash;
// Update feed with parsed data.
$feed->save();
$parser_manager = Drupal::service('plugin.manager.aggregator.parser');
try {
if ($parser_manager->createInstance($config->get('parser'))->parse($feed)) {
if (empty($feed->link->value)) {
$feed->link->value = $feed->url->value;
}
$feed->hash->value = $hash;
// Update feed with parsed data.
$feed->save();
// Log if feed URL has changed.
if ($feed->url->value != $feed_url) {
watchdog('aggregator', 'Updated URL for feed %title to %url.', array('%title' => $feed->label(), '%url' => $feed->url->value));
}
// Log if feed URL has changed.
if ($feed->url->value != $feed_url) {
watchdog('aggregator', 'Updated URL for feed %title to %url.', array('%title' => $feed->label(), '%url' => $feed->url->value));
}
watchdog('aggregator', 'There is new syndicated content from %site.', array('%site' => $feed->label()));
drupal_set_message(t('There is new syndicated content from %site.', array('%site' => $feed->label())));
watchdog('aggregator', 'There is new syndicated content from %site.', array('%site' => $feed->label()));
drupal_set_message(t('There is new syndicated content from %site.', array('%site' => $feed->label())));
// If there are items on the feed, let all enabled processors do their work on it.
if (@count($feed->items)) {
foreach ($processors as $processor) {
module_invoke($processor, 'aggregator_process', $feed);
// If there are items on the feed, let enabled processors process them.
if (!empty($feed->items)) {
foreach ($processor_instances as $instance) {
$instance->process($feed);
}
}
}
}
catch (PluginException $e) {
watchdog_exception('aggregator', $e);
}
}
else {
drupal_set_message(t('There is no new syndicated content from %site.', array('%site' => $feed->label())));
}
// Regardless of successful or not, indicate that this feed has been checked.
db_update('aggregator_feed')
->fields(array('checked' => REQUEST_TIME, 'queued' => 0))
->condition('fid', $feed->id())
->execute();
$feed->checked->value = REQUEST_TIME;
$feed->queued->value = 0;
$feed->save();
// Expire old feed items.
if (function_exists('aggregator_expire')) {
aggregator_expire($feed);
// Processing is done, call postProcess on enabled processors.
foreach ($processor_instances as $instance) {
$instance->postProcess($feed);
}
}
@ -560,55 +553,6 @@ function aggregator_filter_xss($value) {
return filter_xss($value, preg_split('/\s+|<|>/', config('aggregator.settings')->get('items.allowed_html'), -1, PREG_SPLIT_NO_EMPTY));
}
/**
* Checks and sanitizes the aggregator configuration.
*
* Goes through all fetchers, parsers and processors and checks whether they
* are available. If one is missing, resets to standard configuration.
*
* @return
* TRUE if this function resets the configuration; FALSE if not.
*/
function aggregator_sanitize_configuration() {
$reset = FALSE;
list($fetcher, $parser, $processors) = _aggregator_get_variables();
if (!module_exists($fetcher)) {
$reset = TRUE;
}
if (!module_exists($parser)) {
$reset = TRUE;
}
foreach ($processors as $processor) {
if (!module_exists($processor)) {
$reset = TRUE;
break;
}
}
if ($reset) {
// Reset aggregator config if necessary using the module defaults.
config('aggregator.settings')
->set('fetcher', 'aggregator')
->set('parser', 'aggregator')
->set('processors', array('aggregator' => 'aggregator'))
->save();
return TRUE;
}
return FALSE;
}
/**
* Helper function for drupal_map_assoc.
*
* @param $count
* Items count.
*
* @return
* A string that is plural-formatted as "@count items".
*/
function _aggregator_items($count) {
return format_plural($count, '1 item', '@count items');
}
/**
* Implements hook_preprocess_HOOK() for block.tpl.php.
*/

View File

@ -1,326 +0,0 @@
<?php
/**
* @file
* Parser functions for the aggregator module.
*/
use Drupal\aggregator\Plugin\Core\Entity\Feed;
/**
* Implements hook_aggregator_parse_info().
*/
function aggregator_aggregator_parse_info() {
return array(
'title' => t('Default parser'),
'description' => t('Parses RSS, Atom and RDF feeds.'),
);
}
/**
* Implements hook_aggregator_parse().
*/
function aggregator_aggregator_parse(Feed $feed) {
global $channel, $image;
// Filter the input data.
if (aggregator_parse_feed($feed->source_string, $feed)) {
// Prepare the channel data.
foreach ($channel as $key => $value) {
$channel[$key] = trim($value);
}
// Prepare the image data (if any).
foreach ($image as $key => $value) {
$image[$key] = trim($value);
}
// Add parsed data to the feed object.
$feed->link->value = !empty($channel['link']) ? $channel['link'] : '';
$feed->description->value = !empty($channel['description']) ? $channel['description'] : '';
$feed->image->value = !empty($image['url']) ? $image['url'] : '';
// Clear the page and block caches.
cache_invalidate_tags(array('content' => TRUE));
return TRUE;
}
return FALSE;
}
/**
* Parses a feed and stores its items.
*
* @param string $data
* The feed data.
* @param \Drupal\aggregator\Plugin\Core\Entity\Feed $feed
* An object describing the feed to be parsed.
*
* @return
* FALSE on error, TRUE otherwise.
*/
function aggregator_parse_feed(&$data, Feed $feed) {
global $items, $image, $channel;
// Unset the global variables before we use them.
unset($GLOBALS['element'], $GLOBALS['item'], $GLOBALS['tag']);
$items = array();
$image = array();
$channel = array();
// Parse the data.
$xml_parser = drupal_xml_parser_create($data);
xml_set_element_handler($xml_parser, 'aggregator_element_start', 'aggregator_element_end');
xml_set_character_data_handler($xml_parser, 'aggregator_element_data');
if (!xml_parse($xml_parser, $data, 1)) {
watchdog('aggregator', 'The feed from %site seems to be broken due to an error "%error" on line %line.', array('%site' => $feed->label(), '%error' => xml_error_string(xml_get_error_code($xml_parser)), '%line' => xml_get_current_line_number($xml_parser)), WATCHDOG_WARNING);
drupal_set_message(t('The feed from %site seems to be broken because of error "%error" on line %line.', array('%site' => $feed->label(), '%error' => xml_error_string(xml_get_error_code($xml_parser)), '%line' => xml_get_current_line_number($xml_parser))), 'error');
return FALSE;
}
xml_parser_free($xml_parser);
// We reverse the array such that we store the first item last, and the last
// item first. In the database, the newest item should be at the top.
$items = array_reverse($items);
// Initialize items array.
$feed->items = array();
foreach ($items as $item) {
// Prepare the item:
foreach ($item as $key => $value) {
$item[$key] = trim($value);
}
// Resolve the item's title. If no title is found, we use up to 40
// characters of the description ending at a word boundary, but not
// splitting potential entities.
if (!empty($item['title'])) {
$item['title'] = $item['title'];
}
elseif (!empty($item['description'])) {
$item['title'] = preg_replace('/^(.*)[^\w;&].*?$/', "\\1", truncate_utf8($item['description'], 40));
}
else {
$item['title'] = '';
}
// Resolve the items link.
if (!empty($item['link'])) {
$item['link'] = $item['link'];
}
else {
$item['link'] = $feed->link->value;
}
// Atom feeds have an ID tag instead of a GUID tag.
if (!isset($item['guid'])) {
$item['guid'] = isset($item['id']) ? $item['id'] : '';
}
// Atom feeds have a content and/or summary tag instead of a description tag.
if (!empty($item['content:encoded'])) {
$item['description'] = $item['content:encoded'];
}
elseif (!empty($item['summary'])) {
$item['description'] = $item['summary'];
}
elseif (!empty($item['content'])) {
$item['description'] = $item['content'];
}
// Try to resolve and parse the item's publication date.
$date = '';
foreach (array('pubdate', 'dc:date', 'dcterms:issued', 'dcterms:created', 'dcterms:modified', 'issued', 'created', 'modified', 'published', 'updated') as $key) {
if (!empty($item[$key])) {
$date = $item[$key];
break;
}
}
$item['timestamp'] = strtotime($date);
if ($item['timestamp'] === FALSE) {
$item['timestamp'] = aggregator_parse_w3cdtf($date); // Aggregator_parse_w3cdtf() returns FALSE on failure.
}
// Resolve dc:creator tag as the item author if author tag is not set.
if (empty($item['author']) && !empty($item['dc:creator'])) {
$item['author'] = $item['dc:creator'];
}
$item += array('author' => '', 'description' => '');
// Store on $feed object. This is where processors will look for parsed items.
$feed->items[] = $item;
}
return TRUE;
}
/**
* Performs an action when an opening tag is encountered.
*
* Callback function used by xml_parse() within aggregator_parse_feed().
*/
function aggregator_element_start($parser, $name, $attributes) {
global $item, $element, $tag, $items, $channel;
$name = strtolower($name);
switch ($name) {
case 'image':
case 'textinput':
case 'summary':
case 'tagline':
case 'subtitle':
case 'logo':
case 'info':
$element = $name;
break;
case 'id':
case 'content':
if ($element != 'item') {
$element = $name;
}
case 'link':
// According to RFC 4287, link elements in Atom feeds without a 'rel'
// attribute should be interpreted as though the relation type is
// "alternate".
if (!empty($attributes['HREF']) && (empty($attributes['REL']) || $attributes['REL'] == 'alternate')) {
if ($element == 'item') {
$items[$item]['link'] = $attributes['HREF'];
}
else {
$channel['link'] = $attributes['HREF'];
}
}
break;
case 'item':
$element = $name;
$item += 1;
break;
case 'entry':
$element = 'item';
$item += 1;
break;
}
$tag = $name;
}
/**
* Performs an action when a closing tag is encountered.
*
* Callback function used by xml_parse() within aggregator_parse_feed().
*/
function aggregator_element_end($parser, $name) {
global $element;
switch ($name) {
case 'image':
case 'textinput':
case 'item':
case 'entry':
case 'info':
$element = '';
break;
case 'id':
case 'content':
if ($element == $name) {
$element = '';
}
}
}
/**
* Performs an action when data is encountered.
*
* Callback function used by xml_parse() within aggregator_parse_feed().
*/
function aggregator_element_data($parser, $data) {
global $channel, $element, $items, $item, $image, $tag;
$items += array($item => array());
switch ($element) {
case 'item':
$items[$item] += array($tag => '');
$items[$item][$tag] .= $data;
break;
case 'image':
case 'logo':
$image += array($tag => '');
$image[$tag] .= $data;
break;
case 'link':
if ($data) {
$items[$item] += array($tag => '');
$items[$item][$tag] .= $data;
}
break;
case 'content':
$items[$item] += array('content' => '');
$items[$item]['content'] .= $data;
break;
case 'summary':
$items[$item] += array('summary' => '');
$items[$item]['summary'] .= $data;
break;
case 'tagline':
case 'subtitle':
$channel += array('description' => '');
$channel['description'] .= $data;
break;
case 'info':
case 'id':
case 'textinput':
// The sub-element is not supported. However, we must recognize
// it or its contents will end up in the item array.
break;
default:
$channel += array($tag => '');
$channel[$tag] .= $data;
}
}
/**
* Parses the W3C date/time format, a subset of ISO 8601.
*
* PHP date parsing functions do not handle this format. See
* http://www.w3.org/TR/NOTE-datetime for more information. Originally from
* MagpieRSS (http://magpierss.sourceforge.net/).
*
* @param $date_str
* A string with a potentially W3C DTF date.
*
* @return
* A timestamp if parsed successfully or FALSE if not.
*/
function aggregator_parse_w3cdtf($date_str) {
if (preg_match('/(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(:(\d{2}))?(?:([-+])(\d{2}):?(\d{2})|(Z))?/', $date_str, $match)) {
list($year, $month, $day, $hours, $minutes, $seconds) = array($match[1], $match[2], $match[3], $match[4], $match[5], $match[6]);
// Calculate the epoch for current date assuming GMT.
$epoch = gmmktime($hours, $minutes, $seconds, $month, $day, $year);
if ($match[10] != 'Z') { // Z is zulu time, aka GMT
list($tz_mod, $tz_hour, $tz_min) = array($match[8], $match[9], $match[10]);
// Zero out the variables.
if (!$tz_hour) {
$tz_hour = 0;
}
if (!$tz_min) {
$tz_min = 0;
}
$offset_secs = (($tz_hour * 60) + $tz_min) * 60;
// Is timezone ahead of GMT? If yes, subtract offset.
if ($tz_mod == '+') {
$offset_secs *= -1;
}
$epoch += $offset_secs;
}
return $epoch;
}
else {
return FALSE;
}
}

View File

@ -1,182 +0,0 @@
<?php
/**
* @file
* Processor functions for the aggregator module.
*/
use Drupal\aggregator\Plugin\Core\Entity\Feed;
/**
* Implements hook_aggregator_process_info().
*/
function aggregator_aggregator_process_info() {
return array(
'title' => t('Default processor'),
'description' => t('Creates lightweight records from feed items.'),
);
}
/**
* Implements hook_aggregator_process().
*/
function aggregator_aggregator_process($feed) {
if (is_object($feed)) {
if (is_array($feed->items)) {
foreach ($feed->items as $item) {
// @todo: The default entity render controller always returns an empty
// array, which is ignored in aggregator_save_item() currently. Should
// probably be fixed.
if (empty($item['title'])) {
continue;
}
// Save this item. Try to avoid duplicate entries as much as possible. If
// we find a duplicate entry, we resolve it and pass along its ID is such
// that we can update it if needed.
if (!empty($item['guid'])) {
$values = array('fid' => $feed->id(), 'guid' => $item['guid']);
}
elseif ($item['link'] && $item['link'] != $feed->link && $item['link'] != $feed->url) {
$values = array('fid' => $feed->id(), 'link' => $item['link']);
}
else {
$values = array('fid' => $feed->id(), 'title' => $item['title']);
}
// Try to load an existing entry.
if ($entry = entity_load_multiple_by_properties('aggregator_item', $values)) {
$entry = reset($entry);
}
else {
$entry = entity_create('aggregator_item', array('langcode' => $feed->language()->langcode));
}
if ($item['timestamp']) {
$entry->timestamp->value = $item['timestamp'];
}
// Make sure the item title and author fit in the 255 varchar column.
$entry->title->value = truncate_utf8($item['title'], 255, TRUE, TRUE);
$entry->author->value = truncate_utf8($item['author'], 255, TRUE, TRUE);
$entry->fid->value = $feed->id();
$entry->link->value = $item['link'];
$entry->description->value = $item['description'];
$entry->guid->value = $item['guid'];
$entry->save();
}
}
}
}
/**
* Implements hook_aggregator_remove().
*/
function aggregator_aggregator_remove($feed) {
$iids = db_query('SELECT iid FROM {aggregator_item} WHERE fid = :fid', array(':fid' => $feed->id()))->fetchCol();
entity_delete_multiple('aggregator_item', $iids);
drupal_set_message(t('The news items from %site have been removed.', array('%site' => $feed->label())));
}
/**
* Implements hook_form_aggregator_admin_form_alter().
*
* Form alter aggregator module's own form to keep processor functionality
* separate from aggregator API functionality.
*/
function aggregator_form_aggregator_admin_form_alter(&$form, $form_state) {
$config = config('aggregator.settings');
$aggregator_processors = $config->get('processors');
if (in_array('aggregator', $aggregator_processors)) {
$info = module_invoke('aggregator', 'aggregator_process', 'info');
$items = drupal_map_assoc(array(3, 5, 10, 15, 20, 25), '_aggregator_items');
$period = drupal_map_assoc(array(3600, 10800, 21600, 32400, 43200, 86400, 172800, 259200, 604800, 1209600, 2419200, 4838400, 9676800), 'format_interval');
$period[AGGREGATOR_CLEAR_NEVER] = t('Never');
// Only wrap into details if there is a basic configuration.
if (isset($form['basic_conf'])) {
$form['modules']['aggregator'] = array(
'#type' => 'details',
'#title' => t('Default processor settings'),
'#description' => $info['description'],
'#collapsed' => !in_array('aggregator', $aggregator_processors),
);
}
else {
$form['modules']['aggregator'] = array();
}
$form['modules']['aggregator']['aggregator_summary_items'] = array(
'#type' => 'select',
'#title' => t('Number of items shown in listing pages'),
'#default_value' => config('aggregator.settings')->get('source.list_max'),
'#empty_value' => 0,
'#options' => $items,
);
$form['modules']['aggregator']['aggregator_clear'] = array(
'#type' => 'select',
'#title' => t('Discard items older than'),
'#default_value' => config('aggregator.settings')->get('items.expire'),
'#options' => $period,
'#description' => t('Requires a correctly configured <a href="@cron">cron maintenance task</a>.', array('@cron' => url('admin/reports/status'))),
);
$form['modules']['aggregator']['aggregator_category_selector'] = array(
'#type' => 'radios',
'#title' => t('Select categories using'),
'#default_value' => config('aggregator.settings')->get('source.category_selector'),
'#options' => array('checkboxes' => t('checkboxes'),
'select' => t('multiple selector')),
'#description' => t('For a small number of categories, checkboxes are easier to use, while a multiple selector works well with large numbers of categories.'),
);
$form['modules']['aggregator']['aggregator_teaser_length'] = array(
'#type' => 'select',
'#title' => t('Length of trimmed description'),
'#default_value' => config('aggregator.settings')->get('items.teaser_length'),
'#options' => drupal_map_assoc(array(0, 200, 400, 600, 800, 1000, 1200, 1400, 1600, 1800, 2000), '_aggregator_characters'),
'#description' => t("The maximum number of characters used in the trimmed version of content.")
);
}
}
/**
* Creates display text for teaser length option values.
*
* Callback for drupal_map_assoc() within
* aggregator_form_aggregator_admin_form_alter().
*
* @param int $length
* The desired length of teaser text, in bytes.
*
* @return string
* A translated string explaining the teaser string length.
*/
function _aggregator_characters($length) {
return ($length == 0) ? t('Unlimited') : format_plural($length, '1 character', '@count characters');
}
/**
* Expires items from a feed depending on expiration settings.
*
* @param \Drupal\aggregator\Plugin\Core\Entity\Feed $feed
* Object describing feed.
*/
function aggregator_expire(Feed $feed) {
$aggregator_clear = config('aggregator.settings')->get('items.expire');
if ($aggregator_clear != AGGREGATOR_CLEAR_NEVER) {
// Remove all items that are older than flush item timer.
$age = REQUEST_TIME - $aggregator_clear;
$iids = db_query('SELECT iid FROM {aggregator_item} WHERE fid = :fid AND timestamp < :timestamp', array(
':fid' => $feed->id(),
':timestamp' => $age,
))
->fetchCol();
if ($iids) {
entity_delete_multiple('aggregator_item', $iids);
}
}
}

View File

@ -19,8 +19,11 @@ class AggregatorBundle extends Bundle {
* Overrides Bundle::build().
*/
public function build(ContainerBuilder $container) {
$container->register('plugin.manager.aggregator.fetcher', 'Drupal\aggregator\Plugin\FetcherManager')
->addArgument('%container.namespaces%');
foreach (array('fetcher', 'parser', 'processor') as $type) {
$container->register("plugin.manager.aggregator.$type", 'Drupal\aggregator\Plugin\AggregatorPluginManager')
->addArgument($type)
->addArgument('%container.namespaces%');
}
}
}

View File

@ -48,12 +48,13 @@ class FeedStorageController extends DatabaseStorageControllerNG {
// Invalidate the block cache to update aggregator feed-based derivatives.
if (module_exists('block')) {
drupal_container()->get('plugin.manager.block')->clearCachedDefinitions();
\Drupal::service('plugin.manager.block')->clearCachedDefinitions();
}
foreach ($entities as $entity) {
$iids = db_query('SELECT iid FROM {aggregator_item} WHERE fid = :fid', array(':fid' => $entity->id()))->fetchCol();
if ($iids) {
entity_delete_multiple('aggregator_item', $iids);
// Notify processors to remove stored items.
$manager = \Drupal::service('plugin.manager.aggregator.processor');
foreach ($manager->getDefinitions() as $id => $definition) {
$manager->createInstance($id)->remove($entity);
}
}
}

View File

@ -2,7 +2,7 @@
/**
* @file
* Definition of Drupal\aggregator\Plugin\FetcherManager.
* Contains \Drupal\aggregator\Plugin\AggregatorPluginManager.
*/
namespace Drupal\aggregator\Plugin;
@ -13,19 +13,21 @@ use Drupal\Core\Plugin\Discovery\AnnotatedClassDiscovery;
use Drupal\Core\Plugin\Discovery\CacheDecorator;
/**
* Manages aggregator fetcher plugins.
* Manages aggregator plugins.
*/
class FetcherManager extends PluginManagerBase {
class AggregatorPluginManager extends PluginManagerBase {
/**
* Constructs a FetcherManager object.
* Constructs a AggregatorPluginManager object.
*
* @param string $type
* The plugin type, for example fetcher.
* @param array $namespaces
* An array of paths keyed by it's corresponding namespaces.
*/
public function __construct(array $namespaces) {
$this->discovery = new AnnotatedClassDiscovery('aggregator', 'fetcher', $namespaces);
$this->discovery = new CacheDecorator($this->discovery, 'aggregator_fetcher:' . language(LANGUAGE_TYPE_INTERFACE)->langcode);
public function __construct($type, array $namespaces) {
$this->discovery = new AnnotatedClassDiscovery('aggregator', $type, $namespaces);
$this->discovery = new CacheDecorator($this->discovery, "aggregator_$type:" . language(LANGUAGE_TYPE_INTERFACE)->langcode);
$this->factory = new DefaultFactory($this->discovery);
}
}

View File

@ -2,7 +2,7 @@
/**
* @file
* Definition of Drupal\aggregator\Plugin\FetcherInterface.
* Contains \Drupal\aggregator\Plugin\FetcherInterface.
*/
namespace Drupal\aggregator\Plugin;

View File

@ -0,0 +1,53 @@
<?php
/**
* @file
* Contains \Drupal\aggregator\Plugin\FetcherInterface.
*/
namespace Drupal\aggregator\Plugin;
use Drupal\aggregator\Plugin\Core\Entity\Feed;
/**
* Defines an interface for aggregator parser implementations.
*
* A parser converts feed item data to a common format. The parser is called
* at the second of the three aggregation stages: first, data is downloaded
* by the active fetcher; second, it is converted to a common format by the
* active parser; and finally, it is passed to all active processors which
* manipulate or store the data.
*
*/
interface ParserInterface {
/**
* Parses feed data.
*
* @param \Drupal\aggregator\Plugin\Core\Entity\Feed $feed
* An object describing the resource to be parsed.
* $feed->source_string->value contains the raw feed data. Parse the data
* and add the following properties to the $feed object:
* - description: The human-readable description of the feed.
* - link: A full URL that directly relates to the feed.
* - image: An image URL used to display an image of the feed.
* - etag: An entity tag from the HTTP header used for cache validation to
* determine if the content has been changed.
* - modified: The UNIX timestamp when the feed was last modified.
* - items: An array of feed items. The common format for a single feed item
* is an associative array containing:
* - title: The human-readable title of the feed item.
* - description: The full body text of the item or a summary.
* - timestamp: The UNIX timestamp when the feed item was last published.
* - author: The author of the feed item.
* - guid: The global unique identifier (GUID) string that uniquely
* identifies the item. If not available, the link is used to identify
* the item.
* - link: A full URL to the individual feed item.
*
* @return bool
* TRUE if parsing was successful, FALSE otherwise.
*/
public function parse(Feed $feed);
}

View File

@ -0,0 +1,86 @@
<?php
/**
* @file
* Contains \Drupal\aggregator\Plugin\ProcessorInterface.
*/
namespace Drupal\aggregator\Plugin;
use Drupal\aggregator\Plugin\Core\Entity\Feed;
/**
* Defines an interface for aggregator processor implementations.
*
* A processor acts on parsed feed data. Active processors are called at the
* third and last of the aggregation stages: first, data is downloaded by the
* active fetcher; second, it is converted to a common format by the active
* parser; and finally, it is passed to all active processors that manipulate or
* store the data.
*/
interface ProcessorInterface {
/**
* Returns a form to configure settings for the processor.
*
* @param array $form
* The form definition array where the settings form is being included in.
* @param array $form_state
* An associative array containing the current state of the form.
*
* @return array
* The form elements for the processor settings.
*/
public function settingsForm(array $form, array &$form_state);
/**
* Adds processor specific submission handling for the configuration form.
*
* @param array $form
* The form definition array where the settings form is being included in.
* @param array $form_state
* An associative array containing the current state of the form.
*
* @see \Drupal\aggregator\Plugin\ProcessorInterface::settingsForm()
*/
public function settingsSubmit(array $form, array &$form_state);
/**
* Processes feed data.
*
* @param \Drupal\aggregator\Plugin\Core\Entity\Feed $feed
* A feed object representing the resource to be processed.
* $feed->items contains an array of feed items downloaded and parsed at the
* parsing stage. See \Drupal\aggregator\Plugin\FetcherInterface::parse()
* for the basic format of a single item in the $feed->items array.
* For the exact format refer to the particular parser in use.
*
*/
public function process(Feed $feed);
/**
* Refreshes feed information.
*
* Called after the processing of the feed is completed by all selected
* processors.
*
* @param \Drupal\aggregator\Plugin\Core\Entity\Feed $feed
* Object describing feed.
*
* @see aggregator_refresh()
*/
public function postProcess(Feed $feed);
/**
* Removes stored feed data.
*
* Called by aggregator if either a feed is deleted or a user clicks on
* "remove items".
*
* @param \Drupal\aggregator\Plugin\Core\Entity\Feed $feed
* The $feed object whose items are being removed.
*
*/
public function remove(Feed $feed);
}

View File

@ -2,7 +2,7 @@
/**
* @file
* Definition of Drupal\aggregator\Plugin\aggregator\fetcher\DefaultFetcher.
* Contains \Drupal\aggregator\Plugin\aggregator\fetcher\DefaultFetcher.
*/
namespace Drupal\aggregator\Plugin\aggregator\fetcher;
@ -17,7 +17,7 @@ use Guzzle\Http\Exception\RequestException;
/**
* Defines a default fetcher implementation.
*
* Uses drupal_http_request() to download the feed.
* Uses the http_default_client service to download the feed.
*
* @Plugin(
* id = "aggregator",
@ -28,10 +28,10 @@ use Guzzle\Http\Exception\RequestException;
class DefaultFetcher implements FetcherInterface {
/**
* Implements Drupal\aggregator\Plugin\FetcherInterface::fetch().
* Implements \Drupal\aggregator\Plugin\FetcherInterface::fetch().
*/
function fetch(Feed $feed) {
$request = drupal_container()->get('http_default_client')->get($feed->url->value);
public function fetch(Feed $feed) {
$request = \Drupal::service('http_default_client')->get($feed->url->value);
$feed->source_string = FALSE;
// Generate conditional GET headers.

View File

@ -0,0 +1,378 @@
<?php
/**
* @file
* Contains \Drupal\aggregator\Plugin\aggregator\parser\DefaultParser.
*/
namespace Drupal\aggregator\Plugin\aggregator\parser;
use Drupal\aggregator\Plugin\ParserInterface;
use Drupal\aggregator\Plugin\Core\Entity\Feed;
use Drupal\Component\Annotation\Plugin;
use Drupal\Core\Annotation\Translation;
/**
* Defines a default parser implementation.
*
* Parses RSS, Atom and RDF feeds.
*
* @Plugin(
* id = "aggregator",
* title = @Translation("Default parser"),
* description = @Translation("Default parser for RSS, Atom and RDF feeds.")
* )
*/
class DefaultParser implements ParserInterface {
/**
* The extracted channel info.
*
* @var array
*/
protected $channel = array();
/**
* The extracted image info.
*
* @var array
*/
protected $image = array();
/**
* The extracted items.
*
* @var array
*/
protected $items = array();
/**
* The element that is being processed.
*
* @var array
*/
protected $element = array();
/**
* The tag that is being processed.
*
* @var string
*/
protected $tag = '';
/**
* Key that holds the number of processed "entry" and "item" tags.
*
* @var int
*/
protected $item;
/**
* Implements \Drupal\aggregator\Plugin\ParserInterface::parse().
*/
public function parse(Feed $feed) {
// Filter the input data.
if ($this->parseFeed($feed->source_string, $feed)) {
// Prepare the channel data.
foreach ($this->channel as $key => $value) {
$this->channel[$key] = trim($value);
}
// Prepare the image data (if any).
foreach ($this->image as $key => $value) {
$this->image[$key] = trim($value);
}
// Add parsed data to the feed object.
$feed->link->value = !empty($channel['link']) ? $channel['link'] : '';
$feed->description->value = !empty($channel['description']) ? $channel['description'] : '';
$feed->image->value = !empty($image['url']) ? $image['url'] : '';
// Clear the page and block caches.
cache_invalidate_tags(array('content' => TRUE));
return TRUE;
}
return FALSE;
}
/**
* Parses a feed and stores its items.
*
* @param string $data
* The feed data.
* @param \Drupal\aggregator\Plugin\Core\Entity\Feed $feed
* An object describing the feed to be parsed.
*
* @return bool
* FALSE on error, TRUE otherwise.
*/
protected function parseFeed(&$data, Feed $feed) {
// Parse the data.
$xml_parser = drupal_xml_parser_create($data);
xml_set_element_handler($xml_parser, array($this, 'elementStart'), array($this, 'elementEnd'));
xml_set_character_data_handler($xml_parser, array($this, 'elementData'));
if (!xml_parse($xml_parser, $data, 1)) {
watchdog('aggregator', 'The feed from %site seems to be broken due to an error "%error" on line %line.', array('%site' => $feed->label(), '%error' => xml_error_string(xml_get_error_code($xml_parser)), '%line' => xml_get_current_line_number($xml_parser)), WATCHDOG_WARNING);
drupal_set_message(t('The feed from %site seems to be broken because of error "%error" on line %line.', array('%site' => $feed->label(), '%error' => xml_error_string(xml_get_error_code($xml_parser)), '%line' => xml_get_current_line_number($xml_parser))), 'error');
return FALSE;
}
xml_parser_free($xml_parser);
// We reverse the array such that we store the first item last, and the last
// item first. In the database, the newest item should be at the top.
$this->items = array_reverse($this->items);
// Initialize items array.
$feed->items = array();
foreach ($this->items as $item) {
// Prepare the item:
foreach ($item as $key => $value) {
$item[$key] = trim($value);
}
// Resolve the item's title. If no title is found, we use up to 40
// characters of the description ending at a word boundary, but not
// splitting potential entities.
if (!empty($item['title'])) {
$item['title'] = $item['title'];
}
elseif (!empty($item['description'])) {
$item['title'] = preg_replace('/^(.*)[^\w;&].*?$/', "\\1", truncate_utf8($item['description'], 40));
}
else {
$item['title'] = '';
}
// Resolve the items link.
if (!empty($item['link'])) {
$item['link'] = $item['link'];
}
else {
$item['link'] = $feed->link->value;
}
// Atom feeds have an ID tag instead of a GUID tag.
if (!isset($item['guid'])) {
$item['guid'] = isset($item['id']) ? $item['id'] : '';
}
// Atom feeds have a content and/or summary tag instead of a description tag.
if (!empty($item['content:encoded'])) {
$item['description'] = $item['content:encoded'];
}
elseif (!empty($item['summary'])) {
$item['description'] = $item['summary'];
}
elseif (!empty($item['content'])) {
$item['description'] = $item['content'];
}
// Try to resolve and parse the item's publication date.
$date = '';
foreach (array('pubdate', 'dc:date', 'dcterms:issued', 'dcterms:created', 'dcterms:modified', 'issued', 'created', 'modified', 'published', 'updated') as $key) {
if (!empty($item[$key])) {
$date = $item[$key];
break;
}
}
$item['timestamp'] = strtotime($date);
if ($item['timestamp'] === FALSE) {
$item['timestamp'] = $this->parseW3cdtf($date); // Aggregator_parse_w3cdtf() returns FALSE on failure.
}
// Resolve dc:creator tag as the item author if author tag is not set.
if (empty($item['author']) && !empty($item['dc:creator'])) {
$item['author'] = $item['dc:creator'];
}
$item += array('author' => '', 'description' => '');
// Store on $feed object. This is where processors will look for parsed items.
$feed->items[] = $item;
}
return TRUE;
}
/**
* XML parser callback: Perform an action when an opening tag is encountered.
*
* @param resource $parser
* A reference to the XML parser calling the handler.
* @param string $name
* The name of the element for which this handler is called.
* @param array $attributes
* An associative array with the element's attributes (if any).
*/
protected function elementStart($parser, $name, $attributes) {
$name = strtolower($name);
switch ($name) {
case 'image':
case 'textinput':
case 'summary':
case 'tagline':
case 'subtitle':
case 'logo':
case 'info':
$this->element = $name;
break;
case 'id':
case 'content':
if ($this->element != 'item') {
$this->element = $name;
}
case 'link':
// According to RFC 4287, link elements in Atom feeds without a 'rel'
// attribute should be interpreted as though the relation type is
// "alternate".
if (!empty($attributes['HREF']) && (empty($attributes['REL']) || $attributes['REL'] == 'alternate')) {
if ($this->element == 'item') {
$this->items[$this->item]['link'] = $attributes['HREF'];
}
else {
$this->channel['link'] = $attributes['HREF'];
}
}
break;
case 'item':
$this->element = $name;
$this->item += 1;
break;
case 'entry':
$this->element = 'item';
$this->item += 1;
break;
}
$this->tag = $name;
}
/**
* XML parser callback: Perform an action when a closing tag is encountered.
*
* @param resource $parser
* A reference to the XML parser calling the handler.
* @param string $name
* The name of the element for which this handler is called.
* @param array $attributes
* An associative array with the element's attributes (if any).
*/
protected function elementEnd($parser, $name) {
switch ($name) {
case 'image':
case 'textinput':
case 'item':
case 'entry':
case 'info':
$this->element = '';
break;
case 'id':
case 'content':
if ($this->element == $name) {
$this->element = '';
}
}
}
/**
* XML parser callback: Perform an action when data is encountered.
*
* @param resource $parser
* A reference to the XML parser calling the handler.
* @param string $name
* The name of the element for which this handler is called.
* @param array $attributes
* An associative array with the element's attributes (if any).
*/
function elementData($parser, $data) {
$this->items += array($this->item => array());
switch ($this->element) {
case 'item':
$this->items[$this->item] += array($this->tag => '');
$this->items[$this->item][$this->tag] .= $data;
break;
case 'image':
case 'logo':
$this->image += array($this->tag => '');
$this->image[$this->tag] .= $data;
break;
case 'link':
if ($data) {
$this->items[$this->item] += array($tag => '');
$this->items[$this->item][$this->tag] .= $data;
}
break;
case 'content':
$this->items[$this->item] += array('content' => '');
$this->items[$this->item]['content'] .= $data;
break;
case 'summary':
$this->items[$this->item] += array('summary' => '');
$this->items[$this->item]['summary'] .= $data;
break;
case 'tagline':
case 'subtitle':
$this->channel += array('description' => '');
$this->channel['description'] .= $data;
break;
case 'info':
case 'id':
case 'textinput':
// The sub-element is not supported. However, we must recognize
// it or its contents will end up in the item array.
break;
default:
$this->channel += array($this->tag => '');
$this->channel[$this->tag] .= $data;
}
}
/**
* Parses the W3C date/time format, a subset of ISO 8601.
*
* PHP date parsing functions do not handle this format. See
* http://www.w3.org/TR/NOTE-datetime for more information. Originally from
* MagpieRSS (http://magpierss.sourceforge.net/).
*
* @param string $date_str
* A string with a potentially W3C DTF date.
*
* @return int|false
* A timestamp if parsed successfully or FALSE if not.
*/
function parseW3cdtf($date_str) {
if (preg_match('/(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(:(\d{2}))?(?:([-+])(\d{2}):?(\d{2})|(Z))?/', $date_str, $match)) {
list($year, $month, $day, $hours, $minutes, $seconds) = array($match[1], $match[2], $match[3], $match[4], $match[5], $match[6]);
// Calculate the epoch for current date assuming GMT.
$epoch = gmmktime($hours, $minutes, $seconds, $month, $day, $year);
if ($match[10] != 'Z') { // Z is zulu time, aka GMT
list($tz_mod, $tz_hour, $tz_min) = array($match[8], $match[9], $match[10]);
// Zero out the variables.
if (!$tz_hour) {
$tz_hour = 0;
}
if (!$tz_min) {
$tz_min = 0;
}
$offset_secs = (($tz_hour * 60) + $tz_min) * 60;
// Is timezone ahead of GMT? If yes, subtract offset.
if ($tz_mod == '+') {
$offset_secs *= -1;
}
$epoch += $offset_secs;
}
return $epoch;
}
else {
return FALSE;
}
}
}

View File

@ -0,0 +1,210 @@
<?php
/**
* @file
* Contains \Drupal\aggregator\Plugin\aggregator\processor\DefaultProcessor.
*/
namespace Drupal\aggregator\Plugin\aggregator\processor;
use Drupal\Component\Plugin\PluginBase;
use Drupal\aggregator\Plugin\ProcessorInterface;
use Drupal\aggregator\Plugin\Core\Entity\Feed;
use Drupal\Component\Annotation\Plugin;
use Drupal\Core\Annotation\Translation;
use Drupal\Core\Database\Database;
/**
* Defines a default processor implementation.
*
* Creates lightweight records from feed items.
*
* @Plugin(
* id = "aggregator",
* title = @Translation("Default processor"),
* description = @Translation("Creates lightweight records from feed items.")
* )
*/
class DefaultProcessor extends PluginBase implements ProcessorInterface {
/**
* Implements \Drupal\aggregator\Plugin\ProcessorInterface::settingsForm().
*/
public function settingsForm(array $form, array &$form_state) {
$config = config('aggregator.settings');
$processors = $config->get('processors');
$info = $this->getDefinition();
$items = drupal_map_assoc(array(3, 5, 10, 15, 20, 25), array($this, 'formatItems'));
$period = drupal_map_assoc(array(3600, 10800, 21600, 32400, 43200, 86400, 172800, 259200, 604800, 1209600, 2419200, 4838400, 9676800), 'format_interval');
$period[AGGREGATOR_CLEAR_NEVER] = t('Never');
$form['processors'][$info['id']] = array();
// Only wrap into details if there is a basic configuration.
if (isset($form['basic_conf'])) {
$form['processors'][$info['id']] = array(
'#type' => 'details',
'#title' => t('Default processor settings'),
'#description' => $info['description'],
'#collapsed' => !in_array($info['id'], $processors),
);
}
$form['processors'][$info['id']]['aggregator_summary_items'] = array(
'#type' => 'select',
'#title' => t('Number of items shown in listing pages'),
'#default_value' => $config->get('source.list_max'),
'#empty_value' => 0,
'#options' => $items,
);
$form['processors'][$info['id']]['aggregator_clear'] = array(
'#type' => 'select',
'#title' => t('Discard items older than'),
'#default_value' => $config->get('items.expire'),
'#options' => $period,
'#description' => t('Requires a correctly configured <a href="@cron">cron maintenance task</a>.', array('@cron' => url('admin/reports/status'))),
);
$form['processors'][$info['id']]['aggregator_category_selector'] = array(
'#type' => 'radios',
'#title' => t('Select categories using'),
'#default_value' => $config->get('source.category_selector'),
'#options' => array('checkboxes' => t('checkboxes'),
'select' => t('multiple selector')),
'#description' => t('For a small number of categories, checkboxes are easier to use, while a multiple selector works well with large numbers of categories.'),
);
$form['processors'][$info['id']]['aggregator_teaser_length'] = array(
'#type' => 'select',
'#title' => t('Length of trimmed description'),
'#default_value' => $config->get('items.teaser_length'),
'#options' => drupal_map_assoc(array(0, 200, 400, 600, 800, 1000, 1200, 1400, 1600, 1800, 2000), array($this, 'formatCharacters')),
'#description' => t("The maximum number of characters used in the trimmed version of content.")
);
return $form;
}
/**
* Implements \Drupal\aggregator\Plugin\ProcessorInterface::settingsSubmit().
*/
public function settingsSubmit(array $form, array &$form_state) {
$config = config('aggregator.settings');
$config->set('items.expire', $form_state['values']['aggregator_clear'])
->set('items.teaser_length', $form_state['values']['aggregator_teaser_length'])
->set('source.list_max', $form_state['values']['aggregator_summary_items'])
->set('source.category_selector', $form_state['values']['aggregator_category_selector'])
->save();
}
/**
* Implements \Drupal\aggregator\Plugin\ProcessorInterface::process().
*/
public function process(Feed $feed) {
if (!is_array($feed->items)) {
return;
}
foreach ($feed->items as $item) {
// @todo: The default entity render controller always returns an empty
// array, which is ignored in aggregator_save_item() currently. Should
// probably be fixed.
if (empty($item['title'])) {
continue;
}
// Save this item. Try to avoid duplicate entries as much as possible. If
// we find a duplicate entry, we resolve it and pass along its ID is such
// that we can update it if needed.
if (!empty($item['guid'])) {
$values = array('fid' => $feed->id(), 'guid' => $item['guid']);
}
elseif ($item['link'] && $item['link'] != $feed->link && $item['link'] != $feed->url) {
$values = array('fid' => $feed->id(), 'link' => $item['link']);
}
else {
$values = array('fid' => $feed->id(), 'title' => $item['title']);
}
// Try to load an existing entry.
if ($entry = entity_load_multiple_by_properties('aggregator_item', $values)) {
$entry = reset($entry);
}
else {
$entry = entity_create('aggregator_item', array('langcode' => $feed->language()->langcode));
}
if ($item['timestamp']) {
$entry->timestamp->value = $item['timestamp'];
}
// Make sure the item title and author fit in the 255 varchar column.
$entry->title->value = truncate_utf8($item['title'], 255, TRUE, TRUE);
$entry->author->value = truncate_utf8($item['author'], 255, TRUE, TRUE);
$entry->fid->value = $feed->id();
$entry->link->value = $item['link'];
$entry->description->value = $item['description'];
$entry->guid->value = $item['guid'];
$entry->save();
}
}
/**
* Implements \Drupal\aggregator\Plugin\ProcessorInterface::remove().
*/
public function remove(Feed $feed) {
$iids = Database::getConnection()->query('SELECT iid FROM {aggregator_item} WHERE fid = :fid', array(':fid' => $feed->id()))->fetchCol();
if ($iids) {
entity_delete_multiple('aggregator_item', $iids);
}
// @todo This should be moved out to caller with a different message maybe.
drupal_set_message(t('The news items from %site have been removed.', array('%site' => $feed->label())));
}
/**
* Implements \Drupal\aggregator\Plugin\ProcessorInterface::postProcess().
*
* Expires items from a feed depending on expiration settings.
*/
public function postProcess(Feed $feed) {
$aggregator_clear = config('aggregator.settings')->get('items.expire');
if ($aggregator_clear != AGGREGATOR_CLEAR_NEVER) {
// Remove all items that are older than flush item timer.
$age = REQUEST_TIME - $aggregator_clear;
$iids = Database::getConnection()->query('SELECT iid FROM {aggregator_item} WHERE fid = :fid AND timestamp < :timestamp', array(
':fid' => $feed->id(),
':timestamp' => $age,
))
->fetchCol();
if ($iids) {
entity_delete_multiple('aggregator_item', $iids);
}
}
}
/**
* Helper function for drupal_map_assoc.
*
* @param int $count
* Items count.
*
* @return string
* A string that is plural-formatted as "@count items".
*/
protected function formatItems($count) {
return format_plural($count, '1 item', '@count items');
}
/**
* Creates display text for teaser length option values.
*
* Callback for drupal_map_assoc() within settingsForm().
*
* @param int $length
* The desired length of teaser text, in bytes.
*
* @return string
* A translated string explaining the teaser string length.
*/
protected function formatCharacters($length) {
return ($length == 0) ? t('Unlimited') : format_plural($length, '1 character', '@count characters');
}
}

View File

@ -23,12 +23,22 @@ class AggregatorConfigurationTest extends AggregatorTestBase {
* Tests the settings form to ensure the correct default values are used.
*/
function testSettingsPage() {
$this->drupalGet('admin/config/services/aggregator/settings');
// Make sure that test plugins are present.
$this->assertText('Test fetcher');
$this->assertText('Test parser');
$this->assertText('Test processor');
// Set new values and enable test plugins.
$edit = array(
'aggregator_allowed_html_tags' => '<a>',
'aggregator_summary_items' => 10,
'aggregator_clear' => 3600,
'aggregator_category_selector' => 'select',
'aggregator_teaser_length' => 200,
'aggregator_fetcher' => 'aggregator_test_fetcher',
'aggregator_parser' => 'aggregator_test_parser',
'aggregator_processors[aggregator_test_processor]' => 'aggregator_test_processor',
);
$this->drupalPost('admin/config/services/aggregator/settings', $edit, t('Save configuration'));
$this->assertText(t('The configuration options have been saved.'));
@ -36,5 +46,22 @@ class AggregatorConfigurationTest extends AggregatorTestBase {
foreach ($edit as $name => $value) {
$this->assertFieldByName($name, $value, format_string('"@name" has correct default value.', array('@name' => $name)));
}
// Check for our test processor settings form.
$this->assertText(t('Dummy length setting'));
// Change its value to ensure that settingsSubmit is called.
$edit = array(
'dummy_length' => 100,
);
$this->drupalPost('admin/config/services/aggregator/settings', $edit, t('Save configuration'));
$this->assertText(t('The configuration options have been saved.'));
$this->assertFieldByName('dummy_length', 100, '"dummy_length" has correct default value.');
// Make sure settings form is still accessible even after disabling a module
// that provides the selected plugins.
module_disable(array('aggregator_test'));
$this->resetAll();
$this->drupalGet('admin/config/services/aggregator/settings');
$this->assertResponse(200);
}
}

View File

@ -148,10 +148,10 @@ abstract class AggregatorTestBase extends WebTestBase {
*
* @param \Drupal\aggregator\Plugin\Core\Entity\Feed $feed
* Feed object representing the feed.
* @param $expected_count
* Expected number of feed items.
* @param int|null $expected_count
* Expected number of feed items. If omitted no check will happen.
*/
function updateFeedItems(Feed $feed, $expected_count) {
function updateFeedItems(Feed $feed, $expected_count = NULL) {
// First, let's ensure we can get to the rss xml.
$this->drupalGet($feed->url->value);
$this->assertResponse(200, format_string('!url is reachable.', array('!url' => $feed->url->value)));
@ -171,8 +171,11 @@ abstract class AggregatorTestBase extends WebTestBase {
foreach ($result as $item) {
$feed->items[] = $item->iid;
}
$feed->item_count = count($feed->items);
$this->assertEqual($expected_count, $feed->item_count, format_string('Total items in feed equal to the total items in database (!val1 != !val2)', array('!val1' => $expected_count, '!val2' => $feed->item_count)));
if ($expected_count !== NULL) {
$feed->item_count = count($feed->items);
$this->assertEqual($expected_count, $feed->item_count, format_string('Total items in feed equal to the total items in database (!val1 != !val2)', array('!val1' => $expected_count, '!val2' => $feed->item_count)));
}
}
/**
@ -361,4 +364,18 @@ EOF;
$this->drupalPost('node/add/article', $edit, t('Save'));
}
}
/**
* Enable the plugins coming with aggregator_test module.
*/
function enableTestPlugins() {
config('aggregator.settings')
->set('fetcher', 'aggregator_test_fetcher')
->set('parser', 'aggregator_test_parser')
->set('processors', array(
'aggregator_test_processor' => 'aggregator_test_processor',
'aggregator' => 'aggregator'
))
->save();
}
}

View File

@ -0,0 +1,51 @@
<?php
/**
* @file
* Contains \Drupal\aggregator\Tests\FeedProcessorTest.
*/
namespace Drupal\aggregator\Tests;
/**
* Tests feed fetching in the Aggregator module.
*
* @see \Drupal\aggregator_test\Plugin\aggregator\fetcher\TestFetcher.
*/
class FeedFetcherPluginTest extends AggregatorTestBase {
public static function getInfo() {
return array(
'name' => 'Feed fetcher plugins',
'description' => 'Test the fetcher plugins functionality and discoverability.',
'group' => 'Aggregator',
);
}
public function setUp() {
parent::setUp();
// Enable test plugins.
$this->enableTestPlugins();
// Create some nodes.
$this->createSampleNodes();
}
/**
* Test fetching functionality.
*/
public function testfetch() {
// Create feed with local url.
$feed = $this->createFeed();
$this->updateFeedItems($feed);
$this->assertFalse(empty($feed->items));
// Remove items and restore checked property to 0.
$this->removeFeedItems($feed);
// Change its name and try again.
$feed->title->value = 'Do not fetch';
$feed->save();
$this->updateFeedItems($feed);
// Fetch should fail due to feed name.
$this->assertTrue(empty($feed->items));
}
}

View File

@ -0,0 +1,70 @@
<?php
/**
* @file
* Contains \Drupal\aggregator\Tests\FeedProcessorTest.
*/
namespace Drupal\aggregator\Tests;
/**
* Tests feed processing in the Aggregator module.
*
* @see \Drupal\aggregator_test\Plugin\aggregator\processor\TestProcessor.
*/
class FeedProcessorPluginTest extends AggregatorTestBase {
public static function getInfo() {
return array(
'name' => 'Feed processor plugins',
'description' => 'Test the processor plugins functionality and discoverability.',
'group' => 'Aggregator',
);
}
/**
* Overrides \Drupal\simpletest\WebTestBase::setUp().
*/
public function setUp() {
parent::setUp();
// Enable test plugins.
$this->enableTestPlugins();
// Create some nodes.
$this->createSampleNodes();
}
/**
* Test processing functionality.
*/
public function testProcess() {
$feed = $this->createFeed();
$this->updateFeedItems($feed);
foreach ($feed->items as $iid) {
$item = entity_load('aggregator_item', $iid);
$this->assertTrue(strpos($item->title->value, 'testProcessor') === 0);
}
}
/**
* Test removing functionality.
*/
public function testRemove() {
$feed = $this->createFeed();
$this->updateAndRemove($feed, NULL);
// Make sure the feed title is changed.
$entities = entity_load_multiple_by_properties('aggregator_feed', array('description' => $feed->description->value));
$this->assertTrue(empty($entities));
}
/**
* Test post-processing functionality.
*/
public function testPostProcess() {
$feed = $this->createFeed(NULL, array('refresh' => 1800));
$this->updateFeedItems($feed);
// Reload the feed to get new values.
$feed = entity_load('aggregator_feed', $feed->id(), TRUE);
// Make sure its refresh rate doubled.
$this->assertEqual($feed->refresh->value, 3600);
}
}

View File

@ -68,5 +68,12 @@ class UpdateFeedItemTest extends AggregatorTestBase {
$after = db_query('SELECT timestamp FROM {aggregator_item} WHERE fid = :fid', array(':fid' => $feed->id()))->fetchField();
$this->assertTrue($before === $after, format_string('Publish timestamp of feed item was not updated (!before === !after)', array('!before' => $before, '!after' => $after)));
// Make sure updating items works even after disabling a module
// that provides the selected plugins.
$this->enableTestPlugins();
module_disable(array('aggregator_test'));
$this->updateFeedItems($feed);
$this->assertResponse(200);
}
}

View File

@ -0,0 +1,2 @@
items:
dummy_length: 5

View File

@ -0,0 +1,39 @@
<?php
/**
* @file
* Contains \Drupal\aggregator_test\Plugin\aggregator\fetcher\TestFetcher.
*/
namespace Drupal\aggregator_test\Plugin\aggregator\fetcher;
use Drupal\aggregator\Plugin\FetcherInterface;
use Drupal\aggregator\Plugin\aggregator\fetcher\DefaultFetcher;
use Drupal\aggregator\Plugin\Core\Entity\Feed;
use Drupal\Component\Annotation\Plugin;
use Drupal\Core\Annotation\Translation;
use Guzzle\Http\Exception\BadResponseException;
/**
* Defines a test fetcher implementation.
*
* Uses http_default_client class to download the feed.
*
* @Plugin(
* id = "aggregator_test_fetcher",
* title = @Translation("Test fetcher"),
* description = @Translation("Dummy fetcher for testing purposes.")
* )
*/
class TestFetcher extends DefaultFetcher implements FetcherInterface {
/**
* Implements \Drupal\aggregator\Plugin\FetcherInterface::fetch().
*/
public function fetch(Feed $feed) {
if ($feed->label() == 'Do not fetch') {
return FALSE;
}
return parent::fetch($feed);
}
}

View File

@ -0,0 +1,37 @@
<?php
/**
* @file
* Contains \Drupal\aggregator_test\Plugin\aggregator\parser\TestParser.
*/
namespace Drupal\aggregator_test\Plugin\aggregator\parser;
use Drupal\aggregator\Plugin\ParserInterface;
use Drupal\aggregator\Plugin\Core\Entity\Feed;
use Drupal\aggregator\Plugin\aggregator\parser\DefaultParser;
use Drupal\Component\Annotation\Plugin;
use Drupal\Core\Annotation\Translation;
/**
* Defines a Test parser implementation.
*
* Parses RSS, Atom and RDF feeds.
*
* @Plugin(
* id = "aggregator_test_parser",
* title = @Translation("Test parser"),
* description = @Translation("Dummy parser for testing purposes.")
* )
*/
class TestParser extends DefaultParser implements ParserInterface {
/**
* Implements \Drupal\aggregator\Plugin\ParserInterface::parse().
*
* @todo Actually test this.
*/
public function parse(Feed $feed) {
return parent::parse($feed);
}
}

View File

@ -0,0 +1,89 @@
<?php
/**
* @file
* Contains \Drupal\aggregator_test\Plugin\aggregator\processor\TestProcessor.
*/
namespace Drupal\aggregator_test\Plugin\aggregator\processor;
use Drupal\Component\Plugin\PluginBase;
use Drupal\aggregator\Plugin\ProcessorInterface;
use Drupal\aggregator\Plugin\Core\Entity\Feed;
use Drupal\Component\Annotation\Plugin;
use Drupal\Core\Annotation\Translation;
/**
* Defines a default processor implementation.
*
* Creates lightweight records from feed items.
*
* @Plugin(
* id = "aggregator_test_processor",
* title = @Translation("Test processor"),
* description = @Translation("Test generic processor functionality.")
* )
*/
class TestProcessor extends PluginBase implements ProcessorInterface {
/**
* Implements \Drupal\aggregator\Plugin\ProcessorInterface::settingsForm().
*/
public function settingsForm(array $form, array &$form_state) {
$config = config('aggregator.settings');
$processors = $config->get('processors');
$info = $this->getDefinition();
$form['processors'][$info['id']] = array(
'#type' => 'details',
'#title' => t('Test processor settings'),
'#description' => $info['description'],
'#collapsed' => !in_array($info['id'], $processors),
);
// Add some dummy settings to verify settingsForm is called.
$form['processors'][$info['id']]['dummy_length'] = array(
'#title' => t('Dummy length setting'),
'#type' => 'number',
'#min' => 1,
'#max' => 1000,
'#default_value' => config('aggregator_test.settings')->get('items.dummy_length'),
);
return $form;
}
/**
* Implements \Drupal\aggregator\Plugin\ProcessorInterface::settingsSubmit().
*/
public function settingsSubmit(array $form, array &$form_state) {
config('aggregator_test.settings')
->set('items.dummy_length', $form_state['values']['dummy_length'])
->save();
}
/**
* Implements \Drupal\aggregator\Plugin\ProcessorInterface::process().
*/
public function process(Feed $feed) {
foreach ($feed->items as &$item) {
// Prepend our test string.
$item['title'] = 'testProcessor' . $item['title'];
}
}
/**
* Implements \Drupal\aggregator\Plugin\ProcessorInterface::remove().
*/
public function remove(Feed $feed) {
// Append a random number, just to change the feed description.
$feed->description->value .= rand(0, 10);
}
/**
* Implements \Drupal\aggregator\Plugin\ProcessorInterface::postProcess().
*/
public function postProcess(Feed $feed) {
// Double the refresh rate.
$feed->refresh->value *= 2;
$feed->save();
}
}