PluginProbe
Filter Everything — WordPress & WooCommerce Filters / 1.5.1
Filter Everything — WordPress & WooCommerce Filters v1.5.1
1.9.7 1.9.6 1.9.5 1.9.4 1.9.3 1.9.2.2 1.9.2.1 trunk 1.2.1 1.2.3 1.2.4 1.2.5 1.3.0 1.3.1 1.3.2 1.4.1 1.4.4 1.4.5 1.4.8 1.4.9 1.5.0 1.5.1 1.6.0 1.6.1 1.6.2 All 52 releases
filter-everything / src / Admin / FilterFields.php

FilterFields.php in Filter Everything — WordPress & WooCommerce Filters 1.5.1, at src/Admin/FilterFields.php

1,284 lines 48.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3
4 namespace FilterEverything\Filter;
5
6 if ( ! defined('WPINC') ) {
7 wp_die();
8 }
9
10 class FilterFields
11 {
12
13 const FLRT_NEW_FILTER_ID = 'wpc_new_id';
14
15 const FILTER_FIELD_KEY = 'wpc_filter_fields';
16
17 private $defaultFields = [];
18
19 private $fse;
20
21 private $em;
22
23 private $hooksRegistered = false;
24
25 private $errors;
26
27 public function __construct()
28 {
29 $this->fse = Container::instance()->getFilterService();
30 $this->em = Container::instance()->getEntityManager();
31 $this->setupDefaultFields();
32 }
33
34 public function registerHooks()
35 {
36 if( ! $this->hooksRegistered ){
37 add_filter('wpc_input_type_select', [$this, 'addSpinnerToSelect'], 10, 2);
38
39 add_action( 'wp_ajax_wpc-delete-filter', [ $this, 'ajaxDeleteFilter' ] );
40 add_action( 'wp_ajax_wpc-load-exclude-terms', [ $this, 'sendExcludedTerms' ] );
41 add_action( 'wp_ajax_wpc-validate-filters', [ $this, 'ajaxValidateFilters' ] );
42 add_action( 'after_delete_post', [$this, 'deleteRelatedFilters'], 10, 2 );
43
44 $this->hooksRegistered = true;
45 }
46 }
47
48 private function setupDefaultFields()
49 {
50 // maybe add filter in future to allow change default fields
51 do_action( 'wpc_before_setup_filter_fields' );
52 $entity = $this->em->getEntityByFilter( array('entity' => 'taxonomy', 'e_name' => 'category'), 'post' );
53
54 $defaultFields = array(
55 'ID' => array(
56 'type' => 'Hidden'
57 ),
58 'parent' => array(
59 'type' => 'Hidden'
60 ),
61 'menu_order' => array(
62 'type' => 'Hidden',
63 'class' => 'wpc-menu-order-field'
64 ),
65 'label' => array(
66 'type' => 'Text',
67 'label' => esc_html__( 'Filter Label', 'filter-everything' ),
68 'class' => 'wpc-field-label',
69 'placeholder' => esc_html__( 'New Filter', 'filter-everything'),
70 'instructions' => esc_html__( 'Title which will appear in filter widget', 'filter-everything' )
71 ),
72 'entity' => array(
73 'type' => 'Select',
74 'label' => esc_html__( 'Filter by', 'filter-everything' ),
75 'class' => 'wpc-field-entity',
76 'options' => $this->em->getPossibleEntities(),
77 'default' => 'taxonomy_category',
78 'instructions' => esc_html__( 'A thing by which posts will be filtered', 'filter-everything' ),
79 'required' => true
80 ),
81 'e_name' => array(
82 'type' => 'Text',
83 'label' => esc_html__( 'Meta Key', 'filter-everything' ),
84 'class' => 'wpc-field-ename',
85 'instructions' => esc_html__( 'Name of the Custom Field. Please, see Popular Meta keys at the bottom', 'filter-everything'),
86 'required' => true
87 ),
88 'slug' => array(
89 'type' => 'Text',
90 'label' => esc_html__( 'Var Name for URL', 'filter-everything' ),
91 'class' => 'wpc-field-slug',
92 'instructions' => esc_html__( 'Name of a part of URL responsible for this filter', 'filter-everything'),
93 'tooltip' => wp_kses(
94 __( 'For example, in the URL path:<br />/?color=blue&size=large<br />"color" and "size" are the names of the URL vars.<br />In PRO version of the plugin the URL part after "?" becomes <br />/color-blue/size-large/', 'filter-everything'),
95 array( 'br' => array() )
96 ),
97 'required' => true
98 ), // Optional
99 'view' => array(
100 'type' => 'Select',
101 'label' => esc_html__( 'View in Widget', 'filter-everything' ),
102 'class' => 'wpc-field-view',
103 'options' => $this->getViewOptions(),
104 'default' => 'checkboxes',
105 'instructions' => ''
106 ),
107 'logic' => array(
108 'type' => 'Select',
109 'label' => esc_html__( 'Filter Logic', 'filter-everything' ),
110 'class' => 'wpc-field-logic',
111 'options' => array('or' => esc_html__('OR', 'filter-everything' ), 'and' => esc_html__('AND', 'filter-everything') ),
112 'default' => 'or',
113 'instructions' => esc_html__( 'Determines how to select posts, when two or more terms of this filter selected', 'filter-everything' ),
114 'tooltip' => wp_kses(
115 __( '«OR» means to show posts if them are at least in one of selected terms. <br />«AND» means, that posts should belong to all selected terms at the same time.', 'filter-everything' ),
116 array( 'br' => array() )
117 ),
118 ), // AND
119 'orderby' => array(
120 'type' => 'Select',
121 'label' => esc_html__( 'Sort Terms by', 'filter-everything' ),
122 'class' => 'wpc-field-orderby',
123 'options' => $this->getOrderByOptions(),
124 'default' => 'nameasc',
125 'instructions' => esc_html__('The order in which terms appear in widget', 'filter-everything')
126 ),
127 'in_path' => array(
128 'type' => 'Checkbox',
129 'label' => esc_html__( 'In URL', 'filter-everything' ),
130 'class' => 'wpc-field-path',
131 'default' => 'yes',
132 'instructions' => ''
133 ),
134 'exclude' => array(
135 'type' => 'Select',
136 'label' => esc_html__( 'Exclude Terms', 'filter-everything' ),
137 'class' => 'wpc-field-exclude',
138 'options' => $entity->getTermsForSelect(),
139 'multiple' => 'multiple',
140 'instructions' => esc_html__( 'Selected terms will not appear in filter widget', 'filter-everything' )
141 ),
142 'collapse' => array(
143 'type' => 'Checkbox',
144 'label' => esc_html__( 'Folding', 'filter-everything' ),
145 'class' => 'wpc-field-collapse',
146 'default' => 'no',
147 'instructions' => esc_html__( 'Makes the filter collapsible in widget', 'filter-everything'),
148 'tooltip' => esc_html__( 'Useful for situations when the filter is rarely applied but takes up some space in widget. Collapsed by default.', 'filter-everything' )
149 ),
150 'show_chips' => array(
151 'type' => 'Checkbox',
152 'label' => esc_html__( 'Show Selected', 'filter-everything' ),
153 'class' => 'wpc-field-show-chips',
154 'default' => 'yes',
155 'instructions' => esc_html__( 'Show filter selected terms in the list of all chosen items', 'filter-everything' )
156 ),
157 'hierarchy' => array(
158 'type' => 'Checkbox',
159 'label' => esc_html__( 'Show Hierarchy', 'filter-everything' ),
160 'class' => 'wpc-field-hierarchy',
161 'default' => 'no',
162 'instructions' => esc_html__( 'Display the term hierarchy in the filter widget. Child terms will be collapsed by default', 'filter-everything' )
163 ),
164 'range_slider' => array(
165 'type' => 'Checkbox',
166 'label' => esc_html__( 'Enable Range Slider?', 'filter-everything' ),
167 'class' => 'wpc-field-range-slider',
168 'default' => 'yes',
169 'instructions' => esc_html__( 'If disabled, visitors must type numeric values in text inputs', 'filter-everything' )
170 ),
171 'step' => array(
172 'type' => 'Text',
173 'label' => esc_html__( 'Slider Step', 'filter-everything' ),
174 'class' => 'wpc-field-value-step',
175 'instructions' => esc_html__( 'Determines how numeric value will be changed when you move slider controls', 'filter-everything' ),
176 'tooltip' => wp_kses(
177 __('Step 1 means possible values are 1,2,3,4 ...<br />Step 0.1 means possible values are 5.1, 5.2, 5.3, 5.4 ...<br />Step 15 means possible values are 15, 30, 45, 60...', 'filter-everything'),
178 array( 'br' => array() )
179 ),
180 'default' => 1
181 ),
182 'search' => array(
183 'type' => 'Checkbox',
184 'label' => esc_html__( 'Search field', 'filter-everything' ),
185 'class' => 'wpc-field-search',
186 'default' => 'no',
187 'instructions' => esc_html__( 'Adds search field above the terms list', 'filter-everything' )
188 ),
189 'tooltip' => array(
190 'type' => 'Text',
191 'label' => esc_html__( 'Tooltip', 'filter-everything' ),
192 'class' => 'wpc-field-tooltip',
193 'instructions' => esc_html__( 'Will appear next to the Filter Label', 'filter-everything' ),
194 'default' => ''
195 )
196 );
197
198 $this->defaultFields = apply_filters( 'wpc_filter_default_fields', $defaultFields, $this );
199 }
200
201 public static function getViewOptions()
202 {
203 $viewOptions = array(
204 'checkboxes' => esc_html__('Checkboxes', 'filter-everything'),
205 'radio' => esc_html__('Radio buttons', 'filter-everything'),
206 'labels' => esc_html__('Labels list', 'filter-everything'),
207 'dropdown' => esc_html__('Dropdown', 'filter-everything'),
208 'range' => esc_html__('Range', 'filter-everything')
209 );
210
211 return $viewOptions;
212 }
213
214 public function getOrderByOptions()
215 {
216 $orderBy = array(
217 'nameasc' => esc_html__( 'Term name &laquo;abc&raquo;', 'filter-everything' ),
218 'postcountasc' => esc_html__( 'Posts count &laquo;123&raquo;', 'filter-everything' ),
219 'idasc' => esc_html__( 'Term ID &laquo;123&raquo;', 'filter-everything' ),
220 'menuasc' => esc_html__( 'Menu order &laquo;123&raquo;', 'filter-everything' ),
221 'namedesc' => esc_html__( 'Term name &laquo;cba&raquo;', 'filter-everything' ),
222 'postcountdesc' => esc_html__( 'Posts count &laquo;321&raquo;', 'filter-everything' ),
223 'iddesc' => esc_html__( 'Term ID &laquo;321&raquo;', 'filter-everything' ),
224 'menudesc' => esc_html__( 'Menu order &laquo;321&raquo;', 'filter-everything' )
225 );
226
227 return $orderBy;
228 }
229
230 public function getFieldsByType($type, $configuredFields = [] )
231 {
232 $selected = [];
233 $type = ucfirst($type);
234
235 foreach ( $configuredFields as $key => $field) {
236 if ( isset( $field['type'] ) && ( $field['type'] === $type ) ) {
237 $selected[$key] = $field;
238 }
239 }
240
241 return $selected;
242
243 }
244
245 public function getEmptyFilterObject()
246 {
247 $defaults = new \stdClass();
248 $defaults->ID = self::FLRT_NEW_FILTER_ID;
249 $defaults->post_parent = '';
250 $defaults->menu_order = 0;
251 $defaults->post_title = '';
252 $defaults->post_content = '';
253 $defaults->post_name = '';
254
255 $filter = $this->em->prepareFilter( $defaults );
256
257 return $this->prepareFilterInputs( $filter );
258 }
259
260 public function getFiltersInputs( $set_id )
261 {
262 $preparedFilters = [];
263
264 if( ! $set_id || empty( $set_id ) ){
265 return $preparedFilters;
266 }
267
268 $filters = $this->em->selectOnlySetFilters( $set_id );
269
270 foreach ( $filters as $field_key => $filter ){
271 $preparedFilters[] = $this->prepareFilterInputs( $filter );
272 }
273
274 return $preparedFilters;
275 }
276
277 private function prepareFilterInputs( $filter )
278 {
279 $postTypes = false;
280 $terms = [];
281 // Used for filter table class in filter set fields.
282 // E.g. post_meta, taxonomy, author.
283
284 $short_entity = $filter['entity'] ? $filter['entity'] : 'taxonomy';
285
286 if( $short_entity === 'taxonomy' ){
287 // Add hierarchical class
288 if( is_taxonomy_hierarchical( $filter['e_name'] ) ){
289 $short_entity .= ' taxonomy-hierarchical';
290 }
291
292 // Add product attribute class
293 if( strpos( $filter['e_name'], 'pa_' ) === 0 ){
294 $short_entity .= ' taxonomy-product-attribute';
295 }
296
297 }
298
299 $short_entity .= isset( $filter['view'] ) ? ' wpc-view-'.$filter['view'] : '';
300
301 $belongs = $this->filterBelongsToPostType( $filter['parent'], $filter['entity'], $filter['e_name'] );
302
303 $postType = '';
304 // For post_meta_num entity postType is critical value so we have to set it up
305 if( in_array( $filter['entity'], array( 'post_meta_num', 'post_meta_exists' ) ) ){
306 $fss = Container::instance()->getFilterSetService();
307 $set = $fss->getSet( $filter['parent'] );
308 if( isset( $set['post_type']['value'] ) ){
309 $postType = $set['post_type']['value'];
310 }
311 }
312
313 $entity = $this->em->getEntityByFilter( $filter, $postType );
314
315 if( $entity ){
316 $terms = $entity->getTermsForSelect();
317 }
318
319 // This is required only for filter fields inputs
320 $filter = $this->fse->combineEntityNameInFilter( $filter );
321
322 foreach( $this->getFieldsMapping() as $fieldKey => $fieldData ){
323
324 if( isset( $filter[$fieldKey] ) ){
325
326 $default_value = isset( $fieldData['default'] ) ? $fieldData['default'] : '';
327
328 $multiple = ( $fieldKey === 'exclude' && ( $filter['entity'] !== 'post_meta_num' ) );
329
330 $fieldData['name'] = $this->generateInputName( $filter['ID'], $fieldKey, $multiple );
331 $fieldData['id'] = $this->generateInputID( $filter['ID'], $fieldKey );
332
333 $fieldData['value'] = ( $filter[$fieldKey] ) ? $filter[$fieldKey] : $default_value;
334
335 if( $filter['ID'] === self::FLRT_NEW_FILTER_ID ){
336 $fieldData['entity_belongs'] = true;
337 }else{
338 $fieldData['entity_belongs'] = $belongs;
339 }
340
341 if( $fieldKey === 'entity' ){
342 $fieldData['short_entity'] = $short_entity;
343 }
344
345 if( $fieldKey === 'slug' && $fieldData['value'] ){
346 $fieldData['readonly'] = 'readonly';
347 }
348
349 if( $fieldKey === 'e_name' && $fieldData['value'] ){
350 unset( $fieldData['options'] );
351 $fieldData['readonly'] = 'readonly';
352 $fieldData['type'] = 'Text';
353 }
354
355 if( $fieldKey === 'entity' ){
356 // Add instead-entity field
357 if( $filter['ID'] !== self::FLRT_NEW_FILTER_ID ){
358
359 /**
360 * @feature maybe move this to separate method
361 * @bug slug wasn't saved for new filter
362 */
363 // For existing filters we need to forbid changing entity value
364 // And we need to show input instead select
365 $fieldData['type'] = 'Text';
366 // To make it compatible with Text field
367 unset( $fieldData['options'] );
368 // Forbid to edit field
369 $fieldData['readonly'] = 'readonly';
370
371 $flatEntities = $this->em->getFlatEntities();
372
373 $insteadEntityVal = isset( $flatEntities[ $fieldData['value'] ] ) ? $flatEntities[ $fieldData['value'] ] : '';
374
375 if( $fieldData['value'] === 'post_meta_exists' && ! defined('FLRT_FILTERS_PRO') ){
376 $insteadEntityVal = esc_html__('Available in PRO', 'filter-everything');
377 }
378
379 // Field, that will be visible instead of entity, that will be hidden.
380 $prepared[ 'instead-entity' ] = $this->getInsteadEntityField( $filter['ID'], $insteadEntityVal );
381
382 }
383
384 if( $filter['ID'] === self::FLRT_NEW_FILTER_ID ){
385
386 if( ! defined('FLRT_FILTERS_PRO') ){
387 $fieldData['disabled'] = array('post_meta_exists');
388 }
389
390 }
391
392 }
393
394 if( $fieldKey === 'exclude' ){
395 // We always add terms even they are empty array to fill Select2 with related terms.
396 $fieldData['options'] = $terms;
397
398 if( $filter['entity'] === 'post_meta_num'){
399 $fieldData['options'] = [];
400 }
401 }
402
403 // Set disabled fields for some situations
404 if( in_array( $filter['entity'], array( 'author_author', 'post_meta_exists' ) ) /* $filter['entity'] === 'author_author'*/ && $fieldKey === 'logic' ){
405 $fieldData['disabled'] = array('and');
406 }
407
408 if( $filter['entity'] === 'post_meta_num' && $fieldKey === 'logic' ){
409 $fieldData['disabled'] = array('or');
410 }
411
412 if( $filter['entity'] === 'post_meta_num' && $fieldKey === 'view' ){
413 $fieldData['disabled'] = array('checkboxes', 'dropdown', 'radio', 'labels', 'colors', 'image');
414 }else if( $filter['entity'] !== 'post_meta_num' && $fieldKey === 'view' ){
415 $fieldData['disabled'] = array('range');
416 }
417
418 if( $fieldKey === 'orderby' && ( mb_strpos( $filter['entity'], 'taxonomy_pa' ) === false ) ){
419 $fieldData['disabled'] = ['menuasc', 'menudesc'];
420 }
421
422
423
424 }
425
426 $prepared[ $fieldKey ] = $fieldData;
427 }
428
429 return $prepared;
430 }
431
432 public function sanitizeFilterFields( $filter )
433 {
434 if( is_array( $filter ) ){
435 $sanitizedFilter = [];
436 $not_esc_html = [ 'e_name', 'tooltip' ];
437
438 foreach( $filter as $key => $value ){
439
440 if( in_array( $key, $not_esc_html, true ) ){
441 // Why? because meta_key field can contain any different characters
442 $sanitizedFilter[ $key ] = $value;
443 }else{
444
445 if( is_array( $value ) ){
446 array_map( 'esc_html', $value );
447 $sanitizedFilter[ $key ] = $value;
448 } else {
449 $sanitizedFilter[ $key ] = esc_html( $value );
450 }
451
452 }
453 }
454
455 if( isset( $sanitizedFilter['menu_order'] ) ){
456 $sanitizedFilter['menu_order'] = flrt_sanitize_int( $sanitizedFilter['menu_order'] );
457 }
458
459 if( isset( $sanitizedFilter['label'] ) ){
460 $sanitizedFilter['label'] = sanitize_text_field( $sanitizedFilter['label'] );
461 }
462
463 if( isset( $sanitizedFilter['slug'] ) ){
464 $sanitizedFilter['slug'] = preg_replace( '/[^a-z0-9\-\_]+/', '', mb_strtolower($sanitizedFilter['slug']) );
465 $sanitizedFilter['slug'] = trim($sanitizedFilter['slug'], '-');
466 }
467
468 if( isset( $sanitizedFilter['step'] ) ){
469 $sanitizedFilter['step'] = preg_replace('/[^\d\.]+/', '', $sanitizedFilter['step'] );
470 if( ! $sanitizedFilter['step'] ){
471 $sanitizedFilter['step'] = 1;
472 }
473 }
474
475 if( isset( $sanitizedFilter['tooltip'] ) ){
476 $sanitizedFilter['tooltip'] = wp_kses(
477 $sanitizedFilter['tooltip'],
478 array(
479 'br' => array(),
480 'span' => array('class' => true, 'id' => true),
481 'em' => array(),
482 'strong' => array('class' => true),
483 'i' => array(),
484 'b' => array()
485 )
486 );
487 }
488
489 return $sanitizedFilter;
490 }
491
492 return $filter;
493 }
494
495 public function validateTheFilter( $filter, $id = false ){
496
497 $valid = true;
498 $newFilter = false;
499 $filterID = false;
500 $validEntity = true;
501 $validator = new Validator();
502
503 if( $filter['ID'] === self::FLRT_NEW_FILTER_ID ){
504 $newFilter = true;
505 $filterID = $id;
506 }else{
507 $filterID = $filter['ID'];
508 }
509
510 // Check all fields are our fields
511 $defaultFields = $this->getFieldsMapping();
512 // To make compatible with this field
513 $defaultFields['instead-entity'] = true;
514
515 foreach( $filter as $fieldKey => $fieldValue ){
516 if( ! isset( $defaultFields[ $fieldKey ] ) ){
517 $this->pushError(32); // Invalid fields present
518 $valid = false;
519 }
520 }
521
522 /**
523 * Check required field data, that should be not empty
524 */
525 if( isset( $filter['ID'] ) ){
526 // We need to check post type for current ID otherwise we can override any existing post
527 if( $filter['ID'] !== self::FLRT_NEW_FILTER_ID ){
528 $savedFilter = get_post( $filter['ID'] );
529
530 // Filter post doesn't exist
531 if( ! $savedFilter ){
532 $this->pushError(33); // Invalid filter ID
533 $valid = false;
534 }
535 // Other post type
536 if( ! isset( $savedFilter->post_type ) || $savedFilter->post_type !== FLRT_FILTERS_POST_TYPE ){
537 $this->pushError(33); // Invalid filter ID
538 $valid = false;
539 }
540 }
541
542 } else {
543 $this->pushError(33); // Invalid filter ID
544 $valid = false;
545 }
546
547 /**
548 * Parent field aka Set ID
549 */
550 if( isset( $filter['parent'] ) ){
551
552 if( $filter['ID'] !== self::FLRT_NEW_FILTER_ID ) {
553 $savedSet = get_post($filter['parent']);
554
555 // Set post doesn't exist
556 if (!$savedSet) {
557 $this->pushError(34); // Invalid Set ID
558 $valid = false;
559 }
560 // Other post type
561 if ( ! isset( $savedSet->post_type ) || $savedSet->post_type !== FLRT_FILTERS_SET_POST_TYPE) {
562 $this->pushError(34); // Invalid Set ID
563 $valid = false;
564 }
565 }
566
567 }else{
568 $this->pushError( 34); // Invalid Set ID
569 $valid = false;
570 }
571
572 /**
573 * Entity
574 */
575 if( isset( $filter['entity'] ) ){
576 if( ! $validator->validatePossibleEntity( $filter['entity'] ) ){
577 $this->pushError( 35, $filterID, 'entity' ); // Invalid Entity
578 $this->pushError( 35, $filterID, 'instead-entity' );
579 $validEntity = false;
580 $valid = false;
581 }
582
583 }else{
584 $this->pushError( 35, $filterID, 'entity' ); // Invalid Entity
585 $this->pushError( 35, $filterID, 'instead-entity' );
586 $validEntity = false;
587 $valid = false;
588 }
589
590 /**
591 * Slug validations
592 */
593 if( isset( $filter['slug'] ) ){
594 $existingSlugs = get_option('wpc_filter_permalinks');
595 $prefix = $filter['slug'];
596
597 // Check this only for new filters
598 if( $filter['ID'] === self::FLRT_NEW_FILTER_ID && $validEntity ) {
599 $fs = Container::instance()->getFilterService();
600 $newEntityKey = $fs->getEntityKey($filter['entity'], $filter['e_name']);
601 // Prohibit using the same slug for another entity
602 if (!$validator->validateExistingPrefix($prefix, $existingSlugs, $newEntityKey)) {
603 $this->pushError( 36, $filterID, 'slug' ); // Prefix is already used
604 $valid = false;
605 }
606 }
607
608 // Ensure, that prefix contains at least one alphabetic character
609 // Also this prevents from empty prefix
610 if ( ! $validator->validateAlphabCharsExists($prefix) ) {
611 $this->pushError( 37, $filterID, 'slug' ); // Invalid prefix. Has to contain at least one alphabetic symbol
612 $valid = false;
613 }
614
615 // Check hyphens problem when cat and cat-x exists
616 if (!$validator->validatePrefixHyphens($prefix, $existingSlugs) ) {
617 $this->pushError( 39, $filterID, 'slug' ); // Invalid prefix. Incorrect hyphens.
618 $valid = false;
619 }
620
621 // Check for slugs, that matches with native WP Entities
622 if( ! $validator->validateAllowedPrefixes( $prefix, $filter ) ){
623 $errorCode = 3991;
624 if( defined('FLRT_FILTERS_PRO') && FLRT_FILTERS_PRO ){
625 $errorCode = 399;
626 }
627 $this->pushError( $errorCode, $filterID, 'slug' ); // Invalid prefix. Equal to wp entity.
628 $valid = false;
629 }
630
631 } else {
632 $this->pushError( 38, $filterID, 'slug' ); // Invalid prefix. Empty.
633 $valid = false;
634 }
635
636 /**
637 * E_name validations
638 */
639 if( isset( $filter['e_name'] ) ){
640
641 if( isset( $filter['entity'] ) ) {
642 if (in_array($filter['entity'], array('post_meta', 'post_meta_num', 'post_meta_exists') ) ) {
643 $e_name = $filter['e_name'];
644
645 if( $e_name === '' ){
646 $this->pushError(401, $filterID, 'e_name'); // Select or Enter meta key.
647 $valid = false;
648 } else {
649 // Should not be empty and should contain at least one alphabetic character
650 if (!$validator->validateAlphabCharsExists($e_name)) {
651 $this->pushError(40, $filterID, 'e_name'); // Invalid E_name.
652 $valid = false;
653 }
654
655 // Should not contain characters that escaped by esc_attr
656 if (!$validator->validateEscAttrCharacters($e_name)) {
657 $this->pushError(40, $filterID, 'e_name'); // Invalid E_name.
658 $valid = false;
659 }
660
661 // Some meta keys should be prohibited to use
662 $excludedMetaKeys = flrt_get_forbidden_meta_keys();
663 if( in_array( $e_name, $excludedMetaKeys, true ) ){
664 $this->pushError( 40, $filterID, 'e_name'); // Invalid E_name.
665 $valid = false;
666 }
667 }
668 }
669 }
670
671 }else{
672 $this->pushError( 40, $filterID, 'e_name' ); // Invalid E_name.
673 $valid = false;
674 }
675
676 /**
677 * View validations
678 */
679 if( isset( $filter['view'] ) ){
680 $viewOptions = array_keys( $this->getViewOptions() );
681
682 if( ! $validator->validateView( $filter, $viewOptions ) ){
683 $this->pushError( 41, $filterID, 'view' ); // Invalid View.
684 $valid = false;
685 }
686
687 } else {
688 $this->pushError( 41, $filterID, 'view' ); // Invalid View.
689 $valid = false;
690 }
691
692 /**
693 * Logic validations
694 */
695 if( isset( $filter['logic'] ) ){
696
697 if( ! in_array( $filter['logic'], array( 'or', 'and' ), true ) ){
698 $this->pushError( 42, $filterID, 'logic' ); // Invalid Logic.
699 $valid = false;
700 }
701
702 // For author and post_meta_exists entities logic can be only OR
703 if( in_array( $filter['entity'], array( 'author_author', 'post_meta_exists' ) ) ){
704 if( $filter['logic'] !== 'or' ){
705 $this->pushError( 45, $filterID, 'logic' ); // Not acceptable logic.
706 $valid = false;
707 }
708 }
709
710 // For author entity logic can be only OR
711 if( $filter['entity'] === 'post_meta_num' ){
712 if( $filter['logic'] !== 'and' ){
713 $this->pushError( 47, $filterID, 'logic' ); // Not acceptable logic.
714 $valid = false;
715 }
716 }
717
718 } else {
719 $this->pushError( 42, $filterID, 'logic' ); // Invalid Logic.
720 $valid = false;
721 }
722
723 /**
724 * Orderby validations
725 */
726 if( isset( $filter['orderby'] ) ){
727 $orderBy = array_keys( $this->getOrderByOptions() );
728 if( ! in_array( $filter['orderby'], $orderBy, true ) ){
729 $this->pushError( 43, $filterID, 'orderby' ); // Invalid Orderby.
730 $valid = false;
731 }
732 } else {
733 $this->pushError( 43, $filterID, 'orderby' ); // Invalid Orderby.
734 $valid = false;
735 }
736
737 /**
738 * In path and Collapse doesn't require validations
739 * because their values are prepended in code
740 */
741
742 /**
743 * Exclude validations
744 */
745 if( isset( $filter['exclude'] ) && $validEntity ){
746 if( ! $validator->validateExcludeTerms( $filter['exclude'], $filter ) ){
747 $this->pushError( 44, $filterID, 'exclude' ); // Invalid Exclude.
748 $valid = false;
749 }
750 }
751
752 // In case when checkbox is not checked there is no $_POST['in_path'] parameter
753 if( isset( $filter['in_path'] ) ){
754 if( $filter['entity'] === 'post_meta_num' && $filter['in_path'] === 'yes' ){
755 $this->pushError( 46, $filterID, 'in_path' ); // Invalid In Path for Post meta num.
756 $valid = false;
757 }
758 }
759
760 // Check data types if they are correct
761 /**
762 * @todo validate range_slider, show_chips, step fields !!! IMPORTANT
763 */
764
765 // Check combinations of different data
766 /**
767 * The unique slug and entity problem
768 */
769 // The slug and entity pair should be the same between all filters in all sets!
770 // Otherwise we will have different slugs and URLs in different categories for the same Post type.
771 // If user adding new slug for the same entity, it should be notified, that this new slug
772 // will be changed for all same entities.
773
774 // Obligatorily sanitize all data. Maybe separate method for that.
775 // If entity is taxonomy, field e_name should be empty.
776
777 // Filter slugs should not be from blacklist. Blacklist - typical Wordpress entities /categories, tags etc
778
779 // Slugs should be checked for non UTF symbols and maybe converted to latin UTF symbols
780 // Because user can add slug = категория and also user can add slug 'cheap' where 'c' will be cyrillic or other.
781
782 return $valid;
783 }
784
785 public function validateFilters( $filters ){
786
787 if( ! $filters ){
788 return false;
789 }
790 $valid = true;
791
792 // Check permissions
793 if( ! current_user_can( 'manage_options' ) ) {
794 $this->pushError(202);
795 $valid = false;
796 }
797
798 // Check for equal filters
799 foreach ( (array) $filters as $filter ){
800 if( isset( $filter['entity'] ) && isset( $filter['e_name'] ) ){
801 // To avoid few filters with the same meta key
802 if( in_array( $filter['entity'], array( 'post_meta', 'post_meta_num', 'post_meta_exists' ), true ) ){
803 $keys[] = 'post_meta'.$filter['e_name'];
804 }else{
805 $keys[] = $filter['entity'] . $filter['e_name'];
806 }
807 }
808 }
809
810 if( flrt_array_contains_duplicate( $keys ) ){
811 $this->pushError(31); // Equal filters
812 $valid = false;
813 }
814
815 return $valid;
816 }
817
818 public function ajaxValidateFilters()
819 {
820 $postData = Container::instance()->getThePost();
821 $data = isset( $postData['validateData'] ) ? $postData['validateData'] : false;
822 $response = [];
823
824 if( ! $data ){
825 $this->pushError(20);
826 }
827
828 if( ! isset( $data['_flrt_nonce'] ) || ! wp_verify_nonce( $data['_flrt_nonce'], FilterSet::NONCE_ACTION ) ){
829 $this->pushError(20); // Default common error
830 }
831
832 // Validate all filters
833 // If no one filter it's ok
834 if( isset( $data['wpc_filter_fields'] ) ){
835
836 $this->validateFilters( $data['wpc_filter_fields'] );
837
838 // Set up checkbox fields if they are empty
839 $filterConfiguredFields = $this->getFieldsMapping();
840
841 // Validate each filter separately
842 foreach ( (array) $data['wpc_filter_fields'] as $filterId => $filter ) {
843 $filter = $this->prepareFilterCheckboxFields($filter, $this->getFieldsByType('checkbox', $filterConfiguredFields));
844 $this->validateTheFilter( $filter, $filterId );
845 }
846
847 }
848
849
850 $this->fillErrorsMessages();
851 $errors = $this->getErrors();
852 // Send errors if they exist
853 if( $errors && ! empty( $errors ) ){
854 $response['errors'] = $errors;
855 wp_send_json_error($response);
856 }
857
858 /**
859 * @feature it is better to validate all fields and collect all errors to show them simultaneously.
860 */
861
862 wp_send_json_success();
863 }
864
865 function saveFilter( $filter ) {
866 // May have been posted. Remove slashes.
867 $filter = wp_unslash( $filter );
868
869 $filter = apply_filters( 'wpc_pre_save_filter', $filter );
870
871 unset( $filter['instead-entity'] );
872 // Make a backup of field data and remove some args.
873 $_filter = $filter;
874 flrt_extract_vars( $_filter, array( 'ID', 'label', 'parent', 'menu_order', 'slug' ) );
875
876 $_filter = $this->fse->splitEntityFullNameInFilter( $_filter );
877
878 // Create array of data to save.
879 $to_save = array(
880 'ID' => $filter['ID'],
881 'post_status' => 'publish',
882 'post_type' => FLRT_FILTERS_POST_TYPE,
883 'post_title' => $filter['label'],
884 'post_content' => maybe_serialize( $_filter ),
885 'post_parent' => $filter['parent'],
886 'menu_order' => $filter['menu_order'] ? $filter['menu_order'] : 0,
887 'post_name' => $filter['slug'],
888 'post_excerpt' => $filter['entity']
889 );
890
891 // Unhook wp_targeted_link_rel() filter from WP 5.1 corrupting serialized data.
892 remove_filter( 'content_save_pre', 'wp_targeted_link_rel' );
893
894 do_action( 'wpc_pre_save_post_filter', $to_save, $filter );
895
896 add_filter( 'pre_wp_unique_post_slug', 'flrt_force_non_unique_slug', 10, 2 );
897
898 // Slash data.
899 // WP expects all data to be slashed and will unslash it (fixes '\' character issues).
900 $to_save = wp_slash( $to_save );
901
902 // Update or Insert.
903 if( $filter['ID'] === self::FLRT_NEW_FILTER_ID ){
904 $filter['ID'] = wp_insert_post( $to_save );
905 }else{
906 wp_update_post( $to_save );
907 }
908
909 remove_filter( 'pre_wp_unique_post_slug', 'flrt_force_non_unique_slug', 10 );
910
911 // Return field.
912 return $filter;
913 }
914
915 public function prepareFilterCheckboxFields( $filter, $configuredFields = [] )
916 {
917 foreach ( $configuredFields as $key => $checkbox ){
918 if( ! isset( $filter[ $key ] ) ){
919 $filter[ $key ] = 'no';
920 }else{
921 $filter[ $key ] = 'yes';
922 }
923 }
924
925 return $filter;
926 }
927
928 public function getFieldsMapping()
929 {
930 return $this->defaultFields;
931 }
932
933 public function deleteFilter( $ID, $force = false )
934 {
935 if( ! $ID ){
936 return false;
937 }
938
939 if( $force ){
940 return wp_delete_post( $ID, true );
941 }else{
942 return wp_trash_post( $ID );
943 }
944
945 }
946
947 public function deleteRelatedFilters( $postid, $post )
948 {
949 if( $post->post_type !== FLRT_FILTERS_SET_POST_TYPE ){
950 return $postid;
951 }
952
953 $args = array(
954 'post_type' => FLRT_FILTERS_POST_TYPE,
955 'posts_per_page' => -1,
956 'post_parent' => $postid,
957 'post_status' => array('any'),
958 );
959
960 $setFilters = get_posts( $args );
961
962 if( ! empty( $setFilters ) ){
963 foreach ( $setFilters as $filter ) {
964 $this->deleteFilter( $filter->ID, true );
965 }
966 }
967
968 return $postid;
969 }
970
971 public function ajaxDeleteFilter()
972 {
973 $postData = Container::instance()->getThePost();
974 $filterId = isset( $postData['fid'] ) ? $postData['fid'] : false;
975 if( $filterId === self::FLRT_NEW_FILTER_ID ){
976 wp_send_json_success();
977 }
978
979 $nonce = isset( $postData['_wpnonce'] ) ? $postData['_wpnonce'] : false;
980 $errorResponse = array(
981 'fid' => $filterId,
982 'message' => esc_html__('An error occured. Please, refresh the page and try again.', 'filter-everything')
983 );
984
985 if( ! wp_verify_nonce( $nonce, FilterSet::NONCE_ACTION ) ){
986 wp_send_json_error( $errorResponse );
987 }
988
989 if( ! $filterId ){
990 wp_send_json_error( $errorResponse );
991 }
992
993 $filter = get_post( $filterId );
994
995 if( ! isset( $filter->post_type ) || ( $filter->post_type !== FLRT_FILTERS_POST_TYPE ) ){
996 wp_send_json_error( $errorResponse );
997 }
998
999 if( $filterPost = $this->deleteFilter( $filterId, true ) ){
1000 $response['fid'] = $filterPost->ID;
1001 wp_send_json_success( $response );
1002 }else{
1003 wp_send_json_error( $errorResponse );
1004 }
1005 }
1006
1007 public function sendExcludedTerms()
1008 {
1009 $container = Container::instance();
1010 $postData = $container->getThePost();
1011 $validator = new Validator();
1012 $filterId = isset( $postData['fid'] ) ? $postData['fid'] : false;
1013 $nonce = isset( $postData['_wpnonce'] ) ? $postData['_wpnonce'] : false;
1014 $entity = isset( $postData['entity'] ) ? $postData['entity'] : false;
1015 $e_name = isset( $postData['ename'] ) ? $postData['ename'] : false;
1016
1017 $errorResponse = array(
1018 'fid' => $filterId,
1019 'message' => esc_html__('An error occured. Please, refresh the page and try again.', 'filter-everything')
1020 );
1021
1022 if( ! wp_verify_nonce( $nonce, FilterSet::NONCE_ACTION ) ){
1023 wp_send_json_error( $errorResponse );
1024 }
1025
1026 if( ! $validator->validatePossibleEntity( $entity ) ){
1027 wp_send_json_error( $errorResponse );
1028 }
1029
1030 $em = $container->getEntityManager();
1031 if( $e_name ){
1032 $filterEntity['entity'] = $entity;
1033 $filterEntity['e_name'] = $e_name;
1034 }else{
1035 $fse = $container->getFilterService();
1036 $filterEntity = $fse->splitEntityFullNameInFilter( array( 'entity' => $entity ) );
1037 }
1038
1039 $entity = $em->getEntityByFilter( $filterEntity );
1040 $terms = $entity->getTermsForSelect2();
1041
1042 if( ! empty( $terms ) ){
1043 $response['terms'] = $terms;
1044 $response['fid'] = $filterId;
1045 wp_send_json_success( $response );
1046 }else{
1047 wp_send_json_error( $errorResponse );
1048 }
1049
1050 }
1051
1052 public function addSpinnerToSelect( $html, $attributes )
1053 {
1054 if( isset( $attributes['class'] ) ){
1055 $requiredIds = array(
1056 $this->generateInputID( self::FLRT_NEW_FILTER_ID, 'exclude' ),
1057 );
1058
1059 if( in_array( $attributes['id'], $requiredIds ) ){
1060
1061 $spinner = '<span class="spinner"></span>'."\r\n";
1062 $openContainer = '<div class="wpc-after-spinner-container">'."\r\n";
1063
1064 $closeContainer = '</div>'."\r\n";
1065
1066 $html = $spinner . $openContainer . $html . $closeContainer;
1067
1068 }
1069 }
1070 return $html;
1071 }
1072
1073 private function getInsteadEntityField( $filterId, $insteadEntityVal )
1074 {
1075 $insteadEntity = array(
1076 'type' => 'Text',
1077 'label' => esc_html__( 'Filter by', 'filter-everything' ),
1078 'class' => 'wpc-field-instead-entity',
1079 'name' => $this->generateInputName( $filterId, 'instead-entity' ),
1080 'id' => $this->generateInputID( $filterId, 'instead-entity' ),
1081 'value' => $insteadEntityVal,
1082 'readonly' => 'readonly',
1083 'default' => '',
1084 'instructions' => esc_html__( 'A thing by which posts will be filtered', 'filter-everything'),
1085 'tooltip' => esc_html__( 'An already selected value cannot be changed. But you always can delete the current one and create new filter if you need.', 'filter-everything' )
1086 );
1087
1088 return $insteadEntity;
1089 }
1090
1091
1092 /**
1093 * @return true|false
1094 */
1095 public function filterBelongsToPostType( $parent, $entity, $e_name )
1096 {
1097 if( ! isset( $parent ) ){
1098 return false;
1099 }
1100
1101 $fss = Container::instance()->getFilterSetService();
1102 $set = $fss->getSet( $parent );
1103
1104 if( ! isset( $set['post_type']['value'] ) ){
1105 return false;
1106 }
1107
1108 $post_type = $set['post_type']['value'];
1109
1110 return $this->entityBelongsToPostType( $post_type, $entity, $e_name );
1111 }
1112
1113 /**
1114 * @return true|false
1115 */
1116 private function entityBelongsToPostType( $post_type, $entity, $e_name )
1117 {
1118 if ( empty( $post_type ) ){
1119 return false;
1120 }
1121
1122 if( in_array( $entity, array( 'author', 'date', 'post_meta', 'post_meta_num' ) ) ){
1123 return true;
1124 }
1125
1126 if( in_array( $entity, array( 'post_meta_exists' ) ) && defined( 'FLRT_FILTERS_PRO' ) ){
1127 return true;
1128 }
1129
1130 if( $entity === 'taxonomy' ){
1131 return $this->isTaxonomyBelongsToPostType( $post_type, $e_name );
1132 }
1133
1134 return false;
1135 }
1136
1137 public function getErrorMessage( $code = 20 )
1138 {
1139 $messages = $this->getErrorsList();
1140
1141 if( isset( $messages[$code] ) ){
1142 return $messages[$code];
1143 }
1144
1145 return $messages[20];
1146 }
1147
1148 public function pushError( $errorCode, $filterId = false, $filterKey = '' )
1149 {
1150 $error = array(
1151 'code' => $errorCode
1152 );
1153
1154 if( $filterId && $filterKey ){
1155 $error['id'] = $this->generateInputID( $filterId, $filterKey );
1156 }
1157
1158 $this->errors[] = $error;
1159
1160 return $error;
1161 }
1162
1163 public function getErrors()
1164 {
1165 if( ! empty( $this->errors ) ) {
1166 return $this->errors;
1167 }
1168
1169 return false;
1170 }
1171
1172 public function fillErrorsMessages()
1173 {
1174 if( ! empty( $this->errors ) ){
1175 $errorsWithMessages = [];
1176 $messagesList = $this->getErrorsList();
1177
1178 foreach ( $this->errors as $index => $error ){
1179 if( isset( $error['code'] ) && isset( $messagesList[ $error['code'] ] ) ){
1180 $errorsWithMessages[$index] = $error;
1181 $errorsWithMessages[$index]['message'] = $messagesList[ $error['code'] ];
1182 }
1183 }
1184
1185 $this->errors = $errorsWithMessages;
1186 return true;
1187 }
1188
1189 return false;
1190 }
1191
1192 public function getErrorCodes()
1193 {
1194 $codes = [];
1195
1196 if( ! empty( $this->errors ) ){
1197 foreach( $this->errors as $error ){
1198 $codes[] = $error['code'];
1199 }
1200 }
1201
1202 return $codes;
1203 }
1204
1205
1206 /**
1207 * @return true|false
1208 */
1209 private function isTaxonomyBelongsToPostType( $post_type, $taxonomy = null )
1210 {
1211 if ( is_object( $post_type ) )
1212 $post_type = $post_type->post_type;
1213
1214 if ( empty( $post_type ) ){
1215 return false;
1216 }
1217
1218 $taxonomies = get_object_taxonomies( $post_type );
1219
1220 return in_array( $taxonomy, $taxonomies );
1221 }
1222
1223 public function generateInputID( $ID, $key )
1224 {
1225 return self::FILTER_FIELD_KEY . '-' . $ID . '-' . $key;
1226 }
1227
1228 public function generateInputName( $ID, $key, $multiple = false )
1229 {
1230 $name = self::FILTER_FIELD_KEY . '['. $ID .']['. $key . ']';
1231 if( $multiple ){
1232 $name .= '[]';
1233 }
1234 return $name;
1235 }
1236
1237 public static function getErrorsList()
1238 {
1239 $errors = array(
1240 // Set errors
1241 20 => esc_html__('An error occurred. Set fields were not saved, please try again.', 'filter-everything'),
1242 201 => esc_html__('An error occurred. Rule fields were not saved, please try again.', 'filter-everything'),
1243 202 => esc_html__('An error occurred. You do not have permissions to edit this.', 'filter-everything'),
1244 211 => esc_html__( 'Error: invalid WP Page type.', 'filter-everything' ),
1245 21 => esc_html__( 'Error: invalid post type.', 'filter-everything' ),
1246 22 => esc_html__( 'Error: invalid location.', 'filter-everything' ),
1247 221 => esc_html__( 'Error: invalid query.', 'filter-everything' ),
1248 23 => esc_html__( 'Error: invalid Hide empty field.', 'filter-everything' ),
1249 24 => esc_html__( 'Error: invalid Show count field.', 'filter-everything' ),
1250 242 => esc_html__( 'Error: invalid «Apply Button» mode field.', 'filter-everything' ),
1251 // 241 => esc_html__( 'Error: invalid Order field.', 'filter-everything' ),
1252 // Filters errors
1253 31 => esc_html__( 'Error: two or more filters with equal Filter By and Meta key values are forbidden in the same Set. Please, remove or change equal filters.', 'filter-everything' ),
1254 32 => esc_html__( 'Error: invalid fields present.', 'filter-everything' ),
1255 33 => esc_html__( 'Error: invalid filter ID.', 'filter-everything' ),
1256 34 => esc_html__( 'Error: invalid set ID.', 'filter-everything' ),
1257 35 => esc_html__( 'Error: invalid filter entity.', 'filter-everything' ),
1258 36 => esc_html__( 'Error: filter prefix is already used for another entity.', 'filter-everything' ),
1259 37 => esc_html__( 'Error: filter prefix should has at least one alphabetic symbol.', 'filter-everything' ),
1260 38 => esc_html__( 'Error: filter prefix should not be empty.', 'filter-everything' ),
1261 39 => esc_html__( 'Error: prefix part before "-" character can not be equal with other existing prefix.', 'filter-everything' ),
1262 399 => esc_html__( 'Error: this prefix is not allowed because it matches a taxonomy or term name already in use on your site. Please use a different prefix.', 'filter-everything' ),
1263 3991 => esc_html__( 'Error: var name is not allowed because it matches a taxonomy or term name already in use on your site.', 'filter-everything' ),
1264 40 => esc_html__( 'Error: invalid Meta key value', 'filter-everything' ),
1265 401 => esc_html__( 'Error: you must select or enter Meta Key', 'filter-everything' ),
1266 41 => esc_html__( 'Error: invalid View parameter.', 'filter-everything' ),
1267 42 => esc_html__( 'Error: invalid Logic parameter', 'filter-everything' ),
1268 43 => esc_html__( 'Error: invalid Orderby parameter', 'filter-everything' ),
1269 44 => esc_html__( 'Error: invalid exclude terms', 'filter-everything' ),
1270 45 => esc_html__( 'Error: for filter Post Author logic OR is acceptable only.', 'filter-everything' ),
1271 46 => esc_html__( 'Error: Post meta num filter can not be in URL path.', 'filter-everything' ),
1272 47 => esc_html__( 'Error: for filter Post Meta Num logic AND is acceptable only.', 'filter-everything' ),
1273 48 => esc_html__( 'Error: Range slider is acceptable for Post Meta Num filters only.', 'filter-everything' ),
1274 50 => esc_html__( 'Error: SEO Rule must have specified Post Type.', 'filter-everything' ),
1275 51 => esc_html__( 'Error: SEO Rule must contain at least one filter.', 'filter-everything' ),
1276 52 => esc_html__( 'Error: all SEO data fields could not be empty.', 'filter-everything' ),
1277 53 => esc_html__( 'Error: invalid or forbidden filter presents.', 'filter-everything' ),
1278 54 => esc_html__( 'Error: invalid SEO Rule ID.', 'filter-everything' ),
1279 55 => esc_html__( 'Error: SEO rule with selected Filters Combination already exists.', 'filter-everything' ),
1280 );
1281
1282 return $errors;
1283 }
1284 }