Writing a CMS/Community with Smarty and the Zend Framework: Part 8


The blog saga continues, we still don’t have any fancy WordPress style filtering of the content. You know, creating these nice looking quotes and filtering potentially nasty html and stuff. Sure enough, TinyMCE has some function for allowing only certain tags and discarding others but I haven’t had time to look at that yet, I will though… Fancy filtering is not in the requirements for this project either however. But changing and deleting blog posts are necessary actions though, so is some kind of search for articles. Let’s begin by looking at what’s been added to the desktop menu:

INSERT INTO `db_menuitems` (`id`, `db_menu_id`, `item_class`, `item_link`, `item_image`, `image_alt`, `link_text`, `position`, `item_imageover`, `nosess_show`, `sess_show`) VALUES (26, 4, '', 'index/d/c/blog/f/administrate', '', '', 'Administrate Blog', 6, '', 0, 1);
INSERT INTO `db_menuitems` (`id`, `db_menu_id`, `item_class`, `item_link`, `item_image`, `image_alt`, `link_text`, `position`, `item_imageover`, `nosess_show`, `sess_show`) VALUES (27, 4, '', 'index/d/c/blog/f/getSearchForm', '', '', 'Search for articles', 7, '', 0, 1);

So we’ve got two entry points to the new stuff. Administrate() and getSearchForm(). Let’s begin with the administration part:

function administrate(){
  $articles = $this->getArticles($this->usrid)->toArray();
  foreach($articles as &$article)
    $article['categories'] = $this->getCatsByArticle($article['id'])->toArray();
  $this->assign_by_ref('articles', $articles);
  return $this->fetch('blog_administrate.tpl');
}

function getArticles($id = null, $count = null, $offset = null, $where = "user_id = ?", $order = "post_date DESC"){
  $where = !empty($where) ? $this->obj->getAdapter()->quoteInto($where, $id) : null;
  return $this->obj->fetchAll($where, $order, $count, $offset);
}

function getCatsByArticle($article_id = false){
  if($article_id)
    return $this->obj->fetchRow("id = $article_id")->findManyToManyRowset('blogcategories', 'blogcatcon');
  return array();
}

The getArticles function is basically just an alias with some probable default values set. In this case it will retrieve all articles of the logged in user. Next we append all connected categories to each article, the number of round trips to the database could become huge here which is not optimal of course. However, focus is on speed at the moment, we have to get finished sometime and we don’t want to get bogged down by creating some massive optimized SQL statement that will fetch the right stuff from the beginning. Besides, SQL is not my strong side, I can hack most stuff but I’m not lightning fast, going with this possibly temporary PHP solution will do for now and is much faster. Furthermore, when the service is released it won’t get a bazillion users straight away either, performance can always be fine tuned later if need be.

You might also notice that we have a new alias, actually two:

function assign($key, $value = false){
  if(is_array($key))
    $this->smarty->assign($key);
  else
    $this->smarty->assign($key, $value);
}

function assign_by_ref($key, $value = false){
  if(is_array($key))
    $this->smarty->assign_by_ref($key);
  else
    $this->smarty->assign_by_ref($key, $value);
}

K, let’s look at the template blog_administrate.tpl:

{config_load file="$config_file"}
<table>
	<tr>
		<td>{#article_headline#}</td>
		<td>{#categories#}</td>
		<td>{#post_date#}</td>
		<td>{#actions#}</td>
	</tr>
	{foreach from=$articles item=article}
	<tr>
		<td width="300px">
			{$article.headline}
		</td>
		<td>
			{foreach from=$article.categories item=category}
				{$category.headline},&nbsp;
			{/foreach}
		</td>
		<td>
			{$article.post_date|date_format:$date_format}
		</td>
		<td>
			<a href="{$baseUrl}/index/d/c/blog/f/updateArticle/id/{$article.id}">{#edit#}</a>
			<a href="{$baseUrl}/index/d/c/blog/f/viewArticle/id/{$article.id}">{#view#}</a>
			<a href="{$baseUrl}/index/d/c/blog/f/deleteArticle/id/{$article.id}">{#delete#}</a>
		</td>
	</tr>
	{/foreach}
</table>

So, we’ve got three new functions here, updateArticle, viewArticle and deleteArticle:

function updateArticle($params){
  if(!empty($params['id'])){
    $entry = $this->obj->fetchRow("id = {$params['id']}");
    $this->assign('article', $entry->toArray());
    $this->assignCategories($params['id']);
    $this->assign('update', 'yes');
    return $this->fetch('blog_write.tpl');
  }else
    return "We really need an id if we are to update something";
}

function viewArticle($params){
  $article 				= $this->obj->fetchRow("id = {$params['id']}")->toArray();
  $article['categories'] 	= $this->getCatsByArticle($article['id'])->toArray();
  $this->assign('article', $article);
  return $this->fetch('blog_view_article.tpl');
}

function deleteArticle($params){
  $article = $this->obj->fetchRow("id = {$params['id']}");
  $this->deleteConsByArticle($article->id);
  $article->delete();
  return $this->administrate();
}

Apparently updateArticle will launch the blog_write.tpl with a flag called ‘update’ set to ‘yes’. Of course when saving there the good old saveArticle function will be called. In that function we now do one extra thing, in addition to the user id we also keep track of the user name in each article. Why we do this will become apparent soon enough.

Let’s move on to assignCategories() and deleteConsByArticle():

function assignCategories($article_id = false){
  $categories 	= $this->cat_obj->fetchAll("user_id = {$this->usrid}")->toArrayWithKey('id');
  $cur_cats 		= $this->getCatsByArticle($article_id);
  foreach($cur_cats as $cur_cat)
    $categories[ $cur_cat->id ]['checked'] = 'checked = "checked"';
  $this->assign('categories', $categories);
}

function deleteConsByArticle($article_id){
  $this->con_obj->delete("article_id = $article_id");
}

The delete connection function is just an alias. Why don’t I use the $_referenceMap with a cascade delete like it says in the ZF manual, or InnoDB with this set in the schema?

1.) I tried my ass off with the reference map but I couldn’t get it to work, I must have missed something, beats me what I missed though because this isn’t supposed to be complicated at all. Let me know if you’ve made it work and how you did it.

2.) InnoDB has wreaked havoc with my data on several occasions by crashing miserably, I don’t know why. It might have been my fault or something but it doesn’t really matter because I’ve never had any problems with MyISAM, that’s why I’m prepared to go through the extra trouble of explicitly deleting dependencies in this way.

You might notice something strange in assignCategories, it seems that the rowset has got a new function called toArrayWithKey. It has and it is my doing, instead of making yet another static function I opted to put the functionality in an extension of the rowset class this time because it’s somewhat prettier:

class ExtRowset extends Zend_Db_Table_Rowset_Abstract{
	function toArrayWithKey($primary = false){
    	if(!$primary) return false;
      $rarr = array();
    	$rowArr = $this->toArray();
    	foreach($rowArr as $row){
    		$key = $row[ $primary ];
    		$rarr[ $key ] = $row;
    	}
    	return $rarr;
    }
    
    function toKeyValueArray($key_field, $value_field){
    	$rarr = array();
    	$rowArr = $this->toArray();
    	foreach($rowArr as $row)
    		$rarr[ $row[$key_field] ] = $row[ $value_field ];
    	return $rarr;
    }
}

ToArrayWithKey will convert the rowset to a 2d array and assign each sub array a key that matches the id of each entry. The whole point of this is to be able to easily keep track of checked / not checked categories in the template. Take a look at assignCategories again and you will understand what I mean. This functionality is only needed if we allow updating of articles, which we currently do. One more thing, we have to use the new ExtRowset class in ExtModel like this at the top: protected $_rowsetClass = ‘ExtRowset’; Don’t forget to include the new rowset script in the bootstrap file either.

Let’s retrace our steps to the beginning and the new menu item that links to getSearchForm():

function getSearchForm(){
  return $this->fetch('blog_search.tpl');
}

And the template:

{config_load file="$config_file"}
<form id="search_category_form" action="{$baseUrl}/index/d/c/blog/f/searchCategory" method="post">
	{#search_categories#}:
	<br/>
	<input type="text" style="width:250px;" id="search_field" name="search_field">
	<br/>
	<input type="submit" value="{#submit#}">
</form>
<br/>
<div>
	<a href="{$baseUrl}/index/d/c/blog/f/getNewArticles">
		{#view_new_posts#}
	</a>
</div>

Let’s do searchCategory first:

function searchCategory($params){
  $search_params = array(
    "count" 	=> $this->session->conf->cat_count, 
    "where" 	=> array("headline LIKE '%{$params['search_field']}%'", "id IN ( SELECT category_id FROM com_blog_connect )"),
    "obj" 		=> $this->cat_obj,
    "offset"	=> $params['offset']
  );
  $categories = $this->sumCategories( $this->getSearchResults($search_params) );
  
  if(empty($params['offset'])){
    $search_params['count'] = null;
    $temp = $this->getSearchResults($search_params);
    $this->session->category->tot_count = count($temp);
  }
  
  $this->assignPaginationArray($this->session->category->tot_count, $this->session->conf->cat_count, $params['offset']);
  $this->assign('categories', $categories);
  return $this->fetch('blog_cat_search_result.tpl');
}

Yeah, I know, it’s a biggie but it has it’s reasons, in probably 90% of all cases it would be smaller and easier. So we begin by creating an array that we will use with the necessary information like “count” which is how many results we want to display per page, “where” which is yet another array with what we want to search for, “obj” is the object we want to use in the search, leaving this empty will default to $this->obj, last but not least the “offset” which will control where we are in the pagination.

First of all, let’s start with $this->session->conf->cat_count. Since the last part I’ve mustered the strength to actually start putting stuff in a configuration table which we will later be able to change in the future admin interface:

CREATE TABLE `com_blog_config` (
  `id` int(5) NOT NULL auto_increment,
  `conf_var` varchar(100) NOT NULL,
  `conf_val` varchar(100) NOT NULL,
  PRIMARY KEY  (`id`)
) ENGINE=MyISAM DEFAULT CHARSET=utf8;

INSERT INTO `com_blog_config` (`id`, `conf_var`, `conf_val`) VALUES (1, 'cat_count', '2');
INSERT INTO `com_blog_config` (`id`, `conf_var`, `conf_val`) VALUES (2, 'date_format', '%b %e, %Y');
INSERT INTO `com_blog_config` (`id`, `conf_var`, `conf_val`) VALUES (3, 'articles_per_page', '10');
INSERT INTO `com_blog_config` (`id`, `conf_var`, `conf_val`) VALUES (4, 'new_articles_count', '50');

Cat_count is 2 at the moment, it will probably be 10 or 15 in the end but I’ve set it to 2 now because that makes it easier for me to test pagination logic with low amounts of data.

This information is fetched in init() which we should revisit:

function init(){
  parent::init();
  $this->name 	= "blog";
  $this->obj 		= $this->loadModel($this->name);
  $this->cat_obj	= $this->loadModel('blogcategories');
  $this->con_obj  = $this->loadModel('blogcatcon');
  $this->usrid 	= $this->gs->usr_info['id'];
  parent::finishInit();
  $this->chkSessRdr();
  parent::setConfig('blogconfig');
  $this->assign('date_format', $this->session->conf->date_format);
}

In ExtController:
function setConfig($table){
  if(!isset($this->session->conf))
    $this->session->conf = (object)$this->loadModel($table)->fetchAllKeyValue();
}

In ExtModel:
function fetchAllKeyValue(){
  return $this->fetchAll()->toKeyValueArray($this->_key_field, $this->_value_field);
}

In blogconfig.php:
class Blogconfig extends ExtModel{
	protected $_name 			= 'com_blog_config';
	protected $_primary 		= 'id';
	protected $_key_field		= 'conf_var';
	protected $_value_field		= 'conf_val';
}

Here we use the other function we have extended rowset with to fetch the config info as an array that has the field conf_var as it’s keys and conf_val as values. Notice the explicit type conversion from array to object, it’s easier to write ->var instead of [‘var’], at least in my book.

The next function is getSearchResults:

function getSearchResults($params){
  extract($params);
  $obj = empty($obj) ? $this->obj : $obj;
  return $obj->fetchAll($where, $order_by, $count, $offset)->toArray();
}

It’s basically just an alias which I’ve created to avoid a fetchAll function with an ugly list of long strings as parameters. We need the params array in more places and this is also a good way of encapsulating the dangerous but powerful extract function. I know, it’s a borderline example of trying to minimize code size at the expense of readability but we go with it for now. Let’s look at sumCategories:

function sumCategories($categories){
  $final_cats = array();
  foreach($categories as $cat)
    $final_cats[ $cat['headline'] ] += $this->con_obj->fetchAll("category_id = {$cat['id']}")->count();
  arsort($final_cats);
  return $final_cats;
}

What’s happening here is that there might exist categories with the same headline created and attached to different users. When doing the search by category we want the number of articles in each category irrespective of which user has written something in that category. In this case we basically treat the categories as tags by their headlines even though there is a connection to a unique user for each category. Yes it’s complicated and convoluted, but if we had had a tag system then the complexity would have shown it’s ugly face when retrieving “categories” in the write/update template instead. Having a twofold solution like we maybe should have, would’ve added extra code and complexity in it’s own way. It’s always easy to create complexity but sometimes it’s impossible to avoid it. Note also that we are doing a looping retrieve from the database again, not exactly optimized.

The if(empty($params[‘offset’])) conditional is necessary because we need to set $this->session->category->tot_count to a proper value when the search is executed, but only once.

AssignPaginationArray:

function assignPaginationArray($tot_count, $count, $selected){
  $selected = empty($selected) ? 0 : $selected;
  $arr = array();
  $display = 1;
  for($i = 0; $i < $tot_count; $i += $count){
    $temp = array();
    $temp['offset'] = $i;
    $temp['display'] = $display;
    if($i == $selected)
      $temp['selected'] = "selected";
    $display++;
    $arr[] = $temp;
  }
  $this->assign('pagination_arr', $arr);
}

Nothing special here, it’s just creating the array we need to draw the pagination controls. As you can see there is no grouping of pages being done at this stage. That will be added later if needed. In this case we have 2 results per page, a 1000 item result will actually draw 500 page links numbered from 1 to 500. We will see what happens, we will probably have to implement some grouping eventually but at the moment we need something to show asap and this works. Whatever the managers are calling it, “proof of concept” or a “testable prototype”, we all know what they mean, a dirty first iteration that they can play around with.

I think we are finally ready to take a look at blog_cat_search_result.tpl:

{foreach from=$categories key=cat item=article_num}
	<div>
		<a href="{$baseUrl}/index/d/c/blog/f/getArticlesByCatName/cat_name/{$cat}">{$cat} ({$article_num})</a>
	</div>
{/foreach}
<br/>
{include file="paginate.tpl" f="searchCategory" c="blog"}

And paginate.tpl:

{foreach from=$pagination_arr item=page}
	<a href="{$baseUrl}/index/d/c/{$c}/f/{$f}/offset/{$page.offset}">
		{if $page.selected eq "selected"}
			>>{$page.display}<<
		{else}
			{$page.display}
		{/if}
	</a>
	&nbsp;
{/foreach}

Nothing much to comment on here, as you can see the pagination template can be dropped as a widget anywhere where the required back end logic is in place, let’s move on to getArticlesByCatName:

function getArticlesByCatName($params){
  $categories = $this->cat_obj->fetchAll("headline = '{$params['cat_name']}'");
  $articles = array();
  if($categories){
    foreach($categories as $cat)
      $articles += $cat->findManyToManyRowset('blog', 'blogcatcon')->toArray();
  }
  $this->assign('articles', $articles);
  return $this->fetch('blog_view_articles.tpl');
}

Yet again a loop that queries the database… blog_view_articles.tpl:

{foreach from=$articles item=article}
	<div>
		<div>
			<a href="{$baseUrl}/index/d/c/blog/f/viewArticle/id/{$article.id}">
				{$article.headline}
			</a>
			&nbsp;{#by#}&nbsp;
			<a href="{$baseUrl}/index/d/c/user/f/view/id/{$article.user_id}">{$article.username}</a>
			&nbsp;|&nbsp;{$article.post_date|date_format:$date_format}
		</div>
	</div>
{/foreach}

Here the new username field in com_blog makes sense, if it wasn’t already there we would have had to do yet another table join to retrieve it. Moreover, since articles will not be able to change authors this will not prove to be a problem. For once we have a change that makes sense from both an optimization and dev-speed point of view!

There is one more change of importance, during the process leading up to the above logic I thought it would be nice to integrate the gallery into the blog by enabling the gallery thumbs in blog_write.tpl. The goal was to have the thumbs show if the user wanted and if clicked inserted into TinyMCE. Let’s take a look:

.
.
.
tinyMCE.init({
	mode : "textareas",
});
	
{/literal}
// ]]>
</script>
{if $update eq "yes"}
	<form id="save_article_form" action="{$baseUrl}/index/d/c/blog/f/saveUpdatedArticle/id/{$article.id}/" method="post">
{else}
	<form id="save_article_form" action="{$baseUrl}/index/d/c/blog/f/saveArticle" method="post">
{/if}
<table>
	<tr>
		<td>
			{insert name="item" controller="Gallery" function="view" template="blog_gallery.tpl"}
			<label>{#article_headline#}:
				<br/>
				<input type="text" style="width:250px;" value="{$article.headline}" class="validator-required" id="headline" name="headline" title="{#article_headline#} - {#headline_err#}">
			</label>
			<br/>
.
.
.

Obviously some changes to the gallery controller view() has been made:

function view($params){
  $this->chkSessRdr();
  if(!empty($params['id']))
    $this->setDirs($params['id']);
  $this->assign("gallery_dir", $this->dir);
  $this->commonThumbs();
  if(empty($params['template']))
    return $this->fetch('gallery_view.tpl');
  else
    return $this->fetch($params['template']);
}

Not much new, we can override the default template now. Onwards to blog_gallery.tpl:

{if !empty($thumbs)}
{config_load file="$config_file"}
<script language="javascript" type="text/javascript">
	var galleryDir 	= "{$baseUrl}"+"/{$gallery_dir}";
	{literal}
	function toggleViz(el_id){ 
	    $("#"+el_id).toggleClass("hide_me"); 
	}
	
	function insertPic(pic){
		var image_html = '<img src="'+galleryDir+pic+'"/>';
		tinyMCE.setContent( tinyMCE.getContent('article') + image_html );
	}

	{/literal}
</script>
<a href="javascript:toggleViz('gallery')">{#toggle_gallery#}</a>
<br/>
<div id="gallery" class="hide_me">
	{#gallery_explain#}
	<br/>
	{foreach from=$thumbs item=thumb}
		<a href="javascript:insertPic('{$thumb}')">
			<img class="gallery_thumb" src="{$baseUrl}/{$thumb_dir}{$thumb}"/>
		</a>
	{/foreach}
</div>
{/if}

And the CSS:

.hide_me{
	width:0px;
	height:0px;
	visibility:hidden;
}

As you can see we can toggle the thumbs on and off, when a thumb gets clicked it will be inserted at the end of the content currently in TinyMCE. Yes I tried like a maniac to get it to work by being inserted at the place where the marker currently is in the editor but I couldn’t make it work. Probably because it loses focus when you click somewhere outside it and then it becomes impossible to read the marker position but I’m not 100% on this one. If you’ve discovered a solution then please please let me know. Of course it might be possible to create some kind of plugin to TinyMCE but with the state of my basic Javascript skills and the time pressures I’m under… Not likely at the moment.

blog_write2.jpg

Related Posts

Tags: , , ,