Changeset 3789662
- Timestamp:
- 11/07/2026 01:22:55 PM (less than one hour ago)
- Location:
- contributor-resume
- Files:
-
- 24 edited
-
tags/1.6.0/assets/css/stats.css (modified) (1 diff)
-
tags/1.6.0/assets/js/stats.js (modified) (3 diffs)
-
tags/1.6.0/changelog.txt (modified) (2 diffs)
-
tags/1.6.0/includes/stats/class-cresume-stats-admin.php (modified) (7 diffs)
-
tags/1.6.0/includes/stats/class-cresume-stats-cli.php (modified) (3 diffs)
-
tags/1.6.0/includes/stats/class-cresume-stats-fetcher.php (modified) (4 diffs)
-
tags/1.6.0/includes/stats/class-cresume-stats-lock.php (modified) (1 diff)
-
tags/1.6.0/includes/stats/class-cresume-stats-rivals.php (modified) (12 diffs)
-
tags/1.6.0/includes/stats/class-cresume-stats-view.php (modified) (6 diffs)
-
tags/1.6.0/includes/stats/class-cresume-stats-widget.php (modified) (3 diffs)
-
tags/1.6.0/readme.txt (modified) (3 diffs)
-
tags/1.6.0/uninstall.php (modified) (1 diff)
-
trunk/assets/css/stats.css (modified) (1 diff)
-
trunk/assets/js/stats.js (modified) (3 diffs)
-
trunk/changelog.txt (modified) (2 diffs)
-
trunk/includes/stats/class-cresume-stats-admin.php (modified) (7 diffs)
-
trunk/includes/stats/class-cresume-stats-cli.php (modified) (3 diffs)
-
trunk/includes/stats/class-cresume-stats-fetcher.php (modified) (4 diffs)
-
trunk/includes/stats/class-cresume-stats-lock.php (modified) (1 diff)
-
trunk/includes/stats/class-cresume-stats-rivals.php (modified) (12 diffs)
-
trunk/includes/stats/class-cresume-stats-view.php (modified) (6 diffs)
-
trunk/includes/stats/class-cresume-stats-widget.php (modified) (3 diffs)
-
trunk/readme.txt (modified) (3 diffs)
-
trunk/uninstall.php (modified) (1 diff)
Legend:
- Unmodified
- Added
- Removed
-
contributor-resume/tags/1.6.0/assets/css/stats.css
r3789183 r3789662 1555 1555 } 1556 1556 1557 /* The search to add a competitor (1.6.0) and the notice when the list is full. */ 1558 1557 1559 .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 { 1558 1570 display: flex; 1559 1571 flex-wrap: wrap; 1560 1572 align-items: center; 1561 1573 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 { 1572 1581 flex-basis: 100%; 1573 1582 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; 1574 1608 } 1575 1609 -
contributor-resume/tags/1.6.0/assets/js/stats.js
r3789183 r3789662 12 12 * value/max of the <progress> element and in numeric SVG attributes. The 13 13 * 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. 15 18 */ 16 19 ( function () { … … 499 502 } 500 503 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 501 614 initPrefs(); 502 615 initKeywordBox(); … … 506 619 initSwitcher(); 507 620 initScale(); 621 initRivalSearch(); 508 622 }() ); -
contributor-resume/tags/1.6.0/changelog.txt
r3789206 r3789662 5 5 6 6 = 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. 8 8 * 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. 9 9 * 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. … … 16 16 * 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. 17 17 * 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. 18 19 19 20 = 1.5.4 = -
contributor-resume/tags/1.6.0/includes/stats/class-cresume-stats-admin.php
r3789183 r3789662 78 78 * @var string[] 79 79 */ 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' ); 81 81 82 82 /** … … 119 119 add_action( 'wp_ajax_cresume_stats_collect', array( $this, 'ajax_collect' ) ); 120 120 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' ) ); 121 122 add_filter( 'nonce_life', array( $this, 'view_nonce_life' ), 10, 2 ); 122 123 } … … 284 285 'paged' => 1, 285 286 'ov' => 'rivals', 287 'rq' => '', 286 288 ); 287 289 } … … 360 362 } 361 363 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 362 371 // The list of alerts: a topic of the closed list (anything else is all of 363 372 // them) and a page of 1 or more. Neither is remembered in the cookie. … … 716 725 'cresumeStats', 717 726 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( 722 731 'name' => self::COOKIE, 723 732 'path' => (string) wp_parse_url( admin_url(), PHP_URL_PATH ), 724 733 ), 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' ), 729 740 ), 730 741 ) 731 742 ); 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' ); 732 756 } 733 757 … … 1828 1852 $item = self::rival_item( isset( $_POST['item'] ) ? absint( $_POST['item'] ) : 0 ); 1829 1853 1830 // A slug or an address: wp_strip_all_tags() keeps the %XX octets that1831 // 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'] ) ) : ''; 1833 1857 $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 ); 1835 1859 1836 1860 if ( is_wp_error( $result ) ) { … … 2040 2064 2041 2065 /** 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 /** 2042 2102 * Stop unless the user may manage the site. 2043 2103 */ -
contributor-resume/tags/1.6.0/includes/stats/class-cresume-stats-cli.php
r3789183 r3789662 377 377 * 378 378 * <competitor> 379 * : Slug of the competitor , or its addresson WordPress.org.379 * : Slug of the competitor on WordPress.org. 380 380 * 381 381 * [--type=<type>] … … 391 391 * 392 392 * wp cresume stats competitors add vigilante ninjafirewall 393 * wp cresume stats competitors add vigilante https://wordpress.org/plugins/wordfence/--leader393 * wp cresume stats competitors add vigilante wordfence --leader 394 394 * 395 395 * @param array $args Positional args. … … 400 400 401 401 $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'] ) ); 403 404 404 405 if ( is_wp_error( $result ) ) { -
contributor-resume/tags/1.6.0/includes/stats/class-cresume-stats-fetcher.php
r3789183 r3789662 57 57 */ 58 58 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_'; 59 95 60 96 /** … … 308 344 } 309 345 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 ); 329 347 330 348 $updated = ''; … … 338 356 'slug' => $slug, 339 357 'name' => self::clean_text( isset( $raw['name'] ) ? $raw['name'] : $slug, 255 ), 340 'icon' => strlen( $icon ) <= 255 ? $icon : '',358 'icon' => $icon, 341 359 'added' => self::clean_date( isset( $raw['added'] ) ? $raw['added'] : '' ), 342 360 'tags' => $is_theme ? array() : self::clean_tags( isset( $raw['tags'] ) ? $raw['tags'] : array() ), … … 358 376 359 377 /** 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 /** 360 546 * Daily downloads, oldest first, up to yesterday (UTC). 361 547 * -
contributor-resume/tags/1.6.0/includes/stats/class-cresume-stats-lock.php
r3789183 r3789662 108 108 109 109 /** 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. 111 113 * 112 114 * @param string $name Option name. 113 115 */ 114 p rivatestatic function forget( $name ) {116 public static function forget( $name ) { 115 117 wp_cache_delete( $name, 'options' ); 116 118 -
contributor-resume/tags/1.6.0/includes/stats/class-cresume-stats-rivals.php
r3789183 r3789662 90 90 91 91 /** 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 /** 92 114 * Key of an own item. 93 115 * … … 594 616 595 617 /** 596 * Read what the user typed: a slug, or the address of a plugin or theme on597 * 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 that602 * 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 bare606 * 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 /**653 618 * 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. 654 625 * 655 626 * @param int $type Type constant of the own item. 656 627 * @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. 658 629 * @param bool $leader Whether it is added as a leader. 659 630 * @return array|WP_Error ['slug' => string, 'info' => array] with the answer of … … 661 632 * Cresume_Stats_Collector::rival_now(). 662 633 */ 663 public static function add( $type, $own_slug, $ input, $leader = false ) {634 public static function add( $type, $own_slug, $slug, $leader = false ) { 664 635 $type = Cresume_Stats_Store::TYPE_THEME === (int) $type ? Cresume_Stats_Store::TYPE_THEME : Cresume_Stats_Store::TYPE_PLUGIN; 665 636 $own = self::tracked( $type, $own_slug ); … … 669 640 } 670 641 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 } 682 645 683 646 if ( $slug === (string) $own->slug ) { … … 685 648 } 686 649 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 ); 694 651 695 652 if ( in_array( $slug, $mine['r'], true ) ) { … … 717 674 } 718 675 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 } 728 708 729 709 return array( … … 743 723 */ 744 724 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 755 746 self::release_unused(); 756 747 Cresume_Stats_Store::touch(); … … 760 751 761 752 /** 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. 763 755 * 764 756 * @param int $type Type constant. … … 768 760 */ 769 761 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 ) { 778 765 return self::error( 'cresume_rival_not_tracked' ); 779 766 } 780 767 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 ) ) { 788 771 return self::error( 'cresume_rival_exists' ); 789 772 } 790 773 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; 800 815 } 801 816 … … 809 824 */ 810 825 public static function unhide( $type, $own_slug, $slug ) { 811 $all = self::all();812 826 $key = self::key( $type, $own_slug ); 813 827 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 ); 822 842 } 823 843 … … 833 853 */ 834 854 public static function set_leader( $type, $own_slug, $slug, $leader ) { 835 $all = self::all();836 855 $key = self::key( $type, $own_slug ); 837 856 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 ); 852 924 } 853 925 … … 936 1008 public static function error( $code ) { 937 1009 $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' ), 942 1014 'cresume_rival_exists' => __( 'It is already one of its competitors.', 'contributor-resume' ), 943 1015 /* translators: %d: maximum number of competitors */ -
contributor-resume/tags/1.6.0/includes/stats/class-cresume-stats-view.php
r3789183 r3789662 994 994 } 995 995 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; 997 1000 ?> 998 1001 <ul class="cresume-stats-bars"> … … 1743 1746 $add = wp_create_nonce( 'cresume_stats_rival_add' ); 1744 1747 $hide = wp_create_nonce( 'cresume_stats_rival_hide' ); 1748 $max = number_format_i18n( Cresume_Stats_Rivals::MAX_PER_ITEM ); 1745 1749 ?> 1746 1750 <section class="cresume-card cresume-stats-rivals"> … … 1754 1758 <?php 1755 1759 /* 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 ) ); 1757 1761 ?> 1758 1762 </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; ?> 1759 1766 </h2> 1760 1767 <a class="button" href="<?php echo esc_url( $back ); ?>"><?php esc_html_e( 'Back to Competitors', 'contributor-resume' ); ?></a> … … 1764 1771 <?php self::rival_list( $rows, $item, true ); ?> 1765 1772 <?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> 1767 1774 <?php endif; ?> 1768 1775 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 1780 1781 /* 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; ?> 1788 1789 </section> 1789 1790 … … 1798 1799 </span> 1799 1800 </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; ?> 1800 1805 1801 1806 <?php if ( ! $suggest['of'] ) : ?> … … 1849 1854 <?php endif; ?> 1850 1855 </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> 1851 2007 <?php 1852 2008 } -
contributor-resume/tags/1.6.0/includes/stats/class-cresume-stats-widget.php
r3789183 r3789662 158 158 } 159 159 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; 164 166 $item = isset( $raw['item'] ) ? self::id( $raw['item'] ) : 0; 165 167 $picked = array(); … … 176 178 177 179 foreach ( ( isset( $raw['figures'] ) ? (array) $raw['figures'] : array() ) as $figure ) { 178 $figure = sanitize_key( (string) $figure);180 $figure = sanitize_key( self::text( $figure ) ); 179 181 180 182 if ( in_array( $figure, self::FIGURES, true ) ) { … … 194 196 'alerts' => ! empty( $raw['alerts'] ), 195 197 ); 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 : ''; 196 208 } 197 209 -
contributor-resume/tags/1.6.0/readme.txt
r3789206 r3789662 147 147 = Can I track my competitors? = 148 148 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.149 Yes. 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. 150 150 151 151 = How are competitors suggested? = … … 235 235 Competitor 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. 236 236 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. 238 238 * 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. 239 239 * 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. … … 246 246 * 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. 247 247 * 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. 248 249 249 250 For 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 32 32 delete_option( 'cresume_stats_lock' ); 33 33 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 ); 34 44 35 45 // Respect the "keep settings and cached data" choice. -
contributor-resume/trunk/assets/css/stats.css
r3789183 r3789662 1555 1555 } 1556 1556 1557 /* The search to add a competitor (1.6.0) and the notice when the list is full. */ 1558 1557 1559 .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 { 1558 1570 display: flex; 1559 1571 flex-wrap: wrap; 1560 1572 align-items: center; 1561 1573 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 { 1572 1581 flex-basis: 100%; 1573 1582 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; 1574 1608 } 1575 1609 -
contributor-resume/trunk/assets/js/stats.js
r3789183 r3789662 12 12 * value/max of the <progress> element and in numeric SVG attributes. The 13 13 * 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. 15 18 */ 16 19 ( function () { … … 499 502 } 500 503 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 501 614 initPrefs(); 502 615 initKeywordBox(); … … 506 619 initSwitcher(); 507 620 initScale(); 621 initRivalSearch(); 508 622 }() ); -
contributor-resume/trunk/changelog.txt
r3789206 r3789662 5 5 6 6 = 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. 8 8 * 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. 9 9 * 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. … … 16 16 * 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. 17 17 * 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. 18 19 19 20 = 1.5.4 = -
contributor-resume/trunk/includes/stats/class-cresume-stats-admin.php
r3789183 r3789662 78 78 * @var string[] 79 79 */ 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' ); 81 81 82 82 /** … … 119 119 add_action( 'wp_ajax_cresume_stats_collect', array( $this, 'ajax_collect' ) ); 120 120 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' ) ); 121 122 add_filter( 'nonce_life', array( $this, 'view_nonce_life' ), 10, 2 ); 122 123 } … … 284 285 'paged' => 1, 285 286 'ov' => 'rivals', 287 'rq' => '', 286 288 ); 287 289 } … … 360 362 } 361 363 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 362 371 // The list of alerts: a topic of the closed list (anything else is all of 363 372 // them) and a page of 1 or more. Neither is remembered in the cookie. … … 716 725 'cresumeStats', 717 726 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( 722 731 'name' => self::COOKIE, 723 732 'path' => (string) wp_parse_url( admin_url(), PHP_URL_PATH ), 724 733 ), 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' ), 729 740 ), 730 741 ) 731 742 ); 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' ); 732 756 } 733 757 … … 1828 1852 $item = self::rival_item( isset( $_POST['item'] ) ? absint( $_POST['item'] ) : 0 ); 1829 1853 1830 // A slug or an address: wp_strip_all_tags() keeps the %XX octets that1831 // 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'] ) ) : ''; 1833 1857 $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 ); 1835 1859 1836 1860 if ( is_wp_error( $result ) ) { … … 2040 2064 2041 2065 /** 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 /** 2042 2102 * Stop unless the user may manage the site. 2043 2103 */ -
contributor-resume/trunk/includes/stats/class-cresume-stats-cli.php
r3789183 r3789662 377 377 * 378 378 * <competitor> 379 * : Slug of the competitor , or its addresson WordPress.org.379 * : Slug of the competitor on WordPress.org. 380 380 * 381 381 * [--type=<type>] … … 391 391 * 392 392 * wp cresume stats competitors add vigilante ninjafirewall 393 * wp cresume stats competitors add vigilante https://wordpress.org/plugins/wordfence/--leader393 * wp cresume stats competitors add vigilante wordfence --leader 394 394 * 395 395 * @param array $args Positional args. … … 400 400 401 401 $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'] ) ); 403 404 404 405 if ( is_wp_error( $result ) ) { -
contributor-resume/trunk/includes/stats/class-cresume-stats-fetcher.php
r3789183 r3789662 57 57 */ 58 58 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_'; 59 95 60 96 /** … … 308 344 } 309 345 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 ); 329 347 330 348 $updated = ''; … … 338 356 'slug' => $slug, 339 357 'name' => self::clean_text( isset( $raw['name'] ) ? $raw['name'] : $slug, 255 ), 340 'icon' => strlen( $icon ) <= 255 ? $icon : '',358 'icon' => $icon, 341 359 'added' => self::clean_date( isset( $raw['added'] ) ? $raw['added'] : '' ), 342 360 'tags' => $is_theme ? array() : self::clean_tags( isset( $raw['tags'] ) ? $raw['tags'] : array() ), … … 358 376 359 377 /** 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 /** 360 546 * Daily downloads, oldest first, up to yesterday (UTC). 361 547 * -
contributor-resume/trunk/includes/stats/class-cresume-stats-lock.php
r3755315 r3789662 108 108 109 109 /** 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. 111 113 * 112 114 * @param string $name Option name. 113 115 */ 114 p rivatestatic function forget( $name ) {116 public static function forget( $name ) { 115 117 wp_cache_delete( $name, 'options' ); 116 118 -
contributor-resume/trunk/includes/stats/class-cresume-stats-rivals.php
r3789183 r3789662 90 90 91 91 /** 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 /** 92 114 * Key of an own item. 93 115 * … … 594 616 595 617 /** 596 * Read what the user typed: a slug, or the address of a plugin or theme on597 * 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 that602 * 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 bare606 * 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 /**653 618 * 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. 654 625 * 655 626 * @param int $type Type constant of the own item. 656 627 * @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. 658 629 * @param bool $leader Whether it is added as a leader. 659 630 * @return array|WP_Error ['slug' => string, 'info' => array] with the answer of … … 661 632 * Cresume_Stats_Collector::rival_now(). 662 633 */ 663 public static function add( $type, $own_slug, $ input, $leader = false ) {634 public static function add( $type, $own_slug, $slug, $leader = false ) { 664 635 $type = Cresume_Stats_Store::TYPE_THEME === (int) $type ? Cresume_Stats_Store::TYPE_THEME : Cresume_Stats_Store::TYPE_PLUGIN; 665 636 $own = self::tracked( $type, $own_slug ); … … 669 640 } 670 641 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 } 682 645 683 646 if ( $slug === (string) $own->slug ) { … … 685 648 } 686 649 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 ); 694 651 695 652 if ( in_array( $slug, $mine['r'], true ) ) { … … 717 674 } 718 675 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 } 728 708 729 709 return array( … … 743 723 */ 744 724 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 755 746 self::release_unused(); 756 747 Cresume_Stats_Store::touch(); … … 760 751 761 752 /** 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. 763 755 * 764 756 * @param int $type Type constant. … … 768 760 */ 769 761 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 ) { 778 765 return self::error( 'cresume_rival_not_tracked' ); 779 766 } 780 767 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 ) ) { 788 771 return self::error( 'cresume_rival_exists' ); 789 772 } 790 773 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; 800 815 } 801 816 … … 809 824 */ 810 825 public static function unhide( $type, $own_slug, $slug ) { 811 $all = self::all();812 826 $key = self::key( $type, $own_slug ); 813 827 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 ); 822 842 } 823 843 … … 833 853 */ 834 854 public static function set_leader( $type, $own_slug, $slug, $leader ) { 835 $all = self::all();836 855 $key = self::key( $type, $own_slug ); 837 856 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 ); 852 924 } 853 925 … … 936 1008 public static function error( $code ) { 937 1009 $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' ), 942 1014 'cresume_rival_exists' => __( 'It is already one of its competitors.', 'contributor-resume' ), 943 1015 /* translators: %d: maximum number of competitors */ -
contributor-resume/trunk/includes/stats/class-cresume-stats-view.php
r3789183 r3789662 994 994 } 995 995 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; 997 1000 ?> 998 1001 <ul class="cresume-stats-bars"> … … 1743 1746 $add = wp_create_nonce( 'cresume_stats_rival_add' ); 1744 1747 $hide = wp_create_nonce( 'cresume_stats_rival_hide' ); 1748 $max = number_format_i18n( Cresume_Stats_Rivals::MAX_PER_ITEM ); 1745 1749 ?> 1746 1750 <section class="cresume-card cresume-stats-rivals"> … … 1754 1758 <?php 1755 1759 /* 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 ) ); 1757 1761 ?> 1758 1762 </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; ?> 1759 1766 </h2> 1760 1767 <a class="button" href="<?php echo esc_url( $back ); ?>"><?php esc_html_e( 'Back to Competitors', 'contributor-resume' ); ?></a> … … 1764 1771 <?php self::rival_list( $rows, $item, true ); ?> 1765 1772 <?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> 1767 1774 <?php endif; ?> 1768 1775 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 1780 1781 /* 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; ?> 1788 1789 </section> 1789 1790 … … 1798 1799 </span> 1799 1800 </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; ?> 1800 1805 1801 1806 <?php if ( ! $suggest['of'] ) : ?> … … 1849 1854 <?php endif; ?> 1850 1855 </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> 1851 2007 <?php 1852 2008 } -
contributor-resume/trunk/includes/stats/class-cresume-stats-widget.php
r3789183 r3789662 158 158 } 159 159 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; 164 166 $item = isset( $raw['item'] ) ? self::id( $raw['item'] ) : 0; 165 167 $picked = array(); … … 176 178 177 179 foreach ( ( isset( $raw['figures'] ) ? (array) $raw['figures'] : array() ) as $figure ) { 178 $figure = sanitize_key( (string) $figure);180 $figure = sanitize_key( self::text( $figure ) ); 179 181 180 182 if ( in_array( $figure, self::FIGURES, true ) ) { … … 194 196 'alerts' => ! empty( $raw['alerts'] ), 195 197 ); 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 : ''; 196 208 } 197 209 -
contributor-resume/trunk/readme.txt
r3789206 r3789662 147 147 = Can I track my competitors? = 148 148 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.149 Yes. 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. 150 150 151 151 = How are competitors suggested? = … … 235 235 Competitor 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. 236 236 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. 238 238 * 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. 239 239 * 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. … … 246 246 * 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. 247 247 * 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. 248 249 249 250 For 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 32 32 delete_option( 'cresume_stats_lock' ); 33 33 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 ); 34 44 35 45 // Respect the "keep settings and cached data" choice.
Note:
See TracChangeset
for help on using the changeset viewer.
![(please configure the [header_logo] section in trac.ini)](/chrome/site/your_project_logo.png)