? .svn ? feedapi_1_0_x_dedupe.patch ? feedapi_x_dedupe.patch ? feedapi_aggregator/.svn ? feedapi_aggregator/po/.svn ? feedapi_inherit/.svn ? feedapi_inherit/po/.svn ? feedapi_node/.svn ? feedapi_node/po/.svn ? feedapi_node_views/.svn ? feedapi_node_views/po/.svn ? parser_common_syndication/.svn ? parser_common_syndication/po/.svn ? parser_simplepie/.svn ? parser_simplepie/simplepie.inc ? parser_simplepie/po/.svn ? po/.svn Index: feedapi_inherit/feedapi_inherit.module =================================================================== RCS file: /cvs/drupal-contrib/contributions/modules/feedapi/feedapi_inherit/Attic/feedapi_inherit.module,v retrieving revision 1.1.2.13 diff -u -r1.1.2.13 feedapi_inherit.module --- feedapi_inherit/feedapi_inherit.module 21 Dec 2007 10:54:15 -0000 1.1.2.13 +++ feedapi_inherit/feedapi_inherit.module 26 Jan 2008 20:28:11 -0000 @@ -30,10 +30,12 @@ function feedapi_inherit_nodeapi(&$node, $op) { switch ($op) { case 'prepare': - if ($node->feedapi->feed_nid) { - $feed_node = node_load($node->feedapi->feed_nid); - if (feedapi_enabled($feed_node->type, 'feedapi_inherit')) { - _feedapi_inherit_do_inherit($node, $feed_node); + if ($node->feedapi_node->feed_nids) { + foreach ($node->feedapi_node->feed_nids as $feed_nid) { + $feed_node = node_load($feed_nid); + if (feedapi_enabled($feed_node->type, 'feedapi_inherit')) { + _feedapi_inherit_do_inherit($node, $feed_node); + } } } break; Index: feedapi_node/feedapi_node.install =================================================================== RCS file: /cvs/drupal-contrib/contributions/modules/feedapi/feedapi_node/Attic/feedapi_node.install,v retrieving revision 1.1.2.7 diff -u -r1.1.2.7 feedapi_node.install --- feedapi_node/feedapi_node.install 14 Dec 2007 21:16:21 -0000 1.1.2.7 +++ feedapi_node/feedapi_node.install 26 Jan 2008 20:28:11 -0000 @@ -21,6 +21,13 @@ KEY url (url(255)), KEY guid (guid(255)) ) DEFAULT CHARSET=latin1;"); + db_query("CREATE TABLE {feedapi_node_item_feed} ( + `feed_nid` int(10) unsigned NOT NULL default '0', + `feed_item_nid` int(10) unsigned NOT NULL default '0', + PRIMARY KEY (`feed_nid`,`feed_item_nid`), + KEY `feed_nid` (`feed_nid`), + KEY `feed_item_nid` (`feed_item_nid`) + ) ENGINE=MyISAM DEFAULT CHARSET=latin1;"); break; case 'pgsql': db_query("CREATE TABLE {feedapi_node_item} ( @@ -36,6 +43,13 @@ db_query("CREATE INDEX feed_nid_index on {feedapi_node_item}(feed_nid)"); db_query("CREATE INDEX url_index on {feedapi_node_item}(url)"); db_query("CREATE INDEX guid_index on {feedapi_node_item}(guid)"); + db_query("CREATE TABLE {feedapi_node_item_feed} ( + `feed_nid` int(10) unsigned NOT NULL default '0', + `feed_item_nid` int(10) unsigned NOT NULL default '0', + PRIMARY KEY (`feed_nid`,`feed_item_nid`) + ) ENGINE=MyISAM DEFAULT CHARSET=latin1;"); + db_query("CREATE INDEX feed_nid_index on {feedapi_node_item_feed}(feed_nid)"); + db_query("CREATE INDEX feed_item_nid_index on {feedapi_node_item_feed}(feed_item_nid)"); break; } // Creating the content-types for the FeedAPI @@ -51,7 +65,7 @@ $info->custom = TRUE; node_type_save($info); // Adding default FeedAPI settings - $preset = unserialize('a:3:{s:7:"enabled";s:1:"1";s:12:"items_delete";s:1:"0";s:10:"processors";a:1:{s:12:"feedapi_node";a:7:{s:7:"enabled";s:1:"1";s:6:"weight";s:1:"0";s:12:"content_type";s:5:"story";s:9:"node_date";s:4:"feed";s:7:"promote";s:1:"3";s:9:"list_feed";s:1:"3";s:4:"user";s:5:"admin";}}}'); + $preset = unserialize('a:3:{s:7:"enabled";s:1:"1";s:12:"items_delete";s:1:"0";s:10:"processors";a:1:{s:12:"feedapi_node";a:7:{s:7:"enabled";s:1:"1";s:6:"weight";s:1:"0";s:12:"content_type";s:5:"story";s:9:"node_date";s:4:"feed";s:7:"promote";s:1:"0";s:9:"list_feed";s:1:"3";s:4:"user";s:5:"admin";}}}'); if (is_array(variable_get('feedapi_settings_feedapi_node', FALSE))) { $preset = array_merge($preset, variable_get('feedapi_settings_feedapi_node', FALSE)); } @@ -107,15 +121,40 @@ return $ret; } +/** + * Create and populate feed_nid / feed_item_nid table. + * + */ +function feedapi_node_update_3() { + switch ($GLOBALS['db_type']) { + case 'mysqli': + case 'mysql': + $ret[] = update_sql("CREATE TABLE IF NOT EXISTS {feedapi_node_item_feed} ( + `feed_nid` int(10) unsigned NOT NULL default '0', + `feed_item_nid` int(10) unsigned NOT NULL default '0', + PRIMARY KEY (`feed_nid`,`feed_item_nid`), + KEY `feed_nid` (`feed_nid`), + KEY `feed_item_nid` (`feed_item_nid`) + ) ENGINE=MyISAM DEFAULT CHARSET=latin1;"); + $ret[] = update_sql("REPLACE INTO {feedapi_node_item_feed} (feed_nid, feed_item_nid) SELECT feed_nid, nid FROM feedapi_node_item"); + $ret[] = update_sql("ALTER TABLE {feedapi_node_item} DROP `feed_nid`"); + break; + case 'pgsql': + break; + } + return $ret; +} + function feedapi_node_uninstall() { switch ($GLOBALS['db_type']) { case 'mysqli': case 'mysql': case 'pgsql': db_query("DROP TABLE {feedapi_node_item}"); + db_query("DROP TABLE {feedapi_node_item_feed}"); break; } node_type_delete('feedapi_node'); variable_del('feedapi_settings_feedapi_node'); menu_rebuild(); -} \ No newline at end of file +} Index: feedapi_node/feedapi_node.module =================================================================== RCS file: /cvs/drupal-contrib/contributions/modules/feedapi/feedapi_node/Attic/feedapi_node.module,v retrieving revision 1.1.2.15 diff -u -r1.1.2.15 feedapi_node.module --- feedapi_node/feedapi_node.module 24 Jan 2008 19:56:01 -0000 1.1.2.15 +++ feedapi_node/feedapi_node.module 26 Jan 2008 20:28:11 -0000 @@ -24,10 +24,44 @@ */ function feedapi_node_nodeapi(&$node, $op, $teaser) { switch ($op) { + case 'load': + $result = db_query('SELECT fi.*, ff.feed_nid FROM {feedapi_node_item} fi JOIN {feedapi_node_item_feed} ff ON fi.nid = ff.feed_item_nid WHERE fi.nid = %d', $node->nid); + while ($f = db_fetch_object($result)) { + $node->feedapi_node = $f; + $feed_nids[$f->feed_nid] = $f->feed_nid; + } + if ($node->feedapi_node) { + $node->feedapi_node->feed_nids = $feed_nids; + unset($node->feedapi_node->feed_nid); + } + break; + case 'insert': + if ($node->feedapi_node->feed_item) { + $node->feedapi_node->feed_item->nid = $node->nid; + foreach ($node->feedapi_node->feed_nids as $feed_nid) { + db_query("INSERT INTO {feedapi_node_item_feed} (feed_nid, feed_item_nid) VALUES (%d, %d)", $feed_nid, $node->nid); + } + $feed_item = $node->feedapi_node->feed_item; + $feed_item->fiid = db_next_id('{feedapi_node_item}_fiid'); + db_query("INSERT INTO {feedapi_node_item} (fiid, nid, url, timestamp, arrived, guid) VALUES (%d, %d, '%s', %d, %d, '%s')", $feed_item->fiid, $node->nid, $feed_item->options->original_url, $feed_item->options->timestamp, time(), $feed_item->options->guid); + } + break; + case 'update': + if ($node->feedapi_node) { + if ($node->feedapi_node->feed_item) { + $feed_item = $node->feedapi_node->feed_item; + db_query("UPDATE {feedapi_node_item} SET url = '%s', timestamp = %d, guid = '%s' WHERE fiid = %d", $feed_item->options->original_url, $feed_item->options->timestamp, $feed_item->options->guid, $feed_item->fiid); + } + db_query('DELETE FROM {feedapi_node_item_feed} WHERE feed_item_nid = %d', $node->nid); + foreach ($node->feedapi_node->feed_nids as $feed_nid) { + db_query("INSERT INTO {feedapi_node_item_feed} (feed_nid, feed_item_nid) VALUES (%d, %d)", $feed_nid, $node->nid); + } + } + break; case 'delete': - $result = db_fetch_object(db_query("SELECT fiid FROM {feedapi_node_item} WHERE nid = %d", $node->nid)); - if (is_object($result)) { - _feedapi_node_delete($result); + if ($node->feedapi_node) { + db_query('DELETE FROM {feedapi_node_item} WHERE nid = %d', $node->nid); + db_query('DELETE FROM {feedapi_node_item_feed} WHERE feed_item_nid = %d', $node->nid); } break; } @@ -38,22 +72,21 @@ */ function feedapi_node_link($type, $node = NULL) { if ($type == 'node') { - $result = db_fetch_array(db_query("SELECT feed_nid, fiid, url FROM {feedapi_node_item} WHERE nid = %d", $node->nid)); - if (is_numeric($result['feed_nid'])) { - $feed_item->fiid = $result['fiid']; - $item = _feedapi_node_load($feed_item); - $node = node_load($result['feed_nid']); - if ((isset($node->feed->url) || isset($node->feed->guid)) && isset($node->title)) { - $links['feedapi_article'] = array( - 'title' => t('Feed:') .' '. $node->title, - 'href' => 'node/'. $node->nid, + if ($node->feedapi_node) { + $result = db_query("SELECT n.title, n.nid FROM {node} n WHERE n.nid IN (%s) ORDER BY title DESC", implode(', ', $node->feedapi_node->feed_nids)); + while ($feed = db_fetch_object($result)) { + $links['feedapi_feed_'. $feed->nid] = array( + 'title' => t('Feed:') .' '. $feed->title, + 'href' => 'node/'. $feed->nid, ); + } + if ($node->feedapi_node->url) { $links['feedapi_original'] = array( 'title' => t('Original article'), - 'href' => $item->options->original_url, + 'href' => $node->feedapi_node->url, ); - return $links; } + return $links; } } } @@ -93,6 +126,13 @@ '#description' => t('The newest N items per feed will be promoted to front page.'), '#default_value' => 3, ); + $form['x_dedupe'] = array( + '#type' => 'radios', + '#title' => t('Duplicates'), + '#description' => t('If you choose "check for duplicates on all feeds", a feed item will not be created if it already exists on *ANY* feed. Instead, the existing feed item will be linked to the feed. If you are not sure, choose the first option.'), + '#options' => array(0 => t('Check for duplicates only within feed'), 1 => t('Check for duplicates on all feeds')), + '#default_value' => 0, + ); break; } return $form; @@ -118,23 +158,24 @@ * Handle the promote N items to the frontpage setting */ function feedapi_node_feedapi_after_refresh($feed) { - $promote = is_numeric($feed->settings['processors']['feedapi_node']['promote']) ? $feed->settings['processors']['feedapi_node']['promote'] : 15; - $result = db_query("SELECT n.nid FROM {node} n JOIN {feedapi_node_item} fi ON fi.nid = n.nid WHERE fi.feed_nid = %d ORDER BY fi.timestamp DESC", $feed->nid); - $to_promote = array(); - $to_demote = array(); - while ($item = db_fetch_object($result)) { - if ($promote-- > 0) { - $to_promote[] = $item->nid; + if ($promote = $feed->settings['processors']['feedapi_node']['promote']) { + $result = db_query("SELECT n.nid FROM {node} n JOIN {feedapi_node_item_feed} ff ON ff.feed_item_nid = n.nid WHERE ff.feed_nid = %d ORDER BY n.created DESC", $feed->nid); + $to_promote = array(); + $to_demote = array(); + while ($item = db_fetch_object($result)) { + if ($promote-- > 0) { + $to_promote[] = $item->nid; + } + else { + $to_demote[] = $item->nid; + } } - else { - $to_demote[] = $item->nid; + if (count($to_promote) > 0) { + db_query("UPDATE {node} SET promote = 1 WHERE nid IN (%s)", implode(',', $to_promote)); + } + if (count($to_demote) > 0) { + db_query("UPDATE {node} SET promote = 0 WHERE nid IN (%s)", implode(',', $to_demote)); } - } - if (count($to_promote) > 0) { - db_query("UPDATE {node} SET promote = 1 WHERE nid IN (%s)", implode(',', $to_promote)); - } - if (count($to_demote) > 0) { - db_query("UPDATE {node} SET promote = 0 WHERE nid IN (%s)", implode(',', $to_demote)); } } @@ -180,6 +221,9 @@ } // Construct the node object $node = new stdClass(); + if (isset($feed_item->nid)) { + $node->nid = $feed_item->nid; + } $node->type = !empty($settings['content_type']) ? $settings['content_type'] : variable_get('feedapi_node_type', 'story'); // Get the default options from the cont $options = variable_get('node_options_'. $node->type, FALSE); @@ -200,20 +244,28 @@ $node->teaser = $feed_item->options->teaser; } // Stick feed item on node so that add on modules can act on it. - // Todo: find common format for this. - $node->feedapi->feed_item = $feed_item; + // A feed item can come in from more than one feed. + $node->feedapi_node->feed_nids[$feed_nid] = $feed_nid; + $node->feedapi_node->feed_item = $feed_item; + // For backwards compatibility - todo: move to using feedapi_node->feed_nids and feedapi_node->feed_item. $node->feedapi->feed_nid = $feed_nid; + $node->feedapi->feed_item = $feed_item; node_object_prepare($node); - if (!isset($feed_item->nid)) { - node_save($node); - $feed_item->nid = $node->nid; - $feed_item->fiid = db_next_id('{feedapi_node_item}_fiid'); - db_query("INSERT INTO {feedapi_node_item} (fiid, feed_nid, nid, url, timestamp, arrived, guid) VALUES (%d, %d, %d, '%s', %d, %d, '%s')", $feed_item->fiid, $feed_nid, $feed_item->nid, $feed_item->options->original_url, $feed_item->options->timestamp, time(), $feed_item->options->guid); + + // If there are dupes on other feeds, don't create new feed item, but link this feed + // to existing feed item. + // Heads up: if there is a duplicate on the SAME feed, + // _feedapi_node_save() won't even be called. + if (isset($feed_item->feedapi_node->duplicates)) { + foreach ($feed_item->feedapi_node->duplicates as $fi_nid => $f_nids) { + $feed_item_node = node_load($fi_nid); + $feed_item_node->feedapi_node->feed_nids[$feed_nid] = $feed_nid; + node_object_prepare($feed_item_node); + node_save($feed_item_node); + } } else { - $node->nid = $feed_item->nid; node_save($node); - db_query("UPDATE {feedapi_node_item} SET url = '%s', timestamp = %d, guid = '%s' WHERE fiid = %d", $feed_item->options->original_url, $feed_item->options->timestamp, $feed_item->options->guid, $feed_item->fiid); } return $feed_item; } @@ -238,28 +290,13 @@ * Delete a node which already assigned to a feed item */ function _feedapi_node_delete($feed_item) { - if (isset($feed_item->nid)) { - // It's the copy of node_delete(). it's needed because node_delete checks the permission - // and cron can be run anonymously and then the node is not deleted. - $node = node_load($feed_item->nid); - db_query('DELETE FROM {node} WHERE nid = %d', $node->nid); - db_query('DELETE FROM {node_revisions} WHERE nid = %d', $node->nid); - - // Call the node-specific callback (if any): - node_invoke($node, 'delete'); - node_invoke_nodeapi($node, 'delete'); - - // Clear the cache so an anonymous poster can see the node being deleted. - cache_clear_all(); - - // Remove this node from the search index if needed. - if (function_exists('search_wipe')) { - search_wipe($node->nid, 'node'); - } - drupal_set_message(t('%title has been deleted.', array('%title' => $node->title))); - watchdog('content', t('@type: deleted %title.', array('@type' => t($node->type), '%title' => $node->title))); + if (isset($feed_item->nid)) { + node_delete($feed_item->nid); + } + else { + // Let's throw an error on the off chance we land here. + watchdog('feedapi_node', t('No nid on feed item to delete.')); } - db_query("DELETE FROM {feedapi_node_item} WHERE fiid = %d", $feed_item->fiid); } /** @@ -289,7 +326,7 @@ * The array of feed elements with basic information */ function _feedapi_node_fetch($feed) { - $result = db_query("SELECT nid, fiid, feed_nid, arrived, timestamp FROM {feedapi_node_item} WHERE feed_nid = %d ORDER BY timestamp DESC", $feed->nid); + $result = db_query("SELECT fni.nid, fni.fiid, ff.feed_nid, fni.arrived, fni.timestamp FROM {feedapi_node_item} fni JOIN {feedapi_node_item_feed} ff ON ff.feed_item_nid = fni.nid WHERE ff.feed_nid = %d ORDER BY fni.timestamp DESC", $feed->nid); $items = array(); while ($item = db_fetch_object($result)) { $node = node_load($item->nid); @@ -309,20 +346,42 @@ * @return * TRUE if the item is new, FALSE if the item is a duplicated one */ -function _feedapi_node_unique($feed_item, $feed_nid) { +function _feedapi_node_unique($feed_item, $feed_nid, $settings) { // Feed item is duplicate, if URL or GUID are duplicate or if they are both missing. if (isset($feed_item->options->original_url)) { - $count = db_result(db_query("SELECT fiid FROM {feedapi_node_item} WHERE url = '%s' AND feed_nid = %d", $feed_item->options->original_url, $feed_nid)); + $count = db_result(db_query("SELECT fni.nid FROM {feedapi_node_item} fni JOIN {feedapi_node_item_feed} ff ON ff.feed_item_nid = fni.nid WHERE fni.url = '%s' AND ff.feed_nid = %d", $feed_item->options->original_url, $feed_nid)); if ($count) { return FALSE; } } if (isset($feed_item->options->guid)) { - $count = db_result(db_query("SELECT fiid FROM {feedapi_node_item} WHERE guid = '%s' AND feed_nid = %d", $feed_item->options->guid, $feed_nid)); + $count = db_result(db_query("SELECT fni.nid FROM {feedapi_node_item} fni JOIN {feedapi_node_item_feed} ff ON ff.feed_item_nid = fni.nid WHERE fni.guid = '%s' AND ff.feed_nid = %d", $feed_item->options->guid, $feed_nid)); if ($count) { return FALSE; } } + // If cross feed de-dupeing is enabled, check now whether there is a duplicate item on other feeds. + // If so, store duplicates in array. + // There is *usually* only one. However, there might be more than one. + // Todo: don't link to feed items whose feed is not x_dedupe enabled. + if ($settings['x_dedupe']) { + + if (isset($feed_item->options->original_url)) { + $result = db_query("SELECT fni.nid, ff.feed_nid FROM {feedapi_node_item} fni JOIN {feedapi_node_item_feed} ff ON ff.feed_item_nid = fni.nid WHERE ff.feed_nid != %d AND fni.url = '%s'", $feed_nid, $feed_item->options->original_url); + $i = 0; + while ($existing_feed_item = db_fetch_object($result)) { + $feed_item->feedapi_node->duplicates[$existing_feed_item->nid][] = $existing_feed_item->feed_nid; + } + } + if (!isset($feed_item->feedapi_node->duplicates) && isset($feed_item->options->guid)) { + $result = db_query("SELECT fni.nid, ff.feed_nid FROM {feedapi_node_item} fni JOIN {feedapi_node_item_feed} ff ON ff.feed_item_nid = fni.nid WHERE ff.feed_nid != %d AND fni.guid = '%s'", $feed_nid, $feed_item->options->guid); + $i = 0; + while ($existing_feed_item = db_fetch_object($result)) { + $feed_item->feedapi_node->duplicates[$existing_feed_item->nid][] = $existing_feed_item->feed_nid; + } + } + } + if (isset($feed_item->options->original_url) || isset($feed_item->options->guid)) { return TRUE; } Index: feedapi_node/feedapi_node.module.test =================================================================== RCS file: /cvs/drupal-contrib/contributions/modules/feedapi/feedapi_node/Attic/feedapi_node.module.test,v retrieving revision 1.1.2.4 diff -u -r1.1.2.4 feedapi_node.module.test --- feedapi_node/feedapi_node.module.test 18 Jan 2008 22:37:10 -0000 1.1.2.4 +++ feedapi_node/feedapi_node.module.test 26 Jan 2008 20:28:11 -0000 @@ -59,7 +59,7 @@ // Check the feed items - $result = db_query("SELECT nid FROM {feedapi_node_item} WHERE feed_nid = %d", $nid); + $result = db_query("SELECT fi.nid FROM {feedapi_node_item} fi JOIN {feedapi_node_item_feed} ff ON ff.feed_item_nid = fi.nid WHERE ff.feed_nid = %d", $nid); $types = array(); $author_check = TRUE; $item_nids = array(); @@ -88,7 +88,7 @@ node_delete($nid); // Check if the news items are deleted as well - $item_remain = db_result(db_query("SELECT COUNT(*) FROM {feedapi_node_item} WHERE feed_nid = %d", $nid)); + $item_remain = db_result(db_query("SELECT COUNT(*) FROM {feedapi_node_item} fi JOIN {feedapi_node_item_feed} ff ON ff.feed_item_nid = fi.nid WHERE ff.feed_nid = %d", $nid)); $this->assertEqual($item_remain, 0, 'All news item database entries are deleted because of feed deletion.'); // Check if the nodes belong to the news items are really deleted Index: feedapi_node_views/feedapi_node_views.module =================================================================== RCS file: /cvs/drupal-contrib/contributions/modules/feedapi/feedapi_node_views/Attic/feedapi_node_views.module,v retrieving revision 1.1.2.2 diff -u -r1.1.2.2 feedapi_node_views.module --- feedapi_node_views/feedapi_node_views.module 5 Jan 2008 13:00:21 -0000 1.1.2.2 +++ feedapi_node_views/feedapi_node_views.module 26 Jan 2008 20:28:11 -0000 @@ -21,11 +21,6 @@ 'field' => 'nid' ) ), - 'fields' => array( - 'feed_nid' => array( - 'name' => t('Parent feed'), - ), - ), 'sorts' => array( 'timestamp' => array( 'name' => t('FeedAPI Item: Time of the news item'), @@ -33,6 +28,23 @@ 'option' => views_handler_sort_date_options(), 'help' => t('Sort by the arrival date for a feed item.') ) + ) + ); + $tables['feedapi_node_item_feed'] = array( + 'name' => 'feedapi_node_item_feed', + 'join' => array( + 'left' => array( + 'table' => 'node', + 'field' => 'nid', + ), + 'right' => array( + 'field' => 'feed_nid' + ) + ), + 'fields' => array( + 'feed_nid' => array( + 'name' => t('Parent feed'), + ) ), 'filters' => array( 'feed_nid' => array( @@ -40,10 +52,9 @@ 'option' => 'integer', 'operator' => views_handler_operator_gtlt(), 'help' => t('This allows you to filter feed items based on parent feed. You should supply the feed\'s nid.'), - ), + ) ) ); - return $tables; } @@ -80,12 +91,11 @@ $join = array(); $join['left']['table'] = 'node'; $join['left']['field'] = 'nid'; - $join['right']['table'] = 'feedapi_node_item'; - $join['right']['field'] = 'nid'; - $query->add_table('feedapi_node_item', TRUE, 1, $join); - $query->add_field('nid', 'feedapi_node_item'); - $query->add_orderby('feedapi_node_item', 'timestamp', 'DESC'); - $query->add_where('feedapi_node_item.feed_nid = %d', $a2); + $join['right']['table'] = 'feedapi_node_item_feed'; + $join['right']['field'] = 'feed_item_nid'; + $query->add_table('feedapi_node_item_feed', TRUE, 1, $join); + $query->add_field('feed_item_nid', 'feedapi_node_item_feed'); + $query->add_where('feedapi_node_item_feed.feed_nid = %d', $a2); break; case 'link': $query->num_nodes .= format_plural($query->num_nodes, ' item', ' items');