diff --git a/README.md b/README.md index e506df9..53ef71a 100755 --- a/README.md +++ b/README.md @@ -10,8 +10,18 @@ The built-in SilverStripe search form is a very simple search engine. This plugi # Usage * Create a `SearchPage` instance (typically at the root of your website). This page only is used to display results, so please refrain from creating multiple instances. -* Configure your website's `_config/config.yml` to define search parameters. -* Run `dev/build` to instansiate your new configuration +* Configure your website's `_config/config.yml` (or add `_config/search.yml`) to define search parameters. +* Run `dev/build` to instansiate your new configuration (this will also automatically create an instance of `SearchPage` if one does not exist). +* To overwrite the default `SearchPage` tmeplate, add a template file to your application: `templates/PlasticStudio/Search/Layout/SearchPage.ss` + + +# Elemental + +* Elemental search is included +* On page or Element save, all content from all Elements is saved to a field called `ElementalSearchContent` on sitetree. +* Simply include `'SiteTree_Live.ElementalSearchContent'` to the list of page columns +* Currently there is no way to exclude individual elements from being included. +* Run IndexPageContentForSearchTask to index element content # Configuration @@ -23,17 +33,20 @@ The built-in SilverStripe search form is a very simple search engine. This plugi * `Filters`: a list of filters to apply pre-search (maps to `DataList->Filter(key => value)`) * `Columns`: columns to search for query string matches (format `Table.Column`) * `filters`: associative list of filter options - * `Structure`: defines the filter's relational structure (must be one of `db`, `has_one` or `has_many`) + * `Structure`: defines the filter's relational structure (must be one of `db`, `has_one` or `many_many`) * `Label`: front-end field label * `Table`: relational subject's table * `Column`: column to filter on - * `Operator`: SQL filter operator (ie `>`, `=`) + * `Operator`: SQL filter operator (ie `>`, `<`, `=`) * `JoinTables`: associative list of relationship mappings (use the `key` from the `types` array) * `Table`: relational join table * `Column`: column to join by - * `sorts`: associative list of sort options + * `sorts`: associative list of sort options. These are used to popoulate a "Sort by" dropdown field in the Advanced Search Form. Sort order of search results will default to the top item in this list. * `Label`: front-end field label * `Sort`: SQL sort string +* `submit_button_text`: Text to use on search form submit button (defaults to "Search") + +TODO: `defaults`: Default attributes or settings, as opposed to those submitted through the search form. # Example configuration @@ -44,7 +57,7 @@ Name: search Before: - '#site' --- -Jaedb\Search\SearchPageController: +PlasticStudio\Search\SearchPageController: types: docs: Label: 'Documents' @@ -53,6 +66,7 @@ Jaedb\Search\SearchPageController: ClassNameShort: 'File' Filters: File_Live.ShowInSearch: '1' + File_Live.ClassName: '''Silverstripe\\Assets\\File''' # You need to TRIPLE-ESCAPE in order to pass this as a string to the query Columns: ['File_Live.Title','File_Live.Description','File_Live.Name'] pages: Label: 'Pages' @@ -62,7 +76,7 @@ Jaedb\Search\SearchPageController: Filters: SiteTree_Live.ShowInSearch: '1' JoinTables: ['SiteTree_Live'] - Columns: ['SiteTree_Live.Title','SiteTree_Live.MenuTitle','SiteTree_Live.Content'] + Columns: ['SiteTree_Live.Title','SiteTree_Live.MenuTitle','SiteTree_Live.Content', 'SiteTree_Live.ElementalSearchContent'] filters: updated_before: Structure: 'db' @@ -86,6 +100,15 @@ Jaedb\Search\SearchPageController: pages: Table: 'Page_Tags' Column: 'PageID' + authors: + Structure: 'many_many' + Label: 'Authors' + ClassName: 'Member' + Table: 'Member' + JoinTables: + pages: + Table: 'Page_Authors' + Column: 'PageID' sorts: title_asc: Label: 'Title (A-Z)' @@ -99,4 +122,8 @@ Jaedb\Search\SearchPageController: published_desc: Label: 'Publish date (oldest first)' Sort: 'DatePublished ASC' + submit_button_text: 'Go' + ## TODO: + ## defaults: + ## sort: 'Title ASC' ``` diff --git a/_config/config.yml b/_config/config.yml index e3ed9d8..641ad16 100755 --- a/_config/config.yml +++ b/_config/config.yml @@ -1,12 +1,15 @@ --- -Name: search +Name: ps-search Before: - '#site' --- SilverStripe\Control\Controller: extensions: - - Jaedb\Search\SearchControllerExtension -Jaedb\Search\SearchPageController: + - PlasticStudio\Search\SearchControllerExtension +SilverStripe\CMS\Model\SiteTree: + extensions: + - PlasticStudio\Search\SiteTreeSearchExtension +# PlasticStudio\Search\SearchPageController: # types: # docs: # Label: 'Documents' diff --git a/_config/elemental.yml b/_config/elemental.yml new file mode 100644 index 0000000..d44fa5d --- /dev/null +++ b/_config/elemental.yml @@ -0,0 +1,9 @@ +--- +Name: 'ps-search-elemental' +After: '#ps-search' +Only: + moduleexists: 'dnadesign/silverstripe-elemental' +--- +DNADesign\Elemental\Models\BaseElement: + extensions: + - PlasticStudio\Search\ElementalSearchExtension \ No newline at end of file diff --git a/composer.json b/composer.json index 4cf5edc..d7904db 100755 --- a/composer.json +++ b/composer.json @@ -1,19 +1,24 @@ { - "name": "jaedb/search", + "name": "plasticstudio/search", "type": "silverstripe-vendormodule", - "description": "SilverStripe search engine", - "homepage": "http://jamesbarnsley.co.nz", - "keywords": ["silverstripe"], + "description": "Search engine for Silverstripe websites - forked from jaedb/search", + "homepage": "https://psdigital.co.nz", + "keywords": ["silverstripe","silverstripesearch"], "license": "BSD-3-Clause", "authors": [ { "name": "James Barnsley", - "homepage": "http://jamesbarnsley.co.nz", + "homepage": "https://jamesbarnsley.co.nz", "email": "james@barnsley.co.nz" + }, + { + "name": "Jeremy Cole", + "homepage": "https://psdigital.co.nz", + "email": "jeremy@psdigital.co.nz" } ], "support": { - "issues": "http://github.com/jaedb/search/issues" + "issues": "http://github.com/plasticstudio/search/issues" }, "extra": { "expose": [ diff --git a/package.json b/package.json index a3647d9..231671f 100755 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "version": "1.1.0", "author": "James Barnsley", "description": "SilverStripe Search Engine", - "repository": "https://github.com/jaedb/search", + "repository": "https://github.com/plasticstudio/search", "licenses": { "type": "Apache License", "url": "http://www.apache.org/licenses/LICENSE-2.0.txt" diff --git a/src/ElementalSearchExtension.php b/src/ElementalSearchExtension.php new file mode 100644 index 0000000..9a78457 --- /dev/null +++ b/src/ElementalSearchExtension.php @@ -0,0 +1,46 @@ +updateSearchContent(); + } + + /** + * Force a re-index of the parent page on archive of element + * @param Versioned $original + */ + public function onAfterDelete(&$original) + { + $this->updateSearchContent(); + } + /** + * Force a re-index of the parent page on un-publish of element + */ + public function onAfterUnpublish() + { + $this->updateSearchContent(); + } + + public function updateSearchContent() + { + $parent = $this->getOwner()->getPage(); + //Even though we have the parent page. Lets always get the "live" version. This is so when we update the search content we are not indexing draft/unpublished content + $liveParentPage = Versioned::get_by_stage($parent->ClassName, Versioned::LIVE)->byID($parent->ID); + if ($liveParentPage && $liveParentPage->hasExtension(SiteTreeSearchExtension::class)) { + $liveParentPage->updateSearchContent(); + } + } +} \ No newline at end of file diff --git a/src/IndexPageContentForSearchTask.php b/src/IndexPageContentForSearchTask.php new file mode 100644 index 0000000..fde4af4 --- /dev/null +++ b/src/IndexPageContentForSearchTask.php @@ -0,0 +1,132 @@ +getVar('reindex'); + $offset = $request->getVar('offset') ? $request->getVar('offset') : NULL; + $limit = $request->getVar('limit') ? $request->getVar('limit') : 10; + + // select all sitetree items + $items = SiteTree::get()->limit($limit, $offset); + echo 'Running...
'; + echo 'limit: ' . $limit . '
'; + echo 'offset: ' . $offset . '
'; + // echo 'count ' . $items->Count(). '
'; + + if(!$reindex) { + $items = $items->filter(['ElementalSearchContent' => null]); + echo 'Running - generating first index...
'; + } + + if(!$items->count()) { + echo 'No items to update.
'; + } else { + + foreach ($items as $item) { + + // get the page content as plain content string + $content = $this->collateSearchContent($item); + + // Update this item in db + $update = SQLUpdate::create(); + $update->setTable('"SiteTree"'); + $update->addWhere(['ID' => $item->ID]); + $update->addAssignments([ + '"ElementalSearchContent"' => $content + ]); + $update->execute(); + + // IF page is published, update the live table + if ($item->isPublished()) { + $update = SQLUpdate::create(); + $update->setTable('"SiteTree_Live"'); + $update->addWhere(['ID' => $item->ID]); + $update->addAssignments([ + '"ElementalSearchContent"' => $content + ]); + $update->execute(); + } + + echo '

Page ' . $item->Title . ' indexed.

' . PHP_EOL; + } + } + } + + /** + * Generate the search content to use for the searchable object + * + * We just retrieve it from the templates. + */ + private function collateSearchContent($page): string + { + // Get the page + /** @var SiteTree $page */ + // $page = $this->getOwner(); + + $content = ''; + + if (self::isElementalPage($page)) { + // Get the page's elemental content + $content .= $this->collateSearchContentFromElements($page); + } + + return $content; + } + + + /** + * @param SiteTree $page + * @return bool + */ + private static function isElementalPage($page) + { + return $page::has_extension("DNADesign\Elemental\Extensions\ElementalPageExtension"); + } + + /** + * @return string|string[]|null + */ + private function collateSearchContentFromElements($page) + { + // Get the original theme + $originalThemes = SSViewer::get_themes(); + + // Init content + $content = ''; + + try { + // Enable frontend themes in order to correctly render the elements as they would be for the frontend + Config::nest(); + SSViewer::set_themes(SSViewer::config()->get('themes')); + + // Get the elements content + $content .= $page->getOwner()->getElementsForSearch(); + + // Clean up the content + $content = preg_replace('/\s+/', ' ', $content); + + // Return themes back for the CMS + Config::unnest(); + } finally { + // Restore themes + SSViewer::set_themes($originalThemes); + } + + return $content; + } + +} \ No newline at end of file diff --git a/src/SearchControllerExtension.php b/src/SearchControllerExtension.php index 31c5f3f..a24be45 100755 --- a/src/SearchControllerExtension.php +++ b/src/SearchControllerExtension.php @@ -1,6 +1,6 @@ push( TextField::create('query','',SearchPageController::get_query())->addExtraClass('query')->setAttribute('placeholder', 'Keywords') ); + $placeholder_text = 'Keywords'; + if (Config::inst()->get('PlasticStudio\Search\SearchPageController', 'search_form_placeholder_text')) { + $placeholder_text = Config::inst()->get('PlasticStudio\Search\SearchPageController', 'search_form_placeholder_text'); + } + $fields->push( TextField::create('query','',SearchPageController::get_query())->addExtraClass('query')->setAttribute('placeholder', $placeholder_text) ); // create the form actions (we only need a submit button) + $submit_button_text = 'Search'; + if (Config::inst()->get('PlasticStudio\Search\SearchPageController', 'submit_button_text')) { + $submit_button_text = Config::inst()->get('PlasticStudio\Search\SearchPageController', 'submit_button_text'); + } + // don't do action here, set below for 404 error page fix + // fix breaks pagination, reinstating $actions = FieldList::create( - FormAction::create("doSearchForm")->setTitle("Search") + FormAction::create("doSearchForm")->setTitle($submit_button_text) ); // now build the actual form object @@ -46,7 +57,11 @@ public function SearchForm(){ $name = 'SearchForm', $fields = $fields, $actions = $actions - )->addExtraClass('search-form'); + )->addExtraClass('search-form') + ->disableSecurityToken(); + + // $page = SearchPage::get()->first(); + // $form->setFormAction($page->Link()); return $form; } @@ -147,7 +162,12 @@ public function AdvancedSearchForm(){ $source = $source->filter($filter['Filters']); } - $fields->push(ListboxField::create($key, $filter['Label'], $source->map('ID','Title','All'), explode(',',$value))->addExtraClass('chosen-select')); + if ($value == null) { + $default = ''; + } else { + $default = explode(',', $value); + } + $fields->push(CheckboxSetField::create($key, $filter['Label'], $source->map('ID','Title','All'), $default)->addExtraClass('chosen-select')); break; } @@ -169,8 +189,12 @@ public function AdvancedSearchForm(){ } // create the form actions (we only need a submit button) + $submit_button_text = 'Search'; + if (Config::inst()->get('PlasticStudio\Search\SearchPageController', 'submit_button_text')) { + $submit_button_text = Config::inst()->get('PlasticStudio\Search\SearchPageController', 'submit_button_text'); + } $actions = FieldList::create( - FormAction::create("doSearchForm")->setTitle("Search") + FormAction::create("doSearchForm")->setTitle($submit_button_text) ); // now build the actual form object @@ -179,7 +203,8 @@ public function AdvancedSearchForm(){ $name = 'AdvancedSearchForm', $fields = $fields, $actions = $actions - )->addExtraClass('search-form advanced-search-form'); + )->addExtraClass('search-form advanced-search-form') + ->disableSecurityToken(); return $form; } diff --git a/src/SearchPage.php b/src/SearchPage.php index 027ecfe..e402e10 100755 --- a/src/SearchPage.php +++ b/src/SearchPage.php @@ -1,24 +1,36 @@ 0, + 'ShowInSearch' => 0 + ]; + /** * We need to have a SearchPage to use it */ - public function requireDefaultRecords() { + public function requireDefaultRecords() + { parent::requireDefaultRecords(); if (static::class == self::class && $this->config()->create_default_pages) { - if (!SearchPage::get()){ + if (count(SearchPage::get()) < 1) { $page = SearchPage::create(); $page->Title = 'Search'; $page->Content = ''; + $page->ShowInMenus = false; + $page->ShowInSearch = false; $page->write(); + $page->copyVersionToStage(Versioned::DRAFT, Versioned::LIVE); $page->flushCache(); DB::alteration_message('Search page created', 'created'); } diff --git a/src/SearchPageController.php b/src/SearchPageController.php index 71afb00..c7491c4 100755 --- a/src/SearchPageController.php +++ b/src/SearchPageController.php @@ -1,6 +1,6 @@ get('Jaedb\Search\SearchPageController', 'types'); + $types = Config::inst()->get('PlasticStudio\Search\SearchPageController', 'types'); $array = []; if ($types){ @@ -73,7 +74,7 @@ public static function get_types_available(){ } public static function get_filters_available(){ - $filters = Config::inst()->get('Jaedb\Search\SearchPageController', 'filters'); + $filters = Config::inst()->get('PlasticStudio\Search\SearchPageController', 'filters'); $array = []; if ($filters){ @@ -87,7 +88,7 @@ public static function get_filters_available(){ } public static function get_sorts_available(){ - $sorts = Config::inst()->get('Jaedb\Search\SearchPageController', 'sorts'); + $sorts = Config::inst()->get('PlasticStudio\Search\SearchPageController', 'sorts'); $array = []; if ($sorts){ @@ -99,6 +100,19 @@ public static function get_sorts_available(){ return $array; } + + public static function get_defaults_available(){ + $defaults = Config::inst()->get('PlasticStudio\Search\SearchPageController', 'defaults'); + $array = []; + + if ($defaults){ + foreach ($defaults as $key => $value){ + $array[$key] = $value; + } + } + + return $array; + } public static function get_types(){ return self::$types; @@ -146,11 +160,13 @@ public static function get_mapped_filters(){ } public static function get_query($mysqlSafe = false){ - $query = self::$query; - if( $mysqlSafe ){ - $query = str_replace("'", "\'", $query); - $query = str_replace('"', '\"', $query); - $query = str_replace('`', '\`', $query); + $query = self::$query ?? ''; + if ($query) { + if ($mysqlSafe) { + $query = str_replace("'", "\'", $query); + $query = str_replace('"', '\"', $query); + $query = str_replace('`', '\`', $query); + } } return $query; } @@ -163,6 +179,10 @@ public static function get_sort(){ return self::$sort; } + public static function set_sort($sort){ + self::$sort = $sort; + } + public static function get_mapped_sort(){ $sorts_available = self::get_sorts_available(); $sort = self::get_sort(); @@ -175,8 +195,16 @@ public static function get_mapped_sort(){ } } - public static function set_sort($sort){ - self::$sort = $sort; + public static function get_defaults(){ + return self::$defaults; + } + + public static function set_defaults($defaults){ + self::$defaults = $defaults; + } + + public static function get_mapped_defaults(){ + return self::get_defaults_available(); } public static function get_results(){ @@ -327,7 +355,7 @@ public function PerformSearch(){ $tables_to_check[] = $type['Table']; foreach ($tables_to_check as $table_to_check){ - $column_exists_query = DB::query( "SHOW COLUMNS FROM \"".$table_to_check."\" LIKE '".$filter['Column']."'" ); + $column_exists_query = DB::query( "SHOW COLUMNS FROM \"".$table_to_check."\" LIKE '".$filter['Column']."'" ); foreach ($column_exists_query as $column){ $table_with_column = $table_to_check; @@ -401,18 +429,20 @@ public function PerformSearch(){ // join the relationship table to our record(s) $joins.= "LEFT JOIN \"".$filter['Table']."\" ON \"".$filter['Table']."\".\"ID\" = \"".$table_with_column."\".\"".$filter['Column']."\""; - if (is_array($filter['Value'])){ - $ids = ''; - foreach ($filter['Value'] as $id){ - if ($ids != ''){ - $ids.= ','; + if(!empty($filter['Value'])){ + if (is_array($filter['Value'])){ + $ids = ''; + foreach ($filter['Value'] as $id){ + if ($ids != ''){ + $ids.= ','; + } + $ids.= "'".$id."'"; } - $ids.= "'".$id."'"; + } else { + $ids = $filter['Value']; } - } else { - $ids = $filter['Value']; + $where.= ' AND ('."\"".$table_with_column."\".\"".$filter['Column']."\" IN (". $ids .")".')'; } - $where.= ' AND ('."\"".$table_with_column."\".\"".$filter['Column']."\" IN (". $ids .")".')'; break; @@ -426,21 +456,26 @@ public function PerformSearch(){ $filter_join = $filter['JoinTables'][$type['Key']]; - $joins.= "LEFT JOIN \"".$filter_join['Table']."\" ON \"".$type['Table']."\".\"ID\" = \"".$filter_join['Column']."\""; - - if (is_array($filter['Value'])){ - $ids = ''; - foreach ($filter['Value'] as $id){ - if ($ids != ''){ - $ids.= ','; + $joins.= "LEFT JOIN \"".$filter_join['Table']."\" ON \"".$type['Table']."\".\"ID\" = \"".$filter_join['Table']."\".\"".$filter_join['Column']."\""; + + if(!empty($filter['Value'])){ + if (is_array($filter['Value'])){ + $ids = ''; + foreach ($filter['Value'] as $id){ + if ($ids != ''){ + $ids.= ','; + } + $ids.= "'".$id."'"; } - $ids.= "'".$id."'"; + } else { + $ids = $filter['Value']; } - } else { - $ids = $filter['Value']; - } - $relations_sql.= "\"".$filter_join['Table']."\".\"".$filter['Table']."ID\" IN (". $ids .")"; + if ($relations_sql !== ''){ + $relations_sql.= " AND "; + } + $relations_sql.= "\"".$filter_join['Table']."\".\"".$filter['Table']."ID\" IN (". $ids .")"; + } } break; @@ -459,7 +494,7 @@ public function PerformSearch(){ $sql.= $where; // Debugging - //echo '

'.$sql.'

'; + //echo '

'.str_replace('"', '`', $sql).'

'; // Eexecutioner enter stage left $results = DB::query($sql); @@ -480,16 +515,30 @@ public function PerformSearch(){ $allResults->merge($resultObjects); } } - - // Apply sorting - $sort = self::get_mapped_sort()['Sort']; - $sort = str_replace("'", "\'", $sort); - $sort = str_replace('"', '\"', $sort); - $sort = str_replace('`', '\`', $sort); - $allResults = $allResults->Sort($sort); + + $sort = false; + // Sorting applied throug form submission + if(isset(self::get_mapped_sort()['Sort'])){ + $sort = self::get_mapped_sort()['Sort']; + $sort = str_replace("'", "\'", $sort); + $sort = str_replace('"', '\"', $sort); + $sort = str_replace('`', '\`', $sort); + } + // Default sort defined in config + elseif(isset(self::get_mapped_defaults()['sort'])){ + $sort = self::get_mapped_defaults()['sort']; + } + if($sort){ + $allResults = $allResults->Sort($sort); + } // Remove duplicates - //$allResults->removeDuplicates('ID'); + $allResults->removeDuplicates('ID'); + + // filter by permission + if($allResults) foreach($allResults as $result) { + if(!$result->canView()) $allResults->remove($result); + } // load into a paginated list. To change the items per page, set via the template (ie Results.setPageLength(20)) $paginatedItems = PaginatedList::create($allResults, $this->request); diff --git a/src/SiteTreeSearchExtension.php b/src/SiteTreeSearchExtension.php new file mode 100644 index 0000000..73411c4 --- /dev/null +++ b/src/SiteTreeSearchExtension.php @@ -0,0 +1,117 @@ + 'Text', + ]; + + public function updateCMSFields(FieldList $fields) + { + // $fields->addFieldToTab('Root.test', TextField::create('ElementalSearchContent', 'ElementalSearchContent')); + } + + /** + * Trigger page writes so that we trigger the onBefore write + */ + public function updateSearchContent() + { + $content = $this->collateSearchContent(); + + $update = SQLUpdate::create(); + $update->setTable('"SiteTree"'); + $update->addWhere(['ID' => $this->owner->ID]); + $update->addAssignments([ + '"ElementalSearchContent"' => $content + ]); + $update->execute(); + + if ($this->owner->isInDB() && $this->owner->isPublished()) { + $update = SQLUpdate::create(); + $update->setTable('"SiteTree_Live"'); + $update->addWhere(['ID' => $this->owner->ID]); + $update->addAssignments([ + '"ElementalSearchContent"' => $content + ]); + $update->execute(); + } + } + + /** + * Generate the search content to use for the searchable object + * + * We just retrieve it from the templates. + */ + private function collateSearchContent(): string + { + // Get the page + /** @var SiteTree $page */ + $page = $this->getOwner(); + + $content = ''; + + if (self::isElementalPage($page)) { + // Get the page's elemental content + $content .= $this->collateSearchContentFromElements(); + } + + return $content; + } + + + /** + * @param SiteTree $page + * @return bool + */ + private static function isElementalPage($page) + { + return $page::has_extension("DNADesign\Elemental\Extensions\ElementalPageExtension"); + } + + /** + * @return string|string[]|null + */ + private function collateSearchContentFromElements() + { + // Get the original theme + $originalThemes = SSViewer::get_themes(); + + // Init content + $content = ''; + + try { + // Enable frontend themes in order to correctly render the elements as they would be for the frontend + Config::nest(); + SSViewer::set_themes(SSViewer::config()->get('themes')); + + // Get the elements content + $content .= $this->getOwner()->getElementsForSearch(); + + // Clean up the content + $content = preg_replace('/\s+/', ' ', $content); + + // Return themes back for the CMS + Config::unnest(); + } finally { + // Restore themes + SSViewer::set_themes($originalThemes); + } + + return $content; + } +} diff --git a/templates/Jaedb/Search/Layout/SearchPage.ss b/templates/PlasticStudio/Search/Layout/SearchPage.ss old mode 100755 new mode 100644 similarity index 99% rename from templates/Jaedb/Search/Layout/SearchPage.ss rename to templates/PlasticStudio/Search/Layout/SearchPage.ss index e596c04..426d767 --- a/templates/Jaedb/Search/Layout/SearchPage.ss +++ b/templates/PlasticStudio/Search/Layout/SearchPage.ss @@ -1,4 +1,3 @@ -
$AdvancedSearchForm