From: Chuck Hagenbuch Date: Sat, 27 Dec 2008 03:58:24 +0000 (-0500) Subject: tagger is autoloadable now, as are other content libs X-Git-Url: https://git.internetallee.de/?a=commitdiff_plain;h=c6010fd56bcf4d218e8cd3e669d919200384057b;p=horde.git tagger is autoloadable now, as are other content libs --- diff --git a/content/app/controllers/TagController.php b/content/app/controllers/TagController.php index 7337862b9..9c68c8ee3 100644 --- a/content/app/controllers/TagController.php +++ b/content/app/controllers/TagController.php @@ -5,7 +5,6 @@ // @TODO Clean up require_once dirname(__FILE__) . '/ApplicationController.php'; -require_once dirname(__FILE__) . '/../../lib/Tags/Tagger.php'; /** * @package Content diff --git a/content/lib/Tagger.php b/content/lib/Tagger.php new file mode 100644 index 000000000..c472c31a0 --- /dev/null +++ b/content/lib/Tagger.php @@ -0,0 +1,683 @@ + + * @license http://opensource.org/licenses/bsd-license.php BSD + * @category Horde + * @package Horde_Content + */ + +/** + * @author Chuck Hagenbuch + * @license http://opensource.org/licenses/bsd-license.php BSD + * @category Horde + * @package Horde_Content + * + * References: + * http://forge.mysql.com/wiki/TagSchema + * http://www.slideshare.net/edbond/tagging-and-folksonomy-schema-design-for-scalability-and-performance + * http://blog.thinkphp.de/archives/124-An-alternative-Approach-to-Tagging.html + * http://code.google.com/p/freetag/ + * + * @TODO: + * need to add type_id to the rampage_tagged table for performance? + * need stat tables by type_id? + * + * Potential features: + * Infer data from combined tags (capital + washington d.c. - http://www.slideshare.net/kakul/tagging-web-2-expo-2008/) + * Normalize tag text (http://tagsonomy.com/index.php/interview-with-gordon-luk-freetag/) + */ +class Content_Tagger +{ + /** + * Database connection + * @var Horde_Db + */ + protected $_db; + + /** + * Tables + * @var array + */ + protected $_tables = array( + 'tags' => 'rampage_tags', + 'tagged' => 'rampage_tagged', + 'objects' => 'rampage_objects', + 'tag_stats' => 'rampage_tag_stats', + 'user_tag_stats' => 'rampage_user_tag_stats', + 'users' => 'rampage_users', + ); + + /** + * User manager object + * @var Content_Users_Manager + */ + protected $_userManager; + + /** + * Type management object + * @var Content_Types_Manager + */ + protected $_typeManager; + + /** + * Object manager + * @var Content_Objects_Manager + */ + protected $_objectManager; + + /** + * Default radius for relationship queries. + * @var integer + */ + protected $_defaultRadius = 10; + + /** + * Constructor - can take an array of arguments that set the managers + * and DbAdapter + */ + public function __construct($context = array()) + { + if (!empty($context['userManager'])) { + $this->_userManager = $context['userManager']; + } + + if (!empty($context['typeManager'])) { + $this->_typeManager = $context['typeManager']; + } + + if (!empty($context['objectManager'])) { + $this->_objectManager = $context['objectManager']; + } + } + + /** + * Set the database connection for the tagger. + * + * @param Horde_Db $db + */ + public function setDbAdapter($db) + { + $this->_db = $db; + } + + /** + * Change the name of a database table. + * + * @param string $tableType + * @param string $tableName + */ + public function setTableName($tableType, $tableName) + { + $this->_tables[$tableType] = $tableName; + } + + /** + * Adds a tag or several tags to an object_id. This method does not + * remove other tags. + * + * @param mixed $userId The user tagging the object. + * @param mixed $objectId The object id to tag or an array containing + * the object_name and type. + * @param array $tags An array of tag name or ids. + * @param Horde_Date $created The datetime of the tagging operation. + * + * @return void + */ + public function tag($userId, $objectId, $tags, $created = null) + { + if (is_null($created)) { + $created = date('Y-m-d\TH:i:s'); + } else { + $created = $created->format('Y-m-d\TH:i:s'); + } + + // Make sure the object exists + $objectId = $this->_ensureObject($objectId); + + // Validate/ensure the parameters + $userId = array_pop($this->_userManager->ensureUsers($userId)); + + foreach ($this->ensureTags($tags) as $tagId) { + try { + $this->_db->insert('INSERT INTO ' . $this->_t('tagged') . ' (user_id, object_id, tag_id, created) + VALUES (' . (int)$userId . ',' . (int)$objectId . ',' . (int)$tagId . ',' . $this->_db->quote($created) . ')'); + } catch (Horde_Db_Exception $e) { + // @TODO should make sure it's a duplicate and re-throw if not + continue; + } + + // increment tag stats + if (!$this->_db->update('UPDATE ' . $this->_t('tag_stats') . ' SET count = count + 1 WHERE tag_id = ' . (int)$tagId)) { + $this->_db->insert('INSERT INTO ' . $this->_t('tag_stats') . ' (tag_id, count) VALUES (' . (int)$tagId . ', 1)'); + } + + // increment user-tag stats + if (!$this->_db->update('UPDATE ' . $this->_t('user_tag_stats') . ' SET count = count + 1 WHERE user_id = ' . (int)$userId . ' AND tag_id = ' . (int)$tagId)) { + $this->_db->insert('INSERT INTO ' . $this->_t('user_tag_stats') . ' (user_id, tag_id, count) VALUES (' . (int)$userId . ', ' . (int)$tagId . ', 1)'); + } + } + } + + /** + * Undo a user's tagging of an object. + * + * @param mixed $userId The user who tagged the object. + * @param mixed $objectId The object to remove the tag from. + * @param array $tags An array of tag name or ids to remove. + */ + public function untag($userId, $objectId, $tags) + { + // Ensure parameters + $userId = array_pop($this->_userManager->ensureUsers($userId)); + $objectId = $this->_ensureObject($objectId); + + foreach ($this->ensureTags($tags) as $tagId) { + if ($this->_db->delete('DELETE FROM ' . $this->_t('tagged') . ' WHERE user_id = ? AND object_id = ? AND tag_id = ?', array($userId, $objectId, $tagId))) { + $this->_db->update('UPDATE ' . $this->_t('tag_stats') . ' SET count = count - 1 WHERE tag_id = ?', array($tagId)); + $this->_db->update('UPDATE ' . $this->_t('user_tag_stats') . ' SET count = count - 1 WHERE user_id = ? AND tag_id = ?', array($userId, $tagId)); + } + } + + // Cleanup + $this->_db->delete('DELETE FROM ' . $this->_t('tag_stats') . ' WHERE count = 0'); + $this->_db->delete('DELETE FROM ' . $this->_t('user_tag_stats') . ' WHERE count = 0'); + } + + /** + * Retrieve tags based on criteria. + * + * @param array $args Search criteria: + * q Starts-with search on tag_name. + * limit Maximum number of tags to return. + * offset Offset the results. Only useful for paginating, and not recommended. + * userId Only return tags that have been applied by a specific user. + * typeId Only return tags that have been applied by a specific object type. + * objectId Only return tags that have been applied to a specific object. + * + * @return array An array of tags, id => name. + */ + public function getTags($args) + { + if (isset($args['objectId'])) { + // Don't create the object just because we're trying to load an + // objects's tags - just check if the object is there. Assume if we + // have an integer, it's a valid object_id. + if (is_array($args['objectId'])) { + $args['objectId'] = $this->_objectManager->exists($args['objectId']['object'], $args['objectId']['type']); + } + if (!$args['objectId']) { + return array(); + } + + $sql = 'SELECT DISTINCT t.tag_id AS tag_id, tag_name FROM ' . $this->_t('tags') . ' t INNER JOIN ' . $this->_t('tagged') . ' tagged ON t.tag_id = tagged.tag_id AND tagged.object_id = ' . (int)$args['objectId']; + } elseif (isset($args['userId']) && isset($args['typeId'])) { + $args['userId'] = array_pop($this->_userManager->ensureUsers($args['userId'])); + $args['typeId'] = array_pop($this->_typeManager->ensureTypes($arge['typeId'])); + $sql = 'SELECT DISTINCT t.tag_id AS tag_id, tag_name FROM ' . $this->_t('tags') . ' t INNER JOIN ' . $this->_t('tagged') . ' tagged ON t.tag_id = tagged.tag_id AND tagged.user_id = ' . (int)$args['userId'] . ' INNER JOIN ' . $this->_t('objects') . ' objects ON tagged.object_id = objects.object_id AND objects.type_id = ' . (int)$args['typeId']; + } elseif (isset($args['userId'])) { + $args['userId'] = array_pop($this->_userManager->ensureUsers($args['userId'])); + $sql = 'SELECT DISTINCT t.tag_id AS tag_id, tag_name FROM ' . $this->_t('tagged') . ' tagged INNER JOIN ' . $this->_t('tags') . ' t ON tagged.tag_id = t.tag_id WHERE tagged.user_id = ' . (int)$args['userId']; + } elseif (isset($args['typeId'])) { + $args['typeId'] = array_pop($this->_typeManager->ensureTypes($arge['typeId'])); + $sql = 'SELECT DISTINCT t.tag_id AS tag_id, tag_name FROM ' . $this->_t('tagged') . ' tagged INNER JOIN ' . $this->_t('objects') . ' objects ON tagged.object_id = objects.object_id AND objects.type_id = ' . (int)$args['typeId'] . ' INNER JOIN ' . $this->_t('tags') . ' t ON tagged.tag_id = t.tag_id'; + } elseif (isset($args['tagId'])) { + $radius = isset($args['limit']) ? (int)$args['limit'] : $this->_defaultRadius; + unset($args['limit']); + + $inner = $this->_db->addLimitOffset('SELECT object_id FROM ' . $this->_t('tagged') . ' WHERE tag_id = ' . (int)$args['tagId'], array('limit' => $radius)); + $sql = $this->_db->addLimitOffset('SELECT DISTINCT tagged2.tag_id AS tag_id, tag_name FROM (' . $inner . ') AS tagged1 INNER JOIN ' . $this->_t('tagged') . ' tagged2 ON tagged1.object_id = tagged2.object_id INNER JOIN ' . $this->_t('tags') . ' t ON tagged2.tag_id = t.tag_id', array('limit' => $args['limit'])); + } else { + $sql = 'SELECT DISTINCT t.tag_id, tag_name FROM ' . $this->_t('tags') . ' t JOIN ' . $this->_t('tagged') . ' tagged ON t.tag_id = tagged.tag_id'; + } + + if (isset($args['q']) && strlen($args['q'])) { + // @TODO tossing a where clause in won't work with all query modes + $sql .= ' WHERE tag_name LIKE ' . $this->_db->quoteString($args['q'] . '%'); + } + + if (isset($args['limit'])) { + $sql = $this->_db->addLimitOffset($sql, array('limit' => $args['limit'], 'offset' => isset($args['offset']) ? $args['offset'] : 0)); + } + + return $this->_db->selectAssoc($sql); + } + + /** + * Generate a tag cloud. Same syntax as getTags, except that fetching a + * cloud for a userId + objectId combination doesn't make sense - the counts + * would all be one. In addition, this method returns counts for each tag. + * + * @param array $args Search criteria: + * limit Maximum number of tags to return. + * offset Offset the results. Only useful for paginating, and not recommended. + * userId Only return tags that have been applied by a specific user. + * typeId Only return tags that have been applied by a specific object type. + * objectId Only return tags that have been applied to a specific object. + * + * @return array An array of hashes, each containing tag_id, tag_name, and count. + */ + public function getTagCloud($args = array()) + { + if (isset($args['objectId'])) { + $args['objectId'] = $this->_ensureObject($args['objectId']); + $sql = 'SELECT t.tag_id AS tag_id, tag_name, COUNT(*) AS count FROM ' . $this->_t('tagged') . ' tagged INNER JOIN ' . $this->_t('tags') . ' t ON tagged.tag_id = t.tag_id WHERE tagged.object_id = ' . (int)$args['objectId'] . ' GROUP BY t.tag_id'; + } elseif (isset($args['userId']) && isset($args['typeId'])) { + $args['userId'] = array_pop($this->_userManager->ensureUsers($args['userId'])); + $args['typeId'] = array_pop($this->_typeManager->ensureTypes($arge['typeId'])); + // This doesn't use a stat table, so may be slow. + $sql = 'SELECT t.tag_id AS tag_id, tag_name, COUNT(*) AS count FROM ' . $this->_t('tagged') . ' tagged INNER JOIN ' . $this->_t('objects') . ' objects ON tagged.object_id = objects.object_id AND objects.type_id = ' . (int)$args['typeId'] . ' INNER JOIN ' . $this->_t('tags') . ' t ON tagged.tag_id = t.tag_id WHERE tagged.user_id = ' . (int)$args['user_id'] . ' GROUP BY t.tag_id'; + } elseif (isset($args['userId'])) { + $args['userId'] = array_pop($this->_userManager->ensureUsers($args['userId'])); + $sql = 'SELECT t.tag_id AS tag_id, tag_name, count FROM ' . $this->_t('tagged') . ' tagged INNER JOIN ' . $this->_t('tags') . ' t ON tagged.tag_id = t.tag_id INNER JOIN ' . $this->_t('user_tag_stats') . ' uts ON t.tag_id = uts.tag_id AND uts.user_id = ' . (int)$args['userId'] . ' GROUP BY t.tag_id'; + } elseif (isset($args['typeId'])) { + $args['typeId'] = array_pop($this->_typeManager->ensureTypes($arge['typeId'])); + // This doesn't use a stat table, so may be slow. + $sql = 'SELECT t.tag_id AS tag_id, tag_name, COUNT(*) AS count FROM ' . $this->_t('tagged') . ' tagged INNER JOIN ' . $this->_t('objects') . ' objects ON tagged.object_id = objects.object_id AND objects.type_id = ' . (int)$args['typeId'] . ' INNER JOIN ' . $this->_t('tags') . ' t ON tagged.tag_id = t.tag_id GROUP BY t.tag_id'; + } else { + $sql = 'SELECT t.tag_id AS tag_id, tag_name, count FROM ' . $this->_t('tagged') . ' tagged INNER JOIN ' . $this->_t('tags') . ' t ON tagged.tag_id = t.tag_id INNER JOIN ' . $this->_t('tag_stats') . ' ts ON t.tag_id = ts.tag_id GROUP BY t.tag_id'; + } + + if (isset($args['limit'])) { + $sql = $this->_db->addLimitOffset($sql . ' ORDER BY count DESC', array('limit' => $args['limit'], 'offset' => isset($args['offset']) ? $args['offset'] : 0)); + } + + return $this->_db->selectAll($sql); + } + + /** + * Get the most recently used tags. + * + * @param array $args Search criteria: + * limit Maximum number of tags to return. + * offset Offset the results. Only useful for paginating, and not recommended. + * userId Only return tags that have been used by a specific user. + * typeId Only return tags applied to objects of a specific type. + * + * @return array + */ + public function getRecentTags($args = array()) + { + $sql = 'SELECT tagged.tag_id AS tag_id, tag_name, MAX(created) AS created FROM ' . $this->_t('tagged') . ' tagged INNER JOIN ' . $this->_t('tags') . ' t ON tagged.tag_id = t.tag_id'; + if (isset($args['typeId'])) { + $args['typeId'] = array_pop($this->_typeManager->ensureTypes($arge['typeId'])); + $sql .= ' INNER JOIN ' . $this->_t('objects') . ' objects ON tagged.object_id = objects.object_id AND objects.type_id = ' . (int)$args['typeId']; + } + if (isset($args['userId'])) { + $args['userId'] = array_pop($this->_userManager->ensureUsers($args['userId'])); + $sql .= ' WHERE tagged.user_id = ' . (int)$args['userId']; + } + $sql .= ' GROUP BY tagged.tag_id ORDER BY created DESC'; + + if (isset($args['limit'])) { + $sql = $this->_db->addLimitOffset($sql, array('limit' => $args['limit'], 'offset' => isset($args['offset']) ? $args['offset'] : 0)); + } + + return $this->_db->selectAll($sql); + } + + /** + * Get objects matching search criteria. + * + * @param array $args Search criteria: + * limit Maximum number of objects to return. + * offset Offset the results. Only useful for paginating, and not recommended. + * tagId Return objects related through one or more tags. + * typeId Only return objects with a specific type. + * objectId Return objects with the same tags as $objectId. + * + * @return array An array of object ids. + */ + public function getObjects($args) + { + if (isset($args['objectId'])) { + $args['objectId'] = array_pop($this->_objectManager->ensureObject($args['objectId'])); + $radius = isset($args['radius']) ? (int)$args['radius'] : $this->_defaultRadius; + $inner = $this->_db->addLimitOffset('SELECT tag_id FROM ' . $this->_t('tagged') . ' WHERE object_id = ' . (int)$objectId, array('limit' => $radius)); + $sql = $this->_db->addLimitOffset('SELECT tagged2.object_id FROM (' . $inner . ') AS t1 INNER JOIN ' . $this->_tagged . ' AS tagged2 ON t1.tag_id = t2.tag_id WHERE t2.object_id != ' . (int)$objectId . ' GROUP BY t2.object_id', array('limit' => $radius)); + } elseif (isset($args['tagId'])) { + $tags = is_array($args['tagId']) ? array_values($args['tagId']) : array($args['tagId']); + $count = count($tags); + if (!$count) { + return array(); + } + + $notTags = isset($args['notTagId']) ? (is_array($args['notTagId']) ? array_values($args['notTagId']) : array($args['notTagId'])) : array(); + $notCount = count($notTags); + + $sql = 'SELECT DISTINCT tagged.object_id FROM ' . $this->_t('tagged') . ' AS tagged'; + if ($count > 1) { + for ($i = 1; $i < $count; $i++) { + $sql .= ' INNER JOIN ' . $this->_t('tagged') . ' AS tagged' . $i . ' ON tagged.object_id = tagged' . $i . '.object_id'; + } + } + if ($notCount) { + // Left joins for tags we want to exclude. + for ($j = 0; $j < $notCount; $j++) { + $sql .= ' LEFT JOIN ' . $this->_t('tagged') . ' AS not_tagged' . $j . ' ON tagged.object_id = not_tagged' . $j . '.object_id AND not_tagged' . $j . '.tag_id = ' . (int)$notTags[$j]; + } + } + + $sql .= ' WHERE tagged.tag_id = ' . (int)$tags[0]; + + if ($count > 1) { + for ($i = 1; $i < $count; $i++) { + $sql .= ' AND tagged' . $i . '.tag_id = ' . (int)$tags[$i]; + } + } + if ($notCount) { + for ($j = 0; $j < $notCount; $j++) { + $sql .= ' AND not_tagged' . $j . '.object_id IS NULL'; + } + } + } + + if (isset($args['limit'])) { + $sql = $this->_db->addLimitOffset($sql, array('limit' => $args['limit'], 'offset' => isset($args['offset']) ? $args['offset'] : 0)); + } + + return $this->_db->selectValues($sql); + } + + /** + * Return objects related to the given object via tags, along with a + * similarity rank. + * + * @param array $args + * limit Maximum number of objects to return (default 10). + * userId Only return objects that have been tagged by a specific user. + * typeId Only return objects of a specific type. + * objectId The object to find relations for. + * threshold Number of tags-in-common objects must have to match (default 1). + * + * @return array + */ + public function getSimilarObjects($args) + { + $object_id = $this->_ensureObject($args['objectId']); + + /* TODO */ + $threshold = intval($threshold); + $max_objects = intval($max_objects); + if (!isset($object_id) || !($object_id > 0)) { + return $retarr; + } + if ($threshold <= 0) { + return $retarr; + } + if ($max_objects <= 0) { + return $retarr; + } + + // Pass in a zero-limit to get all tags. + $tagObjects = $this->get_tags_on_object($object_id, 0, 0); + + $tagArray = array(); + foreach ($tagObjects as $tagObject) { + $tagArray[] = $db->Quote($tagObject['tag']); + } + $tagArray = array_unique($tagArray); + + $numTags = count($tagArray); + if ($numTags == 0) { + return $retarr; // Return empty set of matches + } + + $tagList = implode(',', $tagArray); + + $prefix = $this->_table_prefix; + + $sql = "SELECT matches.object_id, COUNT( matches.object_id ) AS num_common_tags + FROM ${prefix}freetagged_objects as matches + INNER JOIN ${prefix}freetags as tags ON ( tags.id = matches.tag_id ) + WHERE tags.tag IN ($tagList) + GROUP BY matches.object_id + HAVING num_common_tags >= $threshold + ORDER BY num_common_tags DESC + LIMIT 0, $max_objects + "; + + $rs = $db->Execute($sql) or die("Syntax Error: $sql, Error: " . $db->ErrorMsg()); + while (!$rs->EOF) { + $retarr[] = array ( + 'object_id' => $rs->fields['object_id'], + 'strength' => ($rs->fields['num_common_tags'] / $numTags) + ); + $rs->MoveNext(); + } + + return $retarra; + } + + /** + * Get the most recently tagged objects. + * + * @param array $args Search criteria: + * limit Maximum number of objects to return. + * offset Offset the results. Only useful for paginating, and not recommended. + * userId Only return objects that have been tagged by a specific user. + * typeId Only return objects of a specific object type. + * + * @return array + */ + public function getRecentObjects($args = array()) + { + $sql = 'SELECT tagged.object_id AS object_id, MAX(created) AS created FROM ' . $this->_t('tagged') . ' tagged'; + if (isset($args['typeId'])) { + $args['typeId'] = array_pop($this->_typeManager->ensureTypes($args['typeId'])); + $sql .= ' INNER JOIN ' . $this->_t('objects') . ' objects ON tagged.object_id = objects.object_id AND objects.type_id = ' . (int)$args['typeId']; + } + if (isset($args['userId'])) { + $args['userId'] = array_pop($this->_userManager->ensureUsers($args['userId'])); + $sql .= ' WHERE tagged.user_id = ' . (int)$args['userId']; + } + $sql .= ' GROUP BY tagged.object_id ORDER BY created DESC'; + + if (isset($args['limit'])) { + $sql = $this->_db->addLimitOffset($sql, array('limit' => $args['limit'], 'offset' => isset($args['offset']) ? $args['offset'] : 0)); + } + + return $this->_db->selectAll($sql); + } + + /** + * Find users through objects, tags, or other users. + */ + public function getUsers($args) + { + if (isset($args['objectId'])) { + $args['objectId'] = $this->_ensureObject($args['objectId']); + $sql = 'SELECT t.user_id, user_name FROM ' . $this->_t('tagged') . ' as t INNER JOIN ' . $this->_t('users') . ' as u ON t.user_id = u.user_id WHERE object_id = ' . (int)$args['objectId']; + } elseif (isset($args['userId'])) { + $args['userId'] = array_pop($this->_userManager->ensureUsers($args['userId'])); + $radius = isset($args['radius']) ? (int)$args['radius'] : $this->_defaultRadius; + $sql = 'SELECT others.user_id, user_name FROM ' . $this->_t('tagged') . ' others INNER JOIN ' . $this->_t('users') . ' u ON u.user_id = others.user_id INNER JOIN (SELECT tag_id FROM ' . $this->_t('tagged') . ' WHERE user_id = ' . (int)$args['userId'] . ' GROUP BY tag_id HAVING COUNT(tag_id) >= ' . $radius . ') AS self ON others.tag_id = self.tag_id GROUP BY others.user_id'; + } elseif (isset($args['tagId'])) { + $tags = $this->ensureTags($args['tagId']); + //$tags = is_array($args['tagId']) ? array_values($args['tagId']) : array($args['tagId']); + $count = count($tags); + if (!$count) { + return array(); + } + + $notTags = isset($args['notTagId']) ? (is_array($args['notTagId']) ? array_values($args['notTagId']) : array($args['notTagId'])) : array(); + $notCount = count($notTags); + + $sql = 'SELECT DISTINCT tagged.user_id, user_name FROM ' . $this->_t('tagged') . ' AS tagged INNER JOIN ' . $this->_t('users') . ' as u ON u.user_id = tagged.user_id '; + if ($count > 1) { + for ($i = 1; $i < $count; $i++) { + $sql .= ' INNER JOIN ' . $this->_t('tagged') . ' AS tagged' . $i . ' ON tagged.user_id = tagged' . $i . '.user_id'; + } + } + if ($notCount) { + // Left joins for tags we want to exclude. + for ($j = 0; $j < $notCount; $j++) { + $sql .= ' LEFT JOIN ' . $this->_t('tagged') . ' AS not_tagged' . $j . ' ON tagged.user_id = not_tagged' . $j . '.user_id AND not_tagged' . $j . '.tag_id = ' . (int)$notTags[$j]; + } + } + + $sql .= ' WHERE tagged.tag_id = ' . (int)$tags[0]; + + if ($count > 1) { + for ($i = 1; $i < $count; $i++) { + $sql .= ' AND tagged' . $i . '.tag_id = ' . (int)$tags[$i]; + } + } + if ($notCount) { + for ($j = 0; $j < $notCount; $j++) { + $sql .= ' AND not_tagged' . $j . '.user_id IS NULL'; + } + } + } + + if (isset($args['limit'])) { + $sql = $this->_db->addLimitOffset($sql, array('limit' => $args['limit'], 'offset' => isset($args['offset']) ? $args['offset'] : 0)); + } + + return $this->_db->selectAssoc($sql); + } + + /** + * Get the users who have most recently tagged objects. + * + * @param array $args Search criteria: + * limit Maximum number of users to return. + * offset Offset the results. Only useful for paginating, and not recommended. + * typeId Only return users who have tagged objects of a specific object type. + * + * @return array + */ + public function getRecentUsers($args = array()) + { + $sql = 'SELECT tagged.user_id AS user_id, MAX(created) AS created FROM ' . $this->_t('tagged') . ' tagged'; + if (isset($args['typeId'])) { + $args['typeId'] = array_pop($this->_typeManager->ensureTypes($args['typeId'])); + $sql .= ' INNER JOIN ' . $this->_t('objects') . ' objects ON tagged.object_id = objects.object_id AND objects.type_id = ' . (int)$args['typeId']; + } + $sql .= ' GROUP BY tagged.user_id ORDER BY created DESC'; + + if (isset($args['limit'])) { + $sql = $this->_db->addLimitOffset($sql, array('limit' => $args['limit'], 'offset' => isset($args['offset']) ? $args['offset'] : 0)); + } + + return $this->_db->selectAll($sql); + } + + /** + * Return users related to a given user along with a similarity rank. + */ + public function getSimilarUsers($args) + { + $args['userId'] = array_pop($this->_userManager->ensureUsers($args['userId'])); + $radius = isset($args['radius']) ? (int)$args['radius'] : $this->_defaultRadius; + $sql = 'SELECT others.user_id, (others.count - self.count) AS rank FROM ' . $this->_t('user_tag_stats') . ' others INNER JOIN (SELECT tag_id, count FROM ' . $this->_t('user_tag_stats') . ' WHERE user_id = ' . (int)$args['userId'] . ' AND count >= ' . $radius . ') AS self ON others.tag_id = self.tag_id ORDER BY rank DESC'; + + if (isset($args['limit'])) { + $sql = $this->_db->addLimitOffset($sql, array('limit' => $args['limit'])); + } + + return $this->_db->selectAssoc($sql); + } + + /** + * Ensure that an array of tags exist, create any that don't, and + * return ids for all of them. + * + * @param array $tags Array of tag names or ids. + * + * @return array Array of tag ids. + */ + public function ensureTags($tags) + { + if (!is_array($tags)) { + $tags = array($tags); + } + + $tagIds = array(); + $tagText = array(); + + // Anything already typed as an integer is assumed to be a tag id. + foreach ($tags as $tagIndex => $tag) { + if (is_int($tag)) { + $tagIds[$tagIndex] = $tag; + } else { + $tagText[$tag] = $tagIndex; + } + } + + // Get the ids for any tags that already exist. + if (count($tagText)) { + foreach ($this->_db->selectAll('SELECT tag_id, tag_name FROM ' . $this->_t('tags') . ' WHERE tag_name IN ('.implode(',', array_map(array($this->_db, 'quote'), array_keys($tagText))).')') as $row) { + $tagIndex = $tagText[$row['tag_name']]; + unset($tagText[$row['tag_name']]); + $tagIds[$tagIndex] = $row['tag_id']; + } + } + + // Create any tags that didn't already exist + foreach ($tagText as $tag => $tagIndex) { + $tagIds[$tagIndex] = $this->_db->insert('INSERT INTO ' . $this->_t('tags') . ' (tag_name) VALUES (' . $this->_db->quote($tag) . ')'); + } + + return $tagIds; + } + + /** + * Split a string into an array of tag names, respecting tags with spaces + * and ones that are quoted in some way. For example: + * this, "somecompany, llc", "and ""this"" w,o.rks", foo bar + * + * Would parse to: + * array('this', 'somecompany, llc', 'and "this" w,o.rks', 'foo bar') + * + * @param string $text String to split into 1 or more tags. + * + * @return array Split tag array. + */ + public function splitTags($text) + { + // From http://drupal.org/project/community_tags + $regexp = '%(?:^|,\ *)("(?>[^"]*)(?>""[^"]* )*"|(?: [^",]*))%x'; + preg_match_all($regexp, $text, $matches); + + $tags = array(); + foreach (array_unique($matches[1]) as $tag) { + // Remove escape codes + $tag = trim(str_replace('""', '"', preg_replace('/^"(.*)"$/', '\1', $tag))); + if (strlen($tag)) { + $tags[] = $tag; + } + } + + return $tags; + } + + /** + * Convenience method - if $object is an array, it is taken as an array of + * 'object' and 'type' to pass to objectManager::ensureObjects() if it's a + * scalar value, it's taken as the object_id and simply returned. + */ + protected function _ensureObject($object) + { + if (is_array($object)) { + $object = array_pop($this->_objectManager->ensureObjects( + $object['object'], array_pop($this->_typeManager->ensureTypes($object['type'])))); + } + + return (int)$object; + } + + /** + * Shortcut for getting a table name. + * + * @param string $tableType + * + * @return string Configured table name. + */ + protected function _t($tableType) + { + return $this->_db->quoteTableName($this->_tables[$tableType]); + } + +} diff --git a/content/lib/Tags/Tagger.php b/content/lib/Tags/Tagger.php deleted file mode 100644 index 43da45bc0..000000000 --- a/content/lib/Tags/Tagger.php +++ /dev/null @@ -1,688 +0,0 @@ - - * @license http://opensource.org/licenses/bsd-license.php BSD - * @category Horde - * @package Horde_Content - */ - -// For now, require these, but they should be autoloadable... -require_once dirname(__FILE__) . '/../Objects/Manager.php'; -require_once dirname(__FILE__) . '/../Types/Manager.php'; -require_once dirname(__FILE__) . '/../Users/Manager.php'; - -/** - * @author Chuck Hagenbuch - * @license http://opensource.org/licenses/bsd-license.php BSD - * @category Horde - * @package Horde_Content - * - * References: - * http://forge.mysql.com/wiki/TagSchema - * http://www.slideshare.net/edbond/tagging-and-folksonomy-schema-design-for-scalability-and-performance - * http://blog.thinkphp.de/archives/124-An-alternative-Approach-to-Tagging.html - * http://code.google.com/p/freetag/ - * - * @TODO: - * need to add type_id to the rampage_tagged table for performance? - * need stat tables by type_id? - * - * Potential features: - * Infer data from combined tags (capital + washington d.c. - http://www.slideshare.net/kakul/tagging-web-2-expo-2008/) - * Normalize tag text (http://tagsonomy.com/index.php/interview-with-gordon-luk-freetag/) - */ -class Content_Tagger -{ - /** - * Database connection - * @var Horde_Db - */ - protected $_db; - - /** - * Tables - * @var array - */ - protected $_tables = array( - 'tags' => 'rampage_tags', - 'tagged' => 'rampage_tagged', - 'objects' => 'rampage_objects', - 'tag_stats' => 'rampage_tag_stats', - 'user_tag_stats' => 'rampage_user_tag_stats', - 'users' => 'rampage_users', - ); - - /** - * User manager object - * @var Content_Users_Manager - */ - protected $_userManager; - - /** - * Type management object - * @var Content_Types_Manager - */ - protected $_typeManager; - - /** - * Object manager - * @var Content_Objects_Manager - */ - protected $_objectManager; - - /** - * Default radius for relationship queries. - * @var integer - */ - protected $_defaultRadius = 10; - - /** - * Constructor - can take an array of arguments that set the managers - * and DbAdapter - */ - public function __construct($context = array()) - { - if (!empty($context['userManager'])) { - $this->_userManager = $context['userManager']; - } - - if (!empty($context['typeManager'])) { - $this->_typeManager = $context['typeManager']; - } - - if (!empty($context['objectManager'])) { - $this->_objectManager = $context['objectManager']; - } - } - - /** - * Set the database connection for the tagger. - * - * @param Horde_Db $db - */ - public function setDbAdapter($db) - { - $this->_db = $db; - } - - /** - * Change the name of a database table. - * - * @param string $tableType - * @param string $tableName - */ - public function setTableName($tableType, $tableName) - { - $this->_tables[$tableType] = $tableName; - } - - /** - * Adds a tag or several tags to an object_id. This method does not - * remove other tags. - * - * @param mixed $userId The user tagging the object. - * @param mixed $objectId The object id to tag or an array containing - * the object_name and type. - * @param array $tags An array of tag name or ids. - * @param Horde_Date $created The datetime of the tagging operation. - * - * @return void - */ - public function tag($userId, $objectId, $tags, $created = null) - { - if (is_null($created)) { - $created = date('Y-m-d\TH:i:s'); - } else { - $created = $created->format('Y-m-d\TH:i:s'); - } - - // Make sure the object exists - $objectId = $this->_ensureObject($objectId); - - // Validate/ensure the parameters - $userId = array_pop($this->_userManager->ensureUsers($userId)); - - foreach ($this->ensureTags($tags) as $tagId) { - try { - $this->_db->insert('INSERT INTO ' . $this->_t('tagged') . ' (user_id, object_id, tag_id, created) - VALUES (' . (int)$userId . ',' . (int)$objectId . ',' . (int)$tagId . ',' . $this->_db->quote($created) . ')'); - } catch (Horde_Db_Exception $e) { - // @TODO should make sure it's a duplicate and re-throw if not - continue; - } - - // increment tag stats - if (!$this->_db->update('UPDATE ' . $this->_t('tag_stats') . ' SET count = count + 1 WHERE tag_id = ' . (int)$tagId)) { - $this->_db->insert('INSERT INTO ' . $this->_t('tag_stats') . ' (tag_id, count) VALUES (' . (int)$tagId . ', 1)'); - } - - // increment user-tag stats - if (!$this->_db->update('UPDATE ' . $this->_t('user_tag_stats') . ' SET count = count + 1 WHERE user_id = ' . (int)$userId . ' AND tag_id = ' . (int)$tagId)) { - $this->_db->insert('INSERT INTO ' . $this->_t('user_tag_stats') . ' (user_id, tag_id, count) VALUES (' . (int)$userId . ', ' . (int)$tagId . ', 1)'); - } - } - } - - /** - * Undo a user's tagging of an object. - * - * @param mixed $userId The user who tagged the object. - * @param mixed $objectId The object to remove the tag from. - * @param array $tags An array of tag name or ids to remove. - */ - public function untag($userId, $objectId, $tags) - { - // Ensure parameters - $userId = array_pop($this->_userManager->ensureUsers($userId)); - $objectId = $this->_ensureObject($objectId); - - foreach ($this->ensureTags($tags) as $tagId) { - if ($this->_db->delete('DELETE FROM ' . $this->_t('tagged') . ' WHERE user_id = ? AND object_id = ? AND tag_id = ?', array($userId, $objectId, $tagId))) { - $this->_db->update('UPDATE ' . $this->_t('tag_stats') . ' SET count = count - 1 WHERE tag_id = ?', array($tagId)); - $this->_db->update('UPDATE ' . $this->_t('user_tag_stats') . ' SET count = count - 1 WHERE user_id = ? AND tag_id = ?', array($userId, $tagId)); - } - } - - // Cleanup - $this->_db->delete('DELETE FROM ' . $this->_t('tag_stats') . ' WHERE count = 0'); - $this->_db->delete('DELETE FROM ' . $this->_t('user_tag_stats') . ' WHERE count = 0'); - } - - /** - * Retrieve tags based on criteria. - * - * @param array $args Search criteria: - * q Starts-with search on tag_name. - * limit Maximum number of tags to return. - * offset Offset the results. Only useful for paginating, and not recommended. - * userId Only return tags that have been applied by a specific user. - * typeId Only return tags that have been applied by a specific object type. - * objectId Only return tags that have been applied to a specific object. - * - * @return array An array of tags, id => name. - */ - public function getTags($args) - { - if (isset($args['objectId'])) { - // Don't create the object just because we're trying to load an - // objects's tags - just check if the object is there. Assume if we - // have an integer, it's a valid object_id. - if (is_array($args['objectId'])) { - $args['objectId'] = $this->_objectManager->exists($args['objectId']['object'], $args['objectId']['type']); - } - if (!$args['objectId']) { - return array(); - } - - $sql = 'SELECT DISTINCT t.tag_id AS tag_id, tag_name FROM ' . $this->_t('tags') . ' t INNER JOIN ' . $this->_t('tagged') . ' tagged ON t.tag_id = tagged.tag_id AND tagged.object_id = ' . (int)$args['objectId']; - } elseif (isset($args['userId']) && isset($args['typeId'])) { - $args['userId'] = array_pop($this->_userManager->ensureUsers($args['userId'])); - $args['typeId'] = array_pop($this->_typeManager->ensureTypes($arge['typeId'])); - $sql = 'SELECT DISTINCT t.tag_id AS tag_id, tag_name FROM ' . $this->_t('tags') . ' t INNER JOIN ' . $this->_t('tagged') . ' tagged ON t.tag_id = tagged.tag_id AND tagged.user_id = ' . (int)$args['userId'] . ' INNER JOIN ' . $this->_t('objects') . ' objects ON tagged.object_id = objects.object_id AND objects.type_id = ' . (int)$args['typeId']; - } elseif (isset($args['userId'])) { - $args['userId'] = array_pop($this->_userManager->ensureUsers($args['userId'])); - $sql = 'SELECT DISTINCT t.tag_id AS tag_id, tag_name FROM ' . $this->_t('tagged') . ' tagged INNER JOIN ' . $this->_t('tags') . ' t ON tagged.tag_id = t.tag_id WHERE tagged.user_id = ' . (int)$args['userId']; - } elseif (isset($args['typeId'])) { - $args['typeId'] = array_pop($this->_typeManager->ensureTypes($arge['typeId'])); - $sql = 'SELECT DISTINCT t.tag_id AS tag_id, tag_name FROM ' . $this->_t('tagged') . ' tagged INNER JOIN ' . $this->_t('objects') . ' objects ON tagged.object_id = objects.object_id AND objects.type_id = ' . (int)$args['typeId'] . ' INNER JOIN ' . $this->_t('tags') . ' t ON tagged.tag_id = t.tag_id'; - } elseif (isset($args['tagId'])) { - $radius = isset($args['limit']) ? (int)$args['limit'] : $this->_defaultRadius; - unset($args['limit']); - - $inner = $this->_db->addLimitOffset('SELECT object_id FROM ' . $this->_t('tagged') . ' WHERE tag_id = ' . (int)$args['tagId'], array('limit' => $radius)); - $sql = $this->_db->addLimitOffset('SELECT DISTINCT tagged2.tag_id AS tag_id, tag_name FROM (' . $inner . ') AS tagged1 INNER JOIN ' . $this->_t('tagged') . ' tagged2 ON tagged1.object_id = tagged2.object_id INNER JOIN ' . $this->_t('tags') . ' t ON tagged2.tag_id = t.tag_id', array('limit' => $args['limit'])); - } else { - $sql = 'SELECT DISTINCT t.tag_id, tag_name FROM ' . $this->_t('tags') . ' t JOIN ' . $this->_t('tagged') . ' tagged ON t.tag_id = tagged.tag_id'; - } - - if (isset($args['q']) && strlen($args['q'])) { - // @TODO tossing a where clause in won't work with all query modes - $sql .= ' WHERE tag_name LIKE ' . $this->_db->quoteString($args['q'] . '%'); - } - - if (isset($args['limit'])) { - $sql = $this->_db->addLimitOffset($sql, array('limit' => $args['limit'], 'offset' => isset($args['offset']) ? $args['offset'] : 0)); - } - - return $this->_db->selectAssoc($sql); - } - - /** - * Generate a tag cloud. Same syntax as getTags, except that fetching a - * cloud for a userId + objectId combination doesn't make sense - the counts - * would all be one. In addition, this method returns counts for each tag. - * - * @param array $args Search criteria: - * limit Maximum number of tags to return. - * offset Offset the results. Only useful for paginating, and not recommended. - * userId Only return tags that have been applied by a specific user. - * typeId Only return tags that have been applied by a specific object type. - * objectId Only return tags that have been applied to a specific object. - * - * @return array An array of hashes, each containing tag_id, tag_name, and count. - */ - public function getTagCloud($args = array()) - { - if (isset($args['objectId'])) { - $args['objectId'] = $this->_ensureObject($args['objectId']); - $sql = 'SELECT t.tag_id AS tag_id, tag_name, COUNT(*) AS count FROM ' . $this->_t('tagged') . ' tagged INNER JOIN ' . $this->_t('tags') . ' t ON tagged.tag_id = t.tag_id WHERE tagged.object_id = ' . (int)$args['objectId'] . ' GROUP BY t.tag_id'; - } elseif (isset($args['userId']) && isset($args['typeId'])) { - $args['userId'] = array_pop($this->_userManager->ensureUsers($args['userId'])); - $args['typeId'] = array_pop($this->_typeManager->ensureTypes($arge['typeId'])); - // This doesn't use a stat table, so may be slow. - $sql = 'SELECT t.tag_id AS tag_id, tag_name, COUNT(*) AS count FROM ' . $this->_t('tagged') . ' tagged INNER JOIN ' . $this->_t('objects') . ' objects ON tagged.object_id = objects.object_id AND objects.type_id = ' . (int)$args['typeId'] . ' INNER JOIN ' . $this->_t('tags') . ' t ON tagged.tag_id = t.tag_id WHERE tagged.user_id = ' . (int)$args['user_id'] . ' GROUP BY t.tag_id'; - } elseif (isset($args['userId'])) { - $args['userId'] = array_pop($this->_userManager->ensureUsers($args['userId'])); - $sql = 'SELECT t.tag_id AS tag_id, tag_name, count FROM ' . $this->_t('tagged') . ' tagged INNER JOIN ' . $this->_t('tags') . ' t ON tagged.tag_id = t.tag_id INNER JOIN ' . $this->_t('user_tag_stats') . ' uts ON t.tag_id = uts.tag_id AND uts.user_id = ' . (int)$args['userId'] . ' GROUP BY t.tag_id'; - } elseif (isset($args['typeId'])) { - $args['typeId'] = array_pop($this->_typeManager->ensureTypes($arge['typeId'])); - // This doesn't use a stat table, so may be slow. - $sql = 'SELECT t.tag_id AS tag_id, tag_name, COUNT(*) AS count FROM ' . $this->_t('tagged') . ' tagged INNER JOIN ' . $this->_t('objects') . ' objects ON tagged.object_id = objects.object_id AND objects.type_id = ' . (int)$args['typeId'] . ' INNER JOIN ' . $this->_t('tags') . ' t ON tagged.tag_id = t.tag_id GROUP BY t.tag_id'; - } else { - $sql = 'SELECT t.tag_id AS tag_id, tag_name, count FROM ' . $this->_t('tagged') . ' tagged INNER JOIN ' . $this->_t('tags') . ' t ON tagged.tag_id = t.tag_id INNER JOIN ' . $this->_t('tag_stats') . ' ts ON t.tag_id = ts.tag_id GROUP BY t.tag_id'; - } - - if (isset($args['limit'])) { - $sql = $this->_db->addLimitOffset($sql . ' ORDER BY count DESC', array('limit' => $args['limit'], 'offset' => isset($args['offset']) ? $args['offset'] : 0)); - } - - return $this->_db->selectAll($sql); - } - - /** - * Get the most recently used tags. - * - * @param array $args Search criteria: - * limit Maximum number of tags to return. - * offset Offset the results. Only useful for paginating, and not recommended. - * userId Only return tags that have been used by a specific user. - * typeId Only return tags applied to objects of a specific type. - * - * @return array - */ - public function getRecentTags($args = array()) - { - $sql = 'SELECT tagged.tag_id AS tag_id, tag_name, MAX(created) AS created FROM ' . $this->_t('tagged') . ' tagged INNER JOIN ' . $this->_t('tags') . ' t ON tagged.tag_id = t.tag_id'; - if (isset($args['typeId'])) { - $args['typeId'] = array_pop($this->_typeManager->ensureTypes($arge['typeId'])); - $sql .= ' INNER JOIN ' . $this->_t('objects') . ' objects ON tagged.object_id = objects.object_id AND objects.type_id = ' . (int)$args['typeId']; - } - if (isset($args['userId'])) { - $args['userId'] = array_pop($this->_userManager->ensureUsers($args['userId'])); - $sql .= ' WHERE tagged.user_id = ' . (int)$args['userId']; - } - $sql .= ' GROUP BY tagged.tag_id ORDER BY created DESC'; - - if (isset($args['limit'])) { - $sql = $this->_db->addLimitOffset($sql, array('limit' => $args['limit'], 'offset' => isset($args['offset']) ? $args['offset'] : 0)); - } - - return $this->_db->selectAll($sql); - } - - /** - * Get objects matching search criteria. - * - * @param array $args Search criteria: - * limit Maximum number of objects to return. - * offset Offset the results. Only useful for paginating, and not recommended. - * tagId Return objects related through one or more tags. - * typeId Only return objects with a specific type. - * objectId Return objects with the same tags as $objectId. - * - * @return array An array of object ids. - */ - public function getObjects($args) - { - if (isset($args['objectId'])) { - $args['objectId'] = array_pop($this->_objectManager->ensureObject($args['objectId'])); - $radius = isset($args['radius']) ? (int)$args['radius'] : $this->_defaultRadius; - $inner = $this->_db->addLimitOffset('SELECT tag_id FROM ' . $this->_t('tagged') . ' WHERE object_id = ' . (int)$objectId, array('limit' => $radius)); - $sql = $this->_db->addLimitOffset('SELECT tagged2.object_id FROM (' . $inner . ') AS t1 INNER JOIN ' . $this->_tagged . ' AS tagged2 ON t1.tag_id = t2.tag_id WHERE t2.object_id != ' . (int)$objectId . ' GROUP BY t2.object_id', array('limit' => $radius)); - } elseif (isset($args['tagId'])) { - $tags = is_array($args['tagId']) ? array_values($args['tagId']) : array($args['tagId']); - $count = count($tags); - if (!$count) { - return array(); - } - - $notTags = isset($args['notTagId']) ? (is_array($args['notTagId']) ? array_values($args['notTagId']) : array($args['notTagId'])) : array(); - $notCount = count($notTags); - - $sql = 'SELECT DISTINCT tagged.object_id FROM ' . $this->_t('tagged') . ' AS tagged'; - if ($count > 1) { - for ($i = 1; $i < $count; $i++) { - $sql .= ' INNER JOIN ' . $this->_t('tagged') . ' AS tagged' . $i . ' ON tagged.object_id = tagged' . $i . '.object_id'; - } - } - if ($notCount) { - // Left joins for tags we want to exclude. - for ($j = 0; $j < $notCount; $j++) { - $sql .= ' LEFT JOIN ' . $this->_t('tagged') . ' AS not_tagged' . $j . ' ON tagged.object_id = not_tagged' . $j . '.object_id AND not_tagged' . $j . '.tag_id = ' . (int)$notTags[$j]; - } - } - - $sql .= ' WHERE tagged.tag_id = ' . (int)$tags[0]; - - if ($count > 1) { - for ($i = 1; $i < $count; $i++) { - $sql .= ' AND tagged' . $i . '.tag_id = ' . (int)$tags[$i]; - } - } - if ($notCount) { - for ($j = 0; $j < $notCount; $j++) { - $sql .= ' AND not_tagged' . $j . '.object_id IS NULL'; - } - } - } - - if (isset($args['limit'])) { - $sql = $this->_db->addLimitOffset($sql, array('limit' => $args['limit'], 'offset' => isset($args['offset']) ? $args['offset'] : 0)); - } - - return $this->_db->selectValues($sql); - } - - /** - * Return objects related to the given object via tags, along with a - * similarity rank. - * - * @param array $args - * limit Maximum number of objects to return (default 10). - * userId Only return objects that have been tagged by a specific user. - * typeId Only return objects of a specific type. - * objectId The object to find relations for. - * threshold Number of tags-in-common objects must have to match (default 1). - * - * @return array - */ - public function getSimilarObjects($args) - { - $object_id = $this->_ensureObject($args['objectId']); - - /* TODO */ - $threshold = intval($threshold); - $max_objects = intval($max_objects); - if (!isset($object_id) || !($object_id > 0)) { - return $retarr; - } - if ($threshold <= 0) { - return $retarr; - } - if ($max_objects <= 0) { - return $retarr; - } - - // Pass in a zero-limit to get all tags. - $tagObjects = $this->get_tags_on_object($object_id, 0, 0); - - $tagArray = array(); - foreach ($tagObjects as $tagObject) { - $tagArray[] = $db->Quote($tagObject['tag']); - } - $tagArray = array_unique($tagArray); - - $numTags = count($tagArray); - if ($numTags == 0) { - return $retarr; // Return empty set of matches - } - - $tagList = implode(',', $tagArray); - - $prefix = $this->_table_prefix; - - $sql = "SELECT matches.object_id, COUNT( matches.object_id ) AS num_common_tags - FROM ${prefix}freetagged_objects as matches - INNER JOIN ${prefix}freetags as tags ON ( tags.id = matches.tag_id ) - WHERE tags.tag IN ($tagList) - GROUP BY matches.object_id - HAVING num_common_tags >= $threshold - ORDER BY num_common_tags DESC - LIMIT 0, $max_objects - "; - - $rs = $db->Execute($sql) or die("Syntax Error: $sql, Error: " . $db->ErrorMsg()); - while (!$rs->EOF) { - $retarr[] = array ( - 'object_id' => $rs->fields['object_id'], - 'strength' => ($rs->fields['num_common_tags'] / $numTags) - ); - $rs->MoveNext(); - } - - return $retarra; - } - - /** - * Get the most recently tagged objects. - * - * @param array $args Search criteria: - * limit Maximum number of objects to return. - * offset Offset the results. Only useful for paginating, and not recommended. - * userId Only return objects that have been tagged by a specific user. - * typeId Only return objects of a specific object type. - * - * @return array - */ - public function getRecentObjects($args = array()) - { - $sql = 'SELECT tagged.object_id AS object_id, MAX(created) AS created FROM ' . $this->_t('tagged') . ' tagged'; - if (isset($args['typeId'])) { - $args['typeId'] = array_pop($this->_typeManager->ensureTypes($args['typeId'])); - $sql .= ' INNER JOIN ' . $this->_t('objects') . ' objects ON tagged.object_id = objects.object_id AND objects.type_id = ' . (int)$args['typeId']; - } - if (isset($args['userId'])) { - $args['userId'] = array_pop($this->_userManager->ensureUsers($args['userId'])); - $sql .= ' WHERE tagged.user_id = ' . (int)$args['userId']; - } - $sql .= ' GROUP BY tagged.object_id ORDER BY created DESC'; - - if (isset($args['limit'])) { - $sql = $this->_db->addLimitOffset($sql, array('limit' => $args['limit'], 'offset' => isset($args['offset']) ? $args['offset'] : 0)); - } - - return $this->_db->selectAll($sql); - } - - /** - * Find users through objects, tags, or other users. - */ - public function getUsers($args) - { - if (isset($args['objectId'])) { - $args['objectId'] = $this->_ensureObject($args['objectId']); - $sql = 'SELECT t.user_id, user_name FROM ' . $this->_t('tagged') . ' as t INNER JOIN ' . $this->_t('users') . ' as u ON t.user_id = u.user_id WHERE object_id = ' . (int)$args['objectId']; - } elseif (isset($args['userId'])) { - $args['userId'] = array_pop($this->_userManager->ensureUsers($args['userId'])); - $radius = isset($args['radius']) ? (int)$args['radius'] : $this->_defaultRadius; - $sql = 'SELECT others.user_id, user_name FROM ' . $this->_t('tagged') . ' others INNER JOIN ' . $this->_t('users') . ' u ON u.user_id = others.user_id INNER JOIN (SELECT tag_id FROM ' . $this->_t('tagged') . ' WHERE user_id = ' . (int)$args['userId'] . ' GROUP BY tag_id HAVING COUNT(tag_id) >= ' . $radius . ') AS self ON others.tag_id = self.tag_id GROUP BY others.user_id'; - } elseif (isset($args['tagId'])) { - $tags = $this->ensureTags($args['tagId']); - //$tags = is_array($args['tagId']) ? array_values($args['tagId']) : array($args['tagId']); - $count = count($tags); - if (!$count) { - return array(); - } - - $notTags = isset($args['notTagId']) ? (is_array($args['notTagId']) ? array_values($args['notTagId']) : array($args['notTagId'])) : array(); - $notCount = count($notTags); - - $sql = 'SELECT DISTINCT tagged.user_id, user_name FROM ' . $this->_t('tagged') . ' AS tagged INNER JOIN ' . $this->_t('users') . ' as u ON u.user_id = tagged.user_id '; - if ($count > 1) { - for ($i = 1; $i < $count; $i++) { - $sql .= ' INNER JOIN ' . $this->_t('tagged') . ' AS tagged' . $i . ' ON tagged.user_id = tagged' . $i . '.user_id'; - } - } - if ($notCount) { - // Left joins for tags we want to exclude. - for ($j = 0; $j < $notCount; $j++) { - $sql .= ' LEFT JOIN ' . $this->_t('tagged') . ' AS not_tagged' . $j . ' ON tagged.user_id = not_tagged' . $j . '.user_id AND not_tagged' . $j . '.tag_id = ' . (int)$notTags[$j]; - } - } - - $sql .= ' WHERE tagged.tag_id = ' . (int)$tags[0]; - - if ($count > 1) { - for ($i = 1; $i < $count; $i++) { - $sql .= ' AND tagged' . $i . '.tag_id = ' . (int)$tags[$i]; - } - } - if ($notCount) { - for ($j = 0; $j < $notCount; $j++) { - $sql .= ' AND not_tagged' . $j . '.user_id IS NULL'; - } - } - } - - if (isset($args['limit'])) { - $sql = $this->_db->addLimitOffset($sql, array('limit' => $args['limit'], 'offset' => isset($args['offset']) ? $args['offset'] : 0)); - } - - return $this->_db->selectAssoc($sql); - } - - /** - * Get the users who have most recently tagged objects. - * - * @param array $args Search criteria: - * limit Maximum number of users to return. - * offset Offset the results. Only useful for paginating, and not recommended. - * typeId Only return users who have tagged objects of a specific object type. - * - * @return array - */ - public function getRecentUsers($args = array()) - { - $sql = 'SELECT tagged.user_id AS user_id, MAX(created) AS created FROM ' . $this->_t('tagged') . ' tagged'; - if (isset($args['typeId'])) { - $args['typeId'] = array_pop($this->_typeManager->ensureTypes($args['typeId'])); - $sql .= ' INNER JOIN ' . $this->_t('objects') . ' objects ON tagged.object_id = objects.object_id AND objects.type_id = ' . (int)$args['typeId']; - } - $sql .= ' GROUP BY tagged.user_id ORDER BY created DESC'; - - if (isset($args['limit'])) { - $sql = $this->_db->addLimitOffset($sql, array('limit' => $args['limit'], 'offset' => isset($args['offset']) ? $args['offset'] : 0)); - } - - return $this->_db->selectAll($sql); - } - - /** - * Return users related to a given user along with a similarity rank. - */ - public function getSimilarUsers($args) - { - $args['userId'] = array_pop($this->_userManager->ensureUsers($args['userId'])); - $radius = isset($args['radius']) ? (int)$args['radius'] : $this->_defaultRadius; - $sql = 'SELECT others.user_id, (others.count - self.count) AS rank FROM ' . $this->_t('user_tag_stats') . ' others INNER JOIN (SELECT tag_id, count FROM ' . $this->_t('user_tag_stats') . ' WHERE user_id = ' . (int)$args['userId'] . ' AND count >= ' . $radius . ') AS self ON others.tag_id = self.tag_id ORDER BY rank DESC'; - - if (isset($args['limit'])) { - $sql = $this->_db->addLimitOffset($sql, array('limit' => $args['limit'])); - } - - return $this->_db->selectAssoc($sql); - } - - /** - * Ensure that an array of tags exist, create any that don't, and - * return ids for all of them. - * - * @param array $tags Array of tag names or ids. - * - * @return array Array of tag ids. - */ - public function ensureTags($tags) - { - if (!is_array($tags)) { - $tags = array($tags); - } - - $tagIds = array(); - $tagText = array(); - - // Anything already typed as an integer is assumed to be a tag id. - foreach ($tags as $tagIndex => $tag) { - if (is_int($tag)) { - $tagIds[$tagIndex] = $tag; - } else { - $tagText[$tag] = $tagIndex; - } - } - - // Get the ids for any tags that already exist. - if (count($tagText)) { - foreach ($this->_db->selectAll('SELECT tag_id, tag_name FROM ' . $this->_t('tags') . ' WHERE tag_name IN ('.implode(',', array_map(array($this->_db, 'quote'), array_keys($tagText))).')') as $row) { - $tagIndex = $tagText[$row['tag_name']]; - unset($tagText[$row['tag_name']]); - $tagIds[$tagIndex] = $row['tag_id']; - } - } - - // Create any tags that didn't already exist - foreach ($tagText as $tag => $tagIndex) { - $tagIds[$tagIndex] = $this->_db->insert('INSERT INTO ' . $this->_t('tags') . ' (tag_name) VALUES (' . $this->_db->quote($tag) . ')'); - } - - return $tagIds; - } - - /** - * Split a string into an array of tag names, respecting tags with spaces - * and ones that are quoted in some way. For example: - * this, "somecompany, llc", "and ""this"" w,o.rks", foo bar - * - * Would parse to: - * array('this', 'somecompany, llc', 'and "this" w,o.rks', 'foo bar') - * - * @param string $text String to split into 1 or more tags. - * - * @return array Split tag array. - */ - public function splitTags($text) - { - // From http://drupal.org/project/community_tags - $regexp = '%(?:^|,\ *)("(?>[^"]*)(?>""[^"]* )*"|(?: [^",]*))%x'; - preg_match_all($regexp, $text, $matches); - - $tags = array(); - foreach (array_unique($matches[1]) as $tag) { - // Remove escape codes - $tag = trim(str_replace('""', '"', preg_replace('/^"(.*)"$/', '\1', $tag))); - if (strlen($tag)) { - $tags[] = $tag; - } - } - - return $tags; - } - - /** - * Convenience method - if $object is an array, it is taken as an array of - * 'object' and 'type' to pass to objectManager::ensureObjects() if it's a - * scalar value, it's taken as the object_id and simply returned. - */ - protected function _ensureObject($object) - { - if (is_array($object)) { - $object = array_pop($this->_objectManager->ensureObjects( - $object['object'], array_pop($this->_typeManager->ensureTypes($object['type'])))); - } - - return (int)$object; - } - - /** - * Shortcut for getting a table name. - * - * @param string $tableType - * - * @return string Configured table name. - */ - protected function _t($tableType) - { - return $this->_db->quoteTableName($this->_tables[$tableType]); - } - -}