├── _config └── .gitignore ├── .gitattributes ├── images └── preview.png ├── .editorconfig ├── composer.json ├── README.md ├── LICENSE ├── client ├── css │ └── seo-editor.css └── javascript │ └── seo-editor.js ├── src ├── Forms │ ├── SEOEditorCSVLoader.php │ └── GridField │ │ ├── SEOEditorMetaTitleColumn.php │ │ └── SEOEditorMetaDescriptionColumn.php └── ModelAdmin │ └── SEOEditorAdmin.php └── .scrutinizer.yml /_config/.gitignore: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | /.gitignore export-ignore 2 | -------------------------------------------------------------------------------- /images/preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/isobar-nz/silverstripe-seo-editor/HEAD/images/preview.png -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # For more information about the properties used in this file, 2 | # please see the EditorConfig documentation: 3 | # http://editorconfig.org 4 | 5 | [*] 6 | charset = utf-8 7 | end_of_line = lf 8 | indent_size = 4 9 | indent_style = space 10 | insert_final_newline = true 11 | trim_trailing_whitespace = true 12 | 13 | [*.js] 14 | indent_size = 2 15 | indent_style = space 16 | 17 | [{*.yml,package.json}] 18 | indent_size = 2 19 | 20 | # The indent size used in the package.json file cannot be changed: 21 | # https://github.com/npm/npm/pull/3180#issuecomment-16336516 22 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "littlegiant/silverstripe-seo-editor", 3 | "type": "silverstripe-vendormodule", 4 | "description": "SEO Editor Administration for SilverStripe", 5 | "keywords": ["silverstripe", "seo", "editing", "metatitle", "metadescription"], 6 | "license": "MIT", 7 | "authors": [ 8 | { 9 | "name": "Stevie Mayhew", 10 | "email": "stevie.mayhew@littlegiant.co.nz" 11 | } 12 | ], 13 | "require": { 14 | "silverstripe/cms": "^4.0", 15 | "kinglozzer/metatitle": "^2.0" 16 | }, 17 | "extra": { 18 | "expose": [ 19 | "client/css", 20 | "client/javascript" 21 | ] 22 | }, 23 | "autoload": { 24 | "psr-4": { 25 | "LittleGiant\\SEOEditor\\": "src/" 26 | } 27 | }, 28 | "prefer-stable": true, 29 | "minimum-stability": "dev" 30 | } 31 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SilverStripe SEO Editor 2 | 3 | SEO Editor interface to allow easy editing of SEO MetaTitle and MetaDescriptions for pages within a ModelAdmin 4 | interface. 5 | 6 | Edit inline, or download a CSV export and import changes. 7 | 8 | ![SilverStripe SEO Editor](https://raw.github.com/little-giant/silverstripe-seo-editor/master/images/preview.png) 9 | 10 | ## Installation 11 | 12 | Installation via composer 13 | 14 | ```bash 15 | $ composer require littlegiant/silverstripe-seo-editor 16 | ``` 17 | 18 | ## License 19 | 20 | SilverStripe SEO is released under the MIT license 21 | 22 | ## Contributing 23 | 24 | ### Code guidelines 25 | 26 | This project follows the standards defined in: 27 | 28 | * [PSR-0](https://github.com/php-fig/fig-standards/blob/master/accepted/PSR-0.md) 29 | * [PSR-1](https://github.com/php-fig/fig-standards/blob/master/accepted/PSR-1-basic-coding-standard.md) 30 | * [PSR-2](https://github.com/php-fig/fig-standards/blob/master/accepted/PSR-2-coding-style-guide.md) 31 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Little Giant 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /client/css/seo-editor.css: -------------------------------------------------------------------------------- 1 | .cms table.grid-field__table tbody td.col-Title { 2 | max-width: 8rem; 3 | } 4 | 5 | /** 6 | * @desc Remove the hover background color from the grid-field items, as they're distracting. 7 | */ 8 | .cms tbody tr:hover, 9 | .cms .table tbody tr.even:hover, 10 | .cms .table tbody tr:hover { 11 | background-color: transparent; 12 | } 13 | 14 | /** 15 | * @desc Add a border, and box-shadow to the inputs in an error state. 16 | */ 17 | .cms .seo-editor-error input, 18 | .cms .seo-editor-error input:hover, 19 | .cms .seo-editor-error input:focus, 20 | .cms .seo-editor-error textarea, 21 | .cms .seo-editor-error textarea:hover, 22 | .cms .seo-editor-error textarea:focus { 23 | border-color: #dc3545; 24 | box-shadow: 0 0 0 0.3em rgba(220, 53, 69, .5); 25 | } 26 | 27 | .seo-editor-errors { 28 | font-size: smaller; 29 | margin-top: 1em; 30 | color: #dc3545; 31 | } 32 | 33 | /* Hide the messages until the parent has the appropriate class */ 34 | .seo-editor-message { 35 | display: none; 36 | } 37 | 38 | .seo-editor-error-too-short .seo-editor-message-too-short { 39 | display: block; 40 | } 41 | 42 | .seo-editor-error-too-long .seo-editor-message-too-long { 43 | display: block; 44 | } 45 | 46 | .seo-editor-error-duplicate .seo-editor-message-duplicate { 47 | display: block; 48 | } 49 | 50 | -------------------------------------------------------------------------------- /src/Forms/SEOEditorCSVLoader.php: -------------------------------------------------------------------------------- 1 | 'ID', 20 | ]; 21 | 22 | /** 23 | * Update the columns needed when importing from CSV 24 | * 25 | * @param array $record 26 | * @param array $columnMap 27 | * @param BulkLoader_Result $results 28 | * @param bool $preview 29 | * @return bool|int 30 | */ 31 | public function processRecord($record, $columnMap, &$results, $preview = false) 32 | { 33 | $page = $this->findExistingObject($record, $columnMap); 34 | 35 | if (!$page || !$page->exists()) { 36 | return false; 37 | } 38 | 39 | foreach ($record as $fieldName => $val) { 40 | if ($fieldName == 'MetaTitle' || $fieldName == 'MetaDescription') { 41 | $sqlValue = Convert::raw2sql($val); 42 | DB::query("UPDATE SiteTree SET {$fieldName} = '{$sqlValue}' WHERE ID = {$page->ID}"); 43 | if ($page->isPublished()) { 44 | DB::query("UPDATE SiteTree_Live SET {$fieldName} = '{$sqlValue}' WHERE ID = {$page->ID}"); 45 | } 46 | } 47 | } 48 | 49 | return $page->ID; 50 | } 51 | 52 | } 53 | -------------------------------------------------------------------------------- /client/javascript/seo-editor.js: -------------------------------------------------------------------------------- 1 | (function ($) { 2 | $.entwine('ss', function ($) { 3 | $('.ss-seo-editor .ss-gridfield-item input, .ss-seo-editor .ss-gridfield-item textarea').entwine({ 4 | 5 | onchange: function () { 6 | // kill the popup for form changes 7 | $('.cms-edit-form').removeClass('changed'); 8 | 9 | var $this = $(this); 10 | var id = $this.closest('tr').attr('data-id'); 11 | var url = $this.closest('.ss-gridfield').attr('data-url') + "/update" + $this.attr('data-name') + "/" + id; 12 | var data = encodeURIComponent($this.attr('name')) + '=' + encodeURIComponent($(this).val()); 13 | 14 | $.noticeAdd({ 15 | text: 'Saving changes', 16 | type: 'notice', 17 | stayTime: 5000, 18 | inEffect: {left: '0', opacity: 'show'}, 19 | }); 20 | 21 | $.post( 22 | url, 23 | data, 24 | function (data, textStatus) { 25 | $.noticeAdd({ 26 | text: data.message, 27 | type: data.type, 28 | stayTime: 5000, 29 | inEffect: {left: '0', opacity: 'show'}, 30 | }); 31 | 32 | $this.closest('td').removeClass(); 33 | if (data.errors.length) { 34 | $this.closest('td').addClass('seo-editor-error'); 35 | data.errors.forEach(function (error) { 36 | $this.closest('td').addClass(error) 37 | }); 38 | } else { 39 | $this.closest('td').addClass('seo-editor-valid'); 40 | } 41 | }, 42 | 'json' 43 | ); 44 | } 45 | }); 46 | }); 47 | }(jQuery)); 48 | -------------------------------------------------------------------------------- /.scrutinizer.yml: -------------------------------------------------------------------------------- 1 | inherit: true 2 | 3 | checks: 4 | php: 5 | verify_property_names: true 6 | verify_argument_usable_as_reference: true 7 | verify_access_scope_valid: true 8 | useless_calls: true 9 | use_statement_alias_conflict: true 10 | variable_existence: true 11 | unused_variables: true 12 | unused_properties: true 13 | unused_parameters: true 14 | unused_methods: true 15 | unreachable_code: true 16 | too_many_arguments: true 17 | sql_injection_vulnerabilities: true 18 | simplify_boolean_return: true 19 | side_effects_or_types: true 20 | security_vulnerabilities: true 21 | return_doc_comments: true 22 | return_doc_comment_if_not_inferrable: true 23 | require_scope_for_properties: true 24 | require_scope_for_methods: true 25 | require_php_tag_first: true 26 | psr2_switch_declaration: true 27 | psr2_class_declaration: true 28 | property_assignments: true 29 | prefer_while_loop_over_for_loop: true 30 | precedence_mistakes: true 31 | precedence_in_conditions: true 32 | phpunit_assertions: true 33 | php5_style_constructor: true 34 | parse_doc_comments: true 35 | parameter_non_unique: true 36 | parameter_doc_comments: true 37 | param_doc_comment_if_not_inferrable: true 38 | optional_parameters_at_the_end: true 39 | one_class_per_file: true 40 | no_unnecessary_if: true 41 | no_trailing_whitespace: true 42 | no_property_on_interface: true 43 | no_non_implemented_abstract_methods: true 44 | no_error_suppression: true 45 | no_duplicate_arguments: true 46 | no_commented_out_code: true 47 | newline_at_end_of_file: true 48 | missing_arguments: true 49 | method_calls_on_non_object: true 50 | instanceof_class_exists: true 51 | foreach_traversable: true 52 | fix_line_ending: true 53 | fix_doc_comments: true 54 | duplication: true 55 | deprecated_code_usage: true 56 | deadlock_detection_in_loops: true 57 | code_rating: true 58 | closure_use_not_conflicting: true 59 | catch_class_exists: true 60 | blank_line_after_namespace_declaration: false 61 | avoid_multiple_statements_on_same_line: true 62 | avoid_duplicate_types: true 63 | avoid_conflicting_incrementers: true 64 | avoid_closing_tag: true 65 | assignment_of_null_return: true 66 | argument_type_checks: true 67 | 68 | filter: 69 | paths: [code/*, tests/*] 70 | -------------------------------------------------------------------------------- /src/Forms/GridField/SEOEditorMetaTitleColumn.php: -------------------------------------------------------------------------------- 1 | getDisplayFields()} 31 | * @see {@link GridFieldDataColumns}. 32 | * 33 | * @param GridField $gridField 34 | * @param array - List reference of all column names. 35 | */ 36 | public function augmentColumns($gridField, &$columns) 37 | { 38 | $columns[] = 'MetaTitle'; 39 | } 40 | 41 | /** 42 | * Names of all columns which are affected by this component. 43 | * 44 | * @param GridField $gridField 45 | * @return array 46 | */ 47 | public function getColumnsHandled($gridField) 48 | { 49 | return [ 50 | 'MetaTitle', 51 | ]; 52 | } 53 | 54 | /** 55 | * Attributes for the element containing the content returned by {@link getColumnContent()}. 56 | * 57 | * @param GridField $gridField 58 | * @param DataObject $record displayed in this row 59 | * @param string $columnName 60 | * @return array 61 | */ 62 | public function getColumnAttributes($gridField, $record, $columnName) 63 | { 64 | $errors = $this->getErrors($record); 65 | 66 | return [ 67 | 'class' => count($errors) 68 | ? 'seo-editor-error ' . implode(' ', $errors) 69 | : 'seo-editor-valid', 70 | ]; 71 | } 72 | 73 | /** 74 | * HTML for the column, content of the element. 75 | * 76 | * @param GridField $gridField 77 | * @param DataObject $record - Record displayed in this row 78 | * @param string $columnName 79 | * @return string - HTML for the column. Return NULL to skip. 80 | */ 81 | public function getColumnContent($gridField, $record, $columnName) 82 | { 83 | $field = new TextField('MetaTitle'); 84 | $value = $gridField->getDataFieldValue($record, $columnName); 85 | $value = $this->formatValue($gridField, $record, $columnName, $value); 86 | $field->setName($this->getFieldName($field->getName(), $gridField, $record)); 87 | $field->setValue($value); 88 | $field->setAttribute('data-name', 'MetaTitle'); 89 | 90 | return $field->Field() . $this->getErrorMessages(); 91 | } 92 | 93 | /** 94 | * Additional metadata about the column which can be used by other components, 95 | * e.g. to set a title for a search column header. 96 | * 97 | * @param GridField $gridField 98 | * @param string $column 99 | * @return array - Map of arbitrary metadata identifiers to their values. 100 | */ 101 | public function getColumnMetadata($gridField, $column) 102 | { 103 | return [ 104 | 'title' => 'MetaTitle', 105 | ]; 106 | } 107 | 108 | /** 109 | * Get the errors which are specific to MetaTitle 110 | * 111 | * @param DataObject $record 112 | * @return array 113 | */ 114 | public function getErrors(DataObject $record) 115 | { 116 | $errors = []; 117 | 118 | if (strlen($record->MetaTitle) < 10) { 119 | $errors[] = 'seo-editor-error-too-short'; 120 | } 121 | if (strlen($record->MetaTitle) > 55) { 122 | $errors[] = 'seo-editor-error-too-long'; 123 | } 124 | if (strlen(SiteTree::get()->filter('MetaTitle', $record->MetaTitle)->count() > 1)) { 125 | $errors[] = 'seo-editor-error-duplicate'; 126 | } 127 | 128 | return $errors; 129 | } 130 | 131 | /** 132 | * Return all the error messages 133 | * 134 | * @return string 135 | */ 136 | public function getErrorMessages() 137 | { 138 | return '
' . 139 | 'This title is too short. It should be greater than 10 characters long.' . 140 | 'This title is too long. It should be less than 55 characters long.' . 141 | 'This title is a duplicate. It should be unique.' . 142 | '
'; 143 | } 144 | 145 | /** 146 | * Add a class to the gridfield 147 | * 148 | * @param $gridField 149 | * @return array|void 150 | */ 151 | public function getHTMLFragments($gridField) 152 | { 153 | $gridField->addExtraClass('ss-seo-editor'); 154 | } 155 | 156 | /** 157 | * @param $name 158 | * @param GridField $gridField 159 | * @param DataObjectInterface $record 160 | * @return string 161 | */ 162 | protected function getFieldName($name, GridField $gridField, DataObjectInterface $record) 163 | { 164 | return sprintf( 165 | '%s[%s][%s]', $gridField->getName(), $record->ID, $name 166 | ); 167 | } 168 | 169 | /** 170 | * Return URLs to be handled by this grid field, in an array the same form as $url_handlers. 171 | * Handler methods will be called on the component, rather than the grid field. 172 | * 173 | * @param $gridField 174 | * @return array 175 | */ 176 | public function getURLHandlers($gridField) 177 | { 178 | return [ 179 | 'updateMetaTitle/$ID' => 'handleAction', 180 | ]; 181 | } 182 | 183 | /** 184 | * @param $gridField 185 | * @param $request 186 | * @return string 187 | */ 188 | public function handleAction($gridField, $request) 189 | { 190 | $data = $request->postVar($gridField->getName()); 191 | 192 | foreach ($data as $id => $params) { 193 | $page = $gridField->getList()->byId((int)$id); 194 | 195 | foreach ($params as $fieldName => $val) { 196 | $sqlValue = Convert::raw2sql($val); 197 | $page->$fieldName = $sqlValue; 198 | DB::query("UPDATE SiteTree SET {$fieldName} = '{$sqlValue}' WHERE ID = {$page->ID}"); 199 | if ($page->isPublished()) { 200 | DB::query("UPDATE SiteTree_Live SET {$fieldName} = '{$sqlValue}' WHERE ID = {$page->ID}"); 201 | } 202 | 203 | return json_encode([ 204 | 'type' => 'success', 205 | 'message' => $fieldName . ' saved', 206 | 'errors' => $this->getErrors($page), 207 | ]); 208 | } 209 | } 210 | 211 | return json_encode([ 212 | 'type' => 'error', 213 | 'message' => 'An error occurred while saving', 214 | ]); 215 | } 216 | } 217 | -------------------------------------------------------------------------------- /src/Forms/GridField/SEOEditorMetaDescriptionColumn.php: -------------------------------------------------------------------------------- 1 | getDisplayFields()} 31 | * @see {@link GridFieldDataColumns}. 32 | * 33 | * @param GridField $gridField 34 | * @param array - List reference of all column names. 35 | */ 36 | public function augmentColumns($gridField, &$columns) 37 | { 38 | $columns[] = 'MetaDescription'; 39 | } 40 | 41 | /** 42 | * Names of all columns which are affected by this component. 43 | * 44 | * @param GridField $gridField 45 | * @return array 46 | */ 47 | public function getColumnsHandled($gridField) 48 | { 49 | return [ 50 | 'MetaDescription', 51 | ]; 52 | } 53 | 54 | /** 55 | * Attributes for the element containing the content returned by {@link getColumnContent()}. 56 | * 57 | * @param GridField $gridField 58 | * @param DataObject $record displayed in this row 59 | * @param string $columnName 60 | * @return array 61 | */ 62 | public function getColumnAttributes($gridField, $record, $columnName) 63 | { 64 | $errors = $this->getErrors($record); 65 | 66 | return [ 67 | 'class' => count($errors) 68 | ? 'seo-editor-error ' . implode(' ', $errors) 69 | : 'seo-editor-valid', 70 | ]; 71 | } 72 | 73 | 74 | /** 75 | * HTML for the column, content of the element. 76 | * 77 | * @param GridField $gridField 78 | * @param DataObject $record - Record displayed in this row 79 | * @param string $columnName 80 | * @return string - HTML for the column. Return NULL to skip. 81 | */ 82 | public function getColumnContent($gridField, $record, $columnName) 83 | { 84 | $field = new TextareaField('MetaDescription'); 85 | $value = $gridField->getDataFieldValue($record, $columnName); 86 | $value = $this->formatValue($gridField, $record, $columnName, $value); 87 | $field->setName($this->getFieldName($field->getName(), $gridField, $record)); 88 | $field->setValue($value); 89 | $field->setAttribute('data-name', 'MetaDescription'); 90 | 91 | return $field->Field() . $this->getErrorMessages(); 92 | } 93 | 94 | /** 95 | * Additional metadata about the column which can be used by other components, 96 | * e.g. to set a title for a search column header. 97 | * 98 | * @param GridField $gridField 99 | * @param string $column 100 | * @return array - Map of arbitrary metadata identifiers to their values. 101 | */ 102 | public function getColumnMetadata($gridField, $column) 103 | { 104 | return [ 105 | 'title' => 'MetaDescription', 106 | ]; 107 | } 108 | 109 | /** 110 | * Get the errors which are specific to MetaDescription 111 | * 112 | * @param DataObject $record 113 | * @return array 114 | */ 115 | public function getErrors(DataObject $record) 116 | { 117 | $errors = []; 118 | 119 | if (strlen($record->MetaDescription) < 10) { 120 | $errors[] = 'seo-editor-error-too-short'; 121 | } 122 | if (strlen($record->MetaDescription) > 160) { 123 | $errors[] = 'seo-editor-error-too-long'; 124 | } 125 | if (strlen(SiteTree::get()->filter('MetaDescription', $record->MetaDescription)->count() > 1)) { 126 | $errors[] = 'seo-editor-error-duplicate'; 127 | } 128 | 129 | return $errors; 130 | } 131 | 132 | /** 133 | * Return all the error messages 134 | * 135 | * @return string 136 | */ 137 | public function getErrorMessages() 138 | { 139 | return '
' . 140 | 'This meta description is too short. It should be greater than 10 characters long.' . 141 | 'This meta description is too long. It should be less than 160 characters long.' . 142 | 'This meta description is a duplicate. It should be unique.' . 143 | '
'; 144 | } 145 | 146 | /** 147 | * Add a class to the gridfield 148 | * 149 | * @param $gridField 150 | * @return array|void 151 | */ 152 | public function getHTMLFragments($gridField) 153 | { 154 | $gridField->addExtraClass('ss-seo-editor'); 155 | } 156 | 157 | /** 158 | * @param $name 159 | * @param GridField $gridField 160 | * @param DataObjectInterface $record 161 | * @return string 162 | */ 163 | protected function getFieldName($name, GridField $gridField, DataObjectInterface $record) 164 | { 165 | return sprintf( 166 | '%s[%s][%s]', $gridField->getName(), $record->ID, $name 167 | ); 168 | } 169 | 170 | 171 | /** 172 | * Return URLs to be handled by this grid field, in an array the same form as $url_handlers. 173 | * Handler methods will be called on the component, rather than the grid field. 174 | * 175 | * @param $gridField 176 | * @return array 177 | */ 178 | public function getURLHandlers($gridField) 179 | { 180 | return [ 181 | 'updateMetaDescription/$ID' => 'handleAction', 182 | ]; 183 | } 184 | 185 | /** 186 | * @param $gridField 187 | * @param $request 188 | * @return string 189 | */ 190 | public function handleAction($gridField, $request) 191 | { 192 | $data = $request->postVar($gridField->getName()); 193 | 194 | foreach ($data as $id => $params) { 195 | $page = $gridField->getList()->byId((int)$id); 196 | 197 | foreach ($params as $fieldName => $val) { 198 | $sqlValue = Convert::raw2sql($val); 199 | $page->$fieldName = $sqlValue; 200 | DB::query("UPDATE SiteTree SET {$fieldName} = '{$sqlValue}' WHERE ID = {$page->ID}"); 201 | if ($page->isPublished()) { 202 | DB::query("UPDATE SiteTree_Live SET {$fieldName} = '{$sqlValue}' WHERE ID = {$page->ID}"); 203 | } 204 | 205 | return json_encode([ 206 | 'type' => 'success', 207 | 'message' => $fieldName . ' saved', 208 | 'errors' => $this->getErrors($page), 209 | ]); 210 | } 211 | } 212 | 213 | return json_encode([ 214 | 'type' => 'error', 215 | 'message' => 'An error occurred while saving', 216 | ]); 217 | } 218 | } 219 | -------------------------------------------------------------------------------- /src/ModelAdmin/SEOEditorAdmin.php: -------------------------------------------------------------------------------- 1 | SEOEditorCSVLoader::class 59 | ]; 60 | 61 | /** 62 | * @var array 63 | */ 64 | private static $allowed_actions = [ 65 | 'ImportForm' 66 | ]; 67 | 68 | /** 69 | * @return HTTPResponse|string|void 70 | */ 71 | public function init() 72 | { 73 | parent::init(); 74 | Requirements::css('littlegiant/silverstripe-seo-editor: client/css/seo-editor.css'); 75 | Requirements::javascript('littlegiant/silverstripe-seo-editor: client/javascript/seo-editor.js'); 76 | } 77 | 78 | /** 79 | * @return SearchContext 80 | */ 81 | public function getSearchContext() 82 | { 83 | $context = parent::getSearchContext(); 84 | 85 | $fields = FieldList::create( 86 | TextField::create('Title', 'Title'), 87 | TextField::create('MetaTitle', 'MetaTitle'), 88 | TextField::create('MetaDescription', 'MetaDescription'), 89 | CheckboxField::create('DuplicatesOnly', 'Duplicates Only'), 90 | CheckboxField::create('RemoveEmptyMetaTitles', 'Remove Empty MetaTitles'), 91 | CheckboxField::create('RemoveEmptyMetaDescriptions', 'Remove Empty MetaDescriptions') 92 | ); 93 | 94 | $context->setFields($fields); 95 | $filters = [ 96 | 'Title' => new PartialMatchFilter('Title'), 97 | 'MetaTitle' => new PartialMatchFilter('MetaTitle'), 98 | 'MetaDescription' => new PartialMatchFilter('MetaDescription') 99 | ]; 100 | 101 | $context->setFilters($filters); 102 | 103 | // Namespace fields, for easier detection if a search is present 104 | foreach ($context->getFields() as $field) $field->setName(sprintf('q[%s]', $field->getName())); 105 | foreach ($context->getFilters() as $filter) $filter->setFullName(sprintf('q[%s]', $filter->getFullName())); 106 | 107 | return $context; 108 | } 109 | 110 | /** 111 | * @param null $id 112 | * @param null $fields 113 | * @return Form 114 | */ 115 | public function getEditForm($id = null, $fields = null) 116 | { 117 | $form = parent::getEditForm($id, $fields); 118 | 119 | $grid = $form->Fields()->dataFieldByName('SilverStripe-CMS-Model-SiteTree'); 120 | $gridField = $grid->getConfig(); 121 | if ($gridField) { 122 | $gridField->removeComponentsByType(GridFieldAddNewButton::class); 123 | $gridField->removeComponentsByType(GridFieldPrintButton::class); 124 | $gridField->removeComponentsByType(GridFieldEditButton::class); 125 | $gridField->removeComponentsByType(GridFieldExportButton::class); 126 | $gridField->removeComponentsByType(GridFieldDeleteAction::class); 127 | 128 | $gridField->getComponentByType(GridFieldDataColumns::class)->setDisplayFields( 129 | [ 130 | 'ID' => 'ID', 131 | 'Title' => 'Title', 132 | ] 133 | ); 134 | 135 | $gridField->addComponent( 136 | new GridFieldExportButton( 137 | 'before', 138 | [ 139 | 'ID' => 'ID', 140 | 'Title' => 'Title', 141 | 'MetaTitle' => 'MetaTitle', 142 | 'MetaDescription' => 'MetaDescription' 143 | ] 144 | ) 145 | ); 146 | 147 | $gridField->addComponent(new SEOEditorMetaTitleColumn()); 148 | $gridField->addComponent(new SEOEditorMetaDescriptionColumn()); 149 | } 150 | 151 | return $form; 152 | } 153 | 154 | /** 155 | * @return Form|bool 156 | */ 157 | public function ImportForm() 158 | { 159 | $form = parent::ImportForm(); 160 | $modelName = $this->modelClass; 161 | 162 | if ($form) { 163 | $form->Fields()->removeByName("SpecFor{$modelName}"); 164 | $form->Fields()->removeByName("EmptyBeforeImport"); 165 | } 166 | 167 | return $form; 168 | } 169 | 170 | /** 171 | * Get the list for the GridField 172 | * 173 | * @return SS_List 174 | */ 175 | public function getList() 176 | { 177 | $list = parent::getList(); 178 | $params = $this->request->requestVar('q'); 179 | 180 | if (isset($params['RemoveEmptyMetaTitles']) && $params['RemoveEmptyMetaTitles']) { 181 | $list = $this->removeEmptyAttributes($list, 'MetaTitle'); 182 | } 183 | 184 | if (isset($params['RemoveEmptyMetaDescriptions']) && $params['RemoveEmptyMetaDescriptions']) { 185 | $list = $this->removeEmptyAttributes($list, 'MetaDescription'); 186 | } 187 | 188 | $list = $this->markDuplicates($list); 189 | 190 | if (isset($params['DuplicatesOnly']) && $params['DuplicatesOnly']) { 191 | $list = $list->filter('IsDuplicate', true); 192 | } 193 | 194 | $list = $list->sort('ID'); 195 | 196 | return $list; 197 | } 198 | 199 | /** 200 | * Mark duplicate attributes 201 | * 202 | * @param SS_List $list 203 | * @return SS_List 204 | */ 205 | private function markDuplicates($list) 206 | { 207 | $duplicates = $this->findDuplicates($list, 'MetaTitle')->map('ID', 'ID')->toArray(); 208 | $duplicateList = new ArrayList(); 209 | 210 | foreach ($list as $item) { 211 | if (in_array($item->ID, $duplicates)) { 212 | $item->IsDuplicate = true; 213 | $duplicateList->push($item); 214 | } 215 | } 216 | 217 | $duplicates = $this->findDuplicates($list, 'MetaDescription')->map('ID', 'ID')->toArray(); 218 | foreach ($list as $item) { 219 | if (in_array($item->ID, $duplicates)) { 220 | $item->IsDuplicate = true; 221 | if (!$list->byID($item->ID)) { 222 | $duplicateList->push($item); 223 | } 224 | } 225 | } 226 | 227 | $duplicateList->merge($list); 228 | $duplicateList->removeDuplicates(); 229 | return $duplicateList; 230 | } 231 | 232 | /** 233 | * Find duplicate attributes within a list 234 | * 235 | * @param SS_List $list 236 | * @param string $type 237 | * @return SS_List 238 | */ 239 | private function findDuplicates(SS_List $list, $type) 240 | { 241 | $pageAttributes = $list->map('ID', $type)->toArray(); 242 | 243 | $potentialDuplicateAttributes = array_unique( 244 | array_diff_assoc( 245 | $pageAttributes, 246 | array_unique($pageAttributes) 247 | ) 248 | ); 249 | $duplicateAttributes = array_filter($pageAttributes, function ($value) use ($potentialDuplicateAttributes) { 250 | return in_array($value, $potentialDuplicateAttributes); 251 | }); 252 | 253 | if (!count($duplicateAttributes)) { 254 | return $list; 255 | } 256 | 257 | return $list->filter([ 258 | 'ID' => array_keys($duplicateAttributes), 259 | ]); 260 | } 261 | 262 | /** 263 | * Remove pages with empty attributes 264 | * 265 | * @param SS_List $list 266 | * @param string $type 267 | * @return SS_List 268 | */ 269 | private function removeEmptyAttributes(SS_List $list, $type) 270 | { 271 | $pageAttributes = $list->map('ID', $type)->toArray(); 272 | 273 | $emptyAttributess = array_map(function ($value) { 274 | return $value == ''; 275 | }, $pageAttributes); 276 | 277 | if (!count($emptyAttributess)) { 278 | return $list; 279 | } 280 | 281 | return $list->filter([ 282 | 'ID:not' => array_keys( 283 | array_filter($emptyAttributess, function ($value) { 284 | return $value == 1; 285 | }) 286 | ) 287 | ]); 288 | } 289 | 290 | 291 | } 292 | --------------------------------------------------------------------------------