Creating the add-on Similar Threads v1.1

AndyB

Well-known member
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)
  • Simple one word search
  • 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:

pic001.webp
 
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
 
Create the directory structure:

js
--xenforo
----similarthreads.js
library
--Andy
----SimilarThreads
------ControllerPublic
--------Abstract.php
--------Thread.php
------Model.php
------Route
--------Prefix
----------SimilarThreads.php
 
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);
[/php]
 
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
			$searchWord = '';
			$searchWords = array();
			$safeSearchWord = '';   
			$threadId = '';    
				
			// 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);
			
			// only continue if we have a search word
			if ($count > 0)
			{
				// get first none common word
				$searchWord = $searchWords[0];
			
				// make safe for database
				$safeSearchWord = addslashes($searchWord);
			}
			
			// run query only if we have a search
			if ($safeSearchWord != '')
			{
				// run query in model    
				$threads = $this->getModelFromCache('Andy_SimilarThreads_Model')->getThreads($safeSearchWord,$threadId);    
			} 
			else 
			{
				// $viewParams needs to be an array
				$threads = array();                
			}
			
			// prepare $viewParams for template
			$viewParams = array(
				'threads' => $threads,
				'searchWord' => $searchWord,
			);
			
			// send to template
			return $this->responseView('Andy_SimilarThreads_ViewPublic_SimilarThreads', 'andy_similarthreads', $viewParams);
		}
	}
}

?>
 
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
			$searchWord = '';
			$searchWords = array();
			$safeSearchWord = ''; 
			
			// 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 none common word
				$searchWord = $searchWords[0];
			
				// make safe for database
				$safeSearchWord = addslashes($searchWord);
			}
			
			// run query only if we have a search
			if ($safeSearchWord != '')
			{
				// run query in model    
				$threads = $this->getModelFromCache('Andy_SimilarThreads_Model')->getThreads($safeSearchWord,$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,
				'searchWord' => $searchWord,
			));
				
			return $this->responseView('XenForo_ViewPublic_Thread_View', 'thread_view', $viewParams);
		}
		else
		{
			// run the original actionIndex() function 
			return parent::actionIndex();	
		}
	}
}

?>
 
Create the Model.php file:

library/Andy/SimilarThreads/Model.php

PHP:
<?php

class Andy_SimilarThreads_Model extends XenForo_Model
{	
	public function getThreads($safeSearchWord,$threadId)
	{ 		
		// 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)
		{
			$view[] = $v['view'];
			
			if ($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 = '';
		} 
		
		// declare variable
		$whereclause2 = '';
		
		// coming from Abstract.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;		
		
		// get threads
		return $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 '%$safeSearchWord%'
			AND xf_thread.discussion_type <> 'redirect'
			$whereclause1
			$whereclause2
			ORDER BY xf_thread.thread_id DESC
			LIMIT $maxResults
		");		
	}
}

?>
 
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);
	}
}

?>
 
Create the Listener.php file:

PHP:
<?php

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

?>
 
Create the Template andy_similarthreads:

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

    <div class="sectionMain similarThreads">
    
        <div class="primaryContent">
        	{xen:phrase similar_threads_found_with_the_word_in_the_thread_title, 'searchWord={$searchWord}'}
        </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>
 
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

pic005.webp
 
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:

pic006.webp
 
Create the Template Modifications:

There are five Template Modifications that need to be created.

The final result will look like this:

pic007.webp
 
Create the page_container_js_head Template Modification:

Find:
Code:
<!--XenForo_Require:JS-->

Replace:
Code:
$0
<script src="js/xenforo/similarthreads.js"></script>
 
Create the thread_create #1 Template Modification:

Find:
Code:
data-redirect="on"

Replace:
Code:
$0
id="similarthreadsId" autocomplete="off"
 
Create the thread_create #2 Template Modification:

Find:
Code:
<dl class="ctrlUnit fullWidth">

Replace:
Code:
<dl id="similarthreadsId-result"></dl>$0
 
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" />
 
Top Bottom