Add Content from the incubator
authorMichael J. Rubinsky <mrubinsk@horde.org>
Sat, 20 Dec 2008 21:40:31 +0000 (16:40 -0500)
committerMichael J. Rubinsky <mrubinsk@horde.org>
Sat, 20 Dec 2008 21:40:31 +0000 (16:40 -0500)
31 files changed:
content/.htaccess [new file with mode: 0644]
content/app/controllers/TagController.php [new file with mode: 0644]
content/app/views/Tag/CVS/Entries [new file with mode: 0644]
content/app/views/Tag/CVS/Repository [new file with mode: 0644]
content/app/views/Tag/CVS/Root [new file with mode: 0644]
content/app/views/Tag/CVS/Template [new file with mode: 0644]
content/app/views/Tag/searchTags.html.php [new file with mode: 0644]
content/bin/object_add.php [new file with mode: 0644]
content/bin/object_delete.php [new file with mode: 0644]
content/bin/tag.php [new file with mode: 0644]
content/bin/tag_add.php [new file with mode: 0644]
content/bin/tag_delete.php [new file with mode: 0644]
content/bin/untag.php [new file with mode: 0644]
content/config/.cvsignore [new file with mode: 0644]
content/data/rampage_base.xml [new file with mode: 0644]
content/data/rampage_tags.xml [new file with mode: 0644]
content/doc/TODO.txt [new file with mode: 0644]
content/index.php [new file with mode: 0644]
content/lib/Objects/Manager.php [new file with mode: 0644]
content/lib/Objects/Object.php [new file with mode: 0644]
content/lib/Objects/ObjectMapper.php [new file with mode: 0644]
content/lib/Tags/Tag.php [new file with mode: 0644]
content/lib/Tags/TagMapper.php [new file with mode: 0644]
content/lib/Tags/Tagger.php [new file with mode: 0644]
content/lib/Types/Manager.php [new file with mode: 0644]
content/lib/Users/Manager.php [new file with mode: 0644]
content/lib/base.php [new file with mode: 0644]
content/schema.php [new file with mode: 0644]
content/test/AllTests.php [new file with mode: 0644]
content/test/Tags/TaggerTest.php [new file with mode: 0644]
content/test/fixtures/schema.sql [new file with mode: 0644]

diff --git a/content/.htaccess b/content/.htaccess
new file mode 100644 (file)
index 0000000..9340e69
--- /dev/null
@@ -0,0 +1,6 @@
+<IfModule mod_rewrite.c>
+    RewriteEngine On
+    RewriteCond   %{REQUEST_FILENAME}  !-d
+    RewriteCond   %{REQUEST_FILENAME}  !-f
+    RewriteRule ^(.*)$ index.php [QSA,L]
+</IfModule>
diff --git a/content/app/controllers/TagController.php b/content/app/controllers/TagController.php
new file mode 100644 (file)
index 0000000..2830bbf
--- /dev/null
@@ -0,0 +1,64 @@
+<?php
+/**
+ * @package content
+ */
+class TagController extends Horde_Controller_Base
+{
+    /**
+     */
+    public function __construct($options)
+    {
+        parent::__construct($options);
+
+        $this->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 (file)
index 0000000..42982b8
--- /dev/null
@@ -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 (file)
index 0000000..cb79cc1
--- /dev/null
@@ -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 (file)
index 0000000..06ea18f
--- /dev/null
@@ -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 (file)
index 0000000..3971591
--- /dev/null
@@ -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 (file)
index 0000000..1fa37e0
--- /dev/null
@@ -0,0 +1,5 @@
+<ul>
+<?php foreach ($this->tags as $tag_id => $tag_text): ?>
+ <li value="<?= $this->escape($tag_id) ?>"><?= $this->escape($tag_text) ?></li>
+<?php endforeach ?>
+</ul>
diff --git a/content/bin/object_add.php b/content/bin/object_add.php
new file mode 100644 (file)
index 0000000..2213c22
--- /dev/null
@@ -0,0 +1,24 @@
+<?php
+
+define('AUTH_HANDLER', true);
+require dirname(__FILE__) . '/../lib/base.php';
+require $CONTENT_DIR . 'lib/Objects/Object.php';
+require $CONTENT_DIR . 'lib/Objects/ObjectMapper.php';
+
+$options = array(
+    new Horde_Argv_Option('-i', '--id', array('type' => '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 (file)
index 0000000..71330cd
--- /dev/null
@@ -0,0 +1,25 @@
+<?php
+
+define('AUTH_HANDLER', true);
+require dirname(__FILE__) . '/../lib/base.php';
+require $CONTENT_DIR . 'lib/Objects/Object.php';
+require $CONTENT_DIR . 'lib/Objects/ObjectMapper.php';
+
+$options = array(
+    new Horde_Argv_Option('-m', '--object-id', array('type' => '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 (file)
index 0000000..93aa1df
--- /dev/null
@@ -0,0 +1,23 @@
+<?php
+
+define('AUTH_HANDLER', true);
+require dirname(__FILE__) . '/../lib/base.php';
+require $CONTENT_DIR . 'lib/Tags/Tagger.php';
+
+$options = array(
+    new Horde_Argv_Option('-u', '--user-id', array('type' => '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 (file)
index 0000000..909e1f8
--- /dev/null
@@ -0,0 +1,19 @@
+<?php
+
+define('AUTH_HANDLER', true);
+require dirname(__FILE__) . '/../lib/base.php';
+require $CONTENT_DIR . 'lib/Tags/Tag.php';
+require $CONTENT_DIR . 'lib/Tags/TagMapper.php';
+
+$parser = new Horde_Argv_Parser();
+list($opts, $tags) = $parser->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 (file)
index 0000000..42d94f3
--- /dev/null
@@ -0,0 +1,29 @@
+<?php
+
+define('AUTH_HANDLER', true);
+require dirname(__FILE__) . '/../lib/base.php';
+require $CONTENT_DIR . 'lib/Tags/Tag.php';
+require $CONTENT_DIR . 'lib/Tags/TagMapper.php';
+
+$parser = new Horde_Argv_Parser();
+list($opts, $tags) = $parser->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 (file)
index 0000000..ccdd9fa
--- /dev/null
@@ -0,0 +1,23 @@
+<?php
+
+define('AUTH_HANDLER', true);
+require dirname(__FILE__) . '/../lib/base.php';
+require $CONTENT_DIR . 'lib/Tags/Tagger.php';
+
+$options = array(
+    new Horde_Argv_Option('-u', '--user-id', array('type' => '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 (file)
index 0000000..6f65f87
--- /dev/null
@@ -0,0 +1 @@
+routes.local.php
diff --git a/content/data/rampage_base.xml b/content/data/rampage_base.xml
new file mode 100644 (file)
index 0000000..60c004b
--- /dev/null
@@ -0,0 +1,161 @@
+<?xml version="1.0" encoding="ISO-8859-1" ?>
+<database>
+
+ <name><variable>name</variable></name>
+ <create>false</create>
+ <overwrite>false</overwrite>
+
+ <charset></charset>
+
+ <table>
+
+  <name>rampage_types</name>
+
+  <declaration>
+
+   <field>
+    <name>type_id</name>
+    <type>integer</type>
+    <default>0</default>
+    <notnull>true</notnull>
+    <autoincrement>1</autoincrement>
+    <unsigned>true</unsigned>
+    <length>4</length>
+   </field>
+
+   <field>
+    <name>type_name</name>
+    <type>text</type>
+    <default></default>
+    <notnull>true</notnull>
+    <length>255</length>
+   </field>
+
+   <index>
+    <name>rampage_types_pKey</name>
+    <primary>true</primary>
+    <field>
+     <name>type_id</name>
+     <sorting>ascending</sorting>
+    </field>
+   </index>
+
+   <index>
+    <name>rampage_types_type_name</name>
+    <unique>true</unique>
+    <field>
+     <name>type_name</name>
+     <sorting>ascending</sorting>
+    </field>
+   </index>
+
+  </declaration>
+
+ </table>
+
+ <table>
+
+  <name>rampage_objects</name>
+
+  <declaration>
+
+   <field>
+    <name>object_id</name>
+    <type>integer</type>
+    <default>0</default>
+    <notnull>true</notnull>
+    <autoincrement>1</autoincrement>
+    <unsigned>true</unsigned>
+    <length>4</length>
+   </field>
+
+   <field>
+    <name>object_name</name>
+    <type>text</type>
+    <default></default>
+    <notnull>true</notnull>
+    <length>255</length>
+   </field>
+
+   <field>
+    <name>type_id</name>
+    <type>integer</type>
+    <default></default>
+    <notnull>true</notnull>
+    <unsigned>true</unsigned>
+    <length>4</length>
+   </field>
+
+   <index>
+    <name>rampage_objects_pKey</name>
+    <primary>true</primary>
+    <field>
+     <name>object_id</name>
+     <sorting>ascending</sorting>
+    </field>
+   </index>
+
+   <index>
+    <name>rampage_objects_type_object_name</name>
+    <unique>true</unique>
+    <field>
+     <name>type_id</name>
+     <sorting>ascending</sorting>
+    </field>
+    <field>
+     <name>object_name</name>
+     <sorting>ascending</sorting>
+    </field>
+   </index>
+
+  </declaration>
+
+ </table>
+
+ <table>
+
+  <name>rampage_users</name>
+
+  <declaration>
+
+   <field>
+    <name>user_id</name>
+    <type>integer</type>
+    <default>0</default>
+    <notnull>true</notnull>
+    <autoincrement>1</autoincrement>
+    <unsigned>true</unsigned>
+    <length>4</length>
+   </field>
+
+   <field>
+    <name>user_name</name>
+    <type>text</type>
+    <default></default>
+    <notnull>true</notnull>
+    <length>255</length>
+   </field>
+
+   <index>
+    <name>rampage_users_pKey</name>
+    <primary>true</primary>
+    <field>
+     <name>user_id</name>
+     <sorting>ascending</sorting>
+    </field>
+   </index>
+
+   <index>
+    <name>rampage_users_user_name</name>
+    <unique>true</unique>
+    <field>
+     <name>user_name</name>
+     <sorting>ascending</sorting>
+    </field>
+   </index>
+
+  </declaration>
+
+ </table>
+
+</database>
diff --git a/content/data/rampage_tags.xml b/content/data/rampage_tags.xml
new file mode 100644 (file)
index 0000000..a6b89dd
--- /dev/null
@@ -0,0 +1,236 @@
+<?xml version="1.0" encoding="ISO-8859-1" ?>
+<database>
+
+ <name><variable>name</variable></name>
+ <create>false</create>
+ <overwrite>false</overwrite>
+
+ <charset></charset>
+
+ <table>
+
+  <name>rampage_tags</name>
+
+  <declaration>
+
+   <field>
+    <name>tag_id</name>
+    <type>integer</type>
+    <default>0</default>
+    <notnull>true</notnull>
+    <autoincrement>1</autoincrement>
+    <unsigned>true</unsigned>
+    <length>4</length>
+   </field>
+
+   <field>
+    <name>tag_name</name>
+    <type>text</type>
+    <default></default>
+    <notnull>true</notnull>
+    <length>255</length>
+   </field>
+
+   <index>
+    <name>rampage_tags_pKey</name>
+    <primary>true</primary>
+    <field>
+     <name>tag_id</name>
+     <sorting>ascending</sorting>
+    </field>
+   </index>
+
+   <index>
+    <name>rampage_tags_tag_name</name>
+    <unique>true</unique>
+    <field>
+     <name>tag_name</name>
+     <sorting>ascending</sorting>
+    </field>
+   </index>
+
+  </declaration>
+
+ </table>
+
+ <table>
+
+  <name>rampage_tagged</name>
+
+  <declaration>
+
+   <field>
+    <name>user_id</name>
+    <type>integer</type>
+    <default></default>
+    <notnull>true</notnull>
+    <unsigned>true</unsigned>
+    <length>4</length>
+   </field>
+
+   <field>
+    <name>object_id</name>
+    <type>integer</type>
+    <default></default>
+    <notnull>true</notnull>
+    <unsigned>true</unsigned>
+    <length>4</length>
+   </field>
+
+   <field>
+    <name>tag_id</name>
+    <type>integer</type>
+    <default></default>
+    <notnull>true</notnull>
+    <unsigned>true</unsigned>
+    <length>4</length>
+   </field>
+
+   <field>
+    <name>created</name>
+    <type>timestamp</type>
+    <default></default>
+    <notnull>false</notnull>
+   </field>
+
+   <index>
+    <name>rampage_tagged_object_id</name>
+    <field>
+     <name>object_id</name>
+     <sorting>ascending</sorting>
+    </field>
+   </index>
+
+   <index>
+    <name>rampage_tagged_tag_id</name>
+    <field>
+     <name>tag_id</name>
+     <sorting>ascending</sorting>
+    </field>
+   </index>
+
+   <index>
+    <name>rampage_tagged_created</name>
+    <field>
+     <name>created</name>
+     <sorting>ascending</sorting>
+    </field>
+   </index>
+
+   <index>
+    <name>rampage_tagged_pKey</name>
+    <primary>true</primary>
+    <field>
+     <name>user_id</name>
+     <sorting>ascending</sorting>
+    </field>
+    <field>
+     <name>object_id</name>
+     <sorting>ascending</sorting>
+    </field>
+    <field>
+     <name>tag_id</name>
+     <sorting>ascending</sorting>
+    </field>
+   </index>
+
+  </declaration>
+
+ </table>
+
+ <table>
+
+  <name>rampage_tag_stats</name>
+
+  <declaration>
+
+   <field>
+    <name>tag_id</name>
+    <type>integer</type>
+    <default></default>
+    <notnull>true</notnull>
+    <unsigned>true</unsigned>
+    <length>4</length>
+   </field>
+
+   <field>
+    <name>count</name>
+    <type>integer</type>
+    <default></default>
+    <notnull>true</notnull>
+    <unsigned>true</unsigned>
+    <length>4</length>
+   </field>
+
+   <index>
+    <name>rampage_tag_stats_pKey</name>
+    <primary>true</primary>
+    <field>
+     <name>tag_id</name>
+     <sorting>ascending</sorting>
+    </field>
+   </index>
+
+  </declaration>
+
+ </table>
+
+ <table>
+
+  <name>rampage_user_tag_stats</name>
+
+  <declaration>
+
+   <field>
+    <name>user_id</name>
+    <type>integer</type>
+    <default></default>
+    <notnull>true</notnull>
+    <unsigned>true</unsigned>
+    <length>4</length>
+   </field>
+
+   <field>
+    <name>tag_id</name>
+    <type>integer</type>
+    <default></default>
+    <notnull>true</notnull>
+    <unsigned>true</unsigned>
+    <length>4</length>
+   </field>
+
+   <field>
+    <name>count</name>
+    <type>integer</type>
+    <default></default>
+    <notnull>true</notnull>
+    <unsigned>true</unsigned>
+    <length>4</length>
+   </field>
+
+   <index>
+    <name>rampage_user_tag_stats_tag_id</name>
+    <field>
+     <name>tag_id</name>
+     <sorting>ascending</sorting>
+    </field>
+   </index>
+
+   <index>
+    <name>rampage_user_tag_stats_pKey</name>
+    <primary>true</primary>
+    <field>
+     <name>user_id</name>
+     <sorting>ascending</sorting>
+    </field>
+    <field>
+     <name>tag_id</name>
+     <sorting>ascending</sorting>
+    </field>
+   </index>
+
+  </declaration>
+
+ </table>
+
+</database>
diff --git a/content/doc/TODO.txt b/content/doc/TODO.txt
new file mode 100644 (file)
index 0000000..59eef52
--- /dev/null
@@ -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 (file)
index 0000000..8f57ebe
--- /dev/null
@@ -0,0 +1,19 @@
+<?php
+
+require_once dirname(__FILE__) . '/lib/base.php';
+require_once $CONTENT_DIR . '/lib/Tags/Tagger.php';
+
+$request = new Horde_Controller_Request_Http();
+
+$mapper = new Horde_Routes_Mapper();
+require $CONTENT_DIR . '/config/routes.php';
+
+$context = array(
+    'mapper' => $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 (file)
index 0000000..a0aba84
--- /dev/null
@@ -0,0 +1,152 @@
+<?php
+/**
+ * Copyright 2008 The Horde Project (http://www.horde.org/)
+ *
+ * @author   Chuck Hagenbuch <chuck@horde.org>
+ * @author   Michael Rubinsky <mrubinsk@horde.org>
+ * @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 (file)
index 0000000..33dc2b0
--- /dev/null
@@ -0,0 +1,19 @@
+<?php
+/**
+ * Copyright 2008 The Horde Project (http://www.horde.org/)
+ *
+ * @author   Chuck Hagenbuch <chuck@horde.org>
+ * @license  http://opensource.org/licenses/bsd-license.php BSD
+ * @category Horde
+ * @package  Horde_Content
+ */
+
+/**
+ * @author   Chuck Hagenbuch <chuck@horde.org>
+ * @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 (file)
index 0000000..a1a2505
--- /dev/null
@@ -0,0 +1,24 @@
+<?php
+/**
+ * Copyright 2008 The Horde Project (http://www.horde.org/)
+ *
+ * @author   Chuck Hagenbuch <chuck@horde.org>
+ * @license  http://opensource.org/licenses/bsd-license.php BSD
+ * @category Horde
+ * @package  Horde_Content
+ */
+
+/**
+ * @author   Chuck Hagenbuch <chuck@horde.org>
+ * @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 (file)
index 0000000..0cc4e55
--- /dev/null
@@ -0,0 +1,19 @@
+<?php
+/**
+ * Copyright 2008 The Horde Project (http://www.horde.org/)
+ *
+ * @author   Chuck Hagenbuch <chuck@horde.org>
+ * @license  http://opensource.org/licenses/bsd-license.php BSD
+ * @category Horde
+ * @package  Horde_Content
+ */
+
+/**
+ * @author   Chuck Hagenbuch <chuck@horde.org>
+ * @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 (file)
index 0000000..4234a56
--- /dev/null
@@ -0,0 +1,24 @@
+<?php
+/**
+ * Copyright 2008 The Horde Project (http://www.horde.org/)
+ *
+ * @author   Chuck Hagenbuch <chuck@horde.org>
+ * @license  http://opensource.org/licenses/bsd-license.php BSD
+ * @category Horde
+ * @package  Horde_Content
+ */
+
+/**
+ * @author   Chuck Hagenbuch <chuck@horde.org>
+ * @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 (file)
index 0000000..a1e0778
--- /dev/null
@@ -0,0 +1,691 @@
+<?php
+/**
+ * Copyright 2008 The Horde Project (http://www.horde.org/)
+ *
+ * @author   Chuck Hagenbuch <chuck@horde.org>
+ * @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 <chuck@horde.org>
+ * @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 (file)
index 0000000..c11754e
--- /dev/null
@@ -0,0 +1,118 @@
+<?php
+/**
+ * Copyright 2008 The Horde Project (http://www.horde.org/)
+ *
+ * @author   Chuck Hagenbuch <chuck@horde.org>
+ * @author   Michael Rubinsky <mrubinsk@horde.org>
+ * @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 (file)
index 0000000..1525e0e
--- /dev/null
@@ -0,0 +1,118 @@
+<?php
+/**
+ * Copyright 2008 The Horde Project (http://www.horde.org/)
+ *
+ * @author   Chuck Hagenbuch <chuck@horde.org>
+ * @author   Michael Rubinsky <mrubinsk@horde.org>
+ * @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 (file)
index 0000000..448f7a0
--- /dev/null
@@ -0,0 +1,9 @@
+<?php
+
+require dirname(__FILE__) . '/../../../lib/base.php';
+require 'Horde/Autoloader.php';
+$CONTENT_DIR = dirname(__FILE__) . '/../';
+
+$conf['sql']['adapter'] = $conf['sql']['phptype'] == 'mysqli' ? 'mysqli' : 'pdo_' . $conf['sql']['phptype'];
+Horde_Rdo::setAdapter(Horde_Rdo_Adapter::factory('pdo', $conf['sql']));
+Horde_Db::setAdapter(Horde_Db_Adapter::factory($conf['sql']));
diff --git a/content/schema.php b/content/schema.php
new file mode 100644 (file)
index 0000000..eaead79
--- /dev/null
@@ -0,0 +1,6 @@
+<?php
+require_once dirname(__FILE__) . '/../../lib/base.php';
+
+require_once 'MDB2.php';
+
+?>
\ No newline at end of file
diff --git a/content/test/AllTests.php b/content/test/AllTests.php
new file mode 100644 (file)
index 0000000..eff87e5
--- /dev/null
@@ -0,0 +1,67 @@
+<?php
+/**
+ * @package    Rampage_Content
+ * @subpackage UnitTests
+ */
+
+if (!defined('PHPUnit_MAIN_METHOD')) {
+    define('PHPUnit_MAIN_METHOD', 'Content_AllTests::main');
+}
+
+require_once 'PHPUnit/Framework/TestSuite.php';
+require_once 'PHPUnit/TextUI/TestRunner.php';
+
+// @TODO HACK
+define('AUTH_HANDLER', true);
+require dirname(__FILE__) . '/../lib/base.php';
+require $CONTENT_DIR . 'lib/Tags/Tagger.php';
+
+/**
+ * @package    Rampage_Content
+ * @subpackage UnitTests
+ */
+class Content_AllTests {
+
+    public static function main()
+    {
+        PHPUnit_TextUI_TestRunner::run(self::suite());
+    }
+
+    public static function suite()
+    {
+        // Catch strict standards
+        error_reporting(E_ALL | E_STRICT);
+
+        // Ensure a default timezone is set.
+        date_default_timezone_set('America/New_York');
+
+        // Set up autoload
+        if (!spl_autoload_functions()) {
+            spl_autoload_register(create_function('$class', '$filename = str_replace(array(\'::\', \'_\'), \'/\', $class); @include_once "$filename.php";'));
+        }
+
+        // Build the suite
+        $suite = new PHPUnit_Framework_TestSuite('Rampage Components - Content');
+
+        $basedir = dirname(__FILE__);
+        $baseregexp = preg_quote($basedir . DIRECTORY_SEPARATOR, '/');
+
+        foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($basedir)) as $file) {
+            if ($file->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 (file)
index 0000000..1cdc219
--- /dev/null
@@ -0,0 +1,281 @@
+<?php
+/**
+ * Copyright 2008 The Horde Project (http://www.horde.org/)
+ *
+ * @author     Chuck Hagenbuch <chuck@horde.org>
+ * @category   Horde
+ * @package    Content
+ * @subpackage UnitTests
+ */
+
+/**
+ * @author     Chuck Hagenbuch <chuck@horde.org>
+ * @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 (file)
index 0000000..5515434
--- /dev/null
@@ -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');