Simple Tagging Behavior
So far after looking at other tagging code in this site, I have not seen tags done properly when using normal database form. So this will be a basic, simple tagging system that allows you to use both a tags field in your table and a separate tagging table.
First create three tables.
The first table will be your tags table. This table will consist of the following fields id, tag.
The second table will be the table you would like to associate with tags. For this example let's use the table posts, with the required fields id, name, tags.
Third, we will need to build a connector table to link tags with posts. We will call this table posts_tags.
Please also build the proper hasAndBelongsToMany relationship for your model. The SQL and the Model Definition can be found in the Cake Manual for Models under Section 4. Look for the heading "HABTM Join Tables: Sample models and their join table names"
http://manual.cakephp.org/chapter/models
/app/models/post.php
/app/models/post_tag.php
/app/models/tag.php
/app/models/behaviors/tag.php
/app/models/post.php (REVISION)
Telling the Post model to "act as" a tag behavior will automatically take a comma delimited tags field from the Posts table and when it is saved, it will parse out the tags, save them to the tags table, and save the associated links.
This can work in one table or multiple tables that want to use the same set of tags.
/app/views/posts/add.ctp
/app/views/posts/edit.ctp
Hope this helps someone.
The first table will be your tags table. This table will consist of the following fields id, tag.
The second table will be the table you would like to associate with tags. For this example let's use the table posts, with the required fields id, name, tags.
Third, we will need to build a connector table to link tags with posts. We will call this table posts_tags.
Please also build the proper hasAndBelongsToMany relationship for your model. The SQL and the Model Definition can be found in the Cake Manual for Models under Section 4. Look for the heading "HABTM Join Tables: Sample models and their join table names"
http://manual.cakephp.org/chapter/models
Models
/app/models/post.php
Model Class:
<?php
<?php
class Post extends AppModel
{
var $name = 'Post';
var $hasAndBelongsToMany = array('Tag' =>
array('className' => 'Tag',
'joinTable' => 'posts_tags',
'foreignKey' => 'post_id',
'associationForeignKey'=> 'tag_id',
'conditions' => '',
'order' => '',
'limit' => '',
'unique' => true,
'finderQuery' => '',
'deleteQuery' => '',
)
);
}
?>
?>
/app/models/post_tag.php
Model Class:
<?php
<?php
class PostTag extends AppModel
{
var $name = 'PostTag';
}
?>
?>
/app/models/tag.php
Model Class:
<?php
<?php
class Tag extends AppModel
{
var $name = 'Tag';
}
?>
?>
Tag Behavior
/app/models/behaviors/tag.php
<?php /**
* Tag Behavior class file.
*
* Model Behavior to support tags.
*
* @filesource
* @package app
* @subpackage models.behaviors
*/
/**
* Add tag behavior to a model.
*
*/
class TagBehavior extends ModelBehavior {
/**
* Initiate behaviour for the model using specified settings.
*
* @param object $model Model using the behaviour
* @param array $settings Settings to override for model.
*
* @access public
*/
function setup(&$model, $settings = array()) {
$default = array( 'table_label' => 'tags', 'tag_label' => 'tag', 'separator' => ',');
if (!isset($this->settings[$model->name])) {
$this->settings[$model->name] = $default;
}
$this->settings[$model->name] = array_merge($this->settings[$model->name], ife(is_array($settings), $settings, array()));
}
/**
* Run before a model is saved, used to set up tag for model.
*
* @param object $model Model about to be saved.
*
* @access public
* @since 1.0
*/
function beforeSave(&$model) {
// Define the new tag model
$Tag =& new Tag;
if ($model->hasField($this->settings[$model->name]['table_label'])
&& $Tag->hasField($this->settings[$model->name]['tag_label'])) {
// Parse out all of the
$tag_list = $this->_parseTag($model->data[$model->name][$this->settings[$model->name]['table_label']], $this->settings[$model->name]);
$tag_info = array(); // New tag array to store tag id and names from db
foreach($tag_list as $t) {
if ($res = $Tag->find($this->settings[$model->name]['tag_label'] . " LIKE '" . $t . "'")) {
$tag_info[] = $res['Tag']['id'];
} else {
$Tag->save(array('id'=>'',$this->settings[$model->name]['tag_label']=>$t));
$tag_info[] = sprintf($Tag->getLastInsertID());
}
unset($res);
}
// This prepares the linking table data...
$model->data['Tag']['Tag'] = $tag_info;
// This formats the tags field before save...
$model->data[$model->name][$this->settings[$model->name]['table_label']] = implode(', ', $tag_list);
}
return true;
}
/**
* Parse the tag string and return a properly formatted array
*
* @param string $string String.
* @param array $settings Settings to use (looks for 'separator' and 'length')
*
* @return string Tag for given string.
*
* @access private
*/
function _parseTag($string, $settings) {
$string = strtolower($string);
$string = preg_replace('/[^a-z0-9' . $settings['separator'] . ' ]/i', '', $string);
$string = preg_replace('/' . $settings['separator'] . '[' . $settings['separator'] . ']*/', $settings['separator'], $string);
$string_array = preg_split('/' . $settings['separator'] . '/', $string);
$return_array = array();
foreach($string_array as $t) {
$t = ucwords(trim($t));
if (strlen($t)>0) {
$return_array[] = $t;
}
}
return $return_array;
}
}
?>
Usage
/app/models/post.php (REVISION)
Model Class:
<?php
<?php
class Post extends AppModel
{
var $name = 'Post';
var $actAs = array('Tag'=>array('table_label'=>'tags', 'tags_label'=>'tag', 'separator'=>',');
var $hasAndBelongsToMany = array('Tag' =>
...
?>
?>
Telling the Post model to "act as" a tag behavior will automatically take a comma delimited tags field from the Posts table and when it is saved, it will parse out the tags, save them to the tags table, and save the associated links.
This can work in one table or multiple tables that want to use the same set of tags.
Views
Here is the implementation:/app/views/posts/add.ctp
View Template:
<?php echo $form->create('Posts');?>
<?php echo $form->input('title');?>
<?php echo $form->input('tags');?>
<?php echo $form->input('body');?>
</form>
/app/views/posts/edit.ctp
View Template:
<?php echo $form->create('Posts');?>
<?php echo $form->input('id');?>
<?php echo $form->input('title');?>
<?php echo $form->input('tags');?>
<?php echo $form->input('body');?>
</form>
Controller
Controller Class:
<?php
<?php
class PostsController extends AppController {
var $name = 'Posts';
var $helpers = array('Html', 'Form' );
function index() {
$this->Post->recursive = 0;
$this->set('posts', $this->paginate());
}
function add() {
if(!empty($this->data)) {
$this->cleanUpFields();
$this->Post->create();
if($this->Post->save($this->data)) {
$this->Session->setFlash('The Post has been saved');
$this->redirect(array('action'=>'index'), null, true);
} else {
$this->Session->setFlash('The Post could not be saved. Please, try again.');
}
}
}
function edit($id = null) {
if(!$id && empty($this->data)) {
$this->Session->setFlash('Invalid Post');
$this->redirect(array('action'=>'index'), null, true);
}
if(!empty($this->data)) {
$this->cleanUpFields();
if($this->Post->save($this->data)) {
$this->Session->setFlash('The Post saved');
$this->redirect(array('action'=>'index'), null, true);
} else {
$this->Session->setFlash('The Post could not be saved. Please, try again.');
}
}
if(empty($this->data)) {
$this->data = $this->Post->read(null, $id);
}
}
}
?>
Hope this helps someone.

$tag_info = array(); // New tag array to store tag id and names from db
string:
$tag_list = array_unique ($tag_list);
?
Some users is not so brainy and made mistake like:
tags: news, car, engine, car
What do you think? ;-)
+++Articles+++
id
type_id
name
+++ContentTags+++
id
type_id
tag_id
deleted
+++Tags+++
id
name
no_occurances
+++Types+++
id
name [ARTICLE/PHOTO/etc]
Totally agree. Exploding strings is a bit messy. IMHO i would structure a tagging to be done like so..
eg. Tagging an Article / Photo
+++Articles+++
id
name
[HAS-MANY: ContentTags]
+++ContentTags+++
id
content_type [ENUM (ARTICLE/PHOTO/OTHER-MEDIA-YOU-WISH-TO-TAG)] content_id [JOINS TO Article.id / Photo.id / etc] tag_id
deleted
+++Tags+++
id
name
no_occurances [Each time a tag is add/deleted regardless of content type it would be updated]
i think this would provide a more efficient means of searching/generating tag clouds...
http://bakery.cakephp.org/articles/view/simple-tagging-component
$this->Image->findall(x);
2) var $actsAs = ...
3) Don't forget to put HABTM into tag.php - if you want to see all post relate to this tag ;-)
Today, I'm finishing to write tagcloud "helper". For this behaviors.
Together it's look too nice ;-)
Thanks, DW!
It could also generate a fake 'tags' field in the data on find operations but it doesn't need to be a real field.
gave me a slight insight of how to use behaviors.
What I'm missing now is how to make one of the famous
tag clouds out of all that data ? Is there an example how to
accomplish that in your special case with 3 DB tables ?
-S.
if ($model->hasField($this->settings[$model->name]['table_label']) && $Tag->hasField($this->settings[$model->name]['tag_label'])) {
it seems to fail this check and I'm not sure why. I've set everything else up correctly, and I know it's calling beforeSave because I can print text before that if statement is called but not after.
EDIT: I fixed it. Make sure you have the tags field present, in your Post (or equiv) db table. I overlooked this step.
Model Class:
<?php
<?php
class Article extends AppModel
{
// Model name
var $name = 'Article';
// Database table
var $useTable = 'articles';
// Define the behaviours to use
var $actsAs = array('Slug',
'ExtendAssociations',
'Tag' => array('table_label'=>'tags', 'tags_label'=>'tag', 'separator'=>','));
// Validator
var $validate = array(
'title' => array('rule' => array('between', 3, 255)),
'article' => VALID_NOT_EMPTY
);
var $hasAndBelongsToMany = array(
'Tag' =>
array('className' => 'Tag',
'joinTable' => 'articles_tags',
'foreignKey' => 'article_id',
'associationForeignKey'=> 'tag_id',
'conditions' => '',
'order' => '',
'limit' => '',
'unique' => true,
'finderQuery' => '',
'deleteQuery' => '',
),
'Category' =>
array('className' => 'Category',
'joinTable' => 'articles_categories',
'foreignKey' => 'article_id',
'associationForeignKey'=> 'category_id',
'conditions' => '',
'order' => '',
'limit' => '',
'unique' => true,
'finderQuery' => '',
'deleteQuery' => '',
),
);
}
?>
Model Class:
<?php
<?php
/* SVN FILE: $Id$ */
/**
* Tag behavior
*
* Poloymorphic assocation tagging behaviour, uses a dynamic join table to cut down
* on complexity. Also offers a few funcitons for searching and generating a tagCloud.
*
* Posts Exsample :
* Post Model
*
* var $hasAndBelongsToMany = array(
* 'Tag' => array(
* 'with' => 'ModelsTag',
* 'joinTable' => 'models_tags',
* 'foreignKey' => 'model_id',
* 'associationForeignKey'=> 'tag_id',
* 'conditions' => '`ModelsTag`.`model` = "post"',
* 'order' => '',
* 'limit' => '',
* 'unique' => true,
* 'finderQuery' => '',
* 'deleteQuery' => ''
* )
* );
*
* Post Controller :
*
* set('tagCloud', $this->Post->generateTagCloud());
*
* or
*
* $ids = $this->Post->findIdsByTag($tags);
* $this->Paginate('Posts', array('Post.id'=>$ids));
*
* or
*
* set('surgestions', $this->Post->autocompleteTag('Ta');
*
*
* Based on the work of DW aka "dooltaz"
* @link http://bakery.cakephp.org/articles/view/simple-tagging-behavior
*
* Join table is models_tags
*
* PHP versions 5
*
* acmConsulting <www.acmconsulting.eu>
*
* Copyright 2006-2008, acmConsulting
*
* Licensed under The MIT License
* Redistributions of files must retain the above copyright notice.
*
* @copyright Copyright 2006-2008, acmConsulting
* @link http://www.acmconsulting.eu acmConsulting
*
* @package app
* @subpackage models.behaviors
*
* @version $Revision$
* @modifiedby $LastChangedBy$
* @lastmodified $Date$
*
* @license http://www.opensource.org/licenses/mit-license.php The MIT License
*/
loadModel('Tag');
/**
* Add tag behavior to a model.
*
*/
class TagBehavior extends ModelBehavior {
/**
* Initiate behaviour for the model using specified settings.
*
* @param object $model Model using the behaviour
* @param array $settings Settings to override for model.
*
* @access public
*/
function setup(&$model, $settings = array()) {
$default = array('tagged' => array('tags_text'), 'separator' => ',');
if (!isset($this->settings[$model->name])) {
$this->settings[$model->name] = $default;
}
$this->settings[$model->name] = array_merge($this->settings[$model->name], ife(is_array($settings), $settings, array()));
}
/**
* Run after a model is saved, parses all the rows defined the $settings['tagged']
* and for each row it processes the tags, linking them to the tags model via the
* HABTM relationship in models_tags.
*
* @param object $model Model about to be saved.
*
* @return bool true
*
* @access public
*/
function afterSave(&$model) {
// Define the new tag model
$Tag =& new Tag;
foreach($this->settings[$model->name]['tagged'] as $current_row){
if ($model->hasField($current_row) && $Tag->hasField('name')) {
// Remove old assocations
$model->Tag->ModelsTag->deleteAll('`model` = \''.$model->name.
'\' AND `model_id=`'.$model->data[$model->name]['id'].
' AND `model_row`=\''.$current_row.'\'');
// Parse out all of the tags
$tag_list = $this->_parseTag($model->data[$model->name][$current_row], $this->settings[$model->name]);
$tag_id = null;
foreach($tag_list as $t) {
if ($res = $Tag->find('`name` = \'' . $t . '\'')) {
//tag exsists, set it ID
$tag_id = $res['Tag']['id'];
} else {
//tag doenst exsist yet, create it
$Tag->create();
$Tag->save(array('id'=>'','name'=>$t));
$tag_id = $Tag->getLastInsertID();
}
//create link between tag and model
$Tag->ModelsTag->create();
$Tag->ModelsTag->save(array(
'id'=>'',
'tag_id'=>$tag_id,
'model'=>$model->name,
'model_id'=>$model->data[$model->name]['id'],
'model_row'=>$current_row)
);
unset($res);
}
}
}
return true;
}
/**
* returns a list of ID's found by the tag search
* Usefull for creating a pagnated tag search in the controller.
*
* @param string $row the row to search
* @param string $tags the csv of tags to search for
* @return object results
*/
function findIdsByTag(&$model, $tags = null, $row = 'tags_text')
{
$data = false;
if($tags != null AND $model->hasField($row) AND in_array($row, $this->settings[$model->name]['tagged'])){
$Tag =& new Tag;
//get the tags
$tag_list = $this->_parseTag($tags, $this->settings[$model->name]);
//get thier ID's
$constraint['Tag.name'] = $tag_list;
$constraint['ModelsTag.model'] = $model->name;
$constraint['ModelsTag.model_row'] = $row ;
$data = $Tag->ModelsTag->findAll($constraint);
//pull out a list of the related model's ID's
$data = Set::extract($data, '{n}.ModelsTag.model_id');
}
return $data;
}
/**
* returns a list of possable tags that are LIKE %input%
*
* @param object $model Model about to be autcompleted
* @param string $tags the partial tag
* @param string $limit number of surgestions to return
* @return array A list of possable tags
*
*/
function autocompleteTag(&$model, $tag = null, $row = 'tags_text', $limit = 5)
{
$data = false;
if($tag != ''){
$Tag =& new Tag;
$constraint = array();
$constraint['Tag.name']['LIKE'] = '%'.$tag.'%';
$constraint['ModelsTag.model'] = $model->name;
$constraint['ModelsTag.model_row'] = $row ;
$data = $Tag->ModelsTag->findAll('`Tag`.`name` LIKE \'%'.$tag.'%\'', null, null, $limit);
$data = Set::extract($data, '{n}.Tag.name');
}
return $data;
}
/**
* Generates an array containing the things you need to make a tag cloud for
* the model
*
* @param object $model Model about to be autcompleted
* @param string $row current model row to use
* @param Array $sizes containing size => % of results NB. values are rounded up.
* @param int $cloud_size number of tags to be in the cloud
* @param string $order how the tags are to be ordered e.g. 'abc','rank','rand','date'
* @return Array $data array of tag data, ready to be used as a cloud.
*
* @access public
*
* @todo update to use findAll instead of query
*/
function getTagCloud(&$model, $row = 'tags_text', $sizes = null, $cloud_size = 25, $order = 'abc')
{
if(!$row OR !$model->hasField($row)){
$row = $this->settings[$model->name]['tagged'][0];
}
if($sizes == null){
$sizes = array(
4 => '.10',
3 => '.30',
2 => '.40',
1 => '.20',
);
}
//This can probably be done without using a custom query - but i havent yet. If you do it, please
//email me the code so i can update it.
//Ideally i would like it to use Pagination, so size and direction and things can be set via the URL.
$data = $model->query('SELECT `Tag`.`name` , COUNT( * ) AS "rank", MAX( `ModelsTag`.`created` ) AS "date" '
.'FROM `tags` AS `Tag` '
.'LEFT JOIN `models_tags` AS `ModelsTag` ON ( `ModelsTag`.`tag_id` = `Tag`.`id`) '
.'WHERE `ModelsTag`.`model` =\''.$model->name.'\' AND `ModelsTag`.`model_row` =\''.$row.'\' '
.'AND `ModelsTag`.`tag_id` IS NOT NULL '
.'GROUP BY `Tag`.`name` ORDER BY `rank` DESC '
.'LIMIT '.$cloud_size);
//re-define rank into size (which is defined by the array $sizes)
//again, could be done better...
$total = count($data);
$currentRank = count($sizes);
$leftinRank = round($total*$sizes[$currentRank]);
for($i = 0; $i < $total; $i++){
if($leftinRank < 1 && $currentRank != 1){
$currentRank--;
$leftinRank = round($total*$sizes[$currentRank]);
}
$data[$i]['Tag']['size'] = $currentRank;
$leftinRank--;
}
//this might be able to be improved - again im thinking pagination.
switch($order){
case 'abc':
usort($data, array('TagBehavior','_abc'));
break;
case 'rank':
//do nothing, already in rank order
break;
case 'date';
usort($data, array('TagBehavior','_date'));
break;
case 'rand': default:
shuffle($data);
break;
}
return $data ;
}
/**
* Helper function for getCloud, array sort based on tag name
*
* @param array $x a tag
* @param array $y another tag
* @param int 0 if equal, -1 if $x < $y, 1 if $x > $y
*
* @access private
*/
function _abc($x, $y)
{
if ( $x['Tag']['name'] == $y['Tag']['name'] )
return 0;
else if ( $x['Tag']['name'] < $y['Tag']['name'] )
return -1;
else
return 1;
}
/**
* Helper function for getCloud, array sort based on tag date
*
* @param array $x a tag
* @param array $y another tag
* @param int 0 if equal, -1 if $x < $y, 1 if $x > $y
*
* @access private
*/
function _date($x, $y)
{
if ( $x['Tag']['date'] == $y['Tag']['date'] )
return 0;
else if ( $x['Tag']['date'] < $y['Tag']['date'] )
return -1;
else
return 1;
}
/**
* Parse the tag string and return a properly formatted array
*
* @param string $string String.
* @param array $settings Settings to use (looks for 'separator')
*
* @return string Tag for given string.
*
* @access private
*/
function _parseTag($string, $settings) {
$string = strtolower($string);
//convert anything thats not an alpha numeric or a space into a separator
$string = preg_replace('/[^a-z0-9\s' . $settings['separator'] . ' ]/i', '', $string);
//convert any repeated seporators (e.g. tag,,,,tag2) into one e.g. (tag,tag2).
$string = preg_replace('/' . $settings['separator'] . '[' . $settings['separator'] . ']*/', $settings['separator'], $string);
$string_array = preg_split('/' . $settings['separator'] . '/', $string);
$return_array = array();
foreach($string_array as $t) {
$t = trim($t);
if (strlen($t)>0) {
$return_array[] = $t;
}
}
return $return_array;
}
}
?>
?>
Comments, ideas, updates etc... welcome.
Please, fix it. I spend 2h on this! My fault, because I didn't read the comments. But this IS a bug.
I like your approach, i have a few ideas of ways we could take this up a gear that i would like to run past you. I plan on taking your code on and improving it, but it would be nice to work with you and hear what you think.
The main things i'm interested in are reducing the number of join tables (using the same technique to the translation behavior), using steming to reduce the number of tags and possibly thinking about how it might work with translatable tags...
Would you be interested in this?
Email me : alex.bakery@acmconsulting.eu and we can talk about it. I also sit on the #cakephp room in irc.freenode.net (username drayen), if you prefer.
I have one enhancement to note. If you have some logic, that involves for example a $model->saveField() in the rest of your programm, then the _parseTag function can not work, because in $model->data is only one field. So just before the _parseTag call I have included this line of code:
So _parseTag will only be called if the table_label field is in the current data of the model.if (array_key_exists($this->settings[$model->name]['table_label'], $model->data[$model->name])) {
PHP Snippet:
<?phpޝyXz-j.蝶)))杺ǭulj{+ڬZ+{Wފǧr^tmuA!@Mlt屔而(屔而 4Ȁх}屔而mt屔而 ɕ屔而l屔耍Q屔而ul屔耍屔而t왹4Ȁ屔而 ((ܡם 譶r^tmuA!@Mlt屔而(屔而 4Ȁх}屔而mt屔而 ɕ屔而l屔耍Q屔而ul屔而 Q屔而屔而 ɥ-屔而t왹4Ȁ屔而 ((ܡםX⢻hʋqe"hWzZhțv^r'~'v̨\yHbƬzȧq쨹'\yZ&"jם(2Ɵz\ܡםif (loadModel('Tag')) {
$Tag =& new Tag();
} else {
trigger_error(__('Model Tag could not be instantiated in ' . __METHOD__, true), E_USER_WARNING);
return false;
}
?>
PHP Snippet:
<?php$Tag->recursive = -1;
if ($res = $Tag->find($this->settings[$model->name]['tag_label'] . " LIKE '" . $t . "'", array($Tag->primaryKey))) {
?>
First, all of my working examples are still in development. Also please note that behaviors are not functional in 1.1.x.x. I am using the trunk of 1.2.x.x for my development.
Second, this particular article does not explain how to display tags in views. It only deals with adding tags and editing tags so that they are stored in your "posts" table and in your "tags" table. This allows for the greatest amount of flexability when displaying tags in your views.
Third, beforeSave function calls are done in sequence, so you can have a beforeSave in your behavior and have a model with a beforeSave. As long as each one returns true, it will save properly, however I am not sure which beforeSave is executed first.
Yeah, I get that it's 1.2 only - all Behaviors are. I should have made my question clearer - of course I'm not trying to implement the Behavior in a view; my question was how does this interact with the view of my add/edit forms?
It looks like you've added some controller logic and sample views to the article; I'll give those a shot. Thanks!
Tagging is basically a way to organize your content without having to use categories. It is similar to how search engines use keywords to find a webpage. With tagging, when you write an article, you simply add a number of keywords or "tags" along with the article describing it. The code on this page will insert each tag into a "tags" table so that you can do quick referencing on them. For more information you may want to google "understanding tags".
This code automatically grabs the "tag" field from your model/table (Post, in this example) and it will automatically update the tags table and the posts_tags table according to what you type into the tag field on your view. It also formats the tags properly.
This makes tag searching for multiple tables easier, it makes editing and adding tags seamless, and also makes building tag clouds much easier.