Index: trunk/grails-app/conf/Searchable.groovy
===================================================================
--- trunk/grails-app/conf/Searchable.groovy	(revision 562)
+++ trunk/grails-app/conf/Searchable.groovy	(revision 562)
@@ -0,0 +1,170 @@
+/**
+ * This {@link groovy.util.ConfigObject} script provides Grails Searchable Plugin configuration.
+ *
+ * You can use the "environments" section at the end of the file to define per-environment
+ * configuration.
+ *
+ * Note it is NOT required to add a reference to this file in Config.groovy; it is loaded by
+ * the plugin itself.
+ *
+ * Available properties in the binding are:
+ *
+ * @param userHome The current user's home directory.
+ *                 Same as System.properties['user.home']
+ * @param appName The Grails environment (ie, "development", "test", "production").
+ *                Same as System.properties['grails.env']
+ * @param appVersion The version of your application
+ * @param grailsEnv The Grails environment (ie, "development", "test", "production").
+ *                  Same as System.properties['grails.env']
+ *
+ * You can also use System.properties to refer to other JVM properties.
+ *
+ * This file is created by "grails install-searchable-config", and replaces
+ * the previous "SearchableConfiguration.groovy"
+ */
+searchable {
+
+    /**
+     * The location of the Compass index
+     *
+     * Examples: "/home/app/compassindex", "ram://app-index" or null to use the default
+     *
+     * The default is "${user.home}/.grails/projects/${app.name}/searchable-index/${grails.env}"
+     */
+    // Pickup the Tomcat/Catalina work directory else use the current dir.
+    def catalinaBase = System.properties.getProperty('catalina.base')
+    def indexDirectory = catalinaBase ? "${catalinaBase}/work/lucene/${appName}" : './target/lucene'
+
+    compassConnection = new File(indexDirectory).absolutePath
+
+    /**
+     * Any settings you wish to pass to Compass
+     *
+     * Use this to configure custom/override default analyzers, query parsers, eg
+     *
+     *     Map compassSettings = [
+     *         'compass.engine.analyzer.german.type': 'German'
+     *     ]
+     *
+     * gives you an analyzer called "german" you can then use in mappings and queries, like
+     *
+     *    class Book {
+     *        static searchable = { content analyzer: 'german' }
+     *        String content
+     *    }
+     *
+     *    Book.search("unter", analyzer: 'german')
+     *
+     * Documentation for Compass settings is here: http://www.compass-project.org/docs/2.1.0M2/reference/html/core-settings.html
+     */
+    compassSettings = [:]
+
+    /**
+     * Default mapping property exclusions
+     *
+     * No properties matching the given names will be mapped by default
+     * ie, when using "searchable = true"
+     *
+     * This does not apply for classes using "searchable = [only/except: [...]]"
+     * or mapping by closure
+     */
+    defaultExcludedProperties = ["password"]
+
+    /**
+     * Default property formats
+     *
+     * Value is a Map between Class and format string, eg
+     *
+     *     [(Date): "yyyy-MM-dd'T'HH:mm:ss"]
+     *
+     * Only applies to class properties mapped as "searchable properties", which are typically
+     * simple class types that can be represented as Strings (rather than references
+     * or components) AND only required if overriding the built-in format.
+     */
+    defaultFormats = [:]
+
+    /**
+     * Set default options for each SearchableService/Domain-class method, by method name.
+     *
+     * These can be overriden on a per-query basis by passing the method a Map of options
+     * containing those you want to override.
+     *
+     * You may want to customise the options used by the search method, which are:
+     *
+     * @param reload          whether to reload domain class instances from the DB: true|false
+     *                        If true, the search  will be slower but objects will be associated
+     *                        with the current Hibernate session
+     * @param escape          whether to escape special characters in string queries: true|false
+     * @param offset          the 0-based hit offset of the first page of results.
+     *                        Normally you wouldn't change it from 0, it's only here because paging
+     *                        works by using an offset + max combo for a specific page
+     * @param max             the page size, for paged search results
+     * @param defaultOperator if the query does not otherwise indicate, then the default operator
+     *                        applied: "or" or "and".
+     *                        If "and" means all terms are required for a match, if "or" means
+     *                        any term is required for a match
+     * @param suggestQuery    if true and search method is returning a search-result object
+     *                        (rather than a domain class instance, list or count) then a
+     *                        "suggestedQuery" property is also added to the search-result.
+     *                        This can also be a Map of options as supported by the suggestQuery
+     *                        method itself
+     *
+     * For the options supported by other methods, please see the documentation
+     * http://grails.org/Searchable+Plugin
+     */
+    defaultMethodOptions = [
+        search: [reload: false, escape: false, offset: 0, max: 10, defaultOperator: "and"],
+        suggestQuery: [userFriendly: true]
+    ]
+
+    /**
+     * Should changes made through GORM/Hibernate be mirrored to the index
+     * automatically (using Compass::GPS)?
+     *
+     * If false, you must manage the index manually using index/unindex/reindex
+     */
+    mirrorChanges = true
+
+    /**
+     * Should the database be indexed at startup (using Compass:GPS)?
+     *
+     * Possible values: true|false|"fork"
+     *
+     * The value may be a boolean true|false or a string "fork", which means true,
+     * and fork a thread for it
+     *
+     * If you use BootStrap.groovy to insert your data then you should use "true",
+     * which means do a non-forking, otherwise "fork" is recommended
+     */
+    bulkIndexOnStartup = true
+
+    /**
+     * Should index locks be removed (if present) at startup?
+     */
+    releaseLocksOnStartup = true
+}
+
+// per-environment settings
+environments {
+    development {
+        searchable {
+            // development is default; inherits from above
+        }
+    }
+
+    test {
+        searchable {
+            // disable bulk index on startup
+            bulkIndexOnStartup = false
+
+            // use faster in-memory index
+            compassConnection = "ram://test-index"
+        }
+    }
+
+    production {
+        searchable {
+            // add your production settings here
+        }
+    }
+}
Index: trunk/grails-app/controllers/AppCoreController.groovy
===================================================================
--- trunk/grails-app/controllers/AppCoreController.groovy	(revision 561)
+++ trunk/grails-app/controllers/AppCoreController.groovy	(revision 562)
@@ -11,4 +11,5 @@
     def appConfigService
     def createDataService
+    def searchableService
     def createBulkDataService
 
@@ -251,3 +252,16 @@
     }
 
+    /**
+    * Rebuild the lucene text search index.
+    */
+    @Secured(['ROLE_AppAdmin', 'ROLE_Manager'])
+    def rebuildTextSearchIndex = {
+        log.info "Rebuilding lucene text search index."
+        searchableService.reindex()
+        log.info "Rebuilding lucene text search index, complete."
+
+        flash.message = g.message(code:"default.update.success", args:["Index ", ''])
+        redirect(action: manager)
+    }
+
 } // end of class.
Index: trunk/grails-app/controllers/InventoryItemDetailedController.groovy
===================================================================
--- trunk/grails-app/controllers/InventoryItemDetailedController.groovy	(revision 561)
+++ trunk/grails-app/controllers/InventoryItemDetailedController.groovy	(revision 562)
@@ -134,7 +134,21 @@
         def isFilterApplied = FilterUtils.isFilterApplied(params)
 
+        // Restore default sort if a new text search is requested
+        if(params.newTextSearch) {
+            params.sort = 'id'
+            params.order = 'desc'
+        }
+
         // Restore search unless a new search is being requested.
-        if(!params.quickSearch && !filterParams) {
-            if(session.inventoryItemQuickSearch) {
+        if(!params.searchText && !params.quickSearch && !filterParams) {
+            if(session.inventoryItemSearchText) {
+                params.searchText = session.inventoryItemSearchText
+                params.searchName = session.inventoryItemSearchName
+                params.searchDescription = session.inventoryItemSearchDescription
+                params.searchComment = session.inventoryItemSearchComment
+                params.searchLocation = session.inventoryItemSearchLocation
+                params.searchSpareFor = session.inventoryItemSearchSpareFor
+            }
+            else if(session.inventoryItemQuickSearch) {
                 params.quickSearch = session.inventoryItemQuickSearch
                 if(session.inventoryItemQuickSearchDaysBack)
@@ -166,9 +180,36 @@
             session.inventoryItemSearchFilterParams = new LinkedHashMap(filterParams)
             session.inventoryItemSearchFilter = new LinkedHashMap(params.filter)
+            // Clear any previous search.
+            session.removeAttribute("inventoryItemSearchText")
+            session.removeAttribute("inventoryItemSearchName")
+            session.removeAttribute("inventoryItemSearchDescription")
+            session.removeAttribute("inventoryItemSearchComment")
+            session.removeAttribute("inventoryItemSearchLocation")
+            session.removeAttribute("inventoryItemSearchSpareFor")
             session.removeAttribute("inventoryItemQuickSearch")
             session.removeAttribute("inventoryItemQuickSearchDaysBack")
         }
+        else if(params.searchText) {
+            // Quick Search Text:
+            def result = inventoryItemSearchService.getTextSearch(params, RCU.getLocale(request))
+            inventoryItemInstanceList = result.inventoryItemList
+            inventoryItemInstanceTotal = result.inventoryItemList.totalCount
+            params.message = result.message
+            filterParams.searchText = result.searchText
+            // Remember search.
+            session.inventoryItemSearchText = params.searchText
+            session.inventoryItemSearchName = params.searchName
+            session.inventoryItemSearchDescription = params.searchDescription
+            session.inventoryItemSearchComment = params.searchComment
+            session.inventoryItemSearchLocation = params.searchLocation
+            session.inventoryItemSearchSpareFor = params.searchSpareFor
+            // Clear any previous search.
+            session.removeAttribute("inventoryItemQuickSearch")
+            session.removeAttribute("inventoryItemQuickSearchDaysBack")
+            session.removeAttribute("inventoryItemSearchFilterParams")
+            session.removeAttribute("inventoryItemSearchFilter")
+        }
         else {
-            // Quick Search:
+            // Quick Search Links:
             if(!params.quickSearch) params.quickSearch = "all"
             def result = inventoryItemSearchService.getQuickSearch(params, RCU.getLocale(request))
@@ -178,9 +219,16 @@
             filterParams.quickSearch = result.quickSearch
             // Remember search.
-            session.removeAttribute("inventoryItemSearchFilterParams")
-            session.removeAttribute("inventoryItemSearchFilter")
             session.inventoryItemQuickSearch = result.quickSearch
             if(result.daysBack)
                 session.inventoryItemQuickSearchDaysBack = result.daysBack
+            // Clear any previous search.
+            session.removeAttribute("inventoryItemSearchText")
+            session.removeAttribute("inventoryItemSearchName")
+            session.removeAttribute("inventoryItemSearchDescription")
+            session.removeAttribute("inventoryItemSearchComment")
+            session.removeAttribute("inventoryItemSearchLocation")
+            session.removeAttribute("inventoryItemSearchSpareFor")
+            session.removeAttribute("inventoryItemSearchFilterParams")
+            session.removeAttribute("inventoryItemSearchFilter")
         }
 
Index: trunk/grails-app/domain/Asset.groovy
===================================================================
--- trunk/grails-app/domain/Asset.groovy	(revision 561)
+++ trunk/grails-app/domain/Asset.groovy	(revision 562)
@@ -30,4 +30,9 @@
     }
 
+    static searchable = {
+        root false // only index as a component of InventoryItem.
+        only = ['name', 'description', 'comment']
+    }
+
     //  This additional setter is used to convert the checkBoxList string or string array
     //  of ids selected to the corresponding domain objects.
Index: trunk/grails-app/domain/InventoryItem.groovy
===================================================================
--- trunk/grails-app/domain/InventoryItem.groovy	(revision 561)
+++ trunk/grails-app/domain/InventoryItem.groovy	(revision 562)
@@ -61,4 +61,11 @@
     String toString() {"${this.name}"}
 
+    static searchable = {
+        only = ['name', 'description', 'comment', 'inventoryLocation', 'spareFor']
+        //name boost: 1.5
+        inventoryLocation component: true
+        spareFor component: true
+    }
+
     def afterInsert = {
         addReverseAlternateItems()
Index: trunk/grails-app/domain/InventoryLocation.groovy
===================================================================
--- trunk/grails-app/domain/InventoryLocation.groovy	(revision 561)
+++ trunk/grails-app/domain/InventoryLocation.groovy	(revision 562)
@@ -16,3 +16,9 @@
         "${this.name}"
     }
+
+    static searchable = {
+        root false // only index as a component of InventoryItem.
+        only = ['name']
+    }
+
 }
Index: trunk/grails-app/i18n/messages.properties
===================================================================
--- trunk/grails-app/i18n/messages.properties	(revision 561)
+++ trunk/grails-app/i18n/messages.properties	(revision 562)
@@ -308,4 +308,6 @@
 
 # InventoryItemSearch
+inventoryItem.search.text.found=Results for: {0}
+inventoryItem.search.text.none.found=No results for: {0}
 inventoryItem.search.text.below.reorder=Below Reorder
 inventoryItem.search.text.below.reorder.description=Inventory items at or below reorder point, with reorder enabled.
Index: trunk/grails-app/services/InventoryItemSearchService.groovy
===================================================================
--- trunk/grails-app/services/InventoryItemSearchService.groovy	(revision 561)
+++ trunk/grails-app/services/InventoryItemSearchService.groovy	(revision 562)
@@ -1,3 +1,4 @@
 import grails.orm.PagedResultList
+import org.compass.core.engine.SearchEngineQueryParseException
 
 /**
@@ -138,3 +139,76 @@
     } // getRecentlyUsed
 
+    /**
+    * Get a list of inventory items by search text.
+    * @param params The request params.
+    * @param locale The locale to use when generating result.message.
+    */
+    def getTextSearch(params, locale) {
+        def result = [:]
+        result.searchText = params.searchText.trim() ?: ""
+
+        def getMessage = { Map m ->
+            messageSource.getMessage(m.code, m.args == null ? null : m.args.toArray(), locale)
+        }
+
+        params.max = Math.min(params?.max?.toInteger() ?: 10, paramsMax)
+        params.offset = params?.offset?.toInteger() ?: 0
+        params.sort = params?.sort ?: "id"
+        params.order = params?.order ?: "asc"
+
+        // Build searchableParams.
+        // Do not include params.sort, since not all properites are indexed.
+        def searchableParams = [max: params.max, offset: params.offset,
+                                                    reload: true, defaultOperator: 'or']
+
+        // Perform the searchable query.
+        try {
+            result.inventoryItemList = InventoryItem.search(result.searchText, searchableParams)
+        } catch (e) {
+            log.error e
+            result.inventoryItemList = [:]
+            result.inventoryItemList.results = []
+            result.inventoryItemList.total = 0
+        }
+
+        // Sort the returned instances.
+        if(params.sort != 'id') {
+            if(params.order == 'asc') {
+                if(params.sort == 'name' || params.sort == 'description')
+                    result.inventoryItemList.results.sort { p1, p2 -> p1[params.sort].compareToIgnoreCase(p2[params.sort]) }
+                else if(params.sort == 'inventoryGroup') {
+                    result.inventoryItemList.results.sort { p1, p2 ->
+                        p1.inventoryGroup.name.compareToIgnoreCase(p2.inventoryGroup.name)
+                    }
+                }
+                else if(params.sort == 'unitsInStock')
+                    result.inventoryItemList.results.sort {p1, p2 -> p1[params.sort]  <=> p2[params.sort] }
+            } // asc.
+            else {
+                if(params.sort == 'name' || params.sort == 'description')
+                    result.inventoryItemList.results.sort { p1, p2 -> p2[params.sort].compareToIgnoreCase(p1[params.sort]) }
+                else if(params.sort == 'inventoryGroup') {
+                    result.inventoryItemList.results.sort { p1, p2 ->
+                        p2.inventoryGroup.name.compareToIgnoreCase(p1.inventoryGroup.name)
+                    }
+                }
+                else if(params.sort == 'unitsInStock')
+                    result.inventoryItemList.results.sort {p1, p2 -> p2[params.sort] <=> p1[params.sort]}
+            } // desc.
+        } // sort.
+
+        // Create a PagedResultList.
+        result.inventoryItemList = new PagedResultList(result.inventoryItemList.results, result.inventoryItemList.total)
+
+        // Get the result message.
+        if(result.inventoryItemList.totalCount > 0)
+            result.message = getMessage(code:"inventoryItem.search.text.found", args: [result.searchText])
+        else
+            result.message = getMessage(code:"inventoryItem.search.text.none.found", args: [result.searchText])
+
+        // Success.
+        return result
+
+    } // getTextSearch()
+
 } // end class
Index: trunk/grails-app/views/appCore/manager.gsp
===================================================================
--- trunk/grails-app/views/appCore/manager.gsp	(revision 561)
+++ trunk/grails-app/views/appCore/manager.gsp	(revision 562)
@@ -75,4 +75,16 @@
                         <tr class="prop">
                             <td valign="top" class="name">
+                                <label>Search Index:</label>
+                            </td>
+                            <td valign="top" class="value">
+                                <g:link action="rebuildTextSearchIndex">
+                                    Rebuild
+                                </g:link> - Reindex the entire text search index.
+                                <br />
+                            </td>
+                        </tr>
+
+                        <tr class="prop">
+                            <td valign="top" class="name">
                                 <label>Entity Relationship Diagram:</label>
                             </td>
Index: trunk/grails-app/views/inventoryItemDetailed/search.gsp
===================================================================
--- trunk/grails-app/views/inventoryItemDetailed/search.gsp	(revision 561)
+++ trunk/grails-app/views/inventoryItemDetailed/search.gsp	(revision 562)
@@ -22,5 +22,5 @@
                                     removeImgDir="images" 
                                     removeImgFile="bullet_delete.png"
-                                    title="Search"/>
+                                    title="Advanced Search"/>
 
             <div class="paginateButtons">
@@ -30,5 +30,5 @@
                 Results: ${inventoryItemInstanceList.size()} / ${inventoryItemInstanceTotal}
                 <span class="searchButtons">
-                    <filterpane:filterButton text="Search" appliedText="Change Search" />
+                    <filterpane:filterButton text="Advanced" appliedText="Advanced" />
                 </span>
             </div>
@@ -178,5 +178,5 @@
 
             <filterpane:filterPane domainBean="InventoryItem"
-                                    title="Search"
+                                    title="Advanced Search"
                                     action="search"
                                     class="overlayPane"
@@ -197,5 +197,8 @@
         <div class="overlayPane" id="searchPane" style="display:none;">
             <h2>Quick Search</h2>
+
             <g:form method="post" id="searchForm" name="searchForm" >
+                <g:hiddenField name="newTextSearch" value="true" />
+
                 <table>
                     <tbody>
@@ -252,7 +255,41 @@
                     </tbody>
                 </table>
+
+                <table>
+                    <tbody>
+
+                        <tr class="prop">
+                            <td valign="top" class="name">
+                                <label for="searchText">Search:</label>
+                            </td>
+                            <td valign="top" class="value">
+                                <input type="text" style="width:450px" maxlength="75" id="searchText" name="searchText" value="${filterParams.searchText}"/>
+                            </td>
+                        </tr>
+
+                        <tr class="prop">
+                            <td valign="top" class="name">
+                            </td>
+                            <td valign="top" class="value">
+<!--                                 <g:checkBox name="searchName" value="${true}" ></g:checkBox> -->
+                                <label for="searchName">Name,</label>
+<!--                                 <g:checkBox name="searchDescription" value="${true}" ></g:checkBox> -->
+                                <label for="searchDescription">Description,</label>
+<!--                                 <g:checkBox name="searchComment" value="${true}" ></g:checkBox> -->
+                                <label for="searchComment">Comment,</label>
+<!--                                 <g:checkBox name="searchLocation" value="${true}" ></g:checkBox> -->
+                                <label for="searchLocation">Location and</label>
+<!--                                 <g:checkBox name="searchSpareFor" value="${true}" ></g:checkBox> -->
+                                <label for="searchSpareFor">Spare For.</label>
+                            </td>
+                        </tr>
+
+                    </tbody>
+                </table>
+
                 <div class="buttons">
                     <span class="button">
-                        <input type="button" value="${g.message(code:'fp.tag.filterPane.button.cancel.text', default:'Cancel')}" onclick="return hideElement('searchPane');" />
+                        <g:actionSubmit class="save" value="Update" action="search" />
+                        <g:actionSubmit class="cancel" value="${g.message(code:'fp.tag.filterPane.button.cancel.text', default:'Cancel')}" onclick="return hideElement('searchPane');" />
                     </span>
                 </div>
Index: trunk/grails-app/views/taskDetailed/_quickSearchPane.gsp
===================================================================
--- trunk/grails-app/views/taskDetailed/_quickSearchPane.gsp	(revision 561)
+++ trunk/grails-app/views/taskDetailed/_quickSearchPane.gsp	(revision 562)
@@ -110,5 +110,5 @@
         <div class="buttons">
             <span class="button">
-                <input type="button" value="${g.message(code:'fp.tag.filterPane.button.cancel.text', default:'Cancel')}" onclick="return hideElement('searchPane');" />
+                <g:actionSubmit class="cancel" value="${g.message(code:'fp.tag.filterPane.button.cancel.text', default:'Cancel')}" onclick="return hideElement('searchPane');" />
             </span>
         </div>
