From 695e7e103447ebe2d7c5852b0518ba381d3c9b8f Mon Sep 17 00:00:00 2001 From: "Michael J. Rubinsky" Date: Sat, 20 Dec 2008 16:40:31 -0500 Subject: [PATCH] Add Content from the incubator --- content/.htaccess | 6 + content/app/controllers/TagController.php | 64 +++ content/app/views/Tag/CVS/Entries | 2 + content/app/views/Tag/CVS/Repository | 1 + content/app/views/Tag/CVS/Root | 1 + content/app/views/Tag/CVS/Template | 8 + content/app/views/Tag/searchTags.html.php | 5 + content/bin/object_add.php | 24 ++ content/bin/object_delete.php | 25 ++ content/bin/tag.php | 23 + content/bin/tag_add.php | 19 + content/bin/tag_delete.php | 29 ++ content/bin/untag.php | 23 + content/config/.cvsignore | 1 + content/data/rampage_base.xml | 161 +++++++ content/data/rampage_tags.xml | 236 ++++++++++ content/doc/TODO.txt | 11 + content/index.php | 19 + content/lib/Objects/Manager.php | 152 +++++++ content/lib/Objects/Object.php | 19 + content/lib/Objects/ObjectMapper.php | 24 ++ content/lib/Tags/Tag.php | 19 + content/lib/Tags/TagMapper.php | 24 ++ content/lib/Tags/Tagger.php | 691 ++++++++++++++++++++++++++++++ content/lib/Types/Manager.php | 118 +++++ content/lib/Users/Manager.php | 118 +++++ content/lib/base.php | 9 + content/schema.php | 6 + content/test/AllTests.php | 67 +++ content/test/Tags/TaggerTest.php | 281 ++++++++++++ content/test/fixtures/schema.sql | 69 +++ 31 files changed, 2255 insertions(+) create mode 100644 content/.htaccess create mode 100644 content/app/controllers/TagController.php create mode 100644 content/app/views/Tag/CVS/Entries create mode 100644 content/app/views/Tag/CVS/Repository create mode 100644 content/app/views/Tag/CVS/Root create mode 100644 content/app/views/Tag/CVS/Template create mode 100644 content/app/views/Tag/searchTags.html.php create mode 100644 content/bin/object_add.php create mode 100644 content/bin/object_delete.php create mode 100644 content/bin/tag.php create mode 100644 content/bin/tag_add.php create mode 100644 content/bin/tag_delete.php create mode 100644 content/bin/untag.php create mode 100644 content/config/.cvsignore create mode 100644 content/data/rampage_base.xml create mode 100644 content/data/rampage_tags.xml create mode 100644 content/doc/TODO.txt create mode 100644 content/index.php create mode 100644 content/lib/Objects/Manager.php create mode 100644 content/lib/Objects/Object.php create mode 100644 content/lib/Objects/ObjectMapper.php create mode 100644 content/lib/Tags/Tag.php create mode 100644 content/lib/Tags/TagMapper.php create mode 100644 content/lib/Tags/Tagger.php create mode 100644 content/lib/Types/Manager.php create mode 100644 content/lib/Users/Manager.php create mode 100644 content/lib/base.php create mode 100644 content/schema.php create mode 100644 content/test/AllTests.php create mode 100644 content/test/Tags/TaggerTest.php create mode 100644 content/test/fixtures/schema.sql diff --git a/content/.htaccess b/content/.htaccess new file mode 100644 index 000000000..9340e6954 --- /dev/null +++ b/content/.htaccess @@ -0,0 +1,6 @@ + + RewriteEngine On + RewriteCond %{REQUEST_FILENAME} !-d + RewriteCond %{REQUEST_FILENAME} !-f + RewriteRule ^(.*)$ index.php [QSA,L] + diff --git a/content/app/controllers/TagController.php b/content/app/controllers/TagController.php new file mode 100644 index 000000000..2830bbf11 --- /dev/null +++ b/content/app/controllers/TagController.php @@ -0,0 +1,64 @@ +tagger = new Content_Tagger(); + $this->tagger->setDbAdapter(Horde_Db::getAdapter()); + } + + /** + */ + public function searchTags() + { + $this->tags = $this->tagger->getTags(array( + 'q' => $this->params->q, + 'typeId' => $this->params->typeId, + 'userId' => $this->params->userId, + 'objectId' => $this->params->objectId, + )); + + switch ((string)$this->_request->getFormat()) { + case 'html': + $this->render(); + break; + + case 'json': + default: + $this->renderText(json_encode($this->tags)); + break; + } + } + + public function searchUsers() + { + } + + public function searchObjects() + { + } + + /** + * Add a tag + */ + public function tag() + { + // Enforce POST only + } + + /** + * Remove a tag + */ + public function untag() + { + // Enforce POST only + } + +} diff --git a/content/app/views/Tag/CVS/Entries b/content/app/views/Tag/CVS/Entries new file mode 100644 index 000000000..42982b818 --- /dev/null +++ b/content/app/views/Tag/CVS/Entries @@ -0,0 +1,2 @@ +/searchTags.html.php/1.1/Wed Dec 10 05:58:27 2008// +D diff --git a/content/app/views/Tag/CVS/Repository b/content/app/views/Tag/CVS/Repository new file mode 100644 index 000000000..cb79cc127 --- /dev/null +++ b/content/app/views/Tag/CVS/Repository @@ -0,0 +1 @@ +incubator/content/app/views/Tag diff --git a/content/app/views/Tag/CVS/Root b/content/app/views/Tag/CVS/Root new file mode 100644 index 000000000..06ea18fdd --- /dev/null +++ b/content/app/views/Tag/CVS/Root @@ -0,0 +1 @@ +:ext:mrubinsk@cvs.horde.org:/repository diff --git a/content/app/views/Tag/CVS/Template b/content/app/views/Tag/CVS/Template new file mode 100644 index 000000000..3971591f9 --- /dev/null +++ b/content/app/views/Tag/CVS/Template @@ -0,0 +1,8 @@ + +Bug: +Submitted by: +Merge after: +CVS: ---------------------------------------------------------------------- +CVS: Bug: Fill this in if a listed bug is affected by the change. +CVS: Submitted by: Fill this in if someone else sent in the change. +CVS: Merge after: N [day[s]|week[s]|month[s]] (days assumed by default) diff --git a/content/app/views/Tag/searchTags.html.php b/content/app/views/Tag/searchTags.html.php new file mode 100644 index 000000000..1fa37e010 --- /dev/null +++ b/content/app/views/Tag/searchTags.html.php @@ -0,0 +1,5 @@ + diff --git a/content/bin/object_add.php b/content/bin/object_add.php new file mode 100644 index 000000000..2213c2270 --- /dev/null +++ b/content/bin/object_add.php @@ -0,0 +1,24 @@ + 'int')), + new Horde_Argv_Option('-t', '--type-id', array('type' => 'int')), +); +$parser = new Horde_Argv_Parser(array('optionList' => $options)); +list($opts, $positional) = $parser->parseArgs(); + +if (!$opts->id || !$opts->type_id) { + throw new InvalidArgumentException('id and type-id are both required'); +} + +$m = new Content_ObjectMapper; +$i = $m->create(array('object_name' => $opts->id, + 'type_id' => $opts->type_id, + )); +echo 'Created new object with id ' . $i->object_id . ' for ' . $i->type_id . ':' . $i->object_name . ".\n"; +exit(0); diff --git a/content/bin/object_delete.php b/content/bin/object_delete.php new file mode 100644 index 000000000..71330cd0f --- /dev/null +++ b/content/bin/object_delete.php @@ -0,0 +1,25 @@ + 'int')), +); +$parser = new Horde_Argv_Parser(array('optionList' => $options)); +list($opts, $positional) = $parser->parseArgs(); + +if (!$opts->object_id) { + throw new InvalidArgumentException('object_id is required'); +} + +$m = new Content_ObjectMapper; +if ($m->delete($opts->object_id)) { + echo 'Deleted object with id ' . $opts->object_id . ".\n"; + exit(0); +} else { + echo 'Object #' . $opts->object_id . " not found.\n"; + exit(1); +} diff --git a/content/bin/tag.php b/content/bin/tag.php new file mode 100644 index 000000000..93aa1df5c --- /dev/null +++ b/content/bin/tag.php @@ -0,0 +1,23 @@ + 'int')), + new Horde_Argv_Option('-o', '--object-id', array('type' => 'int')), +); +$parser = new Horde_Argv_Parser(array('optionList' => $options)); +list($opts, $tags) = $parser->parseArgs(); +if (!$opts->user_id || !$opts->object_id) { + throw new InvalidArgumentException('user-id and object-id are both required'); +} +if (!count($tags)) { + throw new InvalidArgumentException('List at least one tag to add.'); +} + +$tagger = new Content_Tagger(); +$tagger->setDbAdapter(Horde_Db::getAdapter()); +$tagger->tag($opts->user_id, $opts->object_id, $tags); +exit(0); diff --git a/content/bin/tag_add.php b/content/bin/tag_add.php new file mode 100644 index 000000000..909e1f8c7 --- /dev/null +++ b/content/bin/tag_add.php @@ -0,0 +1,19 @@ +parseArgs(); +if (!count($tags)) { + throw new InvalidArgumentException('List at least one tag to add.'); +} + +$m = new Content_TagMapper; +foreach ($tags as $tag) { + $t = $m->create(array('tag_name' => $tag)); + echo 'Created new tag with id ' . $t->tag_id . ' and name "' . $t->tag_name . "\".\n"; +} +exit(0); diff --git a/content/bin/tag_delete.php b/content/bin/tag_delete.php new file mode 100644 index 000000000..42d94f352 --- /dev/null +++ b/content/bin/tag_delete.php @@ -0,0 +1,29 @@ +parseArgs(); +if (!count($tags)) { + throw new InvalidArgumentException('List at least tag to delete.'); +} + +$m = new Content_TagMapper; +foreach ($tags as $tag) { + $t = $m->find(Horde_Rdo::FIND_FIRST, array('tag_name' => $tag)); + if (!$t) { + echo "$tag doesn't seem to exist, skipping it.\n"; + continue; + } + if ($t->delete()) { + echo "Delete tag '$tag' (#".$t->tag_id.")\n"; + continue; + } else { + echo "Failed to delete '$tag'\n"; + exit(1); + } +} +exit(0); diff --git a/content/bin/untag.php b/content/bin/untag.php new file mode 100644 index 000000000..ccdd9fa0c --- /dev/null +++ b/content/bin/untag.php @@ -0,0 +1,23 @@ + 'int')), + new Horde_Argv_Option('-i', '--object-id', array('type' => 'int')), +); +$parser = new Horde_Argv_Parser(array('optionList' => $options)); +list($opts, $tags) = $parser->parseArgs(); +if (!$opts->user_id || !$opts->object_id) { + throw new InvalidArgumentException('user-id and object-id are both required'); +} +if (!count($tags)) { + throw new InvalidArgumentException('List at least one tag to remove.'); +} + +$tagger = new Content_Tagger(); +$tagger->setDbAdapter(Horde_Db::getAdapter()); +$tagger->untag($opts->user_id, $opts->object_id, $tags); +exit(0); diff --git a/content/config/.cvsignore b/content/config/.cvsignore new file mode 100644 index 000000000..6f65f87c9 --- /dev/null +++ b/content/config/.cvsignore @@ -0,0 +1 @@ +routes.local.php diff --git a/content/data/rampage_base.xml b/content/data/rampage_base.xml new file mode 100644 index 000000000..60c004b22 --- /dev/null +++ b/content/data/rampage_base.xml @@ -0,0 +1,161 @@ + + + + name + false + false + + + + + + rampage_types + + + + + type_id + integer + 0 + true + 1 + true + 4 + + + + type_name + text + + true + 255 + + + + rampage_types_pKey + true + + type_id + ascending + + + + + rampage_types_type_name + true + + type_name + ascending + + + + + +
+ + + + rampage_objects + + + + + object_id + integer + 0 + true + 1 + true + 4 + + + + object_name + text + + true + 255 + + + + type_id + integer + + true + true + 4 + + + + rampage_objects_pKey + true + + object_id + ascending + + + + + rampage_objects_type_object_name + true + + type_id + ascending + + + object_name + ascending + + + + + +
+ + + + rampage_users + + + + + user_id + integer + 0 + true + 1 + true + 4 + + + + user_name + text + + true + 255 + + + + rampage_users_pKey + true + + user_id + ascending + + + + + rampage_users_user_name + true + + user_name + ascending + + + + + +
+ +
diff --git a/content/data/rampage_tags.xml b/content/data/rampage_tags.xml new file mode 100644 index 000000000..a6b89dd2d --- /dev/null +++ b/content/data/rampage_tags.xml @@ -0,0 +1,236 @@ + + + + name + false + false + + + + + + rampage_tags + + + + + tag_id + integer + 0 + true + 1 + true + 4 + + + + tag_name + text + + true + 255 + + + + rampage_tags_pKey + true + + tag_id + ascending + + + + + rampage_tags_tag_name + true + + tag_name + ascending + + + + + +
+ + + + rampage_tagged + + + + + user_id + integer + + true + true + 4 + + + + object_id + integer + + true + true + 4 + + + + tag_id + integer + + true + true + 4 + + + + created + timestamp + + false + + + + rampage_tagged_object_id + + object_id + ascending + + + + + rampage_tagged_tag_id + + tag_id + ascending + + + + + rampage_tagged_created + + created + ascending + + + + + rampage_tagged_pKey + true + + user_id + ascending + + + object_id + ascending + + + tag_id + ascending + + + + + +
+ + + + rampage_tag_stats + + + + + tag_id + integer + + true + true + 4 + + + + count + integer + + true + true + 4 + + + + rampage_tag_stats_pKey + true + + tag_id + ascending + + + + + +
+ + + + rampage_user_tag_stats + + + + + user_id + integer + + true + true + 4 + + + + tag_id + integer + + true + true + 4 + + + + count + integer + + true + true + 4 + + + + rampage_user_tag_stats_tag_id + + tag_id + ascending + + + + + rampage_user_tag_stats_pKey + true + + user_id + ascending + + + tag_id + ascending + + + + + +
+ +
diff --git a/content/doc/TODO.txt b/content/doc/TODO.txt new file mode 100644 index 000000000..59eef525e --- /dev/null +++ b/content/doc/TODO.txt @@ -0,0 +1,11 @@ +- Tagger todos: + - add missing tests + - finish similar objects method + - duplicate insert detection in tag() + - add routes and controllers for objects and users + - some sort of db_select object for more reliable sql generation + +- implement split read/write database support through Horde_Db, also allowing + for sharding, multi-master, backup connections, etc. Probably will take a + Horde_Db_ConnectionManager object or some such, that can implement logic for + which connection to use. diff --git a/content/index.php b/content/index.php new file mode 100644 index 000000000..8f57ebec2 --- /dev/null +++ b/content/index.php @@ -0,0 +1,19 @@ + $mapper, + 'controllerDir' => $CONTENT_DIR . '/app/controllers', + 'viewsDir' => $CONTENT_DIR . '/app/views', + // 'logger' => '', +); + +$dispatcher = Horde_Controller_Dispatcher::singleton($context); +$dispatcher->dispatch($request); diff --git a/content/lib/Objects/Manager.php b/content/lib/Objects/Manager.php new file mode 100644 index 000000000..a0aba847f --- /dev/null +++ b/content/lib/Objects/Manager.php @@ -0,0 +1,152 @@ + + * @author Michael Rubinsky + * @license http://opensource.org/licenses/bsd-license.php BSD + * @category Horde + * @package Horde_Content + */ +class Content_Objects_Manager { + + protected $_db; + + /** + * Tables + * + * @TODO: this should probably be populated by the responsible manager... + * @var array + */ + protected $_tables = array( + 'objects' => 'rampage_objects', + ); + + protected $_typeManager; + + public function __construct($adapter, $params = array()) + { + $this->_db = $adapter; + + if (!empty($params['type_manager'])) { + $this->_typeManager = $params['type_manager']; + } else { + $this->_typeManager = new Content_Types_Manager(array('db_adapter' => $this->_db)); + } + } + +// /** +// * +// * @param Horde_Db $db The database connection +// */ +// 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; + } + + /** + * Check for object existence without causing the objects to be created. + * Helps save queries for things like tags when we already know the object + * doesn't yet exist in rampage tables. + * + */ + public function exists($object, $type) + { + $type = array_pop($this->_typeManager->ensureTypes($type)); + $id = $this->_db->selectValue('SELECT object_id FROM ' . $this->_t('objects') . ' WHERE object_name = ' . $this->_db->quote($object) . ' AND type_id = ' . $type); + if ($id) { + return (int)$id; + } + + return false; + } + + /** + * Ensure that an array of objects exist in storage. Create any that don't, + * return object_ids for all. All objects in the $objects array must be + * of the same content type. + * + * @param array $objects An array of objects. Values typed as an integer + * are assumed to already be an object_id. + * @param mixed $type Either a string type_name or integer type_id + * + * @return array An array of object_ids. + */ + public function ensureObjects($objects, $type) + { + if (!is_array($objects)) { + $objects = array($objects); + } + + $objectIds = array(); + $objectName = array(); + + $type = array_pop($this->_typeManager->ensureTypes($type)); + + // Anything already typed as an integer is assumed to be a object id. + foreach ($objects as $objectIndex => $object) { + if (is_int($object)) { + $objectIds[$objectIndex] = $object; + } else { + $objectName[$object] = $objectIndex; + } + } + + // Get the ids for any objects that already exist. + if (count($objectName)) { + foreach ($this->_db->selectAll('SELECT object_id, object_name FROM ' . $this->_t('objects') + . ' WHERE object_name IN (' . implode(',', array_map(array($this->_db, 'quote'), array_keys($objectName))) + . ') AND type_id = ' . $type) as $row) { + + $objectIndex = $objectName[$row['object_name']]; + unset($objectName[$row['object_name']]); + $objectIds[$objectIndex] = $row['object_id']; + } + } + + // Create any objects that didn't already exist + foreach ($objectName as $object => $objectIndex) { + $objectIds[$objectIndex] = $this->_db->insert('INSERT INTO ' . $this->_t('objects') . ' (object_name, type_id) VALUES (' . $this->_db->quote($object) . ', ' . $type . ')'); + } + + return $objectIds; + + } + + /** + * @TODO Hmmm, do we do this here, because we will have to remove all + * content linked to the object? + * + * @param array $object An array of objects to remove. Values typed as an + * integer are taken to be object_ids, otherwise, + * the value is taken as an object_name. + */ + public function removeObjects($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]); + } + +} +?> \ No newline at end of file diff --git a/content/lib/Objects/Object.php b/content/lib/Objects/Object.php new file mode 100644 index 000000000..33dc2b066 --- /dev/null +++ b/content/lib/Objects/Object.php @@ -0,0 +1,19 @@ + + * @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 + */ +class Content_Object extends Horde_Rdo_Base +{ +} diff --git a/content/lib/Objects/ObjectMapper.php b/content/lib/Objects/ObjectMapper.php new file mode 100644 index 000000000..a1a250576 --- /dev/null +++ b/content/lib/Objects/ObjectMapper.php @@ -0,0 +1,24 @@ + + * @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 + */ +class Content_ObjectMapper extends Horde_Rdo_Mapper +{ + /** + * Inflector doesn't support Horde-style tables yet + */ + protected $_table = 'rampage_objects'; + +} diff --git a/content/lib/Tags/Tag.php b/content/lib/Tags/Tag.php new file mode 100644 index 000000000..0cc4e55fc --- /dev/null +++ b/content/lib/Tags/Tag.php @@ -0,0 +1,19 @@ + + * @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 + */ +class Content_Tag extends Horde_Rdo_Base +{ +} diff --git a/content/lib/Tags/TagMapper.php b/content/lib/Tags/TagMapper.php new file mode 100644 index 000000000..4234a56fe --- /dev/null +++ b/content/lib/Tags/TagMapper.php @@ -0,0 +1,24 @@ + + * @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 + */ +class Content_TagMapper extends Horde_Rdo_Mapper +{ + /** + * Inflector doesn't support Horde-style tables yet + */ + protected $_table = 'rampage_tags'; + +} diff --git a/content/lib/Tags/Tagger.php b/content/lib/Tags/Tagger.php new file mode 100644 index 000000000..a1e07789a --- /dev/null +++ b/content/lib/Tags/Tagger.php @@ -0,0 +1,691 @@ + + * @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 + * + * @TODO: Should we actually build this array from our composed managers? + * @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', + ); + + // Content managers...I guess these should be public, client code might + // have a use for them. Maybe use __get instead to make sure they can't + // be set though... + public $userManager; + public $typeManager; + public $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($adapter, $params = array()) + { + $this->_db = $adapter; + + if (!empty($params['user_manager'])) { + $this->userManager = $params['user_manager']; + } else { + $this->userManager = new Content_Users_Manager($this->_db); + } + + if (!empty($params['type_manager'])) { + $this->typeManager = $params['type_manager']; + } else { + $this->typeManager = new Content_Types_Manager($this->_db); + } + + if (!empty($params['object_manager'])) { + $this->objectManager = $params['object_manager']; + } else { + $this->objectManager = new Content_Objects_Manager( + $this->_db, array('type_manager' => $this->typeManager)); + } + + } + +// /** +// * Set the database connection for the tagger. +// * @TODO: Should we propogate this to any managers we may have?? +// * @TODO: Do we even need this method now that it's in the constructor?? +// * @param Horde_Db $db +// */ +// public function setDbAdapter($db) +// { +// $this->_db = $db; +// } + + /** + * Change the name of a database table. + * @TODO: Need to propagate these changes to our other managers...or not?? + * + * @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 as simply returned. + */ + private 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/Types/Manager.php b/content/lib/Types/Manager.php new file mode 100644 index 000000000..c11754ec5 --- /dev/null +++ b/content/lib/Types/Manager.php @@ -0,0 +1,118 @@ + + * @author Michael Rubinsky + * @license http://opensource.org/licenses/bsd-license.php BSD + * @category Horde + * @package Horde_Content + */ +class Content_Types_Manager { + + protected $_db; + + /** + * Tables + * @var array + */ + protected $_tables = array( + 'types' => 'rampage_types', + ); + + public function __construct($adapter, $params = array()) + { + $this->_db = $adapter; + } + +// /** +// * +// * @param Horde_Db $db The database connection +// */ +// 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; + } + + /** + * Ensure that an array of types exist in storage. Create any that don't, + * return type_ids for all. + * + * @param array $types An array of types. Values typed as an integer + * are assumed to already be an type_id. + * + * @return array An array of type_ids. + */ + public function ensureTypes($types) + { + if (!is_array($types)) { + $types = array($types); + } + + $typeIds = array(); + $typeName = array(); + + // Anything already typed as an integer is assumed to be a type id. + foreach ($types as $typeIndex => $type) { + if (is_int($type)) { + $typeIds[$typeIndex] = $type; + } else { + $typeName[$type] = $typeIndex; + } + } + + // Get the ids for any types that already exist. + if (count($typeName)) { + foreach ($this->_db->selectAll('SELECT type_id, type_name FROM ' . $this->_t('types') . ' WHERE type_name IN ('.implode(',', array_map(array($this->_db, 'quote'), array_keys($typeName))).')') as $row) { + $typeIndex = $typeName[$row['type_name']]; + unset($typeName[$row['type_name']]); + $typeIds[$typeIndex] = (int)$row['type_id']; + } + } + + // Create any types that didn't already exist + foreach ($typeName as $type => $typeIndex) { + $typeIds[$typeIndex] = $this->_db->insert('INSERT INTO ' . $this->_t('types') . ' (type_name) VALUES (' . $this->_db->quote($type) . ')'); + } + + return $typeIds; + + } + + /** + * @TODO Hmmm, do we do this here, because we will have to remove all + * content linked to the type? + * + * @param array $type An array of types to remove. Values typed as an + * integer are taken to be type_ids, otherwise, + * the value is taken as an type_name. + */ + public function removetypes($type) + { + } + + /** + * 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]); + } + +} +?> \ No newline at end of file diff --git a/content/lib/Users/Manager.php b/content/lib/Users/Manager.php new file mode 100644 index 000000000..1525e0ea4 --- /dev/null +++ b/content/lib/Users/Manager.php @@ -0,0 +1,118 @@ + + * @author Michael Rubinsky + * @license http://opensource.org/licenses/bsd-license.php BSD + * @category Horde + * @package Horde_Content + */ +class Content_Users_Manager { + + protected $_db; + + /** + * Tables + * @var array + */ + protected $_tables = array( + 'users' => 'rampage_users', + ); + + public function __construct($adapter, $params = array()) + { + $this->_db = $adapter; + } + +// /** +// * +// * @param Horde_Db $db The database connection +// */ +// 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; + } + + /** + * Ensure that an array of users exist in storage. Create any that don't, + * return user_ids for all. + * + * @param array $users An array of users. Values typed as an integer + * are assumed to already be an user_id. + * + * @return array An array of user_ids. + */ + public function ensureUsers($users) + { + if (!is_array($users)) { + $users = array($users); + } + + $userIds = array(); + $userName = array(); + + // Anything already typed as an integer is assumed to be a user id. + foreach ($users as $userIndex => $user) { + if (is_int($user)) { + $userIds[$userIndex] = $user; + } else { + $userName[$user] = $userIndex; + } + } + + // Get the ids for any users that already exist. + if (count($userName)) { + foreach ($this->_db->selectAll('SELECT user_id, user_name FROM ' . $this->_t('users') . ' WHERE user_name IN ('.implode(',', array_map(array($this->_db, 'quote'), array_keys($userName))).')') as $row) { + $userIndex = $userName[$row['user_name']]; + unset($userName[$row['user_name']]); + $userIds[$userIndex] = $row['user_id']; + } + } + + // Create any users that didn't already exist + foreach ($userName as $user => $userIndex) { + $userIds[$userIndex] = $this->_db->insert('INSERT INTO ' . $this->_t('users') . ' (user_name) VALUES (' . $this->_db->quote($user) . ')'); + } + + return $userIds; + + } + + /** + * @TODO Hmmm, do we do this here, because we will have to remove all + * content linked to the user? + * + * @param array $user An array of users to remove. Values typed as an + * integer are taken to be user_ids, otherwise, + * the value is taken as an user_name. + */ + public function removeusers($user) + { + } + + /** + * 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]); + } + +} +?> \ No newline at end of file diff --git a/content/lib/base.php b/content/lib/base.php new file mode 100644 index 000000000..448f7a04f --- /dev/null +++ b/content/lib/base.php @@ -0,0 +1,9 @@ + \ No newline at end of file diff --git a/content/test/AllTests.php b/content/test/AllTests.php new file mode 100644 index 000000000..eff87e5e6 --- /dev/null +++ b/content/test/AllTests.php @@ -0,0 +1,67 @@ +isFile() && preg_match('/Test.php$/', $file->getFilename())) { + $pathname = $file->getPathname(); + require $pathname; + + $class = str_replace(DIRECTORY_SEPARATOR, '_', + preg_replace("/^$baseregexp(.*)\.php/", '\\1', $pathname)); + $suite->addTestSuite('Content_' . $class); + } + } + + return $suite; + } + +} + +if (PHPUnit_MAIN_METHOD == 'Content_AllTests::main') { + Content_AllTests::main(); +} diff --git a/content/test/Tags/TaggerTest.php b/content/test/Tags/TaggerTest.php new file mode 100644 index 000000000..1cdc2194a --- /dev/null +++ b/content/test/Tags/TaggerTest.php @@ -0,0 +1,281 @@ + + * @category Horde + * @package Content + * @subpackage UnitTests + */ + +/** + * @author Chuck Hagenbuch + * @category Horde + * @package Content + * @subpackage UnitTests + */ +class Content_Tags_TaggerTest extends PHPUnit_Framework_TestCase +{ + protected function setUp() + { + $this->db = Horde_Db_Adapter::factory(array( + 'adapter' => 'pdo_sqlite', + 'dbname' => ':memory:', + )); + + // Create tagger + $this->tagger = new Content_Tagger(); + $this->tagger->setDbAdapter($this->db); + + // Read sql schema file + $statements = array(); + $current_stmt = ''; + $fp = fopen(dirname(__FILE__) . '/../fixtures/schema.sql', 'r'); + while ($line = fgets($fp, 8192)) { + $line = rtrim(preg_replace('/^(.*)--.*$/s', '\1', $line)); + if (!$line) { + continue; + } + + $current_stmt .= $line; + + if (substr($line, -1) == ';') { + // leave off the ending ; + $statements[] = substr($current_stmt, 0, -1); + $current_stmt = ''; + } + } + + // Run statements + foreach ($statements as $stmt) { + $this->db->execute($stmt); + } + } + + public function testSplitTags() + { + $this->assertEquals(array('this', 'somecompany, llc', 'and "this" w,o.rks', 'foo bar'), + $this->tagger->splitTags('this, "somecompany, llc", "and ""this"" w,o.rks", foo bar')); + } + + public function testEnsureTags() + { + $this->assertEquals(array(1), $this->tagger->ensureTags(1)); + $this->assertEquals(array(1), $this->tagger->ensureTags(array(1))); + $this->assertEquals(array(1), $this->tagger->ensureTags('work')); + $this->assertEquals(array(1), $this->tagger->ensureTags(array('work'))); + + $this->assertEquals(array(1, 2), $this->tagger->ensureTags(array(1, 2))); + $this->assertEquals(array(1, 2), $this->tagger->ensureTags(array(1, 'play'))); + $this->assertEquals(array(1, 2), $this->tagger->ensureTags(array('work', 2))); + $this->assertEquals(array(1, 2), $this->tagger->ensureTags(array('work', 'play'))); + } + + public function testFullTagCloudSimple() + { + $this->assertEquals(array(), $this->tagger->getTagCloud()); + + $this->tagger->tag(1, 1, 1); + $cloud = $this->tagger->getTagCloud(); + $this->assertEquals(1, $cloud[0]['tag_id']); + $this->assertEquals('work', $cloud[0]['tag_text']); + $this->assertEquals(1, $cloud[0]['count']); + } + /* +// var_dump($tagger->getTagIds(1)); + +// $tagger->tag(1, 3, 3); +// $tagger->untag(1, 3, 3); + +var_dump($tagger->getRelatedObjects(1)); +var_dump($tagger->getRelatedObjects(2)); +var_dump($tagger->getRelatedObjects(3)); + +// var_dump($tagger->getTagCloud()); +*/ + + public function testGetRecentTags() + { + $this->assertEquals(array(), $this->tagger->getRecentTags()); + + $this->tagger->tag(1, 1, 1, new Horde_Date('2008-01-01T00:00:00')); + $this->tagger->tag(2, 1, 1, new Horde_Date('2007-01-01T00:00:00')); + + $recent = $this->tagger->getRecentTags(); + $this->assertEquals(1, count($recent)); + $this->assertEquals(1, $recent[0]['tag_id']); + $this->assertEquals('2008-01-01T00:00:00', $recent[0]['created']); + } + + public function testGetRecentTagsLimit() + { + // Create 100 tags on 100 tag_ids, with tag_id = t1 being applied + // most recently, and so on. Prepend "t" to each tag to force the + // creation of tags that don't yet exist in the test database. + for ($i = 1; $i <= 100; $i++) { + $this->tagger->tag(1, 1, "t$i", new Horde_Date(strtotime('now - ' . $i . ' minutes'))); + } + + $recentLimit = $this->tagger->getRecentTags(array('limit' => 25)); + $this->assertEquals(25, count($recentLimit)); + $this->assertEquals('t1', $recentLimit[0]['tag_text']); + } + + public function testGetRecentTagsOffset() + { + // Create 100 tags on 100 tag_ids, with tag_id = t1 being applied + // most recently, and so on. Prepend "t" to each tag to force the + // creation of tags that don't yet exist in the test database. + for ($i = 1; $i <= 100; $i++) { + $this->tagger->tag(1, 1, "t$i", new Horde_Date(strtotime('now - ' . $i . ' minutes'))); + } + + $recentOffset = $this->tagger->getRecentTags(array('limit' => 25, 'offset' => 25)); + $this->assertEquals(25, count($recentOffset)); + $this->assertEquals('t26', $recentOffset[0]['tag_text']); + } + + public function testGetRecentTagsByUser() + { + $this->tagger->tag(1, 1, 1); + + $recent = $this->tagger->getRecentTags(); + $recentByUser = $this->tagger->getRecentTags(array('userId' => 1)); + $this->assertEquals(1, count($recentByUser)); + $this->assertEquals($recent, $recentByUser); + + $recent = $this->tagger->getRecentTags(array('userId' => 2)); + $this->assertEquals(0, count($recent)); + } + + public function testGetRecentTagsByType() + { + $this->tagger->tag(1, 1, 1); + + $recent = $this->tagger->getRecentTags(); + $recentByType = $this->tagger->getRecentTags(array('typeId' => 1)); + $this->assertEquals(1, count($recentByType)); + $this->assertEquals($recent, $recentByType); + + $recent = $this->tagger->getRecentTags(array('typeId' => 2)); + $this->assertEquals(0, count($recent)); + } + + public function testGetRecentObjects() + { + $this->assertEquals(array(), $this->tagger->getRecentObjects()); + + $this->tagger->tag(1, 1, 1, new Horde_Date('2008-01-01T00:00:00')); + $this->tagger->tag(2, 1, 1, new Horde_Date('2007-01-01T00:00:00')); + + $recent = $this->tagger->getRecentObjects(); + $this->assertEquals(1, count($recent)); + $this->assertEquals(1, $recent[0]['object_id']); + $this->assertEquals('2008-01-01T00:00:00', $recent[0]['created']); + } + + public function testGetRecentObjectsLimit() + { + // Create 100 tags on 100 object_ids, with object_id = 1 being tagged + // most recently, and so on. + for ($i = 1; $i <= 100; $i++) { + $this->tagger->tag(1, $i, 1, new Horde_Date(strtotime('now - ' . $i . ' minutes'))); + } + + $recentLimit = $this->tagger->getRecentObjects(array('limit' => 25)); + $this->assertEquals(25, count($recentLimit)); + $this->assertEquals(1, $recentLimit[0]['object_id']); + } + + public function testGetRecentObjectsOffset() + { + // Create 100 tags on 100 object_ids, with object_id = 1 being tagged + // most recently, and so on. + for ($i = 1; $i <= 100; $i++) { + $this->tagger->tag(1, $i, 1, new Horde_Date(strtotime('now - ' . $i . ' minutes'))); + } + + $recentOffset = $this->tagger->getRecentObjects(array('limit' => 25, 'offset' => 25)); + $this->assertEquals(25, count($recentOffset)); + $this->assertEquals(26, $recentOffset[0]['object_id']); + } + + public function testGetRecentObjectsByUser() + { + $this->tagger->tag(1, 1, 1); + + $recent = $this->tagger->getRecentObjects(); + $recentByUser = $this->tagger->getRecentObjects(array('userId' => 1)); + $this->assertEquals(1, count($recentByUser)); + $this->assertEquals($recent, $recentByUser); + + $recent = $this->tagger->getRecentObjects(array('userId' => 2)); + $this->assertEquals(0, count($recent)); + } + + public function testGetRecentObjectsByType() + { + $this->tagger->tag(1, 1, 1); + + $recent = $this->tagger->getRecentObjects(); + $recentByType = $this->tagger->getRecentObjects(array('typeId' => 1)); + $this->assertEquals(1, count($recentByType)); + $this->assertEquals($recent, $recentByType); + + $recent = $this->tagger->getRecentObjects(array('typeId' => 2)); + $this->assertEquals(0, count($recent)); + } + + public function testGetRecentUsers() + { + $this->assertEquals(array(), $this->tagger->getRecentUsers()); + + $this->tagger->tag(1, 1, 1, new Horde_Date('2008-01-01T00:00:00')); + $this->tagger->tag(1, 2, 1, new Horde_Date('2007-01-01T00:00:00')); + + $recent = $this->tagger->getRecentUsers(); + $this->assertEquals(1, count($recent)); + $this->assertEquals(1, $recent[0]['user_id']); + $this->assertEquals('2008-01-01T00:00:00', $recent[0]['created']); + } + + public function testGetRecentUsersLimit() + { + // Create 100 tags by 100 user_ids, with user_id = 1 tagging + // most recently, and so on. + for ($i = 1; $i <= 100; $i++) { + $this->tagger->tag($i, 1, 1, new Horde_Date(strtotime('now - ' . $i . ' minutes'))); + } + + $recentLimit = $this->tagger->getRecentUsers(array('limit' => 25)); + $this->assertEquals(25, count($recentLimit)); + $this->assertEquals(1, $recentLimit[0]['user_id']); + } + + public function testGetRecentUsersOffset() + { + // Create 100 tags by 100 user_ids, with user_id = 1 tagging + // most recently, and so on. + for ($i = 1; $i <= 100; $i++) { + $this->tagger->tag($i, 1, 1, new Horde_Date(strtotime('now - ' . $i . ' minutes'))); + } + + $recentOffset = $this->tagger->getRecentUsers(array('limit' => 25, 'offset' => 25)); + $this->assertEquals(25, count($recentOffset)); + $this->assertEquals(26, $recentOffset[0]['user_id']); + } + + public function testGetRecentUsersByType() + { + $this->tagger->tag(1, 1, 1); + + $recent = $this->tagger->getRecentUsers(); + $recentByType = $this->tagger->getRecentUsers(array('typeId' => 1)); + $this->assertEquals(1, count($recentByType)); + $this->assertEquals($recent, $recentByType); + + $recent = $this->tagger->getRecentUsers(array('typeId' => 2)); + $this->assertEquals(0, count($recent)); + } + +} diff --git a/content/test/fixtures/schema.sql b/content/test/fixtures/schema.sql new file mode 100644 index 000000000..5515434ac --- /dev/null +++ b/content/test/fixtures/schema.sql @@ -0,0 +1,69 @@ +CREATE TABLE rampage_objects ( + object_id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + object_name varchar(255) NOT NULL, + type_id INTEGER NOT NULL +); +CREATE UNIQUE INDEX rampage_objects_type_object_name ON rampage_objects (type_id, object_name); + +CREATE TABLE rampage_types ( + type_id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + type_name varchar(255) NOT NULL +); +CREATE UNIQUE INDEX rampage_types_type_name ON rampage_types (type_name); + +CREATE TABLE rampage_users ( + user_id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + user_username varchar(255) NOT NULL +); +CREATE UNIQUE INDEX rampage_users_user_username ON rampage_users (user_username); + +CREATE TABLE rampage_tags ( + tag_id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + tag_text varchar(255) NOT NULL +); +CREATE UNIQUE INDEX rampage_tags_tag_text ON rampage_tags (tag_text); + +CREATE TABLE rampage_tagged ( + user_id INTEGER NOT NULL, + object_id INTEGER NOT NULL, + tag_id INTEGER NOT NULL, + created datetime default NULL, + PRIMARY KEY (user_id, object_id, tag_id) +); +CREATE INDEX rampage_tagged_object_id ON rampage_tagged (object_id); +CREATE INDEX rampage_tagged_tag_id ON rampage_tagged (tag_id); +CREATE INDEX rampage_tagged_created ON rampage_tagged (created); + +CREATE TABLE rampage_tag_stats ( + tag_id INTEGER NOT NULL, + count INTEGER NOT NULL, + PRIMARY KEY (tag_id) +); + +CREATE TABLE rampage_user_tag_stats ( + user_id INTEGER NOT NULL, + tag_id INTEGER NOT NULL, + count INTEGER NOT NULL, + PRIMARY KEY (user_id, tag_id) +); +CREATE INDEX rampage_user_tag_stats_tag_id ON rampage_user_tag_stats (tag_id); + + +-- +-- Set up some initial types, objects, users, and tags +-- + +INSERT INTO rampage_types (type_id, type_name) VALUES (1, 'event'); +INSERT INTO rampage_types (type_id, type_name) VALUES (2, 'blog'); + +INSERT INTO rampage_objects (object_id, object_name, type_id) VALUES (1, 'party', 1); +INSERT INTO rampage_objects (object_id, object_name, type_id) VALUES (2, 'office hours', 1); +INSERT INTO rampage_objects (object_id, object_name, type_id) VALUES (3, 'huffington post', 2); +INSERT INTO rampage_objects (object_id, object_name, type_id) VALUES (4, 'daring fireball', 2); + +INSERT INTO rampage_users (user_id, user_username) VALUES (1, 'alice'); +INSERT INTO rampage_users (user_id, user_username) VALUES (2, 'bob'); + +INSERT INTO rampage_tags (tag_id, tag_text) VALUES (1, 'work'); +INSERT INTO rampage_tags (tag_id, tag_text) VALUES (2, 'play'); +INSERT INTO rampage_tags (tag_id, tag_text) VALUES (3, 'apple'); -- 2.11.0