├── .distignore ├── .github └── workflows │ ├── deploy-readme-to-repo.yml │ └── deploy-to-repo.yml ├── .gitignore ├── .wordpress-org ├── banner-772x250.png ├── icon-128x128.png ├── screenshot-1.png ├── screenshot-2.png └── screenshot-3.png ├── CODE-OF-CONDUCT.md ├── LICENSE.txt ├── admin ├── admin.php ├── css │ ├── admin.css │ └── quick-edit-autocomplete.css ├── index.php ├── js │ ├── percent.js │ └── quick-edit-autocomplete.js ├── progressbar.php ├── user-handler.php └── views │ ├── message.php │ └── page.php ├── changelog.txt ├── includes ├── activator.php ├── deactivator.php ├── index.php ├── indexer.php ├── instacron.php ├── plugin.php ├── selection-box.php ├── tasks │ ├── count-users.php │ ├── depopulate-meta-indexes.php │ ├── get-editors.php │ ├── populate-meta-index-roles.php │ ├── reindex.php │ └── task.php └── wordpress-hooks.php ├── index-wp-users-for-speed.php ├── index.php ├── languages └── index-wp-users-for-speed.pot ├── notes.MD ├── readme.txt └── uninstall.php /.distignore: -------------------------------------------------------------------------------- 1 | # Directories and files to omit from the wordpress.org svn repo. 2 | .distignore 3 | .gitignore 4 | .wordpress-org 5 | .git 6 | .github 7 | .editorconfig 8 | .idea 9 | .gitlab-ci.yml 10 | .travis.yml 11 | .DS_Store 12 | notes.md 13 | notes.MD 14 | CODE-OF-CONDUCT.md 15 | Thumbs.db 16 | behat.yml 17 | /bin 18 | circle.yml 19 | composer.json 20 | composer.lock 21 | Gruntfile.js 22 | package.json 23 | package-lock.json 24 | phpunit.xml 25 | phpunit.xml.dist 26 | multisite.xml 27 | multisite.xml.dist 28 | phpcs.ruleset.xml 29 | README.md 30 | wp-cli.local.yml 31 | tests 32 | vendor 33 | node_modules 34 | *.sql 35 | *.tar.gz 36 | *.zip 37 | *~ 38 | notes.txt 39 | 40 | -------------------------------------------------------------------------------- /.github/workflows/deploy-readme-to-repo.yml: -------------------------------------------------------------------------------- 1 | name: Deploy updated readme.txt and assets to WordPress.org 2 | # see https://github.com/10up/action-wordpress-plugin-asset-update 3 | 4 | on: 5 | push: 6 | branches: 7 | - release 8 | 9 | jobs: 10 | release: 11 | name: Push assets to release branch 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@master 15 | - name: WordPress.org plugin asset/readme update 16 | uses: 10up/action-wordpress-plugin-asset-update@stable 17 | env: 18 | SVN_PASSWORD: ${{ secrets.SVN_PASSWORD }} 19 | SVN_USERNAME: ${{ secrets.SVN_USERNAME }} 20 | -------------------------------------------------------------------------------- /.github/workflows/deploy-to-repo.yml: -------------------------------------------------------------------------------- 1 | name: Deploy to WordPress.org 2 | # see https://github.com/marketplace/actions/wordpress-plugin-deploy#deploy-on-publishing-a-new-release-and-attach-a-zip-file-to-the-release 3 | on: 4 | release: 5 | types: [published] 6 | jobs: 7 | tag: 8 | name: New release 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Checkout code 12 | uses: actions/checkout@v2 13 | - name: WordPress Plugin Deploy 14 | id: deploy 15 | uses: 10up/action-wordpress-plugin-deploy@stable 16 | with: 17 | generate-zip: true 18 | env: 19 | SVN_USERNAME: ${{ secrets.SVN_USERNAME }} 20 | SVN_PASSWORD: ${{ secrets.SVN_PASSWORD }} 21 | ASSETS_DIR: .wordpress-org 22 | - name: Upload release assets 23 | uses: actions/upload-release-asset@v1 24 | env: 25 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 26 | with: 27 | upload_url: ${{ github.event.release.upload_url }} 28 | asset_path: ${{ steps.deploy.outputs.zip-path }} 29 | asset_name: ${{ github.event.repository.name }}.zip 30 | asset_content_type: application/zip -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.idea/ 2 | *~ 3 | **/hidden-test-treeshake/ 4 | 5 | -------------------------------------------------------------------------------- /.wordpress-org/banner-772x250.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OllieJones/index-wp-users-for-speed/7aea59137a7180dd077708886f63e714e1fbc22e/.wordpress-org/banner-772x250.png -------------------------------------------------------------------------------- /.wordpress-org/icon-128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OllieJones/index-wp-users-for-speed/7aea59137a7180dd077708886f63e714e1fbc22e/.wordpress-org/icon-128x128.png -------------------------------------------------------------------------------- /.wordpress-org/screenshot-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OllieJones/index-wp-users-for-speed/7aea59137a7180dd077708886f63e714e1fbc22e/.wordpress-org/screenshot-1.png -------------------------------------------------------------------------------- /.wordpress-org/screenshot-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OllieJones/index-wp-users-for-speed/7aea59137a7180dd077708886f63e714e1fbc22e/.wordpress-org/screenshot-2.png -------------------------------------------------------------------------------- /.wordpress-org/screenshot-3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OllieJones/index-wp-users-for-speed/7aea59137a7180dd077708886f63e714e1fbc22e/.wordpress-org/screenshot-3.png -------------------------------------------------------------------------------- /CODE-OF-CONDUCT.md: -------------------------------------------------------------------------------- 1 | This project comes under the WordPress [Etiquette](https://wordpress.org/about/etiquette/): 2 | 3 | In the WordPress open source project, and in this plugin, we realize that our biggest asset is the community that we 4 | foster. The project, as a whole, follows these basic philosophical principles from The Cathedral and The Bazaar. 5 | 6 | - Contributions to the WordPress open source project are for the benefit of the WordPress community as a whole, not 7 | specific businesses or individuals. All actions taken as a contributor should be made with the best interests of the 8 | community in mind. 9 | - Participation in the WordPress open source project is open to all who wish to join, regardless of ability, skill, 10 | financial status, or any other criteria. 11 | - The WordPress open source project is a volunteer-run community. Even in cases where contributors are sponsored by 12 | companies, that time is donated for the benefit of the entire open source community. 13 | - Any member of the community can donate their time and contribute to the project in any form including design, code, 14 | documentation, community building, etc. For more information, go to make.wordpress.org. 15 | - The WordPress open source community cares about diversity. We strive to maintain a welcoming environment where 16 | everyone can feel included, by keeping communication free of discrimination, incitement to violence, promotion of 17 | hate, and unwelcoming behavior. 18 | 19 | The team involved will block any user who causes any breach in this. -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | GNU GENERAL PUBLIC LICENSE 2 | Version 2, June 1991 3 | 4 | Copyright (C) 1989, 1991 Free Software Foundation, Inc., 5 | 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA 6 | Everyone is permitted to copy and distribute verbatim copies 7 | of this license document, but changing it is not allowed. 8 | 9 | Preamble 10 | 11 | The licenses for most software are designed to take away your 12 | freedom to share and change it. By contrast, the GNU General Public 13 | License is intended to guarantee your freedom to share and change free 14 | software--to make sure the software is free for all its users. This 15 | General Public License applies to most of the Free Software 16 | Foundation's software and to any other program whose authors commit to 17 | using it. (Some other Free Software Foundation software is covered by 18 | the GNU Lesser General Public License instead.) You can apply it to 19 | your programs, too. 20 | 21 | When we speak of free software, we are referring to freedom, not 22 | price. Our General Public Licenses are designed to make sure that you 23 | have the freedom to distribute copies of free software (and charge for 24 | this service if you wish), that you receive source code or can get it 25 | if you want it, that you can change the software or use pieces of it 26 | in new free programs; and that you know you can do these things. 27 | 28 | To protect your rights, we need to make restrictions that forbid 29 | anyone to deny you these rights or to ask you to surrender the rights. 30 | These restrictions translate to certain responsibilities for you if you 31 | distribute copies of the software, or if you modify it. 32 | 33 | For example, if you distribute copies of such a program, whether 34 | gratis or for a fee, you must give the recipients all the rights that 35 | you have. You must make sure that they, too, receive or can get the 36 | source code. And you must show them these terms so they know their 37 | rights. 38 | 39 | We protect your rights with two steps: (1) copyright the software, and 40 | (2) offer you this license which gives you legal permission to copy, 41 | distribute and/or modify the software. 42 | 43 | Also, for each author's protection and ours, we want to make certain 44 | that everyone understands that there is no warranty for this free 45 | software. If the software is modified by someone else and passed on, we 46 | want its recipients to know that what they have is not the original, so 47 | that any problems introduced by others will not reflect on the original 48 | authors' reputations. 49 | 50 | Finally, any free program is threatened constantly by software 51 | patents. We wish to avoid the danger that redistributors of a free 52 | program will individually obtain patent licenses, in effect making the 53 | program proprietary. To prevent this, we have made it clear that any 54 | patent must be licensed for everyone's free use or not licensed at all. 55 | 56 | The precise terms and conditions for copying, distribution and 57 | modification follow. 58 | 59 | GNU GENERAL PUBLIC LICENSE 60 | TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION 61 | 62 | 0. This License applies to any program or other work which contains 63 | a notice placed by the copyright holder saying it may be distributed 64 | under the terms of this General Public License. The "Program", below, 65 | refers to any such program or work, and a "work based on the Program" 66 | means either the Program or any derivative work under copyright law: 67 | that is to say, a work containing the Program or a portion of it, 68 | either verbatim or with modifications and/or translated into another 69 | language. (Hereinafter, translation is included without limitation in 70 | the term "modification".) Each licensee is addressed as "you". 71 | 72 | Activities other than copying, distribution and modification are not 73 | covered by this License; they are outside its scope. The act of 74 | running the Program is not restricted, and the output from the Program 75 | is covered only if its contents constitute a work based on the 76 | Program (independent of having been made by running the Program). 77 | Whether that is true depends on what the Program does. 78 | 79 | 1. You may copy and distribute verbatim copies of the Program's 80 | source code as you receive it, in any medium, provided that you 81 | conspicuously and appropriately publish on each copy an appropriate 82 | copyright notice and disclaimer of warranty; keep intact all the 83 | notices that refer to this License and to the absence of any warranty; 84 | and give any other recipients of the Program a copy of this License 85 | along with the Program. 86 | 87 | You may charge a fee for the physical act of transferring a copy, and 88 | you may at your option offer warranty protection in exchange for a fee. 89 | 90 | 2. You may modify your copy or copies of the Program or any portion 91 | of it, thus forming a work based on the Program, and copy and 92 | distribute such modifications or work under the terms of Section 1 93 | above, provided that you also meet all of these conditions: 94 | 95 | a) You must cause the modified files to carry prominent notices 96 | stating that you changed the files and the date of any change. 97 | 98 | b) You must cause any work that you distribute or publish, that in 99 | whole or in part contains or is derived from the Program or any 100 | part thereof, to be licensed as a whole at no charge to all third 101 | parties under the terms of this License. 102 | 103 | c) If the modified program normally reads commands interactively 104 | when run, you must cause it, when started running for such 105 | interactive use in the most ordinary way, to print or display an 106 | announcement including an appropriate copyright notice and a 107 | notice that there is no warranty (or else, saying that you provide 108 | a warranty) and that users may redistribute the program under 109 | these conditions, and telling the user how to view a copy of this 110 | License. (Exception: if the Program itself is interactive but 111 | does not normally print such an announcement, your work based on 112 | the Program is not required to print an announcement.) 113 | 114 | These requirements apply to the modified work as a whole. If 115 | identifiable sections of that work are not derived from the Program, 116 | and can be reasonably considered independent and separate works in 117 | themselves, then this License, and its terms, do not apply to those 118 | sections when you distribute them as separate works. But when you 119 | distribute the same sections as part of a whole which is a work based 120 | on the Program, the distribution of the whole must be on the terms of 121 | this License, whose permissions for other licensees extend to the 122 | entire whole, and thus to each and every part regardless of who wrote it. 123 | 124 | Thus, it is not the intent of this section to claim rights or contest 125 | your rights to work written entirely by you; rather, the intent is to 126 | exercise the right to control the distribution of derivative or 127 | collective works based on the Program. 128 | 129 | In addition, mere aggregation of another work not based on the Program 130 | with the Program (or with a work based on the Program) on a volume of 131 | a storage or distribution medium does not bring the other work under 132 | the scope of this License. 133 | 134 | 3. You may copy and distribute the Program (or a work based on it, 135 | under Section 2) in object code or executable form under the terms of 136 | Sections 1 and 2 above provided that you also do one of the following: 137 | 138 | a) Accompany it with the complete corresponding machine-readable 139 | source code, which must be distributed under the terms of Sections 140 | 1 and 2 above on a medium customarily used for software interchange; or, 141 | 142 | b) Accompany it with a written offer, valid for at least three 143 | years, to give any third party, for a charge no more than your 144 | cost of physically performing source distribution, a complete 145 | machine-readable copy of the corresponding source code, to be 146 | distributed under the terms of Sections 1 and 2 above on a medium 147 | customarily used for software interchange; or, 148 | 149 | c) Accompany it with the information you received as to the offer 150 | to distribute corresponding source code. (This alternative is 151 | allowed only for noncommercial distribution and only if you 152 | received the program in object code or executable form with such 153 | an offer, in accord with Subsection b above.) 154 | 155 | The source code for a work means the preferred form of the work for 156 | making modifications to it. For an executable work, complete source 157 | code means all the source code for all modules it contains, plus any 158 | associated interface definition files, plus the scripts used to 159 | control compilation and installation of the executable. However, as a 160 | special exception, the source code distributed need not include 161 | anything that is normally distributed (in either source or binary 162 | form) with the major components (compiler, kernel, and so on) of the 163 | operating system on which the executable runs, unless that component 164 | itself accompanies the executable. 165 | 166 | If distribution of executable or object code is made by offering 167 | access to copy from a designated place, then offering equivalent 168 | access to copy the source code from the same place counts as 169 | distribution of the source code, even though third parties are not 170 | compelled to copy the source along with the object code. 171 | 172 | 4. You may not copy, modify, sublicense, or distribute the Program 173 | except as expressly provided under this License. Any attempt 174 | otherwise to copy, modify, sublicense or distribute the Program is 175 | void, and will automatically terminate your rights under this License. 176 | However, parties who have received copies, or rights, from you under 177 | this License will not have their licenses terminated so long as such 178 | parties remain in full compliance. 179 | 180 | 5. You are not required to accept this License, since you have not 181 | signed it. However, nothing else grants you permission to modify or 182 | distribute the Program or its derivative works. These actions are 183 | prohibited by law if you do not accept this License. Therefore, by 184 | modifying or distributing the Program (or any work based on the 185 | Program), you indicate your acceptance of this License to do so, and 186 | all its terms and conditions for copying, distributing or modifying 187 | the Program or works based on it. 188 | 189 | 6. Each time you redistribute the Program (or any work based on the 190 | Program), the recipient automatically receives a license from the 191 | original licensor to copy, distribute or modify the Program subject to 192 | these terms and conditions. You may not impose any further 193 | restrictions on the recipients' exercise of the rights granted herein. 194 | You are not responsible for enforcing compliance by third parties to 195 | this License. 196 | 197 | 7. If, as a consequence of a court judgment or allegation of patent 198 | infringement or for any other reason (not limited to patent issues), 199 | conditions are imposed on you (whether by court order, agreement or 200 | otherwise) that contradict the conditions of this License, they do not 201 | excuse you from the conditions of this License. If you cannot 202 | distribute so as to satisfy simultaneously your obligations under this 203 | License and any other pertinent obligations, then as a consequence you 204 | may not distribute the Program at all. For example, if a patent 205 | license would not permit royalty-free redistribution of the Program by 206 | all those who receive copies directly or indirectly through you, then 207 | the only way you could satisfy both it and this License would be to 208 | refrain entirely from distribution of the Program. 209 | 210 | If any portion of this section is held invalid or unenforceable under 211 | any particular circumstance, the balance of the section is intended to 212 | apply and the section as a whole is intended to apply in other 213 | circumstances. 214 | 215 | It is not the purpose of this section to induce you to infringe any 216 | patents or other property right claims or to contest validity of any 217 | such claims; this section has the sole purpose of protecting the 218 | integrity of the free software distribution system, which is 219 | implemented by public license practices. Many people have made 220 | generous contributions to the wide range of software distributed 221 | through that system in reliance on consistent application of that 222 | system; it is up to the author/donor to decide if he or she is willing 223 | to distribute software through any other system and a licensee cannot 224 | impose that choice. 225 | 226 | This section is intended to make thoroughly clear what is believed to 227 | be a consequence of the rest of this License. 228 | 229 | 8. If the distribution and/or use of the Program is restricted in 230 | certain countries either by patents or by copyrighted interfaces, the 231 | original copyright holder who places the Program under this License 232 | may add an explicit geographical distribution limitation excluding 233 | those countries, so that distribution is permitted only in or among 234 | countries not thus excluded. In such case, this License incorporates 235 | the limitation as if written in the body of this License. 236 | 237 | 9. The Free Software Foundation may publish revised and/or new versions 238 | of the General Public License from time to time. Such new versions will 239 | be similar in spirit to the present version, but may differ in detail to 240 | address new problems or concerns. 241 | 242 | Each version is given a distinguishing version number. If the Program 243 | specifies a version number of this License which applies to it and "any 244 | later version", you have the option of following the terms and conditions 245 | either of that version or of any later version published by the Free 246 | Software Foundation. If the Program does not specify a version number of 247 | this License, you may choose any version ever published by the Free Software 248 | Foundation. 249 | 250 | 10. If you wish to incorporate parts of the Program into other free 251 | programs whose distribution conditions are different, write to the author 252 | to ask for permission. For software which is copyrighted by the Free 253 | Software Foundation, write to the Free Software Foundation; we sometimes 254 | make exceptions for this. Our decision will be guided by the two goals 255 | of preserving the free status of all derivatives of our free software and 256 | of promoting the sharing and reuse of software generally. 257 | 258 | NO WARRANTY 259 | 260 | 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY 261 | FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN 262 | OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES 263 | PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED 264 | OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF 265 | MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS 266 | TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE 267 | PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, 268 | REPAIR OR CORRECTION. 269 | 270 | 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 271 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR 272 | REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, 273 | INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING 274 | OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED 275 | TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY 276 | YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER 277 | PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE 278 | POSSIBILITY OF SUCH DAMAGES. 279 | 280 | END OF TERMS AND CONDITIONS 281 | 282 | How to Apply These Terms to Your New Programs 283 | 284 | If you develop a new program, and you want it to be of the greatest 285 | possible use to the public, the best way to achieve this is to make it 286 | free software which everyone can redistribute and change under these terms. 287 | 288 | To do so, attach the following notices to the program. It is safest 289 | to attach them to the start of each source file to most effectively 290 | convey the exclusion of warranty; and each file should have at least 291 | the "copyright" line and a pointer to where the full notice is found. 292 | 293 | 294 | Copyright (C) 295 | 296 | This program is free software; you can redistribute it and/or modify 297 | it under the terms of the GNU General Public License as published by 298 | the Free Software Foundation; either version 2 of the License, or 299 | (at your option) any later version. 300 | 301 | This program is distributed in the hope that it will be useful, 302 | but WITHOUT ANY WARRANTY; without even the implied warranty of 303 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 304 | GNU General Public License for more details. 305 | 306 | You should have received a copy of the GNU General Public License along 307 | with this program; if not, write to the Free Software Foundation, Inc., 308 | 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. 309 | 310 | Also add information on how to contact you by electronic and paper mail. 311 | 312 | If the program is interactive, make it output a short notice like this 313 | when it starts in an interactive mode: 314 | 315 | Gnomovision version 69, Copyright (C) year name of author 316 | Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. 317 | This is free software, and you are welcome to redistribute it 318 | under certain conditions; type `show c' for details. 319 | 320 | The hypothetical commands `show w' and `show c' should show the appropriate 321 | parts of the General Public License. Of course, the commands you use may 322 | be called something other than `show w' and `show c'; they could even be 323 | mouse-clicks or menu items--whatever suits your program. 324 | 325 | You should also get your employer (if you work as a programmer) or your 326 | school, if any, to sign a "copyright disclaimer" for the program, if 327 | necessary. Here is a sample; alter the names: 328 | 329 | Yoyodyne, Inc., hereby disclaims all copyright interest in the program 330 | `Gnomovision' (which makes passes at compilers) written by James Hacker. 331 | 332 | , 1 April 1989 333 | Ty Coon, President of Vice 334 | 335 | This General Public License does not permit incorporating your program into 336 | proprietary programs. If your program is a subroutine library, you may 337 | consider it more useful to permit linking proprietary applications with the 338 | library. If this is what you want to do, use the GNU Lesser General 339 | Public License instead of this License. -------------------------------------------------------------------------------- /admin/admin.php: -------------------------------------------------------------------------------- 1 | 18 | */ 19 | class Admin 20 | extends WordPressHooks { 21 | 22 | private $plugin_name; 23 | private $options_name; 24 | private $version; 25 | private $indexer; 26 | private $pluginPath; 27 | /** @var bool Sometimes sanitize() gets called twice. Avoid repeating operations. */ 28 | private $didAnyOperations = false; 29 | 30 | /** 31 | * Initialize the class and set its properties. 32 | * 33 | */ 34 | public function __construct() { 35 | 36 | $this->plugin_name = INDEX_WP_USERS_FOR_SPEED_NAME; 37 | $this->version = INDEX_WP_USERS_FOR_SPEED_VERSION; 38 | $this->pluginPath = plugin_dir_path( dirname( __FILE__ ) ); 39 | $this->options_name = INDEX_WP_USERS_FOR_SPEED_PREFIX . 'options'; 40 | $this->indexer = Indexer::getInstance(); 41 | 42 | /* action link for plugins page */ 43 | add_filter( 'plugin_action_links_' . INDEX_WP_USERS_FOR_SPEED_FILENAME, [ $this, 'action_link' ] ); 44 | 45 | parent::__construct(); 46 | } 47 | 48 | /** @noinspection PhpUnused */ 49 | public function action__admin_menu() { 50 | 51 | add_users_page( 52 | esc_html__( 'Index WP Users For Speed', 'index-wp-users-for-speed' ), 53 | esc_html__( 'Index For Speed', 'index-wp-users-for-speed' ), 54 | 'manage_options', 55 | $this->plugin_name, 56 | [ $this, 'render_admin_page' ], 57 | 12 ); 58 | 59 | $this->addTimingSection(); 60 | } 61 | 62 | private function addTimingSection() { 63 | 64 | $page = $this->plugin_name; 65 | add_settings_section( 'indexing', 66 | esc_html__( 'Rebuilding user indexes', 'index-wp-users-for-speed' ), 67 | [ $this, 'render_indexing_section' ], 68 | $page ); 69 | 70 | add_settings_field( 'auto_rebuild', 71 | esc_html__( 'Rebuild indexes', 'index-wp-users-for-speed' ), 72 | [ $this, 'render_auto_rebuild_field' ], 73 | 74 | $page, 75 | 'indexing' ); 76 | 77 | add_settings_field( 'rebuild_time', 78 | esc_html__( '...at this time', 'index-wp-users-for-speed' ), 79 | [ $this, 'render_rebuild_time_field' ], 80 | $page, 81 | 'indexing' ); 82 | 83 | add_settings_section( 'quickedit', 84 | esc_html__( 'Choosing authors when editing posts and pages', 'index-wp-users-for-speed' ), 85 | [ $this, 'render_quickedit_section' ], 86 | $page ); 87 | 88 | add_settings_field( 'quickedit_threshold', 89 | esc_html__( 'Use selection boxes', 'index-wp-users-for-speed' ), 90 | [ $this, 'render_quickedit_threshold_field' ], 91 | $page, 92 | 'quickedit' ); 93 | 94 | $option = get_option( $this->options_name ); 95 | 96 | /* make sure default option is in place, to avoid double sanitize call */ 97 | if ( $option === false ) { 98 | add_option( $this->options_name, [ 99 | 'auto_rebuild' => 'on', 100 | 'rebuild_time' => '00:25', 101 | 'quickedit_threshold_limit' => 50, 102 | ] ); 103 | } 104 | 105 | register_setting( 106 | $this->options_name, 107 | $this->options_name, 108 | [ 'sanitize_callback' => [ $this, 'sanitize_settings' ] ] ); 109 | } 110 | 111 | /** @noinspection PhpRedundantOptionalArgumentInspection 112 | */ 113 | public function sanitize_settings( $input ) { 114 | 115 | require_once plugin_dir_path( dirname( __FILE__ ) ) . 'includes/indexer.php'; 116 | $this->indexer = Indexer::getInstance(); 117 | 118 | $didAnOperation = false; 119 | 120 | try { 121 | $autoRebuild = isset( $input['auto_rebuild'] ) && ( $input['auto_rebuild'] === 'on' || $input['auto_rebuild'] === 'nowon' ); 122 | $nowRebuild = isset( $input['auto_rebuild'] ) && ( $input['auto_rebuild'] === 'nowoff' || $input['auto_rebuild'] === 'nowon' ); 123 | $time = isset( $input['rebuild_time'] ) ? $input['rebuild_time'] : ''; 124 | $timeString = $this->formatTime( $time ); 125 | 126 | if ( $timeString === false ) { 127 | add_settings_error( 128 | $this->options_name, 'rebuild', 129 | esc_html__( 'Incorrect time.', 'index-wp-users-for-speed' ), 130 | 'error' ); 131 | 132 | return $input; 133 | } 134 | 135 | if ( $nowRebuild ) { 136 | add_settings_error( 137 | $this->options_name, 'rebuild', 138 | esc_html__( 'User index rebuilding starting', 'index-wp-users-for-speed' ), 139 | 'info' ); 140 | if ( ! $this->didAnyOperations ) { 141 | $didAnOperation = true; 142 | $this->indexer->rebuildNow(); 143 | } 144 | } 145 | 146 | if ( $autoRebuild ) { 147 | /* translators: 1: localized time like 1:22 PM or 13:22 */ 148 | $format = __( 'Automatic index rebuilding scheduled for %1$s each day', 'index-wp-users-for-speed' ); 149 | $display = esc_html( sprintf( $format, $timeString ) ); 150 | add_settings_error( $this->options_name, 'rebuild', $display, 'success' ); 151 | if ( ! $this->didAnyOperations ) { 152 | $didAnOperation = true; 153 | $this->indexer->enableAutoRebuild( $this->timeToSeconds( $time ) ); 154 | } 155 | } else { 156 | $display = esc_html__( 'Automatic index rebuilding disabled', 'index-wp-users-for-speed' ); 157 | add_settings_error( $this->options_name, 'rebuild', $display, 'success' ); 158 | if ( ! $this->didAnyOperations ) { 159 | $didAnOperation = true; 160 | $this->indexer->disableAutoRebuild(); 161 | } 162 | } 163 | } catch ( Exception $ex ) { 164 | add_settings_error( $this->options_name, 'rebuild', esc_html( $ex->getMessage() ), 'error' ); 165 | } 166 | if ( $didAnOperation ) { 167 | $this->didAnyOperations = true; 168 | } 169 | 170 | /* persist on and off */ 171 | if ( isset( $input['auto_rebuild'] ) ) { 172 | $i = $input['auto_rebuild']; 173 | $i = $i === 'nowon' ? 'on' : $i; 174 | $i = $i === 'nowoff' ? 'off' : $i; 175 | $input['auto_rebuild'] = $i; 176 | } 177 | 178 | return $input; 179 | } 180 | 181 | /** 182 | * @param string $time like '16:42' 183 | * 184 | * @return string|false time string or false if input was bogus. 185 | */ 186 | private function formatTime( $time ) { 187 | $ts = $this->timeToSeconds( $time ); 188 | $utc = new DateTimeZone ( 'UTC' ); 189 | 190 | return $ts === false ? $time : wp_date( get_option( 'time_format' ), $ts, $utc ); 191 | } 192 | 193 | /** 194 | * @param string $time like '16:42' 195 | * 196 | * @return false|int 197 | */ 198 | private function timeToSeconds( $time ) { 199 | try { 200 | if ( preg_match( '/^\d\d:\d\d$/', $time ) ) { 201 | $ts = intval( substr( $time, 0, 2 ) ) * HOUR_IN_SECONDS; 202 | $ts += intval( substr( $time, 3, 2 ) ) * MINUTE_IN_SECONDS; 203 | if ( $ts >= 0 && $ts < DAY_IN_SECONDS ) { 204 | return intval( $ts ); 205 | } 206 | } 207 | } catch ( Exception $ex ) { 208 | return false; 209 | } 210 | 211 | return false; 212 | } 213 | 214 | public function render_admin_page() { 215 | /* avoid this overhead unless we actually USE the admin page */ 216 | wp_enqueue_style( $this->plugin_name, plugin_dir_url( __FILE__ ) . 'css/admin.css', [], $this->version, 'all' ); 217 | require_once plugin_dir_path( dirname( __FILE__ ) ) . 'includes/indexer.php'; 218 | $this->indexer = Indexer::getInstance(); 219 | include_once $this->pluginPath . 'admin/views/page.php'; 220 | } 221 | 222 | public function render_indexing_section() { 223 | ?> 224 |

225 | 226 | 227 |

228 | options_name ); 233 | $autoRebuild = isset( $options['auto_rebuild'] ) ? $options['auto_rebuild'] : 'on'; 234 | ?> 235 |
236 | 237 | /> 242 | 243 | 244 | 245 | /> 250 | 251 | 252 | 253 | /> 258 | 260 | 261 | 262 | /> 267 | 269 | 270 |
271 | options_name ); 276 | $rebuildTime = isset( $options['rebuild_time'] ) ? $options['rebuild_time'] : '00:25'; 277 | ?> 278 |
279 | 280 | 284 |
285 |

286 | 287 |

288 | 293 |
294 | 295 | 298 |
299 | 304 |
305 | 306 | 309 |
310 | 315 |

316 | 319 |

320 | options_name ); 325 | $limit = isset( $options['quickedit_threshold_limit'] ) ? $options['quickedit_threshold_limit'] : 50; 326 | ?> 327 |
328 |
339 | plugin_name ) . '">' . __( 'Settings' ) . '', 368 | ]; 369 | 370 | return array_merge( $mylinks, $actions ); 371 | } 372 | 373 | } 374 | 375 | new Admin(); 376 | -------------------------------------------------------------------------------- /admin/css/admin.css: -------------------------------------------------------------------------------- 1 | @media (min-width: 783px) { 2 | div.wrap.index-users > form > table.form-table > tbody > tr > th { 3 | text-align: right; 4 | font-weight: 400; 5 | } 6 | } 7 | 8 | div.wrap.index-users > form > table.form-table > tbody > tr > td > div > span.radioitem { 9 | margin-right: 0.5em; 10 | } 11 | 12 | div.wrap.index-users > form > table.form-table > tbody > tr > td > p { 13 | word-break: break-word; 14 | } 15 | 16 | div.wrap.index-users > form > table.form-table > tbody > tr > td > p.menu-note { 17 | margin-bottom: 1em; 18 | } 19 | 20 | input#quickedit_threshold_limit { 21 | width: 5em; 22 | } 23 | -------------------------------------------------------------------------------- /admin/css/quick-edit-autocomplete.css: -------------------------------------------------------------------------------- 1 | /* conceal dropdown in favor of autocomplete field in posts / pages */ 2 | tr.inline-edit-row td label select.authors.index-wp-users-for-speed { 3 | display: none; 4 | } 5 | /* conceal dropdown in favor of autocomplete field in classic editor */ 6 | div#authordiv.postbox > div.inside > select.index-wp-users-for-speed { 7 | display: none; 8 | } 9 | div#authordiv.postbox > div.inside > span.input-text-wrap > input.index-wp-users-for-speed { 10 | width:100%; 11 | max-width:40em; 12 | } 13 | -------------------------------------------------------------------------------- /admin/index.php: -------------------------------------------------------------------------------- 1 | new Promise(r => setTimeout(r, milliseconds)) 61 | 62 | /** 63 | * Sort the labels so items where the search term starts a name come first. 64 | * @param a 65 | * @param b 66 | * @returns {number} 67 | */ 68 | function wordMatchesFirst(a, b) { 69 | const termStartsA = a.label.split(splitter).some(word => word.toLowerCase().startsWith(searchTerm.toLowerCase())) 70 | const termStartsB = b.label.split(splitter).some(word => word.toLowerCase().startsWith(searchTerm.toLowerCase())) 71 | if (termStartsA === termStartsB) { 72 | return a.label.localeCompare(b.label) 73 | } 74 | return termStartsA ? -1 : 1 75 | } 76 | 77 | /* parameters for REST query, from input.dataset */ 78 | let dataset 79 | 80 | function fetch(req, res) { 81 | const endpoint = `${dataset.url}/wp-json/wp/v2/users?context=edit&per_page=${dataset.count}&_fields=id,name,username&_locale=user` 82 | const capabilities = typeof dataset.capabilities === 'string' ? '&capabilities=' + dataset.capabilities : '&who=authors' 83 | searchTerm = req.term 84 | const search = `&search=${req.term}` 85 | $.ajax( 86 | { 87 | url: endpoint + capabilities + search, 88 | dataType: 'json', 89 | type: 'get', 90 | beforeSend: function (xhr) { 91 | xhr.setRequestHeader('X-WP-Nonce', dataset.nonce); 92 | }, 93 | success: function (data) { 94 | if (data.length === 0) { 95 | res(data); 96 | } else { 97 | const list = $.map(data, item => { 98 | const tag = item.name + ' (' + item.username + ')' 99 | return {label: tag, value: tag, id: item.id} 100 | }) 101 | list.sort(wordMatchesFirst) 102 | res(list) 103 | } 104 | } 105 | } 106 | ) 107 | } 108 | 109 | async function bulkClickHandler(event) { 110 | await clickHandler(event, 'bulk') 111 | } 112 | 113 | async function inlineClickHandler(event) { 114 | await clickHandler(event, 'inline') 115 | } 116 | 117 | /* Get an event when the user clicks quick-edit or bulk-edit 118 | * and defer handling the event, using setTimeout, until the next time 119 | * through the Javascript main loop. That's because we may get our 120 | * event before wp-admin/js/inline-edit-post.js line 127 gets theirs, 121 | * and they insert the quick-edit code into the DOM, copying it from 122 | * #inline-edit. */ 123 | async function clickHandler(event, type) { 124 | await sleep(0); 125 | const select = $('tr.inline-edit-row label.inline-edit-author select.index-wp-users-for-speed', theList) 126 | await autocompleteSetup(select[0]) 127 | } 128 | 129 | /* Get an event when the user clicks quick-edit or bulk-edit 130 | * and defer handling the event, using setTimeout, until the next time 131 | * through the Javascript main loop. That's because we may get our 132 | * event before wp-admin/js/inline-edit-post.js line 127 gets theirs, 133 | * and they insert the quick-edit code into the DOM, copying it from 134 | * #inline-edit. */ 135 | async function autocompleteSetup(selectElement) { 136 | const autoCompleteElement = selectElement.parentElement.querySelector('span.input-text-wrap > input') 137 | dataset = autoCompleteElement.dataset 138 | const autoComplete = $(autoCompleteElement) 139 | try { 140 | autoComplete.autocomplete( 141 | { 142 | delay: 500, 143 | appendTo: autoComplete.parent().parent().first(), 144 | source: fetch, 145 | create: function (event) { 146 | const target = event.target 147 | const selecteds = selectElement.selectedOptions 148 | if (selecteds.length === 1) { 149 | const selected = selecteds[0] 150 | const id = selected.value 151 | target.dataset.id = id 152 | let label = selected.label 153 | if (typeof label !== 'string' || label.length === 0) { 154 | try { 155 | /* No label on the first option in the select (no name for the author). 156 | * Go find the item being edited, then the item's author. 157 | * Workaround for https://core.trac.wordpress.org/ticket/56819 */ 158 | let editElement = selectElement 159 | while (editElement && !editElement.classList.contains('inline-edit-row')) { 160 | editElement = editElement.parentNode 161 | } 162 | const id = editElement.id.split('-')[1] 163 | label = $('#post-' + id + ' .author').text() 164 | } catch (_) { 165 | label = target.dataset.p1 166 | } 167 | } 168 | target.dataset.label = label 169 | target.dataset.p2 = label 170 | setSelected(selectElement, id, label) 171 | target.setAttribute('placeholder', label) 172 | completeTheLabel (target, id) 173 | } 174 | }, 175 | select: 176 | function (event, ui) { 177 | const chosen = ui.item 178 | setSelected(selectElement, chosen.id, chosen.label) 179 | } 180 | } 181 | ) 182 | /* Handle the placeholder in the autocomplete field, 183 | * showing the current author, but switching to 184 | * "Type the name" instructions 185 | * when the field gets focus. 186 | */ 187 | autoCompleteElement.addEventListener('focus', event => { 188 | event.target.setAttribute('placeholder', event.target.dataset.p1); 189 | }) 190 | autoCompleteElement.addEventListener('blur', event => { 191 | event.target.setAttribute('placeholder', event.target.dataset.p2); 192 | }) 193 | } catch (e) { 194 | console.error(e) 195 | } 196 | } 197 | 198 | /* classic editor author choice */ 199 | let done = false 200 | while (!done) { 201 | const selectElement = document.querySelector('div#authordiv > div.inside > select.index-wp-users-for-speed') 202 | if (!selectElement) break 203 | const labelElement = selectElement.parentElement.querySelector('label') 204 | if (!labelElement) break 205 | const autocompleteElement = selectElement.parentElement.querySelector('span.input-text-wrap > input') 206 | const id = 'index-mysql-users-for-speed-input' 207 | autocompleteElement.setAttribute('id', id) 208 | labelElement.setAttribute('for', id) 209 | await autocompleteSetup(selectElement) 210 | done = true 211 | } 212 | 213 | 214 | /* quick edit */ 215 | theList.on('click', '.editinline', inlineClickHandler) 216 | 217 | /* bulk edit */ 218 | $('#doaction').on('click', function (event) { 219 | if (this.parentElement.querySelector('select').value === 'edit') { 220 | bulkClickHandler(event).then() 221 | } 222 | }); 223 | 224 | }) 225 | -------------------------------------------------------------------------------- /admin/progressbar.php: -------------------------------------------------------------------------------- 1 | 15 | */ 16 | class ProgressBar { 17 | 18 | private $plugin_name; 19 | private $version; 20 | private $indexer; 21 | private $percentComplete; 22 | private $available; 23 | 24 | /** 25 | * Initialize the class and set its properties. 26 | * 27 | */ 28 | public function __construct() { 29 | 30 | $this->plugin_name = INDEX_WP_USERS_FOR_SPEED_NAME; 31 | $this->version = INDEX_WP_USERS_FOR_SPEED_VERSION; 32 | $this->indexer = Indexer::getInstance(); 33 | 34 | $this->percentComplete = $this->indexer->metaIndexRoleFraction(); 35 | $this->available = $this->indexer->isMetaIndexRoleAvailable(); 36 | if ( $this->percentComplete < 1.0 || wp_doing_ajax() ) { 37 | add_filter( 'heartbeat_received', [ $this, 'heartbeat' ], 10, 2 ); 38 | } 39 | if ( $this->percentComplete < 1.0 ) { 40 | add_action( 'admin_notices', [ $this, 'percent_complete_notice' ] ); 41 | add_filter( 'heartbeat_settings', [ $this, 'heartbeatSettings' ], 10, 1 ); 42 | } 43 | } 44 | 45 | /** Display progress notice bar if need be, dashboard only. 46 | * @return void 47 | */ 48 | public function percent_complete_notice() { 49 | if ( $this->percentComplete < 1.0 ) { 50 | wp_enqueue_script( $this->plugin_name . '_percent', plugin_dir_url( __FILE__ ) . 'js/percent.js', [], $this->version ); 51 | $suffix = esc_html__( '% complete.', 'index-wp-users-for-speed' ); 52 | if ( $this->available ) { 53 | $prefix = esc_html__( 'Background user index refresh in progress:', 'index-wp-users-for-speed' ); 54 | $sentence = esc_html__( 'You may use your site normally during index refreshing.', 'index-wp-users-for-speed' ); 55 | } else { 56 | $prefix = esc_html__( 'Background user index building in progress:', 'index-wp-users-for-speed' ); 57 | $suffix = esc_html__( '% complete.', 'index-wp-users-for-speed' ); 58 | $sentence = esc_html__( 'You may use your site normally during index building.', 'index-wp-users-for-speed' ); 59 | } 60 | $percent = esc_html( number_format( $this->percentComplete * 100.0, 0 ) ); 61 | $percent = "$prefix $percent$suffix $sentence"; 62 | ?> 63 |
64 |

65 |
66 | percentComplete = $this->indexer->metaIndexRoleFraction(); 82 | $response['index_wp_users_for_speed_percent'] = number_format( $this->percentComplete, 3 ); 83 | return $response; 84 | } 85 | 86 | /** Filter to set heartbeat to frequent during index build or refresh. 87 | * Each heartbeat kicks the cronjob, so this is a way to keep the job 88 | * going efficiently on quiet sites. 89 | * (On busy sites this doesn't matter.) 90 | * 91 | * @param array $settings Heartbeat settings. 92 | * 93 | * @return array 94 | */ 95 | public function heartbeatSettings( $settings ) { 96 | $settings['interval'] = 15; 97 | return $settings; 98 | } 99 | } 100 | 101 | new ProgressBar(); 102 | -------------------------------------------------------------------------------- /admin/user-handler.php: -------------------------------------------------------------------------------- 1 | 21 | */ 22 | class UserHandler extends WordPressHooks { 23 | 24 | private $plugin_name; 25 | private $version; 26 | private $indexer; 27 | private $pluginPath; 28 | private $recursionLevelBySite = []; 29 | private $userCount = 0; 30 | private $options_name; 31 | private $selectionBoxCache; 32 | private $doAutocomplete; 33 | private $requestedCapabilities; 34 | private $savedOrderby; 35 | private $doingRestQuery; 36 | 37 | public function __construct() { 38 | 39 | $this->plugin_name = INDEX_WP_USERS_FOR_SPEED_NAME; 40 | $this->version = INDEX_WP_USERS_FOR_SPEED_VERSION; 41 | $this->indexer = Indexer::getInstance(); 42 | $this->pluginPath = plugin_dir_path( dirname( __FILE__ ) ); 43 | $this->options_name = INDEX_WP_USERS_FOR_SPEED_PREFIX . 'options'; 44 | 45 | parent::__construct(); 46 | } 47 | 48 | /** 49 | * Filters whether the site is considered large, based on its number of users. 50 | * 51 | * Here we declare that the site is not large. 52 | * 53 | * @param bool $is_large_user_count Whether the site has a large number of users. 54 | * @param int $count The total number of users. 55 | * @param int|null $network_id ID of the network. `null` represents the current network. 56 | * 57 | * @noinspection PhpUnused 58 | * 59 | * @since 6.0.0 60 | * 61 | */ 62 | public function filter__wp_is_large_user_count( $is_large_user_count, $count, $network_id ) { 63 | return false; 64 | } 65 | 66 | /** 67 | * Fires immediately before updating user metadata. 68 | * 69 | * We use this to watch for changes in the wp_capabilities metadata. 70 | * (It's named wp_2_capabilities etc in multisite). 71 | * 72 | * @param int $meta_id ID of the metadata entry to update. 73 | * @param int $user_id ID of the object metadata is for. 74 | * @param string $meta_key Metadata key. 75 | * @param mixed $meta_value Metadata value, not serialized. 76 | * 77 | * @return void 78 | * @since 2.9.0 79 | * 80 | */ 81 | public function action__update_user_meta( $meta_id, $user_id, $meta_key, $meta_value ) { 82 | if ( ! $this->isCapabilitiesKey( $meta_key ) ) { 83 | return; 84 | } 85 | $newRoles = $this->sanitizeCapabilitiesOption( $meta_value ); 86 | $oldRoles = $this->getCurrentUserRoles( $user_id, $meta_key ); 87 | $this->userRoleChange( $user_id, $newRoles, $oldRoles ); 88 | } 89 | 90 | /** Returns the capabilities meta key, or false if it's not the capabilities key. 91 | * 92 | * @param $meta_key 93 | * 94 | * @return false|string 95 | */ 96 | private function isCapabilitiesKey( $meta_key ) { 97 | global $wpdb; 98 | return $meta_key === $wpdb->prefix . 'capabilities'; 99 | } 100 | 101 | /** Make sure wp_capabilities option values don't contain unexpected junk. 102 | * 103 | * @param array $option Option value, from dbms. 104 | * 105 | * @return array Option value, cleaned up. 106 | */ 107 | private function sanitizeCapabilitiesOption( $option ) { 108 | if ( ! is_array( $option ) ) { 109 | return []; 110 | } 111 | /* each array element must be 'string' => true in a valid option */ 112 | return array_filter( $option, function ( $value, $key ) { 113 | return is_string( $key ) && $value === true; 114 | }, ARRAY_FILTER_USE_BOTH ); 115 | } 116 | 117 | private function getCurrentUserRoles( $user_id, $meta_key = null ) { 118 | global $wpdb; 119 | if ( ! $meta_key ) { 120 | $meta_key = $wpdb->prefix . 'capabilities'; 121 | } 122 | $roles = $this->sanitizeCapabilitiesOption( get_user_meta( $user_id, $meta_key, true ) ); 123 | 124 | $metas = get_user_meta( $user_id, '', false ); 125 | $prefix = $wpdb->prefix . INDEX_WP_USERS_FOR_SPEED_KEY_PREFIX . 'r:'; 126 | foreach ( $metas as $key => $value ) { 127 | if ( strpos( $key, $prefix ) === 0 ) { 128 | $role = explode( ':', $key )[1]; 129 | $roles [ $role ] = true; 130 | } 131 | } 132 | return $roles; 133 | } 134 | 135 | /** 136 | * @param int $user_id 137 | * @param array $newRoles 138 | * @param array $oldRoles 139 | * 140 | * @return void 141 | */ 142 | private function userRoleChange( $user_id, $newRoles, $oldRoles ) { 143 | 144 | if ( $newRoles !== $oldRoles ) { 145 | $toAdd = array_diff_key( $newRoles, $oldRoles ); 146 | $toRemove = array_diff_key( $oldRoles, $newRoles ); 147 | 148 | foreach ( array_keys( $toRemove ) as $role ) { 149 | $this->indexer->updateUserCounts( $role, - 1 ); 150 | $this->indexer->updateEditors( $user_id, true ); 151 | $this->indexer->removeIndexRole( $user_id, $role ); 152 | } 153 | foreach ( array_keys( $toAdd ) as $role ) { 154 | $this->indexer->updateUserCounts( $role, + 1 ); 155 | $this->indexer->updateEditors( $user_id, false ); 156 | $this->indexer->addIndexRole( $user_id, $role ); 157 | } 158 | } 159 | } 160 | 161 | /** 162 | * Fires immediately before user meta is added. 163 | * 164 | * We use this to watch a new wp_capabilities metadata item, meaning 165 | * a new user is added, overall or to a particular multisite blog. 166 | * It's named wp_2_capabilities etc in multisite. 167 | * 168 | * @param int $user_id ID of the object metadata is for. 169 | * @param string $meta_key Metadata key. 170 | * @param mixed $meta_value Metadata value. 171 | * 172 | * @since 3.1.0 173 | * 174 | */ 175 | public function action__add_user_meta( $user_id, $meta_key, $meta_value ) { 176 | if ( ! $this->isCapabilitiesKey( $meta_key ) ) { 177 | return; 178 | } 179 | $newRoles = $this->sanitizeCapabilitiesOption( $meta_value ); 180 | $this->indexer->updateUserCountsTotal( + 1 ); 181 | $oldRoles = $this->getCurrentUserRoles( $user_id, $meta_key ); 182 | $this->userRoleChange( $user_id, $newRoles, $oldRoles ); 183 | } 184 | 185 | /** 186 | * Fires immediately before deleting user metadata. 187 | * 188 | * We use this to watch for deletion of the wp_capabilities metadata. 189 | * That means the user is being deleted. 190 | * It's named wp_2_capabilities etc in multisite. 191 | * This fires when a user is removed from a blog in a multisite setup. 192 | * 193 | * @param string[] $meta_ids An array of metadata entry IDs to delete. 194 | * @param int $user_id ID of the object metadata is for. 195 | * @param string $meta_key Metadata key. 196 | * @param mixed $meta_value Metadata value, not serialized. 197 | * 198 | * @since 3.1.0 199 | * 200 | */ 201 | public function action__delete_user_meta( $meta_ids, $user_id, $meta_key, $meta_value ) { 202 | if ( ! $this->isCapabilitiesKey( $meta_key ) ) { 203 | return; 204 | } 205 | $oldRoles = $this->getCurrentUserRoles( $user_id, $meta_key ); 206 | $this->indexer->updateUserCountsTotal( - 1 ); 207 | $this->userRoleChange( $user_id, [], $oldRoles ); 208 | } 209 | 210 | /** 211 | * Filters the user count before queries are run. 212 | * 213 | * Return a non-null value to cause count_users() to return early. 214 | * We may have pre-accumulated the user counts. If so we can 215 | * skip the expensive query to do that again. 216 | * 217 | * @param null|string $result The value to return instead. Default null to continue with the query. 218 | * @param string $strategy Optional. The computational strategy to use when counting the users. 219 | * Accepts either 'time' or 'memory'. Default 'time'. (ignored) 220 | * @param int|null $site_id Optional. The site ID to count users for. Defaults to the current site. 221 | * 222 | * @noinspection PhpUnused 223 | * @since 5.1.0 224 | * 225 | */ 226 | public function filter__pre_count_users( $result, $strategy, $site_id ) { 227 | /* cron jobs use this; don't intervene with this filter there. */ 228 | if ( wp_doing_cron() ) { 229 | return $result; 230 | } 231 | if ( 'force_recount' === $strategy ) { 232 | return $result; 233 | } 234 | /* this bad boy gets called recursively, the way we cache user counts. */ 235 | if ( ! array_key_exists( $site_id, $this->recursionLevelBySite ) ) { 236 | $this->recursionLevelBySite[ $site_id ] = 0; 237 | } 238 | if ( $this->recursionLevelBySite[ $site_id ] > 0 ) { 239 | return $result; 240 | } 241 | 242 | if ( is_multisite() ) { 243 | switch_to_blog( $site_id ); 244 | } 245 | $this->recursionLevelBySite[ $site_id ] ++; 246 | $output = $this->indexer->getUserCounts(); 247 | $this->recursionLevelBySite[ $site_id ] --; 248 | 249 | if ( is_multisite() ) { 250 | restore_current_blog(); 251 | } 252 | 253 | return $output; 254 | } 255 | 256 | /** 257 | * Filters the query arguments for the list of users in the dropdown (classic editor, quick edit) 258 | * 259 | * @param array $query_args The query arguments for get_users(). 260 | * @param array $parsed_args The arguments passed to wp_dropdown_users() combined with the defaults. 261 | * 262 | * @returns array Updated $query_args 263 | * @since 4.4.0 264 | * 265 | * @noinspection PhpUnused 266 | */ 267 | public function filter__wp_dropdown_users_args( $query_args, $parsed_args ) { 268 | /* is this about posts or pages */ 269 | if ( array_key_exists( 'capability', $query_args ) ) { 270 | $this->requestedCapabilities = $query_args['capability']; 271 | } 272 | /* Is our number of possible authors smaller than the threshold? */ 273 | $threshold = get_option( $this->options_name )['quickedit_threshold_limit']; 274 | $editors = $this->indexer->getEditors(); 275 | $this->doAutocomplete = true; 276 | if ( ! is_array( $editors ) || count( $editors ) <= $threshold ) { 277 | $this->doAutocomplete = false; 278 | $query_args['include'] = $editors; 279 | } else if ( array_key_exists( 'include_selected', $parsed_args ) && $parsed_args['include_selected'] 280 | && array_key_exists( 'name', $parsed_args ) && $parsed_args['name'] === 'post_author_override' 281 | && array_key_exists( 'selected', $parsed_args ) && is_numeric( $parsed_args['selected'] ) && $parsed_args['selected'] > 0 ) { 282 | /* Fetch just that single author, by ID, into the dropdown. The autocomplete code will then use it. */ 283 | $query_args['include'] = [ $parsed_args['selected'] ]; 284 | unset ( $query_args['capability'] ); 285 | return $query_args; 286 | } 287 | $fixed_args = $this->filtered_query_args( $query_args, $parsed_args ); 288 | /* This query is run twice, once for quickedit and again for bulkedit. 289 | * This suppresses most of the work in both runs. */ 290 | if ( $this->doAutocomplete ) { 291 | $fixed_args ['number'] = 1; 292 | $this->savedOrderby = $fixed_args['orderby']; 293 | $fixed_args['orderby'] = 'ID'; 294 | } 295 | return $fixed_args; 296 | } 297 | 298 | private function filtered_query_args( $query_args, $parsed_args ) { 299 | $capsFound = []; 300 | 301 | if ( array_key_exists( 'capability', $parsed_args ) || array_key_exists( 'capability__in', $parsed_args ) ) { 302 | /* deal with the possibility that we have either the capability or the capability__in arg */ 303 | $cap = array_key_exists( 'capability', $parsed_args ) ? $parsed_args['capability'] : []; 304 | $cap = is_array( $cap ) ? $cap : [ $cap ]; 305 | $caps = array_key_exists( 'capability__in', $parsed_args ) ? $parsed_args['capability__in'] : []; 306 | $argsCap = array_unique( $cap + $caps ); 307 | /* capabilites are edit_posts and/or edit_pages */ 308 | foreach ( [ 'edit_posts', 'edit_pages' ] as $capToCheck ) { 309 | if ( in_array( $capToCheck, $argsCap ) ) { 310 | $capsFound [] = $capToCheck; 311 | } 312 | } 313 | } else if ( isset( $query_args['who'] ) && $query_args['who'] === 'authors' ) { 314 | /* Clean up the obsolete 'who' REST argument if it's there, thanks Gutenberg editor. */ 315 | $capsFound = [ 'edit_posts', 'edit_pages' ]; 316 | unset ( $query_args['who'] ); 317 | } else { 318 | return $query_args; 319 | } 320 | /* count up the users if we can */ 321 | $userCounts = $this->indexer->getUserCounts( false ); 322 | $userCounts = is_array( $userCounts ) ? $userCounts : []; 323 | $roleCounts = array_key_exists( 'avail_roles', $userCounts ) ? $userCounts['avail_roles'] : []; 324 | if ( $this->indexer->isMetaIndexRoleAvailable() ) { 325 | /* the meta indexing is done. Use it. */ 326 | /* Find the list of roles (administrator, contributor, etc.) with $capsFound capabilities */ 327 | global $wp_roles; 328 | /* sometimes it isn't initialized in multisite. */ 329 | $wp_roles = $wp_roles ?: new \WP_Roles(); 330 | $wp_roles->for_site( get_current_blog_id() ); 331 | $roleList = []; 332 | foreach ( $capsFound as $capFound ) { 333 | foreach ( $wp_roles->roles as $name => $role ) { 334 | $caps = &$role['capabilities']; 335 | if ( array_key_exists( $capFound, $caps ) && $caps[ $capFound ] === true ) { 336 | $userCount = array_key_exists( $name, $roleCounts ) ? $roleCounts[ $name ] : 0; 337 | if ( $userCount > 0 ) { 338 | $roleList[ $name ] = true; 339 | } 340 | } 341 | } 342 | } 343 | $metaQuery = []; 344 | foreach ( $roleList as $name => $_ ) { 345 | $metaQuery[] = $this->makeRoleQueryArgs( $name ); 346 | $userCount = array_key_exists( $name, $roleCounts ) ? $roleCounts[ $name ] : 0; 347 | $this->userCount += $userCount; 348 | } 349 | if ( count( $metaQuery ) === 0 ) { 350 | return $query_args; 351 | } 352 | if ( count( $metaQuery ) > 1 ) { 353 | $metaQuery['relation'] = 'OR'; 354 | } 355 | add_filter( 'get_meta_sql', [ $this, 'filter_meta_sql' ], 10, 6 ); 356 | $query_args ['meta_query'] = $metaQuery; 357 | unset ( $query_args ['capability__in'] ); 358 | unset ( $query_args['capability'] ); 359 | $this->savedOrderby = $query_args ['orderby']; 360 | $query_args ['orderby'] = 'ID'; 361 | } else { 362 | /* The meta indexing isn't yet done. Return partial list of editors. */ 363 | $editors = $this->indexer->getEditors(); 364 | if ( is_array( $editors ) ) { 365 | $query_args['include'] = $editors; 366 | } 367 | } 368 | return $query_args; 369 | } 370 | 371 | /** Create a meta arg for looking for an exsisting role tag 372 | * 373 | * @param string $role 374 | * @param string $compare 'NOT EXISTS' or 'EXISTS' (the default). 375 | * 376 | * @return array meta query arg array 377 | */ 378 | private 379 | function makeRoleQueryArgs( 380 | $role, $compare = 'EXISTS' 381 | ) { 382 | global $wpdb; 383 | $roleMetaPrefix = $wpdb->prefix . INDEX_WP_USERS_FOR_SPEED_KEY_PREFIX . 'r:'; 384 | $roleMetaKey = $roleMetaPrefix . $role; 385 | 386 | return [ 'key' => $roleMetaKey, 'compare' => $compare ]; 387 | } 388 | 389 | /** 390 | * Filters the meta query's generated SQL. 'get_meta_sql' 391 | * We must intervene in query generation here due to a defect in WP Core's 392 | * generation of postmeta key 'a' EXISTS OR key 'b' EXISTS OR key 'c' EXISTS ... 393 | * 394 | * @param string[] $sql Array containing the query's JOIN and WHERE clauses. 395 | * @param array $queries Array of meta queries. 396 | * @param string $type Type of meta. Possible values include but are not limited 397 | * to 'post', 'comment', 'blog', 'term', and 'user'. 398 | * @param string $primary_table Primary table. 399 | * @param string $primary_id_column Primary column ID. 400 | * @param object $context The main query object that corresponds to the type, for 401 | * example a `WP_Query`, `WP_User_Query`, or `WP_Site_Query`. 402 | * 403 | * @since 3.1.0 404 | * 405 | */ 406 | public function filter_meta_sql( 407 | $sql, $queries, $type, $primary_table, $primary_id_column, $context 408 | ) { 409 | global $wpdb; 410 | if ( $type !== 'user' ) { 411 | return $sql; 412 | } 413 | /* single meta query that doesn't look like one of ours. */ 414 | if ( ! is_multisite() && ( ! array_key_exists( 'relation', $queries ) || $queries['relation'] !== 'OR' ) ) { 415 | return $sql; 416 | } 417 | 418 | if ( is_multisite() ) { 419 | /* fix up the meta query */ 420 | $queries = $this->flattenQuery( $queries, $wpdb->prefix . 'capabilities' ); 421 | } 422 | 423 | if ( $queries['relation'] === 'OR' ) { 424 | $keys = []; 425 | foreach ( $queries as $query ) { 426 | if ( is_array( $query ) ) { 427 | if ( ! array_key_exists( 'compare', $query ) || $query ['compare'] !== 'EXISTS' ) { 428 | return $sql; 429 | } 430 | if ( ! array_key_exists( 'key', $query ) || ! is_string( $query['key'] ) ) { 431 | return $sql; 432 | } 433 | $keys[] = $query['key']; 434 | } 435 | } 436 | $capabilityTags = array_map( function ( $key ) { 437 | global $wpdb; 438 | return $wpdb->prepare( "%s", $key ); 439 | }, $keys ); 440 | 441 | $where = PHP_EOL . " AND $wpdb->users.ID IN ( SELECT user_id FROM $wpdb->usermeta WHERE meta_key IN (" . implode( ',', $capabilityTags ) . '))' . PHP_EOL; 442 | /* only do this once per invocation of user query with metadata */ 443 | remove_filter( 'get_meta_sql', [ $this, 'filter_meta_sql' ], 10 ); 444 | 445 | $sql['join'] = ''; 446 | $sql['where'] = $where; 447 | } 448 | return $sql; 449 | } 450 | 451 | /** Flatten out the meta query terms we get from a multisite setup, 452 | * allowing their interpretation as if from a single site. 453 | * This is kludgey because it removes AND exists(wp_capabilities key). 454 | * 455 | * @param array $query 456 | * @param string $keyToRemove Something like wp_2_capapabilities or wp_capabilities 457 | * 458 | * @return string[] 459 | */ 460 | private function flattenQuery( $query, $keyToRemove ) { 461 | $r = $this->parseQuery( $query ); 462 | $q = [ 'relation' => 'OR' ]; 463 | foreach ( $r as $item ) { 464 | if ( $item['key'] !== $keyToRemove ) { 465 | $q [] = $item; 466 | } 467 | } 468 | return $q; 469 | } 470 | 471 | /** Traverse a nested WP_Query_Meta query array flattening the 472 | * real terms in it. 473 | * 474 | * @param array $query 475 | * 476 | * @return array Flattened array. 477 | */ 478 | private function parseQuery( $query ) { 479 | $result = []; 480 | 481 | foreach ( $query as $k => $q ) { 482 | if ( $k !== 'relation' ) { 483 | if ( array_key_exists( 'key', $q ) && array_key_exists( 'compare', $q ) ) { 484 | $result [] = $q; 485 | } else { 486 | $result = array_merge( $result, $this->parseQuery( $q ) ); 487 | } 488 | } 489 | } 490 | return $result; 491 | } 492 | 493 | /** 494 | * Filters the users array before the query takes place. 495 | * 496 | * Return a non-null value to bypass WordPress' default user queries. 497 | * 498 | * Filtering functions that require pagination information are encouraged to set 499 | * the `total_users` property of the WP_User_Query object, passed to the filter 500 | * by reference. If WP_User_Query does not perform a database query, it will not 501 | * have enough information to generate these values itself. 502 | * 503 | * @param array|null $results Return an array of user data to short-circuit WP's user query 504 | * or null to allow WP to run its normal queries. 505 | * @param WP_User_Query $query The WP_User_Query instance (passed by reference). 506 | * 507 | * @since 5.1.0 508 | * 509 | */ 510 | public function filter__users_pre_query( $results, $query ) { 511 | global $wpdb; 512 | /* Do we want a result set of just one value because we're autocompleting? 513 | * We can just fake it and shortcut the query entirely. */ 514 | if ( $this->doAutocomplete ) { 515 | if ( array_key_exists( 'number', $query->query_vars ) && $query->query_vars['number'] === 1 ) { 516 | $splits = explode( ' ', $query->query_fields ); 517 | if ( count( $splits ) > 1 && strtoupper( $splits[0] ) === 'DISTINCT' ) { 518 | array_shift( $splits ); 519 | $fields = explode( ',', implode( ' ', $splits ) ); 520 | $fakeRow = []; 521 | foreach ( $fields as $field ) { 522 | $components = explode( '.', $field ); 523 | if ( count( $components ) > 1 && $components[0] === $wpdb->users ) { 524 | array_shift( $components ); 525 | $field = implode( '.', $components ); 526 | } 527 | $fakeRow[ $field ] = '-1'; 528 | } 529 | $query->query_fields = $fields; 530 | return [ (object) $fakeRow ]; 531 | } 532 | } 533 | } 534 | 535 | /* Are we doing a REST query? If so we can get rid of the ordering to speed things up. */ 536 | if ( $this->doingRestQuery ) { 537 | $query->query_orderby = ''; 538 | $this->doingRestQuery = false; 539 | } 540 | return $results; 541 | } 542 | 543 | /** 544 | * Fires before the WP_User_Query has been parsed. 545 | * 546 | * The passed WP_User_Query object contains the query variables, 547 | * not yet passed into SQL. We change the variables here, 548 | * if we have the data, to use the index roles and 549 | * count. That meanse the query doesn't need 550 | * meta_value LIKE '%someting%' or the 551 | * 552 | * @param WP_User_Query $query Current instance of WP_User_Query (passed by reference). 553 | * 554 | * @noinspection PhpUnused 555 | * @since 4.0.0 556 | * 557 | */ 558 | public 559 | function action__pre_get_users( 560 | $query 561 | ) { 562 | 563 | /* the order of these is important: mungCountTotal won't work after mungRoleFilters */ 564 | $this->mungCountTotal( $query ); 565 | $this->mungRoleFilters( $query ); 566 | } 567 | 568 | /** 569 | * Here we figure out whether we already know the total users, and 570 | * switch off 'count_total' if we do. That saves the performance-killing 571 | * and deprecated SELECT SQL_CALC_FOUND_ROWS modifier. 572 | * 573 | * @see https://dev.mysql.com/doc/refman/8.0/en/information-functions.html#function_found-rows 574 | * 575 | * Do this before mungRoleFilters. 576 | * 577 | * @param WP_User_Query $query Current instance of WP_User_Query (passed by reference). 578 | * 579 | */ 580 | private function mungCountTotal( $query ) { 581 | /* we will bash $qv in place, so take it by ref */ 582 | $qv = &$query->query_vars; 583 | if ( ! isset( $qv['count_total'] ) || $qv['count_total'] === false ) { 584 | /* not trying to count total, no intervention needed */ 585 | return; 586 | } 587 | /* did we already count up the users ? */ 588 | if ( $this->userCount > 0 ) { 589 | $qv['count_total'] = false; 590 | $query->total_users = $this->userCount; 591 | $this->userCount = 0; 592 | return; 593 | } 594 | /* look for filters other than role filters, if present we can't intervene in user count */ 595 | $filterList = [ 596 | 'meta_key', 597 | 'meta_value', 598 | 'meta_compare', 599 | 'meta_compare_key', 600 | 'meta_type', 601 | 'meta_type_key', 602 | 'meta_query', 603 | 'capability', 604 | 'capability__in', 605 | 'capability__not_in', 606 | 'include', 607 | 'exclude', 608 | 'search', 609 | 'search_columns', 610 | 'has_published_posts', 611 | 'nicename', 612 | 'nicename__in', 613 | 'nicename__not_in', 614 | 'login', 615 | 'login__in', 616 | 'login__not_in', 617 | ]; 618 | foreach ( $filterList as $filter ) { 619 | if ( array_key_exists( $filter, $qv ) && 620 | ( ( is_string( $qv[ $filter ] ) && strlen( $qv[ $filter ] ) > 0 ) || 621 | ( is_array( $qv[ $filter ] ) && count( $qv[ $filter ] ) > 0 ) ) ) { 622 | return; 623 | } 624 | } 625 | 626 | /* we can't handle any complex role filtering. One included role only. */ 627 | list( $roleSet, $roleExclude ) = $this->getRoleFilterSets( $qv ); 628 | if ( count( $roleSet ) > 1 && count( $roleExclude ) > 0 ) { 629 | return; 630 | } 631 | 632 | /* OK, let's see if we have the counts */ 633 | $task = new CountUsers(); 634 | $counts = $task->getStatus(); 635 | if ( ! $task->isAvailable( $counts ) ) { 636 | return; 637 | } 638 | $count = - 1; 639 | if ( count( $roleSet ) === 0 && isset( $counts['total_users'] ) ) { 640 | $count = $counts['total_users']; 641 | } else if ( is_array( $counts['avail_roles'] ) ) { 642 | $availRoles = $counts['avail_roles']; 643 | $role = $roleSet[0]; 644 | if ( isset( $availRoles[ $role ] ) ) { 645 | $count = $availRoles[ $role ]; 646 | } 647 | } 648 | if ( $count >= 0 ) { 649 | $qv['count_total'] = false; 650 | $query->total_users = $count; 651 | } 652 | } 653 | 654 | /** 655 | * @param array $qv 656 | * 657 | * @return array 658 | */ 659 | private function getRoleFilterSets( array $qv ) { 660 | /* make a set of roles to include */ 661 | $roleSet = []; 662 | if ( isset( $qv['role'] ) && $qv['role'] !== '' ) { 663 | $roleSet [] = $qv['role']; 664 | } 665 | 666 | if ( isset( $qv['role__in'] ) ) { 667 | $list = is_array( $qv['role__in'] ) ? $qv['role__in'] : explode( ',', $qv['role__in'] ); 668 | $roleSet = array_merge( $roleSet, $list ); 669 | } 670 | $roleSet = array_unique( $roleSet ); 671 | /* make a set of roles to exclude */ 672 | $roleExclude = []; 673 | if ( isset( $qv['role__not_in'] ) ) { 674 | $list = is_array( $qv['role__not_in'] ) ? $qv['role__not_in'] : explode( ',', $qv['role__not_in'] ); 675 | $roleExclude = array_merge( $roleExclude, $list ); 676 | } 677 | $roleExclude = array_unique( $roleExclude ); 678 | 679 | return [ $roleSet, $roleExclude ]; 680 | } 681 | 682 | /** 683 | * Here we look at the query object to see whether it filters by role. 684 | * If it does, we add meta filters to filter by our 685 | * 'wp_index-wp-users-for-speed-role-ROLENAME' meta_key items 686 | * and remove the role filters. This gets us away from 687 | * the nasty and inefficient 688 | * meta_value LIKE '%ROLENAME%' 689 | * query pattern and into a more sargable pattern. 690 | * 691 | * @param WP_User_Query $query Current instance of WP_User_Query (passed by reference). 692 | * 693 | */ 694 | private function mungRoleFilters( $query ) { 695 | /* we will bash $qv in place, so take it by ref */ 696 | $qv = &$query->query_vars; 697 | list( $roleSet, $roleExclude ) = $this->getRoleFilterSets( $qv ); 698 | 699 | /* if the present query doesn't filter by any roles, don't do anything extra */ 700 | if ( count( $roleSet ) === 0 && count( $roleExclude ) === 0 ) { 701 | return; 702 | } 703 | 704 | $task = new PopulateMetaIndexRoles(); 705 | if ( ! $task->isAvailable() ) { 706 | return; 707 | } 708 | 709 | /* assemble some meta query args per 710 | * https://developer.wordpress.org/reference/classes/wp_meta_query/__construct/ 711 | */ 712 | $includes = []; 713 | foreach ( $roleSet as $role ) { 714 | $includes[] = $this->makeRoleQueryArgs( $role ); 715 | } 716 | if ( count( $includes ) > 1 ) { 717 | $includes = [ 'relation' => 'OR', $includes ]; 718 | } 719 | $excludes = []; 720 | foreach ( $roleExclude as $role ) { 721 | $excludes[] = $this->makeRoleQueryArgs( $role, 'NOT EXISTS' ); 722 | } 723 | if ( count( $excludes ) > 1 ) { 724 | $excludes = [ 'relation' => 'AND', $excludes ]; 725 | } 726 | if ( count( $includes ) > 0 && count( $excludes ) > 0 ) { 727 | $meta = [ 'relation' => 'AND', $includes, $excludes ]; 728 | } else if ( count( $includes ) > 0 ) { 729 | $meta = $includes; 730 | } else if ( count( $excludes ) > 0 ) { 731 | $meta = $excludes; 732 | } else { 733 | $meta = false; 734 | } 735 | /* stash those meta query args in the query variables we got */ 736 | if ( isset( $qv['meta_query'] ) && $meta ) { 737 | $new = array(); 738 | $new ['relation'] = 'AND'; 739 | $new [] = $qv['meta_query']; 740 | $new [] = $meta; 741 | $qv ['meta_query'] = $new; 742 | } else { 743 | $qv ['meta_query'] = $meta; 744 | } 745 | /* and erase the role filters they replace */ 746 | $qv['role'] = ''; 747 | $qv['role__in'] = []; 748 | $qv['role__not_in'] = []; 749 | } 750 | 751 | /** 752 | * Filters WP_User_Query arguments when querying users via the REST API. (Gutenberg author-selection box) 753 | * 754 | * @link https://developer.wordpress.org/reference/classes/wp_user_query/ 755 | * 756 | * @since 4.7.0 757 | * 758 | * @param array $query_args Array of arguments for WP_User_Query. 759 | * @param WP_REST_Request $request The REST API request. 760 | * 761 | * @noinspection PhpUnused 762 | */ 763 | public function filter__rest_user_query( $query_args, $request ) { 764 | $threshold = get_option( $this->options_name )['quickedit_threshold_limit']; 765 | $editors = $this->indexer->getEditors(); 766 | if ( is_array( $editors ) && count( $editors ) <= $threshold ) { 767 | $query_args['include'] = $editors; 768 | } 769 | $this->doingRestQuery = true; 770 | return $this->filtered_query_args( $query_args, $query_args ); 771 | } 772 | 773 | /** 774 | * Filters the response immediately after executing any REST API 775 | * callbacks. 776 | * 777 | * Allows plugins to perform any needed cleanup, for example, 778 | * to undo changes made during the {@see 'rest_request_before_callbacks'} 779 | * filter. 780 | * 781 | * Note that this filter will not be called for requests that 782 | * fail to authenticate or match to a registered route. 783 | * 784 | * Note that an endpoint's `permission_callback` can still be 785 | * called after this filter - see `rest_send_allow_header()`. 786 | * 787 | * @param WP_REST_Response|WP_HTTP_Response|WP_Error|mixed $response Result to send to the client. 788 | * Usually a WP_REST_Response or WP_Error. 789 | * @param array $handler Route handler used for the request. 790 | * @param WP_REST_Request $request Request used to generate the response. 791 | * 792 | * @since 4.7.0 793 | * 794 | */ 795 | public function filter__rest_request_after_callbacks( $response, $handler, $request ) { 796 | 797 | /* Is this a REST operation to fetch a list of users? 798 | * If so, we may have messed around with the orderby parameter 799 | * of the related query to make the DBMS do less work. 800 | * In that case, fix the order of the result set. */ 801 | $params = $request->get_params(); 802 | if ( $this->savedOrderby === 'display_name' 803 | && $request->get_route() === '/wp/v2/users' 804 | && $request->get_method() === 'GET' 805 | && is_array( $params ) 806 | && array_key_exists( '_fields', $params ) 807 | && false !== strpos( $params['_fields'], 'name' ) ) { 808 | usort( $response->data, function ( $a, $b ) { 809 | return strnatcasecmp( $a['name'], $b['name'] ); 810 | } ); 811 | } 812 | $this->savedOrderby = null; 813 | $this->doingRestQuery = false; 814 | return $response; 815 | } 816 | 817 | /** 818 | * Filters the wp_dropdown_users() HTML output. 819 | * 820 | * @param string $html HTML output generated by wp_dropdown_users(). 821 | * 822 | * @returns string HTML to use. 823 | * @since 2.3.0 824 | * 825 | */ 826 | public function filter__wp_dropdown_users( $html ) { 827 | if ( ! $this->doAutocomplete ) { 828 | return $html; 829 | } 830 | 831 | wp_enqueue_script( 'jquery-ui-autocomplete' ); 832 | wp_enqueue_script( 'iufs-ui-autocomplete', 833 | plugins_url( 'js/quick-edit-autocomplete.js', __FILE__ ), 834 | [ 'jquery-ui-autocomplete' ], INDEX_WP_USERS_FOR_SPEED_VERSION ); 835 | wp_enqueue_style( 'iufs-ui-autocomplete-style', 836 | plugins_url( 'css/quick-edit-autocomplete.css', __FILE__ ), 837 | [], INDEX_WP_USERS_FOR_SPEED_VERSION ); 838 | 839 | $selectionBox = new SelectionBox( $html, get_option( $this->options_name ) ); 840 | $selectionBox->addClass( 'index-wp-users-for-speed' ); 841 | 842 | if ( $this->selectionBoxCache ) { 843 | /* Already ran a version of this, look for the No Change entry and put it into the cached SelectionBox */ 844 | if ( count( $selectionBox->users ) > 0 845 | && $selectionBox->users[0]->id === - 1 846 | && ( count( $this->selectionBoxCache->users ) === 0 || $this->selectionBoxCache->users[0]->id !== - 1 ) ) { 847 | $this->selectionBoxCache->prepend( $selectionBox->users[0] ); 848 | } 849 | } else { 850 | $this->selectionBoxCache = $selectionBox; 851 | } 852 | $autocompleteHtml = $this->selectionBoxCache->generateAutocomplete( $this->requestedCapabilities, false ); 853 | $selectHtml = $this->selectionBoxCache->generateSelect( false ); 854 | 855 | $this->savedOrderby = null; 856 | return $selectHtml . PHP_EOL . $autocompleteHtml; 857 | } 858 | } 859 | 860 | new UserHandler(); 861 | -------------------------------------------------------------------------------- /admin/views/message.php: -------------------------------------------------------------------------------- 1 |
2 |

getMessage() ); ?>

3 |
4 | -------------------------------------------------------------------------------- /admin/views/page.php: -------------------------------------------------------------------------------- 1 | %s'; 19 | $supportUrl = "https://wordpress.org/support/plugin/index-wp-users-for-speed/"; 20 | $reviewUrl = "https://wordpress.org/support/plugin/index-wp-users-for-speed/reviews/"; 21 | $clickHere = __( 'click here', 'index-wp-users-for-speed' ); 22 | $support = sprintf( $hyperlink, $supportUrl, $clickHere ); 23 | $review = sprintf( $hyperlink, $reviewUrl, $clickHere ); 24 | /* translators: 1: embeds "For help please ..." 2: hyperlink to review page on wp.org */ 25 | $supportString = '

' . __( 'For support please %1$s. Please %2$s to rate this plugin. Your feedback helps make it better, faster, and more useful.', 'index-wp-users-for-speed' ) . '

'; 26 | $supportString = sprintf( $supportString, $support, $review ); 27 | 28 | settings_errors( $this->options_name ); 29 | ?> 30 | 31 |
32 |

33 | 34 |

35 |

36 | : 37 | 38 |

39 | 40 |
41 | options_name ); 43 | do_settings_sections( $this->plugin_name ); 44 | submit_button(); 45 | ?> 46 |
47 |
48 | -------------------------------------------------------------------------------- /changelog.txt: -------------------------------------------------------------------------------- 1 | = 1.0.3 = 2 | 3 | * Fix bug when deleting users. 4 | 5 | = 1.0.2 = 6 | 7 | * Handle disabled WP_Cron. 8 | * Add ordering of authors by post count, and limiting the number of authors, in Quick Edit pulldowns. 9 | * Correct some object-handling code, making protected methods public. 10 | 11 | = 1.0.1 = 12 | 13 | * Add notice bar showing progress. Use heartbeat to keep progress going. 14 | * Fix defect when changing user role. 15 | * Integrate correctly with https://core.trac.wordpress.org/ticket/38741 for large site handling. 16 | * Now allows changing user roles. Supports WordPress 6.0 17 | 18 | = 1.0.0 = 19 | 20 | First release 21 | -------------------------------------------------------------------------------- /includes/activator.php: -------------------------------------------------------------------------------- 1 | 22 | */ 23 | class Activator { 24 | 25 | /** 26 | * Short Description. Activate the plugin. 27 | * 28 | */ 29 | public static function activate() { 30 | 31 | Activator::startIndexing(); 32 | } 33 | 34 | 35 | /** 36 | * @return void 37 | */ 38 | private static function startIndexing() { 39 | 40 | } 41 | 42 | } 43 | -------------------------------------------------------------------------------- /includes/deactivator.php: -------------------------------------------------------------------------------- 1 | 13 | */ 14 | class Deactivator { 15 | 16 | /** 17 | * We wipe out stashed indexes on deactivation, not deletion. 18 | * 19 | * It doesn't make sense to keep the index metadata when the plugin isn't active 20 | * because it doesn't get maintained. Therefore, we delete it on deactivation, 21 | * not plugin deletion. 22 | * 23 | */ 24 | public static function deactivate() { 25 | 26 | wp_unschedule_hook( 'index_wp_users_for_speed_repeating_task' ); 27 | wp_unschedule_hook( 'index_wp_users_for_speed_task' ); 28 | $sites = is_multisite() ? get_sites( [ 'fields' => 'ids' ] ) : [1]; 29 | foreach ( $sites as $site_id ) { 30 | if ( is_multisite() ) { 31 | switch_to_blog( $site_id ); 32 | } 33 | Deactivator::depopulateIndexMetadata(); 34 | Deactivator::deleteCronOptions(); 35 | if ( is_multisite() ) { 36 | restore_current_blog(); 37 | } 38 | } 39 | } 40 | 41 | private static function depopulateIndexMetadata() { 42 | $depop = new DepopulateMetaIndexes(); 43 | $depop->init(); 44 | $done = false; 45 | while ( ! $done ) { 46 | $done = $depop->doChunk(); 47 | } 48 | } 49 | 50 | private static function deleteCronOptions() { 51 | global $wpdb; 52 | $wpdb->query( 53 | $wpdb->prepare( 54 | "DELETE FROM $wpdb->options WHERE option_name LIKE CONCAT(%s, '%%')", 55 | $wpdb->esc_like( INDEX_WP_USERS_FOR_SPEED_PREFIX_TASK ) ) 56 | ); 57 | } 58 | 59 | } 60 | -------------------------------------------------------------------------------- /includes/index.php: -------------------------------------------------------------------------------- 1 | query( "LOCK TABLES $wpdb->options WRITE" ); 41 | $option = INDEX_WP_USERS_FOR_SPEED_PREFIX_TASK . $name; 42 | $log = get_option( $option ); 43 | if ( is_string( $log ) ) { 44 | $logarray = explode( PHP_EOL, $log ); 45 | } else { 46 | $logarray = []; 47 | } 48 | $logarray = array_slice( $logarray, 0, $maxlength ); 49 | array_unshift( $logarray, date( 'Y-m-d H:i:s' ) . ' ' . $msg ); 50 | $log = implode( PHP_EOL, $logarray ); 51 | add_option ( $option, $log ); 52 | $wpdb->query( 'UNLOCK TABLES' ); 53 | } 54 | 55 | public static function getInstance() { 56 | if ( ! isset( self::$singleInstance ) ) { 57 | self::$singleInstance = new self(); 58 | self::$singleInstance->maybeIndexEverything(); 59 | } 60 | 61 | return self::$singleInstance; 62 | } 63 | 64 | public function maybeIndexEverything( $force = false ) { 65 | $task = new CountUsers(); 66 | $userCounts = $task->getStatus(); 67 | if ( $force || $task->needsRunning( $userCounts ) ) { 68 | $task->init(); 69 | $task->maybeSchedule( $userCounts ); 70 | } 71 | 72 | $task = new GetEditors(); 73 | $editors = $task->getStatus(); 74 | if ( $force || $task->needsRunning( $editors ) ) { 75 | $task->init(); 76 | $task->maybeSchedule( $editors ); 77 | } 78 | 79 | $task = new PopulateMetaIndexRoles(); 80 | $populated = $task->getStatus(); 81 | if ( $force || $task->needsRunning( $populated ) ) { 82 | $task->init(); 83 | $task->maybeSchedule(); 84 | } 85 | } 86 | 87 | public function cleanupNow() { 88 | $pop = new DepopulateMetaIndexes(); 89 | $pop->init(); 90 | /** @noinspection PhpStatementHasEmptyBodyInspection */ 91 | while ( ! $pop->doChunk() ) { 92 | } 93 | } 94 | 95 | public function rebuildNow() { 96 | $this->maybeIndexEverything( true ); 97 | } 98 | 99 | /** 100 | * @return int one higher than the maximum user ID, irrespective of site in multisite. 101 | */ 102 | public function getMaxUserId() { 103 | global $wpdb; 104 | 105 | return 1 + max( 1, intval( $wpdb->get_var( "SELECT MAX(ID) FROM $wpdb->users" ) ) ); 106 | } 107 | 108 | /** Remove all indexing. 109 | * @return void 110 | */ 111 | public function removeNow() { 112 | $task = new DepopulateMetaIndexes(); 113 | $task->init(); 114 | /** @noinspection PhpStatementHasEmptyBodyInspection */ 115 | while ( ! $task->doChunk() ) { 116 | } 117 | $task->clearStatus(); 118 | $task = new CountUsers(); 119 | $task->clearStatus(); 120 | $task = new GetEditors(); 121 | $task->clearStatus(); 122 | } 123 | 124 | public function enableAutoRebuild( $seconds ) { 125 | $task = new Reindex (); 126 | $task->init(); 127 | $task->cancel(); 128 | $whenToRun = $this->nextDailyTimestamp( $seconds ); 129 | $task->schedule( $whenToRun, 'daily' ); 130 | } 131 | 132 | /** @noinspection PhpSameParameterValueInspection */ 133 | private function nextDailyTimestamp( $secondsAfterMidnight, $buffer = 30 ) { 134 | $midnight = $this->getTodayMidnightTimestamp(); 135 | $when = $secondsAfterMidnight + $midnight; 136 | if ( $when + $buffer < time() ) { 137 | /* time already passed today, do it tomorrow */ 138 | $when += DAY_IN_SECONDS; 139 | } 140 | 141 | return $when; 142 | } 143 | 144 | /** Get the UNIX timestamp for midnight today, in local time. 145 | * 146 | * @return int timestamp 147 | */ 148 | private function getTodayMidnightTimestamp() { 149 | try { 150 | $zone = new DateTimeZone ( get_option( 'timezone_string', 'UTC' ) ); 151 | $midnight = new DateTimeImmutable( 'today', $zone ); 152 | 153 | return $midnight->getTimestamp(); 154 | } catch ( Exception $ex ) { 155 | /* fallback if tz stuff fails: midnight UTC */ 156 | $t = time(); 157 | 158 | return $t - ( $t % DAY_IN_SECONDS ); 159 | } 160 | } 161 | 162 | public function disableAutoRebuild() { 163 | $task = new Reindex (); 164 | $task->init(); 165 | $task->cancel(); 166 | } 167 | 168 | /** 169 | * Filters the list of available list table views. 170 | * Replaces sentinel counts in the user views. 171 | * 172 | * @param string[] $views An array of available list table views. 173 | * 174 | * @return array 175 | */ 176 | public function fake_views_users( $views ) { 177 | 178 | $replacement = esc_attr__( 'Still counting users...', 'index-wp-users-for-speed' ); 179 | $sentinel = number_format_i18n( self::$sentinelCount ); 180 | $replacement = '...'; 181 | $result = []; 182 | foreach ( $views as $view ) { 183 | $result[] = str_replace( $sentinel, $replacement, $view ); 184 | } 185 | 186 | return $result; 187 | } 188 | 189 | /** Change the stored list of editors to add or remove a particular user as needed. 190 | * 191 | * @param int $user_id The user's ID 192 | * @param bool $removingUser True if the user is being removed. Default false. 193 | * 194 | * @return void 195 | */ 196 | public function updateEditors( $user_id, $removingUser = false ) { 197 | $userdata = get_userdata( $user_id ); 198 | $canEdit = ( ! $removingUser ) && $userdata->has_cap( 'edit_posts' ); 199 | $task = new GetEditors(); 200 | $editors = $task->getStatus(); 201 | if ( $task->isAvailable( $editors ) ) { 202 | $editorList = &$editors['editors']; 203 | if ( $canEdit ) { 204 | $editorList[] = $user_id; 205 | $editorList = array_unique( $editorList, SORT_NUMERIC ); 206 | $editors['editors'] = $editorList; 207 | } else { 208 | $result = []; 209 | foreach ( $editorList as $editor ) { 210 | if ( $editor !== $user_id ) { 211 | $result[] = $editor; 212 | } 213 | } 214 | $editors['editors'] = $result; 215 | } 216 | $task->setStatus( $editors ); 217 | } 218 | } 219 | 220 | public function getEditors() { 221 | $task = new GetEditors(); 222 | $status = $task->getStatus(); 223 | if ( $task->isAvailable( $status ) ) { 224 | return $status['editors']; 225 | } 226 | 227 | return false; 228 | } 229 | 230 | public function metaIndexRoleFraction() { 231 | $task = new PopulateMetaIndexRoles(); 232 | $status = $task->getStatus(); 233 | return $task->fractionComplete( $status ); 234 | } 235 | 236 | public function isMetaIndexRoleAvailable() { 237 | $task = new PopulateMetaIndexRoles(); 238 | $status = $task->getStatus(); 239 | return $task->isAvailable( $status ); 240 | } 241 | 242 | /** Update the user count for a particular role or roles. 243 | * 244 | * Does not change the total user count. 245 | * 246 | * @param string[]|string $roles rolename or names to change 247 | * @param integer $value number of users to add or subtract 248 | * 249 | * @return void 250 | */ 251 | public function updateUserCounts( $roles, $value ) { 252 | if ( is_string( $roles ) ) { 253 | $roles = [ $roles ]; 254 | } 255 | $task = new CountUsers(); 256 | $userCounts = $task->getStatus(); 257 | if ( $task->isAvailable( $userCounts ) ) { 258 | foreach ( $roles as $role ) { 259 | if ( ! array_key_exists( $role, $userCounts['avail_roles'] ) ) { 260 | $userCounts['avail_roles'][ $role ] = 0; 261 | } 262 | $userCounts['avail_roles'][ $role ] += $value; 263 | } 264 | 265 | $task->setStatus( $userCounts ); 266 | } 267 | } 268 | 269 | /** Update the total user count 270 | * 271 | * @param int $increment 272 | * 273 | * @return void 274 | */ 275 | public function updateUserCountsTotal( $increment ) { 276 | $task = new CountUsers(); 277 | $userCounts = $task->getStatus(); 278 | if ( $task->isAvailable( $userCounts ) ) { 279 | if ( is_numeric( $userCounts['total_users'] ) ) { 280 | $userCounts['total_users'] += $increment; 281 | } 282 | $task->setStatus( $userCounts ); 283 | } 284 | } 285 | 286 | public function getUserCounts( $allowFakes = true ) { 287 | $task = new CountUsers(); 288 | 289 | $userCounts = $task->getStatus(); 290 | if ( ! $task->isAvailable( $userCounts ) ) { 291 | if ( $allowFakes ) { 292 | /* no user counts yet. We will fake them until they're available */ 293 | $userCounts = $this->fakeUserCounts(); 294 | } 295 | if ( $task->needsRunning( $userCounts ) ) { 296 | $task->init(); 297 | $task->maybeSchedule( $userCounts ); 298 | } 299 | } 300 | 301 | return $userCounts; 302 | } 303 | 304 | /** @noinspection SqlNoDataSourceInspection */ 305 | 306 | /** Generate fake user counts for the views list on the users page. 307 | * This is compatible with the structure handled by 'pre_count_users'. 308 | * It's a hack. We put in ludicrously high user counts, 309 | * then filter them out in the `views_users` filter. 310 | * 311 | * @return array 312 | */ 313 | private function fakeUserCounts() { 314 | $total_users = is_multisite() ? self::$sentinelCount : self::getNetworkUserCount(); 315 | $avail_roles = []; 316 | $roles = wp_roles(); 317 | $roles = $roles->get_names(); 318 | foreach ( $roles as $role => $name ) { 319 | $avail_roles[ $role ] = self::$sentinelCount; 320 | } 321 | $avail_roles['none'] = 0; 322 | $result = [ 323 | 'total_users' => $total_users, 324 | 'avail_roles' => & $avail_roles, 325 | 'complete' => false, 326 | ]; 327 | 328 | add_filter( 'views_users', [ $this, 'fake_views_users' ] ); 329 | 330 | return $result; 331 | } 332 | 333 | public static function getNetworkUserCount() { 334 | 335 | if ( function_exists( 'get_user_count' ) ) { 336 | return get_user_count(); 337 | } 338 | global $wpdb; 339 | return $wpdb->get_var( "SELECT COUNT(*) FROM $wpdb->users;" ); 340 | } 341 | 342 | public function removeIndexRole( $user_id, $role ) { 343 | global $wpdb; 344 | 345 | $prefix = $wpdb->prefix . INDEX_WP_USERS_FOR_SPEED_KEY_PREFIX . 'r:'; 346 | $indexRole = $prefix . $role; 347 | delete_user_meta( $user_id, $indexRole ); 348 | } 349 | 350 | public function addIndexRole( $user_id, $role ) { 351 | global $wpdb; 352 | $prefix = $wpdb->prefix . INDEX_WP_USERS_FOR_SPEED_KEY_PREFIX . 'r:'; 353 | $indexRole = $prefix . $role; 354 | add_user_meta( $user_id, $indexRole, null ); 355 | } 356 | 357 | public function __clone() { 358 | 359 | } 360 | 361 | /** 362 | * @throws Exception 363 | */ 364 | public function __wakeup() { 365 | throw new Exception( 'cannot unserialize this singleton' ); 366 | } 367 | 368 | } 369 | -------------------------------------------------------------------------------- /includes/instacron.php: -------------------------------------------------------------------------------- 1 | $job ) { 13 | if ( array_key_exists( INDEX_WP_USERS_FOR_SPEED_HOOKNAME, $job ) ) { 14 | $needJob = true; 15 | break; 16 | } 17 | } 18 | if ( $needJob ) { 19 | $now = time(); 20 | $option = INDEX_WP_USERS_FOR_SPEED_PREFIX_TASK . 'next-job'; 21 | $next = get_option( $option ); 22 | if ( ! $next ) { 23 | $next = $now + INDEX_WP_USERS_FOR_SPEED_DELAY_CRONKICK; 24 | update_option( $option, $next, false ); 25 | } 26 | if ( $now >= $next ) { 27 | $next = $now + INDEX_WP_USERS_FOR_SPEED_DELAY_CRONKICK; 28 | update_option( $option, $next, false ); 29 | add_action( 'shutdown', 'IndexWpUsersForSpeed\kick_cron', 9999, 0 ); 30 | } 31 | } 32 | } 33 | } 34 | 35 | need_cron(); 36 | 37 | function kick_cron() { 38 | if ( wp_doing_cron() ) { 39 | /* NEVER hit the cron endpoint when doing cron, or you'll break lots of things */ 40 | return; 41 | } 42 | $url = get_site_url( null, 'wp-cron.php' ); 43 | $req = new WP_Http(); 44 | $res = $req->get( $url ); 45 | } 46 | -------------------------------------------------------------------------------- /includes/plugin.php: -------------------------------------------------------------------------------- 1 | 29 | */ 30 | class Index_Wp_Users_For_Speed { 31 | 32 | /** 33 | * The unique identifier of this plugin. 34 | * 35 | * @access protected 36 | * @var string $plugin_name The string used to uniquely identify this plugin. 37 | */ 38 | protected $plugin_name; 39 | 40 | /** 41 | * The current version of the plugin. 42 | * 43 | * @access protected 44 | * @var string $version The current version of the plugin. 45 | */ 46 | protected $version; 47 | 48 | /** 49 | * Define the core functionality of the plugin. 50 | * 51 | * Set the plugin name and the plugin version that can be used throughout the plugin. 52 | * Load the dependencies, define the locale, and set the hooks for the admin area and 53 | * the public-facing side of the site. 54 | * 55 | */ 56 | public function __construct() { 57 | $this->version = INDEX_WP_USERS_FOR_SPEED_VERSION; 58 | $this->plugin_name = INDEX_WP_USERS_FOR_SPEED_NAME; 59 | 60 | /* stuff required for all back-end. cron, REST api operations. */ 61 | /** @noinspection PhpUndefinedConstantInspection */ 62 | if ( wp_doing_cron() || wp_doing_ajax() || is_admin() || wp_is_json_request() || wp_is_xml_request() || ( defined( 'WP_CLI' ) && WP_CLI ) ) { 63 | require_once plugin_dir_path( dirname( __FILE__ ) ) . 'includes/wordpress-hooks.php'; 64 | require_once plugin_dir_path( dirname( __FILE__ ) ) . 'admin/user-handler.php'; 65 | require_once plugin_dir_path( dirname( __FILE__ ) ) . 'admin/progressbar.php'; 66 | } 67 | 68 | /* Disabled-cron only. We use WP_Cron's wp_schedule_single_event to do the batched-up index, so 69 | * we need to activate it when it's disabled. */ 70 | if ( ! wp_doing_cron() ) { 71 | $cronDisabled = defined( 'DISABLE_WP_CRON' ) && true === DISABLE_WP_CRON; 72 | if ( $cronDisabled ) { 73 | require_once plugin_dir_path( dirname( __FILE__ ) ) . 'includes/instacron.php'; 74 | } 75 | } 76 | 77 | /* stuff required for admin page but not for cron, REST */ 78 | if ( is_admin() ) { 79 | require_once plugin_dir_path( dirname( __FILE__ ) ) . 'admin/admin.php'; 80 | } 81 | } 82 | 83 | /** 84 | * The name of the plugin used to uniquely identify it within the context of 85 | * WordPress and to define internationalization functionality. 86 | * 87 | * @return string The name of the plugin. 88 | */ 89 | public function get_plugin_name() { 90 | return $this->plugin_name; 91 | } 92 | 93 | /** 94 | * Retrieve the version number of the plugin. 95 | * 96 | * @return string The version number of the plugin. 97 | */ 98 | public function get_version() { 99 | return $this->version; 100 | } 101 | 102 | public function run() { 103 | 104 | } 105 | 106 | } 107 | -------------------------------------------------------------------------------- /includes/selection-box.php: -------------------------------------------------------------------------------- 1 | tags. 10 | */ 11 | class SelectionBox { 12 | 13 | public $users; 14 | public $name; 15 | private $class; 16 | private $options; 17 | 18 | /** Constructor. 19 | * 20 | * @param string|null $html Input tag with one or more OPTION tags 31 | * 32 | * @return void 33 | */ 34 | private function parseHtmlSelectInfo( $html ) { 35 | $inputDom = new DOMDocument(); 36 | $this->users = []; 37 | if ( is_string( $html ) && strlen( $html ) > 0 ) { 38 | $inputDom->loadHTML( $html ); 39 | 40 | $selects = $inputDom->getElementsByTagName( 'select' ); 41 | $selectCount = 0; 42 | foreach ( $selects as $select ) { 43 | if ( ++ $selectCount > 1 ) { 44 | throw new ValueError( "More than one select" ); 45 | } 46 | $attributes = $select->attributes; 47 | $this->name = trim( $attributes->getNamedItem( 'name' )->nodeValue ); 48 | $this->class = array_unique( array_filter( explode( ' ', trim( $attributes->getNamedItem( 'class' )->nodeValue ) ) ) ); 49 | $options = $select->getElementsByTagName( 'option' ); 50 | foreach ( $options as $option ) { 51 | $id = intval( $option->attributes->getNamedItem( 'value' )->nodeValue ); 52 | $label = $option->textContent; 53 | if ( $id < 0 ) { 54 | $id = - 1; 55 | /* this is a core localization, hence no domain */ 56 | $label = __( '— No Change —' ); 57 | } 58 | $this->users [] = (object) [ 'id' => $id, 'label' => $label ]; 59 | } 60 | } 61 | unset ( $inputDom ); 62 | } else { 63 | $this->name = 'unknown'; 64 | $this->class = []; 65 | } 66 | } 67 | 68 | /** Get a class attribute string from the array of class names. 69 | * @return string 70 | */ 71 | private function classes() { 72 | return implode( ' ', array_unique( array_filter( $this->class ) ) ); 73 | } 74 | 75 | /** Add a class name. 76 | * @param string $class Class name to add. 77 | * 78 | * @return void 79 | */ 80 | public function addClass( $class ) { 81 | if ( is_string( $class ) ) { 82 | $class = [ $class ]; 83 | } 84 | $this->class = array_unique( array_merge( array_filter( $this->class ), array_filter( $class ) ) ); 85 | } 86 | 87 | /** Remove a class name. 88 | * 89 | * Does nothing if the class isn't already there. 90 | * 91 | * @param string $class Class to remove. 92 | * 93 | * @return void 94 | */ 95 | public function removeClass( $class ) { 96 | if ( is_string( $class ) ) { 97 | $class = [ $class ]; 98 | } 99 | $this->class = array_unique( array_filter (array_diff( $this->class, $class ))); 100 | } 101 | 102 | /** Generate a "; 111 | $users = $this->users; 112 | usort( $users, function ( $a, $b ) { 113 | return strnatcasecmp( $a->label, $b->label ); 114 | } ); 115 | foreach ( $users as $user ) { 116 | $o [] = ( $pretty ? ' ' : '' ) . ""; 117 | } 118 | $o [] = ""; 119 | return implode( $pretty ? PHP_EOL : '', $o ); 120 | } 121 | 122 | /** Generate an autocomplete tag. 123 | */ 124 | public function generateAutocomplete( $requestedCapabilities, $pretty = false ) { 125 | $nl = $pretty ? PHP_EOL : ''; 126 | 127 | /* pass capabilities to the dataset of the tag so the REST query can include them */ 128 | $data_capabilities = ''; 129 | if ( $requestedCapabilities ) { 130 | $requestedCapabilities = is_string( $requestedCapabilities ) ? [ $requestedCapabilities ] : $requestedCapabilities; 131 | $data_capabilities = esc_attr( 'data-capabilities=' . implode( ',', $requestedCapabilities ) ); 132 | } 133 | /* we need to give the base URL for the REST API to Javascript 134 | * so it gets the right site in multisite. */ 135 | $url = get_site_url(); 136 | $nonce = wp_create_nonce( 'wp_rest' ); 137 | $count = $this->options['quickedit_threshold_limit']; 138 | $placeholder = esc_attr__( 'Type the author\'s name', 'index-wp-users-for-speed' ); 139 | $tag = 140 | "$nl"; 144 | 145 | return $tag; 146 | } 147 | 148 | /** Prepend a user to the list of users 149 | * 150 | * @param $user 151 | * 152 | * @return SelectionBox 153 | */ 154 | public function prepend( $user ) { 155 | if ( ! is_array( $user ) ) { 156 | $user = [ $user ]; 157 | } 158 | $this->users = array_merge( $user, $this->users ); 159 | return $this; 160 | } 161 | } 162 | -------------------------------------------------------------------------------- /includes/tasks/count-users.php: -------------------------------------------------------------------------------- 1 | startChunk(); 24 | 25 | $userCounts = count_users('force_recount'); 26 | $this->setStatus( $userCounts, true, false, 1 ); 27 | 28 | $this->endChunk(); 29 | 30 | return true; 31 | } 32 | 33 | } 34 | -------------------------------------------------------------------------------- /includes/tasks/depopulate-meta-indexes.php: -------------------------------------------------------------------------------- 1 | batchSize = $batchSize; 27 | $this->setBlog(); 28 | $this->maxUserId = $indexer->getMaxUserId(); 29 | $this->restoreBlog(); 30 | } 31 | 32 | public function init() { 33 | parent::init(); 34 | $this->setBlog(); 35 | $this->setStatus( null, false, true, $this->fractionComplete ); 36 | $this->restoreBlog(); 37 | } 38 | 39 | /** Do a chunk of meta index insertion for roles. 40 | * @return boolean done When this is false, schedule another chunk. 41 | */ 42 | public function doChunk() { 43 | global $wpdb; 44 | $this->startChunk(); 45 | 46 | $previouslyShowing = $wpdb->hide_errors(); 47 | $previouslySuppressing = $wpdb->suppress_errors( true ); 48 | $currentEnd = $this->currentStart + $this->batchSize; 49 | $keyPrefix = $wpdb->prefix . INDEX_WP_USERS_FOR_SPEED_KEY_PREFIX; 50 | $queryTemplate = /** @lang text */ 51 | "DELETE FROM $wpdb->usermeta WHERE meta_key LIKE CONCAT(%s, '%%') AND user_id >= %d AND user_id < %d"; 52 | $query = $wpdb->prepare( $queryTemplate, $wpdb->esc_like( $keyPrefix ), $this->currentStart, $currentEnd ); 53 | 54 | $this->doQuery( $query ); 55 | 56 | $this->currentStart = $currentEnd; 57 | $done = $this->currentStart >= $this->maxUserId; 58 | 59 | $this->fractionComplete = max( 0, min( 1, $this->currentStart / $this->maxUserId ) ); 60 | $wpdb->suppress_errors( $previouslySuppressing ); 61 | $wpdb->show_errors( $previouslyShowing ); 62 | 63 | $this->endChunk(); 64 | 65 | return $done; 66 | } 67 | 68 | public function getResult() { 69 | return null; 70 | } 71 | 72 | } 73 | -------------------------------------------------------------------------------- /includes/tasks/get-editors.php: -------------------------------------------------------------------------------- 1 | startChunk(); 36 | $userCount = get_option( INDEX_WP_USERS_FOR_SPEED_PREFIX . 'options' )['quickedit_threshold_limit']; 37 | $editors = []; 38 | 39 | $params = [ 40 | 'blog_id' => $this->siteId, 41 | 'capability__in' => [ 'edit_posts', 'edit_pages' ], 42 | 'fields' => 'ID', 43 | 'orderby' => 'ID', 44 | 'number' => $userCount + 1, 45 | 'count_total' => false, 46 | ]; 47 | 48 | global $wpdb; 49 | $this->log( $wpdb->options ); 50 | $this->log( serialize( $params ) ); 51 | $userQuery = new WP_User_Query( $params ); 52 | $qresults = $userQuery->get_results(); 53 | if ( ! empty ( $qresults ) ) { 54 | $editors = array_map( 'intval', array_filter( $qresults ) ); 55 | $this->log( serialize( $editors ) ); 56 | } 57 | 58 | $this->setStatus( [ 'editors' => $editors ], true, false, 1 ); 59 | 60 | $this->endChunk(); 61 | 62 | /* done in one chunk */ 63 | 64 | return true; 65 | } 66 | 67 | public function getResult() { 68 | return get_option( INDEX_WP_USERS_FOR_SPEED_PREFIX_TASK . "editors" ); 69 | } 70 | 71 | public function reset() { 72 | delete_option( INDEX_WP_USERS_FOR_SPEED_PREFIX_TASK . "editors" ); 73 | } 74 | 75 | } 76 | -------------------------------------------------------------------------------- /includes/tasks/populate-meta-index-roles.php: -------------------------------------------------------------------------------- 1 | batchSize = $batchSize; 31 | $this->chunkSize = $chunkSize; 32 | $this->roles = []; 33 | } 34 | 35 | public function init() { 36 | parent::init(); 37 | $indexer = Indexer::getInstance(); 38 | $this->setBlog(); 39 | $this->maxUserId = $indexer->getMaxUserId(); 40 | $roles = wp_roles(); 41 | $roles = $roles->get_names(); 42 | foreach ( $roles as $role => $name ) { 43 | $this->roles[] = $role; 44 | } 45 | $this->restoreBlog(); 46 | } 47 | 48 | /** Do a chunk of meta index insertion for roles. 49 | * @return boolean done When this is false, schedule another chunk. 50 | */ 51 | public function doChunk() { 52 | global $wpdb; 53 | $this->startChunk(); 54 | 55 | $queries = $this->makeIndexerQueries( $this->roles ); 56 | $currentEnd = $this->currentStart + $this->batchSize; 57 | $transStart = $this->currentStart; 58 | 59 | $done = false; 60 | 61 | while ( $transStart < $currentEnd ) { 62 | 63 | $this->doQuery( 'BEGIN' ); 64 | $transEnd = min( $transStart + $this->chunkSize, $currentEnd ); 65 | $query = "SELECT COUNT(*) FROM $wpdb->usermeta a WHERE a.meta_key = %s AND a.user_id >= %d AND a.user_id < %d ORDER BY a.user_id FOR UPDATE"; 66 | $q = $wpdb->prepare( $query, $wpdb->prefix . 'capabilities', $transStart, $transEnd ); 67 | $this->doQuery( $q ); 68 | 69 | foreach ( $queries as $query ) { 70 | $query .= ' WHERE a.user_id >= %d AND a.user_id < %d'; 71 | $q = $wpdb->prepare( $query, $transStart, $transEnd ); 72 | $this->doQuery( $q ); 73 | } 74 | $this->doQuery( 'COMMIT' ); 75 | $transStart = $transEnd; 76 | $this->currentStart = $transEnd; 77 | 78 | } 79 | 80 | $done = $this->currentStart >= $this->maxUserId; 81 | $this->fractionComplete = min( 1, $this->currentStart / $this->maxUserId ); 82 | 83 | $this->setStatus( null, null, ! $done, $this->fractionComplete ); 84 | /* update table stats at the end of the indexing */ 85 | if ( $done ) { 86 | $this->doQuery( "ANALYZE TABLE $wpdb->usermeta;" ); 87 | } 88 | 89 | $this->endChunk(); 90 | 91 | return $done; 92 | } 93 | 94 | private function makeIndexerQueries( $roles ) { 95 | global $wpdb; 96 | 97 | $results = []; 98 | 99 | $prefix = $wpdb->prefix . INDEX_WP_USERS_FOR_SPEED_KEY_PREFIX . 'r:'; 100 | $capabilitiesKey = $wpdb->prefix . 'capabilities'; 101 | $insertUnions = []; 102 | $deleteUnions = []; 103 | /* This one finds the role index metakeys to insert into usermeta. This is idempotent. */ 104 | $insertTemplate = /** @lang text */ 105 | "SELECT a.user_id, %s meta_key 106 | FROM $wpdb->usermeta a 107 | LEFT JOIN $wpdb->usermeta b 108 | ON a.user_id = b.user_id 109 | AND b.meta_key = %s 110 | WHERE a.meta_key = %s 111 | AND a.meta_value LIKE CONCAT('%%', %s, '%%') 112 | AND b.user_id IS NULL"; 113 | /* This one finds the usermeta rows with wrong capabilities, to delete. 114 | * We want these delete and insert operations to be idempotent, 115 | * doing nothing if the proper row is already present. 116 | * Hence the antijoins (LEFT JOIN ... IS NULL). */ 117 | $deleteTemplate = /** @lang text */ 118 | "SELECT a.user_id, a.umeta_id 119 | FROM $wpdb->usermeta a 120 | LEFT JOIN $wpdb->usermeta b 121 | ON a.user_id = b.user_id 122 | AND b.meta_key = %s 123 | AND b.meta_value LIKE CONCAT('%%', %s, '%%') 124 | WHERE a.meta_key = %s 125 | AND b.umeta_id IS NULL"; 126 | 127 | foreach ( $roles as $role ) { 128 | $prefixedRole = $prefix . $role; 129 | $insertUnions[] = $wpdb->prepare( $insertTemplate, $prefixedRole, $prefixedRole, $capabilitiesKey, $wpdb->esc_like( $role ) ); 130 | $deleteUnions[] = $wpdb->prepare( $deleteTemplate, $capabilitiesKey, $wpdb->esc_like( $role ), $prefixedRole ); 131 | } 132 | 133 | $union = implode( ' UNION ALL ', $deleteUnions ); 134 | /** @noinspection SqlResolve */ 135 | $query = "DELETE a FROM $wpdb->usermeta a JOIN ($union) b ON a.umeta_id = b.umeta_id"; 136 | $results[] = $query; 137 | 138 | $union = implode( ' UNION ', $insertUnions ); 139 | /** @noinspection SqlResolve */ 140 | $query = "INSERT INTO $wpdb->usermeta (user_id, meta_key) SELECT user_id, meta_key FROM ($union) a"; 141 | $results[] = $query; 142 | 143 | return $results; 144 | } 145 | 146 | public function getResult() { 147 | return null; 148 | } 149 | 150 | } 151 | -------------------------------------------------------------------------------- /includes/tasks/reindex.php: -------------------------------------------------------------------------------- 1 | hookName = 'index_wp_users_for_speed_repeating_task'; 19 | } 20 | 21 | public function needsDoing() { 22 | return true; 23 | } 24 | 25 | /** Kick off the other tasks. 26 | * @return boolean done When this is false, schedule another chunk. 27 | */ 28 | public function doChunk() { 29 | $this->startChunk(); 30 | 31 | $task = new CountUsers(); 32 | $task->init(); 33 | $task->schedule(); 34 | 35 | $task = new GetEditors(); 36 | $task->init(); 37 | $task->schedule(); 38 | 39 | $task = new PopulateMetaIndexRoles(); 40 | $task->init(); 41 | $task->schedule(); 42 | 43 | $this->endChunk(); 44 | 45 | /* done in one chunk */ 46 | 47 | return true; 48 | } 49 | 50 | } 51 | 52 | 53 | -------------------------------------------------------------------------------- /includes/tasks/task.php: -------------------------------------------------------------------------------- 1 | doTaskStep(); 22 | } else { 23 | error_log( 'index_wp_users_for_speed_task: task missing, so cannot run: ' . $taskName ); 24 | } 25 | } catch ( Exception $ex ) { 26 | $taskName = ( $task && $task->taskName ) ? $task->taskName : 'persisted ' . $taskName; 27 | error_log( 'index_wp_users_for_speed_task: cron hook exception: ' . $taskName . ' ' . $ex->getMessage() . ' ' . $ex->getTraceAsString() ); 28 | } 29 | } 30 | 31 | add_action( 'index_wp_users_for_speed_task', __NAMESPACE__ . '\index_wp_users_for_speed_do_task', 10, 2 ); 32 | add_action( 'index_wp_users_for_speed_repeating_task', __NAMESPACE__ . '\index_wp_users_for_speed_do_task', 10, 2 ); 33 | 34 | abstract class Task { 35 | public $taskName; 36 | public $lastTouch; 37 | public $fractionComplete = 0; 38 | public $useCount = 0; 39 | public $hookName = INDEX_WP_USERS_FOR_SPEED_HOOKNAME; 40 | public $siteId; 41 | public $timeout; 42 | 43 | /** create a task 44 | * 45 | * @param int|null $siteId Site id for the task 46 | * @param int $timeout Runtime limit in seconds. Default = no limit. 47 | */ 48 | public function __construct( $siteId = null, $timeout = 0 ) { 49 | $this->lastTouch = time(); 50 | $siteId = $siteId === null ? get_current_blog_id() : $siteId; 51 | $this->siteId = $siteId; 52 | $this->timeout = $timeout; 53 | $reflect = new ReflectionClass( $this ); 54 | $this->taskName = $reflect->getShortName() . '_' . $siteId; 55 | } 56 | 57 | public static function restorePersisted( $taskName ) { 58 | $option = INDEX_WP_USERS_FOR_SPEED_PREFIX_TASK . $taskName; 59 | 60 | return get_option( $option ); 61 | } 62 | 63 | public function init() { 64 | 65 | } 66 | 67 | public function doTaskStep() { 68 | $done = $this->doChunk(); 69 | if ( ! $done ) { 70 | $this->schedule(); 71 | } else { 72 | $this->fractionComplete = 1; 73 | $this->setStatus( null, true, false, 1 ); 74 | $this->clearPersisted(); 75 | } 76 | } 77 | 78 | abstract public function doChunk(); 79 | 80 | public function schedule( $time = 0, $frequency = false ) { 81 | $cronArg = $this->persist(); 82 | if ( $frequency === false ) { 83 | $time = $time ?: time(); 84 | wp_schedule_single_event( $time, $this->hookName, [ $cronArg, $this->useCount ] ); 85 | } else { 86 | wp_schedule_event( $time, $frequency, $this->hookName, [ $cronArg, $this->useCount ] ); 87 | } 88 | $msg = ( $frequency ?: 'one-off' ) . ' scheduled'; 89 | } 90 | 91 | protected function persist() { 92 | $jobName = self::toSnake( $this->taskName ); 93 | $option = INDEX_WP_USERS_FOR_SPEED_PREFIX_TASK . $jobName; 94 | update_option( $option, $this, false ); 95 | 96 | return $jobName; 97 | } 98 | 99 | /** Convert to snake case. 100 | * 101 | * @param string $symbol For example, FooBar is converted to -foo-bar 102 | * @param string $delim Optional. Delimiter like - or _. Default is - 103 | * 104 | * @return string 105 | */ 106 | public static function toSnake( 107 | $symbol, $delim = '-' 108 | ) { 109 | $res = []; 110 | $len = strlen( $symbol ); 111 | for ( $i = 0; $i < $len; $i ++ ) { 112 | $c = $symbol[ $i ]; 113 | if ( ctype_upper( $c ) ) { 114 | $res[] = $delim; 115 | $res[] = strtolower( $c ); 116 | } else { 117 | $res[] = $c; 118 | } 119 | } 120 | 121 | return implode( '', $res ); 122 | } 123 | 124 | protected function generateCallTrace() { 125 | $e = new Exception(); 126 | $trace = explode( "\n", $e->getTraceAsString() ); 127 | // reverse array to make steps line up chronologically 128 | $trace = array_reverse( $trace ); 129 | array_shift( $trace ); // remove {main} 130 | array_pop( $trace ); // remove call to this method 131 | $result = []; 132 | 133 | foreach ( $trace as $i => $iValue ) { 134 | $result[] = ( $i + 1 ) . ')' . substr( $iValue, strpos( $iValue, ' ' ) ); // replace '#someNum' with '$i)', set the right ordering 135 | } 136 | 137 | return "\t" . implode( "\n\t", $result ); 138 | } 139 | 140 | public function log( 141 | $msg, $time = 0 142 | ) { 143 | $words = []; 144 | $words[] = 'Task'; 145 | $words[] = $this->taskName; 146 | $words[] = '(' . $this->siteId . ')'; 147 | $words[] = '#' . $this->useCount; 148 | $words[] = is_string( $msg ) ? $msg : serialize( $msg ); 149 | if ( $time ) { 150 | $words[] = 'for time'; 151 | $words[] = date( 'Y-m-d H:i:s', $time ); 152 | } 153 | $words [] = $this->generateCallTrace(); 154 | $msg = implode( ' ', $words ); 155 | Indexer::writeLog( $msg ); 156 | } 157 | 158 | /** Update a task's status option.. 159 | * 160 | * @param null|array $status If the status is known give it here, otherwise give null 161 | * @param null|bool $available Set the available flag, or ignore it if null 162 | * @param null|bool $active Set the active flag, or ignore it if null 163 | * @param null|float $fraction Set the fraction-complete flag, or ignore it if null. 164 | * 165 | * @return void 166 | */ 167 | public function setStatus( 168 | $status, $available = null, $active = null, $fraction = null 169 | ) { 170 | if ( $status === null ) { 171 | $status = $this->getStatus(); 172 | } 173 | if ( ! isset ( $status ) || $status === false ) { 174 | $status = []; 175 | } 176 | if ( isset( $available ) ) { 177 | $status['available'] = $available; 178 | } 179 | if ( isset( $active ) ) { 180 | $status['active'] = $active; 181 | } 182 | if ( isset( $fraction ) ) { 183 | $status['fraction'] = $fraction; 184 | } 185 | $jobResultName = INDEX_WP_USERS_FOR_SPEED_PREFIX_TASK . 'result' . self::toSnake( $this->taskName ); 186 | update_option( $jobResultName, $status, false ); 187 | } 188 | 189 | public function getStatus() { 190 | $jobResultName = INDEX_WP_USERS_FOR_SPEED_PREFIX_TASK . 'result' . self::toSnake( $this->taskName ); 191 | 192 | return get_option( $jobResultName ); 193 | } 194 | 195 | protected function clearPersisted() { 196 | $jobStatusName = self::toSnake( $this->taskName ); 197 | $option = INDEX_WP_USERS_FOR_SPEED_PREFIX_TASK . $jobStatusName; 198 | delete_option( $option ); 199 | } 200 | 201 | public function cancel() { 202 | wp_unschedule_hook( $this->hookName ); 203 | } 204 | 205 | public function maybeSchedule( 206 | $status = null 207 | ) { 208 | if ( ! isset( $status ) || $status === false ) { 209 | $status = $this->getStatus(); 210 | } 211 | if ( ! $this->isActive( $status ) ) { 212 | $this->schedule(); 213 | } 214 | } 215 | 216 | public function needsRunning( 217 | $status = null 218 | ) { 219 | if ( ! isset( $status ) || $status === false ) { 220 | $status = $this->getStatus(); 221 | } 222 | if ( $this->isMissing( $status ) ) { 223 | return true; 224 | } 225 | if ( $this->isAvailable( $status ) ) { 226 | return false; 227 | } 228 | 229 | return true; 230 | } 231 | 232 | /** Is a task active. 233 | * 234 | * This is true when a task is running, either 235 | * for the first time or in a way that updates it. 236 | * running in a way that updates it. 237 | * 238 | * Don't start the task again if this is true. 239 | * 240 | * @param array $status optional status. 241 | * 242 | * @return bool ready to use. 243 | */ 244 | public function isActive( 245 | $status = null 246 | ) { 247 | $status = $status === null ? $this->getStatus() : $status; 248 | 249 | return is_array( $status ) && isset( $status['active'] ) && $status['active']; 250 | } 251 | 252 | public function clearStatus() { 253 | $jobResultName = INDEX_WP_USERS_FOR_SPEED_PREFIX_TASK . 'result' . self::toSnake( $this->taskName ); 254 | delete_option( $jobResultName ); 255 | } 256 | 257 | /** Is a task's output completely missing. 258 | * 259 | * @param array $status optional status 260 | * 261 | * @return bool true means it's missing. 262 | */ 263 | public function isMissing( 264 | $status = null 265 | ) { 266 | $status = $status === null ? $this->getStatus() : $status; 267 | 268 | return $status === false; 269 | } 270 | 271 | /** Is a task's output ready to use. 272 | * 273 | * This can be true if the task has been run, and if it is actively 274 | * running in a way that updates it. 275 | * 276 | * @param array $status optional status. 277 | * 278 | * @return bool ready to use. 279 | */ 280 | public function isAvailable( 281 | $status = null 282 | ) { 283 | $status = $status === null ? $this->getStatus() : $status; 284 | 285 | return is_array( $status ) && isset( $status['available'] ) && $status['available']; 286 | } 287 | 288 | public function fractionComplete( 289 | $status = null 290 | ) { 291 | $status = $status === null ? $this->getStatus() : $status; 292 | return is_array( $status ) && isset( $status['fraction'] ) && is_numeric( $status['fraction'] ) ? $status['fraction'] : 1.0; 293 | } 294 | 295 | protected function startChunk() { 296 | if ( $this->useCount === 0 ) { 297 | $this->setStatus( null, null, true, 0.001 ); 298 | } 299 | set_time_limit( $this->timeout ); 300 | $this->lastTouch = time(); 301 | $this->setBlog(); 302 | } 303 | 304 | protected function endChunk() { 305 | $this->restoreBlog(); 306 | $this->useCount ++; 307 | } 308 | 309 | public function setBlog() { 310 | if ( is_multisite() ) { 311 | switch_to_blog( $this->siteId ); 312 | } 313 | } 314 | 315 | public function restoreBlog() { 316 | if ( is_multisite() ) { 317 | restore_current_blog(); 318 | } 319 | } 320 | 321 | /** Do $wpdb->query, retrying if a deadlock is found. 322 | * 323 | * @param string $query The duly prepared query 324 | * @param int $retries The maximum number of times to retry before giving up, default 5. 325 | * @param float $delay The time, in seconds, to delay after a deadlock failure, default 0.1. 326 | * 327 | * @return bool|int|mixed|\mysqli_result|resource|null 328 | */ 329 | public function doQuery( $query, $retries = 5, $delay = 0.1 ) { 330 | global $wpdb; 331 | $result = 0; 332 | 333 | /* deal with potential deadlocks deactivating */ 334 | $retry = $retries; 335 | while ( $retry > 0 ) { 336 | $success = true; 337 | $result = $wpdb->query( $query ); 338 | if ( $result === false ) { 339 | $err = $wpdb->error; 340 | $message = ''; 341 | $message = is_string( $err ) ? $err : $message; 342 | $message = is_wp_error( $err ) ? $err->get_error_message() : $message; 343 | if ( false === stripos( $message, 'Deadlock found' ) ) { 344 | $success = false; 345 | } 346 | } 347 | if ( $success ) { 348 | break; 349 | } 350 | usleep( $delay * 1000000 ); 351 | $retry --; 352 | if ( $retry <= 0 ) { 353 | error_log( "Deadlock after $retries retries: $query" ); 354 | } 355 | } 356 | return $result; 357 | } 358 | 359 | } 360 | -------------------------------------------------------------------------------- /includes/wordpress-hooks.php: -------------------------------------------------------------------------------- 1 | actionPrefix = $actionPrefix; 24 | $this->filterPrefix = $filterPrefix; 25 | $this->priority = $priority; 26 | 27 | $this->register(); 28 | } 29 | 30 | protected function register() { 31 | $reflector = new ReflectionClass ( $this ); 32 | $this->methods = $reflector->getMethods( ReflectionMethod::IS_PUBLIC ); 33 | 34 | foreach ( $this->methods as $method ) { 35 | $methodName = $method->name; 36 | $argCount = $method->getNumberOfParameters(); 37 | 38 | $splits = explode( '__', $methodName ); 39 | if ( count( $splits ) >= 2 && count( $splits ) <= 3 ) { 40 | /* a possible priority. */ 41 | $priority = $this->priority; 42 | if ( count( $splits ) === 3 ) { 43 | if ( is_numeric( $splits[2] ) ) { 44 | $priority = 0 + $splits[2]; 45 | } else if ( $splits[2] === 'first' ) { 46 | $priority = - 10; 47 | } else if ( $splits[2] === 'last' ) { 48 | $priority = 200; 49 | } 50 | } 51 | if ( $splits[0] === $this->actionPrefix ) { 52 | add_action( $splits[1], [ $this, $methodName ], $priority, $argCount ); 53 | } else if ( $splits[0] === $this->filterPrefix ) { 54 | add_filter( $splits[1], [ $this, $methodName ], $priority, $argCount ); 55 | } 56 | } 57 | } 58 | } 59 | 60 | protected function unregister() { 61 | 62 | foreach ( $this->methods as $method ) { 63 | $methodName = $method->name; 64 | 65 | $splits = explode( '__', $methodName ); 66 | if ( count( $splits ) >= 2 && count( $splits ) <= 3 ) { 67 | if ( $splits[0] === $this->actionPrefix ) { 68 | remove_action( $splits[1], [ $this, $methodName ] ); 69 | } else if ( $splits[0] === $this->filterPrefix ) { 70 | remove_filter( $splits[1], [ $this, $methodName ] ); 71 | } 72 | } 73 | } 74 | } 75 | } -------------------------------------------------------------------------------- /index-wp-users-for-speed.php: -------------------------------------------------------------------------------- 1 | run(); 96 | -------------------------------------------------------------------------------- /index.php: -------------------------------------------------------------------------------- 1 | \n" 8 | "Language-Team: LANGUAGE \n" 9 | "MIME-Version: 1.0\n" 10 | "Content-Type: text/plain; charset=UTF-8\n" 11 | "Content-Transfer-Encoding: 8bit\n" 12 | "POT-Creation-Date: 2024-11-07T11:26:27+00:00\n" 13 | "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" 14 | "X-Generator: WP-CLI 2.9.0\n" 15 | "X-Domain: index-wp-users-for-speed\n" 16 | 17 | #. Plugin Name of the plugin 18 | #: admin/admin.php:52 19 | msgid "Index WP Users For Speed" 20 | msgstr "" 21 | 22 | #. Plugin URI of the plugin 23 | msgid "https://plumislandmedia.org/index-wp-users-for-speed/" 24 | msgstr "" 25 | 26 | #. Description of the plugin 27 | msgid "Speed up your WordPress site with thousands of users." 28 | msgstr "" 29 | 30 | #. Author of the plugin 31 | msgid "Oliver Jones" 32 | msgstr "" 33 | 34 | #. Author URI of the plugin 35 | msgid "https://github.com/OllieJones" 36 | msgstr "" 37 | 38 | #: admin/admin.php:53 39 | msgid "Index For Speed" 40 | msgstr "" 41 | 42 | #: admin/admin.php:66 43 | msgid "Rebuilding user indexes" 44 | msgstr "" 45 | 46 | #: admin/admin.php:71 47 | msgid "Rebuild indexes" 48 | msgstr "" 49 | 50 | #: admin/admin.php:78 51 | msgid "...at this time" 52 | msgstr "" 53 | 54 | #: admin/admin.php:84 55 | msgid "Choosing authors when editing posts and pages" 56 | msgstr "" 57 | 58 | #: admin/admin.php:89 59 | msgid "Use selection boxes" 60 | msgstr "" 61 | 62 | #: admin/admin.php:129 63 | msgid "Incorrect time." 64 | msgstr "" 65 | 66 | #: admin/admin.php:138 67 | msgid "User index rebuilding starting" 68 | msgstr "" 69 | 70 | #. translators: 1: localized time like 1:22 PM or 13:22 71 | #: admin/admin.php:148 72 | msgid "Automatic index rebuilding scheduled for %1$s each day" 73 | msgstr "" 74 | 75 | #: admin/admin.php:156 76 | msgid "Automatic index rebuilding disabled" 77 | msgstr "" 78 | 79 | #: admin/admin.php:225 80 | msgid "You may rebuild your user indexes each day, or immediately." 81 | msgstr "" 82 | 83 | #: admin/admin.php:226 84 | msgid "(It is possible for them to become out-of-date.)" 85 | msgstr "" 86 | 87 | #: admin/admin.php:242 88 | msgid "daily" 89 | msgstr "" 90 | 91 | #: admin/admin.php:250 92 | msgid "never" 93 | msgstr "" 94 | 95 | #: admin/admin.php:259 96 | msgid "immediately, then daily" 97 | msgstr "" 98 | 99 | #: admin/admin.php:268 100 | msgid "immediately, but not daily" 101 | msgstr "" 102 | 103 | #: admin/admin.php:286 104 | msgid "Avoid rebuilding exactly on the hour to avoid contending with other processing jobs." 105 | msgstr "" 106 | 107 | #: admin/admin.php:316 108 | msgid "" 109 | "Author-choice dropdown menus can be unwieldy when your site has many authors. If you have more than this number of authors, you will see selection boxes instead of dropdown menus. Choose an author by typing a few characters of the name into the selection box.\n" 110 | " \n" 111 | " " 112 | msgstr "" 113 | 114 | #: admin/admin.php:330 115 | msgid "when you have more than" 116 | msgstr "" 117 | 118 | #: admin/admin.php:337 119 | msgid "authors registered on your site." 120 | msgstr "" 121 | 122 | #: admin/progressbar.php:51 123 | #: admin/progressbar.php:57 124 | msgid "% complete." 125 | msgstr "" 126 | 127 | #: admin/progressbar.php:53 128 | msgid "Background user index refresh in progress:" 129 | msgstr "" 130 | 131 | #: admin/progressbar.php:54 132 | msgid "You may use your site normally during index refreshing." 133 | msgstr "" 134 | 135 | #: admin/progressbar.php:56 136 | msgid "Background user index building in progress:" 137 | msgstr "" 138 | 139 | #: admin/progressbar.php:58 140 | msgid "You may use your site normally during index building." 141 | msgstr "" 142 | 143 | #: admin/views/page.php:21 144 | msgid "click here" 145 | msgstr "" 146 | 147 | #. translators: 1: embeds "For help please ..." 2: hyperlink to review page on wp.org 148 | #: admin/views/page.php:25 149 | msgid "For support please %1$s. Please %2$s to rate this plugin. Your feedback helps make it better, faster, and more useful." 150 | msgstr "" 151 | 152 | #: admin/views/page.php:36 153 | msgid "Approximate number of users on this entire site" 154 | msgstr "" 155 | 156 | #: includes/indexer.php:178 157 | msgid "Still counting users..." 158 | msgstr "" 159 | 160 | #: includes/selection-box.php:138 161 | msgid "Type the author's name" 162 | msgstr "" 163 | -------------------------------------------------------------------------------- /notes.MD: -------------------------------------------------------------------------------- 1 | wp_usermeta.meta_key = 'wp_capabilities' 2 | --- 3 | 4 | | COUNT(*) | meta_value | 5 | | ---: | --- | 6 | | 167 | a:1:{s:10:"subscriber";b:1;} | 7 | | 71 | a:1:{s:6:"editor";b:1;} | 8 | | 65 | a:1:{s:6:"author";b:1;} | 9 | | 36 | a:1:{s:11:"contributor";b:1;} | 10 | | 34 | a:1:{s:15:"css_js_designer";b:1;} | 11 | | 29 | a:1:{s:10:"translator";b:1;} | 12 | | 1 | a:2:{s:13:"administrator";b:1;s:14:"backwpup_admin";b:1;} | 13 | 14 | ## Hooks.... 15 | 16 | ```php 17 | 18 | /** 19 | * Fires immediately before a user is deleted from the database. 20 | * 21 | * @since 2.0.0 22 | * @since 5.5.0 Added the `$user` parameter. 23 | * 24 | * @param int $id ID of the user to delete. 25 | * @param int|null $reassign ID of the user to reassign posts and links to. 26 | * Default null, for no reassignment. 27 | * @param WP_User $user WP_User object of the user to delete. 28 | */ 29 | do_action( 'delete_user', $id, $reassign, $user ); 30 | 31 | 32 | /** 33 | * Fires immediately after a user is deleted from the database. 34 | * 35 | * @since 2.9.0 36 | * @since 5.5.0 Added the `$user` parameter. 37 | * 38 | * @param int $id ID of the deleted user. 39 | * @param int|null $reassign ID of the user to reassign posts and links to. 40 | * Default null, for no reassignment. 41 | * @param WP_User $user WP_User object of the deleted user. 42 | */ 43 | do_action( 'deleted_user', $id, $reassign, $user ); 44 | 45 | return apply_filters_ref_array( 'get_meta_sql', array( $sql, $this->queries, $type, $primary_table, $primary_id_column, $context ) ); 46 | $search_columns = apply_filters( 'user_search_columns', $search_columns, $search, $this ); 47 | 48 | /** 49 | * Fires after the WP_User_Query has been parsed, and before 50 | * the query is executed. 51 | * 52 | * The passed WP_User_Query object contains SQL parts formed 53 | * from parsing the given query. 54 | * 55 | * @since 3.1.0 56 | * 57 | * @param WP_User_Query $query Current instance of WP_User_Query (passed by reference). 58 | */ 59 | do_action_ref_array( 'pre_user_query', array( &$this ) ); 60 | 61 | 62 | /** 63 | * Filters the users array before the query takes place. 64 | * 65 | * Return a non-null value to bypass WordPress' default user queries. 66 | * 67 | * Filtering functions that require pagination information are encouraged to set 68 | * the `total_users` property of the WP_User_Query object, passed to the filter 69 | * by reference. If WP_User_Query does not perform a database query, it will not 70 | * have enough information to generate these values itself. 71 | * 72 | * @since 5.1.0 73 | * 74 | * @param array|null $results Return an array of user data to short-circuit WP's user query 75 | * or null to allow WP to run its normal queries. 76 | * @param WP_User_Query $query The WP_User_Query instance (passed by reference). 77 | */ 78 | $this->results = apply_filters_ref_array( 'users_pre_query', array( null, &$this ) ); 79 | 80 | 81 | /** 82 | * Filters SELECT FOUND_ROWS() query for the current WP_User_Query instance. 83 | * 84 | * @since 3.2.0 85 | * @since 5.1.0 Added the `$this` parameter. 86 | * 87 | * @global wpdb $wpdb WordPress database abstraction object. 88 | * 89 | * @param string $sql The SELECT FOUND_ROWS() query for the current WP_User_Query. 90 | * @param WP_User_Query $query The current WP_User_Query instance. 91 | */ 92 | $found_users_query = apply_filters( 'found_users_query', 'SELECT FOUND_ROWS()', $this ); 93 | 94 | /** 95 | * Short-circuits updating the metadata cache of a specific type. 96 | * 97 | * The dynamic portion of the hook name, `$meta_type`, refers to the meta object type 98 | * (post, comment, term, user, or any other type with an associated meta table). 99 | * Returning a non-null value will effectively short-circuit the function. 100 | * 101 | * Possible hook names include: 102 | * 103 | * - `update_post_metadata_cache` 104 | * - `update_comment_metadata_cache` 105 | * - `update_term_metadata_cache` 106 | * - `update_user_metadata_cache` 107 | * 108 | * @since 5.0.0 109 | * 110 | * @param mixed $check Whether to allow updating the meta cache of the given type. 111 | * @param int[] $object_ids Array of object IDs to update the meta cache for. 112 | */ 113 | 114 | /** 115 | * Filters the query arguments for the list of users in the dropdown. 116 | * 117 | * @since 4.4.0 118 | * 119 | * @param array $query_args The query arguments for get_users(). 120 | * @param array $parsed_args The arguments passed to wp_dropdown_users() combined with the defaults. 121 | */ 122 | $query_args = apply_filters( 'wp_dropdown_users_args', $query_args, $parsed_args ); 123 | 124 | /** 125 | * Filters the arguments used to generate the Quick Edit authors drop-down. 126 | * 127 | * @since 5.6.0 128 | * 129 | * @see wp_dropdown_users() 130 | * 131 | * @param array $users_opt An array of arguments passed to wp_dropdown_users(). 132 | * @param bool $bulk A flag to denote if it's a bulk action. 133 | */ 134 | $users_opt = apply_filters( 'quick_edit_dropdown_authors_args', $users_opt, $bulk ); 135 | 136 | /** 137 | * Filters WP_User_Query arguments when querying users via the REST API. 138 | * 139 | * @link https://developer.wordpress.org/reference/classes/wp_user_query/ 140 | * 141 | * @since 4.7.0 142 | * 143 | * @param array $prepared_args Array of arguments for WP_User_Query. 144 | * @param WP_REST_Request $request The REST API request. 145 | */ 146 | $prepared_args = apply_filters( 'rest_user_query', $prepared_args, $request ); 147 | 148 | /** 149 | * Fires before the WP_User_Query has been parsed. 150 | * 151 | * The passed WP_User_Query object contains the query variables, 152 | * not yet passed into SQL. 153 | * 154 | * @since 4.0.0 155 | * 156 | * @param WP_User_Query $query Current instance of WP_User_Query (passed by reference). 157 | */ 158 | do_action_ref_array( 'pre_get_users', array( &$this ) ); 159 | 160 | /** 161 | * Fires after the WP_User_Query has been parsed, and before 162 | * the query is executed. 163 | * 164 | * The passed WP_User_Query object contains SQL parts formed 165 | * from parsing the given query. 166 | * 167 | * @since 3.1.0 168 | * 169 | * @param WP_User_Query $query Current instance of WP_User_Query (passed by reference). 170 | */ 171 | do_action_ref_array( 'pre_user_query', array( &$this ) ); 172 | 173 | 174 | /** 175 | * Filters the users array before the query takes place. 176 | * 177 | * Return a non-null value to bypass WordPress' default user queries. 178 | * 179 | * Filtering functions that require pagination information are encouraged to set 180 | * the `total_users` property of the WP_User_Query object, passed to the filter 181 | * by reference. If WP_User_Query does not perform a database query, it will not 182 | * have enough information to generate these values itself. 183 | * 184 | * @since 5.1.0 185 | * 186 | * @param array|null $results Return an array of user data to short-circuit WP's user query 187 | * or null to allow WP to run its normal queries. 188 | * @param WP_User_Query $query The WP_User_Query instance (passed by reference). 189 | */ 190 | $this->results = apply_filters_ref_array( 'users_pre_query', array( null, &$this ) ); 191 | 192 | /** 193 | * Filters SELECT FOUND_ROWS() query for the current WP_User_Query instance. 194 | * BUT! only works if the query contained the SELECT_FOUND_ROWS modifier 195 | * so the performance hit is already taken. 196 | * 197 | * @since 3.2.0 198 | * @since 5.1.0 Added the `$this` parameter. 199 | * 200 | * @global wpdb $wpdb WordPress database abstraction object. 201 | * 202 | * @param string $sql The SELECT FOUND_ROWS() query for the current WP_User_Query. 203 | * @param WP_User_Query $query The current WP_User_Query instance. 204 | */ 205 | $found_users_query = apply_filters( 'found_users_query', 'SELECT FOUND_ROWS()', $this ); 206 | /** 207 | * Fires immediately after a user is deleted via the REST API. 208 | * 209 | * @since 4.7.0 210 | * 211 | * @param WP_User $user The user data. 212 | * @param WP_REST_Response $response The response returned from the API. 213 | * @param WP_REST_Request $request The request sent to the API. 214 | */ 215 | do_action( 'rest_delete_user', $user, $response, $request ); 216 | 217 | /** 218 | * Fires immediately after a user is created or updated via the REST API. 219 | * 220 | * @since 4.7.0 221 | * 222 | * @param WP_User $user Inserted or updated user object. 223 | * @param WP_REST_Request $request Request object. 224 | * @param bool $creating True when creating a user, false when updating. 225 | */ 226 | do_action( 'rest_insert_user', $user, $request, true ); 227 | 228 | /** 229 | * Fires after a user is completely created or updated via the REST API. 230 | * 231 | * @since 5.0.0 232 | * 233 | * @param WP_User $user Inserted or updated user object. 234 | * @param WP_REST_Request $request Request object. 235 | * @param bool $creating True when creating a user, false when updating. 236 | */ 237 | do_action( 'rest_after_insert_user', $user, $request, true ); 238 | 239 | 240 | 241 | ``` 242 | 243 | ## Gets users for Gutenberg editor. 244 | 245 | http://ubu2010.plumislandmedia.local/wp-json/wp/v2/users?context=view&who=authors&per_page=50&_fields=id,name&_locale=user 246 | 247 | Here's a query to populate the easily readable caps meta 248 | 249 | ```sql 250 | # noinspection SqlNoDataSourceInspectionForFile 251 | # noinspection SqlResolve 252 | -- INSERT INTO wp_usermeta (user_id, meta_key) 253 | SELECT a.user_id, meta_key 254 | FROM ( 255 | SELECT user_id, 'wp_caps_edit_posts' meta_key 256 | FROM wp_usermeta 257 | WHERE wp_usermeta.meta_value LIKE '%\"edit\\_posts\"%' 258 | UNION ALL 259 | SELECT user_id, 'wp_caps_administrator' 260 | FROM wp_usermeta 261 | WHERE wp_usermeta.meta_value LIKE '%\"administrator\"%' 262 | UNION ALL 263 | SELECT user_id, 'wp_caps_editor' 264 | FROM wp_usermeta 265 | WHERE wp_usermeta.meta_value LIKE '%\"editor\"%' 266 | UNION ALL 267 | SELECT user_id ID, 'wp_caps_author' 268 | FROM wp_usermeta 269 | WHERE wp_usermeta.meta_value LIKE '%\"author\"%' 270 | UNION ALL 271 | SELECT user_id ID, 'wp_caps_contributor' 272 | FROM wp_usermeta 273 | WHERE wp_usermeta.meta_value LIKE '%\"contributor\"%' 274 | -- UNION ALL 275 | -- SELECT user_id ID, 'wp_caps_subscriber' FROM wp_usermeta WHERE wp_usermeta.meta_value LIKE '%\"subscriber\"%' 276 | ) a 277 | LEFT JOIN ( 278 | SELECT user_id 279 | FROM wp_usermeta 280 | WHERE meta_key IN 281 | ('wp_caps_edit_posts', 'wp_caps_administrator', 'wp_caps_editor', 'wp_caps_author', 282 | 'wp_caps_contributor', 'wp_caps_subscriber') 283 | ) b ON a.user_id = b.user_id 284 | WHERE b.user_id IS NULL 285 | -- LIMIT 5 286 | 287 | 288 | ``` 289 | 290 | ## Error message 291 | 292 | Right after adding a user from the entire site. 293 | 294 | (Maybe a problem with users added by WP Demo) 295 | 296 | ``` 297 | Fatal error: Uncaught Error: Cannot create references to/from string offsets 298 | in /var/www/ubu2010.plumislandmedia.local/wp-admin/includes/class-wp-users-list-table.php on line 191 299 | 300 | Call stack: 301 | 302 | WP_Users_List_Table::get_views() 303 | wp-admin/includes/class-wp-list-table.php:398 304 | WP_List_Table::views() 305 | wp-admin/users.php:641 306 | ``` -------------------------------------------------------------------------------- /readme.txt: -------------------------------------------------------------------------------- 1 | === Index WP Users For Speed === 2 | Contributors: OllieJones 3 | Tags: users, database, index, performance, largesite 4 | Requires at least: 5.2 5 | Tested up to: 6.8 6 | Requires PHP: 5.6 7 | Stable tag: 1.1.9 8 | Network: true 9 | License: GPL v2 or later 10 | License URI: https://www.gnu.org/licenses/gpl-2.0.html 11 | Author URI: https://github.com/OllieJones 12 | Plugin URI: https://plumislandmedia.net/index-wp-users-for-speed/ 13 | GitHub Plugin URI: https://github.com/OllieJones/index-wp-users-for-speed 14 | Primary Branch: main 15 | Text Domain: index-wp-users-for-speed 16 | Domain Path: /languages 17 | 18 | Do you have thousands of users on your WordPress site? Look them up fast. Find authors more easily. Speed up your laggy dashboard. 19 | 20 | == Description == 21 | 22 | This plugin speeds up the handling of your WordPress registered users, especially when your site has many thousands of them. (Congratulations! Building a successful site with thousands of users is an accomplishment.) With optimized MySQL / MariaDB database techniques, it finds and displays your users more quickly. Your All Users panel on your dashboard displays faster and searches faster. Your All Posts and All Pages panels no longer lag when displaying. And, you can edit your posts to change authorship more efficiently. 23 | 24 | Without this plugin WordPress sites with many users slow down drastically on Dashboard pages. It can take many seconds each time you display your Users dashboard panel. It takes just about the same large amount of time to display your Posts or Pages panels. While those slow displays are loading, WordPress is hammering on your site's MySQL or MariaDB database server. That means your site serves your visitors slowly too, not just your dashboard users. 25 | 26 | And, versions of WordPress since 6.0.1 have dealt with [this performance problem](https://make.wordpress.org/core/2022/05/02/performance-increase-for-sites-with-large-user-counts-now-also-available-on-single-site/) by preventing changes to the authors of posts and pages in the Gutenberg editor, the classic editor, and the Quick Edit feature. Those recent versions also suppress the user counts shown at the top of the Users panel. This plugin restores those functions. 27 | 28 | This plugin helps speed up the handling of those large numbers of users. It does so by indexing your users by adding metadata that's easily optimized by MySQL or MariaDB. For example, when your site must ask the database for your post-author users, the database no longer needs to examine every user on your system. (In database jargon, it no longer needs to do notoriously slow full table scans to find users.) 29 | 30 | When slow queries are required to make sure the metadata indexes are up to date, this plugin does them in the background so nobody has to wait for them to complete. You can set the plugin to do this background work at a particular time each day. Many people prefer to do them overnight or at some other off-peak time. 31 | 32 |

How can I learn more about making my WordPress site more efficient?

33 | 34 | This is a companion plugin to [Index WP MySQL for Speed](https://wordpress.org/plugins/index-wp-mysql-for-speed/). If that plugin is in use, this plugin will perform better. But they are in no way dependent on one another; you may use either, both, or of course neither. 35 | 36 | I offer several plugins to help with your site's database efficiency. You can [read about them here](https://www.plumislandmedia.net/wordpress/performance/optimizing-wordpress-database-servers/). 37 | 38 | == Frequently Asked Questions == 39 | 40 | = Should I back up my site before using this? = 41 | 42 | **Yes.** Backups are good practice. Still, this plugin makes no changes to your site or database layout. It adds a few non-autoloaded options, and adds rows to wp_usermeta. 43 | 44 | = My WordPress host offers MariaDB, not MySQL. Can I use this plugin? 45 | 46 | **Yes.** 47 | 48 | = I have a multi-site WordPress installation. Can I use this plugin? 49 | 50 | **Yes.** 51 | 52 | = I see high CPU usage (load average) on my MariaDB / MySQL database server during user index building or refresh. Is that normal? 53 | 54 | **Yes.** Indexing your registered users requires us to insert a row in your wp_usermeta tab;e for each of them. We do this work in batches of 5000 users to avoid locking up your MariaDB / MySQL server. Each batch takes server time. Once all index building or refresh batches are complete, your CPU usage will return to normal. 55 | 56 | = Can I use this if I have disabled WP_Cron and use an operating system cronjob instead? 57 | 58 | **Yes** 59 | 60 | = What if I assign multiple roles to some users? = 61 | 62 | Plugins like Vladimir Garagulya's [User Role Editor](https://wordpress.org/plugins/user-role-editor/) let you assign multiple roles to users. This plugin handles those users correctly. 63 | 64 | = How does it work? (Geeky!) = 65 | 66 | Standard WordPress puts a `wp_capabilities` row in the `wp_usermeta` table for each user. Its `meta_value` contains a small data structure. For example, an author has this data structure. 67 | 68 | `array("author")` 69 | 70 | In order to find all the authors WordPress must issue a database query containing a filter like this one, that starts and ends with the SQL wildcard character `%`. 71 | 72 | `meta_key = 'wp_capabilities' AND meta_value LIKE '%"author"%'` 73 | 74 | Filters like that are notoriously slow: they cannot exploit any database keys, and so MySQL or MariaDB must examine that `wp_usermeta` row for every user in your site. 75 | 76 | This plugin adds rows to `wp_usermeta` describing each user's role (or roles) in a way that's easier to search. To find authors, the plugin uses this much faster filter instead. 77 | 78 | `meta_key = 'wp_index_wp_users_for_speed_role_author'` 79 | 80 | It takes a while to insert these extra indexing rows into the database; that happens in the background. 81 | 82 | Once the indexing rows are in place, you can add, delete, or change user roles without regenerating those rows: the plugin maintains them. 83 | 84 | = What is the background for this plugin? = 85 | 86 | WordPress's trac (defect-tracking) system has [this ticket # 38741](https://core.trac.wordpress.org/ticket/38741). 87 | 88 | = Why use this plugin? = 89 | 90 | Three reasons (maybe four): 91 | 92 | 1. to save carbon footprint. 93 | 2. to save carbon footprint. 94 | 3. to save carbon footprint. 95 | 4. to save people time. 96 | 97 | Seriously, the microwatt hours of electricity saved by faster web site technologies add up fast, especially at WordPress's global scale. 98 | 99 | == Installation == 100 | 101 | Install and activate this plugin in the usual way via the Plugins panel in your site's dashboard. Once you have activated it, configure it via the Index for Speed menu item under Users. 102 | 103 | = WP-CLI = 104 | 105 | `wp plugin install index-wp-users-for-speed 106 | wp plugin activate index-wp-users-for-speed 107 | ` 108 | = Composer = 109 | 110 | If you configure your WordPress installation using composer, you may install this plugin into your WordPress top level configuration with this command. 111 | 112 | `composer require "wpackagist-plugin/index-wp-users-for-speed":"^1.1"` 113 | 114 | 115 | = Credits = 116 | * "Crowd", a photo by James Cridland, in the banner and icon. [CC BY 2.0](https://creativecommons.org/licenses/by/2.0/) 117 | * Japreet Sethi for advice, and for testing on his large installation. 118 | * Rick James for everything. 119 | 120 | == Screenshots == 121 | 122 | 1. Access to this plugin's configuration panel. 123 | 2. This plugin's configuration panel. 124 | 3. The bulk editor for All Posts showing the selection box with autocompletion of author name. 125 | 126 | == Changelog == 127 | 128 | = 1.1.9 = 129 | 130 | Fix typo in cron-disabled code path. 131 | 132 | = 1.1.8 = 133 | 134 | Use transactions to hopefully avoid deadlocks. Use options instead of transients. 135 | 136 | = 1.1.7 = 137 | 138 | Display both user display name and login name in dropdowns. 139 | 140 | = 1.1.6 = 141 | 142 | Handles WP_User_Query operations with metadata search correctly. 143 | 144 | = 1.1.5 = 145 | 146 | Repair problem handing user queries with role__not_in and role__in search terms. 147 | 148 | = 1.1.4 = 149 | 150 | * Fix compatibility with WordPress pre 5.9. 151 | * Display more reliable user count on dashboard panel. 152 | 153 | = 1.1.3 = 154 | 155 | * Correct query-optimization problem when rendering autocompletion fields. 156 | * Test and optimize with MariaDB 10.9. 157 | 158 | = 1.1.2 = 159 | 160 | * Correct query-optimization error. 161 | * Update the usermeta table's query-planning statistics after adding user metadata. 162 | 163 | = 1.1.1 = 164 | 165 | * Replace the author dropdown menus in Quick Edit and Bulk Edit with autocompletion fields, to 166 | allow more flexible changes of post and page authors. 167 | * Improve the performance of user lookups. 168 | * Allow multiple roles per user as provided in plugins like User Role Editor. 169 | 170 | = 1.0.4 = 171 | 172 | * Fix bug preventing wp-cli deactivation. Props to [João Faria](https://github.com/jffaria). 173 | 174 | == Upgrade Notice == 175 | 176 | Version 1.1.6 supports metadata user queries. 177 | 178 | Thanks to my loyal users who have reported problems. 179 | -------------------------------------------------------------------------------- /uninstall.php: -------------------------------------------------------------------------------- 1 |