label; a values filter's choices, a * boolean's two words; a buckets filter's bucket keys), any (raw: the "any" choice's words), field (the row's field * when it is not the key), fields (two fields read as an interval) with match (overlaps, within), min, max, step, unit * (a number range's), placeholder, presets (0.64.4: a calendar's or a presets filter's preset keys), value (the value * when the address has none; 0.64.4: a preset's or a bucket's key too), counts (0.65.6: value => how many rows of the * view have it, said after each choice's label; true: a local table's glue counts them; either way the glue keeps them * current, a local table from its rows, a remote one from its `options` answer). * * The filter controls of 0.64.4 (CONTRACT §8.2, "The filter controls"): each writes the same two inputs as the range * fallback (`[from]`, `[to]`, data-ra-filter and data-ra-range) and sends `change` on them, so the glue, * the engine, the address, the remote request and the chips read them unchanged (assets/filters.js, loaded by the * first control a screen prints); without the script each is a GET form: the two dates (calendar, presets), the two * numbers (slider), the buckets' radios posted as `[bucket]` (buckets, read back by values()). * * @package RoosterAdmin */ namespace Rooster\Admin; defined( 'ABSPATH' ) || exit; /** * The declared filters: the bar, the values, the chips, the kinds for the glue. */ final class Filters { /** * The kinds a declaration may name. */ public const KINDS = array( 'values', 'range', 'text', 'boolean' ); /** * The controls a declaration may name, and what each prints (0.64.4: the calendar, the presets, the slider and the * buckets print themselves; they printed dates or inputs in 0.64.0). */ public const CONTROLS = array( 'select' => 'select', 'combo' => 'combo', 'segmented' => 'segmented', 'checks' => 'checks', 'chips' => 'chips', 'dates' => 'dates', 'inputs' => 'inputs', 'text' => 'text', 'calendar' => 'calendar', 'presets' => 'presets', 'slider' => 'slider', 'buckets' => 'buckets', ); /** * The controls of a range (0.64.4: the two fields, and the four controls that write them). */ public const RANGES = array( 'dates', 'inputs', 'calendar', 'presets', 'slider', 'buckets' ); /** * A calendar's presets, in their default order (0.64.4); a declaration's `presets` chooses and orders them (any * `d` is "Last n days"). Custom, the calendar itself, always closes the list. */ public const PRESETS = array( 'today', 'yesterday', '7d', '30d', '90d', 'this_month', 'last_month', 'this_year' ); /** * A presets filter's recency choices by default (0.64.4): from that many days before the site's today, open-ended. */ public const RECENCY = array( '7d', '30d', '90d' ); /** * A buckets filter's buckets by default (0.64.4): skewed counts. */ public const BUCKETS = array( '0', '1-10', '11-50', '50+' ); /** * The script of the four controls (0.64.4): registered by the loader beside the local table's glue, enqueued by the * first control a screen prints; on the front end rooster_table_engine( …, array( 'filters' => true ) ) loads its * twin, `rooster-table-filters`. */ public const HANDLE = 'rooster-admin-filters'; /** * The declarations, cleaned: a key each, a kind, a control that fits the kind. * * @param array $filters Ui::table()'s `filters`. * @return array> */ public static function clean( array $filters ): array { $out = array(); foreach ( $filters as $filter ) { $key = is_array( $filter ) ? (string) ( $filter['key'] ?? '' ) : ''; if ( 1 !== preg_match( '/^[A-Za-z][A-Za-z0-9_-]*$/', $key ) ) { continue; } $kind = in_array( $filter['kind'] ?? '', self::KINDS, true ) ? (string) $filter['kind'] : 'values'; $control = self::CONTROLS[ (string) ( $filter['control'] ?? '' ) ] ?? ''; if ( '' === $control || ( 'range' === $kind ) !== in_array( $control, self::RANGES, true ) ) { $control = array( 'values' => 'select', 'range' => 'inputs', 'text' => 'text', 'boolean' => 'segmented', )[ $kind ]; } if ( 'text' === $kind ) { $control = 'text'; } $out[] = array( 'key' => $key, 'kind' => $kind, 'control' => $control, 'asked' => (string) ( $filter['control'] ?? '' ), ) + $filter; } return $out; } /** * Each filter's value from a request (the address, or a remote request's `params`): a values filter's value or list * (a list for checks and chips: `key[]` or a comma list), a range's {from, to}, a text's words, a boolean's '1' or * '0'; the declaration's `value` when the request has none. For the product's query too: the first paint, a page * without the script and the remote route read the same. * * @param array $filters Ui::table()'s `filters`. * @param string $prefix The table's prefix (the address's names). * @param array|null $source The request (default: the address, $_GET); a remote route passes its * `params` and no prefix. * @return array */ public static function values( array $filters, string $prefix = '', ?array $source = null ): array { // The address (0.68.12): each filter's argument read by name, unslashed and sanitised (Request::values()). $asked = null === $source; if ( $asked ) { $names = array(); foreach ( self::clean( $filters ) as $filter ) { $names[] = $prefix . $filter['key']; } $source = Request::values( $names ); } $clean = $asked ? static fn( $v ): string => sanitize_text_field( (string) $v ) : static fn( $v ): string => sanitize_text_field( wp_unslash( (string) $v ) ); $out = array(); foreach ( self::clean( $filters ) as $filter ) { $raw = $source[ $prefix . $filter['key'] ] ?? null; if ( 'range' === $filter['kind'] ) { $out[ $filter['key'] ] = self::range_value( $filter, $raw, $clean ); continue; } if ( null === $raw ) { $raw = $filter['value'] ?? ''; } if ( self::is_list( $filter ) ) { $list = is_array( $raw ) ? $raw : ( '' === (string) $raw ? array() : explode( ',', (string) $raw ) ); $out[ $filter['key'] ] = array_values( array_filter( array_map( $clean, array_filter( $list, 'is_scalar' ) ), 'strlen' ) ); continue; } $out[ $filter['key'] ] = is_scalar( $raw ) ? $clean( $raw ) : ''; } return $out; } /** * The filter bar (a GET form: each filter its label over its control, Ui::filter(); the search box when asked; an * Apply button for a page without the script, which the script hides; Clear filters), the page's other address * parameters carried in hidden fields so the form returns to the same screen. * * @param string $table The table's id. * @param array $filters The declarations. * @param array $values values()'s. * @param array $opts prefix, search (true or {label, placeholder}: the table's search box), * typed (the search's value), keys (the address's names: s), caption; * detached (0.64.2: a form grid's bar, inside the grid's own form: no * form, no Apply, no names, so its controls are never posted nor make * the card Unsaved; a segmented choice is a select there). */ public static function bar( string $table, array $filters, array $values, array $opts = array() ): string { $prefix = (string) ( $opts['prefix'] ?? '' ); $apart = ! empty( $opts['detached'] ); $own = array(); $html = ''; $search = $opts['search'] ?? false; if ( false !== $search && null !== $search ) { $name = $apart ? '' : (string) ( $opts['keys']['s'] ?? ( $prefix . 's' ) ); $own[] = $name; $label = is_array( $search ) && '' !== (string) ( $search['label'] ?? '' ) ? (string) $search['label'] : __( 'Search', 'ai-eshop-optimizer' ); $id = $table . '-search'; $html .= Ui::filter( $label, Ui::input( $name, (string) ( $opts['typed'] ?? '' ), array( 'id' => $id, 'type' => 'search', 'icon' => 'search', 'placeholder' => is_array( $search ) ? (string) ( $search['placeholder'] ?? '' ) : '', 'attrs' => array( 'data-ra-search' => '' ), ) ), $id ); } // The few that answer the screen's question first, then the rest under "More filters" (0.68.23, the owner on a // report of 21 filters in a wall: "a mix of various functionalities in random almost layout"): a declaration's // `more` puts it there, in the same form, so it is posted and read as the others are; the summary says how many // of them are set (the chips under the table say which). // Filters about one thing stay together on one line under its name (0.68.23, the owner: "The menu, head etc // categories drop downs ought to be on the same line because they are dealing about the same thing"): the // declarations that follow one another with the same `group` (its words) are one group, which never wraps apart. $main = array(); $rest = array(); $set = 0; foreach ( self::clean( $filters ) as $filter ) { $own[] = $prefix . $filter['key']; $value = $values[ $filter['key'] ] ?? ''; $item = array( (string) ( $filter['group'] ?? '' ), self::field( $table, $filter, $value, $prefix, $apart ) ); if ( empty( $filter['more'] ) ) { $main[] = $item; continue; } $rest[] = $item; $set += self::is_set( $value ) ? 1 : 0; } $html .= self::grouped( $table, $main ); $more = self::grouped( $table, $rest ); if ( '' !== $more ) { $html .= '
' . Icons::svg( 'sliders-horizontal', 15 ) . esc_html__( 'More filters', 'ai-eshop-optimizer' ) /* translators: %s: how many of the filters under "More filters" are set. */ . ( $set > 0 ? ' , ' . esc_html( sprintf( _n( '%s filter set', '%s filters set', $set, 'ai-eshop-optimizer' ), number_format_i18n( $set ) ) ) . '' : '' ) . '
' . $more . '
'; } if ( $apart ) { /* translators: %s: the table's caption. */ $label = '' !== (string) ( $opts['caption'] ?? '' ) ? sprintf( __( 'Filters of %s', 'ai-eshop-optimizer' ), (string) $opts['caption'] ) : __( 'Filters', 'ai-eshop-optimizer' ); return ''; } $keep = ''; foreach ( Request::address() as $name => $value ) { // the page's own address, carried back (0.68.12: from the sanitised address, not the request's arguments) if ( ! in_array( $name, $own, true ) && ! str_starts_with( $name, '' !== $prefix ? $prefix : "\0" ) ) { $keep .= ''; } } $clear = remove_query_arg( array_merge( $own, array( $prefix . 'paged' ) ), self::here() ); /* translators: %s: the table's caption. */ $name = '' !== (string) ( $opts['caption'] ?? '' ) ? sprintf( __( 'Filters of %s', 'ai-eshop-optimizer' ), (string) $opts['caption'] ) : __( 'Filters', 'ai-eshop-optimizer' ); return ''; } /** * The fields in order, those that follow one another with the same group joined in one group (its name over its * fields, which keep their own labels; one item of the bar, so its fields never wrap apart). * * @param string $table The table's id (the group's id). * @param array $items Each field's group words and its HTML. */ private static function grouped( string $table, array $items ): string { $out = ''; $n = count( $items ); for ( $i = 0; $i < $n; $i++ ) { list( $group, $field ) = $items[ $i ]; if ( '' === $group ) { $out .= $field; continue; } $id = $table . '-group-' . $i; // by place: two groups may share words $fields = $field; while ( $i + 1 < $n && $items[ $i + 1 ][0] === $group ) { $fields .= $items[ ++$i ][1]; } $out .= '
' . esc_html( $group ) . '
' . $fields . '
'; } return $out; } /** * Whether a filter's value (values()'s) narrows the rows: a word, a list with something in it, a range with an end. * * @param mixed $value The value. */ private static function is_set( $value ): bool { if ( is_array( $value ) ) { foreach ( $value as $part ) { if ( is_scalar( $part ) && '' !== (string) $part ) { return true; } } return false; } return is_scalar( $value ) && '' !== (string) $value; } /** * One filter's field: its label over its control. * * @param string $table The table's id. * @param array $filter A clean declaration. * @param mixed $value Its value. * @param string $prefix The table's prefix. * @param bool $apart A form grid's bar (0.64.2): nameless controls, a segmented choice a select. */ private static function field( string $table, array $filter, $value, string $prefix, bool $apart = false ): string { $key = (string) $filter['key']; $name = $apart ? '' : $prefix . $key; if ( $apart && 'segmented' === $filter['control'] ) { $filter['control'] = 'select'; // radios need a name to be one group if ( 'boolean' === $filter['kind'] && ! is_array( $filter['options'] ?? null ) ) { $filter['options'] = array( '1' => __( 'Yes', 'ai-eshop-optimizer' ), '0' => __( 'No', 'ai-eshop-optimizer' ), ); } } if ( $apart && in_array( $filter['control'], array( 'calendar', 'presets', 'slider', 'buckets' ), true ) ) { $filter['control'] = in_array( $filter['control'], array( 'calendar', 'presets' ), true ) ? 'dates' : 'inputs'; // 0.64.4: their radios and parts are named; a form grid's bar is not } $id = $table . '-f-' . $key; $label = (string) ( $filter['label'] ?? $key ); $hook = array( 'data-ra-filter' => $key ); $counts = self::counts( $filter ); $tally = null !== $counts ? array( 'counts' => $counts ) : array(); // 0.65.6 if ( null !== $counts ) { $hook['data-ra-counted'] = ''; wp_enqueue_script( 'rooster-admin-table-counts' ); // their script, where a filter counts only } $mark = null !== $counts ? ' data-ra-counted=""' : ''; $any = array( '' => (string) ( $filter['any'] ?? __( 'Any', 'ai-eshop-optimizer' ) ) ); $opts = is_array( $filter['options'] ?? null ) ? array_map( 'strval', $filter['options'] ) : array(); switch ( $filter['control'] ) { case 'combo': return Ui::filter( $label, Ui::combo( $name, $any + $opts, (string) $value, array( 'id' => $id, 'attrs' => $hook ) + $tally ), $id ); case 'chips': return Ui::filter( $label, Ui::combo( $name, $opts, (array) $value, array( 'id' => $id, 'multiple' => true, 'summary' => true, 'placeholder' => (string) ( $filter['any'] ?? __( 'Any', 'ai-eshop-optimizer' ) ), 'attrs' => $hook ) + $tally ), $id ); case 'segmented': if ( 'boolean' === $filter['kind'] && array() === $opts ) { $opts = array( '1' => __( 'Yes', 'ai-eshop-optimizer' ), '0' => __( 'No', 'ai-eshop-optimizer' ), ); } return Ui::filter( $label, '' . Fields::segmented( $name, $any + $opts, (string) $value, array( 'label' => $label ) + $tally ) . '' ); case 'checks': return Ui::filter( $label, '' . Ui::checks( $name, $opts, array_map( 'strval', (array) $value ), array( 'inline' => true ) + $tally ) . '' ); case 'text': return Ui::filter( $label, Ui::input( $name, (string) $value, array( 'id' => $id, 'placeholder' => (string) ( $filter['placeholder'] ?? '' ), 'attrs' => $hook ) ), $id ); case 'dates': case 'inputs': return Ui::filter( $label, self::ends( $id, $name, $filter, $value, 'dates' === $filter['control'] ) ); case 'calendar': return Ui::filter( $label, self::calendar( $id, $name, $filter, $value ) ); case 'presets': return Ui::filter( $label, self::recency( $id, $name, $filter, $value ) ); case 'slider': return Ui::filter( $label, self::slider( $id, $name, $filter, $value ) ); case 'buckets': return self::buckets( $id, $name, $filter, $value ); default: return Ui::filter( $label, Ui::select( $name, $any + $opts, (string) $value, array( 'id' => $id, 'attrs' => $hook ) + $tally ), $id ); } } /** * A filter's counts (0.65.6, Rooster One's request 33): value => rows, the declaration's (true: none printed, a * local table's glue counts them); null when it counts nothing (a range or a text filter never does). * * @param array $filter A clean declaration. * @return array|null */ private static function counts( array $filter ): ?array { $counts = $filter['counts'] ?? null; if ( ( true !== $counts && ! is_array( $counts ) ) || in_array( $filter['kind'], array( 'range', 'text' ), true ) ) { return null; } $out = array(); foreach ( is_array( $counts ) ? $counts : array() as $value => $n ) { $out[ (string) $value ] = max( 0, (int) $n ); } return $out; } /** * A range's two fields (the `dates` and `inputs` controls; 0.64.4: the fallback of the four controls, which write * them): `[from]` and `[to]`, each with the filter's key and its end for the glue (data-ra-filter, * data-ra-range), a number's bounds and unit; a compound row (From, To) with its joiner, or with a part between them * (the slider's track). * * @param string $id The filter's id (its ends' are `-from` and `-to`). * @param string $name Its address name (the table's prefix and its key). * @param array $filter A clean declaration. * @param mixed $value Its value ({from, to}). * @param bool $date Two dates (else two numbers). * @param array $opts between (escaped HTML: a part between the two, in place of the joiner), width * (a number's: short, the default, or xs). */ private static function ends( string $id, string $name, array $filter, $value, bool $date, array $opts = array() ): string { $key = (string) $filter['key']; $label = (string) ( $filter['label'] ?? $key ); $range = is_array( $value ) ? $value : array(); $xs = ! $date && 'xs' === ( $opts['width'] ?? '' ); $part = static function ( string $end, string $sub ) use ( $name, $id, $key, $range, $date, $filter, $xs ): array { return array( 'label' => $sub, 'id' => $id . '-' . $end, 'control' => Ui::input( '' !== $name ? $name . '[' . $end . ']' : '', // a form grid's bar is nameless (0.64.2) (string) ( $range[ $end ] ?? '' ), array( 'id' => $id . '-' . $end, 'type' => $date ? 'date' : 'number', 'xs' => $xs, 'short' => ! $date, 'unit' => $date ? '' : (string) ( $filter['unit'] ?? '' ), 'attrs' => array_filter( array( 'data-ra-filter' => $key, 'data-ra-range' => $end, 'min' => $date ? null : ( $filter['min'] ?? null ), 'max' => $date ? null : ( $filter['max'] ?? null ), 'step' => $date ? null : ( $filter['step'] ?? null ), ), static fn( $v ): bool => null !== $v ), ) ), ); }; $between = (string) ( $opts['between'] ?? '' ); return Fields::compound( '' !== $between ? array( $part( 'from', __( 'From', 'ai-eshop-optimizer' ) ), array( 'control' => $between ), $part( 'to', __( 'To', 'ai-eshop-optimizer' ) ) ) : array( $part( 'from', __( 'From', 'ai-eshop-optimizer' ) ), $part( 'to', __( 'To', 'ai-eshop-optimizer' ) ) ), array( 'label' => $label, 'joiner' => '' !== $between ? '' : '–', ) ); } /** * The `calendar` control (0.64.4): one button that shows the chosen range and opens a popover of the presets and two * months, the range's two dates at its foot as the typing fields. The script makes it: without it, the popover's box * is the two date fields in place (the button, the presets and the months stay hidden), a GET form. * * @param string $id The filter's id. * @param string $name Its address name. * @param array $filter A clean declaration: presets (keys of PRESETS, `d`), any. * @param mixed $value Its value ({from, to}). */ private static function calendar( string $id, string $name, array $filter, $value ): string { $label = (string) ( $filter['label'] ?? $filter['key'] ); $any = (string) ( $filter['any'] ?? __( 'Any', 'ai-eshop-optimizer' ) ); $list = self::preset_words( $filter, false ); $conf = array( 'today' => self::today()->format( 'Y-m-d' ), 'week' => max( 0, min( 6, (int) get_option( 'start_of_week', 1 ) ) ), 'presets' => array_keys( $list ), 'words' => array( 'any' => $any, /* translators: %s: a range's start. */ 'from' => __( 'from %s', 'ai-eshop-optimizer' ), /* translators: %s: a range's end. */ 'to' => __( 'to %s', 'ai-eshop-optimizer' ), 'begin' => __( 'Choose the start date.', 'ai-eshop-optimizer' ), /* translators: %s: the start date chosen ("3 Nov 2026"). */ 'start' => __( 'Start %s. Now choose the end date.', 'ai-eshop-optimizer' ), /* translators: %s: a day ("Tuesday 3 November 2026"). */ 'first' => __( '%s, start of the range', 'ai-eshop-optimizer' ), /* translators: %s: a day ("Tuesday 3 November 2026"). */ 'last' => __( '%s, end of the range', 'ai-eshop-optimizer' ), /* translators: 1: a filter, 2: its range ("When: 1 – 30 Nov 2026"). */ 'chosen' => __( '%1$s: %2$s', 'ai-eshop-optimizer' ), ), ); $side = ''; foreach ( $list + array( 'custom' => __( 'Custom', 'ai-eshop-optimizer' ) ) as $preset => $words ) { $side .= ''; } $nav = static fn( string $words, string $dir ): string => Ui::button( $words, array( 'variant' => 'ghost', 'size' => 'sm', 'icon' => 'chevron-right', 'icon_only' => true, 'class' => 'rooster-admin-fc__nav is-' . ( '-1' === $dir ? 'prev' : 'next' ), 'attrs' => array( 'data-ra-fc-nav' => $dir ), ) ); wp_enqueue_script( self::HANDLE ); wp_enqueue_style( self::HANDLE ); return '
' . '' . '
' . '' . '' . '
' . self::ends( $id, $name, $filter, $value, true ) . Ui::button( __( 'Clear', 'ai-eshop-optimizer' ), array( 'variant' => 'ghost', 'size' => 'sm', 'attrs' => array( 'data-ra-fc-clear' => '', 'hidden' => true, ), ) ) . '
'; } /** * The `presets` control (0.64.4): recency, where exact dates rarely matter (last seen, last sold): a segmented choice * of Any and the declaration's presets (7, 30 and 90 days by default); a preset sets `from` that many days before the * site's today and `to` empty. The script shows it in place of the two date fields, which stand in without it (the * choice's radios post as `[preset]`, which values() leaves alone: the dates are the form's). * * @param string $id The filter's id. * @param string $name Its address name. * @param array $filter A clean declaration: presets (`d` keys), any. * @param mixed $value Its value ({from, to}). */ private static function recency( string $id, string $name, array $filter, $value ): string { $label = (string) ( $filter['label'] ?? $filter['key'] ); $list = self::preset_words( $filter, true ); $range = is_array( $value ) ? $value : array(); $chosen = '' === (string) ( $range['from'] ?? '' ) && '' === (string) ( $range['to'] ?? '' ) ? '' : '-'; foreach ( array_keys( $list ) as $preset ) { if ( self::preset_range( (string) $preset, true ) === array( 'from' => (string) ( $range['from'] ?? '' ), 'to' => (string) ( $range['to'] ?? '' ), ) ) { $chosen = (string) $preset; } } $conf = array( 'today' => self::today()->format( 'Y-m-d' ), 'presets' => array_keys( $list ), ); wp_enqueue_script( self::HANDLE ); wp_enqueue_style( self::HANDLE ); return '' . '' . '' . self::ends( $id, $name, $filter, $value, true ) . ''; } /** * The `slider` control (0.64.4): a track with two thumbs (role slider: the declaration's min, max, step and unit) bound * to the two number fields, between them on their line. The thumbs and the fields move each other, the thumbs never * cross, and `change` fires on release, so a remote table asks once; a thumb at its end leaves that end open. The * script shows the track: without it, the two number fields. Without both bounds there is no track: the two fields. * * @param string $id The filter's id. * @param string $name Its address name. * @param array $filter A clean declaration: min, max, step, unit. * @param mixed $value Its value ({from, to}). */ private static function slider( string $id, string $name, array $filter, $value ): string { $min = $filter['min'] ?? null; $max = $filter['max'] ?? null; if ( ! is_numeric( $min ) || ! is_numeric( $max ) || (float) $max <= (float) $min ) { return self::ends( $id, $name, $filter, $value, false ); } $min = (float) $min; $max = (float) $max; $step = is_numeric( $filter['step'] ?? null ) && (float) $filter['step'] > 0 ? (float) $filter['step'] : 1.0; $places = (int) strlen( (string) substr( strrchr( (string) $step, '.' ) ?: '', 1 ) ); $unit = (string) ( $filter['unit'] ?? '' ); $label = (string) ( $filter['label'] ?? $filter['key'] ); $range = is_array( $value ) ? $value : array(); $at = static fn( string $end, float $open ): float => is_numeric( $range[ $end ] ?? null ) ? max( $min, min( $max, (float) $range[ $end ] ) ) : $open; $from = $at( 'from', $min ); $to = max( $from, $at( 'to', $max ) ); $thumb = static function ( string $end, string $sub, float $now, float $low, float $high ) use ( $label, $unit, $places ): string { return ' 'rooster-admin-slider__thumb', 'role' => 'slider', 'tabindex' => '0', 'aria-label' => $label . ', ' . $sub, 'aria-valuemin' => (string) $low, 'aria-valuemax' => (string) $high, 'aria-valuenow' => (string) $now, 'aria-valuetext' => trim( number_format_i18n( $now, floor( $now ) === $now ? 0 : $places ) . ' ' . $unit ), 'data-ra-fc-thumb' => $end, ) ) . '>'; }; $track = ''; $conf = array( 'min' => $min, 'max' => $max, 'step' => $step, 'unit' => $unit, ); wp_enqueue_script( self::HANDLE ); wp_enqueue_style( self::HANDLE ); return '' . self::ends( $id, $name, $filter, $value, false, array( 'between' => $track, 'width' => strlen( (string) (int) max( abs( $min ), abs( $max ) ) ) + $places <= 4 ? 'xs' : 'short', ) ) . ''; } /** * The `buckets` field (0.64.4): skewed counts (hits, sales): Any and the declaration's buckets (`0`, `1-10`, `11-50`, * `50+` by default) as a segmented choice, or a select past four. Its radios (its select) post as `[bucket]`, * which values() turns into {from, to}, so the GET form filters without the script; the script writes the bucket's * ends into the two hidden inputs the glue reads (an `N+` bucket leaves `to` open). Its label over the control. * * @param string $id The filter's id (the select's). * @param string $name Its address name. * @param array $filter A clean declaration: options (bucket keys, or key => words), any. * @param mixed $value Its value ({from, to}). */ private static function buckets( string $id, string $name, array $filter, $value ): string { $key = (string) $filter['key']; $label = (string) ( $filter['label'] ?? $key ); $any = (string) ( $filter['any'] ?? __( 'Any', 'ai-eshop-optimizer' ) ); $list = self::bucket_words( $filter ); $range = array( 'from' => is_array( $value ) ? (string) ( $value['from'] ?? '' ) : '', 'to' => is_array( $value ) ? (string) ( $value['to'] ?? '' ) : '', ); $ends = array(); $chosen = '' === $range['from'] && '' === $range['to'] ? '' : '-'; foreach ( array_keys( $list ) as $bucket ) { $ends[ (string) $bucket ] = array_values( (array) self::bucket_range( (string) $bucket ) ); if ( self::bucket_range( (string) $bucket ) === $range ) { $chosen = (string) $bucket; } } $select = count( $list ) > 4; $choice = $select // a range that is no bucket (an address the glue wrote from typed ends) shows as itself in a select ? Ui::select( $name . '[bucket]', array( '' => $any ) + $list + ( '-' === $chosen ? array( '-' => self::range_words( $range['from'], $range['to'], (string) ( $filter['unit'] ?? '' ) ) ) : array() ), $chosen, array( 'id' => $id ) ) : Fields::segmented( $name . '[bucket]', array( '' => $any ) + $list, $chosen, array( 'label' => $label ) ); $hooks = ''; foreach ( array( 'from', 'to' ) as $end ) { $hooks .= ' 'hidden', 'value' => $range[ $end ], 'data-ra-filter' => $key, 'data-ra-range' => $end, ) ) . ' />'; } wp_enqueue_script( self::HANDLE ); wp_enqueue_style( self::HANDLE ); return Ui::filter( $label, '' . $choice . $hooks . '', $select ? $id : '' ); } /** * A range's value from a request (values(); 0.64.4): its two ends; a buckets filter's `bucket` when neither end is * set (its GET form without the script posts the bucket's key); the declaration's `value` when the request does not * name the filter (the two ends, or a preset's or a bucket's key). * * @param array $filter A clean declaration. * @param mixed $raw The request's value of the filter. * @param callable $clean A value cleaned. * @return array{from: string, to: string} */ private static function range_value( array $filter, $raw, callable $clean ): array { $ends = static fn( array $range ): array => array( 'from' => is_scalar( $range['from'] ?? null ) ? $clean( $range['from'] ) : '', 'to' => is_scalar( $range['to'] ?? null ) ? $clean( $range['to'] ) : '', ); if ( is_array( $raw ) ) { $range = $ends( $raw ); if ( '' === $range['from'] && '' === $range['to'] && 'buckets' === $filter['control'] && is_scalar( $raw['bucket'] ?? null ) ) { return self::bucket_range( $clean( $raw['bucket'] ) ) ?? $range; } return $range; } $given = $filter['value'] ?? null; if ( is_array( $given ) ) { return $ends( $given ); } $range = null; if ( is_string( $given ) && '' !== $given ) { $range = 'buckets' === $filter['control'] ? self::bucket_range( $given ) : self::preset_range( $given, 'presets' === $filter['control'] ); } return $range ?? array( 'from' => '', 'to' => '', ); } /** * A preset's dates from the site's today (0.64.4; assets/filters.js computes the same): a calendar's today, yesterday, * `d` (the last n days, today among them), this_month, last_month, this_year; a presets filter's `d` (recency: * from n days before today, the end open). Null for a key that is none. * * @param string $preset The key. * @param bool $recency A presets filter's (else a calendar's). * @param \DateTimeImmutable|null $today The day the presets count from (default: the site's today). * @return array{from: string, to: string}|null */ public static function preset_range( string $preset, bool $recency = false, ?\DateTimeImmutable $today = null ): ?array { $today = null !== $today ? $today->setTime( 12, 0 ) : self::today(); $day = static fn( \DateTimeImmutable $d ): string => $d->format( 'Y-m-d' ); $pair = static fn( string $from, string $to ): array => array( 'from' => $from, 'to' => $to, ); if ( 1 === preg_match( '/^([1-9]\d{0,3})d$/', $preset, $m ) ) { return $recency ? $pair( $day( $today->modify( '-' . $m[1] . ' days' ) ), '' ) : $pair( $day( $today->modify( '-' . ( (int) $m[1] - 1 ) . ' days' ) ), $day( $today ) ); } if ( $recency ) { return null; } switch ( $preset ) { case 'today': return $pair( $day( $today ), $day( $today ) ); case 'yesterday': return $pair( $day( $today->modify( '-1 day' ) ), $day( $today->modify( '-1 day' ) ) ); case 'this_month': return $pair( $day( $today->modify( 'first day of this month' ) ), $day( $today->modify( 'last day of this month' ) ) ); case 'last_month': return $pair( $day( $today->modify( 'first day of last month' ) ), $day( $today->modify( 'last day of last month' ) ) ); case 'this_year': return $pair( $today->format( 'Y' ) . '-01-01', $today->format( 'Y' ) . '-12-31' ); } return null; } /** * A bucket's ends (0.64.4): `N` is N to N, `A-B` (or `A–B`) A to B, `N+` from N with the end open; null for a key that * is none. * * @param string $bucket The key. * @return array{from: string, to: string}|null */ public static function bucket_range( string $bucket ): ?array { $num = '(\d+(?:\.\d+)?)'; $key = trim( str_replace( '–', '-', $bucket ) ); if ( 1 === preg_match( '/^' . $num . '$/', $key, $m ) ) { return array( 'from' => $m[1], 'to' => $m[1], ); } if ( 1 === preg_match( '/^' . $num . '\s*-\s*' . $num . '$/', $key, $m ) && (float) $m[1] <= (float) $m[2] ) { return array( 'from' => $m[1], 'to' => $m[2], ); } if ( 1 === preg_match( '/^' . $num . '\s*\+$/', $key, $m ) ) { return array( 'from' => $m[1], 'to' => '', ); } return null; } /** * A buckets filter's buckets and their words (0.64.4): its `options`, a list of keys (written with an en dash) or key * => words; BUCKETS by default. A key that is no bucket is dropped. * * @param array $filter A clean declaration. * @return array */ private static function bucket_words( array $filter ): array { $given = is_array( $filter['options'] ?? null ) && array() !== $filter['options'] ? $filter['options'] : self::BUCKETS; $keys = array_is_list( $given ) && array() === array_filter( $given, static fn( $v ): bool => ! is_scalar( $v ) || null === self::bucket_range( (string) $v ) ); $out = array(); foreach ( $given as $bucket => $words ) { $bucket = $keys ? (string) $words : (string) $bucket; if ( null !== self::bucket_range( $bucket ) ) { $out[ $bucket ] = $keys ? str_replace( '-', '–', $bucket ) : (string) $words; } } return $out; } /** * A calendar's or a presets filter's presets and their words (0.64.4): its `presets` that are keys, in its order; the * defaults (PRESETS, RECENCY) without them. * * @param array $filter A clean declaration. * @param bool $recency A presets filter's (`d` only, "7 days"; else a calendar's, "Last 7 days"). * @return array */ private static function preset_words( array $filter, bool $recency ): array { $asked = is_array( $filter['presets'] ?? null ) && array() !== $filter['presets'] ? array_map( 'strval', array_filter( $filter['presets'], 'is_scalar' ) ) : ( $recency ? self::RECENCY : self::PRESETS ); $named = array( 'today' => __( 'Today', 'ai-eshop-optimizer' ), 'yesterday' => __( 'Yesterday', 'ai-eshop-optimizer' ), 'this_month' => __( 'This month', 'ai-eshop-optimizer' ), 'last_month' => __( 'Last month', 'ai-eshop-optimizer' ), 'this_year' => __( 'This year', 'ai-eshop-optimizer' ), ); $out = array(); foreach ( $asked as $preset ) { if ( 1 === preg_match( '/^([1-9]\d{0,3})d$/', $preset, $m ) ) { $n = (int) $m[1]; $out[ $preset ] = $recency /* translators: %s: a number of days ("7 days": seen in the last 7 days). */ ? sprintf( _n( '%s day', '%s days', $n, 'ai-eshop-optimizer' ), number_format_i18n( $n ) ) /* translators: %s: a number of days. */ : sprintf( _n( 'Last %s day', 'Last %s days', $n, 'ai-eshop-optimizer' ), number_format_i18n( $n ) ); } elseif ( ! $recency && isset( $named[ $preset ] ) ) { $out[ $preset ] = $named[ $preset ]; } } return $out; } /** * The site's today, at noon (0.64.4: the presets' dates are the site's, not the server's or the browser's: a shop's * admin may sit in another time zone). */ private static function today(): \DateTimeImmutable { return current_datetime()->setTime( 12, 0 ); } /** * The active filters as chips (each removable: a link to the view without it, so a page without the script removes * it too) and Clear filters; '' when no filter is set. The glue redraws them after each view. * * @param array $filters The declarations. * @param array $values values()'s. * @param string $prefix The table's prefix. */ public static function chips( array $filters, array $values, string $prefix = '' ): string { $html = ''; $own = array( $prefix . 'paged' ); foreach ( self::clean( $filters ) as $filter ) { $key = (string) $filter['key']; $own[] = $prefix . $key; foreach ( self::chips_of( $filter, $values[ $key ] ?? '' ) as $chip ) { $url = remove_query_arg( $prefix . 'paged', self::here() ); if ( null === $chip['value'] ) { $url = remove_query_arg( $prefix . $key, $url ); } else { $rest = array_values( array_diff( (array) $values[ $key ], array( $chip['value'] ) ) ); $url = array() === $rest ? remove_query_arg( $prefix . $key, $url ) : add_query_arg( $prefix . $key, implode( ',', $rest ), $url ); } /* translators: %s: an active filter, "Status: Low". */ $remove = sprintf( __( 'Remove %s', 'ai-eshop-optimizer' ), $chip['text'] ); $html .= Ui::badge( $chip['text'], 'mono', array( 'chip' => true, 'after' => '' . Icons::ui( 'close', 12 ) . '', ) ); } } if ( '' === $html ) { return ''; } return $html . '' . esc_html__( 'Clear filters', 'ai-eshop-optimizer' ) . ''; } /** * One filter's chips: a value each for a list, one for a value, a range or a text ({text, value: the list value it * removes, or null: the whole filter}). * * @param array $filter A clean declaration. * @param mixed $value Its value. * @return array */ private static function chips_of( array $filter, $value ): array { $label = (string) ( $filter['label'] ?? $filter['key'] ); $words = self::words( $filter ); $named = static fn( string $v ): string => $words[ $v ] ?? $v; /* translators: 1: a filter, 2: its value ("Status: Low"). */ $chip = static fn( string $v ): string => sprintf( __( '%1$s: %2$s', 'ai-eshop-optimizer' ), $label, $v ); if ( 'range' === $filter['kind'] ) { $from = (string) ( $value['from'] ?? '' ); $to = (string) ( $value['to'] ?? '' ); if ( '' === $from && '' === $to ) { return array(); } return array( array( 'text' => $chip( self::range_words( self::is_date( $filter ) ? self::date_words( $from ) : $from, self::is_date( $filter ) ? self::date_words( $to ) : $to, (string) ( $filter['unit'] ?? '' ) ) ), 'value' => null, ), ); } if ( is_array( $value ) ) { return array_map( static fn( string $v ): array => array( 'text' => $chip( $named( $v ) ), 'value' => $v, ), array_map( 'strval', $value ) ); } if ( '' === (string) $value ) { return array(); } return array( array( 'text' => $chip( 'text' === $filter['kind'] ? '“' . $value . '”' : $named( (string) $value ) ), 'value' => null, ), ); } /** * A range's words: "10 – 50 €", "from 10 €", "to 50 €". * * @param string $from The start ('' open). * @param string $to The end ('' open). * @param string $unit The unit. */ private static function range_words( string $from, string $to, string $unit ): string { $u = '' !== $unit ? ' ' . $unit : ''; if ( '' !== $from && '' !== $to ) { return $from . ' – ' . $to . $u; } /* translators: %s: a range's start. */ return '' !== $from ? sprintf( __( 'from %s', 'ai-eshop-optimizer' ), $from . $u ) : sprintf( /* translators: %s: a range's end. */ __( 'to %s', 'ai-eshop-optimizer' ), $to . $u ); } /** * A filter's value words: its options, a boolean's Yes and No. * * @param array $filter A clean declaration. * @return array */ private static function words( array $filter ): array { $words = is_array( $filter['options'] ?? null ) ? array_map( 'strval', $filter['options'] ) : array(); if ( 'boolean' === $filter['kind'] && array() === $words ) { $words = array( '1' => __( 'Yes', 'ai-eshop-optimizer' ), '0' => __( 'No', 'ai-eshop-optimizer' ), ); } return array_combine( array_map( 'strval', array_keys( $words ) ), array_values( $words ) ) ?: array(); } /** * The kinds the glue needs (table-local.js: the engine's admits(), the chips it redraws, the remote request): key * => {kind, field, fields, match, label, words, unit, list}. * * @param array $filters The declarations. * @return array> */ public static function specs( array $filters ): array { $out = array(); foreach ( self::clean( $filters ) as $filter ) { $out[ $filter['key'] ] = array_filter( array( 'kind' => $filter['kind'], 'field' => (string) ( $filter['field'] ?? '' ), 'fields' => is_array( $filter['fields'] ?? null ) && 2 === count( $filter['fields'] ) ? array_values( array_map( 'strval', $filter['fields'] ) ) : null, 'match' => in_array( $filter['match'] ?? '', array( 'overlaps', 'within' ), true ) ? (string) $filter['match'] : '', 'label' => (string) ( $filter['label'] ?? $filter['key'] ), 'words' => (object) self::words( $filter ), 'unit' => (string) ( $filter['unit'] ?? '' ), 'list' => self::is_list( $filter ), 'date' => self::is_date( $filter ), // 0.65.0: its chip in the site's date format (date_conf()) ), static fn( $v ): bool => null !== $v && '' !== $v && false !== $v ); } return $out; } /** * The address the chips' and Clear filters' links start from: this request's, with a fresh view nonce in wp-admin * (0.69.0); '' without one (WP-CLI, a test). */ private static function here(): string { return Request::view_url( Request::uri() ); } /** * Whether a filter is a range of dates (0.65.0: its chip says the dates in the site's format). * * @param array $filter A clean declaration. */ private static function is_date( array $filter ): bool { return 'range' === $filter['kind'] && in_array( $filter['control'], array( 'dates', 'calendar', 'presets' ), true ); } /** * A day (Y-m-d) in the site's date format (the format the table's own cells print), else the value as it came. * * @param string $ymd A date. */ public static function date_words( string $ymd ): string { $day = 1 === preg_match( '/^\d{4}-\d{2}-\d{2}$/', $ymd ) ? \DateTimeImmutable::createFromFormat( '!Y-m-d', $ymd, wp_timezone() ) : false; return false !== $day ? (string) wp_date( (string) get_option( 'date_format', 'Y-m-d' ), $day->getTimestamp(), wp_timezone() ) : $ymd; } /** * What the glue needs to say a date as date_words() does (0.65.0): the site's date format and the locale's month and * day names; null for a table without a date range. * * @param array $filters The declarations. * @return array|null */ public static function date_conf( array $filters ): ?array { if ( array() === array_filter( self::clean( $filters ), array( self::class, 'is_date' ) ) ) { return null; } global $wp_locale; $months = array_values( (array) $wp_locale->month ); $days = array_values( (array) $wp_locale->weekday ); return array( 'format' => (string) get_option( 'date_format', 'Y-m-d' ), 'F' => $months, 'M' => array_map( static fn( string $m ): string => (string) $wp_locale->get_month_abbrev( $m ), $months ), 'l' => $days, 'D' => array_map( static fn( string $d ): string => (string) $wp_locale->get_weekday_abbrev( $d ), $days ), ); } /** * Whether a filter holds a list (checks, chips). * * @param array $filter A clean declaration. */ private static function is_list( array $filter ): bool { return 'values' === $filter['kind'] && in_array( $filter['control'], array( 'checks', 'chips' ), true ); } }