aggregator.module 57.3 KB
Newer Older
1
<?php
Dries's avatar
Dries committed
2
// $Id$
Dries's avatar
 
Dries committed
3

Dries's avatar
 
Dries committed
4 5
/**
 * @file
6
 * Used to aggregate syndicated content (RSS, RDF, and Atom).
Dries's avatar
 
Dries committed
7 8
 */

Dries's avatar
 
Dries committed
9 10 11
/**
 * Implementation of hook_help().
 */
12 13
function aggregator_help($path, $arg) {
  switch ($path) {
Kjartan's avatar
Kjartan committed
14
    case 'admin/help#aggregator':
15
      $output = '<p>'. t('The news aggregator is a powerful on-site RSS syndicator/news reader that can gather fresh content from news sites and weblogs around the web.') .'</p>';
16 17
      $output .= '<p>'. t('Users can view the latest news chronologically in the <a href="@aggregator">main news aggregator display</a> or by <a href="@aggregator-sources">source</a>. Administrators can add, edit and delete feeds and choose how often to check for newly updated news for each individual feed. Administrators can also tag individual feeds with categories, offering selective grouping of some feeds into separate displays. Listings of the latest news for individual sources or categorized sources can be enabled as blocks for display in the sidebar through the <a href="@admin-block">block administration page</a>. The news aggregator requires cron to check for the latest news from the sites to which you have subscribed. Drupal also provides a <a href="@aggregator-opml">machine-readable OPML file</a> of all of your subscribed feeds.', array('@aggregator' => url('aggregator'), '@aggregator-sources' => url('aggregator/sources'), '@admin-block' => url('admin/build/block'), '@aggregator-opml' => url('aggregator/opml'))) .'</p>';
      $output .= '<p>'. t('For more information please read the configuration and customization handbook <a href="@aggregator">Aggregator page</a>.', array('@aggregator' => 'http://drupal.org/handbook/modules/aggregator/')) .'</p>';
18
      return $output;
19
    case 'admin/content/aggregator':
20
      return '<p>'. t('Thousands of sites (particularly news sites and weblogs) publish their latest headlines and/or stories in a machine-readable format so that other sites can easily link to them. This content is usually in the form of an <a href="http://blogs.law.harvard.edu/tech/rss">RSS</a> feed (which is an XML-based syndication standard). To display the feed or category in a block you must decide how many items to show by editing the feed or block and turning on the <a href="@block">feed\'s block</a>.', array('@block' => url('admin/build/block'))) .'</p>';
21
    case 'admin/content/aggregator/add/feed':
22
      return '<p>'. t('Add a site that has an RSS/RDF/Atom feed. The URL is the full path to the feed file. For the feed to update automatically you must run "cron.php" on a regular basis. If you already have a feed with the URL you are planning to use, the system will not accept another feed with the same URL.') .'</p>';
23
    case 'admin/content/aggregator/add/category':
24
      return '<p>'. t('Categories provide a way to group items from different news feeds together. Each news category has its own feed page and block. For example, you could tag various sport-related feeds as belonging to a category called <em>Sports</em>. News items can be added to a category automatically by setting a feed to automatically place its item into that category, or by using the categorize items link in any listing of news items.') .'</p>';
Dries's avatar
 
Dries committed
25
  }
26 27
}

28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48
/**
 * Implementation of hook_theme()
 */
function aggregator_theme() {
  return array(
    'aggregator_page_list' => array(
      'arguments' => array('form' => NULL),
    ),
    'aggregator_feed' => array(
      'arguments' => array('feed' => NULL),
    ),
    'aggregator_block_item' => array(
      'arguments' => array('item' => NULL, 'feed' => 0),
    ),
    'aggregator_summary_item' => array(
      'arguments' => array('item' => NULL),
    ),
    'aggregator_page_item' => array(
      'arguments' => array('item' => NULL),
    ),
  );
49
}
50

51 52 53
/**
 * Implementation of hook_menu().
 */
54 55
function aggregator_menu() {
  $items['admin/content/aggregator'] = array(
56 57
    'title' => 'News aggregator',
    'description' => "Configure which content your site aggregates from other sites, how often it polls them, and how they're categorized.",
58 59 60 61
    'page callback' => 'aggregator_admin_overview',
    'access arguments' => array('administer news feeds'),
  );
  $items['admin/content/aggregator/add/feed'] = array(
62
    'title' => 'Add feed',
63 64 65 66
    'page callback' => 'drupal_get_form',
    'page arguments' => array('aggregator_form_feed'),
    'access arguments' => array('administer news feeds'),
    'type' => MENU_LOCAL_TASK,
67
    'parent' => 'admin/content/aggregator',
68 69
  );
  $items['admin/content/aggregator/add/category'] = array(
70
    'title' => 'Add category',
71 72 73 74
    'page callback' => 'drupal_get_form',
    'page arguments' => array('aggregator_form_category'),
    'access arguments' => array('administer news feeds'),
    'type' => MENU_LOCAL_TASK,
75
    'parent' => 'admin/content/aggregator',
76
  );
77
  $items['admin/content/aggregator/remove/%aggregator_feed'] = array(
78
    'title' => 'Remove items',
79 80 81 82 83
    'page callback' => 'aggregator_admin_remove_feed',
    'page arguments' => array(4),
    'access arguments' => array('administer news feeds'),
    'type' => MENU_CALLBACK,
  );
84
  $items['admin/content/aggregator/update/%aggregator_feed'] = array(
85
    'title' => 'Update items',
86 87 88 89 90 91
    'page callback' => 'aggregator_admin_refresh_feed',
    'page arguments' => array(4),
    'access arguments' => array('administer news feeds'),
    'type' => MENU_CALLBACK,
  );
  $items['admin/content/aggregator/list'] = array(
92
    'title' => 'List',
93 94 95 96
    'type' => MENU_DEFAULT_LOCAL_TASK,
    'weight' => -10,
  );
  $items['admin/content/aggregator/settings'] = array(
97
    'title' => 'Settings',
98 99 100 101 102 103 104
    'page callback' => 'drupal_get_form',
    'page arguments' => array('aggregator_admin_settings'),
    'type' => MENU_LOCAL_TASK,
    'weight' => 10,
    'access arguments' => array('administer news feeds'),
  );
  $items['aggregator'] = array(
105
    'title' => 'News aggregator',
106 107 108 109 110
    'page callback' => 'aggregator_page_last',
    'access arguments' => array('access news feeds'),
    'weight' => 5,
  );
  $items['aggregator/sources'] = array(
111
    'title' => 'Sources',
112 113 114
    'page callback' => 'aggregator_page_sources',
    'access arguments' => array('access news feeds'));
  $items['aggregator/categories'] = array(
115
    'title' => 'Categories',
116
    'page callback' => 'aggregator_page_categories',
117
    'access callback' => '_aggregator_has_categories',
118 119
  );
  $items['aggregator/rss'] = array(
120
    'title' => 'RSS feed',
121 122 123 124 125
    'page callback' => 'aggregator_page_rss',
    'access arguments' => array('access news feeds'),
    'type' => MENU_CALLBACK,
  );
  $items['aggregator/opml'] = array(
126
    'title' => 'OPML feed',
127 128 129 130
    'page callback' => 'aggregator_page_opml',
    'access arguments' => array('access news feeds'),
    'type' => MENU_CALLBACK,
  );
131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158
  $items['aggregator/categories/%aggregator_category'] = array(
    'title callback' => '_aggregator_category_title',
    'title arguments' => array(2),
    'page callback' => 'aggregator_page_category',
    'page arguments' => array(2),
    'access callback' => 'user_access',
    'access arguments' => array('access news feeds'),
  );
  $items['aggregator/categories/%aggregator_category/view'] = array(
    'title' => 'View',
    'type' => MENU_DEFAULT_LOCAL_TASK,
    'weight' => -10,
  );
  $items['aggregator/categories/%aggregator_category/categorize'] = array(
    'title' => 'Categorize',
    'page callback' => 'drupal_get_form',
    'page arguments' => array('aggregator_page_category', 2),
    'access arguments' => array('administer news feeds'),
    'type' => MENU_LOCAL_TASK,
  );
  $items['aggregator/categories/%aggregator_category/configure'] = array(
    'title' => 'Configure',
    'page callback' => 'drupal_get_form',
    'page arguments' => array('aggregator_form_category', 2),
    'access arguments' => array('administer news feeds'),
    'type' => MENU_LOCAL_TASK,
    'weight' => 1,
  );
159
  $items['aggregator/sources/%aggregator_feed'] = array(
160
    'page callback' => 'aggregator_page_source',
161
    'page arguments' => array(2),
162 163
    'type' => MENU_CALLBACK,
  );
164
  $items['aggregator/sources/%aggregator_feed/view'] = array(
165
    'title' => 'View',
166 167 168
    'type' => MENU_DEFAULT_LOCAL_TASK,
    'weight' => -10,
  );
169
  $items['aggregator/sources/%aggregator_feed/categorize'] = array(
170
    'title' => 'Categorize',
171
    'page callback' => 'drupal_get_form',
172
    'page arguments' => array('aggregator_page_source', 2),
173 174 175
    'access arguments' => array('administer news feeds'),
    'type' => MENU_LOCAL_TASK,
  );
176
  $items['aggregator/sources/%aggregator_feed/configure'] = array(
177
    'title' => 'Configure',
178 179 180 181 182 183
    'page callback' => 'drupal_get_form',
    'page arguments' => array('aggregator_form_feed', 2),
    'access arguments' => array('administer news feeds'),
    'type' => MENU_LOCAL_TASK,
    'weight' => 1,
  );
184
  $items['admin/content/aggregator/edit/feed/%aggregator_feed'] = array(
185
    'title' => 'Edit feed',
186 187 188 189 190
    'page callback' => 'drupal_get_form',
    'page arguments' => array('aggregator_form_feed', 5),
    'access arguments' => array('administer news feeds'),
    'type' => MENU_CALLBACK,
  );
191
  $items['admin/content/aggregator/edit/category/%aggregator_category'] = array(
192
    'title' => 'Edit category',
193 194 195 196 197
    'page callback' => 'drupal_get_form',
    'page arguments' => array('aggregator_form_category', 5),
    'access arguments' => array('administer news feeds'),
    'type' => MENU_CALLBACK,
  );
198 199 200 201

  return $items;
}

202 203 204 205
function _aggregator_category_title($category) {
  return $category['title'];
}

206 207 208
function aggregator_init() {
  drupal_add_css(drupal_get_path('module', 'aggregator') .'/aggregator.css');
}
209 210 211 212

function _aggregator_has_categories() {
  return user_access('access news feeds') && db_result(db_query('SELECT COUNT(*) FROM {aggregator_category}'));
}
213

Dries's avatar
Dries committed
214
function aggregator_admin_settings() {
215
  $items = array(0 => t('none')) + drupal_map_assoc(array(3, 5, 10, 15, 20, 25), '_aggregator_items');
216
  $period = drupal_map_assoc(array(3600, 10800, 21600, 32400, 43200, 86400, 172800, 259200, 604800, 1209600, 2419200, 4838400, 9676800), 'format_interval');
Dries's avatar
 
Dries committed
217

218
  $form['aggregator_allowed_html_tags'] = array(
219 220 221
    '#type' => 'textfield', '#title' => t('Allowed HTML tags'), '#size' => 80, '#maxlength' => 255,
    '#default_value' => variable_get('aggregator_allowed_html_tags', '<a> <b> <br> <dd> <dl> <dt> <em> <i> <li> <ol> <p> <strong> <u> <ul>'),
    '#description' => t('The list of tags which are allowed in feeds, i.e., which will not be removed by Drupal.')
222 223 224
  );

  $form['aggregator_summary_items'] = array(
225 226 227
    '#type' => 'select', '#title' => t('Items shown in sources and categories pages') ,
    '#default_value' => variable_get('aggregator_summary_items', 3), '#options' => $items,
    '#description' =>  t('The number of items which will be shown with each feed or category in the feed and category summary pages.')
228 229 230
  );

  $form['aggregator_clear'] = array(
231 232
    '#type' => 'select', '#title' => t('Discard news items older than'),
    '#default_value' => variable_get('aggregator_clear', 9676800), '#options' => $period,
233
    '#description' => t('Older news items will be automatically discarded. Requires crontab.')
234
  );
235

236
  $form['aggregator_category_selector'] = array(
237
    '#type' => 'radios', '#title' => t('Category selection type'), '#default_value' => variable_get('aggregator_category_selector', 'checkboxes'),
238
    '#options' => array('checkboxes' => t('checkboxes'), 'select' => t('multiple selector')),
239
    '#description' => t('The type of category selection widget which is shown on categorization pages. Checkboxes are easier to use; a multiple selector is good for working with large numbers of categories.')
240
  );
Dries's avatar
Dries committed
241

242
  return system_settings_form($form);
Dries's avatar
 
Dries committed
243 244
}

Dries's avatar
 
Dries committed
245 246 247
/**
 * Implementation of hook_perm().
 */
Kjartan's avatar
Kjartan committed
248
function aggregator_perm() {
Dries's avatar
 
Dries committed
249
  return array('administer news feeds', 'access news feeds');
Dries's avatar
 
Dries committed
250 251
}

Dries's avatar
 
Dries committed
252 253 254 255 256
/**
 * Implementation of hook_cron().
 *
 * Checks news feeds for updates once their refresh interval has elapsed.
 */
Dries's avatar
 
Dries committed
257
function aggregator_cron() {
Dries's avatar
 
Dries committed
258
  $result = db_query('SELECT * FROM {aggregator_feed} WHERE checked + refresh < %d', time());
Dries's avatar
 
Dries committed
259 260
  while ($feed = db_fetch_array($result)) {
    aggregator_refresh($feed);
Dries's avatar
 
Dries committed
261 262 263
  }
}

Dries's avatar
 
Dries committed
264 265 266 267 268
/**
 * Implementation of hook_block().
 *
 * Generates blocks for the latest news items in each category and feed.
 */
269
function aggregator_block($op = 'list', $delta = 0, $edit = array()) {
Dries's avatar
 
Dries committed
270
  if (user_access('access news feeds')) {
Dries's avatar
 
Dries committed
271
    if ($op == 'list') {
Dries's avatar
Dries committed
272
      $result = db_query('SELECT cid, title FROM {aggregator_category} ORDER BY title');
Dries's avatar
 
Dries committed
273
      while ($category = db_fetch_object($result)) {
274
        $block['category-'. $category->cid]['info'] = t('!title category latest items', array('!title' => $category->title));
Kjartan's avatar
Kjartan committed
275
      }
Dries's avatar
Dries committed
276
      $result = db_query('SELECT fid, title FROM {aggregator_feed} ORDER BY fid');
Kjartan's avatar
Kjartan committed
277
      while ($feed = db_fetch_object($result)) {
278
        $block['feed-'. $feed->fid]['info'] = t('!title feed latest items', array('!title' => $feed->title));
Kjartan's avatar
Kjartan committed
279
      }
Dries's avatar
 
Dries committed
280
    }
Dries's avatar
Dries committed
281
    else if ($op == 'configure') {
282
      list($type, $id) = explode('-', $delta);
Dries's avatar
Dries committed
283 284 285 286 287 288
      if ($type == 'category') {
        $value = db_result(db_query('SELECT block FROM {aggregator_category} WHERE cid = %d', $id));
      }
      else {
        $value = db_result(db_query('SELECT block FROM {aggregator_feed} WHERE fid = %d', $id));
      }
289
      $form['block'] = array('#type' => 'select', '#title' => t('Number of news items in block'), '#default_value' => $value, '#options' => drupal_map_assoc(array(2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20)));
290
      return $form;
Dries's avatar
Dries committed
291 292
    }
    else if ($op == 'save') {
293
      list($type, $id) = explode('-', $delta);
Dries's avatar
Dries committed
294 295 296 297 298 299 300
      if ($type == 'category') {
        $value = db_query('UPDATE {aggregator_category} SET block = %d WHERE cid = %d', $edit['block'], $id);
      }
      else {
        $value = db_query('UPDATE {aggregator_feed} SET block = %d WHERE fid = %d', $edit['block'], $id);
      }
    }
301
    else if ($op == 'view') {
302
      list($type, $id) = explode('-', $delta);
Kjartan's avatar
Kjartan committed
303
      switch ($type) {
Dries's avatar
 
Dries committed
304
        case 'feed':
305
          if ($feed = db_fetch_object(db_query('SELECT fid, title, block FROM {aggregator_feed} WHERE fid = %d', $id))) {
306
            $block['subject'] = check_plain($feed->title);
307
            $result = db_query_range('SELECT * FROM {aggregator_item} WHERE fid = %d ORDER BY timestamp DESC, iid DESC', $feed->fid, 0, $feed->block);
308
            $read_more = '<div class="more-link">'. l(t('more'), 'aggregator/sources/'. $feed->fid, array('title' => t("View this feed's recent news."))) .'</div>';
309
          }
Kjartan's avatar
Kjartan committed
310
          break;
311

Dries's avatar
 
Dries committed
312
        case 'category':
313
          if ($category = db_fetch_object(db_query('SELECT cid, title, block FROM {aggregator_category} WHERE cid = %d', $id))) {
314
            $block['subject'] = check_plain($category->title);
315
            $result = db_query_range('SELECT i.* FROM {aggregator_category_item} ci LEFT JOIN {aggregator_item} i ON ci.iid = i.iid WHERE ci.cid = %d ORDER BY i.timestamp DESC, i.iid DESC', $category->cid, 0, $category->block);
316
            $read_more = '<div class="more-link">'. l(t('more'), 'aggregator/categories/'. $category->cid, array('title' => t("View this category's recent news."))) .'</div>';
317
          }
Kjartan's avatar
Kjartan committed
318 319
          break;
      }
Dries's avatar
 
Dries committed
320 321
      $items = array();
      while ($item = db_fetch_object($result)) {
Dries's avatar
 
Dries committed
322
        $items[] = theme('aggregator_block_item', $item);
Dries's avatar
 
Dries committed
323
      }
324 325 326 327 328

      // Only display the block if there are items to show.
      if (count($items) > 0) {
        $block['content'] = theme('item_list', $items) . $read_more;
      }
Dries's avatar
 
Dries committed
329
    }
330 331 332
    if (isset($block)) {
      return $block;
    }
Dries's avatar
 
Dries committed
333
  }
Dries's avatar
 
Dries committed
334 335
}

336 337 338
/**
 * Generate a form to add/edit/delete aggregator categories.
 */
339
function aggregator_form_category(&$form_state, $edit = array('title' => '', 'description' => '', 'cid' => NULL)) {
340 341 342 343 344 345 346 347 348 349
  $form['title'] = array('#type' => 'textfield',
    '#title' => t('Title'),
    '#default_value' => $edit['title'],
    '#maxlength' => 64,
    '#required' => TRUE,
  );
  $form['description'] = array('#type' => 'textarea',
    '#title' => t('Description'),
    '#default_value' => $edit['description'],
  );
350
  $form['submit'] = array('#type' => 'submit', '#value' => t('Save'));
351 352

  if ($edit['cid']) {
353
    $form['delete'] = array('#type' => 'submit', '#value' => t('Delete'));
354 355 356
    $form['cid'] = array('#type' => 'hidden', '#value' => $edit['cid']);
  }

357
  return $form;
358 359 360 361 362
}

/**
 * Validate aggregator_form_feed form submissions.
 */
363
function aggregator_form_category_validate($form, &$form_state) {
364
  if ($form_state['values']['op'] == t('Save')) {
365
    // Check for duplicate titles
366 367
    if (isset($form_state['values']['cid'])) {
      $category = db_fetch_object(db_query("SELECT cid FROM {aggregator_category} WHERE title = '%s' AND cid != %d", $form_state['values']['title'], $form_state['values']['cid']));
368 369
    }
    else {
370
      $category = db_fetch_object(db_query("SELECT cid FROM {aggregator_category} WHERE title = '%s'", $form_state['values']['title']));
371 372
    }
    if ($category) {
373
      form_set_error('title', t('A category named %category already exists. Please enter a unique title.', array('%category' => $form_state['values']['title'])));
374 375 376 377 378 379 380 381
    }
  }
}

/**
 * Process aggregator_form_category form submissions.
 * @todo Add delete confirmation dialog.
 */
382 383 384
function aggregator_form_category_submit($form, &$form_state) {
  if ($form_state['values']['op'] == t('Delete')) {
    $title = $form_state['values']['title'];
385
    // Unset the title:
386
    unset($form_state['values']['title']);
387
  }
388 389 390 391
  aggregator_save_category($form_state['values']);
  if (isset($form_state['values']['cid'])) {
    if (isset($form_state['values']['title'])) {
      drupal_set_message(t('The category %category has been updated.', array('%category' => $form_state['values']['title'])));
392
      if (arg(0) == 'admin') {
393 394
        $form_state['redirect'] = 'admin/content/aggregator/';
        return;
395 396
      }
      else {
397
        $form_state['redirect'] = 'aggregator/categories/'. $form_state['values']['cid'];
398
        return;
399 400 401
      }
    }
    else {
402
      watchdog('aggregator', 'Category %category deleted.', array('%category' => $title));
403
      drupal_set_message(t('The category %category has been deleted.', array('%category' => $title)));
404
      if (arg(0) == 'admin') {
405 406
        $form_state['redirect'] = 'admin/content/aggregator/';
        return;
407 408
      }
      else {
409 410
        $form_state['redirect'] = 'aggregator/categories/';
        return;
411 412 413 414
      }
    }
  }
  else {
415 416
    watchdog('aggregator', 'Category %category added.', array('%category' => $form_state['values']['title']), WATCHDOG_NOTICE, l(t('view'), 'admin/content/aggregator'));
    drupal_set_message(t('The category %category has been added.', array('%category' => $form_state['values']['title'])));
417 418 419 420 421 422 423
  }
}

/**
 * Add/edit/delete aggregator categories.
 */
function aggregator_save_category($edit) {
424
  if (!empty($edit['cid']) && !empty($edit['title'])) {
425 426
    db_query("UPDATE {aggregator_category} SET title = '%s', description = '%s' WHERE cid = %d", $edit['title'], $edit['description'], $edit['cid']);
  }
427
  else if (!empty($edit['cid'])) {
428 429
    db_query('DELETE FROM {aggregator_category} WHERE cid = %d', $edit['cid']);
  }
430
  else if (!empty($edit['title'])) {
431
    // A single unique id for bundles and feeds, to use in blocks
432
    db_query("INSERT INTO {aggregator_category} (title, description, block) VALUES ('%s', '%s', 5)", $edit['title'], $edit['description']);
433 434 435 436 437 438
  }
}

/**
 * Generate a form to add/edit feed sources.
 */
439
function aggregator_form_feed(&$form_state, $edit = array('refresh' => 900, 'title' => '', 'url' => '', 'fid' => NULL)) {
440 441 442 443 444 445 446 447 448
  $period = drupal_map_assoc(array(900, 1800, 3600, 7200, 10800, 21600, 32400, 43200, 64800, 86400, 172800, 259200, 604800, 1209600, 2419200), 'format_interval');

  if ($edit['refresh'] == '') {
    $edit['refresh'] = 3600;
  }

  $form['title'] = array('#type' => 'textfield',
    '#title' => t('Title'),
    '#default_value' => $edit['title'],
449
    '#maxlength' => 255,
450
    '#description' => t('The name of the feed; typically the name of the website you syndicate content from.'),
451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471
    '#required' => TRUE,
  );
  $form['url'] = array('#type' => 'textfield',
    '#title' => t('URL'),
    '#default_value' => $edit['url'],
    '#maxlength' => 255,
    '#description' => t('The fully-qualified URL of the feed.'),
    '#required' => TRUE,
  );
  $form['refresh'] = array('#type' => 'select',
    '#title' => t('Update interval'),
    '#default_value' => $edit['refresh'],
    '#options' => $period,
    '#description' => t('The refresh interval indicating how often you want to update this feed. Requires crontab.'),
  );

  // Handling of categories:
  $options = array();
  $values = array();
  $categories = db_query('SELECT c.cid, c.title, f.fid FROM {aggregator_category} c LEFT JOIN {aggregator_category_feed} f ON c.cid = f.cid AND f.fid = %d ORDER BY title', $edit['fid']);
  while ($category = db_fetch_object($categories)) {
472 473
    $options[$category->cid] = check_plain($category->title);
    if ($category->fid) $values[] = $category->cid;
474 475 476 477 478 479 480 481 482
  }
  if ($options) {
    $form['category'] = array('#type' => 'checkboxes',
      '#title' => t('Categorize news items'),
      '#default_value' => $values,
      '#options' => $options,
      '#description' => t('New items in this feed will be automatically filed in the checked categories as they are received.'),
    );
  }
483
  $form['submit'] = array('#type' => 'submit', '#value' => t('Save'));
484 485

  if ($edit['fid']) {
486
    $form['delete'] = array('#type' => 'submit', '#value' => t('Delete'));
487 488 489
    $form['fid'] = array('#type' => 'hidden', '#value' => $edit['fid']);
  }

490
  return $form;
491 492 493 494 495
}

/**
 * Validate aggregator_form_feed form submissions.
 */
496
function aggregator_form_feed_validate($form, &$form_state) {
497
  if ($form_state['values']['op'] == t('Save')) {
498 499 500 501 502
    // Ensure URL is valid.
    if (!valid_url($form_state['values']['url'], TRUE)) {
      form_set_error('url', t('The URL %url is invalid. Please enter a fully-qualified URL, such as http://www.example.com/feed.xml.', array('%url' => $form_state['values']['url'])));
    }
    // Check for duplicate titles.
503 504
    if (isset($form_state['values']['fid'])) {
      $result = db_query("SELECT title, url FROM {aggregator_feed} WHERE (title = '%s' OR url='%s') AND fid != %d", $form_state['values']['title'], $form_state['values']['url'], $form_state['values']['fid']);
505 506
    }
    else {
507
      $result = db_query("SELECT title, url FROM {aggregator_feed} WHERE title = '%s' OR url='%s'", $form_state['values']['title'], $form_state['values']['url']);
508 509
    }
    while ($feed = db_fetch_object($result)) {
510 511
      if (strcasecmp($feed->title, $form_state['values']['title']) == 0) {
        form_set_error('title', t('A feed named %feed already exists. Please enter a unique title.', array('%feed' => $form_state['values']['title'])));
512
      }
513 514
      if (strcasecmp($feed->url, $form_state['values']['url']) == 0) {
        form_set_error('url', t('A feed with this URL %url already exists. Please enter a unique URL.', array('%url' => $form_state['values']['url'])));
515
      }
516 517 518 519 520 521 522 523
    }
  }
}

/**
 * Process aggregator_form_feed form submissions.
 * @todo Add delete confirmation dialog.
 */
524 525 526
function aggregator_form_feed_submit($form, &$form_state) {
  if ($form_state['values']['op'] == t('Delete')) {
    $title = $form_state['values']['title'];
527
    // Unset the title:
528
    unset($form_state['values']['title']);
529
  }
530 531 532 533
  aggregator_save_feed($form_state['values']);
  if (isset($form_state['values']['fid'])) {
    if (isset($form_state['values']['title'])) {
      drupal_set_message(t('The feed %feed has been updated.', array('%feed' => $form_state['values']['title'])));
534
      if (arg(0) == 'admin') {
535 536
        $form_state['redirect'] = 'admin/content/aggregator/';
        return;
537 538
      }
      else {
539
        $form_state['redirect'] = 'aggregator/sources/'. $form_state['values']['fid'];
540
        return;
541 542 543
      }
    }
    else {
544
      watchdog('aggregator', 'Feed %feed deleted.', array('%feed' => $title));
545
      drupal_set_message(t('The feed %feed has been deleted.', array('%feed' => $title)));
546
      if (arg(0) == 'admin') {
547 548
        $form_state['redirect'] = 'admin/content/aggregator/';
        return;
549 550
      }
      else {
551 552
        $form_state['redirect'] = 'aggregator/sources/';
        return;
553 554 555 556
      }
    }
  }
  else {
557 558
    watchdog('aggregator', 'Feed %feed added.', array('%feed' => $form_state['values']['title']), WATCHDOG_NOTICE, l(t('view'), 'admin/content/aggregator'));
    drupal_set_message(t('The feed %feed has been added.', array('%feed' => $form_state['values']['title'])));
559 560 561 562 563 564 565
  }
}

/**
 * Add/edit/delete an aggregator feed.
 */
function aggregator_save_feed($edit) {
566
  if (!empty($edit['fid'])) {
567 568 569
    // An existing feed is being modified, delete the category listings.
    db_query('DELETE FROM {aggregator_category_feed} WHERE fid = %d', $edit['fid']);
  }
570
  if (!empty($edit['fid']) && !empty($edit['title'])) {
571 572
    db_query("UPDATE {aggregator_feed} SET title = '%s', url = '%s', refresh = %d WHERE fid = %d", $edit['title'], $edit['url'], $edit['refresh'], $edit['fid']);
  }
573
  else if (!empty($edit['fid'])) {
574
    $items = array();
575 576 577 578
    $result = db_query('SELECT iid FROM {aggregator_item} WHERE fid = %d', $edit['fid']);
    while ($item = db_fetch_object($result)) {
      $items[] = "iid = $item->iid";
    }
579
    if (!empty($items)) {
580 581 582 583 584
      db_query('DELETE FROM {aggregator_category_item} WHERE '. implode(' OR ', $items));
    }
    db_query('DELETE FROM {aggregator_feed} WHERE fid = %d', $edit['fid']);
    db_query('DELETE FROM {aggregator_item} WHERE fid = %d', $edit['fid']);
  }
585
  else if (!empty($edit['title'])) {
586
    db_query("INSERT INTO {aggregator_feed} (title, url, refresh, block, description) VALUES ('%s', '%s', %d, 5, '')", $edit['title'], $edit['url'], $edit['refresh']);
587
    // A single unique id for bundles and feeds, to use in blocks.
588
    $edit['fid'] = db_last_insert_id('aggregator_feed', 'fid');
589
  }
590
  if (!empty($edit['title'])) {
591
    // The feed is being saved, save the categories as well.
592
    if (!empty($edit['category'])) {
593 594 595 596 597 598 599 600 601
      foreach ($edit['category'] as $cid => $value) {
        if ($value) {
          db_query('INSERT INTO {aggregator_category_feed} (fid, cid) VALUES (%d, %d)', $edit['fid'], $cid);
        }
      }
    }
  }
}

Dries's avatar
 
Dries committed
602 603 604 605
function aggregator_remove($feed) {
  $result = db_query('SELECT iid FROM {aggregator_item} WHERE fid = %d', $feed['fid']);
  while ($item = db_fetch_object($result)) {
    $items[] = "iid = $item->iid";
Dries's avatar
 
Dries committed
606
  }
607
  if (!empty($items)) {
Dries's avatar
 
Dries committed
608
    db_query('DELETE FROM {aggregator_category_item} WHERE '. implode(' OR ', $items));
Dries's avatar
 
Dries committed
609
  }
Dries's avatar
 
Dries committed
610
  db_query('DELETE FROM {aggregator_item} WHERE fid = %d', $feed['fid']);
Kjartan's avatar
Kjartan committed
611
  db_query("UPDATE {aggregator_feed} SET checked = 0, etag = '', modified = 0 WHERE fid = %d", $feed['fid']);
612
  drupal_set_message(t('The news items from %site have been removed.', array('%site' => $feed['title'])));
Dries's avatar
 
Dries committed
613 614
}

Dries's avatar
 
Dries committed
615 616 617
/**
 * Call-back function used by the XML parser.
 */
Kjartan's avatar
Kjartan committed
618
function aggregator_element_start($parser, $name, $attributes) {
619
  global $item, $element, $tag, $items, $channel;
Dries's avatar
 
Dries committed
620 621

  switch ($name) {
Dries's avatar
 
Dries committed
622 623
    case 'IMAGE':
    case 'TEXTINPUT':
624 625 626 627 628 629
    case 'CONTENT':
    case 'SUMMARY':
    case 'TAGLINE':
    case 'SUBTITLE':
    case 'LOGO':
    case 'INFO':
Dries's avatar
 
Dries committed
630 631
      $element = $name;
      break;
632 633 634 635
    case 'ID':
      if ($element != 'ITEM') {
        $element = $name;
      }
636
    case 'LINK':
637
      if (!empty($attributes['REL']) && $attributes['REL'] == 'alternate') {
638 639 640 641 642 643 644 645
        if ($element == 'ITEM') {
          $items[$item]['LINK'] = $attributes['HREF'];
        }
        else {
          $channel['LINK'] = $attributes['HREF'];
        }
      }
      break;
Dries's avatar
 
Dries committed
646
    case 'ITEM':
Dries's avatar
 
Dries committed
647 648
      $element = $name;
      $item += 1;
649 650 651 652 653
      break;
    case 'ENTRY':
      $element = 'ITEM';
      $item += 1;
      break;
654 655 656 657 658
  }

  $tag = $name;
}

Dries's avatar
 
Dries committed
659 660 661
/**
 * Call-back function used by the XML parser.
 */
Kjartan's avatar
Kjartan committed
662
function aggregator_element_end($parser, $name) {
Dries's avatar
 
Dries committed
663 664
  global $element;

Dries's avatar
 
Dries committed
665
  switch ($name) {
Dries's avatar
 
Dries committed
666 667 668
    case 'IMAGE':
    case 'TEXTINPUT':
    case 'ITEM':
669 670 671
    case 'ENTRY':
    case 'CONTENT':
    case 'INFO':
672 673
      $element = '';
      break;
674
    case 'ID':
675
      if ($element == 'ID') {
676 677
        $element = '';
      }
Dries's avatar
 
Dries committed
678
  }
679 680
}

Dries's avatar
 
Dries committed
681 682 683
/**
 * Call-back function used by the XML parser.
 */
Kjartan's avatar
Kjartan committed
684
function aggregator_element_data($parser, $data) {
Dries's avatar
 
Dries committed
685
  global $channel, $element, $items, $item, $image, $tag;
686
  $items += array($item => array());
Dries's avatar
 
Dries committed
687
  switch ($element) {
Dries's avatar
 
Dries committed
688
    case 'ITEM':
689
      $items[$item] += array($tag => '');
Dries's avatar
 
Dries committed
690 691
      $items[$item][$tag] .= $data;
      break;
Dries's avatar
 
Dries committed
692
    case 'IMAGE':
693
    case 'LOGO':
694
      $image += array($tag => '');
Dries's avatar
 
Dries committed
695 696
      $image[$tag] .= $data;
      break;
697 698
    case 'LINK':
      if ($data) {
699
        $items[$item] += array($tag => '');
700 701 702 703
        $items[$item][$tag] .= $data;
      }
      break;
    case 'CONTENT':
704
      $items[$item] += array('CONTENT' => '');
705
      $items[$item]['CONTENT'] .= $data;
706 707
      break;
    case 'SUMMARY':
708
      $items[$item] += array('SUMMARY' => '');
709
      $items[$item]['SUMMARY'] .= $data;
710 711 712
      break;
    case 'TAGLINE':
    case 'SUBTITLE':
713
      $channel += array('DESCRIPTION' => '');
714 715 716 717
      $channel['DESCRIPTION'] .= $data;
      break;
    case 'INFO':
    case 'ID':
Dries's avatar
 
Dries committed
718 719 720
    case 'TEXTINPUT':
      // The sub-element is not supported. However, we must recognize
      // it or its contents will end up in the item array.
Dries's avatar
 
Dries committed
721 722
      break;
    default:
723
      $channel += array($tag => '');
Dries's avatar
 
Dries committed
724
      $channel[$tag] .= $data;
725 726 727
  }
}

Dries's avatar
 
Dries committed
728 729 730
/**
 * Checks a news feed for new items.
 */
Kjartan's avatar
Kjartan committed
731
function aggregator_refresh($feed) {
Dries's avatar
 
Dries committed
732 733
  global $channel, $image;

Dries's avatar
 
Dries committed
734 735 736 737 738 739
  // Generate conditional GET headers.
  $headers = array();
  if ($feed['etag']) {
    $headers['If-None-Match'] = $feed['etag'];
  }
  if ($feed['modified']) {
Dries's avatar
 
Dries committed
740
    $headers['If-Modified-Since'] = gmdate('D, d M Y H:i:s', $feed['modified']) .' GMT';
Dries's avatar
 
Dries committed
741 742 743 744 745
  }

  // Request feed.
  $result = drupal_http_request($feed['url'], $headers);

746
  // Process HTTP response code.
Dries's avatar
 
Dries committed
747 748
  switch ($result->code) {
    case 304:
Dries's avatar
 
Dries committed
749
      db_query('UPDATE {aggregator_feed} SET checked = %d WHERE fid = %d', time(), $feed['fid']);
750
      drupal_set_message(t('There is no new syndicated content from %site.', array('%site' => $feed['title'])));
Dries's avatar
 
Dries committed
751
      break;
Dries's avatar
 
Dries committed
752 753
    case 301:
      $feed['url'] = $result->redirect_url;
754
      watchdog('aggregator', 'Updated URL for feed %title to %url.', array('%title' => $feed['title'], '%url' => $feed['url']));
755

Dries's avatar
 
Dries committed
756 757 758 759
    case 200:
    case 302:
    case 307:
      // Filter the input data:
760
     if (aggregator_parse_feed($result->data, $feed)) {
761

762
        $modified = empty($result->headers['Last-Modified']) ? 0 : strtotime($result->headers['Last-Modified']);
Dries's avatar
Dries committed
763

764 765 766 767 768
        /*
        ** Prepare the channel data:
        */

        foreach ($channel as $key => $value) {
769
          $channel[$key] = trim($value);
770 771
        }

Dries's avatar
 
Dries committed
772 773 774
        /*
        ** Prepare the image data (if any):
        */
Dries's avatar
 
Dries committed
775

Dries's avatar
 
Dries committed
776 777 778
        foreach ($image as $key => $value) {
          $image[$key] = trim($value);
        }
Dries's avatar
 
Dries committed
779

780
        if (!empty($image['LINK']) && !empty($image['URL']) && !empty($image['TITLE'])) {
781 782
          // Note, we should really use theme_image() here but that only works with local images it won't work with images fetched with a URL unless PHP version > 5
          $image = '<a href="'. check_url($image['LINK']) .'" class="feed-image"><img src="'. check_url($image['URL']) .'" alt="'. check_plain($image['TITLE']) .'" /></a>';
Dries's avatar
 
Dries committed
783
        }
Dries's avatar
 
Dries committed
784 785 786
        else {
          $image = NULL;
        }
Dries's avatar
 
Dries committed
787

788
        $etag = empty($result->headers['ETag']) ? '' : $result->headers['ETag'];
Dries's avatar
 
Dries committed
789 790 791
        /*
        ** Update the feed data:
        */
Dries's avatar
 
Dries committed
792

793
        db_query("UPDATE {aggregator_feed} SET url = '%s', checked = %d, link = '%s', description = '%s', image = '%s', etag = '%s', modified = %d WHERE fid = %d", $feed['url'], time(), $channel['LINK'], $channel['DESCRIPTION'], $image, $etag, $modified, $feed['fid']);
Dries's avatar
 
Dries committed
794

Dries's avatar
 
Dries committed
795 796 797
        /*
        ** Clear the cache:
        */
Dries's avatar
 
Dries committed
798

Dries's avatar
 
Dries committed
799
        cache_clear_all();
Dries's avatar
 
Dries committed
800

801
        watchdog('aggregator', 'There is new syndicated content from %site.', array('%site' => $feed['title']));
802
        drupal_set_message(t('There is new syndicated content from %site.', array('%site' => $feed['title'])));
Dries's avatar
 
Dries committed
803 804
      }
      break;
Dries's avatar
 
Dries committed
805
    default:
806
      watchdog('aggregator', 'The feed from %site seems to be broken, due to "%error".', array('%site' => $feed['title'], '%error' => $result->code .' '. $result->error), WATCHDOG_WARNING);
807
      drupal_set_message(t('The feed from %site seems to be broken, because of error "%error".', array('%site' => $feed['title'], '%error' => $result->code .' '. $result->error)));
Dries's avatar
 
Dries committed
808
  }
Dries's avatar
 
Dries committed
809
}
Dries's avatar
 
Dries committed
810

Dries's avatar
 
Dries committed
811 812 813 814
/**
 * Parse 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.
815
 * Originally from MagpieRSS (http://magpierss.sourceforge.net/).
Dries's avatar
 
Dries committed
816 817
 *
 * @param $date_str A string with a potentially W3C DTF date.
818
 * @return A timestamp if parsed successfully or -1 if not.
Dries's avatar
 
Dries committed
819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843
 */
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]);
    // calc 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?  then subtract offset
      if ($tz_mod == '+') {
        $offset_secs *= -1;
      }
      $epoch += $offset_secs;
    }
    return $epoch;
  }
  else {
844
    return FALSE;
Dries's avatar
 
Dries committed
845 846 847
  }
}

Dries's avatar
 
Dries committed
848
function aggregator_parse_feed(&$data, $feed) {
Dries's avatar
 
Dries committed
849
  global $items, $image, $channel;
Dries's avatar
 
Dries committed
850

Dries's avatar
 
Dries committed
851
  // Unset the global variables before we use them:
Dries's avatar
 
Dries committed
852
  unset($GLOBALS['element'], $GLOBALS['item'], $GLOBALS['tag']);
Dries's avatar
 
Dries committed
853
  $items = array();
Dries's avatar
 
Dries committed
854
  $image = array();
Dries's avatar
 
Dries committed
855
  $channel = array();
856

Dries's avatar
 
Dries committed
857 858
  // parse the data:
  $xml_parser = drupal_xml_parser_create($data);
Dries's avatar