• This site uses cookies. By continuing to use this site, you are agreeing to our use of cookies. Learn more.

Creating the add-on Similar Threads v1.2

AndyB

Well-known member
#1
Description:

The Similar Threads add-on will do the following:
  • Show a list of similar threads when a user is creating a new thread (location below thread title)
  • Show a list of similar threads when a user is viewing a thread (location below quick reply)

Key Features:
  • Limit number of results (Admin Control Panel Setting)
  • Exclude common words (Admin Control Panel Setting)
  • Search up to three key words with highest relevancy shown at top
  • Node permissions honored
  • Similar Threads immediately displayed
  • Automatically disabled for displays 800px or less (Responsive design)
  • Fully phrased
How it Works:
  • When a user types a new thread title, as soon as a non-common word is entered a similar threads list is displayed
  • When a user is viewing a thread, the similar threads list is displayed below the quick reply

Example (Create Thread):

pic001.jpg

Example (Thread View):

pic002.jpg
 

AndyB

Well-known member
#2
This tutorial will show the steps required to create this add-on:
  1. Create the directory structure
  2. Create the New Add-on
  3. Create the similarthreads.js file
  4. Create the Abstract.php file
  5. Create the Thread.php file
  6. Create the Model.php file
  7. Create the SimilarThreads.php file
  8. Create the Listener.php file
  9. Create the Route Prefix
  10. Create the Code Event Listener (Thread)
  11. Create the Template andy_similarthreads
  12. Create the Option Group
  13. Create the Options
  14. Create the Template Modifications
  15. Create the page_container_js_head Template Modification
  16. Create the thread_create #1 Template Modification
  17. Create the thread_create #2 Template Modification
  18. Create the thread_view Template Modification
  19. Create the xenforo_data_table.css Template Modification
  20. Create the New Phrase
 

AndyB

Well-known member
#3
Create the directory structure:

js
--xenforo
----similarthreads.js
library
--Andy
----SimilarThreads
------ControllerPublic
--------Abstract.php
--------Thread.php
------Model.php
------Route
--------Prefix
----------SimilarThreads.php
 

AndyB

Well-known member
#5
Create the similarthreads.js file:

js/xenforo/similarthreads.js

Code:
!function($, window, document, _undefined)
{
	XenForo.similarthreadsId = function($form)
	{		
		var typewatch = (function()
		{
			var timer = 0;
			return function(callback, ms)
			{
				clearTimeout (timer);
				timer = setTimeout(callback, ms);
			}  
		})(); 
		$title = $form.find('input[name="title"]');
		$title.keyup(function() 
		{
			typewatch(function () 
			{
				XenForo.ajax(
				$('base').attr('href') + 'similarthreads/',
				$form.serializeArray(),
				function(ajaxData, textStatus)
				{
					if (ajaxData.templateHtml)
					{
						new XenForo.ExtLoader(ajaxData, function()
						{
							$('#similarthreadsId-result').html('<div>' + ajaxData.templateHtml + '</div>');
						});
					}
				});
			}, 500);
		});		
	}	
	XenForo.register('#similarthreadsId', 'XenForo.similarthreadsId');	
}
(jQuery, this, document);
 

AndyB

Well-known member
#6
Create the Abstract.php file:

library/Andy/SimilarThreads/ControllerPublic/Abstract.php

PHP:
<?php

class Andy_SimilarThreads_ControllerPublic_Abstract extends XenForo_ControllerPublic_Abstract
{
    public function actionIndex()
    { 
        // get options from Admin CP -> Options -> Similar Threads -> Show on new thread    
        $showNew = XenForo_Application::get('options')->showNew;	
	
		// show similar threads if true
		if ($showNew)
		{
			// declare variables
			$threadId = '';
			$searchWords = array();   			
			$searchWord1 = '';
			$searchWord2 = '';
			$searchWord3 = '';
			$safeSearchWord1 = ''; 
			$safeSearchWord2 = ''; 
			$safeSearchWord3 = '';  
				
			// get newTitle
			$newTitle = $this->_request->getParam('title');
	
			// put into array
			$newTitle = explode(' ', $newTitle);
			
			// get options from Admin CP -> Options -> Similar Threads -> Common Words    
			$commonWords = XenForo_Application::get('options')->commonWords;
			
			// convert to lowercase
			$commonWords = strtolower($commonWords);    
			
			// put $commonWordsLower into an array
			$commonWords = explode(' ', $commonWords); 
			
			// remove any common words from array
			foreach($newTitle as $var)
			{
				if (!in_array(strtolower($var), $commonWords))
				{
					$searchWords[] = $var;
				}
			}
			
			$count = count($searchWords);
			
			if ($count > 0)
			{
				// get first search word
				$searchWord1 = $searchWords[0];
				
				// make safe for database
				$safeSearchWord1 = addslashes($searchWord1);
				
				if ($count > 1)
				{
					// get second search word
					$searchWord2 = $searchWords[1];
					
					// make safe for database
					$safeSearchWord2 = addslashes($searchWord2);	
				}
				
				if ($count > 2)
				{
					// get third search word
					$searchWord3 = $searchWords[2];
					
					// make safe for database
					$safeSearchWord3 = addslashes($searchWord3);	
				} 			
			}
			
			// run query only if we have a search
			if ($safeSearchWord1 != '')
			{
				// run query in model    
				$threads = $this->getModelFromCache('Andy_SimilarThreads_Model')->getThreads($safeSearchWord1,$safeSearchWord2,$safeSearchWord3,$threadId);    
			} 
			else 
			{
				// $viewParams needs to be an array
				$threads = array();                
			}
			
			// prepare $viewParams for template
			$viewParams = array(
				'threads' => $threads,
			);
			
			// send to template
			return $this->responseView('Andy_SimilarThreads_ViewPublic_SimilarThreads', 'andy_similarthreads', $viewParams);
		}
	}
}

?>
 

AndyB

Well-known member
#7
Create the Thread.php file:

library/Andy/SimilarThreads/ControllerPublic/Thread.php

PHP:
<?php

class Andy_SimilarThreads_ControllerPublic_Thread extends XFCP_Andy_SimilarThreads_ControllerPublic_Thread
{	
	public function actionIndex()
	{	
        // get options from Admin CP -> Options -> Similar Threads -> Show on Thread View    
        $showThreadView = XenForo_Application::get('options')->showThreadView;	
	
		// show similar threads if true
		if ($showThreadView)
		{ 							
			//########################################
			// start default xenforo code
			//########################################		
			
			$threadId = $this->_input->filterSingle('thread_id', XenForo_Input::UINT);
	
			$ftpHelper = $this->getHelper('ForumThreadPost');
			list($threadFetchOptions, $forumFetchOptions) = $this->_getThreadForumFetchOptions();
			list($thread, $forum) = $ftpHelper->assertThreadValidAndViewable($threadId, $threadFetchOptions, $forumFetchOptions);
	
			$visitor = XenForo_Visitor::getInstance();
			$threadModel = $this->_getThreadModel();
			$postModel = $this->_getPostModel();
	
			if ($threadModel->isRedirect($thread))
			{
				$redirect = $this->getModelFromCache('XenForo_Model_ThreadRedirect')->getThreadRedirectById($thread['thread_id']);
				if (!$redirect)
				{
					return $this->responseNoPermission();
				}
				else
				{
					return $this->responseRedirect(
						XenForo_ControllerResponse_Redirect::RESOURCE_CANONICAL_PERMANENT,
						$redirect['target_url']
					);
				}
			}
	
			$page = max(1, $this->_input->filterSingle('page', XenForo_Input::UINT));
			$postsPerPage = XenForo_Application::get('options')->messagesPerPage;
	
			$this->canonicalizePageNumber($page, $postsPerPage, $thread['reply_count'] + 1, 'threads', $thread);
			$this->canonicalizeRequestUrl(
				XenForo_Link::buildPublicLink('threads', $thread, array('page' => $page))
			);
	
			$postFetchOptions = $this->_getPostFetchOptions($thread, $forum);
			$postFetchOptions += array(
				'perPage' => $postsPerPage,
				'page' => $page
			);
	
			$posts = $postModel->getPostsInThread($threadId, $postFetchOptions);
	
			// TODO: add a sanity check to ensure we got posts (invalid thread if page 1, invalid page otherwise)
	
			$posts = $postModel->getAndMergeAttachmentsIntoPosts($posts);
	
			$inlineModOptions = array();
			$maxPostDate = 0;
			$firstUnreadPostId = 0;
	
			$deletedPosts = 0;
			$moderatedPosts = 0;
	
			$pagePosition = 0;
	
			$permissions = $visitor->getNodePermissions($thread['node_id']);
			foreach ($posts AS &$post)
			{
				$post['position_on_page'] = ++$pagePosition;
	
				$postModOptions = $postModel->addInlineModOptionToPost(
					$post, $thread, $forum, $permissions
				);
				$inlineModOptions += $postModOptions;
	
				$post = $postModel->preparePost($post, $thread, $forum, $permissions);
	
				if ($post['post_date'] > $maxPostDate)
				{
					$maxPostDate = $post['post_date'];
				}
	
				if ($post['isDeleted'])
				{
					$deletedPosts++;
				}
				if ($post['isModerated'])
				{
					$moderatedPosts++;
				}
	
				if (!$firstUnreadPostId && $post['isNew'])
				{
					$firstUnreadPostId = $post['post_id'];
				}
			}
	
			if ($firstUnreadPostId)
			{
				$requestPaths = XenForo_Application::get('requestPaths');
				$unreadLink = $requestPaths['requestUri'] . '#post-' . $firstUnreadPostId;
			}
			else if ($thread['isNew'])
			{
				$unreadLink = XenForo_Link::buildPublicLink('threads/unread', $thread);
			}
			else
			{
				$unreadLink = '';
			}
	
			$attachmentHash = null;
			if (!empty($thread['draft_extra']))
			{
				$draftExtra = @unserialize($thread['draft_extra']);
				if (!empty($draftExtra['attachment_hash']))
				{
					$attachmentHash = $draftExtra['attachment_hash'];
				}
			}
	
			$attachmentParams = $this->_getForumModel()->getAttachmentParams($forum, array(
				'thread_id' => $thread['thread_id']
			), null, null, $attachmentHash);
	
			if ($thread['discussion_type'] == 'poll')
			{
				$pollModel = $this->_getPollModel();
				$poll = $pollModel->getPollByContent('thread', $threadId);
				if ($poll)
				{
					$poll = $pollModel->preparePoll($poll, $threadModel->canVoteOnPoll($thread, $forum));
					$poll['canEdit'] = $threadModel->canEditPoll($thread, $forum);
				}
			}
			else
			{
				$poll = false;
			}
	
			$threadModel->markThreadRead($thread, $forum, $maxPostDate);
			$threadModel->logThreadView($threadId);
			
			//########################################
			// start add-on code
			//########################################	
			
			// declare variables
			$searchWords = array();
			$searchWord1 = '';
			$searchWord2 = '';
			$searchWord3 = '';
			$safeSearchWord1 = ''; 
			$safeSearchWord2 = ''; 
			$safeSearchWord3 = '';   
			
			// get thread title
			$parent = parent::actionIndex();
			if ($parent instanceof XenForo_ControllerResponse_View)
			{
				$threadTitle = $parent->params['thread']['title'];
			} 					
		
			// put into array
			$threadTitle = explode(' ', $threadTitle);
			
			// get options from Admin CP -> Options -> Similar Threads -> Common Words    
			$commonWords = XenForo_Application::get('options')->commonWords;
			
			// convert to lowercase
			$commonWords = strtolower($commonWords);    
			
			// put $commonWordsLower into an array
			$commonWords = explode(' ', $commonWords); 
			
			// remove any common words from array
			foreach($threadTitle as $var)
			{
				if (!in_array(strtolower($var), $commonWords))
				{
					$searchWords[] = $var;
				}
			}
			
			$count = count($searchWords);
			
			// only continue if we have a search word
			if ($count > 0)
			{
				// get first search word
				$searchWord1 = $searchWords[0];
				
				// make safe for database
				$safeSearchWord1 = addslashes($searchWord1);
				
				if ($count > 1)
				{
					// get second search word
					$searchWord2 = $searchWords[1];
					
					// make safe for database
					$safeSearchWord2 = addslashes($searchWord2);	
				}
				
				if ($count > 2)
				{
					// get third search word
					$searchWord3 = $searchWords[2];
					
					// make safe for database
					$safeSearchWord3 = addslashes($searchWord3);	
				} 			
			}
			
			// run query only if we have a search
			if ($safeSearchWord1 != '')
			{
				// run query in model    
				$threads = $this->getModelFromCache('Andy_SimilarThreads_Model')->getThreads($safeSearchWord1,$safeSearchWord2,$safeSearchWord3,$threadId);    
			} 
			else 
			{
				// $viewParams needs to be an array
				$threads = array();                
			} 
	
			//########################################
			// end add-on code
			//########################################	
	
			$viewParams = $this->_getDefaultViewParams($forum, $thread, $posts, $page, array(
				'deletedPosts' => $deletedPosts,
				'moderatedPosts' => $moderatedPosts,
	
				'inlineModOptions' => $inlineModOptions,
	
				'firstPost' => reset($posts),
				'lastPost' => end($posts),
				'unreadLink' => $unreadLink,
	
				'poll' => $poll,
	
				'attachmentParams' => $attachmentParams,
				'attachmentConstraints' => $this->_getAttachmentModel()->getAttachmentConstraints(),
	
				'showPostedNotice' => $this->_input->filterSingle('posted', XenForo_Input::UINT),
	
				'nodeBreadCrumbs' => $ftpHelper->getNodeBreadCrumbs($forum),
				
				// add-on variables added here
				'threads' => $threads,
			));
				
			return $this->responseView('XenForo_ViewPublic_Thread_View', 'thread_view', $viewParams);
		}
		else
		{
			// run the original actionIndex() function 
			return parent::actionIndex();	
		}
	}
}

?>
 

AndyB

Well-known member
#8
Create the Model.php file:

library/Andy/SimilarThreads/Model.php

PHP:
<?php

class Andy_SimilarThreads_Model extends XenForo_Model
{	
	public function getThreads($safeSearchWord1,$safeSearchWord2,$safeSearchWord3,$threadId)
	{ 
		// declare variables
		$results1 = array();
		$results2 = array();
		$results3 = array();
		$whereclause1 = '';
		$whereclause2 = '';
		$whereclause3 = '';	
		$whereclause4 = '';	
		$resultsCount1 = array();
		$resultsCount2 = array();			
	
		//########################################
		// permissions
		//########################################
					
		// declare variable	
		$visitor = XenForo_Visitor::getInstance();			
		$permissionCombinationId = $visitor['permission_combination_id'];							
		
		// get permissions
		$permissions = $this->_getDb()->fetchAll("
			SELECT xf_permission_cache_content.cache_value, xf_permission_cache_content.content_id
			FROM xf_permission_cache_content
			INNER JOIN xf_forum ON xf_forum.node_id = xf_permission_cache_content.content_id
			WHERE xf_permission_cache_content.permission_combination_id = '$permissionCombinationId'
			ORDER BY xf_permission_cache_content.content_id ASC
		");	
		
		foreach ($permissions AS $k => $v)
		{
			$cache_value[] = unserialize($v['cache_value']);
			$content_id[] = $v['content_id'];				
		}
		
		$i = 0;
		
		foreach ($cache_value AS $v)
		{
			if (isset($v['view']))
			{
				$view[] = $v['view'];
			}
			
			if (isset($v['view']) AND $v['view'] != 1)
			{
				$forum_id_no_permission[] = $content_id[$i];
			}
			$i++;
		} 
		
		// create whereclause1 for forums the user cannot view
		if (isset($forum_id_no_permission))
		{
			$whereclause1 = 'AND xf_thread.node_id <> ' . implode(' AND xf_thread.node_id <> ', $forum_id_no_permission);
		}
		else
		{
			$whereclause1 = '';
		}
		
		//########################################
		// exclude thread that is being viewed
		//########################################
		
		// coming from Thread.php (thread view), don't include the thread we are viewing
		if ($threadId != '')
		{
			$whereclause2 = "AND xf_thread.thread_id <> '$threadId'";
		}
		
		// get option from Admin CP -> Options -> Similar Threads -> Maximum Results	
		$maxResults = XenForo_Application::get('options')->maxResults; 
		
		//########################################
		// search 1
		//########################################
		
		// get threads
		$results1 = $this->_getDb()->fetchAll("
			SELECT xf_thread.thread_id, xf_thread.title, xf_thread.node_id, xf_node.title AS nodetitle
			FROM xf_thread
			INNER JOIN xf_node ON xf_node.node_id = xf_thread.node_id
			WHERE xf_thread.title LIKE '%$safeSearchWord1%'
			AND xf_thread.title LIKE '%$safeSearchWord2%'
			AND xf_thread.title LIKE '%$safeSearchWord3%'
			AND xf_thread.discussion_type <> 'redirect'
			$whereclause1
			$whereclause2
			ORDER BY xf_thread.thread_id DESC
			LIMIT $maxResults
		");	
		
		// prepare $results for return
		$results = $results1;
		
		//########################################
		// search 2
		//########################################
		
		foreach ($results1 AS $k => $v)
		{
			$resultsCount1[] = $v['thread_id'];
			
			// exclude previously found thread_id's
			$whereclause3 = 'AND xf_thread.thread_id <> ' . implode(' AND xf_thread.thread_id <> ', $resultsCount1);
		}
		
		$count = count($resultsCount1);
		
		if ($count < $maxResults AND is_numeric($count))
		{
			$maxResults2 = $maxResults - $count;
			
			// get threads
			$results2 = $this->_getDb()->fetchAll("
				SELECT xf_thread.thread_id, xf_thread.title, xf_thread.node_id, xf_node.title AS nodetitle
				FROM xf_thread
				INNER JOIN xf_node ON xf_node.node_id = xf_thread.node_id
				WHERE xf_thread.title LIKE '%$safeSearchWord1%'
				AND xf_thread.title LIKE '%$safeSearchWord2%'
				AND xf_thread.discussion_type <> 'redirect'
				$whereclause1
				$whereclause2
				$whereclause3
				ORDER BY xf_thread.thread_id DESC
				LIMIT $maxResults2
			");	
			
			// prepare $results for return
			$results = array_merge($results1, $results2);
		}
		
		//########################################
		// search 3
		//########################################
		
		foreach ($results2 AS $k => $v)
		{
			$resultsCount2[] = $v['thread_id'];
			
			// exclude previously found thread_id's
			$whereclause4 = 'AND xf_thread.thread_id <> ' . implode(' AND xf_thread.thread_id <> ', $resultsCount2);
		}
		
		$count = count($resultsCount1) + count($resultsCount2);
		
		if ($count < $maxResults AND is_numeric($count))
		{
			$maxResults3 = $maxResults - $count;
			
			// get threads
			$results2 = $this->_getDb()->fetchAll("
				SELECT xf_thread.thread_id, xf_thread.title, xf_thread.node_id, xf_node.title AS nodetitle
				FROM xf_thread
				INNER JOIN xf_node ON xf_node.node_id = xf_thread.node_id
				WHERE xf_thread.title LIKE '%$safeSearchWord1%'
				AND xf_thread.discussion_type <> 'redirect'
				$whereclause1
				$whereclause2
				$whereclause3
				$whereclause4
				ORDER BY xf_thread.thread_id DESC
				LIMIT $maxResults3
			");	
			
			// prepare $results for return
			$results = array_merge($results1, $results2);
		} 
		
		//########################################
		// return results
		//########################################		
		
		return $results;	
	}
}

?>
 
Last edited:

AndyB

Well-known member
#9
Create the SimilarThreads.php file:

library/Andy/SimilarThreads/Model/Route/Prefix/SimilarThreads.php

PHP:
<?php

class Andy_SimilarThreads_Route_Prefix_SimilarThreads implements XenForo_Route_Interface
{
	public function match($routePath, Zend_Controller_Request_Http $request, XenForo_Router $router)
	{
		return $router->getRouteMatch('Andy_SimilarThreads_ControllerPublic_Abstract', 'Index', $routePath);
	}
}

?>
 

AndyB

Well-known member
#10
Create the Listener.php file:

library/Andy/SimilarThreads/Listener.php

PHP:
<?php

class Andy_SimilarThreads_Listener
{
	public static function Thread($class, array &$extend)
	{
		$extend[] = 'Andy_SimilarThreads_ControllerPublic_Thread';
	}
}

?>
 
Last edited:

AndyB

Well-known member
#13
Create the Template andy_similarthreads:

Code:
<xen:if is="{$threads}">

    <div class="sectionMain similarThreads">
    
        <div class="primaryContent">
        	{xen:phrase similar_threads}
        </div>
        
        <table class="dataTable">
        
        <tr class="dataRow">
        <th>{xen:phrase forum}</th>
        <th>{xen:phrase title}</th>
        </tr>
        
        <xen:foreach loop="$threads" key="$index" value="$thread">
        
        <tr class="dataRow">
        <td><a href="{xen:link threads, $thread}" title="{$thread.nodetitle}" target="_blank">{$thread.nodetitle}</a></td>
        <td><a href="{xen:link threads, $thread}" title="{$thread.title}" target="_blank">{$thread.title}</a></td>
        </tr>
        
        </xen:foreach>
        
        </table>

    </div>

</xen:if>

<xen:if is="!{$threads}">
</xen:if>
 

AndyB

Well-known member
#14
Create the Option Group:

This will add to the Admin CP to allow configuring the Similar Threads add-on.

Admin CP -> Options -> + Add Option Group

pic006.jpg
 

AndyB

Well-known member
#15
Create the Options:

Create the two options. Refer to the similarthreads.xml file for detailed information on each option.

The final result will look like this:

pic007.jpg
 

AndyB

Well-known member
#16
Create the Template Modifications:

There are five Template Modifications that need to be created.

The final result will look like this:

pic008.jpg
 

AndyB

Well-known member
#17
Create the page_container_js_head Template Modification:

Find:
Code:
<!--XenForo_Require:JS-->
Replace:
Code:
$0
<script src="js/xenforo/similarthreads.js"></script>
 

AndyB

Well-known member
#18
Create the thread_create #1 Template Modification:

Find:
Code:
data-redirect="on"
Replace:
Code:
$0
id="similarthreadsId" autocomplete="off"
 

AndyB

Well-known member
#19
Create the thread_create #2 Template Modification:

Find:
Code:
<dl class="ctrlUnit fullWidth">
Replace:
Code:
<dl id="similarthreadsId-result"></dl>$0
 

AndyB

Well-known member
#20
Create the thread_view Template Modification:

Find:
Code:
<xen:if is="{$canQuickReply}">
   <xen:include template="quick_reply">
     <xen:set var="$formAction">{xen:link 'threads/add-reply', $thread}</xen:set>
     <xen:set var="$lastDate">{$lastPost.post_date}</xen:set>
     <xen:set var="$lastKnownDate">{$thread.last_post_date}</xen:set>
     <xen:set var="$showMoreOptions">1</xen:set>
   </xen:include>
</xen:if>
Replace:
Code:
$0
<xen:include template="andy_similarthreads" />