Plugin Directory

Changeset 3789662


Ignore:
Timestamp:
11/07/2026 01:22:55 PM (less than one hour ago)
Author:
fernandot
Message:

Competitors Search improved

Location:
contributor-resume
Files:
24 edited

Legend:

Unmodified
Added
Removed
  • contributor-resume/tags/1.6.0/assets/css/stats.css

    r3789183 r3789662  
    15551555}
    15561556
     1557/* The search to add a competitor (1.6.0) and the notice when the list is full. */
     1558
    15571559.cresume-stats-rival-add {
     1560        margin-top: 16px;
     1561        padding-top: 16px;
     1562        border-top: 1px solid #f0f0f1;
     1563}
     1564
     1565.cresume-stats-rival-add h3 {
     1566        margin: 0 0 8px;
     1567}
     1568
     1569.cresume-stats-rival-search {
    15581570        display: flex;
    15591571        flex-wrap: wrap;
    15601572        align-items: center;
    15611573        gap: 8px 12px;
    1562         margin-top: 16px;
    1563         padding-top: 16px;
    1564         border-top: 1px solid #f0f0f1;
    1565 }
    1566 
    1567 .cresume-stats-rival-add label[for] {
    1568         font-weight: 600;
    1569 }
    1570 
    1571 .cresume-stats-rival-add .description {
     1574}
     1575
     1576.cresume-stats-rival-search label[for] {
     1577        font-weight: 600;
     1578}
     1579
     1580.cresume-stats-rival-search .description {
    15721581        flex-basis: 100%;
    15731582        margin: 0;
     1583}
     1584
     1585.cresume-stats-search-results[aria-busy="true"] {
     1586        opacity: 0.6;
     1587}
     1588
     1589.cresume-stats-search-none {
     1590        margin: 12px 0 0;
     1591}
     1592
     1593.cresume-stats-suggest-card img.cresume-stats-rival-icon {
     1594        object-fit: cover;
     1595}
     1596
     1597.cresume-stats-rivals h2 .cresume-stats-chip {
     1598        margin-left: 6px;
     1599        vertical-align: middle;
     1600}
     1601
     1602.cresume-stats-rival-full.notice {
     1603        margin: 16px 0 0;
     1604}
     1605
     1606.cresume-stats-rival-full-note {
     1607        margin: 8px 0 0;
    15741608}
    15751609
  • contributor-resume/tags/1.6.0/assets/js/stats.js

    r3789183 r3789662  
    1212 * value/max of the <progress> element and in numeric SVG attributes. The
    1313 * keyword chips are drawn by core's tags box (wp-admin/js/tags-box.js), which
    14  * uses .text() for them.
     14 * uses .text() for them. The one exception is the search of competitors
     15 * (1.6.0): it inserts the results as the server drew and escaped them
     16 * (Cresume_Stats_View::search_results()), the same HTML the page prints
     17 * without JavaScript, and adds nothing of its own to it.
    1518 */
    1619( function () {
    … …  
    499502        }
    500503
     504        /**
     505         * Search WordPress.org while a competitor is typed (1.6.0). Without
     506         * JavaScript the form is a GET of the same view and Enter shows the
     507         * results; here the text is sent after a pause, an older request is
     508         * cancelled, and Enter searches at once without leaving the page.
     509         */
     510        function initRivalSearch() {
     511                var form = document.querySelector( '.cresume-stats-rival-search' );
     512                var input = form ? form.querySelector( 'input[name="rq"]' ) : null;
     513                var results = document.getElementById( 'cresume-stats-rival-results' );
     514                var status = document.getElementById( 'cresume-stats-rival-status' );
     515                var min;
     516                var timer = null;
     517                var controller = null;
     518                var last;
     519
     520                if ( ! form || ! input || ! results || ! status || ! cfg.rivalNonce || ! window.fetch ) {
     521                        return;
     522                }
     523
     524                min = parseInt( form.getAttribute( 'data-min' ), 10 ) || 2;
     525                last = input.value.trim();
     526
     527                function clear() {
     528                        while ( results.firstChild ) {
     529                                results.removeChild( results.firstChild );
     530                        }
     531                }
     532
     533                function run( text ) {
     534                        var body;
     535
     536                        if ( controller ) {
     537                                controller.abort();
     538                                controller = null;
     539                        }
     540
     541                        if ( text.length < min ) {
     542                                clear();
     543                                status.textContent = '';
     544
     545                                return;
     546                        }
     547
     548                        body = new window.FormData();
     549                        body.append( 'action', 'cresume_stats_rival_search' );
     550                        body.append( 'nonce', cfg.rivalNonce );
     551                        body.append( 'item', form.getAttribute( 'data-item' ) || '' );
     552                        body.append( 'rq', text );
     553
     554                        controller = window.AbortController ? new window.AbortController() : null;
     555                        status.textContent = i18n.searching || '';
     556                        results.setAttribute( 'aria-busy', 'true' );
     557
     558                        window
     559                                .fetch( cfg.ajaxUrl, {
     560                                        method: 'POST',
     561                                        credentials: 'same-origin',
     562                                        body: body,
     563                                        signal: controller ? controller.signal : undefined,
     564                                } )
     565                                .then( function ( response ) {
     566                                        return response.json();
     567                                } )
     568                                .then( function ( data ) {
     569                                        results.removeAttribute( 'aria-busy' );
     570
     571                                        if ( ! data || ! data.success || ! data.data ) {
     572                                                status.textContent = data && data.data && data.data.message ? String( data.data.message ) : i18n.failed || '';
     573
     574                                                return;
     575                                        }
     576
     577                                        // HTML drawn and escaped by Cresume_Stats_View::search_results().
     578                                        results.innerHTML = String( data.data.html || '' );
     579                                        status.textContent = String( data.data.status || '' );
     580                                } )
     581                                .catch( function ( error ) {
     582                                        if ( error && 'AbortError' === error.name ) {
     583                                                return;
     584                                        }
     585
     586                                        results.removeAttribute( 'aria-busy' );
     587                                        status.textContent = i18n.failed || '';
     588                                } );
     589                }
     590
     591                input.addEventListener( 'input', function () {
     592                        var text = input.value.trim();
     593
     594                        window.clearTimeout( timer );
     595
     596                        if ( text === last ) {
     597                                return;
     598                        }
     599
     600                        last = text;
     601                        timer = window.setTimeout( function () {
     602                                run( text );
     603                        }, 350 );
     604                } );
     605
     606                form.addEventListener( 'submit', function ( event ) {
     607                        event.preventDefault();
     608                        window.clearTimeout( timer );
     609                        last = input.value.trim();
     610                        run( last );
     611                } );
     612        }
     613
    501614        initPrefs();
    502615        initKeywordBox();
    … …  
    506619        initSwitcher();
    507620        initScale();
     621        initRivalSearch();
    508622}() );
  • contributor-resume/tags/1.6.0/changelog.txt

    r3789206 r3789662  
    55
    66= 1.6.0 =
    7 * New: Competitor tracking. Choose up to 10 competitors for each plugin or theme, by slug or WordPress.org address, or from suggestions taken from your own keywords, each with the reason it is there. They are read every day like your own, with two years of download history from the first day. Market leaders are suggested apart: a leader is followed like the rest but not counted when comparing who ranks better.
     7* New: Competitor tracking. Choose up to 10 competitors for each plugin or theme with a search of the directory by name or slug, with results as you type, or from suggestions taken from your own keywords, each with the reason it is there. They are read every day like your own, with two years of download history from the first day. Market leaders are suggested apart: a leader is followed like the rest but not counted when comparing who ranks better. When a plugin or theme has its 10, the screen says so and explains how to make room.
    88* New: Competitors view in Stats. Five figures, a sortable head-to-head table, a keyword-by-keyword table that shows where you rank better, one downloads chart for all of them (on a logarithmic scale or as an index that starts everyone at 100), your share of the top 10 of your keywords and the opportunities it shows.
    99* New: Competitor alerts, in the Alerts view and in the emails. A competitor passes you or falls behind, enters or leaves the top 10, ranks for one of your keywords for the first time, releases a version, says it is tested with a newer WordPress than yours, changes its installs range or rating, gets a spike in downloads without a release, goes 6 or 12 months without an update or is closed on WordPress.org.
    … …  
    1616* Improved: On the page of each plugin or theme, the downloads chart is a box you can move, fold or hide like the others, next to active installs and at the same height. Translations and version adoption line up as well.
    1717* Improved: The stats tables are updated automatically to keep the competitors and their positions.
     18* Improved: The dashboard widget settings ignore unexpected values without a PHP notice, and the rating bars are drawn empty when WordPress.org gives no breakdown of the ratings.
    1819
    1920= 1.5.4 =
  • contributor-resume/tags/1.6.0/includes/stats/class-cresume-stats-admin.php

    r3789183 r3789662  
    7878         * @var string[]
    7979         */
    80         const RIVAL_ERRORS = array( 'invalid', 'not_tracked', 'type', 'self', 'exists', 'full', 'missing', 'closed', 'unknown' );
     80        const RIVAL_ERRORS = array( 'not_tracked', 'self', 'exists', 'full', 'missing', 'closed', 'unknown', 'not_suggested', 'busy' );
    8181
    8282        /**
    … …  
    119119                add_action( 'wp_ajax_cresume_stats_collect', array( $this, 'ajax_collect' ) );
    120120                add_action( 'wp_ajax_cresume_stats_status', array( $this, 'ajax_status' ) );
     121                add_action( 'wp_ajax_cresume_stats_rival_search', array( $this, 'ajax_rival_search' ) );
    121122                add_filter( 'nonce_life', array( $this, 'view_nonce_life' ), 10, 2 );
    122123        }
    … …  
    284285                        'paged'   => 1,
    285286                        'ov'      => 'rivals',
     287                        'rq'      => '',
    286288                );
    287289        }
    … …  
    360362                }
    361363
     364                // The search of competitors without JavaScript (1.6.0): what was typed,
     365                // shown back in the field and sent to WordPress.org. Never in a link or
     366                // in the cookie: view_args() leaves it out.
     367                if ( 'rivals' === $state['view'] && isset( $_GET['rq'] ) && is_string( $_GET['rq'] ) ) {
     368                        $state['rq'] = self::search_text( sanitize_text_field( wp_unslash( $_GET['rq'] ) ) );
     369                }
     370
    362371                // The list of alerts: a topic of the closed list (anything else is all of
    363372                // them) and a page of 1 or more. Neither is remembered in the cookie.
    … …  
    716725                        'cresumeStats',
    717726                        array(
    718                                 'ajaxUrl' => admin_url( 'admin-ajax.php' ),
    719                                 'nonce'   => wp_create_nonce( 'cresume_admin' ),
    720                                 'running' => Cresume_Stats_Collector::is_running() || Cresume_Stats_Peek::is_running(),
    721                                 'cookie'  => array(
     727                                'ajaxUrl'    => admin_url( 'admin-ajax.php' ),
     728                                'nonce'      => wp_create_nonce( 'cresume_admin' ),
     729                                'running'    => Cresume_Stats_Collector::is_running() || Cresume_Stats_Peek::is_running(),
     730                                'cookie'     => array(
    722731                                        'name' => self::COOKIE,
    723732                                        'path' => (string) wp_parse_url( admin_url(), PHP_URL_PATH ),
    724733                                ),
    725                                 'i18n'    => array(
    726                                         'starting' => __( 'Starting…', 'contributor-resume' ),
    727                                         'failed'   => __( 'Something went wrong. Try again.', 'contributor-resume' ),
    728                                         'done'     => __( 'Collection finished.', 'contributor-resume' ),
     734                                'rivalNonce' => 'rivals' === $state['view'] ? wp_create_nonce( 'cresume_stats_rival_search' ) : '',
     735                                'i18n'       => array(
     736                                        'starting'  => __( 'Starting…', 'contributor-resume' ),
     737                                        'failed'    => __( 'Something went wrong. Try again.', 'contributor-resume' ),
     738                                        'done'      => __( 'Collection finished.', 'contributor-resume' ),
     739                                        'searching' => __( 'Searching WordPress.org…', 'contributor-resume' ),
    729740                                ),
    730741                        )
    731742                );
     743        }
     744
     745        /**
     746         * Text of a search of competitors: trimmed, with its spaces collapsed and
     747         * cut to Cresume_Stats_Fetcher::SEARCH_MAX characters.
     748         *
     749         * @param string $text Sanitized text.
     750         * @return string
     751         */
     752        public static function search_text( $text ) {
     753                $text = trim( preg_replace( '/\s+/u', ' ', (string) $text ) );
     754
     755                return mb_substr( $text, 0, Cresume_Stats_Fetcher::SEARCH_MAX, 'UTF-8' );
    732756        }
    733757
    … …  
    18281852                $item = self::rival_item( isset( $_POST['item'] ) ? absint( $_POST['item'] ) : 0 );
    18291853
    1830                 // A slug or an address: wp_strip_all_tags() keeps the %XX octets that
    1831                 // sanitize_text_field() would delete; parse_input() checks the rest.
    1832                 $raw    = isset( $_POST['competitor'] ) && is_string( $_POST['competitor'] ) ? wp_strip_all_tags( wp_unslash( $_POST['competitor'] ) ) : '';
     1854                // The slug of a search result or a suggestion (1.6.0: nobody types it);
     1855                // add() checks it against Cresume_Stats_Rivals::SLUG_PATTERN.
     1856                $slug   = isset( $_POST['competitor'] ) && is_string( $_POST['competitor'] ) ? sanitize_text_field( wp_unslash( $_POST['competitor'] ) ) : '';
    18331857                $leader = isset( $_POST['leader'] ) && '1' === sanitize_key( wp_unslash( $_POST['leader'] ) );
    1834                 $result = Cresume_Stats_Rivals::add( (int) $item->type, (string) $item->slug, $raw, $leader );
     1858                $result = Cresume_Stats_Rivals::add( (int) $item->type, (string) $item->slug, $slug, $leader );
    18351859
    18361860                if ( is_wp_error( $result ) ) {
    … …  
    20402064
    20412065        /**
     2066         * The search of competitors as you type (1.6.0): the results for one own
     2067         * item, as HTML drawn and escaped by Cresume_Stats_View::search_results(),
     2068         * the same function that draws them without JavaScript. Nonce, capability,
     2069         * an own item the stats follow and a text of 2 to SEARCH_MAX characters;
     2070         * only the text travels to WordPress.org, encoded (query_url()).
     2071         */
     2072        public function ajax_rival_search() {
     2073                check_ajax_referer( 'cresume_stats_rival_search', 'nonce' );
     2074                $this->authorize();
     2075
     2076                if ( ! Cresume_Stats::enabled() || ! Cresume_Stats_Schema::is_current() ) {
     2077                        wp_send_json_error( array( 'message' => __( 'Stats are off or their tables are not ready.', 'contributor-resume' ) ), 400 );
     2078                }
     2079
     2080                $item_id = isset( $_POST['item'] ) ? absint( $_POST['item'] ) : 0;
     2081                $item    = $item_id ? Cresume_Stats_Store::item( $item_id ) : null;
     2082
     2083                if ( ! $item || 1 !== (int) $item->role ) {
     2084                        wp_send_json_error( array( 'message' => Cresume_Stats_Rivals::error( 'cresume_rival_not_tracked' )->get_error_message() ), 400 );
     2085                }
     2086
     2087                $text = isset( $_POST['rq'] ) && is_string( $_POST['rq'] ) ? self::search_text( sanitize_text_field( wp_unslash( $_POST['rq'] ) ) ) : '';
     2088
     2089                ob_start();
     2090                $status = Cresume_Stats_View::search_results( $item, $text );
     2091                $html   = ob_get_clean();
     2092
     2093                wp_send_json_success(
     2094                        array(
     2095                                'html'   => (string) $html,
     2096                                'status' => $status,
     2097                        )
     2098                );
     2099        }
     2100
     2101        /**
    20422102         * Stop unless the user may manage the site.
    20432103         */
  • contributor-resume/tags/1.6.0/includes/stats/class-cresume-stats-cli.php

    r3789183 r3789662  
    377377         *
    378378         * <competitor>
    379          * : Slug of the competitor, or its address on WordPress.org.
     379         * : Slug of the competitor on WordPress.org.
    380380         *
    381381         * [--type=<type>]
    … …  
    391391         *
    392392         *     wp cresume stats competitors add vigilante ninjafirewall
    393          *     wp cresume stats competitors add vigilante https://wordpress.org/plugins/wordfence/ --leader
     393         *     wp cresume stats competitors add vigilante wordfence --leader
    394394         *
    395395         * @param array $args       Positional args.
    … …  
    400400
    401401                $type   = $this->type( $assoc_args );
    402                 $result = Cresume_Stats_Rivals::add( $type, sanitize_key( $args[0] ), $args[1], ! empty( $assoc_args['leader'] ) );
     402                // A slug as typed in the terminal: trimmed and in lowercase, then checked by add().
     403                $result = Cresume_Stats_Rivals::add( $type, sanitize_key( $args[0] ), strtolower( trim( (string) $args[1] ) ), ! empty( $assoc_args['leader'] ) );
    403404
    404405                if ( is_wp_error( $result ) ) {
  • contributor-resume/tags/1.6.0/includes/stats/class-cresume-stats-fetcher.php

    r3789183 r3789662  
    5757         */
    5858        const INFO_MAX = 100;
     59
     60        /**
     61         * Results of the search of competitors (1.6.0).
     62         *
     63         * @var int
     64         */
     65        const SEARCH_RESULTS = 10;
     66
     67        /**
     68         * Characters a search of competitors needs before it asks WordPress.org.
     69         *
     70         * @var int
     71         */
     72        const SEARCH_MIN = 2;
     73
     74        /**
     75         * Characters of a search of competitors, at most.
     76         *
     77         * @var int
     78         */
     79        const SEARCH_MAX = 100;
     80
     81        /**
     82         * Seconds a search of competitors is kept.
     83         *
     84         * @var int
     85         */
     86        const SEARCH_CACHE = 900;
     87
     88        /**
     89         * Prefix of the transients of the search of competitors. uninstall.php
     90         * deletes them by this prefix, even when the data is kept.
     91         *
     92         * @var string
     93         */
     94        const SEARCH_PREFIX = 'cresume_srch_';
    5995
    6096        /**
    … …  
    308344                }
    309345
    310                 $icon = '';
    311 
    312                 if ( $is_theme ) {
    313                         $icon = isset( $raw['screenshot_url'] ) ? (string) $raw['screenshot_url'] : '';
    314 
    315                         // The Themes API returns protocol-relative screenshot URLs.
    316                         if ( 0 === strpos( $icon, '//' ) ) {
    317                                 $icon = 'https:' . $icon;
    318                         }
    319                 } elseif ( isset( $raw['icons'] ) && is_array( $raw['icons'] ) ) {
    320                         foreach ( array( '2x', '1x', 'svg', 'default' ) as $size ) {
    321                                 if ( ! empty( $raw['icons'][ $size ] ) ) {
    322                                         $icon = (string) $raw['icons'][ $size ];
    323                                         break;
    324                                 }
    325                         }
    326                 }
    327 
    328                 $icon = esc_url_raw( $icon, array( 'https' ) );
     346                $icon = self::icon_of( $is_theme, $raw );
    329347
    330348                $updated = '';
    … …  
    338356                        'slug'    => $slug,
    339357                        'name'    => self::clean_text( isset( $raw['name'] ) ? $raw['name'] : $slug, 255 ),
    340                         'icon'    => strlen( $icon ) <= 255 ? $icon : '',
     358                        'icon'    => $icon,
    341359                        'added'   => self::clean_date( isset( $raw['added'] ) ? $raw['added'] : '' ),
    342360                        'tags'    => $is_theme ? array() : self::clean_tags( isset( $raw['tags'] ) ? $raw['tags'] : array() ),
    … …  
    358376
    359377        /**
     378         * The icon of a plugin, or the screenshot of a theme, as an https address
     379         * of 255 characters at most, or ''.
     380         *
     381         * @param bool  $is_theme Whether it is a theme.
     382         * @param array $raw      Raw API item.
     383         * @return string
     384         */
     385        private static function icon_of( $is_theme, array $raw ) {
     386                $icon = '';
     387
     388                if ( $is_theme ) {
     389                        $icon = isset( $raw['screenshot_url'] ) && is_string( $raw['screenshot_url'] ) ? $raw['screenshot_url'] : '';
     390
     391                        // The Themes API returns protocol-relative screenshot URLs.
     392                        if ( 0 === strpos( $icon, '//' ) ) {
     393                                $icon = 'https:' . $icon;
     394                        }
     395                } elseif ( isset( $raw['icons'] ) && is_array( $raw['icons'] ) ) {
     396                        foreach ( array( '2x', '1x', 'svg', 'default' ) as $size ) {
     397                                if ( ! empty( $raw['icons'][ $size ] ) && is_string( $raw['icons'][ $size ] ) ) {
     398                                        $icon = $raw['icons'][ $size ];
     399                                        break;
     400                                }
     401                        }
     402                }
     403
     404                $icon = esc_url_raw( $icon, array( 'https' ) );
     405
     406                return strlen( $icon ) <= 255 ? $icon : '';
     407        }
     408
     409        /**
     410         * Search the directory to add a competitor (1.6.0): what a person types,
     411         * matched by WordPress.org against slugs and names, in the order of the web
     412         * search. Measured on 7 Nov 2026: the slug comes first («wpo-tweaks» and
     413         * «wpo tweaks» find WPO Tweaks, whose name is DietPress; «/vigilante/»
     414         * finds Vigilant), so no lookup by slug is needed. Closed plugins never
     415         * come back. About 5 KB for 10 results.
     416         *
     417         * The answer is kept SEARCH_CACHE seconds per type and text, so typing the
     418         * same search again asks nothing.
     419         *
     420         * @param int    $type  Type constant.
     421         * @param string $query Text typed, already trimmed and cut to SEARCH_MAX.
     422         * @return array|WP_Error ['results' => int, 'list' => [['slug', 'name',
     423         *                        'icon', 'installs', 'rating', 'num_ratings'], …]].
     424         */
     425        public function search( $type, $query ) {
     426                $type  = Cresume_Stats_Store::TYPE_THEME === (int) $type ? Cresume_Stats_Store::TYPE_THEME : Cresume_Stats_Store::TYPE_PLUGIN;
     427                $query = (string) $query;
     428                $key   = self::SEARCH_PREFIX . md5( $type . '|' . mb_strtolower( $query, 'UTF-8' ) );
     429                $kept  = get_transient( $key );
     430
     431                if ( is_array( $kept ) && isset( $kept['results'], $kept['list'] ) ) {
     432                        return $kept;
     433                }
     434
     435                $is_theme = Cresume_Stats_Store::TYPE_THEME === $type;
     436                $args     = array(
     437                        'action'            => $is_theme ? 'query_themes' : 'query_plugins',
     438                        // Encoded here: add_query_arg() does not encode values (see query_url()).
     439                        'request[search]'   => rawurlencode( $query ),
     440                        'request[per_page]' => self::SEARCH_RESULTS,
     441                        'request[page]'     => 1,
     442                );
     443
     444                foreach ( self::search_fields( $type ) as $field => $on ) {
     445                        $args[ 'request[fields][' . $field . ']' ] = $on;
     446                }
     447
     448                $data = $this->get_json( add_query_arg( $args, $is_theme ? self::THEMES_API : self::PLUGINS_API ) );
     449
     450                if ( is_wp_error( $data ) ) {
     451                        return $data;
     452                }
     453
     454                $list_key = $is_theme ? 'themes' : 'plugins';
     455
     456                if ( ! is_array( $data ) || ! isset( $data[ $list_key ], $data['info'] ) || ! is_array( $data[ $list_key ] ) ) {
     457                        return self::bad_payload();
     458                }
     459
     460                $list = array();
     461
     462                foreach ( $data[ $list_key ] as $row ) {
     463                        $slug = is_array( $row ) && isset( $row['slug'] ) && is_string( $row['slug'] ) ? $row['slug'] : '';
     464
     465                        if ( ! preg_match( '/^[a-z0-9-]{1,191}$/', $slug ) || isset( $list[ $slug ] ) ) {
     466                                continue;
     467                        }
     468
     469                        $list[ $slug ] = array(
     470                                'slug'        => $slug,
     471                                'name'        => self::clean_text( isset( $row['name'] ) && is_scalar( $row['name'] ) ? $row['name'] : $slug, 120 ),
     472                                'icon'        => self::icon_of( $is_theme, $row ),
     473                                'installs'    => isset( $row['active_installs'] ) ? absint( $row['active_installs'] ) : 0,
     474                                'rating'      => isset( $row['rating'] ) ? min( 100, absint( $row['rating'] ) ) : 0,
     475                                'num_ratings' => isset( $row['num_ratings'] ) ? absint( $row['num_ratings'] ) : 0,
     476                        );
     477
     478                        if ( count( $list ) >= self::SEARCH_RESULTS ) {
     479                                break;
     480                        }
     481                }
     482
     483                $out = array(
     484                        'results' => isset( $data['info']['results'] ) ? absint( $data['info']['results'] ) : count( $list ),
     485                        'list'    => array_values( $list ),
     486                );
     487
     488                set_transient( $key, $out, self::SEARCH_CACHE );
     489
     490                return $out;
     491        }
     492
     493        /**
     494         * Fields asked for in the search of competitors: what a result shows.
     495         *
     496         * @param int $type Type constant.
     497         * @return array
     498         */
     499        private static function search_fields( $type ) {
     500                if ( Cresume_Stats_Store::TYPE_THEME === (int) $type ) {
     501                        return array(
     502                                'active_installs' => 1,
     503                                'rating'          => 1,
     504                                'num_ratings'     => 1,
     505                                'screenshot_url'  => 1,
     506                                'description'     => 0,
     507                                'sections'        => 0,
     508                                'ratings'         => 0,
     509                                'downloaded'      => 0,
     510                                'last_updated'    => 0,
     511                                'homepage'        => 0,
     512                                'tags'            => 0,
     513                                'versions'        => 0,
     514                                'theme_url'       => 0,
     515                        );
     516                }
     517
     518                return array(
     519                        'active_installs'   => 1,
     520                        'rating'            => 1,
     521                        'num_ratings'       => 1,
     522                        'icons'             => 1,
     523                        'short_description' => 0,
     524                        'description'       => 0,
     525                        'sections'          => 0,
     526                        'tags'              => 0,
     527                        'versions'          => 0,
     528                        'contributors'      => 0,
     529                        'screenshots'       => 0,
     530                        'banners'           => 0,
     531                        'compatibility'     => 0,
     532                        'reviews'           => 0,
     533                        'ratings'           => 0,
     534                        'downloaded'        => 0,
     535                        'last_updated'      => 0,
     536                        'added'             => 0,
     537                        'homepage'          => 0,
     538                        'donate_link'       => 0,
     539                        'tested'            => 0,
     540                        'requires'          => 0,
     541                        'requires_php'      => 0,
     542                );
     543        }
     544
     545        /**
    360546         * Daily downloads, oldest first, up to yesterday (UTC).
    361547         *
  • contributor-resume/tags/1.6.0/includes/stats/class-cresume-stats-lock.php

    r3789183 r3789662  
    108108
    109109        /**
    110          * Drop what the options cache may remember about the lock.
     110         * Drop what the options cache may remember about an option: the lock, or
     111         * (1.6.0) the option a lock guards, so it is read from the database once
     112         * the lock is taken.
    111113         *
    112114         * @param string $name Option name.
    113115         */
    114         private static function forget( $name ) {
     116        public static function forget( $name ) {
    115117                wp_cache_delete( $name, 'options' );
    116118
  • contributor-resume/tags/1.6.0/includes/stats/class-cresume-stats-rivals.php

    r3789183 r3789662  
    9090
    9191        /**
     92         * Lock of the choice (1.6.0): a row of its own in the options table, taken
     93         * with Cresume_Stats_Lock. Deleted by uninstall.php.
     94         *
     95         * @var string
     96         */
     97        const LOCK_OPTION = 'cresume_stats_rivals_lock';
     98
     99        /**
     100         * Seconds the lock outlives a request that died holding it.
     101         *
     102         * @var int
     103         */
     104        const LOCK_TTL = 30;
     105
     106        /**
     107         * Seconds a change waits for another one in progress.
     108         *
     109         * @var int
     110         */
     111        const LOCK_WAIT = 2;
     112
     113        /**
    92114         * Key of an own item.
    93115         *
    … …  
    594616
    595617        /**
    596          * Read what the user typed: a slug, or the address of a plugin or theme on
    597          * WordPress.org (any locale subdomain, with or without scheme, `www.`,
    598          * trailing slash, query or fragment).
    599          *
    600          * The address goes through esc_url_raw() (never sanitize_text_field(),
    601          * which deletes the %XX octets) and its path is cut by hand; the slug that
    602          * comes out must match SLUG_PATTERN, so nothing else reaches the API.
    603          *
    604          * @param mixed $raw Text typed by the user.
    605          * @return array|WP_Error ['slug' => string, 'type' => int], type 0 when a bare
    606          *                        slug does not say whether it is a plugin or a theme.
    607          */
    608         public static function parse_input( $raw ) {
    609                 if ( ! is_string( $raw ) ) {
    610                         return self::error( 'cresume_rival_invalid' );
    611                 }
    612 
    613                 $raw = trim( $raw );
    614 
    615                 if ( '' === $raw || strlen( $raw ) > 500 ) {
    616                         return self::error( 'cresume_rival_invalid' );
    617                 }
    618 
    619                 $slug = strtolower( $raw );
    620 
    621                 if ( preg_match( self::SLUG_PATTERN, $slug ) ) {
    622                         return array(
    623                                 'slug' => $slug,
    624                                 'type' => 0,
    625                         );
    626                 }
    627 
    628                 $url = esc_url_raw( $raw, array( 'http', 'https' ) );
    629 
    630                 if ( '' === $url || ! preg_match( '#^https?://(?:[a-z0-9-]+\.)?wordpress\.org(/[^?\#]*)?(?:[?\#].*)?$#i', $url, $match ) ) {
    631                         return self::error( 'cresume_rival_invalid' );
    632                 }
    633 
    634                 $path = isset( $match[1] ) ? $match[1] : '';
    635 
    636                 if ( ! preg_match( '#^/(plugins|themes)/([^/]+)/?$#i', $path, $parts ) ) {
    637                         return self::error( 'cresume_rival_invalid' );
    638                 }
    639 
    640                 $slug = strtolower( rawurldecode( $parts[2] ) );
    641 
    642                 if ( ! preg_match( self::SLUG_PATTERN, $slug ) ) {
    643                         return self::error( 'cresume_rival_invalid' );
    644                 }
    645 
    646                 return array(
    647                         'slug' => $slug,
    648                         'type' => 'themes' === strtolower( $parts[1] ) ? Cresume_Stats_Store::TYPE_THEME : Cresume_Stats_Store::TYPE_PLUGIN,
    649                 );
    650         }
    651 
    652         /**
    653618         * Add a competitor to an own item, checked against WordPress.org.
     619         *
     620         * The slug comes from a result of the search, a suggestion or WP-CLI, never
     621         * typed in a field (1.6.0: the field for a slug or an address is gone), so it
     622         * is only checked against SLUG_PATTERN. What can be decided without asking
     623         * WordPress.org is decided first, and checked again under the lock before
     624         * saving.
    654625         *
    655626         * @param int    $type     Type constant of the own item.
    656627         * @param string $own_slug Slug of the own item.
    657          * @param mixed  $input    Slug or address typed by the user.
     628         * @param mixed  $slug     Slug of the competitor.
    658629         * @param bool   $leader   Whether it is added as a leader.
    659630         * @return array|WP_Error ['slug' => string, 'info' => array] with the answer of
    … …  
    661632         *                        Cresume_Stats_Collector::rival_now().
    662633         */
    663         public static function add( $type, $own_slug, $input, $leader = false ) {
     634        public static function add( $type, $own_slug, $slug, $leader = false ) {
    664635                $type = Cresume_Stats_Store::TYPE_THEME === (int) $type ? Cresume_Stats_Store::TYPE_THEME : Cresume_Stats_Store::TYPE_PLUGIN;
    665636                $own  = self::tracked( $type, $own_slug );
    … …  
    669640                }
    670641
    671                 $parsed = self::parse_input( $input );
    672 
    673                 if ( is_wp_error( $parsed ) ) {
    674                         return $parsed;
    675                 }
    676 
    677                 if ( $parsed['type'] && $parsed['type'] !== $type ) {
    678                         return self::error( 'cresume_rival_type' );
    679                 }
    680 
    681                 $slug = $parsed['slug'];
     642                if ( ! is_string( $slug ) || ! preg_match( self::SLUG_PATTERN, $slug ) ) {
     643                        return self::error( 'cresume_rival_missing' );
     644                }
    682645
    683646                if ( $slug === (string) $own->slug ) {
    … …  
    685648                }
    686649
    687                 $all  = self::all();
    688                 $key  = self::key( $type, $own->slug );
    689                 $mine = isset( $all[ $key ] ) ? $all[ $key ] : array(
    690                         'r' => array(),
    691                         'l' => array(),
    692                         'h' => array(),
    693                 );
     650                $mine = self::of( $type, $own->slug );
    694651
    695652                if ( in_array( $slug, $mine['r'], true ) ) {
    … …  
    717674                }
    718675
    719                 $mine['r'][] = $slug;
    720 
    721                 if ( $leader ) {
    722                         $mine['l'][] = $slug;
    723                 }
    724 
    725                 $mine['h']   = array_values( array_diff( $mine['h'], array( $slug ) ) );
    726                 $all[ $key ] = $mine;
    727                 self::save( $all );
     676                $key   = self::key( $type, $own->slug );
     677                $saved = self::locked(
     678                        function () use ( $key, $slug, $leader ) {
     679                                $all  = self::all();
     680                                $mine = isset( $all[ $key ] ) ? $all[ $key ] : self::blank();
     681
     682                                // Another administrator may have added it, or filled the list, meanwhile.
     683                                if ( in_array( $slug, $mine['r'], true ) ) {
     684                                        return self::error( 'cresume_rival_exists' );
     685                                }
     686
     687                                if ( count( $mine['r'] ) >= self::MAX_PER_ITEM ) {
     688                                        return self::error( 'cresume_rival_full' );
     689                                }
     690
     691                                $mine['r'][] = $slug;
     692
     693                                if ( $leader ) {
     694                                        $mine['l'][] = $slug;
     695                                }
     696
     697                                $mine['h']   = array_values( array_diff( $mine['h'], array( $slug ) ) );
     698                                $all[ $key ] = $mine;
     699                                self::save( $all );
     700
     701                                return true;
     702                        }
     703                );
     704
     705                if ( is_wp_error( $saved ) ) {
     706                        return $saved;
     707                }
    728708
    729709                return array(
    … …  
    743723         */
    744724        public static function remove( $type, $own_slug, $slug ) {
    745                 $all = self::all();
    746                 $key = self::key( $type, $own_slug );
    747 
    748                 if ( ! isset( $all[ $key ] ) || ! in_array( $slug, $all[ $key ]['r'], true ) ) {
    749                         return self::error( 'cresume_rival_unknown' );
    750                 }
    751 
    752                 $all[ $key ]['r'] = array_values( array_diff( $all[ $key ]['r'], array( $slug ) ) );
    753                 $all[ $key ]['l'] = array_values( array_diff( $all[ $key ]['l'], array( $slug ) ) );
    754                 self::save( $all );
     725                $key   = self::key( $type, $own_slug );
     726                $saved = self::locked(
     727                        function () use ( $key, $slug ) {
     728                                $all = self::all();
     729
     730                                if ( ! isset( $all[ $key ] ) || ! in_array( $slug, $all[ $key ]['r'], true ) ) {
     731                                        return self::error( 'cresume_rival_unknown' );
     732                                }
     733
     734                                $all[ $key ]['r'] = array_values( array_diff( $all[ $key ]['r'], array( $slug ) ) );
     735                                $all[ $key ]['l'] = array_values( array_diff( $all[ $key ]['l'], array( $slug ) ) );
     736                                self::save( $all );
     737
     738                                return true;
     739                        }
     740                );
     741
     742                if ( is_wp_error( $saved ) ) {
     743                        return $saved;
     744                }
     745
    755746                self::release_unused();
    756747                Cresume_Stats_Store::touch();
    … …  
    760751
    761752        /**
    762          * Hide a suggestion for an own item.
     753         * Hide a suggestion for an own item. Only one of the suggestions it has now
     754         * (1.6.0): a forged slug that nothing suggests is not stored.
    763755         *
    764756         * @param int    $type     Type constant.
    … …  
    768760         */
    769761        public static function hide( $type, $own_slug, $slug ) {
    770                 if ( ! is_string( $slug ) || ! preg_match( self::SLUG_PATTERN, $slug ) ) {
    771                         return self::error( 'cresume_rival_invalid' );
    772                 }
    773 
    774                 $all = self::all();
    775                 $key = self::key( $type, $own_slug );
    776 
    777                 if ( ! self::tracked( $type, $own_slug ) ) {
     762                $own = self::tracked( $type, $own_slug );
     763
     764                if ( ! $own ) {
    778765                        return self::error( 'cresume_rival_not_tracked' );
    779766                }
    780767
    781                 $mine = isset( $all[ $key ] ) ? $all[ $key ] : array(
    782                         'r' => array(),
    783                         'l' => array(),
    784                         'h' => array(),
    785                 );
    786 
    787                 if ( in_array( $slug, $mine['r'], true ) ) {
     768                $key = self::key( $type, $own->slug );
     769
     770                if ( is_string( $slug ) && in_array( $slug, self::of( $type, $own->slug )['r'], true ) ) {
    788771                        return self::error( 'cresume_rival_exists' );
    789772                }
    790773
    791                 if ( ! in_array( $slug, $mine['h'], true ) ) {
    792                         array_unshift( $mine['h'], $slug );
    793                         $mine['h'] = array_slice( $mine['h'], 0, self::MAX_HIDDEN );
    794                 }
    795 
    796                 $all[ $key ] = $mine;
    797                 self::save( $all );
    798 
    799                 return true;
     774                if ( ! is_string( $slug ) || ! preg_match( self::SLUG_PATTERN, $slug ) || ! in_array( $slug, self::suggested( (int) $own->id ), true ) ) {
     775                        return self::error( 'cresume_rival_not_suggested' );
     776                }
     777
     778                return self::locked(
     779                        function () use ( $key, $slug ) {
     780                                $all  = self::all();
     781                                $mine = isset( $all[ $key ] ) ? $all[ $key ] : self::blank();
     782
     783                                if ( in_array( $slug, $mine['r'], true ) ) {
     784                                        return self::error( 'cresume_rival_exists' );
     785                                }
     786
     787                                if ( ! in_array( $slug, $mine['h'], true ) ) {
     788                                        array_unshift( $mine['h'], $slug );
     789                                        $mine['h'] = array_slice( $mine['h'], 0, self::MAX_HIDDEN );
     790                                }
     791
     792                                $all[ $key ] = $mine;
     793                                self::save( $all );
     794
     795                                return true;
     796                        }
     797                );
     798        }
     799
     800        /**
     801         * Slugs suggested now for an own item, leaders included: what hide() takes.
     802         *
     803         * @param int $item_id Own item id.
     804         * @return string[]
     805         */
     806        private static function suggested( $item_id ) {
     807                $suggest = self::suggest( $item_id );
     808                $slugs   = array();
     809
     810                foreach ( array_merge( $suggest['list'], $suggest['leaders'] ) as $cand ) {
     811                        $slugs[] = (string) $cand['slug'];
     812                }
     813
     814                return $slugs;
    800815        }
    801816
    … …  
    809824         */
    810825        public static function unhide( $type, $own_slug, $slug ) {
    811                 $all = self::all();
    812826                $key = self::key( $type, $own_slug );
    813827
    814                 if ( ! isset( $all[ $key ] ) || ! in_array( $slug, $all[ $key ]['h'], true ) ) {
    815                         return self::error( 'cresume_rival_unknown' );
    816                 }
    817 
    818                 $all[ $key ]['h'] = array_values( array_diff( $all[ $key ]['h'], array( $slug ) ) );
    819                 self::save( $all );
    820 
    821                 return true;
     828                return self::locked(
     829                        function () use ( $key, $slug ) {
     830                                $all = self::all();
     831
     832                                if ( ! isset( $all[ $key ] ) || ! in_array( $slug, $all[ $key ]['h'], true ) ) {
     833                                        return self::error( 'cresume_rival_unknown' );
     834                                }
     835
     836                                $all[ $key ]['h'] = array_values( array_diff( $all[ $key ]['h'], array( $slug ) ) );
     837                                self::save( $all );
     838
     839                                return true;
     840                        }
     841                );
    822842        }
    823843
    … …  
    833853         */
    834854        public static function set_leader( $type, $own_slug, $slug, $leader ) {
    835                 $all = self::all();
    836855                $key = self::key( $type, $own_slug );
    837856
    838                 if ( ! isset( $all[ $key ] ) || ! in_array( $slug, $all[ $key ]['r'], true ) ) {
    839                         return self::error( 'cresume_rival_unknown' );
    840                 }
    841 
    842                 $l = array_values( array_diff( $all[ $key ]['l'], array( $slug ) ) );
    843 
    844                 if ( $leader ) {
    845                         $l[] = $slug;
    846                 }
    847 
    848                 $all[ $key ]['l'] = $l;
    849                 self::save( $all );
    850 
    851                 return true;
     857                return self::locked(
     858                        function () use ( $key, $slug, $leader ) {
     859                                $all = self::all();
     860
     861                                if ( ! isset( $all[ $key ] ) || ! in_array( $slug, $all[ $key ]['r'], true ) ) {
     862                                        return self::error( 'cresume_rival_unknown' );
     863                                }
     864
     865                                $l = array_values( array_diff( $all[ $key ]['l'], array( $slug ) ) );
     866
     867                                if ( $leader ) {
     868                                        $l[] = $slug;
     869                                }
     870
     871                                $all[ $key ]['l'] = $l;
     872                                self::save( $all );
     873
     874                                return true;
     875                        }
     876                );
     877        }
     878
     879        /**
     880         * Run a change of the choice under its lock (1.6.0), with the option read
     881         * again from the database. Two administrators saving at the same time no
     882         * longer lose one of the changes (informative of the level 2 review of
     883         * 1.6.0). It waits up to LOCK_WAIT seconds for a change in progress; the
     884         * lock is the one of Cresume_Stats_Lock, with its expiry for a worker that
     885         * died holding it.
     886         *
     887         * @param callable $change Reads all(), changes it and saves it; returns true or a WP_Error.
     888         * @return true|WP_Error
     889         */
     890        private static function locked( callable $change ) {
     891                $until = microtime( true ) + self::LOCK_WAIT;
     892                $token = Cresume_Stats_Lock::acquire( self::LOCK_OPTION, self::LOCK_TTL );
     893
     894                while ( false === $token ) {
     895                        if ( microtime( true ) >= $until ) {
     896                                return self::error( 'cresume_rival_busy' );
     897                        }
     898
     899                        usleep( 100000 );
     900                        $token = Cresume_Stats_Lock::acquire( self::LOCK_OPTION, self::LOCK_TTL );
     901                }
     902
     903                // What this request read before taking the lock may be behind.
     904                Cresume_Stats_Lock::forget( self::OPTION );
     905
     906                try {
     907                        return $change();
     908                } finally {
     909                        Cresume_Stats_Lock::release( self::LOCK_OPTION, $token );
     910                }
     911        }
     912
     913        /**
     914         * An empty choice for one own item.
     915         *
     916         * @return array ['r' => [], 'l' => [], 'h' => []].
     917         */
     918        private static function blank() {
     919                return array(
     920                        'r' => array(),
     921                        'l' => array(),
     922                        'h' => array(),
     923                );
    852924        }
    853925
    … …  
    9361008        public static function error( $code ) {
    9371009                $messages = array(
    938                         'cresume_rival_invalid'     => __( 'Type the slug of a plugin or theme, or its address on WordPress.org.', 'contributor-resume' ),
    939                         'cresume_rival_not_tracked' => __( 'That plugin or theme is not one of the ones the stats follow.', 'contributor-resume' ),
    940                         'cresume_rival_type'        => __( 'A plugin can only compete with plugins, and a theme with themes.', 'contributor-resume' ),
    941                         'cresume_rival_self'        => __( 'A plugin or theme cannot be its own competitor.', 'contributor-resume' ),
     1010                        'cresume_rival_not_tracked'   => __( 'That plugin or theme is not one of the ones the stats follow.', 'contributor-resume' ),
     1011                        'cresume_rival_self'          => __( 'A plugin or theme cannot be its own competitor.', 'contributor-resume' ),
     1012                        'cresume_rival_not_suggested' => __( 'That plugin or theme is not among the suggestions any more.', 'contributor-resume' ),
     1013                        'cresume_rival_busy'          => __( 'Someone else is changing these competitors right now. Try again in a moment.', 'contributor-resume' ),
    9421014                        'cresume_rival_exists'      => __( 'It is already one of its competitors.', 'contributor-resume' ),
    9431015                        /* translators: %d: maximum number of competitors */
  • contributor-resume/tags/1.6.0/includes/stats/class-cresume-stats-view.php

    r3789183 r3789662  
    994994                }
    995995
    996                 $max = max( 1, max( array_map( 'intval', $item['ratings'] ) ) );
     996                // max() of an empty array throws ValueError in PHP 8 (1.6.0): with
     997                // num_ratings above zero and no breakdown, the bars are drawn empty.
     998                $counts = is_array( $item['ratings'] ) ? array_map( 'intval', $item['ratings'] ) : array();
     999                $max    = $counts ? max( 1, max( $counts ) ) : 1;
    9971000                ?>
    9981001                <ul class="cresume-stats-bars">
    … …  
    17431746                $add     = wp_create_nonce( 'cresume_stats_rival_add' );
    17441747                $hide    = wp_create_nonce( 'cresume_stats_rival_hide' );
     1748                $max     = number_format_i18n( Cresume_Stats_Rivals::MAX_PER_ITEM );
    17451749                ?>
    17461750                <section class="cresume-card cresume-stats-rivals">
    … …  
    17541758                                                <?php
    17551759                                                /* translators: 1: competitors chosen, 2: maximum */
    1756                                                 echo esc_html( sprintf( __( '%1$s of %2$s · read every day, like your own', 'contributor-resume' ), number_format_i18n( count( $rows ) ), number_format_i18n( Cresume_Stats_Rivals::MAX_PER_ITEM ) ) );
     1760                                                echo esc_html( sprintf( __( '%1$s of %2$s · read every day, like your own', 'contributor-resume' ), number_format_i18n( count( $rows ) ), $max ) );
    17571761                                                ?>
    17581762                                        </span>
     1763                                        <?php if ( $full ) : ?>
     1764                                                <span class="cresume-stats-chip cresume-stats-chip--warn"><?php esc_html_e( 'Full', 'contributor-resume' ); ?></span>
     1765                                        <?php endif; ?>
    17591766                                </h2>
    17601767                                <a class="button" href="<?php echo esc_url( $back ); ?>"><?php esc_html_e( 'Back to Competitors', 'contributor-resume' ); ?></a>
    … …  
    17641771                                <?php self::rival_list( $rows, $item, true ); ?>
    17651772                        <?php else : ?>
    1766                                 <p class="cresume-stats-intro"><?php esc_html_e( 'No competitors yet. Pick from the suggestions below, or add any plugin by its slug or its address on WordPress.org.', 'contributor-resume' ); ?></p>
     1773                                <p class="cresume-stats-intro"><?php esc_html_e( 'No competitors yet. Search for them below, or pick from the suggestions.', 'contributor-resume' ); ?></p>
    17671774                        <?php endif; ?>
    17681775
    1769                         <form method="post" action="<?php echo esc_url( admin_url( 'admin-post.php' ) ); ?>" class="cresume-stats-rival-add">
    1770                                 <input type="hidden" name="action" value="cresume_stats_rival_add" />
    1771                                 <input type="hidden" name="item" value="<?php echo esc_attr( $item->id ); ?>" />
    1772                                 <input type="hidden" name="_wpnonce" value="<?php echo esc_attr( $add ); ?>" />
    1773                                 <label for="cresume-stats-rival-input"><?php esc_html_e( 'Add a competitor', 'contributor-resume' ); ?></label>
    1774                                 <input type="text" id="cresume-stats-rival-input" name="competitor" class="regular-text" placeholder="<?php echo esc_attr( Cresume_Stats_Store::TYPE_THEME === $type ? 'astra  ·  https://wordpress.org/themes/astra/' : 'ninjafirewall  ·  https://wordpress.org/plugins/ninjafirewall/' ); ?>" aria-describedby="cresume-stats-rival-help"<?php disabled( $full ); ?> />
    1775                                 <label class="cresume-inline"><input type="checkbox" name="leader" value="1"<?php disabled( $full ); ?> /> <?php esc_html_e( 'As a leader', 'contributor-resume' ); ?></label>
    1776                                 <?php submit_button( __( 'Add competitor', 'contributor-resume' ), 'primary', 'submit', false, $full ? array( 'disabled' => 'disabled' ) : array() ); ?>
    1777                                 <p class="description" id="cresume-stats-rival-help">
    1778                                         <?php
    1779                                         if ( $full ) {
     1776                        <?php if ( $full ) : ?>
     1777                                <div class="notice notice-warning inline cresume-stats-rival-full">
     1778                                        <p>
     1779                                                <strong><?php esc_html_e( 'No room for more competitors.', 'contributor-resume' ); ?></strong>
     1780                                                <?php
    17801781                                                /* translators: %s: maximum number of competitors */
    1781                                                 echo esc_html( sprintf( __( 'Each plugin or theme can have up to %s competitors. Remove one to add another.', 'contributor-resume' ), number_format_i18n( Cresume_Stats_Rivals::MAX_PER_ITEM ) ) );
    1782                                         } else {
    1783                                                 esc_html_e( 'Its slug or its address on WordPress.org. It is checked there before it is added. A leader is read like the rest, but it is not counted when comparing who ranks better.', 'contributor-resume' );
    1784                                         }
    1785                                         ?>
    1786                                 </p>
    1787                         </form>
     1782                                                echo esc_html( sprintf( __( 'Each plugin or theme can have up to %s competitors. Remove one to add another.', 'contributor-resume' ), $max ) );
     1783                                                ?>
     1784                                        </p>
     1785                                </div>
     1786                        <?php else : ?>
     1787                                <?php self::search_form( $state, $item ); ?>
     1788                        <?php endif; ?>
    17881789                </section>
    17891790
    … …  
    17981799                                </span>
    17991800                        </h2>
     1801
     1802                        <?php if ( $full && ( $suggest['list'] || $suggest['leaders'] ) ) : ?>
     1803                                <p class="description cresume-stats-rival-full-note"><?php esc_html_e( 'The list of competitors is full: remove one to add a suggestion.', 'contributor-resume' ); ?></p>
     1804                        <?php endif; ?>
    18001805
    18011806                        <?php if ( ! $suggest['of'] ) : ?>
    … …  
    18491854                        <?php endif; ?>
    18501855                </section>
     1856                <?php
     1857        }
     1858
     1859        /**
     1860         * The search to add a competitor (1.6.0): a GET form of the same view, so
     1861         * Enter works without JavaScript, with the results of `rq` drawn below.
     1862         * stats.js searches as the text changes and puts its results in the same
     1863         * place. A leader is marked afterwards, in the list.
     1864         *
     1865         * @param array  $state View state.
     1866         * @param object $item  Own item row.
     1867         */
     1868        private static function search_form( array $state, $item ) {
     1869                $is_theme = Cresume_Stats_Store::TYPE_THEME === (int) $item->type;
     1870                $args     = Cresume_Stats_Admin::view_args( array_merge( $state, array( 'orderby' => '', 'order' => '' ) ) );
     1871                ?>
     1872                <div class="cresume-stats-rival-add">
     1873                        <h3><?php esc_html_e( 'Add a competitor', 'contributor-resume' ); ?></h3>
     1874                        <form method="get" action="<?php echo esc_url( admin_url( 'options-general.php' ) ); ?>" class="cresume-stats-rival-search" role="search" data-item="<?php echo esc_attr( (string) (int) $item->id ); ?>" data-min="<?php echo esc_attr( (string) Cresume_Stats_Fetcher::SEARCH_MIN ); ?>">
     1875                                <?php foreach ( $args as $name => $value ) : ?>
     1876                                        <input type="hidden" name="<?php echo esc_attr( $name ); ?>" value="<?php echo esc_attr( (string) $value ); ?>" />
     1877                                <?php endforeach; ?>
     1878                                <label for="cresume-stats-rival-input"><?php echo $is_theme ? esc_html__( 'Search for a theme', 'contributor-resume' ) : esc_html__( 'Search for a plugin', 'contributor-resume' ); ?></label>
     1879                                <input type="search" id="cresume-stats-rival-input" name="rq" value="<?php echo esc_attr( $state['rq'] ); ?>" class="regular-text" maxlength="<?php echo esc_attr( (string) Cresume_Stats_Fetcher::SEARCH_MAX ); ?>" autocomplete="off" spellcheck="false" aria-describedby="cresume-stats-rival-help" aria-controls="cresume-stats-rival-results" />
     1880                                <button type="submit" class="button"><?php esc_html_e( 'Search', 'contributor-resume' ); ?></button>
     1881                                <p class="description" id="cresume-stats-rival-help"><?php esc_html_e( 'By name or slug, as in the WordPress.org directory. Results appear as you type. A leader is marked afterwards, in the list.', 'contributor-resume' ); ?></p>
     1882                        </form>
     1883                        <div id="cresume-stats-rival-results" class="cresume-stats-search-results">
     1884                                <?php $status = '' !== $state['rq'] ? self::search_results( $item, $state['rq'] ) : ''; ?>
     1885                        </div>
     1886                        <p id="cresume-stats-rival-status" class="screen-reader-text" role="status"><?php echo esc_html( $status ); ?></p>
     1887                </div>
     1888                <?php
     1889        }
     1890
     1891        /**
     1892         * Results of the search of competitors (1.6.0), drawn here both for the
     1893         * search as you type (Cresume_Stats_Admin::ajax_rival_search()) and for the
     1894         * one sent with Enter. Names and icons come from WordPress.org: every value
     1895         * is escaped here, and stats.js only inserts this HTML.
     1896         *
     1897         * @param object $item Own item row.
     1898         * @param string $text Text searched (Cresume_Stats_Admin::search_text()).
     1899         * @return string What screen readers hear: how many results, or why none.
     1900         */
     1901        public static function search_results( $item, $text ) {
     1902                $text = (string) $text;
     1903
     1904                if ( mb_strlen( $text, 'UTF-8' ) < Cresume_Stats_Fetcher::SEARCH_MIN ) {
     1905                        return '';
     1906                }
     1907
     1908                $type     = (int) $item->type;
     1909                $is_theme = Cresume_Stats_Store::TYPE_THEME === $type;
     1910                $fetcher  = new Cresume_Stats_Fetcher();
     1911                $answer   = $fetcher->search( $type, $text );
     1912
     1913                if ( is_wp_error( $answer ) ) {
     1914                        $message = __( 'WordPress.org did not answer. Try again in a minute.', 'contributor-resume' );
     1915                        echo '<p class="cresume-stats-search-none">' . esc_html( $message ) . '</p>';
     1916
     1917                        return $message;
     1918                }
     1919
     1920                if ( ! $answer['list'] ) {
     1921                        $message = $is_theme
     1922                                /* translators: %s: text searched */
     1923                                ? sprintf( __( 'No themes found for “%s”.', 'contributor-resume' ), $text )
     1924                                /* translators: %s: text searched */
     1925                                : sprintf( __( 'No plugins found for “%s”.', 'contributor-resume' ), $text );
     1926                        echo '<p class="cresume-stats-search-none">' . esc_html( $message ) . '</p>';
     1927
     1928                        return $message;
     1929                }
     1930
     1931                $choice = Cresume_Stats_Rivals::of( $type, $item->slug );
     1932                $full   = count( $choice['r'] ) >= Cresume_Stats_Rivals::MAX_PER_ITEM;
     1933                $known  = Cresume_Stats_Store::known_items_by_slug( $type );
     1934                $add    = wp_create_nonce( 'cresume_stats_rival_add' );
     1935                ?>
     1936                <ul class="cresume-stats-suggest-grid" role="list">
     1937                        <?php
     1938                        foreach ( $answer['list'] as $row ) {
     1939                                self::search_result( $row, $item, $choice['r'], $known, $full, $add );
     1940                        }
     1941                        ?>
     1942                </ul>
     1943                <?php
     1944                /* translators: %s: number of results */
     1945                return sprintf( _n( '%s result', '%s results', count( $answer['list'] ), 'contributor-resume' ), number_format_i18n( count( $answer['list'] ) ) );
     1946        }
     1947
     1948        /**
     1949         * One result of the search: who it is and, when it can be one, a button
     1950         * that adds it as a competitor.
     1951         *
     1952         * @param array    $row    Row of Cresume_Stats_Fetcher::search().
     1953         * @param object   $item   Own item row.
     1954         * @param string[] $chosen Slugs of its competitors.
     1955         * @param array    $known  Cresume_Stats_Store::known_items_by_slug().
     1956         * @param bool     $full   Whether the item has no room for more.
     1957         * @param string   $add    Nonce of the add handler.
     1958         */
     1959        private static function search_result( array $row, $item, array $chosen, array $known, $full, $add ) {
     1960                $is_theme = Cresume_Stats_Store::TYPE_THEME === (int) $item->type;
     1961                $slug     = (string) $row['slug'];
     1962                $name     = '' !== $row['name'] ? Cresume_Stats_Query::short_name( $row['name'] ) : $slug;
     1963                $self     = $slug === (string) $item->slug;
     1964                $taken    = in_array( $slug, $chosen, true );
     1965                $own      = ! $self && isset( $known[ $slug ] ) && 1 === $known[ $slug ][1];
     1966
     1967                /* translators: 1: slug, 2: active installs */
     1968                $meta = sprintf( __( '%1$s · %2$s+ installs', 'contributor-resume' ), $slug, number_format_i18n( (int) $row['installs'] ) );
     1969                ?>
     1970                <li class="cresume-stats-suggest-card">
     1971                        <?php self::rival_icon( (string) $row['icon'], $name ); ?>
     1972                        <div class="cresume-stats-suggest-body">
     1973                                <a href="<?php echo esc_url( 'https://wordpress.org/' . ( $is_theme ? 'themes' : 'plugins' ) . '/' . rawurlencode( $slug ) . '/' ); ?>" target="_blank" rel="noopener noreferrer"><strong><?php echo esc_html( $name ); ?></strong><span class="screen-reader-text"> <?php esc_html_e( '(opens in a new tab)', 'contributor-resume' ); ?></span></a>
     1974                                <span class="cresume-stats-sub">
     1975                                        <?php echo esc_html( $meta ); ?>
     1976                                        <?php if ( (int) $row['num_ratings'] > 0 ) : ?>
     1977                                                · <?php echo esc_html( number_format_i18n( (int) $row['rating'] / 20, 1 ) ); ?> ★ (<?php echo esc_html( number_format_i18n( (int) $row['num_ratings'] ) ); ?>)
     1978                                        <?php endif; ?>
     1979                                </span>
     1980                                <?php if ( $self || $taken || $own ) : ?>
     1981                                        <span class="cresume-stats-reasons">
     1982                                                <?php if ( $self ) : ?>
     1983                                                        <span class="cresume-stats-chip"><?php echo $is_theme ? esc_html__( 'This theme', 'contributor-resume' ) : esc_html__( 'This plugin', 'contributor-resume' ); ?></span>
     1984                                                <?php elseif ( $taken ) : ?>
     1985                                                        <span class="cresume-stats-chip cresume-stats-chip--info"><?php esc_html_e( 'Already a competitor', 'contributor-resume' ); ?></span>
     1986                                                <?php else : ?>
     1987                                                        <span class="cresume-stats-chip"><?php esc_html_e( 'Yours', 'contributor-resume' ); ?></span>
     1988                                                <?php endif; ?>
     1989                                        </span>
     1990                                <?php endif; ?>
     1991                        </div>
     1992                        <?php if ( ! $self && ! $taken ) : ?>
     1993                                <div class="cresume-stats-suggest-actions">
     1994                                        <form method="post" action="<?php echo esc_url( admin_url( 'admin-post.php' ) ); ?>">
     1995                                                <input type="hidden" name="action" value="cresume_stats_rival_add" />
     1996                                                <input type="hidden" name="item" value="<?php echo esc_attr( (string) (int) $item->id ); ?>" />
     1997                                                <input type="hidden" name="competitor" value="<?php echo esc_attr( $slug ); ?>" />
     1998                                                <input type="hidden" name="_wpnonce" value="<?php echo esc_attr( $add ); ?>" />
     1999                                                <button type="submit" class="button button-primary"<?php disabled( $full ); ?>>
     2000                                                        <?php esc_html_e( 'Add', 'contributor-resume' ); ?>
     2001                                                        <span class="screen-reader-text"><?php echo esc_html( $name ); ?></span>
     2002                                                </button>
     2003                                        </form>
     2004                                </div>
     2005                        <?php endif; ?>
     2006                </li>
    18512007                <?php
    18522008        }
  • contributor-resume/tags/1.6.0/includes/stats/class-cresume-stats-widget.php

    r3789183 r3789662  
    158158                }
    159159
    160                 $layout = isset( $raw['layout'] ) ? (string) $raw['layout'] : '';
    161                 $days   = isset( $raw['days'] ) ? (int) $raw['days'] : 0;
    162                 $sort   = isset( $raw['sort'] ) ? (string) $raw['sort'] : '';
    163                 $rows   = isset( $raw['rows'] ) ? (int) $raw['rows'] : 0;
     160                // Arrays where text is expected come out as '' (1.6.0): a forged
     161                // `layout[]` gave «Array to string conversion» before.
     162                $layout = isset( $raw['layout'] ) ? self::text( $raw['layout'] ) : '';
     163                $days   = isset( $raw['days'] ) && is_scalar( $raw['days'] ) ? (int) $raw['days'] : 0;
     164                $sort   = isset( $raw['sort'] ) ? self::text( $raw['sort'] ) : '';
     165                $rows   = isset( $raw['rows'] ) && is_scalar( $raw['rows'] ) ? (int) $raw['rows'] : 0;
    164166                $item   = isset( $raw['item'] ) ? self::id( $raw['item'] ) : 0;
    165167                $picked = array();
    … …  
    176178
    177179                foreach ( ( isset( $raw['figures'] ) ? (array) $raw['figures'] : array() ) as $figure ) {
    178                         $figure = sanitize_key( (string) $figure );
     180                        $figure = sanitize_key( self::text( $figure ) );
    179181
    180182                        if ( in_array( $figure, self::FIGURES, true ) ) {
    … …  
    194196                        'alerts'  => ! empty( $raw['alerts'] ),
    195197                );
     198        }
     199
     200        /**
     201         * A posted value as text, or '' when it is not a single value.
     202         *
     203         * @param mixed $value Posted value.
     204         * @return string
     205         */
     206        private static function text( $value ) {
     207                return is_scalar( $value ) ? (string) $value : '';
    196208        }
    197209
  • contributor-resume/tags/1.6.0/readme.txt

    r3789206 r3789662  
    147147= Can I track my competitors? =
    148148
    149 Yes. In the Competitors view of the Stats tab, add up to 10 competitors to each plugin or theme, by slug or by their WordPress.org address, or pick them from the suggestions. They are read every day like your own. You compare them in a head-to-head table, keyword by keyword, in one downloads chart, and in the alerts and emails.
     149Yes. In the Competitors view of the Stats tab, search the directory by name or slug and add up to 10 competitors to each plugin or theme, or pick them from the suggestions. They are read every day like your own. You compare them in a head-to-head table, keyword by keyword, in one downloads chart, and in the alerts and emails.
    150150
    151151= How are competitors suggested? =
    … …  
    235235Competitor tracking: choose the plugins you compete with and follow them every day, with a head to head view, keyword positions, alerts, emails and a dashboard widget. The plugin is now called Contributor Résumé, Rank and Competitor Tracker.
    236236
    237 * New: Competitor tracking. Choose up to 10 competitors for each plugin or theme, by slug or WordPress.org address, or from suggestions taken from your own keywords, each with the reason it is there. They are read every day like your own, with two years of download history from the first day. Market leaders are suggested apart: a leader is followed like the rest but not counted when comparing who ranks better.
     237* New: Competitor tracking. Choose up to 10 competitors for each plugin or theme with a search of the directory by name or slug, with results as you type, or from suggestions taken from your own keywords, each with the reason it is there. They are read every day like your own, with two years of download history from the first day. Market leaders are suggested apart: a leader is followed like the rest but not counted when comparing who ranks better. When a plugin or theme has its 10, the screen says so and explains how to make room.
    238238* New: Competitors view in Stats. Five figures, a sortable head-to-head table, a keyword-by-keyword table that shows where you rank better, one downloads chart for all of them (on a logarithmic scale or as an index that starts everyone at 100), your share of the top 10 of your keywords and the opportunities it shows.
    239239* New: Competitor alerts, in the Alerts view and in the emails. A competitor passes you or falls behind, enters or leaves the top 10, ranks for one of your keywords for the first time, releases a version, says it is tested with a newer WordPress than yours, changes its installs range or rating, gets a spike in downloads without a release, goes 6 or 12 months without an update or is closed on WordPress.org.
    … …  
    246246* Improved: On the page of each plugin or theme, the downloads chart is a box you can move, fold or hide like the others, next to active installs and at the same height. Translations and version adoption line up as well.
    247247* Improved: The stats tables are updated automatically to keep the competitors and their positions.
     248* Improved: The dashboard widget settings ignore unexpected values without a PHP notice, and the rating bars are drawn empty when WordPress.org gives no breakdown of the ratings.
    248249
    249250For older changelog entries, please check the [changelog.txt](https://plugins.svn.wordpress.org/contributor-resume/trunk/changelog.txt) file
  • contributor-resume/tags/1.6.0/uninstall.php

    r3789183 r3789662  
    3232        delete_option( 'cresume_stats_lock' );
    3333        delete_option( 'cresume_stats_mail_lock' );
     34        delete_option( 'cresume_stats_rivals_lock' );
     35
     36        // The search of competitors (1.6.0) keeps each answer for 15 minutes:
     37        // nothing worth keeping, so it goes even when the data is kept.
     38        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- One-off bulk transient cleanup on uninstall; caching does not apply here.
     39        $wpdb->query(
     40                "DELETE FROM {$wpdb->options}
     41                 WHERE option_name LIKE '\_transient\_cresume\_srch\_%'
     42                 OR option_name LIKE '\_transient\_timeout\_cresume\_srch\_%'"
     43        );
    3444
    3545        // Respect the "keep settings and cached data" choice.
  • contributor-resume/trunk/assets/css/stats.css

    r3789183 r3789662  
    15551555}
    15561556
     1557/* The search to add a competitor (1.6.0) and the notice when the list is full. */
     1558
    15571559.cresume-stats-rival-add {
     1560        margin-top: 16px;
     1561        padding-top: 16px;
     1562        border-top: 1px solid #f0f0f1;
     1563}
     1564
     1565.cresume-stats-rival-add h3 {
     1566        margin: 0 0 8px;
     1567}
     1568
     1569.cresume-stats-rival-search {
    15581570        display: flex;
    15591571        flex-wrap: wrap;
    15601572        align-items: center;
    15611573        gap: 8px 12px;
    1562         margin-top: 16px;
    1563         padding-top: 16px;
    1564         border-top: 1px solid #f0f0f1;
    1565 }
    1566 
    1567 .cresume-stats-rival-add label[for] {
    1568         font-weight: 600;
    1569 }
    1570 
    1571 .cresume-stats-rival-add .description {
     1574}
     1575
     1576.cresume-stats-rival-search label[for] {
     1577        font-weight: 600;
     1578}
     1579
     1580.cresume-stats-rival-search .description {
    15721581        flex-basis: 100%;
    15731582        margin: 0;
     1583}
     1584
     1585.cresume-stats-search-results[aria-busy="true"] {
     1586        opacity: 0.6;
     1587}
     1588
     1589.cresume-stats-search-none {
     1590        margin: 12px 0 0;
     1591}
     1592
     1593.cresume-stats-suggest-card img.cresume-stats-rival-icon {
     1594        object-fit: cover;
     1595}
     1596
     1597.cresume-stats-rivals h2 .cresume-stats-chip {
     1598        margin-left: 6px;
     1599        vertical-align: middle;
     1600}
     1601
     1602.cresume-stats-rival-full.notice {
     1603        margin: 16px 0 0;
     1604}
     1605
     1606.cresume-stats-rival-full-note {
     1607        margin: 8px 0 0;
    15741608}
    15751609
  • contributor-resume/trunk/assets/js/stats.js

    r3789183 r3789662  
    1212 * value/max of the <progress> element and in numeric SVG attributes. The
    1313 * keyword chips are drawn by core's tags box (wp-admin/js/tags-box.js), which
    14  * uses .text() for them.
     14 * uses .text() for them. The one exception is the search of competitors
     15 * (1.6.0): it inserts the results as the server drew and escaped them
     16 * (Cresume_Stats_View::search_results()), the same HTML the page prints
     17 * without JavaScript, and adds nothing of its own to it.
    1518 */
    1619( function () {
    … …  
    499502        }
    500503
     504        /**
     505         * Search WordPress.org while a competitor is typed (1.6.0). Without
     506         * JavaScript the form is a GET of the same view and Enter shows the
     507         * results; here the text is sent after a pause, an older request is
     508         * cancelled, and Enter searches at once without leaving the page.
     509         */
     510        function initRivalSearch() {
     511                var form = document.querySelector( '.cresume-stats-rival-search' );
     512                var input = form ? form.querySelector( 'input[name="rq"]' ) : null;
     513                var results = document.getElementById( 'cresume-stats-rival-results' );
     514                var status = document.getElementById( 'cresume-stats-rival-status' );
     515                var min;
     516                var timer = null;
     517                var controller = null;
     518                var last;
     519
     520                if ( ! form || ! input || ! results || ! status || ! cfg.rivalNonce || ! window.fetch ) {
     521                        return;
     522                }
     523
     524                min = parseInt( form.getAttribute( 'data-min' ), 10 ) || 2;
     525                last = input.value.trim();
     526
     527                function clear() {
     528                        while ( results.firstChild ) {
     529                                results.removeChild( results.firstChild );
     530                        }
     531                }
     532
     533                function run( text ) {
     534                        var body;
     535
     536                        if ( controller ) {
     537                                controller.abort();
     538                                controller = null;
     539                        }
     540
     541                        if ( text.length < min ) {
     542                                clear();
     543                                status.textContent = '';
     544
     545                                return;
     546                        }
     547
     548                        body = new window.FormData();
     549                        body.append( 'action', 'cresume_stats_rival_search' );
     550                        body.append( 'nonce', cfg.rivalNonce );
     551                        body.append( 'item', form.getAttribute( 'data-item' ) || '' );
     552                        body.append( 'rq', text );
     553
     554                        controller = window.AbortController ? new window.AbortController() : null;
     555                        status.textContent = i18n.searching || '';
     556                        results.setAttribute( 'aria-busy', 'true' );
     557
     558                        window
     559                                .fetch( cfg.ajaxUrl, {
     560                                        method: 'POST',
     561                                        credentials: 'same-origin',
     562                                        body: body,
     563                                        signal: controller ? controller.signal : undefined,
     564                                } )
     565                                .then( function ( response ) {
     566                                        return response.json();
     567                                } )
     568                                .then( function ( data ) {
     569                                        results.removeAttribute( 'aria-busy' );
     570
     571                                        if ( ! data || ! data.success || ! data.data ) {
     572                                                status.textContent = data && data.data && data.data.message ? String( data.data.message ) : i18n.failed || '';
     573
     574                                                return;
     575                                        }
     576
     577                                        // HTML drawn and escaped by Cresume_Stats_View::search_results().
     578                                        results.innerHTML = String( data.data.html || '' );
     579                                        status.textContent = String( data.data.status || '' );
     580                                } )
     581                                .catch( function ( error ) {
     582                                        if ( error && 'AbortError' === error.name ) {
     583                                                return;
     584                                        }
     585
     586                                        results.removeAttribute( 'aria-busy' );
     587                                        status.textContent = i18n.failed || '';
     588                                } );
     589                }
     590
     591                input.addEventListener( 'input', function () {
     592                        var text = input.value.trim();
     593
     594                        window.clearTimeout( timer );
     595
     596                        if ( text === last ) {
     597                                return;
     598                        }
     599
     600                        last = text;
     601                        timer = window.setTimeout( function () {
     602                                run( text );
     603                        }, 350 );
     604                } );
     605
     606                form.addEventListener( 'submit', function ( event ) {
     607                        event.preventDefault();
     608                        window.clearTimeout( timer );
     609                        last = input.value.trim();
     610                        run( last );
     611                } );
     612        }
     613
    501614        initPrefs();
    502615        initKeywordBox();
    … …  
    506619        initSwitcher();
    507620        initScale();
     621        initRivalSearch();
    508622}() );
  • contributor-resume/trunk/changelog.txt

    r3789206 r3789662  
    55
    66= 1.6.0 =
    7 * New: Competitor tracking. Choose up to 10 competitors for each plugin or theme, by slug or WordPress.org address, or from suggestions taken from your own keywords, each with the reason it is there. They are read every day like your own, with two years of download history from the first day. Market leaders are suggested apart: a leader is followed like the rest but not counted when comparing who ranks better.
     7* New: Competitor tracking. Choose up to 10 competitors for each plugin or theme with a search of the directory by name or slug, with results as you type, or from suggestions taken from your own keywords, each with the reason it is there. They are read every day like your own, with two years of download history from the first day. Market leaders are suggested apart: a leader is followed like the rest but not counted when comparing who ranks better. When a plugin or theme has its 10, the screen says so and explains how to make room.
    88* New: Competitors view in Stats. Five figures, a sortable head-to-head table, a keyword-by-keyword table that shows where you rank better, one downloads chart for all of them (on a logarithmic scale or as an index that starts everyone at 100), your share of the top 10 of your keywords and the opportunities it shows.
    99* New: Competitor alerts, in the Alerts view and in the emails. A competitor passes you or falls behind, enters or leaves the top 10, ranks for one of your keywords for the first time, releases a version, says it is tested with a newer WordPress than yours, changes its installs range or rating, gets a spike in downloads without a release, goes 6 or 12 months without an update or is closed on WordPress.org.
    … …  
    1616* Improved: On the page of each plugin or theme, the downloads chart is a box you can move, fold or hide like the others, next to active installs and at the same height. Translations and version adoption line up as well.
    1717* Improved: The stats tables are updated automatically to keep the competitors and their positions.
     18* Improved: The dashboard widget settings ignore unexpected values without a PHP notice, and the rating bars are drawn empty when WordPress.org gives no breakdown of the ratings.
    1819
    1920= 1.5.4 =
  • contributor-resume/trunk/includes/stats/class-cresume-stats-admin.php

    r3789183 r3789662  
    7878         * @var string[]
    7979         */
    80         const RIVAL_ERRORS = array( 'invalid', 'not_tracked', 'type', 'self', 'exists', 'full', 'missing', 'closed', 'unknown' );
     80        const RIVAL_ERRORS = array( 'not_tracked', 'self', 'exists', 'full', 'missing', 'closed', 'unknown', 'not_suggested', 'busy' );
    8181
    8282        /**
    … …  
    119119                add_action( 'wp_ajax_cresume_stats_collect', array( $this, 'ajax_collect' ) );
    120120                add_action( 'wp_ajax_cresume_stats_status', array( $this, 'ajax_status' ) );
     121                add_action( 'wp_ajax_cresume_stats_rival_search', array( $this, 'ajax_rival_search' ) );
    121122                add_filter( 'nonce_life', array( $this, 'view_nonce_life' ), 10, 2 );
    122123        }
    … …  
    284285                        'paged'   => 1,
    285286                        'ov'      => 'rivals',
     287                        'rq'      => '',
    286288                );
    287289        }
    … …  
    360362                }
    361363
     364                // The search of competitors without JavaScript (1.6.0): what was typed,
     365                // shown back in the field and sent to WordPress.org. Never in a link or
     366                // in the cookie: view_args() leaves it out.
     367                if ( 'rivals' === $state['view'] && isset( $_GET['rq'] ) && is_string( $_GET['rq'] ) ) {
     368                        $state['rq'] = self::search_text( sanitize_text_field( wp_unslash( $_GET['rq'] ) ) );
     369                }
     370
    362371                // The list of alerts: a topic of the closed list (anything else is all of
    363372                // them) and a page of 1 or more. Neither is remembered in the cookie.
    … …  
    716725                        'cresumeStats',
    717726                        array(
    718                                 'ajaxUrl' => admin_url( 'admin-ajax.php' ),
    719                                 'nonce'   => wp_create_nonce( 'cresume_admin' ),
    720                                 'running' => Cresume_Stats_Collector::is_running() || Cresume_Stats_Peek::is_running(),
    721                                 'cookie'  => array(
     727                                'ajaxUrl'    => admin_url( 'admin-ajax.php' ),
     728                                'nonce'      => wp_create_nonce( 'cresume_admin' ),
     729                                'running'    => Cresume_Stats_Collector::is_running() || Cresume_Stats_Peek::is_running(),
     730                                'cookie'     => array(
    722731                                        'name' => self::COOKIE,
    723732                                        'path' => (string) wp_parse_url( admin_url(), PHP_URL_PATH ),
    724733                                ),
    725                                 'i18n'    => array(
    726                                         'starting' => __( 'Starting…', 'contributor-resume' ),
    727                                         'failed'   => __( 'Something went wrong. Try again.', 'contributor-resume' ),
    728                                         'done'     => __( 'Collection finished.', 'contributor-resume' ),
     734                                'rivalNonce' => 'rivals' === $state['view'] ? wp_create_nonce( 'cresume_stats_rival_search' ) : '',
     735                                'i18n'       => array(
     736                                        'starting'  => __( 'Starting…', 'contributor-resume' ),
     737                                        'failed'    => __( 'Something went wrong. Try again.', 'contributor-resume' ),
     738                                        'done'      => __( 'Collection finished.', 'contributor-resume' ),
     739                                        'searching' => __( 'Searching WordPress.org…', 'contributor-resume' ),
    729740                                ),
    730741                        )
    731742                );
     743        }
     744
     745        /**
     746         * Text of a search of competitors: trimmed, with its spaces collapsed and
     747         * cut to Cresume_Stats_Fetcher::SEARCH_MAX characters.
     748         *
     749         * @param string $text Sanitized text.
     750         * @return string
     751         */
     752        public static function search_text( $text ) {
     753                $text = trim( preg_replace( '/\s+/u', ' ', (string) $text ) );
     754
     755                return mb_substr( $text, 0, Cresume_Stats_Fetcher::SEARCH_MAX, 'UTF-8' );
    732756        }
    733757
    … …  
    18281852                $item = self::rival_item( isset( $_POST['item'] ) ? absint( $_POST['item'] ) : 0 );
    18291853
    1830                 // A slug or an address: wp_strip_all_tags() keeps the %XX octets that
    1831                 // sanitize_text_field() would delete; parse_input() checks the rest.
    1832                 $raw    = isset( $_POST['competitor'] ) && is_string( $_POST['competitor'] ) ? wp_strip_all_tags( wp_unslash( $_POST['competitor'] ) ) : '';
     1854                // The slug of a search result or a suggestion (1.6.0: nobody types it);
     1855                // add() checks it against Cresume_Stats_Rivals::SLUG_PATTERN.
     1856                $slug   = isset( $_POST['competitor'] ) && is_string( $_POST['competitor'] ) ? sanitize_text_field( wp_unslash( $_POST['competitor'] ) ) : '';
    18331857                $leader = isset( $_POST['leader'] ) && '1' === sanitize_key( wp_unslash( $_POST['leader'] ) );
    1834                 $result = Cresume_Stats_Rivals::add( (int) $item->type, (string) $item->slug, $raw, $leader );
     1858                $result = Cresume_Stats_Rivals::add( (int) $item->type, (string) $item->slug, $slug, $leader );
    18351859
    18361860                if ( is_wp_error( $result ) ) {
    … …  
    20402064
    20412065        /**
     2066         * The search of competitors as you type (1.6.0): the results for one own
     2067         * item, as HTML drawn and escaped by Cresume_Stats_View::search_results(),
     2068         * the same function that draws them without JavaScript. Nonce, capability,
     2069         * an own item the stats follow and a text of 2 to SEARCH_MAX characters;
     2070         * only the text travels to WordPress.org, encoded (query_url()).
     2071         */
     2072        public function ajax_rival_search() {
     2073                check_ajax_referer( 'cresume_stats_rival_search', 'nonce' );
     2074                $this->authorize();
     2075
     2076                if ( ! Cresume_Stats::enabled() || ! Cresume_Stats_Schema::is_current() ) {
     2077                        wp_send_json_error( array( 'message' => __( 'Stats are off or their tables are not ready.', 'contributor-resume' ) ), 400 );
     2078                }
     2079
     2080                $item_id = isset( $_POST['item'] ) ? absint( $_POST['item'] ) : 0;
     2081                $item    = $item_id ? Cresume_Stats_Store::item( $item_id ) : null;
     2082
     2083                if ( ! $item || 1 !== (int) $item->role ) {
     2084                        wp_send_json_error( array( 'message' => Cresume_Stats_Rivals::error( 'cresume_rival_not_tracked' )->get_error_message() ), 400 );
     2085                }
     2086
     2087                $text = isset( $_POST['rq'] ) && is_string( $_POST['rq'] ) ? self::search_text( sanitize_text_field( wp_unslash( $_POST['rq'] ) ) ) : '';
     2088
     2089                ob_start();
     2090                $status = Cresume_Stats_View::search_results( $item, $text );
     2091                $html   = ob_get_clean();
     2092
     2093                wp_send_json_success(
     2094                        array(
     2095                                'html'   => (string) $html,
     2096                                'status' => $status,
     2097                        )
     2098                );
     2099        }
     2100
     2101        /**
    20422102         * Stop unless the user may manage the site.
    20432103         */
  • contributor-resume/trunk/includes/stats/class-cresume-stats-cli.php

    r3789183 r3789662  
    377377         *
    378378         * <competitor>
    379          * : Slug of the competitor, or its address on WordPress.org.
     379         * : Slug of the competitor on WordPress.org.
    380380         *
    381381         * [--type=<type>]
    … …  
    391391         *
    392392         *     wp cresume stats competitors add vigilante ninjafirewall
    393          *     wp cresume stats competitors add vigilante https://wordpress.org/plugins/wordfence/ --leader
     393         *     wp cresume stats competitors add vigilante wordfence --leader
    394394         *
    395395         * @param array $args       Positional args.
    … …  
    400400
    401401                $type   = $this->type( $assoc_args );
    402                 $result = Cresume_Stats_Rivals::add( $type, sanitize_key( $args[0] ), $args[1], ! empty( $assoc_args['leader'] ) );
     402                // A slug as typed in the terminal: trimmed and in lowercase, then checked by add().
     403                $result = Cresume_Stats_Rivals::add( $type, sanitize_key( $args[0] ), strtolower( trim( (string) $args[1] ) ), ! empty( $assoc_args['leader'] ) );
    403404
    404405                if ( is_wp_error( $result ) ) {
  • contributor-resume/trunk/includes/stats/class-cresume-stats-fetcher.php

    r3789183 r3789662  
    5757         */
    5858        const INFO_MAX = 100;
     59
     60        /**
     61         * Results of the search of competitors (1.6.0).
     62         *
     63         * @var int
     64         */
     65        const SEARCH_RESULTS = 10;
     66
     67        /**
     68         * Characters a search of competitors needs before it asks WordPress.org.
     69         *
     70         * @var int
     71         */
     72        const SEARCH_MIN = 2;
     73
     74        /**
     75         * Characters of a search of competitors, at most.
     76         *
     77         * @var int
     78         */
     79        const SEARCH_MAX = 100;
     80
     81        /**
     82         * Seconds a search of competitors is kept.
     83         *
     84         * @var int
     85         */
     86        const SEARCH_CACHE = 900;
     87
     88        /**
     89         * Prefix of the transients of the search of competitors. uninstall.php
     90         * deletes them by this prefix, even when the data is kept.
     91         *
     92         * @var string
     93         */
     94        const SEARCH_PREFIX = 'cresume_srch_';
    5995
    6096        /**
    … …  
    308344                }
    309345
    310                 $icon = '';
    311 
    312                 if ( $is_theme ) {
    313                         $icon = isset( $raw['screenshot_url'] ) ? (string) $raw['screenshot_url'] : '';
    314 
    315                         // The Themes API returns protocol-relative screenshot URLs.
    316                         if ( 0 === strpos( $icon, '//' ) ) {
    317                                 $icon = 'https:' . $icon;
    318                         }
    319                 } elseif ( isset( $raw['icons'] ) && is_array( $raw['icons'] ) ) {
    320                         foreach ( array( '2x', '1x', 'svg', 'default' ) as $size ) {
    321                                 if ( ! empty( $raw['icons'][ $size ] ) ) {
    322                                         $icon = (string) $raw['icons'][ $size ];
    323                                         break;
    324                                 }
    325                         }
    326                 }
    327 
    328                 $icon = esc_url_raw( $icon, array( 'https' ) );
     346                $icon = self::icon_of( $is_theme, $raw );
    329347
    330348                $updated = '';
    … …  
    338356                        'slug'    => $slug,
    339357                        'name'    => self::clean_text( isset( $raw['name'] ) ? $raw['name'] : $slug, 255 ),
    340                         'icon'    => strlen( $icon ) <= 255 ? $icon : '',
     358                        'icon'    => $icon,
    341359                        'added'   => self::clean_date( isset( $raw['added'] ) ? $raw['added'] : '' ),
    342360                        'tags'    => $is_theme ? array() : self::clean_tags( isset( $raw['tags'] ) ? $raw['tags'] : array() ),
    … …  
    358376
    359377        /**
     378         * The icon of a plugin, or the screenshot of a theme, as an https address
     379         * of 255 characters at most, or ''.
     380         *
     381         * @param bool  $is_theme Whether it is a theme.
     382         * @param array $raw      Raw API item.
     383         * @return string
     384         */
     385        private static function icon_of( $is_theme, array $raw ) {
     386                $icon = '';
     387
     388                if ( $is_theme ) {
     389                        $icon = isset( $raw['screenshot_url'] ) && is_string( $raw['screenshot_url'] ) ? $raw['screenshot_url'] : '';
     390
     391                        // The Themes API returns protocol-relative screenshot URLs.
     392                        if ( 0 === strpos( $icon, '//' ) ) {
     393                                $icon = 'https:' . $icon;
     394                        }
     395                } elseif ( isset( $raw['icons'] ) && is_array( $raw['icons'] ) ) {
     396                        foreach ( array( '2x', '1x', 'svg', 'default' ) as $size ) {
     397                                if ( ! empty( $raw['icons'][ $size ] ) && is_string( $raw['icons'][ $size ] ) ) {
     398                                        $icon = $raw['icons'][ $size ];
     399                                        break;
     400                                }
     401                        }
     402                }
     403
     404                $icon = esc_url_raw( $icon, array( 'https' ) );
     405
     406                return strlen( $icon ) <= 255 ? $icon : '';
     407        }
     408
     409        /**
     410         * Search the directory to add a competitor (1.6.0): what a person types,
     411         * matched by WordPress.org against slugs and names, in the order of the web
     412         * search. Measured on 7 Nov 2026: the slug comes first («wpo-tweaks» and
     413         * «wpo tweaks» find WPO Tweaks, whose name is DietPress; «/vigilante/»
     414         * finds Vigilant), so no lookup by slug is needed. Closed plugins never
     415         * come back. About 5 KB for 10 results.
     416         *
     417         * The answer is kept SEARCH_CACHE seconds per type and text, so typing the
     418         * same search again asks nothing.
     419         *
     420         * @param int    $type  Type constant.
     421         * @param string $query Text typed, already trimmed and cut to SEARCH_MAX.
     422         * @return array|WP_Error ['results' => int, 'list' => [['slug', 'name',
     423         *                        'icon', 'installs', 'rating', 'num_ratings'], …]].
     424         */
     425        public function search( $type, $query ) {
     426                $type  = Cresume_Stats_Store::TYPE_THEME === (int) $type ? Cresume_Stats_Store::TYPE_THEME : Cresume_Stats_Store::TYPE_PLUGIN;
     427                $query = (string) $query;
     428                $key   = self::SEARCH_PREFIX . md5( $type . '|' . mb_strtolower( $query, 'UTF-8' ) );
     429                $kept  = get_transient( $key );
     430
     431                if ( is_array( $kept ) && isset( $kept['results'], $kept['list'] ) ) {
     432                        return $kept;
     433                }
     434
     435                $is_theme = Cresume_Stats_Store::TYPE_THEME === $type;
     436                $args     = array(
     437                        'action'            => $is_theme ? 'query_themes' : 'query_plugins',
     438                        // Encoded here: add_query_arg() does not encode values (see query_url()).
     439                        'request[search]'   => rawurlencode( $query ),
     440                        'request[per_page]' => self::SEARCH_RESULTS,
     441                        'request[page]'     => 1,
     442                );
     443
     444                foreach ( self::search_fields( $type ) as $field => $on ) {
     445                        $args[ 'request[fields][' . $field . ']' ] = $on;
     446                }
     447
     448                $data = $this->get_json( add_query_arg( $args, $is_theme ? self::THEMES_API : self::PLUGINS_API ) );
     449
     450                if ( is_wp_error( $data ) ) {
     451                        return $data;
     452                }
     453
     454                $list_key = $is_theme ? 'themes' : 'plugins';
     455
     456                if ( ! is_array( $data ) || ! isset( $data[ $list_key ], $data['info'] ) || ! is_array( $data[ $list_key ] ) ) {
     457                        return self::bad_payload();
     458                }
     459
     460                $list = array();
     461
     462                foreach ( $data[ $list_key ] as $row ) {
     463                        $slug = is_array( $row ) && isset( $row['slug'] ) && is_string( $row['slug'] ) ? $row['slug'] : '';
     464
     465                        if ( ! preg_match( '/^[a-z0-9-]{1,191}$/', $slug ) || isset( $list[ $slug ] ) ) {
     466                                continue;
     467                        }
     468
     469                        $list[ $slug ] = array(
     470                                'slug'        => $slug,
     471                                'name'        => self::clean_text( isset( $row['name'] ) && is_scalar( $row['name'] ) ? $row['name'] : $slug, 120 ),
     472                                'icon'        => self::icon_of( $is_theme, $row ),
     473                                'installs'    => isset( $row['active_installs'] ) ? absint( $row['active_installs'] ) : 0,
     474                                'rating'      => isset( $row['rating'] ) ? min( 100, absint( $row['rating'] ) ) : 0,
     475                                'num_ratings' => isset( $row['num_ratings'] ) ? absint( $row['num_ratings'] ) : 0,
     476                        );
     477
     478                        if ( count( $list ) >= self::SEARCH_RESULTS ) {
     479                                break;
     480                        }
     481                }
     482
     483                $out = array(
     484                        'results' => isset( $data['info']['results'] ) ? absint( $data['info']['results'] ) : count( $list ),
     485                        'list'    => array_values( $list ),
     486                );
     487
     488                set_transient( $key, $out, self::SEARCH_CACHE );
     489
     490                return $out;
     491        }
     492
     493        /**
     494         * Fields asked for in the search of competitors: what a result shows.
     495         *
     496         * @param int $type Type constant.
     497         * @return array
     498         */
     499        private static function search_fields( $type ) {
     500                if ( Cresume_Stats_Store::TYPE_THEME === (int) $type ) {
     501                        return array(
     502                                'active_installs' => 1,
     503                                'rating'          => 1,
     504                                'num_ratings'     => 1,
     505                                'screenshot_url'  => 1,
     506                                'description'     => 0,
     507                                'sections'        => 0,
     508                                'ratings'         => 0,
     509                                'downloaded'      => 0,
     510                                'last_updated'    => 0,
     511                                'homepage'        => 0,
     512                                'tags'            => 0,
     513                                'versions'        => 0,
     514                                'theme_url'       => 0,
     515                        );
     516                }
     517
     518                return array(
     519                        'active_installs'   => 1,
     520                        'rating'            => 1,
     521                        'num_ratings'       => 1,
     522                        'icons'             => 1,
     523                        'short_description' => 0,
     524                        'description'       => 0,
     525                        'sections'          => 0,
     526                        'tags'              => 0,
     527                        'versions'          => 0,
     528                        'contributors'      => 0,
     529                        'screenshots'       => 0,
     530                        'banners'           => 0,
     531                        'compatibility'     => 0,
     532                        'reviews'           => 0,
     533                        'ratings'           => 0,
     534                        'downloaded'        => 0,
     535                        'last_updated'      => 0,
     536                        'added'             => 0,
     537                        'homepage'          => 0,
     538                        'donate_link'       => 0,
     539                        'tested'            => 0,
     540                        'requires'          => 0,
     541                        'requires_php'      => 0,
     542                );
     543        }
     544
     545        /**
    360546         * Daily downloads, oldest first, up to yesterday (UTC).
    361547         *
  • contributor-resume/trunk/includes/stats/class-cresume-stats-lock.php

    r3755315 r3789662  
    108108
    109109        /**
    110          * Drop what the options cache may remember about the lock.
     110         * Drop what the options cache may remember about an option: the lock, or
     111         * (1.6.0) the option a lock guards, so it is read from the database once
     112         * the lock is taken.
    111113         *
    112114         * @param string $name Option name.
    113115         */
    114         private static function forget( $name ) {
     116        public static function forget( $name ) {
    115117                wp_cache_delete( $name, 'options' );
    116118
  • contributor-resume/trunk/includes/stats/class-cresume-stats-rivals.php

    r3789183 r3789662  
    9090
    9191        /**
     92         * Lock of the choice (1.6.0): a row of its own in the options table, taken
     93         * with Cresume_Stats_Lock. Deleted by uninstall.php.
     94         *
     95         * @var string
     96         */
     97        const LOCK_OPTION = 'cresume_stats_rivals_lock';
     98
     99        /**
     100         * Seconds the lock outlives a request that died holding it.
     101         *
     102         * @var int
     103         */
     104        const LOCK_TTL = 30;
     105
     106        /**
     107         * Seconds a change waits for another one in progress.
     108         *
     109         * @var int
     110         */
     111        const LOCK_WAIT = 2;
     112
     113        /**
    92114         * Key of an own item.
    93115         *
    … …  
    594616
    595617        /**
    596          * Read what the user typed: a slug, or the address of a plugin or theme on
    597          * WordPress.org (any locale subdomain, with or without scheme, `www.`,
    598          * trailing slash, query or fragment).
    599          *
    600          * The address goes through esc_url_raw() (never sanitize_text_field(),
    601          * which deletes the %XX octets) and its path is cut by hand; the slug that
    602          * comes out must match SLUG_PATTERN, so nothing else reaches the API.
    603          *
    604          * @param mixed $raw Text typed by the user.
    605          * @return array|WP_Error ['slug' => string, 'type' => int], type 0 when a bare
    606          *                        slug does not say whether it is a plugin or a theme.
    607          */
    608         public static function parse_input( $raw ) {
    609                 if ( ! is_string( $raw ) ) {
    610                         return self::error( 'cresume_rival_invalid' );
    611                 }
    612 
    613                 $raw = trim( $raw );
    614 
    615                 if ( '' === $raw || strlen( $raw ) > 500 ) {
    616                         return self::error( 'cresume_rival_invalid' );
    617                 }
    618 
    619                 $slug = strtolower( $raw );
    620 
    621                 if ( preg_match( self::SLUG_PATTERN, $slug ) ) {
    622                         return array(
    623                                 'slug' => $slug,
    624                                 'type' => 0,
    625                         );
    626                 }
    627 
    628                 $url = esc_url_raw( $raw, array( 'http', 'https' ) );
    629 
    630                 if ( '' === $url || ! preg_match( '#^https?://(?:[a-z0-9-]+\.)?wordpress\.org(/[^?\#]*)?(?:[?\#].*)?$#i', $url, $match ) ) {
    631                         return self::error( 'cresume_rival_invalid' );
    632                 }
    633 
    634                 $path = isset( $match[1] ) ? $match[1] : '';
    635 
    636                 if ( ! preg_match( '#^/(plugins|themes)/([^/]+)/?$#i', $path, $parts ) ) {
    637                         return self::error( 'cresume_rival_invalid' );
    638                 }
    639 
    640                 $slug = strtolower( rawurldecode( $parts[2] ) );
    641 
    642                 if ( ! preg_match( self::SLUG_PATTERN, $slug ) ) {
    643                         return self::error( 'cresume_rival_invalid' );
    644                 }
    645 
    646                 return array(
    647                         'slug' => $slug,
    648                         'type' => 'themes' === strtolower( $parts[1] ) ? Cresume_Stats_Store::TYPE_THEME : Cresume_Stats_Store::TYPE_PLUGIN,
    649                 );
    650         }
    651 
    652         /**
    653618         * Add a competitor to an own item, checked against WordPress.org.
     619         *
     620         * The slug comes from a result of the search, a suggestion or WP-CLI, never
     621         * typed in a field (1.6.0: the field for a slug or an address is gone), so it
     622         * is only checked against SLUG_PATTERN. What can be decided without asking
     623         * WordPress.org is decided first, and checked again under the lock before
     624         * saving.
    654625         *
    655626         * @param int    $type     Type constant of the own item.
    656627         * @param string $own_slug Slug of the own item.
    657          * @param mixed  $input    Slug or address typed by the user.
     628         * @param mixed  $slug     Slug of the competitor.
    658629         * @param bool   $leader   Whether it is added as a leader.
    659630         * @return array|WP_Error ['slug' => string, 'info' => array] with the answer of
    … …  
    661632         *                        Cresume_Stats_Collector::rival_now().
    662633         */
    663         public static function add( $type, $own_slug, $input, $leader = false ) {
     634        public static function add( $type, $own_slug, $slug, $leader = false ) {
    664635                $type = Cresume_Stats_Store::TYPE_THEME === (int) $type ? Cresume_Stats_Store::TYPE_THEME : Cresume_Stats_Store::TYPE_PLUGIN;
    665636                $own  = self::tracked( $type, $own_slug );
    … …  
    669640                }
    670641
    671                 $parsed = self::parse_input( $input );
    672 
    673                 if ( is_wp_error( $parsed ) ) {
    674                         return $parsed;
    675                 }
    676 
    677                 if ( $parsed['type'] && $parsed['type'] !== $type ) {
    678                         return self::error( 'cresume_rival_type' );
    679                 }
    680 
    681                 $slug = $parsed['slug'];
     642                if ( ! is_string( $slug ) || ! preg_match( self::SLUG_PATTERN, $slug ) ) {
     643                        return self::error( 'cresume_rival_missing' );
     644                }
    682645
    683646                if ( $slug === (string) $own->slug ) {
    … …  
    685648                }
    686649
    687                 $all  = self::all();
    688                 $key  = self::key( $type, $own->slug );
    689                 $mine = isset( $all[ $key ] ) ? $all[ $key ] : array(
    690                         'r' => array(),
    691                         'l' => array(),
    692                         'h' => array(),
    693                 );
     650                $mine = self::of( $type, $own->slug );
    694651
    695652                if ( in_array( $slug, $mine['r'], true ) ) {
    … …  
    717674                }
    718675
    719                 $mine['r'][] = $slug;
    720 
    721                 if ( $leader ) {
    722                         $mine['l'][] = $slug;
    723                 }
    724 
    725                 $mine['h']   = array_values( array_diff( $mine['h'], array( $slug ) ) );
    726                 $all[ $key ] = $mine;
    727                 self::save( $all );
     676                $key   = self::key( $type, $own->slug );
     677                $saved = self::locked(
     678                        function () use ( $key, $slug, $leader ) {
     679                                $all  = self::all();
     680                                $mine = isset( $all[ $key ] ) ? $all[ $key ] : self::blank();
     681
     682                                // Another administrator may have added it, or filled the list, meanwhile.
     683                                if ( in_array( $slug, $mine['r'], true ) ) {
     684                                        return self::error( 'cresume_rival_exists' );
     685                                }
     686
     687                                if ( count( $mine['r'] ) >= self::MAX_PER_ITEM ) {
     688                                        return self::error( 'cresume_rival_full' );
     689                                }
     690
     691                                $mine['r'][] = $slug;
     692
     693                                if ( $leader ) {
     694                                        $mine['l'][] = $slug;
     695                                }
     696
     697                                $mine['h']   = array_values( array_diff( $mine['h'], array( $slug ) ) );
     698                                $all[ $key ] = $mine;
     699                                self::save( $all );
     700
     701                                return true;
     702                        }
     703                );
     704
     705                if ( is_wp_error( $saved ) ) {
     706                        return $saved;
     707                }
    728708
    729709                return array(
    … …  
    743723         */
    744724        public static function remove( $type, $own_slug, $slug ) {
    745                 $all = self::all();
    746                 $key = self::key( $type, $own_slug );
    747 
    748                 if ( ! isset( $all[ $key ] ) || ! in_array( $slug, $all[ $key ]['r'], true ) ) {
    749                         return self::error( 'cresume_rival_unknown' );
    750                 }
    751 
    752                 $all[ $key ]['r'] = array_values( array_diff( $all[ $key ]['r'], array( $slug ) ) );
    753                 $all[ $key ]['l'] = array_values( array_diff( $all[ $key ]['l'], array( $slug ) ) );
    754                 self::save( $all );
     725                $key   = self::key( $type, $own_slug );
     726                $saved = self::locked(
     727                        function () use ( $key, $slug ) {
     728                                $all = self::all();
     729
     730                                if ( ! isset( $all[ $key ] ) || ! in_array( $slug, $all[ $key ]['r'], true ) ) {
     731                                        return self::error( 'cresume_rival_unknown' );
     732                                }
     733
     734                                $all[ $key ]['r'] = array_values( array_diff( $all[ $key ]['r'], array( $slug ) ) );
     735                                $all[ $key ]['l'] = array_values( array_diff( $all[ $key ]['l'], array( $slug ) ) );
     736                                self::save( $all );
     737
     738                                return true;
     739                        }
     740                );
     741
     742                if ( is_wp_error( $saved ) ) {
     743                        return $saved;
     744                }
     745
    755746                self::release_unused();
    756747                Cresume_Stats_Store::touch();
    … …  
    760751
    761752        /**
    762          * Hide a suggestion for an own item.
     753         * Hide a suggestion for an own item. Only one of the suggestions it has now
     754         * (1.6.0): a forged slug that nothing suggests is not stored.
    763755         *
    764756         * @param int    $type     Type constant.
    … …  
    768760         */
    769761        public static function hide( $type, $own_slug, $slug ) {
    770                 if ( ! is_string( $slug ) || ! preg_match( self::SLUG_PATTERN, $slug ) ) {
    771                         return self::error( 'cresume_rival_invalid' );
    772                 }
    773 
    774                 $all = self::all();
    775                 $key = self::key( $type, $own_slug );
    776 
    777                 if ( ! self::tracked( $type, $own_slug ) ) {
     762                $own = self::tracked( $type, $own_slug );
     763
     764                if ( ! $own ) {
    778765                        return self::error( 'cresume_rival_not_tracked' );
    779766                }
    780767
    781                 $mine = isset( $all[ $key ] ) ? $all[ $key ] : array(
    782                         'r' => array(),
    783                         'l' => array(),
    784                         'h' => array(),
    785                 );
    786 
    787                 if ( in_array( $slug, $mine['r'], true ) ) {
     768                $key = self::key( $type, $own->slug );
     769
     770                if ( is_string( $slug ) && in_array( $slug, self::of( $type, $own->slug )['r'], true ) ) {
    788771                        return self::error( 'cresume_rival_exists' );
    789772                }
    790773
    791                 if ( ! in_array( $slug, $mine['h'], true ) ) {
    792                         array_unshift( $mine['h'], $slug );
    793                         $mine['h'] = array_slice( $mine['h'], 0, self::MAX_HIDDEN );
    794                 }
    795 
    796                 $all[ $key ] = $mine;
    797                 self::save( $all );
    798 
    799                 return true;
     774                if ( ! is_string( $slug ) || ! preg_match( self::SLUG_PATTERN, $slug ) || ! in_array( $slug, self::suggested( (int) $own->id ), true ) ) {
     775                        return self::error( 'cresume_rival_not_suggested' );
     776                }
     777
     778                return self::locked(
     779                        function () use ( $key, $slug ) {
     780                                $all  = self::all();
     781                                $mine = isset( $all[ $key ] ) ? $all[ $key ] : self::blank();
     782
     783                                if ( in_array( $slug, $mine['r'], true ) ) {
     784                                        return self::error( 'cresume_rival_exists' );
     785                                }
     786
     787                                if ( ! in_array( $slug, $mine['h'], true ) ) {
     788                                        array_unshift( $mine['h'], $slug );
     789                                        $mine['h'] = array_slice( $mine['h'], 0, self::MAX_HIDDEN );
     790                                }
     791
     792                                $all[ $key ] = $mine;
     793                                self::save( $all );
     794
     795                                return true;
     796                        }
     797                );
     798        }
     799
     800        /**
     801         * Slugs suggested now for an own item, leaders included: what hide() takes.
     802         *
     803         * @param int $item_id Own item id.
     804         * @return string[]
     805         */
     806        private static function suggested( $item_id ) {
     807                $suggest = self::suggest( $item_id );
     808                $slugs   = array();
     809
     810                foreach ( array_merge( $suggest['list'], $suggest['leaders'] ) as $cand ) {
     811                        $slugs[] = (string) $cand['slug'];
     812                }
     813
     814                return $slugs;
    800815        }
    801816
    … …  
    809824         */
    810825        public static function unhide( $type, $own_slug, $slug ) {
    811                 $all = self::all();
    812826                $key = self::key( $type, $own_slug );
    813827
    814                 if ( ! isset( $all[ $key ] ) || ! in_array( $slug, $all[ $key ]['h'], true ) ) {
    815                         return self::error( 'cresume_rival_unknown' );
    816                 }
    817 
    818                 $all[ $key ]['h'] = array_values( array_diff( $all[ $key ]['h'], array( $slug ) ) );
    819                 self::save( $all );
    820 
    821                 return true;
     828                return self::locked(
     829                        function () use ( $key, $slug ) {
     830                                $all = self::all();
     831
     832                                if ( ! isset( $all[ $key ] ) || ! in_array( $slug, $all[ $key ]['h'], true ) ) {
     833                                        return self::error( 'cresume_rival_unknown' );
     834                                }
     835
     836                                $all[ $key ]['h'] = array_values( array_diff( $all[ $key ]['h'], array( $slug ) ) );
     837                                self::save( $all );
     838
     839                                return true;
     840                        }
     841                );
    822842        }
    823843
    … …  
    833853         */
    834854        public static function set_leader( $type, $own_slug, $slug, $leader ) {
    835                 $all = self::all();
    836855                $key = self::key( $type, $own_slug );
    837856
    838                 if ( ! isset( $all[ $key ] ) || ! in_array( $slug, $all[ $key ]['r'], true ) ) {
    839                         return self::error( 'cresume_rival_unknown' );
    840                 }
    841 
    842                 $l = array_values( array_diff( $all[ $key ]['l'], array( $slug ) ) );
    843 
    844                 if ( $leader ) {
    845                         $l[] = $slug;
    846                 }
    847 
    848                 $all[ $key ]['l'] = $l;
    849                 self::save( $all );
    850 
    851                 return true;
     857                return self::locked(
     858                        function () use ( $key, $slug, $leader ) {
     859                                $all = self::all();
     860
     861                                if ( ! isset( $all[ $key ] ) || ! in_array( $slug, $all[ $key ]['r'], true ) ) {
     862                                        return self::error( 'cresume_rival_unknown' );
     863                                }
     864
     865                                $l = array_values( array_diff( $all[ $key ]['l'], array( $slug ) ) );
     866
     867                                if ( $leader ) {
     868                                        $l[] = $slug;
     869                                }
     870
     871                                $all[ $key ]['l'] = $l;
     872                                self::save( $all );
     873
     874                                return true;
     875                        }
     876                );
     877        }
     878
     879        /**
     880         * Run a change of the choice under its lock (1.6.0), with the option read
     881         * again from the database. Two administrators saving at the same time no
     882         * longer lose one of the changes (informative of the level 2 review of
     883         * 1.6.0). It waits up to LOCK_WAIT seconds for a change in progress; the
     884         * lock is the one of Cresume_Stats_Lock, with its expiry for a worker that
     885         * died holding it.
     886         *
     887         * @param callable $change Reads all(), changes it and saves it; returns true or a WP_Error.
     888         * @return true|WP_Error
     889         */
     890        private static function locked( callable $change ) {
     891                $until = microtime( true ) + self::LOCK_WAIT;
     892                $token = Cresume_Stats_Lock::acquire( self::LOCK_OPTION, self::LOCK_TTL );
     893
     894                while ( false === $token ) {
     895                        if ( microtime( true ) >= $until ) {
     896                                return self::error( 'cresume_rival_busy' );
     897                        }
     898
     899                        usleep( 100000 );
     900                        $token = Cresume_Stats_Lock::acquire( self::LOCK_OPTION, self::LOCK_TTL );
     901                }
     902
     903                // What this request read before taking the lock may be behind.
     904                Cresume_Stats_Lock::forget( self::OPTION );
     905
     906                try {
     907                        return $change();
     908                } finally {
     909                        Cresume_Stats_Lock::release( self::LOCK_OPTION, $token );
     910                }
     911        }
     912
     913        /**
     914         * An empty choice for one own item.
     915         *
     916         * @return array ['r' => [], 'l' => [], 'h' => []].
     917         */
     918        private static function blank() {
     919                return array(
     920                        'r' => array(),
     921                        'l' => array(),
     922                        'h' => array(),
     923                );
    852924        }
    853925
    … …  
    9361008        public static function error( $code ) {
    9371009                $messages = array(
    938                         'cresume_rival_invalid'     => __( 'Type the slug of a plugin or theme, or its address on WordPress.org.', 'contributor-resume' ),
    939                         'cresume_rival_not_tracked' => __( 'That plugin or theme is not one of the ones the stats follow.', 'contributor-resume' ),
    940                         'cresume_rival_type'        => __( 'A plugin can only compete with plugins, and a theme with themes.', 'contributor-resume' ),
    941                         'cresume_rival_self'        => __( 'A plugin or theme cannot be its own competitor.', 'contributor-resume' ),
     1010                        'cresume_rival_not_tracked'   => __( 'That plugin or theme is not one of the ones the stats follow.', 'contributor-resume' ),
     1011                        'cresume_rival_self'          => __( 'A plugin or theme cannot be its own competitor.', 'contributor-resume' ),
     1012                        'cresume_rival_not_suggested' => __( 'That plugin or theme is not among the suggestions any more.', 'contributor-resume' ),
     1013                        'cresume_rival_busy'          => __( 'Someone else is changing these competitors right now. Try again in a moment.', 'contributor-resume' ),
    9421014                        'cresume_rival_exists'      => __( 'It is already one of its competitors.', 'contributor-resume' ),
    9431015                        /* translators: %d: maximum number of competitors */
  • contributor-resume/trunk/includes/stats/class-cresume-stats-view.php

    r3789183 r3789662  
    994994                }
    995995
    996                 $max = max( 1, max( array_map( 'intval', $item['ratings'] ) ) );
     996                // max() of an empty array throws ValueError in PHP 8 (1.6.0): with
     997                // num_ratings above zero and no breakdown, the bars are drawn empty.
     998                $counts = is_array( $item['ratings'] ) ? array_map( 'intval', $item['ratings'] ) : array();
     999                $max    = $counts ? max( 1, max( $counts ) ) : 1;
    9971000                ?>
    9981001                <ul class="cresume-stats-bars">
    … …  
    17431746                $add     = wp_create_nonce( 'cresume_stats_rival_add' );
    17441747                $hide    = wp_create_nonce( 'cresume_stats_rival_hide' );
     1748                $max     = number_format_i18n( Cresume_Stats_Rivals::MAX_PER_ITEM );
    17451749                ?>
    17461750                <section class="cresume-card cresume-stats-rivals">
    … …  
    17541758                                                <?php
    17551759                                                /* translators: 1: competitors chosen, 2: maximum */
    1756                                                 echo esc_html( sprintf( __( '%1$s of %2$s · read every day, like your own', 'contributor-resume' ), number_format_i18n( count( $rows ) ), number_format_i18n( Cresume_Stats_Rivals::MAX_PER_ITEM ) ) );
     1760                                                echo esc_html( sprintf( __( '%1$s of %2$s · read every day, like your own', 'contributor-resume' ), number_format_i18n( count( $rows ) ), $max ) );
    17571761                                                ?>
    17581762                                        </span>
     1763                                        <?php if ( $full ) : ?>
     1764                                                <span class="cresume-stats-chip cresume-stats-chip--warn"><?php esc_html_e( 'Full', 'contributor-resume' ); ?></span>
     1765                                        <?php endif; ?>
    17591766                                </h2>
    17601767                                <a class="button" href="<?php echo esc_url( $back ); ?>"><?php esc_html_e( 'Back to Competitors', 'contributor-resume' ); ?></a>
    … …  
    17641771                                <?php self::rival_list( $rows, $item, true ); ?>
    17651772                        <?php else : ?>
    1766                                 <p class="cresume-stats-intro"><?php esc_html_e( 'No competitors yet. Pick from the suggestions below, or add any plugin by its slug or its address on WordPress.org.', 'contributor-resume' ); ?></p>
     1773                                <p class="cresume-stats-intro"><?php esc_html_e( 'No competitors yet. Search for them below, or pick from the suggestions.', 'contributor-resume' ); ?></p>
    17671774                        <?php endif; ?>
    17681775
    1769                         <form method="post" action="<?php echo esc_url( admin_url( 'admin-post.php' ) ); ?>" class="cresume-stats-rival-add">
    1770                                 <input type="hidden" name="action" value="cresume_stats_rival_add" />
    1771                                 <input type="hidden" name="item" value="<?php echo esc_attr( $item->id ); ?>" />
    1772                                 <input type="hidden" name="_wpnonce" value="<?php echo esc_attr( $add ); ?>" />
    1773                                 <label for="cresume-stats-rival-input"><?php esc_html_e( 'Add a competitor', 'contributor-resume' ); ?></label>
    1774                                 <input type="text" id="cresume-stats-rival-input" name="competitor" class="regular-text" placeholder="<?php echo esc_attr( Cresume_Stats_Store::TYPE_THEME === $type ? 'astra  ·  https://wordpress.org/themes/astra/' : 'ninjafirewall  ·  https://wordpress.org/plugins/ninjafirewall/' ); ?>" aria-describedby="cresume-stats-rival-help"<?php disabled( $full ); ?> />
    1775                                 <label class="cresume-inline"><input type="checkbox" name="leader" value="1"<?php disabled( $full ); ?> /> <?php esc_html_e( 'As a leader', 'contributor-resume' ); ?></label>
    1776                                 <?php submit_button( __( 'Add competitor', 'contributor-resume' ), 'primary', 'submit', false, $full ? array( 'disabled' => 'disabled' ) : array() ); ?>
    1777                                 <p class="description" id="cresume-stats-rival-help">
    1778                                         <?php
    1779                                         if ( $full ) {
     1776                        <?php if ( $full ) : ?>
     1777                                <div class="notice notice-warning inline cresume-stats-rival-full">
     1778                                        <p>
     1779                                                <strong><?php esc_html_e( 'No room for more competitors.', 'contributor-resume' ); ?></strong>
     1780                                                <?php
    17801781                                                /* translators: %s: maximum number of competitors */
    1781                                                 echo esc_html( sprintf( __( 'Each plugin or theme can have up to %s competitors. Remove one to add another.', 'contributor-resume' ), number_format_i18n( Cresume_Stats_Rivals::MAX_PER_ITEM ) ) );
    1782                                         } else {
    1783                                                 esc_html_e( 'Its slug or its address on WordPress.org. It is checked there before it is added. A leader is read like the rest, but it is not counted when comparing who ranks better.', 'contributor-resume' );
    1784                                         }
    1785                                         ?>
    1786                                 </p>
    1787                         </form>
     1782                                                echo esc_html( sprintf( __( 'Each plugin or theme can have up to %s competitors. Remove one to add another.', 'contributor-resume' ), $max ) );
     1783                                                ?>
     1784                                        </p>
     1785                                </div>
     1786                        <?php else : ?>
     1787                                <?php self::search_form( $state, $item ); ?>
     1788                        <?php endif; ?>
    17881789                </section>
    17891790
    … …  
    17981799                                </span>
    17991800                        </h2>
     1801
     1802                        <?php if ( $full && ( $suggest['list'] || $suggest['leaders'] ) ) : ?>
     1803                                <p class="description cresume-stats-rival-full-note"><?php esc_html_e( 'The list of competitors is full: remove one to add a suggestion.', 'contributor-resume' ); ?></p>
     1804                        <?php endif; ?>
    18001805
    18011806                        <?php if ( ! $suggest['of'] ) : ?>
    … …  
    18491854                        <?php endif; ?>
    18501855                </section>
     1856                <?php
     1857        }
     1858
     1859        /**
     1860         * The search to add a competitor (1.6.0): a GET form of the same view, so
     1861         * Enter works without JavaScript, with the results of `rq` drawn below.
     1862         * stats.js searches as the text changes and puts its results in the same
     1863         * place. A leader is marked afterwards, in the list.
     1864         *
     1865         * @param array  $state View state.
     1866         * @param object $item  Own item row.
     1867         */
     1868        private static function search_form( array $state, $item ) {
     1869                $is_theme = Cresume_Stats_Store::TYPE_THEME === (int) $item->type;
     1870                $args     = Cresume_Stats_Admin::view_args( array_merge( $state, array( 'orderby' => '', 'order' => '' ) ) );
     1871                ?>
     1872                <div class="cresume-stats-rival-add">
     1873                        <h3><?php esc_html_e( 'Add a competitor', 'contributor-resume' ); ?></h3>
     1874                        <form method="get" action="<?php echo esc_url( admin_url( 'options-general.php' ) ); ?>" class="cresume-stats-rival-search" role="search" data-item="<?php echo esc_attr( (string) (int) $item->id ); ?>" data-min="<?php echo esc_attr( (string) Cresume_Stats_Fetcher::SEARCH_MIN ); ?>">
     1875                                <?php foreach ( $args as $name => $value ) : ?>
     1876                                        <input type="hidden" name="<?php echo esc_attr( $name ); ?>" value="<?php echo esc_attr( (string) $value ); ?>" />
     1877                                <?php endforeach; ?>
     1878                                <label for="cresume-stats-rival-input"><?php echo $is_theme ? esc_html__( 'Search for a theme', 'contributor-resume' ) : esc_html__( 'Search for a plugin', 'contributor-resume' ); ?></label>
     1879                                <input type="search" id="cresume-stats-rival-input" name="rq" value="<?php echo esc_attr( $state['rq'] ); ?>" class="regular-text" maxlength="<?php echo esc_attr( (string) Cresume_Stats_Fetcher::SEARCH_MAX ); ?>" autocomplete="off" spellcheck="false" aria-describedby="cresume-stats-rival-help" aria-controls="cresume-stats-rival-results" />
     1880                                <button type="submit" class="button"><?php esc_html_e( 'Search', 'contributor-resume' ); ?></button>
     1881                                <p class="description" id="cresume-stats-rival-help"><?php esc_html_e( 'By name or slug, as in the WordPress.org directory. Results appear as you type. A leader is marked afterwards, in the list.', 'contributor-resume' ); ?></p>
     1882                        </form>
     1883                        <div id="cresume-stats-rival-results" class="cresume-stats-search-results">
     1884                                <?php $status = '' !== $state['rq'] ? self::search_results( $item, $state['rq'] ) : ''; ?>
     1885                        </div>
     1886                        <p id="cresume-stats-rival-status" class="screen-reader-text" role="status"><?php echo esc_html( $status ); ?></p>
     1887                </div>
     1888                <?php
     1889        }
     1890
     1891        /**
     1892         * Results of the search of competitors (1.6.0), drawn here both for the
     1893         * search as you type (Cresume_Stats_Admin::ajax_rival_search()) and for the
     1894         * one sent with Enter. Names and icons come from WordPress.org: every value
     1895         * is escaped here, and stats.js only inserts this HTML.
     1896         *
     1897         * @param object $item Own item row.
     1898         * @param string $text Text searched (Cresume_Stats_Admin::search_text()).
     1899         * @return string What screen readers hear: how many results, or why none.
     1900         */
     1901        public static function search_results( $item, $text ) {
     1902                $text = (string) $text;
     1903
     1904                if ( mb_strlen( $text, 'UTF-8' ) < Cresume_Stats_Fetcher::SEARCH_MIN ) {
     1905                        return '';
     1906                }
     1907
     1908                $type     = (int) $item->type;
     1909                $is_theme = Cresume_Stats_Store::TYPE_THEME === $type;
     1910                $fetcher  = new Cresume_Stats_Fetcher();
     1911                $answer   = $fetcher->search( $type, $text );
     1912
     1913                if ( is_wp_error( $answer ) ) {
     1914                        $message = __( 'WordPress.org did not answer. Try again in a minute.', 'contributor-resume' );
     1915                        echo '<p class="cresume-stats-search-none">' . esc_html( $message ) . '</p>';
     1916
     1917                        return $message;
     1918                }
     1919
     1920                if ( ! $answer['list'] ) {
     1921                        $message = $is_theme
     1922                                /* translators: %s: text searched */
     1923                                ? sprintf( __( 'No themes found for “%s”.', 'contributor-resume' ), $text )
     1924                                /* translators: %s: text searched */
     1925                                : sprintf( __( 'No plugins found for “%s”.', 'contributor-resume' ), $text );
     1926                        echo '<p class="cresume-stats-search-none">' . esc_html( $message ) . '</p>';
     1927
     1928                        return $message;
     1929                }
     1930
     1931                $choice = Cresume_Stats_Rivals::of( $type, $item->slug );
     1932                $full   = count( $choice['r'] ) >= Cresume_Stats_Rivals::MAX_PER_ITEM;
     1933                $known  = Cresume_Stats_Store::known_items_by_slug( $type );
     1934                $add    = wp_create_nonce( 'cresume_stats_rival_add' );
     1935                ?>
     1936                <ul class="cresume-stats-suggest-grid" role="list">
     1937                        <?php
     1938                        foreach ( $answer['list'] as $row ) {
     1939                                self::search_result( $row, $item, $choice['r'], $known, $full, $add );
     1940                        }
     1941                        ?>
     1942                </ul>
     1943                <?php
     1944                /* translators: %s: number of results */
     1945                return sprintf( _n( '%s result', '%s results', count( $answer['list'] ), 'contributor-resume' ), number_format_i18n( count( $answer['list'] ) ) );
     1946        }
     1947
     1948        /**
     1949         * One result of the search: who it is and, when it can be one, a button
     1950         * that adds it as a competitor.
     1951         *
     1952         * @param array    $row    Row of Cresume_Stats_Fetcher::search().
     1953         * @param object   $item   Own item row.
     1954         * @param string[] $chosen Slugs of its competitors.
     1955         * @param array    $known  Cresume_Stats_Store::known_items_by_slug().
     1956         * @param bool     $full   Whether the item has no room for more.
     1957         * @param string   $add    Nonce of the add handler.
     1958         */
     1959        private static function search_result( array $row, $item, array $chosen, array $known, $full, $add ) {
     1960                $is_theme = Cresume_Stats_Store::TYPE_THEME === (int) $item->type;
     1961                $slug     = (string) $row['slug'];
     1962                $name     = '' !== $row['name'] ? Cresume_Stats_Query::short_name( $row['name'] ) : $slug;
     1963                $self     = $slug === (string) $item->slug;
     1964                $taken    = in_array( $slug, $chosen, true );
     1965                $own      = ! $self && isset( $known[ $slug ] ) && 1 === $known[ $slug ][1];
     1966
     1967                /* translators: 1: slug, 2: active installs */
     1968                $meta = sprintf( __( '%1$s · %2$s+ installs', 'contributor-resume' ), $slug, number_format_i18n( (int) $row['installs'] ) );
     1969                ?>
     1970                <li class="cresume-stats-suggest-card">
     1971                        <?php self::rival_icon( (string) $row['icon'], $name ); ?>
     1972                        <div class="cresume-stats-suggest-body">
     1973                                <a href="<?php echo esc_url( 'https://wordpress.org/' . ( $is_theme ? 'themes' : 'plugins' ) . '/' . rawurlencode( $slug ) . '/' ); ?>" target="_blank" rel="noopener noreferrer"><strong><?php echo esc_html( $name ); ?></strong><span class="screen-reader-text"> <?php esc_html_e( '(opens in a new tab)', 'contributor-resume' ); ?></span></a>
     1974                                <span class="cresume-stats-sub">
     1975                                        <?php echo esc_html( $meta ); ?>
     1976                                        <?php if ( (int) $row['num_ratings'] > 0 ) : ?>
     1977                                                · <?php echo esc_html( number_format_i18n( (int) $row['rating'] / 20, 1 ) ); ?> ★ (<?php echo esc_html( number_format_i18n( (int) $row['num_ratings'] ) ); ?>)
     1978                                        <?php endif; ?>
     1979                                </span>
     1980                                <?php if ( $self || $taken || $own ) : ?>
     1981                                        <span class="cresume-stats-reasons">
     1982                                                <?php if ( $self ) : ?>
     1983                                                        <span class="cresume-stats-chip"><?php echo $is_theme ? esc_html__( 'This theme', 'contributor-resume' ) : esc_html__( 'This plugin', 'contributor-resume' ); ?></span>
     1984                                                <?php elseif ( $taken ) : ?>
     1985                                                        <span class="cresume-stats-chip cresume-stats-chip--info"><?php esc_html_e( 'Already a competitor', 'contributor-resume' ); ?></span>
     1986                                                <?php else : ?>
     1987                                                        <span class="cresume-stats-chip"><?php esc_html_e( 'Yours', 'contributor-resume' ); ?></span>
     1988                                                <?php endif; ?>
     1989                                        </span>
     1990                                <?php endif; ?>
     1991                        </div>
     1992                        <?php if ( ! $self && ! $taken ) : ?>
     1993                                <div class="cresume-stats-suggest-actions">
     1994                                        <form method="post" action="<?php echo esc_url( admin_url( 'admin-post.php' ) ); ?>">
     1995                                                <input type="hidden" name="action" value="cresume_stats_rival_add" />
     1996                                                <input type="hidden" name="item" value="<?php echo esc_attr( (string) (int) $item->id ); ?>" />
     1997                                                <input type="hidden" name="competitor" value="<?php echo esc_attr( $slug ); ?>" />
     1998                                                <input type="hidden" name="_wpnonce" value="<?php echo esc_attr( $add ); ?>" />
     1999                                                <button type="submit" class="button button-primary"<?php disabled( $full ); ?>>
     2000                                                        <?php esc_html_e( 'Add', 'contributor-resume' ); ?>
     2001                                                        <span class="screen-reader-text"><?php echo esc_html( $name ); ?></span>
     2002                                                </button>
     2003                                        </form>
     2004                                </div>
     2005                        <?php endif; ?>
     2006                </li>
    18512007                <?php
    18522008        }
  • contributor-resume/trunk/includes/stats/class-cresume-stats-widget.php

    r3789183 r3789662  
    158158                }
    159159
    160                 $layout = isset( $raw['layout'] ) ? (string) $raw['layout'] : '';
    161                 $days   = isset( $raw['days'] ) ? (int) $raw['days'] : 0;
    162                 $sort   = isset( $raw['sort'] ) ? (string) $raw['sort'] : '';
    163                 $rows   = isset( $raw['rows'] ) ? (int) $raw['rows'] : 0;
     160                // Arrays where text is expected come out as '' (1.6.0): a forged
     161                // `layout[]` gave «Array to string conversion» before.
     162                $layout = isset( $raw['layout'] ) ? self::text( $raw['layout'] ) : '';
     163                $days   = isset( $raw['days'] ) && is_scalar( $raw['days'] ) ? (int) $raw['days'] : 0;
     164                $sort   = isset( $raw['sort'] ) ? self::text( $raw['sort'] ) : '';
     165                $rows   = isset( $raw['rows'] ) && is_scalar( $raw['rows'] ) ? (int) $raw['rows'] : 0;
    164166                $item   = isset( $raw['item'] ) ? self::id( $raw['item'] ) : 0;
    165167                $picked = array();
    … …  
    176178
    177179                foreach ( ( isset( $raw['figures'] ) ? (array) $raw['figures'] : array() ) as $figure ) {
    178                         $figure = sanitize_key( (string) $figure );
     180                        $figure = sanitize_key( self::text( $figure ) );
    179181
    180182                        if ( in_array( $figure, self::FIGURES, true ) ) {
    … …  
    194196                        'alerts'  => ! empty( $raw['alerts'] ),
    195197                );
     198        }
     199
     200        /**
     201         * A posted value as text, or '' when it is not a single value.
     202         *
     203         * @param mixed $value Posted value.
     204         * @return string
     205         */
     206        private static function text( $value ) {
     207                return is_scalar( $value ) ? (string) $value : '';
    196208        }
    197209
  • contributor-resume/trunk/readme.txt

    r3789206 r3789662  
    147147= Can I track my competitors? =
    148148
    149 Yes. In the Competitors view of the Stats tab, add up to 10 competitors to each plugin or theme, by slug or by their WordPress.org address, or pick them from the suggestions. They are read every day like your own. You compare them in a head-to-head table, keyword by keyword, in one downloads chart, and in the alerts and emails.
     149Yes. In the Competitors view of the Stats tab, search the directory by name or slug and add up to 10 competitors to each plugin or theme, or pick them from the suggestions. They are read every day like your own. You compare them in a head-to-head table, keyword by keyword, in one downloads chart, and in the alerts and emails.
    150150
    151151= How are competitors suggested? =
    … …  
    235235Competitor tracking: choose the plugins you compete with and follow them every day, with a head to head view, keyword positions, alerts, emails and a dashboard widget. The plugin is now called Contributor Résumé, Rank and Competitor Tracker.
    236236
    237 * New: Competitor tracking. Choose up to 10 competitors for each plugin or theme, by slug or WordPress.org address, or from suggestions taken from your own keywords, each with the reason it is there. They are read every day like your own, with two years of download history from the first day. Market leaders are suggested apart: a leader is followed like the rest but not counted when comparing who ranks better.
     237* New: Competitor tracking. Choose up to 10 competitors for each plugin or theme with a search of the directory by name or slug, with results as you type, or from suggestions taken from your own keywords, each with the reason it is there. They are read every day like your own, with two years of download history from the first day. Market leaders are suggested apart: a leader is followed like the rest but not counted when comparing who ranks better. When a plugin or theme has its 10, the screen says so and explains how to make room.
    238238* New: Competitors view in Stats. Five figures, a sortable head-to-head table, a keyword-by-keyword table that shows where you rank better, one downloads chart for all of them (on a logarithmic scale or as an index that starts everyone at 100), your share of the top 10 of your keywords and the opportunities it shows.
    239239* New: Competitor alerts, in the Alerts view and in the emails. A competitor passes you or falls behind, enters or leaves the top 10, ranks for one of your keywords for the first time, releases a version, says it is tested with a newer WordPress than yours, changes its installs range or rating, gets a spike in downloads without a release, goes 6 or 12 months without an update or is closed on WordPress.org.
    … …  
    246246* Improved: On the page of each plugin or theme, the downloads chart is a box you can move, fold or hide like the others, next to active installs and at the same height. Translations and version adoption line up as well.
    247247* Improved: The stats tables are updated automatically to keep the competitors and their positions.
     248* Improved: The dashboard widget settings ignore unexpected values without a PHP notice, and the rating bars are drawn empty when WordPress.org gives no breakdown of the ratings.
    248249
    249250For older changelog entries, please check the [changelog.txt](https://plugins.svn.wordpress.org/contributor-resume/trunk/changelog.txt) file
  • contributor-resume/trunk/uninstall.php

    r3789183 r3789662  
    3232        delete_option( 'cresume_stats_lock' );
    3333        delete_option( 'cresume_stats_mail_lock' );
     34        delete_option( 'cresume_stats_rivals_lock' );
     35
     36        // The search of competitors (1.6.0) keeps each answer for 15 minutes:
     37        // nothing worth keeping, so it goes even when the data is kept.
     38        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- One-off bulk transient cleanup on uninstall; caching does not apply here.
     39        $wpdb->query(
     40                "DELETE FROM {$wpdb->options}
     41                 WHERE option_name LIKE '\_transient\_cresume\_srch\_%'
     42                 OR option_name LIKE '\_transient\_timeout\_cresume\_srch\_%'"
     43        );
    3444
    3545        // Respect the "keep settings and cached data" choice.
Note: See TracChangeset for help on using the changeset viewer.