Merged com.woltlab.wcf.search into WCF
authorMarcel Werk <burntime@woltlab.com>
Mon, 20 May 2013 23:00:09 +0000 (01:00 +0200)
committerMarcel Werk <burntime@woltlab.com>
Mon, 20 May 2013 23:00:09 +0000 (01:00 +0200)
27 files changed:
com.woltlab.wcf/objectTypeDefinition.xml
com.woltlab.wcf/option.xml
com.woltlab.wcf/template/header.tpl
com.woltlab.wcf/template/search.tpl [new file with mode: 0644]
com.woltlab.wcf/template/searchArea.tpl [new file with mode: 0644]
com.woltlab.wcf/template/searchResult.tpl [new file with mode: 0644]
com.woltlab.wcf/template/searchResultList.tpl [new file with mode: 0644]
wcfsetup/install/files/js/WCF.Search.Message.js [new file with mode: 0644]
wcfsetup/install/files/js/WCF.Search.Message.min.js [new file with mode: 0644]
wcfsetup/install/files/lib/data/search/ISearchResultObject.class.php [new file with mode: 0644]
wcfsetup/install/files/lib/data/search/keyword/SearchKeyword.class.php [new file with mode: 0644]
wcfsetup/install/files/lib/data/search/keyword/SearchKeywordAction.class.php [new file with mode: 0644]
wcfsetup/install/files/lib/data/search/keyword/SearchKeywordEditor.class.php [new file with mode: 0644]
wcfsetup/install/files/lib/data/search/keyword/SearchKeywordList.class.php [new file with mode: 0644]
wcfsetup/install/files/lib/form/SearchForm.class.php [new file with mode: 0644]
wcfsetup/install/files/lib/page/SearchResultPage.class.php [new file with mode: 0644]
wcfsetup/install/files/lib/system/cronjob/DailyCleanUpCronjob.class.php
wcfsetup/install/files/lib/system/search/AbstractSearchableObjectType.class.php [new file with mode: 0644]
wcfsetup/install/files/lib/system/search/ISearchableObjectType.class.php [new file with mode: 0644]
wcfsetup/install/files/lib/system/search/SearchEngine.class.php [new file with mode: 0644]
wcfsetup/install/files/lib/system/search/SearchIndexManager.class.php [new file with mode: 0644]
wcfsetup/install/files/lib/system/search/SearchKeywordManager.class.php [new file with mode: 0644]
wcfsetup/install/files/lib/system/search/SearchResultTextParser.class.php [new file with mode: 0644]
wcfsetup/install/files/style/search.less [new file with mode: 0644]
wcfsetup/install/lang/de.xml
wcfsetup/install/lang/en.xml
wcfsetup/setup/db/install.sql

index 2596592e8c0e3dba0753689dca510f3ee2a5adcf..2a5d72bfb46ee23cc3b64515cbde449dd52d24ee 100644 (file)
                        <name>com.woltlab.wcf.tagging.taggableObject</name>
                        <interfacename>wcf\system\tagging\ITaggable</interfacename>
                </definition>
+               
+               <definition>
+                       <name>com.woltlab.wcf.searchableObjectType</name>
+                       <interfacename>wcf\system\search\ISearchableObjectType</interfacename>
+               </definition>
        </import>
 </data>
index e9a602c1877411dd51cfce67402c58d4d1ca88f0..fe2fd279345497e66c038c3d3dca1060e1f40a11 100644 (file)
                                <category name="message.sidebar">
                                        <parent>message</parent>
                                </category>
+                               
+                               <category name="message.search">
+                                       <parent>message</parent>
+                               </category>
                        <!-- /message -->
                        
                        <category name="dashboard">
@@ -1023,6 +1027,35 @@ DESC:wcf.global.sortOrder.descending]]></selectoptions>
                                <maxvalue>255</maxvalue>
                        </option>
                        <!-- /message.general -->
+                       
+                       <option name="search_results_per_page">
+                               <categoryname>message.search</categoryname>
+                               <optiontype>integer</optiontype>
+                               <defaultvalue>20</defaultvalue>
+                               <minvalue>5</minvalue>
+                               <maxvalue>40</maxvalue>
+                       </option>
+                       <option name="search_use_captcha">
+                               <categoryname>security.antispam</categoryname>
+                               <optiontype>boolean</optiontype>
+                       </option>
+                       
+                       <option name="search_default_sort_field">
+                               <categoryname>message.search</categoryname>
+                               <optiontype>select</optiontype>
+                               <selectoptions><![CDATA[relevance:wcf.search.sortBy.relevance
+subject:wcf.global.subject
+time:wcf.search.sortBy.time
+username:wcf.search.sortBy.username]]></selectoptions>
+                               <defaultvalue><![CDATA[relevance]]></defaultvalue>
+                       </option>
+                       <option name="search_default_sort_order">
+                               <categoryname>message.search</categoryname>
+                               <optiontype>select</optiontype>
+                               <defaultvalue>DESC</defaultvalue>
+                               <selectoptions><![CDATA[ASC:wcf.global.sortOrder.ascending
+DESC:wcf.global.sortOrder.descending]]></selectoptions>
+                       </option>
                </options>
        </import>
 </data>
index 63cdb2585a91cbcab16034177e9799ceb4b37ca7..322f5df1b2ef1361f8993d1e888427ba715fcb6f 100644 (file)
@@ -13,7 +13,7 @@
                                        </ul>
                                {/hascontent}
                                
-                               {event name='searchArea'}
+                               {include file='searchArea'}
                        </div>
                </nav>
                
diff --git a/com.woltlab.wcf/template/search.tpl b/com.woltlab.wcf/template/search.tpl
new file mode 100644 (file)
index 0000000..86e58b7
--- /dev/null
@@ -0,0 +1,147 @@
+{include file='documentHeader'}
+
+<head>
+       <title>{lang}wcf.search.title{/lang} - {PAGE_TITLE|language}</title>
+       
+       {include file='headInclude'}
+</head>
+
+<body id="tpl{$templateName|ucfirst}">
+
+{include file='header'}
+
+<header class="boxHeadline">
+       <h1>{lang}wcf.search.title{/lang}</h1>
+</header>
+
+{include file='userNotice'}
+
+{if $errorField}
+       <p class="error">{lang}wcf.global.form.error{/lang}</p>
+{/if}
+
+{if $errorMessage|isset}
+       <p class="error">{@$errorMessage}</p>
+{/if}
+
+<form method="post" action="{link controller='Search'}{/link}">
+       <div class="container containerPadding marginTop">
+               <fieldset>
+                       <legend>{lang}wcf.search.general{/lang}</legend>
+                       
+                       <dl{if $errorField == 'q'} class="formError"{/if}>
+                               <dt><label for="searchTerm">{lang}wcf.search.query{/lang}</label></dt>
+                               <dd>
+                                       <input type="text" id="searchTerm" name="q" value="{$query}" class="long" maxlength="255" autofocus="autofocus" />
+                                       {if $errorField == 'q'}
+                                               <small class="innerError">
+                                                       {if $errorType == 'empty'}
+                                                               {lang}wcf.global.form.error.empty{/lang}
+                                                       {else}
+                                                               {lang}wcf.search.query.error.{@$errorType}{/lang}
+                                                       {/if}
+                                               </small>
+                                       {/if}
+                                       <label><input type="checkbox" name="subjectOnly" value="1"{if $subjectOnly == 1} checked="checked"{/if} /> {lang}wcf.search.subjectOnly{/lang}</label>
+                                       {event name='queryOptions'}
+                                       
+                                       <p><small>{lang}wcf.search.query.description{/lang}</small></p>
+                               </dd>
+                       </dl>
+                       
+                       <dl>
+                               <dt><label for="searchAuthor">{lang}wcf.search.author{/lang}</label></dt>
+                               <dd>
+                                       <input type="text" id="searchAuthor" name="username" value="{$username}" class="long" maxlength="255" autocomplete="off" />
+                                       <label><input type="checkbox" name="nameExactly" value="1"{if $nameExactly == 1} checked="checked"{/if} /> {lang}wcf.search.matchExactly{/lang}</label>
+                                       {event name='authorOptions'}
+                               </dd>
+                       </dl>
+                       
+                       <dl>
+                               <dt><label for="startDate">{lang}wcf.search.period{/lang}</label></dt>
+                               <dd>
+                                       <input type="date" id="startDate" name="startDate" value="{$startDate}" placeholder="{lang}wcf.date.period.start{/lang}" />
+                                       <input type="date" id="endDate" name="endDate" value="{$endDate}" placeholder="{lang}wcf.date.period.end{/lang}" />
+                                       {event name='periodOptions'}
+                               </dd>
+                       </dl>
+                       
+                       <dl>
+                               <dt><label for="sortField">{lang}wcf.search.sortBy{/lang}</label></dt>
+                               <dd>
+                                       <select id="sortField" name="sortField">
+                                               <option value="relevance"{if $sortField == 'relevance'} selected="selected"{/if}>{lang}wcf.search.sortBy.relevance{/lang}</option>
+                                               <option value="subject"{if $sortField == 'subject'} selected="selected"{/if}>{lang}wcf.search.sortBy.subject{/lang}</option>
+                                               <option value="time"{if $sortField == 'time'} selected="selected"{/if}>{lang}wcf.search.sortBy.time{/lang}</option>
+                                               <option value="username"{if $sortField == 'username'} selected="selected"{/if}>{lang}wcf.search.sortBy.username{/lang}</option>
+                                       </select>
+                                       
+                                       <select name="sortOrder">
+                                               <option value="ASC"{if $sortOrder == 'ASC'} selected="selected"{/if}>{lang}wcf.global.sortOrder.ascending{/lang}</option>
+                                               <option value="DESC"{if $sortOrder == 'DESC'} selected="selected"{/if}>{lang}wcf.global.sortOrder.descending{/lang}</option>
+                                       </select>
+                                       {event name='displayOptions'}
+                               </dd>
+                       </dl>
+                       
+                       {event name='generalFields'}
+                       
+                       <dl>
+                               <dt>{lang}wcf.search.type{/lang}</dt>
+                               <dd class="floated">
+                                       {foreach from=$objectTypes key=objectTypeName item=objectType}
+                                               {if $objectType->isAccessible()}
+                                                       <label><input id="{@'.'|str_replace:'_':$objectTypeName}" type="checkbox" name="types[]" value="{@$objectTypeName}"{if $objectTypeName|in_array:$selectedObjectTypes} checked="checked"{/if} /> {lang}wcf.search.type.{@$objectTypeName}{/lang}</label>
+                                               {/if}
+                                       {/foreach}
+                               </dd>
+                       </dl>
+               </fieldset>
+               
+               {event name='fieldsets'}
+               {if $useCaptcha}{include file='recaptcha'}{/if}
+               
+               {foreach from=$objectTypes key=objectTypeName item=objectType}
+                       {if $objectType->isAccessible() && $objectType->getFormTemplateName()}
+                               {assign var='__jsID' value='.'|str_replace:'_':$objectTypeName}
+                               <fieldset id="{@$__jsID}Form">
+                                       <legend>{lang}wcf.search.type.{@$objectTypeName}{/lang}</legend>
+                                       
+                                       {include file=$objectType->getFormTemplateName() application=$objectType->getApplication()}
+                                       
+                                       <script type="text/javascript">
+                                               //<![CDATA[
+                                               $(function() {
+                                                       $('#{@$__jsID}').click(function() {
+                                                               if (this.checked) $('#{@$__jsID}Form').wcfFadeIn();
+                                                               else $('#{@$__jsID}Form').wcfFadeOut();
+                                                       });
+                                                       {if !$objectTypeName|in_array:$selectedObjectTypes}$('#{@$__jsID}Form').hide();{/if}
+                                               });
+                                               //]]>
+                                       </script>
+                               </fieldset>
+                       {/if}
+               {/foreach}
+       </div>
+       
+       <div class="formSubmit">
+               <input type="submit" value="{lang}wcf.global.button.submit{/lang}" accesskey="s" />
+       </div>
+</form>
+
+{include file='footer'}
+
+<script type="text/javascript">
+       //<![CDATA[
+       $(function() {
+               new WCF.Search.User($('#searchAuthor'), function(data) {
+                       $('#searchAuthor').val(data.label);//.focus();
+               });
+       });
+       //]]>
+</script>
+
+</body>
+</html>
\ No newline at end of file
diff --git a/com.woltlab.wcf/template/searchArea.tpl b/com.woltlab.wcf/template/searchArea.tpl
new file mode 100644 (file)
index 0000000..8cd2752
--- /dev/null
@@ -0,0 +1,35 @@
+{capture assign='__searchFormLink'}{link controller='Search'}{/link}{/capture}
+{capture assign='__searchInputPlaceholder'}{lang}wcf.global.search.enterSearchTerm{/lang}{/capture}
+{capture assign='__searchDropdownOptions'}<label><input type="checkbox" name="subjectOnly" value="1" /> {lang}wcf.search.subjectOnly{/lang}</label>{/capture}
+{assign var='__searchHiddenInputFields' value=''}
+
+{event name='settings'}
+
+<aside id="search" class="searchBar dropdown">
+       <form method="post" action="{@$__searchFormLink}">
+               <input type="search" name="q" placeholder="{@$__searchInputPlaceholder}" autocomplete="off" required="required" value="" class="dropdownToggle" data-toggle="search" />
+               
+               <ul class="dropdownMenu">
+                       {hascontent}
+                               <li class="dropdownText">
+                                       {content}
+                                               {@$__searchDropdownOptions}
+                                       {/content}
+                               </li>
+                               <li class="dropdownDivider"></li>
+                       {/hascontent}
+                       <li><a href="{@$__searchFormLink}">{lang}wcf.search.extended{/lang}</a></li>
+               </ul>
+               
+               {@$__searchHiddenInputFields}
+       </form>
+</aside>
+
+<script type="text/javascript" src="{@$__wcf->getPath('wcf')}js/WCF.Search.Message{if !ENABLE_DEBUG_MODE}.min{/if}.js"></script>
+<script type="text/javascript">
+       //<![CDATA[
+       $(function() {
+               new WCF.Search.Message.SearchArea($('#search'));
+       });
+       //]]>
+</script>
\ No newline at end of file
diff --git a/com.woltlab.wcf/template/searchResult.tpl b/com.woltlab.wcf/template/searchResult.tpl
new file mode 100644 (file)
index 0000000..aca7cf6
--- /dev/null
@@ -0,0 +1,60 @@
+{include file='documentHeader'}
+
+<head>
+       <title>{lang}wcf.search.results{/lang} - {PAGE_TITLE|language}</title>
+       
+       {include file='headInclude'}
+</head>
+
+<body id="tpl{$templateName|ucfirst}">
+
+{include file='header'}
+
+<header class="boxHeadline">
+       <h1>{if $query}<a href="{link controller='Search'}q={$query|urlencode}{/link}">{lang}wcf.search.results{/lang}</a>{else}{lang}wcf.search.results{/lang}{/if}</h1>
+       <p>{lang}wcf.search.results.description{/lang}</p>
+</header>
+
+{include file='userNotice'}
+
+<div class="contentNavigation">
+       {assign var=encodedHighlight value=$highlight|urlencode}
+       {pages print=true assign=pagesLinks controller='SearchResult' id=$searchID link="pageNo=%d&highlight=$encodedHighlight"}
+       
+       {hascontent}
+               <nav>
+                       <ul>
+                               {content}
+                                       {if $alterable}
+                                               <li><a href="{link controller='Search'}modify={@$searchID}{/link}" class="button"><span class="icon icon16 icon-search"></span> <span>{lang}wcf.search.results.change{/lang}</span></a></li>
+                                       {/if}
+                                       {event name='contentNavigationButtonsTop'}
+                               {/content}
+                       </ul>
+               </nav>
+       {/hascontent}
+</div>
+
+{include file=$resultListTemplateName application=$resultListApplication}
+
+<div class="contentNavigation">
+       {@$pagesLinks}
+       
+       {hascontent}
+               <nav>
+                       <ul>
+                               {content}
+                                       {if $alterable}
+                                               <li><a href="{link controller='Search'}modify={@$searchID}{/link}" class="button"><span class="icon icon16 icon-search"></span> <span>{lang}wcf.search.results.change{/lang}</span></a></li>
+                                       {/if}
+                                       {event name='contentNavigationButtonsBottom'}
+                               {/content}
+                       </ul>
+               </nav>
+       {/hascontent}
+</div>
+
+{include file='footer'}
+
+</body>
+</html>
\ No newline at end of file
diff --git a/com.woltlab.wcf/template/searchResultList.tpl b/com.woltlab.wcf/template/searchResultList.tpl
new file mode 100644 (file)
index 0000000..162a538
--- /dev/null
@@ -0,0 +1,25 @@
+<div class="container marginTop">
+       <ul class="containerList messageSearchResultList">
+               {foreach from=$objects item=message}
+                       <li>
+                               <div class="box48">
+                                       <a href="{link controller='User' object=$message->getUserProfile()}{/link}" title="{$message->getUserProfile()->username}" class="framed">{@$message->getUserProfile()->getAvatar()->getImageTag(48)}</a>
+                                       
+                                       <div>
+                                               <div class="containerHeadline">
+                                                       <h3><a href="{$message->getLink($query)}">{$message->getSubject()}</a></h3>
+                                                       <p>
+                                                               <a href="{link controller='User' object=$message->getUserProfile()}{/link}" class="userLink" data-user-id="{@$message->getUserProfile()->userID}">{$message->getUserProfile()->username}</a>
+                                                               <small>- {@$message->getTime()|time}</small>
+                                                               {if $message->getContainerTitle()}<small>- <a href="{$message->getContainerLink()}">{$message->getContainerTitle()}</a></small>{/if}
+                                                       </p> 
+                                                       <small class="containerContentType">{lang}wcf.search.object.{@$message->getObjectTypeName()}{/lang}</small>
+                                               </div>
+                                               
+                                               <p>{@$message->getFormattedMessage()}</p>
+                                       </div>
+                               </div>
+                       </li>
+               {/foreach}
+       </ul>
+</div>
\ No newline at end of file
diff --git a/wcfsetup/install/files/js/WCF.Search.Message.js b/wcfsetup/install/files/js/WCF.Search.Message.js
new file mode 100644 (file)
index 0000000..98eac69
--- /dev/null
@@ -0,0 +1,124 @@
+/**
+ * Namespace
+ */
+WCF.Search.Message = {};
+
+/**
+ * Provides quick search for search keywords.
+ * 
+ * @see        WCF.Search.Base
+ */
+WCF.Search.Message.KeywordList = WCF.Search.Base.extend({
+       /**
+        * @see WCF.Search.Base._className
+        */
+       _className: 'wcf\\data\\search\\keyword\\SearchKeywordAction',
+       
+       /**
+        * dropdown divider
+        * @var jQuery
+        */
+       _divider: null,
+       
+       /**
+        * true, if submit should be forced
+        * @var boolean
+        */
+       _forceSubmit: false,
+       
+       /**
+        * @see WCF.Search.Base.init()
+        */
+       init: function(searchInput, callback, excludedSearchValues) {
+               if (!$.isFunction(callback)) {
+                       console.debug("[WCF.Search.Message.KeywordList] The given callback is invalid, aborting.");
+                       return;
+               }
+               
+               this._callback = callback;
+               this._excludedSearchValues = [];
+               if (excludedSearchValues) {
+                       this._excludedSearchValues = excludedSearchValues;
+               }
+               this._searchInput = $(searchInput).keyup($.proxy(this._keyUp, this)).keydown($.proxy(function(event) {
+                       // block form submit
+                       if (event.which === 13) {
+                               // ... unless there are no suggestions
+                               if (this._itemCount) {
+                                       event.preventDefault();
+                               }
+                       }
+               }, this));
+               
+               var $dropdownMenu = this._searchInput.next('.dropdownMenu');
+               var $lastDivider = $dropdownMenu.find('li.dropdownDivider').last();
+               this._divider = $('<li class="dropdownDivider" />').hide().insertBefore($lastDivider);
+               this._list = $('<li class="dropdownList" />').hide().insertBefore($lastDivider);
+               
+               // supress clicks on checkboxes
+               $dropdownMenu.find('input, label').on('click', function(event) { event.stopPropagation(); });
+               
+               this._proxy = new WCF.Action.Proxy({
+                       showLoadingOverlay: false,
+                       success: $.proxy(this._success, this)
+               });
+       },
+       
+       /**
+        * @see WCF.Search.Base._createListItem()
+        */
+       _createListItem: function(item) {
+               this._divider.show();
+               this._list.show();
+               
+               this._super(item);
+       },
+       
+       /**
+        * @see WCF.Search.Base._clearList()
+        */
+       _clearList: function(clearSearchInput) {
+               if (clearSearchInput) {
+                       this._searchInput.val('');
+               }
+               
+               this._divider.hide();
+               this._list.hide().empty();
+               
+               WCF.CloseOverlayHandler.removeCallback('WCF.Search.Base');
+               
+               // reset item navigation
+               this._itemCount = 0;
+               this._itemIndex = -1;
+       }
+});
+
+/**
+ * 
+ */
+WCF.Search.Message.SearchArea = Class.extend({
+       _searchArea: null,
+       
+       init: function(searchArea) {
+               this._searchArea = searchArea;
+               
+               new WCF.Search.Message.KeywordList(this._searchArea.find('input[type=search]'), $.proxy(this._callback, this));
+               
+               // forward clicks on the search icon to input field
+               var self = this;
+               var $input = this._searchArea.find('input[type=search]');
+               this._searchArea.click(function(event) {
+                       // only forward clicks if the search element itself is the target
+                       if (event.target == self._searchArea[0]) {
+                               $input.focus().trigger('click');
+                               return false;
+                       }
+               });
+       },
+       
+       _callback: function(data) {
+               this._searchArea.find('input[type=search]').val(data.label);
+               this._searchArea.find('input[type=search]').focus();
+               return false;
+       }
+});
\ No newline at end of file
diff --git a/wcfsetup/install/files/js/WCF.Search.Message.min.js b/wcfsetup/install/files/js/WCF.Search.Message.min.js
new file mode 100644 (file)
index 0000000..01b03fd
--- /dev/null
@@ -0,0 +1 @@
+WCF.Search.Message={};WCF.Search.Message.KeywordList=WCF.Search.Base.extend({_className:"wcf\\data\\search\\keyword\\SearchKeywordAction",_divider:null,_forceSubmit:false,init:function(c,e,b){if(!$.isFunction(e)){console.debug("[WCF.Search.Message.KeywordList] The given callback is invalid, aborting.");return}this._callback=e;this._excludedSearchValues=[];if(b){this._excludedSearchValues=b}this._searchInput=$(c).keyup($.proxy(this._keyUp,this)).keydown($.proxy(function(f){if(f.which===13){if(this._itemCount){f.preventDefault()}}},this));var a=this._searchInput.next(".dropdownMenu");var d=a.find("li.dropdownDivider").last();this._divider=$('<li class="dropdownDivider" />').hide().insertBefore(d);this._list=$('<li class="dropdownList" />').hide().insertBefore(d);a.find("input, label").on("click",function(f){f.stopPropagation()});this._proxy=new WCF.Action.Proxy({showLoadingOverlay:false,success:$.proxy(this._success,this)})},_createListItem:function(a){this._divider.show();this._list.show();this._super(a)},_clearList:function(a){if(a){this._searchInput.val("")}this._divider.hide();this._list.hide().empty();WCF.CloseOverlayHandler.removeCallback("WCF.Search.Base");this._itemCount=0;this._itemIndex=-1}});WCF.Search.Message.SearchArea=Class.extend({_searchArea:null,init:function(b){this._searchArea=b;new WCF.Search.Message.KeywordList(this._searchArea.find("input[type=search]"),$.proxy(this._callback,this));var a=this;var c=this._searchArea.find("input[type=search]");this._searchArea.click(function(d){if(d.target==a._searchArea[0]){c.focus().trigger("click");return false}})},_callback:function(a){this._searchArea.find("input[type=search]").val(a.label);this._searchArea.find("input[type=search]").focus();return false}});
\ No newline at end of file
diff --git a/wcfsetup/install/files/lib/data/search/ISearchResultObject.class.php b/wcfsetup/install/files/lib/data/search/ISearchResultObject.class.php
new file mode 100644 (file)
index 0000000..fc5649b
--- /dev/null
@@ -0,0 +1,73 @@
+<?php
+namespace wcf\data\search;
+
+/**
+ * All search result objects should implement this interface. 
+ * 
+ * @author     Marcel Werk
+ * @copyright  2001-2013 WoltLab GmbH
+ * @license    GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @package    com.woltlab.wcf.search
+ * @subpackage data.search
+ * @category   Community Framework
+ */
+interface ISearchResultObject {
+       /**
+        * Returns author's user profile.
+        * 
+        * @return      wcf\data\user\UserProfile
+        */
+       public function getUserProfile();
+       
+       /**
+        * Returns the subject of this object.
+        * 
+        * @return      string
+        */
+       public function getSubject();
+       
+       /**
+        * Returns the creation time.
+        * 
+        * @return      integer
+        */
+       public function getTime();
+       
+       /**
+        * Returns the link to this object.
+        * 
+        * @param       string          $query          search query
+        * @return      string
+        */
+       public function getLink($query = '');
+       
+       /**
+        * Returns the object type name.
+        * 
+        * @return      string
+        */
+       public function getObjectTypeName();
+       
+       /**
+        * Returns the message text.
+        * 
+        * @return      string
+        */
+       public function getFormattedMessage();
+       
+       /**
+        * Returns the title of object's container. Returns empty string if there
+        * is no container.
+        * 
+        * @return      string
+        */
+       public function getContainerTitle();
+       
+       /**
+        * Returns the link to object's container. Returns empty string if there
+        * is no container.
+        * 
+        * @return      string
+        */
+       public function getContainerLink();
+}
diff --git a/wcfsetup/install/files/lib/data/search/keyword/SearchKeyword.class.php b/wcfsetup/install/files/lib/data/search/keyword/SearchKeyword.class.php
new file mode 100644 (file)
index 0000000..c3ffa5b
--- /dev/null
@@ -0,0 +1,25 @@
+<?php
+namespace wcf\data\search\keyword;
+use wcf\data\DatabaseObject;
+
+/**
+ * Represents a search keyword.
+ * 
+ * @author     Marcel Werk
+ * @copyright  2001-2012 WoltLab GmbH
+ * @license    GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @package    com.woltlab.wcf.search
+ * @subpackage data.search.keyword
+ * @category   Community Framework
+ */
+class SearchKeyword extends DatabaseObject {
+       /**
+        * @see wcf\data\DatabaseObject::$databaseTableName
+        */
+       protected static $databaseTableName = 'search_keyword';
+       
+       /**
+        * @see wcf\data\DatabaseObject::$databaseTableIndexName
+        */
+       protected static $databaseTableIndexName = 'keywordID';
+}
diff --git a/wcfsetup/install/files/lib/data/search/keyword/SearchKeywordAction.class.php b/wcfsetup/install/files/lib/data/search/keyword/SearchKeywordAction.class.php
new file mode 100644 (file)
index 0000000..328025f
--- /dev/null
@@ -0,0 +1,58 @@
+<?php
+namespace wcf\data\search\keyword;
+use wcf\data\AbstractDatabaseObjectAction;
+use wcf\data\ISearchAction;
+use wcf\system\exception\UserInputException;
+use wcf\system\WCF;
+
+/**
+ * Executes keyword-related actions.
+ * 
+ * @author     Marcel Werk
+ * @copyright  2001-2013 WoltLab GmbH
+ * @license    GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @package    com.woltlab.wcf.search
+ * @subpackage data.search.keyword
+ * @category   Community Framework
+ */
+class SearchKeywordAction extends AbstractDatabaseObjectAction implements ISearchAction {
+       /**
+        * @see wcf\data\AbstractDatabaseObjectAction::$className
+        */
+       protected $className = 'wcf\data\search\keyword\SearchKeywordEditor';
+       
+       /**
+        * @see wcf\data\AbstractDatabaseObjectAction::$allowGuestAccess
+        */
+       protected $allowGuestAccess = array('getSearchResultList');
+       
+       /**
+        * @see wcf\data\ISearchAction::validateGetSearchResultList()
+        */
+       public function validateGetSearchResultList() {
+               $this->readString('searchString', false, 'data');
+       }
+       
+       /**
+        * @see wcf\data\ISearchAction::getSearchResultList()
+        */
+       public function getSearchResultList() {
+               $list = array();
+               
+               // find users
+               $sql = "SELECT          *
+                       FROM            wcf".WCF_N."_search_keyword
+                       WHERE           keyword LIKE ?
+                       ORDER BY        searches DESC";
+               $statement = WCF::getDB()->prepareStatement($sql);
+               $statement->execute(array($this->parameters['data']['searchString'].'%'));
+               while ($row = $statement->fetchArray()) {
+                       $list[] = array(
+                               'label' => $row['keyword'],
+                               'objectID' => $row['keywordID']
+                       );
+               }
+               
+               return $list;
+       }
+}
diff --git a/wcfsetup/install/files/lib/data/search/keyword/SearchKeywordEditor.class.php b/wcfsetup/install/files/lib/data/search/keyword/SearchKeywordEditor.class.php
new file mode 100644 (file)
index 0000000..a93ba97
--- /dev/null
@@ -0,0 +1,20 @@
+<?php
+namespace wcf\data\search\keyword;
+use wcf\data\DatabaseObjectEditor;
+
+/**
+ * Provides functions to edit keywords.
+ * 
+ * @author     Marcel Werk
+ * @copyright  2001-2012 WoltLab GmbH
+ * @license    GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @package    com.woltlab.wcf.search
+ * @subpackage data.search.keyword
+ * @category   Community Framework
+ */
+class SearchKeywordEditor extends DatabaseObjectEditor {
+       /**
+        * @see wcf\data\DatabaseObjectDecorator::$baseClass
+        */
+       protected static $baseClass = 'wcf\data\search\keyword\SearchKeyword';
+}
diff --git a/wcfsetup/install/files/lib/data/search/keyword/SearchKeywordList.class.php b/wcfsetup/install/files/lib/data/search/keyword/SearchKeywordList.class.php
new file mode 100644 (file)
index 0000000..c84989f
--- /dev/null
@@ -0,0 +1,20 @@
+<?php
+namespace wcf\data\search\keyword;
+use wcf\data\DatabaseObjectList;
+
+/**
+ * Represents a list of keywords.
+ * 
+ * @author     Marcel Werk
+ * @copyright  2001-2012 WoltLab GmbH
+ * @license    GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @package    com.woltlab.wcf.search
+ * @subpackage data.search.keyword
+ * @category   Community Framework
+ */
+class SearchKeywordList extends DatabaseObjectList {
+       /**
+        * @see wcf\data\DatabaseObjectList::$className
+        */
+       public $className = 'wcf\data\search\keyword\SearchKeyword';
+}
diff --git a/wcfsetup/install/files/lib/form/SearchForm.class.php b/wcfsetup/install/files/lib/form/SearchForm.class.php
new file mode 100644 (file)
index 0000000..f30cc02
--- /dev/null
@@ -0,0 +1,465 @@
+<?php
+namespace wcf\form;
+use wcf\data\search\Search;
+use wcf\data\search\SearchAction;
+use wcf\system\database\util\PreparedStatementConditionBuilder;
+use wcf\system\exception\IllegalLinkException;
+use wcf\system\exception\NamedUserException;
+use wcf\system\exception\PermissionDeniedException;
+use wcf\system\exception\SystemException;
+use wcf\system\exception\UserInputException;
+use wcf\system\language\LanguageFactory;
+use wcf\system\request\LinkHandler;
+use wcf\system\search\SearchEngine;
+use wcf\system\search\SearchKeywordManager;
+use wcf\system\WCF;
+use wcf\util\HeaderUtil;
+use wcf\util\StringUtil;
+
+/**
+ * Shows the search form.
+ * 
+ * @author     Marcel Werk
+ * @copyright  2001-2012 WoltLab GmbH
+ * @license    GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @package    com.woltlab.wcf.search
+ * @subpackage form
+ * @category   Community Framework
+ */
+class SearchForm extends RecaptchaForm {
+       /**
+        * list of additional conditions
+        * @var array<string>
+        */
+       public $additionalConditions = array();
+       
+       /**
+        * end date
+        * @var integer
+        */
+       public $endDate = '';
+       
+       /**
+        * true, if search should be modified
+        * @var boolean
+        */
+       public $modifySearch = null;
+       
+       /**
+        * search id used for modification
+        * @var integer
+        */
+       public $modifySearchID = 0;
+       
+       /**
+        * require exact matches
+        * @var integer
+        */
+       public $nameExactly = 1;
+       
+       /**
+        * search query
+        * @var string
+        */
+       public $query = '';
+       
+       /**
+        * list of search results
+        * @var array
+        */
+       public $results = array();
+       
+       /**
+        * @see wcf\page\SortablePage::$sortField
+        */
+       public $sortField = SEARCH_DEFAULT_SORT_FIELD;
+       
+       /**
+        * @see wcf\page\SortablePage::$sortOrder
+        */
+       public $sortOrder = SEARCH_DEFAULT_SORT_ORDER;
+       
+       /**
+        * user id
+        * @var integer
+        */
+       public $userID = 0;
+       
+       /**
+        * username
+        * @var string
+        */
+       public $username = '';
+       
+       /**
+        * @see wcf\form\RecaptchaForm::$useCaptcha
+        */
+       public $useCaptcha = SEARCH_USE_CAPTCHA;
+       
+       /**
+        * parameters used for previous search
+        * @var array
+        */
+       public $searchData = array();
+       
+       /**
+        * search id
+        * @var integer
+        */
+       public $searchID = 0;
+       
+       /**
+        * PreparedStatementConditionBuilder object
+        * @var wcf\system\database\util\PreparedStatementConditionBuilder
+        */
+       public $searchIndexCondition = null;
+       
+       /**
+        * search hash to modify existing search
+        * @var string
+        */
+       public $searchHash = '';
+       
+       /**
+        * selected object types
+        * @var array<string>
+        */
+       public $selectedObjectTypes = array();
+       
+       /**
+        * start date
+        * @var integer
+        */
+       public $startDate = '';
+       
+       /**
+        * search for subject only
+        * @var integer
+        */
+       public $subjectOnly = 0;
+       
+       /**
+        * mark as submitted form if modifying search
+        * @var boolean
+        */
+       public $submit = false;
+       
+       /**
+        * @see wcf\page\IPage::readParameters()
+        */
+       public function readParameters() {
+               parent::readParameters();
+               
+               if (isset($_REQUEST['q'])) $this->query = StringUtil::trim($_REQUEST['q']);
+               if (isset($_REQUEST['username'])) $this->username = StringUtil::trim($_REQUEST['username']);
+               if (isset($_REQUEST['userID'])) $this->userID = intval($_REQUEST['userID']);
+               if (isset($_REQUEST['types']) && is_array($_REQUEST['types'])) $this->selectedObjectTypes = $_REQUEST['types'];
+               $this->submit = (!empty($_POST) || !empty($this->query) || !empty($this->username) || $this->userID);
+               
+               if (isset($_REQUEST['modify'])) {
+                       $this->modifySearchID = intval($_REQUEST['modify']);
+                       $this->modifySearch = new Search($this->modifySearchID);
+                       
+                       if (!$this->modifySearch->searchID || ($this->modifySearch->userID && $this->modifySearch->userID != WCF::getUser()->userID)) {
+                               throw new IllegalLinkException();
+                       }
+                       
+                       $this->searchData = unserialize($this->modifySearch->searchData);
+                       if (empty($this->searchData['alterable'])) {
+                               throw new IllegalLinkException();
+                       }
+                       $this->query = $this->searchData['query'];
+                       $this->sortOrder = $this->searchData['sortOrder'];
+                       $this->sortField = $this->searchData['sortField'];
+                       $this->nameExactly = $this->searchData['nameExactly'];
+                       $this->subjectOnly = $this->searchData['subjectOnly'];
+                       $this->startDate = $this->searchData['startDate'];
+                       $this->endDate = $this->searchData['endDate'];
+                       $this->username = $this->searchData['username'];
+                       $this->userID = $this->searchData['userID'];
+                       $this->selectedObjectTypes = $this->searchData['selectedObjectTypes'];
+                       
+                       if (!empty($_POST)) {
+                               $this->submit = true;
+                       }
+               }
+               
+               // sort order
+               if (isset($_REQUEST['sortField'])) {
+                       $this->sortField = $_REQUEST['sortField'];
+               }
+                       
+               switch ($this->sortField) {
+                       case 'subject':
+                       case 'time':
+                       case 'username': break;
+                       case 'relevance': if (!$this->submit || !empty($this->query)) break;
+                       default: 
+                               if (!$this->submit || !empty($this->query)) $this->sortField = 'relevance';
+                               else $this->sortField = 'time';
+               }
+               
+               if (isset($_REQUEST['sortOrder'])) {
+                       $this->sortOrder = $_REQUEST['sortOrder'];
+                       switch ($this->sortOrder) {
+                               case 'ASC':
+                               case 'DESC': break;
+                               default: $this->sortOrder = 'DESC';
+                       }
+               }
+       }
+       
+       /**
+        * @see wcf\form\IForm::readFormParameters()
+        */
+       public function readFormParameters() {
+               parent::readFormParameters();
+               
+               $this->nameExactly = 0;
+               if (isset($_POST['nameExactly'])) $this->nameExactly = intval($_POST['nameExactly']);
+               if (isset($_POST['subjectOnly'])) $this->subjectOnly = intval($_POST['subjectOnly']);
+               if (isset($_POST['startDate'])) $this->startDate = $_POST['startDate'];
+               if (isset($_POST['endDate'])) $this->endDate = $_POST['endDate'];
+       }
+       
+       /**
+        * @see wcf\form\IForm::validate()
+        */
+       public function validate() {
+               parent::validate();
+               
+               // get search conditions
+               $this->getConditions();
+               
+               // check query and author
+               if (empty($this->query) && empty($this->username) && !$this->userID) {
+                       throw new UserInputException('q');
+               }
+               
+               // build search hash
+               $this->searchHash = StringUtil::getHash(serialize(array($this->query, $this->selectedObjectTypes, !$this->subjectOnly, $this->searchIndexCondition, $this->additionalConditions, $this->sortField.' '.$this->sortOrder, PACKAGE_ID)));
+               
+               // check search hash
+               if (!empty($this->query)) {
+                       $parameters = array($this->searchHash, 'messages', TIME_NOW - 1800);
+                       if (WCF::getUser()->userID) $parameters[] = WCF::getUser()->userID;
+                       
+                       $sql = "SELECT  searchID
+                               FROM    wcf".WCF_N."_search
+                               WHERE   searchHash = ?
+                                       AND searchType = ?
+                                       AND searchTime > ?
+                                       ".(WCF::getUser()->userID ? 'AND userID = ?' : 'AND userID IS NULL');
+                       $statement = WCF::getDB()->prepareStatement($sql);
+                       $statement->execute($parameters);
+                       $row = $statement->fetchArray();
+                       if ($row !== false) {
+                               HeaderUtil::redirect(LinkHandler::getInstance()->getLink('SearchResult', array('id' => $row['searchID']), 'highlight='.urlencode($this->query)));
+                               exit;
+                       }
+               }
+               
+               // do search
+               $this->results = SearchEngine::getInstance()->search($this->query, $this->selectedObjectTypes, $this->subjectOnly, $this->searchIndexCondition, $this->additionalConditions, $this->sortField.' '.$this->sortOrder);
+               
+               // result is empty
+               if (empty($this->results)) {
+                       $this->throwNoMatchesException();
+               }
+       }
+       
+       /**
+        * Throws a NamedUserException on search failure.
+        */
+       public function throwNoMatchesException() {
+               if (empty($this->query)) {
+                       throw new NamedUserException(WCF::getLanguage()->get('wcf.search.error.user.noMatches'));
+               }
+               else {
+                       throw new NamedUserException(WCF::getLanguage()->getDynamicVariable('wcf.search.error.noMatches', array('query' => $this->query)));
+               }
+       }
+       
+       /**
+        * @see wcf\form\IForm::save()
+        */
+       public function save() {
+               parent::save();
+               
+               // get additional data
+               $additionalData = array();
+               foreach (SearchEngine::getInstance()->getAvailableObjectTypes() as $objectTypeName => $objectType) {
+                       if (($data = $objectType->getAdditionalData()) !== null) {
+                               $additionalData[$objectTypeName] = $data;
+                       }
+               }
+               
+               // save result in database
+               $this->searchData = array(
+                       'packageID' => PACKAGE_ID,
+                       'query' => $this->query,
+                       'results' => $this->results,
+                       'additionalData' => $additionalData,
+                       'sortField' => $this->sortField,
+                       'sortOrder' => $this->sortOrder,
+                       'nameExactly' => $this->nameExactly,
+                       'subjectOnly' => $this->subjectOnly,
+                       'startDate' => $this->startDate,
+                       'endDate' => $this->endDate,
+                       'username' => $this->username,
+                       'userID' => $this->userID,
+                       'selectedObjectTypes' => $this->selectedObjectTypes,
+                       'alterable' => (!$this->userID ? 1 : 0)
+               );
+               if ($this->modifySearchID) {
+                       $this->objectAction = new SearchAction(array($this->modifySearchID), 'update', array('data' => array(
+                               'searchData' => serialize($this->searchData),
+                               'searchTime' => TIME_NOW,
+                               'searchType' => 'messages',
+                               'searchHash' => $this->searchHash
+                       )));
+                       $this->objectAction->executeAction();
+               }
+               else {
+                       $this->objectAction = new SearchAction(array(), 'create', array('data' => array(
+                               'userID' => (WCF::getUser()->userID ?: null),
+                               'searchData' => serialize($this->searchData),
+                               'searchTime' => TIME_NOW,
+                               'searchType' => 'messages',
+                               'searchHash' => $this->searchHash
+                       )));
+                       $resultValues = $this->objectAction->executeAction();
+                       $this->searchID = $resultValues['returnValues']->searchID;
+               }
+               // save keyword
+               if (!empty($this->query)) {
+                       SearchKeywordManager::getInstance()->add($this->query);
+               }
+               $this->saved();
+               
+               // forward to result page
+               HeaderUtil::redirect(LinkHandler::getInstance()->getLink('SearchResult', array('id' => $this->searchID), 'highlight='.urlencode($this->query)));
+               exit;
+       }
+       
+       /**
+        * @see wcf\page\IPage::assignVariables()
+        */
+       public function assignVariables() {
+               parent::assignVariables();
+               
+               // init form
+               foreach (SearchEngine::getInstance()->getAvailableObjectTypes() as $objectType) $objectType->show($this);
+               
+               WCF::getTPL()->assign(array(
+                       'query' => $this->query,
+                       'subjectOnly' => $this->subjectOnly,
+                       'username' => $this->username,
+                       'nameExactly' => $this->nameExactly,
+                       'startDate' => $this->startDate,
+                       'endDate' => $this->endDate,
+                       'sortField' => $this->sortField,
+                       'sortOrder' => $this->sortOrder,
+                       'selectedObjectTypes' => $this->selectedObjectTypes,
+                       'objectTypes' => SearchEngine::getInstance()->getAvailableObjectTypes()
+               ));
+       }
+       
+       /**
+        * @see wcf\page\IPage::show()
+        */
+       public function show() {
+               if (empty($_POST) && $this->submit) {
+                       if ($this->userID) $this->useCaptcha = false;
+                       $this->submit();
+               }
+               
+               parent::show();
+       }
+       
+       /**
+        * Gets the conditions for a search in the table of the selected object types.
+        */
+       protected function getConditions() {
+               if (empty($this->selectedObjectTypes)) {
+                       $this->selectedObjectTypes = array_keys(SearchEngine::getInstance()->getAvailableObjectTypes());
+               }
+               
+               // default conditions
+               $userIDs = $this->getUserIDs();
+               $this->searchIndexCondition = new PreparedStatementConditionBuilder(false);
+               
+               // user ids
+               if (!empty($userIDs)) {
+                       $this->searchIndexCondition->add('userID IN (?)', array($userIDs));
+               }
+               
+               // dates
+               if (($startDate = @strtotime($this->startDate)) && ($endDate = @strtotime($this->endDate))) {
+                       $this->searchIndexCondition->add('time BETWEEN ? AND ?', array($startDate, $endDate));
+               }
+               
+               // language
+               if (LanguageFactory::getInstance()->multilingualismEnabled() && count(WCF::getUser()->getLanguageIDs())) {
+                       $this->searchIndexCondition->add('(languageID IN (?) OR languageID IS NULL)', array(WCF::getUser()->getLanguageIDs()));
+               }
+               
+               foreach ($this->selectedObjectTypes as $key => $objectTypeName) {
+                       $objectType = SearchEngine::getInstance()->getObjectType($objectTypeName);
+                       if ($objectType === null) {
+                               throw new SystemException('unknown search object type '.$objectTypeName);
+                       }
+                       
+                       try {
+                               if (!$objectType->isAccessible()) {
+                                       throw new PermissionDeniedException();
+                               }
+                               
+                               // special conditions
+                               if (($conditionBuilder = $objectType->getConditions($this)) !== null) {
+                                       $this->additionalConditions[$objectTypeName] = $conditionBuilder;
+                               }
+                       }
+                       catch (PermissionDeniedException $e) {
+                               unset($this->selectedObjectTypes[$key]);
+                               continue;
+                       }
+               }
+               
+               if (empty($this->selectedObjectTypes)) {
+                       $this->throwNoMatchesException();
+               }
+       }
+       
+       /**
+        * Returns user ids.
+        * 
+        * @return      array<integer>
+        */
+       public function getUserIDs() {
+               $userIDs = array();
+                       
+               // username
+               if (!empty($this->username)) {
+                       $sql = "SELECT  userID
+                               FROM    wcf".WCF_N."_user
+                               WHERE   username ".($this->nameExactly ? "= ?" : "LIKE ?");
+                       $statement = WCF::getDB()->prepareStatement($sql, 100);
+                       $statement->execute(array($this->username.(!$this->nameExactly ? '%' : '')));
+                       while ($row = $statement->fetchArray()) {
+                               $userIDs[] = $row['userID'];
+                       }
+                       
+                       if (empty($userIDs)) {
+                               $this->throwNoMatchesException();
+                       }
+               }
+               
+               // userID
+               if ($this->userID) {
+                       $userIDs[] = $this->userID;
+               }
+               
+               return $userIDs;
+       }
+}
diff --git a/wcfsetup/install/files/lib/page/SearchResultPage.class.php b/wcfsetup/install/files/lib/page/SearchResultPage.class.php
new file mode 100644 (file)
index 0000000..a26fe7e
--- /dev/null
@@ -0,0 +1,181 @@
+<?php
+namespace wcf\page;
+use wcf\data\search\Search;
+use wcf\system\event\EventHandler;
+use wcf\system\exception\IllegalLinkException;
+use wcf\system\search\SearchEngine;
+use wcf\system\WCF;
+
+/**
+ * Shows the result of a search request.
+ * 
+ * @author     Marcel Werk
+ * @copyright  2001-2013 WoltLab GmbH
+ * @license    GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @package    com.woltlab.wcf.search
+ * @subpackage page
+ * @category   Community Framework
+ */
+class SearchResultPage extends MultipleLinkPage {
+       /**
+        * @see wcf\page\MultipleLinkPage::$itemsPerPage
+        */
+       public $itemsPerPage = SEARCH_RESULTS_PER_PAGE;
+       
+       /**
+        * highlight string
+        * @var string
+        */
+       public $highlight = '';
+       
+       /**
+        * search id
+        * @var integer
+        */
+       public $searchID = 0;
+       
+       /**
+        * search object
+        * @var wcf\data\search\Search
+        */
+       public $search = null;
+       
+       /**
+        * messages
+        * @var array
+        */
+       public $messages = array();
+       
+       /**
+        * search data
+        * @var array
+        */
+       public $searchData = null;
+       
+       /**
+        *  result list template
+        * @var string
+        */
+       public $resultListTemplateName = 'searchResultList';
+       
+       /**
+        * result list template's application
+        * @var string
+        */
+       public $resultListApplication = 'wcf';
+       
+       /**
+        * @see wcf\page\IPage::readParameters()
+        */
+       public function readParameters() {
+               parent::readParameters();
+               
+               if (isset($_REQUEST['highlight'])) $this->highlight = $_REQUEST['highlight'];
+               if (isset($_REQUEST['id'])) $this->searchID = intval($_REQUEST['id']);
+               $this->search = new Search($this->searchID);
+               if (!$this->search->searchID || $this->search->searchType != 'messages') {
+                       throw new IllegalLinkException();
+               }
+               if ($this->search->userID && $this->search->userID != WCF::getUser()->userID) {
+                       throw new IllegalLinkException();
+               }
+               
+               // get search data
+               $this->searchData = unserialize($this->search->searchData);
+               
+               // check package id of this search
+               if (!empty($this->searchData['packageID']) && $this->searchData['packageID'] != PACKAGE_ID) {
+                       throw new IllegalLinkException();
+               }
+       }
+       
+       /**
+        * @see wcf\page\IPage::readData()
+        */
+       public function readData() {
+               parent::readData();
+               
+               // cache message data
+               $this->cacheMessageData();
+               
+               // get messages
+               $this->readMessages();
+       }
+       
+       /**
+        * Caches the message data.
+        */
+       protected function cacheMessageData() {
+               $types = array();
+               
+               // group object id by object type
+               for ($i = $this->startIndex - 1; $i < $this->endIndex; $i++) {
+                       $type = $this->searchData['results'][$i]['objectType'];
+                       $objectID = $this->searchData['results'][$i]['objectID'];
+                       
+                       if (!isset($types[$type])) $types[$type] = array();
+                       $types[$type][] = $objectID;
+               }
+               
+               foreach ($types as $type => $objectIDs) {
+                       $objectType = SearchEngine::getInstance()->getObjectType($type);
+                       $objectType->cacheObjects($objectIDs, (isset($this->searchData['additionalData'][$type]) ? $this->searchData['additionalData'][$type] : null));
+               }
+       }
+       
+       /**
+        * Gets the data of the messages.
+        */
+       protected function readMessages() {
+               for ($i = $this->startIndex - 1; $i < $this->endIndex; $i++) {
+                       $type = $this->searchData['results'][$i]['objectType'];
+                       $objectID = $this->searchData['results'][$i]['objectID'];
+                       
+                       $objectType = SearchEngine::getInstance()->getObjectType($type);
+                       if (($message = $objectType->getObject($objectID)) !== null) {
+                               $this->messages[] = $message;
+                       }
+               }
+       }
+       
+       /**
+        * @see wcf\page\IPage::assignVariables()
+        */
+       public function assignVariables() {
+               parent::assignVariables();
+               
+               WCF::getTPL()->assign(array(
+                       'query' => $this->searchData['query'],
+                       'objects' => $this->messages,
+                       'searchData' => $this->searchData,
+                       'searchID' => $this->searchID,
+                       'highlight' => $this->highlight,
+                       'sortField' => $this->searchData['sortField'],
+                       'sortOrder' => $this->searchData['sortOrder'],
+                       'alterable' => (!empty($this->searchData['alterable']) ? 1 : 0),
+                       'objectTypes' => SearchEngine::getInstance()->getAvailableObjectTypes(),
+                       'resultListTemplateName' => $this->resultListTemplateName,
+                       'resultListApplication' => $this->resultListApplication
+               ));
+       }
+       
+       /**
+        * @see wcf\page\MultipleLinkPage::countItems()
+        */
+       public function countItems() {
+               // call countItems event
+               EventHandler::getInstance()->fireAction($this, 'countItems');
+               
+               return count($this->searchData['results']);
+       }
+       
+       /**
+        * @see wcf\page\MultipleLinkPage::initObjectList()
+        */
+       protected function initObjectList() { }
+       
+       /**
+        * @see wcf\page\MultipleLinkPage::readObjects()
+        */
+       protected function readObjects() { }
+}
index 4970b011c0dca7d637a8d3b704764a45acf9e1ba..d0ae11a5c673fc099611b14f3120c01451621b83 100644 (file)
@@ -22,6 +22,22 @@ class DailyCleanUpCronjob extends AbstractCronjob {
        public function execute(Cronjob $cronjob) {
                parent::execute($cronjob);
                
+               // clean up search keywords
+               $sql = "SELECT  AVG(searches) AS searches
+                       FROM    wcf".WCF_N."_search_keyword";
+               $statement = WCF::getDB()->prepareStatement($sql);
+               $statement->execute();
+               if (($row = $statement->fetchArray()) !== false) {
+                       $sql = "DELETE FROM     wcf".WCF_N."_search_keyword
+                               WHERE           searches <= ?
+                                               AND lastSearchTime < ?";
+                       $statement = WCF::getDB()->prepareStatement($sql);
+                       $statement->execute(array(
+                                       floor($row['searches'] / 4),
+                                       (TIME_NOW - 86400 * 30)
+                       ));
+               }
+               
                // clean up notifications
                $sql = "DELETE FROM     wcf".WCF_N."_user_notification
                        WHERE           time < ?";
diff --git a/wcfsetup/install/files/lib/system/search/AbstractSearchableObjectType.class.php b/wcfsetup/install/files/lib/system/search/AbstractSearchableObjectType.class.php
new file mode 100644 (file)
index 0000000..ae29d2f
--- /dev/null
@@ -0,0 +1,92 @@
+<?php
+namespace wcf\system\search;
+use wcf\data\object\type\AbstractObjectTypeProcessor;
+use wcf\form\IForm;
+use wcf\system\database\util\PreparedStatementConditionBuilder;
+
+/**
+ * This class provides default implementations for the ISearchableObjectType interface.
+ * 
+ * @author     Marcel Werk
+ * @copyright  2001-2012 WoltLab GmbH
+ * @license    GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @package    com.woltlab.wcf.search
+ * @subpackage system.search
+ * @category   Community Framework
+ */
+abstract class AbstractSearchableObjectType extends AbstractObjectTypeProcessor implements ISearchableObjectType {
+       /**
+        * @see wcf\system\search\ISearchableObjectType::show()
+        */
+       public function show(IForm $form = null) {}
+       
+       /**
+        * @see wcf\system\search\ISearchableObjectType::getApplication()
+        */
+       public function getApplication() {
+               return 'wcf';
+       }
+       
+       /**
+        * @see wcf\system\search\ISearchableObjectType::getConditions()
+        */
+       public function getConditions(IForm $form = null) {
+               return null;
+       }
+       
+       /**
+        * @see wcf\system\search\ISearchableObjectType::getJoins()
+        */
+       public function getJoins() {
+               return '';
+       }
+       
+       /**
+        * @see wcf\system\search\ISearchableObjectType::getSubjectFieldName()
+        */
+       public function getSubjectFieldName() {
+               return $this->getTableName().'.subject';
+       }
+       
+       /**
+        * @see wcf\system\search\ISearchableObjectType::getUsernameFieldName()
+        */
+       public function getUsernameFieldName() {
+               return $this->getTableName().'.username';
+       }
+       
+       /**
+        * @see wcf\system\search\ISearchableObjectType::getTimeFieldName()
+        */
+       public function getTimeFieldName() {
+               return $this->getTableName().'.time';
+       }
+       
+       /**
+        * @see wcf\system\search\ISearchableObjectType::getAdditionalData()
+        */
+       public function getAdditionalData() {
+               return null;
+       }
+       
+       /**
+        * @see wcf\system\search\ISearchableObjectType::isAccessible()
+        */
+       public function isAccessible() {
+               return true;
+       }
+       
+       /**
+        * @see wcf\system\search\ISearchableObjectType::getFormTemplateName()
+        */
+       public function getFormTemplateName() {
+               return '';
+       }
+       
+       /**
+        * @see wcf\system\search\ISearchableObjectType::getSpecialSQLQuery()
+        */
+       public function getSpecialSQLQuery(PreparedStatementConditionBuilder $fulltextCondition = null, PreparedStatementConditionBuilder $searchIndexConditions = null, PreparedStatementConditionBuilder $additionalConditions = null, $orderBy = 'time DESC') {
+               return '';
+       }
+}
diff --git a/wcfsetup/install/files/lib/system/search/ISearchableObjectType.class.php b/wcfsetup/install/files/lib/system/search/ISearchableObjectType.class.php
new file mode 100644 (file)
index 0000000..01e9694
--- /dev/null
@@ -0,0 +1,128 @@
+<?php
+namespace wcf\system\search;
+use wcf\form\IForm;
+use wcf\system\database\util\PreparedStatementConditionBuilder;
+
+/**
+ * All searchable object types should implement this interface. 
+ * 
+ * @author     Marcel Werk
+ * @copyright  2001-2012 WoltLab GmbH
+ * @license    GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @package    com.woltlab.wcf.search
+ * @subpackage system.search
+ * @category   Community Framework
+ */
+interface ISearchableObjectType {
+       /**
+        * Caches the data for the given object ids.
+        * 
+        * @param       array           $objectIDs
+        * @param       array           $additionalData
+        */
+       public function cacheObjects(array $objectIDs, array $additionalData = null);
+       
+       /**
+        * Returns the object with the given object id.
+        * 
+        * @param       integer         $objectID
+        * @return      wcf\data\search\ISearchResultObject
+        */
+       public function getObject($objectID);
+       
+       /**
+        * Shows the form part of this object type.
+        * 
+        * @param       wcf\form\IForm          $form           instance of the form class where the search has taken place
+        */
+       public function show(IForm $form = null);
+       
+       /**
+        * Returns the application abbreviation.
+        * 
+        * @return      string
+        */
+       public function getApplication();
+       
+       /**
+        * Returns the search conditions of this message type.
+        * 
+        * @param       wcf\form\IForm                  $form
+        * @return      wcf\system\database\util\PreparedStatementConditionBuilder
+        */
+       public function getConditions(IForm $form = null);
+       
+       /**
+        * Provides the ability to add additional joins to sql search query. 
+        * 
+        * @return      string
+        */
+       public function getJoins();
+       
+       /**
+        * Returns the database table name of this message.
+        * 
+        * @return      string
+        */
+       public function getTableName();
+       
+       /**
+        * Returns the database field name of the message id.
+        * 
+        * @return      string
+        */
+       public function getIDFieldName();
+       
+       /**
+        * Returns the database field name of the subject field.
+        * 
+        * @return      string
+        */
+       public function getSubjectFieldName();
+       
+       /**
+        * Returns the database field name of the username.
+        * 
+        * @return      string
+        */
+       public function getUsernameFieldName();
+       
+       /**
+        * Returns the database field name of the time.
+        * 
+        * @return      string
+        */
+       public function getTimeFieldName();
+       
+       /**
+        * Returns additional search information.
+        * 
+        * @return      mixed
+        */
+       public function getAdditionalData();
+       
+       /**
+        * Returns true if the current user can use this searchable object type.
+        * 
+        * @return      boolean
+        */
+       public function isAccessible();
+       
+       /**
+        * Returns the name of the form template for this object type.
+        * 
+        * @return      string
+        */
+       public function getFormTemplateName();
+       
+       /**
+        * Provides the option to replace the default search index SQL query by an own version. 
+        * 
+        * @param       wcf\system\database\util\PreparedStatementConditionBuilder      $fulltextCondition
+        * @param       wcf\system\database\util\PreparedStatementConditionBuilder      $searchIndexConditions
+        * @param       wcf\system\database\util\PreparedStatementConditionBuilder      $additionalConditions
+        * @param       string                                                          $orderBy
+        * @return      string
+        */
+       public function getSpecialSQLQuery(PreparedStatementConditionBuilder $fulltextCondition = null, PreparedStatementConditionBuilder $searchIndexConditions = null, PreparedStatementConditionBuilder $additionalConditions = null, $orderBy = 'time DESC');
+}
diff --git a/wcfsetup/install/files/lib/system/search/SearchEngine.class.php b/wcfsetup/install/files/lib/system/search/SearchEngine.class.php
new file mode 100644 (file)
index 0000000..b5f4b58
--- /dev/null
@@ -0,0 +1,209 @@
+<?php
+namespace wcf\system\search;
+use wcf\data\object\type\ObjectTypeCache;
+use wcf\system\database\util\PreparedStatementConditionBuilder;
+use wcf\system\exception\SystemException;
+use wcf\system\SingletonFactory;
+use wcf\system\WCF;
+use wcf\util\StringUtil;
+
+/**
+ * SearchEngine searches for given query in the selected object types.
+ * 
+ * @author     Marcel Werk
+ * @copyright  2001-2012 WoltLab GmbH
+ * @license    GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @package    com.woltlab.wcf.search
+ * @subpackage system.search
+ * @category   Community Framework
+ */
+class SearchEngine extends SingletonFactory {
+       /**
+        * list of available object types
+        * @var array
+        */
+       protected $availableObjectTypes = array();
+       
+       /**
+        * @see wcf\system\SingletonFactory::init()
+        */
+       protected function init() {
+               // get available object types
+               $this->availableObjectTypes = ObjectTypeCache::getInstance()->getObjectTypes('com.woltlab.wcf.searchableObjectType');
+               
+               // get processors
+               foreach ($this->availableObjectTypes as &$objectType) {
+                       $objectType = $objectType->getProcessor();
+               }
+       }
+       
+       /**
+        * Returns a list of available object types.
+        * 
+        * @return      array
+        */
+       public function getAvailableObjectTypes() {
+               return $this->availableObjectTypes;
+       }
+       
+       /**
+        * Returns the object type with the given name.
+        * 
+        * @param       string          $objectTypeName
+        * @return      wcf\data\object\type\ObjectType
+        */
+       public function getObjectType($objectTypeName) {
+               if (isset($this->availableObjectTypes[$objectTypeName])) {
+                       return $this->availableObjectTypes[$objectTypeName];
+               }
+               
+               return null;
+       }
+       
+       /**
+        * Searches for the given string and returns the data of the found messages.
+        * 
+        * @param       string                                                          $q
+        * @param       array                                                           $objectTypes
+        * @param       boolean                                                         $subjectOnly
+        * @param       wcf\system\database\util\PreparedStatementConditionBuilder      $searchIndexCondition
+        * @param       array                                                           $additionalConditions
+        * @param       string                                                          $orderBy
+        * @param       integer                                                         $limit
+        * @return      array
+        */
+       public function search($q, array $objectTypes, $subjectOnly = false, PreparedStatementConditionBuilder $searchIndexCondition = null, array $additionalConditions = array(), $orderBy = 'time DESC', $limit = 1000) {
+               // handle sql types
+               $fulltextCondition = null;
+               $relevanceCalc = '';
+               if (!empty($q)) {
+                       // expand search terms with a * unless they're encapsulated with quotes
+                       $inQuotes = false;
+                       $tmp = '';
+                       $controlCharacterOrSpace = false;
+                       $chars = array('+', '-', '*');
+                       for ($i = 0, $length = StringUtil::length($q); $i < $length; $i++) {
+                               $char = $q[$i];
+                               
+                               if ($inQuotes) {
+                                       if ($char == '"') {
+                                               $inQuotes = false;
+                                       }
+                               }
+                               else {
+                                       if ($char == '"') {
+                                               $inQuotes = true;
+                                       }
+                                       else {
+                                               if ($char == ' ' && !$controlCharacterOrSpace) {
+                                                       $controlCharacterOrSpace = true;
+                                                       $tmp .= '*';
+                                               }
+                                               else if (in_array($char, $chars)) {
+                                                       $controlCharacterOrSpace = true;
+                                               }
+                                               else {
+                                                       $controlCharacterOrSpace = false;
+                                               }
+                                       }
+                               }
+                               
+                               $tmp .= $char;
+                       }
+                       
+                       // handle last char
+                       if (!$inQuotes && !$controlCharacterOrSpace) {
+                               $tmp .= '*';
+                       }
+                       $q = $tmp;
+                       
+                       $fulltextCondition = new PreparedStatementConditionBuilder(false);
+                       switch (WCF::getDB()->getDBType()) {
+                               case 'wcf\system\database\MySQLDatabase':
+                                       $fulltextCondition->add("MATCH (subject".(!$subjectOnly ? ', message, metaData' : '').") AGAINST (? IN BOOLEAN MODE)", array($q));
+                               break;
+                               
+                               case 'wcf\system\database\PostgreSQLDatabase':
+                                       // replace * with :*
+                                       $q = StringUtil::replace('*', ':*', $q);
+                                       
+                                       $fulltextCondition->add("fulltextIndex".($subjectOnly ? "SubjectOnly" : '')." @@ to_tsquery(?)", array($q));
+                               break;
+                               
+                               default:
+                                       throw new SystemException("your database type doesn't support fulltext search");
+                       }
+                       
+                       if ($orderBy == 'relevance ASC' || $orderBy == 'relevance DESC') {
+                               switch (WCF::getDB()->getDBType()) {
+                                       case 'wcf\system\database\MySQLDatabase':
+                                               $relevanceCalc = "MATCH (subject".(!$subjectOnly ? ', message, metaData' : '').") AGAINST ('".escapeString($q)."') + (5 / (1 + POW(LN(1 + (".TIME_NOW." - time) / 2592000), 2))) AS relevance";
+                                       break;
+                                       
+                                       case 'wcf\system\database\PostgreSQLDatabase':
+                                               $relevanceCalc = "ts_rank_cd(fulltextIndex".($subjectOnly ? "SubjectOnly" : '').", '".escapeString($q)."') AS relevance";
+                                       break;
+                               }
+                       }
+               }
+               
+               // build search query
+               $sql = '';
+               $parameters = array();
+               foreach ($objectTypes as $objectTypeName) {
+                       $objectType = $this->getObjectType($objectTypeName);
+                       if (!empty($sql)) $sql .= "\nUNION\n";
+                       if (($specialSQL = $objectType->getSpecialSQLQuery($fulltextCondition, $searchIndexCondition, (isset($additionalConditions[$objectTypeName]) ? $additionalConditions[$objectTypeName] : null), $orderBy))) {
+                               $sql .= "(".$specialSQL.")";
+                       }
+                       else {
+                               $sql .= "(
+                                       SELECT          ".$objectType->getIDFieldName()." AS objectID,
+                                                       ".$objectType->getSubjectFieldName()." AS subject,
+                                                       ".$objectType->getTimeFieldName()." AS time,
+                                                       ".$objectType->getUsernameFieldName()." AS username,
+                                                       '".$objectTypeName."' AS objectType
+                                                       ".($relevanceCalc ? ',search_index.relevance' : '')."
+                                       FROM            ".$objectType->getTableName()."
+                                       INNER JOIN      (
+                                                               SELECT          objectID
+                                                                               ".($relevanceCalc ? ','.$relevanceCalc : '')."
+                                                               FROM            wcf".WCF_N."_search_index
+                                                               WHERE           ".($fulltextCondition !== null ? $fulltextCondition : '')."
+                                                                               ".(($searchIndexCondition !== null && $searchIndexCondition->__toString()) ? ($fulltextCondition !== null ? "AND " : '').$searchIndexCondition : '')."
+                                                                               AND objectTypeID = ".$objectType->objectTypeID."
+                                                               ".(!empty($orderBy) && $fulltextCondition === null ? 'ORDER BY '.$orderBy : '')."
+                                                               LIMIT           1000
+                                                       ) search_index
+                                       ON              (".$objectType->getIDFieldName()." = search_index.objectID)
+                                       ".$objectType->getJoins()."
+                                       ".(isset($additionalConditions[$objectTypeName]) ? $additionalConditions[$objectTypeName] : '')."
+                               )";
+                       }
+                       
+                       if ($fulltextCondition !== null) $parameters = array_merge($parameters, $fulltextCondition->getParameters());
+                       if ($searchIndexCondition !== null) $parameters = array_merge($parameters, $searchIndexCondition->getParameters());
+                       if (isset($additionalConditions[$objectTypeName])) $parameters = array_merge($parameters, $additionalConditions[$objectTypeName]->getParameters());
+               }
+               if (empty($sql)) {
+                       throw new SystemException('no object types given');
+               }
+               
+               if (!empty($orderBy)) {
+                       $sql .= " ORDER BY " . $orderBy;
+               }
+               
+               // send search query
+               $messages = array();
+               $statement = WCF::getDB()->prepareStatement($sql, $limit);
+               $statement->execute($parameters);
+               while ($row = $statement->fetchArray()) {
+                       $messages[] = array(
+                               'objectID' => $row['objectID'],
+                               'objectType' => $row['objectType']
+                       );
+               }
+               
+               return $messages;
+       }
+}
diff --git a/wcfsetup/install/files/lib/system/search/SearchIndexManager.class.php b/wcfsetup/install/files/lib/system/search/SearchIndexManager.class.php
new file mode 100644 (file)
index 0000000..648e600
--- /dev/null
@@ -0,0 +1,113 @@
+<?php
+namespace wcf\system\search;
+use wcf\data\object\type\ObjectTypeCache;
+use wcf\system\exception\SystemException;
+use wcf\system\SingletonFactory;
+use wcf\system\WCF;
+
+/**
+ * Manages the search index.
+ * 
+ * @author     Marcel Werk
+ * @copyright  2001-2013 WoltLab GmbH
+ * @license    GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @package    com.woltlab.wcf.search
+ * @subpackage system.search
+ * @category   Community Framework
+ */
+class SearchIndexManager extends SingletonFactory {
+       /**
+        * list of available object types
+        * @var array
+        */
+       protected $availableObjectTypes = array();
+       
+       /**
+        * @see wcf\system\SingletonFactory::init()
+        */
+       protected function init() {
+               // get available object types
+               $this->availableObjectTypes = ObjectTypeCache::getInstance()->getObjectTypes('com.woltlab.wcf.searchableObjectType');
+       }
+       
+       /**
+        * Returns the id of the object type with the given name.
+        * 
+        * @param       string          $objectType
+        * @return      integer
+        */
+       public function getObjectTypeID($objectType) {
+               if (!isset($this->availableObjectTypes[$objectType])) {
+                       throw new SystemException("unknown object type '".$objectType."'");
+               }
+               
+               return $this->availableObjectTypes[$objectType]->objectTypeID;
+       }
+       
+       /**
+        * Adds a new entry.
+        *
+        * @param       string          $objectType
+        * @param       integer         $objectID
+        * @param       string          $message
+        * @param       string          $subject
+        * @param       integer         $time
+        * @param       integer         $userID
+        * @param       string          $username
+        * @param       integer         $languageID
+        * @param       string          $metaData
+        */
+       public function add($objectType, $objectID, $message, $subject, $time, $userID, $username, $languageID = null, $metaData = '') {
+               if ($languageID === null) $languageID = 0;
+               
+               // save new entry
+               $sql = "INSERT INTO     wcf".WCF_N."_search_index
+                                       (objectTypeID, objectID, subject, message, time, userID, username, languageID, metaData)
+                       VALUES          (?, ?, ?, ?, ?, ?, ?, ?, ?)";
+               $statement = WCF::getDB()->prepareStatement($sql);
+               $statement->execute(array($this->getObjectTypeID($objectType), $objectID, $subject, $message, $time, $userID, $username, $languageID, $metaData));
+       }
+       
+       /**
+        * Updates the search index.
+        * 
+        * @param       string          $objectType
+        * @param       integer         $objectID
+        * @param       string          $message
+        * @param       string          $subject
+        * @param       integer         $time
+        * @param       integer         $userID
+        * @param       string          $username
+        * @param       integer         $languageID
+        * @param       string          $metaData
+        */
+       public function update($objectType, $objectID, $message, $subject, $time, $userID, $username, $languageID = null, $metaData = '') {
+               // delete existing entry
+               $this->delete($objectType, array($objectID), ($languageID === null ? 0 : $languageID));
+               
+               // save new entry
+               $this->add($objectType, $objectID, $message, $subject, $time, $userID, $username, $languageID, $metaData);
+       }
+       
+       /**
+        * Deletes search index entries.
+        * 
+        * @param       string          $objectType
+        * @param       array<integer>  $objectIDs
+        */
+       public function delete($objectType, array $objectIDs) {
+               $objectTypeID = $this->getObjectTypeID($objectType);
+               
+               $sql = "DELETE FROM     wcf".WCF_N."_search_index
+                       WHERE           objectTypeID = ?
+                                       AND objectID = ?";
+               $statement = WCF::getDB()->prepareStatement($sql);
+               WCF::getDB()->beginTransaction();
+               foreach ($objectIDs as $objectID) {
+                       $parameters = array($objectTypeID, $objectID);
+                       
+                       $statement->execute($parameters);
+               }
+               WCF::getDB()->commitTransaction();
+       }
+}
diff --git a/wcfsetup/install/files/lib/system/search/SearchKeywordManager.class.php b/wcfsetup/install/files/lib/system/search/SearchKeywordManager.class.php
new file mode 100644 (file)
index 0000000..f901b1d
--- /dev/null
@@ -0,0 +1,60 @@
+<?php
+namespace wcf\system\search;
+use wcf\data\search\keyword\SearchKeywordAction;
+use wcf\system\SingletonFactory;
+use wcf\system\WCF;
+
+/**
+ * Manages the search keywords.
+ * 
+ * @author     Marcel Werk
+ * @copyright  2001-2012 WoltLab GmbH
+ * @license    GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @package    com.woltlab.wcf.search
+ * @subpackage system.search
+ * @category   Community Framework
+ */
+class SearchKeywordManager extends SingletonFactory {
+       /**
+        * Adds the given keyword.
+        * 
+        * @param       string          $keyword
+        */
+       public function add($keyword) {
+               $keyword = static::simplifyKeyword($keyword);
+               
+               // search existing entry
+               $sql = "SELECT  *
+                       FROM    wcf".WCF_N."_search_keyword
+                       WHERE   keyword = ?";
+               $statement = WCF::getDB()->prepareStatement($sql);
+               $statement->execute(array($keyword));
+               if (($object = $statement->fetchObject('wcf\data\search\keyword\SearchKeyword')) !== null) {
+                       $action = new SearchKeywordAction(array($object), 'update', array('data' => array(
+                               'searches' => $object->searches + 1,
+                               'lastSearchTime' => TIME_NOW
+                       )));
+                       $action->executeAction();
+               }
+               else {
+                       $action = new SearchKeywordAction(array(), 'create', array('data' => array(
+                               'keyword' => $keyword,
+                               'searches' => 1,
+                               'lastSearchTime' => TIME_NOW
+                       )));
+                       $action->executeAction();
+               }
+       }
+       
+       /**
+        * Returns simplified version of the given keyword.
+        * 
+        * @param       string          $keyword
+        * @return      string
+        */
+       public static function simplifyKeyword($keyword) {
+               // TODO: do something useful
+               
+               return $keyword;
+       }
+}
diff --git a/wcfsetup/install/files/lib/system/search/SearchResultTextParser.class.php b/wcfsetup/install/files/lib/system/search/SearchResultTextParser.class.php
new file mode 100644 (file)
index 0000000..6e2e08f
--- /dev/null
@@ -0,0 +1,207 @@
+<?php
+namespace wcf\system\search;
+use wcf\system\bbcode\KeywordHighlighter;
+use wcf\system\Regex;
+use wcf\system\SingletonFactory;
+use wcf\util\ArrayUtil;
+use wcf\util\StringUtil;
+
+/**
+ * Formats messages for search result output.
+ * 
+ * @author     Marcel Werk
+ * @copyright  2001-2012 WoltLab GmbH
+ * @license    GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @package    com.woltlab.wcf.search
+ * @subpackage system.search
+ * @category   Community Framework
+ */
+class SearchResultTextParser extends SingletonFactory {
+       /**
+        * max length for message abstract
+        * @var integer
+        */
+       const MAX_LENGTH = 500;
+       
+       /**
+        * highlight query
+        * @var mixed
+        */
+       protected $searchQuery = '';
+       
+       /**
+        * @see wcf\system\SingletonFactory::init()
+        */
+       protected function init() {
+               if (isset($_GET['highlight'])) {
+                       $keywordString = $_GET['highlight'];
+                       
+                       // remove search operators
+                       $keywordString = preg_replace('/[\+\-><()~\*]+/', '', $keywordString);
+                       
+                       if (StringUtil::substring($keywordString, 0, 1) == '"' && StringUtil::substring($keywordString, -1) == '"') {
+                               // phrases search
+                               $keywordString = StringUtil::trim(StringUtil::substring($keywordString, 1, -1));
+                               
+                               if (!empty($keywordString)) {
+                                       $this->searchQuery = $keywordString;
+                               }
+                       }
+                       else {
+                               $this->searchQuery = ArrayUtil::trim(explode(' ', $keywordString));
+                               if (empty($this->searchQuery)) {
+                                       $this->searchQuery = false;
+                               }
+                               else if (count($this->searchQuery) == 1) {
+                                       $this->searchQuery = reset($this->searchQuery);
+                               }
+                       }
+               }
+       }
+       
+       /**
+        * Returns an abstract of the given message.
+        * Uses search keywords to shift the start and end position of the abstract (like Google).
+        * 
+        * @param       string          $text
+        * @return      string
+        */
+       protected function getMessageAbstract($text) {
+               // replace newlines with spaces
+               $text = Regex::compile("\s+")->replace($text, ' ');
+               
+               if (StringUtil::length($text) > static::MAX_LENGTH) {
+                       if ($this->searchQuery) {
+                               // phrase search
+                               if (!is_array($this->searchQuery)) {
+                                       $start = StringUtil::indexOfIgnoreCase($text, $this->searchQuery);
+                                       if ($start !== false) {
+                                               $end = $start + StringUtil::length($this->searchQuery);
+                                               $shiftStartBy = $shiftEndBy = round((static::MAX_LENGTH - StringUtil::length($this->searchQuery)) / 2);
+                                               
+                                               // shiftStartBy is negative when search query length is over max length
+                                               if ($shiftStartBy < 0) {
+                                                       $shiftEndBy += $shiftStartBy;
+                                                       $shiftStartBy = 0;
+                                               }
+                                                       
+                                               // shift abstract start
+                                               if ($start - $shiftStartBy < 0) {
+                                                       $shiftEndBy += $shiftStartBy - $start;
+                                                       $start = 0;
+                                               }
+                                               else {
+                                                       $start -= $shiftStartBy;
+                                               }
+                                               
+                                               // shift abstract end
+                                               if ($end + $shiftEndBy > StringUtil::length($text) - 1) {
+                                                       $shiftStartBy = $end + $shiftEndBy - StringUtil::length($text) - 1;
+                                                       $shiftEndBy = 0;
+                                                       if ($shiftStartBy > $start) {
+                                                               $start = 0;
+                                                       }
+                                                       else {
+                                                               $start -= $shiftStartBy;
+                                                       }
+                                               }
+                                               else {
+                                                       $end += $shiftEndBy;
+                                               }
+                                               
+                                               $newText = '';
+                                               if ($start > 0) $newText .= StringUtil::HELLIP;
+                                               $newText .= StringUtil::substring($text, $start, $end - $start);
+                                               if ($end < StringUtil::length($text) - 1) $newText .= StringUtil::HELLIP;
+                                               return $newText;
+                                       }
+                               }
+                               else {
+                                       $matches = array();
+                                       $shiftLength = static::MAX_LENGTH;
+                                       // find first match of each keyword
+                                       foreach ($this->searchQuery as $keyword) {
+                                               $start = StringUtil::indexOfIgnoreCase($text, $keyword);
+                                               if ($start !== false) {
+                                                       $shiftLength -= StringUtil::length($keyword);
+                                                       $matches[$keyword] = array('start' => $start, 'end' => $start + StringUtil::length($keyword));
+                                               }
+                                       }
+                                       
+                                       // shift match position
+                                       $shiftBy = round(($shiftLength / count($this->searchQuery)) / 2);
+                                       foreach ($matches as $keyword => $position) {
+                                               $position['start'] -= $shiftBy;
+                                               $position['end'] += $shiftBy;
+                                               $matches[$keyword] = $position;
+                                       }
+                                       
+                                       $start = 0;
+                                       $end = StringUtil::length($text) - 1;
+                                       $newText = '';
+                                       $i = 0;
+                                       $length = count($matches);
+                                       foreach ($matches as $keyword => $position) {
+                                               if ($position['start'] < $start) {
+                                                       $position['end'] += $start - $position['start'];
+                                                       $position['start'] = $start;
+                                               }
+                                               
+                                               if ($position['end'] > $end) {
+                                                       if ($position['start'] > $start) {
+                                                               $shiftStartBy = $position['end'] - $end;
+                                                               if ($position['start'] - $shiftStartBy < $start) {
+                                                                       $shiftStartBy = $position['start'] - $start;
+                                                               }
+                                                               
+                                                               $position['start'] -= $shiftStartBy;
+                                                       }
+                                                       
+                                                       $position['end'] = $end;
+                                               }
+                                               
+                                               if ($position['start'] > $start) $newText .= StringUtil::HELLIP;
+                                               $newText .= StringUtil::substring($text, $position['start'], $position['end'] - $position['start']);
+                                               if ($i == $length - 1 && $position['end'] < $end) $newText .= StringUtil::HELLIP;
+                                               
+                                               $start = $position['end'];
+                                               $i++;
+                                       }
+                                       
+                                       if (!empty($newText)) return $newText;
+                               }
+                       }
+                       
+                       // no search query or no matches
+                       return StringUtil::substring($text, 0, static::MAX_LENGTH) . StringUtil::HELLIP;
+               }
+               
+               return $text;
+       }
+       
+       /**
+        * Formats a message for search result output.
+        * 
+        * @param       string          $text
+        * @return      string
+        */
+       public function parse($text) {
+               // remove nonessentials
+               $text = Regex::compile('<!-- begin:parser_nonessential -->.*?<!-- end:parser_nonessential -->', Regex::DOT_ALL)->replace($text, '');
+               
+               // remove html codes
+               $text = StringUtil::stripHTML($text);
+               
+               // decode html
+               $text = StringUtil::decodeHTML($text);
+               
+               // get abstract
+               $text = $this->getMessageAbstract($text);
+               
+               // encode html
+               $text = StringUtil::encodeHTML($text);
+               
+               // do highlighting
+               return KeywordHighlighter::getInstance()->doHighlight($text);
+       }
+}
diff --git a/wcfsetup/install/files/style/search.less b/wcfsetup/install/files/style/search.less
new file mode 100644 (file)
index 0000000..36c849b
--- /dev/null
@@ -0,0 +1,5 @@
+.messageSearchResultList {
+       > li > .box48 > div > .containerHeadline > h3 {
+               padding-right: 100px;
+       }
+}
\ No newline at end of file
index ecdd50dd59790f4bf54641081a3f8bb5ffa2ee25..0c2d0f1b975bee9978c5ddd768a8f52480d65087 100644 (file)
                <item name="wcf.acp.option.module_tagging"><![CDATA[Tagging]]></item>
                <item name="wcf.acp.option.module_tagging.description"><![CDATA[Aktiviert die Funktion für das Taggen von Inhalten.]]></item>
                <item name="wcf.acp.option.tagging_max_tag_length"><![CDATA[Maximale Tag-Länge]]></item>
+               <item name="wcf.acp.option.category.message.search"><![CDATA[Suchfunktion]]></item>
+               <item name="wcf.acp.option.search_results_per_page"><![CDATA[Ergebnisse pro Seite]]></item>
+               <item name="wcf.acp.option.search_default_sort_field"><![CDATA[Standardsortierung]]></item>
+               <item name="wcf.acp.option.search_default_sort_order"><![CDATA[Standardreihenfolge]]></item>
+               <item name="wcf.acp.option.search_use_captcha"><![CDATA[reCAPTCHA in Suchfunktion aktivieren]]></item>
        </category>
        
        <category name="wcf.acp.package">
@@ -1710,6 +1715,28 @@ Erlaubte Dateiendungen: {', '|implode:$attachmentHandler->getAllowedExtensions()
                <item name="wcf.recaptcha.error.recaptchaString.false"><![CDATA[Sie haben leider nicht die korrekten Zeichen eingegeben. Bitte versuchen Sie es erneut!]]></item>
        </category>
        
+       <category name="wcf.search">
+               <item name="wcf.search.author"><![CDATA[Suche nach Autor]]></item>
+               <item name="wcf.search.extended"><![CDATA[Erweiterte Suche]]></item>
+               <item name="wcf.search.general"><![CDATA[Allgemeine Suchangaben]]></item>
+               <item name="wcf.search.matchExactly"><![CDATA[Exakter Treffer]]></item>
+               <item name="wcf.search.period"><![CDATA[Im Zeitraum]]></item>
+               <item name="wcf.search.query"><![CDATA[Suche nach Begriff]]></item>
+               <item name="wcf.search.query.description"><![CDATA[Geben Sie einen oder mehrere Begriffe ein. Ein Begriff muss mindestens vier Zeichen lang sein.]]></item>
+               <item name="wcf.search.results"><![CDATA[Suchergebnisse]]></item>
+               <item name="wcf.search.results.change"><![CDATA[Suche ändern]]></item>
+               <item name="wcf.search.results.description"><![CDATA[Suchergebnisse {#$startIndex}-{#$endIndex} von insgesamt {#$items}{if $query} für „<strong>{$query}</strong>“{/if}.{if $items == 1000} Es gibt noch weitere Suchergebnisse, bitte verfeinern Sie Ihre Suche.{/if}{if $highlight}<br />Diese Suchbegriffe wurden hervorgehoben: <span class="highlight">{$highlight}</span>{/if}]]></item>
+               <item name="wcf.search.sortBy"><![CDATA[Sortierung]]></item>
+               <item name="wcf.search.sortBy.relevance"><![CDATA[Relevanz]]></item>
+               <item name="wcf.search.sortBy.time"><![CDATA[Datum]]></item>
+               <item name="wcf.search.sortBy.username"><![CDATA[Autor]]></item>
+               <item name="wcf.search.subjectOnly"><![CDATA[Nur Betreff durchsuchen]]></item>
+               <item name="wcf.search.title"><![CDATA[Suche]]></item>
+               <item name="wcf.search.type"><![CDATA[Suche in]]></item>
+               <item name="wcf.search.error.noMatches"><![CDATA[Es wurde kein{if !$query|empty} mit Ihrer Suchanfrage „{$query}“ übereinstimmender{/if} Eintrag gefunden.]]></item>
+               <item name="wcf.search.error.user.noMatches"><![CDATA[Es wurde kein Eintrag von diesem Autor gefunden.]]></item>
+       </category>
+       
        <category name="wcf.style">
                <item name="wcf.style.changeStyle"><![CDATA[Stil ändern]]></item>
                <item name="wcf.style.colorPicker"><![CDATA[Farbwähler]]></item>
index 8e58246be10fc3eeb0181ee9d279e522a6894c3f..6f8dd8811cab4a31c54f96bc27c21f7c0c4d1f2e 100644 (file)
@@ -765,6 +765,11 @@ Examples for medium ID detection:
                <item name="wcf.acp.option.module_tagging"><![CDATA[Tagging]]></item>
                <item name="wcf.acp.option.module_tagging.description"><![CDATA[Enables the tagging of content.]]></item>
                <item name="wcf.acp.option.tagging_max_tag_length"><![CDATA[Maximum Length of a Tag]]></item>
+               <item name="wcf.acp.option.category.message.search"><![CDATA[Search]]></item>
+               <item name="wcf.acp.option.search_results_per_page"><![CDATA[Results per Page]]></item>
+               <item name="wcf.acp.option.search_default_sort_field"><![CDATA[Default Sort Field]]></item>
+               <item name="wcf.acp.option.search_default_sort_order"><![CDATA[Default Sort Order]]></item>
+               <item name="wcf.acp.option.search_use_captcha"><![CDATA[Enable reCAPTCHA protection for search]]></item>
        </category>
        
        <category name="wcf.acp.package">
@@ -1708,6 +1713,28 @@ Allowed extensions: {', '|implode:$attachmentHandler->getAllowedExtensions()}]]>
                <item name="wcf.recaptcha.error.recaptchaString.false"><![CDATA[You have entered the security code incorrectly. Please try again.]]></item>
        </category>
        
+       <category name="wcf.search">
+               <item name="wcf.search.author"><![CDATA[Search By Author]]></item>
+               <item name="wcf.search.extended"><![CDATA[More Options]]></item>
+               <item name="wcf.search.general"><![CDATA[General Search Terms]]></item>
+               <item name="wcf.search.matchExactly"><![CDATA[Exact match]]></item>
+               <item name="wcf.search.period"><![CDATA[Timeframe]]></item>
+               <item name="wcf.search.query"><![CDATA[Search for Term]]></item>
+               <item name="wcf.search.query.description"><![CDATA[Type in one or more search terms, each must be at least 4 characters.]]></item>
+               <item name="wcf.search.results"><![CDATA[Search Results]]></item>
+               <item name="wcf.search.results.change"><![CDATA[Change Search]]></item>
+               <item name="wcf.search.results.description"><![CDATA[Search results {#$startIndex}-{#$endIndex} of {#$items}{if $query} for “<strong>{$query}</strong>”{/if}.{if $items == 1000} There are more results available, please enhance your search parameters.{/if}{if $highlight}<br />These terms were highlighted: <span class="highlight">{$highlight}</span>{/if}]]></item>
+               <item name="wcf.search.sortBy"><![CDATA[Sort By]]></item>
+               <item name="wcf.search.sortBy.relevance"><![CDATA[Relevance]]></item>
+               <item name="wcf.search.sortBy.time"><![CDATA[Time]]></item>
+               <item name="wcf.search.sortBy.username"><![CDATA[Author]]></item>
+               <item name="wcf.search.subjectOnly"><![CDATA[Search subject only]]></item>
+               <item name="wcf.search.title"><![CDATA[Search]]></item>
+               <item name="wcf.search.type"><![CDATA[Search in]]></item>
+               <item name="wcf.search.error.noMatches"><![CDATA[No items matched your search{if !$query|empty} terms: “{$query}”{/if}.]]></item>
+               <item name="wcf.search.error.user.noMatches"><![CDATA[There were no items matching this author.]]></item>
+       </category>
+       
        <category name="wcf.style">
                <item name="wcf.style.changeStyle"><![CDATA[Change Style]]></item>
                <item name="wcf.style.colorPicker"><![CDATA[Color Picker]]></item>
index ed7b63cb482578e2476678149e5ad2569ec57f21..facbcd0d04d2cb7598620e6954ff674f1afc2d18 100644 (file)
@@ -712,6 +712,33 @@ CREATE TABLE wcf1_search (
        KEY searchHash (searchHash)
 );
 
+DROP TABLE IF EXISTS wcf1_search_index;
+CREATE TABLE wcf1_search_index (
+       objectTypeID INT(10) NOT NULL,
+       objectID INT(10) NOT NULL,
+       subject VARCHAR(255) NOT NULL DEFAULT '',
+       message MEDIUMTEXT,
+       metaData MEDIUMTEXT,
+       time INT(10) NOT NULL DEFAULT 0,
+       userID INT(10),
+       username VARCHAR(255) NOT NULL DEFAULT '',
+       languageID INT(10),
+       UNIQUE KEY (objectTypeID, objectID),
+       FULLTEXT INDEX fulltextIndex (subject, message, metaData),
+       FULLTEXT INDEX fulltextIndexSubjectOnly (subject),
+       KEY (userID, objectTypeID, time)
+);
+
+DROP TABLE IF EXISTS wcf1_search_keyword;
+CREATE TABLE wcf1_search_keyword (
+       keywordID INT(10) NOT NULL AUTO_INCREMENT PRIMARY KEY,
+       keyword VARCHAR(255) NOT NULL,
+       searches INT(10) NOT NULL DEFAULT 0,
+       lastSearchTime INT(10) NOT NULL DEFAULT 0,
+       UNIQUE KEY (keyword),
+       KEY (searches, lastSearchTime)
+);
+
 DROP TABLE IF EXISTS wcf1_session;
 CREATE TABLE wcf1_session (
        sessionID CHAR(40) NOT NULL PRIMARY KEY,
@@ -1450,6 +1477,8 @@ ALTER TABLE wcf1_tag_to_object ADD FOREIGN KEY (tagID) REFERENCES wcf1_tag (tagI
 ALTER TABLE wcf1_tag_to_object ADD FOREIGN KEY (languageID) REFERENCES wcf1_language (languageID) ON DELETE CASCADE;
 ALTER TABLE wcf1_tag_to_object ADD FOREIGN KEY (objectTypeID) REFERENCES wcf1_object_type (objectTypeID) ON DELETE CASCADE;
 
+ALTER TABLE wcf1_search_index ADD FOREIGN KEY (objectTypeID) REFERENCES wcf1_object_type (objectTypeID) ON DELETE CASCADE;
+ALTER TABLE wcf1_search_index ADD FOREIGN KEY (languageID) REFERENCES wcf1_language (languageID) ON DELETE SET NULL;
 
 /* default inserts */
 -- default user groups